From f94cf2c50005439a681d383b1716803801fbef05 Mon Sep 17 00:00:00 2001 From: lxp <2770281812@qq.com> Date: Fri, 11 Sep 2026 10:07:45 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 1 + .../gui_control/config/constants.py | 18 +- src/gui_control/test/test_o12_config.py | 30 + .../CALIBRATION_CODE_REVIEW_20260910.md | 169 + .../CALIBRATION_FLOW.md | 238 - src/linkerhand_calibration/README.md | 1515 +- .../config/calibration.yaml | 7 +- .../config/g20_right_product.yaml | 7 +- .../config/l6_right_product.yaml | 6 +- .../config/l6_three_camera_calibration.yaml | 1 - .../config/o12_right_product.yaml | 8 +- .../config/o12_three_camera_calibration.yaml | 1 - .../config/o6_right_product.yaml | 6 +- .../config/o6_three_camera_calibration.yaml | 1 - .../config/profiles/g20_right_19.yaml | 1504 ++ .../config/profiles/l6_right_8.yaml | 372 + .../config/profiles/o12_right_16.yaml | 926 ++ .../config/profiles/o6_right_8.yaml | 368 + .../config/schemas/hand_profile.schema.yaml | 44 + .../config/schemas/product.schema.yaml | 14 + .../config/three_camera_calibration.yaml | 3 +- .../launch/front_cmc_pitch_zero.launch.py | 272 - .../front_cmc_roll_calibration.launch.py | 276 - .../launch/front_thumb_calibration.launch.py | 391 - .../launch/three_camera_calibration.launch.py | 242 +- .../launch/unified_calibration.launch.py | 18 + .../calibrated_joint_state_bridge.py | 32 +- .../compat/legacy_diagnostic_tools/README.md | 14 + .../legacy_diagnostic_tools/__init__.py | 1 + .../models/__init__.py | 4 +- .../models/g20/__init__.py | 15 + .../models/g20/_adapter.py | 44 +- .../models/g20/artifacts.py | 3 +- .../models/g20/command_layout.py | 0 .../models/g20/golden_regression.py | 2 +- .../models/g20/legacy_11.py | 10 +- .../models/g20/motion.py | 2 +- .../models/g20/offline_replay.py | 20 +- .../models/g20/profile.py | 69 +- .../models/g20/publication.py | 245 +- .../models/g20/reporting_zh.py | 0 .../models/g20/right_19.py | 12 +- .../models/g20/runner.py | 10 + .../models/g20/urdf_input.py | 0 .../models/g20/zero_policy.py | 4 +- .../models/g20/zero_solver.py | 931 ++ .../models/l6/__init__.py | 14 + .../models/l6/artifacts.py | 40 +- .../models/l6/fitting.py | 33 + .../models/l6/left_transfer.py | 14 +- .../models/l6/motion.py | 18 + .../models/l6/profile.py | 78 + .../models/l6/runner.py | 7 + .../models/l6/urdf.py | 40 +- .../models/o12/__init__.py | 11 + .../models/o12/artifacts.py | 238 +- .../models/o12/fitting.py | 38 + .../models/o12/health.py | 0 .../models/o12/kinematics.py | 0 .../models/o12/motion.py | 64 + .../models/o12/observations.py | 17 + .../models/o12/pipeline.py | 31 + .../models/o12/pnp.py | 2 +- .../models/o12/profile.py | 285 + .../models/o12/quality.py | 75 + .../models/o12/runner.py | 5 + .../models/o12/urdf.py | 94 + .../models/o12/zero.py | 70 +- .../models/o6/__init__.py | 14 + .../models/o6/artifacts.py | 33 +- .../models/o6/fitting.py | 25 + .../models/o6/left_transfer.py | 14 +- .../models/o6/profile.py | 86 + .../models/o6/runner.py | 4 + .../models/o6/urdf.py | 14 +- .../models/registry.py | 14 +- .../models/runtime_schema.py | 6 +- .../legacy_diagnostic_tools/progress.py | 251 + .../legacy_diagnostic_tools/runner_helpers.py | 283 + .../linkerhand_calibration/core/__init__.py | 16 +- .../core/artifacts/__init__.py | 6 +- .../core/artifacts/release.py | 27 - .../core/domain/__init__.py | 17 +- .../core/domain/measurement.py | 72 + .../core/domain/profile.py | 137 +- .../core/domain/result.py | 97 + .../core/domain/sample.py | 39 - .../core/domain/status.py | 82 + .../core/fitting/__init__.py | 16 +- .../core/fitting/circle_geometry.py | 144 + .../core/fitting/command_mapping.py | 98 + .../core/fitting/coupling.py | 347 + .../core/fitting/image_curve.py | 74 + .../core/fitting/motion_fit.py | 121 + .../core/fitting/observed_motion.py | 151 + .../core/fitting/rotation_curve.py | 159 + .../core/fitting/session.py | 155 + .../core/fitting/spatial.py | 47 + .../core/fitting/spatial_solver/__init__.py | 1 + .../core/fitting/spatial_solver/acceptance.py | 310 + .../core/fitting/spatial_solver/axis_lines.py | 603 + .../spatial_solver/axis_observations.py | 563 + .../core/fitting/spatial_solver/base_pose.py | 440 + .../spatial_solver/geometry_helpers.py | 216 + .../fitting/spatial_solver/optimization.py | 124 + .../fitting/spatial_solver/preparation.py | 292 + .../core/fitting/spatial_solver/residuals.py | 335 + .../fitting/spatial_solver/rotation_curves.py | 423 + .../core/fitting/spatial_solver/solve.py | 103 + .../core/fitting/spatial_solver/stages.py | 121 + .../core/fitting/spatial_solver/statistics.py | 249 + .../core/fitting/spatial_solver/types.py | 175 + .../core/fitting/spatial_solver/validation.py | 403 + .../core/fitting/tag_installation.py | 158 + .../core/fitting/trajectory_geometry.py | 356 + .../geometry/candidate_selection.py} | 51 +- .../core/geometry/pnp.py | 1769 +- .../core/geometry/tag_pose/__init__.py | 1 + .../core/geometry/tag_pose/ippe.py | 137 + .../core/geometry/tag_pose/parameters.py | 21 + .../core/geometry/tag_pose/relative.py | 37 + .../core/geometry/tag_pose/rigid_group.py | 880 + .../core/geometry/tag_pose/tracking.py | 324 + .../core/geometry/tag_pose/trajectory.py | 424 + .../core/geometry/tag_pose/types.py | 14 + .../core/solver/__init__.py | 15 - .../core/solver/interfaces.py | 42 - .../core/urdf/__init__.py | 19 +- .../core/urdf/acceptance.py | 312 + .../core/urdf/kinematics.py | 200 + .../linkerhand_calibration/core/urdf/patch.py | 124 +- .../linkerhand_calibration/core/urdf/plan.py | 147 +- .../core/urdf/result_plan.py | 57 + .../core/urdf/tag_acceptance.py | 85 + .../core/urdf/validate.py | 183 + .../linkerhand_calibration/full_hand.py | 2 +- .../models/g20/__init__.py | 15 - .../linkerhand_calibration/models/g20/node.py | 13395 ---------------- .../models/g20/runner.py | 736 - .../models/g20/zero_solver.py | 4606 ------ .../models/l6/__init__.py | 14 - .../models/l6/fitting.py | 741 - .../models/l6/motion.py | 82 - .../linkerhand_calibration/models/l6/node.py | 1779 -- .../models/l6/pipeline.py | 295 - .../models/l6/profile.py | 391 - .../models/l6/runner.py | 716 - .../models/o12/__init__.py | 11 - .../models/o12/fitting.py | 688 - .../models/o12/motion.py | 133 - .../linkerhand_calibration/models/o12/node.py | 1758 -- .../models/o12/pipeline.py | 336 - .../models/o12/profile.py | 517 - .../models/o12/quality.py | 115 - .../models/o12/resume.py | 384 - .../models/o12/runner.py | 576 - .../linkerhand_calibration/models/o12/urdf.py | 351 - .../models/o6/__init__.py | 14 - .../models/o6/fitting.py | 334 - .../linkerhand_calibration/models/o6/node.py | 38 - .../models/o6/pipeline.py | 189 - .../models/o6/profile.py | 321 - .../models/o6/runner.py | 225 - .../linkerhand_calibration/node.py | 2891 ---- .../linkerhand_calibration/offline_replay.py | 2 +- .../linkerhand_calibration/product.py | 77 +- .../profiles/__init__.py | 12 + .../linkerhand_calibration/profiles/export.py | 74 + .../linkerhand_calibration/profiles/loader.py | 329 + .../profiles/observations.py | 40 + .../profiles/validator.py | 55 + .../linkerhand_calibration/publication.py | 2 +- .../runtime/__init__.py | 41 +- .../runtime/acquisition.py | 123 + .../runtime/adapters/__init__.py | 10 +- .../runtime/adapters/base.py | 29 +- .../runtime/adapters/legacy_byte_sdk.py | 51 + .../runtime/adapters/o12_bridge.py | 118 + .../runtime/adapters/o12_hcan_sdk.py | 90 + .../runtime/adapters/ros_binding.py | 67 + .../runtime/adapters/ros_topics.py | 20 + .../runtime/adapters/vendor_package.py | 36 + .../runtime/artifacts/__init__.py | 5 + .../runtime/artifacts/completion.py | 46 + .../runtime/artifacts/controller.py | 76 + .../runtime/artifacts/evidence.py | 150 + .../runtime/artifacts/finalization.py | 125 + .../runtime/artifacts/publisher.py | 307 + .../runtime/artifacts/reader.py | 89 + .../runtime/artifacts/replay.py | 83 + .../runtime/artifacts/serializers/__init__.py | 1 + .../artifacts/serializers/compact_v2.py | 61 + .../artifacts/serializers/unified_v1.py | 47 + .../runtime/artifacts/standard_loader.py | 62 + .../runtime/artifacts/worker.py | 49 + .../linkerhand_calibration/runtime/cameras.py | 71 + .../linkerhand_calibration/runtime/capture.py | 175 + .../runtime/controller.py | 160 - .../runtime/coordinator.py | 645 + .../linkerhand_calibration/runtime/engine.py | 309 +- .../runtime/execution.py | 154 + .../linkerhand_calibration/runtime/inputs.py | 54 + .../runtime/motion_execution.py | 143 + .../runtime/nodes/__init__.py | 1 - .../runtime/nodes/calibration.py | 23 - .../runtime/parameters.py | 51 + .../runtime/reference_lock.py | 171 + .../runtime/reporting/__init__.py | 7 +- .../runtime/reporting/codes.py | 14 - .../runtime/reporting/progress_zh.py | 202 + .../runtime/reporting/reasons_zh.py | 168 + .../linkerhand_calibration/runtime/resume.py | 324 + .../runtime/ros/__init__.py | 1 + .../runtime/ros/calibration_node.py | 104 + .../runtime/ros/entrypoint.py | 38 + .../linkerhand_calibration/runtime/ros/io.py | 59 + .../runtime/ros/parameters.py | 160 + .../linkerhand_calibration/runtime/runner.py | 273 +- .../runtime/runner_support.py | 273 + .../linkerhand_calibration/runtime/safety.py | 194 + .../runtime/scan_quality.py | 75 + .../linkerhand_calibration/runtime/session.py | 335 + .../runtime/snapshot.py | 127 + .../linkerhand_calibration/runtime/status.py | 155 + .../linkerhand_calibration/runtime/steady.py | 35 + .../runtime/trajectory.py | 271 + .../three_camera_diagnostics.py | 4 +- .../three_camera_node.py | 4 +- .../linkerhand_calibration/trajectory.py | 328 +- .../linkerhand_calibration/urdf_zero.py | 2 +- .../zero_calibration.py | 126 +- .../linkerhand_calibration/zero_node.py | 1608 -- src/linkerhand_calibration/setup.py | 21 +- .../test/engine_capture_fixture.py | 108 + .../test/o12_artifact_fixture.py | 32 + .../test/runtime_host_fixture.py | 50 + .../test/test_architecture.py | 121 +- .../test/test_artifact_publication.py | 68 + .../test/test_calibration_coordinator.py | 223 + .../test/test_capture_provenance.py | 77 + .../test/test_compact_calibration_json.py | 182 + .../test/test_config.py | 2 +- .../test/test_declared_byte_direction.py | 25 + .../test/test_dual_mapping.py | 123 + .../test/test_finalization_worker.py | 58 + .../test/test_frozen_rotation_reference.py | 47 + .../test/test_frozen_tag_replay.py | 135 + .../test/test_g20_right_product.py | 1933 +-- .../test/test_golden_regression.py | 2 +- .../test/test_l6_right_profile.py | 655 +- .../test/test_native_rotation_curve.py | 72 + .../test/test_o12_axis_residual_policy.py | 2 +- .../test/test_o12_full_hand_zero.py | 131 +- .../test/test_o12_observation_resolution.py | 28 +- .../test/test_o12_readonly_bridge.py | 37 + .../test/test_o12_recorded_replay.py | 4 +- .../test/test_o12_right_profile.py | 1953 +-- .../test/test_o12_static_geometry.py | 156 - .../test/test_o12_thumb_pnp.py | 65 +- .../test/test_o6_right_profile.py | 206 +- .../test/test_observation_capture.py | 92 + .../test/test_package_rename.py | 2 +- src/linkerhand_calibration/test/test_pnp.py | 50 +- .../test/test_pnp_runtime_policy.py | 72 + .../test/test_profile_finalization.py | 221 + .../test/test_profile_loader.py | 136 + .../test/test_progress_status.py | 105 + .../test/test_rectified_camera_contract.py | 40 +- .../test/test_reference_lock.py | 45 + .../test/test_reference_waypoints.py | 59 + .../test/test_relative_standard_mimic.py | 53 + .../test/test_resume_v2.py | 48 + .../test/test_runtime_readiness.py | 96 + .../test/test_safety_policy.py | 80 + .../test/test_session_execution.py | 120 + .../test/test_standard_correction_plan.py | 84 + .../test/test_standard_loader.py | 26 + .../test/test_standard_urdf_acceptance.py | 124 + .../test/test_three_camera_retry.py | 5524 ------- .../test/test_trajectory_policy.py | 39 + .../test/test_unified_artifact_pair.py | 81 + .../test/test_unified_engine.py | 34 +- .../test/test_unified_launch.py | 78 + .../test/test_unified_ros_host.py | 83 + .../test/test_unified_runner.py | 362 + .../test/test_urdf_validation.py | 53 + .../test/test_urdf_zero.py | 56 +- .../test/test_virtual_control_contract.py | 84 + .../linkerhand_o12_t3_left_urdf-0703.urdf} | 2463 ++- .../urdf/o12_left/meshes/base_link.STL | Bin 0 -> 2637584 bytes .../urdf/o12_left/meshes/index_distal.STL | Bin 0 -> 769984 bytes .../o12_left/meshes/index_metacarpals.STL | Bin 0 -> 141184 bytes .../urdf/o12_left/meshes/index_middle.STL | Bin 0 -> 496284 bytes .../urdf/o12_left/meshes/index_proximal.STL | Bin 0 -> 898884 bytes .../urdf/o12_left/meshes/middle_distal.STL | Bin 0 -> 769984 bytes .../o12_left/meshes/middle_metacarpals.STL | Bin 0 -> 124184 bytes .../urdf/o12_left/meshes/middle_middle.STL | Bin 0 -> 446784 bytes .../urdf/o12_left/meshes/middle_proximal.STL | Bin 0 -> 847984 bytes .../urdf/o12_left/meshes/pinky_distal.STL | Bin 0 -> 769984 bytes .../urdf/o12_left/meshes/pinky_middle.STL | Bin 0 -> 496284 bytes .../urdf/o12_left/meshes/pinky_proximal.STL | Bin 0 -> 591684 bytes .../urdf/o12_left/meshes/ring_distal.STL | Bin 0 -> 769984 bytes .../urdf/o12_left/meshes/ring_middle.STL | Bin 0 -> 496284 bytes .../urdf/o12_left/meshes/ring_proximal.STL | Bin 0 -> 898884 bytes .../urdf/o12_left/meshes/thumb_distal.STL | Bin 0 -> 753334 bytes .../o12_left/meshes/thumb_metacarpals.STL | Bin 0 -> 430584 bytes .../meshes/thumb_metacarpals_base1.STL | Bin 0 -> 341384 bytes .../meshes/thumb_metacarpals_base2.STL | Bin 0 -> 223584 bytes .../urdf/o12_left/meshes/thumb_proximal.STL | Bin 0 -> 578384 bytes ...ibrated_O12_RIGHT_001_20260908_134319.urdf | 1234 -- ...ibrated_O12_RIGHT_001_20260908_170656.urdf | 1234 -- ...ibrated_O12_RIGHT_001_20260908_174116.urdf | 1234 -- ...ibrated_O12_RIGHT_001_20260908_181314.urdf | 1234 -- ...ibrated_O12_RIGHT_001_20260908_183332.urdf | 1234 -- ...ibrated_O12_RIGHT_001_20260909_144123.urdf | 1234 -- ...RIGHT_001_REVIEW_ONLY_20260908_195044.urdf | 1234 -- ...RIGHT_001_REVIEW_ONLY_20260909_105543.urdf | 1234 -- ...RIGHT_001_REVIEW_ONLY_20260909_112041.urdf | 1234 -- 319 files changed, 27406 insertions(+), 62594 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/linkerhand_calibration/CALIBRATION_CODE_REVIEW_20260910.md delete mode 100644 src/linkerhand_calibration/CALIBRATION_FLOW.md create mode 100644 src/linkerhand_calibration/config/profiles/g20_right_19.yaml create mode 100644 src/linkerhand_calibration/config/profiles/l6_right_8.yaml create mode 100644 src/linkerhand_calibration/config/profiles/o12_right_16.yaml create mode 100644 src/linkerhand_calibration/config/profiles/o6_right_8.yaml create mode 100644 src/linkerhand_calibration/config/schemas/hand_profile.schema.yaml create mode 100644 src/linkerhand_calibration/config/schemas/product.schema.yaml delete mode 100644 src/linkerhand_calibration/launch/front_cmc_pitch_zero.launch.py delete mode 100644 src/linkerhand_calibration/launch/front_cmc_roll_calibration.launch.py delete mode 100644 src/linkerhand_calibration/launch/front_thumb_calibration.launch.py create mode 100644 src/linkerhand_calibration/launch/unified_calibration.launch.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/README.md create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/__init__.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/__init__.py (59%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/__init__.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/_adapter.py (78%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/artifacts.py (89%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/command_layout.py (100%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/golden_regression.py (97%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/legacy_11.py (66%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/motion.py (82%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/offline_replay.py (98%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/profile.py (97%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/publication.py (73%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/reporting_zh.py (100%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/right_19.py (53%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/runner.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/urdf_input.py (100%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/g20/zero_policy.py (68%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_solver.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/__init__.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/l6/artifacts.py (95%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/fitting.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/l6/left_transfer.py (87%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/motion.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/profile.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/runner.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/l6/urdf.py (91%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/__init__.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o12/artifacts.py (70%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/fitting.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o12/health.py (100%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o12/kinematics.py (100%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/motion.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/observations.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pipeline.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o12/pnp.py (98%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/profile.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/quality.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/runner.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/urdf.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o12/zero.py (72%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/__init__.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o6/artifacts.py (93%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/fitting.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o6/left_transfer.py (87%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/profile.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/runner.py rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/o6/urdf.py (95%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/registry.py (80%) rename src/linkerhand_calibration/linkerhand_calibration/{ => compat/legacy_diagnostic_tools}/models/runtime_schema.py (66%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/progress.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/runner_helpers.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/artifacts/release.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/domain/measurement.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/domain/result.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/domain/sample.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/circle_geometry.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_mapping.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/coupling.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/image_curve.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/motion_fit.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/observed_motion.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/rotation_curve.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/acceptance.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_lines.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_observations.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/base_pose.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/geometry_helpers.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/optimization.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/preparation.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/residuals.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/rotation_curves.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/solve.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/stages.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/statistics.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/types.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/validation.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/tag_installation.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/fitting/trajectory_geometry.py rename src/linkerhand_calibration/linkerhand_calibration/{models/o12/observations.py => core/geometry/candidate_selection.py} (76%) create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/ippe.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/parameters.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/relative.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/rigid_group.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/tracking.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/trajectory.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/types.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/solver/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/solver/interfaces.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/acceptance.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/kinematics.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/result_plan.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/tag_acceptance.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/urdf/validate.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/fitting.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/motion.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/node.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/pipeline.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/profile.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/l6/runner.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/fitting.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/motion.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/node.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/pipeline.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/profile.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/quality.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/resume.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/runner.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o12/urdf.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/fitting.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/node.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/pipeline.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/profile.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/o6/runner.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/node.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/profiles/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/profiles/export.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/profiles/loader.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/profiles/observations.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/acquisition.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/legacy_byte_sdk.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_bridge.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_hcan_sdk.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_binding.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_topics.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/vendor_package.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/completion.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/controller.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/evidence.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/finalization.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/publisher.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/reader.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/compact_v2.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/unified_v1.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/standard_loader.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/worker.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/cameras.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/controller.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/execution.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/inputs.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/parameters.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/reference_lock.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/codes.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/progress_zh.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/ros/__init__.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/ros/calibration_node.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/ros/entrypoint.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/ros/io.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/ros/parameters.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/runner_support.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/session.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/status.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/steady.py create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/trajectory.py delete mode 100644 src/linkerhand_calibration/linkerhand_calibration/zero_node.py create mode 100644 src/linkerhand_calibration/test/engine_capture_fixture.py create mode 100644 src/linkerhand_calibration/test/o12_artifact_fixture.py create mode 100644 src/linkerhand_calibration/test/runtime_host_fixture.py create mode 100644 src/linkerhand_calibration/test/test_artifact_publication.py create mode 100644 src/linkerhand_calibration/test/test_calibration_coordinator.py create mode 100644 src/linkerhand_calibration/test/test_capture_provenance.py create mode 100644 src/linkerhand_calibration/test/test_compact_calibration_json.py create mode 100644 src/linkerhand_calibration/test/test_declared_byte_direction.py create mode 100644 src/linkerhand_calibration/test/test_dual_mapping.py create mode 100644 src/linkerhand_calibration/test/test_finalization_worker.py create mode 100644 src/linkerhand_calibration/test/test_frozen_rotation_reference.py create mode 100644 src/linkerhand_calibration/test/test_frozen_tag_replay.py create mode 100644 src/linkerhand_calibration/test/test_native_rotation_curve.py create mode 100644 src/linkerhand_calibration/test/test_o12_readonly_bridge.py delete mode 100644 src/linkerhand_calibration/test/test_o12_static_geometry.py create mode 100644 src/linkerhand_calibration/test/test_observation_capture.py create mode 100644 src/linkerhand_calibration/test/test_pnp_runtime_policy.py create mode 100644 src/linkerhand_calibration/test/test_profile_finalization.py create mode 100644 src/linkerhand_calibration/test/test_profile_loader.py create mode 100644 src/linkerhand_calibration/test/test_progress_status.py create mode 100644 src/linkerhand_calibration/test/test_reference_lock.py create mode 100644 src/linkerhand_calibration/test/test_reference_waypoints.py create mode 100644 src/linkerhand_calibration/test/test_relative_standard_mimic.py create mode 100644 src/linkerhand_calibration/test/test_resume_v2.py create mode 100644 src/linkerhand_calibration/test/test_runtime_readiness.py create mode 100644 src/linkerhand_calibration/test/test_safety_policy.py create mode 100644 src/linkerhand_calibration/test/test_session_execution.py create mode 100644 src/linkerhand_calibration/test/test_standard_correction_plan.py create mode 100644 src/linkerhand_calibration/test/test_standard_loader.py create mode 100644 src/linkerhand_calibration/test/test_standard_urdf_acceptance.py delete mode 100644 src/linkerhand_calibration/test/test_three_camera_retry.py create mode 100644 src/linkerhand_calibration/test/test_trajectory_policy.py create mode 100644 src/linkerhand_calibration/test/test_unified_artifact_pair.py create mode 100644 src/linkerhand_calibration/test/test_unified_launch.py create mode 100644 src/linkerhand_calibration/test/test_unified_ros_host.py create mode 100644 src/linkerhand_calibration/test/test_unified_runner.py create mode 100644 src/linkerhand_calibration/test/test_urdf_validation.py create mode 100644 src/linkerhand_calibration/test/test_virtual_control_contract.py rename src/linkerhand_calibration/urdf/{o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_141642.urdf => o12_left/linkerhand_o12_t3_left_urdf-0703.urdf} (60%) create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/base_link.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/index_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/index_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/index_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/index_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/middle_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/middle_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/middle_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/middle_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/pinky_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/pinky_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/pinky_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/ring_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/ring_middle.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/ring_proximal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/thumb_distal.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/thumb_metacarpals.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/thumb_metacarpals_base1.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/thumb_metacarpals_base2.STL create mode 100644 src/linkerhand_calibration/urdf/o12_left/meshes/thumb_proximal.STL delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_134319.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_170656.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_174116.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_181314.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_183332.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260909_144123.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_REVIEW_ONLY_20260908_195044.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_REVIEW_ONLY_20260909_105543.urdf delete mode 100644 src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_REVIEW_ONLY_20260909_112041.urdf diff --git a/.gitignore b/.gitignore index 08438f4..b3d8edd 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,7 @@ Thumbs.db # Device-specific robot descriptions derived from local calibration runs # Includes full/partial zero-calibration outputs and local copies. +/src/linkerhand_calibration/urdf/*/*_calibrated_*.urdf /src/linkerhand_calibration/urdf/*/*_zero_calibrated*.urdf /src/linkerhand_calibration/urdf/*/*_transferred_from_*.urdf /src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a8e003d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +避免产生屎山代码,要求代码结构清晰可读性好 \ No newline at end of file diff --git a/src/gui_control/gui_control/config/constants.py b/src/gui_control/gui_control/config/constants.py index 2dcfef1..8b9d21f 100644 --- a/src/gui_control/gui_control/config/constants.py +++ b/src/gui_control/gui_control/config/constants.py @@ -114,8 +114,6 @@ _HAND_CONFIGS: Dict[str, HandConfig] = { "动作9": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 210, 255, 255, 255, 255, 255, 255, 255, 255, 255], "根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255], "根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255], - "根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255], - "根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255], "末端1": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0], "末端2": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 255, 255, 255, 255, 255], "末端3": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0], @@ -309,6 +307,22 @@ _HAND_CONFIGS: Dict[str, HandConfig] = { 0, 650, 700, 0, 650, 800, 700, 700, ], }, + preset_action_overrides={ + "right": { + "拇指对食指": [ + 245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0, + ], + "拇指对中指": [ + 489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0, + ], + "拇指对无名指": [ + 699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0, + ], + "拇指对小指": [ + 786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645, + ], + }, + }, position_scale=1000, position_unit="rad", ), diff --git a/src/gui_control/test/test_o12_config.py b/src/gui_control/test/test_o12_config.py index b616cc1..6978180 100644 --- a/src/gui_control/test/test_o12_config.py +++ b/src/gui_control/test/test_o12_config.py @@ -14,3 +14,33 @@ def test_o12_gui_uses_active_angle_order_and_milliradians() -> None: assert config.position_unit == "rad" assert len(config.init_pos) == 12 assert all(len(pose) == 12 for pose in config.preset_actions.values()) + + +def test_o12_right_fingertip_preset_actions_use_requested_radians() -> None: + config = HAND_CONFIGS["O12"] + actions = config.get_preset_actions("right") + + assert actions["拇指对食指"] == [ + 245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0, + ] + assert actions["拇指对中指"] == [ + 489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0, + ] + assert actions["拇指对无名指"] == [ + 699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0, + ] + assert actions["拇指对小指"] == [ + 786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645, + ] + assert all(len(actions[name]) == 12 for name in ( + "拇指对食指", "拇指对中指", "拇指对无名指", "拇指对小指", + )) + + +def test_o12_fingertip_preset_actions_are_right_hand_only() -> None: + actions = HAND_CONFIGS["O12"].get_preset_actions("left") + + assert "拇指对食指" not in actions + assert "拇指对中指" not in actions + assert "拇指对无名指" not in actions + assert "拇指对小指" not in actions diff --git a/src/linkerhand_calibration/CALIBRATION_CODE_REVIEW_20260910.md b/src/linkerhand_calibration/CALIBRATION_CODE_REVIEW_20260910.md new file mode 100644 index 0000000..1e413cd --- /dev/null +++ b/src/linkerhand_calibration/CALIBRATION_CODE_REVIEW_20260910.md @@ -0,0 +1,169 @@ +# 统一标定需求代码审查(2026-09-10) + +## 结论与审查范围 + +审查当前工作区(包含未提交的统一重构、精简 JSON v2 和本会话启动参数修复), +不是只审查 Git HEAD,也没有把历史 PASS 当作当前实机验收。 +通用采集、拟合、URDF 修正、最终文件验收和发布架构已经存在,但仍有迁移残留和精度验证缺口。 +本轮修正可由代码及隔离测试确认的问题,没有改变原始 CAD、机械限位、SDK、贴 Tag 配置或相机外参。 +没有启动 SDK、相机或发送机械手运动指令。 + +## 已修复的问题 + +| 问题 | 影响 | 修改 | +| --- | --- | --- | +| `vendor_sdk_config:=` 等空 launch 实参 | O6/L6/G20 可能在节点启动前退出 | runner 省略空参数,使用 ROS CLI 的实际解析器覆盖四型号 | +| 在线 IO 使用固定 front/side/top 外参加载器 | 新 Profile 即使静态校验通过,在线仍可能不能使用其他机位 | 直接用通用外参加载器,视图和参考机位来自 Profile | +| launch 固定三机位、型号文件名回退和过期参数 | 新型号需修改启动代码;部分开关没有实际作用 | 按 Profile 创建机位;要求受保护 Profile/原始 URDF/Tag 路径;移除旧诊断、速度、scope 和输出目录覆盖参数 | +| MVS 无图像时只显示“等待设备” | 操作者无法区分 SDK、相机或检测链路故障 | 统一显示每机位有效内参/检测消息等待项、SDK 条件和内参不匹配原因 | +| READY 后设备断流仍保留可开始状态 | 启动请求可能使用已失效的就绪条件 | 开始前可撤销 READY;Start 再次检查完整设备条件 | +| 实时 CameraInfo 只比较 P 的部分字段 | K、D、R 改变但 P 未变时,整流图可能与原外参不一致 | 实时校验宽高及 K/D/R/P 指纹;开始后不匹配按原有坐标变化故障停止继续采集 | +| 每个稳态采样点清空整方向统计 | 显示有效帧和覆盖率归零,误导进度判断 | 分离方向数据与当前稳态点数据,重扫/换方向才重置方向统计 | +| 标题只显示序列号 | 序列号不含型号时无法确认当前型号 | 所有型号统一显示 model、side、serial_number | +| 正式 finalizer 仍允许旧格式绕过指令拟合 | 新型号配置旧版本可能没有所需的 SDK 指令→rad 产物 | 正式 Profile 与 finalizer 均只接受统一输出版本 2,取消旧输出分派 | +| 完成回调读取精简 JSON 已删除的 `quality` | 文件可能已经发布,但节点在结束时抛 KeyError | 删除没有消费者的 `final_quality` 赋值,增加无 quality 字段的完成回调回归 | +| 视觉暂停与控制定时器之间的状态竞争 | 定时器等待锁期间已暂停,取得锁后仍可能继续动作 | 在控制锁内再次检查终止状态 | + +开始前等待相机消息的 2 秒窗口不用于扫描中的实时暂停;空检测数组算视觉链路存活。 +短时 Tag 丢失、低检测率等仍按方向结束的数据质量策略处理,同速重扫一次后仍不足才暂停。 + +清理的在线残留包括无人读取的回调计数、命令频率缓存/方法、型号质量标签、旧 tracker reset hook, +以及每次反馈回调不必要构造的备用 Adapter。没有删除 SDK 协议接口的抽象方法。 + +五个过渡期写出器已移出生产包并保留原文件: +`calibration_output/code_review_retired_serializers_umhxlepj/`。 +包括 `select.py`、`generic_v1.py`、`legacy_v4.py`、`legacy_v6.py`、`native_v7.py`。 +历史读取器和历史诊断工具仍在;此归档不包含或改写用户的历史标定产物。 + +## 视频复核后的补充修复:共用姿态分支筛选 + +视频复核发现旧配置把 `pnp_reprojection_tie_px` 和姿态拒绝阈值均设为 `1.5 px`, +导致通过质量门槛的 IPPE 候选都参加时间连续性比较,旧镜像分支可能压过明确更好的图像拟合。 +用现有候选姿态复现时,旧分支 `0.24 px` 会压过另一分支 `0.05 px`。 + +公共模块统一默认近似同误差阈值为 `0.03 px`;ROS 与离线采集继承该值。 +五份旧 YAML 的重复覆盖已移除,四产品的 `calibration_config_sha256` 已同步更新。 +新增校验拒绝非有限、负值或不小于图像拒绝阈值的分支容差,防止旧的 `1.5/1.5` 配置再次生效。 +保持实际近似同误差时的连续性选择、原有倾角/图像质量限制和方向结束重扫策略。 +因此前文“配置保持不变”仅描述首轮结构审查;这次补充修复修改了上述算法配置及其哈希。 + +覆盖五份配置和四型号默认采集的九个复现用例,在修复前全部失败,修复后全部通过。 +第一组姿态、配置、采集与加载实际参数文件的四型号隔离 ROS host 检查共 51 项通过。 +第二组公共采集/拟合与发布、O12 姿态解析、来源保护、四型号配置及启动检查共 141 项通过, +耗时 114.53 秒。两组为本次修改的定向回归,没有宣称本次又执行过全包测试。 +本次 `colcon build --packages-select linkerhand_calibration --symlink-install` 通过; +安装环境读取四产品配置、校验受保护哈希及公共默认值均通过,`git diff --check` 通过。 +这不代表所有平面双解已消除,也不替代下述相机时序和实机重复性验证。 + +## 仍不能由本轮软件审查保证的事项 + +### P1:真实采集时间与传输积压 + +`hikrobot_camera.py::_publish_frame` 使用主机取到帧时的 ROS 时间戳,未将设备曝光时间映射到统一时钟。 +SDK 帧结构有设备时间戳和帧号,但设备时钟单位、偏移、漂移与主机时钟同步还没有在当前硬件验证。 +`runtime/ros/io.py::_state_callback` 对无时间戳反馈也采用接收时间。 +因此,USB/驱动缓存可能把旧图像与新反馈配对;50 ms 的消息时间戳检查和“只取最新帧”不能排除该问题。 +本会话已经实测到三台相机共享 480 Mbps Hub 上行,且正面/上方取帧超时。 + +后续应在恢复 USB 3.x 链路后,核验设备时间戳、帧号、曝光与反馈时序,并用独立运动数据检查配对误差。 +不能猜测设备时钟单位、直接给时间戳减一个常数,或放宽质量阈值作为修复。 + +### P1:重复性和实机/仿真验收尚未完成 + +前三轮训练、第四轮独立验证、冻结 Tag 安装和双文件重读校验已接入。 +但同手多次独立重采、重新摆放和重新贴 Tag、不同保持姿态/多关节组合动作的实际验收仍缺证据。 +L6/O6 部分关节由小指参数迁移,不属于所有关节独立实测。 +发布报告仍应如实保留 `arbitrary_multiaxis_validated: false`。 + +外部仿真要加载本次修正 URDF,用同一 SDK 指令话题及配套 JSON 驱动;用实机反馈直接驱动仿真, +只能显示反馈对应姿态,不能证明指令映射正确。桥本身是消息转换器,不是独立精度测量工具。 + +### P2:数学模块和旧配置仍有维护负担 + +`core/fitting/spatial.py` 约 3,656 行,包含轴线拟合、基座位姿、零位优化、可观测性和验证, +其中 `solve_urdf_zero_offsets` 从约第 1,921 行开始,嵌套函数及共享局部状态较多。 +它是当前生产算法,不是可直接删除的废代码。后续拆分应按这些数学职责进行, +用固定输入的参数、残差、失败原因和产物回归验证等价性,不应复制成各型号独立算法。 + +当前约 58 个兼容模块仍用于旧导入路径、历史回放/迁移工具和旧回归;不能仅因目录名称或静态引用少就删除。 +几行的公开兼容导出不是第二套拟合算法。旧型号诊断、旧 URDF plan 和部分旧 YAML 参数仍需明确退休范围后继续收缩。 +受保护的历史配置在本轮保持不变;旧 YAML 中一些已不被在线节点读取的参数,不应当作当前算法的生效开关。 + +原始 G20/O12 的 mimic/限位冲突和 CAD 保留策略仍需机械资料确认;当前通用 URDF 修正 +主要处理零位、限位和线性 mimic,不估计连杆长度、轴位置、mesh 或惯量。 + +## 按需求核对 + +| 需求 | 当前判断 | +| --- | --- | +| 多型号共用拟合和 URDF 修正 | 已实现统一生产链;SDK 新协议仍需 Adapter 和 ROS 启动绑定 | +| 新型号只关心 SDK、贴 Tag、避让 | 基本成立,但必须声明关节绑定、可观测性、迁移/保留策略和可修改字段,不能从未知安装的单轴 Tag 自动猜出 CAD 零位 | +| 统一 SDK 指令→rad JSON 与修正 URDF | 正式仅生成统一 v2 指令表;u8 为 256 项,rad 为显式节点;被动表由标准 mimic 推导并验收,详细证据保留在报告 | +| 开始前可移动、开始后固定 | 每次 Start 丢弃预览参考,正式锁定;可见固定基准有漂移监测;遮挡对象是否移动不能实时保证 | +| 正确、可重复 | 有独立验证和来源保护,但仍受采集时序与未完成实机验收限制 | +| 少暂停且原因明确 | 统一策略保留;开始前明确缺失设备,不用短时视觉丢失实时打断扫描 | +| 统一进度且明确型号 | 已修正型号、设备等待原因及方向统计显示 | +| 同源指令实机/仿真验证 | 有统一转换桥;实际动态/组合动作对比尚未完成 | + +## 本轮验证 + +- 审查中完整标定包回归:**544 通过、1 跳过,383.25 秒**。跳过项要求显式提供 + `O12_REPLAY_RAW` / `O12_REPLAY_REFERENCE`,没有用合成数据替代历史实测。 +- 随后补充的启动清理、单一正式输出及归档改动:启动/runner/正式 Profile 拟合/精简产物定向组 + **74 通过,108.78 秒**,包含四型号与虚拟型号的实际公共拟合和发布路径。 +- 最后完成回调、暂停竞争、ROS host、等待诊断、实时内参保护:**25 通过,0.98 秒**。 + 与上一组有重叠,不相加冒充一次全量结果;没有宣称最后所有变更又跑过一次全量。 +- `colcon build --packages-select linkerhand_calibration --symlink-install` 通过。 + 安装入口读取四产品的受保护配置、ROS launch 实参解析均通过;167 个生产/兼容 Python 文件 AST 通过; + `git diff --check` 通过。 +- 新 launch 测试实际构造四型号及改名机位的 launch actions,但不执行这些动作。 + 隔离 ROS 节点/标准加载器测试不启动 SDK 或相机,不代表实机验收。 + +## 数学核心与 ROS 组合重构的验收记录 + +本轮以包含公共 `0.03 px` 姿态分支修复的工作区为基线,只拆分职责、明确数据和并发边界。 +没有删除基线中仍存在的历史入口,没有修改配置、拟合公式、优化初值/顺序、残差权重或验收阈值。 + +- `core/fitting/spatial.py` 保留 45 个原定义符号的显式导出;实际计算在 `spatial_solver/`。 + `solve.py` 的入口顺序为输入准备、训练求解、统计、独立验证及结果装配。 + 数据通过 `ZeroProblem`、`TrainingProblem`、`TrainingFit` 和各验收结果类型传递。 + 训练接口只接收训练观测,训练几何中的掌部姿态按训练轮筛选。 +- `core/geometry/pnp.py` 保留 14 个原定义符号;实现拆为 IPPE、单 Tag 跟踪、刚性组和轨迹选择。 + 跟踪默认值集中在 `tag_pose/parameters.py`,ROS 参数只负责加载和 deg/rad 转换。 +- `UnifiedCalibrationNode` 直接继承 ROS `Node`,组合消息 IO 与 `CalibrationCoordinator`。 + 协调器不加载 ROS 库,复用 `SessionExecution`、采集、运动、固定基准和断点组件。 + 运行参数、观测输入和快照使用明确类型;阶段只有 `CalibrationSession.phase` 一个来源。 +- `FinalizationController` 组合原 worker 与发布器,协调器接收阶段事件并授权提交。 + 状态锁先于 worker 锁;PnP 在状态锁外计算,提交时检查会话版本、采集/运动对象及稳态边界。 + 暂停、中止与提交互斥;失败不触发自动恢复运动,成功只提交一次。 + SDK 绑定显式接收反馈新鲜度、时钟、健康订阅和发布接口。 + +固定输入记录在本次工作区快照 `/tmp/calibration_structure_baseline_30qf3ozo/`。 +持久化的对比报告及压缩前后输出位于 `calibration_output/structure_refactor_20260910/`, +其中 `comparison.json` 记录输入摘要、差异、产物摘要和拒绝原因,`protected_inputs.json` 保存输入哈希。 +这些记录是合成数据的结构等价证据,不是实机精度验收。 + +| 固定输入 | 比较浮点值数量 | 最大绝对差异 | JSON/拟合结果/阶段事件及 URDF 文本 | +| --- | ---: | ---: | --- | +| G20 | 46,046 | 0 | 一致 | +| L6 | 15,304 | 0 | 一致 | +| O6 | 15,304 | 0 | 一致 | +| O12 | 14,494 | 0 | 一致 | +| 虚拟型号(任务重排) | 15,304 | 0 | 一致 | + +对五组输入分别移除第四轮或注入第四轮 Tag 姿态滑移,10 个拒绝原因和阶段事件与基线一致。 +167 个配置、Profile、原始 URDF/资源文件的哈希不变。 +另一个虚拟型号同时重排 SDK 通道、机位名称和扫描任务,经过真实公共采集、finalizer、 +最终 JSON/URDF 验收和发布,再检查同一 SDK 指令经过模拟传输、查表和标准 mimic 图的一致性。 + +验证结果: + +- 整个标定包:581 passed,1 skipped,337.43 s。 +- SDK 绑定时钟补充后,协调器、四型号 ROS 构造和安全策略回归:27 passed。 +- 跳过项是 `test_o12_recorded_replay.py`:未提供 `O12_REPLAY_RAW` 与独立参考模型;不计作实机通过。 +- 改动模块通过 Pyflakes,`git diff --check` 通过。 +- `colcon build --packages-select linkerhand_calibration --symlink-install` 成功。 +- 安装后的 12 个 console entry 可导入;两个产品 CLI 的 `--help` 正常。 + 两个 ROS 节点入口均通过四型号受保护 Profile 的转交检查;安装环境下四型号 ROS 构造/Start 回归 4 passed。 + +本轮不处理相机采集时刻同步,也不替代实机多次标定和同话题实机/仿真运动对照。 diff --git a/src/linkerhand_calibration/CALIBRATION_FLOW.md b/src/linkerhand_calibration/CALIBRATION_FLOW.md deleted file mode 100644 index 274b4f4..0000000 --- a/src/linkerhand_calibration/CALIBRATION_FLOW.md +++ /dev/null @@ -1,238 +0,0 @@ -# LinkerHand 整体标定流程与 URDF 修正原理 - -本文描述 `linkerhand_calibration` 当前的多型号统一架构。具体的通道数、运动单位、 -Tag 布局、任务、零位策略和输出格式由产品 Profile 决定,通用流程不再假定某一种手型。 - -## 1. 整体流程 - -```mermaid -flowchart TD - A[加载产品配置与 Profile] - A --> B[设备、视觉与安全预检] - B --> C[按任务执行三轮训练和一轮留出采集] - C --> D[拟合动态曲线、零位、行程和被动耦合] - D --> E{质量与留出验证通过?} - E -->|数据不足且未重扫| C - E -->|不可恢复| X[保持/暂停,不发布] - E -->|通过| P[形成最终标定结果] - P --> J[生成标定 JSON] - P --> U[从原始 CAD 生成修正后的 URDF] -``` - -对应的通用状态可以概括为: - -```text -配置/Profile → 预检 → 运动与视觉采集 → 参数拟合 → 质量验证 - ├─→ 标定 JSON - └─→ 修正后的 URDF -``` - -图中只展示业务流程和最终产物。实现内部,G20/L6/O6 会额外生成并回读 -`*_urdf_correction_input.json`,用于审计和校验 URDF 修正参数;O12 直接使用已验证的 -拟合结果。该交接细节不改变最终交付物仍是标定 JSON 和修正 URDF。 - -## 2. Profile 负责什么 - -统一入口 `calibrate_hand --config <产品配置>` 先根据产品配置选择 -`MODEL/side/layout/vREVISION` Profile。Profile 是硬件运动和数据解释的边界,声明: - -- SDK 命令顺序、单位(u8 或 rad)、反馈映射和安全 baseline; -- 相机视角、Tag ID、父子连杆角色和外参要求; -- 每项运动任务、起止点、辅助避障姿态和速度; -- 哪些关节实测、迁移、保留 CAD,哪些关节是主动或被动; -- 静态零位、机械端点和 mimic/非线性耦合的求解策略; -- 训练轮、独立留出轮、质量门限、输出 schema 和发布指针。 - -因此通用层只执行“Profile 声明的任务”,不会自行猜测关节顺序、单位、Tag 数量或 -左右手镜像关系。 - -## 3. 标定原理 - -### 3.1 数据采集 - -每项任务只驱动一个目标通道,其余通道保持 baseline 或进入 Profile 规定的避障姿态。 -G20、L6、O6 不再执行逐任务全行程运动预检;O12 只对每个主动通道执行一次不超过 -3° 的映射点动。正式采集同步保存: - -- 请求命令和真实反馈; -- 父、子 AprilTag 的相对旋转与相对平移; -- 相机时间戳、内外参身份、重投影误差及全手状态; -- 扫描方向、轮次、任务和重试编号。 - -请求命令和真实反馈是两个不同的数据域,必须由对应型号的 schema 明确标识,不能混用。 - -### 3.2 视觉几何 - -关节运动由父、子刚性件上 Tag 的相对 SE(3) 轨迹得到。相对观测可以消除相机在世界中的 -绝对位置和固定 Tag 安装变换对动态角度的影响。 - -- 转轴方向由整段相对旋转轨迹拟合; -- 轴线上一点可由刚体旋转关系 `(I-R)p=t` 稳健拟合; -- 接近沿轴观察时,弱可观的单目深度分量会被降级为诊断或投影到图像平面; -- 多视角结果先转换到公共坐标系,再按 Profile 规定用于主测量、交叉检查或零位约束。 - -### 3.3 三类标定结果 - -一次会话通常同时求三类参数: - -1. 动态曲线:控制量/反馈量到关节角的单调映射,并保留或检查正反方向回差。 -2. 静态零位与行程:实物基准姿态相对 CAD 关节坐标系的偏差,以及实测安全范围。 -3. 被动耦合:主动关节与被动关节之间的线性或二次关系。 - -对启用 `isolated_holdout` 的当前产品 Profile,训练轮用于拟合,独立留出轮不参与最终参数 -重拟合,只检验泛化误差。短时 Tag/PnP/同步丢失只丢弃无效帧,运动继续完成。一个方向 -只有在有效同步样本少于 40、行程覆盖不足、少于 32 个分箱或连续盲区超过全行程 1/16 -时,才同速重扫一次。检测率与反馈频率低于理想值只作诊断,只要有效数据足够就不停机。 -拟合或 holdout 失败会拒绝发布,但不自动反复运动。 - -实时运动仅在人工中止/重复控制器、SDK 活动故障或失联、控制模式错误、物理越限、 -明确要求运动后连续两秒无推进,以及固定基准连续 10 帧漂移超过 5 px 时停止。非目标轴 -小幅运动、正常跟随滞后、机构固有耦合和辅助避让轴误差不是停机条件。 - -## 4. URDF 修正原理 - -### 4.1 基本原则 - -修正始终从经过哈希确认的原始 CAD URDF 生成,不在上一份标定 URDF 上叠加,也不覆盖 -源文件。型号适配层只生成声明式 Patch,公共 patch engine 负责字段级修改、禁止覆盖、 -mesh 安全复制和原子写入。 - -允许修改哪些字段由 Profile 和型号 writer 决定,可能包括: - -| 参数 | 作用 | -|---|---| -| `origin.rpy` | 写入可观测且被授权的主动关节静态零偏 | -| `limit.lower/upper` | 把实测行程或端点转换到修正后的关节坐标系 | -| `mimic.multiplier/offset` | 为普通 URDF 使用者提供线性被动联动 | -| MuJoCo equality `polycoef` | 表示 Profile 授权的非线性被动耦合 | - -`origin.xyz`、关节轴、mesh、惯量、连杆长度和拓扑默认保持 CAD;只有型号 Profile 明确 -授权的字段才能变化。 - -### 4.2 静态零位 - -若型号允许修正某主动关节的静态零位,源关节变换为 `T_cad`、源关节轴为 `a`、 -零偏为 `δ`,则: - -```text -T_corrected = T_cad × Rot(a, δ) -``` - -实现上将结果重新表达为 `origin.rpy`。动态曲线描述的是相对该新零位的运动量,所以运行时 -不能再把 `δ` 加到曲线输出中。 - -并非所有型号都修改 origin:如果视觉无法把固定 Tag 安装角与绝对零位可靠分离,Profile -会保留 CAD origin,只发布动态曲线和实测行程。 - -### 4.3 机械端点和限位 - -零位和限位必须作为同一个坐标变换问题处理。Profile 会为不同机构选择经过确认的锚点策略, -例如 `lower_at_start`、`upper_at_end` 或 `cad_range_center`,而不是统一假定命令 0/255 -一定对应某个 CAD 端点。 - -若某物理上限由 CAD 确认,零位移动 `δ` 后,坐标限位也要反向移动,保证: - -```text -静态零偏 + 修正后的坐标端点 = 原 CAD 物理端点 -``` - -其他型号则直接把实测安全行程写成新的 `[lower, upper]`。无论采用哪种策略,发布前都会 -检查运行曲线和被动耦合不越过修正后的 URDF 限位。 - -### 4.4 被动关节和非线性耦合 - -普通 URDF 的 `` 只能表达: - -```text -q_passive = offset + multiplier × q_active -``` - -若实测传动比随行程变化,拟合器可使用二次模型: - -```text -q_passive = a0 + a1·q_active + a2·q_active² -``` - -此时标准 URDF 中保留端点对齐的线性 mimic,保证 RViz 等普通消费者可以合理联动;精确的 -中间行程由运行时标定 JSON/桥接节点提供,支持的型号还会把二次系数写入 MuJoCo equality。 - -## 5. 当前型号差异 - -| 产品 Profile | 单位/范围 | 标定范围 | URDF 修正重点 | 结果 | -|---|---|---|---|---| -| `G20/right/g20_right_19/v1` | 20 路 u8 | 19 Tag、完整右手;主动零位和主动/被动动态曲线 | 主动 origin;部分端点 limit;等价 mimic offset | schema v4,`latest_passed` | -| `L6/right/l6_right_8/v1` | 6 路 u8 | 3 项局部实测,其余三指按已确认同机构迁移 | 主动 origin/行程;线性 mimic;MuJoCo 二次 equality | schema v6,`latest_partial_passed` | -| `O6/right/o6_right_8/v1` | 6 路 u8 | 3 项局部实测,其余三指迁移 | 主动 origin/行程;被动限位;端点线性 mimic | schema v6,`latest_partial_passed` | -| `O12/right/o12_right_16/v1` | 12 路连续 SDK rad | 16 Tag;11 路主动曲线和空间零位;无名指复用小指修正 | 共享 G20 轴方向/相邻轴线相位求解;无名指保留自身平移/范围/mimic | schema v7,`latest_passed` | - -O12 静态零位与行程分开处理:SDK 扫到最大不证明该姿态等于原始 CAD 上限, -禁止使用 `CAD.upper - measured_travel` 推算零位。roll/yaw 使用公共 G20 几何求解器, -由 roll/yaw/pitch 运动轴与小指根部定向轴求解。生产发布使用 -`o12_full_hand_spatial_v3_mount_invariant_phase`:拇指 pitch/MCP、食指/中指 MCP/PIP、小指 MCP -增加相邻轴线位置的相位约束,食指/中指侧摆使用下游 MCP 轴方向。 -三轮训练模型冻结后仅用第四轮验证,不用 SDK/CAD 端点相减生成零位。 -全部 11 个实测主动零位通过后才允许发布,失败时输出 -`spatial_zero_diagnostics.json`,保留逐轴残差和逐关节失败原因,不退回 CAD 后报 PASS。 -O12 显式分离旋转轴线方程的轴向零空间残差;横向残差仍参与几何质量验证。 -侧面相位计算同样先去除轴线点的轴向自由分量,再做相机平面投影;否则重新贴 Tag -造成的轴线参考点改变会被误认为关节零位。该选项由 O12 Profile 显式启用,其他型号 -已验证的默认相位策略本次不变,不表示已完成全型号实测安装不变性验收。 -O12 的 `PHASE_PARENT` 图声明平行机械轴;方向约束覆盖主动 MCP/PIP 以及被动轴。 -不能把独立单目姿态拟合造成的轴向偏差当作机构真实不平行,再通过静态零位补偿。 -通过和失败结果均记录原始/轴向/横向残差,不把不能约束轴线的分量混入拟合。 -动态拟合完成且空间解可用但未通过验证时,自动导出 `review_only/` 下的复核 URDF; -文件名及 manifest 标记 `REVIEW_ONLY`,不输出控制 JSON、不更新发布指针,错误仍向上返回。 -O12 允许保留统计上接近零的修正:训练置信区间必须包含零且半宽不超过 1°, -冻结的零值仍必须通过几何、轮次一致性和独立 holdout 验证。 -这不是跳过未观测关节;该策略显式启用,G20/L6/O6 默认决策不变。 -历史 schema v7 和低层旋转拟合测试仍可能标注 `source_cad_zero_not_measured`, -它们不等于全手空间零位已通过。轴线零位验证也不等于独立指尖接触精度验证; -被动静态零位仍不独立估计,标准 URDF 的线性 mimic 仍是非线性 SDK 联动的近似。 - -无名指迁移的是小指零位的标量修正,按 `R_ring_CAD * Rot(axis_ring, delta_pinky)` -叠加到自身坐标系;不能复制小指的 xyz,也不能将无名指零位遗漏为零。 -反馈曲线在无名指自身限位以内直接复用,到限位才截断,不对整条曲线重新缩放。 -发布同时核对 JSON 静态偏置与实际 URDF origin,轴角 holdout 和曲线通过仅代表 -对应测量通过;被动 SDK 多项式与普通 URDF 的线性 mimic 仍属于不同近似模型。 - -此外仍注册了 G20 左/右 `legacy_11` 兼容 Profile,以及 L6/O6 从右手正式结果生成左手 -迁移产物的 Profile。它们用于兼容或明确的左右手迁移,不代表新增一套通用测量假设。 - -## 6. 发布与产物 - -通过会话通常包含: - -```text -raw_samples.jsonl 原始、可审计采样 -*_calibration.json 运行时曲线和质量信息 -*_urdf_correction_input.json 部分型号的 URDF 参数交接文件 -*_calibrated*.urdf 修正 URDF -meshes/ 会话内可解析的模型资源 -calibration_summary_zh.json 会话范围、迁移来源、质量和哈希摘要 -``` - -发布前会复核源文件身份、输出 schema、URDF 授权字段、曲线限位、被动关节策略、mesh 和 -产物哈希。只有全部通过才更新 `latest_passed` 或 `latest_partial_passed`; -`latest_attempt` 仅表示最近一次尝试,不能作为生产结果。 - -## 7. 代码边界 - -- `core/`:无 ROS、无具体型号的领域契约、几何、拟合接口和 URDF patch engine。 -- `runtime/engine.py`:`unified_engine_v1` 扫描单元、一次同速重扫和统一数据门。 -- `runtime/adapters/`:`SdkAdapter` 契约以及命令/反馈域解析。 -- `models//`:声明式 Profile、SDK I/O 薄封装和兼容旧 schema 的序列化插件。 -- `compat/`:旧配置、旧布局和兼容入口。 -- `config/*_product.yaml`:实物身份、相机、输入文件和哈希。 - -`unified_engine_v1` 不读取旧策略断点;迁移后的第一次运行必须完整重新采集。后续同版本 -断点仍按 Profile、序列号和所有受保护输入哈希校验。 - -全新标定前允许移动底座、重贴 Tag;开始后底座及 Tag 相对连杆安装必须固定。 -断点恢复逻辑和默认行为不变,默认恢复期间安装未变,不增加确认参数。 -G20/O12 安装改变后使用已有 `--no-resume` 开始新采集;L6/O6 产品入口行为不变。 -哈希验证不等于检测物理安装变化。 - -`compare_calibration_urdfs` 对相同 URDF 关节角执行只读 FK 对比,记录连杆原点、方向 -和主动范围。它不加载 SDK、不运行硬件、不参与零位拟合、不改变发布门,也不是 -实机接触精度认证。参考模型不是必须复现的固定参数;同数据回放一致性与独立重采 -精度重复性必须分别验收。 diff --git a/src/linkerhand_calibration/README.md b/src/linkerhand_calibration/README.md index 38f175f..996d3a1 100644 --- a/src/linkerhand_calibration/README.md +++ b/src/linkerhand_calibration/README.md @@ -1,1341 +1,236 @@ -# LinkerHand 专业标定包 +# LinkerHand 多型号统一标定 -## 全型号安装与重复标定约定 +G20、L6、O6、O12 使用同一个产品启动器、在线状态机、采集器、拟合器、标准 URDF +验收和发布器。型号差异来自 `config/profiles/*.yaml` 和 SDK Adapter,不再调用型号节点。 -O12 的 `thumb_mcp_dip_front` 使用 ID1/ID2/ID3 联合 IPPE 候选选择,不再逐 Tag -独立决定分支。复用通用组跟踪器,以实测平行转轴方向辅助消歧;不指定 Tag 安装角, -不将 CAD mimic 比例或 SDK 被动公式替换为视觉测量。方向证据不足时退回视觉连续性, -不新增实时停机条件;最终几何质量验证保持不变。其他型号的选解策略及断点逻辑不变。 -在线选择仅作暂定观测。最终求解用前三轮完整轨迹重新比较拇指候选分支,第四轮 -不参与分支评分;结果写入 `pose_selection_diagnostics.json`。不会把关节轨迹强行 -投影成无残差的刚性运动,也不会降低原几何门限。 +本包校准关节零位、有效行程及标准线性 mimic;保留 CAD 连杆尺寸、轴位置、mesh、惯量。 +JSON 描述 SDK 输入与修正 URDF 角度的转换,不能补偿错误的 URDF。 +软件仿真通过不等于实机精度通过;当前原始 G20/O12 存在需要确认的 mimic/限位冲突。 +详见 [执行与验收记录](CALIBRATION_REFACTOR_PROGRESS.md)。 -三相机检测来自 `image_rect`,PnP 必须使用 `CameraInfo.P[:3,:3]`,不能使用原始 -`K` 并把畸变设为零。L6/O6/O12 共用取参路径已修正,G20 原本即使用 P。 -新会话保存 `rectified_camera_model`(含原 K/D/R/P);O12 各任务保存角点、候选 -和选解记录 `o12_pnp_candidate_frame`,锁定的基准明确标注 `locked_reference`, -不伪造像素。拇指还保留初始化未选出位姿的帧。 - -旧帧若未记录正确投影来源,最终拟合不会把旧 K 静默当成 P;仍走旧观测路径并 -在诊断中标注 `legacy_projection_unverified`。只保存最终位姿的任务无法可靠重算; -外部相机文件仅可显式用于局部诊断,其混合观测不允许生成整手正式产物。 -当前真实数据修正内参、重选拇指候选后仍有 MCP 残差未通过;不能承诺已解决实机精度。 - -标定前允许移动机械手底座、重新安装 Tag;ID 与所属刚性连杆必须正确,Tag 尺寸和 -平面观测条件必须满足配置。开始后底座固定,Tag 相对各自连杆固定,关节仍正常运动。 -相机相互位置及内参不变时,移动手不要求重标相机外参。 - -断点恢复保持原有默认行为,前提是底座、相机和 Tag 安装未变,不增加确认参数。 -G20/O12 在移动底座或重贴 Tag 后开始全新标定时,使用已有 `--no-resume` 选项, -不混用旧安装的采样。原有哈希/数据兼容性检查保持不变;哈希不能检测物理安装变化。 -L6/O6 产品启动入口行为不变。 - -可用只读工具比较不同结果的模型姿态: +## 启动 ```bash -ros2 run linkerhand_calibration compare_calibration_urdfs \ - --reference <参考.urdf> --candidate <本次.urdf> --output <新建报告.json> -``` +cd /home/lxp/projects/linkerhand_retarget_ros2 +source /opt/ros/jazzy/setup.bash +colcon build --packages-select linkerhand_calibration --symlink-install +source install/setup.bash -它检查相同 **URDF 关节角** 下的连杆原点位置、方向及主动行程,不修改文件、不控制 -硬件、不影响标定拟合或发布。坐标不是原始 SDK 弧度,连杆原点也不是指尖接触点; -离线探测姿态不可直接发送给实机。报告不等于精度 PASS。参考结果只作对照,不作为 -固定零位输入,也不强制新数据拟合成旧参数。 +# 无运动、无 SDK 启动的配置检查 +ros2 run linkerhand_calibration calibrate_hand --config \ + src/linkerhand_calibration/config/o12_right_product.yaml --validate-only -## O12 右手 16-Tag 完整标定 - -O12 使用 vendor `omnihand_pro_2025_node` 的标准弧度接口,固定订阅 -`/o12/right/joint_states`、发布 `/o12/right/joint_cmd`。标定程序不会使用 -0–2000 混合控制接口,也不会按可能错位的 `JointState.name[]` 重排数据;12 路 -`position[]` 顺序及 `thumb_roll → thumb_cmc_roll`、 -`thumb_abad → thumb_cmc_yaw` 映射由产品 Profile 固定。 - -把 [o12_right_product.yaml](config/o12_right_product.yaml) 中的 -`serial_number` 改成实物串号后,只需运行: - -```bash +# 正式启动:自动启动 SDK、相机、检测及标定,READY 后自动开始运动 ros2 run linkerhand_calibration calibrate_hand --config \ src/linkerhand_calibration/config/o12_right_product.yaml ``` -runner 会自动加载仓库内 vendor Jazzy overlay,启动 HCAN device 0/channel 0 -节点、三相机、AprilTag 与标定节点;READY 后自动开始。启动前会确认 12 路 -POSITION 模式、错误码和实时反馈,并以不超过 3° 的低速点动执行固定通道预检。 -这版 O12 固件的温度查询会阻塞后返回空,因此默认不发送该无效请求,而以独立错误 -查询中的 bit1 持续提供过热保护。bit0–bit3 或未知错误位始终立即停止;SDK 明确可能 -由历史超时留下的 bit4 `commu_except`,只有在至少三次相同报告、至少 1.5 秒观察且 -命令触发反馈持续新鲜并达到最低频率后才标记为历史锁存。标定运行中出现新的 bit4 -组合,或反馈流中断超过一秒,仍会立即保持当前位置并停止。 -正式扫描以 50 Hz 发布端点速度为零的平滑限速弧度轨迹;中止、堵转或质量失败时保持 -当前位置。小指完成后,小指与无名指会同步弯至各自安全上限;进入食指标定前, -中指 MCP/PIP 也弯至各自安全上限且中指侧摆保持 0 rad。专用避让航点确认这些轴 -到位后才继续,为中指和食指留出完整空间。SDK 的 MCP→PIP 回读联动、其他非目标轴小幅运动以及避让轴 -跟随滞后全部作为采样/诊断保留,不再套用理论 vendor 包络触发停机。O12 的扫描质量 -由统一引擎按有效同步样本、实测行程、归一化分箱和连续未观测区判断;单 Tag 检测率、 -联合帧率和反馈频率低于理想目标只记告警。短时局部遮挡只丢弃无效帧,不中断运动; -数据不足时当前方向只同速重扫一次。结果发布到 -`calibration_output//latest_passed`,其中 JSON -为 schema v7 弧度 knots,不生成 256 点 u8 表。 - -O12 与 G20 共用 Tag 几何拟合、3+1 holdout、URDF 写回和发布门,但输入域不同:G20 -使用 u8,O12 使用连续 SDK 弧度。O12 的 12 路 SDK 坐标包含 tendon/vendor solver -坐标,不能直接当作 19 路 URDF 关节角;必须在完整 SDK 物理范围上由 Tag 相对旋转 -拟合 SDK→URDF 曲线。源 CAD 限位是待修正输出,不能反向截断采集范围。拇指 -roll/yaw 和其余实测主动关节的静态零位复用 G20 空间求解核:非平行轴用方向, -平行弯曲轴用相邻轴线的位置相位。生产发布要求 11 个实测主动零位通过 3+1 验证, -无名指继承小指修正;失败时保存 `spatial_zero_diagnostics.json`,不退回 CAD 报 PASS。 -被动关节的 JSON 仍使用 SDK 非线性公式,标准 URDF 则保留 CAD 端点等价线性 -mimic 近似,两者不能视为任意姿态下完全相同。发布前同时验证曲线、 -静态 origin、URDF 限位和递归 mimic 链;指尖接触精度还需独立组合姿态验收。 - -O12 空间零位策略 `o12_full_hand_spatial_v3_mount_invariant_phase` 正式采用 -复核版的轴向残差分离:求解旋转轴线时只使用可观测的横向方程,同时记录原始、轴向、 -横向残差,不能把轴向误差混作轴线位置误差。每次从本次数据重新求解,不包含某一只手 -的固定修正角度;G20/L6/O6 的默认拟合策略不变。三轮训练、第四轮独立验证及横向几何 -质量门仍保留,不能仅凭模型看起来更像就宣布精度通过。 - -侧面平行关节相位先消除轴线点的轴向自由分量,再投影到图像平面,不能重新使用未经 -处理的两点位移:轴线上的“最近点”随父 Tag 安装原点改变,并不是唯一的物理轴承中心。 -此前这种混用会在略斜的侧面视角下产生安装相关的零位偏差,即使无噪声且四轮重复也 -可能错误通过。回归测试覆盖近轴向机位、移动底座、重新安装父/子 Tag 及两者同时变化。 -这些理想几何测试不替代实测质量验证;单目位姿误差仍可能使数据无法通过。 -O12 的相邻平行轴图同时约束主动和被动弯曲轴的方向,而不只约束被动 DIP;各轴仍 -使用自己的实测旋转行程和轴线位置。配对任务须保持非平行上游轴在相同姿态,不能把 -其他姿态采到的轴方向直接当作当前方向。该约束不适用于 roll/yaw 等非平行轴。 - -如果动态拟合完成、空间零位存在解但验证未通过,会在会话的 `review_only/` 下保存 -文件名带 `REVIEW_ONLY` 的 URDF 和 `review_manifest.json`,方便检查;终端仍明确报告 -失败,不生成可用于控制的标定 JSON,也不更新 `latest_passed`。缺少几何数据、求解 -不可观测或修正超出允许范围时不生成复核模型。验证通过则正常发布,无需人工替换算法。 -标准 URDF 的滑块使用拟合后的关节角,不是原始 SDK 弧度;静态零位已写入 origin, -不要再次加到滑块上。 - -重新完整采集时在原启动命令后加 `--no-resume`;仅验证算法则使用 -`--offline-raw `,不必重采。外参或 Tag 安装改变后,新旧观测不可混用。 - -O12 的命令端点与反馈端点不要求数值相等:完整命令轨迹负责驱动机械全行程,第一轮 -反馈的两个实测端点建立该方向的归一化输入域,后续轮次只需复现首轮实测行程的 90%。 -连续空洞门也只检查这段实测行程内部,不会把反馈比例或零位差形成的命令域末端区间 -误判成 Tag 遮挡。最终曲线和 URDF 端点同样使用该实测输入域,禁止向 SDK 命令端点 -进行未观测外推。 - -四指避让前会锁定无遮挡状态下的正面掌心 ID0 位姿;小指、无名指弯到上限遮住 ID0 -后,中指和食指的正面任务复用该会话固定基准,但 ID12/ID13 等运动 Tag 仍使用实时 -观测。运动 Tag 短时丢失只丢弃对应帧,恢复识别后继续采集,不会中断轨迹或误报映射 -失败;最终仍必须满足统一的有效样本与覆盖率质量门。 - -O12 的主动轴与避让轴共用同一条多轴平滑轨迹。正式扫描期间,避让轴的正常跟随滞后 -只作诊断;进入或退出避让的专用航点则要求侧摆进入 0.03 rad 到位带、每路屈曲反馈 -沿正确方向完成至少 90% 的首轮实测行程并稳定后才继续。避让命令仍发送 SDK 最大值, -但完成判断不再把反馈弧度除以命令弧度;同时屈曲时 vendor solver 的反馈端点可以小于 -单轴命令上限。反馈弧度是连续拟合输入,而 Tag 相对旋转才是目标 URDF 关节角。 -真正的物理越限、活动硬件故障、通信失联 -和要求运动的轴连续两秒无推进仍会停止。 -O12 单独出现的 SDK `commu_except` bit4 只触发连续错误查询并记录诊断;只要完整 -12 路反馈仍然新鲜且目标轴正常推进,就不会把历史/瞬时通信位误判为失联。反馈流 -超时、目标轴无推进,或 bit0--bit3 堵转/过热/过流/电机异常仍会立即安全停止。 - -O12 的 FRONT/TOP 和 FRONT/SIDE 光轴都接近正交,允许最终外参批次 RMS 不超过 -2.0 px,同时继续要求三折旋转稳定性不超过 0.3°、平移稳定性不超过 1.5 mm。 -采集候选时将单相机和候选配对上限设为 2.5 px,以便斜视棋盘进入整批联合拟合; -这两个候选上限不会替代最终的 2.0 px 批次门: - -```bash -ros2 launch linkerhand_calibration three_camera_extrinsics.launch.py \ - output_file:=$PWD/config/o12_three_camera_extrinsics.yaml \ - checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 \ - maximum_reprojection_rms_px:=2.0 \ - maximum_candidate_pair_reprojection_rms_px:=2.5 \ - maximum_single_camera_reprojection_rms_px:=2.5 -``` - -完整 SDK 范围策略不会复用旧 CAD 截断数据,必须重新完整采集。runner 默认 -寻找最新同版本兼容失败会话,使用 `--no-resume` 可禁用恢复。只有通过 -质量门的连续“任务/轮次/方向”单元会被复用;启动后仍先恢复全手安全基准,并执行 -恢复点所需的映射预检和避让。序列号、profile、schema 或任一受保护输入哈希变化 -时拒绝恢复。断点恢复默认安装未变;安装改变后应使用 `--no-resume` 从头采集。 - -只验证配置、16 张 16 mm Tag、相机/外参、源 URDF 和 SDK 配置哈希而不运动: - -```bash -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/o12_right_product.yaml --validate-only -``` - -## O6 右手局部标定(o6_right_8/v1) - -O6 使用与 L6 相同的三机位八 Tag 观测拓扑,但保留 O6 自己的六通道协议与 -URDF 关节名:正面 ID0/1/2 标定 `rh_thumb_cmc_pitch` 与 -`rh_thumb_ip`,侧面 ID3/4/5 标定 `rh_pinky_mcp_pitch` 与 -`rh_pinky_dip`,上面 ID6/7 标定 `rh_thumb_cmc_yaw`。六路 baseline 均为 -`255`;每次只扫描通道 0、1 或 5,其他通道保持 255。经硬件确认,食指、中指、 -无名指与小指同机构,因此小指 MCP/DIP 的实测结果会以迁移来源标记后用于其余 -三指。O6 实测 IP/DIP 均存在稳定的非线性,因此使用通过独立 holdout 的双向 -运行曲线和二次耦合模型。标准 URDF 无法表示二次 mimic,因此修正 URDF 与 L6 -一样保留端点对齐的线性 ``,使普通 URDF/RViz 中五个被动关节能正常联动, -同时把被动关节 limit 更新为实测范围;中间行程的精确双向非线性轨迹由下述标定桥 -发布。 - -```bash -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/o6_right_product.yaml --validate-only - -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/o6_right_product.yaml -``` - -由于 O6 的 FRONT/TOP 光轴接近正交,同一平面棋盘的同步视角天然更倾斜。O6 外参 -允许最终批次 RMS 不超过 1.5 px,但仍保持 0.3° 旋转、1.5 mm 平移稳定性门限, -并在标定发布前额外用 20 mm 跨机位实体轴线 RMS 粗差门限拦截移动相机等明显错误: - -```bash -ros2 launch linkerhand_calibration three_camera_extrinsics.launch.py \ - output_file:=$PWD/config/o6_three_camera_extrinsics.yaml \ - checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 \ - maximum_reprojection_rms_px:=1.5 -``` - -结果发布到 `calibration_output//latest_partial_passed`,运行时 JSON、 -修正 URDF 与 correction-input JSON 均使用 `o6_right_` 前缀。产品 YAML 内的实物 -串号、相机身份、外参和四项输入哈希必须在启动硬件前通过校验。 - -标定完成后,以修正 URDF 启动 robot state publisher,并用同一会话中的 JSON 把 -O6 六路反馈转换为 11 个 URDF 关节: - -```bash -ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \ - hand_type:=right \ - calibration_file:=$PWD/calibration_output/O6_RIGHT_001/latest_partial_passed/o6_right_O6_RIGHT_001_partial_calibration.json -``` - -## L6 右手局部标定(l6_right_8/v1) - -本版只发布 `rh_thumb_cmc_pitch`、`rh_thumb_cmc_roll`、 -`rh_pinky_mcp_pitch` 的静态零位与动态曲线,以及 `rh_thumb_dip`、 -`rh_pinky_dip` 的视觉动态曲线。拇指 DIP 通过线性 mimic;小指 DIP 使用实测 -双方向运行曲线和 MuJoCo 二次 equality,因为它的传动比会随屈曲角变化。生成的 -修正 URDF 保留 `rh_pinky_dip` 的 ``,因此在普通 URDF/RViz 中仍会跟随 -MCP 运动;该线性回退精确对齐实测零位和闭合端点。中间行程的准确非线性轨迹由 -MuJoCo equality 或下述标定桥提供。根据 L6 四指同机构的实机确认,食指、中指、 -无名指的 MCP 零偏、行程、双向反馈曲线及 DIP 耦合从小指迁移;每指自己的 -`origin.xyz`、axis、mesh 和惯量保持 CAD,不把迁移结果标成 Tag 实测。四指 MCP -以反馈 255 的展开端作为 CAD lower/zero 锚点;实测行程不会再被强制压回较短的 -CAD upper,因此不会向四指 origin 写入系统性的负零偏。 -结果指针为 `latest_partial_passed`,不会被当作六路主动关节的完整标定。 - -拇指 pitch/roll 根据两组已记录六路反馈值的实机/仿真姿态对比,以反馈 255 -保持源 CAD joint zero,不叠加端点推断的静态偏置;小指及三指迁移仍以反馈 0 -机械闭合姿态对齐源 CAD upper。该策略只改变坐标锚点,不改变视觉实测的双方向 -行程曲线。现场姿态对比必须同时记录对应的六路反馈值。 - -先启动 SDK 和 GUI 做手动检查时使用: - -```bash -ros2 run linker_hand_ros2_sdk linker_hand_sdk --ros-args \ - -p hand_type:=right -p hand_joint:=L6 -p can:=can0 -p topic_prefix:=/l6 - -ros2 run gui_control gui_control -``` - -正式一键标定由 runner 自己启动 SDK、三相机、AprilTag 和标定节点,不要同时 -运行上面的 SDK/GUI 控制命令: - -```bash -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/l6_right_product.yaml -``` - -只检查 Profile、8 张 16 mm Tag、相机/外参哈希和只读源 URDF: - -```bash -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/l6_right_product.yaml --validate-only -``` - -离线回放与在线发布使用同一拟合/URDF写回路径: - -```bash -ros2 run linkerhand_calibration calibrate_hand --config \ - src/linkerhand_calibration/config/l6_right_product.yaml \ - --offline-raw calibration_output/L6_RIGHT_001/<时间戳>/raw_samples.jsonl -``` - -拟合通过后,程序先原子写入并重新校验 -`l6_right_<序列号>_urdf_correction_input.json`,验证串号、Profile 和源 URDF -SHA-256 后,才把其中的原精度零偏、行程和 DIP 耦合参数交给现有 URDF 写回器。 -面向运行时的 schema-v6 `*_partial_calibration.json` 及修正 URDF 的字段、数值和 -格式保持兼容;单独的 correction-input JSON 是可审计的 URDF 生成依据,不是运行桥 -的输入文件。 - -运行前将产品 YAML 中的 `serial_number` 改成实物串号。通道顺序固定为 pitch、 -roll、index、middle、ring、pinky;旧 SDK 反馈中的 `thumb_cmc_yaw` 仅作为第二路 -兼容别名读取,新产物和运行桥始终输出 `thumb_cmc_roll` / `rh_*` URDF 名。 -预检和正式扫描均使用 L6 硬件速度 `1` 作为上限,并由 100 Hz 余弦缓入缓出 -轨迹把完整 `255↔0` 行程固定为 `6 s`;短行程按距离同比缩短。SDK 会过滤 L6 -在同一 CAN ID 回送的位置命令回显,标定只使用状态查询返回的真实反馈。标定 -启动时还会检查重复 SDK/GUI 发布者,避免两个进程同时访问同一只手。 - -标定完成后,用生成的 JSON 将六路硬件反馈转换成包括小指 DIP 在内的 11 关节 -`JointState`: - -```bash -ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \ - hand_type:=right \ - calibration_file:=$PWD/calibration_output/L6_RIGHT_001/latest_partial_passed/l6_right_L6_RIGHT_001_partial_calibration.json -``` - -schema v6 默认订阅 `/l6/cb_right_hand_state`,发布 -`/sim/mujoco/l6/right/joint_state`。标准 URDF 的 `` 本身只支持线性关系, -所以只查看 URDF 时小指 DIP 中间行程是端点对齐的近似;需要实测轨迹时使用该桥 -或修正 URDF 内的 MuJoCo equality。 - -## G20右手正式一键标定 - -固定三相机和19张Tag安装完成后,用户只运行: - -```bash -ros2 run linkerhand_calibration calibrate_hand -``` - -旧 executable `calibrate_g20_right` 在本发行版内保留为同一入口的别名; -旧 ROS 包名前缀不再提供。新脚本和部署配置统一使用 `calibrate_hand`。 - -构建和正式调用统一为: - -```bash -colcon build --packages-select linkerhand_calibration -ros2 run linkerhand_calibration calibrate_hand --config <产品配置.yaml> -``` - -## 代码边界 - -- `core/`:无 ROS、无具体型号,包含领域类型、PnP/旋转数学、拟合接口、统一样本 - 契约、`TaskEvaluator/SessionSolver` 协议和 `UrdfCorrectionPlan`。其中 - `core/urdf/patch.py` 是所有型号共用的声明式 URDF patch engine,统一负责属性级 - 文本修改、MuJoCo equality、mesh 安全复制、禁止覆盖和原子发布。 -- `runtime/`:通用会话状态机与注册 Profile 分发;ROS 消息和硬件适配只能位于 - `runtime/nodes`、`runtime/adapters`。 -- `models/g20/`、`models/l6/`:只保留型号 Profile、拟合/零位策略以及把拟合结果 - 转成 `UrdfPatchSet` 的薄适配层,不再各自实现 XML/mesh 文件写入器。新增 O6 时 - 优先新增 Profile;只有测量链或传动模型不同的部分才增加小型拟合插件。 - 后续型号或左右手作为新的独立 Profile 加入 `models/`,不在通用层增加分支。 -- `compat/`:v1 配置、旧路径、旧会话与旧单相机逻辑。旧 Python 包名仅保留 - 一版最小转发 shim,不包含算法副本。 - -产品配置在启动硬件前通过本地 `ProfileRegistry` 完成命令索引、任务、视角、 -Tag、零位目标、URDF关节和文件哈希校验。v1 配置原文不改;v2 配置使用 -`profile_id: MODEL/side/layout/vREVISION`。视角名和数量由 Profile 声明,通用层 -不要求 `front/side/top`,也不假设固定 20 个命令。 - -仓库中存在已审定硬件会话时,可执行只读金标准检查(不会覆盖任何产物): - -```bash -ros2 run linkerhand_calibration validate_g20_goldens \ - calibration_output/G20_RIGHT_001 -``` - -它严格核对完整整手、合并拇指、独立拇指、拟合失败和零位失败五个会话;完整 -整手还会重新离线求解并要求 JSON、URDF 的 SHA-256 与正式产物一致。 - -完全独立地只标定大拇指4项任务时,使用: - -```bash -ros2 run linkerhand_calibration calibrate_hand \ - --scope thumb -``` - -该模式不读取任何已标定四指数据。拇指零位求解只使用5条拇指轴、两条顶部同相机 -方向观测以及拇指自己的机械端点;输出URDF从原始CAD生成,只修改4个拇指主动 -关节,12个四指关节保持CAD零位。独立结果发布到`latest_thumb_passed`,其JSON是 -拇指标定/诊断产物,不冒充可直接运行的完整整手曲线JSON。 - -如果确实需要把新的拇指结果合并到一份已经通过的完整整手标定,才额外使用: - -```bash -ros2 run linkerhand_calibration calibrate_hand \ - --scope thumb \ - --base-session calibration_output/G20_RIGHT_001/latest_passed -``` - -合并模式会冻结基础会话中的12个四指主动零位;发布前再次读取基础会话JSON核对, -任何四指零位变化都会拒绝发布。基础四指数据仍不参与4个拇指零位的数值求解。 - -拇指专项结果重复性通过后,不必再做原来的16项整手扫描。以该拇指会话为基础, -只重新采集12项四指任务并合成完整整手URDF: - -```bash -ros2 run linkerhand_calibration calibrate_hand \ - --scope fingers \ - --base-session calibration_output/G20_RIGHT_001/<已通过的拇指会话时间戳> -``` - -`fingers`模式严格冻结基础会话中的4个拇指主动零位;最终完整整手URDF中的拇指 -零位与专项会话schema-v4数值完全一致。默认`full`也先调用与`thumb`完全相同的 -独立拇指内核,再冻结这4个结果求解12个四指零位,因此四指数据不能反向改写 -拇指结果。原来的默认`full`仍保留,用于需要16项全部重新采集的情况。 - -采样文件中的运动域是显式且不可混用的: -`requested_command_u8` 表示下发命令,`feedback_u8` 表示电机反馈。 -在线拟合和离线重放通过同一个数据契约投影到曲线索引;新会话不会把含糊的 -`command_u8` 写入 `raw_samples.jsonl`。基础会话导入期间状态会显示为 -`IMPORTING_BASE` 和 `REVALIDATING_INHERITED`,完成复核后才允许机械手运动。 - -提供`--base-session`时,它必须解析到同一序列号目录下的完整PASS会话;启动前会校验源CAD -URDF、相机外参和标定配置哈希。新会话从原始CAD重新生成完整URDF,不在旧校准 -URDF上叠加。两种thumb模式都只重采`thumb_cmc_pitch`、`thumb_cmc_roll`、 -`thumb_cmc_yaw`、`thumb_mcp/thumb_ip`四项物理任务,其余任务的原始记录导入后仍按 -数据契约和产物哈希检查,但不会以历史四指拟合结果否决本次拇指专项标定。 - -开发阶段若上一次会话失败,同一命令会自动校验硬件/几何哈希,并恢复已经 -完整提交的关节任务;失败中的当前任务始终丢弃重做,位于它后面但已经完整通过的 -独立任务仍会复用,不再因“连续前缀”限制整段重采。导入的任务会立即用与 -最终验收相同的硬门限复检(不含视口实时有效率):只以预警带余量通过的旧数据 -当场剔除并从其在扫掠顺序中的原始位置重采,避免全部任务采完后才在最终验收 -失败、把会话拉回靠前的关节。方向级自动重扫事件是追加日志中的持久失效标记; -恢复时只读取该标记之后的替代采集,不能把同一尝试编号下重扫前后的稳态点合并。 -因此已经在线硬门限验收的任务保持已完成,暂停中的任务从任务开头重采,不会因 -日志中仍保留被自动重扫淘汰的旧点而倒退到更早任务。运行中的多视角任务按正面 -主测量和侧面校验测量独立保留。侧面校验视角的任务级有效率只记录为诊断;逐帧 -识别率低于标称值, -但同步有效位姿已经完整覆盖端点、中点、最小分箱数和最大分箱空洞,也按完整 -轨迹通过。正式扫描仍逐方向执行相同的硬分箱覆盖检查,轴线、曲线和模型质量 -门限保持不变。`unified_engine_v1`取消每任务低速全行程预检;相邻方向共享已验证 -端点和任务级PnP参考。采样不足只按原速度重扫当前方向一次,拟合或第四轮留出 -失败立即锁定发布并保持当前位置,不再通过反复运动碰门限。G20右手正式扫描固定使用 -产品审定速度,不再根据单次识别密度自动提速,确保不同会话测量的是同一动态 -过程。顶部单目`thumb_cmc_yaw`在最终求解后另做零偏轮次重复性检查:前三轮 -极差默认不得超过0.5°,95%置信半宽不得超过0.75°。若两轮形成不超过门限20%的 -紧密簇、仅另一轮越界,也只输出诊断并停止发布,不自动补采。与`latest_passed` -中上一正式结果相差超过0.75°时另写入 -`thumb_yaw_cross_session_diagnostic`提示检查机械手位置和Tag安装,但该历史差值 -不直接否决当前会话,也不会用旧结果约束新零位。默认恢复兼容断点,前提是安装未变; -使用 `--no-resume` 可从第一个关节重新采集。 -升级前已经分别完成的正面/侧面roll也会合并为 -一个完整同步任务断点;只有两边数据都完整时才复用。 - -命令自动完成产品哈希预检、运动、当前任务补扫、前三轮训练、第四轮隔离留出、 -16个会话数据求解主动关节URDF零位修正、 -21条视觉实测命令曲线发布;四指PIP/DIP的动态曲线均实测,四指DIP静态零位保留CAD。 -终端只显示中文进度和问题;失败时复制“请复制以下内容给开发者”块即可。 - -四指末端的16 mm Tag允许使用刚性延长杆避挡;软件不假设末端Tag平面与中节Tag -平面平行。延长杆和Tag在一次标定期间必须完全刚性,不能晃动、扭转或重新调整。 -侧面掌部基准Tag(ID 4)与各活动指节Tag也不要求安装面平行:首次联合PnP使用 -静态多帧刚性、重投影误差和跨轮任务参考选择分支,不再用固定15°安装角门限阻断扫描。 -单Tag独立位姿仍保留75°倾角保护;对包含锁定掌部基准和完整父子链的任务,倾角保护只 -限制独立选择,不会在联合选择前删除正深度、低重投影的IPPE候选。联合跟踪继续用相邻帧 -绝对/相对位姿连续性约束这些斜视候选,最终轴线残差、四轮重复性和隔离留出门限不放宽。 -终端中的Tag计数表示“可见”;等待扫描起点时会另列PnP初始化进度和累计拒绝原因。 -若联合候选仍然失败,`raw_samples.jsonl` 会按8个反馈计数的区间保存 -`group_pnp_candidate_event`,其中包含缺失角色、逐Tag候选数/倾角/重投影、角点和相机内参 -哈希,可直接定位运动到哪个机械位置后开始失效,而不需要再次盲扫整条流程。 - -正式结果位于 `calibration_output/G20_RIGHT_001/latest_passed`。该指针只在 -JSON、URDF数值等价、mesh完整性、21条曲线CAD限位、被动关节保护和隔离留出验证 -全部通过后更新。 - -## G20右手19-Tag底层调试入口 - -以下内容仅保留给旧会话回放和开发调试;正式一键命令只发布上面的精简 -schema v4 JSON,不再生成schema v5运行文件。 - -新布局用独立参数启用,原有左右手11-Tag流程仍默认使用 -`tag_layout:=legacy_11`,两套配置和结果schema互不覆盖: - -```bash -ros2 launch linkerhand_calibration three_camera_calibration.launch.py \ - hand_type:=right \ - tag_layout:=g20_right_19 \ - serial_number:=G20_RIGHT_001 \ - camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ - source_urdf_expected_sha256:= -``` - -当前产品布局共19张`tag36h11`,所有Tag的黑色码区边长均为16 mm;四指末节ID为 -`7,14,16,18`。正面为`0,1,2,3,10,11,12,13`,侧面为 -`4,5,6,7,14,15,16,17,18`,上面为`8,9`。详细角色和逐ID尺寸以 -`config/three_camera_tags_g20_right_19.yaml`为唯一软件配置源。程序执行4项拇指任务, -以及小指、无名指、中指、食指各自的正面+侧面同步roll、侧面pitch、 -侧面PIP/DIP联合任务,共16个物理运动任务。同步roll只驱动电机一次,但两台相机仍分别拟合并通过 -各自的观测质量门限。每个PIP任务只驱动一次对应电机,同时用“手掌→中节Tag”实测PIP、 -用“中节Tag→末节Tag”实测被动DIP;四个DIP不再由URDF mimic系数生成,并参加完整视觉 -拟合和质量门限。任务直接执行四轮双向正式扫描; - -基准形态恢复完成后,程序先用至少30帧稳健锁定正面ID 0、侧面ID 4和顶部ID 8的 -固定掌部位姿。小指和无名指弯曲避让会遮住正面ID 0,因此四指正面+侧面同步roll中 -允许ID 0暂时不可见,并使用本会话基准锁定值;运动连杆Tag仍必须实时可见,门限不 -放宽。终端用`锁`表示该固定参考有效,例如`正面[0锁,12✓]`,`✗`才表示需要处理的 -实时Tag。锁定后本次标定运行中不得再移动相机、手掌底座或整只手,否则缓存参考 -失效,必须重新启动标定;两次独立会话之间轻微调整整只手的位置不会改变机械端点 -零位基准。任务级Tag有效率门限按"当前任务所需角色生效期间的采集帧"统计; -任务结束后角色要求会切回预检全套标签,静止期帧不参与该门限,避免把 -采集质量良好的任务误判为可见性失败。 - -恢复末端Tag后,程序能够独立实测四指PIP和DIP的转轴及动态命令曲线。但同一次 -相机外参和同一套Tag安装下的重复扫描无法排除固定安装相位偏差;实体手在反馈0端 -能够触掌是独立的机械端点约束。三个thumb CMC主动轴、`thumb_mcp`、四指MCP pitch -和PIP的URDF静态零位均由本次实测全行程与CAD机械端点之差求出,不再把跨相机的 -平面PnP绝对相位直接当作编码器零位,也不写死为0。生成URDF时同步修正这些关节的 -坐标上限以及相关被动关节的mimic坐标偏置, -保证非零零偏不会缩短最大闭合量。末节Tag继续用于DIP动态曲线、轴线质量、遮挡和 -第四轮留出检查。正式数据求解静态修正范围为拇指CMC三个主动关节、`thumb_mcp`、 -四指MCP roll、MCP pitch及PIP,共16个。`thumb_mcp`与四指屈伸关节一样使用 -实测全屈曲行程和CAD机械端点联合求解,不直接采用MCP/IP耦合运动的单目PnP相位。 -侧面累计避障按“PIP→MCP pitch→roll”的安全顺序分阶段进入,并按逆序分阶段退出; -同类辅助电机(全部邻指滚转、全部PIP、全部MCP pitch)合并为同一个并行航点同时 -运动,被测通道最后单独进入。“滚转全部回中前不展开弯曲手指”“每指pitch先于PIP” -等已评审不变量保持不变,过渡仍受类别限速、逐航点到位确认、停滞检测和超时保护。 -`parallel_pose_transitions`(默认true)置false可回退旧的逐电机顺序。 -同一任务的四轮正式扫描会保持完整避障姿态连续执行,只在任务切换时退出, -不再每轮重复展开/弯曲辅助手指。跨手指组切换时,下一组避障姿态仍然需要、且 -当前已经在位(含反馈容差)的辅助电机保持原位,只有下一组不再使用的避障电机 -退回基准,避免"先展开回基准、马上又折回"的多余动作;已评审的 -"滚转先回中再展开""先滚开再弯曲"顺序保持不变。G20右手四轮正式速度始终使用 -产品配置的固定值,不会因本次帧率或识别密度而改变。 -四指roll不再把同一反馈127误当成方向无关的唯一机械姿态:以`255→127`为标准物理 -零位,反向到达127的实测偏差保留在`increasing_rad`中。方向分支间隙上限1.5°、 -四轮间隙极差上限0.3°;其他关节仍使用严格的0.5°baseline回差门限。 -正式四轮使用产品审定速度;唯一一次采样重扫保持相同速度。 -正式roll的每个方向会在经过127时先到位保持0.5秒,再独立保存至少10帧静止Tag/反馈; -方向分支检查和动态曲线的127相位都使用这两组双向静止数据,运动中经过127的帧不再 -替代静态保持姿态。 -前三轮只用于训练,第四轮完全留出;留出轮不参与显著性、Student-t置信区间或最终重拟合。 -每个任务在首个正式方向起点执行PnP静态初始化;四轮正式扫描连续复用同一帧间 -分支与任务参考,不再让每一轮独立选择平面Tag解。同一任务第1轮 -已确立的端点相对姿态作为后3轮的分支锚点,防止独立初始化选到相反的 -IPPE镜像解。baseline标准接近和全部质量门限保持不变。 -电机15任务会利用源URDF中已确认的`thumb_ip mimic=1.03`,只在逐帧IPPE双解中 -排除与MCP同步运动明显矛盾(残差超过7.5°)的ID3镜像候选。该先验不生成或缩放 -`thumb_ip`曲线;通过分支选择后的`ID2→ID3`姿态仍独立拟合并接受完整留出验证。 - -当前19-Tag产品流程发布精简schema v4:21条运行时曲线全部来自当前会话的视觉实测。 -URDF零位字段覆盖拇指4个主动关节和四指各自的`mcp_roll/mcp_pitch/pip`,共16个, -其中三个thumb CMC轴、`thumb_mcp`及四指`mcp_pitch/pip`共12个字段由实测旋转行程 -与机械端点联合求解; -写出非零`thumb_mcp`零偏时同步平移其关节坐标上限,并更新被动`thumb_ip`的 -`mimic offset`,因此不会改变CAD定义的最大屈曲实体姿态; -`thumb_ip`及四指DIP静态零位保留源CAD。旧schema v5文件仅作历史回放兼容, -当前一键流程不再生成它。正面/侧面roll在同一次运动中独立拟合;方向、 -轴线和动态曲线均通过时做不确定度加权轴融合。侧面PIP连杆标签在滚转扫掠中 -相对侧相机视线倾斜约13°~20°,平面标签的单目IPPE姿态二义性会给侧视姿态引入 -数度的系统性"绕视线"偏差(亚像素重投影无法发现,会话20260820_105535实测 -前后轴向稳定相差11.4°),因此侧视PIP连杆姿态不再参与MCP轴向融合或角曲线验收, -正侧姿态差只写入`cross_view_roll_axis_diagnostic`。四根MCP侧摆轴在产品URDF中 -严格平行:小指作为先采集的参考轴,其余三指复用该公共方向并各自独立拟合轴线位置, -避免平面PnP分支在不同会话中改变轴向。前视侧摆连杆受丝杆平移影响,其纯旋转拟合得到的是 -随手指结构变化的伪轴线,不能与侧视PIP连杆的物理轴线使用统一距离门限; -两者线距仅记录在诊断中。侧视校验通道 -(`*_mcp_roll_side`)的 -姿态分支间隙跨轮极差和独立姿态轴方向极差只作诊断,不触发重复采集;这两个量来自 -近掠射平面Tag的非发布姿态分量。绝对分支间隙1.5°上限保持不变,真正发布的正面主轴 -仍使用原跨轮严格门限。侧视逐帧`axis_pose_line_rms`同样只作诊断,组合轴线改用四轮 -位置RMS验收;径向、平面、圆一致性及可见性门限全部保留。 -侧面端视roll的圆轨迹方向已经受 -姿态轴约束,因此自由三维圆平面与姿态轴的夹角只保留诊断,不再被重复作为硬门限; -径向残差和四轮轴线位置RMS仍是硬门限。正式MCP动态曲线统一使用正面Tag中心的 -二维投影圆角度,侧面姿态曲线仅保留为诊断;任一正式视角自身四轮不重复或第四轮 -留出失败仍会拒绝发布。任一静态目标、第四轮留出、 -遮挡、PnP或跨机位检查失败时,只保留原始轨迹和`passed:false`诊断,不发布正式URDF。 -8个组合姿态仅保留为开发诊断,正式产品默认不执行。轴线零位求解不提供适合绝对笛卡尔 -位置验收的手基座变换,因此不能用该诊断推翻已经通过的单关节隔离留出结果。三个CMC轴恢复使用 -`a609d521`验证过的完整四轮相对旋转曲线;全部实测关节均由隔离第四轮逐关节验收。 -现场需要区分某根手指的roll机构回差与单机位误差时,可设置 -`cross_view_roll_diagnostic_finger:=pinky|ring|middle|index`。该会话只执行目标手指的一次 -正面+侧面同步roll,共10个预检/正式方向;任一机位数据不足会重扫同一物理任务, -双机位数据齐全后即使存在轴质量失败也不再自动重采,而是把失败项随双机位结果 -一起写入`cross_view_roll_diagnostic`并立即暂停。诊断会话永久 -锁定URDF发布,不能用`resume`转换成正式标定。 -schema v5明确声明曲线输入域为真实反馈u8;运行桥默认订阅 -`/g20/cb_right_hand_state`,并按反馈增减方向选择正程/反程曲线,停止时锁存最后运动 -方向。尚未观察到运动方向时使用`255→127`标准分支,不使用两个机械分支的平均值。 -schema v4继续兼容旧 -命令域。两者都只发布动态角度,不重复叠加已写入URDF的静态偏移。 - -## 三机位三维关节轴零位标定(schema v4) - -正式入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,只有 -`/g20_calibration` 一个节点拥有机械手命令发布权。相机不需要水平,Tag方向也不需要 -贴正;相机和Tag在一次标定中必须固定。默认绑定为: +其他型号替换为 `g20_right_product.yaml`、`l6_right_product.yaml`、`o6_right_product.yaml`。 +`calibrate_g20_right` 是同一 runner 的旧命令别名。不要同时运行 GUI、单独 SDK 或其他控制器。 +`--commands-disabled` 只预览,不发送运动;`--no-resume` 强制新采集。Ctrl+C 中止,不自动快速张手。 +重新开始时先安全回基准,仍必须确保现场没有障碍物。 + +| Profile | SDK 位置通道 | 扫描任务 | Tag | 输出格式 | +| --- | ---: | ---: | ---: | --- | +| G20/right/g20_right_19/v1 | 20 | 16 | 19 | unified v2,256 项指令查表 | +| L6/right/l6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 | +| O6/right/o6_right_8/v1 | 6 | 3 | 8 | unified v2,256 项指令查表 | +| O12/right/o12_right_16/v1 | 12 | 11 | 16 | unified v2,rad 输入节点查表 | + +任务数、主动关节数和实测关节数不必相等。一条运动可观测多个主动/被动关节。 +L6/O6 未贴 Tag 的同机构关节使用显式迁移,不能把迁移称为独立实测。 +每任务四个往返周期:前三轮训练,第四轮独立验证。 +每个方向先连续扫描,再单通道访问稳态点。训练每方向默认 9 点,第四轮为交错点加端点; +每点至少 3 个稳定同步图像,轨迹结束后最多等待 2 秒,不能用精确指令/反馈相等判断稳定。 +稳态点缺失与连续扫描不足统一在方向结束处理,最多同速重扫一次。 +字节拟合不再改写原始 command/feedback,也不将接近端点的读数伪装成 0/255。 + +## 基准、避让与恢复 + +- 开始前可以调整手掌、相机和 Tag;预览数据不会成为正式参考。 +- 开始后清空预览缓存,先安全回基准,再为每机位固定 Tag 收集至少 10 个有效帧。 + 缺少基准时保持并显示缺少的 ID,不因为等待时间长暂停。 +- 锁定后禁止人工移动手掌、相机、支架、Tag 粘贴位置;程序驱动关节和避让仍是允许的。 +- 相机相对位置改变时须重新确认/标定外参;仅重新锁定掌心 Tag 不能替代外参标定。 +- 固定 Tag 被避让遮住时可用已锁定参考继续采集;软件不能保证检测完全不可见对象的移动。 + 运动 Tag 的刚性和几何一致性仍须通过最终独立验证。 +- 断点版本为 `unified_engine_v4_dual_mapping`,不复用缺少稳态证据的旧 v1/v2/v3。 + 新断点检查配置哈希、基准及所复用 Tag 安装关系(5 px、2°、5 mm)。缺失或不一致即放弃复用, + 自动完整重采,旧会话不修改。恢复时重新执行准备与避让,不直接跳到旧姿态。 + +O12 避让:小指和无名指到 Profile 的最大弯曲指令;标定食指前,中指 MCP/PIP 到最大弯曲指令, +中指侧摆为 0 rad。到位采用完成轨迹、正确方向、至少 80% 请求位移和短时稳定, +不要求反馈数值精确等于命令。SDK 指令最大值与实测 URDF 角度不是同一个量。 + +O12 使用 HCAN device 0/channel 0 的已锁定 vendor Python wheel,不检查 `can0`。 +只读反馈桥可在第一次运动命令之前取得状态,不会发送零位命令来“激活回读”。 +POSITION 和活动故障由 SDK 确认;无法单独回读温度时使用 SDK 错误位过热保护,不伪造温度。 +字节 SDK 当前没有独立硬件故障遥测,状态会明确注明这一能力边界。 + +## 哪些情况暂停 + +实时仅因人工中止、竞争控制器、活动硬件故障/模式错误、真实失联、反馈超过 1 秒未更新、 +物理越限、明显运动要求下连续 2 秒无推进、已锁固定基准连续 10 帧漂移超过 5 px 而暂停。 +开始后相机投影参数变化也会拒绝继续使用混合坐标数据。 + +短时 Tag 丢失、PnP/同步失败、低检测率/低反馈 Hz、正常跟随滞后、固有耦合和非目标小幅运动 +不会实时中断轨迹。无效视觉帧直接丢弃。 + +所有型号的独立 Tag 姿态筛选共用 `core/geometry/pnp.py` 中的近似同误差阈值,默认 `0.03 px`。 +ROS 和离线采集使用相同默认值,型号 YAML 不重复声明该值。`1.5 px` 是图像质量拒绝阈值, +不能用它判断两种姿态是否同样可信;仅在误差差值不超过近似同误差阈值时使用时间连续性。 +当另一分支的图像拟合明确更好时允许纠正旧分支。覆盖参数必须有限、非负且小于图像质量拒绝阈值。 +这项修复不保证解决所有平面 Tag 双解,真实角点噪声、采集时序和最终运动精度仍需实测验证。 + +方向结束才检查 ≥40 个有效同步样本、≥32 分箱、内部空白 ≤行程的 1/16、规定的有效行程覆盖, +以及 Profile 声明的端点/双视角观测。数据不足只同速重扫一次;第二次失败暂停。 +拟合或最终文件验收失败不重新自动运动、不更新发布指针。 + +所有型号显示相同进度:阶段、任务/轮次/方向、分机位 Tag ID、命令/反馈/单位/速度、 +有效数据、基准、断点来源,以及中文原因、建议和原始诊断。耗时拟合在后台执行,不阻塞反馈与状态。 +标题明确显示型号、左右手和序列号。开始前等待状态列出缺少或过期的相机内参、检测消息和 SDK 条件; +空 Tag 检测消息可以证明视觉链路工作,不要求开始前已经识别全部 Tag。 +收到开始请求时重新检查设备状态;相机有效内参必须与受保护外参中的指纹一致。 +开始前相机消息的新鲜度窗口为 2 秒,该窗口不用于扫描中的实时暂停。 + +## 唯一生产代码链 ```text -front = DB2163742,Tag 0/1/2/3/10 -side = DB2163749,Tag 4/5/6/7 -top = DB2163739,Tag 8/9 +linkerhand_calibration/ +├── config/profiles/ 唯一型号定义(受保护 YAML) +├── config/*_product.yaml 设备、序列号、相机、路径与哈希 +├── urdf/ 原始 CAD/mesh,不覆盖 +└── linkerhand_calibration/ + ├── profiles/ 加载、静态验证、Tag/CAD 观测关系 + ├── core/domain/ Profile、样本、CalibrationResult、状态 + ├── core/geometry/ 相机、变换、PnP 兼容导出 + │ └── tag_pose/ IPPE、连续跟踪、刚性组、轨迹分支选择 + ├── core/fitting/ 运动曲线、线性耦合、固定安装 + │ └── spatial_solver/ 空间观测、基座、零位、统计与独立验收 + ├── core/urdf/ 授权修正、FK、范围与最终文件验收 + ├── runtime/runner.py 唯一产品启动器 + ├── runtime/coordinator.py 会话协调、采集提交、运动和收尾接线 + ├── runtime/parameters.py 已解析的运行参数;无 ROS 类型 + ├── runtime/inputs.py 相机/检测输入、时钟与发布接口 + ├── runtime/cameras.py 受保护相机身份与消息新鲜度 + ├── runtime/session.py 唯一在线状态机 + ├── runtime/execution.py Profile 任务与运动效果调度 + ├── runtime/motion_execution.py 平滑轨迹与位移到位判定 + ├── runtime/capture.py 多机位视觉/反馈采集 + ├── runtime/scan_quality.py 唯一方向数据门 + ├── runtime/reference_lock.py 开始后固定基准 + ├── runtime/resume.py 唯一断点证据检查 + ├── runtime/safety.py 最小实时保护 + ├── runtime/status.py 统一状态与中文显示 + ├── runtime/snapshot.py 类型化状态快照与旧状态映射 + ├── runtime/adapters/ SDK 协议,不包含标定业务 + ├── runtime/ros/ ROS 参数加载、消息转换、订阅/服务/定时器 + ├── runtime/artifacts/ 收尾控制、后台 finalizer、serializer、原子发布 + └── compat/ 历史格式、布局与离线诊断;不提供旧在线流程 ``` -11 张 `tag36h11` 的程序角色必须与贴纸所在刚性件一致: +`calibrate_hand → runner → runtime/ros/entrypoint → UnifiedCalibrationNode → CalibrationCoordinator → SessionExecution` +是所有型号的生产调用链。节点直接继承 ROS `Node`,组合 `RosCalibrationIO` 和协调器, +不通过父子类业务回调执行标定。SDK 绑定接收明确的反馈新鲜度、时钟、健康订阅和发布接口。 -| ID | 机位 | 固定位置/运动件 | -|---:|---|---| -| 0 | 正面 | 正面掌壳固定基准 | -| 1 | 正面;右手电机0时也由侧面观测 | 拇指 CMC 后连杆 | -| 2 | 正面 | 拇指 MCP 后连杆 | -| 3 | 正面 | 拇指 IP 后末节 | -| 4 | 侧面 | 掌壳侧面固定基准(最底下) | -| 5 | 侧面 | 左手食指/右手小指 MCP 后连杆 | -| 6 | 侧面 | 左手食指/右手小指 PIP 后连杆 | -| 7 | 侧面 | 左手食指/右手小指 DIP 后末节 | -| 8 | 上面 | 上面相机可见的掌壳/底座固定基准 | -| 9 | 上面 | 拇指 CMC yaw 运动件 | -| 10 | 正面 | 左手食指/右手小指根部侧摆运动件 | +空间入口 `core/fitting/spatial.py` 保留显式兼容导出,计算顺序在 +`spatial_solver/solve.py`:输入准备 → 仅训练求解 → 可观测性/轮间统计 → 冻结结果的独立验证 → 结果装配。 +`TrainingProblem` 不包含第四轮观测,训练几何中的掌部姿态也按训练轮筛选。 +基座策略由 Profile 的几何约束选择;曲线、零位坐标约定、权重、阈值和原始 URDF 授权修正规则不变。 -ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且可见, -ID 9 必须在拇指横摆的完整行程中持续可见。 -贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。 +PnP 的原导入路径继续可用。`tag_pose/parameters.py` 集中定义跟踪默认值, +ROS 加载器将角度参数由 deg 转为 rad,离线采集直接使用同一套默认值。 +公共分支容差仍为 `0.03 px`,重投影质量、倾角、过滤原因和连续跟踪恢复策略保持一致。 -每台相机必须有独立内参文件: +`CalibrationSession.phase` 是流程状态来源。协调器在状态锁内开始、中止、推进和提交观测; +耗时姿态计算在锁外进行,提交时核对会话版本、采集对象、运动对象及稳态采样边界。 +Start 更换预览采集对象并重新锁定基准,暂停/中止使尚未完成的旧观测失效。 +收尾由 `FinalizationController` 组合 worker 和发布器,worker 只上报阶段事件、准备候选文件; +协调器核实全部验收阶段后才能提交。锁顺序固定为状态锁 → worker 锁, +拟合失败维持当前姿态,中止后禁止提交,成功产物只提交一次。 + +老的单相机拇指/CMC 在线节点和 launch 已退出安装入口;需要局部测量时应增加经过验证的 Profile, +不能重新启用旧状态机。老版本离线诊断不是新的标准 URDF 发布证据。 + +## 新增型号 + +必需文件: ```text -~/.ros/camera_info/hikrobot_DB2163742.yaml -~/.ros/camera_info/hikrobot_DB2163749.yaml -~/.ros/camera_info/hikrobot_DB2163739.yaml +config/profiles/new_hand.yaml +config/new_hand_product.yaml +urdf/new_hand/raw.urdf +urdf/new_hand/meshes/* ``` -### 1. 一次性三相机外参 +Profile 声明: -三相机第一次安装、任何相机移动、镜头重新聚焦或内参变化后,必须重标外参。使用 -`8x5` 内角点、实测方格边长 `27 mm`、粘在硬质平板上的棋盘: +1. Adapter、SDK 通道顺序、单位、正负方向、命令/反馈范围、基准值、独立速度槽布局。 +2. Tag ID/尺寸/机位/安装 link,以及每次运动的父子观测关系;不明确的 link 不能靠名称猜。 +3. 扫描任务、速度、准备/避让/收尾 waypoint;必要时指定不可见 Tag 的恢复验证姿态。 +4. 哪些零位可观测、哪些固定保留 CAD、哪些参数同机构迁移;声明空间观测约束和拟合原语。 +5. 每关节允许修正的 `origin.rpy`、`limit.lower/upper`、`mimic.multiplier/offset`。 + +这仍需要正确的可观测性设计:单轴加一对未知安装角的 Tag 不能自动辨识绝对 CAD 零位。 +不能为了减少配置,默认把 SDK 零点或最大指令当成 CAD 零位。可参考最简单的 L6/O6 Profile, +删除不适用关系后重新声明;不要盲目复制另一型号的空间约束。 + +复用 SDK 不增加 Python;新协议只增加 `runtime/adapters/` 实现及协议工厂注册。 +没有型号节点、runner、状态机、质量/断点/发布器;新数学只能增加通用拟合原语。 +新型号使用 `artifacts.output_schema_version: 2` 选择统一指令查表格式;产物自身为 +`format: unified_calibration_v2, schema_version: 2`。详细双映射保留在独立标定报告中。 +旧 generic v1、v4/v6/v7 只保留历史兼容工具,不把反馈表改名为指令表来伪造兼容。 +正式 Profile 校验及 finalizer 只接受 `output_schema_version: 2`,不能用旧格式跳过指令映射拟合。 +配置错字、错误绑定、未知修正字段在启动前失败;修改受保护文件后应审查并更新对应 SHA256。 + +## 产物与离线验证 + +会话目录保存原始样本、冻结 Tag 安装、拟合诊断、标准 URDF 验收、JSON、URDF。 +有效发布由 `release_manifest.json` 及产品发布指针共同证明;单独的 `PASS` 摘要或存在 URDF 文件不等于已发布。 +manifest 记录 JSON、URDF、标定报告三文件 SHA256、受保护输入和独立 holdout 证据。任一验收失败都不会替换原发布指针。 +`measurement_contract.json` 提前列出 Tag/link、零位观测关系、CAD 保留/迁移与原始 mimic 限位冲突。 +主 JSON 以原始 URDF 关节名为键,每个关节仅包含 `sdk_channel` 和 `angle_rad`; +O12 等 rad 输入还包含一一对应的 `input_values`(在有效节点间线性插值,不外推)。 +字节型号固定 256 项:SDK 指令 `v` 对应 `angle_rad[v]`,只能在训练指令实际覆盖 0–255 时导出。 +输出均是修正 URDF 的 q_output,不再加零偏。顶层保留 `format/schema_version/profile_id/model/side/serial_number/input_unit`。 +被动关节的查表由导出 URDF 的标准 mimic 递归生成,绑定同一驱动通道,必须与 URDF 完全一致。 +单张查表取训练正反向指令曲线的平均值,再用实际导出表重放第四轮视觉数据;回差等因素导致超差时禁止发布, +不通过隐藏方向分支、外推、放宽精度或 JSON 非线性被动联动制造通过结果。 + +`calibration_report.json` 保存完整的 `command_to_rad` / `feedback_to_rad`、输入域、方向分支、 +零位、来源、迁移和训练/holdout 指标。报告中的 `applicability` 记录保持姿态; +未采集多姿态证据不能宣称任意多轴工况已验证。主 JSON 用于指令查表,不能误作反馈转换。 +旧 unified v1 会话继续只读兼容,不自动覆盖或转换历史产物;新输出单表必须重新通过最终文件验收。 + +统一消费桥可直接读取通过验证的 manifest(纯消息转换,不会发送实机命令): ```bash -mkdir -p /home/lxp/projects/linkerhand_retarget_ros2/config -ros2 launch linkerhand_calibration \ - three_camera_extrinsics.launch.py \ - output_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ - checkerboard_columns:=8 checkerboard_rows:=5 square_size_m:=0.027 +ros2 run linkerhand_calibration calibrated_joint_state_bridge --ros-args \ + -p calibration_file:= -p input_kind:=command \ + -p input_topic:= -p output_topic:=/calibration/verification/joint_states ``` -启动后默认打开 `G20 Three-Camera Extrinsics` 交互窗口。可切换 -`FRONT + SIDE` 和 `FRONT + TOP`;窗口实时显示棋盘角点、单相机/组合 -RMS、时间差、联合拟合稳定性和候选/内点数量。单张只要棋盘完整、 -同步、RMS和姿态差异合格,`ADD CANDIDATE` 就会变绿;不再用单张 -PnP的最终外参偏差锁死采集。点击 `AUTO ON` 后,棋盘稳定1秒会自动 -采集,移到新姿态后再自动采下一组。 - -外参分两组采集。让棋盘静止且同时出现在正面/侧面画面中,每改变一次位置和倾角添加 -一个候选;随后以相同方法采集正面/上面。程序使用固定内参的 -`stereoCalibrate` 联合优化唯一旋转/平移。采集准入和最终验收分离:FRONT和 -配对相机的单帧RMS分别不得超过1.5 px,候选组合RMS不得超过1.5 px;界面中 -单相机1.2 px以内显示绿色、1.2~1.5 px显示黄色且仍可采集、超过1.5 px显示红色。 -新姿态会与全部已采姿态比较,避免在少数姿态间反复采集。拟合先剔除粗大异常组, -再在不低于15个内点的前提下有界裁剪联合误差最高的候选,只有最终批次组合RMS -不超过1.2 px才允许保存。两组均得到 -至少15个内点且联合RMS、三折稳定性合格后 `SAVE` 才变绿。 +需要反馈角度显示时显式选择 `input_kind:=feedback`,桥读取配套报告中的反馈映射。 +桥检查三文件哈希、拒绝越域输入, +被动角由实际导出 URDF 计算,不加第二次零偏。仿真输出不能与实机反馈话题同名。 +MuJoCo 是用户后续使用的外部验收项目,本轮不改动它、不增加 MuJoCo 依赖,自动标定不要求打开 GUI。 +外部对比须确认加载的模型来自这份 URDF,指令转换使用配套 JSON;不能用实机反馈直接驱动仿真来证明指令映射。 ```bash -ros2 service call /g20_camera_extrinsics/capture_front_side std_srvs/srv/Trigger {} -ros2 service call /g20_camera_extrinsics/capture_front_top std_srvs/srv/Trigger {} -ros2 service call /g20_camera_extrinsics/save std_srvs/srv/Trigger {} +# 同一算法离线回放新策略的完整采集;不启动硬件,默认不发布 +ros2 run linkerhand_calibration calibrate_hand --config \ + --offline-raw --offline-output <新的输出目录> + +# 只比较两份 URDF,不修改,不控制机械手 +ros2 run linkerhand_calibration compare_calibration_urdfs \ + --reference <参考.urdf> --candidate <本次.urdf> --output <新报告.json> ``` -采集时可分别查看 `/g20_extrinsics/{front,side,top}/camera/image_rect`。界面始终 -显示当前配对的整批RMS、旋转稳定性和平移稳定性;`BATCH FAIL` 后会直接列出 -`INLIERS`、`RMS`、`ROT` 或 `TRANS` 失败项。保存门限为:联合重投影RMS不超过 -1.2 px、三折重拟外参最大旋转差不超过0.3°、最大平移差不超过1.5 mm。文件同时 -绑定三台相机序列号、分辨率和内参哈希;不满足任一项时不会保存通过结果,正式 -标定也不会运动。 - -### 2. 预检和正式标定 - -先使用禁止运动模式检查三个机位、外参、内参和标签: - -```bash -ros2 launch linkerhand_calibration \ - three_camera_calibration.launch.py \ - hand_type:=left \ - serial_number:=G20_LEFT_001 \ - camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ - commands_enabled:=false -``` - -分别查看正式流程的三个画面: - -```bash -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_calibration/front/camera/image_rect -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_calibration/side/camera/image_rect -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_calibration/top/camera/image_rect -``` - -确认全行程安全、MVS客户端已关闭且没有其他命令发布者后,重启正式流程: - -```bash -ros2 launch linkerhand_calibration \ - three_camera_calibration.launch.py \ - hand_type:=left \ - serial_number:=G20_LEFT_001 \ - camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ - can_interface:=can0 -``` - -原始URDF及其mesh随`linkerhand_calibration`安装,默认根据`hand_type`自动选择。 -如需调试其他CAD版本,仍可通过`source_urdf_path:=<绝对路径>`显式覆盖。 -右手使用同一入口,并自动选择右手SDK话题和原始URDF: - -```bash -ros2 launch linkerhand_calibration \ - three_camera_calibration.launch.py \ - hand_type:=right \ - serial_number:=G20_RIGHT_001 \ - camera_extrinsics_file:=/home/lxp/projects/linkerhand_retarget_ros2/config/g20_three_camera_extrinsics.yaml \ - can_interface:=can0 -``` - -状态显示三个机位均“就绪”后只调用一次: - -```bash -ros2 topic echo /g20_calibration/status_text -ros2 service call /g20_calibration/start std_srvs/srv/Trigger {} -``` - -收到 `start` 后,程序先下发并确认以下20通道基准姿态,稳定保持0.5秒后才开始 -第一条轨迹扫描: - -```text -[255, 255, 255, 255, 255, 255, 127, 127, 127, 127, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255] -``` - -左手依次扫描电机 `0/5/15/6/1/16/10`,右手依次扫描 -`0/5/15/9/4/19/10`,每项三轮 `255→0→255`。轨迹角由父/子Tag完整相对 -四元数的旋转向量投影到三维拟合轴得到。 -轴方向使用相对姿态旋转轴和可信上游轴约束;仅对斜视、非约束关节将中心圆作为独立 -交叉检查并参与融合。轴线上一点则由整段 -相对SE(3)轨迹的 `(I-R)p=t` 方程鲁棒拟合,不再把单目Tag中心自由三维圆的圆心直接 -当成机械轴心。正面/侧面端视关节只使用图像平面内可观分量,丢弃无法由单目确定的 -光轴深度;斜视轨迹仍保留姿态轴和独立三维圆轴的交叉检查。每条主动曲线在其baseline命令 -严格归零:普通通道255,四指侧摆127。左手将食指动态轨迹、右手将小指动态轨迹继承 -给其余三指。11-Tag布局只能可靠恢复参考指的动态命令—角度曲线,不能证明四根独立 -电机的绝对装配相位相同;因此四指全部MCP侧摆、MCP屈伸和PIP静态URDF零偏都保留 -原始CAD的0,只继承动态曲线,避免参考指弯曲或四指整体同向倾斜。 - -零位求解使用行程更充分的根轴方向和保持原始CAD直立的参考指MCP pitch实测轴方向确定 -掌部朝向;两条平行根轴线只确定平移,不再用其单目三维深度间距确定绕根轴的旋转,避免 -稳定PnP深度偏差被写成拇指roll零偏。另一条短行程根轴方向只作诊断。随后按两条运动链逐关节 -进行一维鲁棒求解,避免远端异常把已确定的上游零位一起拖到边界。非平行相邻轴先将上下游 -轴投影到父轴法平面,再计算精确有符号方位角;父子轴夹角是零位无法改变的几何不变量,偏差 -超过5°直接判定模型失败。平行相邻轴比较两轴之间的径向相位,三维路径忽略连杆长度和沿轴 -Tag位置,接近轴向观察时改用相机图像平面相位并丢弃单目PnP深度。轴线SE(3)拟合RMS超过 -1 mm也不允许写URDF。四指静态零位不参与相机相位覆盖,拇指可观测零偏上限20°; -小于0.3°或未超过3倍不确定度的稳定偏移保留原始零位0。 - -`thumb_mcp` 的动态角度曲线仍由电机15的三轮轨迹直接测量,但其绝对静态零位只可通过 -被动 `thumb_ip` 的轴线圆心相位间接推断。固定正面单目机位下这条浅圆弧的姿态轴/圆轨迹轴 -偏差可达数十度,重复性不能排除稳定系统误差,因此不得把该相位写入URDF;左右手 -`thumb_mcp` 都保留原始CAD零位0。该保护只冻结静态 `origin.rpy`,不会冻结或复制其 -`angle_rad[256]` 实测轨迹。 - -前两轮拟合,第三轮强制留出验证;轨迹与零位角度MAE必须≤1°、P95≤2°,三轮轴/零位 -差≤0.75°、径向RMS≤3 mm、轴线SE(3)残差≤1 mm。非零修正必须在第三轮优于原始URDF,并通过按三轮分组的 -训练周期Student-t 95%改善下界检查。最终门限不会因自动重试而放宽。 - -单轮姿态相对理想固定轴的轴外RMS与跨轮重复性分别判定:主动关节上限2.5°,被动 -耦合关节上限7.5°。较宽的被动模型门限只容纳可重复的机构耦合和双Tag PnP系统误差, -不会替代三轮轴方向≤0.75°和第三轮MAE/P95留出验证。 - -四指参考源的MCP pitch虽有约70°大行程,但侧面机位接近沿转轴观察,单目PnP深度偏差 -仍可能把低残差的Tag中心圆平面稳定地倾斜。因此MCP pitch与其他端视关节一样,始终用 -完整相对姿态确定轴方向,Tag中心轨迹只参与轴线位置拟合;不再按10°分界在两种轴模型 -之间切换。固定Tag安装旋转会在相对旋转中抵消,不需要中心圆回退。被动PIP/DIP继承 -上游轴方向时不重复报告同一项跨轮轴失败。 - -四指MCP侧摆的动态曲线仍由参考指三轮实测并继承,但绝对静态侧摆零位固定使用原始CAD -的0。仅凭下游pitch轴相对CAD掌坐标反推roll相位,会把稳定的跨视角/固定几何偏差写成 -约4°的整指倾斜;重复扫描与同源留出不能排除这种系统偏差,因此不得写入URDF。 - -四指MCP屈伸和PIP采用同一静态策略:参考指轨迹仍参与动态曲线、轴质量和机构诊断, -但拟合出的共同掌坐标相位不写入四指 `origin.rpy`;只发布各指相对四指中值的实测 -装配偏差。拇指CMC roll/yaw/pitch的非零修正来自当前会话的完整相对旋转行程与机械 -端点,视觉轴链继续用于轴线、PnP和留出诊断;代码和配置中不保存任何按左右手或 -序列号写死的拇指零位角。电机5的256点动态曲线同样使用本机四轮实测结果。 - -视觉依赖链仍为:yaw轴检查拇指roll、pitch轴检查拇指yaw、MCP轴线相位检查拇指 -pitch、IP轴线相位检查拇指MCP。该链用于几何和PnP诊断,不再决定四个具有机械端点 -的拇指主动关节绝对零位;四指PIP/DIP轴线相位也继续用于机构诊断。 -原始URDF的 `origin.xyz`、`axis.xyz`、连杆长度、mesh和被动结构固定。yaw扫描时电机5 -保持145,求解器使用实测 `angle_rad[145]` 还原该条件,不会把145误当成baseline。 -偏移超过各关节专用上限时整次失败。数值求解会在更宽的诊断范围内继续估计,因此状态和原始JSONL -会显示实际估计值及配置上限,而不是把所有超限结果都截断成恰好±20°或±3°;该诊断搜索 -不会放宽正式结果的硬门限。 - -生成修正URDF时只修改通过验收的主动关节 `origin.rpy`,不会修改任何关节的 -`origin.xyz`、转轴、mimic关系或原始CAD/机械安全限位。256项实测轨迹只保存在最终 -JSON;任一曲线点越过CAD限位都会阻止正式发布,程序不会自动扩大URDF限位。 - -坏帧只丢弃。短时Tag丢失、同步帧中断、扫描超时、端点/分箱不足会自动保持当前位置、 -重置当前机位PnP、返回基准后重扫当前方向,最多3次;速度依次降为80%/60%/50%, -端点保持延长到0.75/1.0/1.25秒,扫描超时按降速比例同步延长。若反馈在远离目标时 -连续8秒没有至少1个u8的进展,则按机械碰撞/摩擦或硬件故障立即保持当前反馈位置并 -暂停,不消耗三次采样重试预算。单轮拟合失败只重扫该轮两个方向,全局不一致才重扫 -完整关节,每关节最多自动重采2轮。过程指标落在最终门限的1.25倍内时会标记为黄色 -预警,但只要仍超过硬门限,就在当前关节立即使用剩余重试预算 -(`provisional_fit_warning_rescan`);第三次仍超限则当场暂停,不允许预警数据继续到 -后续关节。最终拟合仍按原硬门限验收,因此不会在全部任务采完后才回头重采靠前 -关节。零位触边、稳定留出误差或URDF几何无法解释属于模型失败,程序 -只暂停一次且不再自动重扫,防止重复运动;此时也拒绝`resume`形成死循环。其他可恢复 -失败在预算耗尽后才暂停,`resume`从最小失败单元继续,已通过数据保留。所有失败尝试 -仍保存在 `raw_samples.jsonl`。若连续两次完整重扫出现轮次和数值都重复的 -PnP双簇行程,程序将它判为系统性分支失败并当场停止,不再浪费第3次全关节重扫。 - -每个新机位/Tag组合开始运动前,不使用单个端点帧直接决定平面Tag的IPPE姿态分支。 -程序在静止端点联合8帧候选,按相邻Tag相对姿态的跨帧稳定性和重投影误差选择整组 -分支;侧面Tag 4/5/6/7贴面在该端点应近似平行,初始化还会比较相邻Tag法向,避免 -错误镜像分支虽然8帧稳定且重投影很小仍被选中。每轮 `255→0` 前都会在静止端点独立 -重置并重新选择分支,使第三轮同时成为PnP初始化留出,而不是三轮共享同一错误分支。 -再开始正式轨迹采集。初始化帧不写入轨迹;最终单轴、跨轮和留出门限不变。 - -左手测食指roll时将电机7/8/9固定到0;右手测小指roll时因左右手侧摆机构镜像, -将电机6/7/8固定到255。两者均为相机画面向右的物理避挡方向,速度分别为 -`[15,5,15,15,15]` 和 `[15,15,15,15,5]`。参考指MCP pitch/PIP扫描分别使用 -电机1/16(左)或4/19(右),参考指速度10。 - -右手扫描拇指CMC俯仰(电机0)前,程序将拇指横摆电机10和拇指侧摆电机5都固定到 -255,确认两个辅助关节到位后才允许电机0执行全行程。该关节由正面机位使用掌部 -Tag 0和运动Tag 1同帧测量。每帧都会保留两个辅助关节 -的实测条件值,轴线经外参转换到公共坐标系,零位求解按URDF上游关节链补偿;左手 -仍沿用原有正面机位和基准姿态。 - -标定 `thumb_cmc_yaw`(电机10)时,程序将 -`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag -ID 9的可见性和PnP稳定性。当前方向自动重试、失败轮次重试和人工 `resume` 都保持 -电机5为145,只让电机10返回待重扫方向的起点;电机10全部三轮完成后,电机5才 -恢复基准值255。自动恢复直接发送恢复目标,不会短暂发送保持当前位置命令;操作员 -暂停/终止、恢复预算耗尽或机械停滞时仍保持当前位置。最终JSON的 -`baseline_command_u8` 不变。 - -右手标定小指PIP(电机19)时,命令0对应的固件反馈可能稳定饱和在5。只有电机19 -的0端使用±5反馈容差,并将该实测机械端点归入命令0端点分箱;255端和其他电机仍 -使用默认±2。轨迹仍须覆盖至少240个u8并通过完整拟合门限,所以中途卡滞不会被误判 -为端点到达。 - -右手拇指横摆电机10在命令255时多次实测稳定饱和在250,因此仅右手电机10的255端 -使用±5反馈容差;其命令0端实测反馈为4,仍使用±4,其他电机和中间位置不放宽。基准姿态、作为 -电机0辅助避挡姿态以及电机10自身扫描端点都使用同一条专用判定。 - -三机位流程默认 `validation_enabled:=false`,即不增加随机机械动作,但第三轮留出验证 -始终启用且不能关闭;最终 `quality.validation_mae_rad/p95_rad` 正是第三轮轨迹误差。 -状态中的扫描进度和总体进度分开显示:42/42只表示计划轨迹已采完,总体进度在拟合和 -验证完成、正式JSON与URDF成功写入之前不会显示100%。 - -上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag -和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求 -当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。 - -### 3. 输出 - -通过后生成精简JSON和一个新URDF: - -```text -calibration_output/G20_LEFT_001/<时间戳>/ - g20_left_G20_LEFT_001_calibration.json - g20_left_G20_LEFT_001_calibration_urdf_correction_input.json - linkerhand_g20_left_zero_calibrated_G20_LEFT_001_<时间戳>.urdf - meshes/*.STL - -calibration_output/G20_RIGHT_001/<时间戳>/ - g20_right_G20_RIGHT_001_calibration.json - g20_right_G20_RIGHT_001_calibration_urdf_correction_input.json - linkerhand_g20_right_zero_calibrated_G20_RIGHT_001_<时间戳>.urdf - meshes/*.STL -``` - -`*_urdf_correction_input.json` 会在修正 URDF 之前落盘并重新读取,且绑定源 URDF -哈希、型号、侧别、layout 和序列号。公开 schema-v4 运行 JSON 仍保持原格式,原有 -G20 运行桥、金标准哈希和 URDF 写回数值不因该交接层改变。 - -文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、 -`zero_angles.urdf_zero_offset_rad`、5个被动标记、模板来源和总体质量。新URDF每次从 -指定原始CAD文件生成,采用 `T_original × Rot(axis, offset)`,绝不叠加旧校准文件或 -覆盖原文件;只有通过独立求解验证或明确机械装配基准授权的主动关节 `origin.rpy` 可能 -改变;当前19-Tag右手的16个主动静态零位字段全部由本会话数据求解;四指DIP和 -`thumb_ip`为被动关节,发布实测动态曲线并保留CAD静态零位,其mimic坐标偏置只随 -上游主动关节坐标系变换作等价调整。 -未观测关节和其他URDF文本保持不变。源URDF中的相对mesh资源会按原相对路径复制到 -同一会话,保证会话内URDF可独立加载,并在正式发布时逐文件记录SHA256。每帧Tag SE(3)、 -图像时间戳、20通道状态、同步误差和PnP误差只进入 `raw_samples.jsonl`。 - -完整 `raw_samples.jsonl` 已存在时,可以按当前算法离线重放,不连接相机、不发送电机 -命令。`--output-tag` 为新产物增加安全后缀,已有JSON、URDF和验证报告不会被覆盖: - -```bash -python3 -m linkerhand_calibration.offline_replay \ - calibration_output/G20_RIGHT_001/20260811_120146 \ - --output-tag AXIS_FRAME_V3 \ - --write -``` - -下面保留原有正面拇指独立标定说明和兼容入口。 - -### 4. 修正URDF的运行时关节映射 - -修正URDF已经把 `zero_angles.urdf_zero_offset_rad` 写入关节 -`origin.rpy`。仿真运行时只能再使用同一台、同一侧机械手JSON中的256点 -`angle_rad` 动态曲线,不能把 `urdf_zero_offset_rad` 再加一次,也不能把左手曲线 -用于右手URDF。可用桥接节点将GUI的20通道u8命令转换为完整21关节 -`JointState`(包括5个被动关节): - -```bash -ros2 launch linkerhand_calibration calibrated_joint_state_bridge.launch.py \ - hand_type:=right \ - calibration_file:=$PWD/calibration_output/G20_RIGHT_001/20260811_120146/g20_right_G20_RIGHT_001_calibration.json -``` - -schema v5默认订阅 `/g20/cb_right_hand_state`;schema v4默认订阅 -`/g20/cb_right_hand_control_cmd`。两者均发布 -`/sim/mujoco/g20/right/joint_state`。启动前必须停止任何旧的同名话题桥,避免两个 -发布者同时驱动仿真。节点会拒绝左右手不匹配、质量未通过、字段不完整或非有限命令, -因此不会静默退回旧标定。 - -该包启动海康机器人 MVS USB3 Vision 黑白相机、图像校正、`apriltag_ros`、 -Linker Hand SDK 和标定状态机, -只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次 -终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置, -程序把每帧 AprilTag 三维中心与同一时刻的实际电机位置插值配对并按整数位置分箱。 -完成 `255→0→255` 后分别拟合正反方向并检查回差,最终运行时 JSON 将两条曲线逐点 -平均,只为每个关节保存一个 256 项 `angle_rad`。最后用 5 个随机静态命令复测精度。 - -当前默认使用 `trajectory_center_3d`。节点由四个亚像素角点和 `CameraInfo.P` -计算每张 Tag 的三维中心,但不把小尺寸平面 Tag 的 PnP 朝向直接当作关节角: - -- 根部扫描先减去掌心 T0 的位置,再用 T3/T4/T5 三条圆轨迹共同拟合 CMC 旋转轴; - 每帧三个角度取中位数。 -- 尖部扫描用 T4 相对 T3 的圆轨迹直接拟合 MCP。G20 只有电机 15 这一个尖部输入, - URDF 将被动 IP 定义为 `thumb_ip = 1.02 × thumb_mcp`,因此运行时 IP 曲线严格按 - 这个机械耦合生成。这样不会把不同相机角度下 T5 的平面 PnP 深度偏差误认为 IP - 真实运动。 -- 程序仍会按 MCP 角将 T5 反向旋转并拟合剩余小圆,但该结果只用于 - `trajectory_center_quality.tip` 中的观测一致性诊断,不参与最终 IP 数组。 -- 每条曲线都减去命令 255 的测量角,所以最终文件严格满足 - `angle_rad[255] == 0.0`;`angle_rad[0]` 是该关节相对零位的最大角度。 - -这种方法对固定的相机摆放角度、Tag 在同一刚性连杆上的固定位置和贴纸朝向更不敏感。 -但相机或贴纸在一次扫描过程中移动、Tag 翘起、角点严重抖动仍会破坏圆轨迹。程序会 -检查平面残差、圆残差、轨迹半径、实际弧长和根部三个轨迹点的角度一致性。 - -PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维中心及辅助质量检查,不再 -直接生成运行时角度。根部扫描用固定的 T3–T4、T4–T5 中心间距共同选择分支; -尖部扫描用固定的 T0–T3 中心间距约束非目标部分。中心间距漂移超过阈值仍会暂停, -避免错误中心进入圆拟合,但 Tag 的 PnP 朝向抖动不会触发该门限。 - -## 1. 标记和安全检查 - -- `T0` 必须保留并固定在掌壳,作为整体平移参考;`T3` 固定在拇指根部运动连杆,`T4` 固定在 MCP 后的连杆, - `T5` 固定在最末节。四张 Tag 必须与所在刚性件完全固定,不能跨关节或贴在软胶上。 -- 当前实物使用 `tag36h11` 的 ID `0/1/2/3`,依次对应 T0/T3/T4/T5。如果实物 ID 改变,同时修改 - `config/front_tags.yaml` 里检测节点和标定节点的两组数组。 -- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前实物黑色正方形实测为 - `16 mm`,因此配置为 `0.016`。 - 测量检测角点所围成的正方形边长,不包含外围白色留边。 -- 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由 - 静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时, - 启动参数增加 `publish_debug_image:=true`,再订阅 - `/g20_thumb_calibration/debug_image`;正式长时间扫描建议保持默认关闭。 -- 执行全行程前清空拇指周围空间并准备断开电机电源。确认这只手的下标 0 和 15 - 均可安全走完整 `255→0→255`。标定节点发现命令话题上另有发布者时不会解锁扫描。 - -## 2. 安装与构建 - -```bash -sudo apt-get update -sudo apt-get install -y \ - ros-jazzy-image-pipeline \ - ros-jazzy-apriltag-ros \ - ros-jazzy-apriltag-msgs \ - ros-jazzy-camera-calibration \ - python3-yaml - -cd /home/lxp/projects/linkerhand_retarget_ros2 -source /opt/ros/jazzy/setup.bash -colcon build --symlink-install \ - --packages-select linker_hand_ros2_sdk linkerhand_calibration -source install/setup.bash -``` - -相机节点直接使用海康 MVS SDK。当前机器的默认安装位置是 `/opt/MVS`,需要存在: - -```text -/opt/MVS/lib/64/libMvCameraControl.so -/opt/MVS/Samples/64/Python/MvImport/MvCameraControl_class.py -``` - -正面相机默认按序列号 `DB2163742` 绑定(MVS 显示的 GUID 是 -`2BDFB2163742`),型号校验为 `MV-CS020-10UM`。三台相机同时连接时程序不会按枚举 -顺序猜测机位。启动 ROS 节点前必须关闭 MVS 客户端中的相机连接,否则设备可能被占用。 - -`1624x1240 mono8` 每帧约 2.0 MB,超过 Fast DDS 2.14 默认约 512 KB 的共享内存段。 -三相机标定 launch 会固定使用 `rmw_fastrtps_cpp`,并通过新旧两个 Fast DDS 环境变量 -加载 `config/fastdds_large_images.xml`,使用 64 MB 共享内存段;否则相机内部虽为 30 Hz, -大图订阅端通常只能收到约 1~4 Hz。修改配置后必须重启相关 ROS 进程才能生效。 - -首次使用必须先标定该相机和当前镜头的内参。主 launch 默认从 -`~/.ros/camera_info/hikrobot_DB2163742.yaml` 加载标准 ROS CameraInfo YAML;文件缺失时 -仍可预览 `mono8` 原图,但发布的内参无效,轨迹标定预检不会解锁运动。 - -先单独启动相机(不会连接机械手,也不会发送关节命令): - -```bash -ros2 run linkerhand_calibration hikrobot_camera_node --ros-args \ - --remap __ns:=/camera/camera/color \ - -p serial_number:=DB2163742 \ - -p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml -``` - -测速时优先检查同帧发布的小消息和原图;两者正常值都应接近 30 Hz: - -```bash -ros2 topic hz /camera/camera/color/camera_info -ros2 topic hz /camera/camera/color/image_raw -``` - -使用标定板采集内参。下面的 `8x6` 是内角点数量、`0.020` 是单格边长 20 mm,必须按 -实际标定板修改: - -```bash -ros2 run camera_calibration cameracalibrator \ - --size 8x6 --square 0.020 \ - --camera_name hikrobot_front_DB2163742 \ - --ros-args \ - --remap image:=/camera/camera/color/image_raw \ - --remap camera/set_camera_info:=/camera/camera/color/set_camera_info -``` - -在标定界面完成采样后点击 `CALIBRATE`,确认重投影误差,再点击 `COMMIT`。相机节点会 -原子写入上述 YAML,并立即开始发布有效内参。内参只适用于标定时的镜头焦距、对焦、 -分辨率和 ROI;改变任何一项都要重新标定。 - -连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布 -`/g20/cb_left_hand_control_cmd` 的程序。 - -## 3. 启动和操作 - -首次使用时可先用 `commands_enabled:=false` 做预检;SDK 仍会设置速度/扭矩并读取状态, -但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描: - -```bash -ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \ - serial_number:=G20_LEFT_001 \ - camera_serial_number:=DB2163742 \ - commands_enabled:=false -``` - -确认 T0、T3、T4、T5 在根部和尖部全行程中不会被遮挡,且拇指运动不会碰撞后, -停止预检并启动一个新的正式会话。默认使用 AprilTag 内部 `decimate=1.5` 提升检测 -速度,并使用单终点连续运动: - -```bash -ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \ - serial_number:=G20_LEFT_001 \ - camera_serial_number:=DB2163742 \ - can_interface:=can0 \ - calibration_speed:=15 \ - continuous_motion_mode:=endpoint \ - angle_estimation_mode:=trajectory_center_3d \ - apriltag_decimate:=1.5 \ - use_roi:=false -``` - -默认关闭 ROI,AprilTag 使用完整的 1624×1240 校正画面。查看实际送入 AprilTag -的完整画面: - -```bash -ros2 run image_view image_view --ros-args \ - --remap image:=/camera/camera/color/image_rect -``` - -图像检测链路使用 `sensor_data`(BEST_EFFORT)QoS,只保留最新帧,避免完整分辨率 -下可靠队列积压反压相机;这不会裁剪图像,也不会降低相机分辨率。 - -若以后需要以帧率优先,可传入 `use_roi:=true`;默认 ROI 是原图中的 -`x=128, y=192, width=1024, height=528`,也可用 `roi_x`、`roi_y`、 -`roi_width`、`roi_height` 覆盖。 - -监控状态: - -```bash -ros2 topic echo /g20_thumb_calibration/status -``` - -预检通过后状态为 `WAIT_ROOT_CONFIRM`,`reason` 为 `call_start`。只需调用一次: - -```bash -ros2 service call /g20_thumb_calibration/start std_srvs/srv/Trigger {} -``` - -节点随后自动完成下标 0 的 `255→0→255`、下标 15 的 `255→0→255` 和 5 点随机复测, -正常结束状态为 `COMPLETE`,无需在根部和尖部之间再次确认。为安全起见,调用 `start` -前必须一次性确认两个关节的完整行程都已清空。原来的 -`confirm_root_full_range`、`confirm_tip_full_range` 服务仍保留用于兼容。 - -暂停、恢复和终止: - -```bash -ros2 service call /g20_thumb_calibration/pause std_srvs/srv/Trigger {} -ros2 service call /g20_thumb_calibration/resume std_srvs/srv/Trigger {} -ros2 service call /g20_thumb_calibration/abort std_srvs/srv/Trigger {} -``` - -预检要求四 Tag 有效帧率至少 95%,且检测消息频率至少 15 Hz。PnP 有效率也必须 -至少 95%,每个候选解的重投影 RMS 不超过 1.5 px。中心轨迹模式以三组相对中心 -的静止 RMS 不超过 2 mm、5 mm 范围内位置内点不少于 90% 为硬判据;PnP 朝向抖动 -只作为诊断,不会阻止静态捕获。 -状态中的 -`pnp_rejections` 会指出当前是哪张 Tag 因丢失、重投影/倾角超限或姿态跳变而被拒绝, -`pnp_reprojection_error_px` 显示四张 Tag 最近一次有效解的误差。连续扫描要求 -图像与状态的时间差不超过 150 ms、全行程至少得到 40 个有效帧、 -至少覆盖 32 个整数位置且相邻实测位置间隔不超过 16。Tag 或同步状态持续丢失 3 秒、 -90 秒内未到达终点,或覆盖不足时,节点保持当前命令并进入 `PAUSED`。恢复时会先回到 -该方向的起点,再完整重扫这个方向,避免把半程数据混入结果。`abort` 也只停止队列, -不会主动移动机械手。正常扫描和随机复测最后一项均为命令 255。 - -PnP 跟踪在整个会话中对四张 Tag 都优先保持同一个 IPPE 平面分支;最多 5 秒的短暂检测 -间隔不会重新初始化分支。随机复测只有在同步电机反馈与目标相差不超过 2、且稳定 -窗口与捕获窗口内三个相对中心的最大偏差都不超过 3 mm 时才会写入,否则继续等待并最终暂停, -不会再生成明知不可靠但字段完整的结果。 - -单终点连续模式共有 4 个端到端命令:根部和尖部各一个往返。每个方向运动前会先用 -实际电机反馈确认已经到达起点,再做一次短暂静态确认;随机验证的“接近位置”只等待 -电机反馈到位,不再重复采图。若实际 AprilTag 检测仍低于 15 Hz,先优化检测链路, -不要降低到固件低速区。必须临时回退时可启动 -`continuous_motion_mode:=paced`,该模式按步长 8 到位即发下一段。 - -连续扫描中的主要状态字段: - -- `state_zh`/`reason_zh`/`action_zh`:当前阶段、失败原因和下一步操作的中文说明; - 原有 `state`/`reason` 英文机器码继续保留。 -- `tag_quality`:逐张显示 T0/T3/T4/T5 的边长、hamming、识别置信度、重投影误差、 - 是否有效和具体中文问题,不再需要手工解析 `/apriltag/detections`。 -- `/g20_thumb_calibration/status_text`:适合终端直接查看的多行中文状态。使用 - `ros2 topic echo --once /g20_thumb_calibration/status_text --field data` - 即可看到原因、建议及四张标签的质量。 -- `scan_progress`:4 个方向的完成比例,依次约为 0、0.25、0.5、0.75、1.0。 -- `sweep_valid_frames_seen`:当前连续方向已收到的同步有效帧数。 -- `sweep_state_span_u8`:当前方向实际覆盖的电机范围,接近 255 才算完整。 -- `active_phase`/`active_direction`:当前是根部或尖部、下降或上升方向。 -- `pnp_branch_corrections`:四张 Tag 联合跟踪为维持相邻关节姿态连续,而没有选择 - 单张 Tag 最小重投影分支的累计次数。 -- `pnp_trajectory_quality`:最近一个完整方向的整段分支修正帧数,以及相对整段稳健 - 参考的旋转、相对平移和中心间距漂移。中心轨迹模式只按欧氏中心间距判断: - P95 超过 3 mm 或单帧最大值超过 6 mm 时暂停;旋转及随 Tag 坐标轴表达的相对平移 - 只保留为诊断。 -- `trajectory_center_quality`:四个方向完成并拟合后,显示三维平面/圆残差、拟合半径、 - 实际弧长、T0/T3 锚点漂移和根部三个轨迹点的角度一致性。其中 - `tip.ip_observed_vs_constrained_*` 显示T5残余小圆与URDF被动耦合之间的差异; - 它用于发现T5识别误差、标签松动或机构异常,但不会改变最终IP曲线。 - -根部扫描中 T3/T4/T5 作为完整刚性组共同选择 IPPE 分支,不再把 T3 固定为在线解; -尖部扫描仍固定 T3,只用静止的 T0/T3 约束修正非目标根部姿态。 - -## 4. 中断恢复和输出 - -默认会话目录是启动命令当前目录下: - -```text -calibration_output/<序列号>/<时间戳>/ -``` - -恢复时必须显式复用原目录,否则会创建新会话: - -```bash -ros2 launch linkerhand_calibration front_thumb_calibration.launch.py \ - serial_number:=G20_LEFT_001 \ - session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000 -``` - -恢复会校验序列号、Tag 配置、基准命令、扫描模式、采集参数和代码哈希; -任一项变化都会拒绝混用旧样本, -此时应新建会话。 - -目录内文件: - -- `raw_samples.jsonl`:连续帧按实际整数电机位置分箱后的 Tag 三维中心、姿态辅助统计及复测点;每完成一个 - 扫描方向后落盘。 -- `checkpoint.json`:当前状态和进度。 -- `session_manifest.json`:Tag、相机内参、SDK、代码哈希和会话信息。 -- `validation.json`:随机复测及全部质量判据。 -- `rosbag/`:仅在 `record_bag:=true` 时生成,用于保存相机、检测、命令和状态等诊断数据。 -- `g20_left_<序列号>_thumb_angle.json`:精简后的运行时标定文件。 - -最终文件使用 `schema_version: 2`。每个关节只包含: - -```json -{ - "motor_index": 0, - "angle_rad": ["按命令0~255索引的256个弧度值"] -} -``` - -`thumb_ip.angle_rad` 由 `thumb_mcp.angle_rad` 乘 -`ip_coupling.multiplier`(默认 `1.02`)得到,二者在命令255处都严格为零。 - -`thumb_ip` 另外包含 `"passive": true`。正反方向原始曲线不进入最终 JSON,但仍保留 -在 `raw_samples.jsonl` 中,并用于最大回差和质量判定。 - -零位和最大角度可直接读取: - -```python -import json -from pathlib import Path - -data = json.loads(Path("g20_left_G20_LEFT_001_thumb_angle.json").read_text()) -for name, joint in data["joints"].items(): - print(name, "zero(rad)=", joint["angle_rad"][255], - "max(rad)=", joint["angle_rad"][0]) -``` - -如果相机或 SDK 已由外部进程启动,可传 -`start_camera:=false` 或 `start_sdk:=false`。`camera_serial_number` 同时接受 MVS -序列号和 GUID,但推荐使用稳定且简短的序列号 `DB2163742`。 - -海康相机默认输出 `1624x1240@30Hz mono8`,全局快门,曝光时间 `5000us`、增益 -`0dB`,并使用“只取最新帧”策略避免视觉延迟。现场亮度不足时优先增加照明;必要时可用 -`exposure_time_us`、`gain_db` 调整,或临时传 `auto_exposure:=true`。正式轨迹采集建议固定 -曝光,避免自动曝光在运动过程中改变角点质量。rosbag 默认关闭;需要诊断留档时增加 -`record_bag:=true`。 - -默认对完整 1624×1240 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用 -`image_rect` 的角点及同一条处理链对应的 `CameraInfo`,启动文件已自动保证二者配对。 -校正和 AprilTag 组件运行 -在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。 -可选 ROI 模式会额外在同一容器内加入裁剪组件并同步修正 `CameraInfo`。标定节点默认 -不订阅整幅图像,只订阅检测结果和 TF。 -若启用调试图,预览会缩放到 50%、限速 10 Hz 并使用最新帧优先的传输方式, -不影响 AprilTag 的 ROI 输入。 - -静态预检先在单 Tag 层拒绝高重投影误差,再检查三组相对中心的位置内点率和毫米级 RMS。 -当前末节16 mm Tag如果中心位置 RMS 持续不合格,应优先增加照明、缩短 -相机距离或提高 Tag 有效像素,而不是放宽最终随机复测精度。 - -启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像 -订阅者;同时使用 MCAP `zstd_fast` 压缩并按 10 GiB 分卷。快速标定通常不需要录制; -若用于正式可追溯验收,再启用并检查磁盘空间。 - -## 5. CMC Pitch 零位角测量 - -只测量命令 255 时 `thumb_cmc_pitch` 的画面水平投影零位角时,使用独立启动文件。 -它只拟合 CMC 的二维零位轨迹圆,不运行完整 0~255 角度映射,也不会生成或修改 -URDF: - -```bash -ros2 launch linkerhand_calibration \ - front_cmc_pitch_zero.launch.py \ - serial_number:=G20_LEFT_001 -``` - -该流程只要求 T0(ID 0)和 T3(ID 1)有效。T4/T5 可以留在手上,但丢失不会阻塞。 -预检完成后查看中文状态: - -```bash -ros2 topic echo --once --full-length \ - /g20_thumb_cmc_pitch_zero/status_text \ - --field data -``` - -状态显示“等待开始”后启动三轮测量: - -```bash -ros2 service call \ - /g20_thumb_cmc_pitch_zero/start \ - std_srvs/srv/Trigger {} -``` - -查看带红色画面水平线、T0/T3标签中心、青色轨迹点、紫色拟合圆心和径向零位线 -的调试画面: - -```bash -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_thumb_cmc_pitch_zero/debug_image -``` - -画面底部红线是固定的相机水平与构图目标。程序会在画面下部自动寻找一条足够长、 -接近水平的物理桌边或高对比参考直线,并画成蓝线。开始前调整相机,使蓝线与红线 -重合;画面和 `status_text` 会实时显示红蓝线夹角及垂直偏差。`±0.5°` 和 -`±12 px` 只用于显示 `ALIGNED/ADJUST`,完全不参与预检或 `start` 服务判断。 -由操作人员确认相机位置后手动开始标定。参考直线应清晰、连续并尽量横跨画面; -该检测不使用 T0/T3 标签朝向。 - -一条二维直线只能确认相机滚转角和上下构图位置,不能单独证明相机的距离、俯仰、 -偏航或完整三维位置。若需要严格复现这些量,还应使用固定相机支架或专用标定板。 - -每轮只控制电机 0 执行一次 `255→64` 端点运动和一次 `64→255` 返回运动。运动期间 -连续采集 `T3中心−T0中心`,按机械手状态分箱后拟合图像平面圆;返回 255 后使用 -“T3零位中心→拟合圆心”的固定内向径向矢量计算角度。运动前和返回后各采集30帧静态零位, -三轮轨迹合并后得到最终圆心。其他 19 个命令保持固定基准。任何其他节点同时发布 -`/g20/cb_left_hand_control_cmd` 时,`start` 服务会拒绝启动。 - -T0中心用于消除相机或整只手的平移抖动。T0和T3标签自身的朝向与角点 `+x` -都不参与零位或行程计算;标签可以任意平面内旋转或反贴180°,只需标签平整、 -固定且中心始终可见。若轨迹跨度、圆弧、半径、径向RMS/P95或回零误差不合格, -节点暂停或写出 `quality.passed=false`。 - -完成后只生成: - -```text -calibration_output/G20_LEFT_001/<时间戳>/ - g20_left_G20_LEFT_001_thumb_cmc_pitch_zero.json -``` - -核心字段是: - -```text -zero_angles.table_projected_zero_rad -``` - -该值是内向径向零位矢量相对相机画面水平向右方向的角度。它不使用 T0 的方向, -但会用 T0 中心抵消平移;它仍不是真实三维桌面检测,因此会随相机滚转和机械手 -摆放改变。 - -## 6. CMC Roll 零位与行程标定 - -`thumb_cmc_roll` 复用上节的 T0 平移补偿、T3 中心轨迹分箱和稳健圆拟合, -但控制的是电机 5。每轮执行 `255→0→255`:在 255 零位、0 行程端点和返回 -255 后各静态采集 30 帧,因此可以同时测量零位角和完整 `0~255` 实际角行程。 -命令 0 是完整行程端点,开始前必须确认拇指没有机械碰撞或硬限位顶死风险。 -Roll同样固定使用“T3中心→拟合圆心”的内向径向矢量,不读取T3标签朝向。 - -```bash -ros2 launch linkerhand_calibration \ - front_cmc_roll_calibration.launch.py \ - serial_number:=G20_LEFT_001 -``` - -预检通过后启动三轮标定: - -```bash -ros2 topic echo --once --full-length \ - /g20_thumb_cmc_roll_calibration/status_text \ - --field data - -ros2 service call \ - /g20_thumb_cmc_roll_calibration/start \ - std_srvs/srv/Trigger {} -``` - -调试画面: - -```bash -ros2 run image_view image_view --ros-args \ - --remap image:=/g20_thumb_cmc_roll_calibration/debug_image -``` - -Roll 使用与 Pitch 相同的红蓝参考线显示,但是否对齐由操作人员确认,程序不会用 -蓝线状态阻止 `start` 进入电机运动。 - -完成后生成: - -```text -calibration_output/G20_LEFT_001/<时间戳>/ - g20_left_G20_LEFT_001_thumb_cmc_roll_zero_travel.json -``` - -核心输出字段: - -```text -zero_angles.table_projected_zero_rad -travel.signed_rad -travel.range_rad -``` - -`travel.signed_rad` 是从命令 255 到 0 的有符号转角,`travel.range_rad` 是三轮 -行程大小的中值。只有轨迹圆质量、T0/T3 检出率、三轮零位/行程一致性、端点径向 -误差和回零误差全部通过时,`quality.passed` 才为 `true`。 +离线回放也检查原始会话策略、SDK 输入域、配置/相机证据、每方向完整数据及最终文件。 +旧会话不能自动获得当前哈希或新版本认证。`--publish-offline` 必须显式给出,且不会绕过验证。 +算法和坐标规则见 [CALIBRATION_FLOW.md](CALIBRATION_FLOW.md),批准的原始方案保持在 +[CALIBRATION_REFACTOR_PLAN.md](CALIBRATION_REFACTOR_PLAN.md)。 + +当前代码审查、已修复问题与未完成的精度验收见 +[2026-09-10 需求审查](CALIBRATION_CODE_REVIEW_20260910.md)。相机图像目前采用主机取帧时间; +USB/驱动积压情况下,图像与反馈的时间戳接近不能证明曝光时刻同步,不能据此宣称实机精度通过。 diff --git a/src/linkerhand_calibration/config/calibration.yaml b/src/linkerhand_calibration/config/calibration.yaml index 8b660ab..a5a66ca 100644 --- a/src/linkerhand_calibration/config/calibration.yaml +++ b/src/linkerhand_calibration/config/calibration.yaml @@ -60,11 +60,8 @@ g20_thumb_calibration: # 30-38 px tags are usable, but only if IPPE gives a tight image fit and # a pose continuous with the preceding frame. pnp_maximum_reprojection_error_px: 1.5 - # All four tags keep a temporally continuous IPPE solution throughout the - # complete session. With 30 px planar tags, tiny reprojection differences - # do not reliably identify the physical branch and previously caused - # stationary T0 to flip by about 25 deg between scan and validation. - pnp_reprojection_tie_px: 1.5 + # Independent IPPE selection inherits the shared near-tie tolerance from + # core/geometry/pnp.py; continuity cannot override a clear image-fit lead. pnp_maximum_pose_jump_deg: 35.0 pnp_maximum_translation_jump_m: 0.04 pnp_maximum_tag_tilt_deg: 75.0 diff --git a/src/linkerhand_calibration/config/g20_right_product.yaml b/src/linkerhand_calibration/config/g20_right_product.yaml index 936538c..c1f98bb 100644 --- a/src/linkerhand_calibration/config/g20_right_product.yaml +++ b/src/linkerhand_calibration/config/g20_right_product.yaml @@ -1,4 +1,7 @@ -schema_version: 1 +schema_version: 3 +profile_id: G20/right/g20_right_19/v1 +profile_config: package://linkerhand_calibration/config/profiles/g20_right_19.yaml +profile_config_sha256: 35cad4432a01b3e1af157f1e914c2054e71522a44c6786db2519e4c2a0966a46 model: G20 side: right tag_layout: g20_right_19 @@ -26,7 +29,7 @@ artifacts: camera_extrinsics: config/g20_three_camera_extrinsics.yaml camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80 calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml - calibration_config_sha256: afb323494140c88ab6368a061332fd80724a86831f44505dcfab147234e3c4be + calibration_config_sha256: 4927506d787d665c16f0209ee4d052654e648bb25beaa1913ed0d416f449d523 tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41 diff --git a/src/linkerhand_calibration/config/l6_right_product.yaml b/src/linkerhand_calibration/config/l6_right_product.yaml index 5229478..3ecfddc 100644 --- a/src/linkerhand_calibration/config/l6_right_product.yaml +++ b/src/linkerhand_calibration/config/l6_right_product.yaml @@ -1,5 +1,7 @@ -schema_version: 2 +schema_version: 3 profile_id: L6/right/l6_right_8/v1 +profile_config: package://linkerhand_calibration/config/profiles/l6_right_8.yaml +profile_config_sha256: b9654ce998b56c10d7094364e8b715663163d2101e1f70e077cb0723fc47f02e model: L6 side: right tag_layout: l6_right_8 @@ -28,7 +30,7 @@ artifacts: camera_extrinsics: config/g20_three_camera_extrinsics.yaml camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80 calibration_config: package://linkerhand_calibration/config/l6_three_camera_calibration.yaml - calibration_config_sha256: e25e7ab27f4fd918f7cab70717e070d52a52fff17266a367c136388384ad4baa + calibration_config_sha256: 090d82a5609e8b981c1b8c6ebeaf4c2f477323209ae3db3b9d8713ac6d6169b5 tag_config: package://linkerhand_calibration/config/l6_right_8_tags.yaml tag_config_sha256: be1499eb947b61d2fe360ae2c92307a87710480fae8a9dd4cd171fc959fdcbf5 diff --git a/src/linkerhand_calibration/config/l6_three_camera_calibration.yaml b/src/linkerhand_calibration/config/l6_three_camera_calibration.yaml index ca74874..35a6cf3 100644 --- a/src/linkerhand_calibration/config/l6_three_camera_calibration.yaml +++ b/src/linkerhand_calibration/config/l6_three_camera_calibration.yaml @@ -36,7 +36,6 @@ l6_calibration: minimum_decision_margin: 30.0 minimum_edge_pixels: 30.0 pnp_maximum_reprojection_error_px: 1.5 - pnp_reprojection_tie_px: 1.5 pnp_maximum_pose_jump_deg: 35.0 pnp_maximum_translation_jump_m: 0.04 pnp_maximum_tag_tilt_deg: 75.0 diff --git a/src/linkerhand_calibration/config/o12_right_product.yaml b/src/linkerhand_calibration/config/o12_right_product.yaml index 0e46a23..63c2d16 100644 --- a/src/linkerhand_calibration/config/o12_right_product.yaml +++ b/src/linkerhand_calibration/config/o12_right_product.yaml @@ -1,5 +1,7 @@ schema_version: 3 profile_id: O12/right/o12_right_16/v1 +profile_config: package://linkerhand_calibration/config/profiles/o12_right_16.yaml +profile_config_sha256: 0b5cd27168bc863e5c1aed5382c08e33ab4c02a23359a325dc9925fd30599e5f model: O12 side: right tag_layout: o12_right_16 @@ -8,7 +10,9 @@ serial_number: O12_RIGHT_001 output_root: calibration_output sdk: - driver: omnihand_pro_2025_node + driver: o12_sdk_bridge + python_package: src/agillink_omnihand_sdk/linux/x64/python/omnihand-1.1.8-cp312-cp312-linux_x86_64.whl + package_sha256: cae7a0d5bce7e7c9d72cc90a0dd15e152f11e2a1ecf04cb6761e8171399ff170 transport: hcan setup: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/setup.bash config: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/share/omnihand_node/config/omnihand_pro_2025_node.yaml @@ -34,7 +38,7 @@ artifacts: camera_extrinsics: config/o12_three_camera_extrinsics.yaml camera_extrinsics_sha256: 5a515d0706f4e67d5e26bfddb348b519817bd72e885ea9e43997e41016176e53 calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml - calibration_config_sha256: 962a16ce3a21fd2d7886caab17c39a9441ff4eb856c9bf8e86c5c303c2291553 + calibration_config_sha256: c2fcdd532e22c15013413e655311de2ca87107c23a96d5c043e8f340f232eebd tag_config: package://linkerhand_calibration/config/o12_right_16_tags.yaml tag_config_sha256: 41001c3afba74cc01eb524a75dc58561a37e9364fab029ea12d879156a008dab diff --git a/src/linkerhand_calibration/config/o12_three_camera_calibration.yaml b/src/linkerhand_calibration/config/o12_three_camera_calibration.yaml index fe982ae..1ba194c 100644 --- a/src/linkerhand_calibration/config/o12_three_camera_calibration.yaml +++ b/src/linkerhand_calibration/config/o12_three_camera_calibration.yaml @@ -47,7 +47,6 @@ o12_calibration: fixed_base_maximum_corner_drift_px: 5.0 fixed_base_movement_confirmation_frames: 10 pnp_maximum_reprojection_error_px: 1.5 - pnp_reprojection_tie_px: 1.5 pnp_maximum_pose_jump_deg: 35.0 pnp_maximum_translation_jump_m: 0.04 pnp_maximum_tag_tilt_deg: 75.0 diff --git a/src/linkerhand_calibration/config/o6_right_product.yaml b/src/linkerhand_calibration/config/o6_right_product.yaml index 260c422..c9edf88 100644 --- a/src/linkerhand_calibration/config/o6_right_product.yaml +++ b/src/linkerhand_calibration/config/o6_right_product.yaml @@ -1,5 +1,7 @@ -schema_version: 2 +schema_version: 3 profile_id: O6/right/o6_right_8/v1 +profile_config: package://linkerhand_calibration/config/profiles/o6_right_8.yaml +profile_config_sha256: ab1d86cb0c3e514de5322a44c1444e99d9b1f1ce43f8a2451cd032ed9c31e4ff model: O6 side: right tag_layout: o6_right_8 @@ -28,7 +30,7 @@ artifacts: camera_extrinsics: config/o6_three_camera_extrinsics.yaml camera_extrinsics_sha256: 29af61f7bf1bad6718cbbaa54b0536f0a471c83f5bb3554f264ab9d292e56ca4 calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml - calibration_config_sha256: a7fe124a195fa0f491535e96bdc728d25ea3efe298cf905e499917d329e9026f + calibration_config_sha256: 15adaec462d0895dc68d19760e8f333c62e57786d124f22cfc481007a3d976a4 tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml tag_config_sha256: 16abe7119b4764f86333dae8264247571d1e0bca45af959d558bef4fb5485f5e diff --git a/src/linkerhand_calibration/config/o6_three_camera_calibration.yaml b/src/linkerhand_calibration/config/o6_three_camera_calibration.yaml index 162d644..e92130d 100644 --- a/src/linkerhand_calibration/config/o6_three_camera_calibration.yaml +++ b/src/linkerhand_calibration/config/o6_three_camera_calibration.yaml @@ -33,7 +33,6 @@ o6_calibration: minimum_decision_margin: 30.0 minimum_edge_pixels: 30.0 pnp_maximum_reprojection_error_px: 1.5 - pnp_reprojection_tie_px: 1.5 pnp_maximum_pose_jump_deg: 35.0 pnp_maximum_translation_jump_m: 0.04 pnp_maximum_tag_tilt_deg: 75.0 diff --git a/src/linkerhand_calibration/config/profiles/g20_right_19.yaml b/src/linkerhand_calibration/config/profiles/g20_right_19.yaml new file mode 100644 index 0000000..84eef1d --- /dev/null +++ b/src/linkerhand_calibration/config/profiles/g20_right_19.yaml @@ -0,0 +1,1504 @@ +schema_version: 1 +profile_id: G20/right/g20_right_19/v1 +namespace: /g20_calibration +sdk_adapter: legacy_byte_sdk +command: + names: + - thumb_cmc_pitch + - index_mcp_pitch + - middle_mcp_pitch + - ring_mcp_pitch + - pinky_mcp_pitch + - thumb_cmc_roll + - index_mcp_roll + - middle_mcp_roll + - ring_mcp_roll + - pinky_mcp_roll + - thumb_cmc_yaw + - reserved_11 + - reserved_12 + - reserved_13 + - reserved_14 + - thumb_mcp + - index_pip + - middle_pip + - ring_pip + - pinky_pip + baseline_u8: + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + - 127 + - 127 + - 127 + - 127 + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + command_index_by_joint: + thumb_cmc_pitch: 0 + thumb_cmc_roll: 5 + thumb_cmc_yaw: 10 + thumb_mcp: 15 + index_mcp_roll: 6 + index_mcp_pitch: 1 + index_pip: 16 + middle_mcp_roll: 7 + middle_mcp_pitch: 2 + middle_pip: 17 + ring_mcp_roll: 8 + ring_mcp_pitch: 3 + ring_pip: 18 + pinky_mcp_roll: 9 + pinky_mcp_pitch: 4 + pinky_pip: 19 + disabled_indices: + - 11 + - 12 + - 13 + - 14 + urdf_joint_by_joint: {} + feedback_name_aliases: {} + speed_slot_by_command_index: + 0: 0 + 1: 1 + 2: 2 + 3: 3 + 4: 4 + 5: 0 + 6: 1 + 7: 2 + 8: 3 + 9: 4 + 10: 0 + 15: 0 + 16: 1 + 17: 2 + 18: 3 + 19: 4 + unit: u8 + baseline: [] + lower_bounds: [] + upper_bounds: [] + feedback_lower_bounds: [] + feedback_upper_bounds: [] + feedback_by_index: false + 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 + tags: + - role: front_base + fixed_reference: true + id: 0 + - role: thumb_cmc + fixed_reference: false + id: 1 + - role: thumb_mcp + fixed_reference: false + id: 2 + - role: thumb_ip + fixed_reference: false + id: 3 + - role: pinky_roll + fixed_reference: false + id: 10 + - role: ring_roll + fixed_reference: false + id: 11 + - role: middle_roll + fixed_reference: false + id: 12 + - role: index_roll + fixed_reference: false + id: 13 + - name: side + tags: + - role: side_base + fixed_reference: true + id: 4 + - role: ring_pip + fixed_reference: false + id: 5 + - role: pinky_pip + fixed_reference: false + id: 6 + - role: pinky_dip + fixed_reference: false + id: 7 + - role: ring_dip + fixed_reference: false + id: 14 + - role: middle_pip + fixed_reference: false + id: 15 + - role: middle_dip + fixed_reference: false + id: 16 + - role: index_pip + fixed_reference: false + id: 17 + - role: index_dip + fixed_reference: false + id: 18 + - name: top + tags: + - role: top_base + fixed_reference: true + id: 8 + - role: thumb_yaw + fixed_reference: false + id: 9 + common_frame: calibration_common + extrinsic_reference_view: front + extrinsics_quality_limits: + reprojection_rms_px: 1.2 + maximum_rotation_repeatability_deg: 0.3 + maximum_translation_repeatability_m: 0.0015 + minimum_capture_counts: + front_side_captures: 15 + front_top_captures: 15 +motion: + tasks: + - key: thumb_cmc_pitch_front + view: front + command_index: 0 + joints: + - thumb_cmc_pitch + auxiliary_commands: + - - 5 + - 255 + - - 10 + - 255 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 5 + - 10 + - 15 + - - 0 + - key: thumb_cmc_roll_front + view: front + command_index: 5 + joints: + - thumb_cmc_roll + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 10 + - 15 + - - 5 + - key: thumb_mcp_ip_front + view: front + command_index: 15 + joints: + - thumb_mcp + - thumb_ip + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - - 15 + - key: thumb_cmc_yaw_top + view: top + command_index: 10 + joints: + - thumb_cmc_yaw + auxiliary_commands: + - - 5 + - 145 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 15 + - - 10 + - key: pinky_roll_multiview + view: front + command_index: 9 + joints: + - pinky_mcp_roll + auxiliary_commands: + - - 4 + - 255 + - - 6 + - 255 + - - 7 + - 255 + - - 8 + - 255 + - - 19 + - 255 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 9 + - key: pinky_pitch_side + view: side + command_index: 4 + joints: + - pinky_mcp_pitch + auxiliary_commands: + - - 6 + - 255 + - - 7 + - 255 + - - 8 + - 255 + - - 9 + - 127 + - - 19 + - 255 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - - 0 + - 5 + - 10 + - 15 + - - 4 + - key: pinky_pip_side + view: side + command_index: 19 + joints: + - pinky_pip + - pinky_dip + auxiliary_commands: + - - 4 + - 255 + - - 6 + - 255 + - - 7 + - 255 + - - 8 + - 255 + - - 9 + - 127 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 19 + - key: ring_roll_multiview + view: front + command_index: 8 + joints: + - ring_mcp_roll + auxiliary_commands: + - - 3 + - 255 + - - 4 + - 0 + - - 6 + - 255 + - - 7 + - 255 + - - 9 + - 127 + - - 18 + - 255 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 8 + - key: ring_pitch_side + view: side + command_index: 3 + joints: + - ring_mcp_pitch + auxiliary_commands: + - - 4 + - 0 + - - 6 + - 255 + - - 7 + - 255 + - - 8 + - 127 + - - 9 + - 127 + - - 18 + - 255 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 3 + - key: ring_pip_side + view: side + command_index: 18 + joints: + - ring_pip + - ring_dip + auxiliary_commands: + - - 3 + - 255 + - - 4 + - 0 + - - 6 + - 255 + - - 7 + - 255 + - - 8 + - 127 + - - 9 + - 127 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 18 + - key: middle_roll_multiview + view: front + command_index: 7 + joints: + - middle_mcp_roll + auxiliary_commands: + - - 2 + - 255 + - - 3 + - 0 + - - 4 + - 0 + - - 6 + - 255 + - - 8 + - 127 + - - 9 + - 127 + - - 17 + - 255 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 7 + - key: middle_pitch_side + view: side + command_index: 2 + joints: + - middle_mcp_pitch + auxiliary_commands: + - - 3 + - 0 + - - 4 + - 0 + - - 6 + - 255 + - - 7 + - 127 + - - 8 + - 127 + - - 9 + - 127 + - - 17 + - 255 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 2 + - key: middle_pip_side + view: side + command_index: 17 + joints: + - middle_pip + - middle_dip + auxiliary_commands: + - - 2 + - 255 + - - 3 + - 0 + - - 4 + - 0 + - - 6 + - 255 + - - 7 + - 127 + - - 8 + - 127 + - - 9 + - 127 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 17 + - key: index_roll_multiview + view: front + command_index: 6 + joints: + - index_mcp_roll + auxiliary_commands: + - - 1 + - 255 + - - 2 + - 0 + - - 3 + - 0 + - - 4 + - 0 + - - 7 + - 127 + - - 8 + - 127 + - - 9 + - 127 + - - 16 + - 255 + - - 17 + - 0 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 6 + - key: index_pitch_side + view: side + command_index: 1 + joints: + - index_mcp_pitch + auxiliary_commands: + - - 2 + - 0 + - - 3 + - 0 + - - 4 + - 0 + - - 6 + - 127 + - - 7 + - 127 + - - 8 + - 127 + - - 9 + - 127 + - - 16 + - 255 + - - 17 + - 0 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 16 + - 17 + - 18 + - 19 + - - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 1 + - key: index_pip_side + view: side + command_index: 16 + joints: + - index_pip + - index_dip + auxiliary_commands: + - - 1 + - 255 + - - 2 + - 0 + - - 3 + - 0 + - - 4 + - 0 + - - 6 + - 127 + - - 7 + - 127 + - - 8 + - 127 + - - 9 + - 127 + - - 17 + - 0 + - - 18 + - 0 + - - 19 + - 0 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: null + formal_speed_u8: null + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_groups: + - - 6 + - 7 + - 8 + - 9 + - - 17 + - 18 + - 19 + - - 1 + - 2 + - 3 + - 4 + - - 0 + - 5 + - 10 + - 15 + - - 16 + preparation_waypoints_u8: [] + safe_return_waypoints_u8: [] + speed_parameters: {} + precheck_sweeps: false + steady_command_checkpoints: false + return_groups: + - - 6 + - 7 + - 8 + - 9 + - - 1 + - 2 + - 3 + - 4 + - - 16 + - 17 + - 18 + - 19 + - - 0 + - 5 + - 10 + - 15 +measurement: + measurements: + thumb_cmc_pitch: + joint: thumb_cmc_pitch + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: thumb_cmc + validation_source: null + pose_axis_line_required: true + thumb_cmc_roll: + joint: thumb_cmc_roll + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: thumb_cmc + validation_source: null + pose_axis_line_required: true + thumb_cmc_yaw: + joint: thumb_cmc_yaw + kind: urdf_axis_chain + view: top + parent_role: top_base + child_role: thumb_yaw + validation_source: null + pose_axis_line_required: true + thumb_mcp: + joint: thumb_mcp + kind: urdf_axis_chain + view: front + parent_role: thumb_cmc + child_role: thumb_mcp + validation_source: null + pose_axis_line_required: true + thumb_ip: + joint: thumb_ip + kind: curve + view: front + parent_role: thumb_mcp + child_role: thumb_ip + validation_source: null + pose_axis_line_required: false + index_mcp_roll: + joint: index_mcp_roll + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: index_roll + validation_source: index_mcp_roll_side + pose_axis_line_required: true + index_mcp_pitch: + joint: index_mcp_pitch + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: index_pip + validation_source: null + pose_axis_line_required: true + index_pip: + joint: index_pip + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: index_pip + validation_source: null + pose_axis_line_required: true + index_dip: + joint: index_dip + kind: curve + view: side + parent_role: index_pip + child_role: index_dip + validation_source: null + pose_axis_line_required: true + middle_mcp_roll: + joint: middle_mcp_roll + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: middle_roll + validation_source: middle_mcp_roll_side + pose_axis_line_required: true + middle_mcp_pitch: + joint: middle_mcp_pitch + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: middle_pip + validation_source: null + pose_axis_line_required: true + middle_pip: + joint: middle_pip + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: middle_pip + validation_source: null + pose_axis_line_required: true + middle_dip: + joint: middle_dip + kind: curve + view: side + parent_role: middle_pip + child_role: middle_dip + validation_source: null + pose_axis_line_required: true + ring_mcp_roll: + joint: ring_mcp_roll + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: ring_roll + validation_source: ring_mcp_roll_side + pose_axis_line_required: true + ring_mcp_pitch: + joint: ring_mcp_pitch + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: ring_pip + validation_source: null + pose_axis_line_required: true + ring_pip: + joint: ring_pip + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: ring_pip + validation_source: null + pose_axis_line_required: true + ring_dip: + joint: ring_dip + kind: curve + view: side + parent_role: ring_pip + child_role: ring_dip + validation_source: null + pose_axis_line_required: true + pinky_mcp_roll: + joint: pinky_mcp_roll + kind: urdf_axis_chain + view: front + parent_role: front_base + child_role: pinky_roll + validation_source: pinky_mcp_roll_side + pose_axis_line_required: true + pinky_mcp_pitch: + joint: pinky_mcp_pitch + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: pinky_pip + validation_source: null + pose_axis_line_required: true + pinky_pip: + joint: pinky_pip + kind: urdf_axis_chain + view: side + parent_role: side_base + child_role: pinky_pip + validation_source: null + pose_axis_line_required: true + pinky_dip: + joint: pinky_dip + kind: curve + view: side + parent_role: pinky_pip + child_role: pinky_dip + validation_source: null + pose_axis_line_required: true + pinky_mcp_roll_side: + joint: pinky_mcp_roll_side + kind: axis_cross_view_validation + view: side + parent_role: side_base + child_role: pinky_pip + validation_source: null + pose_axis_line_required: true + ring_mcp_roll_side: + joint: ring_mcp_roll_side + kind: axis_cross_view_validation + view: side + parent_role: side_base + child_role: ring_pip + validation_source: null + pose_axis_line_required: true + middle_mcp_roll_side: + joint: middle_mcp_roll_side + kind: axis_cross_view_validation + view: side + parent_role: side_base + child_role: middle_pip + validation_source: null + pose_axis_line_required: true + index_mcp_roll_side: + joint: index_mcp_roll_side + kind: axis_cross_view_validation + view: side + parent_role: side_base + child_role: index_pip + validation_source: null + pose_axis_line_required: true + cross_view_sources: + pinky_mcp_roll: pinky_mcp_roll_side + ring_mcp_roll: ring_mcp_roll_side + middle_mcp_roll: middle_mcp_roll_side + index_mcp_roll: index_mcp_roll_side + image_curve_joints: + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - ring_mcp_roll + - ring_mcp_pitch + - ring_pip + - pinky_mcp_roll + - pinky_mcp_pitch + - pinky_pip + directional_zero: true + cross_view_roll_curve: true + stable_cross_view_cone_bias: true + input_domain: feedback_u8 +zero: + active_joints: + - index_mcp_pitch + - index_mcp_roll + - index_pip + - middle_mcp_pitch + - middle_mcp_roll + - middle_pip + - pinky_mcp_pitch + - pinky_mcp_roll + - pinky_pip + - ring_mcp_pitch + - ring_mcp_roll + - ring_pip + - thumb_cmc_pitch + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_mcp + passive_joints: + - index_dip + - middle_dip + - pinky_dip + - ring_dip + - thumb_ip + direct_zero_joints: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - ring_mcp_roll + - ring_mcp_pitch + - ring_pip + - pinky_mcp_roll + - pinky_mcp_pitch + - pinky_pip + axis_joints: &id001 + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - thumb_ip + - index_mcp_roll + - index_mcp_pitch + - index_pip + - index_dip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - middle_dip + - ring_mcp_roll + - ring_mcp_pitch + - ring_pip + - ring_dip + - pinky_mcp_roll + - pinky_mcp_pitch + - pinky_pip + - pinky_dip + mechanical_endpoint_joints: [] + post_solve_endpoint_joints: [] + mimic_source_by_joint: + index_dip: index_pip + middle_dip: middle_pip + ring_dip: ring_pip + pinky_dip: pinky_pip + thumb_ip: thumb_mcp + cad_frozen_joints: + - index_dip + - middle_dip + - pinky_dip + - ring_dip + - thumb_ip + endpoint_anchor_by_joint: {} + fitted_mimic_joints: + - index_dip + - middle_dip + - pinky_dip + - ring_dip + - thumb_ip + coupling_model_by_joint: + index_dip: linear_mimic + middle_dip: linear_mimic + pinky_dip: linear_mimic + ring_dip: linear_mimic + thumb_ip: linear_mimic + spatial: + base_pose_strategy: full_hand + parallel_root_pattern: true + root_anchor_joints: + - thumb_cmc_roll + - index_mcp_roll + - middle_mcp_roll + - ring_mcp_roll + - pinky_mcp_roll + depth_free_axis_projection: true + axis_order: *id001 + axis_parent_joint: + thumb_cmc_yaw: thumb_cmc_roll + thumb_cmc_pitch: thumb_cmc_yaw + index_mcp_pitch: index_mcp_roll + middle_mcp_pitch: middle_mcp_roll + ring_mcp_pitch: ring_mcp_roll + pinky_mcp_pitch: pinky_mcp_roll + phase_parent_joint: + thumb_mcp: thumb_cmc_pitch + thumb_ip: thumb_mcp + index_pip: index_mcp_pitch + index_dip: index_pip + middle_pip: middle_mcp_pitch + middle_dip: middle_pip + ring_pip: ring_mcp_pitch + ring_dip: ring_pip + pinky_pip: pinky_mcp_pitch + pinky_dip: pinky_pip + offset_observer_joint: + thumb_cmc_roll: thumb_cmc_yaw + thumb_cmc_yaw: thumb_cmc_pitch + thumb_cmc_pitch: thumb_mcp + thumb_mcp: thumb_ip + index_mcp_roll: index_mcp_pitch + index_mcp_pitch: index_pip + index_pip: index_dip + middle_mcp_roll: middle_mcp_pitch + middle_mcp_pitch: middle_pip + middle_pip: middle_dip + ring_mcp_roll: ring_mcp_pitch + ring_mcp_pitch: ring_pip + ring_pip: ring_dip + pinky_mcp_roll: pinky_mcp_pitch + pinky_mcp_pitch: pinky_pip + pinky_pip: pinky_dip + same_view_axis_pair_by_offset: + thumb_cmc_yaw: + - thumb_cmc_roll + - thumb_cmc_pitch +quality: + training_cycles: + - 0 + - 1 + - 2 + holdout_cycle: 3 + hard_threshold_keys: + - maximum_axis_cycle_difference_rad + - maximum_pose_line_rms_m + - maximum_reprojection_error_px + - maximum_validation_error_rad + - minimum_detection_rate + retry_metric_scope: {} + isolated_holdout: true +scope: + calibrate_joints: + full: + - index_mcp_pitch + - index_mcp_roll + - index_pip + - middle_mcp_pitch + - middle_mcp_roll + - middle_pip + - pinky_mcp_pitch + - pinky_mcp_roll + - pinky_pip + - ring_mcp_pitch + - ring_mcp_roll + - ring_pip + - thumb_cmc_pitch + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_mcp + thumb: + - thumb_cmc_pitch + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_mcp + fingers: + - index_mcp_pitch + - index_mcp_roll + - index_pip + - middle_mcp_pitch + - middle_mcp_roll + - middle_pip + - pinky_mcp_pitch + - pinky_mcp_roll + - pinky_pip + - ring_mcp_pitch + - ring_mcp_roll + - ring_pip + frozen_joints: + full: [] + thumb: + - index_mcp_pitch + - index_mcp_roll + - index_pip + - middle_mcp_pitch + - middle_mcp_roll + - middle_pip + - pinky_mcp_pitch + - pinky_mcp_roll + - pinky_pip + - ring_mcp_pitch + - ring_mcp_roll + - ring_pip + fingers: + - thumb_cmc_pitch + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_mcp + default_scope: full +artifacts: + output_schema_version: 2 + calibration_filename: g20_{side}_{serial_number}_calibration.json + corrected_urdf_filename: linkerhand_g20_{side}_{serial_number}_zero_calibrated.urdf + protected_input_fields: + - calibration_config_sha256 + - camera_extrinsics_sha256 + - profile_config_sha256 + - source_urdf_sha256 + - tag_config_sha256 + publication_pointer: latest_passed + session_compatibility_tokens: + - cross_view_roll_curve + - directional_zero + - isolated_holdout + - measured_passive_dips + - palm_axis_relative_motion_v3 + - stable_cross_view_cone_bias + - steady_command_checkpoints + - urdf_zero_publication + publish_corrected_urdf: true +acquisition: + policy_version: unified_engine_v4_dual_mapping + mapping_probe_maximum_rad: 0.0 + automatic_rescan_limit: 1 + minimum_valid_samples: 40 + minimum_bins: 32 + maximum_unobserved_fraction: 0.0625 + legacy_minimum_span_01: 0.9411764705882353 + physical_first_cycle_minimum_span_01: 0.85 + physical_repeat_minimum_fraction: 0.9 + stall_timeout_seconds: 2.0 + feedback_stale_seconds: 1.0 + fixed_reference_minimum_frames: 10 + fixed_reference_maximum_drift_px: 5.0 + fixed_reference_confirmation_frames: 10 +urdf: + authorized_fields: + ring_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + index_pip: + - limit.lower + - limit.upper + - origin.rpy + index_mcp_roll: + - limit.lower + - limit.upper + - origin.rpy + thumb_cmc_yaw: + - limit.lower + - limit.upper + - origin.rpy + middle_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + index_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + ring_mcp_roll: + - limit.lower + - limit.upper + - origin.rpy + middle_mcp_roll: + - limit.lower + - limit.upper + - origin.rpy + ring_pip: + - limit.lower + - limit.upper + - origin.rpy + thumb_cmc_pitch: + - limit.lower + - limit.upper + - origin.rpy + thumb_cmc_roll: + - limit.lower + - limit.upper + - origin.rpy + pinky_pip: + - limit.lower + - limit.upper + - origin.rpy + middle_pip: + - limit.lower + - limit.upper + - origin.rpy + thumb_mcp: + - limit.lower + - limit.upper + - origin.rpy + pinky_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + pinky_mcp_roll: + - limit.lower + - limit.upper + - origin.rpy + thumb_ip: + - mimic.multiplier + - mimic.offset + index_dip: + - mimic.multiplier + - mimic.offset + middle_dip: + - mimic.multiplier + - mimic.offset + ring_dip: + - mimic.multiplier + - mimic.offset + pinky_dip: + - mimic.multiplier + - mimic.offset +joint_coverage: + index_mcp_pitch: measured_static_dynamic + index_mcp_roll: measured_static_dynamic + index_pip: measured_static_dynamic + middle_mcp_pitch: measured_static_dynamic + middle_mcp_roll: measured_static_dynamic + middle_pip: measured_static_dynamic + pinky_mcp_pitch: measured_static_dynamic + pinky_mcp_roll: measured_static_dynamic + pinky_pip: measured_static_dynamic + ring_mcp_pitch: measured_static_dynamic + ring_mcp_roll: measured_static_dynamic + ring_pip: measured_static_dynamic + thumb_cmc_pitch: measured_static_dynamic + thumb_cmc_roll: measured_static_dynamic + thumb_cmc_yaw: measured_static_dynamic + thumb_mcp: measured_static_dynamic + index_dip: measured_dynamic_cad_static + middle_dip: measured_dynamic_cad_static + pinky_dip: measured_dynamic_cad_static + ring_dip: measured_dynamic_cad_static + thumb_ip: measured_dynamic_cad_static diff --git a/src/linkerhand_calibration/config/profiles/l6_right_8.yaml b/src/linkerhand_calibration/config/profiles/l6_right_8.yaml new file mode 100644 index 0000000..675a626 --- /dev/null +++ b/src/linkerhand_calibration/config/profiles/l6_right_8.yaml @@ -0,0 +1,372 @@ +schema_version: 1 +profile_id: L6/right/l6_right_8/v1 +namespace: /l6_calibration +sdk_adapter: legacy_byte_sdk +command: + sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1] + names: + - thumb_cmc_pitch + - thumb_cmc_roll + - index_mcp_pitch + - middle_mcp_pitch + - ring_mcp_pitch + - pinky_mcp_pitch + baseline_u8: + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + command_index_by_joint: + rh_thumb_cmc_pitch: 0 + rh_thumb_cmc_roll: 1 + rh_index_mcp_pitch: 2 + rh_middle_mcp_pitch: 3 + rh_ring_mcp_pitch: 4 + rh_pinky_mcp_pitch: 5 + disabled_indices: [] + urdf_joint_by_joint: + rh_thumb_cmc_pitch: rh_thumb_cmc_pitch + rh_thumb_cmc_roll: rh_thumb_cmc_roll + rh_index_mcp_pitch: rh_index_mcp_pitch + rh_middle_mcp_pitch: rh_middle_mcp_pitch + rh_ring_mcp_pitch: rh_ring_mcp_pitch + rh_pinky_mcp_pitch: rh_pinky_mcp_pitch + feedback_name_aliases: + thumb_cmc_yaw: thumb_cmc_roll + speed_slot_by_command_index: + '0': 0 + '1': 1 + '2': 2 + '3': 3 + '4': 4 + '5': 5 + unit: u8 + baseline: [] + lower_bounds: [] + upper_bounds: [] + feedback_lower_bounds: [] + feedback_upper_bounds: [] + feedback_by_index: false +vision: + views: + - name: front + tags: + - role: front_base + fixed_reference: true + id: 0 + - role: thumb_pitch + fixed_reference: false + id: 1 + - role: thumb_dip + fixed_reference: false + id: 2 + - name: side + tags: + - role: side_base + fixed_reference: true + id: 3 + - role: pinky_pitch + fixed_reference: false + id: 4 + - role: pinky_dip + fixed_reference: false + id: 5 + - name: top + tags: + - role: top_base + fixed_reference: true + id: 6 + - role: thumb_roll + fixed_reference: false + id: 7 + common_frame: calibration_common + extrinsic_reference_view: front + extrinsics_quality_limits: + reprojection_rms_px: 1.2 + maximum_rotation_repeatability_deg: 0.3 + maximum_translation_repeatability_m: 0.0015 + minimum_capture_counts: + front_side_captures: 15 + front_top_captures: 15 +motion: + tasks: + - key: thumb_roll_top + view: top + command_index: 1 + joints: + - rh_thumb_cmc_roll + auxiliary_commands: + - - 0 + - 255 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 1 + formal_speed_u8: 1 + start: null + end: null + preflight_speed: null + formal_speed: null + - key: thumb_pitch_dip_front + view: front + command_index: 0 + joints: + - rh_thumb_cmc_pitch + - rh_thumb_dip + auxiliary_commands: + - - 1 + - 255 + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 1 + formal_speed_u8: 1 + start: null + end: null + preflight_speed: null + formal_speed: null + - key: pinky_pitch_dip_side + view: side + command_index: 5 + joints: + - rh_pinky_mcp_pitch + - rh_pinky_dip + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 1 + formal_speed_u8: 1 + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_waypoints_u8: [] + safe_return_waypoints_u8: [] + speed_parameters: + preflight_u8: 1 + formal_u8: 1 + speed_settle_seconds: 0.2 + command_trajectory_full_range_seconds: 6.0 + torque_u8: 80 + endpoint_hold_seconds: 1.0 + stall_timeout_seconds: 2.0 + precheck_sweeps: false + steady_command_checkpoints: false +measurement: + measurements: + rh_thumb_cmc_roll: + joint: rh_thumb_cmc_roll + kind: relative_rotation + view: top + parent_role: top_base + child_role: thumb_roll + validation_source: null + pose_axis_line_required: true + rh_thumb_cmc_pitch: + joint: rh_thumb_cmc_pitch + kind: relative_rotation + view: front + parent_role: front_base + child_role: thumb_pitch + validation_source: null + pose_axis_line_required: true + rh_thumb_dip: + joint: rh_thumb_dip + kind: relative_rotation + view: front + parent_role: thumb_pitch + child_role: thumb_dip + validation_source: null + pose_axis_line_required: true + rh_pinky_mcp_pitch: + joint: rh_pinky_mcp_pitch + kind: relative_rotation + view: side + parent_role: side_base + child_role: pinky_pitch + validation_source: null + pose_axis_line_required: true + rh_pinky_dip: + joint: rh_pinky_dip + kind: relative_rotation + view: side + parent_role: pinky_pitch + child_role: pinky_dip + validation_source: null + pose_axis_line_required: true + cross_view_sources: {} + image_curve_joints: [] + directional_zero: true + cross_view_roll_curve: false + stable_cross_view_cone_bias: false +zero: + active_joints: + - rh_index_mcp_pitch + - rh_middle_mcp_pitch + - rh_pinky_mcp_pitch + - rh_ring_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_roll + passive_joints: + - rh_index_dip + - rh_middle_dip + - rh_pinky_dip + - rh_ring_dip + - rh_thumb_dip + direct_zero_joints: + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_roll + axis_joints: + - rh_pinky_dip + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_roll + - rh_thumb_dip + mechanical_endpoint_joints: [] + post_solve_endpoint_joints: [] + mimic_source_by_joint: + rh_thumb_dip: rh_thumb_cmc_pitch + rh_index_dip: rh_index_mcp_pitch + rh_middle_dip: rh_middle_mcp_pitch + rh_ring_dip: rh_ring_mcp_pitch + rh_pinky_dip: rh_pinky_mcp_pitch + cad_frozen_joints: + - rh_index_dip + - rh_middle_dip + - rh_pinky_dip + - rh_ring_dip + - rh_thumb_dip + endpoint_anchor_by_joint: {} + fitted_mimic_joints: + - rh_pinky_dip + - rh_thumb_dip + 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 + transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch} + transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip} + spatial: + base_pose_strategy: thumb_serial + root_anchor_joints: [rh_thumb_cmc_roll] + orientation_anchor_joint: rh_pinky_mcp_pitch + directed_base_axis_joints: [rh_thumb_cmc_roll, rh_pinky_mcp_pitch] + depth_free_axis_projection: true + axis_order: [rh_thumb_cmc_roll, rh_thumb_cmc_pitch, rh_thumb_dip, rh_pinky_mcp_pitch, rh_pinky_dip] + axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_roll} + phase_parent_joint: {rh_thumb_dip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch} + offset_observer_joint: {rh_thumb_cmc_roll: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_dip, rh_pinky_mcp_pitch: rh_pinky_dip} +quality: + training_cycles: + - 0 + - 1 + - 2 + holdout_cycle: 3 + hard_threshold_keys: + - maximum_mimic_residual_rad + - maximum_state_image_skew_ms + - maximum_validation_error_rad + - minimum_detection_rate + retry_metric_scope: {} + isolated_holdout: true +scope: + calibrate_joints: + partial: + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_roll + frozen_joints: + partial: + - rh_index_mcp_pitch + - rh_middle_mcp_pitch + - rh_ring_mcp_pitch + default_scope: partial +artifacts: + output_schema_version: 2 + calibration_filename: l6_right_{serial_number}_partial_calibration.json + corrected_urdf_filename: linkerhand_l6_right_{serial_number}_partial_zero_calibrated.urdf + protected_input_fields: + - calibration_config_sha256 + - camera_extrinsics_sha256 + - profile_config_sha256 + - source_urdf_sha256 + - tag_config_sha256 + publication_pointer: latest_partial_passed + session_compatibility_tokens: + - feedback_curves_v6 + - l6_partial_v1 + publish_corrected_urdf: true +acquisition: + policy_version: unified_engine_v4_dual_mapping + mapping_probe_maximum_rad: 0.0 + automatic_rescan_limit: 1 + minimum_valid_samples: 40 + minimum_bins: 32 + maximum_unobserved_fraction: 0.0625 + legacy_minimum_span_01: 0.9411764705882353 + physical_first_cycle_minimum_span_01: 0.85 + physical_repeat_minimum_fraction: 0.9 + stall_timeout_seconds: 2.0 + feedback_stale_seconds: 1.0 + fixed_reference_minimum_frames: 10 + fixed_reference_maximum_drift_px: 5.0 + fixed_reference_confirmation_frames: 10 +urdf: + authorized_fields: + rh_pinky_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_thumb_cmc_roll: + - limit.lower + - limit.upper + - origin.rpy + rh_index_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_thumb_cmc_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_middle_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_ring_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_ring_dip: + - mimic.multiplier + - mimic.offset + rh_thumb_dip: + - mimic.multiplier + - mimic.offset + rh_index_dip: + - mimic.multiplier + - mimic.offset + rh_pinky_dip: + - mimic.multiplier + - mimic.offset + rh_middle_dip: + - mimic.multiplier + - mimic.offset +joint_coverage: + rh_pinky_mcp_pitch: measured_static_dynamic + rh_thumb_cmc_roll: measured_static_dynamic + rh_thumb_cmc_pitch: measured_static_dynamic + rh_middle_mcp_pitch: transferred_static_dynamic + rh_index_mcp_pitch: transferred_static_dynamic + rh_ring_mcp_pitch: transferred_static_dynamic + rh_pinky_dip: measured_dynamic_cad_static + rh_middle_dip: transferred_dynamic_cad_static + rh_ring_dip: transferred_dynamic_cad_static + rh_thumb_dip: measured_dynamic_cad_static + rh_index_dip: transferred_dynamic_cad_static diff --git a/src/linkerhand_calibration/config/profiles/o12_right_16.yaml b/src/linkerhand_calibration/config/profiles/o12_right_16.yaml new file mode 100644 index 0000000..5cf99b8 --- /dev/null +++ b/src/linkerhand_calibration/config/profiles/o12_right_16.yaml @@ -0,0 +1,926 @@ +schema_version: 1 +profile_id: O12/right/o12_right_16/v1 +namespace: /o12_calibration +sdk_adapter: o12_hcan_sdk +command: + unit: rad + feedback_by_index: true + sdk_to_joint_direction: + - 1 + - -1 + - -1 + - -1 + - -1 + - 1 + - 1 + - -1 + - 1 + - 1 + - 1 + - 1 + names: + - thumb_roll + - thumb_abad + - thumb_mcp + - thumb_pip + - index_abad + - index_mcp + - index_pip + - middle_abad + - middle_mcp + - middle_pip + - ring_mcp + - pinky_mcp + baseline_u8: [] + baseline: + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + - 0 + lower_bounds: + - 0 + - -1.387536755335492 + - -0.8272860654453121 + - -1.2915436464758039 + - -0.2617993877991494 + - 0 + - 0 + - -0.2617993877991494 + - 0 + - 0 + - 0 + - 0 + upper_bounds: + - 0.9424777960769379 + - 0 + - 0 + - 0 + - 0.2617993877991494 + - 1.3526301702956054 + - 1.530653753999027 + - 0.2617993877991494 + - 1.3578661580515883 + - 1.8151424220741028 + - 1.53588974175501 + - 1.53588974175501 + feedback_lower_bounds: + - -0.03490658503988659 + - -1.4224433403753787 + - -0.8621926504851987 + - -1.3264502315156905 + - -0.296705972839036 + - -0.03490658503988659 + - -0.03490658503988659 + - -0.296705972839036 + - -0.03490658503988659 + - -0.03490658503988659 + - -0.03490658503988659 + - -0.03490658503988659 + feedback_upper_bounds: + - 0.9773843811168246 + - 0.03490658503988659 + - 0.03490658503988659 + - 0.03490658503988659 + - 0.296705972839036 + - 1.387536755335492 + - 1.5655603390389137 + - 0.296705972839036 + - 1.392772743091475 + - 1.8500490071139892 + - 1.5707963267948966 + - 1.5707963267948966 + command_index_by_joint: + thumb_cmc_roll: 0 + thumb_cmc_yaw: 1 + thumb_cmc_pitch: 2 + thumb_mcp: 3 + index_mcp_roll: 4 + index_mcp_pitch: 5 + index_pip: 6 + middle_mcp_roll: 7 + middle_mcp_pitch: 8 + middle_pip: 9 + ring_mcp_pitch: 10 + pinky_mcp_pitch: 11 + urdf_joint_by_joint: + thumb_cmc_roll: thumb_cmc_roll + thumb_cmc_yaw: thumb_cmc_yaw + thumb_cmc_pitch: thumb_cmc_pitch + thumb_mcp: thumb_mcp + index_mcp_roll: index_mcp_roll + index_mcp_pitch: index_mcp_pitch + index_pip: index_pip + middle_mcp_roll: middle_mcp_roll + middle_mcp_pitch: middle_mcp_pitch + middle_pip: middle_pip + ring_mcp_pitch: ring_mcp_pitch + pinky_mcp_pitch: pinky_mcp_pitch + maximum_velocity: + - 0.16 + - 0.16 + - 0.1 + - 0.32 + - 0.12 + - 0.32 + - 0.32 + - 0.12 + - 0.32 + - 0.32 + - 0.32 + - 0.32 +vision: + common_frame: calibration_common + extrinsic_reference_view: front + extrinsics_quality_limits: + reprojection_rms_px: 2.0 + maximum_rotation_repeatability_deg: 0.3 + maximum_translation_repeatability_m: 0.0015 + minimum_capture_counts: + front_side_captures: 15 + front_top_captures: 15 + views: + - name: front + tags: + - role: front_base + id: 0 + fixed_reference: true + - role: thumb_cmc + id: 1 + - role: thumb_mcp + id: 2 + - role: thumb_dip + id: 3 + - role: middle_roll + id: 12 + - role: index_roll + id: 13 + - name: side + tags: + - role: side_base + id: 4 + fixed_reference: true + - role: pinky_mcp + id: 5 + - role: pinky_pip + id: 6 + - role: pinky_dip + id: 7 + - role: middle_pip + id: 8 + - role: middle_dip + id: 9 + - role: index_pip + id: 10 + - role: index_dip + id: 11 + - name: top + tags: + - role: top_base + id: 14 + fixed_reference: true + - role: thumb_yaw + id: 15 +motion: + speed_parameters: + command_rate_hz: 50 + clearance_flex_rad_s: 0.1 + clearance_splay_rad_s: 0.04 + probe_travel_rad: 0.05235987755982989 + probe_speed_rad_s: 0.02 + endpoint_hold_seconds: 1.0 + stall_timeout_seconds: 2.0 + baseline_rad_s: 0.2 + trajectory_ramp_seconds: 0.4 + tasks: + - key: thumb_pitch_front + view: front + command_index: 2 + joints: + - thumb_cmc_pitch + start: 0 + end: -0.8272860654453121 + preflight_speed: 0.06 + formal_speed: 0.1 + preparation_groups: + - - 0 + - 1 + - 3 + - - 4 + - 7 + - - 5 + - 6 + - 8 + - 9 + - - 10 + - 11 + - - 2 + - key: thumb_roll_front + view: front + command_index: 0 + joints: + - thumb_cmc_roll + start: 0 + end: 0.9424777960769379 + preflight_speed: 0.06 + formal_speed: 0.16 + preparation_groups: + - - 1 + - 2 + - 3 + - - 4 + - 7 + - - 5 + - 6 + - 8 + - 9 + - - 10 + - 11 + - - 0 + - key: thumb_mcp_dip_front + view: front + command_index: 3 + joints: + - thumb_mcp + - thumb_dip + start: 0 + end: -1.2915436464758039 + preflight_speed: 0.06 + formal_speed: 0.32 + preparation_groups: + - - 0 + - 1 + - 2 + - - 4 + - 7 + - - 5 + - 6 + - 8 + - 9 + - - 10 + - 11 + - - 3 + - key: thumb_yaw_top + view: top + command_index: 1 + joints: + - thumb_cmc_yaw + start: 0 + end: -1.387536755335492 + preflight_speed: 0.06 + formal_speed: 0.16 + preparation_groups: + - - 0 + - 2 + - 3 + - - 4 + - 7 + - - 5 + - 6 + - 8 + - 9 + - - 10 + - 11 + - - 1 + - key: pinky_chain_side + view: side + command_index: 11 + joints: + - pinky_mcp_pitch + - pinky_pip + - pinky_dip + start: 0 + end: 1.53588974175501 + preflight_speed: 0.06 + formal_speed: 0.32 + preparation_groups: + - - 0 + - 1 + - 2 + - 3 + - - 4 + - 7 + - - 5 + - 6 + - 8 + - 9 + - - 10 + - - 11 + - key: middle_roll_front + view: front + command_index: 7 + joints: + - middle_mcp_roll + auxiliary_commands: + - - 4 + - -0.17453292519943295 + - - 5 + - 0.11 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + - - 8 + - 0.17 + start: 0.2617993877991494 + end: -0.2617993877991494 + preflight_speed: 0.06 + formal_speed: 0.12 + preparation_groups: + - - 10 + - 11 + - - 5 + - 6 + - - 4 + - - 0 + - 1 + - 2 + - 3 + - - 8 + - 9 + - - 7 + - key: middle_mcp_side + view: side + command_index: 8 + joints: + - middle_mcp_pitch + auxiliary_commands: + - - 4 + - -0.17453292519943295 + - - 5 + - 0.11 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + start: 0 + end: 1.3578661580515883 + preflight_speed: 0.06 + formal_speed: 0.32 + preparation_groups: + - - 10 + - 11 + - - 5 + - 6 + - - 4 + - - 0 + - 1 + - 2 + - 3 + - - 7 + - 9 + - - 8 + - key: middle_pip_dip_side + view: side + command_index: 9 + joints: + - middle_pip + - middle_dip + auxiliary_commands: + - - 4 + - -0.17453292519943295 + - - 5 + - 0.11 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + start: 0 + end: 1.8151424220741028 + preflight_speed: 0.06 + formal_speed: 0.32 + preparation_groups: + - - 10 + - 11 + - - 5 + - 6 + - - 4 + - - 0 + - 1 + - 2 + - 3 + - - 7 + - 8 + - - 9 + - key: index_roll_front + view: front + command_index: 4 + joints: + - index_mcp_roll + auxiliary_commands: + - - 7 + - 0 + - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + - - 5 + - 0.17 + start: 0.2617993877991494 + end: -0.2617993877991494 + preflight_speed: 0.06 + formal_speed: 0.12 + entry_waypoints: + - - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + - - - 4 + - 0 + - - 7 + - 0 + - - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + preparation_groups: + - - 10 + - 11 + - - 7 + - - 8 + - 9 + - - 0 + - 1 + - 2 + - 3 + - - 5 + - 6 + - - 4 + - key: index_mcp_side + view: side + command_index: 5 + joints: + - index_mcp_pitch + auxiliary_commands: + - - 7 + - 0 + - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + start: 0 + end: 1.3526301702956054 + preflight_speed: 0.06 + formal_speed: 0.32 + entry_waypoints: + - - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + - - - 4 + - 0 + - - 7 + - 0 + - - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + preparation_groups: + - - 10 + - 11 + - - 7 + - - 8 + - 9 + - - 0 + - 1 + - 2 + - 3 + - - 4 + - 6 + - - 5 + - key: index_pip_dip_side + view: side + command_index: 6 + joints: + - index_pip + - index_dip + auxiliary_commands: + - - 7 + - 0 + - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + start: 0 + end: 1.530653753999027 + preflight_speed: 0.06 + formal_speed: 0.32 + entry_waypoints: + - - - 10 + - 1.53588974175501 + - - 11 + - 1.53588974175501 + - - - 4 + - 0 + - - 7 + - 0 + - - - 8 + - 1.3578661580515883 + - - 9 + - 1.8151424220741028 + preparation_groups: + - - 10 + - 11 + - - 7 + - - 8 + - 9 + - - 0 + - 1 + - 2 + - 3 + - - 4 + - 5 + - - 6 + return_groups: + - - 4 + - 7 + - - 8 + - 9 + - - 10 + - 11 + - - 0 + - 1 + - 2 + - 3 + - 5 + - 6 +measurement: + candidate_selection_tasks: + - thumb_mcp_dip_front + directional_zero: true + stable_cross_view_cone_bias: true + cross_view_sources: + middle_mcp_roll: middle_mcp_pitch + index_mcp_roll: index_mcp_pitch + measurements: + thumb_cmc_pitch: + kind: relative_rotation + view: front + parent_role: front_base + child_role: thumb_cmc + thumb_cmc_roll: + kind: relative_rotation + view: front + parent_role: front_base + child_role: thumb_cmc + thumb_mcp: + kind: relative_rotation + view: front + parent_role: thumb_cmc + child_role: thumb_mcp + thumb_dip: + kind: relative_rotation + view: front + parent_role: thumb_mcp + child_role: thumb_dip + pose_axis_line_required: false + thumb_cmc_yaw: + kind: relative_rotation + view: top + parent_role: top_base + child_role: thumb_yaw + pinky_mcp_pitch: + kind: relative_rotation + view: side + parent_role: side_base + child_role: pinky_mcp + pinky_pip: + kind: relative_rotation + view: side + parent_role: pinky_mcp + child_role: pinky_pip + pose_axis_line_required: false + pinky_dip: + kind: relative_rotation + view: side + parent_role: pinky_pip + child_role: pinky_dip + pose_axis_line_required: false + middle_mcp_roll: + kind: relative_rotation + view: front + parent_role: front_base + child_role: middle_roll + middle_mcp_pitch: + kind: relative_rotation + view: side + parent_role: side_base + child_role: middle_pip + middle_pip: + kind: relative_rotation + view: side + parent_role: side_base + child_role: middle_pip + middle_dip: + kind: relative_rotation + view: side + parent_role: middle_pip + child_role: middle_dip + pose_axis_line_required: false + index_mcp_roll: + kind: relative_rotation + view: front + parent_role: front_base + child_role: index_roll + index_mcp_pitch: + kind: relative_rotation + view: side + parent_role: side_base + child_role: index_pip + index_pip: + kind: relative_rotation + view: side + parent_role: side_base + child_role: index_pip + index_dip: + kind: relative_rotation + view: side + parent_role: index_pip + child_role: index_dip + pose_axis_line_required: false +zero: + active_joints: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - ring_mcp_pitch + - pinky_mcp_pitch + passive_joints: + - thumb_dip + - index_dip + - middle_dip + - ring_pip + - ring_dip + - pinky_pip + - pinky_dip + direct_zero_joints: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - pinky_mcp_pitch + axis_joints: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - pinky_mcp_pitch + - index_dip + - middle_dip + - pinky_pip + mechanical_endpoint_joints: [] + post_solve_endpoint_joints: [] + mimic_source_by_joint: + thumb_dip: thumb_mcp + index_dip: index_pip + middle_dip: middle_pip + ring_pip: ring_mcp_pitch + ring_dip: ring_pip + pinky_pip: pinky_mcp_pitch + pinky_dip: pinky_pip + cad_frozen_joints: + - thumb_mcp + - thumb_dip + - index_dip + - middle_dip + - ring_pip + - ring_dip + - pinky_pip + - pinky_dip + fitted_mimic_joints: + - thumb_dip + - index_dip + - middle_dip + - pinky_pip + - pinky_dip + coupling_model_by_joint: + thumb_dip: linear_mimic + index_dip: linear_mimic + middle_dip: linear_mimic + pinky_pip: linear_mimic + pinky_dip: linear_mimic + transferred_zero_sources: + ring_mcp_pitch: pinky_mcp_pitch + spatial: + base_pose_strategy: thumb_serial + root_anchor_joints: + - thumb_cmc_roll + orientation_anchor_joint: pinky_mcp_pitch + directed_base_axis_joints: + - thumb_cmc_roll + - pinky_mcp_pitch + depth_free_axis_projection: true + accept_validated_zero_in_confidence_interval: true + project_axis_gauge_before_image: true + axis_order: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - pinky_mcp_pitch + - pinky_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - middle_dip + - index_mcp_roll + - index_mcp_pitch + - index_pip + - index_dip + axis_parent_joint: + thumb_cmc_yaw: thumb_cmc_roll + thumb_cmc_pitch: thumb_cmc_yaw + index_mcp_pitch: index_mcp_roll + middle_mcp_pitch: middle_mcp_roll + phase_parent_joint: + thumb_mcp: thumb_cmc_pitch + index_pip: index_mcp_pitch + index_dip: index_pip + middle_pip: middle_mcp_pitch + middle_dip: middle_pip + pinky_pip: pinky_mcp_pitch + offset_observer_joint: + thumb_cmc_roll: thumb_cmc_yaw + thumb_cmc_yaw: thumb_cmc_pitch + thumb_cmc_pitch: thumb_mcp + index_mcp_roll: index_mcp_pitch + index_mcp_pitch: index_pip + index_pip: index_dip + middle_mcp_roll: middle_mcp_pitch + middle_mcp_pitch: middle_pip + middle_pip: middle_dip + pinky_mcp_pitch: pinky_pip +quality: + training_cycles: + - 0 + - 1 + - 2 + holdout_cycle: 3 + hard_threshold_keys: + - holdout + isolated_holdout: true +scope: + default_scope: full + calibrate_joints: + full: + - thumb_cmc_roll + - thumb_cmc_yaw + - thumb_cmc_pitch + - thumb_mcp + - index_mcp_roll + - index_mcp_pitch + - index_pip + - middle_mcp_roll + - middle_mcp_pitch + - middle_pip + - ring_mcp_pitch + - pinky_mcp_pitch + frozen_joints: + full: [] +artifacts: + output_schema_version: 2 + calibration_filename: o12_right_{serial_number}_calibration.json + corrected_urdf_filename: linkerhand_o12_right_{serial_number}_zero_calibrated.urdf + protected_input_fields: + - source_urdf_sha256 + - camera_extrinsics_sha256 + - calibration_config_sha256 + - tag_config_sha256 + - sdk_config_sha256 + - sdk_package_sha256 + - profile_config_sha256 + publication_pointer: latest_passed + session_compatibility_tokens: + - o12_right_16_v1 + - feedback_rad_v1 + - full_sdk_range_v2 + publish_corrected_urdf: true +acquisition: + policy_version: unified_engine_v4_dual_mapping + mapping_probe_maximum_rad: 0.05235987755982989 +urdf: + authorized_fields: + thumb_cmc_roll: + - origin.rpy + - limit.lower + - limit.upper + thumb_cmc_yaw: + - origin.rpy + - limit.lower + - limit.upper + thumb_cmc_pitch: + - origin.rpy + - limit.lower + - limit.upper + thumb_mcp: + - origin.rpy + - limit.lower + - limit.upper + index_mcp_roll: + - origin.rpy + - limit.lower + - limit.upper + index_mcp_pitch: + - origin.rpy + - limit.lower + - limit.upper + index_pip: + - origin.rpy + - limit.lower + - limit.upper + middle_mcp_roll: + - origin.rpy + - limit.lower + - limit.upper + middle_mcp_pitch: + - origin.rpy + - limit.lower + - limit.upper + middle_pip: + - origin.rpy + - limit.lower + - limit.upper + ring_mcp_pitch: + - origin.rpy + - limit.lower + - limit.upper + pinky_mcp_pitch: + - origin.rpy + - limit.lower + - limit.upper + thumb_dip: + - mimic.multiplier + - mimic.offset + index_dip: + - mimic.multiplier + - mimic.offset + middle_dip: + - mimic.multiplier + - mimic.offset + pinky_pip: + - mimic.multiplier + - mimic.offset + pinky_dip: + - mimic.multiplier + - mimic.offset + ring_pip: + - mimic.offset + ring_dip: + - mimic.offset +joint_coverage: + thumb_cmc_roll: measured_static_dynamic + thumb_cmc_yaw: measured_static_dynamic + thumb_cmc_pitch: measured_static_dynamic + thumb_mcp: measured_dynamic_cad_static + thumb_dip: measured_dynamic_cad_static + index_mcp_roll: measured_static_dynamic + index_mcp_pitch: measured_static_dynamic + index_pip: measured_static_dynamic + index_dip: measured_dynamic_cad_static + middle_mcp_roll: measured_static_dynamic + middle_mcp_pitch: measured_static_dynamic + middle_pip: measured_static_dynamic + middle_dip: measured_dynamic_cad_static + ring_mcp_pitch: transferred_static_dynamic + ring_pip: mimic_nominal + ring_dip: mimic_nominal + pinky_mcp_pitch: measured_static_dynamic + pinky_pip: measured_dynamic_cad_static + pinky_dip: measured_dynamic_cad_static diff --git a/src/linkerhand_calibration/config/profiles/o6_right_8.yaml b/src/linkerhand_calibration/config/profiles/o6_right_8.yaml new file mode 100644 index 0000000..778ee50 --- /dev/null +++ b/src/linkerhand_calibration/config/profiles/o6_right_8.yaml @@ -0,0 +1,368 @@ +schema_version: 1 +profile_id: O6/right/o6_right_8/v1 +namespace: /o6_calibration +sdk_adapter: legacy_byte_sdk +command: + sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1] + names: + - thumb_cmc_pitch + - thumb_cmc_yaw + - index_mcp_pitch + - middle_mcp_pitch + - ring_mcp_pitch + - pinky_mcp_pitch + baseline_u8: + - 255 + - 255 + - 255 + - 255 + - 255 + - 255 + command_index_by_joint: + rh_thumb_cmc_pitch: 0 + rh_thumb_cmc_yaw: 1 + rh_index_mcp_pitch: 2 + rh_middle_mcp_pitch: 3 + rh_ring_mcp_pitch: 4 + rh_pinky_mcp_pitch: 5 + disabled_indices: [] + urdf_joint_by_joint: + rh_thumb_cmc_pitch: rh_thumb_cmc_pitch + rh_thumb_cmc_yaw: rh_thumb_cmc_yaw + rh_index_mcp_pitch: rh_index_mcp_pitch + rh_middle_mcp_pitch: rh_middle_mcp_pitch + rh_ring_mcp_pitch: rh_ring_mcp_pitch + rh_pinky_mcp_pitch: rh_pinky_mcp_pitch + feedback_name_aliases: {} + speed_slot_by_command_index: + '0': 0 + '1': 1 + '2': 2 + '3': 3 + '4': 4 + '5': 5 + unit: u8 + baseline: [] + lower_bounds: [] + upper_bounds: [] + feedback_lower_bounds: [] + feedback_upper_bounds: [] + feedback_by_index: false +vision: + views: + - name: front + tags: + - role: front_base + fixed_reference: true + id: 0 + - role: thumb_pitch + fixed_reference: false + id: 1 + - role: thumb_ip + fixed_reference: false + id: 2 + - name: side + tags: + - role: side_base + fixed_reference: true + id: 3 + - role: pinky_pitch + fixed_reference: false + id: 4 + - role: pinky_dip + fixed_reference: false + id: 5 + - name: top + tags: + - role: top_base + fixed_reference: true + id: 6 + - role: thumb_yaw + fixed_reference: false + id: 7 + common_frame: calibration_common + extrinsic_reference_view: front + extrinsics_quality_limits: + reprojection_rms_px: 1.5 + maximum_rotation_repeatability_deg: 0.3 + maximum_translation_repeatability_m: 0.0015 + minimum_capture_counts: + front_side_captures: 15 + front_top_captures: 15 +motion: + tasks: + - key: thumb_yaw_top + view: top + command_index: 1 + joints: + - rh_thumb_cmc_yaw + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 60 + formal_speed_u8: 40 + start: null + end: null + preflight_speed: null + formal_speed: null + - key: thumb_pitch_ip_front + view: front + command_index: 0 + joints: + - rh_thumb_cmc_pitch + - rh_thumb_ip + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 60 + formal_speed_u8: 40 + start: null + end: null + preflight_speed: null + formal_speed: null + - key: pinky_pitch_dip_side + view: side + command_index: 5 + joints: + - rh_pinky_mcp_pitch + - rh_pinky_dip + auxiliary_commands: [] + validation_only: false + start_u8: 255 + end_u8: 0 + preflight_speed_u8: 60 + formal_speed_u8: 40 + start: null + end: null + preflight_speed: null + formal_speed: null + preparation_waypoints_u8: [] + safe_return_waypoints_u8: [] + speed_parameters: + baseline_u8: 80 + preflight_u8: 60 + formal_u8: 40 + speed_settle_seconds: 0.2 + command_trajectory_full_range_seconds: 6.0 + torque_u8: 80 + endpoint_hold_seconds: 1.0 + stall_timeout_seconds: 2.0 + precheck_sweeps: false + steady_command_checkpoints: false +measurement: + measurements: + rh_thumb_cmc_yaw: + joint: rh_thumb_cmc_yaw + kind: relative_rotation + view: top + parent_role: top_base + child_role: thumb_yaw + validation_source: null + pose_axis_line_required: true + rh_thumb_cmc_pitch: + joint: rh_thumb_cmc_pitch + kind: relative_rotation + view: front + parent_role: front_base + child_role: thumb_pitch + validation_source: null + pose_axis_line_required: true + rh_thumb_ip: + joint: rh_thumb_ip + kind: relative_rotation + view: front + parent_role: thumb_pitch + child_role: thumb_ip + validation_source: null + pose_axis_line_required: true + rh_pinky_mcp_pitch: + joint: rh_pinky_mcp_pitch + kind: relative_rotation + view: side + parent_role: side_base + child_role: pinky_pitch + validation_source: null + pose_axis_line_required: true + rh_pinky_dip: + joint: rh_pinky_dip + kind: relative_rotation + view: side + parent_role: pinky_pitch + child_role: pinky_dip + validation_source: null + pose_axis_line_required: true + cross_view_sources: {} + image_curve_joints: [] + directional_zero: true + cross_view_roll_curve: false + stable_cross_view_cone_bias: false +zero: + active_joints: + - rh_index_mcp_pitch + - rh_middle_mcp_pitch + - rh_pinky_mcp_pitch + - rh_ring_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_yaw + passive_joints: + - rh_index_dip + - rh_middle_dip + - rh_pinky_dip + - rh_ring_dip + - rh_thumb_ip + direct_zero_joints: + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_yaw + axis_joints: + - rh_pinky_dip + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_yaw + - rh_thumb_ip + mechanical_endpoint_joints: [] + post_solve_endpoint_joints: [] + mimic_source_by_joint: + rh_thumb_ip: rh_thumb_cmc_pitch + rh_index_dip: rh_index_mcp_pitch + rh_middle_dip: rh_middle_mcp_pitch + rh_ring_dip: rh_ring_mcp_pitch + rh_pinky_dip: rh_pinky_mcp_pitch + cad_frozen_joints: + - rh_index_dip + - rh_middle_dip + - rh_pinky_dip + - rh_ring_dip + - rh_thumb_ip + endpoint_anchor_by_joint: {} + fitted_mimic_joints: + - rh_pinky_dip + - rh_thumb_ip + coupling_model_by_joint: + rh_thumb_ip: linear_mimic + rh_index_dip: linear_mimic + rh_middle_dip: linear_mimic + rh_ring_dip: linear_mimic + rh_pinky_dip: linear_mimic + transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch} + transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip} + spatial: + base_pose_strategy: thumb_serial + root_anchor_joints: [rh_thumb_cmc_yaw] + orientation_anchor_joint: rh_pinky_mcp_pitch + directed_base_axis_joints: [rh_thumb_cmc_yaw, rh_pinky_mcp_pitch] + depth_free_axis_projection: true + axis_order: [rh_thumb_cmc_yaw, rh_thumb_cmc_pitch, rh_thumb_ip, rh_pinky_mcp_pitch, rh_pinky_dip] + axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_yaw} + phase_parent_joint: {rh_thumb_ip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch} + offset_observer_joint: {rh_thumb_cmc_yaw: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_ip, rh_pinky_mcp_pitch: rh_pinky_dip} +quality: + training_cycles: + - 0 + - 1 + - 2 + holdout_cycle: 3 + hard_threshold_keys: + - maximum_mimic_residual_rad + - maximum_state_image_skew_ms + - maximum_validation_error_rad + - minimum_detection_rate + retry_metric_scope: {} + isolated_holdout: true +scope: + calibrate_joints: + partial: + - rh_pinky_mcp_pitch + - rh_thumb_cmc_pitch + - rh_thumb_cmc_yaw + frozen_joints: + partial: + - rh_index_mcp_pitch + - rh_middle_mcp_pitch + - rh_ring_mcp_pitch + default_scope: partial +artifacts: + output_schema_version: 2 + calibration_filename: o6_right_{serial_number}_partial_calibration.json + corrected_urdf_filename: linkerhand_o6_right_{serial_number}_partial_zero_calibrated.urdf + protected_input_fields: + - calibration_config_sha256 + - camera_extrinsics_sha256 + - profile_config_sha256 + - source_urdf_sha256 + - tag_config_sha256 + publication_pointer: latest_partial_passed + session_compatibility_tokens: + - feedback_curves_v6 + - o6_partial_v1 + publish_corrected_urdf: true +acquisition: + policy_version: unified_engine_v4_dual_mapping + mapping_probe_maximum_rad: 0.0 + automatic_rescan_limit: 1 + minimum_valid_samples: 40 + minimum_bins: 32 + maximum_unobserved_fraction: 0.0625 + legacy_minimum_span_01: 0.9411764705882353 + physical_first_cycle_minimum_span_01: 0.85 + physical_repeat_minimum_fraction: 0.9 + stall_timeout_seconds: 2.0 + feedback_stale_seconds: 1.0 + fixed_reference_minimum_frames: 10 + fixed_reference_maximum_drift_px: 5.0 + fixed_reference_confirmation_frames: 10 +urdf: + authorized_fields: + rh_pinky_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_index_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_thumb_cmc_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_middle_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_ring_mcp_pitch: + - limit.lower + - limit.upper + - origin.rpy + rh_thumb_cmc_yaw: + - limit.lower + - limit.upper + - origin.rpy + rh_ring_dip: + - mimic.multiplier + - mimic.offset + rh_index_dip: + - mimic.multiplier + - mimic.offset + rh_pinky_dip: + - mimic.multiplier + - mimic.offset + rh_thumb_ip: + - mimic.multiplier + - mimic.offset + rh_middle_dip: + - mimic.multiplier + - mimic.offset +joint_coverage: + rh_pinky_mcp_pitch: measured_static_dynamic + rh_thumb_cmc_pitch: measured_static_dynamic + rh_middle_mcp_pitch: transferred_static_dynamic + rh_index_mcp_pitch: transferred_static_dynamic + rh_ring_mcp_pitch: transferred_static_dynamic + rh_thumb_cmc_yaw: measured_static_dynamic + rh_pinky_dip: measured_dynamic_cad_static + rh_thumb_ip: measured_dynamic_cad_static + rh_middle_dip: transferred_dynamic_cad_static + rh_ring_dip: transferred_dynamic_cad_static + rh_index_dip: transferred_dynamic_cad_static diff --git a/src/linkerhand_calibration/config/schemas/hand_profile.schema.yaml b/src/linkerhand_calibration/config/schemas/hand_profile.schema.yaml new file mode 100644 index 0000000..c194df5 --- /dev/null +++ b/src/linkerhand_calibration/config/schemas/hand_profile.schema.yaml @@ -0,0 +1,44 @@ +$schema: https://json-schema.org/draft/2020-12/schema +title: LinkerHand calibration profile +type: object +additionalProperties: false +required: + - schema_version + - profile_id + - namespace + - sdk_adapter + - command + - vision + - motion + - measurement + - zero + - quality + - scope + - artifacts + - urdf +properties: + schema_version: {const: 1} + profile_id: {type: string, pattern: "^[A-Za-z0-9_-]+/(left|right)/[a-z0-9_-]+/v[1-9][0-9]*$"} + namespace: {type: string, pattern: "^/[^/].*[^/]$"} + sdk_adapter: {type: string, minLength: 1} + command: {type: object} + vision: {type: object} + motion: {type: object} + measurement: {type: object} + zero: {type: object} + quality: {type: object} + scope: {type: object} + artifacts: {type: object} + acquisition: {type: object} + urdf: + type: object + required: [authorized_fields] + properties: + authorized_fields: + type: object + additionalProperties: + type: array + uniqueItems: true + items: + enum: [origin.rpy, limit.lower, limit.upper, mimic.multiplier, mimic.offset] + joint_coverage: {type: object} diff --git a/src/linkerhand_calibration/config/schemas/product.schema.yaml b/src/linkerhand_calibration/config/schemas/product.schema.yaml new file mode 100644 index 0000000..f58ae27 --- /dev/null +++ b/src/linkerhand_calibration/config/schemas/product.schema.yaml @@ -0,0 +1,14 @@ +$schema: https://json-schema.org/draft/2020-12/schema +title: LinkerHand calibration product +type: object +required: [schema_version, profile_id, serial_number, cameras, artifacts] +properties: + schema_version: {type: integer, minimum: 2} + profile_id: {type: string} + profile_config: {type: string} + serial_number: {type: string, pattern: "^[A-Za-z0-9_.-]+$"} + namespace: {type: string} + sdk: {type: object} + cameras: {type: object, minProperties: 1} + artifacts: {type: object} + release: {type: object} diff --git a/src/linkerhand_calibration/config/three_camera_calibration.yaml b/src/linkerhand_calibration/config/three_camera_calibration.yaml index 8881a26..6ffc19f 100644 --- a/src/linkerhand_calibration/config/three_camera_calibration.yaml +++ b/src/linkerhand_calibration/config/three_camera_calibration.yaml @@ -45,7 +45,6 @@ g20_calibration: minimum_edge_pixels: 30.0 pnp_maximum_reprojection_error_px: 1.5 - pnp_reprojection_tie_px: 1.5 pnp_maximum_pose_jump_deg: 35.0 pnp_maximum_translation_jump_m: 0.04 pnp_maximum_tag_tilt_deg: 75.0 @@ -110,7 +109,7 @@ g20_calibration: # roll零位127必须从两个方向到位并静止采集,禁止用运动中经过127的帧判回差。 baseline_hold_seconds: 0.5 minimum_baseline_hold_frames: 10 - # unified_engine_v1 不执行每任务全行程预检;保留参数仅兼容旧配置读取。 + # unified_engine_v2 不执行每任务全行程预检;保留参数仅兼容旧配置读取。 task_precheck_hold_seconds: 2.0 position_timeout_seconds: 30.0 sweep_timeout_seconds: 90.0 diff --git a/src/linkerhand_calibration/launch/front_cmc_pitch_zero.launch.py b/src/linkerhand_calibration/launch/front_cmc_pitch_zero.launch.py deleted file mode 100644 index 3e6f19c..0000000 --- a/src/linkerhand_calibration/launch/front_cmc_pitch_zero.launch.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Launch front-camera trajectory-circle CMC pitch zero measurement.""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -import re - -from ament_index_python.packages import get_package_share_directory -from launch import LaunchDescription -from launch.actions import ( - DeclareLaunchArgument, - LogInfo, - OpaqueFunction, - SetEnvironmentVariable, -) -from launch.conditions import IfCondition -from launch.substitutions import LaunchConfiguration -from launch_ros.actions import ComposableNodeContainer, Node -from launch_ros.descriptions import ComposableNode -from launch_ros.parameter_descriptions import ParameterValue - - -def _launch_stack(context): - serial_number = LaunchConfiguration("serial_number").perform(context) - if ( - not serial_number - or serial_number == "UNSET" - or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None - or serial_number in {".", ".."} - ): - raise RuntimeError( - "serial_number is required and may contain only letters, " - "digits, dot, underscore and dash" - ) - - requested_session = LaunchConfiguration("session_dir").perform(context) - output_root = Path( - LaunchConfiguration("output_root").perform(context) - ).expanduser().resolve() - if requested_session: - session_dir = Path(requested_session).expanduser().resolve() - else: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session_dir = output_root / serial_number / timestamp - session_dir.mkdir(parents=True, exist_ok=True) - - tag_config = LaunchConfiguration("tag_config").perform(context) - zero_config = LaunchConfiguration("zero_config").perform(context) - camera = Node( - package="linkerhand_calibration", - executable="hikrobot_camera_node", - name="hikrobot_camera", - namespace="/camera/camera/color", - output="screen", - emulate_tty=True, - condition=IfCondition(LaunchConfiguration("start_camera")), - parameters=[ - { - "serial_number": LaunchConfiguration("camera_serial_number"), - "expected_model": LaunchConfiguration("camera_model"), - "camera_name": LaunchConfiguration("camera_name"), - "frame_id": LaunchConfiguration("camera_frame_id"), - "image_width": ParameterValue( - LaunchConfiguration("image_width"), value_type=int - ), - "image_height": ParameterValue( - LaunchConfiguration("image_height"), value_type=int - ), - "frame_rate": ParameterValue( - LaunchConfiguration("camera_frame_rate"), value_type=float - ), - "exposure_time_us": ParameterValue( - LaunchConfiguration("exposure_time_us"), value_type=float - ), - "gain_db": ParameterValue( - LaunchConfiguration("gain_db"), value_type=float - ), - "auto_exposure": ParameterValue( - LaunchConfiguration("auto_exposure"), value_type=bool - ), - "camera_info_url": LaunchConfiguration("camera_info_url"), - } - ], - ) - - raw_topic = "/camera/camera/color/image_raw" - camera_info_topic = "/camera/camera/color/camera_info" - rect_topic = "/camera/camera/color/image_rect" - vision_container = ComposableNodeContainer( - name="g20_thumb_zero_vision_container", - namespace="/", - package="rclcpp_components", - executable="component_container_mt", - composable_node_descriptions=[ - ComposableNode( - package="image_proc", - plugin="image_proc::RectifyNode", - name="rectify_color", - namespace="/camera/camera/color", - remappings=[ - ("image", raw_topic), - ("camera_info", camera_info_topic), - ("image_rect", rect_topic), - ], - parameters=[{"queue_size": 1}], - extra_arguments=[{"use_intra_process_comms": True}], - ), - ComposableNode( - package="apriltag_ros", - plugin="AprilTagNode", - name="apriltag", - namespace="/apriltag", - parameters=[ - tag_config, - { - "detector.decimate": ParameterValue( - LaunchConfiguration("apriltag_decimate"), - value_type=float, - ) - }, - ], - remappings=[ - ("image_rect", rect_topic), - ("camera_info", camera_info_topic), - ], - extra_arguments=[{"use_intra_process_comms": True}], - ), - ], - output="screen", - emulate_tty=True, - ) - - sdk = Node( - package="linker_hand_ros2_sdk", - executable="linker_hand_sdk", - name="linker_hand_sdk", - output="screen", - condition=IfCondition(LaunchConfiguration("start_sdk")), - parameters=[ - { - "hand_type": "left", - "hand_joint": "G20", - "can": LaunchConfiguration("can_interface"), - "modbus": "None", - "topic_prefix": "/g20", - "move_on_startup": False, - "startup_speed": ParameterValue( - LaunchConfiguration("calibration_speed"), - value_type=int, - ), - "startup_torque": 80, - "state_poll_rate": 10.0, - "repeat_position_commands": False, - "is_touch": False, - } - ], - ) - - zero_node = Node( - package="linkerhand_calibration", - executable="cmc_pitch_zero_node", - name="g20_thumb_cmc_pitch_zero", - output="screen", - parameters=[ - zero_config, - { - "serial_number": serial_number, - "session_dir": str(session_dir), - "commands_enabled": ParameterValue( - LaunchConfiguration("commands_enabled"), - value_type=bool, - ), - "image_topic": rect_topic, - "publish_debug_image": ParameterValue( - LaunchConfiguration("publish_debug_image"), - value_type=bool, - ), - }, - ], - ) - - return [ - LogInfo(msg=f"G20 CMC pitch zero session: {session_dir}"), - LogInfo( - msg=( - "Only T0(ID 0) and T3(ID 1) are required; " - "T4/T5 detections are ignored" - ) - ), - LogInfo( - msg=( - "Motor 0 performs three 255->64->255 sweeps; " - "zero angles come from the fitted T3-centre trajectory radius" - ) - ), - camera, - vision_container, - sdk, - zero_node, - ] - - -def generate_launch_description() -> LaunchDescription: - package_share = Path( - get_package_share_directory("linkerhand_calibration") - ) - return LaunchDescription( - [ - SetEnvironmentVariable( - name="FASTRTPS_DEFAULT_PROFILES_FILE", - value=str( - package_share / "config" / "fastdds_large_images.xml" - ), - ), - DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument( - "camera_serial_number", default_value="DB2163742" - ), - DeclareLaunchArgument( - "camera_model", default_value="MV-CS020-10UM" - ), - DeclareLaunchArgument( - "camera_name", default_value="hikrobot_front_DB2163742" - ), - DeclareLaunchArgument( - "camera_frame_id", default_value="camera_color_optical_frame" - ), - DeclareLaunchArgument("image_width", default_value="1624"), - DeclareLaunchArgument("image_height", default_value="1240"), - DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), - DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), - DeclareLaunchArgument("gain_db", default_value="0.0"), - DeclareLaunchArgument("auto_exposure", default_value="false"), - DeclareLaunchArgument( - "camera_info_url", - default_value=str( - Path.home() - / ".ros" - / "camera_info" - / "hikrobot_DB2163742.yaml" - ), - ), - DeclareLaunchArgument("can_interface", default_value="can0"), - DeclareLaunchArgument("calibration_speed", default_value="15"), - DeclareLaunchArgument("apriltag_decimate", default_value="1.5"), - DeclareLaunchArgument("commands_enabled", default_value="true"), - DeclareLaunchArgument( - "publish_debug_image", default_value="true" - ), - DeclareLaunchArgument("start_camera", default_value="true"), - DeclareLaunchArgument("start_sdk", default_value="true"), - DeclareLaunchArgument( - "output_root", - default_value=str(Path.cwd() / "calibration_output"), - ), - DeclareLaunchArgument("session_dir", default_value=""), - DeclareLaunchArgument( - "zero_config", - default_value=str( - package_share / "config" / "cmc_pitch_zero.yaml" - ), - ), - DeclareLaunchArgument( - "tag_config", - default_value=str( - package_share / "config" / "front_tags.yaml" - ), - ), - OpaqueFunction(function=_launch_stack), - ] - ) diff --git a/src/linkerhand_calibration/launch/front_cmc_roll_calibration.launch.py b/src/linkerhand_calibration/launch/front_cmc_roll_calibration.launch.py deleted file mode 100644 index 4c8c10b..0000000 --- a/src/linkerhand_calibration/launch/front_cmc_roll_calibration.launch.py +++ /dev/null @@ -1,276 +0,0 @@ -"""Launch front-camera trajectory-circle CMC roll zero/travel calibration.""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -import re - -from ament_index_python.packages import get_package_share_directory -from launch import LaunchDescription -from launch.actions import ( - DeclareLaunchArgument, - LogInfo, - OpaqueFunction, - SetEnvironmentVariable, -) -from launch.conditions import IfCondition -from launch.substitutions import LaunchConfiguration -from launch_ros.actions import ComposableNodeContainer, Node -from launch_ros.descriptions import ComposableNode -from launch_ros.parameter_descriptions import ParameterValue - - -def _launch_stack(context): - serial_number = LaunchConfiguration("serial_number").perform(context) - if ( - not serial_number - or serial_number == "UNSET" - or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None - or serial_number in {".", ".."} - ): - raise RuntimeError( - "serial_number is required and may contain only letters, " - "digits, dot, underscore and dash" - ) - - requested_session = LaunchConfiguration("session_dir").perform(context) - output_root = Path( - LaunchConfiguration("output_root").perform(context) - ).expanduser().resolve() - if requested_session: - session_dir = Path(requested_session).expanduser().resolve() - else: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session_dir = output_root / serial_number / timestamp - session_dir.mkdir(parents=True, exist_ok=True) - - tag_config = LaunchConfiguration("tag_config").perform(context) - calibration_config = LaunchConfiguration( - "calibration_config" - ).perform(context) - camera = Node( - package="linkerhand_calibration", - executable="hikrobot_camera_node", - name="hikrobot_camera", - namespace="/camera/camera/color", - output="screen", - emulate_tty=True, - condition=IfCondition(LaunchConfiguration("start_camera")), - parameters=[ - { - "serial_number": LaunchConfiguration("camera_serial_number"), - "expected_model": LaunchConfiguration("camera_model"), - "camera_name": LaunchConfiguration("camera_name"), - "frame_id": LaunchConfiguration("camera_frame_id"), - "image_width": ParameterValue( - LaunchConfiguration("image_width"), value_type=int - ), - "image_height": ParameterValue( - LaunchConfiguration("image_height"), value_type=int - ), - "frame_rate": ParameterValue( - LaunchConfiguration("camera_frame_rate"), value_type=float - ), - "exposure_time_us": ParameterValue( - LaunchConfiguration("exposure_time_us"), value_type=float - ), - "gain_db": ParameterValue( - LaunchConfiguration("gain_db"), value_type=float - ), - "auto_exposure": ParameterValue( - LaunchConfiguration("auto_exposure"), value_type=bool - ), - "camera_info_url": LaunchConfiguration("camera_info_url"), - } - ], - ) - - raw_topic = "/camera/camera/color/image_raw" - camera_info_topic = "/camera/camera/color/camera_info" - rect_topic = "/camera/camera/color/image_rect" - vision_container = ComposableNodeContainer( - name="g20_thumb_roll_vision_container", - namespace="/", - package="rclcpp_components", - executable="component_container_mt", - composable_node_descriptions=[ - ComposableNode( - package="image_proc", - plugin="image_proc::RectifyNode", - name="rectify_color", - namespace="/camera/camera/color", - remappings=[ - ("image", raw_topic), - ("camera_info", camera_info_topic), - ("image_rect", rect_topic), - ], - parameters=[{"queue_size": 1}], - extra_arguments=[{"use_intra_process_comms": True}], - ), - ComposableNode( - package="apriltag_ros", - plugin="AprilTagNode", - name="apriltag", - namespace="/apriltag", - parameters=[ - tag_config, - { - "detector.decimate": ParameterValue( - LaunchConfiguration("apriltag_decimate"), - value_type=float, - ) - }, - ], - remappings=[ - ("image_rect", rect_topic), - ("camera_info", camera_info_topic), - ], - extra_arguments=[{"use_intra_process_comms": True}], - ), - ], - output="screen", - emulate_tty=True, - ) - - sdk = Node( - package="linker_hand_ros2_sdk", - executable="linker_hand_sdk", - name="linker_hand_sdk", - output="screen", - condition=IfCondition(LaunchConfiguration("start_sdk")), - parameters=[ - { - "hand_type": "left", - "hand_joint": "G20", - "can": LaunchConfiguration("can_interface"), - "modbus": "None", - "topic_prefix": "/g20", - "move_on_startup": False, - "startup_speed": ParameterValue( - LaunchConfiguration("calibration_speed"), - value_type=int, - ), - "startup_torque": 80, - "state_poll_rate": 10.0, - "repeat_position_commands": False, - "is_touch": False, - } - ], - ) - - calibration_node = Node( - package="linkerhand_calibration", - executable="cmc_roll_calibration_node", - name="g20_thumb_cmc_roll_calibration", - output="screen", - parameters=[ - calibration_config, - { - "serial_number": serial_number, - "session_dir": str(session_dir), - "commands_enabled": ParameterValue( - LaunchConfiguration("commands_enabled"), - value_type=bool, - ), - "image_topic": rect_topic, - "publish_debug_image": ParameterValue( - LaunchConfiguration("publish_debug_image"), - value_type=bool, - ), - }, - ], - ) - - return [ - LogInfo(msg=f"G20 CMC roll calibration session: {session_dir}"), - LogInfo( - msg=( - "Only T0(ID 0) and T3(ID 1) are required; " - "T4/T5 detections are ignored" - ) - ), - LogInfo( - msg=( - "Motor 5 performs three 255->0->255 sweeps; " - "static captures measure both zero and angular travel" - ) - ), - camera, - vision_container, - sdk, - calibration_node, - ] - - -def generate_launch_description() -> LaunchDescription: - package_share = Path( - get_package_share_directory("linkerhand_calibration") - ) - return LaunchDescription( - [ - SetEnvironmentVariable( - name="FASTRTPS_DEFAULT_PROFILES_FILE", - value=str( - package_share / "config" / "fastdds_large_images.xml" - ), - ), - DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument( - "camera_serial_number", default_value="DB2163742" - ), - DeclareLaunchArgument( - "camera_model", default_value="MV-CS020-10UM" - ), - DeclareLaunchArgument( - "camera_name", default_value="hikrobot_front_DB2163742" - ), - DeclareLaunchArgument( - "camera_frame_id", default_value="camera_color_optical_frame" - ), - DeclareLaunchArgument("image_width", default_value="1624"), - DeclareLaunchArgument("image_height", default_value="1240"), - DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), - DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), - DeclareLaunchArgument("gain_db", default_value="0.0"), - DeclareLaunchArgument("auto_exposure", default_value="false"), - DeclareLaunchArgument( - "camera_info_url", - default_value=str( - Path.home() - / ".ros" - / "camera_info" - / "hikrobot_DB2163742.yaml" - ), - ), - DeclareLaunchArgument("can_interface", default_value="can0"), - DeclareLaunchArgument("calibration_speed", default_value="15"), - DeclareLaunchArgument("apriltag_decimate", default_value="1.5"), - DeclareLaunchArgument("commands_enabled", default_value="true"), - DeclareLaunchArgument( - "publish_debug_image", default_value="true" - ), - DeclareLaunchArgument("start_camera", default_value="true"), - DeclareLaunchArgument("start_sdk", default_value="true"), - DeclareLaunchArgument( - "output_root", - default_value=str(Path.cwd() / "calibration_output"), - ), - DeclareLaunchArgument("session_dir", default_value=""), - DeclareLaunchArgument( - "calibration_config", - default_value=str( - package_share - / "config" - / "cmc_roll_zero_travel.yaml" - ), - ), - DeclareLaunchArgument( - "tag_config", - default_value=str( - package_share / "config" / "front_tags.yaml" - ), - ), - OpaqueFunction(function=_launch_stack), - ] - ) diff --git a/src/linkerhand_calibration/launch/front_thumb_calibration.launch.py b/src/linkerhand_calibration/launch/front_thumb_calibration.launch.py deleted file mode 100644 index 3c3284d..0000000 --- a/src/linkerhand_calibration/launch/front_thumb_calibration.launch.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Launch the complete front-camera G20 thumb calibration stack.""" - -from __future__ import annotations - -from datetime import datetime -from pathlib import Path -import re - -from ament_index_python.packages import get_package_share_directory -from launch import LaunchDescription -from launch.actions import ( - DeclareLaunchArgument, - ExecuteProcess, - LogInfo, - OpaqueFunction, - SetEnvironmentVariable, -) -from launch.conditions import IfCondition -from launch.substitutions import LaunchConfiguration -from launch_ros.actions import ComposableNodeContainer, Node -from launch_ros.descriptions import ComposableNode -from launch_ros.parameter_descriptions import ParameterValue - - -def _launch_stack(context): - serial_number = LaunchConfiguration("serial_number").perform(context) - if not serial_number or serial_number == "UNSET": - raise RuntimeError( - "serial_number is required, for example serial_number:=G20_LEFT_001" - ) - if ( - re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None - or serial_number in {".", ".."} - ): - raise RuntimeError( - "serial_number may contain only letters, digits, dot, underscore and dash" - ) - requested_session = LaunchConfiguration("session_dir").perform(context) - output_root = Path(LaunchConfiguration("output_root").perform(context)).resolve() - if requested_session: - session_dir = Path(requested_session).expanduser().resolve() - else: - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session_dir = output_root / serial_number / timestamp - session_dir.mkdir(parents=True, exist_ok=True) - bag_path = session_dir / "rosbag" - - calibration_config = LaunchConfiguration("calibration_config").perform(context) - tag_config = LaunchConfiguration("tag_config").perform(context) - use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower() - if use_roi_text not in {"true", "false"}: - raise RuntimeError("use_roi must be true or false") - use_roi = use_roi_text == "true" - - roi_values = {} - for name in ("roi_x", "roi_y", "roi_width", "roi_height"): - text = LaunchConfiguration(name).perform(context) - try: - roi_values[name] = int(text) - except ValueError as error: - raise RuntimeError(f"{name} must be an integer") from error - if roi_values["roi_x"] < 0 or roi_values["roi_y"] < 0: - raise RuntimeError("roi_x and roi_y must be non-negative") - if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0: - raise RuntimeError("roi_width and roi_height must be positive") - - try: - image_width = int(LaunchConfiguration("image_width").perform(context)) - image_height = int(LaunchConfiguration("image_height").perform(context)) - except ValueError as error: - raise RuntimeError("image_width and image_height must be integers") from error - if image_width <= 0 or image_height <= 0: - raise RuntimeError("image_width and image_height must be positive") - if use_roi: - if ( - roi_values["roi_x"] + roi_values["roi_width"] > image_width - or roi_values["roi_y"] + roi_values["roi_height"] > image_height - ): - raise RuntimeError( - "ROI lies outside camera image " - f"{image_width}x{image_height}" - ) - - camera = Node( - package="linkerhand_calibration", - executable="hikrobot_camera_node", - name="hikrobot_camera", - namespace="/camera/camera/color", - output="screen", - emulate_tty=True, - condition=IfCondition(LaunchConfiguration("start_camera")), - parameters=[ - { - "serial_number": LaunchConfiguration("camera_serial_number"), - "expected_model": LaunchConfiguration("camera_model"), - "camera_name": LaunchConfiguration("camera_name"), - "frame_id": LaunchConfiguration("camera_frame_id"), - "image_width": ParameterValue( - LaunchConfiguration("image_width"), value_type=int - ), - "image_height": ParameterValue( - LaunchConfiguration("image_height"), value_type=int - ), - "frame_rate": ParameterValue( - LaunchConfiguration("camera_frame_rate"), value_type=float - ), - "exposure_time_us": ParameterValue( - LaunchConfiguration("exposure_time_us"), value_type=float - ), - "gain_db": ParameterValue( - LaunchConfiguration("gain_db"), value_type=float - ), - "auto_exposure": ParameterValue( - LaunchConfiguration("auto_exposure"), value_type=bool - ), - "camera_info_url": LaunchConfiguration("camera_info_url"), - } - ], - ) - - vision_components = [] - if use_roi: - processed_image_raw_topic = "/g20_thumb_roi/image_raw" - processed_camera_info_topic = "/g20_thumb_roi/camera_info" - processed_image_rect_topic = "/g20_thumb_roi/image_rect" - vision_components.append( - ComposableNode( - package="image_proc", - plugin="image_proc::CropDecimateNode", - name="crop_color_roi", - namespace="/g20_thumb_roi", - remappings=[ - ("in/image_raw", "/camera/camera/color/image_raw"), - ("in/camera_info", "/camera/camera/color/camera_info"), - ("out/image_raw", processed_image_raw_topic), - ("out/camera_info", processed_camera_info_topic), - ], - parameters=[ - { - "queue_size": 5, - "decimation_x": 1, - "decimation_y": 1, - "offset_x": roi_values["roi_x"], - "offset_y": roi_values["roi_y"], - "width": roi_values["roi_width"], - "height": roi_values["roi_height"], - } - ], - extra_arguments=[{"use_intra_process_comms": True}], - ) - ) - rectifier_namespace = "/g20_thumb_roi" - rectifier_name = "rectify_color_roi" - else: - processed_image_raw_topic = "/camera/camera/color/image_raw" - processed_camera_info_topic = "/camera/camera/color/camera_info" - processed_image_rect_topic = "/camera/camera/color/image_rect" - rectifier_namespace = "/camera/camera/color" - rectifier_name = "rectify_color" - - vision_components.append( - ComposableNode( - package="image_proc", - plugin="image_proc::RectifyNode", - name=rectifier_name, - namespace=rectifier_namespace, - remappings=[ - ("image", processed_image_raw_topic), - ("camera_info", processed_camera_info_topic), - ("image_rect", processed_image_rect_topic), - ], - parameters=[{"queue_size": 1}], - extra_arguments=[{"use_intra_process_comms": True}], - ) - ) - - vision_components.append( - ComposableNode( - package="apriltag_ros", - plugin="AprilTagNode", - name="apriltag", - namespace="/apriltag", - parameters=[ - tag_config, - { - "detector.decimate": ParameterValue( - LaunchConfiguration("apriltag_decimate"), - value_type=float, - ) - }, - ], - remappings=[ - ("image_rect", processed_image_rect_topic), - ("camera_info", processed_camera_info_topic), - ], - extra_arguments=[{"use_intra_process_comms": True}], - ) - ) - - vision_container = ComposableNodeContainer( - name="g20_thumb_vision_container", - namespace="/", - package="rclcpp_components", - executable="component_container_mt", - composable_node_descriptions=vision_components, - output="screen", - emulate_tty=True, - ) - - sdk = Node( - package="linker_hand_ros2_sdk", - executable="linker_hand_sdk", - name="linker_hand_sdk", - output="screen", - condition=IfCondition(LaunchConfiguration("start_sdk")), - parameters=[ - { - "hand_type": "left", - "hand_joint": "G20", - "can": LaunchConfiguration("can_interface"), - "modbus": "None", - "topic_prefix": "/g20", - "move_on_startup": False, - "startup_speed": ParameterValue( - LaunchConfiguration("calibration_speed"), - value_type=int, - ), - "startup_torque": 80, - "state_poll_rate": 10.0, - "repeat_position_commands": False, - "is_touch": False, - } - ], - ) - - calibration = Node( - package="linkerhand_calibration", - executable="calibration_node", - name="g20_thumb_calibration", - output="screen", - parameters=[ - calibration_config, - tag_config, - { - "serial_number": serial_number, - "session_dir": str(session_dir), - "commands_enabled": LaunchConfiguration("commands_enabled"), - "calibration_speed": ParameterValue( - LaunchConfiguration("calibration_speed"), - value_type=int, - ), - "continuous_motion_mode": ParameterValue( - LaunchConfiguration("continuous_motion_mode"), - value_type=str, - ), - "angle_estimation_mode": ParameterValue( - LaunchConfiguration("angle_estimation_mode"), - value_type=str, - ), - "camera_serial_number": LaunchConfiguration("camera_serial_number"), - "rosbag_path": str(bag_path), - "camera_info_topic": processed_camera_info_topic, - "image_topic": processed_image_rect_topic, - "publish_debug_image": LaunchConfiguration( - "publish_debug_image" - ), - }, - ], - ) - - bag = ExecuteProcess( - condition=IfCondition(LaunchConfiguration("record_bag")), - cmd=[ - "ros2", - "bag", - "record", - "--storage", - "mcap", - "--storage-preset-profile", - "zstd_fast", - "--max-bag-size", - "10737418240", - "--output", - str(bag_path), - processed_image_raw_topic, - processed_camera_info_topic, - "/apriltag/detections", - "/tf", - "/g20/cb_left_hand_control_cmd", - "/g20/cb_left_hand_state", - "/g20/cb_left_hand_info", - "/g20_thumb_calibration/status", - ], - output="screen", - ) - - actions = [ - LogInfo(msg=f"G20 thumb calibration session: {session_dir}"), - LogInfo( - msg=( - "G20 thumb image ROI: " - f"x={roi_values['roi_x']}, y={roi_values['roi_y']}, " - f"width={roi_values['roi_width']}, " - f"height={roi_values['roi_height']}" - if use_roi - else "G20 thumb image ROI: disabled" - ) - ), - camera, - vision_container, - ] - actions.extend([sdk, calibration, bag]) - return actions - - -def generate_launch_description() -> LaunchDescription: - package_share = Path( - get_package_share_directory("linkerhand_calibration") - ) - default_output = str(Path.cwd() / "calibration_output") - return LaunchDescription( - [ - SetEnvironmentVariable( - name="FASTRTPS_DEFAULT_PROFILES_FILE", - value=str( - package_share / "config" / "fastdds_large_images.xml" - ), - ), - DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument( - "camera_serial_number", default_value="DB2163742" - ), - DeclareLaunchArgument( - "camera_model", default_value="MV-CS020-10UM" - ), - DeclareLaunchArgument( - "camera_name", default_value="hikrobot_front_DB2163742" - ), - DeclareLaunchArgument( - "camera_frame_id", default_value="camera_color_optical_frame" - ), - DeclareLaunchArgument("image_width", default_value="1624"), - DeclareLaunchArgument("image_height", default_value="1240"), - DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), - DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), - DeclareLaunchArgument("gain_db", default_value="0.0"), - DeclareLaunchArgument("auto_exposure", default_value="false"), - DeclareLaunchArgument( - "camera_info_url", - default_value=str( - Path.home() - / ".ros" - / "camera_info" - / "hikrobot_DB2163742.yaml" - ), - ), - DeclareLaunchArgument( - "publish_debug_image", default_value="false" - ), - DeclareLaunchArgument("use_roi", default_value="false"), - DeclareLaunchArgument("roi_x", default_value="128"), - DeclareLaunchArgument("roi_y", default_value="192"), - DeclareLaunchArgument("roi_width", default_value="1024"), - DeclareLaunchArgument("roi_height", default_value="528"), - DeclareLaunchArgument("can_interface", default_value="can0"), - DeclareLaunchArgument("calibration_speed", default_value="15"), - DeclareLaunchArgument( - "continuous_motion_mode", default_value="endpoint" - ), - DeclareLaunchArgument( - "angle_estimation_mode", - default_value="trajectory_center_3d", - ), - DeclareLaunchArgument("apriltag_decimate", default_value="1.5"), - DeclareLaunchArgument("commands_enabled", default_value="true"), - DeclareLaunchArgument("start_camera", default_value="true"), - DeclareLaunchArgument("start_sdk", default_value="true"), - DeclareLaunchArgument("record_bag", default_value="false"), - DeclareLaunchArgument("output_root", default_value=default_output), - DeclareLaunchArgument("session_dir", default_value=""), - DeclareLaunchArgument( - "calibration_config", - default_value=str(package_share / "config" / "calibration.yaml"), - ), - DeclareLaunchArgument( - "tag_config", - default_value=str(package_share / "config" / "front_tags.yaml"), - ), - OpaqueFunction(function=_launch_stack), - ] - ) diff --git a/src/linkerhand_calibration/launch/three_camera_calibration.launch.py b/src/linkerhand_calibration/launch/three_camera_calibration.launch.py index 579032b..7dab455 100644 --- a/src/linkerhand_calibration/launch/three_camera_calibration.launch.py +++ b/src/linkerhand_calibration/launch/three_camera_calibration.launch.py @@ -1,4 +1,4 @@ -"""Launch three Hikrobot views and one registered hand calibration owner.""" +"""Launch Profile-declared Hikrobot views and one calibration owner.""" from __future__ import annotations @@ -23,32 +23,12 @@ from launch_ros.descriptions import ComposableNode from launch_ros.parameter_descriptions import ParameterValue -VIEWS = ("front", "side", "top") - - -def _default_source_urdf(model: str, hand_type: str) -> Path: - relative = ( - Path("urdf") / "l6_right" / "linkerhand_l6v3.1_right.urdf" - if model.upper() == "L6" and hand_type == "right" - else Path("urdf") - / f"{model.lower()}_{hand_type}" - / f"linkerhand_{model.lower()}_{hand_type}.urdf" - ) - package_source_or_share = Path(__file__).resolve().parents[1] / relative - try: - installed = ( - Path(get_package_share_directory("linkerhand_calibration")) - / relative - ) - except Exception: - installed = package_source_or_share - return installed if installed.is_file() else package_source_or_share - - def _launch_stack(context): from linkerhand_calibration.product import ( - get_product_calibration_contract, + ProductCalibrationContract, ) + from linkerhand_calibration.profiles import load_hand_profile + from linkerhand_calibration.runtime.adapters.ros_topics import sdk_topics model = LaunchConfiguration("model").perform(context).strip().upper() hand_type = LaunchConfiguration("hand_type").perform(context).lower() @@ -56,53 +36,35 @@ def _launch_stack(context): raise RuntimeError("hand_type must be left or right") tag_layout = LaunchConfiguration("tag_layout").perform(context).lower() try: - contract = get_product_calibration_contract( - model, hand_type, tag_layout - ) + profile_path = LaunchConfiguration("profile_config").perform(context).strip() + if not profile_path: + raise ValueError("online calibration requires a protected YAML Profile; use calibrate_hand --config") + expected = LaunchConfiguration("profile_config_expected_sha256").perform(context).strip() + if not expected or hashlib.sha256(Path(profile_path).read_bytes()).hexdigest() != expected: + raise ValueError("Profile changed between product validation and launch") + contract = ProductCalibrationContract(declarative=load_hand_profile(profile_path)) + key = contract.typed_profile.key + if (key.model, key.side, key.layout) != (model, hand_type, tag_layout): + raise ValueError("launch identity differs from the protected Profile") except ValueError as error: raise RuntimeError(str(error)) from error + views = contract.typed_profile.vision.view_names requested_tag_config = LaunchConfiguration("tag_config").perform(context) - package_share = Path( - get_package_share_directory("linkerhand_calibration") - ) - tag_config = ( - Path(requested_tag_config).expanduser().resolve() - if requested_tag_config - else package_share - / "config" - / ( - "three_camera_tags_g20_right_19.yaml" - if tag_layout == "g20_right_19" - else "o12_right_16_tags.yaml" - if tag_layout == "o12_right_16" - else "o6_right_8_tags.yaml" - if tag_layout == "o6_right_8" - else "l6_right_8_tags.yaml" - if tag_layout == "l6_right_8" - else "three_camera_tags_g20_right_15.yaml" - if tag_layout == "g20_right_15" - else "three_camera_tags.yaml" - ) - ) + if not requested_tag_config: + raise RuntimeError("tag_config is required; use calibrate_hand --config") + tag_config = Path(requested_tag_config).expanduser().resolve() if not tag_config.is_file(): raise RuntimeError(f"tag config does not exist: {tag_config}") topic_prefix = f"/{model.lower()}" - is_o12 = model == "O12" - command_topic = ( - f"{topic_prefix}/{hand_type}/joint_cmd" - if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_control_cmd" - ) - state_topic = ( - f"{topic_prefix}/{hand_type}/joint_states" - if is_o12 else f"{topic_prefix}/cb_{hand_type}_hand_state" - ) - info_topic = f"{topic_prefix}/cb_{hand_type}_hand_info" + uses_hcan = contract.typed_profile.sdk_adapter == "o12_hcan_sdk" + if contract.typed_profile.sdk_adapter not in {"legacy_byte_sdk", "o12_hcan_sdk"}: + raise RuntimeError("SDK adapter has no ROS launch binding") + topics = sdk_topics(contract.typed_profile) + command_topic, state_topic = topics.command, topics.feedback requested_source = LaunchConfiguration("source_urdf_path").perform(context) - source_urdf = ( - Path(requested_source).expanduser().resolve() - if requested_source - else _default_source_urdf(model, hand_type).resolve() - ) + if not requested_source: + raise RuntimeError("source_urdf_path is required; use calibrate_hand --config") + source_urdf = Path(requested_source).expanduser().resolve() if not source_urdf.is_file(): raise RuntimeError(f"source URDF does not exist: {source_urdf}") expected_source_hash = LaunchConfiguration( @@ -145,12 +107,12 @@ def _launch_stack(context): camera_serials = { view: LaunchConfiguration(f"{view}_camera_serial").perform(context) - for view in VIEWS + for view in views } if any(not serial for serial in camera_serials.values()): - raise RuntimeError("all three camera serial numbers are required") - if len(set(camera_serials.values())) != 3: - raise RuntimeError("front/side/top camera serial numbers must be unique") + raise RuntimeError("all Profile camera serial numbers are required") + if len(set(camera_serials.values())) != len(views): + raise RuntimeError("Profile camera serial numbers must be unique") cameras = [] components = [] @@ -158,7 +120,7 @@ def _launch_stack(context): info_topics = [] detection_topics = [] calibration_namespace = contract.typed_profile.namespace - for view in VIEWS: + for view in views: namespace = f"{calibration_namespace}/{view}/camera" raw_topic = f"{namespace}/image_raw" info_topic = f"{namespace}/camera_info" @@ -251,7 +213,7 @@ def _launch_stack(context): ) vision = ComposableNodeContainer( - name=f"{model.lower()}_three_camera_vision", + name=f"{model.lower()}_calibration_vision", namespace="/", package="rclcpp_components", executable="component_container_mt", @@ -261,15 +223,21 @@ def _launch_stack(context): ) sdk = ( Node( - package="omnihand_node", - executable="omnihand_pro_2025_node", - name="omnihand_pro_2025_node", - namespace="o12", + package="linkerhand_calibration", + executable="o12_sdk_bridge", + name="o12_sdk_bridge", output="screen", condition=IfCondition(LaunchConfiguration("start_sdk")), - parameters=[LaunchConfiguration("vendor_sdk_config")], + parameters=[{ + "vendor_config": LaunchConfiguration("vendor_sdk_config"), + "vendor_config_sha256": LaunchConfiguration("sdk_config_expected_sha256"), + "vendor_python_package": LaunchConfiguration("vendor_sdk_python_package"), + "vendor_package_sha256": LaunchConfiguration("sdk_package_expected_sha256"), + "hand_type": hand_type, + "topic_prefix": f"/{model.lower()}/{hand_type}", + }], ) - if is_o12 + if uses_hcan else Node( package="linker_hand_ros2_sdk", executable="linker_hand_sdk", @@ -312,26 +280,16 @@ def _launch_stack(context): arguments=[ "--profile-id", contract.typed_profile.key.profile_id, + "--profile-config", profile_path, "--profile-sha256", expected, ], parameters=[ LaunchConfiguration("calibration_config"), { "serial_number": hand_serial, - "model": model, - "hand_type": hand_type, - "tag_layout": tag_layout, "session_dir": str(session_dir), "resume_raw_samples_path": LaunchConfiguration( "resume_raw_samples_path" ), - "recalibration_scope": LaunchConfiguration( - "recalibration_scope" - ), - # The SDK performs roughly 25 synchronous CAN queries whenever - # cb__hand_info has a subscriber. Calibration only used - # that topic to display a speed diagnostic, while those reads - # created 17-33 command-unit holes in position trajectories. - "info_topic": f"{calibration_namespace}/disabled_hand_info", "command_topic": command_topic, "state_topic": state_topic, "camera_extrinsics_file": LaunchConfiguration( @@ -353,37 +311,13 @@ def _launch_stack(context): "sdk_config_expected_sha256": LaunchConfiguration( "sdk_config_expected_sha256" ), - "corrected_urdf_output_dir": LaunchConfiguration( - "corrected_urdf_output_dir" + "sdk_package_expected_sha256": LaunchConfiguration("sdk_package_expected_sha256"), + "profile_config_expected_sha256": LaunchConfiguration( + "profile_config_expected_sha256" ), - **{ - f"{view}_camera_serial": camera_serials[view] - for view in VIEWS - }, "commands_enabled": ParameterValue( LaunchConfiguration("commands_enabled"), value_type=bool ), - "normal_calibration_speed": ParameterValue( - LaunchConfiguration("calibration_speed"), value_type=int - ), - "index_roll_calibration_speed": ParameterValue( - LaunchConfiguration("index_roll_calibration_speed"), - value_type=int, - ), - "index_flex_calibration_speed": ParameterValue( - LaunchConfiguration("index_flex_calibration_speed"), - value_type=int, - ), - "adaptive_formal_speed_enabled": ParameterValue( - LaunchConfiguration("adaptive_formal_speed_enabled"), - value_type=bool, - ), - "cross_view_roll_diagnostic_finger": LaunchConfiguration( - "cross_view_roll_diagnostic_finger" - ), - "validation_enabled": ParameterValue( - LaunchConfiguration("validation_enabled"), value_type=bool - ), }, ], ) @@ -406,14 +340,11 @@ def _launch_stack(context): *detection_topics, command_topic, state_topic, - info_topic, *( [ - "/o12/right/joint_control_mode_states", - "/o12/right/joint_error_states", - "/o12/right/joint_temperature_states", + f"/{model.lower()}/{hand_type}/calibration_health", ] - if is_o12 else [] + if uses_hcan else [] ), f"{calibration_namespace}/status", ], @@ -422,15 +353,13 @@ def _launch_stack(context): return [ LogInfo( msg=( - f"{model} {hand_type} {tag_layout} three-camera session: {session_dir}; " + f"{model} {hand_type} {tag_layout} calibration session: {session_dir}; " f"source_urdf={source_urdf}" ) ), LogInfo( msg=( - "Camera mapping: front=" - f"{camera_serials['front']} side={camera_serials['side']} " - f"top={camera_serials['top']}" + "Camera mapping: " + " ".join(f"{view}={serial}" for view, serial in camera_serials.items()) ) ), *cameras, @@ -445,7 +374,6 @@ def generate_launch_description() -> LaunchDescription: package_share = Path( get_package_share_directory("linkerhand_calibration") ) - info_root = Path.home() / ".ros" / "camera_info" return LaunchDescription( [ # Camera processes publish ~2 MB frames across DDS. Force the @@ -463,41 +391,11 @@ def generate_launch_description() -> LaunchDescription: name="FASTRTPS_DEFAULT_PROFILES_FILE", value=str(package_share / "config" / "fastdds_large_images.xml"), ), - DeclareLaunchArgument("model", default_value="G20"), - DeclareLaunchArgument("hand_type", default_value="left"), - DeclareLaunchArgument("tag_layout", default_value="legacy_11"), + DeclareLaunchArgument("model", default_value=""), + DeclareLaunchArgument("hand_type", default_value=""), + DeclareLaunchArgument("tag_layout", default_value=""), DeclareLaunchArgument("serial_number", default_value="UNSET"), - DeclareLaunchArgument( - "front_camera_serial", default_value="DB2163742" - ), - DeclareLaunchArgument( - "side_camera_serial", default_value="DB2163749" - ), - DeclareLaunchArgument( - "top_camera_serial", default_value="DB2163739" - ), DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"), - DeclareLaunchArgument( - "front_camera_name", default_value="hikrobot_front_DB2163742" - ), - DeclareLaunchArgument( - "side_camera_name", default_value="hikrobot_side_DB2163749" - ), - DeclareLaunchArgument( - "top_camera_name", default_value="hikrobot_top_DB2163739" - ), - DeclareLaunchArgument( - "front_camera_info_url", - default_value=str(info_root / "hikrobot_DB2163742.yaml"), - ), - DeclareLaunchArgument( - "side_camera_info_url", - default_value=str(info_root / "hikrobot_DB2163749.yaml"), - ), - DeclareLaunchArgument( - "top_camera_info_url", - default_value=str(info_root / "hikrobot_DB2163739.yaml"), - ), DeclareLaunchArgument("camera_frame_rate", default_value="30.0"), DeclareLaunchArgument("exposure_time_us", default_value="5000.0"), DeclareLaunchArgument("gain_db", default_value="0.0"), @@ -505,25 +403,7 @@ def generate_launch_description() -> LaunchDescription: DeclareLaunchArgument("apriltag_decimate", default_value="1.5"), DeclareLaunchArgument("can_interface", default_value="can0"), DeclareLaunchArgument("calibration_speed", default_value="15"), - DeclareLaunchArgument( - "index_roll_calibration_speed", default_value="5" - ), - DeclareLaunchArgument( - "index_flex_calibration_speed", default_value="10" - ), - DeclareLaunchArgument( - "adaptive_formal_speed_enabled", default_value="true" - ), - DeclareLaunchArgument( - "cross_view_roll_diagnostic_finger", default_value="" - ), - DeclareLaunchArgument("validation_enabled", default_value="false"), - DeclareLaunchArgument( - "camera_extrinsics_file", - default_value=str( - Path.cwd() / "config" / "g20_three_camera_extrinsics.yaml" - ), - ), + DeclareLaunchArgument("camera_extrinsics_file", default_value=""), DeclareLaunchArgument( "source_urdf_path", default_value="" ), @@ -540,12 +420,15 @@ def generate_launch_description() -> LaunchDescription: "tag_config_expected_sha256", default_value="" ), DeclareLaunchArgument("vendor_sdk_config", default_value=""), + DeclareLaunchArgument("vendor_sdk_python_package", default_value=""), + DeclareLaunchArgument("sdk_package_expected_sha256", default_value=""), DeclareLaunchArgument( "sdk_config_expected_sha256", default_value="" ), DeclareLaunchArgument( - "corrected_urdf_output_dir", default_value="" + "profile_config_expected_sha256", default_value="" ), + DeclareLaunchArgument("profile_config", default_value=""), DeclareLaunchArgument("commands_enabled", default_value="true"), DeclareLaunchArgument("start_cameras", default_value="true"), DeclareLaunchArgument("start_sdk", default_value="true"), @@ -556,12 +439,9 @@ def generate_launch_description() -> LaunchDescription: ), DeclareLaunchArgument("session_dir", default_value=""), DeclareLaunchArgument("resume_raw_samples_path", default_value=""), - DeclareLaunchArgument("recalibration_scope", default_value="full"), DeclareLaunchArgument( "calibration_config", - default_value=str( - package_share / "config" / "three_camera_calibration.yaml" - ), + default_value="", ), DeclareLaunchArgument( "tag_config", diff --git a/src/linkerhand_calibration/launch/unified_calibration.launch.py b/src/linkerhand_calibration/launch/unified_calibration.launch.py new file mode 100644 index 0000000..8bb155d --- /dev/null +++ b/src/linkerhand_calibration/launch/unified_calibration.launch.py @@ -0,0 +1,18 @@ +"""Stable launch name for the profile-driven calibration stack.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + + +def generate_launch_description(): + implementation = Path(__file__).with_name("three_camera_calibration.launch.py") + spec = importlib.util.spec_from_file_location( + "linkerhand_unified_calibration_launch", implementation + ) + if spec is None or spec.loader is None: + raise RuntimeError("unified calibration launch implementation is missing") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.generate_launch_description() diff --git a/src/linkerhand_calibration/linkerhand_calibration/calibrated_joint_state_bridge.py b/src/linkerhand_calibration/linkerhand_calibration/calibrated_joint_state_bridge.py index be87ad9..6ecb9a1 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/calibrated_joint_state_bridge.py +++ b/src/linkerhand_calibration/linkerhand_calibration/calibrated_joint_state_bridge.py @@ -1,4 +1,7 @@ -"""Map model SDK u8 feedback to URDF joint angles using one calibration JSON. +"""Map explicit SDK commands or feedback to corrected URDF joint coordinates. + +Unified artifacts load their certified manifest and standard URDF. The old +readers below are used only when a historical payload is explicitly supplied. The static encoder-zero corrections in ``zero_angles`` are already baked into the corrected URDF joint origins. This bridge therefore publishes only the @@ -22,16 +25,6 @@ import rclpy from rclpy.node import Node from sensor_msgs.msg import JointState -from .full_hand import ( - get_hand_calibration_profile, - infer_compact_payload_layout, - validate_compact_payload, -) -from .models import get_default_registry, validate_schema_v6_runtime_payload -from .models.o12.artifacts import validate_o12_runtime_payload -from .core import ProfileKey - - G20_COMMAND_NAMES: tuple[str, ...] = ( "thumb_cmc_pitch", "index_mcp_pitch", @@ -89,6 +82,10 @@ class CalibratedCommandMapper: def __init__( self, payload: Mapping[str, Any], *, expected_side: str | None = None ) -> None: + from .full_hand import get_hand_calibration_profile, infer_compact_payload_layout, validate_compact_payload + from .compat.legacy_diagnostic_tools.models import get_default_registry, validate_schema_v6_runtime_payload + from .compat.legacy_diagnostic_tools.models.o12.artifacts import validate_o12_runtime_payload + from .core import ProfileKey schema_version = int(payload["schema_version"]) if schema_version == 7: validate_o12_runtime_payload(payload) @@ -273,12 +270,15 @@ class CalibratedCommandMapper: def load_calibrated_command_mapper( - calibration_file: str | Path, *, expected_side: str | None = None + calibration_file: str | Path, *, expected_side: str | None = None, input_kind="command" ) -> CalibratedCommandMapper: path = Path(calibration_file).expanduser().resolve() if not path.is_file(): raise ValueError(f"calibration JSON does not exist: {path}") payload = json.loads(path.read_text(encoding="utf-8")) + if path.name == "release_manifest.json" or payload.get("format") in {"unified_calibration_v1", "unified_calibration_v2"}: + from .runtime.artifacts.reader import load_unified_mapper + return load_unified_mapper(path, expected_side=expected_side, input_kind=input_kind) return CalibratedCommandMapper(payload, expected_side=expected_side) @@ -294,6 +294,8 @@ def default_input_topic( return f"/{str(model).lower()}/cb_{side}_hand_control_cmd" if input_domain == "feedback_rad": return f"/{str(model).lower()}/{side}/joint_states" + if input_domain == "command_rad": + return f"/{str(model).lower()}/{side}/joint_cmd" raise ValueError("calibration curve_input_domain is invalid") @@ -304,6 +306,7 @@ class CalibratedJointStateBridge(Node): self.declare_parameter("calibration_file", "") self.declare_parameter("input_topic", "") self.declare_parameter("output_topic", "") + self.declare_parameter("input_kind", "command") hand_type = str(self.get_parameter("hand_type").value).lower() if hand_type not in {"left", "right"}: @@ -312,7 +315,7 @@ class CalibratedJointStateBridge(Node): if not calibration_file: raise ValueError("calibration_file is required") self.mapper = load_calibrated_command_mapper( - calibration_file, expected_side=hand_type + calibration_file, expected_side=hand_type, input_kind=str(self.get_parameter("input_kind").value) ) input_topic = str(self.get_parameter("input_topic").value).strip() output_topic = str(self.get_parameter("output_topic").value).strip() @@ -323,6 +326,9 @@ class CalibratedJointStateBridge(Node): output_topic or f"/sim/mujoco/{self.mapper.model.lower()}/{hand_type}/joint_state" ) + if self.output_topic in {self.input_topic, default_input_topic(hand_type, + "feedback_rad" if self.mapper.input_domain.endswith("rad") else "feedback_u8", self.mapper.model)}: + raise ValueError("mapped output must not overwrite the real SDK command/feedback topic") self.publisher = self.create_publisher(JointState, self.output_topic, 10) self.subscription = self.create_subscription( JointState, self.input_topic, self._command_callback, 10 diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/README.md b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/README.md new file mode 100644 index 0000000..71fc3de --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/README.md @@ -0,0 +1,14 @@ +# 历史兼容区 + +此目录保留旧格式读写、旧布局、历史数据诊断及必要的离线工具,不参与正式在线调度。 +四型号的 runner/node/pipeline 已由 `runtime/runner.py`、`runtime/session.py` 和 +`runtime/artifacts/finalization.py` 替代。只保留仍被历史工具调用的入口; +L6/O6 无调用的 pipeline 包装已删除。 + +旧独立断点实现、低速预检和在线节点已移除。旧发布函数已拒绝更新正式发布指针。 +这里生成的离线诊断文件不能作为标准 URDF 已验收的证据;正式回放使用 +`calibrate_hand --config --offline-raw `。 + +不要在此目录增加新型号。新型号提供 Profile、产品 YAML、原始 CAD/mesh;仅新 SDK 协议增加 Adapter。 +需要恢复已删除的历史实现时,使用工作区 +`calibration_output/refactor_backup.4WRWNn/` 中的归档,不要重新接入生产入口。 diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/__init__.py new file mode 100644 index 0000000..581de90 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/__init__.py @@ -0,0 +1 @@ +"""Read-only compatibility for archived sessions and diagnostic tools.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/__init__.py similarity index 59% rename from src/linkerhand_calibration/linkerhand_calibration/models/__init__.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/__init__.py index a86bee6..1962558 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/__init__.py @@ -1,12 +1,12 @@ """Model- and side-specific calibration policies.""" -from .registry import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ( EngineBindings, ProfileRegistry, RegisteredProfile, get_default_registry, ) -from .runtime_schema import validate_schema_v6_runtime_payload +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.runtime_schema import validate_schema_v6_runtime_payload __all__ = [ "EngineBindings", diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/__init__.py new file mode 100644 index 0000000..ce3e88f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/__init__.py @@ -0,0 +1,15 @@ +"""Registered profiles for this hand family.""" + +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry + + +def register_profiles(registry: ProfileRegistry) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.legacy_11 import build_left_profile, build_right_profile + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.right_19 import build_profile + + registry.register(build_profile()) + registry.register(build_left_profile()) + registry.register(build_right_profile()) + + +__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/_adapter.py similarity index 78% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/_adapter.py index e544e89..7bf7d45 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/_adapter.py @@ -2,7 +2,7 @@ from __future__ import annotations -from ...core import ( +from linkerhand_calibration.core import ( CalibrationProfile, CommandLayout, MeasurementPolicy, @@ -17,10 +17,10 @@ from ...core import ( VisionRigSpec, ZeroSolvePolicy, ) -from .profile import MIMIC_DERIVED_FINGER_DIPS -from ..registry import EngineBindings, RegisteredProfile -from .artifacts import build_artifact_policy -from .motion import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.artifacts import build_artifact_policy +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.motion import ( build_calibration_motion_command, build_calibration_preparation_waypoints, build_calibration_return_waypoints, @@ -39,18 +39,18 @@ _HARD_THRESHOLD_KEYS = frozenset( def _run_cli(args: list[str] | None = None) -> None: - from .runner import main + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.runner import main main(args) def _run_node(args: list[str] | None = None) -> None: - from .node import main + from linkerhand_calibration.runtime.ros.entrypoint import main main(args) -def adapt_profile( +def _legacy_typed_profile( *, key: ProfileKey, namespace: str, @@ -58,7 +58,7 @@ def adapt_profile( zero_profile, mechanical_endpoint_joints: frozenset[str] = frozenset(), post_solve_endpoint_joints: frozenset[str] = frozenset(), -) -> RegisteredProfile: +) -> CalibrationProfile: fixed_by_view = { view: frozenset(roles) for view, roles in hand_profile.preflight_view_roles.items() @@ -203,7 +203,33 @@ def adapt_profile( artifacts=build_artifact_policy( frozenset(hand_profile.capabilities) ), + urdf_authorized_fields={ + name: frozenset({"origin.rpy", "limit.upper"}) + for name in active + } | { + name: frozenset({"mimic.offset"}) + for name in passive + if name in zero_profile.static_output_zero_offsets_rad + }, ) + return typed + + +def adapt_profile( + *, key: ProfileKey, namespace: str, hand_profile, zero_profile, + mechanical_endpoint_joints: frozenset[str] = frozenset(), + post_solve_endpoint_joints: frozenset[str] = frozenset(), + typed_profile: CalibrationProfile | None = None, +) -> RegisteredProfile: + # Current products supply their authoritative YAML; only the archived + # 11-Tag compatibility contract is still compiled from its old definition. + typed = typed_profile if typed_profile is not None else _legacy_typed_profile( + key=key, namespace=namespace, hand_profile=hand_profile, + zero_profile=zero_profile, mechanical_endpoint_joints=mechanical_endpoint_joints, + post_solve_endpoint_joints=post_solve_endpoint_joints, + ) + if typed.key != key or typed.namespace != namespace: + raise ValueError("declarative profile identity differs from product binding") return RegisteredProfile( profile=typed, engine=EngineBindings( diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/artifacts.py similarity index 89% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/artifacts.py index d7d5cc3..c801bd4 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/artifacts.py @@ -1,6 +1,6 @@ """Runtime JSON and corrected-URDF naming policy.""" -from ...core import ArtifactPolicy +from linkerhand_calibration.core import ArtifactPolicy def build_artifact_policy( @@ -18,6 +18,7 @@ def build_artifact_policy( "camera_extrinsics_sha256", "calibration_config_sha256", "tag_config_sha256", + "profile_config_sha256", } ), session_compatibility_tokens=frozenset(compatibility_tokens), diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/command_layout.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/command_layout.py similarity index 100% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/command_layout.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/command_layout.py diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/golden_regression.py similarity index 97% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/golden_regression.py index d2de771..c4f0e2c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/golden_regression.py @@ -99,7 +99,7 @@ def validate_golden_sessions( raise ValueError(f"failure session was published through {pointer_name}") if replay_full_session: - from .offline_replay import replay_session + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.offline_replay import replay_session session_id = "20260830_181154" replay = replay_session(root / session_id, write_outputs=False) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/legacy_11.py similarity index 66% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/legacy_11.py index 9a5223e..bcd0708 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/legacy_11.py @@ -1,10 +1,10 @@ """One-release typed wrappers for the legacy 11-Tag layouts.""" -from ...core import ProfileKey -from .profile import get_hand_calibration_profile -from ...urdf_zero import get_zero_calibration_profile -from ..registry import RegisteredProfile -from ._adapter import adapt_profile +from linkerhand_calibration.core import ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import get_hand_calibration_profile +from linkerhand_calibration.urdf_zero import get_zero_calibration_profile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile LEFT_KEY = ProfileKey("G20", "left", "legacy_11", 1) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/motion.py similarity index 82% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/motion.py index 465bd0f..c7d0dc5 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/motion.py @@ -1,6 +1,6 @@ """Reviewed motion and safe-waypoint strategy exports.""" -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import ( build_calibration_motion_command, build_calibration_preparation_waypoints, build_calibration_return_waypoints, diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/offline_replay.py similarity index 98% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/offline_replay.py index b81ea41..385ba35 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/offline_replay.py @@ -19,10 +19,10 @@ import numpy as np from scipy.spatial.transform import Rotation import yaml -from ...compat import default_three_camera_config_path -from ...compat.legacy import uses_coupled_full_hand_zero_solver -from ...extrinsics import load_three_camera_extrinsics -from .profile import ( +from linkerhand_calibration.compat import default_three_camera_config_path +from linkerhand_calibration.compat.legacy import uses_coupled_full_hand_zero_solver +from linkerhand_calibration.extrinsics import load_three_camera_extrinsics +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import ( G20_REFERENCE_THUMB_CMC_JOINTS, G20_RIGHT_19_LAYOUT, RIGHT_19_END_ON_IMAGE_CURVE_JOINTS, @@ -38,15 +38,15 @@ from .profile import ( get_hand_calibration_profile, validate_compact_payload, ) -from ...product import get_product_calibration_contract -from ...sample_schema import fitting_sample_record, fitting_sample_records -from ...storage import atomic_write_json -from .publication import clamp_compact_payload_to_urdf_limits -from .urdf_input import ( +from linkerhand_calibration.product import get_product_calibration_contract +from linkerhand_calibration.sample_schema import fitting_sample_record, fitting_sample_records +from linkerhand_calibration.storage import atomic_write_json +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.publication import clamp_compact_payload_to_urdf_limits +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.urdf_input import ( build_g20_urdf_input_payload, load_g20_urdf_input, ) -from .zero_solver import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import ( JointAxisMeasurement, RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS, UrdfKinematicModel, diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/profile.py similarity index 97% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/profile.py index b38bede..1a40084 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/profile.py @@ -17,15 +17,15 @@ import xml.etree.ElementTree as ET import numpy as np -from .command_layout import G20_COMMAND_NAMES as COMMAND_NAMES -from ...trajectory import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.command_layout import G20_COMMAND_NAMES as COMMAND_NAMES +from linkerhand_calibration.trajectory import ( _angle_for_circle, _fit_circle_with_axis, _fit_joint_curve, _fit_plane_axis, _orient_circle_positive, ) -from ...zero_calibration import ( +from linkerhand_calibration.zero_calibration import ( _fit_circle, _trajectory_arc_rad, circular_median_rad, @@ -41,72 +41,15 @@ THREE_CAMERA_BASELINE_COMMAND: tuple[int, ...] = ( ) -@dataclass(frozen=True) -class JointSpec: - name: str - motor_index: int - active: bool - view: str | None - parent_role: str | None - child_role: str | None - source_joint: str | None = None - zero_kind: str | None = None - # Keep the physical axis-line gate only when that line contributes to a - # released URDF zero/axis decision. Curve-only passive measurements may - # retain the monocular line residual as a diagnostic while their image - # trajectory, relative rotation, synchronisation and holdout gates remain - # release-critical. - pose_axis_line_required: bool = True - - @property - def measured(self) -> bool: - return self.source_joint is None +from linkerhand_calibration.core.domain.measurement import ( + JointSpec, SweepSpec, PalmAxisObserver, JointCurveFit +) -@dataclass(frozen=True) -class SweepSpec: - view: str - motor_index: int - joints: tuple[str, ...] - task_name: str = "" - auxiliary_commands: tuple[tuple[int, int], ...] = () - validation_only: bool = False - - @property - def key(self) -> str: - if self.task_name: - return self.task_name - return f"{self.view}:motor{self.motor_index}:{','.join(self.joints)}" -@dataclass(frozen=True) -class PalmAxisObserver: - """Non-blocking direction observation attached to an existing task. - - This is deliberately not a ``JointSpec``: it has no curve, endpoint or - retry semantics. The camera callback records it only while both Tags are - visible during the named sweep. - """ - - source_name: str - task_name: str - view: str - parent_role: str - child_role: str - model_joint: str - motor_index: int -@dataclass(frozen=True) -class JointCurveFit: - angle_rad: tuple[float, ...] - decreasing_rad: tuple[float, ...] - increasing_rad: tuple[float, ...] - circle: Mapping[str, Any] - maximum_monotonic_correction_rad: float - maximum_hysteresis_rad: float - quality: Mapping[str, float] - zero_offset_rad: float = 0.0 @dataclass(frozen=True) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/publication.py similarity index 73% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/publication.py index 0650610..4ecae2e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/publication.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime, timezone import copy import json import math @@ -15,16 +14,16 @@ import xml.etree.ElementTree as ET import numpy as np from scipy.spatial.transform import Rotation -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import ( G20_COMBINATION_REQUIRED_TARGET_KEYS, G20_RIGHT_19_LAYOUT, get_hand_calibration_profile, validate_compact_payload, ) -from ...product import ProductConfig, sha256_file -from ...storage import atomic_write_json -from ...core.urdf import UrdfCorrectionPlan, build_correction_plan -from .zero_solver import ( +from linkerhand_calibration.product import ProductConfig, sha256_file +from linkerhand_calibration.storage import atomic_write_json +from linkerhand_calibration.core.urdf import UrdfCorrectionPlan, build_correction_plan +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import ( RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, get_zero_calibration_profile, ) @@ -429,6 +428,8 @@ def build_mujoco_validation_commands( def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path: + if "passed" in name: + raise ValueError("release pointers require the common ArtifactPublisher") parent = Path(root).resolve() target = Path(session).resolve() if target.parent != parent: @@ -682,233 +683,5 @@ def _verify_combination_validation(combination: Mapping[str, Any]) -> None: ) -def finalize_session_artifacts( - config: ProductConfig, - session: str | Path, - *, - node_status: Mapping[str, Any], -) -> tuple[dict[str, Any], bool]: - """Create diagnostics and update latest_passed only after every guard.""" - directory = Path(session).resolve() - paths = session_artifact_paths(directory, config.serial_number) - for name in ("json", "urdf", "raw", "log"): - if not paths[name].is_file(): - raise ValueError(f"session is missing {name}: {paths[name]}") - payload = _load_json(paths["json"]) - standalone_thumb = bool( - payload.get("artifact_type") - == "g20_right_standalone_thumb_calibration" - ) - if standalone_thumb: - standalone_thumb_offsets(payload) - else: - validate_compact_payload(payload) - if payload.get("schema_version") != 4 or payload.get("side") != "right": - raise ValueError("runtime JSON is not the compact right-hand schema v4") - if not bool(payload.get("quality", {}).get("passed")): - raise ValueError("runtime JSON quality is not passed") - combination = node_status.get("combination_validation") - if not isinstance(combination, Mapping): - raise ValueError("node status is missing combination validation") - _verify_combination_validation(combination) - resume = node_status.get("resume", {}) - if not isinstance(resume, Mapping): - raise ValueError("node status has invalid resume provenance") - calibration_scope = str( - resume.get("recalibration_scope", "full") - ).strip().lower() - if calibration_scope not in {"full", "thumb", "fingers"}: - raise ValueError("node status has an unsupported recalibration scope") - recalibration_tasks = tuple( - str(value) for value in resume.get("recalibration_task_keys", ()) - ) - if calibration_scope == "thumb" and not standalone_thumb and ( - not bool(resume.get("used")) - or not str(resume.get("source_session", "")) - or len(recalibration_tasks) != 4 - or any("thumb_" not in name for name in recalibration_tasks) - ): - raise ValueError( - "thumb recalibration is missing its passed base-session provenance" - ) - if calibration_scope == "fingers" and ( - not bool(resume.get("used")) - or not str(resume.get("source_session", "")) - or len(recalibration_tasks) != 12 - or any("thumb_" in name for name in recalibration_tasks) - ): - raise ValueError( - "finger recalibration is missing its certified thumb-session " - "provenance" - ) - if standalone_thumb and calibration_scope != "thumb": - raise ValueError("standalone thumb artifact has the wrong node scope") - offsets = ( - standalone_thumb_offsets(payload) - if standalone_thumb - else active_offsets(payload) - ) - if calibration_scope != "full" and not standalone_thumb: - verify_partial_scope_preserves_certified_zeros( - scope=calibration_scope, - source_session=str(resume.get("source_session", "")), - serial_root=directory.parent, - serial_number=config.serial_number, - current_offsets=offsets, - ) - endpoint_offsets = { - name: offsets[name] - for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS - } - typed_profile = config.calibration_contract.typed_profile - frozen_names = typed_profile.scope.frozen_joints[calibration_scope] - correction_plan = build_correction_plan( - typed_profile, - source_sha256=config.source_urdf_sha256, - scope=calibration_scope, - frozen_offsets_rad={name: offsets[name] for name in frozen_names}, - ) - changed_joints = verify_corrected_urdf( - config.source_urdf, - paths["urdf"], - expected_offsets_rad=offsets, - endpoint_anchored_offsets_rad=endpoint_offsets, - correction_plan=correction_plan, - ) - if standalone_thumb: - clipped_runtime_joints = {} - else: - payload, clipped_runtime_joints = clamp_compact_payload_to_urdf_limits( - payload, paths["urdf"] - ) - if clipped_runtime_joints: - atomic_write_json(paths["json"], payload) - validate_runtime_curves_against_urdf_limits(payload, paths["urdf"]) - mesh_resources = verify_urdf_mesh_resources(paths["urdf"]) - mesh_hashes = { - name: sha256_file(path) for name, path in mesh_resources.items() - } - commands = ( - { - "artifact_type": "standalone_thumb_no_full_hand_validation", - "poses": [], - } - if standalone_thumb - else build_mujoco_validation_commands(payload["baseline_command_u8"]) - ) - atomic_write_json(paths["commands"], commands) - - prior, differences = find_compatible_prior_session(config, directory, payload) - release_ready = config.required_independent_passes == 1 or prior is not None - quality = dict(payload["quality"]) - preserved_certified_zeros = sorted( - name - for name in ACTIVE_ZERO_JOINTS - if ( - calibration_scope == "thumb" - and not name.startswith("thumb_") - ) - or ( - calibration_scope == "fingers" - and name.startswith("thumb_") - ) - ) - if standalone_thumb: - preserved_certified_zeros = [] - calibrated_zero_joints = ( - sorted( - name for name in ACTIVE_ZERO_JOINTS if name.startswith("thumb_") - ) - if standalone_thumb - else sorted(ACTIVE_ZERO_JOINTS) - ) - summary: dict[str, Any] = { - "schema_version": 1, - "serial_number": config.serial_number, - "session_id": f"{config.serial_number}_{directory.name}", - "calibration_scope": calibration_scope, - "inherited_base_session": ( - None - if calibration_scope == "full" or standalone_thumb - else str(resume.get("source_session")) - ), - "freshly_calibrated_task_keys": list(recalibration_tasks), - "preserved_certified_zero_joints": preserved_certified_zeros, - "result": "PASS" if release_ready else "PASS_AWAITING_SECOND_SESSION", - "quality": quality, - "runtime_limit_clipped_bins": clipped_runtime_joints, - "runtime_curve_domain": ( - "thumb_diagnostic_only" - if standalone_thumb - else "requested_command_u8" - ), - "static_zero_calibrated_joints": calibrated_zero_joints, - "non_thumb_zero_policy": ( - "source_cad_unchanged" if standalone_thumb else None - ), - "retained_active_urdf_zero_joints": sorted( - RETAINED_ACTIVE_ZERO_JOINTS - ), - "retained_passive_urdf_joints": sorted(PASSIVE_JOINTS), - "mimic_derived_dynamic_joints": [], - "visually_measured_passive_joints": sorted( - VISUALLY_MEASURED_FINGER_DIPS - ), - "changed_urdf_joint_origins": list(changed_joints), - "hashes": { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "corrected_urdf_sha256": sha256_file(paths["urdf"]), - "mesh_resources_sha256": mesh_hashes, - "calibration_json_sha256": sha256_file(paths["json"]), - "raw_samples_sha256": sha256_file(paths["raw"]), - }, - "holdout": { - "training_cycles": [0, 1, 2], - "validation_cycle": 3, - "isolated": True, - }, - "combination_validation": dict(combination), - "formal_release": { - "passed": release_ready, - "required_independent_passes": config.required_independent_passes, - "comparison_session": None if prior is None else prior.name, - "maximum_static_difference_deg": ( - None if not differences else math.degrees(max(differences.values())) - ), - }, - "node_status": dict(node_status), - "created_at_utc": datetime.now(timezone.utc).isoformat(), - "artifacts": {name: path.name for name, path in paths.items()}, - } - atomic_write_json(paths["summary"], summary) - # Recompute hashes after all files exist and validate the pair once more - # immediately before the one atomic publication operation. - if sha256_file(config.source_urdf) != config.source_urdf_sha256: - raise ValueError("source URDF changed during calibration") - verify_corrected_urdf( - config.source_urdf, - paths["urdf"], - expected_offsets_rad=offsets, - endpoint_anchored_offsets_rad=endpoint_offsets, - ) - # Revalidate the same coordinate contract immediately before publication: - # verify_corrected_urdf proves that corrected endpoint limits map back to - # the original physical CAD endpoints, while the runtime curves must stay - # inside those corrected-coordinate limits. - if not standalone_thumb: - validate_runtime_curves_against_urdf_limits(payload, paths["urdf"]) - final_mesh_hashes = { - name: sha256_file(path) - for name, path in verify_urdf_mesh_resources(paths["urdf"]).items() - } - if final_mesh_hashes != mesh_hashes: - raise ValueError("URDF mesh resources changed during publication") - if release_ready: - atomic_session_pointer( - config.session_root, - "latest_thumb_passed" if standalone_thumb else "latest_passed", - directory, - ) - return summary, release_ready +def finalize_session_artifacts(*args, **kwargs): + raise ValueError("legacy publication is retired; use calibrate_hand --offline-raw with the common finalizer") diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/reporting_zh.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/reporting_zh.py similarity index 100% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/reporting_zh.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/reporting_zh.py diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/right_19.py similarity index 53% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/right_19.py index 4948065..5d6b2aa 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/right_19.py @@ -1,14 +1,15 @@ """Independent reviewed profile for the right 19-Tag product layout.""" -from ...core import ProfileKey -from .profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile -from .zero_policy import ( +from linkerhand_calibration.core import ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_policy import ( RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS, get_zero_calibration_profile, ) -from ..registry import RegisteredProfile -from ._adapter import adapt_profile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20._adapter import adapt_profile +from linkerhand_calibration.profiles.loader import load_bundled_hand_profile KEY = ProfileKey("G20", "right", G20_RIGHT_19_LAYOUT, 1) @@ -24,4 +25,5 @@ def build_profile() -> RegisteredProfile: zero_profile=zero, mechanical_endpoint_joints=RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, post_solve_endpoint_joints=RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS, + typed_profile=load_bundled_hand_profile(KEY.layout), ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/runner.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/runner.py new file mode 100644 index 0000000..686d0f3 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/runner.py @@ -0,0 +1,10 @@ +"""Legacy import path; all online work uses the common product runner.""" + +from linkerhand_calibration.runtime.runner import main +from linkerhand_calibration.compat.legacy_diagnostic_tools.runner_helpers import ( + _automatic_resume_candidate, _calibration_node_exited_before_status, + _launch_command, _resolve_partial_base_session, _status_timeout_seconds, +) + +if __name__ == "__main__": + main() diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/urdf_input.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/urdf_input.py similarity index 100% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/urdf_input.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/urdf_input.py diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_policy.py similarity index 68% rename from src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_policy.py index c2a4bbf..5d225c1 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_policy.py @@ -1,7 +1,7 @@ """Reviewed static-zero, endpoint, and mimic topology exports.""" -from .profile import MIMIC_DERIVED_FINGER_DIPS -from .zero_solver import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import MIMIC_DERIVED_FINGER_DIPS +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import ( RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS, RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS, diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_solver.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_solver.py new file mode 100644 index 0000000..bb724f7 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/g20/zero_solver.py @@ -0,0 +1,931 @@ +"""Three-dimensional joint-axis fitting and URDF zero correction.""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import datetime +import math +from pathlib import Path +import re +from typing import Any, Mapping, Sequence +import xml.etree.ElementTree as ET + +import numpy as np +from scipy.spatial.transform import Rotation + +from linkerhand_calibration.core import fit_rotation_axis, robust_rotation_summary +from linkerhand_calibration.core.urdf import ( + UrdfCorrectionPlan, + UrdfJointPatch, + UrdfPatchSet, + materialize_relative_mesh_assets as _materialize_relative_mesh_assets, + write_urdf_patches, +) +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import ( + G20_RIGHT_19_LAYOUT, + IMAGE_TRAJECTORY_JOINTS, + LEFT_HAND_PROFILE, + RIGHT_19_END_ON_IMAGE_CURVE_JOINTS, + RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS, + HandCalibrationProfile, + JointCurveFit, + get_hand_calibration_profile, +) +from linkerhand_calibration.sample_schema import explicit_domain_value +from linkerhand_calibration.trajectory import ( + _fit_circle_with_axis, + _fit_joint_curve, + _fit_plane_axis, + _plane_basis, +) + + +from linkerhand_calibration.core.fitting import spatial as _spatial +from linkerhand_calibration.core.urdf.kinematics import UrdfKinematicModel, _parse_triplet, _axis_rotation +from linkerhand_calibration.core.fitting.spatial import ( + ZeroCalibrationProfile, + circle_direction_is_constrained, + select_cross_view_roll_direction_source, + _zero_sensitive_axis_error_rad, + _axis_cone_mismatch_rad, + _vector, + _pose_matrix, + _relative_rotation, + _reference_group_key, + _canonical_reference_records, + _interpolate_reference_rotation, + _near_zero_records, + _baseline_reference, + baseline_hysteresis_by_cycle_rad, + fit_rotation_joint_curve, + measure_rotation_joint_observation, + measure_joint_curve_observation, + rotation_curve_holdout_errors, + joint_curve_holdout_errors, + JointAxisMeasurement, + PalmOrientationMeasurement, + _incremental_common_rotation_axis, + fit_partial_palm_orientation_measurement, + fit_partial_palm_orientation_measurements, + with_depth_free_axis_projection, + cross_view_side_line_source, + axis_line_uses_depth_free_interpretation_plane, + refit_axis_line_group_with_shared_radius, + maximum_axis_line_cycle_spread_m, + axis_line_cycle_rms_m, + _fit_axis_point_from_pose_trajectory, + ZeroSolveResult, + _angles_from_state +) + + +def solve_urdf_zero_offsets(**kwargs) -> ZeroSolveResult: + """Compatibility input binding; all numerical work lives in core.""" + options = dict(kwargs) + profile = options.get("zero_profile") + if profile is None: + profile = get_zero_calibration_profile(options.get("hand_type", "left"), options.get("tag_layout", "legacy_11")) + pattern = profile.hand.layout_id == G20_RIGHT_19_LAYOUT + profile = replace(profile, + parallel_root_pattern=pattern, + common_mode_zero_joints=tuple(name for name in profile.direct_zero_joints if name.endswith("_mcp_roll") and not name.startswith("thumb_")) if pattern else (), + small_offset_joints=frozenset(name for name in profile.direct_zero_joints if name.startswith(("index_", "middle_", "ring_", "pinky_"))), + orientation_anchor_joint=profile.orientation_anchor_joint or ("thumb_cmc_pitch" if profile.base_pose_strategy == "thumb_serial" else f"{profile.reference_finger}_mcp_pitch"), + base_pose_strategy="serial_chain" if profile.base_pose_strategy == "thumb_serial" else profile.base_pose_strategy, + ) + options["zero_profile"] = profile + return _spatial.solve_urdf_zero_offsets(**options) + + +def fit_joint_axis_measurement(*args, **kwargs) -> JointAxisMeasurement: + kwargs.setdefault("constrained_circle_joints", CONSTRAINED_CIRCLE_JOINTS) + return _spatial.fit_joint_axis_measurement(*args, **kwargs) + + +def _build_zero_profile(hand: HandCalibrationProfile) -> ZeroCalibrationProfile: + if hand.layout_id == G20_RIGHT_19_LAYOUT: + return _build_right_19_zero_profile(hand) + reference = hand.reference_finger + reference_roll = f"{reference}_mcp_roll" + reference_pitch = f"{reference}_mcp_pitch" + reference_pip = f"{reference}_pip" + reference_dip = f"{reference}_dip" + direct = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + reference_roll, + reference_pitch, + reference_pip, + ) + inherited = { + f"{finger}_{suffix}": f"{reference}_{suffix}" + for finger in ("index", "middle", "ring", "pinky") + if finger != reference + for suffix in ("mcp_roll", "mcp_pitch", "pip") + } + axis_parent = { + "thumb_cmc_yaw": "thumb_cmc_roll", + "thumb_cmc_pitch": "thumb_cmc_yaw", + reference_pitch: reference_roll, + } + phase_parent = { + "thumb_mcp": "thumb_cmc_pitch", + "thumb_ip": "thumb_mcp", + reference_pip: reference_pitch, + reference_dip: reference_pip, + } + observer = { + "thumb_cmc_roll": "thumb_cmc_yaw", + "thumb_cmc_yaw": "thumb_cmc_pitch", + "thumb_cmc_pitch": "thumb_mcp", + "thumb_mcp": "thumb_ip", + reference_roll: reference_pitch, + reference_pitch: reference_pip, + reference_pip: reference_dip, + } + fixed_direct_zero_offsets = { + "thumb_mcp": 0.0, + reference_roll: 0.0, + reference_pitch: 0.0, + reference_pip: 0.0, + } + return ZeroCalibrationProfile( + hand=hand, + direct_zero_joints=direct, + axis_joints=( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + "thumb_ip", + reference_roll, + reference_pitch, + reference_pip, + reference_dip, + ), + inherited_zero_joints=inherited, + inherited_static_zero_joints={}, + constrained_circle_joints=( + hand.image_trajectory_joints | {reference_dip, "thumb_cmc_yaw"} + ), + root_anchor_joints=frozenset({"thumb_cmc_roll", reference_roll}), + axis_parent_joint=axis_parent, + phase_parent_joint=phase_parent, + offset_observer_joint=observer, + same_view_axis_pair_by_offset={}, + fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets, + static_output_zero_offsets_rad={}, + ) + + +def _build_right_19_zero_profile( + hand: HandCalibrationProfile, +) -> ZeroCalibrationProfile: + """Return the 16-active-zero graph for the 19-Tag layout. + + Distal Tags recover all four PIP/DIP dynamic curves and axis quality, but + repeated motion with one unchanged camera/Tag installation cannot by + itself distinguish a fixed parallel-axis phase bias from an encoder zero. + The three coupled thumb-CMC offsets must therefore remain a visual + multi-axis solve; their electrical endpoints are not assumed to coincide + with source-CAD limits. Independently repeatable contact endpoints still + anchor thumb MCP, finger MCP-pitch and PIP from each session. DIP stays + passive and has no independently identifiable static zero. + """ + fingers = ("index", "middle", "ring", "pinky") + direct = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + *( + f"{finger}_{suffix}" + for finger in fingers + for suffix in ("mcp_roll", "mcp_pitch", "pip") + ), + ) + axis_joints = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + "thumb_ip", + *( + f"{finger}_{suffix}" + for finger in fingers + for suffix in ("mcp_roll", "mcp_pitch", "pip", "dip") + ), + ) + axis_parent = { + "thumb_cmc_yaw": "thumb_cmc_roll", + "thumb_cmc_pitch": "thumb_cmc_yaw", + **{ + f"{finger}_mcp_pitch": f"{finger}_mcp_roll" + for finger in fingers + }, + } + phase_parent = { + "thumb_mcp": "thumb_cmc_pitch", + "thumb_ip": "thumb_mcp", + **{ + f"{finger}_{child}": f"{finger}_{parent}" + for finger in fingers + for child, parent in ( + ("pip", "mcp_pitch"), + ("dip", "pip"), + ) + }, + } + observer = { + "thumb_cmc_roll": "thumb_cmc_yaw", + "thumb_cmc_yaw": "thumb_cmc_pitch", + "thumb_cmc_pitch": "thumb_mcp", + "thumb_mcp": "thumb_ip", + **{ + f"{finger}_{target}": f"{finger}_{observed}" + for finger in fingers + for target, observed in ( + ("mcp_roll", "mcp_pitch"), + ("mcp_pitch", "pip"), + ("pip", "dip"), + ) + }, + } + root_anchors = frozenset( + {"thumb_cmc_roll", *(f"{finger}_mcp_roll" for finger in fingers)} + ) + constrained = frozenset( + set(hand.image_trajectory_joints) + | {"thumb_cmc_yaw"} + # The side camera observes all four flexion axes nearly end-on. A + # 16 mm monocular planar Tag gives a very repeatable orientation axis, + # while its command-correlated depth bias can tilt the otherwise clean + # centre-trajectory plane by several degrees. Use the orientation + # axis to constrain that circle; PIP retains its independently fitted + # axis line and all final SE(3)/holdout quality gates. + | set(RIGHT_19_END_ON_IMAGE_CURVE_JOINTS) + # _fit_axis_measurement_raw constrains every passive DIP direction to + # its source-URDF-parallel upstream PIP. Declare the same fact here + # so validation does not compare that trusted direction with the + # depth-biased free plane of a distal Tag trajectory. + | set(RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS) + ) + return ZeroCalibrationProfile( + hand=hand, + direct_zero_joints=tuple(direct), + axis_joints=tuple(axis_joints), + inherited_zero_joints={}, + inherited_static_zero_joints={}, + constrained_circle_joints=constrained, + root_anchor_joints=root_anchors, + axis_parent_joint=axis_parent, + phase_parent_joint=phase_parent, + offset_observer_joint=observer, + same_view_axis_pair_by_offset={ + "thumb_cmc_yaw": ("thumb_cmc_roll", "thumb_cmc_pitch") + }, + # Endpoint-observable offsets are supplied by the caller. CMC remains + # visually solved and no serial-specific zero is hidden in the shared + # profile. + fixed_direct_zero_offsets_rad={}, + static_output_zero_offsets_rad={}, + ) + + +def get_right_19_thumb_zero_profile() -> ZeroCalibrationProfile: + """Return the independent four-zero thumb observer graph. + + Roll and MCP are supplied as mechanical endpoint datums by the caller; + yaw is observed by the same-view roll/pitch axis pair and pitch by the + pitch/MCP serial phase. No finger axis is part of this profile. + """ + full = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) + thumb_direct = tuple( + name for name in full.direct_zero_joints if name.startswith("thumb_") + ) + thumb_axes = tuple( + name for name in full.axis_joints if name.startswith("thumb_") + ) + return replace( + full, + direct_zero_joints=thumb_direct, + axis_joints=thumb_axes, + constrained_circle_joints=frozenset( + name + for name in full.constrained_circle_joints + if name.startswith("thumb_") + ), + root_anchor_joints=frozenset({"thumb_cmc_roll"}), + axis_parent_joint={ + child: parent + for child, parent in full.axis_parent_joint.items() + if child.startswith("thumb_") and parent.startswith("thumb_") + }, + phase_parent_joint={ + child: parent + for child, parent in full.phase_parent_joint.items() + if child.startswith("thumb_") and parent.startswith("thumb_") + }, + offset_observer_joint={ + target: observer + for target, observer in full.offset_observer_joint.items() + if target.startswith("thumb_") and observer.startswith("thumb_") + }, + same_view_axis_pair_by_offset={ + target: pair + for target, pair in full.same_view_axis_pair_by_offset.items() + if target.startswith("thumb_") + }, + fixed_direct_zero_offsets_rad={}, + static_output_zero_offsets_rad={}, + base_pose_strategy="thumb_serial", + ) + + +RIGHT_19_MECHANICAL_ENDPOINT_JOINTS = frozenset( + { + "thumb_mcp", + *( + f"{finger}_{suffix}" + for finger in ("index", "middle", "ring", "pinky") + for suffix in ("mcp_pitch", "pip") + ), + } +) + +# Thumb CMC roll has a directly observed actuator-to-actuator travel, but its +# electrical stop is not used as a runtime URDF limit. Keep it out of +# RIGHT_19_MECHANICAL_ENDPOINT_JOINTS (whose offsets also move upper limits), +# and use its endpoint estimate only as the final static roll origin. This +# makes roll independent of the fitted palm-frame phase and of all yaw logic. +RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS = frozenset({"thumb_cmc_roll"}) +RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS = ( + RIGHT_19_MECHANICAL_ENDPOINT_JOINTS + | RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS +) +RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS = RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS + + +def anchor_right_19_mechanical_endpoint_curves( + curves: Mapping[str, JointCurveFit], + records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], + *, + maximum_direction_difference_rad: float = math.radians(1.0), + maximum_curve_correction_rad: float = math.radians(3.0), + feedback_endpoint_joints: frozenset[str] = frozenset(), + endpoint_joints: frozenset[str] | None = None, +) -> dict[str, JointCurveFit]: + """Set mechanical-endpoint curve scale from direct SO(3) travel. + + A fitted-axis projection is useful for a dense, signed command curve, but + a small view-dependent orthogonal PnP component can change that + projection's full-scale value. The rotation magnitude between the two + settled endpoint poses is the revolute-joint travel itself: conjugating + both poses by a rigid hand/camera transform or either fixed Tag mounting + rotation cannot change it. + + Use every supplied cycle and both sweep directions as independent endpoint + measurements, reject disagreement, and apply their robust median as one + scale correction to the already validated curve. Dense CMC-roll records + are selected in feedback coordinates; settled mechanical-contact records + remain selected in requested-command coordinates. + """ + if ( + not math.isfinite(maximum_direction_difference_rad) + or maximum_direction_difference_rad <= 0.0 + ): + raise ValueError("endpoint direction difference limit must be positive") + if ( + not math.isfinite(maximum_curve_correction_rad) + or maximum_curve_correction_rad <= 0.0 + ): + raise ValueError("endpoint curve correction limit must be positive") + + result = dict(curves) + selected_endpoint_joints = ( + RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS + if endpoint_joints is None + else frozenset(str(name) for name in endpoint_joints) + ) + unknown_endpoint_joints = sorted( + set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) + ) + if unknown_endpoint_joints: + raise ValueError( + "endpoint joints are not endpoint measurements: " + + ",".join(unknown_endpoint_joints) + ) + unknown_feedback_joints = sorted( + set(feedback_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) + ) + if unknown_feedback_joints: + raise ValueError( + "feedback endpoint joints are not endpoint measurements: " + + ",".join(unknown_feedback_joints) + ) + + for name in sorted(selected_endpoint_joints): + fit = curves.get(name) + records = list(records_by_joint.get(name, ())) + if fit is None: + raise ValueError(f"missing measured endpoint curve for {name}") + + direction_travel: dict[str, float] = {} + all_travel: list[float] = [] + for direction in ("decreasing", "increasing"): + cycles = sorted( + { + int(record.get("cycle", 0)) + for record in records + if str(record.get("direction")) == direction + } + ) + branch_travel: list[float] = [] + for cycle in cycles: + endpoint_rotations: dict[int, Rotation] = {} + for command in (0, 255): + quaternions = [] + for record in records: + if ( + str(record.get("direction")) != direction + or int(record.get("cycle", 0)) != cycle + ): + continue + endpoint_value = ( + explicit_domain_value(record, "feedback") + if name in feedback_endpoint_joints + else record.get( + "requested_command_u8", + record.get("command_u8", -1), + ) + ) + if endpoint_value is None: + continue + if int(round(float(endpoint_value))) == command: + quaternions.append(_relative_rotation(record)) + if not quaternions: + raise ValueError( + f"{name} {direction} cycle {cycle} is missing " + f"settled endpoint {command}" + ) + endpoint_rotations[command] = Rotation.from_quat( + robust_rotation_summary(quaternions)[0] + ) + branch_travel.append( + float( + ( + endpoint_rotations[255].inv() + * endpoint_rotations[0] + ).magnitude() + ) + ) + if not branch_travel: + raise ValueError(f"{name} is missing {direction} endpoint travel") + direction_travel[direction] = float(np.median(branch_travel)) + all_travel.extend(branch_travel) + + travel_values = np.asarray(list(direction_travel.values()), dtype=float) + direction_difference = float(np.ptp(travel_values)) + if direction_difference > maximum_direction_difference_rad: + raise ValueError( + f"{name} settled endpoint directions disagree: " + f"{math.degrees(direction_difference):.3f}deg" + ) + cycle_range = float(np.ptp(np.asarray(all_travel, dtype=float))) + if cycle_range > maximum_direction_difference_rad: + raise ValueError( + f"{name} settled endpoint cycles disagree: " + f"{math.degrees(cycle_range):.3f}deg" + ) + direct_travel = float(np.median(np.asarray(all_travel, dtype=float))) + curve = np.asarray(fit.angle_rad, dtype=float) + if curve.shape != (256,) or not np.all(np.isfinite(curve)): + raise ValueError(f"{name} endpoint curve must contain 256 finite bins") + projected_travel = float(curve[0] - curve[255]) + if direct_travel <= 0.0 or projected_travel <= 0.0: + raise ValueError(f"{name} endpoint travel must be positive") + correction = abs(projected_travel - direct_travel) + if correction > maximum_curve_correction_rad: + raise ValueError( + f"{name} fitted/direct endpoint travel differs by " + f"{math.degrees(correction):.3f}deg" + ) + scale = direct_travel / projected_travel + + def scaled(values: Sequence[float]) -> tuple[float, ...]: + return tuple(float(value) * scale for value in values) + + circle = dict(fit.circle) + circle.update( + { + "mechanical_endpoint_direct_travel_rad": direct_travel, + "mechanical_endpoint_direction_difference_rad": ( + direction_difference + ), + "mechanical_endpoint_cycle_range_rad": cycle_range, + "mechanical_endpoint_sample_count": len(all_travel), + "mechanical_endpoint_raw_curve_travel_rad": projected_travel, + "mechanical_endpoint_curve_scale": scale, + } + ) + quality = dict(fit.quality) + if "arc_rad" in quality: + quality["arc_rad"] = float(quality["arc_rad"]) * scale + result[name] = replace( + fit, + angle_rad=scaled(fit.angle_rad), + decreasing_rad=scaled(fit.decreasing_rad), + increasing_rad=scaled(fit.increasing_rad), + circle=circle, + maximum_monotonic_correction_rad=( + float(fit.maximum_monotonic_correction_rad) * scale + ), + maximum_hysteresis_rad=float(fit.maximum_hysteresis_rad) * scale, + quality=quality, + zero_offset_rad=float(fit.zero_offset_rad) * scale, + ) + return result + + +def derive_right_19_mechanical_endpoint_offsets( + source_urdf: str | Path, + curves: Mapping[str, JointCurveFit], + *, + maximum_offset_rad: float = math.radians(5.0), + endpoint_joints: frozenset[str] | None = None, +) -> dict[str, float]: + """Estimate encoder origins from repeatable measured mechanical endpoints. + + For the four fingers feedback 0 is the independently verified palm-contact + endpoint; thumb MCP uses its independently verified actuator endpoint. + The source URDF upper limit describes those same physical endpoints. + Therefore ``origin_offset + measured_travel == CAD_upper``. + + Thumb CMC pitch/yaw remain excluded because their endpoint-to-CAD contract + is not independently established. CMC roll is included as a post-solve + origin only: its measured travel determines roll without allowing the + palm common-direction fit to write that joint. + + The returned corrections are measured, generally non-zero encoder zeros; + they do not retain the CAD origin. Using full relative rotation travel + makes them invariant to rigid hand movement and arbitrary fixed Tag + mounting rotation, unlike a cross-camera absolute planar-PnP phase. + """ + if not math.isfinite(maximum_offset_rad) or maximum_offset_rad <= 0.0: + raise ValueError("mechanical endpoint maximum offset must be positive") + root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot() + joints = {str(node.get("name")): node for node in root.findall("joint")} + result: dict[str, float] = {} + selected_endpoint_joints = ( + RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS + if endpoint_joints is None + else frozenset(str(name) for name in endpoint_joints) + ) + unknown_endpoint_joints = sorted( + set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) + ) + if unknown_endpoint_joints: + raise ValueError( + "endpoint joints are not endpoint measurements: " + + ",".join(unknown_endpoint_joints) + ) + for name in sorted(selected_endpoint_joints): + fit = curves.get(name) + joint = joints.get(name) + limit = None if joint is None else joint.find("limit") + if fit is None: + raise ValueError(f"missing measured endpoint curve for {name}") + if limit is None or limit.get("upper") is None: + raise ValueError(f"source URDF joint {name} has no upper limit") + upper = float(limit.get("upper")) + curve = np.asarray(fit.angle_rad, dtype=float) + if curve.shape != (256,) or not np.all(np.isfinite(curve)): + raise ValueError(f"{name} endpoint curve must contain 256 finite bins") + measured_endpoint = float(curve[0]) + if measured_endpoint <= 0.0 or abs(float(curve[255])) > math.radians(0.05): + raise ValueError( + f"{name} endpoint curve does not use command 255 as its zero reference" + ) + if abs(measured_endpoint - float(np.max(curve))) > math.radians(0.05): + raise ValueError( + f"{name} command 0 is not the measured upper mechanical endpoint" + ) + offset = upper - measured_endpoint + if not math.isfinite(offset) or abs(offset) > maximum_offset_rad: + raise ValueError( + f"{name} endpoint-derived zero offset is outside the safe range: " + f"{math.degrees(offset):.3f}deg" + ) + result[name] = offset + return result + + +def get_zero_calibration_profile( + side: str, layout_id: str = "legacy_11" +) -> ZeroCalibrationProfile: + return _build_zero_profile(get_hand_calibration_profile(side, layout_id)) + + +LEFT_ZERO_PROFILE = _build_zero_profile(LEFT_HAND_PROFILE) + +# Backwards-compatible left-hand aliases. +CONSTRAINED_CIRCLE_JOINTS = LEFT_ZERO_PROFILE.constrained_circle_joints +DIRECT_ZERO_JOINTS = LEFT_ZERO_PROFILE.direct_zero_joints +FIXED_DIRECT_ZERO_OFFSETS_RAD = dict( + LEFT_ZERO_PROFILE.fixed_direct_zero_offsets_rad +) +OPTIMIZED_ZERO_JOINTS = tuple( + name for name in DIRECT_ZERO_JOINTS + if name not in FIXED_DIRECT_ZERO_OFFSETS_RAD +) +AXIS_JOINTS = LEFT_ZERO_PROFILE.axis_joints +INHERITED_ZERO_JOINTS = dict(LEFT_ZERO_PROFILE.inherited_zero_joints) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +def merge_right_19_thumb_zero_result( + *, + thumb_result: ZeroSolveResult, + preserved_offsets_rad: Mapping[str, float] | None = None, + companion_result: ZeroSolveResult | None = None, +) -> ZeroSolveResult: + """Merge an independent thumb solve without allowing reverse coupling. + + ``companion_result`` is the finger/full solver result whose thumb values + were fixed to ``thumb_result``. For a thumb-only run, the 12 certified + finger values are supplied through ``preserved_offsets_rad`` instead. + """ + thumb_names = { + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + } + if set(thumb_result.direct_offsets_rad) != thumb_names: + raise ValueError("independent thumb result must contain exactly four zeros") + full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) + direct_names = set(full_profile.direct_zero_joints) + preserved = { + str(name): float(value) + for name, value in (preserved_offsets_rad or {}).items() + } + if set(preserved) - (direct_names - thumb_names): + raise ValueError("preserved offsets contain a thumb or unknown joint") + if any(not math.isfinite(value) for value in preserved.values()): + raise ValueError("preserved offsets must be finite") + + if companion_result is None: + if set(preserved) != direct_names - thumb_names: + raise ValueError("thumb-only merge requires all 12 certified finger zeros") + direct = dict(preserved) + all_active = {name: 0.0 for name in full_profile.hand.active_joints} + all_active.update(preserved) + base_result = thumb_result + else: + if set(companion_result.direct_offsets_rad) != direct_names: + raise ValueError("full companion result must contain all 16 zeros") + direct = dict(companion_result.direct_offsets_rad) + all_active = dict(companion_result.all_active_offsets_rad) + base_result = companion_result + direct.update(thumb_result.direct_offsets_rad) + all_active.update(thumb_result.direct_offsets_rad) + + def merged_map(name: str) -> dict[str, Any]: + values = ( + {} + if companion_result is None + else dict(getattr(companion_result, name)) + ) + values.update(dict(getattr(thumb_result, name))) + return values + + failure_reasons = merged_map("failure_reasons") + passed = bool( + thumb_result.passed + and (companion_result is None or companion_result.passed) + ) + return replace( + base_result, + direct_offsets_rad=direct, + all_active_offsets_rad=all_active, + validation_errors_rad=( + tuple(thumb_result.validation_errors_rad) + if companion_result is None + else ( + *companion_result.validation_errors_rad, + *thumb_result.validation_errors_rad, + ) + ), + validation_error_by_joint_rad=merged_map( + "validation_error_by_joint_rad" + ), + validation_line_error_by_joint_m=merged_map( + "validation_line_error_by_joint_m" + ), + axis_line_rms_m=( + thumb_result.axis_line_rms_m + if companion_result is None + else max( + companion_result.axis_line_rms_m, + thumb_result.axis_line_rms_m, + ) + ), + passed=passed, + cycle_offsets_rad=merged_map("cycle_offsets_rad"), + offset_uncertainty_rad=merged_map("offset_uncertainty_rad"), + offset_confidence_half_width_rad=merged_map( + "offset_confidence_half_width_rad" + ), + validation_original_error_by_joint_rad=merged_map( + "validation_original_error_by_joint_rad" + ), + validation_improvement_by_joint_rad=merged_map( + "validation_improvement_by_joint_rad" + ), + validation_improvement_confidence_lower_rad=merged_map( + "validation_improvement_confidence_lower_rad" + ), + offset_covariance_rad2=merged_map("offset_covariance_rad2"), + axis_cone_mismatch_by_joint_rad=merged_map( + "axis_cone_mismatch_by_joint_rad" + ), + axis_cone_bias_classification_by_joint=merged_map( + "axis_cone_bias_classification_by_joint" + ), + failure_reasons=failure_reasons, + ) + + +def expand_right_19_thumb_zero_result_with_cad_fingers( + thumb_result: ZeroSolveResult, +) -> ZeroSolveResult: + """Build a standalone thumb result with every finger left at source CAD. + + This is intentionally different from inheriting a prior finger + calibration: the twelve non-thumb values are explicit zeros and no finger + observation or artifact is required. + """ + full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) + thumb_names = { + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + } + if set(thumb_result.direct_offsets_rad) != thumb_names: + raise ValueError("independent thumb result must contain exactly four zeros") + direct = {name: 0.0 for name in full_profile.direct_zero_joints} + direct.update(thumb_result.direct_offsets_rad) + all_active = {name: 0.0 for name in full_profile.hand.active_joints} + all_active.update(thumb_result.direct_offsets_rad) + return replace( + thumb_result, + direct_offsets_rad=direct, + all_active_offsets_rad=all_active, + ) + + + + + + +def write_zero_corrected_urdf( + *, + source_urdf: str | Path, + output_directory: str | Path, + serial_number: str, + offsets_rad: Mapping[str, float], + endpoint_anchored_offsets_rad: Mapping[str, float] | None = None, + timestamp: str | None = None, + correction_plan: UrdfCorrectionPlan | None = None, +) -> Path: + source = Path(source_urdf).expanduser().resolve() + output = Path(output_directory).expanduser().resolve() + if not source.is_file(): + raise ValueError(f"source URDF does not exist: {source}") + if not offsets_rad: + raise ValueError("offsets_rad must contain at least one joint") + offsets = {str(name): float(value) for name, value in offsets_rad.items()} + endpoint_offsets = { + str(name): float(value) + for name, value in dict(endpoint_anchored_offsets_rad or {}).items() + } + if not set(endpoint_offsets) <= set(offsets): + raise ValueError("endpoint-anchored offsets must be URDF zero targets") + if correction_plan is not None: + correction_plan.verify_source(source) + correction_plan.authorize_offsets(offsets) + if not set(endpoint_offsets).issubset( + correction_plan.endpoint_limit_joints + ): + raise ValueError( + "endpoint offsets are not authorized by the correction plan" + ) + if any( + not math.isfinite(value) or abs(value) > math.radians(90.0) + for value in offsets.values() + ): + raise ValueError("URDF zero offsets must be finite and within +/-90deg") + stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + if re.fullmatch(r"\d{8}_\d{6}", stamp) is None: + raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS") + safe_serial = "".join( + character if character.isalnum() or character in "_.-" else "_" + for character in str(serial_number) + ) + if not safe_serial: + raise ValueError("serial_number must not be empty") + from linkerhand_calibration.core.urdf.plan import build_standard_correction_plan + import hashlib + + if "calibrated" in source.stem.lower(): + raise ValueError("source must be the original CAD URDF") + # The deployed JSON keeps the compatibility shape; zero coordinates must + # nevertheless transform both limits and every dependent mimic together. + # Endpoint metadata cannot select a different coordinate rule. + if any(not math.isclose(value, offsets[name], rel_tol=0, abs_tol=1e-12) + for name, value in endpoint_offsets.items()): + raise ValueError("endpoint metadata disagrees with the actual zero correction") + if correction_plan is not None: + rights = correction_plan.authorized_fields + if not rights: + raise ValueError("production correction plan lacks independent Profile field authorization") + source_hash = correction_plan.source_sha256 + else: + # Compatibility for standalone offline correction tools. The online + # product path always supplies its protected, scope-authorized plan. + model = UrdfKinematicModel(source) + rights = {name: ("origin.rpy", "limit.lower", "limit.upper") for name in offsets} + for name, joint in model.joints.items(): + if joint.mimic_joint is not None: + rights[name] = (*rights.get(name, ()), "mimic.offset") + source_hash = hashlib.sha256(source.read_bytes()).hexdigest() + plan = build_standard_correction_plan(source_urdf=source, + source_sha256=source_hash, zero_offsets_rad=offsets, authorized_fields=rights) + destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf" + return plan.write(source, destination) diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/__init__.py new file mode 100644 index 0000000..01c6175 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/__init__.py @@ -0,0 +1,14 @@ +"""Registered L6 calibration profiles.""" + +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry + + +def register_profiles(registry: ProfileRegistry) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import build_profile as build_left_transfer_profile + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_profile + + registry.register(build_profile()) + registry.register(build_left_transfer_profile()) + + +__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/artifacts.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/artifacts.py similarity index 95% rename from src/linkerhand_calibration/linkerhand_calibration/models/l6/artifacts.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/artifacts.py index 5313922..6ea2b70 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/artifacts.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/artifacts.py @@ -13,9 +13,9 @@ import xml.etree.ElementTree as ET import numpy as np -from ..g20.profile import JointCurveFit -from .fitting import L6FitResult, MimicFit -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult, MimicFit +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import ( ACTIVE_JOINTS, CALIBRATED_ACTIVE_JOINTS, COMMAND_INDEX_BY_JOINT, @@ -558,7 +558,7 @@ def load_l6_urdf_input( def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: - from ...core import ProfileKey + from linkerhand_calibration.core import ProfileKey key = ProfileKey.parse(str(payload.get("profile_id", ""))) if key == KEY: @@ -568,7 +568,7 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT else: - from .left_transfer import ( + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import ( KEY as LEFT_TRANSFER_KEY, build_typed_profile as build_left_transfer_profile, left_joint_name, @@ -667,7 +667,7 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: expected_model = coupling_models.get( name, "linear_mimic" ) - if model != expected_model: + if model not in {"linear_mimic", "quadratic_runtime"}: raise ValueError(f"{name} coupling model is invalid") coefficients = np.asarray( joint.get("coupling_coefficients"), dtype=float @@ -683,7 +683,7 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: "endpoint_linear_fallback" if model == "quadratic_runtime" else "exact_linear" - if name in profile.zero.fitted_mimic_joints + if name in profile.zero.fitted_mimic_joints or name in passive_transfers else "cad_nominal" ) # Early schema-v6 linear artifacts predate the explicit policy @@ -692,7 +692,7 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: policy = str( joint.get("urdf_mimic_policy", expected_policy) ) - if policy != expected_policy: + if policy not in {expected_policy, "exact_linear" if model == "linear_mimic" else "endpoint_linear_fallback"}: raise ValueError(f"{name} URDF mimic fallback is invalid") multiplier = float(joint.get("mimic_multiplier", "nan")) if not math.isfinite(multiplier) or multiplier <= 0.0: @@ -715,8 +715,14 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None: raise ValueError(f"{name} transfer provenance is invalid") donor = joints[transferred_from] for field in ("angle_rad", "decreasing_rad", "increasing_rad"): - if joint[field] != donor[field]: + if name not in passive_set and joint[field] != donor[field]: raise ValueError(f"{name} transfer curve differs from donor") + if name in passive_set and joint["coupling_model"] == "linear_mimic": + parent = joints[mimic_sources[name]] + for field in ("angle_rad", "decreasing_rad", "increasing_rad"): + expected = float(joint["mimic_offset_rad"]) + float(joint["mimic_multiplier"]) * np.asarray(parent[field]) + if not np.allclose(joint[field], expected, atol=1e-7, rtol=0): + raise ValueError(f"{name} curve differs from its declared standard mimic") quality = payload["quality"] if not isinstance(quality, Mapping) or quality.get("scope") != "partial": raise ValueError("schema v6 quality scope must be partial") @@ -732,7 +738,7 @@ def build_l6_left_transferred_runtime_payload( serial_number: str, ) -> dict[str, Any]: """Create a validated L6 left runtime JSON paired with a mirrored URDF.""" - from .left_transfer import ( + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.left_transfer import ( KEY as LEFT_TRANSFER_KEY, build_typed_profile as build_left_transfer_profile, left_joint_name, @@ -845,18 +851,8 @@ def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> Path: return destination -def publish_partial_session(serial_root: str | Path, session: str | Path) -> Path: - parent = Path(serial_root).resolve() - target = Path(session).resolve() - if target.parent != parent or not target.is_dir(): - raise ValueError("partial session must be a direct existing child") - destination = parent / "latest_partial_passed" - temporary = parent / f".latest_partial_passed.{os.getpid()}.tmp" - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() - os.symlink(target.name, temporary, target_is_directory=True) - os.replace(temporary, destination) - return destination +def publish_partial_session(*args, **kwargs): + raise ValueError("legacy publication is retired; use the common ArtifactPublisher") def artifact_hashes(json_path: str | Path, urdf_path: str | Path) -> dict[str, str]: diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/fitting.py new file mode 100644 index 0000000..93ba434 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/fitting.py @@ -0,0 +1,33 @@ +"""Legacy result types plus a direct call to the ONE profile fitter. + +Old endpoint/range-centre fallbacks and model-specific refitting are retired. +The legacy result type remains only for reading archived JSON/tool inputs. +""" +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping +from linkerhand_calibration.core.domain.measurement import JointCurveFit +from linkerhand_calibration.core.domain.result import JointMapping +from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad +from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence +from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult +from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile +from linkerhand_calibration.profiles import load_bundled_hand_profile + +@dataclass(frozen=True) +class L6FitResult: + curves: Mapping[str, JointCurveFit] + zero_offsets_rad: Mapping[str, float] + travels_rad: Mapping[str, float] + mimic_fits: Mapping[str, MimicFit] + holdout_errors_rad: Mapping[str, tuple[float, ...]] + zero_method_by_joint: Mapping[str, str] + zero_fallback_reason_by_joint: Mapping[str, str] + thumb_zero_result: ZeroSolveResult | None = None + + +def fit_l6_session(source_urdf, records_by_joint, *, require_thumb_axis_zero=False): + # Historical flags can no longer disable spatial or holdout verification. + return fit_profile_calibration(load_bundled_hand_profile("l6_right_8"), + Path(source_urdf), records_by_joint) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/left_transfer.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/left_transfer.py similarity index 87% rename from src/linkerhand_calibration/linkerhand_calibration/models/l6/left_transfer.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/left_transfer.py index 7e9474f..a6ab2ab 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/left_transfer.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/left_transfer.py @@ -4,9 +4,9 @@ from __future__ import annotations from dataclasses import replace -from ...core import ProfileKey -from ..registry import EngineBindings, RegisteredProfile -from .profile import build_typed_profile as build_right_typed_profile +from linkerhand_calibration.core import ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import build_typed_profile as build_right_typed_profile KEY = ProfileKey("L6", "left", "l6_left_transferred_8", 1) @@ -59,6 +59,12 @@ def build_typed_profile(): ), zero=replace( right.zero, + transferred_zero_sources={left_joint_name(k): left_joint_name(v) + for k, v in right.zero.transferred_zero_sources.items()}, + transferred_mimic_sources={left_joint_name(k): left_joint_name(v) + for k, v in right.zero.transferred_mimic_sources.items()}, + # Runtime-only mirrored products do not acquire/solve new geometry. + spatial={}, active_joints=active, passive_joints=passive, direct_zero_joints=tuple( @@ -131,7 +137,7 @@ def _unsupported(_args=None) -> None: def build_profile() -> RegisteredProfile: - from .motion import ( + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import ( build_calibration_motion_command, build_calibration_preparation_waypoints, build_calibration_return_waypoints, diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/motion.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/motion.py new file mode 100644 index 0000000..e81360d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/motion.py @@ -0,0 +1,18 @@ +"""Safe six-channel motion helpers for the partial L6 profile.""" + +from __future__ import annotations + +from linkerhand_calibration.runtime.trajectory import ( + build_calibration_motion_command, + build_calibration_preparation_waypoints, + build_calibration_return_waypoints, + cosine_position_trajectory_u8, +) + + +__all__ = [ + "build_calibration_motion_command", + "build_calibration_preparation_waypoints", + "build_calibration_return_waypoints", + "cosine_position_trajectory_u8", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/profile.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/profile.py new file mode 100644 index 0000000..8951f82 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/profile.py @@ -0,0 +1,78 @@ +"""Reviewed partial-calibration profile for the right L6 eight-Tag rig.""" + +from __future__ import annotations + +from linkerhand_calibration.core import CalibrationProfile, ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.motion import ( + build_calibration_motion_command, + build_calibration_preparation_waypoints, + build_calibration_return_waypoints, +) + + +# Historical import constants are projections of the authoritative YAML, +# never a second set of model/zero/coupling definitions. +from linkerhand_calibration.profiles.loader import load_bundled_hand_profile +_DECLARED = load_bundled_hand_profile("l6_right_8") +KEY = _DECLARED.key +COMMAND_NAMES = _DECLARED.command.names +COMMAND_INDEX_BY_JOINT = dict(_DECLARED.command.command_index_by_joint) +ACTIVE_JOINTS = tuple(sorted(COMMAND_INDEX_BY_JOINT, key=COMMAND_INDEX_BY_JOINT.get)) +MIMIC_SOURCE_BY_JOINT = dict(_DECLARED.zero.mimic_source_by_joint) +PASSIVE_JOINTS = tuple(name for source in ACTIVE_JOINTS + for name, parent in MIMIC_SOURCE_BY_JOINT.items() if parent == source) +CALIBRATED_ACTIVE_JOINTS = _DECLARED.scope.selected_joints(_DECLARED.scope.default_scope) +MEASURED_PASSIVE_JOINTS = _DECLARED.zero.fitted_mimic_joints +TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_zero_sources) +TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_mimic_sources) +CORRECTED_ACTIVE_JOINTS = _DECLARED.zero.active_joints +CORRECTED_PASSIVE_JOINTS = frozenset(MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys()) +ENDPOINT_ANCHOR_BY_JOINT = dict(_DECLARED.zero.endpoint_anchor_by_joint) +COUPLING_MODEL_BY_JOINT = dict(_DECLARED.zero.coupling_model_by_joint) + + +def build_typed_profile() -> CalibrationProfile: + """Compatibility entry; the complete typed contract lives only in YAML.""" + from linkerhand_calibration.profiles.loader import load_bundled_hand_profile + return load_bundled_hand_profile("l6_right_8") + + +def _run_cli(args: list[str] | None = None) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.runner import main + + main(args) + + +def _run_node(args: list[str] | None = None) -> None: + from linkerhand_calibration.runtime.ros.calibration_node import run_profile_node + run_profile_node(build_typed_profile(), args) + + +def build_profile() -> RegisteredProfile: + typed = build_typed_profile() + return RegisteredProfile( + profile=typed, + engine=EngineBindings( + hand_profile=typed, + zero_profile=typed.zero, + motion_command=build_calibration_motion_command, + preparation_waypoints=build_calibration_preparation_waypoints, + return_waypoints=build_calibration_return_waypoints, + cli_main=_run_cli, + node_main=_run_node, + ), + ) + + +__all__ = [ + "ACTIVE_JOINTS", + "CALIBRATED_ACTIVE_JOINTS", + "COMMAND_NAMES", + "KEY", + "MEASURED_PASSIVE_JOINTS", + "MIMIC_SOURCE_BY_JOINT", + "PASSIVE_JOINTS", + "build_profile", + "build_typed_profile", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/runner.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/runner.py new file mode 100644 index 0000000..330a386 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/runner.py @@ -0,0 +1,7 @@ +"""Legacy import path; all online work uses the common product runner.""" + +from linkerhand_calibration.runtime.runner import main +from linkerhand_calibration.runtime.runner_support import launch_command as _launch_command +from linkerhand_calibration.compat.legacy_diagnostic_tools.progress import ( + render_l6_progress_zh, render_six_channel_progress_zh, +) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/urdf.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/urdf.py similarity index 91% rename from src/linkerhand_calibration/linkerhand_calibration/models/l6/urdf.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/urdf.py index e7fdff7..6ca1e12 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/urdf.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/l6/urdf.py @@ -13,14 +13,16 @@ import xml.etree.ElementTree as ET import numpy as np from scipy.spatial.transform import Rotation -from ...core.urdf import ( +from linkerhand_calibration.core.urdf import ( MujocoEqualityPatch, UrdfJointPatch, UrdfPatchSet, + corrected_origin_rpy as _corrected_origin_rpy, + parse_vector3 as _triplet, write_urdf_patches, ) -from .fitting import L6FitResult -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.profile import ( CALIBRATED_ACTIVE_JOINTS, CORRECTED_ACTIVE_JOINTS, CORRECTED_PASSIVE_JOINTS, @@ -28,6 +30,7 @@ from .profile import ( MIMIC_SOURCE_BY_JOINT, TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, + build_typed_profile, ) @@ -41,36 +44,6 @@ class L6UrdfCorrection: explicit_runtime_joints: frozenset[str] -def _triplet(value: str) -> np.ndarray: - result = np.asarray([float(item) for item in str(value).split()], dtype=float) - if result.shape != (3,) or not np.all(np.isfinite(result)): - raise ValueError(f"invalid URDF vector: {value}") - return result - - -def _corrected_origin_rpy(joint: ET.Element, offset: float) -> str: - origin = joint.find("origin") - if origin is None or origin.get("rpy") is None: - raise ValueError(f"joint {joint.get('name')} has no origin.rpy") - # Preserve the reviewed CAD spelling exactly when the calibrated open - # endpoint is the source joint zero. Apart from avoiding Euler round-off, - # this makes it explicit that a zero correction must not rotate the frame. - if abs(float(offset)) <= 1.0e-12: - return str(origin.get("rpy")) - axis_node = joint.find("axis") - axis = _triplet( - "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") - ) - norm = float(np.linalg.norm(axis)) - if norm <= 1.0e-12: - raise ValueError(f"joint {joint.get('name')} has a degenerate axis") - source = Rotation.from_euler("xyz", _triplet(origin.get("rpy", "0 0 0"))) - corrected = source * Rotation.from_rotvec(axis / norm * float(offset)) - return " ".join( - f"{float(value):.15g}" for value in corrected.as_euler("xyz") - ) - - def _revolute_joints(root: ET.Element) -> dict[str, ET.Element]: return { str(joint.get("name")): joint @@ -387,6 +360,7 @@ def write_l6_corrected_urdf( # Keep the complete vendor mesh bundle, including auxiliary meshes not # referenced by this XML revision. copy_complete_mesh_directory=True, + authorized_fields=build_typed_profile().urdf_authorized_fields, ) return L6UrdfCorrection( path=destination, diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/__init__.py new file mode 100644 index 0000000..163131d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/__init__.py @@ -0,0 +1,11 @@ +"""Registered O12 calibration profiles.""" + +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry + + +def register_profiles(registry: ProfileRegistry) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import build_profile + registry.register(build_profile()) + + +__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/artifacts.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/artifacts.py similarity index 70% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/artifacts.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/artifacts.py index 301570f..2c893cb 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/artifacts.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/artifacts.py @@ -11,13 +11,8 @@ import xml.etree.ElementTree as ET import numpy as np from scipy.spatial.transform import Rotation -from .fitting import O12FitResult, curve_input_knots_rad, curve_values_at_rad -from .kinematics import ( - PASSIVE_POLYNOMIAL_BY_JOINT, - PASSIVE_SDK_SOURCE_BY_JOINT, - vendor_passive_curve, -) -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.fitting import O12FitResult +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import ( ACTIVE_JOINTS, COMMAND_INDEX_BY_JOINT, COMMAND_NAMES, @@ -35,8 +30,8 @@ from .profile import ( TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, build_typed_profile, ) -from .urdf import o12_endpoint_mimic_contract -from .zero import SPATIAL_ZERO_POLICY +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.urdf import o12_correction_plan +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.zero import SPATIAL_ZERO_POLICY ALL_REVOLUTE_JOINTS = frozenset(ACTIVE_JOINTS + PASSIVE_JOINTS) @@ -49,21 +44,6 @@ def _motor_index(joint: str) -> int: return COMMAND_INDEX_BY_JOINT[source] -def _task_for_joint(joint: str): - target = str(joint) - while True: - if target in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT: - target = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT[target] - elif target not in COMMAND_INDEX_BY_JOINT: - target = MIMIC_SOURCE_BY_JOINT[target] - else: - break - return next( - task for task in build_typed_profile().motion.tasks - if target in task.joints - ) - - def _source_joint_metadata(source_urdf: str | Path) -> dict[str, dict[str, Any]]: root = ET.parse(Path(source_urdf)).getroot() result: dict[str, dict[str, Any]] = {} @@ -98,12 +78,6 @@ def _round(values: Sequence[float], digits: int = 10) -> list[float]: return [round(float(value), digits) for value in array] -def _zeroed(values: Sequence[float], inputs: Sequence[float]) -> np.ndarray: - curve = np.asarray(values, dtype=float) - baseline = float(np.interp(0.0, np.asarray(inputs, dtype=float), curve)) - return curve - baseline - - def build_o12_runtime_payload( *, serial_number: str, @@ -129,48 +103,25 @@ def build_o12_runtime_payload( raise ValueError( f"O12 {name} must retain immutable source-CAD static zero" ) - source = _source_joint_metadata(source_urdf) - endpoint_mimics = o12_endpoint_mimic_contract(source_urdf, result) + plan = o12_correction_plan(source_urdf, result) curves: dict[str, tuple[np.ndarray, np.ndarray, np.ndarray, list[float]]] = {} joints: dict[str, dict[str, Any]] = {} for name in ACTIVE_JOINTS: donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name) - fit = result.curves[donor] feedback_domain = result.feedback_domains_rad[donor] - inputs = list(curve_input_knots_rad( - donor, feedback_domain_rad=feedback_domain - )) - motor = COMMAND_INDEX_BY_JOINT[name] + mapping = result.output_mappings[name] + inputs = list(mapping.knots) + motor = mapping.motor_index sign = float(SDK_TO_URDF_SIGN[motor]) - decreasing = _zeroed( - curve_values_at_rad( - donor, fit, inputs, "decreasing_rad", feedback_domain - ), inputs - ) - increasing = _zeroed( - curve_values_at_rad( - donor, fit, inputs, "increasing_rad", feedback_domain - ), inputs - ) - angle = 0.5 * (decreasing + increasing) - # fit_rotation_joint_curve has an arbitrary SVD axis sign. Apply the - # reviewed SDK-to-CAD direction contract after fitting so all curves - # use the target URDF convention deterministically. - observed_direction = float(np.sign(angle[-1] - angle[0])) - if observed_direction and observed_direction != sign: - angle = -angle - decreasing = -decreasing - increasing = -increasing + decreasing = np.asarray(mapping.decreasing_rad) + increasing = np.asarray(mapping.increasing_rad) + angle = np.asarray(mapping.angle_rad) if donor != name: - cad_lower = float(source[name]["lower"]) - cad_upper = float(source[name]["upper"]) - # Same feedback means the same transferred correction until the - # ring's own CAD/mimic range saturates. Rescaling all donor values - # would invent a different gain for this unobserved finger. - angle = np.clip(angle, cad_lower, cad_upper) - decreasing = np.clip(decreasing, cad_lower, cad_upper) - increasing = np.clip(increasing, cad_lower, cad_upper) + lower, upper = plan.limits_output_rad[name] + values = np.concatenate((angle, decreasing, increasing)) + if np.min(values) < lower - 1e-9 or np.max(values) > upper + 1e-9: + raise ValueError(f"transferred curve exceeds recipient physical range: {name}; no clipping is permitted") curves[name] = (angle, decreasing, increasing, inputs) joint: dict[str, Any] = { "urdf_joint": name, @@ -201,66 +152,28 @@ def build_o12_runtime_payload( "visual_arc_diagnostic_rad": round( float(result.visual_arc_diagnostics_rad[donor]), 10 ), - "raw_increasing_curve_branch": ( - "decreasing" - if _task_for_joint(donor).end_value - > _task_for_joint(donor).start_value - else "increasing" - ), + "raw_increasing_curve_branch": "increasing", } if donor != name: joint["transferred_from_joint"] = donor joint["transfer_policy"] = ( - "pinky_curve_clamped_to_ring_cad_range" + "donor_scalar_mapping_on_own_cad_frame" ) joint["static_zero_transfer_policy"] = "donor_offset_on_own_cad_frame" joints[name] = joint for name in PASSIVE_JOINTS: - metadata = source[name] - source_name = MIMIC_SOURCE_BY_JOINT[name] + source_name, multiplier, offset = plan.mimic_output[name] motor = _motor_index(source_name) - if name in MEASURED_PASSIVE_JOINTS: - fit = result.curves[name] - feedback_domain = result.feedback_domains_rad[name] - inputs = list(curve_input_knots_rad( - name, feedback_domain_rad=feedback_domain - )) - sdk_source = PASSIVE_SDK_SOURCE_BY_JOINT[name] - sdk_motor = _motor_index(sdk_source) - expected_direction = float(SDK_TO_URDF_SIGN[sdk_motor]) - vendor_values = np.asarray( - vendor_passive_curve( - name, - inputs, - sdk_to_urdf_sign=expected_direction, - ), - dtype=float, - ) - angle = vendor_values - decreasing = vendor_values.copy() - increasing = vendor_values.copy() - observed = result.mimic_fits[name] - coefficients = [ - expected_direction * float(value) - for value in PASSIVE_POLYNOMIAL_BY_JOINT[name] - ] - coefficients.extend([0.0] * (6 - len(coefficients))) - coupling_model = "vendor_o12_polynomial" - multiplier = endpoint_mimics[name] - policy = "source_cad_closed_endpoint" - status = profile.joint_coverage[name] - else: - source_curve, source_dec, source_inc, inputs = curves[source_name] - multiplier = float(metadata["multiplier"]) - offset = float(metadata["offset"]) - angle = offset + multiplier * source_curve - decreasing = offset + multiplier * source_dec - increasing = offset + multiplier * source_inc - coefficients = [offset, multiplier, 0.0, 0.0, 0.0, 0.0] - coupling_model = "linear_mimic" - policy = "cad_nominal_preserved" - status = profile.joint_coverage[name] + source_curve, source_dec, source_inc, inputs = curves[source_name] + angle = offset + multiplier * source_curve + decreasing = offset + multiplier * source_dec + increasing = offset + multiplier * source_inc + coefficients = [offset, multiplier, 0.0, 0.0, 0.0, 0.0] + coupling_model = "linear_mimic" + policy = ("visual_standard_linear_mimic" if name in MEASURED_PASSIVE_JOINTS + else "cad_nominal_coordinate_transformed") + status = profile.joint_coverage[name] curves[name] = (angle, decreasing, increasing, list(inputs)) joint = { "urdf_joint": name, @@ -269,7 +182,7 @@ def build_o12_runtime_payload( "passive": True, "source_joint": source_name, "calibration_status": status, - "static_zero_policy": "cad_mechanical_endpoint", + "static_zero_policy": "unobservable_static_offset_retains_cad", "curve_input_knots_rad": _round(inputs), "angle_rad": _round(angle), "decreasing_rad": _round(decreasing), @@ -279,34 +192,21 @@ def build_o12_runtime_payload( "mimic_multiplier": round(float(multiplier), 10), "urdf_mimic_policy": policy, "runtime_mapping_source": ( - "o12_vendor_solver_over_sdk_feedback_rad" - if name in MEASURED_PASSIVE_JOINTS - else "source_urdf_cad_mimic" - ), - "raw_increasing_curve_branch": ( - "decreasing" - if _task_for_joint(name).end_value - > _task_for_joint(name).start_value - else "increasing" + "exported_standard_urdf_mimic" ), + "raw_increasing_curve_branch": "increasing", } if name in MEASURED_PASSIVE_JOINTS: + evidence = result.standard_mimic_evidence[name] joint.update({ - "measured_feedback_domain_rad": [ - round(float(value), 10) for value in feedback_domain - ], - "visual_observation_role": "independent_dynamic_validation", - "vendor_sdk_source_joint": sdk_source, - "observed_coupling_model": observed.model, - "observed_mimic_multiplier": round( - float(observed.urdf_mimic_multiplier), 10 - ), - "observed_residual_rms_rad": round( - float(observed.residual_rms_rad), 10 - ), - "observed_visual_arc_rad": round( - float(result.visual_arc_diagnostics_rad[name]), 10 - ), + "measured_feedback_domain_rad": list(result.feedback_domains_rad[name]), + "visual_observation_role": "independent_linear_mimic_training_and_holdout", + "observed_coupling_model": "linear_mimic", + "observed_mimic_multiplier": round(evidence.fit.multiplier, 10), + "observed_residual_rms_rad": round(float(np.sqrt(np.mean(np.square(evidence.fit.residuals_rad)))), 10), + "observed_visual_arc_rad": round(float(result.visual_arc_diagnostics_rad[name]), 10), + "linear_mimic_holdout_mae_rad": float(np.mean(np.abs(evidence.holdout_errors_rad))), + "linear_mimic_holdout_max_rad": float(np.max(np.abs(evidence.holdout_errors_rad))), }) joints[name] = joint @@ -404,8 +304,8 @@ def build_o12_runtime_payload( }, "ring_cad_geometry_and_mimic_preserved": True, "active_angle_source": "tag_rotation_over_sdk_feedback_rad", - "passive_runtime_source": "o12_vendor_solver_polynomial", - "visual_measurement_role": "active_curve_and_passive_validation", + "passive_runtime_source": "exported_standard_urdf_mimic", + "visual_measurement_role": "active_curve_and_passive_linear_fit_with_independent_holdout", }, } validate_o12_runtime_payload(payload) @@ -500,21 +400,17 @@ def validate_o12_runtime_payload(payload: Mapping[str, Any]) -> None: raise ValueError(f"{name} has invalid O12 CAD mimic coefficients") if not math.isfinite(multiplier) or multiplier <= 0.0: raise ValueError(f"{name} has invalid O12 mimic multiplier") - if name not in MEASURED_PASSIVE_JOINTS: - offset = float(coefficients[0]) - for field in ("angle_rad", "decreasing_rad", "increasing_rad"): - expected_values = offset + multiplier * np.asarray( - joints[source_joint][field], dtype=float - ) - if not np.allclose( - np.asarray(joint[field], dtype=float), - expected_values, - rtol=0.0, - atol=2.0e-9, - ): - raise ValueError( - f"{name}.{field} differs from the source CAD mimic chain" - ) + if joint.get("coupling_model") != "linear_mimic" or not np.allclose( + coefficients[1:], [multiplier, 0, 0, 0, 0], rtol=0, atol=2e-9 + ): + raise ValueError(f"{name} cannot replace standard URDF mimic with a nonlinear JSON model") + if not np.allclose(joint["curve_input_knots_rad"], joints[source_joint]["curve_input_knots_rad"], rtol=0, atol=1e-10): + raise ValueError(f"{name} mimic and source must have the same input knots") + offset = float(coefficients[0]) + for field in ("angle_rad", "decreasing_rad", "increasing_rad"): + expected_values = offset + multiplier * np.asarray(joints[source_joint][field], dtype=float) + if not np.allclose(np.asarray(joint[field], dtype=float), expected_values, rtol=0, atol=2e-9): + raise ValueError(f"{name}.{field} differs from standard URDF mimic chain") if not bool(payload["quality"].get("passed")): raise ValueError("failed O12 calibration cannot be published") @@ -526,7 +422,7 @@ def validate_o12_runtime_payload_against_urdf( """Check curve bounds/mimics and, with original CAD, actual static frames. This verifies the artifact contract, not full-hand physical accuracy. - Passive runtime polynomials and linear URDF mimic are distinct models. + Passive runtime values must equal the actual exported linear URDF mimic. """ validate_o12_runtime_payload(payload) metadata = _source_joint_metadata(urdf) @@ -535,7 +431,6 @@ def validate_o12_runtime_payload_against_urdf( expected = metadata[name] for field in ("angle_rad", "decreasing_rad", "increasing_rad"): values = np.asarray(joint[field], dtype=float) - allowance = 0.0 if bool(joint.get("passive")): source_name = str(expected["source_joint"]) if source_name != str(joint["source_joint"]): @@ -551,33 +446,18 @@ def validate_o12_runtime_payload_against_urdf( raise ValueError( f"{name} corrected URDF mimic differs from payload" ) - if name not in MEASURED_PASSIVE_JOINTS: - source_values = np.asarray( - joints[source_name][field], dtype=float - ) - cad_values = ( - float(expected["offset"]) - + float(expected["multiplier"]) * source_values - ) - if not np.allclose( - values, cad_values, rtol=0.0, atol=2.0e-9 - ): - raise ValueError( - f"{name}.{field} differs from preserved CAD mimic" - ) - allowance = max( - 0.0, - float(np.max(cad_values)) - float(expected["upper"]), - float(expected["lower"]) - float(np.min(cad_values)), - ) + source_values = np.asarray(joints[source_name][field], dtype=float) + urdf_values = float(expected["offset"]) + float(expected["multiplier"]) * source_values + if not np.allclose(values, urdf_values, rtol=0, atol=2e-9): + raise ValueError(f"{name}.{field} differs from exported standard URDF mimic") if ( float(np.min(values)) - < float(expected["lower"]) - allowance - 2.0e-9 + < float(expected["lower"]) - 2.0e-9 ): raise ValueError(f"{name}.{field} is below the URDF physical limit") if ( float(np.max(values)) - > float(expected["upper"]) + allowance + 2.0e-9 + > float(expected["upper"]) + 2.0e-9 ): raise ValueError(f"{name}.{field} is above the URDF physical limit") if source_urdf is not None: diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/fitting.py new file mode 100644 index 0000000..b5daa82 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/fitting.py @@ -0,0 +1,38 @@ +"""Legacy result types plus a direct call to the ONE profile fitter. + +Old endpoint/range-centre fallbacks and model-specific refitting are retired. +The legacy result type remains only for reading archived JSON/tool inputs. +""" +from __future__ import annotations +from dataclasses import dataclass, field +from pathlib import Path +from typing import Mapping +from linkerhand_calibration.core.domain.measurement import JointCurveFit +from linkerhand_calibration.core.domain.result import JointMapping +from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad +from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence +from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult +from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile +from linkerhand_calibration.profiles import load_bundled_hand_profile + +@dataclass(frozen=True) +class O12FitResult: + curves: Mapping[str, JointCurveFit] + zero_offsets_rad: Mapping[str, float] + travels_rad: Mapping[str, float] + mimic_fits: Mapping[str, LinearMimicFit] + holdout_errors_rad: Mapping[str, tuple[float, ...]] + cross_view_roll_metrics: Mapping[str, Mapping[str, float]] + visual_arc_diagnostics_rad: Mapping[str, float] + feedback_domains_rad: Mapping[str, tuple[float, float]] + zero_method_by_joint: Mapping[str, str] + thumb_root_zero_result: ZeroSolveResult | None + full_hand_zero_result: ZeroSolveResult | None = None + standard_mimic_evidence: Mapping[str, RelativeMimicEvidence] = field(default_factory=dict) + output_mappings: Mapping[str, JointMapping] = field(default_factory=dict) + + +def fit_o12_session(source_urdf, records_by_joint, *, cross_view_records_by_joint=None, require_cross_view=False, require_thumb_root_spatial_zero=False, require_full_hand_spatial_zero=False): + # Historical flags can no longer disable spatial or holdout verification. + return fit_profile_calibration(load_bundled_hand_profile("o12_right_16"), + Path(source_urdf), records_by_joint, cross_view_records=cross_view_records_by_joint) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/health.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/health.py similarity index 100% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/health.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/health.py diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/kinematics.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/kinematics.py similarity index 100% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/kinematics.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/kinematics.py diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/motion.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/motion.py new file mode 100644 index 0000000..270e07a --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/motion.py @@ -0,0 +1,64 @@ +"""Physical-radian motion helpers for O12 right calibration.""" + +from __future__ import annotations + +from typing import Sequence + +from linkerhand_calibration.core import CalibrationProfile, TaskSpec + + +from linkerhand_calibration.runtime.trajectory import cosine_position_trajectory, cosine_ramp_velocity_trajectory + + +def cosine_position_trajectory_rad(start_rad, target_rad, elapsed_seconds, maximum_speed_rad_s): + return cosine_position_trajectory(start_rad, target_rad, elapsed_seconds, maximum_speed_rad_s) + + +def cosine_ramp_velocity_trajectory_rad(start_rad, target_rad, elapsed_seconds, maximum_speed_rad_s, ramp_seconds): + return cosine_ramp_velocity_trajectory(start_rad, target_rad, elapsed_seconds, maximum_speed_rad_s, ramp_seconds) + + +def build_calibration_motion_command( + task: TaskSpec, + command_rad: float, + *, + profile: CalibrationProfile, +) -> list[float]: + values = list(profile.command.baseline_values) + for index, value in task.auxiliary_commands: + values[int(index)] = float(value) + values[int(task.command_index)] = float(command_rad) + return values + + +def build_calibration_preparation_waypoints( + task: TaskSpec, + *, + profile: CalibrationProfile, + current_command: Sequence[float] | None = None, +) -> tuple[tuple[float, ...], ...]: + del current_command + return (tuple(build_calibration_motion_command(task, task.start_value, profile=profile)),) + + +def build_calibration_return_waypoints( + target_command: Sequence[float] | None = None, + *, + profile: CalibrationProfile, + current_command: Sequence[float] | None = None, + **_: object, +) -> tuple[tuple[float, ...], ...]: + del current_command + target = tuple( + profile.command.baseline_values if target_command is None else target_command + ) + if len(target) != profile.command.command_count: + raise ValueError("O12 return command has the wrong channel count") + return (tuple(float(value) for value in target),) + + +__all__ = [ + "build_calibration_motion_command", "build_calibration_preparation_waypoints", + "build_calibration_return_waypoints", "cosine_position_trajectory_rad", + "cosine_ramp_velocity_trajectory_rad", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/observations.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/observations.py new file mode 100644 index 0000000..dbb7d4e --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/observations.py @@ -0,0 +1,17 @@ +"""Legacy offline name for the generic articulated candidate primitive.""" + +from linkerhand_calibration.core.geometry.candidate_selection import ( + MATRIX_SOURCE, POLICY, _relative, _rotation, resolve_chain_observations, +) +from linkerhand_calibration.core.geometry.pnp import solve_square_tag_ippe +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.pnp import THUMB_ROLES +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import build_typed_profile + + +def resolve_thumb_observations(records, *, projection_override=None): + profile = build_typed_profile() + task = next(task for task in profile.motion.tasks if task.key == 'thumb_mcp_dip_front') + return resolve_chain_observations(records, task_key=task.key, + role_pairs={name: (profile.measurement.measurements[name].parent_role, + profile.measurement.measurements[name].child_role) for name in task.joints}, + projection_override=projection_override, solve_pose=solve_square_tag_ippe) diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pipeline.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pipeline.py new file mode 100644 index 0000000..f326a3f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pipeline.py @@ -0,0 +1,31 @@ +"""Old O12 journal names at the common finalization boundary. + +All fitting, quality, standard-URDF generation and publication live in the +common finalizer. This adapter only translates old capture field names and +task-relative direction labels, without changing numerical observations. +""" + +from linkerhand_calibration.runtime.acquisition import accepted_joint_records, accepted_secondary_records, load_capture +from linkerhand_calibration.runtime.artifacts.finalization import finalize_profile_session +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import build_typed_profile + + +def _journal_rows(records): + return [dict(row, observation_joint=row["model_joint"]) + if row.get("kind") == "o12_roll_cross_view_sample" else dict(row) for row in records] + + +def accepted_records_by_joint(records): + return accepted_joint_records(build_typed_profile(), records) + + +def accepted_roll_cross_view_records(records): + return accepted_secondary_records(build_typed_profile(), _journal_rows(records)) + + +load_o12_raw_samples = load_capture + + +def finalize_o12_session(*, records, **kwargs): + return finalize_profile_session(profile=build_typed_profile(), + records=_journal_rows(records), directions_are_task_relative=True, **kwargs) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/pnp.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pnp.py similarity index 98% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/pnp.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pnp.py index 5825b6e..dadcdc8 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/pnp.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/pnp.py @@ -3,7 +3,7 @@ import math import numpy as np from scipy.spatial.transform import Rotation -from ...pnp import SquareTagGroupPoseTracker +from linkerhand_calibration.pnp import SquareTagGroupPoseTracker THUMB_ROLES = ('thumb_cmc', 'thumb_mcp', 'thumb_dip') diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/profile.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/profile.py new file mode 100644 index 0000000..a634686 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/profile.py @@ -0,0 +1,285 @@ +"""Reviewed O12 right-hand 16-Tag calibration profile.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +from linkerhand_calibration.core import CalibrationProfile, ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.motion import ( + build_calibration_motion_command, + build_calibration_preparation_waypoints, + build_calibration_return_waypoints, +) + + +KEY = ProfileKey("O12", "right", "o12_right_16", 1) + +# sensor_msgs/JointState.position active-angle order from API_CPP_O12.md. +COMMAND_NAMES: tuple[str, ...] = ( + "thumb_roll", + "thumb_abad", + "thumb_mcp", + "thumb_pip", + "index_abad", + "index_mcp", + "index_pip", + "middle_abad", + "middle_mcp", + "middle_pip", + "ring_mcp", + "pinky_mcp", +) + +SDK_LOWER_RAD = ( + 0.0, -1.387536755335492, -0.8272860654453121, -1.2915436464758039, + -0.2617993877991494, 0.0, 0.0, -0.2617993877991494, + 0.0, 0.0, 0.0, 0.0, +) +SDK_UPPER_RAD = ( + 0.9424777960769379, 0.0, 0.0, 0.0, + 0.2617993877991494, 1.3526301702956054, 1.530653753999027, + 0.2617993877991494, 1.3578661580515883, 1.8151424220741028, + 1.53588974175501, 1.53588974175501, +) + +# These legacy constants describe SDK coordinates, not measured CAD angles. +# The YAML owns the task domain. Measured output angles must independently +# satisfy the source CAD physical range; a larger SDK value never authorizes +# expanding URDF limits. Remove these aliases with the remaining old runtime. +SAFE_LOWER_RAD = SDK_LOWER_RAD +SAFE_UPPER_RAD = SDK_UPPER_RAD + +# Joint-state feedback can cross a commanded endpoint slightly because it is +# measured after servo tracking and encoder conversion. The envelope remains +# anchored to the reviewed SDK hard range, with an explicit two-degree +# observation allowance that is never used to generate a command. +FEEDBACK_DOMAIN_MARGIN_RAD = math.radians(2.0) +FEEDBACK_LOWER_RAD = tuple( + value - FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_LOWER_RAD +) +FEEDBACK_UPPER_RAD = tuple( + value + FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_UPPER_RAD +) + +SDK_TO_URDF_JOINT: tuple[str, ...] = ( + "thumb_cmc_roll", + "thumb_cmc_yaw", + "thumb_cmc_pitch", + "thumb_mcp", + "index_mcp_roll", + "index_mcp_pitch", + "index_pip", + "middle_mcp_roll", + "middle_mcp_pitch", + "middle_pip", + "ring_mcp_pitch", + "pinky_mcp_pitch", +) + +# Fixed direction contract between vendor channels and the target CAD axes. +# The magnitude remains visually calibrated because O12's 12 active SDK +# coordinates include tendon/solver coordinates that are not one-to-one with +# all 19 physical URDF joints. +SDK_TO_URDF_SIGN: tuple[float, ...] = ( + 1.0, -1.0, -1.0, -1.0, + -1.0, 1.0, 1.0, -1.0, + 1.0, 1.0, 1.0, 1.0, +) + +ACTIVE_JOINTS = SDK_TO_URDF_JOINT +PASSIVE_JOINTS: tuple[str, ...] = ( + "thumb_dip", + "index_dip", + "middle_dip", + "ring_pip", + "ring_dip", + "pinky_pip", + "pinky_dip", +) +CALIBRATED_ACTIVE_JOINTS = frozenset(ACTIVE_JOINTS) - {"ring_mcp_pitch"} +MEASURED_PASSIVE_JOINTS = frozenset( + {"thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip"} +) +TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = { + "ring_mcp_pitch": "pinky_mcp_pitch", +} +MIMIC_SOURCE_BY_JOINT = { + "thumb_dip": "thumb_mcp", + "index_dip": "index_pip", + "middle_dip": "middle_pip", + "ring_pip": "ring_mcp_pitch", + "ring_dip": "ring_pip", + "pinky_pip": "pinky_mcp_pitch", + "pinky_dip": "pinky_pip", +} + +# SDK endpoints measure travel; they have not been surveyed as CAD contact +# datums. In particular CAD.upper - travel is NOT an encoder zero. The root +# thumb axes observe roll/yaw phase; parallel flexion zeros additionally need +# adjacent axis-line observations. The one declared exclusion below remains +# CAD-owned; all other observable active zeros are required for publication. +ROOT_GEOMETRIC_ZERO_JOINTS = frozenset({"thumb_cmc_roll", "thumb_cmc_yaw"}) +# ID2/ID3 are still used to fit and independently validate thumb MCP/DIP +# motion, but the curved-shell ID3 observation is not a reliable absolute +# axis-line datum for the thumb MCP assembly phase. Keep that one static +# origin at immutable source CAD until a rigid downstream fiducial is +# available. This is an explicit O12 profile contract, not an exception-path +# quality bypass; every other observable active zero remains mandatory. +STATIC_ZERO_EXCLUDED_JOINTS = frozenset({"thumb_mcp"}) +GEOMETRIC_ZERO_JOINTS = frozenset( + CALIBRATED_ACTIVE_JOINTS - STATIC_ZERO_EXCLUDED_JOINTS +) +ENDPOINT_ANCHOR_BY_JOINT: dict[str, str] = {} +COMMAND_INDEX_BY_JOINT = { + joint: index for index, joint in enumerate(SDK_TO_URDF_JOINT) +} + +# The outer pair is used only as collision clearance after the pinky's own +# full-range scan has already exercised the same safe endpoint. O12 has only +# one active coordinate on ring/pinky, so partial MCP flexion leaves both long +# passive chains inside the middle/index camera corridor. Park them at the +# reviewed CAD/SDK intersection instead of the former 65% pose. +OUTER_CLEARANCE_FRACTION = 1.0 +PARK_RING_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[10] +PARK_PINKY_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[11] +# Compatibility name for downstream diagnostics; motion code uses the two +# channel-specific values above and never takes their minimum. +PARK_FINGER_RAD = PARK_PINKY_MCP_RAD +PARK_MIDDLE_MCP_RAD = SAFE_UPPER_RAD[8] +PARK_MIDDLE_PIP_RAD = SAFE_UPPER_RAD[9] +# Feedback radians are the quantity being calibrated and need not numerically +# equal the command endpoint. Once a finger has been scanned, clearance uses +# that measured feedback travel as its endpoint reference. Simultaneous MCP +# and PIP flexion may shorten the vendor solver coordinate slightly. The +# unified waypoint rule accepts 80% measured travel after the command +# trajectory ends and feedback settles; +# exact command equality is intentionally not required for tendon mechanisms. +CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION = 0.80 +INDEX_CLEARANCE_RAD = -math.radians(10.0) +# O12's index ABAD/MCP and middle ABAD/MCP are coupled tendon coordinates. +# The vendor solver clamps MCP upward when a large ABAD command is paired +# with MCP=0. These reviewed values keep the requested pose inside the +# solver's feasible set instead of relying on an invisible SDK correction: +# - index ABAD -10 deg requires MCP >= 0.109846 rad; +# - either ABAD roll endpoint (+/-0.26 rad) requires MCP >= 0.163636 rad. +INDEX_CLEARANCE_MCP_RAD = 0.11 +ROLL_CLEARANCE_MCP_RAD = 0.17 +# O12 feedback is continuous radians. Use 64 possible normalized cells and +# require at least 32 of them; using exactly 32 possible cells accidentally +# required both mechanical endpoints despite the separate 90% span gate. +NORMALIZED_SWEEP_BIN_COUNT = 64 +# A feedback curve is the quantity being identified, so its unknown endpoint +# scale must never be judged against the command endpoint as if they were the +# same coordinate. Cycle 0 is normalized over its own measured endpoints and +# therefore establishes a unit-span physical reference. Later cycles must +# reproduce at least 90% of that measured full-stroke reference. +INITIAL_FEEDBACK_SPAN_FRACTION = 0.99 +EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION = 0.90 +# Both FRONT/TOP and FRONT/SIDE are close to orthogonal on the reviewed O12 +# rig. Their common planar checkerboard must be oblique in at least one view, +# so the G20 1.2 px (and O6 1.5 px) batch gates reject highly repeatable O12 +# solutions for image-geometry reasons. At the measured 3700--3800 px focal +# length, 2.0 px is about 0.030 degrees. Treat RMS as a gross-error gate and +# keep the independent 0.3 degree and 1.5 mm pose-repeatability gates as the +# final geometric consistency evidence. +MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX = 2.0 + + +@dataclass(frozen=True) +class RollCrossViewObserver: + """O12-only side-camera contract for one front-camera roll sweep.""" + + task_name: str + view: str + parent_role: str + child_role: str + source_joint: str + model_joint: str + coupled_channel_index: int + maximum_coupled_motion_rad: float + + +# The downstream PIP Tag is rigidly carried by MCP roll, so it supplies an +# independent side-view roll curve while the front Tag supplies the published +# curve. O12's tendon solver also has a small, deterministic PIP back-drive +# around centred ABAD: joint->actuator->joint reaches about 0.0124 rad even +# when PIP is commanded to zero. The reviewed 0.020 rad envelope includes +# encoder quantisation and applies only to roll-task cross-view integrity. +ROLL_CROSS_VIEW_OBSERVERS: tuple[RollCrossViewObserver, ...] = ( + RollCrossViewObserver( + "middle_roll_front", "side", "side_base", "middle_pip", + "middle_mcp_roll_side", "middle_mcp_roll", 9, 0.020, + ), + RollCrossViewObserver( + "index_roll_front", "side", "side_base", "index_pip", + "index_mcp_roll_side", "index_mcp_roll", 6, 0.020, + ), +) +ROLL_CROSS_VIEW_BY_TASK = { + observer.task_name: observer for observer in ROLL_CROSS_VIEW_OBSERVERS +} + +# Fast-calibration caps by fixed SDK channel. The thumb CMC pitch cap keeps +# explicit margin below its unusually low 0.11 rad/s source-URDF limit; the +# remaining caps are deliberately far below their corresponding CAD limits +# while keeping one visual sweep long enough for dense 30 Hz observations. +FORMAL_SPEED_CAP_RAD_S: tuple[float, ...] = ( + 0.16, 0.16, 0.10, 0.32, + # Index/middle ABAD at 0.16 rad/s repeatedly returned only 89.2--89.5% + # feedback coverage; 0.12 rad/s measured 91.2% on the physical hand. + 0.12, 0.32, 0.32, 0.12, + 0.32, 0.32, 0.32, 0.32, +) + + +def build_typed_profile() -> CalibrationProfile: + """Compatibility entry; the complete typed contract lives only in YAML.""" + from linkerhand_calibration.profiles.loader import load_bundled_hand_profile + return load_bundled_hand_profile("o12_right_16") + + +def _run_cli(args: list[str] | None = None) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.runner import main + main(args) + + +def _run_node(args: list[str] | None = None) -> None: + from linkerhand_calibration.runtime.ros.calibration_node import run_profile_node + run_profile_node(build_typed_profile(), args) + + +def build_profile() -> RegisteredProfile: + typed = build_typed_profile() + return RegisteredProfile( + typed, + EngineBindings( + hand_profile=typed, + zero_profile=typed.zero, + motion_command=build_calibration_motion_command, + preparation_waypoints=build_calibration_preparation_waypoints, + return_waypoints=build_calibration_return_waypoints, + cli_main=_run_cli, + node_main=_run_node, + ), + ) + + +__all__ = [ + "ACTIVE_JOINTS", "CALIBRATED_ACTIVE_JOINTS", "COMMAND_INDEX_BY_JOINT", + "COMMAND_NAMES", + "GEOMETRIC_ZERO_JOINTS", "INDEX_CLEARANCE_RAD", "KEY", + "MEASURED_PASSIVE_JOINTS", + "EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION", + "INITIAL_FEEDBACK_SPAN_FRACTION", "MIMIC_SOURCE_BY_JOINT", + "NORMALIZED_SWEEP_BIN_COUNT", "OUTER_CLEARANCE_FRACTION", + "PARK_FINGER_RAD", "PARK_MIDDLE_MCP_RAD", "PARK_PINKY_MCP_RAD", + "PARK_RING_MCP_RAD", "CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION", + "PARK_MIDDLE_PIP_RAD", "PASSIVE_JOINTS", "SAFE_LOWER_RAD", "SAFE_UPPER_RAD", + "ROLL_CROSS_VIEW_BY_TASK", "ROLL_CROSS_VIEW_OBSERVERS", + "RollCrossViewObserver", + "SDK_LOWER_RAD", "SDK_TO_URDF_JOINT", "SDK_UPPER_RAD", + "STATIC_ZERO_EXCLUDED_JOINTS", + "TRANSFERRED_ACTIVE_SOURCE_BY_JOINT", "build_profile", "build_typed_profile", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/quality.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/quality.py new file mode 100644 index 0000000..07ed869 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/quality.py @@ -0,0 +1,75 @@ +"""O12 schema-v7 compatibility facade for the common data gate. + +New runtime code calls :class:`runtime.engine.CalibrationEngine` directly. +The legacy function remains because deployed schema-v7 diagnostics and tests +import it; it delegates to the same model-independent evaluator. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from linkerhand_calibration.runtime.engine import evaluate_observability + +QUALITY_POLICY_VERSION = 5 +MAXIMUM_UNOBSERVED_BIN_FRACTION = 1.0 / 16.0 + + +def evaluate_o12_observation_quality( + normalized_feedback: Sequence[float], + observation: Mapping[str, Any], + *, + normalized_bin_count: int, + required_feedback_span: float, + minimum_sweep_frames: int, + minimum_sweep_bins: int, + minimum_joint_frame_rate: float, + minimum_feedback_hz: float, + feedback_hz: float, + target_detection_rate: float, + target_maximum_bin_gap: int, +) -> dict[str, Any]: + """Return schema-v7 fields derived from the common acceptance policy.""" + del minimum_joint_frame_rate, minimum_feedback_hz + quality = evaluate_observability( + normalized_feedback, + minimum_span=required_feedback_span, + minimum_valid_samples=minimum_sweep_frames, + minimum_bins=minimum_sweep_bins, + maximum_unobserved_fraction=MAXIMUM_UNOBSERVED_BIN_FRACTION, + total_frames=int(observation.get("total_frames", 0)), + joint_frame_rate=float(observation.get("joint_frame_rate", 0.0)), + feedback_hz=feedback_hz, + detection_rate=float(observation.get("tag_detection_rate", 0.0)), + bin_count=normalized_bin_count, + include_endpoint_gaps=False, + ) + warnings = list(quality.warnings) + maximum_gap = int(quality.metrics["maximum_bin_gap"]) + detection_rate = float(observation.get("tag_detection_rate", 0.0)) + if detection_rate < float(target_detection_rate): + role_rates = observation.get("tag_detection_rate_by_role", {}) + worst_role = min(role_rates, key=role_rates.get, default="unknown") + target_warning = f"tag_rate_target[{worst_role}]={detection_rate:.3f}" + if target_warning not in warnings: + warnings.append(target_warning) + if maximum_gap > int(target_maximum_bin_gap): + warnings.append(f"maximum_gap_target={maximum_gap}") + return { + "quality_policy_version": QUALITY_POLICY_VERSION, + "decision_basis": "normalized_fit_observability", + **dict(quality.metrics), + "required_feedback_span": round(float(required_feedback_span), 9), + "maximum_unobserved_bin_fraction": MAXIMUM_UNOBSERVED_BIN_FRACTION, + "target_detection_rate": float(target_detection_rate), + "target_maximum_bin_gap": int(target_maximum_bin_gap), + "warnings": warnings, + "failures": list(quality.failures), + "passed": quality.passed, + } + +__all__ = [ + "MAXIMUM_UNOBSERVED_BIN_FRACTION", + "QUALITY_POLICY_VERSION", + "evaluate_o12_observation_quality", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/runner.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/runner.py new file mode 100644 index 0000000..ab09480 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/runner.py @@ -0,0 +1,5 @@ +"""Legacy import path; all online work uses the common product runner.""" + +from linkerhand_calibration.runtime.runner import main +from linkerhand_calibration.runtime.runner_support import log_exception_summary as _log_exception_summary +from linkerhand_calibration.compat.legacy_diagnostic_tools.progress import render_o12_progress_zh diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/urdf.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/urdf.py new file mode 100644 index 0000000..8ecf4b3 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/urdf.py @@ -0,0 +1,94 @@ +"""O12 result adapter for the common standard-URDF correction engine.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +import hashlib +import math +from pathlib import Path +import re +from typing import Mapping + +from linkerhand_calibration.core.urdf.plan import StandardUrdfPlan, build_standard_correction_plan +from linkerhand_calibration.runtime.artifacts.publisher import StagedRelease +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.fitting import O12FitResult +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import ( + ACTIVE_JOINTS, CALIBRATED_ACTIVE_JOINTS, COMMAND_INDEX_BY_JOINT, + MEASURED_PASSIVE_JOINTS, SDK_TO_URDF_SIGN, STATIC_ZERO_EXCLUDED_JOINTS, + TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, build_typed_profile, +) + + +def o12_active_ranges(result: O12FitResult) -> dict[str, tuple[float, float]]: + if set(result.output_mappings) != set(ACTIVE_JOINTS): + raise ValueError("O12 fit is missing frozen native output mappings") + return { + name: result.output_mappings[name].bounds_rad + for name in sorted(CALIBRATED_ACTIVE_JOINTS) + } + + +def o12_correction_plan(source_urdf: str | Path, result: O12FitResult) -> StandardUrdfPlan: + """Translate measured parameters; never invent endpoint mimics or limits.""" + if set(result.travels_rad) != CALIBRATED_ACTIVE_JOINTS | set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT): + raise ValueError("O12 travel result has the wrong active joint set") + if set(result.standard_mimic_evidence) != MEASURED_PASSIVE_JOINTS: + raise ValueError("O12 standard mimic requires independent visual training/holdout evidence") + for name in STATIC_ZERO_EXCLUDED_JOINTS: + if not math.isclose(float(result.zero_offsets_rad.get(name, math.nan)), 0., abs_tol=1e-12) or result.zero_method_by_joint.get(name) != "source_cad_zero_profile_excluded": + raise ValueError(f"O12 {name} must retain immutable source-CAD static zero") + for name, evidence in result.standard_mimic_evidence.items(): + fit = evidence.fit + if fit.target_joint != name or not evidence.training_sample_ids or not evidence.holdout_sample_ids or set(evidence.training_sample_ids) & set(evidence.holdout_sample_ids): + raise ValueError(f"O12 mimic evidence is not independent: {name}") + from linkerhand_calibration.core.urdf.acceptance import angular_metrics + if not angular_metrics(evidence.holdout_errors_rad).passed: + raise ValueError(f"standard_urdf_mimic_not_expressive:{name}") + source = Path(source_urdf) + return build_standard_correction_plan(source_urdf=source, + source_sha256=hashlib.sha256(source.read_bytes()).hexdigest(), + zero_offsets_rad=result.zero_offsets_rad, + measured_ranges_output_rad=o12_active_ranges(result), + fitted_mimics_cad={name: evidence.fit for name, evidence in result.standard_mimic_evidence.items()}, + transferred_zero_sources=TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, + authorized_fields=build_typed_profile().urdf_authorized_fields) + + +@dataclass(frozen=True) +class O12UrdfCorrection: + path: Path + origin_offsets_rad: Mapping[str, float] + corrected_limits_rad: Mapping[str, tuple[float, float]] + mimic_multipliers: Mapping[str, float] + preserved_ring_fields: tuple[str, ...] + staged_release: StagedRelease | None = None + + +def write_o12_corrected_urdf( + *, source_urdf: str | Path, output_directory: str | Path, + serial_number: str, result: O12FitResult, timestamp: str | None = None, +) -> O12UrdfCorrection: + """Write authorized standard coordinates using the shared patch engine.""" + source = Path(source_urdf).expanduser().resolve() + if "calibrated" in source.stem.lower(): + raise ValueError("O12 source URDF must be immutable original CAD") + plan = o12_correction_plan(source, result) + stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") + if re.fullmatch(r"\d{8}_\d{6}", stamp) is None: + raise ValueError("O12 URDF timestamp must use YYYYMMDD_HHMMSS") + serial = "".join(c if c.isalnum() or c in "_.-" else "_" for c in str(serial_number)) + if not serial: + raise ValueError("serial number must not be empty") + destination = Path(output_directory).expanduser().resolve() / f"{source.stem}_calibrated_{serial}_{stamp}.urdf" + plan.write(source, destination) + return O12UrdfCorrection(destination, + {name: float(result.zero_offsets_rad[name]) for name in ACTIVE_JOINTS}, + plan.limits_output_rad, + {name: value[1] for name, value in plan.mimic_output.items()}, + ("ring_mcp_pitch.origin.xyz", "ring_mcp_pitch.physical_range", + "ring_pip.origin", "ring_pip.limit", "ring_pip.mimic.multiplier", + "ring_dip.origin", "ring_dip.limit", "ring_dip.mimic.multiplier")) + + +__all__ = ["O12UrdfCorrection", "o12_active_ranges", "o12_correction_plan", "write_o12_corrected_urdf"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/zero.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/zero.py similarity index 72% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/zero.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/zero.py index 7b6306d..bdfb6a3 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/zero.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o12/zero.py @@ -10,51 +10,31 @@ import math from pathlib import Path from typing import Any, Mapping, Sequence -from ..g20.profile import JointCurveFit, JointSpec -from ..g20.zero_solver import ( +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 ( ZeroCalibrationProfile, ZeroSolveResult, fit_joint_axis_measurement, solve_urdf_zero_offsets, with_depth_free_axis_projection, ) -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.profile import ( CALIBRATED_ACTIVE_JOINTS, COMMAND_INDEX_BY_JOINT, + SDK_TO_URDF_SIGN, + build_typed_profile, ) -from .kinematics import PASSIVE_SDK_SOURCE_BY_JOINT +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.kinematics import PASSIVE_SDK_SOURCE_BY_JOINT SPATIAL_ZERO_POLICY = ( "o12_full_hand_spatial_v5_thumb_mcp_cad_static_stable_pnp_bias" ) -AXIS_PARENT = { - "thumb_cmc_yaw": "thumb_cmc_roll", - "thumb_cmc_pitch": "thumb_cmc_yaw", - "index_mcp_pitch": "index_mcp_roll", - "middle_mcp_pitch": "middle_mcp_roll", -} -PHASE_PARENT = { - "thumb_mcp": "thumb_cmc_pitch", - "index_pip": "index_mcp_pitch", "index_dip": "index_pip", - "middle_pip": "middle_mcp_pitch", "middle_dip": "middle_pip", - "pinky_pip": "pinky_mcp_pitch", -} -ZERO_OBSERVER = { - "thumb_cmc_roll": "thumb_cmc_yaw", - "thumb_cmc_yaw": "thumb_cmc_pitch", - "thumb_cmc_pitch": "thumb_mcp", - "index_mcp_roll": "index_mcp_pitch", - "index_mcp_pitch": "index_pip", "index_pip": "index_dip", - "middle_mcp_roll": "middle_mcp_pitch", - "middle_mcp_pitch": "middle_pip", "middle_pip": "middle_dip", - "pinky_mcp_pitch": "pinky_pip", -} -# Order also ensures an upstream axis exists before its passive observer. -AXIS_JOINTS = ( - "thumb_cmc_roll", "thumb_cmc_yaw", "thumb_cmc_pitch", "thumb_mcp", - "pinky_mcp_pitch", "pinky_pip", - "middle_mcp_roll", "middle_mcp_pitch", "middle_pip", "middle_dip", - "index_mcp_roll", "index_mcp_pitch", "index_pip", "index_dip", -) +# Historical diagnostic imports read the same protected YAML as production. +_SPATIAL = build_typed_profile().zero.spatial +AXIS_PARENT = dict(_SPATIAL["axis_parent_joint"]) +PHASE_PARENT = dict(_SPATIAL["phase_parent_joint"]) +ZERO_OBSERVER = dict(_SPATIAL["offset_observer_joint"]) +AXIS_JOINTS = tuple(_SPATIAL["axis_order"]) def motor_index(joint: str) -> int: @@ -62,24 +42,7 @@ def motor_index(joint: str) -> int: def full_hand_zero_profile() -> ZeroCalibrationProfile: - # Reuse the existing root orientation datum; adding flexion phases must - # not silently change the palm frame or the fixed SDK channel contract. - from .fitting import _thumb_root_zero_profile - root = _thumb_root_zero_profile() - specs = { - name: JointSpec(name, motor_index(name), name in CALIBRATED_ACTIVE_JOINTS, - None, None, None, pose_axis_line_required=name in PHASE_PARENT) - for name in AXIS_JOINTS - } - return replace( - root, hand=replace(root.hand, joint_specs=specs), - direct_zero_joints=tuple(ZERO_OBSERVER), axis_joints=AXIS_JOINTS, - constrained_circle_joints=frozenset(AXIS_JOINTS), - axis_parent_joint=AXIS_PARENT, phase_parent_joint=PHASE_PARENT, - offset_observer_joint=ZERO_OBSERVER, - accept_validated_zero_in_confidence_interval=True, - project_axis_gauge_before_image=True, - ) + return compile_spatial_profile(build_typed_profile()) class O12SpatialZeroError(ValueError): @@ -99,6 +62,7 @@ def solve_full_hand_zero( solver_curves: Mapping[str, JointCurveFit], ) -> ZeroSolveResult: profile = full_hand_zero_profile() + tasks = {joint: task for task in build_typed_profile().motion.tasks for joint in task.joints} measurements = [] for cycle in range(4): by_joint = {} @@ -117,7 +81,9 @@ def solve_full_hand_zero( constraint = by_joint[parent].axis_common_xyz if parent in by_joint else None try: item = fit_joint_axis_measurement( - name, rows, cycle=cycle, zero_command_u8=255, + name, rows, cycle=cycle, + zero_command_u8=tasks[name].start_value if "input_value" in rows[0] else 255, + input_to_joint_direction=int(SDK_TO_URDF_SIGN[motor_index(name)]), constrained_circle_joints=profile.constrained_circle_joints, view_normal_common_xyz=rows[0]["view_normal_common_xyz"], canonical_zero_direction="decreasing", diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/__init__.py new file mode 100644 index 0000000..b07d9f8 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/__init__.py @@ -0,0 +1,14 @@ +"""Registered O6 calibration profiles.""" + +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import ProfileRegistry + + +def register_profiles(registry: ProfileRegistry) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.left_transfer import build_profile as build_left_transfer_profile + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.profile import build_profile + + registry.register(build_profile()) + registry.register(build_left_transfer_profile()) + + +__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/artifacts.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/artifacts.py similarity index 93% rename from src/linkerhand_calibration/linkerhand_calibration/models/o6/artifacts.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/artifacts.py index 7d84051..a58b49d 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/artifacts.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/artifacts.py @@ -12,10 +12,10 @@ import xml.etree.ElementTree as ET import numpy as np -from ..g20.profile import JointCurveFit -from ..l6.artifacts import artifact_hashes, atomic_write_json, publish_partial_session -from .fitting import MimicFit, O6FitResult -from .profile import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.profile import JointCurveFit +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.artifacts import artifact_hashes, atomic_write_json, publish_partial_session +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.fitting import MimicFit, O6FitResult +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.profile import ( ACTIVE_JOINTS, CALIBRATED_ACTIVE_JOINTS, COMMAND_INDEX_BY_JOINT, @@ -241,7 +241,7 @@ def build_o6_runtime_payload( def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None: - from ...core import ProfileKey + from linkerhand_calibration.core import ProfileKey key = ProfileKey.parse(str(payload.get("profile_id", ""))) if key == KEY: @@ -251,7 +251,7 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None: active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT else: - from .left_transfer import ( + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.left_transfer import ( KEY as LEFT_TRANSFER_KEY, build_typed_profile as build_left_transfer_profile, left_joint_name, @@ -299,10 +299,11 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None: ): raise ValueError("O6 schema v6 identity or command contract is invalid") protected = payload["protected_inputs"] - if not isinstance(protected, Mapping) or set(protected) != { + legacy_protected = { "source_urdf_sha256", "camera_extrinsics_sha256", "calibration_config_sha256", "tag_config_sha256", - } or any(len(str(value)) != 64 for value in protected.values()): + } + if not isinstance(protected, Mapping) or set(protected) not in (legacy_protected, set(profile.artifacts.protected_input_fields)) or any(len(str(value)) != 64 for value in protected.values()): raise ValueError("O6 protected inputs are invalid") joints = payload["joints"] if not isinstance(joints, Mapping) or set(joints) != all_joints: @@ -327,9 +328,9 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None: if name in passive_set: if ( item.get("source_joint") != mimic_sources[name] - or item.get("coupling_model") != coupling_models[name] + or item.get("coupling_model") not in {"linear_mimic", "quadratic_runtime"} or item.get("urdf_mimic_enabled") is not True - or item.get("urdf_mimic_policy") != "endpoint_linear_fallback" + or item.get("urdf_mimic_policy") != ("exact_linear" if item.get("coupling_model") == "linear_mimic" else "endpoint_linear_fallback") ): raise ValueError(f"O6 passive coupling is invalid: {name}") coefficients = np.asarray(item.get("coupling_coefficients"), dtype=float) @@ -346,12 +347,20 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None: multiplier = float(item.get("mimic_multiplier", "nan")) if not math.isfinite(multiplier) or multiplier <= 0.0: raise ValueError(f"O6 passive mimic multiplier is invalid: {name}") + if item["coupling_model"] == "linear_mimic": + if abs(multiplier - coefficients[1]) > 1e-7 or np.any(np.abs(coefficients[2:]) > 1e-10): + raise ValueError(f"{name} linear mimic is not linear") + parent = joints[mimic_sources[name]] + for field in ("angle_rad", "decreasing_rad", "increasing_rad"): + expected = coefficients[0] + multiplier * np.asarray(parent[field]) + if not np.allclose(item[field], expected, atol=1e-7, rtol=0): + raise ValueError(f"{name} curve differs from its declared standard mimic") donor = active_transfers.get(name) or passive_transfers.get(name) if donor is not None: if item.get("transferred_from_joint") != donor: raise ValueError(f"O6 transfer provenance is invalid: {name}") for field in ("angle_rad", "decreasing_rad", "increasing_rad"): - if item[field] != joints[donor][field]: + if name not in passive_set and item[field] != joints[donor][field]: raise ValueError(f"O6 transferred curve differs: {name}") if payload["quality"].get("passed") is not True: raise ValueError("O6 quality.passed must be true") @@ -365,7 +374,7 @@ def build_o6_left_transferred_runtime_payload( serial_number: str, ) -> dict[str, Any]: """Create a validated left runtime JSON paired with a mirrored URDF.""" - from .left_transfer import ( + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.left_transfer import ( KEY as LEFT_TRANSFER_KEY, build_typed_profile as build_left_transfer_profile, left_joint_name, diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/fitting.py new file mode 100644 index 0000000..e552376 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/fitting.py @@ -0,0 +1,25 @@ +"""Legacy result types plus a direct call to the ONE profile fitter. + +Old endpoint/range-centre fallbacks and model-specific refitting are retired. +The legacy result type remains only for reading archived JSON/tool inputs. +""" +from __future__ import annotations +from pathlib import Path +from linkerhand_calibration.core.domain.measurement import JointCurveFit +from linkerhand_calibration.core.domain.result import JointMapping +from linkerhand_calibration.core.fitting import CouplingFit as MimicFit, fit_coupling_model, curve_travel_rad +from linkerhand_calibration.core.fitting.coupling import LinearMimicFit, RelativeMimicEvidence +from linkerhand_calibration.core.fitting.spatial import ZeroSolveResult +from linkerhand_calibration.core.fitting.session import fit_profile_calibration, compile_spatial_profile +from linkerhand_calibration.profiles import load_bundled_hand_profile + +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.fitting import L6FitResult as O6FitResult + + +def _zero_profile(): + return compile_spatial_profile(load_bundled_hand_profile("o6_right_8")) + +def fit_o6_session(source_urdf, records_by_joint, *, require_thumb_axis_zero=False): + # Historical flags can no longer disable spatial or holdout verification. + return fit_profile_calibration(load_bundled_hand_profile("o6_right_8"), + Path(source_urdf), records_by_joint) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/left_transfer.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/left_transfer.py similarity index 87% rename from src/linkerhand_calibration/linkerhand_calibration/models/o6/left_transfer.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/left_transfer.py index 0f240fd..d41a4a8 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/left_transfer.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/left_transfer.py @@ -4,9 +4,9 @@ from __future__ import annotations from dataclasses import replace -from ...core import ProfileKey -from ..registry import EngineBindings, RegisteredProfile -from .profile import build_typed_profile as build_right_typed_profile +from linkerhand_calibration.core import ProfileKey +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.profile import build_typed_profile as build_right_typed_profile KEY = ProfileKey("O6", "left", "o6_left_transferred_8", 1) @@ -59,6 +59,12 @@ def build_typed_profile(): ), zero=replace( right.zero, + transferred_zero_sources={left_joint_name(k): left_joint_name(v) + for k, v in right.zero.transferred_zero_sources.items()}, + transferred_mimic_sources={left_joint_name(k): left_joint_name(v) + for k, v in right.zero.transferred_mimic_sources.items()}, + # Runtime-only mirrored products do not acquire/solve new geometry. + spatial={}, active_joints=active, passive_joints=passive, direct_zero_joints=tuple( @@ -135,7 +141,7 @@ def _unsupported(_args=None) -> None: def build_profile() -> RegisteredProfile: - from ..l6.motion import ( + from linkerhand_calibration.runtime.trajectory import ( build_calibration_motion_command, build_calibration_preparation_waypoints, build_calibration_return_waypoints, diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/profile.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/profile.py new file mode 100644 index 0000000..fce6606 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/profile.py @@ -0,0 +1,86 @@ +"""Reviewed partial-calibration profile for the right O6 eight-Tag rig.""" + +from __future__ import annotations + +from linkerhand_calibration.core import CalibrationProfile, ProfileKey +from linkerhand_calibration.runtime.trajectory import ( + build_calibration_motion_command, + build_calibration_preparation_waypoints, + build_calibration_return_waypoints, +) +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.registry import EngineBindings, RegisteredProfile + + +# Historical import constants are projections of the authoritative YAML, +# never a second set of model/zero/coupling definitions. +from linkerhand_calibration.profiles.loader import load_bundled_hand_profile +_DECLARED = load_bundled_hand_profile("o6_right_8") +KEY = _DECLARED.key +COMMAND_NAMES = _DECLARED.command.names +COMMAND_INDEX_BY_JOINT = dict(_DECLARED.command.command_index_by_joint) +ACTIVE_JOINTS = tuple(sorted(COMMAND_INDEX_BY_JOINT, key=COMMAND_INDEX_BY_JOINT.get)) +MIMIC_SOURCE_BY_JOINT = dict(_DECLARED.zero.mimic_source_by_joint) +PASSIVE_JOINTS = tuple(name for source in ACTIVE_JOINTS + for name, parent in MIMIC_SOURCE_BY_JOINT.items() if parent == source) +CALIBRATED_ACTIVE_JOINTS = _DECLARED.scope.selected_joints(_DECLARED.scope.default_scope) +MEASURED_PASSIVE_JOINTS = _DECLARED.zero.fitted_mimic_joints +TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_zero_sources) +TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = dict(_DECLARED.zero.transferred_mimic_sources) +CORRECTED_ACTIVE_JOINTS = _DECLARED.zero.active_joints +CORRECTED_PASSIVE_JOINTS = frozenset(MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys()) +ENDPOINT_ANCHOR_BY_JOINT = dict(_DECLARED.zero.endpoint_anchor_by_joint) +COUPLING_MODEL_BY_JOINT = dict(_DECLARED.zero.coupling_model_by_joint) +GEOMETRIC_ZERO_JOINTS = frozenset(_DECLARED.zero.direct_zero_joints) +CAD_ENDPOINT_ZERO_JOINTS = frozenset() +BASELINE_SPEED_U8 = _DECLARED.motion.speed_parameters["baseline_u8"] +PREFLIGHT_SPEED_U8 = _DECLARED.motion.speed_parameters["preflight_u8"] +FORMAL_SPEED_U8 = _DECLARED.motion.speed_parameters["formal_u8"] +MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX = _DECLARED.vision.extrinsics_quality_limits["reprojection_rms_px"] +# Kept only for archived result readers, never online stop or publication gates. +MAXIMUM_HYSTERESIS_DEG = 3.5 +COUPLING_RESIDUAL_P95_DEG = 2.0 +COUPLING_RESIDUAL_MAX_DEG = 3.0 +MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M = 0.020 + + +def build_typed_profile() -> CalibrationProfile: + """Compatibility entry; the complete typed contract lives only in YAML.""" + from linkerhand_calibration.profiles.loader import load_bundled_hand_profile + return load_bundled_hand_profile("o6_right_8") + + +def _run_cli(args: list[str] | None = None) -> None: + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.runner import main + + main(args) + + +def _run_node(args: list[str] | None = None) -> None: + from linkerhand_calibration.runtime.ros.calibration_node import run_profile_node + run_profile_node(build_typed_profile(), args) + + +def build_profile() -> RegisteredProfile: + typed = build_typed_profile() + return RegisteredProfile( + profile=typed, + engine=EngineBindings( + hand_profile=typed, + zero_profile=typed.zero, + motion_command=build_calibration_motion_command, + preparation_waypoints=build_calibration_preparation_waypoints, + return_waypoints=build_calibration_return_waypoints, + cli_main=_run_cli, + node_main=_run_node, + ), + ) + + +__all__ = [ + "ACTIVE_JOINTS", "CALIBRATED_ACTIVE_JOINTS", "COMMAND_NAMES", + "CORRECTED_ACTIVE_JOINTS", "CORRECTED_PASSIVE_JOINTS", + "COUPLING_MODEL_BY_JOINT", "ENDPOINT_ANCHOR_BY_JOINT", "KEY", + "MEASURED_PASSIVE_JOINTS", "MIMIC_SOURCE_BY_JOINT", "PASSIVE_JOINTS", + "TRANSFERRED_ACTIVE_SOURCE_BY_JOINT", + "TRANSFERRED_PASSIVE_SOURCE_BY_JOINT", "build_profile", "build_typed_profile", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/runner.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/runner.py new file mode 100644 index 0000000..6ba7101 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/runner.py @@ -0,0 +1,4 @@ +"""Legacy import path; all online work uses the common product runner.""" + +from linkerhand_calibration.runtime.runner import main +from linkerhand_calibration.compat.legacy_diagnostic_tools.progress import render_o6_progress_zh diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/urdf.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/urdf.py similarity index 95% rename from src/linkerhand_calibration/linkerhand_calibration/models/o6/urdf.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/urdf.py index 029c9fa..2d7ca1c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/urdf.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/o6/urdf.py @@ -11,10 +11,14 @@ import xml.etree.ElementTree as ET import numpy as np from scipy.spatial.transform import Rotation -from ...core.urdf import UrdfJointPatch, UrdfPatchSet, write_urdf_patches -from ..l6.urdf import L6UrdfCorrection, _corrected_origin_rpy, _triplet -from .fitting import O6FitResult -from .profile import ( +from linkerhand_calibration.core.urdf import UrdfJointPatch, UrdfPatchSet, write_urdf_patches +from linkerhand_calibration.core.urdf import ( + corrected_origin_rpy as _corrected_origin_rpy, + parse_vector3 as _triplet, +) +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.urdf import L6UrdfCorrection +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.fitting import O6FitResult +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.profile import ( CALIBRATED_ACTIVE_JOINTS, CORRECTED_ACTIVE_JOINTS, CORRECTED_PASSIVE_JOINTS, @@ -22,6 +26,7 @@ from .profile import ( MIMIC_SOURCE_BY_JOINT, TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, + build_typed_profile, ) @@ -254,6 +259,7 @@ def write_o6_corrected_urdf( patches=UrdfPatchSet(joints=patches), forbidden_source_stem_patterns=(r"calibrated",), copy_complete_mesh_directory=True, + authorized_fields=build_typed_profile().urdf_authorized_fields, ) return O6UrdfCorrection( path=destination, diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/registry.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/registry.py similarity index 80% rename from src/linkerhand_calibration/linkerhand_calibration/models/registry.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/registry.py index b2fcd54..b18d0b7 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/registry.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/registry.py @@ -5,9 +5,9 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Callable, Iterator -from ..core import CalibrationProfile, ProfileKey, validate_profile -from ..runtime.engine import CalibrationEngine -from ..runtime.adapters import ProfileSdkAdapter +from linkerhand_calibration.core import CalibrationProfile, ProfileKey, validate_profile +from linkerhand_calibration.runtime.engine import CalibrationEngine +from linkerhand_calibration.runtime.adapters import ProfileSdkAdapter @dataclass(frozen=True) @@ -85,10 +85,10 @@ _DEFAULT_REGISTRY: ProfileRegistry | None = None def get_default_registry() -> ProfileRegistry: global _DEFAULT_REGISTRY if _DEFAULT_REGISTRY is None: - from .g20 import register_profiles - from .l6 import register_profiles as register_l6_profiles - from .o6 import register_profiles as register_o6_profiles - from .o12 import register_profiles as register_o12_profiles + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20 import register_profiles + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6 import register_profiles as register_l6_profiles + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6 import register_profiles as register_o6_profiles + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12 import register_profiles as register_o12_profiles registry = ProfileRegistry() register_profiles(registry) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/runtime_schema.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/runtime_schema.py similarity index 66% rename from src/linkerhand_calibration/linkerhand_calibration/models/runtime_schema.py rename to src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/runtime_schema.py index 1b42c27..8f7e438 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/runtime_schema.py +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/models/runtime_schema.py @@ -4,18 +4,18 @@ from __future__ import annotations from typing import Any, Mapping -from ..core import ProfileKey +from linkerhand_calibration.core import ProfileKey def validate_schema_v6_runtime_payload(payload: Mapping[str, Any]) -> None: key = ProfileKey.parse(str(payload.get("profile_id", ""))) if key.model == "L6": - from .l6.artifacts import validate_l6_runtime_payload + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.l6.artifacts import validate_l6_runtime_payload validate_l6_runtime_payload(payload) return if key.model == "O6": - from .o6.artifacts import validate_o6_runtime_payload + from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.artifacts import validate_o6_runtime_payload validate_o6_runtime_payload(payload) return diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/progress.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/progress.py new file mode 100644 index 0000000..b2ff8d7 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/progress.py @@ -0,0 +1,251 @@ +"""Read-only renderers for old recorded status payloads, not live runners.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from ...operator_report import ProgressEstimator +from ...runtime.reporting import reason_zh, render_progress_zh + +common_reason_zh = reason_zh +render_six_channel_progress_zh = render_progress_zh + +_L6_TASK_LABELS = { + "thumb_roll_top": "拇指 CMC roll(上方机位,ID6→ID7)", + "thumb_pitch_dip_front": "拇指 CMC pitch / DIP(正面机位,ID0→ID1→ID2)", + "pinky_pitch_dip_side": "小指 MCP pitch / DIP(侧面机位,ID3→ID4→ID5)", +} + + +def _l6_reason_zh( + status: Mapping[str, Any], *, model_name: str = "L6" +) -> tuple[str, str, str]: + return common_reason_zh(status, model_name=model_name) + + +def render_l6_progress_zh( + status: Mapping[str, Any], + estimator: ProgressEstimator | None = None, +) -> str: + """Render L6 progress in the same operator-oriented layout as G20.""" + return render_six_channel_progress_zh( + status, + task_labels=_L6_TASK_LABELS, + reason_renderer=_l6_reason_zh, + estimator=estimator, + ) + +_O6_TASK_LABELS = { + "thumb_yaw_top": "拇指 CMC yaw(上面机位,ID6→ID7)", + "thumb_pitch_ip_front": "拇指 CMC pitch / IP(正面机位,ID0→ID1→ID2)", + "pinky_pitch_dip_side": "小指 MCP pitch / DIP(侧面机位,ID3→ID4→ID5)", +} + + +def render_o6_progress_zh( + status: dict[str, Any], + estimator: ProgressEstimator | None = None, +) -> str: + return render_progress_zh( + status, + task_labels=_O6_TASK_LABELS, + reason_renderer=lambda value: reason_zh( + value, model_name="O6" + ), + estimator=estimator, + ) + +_O12_TASK_LABELS = { + "thumb_pitch_front": "拇指 CMC pitch(正面 ID0→ID1)", + "thumb_roll_front": "拇指 CMC roll(正面 ID0→ID1)", + "thumb_mcp_dip_front": "拇指 MCP / 被动 DIP(正面 ID1→ID2→ID3)", + "thumb_yaw_top": "拇指 CMC yaw(顶部 ID14→ID15)", + "pinky_chain_side": "小指 MCP / 被动 PIP/DIP(侧面 ID4→ID5→ID6→ID7)", + "middle_roll_front": "中指 MCP roll(正面 ID0→ID12 + 侧面 ID4→ID8)", + "middle_mcp_side": "中指 MCP pitch(侧面 ID4→ID8)", + "middle_pip_dip_side": "中指 PIP / 被动 DIP(侧面 ID8→ID9)", + "index_roll_front": "食指 MCP roll(正面 ID0→ID13 + 侧面 ID4→ID10)", + "index_mcp_side": "食指 MCP pitch(侧面 ID4→ID10)", + "index_pip_dip_side": "食指 PIP / 被动 DIP(侧面 ID10→ID11)", +} + + +def _o12_reason_zh(status): + reason = str(status.get("reason", "")) + if reason.startswith("sweep_quality_failed:"): + details = reason.split(":", 2)[-1] + if any( + item in details + for item in ("side_frames=", "side_bins=", "side_maximum_gap=") + ): + return ( + "O12-CROSS-VIEW-SAMPLING-104", + "侧摆任务的主视角数据已采到,但辅助侧面视角在本段轨迹内" + f"保留的时序覆盖不足:{details}。这不表示 Tag 被遮挡。", + "程序会保留已通过断点;重新启动后只重做该扫描单元。" + "若 Tag 持续可见,优先检查侧面 AprilTag 节点的发布频率和 CPU 调度。", + ) + if reason.startswith("mapping_preflight_no_target_motion:"): + task = reason.split(":", 1)[1] + return ( + "O12-MAPPING-301", + f"固定 SDK 映射点动时没有观察到目标关节运动:{task}。", + "检查对应 Tag、通道映射和机械连接;不要交换通道后强行继续。", + ) + if reason.startswith("mapping_preflight_wrong_feedback_direction:"): + task = reason.split(":", 1)[1] + return ( + "O12-MAPPING-302", + f"固定 SDK 映射点动的目标反馈方向不正确:{task}。", + "检查对应 SDK 通道和机械连接;不要交换通道后强行继续。", + ) + if reason.startswith("o12_active_motor_fault"): + return ( + "O12-HEALTH-201", + f"O12 返回活动电机故障:{status.get('error_faults', [])}。", + "检查对应电机的堵转、过流、过热或电机异常;排除后从断点启动。", + ) + if reason.startswith("o12_new_communication_fault"): + return ( + "O12-HEALTH-202", + f"运行中出现了新的 O12 通信异常:{status.get('error_faults', [])}。", + "检查 HCAN 线缆、供电和对应电机通信;程序已保持当前位置。", + ) + if reason.startswith("o12_feedback_stream_timeout"): + return ( + "O12-HEALTH-203", + "O12 命令触发反馈流中断超过 1 秒。", + "检查 HCAN 连接、SDK 节点和供电;恢复后从断点启动。", + ) + if reason.startswith("feedback_outside_registered_feedback_domain:"): + return ( + "O12-FEEDBACK-202", + "O12 反馈超出了已登记的物理反馈范围。", + "保持当前位置,检查诊断中的通道、反馈值和机械端点。", + ) + if reason == "calibration_node_status_timeout": + return ( + "O12-NODE-500", + "O12 标定节点已停止发布状态,运行栈将自动退出。", + "查看本次 calibration.log 中的 Python traceback,并从断点重启。", + ) + return reason_zh(status, model_name="O12") + + +def render_o12_progress_zh(status, estimator=None) -> str: + """Render O12 in the same operator-oriented layout as G20 and L6.""" + text = render_progress_zh( + status, + task_labels=_O12_TASK_LABELS, + reason_renderer=_o12_reason_zh, + estimator=estimator, + ) + temperature = ( + "直接温度回读" + if status.get("temperature_report_verified") + else "错误码 bit1 过热保护" + if status.get("temperature_fallback_active") + else "等待温度能力确认" + ) + resume = status.get("resume", {}) + resume_text = "" + if isinstance(resume, dict) and resume.get("used"): + resume_text = ( + "\n断点恢复:已复用 " + f"{int(resume.get('completed_unit_count', 0))} 个扫描单元;" + f"来源 {resume.get('source_session', '-')}" + ) + cross_view = status.get("roll_cross_view", {}) + cross_view_text = "" + if isinstance(cross_view, dict) and cross_view.get("active"): + recognized = "/".join( + f"ID{int(value)}" + for value in cross_view.get("recognized_tag_ids", ()) + ) or "无" + missing = "/".join( + f"ID{int(value)}" + for value in cross_view.get("unrecognized_tag_ids", ()) + ) or "无" + cross_view_text = ( + "\n侧摆双机位:侧面已识别 " + f"{recognized};未识别/不合格 {missing};联合 " + f"{int(cross_view.get('valid_frames', 0))}/" + f"{int(cross_view.get('total_frames', 0))} 帧(" + f"{float(cross_view.get('joint_frame_rate', 0.0)):.1%})" + ) + coupling = status.get("feedback_coupling", {}) + coupling_text = "" + if isinstance(coupling, dict) and coupling.get("active"): + stage = { + "training_observation": "训练采集", + "holdout_pending_full_sweep_validation": "第四轮整段验证", + "vendor_solver_motion_prior": "运动准备", + }.get(str(coupling.get("reference_kind", "")), "耦合观测") + coupling_text = ( + "\nO12耦合:" + f"{coupling.get('coupled_channel', '-')} 位移 " + f"{float(coupling.get('displacement_rad', 0.0)):.3f}/" + f"{float(coupling.get('hard_displacement_limit_rad', 0.0)):.3f} rad;" + f"{stage};vendor偏差 " + f"{float(coupling.get('residual_rad', 0.0)):.3f} rad(仅诊断)" + ) + auxiliary = status.get("auxiliary_tracking", {}) + auxiliary_text = "" + if isinstance(auxiliary, dict) and auxiliary.get("active"): + channels = list(auxiliary.get("channels", ())) + worst = max( + channels, + key=lambda item: float(item.get("error_rad", 0.0)), + default={}, + ) + stage = ( + "平滑过渡中" + if auxiliary.get("mode") == "transitioning" + else "已稳定保持" + if auxiliary.get("ready") + else "等待稳定" + ) + auxiliary_text = ( + "\nO12避让轴:" + f"{stage};最大偏差 {worst.get('channel', '-')}=" + f"{float(worst.get('error_rad', 0.0)):.3f} rad" + ) + locked_ids = list(status.get("locked_reference_tag_ids", ())) + locked_reference_text = "" + if locked_ids: + locked_reference_text = ( + "\n固定基准:" + + "/".join(f"ID{int(value)}" for value in locked_ids) + + " 已锁定,避让遮挡期间复用" + ) + error_health = str( + status.get("error_health_classification", "awaiting_error_report") + ) + if error_health == "historical_communication_latch": + channels = "/".join( + str(value) for value in status.get( + "confirmed_historical_communication_channels", () + ) + ) or "未知通道" + error_health_text = f"历史通信位已核验({channels})" + elif error_health == "confirming_historical_communication": + error_health_text = ( + "确认历史通信位 " + f"{int(status.get('error_report_matching_count', 0))}/3" + ) + elif error_health == "clear": + error_health_text = "正常" + else: + error_health_text = error_health + return ( + text + cross_view_text + coupling_text + auxiliary_text + + locked_reference_text + resume_text + ( + "\nO12安全:POSITION=" + f"{bool(status.get('position_mode_verified'))};" + f"错误码通道={bool(status.get('error_report_verified'))}" + f"({error_health_text});" + f"温度策略={temperature};速度倍率=" + f"{float(status.get('motion_speed_scale', 1.0)):.1f}x;" + f"控制发布={float(status.get('command_publish_hz', 0.0)):.1f} Hz" + ) + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/runner_helpers.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/runner_helpers.py new file mode 100644 index 0000000..850e2c0 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy_diagnostic_tools/runner_helpers.py @@ -0,0 +1,283 @@ +"""Read-only helpers retained for archived runner diagnostics; no live loop.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Mapping + +from ...product import ProductConfig, sha256_file +from ...runtime.engine import ACQUISITION_POLICY_VERSION + +STATUS_TIMEOUT_SECONDS = 90.0 +FITTING_STATUS_TIMEOUT_SECONDS = 600.0 + +def _status_timeout_seconds(status: Mapping[str, Any]) -> float: + """Return the watchdog deadline for the node's current phase. + + Motion and acquisition are expected to publish twice a second and retain + the strict transport watchdog. The final 3-D fit is intentionally a + synchronous, CPU-bound operation, so its executor cannot service the + status timer until the fit returns. The node publishes an explicit + FITTING status immediately before entering that operation; allow that + known phase enough time without weakening motion safety checks. + """ + if str(status.get("state", "")) == "FITTING": + return FITTING_STATUS_TIMEOUT_SECONDS + return STATUS_TIMEOUT_SECONDS + + +def _calibration_node_exited_before_status(log_path: Path) -> bool: + """Detect a launch child crash while the parent launch is still alive.""" + try: + with log_path.open("rb") as stream: + stream.seek(0, os.SEEK_END) + size = stream.tell() + stream.seek(max(0, size - 65536), os.SEEK_SET) + tail = stream.read().decode("utf-8", errors="replace") + except OSError: + return False + return ( + "[three_camera_calibration_node-" in tail + and "]: process has died" in tail + ) + +def _launch_command( + config: ProductConfig, + session: Path, + *, + resume_from: Path | None = None, + recalibration_scope: str = "full", +) -> list[str]: + values = { + "model": config.model, + "hand_type": config.side, + "tag_layout": config.tag_layout, + "serial_number": config.serial_number, + "can_interface": config.can_interface, + "session_dir": str(session), + "output_root": str(config.output_root), + "camera_extrinsics_file": str(config.camera_extrinsics), + "source_urdf_path": str(config.source_urdf), + "source_urdf_expected_sha256": config.source_urdf_sha256, + "camera_extrinsics_expected_sha256": config.camera_extrinsics_sha256, + "calibration_config_expected_sha256": config.calibration_config_sha256, + "tag_config_expected_sha256": config.tag_config_sha256, + "profile_config_expected_sha256": getattr( + config, "profile_config_sha256", "" + ), + "corrected_urdf_output_dir": str(session), + "calibration_config": str(config.calibration_config), + "tag_config": str(config.tag_config), + "commands_enabled": "true", + "start_cameras": "true", + "start_sdk": "true", + "record_bag": "false", + "validation_enabled": "false", + "recalibration_scope": recalibration_scope, + } + if resume_from is not None: + values["resume_raw_samples_path"] = str( + resume_from / "raw_samples.jsonl" + ) + for view, camera in config.cameras.items(): + values[f"{view}_camera_serial"] = camera["serial_number"] + values[f"{view}_camera_name"] = camera["camera_name"] + values[f"{view}_camera_info_url"] = camera["camera_info"] + return [ + "ros2", + "launch", + "linkerhand_calibration", + "unified_calibration.launch.py", + *(f"{name}:={value}" for name, value in values.items()), + ] + +def _automatic_resume_candidate(config: ProductConfig) -> Path | None: + """Return the newest compatible failed attempt, never a passed session. + + Do not trust only ``latest_attempt``. A process interrupted during the + device-only startup gate may have already moved that pointer while still + containing no ``session_start`` checkpoint. In that case walk backwards + to the preceding usable failed session instead of throwing away hours of + completed tasks. + """ + root = config.session_root + try: + resolved_root = root.resolve(strict=True) + except OSError: + return None + + candidates: list[Path] = [] + pointer = config.session_root / "latest_attempt" + if pointer.exists(): + try: + candidates.append(pointer.resolve(strict=True)) + except OSError: + pass + try: + candidates.extend( + sorted( + ( + path + for path in root.iterdir() + if path.is_dir() and not path.name.startswith("latest_") + ), + key=lambda path: path.name, + reverse=True, + ) + ) + except OSError: + return None + + passed_pointer = config.session_root / "latest_passed" + passed: Path | None = None + if passed_pointer.exists(): + try: + passed = passed_pointer.resolve(strict=True) + except OSError: + pass + + seen: set[Path] = set() + for unresolved in candidates: + try: + candidate = unresolved.resolve(strict=True) + except OSError: + continue + if candidate in seen: + continue + seen.add(candidate) + if candidate.parent != resolved_root or not candidate.is_dir(): + continue + # A failed attempt older than the current formal release is stale and + # must not seed a new independent calibration. + if passed is not None and candidate.name <= passed.name: + continue + raw_path = candidate / "raw_samples.jsonl" + if not raw_path.is_file(): + continue + summary_path = candidate / "calibration_summary_zh.json" + summary: dict[str, Any] | None = None + if summary_path.is_file(): + try: + loaded = json.loads(summary_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + if not isinstance(loaded, dict) or loaded.get("result") != "FAIL": + continue + summary = loaded + hashes = summary.get("hashes", {}) + if not isinstance(hashes, Mapping): + continue + if ( + str(hashes.get("source_urdf_sha256", "")) + != config.source_urdf_sha256 + or str(hashes.get("camera_extrinsics_sha256", "")) + != config.camera_extrinsics_sha256 + ): + continue + start: dict[str, Any] | None = None + try: + with raw_path.open("r", encoding="utf-8") as stream: + for line in stream: + if not line.strip(): + continue + value = json.loads(line) + if ( + isinstance(value, dict) + and value.get("kind") == "session_start" + ): + start = value + break + except (OSError, json.JSONDecodeError): + continue + if ( + start is None + or start.get("acquisition_policy_version") + != ACQUISITION_POLICY_VERSION + or start.get("hand_type") != config.side + or start.get("tag_layout") != config.tag_layout + or start.get("source_urdf_sha256") + != config.source_urdf_sha256 + or ( + bool(start.get("protected_hashes")) + and dict(start.get("protected_hashes", {})) + != { + "source_urdf_sha256": config.source_urdf_sha256, + "camera_extrinsics_sha256": config.camera_extrinsics_sha256, + "calibration_config_sha256": config.calibration_config_sha256, + "tag_config_sha256": config.tag_config_sha256, + "profile_config_sha256": config.profile_config_sha256, + } + ) + ): + continue + if summary is None: + # Ctrl+C can terminate the ROS launch tree before the wrapper gets + # a chance to create calibration_summary_zh.json. The immutable + # checkpoint itself is enough to resume only after independently + # proving that its external geometry still matches the product. + try: + checkpoint_extrinsics = Path( + str(start["camera_extrinsics_file"]) + ).expanduser().resolve(strict=True) + if sha256_file(checkpoint_extrinsics) != ( + config.camera_extrinsics_sha256 + ): + continue + except (KeyError, OSError, ValueError): + continue + return candidate + return None + + +def _resolve_partial_base_session( + config: ProductConfig, base_session: str | Path | None +) -> Path: + """Validate the complete passed session that donates non-target tasks.""" + if base_session is None or not str(base_session).strip(): + raise ValueError( + "partial scope requires --base-session pointing to a passed " + "complete G20 right session" + ) + candidate = Path(base_session).expanduser().resolve(strict=True) + root = config.session_root.resolve() + if candidate.parent != root or not candidate.is_dir(): + raise ValueError( + "base session must resolve to a direct session directory under " + f"{root}" + ) + raw_path = candidate / "raw_samples.jsonl" + summary_path = candidate / "calibration_summary_zh.json" + payload_path = ( + candidate + / f"g20_right_{config.serial_number}_calibration.json" + ) + for required in (raw_path, summary_path, payload_path): + if not required.is_file(): + raise ValueError(f"base session is missing required artifact: {required}") + try: + summary = json.loads(summary_path.read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + raise ValueError("base session summary is invalid JSON") from error + if ( + not isinstance(summary, dict) + or summary.get("result") != "PASS" + or not bool(summary.get("quality", {}).get("passed")) + ): + raise ValueError("base session is not a formally passed session") + hashes = summary.get("hashes", {}) + expected_hashes = { + "source_urdf_sha256": config.source_urdf_sha256, + "camera_extrinsics_sha256": config.camera_extrinsics_sha256, + "calibration_config_sha256": config.calibration_config_sha256, + } + if not isinstance(hashes, Mapping) or any( + str(hashes.get(name, "")) != expected + for name, expected in expected_hashes.items() + ): + raise ValueError( + "base session source URDF, camera extrinsics or calibration " + "configuration differs from the current product" + ) + return candidate diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/__init__.py index b6b43d6..6cd02ad 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/__init__.py @@ -3,17 +3,23 @@ from .domain import ( AcquisitionPolicy, ArtifactPolicy, + AcquisitionStatus, CalibrationProfile, + CalibrationResult, + JointMapping, + CalibrationStatus, CommandLayout, MeasurementPolicy, MeasurementSpec, MotionPolicy, + MotionStatus, + PauseStatus, ProfileKey, ProfileValidationError, QualityPolicy, - SampleRecord, ScopePolicy, TagSpec, + TaskStatus, TaskSpec, ViewSpec, VisionRigSpec, @@ -43,7 +49,11 @@ from .geometry import ( __all__ = [ "AcquisitionPolicy", "ArtifactPolicy", + "AcquisitionStatus", "CalibrationProfile", + "CalibrationResult", + "JointMapping", + "CalibrationStatus", "CommandLayout", "DIRECTION_DECREASING", "DIRECTION_INCREASING", @@ -52,14 +62,16 @@ __all__ = [ "MeasurementPolicy", "MeasurementSpec", "MotionPolicy", + "MotionStatus", + "PauseStatus", "PHASE_ROOT", "PHASE_TIP", "ProfileKey", "ProfileValidationError", "QualityPolicy", - "SampleRecord", "ScopePolicy", "TagSpec", + "TaskStatus", "TaskSpec", "ViewSpec", "VisionRigSpec", diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/__init__.py index 25cb69c..26ef89e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/__init__.py @@ -1,5 +1 @@ -"""Artifact schema and release validation contracts.""" - -from .release import ReleaseValidation, ReleaseValidator - -__all__ = ["ReleaseValidation", "ReleaseValidator"] +"""Storage helpers shared by calibration artifact writers.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/release.py b/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/release.py deleted file mode 100644 index ce0bf4b..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/core/artifacts/release.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Release validation protocol used before atomic publication.""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path -from typing import Mapping, Protocol - -from ..domain import CalibrationProfile -from ..urdf import UrdfCorrectionPlan - - -@dataclass(frozen=True) -class ReleaseValidation: - passed: bool - errors: tuple[str, ...] = () - verified_hashes: Mapping[str, str] | None = None - - -class ReleaseValidator(Protocol): - def validate_release( - self, - profile: CalibrationProfile, - plan: UrdfCorrectionPlan, - calibration_json: Path, - corrected_urdf: Path, - ) -> ReleaseValidation: ... diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/__init__.py index dd75d79..cfd381e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/__init__.py @@ -19,22 +19,35 @@ from .profile import ( ZeroSolvePolicy, validate_profile, ) -from .sample import SampleRecord +from .result import CalibrationResult, JointMapping +from .status import ( + AcquisitionStatus, + CalibrationStatus, + MotionStatus, + PauseStatus, + TaskStatus, +) __all__ = [ "AcquisitionPolicy", "ArtifactPolicy", + "AcquisitionStatus", "CalibrationProfile", + "CalibrationResult", + "JointMapping", + "CalibrationStatus", "CommandLayout", "MeasurementPolicy", "MeasurementSpec", "MotionPolicy", + "MotionStatus", "ProfileKey", "ProfileValidationError", + "PauseStatus", "QualityPolicy", - "SampleRecord", "ScopePolicy", "TagSpec", + "TaskStatus", "TaskSpec", "ViewSpec", "VisionRigSpec", diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/measurement.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/measurement.py new file mode 100644 index 0000000..cbb27a9 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/measurement.py @@ -0,0 +1,72 @@ +"""Shared measurement and curve contracts; no hardware or model defaults.""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Mapping + +@dataclass(frozen=True) +class JointSpec: + name: str + motor_index: int + active: bool + view: str | None + parent_role: str | None + child_role: str | None + source_joint: str | None = None + zero_kind: str | None = None + # Keep the physical axis-line gate only when that line contributes to a + # released URDF zero/axis decision. Curve-only passive measurements may + # retain the monocular line residual as a diagnostic while their image + # trajectory, relative rotation, synchronisation and holdout gates remain + # release-critical. + pose_axis_line_required: bool = True + + @property + def measured(self) -> bool: + return self.source_joint is None + + +@dataclass(frozen=True) +class SweepSpec: + view: str + motor_index: int + joints: tuple[str, ...] + task_name: str = "" + auxiliary_commands: tuple[tuple[int, int], ...] = () + validation_only: bool = False + + @property + def key(self) -> str: + if self.task_name: + return self.task_name + return f"{self.view}:motor{self.motor_index}:{','.join(self.joints)}" + + +@dataclass(frozen=True) +class PalmAxisObserver: + """Non-blocking direction observation attached to an existing task. + + This is deliberately not a ``JointSpec``: it has no curve, endpoint or + retry semantics. The camera callback records it only while both Tags are + visible during the named sweep. + """ + + source_name: str + task_name: str + view: str + parent_role: str + child_role: str + model_joint: str + motor_index: int + + +@dataclass(frozen=True) +class JointCurveFit: + angle_rad: tuple[float, ...] + decreasing_rad: tuple[float, ...] + increasing_rad: tuple[float, ...] + circle: Mapping[str, Any] + maximum_monotonic_correction_rad: float + maximum_hysteresis_rad: float + quality: Mapping[str, float] + zero_offset_rad: float = 0.0 + diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py index e0d26aa..6fa6244 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py @@ -8,6 +8,9 @@ from pathlib import PurePath from typing import Mapping +ACQUISITION_POLICY_VERSION = "unified_engine_v4_dual_mapping" + + @dataclass(frozen=True, order=True) class ProfileKey: """Stable identity for one independently reviewed hand profile.""" @@ -74,6 +77,15 @@ class CommandLayout: feedback_lower_bounds: tuple[float, ...] = () feedback_upper_bounds: tuple[float, ...] = () feedback_by_index: bool = False + # Signed derivative of the URDF coordinate with respect to native SDK + # feedback, in fixed channel order. It is a product contract, not inferred + # by swapping channels after observing motion. + sdk_to_joint_direction: tuple[int, ...] = () + maximum_velocity: tuple[float, ...] = () + + @property + def joint_directions(self) -> tuple[int, ...]: + return self.sdk_to_joint_direction or (-1,) * self.command_count @property def command_count(self) -> int: @@ -137,6 +149,8 @@ class TagSpec: role: str tag_id: int fixed_reference: bool = False + link: str | None = None + size_m: float = 0.016 @dataclass(frozen=True) @@ -182,6 +196,8 @@ class TaskSpec: end: float | None = None preflight_speed: float | None = None formal_speed: float | None = None + preparation_groups: tuple[tuple[int, ...], ...] = () + entry_waypoints: tuple[tuple[tuple[int, float], ...], ...] = () @property def start_value(self) -> float: @@ -192,6 +208,13 @@ class TaskSpec: return float(self.end_u8 if self.end is None else self.end) +@dataclass(frozen=True) +class ReferenceWaypoint: + key: str + command: tuple[float, ...] + tag_ids_by_view: Mapping[str, tuple[int, ...]] + + @dataclass(frozen=True) class MotionPolicy: """Reviewed motion tasks and optional safe waypoint sequences.""" @@ -202,6 +225,8 @@ class MotionPolicy: speed_parameters: Mapping[str, float] = field(default_factory=dict) precheck_sweeps: bool = False steady_command_checkpoints: bool = False + return_groups: tuple[tuple[int, ...], ...] = () + resume_verification_waypoints: tuple[ReferenceWaypoint, ...] = () @dataclass(frozen=True) @@ -228,6 +253,8 @@ class MeasurementPolicy: directional_zero: bool = False cross_view_roll_curve: bool = False stable_cross_view_cone_bias: bool = False + candidate_selection_tasks: frozenset[str] = frozenset() + input_domain: str = "" @dataclass(frozen=True) @@ -249,6 +276,10 @@ class ZeroSolvePolicy: # ``mimic`` element. Profiles must opt in explicitly before a nonlinear # runtime/MuJoCo relation may be published. coupling_model_by_joint: Mapping[str, str] = field(default_factory=dict) + transferred_zero_sources: Mapping[str, str] = field(default_factory=dict) + transferred_mimic_sources: Mapping[str, str] = field(default_factory=dict) + # Geometry policy only; no Python callback/import strings are permitted. + spatial: Mapping[str, object] = field(default_factory=dict) @dataclass(frozen=True) @@ -264,7 +295,7 @@ class QualityPolicy: class AcquisitionPolicy: """Cross-model runtime safety and retained-data acceptance contract.""" - policy_version: str = "unified_engine_v1" + policy_version: str = ACQUISITION_POLICY_VERSION mapping_probe_maximum_rad: float = 0.0 automatic_rescan_limit: int = 1 minimum_valid_samples: int = 40 @@ -274,8 +305,16 @@ class AcquisitionPolicy: physical_first_cycle_minimum_span_01: float = 0.85 physical_repeat_minimum_fraction: float = 0.90 stall_timeout_seconds: float = 2.0 + feedback_stale_seconds: float = 1.0 + fixed_reference_minimum_frames: int = 10 fixed_reference_maximum_drift_px: float = 5.0 fixed_reference_confirmation_frames: int = 10 + require_endpoint_observations: bool = False + endpoint_tolerance_01: float = 2.0 / 255.0 + steady_training_nodes: int = 9 + steady_minimum_samples: int = 3 + steady_window_seconds: float = 0.2 + steady_timeout_seconds: float = 2.0 @dataclass(frozen=True) @@ -315,12 +354,20 @@ class CalibrationProfile: scope: ScopePolicy artifacts: ArtifactPolicy acquisition: AcquisitionPolicy = field(default_factory=AcquisitionPolicy) + sdk_adapter: str = "legacy_byte_sdk" + urdf_authorized_fields: Mapping[str, frozenset[str]] = field( + default_factory=dict + ) # Per-URDF-joint provenance used by partial calibration artifacts. # Known values are: measured_static_dynamic, measured_dynamic_cad_static, # transferred_static_dynamic, transferred_dynamic_cad_static, cad_nominal, # and mimic_nominal. joint_coverage: Mapping[str, str] = field(default_factory=dict) + @property + def curve_input_domain(self) -> str: + return self.measurement.input_domain or f"feedback_{self.command.unit}" + class ProfileValidationError(ValueError): """Raised before hardware startup when a profile is internally unsafe.""" @@ -337,6 +384,12 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append("command names must be unique") if command.unit not in {"u8", "rad"}: errors.append("command unit must be u8 or rad") + if profile.curve_input_domain not in {f"feedback_{command.unit}", f"command_{command.unit}"}: + errors.append("curve input must explicitly identify native command or feedback coordinates") + if (len(command.joint_directions) != command.command_count + or any(value not in {-1, 1} for value in command.joint_directions) + or (command.unit == "rad" and not command.sdk_to_joint_direction)): + errors.append("native SDK directions must explicitly bind every physical-angle channel") if command.unit == "u8" and any(value < 0 or value > 255 for value in baseline): errors.append("baseline command values must be in [0, 255]") if ( @@ -363,8 +416,6 @@ def validate_profile(profile: CalibrationProfile) -> None: not math.isfinite(feedback_lower) or not math.isfinite(feedback_upper) or feedback_lower >= feedback_upper - or feedback_lower > command_lower - or feedback_upper < command_upper for feedback_lower, feedback_upper, command_lower, command_upper in zip( command.minimum_feedback_values, command.maximum_feedback_values, @@ -373,9 +424,12 @@ def validate_profile(profile: CalibrationProfile) -> None: ) ): errors.append( - "feedback bounds must be finite and contain the command domain" + "feedback bounds must be finite and ordered in feedback coordinates" ) indices = set(range(command.command_count)) + if command.maximum_velocity and (len(command.maximum_velocity) != command.command_count + or any(not math.isfinite(v) or v <= 0 for v in command.maximum_velocity)): + errors.append("per-channel velocity limits must be finite, positive and complete") if not set(command.disabled_indices).issubset(indices): errors.append("disabled command index is out of range") if any(index not in indices for index in command.command_index_by_joint.values()): @@ -406,6 +460,8 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append("extrinsic reference view is not declared") tag_ids = [tag.tag_id for view in profile.vision.views for tag in view.tags] tag_roles = [tag.role for view in profile.vision.views for tag in view.tags] + if any(not math.isfinite(tag.size_m) or tag.size_m <= 0 for view in profile.vision.views for tag in view.tags): + errors.append("Tag side lengths must be finite positive meters") if len(set(tag_ids)) != len(tag_ids): errors.append("Tag IDs must be unique across views") if len(set(tag_roles)) != len(tag_roles): @@ -416,6 +472,24 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append("at least one fixed reference Tag is required") task_keys = [task.key for task in profile.motion.tasks] + reference_keys = set() + for waypoint in profile.motion.resume_verification_waypoints: + if not waypoint.key or waypoint.key in reference_keys or "/" in waypoint.key: + errors.append("reference waypoint keys must be nonempty, unique and contain no slash") + reference_keys.add(waypoint.key) + if len(waypoint.command) != command.command_count or any( + not math.isfinite(v) or not lo <= v <= hi for v, lo, hi in + zip(waypoint.command, command.minimum_values, command.maximum_values)): + errors.append("reference waypoint command outside declared domain") + if any(i < len(waypoint.command) and waypoint.command[i] != command.baseline_values[i] + for i in command.disabled_indices): + errors.append("reference waypoint must keep disabled channels at baseline") + if not waypoint.tag_ids_by_view or any(view not in view_names or not ids or not set(ids) <= { + tag.tag_id for rig_view in profile.vision.views if rig_view.name == view for tag in rig_view.tags + if not tag.fixed_reference} for view, ids in waypoint.tag_ids_by_view.items()): + errors.append("reference waypoint must name observed moving Tags in their declared views") + if not profile.measurement.candidate_selection_tasks <= set(task_keys): + errors.append("candidate selection references an unknown motion task") if not task_keys or len(set(task_keys)) != len(task_keys): errors.append("motion task keys must be non-empty and unique") measurement_names = set(profile.measurement.measurements) @@ -424,6 +498,7 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append(f"task {task.key} uses an unknown view") if task.command_index not in indices: errors.append(f"task {task.key} command index is out of range") + continue if not task.joints or not set(task.joints).issubset(measurement_names): errors.append(f"task {task.key} references unknown measurements") if any(index not in indices for index, _ in task.auxiliary_commands): @@ -439,11 +514,21 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append(f"task {task.key} sweep endpoint is out of range") if task.start_value == task.end_value: errors.append(f"task {task.key} sweep endpoints must differ") + grouped = [index for group in task.preparation_groups for index in group] + if any(not group for group in task.preparation_groups) or len(grouped) != len(set(grouped)) or not set(grouped) <= indices: + errors.append(f"task {task.key} preparation groups must contain unique valid channels") + if any(task.command_index in group for group in task.preparation_groups[:-1]): + errors.append(f"task {task.key} measured channel must move after avoidance channels") + for waypoint in task.entry_waypoints: + if not waypoint or len({i for i, _ in waypoint}) != len(waypoint) or any( + i not in indices or i in command.disabled_indices or not math.isfinite(v) + or not command.minimum_values[i] <= v <= command.maximum_values[i] for i, v in waypoint): + errors.append(f"task {task.key} has an invalid entry waypoint") for speed in (task.preflight_speed_u8, task.formal_speed_u8): if speed is not None and not 0 <= speed <= 255: errors.append(f"task {task.key} speed is out of range") if command.unit == "rad" and ( - task.formal_speed is None or float(task.formal_speed) <= 0.0 + task.formal_speed is None or not math.isfinite(float(task.formal_speed)) or float(task.formal_speed) <= 0.0 ): errors.append(f"task {task.key} physical speed must be positive") for name, spec in profile.measurement.measurements.items(): @@ -451,9 +536,16 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append(f"measurement mapping key differs for {name}") if spec.view is not None and spec.view not in view_names: errors.append(f"measurement {name} uses an unknown view") + if spec.view is not None: + roles = {tag.role for view in profile.vision.views if view.name == spec.view for tag in view.tags} + if spec.parent_role not in roles or spec.child_role not in roles or spec.parent_role == spec.child_role: + errors.append(f"measurement {name} must reference distinct parent/child Tags in its view") for primary, validation in profile.measurement.cross_view_sources.items(): if primary not in measurement_names or validation not in measurement_names: errors.append("cross-view measurement source is unknown") + return_indices = [index for group in profile.motion.return_groups for index in group] + if any(not group for group in profile.motion.return_groups) or len(return_indices) != len(set(return_indices)) or not set(return_indices) <= indices: + errors.append("return groups must contain unique valid channels") zero = profile.zero if zero.active_joints & zero.passive_joints: @@ -486,6 +578,12 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append("endpoint anchor policy is unsupported") if not zero.fitted_mimic_joints.issubset(zero.passive_joints): errors.append("fitted mimic targets must be passive joints") + for transfers, targets, donors, label in ( + (zero.transferred_zero_sources, zero.active_joints, set(zero.direct_zero_joints), "zero"), + (zero.transferred_mimic_sources, zero.passive_joints, zero.fitted_mimic_joints, "mimic"), + ): + if not set(transfers) <= targets or not set(transfers.values()) <= donors or any(k == v for k, v in transfers.items()): + errors.append(f"{label} transfer requires distinct known recipients and measured donors") if not zero.fitted_mimic_joints.issubset(zero.mimic_source_by_joint): errors.append("fitted mimic target has no source mapping") if not set(zero.coupling_model_by_joint).issubset( @@ -525,8 +623,22 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append(f"{label} filename must not contain a directory") if not profile.namespace.startswith("/"): errors.append("runtime namespace must be absolute") + if not str(profile.sdk_adapter).strip(): + errors.append("SDK adapter name must be non-empty") + valid_urdf_fields = { + "origin.rpy", "limit.lower", "limit.upper", + "mimic.multiplier", "mimic.offset", + } + if not set().union(*profile.urdf_authorized_fields.values(), set()).issubset( + valid_urdf_fields + ): + errors.append("URDF authorization contains an unsupported field") acquisition = profile.acquisition - if acquisition.policy_version != "unified_engine_v1": + if acquisition.steady_training_nodes < 3 or acquisition.steady_minimum_samples < 3: + errors.append("steady mapping requires at least three nodes and three images per node") + if not 0 < acquisition.steady_window_seconds < acquisition.steady_timeout_seconds <= 2.0: + errors.append("steady acquisition must have a finite stability window and <=2 second wait") + if acquisition.policy_version != ACQUISITION_POLICY_VERSION: errors.append("unsupported acquisition policy version") if acquisition.automatic_rescan_limit != 1: errors.append("unified engine requires exactly one automatic rescan") @@ -538,6 +650,19 @@ def validate_profile(profile: CalibrationProfile) -> None: errors.append("physical-angle profiles require a <=3 degree mapping probe") if command.unit == "u8" and acquisition.mapping_probe_maximum_rad != 0.0: errors.append("legacy profiles must not request a radian mapping probe") + if acquisition.feedback_stale_seconds <= 0.0: + errors.append("feedback stale timeout must be positive") + if not all(math.isfinite(float(value)) and float(value) > 0 for value in ( + acquisition.feedback_stale_seconds, acquisition.stall_timeout_seconds, + acquisition.fixed_reference_maximum_drift_px, + )): + errors.append("safety timeouts and reference drift must be finite and positive") + if not 0 <= acquisition.endpoint_tolerance_01 < 0.5: + errors.append("endpoint observation tolerance must be in [0, 0.5)") + if acquisition.fixed_reference_minimum_frames < 10: + errors.append("fixed reference locking requires at least ten frames") + if acquisition.fixed_reference_confirmation_frames < 10: + errors.append("fixed reference movement requires ten confirming frames") if profile.joint_coverage: valid_coverage = { "measured_static_dynamic", diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/result.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/result.py new file mode 100644 index 0000000..d49772f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/result.py @@ -0,0 +1,97 @@ +"""Frozen numerical results, independent of a serializer or SDK transport.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import math +from typing import TYPE_CHECKING, Mapping, Sequence + +import numpy as np + +if TYPE_CHECKING: + from .measurement import JointCurveFit + from ..fitting.coupling import RelativeMimicEvidence + from ..fitting.spatial import ZeroSolveResult + + +@dataclass(frozen=True) +class CalibrationResult: + """Training-frozen measurements and parameters consumed by every writer. + + This is not a publication certificate: the final serialized URDF must + still pass independent spatial replay and structural authorization. + """ + + curves: Mapping[str, JointCurveFit] + output_mappings: Mapping[str, JointMapping] + zero_offsets_rad: Mapping[str, float] + zero_method_by_joint: Mapping[str, str] + standard_mimic_evidence: Mapping[str, RelativeMimicEvidence] + holdout_errors_rad: Mapping[str, tuple[float, ...]] + spatial_zero: ZeroSolveResult + cross_view_metrics: Mapping = field(default_factory=dict) + reference_inputs: Mapping[str, float] = field(default_factory=dict) + command_mappings: Mapping[str, JointMapping] = field(default_factory=dict) + command_holdout_metrics: Mapping = field(default_factory=dict) + command_applicability: Mapping = field(default_factory=dict) + + +@dataclass(frozen=True) +class JointMapping: + """SDK input -> output URDF coordinate; branches use raw SDK direction. + + Coordinate/sign choices belong to fitting. Writers may round numbers but + must not rebase, reverse, clip or refit this result. + """ + + joint: str + motor_index: int + input_domain: str + knots: tuple[float, ...] + angle_rad: tuple[float, ...] + increasing_rad: tuple[float, ...] + decreasing_rad: tuple[float, ...] + transferred_from: str | None = None + + def __post_init__(self) -> None: + if not self.joint or self.motor_index < 0: + raise ValueError("mapping requires a joint and a valid SDK channel") + if self.input_domain not in {"feedback_rad", "command_rad", "feedback_u8", "command_u8"}: + raise ValueError("mapping requires an explicit SDK input domain") + x = np.asarray(self.knots, dtype=float) + if x.ndim != 1 or x.size < 2 or not np.all(np.isfinite(x)) or np.any(np.diff(x) <= 0): + raise ValueError("mapping knots must be finite and strictly increasing") + for branch in (self.angle_rad, self.increasing_rad, self.decreasing_rad): + y = np.asarray(branch, dtype=float) + if y.shape != x.shape or not np.all(np.isfinite(y)): + raise ValueError("mapping curve must align with its native knots") + d = np.diff(y) + if not (np.all(d >= -1e-9) or np.all(d <= 1e-9)): + raise ValueError("mapping curve must be monotonic") + + @property + def bounds_rad(self) -> tuple[float, float]: + values = self.angle_rad + self.increasing_rad + self.decreasing_rad + return min(values), max(values) + + def evaluate(self, value: float, direction: str = "") -> float: + if direction not in {"", "increasing", "decreasing"}: + raise ValueError("invalid native SDK direction") + if not math.isfinite(value) or not self.knots[0] <= value <= self.knots[-1]: + raise ValueError(f"SDK input outside fitted support:{self.joint}:{value}") + branch = getattr(self, direction + "_rad") if direction else self.angle_rad + if self.input_domain.endswith("_u8"): + value = math.floor(value + 0.5) + return float(np.interp(value, self.knots, branch)) + + +def evaluate_mappings( + mappings: Mapping[str, JointMapping], sdk_values: Sequence[float], + directions: Mapping[str, str], +) -> dict[str, float]: + result = {} + for joint, mapping in mappings.items(): + if joint != mapping.joint or mapping.motor_index >= len(sdk_values): + raise ValueError("mapping/SDK vector contract differs") + result[joint] = mapping.evaluate(float(sdk_values[mapping.motor_index]), directions.get(joint, "")) + return result diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/sample.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/sample.py deleted file mode 100644 index fa00099..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/sample.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Normalized records shared by online evaluation and offline replay.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -import math -from typing import Any, Mapping - - -@dataclass(frozen=True) -class SampleRecord: - task_key: str - measurement: str - view: str - cycle: int - direction: str - command_u8: int - timestamp_ns: int - values: Mapping[str, Any] - quality: Mapping[str, float] = field(default_factory=dict) - command: float | None = None - command_unit: str = "u8" - progress_01: float | None = None - - def __post_init__(self) -> None: - if not self.task_key or not self.measurement or not self.view: - raise ValueError("sample task, measurement, and view are required") - if self.cycle < 0: - raise ValueError("sample cycle or command is out of range") - if self.command_unit == "u8" and not 0 <= self.command_u8 <= 255: - raise ValueError("sample cycle or command is out of range") - if self.command_unit not in {"u8", "rad"}: - raise ValueError("sample command unit must be u8 or rad") - if self.command is not None and not math.isfinite(float(self.command)): - raise ValueError("sample command must be finite") - if self.progress_01 is not None and not 0.0 <= float(self.progress_01) <= 1.0: - raise ValueError("sample progress must be in [0, 1]") - if self.timestamp_ns < 0: - raise ValueError("sample timestamp must be non-negative") diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py new file mode 100644 index 0000000..2b5cb66 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/status.py @@ -0,0 +1,82 @@ +"""Model-independent progress and failure status contracts.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Any, Mapping + + +@dataclass(frozen=True) +class TaskStatus: + key: str = "" + label: str = "" + cycle: int | None = None + cycle_count: int = 4 + direction: str = "" + required_tag_ids_by_view: Mapping[str, tuple[int, ...]] = field( + default_factory=dict + ) + + +@dataclass(frozen=True) +class MotionStatus: + channel: str = "" + command: float | None = None + feedback: float | None = None + unit: str = "" + speed: float | None = None + + +@dataclass(frozen=True) +class AcquisitionStatus: + valid_samples: int = 0 + minimum_samples: int = 40 + coverage_01: float = 0.0 + bins: int = 0 + maximum_gap: int = 0 + retry_count: int = 0 + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True) +class PauseStatus: + code: str = "" + reason: str = "" + suggestion: str = "" + details: Mapping[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class CalibrationStatus: + """Stable payload rendered by the one common product runner.""" + + schema_version: int = 1 + state: str = "WAIT_DEVICE" + phase: str = "" + overall_progress_01: float = 0.0 + estimated_remaining_seconds: float | None = None + reference_locked: bool = False + reference_message: str = "" + task: TaskStatus = field(default_factory=TaskStatus) + recognized_tag_ids: tuple[int, ...] = () + missing_tag_ids: tuple[int, ...] = () + rejected_tag_ids: tuple[int, ...] = () + motion: MotionStatus = field(default_factory=MotionStatus) + acquisition: AcquisitionStatus = field(default_factory=AcquisitionStatus) + resume: Mapping[str, Any] = field(default_factory=dict) + hardware: Mapping[str, Any] = field(default_factory=dict) + pause: PauseStatus = field(default_factory=PauseStatus) + log_path: str = "" + outputs: Mapping[str, str] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + return asdict(self) + + +__all__ = [ + "AcquisitionStatus", + "CalibrationStatus", + "MotionStatus", + "PauseStatus", + "TaskStatus", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/__init__.py index cb2a4bb..2bdcb99 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/__init__.py @@ -1,5 +1,19 @@ """Pure curve and axis fitting.""" from .curve import FitResult, isotonic_nonincreasing +from .coupling import ( + CouplingFit, LinearMimicFit, PairedJointObservation, RelativeMimicEvidence, + curve_travel_rad, fit_coupling_model, fit_standard_mimic, fit_relative_standard_mimic, +) +from .rotation_curve import RotationCurve, RotationObservation, fit_rotation_curve -__all__ = ["FitResult", "isotonic_nonincreasing"] +__all__ = [ + "CouplingFit", + "FitResult", + "curve_travel_rad", + "fit_coupling_model", + "isotonic_nonincreasing", + "LinearMimicFit", "PairedJointObservation", "RelativeMimicEvidence", + "fit_standard_mimic", "fit_relative_standard_mimic", + "RotationCurve", "RotationObservation", "fit_rotation_curve", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/circle_geometry.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/circle_geometry.py new file mode 100644 index 0000000..a3951b3 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/circle_geometry.py @@ -0,0 +1,144 @@ +"""Generic circular statistics and robust image-plane circle fitting.""" +from __future__ import annotations +import math +from typing import Sequence +import numpy as np +from scipy.optimize import least_squares + +def wrap_angle_rad(angle_rad: float) -> float: + """Wrap one finite angle to [-pi, pi).""" + angle = float(angle_rad) + if not math.isfinite(angle): + raise ValueError("angle must be finite") + return (angle + math.pi) % (2.0 * math.pi) - math.pi + + +def signed_angle_difference_rad(angle_rad: float, reference_rad: float) -> float: + """Return the wrapped signed rotation from reference to angle.""" + return wrap_angle_rad(float(angle_rad) - float(reference_rad)) + + +def circular_mean_rad(angles_rad: Sequence[float]) -> float: + """Return the circular mean of at least one finite angle.""" + angles = np.asarray(angles_rad, dtype=float) + if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)): + raise ValueError("angles_rad must contain finite angles") + vector = complex( + float(np.mean(np.cos(angles))), + float(np.mean(np.sin(angles))), + ) + if abs(vector) < 1.0e-12: + raise ValueError("angles do not have a unique circular mean") + return wrap_angle_rad(math.atan2(vector.imag, vector.real)) + + +def circular_median_rad(angles_rad: Sequence[float]) -> float: + """Return a robust circular median around the circular mean.""" + angles = np.asarray(angles_rad, dtype=float) + reference = circular_mean_rad(angles) + deltas = np.asarray( + [signed_angle_difference_rad(value, reference) for value in angles], + dtype=float, + ) + return wrap_angle_rad(reference + float(np.median(deltas))) + + +def circular_std_rad(angles_rad: Sequence[float]) -> float: + """Return circular standard deviation in radians.""" + angles = np.asarray(angles_rad, dtype=float) + if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)): + raise ValueError("angles_rad must contain finite angles") + resultant = float( + math.hypot( + float(np.mean(np.cos(angles))), + float(np.mean(np.sin(angles))), + ) + ) + resultant = min(1.0, max(1.0e-15, resultant)) + return math.sqrt(max(0.0, -2.0 * math.log(resultant))) + + +def maximum_pairwise_angle_difference_rad( + angles_rad: Sequence[float], +) -> float: + """Return the largest wrapped distance between any two angles.""" + angles = [float(value) for value in angles_rad] + if not angles or not all(math.isfinite(value) for value in angles): + raise ValueError("angles_rad must contain finite angles") + return max( + ( + abs(signed_angle_difference_rad(left, right)) + for index, left in enumerate(angles) + for right in angles[index + 1:] + ), + default=0.0, + ) + + +def _fit_circle(points_xy: np.ndarray) -> tuple[np.ndarray, float]: + """Fit a geometric circle with robust nonlinear least squares.""" + points = np.asarray(points_xy, dtype=float) + if points.ndim != 2 or points.shape[1] != 2 or len(points) < 3: + raise ValueError("circle fit requires at least three 2D points") + origin = np.mean(points, axis=0) + centered = points - origin + matrix = np.column_stack( + (2.0 * centered[:, 0], 2.0 * centered[:, 1], np.ones(len(points))) + ) + rhs = np.sum(np.square(centered), axis=1) + solution, _, rank, _ = np.linalg.lstsq(matrix, rhs, rcond=None) + if rank < 3: + raise ValueError("trajectory points are degenerate") + centre0 = solution[:2] + origin + radius0_squared = ( + float(solution[2]) + float(np.dot(solution[:2], solution[:2])) + ) + if radius0_squared <= 0.0: + raise ValueError("trajectory circle radius is invalid") + radius0 = math.sqrt(radius0_squared) + + def residual(parameters: np.ndarray) -> np.ndarray: + centre = parameters[:2] + radius = parameters[2] + return np.linalg.norm(points - centre, axis=1) - radius + + initial_error = residual(np.asarray([*centre0, radius0], dtype=float)) + robust_scale = max( + 0.25, + 1.4826 + * float( + np.median( + np.abs(initial_error - float(np.median(initial_error))) + ) + ), + ) + result = least_squares( + residual, + np.asarray([*centre0, radius0], dtype=float), + loss="soft_l1", + f_scale=robust_scale, + max_nfev=1000, + ) + centre = np.asarray(result.x[:2], dtype=float) + radius = float(abs(result.x[2])) + if not result.success or not np.all(np.isfinite(centre)): + raise ValueError("trajectory circle optimization failed") + if not math.isfinite(radius) or radius <= 0.0: + raise ValueError("trajectory circle radius is invalid") + return centre, radius + + +def _trajectory_arc_rad(points_xy: np.ndarray, centre_xy: np.ndarray) -> float: + angles = np.mod( + np.arctan2( + points_xy[:, 1] - centre_xy[1], + points_xy[:, 0] - centre_xy[0], + ), + 2.0 * math.pi, + ) + if len(angles) < 2: + return 0.0 + angles = np.sort(angles) + gaps = np.diff(np.r_[angles, angles[0] + 2.0 * math.pi]) + return float(2.0 * math.pi - np.max(gaps)) + diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_mapping.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_mapping.py new file mode 100644 index 0000000..88c8000 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/command_mapping.py @@ -0,0 +1,98 @@ +"""Steady command calibration against independently observed Tag angles. + +Feedback is NOT a command label and is never the target of this regression. +The visual reference/axis was frozen by the continuous training acquisition. +""" + +from dataclasses import replace +import math +import numpy as np + +from ..domain.result import JointMapping +from ..urdf.acceptance import angular_metrics +from .curve import isotonic_nonincreasing +from .spatial import measure_joint_curve_observation + + +def fit_command_mappings(profile, fit, records_by_joint): + mappings, metrics, applicability = {}, {}, {} + for joint in sorted(fit.output_mappings): + donor = profile.zero.transferred_zero_sources.get(joint, joint) + rows = records_by_joint.get(donor, ()) + if not rows or any(r.get("sample_phase") != "steady" for r in rows): + raise ValueError(f"steady_command_observations_missing:{joint}") + curve = fit.curves[donor] + gauge = float(np.interp(fit.reference_inputs[donor], curve.circle["input_knots"], curve.angle_rad)) + # Byte curves already carry their shared coordinate reference. Native + # curves retain the arbitrary observed Tag reference in fit.curves. + if profile.command.unit == "u8": + gauge = 0.0 + observed = {r["sample_id"]: measure_joint_curve_observation(curve, + quaternion_xyzw=r.get("relative_quaternion_xyzw"), + image_relative_xy_px=r.get("image_relative_xy_px"))-gauge for r in rows} + if len(observed) != len(rows): + raise ValueError("duplicate steady visual image") + channel = fit.output_mappings[joint].motor_index + source_channel = fit.output_mappings[donor].motor_index + branches = {} + task = next(t for t in profile.motion.tasks if donor in t.joints) + lo, hi = sorted((task.start_value, task.end_value)) + train_knots = np.linspace(lo, hi, profile.acquisition.steady_training_nodes) + if profile.command.unit == "u8": + train_knots = np.floor(train_knots+0.5) + knots = tuple(sorted(set(float(v) for v in train_knots))) + sign = profile.command.joint_directions[source_channel] + for direction in ("increasing", "decreasing"): + values = [] + for knot in knots: + medians = [] + for cycle in (0, 1, 2): + selected = [r for r in rows if r["cycle"] == cycle and r["direction"] == direction + and math.isclose(float(r["steady_target"]), knot, abs_tol=1e-9)] + if len(selected) < profile.acquisition.steady_minimum_samples: + raise ValueError(f"steady_training_incomplete:{joint}:{cycle}:{direction}:{knot}") + for r in selected: + if not math.isclose(float(r[f"command_{profile.command.unit}"]), knot, abs_tol=1e-9): + raise ValueError("steady observation captured during command motion") + medians.append(float(np.median([observed[r["sample_id"]] for r in selected]))) + values.append(float(np.median(medians))) + values = np.asarray(values) + projected = -sign*isotonic_nonincreasing(-sign*values) + if np.max(np.abs(projected-values)) > math.radians(1): + raise ValueError(f"steady_command_not_monotonic:{joint}:{direction}") + branches[direction] = tuple(float(v) for v in projected) + mean = tuple((a+b)/2 for a, b in zip(branches["increasing"], branches["decreasing"])) + mapping = JointMapping(joint, channel, f"command_{profile.command.unit}", knots, + mean, branches["increasing"], branches["decreasing"], donor if donor != joint else None) + validation = [r for r in rows if r["cycle"] == 3] + expected = np.r_[lo, (np.asarray(knots[:-1])+knots[1:])/2, hi] + if profile.command.unit == "u8": + # Acquisition interleaves the unrounded grid, then encodes bytes. + grid = np.linspace(lo, hi, profile.acquisition.steady_training_nodes) + expected = np.floor(np.r_[lo, (grid[:-1]+grid[1:])/2, hi]+0.5) + for direction in ("increasing", "decreasing"): + for knot in set(expected): + if sum(r["direction"] == direction and math.isclose(float(r["steady_target"]), knot, abs_tol=1e-9) + for r in validation) < profile.acquisition.steady_minimum_samples: + raise ValueError(f"steady_holdout_incomplete:{joint}:{direction}:{knot}") + errors = [mapping.evaluate(float(r[f"command_{profile.command.unit}"]), r["direction"]) + - observed[r["sample_id"]] for r in validation] + quality = angular_metrics(errors) + if not quality.passed: + raise ValueError(f"steady_command_holdout_failed:{joint}:{quality}") + mappings[joint] = mapping + from dataclasses import asdict + metrics[joint] = {**asdict(quality), "independently_measured": donor == joint, + "source_joint": donor, "training_cycles": [0, 1, 2], "holdout_cycle": 3} + training = [r for r in rows if r["cycle"] in {0, 1, 2}] + vectors = np.asarray([r[f"command_vector_{profile.command.unit}"] for r in training]) + held = {str(i): float(np.median(vectors[:, i])) for i in range(profile.command.command_count) + if i != source_channel} + for i, value in held.items(): + if np.max(np.abs(vectors[:, int(i)]-value)) > 1e-8: + raise ValueError(f"scan_changed_multiple_command_channels:{donor}:{i}") + applicability[joint] = {"task": task.key, "held_command_values": held, + "scope": "single_channel_at_declared_pose", "arbitrary_multiaxis_validated": False, + "branch_initialization": "full_range_endpoint_reset"} + return replace(fit, command_mappings=mappings, command_holdout_metrics=metrics, + command_applicability=applicability) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/coupling.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/coupling.py new file mode 100644 index 0000000..ea1816f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/coupling.py @@ -0,0 +1,347 @@ +"""Model-independent active/passive joint coupling fitting.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Protocol, Sequence + +import numpy as np +from scipy.optimize import least_squares + + +class DirectionalCurve(Protocol): + decreasing_rad: Sequence[float] + increasing_rad: Sequence[float] + + +@dataclass(frozen=True) +class LinearMimicFit: + """The one relationship that a standard URDF can actually serialize.""" + + source_joint: str + target_joint: str + multiplier: float + offset_rad: float + offset_observed: bool + training_cycles: tuple[int, ...] + residuals_rad: tuple[float, ...] + + +@dataclass(frozen=True) +class PairedJointObservation: + """Two independent visual angles from the same image and frozen mounts.""" + + sample_id: str + cycle: int + direction: str + source_rad: float + target_rad: float + + +@dataclass(frozen=True) +class RelativeMimicEvidence: + fit: LinearMimicFit + # Nuisance phase between the two frozen visual references, NOT a URDF zero. + installation_phase_rad: float + training_sample_ids: tuple[str, ...] + holdout_sample_ids: tuple[str, ...] + holdout_errors_rad: tuple[float, ...] + + +def fit_relative_standard_mimic( + source_joint: str, target_joint: str, + observations: Sequence[PairedJointObservation], *, cad_offset_rad: float, +) -> RelativeMimicEvidence: + """Fit a standard linear mimic from paired visual motion, not SDK priors. + + Unknown mounting angles make the absolute passive intercept unobservable. + A training-only nuisance phase aligns the *visual references*, while the + published CAD intercept is preserved. It cannot be re-estimated on holdout + or used as an origin/mimic correction. Both directions share one slope. + """ + rows = tuple(observations) + identities = [row.sample_id for row in rows] + if any(not value for value in identities) or len(set(identities)) != len(identities): + raise ValueError("paired mimic image identities are missing or duplicated") + if any(row.cycle not in (0, 1, 2, 3) or row.direction not in {"increasing", "decreasing"} + or not math.isfinite(row.source_rad) or not math.isfinite(row.target_rad) + for row in rows): + raise ValueError("invalid paired mimic observation") + for cycle in range(4): + for direction in ("increasing", "decreasing"): + if sum(row.cycle == cycle and row.direction == direction for row in rows) < 40: + raise ValueError(f"missing paired mimic observations: {target_joint}:{cycle}:{direction}") + training = tuple(row for row in rows if row.cycle != 3) + holdout = tuple(row for row in rows if row.cycle == 3) + relative_fit = fit_standard_mimic(source_joint, target_joint, + [row.source_rad for row in training], [row.target_rad for row in training], + cycles=[row.cycle for row in training], offset_rad=None, offset_observable=True) + errors = tuple(relative_fit.multiplier * row.source_rad + relative_fit.offset_rad - row.target_rad + for row in holdout) + # Final-file FK remains a separate mandatory gate; this is the prerequisite + # that a single standard mimic can even explain the independent motion. + for label, residuals in (("training", relative_fit.residuals_rad), ("holdout", errors)): + degrees = np.rad2deg(np.abs(residuals)) + if np.mean(degrees) > 1 or np.percentile(degrees, 95) > 2 or np.max(degrees) > 3: + raise ValueError( + f"standard_urdf_mimic_not_expressive:joint={target_joint}:phase={label}:" + f"mae_deg={np.mean(degrees):.6f}:p95_deg={np.percentile(degrees, 95):.6f}:" + f"maximum_deg={np.max(degrees):.6f}; 标准 URDF 线性 mimic 表达能力不足或观测不一致" + ) + if not math.isfinite(cad_offset_rad): + raise ValueError("passive CAD offset must be finite") + fit = LinearMimicFit(source_joint, target_joint, relative_fit.multiplier, + float(cad_offset_rad), False, (0, 1, 2), relative_fit.residuals_rad) + return RelativeMimicEvidence(fit, relative_fit.offset_rad, + tuple(row.sample_id for row in training), tuple(row.sample_id for row in holdout), errors) + + +def fit_standard_mimic( + source_joint: str, + target_joint: str, + active_rad: Sequence[float], + passive_rad: Sequence[float], + *, + cycles: Sequence[int], + offset_rad: float | None, + offset_observable: bool = False, +) -> LinearMimicFit: + """Fit actual synchronous observations, never a vendor-generated curve. + + A static offset is fixed to the CAD/coordinate-conversion value unless an + independent datum makes it observable. Unknown tag mounting cannot justify + fitting an intercept. Numerical fitting is training-only; the serialized + URDF and independent fourth cycle decide acceptance afterwards. + """ + x, y = np.asarray(active_rad, dtype=float), np.asarray(passive_rad, dtype=float) + identifiers = np.asarray(cycles) + if x.ndim != 1 or x.shape != y.shape or x.shape != identifiers.shape or x.size < 40: + raise ValueError("mimic fitting requires at least 40 aligned observations") + if not np.all(np.isfinite(x)) or not np.all(np.isfinite(y)): + raise ValueError("mimic observations must be finite") + if not np.all(np.isin(identifiers, (0, 1, 2))) or set(identifiers.tolist()) != {0, 1, 2}: + raise ValueError("mimic fitting requires three training cycles; holdout is forbidden") + if not source_joint or not target_joint or source_joint == target_joint: + raise ValueError("mimic source and target must be distinct") + if float(np.ptp(x)) <= math.radians(1.0): + raise ValueError("mimic source travel is not observable") + if offset_rad is None: + if not offset_observable: + raise ValueError("unobservable passive static offset must retain CAD") + design, rhs = np.column_stack((x, np.ones_like(x))), y + else: + if not math.isfinite(float(offset_rad)): + raise ValueError("fixed mimic offset must be finite") + design, rhs = x[:, None], y - float(offset_rad) + initial, _, rank, _ = np.linalg.lstsq(design, rhs, rcond=None) + if rank != design.shape[1]: + raise ValueError("mimic parameters are not independently observable") + residual = rhs - design @ initial + scale = max(math.radians(0.1), 1.4826 * float(np.median(np.abs(residual - np.median(residual))))) + solution = least_squares(lambda parameters: design @ parameters - rhs, + initial, loss="soft_l1", f_scale=scale) + if not solution.success or not np.all(np.isfinite(solution.x)): + raise ValueError("linear mimic fit failed") + multiplier = float(solution.x[0]) + offset = float(solution.x[1]) if offset_rad is None else float(offset_rad) + return LinearMimicFit(source_joint, target_joint, multiplier, offset, + offset_rad is None, (0, 1, 2), tuple(float(v) for v in (multiplier*x + offset - y))) + + +@dataclass(frozen=True) +class CouplingFit: + source_joint: str + target_joint: str + model: str + coefficients: tuple[float, ...] + urdf_mimic_multiplier: float + urdf_mimic_policy: str + cycle_coefficients: tuple[tuple[float, ...], ...] + maximum_cycle_range: float + maximum_cycle_prediction_range_rad: float + residual_rms_rad: float + residual_p95_rad: float + residual_max_rad: float + + @property + def multiplier(self) -> float: + return float(self.coefficients[0]) + + @property + def cycle_multipliers(self) -> tuple[float, ...]: + return tuple(float(values[0]) for values in self.cycle_coefficients) + + @property + def mujoco_polycoef(self) -> tuple[float, ...]: + return (0.0, *self.coefficients, *(0.0,) * (5 - len(self.coefficients))) + + +def curve_travel_rad(fit: DirectionalCurve) -> float: + """Return the mean travel of the decreasing and increasing curves.""" + decreasing = np.asarray(fit.decreasing_rad, dtype=float) + increasing = np.asarray(fit.increasing_rad, dtype=float) + if decreasing.size < 2 or increasing.size < 2: + raise ValueError("directional curves require at least two knots") + travel = 0.5 * ( + float(decreasing[0] - decreasing[-1]) + + float(increasing[0] - increasing[-1]) + ) + if not math.isfinite(travel) or travel <= 0.0: + raise ValueError("fitted travel must be finite and positive") + return travel + + +def _coupling_regression( + active: Sequence[float], passive: Sequence[float], *, degree: int +) -> tuple[tuple[float, ...], np.ndarray]: + x = np.asarray(active, dtype=float) + y = np.asarray(passive, dtype=float) + if x.shape != y.shape or x.ndim != 1 or x.size < 16: + raise ValueError("coupling curves must be aligned finite vectors") + if not np.all(np.isfinite(x)) or not np.all(np.isfinite(y)): + raise ValueError("coupling curves must be finite") + if degree not in {1, 2}: + raise ValueError("coupling degree must be one or two") + if float(x @ x) <= 1.0e-9: + raise ValueError("active coupling source has insufficient travel") + design = np.column_stack([x ** power for power in range(1, degree + 1)]) + initial = np.linalg.lstsq(design, y, rcond=None)[0] + scale = max( + math.radians(0.25), + float(np.median(np.abs(y - design @ initial))), + ) + fitted = least_squares( + lambda value: y - design @ value, + np.asarray(initial, dtype=float), + loss="soft_l1", + f_scale=scale, + ) + if not fitted.success or not np.all(np.isfinite(fitted.x)): + raise ValueError("robust through-origin coupling regression failed") + coefficients = tuple(float(value) for value in fitted.x) + grid = np.linspace(0.0, float(np.max(x)), 256) + derivative = np.full_like(grid, coefficients[0]) + if degree == 2: + derivative += 2.0 * coefficients[1] * grid + if float(np.min(derivative)) < -1.0e-7: + raise ValueError("coupling model is not monotonic") + return coefficients, y - design @ fitted.x + + +def fit_coupling_model( + source_joint: str, + target_joint: str, + active_fit: DirectionalCurve, + passive_fit: DirectionalCurve, + *, + model: str, + cycle_curve_pairs: Sequence[ + tuple[Sequence[float], Sequence[float]] + ] = (), + minimum_multiplier: float = 0.5, + maximum_multiplier: float = 1.5, + maximum_cycle_range: float = 0.03, + maximum_cycle_prediction_range_rad: float = math.radians(1.0), + maximum_residual_p95_rad: float = math.radians(2.0), + maximum_residual_rad: float = math.radians(3.0), +) -> CouplingFit: + """Fit a profile-selected coupling primitive with release-grade metrics.""" + supported = {"linear_mimic", "quadratic_runtime", "direction_aware_knots"} + if model not in supported: + raise ValueError(f"unsupported coupling model: {model}") + source_travel = curve_travel_rad(active_fit) + target_travel = curve_travel_rad(passive_fit) + endpoint_multiplier = float(target_travel / source_travel) + if model == "direction_aware_knots": + if not minimum_multiplier <= endpoint_multiplier <= maximum_multiplier: + raise ValueError( + f"{target_joint} coupling endpoint ratio is outside " + f"[{minimum_multiplier}, {maximum_multiplier}]" + ) + return CouplingFit( + source_joint, target_joint, model, (endpoint_multiplier,), + endpoint_multiplier, "endpoint_linear_fallback", (), + 0.0, 0.0, 0.0, 0.0, 0.0, + ) + + degree = 1 if model == "linear_mimic" else 2 + active = np.concatenate(( + np.asarray(active_fit.decreasing_rad, dtype=float), + np.asarray(active_fit.increasing_rad, dtype=float), + )) + passive = np.concatenate(( + np.asarray(passive_fit.decreasing_rad, dtype=float), + np.asarray(passive_fit.increasing_rad, dtype=float), + )) + coefficients, residual = _coupling_regression(active, passive, degree=degree) + multiplier = coefficients[0] + if not minimum_multiplier <= multiplier <= maximum_multiplier: + raise ValueError( + f"{target_joint} coupling linear term is outside " + f"[{minimum_multiplier}, {maximum_multiplier}]" + ) + cycle_coefficients = tuple( + _coupling_regression(source, target, degree=degree)[0] + for source, target in cycle_curve_pairs + ) + cycle_multipliers = tuple(values[0] for values in cycle_coefficients) + cycle_range = ( + 0.0 if len(cycle_multipliers) < 2 + else float(max(cycle_multipliers) - min(cycle_multipliers)) + ) + if cycle_range > maximum_cycle_range: + raise ValueError( + f"{target_joint} coupling linear-term cycle range exceeds " + f"{maximum_cycle_range}" + ) + cycle_prediction_range = 0.0 + if len(cycle_coefficients) >= 2: + grid = np.linspace(0.0, float(np.max(active)), 256) + predictions = np.asarray([ + sum(value * grid ** (index + 1) for index, value in enumerate(values)) + for values in cycle_coefficients + ]) + cycle_prediction_range = float(np.max(np.ptp(predictions, axis=0))) + if cycle_prediction_range > maximum_cycle_prediction_range_rad: + raise ValueError( + f"{target_joint} coupling cycle prediction range exceeds limit" + ) + absolute = np.abs(residual) + rms = float(np.sqrt(np.mean(np.square(residual)))) + p95 = float(np.percentile(absolute, 95.0)) + maximum = float(np.max(absolute)) + if p95 > maximum_residual_p95_rad or maximum > maximum_residual_rad: + raise ValueError( + "coupling_residual_exceeds:" + f"joint={target_joint}:model={model}:" + f"linear_term={multiplier:.6f}:" + f"p95_deg={math.degrees(p95):.3f}:" + f"maximum_deg={math.degrees(maximum):.3f}:" + f"p95_limit_deg={math.degrees(maximum_residual_p95_rad):.3f}:" + f"maximum_limit_deg={math.degrees(maximum_residual_rad):.3f}" + ) + return CouplingFit( + source_joint=source_joint, + target_joint=target_joint, + model=model, + coefficients=coefficients, + urdf_mimic_multiplier=( + multiplier if model == "linear_mimic" else endpoint_multiplier + ), + urdf_mimic_policy=( + "exact_linear" if model == "linear_mimic" + else "endpoint_linear_fallback" + ), + cycle_coefficients=cycle_coefficients, + maximum_cycle_range=cycle_range, + maximum_cycle_prediction_range_rad=cycle_prediction_range, + residual_rms_rad=rms, + residual_p95_rad=p95, + residual_max_rad=maximum, + ) + + +__all__ = ["CouplingFit", "DirectionalCurve", "curve_travel_rad", "fit_coupling_model", + "LinearMimicFit", "fit_standard_mimic", "PairedJointObservation", + "RelativeMimicEvidence", "fit_relative_standard_mimic"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/image_curve.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/image_curve.py new file mode 100644 index 0000000..3175618 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/image_curve.py @@ -0,0 +1,74 @@ +"""Training-frozen angular phase of an observed image-plane circle. + +Only use this primitive where the Profile declares the projected circle +observable. Final standard-URDF SE(3) replay remains mandatory: a good image +circle alone cannot certify physical angles in an oblique projection. +""" + +import math + +import numpy as np + +from ..domain.measurement import JointCurveFit +from .circle_geometry import _fit_circle, _trajectory_arc_rad +from .trajectory_geometry import _fit_joint_curve + + +def image_phase(point, circle): + point, centre, reference = (np.asarray(value, dtype=float) for value in + (point, circle["center_xy_px"], circle["reference_xy_px"])) + vector = point - centre + if point.shape != (2,) or not np.all(np.isfinite(point)) or np.linalg.norm(vector) < 1e-9: + raise ValueError("invalid image trajectory observation") + return float(circle["orientation_sign"]) * math.atan2( + float(reference[0]*vector[1] - reference[1]*vector[0]), float(reference @ vector)) + + +def fit_joint_image_curve(records, *, zero_command_u8=255, + canonical_zero_direction="decreasing", maximum_radial_rms_px=2., + maximum_radial_p95_px=3.5, minimum_radius_px=20., minimum_arc_rad=math.radians(15)): + samples = list(records) + if not samples or any(int(row["cycle"]) not in {0, 1, 2} for row in samples): + raise ValueError("image curve requires training observations only") + points = np.asarray([row["image_relative_xy_px"] for row in samples], dtype=float) + if points.shape != (len(samples), 2) or not np.all(np.isfinite(points)): + raise ValueError("image curve requires finite two-dimensional observations") + centre, radius = _fit_circle(points) + radial = np.linalg.norm(points-centre, axis=1)-radius + quality = {"radial_rms_px": float(np.sqrt(np.mean(radial**2))), + "radial_p95_px": float(np.percentile(np.abs(radial), 95)), + "radius_px": radius, "arc_rad": float(_trajectory_arc_rad(points, centre))} + if (quality["radial_rms_px"] > maximum_radial_rms_px + or quality["radial_p95_px"] > maximum_radial_p95_px + or radius < minimum_radius_px or quality["arc_rad"] < minimum_arc_rad): + raise ValueError("image_trajectory_not_observable") + # One observed reference for all training directions/cycles. Choose it + # near nominal baseline, then interpolate a single coordinate gauge. + indices = [index for index, row in enumerate(samples) + if canonical_zero_direction is None or row["direction"] == canonical_zero_direction] + if not indices: + raise ValueError("canonical image direction is missing") + index = min(indices, key=lambda i: abs(int(samples[i]["command_u8"])-zero_command_u8)) + reference = points[index]-centre + reference /= np.linalg.norm(reference) + circle = {"space": "image_2d", "center_xy_px": centre.tolist(), + "reference_xy_px": reference.tolist(), "orientation_sign": 1., "radius_px": radius, + "training_cycles": sorted({int(row["cycle"]) for row in samples}), + "input_domain": "feedback_u8", "input_knots": list(range(256)), + "zero_command_u8": int(zero_command_u8)} + values = np.asarray([image_phase(point, circle) for point in points]) + commands = np.asarray([int(row["command_u8"]) for row in samples]) + trend = float((commands-np.mean(commands)) @ (values-np.mean(values))) + if abs(trend) < 1e-12: + raise ValueError("image motion direction is not observable") + if trend > 0: + values = -values + circle["orientation_sign"] = -1. + curves, correction, hysteresis = _fit_joint_curve(samples, values, preserve_direction_offset=True) + reference_angle = float(curves["angle_rad" if canonical_zero_direction is None else canonical_zero_direction+"_rad"][int(zero_command_u8)]) + turn = reference_angle / circle["orientation_sign"] + rotation = np.array([[math.cos(turn), -math.sin(turn)], [math.sin(turn), math.cos(turn)]]) + circle["reference_xy_px"] = (rotation @ reference).tolist() + curves = {name: tuple(float(value-reference_angle) for value in values) for name, values in curves.items()} + return JointCurveFit(curves["angle_rad"], curves["decreasing_rad"], curves["increasing_rad"], + circle, correction, hysteresis, quality) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/motion_fit.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/motion_fit.py new file mode 100644 index 0000000..77213e8 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/motion_fit.py @@ -0,0 +1,121 @@ +"""Profile-selected native measurement primitives and coordinate gauges. + +The curve reference is only a coordinate choice. CAD zero is solved later +from independently observed spatial axes; neither SDK zero nor an endpoint +is treated as a mechanical datum. +""" + +from dataclasses import dataclass, replace +from typing import Mapping + +import numpy as np + +from ..domain.measurement import JointCurveFit +from ..urdf.acceptance import angular_metrics +from .coupling import fit_relative_standard_mimic +from .observed_motion import ( + compare_native_views, fit_byte_observed_motion, fit_native_observed_rotation, + paired_visual_observations, +) + + +@dataclass(frozen=True) +class MotionFit: + observed_curves: Mapping[str, JointCurveFit] + coordinate_curves: Mapping[str, JointCurveFit] + holdout_errors_rad: Mapping[str, tuple[float, ...]] + mimic_evidence: Mapping + cross_view_metrics: Mapping + reference_inputs: Mapping[str, float] + + +def channel_for_joint(profile, joint): + seen = set() + while joint not in profile.command.command_index_by_joint: + if joint in seen or joint not in profile.zero.mimic_source_by_joint: + raise ValueError(f"measured joint has no SDK source:{joint}") + seen.add(joint) + joint = profile.zero.mimic_source_by_joint[joint] + return profile.command.command_index_by_joint[joint] + + +def joint_input_direction(profile, source_model, joint): + """A passive measurement follows its declared CAD mimic chain's sign.""" + sign, current, seen = 1, joint, set() + while current not in profile.command.command_index_by_joint: + if current in seen: + raise ValueError("cyclic mimic input binding") + seen.add(current) + cad = source_model.joints[current] + if cad.mimic_joint != profile.zero.mimic_source_by_joint.get(current) or cad.mimic_multiplier == 0: + raise ValueError(f"invalid measured mimic input binding:{current}") + sign *= 1 if cad.mimic_multiplier > 0 else -1 + current = cad.mimic_joint + return sign * profile.command.joint_directions[profile.command.command_index_by_joint[current]] + + +def native_coordinate_curve(curve, baseline): + """Choose one training-supported gauge, never extend a fitted domain. + + Both directional branches subtract the SAME reference, retaining actual + backlash. If nominal baseline lies outside observed support, the nearest + measured input is the reference and is explicitly recorded as such. + """ + inputs = curve.circle["input_knots"] + reference_input = float(np.clip(baseline, inputs[0], inputs[-1])) + reference_angle = float(np.interp(reference_input, inputs, curve.angle_rad)) + branches = {name: tuple(float(value - reference_angle) for value in getattr(curve, name)) + for name in ("angle_rad", "increasing_rad", "decreasing_rad")} + return replace(curve, **branches, circle={**curve.circle, + "coordinate_reference_input": reference_input, + "coordinate_reference_angle_rad": reference_angle}), reference_input + + +def fit_profile_motion(profile, source_model, records_by_joint, *, cross_view_records=None): + expected = {name for name, spec in profile.measurement.measurements.items() + if spec.view is not None and spec.kind != "axis_cross_view_validation"} + if set(records_by_joint) != expected: + raise ValueError("fitting observations differ from the Profile") + if any(profile.measurement.measurements[name].kind not in {"relative_rotation", "urdf_axis_chain", "curve"} for name in expected): + raise ValueError("measurement primitive is not implemented in this pipeline") + mimic_sources = {name: profile.zero.mimic_source_by_joint[name] for name in profile.zero.fitted_mimic_joints} + if profile.command.unit == "u8": + observed, holdout, evidence = fit_byte_observed_motion(records_by_joint, + source_model=source_model, mimic_sources=mimic_sources, + baseline_by_joint={name: profile.command.baseline_values[channel_for_joint(profile, name)] for name in expected}, + direction_by_joint={name: joint_input_direction(profile, source_model, name) for name in expected}, + image_curve_joints=profile.measurement.image_curve_joints, input_domain=profile.curve_input_domain) + coordinates = observed + references = {name: float(profile.command.baseline_values[channel_for_joint(profile, name)]) + for name in observed} + else: + observed, coordinates, holdout, evidence, references = {}, {}, {}, {}, {} + for name, rows in sorted(records_by_joint.items()): + curve, errors = fit_native_observed_rotation(rows, input_domain=profile.curve_input_domain, + input_to_joint_direction=joint_input_direction(profile, source_model, name)) + observed[name], holdout[name] = curve, errors + coordinates[name], references[name] = native_coordinate_curve(curve, + profile.command.baseline_values[channel_for_joint(profile, name)]) + for target, source in mimic_sources.items(): + cad = source_model.joints[target] + if cad.mimic_joint != source: + raise ValueError(f"mimic source differs from CAD:{target}") + evidence[target] = fit_relative_standard_mimic(source, target, + paired_visual_observations(records_by_joint[source], records_by_joint[target], + observed[source], observed[target]), cad_offset_rad=cad.mimic_offset) + secondary = cross_view_records or {} + if set(secondary) != set(profile.measurement.cross_view_sources): + raise ValueError("required cross-view observations are incomplete") + metrics = {} + for name, rows in secondary.items(): + curve, errors = fit_native_observed_rotation(rows, input_domain=profile.curve_input_domain, + input_to_joint_direction=joint_input_direction(profile, source_model, name)) + if "input_knots" not in observed[name].circle: + raise ValueError("cross-view comparison requires native input knots") + comparison = compare_native_views(observed[name], curve) + if comparison.get("direction_disagrees", False): + raise ValueError(f"cross-view motion direction disagrees:{name}") + quality = angular_metrics(errors) + metrics[name] = {**comparison, "holdout_mae_deg": quality.mae_deg, + "holdout_p95_deg": quality.p95_deg, "holdout_max_deg": quality.maximum_deg} + return MotionFit(observed, coordinates, holdout, evidence, metrics, references) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/observed_motion.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/observed_motion.py new file mode 100644 index 0000000..0e25ddf --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/observed_motion.py @@ -0,0 +1,151 @@ +"""Shared visual motion fitting and independently paired standard mimics.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from .coupling import PairedJointObservation, fit_relative_standard_mimic +from .spatial import fit_rotation_joint_curve, measure_joint_curve_observation, joint_curve_holdout_errors +from .image_curve import fit_joint_image_curve +from ..urdf.acceptance import angular_metrics +from ..domain.measurement import JointCurveFit +from .rotation_curve import RotationObservation, fit_rotation_curve + + +def image_identity(row: Mapping[str, Any]) -> str: + identity = str(row.get("sample_id", "")) + if not identity and row.get("view") and row.get("image_stamp_ns") is not None: + identity = f"{row['view']}:{int(row['image_stamp_ns'])}" + if not identity: + raise ValueError("visual fitting requires recorded image identity") + return identity + + +def paired_visual_observations(source_rows, target_rows, source_fit, target_fit): + def indexed(rows): + output = {} + for row in rows: + identity = image_identity(row) + if identity in output: + raise ValueError("duplicated paired visual image identity") + output[identity] = row + return output + source, target = indexed(source_rows), indexed(target_rows) + output = [] + for identity in sorted(source.keys() & target.keys()): + a, b = source[identity], target[identity] + if (a["cycle"], a["direction"]) != (b["cycle"], b["direction"]): + raise ValueError("paired visual image has different cycle/direction") + output.append(PairedJointObservation(identity, int(a["cycle"]), str(a["direction"]), + measure_joint_curve_observation(source_fit, quaternion_xyzw=a.get("relative_quaternion_xyzw"), + image_relative_xy_px=a.get("image_relative_xy_px")), + measure_joint_curve_observation(target_fit, quaternion_xyzw=b.get("relative_quaternion_xyzw"), + image_relative_xy_px=b.get("image_relative_xy_px")))) + return output + + +def fit_native_observed_rotation(rows, *, input_domain, input_to_joint_direction): + """Native-coordinate 3+1 hinge primitive with the shared geometric result. + + The metadata carries real knots, not a synthetic byte command domain. + Scalar angle zero is only a fixed Tag reference, never a CAD datum. + """ + import numpy as np + observations = [RotationObservation(image_identity(row), float(row[input_domain]), + tuple(row["relative_quaternion_xyzw"]), int(row["cycle"]), str(row["direction"])) + for row in rows] + for direction in ("increasing", "decreasing"): + if sum(row.cycle == 3 and row.direction == direction for row in observations) < 40: + raise ValueError("holdout requires 40 images in each direction") + fitted = fit_rotation_curve([row for row in observations if row.cycle != 3], + input_domain=input_domain, sdk_to_joint_direction=input_to_joint_direction) + errors = fitted.holdout_errors([row for row in observations if row.cycle == 3]) + metrics = angular_metrics(errors) + if not metrics.passed: + raise ValueError("independent_rotation_holdout_failed") + hysteresis = float(np.max(np.abs(np.asarray(fitted.increasing_rad) - fitted.decreasing_rad))) + curve = JointCurveFit(fitted.angle_rad, fitted.decreasing_rad, fitted.increasing_rad, + {"space": "relative_rotation_3d", "input_domain": input_domain, "input_knots": fitted.knots, + "reference_quaternion_xyzw": fitted.reference_xyzw, "axis_xyz": fitted.axis_xyz, + "training_cycles": [0, 1, 2], "training_sample_ids": sorted(fitted.training_sample_ids)}, + fitted.maximum_monotonic_correction_rad, hysteresis, {"sample_count": float(len(observations)), + "holdout_mae_deg": metrics.mae_deg, "holdout_p95_deg": metrics.p95_deg, + "holdout_max_deg": metrics.maximum_deg}) + return curve, errors + + +def compare_native_views(primary, secondary): + """Diagnostic comparison in their common physical input interval. + + Neither curve is replaced or recalibrated from the other view. Absolute + mount phase differs between Tags, so centre each frozen branch for this + diagnostic alone; final file FK validates the actual fixed mounts. + """ + import numpy as np + a, b = primary.circle["input_knots"], secondary.circle["input_knots"] + lower, upper = max(a[0], b[0]), min(a[-1], b[-1]) + if upper <= lower: + raise ValueError("cross-view native input supports do not overlap") + inputs = np.linspace(lower, upper, 65) + left = np.interp(inputs, a, primary.angle_rad) + right = np.interp(inputs, b, secondary.angle_rad) + if (left[-1] - left[0]) * (right[-1] - right[0]) <= 0: + return {"direction_disagrees": 1.0} + result = {"travel_difference_rad": abs(float(np.ptp(left) - np.ptp(right)))} + for field in ("angle_rad", "increasing_rad", "decreasing_rad"): + x = np.interp(inputs, a, getattr(primary, field)) + y = np.interp(inputs, b, getattr(secondary, field)) + delta = (x - np.mean(x)) - (y - np.mean(y)) + result[f"{field}_rms_difference_rad"] = float(np.sqrt(np.mean(delta ** 2))) + return result + + +def fit_byte_observed_motion(records_by_joint, *, source_model, mimic_sources, + baseline_by_joint=None, image_curve_joints=(), input_domain="feedback_u8", direction_by_joint=None): + """3+1 frozen-reference byte-domain hinge fit; no per-cycle re-zeroing.""" + curves, errors = {}, {} + for joint, rows in sorted(records_by_joint.items()): + # The retained byte primitive calls its abscissa command_u8. Adapt a + # PRIVATE numerical input, never overwrite the captured command. + rows = [dict(row, command_u8=int(round(float(row[input_domain])))) for row in rows] + ids = [image_identity(row) for row in rows] + if len(set(ids)) != len(ids): + raise ValueError(f"duplicate visual fitting image:{joint}") + for cycle in range(4): + for direction in ("increasing", "decreasing"): + if sum(int(row["cycle"]) == cycle and row["direction"] == direction for row in rows) < 40: + raise ValueError(f"missing bidirectional visual samples:{joint}:{cycle}:{direction}") + training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}] + holdout = [row for row in rows if int(row["cycle"]) == 3] + baseline = int((baseline_by_joint or {}).get(joint, 255)) + primitive = fit_joint_image_curve if joint in image_curve_joints else fit_rotation_joint_curve + curve = primitive(training, zero_command_u8=baseline, canonical_zero_direction=None) + from dataclasses import replace + sign = int((direction_by_joint or {}).get(joint, -1)) + if sign not in {-1, 1}: + raise ValueError("byte SDK direction must be explicitly signed") + if sign == 1: + circle = dict(curve.circle) + if circle["space"] == "image_2d": + circle["orientation_sign"] = -float(circle["orientation_sign"]) + else: + circle["axis_xyz"] = [-float(v) for v in circle["axis_xyz"]] + # The legacy primitive chooses an arbitrary axis orientation with + # a negative input slope. Reorient BOTH the observation axis and + # every branch to the declared SDK direction, never just the table. + curve = replace(curve, circle=circle, **{field: tuple(-v for v in getattr(curve, field)) + for field in ("angle_rad", "increasing_rad", "decreasing_rad")}) + curve = replace(curve, circle={**curve.circle, "input_domain": input_domain, "input_knots": tuple(range(256))}) + residuals = joint_curve_holdout_errors(curve, holdout, zero_command_u8=baseline) + if not angular_metrics(residuals).passed: + raise ValueError(f"independent_rotation_holdout_failed:{joint}") + curves[joint], errors[joint] = curve, residuals + evidence = {} + for target, source in mimic_sources.items(): + cad = source_model.joints[target] + if cad.mimic_joint != source: + raise ValueError(f"mimic source differs from CAD:{target}") + evidence[target] = fit_relative_standard_mimic(source, target, + paired_visual_observations(records_by_joint[source], records_by_joint[target], curves[source], curves[target]), + cad_offset_rad=cad.mimic_offset) + return curves, errors, evidence diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/rotation_curve.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/rotation_curve.py new file mode 100644 index 0000000..621a2e3 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/rotation_curve.py @@ -0,0 +1,159 @@ +"""Native-domain hinge measurements with a single training-frozen Tag frame. + +This primitive measures *relative* motion. Its arbitrary reference angle is +not a CAD zero; spatial calibration must supply that separate datum. Neither +holdout observations nor SDK endpoints are allowed to set that datum. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Sequence + +import numpy as np +from scipy.spatial.transform import Rotation + +from .curve import isotonic_nonincreasing + + +@dataclass(frozen=True) +class RotationObservation: + sample_id: str + input_value: float + quaternion_xyzw: tuple[float, float, float, float] + cycle: int + direction: str + + +@dataclass(frozen=True) +class RotationCurve: + input_domain: str + knots: tuple[float, ...] + increasing_rad: tuple[float, ...] + decreasing_rad: tuple[float, ...] + reference_xyzw: tuple[float, float, float, float] + axis_xyz: tuple[float, float, float] + sdk_to_joint_direction: int + training_sample_ids: frozenset[str] + maximum_monotonic_correction_rad: float = 0.0 + + @property + def angle_rad(self) -> tuple[float, ...]: + return tuple((a + b) / 2 for a, b in zip(self.increasing_rad, self.decreasing_rad)) + + def predict(self, value: float, direction: str) -> float: + if direction not in {"increasing", "decreasing"}: + raise ValueError("curve direction must be a native SDK direction") + if not math.isfinite(value) or not self.knots[0] <= value <= self.knots[-1]: + raise ValueError("input outside measured curve domain") + return float(np.interp(value, self.knots, getattr(self, direction + "_rad"))) + + def measure(self, quaternion_xyzw: Sequence[float]) -> tuple[float, float]: + relative = Rotation.from_quat(self.reference_xyzw).inv() * _rotation(quaternion_xyzw) + vector = relative.as_rotvec() + axis = np.asarray(self.axis_xyz) + angle = float(vector @ axis) + predicted = Rotation.from_rotvec(axis * angle) + off_axis_error = float((predicted.inv() * relative).magnitude()) + return angle, off_axis_error + + def holdout_errors(self, observations: Sequence[RotationObservation]) -> tuple[float, ...]: + _validate_observations(observations, cycles={3}) + if any(row.sample_id in self.training_sample_ids for row in observations): + raise ValueError("holdout image identity overlaps training") + reference = Rotation.from_quat(self.reference_xyzw) + axis = np.asarray(self.axis_xyz) + # Full SO(3) residual, not just its projection onto the fitted axis: + # a slipped/tilted Tag cannot pass by retaining a plausible scalar angle. + return tuple(float(((reference * Rotation.from_rotvec( + axis * self.predict(row.input_value, row.direction) + )).inv() * _rotation(row.quaternion_xyzw)).magnitude()) for row in observations) + + +def _rotation(quaternion: Sequence[float]) -> Rotation: + values = np.asarray(quaternion, dtype=float) + if values.shape != (4,) or not np.all(np.isfinite(values)) or np.linalg.norm(values) < 1e-12: + raise ValueError("Tag quaternion must be finite and nondegenerate") + return Rotation.from_quat(values) + + +def _validate_observations(rows: Sequence[RotationObservation], *, cycles: set[int]) -> None: + if not rows: + raise ValueError("rotation observations are missing") + ids = [row.sample_id for row in rows] + if not all(ids) or len(set(ids)) != len(ids): + raise ValueError("rotation image identities must be nonempty and unique") + for row in rows: + if row.cycle not in cycles or row.direction not in {"increasing", "decreasing"}: + raise ValueError("rotation cycle/direction is outside the requested data partition") + if not math.isfinite(row.input_value): + raise ValueError("rotation input must be finite") + _rotation(row.quaternion_xyzw) + + +def fit_rotation_curve( + observations: Sequence[RotationObservation], *, input_domain: str, + sdk_to_joint_direction: int, knot_count: int = 65, +) -> RotationCurve: + """Fit three complete training cycles without integerizing physical inputs. + + A fixed reference and axis are learned once. The two branches keep their + measured hysteresis; per-cycle/per-direction recentering is forbidden. + The resulting support is the common observed native-input interval. + """ + if input_domain not in {"feedback_rad", "command_rad", "feedback_u8", "command_u8"}: + raise ValueError("an explicit native SDK input domain is required") + if sdk_to_joint_direction not in {-1, 1} or knot_count < 32: + raise ValueError("invalid direction or curve knot count") + _validate_observations(observations, cycles={0, 1, 2}) + required = {(cycle, direction) for cycle in range(3) for direction in ("increasing", "decreasing")} + groups = {(row.cycle, row.direction) for row in observations} + if groups != required: + raise ValueError("three complete bidirectional training cycles are required") + for cycle, direction in required: + if sum(row.cycle == cycle and row.direction == direction for row in observations) < 40: + raise ValueError("each training direction requires 40 unique observations") + inputs = np.asarray([row.input_value for row in observations]) + if input_domain.endswith("u8") and (np.min(inputs) < 0 or np.max(inputs) > 255): + raise ValueError("byte observations are outside the SDK domain") + rotations = Rotation.from_quat([row.quaternion_xyzw for row in observations]) + # Use one observed reference, never assert that its input is a CAD zero. + reference = rotations[int(np.argmin(inputs))] + vectors = (reference.inv() * rotations).as_rotvec() + _, singular, vh = np.linalg.svd(vectors, full_matrices=False) + if not singular.size or singular[0] < math.radians(1): + raise ValueError("hinge rotation is not observable") + axis = vh[0] + angles = vectors @ axis + trend = float((inputs - np.mean(inputs)) @ (angles - np.mean(angles))) + if abs(trend) <= 1e-12: + raise ValueError("SDK/visual motion direction is not observable") + if math.copysign(1, trend) != sdk_to_joint_direction: + axis, angles = -axis, -angles + if np.ptp(angles) >= math.pi - math.radians(1): + raise ValueError("hinge travel exceeds this rotation primitive's unambiguous interval") + supports = [] + for direction in ("increasing", "decreasing"): + selected = [i for i, row in enumerate(observations) if row.direction == direction] + x = inputs[selected] + supports.append((float(np.min(x)), float(np.max(x)))) + lower, upper = max(s[0] for s in supports), min(s[1] for s in supports) + if upper <= lower: + raise ValueError("directional SDK supports do not overlap") + knots = np.linspace(lower, upper, knot_count) + branches = {} + maximum_correction = 0.0 + for direction in ("increasing", "decreasing"): + mask = np.asarray([row.direction == direction for row in observations]) + x, y = inputs[mask], angles[mask] + order = np.argsort(x, kind="stable") + unique, starts = np.unique(x[order], return_index=True) + # Median repeated encoder observations before projecting monotonically. + medians = np.asarray([np.median(group) for group in np.split(y[order], starts[1:])]) + projected = -sdk_to_joint_direction * isotonic_nonincreasing(-sdk_to_joint_direction * medians) + maximum_correction = max(maximum_correction, float(np.max(np.abs(projected - medians)))) + branches[direction] = tuple(float(v) for v in np.interp(knots, unique, projected)) + return RotationCurve(input_domain, tuple(float(v) for v in knots), + branches["increasing"], branches["decreasing"], tuple(reference.as_quat()), + tuple(axis), sdk_to_joint_direction, frozenset(row.sample_id for row in observations), maximum_correction) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py new file mode 100644 index 0000000..9d4c50d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/session.py @@ -0,0 +1,155 @@ +"""Profile-selected geometry/curve/mimic fitting, with no model imports.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import math +from pathlib import Path +from typing import Mapping + +from ..domain.profile import CalibrationProfile +from ..domain.result import CalibrationResult, JointMapping +from ..urdf.kinematics import UrdfKinematicModel +from .motion_fit import channel_for_joint, fit_profile_motion, joint_input_direction +from .spatial import ZeroCalibrationProfile, fit_joint_axis_measurement, solve_urdf_zero_offsets, with_depth_free_axis_projection + + +@dataclass(frozen=True) +class SpatialHandContract: + side: str + layout_id: str + active_joints: tuple[str, ...] + reference_finger: str = "" + palm_orientation_sources: Mapping[str, str] = field(default_factory=dict) + minimum_palm_orientation_sources: int = 0 + stable_cross_view_cone_bias: bool = False + + +def compile_spatial_profile(profile: CalibrationProfile) -> ZeroCalibrationProfile: + data = profile.zero.spatial + allowed = {"root_anchor_joints", "axis_parent_joint", "phase_parent_joint", + "offset_observer_joint", "base_pose_strategy", "orientation_anchor_joint", + "directed_base_axis_joints", "depth_free_axis_projection", "axis_order", + "same_view_axis_pair_by_offset", + "parallel_root_pattern", + "accept_validated_zero_in_confidence_interval", "project_axis_gauge_before_image"} + if not data or set(data) - allowed: + raise ValueError("spatial observation graph is missing or has unsupported fields") + direct = tuple(profile.zero.direct_zero_joints) + observers = dict(data.get("offset_observer_joint", {})) + if set(observers) != set(direct): + raise ValueError("every required zero must have an explicit observable axis/phase datum") + axes = tuple(data.get("axis_order", profile.zero.axis_joints)) + if set(axes) != set(profile.zero.axis_joints) or len(set(axes)) != len(axes): + raise ValueError("spatial axis order differs from declared observations") + return ZeroCalibrationProfile( + hand=SpatialHandContract(profile.key.side, profile.key.layout, tuple(profile.zero.active_joints), + stable_cross_view_cone_bias=profile.measurement.stable_cross_view_cone_bias), + direct_zero_joints=direct, axis_joints=axes, + inherited_zero_joints=dict(profile.zero.transferred_zero_sources), + inherited_static_zero_joints=dict(profile.zero.transferred_zero_sources), + constrained_circle_joints=frozenset(axes), + root_anchor_joints=frozenset(data.get("root_anchor_joints", ())), + axis_parent_joint=dict(data.get("axis_parent_joint", {})), + phase_parent_joint=dict(data.get("phase_parent_joint", {})), + offset_observer_joint=observers, + same_view_axis_pair_by_offset={name: tuple(pair) for name, pair in data.get("same_view_axis_pair_by_offset", {}).items()}, + fixed_direct_zero_offsets_rad={}, static_output_zero_offsets_rad={}, + base_pose_strategy=str(data.get("base_pose_strategy", "full_hand")), + orientation_anchor_joint=data.get("orientation_anchor_joint"), + directed_base_axis_joints=frozenset(data.get("directed_base_axis_joints", ())), + accept_validated_zero_in_confidence_interval=bool(data.get("accept_validated_zero_in_confidence_interval", False)), + project_axis_gauge_before_image=bool(data.get("project_axis_gauge_before_image", False)), + parallel_root_pattern=bool(data.get("parallel_root_pattern", False))) + + +def fit_profile_calibration(profile: CalibrationProfile, source_urdf: Path, records_by_joint, + *, cross_view_records=None) -> CalibrationResult: + """Shared native-domain motion, spatial zero and standard mimic fit. + + No mechanical endpoint or range-centre fallback can replace a requested + but unobservable spatial zero. The fourth cycle never updates the fit. + """ + if profile.zero.endpoint_anchor_by_joint: + raise ValueError("unverified mechanical endpoints cannot define CAD zeros") + required_pose_fields = {"parent_pose_common", "child_pose_common", + "relative_translation_xyz_m", "view_normal_common_xyz", + "camera_center_common_xyz_m", f"state_{profile.command.unit}"} + for joint in profile.zero.axis_joints: + rows = records_by_joint.get(joint, ()) + if not rows or any(not required_pose_fields.issubset(row) for row in rows): + raise ValueError(f"spatial calibration requires identified common-frame pose observations:{joint}") + model = UrdfKinematicModel(source_urdf) + zero_profile = compile_spatial_profile(profile) + motion = fit_profile_motion(profile, model, records_by_joint, cross_view_records=cross_view_records) + curves = motion.coordinate_curves + observations = [] + for cycle in range(4): + by_joint = {} + for joint in zero_profile.axis_joints: + rows = records_by_joint[joint] + if profile.command.unit == "rad": + rows = [dict(row, input_value=float(row[profile.curve_input_domain]), state_values=row["state_rad"]) + for row in rows] + else: + rows = [dict(row, command_u8=int(round(float(row[profile.curve_input_domain])))) for row in rows] + parent = zero_profile.phase_parent_joint.get(joint) + if parent is not None and parent not in by_joint: + raise ValueError("spatial axis order must place phase parents first") + task = next(task for task in profile.motion.tasks if joint in task.joints) + baseline = (task.start_value if profile.command.unit == "rad" + else profile.command.baseline_values[channel_for_joint(profile, joint)]) + axis = fit_joint_axis_measurement(joint, rows, cycle=cycle, zero_command_u8=baseline, + input_to_joint_direction=joint_input_direction(profile, model, joint), + constrained_circle_joints=zero_profile.constrained_circle_joints, + view_normal_common_xyz=rows[0]["view_normal_common_xyz"], + canonical_zero_direction="decreasing", + axis_common_constraint=None if parent is None else by_joint[parent].axis_common_xyz, + separate_axial_residual=True) + if profile.zero.spatial.get("depth_free_axis_projection", False): + axis = with_depth_free_axis_projection(axis, rows[0]["camera_center_common_xyz_m"]) + observations.append(axis) + by_joint[joint] = axis + zero = solve_urdf_zero_offsets(source_urdf=source_urdf, measurements=observations, + curves=curves, motor_by_joint={joint: channel_for_joint(profile, joint) for joint in curves}, + zero_profile=zero_profile, training_cycles=(0, 1, 2), validation_cycle=3, + maximum_offset_rad=math.radians(20), finger_maximum_offset_rad=math.radians(20), + maximum_validation_mae_rad=math.radians(1), maximum_validation_p95_rad=math.radians(2), + maximum_validation_error_rad=math.radians(3), maximum_confidence_half_width_rad=math.radians(1), + maximum_pose_axis_line_rms_m=0.0015) + if not zero.passed: + raise ValueError("spatial_zero_not_observable_or_inaccurate:" + str(zero.failure_reasons)) + offsets = {name: float(zero.direct_offsets_rad[name]) for name in profile.zero.direct_zero_joints} + methods = {name: "urdf_serial_axis_geometry" for name in offsets} + for name in profile.zero.active_joints - offsets.keys() - profile.zero.transferred_zero_sources.keys(): + if name not in profile.zero.cad_frozen_joints: + raise ValueError(f"active static zero has no measured or CAD-retention policy:{name}") + offsets[name], methods[name] = 0.0, "source_cad_zero_profile_excluded" + for target, donor in profile.zero.transferred_zero_sources.items(): + offsets[target] = offsets[donor] + methods[target] = "transferred_static_zero_on_own_cad" + mappings = {} + for joint, channel in profile.command.command_index_by_joint.items(): + if joint not in profile.zero.active_joints: + continue + donor = profile.zero.transferred_zero_sources.get(joint, joint) + curve = curves[donor] + knots = tuple(curve.circle.get("input_knots", range(256))) + branches = (curve.angle_rad, curve.increasing_rad, curve.decreasing_rad) + if profile.command.unit == "u8": + # Legacy primitive tables may extend to 0/255. Publish only the + # intersection actually measured in both training directions. + import numpy as np + training = records_by_joint[donor] + support = [[float(r[profile.curve_input_domain]) for r in training + if r["cycle"] in {0, 1, 2} and r["direction"] == direction] + for direction in ("increasing", "decreasing")] + lo, hi = math.ceil(max(min(s) for s in support)), math.floor(min(max(s) for s in support)) + knots = tuple(float(v) for v in range(lo, hi+1)) + branches = tuple(tuple(float(v) for v in np.interp(knots, range(256), values)) for values in branches) + mappings[joint] = JointMapping(joint, channel, profile.curve_input_domain, knots, + *branches, + donor if donor != joint else None) + return CalibrationResult(motion.observed_curves, mappings, offsets, methods, + motion.mimic_evidence, motion.holdout_errors_rad, zero, + motion.cross_view_metrics, motion.reference_inputs) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial.py new file mode 100644 index 0000000..eb2f687 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial.py @@ -0,0 +1,47 @@ +"""Stable spatial API; numerical implementations live in spatial_solver.""" + +from .spatial_solver.types import ZeroCalibrationProfile as ZeroCalibrationProfile +from .spatial_solver.geometry_helpers import circle_direction_is_constrained as circle_direction_is_constrained +from .spatial_solver.geometry_helpers import select_cross_view_roll_direction_source as select_cross_view_roll_direction_source +from .spatial_solver.geometry_helpers import ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 as ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 +from .spatial_solver.geometry_helpers import AXIS_POINT_MINIMUM_ROTATION_RAD as AXIS_POINT_MINIMUM_ROTATION_RAD +from .spatial_solver.geometry_helpers import AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD as AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD +from .spatial_solver.geometry_helpers import AXIS_POINT_RESIDUAL_SCALE_M as AXIS_POINT_RESIDUAL_SCALE_M +from .spatial_solver.geometry_helpers import ZERO_MINIMUM_AXIS_CONE_RAD as ZERO_MINIMUM_AXIS_CONE_RAD +from .spatial_solver.geometry_helpers import _zero_sensitive_axis_error_rad as _zero_sensitive_axis_error_rad +from .spatial_solver.geometry_helpers import _axis_cone_mismatch_rad as _axis_cone_mismatch_rad +from .spatial_solver.geometry_helpers import _vector as _vector +from .spatial_solver.geometry_helpers import _pose_matrix as _pose_matrix +from .spatial_solver.geometry_helpers import _relative_rotation as _relative_rotation +from .spatial_solver.rotation_curves import _reference_group_key as _reference_group_key +from .spatial_solver.rotation_curves import _canonical_reference_records as _canonical_reference_records +from .spatial_solver.rotation_curves import _spatial_input as _spatial_input +from .spatial_solver.rotation_curves import _interpolate_reference_rotation as _interpolate_reference_rotation +from .spatial_solver.rotation_curves import _near_zero_records as _near_zero_records +from .spatial_solver.rotation_curves import _baseline_reference as _baseline_reference +from .spatial_solver.rotation_curves import baseline_hysteresis_by_cycle_rad as baseline_hysteresis_by_cycle_rad +from .spatial_solver.rotation_curves import fit_rotation_joint_curve as fit_rotation_joint_curve +from .spatial_solver.rotation_curves import measure_rotation_joint_observation as measure_rotation_joint_observation +from .spatial_solver.rotation_curves import measure_joint_curve_observation as measure_joint_curve_observation +from .spatial_solver.rotation_curves import rotation_curve_holdout_errors as rotation_curve_holdout_errors +from .spatial_solver.rotation_curves import joint_curve_holdout_errors as joint_curve_holdout_errors +from .spatial_solver.types import JointAxisMeasurement as JointAxisMeasurement +from .spatial_solver.types import PalmOrientationMeasurement as PalmOrientationMeasurement +from .spatial_solver.geometry_helpers import PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 as PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 +from .spatial_solver.geometry_helpers import PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD as PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD +from .spatial_solver.geometry_helpers import PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT as PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT +from .spatial_solver.geometry_helpers import PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE as PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE +from .spatial_solver.axis_observations import _incremental_common_rotation_axis as _incremental_common_rotation_axis +from .spatial_solver.axis_observations import fit_partial_palm_orientation_measurement as fit_partial_palm_orientation_measurement +from .spatial_solver.axis_observations import fit_partial_palm_orientation_measurements as fit_partial_palm_orientation_measurements +from .spatial_solver.axis_lines import with_depth_free_axis_projection as with_depth_free_axis_projection +from .spatial_solver.axis_lines import cross_view_side_line_source as cross_view_side_line_source +from .spatial_solver.axis_lines import axis_line_uses_depth_free_interpretation_plane as axis_line_uses_depth_free_interpretation_plane +from .spatial_solver.axis_lines import refit_axis_line_group_with_shared_radius as refit_axis_line_group_with_shared_radius +from .spatial_solver.axis_lines import maximum_axis_line_cycle_spread_m as maximum_axis_line_cycle_spread_m +from .spatial_solver.axis_lines import axis_line_cycle_rms_m as axis_line_cycle_rms_m +from .spatial_solver.axis_lines import _fit_axis_point_from_pose_trajectory as _fit_axis_point_from_pose_trajectory +from .spatial_solver.axis_observations import fit_joint_axis_measurement as fit_joint_axis_measurement +from .spatial_solver.types import ZeroSolveResult as ZeroSolveResult +from .spatial_solver.geometry_helpers import _angles_from_state as _angles_from_state +from .spatial_solver.solve import solve_urdf_zero_offsets as solve_urdf_zero_offsets diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/__init__.py new file mode 100644 index 0000000..d40bebb --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/__init__.py @@ -0,0 +1 @@ +"""Shared spatial mathematics; import the stable spatial facade for public APIs.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/acceptance.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/acceptance.py new file mode 100644 index 0000000..2fe9046 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/acceptance.py @@ -0,0 +1,310 @@ +"""Unchanged spatial acceptance gates and public result assembly.""" + +from __future__ import annotations + +import math + +import numpy as np + +from .stages import ( + AcceptanceDecision, + CycleEvidence, + GeometryEvidence, + HoldoutEvidence, + LineEvidence, + ObservabilityEvidence, + OffsetLimits, + TrainingFit, + ZeroProblem, + ZeroSolveOptions, +) +from .types import ZeroSolveResult + + +def decide_acceptance( + *, + cycles: CycleEvidence, + fit: TrainingFit, + geometry_evidence: GeometryEvidence, + holdout: HoldoutEvidence, + limits: OffsetLimits, + observability: ObservabilityEvidence, + options: ZeroSolveOptions, + problem: ZeroProblem, +) -> AcceptanceDecision: + applied_training = cycles.applied_training + confidence_half_widths = cycles.confidence_half_widths + cycle_consistent = cycles.cycle_consistent + diagnostic_offset_limits = limits.diagnostic_offset_limits + fixed_offsets = problem.fixed_offsets + improvement_confidence_lower = holdout.improvement_confidence_lower + improvement_passed = holdout.improvement_passed + inconsistent_cycles = cycles.inconsistent_cycles + insignificant_large = cycles.insignificant_large + maximum_confidence_half_width_rad = options.maximum_confidence_half_width_rad + maximum_observability_condition_number = options.maximum_observability_condition_number + maximum_offset_rad = options.maximum_offset_rad + maximum_validation_error_rad = options.maximum_validation_error_rad + maximum_validation_mae_rad = options.maximum_validation_mae_rad + maximum_validation_p95_rad = options.maximum_validation_p95_rad + observability_condition_number = observability.observability_condition_number + observability_parameter_count = observability.observability_parameter_count + observability_rank = observability.observability_rank + observation_failures = geometry_evidence.observation_failures + offset_limits = limits.offset_limits + output_offsets = problem.output_offsets + palm_orientation_validation_passed = holdout.palm_orientation_validation_passed + product_finger_rolls = limits.product_finger_rolls + profile = problem.profile + training_offsets = fit.training_offsets + validation_errors = holdout.validation_errors + + finger_roll_common_mode = ( + float( + np.median( + [training_offsets[name] for name in product_finger_rolls] + ) + ) + if profile.parallel_root_pattern + and product_finger_rolls + else 0.0 + ) + configured_limit_exceeded: list[str] = [] + for name, limit in zip(profile.direct_zero_joints, offset_limits): + if name in fixed_offsets: + continue + # A post-solve mechanical endpoint datum is the value that will be + # published for this joint. The visual root-axis scalar remains a + # nuisance gauge used for holdout geometry and must not be compared + # with the safety bound of a different, endpoint-anchored output. + checked_offset = output_offsets.get(name, training_offsets[name]) + if name in product_finger_rolls: + # The four roll motors share the same electrical centre and the + # absolute palm axial datum is recovered from the root-line + # pattern. Protect the independently assembled finger-to-finger + # deviations with the strict finger bound; protect their shared + # common mode with the unchanged global zero bound. Treating the + # same common datum as four independent failures is both + # over-counting and sensitive to the palm-frame gauge. + checked_offset -= finger_roll_common_mode + if abs(checked_offset) > limit + math.radians(0.01): + configured_limit_exceeded.append(name) + if ( + product_finger_rolls + and abs(finger_roll_common_mode) + > maximum_offset_rad + math.radians(0.01) + ): + configured_limit_exceeded.append("finger_mcp_roll_common_mode") + diagnostic_bound_hits: list[str] = [] + for name, limit in zip( + profile.direct_zero_joints, diagnostic_offset_limits + ): + if name in fixed_offsets: + continue + checked_offset = output_offsets.get(name, training_offsets[name]) + if name in product_finger_rolls: + # Match the configured-limit and publication convention above. + # The raw common roll is a fitted-palm-frame gauge; only the + # finger-to-finger deviation is a physical zero correction. + checked_offset -= finger_roll_common_mode + if abs(checked_offset) >= limit - math.radians(0.01): + diagnostic_bound_hits.append(name) + failure_reasons: dict[str, str] = {} + if not palm_orientation_validation_passed: + failure_reasons["palm_orientation"] = ( + "palm_orientation_holdout_too_large" + ) + requires_full_observability = bool( + profile.parallel_root_pattern + and profile.base_pose_strategy == "full_hand" + ) + if ( + requires_full_observability + and observability_rank < observability_parameter_count + ): + failure_reasons["palm_and_static_zero"] = ( + "zero_observation_jacobian_rank_deficient" + ) + elif ( + requires_full_observability + and observability_condition_number + > maximum_observability_condition_number + ): + failure_reasons["palm_and_static_zero"] = ( + "zero_observation_jacobian_ill_conditioned" + ) + for name in configured_limit_exceeded: + failure_reasons[name] = "zero_offset_exceeds_configured_limit" + for name in diagnostic_bound_hits: + failure_reasons[name] = "zero_offset_reached_diagnostic_bound" + for name in inconsistent_cycles: + failure_reasons[name] = "zero_offset_cycle_difference_too_large" + for name in insignificant_large: + failure_reasons[name] = "zero_offset_not_statistically_significant" + if maximum_confidence_half_width_rad is not None: + for name, half_width in confidence_half_widths.items(): + if half_width > maximum_confidence_half_width_rad: + failure_reasons[name] = ( + "zero_offset_confidence_interval_too_wide" + ) + # This solver publishes rotational encoder zeros only. A post-fit CAD to + # measured axis-line displacement is invariant to the joint's own zero + # and cannot be repaired by changing that rotational parameter. Keep the + # per-joint and aggregate values in ZeroSolveResult for geometry audit, + # but do not misclassify a fixed link-origin/Tag-depth discrepancy as a + # failed rotational holdout. Axis-point *fit* quality is still guarded + # above for every phase observation that actually uses line position. + if not improvement_passed: + for name, value in applied_training.items(): + if ( + name not in fixed_offsets + and value != 0.0 + and improvement_confidence_lower.get(name, 0.0) <= 0.0 + ): + failure_reasons[name] = "zero_offset_did_not_improve_with_95pct_confidence" + # Geometry failures are the root cause and must not be hidden by the + # downstream validation symptom produced by the same bad observation. + failure_reasons.update(observation_failures) + passed = bool( + validation_errors.size + == len(profile.direct_zero_joints) - len(fixed_offsets) + and float(np.mean(validation_errors)) <= maximum_validation_mae_rad + and float(np.percentile(validation_errors, 95.0)) + <= maximum_validation_p95_rad + and ( + maximum_validation_error_rad is None + or float(np.max(validation_errors)) + <= maximum_validation_error_rad + ) + and cycle_consistent + and not insignificant_large + and not configured_limit_exceeded + and not diagnostic_bound_hits + and not observation_failures + and ( + not requires_full_observability + or ( + observability_rank == observability_parameter_count + and observability_condition_number + <= maximum_observability_condition_number + ) + ) + and not any( + reason == "zero_offset_confidence_interval_too_wide" + for reason in failure_reasons.values() + ) + and improvement_passed + and palm_orientation_validation_passed + ) + + return AcceptanceDecision( + finger_roll_common_mode=finger_roll_common_mode, + failure_reasons=failure_reasons, + passed=passed, + ) + + +def assemble_result( + validation_cycle: int, + *, + cycles: CycleEvidence, + decision: AcceptanceDecision, + fit: TrainingFit, + geometry_evidence: GeometryEvidence, + holdout: HoldoutEvidence, + limits: OffsetLimits, + lines: LineEvidence, + observability: ObservabilityEvidence, + problem: ZeroProblem, +): + applied_training = cycles.applied_training + axis_cone_bias_classification_by_joint = geometry_evidence.axis_cone_bias_classification_by_joint + axis_cone_mismatch_by_joint = geometry_evidence.axis_cone_mismatch_by_joint + axis_line_rms = lines.axis_line_rms + base_rotation = fit.base_rotation + base_translation = fit.base_translation + confidence_half_widths = cycles.confidence_half_widths + cycle_values = cycles.cycle_values + failure_reasons = decision.failure_reasons + finger_roll_common_mode = decision.finger_roll_common_mode + improvement_by_joint = holdout.improvement_by_joint + improvement_confidence_lower = holdout.improvement_confidence_lower + observability_condition_number = observability.observability_condition_number + observability_parameter_count = observability.observability_parameter_count + observability_rank = observability.observability_rank + offset_covariance = observability.offset_covariance + original_error_by_joint = holdout.original_error_by_joint + output_offsets = problem.output_offsets + passed = decision.passed + product_finger_rolls = limits.product_finger_rolls + profile = problem.profile + training_cycle_ids = cycles.training_cycle_ids + uncertainties = cycles.uncertainties + validation_error_by_joint = holdout.validation_error_by_joint + validation_errors = holdout.validation_errors + validation_line_error_by_joint = lines.validation_line_error_by_joint + + # Never refit a model that has passed its holdout with the validation + # cycle. The published offsets are exactly the frozen training result + # that produced ``validation_errors`` above. + final_offsets = dict(applied_training) + # Apply independently validated assembly datums only after trajectory + # fitting and holdout validation. A mechanical prior must define the + # written artifact without perturbing downstream yaw/pitch estimates. + final_offsets.update(output_offsets) + if profile.parallel_root_pattern and product_finger_rolls: + # The camera solve observes the four roll axes in a fitted palm frame. + # Rotation of that frame about their shared datum is a gauge, not four + # independent finger assembly errors. The product command 127/CAD + # pose defines the common straight-ahead datum; publish only each + # finger's robust deviation from the four-finger median. Validation + # above remains in the observation gauge, so no measured residual is + # discarded. + for name in product_finger_rolls: + final_offsets[name] = ( + float(final_offsets[name]) - finger_roll_common_mode + ) + + # Every active joint must be present in the runtime payload/URDF writer, + # but absence of an absolute observation is not evidence for the + # reference finger's assembly offset. Preserve the source-CAD zero for + # those independent motors while continuing to share their dynamic curve. + all_offsets = { + name: 0.0 for name in profile.hand.active_joints + } + all_offsets.update(final_offsets) + for target, source in profile.inherited_static_zero_joints.items(): + all_offsets[target] = final_offsets[source] + return ZeroSolveResult( + direct_offsets_rad=final_offsets, + all_active_offsets_rad=all_offsets, + base_translation_xyz_m=tuple(float(value) for value in base_translation), + base_quaternion_xyzw=tuple(float(value) for value in base_rotation.as_quat()), + validation_errors_rad=tuple(float(value) for value in validation_errors), + validation_error_by_joint_rad=validation_error_by_joint, + validation_line_error_by_joint_m=validation_line_error_by_joint, + axis_line_rms_m=axis_line_rms, + passed=passed, + cycle_offsets_rad={ + name: tuple(float(value) for value in values) + for name, values in cycle_values.items() + }, + offset_uncertainty_rad=uncertainties, + offset_confidence_half_width_rad=confidence_half_widths, + training_cycles=training_cycle_ids, + validation_cycle=int(validation_cycle), + validation_original_error_by_joint_rad=original_error_by_joint, + validation_improvement_by_joint_rad=improvement_by_joint, + validation_improvement_confidence_lower_rad=( + improvement_confidence_lower + ), + observability_rank=observability_rank, + observability_parameter_count=observability_parameter_count, + observability_condition_number=observability_condition_number, + offset_covariance_rad2=offset_covariance, + axis_cone_mismatch_by_joint_rad=axis_cone_mismatch_by_joint, + axis_cone_bias_classification_by_joint=( + axis_cone_bias_classification_by_joint + ), + failure_reasons=failure_reasons, + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_lines.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_lines.py new file mode 100644 index 0000000..6519a14 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_lines.py @@ -0,0 +1,603 @@ +"""Axis lines.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Any, Mapping, Sequence +import math + +from scipy.optimize import least_squares +from scipy.spatial.transform import Rotation +import numpy as np + +from ...geometry.rotation import robust_rotation_summary +from ..trajectory_geometry import _fit_circle_with_axis, _plane_basis +from .geometry_helpers import ( + AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD, + AXIS_POINT_MINIMUM_ROTATION_RAD, + AXIS_POINT_RESIDUAL_SCALE_M, + _pose_matrix, + _relative_rotation, + _vector, +) +from .rotation_curves import _baseline_reference, _canonical_reference_records, _near_zero_records +from .types import JointAxisMeasurement + + +def with_depth_free_axis_projection( + measurement: JointAxisMeasurement, + camera_center_common_xyz_m: Sequence[float], +) -> JointAxisMeasurement: + """Attach the source-camera interpretation plane of an axis line. + + The plane is defined by the camera centre and the fitted 3-D line, but is + only a projective observation: moving either fitted line point along its + optical ray leaves the plane unchanged. A fixed Tag mount changes the + moving point trajectory, not its recovered physical screw axis. + """ + camera_center = _vector( + camera_center_common_xyz_m, 3, name="axis camera centre" + ) + point = np.asarray(measurement.point_common_xyz_m, dtype=float) + axis = np.asarray(measurement.axis_common_xyz, dtype=float) + axis /= np.linalg.norm(axis) + ray = point - camera_center + ray_norm = float(np.linalg.norm(ray)) + if ray_norm <= 1.0e-6: + raise ValueError("axis point coincides with its source camera") + plane_normal = np.cross(ray / ray_norm, axis) + plane_norm = float(np.linalg.norm(plane_normal)) + if plane_norm <= 1.0e-6: + raise ValueError("axis projection is degenerate in its source camera") + plane_normal /= plane_norm + return replace( + measurement, + axis_point_camera_center_common_xyz_m=tuple( + float(value) for value in camera_center + ), + axis_point_interpretation_plane_normal_common_xyz=tuple( + float(value) for value in plane_normal + ), + ) + + +def cross_view_side_line_source( + measurement: JointAxisMeasurement, +) -> str | None: + """Return the side alias that supplied a fallback axis-line point.""" + sources = tuple( + str(source) + for source in getattr( + measurement, "pose_axis_line_source_joints", () + ) + ) + if ( + getattr(measurement, "axis_point_source", "") + in { + "side_circle_cross_view", + "side_circle_shared_radius_cross_view", + "side_interpretation_plane_cross_view", + } + and len(sources) == 1 + and sources[0].endswith("_side") + ): + return sources[0] + return None + + +def axis_line_uses_depth_free_interpretation_plane( + measurement: JointAxisMeasurement, +) -> bool: + """Return whether only the source-camera bearing is geometrically used.""" + return bool( + measurement.axis_point_source + in { + "side_interpretation_plane_cross_view", + "front_interpretation_plane_cross_view_validated", + } + and measurement.axis_point_camera_center_common_xyz_m is not None + ) + + +def refit_axis_line_group_with_shared_radius( + measurements: Sequence[JointAxisMeasurement], + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, + canonical_zero_direction: str | None, +) -> tuple[JointAxisMeasurement, ...]: + """Refit repeated cross-view axis lines with one physical radius. + + A side-view roll alias observes the same child Tag and the same physical + lever arm in every cycle. Fitting an independent radius to each short, + near-edge-on arc leaves radius and circle centre strongly correlated; + sub-pixel PnP noise can then move the reported axis line by several + millimetres even though every trajectory has a low radial residual. + + Keep a separate centre (and therefore an independent line-repeatability + check) for every cycle, but solve one shared radius from all cycles. This + is a physical constraint rather than a relaxed quality gate: the returned + lines are still checked against the unchanged cycle RMS limit, and each + cycle's radial residual remains an independent hard check. + """ + group = tuple(measurements) + if len(group) < 2: + return group + cycles = [int(measurement.cycle) for measurement in group] + if len(set(cycles)) != len(cycles): + raise ValueError("shared-radius axis group contains duplicate cycles") + + entries: list[dict[str, Any]] = [] + initial_parameters: list[float] = [] + initial_radii: list[float] = [] + for measurement in group: + cycle_records = [ + dict(record) + for record in records + if int(record.get("cycle", -1)) == int(measurement.cycle) + ] + motion_records = [ + record + for record in cycle_records + if str(record.get("kind", "sample")) != "baseline_hold_sample" + ] + if len(motion_records) < 6: + motion_records = cycle_records + if len(motion_records) < 6: + raise ValueError( + f"cycle {measurement.cycle + 1} has too few shared-radius samples" + ) + + reference_records = _canonical_reference_records( + cycle_records, canonical_zero_direction + ) + zero_records = _near_zero_records( + reference_records, int(zero_command_u8) + ) + if not zero_records: + raise ValueError( + f"cycle {measurement.cycle + 1} has no shared-radius zero pose" + ) + parent_poses = np.asarray( + [_pose_matrix(record["parent_pose_common"]) for record in zero_records] + ) + parent_translation = np.median(parent_poses[:, :3, 3], axis=0) + parent_quaternion = robust_rotation_summary( + [ + Rotation.from_matrix(matrix[:3, :3]).as_quat() + for matrix in parent_poses + ] + )[0] + parent_rotation = Rotation.from_quat(parent_quaternion) + common_axis = _vector( + measurement.axis_common_xyz, 3, name="shared-radius common axis" + ) + common_axis /= np.linalg.norm(common_axis) + parent_axis = parent_rotation.inv().apply(common_axis) + parent_axis /= np.linalg.norm(parent_axis) + + points = np.asarray( + [record["relative_translation_xyz_m"] for record in motion_records], + dtype=float, + ) + basis_x, basis_y = _plane_basis(parent_axis) + origin = np.mean(points, axis=0) + local = points - origin + points_xy = np.column_stack((local @ basis_x, local @ basis_y)) + initial = _fit_circle_with_axis(points, parent_axis) + initial_center = _vector( + initial["center_xyz_m"], 3, name="initial shared-radius centre" + ) + initial_parameters.extend( + [ + float((initial_center - origin) @ basis_x), + float((initial_center - origin) @ basis_y), + ] + ) + initial_radii.append(float(initial["radius_m"])) + entries.append( + { + "measurement": measurement, + "points": points, + "points_xy": points_xy, + "origin": origin, + "basis_x": basis_x, + "basis_y": basis_y, + "parent_axis": parent_axis, + "parent_rotation": parent_rotation, + "parent_translation": parent_translation, + } + ) + + median_radius = float(np.median(initial_radii)) + if median_radius <= 1.0e-6 or not math.isfinite(median_radius): + raise ValueError("shared-radius axis group has an invalid initial radius") + initial_value = np.asarray( + [*initial_parameters, median_radius], dtype=float + ) + + def residual(parameters: np.ndarray) -> np.ndarray: + radius = float(parameters[-1]) + return np.concatenate( + [ + np.linalg.norm( + entry["points_xy"] + - parameters[2 * index : 2 * index + 2], + axis=1, + ) + - radius + for index, entry in enumerate(entries) + ] + ) / 0.0005 + + lower = np.full(initial_value.shape, -np.inf, dtype=float) + upper = np.full(initial_value.shape, np.inf, dtype=float) + lower[-1] = max(1.0e-6, 0.5 * median_radius) + upper[-1] = 2.0 * median_radius + solution = least_squares( + residual, + initial_value, + bounds=(lower, upper), + loss="soft_l1", + f_scale=1.0, + max_nfev=3000, + ) + if not solution.success: + raise ValueError( + "shared-radius axis optimization failed: " + solution.message + ) + + shared_radius = float(solution.x[-1]) + if not math.isfinite(shared_radius): + raise ValueError("shared-radius axis optimization is non-finite") + result: list[JointAxisMeasurement] = [] + for index, entry in enumerate(entries): + center_xy = solution.x[2 * index : 2 * index + 2] + center_parent = ( + entry["origin"] + + float(center_xy[0]) * entry["basis_x"] + + float(center_xy[1]) * entry["basis_y"] + ) + # The coordinate along an infinite axis is a gauge. Retain the + # robust centre of this cycle's observed axial coordinates. + axial_offsets = ( + entry["points"] - center_parent + ) @ entry["parent_axis"] + center_parent += float(np.median(axial_offsets)) * entry["parent_axis"] + center_common = ( + entry["parent_rotation"].apply(center_parent) + + entry["parent_translation"] + ) + radial_residual = ( + np.linalg.norm( + entry["points_xy"] - center_xy, + axis=1, + ) + - shared_radius + ) + radial_rms = float(np.sqrt(np.mean(np.square(radial_residual)))) + measurement = entry["measurement"] + result.append( + replace( + measurement, + point_common_xyz_m=tuple( + float(value) for value in center_common + ), + radial_rms_m=max(float(measurement.radial_rms_m), radial_rms), + axis_point_source="side_circle_shared_radius_cross_view", + ) + ) + return tuple(result) + + +def maximum_axis_line_cycle_spread_m( + measurements: Sequence[JointAxisMeasurement], +) -> float: + """Measure repeatability of independently fitted near-parallel lines. + + Axis-line points have an arbitrary coordinate along their own direction. + Compare only the perpendicular displacement, symmetrically against both + fitted directions, so that the result remains meaningful with the small + allowed cycle-to-cycle direction variation. + """ + maximum = 0.0 + for left_index, left in enumerate(measurements): + left_axis = np.asarray(left.axis_common_xyz, dtype=float) + left_axis /= np.linalg.norm(left_axis) + left_point = np.asarray(left.point_common_xyz_m, dtype=float) + for right in measurements[left_index + 1 :]: + right_axis = np.asarray(right.axis_common_xyz, dtype=float) + right_axis /= np.linalg.norm(right_axis) + delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point + maximum = max( + maximum, + float(np.linalg.norm(np.cross(delta, left_axis))), + float(np.linalg.norm(np.cross(delta, right_axis))), + ) + return maximum + + +def axis_line_cycle_rms_m( + measurements: Sequence[JointAxisMeasurement], +) -> float: + """Return RMS line-position scatter about the four-cycle consensus. + + For parallel lines, the sum of squared pairwise distances divided by + ``n**2`` equals the mean squared distance from their centroid. Averaging + each pair's distance against both near-parallel directions preserves that + identity while avoiding an arbitrary choice of one cycle's direction. + """ + count = len(measurements) + if count < 2: + return 0.0 + squared_pairwise_sum = 0.0 + for left_index, left in enumerate(measurements): + left_axis = np.asarray(left.axis_common_xyz, dtype=float) + left_axis /= np.linalg.norm(left_axis) + left_point = np.asarray(left.point_common_xyz_m, dtype=float) + for right in measurements[left_index + 1 :]: + right_axis = np.asarray(right.axis_common_xyz, dtype=float) + right_axis /= np.linalg.norm(right_axis) + delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point + left_distance = float(np.linalg.norm(np.cross(delta, left_axis))) + right_distance = float(np.linalg.norm(np.cross(delta, right_axis))) + squared_pairwise_sum += 0.5 * ( + left_distance**2 + right_distance**2 + ) + return float(math.sqrt(squared_pairwise_sum / (count**2))) + + +def _fit_axis_point_from_pose_trajectory( + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, + axis_parent_xyz: Sequence[float], + angle_axis_parent_xyz: Sequence[float], + phase_reference_point_parent_xyz: Sequence[float], + view_normal_common_xyz: Sequence[float] | None, + canonical_zero_direction: str | None = None, + allow_axial_translation: bool = False, + residual_diagnostics: dict[str, float] | None = None, +) -> tuple[np.ndarray, float, str]: + """Fit the closest point on a revolute axis from full relative poses. + + If ``T(q)`` maps the moving Tag into its parent Tag frame, then + ``T(q) @ inv(T(0))`` is a rotation about the physical joint axis and its + translation obeys ``(I - R(q)) p = t(q)``. Solving this equation over the + complete trajectory uses both pose orientation and translation and avoids + treating a monocular Tag-centre depth arc as ground-truth geometry. + """ + samples = [dict(record) for record in records] + reference_samples = _canonical_reference_records( + samples, canonical_zero_direction + ) + zero_records = _near_zero_records(reference_samples, zero_command_u8) + if not zero_records: + raise ValueError( + f"axis-point fit has no record near baseline {zero_command_u8}" + ) + + reference_rotation = Rotation.from_quat( + _baseline_reference(reference_samples, zero_command_u8) + ).as_matrix() + reference_translation = np.median( + np.asarray( + [record["relative_translation_xyz_m"] for record in zero_records], + dtype=float, + ), + axis=0, + ) + axis = _vector(axis_parent_xyz, 3, name="axis-point direction") + axis /= np.linalg.norm(axis) + angle_axis = _vector( + angle_axis_parent_xyz, 3, name="axis-point angle direction" + ) + angle_axis /= np.linalg.norm(angle_axis) + phase_reference_point = _vector( + phase_reference_point_parent_xyz, + 3, + name="axis-point phase reference", + ) + helper = ( + np.asarray([1.0, 0.0, 0.0]) + if abs(float(axis[0])) < 0.8 + else np.asarray([0.0, 1.0, 0.0]) + ) + basis_first = np.cross(axis, helper) + basis_first /= np.linalg.norm(basis_first) + basis = np.column_stack( + (basis_first, np.cross(axis, basis_first)) + ) + + view_normal_common = None + use_image_plane_projection = False + if view_normal_common_xyz is not None: + view_normal_common = _vector( + view_normal_common_xyz, 3, name="view normal" + ) + view_normal_common /= np.linalg.norm(view_normal_common) + reference_parent_quaternion = robust_rotation_summary( + [ + Rotation.from_matrix( + _pose_matrix(record["parent_pose_common"])[:3, :3] + ).as_quat() + for record in zero_records + ] + )[0] + reference_parent_rotation = Rotation.from_quat( + reference_parent_quaternion + ) + reference_view_normal_parent = reference_parent_rotation.inv().apply( + view_normal_common + ) + reference_view_normal_parent /= np.linalg.norm( + reference_view_normal_parent + ) + use_image_plane_projection = abs( + float(axis @ reference_view_normal_parent) + ) >= math.cos(AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD) + + matrices: list[np.ndarray] = [] + translations: list[np.ndarray] = [] + raw_matrices: list[np.ndarray] = [] + raw_translations: list[np.ndarray] = [] + used_image_plane_projection = False + raw_angles: list[float] = [] + translation_angles: list[float] = [] + reference_radial = reference_translation - phase_reference_point + reference_radial -= axis * float(reference_radial @ axis) + reference_radial_norm = float(np.linalg.norm(reference_radial)) + if reference_radial_norm < 1.0e-6: + raise ValueError("axis-point phase reference radius is degenerate") + reference_radial /= reference_radial_norm + for record in samples: + observed_rotation = Rotation.from_quat( + _relative_rotation(record) + ).as_matrix() + observed_delta_rotation = observed_rotation @ reference_rotation.T + raw_angle = float( + Rotation.from_matrix(observed_delta_rotation).as_rotvec() + @ angle_axis + ) + if abs(raw_angle) < AXIS_POINT_MINIMUM_ROTATION_RAD: + continue + observed_translation = _vector( + record["relative_translation_xyz_m"], + 3, + name="relative translation", + ) + observed_radial = observed_translation - phase_reference_point + observed_radial -= axis * float(observed_radial @ axis) + observed_radial_norm = float(np.linalg.norm(observed_radial)) + if observed_radial_norm <= 1.0e-6: + continue + observed_radial /= observed_radial_norm + raw_angles.append(raw_angle) + translation_angles.append( + math.atan2( + float(axis @ np.cross(reference_radial, observed_radial)), + float( + np.clip(reference_radial @ observed_radial, -1.0, 1.0) + ), + ) + ) + orientation_sign = 1.0 + if raw_angles and float( + np.sum(np.asarray(raw_angles) * np.asarray(translation_angles)) + ) < 0.0: + orientation_sign = -1.0 + + for record in samples: + observed_rotation = Rotation.from_quat( + _relative_rotation(record) + ).as_matrix() + observed_translation = _vector( + record["relative_translation_xyz_m"], + 3, + name="relative translation", + ) + observed_delta_rotation = observed_rotation @ reference_rotation.T + signed_angle = float( + Rotation.from_matrix(observed_delta_rotation).as_rotvec() + @ angle_axis + ) * orientation_sign + if abs(signed_angle) < AXIS_POINT_MINIMUM_ROTATION_RAD: + continue + # Direction and angle have deliberately separate sources. A trusted + # upstream/circle direction may replace a biased planar-PnP axis, while + # the relative-orientation trajectory still provides a repeatable + # scalar travel. Reconstruct the admissible revolute motion before + # solving its axis line instead of feeding a contradictory 3-D + # rotation into the translation equations. + # The rotation-trajectory axis is undirected. Align its scalar sign + # with the selected physical axis using the Tag-centre trajectory; + # this remains robust even when a trusted upstream direction replaces + # a badly biased planar-PnP orientation axis. + delta_rotation = Rotation.from_rotvec( + axis * signed_angle + ).as_matrix() + delta_translation = ( + observed_translation + - delta_rotation @ reference_translation + ) + # A screw-driven revolute joint may carry real translation along its + # axis. That component is in the null space of (I - R), contains no + # information about the perpendicular axis-line point, and must not + # inflate or bias the line fit as if the mechanism were pure rotary. + axis_projection = ( + np.eye(3) - np.outer(axis, axis) + if allow_axial_translation + else np.eye(3) + ) + projection = axis_projection + axis_point_matrix = (np.eye(3) - delta_rotation) @ basis + raw_matrices.append(axis_point_matrix) + raw_translations.append(delta_translation) + if use_image_plane_projection: + assert view_normal_common is not None + parent_rotation = Rotation.from_matrix( + _pose_matrix(record["parent_pose_common"])[:3, :3] + ) + view_normal_parent = parent_rotation.inv().apply( + view_normal_common + ) + view_normal_parent /= np.linalg.norm(view_normal_parent) + image_projection = np.eye(3) - np.outer( + view_normal_parent, view_normal_parent + ) + projection = image_projection @ axis_projection + # A single end-on camera cannot distinguish absolute depth from a + # shift along the revolute axis. Keep the observable image-plane + # equations only; adding a free baseline-depth variable makes the + # axis point rank-deficient and pretends that this gauge is + # observable. Parallel phase chains are later compared in the + # same view, where their common depth gauge cancels. + used_image_plane_projection = True + matrices.append(projection @ axis_point_matrix) + translations.append(projection @ delta_translation) + + if len(matrices) < 6: + raise ValueError("axis-point fit has too few observable pose samples") + matrix = np.vstack(matrices) + translation = np.concatenate(translations) + singular_values = np.linalg.svd(matrix, compute_uv=False) + if ( + singular_values.size < 2 + or singular_values[-1] <= 1.0e-9 + or singular_values[-1] / singular_values[0] < 1.0e-3 + ): + raise ValueError("axis-point pose trajectory is ill-conditioned") + + unknown_count = matrix.shape[1] + solution = least_squares( + lambda value: ( + matrix @ value - translation + ) / AXIS_POINT_RESIDUAL_SCALE_M, + np.zeros(unknown_count, dtype=float), + loss="soft_l1", + f_scale=1.0, + max_nfev=1000, + ) + if not solution.success: + raise ValueError( + "axis-point pose optimization failed: " + solution.message + ) + residual = matrix @ solution.x - translation + rms = float(np.sqrt(np.mean(np.square(residual)))) + if residual_diagnostics is not None: + raw_residual = ( + np.asarray(raw_matrices) @ solution.x - np.asarray(raw_translations) + ) + axial = raw_residual @ axis + transverse = raw_residual - axial[:, None] * axis + residual_diagnostics.update( + raw_rms_m=float(np.sqrt(np.mean(raw_residual ** 2))), + axial_rms_m=float(np.sqrt(np.mean(axial ** 2))), + transverse_rms_m=float(np.sqrt(np.mean(transverse ** 2))), + ) + source = ( + "pose_trajectory_image_plane" + if used_image_plane_projection + else "pose_trajectory_3d" + ) + return basis @ solution.x[:2], rms, source diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_observations.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_observations.py new file mode 100644 index 0000000..3453e17 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/axis_observations.py @@ -0,0 +1,563 @@ +"""Axis observations.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from ...geometry.rotation import fit_rotation_axis, robust_rotation_summary +from ..trajectory_geometry import _fit_circle_with_axis, _fit_plane_axis +from .axis_lines import _fit_axis_point_from_pose_trajectory +from .geometry_helpers import ( + PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8, + PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE, + PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT, + PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD, + _pose_matrix, + _relative_rotation, + _vector, +) +from .rotation_curves import ( + _baseline_reference, + _canonical_reference_records, + _near_zero_records, + _spatial_input, +) +from .types import JointAxisMeasurement, PalmOrientationMeasurement + + +def _incremental_common_rotation_axis( + samples_by_bin: Mapping[ + tuple[str, int], Sequence[Mapping[str, Any]] + ], + *, + command_separation_u8: int = ( + PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 + ), + minimum_rotation_rad: float = ( + PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD + ), + minimum_pair_count: int = PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT, + consensus_percentile: float = ( + PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE + ), +) -> tuple[np.ndarray, int]: + """Fit one physical axis from robust local Tag rotations. + + A single baseline-to-endpoint logarithm is sensitive to smooth planar-PnP + curvature: the fitted axis then changes when the same Tag trajectory is + translated to another part of the image. Local finite rotations are + expressed directly in the common camera frame, and their undirected + weighted consensus estimates the physical revolute axis. The worst + quartile is discarded once more after the initial consensus, which is + enough to reject endpoint branch curvature without learning a + serial-specific yaw value or an image-position correction table. + """ + separation = int(command_separation_u8) + if separation < 4 or separation > 64: + raise ValueError("palm axis command separation must be in [4, 64]") + if not 0.0 < float(minimum_rotation_rad) < math.pi: + raise ValueError("palm axis minimum increment must be in (0, pi)") + if int(minimum_pair_count) < 6: + raise ValueError("palm axis minimum pair count must be at least six") + percentile = float(consensus_percentile) + if not 50.0 <= percentile <= 90.0: + raise ValueError("palm axis consensus percentile must be in [50, 90]") + + binned_common_rotations: dict[tuple[str, int], Rotation] = {} + for key, group in samples_by_bin.items(): + child_quaternions = [] + for record in group: + pose = record.get("child_pose_common") + if not isinstance(pose, Mapping): + continue + quaternion = pose.get("quaternion_xyzw") + if quaternion is not None: + child_quaternions.append(quaternion) + if not child_quaternions: + continue + binned_common_rotations[(str(key[0]), int(key[1]))] = ( + Rotation.from_quat( + robust_rotation_summary(child_quaternions)[0] + ) + ) + + directions: list[np.ndarray] = [] + weights: list[float] = [] + for direction in ("decreasing", "increasing"): + commands = sorted( + command + for candidate_direction, command in binned_common_rotations + if candidate_direction == direction + ) + for start_command in commands: + candidates = [ + command + for command in commands + if separation <= command - start_command <= separation + 2 + ] + if not candidates: + continue + end_command = candidates[0] + start = binned_common_rotations[(direction, start_command)] + end = binned_common_rotations[(direction, end_command)] + local_rotvec = (start.inv() * end).as_rotvec() + common_rotvec = start.apply(local_rotvec) + increment = float(np.linalg.norm(common_rotvec)) + if increment < float(minimum_rotation_rad): + continue + directions.append(common_rotvec / increment) + weights.append(increment) + + if len(directions) < int(minimum_pair_count): + raise ValueError( + "palm axis has too few full-stroke local rotation pairs: " + f"{len(directions)}/{int(minimum_pair_count)}" + ) + vectors = np.asarray(directions, dtype=float) + increments = np.asarray(weights, dtype=float) + + def consensus(selected: np.ndarray) -> np.ndarray: + scatter = np.einsum( + "n,ni,nj->ij", + increments[selected], + vectors[selected], + vectors[selected], + ) + eigenvalues, eigenvectors = np.linalg.eigh(scatter) + if not np.all(np.isfinite(eigenvalues)): + raise ValueError("palm axis local-rotation consensus is invalid") + axis = eigenvectors[:, -1] + return axis / np.linalg.norm(axis) + + selected = np.ones(len(vectors), dtype=bool) + axis = consensus(selected) + # Two deterministic refinement rounds prevent the initial scatter from + # being pulled toward a dense endpoint-bias cluster. + for _ in range(2): + deviations = np.arccos( + np.clip(np.abs(vectors @ axis), -1.0, 1.0) + ) + limit = float(np.percentile(deviations, percentile)) + selected = deviations <= limit + if int(np.count_nonzero(selected)) < int(minimum_pair_count): + raise ValueError("palm axis robust consensus retained too few pairs") + axis = consensus(selected) + return axis, int(np.count_nonzero(selected)) + + +def fit_partial_palm_orientation_measurement( + source_joint: str, + model_joint: str, + records: Sequence[Mapping[str, Any]], + *, + cycle: int, + zero_command_u8: int, + minimum_arc_rad: float = math.radians(15.0), + maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5), + maximum_command_distance_u8: int = 255, +) -> PalmOrientationMeasurement: + """Fit a physical axis within a configured window around encoder zero.""" + cycle_samples = [ + dict(record) + for record in records + if int(record.get("cycle", -1)) == int(cycle) + ] + if len(cycle_samples) < 12: + raise ValueError( + f"{source_joint} cycle {cycle + 1} has too few visible samples" + ) + if not 0.0 < float(minimum_arc_rad) < math.pi: + raise ValueError("palm orientation minimum arc must be in (0, pi)") + if float(maximum_rotation_orthogonal_rms_rad) <= 0.0: + raise ValueError( + "palm orientation rotation residual limit must be positive" + ) + command_distance = int(maximum_command_distance_u8) + if command_distance < 1 or command_distance > 255: + raise ValueError( + "palm orientation command distance must be in [1, 255]" + ) + zero = int(zero_command_u8) + samples = [ + record + for record in cycle_samples + if abs(int(record["command_u8"]) - zero) <= command_distance + ] + if len(samples) < 12: + raise ValueError( + f"{source_joint} cycle {cycle + 1} has too few zero-adjacent " + "visible samples" + ) + reference = Rotation.from_quat( + _baseline_reference(samples, int(zero_command_u8)) + ) + samples_by_bin: dict[tuple[str, int], list[Mapping[str, Any]]] = {} + for record in samples: + key = ( + str(record.get("direction", "")), + int(record["command_u8"]), + ) + samples_by_bin.setdefault(key, []).append(record) + if len(samples_by_bin) < 6: + raise ValueError( + f"{source_joint} cycle {cycle + 1} has too few visible command bins" + ) + binned_rotations = [ + Rotation.from_quat( + robust_rotation_summary( + [_relative_rotation(record) for record in group] + )[0] + ) + for group in samples_by_bin.values() + ] + vectors = [ + (reference.inv() * rotation).as_rotvec() + for rotation in binned_rotations + ] + axis_child = fit_rotation_axis( + vectors, [command for _direction, command in samples_by_bin] + ) + angles = np.asarray( + [float(vector @ axis_child) for vector in vectors], dtype=float + ) + observed_arc = float(np.ptp(angles)) + orthogonal_rms = float( + np.sqrt( + np.mean( + [ + np.linalg.norm( + vector - float(vector @ axis_child) * axis_child + ) + ** 2 + for vector in vectors + ] + ) + ) + ) + if observed_arc < float(minimum_arc_rad): + raise ValueError( + f"{source_joint} cycle {cycle + 1} visible rotation arc " + f"{math.degrees(observed_arc):.3f}deg is below " + f"{math.degrees(minimum_arc_rad):.3f}deg" + ) + if orthogonal_rms > float(maximum_rotation_orthogonal_rms_rad): + raise ValueError( + f"{source_joint} cycle {cycle + 1} zero-adjacent rotation " + f"residual {math.degrees(orthogonal_rms):.3f}deg exceeds " + f"{math.degrees(maximum_rotation_orthogonal_rms_rad):.3f}deg" + ) + zero_records = _near_zero_records(samples, int(zero_command_u8)) + if not zero_records: + raise ValueError( + f"{source_joint} cycle {cycle + 1} has no visible zero pose" + ) + parent_poses = np.asarray( + [_pose_matrix(record["parent_pose_common"]) for record in zero_records] + ) + parent_quaternion = robust_rotation_summary( + [ + Rotation.from_matrix(matrix[:3, :3]).as_quat() + for matrix in parent_poses + ] + )[0] + axis_parent = reference.apply(axis_child) + axis_common = Rotation.from_quat(parent_quaternion).apply(axis_parent) + axis_common /= np.linalg.norm(axis_common) + axis_estimator = "baseline_relative_so3" + incremental_pair_count = 0 + # Product recordings persist the selected moving-Tag pose in the common + # camera frame. Use the complete visible stroke only for a local-motion + # axis consensus; retain the zero-adjacent fit above as the unchanged arc + # and residual quality gate. Legacy/synthetic records without the common + # child pose keep their previous estimator exactly. + if any( + isinstance(record.get("child_pose_common"), Mapping) + for record in cycle_samples + ): + full_stroke_bins: dict[ + tuple[str, int], list[Mapping[str, Any]] + ] = {} + for record in cycle_samples: + key = ( + str(record.get("direction", "")), + int(record["command_u8"]), + ) + full_stroke_bins.setdefault(key, []).append(record) + incremental_axis, incremental_pair_count = ( + _incremental_common_rotation_axis(full_stroke_bins) + ) + if float(incremental_axis @ axis_common) < 0.0: + incremental_axis = -incremental_axis + axis_common = incremental_axis + axis_estimator = "robust_full_stroke_local_so3_v1" + state = np.median( + np.asarray( + [record["state_u8"] for record in zero_records], dtype=float + ), + axis=0, + ) + return PalmOrientationMeasurement( + source_joint=str(source_joint), + model_joint=str(model_joint), + cycle=int(cycle), + axis_common_xyz=tuple(float(value) for value in axis_common), + condition_state_u8=tuple(float(value) for value in state), + observed_arc_rad=observed_arc, + rotation_orthogonal_rms_rad=orthogonal_rms, + axis_estimator=axis_estimator, + incremental_pair_count=incremental_pair_count, + ) + + +def fit_partial_palm_orientation_measurements( + *, + sources: Mapping[str, str], + records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], + motor_by_source: Mapping[str, int], + baseline_command_u8: Sequence[int], + cycles: Sequence[int], + minimum_sources: int, + minimum_arc_rad: float = math.radians(15.0), + maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5), + maximum_command_distance_u8: int = 255, +) -> tuple[tuple[PalmOrientationMeasurement, ...], Mapping[str, str]]: + """Fit every usable optional source and require configured coverage.""" + source_map = {str(name): str(model) for name, model in sources.items()} + minimum = int(minimum_sources) + if not source_map: + if minimum != 0: + raise ValueError( + "palm orientation minimum is non-zero without sources" + ) + return (), {} + if minimum < 2 or minimum > len(source_map): + raise ValueError( + "palm orientation minimum source count must be between two " + "and the configured source count" + ) + missing_motors = sorted(set(source_map) - set(motor_by_source)) + if missing_motors: + raise ValueError( + "palm orientation sources are missing motor mappings: " + + ",".join(missing_motors) + ) + fitted: list[PalmOrientationMeasurement] = [] + rejected: dict[str, str] = {} + for cycle in (int(value) for value in cycles): + cycle_fitted: list[PalmOrientationMeasurement] = [] + for source_joint, model_joint in source_map.items(): + motor = int(motor_by_source[source_joint]) + try: + measurement = fit_partial_palm_orientation_measurement( + source_joint, + model_joint, + records_by_joint.get(source_joint, ()), + cycle=cycle, + zero_command_u8=int(baseline_command_u8[motor]), + minimum_arc_rad=minimum_arc_rad, + maximum_rotation_orthogonal_rms_rad=( + maximum_rotation_orthogonal_rms_rad + ), + maximum_command_distance_u8=( + maximum_command_distance_u8 + ), + ) + except Exception as error: + rejected[f"{source_joint}:cycle{cycle + 1}"] = str(error) + continue + cycle_fitted.append(measurement) + if len(cycle_fitted) < minimum: + cycle_reasons = { + key: value + for key, value in rejected.items() + if key.endswith(f":cycle{cycle + 1}") + } + raise ValueError( + f"palm orientation cycle {cycle + 1} has " + f"{len(cycle_fitted)}/{minimum} usable sources: " + + "; ".join( + f"{key}={value}" for key, value in cycle_reasons.items() + ) + ) + fitted.extend(cycle_fitted) + return tuple(fitted), rejected + + +def fit_joint_axis_measurement( + joint: str, + records: Sequence[Mapping[str, Any]], + *, + cycle: int, + zero_command_u8: int, + axis_common_constraint: Sequence[float] | None = None, + constrained_circle_joints: frozenset[str] = frozenset(), + view_normal_common_xyz: Sequence[float] | None = None, + canonical_zero_direction: str | None = None, + separate_axial_residual: bool = False, + input_to_joint_direction: int = -1, +) -> JointAxisMeasurement: + """Fit one physical screw axis from one complete scan cycle.""" + samples = [ + dict(record) for record in records if int(record["cycle"]) == int(cycle) + ] + if len(samples) < 12: + raise ValueError(f"{joint} cycle {cycle + 1} has too few samples") + points = np.asarray( + [record["relative_translation_xyz_m"] for record in samples], dtype=float + ) + free_circle_axis, free_plane_rms = _fit_plane_axis([points]) + singular_values = np.linalg.svd( + points - np.mean(points, axis=0), compute_uv=False + ) + circle_axis_observability = ( + 0.0 + if singular_values.size < 2 or singular_values[0] <= 1.0e-12 + else float(singular_values[1] / singular_values[0]) + ) + + reference_samples = _canonical_reference_records( + samples, canonical_zero_direction + ) + reference = Rotation.from_quat( + _baseline_reference(reference_samples, zero_command_u8) + ) + rotation_vectors = [ + (reference.inv() * Rotation.from_quat(_relative_rotation(record))).as_rotvec() + for record in samples + ] + commands = [_spatial_input(record) for record in samples] + if "input_value" in samples[0]: + if input_to_joint_direction not in {-1, 1}: + raise ValueError("native spatial input requires a fixed direction") + vectors = np.asarray(rotation_vectors, dtype=float) + _, singular, vh = np.linalg.svd(vectors, full_matrices=False) + if not singular.size or singular[0] <= 1e-6: + raise ValueError("native rotation axis is unobservable") + rotation_axis_child = vh[0] + trend = float((np.asarray(commands) - np.mean(commands)) @ (vectors @ rotation_axis_child)) + if abs(trend) <= 1e-12: + raise ValueError("native rotation direction is unobservable") + if np.sign(trend) != input_to_joint_direction: + rotation_axis_child = -rotation_axis_child + else: + rotation_axis_child = fit_rotation_axis(rotation_vectors, commands) + # reference maps the child Tag frame at baseline into the parent Tag + # frame. The quaternion delta axis is expressed in that child frame, + # while the fitted centre circle is expressed in the parent frame. This + # conversion is what makes arbitrary Tag mounting rotations harmless. + rotation_axis = reference.apply(rotation_axis_child) + if float(rotation_axis @ free_circle_axis) < 0.0: + free_circle_axis = -free_circle_axis + disagreement = math.acos( + float(np.clip(rotation_axis @ free_circle_axis, -1.0, 1.0)) + ) + + zero_records = _near_zero_records(reference_samples, zero_command_u8) + if not zero_records: + raise ValueError( + f"{joint} cycle has no record near baseline {zero_command_u8}" + ) + parent_poses = np.asarray( + [_pose_matrix(record["parent_pose_common"]) for record in zero_records] + ) + parent_translation = np.median(parent_poses[:, :3, 3], axis=0) + parent_quaternion = robust_rotation_summary( + [ + Rotation.from_matrix(matrix[:3, :3]).as_quat() + for matrix in parent_poses + ] + )[0] + parent_rotation = Rotation.from_quat(parent_quaternion) + axis_direction_source = "rotation" + + if axis_common_constraint is not None: + common_axis = _vector( + axis_common_constraint, 3, name="common axis constraint" + ) + common_axis /= np.linalg.norm(common_axis) + fitted_axis = parent_rotation.inv().apply(common_axis) + if float(fitted_axis @ rotation_axis) < 0.0: + fitted_axis = -fitted_axis + circle = _fit_circle_with_axis(points, fitted_axis) + plane_rms = float(circle["plane_rms_m"]) + axis_direction_source = "upstream_constraint" + elif str(joint) in constrained_circle_joints: + # The side/front views are close to end-on for these axes. Monocular + # planar-PnP depth bias can therefore tilt even an apparently smooth, + # low-residual Tag-centre circle. The relative orientation trajectory + # observes the screw-axis direction directly and is invariant to an + # arbitrary fixed Tag mounting rotation; use it to constrain the 3-D + # circle and estimate only the axis line. Never switch the direction + # source based on a rotation-vs-circle threshold: that discontinuity + # makes otherwise identical repeat cycles choose different models. + fitted_axis = rotation_axis + circle = _fit_circle_with_axis(points, fitted_axis) + plane_rms = float(circle["plane_rms_m"]) + else: + # Oblique trajectories with an observable 3-D motion plane retain the + # independent rotation/centre cross-check and fuse both estimates. + free_circle = _fit_circle_with_axis(points, free_circle_axis) + fitted_axis = rotation_axis + free_circle_axis + if float(np.linalg.norm(fitted_axis)) < 1.0e-9: + raise ValueError(f"{joint} rotation and centre axes are opposed") + fitted_axis /= np.linalg.norm(fitted_axis) + circle = free_circle + plane_rms = max( + float(free_plane_rms), float(circle["plane_rms_m"]) + ) + axis_direction_source = "rotation_circle_fusion" + axis_common = parent_rotation.apply(fitted_axis) + residual_diagnostics: dict[str, float] = {} + separate_axial = separate_axial_residual or str(joint).endswith("_side") + point_parent, pose_axis_line_rms_m, axis_point_source = ( + _fit_axis_point_from_pose_trajectory( + samples, + zero_command_u8=zero_command_u8, + axis_parent_xyz=fitted_axis, + angle_axis_parent_xyz=rotation_axis, + phase_reference_point_parent_xyz=circle["center_xyz_m"], + view_normal_common_xyz=view_normal_common_xyz, + canonical_zero_direction=canonical_zero_direction, + allow_axial_translation=separate_axial, + residual_diagnostics=residual_diagnostics, + ) + ) + point_common = parent_rotation.apply(point_parent) + parent_translation + state = np.median( + np.asarray([record["state_values"] if "state_values" in record else record["state_u8"] + for record in zero_records], dtype=float), + axis=0, + ) + return JointAxisMeasurement( + joint=str(joint), + cycle=int(cycle), + axis_common_xyz=tuple(float(value) for value in axis_common), + point_common_xyz_m=tuple(float(value) for value in point_common), + condition_state_u8=tuple(float(value) for value in state), + plane_rms_m=float(plane_rms), + radial_rms_m=float(circle["radial_rms_m"]), + rotation_circle_axis_difference_rad=float(disagreement), + view_normal_common_xyz=( + None + if view_normal_common_xyz is None + else tuple( + float(value) + for value in _vector( + view_normal_common_xyz, 3, name="view normal" + ) + ) + ), + axis_direction_source=axis_direction_source, + circle_axis_observability=float(circle_axis_observability), + axis_point_source=axis_point_source, + pose_axis_line_rms_m=pose_axis_line_rms_m, + pose_axis_line_raw_rms_m=residual_diagnostics["raw_rms_m"], + pose_axis_line_axial_rms_m=residual_diagnostics["axial_rms_m"], + pose_axis_line_transverse_rms_m=residual_diagnostics["transverse_rms_m"], + axis_point_axial_component_separated=separate_axial, + pose_axis_line_source_joints=(str(joint),), + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/base_pose.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/base_pose.py new file mode 100644 index 0000000..4e76bb1 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/base_pose.py @@ -0,0 +1,440 @@ +"""Base localization selected by geometric constraints, shared by all profiles.""" + +from __future__ import annotations + +from typing import Mapping, Sequence +import math + +from scipy.optimize import least_squares +from scipy.spatial.transform import Rotation +import numpy as np + +from .geometry_helpers import ZERO_MINIMUM_AXIS_CONE_RAD +from .residuals import ObservationGeometry +from .stages import TrainingProblem +from .types import JointAxisMeasurement + + +def fit_base_pose( + selected: Sequence[JointAxisMeasurement], + pose_offsets: Mapping[str, float] | None = None, + *, + problem: TrainingProblem, + geometry: ObservationGeometry, +) -> tuple[Rotation, np.ndarray]: + profile = problem.profile + zero_offsets = problem.zero_offsets + curves = geometry.curves + predicted_local = geometry.predicted_local + + pose_zero_offsets = zero_offsets if pose_offsets is None else pose_offsets + anchors_by_joint = { + name: [item for item in selected if item.joint == name] + for name in profile.root_anchor_joints + } + if any(not items for items in anchors_by_joint.values()): + raise ValueError("zero solve is missing root axis anchors") + + def undirected_axis_average( + values: Sequence[Sequence[float]], + ) -> np.ndarray: + vectors = np.asarray(values, dtype=float) + reference = vectors[0] / np.linalg.norm(vectors[0]) + signs = np.where(vectors @ reference < 0.0, -1.0, 1.0) + result = np.sum(vectors * signs[:, None], axis=0) + norm = float(np.linalg.norm(result)) + if norm < 1.0e-9: + raise ValueError("root axis average is degenerate") + return result / norm + + root_names = tuple(sorted(profile.root_anchor_joints)) + predicted_axes: dict[str, np.ndarray] = {} + predicted_points: dict[str, np.ndarray] = {} + observed_axes: dict[str, np.ndarray] = {} + observed_points: dict[str, np.ndarray] = {} + for name in root_names: + items = anchors_by_joint[name] + predicted = [predicted_local(item, pose_zero_offsets) for item in items] + predicted_axes[name] = undirected_axis_average( + [axis for axis, _ in predicted] + ) + predicted_points[name] = np.median( + np.asarray([point for _, point in predicted]), axis=0 + ) + observed_axes[name] = undirected_axis_average( + [item.axis_common_xyz for item in items] + ) + observed_points[name] = np.median( + np.asarray( + [item.point_common_xyz_m for item in items], dtype=float + ), + axis=0, + ) + + if ( + profile.parallel_root_pattern + and profile.base_pose_strategy == "full_hand" + ): + # In the product layout none of the four finger-roll zeros is a + # mechanical prior. Using one finger's pitch axis to orient the + # palm would therefore absorb that finger's roll zero into the + # palm pose. The named positions of five parallel root lines do + # observe rotation about their common direction, independent of + # all five root-joint zeros. Fit that transverse line pattern and + # leave the unobservable translation along the common direction + # at zero; all zero-sensitive phase residuals use line-to-line + # differences and are invariant to that gauge. + predicted_common = undirected_axis_average( + [predicted_axes[name] for name in root_names] + ) + observed_common = undirected_axis_average( + [observed_axes[name] for name in root_names] + ) + predicted_center = np.mean( + np.asarray([predicted_points[name] for name in root_names]), + axis=0, + ) + observed_center = np.mean( + np.asarray([observed_points[name] for name in root_names]), + axis=0, + ) + predicted_pattern = { + name: ( + (delta := predicted_points[name] - predicted_center) + - predicted_common * float(delta @ predicted_common) + ) + for name in root_names + } + observed_pattern = { + name: ( + (delta := observed_points[name] - observed_center) + - observed_common * float(delta @ observed_common) + ) + for name in root_names + } + if max( + np.linalg.norm(value) for value in predicted_pattern.values() + ) < 0.003: + raise ValueError("root axis-line pattern is degenerate") + if max( + np.linalg.norm(value) for value in observed_pattern.values() + ) < 0.003: + raise ValueError("observed root axis-line pattern is degenerate") + + def align_axis(source: np.ndarray, target: np.ndarray) -> Rotation: + source = source / np.linalg.norm(source) + target = target / np.linalg.norm(target) + cross = np.cross(source, target) + cross_norm = float(np.linalg.norm(cross)) + dot = float(np.clip(source @ target, -1.0, 1.0)) + if cross_norm > 1.0e-10: + return Rotation.from_rotvec( + cross / cross_norm * math.atan2(cross_norm, dot) + ) + if dot > 0.0: + return Rotation.identity() + basis = np.eye(3)[int(np.argmin(np.abs(source)))] + axis = np.cross(source, basis) + axis /= np.linalg.norm(axis) + return Rotation.from_rotvec(axis * math.pi) + + candidates: list[tuple[float, Rotation, np.ndarray]] = [] + for sign in (1.0, -1.0): + signed_observed_axis = sign * observed_common + axis_rotation = align_axis( + predicted_common, signed_observed_axis + ) + mapped_pattern = { + name: axis_rotation.apply(predicted_pattern[name]) + for name in root_names + } + cosine = sum( + float(mapped_pattern[name] @ observed_pattern[name]) + for name in root_names + ) + sine = sum( + float( + signed_observed_axis + @ np.cross( + mapped_pattern[name], observed_pattern[name] + ) + ) + for name in root_names + ) + phase_rotation = Rotation.from_rotvec( + signed_observed_axis * math.atan2(sine, cosine) + ) + rotation = phase_rotation * axis_rotation + translation_samples = [] + for name in root_names: + delta = observed_points[name] - rotation.apply( + predicted_points[name] + ) + translation_samples.append( + delta + - signed_observed_axis + * float(delta @ signed_observed_axis) + ) + translation = np.median( + np.asarray(translation_samples), axis=0 + ) + + # Root-axis points recovered from planar Tags occasionally + # contain a large but repeatable depth/line-position outlier. + # A plain Procrustes sum lets one such point rotate the palm + # frame and then makes the same bias look like a common zero + # offset on all four finger-roll joints. Refine only the + # rigid palm pose with a millimetre-scale robust loss. At + # least three mutually consistent named root lines therefore + # determine the transverse pattern while an outlier remains + # visible in the line diagnostics below. + transverse_helper = np.eye(3)[ + int(np.argmin(np.abs(signed_observed_axis))) + ] + transverse_first = np.cross( + signed_observed_axis, transverse_helper + ) + transverse_first /= np.linalg.norm(transverse_first) + transverse_second = np.cross( + signed_observed_axis, transverse_first + ) + + def robust_root_pattern_residual( + value: np.ndarray, + ) -> np.ndarray: + candidate_rotation = ( + Rotation.from_rotvec( + signed_observed_axis * float(value[0]) + ) + * rotation + ) + candidate_translation = ( + translation + + transverse_first * float(value[1]) + + transverse_second * float(value[2]) + ) + residuals: list[float] = [] + for name in root_names: + delta = observed_points[name] - ( + candidate_rotation.apply(predicted_points[name]) + + candidate_translation + ) + residuals.extend( + ( + float(delta @ transverse_first) / 0.001, + float(delta @ transverse_second) / 0.001, + ) + ) + return np.asarray(residuals, dtype=float) + + root_pattern_solution = least_squares( + robust_root_pattern_residual, + np.zeros(3, dtype=float), + bounds=( + np.asarray([-math.pi, -0.25, -0.25]), + np.asarray([math.pi, 0.25, 0.25]), + ), + loss="soft_l1", + f_scale=1.0, + max_nfev=1000, + ) + if not root_pattern_solution.success: + raise ValueError("robust root axis-line pattern fit failed") + rotation = ( + Rotation.from_rotvec( + signed_observed_axis + * float(root_pattern_solution.x[0]) + ) + * rotation + ) + translation = ( + translation + + transverse_first * float(root_pattern_solution.x[1]) + + transverse_second * float(root_pattern_solution.x[2]) + ) + axis_errors: list[float] = [] + for item in selected: + predicted_axis, _ = predicted_local(item, pose_zero_offsets) + observed_axis = np.asarray(item.axis_common_xyz, dtype=float) + axis_errors.append( + math.acos( + abs( + float( + np.clip( + rotation.apply(predicted_axis) + @ observed_axis, + -1.0, + 1.0, + ) + ) + ) + ) + ) + line_errors = [] + for name in root_names: + predicted_point = ( + rotation.apply(predicted_points[name]) + translation + ) + delta = observed_points[name] - predicted_point + line_errors.append( + float( + np.linalg.norm( + delta + - signed_observed_axis + * float(delta @ signed_observed_axis) + ) + ) + ) + score = float(np.mean(np.square(axis_errors))) + float( + np.mean(np.square(np.asarray(line_errors) / 0.01)) + ) + candidates.append((score, rotation, translation)) + _, rotation, translation = min( + candidates, key=lambda item: item[0] + ) + non_root_items = [ + item + for item in selected + if item.joint not in profile.root_anchor_joints + ] + if not non_root_items: + raise ValueError( + "non-parallel axes are required for palm axial translation" + ) + + def axial_translation_residual(value: np.ndarray) -> np.ndarray: + candidate_translation = ( + translation + observed_common * float(value[0]) + ) + residuals: list[float] = [] + for item in non_root_items: + predicted_axis, predicted_point = predicted_local( + item, pose_zero_offsets + ) + predicted_axis = rotation.apply(predicted_axis) + predicted_point = ( + rotation.apply(predicted_point) + + candidate_translation + ) + observed_axis = np.asarray( + item.axis_common_xyz, dtype=float + ) + if float(predicted_axis @ observed_axis) < 0.0: + observed_axis = -observed_axis + delta = np.asarray( + item.point_common_xyz_m, dtype=float + ) - predicted_point + perpendicular = delta - observed_axis * float( + delta @ observed_axis + ) + residuals.extend( + float(component) / 0.001 + for component in perpendicular + ) + return np.asarray(residuals, dtype=float) + + axial_solution = least_squares( + axial_translation_residual, + np.asarray([0.0]), + bounds=(np.asarray([-1.0]), np.asarray([1.0])), + loss="soft_l1", + f_scale=1.0, + ) + if not axial_solution.success: + raise ValueError("palm axial translation fit failed") + translation = ( + translation + + observed_common * float(axial_solution.x[0]) + ) + return rotation, translation + + # Legacy layouts have only one observed finger chain. Their + # non-parallel reference-finger pitch axis remains the orientation + # anchor; the product profile above deliberately does not use it. + primary = max( + root_names, + key=lambda name: float( + np.ptp(np.asarray(curves[name].angle_rad)) + ), + ) + orientation_anchor = profile.orientation_anchor_joint + if not orientation_anchor: + raise ValueError("serial zero solve requires an orientation anchor") + orientation_items = [ + item for item in selected if item.joint == orientation_anchor + ] + if not orientation_items: + raise ValueError( + f"zero solve is missing palm orientation anchor " + f"{orientation_anchor}" + ) + predicted_orientation_axis = undirected_axis_average( + [ + predicted_local(item, pose_zero_offsets)[0] + for item in orientation_items + ] + ) + observed_orientation_axis = undirected_axis_average( + [item.axis_common_xyz for item in orientation_items] + ) + + def frame_from_two_axes( + primary_axis: np.ndarray, orientation_axis: np.ndarray + ) -> np.ndarray: + first = primary_axis / np.linalg.norm(primary_axis) + second = orientation_axis - first * float(orientation_axis @ first) + second_norm = float(np.linalg.norm(second)) + if second_norm < math.sin(ZERO_MINIMUM_AXIS_CONE_RAD): + raise ValueError("palm orientation axes are nearly parallel") + second /= second_norm + return np.column_stack((first, second, np.cross(first, second))) + + predicted_frame = frame_from_two_axes( + predicted_axes[primary], + predicted_orientation_axis, + ) + candidates: list[tuple[float, Rotation, np.ndarray]] = [] + for primary_sign in (1.0, -1.0): + for orientation_sign in (1.0, -1.0): + observed_frame = frame_from_two_axes( + primary_sign * observed_axes[primary], + orientation_sign * observed_orientation_axis, + ) + rotation = Rotation.from_matrix( + observed_frame @ predicted_frame.T + ) + translation = np.median( + np.asarray( + [ + observed_points[name] + - rotation.apply(predicted_points[name]) + for name in root_names + ] + ), + axis=0, + ) + + # Resolve both undirected-axis sign branches using every + # distinct measured joint. This selects a palm-frame branch; + # it does not fit link geometry or encoder offsets. + errors_by_joint: dict[str, list[float]] = {} + for item in selected: + predicted_axis, _ = predicted_local(item, pose_zero_offsets) + predicted_axis = rotation.apply(predicted_axis) + observed_axis = np.asarray(item.axis_common_xyz, dtype=float) + alignment = float( + np.clip(predicted_axis @ observed_axis, -1.0, 1.0) + ) + error = math.acos( + alignment + if item.joint in profile.directed_base_axis_joints + else abs(alignment) + ) + errors_by_joint.setdefault(item.joint, []).append(error) + branch_score = sum( + min(float(np.median(values)), math.radians(30.0)) ** 2 + for values in errors_by_joint.values() + ) + candidates.append((branch_score, rotation, translation)) + _, rotation, translation = min(candidates, key=lambda item: item[0]) + return rotation, translation diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/geometry_helpers.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/geometry_helpers.py new file mode 100644 index 0000000..da35c05 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/geometry_helpers.py @@ -0,0 +1,216 @@ +"""Geometry helpers.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from ...domain.measurement import JointCurveFit + + +def circle_direction_is_constrained( + joint_name: str, constrained_circle_joints: frozenset[str] +) -> bool: + """Match the constraint used by both canonical and side aliases.""" + name = str(joint_name) + return name in constrained_circle_joints or name.endswith("_side") + + +def select_cross_view_roll_direction_source( + primary_cone_residuals_rad: Sequence[float], + secondary_cone_residuals_rad: Sequence[float], + maximum_cone_mismatch_rad: float, +) -> str: + """Choose one roll-axis view for the complete repeated-sweep group. + + The choice must not be made independently for every cycle. A residual + sitting just either side of the cone gate can otherwise alternate the + selected camera and turn a fixed cross-view bias into a false cycle-axis + spread. The secondary view wins only with a three-quarter consensus and + a median residual inside the unchanged geometry gate; ambiguous groups + retain the primary view. + """ + primary = np.asarray(primary_cone_residuals_rad, dtype=float) + secondary = np.asarray(secondary_cone_residuals_rad, dtype=float) + if ( + primary.ndim != 1 + or secondary.ndim != 1 + or len(primary) != len(secondary) + or len(primary) < 3 + or not np.all(np.isfinite(primary)) + or not np.all(np.isfinite(secondary)) + or float(maximum_cone_mismatch_rad) <= 0.0 + ): + return "primary" + limit = float(maximum_cone_mismatch_rad) + required_consensus = int(math.ceil(0.75 * len(primary))) + primary_passes = int(np.count_nonzero(primary <= limit)) + secondary_passes = int(np.count_nonzero(secondary <= limit)) + if ( + secondary_passes >= required_consensus + and primary_passes < required_consensus + and float(np.median(secondary)) <= limit + and float(np.median(primary)) > limit + ): + return "secondary" + return "primary" + + +ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 = 16 + + +AXIS_POINT_MINIMUM_ROTATION_RAD = math.radians(3.0) + + +AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD = math.radians(45.0) + + +AXIS_POINT_RESIDUAL_SCALE_M = 0.001 + + +ZERO_MINIMUM_AXIS_CONE_RAD = math.radians(15.0) + + +def _zero_sensitive_axis_error_rad( + predicted_axis: Sequence[float], + observed_axis: Sequence[float], + parent_axis: Sequence[float], +) -> float: + """Return only the axis error that a parent-joint zero can change. + + Rotating a downstream axis about its parent preserves their mutual cone + angle. The component normal to their plane is therefore the observable + encoder-zero error; cone-angle mismatch belongs to fixed geometry/PnP and + must not push a zero offset or fail its holdout validation. + """ + predicted = _vector(predicted_axis, 3, name="predicted axis") + predicted /= np.linalg.norm(predicted) + observed = _vector(observed_axis, 3, name="observed axis") + observed /= np.linalg.norm(observed) + parent = _vector(parent_axis, 3, name="parent axis") + parent /= np.linalg.norm(parent) + if float(predicted @ observed) < 0.0: + observed = -observed + predicted_projected = predicted - parent * float(predicted @ parent) + observed_projected = observed - parent * float(observed @ parent) + predicted_norm = float(np.linalg.norm(predicted_projected)) + observed_norm = float(np.linalg.norm(observed_projected)) + minimum_projection = math.sin(ZERO_MINIMUM_AXIS_CONE_RAD) + if min(predicted_norm, observed_norm) < minimum_projection: + raise ValueError( + "parent and downstream axes have an unobservable cone angle" + ) + predicted_projected /= predicted_norm + observed_projected /= observed_norm + return math.atan2( + float(parent @ np.cross(predicted_projected, observed_projected)), + float( + np.clip(predicted_projected @ observed_projected, -1.0, 1.0) + ), + ) + + +def _axis_cone_mismatch_rad( + predicted_axis: Sequence[float], + observed_axis: Sequence[float], + parent_axis: Sequence[float], +) -> float: + """Return zero-invariant parent/downstream cone-angle disagreement.""" + predicted = _vector(predicted_axis, 3, name="predicted axis") + predicted /= np.linalg.norm(predicted) + observed = _vector(observed_axis, 3, name="observed axis") + observed /= np.linalg.norm(observed) + parent = _vector(parent_axis, 3, name="parent axis") + parent /= np.linalg.norm(parent) + predicted_cone = math.acos( + abs(float(np.clip(parent @ predicted, -1.0, 1.0))) + ) + observed_cone = math.acos( + abs(float(np.clip(parent @ observed, -1.0, 1.0))) + ) + return abs(predicted_cone - observed_cone) + + +def _vector(value: Sequence[float], size: int, *, name: str) -> np.ndarray: + result = np.asarray(value, dtype=float) + if result.shape != (size,) or not np.all(np.isfinite(result)): + raise ValueError(f"{name} must contain {size} finite values") + return result + + +def _pose_matrix(payload: Mapping[str, Any]) -> np.ndarray: + translation = _vector(payload["translation_xyz_m"], 3, name="translation") + quaternion = _vector(payload["quaternion_xyzw"], 4, name="quaternion") + quaternion /= np.linalg.norm(quaternion) + result = np.eye(4) + result[:3, :3] = Rotation.from_quat(quaternion).as_matrix() + result[:3, 3] = translation + return result + + +def _relative_rotation(record: Mapping[str, Any]) -> np.ndarray: + quaternion = _vector( + record["relative_quaternion_xyzw"], 4, name="relative quaternion" + ) + return quaternion / np.linalg.norm(quaternion) + + +PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 = 24 + + +PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD = math.radians(0.3) + + +PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT = 24 + + +PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE = 75.0 + + +def _angles_from_state( + state_u8: Sequence[float], + *, + curves: Mapping[str, JointCurveFit], + motor_by_joint: Mapping[str, int], + inherited_zero_joints: Mapping[str, str] | None = None, +) -> dict[str, float]: + state = np.asarray(state_u8, dtype=float) + result: dict[str, float] = {} + for joint, motor in motor_by_joint.items(): + source = (inherited_zero_joints or {}).get(joint, joint) + if source not in curves: + continue + curve = curves[source] + value = float(state[int(motor)]) + if not math.isfinite(value): + raise ValueError("nonfinite spatial condition feedback") + knots = curve.circle.get("input_knots") + if knots is not None: + if not knots[0] <= value <= knots[-1]: + raise ValueError(f"spatial condition outside measured native support:{joint}:{value}") + result[joint] = float(np.interp(value, knots, curve.angle_rad)) + else: + if not 0 <= value <= 255: + raise ValueError(f"spatial condition outside byte domain:{joint}") + result[joint] = float(curve.angle_rad[int(np.rint(value))]) + return result + + +def robust_circular_location(values: Sequence[float]) -> float: + array = np.asarray(values, dtype=float) + if array.size == 0: + raise ValueError("circular residual set is empty") + centre = math.atan2( + float(np.mean(np.sin(array))), + float(np.mean(np.cos(array))), + ) + centred = np.arctan2(np.sin(array - centre), np.cos(array - centre)) + return float( + math.atan2( + math.sin(centre + float(np.median(centred))), + math.cos(centre + float(np.median(centred))), + ) + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/optimization.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/optimization.py new file mode 100644 index 0000000..c1c76ed --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/optimization.py @@ -0,0 +1,124 @@ +"""Ordered zero-offset optimization using training observations only.""" + +from __future__ import annotations + +from functools import partial +from typing import Mapping, Sequence +import math + +from scipy.optimize import least_squares +from scipy.spatial.transform import Rotation +import numpy as np + +from .base_pose import fit_base_pose +from .residuals import ObservationGeometry +from .stages import OffsetLimits, TrainingProblem +from .types import JointAxisMeasurement + + +def optimise_offsets( + selected: Sequence[JointAxisMeasurement], + base_rotation: Rotation, + base_translation: np.ndarray, + initial: Mapping[str, float] | None = None, + *, + limits: OffsetLimits, + problem: TrainingProblem, + geometry: ObservationGeometry, +) -> dict[str, float]: + diagnostic_offset_limits = limits.diagnostic_offset_limits + fixed_offsets = problem.fixed_offsets + offset_limits = limits.offset_limits + profile = problem.profile + angular_error_samples = geometry.angular_error_samples + + result = {name: 0.0 for name in profile.direct_zero_joints} + if initial is not None: + result.update( + { + name: float(initial[name]) + for name in profile.direct_zero_joints + } + ) + result.update(fixed_offsets) + for name, diagnostic_limit, configured_limit in zip( + profile.direct_zero_joints, + diagnostic_offset_limits, + offset_limits, + ): + if name in fixed_offsets: + continue + has_axis_pair = name in profile.same_view_axis_pair_by_offset + limit = float( + configured_limit if has_axis_pair else diagnostic_limit + ) + + def residual(value: np.ndarray) -> np.ndarray: + candidate = dict(result) + candidate[name] = float(value[0]) + samples = angular_error_samples( + candidate, selected, base_rotation, base_translation + )[name] + return np.asarray( + [ + math.atan2(math.sin(item), math.cos(item)) + / math.radians(1.0) + for item in samples + ], + dtype=float, + ) + + starts = [0.0, -0.5 * limit, 0.5 * limit] + if initial is not None: + starts.append(float(initial[name])) + solutions = [ + least_squares( + residual, + np.asarray( + [np.clip(start, -limit + 1.0e-9, limit - 1.0e-9)] + ), + bounds=(np.asarray([-limit]), np.asarray([limit])), + loss="soft_l1", + f_scale=1.0, + max_nfev=1000, + ) + for start in starts + ] + solution = min( + solutions, key=lambda item: float(np.sum(np.square(item.fun))) + ) + if not solution.success: + raise ValueError( + f"URDF zero optimization failed for {name}: " + f"{solution.message}" + ) + result[name] = float(solution.x[0]) + return result + + +def solve_selected( + selected: Sequence[JointAxisMeasurement], + initial: Mapping[str, float] | None = None, + *, + limits: OffsetLimits, + problem: TrainingProblem, + geometry: ObservationGeometry, +) -> tuple[Rotation, np.ndarray, dict[str, float]]: + profile = problem.profile + + fit_pose = partial(fit_base_pose, geometry=geometry, problem=problem) + fit_offsets = partial(optimise_offsets, geometry=geometry, problem=problem, limits=limits) + rotation, translation = fit_pose(selected) + offsets = fit_offsets( + selected, rotation, translation, initial=initial + ) + if profile.base_pose_strategy == "serial_chain": + # The first pose estimate only supplies a branch for the invariant + # same-view yaw solve. Refit after yaw is known so that the + # downstream pitch phase is evaluated in the corrected serial + # thumb frame, then freeze that pose for holdout validation. + rotation, translation = fit_pose(selected, offsets) + offsets = fit_offsets( + selected, rotation, translation, initial=offsets + ) + return rotation, translation, offsets diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/preparation.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/preparation.py new file mode 100644 index 0000000..e7245cd --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/preparation.py @@ -0,0 +1,292 @@ +"""Input validation and separation of training and independent validation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Mapping, Sequence +import math + +import numpy as np + +from ...urdf.kinematics import UrdfKinematicModel +from .stages import OffsetLimits, ZeroProblem, ZeroSolveOptions +from .types import JointAxisMeasurement, PalmOrientationMeasurement, ZeroCalibrationProfile + + +def prepare_zero_problem( + *, + source_urdf: str | Path, + measurements: Sequence[JointAxisMeasurement], + palm_orientation_measurements: Sequence[PalmOrientationMeasurement], + training_cycles: Sequence[int], + validation_cycle: int, + options: ZeroSolveOptions, + hand_type: str | None, + tag_layout: str | None, + fixed_direct_zero_offsets_rad: Mapping[str, float] | None, + static_output_zero_offsets_rad: Mapping[str, float] | None, + zero_profile: ZeroCalibrationProfile | None, +) -> ZeroProblem: + """Validate protected inputs and separate observations before optimization.""" + maximum_offset_rad = options.maximum_offset_rad + finger_maximum_offset_rad = options.finger_maximum_offset_rad + joint_maximum_offset_rad = options.joint_maximum_offset_rad + maximum_cycle_difference_rad = options.maximum_cycle_difference_rad + minimum_applied_offset_rad = options.minimum_applied_offset_rad + significance_sigma = options.significance_sigma + maximum_validation_error_rad = options.maximum_validation_error_rad + maximum_confidence_half_width_rad = options.maximum_confidence_half_width_rad + maximum_axis_cone_mismatch_rad = options.maximum_axis_cone_mismatch_rad + maximum_systematic_axis_cone_bias_rad = options.maximum_systematic_axis_cone_bias_rad + maximum_pose_axis_line_rms_m = options.maximum_pose_axis_line_rms_m + maximum_observability_condition_number = options.maximum_observability_condition_number + if zero_profile is None: + raise ValueError("zero_profile is required; the core has no model defaults") + profile = zero_profile + hand_type = profile.hand.side if hand_type is None else hand_type + tag_layout = profile.hand.layout_id if tag_layout is None else tag_layout + if profile.hand.side != str(hand_type).lower(): + raise ValueError("zero profile hand side does not match hand_type") + if profile.hand.layout_id != str(tag_layout): + raise ValueError("zero profile layout does not match tag_layout") + model = UrdfKinematicModel(source_urdf) + training = [m for m in measurements if m.cycle in set(training_cycles)] + validation = [m for m in measurements if m.cycle == int(validation_cycle)] + expected = set(profile.axis_joints) + if ( + {m.joint for m in training} != expected + or {m.joint for m in validation} != expected + ): + raise ValueError("axis measurements do not contain all required joints/cycles") + configured_palm_sources = { + str(name): str(model_joint) + for name, model_joint in ( + profile.hand.palm_orientation_sources or {} + ).items() + } + orientation_measurements = tuple(palm_orientation_measurements) + if configured_palm_sources: + minimum_orientation_sources = int( + profile.hand.minimum_palm_orientation_sources + ) + unknown_orientation_sources = sorted( + { + item.source_joint + for item in orientation_measurements + if item.source_joint not in configured_palm_sources + or configured_palm_sources[item.source_joint] + != item.model_joint + } + ) + if unknown_orientation_sources: + raise ValueError( + "palm orientation measurements do not match the product " + "profile: " + + ",".join(unknown_orientation_sources) + ) + required_orientation_cycles = { + *(int(value) for value in training_cycles), + int(validation_cycle), + } + for cycle in required_orientation_cycles: + cycle_sources = { + item.source_joint + for item in orientation_measurements + if int(item.cycle) == cycle + } + if len(cycle_sources) < minimum_orientation_sources: + raise ValueError( + f"palm orientation cycle {cycle + 1} contains " + f"{len(cycle_sources)}/{minimum_orientation_sources} " + "qualified sources" + ) + elif orientation_measurements: + raise ValueError( + "palm orientation measurements were supplied without a profile" + ) + orientation_validation = tuple( + item + for item in orientation_measurements + if int(item.cycle) == int(validation_cycle) + ) + orientation_by_model_cycle: dict[ + tuple[str, int], PalmOrientationMeasurement + ] = {} + for item in orientation_measurements: + key = (str(item.model_joint), int(item.cycle)) + if key in orientation_by_model_cycle: + raise ValueError( + "palm orientation has multiple sources for " + f"{key[0]} cycle {key[1] + 1}" + ) + orientation_by_model_cycle[key] = item + if not 0.0 < finger_maximum_offset_rad <= maximum_offset_rad: + raise ValueError("finger maximum offset must be positive and no larger than thumb") + joint_limits = { + str(name): float(value) + for name, value in dict(joint_maximum_offset_rad or {}).items() + } + unknown_joint_limits = sorted(set(joint_limits) - set(profile.direct_zero_joints)) + if unknown_joint_limits: + raise ValueError( + "joint maximum offsets contain unknown direct joints: " + + ",".join(unknown_joint_limits) + ) + if any( + not math.isfinite(value) or not 0.0 < value <= math.radians(90.0) + for value in joint_limits.values() + ): + raise ValueError("joint maximum offsets must be finite and in (0, 90deg]") + if maximum_cycle_difference_rad <= 0.0: + raise ValueError("maximum cycle difference must be positive") + if minimum_applied_offset_rad < 0.0 or significance_sigma < 0.0: + raise ValueError("zero significance thresholds must be non-negative") + if maximum_axis_cone_mismatch_rad <= 0.0: + raise ValueError("maximum axis cone mismatch must be positive") + if ( + maximum_systematic_axis_cone_bias_rad is not None + and ( + maximum_systematic_axis_cone_bias_rad + <= maximum_axis_cone_mismatch_rad + or maximum_systematic_axis_cone_bias_rad > math.radians(90.0) + ) + ): + raise ValueError( + "maximum systematic axis cone bias must exceed the precision " + "limit and be at most 90deg" + ) + if maximum_pose_axis_line_rms_m <= 0.0: + raise ValueError("maximum pose axis-line RMS must be positive") + if maximum_observability_condition_number <= 1.0: + raise ValueError( + "maximum observability condition number must be greater than one" + ) + if ( + maximum_validation_error_rad is not None + and maximum_validation_error_rad <= 0.0 + ): + raise ValueError("maximum validation error must be positive") + if ( + maximum_confidence_half_width_rad is not None + and maximum_confidence_half_width_rad <= 0.0 + ): + raise ValueError("maximum confidence half width must be positive") + fixed_offsets = { + str(name): float(value) + for name, value in ( + profile.fixed_direct_zero_offsets_rad + if fixed_direct_zero_offsets_rad is None + else fixed_direct_zero_offsets_rad + ).items() + } + unknown_fixed_offsets = sorted( + set(fixed_offsets) - set(profile.direct_zero_joints) + ) + if unknown_fixed_offsets: + raise ValueError( + "fixed direct zero offsets contain unknown joints: " + + ",".join(unknown_fixed_offsets) + ) + if any( + not math.isfinite(value) or abs(value) > math.radians(90.0) + for value in fixed_offsets.values() + ): + raise ValueError( + "fixed direct zero offsets must be finite and within +/-90deg" + ) + output_offsets = { + str(name): float(value) + for name, value in ( + profile.static_output_zero_offsets_rad + if static_output_zero_offsets_rad is None + else static_output_zero_offsets_rad + ).items() + } + unknown_output_offsets = sorted( + set(output_offsets) - set(profile.direct_zero_joints) + ) + if unknown_output_offsets: + raise ValueError( + "static output zero offsets contain unknown joints: " + + ",".join(unknown_output_offsets) + ) + if any( + not math.isfinite(value) or abs(value) > math.radians(90.0) + for value in output_offsets.values() + ): + raise ValueError( + "static output zero offsets must be finite and within +/-90deg" + ) + zero_offsets = {name: 0.0 for name in profile.direct_zero_joints} + zero_offsets.update(fixed_offsets) + + return ZeroProblem( + profile=profile, + model=model, + training=training, + validation=validation, + configured_palm_sources=configured_palm_sources, + orientation_validation=orientation_validation, + orientation_by_model_cycle=orientation_by_model_cycle, + joint_limits=joint_limits, + fixed_offsets=fixed_offsets, + output_offsets=output_offsets, + zero_offsets=zero_offsets, + ) + + +def prepare_offset_limits( + *, + options: ZeroSolveOptions, + problem: ZeroProblem, +) -> OffsetLimits: + finger_maximum_offset_rad = options.finger_maximum_offset_rad + joint_limits = problem.joint_limits + maximum_offset_rad = options.maximum_offset_rad + profile = problem.profile + + product_finger_rolls = tuple(profile.common_mode_zero_joints) + offset_limits = np.asarray( + [ + joint_limits.get( + name, + ( + finger_maximum_offset_rad + if name in profile.small_offset_joints + else maximum_offset_rad + ), + ) + for name in profile.direct_zero_joints + ], + dtype=float, + ) + # Do not make a configured safety limit the numerical optimizer's bound. + # Otherwise a genuine out-of-range estimate and a modelling failure both + # collapse to exactly +/-20 or +/-3 degrees, which hides the magnitude and + # encourages pointless rescans. Search farther for diagnostics, then keep + # the original configured limits as unchanged pass/fail gates below. + diagnostic_offset_limits = np.minimum( + math.radians(90.0), + np.maximum(3.0 * offset_limits, offset_limits + math.radians(5.0)), + ) + if profile.parallel_root_pattern: + # The absolute rotation of the fitted palm frame about the four + # parallel roll axes is a shared gauge. Each physical finger zero is + # only its deviation from the four-finger median, but the raw scalar + # solves include that common mode. Give those four diagnostic solves + # enough range for the unchanged global common-mode limit plus the + # unchanged per-finger deviation limit; applying the per-finger bound + # before removing the gauge clips every solve to the same value and + # destroys the observable deviations. + for index, name in enumerate(profile.direct_zero_joints): + if name in product_finger_rolls: + diagnostic_offset_limits[index] = min( + math.radians(90.0), + maximum_offset_rad + offset_limits[index], + ) + + return OffsetLimits( + product_finger_rolls=product_finger_rolls, + offset_limits=offset_limits, + diagnostic_offset_limits=diagnostic_offset_limits, + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/residuals.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/residuals.py new file mode 100644 index 0000000..66f34f5 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/residuals.py @@ -0,0 +1,335 @@ +"""CAD observation residuals with explicit, immutable geometry inputs.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from ...domain.measurement import JointCurveFit +from ...urdf.kinematics import UrdfKinematicModel +from .geometry_helpers import ( + _angles_from_state, + _zero_sensitive_axis_error_rad, + robust_circular_location, +) +from .types import JointAxisMeasurement, PalmOrientationMeasurement, ZeroCalibrationProfile + + +@dataclass(frozen=True) +class ObservationGeometry: + """CAD and frozen observations; no fitted state or acceptance policy.""" + profile: ZeroCalibrationProfile + model: UrdfKinematicModel + curves: Mapping[str, JointCurveFit] + motor_by_joint: Mapping[str, int] + orientation_by_model_cycle: Mapping[tuple[str, int], PalmOrientationMeasurement] + + def for_cycles(self, cycles: Sequence[int]) -> ObservationGeometry: + selected = set(cycles) + return replace(self, orientation_by_model_cycle={ + key: value for key, value in self.orientation_by_model_cycle.items() if key[1] in selected + }) + + def predicted_local( + self, + measurement: JointAxisMeasurement, + offsets: Mapping[str, float], + ) -> tuple[np.ndarray, np.ndarray]: + curves = self.curves + model = self.model + motor_by_joint = self.motor_by_joint + profile = self.profile + angles = _angles_from_state( + ( + measurement.condition_state_u8 + if measurement.condition_command_u8 is None + else measurement.condition_command_u8 + ), + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=profile.inherited_zero_joints, + ) + return model.axis_line( + measurement.joint, + zero_offsets=offsets, + joint_angles=angles, + ) + + def predicted_palm_orientation_local( + self, + measurement: PalmOrientationMeasurement, + offsets: Mapping[str, float], + ) -> np.ndarray: + curves = self.curves + model = self.model + motor_by_joint = self.motor_by_joint + profile = self.profile + angles = _angles_from_state( + measurement.condition_state_u8, + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=profile.inherited_zero_joints, + ) + axis, _ = model.axis_line( + measurement.model_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + return axis / np.linalg.norm(axis) + + def phase_error( + self, + predicted_parent_axis: np.ndarray, + predicted_parent_point: np.ndarray, + predicted_child_point: np.ndarray, + observed_parent_axis: np.ndarray, + observed_parent_point: np.ndarray, + observed_child_point: np.ndarray, + view_normal_common: Sequence[float] | None = None, + ) -> float: + profile = self.profile + parent_axis = predicted_parent_axis / np.linalg.norm(predicted_parent_axis) + observed_axis = observed_parent_axis / np.linalg.norm(observed_parent_axis) + if float(parent_axis @ observed_axis) < 0.0: + observed_axis = -observed_axis + predicted_delta = predicted_child_point - predicted_parent_point + observed_delta = observed_child_point - observed_parent_point + predicted_radial = predicted_delta - parent_axis * float( + predicted_delta @ parent_axis + ) + observed_radial = observed_delta - observed_axis * float( + observed_delta @ observed_axis + ) + angle_axis = parent_axis + if view_normal_common is not None: + view_normal = np.asarray(view_normal_common, dtype=float) + normal_norm = float(np.linalg.norm(view_normal)) + if normal_norm > 1.0e-9: + view_normal /= normal_norm + # When looking approximately along the rotation axis, image + # x/y contains the complete radial phase while optical depth + # is both unnecessary and much noisier for a 16 mm planar + # Tag. For an edge-on axis the depth component is genuinely + # needed for observability, so retain the full 3-D residual. + if abs(float(parent_axis @ view_normal)) >= math.cos( + math.radians(45.0) + ): + # Use an image-plane phase for an end-on observation. + # Projection alone removes optical depth, but does NOT + # remove an arbitrary along-axis point coordinate when + # the camera is even slightly oblique to the axis. + # A closest point is defined relative to its parent Tag + # origin, not the physical bearing centre. Different Tag + # mounts therefore choose different along-axis gauges. + # Reusing delta here would reintroduce that arbitrary + # coordinate and turn it into a phase at oblique views. + predicted_image_input = ( + predicted_radial if profile.project_axis_gauge_before_image + else predicted_delta + ) + observed_image_input = ( + observed_radial if profile.project_axis_gauge_before_image + else observed_delta + ) + predicted_radial = predicted_image_input - view_normal * float( + predicted_image_input @ view_normal + ) + observed_radial = observed_image_input - view_normal * float( + observed_image_input @ view_normal + ) + angle_axis = view_normal + if float(angle_axis @ parent_axis) < 0.0: + angle_axis = -angle_axis + predicted_radius = float(np.linalg.norm(predicted_radial)) + observed_radius = float(np.linalg.norm(observed_radial)) + if min(predicted_radius, observed_radius) < 0.003: + raise ValueError("parallel-axis radial phase is not observable") + predicted_radial /= predicted_radius + observed_radial /= observed_radius + return math.atan2( + float(angle_axis @ np.cross(predicted_radial, observed_radial)), + float(np.clip(predicted_radial @ observed_radial, -1.0, 1.0)), + ) + + def same_view_axis_pair_errors( + self, + offset_joint: str, + selected: Sequence[JointAxisMeasurement], + offsets: Mapping[str, float], + ) -> tuple[float, ...]: + """Return the camera-frame-invariant residual for a serial-axis pair. + + This observation intentionally has two mirror roots. The optimiser + disambiguates them with the original cross-view estimate, but the + pair residual alone determines the final numerical zero. + """ + curves = self.curves + model = self.model + motor_by_joint = self.motor_by_joint + orientation_by_model_cycle = self.orientation_by_model_cycle + profile = self.profile + pair = profile.same_view_axis_pair_by_offset.get(offset_joint) + if pair is None: + return () + anchor_joint, observer_joint = pair + + def angle(left: np.ndarray, right: np.ndarray) -> float: + cosine = float(left @ right) / float( + np.linalg.norm(left) * np.linalg.norm(right) + ) + return math.acos(abs(float(np.clip(cosine, -1.0, 1.0)))) + + errors: list[float] = [] + for cycle in sorted({int(item.cycle) for item in selected}): + anchor = orientation_by_model_cycle.get((anchor_joint, cycle)) + observer = orientation_by_model_cycle.get( + (observer_joint, cycle) + ) + if anchor is None or observer is None: + return () + angles = _angles_from_state( + observer.condition_state_u8, + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=profile.inherited_zero_joints, + ) + predicted_anchor, _ = model.axis_line( + anchor_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + predicted_observer, _ = model.axis_line( + observer_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + observed_anchor = np.asarray( + anchor.axis_common_xyz, dtype=float + ) + observed_observer = np.asarray( + observer.axis_common_xyz, dtype=float + ) + errors.append( + angle(predicted_anchor, predicted_observer) + - angle(observed_anchor, observed_observer) + ) + return tuple(errors) + + def angular_error_samples( + self, + offsets: Mapping[str, float], + selected: Sequence[JointAxisMeasurement], + base_rotation: Rotation, + base_translation: np.ndarray, + ) -> dict[str, tuple[float, ...]]: + curves = self.curves + model = self.model + motor_by_joint = self.motor_by_joint + phase_error = self.phase_error + predicted_local = self.predicted_local + profile = self.profile + same_view_axis_pair_errors = self.same_view_axis_pair_errors + by_key = {(item.joint, item.cycle): item for item in selected} + errors: dict[str, list[float]] = { + name: [] for name in profile.direct_zero_joints + } + for offset_joint, observer_joint in profile.offset_observer_joint.items(): + pair_errors = same_view_axis_pair_errors( + offset_joint, selected, offsets + ) + if pair_errors: + errors[offset_joint].extend(pair_errors) + continue + observer_items = [ + item for item in selected if item.joint == observer_joint + ] + if not observer_items: + raise ValueError(f"zero observer is missing: {observer_joint}") + for item in observer_items: + observer_axis, observer_point = predicted_local(item, offsets) + observer_axis = base_rotation.apply(observer_axis) + observer_point = ( + base_rotation.apply(observer_point) + base_translation + ) + observed_axis = np.asarray(item.axis_common_xyz, dtype=float) + if observer_joint in profile.axis_parent_joint: + parent_joint = profile.axis_parent_joint[observer_joint] + angles = _angles_from_state( + ( + item.condition_state_u8 + if item.condition_command_u8 is None + else item.condition_command_u8 + ), + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=profile.inherited_zero_joints, + ) + parent_axis, _ = model.axis_line( + parent_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + parent_axis = base_rotation.apply(parent_axis) + error = _zero_sensitive_axis_error_rad( + observer_axis, observed_axis, parent_axis + ) + else: + parent_joint = profile.phase_parent_joint[observer_joint] + observed_parent = by_key.get((parent_joint, item.cycle)) + if observed_parent is None: + raise ValueError( + f"phase parent is missing: {parent_joint} cycle {item.cycle}" + ) + angles = _angles_from_state( + ( + item.condition_state_u8 + if item.condition_command_u8 is None + else item.condition_command_u8 + ), + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=profile.inherited_zero_joints, + ) + parent_axis, parent_point = model.axis_line( + parent_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + parent_axis = base_rotation.apply(parent_axis) + parent_point = ( + base_rotation.apply(parent_point) + base_translation + ) + error = phase_error( + parent_axis, + parent_point, + observer_point, + np.asarray(observed_parent.axis_common_xyz, dtype=float), + np.asarray(observed_parent.point_common_xyz_m, dtype=float), + np.asarray(item.point_common_xyz_m, dtype=float), + item.view_normal_common_xyz, + ) + errors[offset_joint].append(float(error)) + return { + name: tuple(values) + for name, values in errors.items() + if values + } + + def angular_errors( + self, + offsets: Mapping[str, float], + selected: Sequence[JointAxisMeasurement], + base_rotation: Rotation, + base_translation: np.ndarray, + ) -> dict[str, float]: + angular_error_samples = self.angular_error_samples + return { + name: robust_circular_location(values) + for name, values in angular_error_samples( + offsets, selected, base_rotation, base_translation + ).items() + } diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/rotation_curves.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/rotation_curves.py new file mode 100644 index 0000000..061d526 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/rotation_curves.py @@ -0,0 +1,423 @@ +"""Rotation curves.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from ...domain.measurement import JointCurveFit +from ...geometry.rotation import fit_rotation_axis, robust_rotation_summary +from ..trajectory_geometry import _fit_joint_curve +from .geometry_helpers import ZERO_REFERENCE_MAXIMUM_DISTANCE_U8, _relative_rotation, _vector + + +def _reference_group_key(record: Mapping[str, Any]) -> tuple[Any, Any]: + return record.get("cycle"), record.get("direction") + + +def _canonical_reference_records( + records: Sequence[Mapping[str, Any]], + canonical_zero_direction: str | None, +) -> list[Mapping[str, Any]]: + """Select the sole physical-zero branch when one is configured.""" + if canonical_zero_direction is None: + return list(records) + if canonical_zero_direction not in {"decreasing", "increasing"}: + raise ValueError("canonical zero direction is invalid") + selected = [ + record + for record in records + if str(record.get("direction")) == canonical_zero_direction + ] + if not selected: + raise ValueError( + f"canonical {canonical_zero_direction} zero branch has no samples" + ) + return selected + + +def _spatial_input(record: Mapping[str, Any]) -> float: + """Native input for geometry; the old byte spelling is read-only compatibility.""" + value = float(record["input_value"] if "input_value" in record else record["command_u8"]) + if not math.isfinite(value): + raise ValueError("nonfinite spatial observation input") + return value + + +def _interpolate_reference_rotation( + records: Sequence[Mapping[str, Any]], + zero_command_u8: int, + maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8, +) -> Rotation | None: + by_command: dict[float, list[np.ndarray]] = {} + for record in records: + command = _spatial_input(record) + by_command.setdefault(command, []).append(_relative_rotation(record)) + if not by_command: + return None + + rotations = { + command: Rotation.from_quat(robust_rotation_summary(values)[0]) + for command, values in by_command.items() + } + zero = float(zero_command_u8) + if zero in rotations: + return rotations[zero] + + lower = [command for command in rotations if command < zero] + upper = [command for command in rotations if command > zero] + lower_command = max(lower) if lower else None + upper_command = min(upper) if upper else None + if lower_command is not None and upper_command is not None: + lower_distance = zero - lower_command + upper_distance = upper_command - zero + if maximum_distance_u8 is None or max( + lower_distance, upper_distance + ) <= float(maximum_distance_u8): + lower_rotation = rotations[lower_command] + upper_rotation = rotations[upper_command] + fraction = lower_distance / (upper_command - lower_command) + delta = (lower_rotation.inv() * upper_rotation).as_rotvec() + return lower_rotation * Rotation.from_rotvec(delta * fraction) + + nearest_command = min(rotations, key=lambda command: abs(command - zero)) + if maximum_distance_u8 is None or abs( + nearest_command - zero + ) <= float(maximum_distance_u8): + return rotations[nearest_command] + return None + + +def _near_zero_records( + records: Sequence[Mapping[str, Any]], zero_command_u8: int +) -> list[Mapping[str, Any]]: + groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} + for record in records: + groups.setdefault(_reference_group_key(record), []).append(record) + selected: list[Mapping[str, Any]] = [] + zero = float(zero_command_u8) + for group in groups.values(): + distance = min(abs(_spatial_input(record) - zero) for record in group) + if "input_value" not in group[0] and distance > ZERO_REFERENCE_MAXIMUM_DISTANCE_U8: + continue + selected.extend( + record + for record in group + if abs(_spatial_input(record) - zero) == distance + ) + return selected + + +def _baseline_reference( + records: Sequence[Mapping[str, Any]], + zero_command_u8: int, + maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8, +) -> tuple[float, float, float, float]: + groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} + for record in records: + groups.setdefault(_reference_group_key(record), []).append(record) + values = [ + rotation.as_quat() + for group in groups.values() + if ( + rotation := _interpolate_reference_rotation( + group, + zero_command_u8, + maximum_distance_u8, + ) + ) + is not None + ] + if not values: + distance = ( + "the observed physical stroke" + if maximum_distance_u8 is None + else f"{int(maximum_distance_u8)} commands of zero {zero_command_u8}" + ) + raise ValueError(f"joint records have no samples within {distance}") + return robust_rotation_summary(values)[0] + + +def baseline_hysteresis_by_cycle_rad( + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, + axis_xyz: Sequence[float] | None = None, +) -> tuple[float, ...]: + """Return decreasing/increasing joint-angle disagreement per cycle. + + Full SO(3) disagreement includes planar-PnP tilt noise that is orthogonal + to the fitted revolute axis. When an axis is supplied, report only the + physically meaningful component about that axis; the orthogonal component + remains covered by the independent rotation-model residual gate. + """ + samples = [dict(record) for record in records] + axis: np.ndarray | None = None + if axis_xyz is not None: + axis = _vector(axis_xyz, 3, name="baseline hysteresis axis") + norm = float(np.linalg.norm(axis)) + if norm <= 0.0: + raise ValueError("baseline hysteresis axis must be non-zero") + axis /= norm + result: list[float] = [] + for cycle in sorted({int(record["cycle"]) for record in samples}): + rotations: dict[str, Rotation] = {} + for direction in ("decreasing", "increasing"): + selected = [ + record + for record in samples + if int(record["cycle"]) == cycle + and str(record["direction"]) == direction + ] + reference = _interpolate_reference_rotation( + selected, zero_command_u8 + ) + if reference is None: + raise ValueError( + f"cycle {cycle} {direction} is missing baseline samples" + ) + rotations[direction] = reference + delta = ( + rotations["decreasing"].inv() + * rotations["increasing"] + ) + result.append( + float( + delta.magnitude() + if axis is None + else abs(float(delta.as_rotvec() @ axis)) + ) + ) + if not result: + raise ValueError("baseline hysteresis requires at least one cycle") + return tuple(result) + + +def fit_rotation_joint_curve( + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, + canonical_zero_direction: str | None = None, + require_observed_domain_endpoints: bool = True, + zero_reference_maximum_distance_u8: int | None = ( + ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 + ), +) -> JointCurveFit: + """Fit a direction-aware curve from parent-to-child Tag orientations. + + With ``canonical_zero_direction`` both branches share one physical + reference. Only the canonical branch is zero at ``zero_command_u8``; + the other branch retains its measured backlash/compliance offset. + + Byte-feedback products keep the default requirement that both exact + 0/255 endpoints were observed. Physical-angle products may set + ``require_observed_domain_endpoints=False`` after their acquisition policy + has independently proved feedback travel and coverage; the dense internal + curve then uses bounded edge extrapolation instead of inventing endpoint + feedback samples. + """ + samples = [dict(record) for record in records] + if len(samples) < 12: + raise ValueError("rotation trajectory requires at least 12 samples") + vectors: list[np.ndarray] = [] + commands: list[int] = [] + values_by_record: list[float] = [] + # One installation/reference for the entire training session. Recentring + # each cycle would absorb physical Tag slip into nuisance mounting angles. + reference = Rotation.from_quat(_baseline_reference( + _canonical_reference_records(samples, canonical_zero_direction), + zero_command_u8, zero_reference_maximum_distance_u8, + )) + for record in samples: + observed = Rotation.from_quat(_relative_rotation(record)) + vector = ( + reference.inv() * observed + ).as_rotvec() + vectors.append(vector) + commands.append(int(record["command_u8"])) + axis = fit_rotation_axis(vectors, commands) + values_by_record = [float(vector @ axis) for vector in vectors] + curves, correction, hysteresis = _fit_joint_curve( + samples, + values_by_record, + preserve_direction_offset=True, + require_observed_domain_endpoints=require_observed_domain_endpoints, + ) + canonical_key = "angle_rad" if canonical_zero_direction is None else f"{canonical_zero_direction}_rad" + shared_zero = float(curves[canonical_key][int(zero_command_u8)]) + for key in ("angle_rad", "decreasing_rad", "increasing_rad"): + values = np.asarray(curves[key], dtype=float) - shared_zero + curves[key] = [round(float(value), 8) for value in values] + # Keep the stored measurement reference in exactly the same coordinate + # gauge as the shifted curve; do not apply this shift twice at validation. + reference = reference * Rotation.from_rotvec(axis * shared_zero) + if canonical_zero_direction is not None: + # Before the bridge has observed motion direction it must use the + # same branch that defines the URDF physical zero, never an average + # pose that the mechanism may not be able to occupy. + curves["angle_rad"] = list(curves[canonical_key]) + orthogonal = [ + float(np.linalg.norm(vector - float(vector @ axis) * axis)) + for vector in vectors + ] + return JointCurveFit( + angle_rad=tuple(float(value) for value in curves["angle_rad"]), + decreasing_rad=tuple(float(value) for value in curves["decreasing_rad"]), + increasing_rad=tuple(float(value) for value in curves["increasing_rad"]), + circle={ + "space": "relative_rotation_3d", + "axis_xyz": [float(value) for value in axis], + "zero_command_u8": int(zero_command_u8), + "reference_quaternion_xyzw": [ + float(value) for value in reference.as_quat() + ], + "canonical_zero_direction": canonical_zero_direction, + "reference_policy": "training_frozen_installation_v1", + "training_cycles": sorted({int(record["cycle"]) for record in samples}), + }, + maximum_monotonic_correction_rad=float(correction), + maximum_hysteresis_rad=float(hysteresis), + quality={ + "rotation_orthogonal_rms_rad": float( + np.sqrt(np.mean(np.square(orthogonal))) + ), + "arc_rad": float( + max(curves["angle_rad"]) - min(curves["angle_rad"]) + ), + }, + ) + + +def measure_rotation_joint_observation( + fit: JointCurveFit, quaternion_xyzw: Sequence[float] +) -> float: + """Measure one parent-to-child orientation with a fitted 3-D curve.""" + if fit.circle.get("space") != "relative_rotation_3d": + raise ValueError("joint fit is not a relative-rotation curve") + reference = Rotation.from_quat( + _vector( + fit.circle["reference_quaternion_xyzw"], + 4, + name="reference quaternion", + ) + ) + observed = Rotation.from_quat( + _vector(quaternion_xyzw, 4, name="observed quaternion") + ) + axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") + axis /= np.linalg.norm(axis) + return float((reference.inv() * observed).as_rotvec() @ axis) + + +def measure_joint_curve_observation( + fit: JointCurveFit, + *, + quaternion_xyzw: Sequence[float] | None = None, + image_relative_xy_px: Sequence[float] | None = None, +) -> float: + """Measure one observation in the same space as its fitted curve.""" + if fit.circle.get("space") == "relative_rotation_3d": + if quaternion_xyzw is None: + raise ValueError("rotation observation quaternion is missing") + return measure_rotation_joint_observation(fit, quaternion_xyzw) + if fit.circle.get("space") != "image_2d": + raise ValueError("unsupported joint curve observation representation") + if image_relative_xy_px is None: + raise ValueError("image curve observation point is missing") + point = np.asarray(image_relative_xy_px, dtype=float) + centre = np.asarray(fit.circle["center_xy_px"], dtype=float) + reference = np.asarray(fit.circle["reference_xy_px"], dtype=float) + vector = point - centre + if ( + point.shape != (2,) + or not np.all(np.isfinite(point)) + or float(np.linalg.norm(vector)) < 1.0e-9 + ): + raise ValueError("image curve observation point is invalid") + return float(fit.circle["orientation_sign"]) * math.atan2( + float(reference[0] * vector[1] - reference[1] * vector[0]), + float(reference @ vector), + ) + + +def rotation_curve_holdout_errors( + fit: JointCurveFit, + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, + zero_reference_maximum_distance_u8: int | None = ( + ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 + ), +) -> tuple[float, ...]: + """Validate against the frozen training reference, never refit holdout. + + The distance argument is retained for legacy callers but deliberately + cannot select a new reference from validation samples. + """ + samples = [dict(record) for record in records] + if not samples: + raise ValueError("holdout records are empty") + if int(fit.circle["zero_command_u8"]) != int(zero_command_u8): + raise ValueError("holdout cannot change the fitted zero coordinate") + training_cycles = set(fit.circle.get("training_cycles", ())) + if any(int(row["cycle"]) in training_cycles for row in samples): + raise ValueError("training cycles cannot enter independent holdout") + reference = Rotation.from_quat(_vector( + fit.circle["reference_quaternion_xyzw"], 4, name="frozen reference quaternion")) + axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") + axis /= np.linalg.norm(axis) + errors: list[float] = [] + for record in samples: + observed = Rotation.from_quat(_relative_rotation(record)) + angle = float((reference.inv() * observed).as_rotvec() @ axis) + command = int(record["command_u8"]) + direction = str(record["direction"]) + if not 0 <= command <= 255 or direction not in {"increasing", "decreasing"}: + raise ValueError("holdout SDK input or direction is invalid") + expected_curve = ( + fit.decreasing_rad + if direction == "decreasing" + else fit.increasing_rad + ) + expected = float(expected_curve[command]) + predicted = reference * Rotation.from_rotvec(axis * expected) + magnitude = float((predicted.inv() * observed).magnitude()) + # Include off-axis installation slip without losing the historical + # signed phase-error diagnostic used by reports. + errors.append(math.copysign(magnitude, angle - expected)) + return tuple(errors) + + +def joint_curve_holdout_errors( + fit: JointCurveFit, + records: Sequence[Mapping[str, Any]], + *, + zero_command_u8: int, +) -> tuple[float, ...]: + """Validate either a rotation or fixed-parent image-circle curve.""" + if fit.circle.get("space") == "relative_rotation_3d": + return rotation_curve_holdout_errors( + fit, records, zero_command_u8=zero_command_u8 + ) + if fit.circle.get("space") != "image_2d": + raise ValueError("unsupported joint curve holdout representation") + if not records: + raise ValueError("holdout records are empty") + errors: list[float] = [] + for record in records: + observed = measure_joint_curve_observation( + fit, + image_relative_xy_px=record["image_relative_xy_px"], + ) + command = int(record["command_u8"]) + direction = str(record["direction"]) + expected_curve = ( + fit.decreasing_rad + if direction == "decreasing" + else fit.increasing_rad + ) + errors.append(observed - float(expected_curve[command])) + return tuple(errors) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/solve.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/solve.py new file mode 100644 index 0000000..85974f3 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/solve.py @@ -0,0 +1,103 @@ +"""Public spatial zero pipeline with frozen training and independent validation.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Mapping, Sequence +import math + +from ...domain.measurement import JointCurveFit +from .acceptance import assemble_result, decide_acceptance +from .optimization import solve_selected +from .preparation import prepare_offset_limits, prepare_zero_problem +from .residuals import ObservationGeometry +from .stages import TrainingFit, TrainingProblem, ZeroSolveOptions +from .statistics import estimate_cycle_evidence, estimate_observability +from .types import ( + JointAxisMeasurement, + PalmOrientationMeasurement, + ZeroCalibrationProfile, + ZeroSolveResult, +) +from .validation import check_observation_geometry, validate_axis_lines, validate_holdout + + +def solve_urdf_zero_offsets( + *, + source_urdf: str | Path, + measurements: Sequence[JointAxisMeasurement], + palm_orientation_measurements: Sequence[PalmOrientationMeasurement] = (), + curves: Mapping[str, JointCurveFit], + motor_by_joint: Mapping[str, int], + training_cycles: Sequence[int] = (0, 1), + validation_cycle: int = 2, + maximum_offset_rad: float = math.radians(20.0), + finger_maximum_offset_rad: float = math.radians(3.0), + joint_maximum_offset_rad: Mapping[str, float] | None = None, + maximum_cycle_difference_rad: float = math.radians(0.75), + minimum_applied_offset_rad: float = math.radians(0.3), + significance_sigma: float = 3.0, + maximum_validation_mae_rad: float = math.radians(1.0), + maximum_validation_p95_rad: float = math.radians(2.0), + maximum_validation_error_rad: float | None = None, + maximum_confidence_half_width_rad: float | None = None, + maximum_axis_cone_mismatch_rad: float = math.radians(5.0), + maximum_systematic_axis_cone_bias_rad: float | None = None, + maximum_pose_axis_line_rms_m: float = 0.001, + maximum_observability_condition_number: float = 10000000000.0, + hand_type: str | None = None, + tag_layout: str | None = None, + fixed_direct_zero_offsets_rad: Mapping[str, float] | None = None, + static_output_zero_offsets_rad: Mapping[str, float] | None = None, + zero_profile: ZeroCalibrationProfile | None = None, +) -> ZeroSolveResult: + """Prepare, fit training only, validate the frozen result, then assemble.""" + options = ZeroSolveOptions( + maximum_offset_rad=maximum_offset_rad, + finger_maximum_offset_rad=finger_maximum_offset_rad, + joint_maximum_offset_rad=joint_maximum_offset_rad, + maximum_cycle_difference_rad=maximum_cycle_difference_rad, + minimum_applied_offset_rad=minimum_applied_offset_rad, + significance_sigma=significance_sigma, + maximum_validation_mae_rad=maximum_validation_mae_rad, + maximum_validation_p95_rad=maximum_validation_p95_rad, + maximum_validation_error_rad=maximum_validation_error_rad, + maximum_confidence_half_width_rad=maximum_confidence_half_width_rad, + maximum_axis_cone_mismatch_rad=maximum_axis_cone_mismatch_rad, + maximum_systematic_axis_cone_bias_rad=maximum_systematic_axis_cone_bias_rad, + maximum_pose_axis_line_rms_m=maximum_pose_axis_line_rms_m, + maximum_observability_condition_number=maximum_observability_condition_number, + ) + problem = prepare_zero_problem( + options=options, + source_urdf=source_urdf, + measurements=measurements, + palm_orientation_measurements=palm_orientation_measurements, + training_cycles=training_cycles, + validation_cycle=validation_cycle, + hand_type=hand_type, + tag_layout=tag_layout, + fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets_rad, + static_output_zero_offsets_rad=static_output_zero_offsets_rad, + zero_profile=zero_profile, + ) + geometry = ObservationGeometry(problem.profile, problem.model, curves, motor_by_joint, problem.orientation_by_model_cycle) + training_geometry = geometry.for_cycles(training_cycles) + training_problem = TrainingProblem(problem.profile, problem.training, problem.fixed_offsets, problem.zero_offsets) + limits = prepare_offset_limits(problem=problem, options=options) + rotation, translation, offsets = solve_selected( + problem=training_problem, limits=limits, geometry=training_geometry, selected=problem.training) + fit = TrainingFit(rotation, translation, offsets) + observability = estimate_observability(problem=training_problem, fit=fit, geometry=training_geometry) + geometry_evidence = check_observation_geometry(problem=problem, options=options, + geometry=geometry, selected=list(measurements), offsets=offsets, rotation=rotation) + cycles = estimate_cycle_evidence(problem=training_problem, limits=limits, fit=fit, options=options, + geometry=training_geometry, measurements=problem.training, training_cycles=training_cycles) + holdout = validate_holdout(problem=problem, fit=fit, cycles=cycles, options=options, + geometry=geometry, measurements=measurements) + lines = validate_axis_lines(problem=problem, fit=fit, cycles=cycles, geometry=geometry) + decision = decide_acceptance(problem=problem, limits=limits, fit=fit, options=options, + observability=observability, geometry_evidence=geometry_evidence, cycles=cycles, holdout=holdout) + return assemble_result(problem=problem, limits=limits, fit=fit, observability=observability, + geometry_evidence=geometry_evidence, cycles=cycles, holdout=holdout, lines=lines, + decision=decision, validation_cycle=validation_cycle) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/stages.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/stages.py new file mode 100644 index 0000000..e9f4cc9 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/stages.py @@ -0,0 +1,121 @@ +"""Explicit inputs and evidence passed between spatial solution stages.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +from scipy.spatial.transform import Rotation +import numpy as np + +from ...urdf.kinematics import UrdfKinematicModel +from .types import JointAxisMeasurement, PalmOrientationMeasurement, ZeroCalibrationProfile + + +@dataclass(frozen=True) +class ZeroProblem: + profile: ZeroCalibrationProfile + model: UrdfKinematicModel + training: Sequence[JointAxisMeasurement] + validation: Sequence[JointAxisMeasurement] + configured_palm_sources: Mapping[str, str] + orientation_validation: Sequence[PalmOrientationMeasurement] + orientation_by_model_cycle: Mapping[tuple[str, int], PalmOrientationMeasurement] + joint_limits: Mapping[str, float] + fixed_offsets: Mapping[str, float] + output_offsets: Mapping[str, float] + zero_offsets: Mapping[str, float] + + +@dataclass(frozen=True) +class TrainingProblem: + """Training-only view: the optimizer cannot access holdout observations.""" + + profile: ZeroCalibrationProfile + training: Sequence[JointAxisMeasurement] + fixed_offsets: Mapping[str, float] + zero_offsets: Mapping[str, float] + + +@dataclass(frozen=True) +class OffsetLimits: + product_finger_rolls: tuple[str, ...] + offset_limits: np.ndarray + diagnostic_offset_limits: np.ndarray + + +@dataclass(frozen=True) +class TrainingFit: + base_rotation: Rotation + base_translation: np.ndarray + training_offsets: Mapping[str, float] + + +@dataclass(frozen=True) +class ObservabilityEvidence: + observability_rank: int + observability_parameter_count: int + observability_condition_number: float + offset_covariance: Mapping[str, float] + + +@dataclass(frozen=True) +class GeometryEvidence: + observation_failures: Mapping[str, str] + axis_cone_mismatch_by_joint: Mapping[str, float] + axis_cone_bias_classification_by_joint: Mapping[str, str] + + +@dataclass(frozen=True) +class CycleEvidence: + cycle_values: Mapping[str, Sequence[float]] + cycle_models: Mapping[int, tuple[Rotation, np.ndarray]] + training_cycle_ids: tuple[int, ...] + uncertainties: Mapping[str, float] + confidence_half_widths: Mapping[str, float] + cycle_consistent: bool + inconsistent_cycles: Sequence[str] + insignificant_large: Sequence[str] + applied_training: Mapping[str, float] + + +@dataclass(frozen=True) +class HoldoutEvidence: + validation_error_by_joint: Mapping[str, float] + original_error_by_joint: Mapping[str, float] + improvement_by_joint: Mapping[str, float] + improvement_confidence_lower: Mapping[str, float] + improvement_passed: bool + palm_orientation_validation_passed: bool + validation_errors: np.ndarray + + +@dataclass(frozen=True) +class LineEvidence: + validation_line_error_by_joint: Mapping[str, float] + axis_line_rms: float + + +@dataclass(frozen=True) +class AcceptanceDecision: + finger_roll_common_mode: float + failure_reasons: Mapping[str, str] + passed: bool + + +@dataclass(frozen=True) +class ZeroSolveOptions: + maximum_offset_rad: float + finger_maximum_offset_rad: float + joint_maximum_offset_rad: Mapping[str, float] | None + maximum_cycle_difference_rad: float + minimum_applied_offset_rad: float + significance_sigma: float + maximum_validation_mae_rad: float + maximum_validation_p95_rad: float + maximum_validation_error_rad: float | None + maximum_confidence_half_width_rad: float | None + maximum_axis_cone_mismatch_rad: float + maximum_systematic_axis_cone_bias_rad: float | None + maximum_pose_axis_line_rms_m: float + maximum_observability_condition_number: float diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/statistics.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/statistics.py new file mode 100644 index 0000000..6ad3f2d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/statistics.py @@ -0,0 +1,249 @@ +"""Training observability, cycle repeatability and offset confidence.""" + +from __future__ import annotations + +from functools import partial +from typing import Sequence +import math + +from scipy.spatial.transform import Rotation +from scipy.stats import t as student_t +import numpy as np + +from .optimization import optimise_offsets +from .residuals import ObservationGeometry +from .stages import ( + CycleEvidence, + ObservabilityEvidence, + OffsetLimits, + TrainingFit, + TrainingProblem, + ZeroSolveOptions, +) +from .types import JointAxisMeasurement + + +def observability_residual( + parameters: np.ndarray, + *, + problem: TrainingProblem, + geometry: ObservationGeometry, +): + profile = problem.profile + training = problem.training + predicted_local = geometry.predicted_local + + rotation = Rotation.from_rotvec(parameters[:3]) + translation = parameters[3:6] * 0.05 + offsets = { + name: float(value) + for name, value in zip( + profile.direct_zero_joints, parameters[6:] + ) + } + residuals: list[float] = [] + for item in training: + predicted_axis, predicted_point = predicted_local(item, offsets) + predicted_axis = rotation.apply(predicted_axis) + predicted_axis /= np.linalg.norm(predicted_axis) + predicted_point = rotation.apply(predicted_point) + translation + observed_axis = np.asarray(item.axis_common_xyz, dtype=float) + observed_axis /= np.linalg.norm(observed_axis) + if float(predicted_axis @ observed_axis) < 0.0: + observed_axis = -observed_axis + observed_point = np.asarray(item.point_common_xyz_m, dtype=float) + predicted_moment = np.cross(predicted_point, predicted_axis) + observed_moment = np.cross(observed_point, observed_axis) + residuals.extend(float(value) for value in predicted_axis - observed_axis) + residuals.extend( + float(value) / 0.05 + for value in predicted_moment - observed_moment + ) + return np.asarray(residuals, dtype=float) + + +def estimate_observability( + *, + fit: TrainingFit, + problem: TrainingProblem, + geometry: ObservationGeometry, +) -> ObservabilityEvidence: + base_rotation = fit.base_rotation + base_translation = fit.base_translation + profile = problem.profile + training_offsets = fit.training_offsets + + residual = partial(observability_residual, problem=problem, geometry=geometry) + observability_parameters = np.asarray( + [ + *base_rotation.as_rotvec(), + *(base_translation / 0.05), + *( + training_offsets[name] + for name in profile.direct_zero_joints + ), + ], + dtype=float, + ) + observability_base_residual = residual( + observability_parameters + ) + observability_jacobian = np.empty( + ( + observability_base_residual.size, + observability_parameters.size, + ), + dtype=float, + ) + finite_difference_step = 1.0e-6 + for column in range(observability_parameters.size): + positive = observability_parameters.copy() + negative = observability_parameters.copy() + positive[column] += finite_difference_step + negative[column] -= finite_difference_step + observability_jacobian[:, column] = ( + residual(positive) + - residual(negative) + ) / (2.0 * finite_difference_step) + singular_values = np.linalg.svd( + observability_jacobian, compute_uv=False + ) + singular_threshold = ( + 0.0 + if singular_values.size == 0 + else float(singular_values[0]) * 1.0e-7 + ) + observability_rank = int( + np.count_nonzero(singular_values > singular_threshold) + ) + observability_parameter_count = int(observability_parameters.size) + observability_condition_number = ( + float("inf") + if observability_rank < observability_parameter_count + else float(singular_values[0] / singular_values[-1]) + ) + residual_dof = max( + 1, + observability_base_residual.size - observability_parameter_count, + ) + residual_variance = float( + observability_base_residual @ observability_base_residual + ) / residual_dof + covariance = residual_variance * np.linalg.pinv( + observability_jacobian.T @ observability_jacobian, + rcond=1.0e-12, + ) + offset_covariance = { + name: max(0.0, float(covariance[6 + index, 6 + index])) + for index, name in enumerate(profile.direct_zero_joints) + } + + return ObservabilityEvidence( + observability_rank=observability_rank, + observability_parameter_count=observability_parameter_count, + observability_condition_number=observability_condition_number, + offset_covariance=offset_covariance, + ) + + +def estimate_cycle_evidence( + measurements: Sequence[JointAxisMeasurement], + training_cycles: Sequence[int], + *, + limits: OffsetLimits, + fit: TrainingFit, + options: ZeroSolveOptions, + problem: TrainingProblem, + geometry: ObservationGeometry, +) -> CycleEvidence: + base_rotation = fit.base_rotation + base_translation = fit.base_translation + fixed_offsets = problem.fixed_offsets + maximum_confidence_half_width_rad = options.maximum_confidence_half_width_rad + maximum_cycle_difference_rad = options.maximum_cycle_difference_rad + minimum_applied_offset_rad = options.minimum_applied_offset_rad + profile = problem.profile + significance_sigma = options.significance_sigma + training_offsets = fit.training_offsets + + fit_offsets = partial(optimise_offsets, geometry=geometry, problem=problem, limits=limits) + cycle_values: dict[str, list[float]] = { + name: [] for name in profile.direct_zero_joints + } + cycle_models: dict[int, tuple[Rotation, np.ndarray]] = {} + training_cycle_ids = tuple(sorted({int(value) for value in training_cycles})) + for cycle in training_cycle_ids: + selected = [item for item in measurements if item.cycle == cycle] + # Keep one palm pose while comparing cycles. Refitting a base pose from + # only two nearly parallel root axes per cycle makes harmless root-line + # noise appear as a large encoder-zero change. + cycle_rotation, cycle_translation = base_rotation, base_translation + cycle_offsets = fit_offsets( + selected, + cycle_rotation, + cycle_translation, + initial=training_offsets, + ) + cycle_models[cycle] = (cycle_rotation, cycle_translation) + for name, value in cycle_offsets.items(): + cycle_values[name].append(value) + uncertainties: dict[str, float] = {} + confidence_half_widths: dict[str, float] = {} + cycle_consistent = True + inconsistent_cycles: list[str] = [] + for name, values in cycle_values.items(): + array = np.asarray(values, dtype=float) + spread = float(np.max(array) - np.min(array)) + if spread > maximum_cycle_difference_rad: + cycle_consistent = False + inconsistent_cycles.append(name) + uncertainties[name] = ( + 0.0 + if array.size < 2 + else float(np.std(array, ddof=1) / math.sqrt(array.size)) + ) + confidence_half_widths[name] = ( + float("inf") + if array.size < 2 + else float( + student_t.ppf(0.975, df=array.size - 1) + * uncertainties[name] + ) + ) + + insignificant_large: list[str] = [] + applied_training: dict[str, float] = {} + for name, value in training_offsets.items(): + if name in fixed_offsets: + applied_training[name] = fixed_offsets[name] + continue + uncertainty = uncertainties[name] + confidence_half_width = confidence_half_widths[name] + if ( + abs(value) < minimum_applied_offset_rad + or abs(value) + <= max(significance_sigma * uncertainty, confidence_half_width) + ): + applied_training[name] = 0.0 + validated_zero_candidate = bool( + profile.accept_validated_zero_in_confidence_interval + and maximum_confidence_half_width_rad is not None + and abs(value) <= confidence_half_width + and confidence_half_width <= maximum_confidence_half_width_rad + ) + if abs(value) >= minimum_applied_offset_rad and not validated_zero_candidate: + insignificant_large.append(name) + else: + applied_training[name] = value + + return CycleEvidence( + cycle_values=cycle_values, + cycle_models=cycle_models, + training_cycle_ids=training_cycle_ids, + uncertainties=uncertainties, + confidence_half_widths=confidence_half_widths, + cycle_consistent=cycle_consistent, + inconsistent_cycles=inconsistent_cycles, + insignificant_large=insignificant_large, + applied_training=applied_training, + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/types.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/types.py new file mode 100644 index 0000000..140da8c --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/types.py @@ -0,0 +1,175 @@ +"""Types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Protocol, Sequence + + +class SpatialHandGeometry(Protocol): + side: str + layout_id: str + active_joints: Sequence[str] + reference_finger: str + palm_orientation_sources: Mapping[str, str] + minimum_palm_orientation_sources: int + stable_cross_view_cone_bias: bool + + +@dataclass(frozen=True) +class ZeroCalibrationProfile: + hand: SpatialHandGeometry + direct_zero_joints: tuple[str, ...] + axis_joints: tuple[str, ...] + # Dynamic command-angle curves may be shared by unobserved fingers. This + # mapping is intentionally *not* an authorization to copy an absolute + # encoder/URDF zero between independent motors. + inherited_zero_joints: Mapping[str, str] + # Absolute static-zero inheritance requires an independent mechanical + # guarantee. With the current 11-Tag layout only the reference finger is + # observed, so the conservative mapping is empty and unobserved active + # finger origins retain their source-CAD zero. + inherited_static_zero_joints: Mapping[str, str] + constrained_circle_joints: frozenset[str] + root_anchor_joints: frozenset[str] + axis_parent_joint: Mapping[str, str] + phase_parent_joint: Mapping[str, str] + offset_observer_joint: Mapping[str, str] + # Some serial offsets can be observed from the angle between two axes + # captured by the same camera. The mapping is geometric topology only; + # it never contains a model- or serial-specific zero value. + same_view_axis_pair_by_offset: Mapping[str, tuple[str, str]] + fixed_direct_zero_offsets_rad: Mapping[str, float] + static_output_zero_offsets_rad: Mapping[str, float] + # Product full-hand fitting uses the five root-axis line pattern. The + # thumb kernel instead uses only the serial thumb chain, so its nuisance + # palm pose cannot be influenced by finger observations. + base_pose_strategy: str = "full_hand" + # A serial-chain model may use a separate palm-root joint axis to fix the + # otherwise free rotation about its primary root axis. G20 retains its + # historical defaults; other model profiles can name the physical anchor + # explicitly without introducing model-specific branches in the solver. + orientation_anchor_joint: str | None = None + # A model whose feedback direction is mechanically reviewed may use the + # signed rotation axes to disambiguate the otherwise mirrored palm-frame + # branches. The default remains undirected for legacy G20/L6 profiles. + directed_base_axis_joints: frozenset[str] = frozenset() + # A profile may retain zero when a *bounded training confidence interval* + # contains it. The frozen zero still faces every geometry/cycle/holdout + # check below. Default False preserves existing G20/L6/O6 decisions. + accept_validated_zero_in_confidence_interval: bool = False + # Axis-line points have no unique coordinate along the axis. Remove that + # gauge before projecting a parallel-axis phase into the camera plane. + # Opt in explicitly while legacy profiles retain their reviewed policy. + project_axis_gauge_before_image: bool = False + + parallel_root_pattern: bool = False + common_mode_zero_joints: tuple[str, ...] = () + small_offset_joints: frozenset[str] = frozenset() + + @property + def reference_finger(self) -> str: + return self.hand.reference_finger + + +@dataclass(frozen=True) +class JointAxisMeasurement: + joint: str + cycle: int + axis_common_xyz: tuple[float, float, float] + point_common_xyz_m: tuple[float, float, float] + condition_state_u8: tuple[float, ...] + plane_rms_m: float + radial_rms_m: float + rotation_circle_axis_difference_rad: float + # Optical-axis direction expressed in the shared calibration frame. It + # lets the phase solver discard the least reliable monocular-PnP depth + # component when the joint axis is viewed approximately end-on. Older + # recordings and synthetic callers may omit it and retain the 3-D path. + view_normal_common_xyz: tuple[float, float, float] | None = None + # Logical commands that produced the condition. Firmware feedback may + # saturate a few u8 short of an endpoint (for example right motor 10 reads + # 250 for command 255); kinematics must use the command-indexed curve while + # retaining condition_state_u8 for diagnostics and safety. + condition_command_u8: tuple[float, ...] | None = None + axis_direction_source: str = "unspecified" + circle_axis_observability: float = 0.0 + axis_point_source: str = "circle_center" + pose_axis_line_rms_m: float = 0.0 + # Unprojected residuals are evidence, not extra phase observations. The + # axial component lies in the null space of (I-R) for a revolute axis. + pose_axis_line_raw_rms_m: float | None = None + pose_axis_line_axial_rms_m: float | None = None + pose_axis_line_transverse_rms_m: float | None = None + axis_point_axial_component_separated: bool = False + # Measurement record(s) that supplied pose_axis_line_rms_m. A combined + # cross-view axis may keep the front direction but take its physical line + # point and line-quality residual from the side alias. Retry logic must + # clear the actual quality source instead of blindly rescanning ``joint``. + pose_axis_line_source_joints: tuple[str, ...] = () + # Camera centre that observed ``point_common_xyz_m``. When populated, + # the zero solver can use only the ray from this centre to the fitted axis + # point. That ray is the depth-free interpretation-plane observation of + # the physical axis; translating a monocular planar-PnP solution along + # its optical ray therefore cannot rotate the recovered palm frame. + axis_point_camera_center_common_xyz_m: ( + tuple[float, float, float] | None + ) = None + # Normal of the source camera's interpretation plane for this axis line. + # Unlike a 3-D PnP line point, the plane is unchanged by optical-depth + # error. Several named parallel root lines jointly recover their common + # physical direction as the null direction of these plane normals. + axis_point_interpretation_plane_normal_common_xyz: ( + tuple[float, float, float] | None + ) = None + + +@dataclass(frozen=True) +class PalmOrientationMeasurement: + """Direction-only joint observation from a partially visible sweep. + + The moving Tag may disappear before the motor reaches its far endpoint. + Only the relative SO(3) trajectory is retained, so fixed Tag translation + and mounting rotation cannot define the palm phase. + """ + + source_joint: str + model_joint: str + cycle: int + axis_common_xyz: tuple[float, float, float] + condition_state_u8: tuple[float, ...] + observed_arc_rad: float + rotation_orthogonal_rms_rad: float + axis_estimator: str = "baseline_relative_so3" + incremental_pair_count: int = 0 + + +@dataclass(frozen=True) +class ZeroSolveResult: + direct_offsets_rad: Mapping[str, float] + all_active_offsets_rad: Mapping[str, float] + base_translation_xyz_m: tuple[float, float, float] + base_quaternion_xyzw: tuple[float, float, float, float] + validation_errors_rad: tuple[float, ...] + validation_error_by_joint_rad: Mapping[str, float] + validation_line_error_by_joint_m: Mapping[str, float] + axis_line_rms_m: float + passed: bool + cycle_offsets_rad: Mapping[str, tuple[float, ...]] + offset_uncertainty_rad: Mapping[str, float] + offset_confidence_half_width_rad: Mapping[str, float] + training_cycles: tuple[int, ...] + validation_cycle: int + validation_original_error_by_joint_rad: Mapping[str, float] + validation_improvement_by_joint_rad: Mapping[str, float] + validation_improvement_confidence_lower_rad: Mapping[str, float] + observability_rank: int + observability_parameter_count: int + observability_condition_number: float + offset_covariance_rad2: Mapping[str, float] + axis_cone_mismatch_by_joint_rad: Mapping[str, float] + axis_cone_bias_classification_by_joint: Mapping[str, str] + failure_reasons: Mapping[str, str] + # Optional diagnostic evidence from an explicitly selected observation + # policy; does not change the generic solver's acceptance decision. + axis_residual_diagnostics: Mapping[str, Mapping[str, Any]] | None = None diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/validation.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/validation.py new file mode 100644 index 0000000..42b0bcb --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/spatial_solver/validation.py @@ -0,0 +1,403 @@ +"""Independent holdout and geometric consistency checks of the frozen fit.""" + +from __future__ import annotations + +from typing import Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +from scipy.stats import t as student_t +import numpy as np + +from .geometry_helpers import _angles_from_state, _axis_cone_mismatch_rad +from .residuals import ObservationGeometry +from .stages import ( + CycleEvidence, + GeometryEvidence, + HoldoutEvidence, + LineEvidence, + TrainingFit, + ZeroProblem, + ZeroSolveOptions, +) +from .types import JointAxisMeasurement + + +def check_observation_geometry( + selected: Sequence[JointAxisMeasurement], + offsets: Mapping[str, float], + rotation: Rotation, + *, + options: ZeroSolveOptions, + problem: ZeroProblem, + geometry: ObservationGeometry, +): + fixed_offsets = problem.fixed_offsets + maximum_axis_cone_mismatch_rad = options.maximum_axis_cone_mismatch_rad + maximum_confidence_half_width_rad = options.maximum_confidence_half_width_rad + maximum_cycle_difference_rad = options.maximum_cycle_difference_rad + maximum_pose_axis_line_rms_m = options.maximum_pose_axis_line_rms_m + maximum_systematic_axis_cone_bias_rad = options.maximum_systematic_axis_cone_bias_rad + model = problem.model + profile = problem.profile + curves = geometry.curves + motor_by_joint = geometry.motor_by_joint + + axis_cone_mismatch_by_joint: dict[str, float] = {} + axis_cone_bias_classification_by_joint: dict[str, str] = {} + """Reject repeatable but geometrically inadmissible zero observers.""" + by_key = {(item.joint, item.cycle): item for item in selected} + failures: dict[str, str] = {} + for offset_joint, observer_joint in profile.offset_observer_joint.items(): + if offset_joint in fixed_offsets: + continue + observer_items = [ + item for item in selected if item.joint == observer_joint + ] + if observer_joint in profile.axis_parent_joint: + parent_joint = profile.axis_parent_joint[observer_joint] + cone_mismatches: list[float] = [] + for item in observer_items: + state = ( + item.condition_state_u8 + if item.condition_command_u8 is None + else item.condition_command_u8 + ) + angles = _angles_from_state( + state, + curves=curves, + motor_by_joint=motor_by_joint, + inherited_zero_joints=( + profile.inherited_zero_joints + ), + ) + predicted_axis, _ = model.axis_line( + observer_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + parent_axis, _ = model.axis_line( + parent_joint, + zero_offsets=offsets, + joint_angles=angles, + ) + cone_mismatches.append( + _axis_cone_mismatch_rad( + rotation.apply(predicted_axis), + item.axis_common_xyz, + rotation.apply(parent_axis), + ) + ) + maximum_cone_mismatch = max(cone_mismatches) + axis_cone_mismatch_by_joint[offset_joint] = ( + maximum_cone_mismatch + ) + if maximum_cone_mismatch > maximum_axis_cone_mismatch_rad: + cone_range = float(np.ptp(cone_mismatches)) + stable_product_bias = bool( + profile.hand.stable_cross_view_cone_bias + and maximum_systematic_axis_cone_bias_rad is not None + and len(cone_mismatches) >= 4 + and maximum_cone_mismatch + <= maximum_systematic_axis_cone_bias_rad + and cone_range <= maximum_cycle_difference_rad + ) + if stable_product_bias: + # A parent zero rotates the downstream direction + # around the parent axis and cannot change their cone + # angle. The zero-sensitive residual above already + # projects both directions onto the parent-normal + # plane, so a repeatable cross-camera/planar-PnP cone + # bias cannot corrupt the written encoder zero. Keep + # it visible in the result while retaining the gross + # gate for a wrong axis, loose Tag, or moved camera. + axis_cone_bias_classification_by_joint[offset_joint] = ( + "stable_cross_view_or_planar_pnp_bias" + ) + else: + failures[offset_joint] = ( + "zero_axis_cone_mismatch_too_large" + ) + else: + parent_joint = profile.phase_parent_joint[observer_joint] + phase_items: list[JointAxisMeasurement] = [] + propagated_angle_uncertainties: list[float] = [] + for item in observer_items: + phase_items.append(item) + parent_item = by_key.get((parent_joint, item.cycle)) + if parent_item is not None: + phase_items.append(parent_item) + parent_axis = np.asarray( + parent_item.axis_common_xyz, dtype=float + ) + parent_axis /= np.linalg.norm(parent_axis) + separation = ( + np.asarray(item.point_common_xyz_m, dtype=float) + - np.asarray( + parent_item.point_common_xyz_m, dtype=float + ) + ) + radial = separation - parent_axis * float( + separation @ parent_axis + ) + effective_distance = float(np.linalg.norm(radial)) + if effective_distance <= 1.0e-6: + propagated_angle_uncertainties.append(float("inf")) + else: + line_uncertainty = math.hypot( + item.pose_axis_line_rms_m, + parent_item.pose_axis_line_rms_m, + ) + propagated_angle_uncertainties.append( + math.atan2( + line_uncertainty, effective_distance + ) + ) + if profile.parallel_root_pattern: + angle_limit = ( + maximum_confidence_half_width_rad + if maximum_confidence_half_width_rad is not None + else math.radians(0.5) + ) + if ( + not propagated_angle_uncertainties + or max(propagated_angle_uncertainties) > angle_limit + ): + failures[offset_joint] = ( + "zero_phase_axis_line_angle_uncertainty_too_large" + ) + elif ( + not profile.parallel_root_pattern + and any( + item.pose_axis_line_rms_m + > maximum_pose_axis_line_rms_m + for item in phase_items + ) + ): + failures[offset_joint] = ( + "zero_phase_axis_line_residual_too_large" + ) + return GeometryEvidence(failures, axis_cone_mismatch_by_joint, axis_cone_bias_classification_by_joint) + + +def validate_holdout( + measurements: Sequence[JointAxisMeasurement], + *, + cycles: CycleEvidence, + fit: TrainingFit, + options: ZeroSolveOptions, + problem: ZeroProblem, + geometry: ObservationGeometry, +) -> HoldoutEvidence: + applied_training = cycles.applied_training + base_rotation = fit.base_rotation + base_translation = fit.base_translation + configured_palm_sources = problem.configured_palm_sources + cycle_models = cycles.cycle_models + fixed_offsets = problem.fixed_offsets + maximum_axis_cone_mismatch_rad = options.maximum_axis_cone_mismatch_rad + maximum_validation_error_rad = options.maximum_validation_error_rad + orientation_validation = problem.orientation_validation + profile = problem.profile + training_cycle_ids = cycles.training_cycle_ids + validation = problem.validation + zero_offsets = problem.zero_offsets + angular_errors = geometry.angular_errors + predicted_palm_orientation_local = geometry.predicted_palm_orientation_local + same_view_axis_pair_errors = geometry.same_view_axis_pair_errors + + candidate_errors = angular_errors( + applied_training, validation, base_rotation, base_translation + ) + original_errors = angular_errors( + zero_offsets, validation, base_rotation, base_translation + ) + validation_error_by_joint: dict[str, float] = {} + original_error_by_joint: dict[str, float] = {} + improvement_by_joint: dict[str, float] = {} + improvement_confidence_lower: dict[str, float] = {} + improvement_passed = True + if profile.same_view_axis_pair_by_offset: + # Validate the same camera/Tag-mount-invariant axis-pair angle used by + # the configured offset. This keeps the side channel out of every + # other joint, including the already stable thumb CMC roll solve. + palm_orientation_validation_errors = tuple( + abs(float(error)) + for offset_joint in profile.same_view_axis_pair_by_offset + for error in same_view_axis_pair_errors( + offset_joint, validation, applied_training + ) + ) + else: + palm_orientation_validation_errors = tuple( + math.acos( + abs( + float( + np.clip( + base_rotation.apply( + predicted_palm_orientation_local( + item, zero_offsets + ) + ) + @ np.asarray(item.axis_common_xyz, dtype=float), + -1.0, + 1.0, + ) + ) + ) + ) + for item in orientation_validation + ) + palm_orientation_validation_limit = ( + maximum_validation_error_rad + if maximum_validation_error_rad is not None + else maximum_axis_cone_mismatch_rad + ) + palm_orientation_validation_passed = bool( + not configured_palm_sources + or ( + palm_orientation_validation_errors + and float(np.median(palm_orientation_validation_errors)) + <= palm_orientation_validation_limit + ) + ) + for offset_joint, observer_joint in profile.offset_observer_joint.items(): + if offset_joint in fixed_offsets: + improvement_by_joint[offset_joint] = 0.0 + improvement_confidence_lower[offset_joint] = 0.0 + continue + candidate = abs(candidate_errors[offset_joint]) + original = abs(original_errors[offset_joint]) + validation_error_by_joint[observer_joint] = candidate + original_error_by_joint[observer_joint] = original + improvement_by_joint[offset_joint] = original - candidate + if applied_training[offset_joint] != 0.0 and not candidate < original: + improvement_passed = False + for offset_joint in profile.direct_zero_joints: + if offset_joint in fixed_offsets: + improvement_confidence_lower[offset_joint] = 0.0 + continue + if applied_training[offset_joint] == 0.0: + improvement_confidence_lower[offset_joint] = 0.0 + continue + cycle_improvements: list[float] = [] + for cycle in training_cycle_ids: + selected = [item for item in measurements if item.cycle == cycle] + cycle_rotation, cycle_translation = cycle_models[cycle] + candidate = abs( + angular_errors( + applied_training, + selected, + cycle_rotation, + cycle_translation, + )[offset_joint] + ) + original = abs( + angular_errors( + zero_offsets, + selected, + cycle_rotation, + cycle_translation, + )[offset_joint] + ) + cycle_improvements.append(original - candidate) + improvement_array = np.asarray(cycle_improvements, dtype=float) + improvement_se = ( + float("inf") + if improvement_array.size < 2 + else float( + np.std(improvement_array, ddof=1) + / math.sqrt(improvement_array.size) + ) + ) + lower = ( + float("-inf") + if not math.isfinite(improvement_se) + else float( + np.mean(improvement_array) + - student_t.ppf( + 0.975, df=improvement_array.size - 1 + ) + * improvement_se + ) + ) + improvement_confidence_lower[offset_joint] = lower + if lower <= 0.0: + improvement_passed = False + + validation_errors = np.asarray( + list(validation_error_by_joint.values()), dtype=float + ) + + return HoldoutEvidence( + validation_error_by_joint=validation_error_by_joint, + original_error_by_joint=original_error_by_joint, + improvement_by_joint=improvement_by_joint, + improvement_confidence_lower=improvement_confidence_lower, + improvement_passed=improvement_passed, + palm_orientation_validation_passed=palm_orientation_validation_passed, + validation_errors=validation_errors, + ) + + +def validate_axis_lines( + *, + cycles: CycleEvidence, + fit: TrainingFit, + problem: ZeroProblem, + geometry: ObservationGeometry, +) -> LineEvidence: + applied_training = cycles.applied_training + base_rotation = fit.base_rotation + base_translation = fit.base_translation + validation = problem.validation + predicted_local = geometry.predicted_local + + validation_line_samples: dict[str, list[float]] = {} + for item in validation: + predicted_axis, predicted_point = predicted_local( + item, applied_training + ) + predicted_axis = base_rotation.apply(predicted_axis) + predicted_axis /= np.linalg.norm(predicted_axis) + predicted_point = ( + base_rotation.apply(predicted_point) + base_translation + ) + observed_axis = np.asarray(item.axis_common_xyz, dtype=float) + observed_axis /= np.linalg.norm(observed_axis) + observed_point = np.asarray(item.point_common_xyz_m, dtype=float) + cross = np.cross(predicted_axis, observed_axis) + cross_norm = float(np.linalg.norm(cross)) + separation = observed_point - predicted_point + line_error = ( + abs(float(separation @ cross)) / cross_norm + if cross_norm > 1.0e-6 + else float( + np.linalg.norm( + separation + - predicted_axis * float(separation @ predicted_axis) + ) + ) + ) + validation_line_samples.setdefault(item.joint, []).append(line_error) + validation_line_error_by_joint = { + name: float(np.sqrt(np.mean(np.square(values)))) + for name, values in validation_line_samples.items() + } + all_validation_line_errors = np.asarray( + [ + value + for values in validation_line_samples.values() + for value in values + ], + dtype=float, + ) + axis_line_rms = ( + float("inf") + if all_validation_line_errors.size == 0 + else float( + np.sqrt(np.mean(np.square(all_validation_line_errors))) + ) + ) + + return LineEvidence(validation_line_error_by_joint=validation_line_error_by_joint, axis_line_rms=axis_line_rms) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/tag_installation.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/tag_installation.py new file mode 100644 index 0000000..2f4f3ab --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/tag_installation.py @@ -0,0 +1,158 @@ +"""One training-frozen Tag-to-link installation per physical Tag. + +This is not a zero solver. The CAD zero/base registration must already have +independent observability evidence. No holdout observation participates in +installation fitting, and no installation is fitted again for another task. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +import numpy as np +from scipy.spatial.transform import Rotation + +from ..geometry.rotation import robust_rotation_summary +from ..urdf.kinematics import UrdfKinematicModel + + +def rigid_matrix(value) -> np.ndarray: + result = np.asarray(value, dtype=float) + if result.shape != (4, 4) or not np.all(np.isfinite(result)): + raise ValueError("Tag/base pose must be a finite rigid transform") + r = result[:3, :3] + if not np.allclose(result[3], (0, 0, 0, 1), atol=1e-9) or not np.allclose(r.T @ r, np.eye(3), atol=1e-6) or not np.isclose(np.linalg.det(r), 1, atol=1e-6): + raise ValueError("Tag/base pose must be a rigid transform") + return result + + +def matrix_tuple(value) -> tuple[tuple[float, ...], ...]: + return tuple(tuple(float(v) for v in row) for row in rigid_matrix(value)) + + +def link_pose(model: UrdfKinematicModel, link: str, angles: Mapping[str, float]) -> np.ndarray: + if link == model.root_link: + return np.eye(4) + if link not in model.parent_joint_by_child: + raise ValueError(f"Tag installation references a missing link:{link}") + return model.link_transform(model.parent_joint_by_child[link], + zero_offsets={}, joint_angles=angles, independent_mimic_angles=True) + + +@dataclass(frozen=True) +class TagTrainingPose: + sample_id: str + role: str + cycle: int + common_from_tag: tuple[tuple[float, ...], ...] + # Explicit CAD coordinates, prepared from the fitted measurement result, + # NOT obtained by reading a serializer's just-written JSON/URDF. + cad_angles: Mapping[str, float] + + +@dataclass(frozen=True) +class FrozenTagInstallation: + role: str + link: str + link_from_tag: tuple[tuple[float, ...], ...] + training_sample_ids: tuple[str, ...] + training_rotation_p95_deg: float + training_translation_p95_m: float + + +def fit_tag_installations( + *, source_model: UrdfKinematicModel, common_from_base, + link_by_role: Mapping[str, str], observations: Sequence[TagTrainingPose], + minimum_samples: int = 40, +) -> dict[str, FrozenTagInstallation]: + """Fit mounting transforms once across all tasks and three cycles. + + Repeated records of one image/Tag are deduplicated only when their pose + and joint conditions agree. Conflicting duplicates are invalid evidence. + Training residuals check rigid installation, not a per-frame PnP gate. + """ + base_from_common = np.linalg.inv(rigid_matrix(common_from_base)) + grouped: dict[str, dict[str, tuple[int, np.ndarray]]] = {role: {} for role in link_by_role} + for row in observations: + if row.cycle not in {0, 1, 2} or not row.sample_id: + raise ValueError("only identified training images may fit Tag installations") + if row.role not in link_by_role: + raise ValueError(f"undeclared Tag installation:{row.role}") + candidate = np.linalg.inv(link_pose(source_model, link_by_role[row.role], row.cad_angles)) @ base_from_common @ rigid_matrix(row.common_from_tag) + previous = grouped[row.role].get(row.sample_id) + if previous is not None and (previous[0] != row.cycle or not np.allclose(previous[1], candidate, atol=1e-8, rtol=0)): + raise ValueError(f"conflicting duplicate Tag image:{row.role}:{row.sample_id}") + grouped[row.role][row.sample_id] = (row.cycle, candidate) + output = {} + for role, unique in grouped.items(): + if len(unique) < minimum_samples or {value[0] for value in unique.values()} != {0, 1, 2}: + raise ValueError(f"Tag installation lacks three-cycle training evidence:{role}") + matrices = np.asarray([value[1] for value in unique.values()]) + rotation = Rotation.from_quat(robust_rotation_summary( + Rotation.from_matrix(matrices[:, :3, :3]).as_quat().tolist())[0]) + translation = np.median(matrices[:, :3, 3], axis=0) + errors = (rotation.inv() * Rotation.from_matrix(matrices[:, :3, :3])).magnitude() + distances = np.linalg.norm(matrices[:, :3, 3] - translation, axis=1) + rotation_p95 = float(np.rad2deg(np.percentile(errors, 95))) + translation_p95 = float(np.percentile(distances, 95)) + if rotation_p95 > 2.0 or translation_p95 > 0.003: + raise ValueError(f"Tag installation is not rigid across training tasks:{role}:rotation_p95_deg={rotation_p95}:translation_p95_m={translation_p95}") + transform = np.eye(4) + transform[:3, :3], transform[:3, 3] = rotation.as_matrix(), translation + output[role] = FrozenTagInstallation(role, link_by_role[role], matrix_tuple(transform), + tuple(sorted(unique)), rotation_p95, translation_p95) + return output + + +def register_base_translation( + *, source_model: UrdfKinematicModel, common_from_base, + link_by_role: Mapping[str, str], observations: Sequence[TagTrainingPose], +) -> tuple[tuple[float, ...], ...]: + """Register translation jointly with constant mounts, training only. + + The axis/zero solver owns orientation and observable joint zeros. Its + monocular line-point translation can contain a depth gauge. Full rigid + trajectories remove that gauge through p_tag = p_base + R_base*p_link + + R_base*R_link*p_mount. No extra joint zero or CAD geometry is estimated. + """ + base = rigid_matrix(common_from_base).copy() + roles = tuple(sorted(link_by_role)) + slots = {role: 3 + 3*index for index, role in enumerate(roles)} + count = 3 + 3*len(roles) + matrices, rhs = [], [] + seen = set() + for row in observations: + if row.cycle not in {0, 1, 2} or not row.sample_id or row.role not in slots: + raise ValueError("base registration requires identified training Tag poses") + identity = (row.role, row.sample_id) + if identity in seen: + raise ValueError("duplicate base-registration image") + seen.add(identity) + link = link_pose(source_model, link_by_role[row.role], row.cad_angles) + matrix = np.zeros((3, count)) + matrix[:, :3] = np.eye(3) + index = slots[row.role] + matrix[:, index:index+3] = base[:3, :3] @ link[:3, :3] + matrices.append(matrix) + rhs.append(rigid_matrix(row.common_from_tag)[:3, 3] - base[:3, :3] @ link[:3, 3]) + if not matrices: + raise ValueError("base registration has no training poses") + a, b = np.vstack(matrices), np.concatenate(rhs) + estimate, _, rank, singular = np.linalg.lstsq(a, b, rcond=None) + if rank != count or singular[-1] / singular[0] < 1e-8: + raise ValueError("base translation/mounts are not jointly observable") + # Robust weights act on entire 3-D observations; no axis is selectively + # discarded to hide a bad CAD/mount fit. + for _ in range(8): + residual = (a @ estimate - b).reshape(-1, 3) + distance = np.linalg.norm(residual, axis=1) + scale = max(0.0005, 1.4826*float(np.median(distance))) + weights = np.repeat(np.sqrt(np.minimum(1.0, scale / np.maximum(distance, 1e-12))), 3) + updated = np.linalg.lstsq(a*weights[:, None], b*weights, rcond=None)[0] + if np.linalg.norm(updated - estimate) < 1e-10: + estimate = updated + break + estimate = updated + base[:3, 3] = estimate[:3] + return matrix_tuple(base) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/fitting/trajectory_geometry.py b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/trajectory_geometry.py new file mode 100644 index 0000000..a02bbca --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/fitting/trajectory_geometry.py @@ -0,0 +1,356 @@ +"""Shared point-trajectory and legacy byte-domain numerical primitives.""" +from __future__ import annotations +import math +from typing import Any, Mapping, Sequence +import numpy as np +from scipy.optimize import least_squares +from ..domain.task import DIRECTION_DECREASING, DIRECTION_INCREASING, DIRECTIONS +from .curve import isotonic_nonincreasing + +def _vector3(value: Sequence[float], *, name: str) -> np.ndarray: + vector = np.asarray(value, dtype=float) + if vector.shape != (3,) or not np.all(np.isfinite(vector)): + raise ValueError(f"{name} must contain three finite values") + return vector + + +def _plane_basis(axis: Sequence[float]) -> tuple[np.ndarray, np.ndarray]: + normal = _vector3(axis, name="axis") + normal /= np.linalg.norm(normal) + candidates = np.eye(3) + seed = candidates[int(np.argmin(np.abs(candidates @ normal)))] + first = np.cross(normal, seed) + first /= np.linalg.norm(first) + second = np.cross(normal, first) + second /= np.linalg.norm(second) + return first, second + + +def _fit_plane_axis(point_sets: Sequence[np.ndarray]) -> tuple[np.ndarray, float]: + centred: list[np.ndarray] = [] + for points in point_sets: + array = np.asarray(points, dtype=float) + if array.ndim != 2 or array.shape[1] != 3 or len(array) < 3: + raise ValueError("each trajectory must contain at least three 3-D points") + # The arithmetic centroid of points in a plane remains in that plane + # and transforms correctly under a camera rotation. A component-wise + # median generally does neither. + centred.append(array - np.mean(array, axis=0)) + matrix = np.concatenate(centred, axis=0) + _, _, vh = np.linalg.svd(matrix, full_matrices=False) + axis = vh[-1] + axis /= np.linalg.norm(axis) + residuals = matrix @ axis + plane_rms = float(np.sqrt(np.mean(np.square(residuals)))) + return axis, plane_rms + + +def _fit_circle_with_axis( + points_xyz: Sequence[Sequence[float]], + axis_xyz: Sequence[float], +) -> dict[str, Any]: + points = np.asarray(points_xyz, dtype=float) + if ( + points.ndim != 2 + or points.shape[1] != 3 + or len(points) < 6 + or not np.all(np.isfinite(points)) + ): + raise ValueError("a circle requires at least six finite 3-D points") + axis = _vector3(axis_xyz, name="circle axis") + axis /= np.linalg.norm(axis) + basis_x, basis_y = _plane_basis(axis) + origin = np.mean(points, axis=0) + local = points - origin + xy = np.column_stack((local @ basis_x, local @ basis_y)) + + design = np.column_stack((2.0 * xy[:, 0], 2.0 * xy[:, 1], np.ones(len(xy)))) + target = np.sum(np.square(xy), axis=1) + initial_x, initial_y, constant = np.linalg.lstsq( + design, target, rcond=None + )[0] + initial_radius = math.sqrt( + max( + float(constant + initial_x * initial_x + initial_y * initial_y), + np.finfo(float).eps, + ) + ) + + def residual(parameters: np.ndarray) -> np.ndarray: + centre = parameters[:2] + radius = float(parameters[2]) + return np.linalg.norm(xy - centre, axis=1) - radius + + fitted = least_squares( + residual, + np.asarray([initial_x, initial_y, initial_radius], dtype=float), + loss="soft_l1", + f_scale=0.0005, + max_nfev=2000, + ) + centre_xy = fitted.x[:2] + radius = abs(float(fitted.x[2])) + radial_residuals = residual( + np.asarray([centre_xy[0], centre_xy[1], radius], dtype=float) + ) + centre_xyz = origin + centre_xy[0] * basis_x + centre_xy[1] * basis_y + plane_offsets = (points - centre_xyz) @ axis + centre_xyz += float(np.median(plane_offsets)) * axis + plane_residuals = (points - centre_xyz) @ axis + return { + "axis_xyz": [float(value) for value in axis], + "center_xyz_m": [float(value) for value in centre_xyz], + "radius_m": radius, + "radial_rms_m": float( + np.sqrt(np.mean(np.square(radial_residuals))) + ), + "plane_rms_m": float( + np.sqrt(np.mean(np.square(plane_residuals))) + ), + } + + +def _project_radial( + point_xyz: Sequence[float], + circle: Mapping[str, Any], +) -> np.ndarray: + point = _vector3(point_xyz, name="trajectory point") + centre = _vector3(circle["center_xyz_m"], name="circle centre") + axis = _vector3(circle["axis_xyz"], name="circle axis") + axis /= np.linalg.norm(axis) + radial = point - centre + radial -= float(radial @ axis) * axis + if float(np.linalg.norm(radial)) < 1.0e-9: + raise ValueError("trajectory point lies on the fitted rotation axis") + return radial + + +def _signed_angle( + reference_radial_xyz: Sequence[float], + observed_radial_xyz: Sequence[float], + axis_xyz: Sequence[float], +) -> float: + reference = _vector3(reference_radial_xyz, name="reference radial") + observed = _vector3(observed_radial_xyz, name="observed radial") + axis = _vector3(axis_xyz, name="angle axis") + axis /= np.linalg.norm(axis) + reference -= float(reference @ axis) * axis + observed -= float(observed @ axis) * axis + reference /= np.linalg.norm(reference) + observed /= np.linalg.norm(observed) + return math.atan2( + float(axis @ np.cross(reference, observed)), + float(np.clip(reference @ observed, -1.0, 1.0)), + ) + + +def _reference_radial( + records: Sequence[Mapping[str, Any]], + points: Sequence[np.ndarray], + circle: Mapping[str, Any], +) -> np.ndarray: + references = [ + _project_radial(point, circle) + for record, point in zip(records, points) + if int(record.get("command_u8", -1)) == 255 + ] + if not references: + raise ValueError("trajectory is missing command-255 reference points") + radial = np.median(np.asarray(references, dtype=float), axis=0) + axis = _vector3(circle["axis_xyz"], name="circle axis") + axis /= np.linalg.norm(axis) + radial -= float(radial @ axis) * axis + if float(np.linalg.norm(radial)) < 1.0e-9: + raise ValueError("command-255 reference is degenerate") + return radial + + +def _angle_for_circle( + point_xyz: Sequence[float], + circle: Mapping[str, Any], +) -> float: + return _signed_angle( + circle["reference_radial_xyz_m"], + _project_radial(point_xyz, circle), + circle["axis_xyz"], + ) + + +def _low_command_median( + records: Sequence[Mapping[str, Any]], + values: Sequence[float], +) -> float: + selected = [ + float(value) + for record, value in zip(records, values) + if int(record.get("command_u8", 255)) <= 16 + ] + if not selected: + selected = [ + float(value) + for _, value in sorted( + zip(records, values), + key=lambda item: int(item[0].get("command_u8", 255)), + )[: max(3, len(records) // 20)] + ] + return float(np.median(selected)) + + +def _orient_circle_positive( + circle: dict[str, Any], + records: Sequence[Mapping[str, Any]], + points: Sequence[np.ndarray], +) -> dict[str, Any]: + reference = _reference_radial(records, points, circle) + circle["reference_radial_xyz_m"] = [ + float(value) for value in reference + ] + values = [_angle_for_circle(point, circle) for point in points] + if _low_command_median(records, values) < 0.0: + axis = -_vector3(circle["axis_xyz"], name="circle axis") + circle["axis_xyz"] = [float(value) for value in axis] + values = [_angle_for_circle(point, circle) for point in points] + circle["observed_arc_rad"] = float(max(values) - min(values)) + return circle + + +def _fit_joint_curve( + records: Sequence[Mapping[str, Any]], + values: Sequence[float], + *, + endpoint_reference: Mapping[str, Sequence[float]] | None = None, + preserve_direction_offset: bool = False, + require_observed_domain_endpoints: bool = True, +) -> tuple[dict[str, Any], float, float]: + by_direction: dict[str, list[list[float]]] = { + direction: [[] for _ in range(256)] for direction in DIRECTIONS + } + for record, value in zip(records, values): + direction = str(record["direction"]) + command = int(record["command_u8"]) + by_direction[direction][command].append(float(value)) + + fitted: dict[str, list[float]] = {} + maximum_correction = 0.0 + for direction in DIRECTIONS: + commands = np.asarray( + [ + command + for command, samples in enumerate(by_direction[direction]) + if samples + ], + dtype=int, + ) + if commands.size < 3: + raise ValueError( + f"{direction} centre trajectory requires at least 3 commands" + ) + if require_observed_domain_endpoints and ( + int(commands[0]) != 0 or int(commands[-1]) != 255 + ): + raise ValueError( + f"{direction} centre trajectory requires commands 0 and 255" + ) + raw = np.asarray( + [ + float(np.median(by_direction[direction][command])) + for command in commands + ], + dtype=float, + ) + if not preserve_direction_offset: + raw -= raw[-1] + projected_samples = isotonic_nonincreasing(raw) + if not preserve_direction_offset: + projected_samples -= projected_samples[-1] + maximum_correction = max( + maximum_correction, + float(np.max(np.abs(projected_samples - raw))), + ) + curve = np.interp( + np.arange(256, dtype=float), + commands.astype(float), + projected_samples, + ) + if not preserve_direction_offset: + curve -= curve[255] + if endpoint_reference is not None: + curve = _regularize_coupled_zero_tail( + curve, + endpoint_reference[f"{direction}_rad"], + ) + fitted[direction] = [ + round(float(value), 8) for value in curve + ] + + decreasing = np.asarray(fitted[DIRECTION_DECREASING], dtype=float) + increasing = np.asarray(fitted[DIRECTION_INCREASING], dtype=float) + hysteresis = float(np.max(np.abs(decreasing - increasing))) + combined = 0.5 * (decreasing + increasing) + if not preserve_direction_offset: + combined -= combined[255] + return ( + { + "angle_rad": [round(float(value), 8) for value in combined], + "decreasing_rad": fitted[DIRECTION_DECREASING], + "increasing_rad": fitted[DIRECTION_INCREASING], + }, + maximum_correction, + hysteresis, + ) + + +def _regularize_coupled_zero_tail( + values: Sequence[float], + reference_values: Sequence[float], + *, + maximum_tail_commands: int = 16, + zero_tolerance_rad: float = 1.0e-10, +) -> np.ndarray: + """Replace a short noise-created zero tail using a coupled joint shape. + + The passive IP and active MCP share motor 15. Close to command 255 the + IP centre trajectory is small enough that measurement noise can become + negative. Isotonic projection correctly prevents a negative angle, but + otherwise turns all remaining commands into an artificial zero plateau. + Continue the last resolved IP/MCP ratio over a short tail instead. This + changes neither the resolved part of the curve nor the exact 255 zero. + """ + curve = np.asarray(values, dtype=float).copy() + reference = np.asarray(reference_values, dtype=float) + if curve.shape != (256,) or reference.shape != (256,): + raise ValueError("endpoint curves must each contain 256 values") + if not np.all(np.isfinite(curve)) or not np.all(np.isfinite(reference)): + raise ValueError("endpoint curves must be finite") + maximum_tail = int(maximum_tail_commands) + tolerance = float(zero_tolerance_rad) + if maximum_tail < 2 or tolerance < 0.0: + raise ValueError("invalid endpoint regularization settings") + + anchor = 254 + while anchor >= 0 and abs(float(curve[anchor])) <= tolerance: + anchor -= 1 + tail_commands = 255 - anchor + if ( + anchor < 0 + or tail_commands < 2 + or tail_commands > maximum_tail + or curve[anchor] <= tolerance + or reference[anchor] <= tolerance + or abs(float(curve[255])) > tolerance + or abs(float(reference[255])) > tolerance + ): + curve[255] = 0.0 + return curve + + ratio = float(curve[anchor] / reference[anchor]) + continuation = np.maximum( + 0.0, + ratio * reference[anchor + 1 :], + ) + continuation = np.minimum.accumulate(continuation) + continuation = np.minimum(continuation, float(curve[anchor])) + continuation[-1] = 0.0 + curve[anchor + 1 :] = continuation + return curve + diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/observations.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/candidate_selection.py similarity index 76% rename from src/linkerhand_calibration/linkerhand_calibration/models/o12/observations.py rename to src/linkerhand_calibration/linkerhand_calibration/core/geometry/candidate_selection.py index 35d6c7e..7b01aec 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/observations.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/candidate_selection.py @@ -1,4 +1,4 @@ -"""Offline thumb candidate resolution using training data only. +"""Articulated Tag candidate resolution using training data only. Re-selects actual corner-derived IPPE solutions; does not manufacture rigid poses, substitute SDK angles, or relax the spatial solver's acceptance gates. @@ -9,10 +9,9 @@ import math import numpy as np from scipy.spatial.transform import Rotation as R -from ...pnp import solve_square_tag_ippe -from .pnp import THUMB_ROLES +from .pnp import solve_square_tag_ippe -POLICY = 'o12_thumb_training_candidates_v1' +POLICY = 'training_frozen_articulated_candidates_v1' MATRIX_SOURCE = 'CameraInfo.P[:3,:3]' @@ -42,7 +41,8 @@ def _trajectory_score(parent, child): 'score': rms/.0015 + off_axis/math.radians(1)} -def resolve_thumb_observations(records, *, projection_override=None): +def resolve_chain_observations(records, *, task_key, role_pairs, + projection_override=None, solve_pose=solve_square_tag_ippe): """Return copied observations and auditable candidate-selection evidence. Older captures did not save P. They remain on the legacy path; K is never @@ -50,11 +50,14 @@ def resolve_thumb_observations(records, *, projection_override=None): and must be reported as such, not advertised as a verified whole session. """ rows = [dict(r) for r in records] - joint_rows = [r for r in rows if r.get('kind') == 'o12_joint_sample' - and r.get('task_name') == 'thumb_mcp_dip_front'] + roles = tuple(dict.fromkeys(role for pair in role_pairs.values() for role in pair)) + if not role_pairs or len(roles) > 8 or any(len(pair) != 2 or pair[0] == pair[1] for pair in role_pairs.values()): + raise ValueError('candidate selection requires a bounded articulated observation graph') + joint_rows = [r for r in rows if r.get('joint') in role_pairs + and r.get('task_name') == task_key] evidence = {int(r['image_stamp_ns']): r for r in rows - if r.get('kind') == 'o12_pnp_candidate_frame' - and r.get('task_name') == 'thumb_mcp_dip_front'} + if ('roles' in r or str(r.get('kind', '')).endswith('pnp_candidate_frame')) + and r.get('task_name') == task_key} report = {'policy': POLICY, 'training_cycles': [0, 1, 2], 'holdout_cycle': 3, 'projection_override_used': projection_override is not None, 'is_accuracy_certificate': False} @@ -72,23 +75,23 @@ def resolve_thumb_observations(records, *, projection_override=None): chosen_rows = [r for r in joint_rows if int(r.get('attempt', 1)) == latest[(int(r['cycle']), r['direction'])]] stamps = sorted({int(r['image_stamp_ns']) for r in chosen_rows}) if not stamps or any(s not in evidence for s in stamps): - raise ValueError('O12 corner replay lacks evidence for accepted observations') + raise ValueError('corner replay lacks evidence for accepted observations') training = np.asarray([int(evidence[s]['cycle']) in (0, 1, 2) for s in stamps]) if sum(training) < 40 or not any(int(evidence[s]['cycle']) == 3 for s in stamps): - raise ValueError('O12 corner replay requires training and independent holdout') + raise ValueError('corner replay requires training and independent holdout') if np.flatnonzero(training)[-1] > np.flatnonzero(~training)[0]: - raise ValueError('O12 holdout must follow the complete training trajectory') - paths = {role: [[], []] for role in THUMB_ROLES} + raise ValueError('holdout must follow the complete training trajectory') + paths = {role: [[], []] for role in roles} for stamp in stamps: frame = evidence[stamp] matrix = np.asarray(frame['camera_matrix'] if projection_override is None else projection_override) - for role in THUMB_ROLES: + for role in roles: data = frame['roles'][role] - poses = solve_square_tag_ippe(data['corners_xy'], tag_size_m=frame['tag_size_m'], camera_matrix=matrix) + poses = solve_pose(data['corners_xy'], tag_size_m=data.get('tag_size_m', frame['tag_size_m']), camera_matrix=matrix) limit = float(data.get('maximum_reprojection_error_px', 1.5)) poses = [p for p in poses if p.reprojection_error_px <= limit] if not poses: - raise ValueError(f'O12 corner replay has no eligible pose:{stamp}:{role}') + raise ValueError(f'corner replay has no eligible pose:{stamp}:{role}') cs = [dict(quaternion_xyzw=list(p.quaternion_xyzw), translation_xyz_m=list(p.translation_xyz_m), reprojection_error_px=p.reprojection_error_px) for p in poses[:2]] if len(cs) == 1: @@ -102,20 +105,20 @@ def resolve_thumb_observations(records, *, projection_override=None): for i, pose in enumerate(cs): paths[role][i].append(pose) scores = [] - for branches in product(range(2), repeat=3): - train = [[p for p, keep in zip(paths[role][branch], training) if keep] - for role, branch in zip(THUMB_ROLES, branches)] - metrics = [_trajectory_score(train[i], train[i+1]) for i in (0, 1)] - image_penalty = float(np.mean([p['reprojection_error_px'] for ps in train for p in ps])) + for branches in product(range(2), repeat=len(roles)): + train = {role: [p for p, keep in zip(paths[role][branch], training) if keep] + for role, branch in zip(roles, branches)} + metrics = [_trajectory_score(train[parent], train[child]) for parent, child in role_pairs.values()] + image_penalty = float(np.mean([p['reprojection_error_px'] for ps in train.values() for p in ps])) scores.append({'branches': list(branches), 'score': sum(m['score'] for m in metrics)+.5*image_penalty, 'training_pairs': metrics, 'mean_reprojection_px': image_penalty}) selected = min(scores, key=lambda s: s['score']) - lookup = {stamp: {role: paths[role][branch][i] for role, branch in zip(THUMB_ROLES, selected['branches'])} + lookup = {stamp: {role: paths[role][branch][i] for role, branch in zip(roles, selected['branches'])} for i, stamp in enumerate(stamps)} changed = 0 for row in chosen_rows: stamp = int(row['image_stamp_ns']) - parent_role, child_role = THUMB_ROLES[:2] if row['joint'] == 'thumb_mcp' else THUMB_ROLES[1:] + parent_role, child_role = role_pairs[row['joint']] parent, child = lookup[stamp][parent_role], lookup[stamp][child_role] # Preserve the actual camera->common transform, not an assumed identity. old_camera_parent = evidence[stamp]['roles'][parent_role]['selected'] @@ -133,7 +136,7 @@ def resolve_thumb_observations(records, *, projection_override=None): row['pnp_reprojection_error_px'] = max(parent['reprojection_error_px'], child['reprojection_error_px']) row['pose_selection_policy'] = POLICY if projection_override is not None: - row['projection_reprocessing_scope'] = 'thumb_only_external_projection' + row['projection_reprocessing_scope'] = 'partial_external_projection' return rows, {**report, 'status': 'resolved', 'selected_branches': selected['branches'], 'hypotheses': scores, 'training_frames': int(sum(training)), 'holdout_frames': int(sum(~training)), 'changed_joint_rows': changed} diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/pnp.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/pnp.py index 9ee5c91..6986ab3 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/pnp.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/pnp.py @@ -1,1751 +1,18 @@ -"""Square AprilTag pose estimation with planar ambiguity tracking. - -The AprilTag detections contain accurately refined image corners. This module -uses OpenCV's IPPE square solver directly so the calibration node can inspect -both planar PnP solutions instead of accepting an occasionally flipped TF -pose. -""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from itertools import product -import math -from typing import Mapping, Sequence - -import cv2 -import numpy as np -from scipy.spatial.transform import Rotation - - -@dataclass(frozen=True) -class SquareTagPose: - """One tag-to-camera pose candidate returned by IPPE.""" - - quaternion_xyzw: tuple[float, float, float, float] - translation_xyz_m: tuple[float, float, float] - reprojection_error_px: float - - -def _relative_pose( - parent: SquareTagPose, - child: SquareTagPose, -) -> tuple[Rotation, np.ndarray]: - parent_rotation = Rotation.from_quat(parent.quaternion_xyzw) - child_rotation = Rotation.from_quat(child.quaternion_xyzw) - relative_rotation = parent_rotation.inv() * child_rotation - relative_translation = parent_rotation.inv().apply( - np.asarray(child.translation_xyz_m, dtype=float) - - np.asarray(parent.translation_xyz_m, dtype=float) - ) - return relative_rotation, relative_translation - - -def _normal_alignment_rad(first: SquareTagPose, second: SquareTagPose) -> float: - """Return the undirected angle between two observed Tag face normals.""" - first_normal = Rotation.from_quat(first.quaternion_xyzw).apply( - [0.0, 0.0, 1.0] - ) - second_normal = Rotation.from_quat(second.quaternion_xyzw).apply( - [0.0, 0.0, 1.0] - ) - return math.acos( - abs(float(np.clip(first_normal @ second_normal, -1.0, 1.0))) - ) - - -def select_rigid_group_trajectory( - frames: Sequence[Mapping[str, Sequence[SquareTagPose]]], - *, - roles: Sequence[str], - fixed_pairs: Sequence[tuple[str, str]], - reprojection_scale_px: float, - rotation_scale_rad: float, - translation_scale_m: float, - pair_geometry: str = "pose", - normal_alignment_pairs: Sequence[tuple[str, str]] = (), - normal_alignment_scale_rad: float | None = None, -) -> tuple[ - list[dict[str, SquareTagPose]], - dict[str, float | str], -]: - """Resolve planar branches using geometry that should stay rigid. - - Every possible branch combination in the first frame is treated as a - candidate rigid reference. For each such reference, every later frame - independently chooses the combination with the lowest reprojection plus - geometric-drift cost. ``pose`` compares relative rotation and translation; - ``distance`` compares only Euclidean centre distances and therefore does - not allow planar-PnP orientation jitter into centre-trajectory angles. - The globally cheapest reference and path win. - """ - role_names = tuple(str(role) for role in roles) - pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs) - normal_pair_names = tuple( - (str(first), str(second)) - for first, second in normal_alignment_pairs - ) - if not frames: - raise ValueError("at least one PnP frame is required") - if len(set(role_names)) != len(role_names) or not role_names: - raise ValueError("roles must be non-empty and unique") - if any( - parent not in role_names or child not in role_names - for parent, child in (*pair_names, *normal_pair_names) - ): - raise ValueError("geometry pairs must reference roles") - reprojection_scale = float(reprojection_scale_px) - rotation_scale = float(rotation_scale_rad) - translation_scale = float(translation_scale_m) - geometry_mode = str(pair_geometry) - normal_scale = ( - rotation_scale - if normal_alignment_scale_rad is None - else float(normal_alignment_scale_rad) - ) - if min( - reprojection_scale, - rotation_scale, - translation_scale, - normal_scale, - ) <= 0.0: - raise ValueError("trajectory selection scales must be positive") - if geometry_mode not in {"pose", "distance"}: - raise ValueError("pair_geometry must be pose or distance") - - combinations_by_frame: list[list[dict[str, SquareTagPose]]] = [] - for frame in frames: - candidate_lists = [tuple(frame.get(role, ())) for role in role_names] - if any(not candidates for candidates in candidate_lists): - raise ValueError("every frame must contain every requested role") - combinations_by_frame.append( - [ - dict(zip(role_names, combination)) - for combination in product(*candidate_lists) - ] - ) - - best_total = float("inf") - best_path: list[dict[str, SquareTagPose]] | None = None - - def emission( - combination: Mapping[str, SquareTagPose], - reference_pairs: Mapping[ - tuple[str, str], tuple[Rotation, np.ndarray] - ], - reference_distances: Mapping[tuple[str, str], float], - ) -> tuple[float, float, float]: - reprojection_cost = sum( - pose.reprojection_error_px - for pose in combination.values() - ) / reprojection_scale - normal_alignment_cost = sum( - _normal_alignment_rad( - combination[first], combination[second] - ) - for first, second in normal_pair_names - ) / normal_scale - rotation_drifts: list[float] = [] - translation_drifts: list[float] = [] - distance_drifts: list[float] = [] - for pair, ( - reference_rotation, - reference_translation, - ) in reference_pairs.items(): - rotation, translation = _relative_pose( - combination[pair[0]], - combination[pair[1]], - ) - rotation_drifts.append( - float( - (reference_rotation.inv() * rotation).magnitude() - ) - ) - translation_drifts.append( - float( - np.linalg.norm( - translation - reference_translation - ) - ) - ) - current_distance = float( - np.linalg.norm( - np.asarray( - combination[pair[1]].translation_xyz_m, - dtype=float, - ) - - np.asarray( - combination[pair[0]].translation_xyz_m, - dtype=float, - ) - ) - ) - distance_drifts.append( - abs(current_distance - reference_distances[pair]) - ) - if geometry_mode == "distance": - geometry_cost = sum(distance_drifts) / translation_scale - else: - geometry_cost = ( - sum(rotation_drifts) / rotation_scale - + sum(translation_drifts) / translation_scale - ) - return ( - reprojection_cost + geometry_cost + normal_alignment_cost, - max(rotation_drifts, default=0.0), - ( - max(distance_drifts, default=0.0) - if geometry_mode == "distance" - else max(translation_drifts, default=0.0) - ), - ) - - def transition_cost( - previous: Mapping[str, SquareTagPose], - current: Mapping[str, SquareTagPose], - ) -> float: - rotation_motion = sum( - rotation_distance_rad( - previous[role].quaternion_xyzw, - current[role].quaternion_xyzw, - ) - for role in role_names - ) - translation_motion = sum( - float( - np.linalg.norm( - np.asarray(current[role].translation_xyz_m) - - np.asarray(previous[role].translation_xyz_m) - ) - ) - for role in role_names - ) - if geometry_mode == "distance": - return translation_motion / translation_scale - return ( - rotation_motion / rotation_scale - + translation_motion / translation_scale - ) - - for reference_index, reference_combination in enumerate( - combinations_by_frame[0] - ): - reference_pairs = { - pair: _relative_pose( - reference_combination[pair[0]], - reference_combination[pair[1]], - ) - for pair in pair_names - } - reference_distances = { - pair: float( - np.linalg.norm( - np.asarray( - reference_combination[pair[1]].translation_xyz_m, - dtype=float, - ) - - np.asarray( - reference_combination[pair[0]].translation_xyz_m, - dtype=float, - ) - ) - ) - for pair in pair_names - } - first_emission = emission( - reference_combination, - reference_pairs, - reference_distances, - ) - previous_costs = np.full( - len(combinations_by_frame[0]), - np.inf, - dtype=float, - ) - previous_costs[reference_index] = first_emission[0] - back_pointers: list[list[int]] = [] - for frame_index in range(1, len(combinations_by_frame)): - previous_combinations = combinations_by_frame[frame_index - 1] - combinations = combinations_by_frame[frame_index] - frame_emissions = [ - emission( - combination, - reference_pairs, - reference_distances, - ) - for combination in combinations - ] - current_costs = np.full(len(combinations), np.inf, dtype=float) - frame_back_pointers: list[int] = [] - for current_index, combination in enumerate(combinations): - transition_costs = [ - previous_costs[previous_index] - + transition_cost( - previous_combination, - combination, - ) - for previous_index, previous_combination in enumerate( - previous_combinations - ) - ] - best_previous = int(np.argmin(transition_costs)) - frame_back_pointers.append(best_previous) - current_costs[current_index] = ( - transition_costs[best_previous] - + frame_emissions[current_index][0] - ) - back_pointers.append(frame_back_pointers) - previous_costs = current_costs - - final_index = int(np.argmin(previous_costs)) - total = float(previous_costs[final_index]) - path_indices = [final_index] - for frame_back_pointers in reversed(back_pointers): - path_indices.append( - frame_back_pointers[path_indices[-1]] - ) - path_indices.reverse() - path = [ - combinations[index] - for combinations, index in zip( - combinations_by_frame, - path_indices, - ) - ] - if total < best_total: - best_total = total - best_path = path - - if best_path is None: - raise RuntimeError("trajectory branch selection produced no path") - - # The marker-to-marker mounting transforms are unknown, so the rigid - # reference must be estimated from the complete sweep. Using frame zero - # as both the optimisation seed and the reported quality reference made - # one noisy endpoint frame look like drift in every other frame. A - # rotation medoid and component-wise translation median are insensitive - # to that endpoint noise while still exposing a persistent mirror branch. - robust_reference_pairs: dict[ - tuple[str, str], tuple[Rotation, np.ndarray] - ] = {} - for pair in pair_names: - pair_poses = [ - _relative_pose(frame[pair[0]], frame[pair[1]]) - for frame in best_path - ] - pair_rotations = [pose[0] for pose in pair_poses] - angular_costs = np.asarray( - [ - sum( - float((candidate.inv() * other).magnitude()) - for other in pair_rotations - ) - for candidate in pair_rotations - ], - dtype=float, - ) - rotation_medoid = pair_rotations[int(np.argmin(angular_costs))] - translation_median = np.median( - np.asarray([pose[1] for pose in pair_poses], dtype=float), - axis=0, - ) - robust_reference_pairs[pair] = ( - rotation_medoid, - translation_median, - ) - - rotation_drifts_by_frame: list[float] = [] - translation_drifts_by_frame: list[float] = [] - pair_distances_by_pair = { - pair: np.asarray( - [ - np.linalg.norm( - np.asarray(frame[pair[1]].translation_xyz_m, dtype=float) - - np.asarray( - frame[pair[0]].translation_xyz_m, dtype=float - ) - ) - for frame in best_path - ], - dtype=float, - ) - for pair in pair_names - } - robust_pair_distances = { - pair: float(np.median(distances)) - for pair, distances in pair_distances_by_pair.items() - } - distance_drifts_by_frame: list[float] = [] - for frame in best_path: - frame_rotation_drifts: list[float] = [] - frame_translation_drifts: list[float] = [] - frame_distance_drifts: list[float] = [] - for pair, ( - reference_rotation, - reference_translation, - ) in robust_reference_pairs.items(): - rotation, translation = _relative_pose( - frame[pair[0]], frame[pair[1]] - ) - frame_rotation_drifts.append( - float((reference_rotation.inv() * rotation).magnitude()) - ) - frame_translation_drifts.append( - float(np.linalg.norm(translation - reference_translation)) - ) - distance = float( - np.linalg.norm( - np.asarray(frame[pair[1]].translation_xyz_m, dtype=float) - - np.asarray( - frame[pair[0]].translation_xyz_m, dtype=float - ) - ) - ) - frame_distance_drifts.append( - abs(distance - robust_pair_distances[pair]) - ) - rotation_drifts_by_frame.append( - max(frame_rotation_drifts, default=0.0) - ) - translation_drifts_by_frame.append( - max(frame_translation_drifts, default=0.0) - ) - distance_drifts_by_frame.append( - max(frame_distance_drifts, default=0.0) - ) - - rotation_drifts = np.asarray(rotation_drifts_by_frame, dtype=float) - translation_drifts = np.asarray( - translation_drifts_by_frame, dtype=float - ) - distance_drifts = np.asarray(distance_drifts_by_frame, dtype=float) - normal_alignments = np.asarray( - [ - max( - ( - _normal_alignment_rad(frame[first], frame[second]) - for first, second in normal_pair_names - ), - default=0.0, - ) - for frame in best_path - ], - dtype=float, - ) - return best_path, { - "total_cost": float(best_total), - "pair_geometry": geometry_mode, - "maximum_normal_alignment_rad": float( - np.max(normal_alignments, initial=0.0) - ), - "maximum_pair_rotation_drift_rad": float( - np.max(rotation_drifts, initial=0.0) - ), - "p95_pair_rotation_drift_rad": float( - np.percentile(rotation_drifts, 95.0) - ), - "median_pair_rotation_drift_rad": float( - np.median(rotation_drifts) - ), - "maximum_pair_translation_drift_m": float( - np.max(translation_drifts, initial=0.0) - ), - "p95_pair_translation_drift_m": float( - np.percentile(translation_drifts, 95.0) - ), - "maximum_pair_distance_drift_m": float( - np.max(distance_drifts, initial=0.0) - ), - "p95_pair_distance_drift_m": float( - np.percentile(distance_drifts, 95.0) - ), - "median_pair_distance_drift_m": float( - np.median(distance_drifts) - ), - } - - -def select_static_rigid_group_initialization( - frames: Sequence[Mapping[str, Sequence[SquareTagPose]]], - *, - roles: Sequence[str], - fixed_pairs: Sequence[tuple[str, str]], - reprojection_scale_px: float, - maximum_pose_jump_rad: float, - maximum_translation_jump_m: float, - relative_rotation_scale_rad: float, - relative_translation_scale_m: float, - normal_alignment_pairs: Sequence[tuple[str, str]] = (), - normal_alignment_scale_rad: float = math.radians(5.0), - task_reference_pairs: Mapping[ - tuple[str, str], tuple[Rotation, np.ndarray] - ] | None = None, - task_reference_rotation_scale_rad: float = math.radians(1.0), - task_reference_translation_scale_m: float = 0.01, -) -> tuple[list[dict[str, SquareTagPose]], dict[str, float | str]]: - """Select a static multi-Tag IPPE branch path in bounded time. - - Group initialization is performed while the hand is held at an endpoint, - so every frame should describe the same camera and relative Tag poses. - Enumerating a full Viterbi transition matrix for every possible first-frame - branch is therefore unnecessary: with four two-branch Tags and eight - frames it performs more than one hundred thousand scipy rotations and can - block the live ROS callback for several seconds. - - Instead, treat every first-frame combination as a possible static - reference and independently select the closest combination in each later - frame. This preserves the multi-frame rigidity and normal-alignment - evidence while changing the search from O(F*C^2*C0) to O(F*C*C0). - """ - role_names = tuple(str(role) for role in roles) - pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs) - normal_pair_names = tuple( - (str(first), str(second)) for first, second in normal_alignment_pairs - ) - task_references = dict(task_reference_pairs or {}) - if not frames: - raise ValueError("at least one PnP frame is required") - if not role_names or len(set(role_names)) != len(role_names): - raise ValueError("roles must be non-empty and unique") - if any( - parent not in role_names or child not in role_names - for parent, child in ( - *pair_names, - *normal_pair_names, - *task_references, - ) - ): - raise ValueError("geometry pairs must reference roles") - scales = ( - float(reprojection_scale_px), - float(maximum_pose_jump_rad), - float(maximum_translation_jump_m), - float(relative_rotation_scale_rad), - float(relative_translation_scale_m), - float(normal_alignment_scale_rad), - float(task_reference_rotation_scale_rad), - float(task_reference_translation_scale_m), - ) - if min(scales) <= 0.0: - raise ValueError("static initialization scales must be positive") - ( - reprojection_scale, - pose_scale, - translation_scale, - relative_rotation_scale, - relative_translation_scale, - normal_scale, - task_reference_rotation_scale, - task_reference_translation_scale, - ) = scales - - combinations_by_frame: list[list[dict[str, SquareTagPose]]] = [] - for frame in frames: - candidate_lists = [tuple(frame.get(role, ())) for role in role_names] - if any(not candidates for candidates in candidate_lists): - raise ValueError("every frame must contain every requested role") - combinations_by_frame.append( - [ - dict(zip(role_names, combination)) - for combination in product(*candidate_lists) - ] - ) - - def score_against_reference( - reference: Mapping[str, SquareTagPose], - reference_pairs: Mapping[ - tuple[str, str], tuple[Rotation, np.ndarray] - ], - combination: Mapping[str, SquareTagPose], - ) -> float: - score = sum( - pose.reprojection_error_px for pose in combination.values() - ) / reprojection_scale - score += sum( - _normal_alignment_rad(combination[first], combination[second]) - for first, second in normal_pair_names - ) / normal_scale - score += sum( - rotation_distance_rad( - reference[role].quaternion_xyzw, - combination[role].quaternion_xyzw, - ) - / pose_scale - + float( - np.linalg.norm( - np.asarray(combination[role].translation_xyz_m, dtype=float) - - np.asarray(reference[role].translation_xyz_m, dtype=float) - ) - ) - / translation_scale - for role in role_names - ) - for pair, (reference_rotation, reference_translation) in ( - reference_pairs.items() - ): - rotation, translation = _relative_pose( - combination[pair[0]], combination[pair[1]] - ) - score += ( - float((reference_rotation.inv() * rotation).magnitude()) - / relative_rotation_scale - + float(np.linalg.norm(translation - reference_translation)) - / relative_translation_scale - ) - for pair, (task_rotation, task_translation) in task_references.items(): - rotation, translation = _relative_pose( - combination[pair[0]], combination[pair[1]] - ) - score += ( - float((task_rotation.inv() * rotation).magnitude()) - / task_reference_rotation_scale - + float(np.linalg.norm(translation - task_translation)) - / task_reference_translation_scale - ) - return float(score) - - best_total = float("inf") - best_path: list[dict[str, SquareTagPose]] | None = None - for reference in combinations_by_frame[0]: - reference_pairs = { - pair: _relative_pose(reference[pair[0]], reference[pair[1]]) - for pair in pair_names - } - path = [reference] - total = score_against_reference(reference, reference_pairs, reference) - for combinations in combinations_by_frame[1:]: - scored = [ - ( - score_against_reference( - reference, reference_pairs, combination - ), - combination, - ) - for combination in combinations - ] - cost, selected = min(scored, key=lambda item: item[0]) - total += cost - path.append(selected) - if total < best_total: - best_total = total - best_path = path - - if best_path is None: - raise RuntimeError("static group initialization produced no path") - - # Reuse the complete quality calculation with exactly one chosen branch - # per role and frame. This retains all existing quality fields without - # reintroducing the combinatorial branch search. - reduced_frames = [ - {role: (frame[role],) for role in role_names} for frame in best_path - ] - selected_path, quality = select_rigid_group_trajectory( - reduced_frames, - roles=role_names, - fixed_pairs=pair_names, - reprojection_scale_px=reprojection_scale, - rotation_scale_rad=relative_rotation_scale, - translation_scale_m=relative_translation_scale, - normal_alignment_pairs=normal_pair_names, - normal_alignment_scale_rad=normal_scale, - ) - quality = dict(quality) - quality["total_cost"] = float(best_total) - quality["initialization_search"] = "static_reference" - quality["task_reference_used"] = ( - "true" if task_references else "false" - ) - return selected_path, quality - - -def _as_camera_matrix(camera_matrix: Sequence[Sequence[float]]) -> np.ndarray: - matrix = np.asarray(camera_matrix, dtype=np.float64) - if matrix.shape != (3, 3): - raise ValueError("camera_matrix must have shape (3, 3)") - if not np.all(np.isfinite(matrix)): - raise ValueError("camera_matrix must be finite") - if matrix[0, 0] <= 0.0 or matrix[1, 1] <= 0.0: - raise ValueError("camera focal lengths must be positive") - return matrix - - -def square_object_points(tag_size_m: float) -> np.ndarray: - """Return IPPE-square points matching apriltag_msgs corner order. - - ``apriltag_ros`` reports bottom-left, bottom-right, top-right, top-left. - OpenCV's ``SOLVEPNP_IPPE_SQUARE`` requires the same physical corners in - the order below. - """ - size = float(tag_size_m) - if not math.isfinite(size) or size <= 0.0: - raise ValueError("tag_size_m must be finite and positive") - half = size / 2.0 - return np.asarray( - [ - [-half, half, 0.0], - [half, half, 0.0], - [half, -half, 0.0], - [-half, -half, 0.0], - ], - dtype=np.float64, - ) - - -def solve_square_tag_ippe( - corners_xy: Sequence[Sequence[float]], - *, - tag_size_m: float, - camera_matrix: Sequence[Sequence[float]], -) -> list[SquareTagPose]: - """Return every finite, positive-depth IPPE pose for one square tag.""" - image_points = np.asarray(corners_xy, dtype=np.float64) - if image_points.shape != (4, 2): - raise ValueError("corners_xy must have shape (4, 2)") - if not np.all(np.isfinite(image_points)): - raise ValueError("corners_xy must be finite") - intrinsic = _as_camera_matrix(camera_matrix) - object_points = square_object_points(tag_size_m) - distortion = np.zeros((4, 1), dtype=np.float64) - - solved, rotation_vectors, translations, _ = cv2.solvePnPGeneric( - object_points, - image_points, - intrinsic, - distortion, - flags=cv2.SOLVEPNP_IPPE_SQUARE, - ) - if not solved: - return [] - - candidates: list[SquareTagPose] = [] - for rotation_vector, translation in zip(rotation_vectors, translations): - rotation_matrix, _ = cv2.Rodrigues(rotation_vector) - translation_vector = np.asarray(translation, dtype=float).reshape(3) - camera_points = ( - rotation_matrix @ object_points.T - + translation_vector.reshape(3, 1) - ).T - if np.min(camera_points[:, 2]) <= 0.0: - continue - projected, _ = cv2.projectPoints( - object_points, - rotation_vector, - translation_vector, - intrinsic, - distortion, - ) - residual = projected.reshape(4, 2) - image_points - reprojection_error = float( - np.sqrt(np.mean(np.sum(residual * residual, axis=1))) - ) - quaternion = Rotation.from_matrix(rotation_matrix).as_quat() - if not ( - np.all(np.isfinite(quaternion)) - and np.all(np.isfinite(translation_vector)) - and math.isfinite(reprojection_error) - ): - continue - candidates.append( - SquareTagPose( - quaternion_xyzw=tuple(float(value) for value in quaternion), - translation_xyz_m=tuple( - float(value) for value in translation_vector - ), - reprojection_error_px=reprojection_error, - ) - ) - return candidates - - -def rotation_distance_rad( - first_xyzw: Sequence[float], - second_xyzw: Sequence[float], -) -> float: - first = np.asarray(first_xyzw, dtype=float) - second = np.asarray(second_xyzw, dtype=float) - if first.shape != (4,) or second.shape != (4,): - raise ValueError("quaternions must contain four values") - first_norm = float(np.linalg.norm(first)) - second_norm = float(np.linalg.norm(second)) - if ( - not np.all(np.isfinite(first)) - or not np.all(np.isfinite(second)) - or first_norm <= 0.0 - or second_norm <= 0.0 - ): - raise ValueError("quaternions must be finite and non-zero") - # q and -q represent the same rotation. The absolute dot-product gives - # the geodesic SO(3) distance without constructing two scipy Rotation - # objects for every branch comparison in the live tracker. - cosine_half_angle = abs( - float(np.dot(first / first_norm, second / second_norm)) - ) - return 2.0 * math.acos(float(np.clip(cosine_half_angle, 0.0, 1.0))) - - -def select_continuous_pose( - candidates: Sequence[SquareTagPose], - *, - previous: SquareTagPose | None, - maximum_reprojection_error_px: float, - reprojection_tie_px: float, - maximum_pose_jump_rad: float, - maximum_translation_jump_m: float, - maximum_tag_tilt_rad: float, -) -> tuple[SquareTagPose | None, str]: - """Select the best IPPE branch using image fit and temporal continuity.""" - maximum_error = float(maximum_reprojection_error_px) - tie_error = float(reprojection_tie_px) - maximum_rotation = float(maximum_pose_jump_rad) - maximum_translation = float(maximum_translation_jump_m) - maximum_tilt = float(maximum_tag_tilt_rad) - if min( - maximum_error, - maximum_rotation, - maximum_translation, - maximum_tilt, - ) <= 0.0: - raise ValueError("PnP selection thresholds must be positive") - if tie_error < 0.0: - raise ValueError("reprojection_tie_px must be non-negative") - - eligible: list[SquareTagPose] = [] - for candidate in candidates: - if candidate.reprojection_error_px > maximum_error: - continue - normal = Rotation.from_quat(candidate.quaternion_xyzw).as_matrix()[:, 2] - tilt = math.acos(float(np.clip(abs(normal[2]), 0.0, 1.0))) - if tilt > maximum_tilt: - continue - eligible.append(candidate) - if not eligible: - return None, "no_pose_within_reprojection_or_tilt_limit" - - eligible.sort(key=lambda item: item.reprojection_error_px) - best = eligible[0] - if previous is None: - return best, "" - - # Temporal continuity must only break a genuine planar-PnP tie. The old - # implementation normalised reprojection error by the permissive 1.5 px - # rejection limit, which allowed a stale mirror branch at 0.25 px to beat - # the true branch at e.g. 0.05 px merely because it was closer to the - # preceding (already wrong) pose. Once one IPPE solution has a meaningful - # image-fit advantage, trust it and allow the tracker to leave the stale - # branch even if that correction is a large pose jump. - competitive = [ - candidate - for candidate in eligible - if candidate.reprojection_error_px - <= best.reprojection_error_px + tie_error - ] - if len(competitive) == 1: - return best, "" - - previous_translation = np.asarray(previous.translation_xyz_m, dtype=float) - scored: list[tuple[float, SquareTagPose]] = [] - for candidate in competitive: - rotation_jump = rotation_distance_rad( - previous.quaternion_xyzw, - candidate.quaternion_xyzw, - ) - translation_jump = float( - np.linalg.norm( - np.asarray(candidate.translation_xyz_m, dtype=float) - - previous_translation - ) - ) - if ( - rotation_jump > maximum_rotation - or translation_jump > maximum_translation - ): - continue - score = ( - ( - candidate.reprojection_error_px - - best.reprojection_error_px - ) - / max(tie_error, np.finfo(float).eps) - + rotation_jump / maximum_rotation - + translation_jump / maximum_translation - ) - scored.append((float(score), candidate)) - if not scored: - return None, "pose_jump" - - selected = min(scored, key=lambda item: item[0])[1] - previous_quaternion = np.asarray(previous.quaternion_xyzw, dtype=float) - selected_quaternion = np.asarray(selected.quaternion_xyzw, dtype=float) - if float(np.dot(previous_quaternion, selected_quaternion)) < 0.0: - selected = replace( - selected, - quaternion_xyzw=tuple( - float(value) for value in -selected_quaternion - ), - ) - return selected, "" - - -class SquareTagPoseTracker: - """Maintain the selected planar-PnP branch independently for each tag.""" - - def __init__( - self, - *, - maximum_reprojection_error_px: float, - reprojection_tie_px: float, - maximum_pose_jump_rad: float, - maximum_translation_jump_m: float, - maximum_tag_tilt_rad: float, - reset_after_seconds: float, - ) -> None: - self.maximum_reprojection_error_px = float( - maximum_reprojection_error_px - ) - self.reprojection_tie_px = float(reprojection_tie_px) - self.maximum_pose_jump_rad = float(maximum_pose_jump_rad) - self.maximum_translation_jump_m = float(maximum_translation_jump_m) - self.maximum_tag_tilt_rad = float(maximum_tag_tilt_rad) - self.reset_after_ns = int(float(reset_after_seconds) * 1_000_000_000) - if self.reset_after_ns <= 0: - raise ValueError("reset_after_seconds must be positive") - self._previous: dict[str, tuple[int, SquareTagPose]] = {} - self.last_candidates_by_role: dict[ - str, tuple[SquareTagPose, ...] - ] = {} - self.last_candidate_diagnostics_by_role: dict[ - str, dict[str, float | int] - ] = {} - self.branch_correction_counts: dict[str, int] = {} - - def reset(self) -> None: - self._previous.clear() - self.last_candidates_by_role.clear() - self.last_candidate_diagnostics_by_role.clear() - self.branch_correction_counts.clear() - - def estimate( - self, - role: str, - corners_xy: Sequence[Sequence[float]], - *, - tag_size_m: float, - camera_matrix: Sequence[Sequence[float]], - stamp_ns: int, - reprojection_tie_px: float | None = None, - ) -> tuple[SquareTagPose | None, str]: - try: - candidates = solve_square_tag_ippe( - corners_xy, - tag_size_m=tag_size_m, - camera_matrix=camera_matrix, - ) - except (ValueError, cv2.error): - self.last_candidates_by_role[str(role)] = () - self.last_candidate_diagnostics_by_role[str(role)] = { - "solved_candidate_count": 0, - "reprojection_candidate_count": 0, - "independent_tilt_candidate_count": 0, - "maximum_reprojection_error_px": float( - self.maximum_reprojection_error_px - ), - "maximum_independent_tilt_deg": math.degrees( - self.maximum_tag_tilt_rad - ), - } - return None, "pnp_solve_failed" - if not candidates: - self.last_candidates_by_role[str(role)] = () - self.last_candidate_diagnostics_by_role[str(role)] = { - "solved_candidate_count": 0, - "reprojection_candidate_count": 0, - "independent_tilt_candidate_count": 0, - "maximum_reprojection_error_px": float( - self.maximum_reprojection_error_px - ), - "maximum_independent_tilt_deg": math.degrees( - self.maximum_tag_tilt_rad - ), - } - return None, "pnp_solve_failed" - reprojection_candidates = [ - candidate - for candidate in candidates - if candidate.reprojection_error_px - <= self.maximum_reprojection_error_px - ] - candidate_tilts_rad: list[float] = [] - independent_candidates: list[SquareTagPose] = [] - for candidate in reprojection_candidates: - normal = Rotation.from_quat( - candidate.quaternion_xyzw - ).as_matrix()[:, 2] - tilt = math.acos( - float(np.clip(abs(normal[2]), 0.0, 1.0)) - ) - candidate_tilts_rad.append(float(tilt)) - if tilt <= self.maximum_tag_tilt_rad: - independent_candidates.append(candidate) - - # Candidate generation and candidate selection have different - # contracts. The per-Tag tilt limit protects a pose used without any - # other geometry, but it must not erase a finite, low-reprojection - # IPPE solution before SquareTagGroupPoseTracker can evaluate it - # against the fixed palm reference, the articulated chain and the - # preceding group pose. At a strongly oblique view the planar - # ambiguity is usually smaller, and rejecting both branches at a - # fixed angle caused deterministic mid-sweep holes despite continuous - # image detections. Group tracking therefore receives every - # reprojection-valid candidate; independent tracking below retains the - # original tilt safety gate. - self.last_candidates_by_role[str(role)] = tuple( - reprojection_candidates - ) - diagnostics: dict[str, float | int] = { - "solved_candidate_count": len(candidates), - "reprojection_candidate_count": len(reprojection_candidates), - "independent_tilt_candidate_count": len(independent_candidates), - "minimum_reprojection_error_px": float( - min( - candidate.reprojection_error_px - for candidate in candidates - ) - ), - "maximum_reprojection_error_px": float( - self.maximum_reprojection_error_px - ), - "maximum_independent_tilt_deg": math.degrees( - self.maximum_tag_tilt_rad - ), - } - if candidate_tilts_rad: - diagnostics["minimum_candidate_tilt_deg"] = math.degrees( - min(candidate_tilts_rad) - ) - diagnostics["maximum_candidate_tilt_deg"] = math.degrees( - max(candidate_tilts_rad) - ) - self.last_candidate_diagnostics_by_role[str(role)] = diagnostics - if not independent_candidates: - return None, "no_pose_within_reprojection_or_tilt_limit" - - previous_record = self._previous.get(str(role)) - previous: SquareTagPose | None = None - if previous_record is not None: - previous_stamp, previous_pose = previous_record - elapsed = int(stamp_ns) - previous_stamp - if 0 <= elapsed <= self.reset_after_ns: - previous = previous_pose - - selected, reason = select_continuous_pose( - independent_candidates, - previous=previous, - maximum_reprojection_error_px=( - self.maximum_reprojection_error_px - ), - reprojection_tie_px=( - self.reprojection_tie_px - if reprojection_tie_px is None - else float(reprojection_tie_px) - ), - maximum_pose_jump_rad=self.maximum_pose_jump_rad, - maximum_translation_jump_m=self.maximum_translation_jump_m, - maximum_tag_tilt_rad=self.maximum_tag_tilt_rad, - ) - if selected is not None: - if previous is not None: - rotation_jump = rotation_distance_rad( - previous.quaternion_xyzw, - selected.quaternion_xyzw, - ) - translation_jump = float( - np.linalg.norm( - np.asarray(selected.translation_xyz_m, dtype=float) - - np.asarray( - previous.translation_xyz_m, - dtype=float, - ) - ) - ) - if ( - rotation_jump > self.maximum_pose_jump_rad - or translation_jump - > self.maximum_translation_jump_m - ): - key = str(role) - self.branch_correction_counts[key] = ( - self.branch_correction_counts.get(key, 0) + 1 - ) - self._previous[str(role)] = (int(stamp_ns), selected) - return selected, reason - - -class SquareTagGroupPoseTracker: - """Choose all tag branches together using thumb-chain continuity. - - A 30 px planar tag has two IPPE solutions whose reprojection errors can - exchange order from one frame to the next. Tracking each tag - independently can therefore choose an incompatible pair for a relative - joint such as T4->T5. This tracker enumerates the small Cartesian product - (at most 2**4 combinations) and favours the combination that keeps both - the camera poses and all adjacent relative poses continuous. - """ - - def __init__( - self, - *, - roles: Sequence[str], - adjacent_pairs: Sequence[tuple[str, str]], - maximum_pose_jump_rad: float, - maximum_translation_jump_m: float, - relative_rotation_scale_rad: float, - relative_translation_scale_m: float, - reprojection_scale_px: float, - reprojection_weight: float, - reset_after_seconds: float, - initialization_frames: int = 1, - normal_alignment_pairs: Sequence[tuple[str, str]] = (), - normal_alignment_scale_rad: float = math.radians(5.0), - maximum_normal_alignment_rad: float | None = None, - return_reference_rotation_scale_rad: float = math.radians(1.0), - return_reference_maximum_command_gap_u8: int = 8, - coupled_rotation_pairs: Sequence[ - tuple[str, str, str, str, float] - ] = (), - coupled_rotation_scale_rad: float = math.radians(3.0), - maximum_coupled_rotation_residual_rad: float | None = None, - ) -> None: - self.roles = tuple(str(role) for role in roles) - self.adjacent_pairs = tuple( - (str(parent), str(child)) - for parent, child in adjacent_pairs - ) - self.normal_alignment_pairs = tuple( - (str(first), str(second)) - for first, second in normal_alignment_pairs - ) - self.coupled_rotation_pairs = tuple( - ( - str(driver_parent), - str(driver_child), - str(follower_parent), - str(follower_child), - float(multiplier), - ) - for ( - driver_parent, - driver_child, - follower_parent, - follower_child, - multiplier, - ) in coupled_rotation_pairs - ) - if not self.roles or len(set(self.roles)) != len(self.roles): - raise ValueError("roles must be non-empty and unique") - if any( - parent not in self.roles or child not in self.roles - for parent, child in ( - *self.adjacent_pairs, - *self.normal_alignment_pairs, - ) - ): - raise ValueError("group geometry pairs must reference roles") - self.maximum_pose_jump_rad = float(maximum_pose_jump_rad) - self.maximum_translation_jump_m = float( - maximum_translation_jump_m - ) - self.relative_rotation_scale_rad = float( - relative_rotation_scale_rad - ) - self.relative_translation_scale_m = float( - relative_translation_scale_m - ) - self.reprojection_scale_px = float(reprojection_scale_px) - self.reprojection_weight = float(reprojection_weight) - self.initialization_frames = int(initialization_frames) - self.normal_alignment_scale_rad = float( - normal_alignment_scale_rad - ) - self.maximum_normal_alignment_rad = ( - None - if maximum_normal_alignment_rad is None - else float(maximum_normal_alignment_rad) - ) - self.return_reference_rotation_scale_rad = float( - return_reference_rotation_scale_rad - ) - self.return_reference_maximum_command_gap_u8 = int( - return_reference_maximum_command_gap_u8 - ) - self.coupled_rotation_scale_rad = float( - coupled_rotation_scale_rad - ) - self.maximum_coupled_rotation_residual_rad = ( - None - if maximum_coupled_rotation_residual_rad is None - else float(maximum_coupled_rotation_residual_rad) - ) - reset_seconds = float(reset_after_seconds) - if min( - self.maximum_pose_jump_rad, - self.maximum_translation_jump_m, - self.relative_rotation_scale_rad, - self.relative_translation_scale_m, - self.reprojection_scale_px, - self.normal_alignment_scale_rad, - self.return_reference_rotation_scale_rad, - self.coupled_rotation_scale_rad, - reset_seconds, - ) <= 0.0: - raise ValueError("group tracking scales must be positive") - if self.reprojection_weight < 0.0: - raise ValueError("reprojection_weight must be non-negative") - if self.initialization_frames < 1: - raise ValueError("initialization_frames must be positive") - if self.return_reference_maximum_command_gap_u8 < 0: - raise ValueError( - "return reference maximum command gap must be non-negative" - ) - if ( - self.maximum_normal_alignment_rad is not None - and self.maximum_normal_alignment_rad <= 0.0 - ): - raise ValueError("maximum normal alignment must be positive") - if any( - role not in self.roles - for coupling in self.coupled_rotation_pairs - for role in coupling[:4] - ): - raise ValueError("coupled rotation pairs must reference roles") - if any( - multiplier <= 0.0 - for *_, multiplier in self.coupled_rotation_pairs - ): - raise ValueError("coupled rotation multipliers must be positive") - if ( - self.maximum_coupled_rotation_residual_rad is not None - and self.maximum_coupled_rotation_residual_rad <= 0.0 - ): - raise ValueError( - "maximum coupled rotation residual must be positive" - ) - self.reset_after_ns = int(reset_seconds * 1_000_000_000) - self._previous: dict[str, SquareTagPose] = {} - self._previous_stamp_ns: int | None = None - self._initial_candidates: list[ - dict[str, tuple[SquareTagPose, ...]] - ] = [] - self._initial_stamps_ns: list[int] = [] - self.last_initialization_quality: dict[str, float | str] = {} - self.branch_correction_counts: dict[str, int] = {} - self._decreasing_relative_rotations: dict[ - int, dict[tuple[str, str], Rotation] - ] = {} - self._coupled_reference_rotations: dict[ - tuple[str, str], Rotation - ] = {} - self._task_reference_relative_poses: dict[ - tuple[str, str], tuple[Rotation, np.ndarray] - ] = {} - self.last_missing_roles: tuple[str, ...] = () - - def reset(self, *, preserve_task_reference: bool = False) -> None: - self._previous.clear() - self._previous_stamp_ns = None - self._initial_candidates.clear() - self._initial_stamps_ns.clear() - self.last_initialization_quality.clear() - self.branch_correction_counts.clear() - self._decreasing_relative_rotations.clear() - self._coupled_reference_rotations.clear() - self.last_missing_roles = () - if not preserve_task_reference: - self._task_reference_relative_poses.clear() - - def _task_reference_cost( - self, combination: Mapping[str, SquareTagPose] - ) -> float: - residual = 0.0 - for pair, (expected_rotation, expected_translation) in ( - self._task_reference_relative_poses.items() - ): - rotation, translation = _relative_pose( - combination[pair[0]], combination[pair[1]] - ) - residual += ( - float((expected_rotation.inv() * rotation).magnitude()) - / self.return_reference_rotation_scale_rad - + float(np.linalg.norm(translation - expected_translation)) - / self.relative_translation_scale_m - ) - return residual - - def _coupled_rotation_residuals( - self, combination: Mapping[str, SquareTagPose] - ) -> tuple[float, ...]: - if not self.coupled_rotation_pairs: - return () - residuals: list[float] = [] - for ( - driver_parent, - driver_child, - follower_parent, - follower_child, - multiplier, - ) in self.coupled_rotation_pairs: - driver_pair = (driver_parent, driver_child) - follower_pair = (follower_parent, follower_child) - if ( - driver_pair not in self._coupled_reference_rotations - or follower_pair not in self._coupled_reference_rotations - ): - return () - driver_rotation = _relative_pose( - combination[driver_parent], combination[driver_child] - )[0] - follower_rotation = _relative_pose( - combination[follower_parent], combination[follower_child] - )[0] - driver_travel = ( - self._coupled_reference_rotations[driver_pair].inv() - * driver_rotation - ).magnitude() - follower_travel = ( - self._coupled_reference_rotations[follower_pair].inv() - * follower_rotation - ).magnitude() - residuals.append( - abs(float(follower_travel) - multiplier * float(driver_travel)) - ) - return tuple(residuals) - - def _informative_coupled_rotation_costs( - self, - combinations: Sequence[Mapping[str, SquareTagPose]], - ) -> tuple[float, ...]: - """Return branch costs only while the weak coupling prior is credible. - - The URDF mimic ratio is useful for distinguishing two planar-IPPE - branches, but it is not measurement truth for a passive joint. Once - every otherwise viable combination disagrees with that ratio, using - it would bias the measured curve (and previously rejected every - frame). In that case fall back to visual continuity for this frame. - """ - residuals = tuple( - self._coupled_rotation_residuals(combination) - for combination in combinations - ) - if not residuals or not any(residuals): - return tuple(0.0 for _ in combinations) - if ( - self.maximum_coupled_rotation_residual_rad is not None - and not any( - values - and max(values) - <= self.maximum_coupled_rotation_residual_rad - for values in residuals - ) - ): - return tuple(0.0 for _ in combinations) - return tuple( - sum(values) / self.coupled_rotation_scale_rad - for values in residuals - ) - - def _return_reference( - self, command_u8: int | None - ) -> dict[tuple[str, str], tuple[Rotation, np.ndarray | None]]: - if command_u8 is None or not self._decreasing_relative_rotations: - return {} - command = int(command_u8) - nearest = min( - self._decreasing_relative_rotations, - key=lambda candidate: abs(candidate - command), - ) - if ( - abs(nearest - command) - > self.return_reference_maximum_command_gap_u8 - ): - return {} - references = self._decreasing_relative_rotations[nearest] - commands = sorted(self._decreasing_relative_rotations) - axes: dict[tuple[str, str], np.ndarray | None] = {} - for pair in self.adjacent_pairs: - endpoint_delta = ( - self._decreasing_relative_rotations[commands[-1]][pair].inv() - * self._decreasing_relative_rotations[commands[0]][pair] - ).as_rotvec() - norm = float(np.linalg.norm(endpoint_delta)) - axes[pair] = ( - None - if norm < math.radians(5.0) - else endpoint_delta / norm - ) - return { - pair: (rotation, axes[pair]) - for pair, rotation in references.items() - } - - def _return_reference_cost( - self, - combination: Mapping[str, SquareTagPose], - reference: Mapping[ - tuple[str, str], tuple[Rotation, np.ndarray | None] - ], - ) -> float: - residual = 0.0 - for pair, (expected, motion_axis) in reference.items(): - vector = ( - expected.inv() - * _relative_pose( - combination[pair[0]], combination[pair[1]] - )[0] - ).as_rotvec() - if motion_axis is not None: - # The outbound trajectory identifies the physical one-DOF - # motion axis. Do not penalize return travel along that axis: - # it may contain real mechanical hysteresis that calibration - # must measure. A planar-IPPE mirror branch appears primarily - # as a large orthogonal tilt and is rejected by this residual. - vector = vector - motion_axis * float(vector @ motion_axis) - residual += float(np.linalg.norm(vector)) - return residual / self.return_reference_rotation_scale_rad - - def select( - self, - candidates_by_role: Mapping[str, Sequence[SquareTagPose]], - *, - stamp_ns: int, - trajectory_command_u8: int | None = None, - trajectory_direction: str | None = None, - ) -> tuple[dict[str, SquareTagPose] | None, str]: - """Return one mutually consistent pose for every configured role.""" - direction = ( - None - if trajectory_direction is None - else str(trajectory_direction) - ) - if direction not in {None, "decreasing", "increasing"}: - raise ValueError( - "trajectory_direction must be decreasing or increasing" - ) - return_reference = ( - self._return_reference(trajectory_command_u8) - if direction == "increasing" - else {} - ) - candidate_lists = [ - tuple(candidates_by_role.get(role, ())) - for role in self.roles - ] - self.last_missing_roles = tuple( - role - for role, candidates in zip(self.roles, candidate_lists) - if not candidates - ) - if self.last_missing_roles: - return None, "group_missing_pose_candidates" - self.last_missing_roles = () - - combinations = [ - dict(zip(self.roles, combination)) - for combination in product(*candidate_lists) - ] - minimum_errors = { - role: min( - candidate.reprojection_error_px - for candidate in candidates - ) - for role, candidates in zip(self.roles, candidate_lists) - } - stamp = int(stamp_ns) - previous_is_fresh = ( - self._previous_stamp_ns is not None - and 0 <= stamp - self._previous_stamp_ns - <= self.reset_after_ns - and set(self._previous) == set(self.roles) - ) - - if not previous_is_fresh: - if self._previous_stamp_ns is not None: - self._previous.clear() - self._previous_stamp_ns = None - self._initial_candidates.clear() - self._initial_stamps_ns.clear() - self.last_initialization_quality.clear() - if self.initialization_frames > 1: - if self._initial_stamps_ns and not ( - 0 <= stamp - self._initial_stamps_ns[-1] - <= self.reset_after_ns - ): - self._initial_candidates.clear() - self._initial_stamps_ns.clear() - self._initial_candidates.append( - { - role: tuple(candidates_by_role.get(role, ())) - for role in self.roles - } - ) - self._initial_stamps_ns.append(stamp) - if len(self._initial_candidates) < self.initialization_frames: - return ( - None, - "group_initializing:" - f"{len(self._initial_candidates)}/" - f"{self.initialization_frames}", - ) - selected_path, initialization_quality = ( - select_static_rigid_group_initialization( - self._initial_candidates, - roles=self.roles, - fixed_pairs=self.adjacent_pairs, - reprojection_scale_px=self.reprojection_scale_px, - maximum_pose_jump_rad=self.maximum_pose_jump_rad, - maximum_translation_jump_m=( - self.maximum_translation_jump_m - ), - relative_rotation_scale_rad=( - self.relative_rotation_scale_rad - ), - relative_translation_scale_m=( - self.relative_translation_scale_m - ), - normal_alignment_pairs=( - self.normal_alignment_pairs - ), - normal_alignment_scale_rad=( - self.normal_alignment_scale_rad - ), - task_reference_pairs=( - self._task_reference_relative_poses - ), - task_reference_rotation_scale_rad=( - self.return_reference_rotation_scale_rad - ), - task_reference_translation_scale_m=( - self.relative_translation_scale_m - ), - ) - ) - selected = selected_path[-1] - stamp = self._initial_stamps_ns[-1] - self.last_initialization_quality = dict( - initialization_quality - ) - self._initial_candidates.clear() - self._initial_stamps_ns.clear() - if ( - self.maximum_normal_alignment_rad is not None - and float( - initialization_quality[ - "maximum_normal_alignment_rad" - ] - ) - > self.maximum_normal_alignment_rad - ): - return None, "group_normal_alignment" - else: - coupling_costs = self._informative_coupled_rotation_costs( - combinations - ) - selected = min( - zip(combinations, coupling_costs), - key=lambda item: ( - sum( - pose.reprojection_error_px - for pose in item[0].values() - ) - / self.reprojection_scale_px - + sum( - _normal_alignment_rad( - item[0][first], item[0][second] - ) - for first, second in self.normal_alignment_pairs - ) - / self.normal_alignment_scale_rad - + self._return_reference_cost( - item[0], return_reference - ) - + item[1] - + self._task_reference_cost(item[0]) - ), - )[0] - maximum_alignment = max( - ( - _normal_alignment_rad( - selected[first], selected[second] - ) - for first, second in self.normal_alignment_pairs - ), - default=0.0, - ) - self.last_initialization_quality = { - "maximum_normal_alignment_rad": maximum_alignment - } - if ( - self.maximum_normal_alignment_rad is not None - and maximum_alignment - > self.maximum_normal_alignment_rad - ): - return None, "group_normal_alignment" - else: - previous_relative = { - pair: _relative_pose( - self._previous[pair[0]], - self._previous[pair[1]], - ) - for pair in self.adjacent_pairs - } - base_scored: list[tuple[float, dict[str, SquareTagPose]]] = [] - for combination in combinations: - absolute_rotation_motion = 0.0 - absolute_translation_motion = 0.0 - rejected = False - for role in self.roles: - rotation_motion = rotation_distance_rad( - self._previous[role].quaternion_xyzw, - combination[role].quaternion_xyzw, - ) - translation_motion = float( - np.linalg.norm( - np.asarray( - combination[role].translation_xyz_m, - dtype=float, - ) - - np.asarray( - self._previous[role].translation_xyz_m, - dtype=float, - ) - ) - ) - if ( - rotation_motion > self.maximum_pose_jump_rad - or translation_motion - > self.maximum_translation_jump_m - ): - rejected = True - break - absolute_rotation_motion += rotation_motion - absolute_translation_motion += translation_motion - if rejected: - continue - - relative_rotation_motion = 0.0 - relative_translation_motion = 0.0 - for pair in self.adjacent_pairs: - rotation, translation = _relative_pose( - combination[pair[0]], - combination[pair[1]], - ) - old_rotation, old_translation = previous_relative[pair] - relative_rotation_motion += float( - (old_rotation.inv() * rotation).magnitude() - ) - relative_translation_motion += float( - np.linalg.norm(translation - old_translation) - ) - - reprojection_penalty = sum( - max( - 0.0, - combination[role].reprojection_error_px - - minimum_errors[role], - ) - for role in self.roles - ) / self.reprojection_scale_px - score = ( - absolute_rotation_motion - / self.maximum_pose_jump_rad - + absolute_translation_motion - / self.maximum_translation_jump_m - + relative_rotation_motion - / self.relative_rotation_scale_rad - + relative_translation_motion - / self.relative_translation_scale_m - + self.reprojection_weight * reprojection_penalty - + self._return_reference_cost( - combination, return_reference - ) - ) - base_scored.append((float(score), combination)) - - if not base_scored: - return None, "group_pose_jump" - coupling_costs = self._informative_coupled_rotation_costs( - [combination for _, combination in base_scored] - ) - scored = [ - (base_score + coupling_cost, combination) - for (base_score, combination), coupling_cost in zip( - base_scored, coupling_costs - ) - ] - selected = min(scored, key=lambda item: item[0])[1] - - aligned: dict[str, SquareTagPose] = {} - for role in self.roles: - pose = selected[role] - if previous_is_fresh: - old_quaternion = np.asarray( - self._previous[role].quaternion_xyzw, - dtype=float, - ) - quaternion = np.asarray( - pose.quaternion_xyzw, - dtype=float, - ) - if float(np.dot(old_quaternion, quaternion)) < 0.0: - pose = replace( - pose, - quaternion_xyzw=tuple( - float(value) for value in -quaternion - ), - ) - best_reprojection = min( - candidate_lists[self.roles.index(role)], - key=lambda candidate: candidate.reprojection_error_px, - ) - if pose != best_reprojection: - self.branch_correction_counts[role] = ( - self.branch_correction_counts.get(role, 0) + 1 - ) - aligned[role] = pose - - self._previous = aligned - self._previous_stamp_ns = stamp - if ( - direction == "decreasing" - and trajectory_command_u8 is not None - and not self._coupled_reference_rotations - ): - for ( - driver_parent, - driver_child, - follower_parent, - follower_child, - _multiplier, - ) in self.coupled_rotation_pairs: - for pair in ( - (driver_parent, driver_child), - (follower_parent, follower_child), - ): - self._coupled_reference_rotations[pair] = _relative_pose( - aligned[pair[0]], aligned[pair[1]] - )[0] - if direction == "decreasing" and trajectory_command_u8 is not None: - self._decreasing_relative_rotations[ - int(trajectory_command_u8) - ] = { - pair: _relative_pose( - aligned[pair[0]], aligned[pair[1]] - )[0] - for pair in self.adjacent_pairs - } - if not self._task_reference_relative_poses: - self._task_reference_relative_poses = { - pair: _relative_pose( - aligned[pair[0]], aligned[pair[1]] - ) - for pair in self.adjacent_pairs - } - return dict(aligned), "" +"""Stable PnP API; implementations are separated by pose-estimation responsibility.""" + +from .tag_pose.types import SquareTagPose as SquareTagPose +from .tag_pose.relative import _relative_pose as _relative_pose +from .tag_pose.relative import _normal_alignment_rad as _normal_alignment_rad +from .tag_pose.trajectory import select_rigid_group_trajectory as select_rigid_group_trajectory +from .tag_pose.rigid_group import select_static_rigid_group_initialization as select_static_rigid_group_initialization +from .tag_pose.ippe import _as_camera_matrix as _as_camera_matrix +from .tag_pose.ippe import square_object_points as square_object_points +from .tag_pose.ippe import solve_square_tag_ippe as solve_square_tag_ippe +from .tag_pose.ippe import rotation_distance_rad as rotation_distance_rad +from .tag_pose.tracking import _validate_reprojection_thresholds as _validate_reprojection_thresholds +from .tag_pose.tracking import select_continuous_pose as select_continuous_pose +from .tag_pose.tracking import SquareTagPoseTracker as SquareTagPoseTracker +from .tag_pose.rigid_group import SquareTagGroupPoseTracker as SquareTagGroupPoseTracker +from .tag_pose.parameters import PoseTrackingParameters as PoseTrackingParameters +from .tag_pose.parameters import DEFAULT_POSE_TRACKING_PARAMETERS as DEFAULT_POSE_TRACKING_PARAMETERS +from .tag_pose.parameters import DEFAULT_REPROJECTION_TIE_PX as DEFAULT_REPROJECTION_TIE_PX diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/__init__.py new file mode 100644 index 0000000..b85ebd0 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/__init__.py @@ -0,0 +1 @@ +"""Shared pose estimation primitives; public API is core.geometry.pnp.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/ippe.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/ippe.py new file mode 100644 index 0000000..f7da3a9 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/ippe.py @@ -0,0 +1,137 @@ +"""Ippe for shared square Tag pose estimation.""" + +from __future__ import annotations + +from typing import Sequence +import math + +from scipy.spatial.transform import Rotation +import cv2 +import numpy as np + +from .types import SquareTagPose + + +def _as_camera_matrix(camera_matrix: Sequence[Sequence[float]]) -> np.ndarray: + matrix = np.asarray(camera_matrix, dtype=np.float64) + if matrix.shape != (3, 3): + raise ValueError("camera_matrix must have shape (3, 3)") + if not np.all(np.isfinite(matrix)): + raise ValueError("camera_matrix must be finite") + if matrix[0, 0] <= 0.0 or matrix[1, 1] <= 0.0: + raise ValueError("camera focal lengths must be positive") + return matrix + + +def square_object_points(tag_size_m: float) -> np.ndarray: + """Return IPPE-square points matching apriltag_msgs corner order. + + ``apriltag_ros`` reports bottom-left, bottom-right, top-right, top-left. + OpenCV's ``SOLVEPNP_IPPE_SQUARE`` requires the same physical corners in + the order below. + """ + size = float(tag_size_m) + if not math.isfinite(size) or size <= 0.0: + raise ValueError("tag_size_m must be finite and positive") + half = size / 2.0 + return np.asarray( + [ + [-half, half, 0.0], + [half, half, 0.0], + [half, -half, 0.0], + [-half, -half, 0.0], + ], + dtype=np.float64, + ) + + +def solve_square_tag_ippe( + corners_xy: Sequence[Sequence[float]], + *, + tag_size_m: float, + camera_matrix: Sequence[Sequence[float]], +) -> list[SquareTagPose]: + """Return every finite, positive-depth IPPE pose for one square tag.""" + image_points = np.asarray(corners_xy, dtype=np.float64) + if image_points.shape != (4, 2): + raise ValueError("corners_xy must have shape (4, 2)") + if not np.all(np.isfinite(image_points)): + raise ValueError("corners_xy must be finite") + intrinsic = _as_camera_matrix(camera_matrix) + object_points = square_object_points(tag_size_m) + distortion = np.zeros((4, 1), dtype=np.float64) + + solved, rotation_vectors, translations, _ = cv2.solvePnPGeneric( + object_points, + image_points, + intrinsic, + distortion, + flags=cv2.SOLVEPNP_IPPE_SQUARE, + ) + if not solved: + return [] + + candidates: list[SquareTagPose] = [] + for rotation_vector, translation in zip(rotation_vectors, translations): + rotation_matrix, _ = cv2.Rodrigues(rotation_vector) + translation_vector = np.asarray(translation, dtype=float).reshape(3) + camera_points = ( + rotation_matrix @ object_points.T + + translation_vector.reshape(3, 1) + ).T + if np.min(camera_points[:, 2]) <= 0.0: + continue + projected, _ = cv2.projectPoints( + object_points, + rotation_vector, + translation_vector, + intrinsic, + distortion, + ) + residual = projected.reshape(4, 2) - image_points + reprojection_error = float( + np.sqrt(np.mean(np.sum(residual * residual, axis=1))) + ) + quaternion = Rotation.from_matrix(rotation_matrix).as_quat() + if not ( + np.all(np.isfinite(quaternion)) + and np.all(np.isfinite(translation_vector)) + and math.isfinite(reprojection_error) + ): + continue + candidates.append( + SquareTagPose( + quaternion_xyzw=tuple(float(value) for value in quaternion), + translation_xyz_m=tuple( + float(value) for value in translation_vector + ), + reprojection_error_px=reprojection_error, + ) + ) + return candidates + + +def rotation_distance_rad( + first_xyzw: Sequence[float], + second_xyzw: Sequence[float], +) -> float: + first = np.asarray(first_xyzw, dtype=float) + second = np.asarray(second_xyzw, dtype=float) + if first.shape != (4,) or second.shape != (4,): + raise ValueError("quaternions must contain four values") + first_norm = float(np.linalg.norm(first)) + second_norm = float(np.linalg.norm(second)) + if ( + not np.all(np.isfinite(first)) + or not np.all(np.isfinite(second)) + or first_norm <= 0.0 + or second_norm <= 0.0 + ): + raise ValueError("quaternions must be finite and non-zero") + # q and -q represent the same rotation. The absolute dot-product gives + # the geodesic SO(3) distance without constructing two scipy Rotation + # objects for every branch comparison in the live tracker. + cosine_half_angle = abs( + float(np.dot(first / first_norm, second / second_norm)) + ) + return 2.0 * math.acos(float(np.clip(cosine_half_angle, 0.0, 1.0))) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/parameters.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/parameters.py new file mode 100644 index 0000000..37177e1 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/parameters.py @@ -0,0 +1,21 @@ +"""Parameters for shared square Tag pose estimation.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math + +@dataclass(frozen=True) +class PoseTrackingParameters: + """One shared set of defaults, with angles stored in radians.""" + + maximum_reprojection_error_px: float = 1.5 + reprojection_tie_px: float = 0.03 + maximum_pose_jump_rad: float = math.radians(35.0) + maximum_translation_jump_m: float = 0.04 + maximum_tag_tilt_rad: float = math.radians(75.0) + reset_after_seconds: float = 5.0 + + +DEFAULT_POSE_TRACKING_PARAMETERS = PoseTrackingParameters() +DEFAULT_REPROJECTION_TIE_PX = DEFAULT_POSE_TRACKING_PARAMETERS.reprojection_tie_px diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/relative.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/relative.py new file mode 100644 index 0000000..0fc3324 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/relative.py @@ -0,0 +1,37 @@ +"""Relative for shared square Tag pose estimation.""" + +from __future__ import annotations + +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from .types import SquareTagPose + + +def _relative_pose( + parent: SquareTagPose, + child: SquareTagPose, +) -> tuple[Rotation, np.ndarray]: + parent_rotation = Rotation.from_quat(parent.quaternion_xyzw) + child_rotation = Rotation.from_quat(child.quaternion_xyzw) + relative_rotation = parent_rotation.inv() * child_rotation + relative_translation = parent_rotation.inv().apply( + np.asarray(child.translation_xyz_m, dtype=float) + - np.asarray(parent.translation_xyz_m, dtype=float) + ) + return relative_rotation, relative_translation + + +def _normal_alignment_rad(first: SquareTagPose, second: SquareTagPose) -> float: + """Return the undirected angle between two observed Tag face normals.""" + first_normal = Rotation.from_quat(first.quaternion_xyzw).apply( + [0.0, 0.0, 1.0] + ) + second_normal = Rotation.from_quat(second.quaternion_xyzw).apply( + [0.0, 0.0, 1.0] + ) + return math.acos( + abs(float(np.clip(first_normal @ second_normal, -1.0, 1.0))) + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/rigid_group.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/rigid_group.py new file mode 100644 index 0000000..e5d9b81 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/rigid_group.py @@ -0,0 +1,880 @@ +"""Rigid group for shared square Tag pose estimation.""" + +from __future__ import annotations + +from dataclasses import replace +from itertools import product +from typing import Mapping, Sequence +import math + +from scipy.spatial.transform import Rotation +import numpy as np + +from .ippe import rotation_distance_rad +from .relative import _normal_alignment_rad, _relative_pose +from .trajectory import select_rigid_group_trajectory +from .types import SquareTagPose + + +def select_static_rigid_group_initialization( + frames: Sequence[Mapping[str, Sequence[SquareTagPose]]], + *, + roles: Sequence[str], + fixed_pairs: Sequence[tuple[str, str]], + reprojection_scale_px: float, + maximum_pose_jump_rad: float, + maximum_translation_jump_m: float, + relative_rotation_scale_rad: float, + relative_translation_scale_m: float, + normal_alignment_pairs: Sequence[tuple[str, str]] = (), + normal_alignment_scale_rad: float = math.radians(5.0), + task_reference_pairs: Mapping[ + tuple[str, str], tuple[Rotation, np.ndarray] + ] | None = None, + task_reference_rotation_scale_rad: float = math.radians(1.0), + task_reference_translation_scale_m: float = 0.01, +) -> tuple[list[dict[str, SquareTagPose]], dict[str, float | str]]: + """Select a static multi-Tag IPPE branch path in bounded time. + + Group initialization is performed while the hand is held at an endpoint, + so every frame should describe the same camera and relative Tag poses. + Enumerating a full Viterbi transition matrix for every possible first-frame + branch is therefore unnecessary: with four two-branch Tags and eight + frames it performs more than one hundred thousand scipy rotations and can + block the live ROS callback for several seconds. + + Instead, treat every first-frame combination as a possible static + reference and independently select the closest combination in each later + frame. This preserves the multi-frame rigidity and normal-alignment + evidence while changing the search from O(F*C^2*C0) to O(F*C*C0). + """ + role_names = tuple(str(role) for role in roles) + pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs) + normal_pair_names = tuple( + (str(first), str(second)) for first, second in normal_alignment_pairs + ) + task_references = dict(task_reference_pairs or {}) + if not frames: + raise ValueError("at least one PnP frame is required") + if not role_names or len(set(role_names)) != len(role_names): + raise ValueError("roles must be non-empty and unique") + if any( + parent not in role_names or child not in role_names + for parent, child in ( + *pair_names, + *normal_pair_names, + *task_references, + ) + ): + raise ValueError("geometry pairs must reference roles") + scales = ( + float(reprojection_scale_px), + float(maximum_pose_jump_rad), + float(maximum_translation_jump_m), + float(relative_rotation_scale_rad), + float(relative_translation_scale_m), + float(normal_alignment_scale_rad), + float(task_reference_rotation_scale_rad), + float(task_reference_translation_scale_m), + ) + if min(scales) <= 0.0: + raise ValueError("static initialization scales must be positive") + ( + reprojection_scale, + pose_scale, + translation_scale, + relative_rotation_scale, + relative_translation_scale, + normal_scale, + task_reference_rotation_scale, + task_reference_translation_scale, + ) = scales + + combinations_by_frame: list[list[dict[str, SquareTagPose]]] = [] + for frame in frames: + candidate_lists = [tuple(frame.get(role, ())) for role in role_names] + if any(not candidates for candidates in candidate_lists): + raise ValueError("every frame must contain every requested role") + combinations_by_frame.append( + [ + dict(zip(role_names, combination)) + for combination in product(*candidate_lists) + ] + ) + + def score_against_reference( + reference: Mapping[str, SquareTagPose], + reference_pairs: Mapping[ + tuple[str, str], tuple[Rotation, np.ndarray] + ], + combination: Mapping[str, SquareTagPose], + ) -> float: + score = sum( + pose.reprojection_error_px for pose in combination.values() + ) / reprojection_scale + score += sum( + _normal_alignment_rad(combination[first], combination[second]) + for first, second in normal_pair_names + ) / normal_scale + score += sum( + rotation_distance_rad( + reference[role].quaternion_xyzw, + combination[role].quaternion_xyzw, + ) + / pose_scale + + float( + np.linalg.norm( + np.asarray(combination[role].translation_xyz_m, dtype=float) + - np.asarray(reference[role].translation_xyz_m, dtype=float) + ) + ) + / translation_scale + for role in role_names + ) + for pair, (reference_rotation, reference_translation) in ( + reference_pairs.items() + ): + rotation, translation = _relative_pose( + combination[pair[0]], combination[pair[1]] + ) + score += ( + float((reference_rotation.inv() * rotation).magnitude()) + / relative_rotation_scale + + float(np.linalg.norm(translation - reference_translation)) + / relative_translation_scale + ) + for pair, (task_rotation, task_translation) in task_references.items(): + rotation, translation = _relative_pose( + combination[pair[0]], combination[pair[1]] + ) + score += ( + float((task_rotation.inv() * rotation).magnitude()) + / task_reference_rotation_scale + + float(np.linalg.norm(translation - task_translation)) + / task_reference_translation_scale + ) + return float(score) + + best_total = float("inf") + best_path: list[dict[str, SquareTagPose]] | None = None + for reference in combinations_by_frame[0]: + reference_pairs = { + pair: _relative_pose(reference[pair[0]], reference[pair[1]]) + for pair in pair_names + } + path = [reference] + total = score_against_reference(reference, reference_pairs, reference) + for combinations in combinations_by_frame[1:]: + scored = [ + ( + score_against_reference( + reference, reference_pairs, combination + ), + combination, + ) + for combination in combinations + ] + cost, selected = min(scored, key=lambda item: item[0]) + total += cost + path.append(selected) + if total < best_total: + best_total = total + best_path = path + + if best_path is None: + raise RuntimeError("static group initialization produced no path") + + # Reuse the complete quality calculation with exactly one chosen branch + # per role and frame. This retains all existing quality fields without + # reintroducing the combinatorial branch search. + reduced_frames = [ + {role: (frame[role],) for role in role_names} for frame in best_path + ] + selected_path, quality = select_rigid_group_trajectory( + reduced_frames, + roles=role_names, + fixed_pairs=pair_names, + reprojection_scale_px=reprojection_scale, + rotation_scale_rad=relative_rotation_scale, + translation_scale_m=relative_translation_scale, + normal_alignment_pairs=normal_pair_names, + normal_alignment_scale_rad=normal_scale, + ) + quality = dict(quality) + quality["total_cost"] = float(best_total) + quality["initialization_search"] = "static_reference" + quality["task_reference_used"] = ( + "true" if task_references else "false" + ) + return selected_path, quality + + +class SquareTagGroupPoseTracker: + """Choose all tag branches together using thumb-chain continuity. + + A 30 px planar tag has two IPPE solutions whose reprojection errors can + exchange order from one frame to the next. Tracking each tag + independently can therefore choose an incompatible pair for a relative + joint such as T4->T5. This tracker enumerates the small Cartesian product + (at most 2**4 combinations) and favours the combination that keeps both + the camera poses and all adjacent relative poses continuous. + """ + + def __init__( + self, + *, + roles: Sequence[str], + adjacent_pairs: Sequence[tuple[str, str]], + maximum_pose_jump_rad: float, + maximum_translation_jump_m: float, + relative_rotation_scale_rad: float, + relative_translation_scale_m: float, + reprojection_scale_px: float, + reprojection_weight: float, + reset_after_seconds: float, + initialization_frames: int = 1, + normal_alignment_pairs: Sequence[tuple[str, str]] = (), + normal_alignment_scale_rad: float = math.radians(5.0), + maximum_normal_alignment_rad: float | None = None, + return_reference_rotation_scale_rad: float = math.radians(1.0), + return_reference_maximum_command_gap_u8: int = 8, + coupled_rotation_pairs: Sequence[ + tuple[str, str, str, str, float] + ] = (), + coupled_rotation_scale_rad: float = math.radians(3.0), + maximum_coupled_rotation_residual_rad: float | None = None, + ) -> None: + self.roles = tuple(str(role) for role in roles) + self.adjacent_pairs = tuple( + (str(parent), str(child)) + for parent, child in adjacent_pairs + ) + self.normal_alignment_pairs = tuple( + (str(first), str(second)) + for first, second in normal_alignment_pairs + ) + self.coupled_rotation_pairs = tuple( + ( + str(driver_parent), + str(driver_child), + str(follower_parent), + str(follower_child), + float(multiplier), + ) + for ( + driver_parent, + driver_child, + follower_parent, + follower_child, + multiplier, + ) in coupled_rotation_pairs + ) + if not self.roles or len(set(self.roles)) != len(self.roles): + raise ValueError("roles must be non-empty and unique") + if any( + parent not in self.roles or child not in self.roles + for parent, child in ( + *self.adjacent_pairs, + *self.normal_alignment_pairs, + ) + ): + raise ValueError("group geometry pairs must reference roles") + self.maximum_pose_jump_rad = float(maximum_pose_jump_rad) + self.maximum_translation_jump_m = float( + maximum_translation_jump_m + ) + self.relative_rotation_scale_rad = float( + relative_rotation_scale_rad + ) + self.relative_translation_scale_m = float( + relative_translation_scale_m + ) + self.reprojection_scale_px = float(reprojection_scale_px) + self.reprojection_weight = float(reprojection_weight) + self.initialization_frames = int(initialization_frames) + self.normal_alignment_scale_rad = float( + normal_alignment_scale_rad + ) + self.maximum_normal_alignment_rad = ( + None + if maximum_normal_alignment_rad is None + else float(maximum_normal_alignment_rad) + ) + self.return_reference_rotation_scale_rad = float( + return_reference_rotation_scale_rad + ) + self.return_reference_maximum_command_gap_u8 = int( + return_reference_maximum_command_gap_u8 + ) + self.coupled_rotation_scale_rad = float( + coupled_rotation_scale_rad + ) + self.maximum_coupled_rotation_residual_rad = ( + None + if maximum_coupled_rotation_residual_rad is None + else float(maximum_coupled_rotation_residual_rad) + ) + reset_seconds = float(reset_after_seconds) + if min( + self.maximum_pose_jump_rad, + self.maximum_translation_jump_m, + self.relative_rotation_scale_rad, + self.relative_translation_scale_m, + self.reprojection_scale_px, + self.normal_alignment_scale_rad, + self.return_reference_rotation_scale_rad, + self.coupled_rotation_scale_rad, + reset_seconds, + ) <= 0.0: + raise ValueError("group tracking scales must be positive") + if self.reprojection_weight < 0.0: + raise ValueError("reprojection_weight must be non-negative") + if self.initialization_frames < 1: + raise ValueError("initialization_frames must be positive") + if self.return_reference_maximum_command_gap_u8 < 0: + raise ValueError( + "return reference maximum command gap must be non-negative" + ) + if ( + self.maximum_normal_alignment_rad is not None + and self.maximum_normal_alignment_rad <= 0.0 + ): + raise ValueError("maximum normal alignment must be positive") + if any( + role not in self.roles + for coupling in self.coupled_rotation_pairs + for role in coupling[:4] + ): + raise ValueError("coupled rotation pairs must reference roles") + if any( + multiplier <= 0.0 + for *_, multiplier in self.coupled_rotation_pairs + ): + raise ValueError("coupled rotation multipliers must be positive") + if ( + self.maximum_coupled_rotation_residual_rad is not None + and self.maximum_coupled_rotation_residual_rad <= 0.0 + ): + raise ValueError( + "maximum coupled rotation residual must be positive" + ) + self.reset_after_ns = int(reset_seconds * 1_000_000_000) + self._previous: dict[str, SquareTagPose] = {} + self._previous_stamp_ns: int | None = None + self._initial_candidates: list[ + dict[str, tuple[SquareTagPose, ...]] + ] = [] + self._initial_stamps_ns: list[int] = [] + self.last_initialization_quality: dict[str, float | str] = {} + self.branch_correction_counts: dict[str, int] = {} + self._decreasing_relative_rotations: dict[ + int, dict[tuple[str, str], Rotation] + ] = {} + self._coupled_reference_rotations: dict[ + tuple[str, str], Rotation + ] = {} + self._task_reference_relative_poses: dict[ + tuple[str, str], tuple[Rotation, np.ndarray] + ] = {} + self.last_missing_roles: tuple[str, ...] = () + + def reset(self, *, preserve_task_reference: bool = False) -> None: + self._previous.clear() + self._previous_stamp_ns = None + self._initial_candidates.clear() + self._initial_stamps_ns.clear() + self.last_initialization_quality.clear() + self.branch_correction_counts.clear() + self._decreasing_relative_rotations.clear() + self._coupled_reference_rotations.clear() + self.last_missing_roles = () + if not preserve_task_reference: + self._task_reference_relative_poses.clear() + + def _task_reference_cost( + self, combination: Mapping[str, SquareTagPose] + ) -> float: + residual = 0.0 + for pair, (expected_rotation, expected_translation) in ( + self._task_reference_relative_poses.items() + ): + rotation, translation = _relative_pose( + combination[pair[0]], combination[pair[1]] + ) + residual += ( + float((expected_rotation.inv() * rotation).magnitude()) + / self.return_reference_rotation_scale_rad + + float(np.linalg.norm(translation - expected_translation)) + / self.relative_translation_scale_m + ) + return residual + + def _coupled_rotation_residuals( + self, combination: Mapping[str, SquareTagPose] + ) -> tuple[float, ...]: + if not self.coupled_rotation_pairs: + return () + residuals: list[float] = [] + for ( + driver_parent, + driver_child, + follower_parent, + follower_child, + multiplier, + ) in self.coupled_rotation_pairs: + driver_pair = (driver_parent, driver_child) + follower_pair = (follower_parent, follower_child) + if ( + driver_pair not in self._coupled_reference_rotations + or follower_pair not in self._coupled_reference_rotations + ): + return () + driver_rotation = _relative_pose( + combination[driver_parent], combination[driver_child] + )[0] + follower_rotation = _relative_pose( + combination[follower_parent], combination[follower_child] + )[0] + driver_travel = ( + self._coupled_reference_rotations[driver_pair].inv() + * driver_rotation + ).magnitude() + follower_travel = ( + self._coupled_reference_rotations[follower_pair].inv() + * follower_rotation + ).magnitude() + residuals.append( + abs(float(follower_travel) - multiplier * float(driver_travel)) + ) + return tuple(residuals) + + def _informative_coupled_rotation_costs( + self, + combinations: Sequence[Mapping[str, SquareTagPose]], + ) -> tuple[float, ...]: + """Return branch costs only while the weak coupling prior is credible. + + The URDF mimic ratio is useful for distinguishing two planar-IPPE + branches, but it is not measurement truth for a passive joint. Once + every otherwise viable combination disagrees with that ratio, using + it would bias the measured curve (and previously rejected every + frame). In that case fall back to visual continuity for this frame. + """ + residuals = tuple( + self._coupled_rotation_residuals(combination) + for combination in combinations + ) + if not residuals or not any(residuals): + return tuple(0.0 for _ in combinations) + if ( + self.maximum_coupled_rotation_residual_rad is not None + and not any( + values + and max(values) + <= self.maximum_coupled_rotation_residual_rad + for values in residuals + ) + ): + return tuple(0.0 for _ in combinations) + return tuple( + sum(values) / self.coupled_rotation_scale_rad + for values in residuals + ) + + def _return_reference( + self, command_u8: int | None + ) -> dict[tuple[str, str], tuple[Rotation, np.ndarray | None]]: + if command_u8 is None or not self._decreasing_relative_rotations: + return {} + command = int(command_u8) + nearest = min( + self._decreasing_relative_rotations, + key=lambda candidate: abs(candidate - command), + ) + if ( + abs(nearest - command) + > self.return_reference_maximum_command_gap_u8 + ): + return {} + references = self._decreasing_relative_rotations[nearest] + commands = sorted(self._decreasing_relative_rotations) + axes: dict[tuple[str, str], np.ndarray | None] = {} + for pair in self.adjacent_pairs: + endpoint_delta = ( + self._decreasing_relative_rotations[commands[-1]][pair].inv() + * self._decreasing_relative_rotations[commands[0]][pair] + ).as_rotvec() + norm = float(np.linalg.norm(endpoint_delta)) + axes[pair] = ( + None + if norm < math.radians(5.0) + else endpoint_delta / norm + ) + return { + pair: (rotation, axes[pair]) + for pair, rotation in references.items() + } + + def _return_reference_cost( + self, + combination: Mapping[str, SquareTagPose], + reference: Mapping[ + tuple[str, str], tuple[Rotation, np.ndarray | None] + ], + ) -> float: + residual = 0.0 + for pair, (expected, motion_axis) in reference.items(): + vector = ( + expected.inv() + * _relative_pose( + combination[pair[0]], combination[pair[1]] + )[0] + ).as_rotvec() + if motion_axis is not None: + # The outbound trajectory identifies the physical one-DOF + # motion axis. Do not penalize return travel along that axis: + # it may contain real mechanical hysteresis that calibration + # must measure. A planar-IPPE mirror branch appears primarily + # as a large orthogonal tilt and is rejected by this residual. + vector = vector - motion_axis * float(vector @ motion_axis) + residual += float(np.linalg.norm(vector)) + return residual / self.return_reference_rotation_scale_rad + + def select( + self, + candidates_by_role: Mapping[str, Sequence[SquareTagPose]], + *, + stamp_ns: int, + trajectory_command_u8: int | None = None, + trajectory_direction: str | None = None, + ) -> tuple[dict[str, SquareTagPose] | None, str]: + """Return one mutually consistent pose for every configured role.""" + direction = ( + None + if trajectory_direction is None + else str(trajectory_direction) + ) + if direction not in {None, "decreasing", "increasing"}: + raise ValueError( + "trajectory_direction must be decreasing or increasing" + ) + return_reference = ( + self._return_reference(trajectory_command_u8) + if direction == "increasing" + else {} + ) + candidate_lists = [ + tuple(candidates_by_role.get(role, ())) + for role in self.roles + ] + self.last_missing_roles = tuple( + role + for role, candidates in zip(self.roles, candidate_lists) + if not candidates + ) + if self.last_missing_roles: + return None, "group_missing_pose_candidates" + self.last_missing_roles = () + + combinations = [ + dict(zip(self.roles, combination)) + for combination in product(*candidate_lists) + ] + minimum_errors = { + role: min( + candidate.reprojection_error_px + for candidate in candidates + ) + for role, candidates in zip(self.roles, candidate_lists) + } + stamp = int(stamp_ns) + previous_is_fresh = ( + self._previous_stamp_ns is not None + and 0 <= stamp - self._previous_stamp_ns + <= self.reset_after_ns + and set(self._previous) == set(self.roles) + ) + + if not previous_is_fresh: + if self._previous_stamp_ns is not None: + self._previous.clear() + self._previous_stamp_ns = None + self._initial_candidates.clear() + self._initial_stamps_ns.clear() + self.last_initialization_quality.clear() + if self.initialization_frames > 1: + if self._initial_stamps_ns and not ( + 0 <= stamp - self._initial_stamps_ns[-1] + <= self.reset_after_ns + ): + self._initial_candidates.clear() + self._initial_stamps_ns.clear() + self._initial_candidates.append( + { + role: tuple(candidates_by_role.get(role, ())) + for role in self.roles + } + ) + self._initial_stamps_ns.append(stamp) + if len(self._initial_candidates) < self.initialization_frames: + return ( + None, + "group_initializing:" + f"{len(self._initial_candidates)}/" + f"{self.initialization_frames}", + ) + selected_path, initialization_quality = ( + select_static_rigid_group_initialization( + self._initial_candidates, + roles=self.roles, + fixed_pairs=self.adjacent_pairs, + reprojection_scale_px=self.reprojection_scale_px, + maximum_pose_jump_rad=self.maximum_pose_jump_rad, + maximum_translation_jump_m=( + self.maximum_translation_jump_m + ), + relative_rotation_scale_rad=( + self.relative_rotation_scale_rad + ), + relative_translation_scale_m=( + self.relative_translation_scale_m + ), + normal_alignment_pairs=( + self.normal_alignment_pairs + ), + normal_alignment_scale_rad=( + self.normal_alignment_scale_rad + ), + task_reference_pairs=( + self._task_reference_relative_poses + ), + task_reference_rotation_scale_rad=( + self.return_reference_rotation_scale_rad + ), + task_reference_translation_scale_m=( + self.relative_translation_scale_m + ), + ) + ) + selected = selected_path[-1] + stamp = self._initial_stamps_ns[-1] + self.last_initialization_quality = dict( + initialization_quality + ) + self._initial_candidates.clear() + self._initial_stamps_ns.clear() + if ( + self.maximum_normal_alignment_rad is not None + and float( + initialization_quality[ + "maximum_normal_alignment_rad" + ] + ) + > self.maximum_normal_alignment_rad + ): + return None, "group_normal_alignment" + else: + coupling_costs = self._informative_coupled_rotation_costs( + combinations + ) + selected = min( + zip(combinations, coupling_costs), + key=lambda item: ( + sum( + pose.reprojection_error_px + for pose in item[0].values() + ) + / self.reprojection_scale_px + + sum( + _normal_alignment_rad( + item[0][first], item[0][second] + ) + for first, second in self.normal_alignment_pairs + ) + / self.normal_alignment_scale_rad + + self._return_reference_cost( + item[0], return_reference + ) + + item[1] + + self._task_reference_cost(item[0]) + ), + )[0] + maximum_alignment = max( + ( + _normal_alignment_rad( + selected[first], selected[second] + ) + for first, second in self.normal_alignment_pairs + ), + default=0.0, + ) + self.last_initialization_quality = { + "maximum_normal_alignment_rad": maximum_alignment + } + if ( + self.maximum_normal_alignment_rad is not None + and maximum_alignment + > self.maximum_normal_alignment_rad + ): + return None, "group_normal_alignment" + else: + previous_relative = { + pair: _relative_pose( + self._previous[pair[0]], + self._previous[pair[1]], + ) + for pair in self.adjacent_pairs + } + base_scored: list[tuple[float, dict[str, SquareTagPose]]] = [] + for combination in combinations: + absolute_rotation_motion = 0.0 + absolute_translation_motion = 0.0 + rejected = False + for role in self.roles: + rotation_motion = rotation_distance_rad( + self._previous[role].quaternion_xyzw, + combination[role].quaternion_xyzw, + ) + translation_motion = float( + np.linalg.norm( + np.asarray( + combination[role].translation_xyz_m, + dtype=float, + ) + - np.asarray( + self._previous[role].translation_xyz_m, + dtype=float, + ) + ) + ) + if ( + rotation_motion > self.maximum_pose_jump_rad + or translation_motion + > self.maximum_translation_jump_m + ): + rejected = True + break + absolute_rotation_motion += rotation_motion + absolute_translation_motion += translation_motion + if rejected: + continue + + relative_rotation_motion = 0.0 + relative_translation_motion = 0.0 + for pair in self.adjacent_pairs: + rotation, translation = _relative_pose( + combination[pair[0]], + combination[pair[1]], + ) + old_rotation, old_translation = previous_relative[pair] + relative_rotation_motion += float( + (old_rotation.inv() * rotation).magnitude() + ) + relative_translation_motion += float( + np.linalg.norm(translation - old_translation) + ) + + reprojection_penalty = sum( + max( + 0.0, + combination[role].reprojection_error_px + - minimum_errors[role], + ) + for role in self.roles + ) / self.reprojection_scale_px + score = ( + absolute_rotation_motion + / self.maximum_pose_jump_rad + + absolute_translation_motion + / self.maximum_translation_jump_m + + relative_rotation_motion + / self.relative_rotation_scale_rad + + relative_translation_motion + / self.relative_translation_scale_m + + self.reprojection_weight * reprojection_penalty + + self._return_reference_cost( + combination, return_reference + ) + ) + base_scored.append((float(score), combination)) + + if not base_scored: + return None, "group_pose_jump" + coupling_costs = self._informative_coupled_rotation_costs( + [combination for _, combination in base_scored] + ) + scored = [ + (base_score + coupling_cost, combination) + for (base_score, combination), coupling_cost in zip( + base_scored, coupling_costs + ) + ] + selected = min(scored, key=lambda item: item[0])[1] + + aligned: dict[str, SquareTagPose] = {} + for role in self.roles: + pose = selected[role] + if previous_is_fresh: + old_quaternion = np.asarray( + self._previous[role].quaternion_xyzw, + dtype=float, + ) + quaternion = np.asarray( + pose.quaternion_xyzw, + dtype=float, + ) + if float(np.dot(old_quaternion, quaternion)) < 0.0: + pose = replace( + pose, + quaternion_xyzw=tuple( + float(value) for value in -quaternion + ), + ) + best_reprojection = min( + candidate_lists[self.roles.index(role)], + key=lambda candidate: candidate.reprojection_error_px, + ) + if pose != best_reprojection: + self.branch_correction_counts[role] = ( + self.branch_correction_counts.get(role, 0) + 1 + ) + aligned[role] = pose + + self._previous = aligned + self._previous_stamp_ns = stamp + if ( + direction == "decreasing" + and trajectory_command_u8 is not None + and not self._coupled_reference_rotations + ): + for ( + driver_parent, + driver_child, + follower_parent, + follower_child, + _multiplier, + ) in self.coupled_rotation_pairs: + for pair in ( + (driver_parent, driver_child), + (follower_parent, follower_child), + ): + self._coupled_reference_rotations[pair] = _relative_pose( + aligned[pair[0]], aligned[pair[1]] + )[0] + if direction == "decreasing" and trajectory_command_u8 is not None: + self._decreasing_relative_rotations[ + int(trajectory_command_u8) + ] = { + pair: _relative_pose( + aligned[pair[0]], aligned[pair[1]] + )[0] + for pair in self.adjacent_pairs + } + if not self._task_reference_relative_poses: + self._task_reference_relative_poses = { + pair: _relative_pose( + aligned[pair[0]], aligned[pair[1]] + ) + for pair in self.adjacent_pairs + } + return dict(aligned), "" diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/tracking.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/tracking.py new file mode 100644 index 0000000..238463c --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/tracking.py @@ -0,0 +1,324 @@ +"""Tracking for shared square Tag pose estimation.""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Sequence +import math + +from scipy.spatial.transform import Rotation +import cv2 +import numpy as np + +from .ippe import rotation_distance_rad, solve_square_tag_ippe +from .parameters import DEFAULT_POSE_TRACKING_PARAMETERS, DEFAULT_REPROJECTION_TIE_PX +from .types import SquareTagPose + + +def _validate_reprojection_thresholds(maximum_error: float, tie_error: float) -> None: + if not math.isfinite(maximum_error) or maximum_error <= 0: + raise ValueError("maximum_reprojection_error_px must be finite and positive") + if not math.isfinite(tie_error) or not 0 <= tie_error < maximum_error: + raise ValueError( + "reprojection_tie_px must be finite, non-negative and smaller than " + "maximum_reprojection_error_px; image acceptance is not a branch tie" + ) + + +def select_continuous_pose( + candidates: Sequence[SquareTagPose], + *, + previous: SquareTagPose | None, + maximum_reprojection_error_px: float, + reprojection_tie_px: float, + maximum_pose_jump_rad: float, + maximum_translation_jump_m: float, + maximum_tag_tilt_rad: float, +) -> tuple[SquareTagPose | None, str]: + """Select the best IPPE branch using image fit and temporal continuity.""" + maximum_error = float(maximum_reprojection_error_px) + tie_error = float(reprojection_tie_px) + _validate_reprojection_thresholds(maximum_error, tie_error) + maximum_rotation = float(maximum_pose_jump_rad) + maximum_translation = float(maximum_translation_jump_m) + maximum_tilt = float(maximum_tag_tilt_rad) + if min( + maximum_error, + maximum_rotation, + maximum_translation, + maximum_tilt, + ) <= 0.0: + raise ValueError("PnP selection thresholds must be positive") + + eligible: list[SquareTagPose] = [] + for candidate in candidates: + if candidate.reprojection_error_px > maximum_error: + continue + normal = Rotation.from_quat(candidate.quaternion_xyzw).as_matrix()[:, 2] + tilt = math.acos(float(np.clip(abs(normal[2]), 0.0, 1.0))) + if tilt > maximum_tilt: + continue + eligible.append(candidate) + if not eligible: + return None, "no_pose_within_reprojection_or_tilt_limit" + + eligible.sort(key=lambda item: item.reprojection_error_px) + best = eligible[0] + if previous is None: + return best, "" + + # Temporal continuity must only break a genuine planar-PnP tie. The old + # implementation normalised reprojection error by the permissive 1.5 px + # rejection limit, which allowed a stale mirror branch at 0.25 px to beat + # the true branch at e.g. 0.05 px merely because it was closer to the + # preceding (already wrong) pose. Once one IPPE solution has a meaningful + # image-fit advantage, trust it and allow the tracker to leave the stale + # branch even if that correction is a large pose jump. + competitive = [ + candidate + for candidate in eligible + if candidate.reprojection_error_px + <= best.reprojection_error_px + tie_error + ] + if len(competitive) == 1: + return best, "" + + previous_translation = np.asarray(previous.translation_xyz_m, dtype=float) + scored: list[tuple[float, SquareTagPose]] = [] + for candidate in competitive: + rotation_jump = rotation_distance_rad( + previous.quaternion_xyzw, + candidate.quaternion_xyzw, + ) + translation_jump = float( + np.linalg.norm( + np.asarray(candidate.translation_xyz_m, dtype=float) + - previous_translation + ) + ) + if ( + rotation_jump > maximum_rotation + or translation_jump > maximum_translation + ): + continue + score = ( + ( + candidate.reprojection_error_px + - best.reprojection_error_px + ) + / max(tie_error, np.finfo(float).eps) + + rotation_jump / maximum_rotation + + translation_jump / maximum_translation + ) + scored.append((float(score), candidate)) + if not scored: + return None, "pose_jump" + + selected = min(scored, key=lambda item: item[0])[1] + previous_quaternion = np.asarray(previous.quaternion_xyzw, dtype=float) + selected_quaternion = np.asarray(selected.quaternion_xyzw, dtype=float) + if float(np.dot(previous_quaternion, selected_quaternion)) < 0.0: + selected = replace( + selected, + quaternion_xyzw=tuple( + float(value) for value in -selected_quaternion + ), + ) + return selected, "" + + +class SquareTagPoseTracker: + """Maintain the selected planar-PnP branch independently for each tag.""" + + def __init__( + self, + *, + maximum_reprojection_error_px: float = DEFAULT_POSE_TRACKING_PARAMETERS.maximum_reprojection_error_px, + reprojection_tie_px: float = DEFAULT_REPROJECTION_TIE_PX, + maximum_pose_jump_rad: float = DEFAULT_POSE_TRACKING_PARAMETERS.maximum_pose_jump_rad, + maximum_translation_jump_m: float = DEFAULT_POSE_TRACKING_PARAMETERS.maximum_translation_jump_m, + maximum_tag_tilt_rad: float = DEFAULT_POSE_TRACKING_PARAMETERS.maximum_tag_tilt_rad, + reset_after_seconds: float = DEFAULT_POSE_TRACKING_PARAMETERS.reset_after_seconds, + ) -> None: + self.maximum_reprojection_error_px = float( + maximum_reprojection_error_px + ) + self.reprojection_tie_px = float(reprojection_tie_px) + _validate_reprojection_thresholds( + self.maximum_reprojection_error_px, self.reprojection_tie_px + ) + self.maximum_pose_jump_rad = float(maximum_pose_jump_rad) + self.maximum_translation_jump_m = float(maximum_translation_jump_m) + self.maximum_tag_tilt_rad = float(maximum_tag_tilt_rad) + self.reset_after_ns = int(float(reset_after_seconds) * 1_000_000_000) + if self.reset_after_ns <= 0: + raise ValueError("reset_after_seconds must be positive") + self._previous: dict[str, tuple[int, SquareTagPose]] = {} + self.last_candidates_by_role: dict[ + str, tuple[SquareTagPose, ...] + ] = {} + self.last_candidate_diagnostics_by_role: dict[ + str, dict[str, float | int] + ] = {} + self.branch_correction_counts: dict[str, int] = {} + + def reset(self) -> None: + self._previous.clear() + self.last_candidates_by_role.clear() + self.last_candidate_diagnostics_by_role.clear() + self.branch_correction_counts.clear() + + def estimate( + self, + role: str, + corners_xy: Sequence[Sequence[float]], + *, + tag_size_m: float, + camera_matrix: Sequence[Sequence[float]], + stamp_ns: int, + reprojection_tie_px: float | None = None, + ) -> tuple[SquareTagPose | None, str]: + try: + candidates = solve_square_tag_ippe( + corners_xy, + tag_size_m=tag_size_m, + camera_matrix=camera_matrix, + ) + except (ValueError, cv2.error): + self.last_candidates_by_role[str(role)] = () + self.last_candidate_diagnostics_by_role[str(role)] = { + "solved_candidate_count": 0, + "reprojection_candidate_count": 0, + "independent_tilt_candidate_count": 0, + "maximum_reprojection_error_px": float( + self.maximum_reprojection_error_px + ), + "maximum_independent_tilt_deg": math.degrees( + self.maximum_tag_tilt_rad + ), + } + return None, "pnp_solve_failed" + if not candidates: + self.last_candidates_by_role[str(role)] = () + self.last_candidate_diagnostics_by_role[str(role)] = { + "solved_candidate_count": 0, + "reprojection_candidate_count": 0, + "independent_tilt_candidate_count": 0, + "maximum_reprojection_error_px": float( + self.maximum_reprojection_error_px + ), + "maximum_independent_tilt_deg": math.degrees( + self.maximum_tag_tilt_rad + ), + } + return None, "pnp_solve_failed" + reprojection_candidates = [ + candidate + for candidate in candidates + if candidate.reprojection_error_px + <= self.maximum_reprojection_error_px + ] + candidate_tilts_rad: list[float] = [] + independent_candidates: list[SquareTagPose] = [] + for candidate in reprojection_candidates: + normal = Rotation.from_quat( + candidate.quaternion_xyzw + ).as_matrix()[:, 2] + tilt = math.acos( + float(np.clip(abs(normal[2]), 0.0, 1.0)) + ) + candidate_tilts_rad.append(float(tilt)) + if tilt <= self.maximum_tag_tilt_rad: + independent_candidates.append(candidate) + + # Candidate generation and candidate selection have different + # contracts. The per-Tag tilt limit protects a pose used without any + # other geometry, but it must not erase a finite, low-reprojection + # IPPE solution before SquareTagGroupPoseTracker can evaluate it + # against the fixed palm reference, the articulated chain and the + # preceding group pose. At a strongly oblique view the planar + # ambiguity is usually smaller, and rejecting both branches at a + # fixed angle caused deterministic mid-sweep holes despite continuous + # image detections. Group tracking therefore receives every + # reprojection-valid candidate; independent tracking below retains the + # original tilt safety gate. + self.last_candidates_by_role[str(role)] = tuple( + reprojection_candidates + ) + diagnostics: dict[str, float | int] = { + "solved_candidate_count": len(candidates), + "reprojection_candidate_count": len(reprojection_candidates), + "independent_tilt_candidate_count": len(independent_candidates), + "minimum_reprojection_error_px": float( + min( + candidate.reprojection_error_px + for candidate in candidates + ) + ), + "maximum_reprojection_error_px": float( + self.maximum_reprojection_error_px + ), + "maximum_independent_tilt_deg": math.degrees( + self.maximum_tag_tilt_rad + ), + } + if candidate_tilts_rad: + diagnostics["minimum_candidate_tilt_deg"] = math.degrees( + min(candidate_tilts_rad) + ) + diagnostics["maximum_candidate_tilt_deg"] = math.degrees( + max(candidate_tilts_rad) + ) + self.last_candidate_diagnostics_by_role[str(role)] = diagnostics + if not independent_candidates: + return None, "no_pose_within_reprojection_or_tilt_limit" + + previous_record = self._previous.get(str(role)) + previous: SquareTagPose | None = None + if previous_record is not None: + previous_stamp, previous_pose = previous_record + elapsed = int(stamp_ns) - previous_stamp + if 0 <= elapsed <= self.reset_after_ns: + previous = previous_pose + + selected, reason = select_continuous_pose( + independent_candidates, + previous=previous, + maximum_reprojection_error_px=( + self.maximum_reprojection_error_px + ), + reprojection_tie_px=( + self.reprojection_tie_px + if reprojection_tie_px is None + else float(reprojection_tie_px) + ), + maximum_pose_jump_rad=self.maximum_pose_jump_rad, + maximum_translation_jump_m=self.maximum_translation_jump_m, + maximum_tag_tilt_rad=self.maximum_tag_tilt_rad, + ) + if selected is not None: + if previous is not None: + rotation_jump = rotation_distance_rad( + previous.quaternion_xyzw, + selected.quaternion_xyzw, + ) + translation_jump = float( + np.linalg.norm( + np.asarray(selected.translation_xyz_m, dtype=float) + - np.asarray( + previous.translation_xyz_m, + dtype=float, + ) + ) + ) + if ( + rotation_jump > self.maximum_pose_jump_rad + or translation_jump + > self.maximum_translation_jump_m + ): + key = str(role) + self.branch_correction_counts[key] = ( + self.branch_correction_counts.get(key, 0) + 1 + ) + self._previous[str(role)] = (int(stamp_ns), selected) + return selected, reason diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/trajectory.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/trajectory.py new file mode 100644 index 0000000..a82be08 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/trajectory.py @@ -0,0 +1,424 @@ +"""Trajectory for shared square Tag pose estimation.""" + +from __future__ import annotations + +from itertools import product +from typing import Mapping, Sequence + +from scipy.spatial.transform import Rotation +import numpy as np + +from .ippe import rotation_distance_rad +from .relative import _normal_alignment_rad, _relative_pose +from .types import SquareTagPose + + +def select_rigid_group_trajectory( + frames: Sequence[Mapping[str, Sequence[SquareTagPose]]], + *, + roles: Sequence[str], + fixed_pairs: Sequence[tuple[str, str]], + reprojection_scale_px: float, + rotation_scale_rad: float, + translation_scale_m: float, + pair_geometry: str = "pose", + normal_alignment_pairs: Sequence[tuple[str, str]] = (), + normal_alignment_scale_rad: float | None = None, +) -> tuple[ + list[dict[str, SquareTagPose]], + dict[str, float | str], +]: + """Resolve planar branches using geometry that should stay rigid. + + Every possible branch combination in the first frame is treated as a + candidate rigid reference. For each such reference, every later frame + independently chooses the combination with the lowest reprojection plus + geometric-drift cost. ``pose`` compares relative rotation and translation; + ``distance`` compares only Euclidean centre distances and therefore does + not allow planar-PnP orientation jitter into centre-trajectory angles. + The globally cheapest reference and path win. + """ + role_names = tuple(str(role) for role in roles) + pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs) + normal_pair_names = tuple( + (str(first), str(second)) + for first, second in normal_alignment_pairs + ) + if not frames: + raise ValueError("at least one PnP frame is required") + if len(set(role_names)) != len(role_names) or not role_names: + raise ValueError("roles must be non-empty and unique") + if any( + parent not in role_names or child not in role_names + for parent, child in (*pair_names, *normal_pair_names) + ): + raise ValueError("geometry pairs must reference roles") + reprojection_scale = float(reprojection_scale_px) + rotation_scale = float(rotation_scale_rad) + translation_scale = float(translation_scale_m) + geometry_mode = str(pair_geometry) + normal_scale = ( + rotation_scale + if normal_alignment_scale_rad is None + else float(normal_alignment_scale_rad) + ) + if min( + reprojection_scale, + rotation_scale, + translation_scale, + normal_scale, + ) <= 0.0: + raise ValueError("trajectory selection scales must be positive") + if geometry_mode not in {"pose", "distance"}: + raise ValueError("pair_geometry must be pose or distance") + + combinations_by_frame: list[list[dict[str, SquareTagPose]]] = [] + for frame in frames: + candidate_lists = [tuple(frame.get(role, ())) for role in role_names] + if any(not candidates for candidates in candidate_lists): + raise ValueError("every frame must contain every requested role") + combinations_by_frame.append( + [ + dict(zip(role_names, combination)) + for combination in product(*candidate_lists) + ] + ) + + best_total = float("inf") + best_path: list[dict[str, SquareTagPose]] | None = None + + def emission( + combination: Mapping[str, SquareTagPose], + reference_pairs: Mapping[ + tuple[str, str], tuple[Rotation, np.ndarray] + ], + reference_distances: Mapping[tuple[str, str], float], + ) -> tuple[float, float, float]: + reprojection_cost = sum( + pose.reprojection_error_px + for pose in combination.values() + ) / reprojection_scale + normal_alignment_cost = sum( + _normal_alignment_rad( + combination[first], combination[second] + ) + for first, second in normal_pair_names + ) / normal_scale + rotation_drifts: list[float] = [] + translation_drifts: list[float] = [] + distance_drifts: list[float] = [] + for pair, ( + reference_rotation, + reference_translation, + ) in reference_pairs.items(): + rotation, translation = _relative_pose( + combination[pair[0]], + combination[pair[1]], + ) + rotation_drifts.append( + float( + (reference_rotation.inv() * rotation).magnitude() + ) + ) + translation_drifts.append( + float( + np.linalg.norm( + translation - reference_translation + ) + ) + ) + current_distance = float( + np.linalg.norm( + np.asarray( + combination[pair[1]].translation_xyz_m, + dtype=float, + ) + - np.asarray( + combination[pair[0]].translation_xyz_m, + dtype=float, + ) + ) + ) + distance_drifts.append( + abs(current_distance - reference_distances[pair]) + ) + if geometry_mode == "distance": + geometry_cost = sum(distance_drifts) / translation_scale + else: + geometry_cost = ( + sum(rotation_drifts) / rotation_scale + + sum(translation_drifts) / translation_scale + ) + return ( + reprojection_cost + geometry_cost + normal_alignment_cost, + max(rotation_drifts, default=0.0), + ( + max(distance_drifts, default=0.0) + if geometry_mode == "distance" + else max(translation_drifts, default=0.0) + ), + ) + + def transition_cost( + previous: Mapping[str, SquareTagPose], + current: Mapping[str, SquareTagPose], + ) -> float: + rotation_motion = sum( + rotation_distance_rad( + previous[role].quaternion_xyzw, + current[role].quaternion_xyzw, + ) + for role in role_names + ) + translation_motion = sum( + float( + np.linalg.norm( + np.asarray(current[role].translation_xyz_m) + - np.asarray(previous[role].translation_xyz_m) + ) + ) + for role in role_names + ) + if geometry_mode == "distance": + return translation_motion / translation_scale + return ( + rotation_motion / rotation_scale + + translation_motion / translation_scale + ) + + for reference_index, reference_combination in enumerate( + combinations_by_frame[0] + ): + reference_pairs = { + pair: _relative_pose( + reference_combination[pair[0]], + reference_combination[pair[1]], + ) + for pair in pair_names + } + reference_distances = { + pair: float( + np.linalg.norm( + np.asarray( + reference_combination[pair[1]].translation_xyz_m, + dtype=float, + ) + - np.asarray( + reference_combination[pair[0]].translation_xyz_m, + dtype=float, + ) + ) + ) + for pair in pair_names + } + first_emission = emission( + reference_combination, + reference_pairs, + reference_distances, + ) + previous_costs = np.full( + len(combinations_by_frame[0]), + np.inf, + dtype=float, + ) + previous_costs[reference_index] = first_emission[0] + back_pointers: list[list[int]] = [] + for frame_index in range(1, len(combinations_by_frame)): + previous_combinations = combinations_by_frame[frame_index - 1] + combinations = combinations_by_frame[frame_index] + frame_emissions = [ + emission( + combination, + reference_pairs, + reference_distances, + ) + for combination in combinations + ] + current_costs = np.full(len(combinations), np.inf, dtype=float) + frame_back_pointers: list[int] = [] + for current_index, combination in enumerate(combinations): + transition_costs = [ + previous_costs[previous_index] + + transition_cost( + previous_combination, + combination, + ) + for previous_index, previous_combination in enumerate( + previous_combinations + ) + ] + best_previous = int(np.argmin(transition_costs)) + frame_back_pointers.append(best_previous) + current_costs[current_index] = ( + transition_costs[best_previous] + + frame_emissions[current_index][0] + ) + back_pointers.append(frame_back_pointers) + previous_costs = current_costs + + final_index = int(np.argmin(previous_costs)) + total = float(previous_costs[final_index]) + path_indices = [final_index] + for frame_back_pointers in reversed(back_pointers): + path_indices.append( + frame_back_pointers[path_indices[-1]] + ) + path_indices.reverse() + path = [ + combinations[index] + for combinations, index in zip( + combinations_by_frame, + path_indices, + ) + ] + if total < best_total: + best_total = total + best_path = path + + if best_path is None: + raise RuntimeError("trajectory branch selection produced no path") + + # The marker-to-marker mounting transforms are unknown, so the rigid + # reference must be estimated from the complete sweep. Using frame zero + # as both the optimisation seed and the reported quality reference made + # one noisy endpoint frame look like drift in every other frame. A + # rotation medoid and component-wise translation median are insensitive + # to that endpoint noise while still exposing a persistent mirror branch. + robust_reference_pairs: dict[ + tuple[str, str], tuple[Rotation, np.ndarray] + ] = {} + for pair in pair_names: + pair_poses = [ + _relative_pose(frame[pair[0]], frame[pair[1]]) + for frame in best_path + ] + pair_rotations = [pose[0] for pose in pair_poses] + angular_costs = np.asarray( + [ + sum( + float((candidate.inv() * other).magnitude()) + for other in pair_rotations + ) + for candidate in pair_rotations + ], + dtype=float, + ) + rotation_medoid = pair_rotations[int(np.argmin(angular_costs))] + translation_median = np.median( + np.asarray([pose[1] for pose in pair_poses], dtype=float), + axis=0, + ) + robust_reference_pairs[pair] = ( + rotation_medoid, + translation_median, + ) + + rotation_drifts_by_frame: list[float] = [] + translation_drifts_by_frame: list[float] = [] + pair_distances_by_pair = { + pair: np.asarray( + [ + np.linalg.norm( + np.asarray(frame[pair[1]].translation_xyz_m, dtype=float) + - np.asarray( + frame[pair[0]].translation_xyz_m, dtype=float + ) + ) + for frame in best_path + ], + dtype=float, + ) + for pair in pair_names + } + robust_pair_distances = { + pair: float(np.median(distances)) + for pair, distances in pair_distances_by_pair.items() + } + distance_drifts_by_frame: list[float] = [] + for frame in best_path: + frame_rotation_drifts: list[float] = [] + frame_translation_drifts: list[float] = [] + frame_distance_drifts: list[float] = [] + for pair, ( + reference_rotation, + reference_translation, + ) in robust_reference_pairs.items(): + rotation, translation = _relative_pose( + frame[pair[0]], frame[pair[1]] + ) + frame_rotation_drifts.append( + float((reference_rotation.inv() * rotation).magnitude()) + ) + frame_translation_drifts.append( + float(np.linalg.norm(translation - reference_translation)) + ) + distance = float( + np.linalg.norm( + np.asarray(frame[pair[1]].translation_xyz_m, dtype=float) + - np.asarray( + frame[pair[0]].translation_xyz_m, dtype=float + ) + ) + ) + frame_distance_drifts.append( + abs(distance - robust_pair_distances[pair]) + ) + rotation_drifts_by_frame.append( + max(frame_rotation_drifts, default=0.0) + ) + translation_drifts_by_frame.append( + max(frame_translation_drifts, default=0.0) + ) + distance_drifts_by_frame.append( + max(frame_distance_drifts, default=0.0) + ) + + rotation_drifts = np.asarray(rotation_drifts_by_frame, dtype=float) + translation_drifts = np.asarray( + translation_drifts_by_frame, dtype=float + ) + distance_drifts = np.asarray(distance_drifts_by_frame, dtype=float) + normal_alignments = np.asarray( + [ + max( + ( + _normal_alignment_rad(frame[first], frame[second]) + for first, second in normal_pair_names + ), + default=0.0, + ) + for frame in best_path + ], + dtype=float, + ) + return best_path, { + "total_cost": float(best_total), + "pair_geometry": geometry_mode, + "maximum_normal_alignment_rad": float( + np.max(normal_alignments, initial=0.0) + ), + "maximum_pair_rotation_drift_rad": float( + np.max(rotation_drifts, initial=0.0) + ), + "p95_pair_rotation_drift_rad": float( + np.percentile(rotation_drifts, 95.0) + ), + "median_pair_rotation_drift_rad": float( + np.median(rotation_drifts) + ), + "maximum_pair_translation_drift_m": float( + np.max(translation_drifts, initial=0.0) + ), + "p95_pair_translation_drift_m": float( + np.percentile(translation_drifts, 95.0) + ), + "maximum_pair_distance_drift_m": float( + np.max(distance_drifts, initial=0.0) + ), + "p95_pair_distance_drift_m": float( + np.percentile(distance_drifts, 95.0) + ), + "median_pair_distance_drift_m": float( + np.median(distance_drifts) + ), + } diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/types.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/types.py new file mode 100644 index 0000000..8eb7a8d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/tag_pose/types.py @@ -0,0 +1,14 @@ +"""Types for shared square Tag pose estimation.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SquareTagPose: + """One tag-to-camera pose candidate returned by IPPE.""" + + quaternion_xyzw: tuple[float, float, float, float] + translation_xyz_m: tuple[float, float, float] + reprojection_error_px: float diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/solver/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/solver/__init__.py deleted file mode 100644 index ad28c51..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/core/solver/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Task acceptance and final-session solver contracts.""" - -from .interfaces import ( - SessionSolution, - SessionSolver, - TaskEvaluation, - TaskEvaluator, -) - -__all__ = [ - "SessionSolution", - "SessionSolver", - "TaskEvaluation", - "TaskEvaluator", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/solver/interfaces.py b/src/linkerhand_calibration/linkerhand_calibration/core/solver/interfaces.py deleted file mode 100644 index 023baeb..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/core/solver/interfaces.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shared evaluator and final-solver interfaces.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Mapping, Protocol, Sequence - -from ..domain import CalibrationProfile, SampleRecord, TaskSpec - - -@dataclass(frozen=True) -class TaskEvaluation: - accepted: bool - failures: tuple[Mapping[str, Any], ...] = () - rescan_measurements: frozenset[str] = frozenset() - rescan_cycles: frozenset[int] = frozenset() - - -@dataclass(frozen=True) -class SessionSolution: - passed: bool - calibration: Mapping[str, Any] - zero_offsets_rad: Mapping[str, float] - failures: tuple[Mapping[str, Any], ...] = () - diagnostics: Mapping[str, Any] = field(default_factory=dict) - - -class TaskEvaluator(Protocol): - def evaluate_task( - self, - profile: CalibrationProfile, - task: TaskSpec, - samples: Sequence[SampleRecord], - ) -> TaskEvaluation: ... - - -class SessionSolver(Protocol): - def solve_session( - self, - profile: CalibrationProfile, - samples: Sequence[SampleRecord], - ) -> SessionSolution: ... diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/__init__.py index 46c1413..6766385 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/__init__.py @@ -1,12 +1,22 @@ """URDF correction authorization and validation types.""" -from .plan import UrdfCorrectionPlan, build_correction_plan +from .plan import ( + StandardUrdfPlan, UrdfCorrectionPlan, build_correction_plan, + build_standard_correction_plan, +) +from .validate import ( + URDF_FIELD_TO_XML, + validate_runtime_curve_limits, + validate_structural_urdf_diff, +) from .patch import ( MujocoEqualityPatch, UrdfJointPatch, UrdfPatchSet, apply_urdf_patch_text, + corrected_origin_rpy, materialize_relative_mesh_assets, + parse_vector3, validate_urdf_mimic_ranges, write_urdf_patches, ) @@ -14,11 +24,18 @@ from .patch import ( __all__ = [ "MujocoEqualityPatch", "UrdfCorrectionPlan", + "StandardUrdfPlan", "UrdfJointPatch", "UrdfPatchSet", + "URDF_FIELD_TO_XML", "apply_urdf_patch_text", "build_correction_plan", + "build_standard_correction_plan", + "corrected_origin_rpy", "materialize_relative_mesh_assets", + "parse_vector3", "validate_urdf_mimic_ranges", + "validate_runtime_curve_limits", + "validate_structural_urdf_diff", "write_urdf_patches", ] diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/acceptance.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/acceptance.py new file mode 100644 index 0000000..01be3ee --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/acceptance.py @@ -0,0 +1,312 @@ +"""Acceptance of serialized standard-URDF motion, not a substitute JSON model.""" + +from __future__ import annotations + +from dataclasses import dataclass +import hashlib +import math +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +from scipy.spatial.transform import Rotation + +from .kinematics import UrdfKinematicModel +from .patch import validate_urdf_mimic_ranges +from .validate import validate_runtime_curve_limits, validate_structural_urdf_diff + + +@dataclass(frozen=True) +class JointHoldout: + """Independent observation in the corrected coordinate convention. + + Inputs retain their recorded SDK domain. No validation-time tag mounting, + zero, branch selection, curve fit or base registration is permitted. + """ + + sample_id: str + joint: str + sdk_values: tuple[float, ...] + observed_rad: float + direction: str + cycle: int = 3 + # Optional independent child-link pose in the *training-locked* base frame. + base_from_link: tuple[tuple[float, ...], ...] | None = None + # An active scan direction must not be assigned to every held axis. + sdk_directions_by_joint: Mapping[str, str] | None = None + + +@dataclass(frozen=True) +class ErrorMetrics: + count: int + mae_deg: float + p95_deg: float + maximum_deg: float + + @property + def passed(self) -> bool: + return self.count > 0 and self.mae_deg <= 1.0 and self.p95_deg <= 2.0 and self.maximum_deg <= 3.0 + + +def angular_metrics(errors_rad: Sequence[float]) -> ErrorMetrics: + errors = np.abs(np.asarray(errors_rad, dtype=float)) + if errors.ndim != 1 or not errors.size or not np.all(np.isfinite(errors)): + raise ValueError("angular validation requires finite independent errors") + degrees = np.rad2deg(errors) + return ErrorMetrics(len(errors), float(np.mean(degrees)), float(np.percentile(degrees, 95)), float(np.max(degrees))) + + +class SerializedJointMapping: + """Read the numeric legacy v4/v6/v7 curve shapes without model imports. + + Byte payloads preserve their deployed nearest-integer lookup; continuous + payloads interpolate explicit knots. Neither path silently clamps inputs. + """ + + def __init__(self, payload: Mapping[str, Any], *, input_kind=None) -> None: + self.payload = payload + self.compact = payload.get("format") == "unified_calibration_v2" + input_kind = input_kind or ("command" if self.compact else "feedback") + self.unified = payload.get("format") == "unified_calibration_v1" + if self.compact: + if input_kind != "command" or payload.get("schema_version") != 2: + raise ValueError("compact JSON is command-only; feedback mapping belongs to the report") + unit = payload.get("input_unit") + if unit not in {"rad", "u8"}: + raise ValueError("invalid compact input unit") + joints = {} + for name, row in payload.get("joints", {}).items(): + expected = {"sdk_channel", "angle_rad"} | ({"input_values"} if unit == "rad" else set()) + index = row.get("sdk_channel") + if set(row) != expected or type(index) is not int or index < 0: + raise ValueError(f"invalid compact joint/channel fields:{name}") + joints[name] = {"motor_index": index, "angle_rad": row["angle_rad"], + "curve_input_knots_rad": row.get("input_values", ())} + payload = {"joints": joints, "curve_input_domain": f"command_{unit}"} + elif self.unified: + if input_kind not in {"command", "feedback"} or payload.get("schema_version") != 1: + raise ValueError("unsupported unified mapping kind/version") + joints = {} + unit = payload["command_unit"] + count = len(payload["command_names"]) + if unit not in {"rad", "u8"} or count == 0: + raise ValueError("invalid unified SDK layout") + for name, row in payload["joints"].items(): + if row.get("urdf_joint") != name: + raise ValueError("unified keys must be original URDF joint names") + if row.get("passive"): + if "command_to_rad" in row or "feedback_to_rad" in row: + raise ValueError("passive runtime curves cannot bypass standard URDF mimic") + continue + data = row[f"{input_kind}_to_rad"] + if (data["input_domain"] != f"{input_kind}_{unit}" or data["input_unit"] != unit + or data["output_unit"] != "rad" or data["interpolation"] != "piecewise_linear" + or data["extrapolation"] != "reject" or not 0 <= data["channel_index"] < count + or data["valid_input_range"] != [data["knots"][0], data["knots"][-1]]): + raise ValueError("unified mapping coordinate contract changed") + joints[name] = {**data, "motor_index": data["channel_index"], + "curve_input_knots_rad": data["knots"], "urdf_joint": name} + payload = {**payload, "joints": joints, "curve_input_domain": f"{input_kind}_{unit}"} + self.domain = str(payload.get("curve_input_domain", "command_u8" if payload.get("schema_version") == 4 else "")) + if self.domain not in {"command_u8", "feedback_u8", "feedback_rad", "command_rad"}: + raise ValueError("serialized SDK input domain is missing or unsupported") + self.joints: dict[str, Mapping[str, Any]] = {} + for name, row in payload.get("joints", {}).items(): + target = str(row.get("urdf_joint", name)) + if target in self.joints: + raise ValueError(f"duplicate serialized joint: {target}") + self.joints[target] = row + if int(row["motor_index"]) < 0: + raise ValueError("negative serialized motor index") + for branch in ("angle_rad", "increasing_rad", "decreasing_rad"): + values = np.asarray(row.get(branch, row["angle_rad"]), dtype=float) + if values.ndim != 1 or len(values) < 2 or not np.all(np.isfinite(values)): + raise ValueError(f"invalid serialized curve: {target}:{branch}") + differences = np.diff(values) + if not (np.all(differences >= -1e-9) or np.all(differences <= 1e-9)): + raise ValueError(f"serialized curve is not monotonic: {target}:{branch}") + if self.domain.endswith("_u8") and not self.unified: + if len(values) != 256: + raise ValueError("byte mapping requires 256 entries") + else: + knots = np.asarray(row.get("curve_input_knots_rad", ()), dtype=float) + if knots.shape != values.shape or not np.all(np.isfinite(knots)) or np.any(np.diff(knots) <= 0): + raise ValueError(f"continuous curve requires strictly ordered finite knots: {target}") + if not self.joints: + raise ValueError("serialized calibration has no joint mappings") + + def evaluate(self, sdk_values: Sequence[float], direction: str, *, active_joints: Sequence[str], directions_by_joint: Mapping[str, str] | None = None) -> dict[str, float]: + if direction not in {"increasing", "decreasing", ""}: + raise ValueError("invalid recorded SDK direction") + output: dict[str, float] = {} + for name in active_joints: + row = self.joints.get(name) + if row is None: + raise ValueError(f"serialized mapping is missing active joint: {name}") + index = int(row["motor_index"]) + if index >= len(sdk_values): + raise ValueError("holdout SDK vector has missing channels") + value = float(sdk_values[index]) + if not math.isfinite(value): + raise ValueError("holdout SDK input is nonfinite") + selected = direction if directions_by_joint is None else directions_by_joint.get(name, "") + if selected not in {"increasing", "decreasing", ""}: + raise ValueError("invalid per-joint SDK direction") + if self.domain.endswith("_rad") and selected: + increasing_branch = str(row.get("raw_increasing_curve_branch", "increasing")) + if increasing_branch not in {"increasing", "decreasing"}: + raise ValueError("invalid serialized direction convention") + selected = increasing_branch if selected == "increasing" else ("decreasing" if increasing_branch == "increasing" else "increasing") + values = row.get(f"{selected}_rad", row["angle_rad"]) if selected else row["angle_rad"] + if self.domain.endswith("_u8") and not self.unified: + if not 0 <= value <= 255: + raise ValueError("holdout input is outside serialized byte domain") + angle = float(values[int(math.floor(value + 0.5))]) + else: + knots = row["curve_input_knots_rad"] + if self.domain.endswith("_u8"): + value = float(math.floor(value+0.5)) + if not knots[0] <= value <= knots[-1]: + raise ValueError(f"holdout input is outside serialized knot domain: {name}") + angle = float(np.interp(value, knots, values)) + output[name] = angle + return output + + +def validate_standard_urdf_holdout( + *, corrected_urdf: str | Path, payload: Mapping[str, Any], + observations: Sequence[JointHoldout], required_joints: Sequence[str], + required_pose_joints: Sequence[str] = (), holdout_cycle: int = 3, + minimum_samples: int = 40, +) -> dict[str, ErrorMetrics]: + """Resolve passive joints exclusively from the written URDF and validate. + + A perfect nonlinear passive JSON curve cannot make a wrong linear URDF + pass. Missing observations, duplicate-only samples and training rows fail. + """ + model = UrdfKinematicModel(corrected_urdf) + mapping = SerializedJointMapping(payload) + required = frozenset(required_joints) + pose_required = frozenset(required_pose_joints) + if not required or not pose_required <= required or not required <= model.joints.keys(): + raise ValueError("holdout requires declared, existing measured joints") + if minimum_samples < 1: + raise ValueError("minimum holdout samples must be positive") + active = tuple(name for name, joint in model.joints.items() if joint.kind != "fixed" and joint.mimic_joint is None) + errors: dict[str, list[float]] = {name: [] for name in required} + pose_errors: dict[str, list[tuple[float, float]]] = {name: [] for name in pose_required} + seen: set[tuple[str, str]] = set() + for row in observations: + if row.cycle != holdout_cycle: + raise ValueError("training data cannot enter serialized URDF holdout") + if row.joint not in required: + continue + identity = (row.joint, row.sample_id) + if not row.sample_id or identity in seen: + raise ValueError("holdout sample identity is empty or duplicated") + seen.add(identity) + if not math.isfinite(row.observed_rad): + raise ValueError("nonfinite holdout observation") + angles = mapping.evaluate(row.sdk_values, row.direction, active_joints=active, + directions_by_joint=row.sdk_directions_by_joint) + resolved = model.resolve_angles(angles) + for name, angle in resolved.items(): + joint = model.joints[name] + if joint.lower is not None and not joint.lower - 1e-9 <= angle <= joint.upper + 1e-9: + raise ValueError(f"serialized URDF motion exceeds joint limit: {name}") + errors[row.joint].append(resolved[row.joint] - row.observed_rad) + if row.joint in pose_required: + observed = np.asarray(row.base_from_link, dtype=float) + if observed.shape != (4, 4) or not np.all(np.isfinite(observed)): + raise ValueError(f"missing independent locked-base pose: {row.joint}") + rotation = observed[:3, :3] + if not np.allclose(observed[3], (0, 0, 0, 1), atol=1e-9) or not np.allclose(rotation.T @ rotation, np.eye(3), atol=1e-6) or not np.isclose(np.linalg.det(rotation), 1, atol=1e-6): + raise ValueError("holdout pose is not a rigid transform") + predicted = model.link_transform(row.joint, zero_offsets={}, joint_angles=angles) + angular = float(Rotation.from_matrix(predicted[:3, :3].T @ rotation).magnitude()) + translation = float(np.linalg.norm(predicted[:3, 3] - observed[:3, 3])) + pose_errors[row.joint].append((angular, translation)) + result: dict[str, ErrorMetrics] = {} + for name, values in errors.items(): + if len(values) < minimum_samples: + raise ValueError(f"missing independent URDF holdout samples: {name}:{len(values)}") + metrics = angular_metrics(values) + if not metrics.passed: + code = "standard_urdf_mimic_not_accurate" if model.joints[name].mimic_joint else "serialized_active_mapping_not_accurate" + raise ValueError(f"{code}:{name}:mae={metrics.mae_deg:.6f}:p95={metrics.p95_deg:.6f}:max={metrics.maximum_deg:.6f}") + result[name] = metrics + for name, values in pose_errors.items(): + metrics = angular_metrics([value[0] for value in values]) + # Existing independent combination-pose acceptance: 3 mm P95 / 2 deg P95. + if not metrics.passed or float(np.percentile([value[1] for value in values], 95)) > 0.003: + raise ValueError(f"serialized_urdf_spatial_holdout_failed:{name}") + return result + + +def validate_artifact_structure(*, source_urdf: str | Path, corrected_urdf: str | Path, + source_sha256: str, authorized_fields: Mapping[str, Sequence[str]], + payload: Mapping[str, Any], zero_offsets_rad: Mapping[str, float]) -> tuple[str, ...]: + """Validate physical ranges in CAD coordinates, including coordinate shifts.""" + if len(source_sha256) != 64 or hashlib.sha256(Path(source_urdf).read_bytes()).hexdigest() != source_sha256: + raise ValueError("protected source URDF SHA256 mismatch") + changes = validate_structural_urdf_diff(source_urdf, corrected_urdf, + authorized_fields=authorized_fields, expected_source_sha256=source_sha256) + before, after = UrdfKinematicModel(source_urdf), UrdfKinematicModel(corrected_urdf) + if not set(zero_offsets_rad) <= before.joints.keys(): + raise ValueError("declared coordinate zero targets missing joints") + # No model bypass, no inherited-CAD excess allowance at final acceptance. + validate_urdf_mimic_ranges(corrected_urdf) + for name, joint in after.joints.items(): + original = before.joints[name] + delta = float(zero_offsets_rad.get(name, 0.0)) + if not math.isfinite(delta): + raise ValueError("nonfinite coordinate zero") + if joint.lower is not None and original.lower is not None: + if joint.lower + delta < original.lower - 1e-8 or joint.upper + delta > original.upper + 1e-8: + raise ValueError(f"corrected range exceeds source mechanical range: {name}") + expected = original.origin @ before._motion(original, delta) + if not np.allclose(expected, joint.origin, atol=1e-8, rtol=0): + raise ValueError(f"URDF origin does not match declared coordinate zero: {name}") + mapping = SerializedJointMapping(payload) + if mapping.compact: + validate_compact_urdf_tables(payload, after) + for branch in ("angle_rad", "increasing_rad", "decreasing_rad"): + validate_runtime_curve_limits(corrected_urdf, + {name: row.get(branch, row["angle_rad"]) for name, row in mapping.joints.items()}) + if payload.get("format") == "unified_calibration_v1": + commands = SerializedJointMapping(payload, input_kind="command") + for branch in ("angle_rad", "increasing_rad", "decreasing_rad"): + validate_runtime_curve_limits(corrected_urdf, + {name: row[branch] for name, row in commands.joints.items()}) + for name, row in payload["joints"].items(): + if row.get("passive"): + joint = after.joints[name] + expected = {"joint": joint.mimic_joint, "multiplier": joint.mimic_multiplier, + "offset_rad": joint.mimic_offset} + actual = row.get("mimic", {}) + if (actual.get("joint") != expected["joint"] or any(not math.isclose( + float(actual.get(field, float("nan"))), expected[field], abs_tol=1e-8, rel_tol=0) + for field in ("multiplier", "offset_rad"))): + raise ValueError(f"JSON mimic differs from exported URDF:{name}") + return changes + + +def validate_compact_urdf_tables(payload, model): + """Redundant passive lookup entries must equal the written URDF, recursively.""" + mapping = SerializedJointMapping(payload, input_kind="command") + moving = {name for name, joint in model.joints.items() if joint.kind != "fixed"} + if set(mapping.joints) != moving: + raise ValueError("compact JSON joints differ from the exported URDF") + for name, row in mapping.joints.items(): + joint = model.joints[name] + values = np.asarray(row["angle_rad"]) + if joint.lower is not None and (np.min(values) < joint.lower-1e-9 or np.max(values) > joint.upper+1e-9): + raise ValueError(f"compact table exceeds exported URDF range:{name}") + if joint.mimic_joint: + parent = mapping.joints[joint.mimic_joint] + if (row["motor_index"] != parent["motor_index"] + or row["curve_input_knots_rad"] != parent["curve_input_knots_rad"] + or len(values) != len(parent["angle_rad"]) + or not np.allclose(values, joint.mimic_multiplier*np.asarray(parent["angle_rad"]) + + joint.mimic_offset, atol=1e-8, rtol=0)): + raise ValueError(f"compact passive table differs from exported URDF mimic:{name}") diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/kinematics.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/kinematics.py new file mode 100644 index 0000000..208d26e --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/kinematics.py @@ -0,0 +1,200 @@ +"""Standard URDF kinematics; independent of SDK, ROS and product profiles. + +Measured passive angles are useful to estimate geometry, but release validation +must resolve the *serialized* mimic graph, never those independent observations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from pathlib import Path +from typing import Mapping, Sequence +import xml.etree.ElementTree as ET + +import numpy as np +from scipy.spatial.transform import Rotation + + +def _parse_triplet(value: str) -> np.ndarray: + result = np.asarray([float(v) for v in str(value).split()], dtype=float) + if result.shape != (3,) or not np.all(np.isfinite(result)): + raise ValueError(f"invalid URDF vector: {value}") + return result + + +def _axis_rotation(axis: Sequence[float], angle: float) -> np.ndarray: + vector = np.asarray(axis, dtype=float) + if vector.shape != (3,) or not np.all(np.isfinite(vector)): + raise ValueError("joint axis must be a finite 3-vector") + norm = float(np.linalg.norm(vector)) + if norm <= 1e-12 or not math.isfinite(float(angle)): + raise ValueError("joint axis/angle is invalid") + transform = np.eye(4) + transform[:3, :3] = Rotation.from_rotvec(vector / norm * float(angle)).as_matrix() + return transform + + +@dataclass(frozen=True) +class UrdfJoint: + name: str + parent: str + child: str + origin: np.ndarray + axis: np.ndarray + mimic_joint: str | None + mimic_multiplier: float + mimic_offset: float + kind: str = "revolute" + lower: float | None = None + upper: float | None = None + + +class UrdfKinematicModel: + def __init__(self, source: str | Path) -> None: + self.source = Path(source).expanduser().resolve() + root = ET.parse(self.source).getroot() + if root.tag != "robot": + raise ValueError("URDF root must be robot") + names = [element.get("name", "") for element in root.findall("link")] + if not names or "" in names or len(set(names)) != len(names): + raise ValueError("URDF link names must be nonempty and unique") + self.links = frozenset(names) + self.joints: dict[str, UrdfJoint] = {} + self.parent_joint_by_child: dict[str, str] = {} + for element in root.findall("joint"): + name = element.get("name", "") + kind = element.get("type", "") + if not name or name in self.joints: + raise ValueError("URDF joint names must be nonempty and unique") + if kind not in {"fixed", "revolute", "continuous", "prismatic"}: + raise ValueError(f"unsupported calibration joint type: {name}:{kind}") + parent, child = element.find("parent"), element.find("child") + if parent is None or child is None: + raise ValueError(f"URDF joint is missing parent/child: {name}") + parent_name, child_name = parent.get("link", ""), child.get("link", "") + if parent_name not in self.links or child_name not in self.links: + raise ValueError(f"URDF joint references a missing link: {name}") + if child_name in self.parent_joint_by_child: + raise ValueError(f"URDF link has multiple parents: {child_name}") + origin_node, axis_node = element.find("origin"), element.find("axis") + xyz = _parse_triplet("0 0 0" if origin_node is None else origin_node.get("xyz", "0 0 0")) + rpy = _parse_triplet("0 0 0" if origin_node is None else origin_node.get("rpy", "0 0 0")) + axis = _parse_triplet("1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")) + if float(np.linalg.norm(axis)) <= 1e-12: + raise ValueError(f"URDF joint has a zero axis: {name}") + axis /= np.linalg.norm(axis) + origin = np.eye(4) + origin[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() + origin[:3, 3] = xyz + mimic, limit = element.find("mimic"), element.find("limit") + multiplier = 1.0 if mimic is None else float(mimic.get("multiplier", "1")) + offset = 0.0 if mimic is None else float(mimic.get("offset", "0")) + if not math.isfinite(multiplier) or not math.isfinite(offset): + raise ValueError(f"URDF mimic is nonfinite: {name}") + lower = None if limit is None or kind in {"fixed", "continuous"} else float(limit.get("lower", "nan")) + upper = None if lower is None else float(limit.get("upper", "nan")) + if lower is not None and (not math.isfinite(lower) or not math.isfinite(upper) or lower > upper): + raise ValueError(f"URDF joint has invalid limits: {name}") + self.joints[name] = UrdfJoint(name, parent_name, child_name, origin, axis, + None if mimic is None else mimic.get("joint", ""), multiplier, offset, + kind, lower, upper) + self.parent_joint_by_child[child_name] = name + roots = self.links - self.parent_joint_by_child.keys() + if len(roots) != 1: + raise ValueError("URDF must have exactly one root link") + self.root_link = next(iter(roots)) + for name in self.joints: + self._chain(name) + self.resolve_angles({}) # Validate the complete mimic graph, including siblings. + + def _chain(self, target_joint: str) -> list[UrdfJoint]: + if target_joint not in self.joints: + raise ValueError(f"URDF is missing joint {target_joint}") + chain: list[UrdfJoint] = [] + seen: set[str] = set() + current = self.joints[target_joint] + while True: + if current.name in seen: + raise ValueError(f"URDF kinematic cycle: {current.name}") + seen.add(current.name) + chain.append(current) + parent = self.parent_joint_by_child.get(current.parent) + if parent is None: + if current.parent != self.root_link: + raise ValueError("URDF contains a disconnected joint") + return list(reversed(chain)) + current = self.joints[parent] + + def resolve_angles(self, joint_angles: Mapping[str, float], *, independent_mimic_angles: bool = False) -> dict[str, float]: + supplied = {str(k): float(v) for k, v in joint_angles.items()} + if not all(math.isfinite(v) for v in supplied.values()): + raise ValueError("joint angles must be finite") + resolved: dict[str, float] = {} + visiting: set[str] = set() + + def resolve(name: str) -> float: + if name in resolved: + return resolved[name] + if name in visiting or name not in self.joints: + raise ValueError(f"invalid URDF mimic dependency: {name}") + visiting.add(name) + joint = self.joints[name] + if joint.kind == "fixed": + angle = 0.0 + elif joint.mimic_joint is not None and not (independent_mimic_angles and name in supplied): + angle = joint.mimic_multiplier * resolve(joint.mimic_joint) + joint.mimic_offset + else: + angle = supplied.get(name, 0.0) + if not math.isfinite(angle): + raise ValueError(f"nonfinite resolved URDF angle: {name}") + visiting.remove(name) + resolved[name] = angle + return angle + + for name in self.joints: + resolve(name) + return resolved + + @staticmethod + def _motion(joint: UrdfJoint, angle: float) -> np.ndarray: + if joint.kind == "fixed": + return np.eye(4) + if joint.kind == "prismatic": + transform = np.eye(4) + transform[:3, 3] = joint.axis * angle + return transform + return _axis_rotation(joint.axis, angle) + + def link_transform(self, target_joint: str, *, zero_offsets: Mapping[str, float], joint_angles: Mapping[str, float], independent_mimic_angles: bool = False) -> np.ndarray: + """Base-to-child-link pose. Independent passive input is measurement-only.""" + resolved = self.resolve_angles(joint_angles, independent_mimic_angles=independent_mimic_angles) + transform = np.eye(4) + for joint in self._chain(target_joint): + transform = transform @ joint.origin @ self._motion(joint, float(zero_offsets.get(joint.name, 0.0)) + resolved[joint.name]) + return transform + + def axis_line(self, target_joint: str, *, zero_offsets: Mapping[str, float], joint_angles: Mapping[str, float]) -> tuple[np.ndarray, np.ndarray]: + resolved = self.resolve_angles(joint_angles) + transform = np.eye(4) + for joint in self._chain(target_joint): + frame = transform @ joint.origin @ self._motion(joint, float(zero_offsets.get(joint.name, 0.0))) + if joint.name == target_joint: + return frame[:3, :3] @ joint.axis, frame[:3, 3].copy() + transform = frame @ self._motion(joint, resolved[joint.name]) + raise ValueError(f"cannot resolve joint axis: {target_joint}") + + +def corrected_origin_rpy(joint: UrdfJoint, offset_rad: float) -> tuple[float, float, float]: + """q_CAD = q_output + offset; compose about the local axis, not an RPY component.""" + if joint.kind not in {"revolute", "continuous"}: + raise ValueError("angular zero correction requires a revolute joint") + matrix = joint.origin @ _axis_rotation(joint.axis, offset_rad) + return tuple(float(v) for v in Rotation.from_matrix(matrix[:3, :3]).as_euler("xyz")) + + +def corrected_mimic_offset(multiplier: float, offset: float, parent_zero: float, child_zero: float) -> float: + values = (multiplier, offset, parent_zero, child_zero) + if not all(math.isfinite(float(v)) for v in values): + raise ValueError("mimic coordinate correction must be finite") + return float(offset + multiplier * parent_zero - child_zero) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/patch.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/patch.py index 48b5e9a..6189b7b 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/patch.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/patch.py @@ -9,14 +9,19 @@ publishing a new file. from __future__ import annotations from dataclasses import dataclass, field +import hashlib import math import os from pathlib import Path import re import shutil +import tempfile from typing import Mapping, Sequence import xml.etree.ElementTree as ET +import numpy as np +from scipy.spatial.transform import Rotation + @dataclass(frozen=True) class UrdfJointPatch: @@ -73,6 +78,39 @@ class UrdfPatchSet: ) +def parse_vector3(value: str) -> np.ndarray: + """Parse and validate a finite three-component URDF vector.""" + result = np.asarray([float(item) for item in str(value).split()], dtype=float) + if result.shape != (3,) or not np.all(np.isfinite(result)): + raise ValueError(f"invalid URDF vector: {value}") + return result + + +def corrected_origin_rpy(joint: ET.Element, offset_rad: float) -> str: + """Rotate a joint origin about its declared axis while preserving zero edits.""" + origin = joint.find("origin") + if origin is None or origin.get("rpy") is None: + raise ValueError(f"joint {joint.get('name')} has no origin.rpy") + if abs(float(offset_rad)) <= 1.0e-12: + return str(origin.get("rpy")) + axis_node = joint.find("axis") + axis = parse_vector3( + "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") + ) + norm = float(np.linalg.norm(axis)) + if norm <= 1.0e-12: + raise ValueError(f"joint {joint.get('name')} has a degenerate axis") + source = Rotation.from_euler( + "xyz", parse_vector3(origin.get("rpy", "0 0 0")) + ) + corrected = source * Rotation.from_rotvec( + axis / norm * float(offset_rad) + ) + return " ".join( + f"{float(value):.15g}" for value in corrected.as_euler("xyz") + ) + + def _replace_attribute( block: str, element: str, attribute: str, value: str ) -> str: @@ -83,7 +121,15 @@ def _replace_attribute( ) match = pattern.search(block) if match is None: - raise ValueError(f"{element} has no {attribute} attribute") + # URDF permits omitted mimic coefficients (defaults: 1 and 0). A + # coordinate change must be able to materialize an authorized offset. + if (element, attribute) not in {("mimic", "offset"), ("mimic", "multiplier")}: + raise ValueError(f"{element} has no {attribute} attribute") + opening = re.search(rf"<{element}\b[^>]*>", block) + if opening is None: + raise ValueError(f"{element} has no {attribute} attribute: joint has no {element} element") + position = opening.end() - (2 if opening.group(0).endswith("/>") else 1) + return block[:position] + f' {attribute}="{value}"' + block[position:] start, end = match.span("value") return block[:start] + str(value) + block[end:] @@ -362,37 +408,89 @@ def write_urdf_patches( patches: UrdfPatchSet, forbidden_source_stem_patterns: Sequence[str] = (), copy_complete_mesh_directory: bool = False, + authorized_fields: Mapping[str, Sequence[str]] | None = None, + validate_mimic_ranges: bool = True, + expected_source_sha256: str | None = None, ) -> Path: """Validate and atomically materialize one patched URDF.""" source = Path(source_urdf).expanduser().resolve() destination = Path(destination_urdf).expanduser().resolve() if not source.is_file(): raise ValueError(f"source URDF does not exist: {source}") + source_bytes = source.read_bytes() + source_digest = hashlib.sha256(source_bytes).hexdigest() + if expected_source_sha256 is not None and source_digest != expected_source_sha256: + raise ValueError("source URDF SHA256 differs from protected input") for pattern in forbidden_source_stem_patterns: if re.search(str(pattern), source.stem, re.IGNORECASE): raise ValueError( "source URDF must be the immutable original CAD URDF" ) - if destination == source or destination.exists(): + if destination == source or destination.exists() or destination.is_symlink(): raise ValueError(f"refusing to overwrite URDF: {destination}") destination.parent.mkdir(parents=True, exist_ok=True) - tree = ET.parse(source) - root = tree.getroot() + root = ET.fromstring(source_bytes) corrected = apply_urdf_patch_text( - source.read_text(encoding="utf-8"), root, patches + source_bytes.decode("utf-8"), root, patches ) - materialize_relative_mesh_assets( - source=source, output=destination.parent, urdf_root=root - ) - if copy_complete_mesh_directory: - _copy_complete_mesh_directory(source, destination.parent) - temporary = destination.with_suffix(destination.suffix + ".tmp") + descriptor, temporary_name = tempfile.mkstemp(prefix=".urdf-validation-", suffix=".urdf", dir=destination.parent) + temporary = Path(temporary_name) try: - with temporary.open("w", encoding="utf-8") as stream: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: stream.write(corrected) stream.flush() os.fsync(stream.fileno()) - os.replace(temporary, destination) + # Reparse both documents and prove that patching preserved topology, + # mesh/inertial/collision/visual data, and every non-requested field. + # The local import avoids a module cycle with ``validate``. + from .validate import validate_structural_urdf_diff + + requested_authorization = { + name: tuple( + f"{element}.{attribute}" + for element, attribute, _value in patch.replacements() + ) + for name, patch in patches.joints.items() + } + authorization = ( + requested_authorization + if authorized_fields is None + else { + str(name): tuple(str(field) for field in fields) + for name, fields in authorized_fields.items() + } + ) + for name, requested in requested_authorization.items(): + unauthorized = set(requested) - set(authorization.get(name, ())) + if unauthorized: + raise ValueError( + "URDF patch requested fields absent from Profile authorization: " + f"{name}:" + ",".join(sorted(unauthorized)) + ) + validate_structural_urdf_diff( + source, + temporary, + authorized_fields=authorization, + authorized_mujoco_equalities=tuple(patches.mujoco_equalities), + expected_source_sha256=source_digest, + ) + # This belongs to the common writer rather than individual model + # serializers: every emitted URDF must have valid finite joint limits, + # existing mimic sources, acyclic chains, and no newly enlarged mimic + # range violation. Pre-existing CAD rounding excess is tolerated only + # when calibration does not make it worse. + if validate_mimic_ranges: + validate_urdf_mimic_ranges(temporary, reference_urdf=source) + materialize_relative_mesh_assets( + source=source, output=destination.parent, urdf_root=root + ) + if copy_complete_mesh_directory: + _copy_complete_mesh_directory(source, destination.parent) + if hashlib.sha256(source.read_bytes()).hexdigest() != source_digest: + raise ValueError("source URDF changed during validation") + # Atomic no-clobber installation. A concurrent writer (or user file) + # must never be overwritten or removed by our failure cleanup. + os.link(temporary, destination) finally: if temporary.exists(): temporary.unlink() diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py index 58eb643..441fc13 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/plan.py @@ -5,9 +5,152 @@ from __future__ import annotations from dataclasses import dataclass, field import hashlib from pathlib import Path -from typing import Mapping +from typing import Mapping, Sequence +import math +import xml.etree.ElementTree as ET from ..domain import CalibrationProfile +from ..fitting.coupling import LinearMimicFit +from .kinematics import UrdfKinematicModel, corrected_mimic_offset +from .patch import UrdfJointPatch, UrdfPatchSet, corrected_origin_rpy, write_urdf_patches + + +@dataclass(frozen=True) +class StandardUrdfPlan: + """Frozen coordinate transform plus independently declared field rights. + + Limits are expressed in output coordinates. Mimic fits enter in CAD + coordinates and are transformed exactly once here, including chains whose + parent is a passive joint. Nothing is inferred from JSON runtime curves. + """ + + source_sha256: str + zero_offsets_rad: Mapping[str, float] + limits_output_rad: Mapping[str, tuple[float, float]] + mimic_output: Mapping[str, tuple[str, float, float]] + patches: UrdfPatchSet + authorized_fields: Mapping[str, Sequence[str]] + + def write(self, source_urdf: str | Path, destination_urdf: str | Path) -> Path: + source = Path(source_urdf) + if hashlib.sha256(source.read_bytes()).hexdigest() != self.source_sha256: + raise ValueError("protected source URDF SHA256 changed before write") + return write_urdf_patches( + source_urdf=source, destination_urdf=destination_urdf, + patches=self.patches, authorized_fields=self.authorized_fields, + expected_source_sha256=self.source_sha256, + ) + + +def build_standard_correction_plan( + *, source_urdf: str | Path, source_sha256: str, + zero_offsets_rad: Mapping[str, float], + authorized_fields: Mapping[str, Sequence[str]], + measured_ranges_output_rad: Mapping[str, tuple[float, float]] | None = None, + fitted_mimics_cad: Mapping[str, LinearMimicFit] | None = None, + transferred_zero_sources: Mapping[str, str] | None = None, +) -> StandardUrdfPlan: + """Generate authorized standard-URDF edits, never clamp an invalid fit. + + q_CAD = q_output + delta. Consequently both bounds shift by -delta and + b_output = b_CAD + a*delta_parent - delta_child. Unobserved joints retain + their own CAD geometry and physical range, including transferred joints. + """ + source = Path(source_urdf) + if hashlib.sha256(source.read_bytes()).hexdigest() != source_sha256: + raise ValueError("protected source URDF SHA256 mismatch") + model = UrdfKinematicModel(source) + nodes = {str(node.get("name")): node for node in ET.parse(source).getroot().findall("joint")} + offsets = {str(name): float(value) for name, value in zero_offsets_rad.items()} + ranges = dict(measured_ranges_output_rad or {}) + mimics = dict(fitted_mimics_cad or {}) + requested = set(offsets) | set(ranges) | set(mimics) + if not requested <= model.joints.keys(): + raise ValueError("correction references a missing URDF joint") + for target, donor in (transferred_zero_sources or {}).items(): + if target == donor or target not in offsets or donor not in offsets: + raise ValueError("transfer requires distinct, explicitly solved donor and recipient") + if not math.isclose(offsets[target], offsets[donor], rel_tol=0, abs_tol=1e-12): + raise ValueError("transferred scalar zero differs from its declared donor") + if target in ranges: + raise ValueError("unmeasured transfer must retain recipient CAD physical range") + for name, value in offsets.items(): + if not math.isfinite(value) or model.joints[name].kind not in {"revolute", "continuous"}: + raise ValueError("zero corrections require finite revolute coordinates") + edits: dict[str, UrdfJointPatch] = {} + output_ranges: dict[str, tuple[float, float]] = {} + output_mimics: dict[str, tuple[str, float, float]] = {} + for name, joint in model.joints.items(): + delta = offsets.get(name, 0.0) + fields: dict[str, str] = {} + if delta != 0: + fields["origin_rpy"] = corrected_origin_rpy(nodes[name], delta) + if joint.lower is not None: + mechanical = (joint.lower - delta, joint.upper - delta) + interval = tuple(float(v) for v in ranges.get(name, mechanical)) + 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 interval[0] < mechanical[0] - 1e-9 or interval[1] > mechanical[1] + 1e-9: + raise ValueError(f"corrected range exceeds source mechanical range: {name}") + output_ranges[name] = interval + if interval[0] != joint.lower: + fields["limit_lower"] = f"{interval[0]:.15g}" + if interval[1] != joint.upper: + fields["limit_upper"] = f"{interval[1]:.15g}" + elif name in ranges: + raise ValueError(f"cannot bound an unbounded joint without changing its contract: {name}") + if name in mimics and joint.mimic_joint is None: + raise ValueError(f"cannot create a new mimic topology: {name}") + if joint.mimic_joint is not None: + fit = mimics.get(name) + if fit is not None: + if not isinstance(fit, LinearMimicFit) or fit.target_joint != name or fit.source_joint != joint.mimic_joint: + raise ValueError(f"mimic fit does not match source URDF topology: {name}") + if fit.training_cycles != (0, 1, 2) or not fit.residuals_rad or not all(math.isfinite(v) for v in fit.residuals_rad): + raise ValueError(f"mimic fit lacks training-only evidence: {name}") + if not fit.offset_observed and not math.isclose(fit.offset_rad, joint.mimic_offset, rel_tol=0, abs_tol=1e-12): + raise ValueError(f"unobservable passive static offset must retain CAD: {name}") + multiplier, offset = fit.multiplier, fit.offset_rad + else: + multiplier, offset = joint.mimic_multiplier, joint.mimic_offset + if not math.isfinite(multiplier) or not math.isfinite(offset): + raise ValueError(f"nonfinite mimic fit: {name}") + shifted = corrected_mimic_offset(multiplier, offset, offsets.get(joint.mimic_joint, 0), delta) + output_mimics[name] = (joint.mimic_joint, multiplier, shifted) + if multiplier != joint.mimic_multiplier: + fields["mimic_multiplier"] = f"{multiplier:.15g}" + if shifted != joint.mimic_offset: + fields["mimic_offset"] = f"{shifted:.15g}" + if fields: + patch = UrdfJointPatch(**fields) + rights = set(authorized_fields.get(name, ())) + if any(f"{element}.{attribute}" not in rights for element, attribute, _ in patch.replacements()): + raise ValueError(f"correction requests a field absent from Profile authorization: {name}") + edits[name] = patch + # Reject inconsistent full mimic ranges before creating any artifacts. + reachable: dict[str, tuple[float, float]] = {} + + def resolve(name: str) -> tuple[float, float]: + if name in reachable: + return reachable[name] + if name in output_mimics: + parent, a, b = output_mimics[name] + bounds = resolve(parent) + values = (a*bounds[0] + b, a*bounds[1] + b) + interval = (min(values), max(values)) + elif name in output_ranges: + interval = output_ranges[name] + else: + raise ValueError(f"mimic source has no bounded mechanical interval: {name}") + if name in output_ranges and (interval[0] < output_ranges[name][0] - 1e-9 or interval[1] > output_ranges[name][1] + 1e-9): + raise ValueError(f"URDF mimic reachable range exceeds limits: {name}") + reachable[name] = interval + return interval + + for name in output_mimics: + resolve(name) + return StandardUrdfPlan(source_sha256, offsets, output_ranges, output_mimics, + UrdfPatchSet(edits), {name: tuple(fields) for name, fields in authorized_fields.items()}) @dataclass(frozen=True) @@ -21,6 +164,7 @@ class UrdfCorrectionPlan: forbid_calibrated_source: bool = True forbid_overwrite: bool = True preserve_passive_joints: bool = True + authorized_fields: Mapping[str, Sequence[str]] = field(default_factory=dict) def __post_init__(self) -> None: if len(self.source_sha256) != 64 or any( @@ -101,4 +245,5 @@ def build_correction_plan( mimic_source_by_joint=profile.zero.mimic_source_by_joint, frozen_joints=frozen | profile.zero.cad_frozen_joints, frozen_offsets_rad=expected_frozen, + authorized_fields=profile.urdf_authorized_fields, ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/result_plan.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/result_plan.py new file mode 100644 index 0000000..4a652d8 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/result_plan.py @@ -0,0 +1,57 @@ +"""Prepare one standard-coordinate artifact result before serialization.""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Mapping + +from ..domain.profile import CalibrationProfile +from ..domain.result import CalibrationResult, JointMapping +from .kinematics import UrdfKinematicModel +from .plan import StandardUrdfPlan, build_standard_correction_plan + + +@dataclass(frozen=True) +class PreparedCalibration: + plan: StandardUrdfPlan + mappings: Mapping[str, JointMapping] + + +def prepare_standard_result(profile: CalibrationProfile, source: Path, + source_sha256: str, fit: CalibrationResult) -> PreparedCalibration: + model = UrdfKinematicModel(source) + mimics = {name: evidence.fit for name, evidence in fit.standard_mimic_evidence.items()} + for target, donor in profile.zero.transferred_mimic_sources.items(): + original = model.joints[target] + mimics[target] = replace(mimics[donor], target_joint=target, + source_joint=original.mimic_joint, offset_rad=original.mimic_offset) + ranges = {name: mapping.bounds_rad for name, mapping in fit.output_mappings.items() + if name not in profile.zero.transferred_zero_sources} + for name, mapping in fit.command_mappings.items(): + if name in ranges: + lo, hi = ranges[name] + ranges[name] = (min(lo, mapping.bounds_rad[0]), max(hi, mapping.bounds_rad[1])) + plan = build_standard_correction_plan(source_urdf=source, source_sha256=source_sha256, + zero_offsets_rad=fit.zero_offsets_rad, measured_ranges_output_rad=ranges, + fitted_mimics_cad=mimics, transferred_zero_sources=profile.zero.transferred_zero_sources, + authorized_fields=profile.urdf_authorized_fields) + mappings = dict(fit.output_mappings) + for name, mapping in mappings.items(): + lo, hi = plan.limits_output_rad[name] + actual_lo, actual_hi = mapping.bounds_rad + if actual_lo < lo - 1e-9 or actual_hi > hi + 1e-9: + raise ValueError(f"transferred mapping exceeds recipient CAD physical range:{name}") + def resolve(joint): + if joint in mappings: + return mappings[joint] + source_joint, multiplier, offset = plan.mimic_output[joint] + parent = resolve(source_joint) + branches = [tuple(offset + multiplier*v for v in values) + for values in (parent.angle_rad, parent.increasing_rad, parent.decreasing_rad)] + mappings[joint] = JointMapping(joint, parent.motor_index, parent.input_domain, + parent.knots, *branches, profile.zero.transferred_mimic_sources.get(joint)) + return mappings[joint] + for joint in plan.mimic_output: + resolve(joint) + return PreparedCalibration(plan, mappings) diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/tag_acceptance.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/tag_acceptance.py new file mode 100644 index 0000000..a5d6d1f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/tag_acceptance.py @@ -0,0 +1,85 @@ +"""Replay raw fourth-cycle Tag poses with the *written* URDF and JSON. + +Mounting transforms and base registration are frozen from training. Passive +poses are resolved only through the exported standard URDF mimic graph. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +from scipy.spatial.transform import Rotation + +from ..fitting.tag_installation import FrozenTagInstallation, rigid_matrix +from .acceptance import SerializedJointMapping, angular_metrics +from .kinematics import UrdfKinematicModel + + +@dataclass(frozen=True) +class TagHoldout: + sample_id: str + role: str + cycle: int + sdk_values: tuple[float, ...] + sdk_directions_by_joint: Mapping[str, str] + common_from_tag: tuple[tuple[float, ...], ...] + + +def validate_serialized_tag_holdout( + *, corrected_urdf: str | Path, payload: Mapping[str, Any], + common_from_base, installations: Mapping[str, FrozenTagInstallation], + observations: Sequence[TagHoldout], required_roles: Sequence[str], + minimum_samples: int = 40, + input_kind: str = "feedback", +) -> dict[str, dict[str, Any]]: + model = UrdfKinematicModel(corrected_urdf) + mapping = SerializedJointMapping(payload, input_kind=input_kind) + base = rigid_matrix(common_from_base) + roles = frozenset(required_roles) + if not roles or not roles <= installations.keys() or minimum_samples < 1: + raise ValueError("final Tag holdout requires declared frozen installations") + active = tuple(name for name, joint in model.joints.items() + if joint.kind != "fixed" and joint.mimic_joint is None) + training_ids = {sample for mount in installations.values() for sample in mount.training_sample_ids} + seen = set() + errors: dict[str, list[tuple[float, float]]] = {role: [] for role in roles} + for row in observations: + if row.cycle != 3 or not row.sample_id or row.sample_id in training_ids: + raise ValueError("final Tag holdout overlaps training or has no image identity") + if row.role not in roles: + raise ValueError("final Tag holdout contains an undeclared Tag") + identity = (row.role, row.sample_id) + if identity in seen: + raise ValueError("duplicate final Tag holdout image") + seen.add(identity) + mount = installations[row.role] + if mount.role != row.role or mount.link not in model.links: + raise ValueError("frozen Tag installation has a different role/link") + angles = mapping.evaluate(row.sdk_values, "", active_joints=active, + directions_by_joint=row.sdk_directions_by_joint) + for name, value in model.resolve_angles(angles).items(): + joint = model.joints[name] + if joint.lower is not None and not joint.lower - 1e-9 <= value <= joint.upper + 1e-9: + raise ValueError(f"serialized URDF motion exceeds joint limit:{name}") + transform = np.eye(4) if mount.link == model.root_link else model.link_transform( + model.parent_joint_by_child[mount.link], zero_offsets={}, joint_angles=angles) + predicted = base @ transform @ rigid_matrix(mount.link_from_tag) + observed = rigid_matrix(row.common_from_tag) + angle = float(Rotation.from_matrix(predicted[:3, :3].T @ observed[:3, :3]).magnitude()) + distance = float(np.linalg.norm(predicted[:3, 3] - observed[:3, 3])) + errors[row.role].append((angle, distance)) + results = {} + for role, values in errors.items(): + if len(values) < minimum_samples: + raise ValueError(f"missing independent Tag holdout samples:{role}:{len(values)}") + metrics = angular_metrics([value[0] for value in values]) + translation_p95 = float(np.percentile([value[1] for value in values], 95)) + if not metrics.passed or translation_p95 > 0.003: + raise ValueError(f"serialized_urdf_tag_holdout_failed:{role}:mae_deg={metrics.mae_deg}:p95_deg={metrics.p95_deg}:max_deg={metrics.maximum_deg}:translation_p95_m={translation_p95}") + results[role] = {"count": metrics.count, "mae_deg": metrics.mae_deg, + "p95_deg": metrics.p95_deg, "maximum_deg": metrics.maximum_deg, + "translation_p95_m": translation_p95} + return results diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/urdf/validate.py b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/validate.py new file mode 100644 index 0000000..0aedf5a --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/core/urdf/validate.py @@ -0,0 +1,183 @@ +"""Post-write structural and runtime-range validation for corrected URDFs.""" + +from __future__ import annotations + +import hashlib +import math +from pathlib import Path +from typing import Mapping, Sequence +import xml.etree.ElementTree as ET + + +URDF_FIELD_TO_XML = { + "origin.rpy": ("origin", "rpy"), + "limit.lower": ("limit", "lower"), + "limit.upper": ("limit", "upper"), + "mimic.multiplier": ("mimic", "multiplier"), + "mimic.offset": ("mimic", "offset"), +} + + +def validate_structural_urdf_diff( + source_urdf: str | Path, + corrected_urdf: str | Path, + *, + authorized_fields: Mapping[str, Sequence[str]], + authorized_mujoco_equalities: Sequence[str] = (), + expected_source_sha256: str | None = None, +) -> tuple[str, ...]: + """Prove that every parsed XML difference is explicitly authorized.""" + source_path = Path(source_urdf) + corrected_path = Path(corrected_urdf) + digest = hashlib.sha256(source_path.read_bytes()).hexdigest() + if expected_source_sha256 is not None and digest != str( + expected_source_sha256 + ).lower(): + raise ValueError("source URDF SHA256 differs from protected input") + source = ET.parse(source_path).getroot() + corrected = ET.parse(corrected_path).getroot() + allowed = { + str(joint): frozenset(str(field) for field in fields) + for joint, fields in authorized_fields.items() + } + allowed_equalities = frozenset( + str(name) for name in authorized_mujoco_equalities + ) + unsupported = { + field + for fields in allowed.values() + for field in fields + if field not in URDF_FIELD_TO_XML + } + if unsupported: + raise ValueError( + "unsupported authorized URDF fields: " + ",".join(sorted(unsupported)) + ) + changes: list[str] = [] + + def compare( + left: ET.Element, + right: ET.Element, + path: str, + joint_name: str | None, + joint_child_tag: str | None, + ) -> None: + if left.tag != right.tag: + raise ValueError(f"URDF topology changed at {path}") + current_joint = joint_name + current_child = joint_child_tag + if path == "/robot" and left.tag != "robot": + raise ValueError("URDF root is not robot") + if left.tag == "joint" and path.count("/") == 2: + if left.get("name") != right.get("name"): + raise ValueError(f"URDF joint topology changed at {path}") + current_joint = str(left.get("name", "")) + current_child = None + elif current_joint is not None and path.count("/") == 3: + current_child = left.tag + else: + # Authorization applies only to direct children of robot/joint. + # An extension's nested origin is not the kinematic joint origin. + current_child = None + if (left.text or "").strip() != (right.text or "").strip(): + raise ValueError(f"URDF element text changed at {path}") + keys = set(left.attrib) | set(right.attrib) + for attribute in sorted(keys): + before = left.attrib.get(attribute) + after = right.attrib.get(attribute) + if before == after: + continue + equality_change = bool( + left.tag == "joint" + and "/mujoco" in path + and str(left.get("name", "")) in allowed_equalities + and attribute == "polycoef" + ) + if equality_change: + changes.append( + f"mujoco.equality.{left.get('name')}.polycoef" + ) + continue + field = next( + ( + name + for name, location in URDF_FIELD_TO_XML.items() + if location == (current_child, attribute) + ), + None, + ) + if ( + current_joint is None + or field is None + or field not in allowed.get(current_joint, frozenset()) + ): + raise ValueError( + "corrected URDF changed an unauthorized field: " + f"{path}@{attribute}" + ) + changes.append(f"{current_joint}.{field}") + left_children = list(left) + right_children = list(right) + if len(left_children) != len(right_children): + raise ValueError(f"URDF topology changed at {path}") + for index, (left_child, right_child) in enumerate( + zip(left_children, right_children) + ): + left_identity = ( + left_child.tag, + left_child.get("name"), + left_child.get("link"), + ) + right_identity = ( + right_child.tag, + right_child.get("name"), + right_child.get("link"), + ) + if left_identity != right_identity: + raise ValueError(f"URDF child ordering/topology changed at {path}") + compare( + left_child, + right_child, + f"{path}/{left_child.tag}[{index}]", + current_joint, + current_child, + ) + + compare(source, corrected, "/robot", None, None) + return tuple(changes) + + +def validate_runtime_curve_limits( + corrected_urdf: str | Path, + curves_rad: Mapping[str, Sequence[float]], +) -> None: + """Reject JSON/runtime curves that exceed their corrected URDF limits.""" + root = ET.parse(Path(corrected_urdf)).getroot() + joints = {str(node.get("name")): node for node in root.findall("joint")} + for name, values in curves_rad.items(): + node = joints.get(str(name)) + if node is None: + raise ValueError(f"runtime curve targets missing URDF joint: {name}") + limit = node.find("limit") + if limit is None: + raise ValueError(f"runtime curve joint has no URDF limit: {name}") + lower = float(limit.get("lower", "nan")) + upper = float(limit.get("upper", "nan")) + samples = tuple(float(value) for value in values) + if ( + not samples + or not math.isfinite(lower) + or not math.isfinite(upper) + or any( + not math.isfinite(value) or value < lower - 1e-9 or value > upper + 1e-9 + for value in samples + ) + ): + raise ValueError(f"runtime curve exceeds corrected URDF limits: {name}") + + +__all__ = [ + "URDF_FIELD_TO_XML", + "validate_runtime_curve_limits", + "validate_structural_urdf_diff", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/full_hand.py b/src/linkerhand_calibration/linkerhand_calibration/full_hand.py index 71a0664..d2018a0 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/full_hand.py +++ b/src/linkerhand_calibration/linkerhand_calibration/full_hand.py @@ -2,6 +2,6 @@ import sys -from .models.g20 import profile as _implementation +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20 import profile as _implementation sys.modules[__name__] = _implementation diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/__init__.py deleted file mode 100644 index 5915a14..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Registered profiles for this hand family.""" - -from ..registry import ProfileRegistry - - -def register_profiles(registry: ProfileRegistry) -> None: - from .legacy_11 import build_left_profile, build_right_profile - from .right_19 import build_profile - - registry.register(build_profile()) - registry.register(build_left_profile()) - registry.register(build_right_profile()) - - -__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py deleted file mode 100644 index 571888f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py +++ /dev/null @@ -1,13395 +0,0 @@ -"""One-command three-camera calibration for either complete G20 hand.""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass, field, replace -import hashlib -import json -import math -from pathlib import Path -import random -import re -import time -import traceback -from typing import Any, Mapping, Sequence - -import numpy as np -import rclpy -from apriltag_msgs.msg import AprilTagDetectionArray -from rclpy.exceptions import ParameterUninitializedException -from rclpy.node import Node -from rclpy.parameter import Parameter -from rclpy.qos import qos_profile_sensor_data -from scipy.spatial.transform import Rotation -from sensor_msgs.msg import CameraInfo, JointState -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...acquisition import ( - StateSample, - TagQuality, - interpolate_state_u8, - required_resume_views, - tag_quality_is_valid, - update_pnp_reset_watchdog, -) -from ...core import ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - robust_rotation_summary, -) -from ...core.urdf import build_correction_plan -from ...runtime import ACQUISITION_POLICY_VERSION, CalibrationEngine -from ...runtime.adapters import ProfileSdkAdapter -from ...extrinsics import ( - ThreeCameraExtrinsics, - camera_info_fingerprint, - load_three_camera_extrinsics, - matrix_payload, - transform_matrix, -) -from .profile import ( - G20_COMBINATION_REQUIRED_TARGET_KEYS, - G20_REFERENCE_THUMB_CMC_JOINTS, - G20_RIGHT_19_LAYOUT, - LEFT_HAND_PROFILE, - RIGHT_19_END_ON_IMAGE_CURVE_JOINTS, - RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS, - THREE_CAMERA_BASELINE_COMMAND, - HandCalibrationProfile, - JointCurveFit, - PalmAxisObserver, - SweepSpec, - build_calibration_motion_command, - build_calibration_preparation_waypoints, - build_calibration_return_waypoints, - build_calibration_speed_profile, - build_compact_payload, - calibration_auxiliary_commands, - canonical_zero_direction, - clamp_runtime_fits_to_urdf_limits, - compare_cross_view_roll_curves, - cross_view_roll_diagnostic_metrics, - derive_mimic_passive_fits, - fit_joint_image_curve, - get_hand_calibration_profile, -) -from ...hikrobot_camera import configure_fastdds_large_image_transport -from .command_layout import G20_COMMAND_NAMES as COMMAND_NAMES -from ...pnp import ( - SquareTagGroupPoseTracker, - SquareTagPose, - SquareTagPoseTracker, -) -from ...product import get_product_calibration_contract -from ...sample_schema import ( - SampleDataContractError, - canonical_sample_record, - fitting_sample_record, - fitting_sample_records, - validate_sample_records, -) -from ...storage import append_jsonl, append_jsonl_many, atomic_write_json -from .reporting_zh import render_three_camera_status_text_zh -from .urdf_input import ( - build_g20_urdf_input_payload, - load_g20_urdf_input, -) -from .zero_solver import ( - LEFT_ZERO_PROFILE, - RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS, - RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, - RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS, - JointAxisMeasurement, - PalmOrientationMeasurement, - UrdfKinematicModel, - ZeroCalibrationProfile, - ZeroSolveResult, - anchor_right_19_mechanical_endpoint_curves, - axis_line_cycle_rms_m, - axis_line_uses_depth_free_interpretation_plane, - baseline_hysteresis_by_cycle_rad, - circle_direction_is_constrained, - cross_view_side_line_source, - fit_joint_axis_measurement, - fit_partial_palm_orientation_measurements, - fit_rotation_joint_curve, - derive_right_19_mechanical_endpoint_offsets, - expand_right_19_thumb_zero_result_with_cad_fingers, - joint_curve_holdout_errors, - maximum_axis_line_cycle_spread_m, - measure_joint_curve_observation, - merge_right_19_thumb_zero_result, - refit_axis_line_group_with_shared_radius, - select_cross_view_roll_direction_source, - solve_urdf_zero_offsets, - get_zero_calibration_profile, - get_right_19_thumb_zero_profile, - write_zero_corrected_urdf, - with_depth_free_axis_projection, -) - - -STATE_PREFLIGHT = "PREFLIGHT" -STATE_WAIT_START = "WAIT_START" -STATE_IMPORTING_BASE = "IMPORTING_BASE" -STATE_REVALIDATING_INHERITED = "REVALIDATING_INHERITED" -STATE_RETURN_BASELINE = "RETURN_BASELINE" -STATE_PREPARE_SWEEP = "PREPARE_SWEEP" -STATE_SWEEP = "SWEEP" -STATE_FITTING = "FITTING" -STATE_VALIDATION_MOVE = "VALIDATION_MOVE" -STATE_VALIDATION_CAPTURE = "VALIDATION_CAPTURE" -STATE_PAUSED = "PAUSED" -STATE_ABORTED = "ABORTED" -STATE_COMPLETE = "COMPLETE" - -STEADY_COMMAND_CHECKPOINTS: tuple[int, ...] = ( - 255, 224, 192, 160, 128, 96, 64, 32, 0 -) - -COMBINATION_TAG_TARGETS_BY_VIEW: Mapping[ - str, tuple[tuple[str, str], ...] -] = { - "front": tuple( - (name, name) - for name in ( - "thumb_cmc_pitch", "thumb_mcp", "thumb_ip", - "index_mcp_roll", "middle_mcp_roll", "ring_mcp_roll", "pinky_mcp_roll", - ) - ), - "side": tuple( - (f"{finger}_{joint}", f"{finger}_{joint}") - for finger in ("index", "middle", "ring", "pinky") - for joint in ("pip", "dip") - ), - # The top-view Tag observes yaw but is mounted on the final CMC link, - # downstream of pitch in the URDF chain. - "top": (("thumb_cmc_yaw", "thumb_cmc_pitch"),), -} - -COMBINATION_BASE_OBSERVER_BY_VIEW = { - "front": "thumb_cmc_pitch", - "side": "index_pip", - "top": "thumb_cmc_yaw", -} - -COMBINATION_BASE_ROLE_BY_VIEW = { - "front": "front_base", - "side": "side_base", - "top": "top_base", -} - - -def combination_target_coverage( - observation_counts: Mapping[str, int], - validation_counts: Mapping[str, int], -) -> dict[str, Any]: - required = G20_COMBINATION_REQUIRED_TARGET_KEYS - observations = { - key: int(observation_counts.get(key, 0)) for key in required - } - validations = { - key: int(validation_counts.get(key, 0)) for key in required - } - missing_observations = sorted( - key for key, count in observations.items() if count < 2 - ) - missing_validations = sorted( - key for key, count in validations.items() if count < 1 - ) - return { - "required_targets": list(required), - "minimum_observations_per_target": 2, - "minimum_validations_per_target": 1, - "observation_counts": observations, - "validation_counts": validations, - "missing_observation_targets": missing_observations, - "missing_validation_targets": missing_validations, - "coverage_passed": not missing_observations and not missing_validations, - } - - -def _combination_observable_joints( - profile: HandCalibrationProfile, - view: str, - roles: Sequence[str], -) -> tuple[str, ...]: - """Return combination targets measurable from the currently visible Tags. - - Combination poses deliberately do not require all four adjacent fingers - to be visible at once. A joint is usable only when both Tags that define - its relative pose are present in the same camera frame. - """ - visible = {str(role) for role in roles} - targets = { - observation_name - for observation_name, _ in COMBINATION_TAG_TARGETS_BY_VIEW[view] - } - return tuple( - name - for name, spec in profile.record_specs.items() - if name in targets - and spec.measured - and spec.view == view - and spec.parent_role in visible - and spec.child_role in visible - ) - - -def _overall_progress( - state: str, - *, - scan_progress: float, - validation_index: int = 0, - validation_total: int = 0, -) -> float: - """Keep 100% reserved for a validated, written calibration result.""" - scan = float(np.clip(scan_progress, 0.0, 1.0)) - if state == STATE_COMPLETE: - return 1.0 - if state in {STATE_FITTING}: - return 0.92 - if state in {STATE_VALIDATION_MOVE, STATE_VALIDATION_CAPTURE}: - validation = ( - 0.0 - if validation_total <= 0 - else float(np.clip(validation_index / validation_total, 0.0, 1.0)) - ) - return 0.92 + 0.07 * validation - return min(0.90, 0.90 * scan) - - -def _stamp_ns(stamp: Any) -> int: - return int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec) - - -def _safe_name(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()) - return safe or "UNSET" - - -def _sweep_storage_key(spec: SweepSpec) -> str | int: - """Keep legacy motor-keyed retry state while separating product tasks.""" - return spec.key if spec.task_name else int(spec.motor_index) - - -def _sweep_views( - profile: HandCalibrationProfile, spec: SweepSpec -) -> tuple[str, ...]: - """Return every camera that contributes a joint to one physical sweep.""" - views: list[str] = [] - for joint_name in spec.joints: - joint = profile.record_specs[joint_name] - if joint.view is not None and joint.view not in views: - views.append(joint.view) - return tuple(views or [spec.view]) - - -def _palm_axis_observer_for_sweep( - profile: HandCalibrationProfile, - spec: SweepSpec, - view: str, -) -> PalmAxisObserver | None: - """Return the one non-blocking palm observer attached to this task/view.""" - matches = tuple( - observer - for observer in profile.palm_axis_observers - if observer.task_name == spec.key and observer.view == str(view) - ) - if len(matches) > 1: - raise ValueError( - f"multiple palm-axis observers configured for {spec.key}:{view}" - ) - return matches[0] if matches else None - - -def _palm_axis_observer_schema( - profile: HandCalibrationProfile, -) -> list[dict[str, Any]]: - return [ - { - "source_name": item.source_name, - "task_name": item.task_name, - "view": item.view, - "parent_role": item.parent_role, - "child_role": item.child_role, - "model_joint": item.model_joint, - "motor_index": int(item.motor_index), - } - for item in profile.palm_axis_observers - ] - - -def _palm_axis_resume_policy( - profile: HandCalibrationProfile, - session_start: Mapping[str, Any], - compatibility_tokens: frozenset[str] | None = None, -) -> tuple[bool, tuple[str, ...]]: - """Validate optional palm-axis checkpoint data when a model uses it.""" - capability = "palm_axis_relative_motion_v3" - previous = {str(value) for value in session_start.get("capabilities", [])} - current = ( - frozenset(profile.capabilities) - if compatibility_tokens is None - else frozenset(compatibility_tokens) - ) - if not profile.palm_axis_observers and capability not in current: - return True, () - required_previous = set(current) - {capability} - if not required_previous.issubset(previous): - raise ValueError("resume checkpoint lacks required capabilities") - if capability in previous: - if session_start.get("palm_axis_observers") != ( - _palm_axis_observer_schema(profile) - ): - raise ValueError("resume palm-axis observer schema differs") - return True, () - # A new side-channel version changes the zero solver's observation model. - # Do not combine its fresh CMC axes with any trajectory captured under the - # previous PnP transaction semantics. This full invalidation happens only - # once; same-version checkpoints keep their normal task-level resume. - return False, tuple(spec.key for spec in profile.sweep_specs) - - -def _node_profile(node: Any) -> HandCalibrationProfile: - return getattr(node, "profile", LEFT_HAND_PROFILE) - - -def _command_names(node: Any) -> tuple[str, ...]: - profile = _node_profile(node) - names = tuple(getattr(profile, "command_names", ()) or COMMAND_NAMES) - if not names or len(set(names)) != len(names): - raise ValueError("product command names must be non-empty and unique") - return names - - -def _command_count(node: Any) -> int: - return len(_command_names(node)) - - -def _sweep_joints_for_view( - profile: HandCalibrationProfile, - spec: SweepSpec, - view: str, -) -> tuple[str, ...]: - return tuple( - joint_name - for joint_name in spec.joints - if profile.record_specs[joint_name].view == str(view) - ) - - -def resume_tasks_invalidated_by_tag_size_changes( - profile: HandCalibrationProfile, - previous_sizes_m_by_id: Mapping[int, float], - current_sizes_m_by_id: Mapping[int, float], -) -> tuple[tuple[int, ...], tuple[str, ...]]: - """Localise a corrected physical Tag size to dependent motion tasks. - - PnP translations stored in a checkpoint are already scaled by the Tag - size used during acquisition and cannot be repaired exactly afterwards. - A size-only product correction therefore invalidates every task that used - one of those Tags, while complete tasks whose required Tags are unchanged - remain valid sparse checkpoints. - """ - previous = { - int(key): float(value) - for key, value in previous_sizes_m_by_id.items() - } - current = { - int(key): float(value) - for key, value in current_sizes_m_by_id.items() - } - if set(previous) != set(current): - raise ValueError("resume Tag-size maps contain different Tag IDs") - if any( - not math.isfinite(value) or value <= 0.0 - for value in (*previous.values(), *current.values()) - ): - raise ValueError("resume Tag sizes must be finite and positive") - changed_ids = { - tag_id - for tag_id in current - if not math.isclose( - previous[tag_id], current[tag_id], rel_tol=0.0, abs_tol=1.0e-12 - ) - } - affected: list[str] = [] - base_role_by_view = { - "front": "front_base", - "side": "side_base", - "top": "top_base", - } - for spec in profile.sweep_specs: - used_ids: set[int] = set() - for view in _sweep_views(profile, spec): - used_ids.add(int(profile.view_tags[view][base_role_by_view[view]])) - for joint_name in _sweep_joints_for_view(profile, spec, view): - joint = profile.record_specs[joint_name] - for role in (joint.parent_role, joint.child_role): - if role is not None: - used_ids.add(int(profile.view_tags[view][role])) - if used_ids & changed_ids: - affected.append(spec.key) - return tuple(sorted(changed_ids)), tuple(affected) - - -def _fit_retry_joint_names( - profile: HandCalibrationProfile, - spec: SweepSpec, - failures: Sequence[Mapping[str, Any]], -) -> set[str]: - """Resolve the independently failed measurements in a physical task.""" - selected: set[str] = set() - validation_sources = profile.axis_validation_sources or {} - for failure in failures: - quality_sources = failure.get("quality_source_joints", ()) - if ( - isinstance(quality_sources, Sequence) - and not isinstance(quality_sources, (str, bytes)) - ): - selected_sources = { - str(source) - for source in quality_sources - if str(source) in spec.joints - } - if selected_sources: - selected.update(selected_sources) - continue - name = str(failure.get("joint", "")) - if str(failure.get("metric", "")) == "cross_view_roll_curve": - name = str(validation_sources.get(name, name)) - if name in spec.joints: - selected.add(name) - return selected or set(spec.joints) - - -def _isolated_axis_cycle_outliers( - axes: Sequence[np.ndarray], limit_rad: float -) -> set[int]: - """Return one unambiguous outlier outside an all-other-cycle cluster. - - At least three mutually consistent inlier cycles are required. Ambiguous - or generally scattered axes deliberately return an empty set so callers - retain the conservative full-task retry. - """ - if len(axes) < 4: - return set() - candidates: list[tuple[float, int]] = [] - for excluded in range(len(axes)): - inliers = [ - np.asarray(axis, dtype=float) - for index, axis in enumerate(axes) - if index != excluded - ] - consistent = True - maximum_inlier_difference = 0.0 - for left_index, left in enumerate(inliers): - for right in inliers[left_index + 1 :]: - difference = math.acos( - abs(float(np.clip(left @ right, -1.0, 1.0))) - ) - maximum_inlier_difference = max( - maximum_inlier_difference, difference - ) - if difference > float(limit_rad): - consistent = False - break - if not consistent: - break - if consistent: - candidates.append((maximum_inlier_difference, excluded)) - if not candidates: - return set() - candidates.sort() - best_spread, best_excluded = candidates[0] - # A gradual end-to-end drift can produce two different all-but-one - # subsets just under the formal limit. Localize only when one three-cycle - # cluster is substantially tighter than the acceptance band. - if best_spread > 0.5 * float(limit_rad): - return set() - if ( - len(candidates) > 1 - and abs(candidates[1][0] - best_spread) <= math.radians(0.01) - ): - return set() - return {best_excluded} - - -def _isolated_scalar_cycle_outlier( - values: Sequence[float], limit_rad: float -) -> set[int]: - """Return one clearly separated scalar outlier among three rounds. - - Zero-offset fits have three training cycles, so the axis helper above - cannot localize them (it deliberately requires three inliers). Use the - unique closest pair as a two-cycle consensus only when that pair is tight - and clearly better than either alternative. Symmetric drift and - generally scattered values retain the conservative full-task retry. - """ - samples = np.asarray(values, dtype=float) - limit = float(limit_rad) - if ( - samples.ndim != 1 - or samples.size != 3 - or not np.all(np.isfinite(samples)) - or not math.isfinite(limit) - or limit <= 0.0 - ): - return set() - pair_candidates = sorted( - ( - abs(float(samples[left] - samples[right])), - left, - right, - ) - for left in range(samples.size) - for right in range(left + 1, samples.size) - ) - pair_spread, left, right = pair_candidates[0] - second_pair_spread = pair_candidates[1][0] - overall_spread = pair_candidates[-1][0] - if overall_spread <= limit or pair_spread > 0.5 * limit: - return set() - # Do not arbitrarily pick one side of a gradual or nearly symmetric - # three-cycle drift. The winning pair must be materially tighter than - # the next candidate, not merely win due to floating-point noise. - if second_pair_spread - pair_spread < 0.1 * limit: - return set() - excluded = [ - index for index in range(samples.size) if index not in {left, right} - ] - if len(excluded) != 1: - return set() - outlier = excluded[0] - inlier_center = 0.5 * float(samples[left] + samples[right]) - if abs(float(samples[outlier]) - inlier_center) < 0.75 * limit: - return set() - return {outlier} - - -def _thumb_yaw_zero_repeatability_failures( - zero_result: ZeroSolveResult, - *, - maximum_cycle_range_rad: float, - maximum_confidence_half_width_rad: float, -) -> list[dict[str, Any]]: - """Apply a stricter, retry-aware publication gate to thumb CMC yaw.""" - joint_name = "thumb_cmc_yaw" - cycle_offsets = tuple( - float(value) - for value in zero_result.cycle_offsets_rad.get(joint_name, ()) - ) - failures: list[dict[str, Any]] = [] - if len(cycle_offsets) >= 2 and all( - math.isfinite(value) for value in cycle_offsets - ): - cycle_range = max(cycle_offsets) - min(cycle_offsets) - if cycle_range > float(maximum_cycle_range_rad): - failure: dict[str, Any] = { - "joint": joint_name, - "metric": "zero_cycle_offset_range_deg", - "actual": round(math.degrees(cycle_range), 6), - "limit": round( - math.degrees(maximum_cycle_range_rad), 6 - ), - "comparison": "maximum", - "cycle_offset_deg": [ - round(math.degrees(value), 6) - for value in cycle_offsets - ], - } - outliers = _isolated_scalar_cycle_outlier( - cycle_offsets, maximum_cycle_range_rad - ) - if outliers: - outlier = next(iter(outliers)) - failure["cycle"] = outlier + 1 - failure["inlier_cycles"] = [ - index + 1 - for index in range(len(cycle_offsets)) - if index != outlier - ] - failures.append(failure) - - # A range failure is more actionable: it can often identify exactly one - # acquisition cycle. Avoid adding a second, non-localized failure that - # would unnecessarily turn that retry back into a full yaw rescan. - if failures: - return failures - confidence = zero_result.offset_confidence_half_width_rad.get(joint_name) - if ( - confidence is not None - and math.isfinite(float(confidence)) - and float(confidence) > float(maximum_confidence_half_width_rad) - ): - failures.append( - { - "joint": joint_name, - "metric": "zero_confidence_95_half_width_deg", - "actual": round(math.degrees(float(confidence)), 6), - "limit": round( - math.degrees(maximum_confidence_half_width_rad), 6 - ), - "comparison": "maximum", - } - ) - return failures - - -def _previous_passed_joint_zero_offset( - session_dir: Path, serial_number: str, joint_name: str -) -> tuple[Path, float] | None: - """Read one zero from the previous passed sibling session, if present.""" - pointer = session_dir.parent / "latest_passed" - try: - previous = pointer.resolve(strict=True) - except OSError: - return None - if previous == session_dir or previous.parent != session_dir.parent: - return None - payload_path = previous / ( - f"g20_right_{_safe_name(serial_number)}_calibration.json" - ) - try: - payload = json.loads(payload_path.read_text(encoding="utf-8")) - offset = float( - payload["joints"][joint_name]["zero_angles"][ - "urdf_zero_offset_rad" - ] - ) - except (KeyError, OSError, TypeError, ValueError, json.JSONDecodeError): - return None - if not math.isfinite(offset): - return None - return previous, offset - - -def _isolated_axis_line_cycle_outliers( - measurements: Sequence[JointAxisMeasurement], limit_m: float -) -> set[int]: - """Return one cycle whose removal restores line-position repeatability. - - Four independently fitted near-parallel lines can contain one bad PnP - branch or one mechanically unsettled pass. When three cycles form a - clearly tighter cluster below the unchanged formal RMS limit, reacquire - only the excluded cycle. Ambiguous gradual drift still returns no - outlier and therefore keeps the conservative full four-cycle retry. - """ - if len(measurements) < 4 or float(limit_m) <= 0.0: - return set() - full_rms = axis_line_cycle_rms_m(measurements) - if full_rms <= float(limit_m): - return set() - candidates: list[tuple[float, int]] = [] - for excluded in range(len(measurements)): - inliers = [ - measurement - for index, measurement in enumerate(measurements) - if index != excluded - ] - inlier_rms = axis_line_cycle_rms_m(inliers) - if inlier_rms <= float(limit_m): - candidates.append((inlier_rms, excluded)) - if not candidates: - return set() - candidates.sort() - best_rms, best_excluded = candidates[0] - # Require a strong three-cycle cluster and a unique choice. This rejects - # the common gradual-drift case where excluding either endpoint happens - # to move a marginal RMS just below the gate. - if best_rms > 0.75 * float(limit_m): - return set() - if ( - len(candidates) > 1 - and candidates[1][0] - best_rms <= 0.10 * float(limit_m) - ): - return set() - return {best_excluded} - - -def _preserve_pnp_task_reference_for_sweep( - item: SweepItem, *, is_fit_retry: bool, has_precheck_anchor: bool -) -> bool: - """Keep the accepted IPPE branch after precheck and across retries.""" - return bool( - not item.precheck - and ( - item.cycle > 0 - or bool(is_fit_retry) - or bool(has_precheck_anchor) - ) - ) - - -def _requires_pnp_tracker_reset_for_sweep( - profile: HandCalibrationProfile, - item: SweepItem, - *, - is_fit_retry: bool, - preserve_retry_continuity: bool = False, -) -> bool: - """Reset G20-right PnP once per task, not once per formal cycle. - - The low-speed decreasing precheck establishes a branch with a static - endpoint window. Clearing frame-to-frame state at every later cycle made - four measurements of one rigid installation depend on four independent - planar-PnP initializations. Keep the tracker continuous for the whole - normal task transaction; a bounded retry remains a deliberately fresh - initialization while retaining the accepted task reference. - """ - if item.direction != DIRECTION_DECREASING: - return False - if profile.layout_id != G20_RIGHT_19_LAYOUT: - return True - if is_fit_retry and preserve_retry_continuity: - # A dependent retry (currently the top-view thumb-yaw axis pair) - # replaces motion samples inside an already accepted task-relative - # PnP generation. Resetting here can move the two planar Tags to a - # different, internally smooth pose branch and shift every yaw zero - # by several degrees. Keep the exact live tracker/reference while - # replacing only the localized physical acquisition. - return False - if is_fit_retry: - return True - if profile.precheck_sweeps: - return bool(item.precheck) - return not item.precheck and item.cycle == 0 - - -def _fixed_base_role(view: str) -> str: - return { - "front": "front_base", - "side": "side_base", - "top": "top_base", - }[str(view)] - - -def _sweep_uses_locked_base_reference( - profile: HandCalibrationProfile, - spec: SweepSpec, - view: str, -) -> bool: - """Use one session-fixed base pose wherever task geometry needs it. - - Pinky/ring flexion is required to expose the middle/index side Tag, but - that same safe pose physically covers front Tag 0. The clearance remains - parked through that finger's roll, pitch and PIP tasks, including stages - where front is only an inactive status camera. Tag 0 is therefore locked - from baseline for every four-finger task; any moving Tag used by the - actual capture remains live and mandatory. - - Top Tag 8 has a different contract: it remains live and visible, but its - pose is frozen after preflight. Pitch and roll are collected in separate - tasks and jointly define thumb yaw. Re-solving a near-frontal planar base - Tag independently in those tasks expresses the two axes in subtly - different frames and turns image-position-dependent IPPE tilt into a yaw - zero shift. One session reference makes any fixed pose bias common to - both axes, where it cancels geometrically. - """ - if profile.layout_id != G20_RIGHT_19_LAYOUT: - return False - selected_view = str(view) - if selected_view == "front": - return spec.key.startswith( - ("pinky_", "ring_", "middle_", "index_") - ) - return bool( - selected_view == "top" - and spec.key - in { - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - "thumb_cmc_yaw_top", - } - ) - - -def _maximum_corner_drift_px( - reference: Sequence[Sequence[float]], - current: Sequence[Sequence[float]], -) -> float: - """Return the largest same-corner displacement for one fixed Tag.""" - reference_array = np.asarray(reference, dtype=float) - current_array = np.asarray(current, dtype=float) - if ( - reference_array.shape != (4, 2) - or current_array.shape != (4, 2) - or not np.all(np.isfinite(reference_array)) - or not np.all(np.isfinite(current_array)) - ): - return float("inf") - return float( - np.max(np.linalg.norm(current_array - reference_array, axis=1)) - ) - - -def _resume_fixed_base_position_compatibility( - rows: Sequence[Mapping[str, Any]], - current_corners_by_view: Mapping[ - str, Sequence[Sequence[float]] | None - ], - maximum_corner_drift_px: float, -) -> tuple[dict[str, float], tuple[str, ...], tuple[str, ...]]: - """Compare the previous and current session-start palm references. - - Calibration measurements are invariant to one rigid hand placement, but - samples expressed in two independently established common frames must - never be combined. Fixed palm-Tag corners provide a camera-native check - before any durable task is imported. - """ - previous_corners_by_view: dict[str, Any] = {} - for row in rows: - if str(row.get("kind", "")) != "fixed_base_reference_locked": - continue - view = str(row.get("view", "")) - if view in current_corners_by_view: - # Keep the last lock in case a future compatible schema records a - # deliberate pre-scan relock in the same raw stream. - previous_corners_by_view[view] = row.get("corner_reference_xy") - - drift_by_view_px: dict[str, float] = {} - changed_views: list[str] = [] - unverifiable_views: list[str] = [] - for view, current in current_corners_by_view.items(): - previous = previous_corners_by_view.get(str(view)) - if previous is None or current is None: - unverifiable_views.append(str(view)) - continue - drift = _maximum_corner_drift_px(previous, current) - if not math.isfinite(drift): - unverifiable_views.append(str(view)) - continue - drift_by_view_px[str(view)] = drift - if drift > float(maximum_corner_drift_px): - changed_views.append(str(view)) - return ( - drift_by_view_px, - tuple(sorted(changed_views)), - tuple(sorted(unverifiable_views)), - ) - - -def _selected_pose_qualities( - selected: Mapping[str, SquareTagPose], - live_qualities: Mapping[str, TagQuality], - *, - locked_base_role: str | None, - locked_base_quality: TagQuality | None, -) -> dict[str, TagQuality]: - """Attach PnP residuals without pretending a cached Tag is live. - - A hidden fixed palm Tag has no entry in ``live_qualities``. Its cached - pose can still be selected together with the live moving Tag, so quality - propagation must explicitly use the quality captured at baseline instead - of indexing a missing live detection. - """ - updated = dict(live_qualities) - for role, pose in selected.items(): - source = ( - locked_base_quality - if role == locked_base_role - else live_qualities.get(role) - ) - if source is None: - raise KeyError(f"selected role has no quality source: {role}") - updated[role] = TagQuality( - hamming=source.hamming, - decision_margin=source.decision_margin, - edge_pixels=source.edge_pixels, - reprojection_error_px=pose.reprojection_error_px, - ) - return updated - - -def _frames_for_joint( - frames: Sequence[Any], joint_name: str -) -> list[Any]: - """Filter an asynchronous multi-camera buffer to one joint observation.""" - return [ - frame - for frame in frames - if ( - not frame.joint_quaternions_xyzw - and not frame.joint_vectors_xyz_m - and not frame.image_vectors_xy_px - and not frame.parent_poses_common - and not frame.child_poses_common - ) - or ( - joint_name in frame.joint_quaternions_xyzw - and joint_name in frame.joint_vectors_xyz_m - and joint_name in frame.image_vectors_xy_px - and joint_name in frame.parent_poses_common - and joint_name in frame.child_poses_common - ) - ] - - -def _frames_cover_sweep_joints( - frames: Sequence[Any], spec: SweepSpec, minimum_per_joint: int -) -> bool: - minimum = max(1, int(minimum_per_joint)) - return all( - len(_frames_for_joint(frames, joint_name)) >= minimum - for joint_name in spec.joints - ) - - -def _records_have_formal_sweep_coverage( - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], - joint_names: Sequence[str], - *, - repetitions: int, - minimum_bins: int, - maximum_bin_gap: int, -) -> bool: - """Return whether every persisted direction has formal bin coverage.""" - expected_groups = { - (cycle, direction) - for cycle in range(int(repetitions)) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING) - } - if not joint_names or not expected_groups: - return False - for joint_name in joint_names: - grouped: dict[tuple[int, str], set[int]] = {} - for record in records_by_joint.get(joint_name, ()): - direction = str(record.get("direction", "")) - if direction not in { - DIRECTION_DECREASING, - DIRECTION_INCREASING, - }: - continue - command = int( - np.clip( - np.rint( - float( - record.get( - "command_u8", - record.get("feedback_u8", -1), - ) - ) - ), - 0, - 255, - ) - ) - grouped.setdefault( - (int(record.get("cycle", -1)), direction), set() - ).add(command) - if not expected_groups.issubset(grouped): - return False - for key in expected_groups: - commands = sorted(grouped[key]) - if ( - not commands - or commands[0] != 0 - or commands[-1] != 255 - or len(commands) < int(minimum_bins) - or max(np.diff(commands), default=0) > int(maximum_bin_gap) - ): - return False - return True - - -def _frames_cover_sweep_motion( - frames: Sequence[Any], - spec: SweepSpec, - *, - motor_index: int, - minimum_per_joint: int, - minimum_span_u8: float, -) -> bool: - for joint_name in spec.joints: - selected = _frames_for_joint(frames, joint_name) - if len(selected) < int(minimum_per_joint): - return False - values = [float(frame.state_u8[motor_index]) for frame in selected] - if max(values) - min(values) < float(minimum_span_u8): - return False - return True - - -def _joint_failure_reason( - reason: str, spec: SweepSpec, joint_name: str -) -> str: - return ( - str(reason) - if len(spec.joints) == 1 - else f"{reason}:{joint_name}" - ) - - -def _steady_checkpoint_commands( - profile: HandCalibrationProfile, item: "SweepItem" -) -> tuple[int, ...]: - """Return first-training-round steady commands after the start pose.""" - if ( - profile.layout_id != G20_RIGHT_19_LAYOUT - or item.precheck - or item.cycle != 0 - ): - return () - ordered = ( - STEADY_COMMAND_CHECKPOINTS - if item.direction == DIRECTION_DECREASING - else tuple(reversed(STEADY_COMMAND_CHECKPOINTS)) - ) - if int(ordered[0]) != item.start_u8 or int(ordered[-1]) != item.target_u8: - raise RuntimeError("steady command checkpoint direction is inconsistent") - return tuple(int(value) for value in ordered[1:]) - - -RESUMABLE_SAMPLE_KINDS = frozenset( - {"sample", "baseline_hold_sample", "steady_command_sample"} -) - -RECALIBRATION_SCOPES = frozenset({"full", "thumb", "fingers"}) - - -def recalibration_task_keys( - profile: HandCalibrationProfile, scope: str -) -> tuple[str, ...]: - """Return tasks that must be freshly acquired for a partial session.""" - selected = str(scope).strip().lower() - if selected not in RECALIBRATION_SCOPES: - raise ValueError( - "recalibration_scope must be one of: " - + ", ".join(sorted(RECALIBRATION_SCOPES)) - ) - if selected == "full": - return () - if ( - profile.side != "right" - or profile.layout_id != G20_RIGHT_19_LAYOUT - ): - raise ValueError( - "partial recalibration is supported only for the G20 right " - "19-Tag product" - ) - thumb_tasks = tuple( - spec.key - for spec in profile.sweep_specs - if any(str(name).startswith("thumb_") for name in spec.joints) - ) - if len(thumb_tasks) != 4: - raise ValueError( - "G20 right thumb recalibration must resolve exactly four tasks" - ) - if selected == "thumb": - return thumb_tasks - thumb_task_set = set(thumb_tasks) - return tuple( - spec.key - for spec in profile.sweep_specs - if spec.key not in thumb_task_set - ) - - -def recalibration_quality_joints( - profile: HandCalibrationProfile, scope: str -) -> tuple[str, ...]: - """Return joints whose quality is decided by freshly acquired data.""" - selected = str(scope).strip().lower() - if selected == "full": - return tuple(profile.measured_joints) - # Reuse the scope validation and product guard in the task selector. - recalibration_task_keys(profile, selected) - want_thumb = selected == "thumb" - return tuple( - name - for name in profile.measured_joints - if str(name).startswith("thumb_") == want_thumb - ) - - -def build_standalone_thumb_payload( - *, - profile: HandCalibrationProfile, - serial_number: str, - measured_fits: Mapping[str, JointCurveFit], - thumb_offsets_rad: Mapping[str, float], - validation_errors_rad: Sequence[float], - baseline_command_u8: Sequence[int], - source_urdf_sha256: str, - camera_extrinsics_sha256: str, - corrected_urdf_sha256: str, -) -> dict[str, Any]: - """Build a non-runtime artifact for a fully standalone thumb solve.""" - if profile.side != "right" or profile.layout_id != G20_RIGHT_19_LAYOUT: - raise ValueError("standalone thumb payload requires G20 right 19-Tag") - expected_fits = set(recalibration_quality_joints(profile, "thumb")) - if set(measured_fits) != expected_fits: - raise ValueError("standalone thumb fits have the wrong joint set") - expected_offsets = { - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - } - offsets = { - str(name): float(value) for name, value in thumb_offsets_rad.items() - } - if set(offsets) != expected_offsets or any( - not math.isfinite(value) for value in offsets.values() - ): - raise ValueError("standalone thumb offsets must contain four finite zeros") - if len(baseline_command_u8) != 20: - raise ValueError("standalone thumb baseline must contain 20 commands") - joints: dict[str, dict[str, Any]] = {} - for name in sorted(expected_fits): - fit = measured_fits[name] - curve = np.asarray(fit.angle_rad, dtype=float) - if curve.shape != (256,) or not np.all(np.isfinite(curve)): - raise ValueError(f"standalone thumb curve is invalid: {name}") - spec = profile.joint_specs[name] - item: dict[str, Any] = { - "motor_index": int(spec.motor_index), - "angle_rad": [round(float(value), 8) for value in curve], - } - if name in offsets: - item["zero_command_u8"] = int( - baseline_command_u8[spec.motor_index] - ) - item["zero_angles"] = { - "urdf_zero_offset_rad": round(offsets[name], 8) - } - else: - item["passive"] = True - joints[name] = item - absolute_errors = np.abs( - np.asarray(validation_errors_rad, dtype=float) - ) - if absolute_errors.size == 0 or not np.all(np.isfinite(absolute_errors)): - raise ValueError("standalone thumb validation errors are missing") - return { - "schema_version": 1, - "artifact_type": "g20_right_standalone_thumb_calibration", - "model": "G20", - "side": "right", - "serial_number": str(serial_number), - "angle_unit": "rad", - "command_range": [0, 255], - "baseline_command_u8": [int(value) for value in baseline_command_u8], - "non_thumb_zero_policy": "source_cad_unchanged", - "joints": joints, - "quality": { - "passed": True, - "validation_mae_rad": round(float(np.mean(absolute_errors)), 8), - "validation_p95_rad": round( - float(np.percentile(absolute_errors, 95.0)), 8 - ), - }, - "hashes": { - "source_urdf_sha256": str(source_urdf_sha256), - "camera_extrinsics_sha256": str(camera_extrinsics_sha256), - "corrected_urdf_sha256": str(corrected_urdf_sha256), - }, - } - - -def _partial_scope_frozen_zero_offsets( - profile: HandCalibrationProfile, - scope: str, - payload: Mapping[str, Any], -) -> dict[str, float]: - """Return certified non-target zeros that a partial run must preserve.""" - selected = str(scope).strip().lower() - if selected == "full": - return {} - if selected not in {"thumb", "fingers"}: - raise ValueError(f"unsupported partial recalibration scope: {scope}") - joints = payload.get("joints") - if not isinstance(joints, Mapping): - raise ValueError("base calibration payload is missing joints") - freeze_thumb = selected == "fingers" - result: dict[str, float] = {} - for name in get_zero_calibration_profile( - profile.side, profile.layout_id - ).direct_zero_joints: - is_thumb = str(name).startswith("thumb_") - if is_thumb != freeze_thumb: - continue - try: - value = float( - joints[name]["zero_angles"]["urdf_zero_offset_rad"] - ) - except (KeyError, TypeError, ValueError) as error: - raise ValueError( - f"base calibration is missing certified zero for {name}" - ) from error - if not math.isfinite(value): - raise ValueError(f"base calibration zero is invalid for {name}") - result[name] = value - expected = 4 if freeze_thumb else 12 - if len(result) != expected: - raise ValueError( - f"partial {selected} scope must freeze exactly {expected} zeros" - ) - return result - - -def _normalise_legacy_split_roll_resume_rows( - profile: HandCalibrationProfile, - rows: Sequence[Mapping[str, Any]], -) -> tuple[dict[str, Any], ...]: - """Map the former front/side roll tasks onto one multiview checkpoint. - - This lets a calibration that was already running during the task-layout - upgrade donate all fully completed data if it later needs to resume. The - two historical motions remain independent observations; they are never - presented as timestamp-synchronised frames or accepted unless both joint - record sets pass the normal completeness checks. - """ - if profile.layout_id != G20_RIGHT_19_LAYOUT: - return tuple(dict(row) for row in rows) - aliases = { - f"{finger}_roll_{view}": f"{finger}_roll_multiview" - for finger in ("pinky", "ring", "middle", "index") - for view in ("front", "side") - } - normalised: list[dict[str, Any]] = [] - for source in rows: - row = dict(source) - old_task = str(row.get("task_name", "")) - replacement = aliases.get(old_task) - if ( - replacement is not None - and row.get("kind") != "synchronised_frame" - ): - row["task_name"] = replacement - row["resume_source_task_name"] = old_task - normalised.append(row) - return tuple(normalised) - - -def _discard_automatically_retried_sweep_rows( - rows: Sequence[Mapping[str, Any]], -) -> tuple[dict[str, Any], ...]: - """Replay automatic-sweep retry tombstones before checkpoint selection. - - An automatic acquisition retry clears that cycle/direction from the - online in-memory stores. The raw JSONL is append-only, so the superseded - steady checkpoints remain physically present before the - ``automatic_sweep_retry`` event. Treat that event as a write-ahead - tombstone: records before it must never be merged with the replacement - acquisition written afterwards. - - Older sessions did not include ``task_name`` on retry events. Their - motor/cycle/direction and joint list still identify the invalidated - acquisition unambiguously. New sessions include both task and attempt - for a stronger durable identity. - """ - materialised = tuple(dict(row) for row in rows) - retry_events = [ - (index, row) - for index, row in enumerate(materialised) - if str(row.get("kind", "")) == "automatic_sweep_retry" - ] - if not retry_events: - return materialised - - durable_kinds = set(RESUMABLE_SAMPLE_KINDS) | { - "palm_axis_sample", - "synchronised_frame", - } - - def invalidated( - row_index: int, row: Mapping[str, Any] - ) -> bool: - if str(row.get("kind", "")) not in durable_kinds: - return False - row_task = str(row.get("task_name", "")) - row_cycle = int(row.get("cycle", -999)) - row_direction = str(row.get("direction", "")) - row_motor = int(row.get("motor_index", -1)) - row_joints = { - str(name) - for name in ( - row.get("joint"), - row.get("model_joint"), - *(row.get("joints", ()) or ()), - ) - if name - } - for retry_index, retry in retry_events: - if retry_index <= row_index: - continue - if int(retry.get("cycle", -998)) != row_cycle: - continue - if str(retry.get("direction", "")) != row_direction: - continue - retry_task = str(retry.get("task_name", "")) - if retry_task and row_task != retry_task: - continue - retry_motor = int(retry.get("motor_index", -1)) - if ( - row_motor >= 0 - and retry_motor >= 0 - and row_motor != retry_motor - ): - continue - retry_joints = { - str(name) for name in (retry.get("joints", ()) or ()) if name - } - if ( - row_joints - and retry_joints - and row_joints.isdisjoint(retry_joints) - ): - continue - return True - return False - - return tuple( - row - for index, row in enumerate(materialised) - if not invalidated(index, row) - ) - - -def _latest_resume_rows( - rows: Sequence[Mapping[str, Any]], - *, - kind: str, - minimum_sweep_bins: int = 0, -) -> dict[tuple[str, str, int, str], list[dict[str, Any]]]: - """Select the latest *complete* persisted attempt for each group. - - An interrupted retry must not hide the previous complete scan. This is - especially important for the nine steady checkpoints, which are written - before the continuous trajectory starts. - """ - selected = [ - dict(row) - for row in rows - if row.get("kind") == kind - and row.get("task_name") - and row.get("joint") - and row.get("direction") in {DIRECTION_DECREASING, DIRECTION_INCREASING} - ] - grouped: dict[ - tuple[str, str, int, str], dict[int, list[dict[str, Any]]] - ] = {} - for row in selected: - key = ( - str(row["task_name"]), - str(row["joint"]), - int(row.get("cycle", -1)), - str(row["direction"]), - ) - grouped.setdefault(key, {}).setdefault( - int(row.get("attempt", 1)), [] - ).append(row) - result: dict[tuple[str, str, int, str], list[dict[str, Any]]] = {} - for key, attempts in grouped.items(): - for _attempt, candidate in sorted( - attempts.items(), reverse=True - ): - if kind == "steady_command_sample": - requested = { - int(row.get("requested_command_u8", -1)) - for row in candidate - } - complete = requested == set(STEADY_COMMAND_CHECKPOINTS) - elif kind == "sample": - feedback_bins = { - int(round(float(row.get("feedback_u8", -1)))) - for row in candidate - } - complete = ( - 0 in feedback_bins - and 255 in feedback_bins - and len(feedback_bins) >= int(minimum_sweep_bins) - ) - else: - complete = bool(candidate) - if complete: - result[key] = candidate - break - return result - - -def _unresolved_fit_failure_tasks( - profile: HandCalibrationProfile, - rows: Sequence[Mapping[str, Any]], -) -> set[str]: - """Return tasks whose newest complete acquisition was still rejected. - - A newer model may deliberately retire a metric for a constrained joint. - Such historical failures are safe to replay because the final fit will - evaluate the imported raw samples against all current quality gates. - """ - sample_attempts: dict[str, int] = {} - for row in rows: - task_name = str(row.get("task_name", "")) - if row.get("kind") == "sample" and task_name: - sample_attempts[task_name] = max( - sample_attempts.get(task_name, 0), - int(row.get("attempt", 1)), - ) - zero_profile = get_zero_calibration_profile( - profile.side, profile.layout_id - ) - unresolved: set[str] = set() - for row in rows: - if row.get("kind") != "fit_failure": - continue - task_name = str(row.get("task_name", "")) - if not task_name: - matches = [ - spec - for spec in profile.sweep_specs - if spec.view == str(row.get("view", "")) - and spec.motor_index == int(row.get("motor_index", -1)) - and set(spec.joints) - == {str(name) for name in row.get("joints", [])} - ] - if len(matches) != 1: - continue - task_name = matches[0].key - failure_attempt = int(row.get("attempt", 1)) - if failure_attempt < sample_attempts.get(task_name, 0): - continue - failures = [ - item - for item in row.get("failures", []) - if isinstance(item, Mapping) - ] - def retired(item: Mapping[str, Any]) -> bool: - metric = str(item.get("metric", "")) - joint_name = str(item.get("joint", "")) - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and metric == "rotation_orthogonal_rms_deg" - and joint_name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS - ): - return True - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and metric in { - "hysteresis_deg", - "command_direction_gap_deg", - } - ): - # Product curves now gate direction dependence with settled - # feedback-domain samples and final-cycle holdout. Historical - # failures that mixed velocity lag or firmware tracking - # deadband with mechanical hysteresis are safe to revalidate. - return True - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and metric == "axis_pose_line_rms_mm" - and joint_name in profile.record_specs - and not profile.record_specs[ - joint_name - ].pose_axis_line_required - ): - # Current profile policy owns whether this monocular 3-D - # diagnostic is release-critical. Revalidate the complete - # raw task under that policy instead of making an old failure - # force another acquisition forever. - return True - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and joint_name - in RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS - and metric == "axis_pose_line_rms_mm" - ): - # Product v19 originally treated the correlated per-frame - # pose-line residual of an incorrectly scaled passive-DIP Tag as the - # uncertainty of its fitted axis centre. Current code - # revalidates those complete raw trajectories using radial, - # cross-cycle and holdout gates, so the old rejection itself - # must not force another eight-direction scan. - return True - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and metric == "cross_view_roll_curve" - and joint_name in (profile.axis_validation_sources or {}) - ): - # The side roll alias is a validation-only observation in the - # current product model. Its raw samples are still - # revalidated through side-line radial/cycle/holdout gates, - # but a historical front/side curve-shape warning must not - # make a complete task look unfinished on every restart. - return True - return metric in { - "rotation_circle_axis_difference_deg", - "axis_plane_rms_mm", - } and circle_direction_is_constrained( - joint_name, - zero_profile.constrained_circle_joints, - ) - - retired_by_current_model = bool(failures) and all( - retired(item) for item in failures - ) - if not retired_by_current_model: - unresolved.add(task_name) - return unresolved - - -def resumable_completed_task_prefix( - profile: HandCalibrationProfile, - repetitions: int, - baseline: Sequence[int], - rows: Sequence[Mapping[str, Any]], - *, - minimum_sweep_bins: int = 32, - allow_sparse: bool = False, -) -> tuple[tuple[str, ...], tuple[dict[str, Any], ...]]: - """Return quality-preserving complete tasks and their raw records. - - A partially written task is never reused. Every joint, all four cycles, - both directions, both endpoints, the two nine-point command curves and - any required directional baseline holds must be present before the whole - task becomes a durable restart checkpoint. The compatibility default - returns only a contiguous prefix; product resume can opt into sparse - recovery so a failed early task does not discard independent later tasks. - """ - rows = _discard_automatically_retried_sweep_rows( - _normalise_legacy_split_roll_resume_rows(profile, rows) - ) - samples = _latest_resume_rows( - rows, - kind="sample", - minimum_sweep_bins=minimum_sweep_bins, - ) - baselines = _latest_resume_rows(rows, kind="baseline_hold_sample") - checkpoints = _latest_resume_rows(rows, kind="steady_command_sample") - directions = (DIRECTION_DECREASING, DIRECTION_INCREASING) - expected_checkpoints = set(STEADY_COMMAND_CHECKPOINTS) - unresolved_fit_failures = _unresolved_fit_failure_tasks(profile, rows) - completed: list[str] = [] - selected_group_keys: set[tuple[str, tuple[str, str, int, str]]] = set() - - for spec in profile.sweep_specs: - if spec.key in unresolved_fit_failures: - if allow_sparse: - continue - break - complete = True - task_group_keys: set[ - tuple[str, tuple[str, str, int, str]] - ] = set() - for joint_name in spec.joints: - joint = profile.record_specs[joint_name] - needs_baseline_hold = int(baseline[joint.motor_index]) not in { - 0, - 255, - } - for cycle in range(int(repetitions)): - for direction in directions: - key = (spec.key, joint_name, cycle, direction) - sample_rows = samples.get(key, []) - feedback_bins = { - int(round(float(row.get("feedback_u8", -1)))) - for row in sample_rows - } - if ( - len(feedback_bins) < int(minimum_sweep_bins) - or 0 not in feedback_bins - or 255 not in feedback_bins - ): - complete = False - break - task_group_keys.add(("sample", key)) - if needs_baseline_hold: - if not baselines.get(key): - complete = False - break - task_group_keys.add(("baseline_hold_sample", key)) - if not complete: - break - if not complete: - break - for direction in directions: - key = (spec.key, joint_name, 0, direction) - requested = { - int(row.get("requested_command_u8", -1)) - for row in checkpoints.get(key, []) - } - if requested != expected_checkpoints: - complete = False - break - task_group_keys.add(("steady_command_sample", key)) - if not complete: - break - if not complete: - if allow_sparse: - continue - break - completed.append(spec.key) - selected_group_keys.update(task_group_keys) - - reusable: list[dict[str, Any]] = [] - grouped = { - "sample": samples, - "baseline_hold_sample": baselines, - "steady_command_sample": checkpoints, - } - for kind, key in selected_group_keys: - reusable.extend(grouped[kind][key]) - completed_set = set(completed) - latest_task_attempt = { - (key[0], key[2], key[3]): max( - int(row.get("attempt", 1)) for row in sample_rows - ) - for key, sample_rows in samples.items() - if key[0] in completed_set and sample_rows - } - reusable.extend( - dict(row) - for row in rows - if row.get("kind") == "synchronised_frame" - and str(row.get("task_name", "")) in completed_set - and int(row.get("attempt", 1)) - == latest_task_attempt.get( - ( - str(row.get("task_name", "")), - int(row.get("cycle", -1)), - str(row.get("direction", "")), - ), - -1, - ) - ) - reusable.extend( - dict(row) - for row in rows - if row.get("kind") == "palm_axis_sample" - and str(row.get("task_name", "")) in completed_set - and int(row.get("attempt", 1)) - == latest_task_attempt.get( - ( - str(row.get("task_name", "")), - int(row.get("cycle", -1)), - str(row.get("direction", "")), - ), - -1, - ) - ) - reusable.sort( - key=lambda row: ( - profile.sweep_specs.index( - next(spec for spec in profile.sweep_specs if spec.key == row["task_name"]) - ), - int(row.get("cycle", -1)), - 0 if row.get("direction") == DIRECTION_DECREASING else 1, - 0 if row.get("kind") == "synchronised_frame" else 1, - str(row.get("joint", "")), - float(row.get("feedback_u8", row.get("requested_command_u8", 0))), - ) - ) - return tuple(completed), tuple(reusable) - - -def _file_sha256(path: str | Path) -> str: - digest = hashlib.sha256() - with Path(path).open("rb") as stream: - for chunk in iter(lambda: stream.read(1024 * 1024), b""): - digest.update(chunk) - return digest.hexdigest() - - -def _publish_import_status(node: Any) -> None: - """Publish synchronous import progress when running inside a ROS node.""" - publisher = getattr(node, "_publish_status", None) - if callable(publisher): - publisher(time.monotonic()) - - -def _steady_records_in_feedback_domain( - records: Sequence[Mapping[str, Any]], -) -> list[dict[str, Any]]: - """Re-index settled poses by feedback for mechanical backlash checks. - - Runtime curves remain indexed by requested command. This companion - representation separates intrinsic hysteresis from the firmware's - direction-dependent command/feedback deadband. Endpoint records have - already passed their motor-specific reach gate, so they are snapped to - the exact endpoints required by the curve fitter. - """ - return fitting_sample_records( - records, - domain="feedback", - allow_legacy_command=True, - snap_requested_endpoints=True, - ) - - -def _classify_cross_view_roll_hysteresis( - front_values_deg: Sequence[float], - side_values_deg: Sequence[float], - *, - limit_deg: float, -) -> str: - """Classify a diagnostic without turning it into a formal acceptance.""" - front = np.asarray(front_values_deg, dtype=float) - side = np.asarray(side_values_deg, dtype=float) - if front.size == 0 or side.size == 0: - raise ValueError("cross-view diagnostic requires both camera results") - front_max = float(np.max(front)) - side_max = float(np.max(side)) - if front_max <= limit_deg and side_max <= limit_deg: - return "both_views_within_formal_hysteresis_limit" - if front_max > limit_deg and side_max > limit_deg: - return "both_views_confirm_direction_dependent_pose" - if front_max > limit_deg: - return "front_only_difference_check_roll_tag_bracket_or_front_pnp" - return "side_only_difference_check_side_tag_chain_or_side_pnp" - - -def _fit_failure_is_systematic( - failures: Sequence[Mapping[str, Any]], repetitions: int -) -> bool: - """Identify a repeatable model conflict that more motion cannot repair.""" - if not failures or int(repetitions) <= 1: - return False - if all(bool(item.get("systematic", False)) for item in failures): - return True - if any( - str(item.get("metric", "")) - != "rotation_circle_axis_difference_deg" - for item in failures - ): - return False - cycles = {int(item.get("cycle", -1)) for item in failures} - if cycles != set(range(1, int(repetitions) + 1)): - return False - actual = np.asarray([float(item["actual"]) for item in failures]) - limits = np.asarray([float(item["limit"]) for item in failures]) - if ( - not np.all(np.isfinite(actual)) - or not np.all(np.isfinite(limits)) - or np.any(limits <= 0.0) - ): - return False - # Every independent round is far outside the limit and agrees with the - # others. This is a fixed geometry/PnP model conflict, not random capture - # loss, so two full four-round retries would only reproduce it. - return bool( - np.all(actual >= 1.5 * limits) - and float(np.ptp(actual)) <= max(0.5, 0.2 * float(np.mean(actual))) - ) - - -def _cross_view_curve_failure( - primary_name: str, - validation_name: str, - primary: JointCurveFit, - validation: JointCurveFit, - *, - scope: str, - maximum_rms_difference_rad: float, - maximum_branch_gap_difference_rad: float, - allow_projection_scale: bool = False, - maximum_projection_scale_ratio: float = 1.5, - cycle_fits: Sequence[tuple[JointCurveFit, JointCurveFit]] = (), -) -> dict[str, Any] | None: - """Return a localized, retry-aware cross-view failure. - - A stable offset repeated in every cycle is an installation/model conflict; - reacquiring the same four motions cannot change it. A single bad cycle is - instead localized so only that side-view cycle is reacquired. - """ - try: - compare_cross_view_roll_curves( - primary, - validation, - maximum_rms_difference_rad=maximum_rms_difference_rad, - maximum_branch_gap_difference_rad=( - maximum_branch_gap_difference_rad - ), - allow_projection_scale=allow_projection_scale, - maximum_projection_scale_ratio=( - maximum_projection_scale_ratio - ), - ) - return None - except ValueError as error: - reason = str(error) - - metrics: dict[str, float] = {} - if reason != "cross_view_roll_curve_direction_disagrees": - metrics = compare_cross_view_roll_curves( - primary, - validation, - maximum_rms_difference_rad=float("inf"), - maximum_branch_gap_difference_rad=float("inf"), - allow_projection_scale=allow_projection_scale, - maximum_projection_scale_ratio=float("inf"), - ) - failure: dict[str, Any] = { - "joint": primary_name, - "metric": "cross_view_roll_curve", - "reason": reason, - "scope": scope, - "quality_source_joints": [validation_name], - } - metric_key: str | None = None - metric_limit: float | None = None - metric_in_degrees = True - if reason.startswith("cross_view_roll_curve_difference_too_large:"): - field_name = reason.split(":", 2)[1] - metric_key = f"{field_name}_rms_difference_rad" - metric_limit = float(maximum_rms_difference_rad) - elif reason.startswith( - "cross_view_roll_branch_gap_difference_too_large:" - ): - metric_key = "baseline_branch_gap_difference_rad" - metric_limit = float(maximum_branch_gap_difference_rad) - elif reason.startswith( - "cross_view_roll_projection_scale_ratio_too_large:" - ): - metric_key = "projection_scale_ratio" - metric_limit = float(maximum_projection_scale_ratio) - metric_in_degrees = False - - cycle_values_rad: list[float] = [] - cycle_direction_disagrees: list[bool] = [] - for primary_cycle, validation_cycle in cycle_fits: - try: - cycle_metrics = compare_cross_view_roll_curves( - primary_cycle, - validation_cycle, - maximum_rms_difference_rad=float("inf"), - maximum_branch_gap_difference_rad=float("inf"), - allow_projection_scale=allow_projection_scale, - maximum_projection_scale_ratio=float("inf"), - ) - except ValueError as cycle_error: - if str(cycle_error) != "cross_view_roll_curve_direction_disagrees": - raise - cycle_metrics = {} - if metric_key is not None: - value = cycle_metrics.get(metric_key) - if value is not None: - cycle_values_rad.append(float(value)) - primary_travel = float( - primary_cycle.angle_rad[0] - primary_cycle.angle_rad[255] - ) - validation_travel = float( - validation_cycle.angle_rad[0] - - validation_cycle.angle_rad[255] - ) - cycle_direction_disagrees.append( - primary_travel * validation_travel <= 0.0 - ) - - if metric_key is not None and metric_limit is not None: - actual_value = float(metrics[metric_key]) - rendered_actual = ( - math.degrees(actual_value) - if metric_in_degrees - else actual_value - ) - rendered_limit = ( - math.degrees(metric_limit) - if metric_in_degrees - else metric_limit - ) - failure.update( - { - "actual": round(rendered_actual, 6), - "limit": round(rendered_limit, 6), - "comparison": "maximum", - "unit": "deg" if metric_in_degrees else "ratio", - } - ) - if cycle_values_rad: - values = np.asarray(cycle_values_rad, dtype=float) - cycle_key = ( - "cycle_values_deg" - if metric_in_degrees - else "cycle_values_ratio" - ) - failure[cycle_key] = [ - round( - math.degrees(value) if metric_in_degrees else value, - 6, - ) - for value in values - ] - over = np.flatnonzero(values > metric_limit) - if ( - over.size == 1 - and np.all( - np.delete(values, over[0]) <= 0.9 * metric_limit - ) - ): - failure["cycle"] = int(over[0]) + 1 - # All cycles tell the same near-threshold story: this is a fixed - # view/mount/model bias, not random data that another scan heals. - if ( - np.all(values >= 0.85 * metric_limit) - and float(np.ptp(values)) <= 0.35 * metric_limit - ): - failure["systematic"] = True - failure["classification"] = ( - "stable_cross_view_installation_or_model_bias" - ) - elif reason == "cross_view_roll_curve_direction_disagrees" and ( - cycle_direction_disagrees - and all(cycle_direction_disagrees) - ): - failure["systematic"] = True - failure["classification"] = "stable_cross_view_direction_conflict" - return failure - - -def _fit_failure_repeats_branch_clusters( - previous: Sequence[Mapping[str, Any]], - current: Sequence[Mapping[str, Any]], -) -> bool: - """Detect the same cross-cycle PnP branch split on two full retries.""" - - def travel_failure( - failures: Sequence[Mapping[str, Any]], - ) -> Mapping[str, Any] | None: - return next( - ( - item - for item in failures - if str(item.get("metric")) == "cycle_travel_range_deg" - and isinstance(item.get("cycle_travel_deg"), Sequence) - ), - None, - ) - - left = travel_failure(previous) - right = travel_failure(current) - if left is None or right is None: - return False - left_values = np.asarray(left["cycle_travel_deg"], dtype=float) - right_values = np.asarray(right["cycle_travel_deg"], dtype=float) - if ( - left_values.shape != right_values.shape - or left_values.size < 3 - or not np.all(np.isfinite(left_values)) - or not np.all(np.isfinite(right_values)) - or np.max(np.abs(left_values - right_values)) > 1.0 - ): - return False - limit = float(right.get("limit", 0.0)) - if limit <= 0.0 or float(np.ptp(right_values)) <= limit: - return False - - previous_by_metric = { - (str(item.get("joint")), str(item.get("metric"))): item - for item in previous - if "actual" in item and "limit" in item - } - stable_model_failures = 0 - for item in current: - key = (str(item.get("joint")), str(item.get("metric"))) - old = previous_by_metric.get(key) - if old is None or key[1] == "cycle_travel_range_deg": - continue - actual = float(item.get("actual", float("nan"))) - old_actual = float(old.get("actual", float("nan"))) - item_limit = float(item.get("limit", 0.0)) - if ( - np.isfinite(actual) - and np.isfinite(old_actual) - and item_limit > 0.0 - and actual > item_limit - and old_actual > item_limit - and abs(actual - old_actual) <= max(0.25, 0.1 * item_limit) - ): - stable_model_failures += 1 - return stable_model_failures >= 1 - - -def _robust_pose_payload( - poses: Sequence[Mapping[str, Sequence[float]]], -) -> dict[str, list[float]]: - if not poses: - raise ValueError("at least one pose is required") - translation = np.median( - np.asarray([pose["translation_xyz_m"] for pose in poses], dtype=float), - axis=0, - ) - quaternion = robust_rotation_summary( - [pose["quaternion_xyzw"] for pose in poses] - )[0] - return { - "translation_xyz_m": [float(value) for value in translation], - "quaternion_xyzw": [float(value) for value in quaternion], - } - - -def _view_pairs( - view: str, reference_finger: str = "index" -) -> tuple[tuple[str, str], ...]: - if view == "front": - return ( - ("front_base", "thumb_cmc"), - ("thumb_cmc", "thumb_mcp"), - ("thumb_mcp", "thumb_ip"), - ("front_base", f"{reference_finger}_roll"), - ) - if view == "side": - return ( - ("side_base", "thumb_cmc"), - ("side_base", f"{reference_finger}_mcp"), - (f"{reference_finger}_mcp", f"{reference_finger}_pip"), - (f"{reference_finger}_pip", f"{reference_finger}_dip"), - ) - if view == "top": - return (("top_base", "thumb_yaw"),) - raise ValueError(f"unknown view {view}") - - -@dataclass(frozen=True) -class FrameObservation: - stamp_ns: int - received_at: float - view: str - state_u8: tuple[float, ...] - state_sync_error_ns: int - joint_vectors_xyz_m: Mapping[str, tuple[float, float, float]] - image_vectors_xy_px: Mapping[str, tuple[float, float]] - joint_quaternions_xyzw: Mapping[str, tuple[float, float, float, float]] - parent_poses_common: Mapping[str, Mapping[str, list[float]]] - child_poses_common: Mapping[str, Mapping[str, list[float]]] - joint_reprojection_error_px: Mapping[str, float] - - -@dataclass(frozen=True) -class SweepItem: - spec: SweepSpec - cycle: int - direction: str - precheck: bool = False - - @property - def start_u8(self) -> int: - return 255 if self.direction == DIRECTION_DECREASING else 0 - - @property - def target_u8(self) -> int: - return 0 if self.direction == DIRECTION_DECREASING else 255 - - -def _build_sweep_plan( - profile: HandCalibrationProfile, repetitions: int -) -> tuple[SweepItem, ...]: - """Build one deterministic qualification-and-measurement transaction. - - Each product task owns a low-speed outbound/return qualification followed - by fixed-order formal cycles. Keeping this construction in one place - prevents resume, progress and runtime transitions from silently inventing - different task lifecycles. - """ - plan: list[SweepItem] = [] - for spec in profile.sweep_specs: - if profile.precheck_sweeps: - plan.extend( - SweepItem(spec, -1, direction, precheck=True) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ) - plan.extend( - SweepItem(spec, cycle, direction) - for cycle in range(int(repetitions)) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ) - return tuple(plan) - - -def _sweep_plan_transition( - profile: HandCalibrationProfile, - completed: SweepItem, - following: SweepItem, -) -> str: - """Return the only allowed boundary between two planned sweep items.""" - if completed.spec != following.spec: - return "task_change" - # Both qualification directions and the qualification-to-formal handoff - # are one continuous transaction. The physical endpoint and task - # clearance pose are unchanged, so a baseline detour can only discard - # good observations and perturb the PnP branch. - if completed.precheck: - return "immediate_reverse" - if completed.direction == DIRECTION_INCREASING: - # Formal cycles deliberately re-establish the same mechanical approach - # between repetitions so hysteresis measurements remain comparable. - return "cycle_reset" - return "immediate_reverse" - - -@dataclass(frozen=True) -class ValidationItem: - spec: SweepSpec - command_u8: int - - -@dataclass(frozen=True) -class CombinationValidationItem: - name: str - label_zh: str - command_u8: tuple[int, ...] - - -def _combination_validation_items( - baseline: Sequence[int], -) -> tuple[CombinationValidationItem, ...]: - if len(baseline) != 20: - raise ValueError("combination baseline must contain 20 commands") - base = [int(value) for value in baseline] - - def item( - name: str, label: str, changes: Mapping[int, int] - ) -> CombinationValidationItem: - values = list(base) - for index, value in changes.items(): - values[int(index)] = int(value) - return CombinationValidationItem(name, label, tuple(values)) - - return ( - item("all_open", "全开", {}), - item("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}), - item("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}), - item("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}), - item("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}), - item("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}), - item( - "half_grip", - "四指半握", - {1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160}, - ), - item( - "light_pinch", - "轻捏", - {0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176}, - ), - ) - - -def _combination_joint_angles( - profile: HandCalibrationProfile, - measured_fits: Mapping[str, JointCurveFit], - command_u8: Sequence[int], - motor_directions: Sequence[str], -) -> dict[str, float]: - """Evaluate a combination pose on the branch used to reach it. - - Using ``angle_rad`` here averages the increasing and decreasing branches; - on a serial chain (especially the thumb) those half-hysteresis errors - accumulate and can reject an otherwise correct pose. - """ - if len(command_u8) != 20 or len(motor_directions) != 20: - raise ValueError("combination commands and directions require 20 motors") - angles: dict[str, float] = {} - for name, spec in profile.joint_specs.items(): - target = int(command_u8[spec.motor_index]) - fit = measured_fits[name] - direction = str(motor_directions[spec.motor_index]) - if direction == DIRECTION_DECREASING: - curve = fit.decreasing_rad - elif direction == DIRECTION_INCREASING: - curve = fit.increasing_rad - else: - raise ValueError(f"invalid combination direction: {direction}") - angles[name] = float(curve[target]) - return angles - - -def _combination_motor_directions( - items: Sequence[CombinationValidationItem], - pose_index: int, - baseline_u8: Sequence[int], -) -> tuple[str, ...]: - """Reconstruct the direction in which every motor reached a pose. - - Pose zero establishes the all-open Tag mounts. Each later pose starts - from that baseline. A motor moved by an earlier pose returns along the - opposite branch and stays on that branch until another pose moves it. - """ - if len(baseline_u8) != 20: - raise ValueError("combination baseline must contain 20 motors") - if pose_index < 0 or pose_index >= len(items): - raise ValueError("combination pose index is out of range") - baseline = tuple(int(value) for value in baseline_u8) - directions = [DIRECTION_DECREASING] * 20 - for previous in items[1:pose_index]: - for motor, target in enumerate(previous.command_u8): - if int(target) < baseline[motor]: - directions[motor] = DIRECTION_INCREASING - elif int(target) > baseline[motor]: - directions[motor] = DIRECTION_DECREASING - current = items[pose_index] - for motor, target in enumerate(current.command_u8): - if int(target) < baseline[motor]: - directions[motor] = DIRECTION_DECREASING - elif int(target) > baseline[motor]: - directions[motor] = DIRECTION_INCREASING - return tuple(directions) - - -def _model_link_in_observer_base( - observer_base_common: Sequence[Sequence[float]], - model_base_common: Sequence[Sequence[float]], - model_link_from_base: Sequence[Sequence[float]], -) -> np.ndarray: - """Express a URDF link in the fixed palm Tag's coordinate frame.""" - observer = np.asarray(observer_base_common, dtype=float) - model_base = np.asarray(model_base_common, dtype=float) - link = np.asarray(model_link_from_base, dtype=float) - if any(value.shape != (4, 4) for value in (observer, model_base, link)): - raise ValueError("combination transforms must be 4x4 matrices") - return np.linalg.inv(observer) @ model_base @ link - - -@dataclass -class ViewRuntime: - name: str - tag_size_m: float - preflight_frames: int - tracker: SquareTagPoseTracker - group_tracker: SquareTagGroupPoseTracker - view_tags: Mapping[str, int] = field(default_factory=dict) - tag_sizes_m_by_role: Mapping[str, float] = field(default_factory=dict) - preflight_roles: tuple[str, ...] = () - role_by_id: dict[int, str] = field(default_factory=dict) - group_trackers: dict[ - tuple[str, ...], SquareTagGroupPoseTracker - ] = field(default_factory=dict) - current_required_roles: tuple[str, ...] = () - camera_matrix: np.ndarray | None = None - camera_info_valid: bool = False - camera_frame: str = "" - image_width: int = 0 - image_height: int = 0 - intrinsics_sha256: str = "" - extrinsics_valid: bool = False - valid_flags: deque[bool] = field(default_factory=deque) - detection_times: deque[float] = field(default_factory=deque) - # Task-scoped capture validity: only frames judged against an active - # task's required roles are counted. The rolling valid_flags window is - # cleared whenever the required-role set changes, so right after a task - # finishes it silently switches to scoring idle frames against the full - # preflight role set — the wrong measurement for a per-task data gate. - task_valid_frames: int = 0 - task_total_frames: int = 0 - latest_tag_quality: dict[str, TagQuality] = field(default_factory=dict) - latest_pnp_rejections: dict[str, str] = field(default_factory=dict) - latest_group_pnp_reason: str = "" - latest_group_missing_candidate_roles: tuple[str, ...] = () - # Task-scoped counters preserve the reasons that occurred before the last - # camera frame. A final ``group_initializing:5/8`` frame must not hide a - # recurring per-Tag rejection or a completed-window geometry rejection. - pnp_rejection_counts: dict[str, int] = field(default_factory=dict) - group_pnp_rejection_counts: dict[str, int] = field(default_factory=dict) - pnp_initialization_progress: tuple[int, int] | None = None - latest_pnp_valid: bool = False - last_pnp_diagnostic_signature: tuple[Any, ...] | None = None - pnp_invalid_since: float | None = None - pnp_reset_count: int = 0 - last_message_at: float = 0.0 - last_valid_at: float = 0.0 - fixed_base_observations: deque[ - tuple[SquareTagPose, tuple[float, float], TagQuality] - ] = field(default_factory=deque) - fixed_base_corner_observations: deque[np.ndarray] = field( - default_factory=deque - ) - locked_base_pose: SquareTagPose | None = None - locked_base_center_xy_px: tuple[float, float] | None = None - locked_base_corners_xy: tuple[ - tuple[float, float], - tuple[float, float], - tuple[float, float], - tuple[float, float], - ] | None = None - locked_base_quality: TagQuality | None = None - locked_base_corner_drift_count: int = 0 - latest_locked_base_corner_drift_px: float = 0.0 - - def __post_init__(self) -> None: - self.role_by_id = { - tag_id: role for role, tag_id in self.view_tags.items() - } - self.valid_flags = deque(maxlen=self.preflight_frames) - self.detection_times = deque(maxlen=self.preflight_frames) - self.fixed_base_observations = deque(maxlen=self.preflight_frames) - self.fixed_base_corner_observations = deque( - maxlen=self.preflight_frames - ) - if not self.preflight_roles: - self.preflight_roles = tuple(self.view_tags) - if any(role not in self.view_tags for role in self.preflight_roles): - raise ValueError("preflight roles must be configured for the view") - if any(role not in self.view_tags for role in self.tag_sizes_m_by_role): - raise ValueError("Tag-size roles must be configured for the view") - if any( - not math.isfinite(float(size)) or float(size) <= 0.0 - for size in self.tag_sizes_m_by_role.values() - ): - raise ValueError("Tag sizes must be finite and positive") - self.current_required_roles = tuple(self.preflight_roles) - self.group_trackers[tuple(self.preflight_roles)] = self.group_tracker - - @property - def roles(self) -> tuple[str, ...]: - return tuple(self.view_tags) - - @property - def valid_rate(self) -> float: - if not self.valid_flags: - return 0.0 - return float(sum(self.valid_flags) / len(self.valid_flags)) - - @property - def detection_hz(self) -> float: - if len(self.detection_times) < 2: - return 0.0 - elapsed = self.detection_times[-1] - self.detection_times[0] - return 0.0 if elapsed <= 0.0 else (len(self.detection_times) - 1) / elapsed - - -class G20ThreeCameraCalibrationNode(Node): - """Own all hand commands while observing three fixed Hikrobot views.""" - - def __init__(self) -> None: - super().__init__("g20_calibration") - self._declare_parameters() - self._load_parameters() - - self.session_dir.mkdir(parents=True, exist_ok=True) - self.raw_path = self.session_dir / "raw_samples.jsonl" - self.final_path = self.session_dir / ( - f"g20_{self.hand_type}_{_safe_name(self.serial_number)}_calibration.json" - ) - - self.views = { - name: self._make_view_runtime(name) - for name in ("front", "side", "top") - } - self.latest_state_u8: tuple[float, ...] = () - self.last_state_at = 0.0 - self.state_history: deque[StateSample] = deque(maxlen=1200) - self.state_receive_times: deque[float] = deque(maxlen=300) - self.latest_hand_info: dict[str, Any] = {} - self.commanded_speed_profile: tuple[int, ...] = () - self.speed_commanded_at = 0.0 - - self.state = STATE_PREFLIGHT - self.reason = "waiting_for_devices_and_sdk" - self.sample_schema_version = 1 - self.base_import_progress: dict[str, Any] = {} - self.paused_reason = "" - self.started = False - # Startup has two deliberately separate gates. Camera/SDK health is - # enough to let us recover the hand from an interrupted pose; fixed - # palm Tags are checked only after that recovery reaches baseline. - self.startup_baseline_recovered = False - self.abort_original_reason = "" - self.baseline_after = "" - self.position_hold_since: float | None = None - self.motion_stage_started_at = 0.0 - self.return_waypoints: deque[tuple[int, ...]] = deque() - self.preparation_waypoints: deque[tuple[int, ...]] = deque() - self.preparation_command_u8: tuple[int, ...] = tuple( - self.baseline_command - ) - - self.sweep_items: list[SweepItem] = [] - self.sweep_index = 0 - self.active_sweep: SweepItem | None = None - # Owns required Tag roles and PnP continuity across the brief - # active_sweep=None interval used for same-task cycle resets. - self.pnp_task_spec: SweepSpec | None = None - self.sweep_frames: list[FrameObservation] = [] - # Roll joints use command 127 as their zero. A frame acquired while - # merely passing 127 contains velocity/latency error and is not a - # valid backlash observation. Keep a separate buffer populated only - # after the motor has reached 127 and the commanded mid-sweep hold has - # started. - self.sweep_baseline_frames: list[FrameObservation] = [] - self.sweep_baseline_pending = False - self.sweep_baseline_hold_since: float | None = None - self.sweep_checkpoint_commands: deque[int] = deque() - self.sweep_checkpoint_mode = False - self.sweep_checkpoint_target_u8: int | None = None - self.sweep_checkpoint_hold_since: float | None = None - self.sweep_checkpoint_frames: list[FrameObservation] = [] - # Keep synchronised endpoint observations acquired while the motor is - # held at the sweep start. If these frames are discarded and the - # target is commanded immediately, a fast motor can leave the endpoint - # before the next camera/state pair arrives. - self.sweep_start_frames: list[FrameObservation] = [] - # A completed direction already supplies fresh, synchronised frames at - # the physical endpoint where the immediately reversed direction - # starts. Stage those frames across the state transition instead of - # discarding them and waiting again at the unchanged pose. - self.carried_sweep_start_frames: list[FrameObservation] = [] - self.sweep_started_at = 0.0 - self.sweep_last_valid_at = 0.0 - self.sweep_last_valid_at_by_view: dict[str, float] = {} - self.sweep_endpoint_since: float | None = None - self.sweep_detection_total_frames = 0 - self.sweep_detection_valid_frames = 0 - self.sweep_detection_total_by_view: dict[str, int] = {} - self.sweep_detection_valid_by_view: dict[str, int] = {} - self.precheck_speed_metrics: dict[ - str, dict[str, dict[str, float | int]] - ] = {} - self.formal_speed_scales: dict[str, float] = {} - self.motion_progress_reference_error_u8 = float("inf") - self.motion_last_progress_at = 0.0 - self.motion_stall_details: dict[str, Any] = {} - self.retry_sweep_spec: SweepSpec | None = None - self.retry_resume_index: int | None = None - self.retry_sweep_items: list[SweepItem] = [] - self.retry_joint_names: set[str] = set() - # Some fitted quantities are observed by data collected during other - # physical tasks. In particular, right-hand thumb yaw zero comes from - # the two top-view side channels captured during CMC pitch and roll. - self.retry_source_failure_task_key: str | None = None - self.retry_source_task_keys: tuple[str, ...] = () - self.retry_cycle_override: set[int] = set() - self.retry_preserve_pnp_continuity = False - self.active_sweep_is_fit_retry = False - self.fit_failure: dict[str, Any] = {} - self.fit_failure_history_by_task: dict[ - str, list[list[dict[str, Any]]] - ] = {} - self.cross_view_roll_diagnostic_result: dict[str, Any] = {} - self.sweep_attempts: dict[str | int, int] = { - _sweep_storage_key(spec): 1 for spec in self.profile.sweep_specs - } - self.sweep_retry_counts: dict[tuple[str | int, int, str], int] = {} - self.motion_retry_counts: dict[str, int] = {} - self.validation_retry_counts: dict[tuple[str | int, int], int] = {} - self.records_by_joint: dict[str, list[dict[str, Any]]] = { - name: [] for name in self.profile.record_joints - } - self.baseline_records_by_joint: dict[str, list[dict[str, Any]]] = { - name: [] for name in self.profile.record_joints - } - self.command_records_by_joint: dict[str, list[dict[str, Any]]] = { - name: [] for name in self.profile.record_joints - } - self.palm_axis_records_by_source: dict[ - str, list[dict[str, Any]] - ] = { - observer.source_name: [] - for observer in self.profile.palm_axis_observers - } - - self.measured_fits: dict[str, JointCurveFit] = {} - self.axis_measurements: list[JointAxisMeasurement] = [] - self.palm_orientation_measurements: list[ - PalmOrientationMeasurement - ] = [] - self.palm_orientation_rejections: dict[str, str] = {} - self.zero_result: ZeroSolveResult | None = None - self.validated_endpoint_zero_offsets_rad: dict[str, float] = {} - self.corrected_urdf_path: Path | None = None - self.fit_quality_passed = False - - self.validation_items: list[ValidationItem] = [] - self.validation_index = 0 - self.active_validation: ValidationItem | None = None - self.active_validation_direction: str | None = None - self.validation_frames_buffer: list[FrameObservation] = [] - self.validation_errors_rad: list[float] = [] - self.validation_stage_started_at = 0.0 - self.combination_validation_items: list[CombinationValidationItem] = [] - self.combination_validation_index = 0 - self.active_combination_validation: CombinationValidationItem | None = None - self.combination_validation_frames_buffer: dict[ - str, list[FrameObservation] - ] = {view: [] for view in ("front", "side", "top")} - self.combination_validation_completed = False - self.combination_tag_mounts: dict[str, np.ndarray] = {} - self.combination_tag_observation_counts: dict[str, int] = {} - self.combination_tag_validation_counts: dict[str, int] = {} - self.combination_position_errors_m: list[float] = [] - self.combination_orientation_errors_rad: list[float] = [] - self.completed_payload: dict[str, Any] | None = None - - self.command_publisher = self.create_publisher( - JointState, self.command_topic, 1 - ) - self.setting_publisher = self.create_publisher( - String, self.setting_topic, 10 - ) - self.status_publisher = self.create_publisher(String, "~/status", 10) - self.status_text_publisher = self.create_publisher( - String, "~/status_text", 10 - ) - self.create_subscription( - JointState, self.state_topic, self._state_callback, 10 - ) - self.create_subscription( - String, self.info_topic, self._info_callback, 10 - ) - for name, runtime in self.views.items(): - del runtime - self.create_subscription( - CameraInfo, - self.camera_info_topics[name], - lambda message, view=name: self._camera_info_callback( - view, message - ), - qos_profile_sensor_data, - ) - self.create_subscription( - AprilTagDetectionArray, - self.detections_topics[name], - lambda message, view=name: self._detections_callback( - view, message - ), - qos_profile_sensor_data, - ) - - self.create_service(Trigger, "~/start", self._start_callback) - self.create_service(Trigger, "~/pause", self._pause_callback) - self.create_service(Trigger, "~/resume", self._resume_callback) - self.create_service(Trigger, "~/abort", self._abort_callback) - self.last_status_publish = 0.0 - self.timer = self.create_timer(0.05, self._timer_callback) - self.get_logger().info( - f"Three-camera calibration session: {self.session_dir}" - ) - - def _declare_parameters(self) -> None: - self.declare_parameter("model", "G20") - self.declare_parameter("hand_type", "left") - self.declare_parameter("tag_layout", "legacy_11") - self.declare_parameter("serial_number", "UNSET") - self.declare_parameter("session_dir", "calibration_output/session") - self.declare_parameter("resume_raw_samples_path", "") - self.declare_parameter("recalibration_scope", "full") - self.declare_parameter("camera_extrinsics_file", "") - self.declare_parameter("source_urdf_path", "") - self.declare_parameter("source_urdf_expected_sha256", "") - self.declare_parameter("corrected_urdf_output_dir", "") - self.declare_parameter("commands_enabled", True) - self.declare_parameter("command_topic", "/g20/cb_left_hand_control_cmd") - self.declare_parameter("state_topic", "/g20/cb_left_hand_state") - self.declare_parameter("info_topic", "/g20/cb_left_hand_info") - self.declare_parameter( - "setting_topic", "/g20/cb_hand_setting_cmd" - ) - for view in ("front", "side", "top"): - self.declare_parameter(f"{view}_camera_serial", "") - self.declare_parameter( - f"{view}_camera_info_topic", - f"/g20_calibration/{view}/camera/camera_info", - ) - self.declare_parameter( - f"{view}_detections_topic", - f"/g20_calibration/{view}/apriltag/detections", - ) - self.declare_parameter("tag_size_m", 0.016) - self.declare_parameter( - "tag_size_override_ids", Parameter.Type.INTEGER_ARRAY - ) - self.declare_parameter( - "tag_size_overrides_m", Parameter.Type.DOUBLE_ARRAY - ) - self.declare_parameter( - "baseline_command_u8", list(THREE_CAMERA_BASELINE_COMMAND) - ) - self.declare_parameter("normal_calibration_speed", 15) - self.declare_parameter("index_roll_calibration_speed", 5) - self.declare_parameter("index_flex_calibration_speed", 10) - self.declare_parameter("adaptive_formal_speed_enabled", True) - self.declare_parameter("adaptive_formal_speed_max_scale", 1.5) - self.declare_parameter("adaptive_formal_speed_minimum_bins", 64) - self.declare_parameter("adaptive_formal_speed_maximum_bin_gap", 8) - self.declare_parameter("speed_setting_settle_seconds", 0.25) - self.declare_parameter("parallel_pose_transitions", True) - self.declare_parameter("repetitions", 3) - self.declare_parameter("g20_right_19_repetitions", 4) - self.declare_parameter("preflight_frames", 60) - self.declare_parameter("minimum_detection_rate", 0.95) - self.declare_parameter("minimum_detection_hz", 15.0) - self.declare_parameter("minimum_feedback_hz", 25.0) - self.declare_parameter("maximum_hamming", 0) - self.declare_parameter("minimum_decision_margin", 30.0) - self.declare_parameter("minimum_edge_pixels", 30.0) - self.declare_parameter("pnp_maximum_reprojection_error_px", 1.5) - self.declare_parameter("pnp_reprojection_tie_px", 1.5) - self.declare_parameter("pnp_maximum_pose_jump_deg", 35.0) - self.declare_parameter("pnp_maximum_translation_jump_m", 0.04) - self.declare_parameter("pnp_maximum_tag_tilt_deg", 75.0) - self.declare_parameter("pnp_tracker_reset_seconds", 5.0) - self.declare_parameter("pnp_group_initialization_frames", 8) - self.declare_parameter("pnp_group_normal_alignment_scale_deg", 5.0) - self.declare_parameter("pnp_group_maximum_normal_alignment_deg", 15.0) - self.declare_parameter("fixed_base_maximum_corner_drift_px", 5.0) - self.declare_parameter("fixed_base_movement_confirmation_frames", 10) - self.declare_parameter("thumb_ip_pnp_coupling_multiplier", 1.03) - self.declare_parameter("thumb_ip_pnp_coupling_scale_deg", 3.0) - self.declare_parameter( - "thumb_ip_pnp_maximum_coupling_residual_deg", 7.5 - ) - self.declare_parameter("top_pnp_invalid_reset_seconds", 1.0) - self.declare_parameter("maximum_state_image_skew_ms", 50.0) - self.declare_parameter("axis_maximum_plane_rms_m", 0.003) - self.declare_parameter("passive_axis_maximum_plane_rms_m", 0.004) - self.declare_parameter("axis_maximum_radial_rms_m", 0.003) - self.declare_parameter("axis_maximum_pose_line_rms_m", 0.001) - self.declare_parameter("axis_maximum_rotation_circle_difference_deg", 1.0) - self.declare_parameter( - "active_maximum_rotation_orthogonal_rms_deg", 2.5 - ) - self.declare_parameter( - "passive_maximum_rotation_orthogonal_rms_deg", 7.5 - ) - self.declare_parameter("zero_maximum_axis_cycle_difference_deg", 0.75) - self.declare_parameter( - "thumb_yaw_maximum_zero_cycle_difference_deg", 0.5 - ) - self.declare_parameter( - "thumb_yaw_maximum_confidence_half_width_deg", 0.75 - ) - self.declare_parameter( - "thumb_yaw_cross_session_diagnostic_deg", 0.75 - ) - self.declare_parameter("zero_maximum_axis_cone_mismatch_deg", 5.0) - self.declare_parameter( - "zero_maximum_observability_condition_number", 1.0e10 - ) - self.declare_parameter("zero_maximum_offset_deg", 20.0) - self.declare_parameter("zero_finger_maximum_offset_deg", 3.0) - self.declare_parameter("mechanical_endpoint_maximum_offset_deg", 5.0) - self.declare_parameter("endpoint_tolerance_u8", 2.0) - self.declare_parameter( - "synchronised_endpoint_tolerance_margin_u8", 1.0 - ) - self.declare_parameter( - "steady_checkpoint_command_feedback_tolerance_u8", 8.0 - ) - self.declare_parameter( - "steady_checkpoint_maximum_feedback_range_u8", 2.0 - ) - self.declare_parameter("thumb_yaw_zero_endpoint_tolerance_u8", 4.0) - self.declare_parameter( - "right_thumb_yaw_255_endpoint_tolerance_u8", 5.0 - ) - self.declare_parameter("pinky_pip_zero_endpoint_tolerance_u8", 5.0) - self.declare_parameter("endpoint_hold_seconds", 0.5) - self.declare_parameter("baseline_hold_seconds", 0.5) - self.declare_parameter("minimum_baseline_hold_frames", 10) - self.declare_parameter("task_precheck_hold_seconds", 2.0) - self.declare_parameter("position_timeout_seconds", 30.0) - self.declare_parameter("sweep_timeout_seconds", 90.0) - self.declare_parameter("motor_stall_timeout_seconds", 2.0) - self.declare_parameter("motor_stall_startup_grace_seconds", 1.0) - self.declare_parameter("motor_stall_minimum_progress_u8", 1.0) - self.declare_parameter("invalid_timeout_seconds", 3.0) - self.declare_parameter("minimum_sweep_frames", 40) - self.declare_parameter("minimum_state_span_u8", 240.0) - self.declare_parameter("minimum_sweep_bins", 32) - self.declare_parameter("maximum_bin_gap", 16) - self.declare_parameter("automatic_sweep_retry_limit", 1) - self.declare_parameter("automatic_fit_retry_limit", 0) - self.declare_parameter("automatic_motion_retry_limit", 0) - self.declare_parameter("cross_view_roll_diagnostic_finger", "") - self.declare_parameter("provisional_warning_ratio", 1.25) - self.declare_parameter("retry_minimum_speed", 3) - self.declare_parameter("retry_speed_scales", [1.0]) - self.declare_parameter( - "retry_endpoint_hold_seconds", [0.5] - ) - self.declare_parameter("trajectory_maximum_plane_rms_m", 0.004) - self.declare_parameter("trajectory_maximum_radial_rms_m", 0.004) - self.declare_parameter("trajectory_minimum_radius_m", 0.003) - self.declare_parameter("trajectory_minimum_arc_deg", 15.0) - self.declare_parameter("image_trajectory_maximum_radial_rms_px", 2.0) - self.declare_parameter("image_trajectory_maximum_radial_p95_px", 3.5) - self.declare_parameter("image_trajectory_minimum_radius_px", 20.0) - self.declare_parameter( - "trajectory_maximum_cycle_travel_difference_deg", 3.0 - ) - self.declare_parameter( - "passive_maximum_cycle_travel_difference_deg", 10.0 - ) - self.declare_parameter("maximum_monotonic_correction_deg", 2.0) - self.declare_parameter("maximum_hysteresis_deg", 5.0) - self.declare_parameter("baseline_maximum_hysteresis_deg", 0.5) - self.declare_parameter( - "directional_zero_maximum_branch_gap_deg", 1.5 - ) - self.declare_parameter( - "directional_zero_maximum_branch_gap_range_deg", 0.3 - ) - self.declare_parameter( - "cross_view_roll_maximum_branch_gap_difference_deg", 0.3 - ) - self.declare_parameter( - "cross_view_roll_maximum_shape_rms_deg", 1.25 - ) - self.declare_parameter( - "cross_view_roll_maximum_projection_scale_ratio", 1.5 - ) - self.declare_parameter( - "cross_view_roll_maximum_axis_difference_deg", 15.0 - ) - self.declare_parameter("passive_maximum_monotonic_correction_deg", 3.0) - self.declare_parameter("passive_maximum_hysteresis_deg", 7.5) - self.declare_parameter("command_maximum_direction_gap_deg", 2.0) - self.declare_parameter("validation_enabled", False) - self.declare_parameter("combination_validation_enabled", True) - self.declare_parameter("combination_validation_frames", 10) - self.declare_parameter("combination_maximum_position_p95_m", 0.003) - self.declare_parameter("combination_maximum_orientation_p95_deg", 2.0) - self.declare_parameter("validation_command_count", 3) - self.declare_parameter("validation_frames", 10) - self.declare_parameter("validation_seed", 20260804) - self.declare_parameter("validation_timeout_seconds", 20.0) - self.declare_parameter("maximum_validation_mae_deg", 1.0) - self.declare_parameter("maximum_validation_p95_deg", 2.0) - self.declare_parameter("maximum_validation_error_deg", 1.5) - self.declare_parameter("zero_maximum_confidence_half_width_deg", 0.5) - - def _load_parameters(self) -> None: - def value(name: str) -> Any: - return self.get_parameter(name).value - - def optional_array(name: str) -> Sequence[Any]: - # An empty YAML sequence has no element type. With a typed ROS 2 - # declaration it can therefore remain NOT_SET and ``.value`` - # raises instead of returning an empty list. These overrides are - # optional, so preserve the default-size semantics in that case. - try: - result = value(name) - except ParameterUninitializedException: - return () - return () if result is None else result - - self.model = str(value("model")).strip().upper() - self.hand_type = str(value("hand_type")).lower() - self.tag_layout = str(value("tag_layout")).lower() - product_contract = get_product_calibration_contract( - self.model, self.hand_type, self.tag_layout - ) - self.profile = product_contract.profile - self.zero_profile = product_contract.zero_profile - self.calibration_profile = product_contract.typed_profile - self.calibration_engine = CalibrationEngine(self.calibration_profile) - self.sdk_adapter = ProfileSdkAdapter(self.calibration_profile.command) - self.serial_number = str(value("serial_number")) - if self.serial_number == "UNSET": - raise ValueError("serial_number is required") - self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() - resume_value = str(value("resume_raw_samples_path")).strip() - self.resume_raw_samples_path = ( - None - if not resume_value - else Path(resume_value).expanduser().resolve() - ) - self.recalibration_scope = str( - value("recalibration_scope") - ).strip().lower() - self.recalibration_task_keys = recalibration_task_keys( - self.profile, self.recalibration_scope - ) - self.standalone_thumb_calibration = bool( - self.recalibration_scope == "thumb" - and self.resume_raw_samples_path is None - ) - if ( - self.recalibration_scope == "fingers" - and self.resume_raw_samples_path is None - ): - raise ValueError( - "finger recalibration requires resume_raw_samples_path from " - "a passed thumb or complete session" - ) - self.partial_scope_fixed_zero_offsets_rad: dict[str, float] = {} - if ( - self.recalibration_scope != "full" - and self.resume_raw_samples_path is not None - ): - assert self.resume_raw_samples_path is not None - base_payload_path = self.resume_raw_samples_path.parent / ( - f"g20_right_{_safe_name(self.serial_number)}_calibration.json" - ) - try: - base_payload = json.loads( - base_payload_path.read_text(encoding="utf-8") - ) - except (OSError, json.JSONDecodeError) as error: - raise ValueError( - "partial recalibration base calibration JSON is invalid" - ) from error - if not isinstance(base_payload, Mapping): - raise ValueError( - "partial recalibration base calibration JSON must be an object" - ) - self.partial_scope_fixed_zero_offsets_rad = ( - _partial_scope_frozen_zero_offsets( - self.profile, - self.recalibration_scope, - base_payload, - ) - ) - self.resumed_task_keys: tuple[str, ...] = () - self.resume_source_session = "" - self.resume_checkpoint_pending = False - self.resume_position_policy = "not_requested" - self.resume_position_changed_views: tuple[str, ...] = () - self.resume_position_unverifiable_views: tuple[str, ...] = () - self.resume_position_drift_by_view_px: dict[str, float] = {} - self.camera_extrinsics_file = Path( - str(value("camera_extrinsics_file")) - ).expanduser().resolve() - self.source_urdf_path = Path( - str(value("source_urdf_path")) - ).expanduser().resolve() - self.source_urdf_expected_sha256 = str( - value("source_urdf_expected_sha256") - ).strip().lower() - output_value = str(value("corrected_urdf_output_dir")) - self.corrected_urdf_output_dir = ( - Path(output_value).expanduser().resolve() - if output_value - else self.source_urdf_path.parent - ) - self.camera_serials = { - view: str(value(f"{view}_camera_serial")) - for view in ("front", "side", "top") - } - self.extrinsics: ThreeCameraExtrinsics | None = None - self.extrinsics_error = "" - try: - self.extrinsics = load_three_camera_extrinsics( - self.camera_extrinsics_file - ) - except Exception as error: - self.extrinsics_error = str(error) - self.commands_enabled = bool(value("commands_enabled")) - self.command_topic = str(value("command_topic")) - self.state_topic = str(value("state_topic")) - self.info_topic = str(value("info_topic")) - if self.hand_type == "right": - if self.command_topic == "/g20/cb_left_hand_control_cmd": - self.command_topic = "/g20/cb_right_hand_control_cmd" - if self.state_topic == "/g20/cb_left_hand_state": - self.state_topic = "/g20/cb_right_hand_state" - if self.info_topic == "/g20/cb_left_hand_info": - self.info_topic = "/g20/cb_right_hand_info" - self.setting_topic = str(value("setting_topic")) - self.camera_info_topics = { - view: str(value(f"{view}_camera_info_topic")) - for view in ("front", "side", "top") - } - self.detections_topics = { - view: str(value(f"{view}_detections_topic")) - for view in ("front", "side", "top") - } - self.tag_size_m = float(value("tag_size_m")) - override_ids = tuple( - int(item) for item in optional_array("tag_size_override_ids") - ) - override_sizes = tuple( - float(item) for item in optional_array("tag_size_overrides_m") - ) - if len(override_ids) != len(override_sizes): - raise ValueError( - "tag_size_override_ids and tag_size_overrides_m must have " - "the same length" - ) - if len(set(override_ids)) != len(override_ids): - raise ValueError("tag_size_override_ids must be unique") - configured_tag_ids = { - int(tag_id) - for tags in self.profile.view_tags.values() - for tag_id in tags.values() - } - if ( - self.profile.layout_id == G20_RIGHT_19_LAYOUT - and any(tag_id not in configured_tag_ids for tag_id in override_ids) - ): - raise ValueError("Tag-size override contains an unconfigured Tag ID") - if ( - not math.isfinite(self.tag_size_m) - or self.tag_size_m <= 0.0 - or any( - not math.isfinite(size) or size <= 0.0 - for size in override_sizes - ) - ): - raise ValueError("Tag sizes must be finite and positive") - self.tag_sizes_m_by_id = { - tag_id: self.tag_size_m for tag_id in configured_tag_ids - } - self.tag_sizes_m_by_id.update( - (tag_id, size) - for tag_id, size in zip(override_ids, override_sizes) - if tag_id in configured_tag_ids - ) - self.baseline_command = tuple( - int(item) for item in value("baseline_command_u8") - ) - self.return_command_u8 = tuple(self.baseline_command) - self.normal_calibration_speed = int(value("normal_calibration_speed")) - self.index_roll_calibration_speed = int( - value("index_roll_calibration_speed") - ) - self.index_flex_calibration_speed = int( - value("index_flex_calibration_speed") - ) - self.adaptive_formal_speed_enabled = bool( - value("adaptive_formal_speed_enabled") - ) - self.adaptive_formal_speed_max_scale = float( - value("adaptive_formal_speed_max_scale") - ) - self.adaptive_formal_speed_minimum_bins = int( - value("adaptive_formal_speed_minimum_bins") - ) - self.adaptive_formal_speed_maximum_bin_gap = int( - value("adaptive_formal_speed_maximum_bin_gap") - ) - self.speed_setting_settle_seconds = float( - value("speed_setting_settle_seconds") - ) - self.repetitions = int(value("repetitions")) - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - self.repetitions = int(value("g20_right_19_repetitions")) - self.preflight_frames = int(value("preflight_frames")) - self.minimum_detection_rate = float(value("minimum_detection_rate")) - self.minimum_detection_hz = float(value("minimum_detection_hz")) - self.minimum_feedback_hz = float(value("minimum_feedback_hz")) - self.maximum_hamming = int(value("maximum_hamming")) - self.minimum_decision_margin = float(value("minimum_decision_margin")) - self.minimum_edge_pixels = float(value("minimum_edge_pixels")) - self.pnp_maximum_reprojection_error_px = float( - value("pnp_maximum_reprojection_error_px") - ) - self.pnp_reprojection_tie_px = float(value("pnp_reprojection_tie_px")) - self.pnp_maximum_pose_jump_rad = math.radians( - float(value("pnp_maximum_pose_jump_deg")) - ) - self.pnp_maximum_translation_jump_m = float( - value("pnp_maximum_translation_jump_m") - ) - self.pnp_maximum_tag_tilt_rad = math.radians( - float(value("pnp_maximum_tag_tilt_deg")) - ) - self.pnp_tracker_reset_seconds = float(value("pnp_tracker_reset_seconds")) - self.pnp_group_initialization_frames = int( - value("pnp_group_initialization_frames") - ) - self.pnp_group_normal_alignment_scale_rad = math.radians( - float(value("pnp_group_normal_alignment_scale_deg")) - ) - self.pnp_group_maximum_normal_alignment_rad = math.radians( - float(value("pnp_group_maximum_normal_alignment_deg")) - ) - self.fixed_base_maximum_corner_drift_px = float( - value("fixed_base_maximum_corner_drift_px") - ) - self.fixed_base_movement_confirmation_frames = int( - value("fixed_base_movement_confirmation_frames") - ) - self.thumb_ip_pnp_coupling_multiplier = float( - value("thumb_ip_pnp_coupling_multiplier") - ) - self.thumb_ip_pnp_coupling_scale_rad = math.radians( - float(value("thumb_ip_pnp_coupling_scale_deg")) - ) - self.thumb_ip_pnp_maximum_coupling_residual_rad = math.radians( - float(value("thumb_ip_pnp_maximum_coupling_residual_deg")) - ) - self.top_pnp_invalid_reset_seconds = float( - value("top_pnp_invalid_reset_seconds") - ) - self.maximum_state_image_skew_ns = int( - float(value("maximum_state_image_skew_ms")) * 1_000_000.0 - ) - self.axis_maximum_plane_rms_m = float( - value("axis_maximum_plane_rms_m") - ) - self.passive_axis_maximum_plane_rms_m = float( - value("passive_axis_maximum_plane_rms_m") - ) - self.axis_maximum_radial_rms_m = float( - value("axis_maximum_radial_rms_m") - ) - self.axis_maximum_pose_line_rms_m = float( - value("axis_maximum_pose_line_rms_m") - ) - self.axis_maximum_rotation_circle_difference_rad = math.radians( - float(value("axis_maximum_rotation_circle_difference_deg")) - ) - self.active_maximum_rotation_orthogonal_rms_rad = math.radians( - float(value("active_maximum_rotation_orthogonal_rms_deg")) - ) - self.passive_maximum_rotation_orthogonal_rms_rad = math.radians( - float(value("passive_maximum_rotation_orthogonal_rms_deg")) - ) - self.zero_maximum_axis_cycle_difference_rad = math.radians( - float(value("zero_maximum_axis_cycle_difference_deg")) - ) - self.thumb_yaw_maximum_zero_cycle_difference_rad = math.radians( - float(value("thumb_yaw_maximum_zero_cycle_difference_deg")) - ) - self.thumb_yaw_maximum_confidence_half_width_rad = math.radians( - float(value("thumb_yaw_maximum_confidence_half_width_deg")) - ) - self.thumb_yaw_cross_session_diagnostic_rad = math.radians( - float(value("thumb_yaw_cross_session_diagnostic_deg")) - ) - self.zero_maximum_axis_cone_mismatch_rad = math.radians( - float(value("zero_maximum_axis_cone_mismatch_deg")) - ) - self.zero_maximum_observability_condition_number = float( - value("zero_maximum_observability_condition_number") - ) - self.zero_maximum_offset_rad = math.radians( - float(value("zero_maximum_offset_deg")) - ) - self.zero_finger_maximum_offset_rad = math.radians( - float(value("zero_finger_maximum_offset_deg")) - ) - self.mechanical_endpoint_maximum_offset_rad = math.radians( - float(value("mechanical_endpoint_maximum_offset_deg")) - ) - self.zero_joint_maximum_offsets_rad = {} - self.endpoint_tolerance_u8 = float(value("endpoint_tolerance_u8")) - self.synchronised_endpoint_tolerance_margin_u8 = float( - value("synchronised_endpoint_tolerance_margin_u8") - ) - self.steady_checkpoint_command_feedback_tolerance_u8 = float( - value("steady_checkpoint_command_feedback_tolerance_u8") - ) - self.steady_checkpoint_maximum_feedback_range_u8 = float( - value("steady_checkpoint_maximum_feedback_range_u8") - ) - self.thumb_yaw_zero_endpoint_tolerance_u8 = float( - value("thumb_yaw_zero_endpoint_tolerance_u8") - ) - self.right_thumb_yaw_255_endpoint_tolerance_u8 = float( - value("right_thumb_yaw_255_endpoint_tolerance_u8") - ) - self.pinky_pip_zero_endpoint_tolerance_u8 = float( - value("pinky_pip_zero_endpoint_tolerance_u8") - ) - self.endpoint_hold_seconds = float(value("endpoint_hold_seconds")) - self.baseline_hold_seconds = float(value("baseline_hold_seconds")) - self.minimum_baseline_hold_frames = int( - value("minimum_baseline_hold_frames") - ) - self.task_precheck_hold_seconds = float( - value("task_precheck_hold_seconds") - ) - self.position_timeout_seconds = float(value("position_timeout_seconds")) - self.parallel_pose_transitions = bool( - value("parallel_pose_transitions") - ) - self.sweep_timeout_seconds = float(value("sweep_timeout_seconds")) - self.motor_stall_timeout_seconds = float( - value("motor_stall_timeout_seconds") - ) - self.motor_stall_startup_grace_seconds = float( - value("motor_stall_startup_grace_seconds") - ) - self.motor_stall_minimum_progress_u8 = float( - value("motor_stall_minimum_progress_u8") - ) - self.invalid_timeout_seconds = float(value("invalid_timeout_seconds")) - self.minimum_sweep_frames = int(value("minimum_sweep_frames")) - self.minimum_state_span_u8 = float(value("minimum_state_span_u8")) - self.minimum_sweep_bins = int(value("minimum_sweep_bins")) - self.maximum_bin_gap = int(value("maximum_bin_gap")) - self.automatic_sweep_retry_limit = int( - value("automatic_sweep_retry_limit") - ) - self.automatic_fit_retry_limit = int( - value("automatic_fit_retry_limit") - ) - self.cross_view_roll_diagnostic_finger = str( - value("cross_view_roll_diagnostic_finger") - ).strip().lower() - self.automatic_motion_retry_limit = int( - value("automatic_motion_retry_limit") - ) - self.provisional_warning_ratio = float( - value("provisional_warning_ratio") - ) - self.retry_minimum_speed = int(value("retry_minimum_speed")) - self.retry_speed_scales = tuple( - float(item) for item in value("retry_speed_scales") - ) - self.retry_endpoint_hold_seconds = tuple( - float(item) for item in value("retry_endpoint_hold_seconds") - ) - self.trajectory_maximum_plane_rms_m = float( - value("trajectory_maximum_plane_rms_m") - ) - self.trajectory_maximum_radial_rms_m = float( - value("trajectory_maximum_radial_rms_m") - ) - self.trajectory_minimum_radius_m = float( - value("trajectory_minimum_radius_m") - ) - self.trajectory_minimum_arc_rad = math.radians( - float(value("trajectory_minimum_arc_deg")) - ) - self.image_trajectory_maximum_radial_rms_px = float( - value("image_trajectory_maximum_radial_rms_px") - ) - self.image_trajectory_maximum_radial_p95_px = float( - value("image_trajectory_maximum_radial_p95_px") - ) - self.image_trajectory_minimum_radius_px = float( - value("image_trajectory_minimum_radius_px") - ) - self.trajectory_maximum_cycle_travel_difference_rad = math.radians( - float(value("trajectory_maximum_cycle_travel_difference_deg")) - ) - self.passive_maximum_cycle_travel_difference_rad = math.radians( - float(value("passive_maximum_cycle_travel_difference_deg")) - ) - self.maximum_monotonic_correction_rad = math.radians( - float(value("maximum_monotonic_correction_deg")) - ) - self.maximum_hysteresis_rad = math.radians( - float(value("maximum_hysteresis_deg")) - ) - self.baseline_maximum_hysteresis_rad = math.radians( - float(value("baseline_maximum_hysteresis_deg")) - ) - self.directional_zero_maximum_branch_gap_rad = math.radians( - float(value("directional_zero_maximum_branch_gap_deg")) - ) - self.directional_zero_maximum_branch_gap_range_rad = math.radians( - float(value("directional_zero_maximum_branch_gap_range_deg")) - ) - self.cross_view_roll_maximum_branch_gap_difference_rad = math.radians( - float(value("cross_view_roll_maximum_branch_gap_difference_deg")) - ) - self.cross_view_roll_maximum_shape_rms_rad = math.radians( - float(value("cross_view_roll_maximum_shape_rms_deg")) - ) - self.cross_view_roll_maximum_projection_scale_ratio = float( - value("cross_view_roll_maximum_projection_scale_ratio") - ) - self.cross_view_roll_maximum_axis_difference_rad = math.radians( - float(value("cross_view_roll_maximum_axis_difference_deg")) - ) - self.passive_maximum_monotonic_correction_rad = math.radians( - float(value("passive_maximum_monotonic_correction_deg")) - ) - self.passive_maximum_hysteresis_rad = math.radians( - float(value("passive_maximum_hysteresis_deg")) - ) - self.command_maximum_direction_gap_rad = math.radians( - float(value("command_maximum_direction_gap_deg")) - ) - self.validation_enabled = bool(value("validation_enabled")) - self.combination_validation_enabled = bool( - value("combination_validation_enabled") - ) - if self.standalone_thumb_calibration: - # A standalone thumb artifact has no four-finger curves or - # Cartesian combination model to validate. - self.combination_validation_enabled = False - self.combination_validation_frames = int( - value("combination_validation_frames") - ) - self.combination_maximum_position_p95_m = float( - value("combination_maximum_position_p95_m") - ) - self.combination_maximum_orientation_p95_rad = math.radians( - float(value("combination_maximum_orientation_p95_deg")) - ) - self.validation_command_count = int(value("validation_command_count")) - self.validation_frames = int(value("validation_frames")) - self.validation_seed = int(value("validation_seed")) - self.validation_timeout_seconds = float( - value("validation_timeout_seconds") - ) - self.maximum_validation_mae_rad = math.radians( - float(value("maximum_validation_mae_deg")) - ) - self.maximum_validation_p95_rad = math.radians( - float(value("maximum_validation_p95_deg")) - ) - self.maximum_validation_error_rad = math.radians( - float(value("maximum_validation_error_deg")) - ) - self.zero_maximum_confidence_half_width_rad = math.radians( - float(value("zero_maximum_confidence_half_width_deg")) - ) - if len(self.baseline_command) != self.profile.command_count: - raise ValueError( - "baseline_command_u8 length does not match the registered " - f"product command schema ({self.profile.command_count})" - ) - if any(value < 0 or value > 255 for value in self.baseline_command): - raise ValueError("baseline_command_u8 values must be in [0, 255]") - if self.baseline_command != tuple(self.profile.baseline_command): - raise ValueError( - "baseline_command_u8 differs from the registered product " - "calibration baseline" - ) - if any(not serial for serial in self.camera_serials.values()): - raise ValueError("front/side/top camera serial parameters are required") - if not self.source_urdf_path.is_file(): - raise ValueError( - f"source_urdf_path does not exist: {self.source_urdf_path}" - ) - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - if re.fullmatch( - r"[0-9a-f]{64}", self.source_urdf_expected_sha256 - ) is None: - raise ValueError( - "g20_right_19 requires source_urdf_expected_sha256 from " - "the CAD/hardware owner; unconfirmed source CAD cannot " - "produce a formal corrected URDF" - ) - actual_source_hash = _file_sha256(self.source_urdf_path) - if actual_source_hash != self.source_urdf_expected_sha256: - raise ValueError( - "source_urdf_path SHA-256 does not match the confirmed " - "G20 CAD hash" - ) - source_model = UrdfKinematicModel(self.source_urdf_path) - thumb_ip_model = source_model.joints.get("thumb_ip") - if ( - thumb_ip_model is None - or thumb_ip_model.mimic_joint != "thumb_mcp" - or abs(thumb_ip_model.mimic_offset) > 1.0e-9 - ): - raise ValueError( - "source URDF thumb_ip mimic structure is invalid" - ) - source_name = self.source_urdf_path.name.lower() - opposite = "right" if self.hand_type == "left" else "left" - if f"g20_{opposite}" in source_name: - raise ValueError( - "source_urdf_path hand side does not match hand_type" - ) - if ( - "zero_calibrated" in self.source_urdf_path.stem.lower() - or re.search( - r"calibrated_20\d{6}", - self.source_urdf_path.stem.lower(), - ) - ): - raise ValueError( - "source_urdf_path must be the original CAD URDF" - ) - if not 0 <= self.normal_calibration_speed <= 255: - raise ValueError("normal_calibration_speed must be in [0, 255]") - if not 0 <= self.index_roll_calibration_speed <= 255: - raise ValueError("index_roll_calibration_speed must be in [0, 255]") - if not 0 <= self.index_flex_calibration_speed <= 255: - raise ValueError("index_flex_calibration_speed must be in [0, 255]") - if not 1.0 <= self.adaptive_formal_speed_max_scale <= 2.0: - raise ValueError( - "adaptive_formal_speed_max_scale must be in [1, 2]" - ) - if ( - self.adaptive_formal_speed_minimum_bins - < self.minimum_sweep_bins - ): - raise ValueError( - "adaptive_formal_speed_minimum_bins cannot be below the " - "formal minimum_sweep_bins" - ) - if not ( - 1 - <= self.adaptive_formal_speed_maximum_bin_gap - <= self.maximum_bin_gap - ): - raise ValueError( - "adaptive_formal_speed_maximum_bin_gap must be in " - "[1, maximum_bin_gap]" - ) - if self.speed_setting_settle_seconds < 0.0: - raise ValueError("speed_setting_settle_seconds must be non-negative") - if not 0.0 <= self.endpoint_tolerance_u8 <= 10.0: - raise ValueError("endpoint_tolerance_u8 must be in [0, 10]") - if not 0.0 <= self.synchronised_endpoint_tolerance_margin_u8 <= 4.0: - raise ValueError( - "synchronised_endpoint_tolerance_margin_u8 must be in [0, 4]" - ) - if not ( - self.endpoint_tolerance_u8 - <= self.steady_checkpoint_command_feedback_tolerance_u8 - <= 16.0 - ): - raise ValueError( - "steady_checkpoint_command_feedback_tolerance_u8 must be " - "between endpoint_tolerance_u8 and 16" - ) - if not 0.0 < self.steady_checkpoint_maximum_feedback_range_u8 <= 4.0: - raise ValueError( - "steady_checkpoint_maximum_feedback_range_u8 must be in (0, 4]" - ) - if not ( - self.endpoint_tolerance_u8 - <= self.thumb_yaw_zero_endpoint_tolerance_u8 - <= 10.0 - ): - raise ValueError( - "thumb_yaw_zero_endpoint_tolerance_u8 must be between the " - "default endpoint tolerance and 10" - ) - if not ( - self.endpoint_tolerance_u8 - <= self.right_thumb_yaw_255_endpoint_tolerance_u8 - <= 10.0 - ): - raise ValueError( - "right_thumb_yaw_255_endpoint_tolerance_u8 must be between " - "the default endpoint tolerance and 10" - ) - if self.top_pnp_invalid_reset_seconds <= 0.0: - raise ValueError("top_pnp_invalid_reset_seconds must be positive") - if not 3 <= self.pnp_group_initialization_frames <= 30: - raise ValueError( - "pnp_group_initialization_frames must be in [3, 30]" - ) - if not 0.5 <= self.fixed_base_maximum_corner_drift_px <= 10.0: - raise ValueError( - "fixed_base_maximum_corner_drift_px must be in [0.5, 10]" - ) - if not 1 <= self.fixed_base_movement_confirmation_frames <= 30: - raise ValueError( - "fixed_base_movement_confirmation_frames must be in [1, 30]" - ) - if not ( - self.endpoint_tolerance_u8 - <= self.pinky_pip_zero_endpoint_tolerance_u8 - <= 8.0 - ): - raise ValueError( - "pinky_pip_zero_endpoint_tolerance_u8 must be between " - "endpoint_tolerance_u8 and 8" - ) - if self.motor_stall_timeout_seconds <= 0.0: - raise ValueError("motor_stall_timeout_seconds must be positive") - if not 0.0 <= self.motor_stall_startup_grace_seconds <= 5.0: - raise ValueError("motor_stall_startup_grace_seconds must be in [0, 5]") - if not 0.0 < self.motor_stall_minimum_progress_u8 <= 10.0: - raise ValueError( - "motor_stall_minimum_progress_u8 must be in (0, 10]" - ) - if not all( - value > 0.0 - for value in ( - self.image_trajectory_maximum_radial_rms_px, - self.image_trajectory_maximum_radial_p95_px, - self.image_trajectory_minimum_radius_px, - self.pnp_group_normal_alignment_scale_rad, - self.pnp_group_maximum_normal_alignment_rad, - self.thumb_ip_pnp_coupling_multiplier, - self.thumb_ip_pnp_coupling_scale_rad, - self.thumb_ip_pnp_maximum_coupling_residual_rad, - self.axis_maximum_plane_rms_m, - self.passive_axis_maximum_plane_rms_m, - self.axis_maximum_pose_line_rms_m, - self.zero_maximum_axis_cycle_difference_rad, - self.thumb_yaw_maximum_zero_cycle_difference_rad, - self.thumb_yaw_maximum_confidence_half_width_rad, - self.thumb_yaw_cross_session_diagnostic_rad, - self.zero_maximum_axis_cone_mismatch_rad, - self.zero_maximum_offset_rad, - self.zero_finger_maximum_offset_rad, - self.mechanical_endpoint_maximum_offset_rad, - self.maximum_validation_error_rad, - self.zero_maximum_confidence_half_width_rad, - self.task_precheck_hold_seconds, - self.active_maximum_rotation_orthogonal_rms_rad, - self.passive_maximum_rotation_orthogonal_rms_rad, - self.trajectory_maximum_cycle_travel_difference_rad, - self.passive_maximum_cycle_travel_difference_rad, - self.baseline_maximum_hysteresis_rad, - self.directional_zero_maximum_branch_gap_rad, - self.directional_zero_maximum_branch_gap_range_rad, - self.cross_view_roll_maximum_branch_gap_difference_rad, - self.cross_view_roll_maximum_shape_rms_rad, - self.passive_maximum_monotonic_correction_rad, - self.passive_maximum_hysteresis_rad, - self.command_maximum_direction_gap_rad, - ) - ): - raise ValueError("trajectory quality thresholds must be positive") - if ( - self.thumb_yaw_maximum_zero_cycle_difference_rad - > self.zero_maximum_axis_cycle_difference_rad - ): - raise ValueError( - "thumb_yaw_maximum_zero_cycle_difference_deg must not " - "exceed zero_maximum_axis_cycle_difference_deg" - ) - if ( - self.thumb_yaw_maximum_confidence_half_width_rad - > self.zero_maximum_confidence_half_width_rad - ): - raise ValueError( - "thumb_yaw_maximum_confidence_half_width_deg must not " - "exceed zero_maximum_confidence_half_width_deg" - ) - if not 1.0 < self.cross_view_roll_maximum_projection_scale_ratio <= 2.0: - raise ValueError( - "cross-view roll projection-scale ratio must be in (1, 2]" - ) - if self.zero_finger_maximum_offset_rad > self.zero_maximum_offset_rad: - raise ValueError( - "zero_finger_maximum_offset_deg cannot exceed " - "zero_maximum_offset_deg" - ) - if ( - self.profile.layout_id == G20_RIGHT_19_LAYOUT - and self.repetitions < 4 - ): - raise ValueError( - "g20_right_19 requires at least 4 repetitions: at least 3 " - "training cycles and one isolated holdout" - ) - if ( - self.profile.layout_id != G20_RIGHT_19_LAYOUT - and self.repetitions != 3 - ): - raise ValueError( - "legacy three-camera calibration requires exactly 3 repetitions" - ) - if self.preflight_frames < 10 or self.minimum_sweep_bins < 3: - raise ValueError("preflight_frames or minimum_sweep_bins is too small") - if self.minimum_feedback_hz <= 0.0: - raise ValueError("minimum_feedback_hz must be positive") - if self.minimum_baseline_hold_frames < 3: - raise ValueError("minimum_baseline_hold_frames must be at least 3") - if not 0 <= self.automatic_sweep_retry_limit <= 5: - raise ValueError("automatic_sweep_retry_limit must be in [0, 5]") - if not 0 <= self.automatic_fit_retry_limit <= 2: - raise ValueError("automatic_fit_retry_limit must be in [0, 2]") - if not 0 <= self.automatic_motion_retry_limit <= 5: - raise ValueError("automatic_motion_retry_limit must be in [0, 5]") - if self.cross_view_roll_diagnostic_finger not in { - "", "pinky", "ring", "middle", "index" - }: - raise ValueError( - "cross_view_roll_diagnostic_finger must be empty or one of " - "pinky/ring/middle/index" - ) - if ( - self.cross_view_roll_diagnostic_finger - and self.profile.layout_id != G20_RIGHT_19_LAYOUT - ): - raise ValueError( - "cross-view roll diagnostic requires g20_right_19" - ) - if not 1.0 <= self.provisional_warning_ratio <= 2.0: - raise ValueError("provisional_warning_ratio must be in [1, 2]") - if not 1 <= self.retry_minimum_speed <= 255: - raise ValueError("retry_minimum_speed must be in [1, 255]") - if ( - len(self.retry_speed_scales) != self.automatic_sweep_retry_limit - or any(not 0.0 < item <= 1.0 for item in self.retry_speed_scales) - ): - raise ValueError("retry_speed_scales must match the sweep retry limit") - if ( - len(self.retry_endpoint_hold_seconds) - != self.automatic_sweep_retry_limit - or any(item < self.endpoint_hold_seconds for item in self.retry_endpoint_hold_seconds) - ): - raise ValueError( - "retry endpoint holds must match retries and not shorten the hold" - ) - if not 1 <= self.validation_command_count <= 10: - raise ValueError("validation_command_count must be in [1, 10]") - if self.validation_frames < 3: - raise ValueError("validation_frames must be at least 3") - if self.combination_validation_frames < 3: - raise ValueError("combination_validation_frames must be at least 3") - if ( - self.combination_maximum_position_p95_m <= 0.0 - or self.combination_maximum_orientation_p95_rad <= 0.0 - ): - raise ValueError("combination pose validation thresholds must be positive") - - def _make_group_pose_tracker( - self, name: str, roles: Sequence[str] - ) -> SquareTagGroupPoseTracker: - role_tuple = tuple(str(role) for role in roles) - role_set = set(role_tuple) - adjacent_pairs = tuple( - pair - for pair in dict.fromkeys( - (spec.parent_role, spec.child_role) - for spec in self.profile.record_specs.values() - if spec.measured - and spec.view == name - and spec.parent_role is not None - and spec.child_role is not None - ) - if pair[0] in role_set and pair[1] in role_set - ) - # Do not impose an absolute face-normal relationship between the - # fixed palm Tag and an articulated finger Tag. That angle depends on - # the physical Tag bracket and the endpoint joint pose; it is not a - # product invariant. The old "nearly parallel" prior repeatedly - # cleared otherwise stable 8-frame initialisation windows for ID4/ID5 - # on ring MCP flexion. Multi-frame relative rigidity, reprojection - # error and the task reference retained across cycles provide the - # valid branch evidence without assuming a particular mounting plane. - normal_alignment_pairs: tuple[tuple[str, str], ...] = () - thumb_mcp_ip_roles = {"thumb_cmc", "thumb_mcp", "thumb_ip"} - thumb_mcp_ip_coupling = () - if name == "front" and thumb_mcp_ip_roles.issubset(role_set): - thumb_mcp_ip_coupling = ( - ( - "thumb_cmc", - "thumb_mcp", - "thumb_mcp", - "thumb_ip", - self.thumb_ip_pnp_coupling_multiplier, - ), - ) - return SquareTagGroupPoseTracker( - roles=role_tuple, - adjacent_pairs=adjacent_pairs, - maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, - maximum_translation_jump_m=self.pnp_maximum_translation_jump_m, - relative_rotation_scale_rad=math.radians(5.0), - relative_translation_scale_m=0.01, - reprojection_scale_px=0.1, - reprojection_weight=0.05, - reset_after_seconds=self.pnp_tracker_reset_seconds, - # Five-tag front preflight would make the exhaustive multi-frame - # search unnecessarily expensive. Four-tag side groups remain - # small enough and need the robust initializer so the fixed base - # Tag can anchor the three reference-finger IPPE branches. - initialization_frames=( - 1 - if len(role_tuple) > 4 - else self.pnp_group_initialization_frames - ), - normal_alignment_pairs=normal_alignment_pairs, - normal_alignment_scale_rad=( - self.pnp_group_normal_alignment_scale_rad - ), - maximum_normal_alignment_rad=( - self.pnp_group_maximum_normal_alignment_rad - if normal_alignment_pairs - else None - ), - # The source-URDF thumb_ip mimic ratio is used only to disambiguate - # the two planar-IPPE candidates while motor 15 drives MCP and IP - # together. If no candidate matches this weak prior, selection - # falls back to visual continuity instead of dropping the frame. - # Accepted Tag poses still fit both visual curves independently. - coupled_rotation_pairs=thumb_mcp_ip_coupling, - coupled_rotation_scale_rad=self.thumb_ip_pnp_coupling_scale_rad, - maximum_coupled_rotation_residual_rad=( - self.thumb_ip_pnp_maximum_coupling_residual_rad - if thumb_mcp_ip_coupling - else None - ), - ) - - def _make_view_runtime(self, name: str) -> ViewRuntime: - tracker = SquareTagPoseTracker( - maximum_reprojection_error_px=self.pnp_maximum_reprojection_error_px, - reprojection_tie_px=self.pnp_reprojection_tie_px, - maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, - maximum_translation_jump_m=self.pnp_maximum_translation_jump_m, - maximum_tag_tilt_rad=self.pnp_maximum_tag_tilt_rad, - reset_after_seconds=self.pnp_tracker_reset_seconds, - ) - preflight_roles = tuple(self.profile.preflight_view_roles[name]) - if getattr(self, "standalone_thumb_calibration", False): - base_role = _fixed_base_role(name) - preflight_roles = tuple( - role - for role in self.profile.view_tags[name] - if role == base_role or role.startswith("thumb_") - ) - group = self._make_group_pose_tracker( - name, - preflight_roles, - ) - return ViewRuntime( - name=name, - tag_size_m=self.tag_size_m, - preflight_frames=self.preflight_frames, - tracker=tracker, - group_tracker=group, - view_tags=self.profile.view_tags[name], - tag_sizes_m_by_role={ - role: self.tag_sizes_m_by_id[int(tag_id)] - for role, tag_id in self.profile.view_tags[name].items() - }, - preflight_roles=preflight_roles, - ) - - def _required_roles_for_view(self, view: str) -> tuple[str, ...]: - """Return only the roles required by the active task in this view.""" - runtime = self.views[view] - if getattr(self, "active_combination_validation", None) is not None: - # Combination validation consumes every reliable visible target, - # but only the fixed palm reference is a blocking requirement. - # Four adjacent fingers cannot expose all of their Tags to one - # side camera at the same instant. - return tuple(runtime.preflight_roles) - active_spec: SweepSpec | None = None - if ( - self.active_sweep is not None - and view in _sweep_views(_node_profile(self), self.active_sweep.spec) - ): - active_spec = self.active_sweep.spec - elif ( - self.active_validation is not None - and view in _sweep_views( - self.profile, self.active_validation.spec - ) - ): - active_spec = self.active_validation.spec - elif ( - self.retry_sweep_spec is not None - and view in _sweep_views(_node_profile(self), self.retry_sweep_spec) - ): - active_spec = self.retry_sweep_spec - elif ( - getattr(self, "pnp_task_spec", None) is not None - and view - in _sweep_views( - _node_profile(self), self.pnp_task_spec - ) - ): - active_spec = self.pnp_task_spec - if active_spec is None: - return tuple(runtime.preflight_roles) - required: set[str] = set() - for joint_name in _sweep_joints_for_view( - self.profile, active_spec, view - ): - joint = self.profile.record_specs[joint_name] - if joint.parent_role is not None: - required.add(joint.parent_role) - if joint.child_role is not None: - required.add(joint.child_role) - # Every task keeps its view's fixed palm Tag. Besides suppressing an - # unanchored IPPE mirror branch, this makes the thumb MCP/IP task obey - # its explicit front 0/1/2/3 visibility contract. - base_role = { - "front": "front_base", - "side": "side_base", - "top": "top_base", - }[view] - required.add(base_role) - return tuple(role for role in runtime.roles if role in required) - - def _locked_base_role_for_active_capture(self, view: str) -> str | None: - runtime = self.views[view] - if ( - getattr(runtime, "locked_base_pose", None) is None - or getattr(runtime, "locked_base_center_xy_px", None) is None - or getattr(runtime, "locked_base_quality", None) is None - ): - return None - if getattr(self, "active_combination_validation", None) is not None: - return _fixed_base_role(view) - spec: SweepSpec | None = None - if self.active_sweep is not None: - spec = self.active_sweep.spec - elif self.active_validation is not None: - spec = self.active_validation.spec - elif self.retry_sweep_spec is not None: - spec = self.retry_sweep_spec - elif getattr(self, "pnp_task_spec", None) is not None: - spec = self.pnp_task_spec - if spec is None or not _sweep_uses_locked_base_reference( - _node_profile(self), spec, view - ): - return None - return _fixed_base_role(view) - - def _live_required_roles_for_view( - self, view: str, required_roles: Sequence[str] - ) -> tuple[str, ...]: - locked = self._locked_base_role_for_active_capture(view) - # Front Tag 0 may be intentionally hidden by finger clearance. Top - # Tag 8 must remain visible so its raw corners can prove that the - # session reference has not moved, even though its PnP pose is frozen. - if str(view) == "top": - return tuple(required_roles) - return tuple(role for role in required_roles if role != locked) - - def _lock_fixed_base_references(self) -> bool: - """Freeze robust palm references captured at the confirmed baseline.""" - minimum = min(30, int(self.preflight_frames)) - pending = False - for view, runtime in self.views.items(): - if runtime.locked_base_pose is not None: - continue - observations = list(runtime.fixed_base_observations) - if len(observations) < minimum: - pending = True - continue - poses = [item[0] for item in observations] - centres = np.asarray([item[1] for item in observations], dtype=float) - qualities = [item[2] for item in observations] - translation = np.median( - np.asarray( - [pose.translation_xyz_m for pose in poses], dtype=float - ), - axis=0, - ) - quaternion = robust_rotation_summary( - [pose.quaternion_xyzw for pose in poses] - )[0] - reprojection = float( - np.percentile( - [pose.reprojection_error_px for pose in poses], 95.0 - ) - ) - runtime.locked_base_pose = SquareTagPose( - tuple(float(value) for value in quaternion), - tuple(float(value) for value in translation), - reprojection, - ) - runtime.locked_base_center_xy_px = tuple( - float(value) for value in np.median(centres, axis=0) - ) - corner_observations = list( - getattr(runtime, "fixed_base_corner_observations", ()) - ) - if len(corner_observations) >= minimum: - locked_corners = np.median( - np.asarray(corner_observations, dtype=float), axis=0 - ) - runtime.locked_base_corners_xy = tuple( - tuple(float(value) for value in point) - for point in locked_corners - ) - else: - runtime.locked_base_corners_xy = None - runtime.locked_base_corner_drift_count = 0 - runtime.latest_locked_base_corner_drift_px = 0.0 - runtime.locked_base_quality = TagQuality( - hamming=max(item.hamming for item in qualities), - decision_margin=min(item.decision_margin for item in qualities), - edge_pixels=min(item.edge_pixels for item in qualities), - reprojection_error_px=reprojection, - ) - translation_rms_m = float( - np.sqrt( - np.mean( - np.sum( - ( - np.asarray( - [pose.translation_xyz_m for pose in poses], - dtype=float, - ) - - translation - ) - ** 2, - axis=1, - ) - ) - ) - ) - rotation_p95_deg = math.degrees( - float( - np.percentile( - [ - ( - Rotation.from_quat(quaternion).inv() - * Rotation.from_quat(pose.quaternion_xyzw) - ).magnitude() - for pose in poses - ], - 95.0, - ) - ) - ) - append_jsonl( - self.raw_path, - { - "kind": "fixed_base_reference_locked", - "view": view, - "role": _fixed_base_role(view), - "tag_id": runtime.view_tags[_fixed_base_role(view)], - "sample_count": len(observations), - "translation_rms_m": round(translation_rms_m, 9), - "rotation_p95_deg": round(rotation_p95_deg, 6), - "reprojection_p95_px": round(reprojection, 6), - "corner_reference_xy": ( - None - if runtime.locked_base_corners_xy is None - else [ - list(point) - for point in runtime.locked_base_corners_xy - ] - ), - }, - ) - return not pending and all( - runtime.locked_base_pose is not None - for runtime in self.views.values() - ) - - def _group_tracker_for_roles( - self, runtime: ViewRuntime, roles: Sequence[str] - ) -> SquareTagGroupPoseTracker: - key = tuple(str(role) for role in roles) - group = runtime.group_trackers.get(key) - if group is None: - group = self._make_group_pose_tracker(runtime.name, key) - runtime.group_trackers[key] = group - return group - - @staticmethod - def _reset_view_trackers( - runtime: ViewRuntime, *, preserve_task_reference: bool = False - ) -> None: - runtime.tracker.reset() - for group in runtime.group_trackers.values(): - group.reset( - preserve_task_reference=preserve_task_reference - ) - - @staticmethod - def _reset_view_pnp_diagnostics(runtime: ViewRuntime) -> None: - """Clear PnP evidence only when a new capture scope begins.""" - runtime.latest_pnp_rejections.clear() - runtime.latest_group_pnp_reason = "" - runtime.latest_group_missing_candidate_roles = () - runtime.pnp_rejection_counts.clear() - runtime.group_pnp_rejection_counts.clear() - runtime.pnp_initialization_progress = None - runtime.latest_pnp_valid = False - runtime.last_pnp_diagnostic_signature = None - - def _record_group_pnp_candidate_event( - self, - *, - runtime: ViewRuntime, - stamp_ns: int, - required_roles: Sequence[str], - pose_roles: Sequence[str], - corners_by_role: Mapping[str, np.ndarray], - qualities: Mapping[str, TagQuality], - matched_tracking: tuple[tuple[float, ...], int] | None, - selected: Mapping[str, SquareTagPose] | None, - pnp_rejections: Mapping[str, str], - group_pnp_reason: str, - ) -> None: - """Persist bounded, replayable evidence for group candidate decisions. - - Valid FrameObservation records intentionally contain only accepted - poses. Without a separate event, a deterministic PnP dropout leaves - no corners, per-candidate tilt, reprojection error or missing role in - raw_samples.jsonl, making the next run guess at the cause. Record one - event per eight-count motor bucket and decision signature. This is - dense enough to locate a repeatable geometry boundary without writing - every 30 Hz rejected camera message. - """ - item = self.active_sweep - if ( - item is None - or self.state not in {STATE_PREPARE_SWEEP, STATE_SWEEP} - or runtime.name not in _sweep_views(_node_profile(self), item.spec) - ): - return - candidate_diagnostics = { - role: dict( - runtime.tracker.last_candidate_diagnostics_by_role.get( - role, {} - ) - ) - for role in pose_roles - if runtime.tracker.last_candidate_diagnostics_by_role.get(role) - } - oblique_group_roles = tuple( - role - for role, diagnostics in candidate_diagnostics.items() - if int(diagnostics.get("reprojection_candidate_count", 0)) > 0 - and int( - diagnostics.get("independent_tilt_candidate_count", 0) - ) - == 0 - ) - if ( - not group_pnp_reason - and not pnp_rejections - and not oblique_group_roles - ): - return - if selected is not None and not oblique_group_roles: - return - state_u8 = ( - matched_tracking[0] - if matched_tracking is not None - else self.latest_state_u8 - ) - motor_value = ( - None - if len(state_u8) != 20 - else float(state_u8[item.spec.motor_index]) - ) - motor_bucket = ( - -1 - if motor_value is None or not math.isfinite(motor_value) - else int(np.clip(math.floor(motor_value / 8.0), 0, 31)) - ) - missing_roles = tuple(runtime.latest_group_missing_candidate_roles) - signature = ( - item.spec.key, - item.cycle, - item.direction, - motor_bucket, - selected is not None, - str(group_pnp_reason), - tuple( - sorted( - (str(key), str(value)) - for key, value in pnp_rejections.items() - ) - ), - missing_roles, - oblique_group_roles, - ) - if runtime.last_pnp_diagnostic_signature == signature: - return - runtime.last_pnp_diagnostic_signature = signature - append_jsonl( - self.raw_path, - { - "kind": "group_pnp_candidate_event", - "outcome": ( - "selected_with_oblique_group_constraint" - if selected is not None - else "group_pose_rejected" - ), - "view": runtime.name, - "task_name": item.spec.key, - "motor_index": item.spec.motor_index, - "cycle": item.cycle, - "direction": item.direction, - "feedback_u8": motor_value, - "feedback_bucket_u8": ( - None if motor_bucket < 0 else motor_bucket * 8 - ), - "image_stamp_ns": int(stamp_ns), - "required_roles": list(required_roles), - "required_tag_ids": { - role: runtime.view_tags[role] - for role in required_roles - }, - "group_pnp_reason": str(group_pnp_reason), - "group_missing_candidate_roles": list(missing_roles), - "pnp_rejections": dict(pnp_rejections), - "oblique_group_roles": list(oblique_group_roles), - "candidate_diagnostics": candidate_diagnostics, - "tag_quality": { - role: { - "hamming": int(quality.hamming), - "decision_margin": float(quality.decision_margin), - "mean_edge_pixels": float(quality.edge_pixels), - } - for role, quality in qualities.items() - if role in required_roles - }, - "corners_xy": { - role: np.asarray(corners, dtype=float).tolist() - for role, corners in corners_by_role.items() - if role in required_roles - }, - "camera_intrinsics_sha256": runtime.intrinsics_sha256, - "camera_matrix": ( - None - if runtime.camera_matrix is None - else runtime.camera_matrix.tolist() - ), - }, - ) - - def _camera_info_callback(self, view: str, message: CameraInfo) -> None: - runtime = self.views[view] - valid = ( - int(message.width) > 0 - and int(message.height) > 0 - and len(message.p) == 12 - and float(message.p[0]) > 0.0 - and float(message.p[5]) > 0.0 - ) - matrix = None - if valid: - matrix = np.asarray(message.p, dtype=float).reshape(3, 4)[:, :3] - valid = bool( - np.all(np.isfinite(matrix)) - and matrix[0, 0] > 0.0 - and matrix[1, 1] > 0.0 - ) - if ( - matrix is not None - and runtime.camera_matrix is not None - and not np.allclose(matrix, runtime.camera_matrix) - ): - self._reset_view_trackers(runtime) - runtime.camera_info_valid = valid - runtime.camera_matrix = matrix if valid else None - runtime.camera_frame = str(message.header.frame_id) - runtime.image_width = int(message.width) - runtime.image_height = int(message.height) - runtime.intrinsics_sha256 = ( - "" - if matrix is None - else camera_info_fingerprint( - width=message.width, - height=message.height, - camera_matrix=message.k, - distortion=message.d, - rectification=message.r, - projection=message.p, - ) - ) - runtime.extrinsics_valid = False - if valid and self.extrinsics is not None: - runtime.extrinsics_valid = self.extrinsics.camera_matches( - view, - serial_number=self.camera_serials[view], - width=runtime.image_width, - height=runtime.image_height, - intrinsics_sha256=runtime.intrinsics_sha256, - ) - - def _state_callback(self, message: JointState) -> None: - command_names = _command_names(self) - named_feedback = ( - tuple(message.name) - if ( - len(message.name) == len(command_names) - and set(message.name) == set(command_names) - ) - else () - ) - adapter = getattr(self, "sdk_adapter", None) - typed_profile = getattr(self, "calibration_profile", None) - if adapter is None and typed_profile is not None: - adapter = ProfileSdkAdapter(self.calibration_profile.command) - state = ( - adapter.parse_feedback(named_feedback, message.position) - if adapter is not None - else ( - tuple( - float(dict(zip(message.name, message.position))[name]) - for name in command_names - ) - if named_feedback - else tuple(float(value) for value in message.position) - if len(message.position) == len(command_names) - else None - ) - ) - if state is None: - return - stamp = _stamp_ns(message.header.stamp) - if stamp <= 0: - stamp = int(self.get_clock().now().nanoseconds) - self.latest_state_u8 = state - self.last_state_at = time.monotonic() - self.state_receive_times.append(self.last_state_at) - if not self.state_history or stamp > self.state_history[-1].stamp_ns: - self.state_history.append(StateSample(stamp, state)) - - def _info_callback(self, message: String) -> None: - try: - value = json.loads(message.data) - except json.JSONDecodeError: - return - if isinstance(value, dict): - self.latest_hand_info = value - - def _quality_valid(self, quality: TagQuality, *, include_pnp: bool) -> bool: - return tag_quality_is_valid( - quality, - maximum_hamming=self.maximum_hamming, - minimum_decision_margin=self.minimum_decision_margin, - minimum_edge_pixels=self.minimum_edge_pixels, - maximum_reprojection_error_px=( - self.pnp_maximum_reprojection_error_px if include_pnp else None - ), - ) - - def _detections_callback( - self, view: str, message: AprilTagDetectionArray - ) -> None: - runtime = self.views[view] - now = time.monotonic() - stamp = _stamp_ns(message.header.stamp) - required_roles = self._required_roles_for_view(view) - locked_base_role = self._locked_base_role_for_active_capture(view) - live_required_roles = self._live_required_roles_for_view( - view, required_roles - ) - palm_observer: PalmAxisObserver | None = None - if ( - self.active_sweep is not None - and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP} - ): - palm_observer = _palm_axis_observer_for_sweep( - _node_profile(self), self.active_sweep.spec, view - ) - palm_observer_roles = ( - () - if palm_observer is None - else (palm_observer.parent_role, palm_observer.child_role) - ) - combination_capture = bool( - self.active_combination_validation is not None - and self.state in {STATE_VALIDATION_MOVE, STATE_VALIDATION_CAPTURE} - ) - if required_roles != runtime.current_required_roles: - runtime.current_required_roles = required_roles - runtime.valid_flags.clear() - runtime.detection_times.clear() - self._reset_view_pnp_diagnostics(runtime) - runtime.task_valid_frames = 0 - runtime.task_total_frames = 0 - self._reset_view_trackers(runtime) - qualities: dict[str, TagQuality] = {} - corners_by_role: dict[str, np.ndarray] = {} - centres_by_role: dict[str, np.ndarray] = {} - for detection in message.detections: - role = runtime.role_by_id.get(int(detection.id)) - if role is None: - continue - corners = np.asarray( - [[float(point.x), float(point.y)] for point in detection.corners], - dtype=float, - ) - if corners.shape != (4, 2): - continue - edges = np.linalg.norm(corners - np.roll(corners, -1, axis=0), axis=1) - qualities[role] = TagQuality( - hamming=int(detection.hamming), - decision_margin=float(detection.decision_margin), - edge_pixels=float(np.mean(edges)), - ) - corners_by_role[role] = corners - centres_by_role[role] = np.mean(corners, axis=0) - - if ( - view == "top" - and locked_base_role is not None - and locked_base_role in corners_by_role - and runtime.locked_base_corners_xy is not None - ): - corner_drift = _maximum_corner_drift_px( - runtime.locked_base_corners_xy, - corners_by_role[locked_base_role], - ) - runtime.latest_locked_base_corner_drift_px = corner_drift - if corner_drift > self.fixed_base_maximum_corner_drift_px: - runtime.locked_base_corner_drift_count += 1 - else: - runtime.locked_base_corner_drift_count = 0 - if ( - runtime.locked_base_corner_drift_count - >= self.fixed_base_movement_confirmation_frames - ): - append_jsonl( - self.raw_path, - { - "kind": "fixed_base_reference_moved", - "view": view, - "role": locked_base_role, - "tag_id": runtime.view_tags[locked_base_role], - "corner_drift_px": round(corner_drift, 6), - "maximum_corner_drift_px": ( - self.fixed_base_maximum_corner_drift_px - ), - "confirmation_frames": ( - runtime.locked_base_corner_drift_count - ), - "task_name": ( - "" - if self.active_sweep is None - else self.active_sweep.spec.key - ), - }, - ) - self._pause("fixed_base_reference_moved") - return - - pose_roles = ( - tuple( - role - for role in runtime.roles - if role in qualities - and role != locked_base_role - and self._quality_valid(qualities[role], include_pnp=False) - ) - if combination_capture - else tuple( - dict.fromkeys( - ( - *live_required_roles, - *( - role - for role in palm_observer_roles - if role in qualities - and role != locked_base_role - and self._quality_valid( - qualities[role], include_pnp=False - ) - ), - ) - ) - ) - ) - if locked_base_role is not None: - # The fixed pose is injected below. A live top base remains a - # detection/movement requirement but is never re-solved into the - # pitch/roll/yaw geometry after the session reference is locked. - pose_roles = tuple( - role for role in pose_roles if role != locked_base_role - ) - observable_combination_joints = ( - _combination_observable_joints( - self.profile, - view, - ( - *pose_roles, - *((locked_base_role,) if locked_base_role else ()), - ), - ) - if combination_capture - else () - ) - detection_good = bool( - all(role in qualities for role in live_required_roles) - and all( - self._quality_valid(qualities[role], include_pnp=False) - for role in live_required_roles - ) - and ( - not combination_capture - or bool(observable_combination_joints) - ) - ) - tracking_command_u8: int | None = None - tracking_direction: str | None = None - matched_tracking: tuple[tuple[float, ...], int] | None = None - if ( - self.active_sweep is not None - and ( - view in _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - or palm_observer is not None - ) - and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP} - ): - matched_tracking = interpolate_state_u8( - list(self.state_history), - stamp, - maximum_skew_ns=self.maximum_state_image_skew_ns, - ) - if matched_tracking is not None: - matched_tracking_state = matched_tracking[0] - if ( - self.state == STATE_SWEEP - or self._motion_command_reached( - self.active_sweep.spec, - self.active_sweep.start_u8, - matched_tracking_state, - ) - ): - # During PREPARE, learn only the final held start pose. - # In particular a roll task approaches 255 from its 127 - # baseline; caching that preparation motion as the formal - # decreasing trajectory would contaminate the same-command - # return reference before the real scan overwrites it. - tracking_command_u8 = int( - np.clip( - np.rint( - matched_tracking_state[ - self.active_sweep.spec.motor_index - ] - ), - 0, - 255, - ) - ) - tracking_direction = self.active_sweep.direction - selected: dict[str, SquareTagPose] | None = None - pnp_rejections: dict[str, str] = {} - group_pnp_reason = "" - if detection_good and runtime.camera_matrix is not None: - candidates: dict[str, tuple[SquareTagPose, ...]] = {} - individual_selected: dict[str, SquareTagPose] = {} - for role in pose_roles: - pose, rejection = runtime.tracker.estimate( - role, - corners_by_role[role], - tag_size_m=float( - runtime.tag_sizes_m_by_role.get( - role, runtime.tag_size_m - ) - ), - camera_matrix=runtime.camera_matrix, - stamp_ns=stamp, - ) - if pose is not None: - individual_selected[role] = pose - candidates[role] = runtime.tracker.last_candidates_by_role.get( - role, () - ) - # A group-constrained task deliberately retains an oblique - # candidate that the independent per-Tag selector would not - # use. Report a rejection only if candidate generation itself - # failed; otherwise the group tracker owns the decision. - if rejection and ( - combination_capture or not candidates[role] - ): - pnp_rejections[role] = rejection - if combination_capture: - # Each visible target is tracked independently. This avoids - # an exponential all-Tag IPPE search and, more importantly, - # lets an occluded neighbouring finger remain optional. - selected = individual_selected - else: - if locked_base_role is not None: - assert runtime.locked_base_pose is not None - candidates[locked_base_role] = ( - runtime.locked_base_pose, - ) - group_tracker = self._group_tracker_for_roles( - runtime, required_roles - ) - selected, group_pnp_reason = group_tracker.select( - candidates, - stamp_ns=stamp, - trajectory_command_u8=tracking_command_u8, - trajectory_direction=tracking_direction, - ) - runtime.latest_group_missing_candidate_roles = ( - group_tracker.last_missing_roles - ) - if selected is not None and palm_observer_roles: - selected = dict(selected) - for role in palm_observer_roles: - pose = individual_selected.get(role) - quality = qualities.get(role) - if pose is None or quality is None: - continue - pose_quality = TagQuality( - hamming=quality.hamming, - decision_margin=quality.decision_margin, - edge_pixels=quality.edge_pixels, - reprojection_error_px=pose.reprojection_error_px, - ) - if self._quality_valid( - pose_quality, include_pnp=True - ): - selected[role] = pose - if selected is not None: - qualities = _selected_pose_qualities( - selected, - qualities, - locked_base_role=locked_base_role, - locked_base_quality=runtime.locked_base_quality, - ) - # Preserve exactly which Tags were present in this camera message. - # The cached palm reference participates in geometry and quality - # checks below, but must never be reported as a live detection. - live_qualities = { - role: quality - for role, quality in qualities.items() - if role in corners_by_role - } - if selected is not None and locked_base_role is not None: - assert runtime.locked_base_pose is not None - assert runtime.locked_base_center_xy_px is not None - assert runtime.locked_base_quality is not None - selected = dict(selected) - selected[locked_base_role] = runtime.locked_base_pose - centres_by_role[locked_base_role] = np.asarray( - runtime.locked_base_center_xy_px, dtype=float - ) - qualities[locked_base_role] = runtime.locked_base_quality - valid = bool( - selected is not None - and all(role in selected for role in required_roles) - and all( - self._quality_valid(qualities[role], include_pnp=True) - for role in ( - selected - if combination_capture - else required_roles - ) - ) - and ( - not combination_capture - or bool( - _combination_observable_joints( - self.profile, view, tuple(selected) - ) - ) - ) - ) - if ( - self.state == STATE_SWEEP - and self.active_sweep is not None - and view in _sweep_views(_node_profile(self), self.active_sweep.spec) - ): - self.sweep_detection_total_frames += 1 - total_by_view = getattr( - self, "sweep_detection_total_by_view", None - ) - if total_by_view is None: - self.sweep_detection_total_by_view = {} - total_by_view = self.sweep_detection_total_by_view - total_by_view[view] = total_by_view.get(view, 0) + 1 - if valid: - self.sweep_detection_valid_frames += 1 - valid_by_view = getattr( - self, "sweep_detection_valid_by_view", None - ) - if valid_by_view is None: - self.sweep_detection_valid_by_view = {} - valid_by_view = self.sweep_detection_valid_by_view - valid_by_view[view] = valid_by_view.get(view, 0) + 1 - runtime.latest_pnp_rejections = pnp_rejections - runtime.latest_group_pnp_reason = group_pnp_reason - if not group_pnp_reason: - runtime.latest_group_missing_candidate_roles = () - runtime.latest_pnp_valid = valid - task_pnp_capture = bool( - self.active_sweep is not None - and self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP} - and view in _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - ) - if task_pnp_capture: - for role, rejection in pnp_rejections.items(): - key = f"{role}:{rejection}" - runtime.pnp_rejection_counts[key] = ( - runtime.pnp_rejection_counts.get(key, 0) + 1 - ) - if group_pnp_reason.startswith("group_initializing:"): - try: - progress = group_pnp_reason.split(":", 1)[1] - accepted, required = progress.split("/", 1) - runtime.pnp_initialization_progress = ( - int(accepted), - int(required), - ) - except (IndexError, TypeError, ValueError): - runtime.pnp_initialization_progress = None - elif group_pnp_reason: - runtime.group_pnp_rejection_counts[group_pnp_reason] = ( - runtime.group_pnp_rejection_counts.get( - group_pnp_reason, 0 - ) - + 1 - ) - elif selected is not None and runtime.pnp_initialization_progress: - required = runtime.pnp_initialization_progress[1] - runtime.pnp_initialization_progress = (required, required) - self._record_group_pnp_candidate_event( - runtime=runtime, - stamp_ns=stamp, - required_roles=required_roles, - pose_roles=pose_roles, - corners_by_role=corners_by_role, - qualities=qualities, - matched_tracking=matched_tracking, - selected=selected, - pnp_rejections=pnp_rejections, - group_pnp_reason=group_pnp_reason, - ) - if view == "top": - runtime.pnp_invalid_since, reset_due = update_pnp_reset_watchdog( - detection_good=bool( - detection_good and runtime.camera_matrix is not None - ), - pnp_valid=valid, - now=now, - invalid_since=runtime.pnp_invalid_since, - reset_after_seconds=self.top_pnp_invalid_reset_seconds, - ) - if reset_due: - self._reset_view_trackers(runtime) - runtime.pnp_reset_count += 1 - self.get_logger().warning( - "Top-view PnP was continuously invalid for " - f"{self.top_pnp_invalid_reset_seconds:.1f}s; reset trackers " - f"(count={runtime.pnp_reset_count}, " - f"group_reason={group_pnp_reason or 'none'}, " - f"tag_rejections={pnp_rejections})" - ) - if ( - valid - and selected is not None - and self.state == STATE_PREFLIGHT - and self.started - and self.startup_baseline_recovered - ): - base_role = _fixed_base_role(view) - if ( - base_role in selected - and base_role in centres_by_role - and base_role in live_qualities - ): - runtime.fixed_base_observations.append( - ( - selected[base_role], - tuple( - float(value) - for value in centres_by_role[base_role] - ), - live_qualities[base_role], - ) - ) - runtime.fixed_base_corner_observations.append( - np.asarray(corners_by_role[base_role], dtype=float).copy() - ) - runtime.latest_tag_quality = live_qualities - runtime.last_message_at = now - runtime.detection_times.append(now) - runtime.valid_flags.append(valid) - task_sweep_capture = bool( - self.state == STATE_SWEEP - and self.active_sweep is not None - and view in _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - ) - if ( - runtime.current_required_roles - != tuple(runtime.preflight_roles) - and task_sweep_capture - ): - # Attempt-level validity is a motion-trajectory metric. PREPARE - # deliberately rejects the first N frames while the group PnP - # tracker initializes; counting those expected frames made clean - # side tasks report about 94% and triggered full four-round - # rescans despite 100% visibility prechecks and dense trajectories. - runtime.task_total_frames += 1 - if valid: - runtime.task_valid_frames += 1 - if not valid or selected is None: - return - runtime.last_valid_at = now - - # Preflight and motion-only states need pose quality/status, but do not - # consume FrameObservation objects. Building transforms and payloads - # for every measured joint on all three 30 Hz streams was pure work - # and could starve the detection subscriptions. Only materialize an - # observation for the one view used by an active capture stage. - sweep_capture = bool( - self.state in {STATE_PREPARE_SWEEP, STATE_SWEEP} - and self.active_sweep is not None - and view in _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - ) - palm_axis_capture = bool( - self.state == STATE_SWEEP - and self.active_sweep is not None - and not self.active_sweep.precheck - and self.active_sweep.cycle >= 0 - and palm_observer is not None - ) - validation_capture = bool( - self.state == STATE_VALIDATION_CAPTURE - and ( - ( - self.active_validation is not None - and view in _sweep_views( - self.profile, self.active_validation.spec - ) - ) - or self.active_combination_validation is not None - ) - ) - if not sweep_capture and not validation_capture and not palm_axis_capture: - return - - matched = matched_tracking - if matched is None: - matched = interpolate_state_u8( - list(self.state_history), - stamp, - maximum_skew_ns=self.maximum_state_image_skew_ns, - ) - if matched is None: - return - state_u8, sync_error_ns = matched - joint_vectors: dict[str, tuple[float, float, float]] = {} - image_vectors: dict[str, tuple[float, float]] = {} - joint_quaternions: dict[str, tuple[float, float, float, float]] = {} - parent_poses_common: dict[str, Mapping[str, list[float]]] = {} - child_poses_common: dict[str, Mapping[str, list[float]]] = {} - joint_reprojection: dict[str, float] = {} - if self.extrinsics is None or not runtime.extrinsics_valid: - return - front_from_view = self.extrinsics.transform(view) - if palm_axis_capture and palm_observer is not None: - self._record_palm_axis_sample( - palm_observer, - selected, - front_from_view, - state_u8, - sync_error_ns=int(sync_error_ns), - stamp_ns=stamp, - ) - if not sweep_capture and not validation_capture: - return - for name, spec in self.profile.record_specs.items(): - if not spec.measured or spec.view != view: - continue - assert spec.parent_role is not None and spec.child_role is not None - if ( - spec.parent_role not in selected - or spec.child_role not in selected - ): - continue - parent = selected[spec.parent_role] - child = selected[spec.child_role] - parent_rotation = Rotation.from_quat(parent.quaternion_xyzw) - relative = parent_rotation.inv().apply( - np.asarray(child.translation_xyz_m, dtype=float) - - np.asarray(parent.translation_xyz_m, dtype=float) - ) - image_relative = ( - centres_by_role[spec.child_role] - - centres_by_role[spec.parent_role] - ) - parent_matrix = front_from_view @ transform_matrix( - parent.translation_xyz_m, parent.quaternion_xyzw - ) - child_matrix = front_from_view @ transform_matrix( - child.translation_xyz_m, child.quaternion_xyzw - ) - relative_rotation = Rotation.from_matrix( - parent_matrix[:3, :3] - ).inv() * Rotation.from_matrix(child_matrix[:3, :3]) - joint_vectors[name] = tuple(float(value) for value in relative) - image_vectors[name] = tuple(float(value) for value in image_relative) - joint_quaternions[name] = tuple( - float(value) for value in relative_rotation.as_quat() - ) - parent_poses_common[name] = matrix_payload(parent_matrix) - child_poses_common[name] = matrix_payload(child_matrix) - joint_reprojection[name] = max( - float(parent.reprojection_error_px), - float(child.reprojection_error_px), - ) - observation = FrameObservation( - stamp_ns=stamp, - received_at=now, - view=view, - state_u8=state_u8, - state_sync_error_ns=int(sync_error_ns), - joint_vectors_xyz_m=joint_vectors, - image_vectors_xy_px=image_vectors, - joint_quaternions_xyzw=joint_quaternions, - parent_poses_common=parent_poses_common, - child_poses_common=child_poses_common, - joint_reprojection_error_px=joint_reprojection, - ) - self._accept_frame(observation) - - def _record_palm_axis_sample( - self, - observer: PalmAxisObserver, - selected: Mapping[str, SquareTagPose], - common_from_view: np.ndarray, - state_u8: Sequence[float], - *, - sync_error_ns: int, - stamp_ns: int, - ) -> None: - """Buffer one direction-only sample without touching sweep validity.""" - item = self.active_sweep - if ( - item is None - or item.spec.key != observer.task_name - or observer.parent_role not in selected - or observer.child_role not in selected - ): - return - parent = selected[observer.parent_role] - child = selected[observer.child_role] - parent_matrix = common_from_view @ transform_matrix( - parent.translation_xyz_m, parent.quaternion_xyzw - ) - child_matrix = common_from_view @ transform_matrix( - child.translation_xyz_m, child.quaternion_xyzw - ) - relative_rotation = ( - Rotation.from_matrix(parent_matrix[:3, :3]).inv() - * Rotation.from_matrix(child_matrix[:3, :3]) - ) - feedback = float(state_u8[observer.motor_index]) - durable = canonical_sample_record({ - "kind": "palm_axis_sample", - "attempt": self.sweep_attempts.get( - _sweep_storage_key(item.spec), 1 - ), - "task_name": item.spec.key, - "view": observer.view, - "source_joint": observer.source_name, - "model_joint": observer.model_joint, - "motor_index": int(observer.motor_index), - "cycle": int(item.cycle), - "direction": item.direction, - "requested_command_u8": int(item.target_u8), - "feedback_u8": feedback, - "relative_quaternion_xyzw": [ - float(value) for value in relative_rotation.as_quat() - ], - "parent_pose_common": matrix_payload(parent_matrix), - "child_pose_common": matrix_payload(child_matrix), - "state_u8": [float(value) for value in state_u8], - "state_image_sync_error_ms": round( - abs(int(sync_error_ns)) / 1_000_000.0, 6 - ), - "pnp_reprojection_error_px": round( - max( - float(parent.reprojection_error_px), - float(child.reprojection_error_px), - ), - 6, - ), - "image_stamp_ns": int(stamp_ns), - }) - record = fitting_sample_record(durable) - self.palm_axis_records_by_source.setdefault( - observer.source_name, [] - ).append(record) - - def _persist_palm_axis_samples(self, item: SweepItem) -> None: - """Commit the side channel only after its authoritative sweep passes.""" - observer = next( - ( - candidate - for candidate in self.profile.palm_axis_observers - if candidate.task_name == item.spec.key - ), - None, - ) - if observer is None: - return - attempt = int( - self.sweep_attempts.get(_sweep_storage_key(item.spec), 1) - ) - rows = [ - record - for record in self.palm_axis_records_by_source.get( - observer.source_name, () - ) - if int(record.get("attempt", 1)) == attempt - and int(record.get("cycle", -1)) == int(item.cycle) - and str(record.get("direction", "")) == item.direction - ] - append_jsonl_many( - self.raw_path, - (canonical_sample_record(record) for record in rows), - ) - - def _accept_frame(self, observation: FrameObservation) -> None: - if self.state == STATE_PREPARE_SWEEP and self.active_sweep is not None: - if observation.view not in _sweep_views( - _node_profile(self), self.active_sweep.spec - ): - return - if not self._motion_command_reached( - self.active_sweep.spec, - self.active_sweep.start_u8, - observation.state_u8, - ): - return - self.sweep_start_frames.append(observation) - maximum_start_frames = max( - 1, int(getattr(self, "preflight_frames", 30)) - ) * len( - _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - ) - if len(self.sweep_start_frames) > maximum_start_frames: - del self.sweep_start_frames[:-maximum_start_frames] - return - if self.state == STATE_SWEEP and self.active_sweep is not None: - if observation.view not in _sweep_views( - self.profile, self.active_sweep.spec - ): - return - motor = self.active_sweep.spec.motor_index - value = float(observation.state_u8[motor]) - if not -3.0 <= value <= 258.0: - return - if not self._motion_command_reached( - self.active_sweep.spec, - int(np.clip(np.rint(value), 0, 255)), - observation.state_u8, - ): - return - self.sweep_frames.append(observation) - checkpoint_target = getattr( - self, "sweep_checkpoint_target_u8", None - ) - if ( - checkpoint_target is not None - and getattr(self, "sweep_checkpoint_hold_since", None) - is not None - and self._steady_checkpoint_reached( - self.active_sweep.spec, - int(checkpoint_target), - observation.state_u8, - ) - ): - self.sweep_checkpoint_frames.append(observation) - if ( - getattr(self, "sweep_baseline_pending", False) - and getattr(self, "sweep_baseline_hold_since", None) - is not None - and observation.received_at - - float(self.sweep_baseline_hold_since) - >= float(self.baseline_hold_seconds) - and self._motion_command_reached( - self.active_sweep.spec, - int( - self.baseline_command[ - self.active_sweep.spec.motor_index - ] - ), - observation.state_u8, - ) - ): - self.sweep_baseline_frames.append(observation) - self.sweep_last_valid_at = observation.received_at - last_by_view = getattr( - self, "sweep_last_valid_at_by_view", None - ) - if last_by_view is None: - self.sweep_last_valid_at_by_view = {} - last_by_view = self.sweep_last_valid_at_by_view - last_by_view[observation.view] = observation.received_at - return - if ( - self.state == STATE_VALIDATION_CAPTURE - and self.active_validation is not None - and observation.view in _sweep_views( - self.profile, self.active_validation.spec - ) - ): - if self._motion_command_reached( - self.active_validation.spec, - self.active_validation.command_u8, - observation.state_u8, - ): - self.validation_frames_buffer.append(observation) - return - if ( - self.state == STATE_VALIDATION_CAPTURE - and self.active_combination_validation is not None - and self._command_vector_reached( - self.active_combination_validation.command_u8 - ) - ): - self.combination_validation_frames_buffer[ - observation.view - ].append(observation) - - def _view_ready(self, runtime: ViewRuntime, now: float) -> bool: - minimum_frames = min(30, self.preflight_frames) - return bool( - runtime.camera_info_valid - and runtime.extrinsics_valid - and len(runtime.valid_flags) >= minimum_frames - and runtime.valid_rate >= self.minimum_detection_rate - and runtime.detection_hz >= self.minimum_detection_hz - and now - runtime.last_message_at <= 1.0 - ) - - def _all_preflight_ready(self, now: float) -> bool: - feedback_hz = ( - 0.0 - if len(self.state_receive_times) < 2 - or self.state_receive_times[-1] <= self.state_receive_times[0] - else (len(self.state_receive_times) - 1) - / (self.state_receive_times[-1] - self.state_receive_times[0]) - ) - return bool( - self.extrinsics is not None - and len(self.latest_state_u8) == 20 - and now - self.last_state_at <= 1.0 - and feedback_hz >= self.minimum_feedback_hz - and all(self._view_ready(runtime, now) for runtime in self.views.values()) - ) - - def _all_devices_ready(self, now: float) -> bool: - """Check only hardware transport needed for a safe baseline return. - - Tag visibility must not gate this check: an interrupted calibration can - leave the fingers in a pose that occludes a fixed palm Tag. Requiring - that Tag before commanding baseline would create a startup deadlock. - """ - feedback_hz = ( - 0.0 - if len(self.state_receive_times) < 2 - or self.state_receive_times[-1] <= self.state_receive_times[0] - else (len(self.state_receive_times) - 1) - / (self.state_receive_times[-1] - self.state_receive_times[0]) - ) - return bool( - self.extrinsics is not None - and len(self.latest_state_u8) == 20 - and now - self.last_state_at <= 1.0 - and feedback_hz >= self.minimum_feedback_hz - and all( - runtime.camera_info_valid - and runtime.extrinsics_valid - and runtime.last_message_at > 0.0 - and now - runtime.last_message_at <= 1.0 - for runtime in self.views.values() - ) - ) - - def _finish_startup_baseline_recovery(self) -> None: - """Start a fresh fixed-palm-Tag window at confirmed baseline.""" - for runtime in self.views.values(): - runtime.valid_flags.clear() - runtime.detection_times.clear() - observations = getattr(runtime, "fixed_base_observations", None) - if observations is not None: - observations.clear() - corner_observations = getattr( - runtime, "fixed_base_corner_observations", None - ) - if corner_observations is not None: - corner_observations.clear() - runtime.locked_base_pose = None - runtime.locked_base_center_xy_px = None - runtime.locked_base_corners_xy = None - runtime.locked_base_quality = None - runtime.locked_base_corner_drift_count = 0 - runtime.latest_locked_base_corner_drift_px = 0.0 - self._reset_view_trackers(runtime) - self.startup_baseline_recovered = True - self.state = STATE_PREFLIGHT - self.reason = "waiting_for_baseline_tags_after_recovery" - - def _active_view_for_resume(self) -> str | None: - if self.active_sweep is not None: - return self.active_sweep.spec.view - if self.active_validation is not None: - return self.active_validation.spec.view - if self.retry_sweep_spec is not None: - return self.retry_sweep_spec.view - return None - - def _active_views_for_resume(self) -> tuple[str, ...]: - if self.active_sweep is not None: - return _sweep_views(_node_profile(self), self.active_sweep.spec) - if self.active_validation is not None: - return _sweep_views(_node_profile(self), self.active_validation.spec) - if self.retry_sweep_spec is not None: - return _sweep_views(_node_profile(self), self.retry_sweep_spec) - return required_resume_views(None) - - def _resume_preflight_ready(self, now: float) -> bool: - if ( - len(self.latest_state_u8) != _command_count(self) - or now - self.last_state_at > 1.0 - ): - return False - return all( - self._view_ready(self.views[view], now) - for view in self._active_views_for_resume() - ) - - def _prepare_failed_sweep_retry(self) -> SweepSpec: - """Discard only failed measurements/cycles and queue two directions.""" - if self.retry_sweep_spec is None: - raise RuntimeError("no failed sweep is pending retry") - spec = self.retry_sweep_spec - repetitions = int(getattr(self, "repetitions", 3)) - cycles = set(getattr(self, "retry_cycles", set(range(repetitions)))) - profile = _node_profile(self) - source_task_keys = ( - tuple(getattr(self, "retry_source_task_keys", ())) - if getattr(self, "retry_source_failure_task_key", None) == spec.key - else () - ) - source_key_set = set(source_task_keys) - source_specs = [ - candidate - for candidate in profile.sweep_specs - if candidate.key in source_key_set - ] - # Yaw zero is a same-view relationship between the pitch and roll - # axes. A retry must remain in the original top-camera PnP reference - # generation; otherwise the retry changes the datum it is intended to - # verify. Ordinary single-task retries still reset their camera to - # recover from a genuinely bad branch. - self.retry_preserve_pnp_continuity = bool(source_specs) - data_specs = source_specs or [spec] - retry_joint_names = set(getattr(self, "retry_joint_names", set())) - if source_specs: - retry_joint_names = { - joint_name - for candidate in source_specs - for joint_name in candidate.joints - } - else: - retry_joint_names = retry_joint_names or set(spec.joints) - retry_joint_names.intersection_update(spec.joints) - if not retry_joint_names: - retry_joint_names = set(spec.joints) - self.retry_joint_names = retry_joint_names - baseline_records_by_joint = getattr( - self, "baseline_records_by_joint", {} - ) - for joint_name in retry_joint_names: - self.records_by_joint[joint_name][:] = [ - record - for record in self.records_by_joint[joint_name] - if int(record.get("cycle", -1)) not in cycles - ] - if joint_name in baseline_records_by_joint: - baseline_records_by_joint[joint_name][:] = [ - record - for record in baseline_records_by_joint[joint_name] - if int(record.get("cycle", -1)) not in cycles - ] - command_records = getattr(self, "command_records_by_joint", {}) - if joint_name in command_records: - command_records[joint_name][:] = [ - record - for record in command_records[joint_name] - if int(record.get("cycle", -1)) not in cycles - ] - data_task_keys = {candidate.key for candidate in data_specs} - observers = [ - item - for item in profile.palm_axis_observers - if item.task_name in data_task_keys - ] - for observer in observers: - records = self.palm_axis_records_by_source.get( - observer.source_name, [] - ) - records[:] = [ - record - for record in records - if int(record.get("cycle", -1)) not in cycles - ] - all_items = getattr(self, "sweep_items", []) - self.retry_sweep_items = [ - item - for item in all_items - if item.spec in data_specs and item.cycle in cycles - ] - if not self.retry_sweep_items: - self.retry_sweep_items = [ - SweepItem(candidate, cycle, direction) - for candidate in data_specs - for cycle in sorted(cycles) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ] - # A planar Tag PnP tracker can occasionally remain on a continuous but - # biased pose branch for an otherwise clean sweep. Starting a full - # fit retry with that tracker state intact tends to reproduce the same - # orientation residual, so reset only the affected camera before the - # retry. Raw samples remain logged and all final quality gates stay - # unchanged. - reset_trackers = getattr(self, "_reset_view_trackers", None) - retry_views = { - profile.record_specs[name].view - for name in retry_joint_names - } - for observer in observers: - retry_views.add(observer.view) - for view in retry_views: - if view is None: - continue - runtime = getattr(self, "views", {}).get(view) - if runtime is None or reset_trackers is None: - continue - if self.retry_preserve_pnp_continuity: - # Frame-quality counters are attempt-local even though the - # geometric PnP state deliberately remains continuous. - runtime.task_valid_frames = 0 - runtime.task_total_frames = 0 - continue - if _node_profile(self).layout_id == G20_RIGHT_19_LAYOUT: - reset_trackers(runtime, preserve_task_reference=True) - else: - reset_trackers(runtime) - runtime.pnp_invalid_since = None - runtime.pnp_reset_count += 1 - # Detection quality is attempt-local. Carrying the previous - # attempt into this retry made its percentage describe two PnP - # branches instead of the data currently being admitted. - runtime.task_valid_frames = 0 - runtime.task_total_frames = 0 - storage_key = _sweep_storage_key(spec) - self.sweep_attempts[storage_key] = ( - self.sweep_attempts.get(storage_key, 1) + 1 - ) - source_attempts: dict[str, int] = {} - for source_spec in source_specs: - source_key = _sweep_storage_key(source_spec) - self.sweep_attempts[source_key] = ( - self.sweep_attempts.get(source_key, 1) + 1 - ) - source_attempts[str(source_spec.key)] = self.sweep_attempts[ - source_key - ] - invalidated_storage_keys = { - storage_key, - *(_sweep_storage_key(candidate) for candidate in source_specs), - } - for key in list(getattr(self, "sweep_retry_counts", {})): - if key[0] in invalidated_storage_keys: - self.sweep_retry_counts.pop(key, None) - append_jsonl( - self.raw_path, - { - "kind": "retry", - "view": spec.view, - **({"task_name": spec.key} if spec.task_name else {}), - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "joints_to_rescan": sorted(retry_joint_names), - **( - {"source_task_names": list(source_task_keys)} - if source_task_keys - else {} - ), - **( - {"source_attempts": source_attempts} - if source_attempts - else {} - ), - **( - {"pnp_reference_policy": "preserve_existing_generation"} - if self.retry_preserve_pnp_continuity - else {} - ), - "attempt": self.sweep_attempts[storage_key], - "reason": self.paused_reason, - "cycles": [cycle + 1 for cycle in sorted(cycles)], - }, - ) - self.fit_failure = {} - G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self) - getattr(self, "motion_stall_details", {}).clear() - return spec - - def _invalidate_fitted_calibration_state(self) -> None: - """Invalidate every artifact derived from the current motion records. - - A task rescan changes the observation set, so curves, zero offsets and - validation diagnostics must be treated as one generation. Keeping - this transition in one place prevents final publication from mixing a - newly collected task with state left by an earlier fit attempt. - """ - self.measured_fits = {} - self.axis_measurements = [] - self.palm_orientation_measurements = [] - self.palm_orientation_rejections = {} - self.zero_result = None - self.validated_endpoint_zero_offsets_rad = {} - self.corrected_urdf_path = None - self.fit_quality_passed = False - self.validation_errors_rad = [] - self.cross_view_roll_metrics = {} - self.validation_only_fits = {} - self.joint_dynamic_diagnostics = {} - - def _endpoint_zero_offsets_for_publication(self) -> dict[str, float]: - """Return the endpoint anchors belonging to the validated zero fit.""" - stored = { - str(name): float(value) - for name, value in getattr( - self, "validated_endpoint_zero_offsets_rad", {} - ).items() - } - if self.profile.layout_id != G20_RIGHT_19_LAYOUT: - if stored: - raise RuntimeError( - "validated_endpoint_zero_state_unexpected_for_layout" - ) - return {} - expected = set(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS) - if getattr(self, "standalone_thumb_calibration", False): - expected = { - name for name in expected if name.startswith("thumb_") - } - actual = set(stored) - if actual != expected: - missing = ",".join(sorted(expected - actual)) or "-" - extra = ",".join(sorted(actual - expected)) or "-" - raise RuntimeError( - "validated_endpoint_zero_state_incomplete:" - f"missing={missing};extra={extra}" - ) - if not all(math.isfinite(value) for value in stored.values()): - raise RuntimeError("validated_endpoint_zero_state_non_finite") - if self.zero_result is None: - raise RuntimeError("URDF zero solution is missing") - solved = self.zero_result.direct_offsets_rad - diverged = sorted( - name - for name, value in stored.items() - if name not in solved - or not math.isclose( - value, - float(solved[name]), - rel_tol=0.0, - abs_tol=1.0e-12, - ) - ) - if diverged: - raise RuntimeError( - "validated_endpoint_zero_state_diverged:" - + ",".join(diverged) - ) - return stored - - def _restore_durable_task_checkpoint(self) -> int: - """Restore independently validated complete tasks from a failed session.""" - source = self.resume_raw_samples_path - if source is None: - return 0 - if not source.is_file(): - raise RuntimeError(f"resume raw samples do not exist: {source}") - if source.resolve() == self.raw_path.resolve(): - raise RuntimeError("resume raw samples must come from an older session") - rows: list[dict[str, Any]] = [] - source_size = max(1, int(source.stat().st_size)) - bytes_read = 0 - with source.open("rb") as stream: - for line_number, raw_line in enumerate(stream, start=1): - bytes_read += len(raw_line) - if not raw_line.strip(): - continue - try: - line = raw_line.decode("utf-8") - value = json.loads(line) - except (UnicodeDecodeError, json.JSONDecodeError) as error: - raise RuntimeError( - f"resume raw JSON is invalid at line {line_number}" - ) from error - if isinstance(value, dict): - rows.append(value) - if line_number % 1000 == 0: - self.base_import_progress = { - "phase": "reading", - "records_read": len(rows), - "bytes_read": bytes_read, - "total_bytes": source_size, - "fraction": min(1.0, bytes_read / source_size), - } - self.reason = "reading_base_session_records" - _publish_import_status(self) - starts = [row for row in rows if row.get("kind") == "session_start"] - if len(starts) != 1: - raise RuntimeError("resume raw must contain exactly one session_start") - start = starts[0] - if start.get("acquisition_policy_version") != ACQUISITION_POLICY_VERSION: - raise RuntimeError( - "resume acquisition policy differs; unified_engine_v1 " - "requires a new full capture" - ) - previous_tag_sizes = { - int(tag_id): float(size) - for tag_id, size in dict( - start.get("tag_sizes_m_by_id", {}) - ).items() - } - current_tag_sizes = dict(getattr(self, "tag_sizes_m_by_id", {})) - try: - ( - palm_axis_schema_compatible, - palm_axis_invalidated_tasks, - ) = _palm_axis_resume_policy( - self.profile, - start, - getattr( - getattr( - getattr(self, "calibration_profile", None), - "artifacts", - None, - ), - "session_compatibility_tokens", - frozenset(self.profile.capabilities), - ), - ) - except ValueError as error: - raise RuntimeError( - "resume checkpoint algorithm capabilities differ" - ) from error - if ( - str(start.get("model", "G20")).upper() - != str(getattr(self, "model", "G20")).upper() - or str(start.get("hand_type")) != self.hand_type - or str(start.get("tag_layout")) != self.profile.layout_id - or start.get("view_tags") - != {view: dict(tags) for view, tags in self.profile.view_tags.items()} - or tuple(int(value) for value in start.get("baseline_command_u8", [])) - != tuple(self.baseline_command) - or str(start.get("source_urdf_sha256", "")) - != _file_sha256(self.source_urdf_path) - ): - raise RuntimeError( - "resume checkpoint geometry, algorithm capabilities, Tag " - "layout, baseline or source URDF differs" - ) - current_corners_by_view = { - str(view): getattr(runtime, "locked_base_corners_xy", None) - for view, runtime in getattr(self, "views", {}).items() - } - if not current_corners_by_view: - raise RuntimeError( - "resume checkpoint requires current fixed-base references" - ) - ( - position_drift_by_view_px, - position_changed_views, - position_unverifiable_views, - ) = _resume_fixed_base_position_compatibility( - rows, - current_corners_by_view, - float( - getattr(self, "fixed_base_maximum_corner_drift_px", 2.0) - ), - ) - self.resume_source_session = source.parent.name - self.resume_position_drift_by_view_px = dict( - position_drift_by_view_px - ) - self.resume_position_changed_views = position_changed_views - self.resume_position_unverifiable_views = ( - position_unverifiable_views - ) - start_position_invalidated_tasks: tuple[str, ...] = () - if position_changed_views or position_unverifiable_views: - if str(getattr(self, "recalibration_scope", "full")) != "full": - affected = sorted( - {*position_changed_views, *position_unverifiable_views} - ) - raise RuntimeError( - "resume checkpoint start pose differs or cannot be " - "verified for partial recalibration: " - + ",".join(affected) - ) - start_position_invalidated_tasks = tuple( - spec.key for spec in self.profile.sweep_specs - ) - self.resume_position_policy = ( - "discard_all_tasks_for_new_start_pose" - if position_changed_views - else "discard_all_tasks_for_unverified_start_pose" - ) - else: - self.resume_position_policy = "reuse_same_start_pose" - try: - changed_tag_size_ids, size_invalidated_tasks = ( - resume_tasks_invalidated_by_tag_size_changes( - self.profile, - previous_tag_sizes, - current_tag_sizes, - ) - ) - except ValueError as error: - raise RuntimeError( - "resume checkpoint Tag-size geometry differs incompatibly" - ) from error - invalidated_task_set = set(size_invalidated_tasks) - invalidated_task_set.update(palm_axis_invalidated_tasks) - invalidated_task_set.update(start_position_invalidated_tasks) - scope_invalidated_tasks = tuple( - getattr(self, "recalibration_task_keys", ()) or () - ) - invalidated_task_set.update(scope_invalidated_tasks) - if invalidated_task_set: - rows = [ - row - for row in rows - if str(row.get("task_name", "")) not in invalidated_task_set - ] - completed, reusable = resumable_completed_task_prefix( - self.profile, - self.repetitions, - self.baseline_command, - rows, - minimum_sweep_bins=self.minimum_sweep_bins, - allow_sparse=True, - ) - validate_sample_records( - row - for row in reusable - if str(row.get("kind", "")) in RESUMABLE_SAMPLE_KINDS - or row.get("kind") == "palm_axis_sample" - ) - self.state = STATE_REVALIDATING_INHERITED - self.reason = "revalidating_inherited_tasks" - self.base_import_progress = { - "phase": "revalidating", - "records_read": len(rows), - "completed_tasks": 0, - "total_tasks": len(completed), - "fraction": 0.0, - } - _publish_import_status(self) - for durable in reusable: - kind = str(durable["kind"]) - if kind == "synchronised_frame": - continue - if kind == "palm_axis_sample": - source_name = str(durable.get("source_joint", "")) - if source_name not in self.palm_axis_records_by_source: - raise RuntimeError( - "resume checkpoint contains unknown palm-axis source " - + source_name - ) - record = fitting_sample_record(durable) - self.palm_axis_records_by_source[source_name].append(record) - continue - joint_name = str(durable["joint"]) - if joint_name not in self.records_by_joint: - raise RuntimeError( - f"resume checkpoint contains unknown joint {joint_name}" - ) - if kind not in { - "sample", "baseline_hold_sample", "steady_command_sample" - }: - continue - record = fitting_sample_record(durable) - if kind == "sample": - self.records_by_joint[joint_name].append(record) - elif kind == "baseline_hold_sample": - self.baseline_records_by_joint[joint_name].append(record) - else: - self.command_records_by_joint[joint_name].append(record) - completed, dropped = G20ThreeCameraCalibrationNode._revalidate_imported_tasks( - self, completed, allow_sparse=True - ) - completed_set = set(completed) - reusable = tuple( - row - for row in reusable - if str(row.get("task_name", "")) in completed_set - ) - # Attempts are durable identities, not counters local to one process. - # Without this floor a resumed task whose accepted data used attempt 2 - # would label its next retry as attempt 2 again. The raw log then - # contained two different acquisitions with the same identity and an - # offline replay merged them into one invalid trajectory. - attempt_floor_by_task: dict[str, int] = {} - for row in reusable: - task_key = str(row.get("task_name", "")) - if task_key not in completed_set: - continue - try: - attempt = max(1, int(row.get("attempt", 1))) - except (TypeError, ValueError): - attempt = 1 - attempt_floor_by_task[task_key] = max( - attempt_floor_by_task.get(task_key, 1), attempt - ) - attempts = getattr(self, "sweep_attempts", None) - if attempts is None: - self.sweep_attempts = { - _sweep_storage_key(spec): 1 - for spec in self.profile.sweep_specs - } - attempts = self.sweep_attempts - for spec in self.profile.sweep_specs: - if spec.key not in completed_set: - continue - storage_key = _sweep_storage_key(spec) - attempts[storage_key] = max( - int(attempts.get(storage_key, 1)), - attempt_floor_by_task.get(spec.key, 1), - ) - self.resumed_task_keys = completed - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self) - missing_tasks = [ - spec.key - for spec in self.profile.sweep_specs - if spec.key not in completed_set - ] - append_jsonl( - self.raw_path, - { - "kind": "resume_checkpoint_import", - "source_session": source.parent.name, - "completed_task_count": len(completed), - "completed_task_keys": list(completed), - "discarded_incomplete_task": ( - missing_tasks[0] if missing_tasks else None - ), - "pending_task_keys": missing_tasks, - "revalidation_dropped_tasks": dropped, - "tag_size_changed_ids": list(changed_tag_size_ids), - "tag_size_invalidated_task_keys": list(size_invalidated_tasks), - "palm_axis_schema_compatible": palm_axis_schema_compatible, - "palm_axis_invalidated_task_keys": list( - palm_axis_invalidated_tasks - ), - "recalibration_scope": str( - getattr(self, "recalibration_scope", "full") - ), - "scope_invalidated_task_keys": list( - scope_invalidated_tasks - ), - "start_position_policy": self.resume_position_policy, - "start_position_changed_views": list( - position_changed_views - ), - "start_position_unverifiable_views": list( - position_unverifiable_views - ), - "start_position_drift_by_view_px": { - view: round(float(value), 6) - for view, value in position_drift_by_view_px.items() - }, - "start_position_invalidated_task_keys": list( - start_position_invalidated_tasks - ), - "imported_record_count": len(reusable), - "imported_attempt_floor_by_task": attempt_floor_by_task, - "source_raw_samples_sha256": _file_sha256(source), - }, - ) - append_jsonl_many(self.raw_path, reusable) - return len(completed) - - def _revalidate_imported_tasks( - self, completed: Sequence[str], *, allow_sparse: bool = False - ) -> tuple[list[str], list[dict[str, Any]]]: - """Re-apply the final hard gates to imported tasks at import time. - - A task that only provisionally passed (warning band) in its source - session would otherwise survive until the final fit re-applies the - unmodified thresholds after every other task is collected, forcing - the session to go back and re-scan it at the very end. Failing - tasks are dropped here. Sparse product resume independently checks - later complete tasks so only the failed task has to be re-collected; - the compatibility mode still drops the remaining suffix. - """ - def discard_task_records(task_key: str) -> None: - spec = next( - ( - candidate - for candidate in self.profile.sweep_specs - if candidate.key == task_key - ), - None, - ) - if spec is None: - return - for joint_name in spec.joints: - for store in ( - self.records_by_joint, - self.baseline_records_by_joint, - self.command_records_by_joint, - ): - if joint_name in store: - store[joint_name].clear() - observer = next( - ( - item - for item in self.profile.palm_axis_observers - if item.task_name == task_key - ), - None, - ) - if observer is not None: - self.palm_axis_records_by_source[ - observer.source_name - ].clear() - - accepted: list[str] = [] - dropped: list[dict[str, Any]] = [] - for task_index, task_key in enumerate(completed, start=1): - if getattr(self, "state", "") == STATE_REVALIDATING_INHERITED: - total = max(1, len(completed)) - self.base_import_progress.update( - { - "current_task": task_key, - "completed_tasks": task_index - 1, - "total_tasks": len(completed), - "fraction": (task_index - 1) / total, - } - ) - _publish_import_status(self) - spec = next( - ( - item.spec - for item in self.sweep_items - if item.spec.key == task_key - ), - None, - ) - failures = ( - G20ThreeCameraCalibrationNode._provisional_fit_failures( - self, spec, include_view_validity=False - ) - if spec is not None - else [{"metric": "missing_task_spec"}] - ) - if failures: - dropped.append( - { - "task": task_key, - "failures": [ - { - key: value - for key, value in failure.items() - if key in {"joint", "metric", "actual", "limit"} - } - for failure in failures[:6] - ], - } - ) - # Make the rejection visible before checking later tasks. - # Some fits intentionally depend on an earlier reference - # task (for example every non-reference finger roll uses the - # pinky roll direction). Delaying this clear until the end - # let a dependent task pass with records that were about to - # be removed, producing a checkpoint that failed only on the - # next restart. - discard_task_records(task_key) - if allow_sparse: - continue - break - accepted.append(task_key) - if getattr(self, "state", "") == STATE_REVALIDATING_INHERITED: - self.base_import_progress.update( - { - "current_task": "", - "completed_tasks": len(completed), - "total_tasks": len(completed), - "fraction": 1.0, - } - ) - _publish_import_status(self) - if dropped and not allow_sparse: - accepted_set = set(accepted) - for task_key in completed: - if task_key not in accepted_set: - discard_task_records(task_key) - if isinstance(completed, tuple): - accepted = tuple(accepted) - return accepted, dropped - - def _start_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state != STATE_WAIT_START: - response.success = False - response.message = f"not ready: state={self.state} reason={self.reason}" - return response - if not self.commands_enabled: - response.success = False - response.message = "commands_enabled=false; preview cannot move the hand" - return response - if self.command_publisher.get_subscription_count() < 1: - response.success = False - response.message = "hand SDK is not subscribed to the command topic" - return response - if self.setting_publisher.get_subscription_count() < 1: - response.success = False - response.message = "hand SDK is not subscribed to the setting topic" - return response - if len(self.get_publishers_info_by_topic(self.command_topic)) > 1: - response.success = False - response.message = "another node is publishing hand commands" - return response - self.started = True - self.startup_baseline_recovered = False - self.resumed_task_keys = () - self.resume_source_session = "" - self.resume_checkpoint_pending = bool( - self.resume_raw_samples_path is not None - ) - self.resume_position_policy = ( - "pending_new_start_pose_check" - if self.resume_checkpoint_pending - else "not_requested" - ) - self.resume_position_changed_views = () - self.resume_position_unverifiable_views = () - self.resume_position_drift_by_view_px = {} - self.sweep_items = [] - self.pnp_task_spec = None - selected_sweep_specs = list(self.profile.sweep_specs) - if self.standalone_thumb_calibration: - standalone_task_keys = set(self.recalibration_task_keys) - selected_sweep_specs = [ - spec - for spec in selected_sweep_specs - if spec.key in standalone_task_keys - ] - if self.cross_view_roll_diagnostic_finger: - selected_sweep_specs = [ - spec - for spec in selected_sweep_specs - if spec.key - == f"{self.cross_view_roll_diagnostic_finger}_roll_multiview" - ] - if len(selected_sweep_specs) != 1: - raise RuntimeError( - "cross-view diagnostic could not resolve multiview task" - ) - selected_profile = replace( - self.profile, - sweep_specs=tuple(selected_sweep_specs), - ) - self.sweep_items.extend( - _build_sweep_plan(selected_profile, self.repetitions) - ) - self.sweep_index = 0 - self.retry_sweep_spec = None - self.retry_resume_index = None - self.retry_sweep_items.clear() - self.retry_joint_names.clear() - self.retry_source_failure_task_key = None - self.retry_source_task_keys = () - self.retry_cycle_override.clear() - self.retry_preserve_pnp_continuity = False - self.active_sweep_is_fit_retry = False - self.fit_failure = {} - self.fit_failure_history_by_task.clear() - self.cross_view_roll_diagnostic_result = {} - self.motion_stall_details.clear() - self.sweep_attempts = { - _sweep_storage_key(spec): 1 for spec in self.profile.sweep_specs - } - self.sweep_retry_counts.clear() - self.motion_retry_counts.clear() - self.validation_retry_counts.clear() - self.precheck_speed_metrics.clear() - self.formal_speed_scales.clear() - self.records_by_joint = { - name: [] for name in self.profile.record_joints - } - self.baseline_records_by_joint = { - name: [] for name in self.profile.record_joints - } - self.command_records_by_joint = { - name: [] for name in self.profile.record_joints - } - self.palm_axis_records_by_source = { - observer.source_name: [] - for observer in self.profile.palm_axis_observers - } - G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self) - self.combination_validation_items.clear() - self.combination_validation_index = 0 - self.active_combination_validation = None - self.combination_validation_completed = False - self.combination_tag_mounts.clear() - self.combination_tag_observation_counts.clear() - self.combination_tag_validation_counts.clear() - self.combination_position_errors_m.clear() - self.combination_orientation_errors_rad.clear() - for frames in self.combination_validation_frames_buffer.values(): - frames.clear() - self.completed_payload = None - self.sweep_frames.clear() - self.sweep_baseline_frames.clear() - self.sweep_baseline_pending = False - self.sweep_baseline_hold_since = None - self.sweep_start_frames.clear() - self.carried_sweep_start_frames.clear() - append_jsonl( - self.raw_path, - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "model": self.model, - "hand_type": self.hand_type, - "tag_layout": self.profile.layout_id, - "sample_schema_version": self.sample_schema_version, - "recalibration_scope": self.recalibration_scope, - "recalibration_task_keys": list( - self.recalibration_task_keys - ), - "command_names": list(_command_names(self)), - # Retained only as an old-session serialization token. Live - # decisions use the typed motion/measurement policies. - "capabilities": sorted( - getattr( - getattr( - getattr(self, "calibration_profile", None), - "artifacts", - None, - ), - "session_compatibility_tokens", - frozenset(self.profile.capabilities), - ) - ), - "reference_finger": self.profile.reference_finger, - "view_tags": { - view: dict(tags) - for view, tags in self.profile.view_tags.items() - }, - "palm_axis_observers": _palm_axis_observer_schema( - self.profile - ), - "tag_family": "36h11", - "tag_size_m": float(self.tag_size_m), - "tag_sizes_m_by_id": { - str(tag_id): float(self.tag_sizes_m_by_id[tag_id]) - for tag_id in sorted(self.tag_sizes_m_by_id) - }, - "baseline_command_u8": [ - int(value) for value in self.baseline_command - ], - "camera_extrinsics_file": str(self.camera_extrinsics_file), - "source_urdf_path": str(self.source_urdf_path), - "source_urdf_sha256": _file_sha256(self.source_urdf_path), - "curve_input_domain": ( - "requested_command_u8" - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else "command_u8" - ), - "baseline_hysteresis_source": ( - "dedicated_mid_sweep_hold" - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else "trajectory_endpoint" - ), - "directional_zero_policy": ( - "canonical_decreasing_255_to_127" - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else "independent_direction_rezero" - ), - "thumb_ip_pnp_coupling": { - "usage": "candidate_prior_with_visual_fallback", - }, - "cross_view_roll_diagnostic_finger": ( - self.cross_view_roll_diagnostic_finger - ), - "training_cycles": list(range(self.repetitions - 1)), - "validation_cycle": self.repetitions - 1, - "adaptive_formal_speed": { - "enabled": bool(self.adaptive_formal_speed_enabled), - "maximum_scale": float( - self.adaptive_formal_speed_max_scale - ), - "minimum_bins": int( - self.adaptive_formal_speed_minimum_bins - ), - "maximum_bin_gap": int( - self.adaptive_formal_speed_maximum_bin_gap - ), - }, - }, - ) - try: - if self.resume_raw_samples_path is not None: - if not self.resume_raw_samples_path.is_file(): - raise RuntimeError( - "resume raw samples do not exist: " - f"{self.resume_raw_samples_path}" - ) - self.resume_source_session = ( - self.resume_raw_samples_path.parent.name - ) - self.base_import_progress = { - "phase": "waiting_for_new_start_pose_reference", - "records_read": 0, - "bytes_read": 0, - "total_bytes": int( - self.resume_raw_samples_path.stat().st_size - ), - "fraction": 0.0, - } - except Exception as error: - self.started = False - self.resume_checkpoint_pending = False - response.success = False - response.message = f"CFG-RESUME-009:{error}" - return response - self._begin_return_baseline("startup_tag_preflight") - response.success = True - response.message = ( - "three-camera calibration started" - if not self.resume_checkpoint_pending - else ( - "three-camera calibration started; checkpoint will be " - "verified after the new start-pose reference is locked" - ) - ) - return response - - def _pause_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state in {STATE_COMPLETE, STATE_ABORTED, STATE_PREFLIGHT, STATE_WAIT_START}: - response.success = False - response.message = f"cannot pause from {self.state}" - return response - self._pause("operator_pause") - response.success = True - response.message = "calibration paused and current pose held" - return response - - def _resume_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state != STATE_PAUSED: - response.success = False - response.message = "calibration is not paused" - return response - if self.paused_reason == "zero_model_validation_failed": - response.success = False - response.message = ( - "zero/URDF model validation failed; samples are retained but " - "resume cannot repair this result by rescanning. Inspect the " - "reported zero guards and start a new session after correcting " - "the camera/tag/URDF geometry" - ) - return response - if self.paused_reason == "fixed_base_reference_moved": - response.success = False - response.message = ( - "the locked top base reference moved during acquisition; " - "start a new session so preflight can establish a new " - "reference before any motion" - ) - return response - if self.paused_reason == "cross_view_roll_diagnostic_complete": - response.success = False - response.message = ( - "cross-view roll diagnostic is complete and URDF publication " - "is locked; inspect the front/side result and start a formal " - "session only after choosing the mechanical or vision remedy" - ) - return response - now = time.monotonic() - if not self._resume_preflight_ready(now): - response.success = False - required = ",".join(self._active_views_for_resume()) - response.message = ( - f"resume preflight is not ready; required_views={required}" - ) - return response - for view in self._active_views_for_resume(): - runtime = self.views[view] - self._reset_view_trackers(runtime) - runtime.pnp_invalid_since = None - self.sweep_frames.clear() - self.sweep_start_frames.clear() - self.validation_frames_buffer.clear() - if self.retry_sweep_spec is not None: - self._prepare_failed_sweep_retry() - self._begin_return_baseline("resume_sweep") - response.success = True - response.message = ( - "calibration resumed; only the failed six-direction joint " - "sweep will restart" - ) - return response - if self.active_sweep is not None and self.sweep_index < len(self.sweep_items): - self._begin_return_baseline("resume_sweep") - elif self.active_combination_validation is not None: - item = self.active_combination_validation - active = { - "kind": "combination_validation", - "pose_name": item.name, - "label_zh": item.label_zh, - "pose_index": self.combination_validation_index + 1, - "pose_total": len(self.combination_validation_items), - "requested_command_u8": list(item.command_u8), - "valid_frames_by_view": { - view: len(frames) - for view, frames in self.combination_validation_frames_buffer.items() - }, - "automatic_retry_count": self.validation_retry_counts.get( - ("combination", self.combination_validation_index), 0 - ), - "automatic_retry_limit": self.automatic_sweep_retry_limit, - } - elif self.active_validation is not None: - self._begin_return_baseline("resume_validation") - else: - self._begin_return_baseline("next_sweep") - response.success = True - response.message = "calibration resumed; active step will restart" - return response - - def _abort_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - self._publish_speed_profile(self._normal_speed_profile()) - unsafe_to_return = bool( - self.motion_stall_details - or "stall" in str(self.reason) - or len(self.latest_state_u8) != 20 - or time.monotonic() - self.last_state_at > 1.0 - ) - if unsafe_to_return: - self._publish_hold_current() - self.state = STATE_ABORTED - self.reason = "operator_abort_held_current_for_safety" - else: - self.abort_original_reason = str(self.reason or "operator_abort") - self._begin_return_baseline("abort") - response.success = True - response.message = ( - "calibration aborted and current pose held" - if unsafe_to_return - else "calibration stopping after safe baseline return" - ) - return response - - def _publish_command(self, values: list[int]) -> None: - if not self.commands_enabled: - return - message = JointState() - message.header.stamp = self.get_clock().now().to_msg() - message.name = list(_command_names(self)) - message.position = [float(value) for value in values] - self.command_publisher.publish(message) - - def _publish_speed_profile(self, speeds: list[int]) -> None: - if not self.commands_enabled: - return - profile = tuple(int(value) for value in speeds) - if profile == self.commanded_speed_profile: - return - message = String() - message.data = json.dumps( - { - "setting_cmd": "set_speed", - "params": { - "hand_type": self.hand_type, - "speed": list(profile), - }, - } - ) - self.setting_publisher.publish(message) - self.commanded_speed_profile = profile - self.speed_commanded_at = time.monotonic() - - def _normal_speed_profile(self) -> list[int]: - return [self.normal_calibration_speed] * 5 - - def _transition_speed_profile( - self, target_command: Sequence[int] - ) -> list[int]: - """Use conservative joint-class speeds for one safe pose waypoint.""" - speeds = list(self._normal_speed_profile()) - current = getattr(self, "latest_state_u8", ()) - if len(current) != 20 or len(target_command) != 20: - return speeds - roll_speed = int( - getattr(self, "index_roll_calibration_speed", min(speeds)) - ) - flex_speed = int( - getattr(self, "index_flex_calibration_speed", min(speeds)) - ) - for motor, (actual, target) in enumerate( - zip(current, target_command) - ): - if abs(float(actual) - float(target)) <= 0.5: - continue - if motor in range(6, 10): - slot = motor - 5 - speeds[slot] = min(speeds[slot], roll_speed) - elif motor in range(1, 5): - slot = motor - speeds[slot] = min(speeds[slot], flex_speed) - elif motor in range(16, 20): - slot = motor - 15 - speeds[slot] = min(speeds[slot], flex_speed) - return speeds - - @staticmethod - def _speed_slot_for_spec(spec: SweepSpec) -> int: - motor = int(spec.motor_index) - if motor in {0, 5, 10, 15}: - return 0 - if motor in range(1, 5): - return motor - if motor in range(6, 10): - return motor - 5 - if motor in range(16, 20): - return motor - 15 - raise ValueError(f"unsupported G20 calibration motor: {motor}") - - def _base_speed_profile_for_spec(self, spec: SweepSpec) -> list[int]: - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - return build_calibration_speed_profile( - spec, - normal_speed=self.normal_calibration_speed, - index_roll_speed=self.index_roll_calibration_speed, - index_flex_speed=self.index_flex_calibration_speed, - profile=profile, - ) - - def _speed_profile_for_spec(self, spec: SweepSpec) -> list[int]: - speeds = G20ThreeCameraCalibrationNode._base_speed_profile_for_spec( - self, spec - ) - active = getattr(self, "active_sweep", None) - precheck = bool(active is not None and active.precheck) - fit_retry = bool(getattr(self, "active_sweep_is_fit_retry", False)) - if not precheck and not fit_retry: - scale = float( - getattr(self, "formal_speed_scales", {}).get(spec.key, 1.0) - ) - slot = G20ThreeCameraCalibrationNode._speed_slot_for_spec(spec) - speeds[slot] = min( - 255, int(math.floor(float(speeds[slot]) * scale + 1e-9)) - ) - retry = 0 - if active is not None: - key = ( - _sweep_storage_key(active.spec), - active.cycle, - active.direction, - ) - retry = self.sweep_retry_counts.get(key, 0) - if retry: - scale = self.calibration_engine.retry_speed(1.0, 2) - speeds = [ - max(self.retry_minimum_speed, int(round(speed * scale))) - for speed in speeds - ] - return speeds - - def _record_precheck_speed_metric( - self, - item: SweepItem, - *, - bin_count: int, - maximum_bin_gap: int, - valid_frames: int, - ) -> None: - """Use the accepted low-speed precheck to bound formal-scan speed.""" - key = ( - _sweep_storage_key(item.spec), item.cycle, item.direction - ) - retry = int(getattr(self, "sweep_retry_counts", {}).get(key, 0)) - base_speeds = ( - G20ThreeCameraCalibrationNode._base_speed_profile_for_spec( - self, item.spec - ) - ) - actual_speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec( - self, item.spec - ) - slot = G20ThreeCameraCalibrationNode._speed_slot_for_spec(item.spec) - base_speed = int(base_speeds[slot]) - actual_speed = int(actual_speeds[slot]) - acquisition_scale = ( - 1.0 - if base_speed <= 0 - else float(actual_speed) / float(base_speed) - ) - metrics = getattr(self, "precheck_speed_metrics", None) - if metrics is None: - self.precheck_speed_metrics = {} - metrics = self.precheck_speed_metrics - by_direction = metrics.setdefault(item.spec.key, {}) - by_direction[item.direction] = { - "bin_count": int(bin_count), - "maximum_bin_gap": int(maximum_bin_gap), - "valid_frames": int(valid_frames), - "retry": retry, - "acquisition_speed": actual_speed, - "acquisition_speed_scale": acquisition_scale, - } - required = { - DIRECTION_DECREASING, - DIRECTION_INCREASING, - } - if not required.issubset(by_direction): - return - - profile = _node_profile(self) - configured_enabled = bool( - getattr(self, "adaptive_formal_speed_enabled", False) - ) - # G20 right repeatability is evaluated across independent sessions. - # Letting camera-dependent precheck density alter the physical scan - # speed changes velocity lag and backlash, so two runs of the same hand - # no longer measure the same process. Keep the reviewed base speed for - # every product task; adaptive speed remains available to legacy tools. - stability_speed_lock = profile.layout_id == G20_RIGHT_19_LAYOUT - enabled = configured_enabled and not stability_speed_lock - # MCP roll is both the fastest native mechanism and the one guarded by - # the strict 0.5-degree baseline backlash limit. Field data at speed - # 7 exceeded that limit after a clean speed-5 precheck, so sampling - # density alone is not sufficient evidence to accelerate roll. - eligible = int(item.spec.motor_index) not in range(6, 10) - selected_scale = 1.0 - if enabled and eligible and base_speed > 0: - caps: list[float] = [] - target_bins = int(self.adaptive_formal_speed_minimum_bins) - target_gap = int(self.adaptive_formal_speed_maximum_bin_gap) - for direction in sorted(required): - metric = by_direction[direction] - observed_scale = float(metric["acquisition_speed_scale"]) - caps.append( - observed_scale - * float(metric["bin_count"]) - / float(target_bins) - ) - caps.append( - observed_scale - * float(target_gap) - / float(max(1, int(metric["maximum_bin_gap"]))) - ) - selected_scale = max( - 1.0, - min(float(self.adaptive_formal_speed_max_scale), *caps), - ) - selected_speed = min( - 255, - int(math.floor(float(base_speed) * selected_scale + 1e-9)), - ) - if selected_speed <= base_speed or base_speed <= 0: - selected_speed = base_speed - selected_scale = 1.0 - else: - selected_scale = float(selected_speed) / float(base_speed) - scales = getattr(self, "formal_speed_scales", None) - if scales is None: - self.formal_speed_scales = {} - scales = self.formal_speed_scales - scales[item.spec.key] = selected_scale - raw_path = getattr(self, "raw_path", None) - if raw_path is not None: - append_jsonl( - raw_path, - { - "kind": "task_formal_speed_selected", - "task_name": item.spec.key, - "motor_index": int(item.spec.motor_index), - "enabled": enabled, - "configured_enabled": configured_enabled, - "stability_speed_lock": stability_speed_lock, - "eligible": eligible, - "ineligible_reason": ( - "g20_right_deterministic_acquisition_speed" - if stability_speed_lock - else "roll_baseline_hysteresis_sensitive" - if configured_enabled and not eligible - else "" - ), - "base_speed": base_speed, - "formal_speed": selected_speed, - "formal_speed_scale": round(selected_scale, 6), - "minimum_retained_bins": int( - self.adaptive_formal_speed_minimum_bins - ), - "maximum_retained_bin_gap": int( - self.adaptive_formal_speed_maximum_bin_gap - ), - "precheck": { - direction: dict(by_direction[direction]) - for direction in sorted(required) - }, - }, - ) - - def _active_endpoint_hold_seconds(self) -> float: - if self.active_sweep is None: - return float(self.endpoint_hold_seconds) - if self.active_sweep.precheck: - return max( - float(self.endpoint_hold_seconds), - float(self.task_precheck_hold_seconds), - ) - key = ( - _sweep_storage_key(self.active_sweep.spec), - self.active_sweep.cycle, - self.active_sweep.direction, - ) - retry = self.sweep_retry_counts.get(key, 0) - if retry <= 0: - return float(self.endpoint_hold_seconds) - return float( - self.retry_endpoint_hold_seconds[ - min(retry, len(self.retry_endpoint_hold_seconds)) - 1 - ] - ) - - def _publish_hold_current(self) -> None: - if len(self.latest_state_u8) != 20: - return - values = [ - int(np.clip(np.rint(value), 0, 255)) - for value in self.latest_state_u8 - ] - self._publish_command(values) - - def _snap_reached_state_to_command( - self, - current_state: Sequence[float], - target_command: Sequence[int], - ) -> tuple[int, ...]: - """Remove no-op transition steps already inside feedback tolerance.""" - if len(current_state) != 20 or len(target_command) != 20: - raise ValueError("state and target command must contain 20 values") - result = [ - int(np.clip(np.rint(value), 0, 255)) for value in current_state - ] - controlled = { - int(spec.motor_index) for spec in self.profile.joint_specs.values() - } - for index in controlled: - target = int(target_command[index]) - if abs(float(current_state[index]) - target) <= ( - G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, target - ) - ): - result[index] = target - return tuple(result) - - def _begin_return_baseline(self, after: str) -> None: - self.baseline_after = str(after) - if str(after) not in {"next_cycle", "resume_sweep"}: - # A mechanical task boundary is also the PnP ownership boundary. - # Same-task cycle resets and localized fit recovery retain it. - self.pnp_task_spec = None - target_command = ( - G20ThreeCameraCalibrationNode._return_command_for_transition( - self, after - ) - ) - current_state = getattr(self, "latest_state_u8", ()) - if len(current_state) != 20: - current_state = target_command - else: - current_state = self._snap_reached_state_to_command( - current_state, target_command - ) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - self.return_waypoints = deque( - build_calibration_return_waypoints( - target_command, - current_command=current_state, - profile=profile, - anchor_roll_motor=( - G20ThreeCameraCalibrationNode._return_anchor_roll_motor( - self - ) - ), - parallel=self.parallel_pose_transitions, - ) - ) - self.return_command_u8 = self.return_waypoints.popleft() - self.position_hold_since = None - self.motion_stage_started_at = time.monotonic() - getattr(self, "motion_stall_details", {}).clear() - self._reset_motion_progress( - self.motion_stage_started_at, self._baseline_error_u8() - ) - self.state = STATE_RETURN_BASELINE - self.reason = ( - "holding_same_finger_clearance_before_next_task" - if str(after) == "next_task_same_finger" - else f"return_baseline_before_{after}" - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, self.return_command_u8 - ) - ) - self._publish_command(list(self.return_command_u8)) - - def _return_anchor_roll_motor(self) -> int | None: - """Resolve the finger that must leave a fan pose first.""" - retry_items = getattr(self, "retry_sweep_items", []) - item = retry_items[0] if retry_items else None - if item is None: - items = getattr(self, "sweep_items", []) - index = int(getattr(self, "sweep_index", len(items))) - if index < len(items): - item = items[index] - if item is None: - item = getattr(self, "active_sweep", None) - if item is None: - return None - motor = int(item.spec.motor_index) - if motor in range(6, 10): - return motor - if motor in range(1, 5): - return motor + 5 - if motor in range(16, 20): - return motor - 10 - return None - - @staticmethod - def _four_finger_task_group(spec: SweepSpec) -> str | None: - """Return the finger whose consecutive tasks share one avoidance pose.""" - for joint_name in spec.joints: - finger = str(joint_name).split("_", 1)[0] - if finger in {"pinky", "ring", "middle", "index"}: - return finger - return None - - def _next_pending_sweep_item(self) -> SweepItem | None: - retry_items = getattr(self, "retry_sweep_items", []) - if retry_items: - return retry_items[0] - items = getattr(self, "sweep_items", []) - index = int(getattr(self, "sweep_index", len(items))) - return items[index] if index < len(items) else None - - def _advance_past_resumed_sweeps(self) -> None: - """Skip every independently revalidated task restored from disk.""" - completed = set(getattr(self, "resumed_task_keys", ()) or ()) - items = getattr(self, "sweep_items", []) - while ( - self.sweep_index < len(items) - and items[self.sweep_index].spec.key in completed - ): - self.sweep_index += 1 - - def _transition_after_completed_spec(self, spec: SweepSpec) -> str: - """Keep clearance parked between consecutive tasks of one finger.""" - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - next_item = G20ThreeCameraCalibrationNode._next_pending_sweep_item(self) - current_group = ( - G20ThreeCameraCalibrationNode._four_finger_task_group(spec) - ) - next_group = ( - None - if next_item is None - else G20ThreeCameraCalibrationNode._four_finger_task_group( - next_item.spec - ) - ) - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and current_group is not None - and current_group == next_group - ): - return "next_task_same_finger" - return "next_sweep" - - def _return_command_for_transition(self, after: str) -> tuple[int, ...]: - """Choose a global or task-local safe transition target.""" - if str(after) in {"next_cycle", "next_task_same_finger"}: - next_item = G20ThreeCameraCalibrationNode._next_pending_sweep_item( - self - ) - if next_item is None: - return tuple(self.baseline_command) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - return tuple( - build_calibration_motion_command( - next_item.spec, - int(self.baseline_command[next_item.spec.motor_index]), - baseline=self.baseline_command, - profile=profile, - ) - ) - if str(after) == "next_sweep": - merged = ( - G20ThreeCameraCalibrationNode._cross_group_return_command( - self - ) - ) - if merged is not None: - return merged - if str(after) not in {"retry_sweep", "resume_sweep"}: - return tuple(self.baseline_command) - retry_items = getattr(self, "retry_sweep_items", []) - item = retry_items[0] if retry_items else getattr(self, "active_sweep", None) - if item is None: - return tuple(self.baseline_command) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - # A four-finger retry immediately repeats the same task. Preserve its - # reviewed occlusion-clearance pose and return only to the queued - # direction start; a global baseline would unfold the parked fingers - # and make the next preparation flex them again. The retry setup has - # already reset every contributing PnP tracker. - keep_task_clearance = bool( - item.spec.motor_index == 10 - or ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and G20ThreeCameraCalibrationNode._four_finger_task_group( - item.spec - ) - is not None - ) - ) - if not keep_task_clearance: - return tuple(self.baseline_command) - return tuple( - build_calibration_motion_command( - item.spec, - item.start_u8, - baseline=self.baseline_command, - profile=profile, - ) - ) - - def _cross_group_return_command(self) -> tuple[int, ...] | None: - """Keep already-positioned clearance motors through a finger change. - - Crossing from one finger's task group to the next used to unfold every - auxiliary motor back to baseline and then re-flex it for the next - avoidance pose. Motors the next group still wants at a non-baseline - value and that are already in place (within their endpoint deadband) - now simply stay put; everything else still returns to baseline, so the - reviewed roll-before-unfold and open-before-flex orderings keep - holding and only genuinely unused clearance unfolds. - """ - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - if profile.layout_id != G20_RIGHT_19_LAYOUT: - return None - next_item = G20ThreeCameraCalibrationNode._next_pending_sweep_item( - self - ) - if next_item is None: - return None - current_state = getattr(self, "latest_state_u8", ()) - if len(current_state) != 20: - return None - baseline = [int(value) for value in self.baseline_command] - # Match what the next preparation will actually command: the measured - # motor enters at its sweep start, not at the baseline value. - next_start = int( - getattr( - next_item, - "start_u8", - baseline[int(next_item.spec.motor_index)], - ) - ) - next_final = [ - int(value) - for value in build_calibration_motion_command( - next_item.spec, - next_start, - baseline=baseline, - profile=profile, - ) - ] - controlled = { - int(spec.motor_index) for spec in profile.joint_specs.values() - } - target = list(baseline) - for motor in sorted(controlled): - next_value = next_final[motor] - if next_value == baseline[motor]: - continue - actual = float(current_state[motor]) - tolerance = ( - G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, motor, next_value - ) - ) - if abs(actual - next_value) <= tolerance: - target[motor] = next_value - return tuple(target) - - def _current_return_command(self) -> tuple[int, ...]: - command = getattr(self, "return_command_u8", self.baseline_command) - return tuple(int(value) for value in command) - - def _baseline_reached(self) -> bool: - if len(self.latest_state_u8) != 20: - return False - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - return_command = G20ThreeCameraCalibrationNode._current_return_command( - self - ) - indices = sorted( - {spec.motor_index for spec in profile.joint_specs.values()} - ) - return all( - abs(float(self.latest_state_u8[index]) - return_command[index]) - <= G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, int(return_command[index]) - ) - for index in indices - ) - - def _baseline_error_details(self) -> dict[str, float | int | str]: - if len(self.latest_state_u8) != 20: - return { - "stage": "return_baseline", - "motor_index": -1, - "target_u8": float("nan"), - "actual_u8": float("nan"), - "error_u8": float("inf"), - "tolerance_u8": float(self.endpoint_tolerance_u8), - } - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - return_command = G20ThreeCameraCalibrationNode._current_return_command( - self - ) - indices = sorted({spec.motor_index for spec in profile.joint_specs.values()}) - values: list[dict[str, float | int | str]] = [] - for index in indices: - target = float(return_command[index]) - actual = float(self.latest_state_u8[index]) - tolerance = ( - G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, int(target) - ) - ) - values.append( - { - "stage": "return_baseline", - "motor_index": int(index), - "target_u8": target, - "actual_u8": actual, - "error_u8": abs(actual - target), - "tolerance_u8": tolerance, - } - ) - outside = [ - item - for item in values - if float(item["error_u8"]) > float(item["tolerance_u8"]) - ] - candidates = outside or values - return max( - candidates, - key=lambda item: ( - float(item["error_u8"]) - float(item["tolerance_u8"]), - float(item["error_u8"]), - ), - ) - - def _baseline_error_u8(self) -> float: - if len(self.latest_state_u8) != 20: - return float("inf") - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - return_command = G20ThreeCameraCalibrationNode._current_return_command( - self - ) - indices = {spec.motor_index for spec in profile.joint_specs.values()} - return max( - abs( - float(self.latest_state_u8[index]) - - float(return_command[index]) - ) - for index in indices - ) - - def _command_vector_reached(self, command: Sequence[int]) -> bool: - if len(self.latest_state_u8) != 20 or len(command) != 20: - return False - controlled = { - int(spec.motor_index) for spec in self.profile.joint_specs.values() - } - return all( - abs(float(self.latest_state_u8[index]) - float(command[index])) - <= G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, int(command[index]) - ) - for index in controlled - ) - - def _command_vector_error_u8(self, command: Sequence[int]) -> float: - if len(self.latest_state_u8) != 20 or len(command) != 20: - return float("inf") - controlled = { - int(spec.motor_index) for spec in self.profile.joint_specs.values() - } - return max( - abs(float(self.latest_state_u8[index]) - float(command[index])) - for index in controlled - ) - - def _command_vector_error_details( - self, command: Sequence[int], context: str - ) -> dict[str, float | int | str]: - """Identify the actual controlled motor blocking one safe waypoint.""" - if len(self.latest_state_u8) != 20 or len(command) != 20: - return { - "stage": str(context), - "motor_index": -1, - "target_u8": float("nan"), - "actual_u8": float("nan"), - "error_u8": float("inf"), - "tolerance_u8": float(self.endpoint_tolerance_u8), - } - controlled = sorted( - {int(spec.motor_index) for spec in self.profile.joint_specs.values()} - ) - values: list[dict[str, float | int | str]] = [] - for index in controlled: - target = float(command[index]) - actual = float(self.latest_state_u8[index]) - tolerance = G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, int(target) - ) - values.append( - { - "stage": str(context), - "motor_index": index, - "target_u8": target, - "actual_u8": actual, - "error_u8": abs(actual - target), - "tolerance_u8": float(tolerance), - } - ) - outside = [ - item - for item in values - if float(item["error_u8"]) > float(item["tolerance_u8"]) - ] - return max( - outside or values, - key=lambda item: ( - float(item["error_u8"]) - float(item["tolerance_u8"]), - float(item["error_u8"]), - ), - ) - - def _motion_command_reached( - self, - spec: SweepSpec, - command_u8: int, - state_u8: tuple[float, ...] | None = None, - ) -> bool: - """Check the swept motor and all clearance motors together.""" - state = self.latest_state_u8 if state_u8 is None else state_u8 - if len(state) != 20: - return False - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - expected = build_calibration_motion_command( - spec, - command_u8, - baseline=self.baseline_command, - profile=profile, - ) - indices = {spec.motor_index} - indices.update(calibration_auxiliary_commands(spec, profile=profile)) - return all( - abs(float(state[index]) - expected[index]) - <= ( - self._endpoint_tolerance_for_spec(spec, command_u8) - if index == spec.motor_index - else G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, expected[index] - ) - ) - for index in indices - ) - - def _steady_checkpoint_reached( - self, - spec: SweepSpec, - command_u8: int, - state_u8: tuple[float, ...] | None = None, - ) -> bool: - """Accept a settled command/feedback offset without hiding a stall. - - The checkpoint exists specifically to identify requested-command to - feedback/angle offsets, so applying the ordinary ±2-u8 equality gate - here is circular. Only the swept motor gets the bounded calibration - deadband; every clearance motor keeps its normal strict tolerance. - """ - state = self.latest_state_u8 if state_u8 is None else state_u8 - if len(state) != 20: - return False - expected = build_calibration_motion_command( - spec, - command_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - swept_tolerance = max( - float(self.steady_checkpoint_command_feedback_tolerance_u8), - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - self, spec, command_u8 - ), - ) - if ( - abs(float(state[spec.motor_index]) - expected[spec.motor_index]) - > swept_tolerance - ): - return False - return all( - abs(float(state[index]) - expected[index]) - <= G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, expected[index] - ) - for index in calibration_auxiliary_commands( - spec, profile=self.profile - ) - ) - - def _steady_checkpoint_feedback_is_stable( - self, - spec: SweepSpec, - frames: Sequence[FrameObservation], - ) -> bool: - """Require the held feedback range to be small before recording it.""" - if len(frames) < 3: - return False - values = [ - float(frame.state_u8[spec.motor_index]) for frame in frames - ] - return bool( - max(values) - min(values) - <= float(self.steady_checkpoint_maximum_feedback_range_u8) - ) - - def _motion_command_error_u8( - self, - spec: SweepSpec, - command_u8: int, - state_u8: tuple[float, ...] | None = None, - ) -> float: - """Return the worst swept/auxiliary motor error for stall detection.""" - state = self.latest_state_u8 if state_u8 is None else state_u8 - if len(state) != 20: - return float("inf") - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - expected = build_calibration_motion_command( - spec, - command_u8, - baseline=self.baseline_command, - profile=profile, - ) - indices = {spec.motor_index} - indices.update(calibration_auxiliary_commands(spec, profile=profile)) - return max( - abs(float(state[index]) - float(expected[index])) - for index in indices - ) - - def _motion_command_error_details( - self, - spec: SweepSpec, - command_u8: int, - context: str, - ) -> dict[str, float | int | str]: - """Identify the swept or clearance motor blocking a motion.""" - if len(self.latest_state_u8) != 20: - return { - "stage": str(context), - "motor_index": -1, - "target_u8": float(command_u8), - "actual_u8": float("nan"), - "error_u8": float("inf"), - "tolerance_u8": float(self.endpoint_tolerance_u8), - } - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - expected = build_calibration_motion_command( - spec, - command_u8, - baseline=self.baseline_command, - profile=profile, - ) - indices = {spec.motor_index} - indices.update(calibration_auxiliary_commands(spec, profile=profile)) - values: list[dict[str, float | int | str]] = [] - for index in indices: - target = float(expected[index]) - actual = float(self.latest_state_u8[index]) - tolerance = ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - self, spec, command_u8 - ) - if index == spec.motor_index - else G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, index, int(target) - ) - ) - values.append( - { - "stage": str(context), - "motor_index": int(index), - "target_u8": target, - "actual_u8": actual, - "error_u8": abs(actual - target), - "tolerance_u8": float(tolerance), - } - ) - outside = [ - item - for item in values - if float(item["error_u8"]) > float(item["tolerance_u8"]) - ] - return max( - outside or values, - key=lambda item: ( - float(item["error_u8"]) - float(item["tolerance_u8"]), - float(item["error_u8"]), - ), - ) - - def _reset_motion_progress(self, now: float, error_u8: float) -> None: - self.motion_progress_reference_error_u8 = float(error_u8) - self.motion_last_progress_at = float(now) - - def _pause_if_motion_stalled( - self, - *, - now: float, - error_u8: float, - context: str, - details: Mapping[str, Any] | None = None, - ) -> bool: - """Pause immediately when feedback stops moving toward its target.""" - error = float(error_u8) - if not math.isfinite(error): - return False - reference = float( - getattr(self, "motion_progress_reference_error_u8", float("inf")) - ) - last_progress = float(getattr(self, "motion_last_progress_at", now)) - minimum_progress = float( - getattr(self, "motor_stall_minimum_progress_u8", 1.0) - ) - if not math.isfinite(reference): - self._reset_motion_progress(now, error) - return False - if reference - error >= minimum_progress: - self._reset_motion_progress(now, error) - return False - motion_started_value = getattr(self, "motion_stage_started_at", None) - startup_grace = float( - getattr(self, "motor_stall_startup_grace_seconds", 1.0) - ) - if ( - motion_started_value is not None - and now - float(motion_started_value) < startup_grace - ): - return False - timeout = float(getattr(self, "motor_stall_timeout_seconds", 8.0)) - if now - last_progress < timeout: - return False - detail_payload = {} if details is None else dict(details) - reason_parts = ["motor_state_stalled", str(context)] - for key in ("motor_index", "target_u8", "actual_u8", "tolerance_u8"): - if key in detail_payload: - reason_parts.append(f"{key}={detail_payload[key]}") - reason_parts.append(f"timeout_seconds={timeout:.3f}") - reason_parts.append(f"error_u8={error:.3f}") - reason = ":".join(reason_parts) - self.motion_stall_details = { - "kind": "motion_stall", - "stage": str(context), - "error_u8": error, - "timeout_seconds": timeout, - **detail_payload, - } - append_jsonl( - self.raw_path, - { - "kind": "mechanical_motion_stall", - "context": context, - "error_u8": error, - "timeout_seconds": timeout, - "minimum_progress_u8": minimum_progress, - "state_u8": [float(value) for value in self.latest_state_u8], - "details": detail_payload, - }, - ) - self._pause(reason) - return True - - def _active_sweep_timeout_seconds(self) -> float: - """Scale retry timeout so a deliberately slower retry can finish.""" - if self.active_sweep is None: - return float(self.sweep_timeout_seconds) - key = ( - _sweep_storage_key(self.active_sweep.spec), - self.active_sweep.cycle, - self.active_sweep.direction, - ) - retry = self.sweep_retry_counts.get(key, 0) - if retry <= 0: - return float(self.sweep_timeout_seconds) - # unified_engine_v1 retries at the original speed. - return float(self.sweep_timeout_seconds) - - def _endpoint_tolerance_for_spec( - self, spec: SweepSpec, endpoint_u8: int - ) -> float: - """Return the measured feedback deadband for one sweep endpoint.""" - if spec.motor_index == 10 and int(endpoint_u8) == 0: - return float( - getattr( - self, - "thumb_yaw_zero_endpoint_tolerance_u8", - self.endpoint_tolerance_u8, - ) - ) - if spec.motor_index == 19 and int(endpoint_u8) == 0: - return float( - getattr( - self, - "pinky_pip_zero_endpoint_tolerance_u8", - self.endpoint_tolerance_u8, - ) - ) - return G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - self, spec.motor_index, int(endpoint_u8) - ) - - def _synchronised_endpoint_tolerance_for_spec( - self, spec: SweepSpec, endpoint_u8: int - ) -> float: - """Return the endpoint bin deadband for image-timestamped feedback. - - Motion completion deliberately keeps the physical endpoint deadband. - A synchronised image observation uses interpolated feedback at the - camera timestamp and can trail that latest feedback by a fraction of - one u8 count. One additional count prevents a reached endpoint from - being discarded solely at this continuous-to-integer bin boundary. - """ - mechanical_tolerance = self._endpoint_tolerance_for_spec( - spec, endpoint_u8 - ) - return mechanical_tolerance + float( - getattr(self, "synchronised_endpoint_tolerance_margin_u8", 1.0) - ) - - def _motor_endpoint_tolerance( - self, motor_index: int, endpoint_u8: int - ) -> float: - """Return a side/motor-specific feedback deadband when established.""" - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - if int(motor_index) == 10 and int(endpoint_u8) == 0: - return float( - getattr( - self, - "thumb_yaw_zero_endpoint_tolerance_u8", - 4.0, - ) - ) - if ( - profile.side == "right" - and int(motor_index) == 10 - and int(endpoint_u8) == 255 - ): - return float( - getattr( - self, - "right_thumb_yaw_255_endpoint_tolerance_u8", - 5.0, - ) - ) - if ( - profile.side == "right" - and int(motor_index) == 19 - and int(endpoint_u8) == 0 - ): - return float( - getattr( - self, - "pinky_pip_zero_endpoint_tolerance_u8", - 5.0, - ) - ) - return float(self.endpoint_tolerance_u8) - - def _requires_mid_sweep_baseline_hold(self, item: SweepItem) -> bool: - """Return whether this formal sweep crosses a non-endpoint zero. - - The four MCP-roll motors use command 127 as zero. Their zero-pose - hysteresis must be measured after settling at 127 from each direction, - not inferred from frames captured while the motor is moving through - 127. Endpoint-zero joints already have static start/end holds. - """ - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - if ( - profile.layout_id != G20_RIGHT_19_LAYOUT - or item.precheck - or item.cycle < 0 - ): - return False - baseline = int(self.baseline_command[item.spec.motor_index]) - return baseline not in {item.start_u8, item.target_u8} - - def _start_next_sweep(self) -> None: - carried_start_frames = list( - getattr(self, "carried_sweep_start_frames", ()) - ) - if hasattr(self, "carried_sweep_start_frames"): - self.carried_sweep_start_frames.clear() - retry_items = getattr(self, "retry_sweep_items", []) - if retry_items: - self.active_sweep = retry_items.pop(0) - self.active_sweep_is_fit_retry = True - else: - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self) - if self.sweep_index >= len(self.sweep_items): - self.active_sweep = None - self._begin_return_baseline("fit") - return - self.active_sweep = self.sweep_items[self.sweep_index] - self.active_sweep_is_fit_retry = False - item = self.active_sweep - profile = _node_profile(self) - if profile.layout_id == G20_RIGHT_19_LAYOUT: - self.pnp_task_spec = item.spec - is_fit_retry = bool( - getattr(self, "active_sweep_is_fit_retry", False) - ) - if _requires_pnp_tracker_reset_for_sweep( - profile, - item, - is_fit_retry=is_fit_retry, - preserve_retry_continuity=bool( - getattr(self, "retry_preserve_pnp_continuity", False) - ), - ): - reset_trackers = getattr(self, "_reset_view_trackers", None) - preserve_task_reference = _preserve_pnp_task_reference_for_sweep( - item, - is_fit_retry=is_fit_retry, - has_precheck_anchor=( - profile.layout_id == G20_RIGHT_19_LAYOUT - ), - ) - reset_views = set(_sweep_views(profile, item.spec)) - observer = next( - ( - candidate - for candidate in profile.palm_axis_observers - if candidate.task_name == item.spec.key - ), - None, - ) - if observer is not None: - reset_views.add(observer.view) - if is_fit_retry: - scoped = set(getattr(self, "retry_joint_names", set())) - if scoped: - reset_views = { - _node_profile(self).record_specs[name].view - for name in scoped - } - reset_views.discard(None) - if observer is not None: - reset_views.add(observer.view) - for view in reset_views: - runtime = getattr(self, "views", {}).get(view) - if runtime is None or reset_trackers is None: - continue - # A normal G20-right task enters here only for its first - # decreasing precheck. All formal cycles keep this exact - # frame-to-frame branch instead of independently selecting a - # new stable IPPE solution. A fit retry deliberately starts - # fresh but retains the task-relative anchor. - if preserve_task_reference: - reset_trackers( - runtime, preserve_task_reference=True - ) - else: - reset_trackers(runtime) - self._reset_view_pnp_diagnostics(runtime) - runtime.pnp_invalid_since = None - runtime.pnp_reset_count += 1 - raw_path = getattr(self, "raw_path", None) - if raw_path is not None: - append_jsonl( - raw_path, - { - "kind": "pnp_task_initialization", - "view": view, - "task_name": item.spec.key, - "motor_index": item.spec.motor_index, - "cycle": item.cycle, - "fit_retry": is_fit_retry, - "task_reference_preserved": ( - preserve_task_reference - ), - }, - ) - getattr(self, "sweep_frames", []).clear() - getattr(self, "sweep_baseline_frames", []).clear() - self.sweep_baseline_pending = False - self.sweep_baseline_hold_since = None - getattr(self, "sweep_start_frames", []).clear() - getattr(self, "sweep_start_frames", []).extend(carried_start_frames) - self.sweep_detection_total_frames = 0 - self.sweep_detection_valid_frames = 0 - self.sweep_detection_total_by_view = {} - self.sweep_detection_valid_by_view = {} - self.position_hold_since = None - self.motion_stage_started_at = time.monotonic() - current_state = getattr(self, "latest_state_u8", ()) - if len(current_state) != 20: - current_state = self.baseline_command - else: - preparation_target = build_calibration_motion_command( - item.spec, - item.start_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - current_state = self._snap_reached_state_to_command( - current_state, preparation_target - ) - self.preparation_waypoints = deque( - build_calibration_preparation_waypoints( - item.spec, - item.start_u8, - current_command=current_state, - baseline=self.baseline_command, - profile=self.profile, - parallel=self.parallel_pose_transitions, - ) - ) - self.preparation_command_u8 = self.preparation_waypoints.popleft() - self._reset_motion_progress( - self.motion_stage_started_at, - self._command_vector_error_u8(self.preparation_command_u8), - ) - self.state = STATE_PREPARE_SWEEP - self.reason = ( - f"prepare_{item.spec.view}_motor_{item.spec.motor_index}_" - f"cycle_{item.cycle}_{item.direction}" - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, self.preparation_command_u8 - ) - ) - self._publish_command(list(self.preparation_command_u8)) - - def _stage_immediate_reverse_start_frames( - self, completed: SweepItem, following: SweepItem - ) -> int: - """Carry a proven terminal endpoint into an immediate reverse pass.""" - carried = getattr(self, "carried_sweep_start_frames", None) - if carried is None: - self.carried_sweep_start_frames = [] - carried = self.carried_sweep_start_frames - carried.clear() - if ( - completed.spec != following.spec - or completed.target_u8 != following.start_u8 - or completed.direction == following.direction - ): - return 0 - tolerance = ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - self, following.spec, following.start_u8 - ) - ) - limit_per_view = max(1, int(getattr(self, "preflight_frames", 30))) - selected: list[FrameObservation] = [] - for view in _sweep_views(_node_profile(self), following.spec): - view_frames = [ - frame - for frame in self.sweep_frames - if frame.view == view - and abs( - float(frame.state_u8[following.spec.motor_index]) - - following.start_u8 - ) - <= tolerance - ] - selected.extend(view_frames[-limit_per_view:]) - selected.sort(key=lambda frame: frame.stamp_ns) - carried.extend(selected) - if selected and getattr(self, "raw_path", None) is not None: - append_jsonl( - self.raw_path, - { - "kind": "sweep_endpoint_frames_carried", - "task_name": completed.spec.key, - "from_cycle": completed.cycle, - "from_direction": completed.direction, - "to_cycle": following.cycle, - "to_direction": following.direction, - "endpoint_u8": following.start_u8, - "valid_frames": len(selected), - "valid_frames_by_joint": { - name: len(_frames_for_joint(selected, name)) - for name in following.spec.joints - }, - }, - ) - return len(selected) - - def _begin_active_sweep(self, now: float) -> None: - assert self.active_sweep is not None - self.sweep_frames.clear() - self.sweep_frames.extend(self.sweep_start_frames) - self.sweep_start_frames.clear() - if not hasattr(self, "sweep_baseline_frames"): - self.sweep_baseline_frames = [] - self.sweep_baseline_frames.clear() - self.sweep_baseline_pending = ( - G20ThreeCameraCalibrationNode._requires_mid_sweep_baseline_hold( - self, self.active_sweep - ) - ) - self.sweep_baseline_hold_since = None - self.sweep_started_at = now - self.sweep_last_valid_at = now - capture_views = _sweep_views( - _node_profile(self), self.active_sweep.spec - ) - self.sweep_last_valid_at_by_view = { - view: now for view in capture_views - } - self.sweep_endpoint_since = None - self.sweep_detection_total_frames = 0 - self.sweep_detection_valid_frames = 0 - self.sweep_detection_total_by_view = { - view: 0 for view in capture_views - } - self.sweep_detection_valid_by_view = { - view: 0 for view in capture_views - } - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - self.sweep_checkpoint_commands = deque( - _steady_checkpoint_commands(profile, self.active_sweep) - ) - self.sweep_checkpoint_mode = bool(self.sweep_checkpoint_commands) - self.sweep_checkpoint_target_u8 = None - self.sweep_checkpoint_hold_since = None - if not hasattr(self, "sweep_checkpoint_frames"): - self.sweep_checkpoint_frames = [] - self.sweep_checkpoint_frames.clear() - if self.sweep_checkpoint_commands: - # PREPARE_SWEEP already held the start endpoint long enough to be - # a steady command-domain observation. - if hasattr(self, "command_records_by_joint"): - self._record_command_checkpoint( - self.active_sweep, - self.active_sweep.start_u8, - list(self.sweep_frames), - ) - self._reset_motion_progress( - now, - self._motion_command_error_u8( - self.active_sweep.spec, self.active_sweep.target_u8 - ), - ) - self.state = STATE_SWEEP - self.reason = ( - "collecting_dedicated_baseline_hold" - if self.sweep_baseline_pending - else "collecting_timestamp_synchronised_tag_centres" - ) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - target_u8 = ( - int(self.baseline_command[self.active_sweep.spec.motor_index]) - if self.sweep_baseline_pending - else ( - self.sweep_checkpoint_commands.popleft() - if self.sweep_checkpoint_commands - else self.active_sweep.target_u8 - ) - ) - if not self.sweep_baseline_pending and self.sweep_checkpoint_mode: - self.sweep_checkpoint_target_u8 = int(target_u8) - self._publish_command( - build_calibration_motion_command( - self.active_sweep.spec, - target_u8, - baseline=self.baseline_command, - profile=profile, - ) - ) - - def _record_command_checkpoint( - self, - item: SweepItem, - requested_command_u8: int, - frames: Sequence[FrameObservation], - ) -> None: - """Persist one steady requested-command/feedback/vision observation.""" - motor = int(item.spec.motor_index) - record_joint_names = tuple(item.spec.joints) - if getattr(self, "active_sweep_is_fit_retry", False): - scoped = set(getattr(self, "retry_joint_names", set())) - if scoped: - record_joint_names = tuple( - name for name in item.spec.joints if name in scoped - ) - for joint_name in record_joint_names: - selected = _frames_for_joint(frames, joint_name) - if len(selected) < 3: - raise RuntimeError( - f"steady command checkpoint {joint_name} has fewer than 3 frames" - ) - feedback = float( - np.median([float(frame.state_u8[motor]) for frame in selected]) - ) - state = np.median( - np.asarray( - [frame.state_u8 for frame in selected], dtype=float - ), - axis=0, - ) - durable = canonical_sample_record({ - "kind": "steady_command_sample", - "attempt": self.sweep_attempts.get( - _sweep_storage_key(item.spec), 1 - ), - "task_name": item.spec.key, - "view": self.profile.record_specs[joint_name].view, - "joint": joint_name, - "motor_index": motor, - "cycle": item.cycle, - "direction": item.direction, - "requested_command_u8": int(requested_command_u8), - "feedback_u8": round(feedback, 6), - "image_stamp_ns": int( - np.median([frame.stamp_ns for frame in selected]) - ), - "relative_quaternion_xyzw": [ - float(value) - for value in robust_rotation_summary( - [ - frame.joint_quaternions_xyzw[joint_name] - for frame in selected - ] - )[0] - ], - "relative_translation_xyz_m": [ - float(value) - for value in np.median( - np.asarray( - [ - frame.joint_vectors_xyz_m[joint_name] - for frame in selected - ], - dtype=float, - ), - axis=0, - ) - ], - "image_relative_xy_px": [ - float(value) - for value in np.median( - np.asarray( - [ - frame.image_vectors_xy_px[joint_name] - for frame in selected - ], - dtype=float, - ), - axis=0, - ) - ], - "parent_pose_common": _robust_pose_payload( - [frame.parent_poses_common[joint_name] for frame in selected] - ), - "child_pose_common": _robust_pose_payload( - [frame.child_poses_common[joint_name] for frame in selected] - ), - "state_u8": [float(value) for value in state], - "valid_frames": len(selected), - }) - record = fitting_sample_record(durable) - self.command_records_by_joint[joint_name].append(record) - append_jsonl(self.raw_path, durable) - - def _publish_next_checkpoint(self, now: float) -> bool: - """Advance a first-round steady scan; return False at its endpoint.""" - if not self.sweep_checkpoint_commands: - self.sweep_checkpoint_target_u8 = None - # The final checkpoint is also the normal sweep endpoint. Leave - # checkpoint mode here so _advance can run its common endpoint - # hold/completion path on this and subsequent timer ticks. Keeping - # the mode latched made the next tick treat the intentionally - # cleared target as an internal error. - self.sweep_checkpoint_mode = False - return False - assert self.active_sweep is not None - target = int(self.sweep_checkpoint_commands.popleft()) - self.sweep_checkpoint_target_u8 = target - self.sweep_checkpoint_hold_since = None - self.sweep_checkpoint_frames.clear() - self.sweep_endpoint_since = None - self.motion_stage_started_at = now - self._reset_motion_progress( - now, self._motion_command_error_u8(self.active_sweep.spec, target) - ) - self._publish_command( - build_calibration_motion_command( - self.active_sweep.spec, - target, - baseline=self.baseline_command, - profile=self.profile, - ) - ) - return True - - def _sweep_spec_start_index(self, spec: SweepSpec) -> int: - return next( - index - for index, item in enumerate(self.sweep_items) - if item.spec == spec - ) - - def _fit_joint_records( - self, - joint_name: str, - records: list[dict[str, Any]], - *, - relaxed: bool = False, - ) -> JointCurveFit: - del relaxed - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - zero_command = int( - self.baseline_command[ - profile.record_specs[joint_name].motor_index - ] - ) - model_records = G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, joint_name, records - ) - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and ( - joint_name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS - or ( - joint_name.endswith("_mcp_roll") - and not joint_name.startswith("thumb_") - ) - ) - ): - return fit_joint_image_curve( - model_records, - maximum_radial_rms_px=( - self.image_trajectory_maximum_radial_rms_px - ), - maximum_radial_p95_px=( - self.image_trajectory_maximum_radial_p95_px - ), - minimum_radius_px=self.image_trajectory_minimum_radius_px, - minimum_arc_rad=self.trajectory_minimum_arc_rad, - ) - return fit_rotation_joint_curve( - model_records, - zero_command_u8=zero_command, - canonical_zero_direction=canonical_zero_direction( - profile, joint_name - ), - ) - - def _hysteresis_axis_for_fit( - self, - joint_name: str, - records: Sequence[Mapping[str, Any]], - fit: JointCurveFit, - ) -> Sequence[float]: - """Return a physical rotation axis for directional zero checks. - - End-on finger flexion uses its much more stable projected circle for - the dynamic curve. Baseline hysteresis is still a pose-domain check, - so fit a separate diagnostic rotation axis instead of accidentally - treating the image circle as a 3-D pose model. - """ - axis = fit.circle.get("axis_xyz") - if axis is not None: - return axis - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - zero_command = int( - self.baseline_command[ - profile.record_specs[joint_name].motor_index - ] - ) - diagnostic_fit = fit_rotation_joint_curve( - G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, joint_name, records - ), - zero_command_u8=zero_command, - canonical_zero_direction=canonical_zero_direction( - profile, joint_name - ), - ) - return diagnostic_fit.circle["axis_xyz"] - - def _records_with_baseline_holds( - self, - joint_name: str, - records: Sequence[Mapping[str, Any]], - ) -> list[dict[str, Any]]: - """Add settled mid-range zero poses to directional model fitting.""" - samples = [dict(record) for record in records] - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - if canonical_zero_direction(profile, joint_name) is None: - return samples - included_groups = { - (int(record["cycle"]), str(record["direction"])) - for record in samples - } - holds = getattr(self, "baseline_records_by_joint", {}).get( - joint_name, () - ) - samples.extend( - dict(record) - for record in holds - if ( - int(record["cycle"]), str(record["direction"]) - ) in included_groups - ) - return samples - - def _fit_axis_measurement_raw( - self, joint_name: str, cycle: int - ) -> JointAxisMeasurement: - constraint: Sequence[float] | None = None - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - zero_profile = getattr(self, "zero_profile", LEFT_ZERO_PROFILE) - upstream_joint = { - # These neighbouring axes are parallel in the fixed source URDF. - # A small planar Tag's monocular PnP orientation can have a stable - # field-dependent bias, so use the well-observed upstream axis - # direction and let each Tag-centre trajectory independently fit - # its physical axis line. This changes no URDF geometry and is - # valid only because the source-URDF axes are deliberately locked. - "thumb_mcp": "thumb_cmc_pitch", - "thumb_ip": "thumb_mcp", - **{ - f"{finger}_pip": f"{finger}_mcp_pitch" - for finger in ("index", "middle", "ring", "pinky") - }, - **{ - f"{finger}_dip": f"{finger}_pip" - for finger in ("index", "middle", "ring", "pinky") - }, - }.get(joint_name) - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and joint_name.endswith("_mcp_roll") - and not joint_name.endswith("_mcp_roll_side") - and not joint_name.startswith("thumb_") - ): - reference_roll = f"{profile.reference_finger}_mcp_roll" - if joint_name != reference_roll: - # The four finger roll axes are exactly parallel in the - # product URDF. The reference-finger task is acquired first, - # so reuse its measured common-frame direction for the other - # three fingers. This removes session-to-session planar-PnP - # branch tilt without changing any fitted axis-line position. - upstream_joint = reference_roll - if upstream_joint is not None: - # Resolve recursively so thumb_ip receives the already constrained - # thumb_mcp direction (and index_dip the constrained index_pip - # direction), rather than reintroducing the raw PnP orientation at - # the last passive joint. - upstream = ( - G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - self, upstream_joint, cycle - ) - ) - constraint = upstream.axis_common_xyz - spec = profile.record_specs[joint_name] - extrinsics = getattr(self, "extrinsics", None) - view_normal = None - if extrinsics is not None: - view_transform = extrinsics.transform(spec.view) - view_normal = view_transform[:3, :3] @ np.asarray( - [0.0, 0.0, 1.0], dtype=float - ) - measurement_records = ( - G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, - joint_name, - self.records_by_joint[joint_name], - ) - ) - measurement = fit_joint_axis_measurement( - joint_name, - measurement_records, - cycle=cycle, - zero_command_u8=int( - self.baseline_command[spec.motor_index] - ), - axis_common_constraint=constraint, - constrained_circle_joints=( - zero_profile.constrained_circle_joints - | ({joint_name} if joint_name.endswith("_side") else set()) - ), - view_normal_common_xyz=view_normal, - canonical_zero_direction=canonical_zero_direction( - profile, joint_name - ), - ) - sweep_spec = next( - sweep - for sweep in profile.sweep_specs - if joint_name in sweep.joints - ) - condition_command = build_calibration_motion_command( - sweep_spec, - int(self.baseline_command[spec.motor_index]), - baseline=self.baseline_command, - profile=profile, - ) - if profile.layout_id != G20_RIGHT_19_LAYOUT: - measurement = replace( - measurement, - condition_command_u8=tuple( - float(value) for value in condition_command - ), - ) - if extrinsics is None: - return measurement - return with_depth_free_axis_projection( - replace( - measurement, - view_normal_common_xyz=tuple( - float(value) for value in view_normal - ), - ), - extrinsics.transform(spec.view)[:3, 3], - ) - - def _fit_axis_measurement( - self, joint_name: str, cycle: int - ) -> JointAxisMeasurement: - """Fit one axis, fusing a separately accepted cross-view estimate.""" - primary = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - self, joint_name, cycle - ) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - validation_sources = profile.axis_validation_sources or {} - validation_name = validation_sources.get(joint_name) - if validation_name is None: - return primary - validation_records = self.records_by_joint.get(validation_name, []) - if not any( - int(record.get("cycle", -1)) == int(cycle) - for record in validation_records - ): - # Front roll is provisionally checked before the independent side - # task has been acquired. Fusion is allowed only in the final fit - # after both complete raw datasets have independently passed. - return primary - secondary = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - self, validation_name, cycle - ) - primary_axis = np.asarray(primary.axis_common_xyz, dtype=float) - secondary_axis = np.asarray(secondary.axis_common_xyz, dtype=float) - if float(primary_axis @ secondary_axis) < 0.0: - secondary_axis = -secondary_axis - axis_difference = math.acos( - float(np.clip(primary_axis @ secondary_axis, -1.0, 1.0)) - ) - primary_point = np.asarray(primary.point_common_xyz_m, dtype=float) - secondary_point = np.asarray(secondary.point_common_xyz_m, dtype=float) - line_distance = float( - np.linalg.norm( - np.cross(secondary_point - primary_point, primary_axis) - ) - ) - validation_spec = profile.record_specs[validation_name] - if validation_spec.zero_kind == "axis_cross_view_validation": - # This Tag is attached to the downstream PIP link. During MCP - # roll its orientation contains linkage/pitch coupling and is not - # an independent observation of the published MCP angle or axis - # direction. It remains the physical axis-line-position source; - # its own radial, line-repeatability, visibility and trajectory - # quality gates are all applied before reaching this point. - decision = ( - "diagnostic_only_gross_pose_disagreement_use_primary" - if axis_difference - > getattr( - self, - "cross_view_roll_maximum_axis_difference_rad", - math.radians(15.0), - ) - else "diagnostic_only_pose_disagreement_use_primary" - ) - G20ThreeCameraCalibrationNode._record_cross_view_roll_axis_diagnostic( - self, - joint_name, - cycle, - primary=primary, - secondary=secondary, - axis_difference_rad=axis_difference, - line_distance_m=line_distance, - decision=decision, - ) - extrinsics = getattr(self, "extrinsics", None) - if extrinsics is None or validation_spec.view is None: - raise ValueError( - f"{validation_name}: cross-view camera geometry is unavailable" - ) - primary_view = profile.record_specs[joint_name].view - if primary_view is None: - raise ValueError( - f"{joint_name}: primary point source has no view" - ) - point_camera_center = extrinsics.transform(primary_view)[:3, 3] - point_ray = primary_point - point_camera_center - interpretation_plane_normal = np.cross( - point_ray, primary_axis - ) - interpretation_plane_normal /= np.linalg.norm( - interpretation_plane_normal - ) - return replace( - primary, - point_common_xyz_m=tuple( - float(value) for value in primary.point_common_xyz_m - ), - axis_point_source=( - "front_interpretation_plane_cross_view_validated" - ), - pose_axis_line_rms_m=primary.pose_axis_line_rms_m, - pose_axis_line_source_joints=(joint_name,), - axis_point_camera_center_common_xyz_m=tuple( - float(value) - for value in point_camera_center - ), - axis_point_interpretation_plane_normal_common_xyz=tuple( - float(value) - for value in interpretation_plane_normal - ), - ) - # Monocular planar-tag orientation on the side view carries a - # systematic line-of-sight IPPE bias of several degrees through roll - # sweeps (tags tilted ~13-20 deg from the ray), which sub-pixel - # reprojection cannot expose. Session 20260820_105535 showed a - # stable 11.4 deg front/side disagreement from exactly this effect, - # so sub-degree cross-view agreement is not achievable at this - # geometry. Disagreement above the fusion gate now falls back to the - # trusted front-only axis with a recorded diagnostic instead of - # failing the joint; only a gross direction error (wrong-link or a - # loose Tag that changes the observed motion axis) still fails here. - gross_axis_limit = getattr( - self, - "cross_view_roll_maximum_axis_difference_rad", - math.radians(15.0), - ) - # Do not apply a universal front/side line-distance gate here. The - # front roll link rides the splay screw, so its pure-revolute pose fit - # is a displaced pseudo-line whose offset varies by finger (about - # 15 mm for pinky and 37 mm for ring on the same fixed setup). It is - # not the physical line ultimately published. Retain the distance in - # diagnostics; validate the selected side physical line through its - # own radial quality, cycle RMS and final URDF geometry instead. - if axis_difference > gross_axis_limit: - raise ValueError( - "cross_view_roll_axis_gross_disagreement:" - f"{math.degrees(axis_difference):.6f}deg," - f"{1000.0 * line_distance:.6f}mm" - ) - if ( - axis_difference > self.zero_maximum_axis_cycle_difference_rad - or line_distance > self.axis_maximum_pose_line_rms_m - ): - selected_direction = primary - decision = "skip_fusion_use_primary" - zero_profile = getattr(self, "zero_profile", LEFT_ZERO_PROFILE) - observer_name = next( - ( - observer - for observer, parent in zero_profile.axis_parent_joint.items() - if parent == joint_name - ), - None, - ) - observer_records = ( - [] - if observer_name is None - else self.records_by_joint.get(observer_name, []) - ) - if observer_records: - model = UrdfKinematicModel(self.source_urdf_path) - parent_axis, _ = model.axis_line( - joint_name, zero_offsets={}, joint_angles={} - ) - observer_axis, _ = model.axis_line( - observer_name, zero_offsets={}, joint_angles={} - ) - expected_cone = math.acos( - abs( - float( - np.clip(parent_axis @ observer_axis, -1.0, 1.0) - ) - ) - ) - - def cone_residual( - candidate_axis: np.ndarray, - measured_observer_axis: np.ndarray, - ) -> float: - measured_cone = math.acos( - abs( - float( - np.clip( - candidate_axis @ measured_observer_axis, - -1.0, - 1.0, - ) - ) - ) - ) - return abs(measured_cone - expected_cone) - - # A zero rotates the downstream axis around this parent and - # cannot change their mutual cone angle. Select the camera - # once for the complete repeated-sweep group. Per-cycle - # selection is invalid because a residual close to the gate - # can alternate views and turn their fixed systematic bias - # into a false cycle-axis spread. - group_cycles = tuple(range(int(self.repetitions))) - required_names = ( - joint_name, - validation_name, - observer_name, - ) - group_is_complete = all( - any( - int(record.get("cycle", -1)) == group_cycle - for record in self.records_by_joint.get(name, ()) - ) - for name in required_names - for group_cycle in group_cycles - ) - group_source = "primary" - if group_is_complete: - group_signature = tuple( - ( - name, - group_cycle, - len(cycle_records), - max( - ( - int(record.get("attempt", 0)) - for record in cycle_records - ), - default=0, - ), - max( - ( - int(record.get("image_stamp_ns", 0)) - for record in cycle_records - ), - default=0, - ), - ) - for name in required_names - for group_cycle in group_cycles - for cycle_records in ( - [ - record - for record in self.records_by_joint.get( - name, () - ) - if int(record.get("cycle", -1)) - == group_cycle - ], - ) - ) - cache_key = ( - joint_name, - validation_name, - observer_name, - group_signature, - ) - source_cache = getattr( - self, "_cross_view_roll_group_source_cache", {} - ) - cached_source = source_cache.get(cache_key) - if cached_source is not None: - group_source = str(cached_source) - else: - primary_residuals: list[float] = [] - secondary_residuals: list[float] = [] - fit_raw = ( - G20ThreeCameraCalibrationNode - ._fit_axis_measurement_raw - ) - try: - for group_cycle in group_cycles: - group_primary = fit_raw( - self, joint_name, group_cycle - ) - group_secondary = fit_raw( - self, validation_name, group_cycle - ) - group_observer = fit_raw( - self, observer_name, group_cycle - ) - observer_axis_common = np.asarray( - group_observer.axis_common_xyz, - dtype=float, - ) - primary_residuals.append( - cone_residual( - np.asarray( - group_primary.axis_common_xyz, - dtype=float, - ), - observer_axis_common, - ) - ) - secondary_residuals.append( - cone_residual( - np.asarray( - group_secondary.axis_common_xyz, - dtype=float, - ), - observer_axis_common, - ) - ) - except (KeyError, ValueError): - primary_residuals = [] - secondary_residuals = [] - group_source = ( - select_cross_view_roll_direction_source( - primary_residuals, - secondary_residuals, - self.zero_maximum_axis_cone_mismatch_rad, - ) - ) - source_cache[cache_key] = group_source - if len(source_cache) > 32: - source_cache.pop(next(iter(source_cache))) - self._cross_view_roll_group_source_cache = source_cache - if group_source == "secondary": - selected_direction = replace( - primary, - axis_common_xyz=tuple( - float(value) for value in secondary_axis - ), - axis_direction_source=( - "cross_view_cone_selected_secondary" - ), - ) - decision = ( - "use_secondary_zero_invariant_cone_consensus" - ) - G20ThreeCameraCalibrationNode._record_cross_view_roll_axis_diagnostic( - self, - joint_name, - cycle, - primary=primary, - secondary=secondary, - axis_difference_rad=axis_difference, - line_distance_m=line_distance, - decision=decision, - ) - # The front roll link rides the splay screw: its orientation - # tracks the rotation faithfully, but its centre trajectory - # carries the screw translation, displacing the fitted axis - # line by ~21 mm from the finger's physical MCP axis. The side - # PIP-link circle recovers that physical line. Keep the one - # direction selected consistently for all cycles and take the - # line position from the side view. - return replace( - selected_direction, - point_common_xyz_m=tuple( - float(value) - for value in secondary.point_common_xyz_m - ), - axis_point_source="side_circle_cross_view", - pose_axis_line_rms_m=secondary.pose_axis_line_rms_m, - pose_axis_line_source_joints=(validation_name,), - ) - primary_variance = max( - primary.radial_rms_m ** 2 + primary.pose_axis_line_rms_m ** 2, - 1.0e-12, - ) - secondary_variance = max( - secondary.radial_rms_m ** 2 + secondary.pose_axis_line_rms_m ** 2, - 1.0e-12, - ) - primary_weight = 1.0 / primary_variance - secondary_weight = 1.0 / secondary_variance - total_weight = primary_weight + secondary_weight - fused_axis = ( - primary_weight * primary_axis - + secondary_weight * secondary_axis - ) - fused_axis /= np.linalg.norm(fused_axis) - # Axis-line points have a free coordinate along the axis. Fuse only - # the observable perpendicular displacement and retain the primary - # point's along-axis gauge. - delta = secondary_point - primary_point - perpendicular_delta = delta - fused_axis * float(delta @ fused_axis) - fused_point = ( - primary_point - + secondary_weight / total_weight * perpendicular_delta - ) - return replace( - primary, - axis_common_xyz=tuple(float(value) for value in fused_axis), - point_common_xyz_m=tuple(float(value) for value in fused_point), - plane_rms_m=max(primary.plane_rms_m, secondary.plane_rms_m), - radial_rms_m=max(primary.radial_rms_m, secondary.radial_rms_m), - pose_axis_line_rms_m=max( - primary.pose_axis_line_rms_m, - secondary.pose_axis_line_rms_m, - line_distance, - ), - pose_axis_line_source_joints=(joint_name, validation_name), - axis_direction_source="cross_view_weighted_fusion", - ) - - def _refit_cross_view_axis_line_group( - self, - joint_name: str, - measurements: Sequence[JointAxisMeasurement], - ) -> list[JointAxisMeasurement]: - """Apply the repeated-cycle physical-radius constraint when eligible.""" - group = list(measurements) - sources = { - source - for measurement in group - if (source := cross_view_side_line_source(measurement)) is not None - } - if ( - len(group) != int(self.repetitions) - or len(sources) != 1 - or not all( - cross_view_side_line_source(measurement) is not None - for measurement in group - ) - or any( - axis_line_uses_depth_free_interpretation_plane(measurement) - for measurement in group - ) - ): - return group - source = next(iter(sources)) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - source_spec = profile.record_specs[source] - source_records = G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, - source, - self.records_by_joint[source], - ) - return list( - refit_axis_line_group_with_shared_radius( - group, - source_records, - zero_command_u8=int( - self.baseline_command[source_spec.motor_index] - ), - canonical_zero_direction=canonical_zero_direction( - profile, source - ), - ) - ) - - def _record_cross_view_roll_axis_diagnostic( - self, - joint_name: str, - cycle: int, - *, - primary: Any, - secondary: Any, - axis_difference_rad: float, - line_distance_m: float, - decision: str = "skip_fusion_use_primary", - ) -> None: - """Log the deterministic cross-view direction decision.""" - record = { - "kind": "cross_view_roll_axis_diagnostic", - "joint": joint_name, - "cycle": int(cycle), - "decision": str(decision), - "axis_difference_deg": round( - math.degrees(axis_difference_rad), 6 - ), - "line_distance_mm": round(1000.0 * line_distance_m, 6), - "primary_axis_common_xyz": [ - round(float(value), 9) for value in primary.axis_common_xyz - ], - "secondary_axis_common_xyz": [ - round(float(value), 9) for value in secondary.axis_common_xyz - ], - "primary_axis_direction_source": primary.axis_direction_source, - } - try: - append_jsonl(self.raw_path, record) - except Exception as error: - logger_factory = getattr(self, "get_logger", None) - if callable(logger_factory): - logger_factory().warning( - f"failed to record cross-view roll diagnostic: {error}" - ) - logger_factory = getattr(self, "get_logger", None) - if callable(logger_factory): - selected_text = ( - "using the side axis selected by the zero-invariant cone" - if decision.startswith( - "use_secondary_zero_invariant_cone" - ) - else "keeping the front-only axis" - ) - logger_factory().warning( - f"{joint_name} cycle {cycle}: front/side roll axes disagree " - f"by {math.degrees(axis_difference_rad):.2f} deg; skipping " - f"fusion and {selected_text}" - ) - - def _provisional_fit_failures( - self, spec: SweepSpec, *, include_view_validity: bool = True - ) -> list[dict[str, Any]]: - """Check full-pose curve and 3-D axis after one six-way task.""" - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - zero_profile = getattr(self, "zero_profile", LEFT_ZERO_PROFILE) - failures: list[dict[str, Any]] = [] - if include_view_validity and hasattr(self, "views"): - for view in _sweep_views(profile, spec): - runtime = self.views[view] - view_joints = _sweep_joints_for_view(profile, spec, view) - validation_only_view = bool(view_joints) and all( - profile.record_specs[name].zero_kind - == "axis_cross_view_validation" - for name in view_joints - ) - retry_scope = set( - getattr(self, "retry_joint_names", set()) - if getattr(self, "retry_sweep_spec", None) == spec - else () - ) - if retry_scope and not retry_scope.intersection(view_joints): - # This view's accepted records were intentionally kept; - # only the independently failed view is being measured. - continue - valid_rate = float(runtime.valid_rate) - # Prefer the task-scoped counters: after the last sweep the - # required-role set flips back to the full preflight set and - # the rolling window restarts on idle frames, which is not - # what a per-task capture-quality gate should measure. - total = int(getattr(runtime, "task_total_frames", 0)) - if total > 0: - valid_rate = ( - float(getattr(runtime, "task_valid_frames", 0)) - / total - ) - imported_without_capture = ( - total == 0 - and spec.key - in set(getattr(self, "resumed_task_keys", ()) or ()) - ) - dense_trajectory_coverage = bool( - profile.layout_id == G20_RIGHT_19_LAYOUT - and _records_have_formal_sweep_coverage( - self.records_by_joint, - view_joints, - repetitions=self.repetitions, - minimum_bins=int( - getattr(self, "minimum_sweep_bins", 32) - ), - maximum_bin_gap=int( - getattr(self, "maximum_bin_gap", 16) - ), - ) - ) - if ( - not imported_without_capture - and valid_rate < self.minimum_detection_rate - and not validation_only_view - and not dense_trajectory_coverage - ): - failures.append( - { - "joint": view_joints[0], - "view": view, - "metric": "tag_valid_rate_percent", - "actual": round(100.0 * valid_rate, 3), - "limit": round( - 100.0 * self.minimum_detection_rate, 3 - ), - "comparison": "minimum", - "task_valid_frames": int( - getattr(runtime, "task_valid_frames", 0) - ), - "task_total_frames": total, - } - ) - for joint_name in spec.joints: - records = self.records_by_joint[joint_name] - sync_p95 = float( - np.percentile( - [ - float(record.get("state_image_sync_error_ms", 0.0)) - for record in records - ], - 95.0, - ) - ) - sync_limit_ms = ( - getattr(self, "maximum_state_image_skew_ns", 50_000_000) - / 1_000_000.0 - ) - if sync_p95 > sync_limit_ms: - failures.append( - { - "joint": joint_name, - "metric": "state_image_sync_p95_ms", - "actual": round(sync_p95, 6), - "limit": round(sync_limit_ms, 6), - "comparison": "maximum", - } - ) - try: - fit = self._fit_joint_records( - joint_name, - records, - ) - except Exception as error: - failures.append( - { - "joint": joint_name, - "metric": "trajectory_fit", - "reason": str(error), - } - ) - continue - if profile.steady_command_checkpoints: - command_store = getattr( - self, "command_records_by_joint", None - ) - if command_store is not None: - command_records = [ - dict(record) - for record in command_store.get(joint_name, ()) - if int(record.get("cycle", 0)) == 0 - ] - if len(command_records) < 18: - failures.append( - { - "joint": joint_name, - "metric": "steady_command_checkpoints", - "actual": len(command_records), - "limit": 18, - "comparison": "minimum", - } - ) - else: - try: - self._fit_joint_records( - joint_name, command_records - ) - except Exception as error: - failures.append( - { - "joint": joint_name, - "metric": "command_trajectory_fit", - "reason": str(error), - } - ) - else: - feedback_fit = self._fit_joint_records( - joint_name, - _steady_records_in_feedback_domain( - command_records - ), - ) - command_gap = float( - feedback_fit.maximum_hysteresis_rad - ) - command_gap_limit = float( - self.command_maximum_direction_gap_rad - ) - if command_gap > command_gap_limit: - failures.append( - { - "joint": joint_name, - "metric": ( - "feedback_direction_gap_deg" - ), - "actual": round( - math.degrees(command_gap), 6 - ), - "limit": round( - math.degrees(command_gap_limit), 6 - ), - "comparison": "maximum", - } - ) - try: - baseline_hysteresis = baseline_hysteresis_by_cycle_rad( - G20ThreeCameraCalibrationNode._baseline_hysteresis_records( - self, joint_name, records - ), - zero_command_u8=int( - self.baseline_command[ - profile.record_specs[joint_name].motor_index - ] - ), - axis_xyz=( - G20ThreeCameraCalibrationNode - ._hysteresis_axis_for_fit( - self, joint_name, records, fit - ) - ), - ) - except Exception as error: - failures.append( - { - "joint": joint_name, - "metric": "baseline_hysteresis", - "reason": str(error), - } - ) - continue - maximum_baseline_hysteresis = max(baseline_hysteresis) - canonical_direction = canonical_zero_direction( - profile, joint_name - ) - if canonical_direction is not None: - validation_only_pose = ( - profile.record_specs[joint_name].zero_kind - == "axis_cross_view_validation" - ) - branch_gap_range = ( - maximum_baseline_hysteresis - - min(baseline_hysteresis) - ) - branch_gap_limit = getattr( - self, - "directional_zero_maximum_branch_gap_rad", - math.radians(1.5), - ) - branch_gap_range_limit = getattr( - self, - "directional_zero_maximum_branch_gap_range_rad", - math.radians(0.3), - ) - if maximum_baseline_hysteresis > branch_gap_limit: - failures.append( - { - "joint": joint_name, - "metric": "baseline_directional_gap_deg", - "actual": round( - math.degrees( - maximum_baseline_hysteresis - ), - 6, - ), - "limit": round( - math.degrees(branch_gap_limit), 6 - ), - "comparison": "maximum", - "cycle_values_deg": [ - round(math.degrees(value), 6) - for value in baseline_hysteresis - ], - } - ) - if ( - branch_gap_range > branch_gap_range_limit - and not validation_only_pose - ): - failures.append( - { - "joint": joint_name, - "metric": ( - "baseline_directional_gap_range_deg" - ), - "actual": round( - math.degrees(branch_gap_range), 6 - ), - "limit": round( - math.degrees(branch_gap_range_limit), 6 - ), - "comparison": "maximum", - "cycle_values_deg": [ - round(math.degrees(value), 6) - for value in baseline_hysteresis - ], - } - ) - elif ( - branch_gap_range > branch_gap_range_limit - and validation_only_pose - ): - # This side-view alias is never published as a joint - # angle. Its orientation is reconstructed from a - # near-grazing planar Tag, so IPPE pose drift can move - # the apparent branch gap between rounds even when - # the image trajectory and the authoritative front - # axis are repeatable. Keep the production threshold - # as an audit reference, but do not rescan identical - # motion based on a non-published pose component. - append_jsonl( - self.raw_path, - { - "kind": "validation_only_quality_diagnostic", - "joint": joint_name, - "metric": ( - "baseline_directional_gap_range_deg" - ), - "actual": round( - math.degrees(branch_gap_range), 6 - ), - "reference_limit": round( - math.degrees(branch_gap_range_limit), 6 - ), - "cycle_values_deg": [ - round(math.degrees(value), 6) - for value in baseline_hysteresis - ], - "decision": "diagnostic_only", - }, - ) - elif ( - maximum_baseline_hysteresis - > self.baseline_maximum_hysteresis_rad - ): - failures.append( - { - "joint": joint_name, - "metric": "baseline_hysteresis_deg", - "actual": round( - math.degrees(maximum_baseline_hysteresis), 6 - ), - "limit": round( - math.degrees( - self.baseline_maximum_hysteresis_rad - ), - 6, - ), - "comparison": "maximum", - "cycle_values_deg": [ - round(math.degrees(value), 6) - for value in baseline_hysteresis - ], - } - ) - if fit.circle.get("space") == "image_2d": - checks = ( - ( - "image_radial_rms_px", - float(fit.quality["radial_rms_px"]), - self.image_trajectory_maximum_radial_rms_px, - "maximum", - ), - ( - "image_radial_p95_px", - float(fit.quality["radial_p95_px"]), - self.image_trajectory_maximum_radial_p95_px, - "maximum", - ), - ( - "image_radius_px", - float(fit.quality["radius_px"]), - self.image_trajectory_minimum_radius_px, - "minimum", - ), - ( - "arc_deg", - math.degrees(float(fit.quality["arc_rad"])), - math.degrees(self.trajectory_minimum_arc_rad), - "minimum", - ), - ) - else: - checks = ( - ( - "rotation_orthogonal_rms_deg", - math.degrees( - float( - fit.quality["rotation_orthogonal_rms_rad"] - ) - ), - math.degrees( - self.active_maximum_rotation_orthogonal_rms_rad - if profile.record_specs[joint_name].active - else self.passive_maximum_rotation_orthogonal_rms_rad - ), - "maximum", - ), - ( - "arc_deg", - math.degrees(float(fit.quality["arc_rad"])), - math.degrees(self.trajectory_minimum_arc_rad), - "minimum", - ), - ) - joint_spec = profile.record_specs[joint_name] - monotonic_limit = ( - self.maximum_monotonic_correction_rad - if joint_spec.active - else self.passive_maximum_monotonic_correction_rad - ) - hysteresis_limit = ( - self.maximum_hysteresis_rad - if joint_spec.active - else self.passive_maximum_hysteresis_rad - ) - checks = checks + ( - ( - "monotonic_correction_deg", - math.degrees(fit.maximum_monotonic_correction_rad), - math.degrees(monotonic_limit), - "maximum", - ), - ) - if not profile.directional_zero: - checks = checks + ( - ( - "hysteresis_deg", - math.degrees(fit.maximum_hysteresis_rad), - math.degrees(hysteresis_limit), - "maximum", - ), - ) - for metric, actual, limit, comparison in checks: - failed = ( - actual > limit - if comparison == "maximum" - else actual < limit - ) - if failed: - failures.append( - { - "joint": joint_name, - "metric": metric, - "actual": round(float(actual), 6), - "limit": round(float(limit), 6), - "comparison": comparison, - } - ) - - cycle_travels: list[float] = [] - cycle_axes: list[np.ndarray] = [] - cycle_axis_sources: list[str] = [] - cycle_axis_measurements: list[JointAxisMeasurement] = [] - for cycle in range(self.repetitions): - cycle_records = [ - record - for record in self.records_by_joint[joint_name] - if int(record["cycle"]) == cycle - ] - try: - cycle_fit = self._fit_joint_records( - joint_name, cycle_records - ) - except Exception as error: - failures.append( - { - "joint": joint_name, - "metric": "cycle_fit", - "cycle": cycle + 1, - "reason": str(error), - } - ) - continue - cycle_travels.append( - abs( - float(cycle_fit.angle_rad[0]) - - float(cycle_fit.angle_rad[255]) - ) - ) - try: - axis = self._fit_axis_measurement(joint_name, cycle) - except Exception as error: - reason = str(error) - failure: dict[str, Any] = { - "joint": joint_name, - "metric": "axis_fit", - "cycle": cycle + 1, - "reason": reason, - } - if reason.startswith( - "cross_view_roll_axis_gross_disagreement:" - ): - validation_name = ( - profile.axis_validation_sources or {} - ).get(joint_name) - if validation_name in spec.joints: - # A gross direction disagreement does not identify - # which independently observed view is wrong. A - # retry must therefore reacquire both sources. - failure["quality_source_joints"] = [ - joint_name, - validation_name, - ] - failures.append(failure) - continue - cycle_axes.append(np.asarray(axis.axis_common_xyz, dtype=float)) - cycle_axis_sources.append(axis.axis_direction_source) - cycle_axis_measurements.append(axis) - plane_limit = ( - self.axis_maximum_plane_rms_m - if joint_spec.active - else self.passive_axis_maximum_plane_rms_m - ) - axis_checks = [ - ( - "axis_radial_rms_mm", - 1000.0 * axis.radial_rms_m, - 1000.0 * self.axis_maximum_radial_rms_m, - ), - ] - if ( - joint_spec.zero_kind != "axis_cross_view_validation" - and cross_view_side_line_source(axis) is None - ): - # A cross-view fallback deliberately takes its direction - # from the front and its line position from the side. The - # side Tag's monocular orientation is not the published - # direction, so its per-frame ideal-revolute residual is - # not an accuracy measure for the combined line. Those - # lines are checked for independent-cycle position - # repeatability below, at this same threshold. - axis_checks.append( - ( - "axis_pose_line_rms_mm", - 1000.0 * axis.pose_axis_line_rms_m, - 1000.0 * self.axis_maximum_pose_line_rms_m, - ) - ) - circle_is_constrained = circle_direction_is_constrained( - joint_name, zero_profile.constrained_circle_joints - ) - if not circle_is_constrained: - axis_checks.extend( - [ - ( - "axis_plane_rms_mm", - 1000.0 * axis.plane_rms_m, - 1000.0 * plane_limit, - ), - ] - ) - if not circle_is_constrained: - axis_checks.append( - ( - "rotation_circle_axis_difference_deg", - math.degrees( - axis.rotation_circle_axis_difference_rad - ), - math.degrees( - self.axis_maximum_rotation_circle_difference_rad - ), - ) - ) - # With a trusted orientation-constrained axis, plane_rms is - # scatter *along* that infinite axis. It cannot change the - # axis line or joint zero, so only the perpendicular/radial - # error is an admissibility check for constrained circles. - for metric, actual, limit in axis_checks: - if actual > limit: - if ( - metric == "axis_pose_line_rms_mm" - and not joint_spec.pose_axis_line_required - ): - append_jsonl( - self.raw_path, - { - "kind": ( - "position_invariant_quality_diagnostic" - ), - "task_name": spec.key, - "joint": joint_name, - "cycle": cycle + 1, - "metric": metric, - "actual": round(float(actual), 6), - "reference_limit": round( - float(limit), 6 - ), - "decision": "diagnostic_only", - "authoritative_quality": [ - "tag_pnp", - "image_trajectory", - "relative_rotation", - "synchronisation", - "isolated_holdout", - ], - }, - ) - continue - failure_joint = joint_name - quality_sources: tuple[str, ...] = () - if metric == "axis_pose_line_rms_mm": - quality_sources = tuple( - str(source) - for source in getattr( - axis, - "pose_axis_line_source_joints", - (), - ) - if str(source) in spec.joints - ) - if len(quality_sources) == 1: - failure_joint = quality_sources[0] - failure = { - "joint": failure_joint, - "metric": metric, - "cycle": cycle + 1, - "actual": round(float(actual), 6), - "limit": round(float(limit), 6), - "comparison": "maximum", - } - if quality_sources: - failure["quality_source_joints"] = list( - quality_sources - ) - if failure_joint != joint_name: - failure["model_joint"] = joint_name - failures.append(failure) - side_line_sources = { - source - for measurement in cycle_axis_measurements - if ( - source := cross_view_side_line_source(measurement) - ) is not None - } - if side_line_sources: - try: - cycle_axis_measurements = ( - G20ThreeCameraCalibrationNode - ._refit_cross_view_axis_line_group( - self, joint_name, cycle_axis_measurements - ) - ) - except Exception as error: - source = next(iter(side_line_sources)) - failures.append( - { - "joint": source, - "model_joint": joint_name, - "quality_source_joints": [source], - "metric": "axis_line_shared_radius_fit", - "reason": str(error), - } - ) - side_line_sources = { - source - for measurement in cycle_axis_measurements - if ( - source := cross_view_side_line_source(measurement) - ) is not None - } - if ( - len(cycle_axis_measurements) == self.repetitions - and len(side_line_sources) == 1 - and all( - cross_view_side_line_source(measurement) is not None - for measurement in cycle_axis_measurements - ) - and not any( - axis_line_uses_depth_free_interpretation_plane(measurement) - for measurement in cycle_axis_measurements - ) - ): - line_rms = axis_line_cycle_rms_m( - cycle_axis_measurements - ) - if line_rms > self.axis_maximum_pose_line_rms_m: - source = next(iter(side_line_sources)) - outliers = _isolated_axis_line_cycle_outliers( - cycle_axis_measurements, - self.axis_maximum_pose_line_rms_m, - ) - failure = { - "joint": source, - "model_joint": joint_name, - "quality_source_joints": [source], - "metric": "axis_line_cycle_rms_mm", - "actual": round(1000.0 * line_rms, 6), - "limit": round( - 1000.0 * self.axis_maximum_pose_line_rms_m, - 6, - ), - "maximum_pairwise_mm": round( - 1000.0 - * maximum_axis_line_cycle_spread_m( - cycle_axis_measurements - ), - 6, - ), - "comparison": "maximum", - } - if outliers: - outlier = next(iter(outliers)) - failure["cycle"] = outlier + 1 - failure["inlier_cycles"] = [ - index + 1 - for index in range(len(cycle_axis_measurements)) - if index != outlier - ] - failures.append(failure) - if len(cycle_travels) == self.repetitions: - travel_range = max(cycle_travels) - min(cycle_travels) - cycle_limit = ( - self.trajectory_maximum_cycle_travel_difference_rad - if joint_spec.active - else self.passive_maximum_cycle_travel_difference_rad - ) - if travel_range > cycle_limit: - failures.append( - { - "joint": joint_name, - "metric": "cycle_travel_range_deg", - "actual": round(math.degrees(travel_range), 6), - "limit": round(math.degrees(cycle_limit), 6), - "comparison": "maximum", - "cycle_travel_deg": [ - round(math.degrees(value), 6) - for value in cycle_travels - ], - } - ) - if ( - len(cycle_axes) == self.repetitions - and not all( - source == "upstream_constraint" - for source in cycle_axis_sources - ) - ): - maximum_difference = 0.0 - for left in cycle_axes: - for right in cycle_axes: - maximum_difference = max( - maximum_difference, - math.acos( - abs(float(np.clip(left @ right, -1.0, 1.0))) - ), - ) - if ( - maximum_difference - > self.zero_maximum_axis_cycle_difference_rad - and joint_spec.zero_kind - == "axis_cross_view_validation" - ): - # As above, the side alias' independently reconstructed - # pose axis is not the axis written to the URDF. The - # published front direction and the side translation - # circle retain their own strict cross-round gates. - append_jsonl( - self.raw_path, - { - "kind": "validation_only_quality_diagnostic", - "joint": joint_name, - "metric": "axis_cycle_difference_deg", - "actual": round( - math.degrees(maximum_difference), 6 - ), - "reference_limit": round( - math.degrees( - self.zero_maximum_axis_cycle_difference_rad - ), - 6, - ), - "decision": "diagnostic_only", - }, - ) - elif ( - maximum_difference - > self.zero_maximum_axis_cycle_difference_rad - ): - outliers = _isolated_axis_cycle_outliers( - cycle_axes, - self.zero_maximum_axis_cycle_difference_rad, - ) - failure = { - "joint": joint_name, - "metric": "axis_cycle_difference_deg", - "actual": round(math.degrees(maximum_difference), 6), - "limit": round( - math.degrees( - self.zero_maximum_axis_cycle_difference_rad - ), - 6, - ), - "comparison": "maximum", - } - if outliers: - outlier = next(iter(outliers)) - failure["cycle"] = outlier + 1 - failure["inlier_cycles"] = [ - index + 1 - for index in range(len(cycle_axes)) - if index != outlier - ] - failures.append(failure) - if profile.cross_view_roll_curve: - for primary_name, validation_name in ( - profile.axis_validation_sources or {} - ).items(): - if not {primary_name, validation_name}.issubset(spec.joints): - continue - try: - cycle_fits = [ - ( - self._fit_joint_records( - primary_name, - [ - record - for record in self.records_by_joint[ - primary_name - ] - if int(record.get("cycle", -1)) == cycle - ], - ), - self._fit_joint_records( - validation_name, - [ - record - for record in self.records_by_joint[ - validation_name - ] - if int(record.get("cycle", -1)) == cycle - ], - ), - ) - for cycle in range(self.repetitions) - ] - training_cycles = set(range(max(1, self.repetitions - 1))) - scopes = ( - ( - "training", - [ - record - for record in self.records_by_joint[primary_name] - if int(record.get("cycle", -1)) - in training_cycles - ], - [ - record - for record in self.records_by_joint[ - validation_name - ] - if int(record.get("cycle", -1)) - in training_cycles - ], - ), - ( - "all_cycles", - self.records_by_joint[primary_name], - self.records_by_joint[validation_name], - ), - ) - for scope, primary_records, validation_records in scopes: - failure = _cross_view_curve_failure( - primary_name, - validation_name, - self._fit_joint_records( - primary_name, primary_records - ), - self._fit_joint_records( - validation_name, validation_records - ), - scope=scope, - maximum_rms_difference_rad=( - self.cross_view_roll_maximum_shape_rms_rad - ), - maximum_branch_gap_difference_rad=( - self.cross_view_roll_maximum_branch_gap_difference_rad - ), - allow_projection_scale=True, - maximum_projection_scale_ratio=( - self.cross_view_roll_maximum_projection_scale_ratio - ), - cycle_fits=cycle_fits, - ) - if failure is not None: - append_jsonl( - self.raw_path, - { - "kind": ( - "validation_only_quality_diagnostic" - ), - **failure, - "decision": "diagnostic_only", - }, - ) - # One structured result contains all per-cycle - # evidence; avoid duplicate training/final errors. - break - except Exception as error: - append_jsonl( - self.raw_path, - { - "kind": "validation_only_quality_diagnostic", - "joint": primary_name, - "metric": "cross_view_roll_curve", - "reason": str(error), - "quality_source_joints": [validation_name], - "decision": "diagnostic_only", - }, - ) - return failures - - def _cross_view_roll_diagnostic_role( - self, spec: SweepSpec - ) -> str | None: - finger = str( - getattr(self, "cross_view_roll_diagnostic_finger", "") - ) - if not finger: - return None - if spec.key == f"{finger}_roll_multiview": - return "multiview" - return None - - @staticmethod - def _only_baseline_hysteresis_failures( - failures: Sequence[Mapping[str, Any]], - ) -> bool: - return bool(failures) and all( - str(item.get("metric", "")) - in { - "baseline_hysteresis_deg", - "baseline_directional_gap_deg", - "baseline_directional_gap_range_deg", - } - for item in failures - ) - - def _roll_baseline_hysteresis_degrees( - self, joint_name: str - ) -> list[float]: - records = self.records_by_joint[joint_name] - fit = self._fit_joint_records(joint_name, records) - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - spec = profile.record_specs[joint_name] - values = baseline_hysteresis_by_cycle_rad( - G20ThreeCameraCalibrationNode._baseline_hysteresis_records( - self, joint_name, records - ), - zero_command_u8=int( - self.baseline_command[spec.motor_index] - ), - axis_xyz=fit.circle["axis_xyz"], - ) - return [round(math.degrees(value), 6) for value in values] - - def _complete_cross_view_roll_diagnostic( - self, - side_spec: SweepSpec, - side_quality_failures: Sequence[Mapping[str, Any]] = (), - ) -> None: - finger = self.cross_view_roll_diagnostic_finger - front_joint = f"{finger}_mcp_roll" - side_joint = f"{front_joint}_side" - front_values = self._roll_baseline_hysteresis_degrees(front_joint) - side_values = self._roll_baseline_hysteresis_degrees(side_joint) - limit_deg = math.degrees(self.baseline_maximum_hysteresis_rad) - interpretation = _classify_cross_view_roll_hysteresis( - front_values, side_values, limit_deg=limit_deg - ) - result = { - "kind": "cross_view_roll_diagnostic", - "finger": finger, - "motor_index": side_spec.motor_index, - "front_joint": front_joint, - "side_joint": side_joint, - "front_by_cycle_deg": front_values, - "side_by_cycle_deg": side_values, - "front_maximum_deg": max(front_values), - "side_maximum_deg": max(side_values), - "formal_limit_deg": limit_deg, - "interpretation": interpretation, - "side_quality_failures": [ - dict(item) for item in side_quality_failures - ], - "publication_locked": True, - } - self.cross_view_roll_diagnostic_result = result - self.fit_failure = result - append_jsonl(self.raw_path, result) - self._pause("cross_view_roll_diagnostic_complete") - - def _pause_for_provisional_fit_failure( - self, - spec: SweepSpec, - failures: list[dict[str, Any]], - *, - allow_warning: bool = False, - ) -> bool: - diagnostic_role = ( - G20ThreeCameraCalibrationNode._cross_view_roll_diagnostic_role( - self, spec - ) - ) - if ( - diagnostic_role == "front" - and G20ThreeCameraCalibrationNode._only_baseline_hysteresis_failures( - failures - ) - ): - append_jsonl( - self.raw_path, - { - "kind": "cross_view_roll_front_failure_deferred", - "finger": self.cross_view_roll_diagnostic_finger, - "view": spec.view, - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "failures": failures, - "publication_locked": True, - }, - ) - self.reason = "cross_view_roll_front_failure_deferred" - return False - - def is_warning(item: Mapping[str, Any]) -> bool: - if "actual" not in item or "limit" not in item: - return False - actual = float(item["actual"]) - limit = float(item["limit"]) - ratio = float(getattr(self, "provisional_warning_ratio", 1.0)) - if limit <= 0.0: - return False - if item.get("comparison") == "minimum": - return actual >= limit / ratio - return actual <= limit * ratio - - if allow_warning and failures and all(is_warning(item) for item in failures): - band_attempt = int( - self.sweep_attempts.get(_sweep_storage_key(spec), 1) - ) - retry_limit = 0 - # The final fit re-applies the unmodified hard thresholds to the - # same records. Letting even a tiny overrun continue would make - # the final fit recall this task after every later joint has been - # collected. Consume every remaining retry here; when the budget - # is exhausted, the common failure path below pauses in place. - append_jsonl( - self.raw_path, - { - "kind": ( - "provisional_fit_warning_rescan" - if band_attempt <= retry_limit - else "provisional_fit_warning_retry_exhausted" - ), - "view": spec.view, - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "attempt": band_attempt, - "attempt_limit": retry_limit + 1, - "warning_ratio": float( - getattr( - self, "provisional_warning_ratio", 1.0 - ), - ), - "failures": failures, - }, - ) - - attempt = self.sweep_attempts.get(_sweep_storage_key(spec), 1) - self.retry_resume_index = self.sweep_index - self.retry_sweep_spec = spec - self.retry_joint_names = _fit_retry_joint_names( - _node_profile(self), spec, failures - ) - source_task_keys = ( - tuple(getattr(self, "retry_source_task_keys", ())) - if getattr(self, "retry_source_failure_task_key", None) == spec.key - else () - ) - source_key_set = set(source_task_keys) - source_specs = [ - candidate - for candidate in _node_profile(self).sweep_specs - if candidate.key in source_key_set - ] - if source_specs: - self.retry_joint_names = { - joint_name - for candidate in source_specs - for joint_name in candidate.joints - } - localized_cycles = { - int(item["cycle"]) - 1 - for item in failures - if item.get("cycle") is not None - } - cycle_override = set( - getattr(self, "retry_cycle_override", set()) - ) - if source_specs and cycle_override: - # These source tasks jointly define one PnP-relative zero. Mixing - # cycles acquired before and after a tracker/reference reset can - # make a clean branch change look like a physical zero change. - # Reacquire the whole source holdout set as one generation. - self.retry_cycles = cycle_override - elif failures and all( - item.get("cycle") is not None - or str(item.get("metric", "")).startswith("third_cycle_") - for item in failures - ): - if any( - str(item.get("metric", "")).startswith("third_cycle_") - for item in failures - ): - localized_cycles.add(self.repetitions - 1) - self.retry_cycles = localized_cycles - else: - self.retry_cycles = set(range(self.repetitions)) - history_by_task = getattr( - self, "fit_failure_history_by_task", None - ) - if history_by_task is None: - self.fit_failure_history_by_task = {} - history_by_task = self.fit_failure_history_by_task - task_history = history_by_task.setdefault(spec.key, []) - repeated_branch_clusters = bool( - task_history - and _fit_failure_repeats_branch_clusters( - task_history[-1], failures - ) - ) - task_history.append([dict(item) for item in failures]) - systematic = bool( - _fit_failure_is_systematic(failures, self.repetitions) - or repeated_branch_clusters - ) - fit_retry_limit = 0 - self.fit_failure = { - "kind": "fit_failure", - "view": spec.view, - "task_name": spec.key, - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "joints_to_rescan": sorted(self.retry_joint_names), - **( - {"source_task_names": list(source_task_keys)} - if source_task_keys - else {} - ), - "attempt": attempt, - "fit_attempt": attempt, - "fit_attempt_limit": fit_retry_limit + 1, - "automatic_retry_count": max(0, attempt - 1), - "automatic_retry_limit": fit_retry_limit, - "cycles_to_rescan": [ - cycle + 1 for cycle in sorted(self.retry_cycles) - ], - "directions_to_rescan": ( - 0 - if systematic - else 2 - * len(self.retry_cycles) - * max(1, len(source_specs)) - ), - "recoverable_by_rescan": not systematic, - "repeated_branch_clusters": repeated_branch_clusters, - "failures": failures, - } - append_jsonl( - self.raw_path, - { - "kind": "fit_failure", - **self.fit_failure, - }, - ) - if systematic: - self._pause( - "joint_fit_repeated_branch_failure" - if repeated_branch_clusters - else "joint_fit_systematic_failure" - ) - return True - if attempt <= fit_retry_limit: - self.paused_reason = "joint_fit_check_failed" - self.reason = "automatic_retry_joint_fit_check_failed" - self._prepare_failed_sweep_retry() - self._begin_return_baseline("resume_sweep") - return True - self._pause("joint_fit_check_failed") - return True - - def _pause_for_zero_model_failure( - self, zero_result: ZeroSolveResult - ) -> None: - """Stop once for a model/zero failure; repeated motion cannot fix it.""" - line_errors_m = dict( - getattr(zero_result, "validation_line_error_by_joint_m", {}) - ) - axis_line_rms_m = float(getattr(zero_result, "axis_line_rms_m", 0.0)) - axis_line_limit_m = float( - getattr(self, "axis_maximum_pose_line_rms_m", 0.0) - ) - observability_rank = int(getattr(zero_result, "observability_rank", 0)) - observability_parameter_count = int( - getattr(zero_result, "observability_parameter_count", 0) - ) - observability_condition_number = float( - getattr(zero_result, "observability_condition_number", 0.0) - ) - failures: list[dict[str, Any]] = [] - for name, reason in zero_result.failure_reasons.items(): - failure: dict[str, Any] = { - "joint": name, - "metric": "zero_guard", - "reason": reason, - } - if reason == "zero_offset_exceeds_configured_limit": - configured_limit = self.zero_joint_maximum_offsets_rad.get( - name, - ( - self.zero_finger_maximum_offset_rad - if name.startswith( - ("index_", "middle_", "ring_", "pinky_") - ) - else self.zero_maximum_offset_rad - ), - ) - failure["actual_deg"] = round( - math.degrees(zero_result.direct_offsets_rad[name]), 6 - ) - failure["limit_deg"] = round( - math.degrees(configured_limit), 6 - ) - failures.append(failure) - failures.extend( - { - "joint": observer, - "metric": "third_cycle_axis_holdout_deg", - "actual": round(math.degrees(error), 6), - "limit": math.degrees(self.maximum_validation_p95_rad), - "comparison": "maximum", - } - for observer, error in ( - zero_result.validation_error_by_joint_rad.items() - ) - if error > self.maximum_validation_p95_rad - ) - failures.extend( - { - "joint": observer, - "metric": "validation_axis_line_residual_mm", - "actual": round(1000.0 * float(error), 6), - "limit": round( - 1000.0 * axis_line_limit_m, 6 - ), - "comparison": "maximum", - } - for observer, error in ( - line_errors_m.items() - ) - if error > axis_line_limit_m - ) - direct_joint = next( - iter(zero_result.failure_reasons), - next( - iter(zero_result.validation_error_by_joint_rad), - "thumb_cmc_pitch", - ), - ) - spec = next( - ( - item - for item in self.profile.sweep_specs - if direct_joint in item.joints - ), - self.profile.sweep_specs[0], - ) - self.retry_sweep_spec = None - self.retry_resume_index = None - self.retry_cycles = set() - self.retry_joint_names = set() - self.fit_failure = { - "kind": "zero_model_failure", - "view": spec.view, - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "directions_to_rescan": 0, - "recoverable_by_rescan": False, - "failures": failures, - "zero_geometry": { - "axis_line_rms_mm": round( - 1000.0 * axis_line_rms_m, 6 - ), - "axis_line_rms_limit_mm": round( - 1000.0 * axis_line_limit_m, 6 - ), - "validation_line_error_by_joint_mm": { - name: round(1000.0 * float(value), 6) - for name, value in ( - line_errors_m.items() - ) - }, - "observability_rank": observability_rank, - "observability_parameter_count": observability_parameter_count, - "observability_condition_number": observability_condition_number, - }, - } - append_jsonl( - self.raw_path, - {"kind": "zero_model_failure", **self.fit_failure}, - ) - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - atomic_write_json( - self.session_dir / "g20_right_19_failed_diagnostics.json", - { - "schema_version": 5, - "layout_id": G20_RIGHT_19_LAYOUT, - "passed": False, - "reason": "zero_model_validation_failed", - "failure_reasons": dict(zero_result.failure_reasons), - "axis_line_rms_m": axis_line_rms_m, - "axis_line_rms_limit_m": axis_line_limit_m, - "validation_line_error_by_joint_m": dict( - line_errors_m - ), - "validation_error_by_joint_rad": dict( - zero_result.validation_error_by_joint_rad - ), - "direct_offsets_rad": dict(zero_result.direct_offsets_rad), - "offset_confidence_95_half_width_rad": dict( - zero_result.offset_confidence_half_width_rad - ), - "observability": { - "rank": observability_rank, - "parameter_count": observability_parameter_count, - "condition_number": observability_condition_number, - }, - "raw_samples": str(self.raw_path), - "source_urdf": str(self.source_urdf_path), - "camera_extrinsics": str( - self.camera_extrinsics_file - ), - "urdf_published": False, - }, - ) - self._pause("zero_model_validation_failed") - - def _retry_active_sweep_or_pause(self, reason: str) -> None: - """Retry a recoverable acquisition failure without weakening gates.""" - if self.active_sweep is None: - self._pause(reason) - return - item = self.active_sweep - key = ( - _sweep_storage_key(item.spec), item.cycle, item.direction - ) - retries = self.sweep_retry_counts.get(key, 0) - retry_limit = 1 - if not CalibrationEngine.permits_retry("sweep_acquisition", retries): - self._pause(reason) - return - retries += 1 - self.sweep_retry_counts[key] = retries - observer = next( - ( - candidate - for candidate in _node_profile(self).palm_axis_observers - if candidate.task_name == item.spec.key - ), - None, - ) - retry_views = set(_sweep_views(_node_profile(self), item.spec)) - if observer is not None: - retry_views.add(observer.view) - for view in retry_views: - runtime = getattr(self, "views", {}).get(view) - if runtime is None: - continue - self._reset_view_trackers(runtime) - runtime.pnp_reset_count += 1 - speed_scales = getattr( - self, "retry_speed_scales", (1.0,) - ) - endpoint_holds = getattr( - self, "retry_endpoint_hold_seconds", (0.5,) - ) - # Write the invalidation before mutating the authoritative in-memory - # stores. Restart replays this event as a tombstone, so a crash can - # neither resurrect the rejected sweep nor merge it with its - # replacement acquisition. - append_jsonl( - self.raw_path, - { - "kind": "automatic_sweep_retry", - "task_name": item.spec.key, - "attempt": int( - getattr(self, "sweep_attempts", {}).get( - _sweep_storage_key(item.spec), 1 - ) - ), - "view": item.spec.view, - "motor_index": item.spec.motor_index, - "joints": list(item.spec.joints), - "cycle": item.cycle, - "direction": item.direction, - "retry": retries, - "retry_limit": retry_limit, - "reason": str(reason), - "speed_scale": float( - speed_scales[ - min(retries, len(speed_scales)) - 1 - ] - ), - "endpoint_hold_seconds": float( - endpoint_holds[ - min(retries, len(endpoint_holds)) - 1 - ] - ), - }, - ) - command_records = getattr(self, "command_records_by_joint", {}) - for joint_name in item.spec.joints: - if joint_name in command_records: - command_records[joint_name] = [ - record - for record in command_records[joint_name] - if not ( - str(record.get("task_name")) == item.spec.key - and int(record.get("cycle", -999)) == item.cycle - and str(record.get("direction")) == item.direction - ) - ] - if observer is not None: - records = self.palm_axis_records_by_source.get( - observer.source_name, [] - ) - records[:] = [ - record - for record in records - if not ( - int(record.get("cycle", -999)) == item.cycle - and str(record.get("direction", "")) == item.direction - ) - ] - getattr(self, "sweep_frames", []).clear() - getattr(self, "sweep_start_frames", []).clear() - self.reason = f"automatic_retry_{reason}" - self._begin_return_baseline("retry_sweep") - - def _record_dedicated_baseline_hold(self, item: SweepItem) -> None: - """Persist one settled baseline observation for one approach path.""" - if not G20ThreeCameraCalibrationNode._requires_mid_sweep_baseline_hold( - self, item - ): - return - frames = list(getattr(self, "sweep_baseline_frames", [])) - minimum_frames = int(getattr(self, "minimum_baseline_hold_frames", 10)) - motor = item.spec.motor_index - command = int(self.baseline_command[motor]) - attempt = self.sweep_attempts.get(_sweep_storage_key(item.spec), 1) - record_joint_names = tuple(item.spec.joints) - if getattr(self, "active_sweep_is_fit_retry", False): - scoped = set(getattr(self, "retry_joint_names", set())) - if scoped: - record_joint_names = tuple( - name for name in item.spec.joints if name in scoped - ) - for joint_name in record_joint_names: - selected = _frames_for_joint(frames, joint_name) - if len(selected) < minimum_frames: - raise RuntimeError( - "dedicated baseline hold for " - f"{joint_name} has {len(selected)} valid frames; " - f"at least {minimum_frames} required" - ) - state = np.median( - np.asarray( - [frame.state_u8 for frame in selected], dtype=float - ), - axis=0, - ) - quaternion = robust_rotation_summary( - [ - frame.joint_quaternions_xyzw[joint_name] - for frame in selected - ] - )[0] - relative_translation = np.median( - np.asarray( - [ - frame.joint_vectors_xyz_m[joint_name] - for frame in selected - ], - dtype=float, - ), - axis=0, - ) - image_relative = np.median( - np.asarray( - [ - frame.image_vectors_xy_px[joint_name] - for frame in selected - ], - dtype=float, - ), - axis=0, - ) - durable = canonical_sample_record({ - "kind": "baseline_hold_sample", - "attempt": attempt, - "task_name": item.spec.key, - "view": self.profile.record_specs[joint_name].view, - "joint": joint_name, - "motor_index": motor, - "cycle": item.cycle, - "direction": item.direction, - "requested_command_u8": command, - "feedback_u8": round(float(state[motor]), 6), - "image_stamp_ns": int( - np.median([frame.stamp_ns for frame in selected]) - ), - "relative_quaternion_xyzw": [ - float(value) for value in quaternion - ], - "relative_translation_xyz_m": [ - float(value) for value in relative_translation - ], - "image_relative_xy_px": [ - float(value) for value in image_relative - ], - "parent_pose_common": _robust_pose_payload( - [ - frame.parent_poses_common[joint_name] - for frame in selected - ] - ), - "child_pose_common": _robust_pose_payload( - [ - frame.child_poses_common[joint_name] - for frame in selected - ] - ), - "state_u8": [float(value) for value in state], - "state_image_sync_error_ms": round( - float( - np.percentile( - [ - abs(frame.state_sync_error_ns) - for frame in selected - ], - 95.0, - ) - / 1_000_000.0 - ), - 6, - ), - "pnp_reprojection_error_px": round( - float( - np.percentile( - [ - frame.joint_reprojection_error_px[joint_name] - for frame in selected - ], - 95.0, - ) - ), - 6, - ), - "valid_frames": len(selected), - "hold_seconds": float(self.baseline_hold_seconds), - }) - record = fitting_sample_record(durable) - self.baseline_records_by_joint[joint_name].append(record) - if item.cycle == 0: - self.command_records_by_joint[joint_name].append(record) - append_jsonl(self.raw_path, durable) - - def _baseline_hysteresis_records( - self, - joint_name: str, - trajectory_records: Sequence[Mapping[str, Any]], - ) -> Sequence[Mapping[str, Any]]: - """Use dedicated settled records for non-endpoint baseline joints.""" - profile = getattr(self, "profile", LEFT_HAND_PROFILE) - spec = profile.record_specs[joint_name] - baseline = int(self.baseline_command[spec.motor_index]) - if ( - profile.layout_id == G20_RIGHT_19_LAYOUT - and baseline not in {0, 255} - ): - return self.baseline_records_by_joint.get(joint_name, []) - return trajectory_records - - def _finish_active_sweep(self) -> None: - assert self.active_sweep is not None - item = self.active_sweep - motor = item.spec.motor_index - start_tolerance = ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - self, item.spec, item.start_u8 - ) - ) - target_tolerance = ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - self, item.spec, item.target_u8 - ) - ) - record_joint_names = tuple(item.spec.joints) - if getattr(self, "active_sweep_is_fit_retry", False): - scoped = set(getattr(self, "retry_joint_names", set())) - if scoped: - record_joint_names = tuple( - name for name in item.spec.joints if name in scoped - ) - - def observation_bins( - joint_name: str, - ) -> dict[int, list[FrameObservation]]: - bins: dict[int, list[FrameObservation]] = {} - frames = _frames_for_joint(self.sweep_frames, joint_name) - for frame in frames: - state = float(frame.state_u8[motor]) - if abs(state - item.start_u8) <= start_tolerance: - command = item.start_u8 - elif abs(state - item.target_u8) <= target_tolerance: - command = item.target_u8 - else: - command = int(np.clip(np.rint(state), 0, 255)) - bins.setdefault(command, []).append(frame) - return bins - - joint_bins: dict[str, dict[int, list[FrameObservation]]] = {} - for joint_name in record_joint_names: - bins = observation_bins(joint_name) - commands = sorted(bins) - if not commands or commands[0] != 0 or commands[-1] != 255: - self._retry_active_sweep_or_pause( - _joint_failure_reason( - "sweep_missing_endpoint_bin", item.spec, joint_name - ) - ) - return - joint_bins[joint_name] = bins - if item.precheck: - # This low-speed pass is a visibility/safety check at 0, 127 and - # 255, not a source for the command-angle curve. Its full-sweep - # detection rate is counted directly in the camera callback. A - # raw-frame rate below the nominal threshold is not itself a data - # defect when synchronised valid poses still cover the complete - # trajectory densely: detector flicker then changes only how many - # duplicate observations exist, not what motion is observable. - for joint_name, bins in joint_bins.items(): - commands = sorted(bins) - if min(abs(command - 127) for command in commands) > 2: - self._retry_active_sweep_or_pause( - _joint_failure_reason( - "task_precheck_missing_command_127", - item.spec, - joint_name, - ) - ) - return - detection_rates: dict[str, float] = {} - total_by_view = getattr( - self, "sweep_detection_total_by_view", {} - ) - valid_by_view = getattr( - self, "sweep_detection_valid_by_view", {} - ) - capture_views = _sweep_views(_node_profile(self), item.spec) - profile = _node_profile(self) - trajectory_coverage_by_view: dict[str, bool] = {} - for view in capture_views: - view_joint_bins = [ - joint_bins[name] - for name in _sweep_joints_for_view( - profile, item.spec, view - ) - if name in joint_bins - ] - trajectory_coverage_by_view[view] = bool(view_joint_bins) and all( - len(bins) >= self.minimum_sweep_bins - and max(np.diff(sorted(bins)), default=0) - <= self.maximum_bin_gap - for bins in view_joint_bins - ) - detection_rate_below_threshold_views: list[str] = [] - trajectory_coverage_override_views: list[str] = [] - for view in capture_views: - if view in total_by_view: - total = int(total_by_view.get(view, 0)) - valid = int(valid_by_view.get(view, 0)) - elif len(capture_views) == 1: - # Compatibility for legacy sessions and focused unit - # harnesses created before per-camera counters existed. - total = int( - getattr(self, "sweep_detection_total_frames", 0) - ) - valid = int( - getattr(self, "sweep_detection_valid_frames", 0) - ) - else: - total = 0 - valid = 0 - rate = 0.0 if total <= 0 else valid / total - detection_rates[view] = rate - if rate < self.minimum_detection_rate: - detection_rate_below_threshold_views.append(view) - if trajectory_coverage_by_view.get(view, False): - trajectory_coverage_override_views.append(view) - else: - self._retry_active_sweep_or_pause( - f"task_precheck_detection_rate_too_low:{view}" - ) - return - minimum_bin_count = min( - len(bins) for bins in joint_bins.values() - ) - precheck_maximum_bin_gap = max( - int(max(np.diff(sorted(bins)), default=0)) - for bins in joint_bins.values() - ) - minimum_valid_frames = min( - sum(len(frames) for frames in bins.values()) - for bins in joint_bins.values() - ) - G20ThreeCameraCalibrationNode._record_precheck_speed_metric( - self, - item, - bin_count=minimum_bin_count, - maximum_bin_gap=precheck_maximum_bin_gap, - valid_frames=minimum_valid_frames, - ) - append_jsonl( - self.raw_path, - { - "kind": "task_visibility_precheck", - "task_name": item.spec.key, - "view": item.spec.view, - "capture_views": list( - _sweep_views(_node_profile(self), item.spec) - ), - "motor_index": motor, - "direction": item.direction, - "checked_commands_u8": [0, 127, 255], - "bin_count": minimum_bin_count, - "maximum_bin_gap": precheck_maximum_bin_gap, - "detection_rate": round(min(detection_rates.values()), 6), - "detection_rate_by_view": { - view: round(rate, 6) - for view, rate in detection_rates.items() - }, - "minimum_detection_rate": round( - float(self.minimum_detection_rate), 6 - ), - "detection_rate_below_threshold_views": ( - detection_rate_below_threshold_views - ), - "trajectory_coverage_by_view": ( - trajectory_coverage_by_view - ), - "trajectory_coverage_override_views": ( - trajectory_coverage_override_views - ), - "passed": True, - }, - ) - if not getattr(self, "active_sweep_is_fit_retry", False): - self.sweep_index += 1 - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps( - self - ) - self.active_sweep = None - self.active_sweep_is_fit_retry = False - if self.sweep_index >= len(self.sweep_items): - self._begin_return_baseline("fit") - elif self.sweep_items[self.sweep_index].spec == item.spec: - following = self.sweep_items[self.sweep_index] - transition = _sweep_plan_transition( - self.profile, item, following - ) - if transition == "cycle_reset": - self._begin_return_baseline("next_cycle") - else: - G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames( - self, - item, - following, - ) - self._start_next_sweep() - else: - self._begin_return_baseline( - G20ThreeCameraCalibrationNode._transition_after_completed_spec( - self, item.spec - ) - ) - return - - total_by_view = getattr(self, "sweep_detection_total_by_view", {}) - valid_by_view = getattr(self, "sweep_detection_valid_by_view", {}) - rates = [ - float(valid_by_view.get(view, 0)) / float(total_by_view[view]) - for view in _sweep_views(_node_profile(self), item.spec) - if int(total_by_view.get(view, 0)) > 0 - ] - detection_rate = min(rates, default=0.0) - for joint_name, bins in joint_bins.items(): - feedback = [ - float(frame.state_u8[motor]) - for frame in _frames_for_joint(self.sweep_frames, joint_name) - ] - engine = getattr(self, "calibration_engine", None) - if engine is None: - typed_profile = getattr(self, "calibration_profile", None) - if typed_profile is None: - legacy_profile = getattr( - self, - "profile", - get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT), - ) - typed_profile = get_product_calibration_contract( - str(getattr(self, "model", "G20")), - legacy_profile.side, - legacy_profile.layout_id, - ).typed_profile - engine = CalibrationEngine(typed_profile) - receive_times = getattr(self, "state_receive_times", ()) - quality = engine.evaluate_sweep( - [value / 255.0 for value in feedback], - minimum_span=getattr(self, "minimum_state_span_u8", 240.0) / 255.0, - total_frames=max(total_by_view.values(), default=0), - joint_frame_rate=( - 0.0 - if max(total_by_view.values(), default=0) == 0 - else len(feedback) / max(total_by_view.values()) - ), - feedback_hz=( - 0.0 - if len(receive_times) < 2 - or receive_times[-1] <= receive_times[0] - else (len(receive_times) - 1) - / (receive_times[-1] - receive_times[0]) - ), - detection_rate=detection_rate, - bin_count=256, - ) - if getattr(self, "raw_path", None) is not None: - append_jsonl(self.raw_path, { - "kind": "unified_sweep_observation_quality", - "task_name": item.spec.key, - "joint": joint_name, - "cycle": item.cycle, - "direction": item.direction, - **quality.metrics, - "warnings": list(quality.warnings), - "failures": list(quality.failures), - "passed": quality.passed, - }) - if not quality.passed: - self._retry_active_sweep_or_pause( - _joint_failure_reason( - "sweep_observability_failed", item.spec, joint_name - ) + ":" + ",".join(quality.failures) - ) - return - commands = sorted(bins) - if len(commands) < self.minimum_sweep_bins: - self._retry_active_sweep_or_pause( - _joint_failure_reason( - "sweep_bins_too_few", item.spec, joint_name - ) - ) - return - if max(np.diff(commands), default=0) > self.maximum_bin_gap: - self._retry_active_sweep_or_pause( - _joint_failure_reason( - "sweep_bin_gap_too_large", item.spec, joint_name - ) - ) - return - - stored_joint_names = tuple(joint_bins) - - def synchronised_frame_payload( - frame: FrameObservation, - ) -> dict[str, Any]: - names = [ - name - for name in stored_joint_names - if name in frame.joint_quaternions_xyzw - ] - return { - "kind": "synchronised_frame", - "attempt": self.sweep_attempts.get( - _sweep_storage_key(item.spec), 1 - ), - "task_name": item.spec.key, - "view": frame.view, - "motor_index": motor, - "joints": names, - "cycle": item.cycle, - "direction": item.direction, - "requested_command_u8": int( - self.sweep_checkpoint_target_u8 - if self.sweep_checkpoint_target_u8 is not None - else item.target_u8 - ), - "feedback_u8": round(float(frame.state_u8[motor]), 6), - "image_stamp_ns": int(frame.stamp_ns), - "actual_state_u8": [ - float(value) for value in frame.state_u8 - ], - "state_image_sync_error_ms": round( - abs(frame.state_sync_error_ns) / 1_000_000.0, 6 - ), - "relative_quaternion_xyzw": { - name: list(frame.joint_quaternions_xyzw[name]) - for name in names - }, - "parent_pose_common": { - name: frame.parent_poses_common[name] - for name in names - }, - "child_pose_common": { - name: frame.child_poses_common[name] - for name in names - }, - "pnp_reprojection_error_px": { - name: frame.joint_reprojection_error_px[name] - for name in names - }, - } - append_jsonl_many( - self.raw_path, - ( - synchronised_frame_payload(frame) - for frame in self.sweep_frames - if any( - name in frame.joint_quaternions_xyzw - for name in stored_joint_names - ) - ), - ) - - for joint_name in stored_joint_names: - bins = joint_bins[joint_name] - commands = sorted(bins) - for command in commands: - frames = bins[command] - vector = np.median( - np.asarray( - [frame.joint_vectors_xyz_m[joint_name] for frame in frames] - ), - axis=0, - ) - image_vector = np.median( - np.asarray( - [frame.image_vectors_xy_px[joint_name] for frame in frames] - ), - axis=0, - ) - relative_quaternion = robust_rotation_summary( - [ - frame.joint_quaternions_xyzw[joint_name] - for frame in frames - ] - )[0] - parent_pose = _robust_pose_payload( - [ - frame.parent_poses_common[joint_name] - for frame in frames - ] - ) - child_pose = _robust_pose_payload( - [ - frame.child_poses_common[joint_name] - for frame in frames - ] - ) - state = np.median( - np.asarray([frame.state_u8 for frame in frames], dtype=float), - axis=0, - ) - durable = canonical_sample_record({ - "kind": "sample", - "attempt": self.sweep_attempts.get( - _sweep_storage_key(item.spec), 1 - ), - "task_name": item.spec.key, - "view": self.profile.record_specs[joint_name].view, - "joint": joint_name, - "motor_index": motor, - "cycle": item.cycle, - "direction": item.direction, - "requested_command_u8": int( - self.sweep_checkpoint_target_u8 - if self.sweep_checkpoint_target_u8 is not None - else item.target_u8 - ), - "feedback_u8": int(command), - "image_stamp_ns": int( - np.median([frame.stamp_ns for frame in frames]) - ), - "relative_translation_xyz_m": [ - float(value) for value in vector - ], - "image_relative_xy_px": [ - float(value) for value in image_vector - ], - "relative_quaternion_xyzw": [ - float(value) for value in relative_quaternion - ], - "parent_pose_common": parent_pose, - "child_pose_common": child_pose, - "state_u8": [float(value) for value in state], - "state_image_sync_error_ms": round( - float( - np.percentile( - [ - abs(frame.state_sync_error_ns) - for frame in frames - ], - 95.0, - ) - / 1_000_000.0 - ), - 6, - ), - "pnp_reprojection_error_px": round( - float( - np.percentile( - [ - frame.joint_reprojection_error_px[joint_name] - for frame in frames - ], - 95.0, - ) - ), - 6, - ), - "valid_frames": len(frames), - }) - record = fitting_sample_record(durable) - self.records_by_joint[joint_name].append(record) - append_jsonl(self.raw_path, durable) - - G20ThreeCameraCalibrationNode._persist_palm_axis_samples(self, item) - G20ThreeCameraCalibrationNode._record_dedicated_baseline_hold( - self, item - ) - previous_spec = item.spec - was_fit_retry = bool( - getattr(self, "active_sweep_is_fit_retry", False) - ) - if not was_fit_retry: - self.sweep_index += 1 - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(self) - self.active_sweep = None - self.active_sweep_is_fit_retry = False - if was_fit_retry: - if self.retry_sweep_items: - following = self.retry_sweep_items[0] - transition = _sweep_plan_transition( - self.profile, item, following - ) - if transition == "cycle_reset": - self._begin_return_baseline("next_cycle") - else: - G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames( - self, - item, - following, - ) - self._start_next_sweep() - return - # All physical source items for a dependent retry are complete. - # Clear the routing before checking the final source task itself, - # so an ordinary source-task failure retries only that task. - self.retry_source_failure_task_key = None - self.retry_source_task_keys = () - self.retry_cycle_override.clear() - self.retry_preserve_pnp_continuity = False - failures = self._provisional_fit_failures(previous_spec) - diagnostic_role = ( - G20ThreeCameraCalibrationNode._cross_view_roll_diagnostic_role( - self, previous_spec - ) - ) - if diagnostic_role in {"side", "multiview"}: - G20ThreeCameraCalibrationNode._complete_cross_view_roll_diagnostic( - self, previous_spec, failures - ) - return - if failures and self._pause_for_provisional_fit_failure( - previous_spec, failures, allow_warning=True - ): - return - self.retry_sweep_spec = None - self.retry_resume_index = None - self.retry_cycles = set() - self.retry_joint_names = set() - self.fit_failure = {} - if self.sweep_index >= len(self.sweep_items): - self._begin_return_baseline("fit") - else: - self._begin_return_baseline( - G20ThreeCameraCalibrationNode._transition_after_completed_spec( - self, previous_spec - ) - ) - return - spec_complete = bool( - self.sweep_index >= len(self.sweep_items) - or self.sweep_items[self.sweep_index].spec != previous_spec - ) - if spec_complete: - failures = self._provisional_fit_failures(previous_spec) - diagnostic_role = ( - G20ThreeCameraCalibrationNode._cross_view_roll_diagnostic_role( - self, previous_spec - ) - ) - if diagnostic_role in {"side", "multiview"}: - G20ThreeCameraCalibrationNode._complete_cross_view_roll_diagnostic( - self, previous_spec, failures - ) - return - if failures and self._pause_for_provisional_fit_failure( - previous_spec, failures, allow_warning=True - ): - return - if self.retry_sweep_spec == previous_spec: - resume_index = self.retry_resume_index - self.retry_sweep_spec = None - self.retry_resume_index = None - self.retry_cycles = set() - self.retry_joint_names = set() - if resume_index is not None: - self.sweep_index = resume_index - self.fit_failure = {} - if self.sweep_index >= len(self.sweep_items): - self._begin_return_baseline("fit") - elif self.sweep_items[self.sweep_index].spec != previous_spec: - self._begin_return_baseline( - G20ThreeCameraCalibrationNode._transition_after_completed_spec( - self, previous_spec - ) - ) - elif _sweep_plan_transition( - self.profile, - item, - self.sweep_items[self.sweep_index], - ) == "cycle_reset": - # Keep the task's complete avoidance pose between its three - # repetitions. Only the active joint returns to its standard-side - # baseline (roll: 255->127); global unfolding is deferred until - # the task really changes. - self._begin_return_baseline("next_cycle") - else: - G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames( - self, - item, - self.sweep_items[self.sweep_index], - ) - self._start_next_sweep() - - def _fit_all_curves(self) -> None: - G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(self) - self.state = STATE_FITTING - self.reason = "fitting_3d_axes_and_urdf_zero_offsets" - # Fitting is deliberately synchronous so no capture callback can - # mutate the observation set midway through a model fit. Announce - # the state before entering the CPU-bound section: the outer product - # runner then applies its fitting-specific watchdog instead of - # mistaking a blocked ROS timer for a motion communication failure. - fitting_started_at = time.monotonic() - self.last_status_publish = fitting_started_at - self._publish_status(fitting_started_at) - if int(getattr(self, "sample_schema_version", 0)) >= 1: - for store_name, store in ( - ("trajectory", self.records_by_joint), - ("baseline", self.baseline_records_by_joint), - ("steady", self.command_records_by_joint), - ("palm_axis", self.palm_axis_records_by_source), - ): - try: - validate_sample_records( - record - for records in store.values() - for record in records - ) - except SampleDataContractError as error: - raise SampleDataContractError( - f"{error};store={store_name}" - ) from error - # Provisional checks have a warning band so collection can continue, - # but the final fit always re-applies the unmodified hard thresholds. - fresh_task_keys = set( - getattr(self, "recalibration_task_keys", ()) - ) - for spec in self.profile.sweep_specs: - if fresh_task_keys and spec.key not in fresh_task_keys: - continue - failures = self._provisional_fit_failures(spec) - if failures: - self._pause_for_provisional_fit_failure( - spec, failures, allow_warning=False - ) - return - training_fits: dict[str, JointCurveFit] = {} - holdout_by_joint: dict[str, tuple[float, ...]] = {} - measured: dict[str, JointCurveFit] = {} - validation_cycle = self.repetitions - 1 - training_cycles = tuple(range(validation_cycle)) - training_cycle_set = set(training_cycles) - quality_joint_names = set( - recalibration_quality_joints( - self.profile, - getattr(self, "recalibration_scope", "full"), - ) - ) - standalone_thumb_calibration = bool( - getattr(self, "standalone_thumb_calibration", False) - ) - fit_joint_names = tuple( - name - for name in self.profile.measured_joints - if ( - not standalone_thumb_calibration - or name in quality_joint_names - ) - ) - for name in fit_joint_names: - training_records = [ - record - for record in self.records_by_joint[name] - if int(record["cycle"]) in training_cycle_set - ] - holdout_records = [ - record - for record in self.records_by_joint[name] - if int(record["cycle"]) == validation_cycle - ] - holdout_model_records = ( - G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, name, holdout_records - ) - ) - zero_command = int( - self.baseline_command[ - self.profile.joint_specs[name].motor_index - ] - ) - training_fits[name] = self._fit_joint_records( - name, training_records - ) - holdout_by_joint[name] = joint_curve_holdout_errors( - training_fits[name], - holdout_model_records, - zero_command_u8=zero_command, - ) - measured[name] = self._fit_joint_records( - name, self.records_by_joint[name] - ) - command_fits: dict[str, JointCurveFit] = dict(training_fits) - command_feedback_fits: dict[str, JointCurveFit] = dict( - training_fits - ) - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - command_fits = {} - command_feedback_fits = {} - for name in fit_joint_names: - if name in G20_REFERENCE_THUMB_CMC_JOINTS: - # a609d521's dense feedback-indexed relative-rotation - # curve is the validated transfer function for the three - # CMC axes. Do not replace it with nine planar PnP - # checkpoints; those points were the source of the later - # thumb regression and combination-pose false failures. - command_fits[name] = measured[name] - continue - command_records = [ - dict(record) - for record in self.command_records_by_joint[name] - if int(record["cycle"]) == 0 - ] - if len(command_records) < 18: - failed_spec = next( - spec - for spec in self.profile.sweep_specs - if name in spec.joints - ) - self._pause_for_provisional_fit_failure( - failed_spec, - [ - { - "joint": name, - "metric": "steady_command_checkpoints", - "actual": len(command_records), - "limit": 18, - "comparison": "minimum", - } - ], - ) - return - zero_command = int( - self.baseline_command[ - self.profile.joint_specs[name].motor_index - ] - ) - if name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS: - command_fits[name] = fit_joint_image_curve( - command_records, - maximum_radial_rms_px=( - self.image_trajectory_maximum_radial_rms_px - ), - maximum_radial_p95_px=( - self.image_trajectory_maximum_radial_p95_px - ), - minimum_radius_px=( - self.image_trajectory_minimum_radius_px - ), - minimum_arc_rad=self.trajectory_minimum_arc_rad, - ) - else: - command_fits[name] = fit_rotation_joint_curve( - command_records, - zero_command_u8=zero_command, - canonical_zero_direction=canonical_zero_direction( - self.profile, name - ), - ) - command_feedback_fits[name] = self._fit_joint_records( - name, - _steady_records_in_feedback_domain(command_records), - ) - self.cross_view_roll_metrics: dict[str, dict[str, float]] = {} - self.validation_only_fits: dict[str, JointCurveFit] = {} - for name, validation_name in ( - self.profile.axis_validation_sources or {} - ).items(): - if ( - name not in training_fits - or validation_name not in quality_joint_names - ): - continue - validation_training_records = [ - record - for record in self.records_by_joint[validation_name] - if int(record["cycle"]) in training_cycle_set - ] - validation_holdout_records = [ - record - for record in self.records_by_joint[validation_name] - if int(record["cycle"]) == validation_cycle - ] - validation_holdout_model_records = ( - G20ThreeCameraCalibrationNode._records_with_baseline_holds( - self, - validation_name, - validation_holdout_records, - ) - ) - validation_training_fit = self._fit_joint_records( - validation_name, validation_training_records - ) - validation_fit = self._fit_joint_records( - validation_name, self.records_by_joint[validation_name] - ) - # Any optional post-fit random validation must query the same - # frozen training model that passed the isolated holdout. - self.validation_only_fits[validation_name] = ( - validation_training_fit - ) - # The structured task-level check above records threshold - # overruns with per-cycle evidence. At publication time this - # secondary view remains diagnostic: the primary front curve and - # the side physical axis line retain their independent hard - # quality gates. Reuse the same policy as offline replay instead - # of reintroducing an unlocalized retry from a second code path. - training_metrics = cross_view_roll_diagnostic_metrics( - training_fits[name], validation_training_fit - ) - final_metrics = cross_view_roll_diagnostic_metrics( - measured[name], validation_fit - ) - validation_errors = joint_curve_holdout_errors( - validation_training_fit, - validation_holdout_model_records, - zero_command_u8=int( - self.baseline_command[ - self.profile.record_specs[validation_name].motor_index - ] - ), - ) - holdout_by_joint[validation_name] = validation_errors - self.cross_view_roll_metrics[name] = { - **{ - f"training_{key}": float(value) - for key, value in training_metrics.items() - }, - **{ - f"final_{key}": float(value) - for key, value in final_metrics.items() - }, - } - self.joint_dynamic_diagnostics: dict[str, dict[str, Any]] = {} - for name in fit_joint_names: - cycle_travel: list[float] = [] - for cycle in range(self.repetitions): - cycle_fit = self._fit_joint_records( - name, - [ - record - for record in self.records_by_joint[name] - if int(record["cycle"]) == cycle - ], - ) - cycle_travel.append( - abs( - float(cycle_fit.angle_rad[0]) - - float(cycle_fit.angle_rad[255]) - ) - ) - holdout = np.abs( - np.asarray(holdout_by_joint[name], dtype=float) - ) - baseline_hysteresis = baseline_hysteresis_by_cycle_rad( - G20ThreeCameraCalibrationNode._baseline_hysteresis_records( - self, name, self.records_by_joint[name] - ), - zero_command_u8=int( - self.baseline_command[ - self.profile.joint_specs[name].motor_index - ] - ), - axis_xyz=( - G20ThreeCameraCalibrationNode._hysteresis_axis_for_fit( - self, - name, - self.records_by_joint[name], - measured[name], - ) - ), - ) - self.joint_dynamic_diagnostics[name] = { - "cycle_travel_rad": [ - round(float(value), 8) for value in cycle_travel - ], - "cycle_travel_range_rad": round( - max(cycle_travel) - min(cycle_travel), 8 - ), - "baseline_hysteresis_by_cycle_rad": [ - round(float(value), 8) - for value in baseline_hysteresis - ], - "holdout_cycle": validation_cycle, - "holdout_cycle_mae_rad": round(float(np.mean(holdout)), 8), - "holdout_cycle_max_rad": round(float(np.max(holdout)), 8), - } - axes: list[JointAxisMeasurement] = [] - axis_profile = ( - get_right_19_thumb_zero_profile() - if ( - self.profile.layout_id == G20_RIGHT_19_LAYOUT - and self.recalibration_scope == "thumb" - ) - else self.zero_profile - ) - for name in axis_profile.axis_joints: - group = [ - self._fit_axis_measurement(name, cycle) - for cycle in range(self.repetitions) - ] - axes.extend(self._refit_cross_view_axis_line_group(name, group)) - - try: - palm_orientation_measurements, palm_orientation_rejections = ( - fit_partial_palm_orientation_measurements( - sources=self.profile.palm_orientation_sources, - records_by_joint=self.palm_axis_records_by_source, - motor_by_source=self.profile.palm_axis_motor_by_source, - baseline_command_u8=self.baseline_command, - cycles=range(self.repetitions), - minimum_sources=int( - self.profile.minimum_palm_orientation_sources - ), - minimum_arc_rad=( - self.profile.palm_orientation_minimum_arc_rad - ), - maximum_rotation_orthogonal_rms_rad=( - self.active_maximum_rotation_orthogonal_rms_rad - ), - maximum_command_distance_u8=( - self.profile - .palm_orientation_maximum_command_distance_u8 - ), - ) - ) - except ValueError as error: - failure = str(error) - self.palm_orientation_rejections = {"coverage": failure} - self.fit_failure = { - "task": "palm_orientation_side_channel", - "failures": [ - { - "joint": "palm_orientation", - "metric": "palm_orientation_coverage", - "reason": failure, - } - ], - "directions_to_rescan": 0, - } - append_jsonl( - self.raw_path, - { - "kind": "palm_orientation_optional_source_diagnostic", - "rejections": {"coverage": failure}, - "decision": "reject_unobservable_palm_phase", - }, - ) - self._pause("palm_orientation_quality_failed") - return - if palm_orientation_rejections: - append_jsonl( - self.raw_path, - { - "kind": "palm_orientation_optional_source_diagnostic", - "rejections": dict(palm_orientation_rejections), - "decision": "use_qualified_redundant_sources", - }, - ) - - motor_by_joint = { - name: int(spec.motor_index) - for name, spec in self.profile.joint_specs.items() - } - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - if self.recalibration_scope == "full": - scoped_endpoint_joints = frozenset( - RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS - ) - else: - want_thumb_endpoints = self.recalibration_scope == "thumb" - scoped_endpoint_joints = frozenset( - name - for name in RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS - if name.startswith("thumb_") == want_thumb_endpoints - ) - command_fits = anchor_right_19_mechanical_endpoint_curves( - command_fits, - { - name: ( - [ - record - for record in self.records_by_joint[name] - if int(record["cycle"]) in training_cycle_set - ] - if name == "thumb_cmc_roll" - else self.command_records_by_joint[name] - ) - for name in scoped_endpoint_joints - }, - maximum_direction_difference_rad=( - self.command_maximum_direction_gap_rad - ), - feedback_endpoint_joints=( - frozenset({"thumb_cmc_roll"}) - & scoped_endpoint_joints - ), - endpoint_joints=scoped_endpoint_joints, - ) - measured_endpoint_zero_offsets = ( - derive_right_19_mechanical_endpoint_offsets( - self.source_urdf_path, - command_fits, - maximum_offset_rad=( - self.mechanical_endpoint_maximum_offset_rad - ), - endpoint_joints=scoped_endpoint_joints, - ) - ) - frozen_offsets = dict( - getattr( - self, - "partial_scope_fixed_zero_offsets_rad", - {}, - ) - ) - # Endpoint-derived zeros also move URDF limits or mimic offsets. - # A partial run must preserve those coupled fields together with - # the certified non-target origin, not merely freeze the solver's - # scalar offset after endpoint derivation. - measured_endpoint_zero_offsets.update( - { - name: value - for name, value in frozen_offsets.items() - if name in RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS - } - ) - endpoint_zero_offsets = { - name: value - for name, value in measured_endpoint_zero_offsets.items() - if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS - } - post_solve_endpoint_offsets = { - name: value - for name, value in measured_endpoint_zero_offsets.items() - if name in RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS - } - else: - endpoint_zero_offsets = {} - post_solve_endpoint_offsets = {} - zero_solve_arguments = dict( - source_urdf=self.source_urdf_path, - palm_orientation_measurements=palm_orientation_measurements, - curves=training_fits, - motor_by_joint=motor_by_joint, - maximum_offset_rad=self.zero_maximum_offset_rad, - joint_maximum_offset_rad=self.zero_joint_maximum_offsets_rad, - maximum_validation_mae_rad=self.maximum_validation_mae_rad, - maximum_validation_p95_rad=self.maximum_validation_p95_rad, - maximum_validation_error_rad=( - self.maximum_validation_error_rad - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else None - ), - maximum_confidence_half_width_rad=( - self.zero_maximum_confidence_half_width_rad - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else None - ), - maximum_axis_cone_mismatch_rad=( - self.zero_maximum_axis_cone_mismatch_rad - ), - maximum_systematic_axis_cone_bias_rad=( - self.cross_view_roll_maximum_axis_difference_rad - ), - maximum_observability_condition_number=( - self.zero_maximum_observability_condition_number - ), - maximum_pose_axis_line_rms_m=( - self.axis_maximum_pose_line_rms_m - ), - finger_maximum_offset_rad=self.zero_finger_maximum_offset_rad, - maximum_cycle_difference_rad=( - self.zero_maximum_axis_cycle_difference_rad - ), - hand_type=self.hand_type, - tag_layout=self.profile.layout_id, - training_cycles=training_cycles, - validation_cycle=validation_cycle, - ) - frozen_offsets = dict( - getattr(self, "partial_scope_fixed_zero_offsets_rad", {}) - ) - independent_thumb_scope = bool( - self.profile.layout_id == G20_RIGHT_19_LAYOUT - and self.recalibration_scope in {"full", "thumb"} - ) - if independent_thumb_scope: - thumb_profile = get_right_19_thumb_zero_profile() - thumb_names = set(thumb_profile.direct_zero_joints) - thumb_result = solve_urdf_zero_offsets( - **zero_solve_arguments, - measurements=[ - item for item in axes if item.joint in thumb_profile.axis_joints - ], - fixed_direct_zero_offsets_rad={ - name: value - for name, value in endpoint_zero_offsets.items() - if name in thumb_names - }, - static_output_zero_offsets_rad={ - name: value - for name, value in post_solve_endpoint_offsets.items() - if name in thumb_names - }, - zero_profile=thumb_profile, - ) - if self.recalibration_scope == "thumb": - if self.standalone_thumb_calibration: - holdout_zero_result = ( - expand_right_19_thumb_zero_result_with_cad_fingers( - thumb_result - ) - ) - else: - holdout_zero_result = merge_right_19_thumb_zero_result( - thumb_result=thumb_result, - preserved_offsets_rad=frozen_offsets, - ) - else: - # The full-hand solve still determines the 12 finger zeros and - # palm nuisance pose, but all four thumb values are immutable. - # Consequently no finger observation can pull a thumb zero. - companion_result = solve_urdf_zero_offsets( - **zero_solve_arguments, - measurements=axes, - fixed_direct_zero_offsets_rad={ - **self.zero_profile.fixed_direct_zero_offsets_rad, - **endpoint_zero_offsets, - **thumb_result.direct_offsets_rad, - }, - static_output_zero_offsets_rad={ - **self.zero_profile.static_output_zero_offsets_rad, - **post_solve_endpoint_offsets, - }, - ) - holdout_zero_result = merge_right_19_thumb_zero_result( - thumb_result=thumb_result, - companion_result=companion_result, - ) - else: - holdout_zero_result = solve_urdf_zero_offsets( - **zero_solve_arguments, - measurements=axes, - fixed_direct_zero_offsets_rad={ - **self.zero_profile.fixed_direct_zero_offsets_rad, - **endpoint_zero_offsets, - **frozen_offsets, - }, - static_output_zero_offsets_rad={ - **self.zero_profile.static_output_zero_offsets_rad, - **post_solve_endpoint_offsets, - **{ - name: value - for name, value in frozen_offsets.items() - if name in RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS - }, - }, - ) - thumb_yaw_zero_failures = ( - _thumb_yaw_zero_repeatability_failures( - holdout_zero_result, - maximum_cycle_range_rad=( - self.thumb_yaw_maximum_zero_cycle_difference_rad - ), - maximum_confidence_half_width_rad=( - self.thumb_yaw_maximum_confidence_half_width_rad - ), - ) - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else [] - ) - append_jsonl( - self.raw_path, - { - "kind": "zero_holdout_diagnostics", - "hand_type": self.hand_type, - "passed": holdout_zero_result.passed, - "direct_offsets_rad": dict( - holdout_zero_result.direct_offsets_rad - ), - "partial_scope_fixed_zero_offsets_rad": dict( - getattr( - self, - "partial_scope_fixed_zero_offsets_rad", - {}, - ) - ), - "model_base_translation_xyz_m": list( - holdout_zero_result.base_translation_xyz_m - ), - "model_base_quaternion_xyzw": list( - holdout_zero_result.base_quaternion_xyzw - ), - "cycle_offsets_rad": { - name: list(values) - for name, values in ( - holdout_zero_result.cycle_offsets_rad.items() - ) - }, - "offset_uncertainty_rad": dict( - holdout_zero_result.offset_uncertainty_rad - ), - "validation_error_by_joint_rad": dict( - holdout_zero_result.validation_error_by_joint_rad - ), - "validation_original_error_by_joint_rad": dict( - holdout_zero_result.validation_original_error_by_joint_rad - ), - "validation_improvement_by_joint_rad": dict( - holdout_zero_result.validation_improvement_by_joint_rad - ), - "validation_improvement_confidence_lower_rad": dict( - holdout_zero_result - .validation_improvement_confidence_lower_rad - ), - "axis_line_rms_m": holdout_zero_result.axis_line_rms_m, - "validation_line_error_by_joint_m": dict( - holdout_zero_result.validation_line_error_by_joint_m - ), - "offset_confidence_95_half_width_rad": dict( - holdout_zero_result.offset_confidence_half_width_rad - ), - "observability_rank": holdout_zero_result.observability_rank, - "observability_parameter_count": ( - holdout_zero_result.observability_parameter_count - ), - "observability_condition_number": ( - holdout_zero_result.observability_condition_number - ), - "failure_reasons": dict( - holdout_zero_result.failure_reasons - ), - "thumb_yaw_repeatability_failures": ( - thumb_yaw_zero_failures - ), - "axis_cone_mismatch_by_joint_rad": dict( - holdout_zero_result.axis_cone_mismatch_by_joint_rad - ), - "axis_cone_bias_classification_by_joint": dict( - holdout_zero_result - .axis_cone_bias_classification_by_joint - ), - }, - ) - holdout_errors = np.abs( - np.asarray( - [ - value - for name, values in holdout_by_joint.items() - if name in quality_joint_names - for value in values - ], - dtype=float, - ) - ) - trajectory_holdout_passed = bool( - holdout_errors.size - and float(np.mean(holdout_errors)) - <= self.maximum_validation_mae_rad - and float(np.percentile(holdout_errors, 95.0)) - <= self.maximum_validation_p95_rad - and ( - self.profile.layout_id != G20_RIGHT_19_LAYOUT - or float(np.max(holdout_errors)) - <= self.maximum_validation_error_rad - ) - and ( - self.profile.layout_id != G20_RIGHT_19_LAYOUT - or all( - float(np.mean(np.abs(np.asarray(values, dtype=float)))) - <= self.maximum_validation_mae_rad - and float(np.max(np.abs(np.asarray(values, dtype=float)))) - <= self.maximum_validation_error_rad - for name, values in holdout_by_joint.items() - if name in quality_joint_names - ) - ) - ) - self.fit_quality_passed = all( - fit.maximum_monotonic_correction_rad - <= ( - self.maximum_monotonic_correction_rad - if self.profile.joint_specs[name].active - else self.passive_maximum_monotonic_correction_rad - ) - and ( - self.profile.layout_id == G20_RIGHT_19_LAYOUT - or fit.maximum_hysteresis_rad - <= ( - self.maximum_hysteresis_rad - if self.profile.joint_specs[name].active - else self.passive_maximum_hysteresis_rad - ) - ) - for name, fit in training_fits.items() - if name in quality_joint_names - ) and all( - fit.maximum_hysteresis_rad - <= self.command_maximum_direction_gap_rad - for name, fit in command_feedback_fits.items() - if name in quality_joint_names - ) - if not trajectory_holdout_passed: - trajectory_score = { - name: float(np.percentile(np.abs(values), 95.0)) - for name, values in holdout_by_joint.items() - if name in quality_joint_names - } - worst_joint = max(trajectory_score, key=trajectory_score.get) - failed_spec = next( - spec - for spec in self.profile.sweep_specs - if worst_joint in spec.joints - ) - failures: list[dict[str, Any]] = [ - { - "joint": name, - "metric": "third_cycle_trajectory_p95_deg", - "actual": round(math.degrees(error), 6), - "limit": math.degrees( - self.maximum_validation_p95_rad - ), - "comparison": "maximum", - } - for name, error in trajectory_score.items() - if error > self.maximum_validation_p95_rad - ] - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - for name, values in holdout_by_joint.items(): - if name not in quality_joint_names: - continue - absolute = np.abs(np.asarray(values, dtype=float)) - mean_error = float(np.mean(absolute)) - maximum_error = float(np.max(absolute)) - if mean_error > self.maximum_validation_mae_rad: - failures.append( - { - "joint": name, - "metric": "third_cycle_trajectory_mae_deg", - "actual": round( - math.degrees(mean_error), 6 - ), - "limit": math.degrees( - self.maximum_validation_mae_rad - ), - "comparison": "maximum", - } - ) - if maximum_error > self.maximum_validation_error_rad: - failures.append( - { - "joint": name, - "metric": "third_cycle_trajectory_max_deg", - "actual": round( - math.degrees(maximum_error), 6 - ), - "limit": math.degrees( - self.maximum_validation_error_rad - ), - "comparison": "maximum", - } - ) - self._pause_for_provisional_fit_failure(failed_spec, failures) - return - if thumb_yaw_zero_failures: - thumb_yaw_spec = next( - spec - for spec in self.profile.sweep_specs - if "thumb_cmc_yaw" in spec.joints - ) - yaw_pair = self.zero_profile.same_view_axis_pair_by_offset.get( - "thumb_cmc_yaw", () - ) - source_task_key_set = { - observer.task_name - for observer in self.profile.palm_axis_observers - if observer.model_joint in set(yaw_pair) - } - self.retry_source_failure_task_key = thumb_yaw_spec.key - self.retry_source_task_keys = tuple( - candidate.key - for candidate in self.profile.sweep_specs - if candidate.key in source_task_key_set - ) - # A localized scalar outlier is replaced in place while the live - # PnP tracker/reference remains continuous. Non-localized yaw - # failures still select all cycles in the generic retry policy, - # but no dependent retry is allowed to create a new yaw datum. - self.retry_cycle_override.clear() - self._pause_for_provisional_fit_failure( - thumb_yaw_spec, thumb_yaw_zero_failures - ) - return - if not holdout_zero_result.passed: - self._pause_for_zero_model_failure(holdout_zero_result) - return - # Publish the exact frozen training model that was evaluated on the - # isolated final cycle. Re-solving with the holdout would invalidate - # the acceptance result. - zero_result = holdout_zero_result - self.thumb_yaw_cross_session_diagnostic = {} - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - previous_yaw = _previous_passed_joint_zero_offset( - self.session_dir, - self.serial_number, - "thumb_cmc_yaw", - ) - current_yaw = zero_result.all_active_offsets_rad.get( - "thumb_cmc_yaw" - ) - if previous_yaw is not None and current_yaw is not None: - previous_session, previous_offset = previous_yaw - delta = float(current_yaw) - float(previous_offset) - self.thumb_yaw_cross_session_diagnostic = { - "kind": "thumb_yaw_cross_session_diagnostic", - "joint": "thumb_cmc_yaw", - "previous_session": str(previous_session), - "previous_offset_deg": round( - math.degrees(previous_offset), 6 - ), - "current_offset_deg": round( - math.degrees(float(current_yaw)), 6 - ), - "absolute_difference_deg": round( - abs(math.degrees(delta)), 6 - ), - "reference_limit_deg": round( - math.degrees( - self.thumb_yaw_cross_session_diagnostic_rad - ), - 6, - ), - "classification": ( - "review_position_or_tag_installation" - if abs(delta) - > self.thumb_yaw_cross_session_diagnostic_rad - else "within_reference_band" - ), - "decision": "diagnostic_only", - } - append_jsonl( - self.raw_path, - self.thumb_yaw_cross_session_diagnostic, - ) - append_jsonl( - self.raw_path, - { - "kind": "zero_final_diagnostics", - "hand_type": self.hand_type, - "direct_offsets_rad": dict(zero_result.direct_offsets_rad), - "partial_scope_fixed_zero_offsets_rad": dict( - getattr( - self, - "partial_scope_fixed_zero_offsets_rad", - {}, - ) - ), - "model_base_translation_xyz_m": list( - zero_result.base_translation_xyz_m - ), - "model_base_quaternion_xyzw": list( - zero_result.base_quaternion_xyzw - ), - "offset_uncertainty_rad": dict( - zero_result.offset_uncertainty_rad - ), - "offset_confidence_95_half_width_rad": dict( - zero_result.offset_confidence_half_width_rad - ), - "training_cycles": list(zero_result.training_cycles), - "validation_cycle": zero_result.validation_cycle, - "axis_line_rms_m": zero_result.axis_line_rms_m, - "validation_line_error_by_joint_m": dict( - zero_result.validation_line_error_by_joint_m - ), - "observability_rank": zero_result.observability_rank, - "observability_parameter_count": ( - zero_result.observability_parameter_count - ), - "observability_condition_number": ( - zero_result.observability_condition_number - ), - "offset_covariance_rad2": dict( - zero_result.offset_covariance_rad2 - ), - "axis_cone_mismatch_by_joint_rad": dict( - zero_result.axis_cone_mismatch_by_joint_rad - ), - "axis_cone_bias_classification_by_joint": dict( - zero_result.axis_cone_bias_classification_by_joint - ), - }, - ) - # Runtime/MuJoCo consumes requested commands, never feedback bins. - # Static geometry and holdout above intentionally continue to use the - # dense feedback-domain training fit. - if standalone_thumb_calibration: - self.measured_fits = dict(command_fits) - else: - self.measured_fits = clamp_runtime_fits_to_urdf_limits( - self.source_urdf_path, - derive_mimic_passive_fits( - self.source_urdf_path, - command_fits, - profile=self.profile, - ), - endpoint_anchored_offsets_rad=endpoint_zero_offsets, - ) - self.axis_measurements = axes - self.palm_orientation_measurements = list( - palm_orientation_measurements - ) - self.palm_orientation_rejections = dict( - palm_orientation_rejections - ) - self.zero_result = zero_result - self.validated_endpoint_zero_offsets_rad = dict(endpoint_zero_offsets) - self.validation_errors_rad = [ - float(value) - for name, values in holdout_by_joint.items() - if name in quality_joint_names - for value in values - ] - self.validation_errors_rad.extend( - float(value) - for value in holdout_zero_result.validation_errors_rad - ) - if self.combination_validation_enabled: - self._build_combination_validation_items() - self._start_next_combination_validation() - elif self.validation_enabled: - self._build_validation_items() - self._start_next_validation() - else: - self.validation_items.clear() - self.validation_index = 0 - self._begin_return_baseline("finalize") - - def _build_validation_items(self) -> None: - generator = random.Random(self.validation_seed) - self.validation_items = [] - for spec in self.profile.sweep_specs: - commands = generator.sample( - list(range(16, 240)), self.validation_command_count - ) - self.validation_items.extend( - ValidationItem(spec, command) for command in commands - ) - self.validation_index = 0 - - def _build_combination_validation_items(self) -> None: - self.combination_validation_items = list( - _combination_validation_items(self.baseline_command) - ) - self.combination_validation_index = 0 - self.combination_validation_completed = False - - def _start_next_combination_validation(self) -> None: - if self.combination_validation_index >= len( - self.combination_validation_items - ): - self.active_combination_validation = None - self.combination_validation_completed = True - self._begin_return_baseline("finalize") - return - item = self.combination_validation_items[ - self.combination_validation_index - ] - self.active_combination_validation = item - for frames in self.combination_validation_frames_buffer.values(): - frames.clear() - self.position_hold_since = None - self.validation_stage_started_at = time.monotonic() - self.motion_stage_started_at = self.validation_stage_started_at - self._reset_motion_progress( - self.validation_stage_started_at, - self._command_vector_error_u8(item.command_u8), - ) - self.state = STATE_VALIDATION_MOVE - self.reason = f"combination_validation_move_{item.name}" - self._publish_speed_profile(self._normal_speed_profile()) - self._publish_command(list(item.command_u8)) - - def _finish_combination_validation_capture(self) -> None: - item = self.active_combination_validation - if item is None: - raise RuntimeError("combination validation item is missing") - observations: dict[str, Any] = {} - all_frames = [ - frame - for frames in self.combination_validation_frames_buffer.values() - for frame in frames - ] - feedback = np.median( - np.asarray([frame.state_u8 for frame in all_frames], dtype=float), - axis=0, - ) - motor_directions = _combination_motor_directions( - self.combination_validation_items, - self.combination_validation_index, - self.baseline_command, - ) - angles = _combination_joint_angles( - self.profile, - self.measured_fits, - item.command_u8, - motor_directions, - ) - model = UrdfKinematicModel(self.source_urdf_path) - model_base_common = transform_matrix( - self.zero_result.base_translation_xyz_m, - self.zero_result.base_quaternion_xyzw, - ) - pose_position_errors: list[float] = [] - pose_orientation_errors: list[float] = [] - target_errors: list[dict[str, Any]] = [] - observed_target_keys: list[str] = [] - evaluated_target_keys: list[str] = [] - pending_mounts: dict[str, np.ndarray] = {} - for view, frames in self.combination_validation_frames_buffer.items(): - by_joint: dict[str, Any] = {} - joint_names = sorted( - set.intersection( - *(set(frame.joint_quaternions_xyzw) for frame in frames) - ) - ) - for name in joint_names: - by_joint[name] = { - "relative_quaternion_xyzw": [ - float(value) - for value in robust_rotation_summary( - [frame.joint_quaternions_xyzw[name] for frame in frames] - )[0] - ], - "parent_pose_common": _robust_pose_payload( - [frame.parent_poses_common[name] for frame in frames] - ), - "child_pose_common": _robust_pose_payload( - [frame.child_poses_common[name] for frame in frames] - ), - } - observations[view] = by_joint - base_role = COMBINATION_BASE_ROLE_BY_VIEW[view] - base_candidates = [ - name - for name in joint_names - if self.profile.record_specs[name].parent_role == base_role - ] - preferred_base = COMBINATION_BASE_OBSERVER_BY_VIEW[view] - if preferred_base in base_candidates: - base_name = preferred_base - elif base_candidates: - base_name = base_candidates[0] - else: - self._retry_combination_validation_or_pause( - f"combination_{view}_has_no_visible_base_target", - time.monotonic(), - ) - return - base_pose = _robust_pose_payload( - [frame.parent_poses_common[base_name] for frame in frames] - ) - base_matrix = transform_matrix( - base_pose["translation_xyz_m"], base_pose["quaternion_xyzw"] - ) - for observation_name, model_joint in COMBINATION_TAG_TARGETS_BY_VIEW[view]: - if observation_name not in by_joint: - continue - child_pose = by_joint[observation_name]["child_pose_common"] - child_matrix = transform_matrix( - child_pose["translation_xyz_m"], - child_pose["quaternion_xyzw"], - ) - observed = np.linalg.inv(base_matrix) @ child_matrix - # ``observed`` is expressed in this view's fixed palm-Tag - # frame, while UrdfKinematicModel returns hand_base->link. - # The zero solve already estimates hand_base->common; carry - # the model through common and into the observer base frame - # before solving or applying the moving Tag mount. - link = _model_link_in_observer_base( - base_matrix, - model_base_common, - model.link_transform( - model_joint, - zero_offsets=( - self.zero_result.all_active_offsets_rad - ), - joint_angles=angles, - independent_mimic_angles=True, - ), - ) - key = f"{view}:{observation_name}" - quality_gated = key in G20_COMBINATION_REQUIRED_TARGET_KEYS - observed_target_keys.append(key) - if key not in self.combination_tag_mounts: - pending_mounts[key] = np.linalg.inv(link) @ observed - continue - else: - mount = self.combination_tag_mounts[key] - predicted = link @ mount - if quality_gated: - evaluated_target_keys.append(key) - position_error = float( - np.linalg.norm(predicted[:3, 3] - observed[:3, 3]) - ) - orientation_error = float( - ( - Rotation.from_matrix(predicted[:3, :3]).inv() - * Rotation.from_matrix(observed[:3, :3]) - ).magnitude() - ) - if quality_gated: - pose_position_errors.append(position_error) - pose_orientation_errors.append(orientation_error) - target_errors.append( - { - "target": key, - "model_joint": model_joint, - "quality_gated": quality_gated, - "position_error_m": round(position_error, 8), - "orientation_error_rad": round( - orientation_error, 8 - ), - "orientation_error_deg": round( - math.degrees(orientation_error), 6 - ), - "observed_pose_in_base": matrix_payload(observed), - "predicted_pose_in_base": matrix_payload(predicted), - } - ) - if self.combination_validation_index > 0 and not pose_position_errors: - self._retry_combination_validation_or_pause( - "combination_pose_has_no_previously_observed_visible_target", - time.monotonic(), - ) - return - position_p95 = ( - 0.0 - if not pose_position_errors - else float(np.percentile(pose_position_errors, 95.0)) - ) - orientation_p95 = ( - 0.0 - if not pose_orientation_errors - else float(np.percentile(pose_orientation_errors, 95.0)) - ) - if ( - position_p95 > self.combination_maximum_position_p95_m - or orientation_p95 > self.combination_maximum_orientation_p95_rad - ): - retry_key = ("combination", self.combination_validation_index) - append_jsonl( - self.raw_path, - { - "kind": "combination_validation_failure", - "reason": "combination_pose_prediction_failed", - "pose_index": self.combination_validation_index, - "pose_name": item.name, - "label_zh": item.label_zh, - "capture_attempt": ( - self.validation_retry_counts.get(retry_key, 0) + 1 - ), - "requested_command_u8": list(item.command_u8), - "feedback_u8": [ - round(float(value), 6) for value in feedback - ], - "angle_branch_by_motor": list(motor_directions), - "evaluated_targets": sorted(set(evaluated_target_keys)), - "target_errors": sorted( - target_errors, key=lambda value: value["target"] - ), - "valid_frames_by_view": { - view: len(frames) - for view, frames in ( - self.combination_validation_frames_buffer.items() - ) - }, - "position_p95_m": round(position_p95, 8), - "maximum_position_p95_m": round( - self.combination_maximum_position_p95_m, 8 - ), - "orientation_p95_rad": round(orientation_p95, 8), - "orientation_p95_deg": round( - math.degrees(orientation_p95), 6 - ), - "maximum_orientation_p95_rad": round( - self.combination_maximum_orientation_p95_rad, 8 - ), - "maximum_orientation_p95_deg": round( - math.degrees( - self.combination_maximum_orientation_p95_rad - ), - 6, - ), - "passed": False, - }, - ) - self._retry_combination_validation_or_pause( - "combination_pose_prediction_failed", time.monotonic() - ) - return - self.combination_tag_mounts.update(pending_mounts) - for key in set(observed_target_keys): - self.combination_tag_observation_counts[key] = ( - self.combination_tag_observation_counts.get(key, 0) + 1 - ) - for key in set(evaluated_target_keys): - self.combination_tag_validation_counts[key] = ( - self.combination_tag_validation_counts.get(key, 0) + 1 - ) - is_final_pose = self.combination_validation_index + 1 >= len( - self.combination_validation_items - ) - if is_final_pose: - coverage = combination_target_coverage( - self.combination_tag_observation_counts, - self.combination_tag_validation_counts, - ) - if not coverage["coverage_passed"]: - append_jsonl( - self.raw_path, - { - "kind": "combination_target_coverage_failure", - "pose_name": item.name, - **coverage, - }, - ) - self._retry_combination_validation_or_pause( - "combination_target_coverage_incomplete", time.monotonic() - ) - return - self.combination_position_errors_m.extend(pose_position_errors) - self.combination_orientation_errors_rad.extend(pose_orientation_errors) - append_jsonl( - self.raw_path, - { - "kind": "combination_validation_sample", - "pose_index": self.combination_validation_index, - "pose_name": item.name, - "label_zh": item.label_zh, - "requested_command_u8": list(item.command_u8), - "feedback_u8": [round(float(value), 6) for value in feedback], - "image_stamp_ns": int( - np.median([frame.stamp_ns for frame in all_frames]) - ), - "views": observations, - "observed_targets": sorted(set(observed_target_keys)), - "evaluated_targets": sorted(set(evaluated_target_keys)), - "valid_frames_by_view": { - view: len(frames) - for view, frames in self.combination_validation_frames_buffer.items() - }, - "position_p95_m": round(position_p95, 8), - "orientation_p95_rad": round(orientation_p95, 8), - "passed": True, - }, - ) - self.combination_validation_index += 1 - self.active_combination_validation = None - if self.combination_validation_index >= len( - self.combination_validation_items - ): - self.combination_validation_completed = True - self._begin_return_baseline("finalize") - else: - self._begin_return_baseline("combination_next") - - def _retry_combination_validation_or_pause( - self, reason: str, now: float - ) -> None: - if not CalibrationEngine.permits_retry("holdout", 0): - self._pause(reason) - return - item = self.active_combination_validation - if item is None: - self._pause(reason) - return - key = ("combination", self.combination_validation_index) - retries = self.validation_retry_counts.get(key, 0) - if retries >= self.automatic_sweep_retry_limit: - self._pause(reason) - return - retries += 1 - self.validation_retry_counts[key] = retries - for view in self.combination_validation_frames_buffer: - runtime = self.views.get(view) - if runtime is None: - continue - self._reset_view_trackers(runtime) - runtime.pnp_invalid_since = None - runtime.pnp_reset_count += 1 - append_jsonl( - self.raw_path, - { - "kind": "automatic_combination_retry", - "pose_name": item.name, - "reason": reason, - "retry": retries, - "retry_limit": self.automatic_sweep_retry_limit, - }, - ) - for frames in self.combination_validation_frames_buffer.values(): - frames.clear() - self.validation_stage_started_at = now - self.motion_stage_started_at = now - self.position_hold_since = None - self.state = STATE_VALIDATION_MOVE - self.reason = f"automatic_retry_{reason}" - self._reset_motion_progress(now, self._command_vector_error_u8(item.command_u8)) - self._publish_command(list(item.command_u8)) - - def _start_next_validation(self) -> None: - if self.validation_index >= len(self.validation_items): - self.active_validation = None - self._begin_return_baseline("finalize") - return - self.active_validation = self.validation_items[self.validation_index] - validation_motor = self.active_validation.spec.motor_index - validation_target = float(self.active_validation.command_u8) - validation_current = ( - float(self.latest_state_u8[validation_motor]) - if len(self.latest_state_u8) == 20 - else validation_target - ) - self.active_validation_direction = ( - DIRECTION_INCREASING - if validation_target > validation_current + 0.5 - else DIRECTION_DECREASING - if validation_target < validation_current - 0.5 - else canonical_zero_direction( - self.profile, self.active_validation.spec.joints[0] - ) - ) - self.validation_frames_buffer.clear() - self.position_hold_since = None - self.validation_stage_started_at = time.monotonic() - self._reset_motion_progress( - self.validation_stage_started_at, - self._motion_command_error_u8( - self.active_validation.spec, - self.active_validation.command_u8, - ), - ) - self.state = STATE_VALIDATION_MOVE - self.reason = ( - f"validation_{self.active_validation.spec.view}_motor_" - f"{self.active_validation.spec.motor_index}_" - f"command_{self.active_validation.command_u8}" - ) - self._publish_speed_profile( - self._speed_profile_for_spec(self.active_validation.spec) - ) - self._publish_command( - build_calibration_motion_command( - self.active_validation.spec, - self.active_validation.command_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - ) - - def _finish_validation_capture(self) -> None: - assert self.active_validation is not None - item = self.active_validation - command = item.command_u8 - for name in item.spec.joints: - selected = _frames_for_joint( - self.validation_frames_buffer, name - ) - if len(selected) < self.validation_frames: - raise RuntimeError( - f"random validation {name} has too few frames" - ) - fit = self.measured_fits.get(name) - if fit is None: - fit = self.validation_only_fits[name] - quaternion = robust_rotation_summary( - [ - frame.joint_quaternions_xyzw[name] - for frame in selected - ] - )[0] - image_relative_xy_px = np.median( - np.asarray( - [ - frame.image_vectors_xy_px[name] - for frame in selected - ], - dtype=float, - ), - axis=0, - ) - observed = measure_joint_curve_observation( - fit, - quaternion_xyzw=quaternion, - image_relative_xy_px=image_relative_xy_px, - ) - expected_curve = ( - fit.decreasing_rad - if self.active_validation_direction == DIRECTION_DECREASING - else fit.increasing_rad - if self.active_validation_direction == DIRECTION_INCREASING - else fit.angle_rad - ) - expected = float(expected_curve[command]) - self.validation_errors_rad.append(observed - expected) - previous_spec = item.spec - self.validation_index += 1 - self.active_validation = None - self.active_validation_direction = None - if self.validation_index >= len(self.validation_items): - self._begin_return_baseline("finalize") - elif self.validation_items[self.validation_index].spec != previous_spec: - self._begin_return_baseline("validation_next") - else: - self._start_next_validation() - - def _finalize(self) -> None: - if self.zero_result is None: - raise RuntimeError("URDF zero solution is missing") - errors = np.abs(np.asarray(self.validation_errors_rad, dtype=float)) - validation_passed = bool( - self.zero_result.passed - and ( - not self.combination_validation_enabled - or self.combination_validation_completed - ) - and ( - not self.validation_enabled - or ( - errors.size > 0 - and float(np.mean(errors)) - <= self.maximum_validation_mae_rad - and float(np.percentile(errors, 95.0)) - <= self.maximum_validation_p95_rad - and ( - self.profile.layout_id != G20_RIGHT_19_LAYOUT - or float(np.max(errors)) - <= self.maximum_validation_error_rad - ) - ) - ) - ) - passed = bool(self.fit_quality_passed and validation_passed) - if not passed: - raise RuntimeError("final_calibration_quality_failed") - endpoint_zero_offsets = ( - G20ThreeCameraCalibrationNode - ._endpoint_zero_offsets_for_publication(self) - ) - stamp = ( - self.session_dir.name - if re.fullmatch(r"\d{8}_\d{6}", self.session_dir.name) - else None - ) - # Zero calibration changes only joint-origin rotations. Measured - # motion curves belong in schema-v4 JSON and must never enlarge the - # original CAD/mechanical safety limits in the generated URDF. - # JSON schema v4 publishes eight decimal places. Generate the URDF - # from those exact same values so the pair is numerically inseparable - # instead of differing by harmless formatter quantisation. - published_zero_offsets = { - name: round(float(value), 8) - for name, value in self.zero_result.all_active_offsets_rad.items() - } - # All models cross the same internal 3+1 result gate before either - # their deployed JSON serializer or the URDF patcher may run. - engine = getattr(self, "calibration_engine", None) - if engine is not None: - self.unified_result = engine.result_from_fit( - self.zero_result, - curves=self.measured_fits, - zero_offsets_rad=published_zero_offsets, - holdout_errors_rad={ - "random_validation": tuple(self.validation_errors_rad) - }, - metadata={"layout_id": self.profile.layout_id}, - ) - urdf_offsets = published_zero_offsets - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - urdf_offsets = { - name: published_zero_offsets[name] - for name in self.zero_profile.direct_zero_joints - } - correction_plan = None - typed_profile = getattr(self, "calibration_profile", None) - if typed_profile is not None: - frozen_names = typed_profile.scope.frozen_joints[ - self.recalibration_scope - ] - correction_plan = build_correction_plan( - typed_profile, - source_sha256=_file_sha256(self.source_urdf_path), - scope=self.recalibration_scope, - frozen_offsets_rad={ - name: urdf_offsets[name] for name in frozen_names - }, - ) - urdf_input_path = self.final_path.with_name( - f"{self.final_path.stem}_urdf_correction_input.json" - ) - atomic_write_json( - urdf_input_path, - build_g20_urdf_input_payload( - side=self.hand_type, - layout_id=self.profile.layout_id, - serial_number=self.serial_number, - source_urdf=self.source_urdf_path, - offsets_rad=urdf_offsets, - endpoint_anchored_offsets_rad=endpoint_zero_offsets, - ), - ) - urdf_offsets, endpoint_zero_offsets = load_g20_urdf_input( - urdf_input_path, - source_urdf=self.source_urdf_path, - side=self.hand_type, - layout_id=self.profile.layout_id, - serial_number=self.serial_number, - ) - self.corrected_urdf_path = write_zero_corrected_urdf( - source_urdf=self.source_urdf_path, - output_directory=self.corrected_urdf_output_dir, - serial_number=self.serial_number, - offsets_rad=urdf_offsets, - endpoint_anchored_offsets_rad=endpoint_zero_offsets, - timestamp=stamp, - correction_plan=correction_plan, - ) - try: - if self.standalone_thumb_calibration: - payload = build_standalone_thumb_payload( - profile=self.profile, - serial_number=self.serial_number, - measured_fits=self.measured_fits, - thumb_offsets_rad={ - name: published_zero_offsets[name] - for name in ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - ) - }, - validation_errors_rad=self.validation_errors_rad, - baseline_command_u8=self.baseline_command, - source_urdf_sha256=_file_sha256( - self.source_urdf_path - ), - camera_extrinsics_sha256=_file_sha256( - self.camera_extrinsics_file - ), - corrected_urdf_sha256=_file_sha256( - self.corrected_urdf_path - ), - ) - else: - payload = build_compact_payload( - serial_number=self.serial_number, - measured_fits=self.measured_fits, - urdf_zero_offsets_rad=published_zero_offsets, - validation_errors_rad=self.validation_errors_rad, - passed=passed, - baseline=self.baseline_command, - side=self.hand_type, - layout_id=self.profile.layout_id, - zero_uncertainty_rad=( - self.zero_result.offset_confidence_half_width_rad - ), - zero_cycle_offsets_rad=self.zero_result.cycle_offsets_rad, - zero_observers=self.zero_profile.offset_observer_joint, - artifact_hashes=( - { - "source_urdf_sha256": _file_sha256( - self.source_urdf_path - ), - "camera_extrinsics_sha256": _file_sha256( - self.camera_extrinsics_file - ), - "corrected_urdf_sha256": _file_sha256( - self.corrected_urdf_path - ), - } - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else None - ), - cross_view_roll_metrics=getattr( - self, "cross_view_roll_metrics", {} - ), - joint_dynamic_diagnostics=getattr( - self, "joint_dynamic_diagnostics", {} - ), - zero_geometry_diagnostics=( - { - "training_cycles": self.zero_result.training_cycles, - "validation_cycle": self.zero_result.validation_cycle, - "axis_line_rms_m": self.zero_result.axis_line_rms_m, - "validation_line_error_by_joint_m": ( - self.zero_result.validation_line_error_by_joint_m - ), - "observability_rank": ( - self.zero_result.observability_rank - ), - "observability_parameter_count": ( - self.zero_result.observability_parameter_count - ), - "observability_condition_number": ( - self.zero_result.observability_condition_number - ), - "offset_covariance_rad2": ( - self.zero_result.offset_covariance_rad2 - ), - "palm_orientation_sources": [ - { - "source_joint": item.source_joint, - "model_joint": item.model_joint, - "cycle": item.cycle, - "observed_arc_rad": item.observed_arc_rad, - "rotation_orthogonal_rms_rad": ( - item.rotation_orthogonal_rms_rad - ), - "axis_estimator": item.axis_estimator, - "incremental_pair_count": ( - item.incremental_pair_count - ), - } - for item in getattr( - self, - "palm_orientation_measurements", - (), - ) - ], - "palm_orientation_rejections": dict( - getattr( - self, - "palm_orientation_rejections", - {}, - ) - ), - } - if self.profile.layout_id == G20_RIGHT_19_LAYOUT - else None - ), - ) - # The JSON is the commit marker for the URDF/JSON pair. It is - # atomically renamed only after the corrected URDF and all three - # artifact hashes have been produced and schema-validated. - atomic_write_json(self.final_path, payload) - except Exception: - if self.profile.layout_id == G20_RIGHT_19_LAYOUT: - rejected = self.corrected_urdf_path.with_suffix( - ".urdf.rejected" - ) - self.corrected_urdf_path.replace(rejected) - self.corrected_urdf_path = None - raise - self.completed_payload = payload - self.state = STATE_COMPLETE - self.reason = "calibration_passed" if passed else "quality_failed" - self.get_logger().info( - f"Three-camera calibration complete: passed={passed}; {self.final_path}" - ) - - def _pause(self, reason: str) -> None: - self._publish_hold_current() - self.paused_reason = str(reason) - self.state = STATE_PAUSED - self.reason = str(reason) - self.position_hold_since = None - - def _handle_sweep_start_timeout(self, now: float, reached: bool) -> None: - if not reached: - self._pause("sweep_start_position_timeout") - return - assert self.active_sweep is not None - # Vision is not a live motion interlock. Start the sweep and let the - # retained-data gate decide whether this direction needs its single - # same-speed rescan. - append_jsonl( - self.raw_path, - { - "kind": "sweep_start_vision_timeout_warning", - "task_name": self.active_sweep.spec.key, - "cycle": self.active_sweep.cycle, - "direction": self.active_sweep.direction, - }, - ) - self.position_hold_since = None - self._begin_active_sweep(now) - - def _retry_motion_or_pause(self, reason: str, now: float) -> None: - if not CalibrationEngine.permits_retry("motion", 0): - self._pause(reason) - return - retries = self.motion_retry_counts.get(reason, 0) - if retries >= self.automatic_motion_retry_limit: - self._pause(reason) - return - retries += 1 - self.motion_retry_counts[reason] = retries - reset_views: list[str] = [] - task_reference_preserved = False - if ( - reason == "sweep_start_tag_timeout" - and self.state == STATE_PREPARE_SWEEP - and self.active_sweep is not None - ): - # Re-publishing the same endpoint command cannot repair a PnP - # tracker stuck on a planar mirror branch. Give every active - # camera a genuinely fresh bounded initialization window while - # retaining the task-level endpoint reference established by the - # visibility precheck/earlier cycles. - profile = _node_profile(self) - task_reference_preserved = ( - _preserve_pnp_task_reference_for_sweep( - self.active_sweep, - is_fit_retry=bool( - getattr(self, "active_sweep_is_fit_retry", False) - ), - has_precheck_anchor=( - profile.layout_id == G20_RIGHT_19_LAYOUT - ), - ) - ) - reset_trackers = getattr(self, "_reset_view_trackers", None) - reset_diagnostics = getattr( - self, "_reset_view_pnp_diagnostics", None - ) - for view in _sweep_views(profile, self.active_sweep.spec): - runtime = getattr(self, "views", {}).get(view) - if runtime is None or not callable(reset_trackers): - continue - reset_trackers( - runtime, - preserve_task_reference=task_reference_preserved, - ) - if callable(reset_diagnostics): - reset_diagnostics(runtime) - runtime.pnp_invalid_since = None - runtime.pnp_reset_count += 1 - reset_views.append(view) - getattr(self, "sweep_start_frames", []).clear() - append_jsonl( - self.raw_path, - { - "kind": "automatic_motion_retry", - "state": self.state, - "reason": reason, - "retry": retries, - "retry_limit": self.automatic_motion_retry_limit, - "pnp_trackers_reset": reset_views, - "task_reference_preserved": task_reference_preserved, - }, - ) - self.motion_stage_started_at = now - self.position_hold_since = None - if self.state == STATE_RETURN_BASELINE: - self._reset_motion_progress(now, self._baseline_error_u8()) - return_command = ( - G20ThreeCameraCalibrationNode._current_return_command(self) - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, return_command - ) - ) - self._publish_command( - list(return_command) - ) - elif self.state == STATE_PREPARE_SWEEP and self.active_sweep is not None: - preparation_command = getattr( - self, - "preparation_command_u8", - tuple( - build_calibration_motion_command( - self.active_sweep.spec, - self.active_sweep.start_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - ), - ) - self._reset_motion_progress( - now, - self._command_vector_error_u8(preparation_command), - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, preparation_command - ) - ) - self._publish_command(list(preparation_command)) - self.reason = f"automatic_retry_{reason}" - - def _retry_validation_or_pause(self, reason: str, now: float) -> None: - if not CalibrationEngine.permits_retry("holdout", 0): - self._pause(reason) - return - if self.active_validation is None: - self._pause(reason) - return - item = self.active_validation - key = (_sweep_storage_key(item.spec), item.command_u8) - retries = self.validation_retry_counts.get(key, 0) - retry_limit = self.automatic_sweep_retry_limit - if retries >= retry_limit: - self._pause(reason) - return - retries += 1 - self.validation_retry_counts[key] = retries - for view in _sweep_views(_node_profile(self), item.spec): - runtime = self.views[view] - self._reset_view_trackers(runtime) - runtime.pnp_reset_count += 1 - self.validation_frames_buffer.clear() - self.validation_stage_started_at = now - self._reset_motion_progress( - now, - self._motion_command_error_u8(item.spec, item.command_u8), - ) - self.position_hold_since = None - self.state = STATE_VALIDATION_MOVE - append_jsonl( - self.raw_path, - { - "kind": "automatic_validation_retry", - "view": item.spec.view, - "motor_index": item.spec.motor_index, - "requested_command_u8": item.command_u8, - "reason": reason, - "retry": retries, - "retry_limit": retry_limit, - }, - ) - self._publish_speed_profile(self._speed_profile_for_spec(item.spec)) - self._publish_command( - build_calibration_motion_command( - item.spec, - item.command_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - ) - self.reason = f"automatic_retry_{reason}" - - def _timer_callback(self) -> None: - now = time.monotonic() - try: - self._advance(now) - except Exception as error: - self.get_logger().error( - f"Calibration paused: {error}\n{traceback.format_exc()}" - ) - self._pause(str(error)) - if now - self.last_status_publish >= 0.5: - self.last_status_publish = now - self._publish_status(now) - - def _advance(self, now: float) -> None: - if self.state == STATE_PREFLIGHT: - if not self.started: - if self._all_devices_ready(now): - self.state = STATE_WAIT_START - self.reason = "call_start_for_baseline_recovery" - elif self.startup_baseline_recovered and self._all_preflight_ready(now): - locker = getattr(self, "_lock_fixed_base_references", None) - if locker is None or locker(): - if getattr(self, "resume_checkpoint_pending", False): - self.resume_checkpoint_pending = False - self.state = STATE_IMPORTING_BASE - self.reason = "reading_base_session_records" - self.base_import_progress = { - "phase": "reading", - "records_read": 0, - "bytes_read": 0, - "total_bytes": int( - self.resume_raw_samples_path.stat().st_size - ), - "fraction": 0.0, - } - self._publish_status(time.monotonic()) - self._restore_durable_task_checkpoint() - self._start_next_sweep() - else: - self.reason = "locking_fixed_base_references" - return - if self.state == STATE_WAIT_START: - if not self._all_devices_ready(now): - self.state = STATE_PREFLIGHT - self.reason = "device_preflight_lost" - return - if self.state == STATE_RETURN_BASELINE: - baseline_reached = self._baseline_reached() - baseline_details = self._baseline_error_details() - if ( - not baseline_reached - and self._pause_if_motion_stalled( - now=now, - error_u8=float(baseline_details["error_u8"]), - context="return_baseline", - details=baseline_details, - ) - ): - return - if baseline_reached: - return_waypoints = getattr( - self, "return_waypoints", deque() - ) - if return_waypoints: - # Intermediate avoidance waypoints need confirmed feedback, - # but only the final baseline needs the dedicated 0.5 s - # backlash hold. This removes several seconds of no-op - # waiting from every multi-finger return sequence. - self.return_command_u8 = return_waypoints.popleft() - self.position_hold_since = None - self.motion_stage_started_at = now - self._reset_motion_progress( - now, self._baseline_error_u8() - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, self.return_command_u8 - ) - ) - self._publish_command( - list(self.return_command_u8) - ) - return - if self.position_hold_since is None: - self.position_hold_since = now - elif now - self.position_hold_since >= self.baseline_hold_seconds: - after = self.baseline_after - self.motion_retry_counts.pop("return_baseline_timeout", None) - self.position_hold_since = None - if after in { - "next_sweep", - "next_cycle", - "next_task_same_finger", - "resume_sweep", - "retry_sweep", - }: - self._start_next_sweep() - elif after == "fit": - self._fit_all_curves() - elif after in {"validation_next", "resume_validation"}: - self._start_next_validation() - elif after == "combination_next": - self._start_next_combination_validation() - elif after == "finalize": - self._finalize() - elif after == "startup_tag_preflight": - # Discard observations and PnP branches from the - # interrupted pose. The normal preflight then requires - # a fresh window of all three fixed palm Tags while the - # hand is confirmed at baseline. - self._finish_startup_baseline_recovery() - elif after == "abort": - self.state = STATE_ABORTED - self.reason = ( - "safe_abort_complete:" - f"{self.abort_original_reason or 'operator_abort'}" - ) - else: - self.position_hold_since = None - return - if self.state == STATE_PREPARE_SWEEP: - assert self.active_sweep is not None - preparation_command = getattr( - self, - "preparation_command_u8", - tuple( - build_calibration_motion_command( - self.active_sweep.spec, - self.active_sweep.start_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - ), - ) - reached = self._command_vector_reached(preparation_command) - if ( - reached - and now - self.motion_stage_started_at - > self.position_timeout_seconds - ): - self._handle_sweep_start_timeout(now, reached) - return - prepare_context = ( - f"prepare_motor_{self.active_sweep.spec.motor_index}" - ) - prepare_details = self._command_vector_error_details( - preparation_command, prepare_context - ) - if ( - not reached - and self._pause_if_motion_stalled( - now=now, - error_u8=self._command_vector_error_u8( - preparation_command - ), - context=prepare_context, - details=prepare_details, - ) - ): - return - preparation_waypoints = getattr( - self, "preparation_waypoints", deque() - ) - if reached and preparation_waypoints: - self.preparation_command_u8 = ( - preparation_waypoints.popleft() - ) - self.motion_stage_started_at = now - self.position_hold_since = None - self._reset_motion_progress( - now, - self._command_vector_error_u8( - self.preparation_command_u8 - ), - ) - self._publish_speed_profile( - G20ThreeCameraCalibrationNode._transition_speed_profile( - self, self.preparation_command_u8 - ) - ) - self._publish_command(list(self.preparation_command_u8)) - return - if reached: - # Pose-entry waypoints use conservative joint-class speeds. - # Switch to the accepted task scan speed only after the full - # start pose has been reached, then honour the SDK settle time. - self._publish_speed_profile( - self._speed_profile_for_spec(self.active_sweep.spec) - ) - speed_ready = bool( - now - self.speed_commanded_at - >= self.speed_setting_settle_seconds - ) - # Do not leave the start endpoint until every contributing camera - # has enough timestamp-synchronised observations. The retained - # frames become the strict 0/255 endpoint bin and, in the first - # training round, the first steady command checkpoint. - minimum_start_frames = ( - 3 - if _steady_checkpoint_commands( - _node_profile(self), self.active_sweep - ) - else 1 - ) - start_frames_ready = _frames_cover_sweep_joints( - self.sweep_start_frames, - self.active_sweep.spec, - minimum_start_frames, - ) - if reached and speed_ready and not start_frames_ready: - self.reason = "waiting_for_task_tags_at_sweep_start" - if ( - reached - and speed_ready - and start_frames_ready - ): - if self.position_hold_since is None: - self.position_hold_since = now - elif now - self.position_hold_since >= self._active_endpoint_hold_seconds(): - self.motion_retry_counts.pop( - "sweep_start_position_timeout", None - ) - self.motion_retry_counts.pop( - "sweep_start_tag_timeout", None - ) - self.position_hold_since = None - self._begin_active_sweep(now) - else: - self.position_hold_since = None - return - if self.state == STATE_SWEEP: - assert self.active_sweep is not None - motor = self.active_sweep.spec.motor_index - # Temporary vision/synchronisation loss only drops those frames. - # Complete the motion and let retained-data quality request the - # single same-speed rescan when necessary. - checkpoint_target_value = getattr( - self, "sweep_checkpoint_target_u8", None - ) - checkpoint_active = bool( - getattr(self, "sweep_checkpoint_mode", False) - and checkpoint_target_value is not None - and not getattr(self, "sweep_baseline_pending", False) - ) - motion_target_u8 = ( - int(self.baseline_command[motor]) - if getattr(self, "sweep_baseline_pending", False) - else int( - checkpoint_target_value - if checkpoint_target_value is not None - else self.active_sweep.target_u8 - ) - ) - target_reached = ( - self._steady_checkpoint_reached( - self.active_sweep.spec, motion_target_u8 - ) - if checkpoint_active - else self._motion_command_reached( - self.active_sweep.spec, motion_target_u8 - ) - ) - sweep_context = ( - f"sweep_baseline_motor_{motor}" - if getattr(self, "sweep_baseline_pending", False) - else f"sweep_motor_{motor}" - ) - sweep_details = self._motion_command_error_details( - self.active_sweep.spec, - motion_target_u8, - sweep_context, - ) - if ( - checkpoint_active - and int(sweep_details.get("motor_index", -1)) == motor - ): - sweep_details["tolerance_u8"] = max( - float( - self.steady_checkpoint_command_feedback_tolerance_u8 - ), - float(sweep_details["tolerance_u8"]), - ) - if ( - not target_reached - and self._pause_if_motion_stalled( - now=now, - error_u8=float(sweep_details["error_u8"]), - context=sweep_context, - details=sweep_details, - ) - ): - return - if getattr(self, "sweep_baseline_pending", False): - if target_reached: - if self.sweep_baseline_hold_since is None: - # Frames acquired during deceleration into 127 are not - # static observations. Wait baseline_hold_seconds - # after feedback first reaches 127, then collect a - # fresh multi-frame baseline sample. - self.sweep_baseline_frames.clear() - self.sweep_baseline_hold_since = now - elif ( - now - self.sweep_baseline_hold_since - >= self.baseline_hold_seconds - and _frames_cover_sweep_joints( - self.sweep_baseline_frames, - self.active_sweep.spec, - self.minimum_baseline_hold_frames, - ) - ): - self.sweep_baseline_pending = False - self.sweep_baseline_hold_since = None - self.sweep_endpoint_since = None - self.reason = ( - "collecting_timestamp_synchronised_tag_centres" - ) - if getattr(self, "sweep_checkpoint_mode", False): - self._publish_next_checkpoint(now) - else: - self._reset_motion_progress( - now, - self._motion_command_error_u8( - self.active_sweep.spec, - self.active_sweep.target_u8, - ), - ) - self._publish_command( - build_calibration_motion_command( - self.active_sweep.spec, - self.active_sweep.target_u8, - baseline=self.baseline_command, - profile=self.profile, - ) - ) - else: - self.sweep_baseline_hold_since = None - self.sweep_baseline_frames.clear() - return - if getattr(self, "sweep_checkpoint_mode", False): - checkpoint_target = self.sweep_checkpoint_target_u8 - if checkpoint_target is None: - if self.sweep_checkpoint_commands: - # Recover a missing in-flight target by publishing the - # next queued checkpoint instead of aborting a healthy - # hardware session. - self._publish_next_checkpoint(now) - return - self.sweep_checkpoint_mode = False - else: - if target_reached: - if self.sweep_checkpoint_hold_since is None: - self.sweep_checkpoint_frames.clear() - self.sweep_checkpoint_hold_since = now - elif ( - now - self.sweep_checkpoint_hold_since - >= self.endpoint_hold_seconds - and _frames_cover_sweep_joints( - self.sweep_checkpoint_frames, - self.active_sweep.spec, - 3, - ) - ): - if not self._steady_checkpoint_feedback_is_stable( - self.active_sweep.spec, - self.sweep_checkpoint_frames, - ): - # The command/feedback offset is acceptable, - # but feedback is still settling inside that - # band. Restart the hold window without - # issuing another command or weakening the - # mechanical-stall gate. - self.sweep_checkpoint_hold_since = now - self.sweep_checkpoint_frames.clear() - return - self._record_command_checkpoint( - self.active_sweep, - int(checkpoint_target), - list(self.sweep_checkpoint_frames), - ) - self.sweep_checkpoint_hold_since = None - self.sweep_checkpoint_frames.clear() - if self._publish_next_checkpoint(now): - return - self.sweep_endpoint_since = now - else: - self.sweep_checkpoint_hold_since = None - self.sweep_checkpoint_frames.clear() - if self.sweep_checkpoint_target_u8 is not None: - return - if target_reached: - if self.sweep_endpoint_since is None: - self.sweep_endpoint_since = now - else: - self.sweep_endpoint_since = None - if ( - self.sweep_endpoint_since is not None - and now - self.sweep_endpoint_since - >= self._active_endpoint_hold_seconds() - ): - self._finish_active_sweep() - return - if self.state == STATE_VALIDATION_MOVE: - if self.active_combination_validation is not None: - command = self.active_combination_validation.command_u8 - reached = self._command_vector_reached(command) - details = self._command_vector_error_details( - command, - f"combination_{self.active_combination_validation.name}", - ) - if ( - not reached - and self._pause_if_motion_stalled( - now=now, - error_u8=float(details["error_u8"]), - context=str(details["stage"]), - details=details, - ) - ): - return - if reached: - if self.position_hold_since is None: - self.position_hold_since = now - elif now - self.position_hold_since >= self.endpoint_hold_seconds: - self.position_hold_since = None - for frames in self.combination_validation_frames_buffer.values(): - frames.clear() - self.validation_stage_started_at = now - self.state = STATE_VALIDATION_CAPTURE - self.reason = ( - "combination_validation_capture_" - f"{self.active_combination_validation.name}" - ) - else: - self.position_hold_since = None - return - assert self.active_validation is not None - reached = self._motion_command_reached( - self.active_validation.spec, - self.active_validation.command_u8, - ) - validation_context = ( - "validation_motor_" - f"{self.active_validation.spec.motor_index}" - ) - validation_details = self._motion_command_error_details( - self.active_validation.spec, - self.active_validation.command_u8, - validation_context, - ) - if ( - not reached - and self._pause_if_motion_stalled( - now=now, - error_u8=float(validation_details["error_u8"]), - context=validation_context, - details=validation_details, - ) - ): - return - speed_ready = bool( - now - self.speed_commanded_at - >= self.speed_setting_settle_seconds - ) - if reached and speed_ready: - if self.position_hold_since is None: - self.position_hold_since = now - elif now - self.position_hold_since >= self.endpoint_hold_seconds: - self.position_hold_since = None - self.validation_frames_buffer.clear() - self.validation_stage_started_at = now - self.state = STATE_VALIDATION_CAPTURE - self.reason = "capturing_random_validation_pose" - else: - self.position_hold_since = None - return - if self.state == STATE_VALIDATION_CAPTURE: - if self.active_combination_validation is not None: - if all( - len(frames) >= self.combination_validation_frames - for frames in self.combination_validation_frames_buffer.values() - ): - self._finish_combination_validation_capture() - elif now - self.validation_stage_started_at > self.validation_timeout_seconds: - self._retry_combination_validation_or_pause( - "combination_validation_capture_timeout", now - ) - return - if _frames_cover_sweep_joints( - self.validation_frames_buffer, - self.active_validation.spec, - self.validation_frames, - ): - self._finish_validation_capture() - elif now - self.validation_stage_started_at > self.validation_timeout_seconds: - self._retry_validation_or_pause( - "validation_capture_timeout", now - ) - - def _publish_status(self, now: float) -> None: - views: dict[str, dict[str, Any]] = {} - for name, runtime in self.views.items(): - required_roles = set(self._required_roles_for_view(name)) - live_required_roles = set( - self._live_required_roles_for_view( - name, tuple(required_roles) - ) - ) - locked_role = self._locked_base_role_for_active_capture(name) - locked_roles = set(() if locked_role is None else (locked_role,)) - views[name] = { - "ready": self._view_ready(runtime, now), - "stream_alive": bool( - runtime.last_message_at > 0.0 - and now - runtime.last_message_at <= 1.0 - ), - "camera_info_valid": runtime.camera_info_valid, - "camera_extrinsics_valid": runtime.extrinsics_valid, - "detection_hz": round(runtime.detection_hz, 2), - "valid_rate": round(runtime.valid_rate, 4), - "required_tag_ids": sorted( - runtime.view_tags[role] for role in required_roles - ), - "locked_reference_tag_ids": sorted( - runtime.view_tags[role] for role in locked_roles - ), - "locked_base_corner_drift_px": round( - runtime.latest_locked_base_corner_drift_px, 4 - ), - "locked_base_maximum_corner_drift_px": ( - self.fixed_base_maximum_corner_drift_px - ), - "locked_base_corner_drift_count": ( - runtime.locked_base_corner_drift_count - ), - "configured_tag_ids": sorted(runtime.view_tags.values()), - "visible_configured_tag_ids": sorted( - tag_id - for role, tag_id in runtime.view_tags.items() - if role in runtime.latest_tag_quality - and self._quality_valid( - runtime.latest_tag_quality[role], include_pnp=False - ) - ), - "detected_tag_ids": sorted( - tag_id - for tag_id, role in runtime.role_by_id.items() - if role in required_roles - and role in runtime.latest_tag_quality - ), - "missing_tag_ids": sorted( - tag_id - for tag_id, role in runtime.role_by_id.items() - if role in live_required_roles - and role not in runtime.latest_tag_quality - ), - "pnp_rejections": dict(runtime.latest_pnp_rejections), - "group_pnp_reason": runtime.latest_group_pnp_reason, - "group_missing_candidate_roles": list( - runtime.latest_group_missing_candidate_roles - ), - "pnp_candidate_diagnostics": { - role: { - "tag_id": runtime.view_tags[role], - **dict(diagnostics), - } - for role, diagnostics in ( - runtime.tracker.last_candidate_diagnostics_by_role.items() - ) - if role in required_roles and role not in locked_roles - }, - "pnp_pose_valid": runtime.latest_pnp_valid, - "pnp_rejection_counts": dict( - runtime.pnp_rejection_counts - ), - "group_pnp_rejection_counts": dict( - runtime.group_pnp_rejection_counts - ), - "pnp_initialization_progress": ( - None - if runtime.pnp_initialization_progress is None - else { - "accepted": runtime.pnp_initialization_progress[0], - "required": runtime.pnp_initialization_progress[1], - } - ), - "pnp_reset_count": runtime.pnp_reset_count, - "pnp_invalid_seconds": ( - 0.0 - if runtime.pnp_invalid_since is None - else round(max(0.0, now - runtime.pnp_invalid_since), 3) - ), - } - active: dict[str, Any] = {} - if self.motion_stall_details: - active = dict(self.motion_stall_details) - elif self.active_sweep is not None: - motor = self.active_sweep.spec.motor_index - start_tolerance = ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - self, - self.active_sweep.spec, - self.active_sweep.start_u8, - ) - ) - target_tolerance = ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - self, - self.active_sweep.spec, - self.active_sweep.target_u8, - ) - ) - mechanical_start_tolerance = self._endpoint_tolerance_for_spec( - self.active_sweep.spec, self.active_sweep.start_u8 - ) - mechanical_target_tolerance = self._endpoint_tolerance_for_spec( - self.active_sweep.spec, self.active_sweep.target_u8 - ) - values = [ - float(frame.state_u8[motor]) for frame in self.sweep_frames - ] - bins = sorted( - { - ( - self.active_sweep.start_u8 - if abs(value - self.active_sweep.start_u8) - <= start_tolerance - else self.active_sweep.target_u8 - if abs(value - self.active_sweep.target_u8) - <= target_tolerance - else int(np.clip(np.rint(value), 0, 255)) - ) - for value in values - } - ) - adjacent_bin_gaps = [ - (right - left, left, right) - for left, right in zip(bins, bins[1:]) - ] - maximum_bin_gap, gap_start_u8, gap_end_u8 = max( - adjacent_bin_gaps, - default=(0, None, None), - ) - missing_endpoints = [ - endpoint for endpoint in (0, 255) if endpoint not in bins - ] - actual = ( - None - if len(self.latest_state_u8) != 20 - else float(self.latest_state_u8[motor]) - ) - motion_progress = None - if actual is not None: - motion_progress = float( - np.clip( - abs(actual - self.active_sweep.start_u8) / 255.0, - 0.0, - 1.0, - ) - ) - storage_key = _sweep_storage_key(self.active_sweep.spec) - frame_counts = { - name: len(_frames_for_joint(self.sweep_frames, name)) - for name in self.active_sweep.spec.joints - } - baseline_frame_counts = { - name: len( - _frames_for_joint(self.sweep_baseline_frames, name) - ) - for name in self.active_sweep.spec.joints - } - detection_rates_by_view = { - view: ( - 0.0 - if getattr( - self, "sweep_detection_total_by_view", {} - ).get(view, 0) <= 0 - else getattr( - self, "sweep_detection_valid_by_view", {} - ).get(view, 0) - / getattr( - self, "sweep_detection_total_by_view", {} - )[view] - ) - for view in _sweep_views( - self.profile, self.active_sweep.spec - ) - } - active = { - "kind": "sweep", - "view": self.active_sweep.spec.view, - "motor_index": motor, - "joints": list(self.active_sweep.spec.joints), - "cycle": self.active_sweep.cycle + 1, - "repetitions": self.repetitions, - "direction": self.active_sweep.direction, - "task_name": self.active_sweep.spec.key, - "fit_attempt": self.sweep_attempts.get( - storage_key, 1 - ), - "fit_attempt_limit": self.automatic_fit_retry_limit + 1, - "fit_retry_cycles": ( - [ - cycle + 1 - for cycle in sorted( - getattr(self, "retry_cycles", set()) - ) - ] - if getattr(self, "active_sweep_is_fit_retry", False) - else [] - ), - "automatic_retry_count": max( - self.sweep_retry_counts.get( - ( - storage_key, - self.active_sweep.cycle, - self.active_sweep.direction, - ), - 0, - ), - self.motion_retry_counts.get( - "sweep_start_tag_timeout", 0 - ), - self.motion_retry_counts.get( - "sweep_start_position_timeout", 0 - ), - ), - "automatic_retry_limit": self.automatic_sweep_retry_limit, - "retry_speed_scale": ( - 1.0 - if self.sweep_retry_counts.get( - ( - storage_key, - self.active_sweep.cycle, - self.active_sweep.direction, - ), - 0, - ) - == 0 - else self.retry_speed_scales[ - min( - self.sweep_retry_counts[ - ( - storage_key, - self.active_sweep.cycle, - self.active_sweep.direction, - ) - ], - len(self.retry_speed_scales), - ) - - 1 - ] - ), - "endpoint_hold_seconds": self._active_endpoint_hold_seconds(), - "sweep_timeout_seconds": self._active_sweep_timeout_seconds(), - "motor_stall_timeout_seconds": self.motor_stall_timeout_seconds, - "motor_stall_minimum_progress_u8": ( - self.motor_stall_minimum_progress_u8 - ), - "start_u8": self.active_sweep.start_u8, - "target_u8": self.active_sweep.target_u8, - "current_motion_target_u8": ( - int( - getattr( - self, - "preparation_command_u8", - build_calibration_motion_command( - self.active_sweep.spec, - self.active_sweep.start_u8, - baseline=self.baseline_command, - profile=self.profile, - ), - )[motor] - ) - if self.state == STATE_PREPARE_SWEEP - else int(self.baseline_command[motor]) - if getattr(self, "sweep_baseline_pending", False) - else int( - self.sweep_checkpoint_target_u8 - if getattr(self, "sweep_checkpoint_target_u8", None) - is not None - else self.active_sweep.target_u8 - ) - ), - "steady_checkpoint_mode": bool( - getattr(self, "sweep_checkpoint_mode", False) - ), - "remaining_steady_checkpoints": len( - getattr(self, "sweep_checkpoint_commands", ()) - ), - "baseline_hold_pending": bool( - getattr(self, "sweep_baseline_pending", False) - ), - "baseline_hold_valid_frames": min( - baseline_frame_counts.values(), default=0 - ), - "minimum_baseline_hold_frames": int( - getattr(self, "minimum_baseline_hold_frames", 10) - ), - "actual_u8": actual, - "motion_progress": motion_progress, - "valid_frames": min(frame_counts.values(), default=0), - "valid_frames_by_joint": frame_counts, - "detection_frames": int( - getattr(self, "sweep_detection_total_frames", 0) - ), - "detection_valid_frames": int( - getattr(self, "sweep_detection_valid_frames", 0) - ), - "detection_rate": min( - detection_rates_by_view.values(), default=0.0 - ), - "detection_rate_by_view": detection_rates_by_view, - "sample": { - "minimum_u8": None if not values else min(values), - "maximum_u8": None if not values else max(values), - "span_u8": 0.0 if not values else max(values) - min(values), - "bin_count": len(bins), - "minimum_bin_count": self.minimum_sweep_bins, - "maximum_bin_gap": int(maximum_bin_gap), - "maximum_bin_gap_start_u8": gap_start_u8, - "maximum_bin_gap_end_u8": gap_end_u8, - "allowed_maximum_bin_gap": self.maximum_bin_gap, - "missing_endpoint_u8": missing_endpoints, - "endpoint_tolerance_u8": max( - start_tolerance, target_tolerance - ), - "start_endpoint_tolerance_u8": start_tolerance, - "target_endpoint_tolerance_u8": target_tolerance, - "mechanical_endpoint_tolerance_u8": max( - mechanical_start_tolerance, - mechanical_target_tolerance, - ), - "synchronised_endpoint_tolerance_margin_u8": float( - getattr( - self, - "synchronised_endpoint_tolerance_margin_u8", - 1.0, - ) - ), - }, - "auxiliary_motors": [ - { - "motor_index": index, - "command_u8": command, - "actual_u8": ( - None - if len(self.latest_state_u8) != 20 - else float(self.latest_state_u8[index]) - ), - } - for index, command in calibration_auxiliary_commands( - self.active_sweep.spec, profile=self.profile - ).items() - ], - "speed": { - "commanded_finger_speed": self._speed_profile_for_spec( - self.active_sweep.spec - ), - "reported_finger_speed": self.latest_hand_info.get("speed"), - "normal_speed": self.normal_calibration_speed, - "index_roll_speed": self.index_roll_calibration_speed, - "index_flex_speed": self.index_flex_calibration_speed, - "adaptive_formal_speed_scale": float( - self.formal_speed_scales.get( - self.active_sweep.spec.key, 1.0 - ) - ), - }, - } - elif self.active_validation is not None: - motor = self.active_validation.spec.motor_index - active = { - "kind": "validation", - "view": self.active_validation.spec.view, - "motor_index": motor, - "command_u8": self.active_validation.command_u8, - "actual_u8": ( - None - if len(self.latest_state_u8) != 20 - else float(self.latest_state_u8[motor]) - ), - "valid_frames": len(self.validation_frames_buffer), - "auxiliary_motors": [ - { - "motor_index": index, - "command_u8": command, - "actual_u8": ( - None - if len(self.latest_state_u8) != 20 - else float(self.latest_state_u8[index]) - ), - } - for index, command in calibration_auxiliary_commands( - self.active_validation.spec, profile=self.profile - ).items() - ], - "speed": { - "commanded_finger_speed": self._speed_profile_for_spec( - self.active_validation.spec - ), - "reported_finger_speed": self.latest_hand_info.get("speed"), - "normal_speed": self.normal_calibration_speed, - "index_roll_speed": self.index_roll_calibration_speed, - "index_flex_speed": self.index_flex_calibration_speed, - "adaptive_formal_speed_scale": float( - self.formal_speed_scales.get( - self.active_validation.spec.key, 1.0 - ) - ), - }, - } - elif self.fit_failure: - active = dict(self.fit_failure) - group_pnp_reasons = { - name: runtime.latest_group_pnp_reason - for name, runtime in self.views.items() - if runtime.latest_group_pnp_reason - } - if group_pnp_reasons: - active = dict(active) - active["group_pnp_reasons"] = group_pnp_reasons - pnp_rejection_counts = { - name: dict(runtime.pnp_rejection_counts) - for name, runtime in self.views.items() - if runtime.pnp_rejection_counts - } - if pnp_rejection_counts: - active = dict(active) - active["pnp_rejection_counts"] = pnp_rejection_counts - group_pnp_rejection_counts = { - name: dict(runtime.group_pnp_rejection_counts) - for name, runtime in self.views.items() - if runtime.group_pnp_rejection_counts - } - if group_pnp_rejection_counts: - active = dict(active) - active["group_pnp_rejection_counts"] = ( - group_pnp_rejection_counts - ) - pnp_initialization_progress = { - name: { - "accepted": runtime.pnp_initialization_progress[0], - "required": runtime.pnp_initialization_progress[1], - } - for name, runtime in self.views.items() - if runtime.pnp_initialization_progress is not None - } - if pnp_initialization_progress: - active = dict(active) - active["pnp_initialization_progress"] = ( - pnp_initialization_progress - ) - resumed_tasks = set(self.resumed_task_keys) - completed_sweep_count = sum( - 1 - for index, item in enumerate(self.sweep_items) - if index < self.sweep_index or item.spec.key in resumed_tasks - ) - scan_progress = ( - 0.0 - if not self.sweep_items - else float( - np.clip( - completed_sweep_count / len(self.sweep_items), 0.0, 1.0 - ) - ) - ) - status_validation_index = ( - self.combination_validation_index - if self.combination_validation_items - else self.validation_index - ) - status_validation_total = ( - len(self.combination_validation_items) - if self.combination_validation_items - else len(self.validation_items) - ) - payload = { - "serial_number": self.serial_number, - "session_dir": str(self.session_dir), - "hand_type": self.hand_type, - "reference_finger": self.profile.reference_finger, - "state": self.state, - "reason": self.reason, - "preflight_mode": ( - "recovering_baseline" - if self.state == STATE_RETURN_BASELINE - and self.baseline_after == "startup_tag_preflight" - else ( - "device_only_before_baseline" - if self.state in {STATE_PREFLIGHT, STATE_WAIT_START} - and not self.started - else ( - "baseline_tags_after_recovery" - if self.state == STATE_PREFLIGHT - and self.started - and self.startup_baseline_recovered - else "" - ) - ) - ), - "progress": round( - _overall_progress( - self.state, - scan_progress=scan_progress, - validation_index=status_validation_index, - validation_total=status_validation_total, - ), - 4, - ), - "scan_progress": round(scan_progress, 4), - "completed_sweeps": self.sweep_index, - "total_sweeps": len(self.sweep_items), - "baseline_command_u8": list(self.baseline_command), - "return_command_u8": list( - G20ThreeCameraCalibrationNode._current_return_command(self) - ), - "hand_state_fresh": bool( - len(self.latest_state_u8) == 20 - and now - self.last_state_at <= 1.0 - ), - "feedback_hz": round( - ( - 0.0 - if len(self.state_receive_times) < 2 - or self.state_receive_times[-1] - <= self.state_receive_times[0] - else (len(self.state_receive_times) - 1) - / ( - self.state_receive_times[-1] - - self.state_receive_times[0] - ) - ), - 2, - ), - "views": views, - "active": active, - "result_path": ( - str(self.final_path) if self.completed_payload is not None else "" - ), - "corrected_urdf_path": ( - "" - if self.corrected_urdf_path is None - else str(self.corrected_urdf_path) - ), - "camera_extrinsics_error": self.extrinsics_error, - "resume": { - "used": bool(self.resumed_task_keys), - "checkpoint_requested": bool( - self.resume_raw_samples_path is not None - ), - "checkpoint_pending": bool( - getattr(self, "resume_checkpoint_pending", False) - ), - "source_session": self.resume_source_session, - "start_position_policy": getattr( - self, "resume_position_policy", "not_requested" - ), - "start_position_changed_views": list( - getattr(self, "resume_position_changed_views", ()) - ), - "start_position_unverifiable_views": list( - getattr( - self, - "resume_position_unverifiable_views", - (), - ) - ), - "start_position_drift_by_view_px": { - view: round(float(value), 6) - for view, value in getattr( - self, "resume_position_drift_by_view_px", {} - ).items() - }, - "recalibration_scope": self.recalibration_scope, - "recalibration_task_keys": list( - self.recalibration_task_keys - ), - "completed_task_count": len(self.resumed_task_keys), - "total_task_count": len(self.profile.sweep_specs), - "completed_task_keys": list(self.resumed_task_keys), - }, - "base_import": dict( - getattr(self, "base_import_progress", {}) - ), - "quality": ( - {} if self.completed_payload is None else self.completed_payload["quality"] - ), - "thumb_yaw_cross_session": dict( - getattr(self, "thumb_yaw_cross_session_diagnostic", {}) - ), - "combination_validation": { - "enabled": bool(self.combination_validation_enabled), - "completed": bool(self.combination_validation_completed), - "completed_poses": int(self.combination_validation_index), - "total_poses": len(self.combination_validation_items), - "position_p95_m": ( - None - if not self.combination_position_errors_m - else round( - float( - np.percentile( - self.combination_position_errors_m, 95.0 - ) - ), - 8, - ) - ), - "orientation_p95_rad": ( - None - if not self.combination_orientation_errors_rad - else round( - float( - np.percentile( - self.combination_orientation_errors_rad, 95.0 - ) - ), - 8, - ) - ), - **combination_target_coverage( - self.combination_tag_observation_counts, - self.combination_tag_validation_counts, - ), - }, - } - status = String() - status.data = json.dumps(payload, ensure_ascii=False) - self.status_publisher.publish(status) - text = String() - text.data = render_three_camera_status_text_zh(payload) - self.status_text_publisher.publish(text) - - -def main(args: list[str] | None = None) -> None: - configure_fastdds_large_image_transport() - rclpy.init(args=args) - node: G20ThreeCameraCalibrationNode | None = None - try: - node = G20ThreeCameraCalibrationNode() - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - - -if __name__ == "__main__": - main() diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py deleted file mode 100644 index 58731bb..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py +++ /dev/null @@ -1,736 +0,0 @@ -"""One-command runner for a registered hand-calibration product.""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -import os -from pathlib import Path -import signal -import subprocess -import sys -import time -import traceback -from typing import Any, Mapping - -from ament_index_python.packages import get_package_share_directory -import rclpy -from rclpy.node import Node -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...hikrobot_camera import configure_fastdds_large_image_transport -from ...operator_report import ( - ProgressEstimator, - build_failure_report, - render_progress_zh, -) -from ...product import ProductConfig, load_product_config, sha256_file -from .publication import atomic_session_pointer, finalize_session_artifacts -from ...storage import atomic_write_json -from ...runtime import ACQUISITION_POLICY_VERSION - - -EXIT_PASS = 0 -EXIT_QUALITY = 2 -EXIT_SAFETY = 3 -STATUS_TIMEOUT_SECONDS = 90.0 -FITTING_STATUS_TIMEOUT_SECONDS = 600.0 - - -def _status_timeout_seconds(status: Mapping[str, Any]) -> float: - """Return the watchdog deadline for the node's current phase. - - Motion and acquisition are expected to publish twice a second and retain - the strict transport watchdog. The final 3-D fit is intentionally a - synchronous, CPU-bound operation, so its executor cannot service the - status timer until the fit returns. The node publishes an explicit - FITTING status immediately before entering that operation; allow that - known phase enough time without weakening motion safety checks. - """ - if str(status.get("state", "")) == "FITTING": - return FITTING_STATUS_TIMEOUT_SECONDS - return STATUS_TIMEOUT_SECONDS - - -def _calibration_node_exited_before_status(log_path: Path) -> bool: - """Detect a launch child crash while the parent launch is still alive.""" - try: - with log_path.open("rb") as stream: - stream.seek(0, os.SEEK_END) - size = stream.tell() - stream.seek(max(0, size - 65536), os.SEEK_SET) - tail = stream.read().decode("utf-8", errors="replace") - except OSError: - return False - return ( - "[three_camera_calibration_node-" in tail - and "]: process has died" in tail - ) - - -class CalibrationMonitor(Node): - def __init__(self) -> None: - super().__init__("g20_calibration_product_runner") - self.latest_status: dict[str, Any] = {} - self.last_status_at = time.monotonic() - self.start_requested = False - self.start_future: Any = None - self.abort_future: Any = None - self.create_subscription(String, "/g20_calibration/status", self._status, 10) - self.start_client = self.create_client(Trigger, "/g20_calibration/start") - self.abort_client = self.create_client(Trigger, "/g20_calibration/abort") - - def _status(self, message: String) -> None: - try: - payload = json.loads(message.data) - except (TypeError, json.JSONDecodeError): - return - if isinstance(payload, dict): - self.latest_status = payload - self.last_status_at = time.monotonic() - - def maybe_start(self) -> None: - if self.start_requested or self.latest_status.get("state") != "WAIT_START": - return - if not self.start_client.service_is_ready(): - self.start_client.wait_for_service(timeout_sec=0.05) - return - self.start_requested = True - self.start_future = self.start_client.call_async(Trigger.Request()) - - def abort(self) -> None: - if not self.abort_client.service_is_ready(): - self.abort_client.wait_for_service(timeout_sec=1.0) - if self.abort_client.service_is_ready(): - self.abort_future = self.abort_client.call_async(Trigger.Request()) - - -class ProgressConsole: - def __init__(self, serial_number: str) -> None: - self.serial_number = serial_number - self.estimator = ProgressEstimator.start() - self.last_text = "" - self.last_issue = "" - - def update(self, status: Mapping[str, Any]) -> None: - text = render_progress_zh(self.serial_number, status, self.estimator) - if text == self.last_text: - return - self.last_text = text - if sys.stdout.isatty(): - sys.stdout.write("\x1b[2J\x1b[H" + text + "\n") - sys.stdout.flush() - else: - print(text, flush=True) - reason = str(status.get("reason", "")) - if reason.startswith("automatic_retry_") and reason != self.last_issue: - self.last_issue = reason - active = status.get("active", {}) - print( - "\n".join( - [ - f"⚠ 当前任务出现问题:{reason.removeprefix('automatic_retry_')}", - "系统处理:只按原速度重扫当前方向" - f"(第 {active.get('automatic_retry_count', 1)}/" - f"{active.get('automatic_retry_limit', 1)} 次)", - ] - ), - flush=True, - ) - - -def _default_product_config() -> Path: - try: - installed = Path( - get_package_share_directory("linkerhand_calibration") - ) / "config" / "g20_right_product.yaml" - if installed.is_file(): - return installed - except Exception: - pass - return ( - Path.cwd() - / "src/linkerhand_calibration/config/g20_right_product.yaml" - ).resolve() - - -def _launch_command( - config: ProductConfig, - session: Path, - *, - resume_from: Path | None = None, - recalibration_scope: str = "full", -) -> list[str]: - values = { - "model": config.model, - "hand_type": config.side, - "tag_layout": config.tag_layout, - "serial_number": config.serial_number, - "can_interface": config.can_interface, - "session_dir": str(session), - "output_root": str(config.output_root), - "camera_extrinsics_file": str(config.camera_extrinsics), - "source_urdf_path": str(config.source_urdf), - "source_urdf_expected_sha256": config.source_urdf_sha256, - "corrected_urdf_output_dir": str(session), - "calibration_config": str(config.calibration_config), - "tag_config": str(config.tag_config), - "commands_enabled": "true", - "start_cameras": "true", - "start_sdk": "true", - "record_bag": "false", - "validation_enabled": "false", - "recalibration_scope": recalibration_scope, - } - if resume_from is not None: - values["resume_raw_samples_path"] = str( - resume_from / "raw_samples.jsonl" - ) - for view, camera in config.cameras.items(): - values[f"{view}_camera_serial"] = camera["serial_number"] - values[f"{view}_camera_name"] = camera["camera_name"] - values[f"{view}_camera_info_url"] = camera["camera_info"] - return [ - "ros2", - "launch", - "linkerhand_calibration", - "three_camera_calibration.launch.py", - *(f"{name}:={value}" for name, value in values.items()), - ] - - -def _stop_stack(process: subprocess.Popen[Any]) -> None: - if process.poll() is not None: - return - try: - os.killpg(process.pid, signal.SIGINT) - except ProcessLookupError: - return - try: - process.wait(timeout=15.0) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - return - try: - process.wait(timeout=5.0) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - return - process.wait(timeout=5.0) - - -def _write_trace(log_path: Path, error: BaseException) -> None: - with log_path.open("a", encoding="utf-8") as stream: - stream.write("\n[one-command exception]\n") - traceback.print_exception(type(error), error, error.__traceback__, file=stream) - - -def _request_safe_abort(monitor: CalibrationMonitor, timeout_seconds: float = 35.0) -> None: - monitor.abort() - deadline = time.monotonic() + float(timeout_seconds) - while time.monotonic() < deadline and rclpy.ok(): - rclpy.spin_once(monitor, timeout_sec=0.1) - if monitor.latest_status.get("state") == "ABORTED": - return - - -def _run_hardware_session( - config: ProductConfig, - session: Path, - *, - resume_from: Path | None = None, - recalibration_scope: str = "full", -) -> tuple[dict[str, Any], int]: - session.mkdir(parents=True, exist_ok=False) - (session / "raw_samples.jsonl").touch() - log_path = session / "calibration.log" - log_stream = log_path.open("a", encoding="utf-8", buffering=1) - atomic_session_pointer(config.session_root, "latest_attempt", session) - monitor = CalibrationMonitor() - console = ProgressConsole(config.serial_number) - process: subprocess.Popen[Any] | None = None - latest_status: dict[str, Any] = { - "state": "PREFLIGHT", - "reason": "starting_ros_stack", - "progress": 0.0, - "views": {}, - "feedback_hz": 0.0, - } - exit_code = EXIT_QUALITY - try: - process = subprocess.Popen( - _launch_command( - config, - session, - resume_from=resume_from, - recalibration_scope=recalibration_scope, - ), - cwd=config.workspace, - stdout=log_stream, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - ) - launched_at = time.monotonic() - last_render = 0.0 - last_startup_log_check = 0.0 - while True: - rclpy.spin_once(monitor, timeout_sec=0.1) - if monitor.latest_status: - latest_status = monitor.latest_status - monitor.maybe_start() - now = time.monotonic() - if now - last_render >= 0.5: - console.update(latest_status) - last_render = now - if monitor.start_future is not None and monitor.start_future.done(): - response = monitor.start_future.result() - if response is None or not response.success: - message = "start service failed" if response is None else response.message - raise RuntimeError(f"CFG-START-008:{message}") - monitor.start_future = None - state = str(latest_status.get("state", "")) - if state == "COMPLETE": - exit_code = EXIT_PASS - break - if state in {"PAUSED", "ABORTED"}: - reason = str(latest_status.get("reason", "calibration_paused")) - exit_code = EXIT_SAFETY if "stall" in reason or state == "ABORTED" else EXIT_QUALITY - if state == "PAUSED" and "stall" not in reason: - # Ordinary quality failures return to the reviewed baseline - # before the process tree is stopped. Mechanical stalls - # deliberately skip this path and keep the current pose. - failure_status = dict(latest_status) - _request_safe_abort(monitor) - latest_status = failure_status - break - if process.poll() is not None: - raise RuntimeError(f"PUB-STACK-602:ROS stack exited with {process.returncode}") - if ( - not monitor.latest_status - and now - last_startup_log_check >= 0.5 - ): - last_startup_log_check = now - log_stream.flush() - if _calibration_node_exited_before_status(log_path): - raise RuntimeError( - "CAM-STATUS-202:calibration node exited before status" - ) - if ( - not monitor.latest_status - and now - launched_at > STATUS_TIMEOUT_SECONDS - ): - raise RuntimeError("CAM-STATUS-202:no calibration status received") - if ( - monitor.latest_status - and now - monitor.last_status_at - > _status_timeout_seconds(monitor.latest_status) - ): - raise RuntimeError("MOTION-COMM-303:calibration status stopped") - except KeyboardInterrupt as error: - latest_status["state"] = "ABORTED" - latest_status["reason"] = "operator_abort" - _request_safe_abort(monitor) - _write_trace(log_path, error) - exit_code = EXIT_SAFETY - except BaseException as error: - latest_status["state"] = "PAUSED" - latest_status["reason"] = str(error) - _write_trace(log_path, error) - exit_code = EXIT_QUALITY - finally: - if process is not None: - _stop_stack(process) - monitor.destroy_node() - log_stream.flush() - os.fsync(log_stream.fileno()) - log_stream.close() - - if exit_code != EXIT_PASS: - _, block = build_failure_report( - config, - session, - latest_status, - reason=str(latest_status.get("reason", "unknown_failure")), - ) - print(block, flush=True) - return latest_status, exit_code - - -def _startup_failure_block(path: Path, error: BaseException) -> str: - return "\n".join( - [ - "========== 请复制以下内容给开发者 ==========", - "结果:FAIL", - "错误代码:CFG-PRODUCT-001", - "失败阶段:启动静态预检", - f"问题:{error}", - f"产品配置:{path}", - "自动处理:未启动相机、SDK或机械手运动", - "建议:复制本诊断块给开发者,不要手工修改哈希绕过检查。", - "========== 复制结束 ==========", - ] - ) - - -def _automatic_resume_candidate(config: ProductConfig) -> Path | None: - """Return the newest compatible failed attempt, never a passed session. - - Do not trust only ``latest_attempt``. A process interrupted during the - device-only startup gate may have already moved that pointer while still - containing no ``session_start`` checkpoint. In that case walk backwards - to the preceding usable failed session instead of throwing away hours of - completed tasks. - """ - root = config.session_root - try: - resolved_root = root.resolve(strict=True) - except OSError: - return None - - candidates: list[Path] = [] - pointer = config.session_root / "latest_attempt" - if pointer.exists(): - try: - candidates.append(pointer.resolve(strict=True)) - except OSError: - pass - try: - candidates.extend( - sorted( - ( - path - for path in root.iterdir() - if path.is_dir() and not path.name.startswith("latest_") - ), - key=lambda path: path.name, - reverse=True, - ) - ) - except OSError: - return None - - passed_pointer = config.session_root / "latest_passed" - passed: Path | None = None - if passed_pointer.exists(): - try: - passed = passed_pointer.resolve(strict=True) - except OSError: - pass - - seen: set[Path] = set() - for unresolved in candidates: - try: - candidate = unresolved.resolve(strict=True) - except OSError: - continue - if candidate in seen: - continue - seen.add(candidate) - if candidate.parent != resolved_root or not candidate.is_dir(): - continue - # A failed attempt older than the current formal release is stale and - # must not seed a new independent calibration. - if passed is not None and candidate.name <= passed.name: - continue - raw_path = candidate / "raw_samples.jsonl" - if not raw_path.is_file(): - continue - summary_path = candidate / "calibration_summary_zh.json" - summary: dict[str, Any] | None = None - if summary_path.is_file(): - try: - loaded = json.loads(summary_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - continue - if not isinstance(loaded, dict) or loaded.get("result") != "FAIL": - continue - summary = loaded - hashes = summary.get("hashes", {}) - if not isinstance(hashes, Mapping): - continue - if ( - str(hashes.get("source_urdf_sha256", "")) - != config.source_urdf_sha256 - or str(hashes.get("camera_extrinsics_sha256", "")) - != config.camera_extrinsics_sha256 - ): - continue - start: dict[str, Any] | None = None - try: - with raw_path.open("r", encoding="utf-8") as stream: - for line in stream: - if not line.strip(): - continue - value = json.loads(line) - if ( - isinstance(value, dict) - and value.get("kind") == "session_start" - ): - start = value - break - except (OSError, json.JSONDecodeError): - continue - if ( - start is None - or start.get("acquisition_policy_version") - != ACQUISITION_POLICY_VERSION - or start.get("hand_type") != config.side - or start.get("tag_layout") != config.tag_layout - or start.get("source_urdf_sha256") - != config.source_urdf_sha256 - ): - continue - if summary is None: - # Ctrl+C can terminate the ROS launch tree before the wrapper gets - # a chance to create calibration_summary_zh.json. The immutable - # checkpoint itself is enough to resume only after independently - # proving that its external geometry still matches the product. - try: - checkpoint_extrinsics = Path( - str(start["camera_extrinsics_file"]) - ).expanduser().resolve(strict=True) - if sha256_file(checkpoint_extrinsics) != ( - config.camera_extrinsics_sha256 - ): - continue - except (KeyError, OSError, ValueError): - continue - return candidate - return None - - -def _resolve_partial_base_session( - config: ProductConfig, base_session: str | Path | None -) -> Path: - """Validate the complete passed session that donates non-target tasks.""" - if base_session is None or not str(base_session).strip(): - raise ValueError( - "partial scope requires --base-session pointing to a passed " - "complete G20 right session" - ) - candidate = Path(base_session).expanduser().resolve(strict=True) - root = config.session_root.resolve() - if candidate.parent != root or not candidate.is_dir(): - raise ValueError( - "base session must resolve to a direct session directory under " - f"{root}" - ) - raw_path = candidate / "raw_samples.jsonl" - summary_path = candidate / "calibration_summary_zh.json" - payload_path = ( - candidate - / f"g20_right_{config.serial_number}_calibration.json" - ) - for required in (raw_path, summary_path, payload_path): - if not required.is_file(): - raise ValueError(f"base session is missing required artifact: {required}") - try: - summary = json.loads(summary_path.read_text(encoding="utf-8")) - except json.JSONDecodeError as error: - raise ValueError("base session summary is invalid JSON") from error - if ( - not isinstance(summary, dict) - or summary.get("result") != "PASS" - or not bool(summary.get("quality", {}).get("passed")) - ): - raise ValueError("base session is not a formally passed session") - hashes = summary.get("hashes", {}) - expected_hashes = { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - } - if not isinstance(hashes, Mapping) or any( - str(hashes.get(name, "")) != expected - for name, expected in expected_hashes.items() - ): - raise ValueError( - "base session source URDF, camera extrinsics or calibration " - "configuration differs from the current product" - ) - return candidate - - -def run( - config_path: str | Path, - *, - workspace: str | Path | None = None, - preflight_only: bool = False, - allow_resume: bool = True, - scope: str = "full", - base_session: str | Path | None = None, -) -> int: - path = Path(config_path).expanduser().resolve() - try: - # Resolve every file and camera identity before allowing a hardware - # process to start. A second load enables the real CAN existence gate. - config = load_product_config(path, workspace=workspace, check_can=False) - load_product_config(path, workspace=workspace, check_can=True) - selected_scope = str(scope).strip().lower() - if selected_scope not in {"full", "thumb", "fingers"}: - raise ValueError("scope must be one of: full, thumb, fingers") - if selected_scope == "full" and base_session is not None: - raise ValueError( - "--base-session is valid only with --scope thumb/fingers" - ) - partial_base = None - if selected_scope == "fingers" or base_session is not None: - partial_base = _resolve_partial_base_session(config, base_session) - except BaseException as error: - print(_startup_failure_block(path, error), flush=True) - return EXIT_QUALITY - if preflight_only: - print("PASS:产品文件、相机内外参、19张Tag配置和CAN接口静态预检通过。") - return EXIT_PASS - - config.session_root.mkdir(parents=True, exist_ok=True) - resume_candidate = ( - partial_base - if selected_scope != "full" - else (_automatic_resume_candidate(config) if allow_resume else None) - ) - if resume_candidate is not None: - if selected_scope == "thumb": - print( - "拇指专项标定:四指任务继承自已通过会话 " - f"{resume_candidate.name};4项拇指任务将全部重新采集," - "四指零位保持不变。", - flush=True, - ) - elif selected_scope == "fingers": - print( - "四指专项标定:拇指任务和4个拇指零位继承自已通过会话 " - f"{resume_candidate.name};12项四指任务将全部重新采集。", - flush=True, - ) - else: - print( - "检测到兼容的失败会话,将恢复已完整通过的关节任务:" - f"{resume_candidate.name}。失败中的当前任务会从头重做。", - flush=True, - ) - elif selected_scope == "thumb": - print( - "独立拇指标定:不导入四指会话;仅采集4项拇指任务," - "四指URDF零位保持原始CAD值。", - flush=True, - ) - maximum_sessions = config.required_independent_passes - for pass_index in range(maximum_sessions): - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session = config.session_root / stamp - while session.exists(): - time.sleep(1.0) - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session = config.session_root / stamp - if maximum_sessions > 1: - print(f"正式标定复验:第 {pass_index + 1}/{maximum_sessions} 次", flush=True) - status, code = _run_hardware_session( - config, - session, - resume_from=( - resume_candidate - if selected_scope != "full" or pass_index == 0 - else None - ), - recalibration_scope=selected_scope, - ) - if code != EXIT_PASS: - return code - # Keep the exact node-side completion contract durable before the - # independent publication layer starts. If publication itself fails, - # developers can re-run artifact checks without repeating motion or - # inventing lost combination-validation metrics. - atomic_write_json(session / "node_status.json", status) - try: - summary, release_ready = finalize_session_artifacts( - config, session, node_status=status - ) - except BaseException as error: - _write_trace(session / "calibration.log", error) - status = dict(status) - status["state"] = "PAUSED" - status["reason"] = f"PUB-ARTIFACT-601:{error}" - _, block = build_failure_report(config, session, status, reason=status["reason"]) - print(block, flush=True) - return EXIT_QUALITY - if release_ready: - result_pointer = ( - config.session_root / "latest_thumb_passed" - if selected_scope == "thumb" and partial_base is None - else config.session_root / "latest_passed" - ) - print( - "\n".join( - [ - f"PASS:{config.model} {config.side} 标定、URDF修正和复验全部通过。", - f"正式结果:{result_pointer}", - f"JSON:{session / summary['artifacts']['json']}", - f"URDF:{summary['artifacts']['urdf']}", - ] - ), - flush=True, - ) - return EXIT_PASS - print("本次会话质量PASS;正在自动执行第二次独立完整复验。", flush=True) - return EXIT_QUALITY - - -def main(args: list[str] | None = None) -> None: - parser = argparse.ArgumentParser(description="配置驱动的机械手精密标定") - parser.add_argument("--config", default=str(_default_product_config())) - parser.add_argument("--workspace", default=None) - parser.add_argument("--preflight-only", action="store_true") - parser.add_argument( - "--scope", - choices=("full", "thumb", "fingers"), - default="full", - help=( - "full重新标定全手;thumb仅重采4项拇指任务;" - "fingers复用已认证拇指并仅重采12项四指任务" - ), - ) - parser.add_argument( - "--base-session", - default=None, - help=( - "可选:thumb模式将结果合并到该完整会话;" - "fingers模式必须提供该基础会话" - ), - ) - parser.add_argument( - "--no-resume", - action="store_true", - help="忽略失败会话,从第一个关节开始全新采集", - ) - arguments = parser.parse_args(args) - configure_fastdds_large_image_transport() - ros_log_dir = Path( - os.environ.setdefault("ROS_LOG_DIR", "/tmp/g20_calibration_ros_logs") - ) - ros_log_dir.mkdir(parents=True, exist_ok=True) - rclpy.init() - try: - code = run( - arguments.config, - workspace=arguments.workspace, - preflight_only=arguments.preflight_only, - allow_resume=not arguments.no_resume, - scope=arguments.scope, - base_session=arguments.base_session, - ) - finally: - if rclpy.ok(): - rclpy.shutdown() - raise SystemExit(code) - - -if __name__ == "__main__": - main() diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py deleted file mode 100644 index 34d081d..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py +++ /dev/null @@ -1,4606 +0,0 @@ -"""Three-dimensional joint-axis fitting and URDF zero correction.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -from datetime import datetime -import math -from pathlib import Path -import re -from typing import Any, Mapping, Sequence -import xml.etree.ElementTree as ET - -import numpy as np -from scipy.optimize import least_squares -from scipy.spatial.transform import Rotation -from scipy.stats import t as student_t - -from ...core import fit_rotation_axis, robust_rotation_summary -from ...core.urdf import ( - UrdfCorrectionPlan, - UrdfJointPatch, - UrdfPatchSet, - materialize_relative_mesh_assets as _materialize_relative_mesh_assets, - write_urdf_patches, -) -from .profile import ( - G20_RIGHT_19_LAYOUT, - IMAGE_TRAJECTORY_JOINTS, - LEFT_HAND_PROFILE, - RIGHT_19_END_ON_IMAGE_CURVE_JOINTS, - RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS, - HandCalibrationProfile, - JointCurveFit, - get_hand_calibration_profile, -) -from ...sample_schema import explicit_domain_value -from ...trajectory import ( - _fit_circle_with_axis, - _fit_joint_curve, - _fit_plane_axis, - _plane_basis, -) - - -@dataclass(frozen=True) -class ZeroCalibrationProfile: - hand: HandCalibrationProfile - direct_zero_joints: tuple[str, ...] - axis_joints: tuple[str, ...] - # Dynamic command-angle curves may be shared by unobserved fingers. This - # mapping is intentionally *not* an authorization to copy an absolute - # encoder/URDF zero between independent motors. - inherited_zero_joints: Mapping[str, str] - # Absolute static-zero inheritance requires an independent mechanical - # guarantee. With the current 11-Tag layout only the reference finger is - # observed, so the conservative mapping is empty and unobserved active - # finger origins retain their source-CAD zero. - inherited_static_zero_joints: Mapping[str, str] - constrained_circle_joints: frozenset[str] - root_anchor_joints: frozenset[str] - axis_parent_joint: Mapping[str, str] - phase_parent_joint: Mapping[str, str] - offset_observer_joint: Mapping[str, str] - # Some serial offsets can be observed from the angle between two axes - # captured by the same camera. The mapping is geometric topology only; - # it never contains a model- or serial-specific zero value. - same_view_axis_pair_by_offset: Mapping[str, tuple[str, str]] - fixed_direct_zero_offsets_rad: Mapping[str, float] - static_output_zero_offsets_rad: Mapping[str, float] - # Product full-hand fitting uses the five root-axis line pattern. The - # thumb kernel instead uses only the serial thumb chain, so its nuisance - # palm pose cannot be influenced by finger observations. - base_pose_strategy: str = "full_hand" - # A serial-chain model may use a separate palm-root joint axis to fix the - # otherwise free rotation about its primary root axis. G20 retains its - # historical defaults; other model profiles can name the physical anchor - # explicitly without introducing model-specific branches in the solver. - orientation_anchor_joint: str | None = None - # A model whose feedback direction is mechanically reviewed may use the - # signed rotation axes to disambiguate the otherwise mirrored palm-frame - # branches. The default remains undirected for legacy G20/L6 profiles. - directed_base_axis_joints: frozenset[str] = frozenset() - # A profile may retain zero when a *bounded training confidence interval* - # contains it. The frozen zero still faces every geometry/cycle/holdout - # check below. Default False preserves existing G20/L6/O6 decisions. - accept_validated_zero_in_confidence_interval: bool = False - # Axis-line points have no unique coordinate along the axis. Remove that - # gauge before projecting a parallel-axis phase into the camera plane. - # Opt in explicitly while legacy profiles retain their reviewed policy. - project_axis_gauge_before_image: bool = False - - @property - def reference_finger(self) -> str: - return self.hand.reference_finger - - -def _build_zero_profile(hand: HandCalibrationProfile) -> ZeroCalibrationProfile: - if hand.layout_id == G20_RIGHT_19_LAYOUT: - return _build_right_19_zero_profile(hand) - reference = hand.reference_finger - reference_roll = f"{reference}_mcp_roll" - reference_pitch = f"{reference}_mcp_pitch" - reference_pip = f"{reference}_pip" - reference_dip = f"{reference}_dip" - direct = ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - reference_roll, - reference_pitch, - reference_pip, - ) - inherited = { - f"{finger}_{suffix}": f"{reference}_{suffix}" - for finger in ("index", "middle", "ring", "pinky") - if finger != reference - for suffix in ("mcp_roll", "mcp_pitch", "pip") - } - axis_parent = { - "thumb_cmc_yaw": "thumb_cmc_roll", - "thumb_cmc_pitch": "thumb_cmc_yaw", - reference_pitch: reference_roll, - } - phase_parent = { - "thumb_mcp": "thumb_cmc_pitch", - "thumb_ip": "thumb_mcp", - reference_pip: reference_pitch, - reference_dip: reference_pip, - } - observer = { - "thumb_cmc_roll": "thumb_cmc_yaw", - "thumb_cmc_yaw": "thumb_cmc_pitch", - "thumb_cmc_pitch": "thumb_mcp", - "thumb_mcp": "thumb_ip", - reference_roll: reference_pitch, - reference_pitch: reference_pip, - reference_pip: reference_dip, - } - fixed_direct_zero_offsets = { - "thumb_mcp": 0.0, - reference_roll: 0.0, - reference_pitch: 0.0, - reference_pip: 0.0, - } - return ZeroCalibrationProfile( - hand=hand, - direct_zero_joints=direct, - axis_joints=( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - "thumb_ip", - reference_roll, - reference_pitch, - reference_pip, - reference_dip, - ), - inherited_zero_joints=inherited, - inherited_static_zero_joints={}, - constrained_circle_joints=( - hand.image_trajectory_joints | {reference_dip, "thumb_cmc_yaw"} - ), - root_anchor_joints=frozenset({"thumb_cmc_roll", reference_roll}), - axis_parent_joint=axis_parent, - phase_parent_joint=phase_parent, - offset_observer_joint=observer, - same_view_axis_pair_by_offset={}, - fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets, - static_output_zero_offsets_rad={}, - ) - - -def _build_right_19_zero_profile( - hand: HandCalibrationProfile, -) -> ZeroCalibrationProfile: - """Return the 16-active-zero graph for the 19-Tag layout. - - Distal Tags recover all four PIP/DIP dynamic curves and axis quality, but - repeated motion with one unchanged camera/Tag installation cannot by - itself distinguish a fixed parallel-axis phase bias from an encoder zero. - The three coupled thumb-CMC offsets must therefore remain a visual - multi-axis solve; their electrical endpoints are not assumed to coincide - with source-CAD limits. Independently repeatable contact endpoints still - anchor thumb MCP, finger MCP-pitch and PIP from each session. DIP stays - passive and has no independently identifiable static zero. - """ - fingers = ("index", "middle", "ring", "pinky") - direct = ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - *( - f"{finger}_{suffix}" - for finger in fingers - for suffix in ("mcp_roll", "mcp_pitch", "pip") - ), - ) - axis_joints = ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - "thumb_ip", - *( - f"{finger}_{suffix}" - for finger in fingers - for suffix in ("mcp_roll", "mcp_pitch", "pip", "dip") - ), - ) - axis_parent = { - "thumb_cmc_yaw": "thumb_cmc_roll", - "thumb_cmc_pitch": "thumb_cmc_yaw", - **{ - f"{finger}_mcp_pitch": f"{finger}_mcp_roll" - for finger in fingers - }, - } - phase_parent = { - "thumb_mcp": "thumb_cmc_pitch", - "thumb_ip": "thumb_mcp", - **{ - f"{finger}_{child}": f"{finger}_{parent}" - for finger in fingers - for child, parent in ( - ("pip", "mcp_pitch"), - ("dip", "pip"), - ) - }, - } - observer = { - "thumb_cmc_roll": "thumb_cmc_yaw", - "thumb_cmc_yaw": "thumb_cmc_pitch", - "thumb_cmc_pitch": "thumb_mcp", - "thumb_mcp": "thumb_ip", - **{ - f"{finger}_{target}": f"{finger}_{observed}" - for finger in fingers - for target, observed in ( - ("mcp_roll", "mcp_pitch"), - ("mcp_pitch", "pip"), - ("pip", "dip"), - ) - }, - } - root_anchors = frozenset( - {"thumb_cmc_roll", *(f"{finger}_mcp_roll" for finger in fingers)} - ) - constrained = frozenset( - set(hand.image_trajectory_joints) - | {"thumb_cmc_yaw"} - # The side camera observes all four flexion axes nearly end-on. A - # 16 mm monocular planar Tag gives a very repeatable orientation axis, - # while its command-correlated depth bias can tilt the otherwise clean - # centre-trajectory plane by several degrees. Use the orientation - # axis to constrain that circle; PIP retains its independently fitted - # axis line and all final SE(3)/holdout quality gates. - | set(RIGHT_19_END_ON_IMAGE_CURVE_JOINTS) - # _fit_axis_measurement_raw constrains every passive DIP direction to - # its source-URDF-parallel upstream PIP. Declare the same fact here - # so validation does not compare that trusted direction with the - # depth-biased free plane of a distal Tag trajectory. - | set(RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS) - ) - return ZeroCalibrationProfile( - hand=hand, - direct_zero_joints=tuple(direct), - axis_joints=tuple(axis_joints), - inherited_zero_joints={}, - inherited_static_zero_joints={}, - constrained_circle_joints=constrained, - root_anchor_joints=root_anchors, - axis_parent_joint=axis_parent, - phase_parent_joint=phase_parent, - offset_observer_joint=observer, - same_view_axis_pair_by_offset={ - "thumb_cmc_yaw": ("thumb_cmc_roll", "thumb_cmc_pitch") - }, - # Endpoint-observable offsets are supplied by the caller. CMC remains - # visually solved and no serial-specific zero is hidden in the shared - # profile. - fixed_direct_zero_offsets_rad={}, - static_output_zero_offsets_rad={}, - ) - - -def get_right_19_thumb_zero_profile() -> ZeroCalibrationProfile: - """Return the independent four-zero thumb observer graph. - - Roll and MCP are supplied as mechanical endpoint datums by the caller; - yaw is observed by the same-view roll/pitch axis pair and pitch by the - pitch/MCP serial phase. No finger axis is part of this profile. - """ - full = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) - thumb_direct = tuple( - name for name in full.direct_zero_joints if name.startswith("thumb_") - ) - thumb_axes = tuple( - name for name in full.axis_joints if name.startswith("thumb_") - ) - return replace( - full, - direct_zero_joints=thumb_direct, - axis_joints=thumb_axes, - constrained_circle_joints=frozenset( - name - for name in full.constrained_circle_joints - if name.startswith("thumb_") - ), - root_anchor_joints=frozenset({"thumb_cmc_roll"}), - axis_parent_joint={ - child: parent - for child, parent in full.axis_parent_joint.items() - if child.startswith("thumb_") and parent.startswith("thumb_") - }, - phase_parent_joint={ - child: parent - for child, parent in full.phase_parent_joint.items() - if child.startswith("thumb_") and parent.startswith("thumb_") - }, - offset_observer_joint={ - target: observer - for target, observer in full.offset_observer_joint.items() - if target.startswith("thumb_") and observer.startswith("thumb_") - }, - same_view_axis_pair_by_offset={ - target: pair - for target, pair in full.same_view_axis_pair_by_offset.items() - if target.startswith("thumb_") - }, - fixed_direct_zero_offsets_rad={}, - static_output_zero_offsets_rad={}, - base_pose_strategy="thumb_serial", - ) - - -RIGHT_19_MECHANICAL_ENDPOINT_JOINTS = frozenset( - { - "thumb_mcp", - *( - f"{finger}_{suffix}" - for finger in ("index", "middle", "ring", "pinky") - for suffix in ("mcp_pitch", "pip") - ), - } -) - -# Thumb CMC roll has a directly observed actuator-to-actuator travel, but its -# electrical stop is not used as a runtime URDF limit. Keep it out of -# RIGHT_19_MECHANICAL_ENDPOINT_JOINTS (whose offsets also move upper limits), -# and use its endpoint estimate only as the final static roll origin. This -# makes roll independent of the fitted palm-frame phase and of all yaw logic. -RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS = frozenset({"thumb_cmc_roll"}) -RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS = ( - RIGHT_19_MECHANICAL_ENDPOINT_JOINTS - | RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS -) -RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS = RIGHT_19_ROLL_ENDPOINT_ZERO_JOINTS - - -def anchor_right_19_mechanical_endpoint_curves( - curves: Mapping[str, JointCurveFit], - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], - *, - maximum_direction_difference_rad: float = math.radians(1.0), - maximum_curve_correction_rad: float = math.radians(3.0), - feedback_endpoint_joints: frozenset[str] = frozenset(), - endpoint_joints: frozenset[str] | None = None, -) -> dict[str, JointCurveFit]: - """Set mechanical-endpoint curve scale from direct SO(3) travel. - - A fitted-axis projection is useful for a dense, signed command curve, but - a small view-dependent orthogonal PnP component can change that - projection's full-scale value. The rotation magnitude between the two - settled endpoint poses is the revolute-joint travel itself: conjugating - both poses by a rigid hand/camera transform or either fixed Tag mounting - rotation cannot change it. - - Use every supplied cycle and both sweep directions as independent endpoint - measurements, reject disagreement, and apply their robust median as one - scale correction to the already validated curve. Dense CMC-roll records - are selected in feedback coordinates; settled mechanical-contact records - remain selected in requested-command coordinates. - """ - if ( - not math.isfinite(maximum_direction_difference_rad) - or maximum_direction_difference_rad <= 0.0 - ): - raise ValueError("endpoint direction difference limit must be positive") - if ( - not math.isfinite(maximum_curve_correction_rad) - or maximum_curve_correction_rad <= 0.0 - ): - raise ValueError("endpoint curve correction limit must be positive") - - result = dict(curves) - selected_endpoint_joints = ( - RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS - if endpoint_joints is None - else frozenset(str(name) for name in endpoint_joints) - ) - unknown_endpoint_joints = sorted( - set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) - ) - if unknown_endpoint_joints: - raise ValueError( - "endpoint joints are not endpoint measurements: " - + ",".join(unknown_endpoint_joints) - ) - unknown_feedback_joints = sorted( - set(feedback_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) - ) - if unknown_feedback_joints: - raise ValueError( - "feedback endpoint joints are not endpoint measurements: " - + ",".join(unknown_feedback_joints) - ) - - for name in sorted(selected_endpoint_joints): - fit = curves.get(name) - records = list(records_by_joint.get(name, ())) - if fit is None: - raise ValueError(f"missing measured endpoint curve for {name}") - - direction_travel: dict[str, float] = {} - all_travel: list[float] = [] - for direction in ("decreasing", "increasing"): - cycles = sorted( - { - int(record.get("cycle", 0)) - for record in records - if str(record.get("direction")) == direction - } - ) - branch_travel: list[float] = [] - for cycle in cycles: - endpoint_rotations: dict[int, Rotation] = {} - for command in (0, 255): - quaternions = [] - for record in records: - if ( - str(record.get("direction")) != direction - or int(record.get("cycle", 0)) != cycle - ): - continue - endpoint_value = ( - explicit_domain_value(record, "feedback") - if name in feedback_endpoint_joints - else record.get( - "requested_command_u8", - record.get("command_u8", -1), - ) - ) - if endpoint_value is None: - continue - if int(round(float(endpoint_value))) == command: - quaternions.append(_relative_rotation(record)) - if not quaternions: - raise ValueError( - f"{name} {direction} cycle {cycle} is missing " - f"settled endpoint {command}" - ) - endpoint_rotations[command] = Rotation.from_quat( - robust_rotation_summary(quaternions)[0] - ) - branch_travel.append( - float( - ( - endpoint_rotations[255].inv() - * endpoint_rotations[0] - ).magnitude() - ) - ) - if not branch_travel: - raise ValueError(f"{name} is missing {direction} endpoint travel") - direction_travel[direction] = float(np.median(branch_travel)) - all_travel.extend(branch_travel) - - travel_values = np.asarray(list(direction_travel.values()), dtype=float) - direction_difference = float(np.ptp(travel_values)) - if direction_difference > maximum_direction_difference_rad: - raise ValueError( - f"{name} settled endpoint directions disagree: " - f"{math.degrees(direction_difference):.3f}deg" - ) - cycle_range = float(np.ptp(np.asarray(all_travel, dtype=float))) - if cycle_range > maximum_direction_difference_rad: - raise ValueError( - f"{name} settled endpoint cycles disagree: " - f"{math.degrees(cycle_range):.3f}deg" - ) - direct_travel = float(np.median(np.asarray(all_travel, dtype=float))) - curve = np.asarray(fit.angle_rad, dtype=float) - if curve.shape != (256,) or not np.all(np.isfinite(curve)): - raise ValueError(f"{name} endpoint curve must contain 256 finite bins") - projected_travel = float(curve[0] - curve[255]) - if direct_travel <= 0.0 or projected_travel <= 0.0: - raise ValueError(f"{name} endpoint travel must be positive") - correction = abs(projected_travel - direct_travel) - if correction > maximum_curve_correction_rad: - raise ValueError( - f"{name} fitted/direct endpoint travel differs by " - f"{math.degrees(correction):.3f}deg" - ) - scale = direct_travel / projected_travel - - def scaled(values: Sequence[float]) -> tuple[float, ...]: - return tuple(float(value) * scale for value in values) - - circle = dict(fit.circle) - circle.update( - { - "mechanical_endpoint_direct_travel_rad": direct_travel, - "mechanical_endpoint_direction_difference_rad": ( - direction_difference - ), - "mechanical_endpoint_cycle_range_rad": cycle_range, - "mechanical_endpoint_sample_count": len(all_travel), - "mechanical_endpoint_raw_curve_travel_rad": projected_travel, - "mechanical_endpoint_curve_scale": scale, - } - ) - quality = dict(fit.quality) - if "arc_rad" in quality: - quality["arc_rad"] = float(quality["arc_rad"]) * scale - result[name] = replace( - fit, - angle_rad=scaled(fit.angle_rad), - decreasing_rad=scaled(fit.decreasing_rad), - increasing_rad=scaled(fit.increasing_rad), - circle=circle, - maximum_monotonic_correction_rad=( - float(fit.maximum_monotonic_correction_rad) * scale - ), - maximum_hysteresis_rad=float(fit.maximum_hysteresis_rad) * scale, - quality=quality, - zero_offset_rad=float(fit.zero_offset_rad) * scale, - ) - return result - - -def derive_right_19_mechanical_endpoint_offsets( - source_urdf: str | Path, - curves: Mapping[str, JointCurveFit], - *, - maximum_offset_rad: float = math.radians(5.0), - endpoint_joints: frozenset[str] | None = None, -) -> dict[str, float]: - """Estimate encoder origins from repeatable measured mechanical endpoints. - - For the four fingers feedback 0 is the independently verified palm-contact - endpoint; thumb MCP uses its independently verified actuator endpoint. - The source URDF upper limit describes those same physical endpoints. - Therefore ``origin_offset + measured_travel == CAD_upper``. - - Thumb CMC pitch/yaw remain excluded because their endpoint-to-CAD contract - is not independently established. CMC roll is included as a post-solve - origin only: its measured travel determines roll without allowing the - palm common-direction fit to write that joint. - - The returned corrections are measured, generally non-zero encoder zeros; - they do not retain the CAD origin. Using full relative rotation travel - makes them invariant to rigid hand movement and arbitrary fixed Tag - mounting rotation, unlike a cross-camera absolute planar-PnP phase. - """ - if not math.isfinite(maximum_offset_rad) or maximum_offset_rad <= 0.0: - raise ValueError("mechanical endpoint maximum offset must be positive") - root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot() - joints = {str(node.get("name")): node for node in root.findall("joint")} - result: dict[str, float] = {} - selected_endpoint_joints = ( - RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS - if endpoint_joints is None - else frozenset(str(name) for name in endpoint_joints) - ) - unknown_endpoint_joints = sorted( - set(selected_endpoint_joints) - set(RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS) - ) - if unknown_endpoint_joints: - raise ValueError( - "endpoint joints are not endpoint measurements: " - + ",".join(unknown_endpoint_joints) - ) - for name in sorted(selected_endpoint_joints): - fit = curves.get(name) - joint = joints.get(name) - limit = None if joint is None else joint.find("limit") - if fit is None: - raise ValueError(f"missing measured endpoint curve for {name}") - if limit is None or limit.get("upper") is None: - raise ValueError(f"source URDF joint {name} has no upper limit") - upper = float(limit.get("upper")) - curve = np.asarray(fit.angle_rad, dtype=float) - if curve.shape != (256,) or not np.all(np.isfinite(curve)): - raise ValueError(f"{name} endpoint curve must contain 256 finite bins") - measured_endpoint = float(curve[0]) - if measured_endpoint <= 0.0 or abs(float(curve[255])) > math.radians(0.05): - raise ValueError( - f"{name} endpoint curve does not use command 255 as its zero reference" - ) - if abs(measured_endpoint - float(np.max(curve))) > math.radians(0.05): - raise ValueError( - f"{name} command 0 is not the measured upper mechanical endpoint" - ) - offset = upper - measured_endpoint - if not math.isfinite(offset) or abs(offset) > maximum_offset_rad: - raise ValueError( - f"{name} endpoint-derived zero offset is outside the safe range: " - f"{math.degrees(offset):.3f}deg" - ) - result[name] = offset - return result - - -def get_zero_calibration_profile( - side: str, layout_id: str = "legacy_11" -) -> ZeroCalibrationProfile: - return _build_zero_profile(get_hand_calibration_profile(side, layout_id)) - - -LEFT_ZERO_PROFILE = _build_zero_profile(LEFT_HAND_PROFILE) - -# Backwards-compatible left-hand aliases. -CONSTRAINED_CIRCLE_JOINTS = LEFT_ZERO_PROFILE.constrained_circle_joints -DIRECT_ZERO_JOINTS = LEFT_ZERO_PROFILE.direct_zero_joints -FIXED_DIRECT_ZERO_OFFSETS_RAD = dict( - LEFT_ZERO_PROFILE.fixed_direct_zero_offsets_rad -) -OPTIMIZED_ZERO_JOINTS = tuple( - name for name in DIRECT_ZERO_JOINTS - if name not in FIXED_DIRECT_ZERO_OFFSETS_RAD -) -AXIS_JOINTS = LEFT_ZERO_PROFILE.axis_joints -INHERITED_ZERO_JOINTS = dict(LEFT_ZERO_PROFILE.inherited_zero_joints) - - -def circle_direction_is_constrained( - joint_name: str, constrained_circle_joints: frozenset[str] -) -> bool: - """Match the constraint used by both canonical and side aliases.""" - name = str(joint_name) - return name in constrained_circle_joints or name.endswith("_side") - - -def select_cross_view_roll_direction_source( - primary_cone_residuals_rad: Sequence[float], - secondary_cone_residuals_rad: Sequence[float], - maximum_cone_mismatch_rad: float, -) -> str: - """Choose one roll-axis view for the complete repeated-sweep group. - - The choice must not be made independently for every cycle. A residual - sitting just either side of the cone gate can otherwise alternate the - selected camera and turn a fixed cross-view bias into a false cycle-axis - spread. The secondary view wins only with a three-quarter consensus and - a median residual inside the unchanged geometry gate; ambiguous groups - retain the primary view. - """ - primary = np.asarray(primary_cone_residuals_rad, dtype=float) - secondary = np.asarray(secondary_cone_residuals_rad, dtype=float) - if ( - primary.ndim != 1 - or secondary.ndim != 1 - or len(primary) != len(secondary) - or len(primary) < 3 - or not np.all(np.isfinite(primary)) - or not np.all(np.isfinite(secondary)) - or float(maximum_cone_mismatch_rad) <= 0.0 - ): - return "primary" - limit = float(maximum_cone_mismatch_rad) - required_consensus = int(math.ceil(0.75 * len(primary))) - primary_passes = int(np.count_nonzero(primary <= limit)) - secondary_passes = int(np.count_nonzero(secondary <= limit)) - if ( - secondary_passes >= required_consensus - and primary_passes < required_consensus - and float(np.median(secondary)) <= limit - and float(np.median(primary)) > limit - ): - return "secondary" - return "primary" - - -ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 = 16 - -# Fit an axis-line point from the complete relative SE(3) trajectory instead -# of using only the moving Tag centre's free 3-D circle. For an approximately -# end-on view, planar-PnP optical depth is deliberately projected out: image -# x/y still observes the radial motion that determines an axis line, while -# the independent Tag depths are the dominant source of false zero phase. -AXIS_POINT_MINIMUM_ROTATION_RAD = math.radians(3.0) -AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD = math.radians(45.0) -AXIS_POINT_RESIDUAL_SCALE_M = 0.001 - -# A joint's own screw axis is invariant to its own encoder-zero offset. Only -# the downstream axis selected by the kinematic chain can observe that zero. -# Feeding every axis-direction and axis-line residual into the optimizer lets -# fixed CAD/PnP geometry residuals push unrelated zero offsets to their bounds. -# Keep the two root lines as the palm-pose anchor, then use only the component -# that actually observes the preceding zero in each serial chain. -ZERO_AXIS_PARENT_JOINT = dict(LEFT_ZERO_PROFILE.axis_parent_joint) -ZERO_AXIS_OBSERVATION_JOINTS = frozenset( - set(LEFT_ZERO_PROFILE.root_anchor_joints) | set(ZERO_AXIS_PARENT_JOINT) -) -ZERO_LINE_OBSERVATION_JOINTS = frozenset( - set(LEFT_ZERO_PROFILE.root_anchor_joints) - | set(LEFT_ZERO_PROFILE.phase_parent_joint) -) -ZERO_ROOT_ANCHOR_JOINTS = LEFT_ZERO_PROFILE.root_anchor_joints -ZERO_MINIMUM_AXIS_CONE_RAD = math.radians(15.0) - - -def _zero_sensitive_axis_error_rad( - predicted_axis: Sequence[float], - observed_axis: Sequence[float], - parent_axis: Sequence[float], -) -> float: - """Return only the axis error that a parent-joint zero can change. - - Rotating a downstream axis about its parent preserves their mutual cone - angle. The component normal to their plane is therefore the observable - encoder-zero error; cone-angle mismatch belongs to fixed geometry/PnP and - must not push a zero offset or fail its holdout validation. - """ - predicted = _vector(predicted_axis, 3, name="predicted axis") - predicted /= np.linalg.norm(predicted) - observed = _vector(observed_axis, 3, name="observed axis") - observed /= np.linalg.norm(observed) - parent = _vector(parent_axis, 3, name="parent axis") - parent /= np.linalg.norm(parent) - if float(predicted @ observed) < 0.0: - observed = -observed - predicted_projected = predicted - parent * float(predicted @ parent) - observed_projected = observed - parent * float(observed @ parent) - predicted_norm = float(np.linalg.norm(predicted_projected)) - observed_norm = float(np.linalg.norm(observed_projected)) - minimum_projection = math.sin(ZERO_MINIMUM_AXIS_CONE_RAD) - if min(predicted_norm, observed_norm) < minimum_projection: - raise ValueError( - "parent and downstream axes have an unobservable cone angle" - ) - predicted_projected /= predicted_norm - observed_projected /= observed_norm - return math.atan2( - float(parent @ np.cross(predicted_projected, observed_projected)), - float( - np.clip(predicted_projected @ observed_projected, -1.0, 1.0) - ), - ) - - -def _axis_cone_mismatch_rad( - predicted_axis: Sequence[float], - observed_axis: Sequence[float], - parent_axis: Sequence[float], -) -> float: - """Return zero-invariant parent/downstream cone-angle disagreement.""" - predicted = _vector(predicted_axis, 3, name="predicted axis") - predicted /= np.linalg.norm(predicted) - observed = _vector(observed_axis, 3, name="observed axis") - observed /= np.linalg.norm(observed) - parent = _vector(parent_axis, 3, name="parent axis") - parent /= np.linalg.norm(parent) - predicted_cone = math.acos( - abs(float(np.clip(parent @ predicted, -1.0, 1.0))) - ) - observed_cone = math.acos( - abs(float(np.clip(parent @ observed, -1.0, 1.0))) - ) - return abs(predicted_cone - observed_cone) - - -def _vector(value: Sequence[float], size: int, *, name: str) -> np.ndarray: - result = np.asarray(value, dtype=float) - if result.shape != (size,) or not np.all(np.isfinite(result)): - raise ValueError(f"{name} must contain {size} finite values") - return result - - -def _pose_matrix(payload: Mapping[str, Any]) -> np.ndarray: - translation = _vector(payload["translation_xyz_m"], 3, name="translation") - quaternion = _vector(payload["quaternion_xyzw"], 4, name="quaternion") - quaternion /= np.linalg.norm(quaternion) - result = np.eye(4) - result[:3, :3] = Rotation.from_quat(quaternion).as_matrix() - result[:3, 3] = translation - return result - - -def _relative_rotation(record: Mapping[str, Any]) -> np.ndarray: - quaternion = _vector( - record["relative_quaternion_xyzw"], 4, name="relative quaternion" - ) - return quaternion / np.linalg.norm(quaternion) - - -def _reference_group_key(record: Mapping[str, Any]) -> tuple[Any, Any]: - return record.get("cycle"), record.get("direction") - - -def _canonical_reference_records( - records: Sequence[Mapping[str, Any]], - canonical_zero_direction: str | None, -) -> list[Mapping[str, Any]]: - """Select the sole physical-zero branch when one is configured.""" - if canonical_zero_direction is None: - return list(records) - if canonical_zero_direction not in {"decreasing", "increasing"}: - raise ValueError("canonical zero direction is invalid") - selected = [ - record - for record in records - if str(record.get("direction")) == canonical_zero_direction - ] - if not selected: - raise ValueError( - f"canonical {canonical_zero_direction} zero branch has no samples" - ) - return selected - - -def _interpolate_reference_rotation( - records: Sequence[Mapping[str, Any]], - zero_command_u8: int, - maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8, -) -> Rotation | None: - by_command: dict[int, list[np.ndarray]] = {} - for record in records: - command = int(record["command_u8"]) - by_command.setdefault(command, []).append(_relative_rotation(record)) - if not by_command: - return None - - rotations = { - command: Rotation.from_quat(robust_rotation_summary(values)[0]) - for command, values in by_command.items() - } - zero = int(zero_command_u8) - if zero in rotations: - return rotations[zero] - - lower = [command for command in rotations if command < zero] - upper = [command for command in rotations if command > zero] - lower_command = max(lower) if lower else None - upper_command = min(upper) if upper else None - if lower_command is not None and upper_command is not None: - lower_distance = zero - lower_command - upper_distance = upper_command - zero - if maximum_distance_u8 is None or max( - lower_distance, upper_distance - ) <= int(maximum_distance_u8): - lower_rotation = rotations[lower_command] - upper_rotation = rotations[upper_command] - fraction = lower_distance / (upper_command - lower_command) - delta = (lower_rotation.inv() * upper_rotation).as_rotvec() - return lower_rotation * Rotation.from_rotvec(delta * fraction) - - nearest_command = min(rotations, key=lambda command: abs(command - zero)) - if maximum_distance_u8 is None or abs( - nearest_command - zero - ) <= int(maximum_distance_u8): - return rotations[nearest_command] - return None - - -def _near_zero_records( - records: Sequence[Mapping[str, Any]], zero_command_u8: int -) -> list[Mapping[str, Any]]: - groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} - for record in records: - groups.setdefault(_reference_group_key(record), []).append(record) - selected: list[Mapping[str, Any]] = [] - zero = int(zero_command_u8) - for group in groups.values(): - distance = min(abs(int(record["command_u8"]) - zero) for record in group) - if distance > ZERO_REFERENCE_MAXIMUM_DISTANCE_U8: - continue - selected.extend( - record - for record in group - if abs(int(record["command_u8"]) - zero) == distance - ) - return selected - - -def _baseline_reference( - records: Sequence[Mapping[str, Any]], - zero_command_u8: int, - maximum_distance_u8: int | None = ZERO_REFERENCE_MAXIMUM_DISTANCE_U8, -) -> tuple[float, float, float, float]: - groups: dict[tuple[Any, Any], list[Mapping[str, Any]]] = {} - for record in records: - groups.setdefault(_reference_group_key(record), []).append(record) - values = [ - rotation.as_quat() - for group in groups.values() - if ( - rotation := _interpolate_reference_rotation( - group, - zero_command_u8, - maximum_distance_u8, - ) - ) - is not None - ] - if not values: - distance = ( - "the observed physical stroke" - if maximum_distance_u8 is None - else f"{int(maximum_distance_u8)} commands of zero {zero_command_u8}" - ) - raise ValueError(f"joint records have no samples within {distance}") - return robust_rotation_summary(values)[0] - - -def baseline_hysteresis_by_cycle_rad( - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, - axis_xyz: Sequence[float] | None = None, -) -> tuple[float, ...]: - """Return decreasing/increasing joint-angle disagreement per cycle. - - Full SO(3) disagreement includes planar-PnP tilt noise that is orthogonal - to the fitted revolute axis. When an axis is supplied, report only the - physically meaningful component about that axis; the orthogonal component - remains covered by the independent rotation-model residual gate. - """ - samples = [dict(record) for record in records] - axis: np.ndarray | None = None - if axis_xyz is not None: - axis = _vector(axis_xyz, 3, name="baseline hysteresis axis") - norm = float(np.linalg.norm(axis)) - if norm <= 0.0: - raise ValueError("baseline hysteresis axis must be non-zero") - axis /= norm - result: list[float] = [] - for cycle in sorted({int(record["cycle"]) for record in samples}): - rotations: dict[str, Rotation] = {} - for direction in ("decreasing", "increasing"): - selected = [ - record - for record in samples - if int(record["cycle"]) == cycle - and str(record["direction"]) == direction - ] - reference = _interpolate_reference_rotation( - selected, zero_command_u8 - ) - if reference is None: - raise ValueError( - f"cycle {cycle} {direction} is missing baseline samples" - ) - rotations[direction] = reference - delta = ( - rotations["decreasing"].inv() - * rotations["increasing"] - ) - result.append( - float( - delta.magnitude() - if axis is None - else abs(float(delta.as_rotvec() @ axis)) - ) - ) - if not result: - raise ValueError("baseline hysteresis requires at least one cycle") - return tuple(result) - - -def fit_rotation_joint_curve( - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, - canonical_zero_direction: str | None = None, - require_observed_domain_endpoints: bool = True, - zero_reference_maximum_distance_u8: int | None = ( - ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 - ), -) -> JointCurveFit: - """Fit a direction-aware curve from parent-to-child Tag orientations. - - With ``canonical_zero_direction`` both branches share one physical - reference. Only the canonical branch is zero at ``zero_command_u8``; - the other branch retains its measured backlash/compliance offset. - - Byte-feedback products keep the default requirement that both exact - 0/255 endpoints were observed. Physical-angle products may set - ``require_observed_domain_endpoints=False`` after their acquisition policy - has independently proved feedback travel and coverage; the dense internal - curve then uses bounded edge extrapolation instead of inventing endpoint - feedback samples. - """ - samples = [dict(record) for record in records] - if len(samples) < 12: - raise ValueError("rotation trajectory requires at least 12 samples") - vectors: list[np.ndarray] = [] - commands: list[int] = [] - values_by_record: list[float] = [] - references: dict[int, Rotation] = {} - for cycle in sorted({int(record["cycle"]) for record in samples}): - cycle_records = [ - record for record in samples if int(record["cycle"]) == cycle - ] - references[cycle] = Rotation.from_quat( - _baseline_reference( - _canonical_reference_records( - cycle_records, canonical_zero_direction - ), - zero_command_u8, - zero_reference_maximum_distance_u8, - ) - ) - for record in samples: - observed = Rotation.from_quat(_relative_rotation(record)) - vector = ( - references[int(record["cycle"])].inv() * observed - ).as_rotvec() - vectors.append(vector) - commands.append(int(record["command_u8"])) - axis = fit_rotation_axis(vectors, commands) - values_by_record = [float(vector @ axis) for vector in vectors] - curves, correction, hysteresis = _fit_joint_curve( - samples, - values_by_record, - preserve_direction_offset=canonical_zero_direction is not None, - require_observed_domain_endpoints=require_observed_domain_endpoints, - ) - if canonical_zero_direction is None: - for key in ("angle_rad", "decreasing_rad", "increasing_rad"): - values = np.asarray(curves[key], dtype=float) - values -= float(values[int(zero_command_u8)]) - curves[key] = [round(float(value), 8) for value in values] - else: - canonical_key = f"{canonical_zero_direction}_rad" - shared_zero = float(curves[canonical_key][int(zero_command_u8)]) - for key in ("decreasing_rad", "increasing_rad"): - values = np.asarray(curves[key], dtype=float) - shared_zero - curves[key] = [round(float(value), 8) for value in values] - # Before the bridge has observed motion direction it must use the - # same branch that defines the URDF physical zero, never an average - # pose that the mechanism may not be able to occupy. - curves["angle_rad"] = list(curves[canonical_key]) - orthogonal = [ - float(np.linalg.norm(vector - float(vector @ axis) * axis)) - for vector in vectors - ] - return JointCurveFit( - angle_rad=tuple(float(value) for value in curves["angle_rad"]), - decreasing_rad=tuple(float(value) for value in curves["decreasing_rad"]), - increasing_rad=tuple(float(value) for value in curves["increasing_rad"]), - circle={ - "space": "relative_rotation_3d", - "axis_xyz": [float(value) for value in axis], - "zero_command_u8": int(zero_command_u8), - "reference_quaternion_xyzw": [ - float(value) - for value in _baseline_reference( - _canonical_reference_records( - samples, canonical_zero_direction - ), - zero_command_u8, - zero_reference_maximum_distance_u8, - ) - ], - "canonical_zero_direction": canonical_zero_direction, - }, - maximum_monotonic_correction_rad=float(correction), - maximum_hysteresis_rad=float(hysteresis), - quality={ - "rotation_orthogonal_rms_rad": float( - np.sqrt(np.mean(np.square(orthogonal))) - ), - "arc_rad": float( - max(curves["angle_rad"]) - min(curves["angle_rad"]) - ), - }, - ) - - -def measure_rotation_joint_observation( - fit: JointCurveFit, quaternion_xyzw: Sequence[float] -) -> float: - """Measure one parent-to-child orientation with a fitted 3-D curve.""" - if fit.circle.get("space") != "relative_rotation_3d": - raise ValueError("joint fit is not a relative-rotation curve") - reference = Rotation.from_quat( - _vector( - fit.circle["reference_quaternion_xyzw"], - 4, - name="reference quaternion", - ) - ) - observed = Rotation.from_quat( - _vector(quaternion_xyzw, 4, name="observed quaternion") - ) - axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") - axis /= np.linalg.norm(axis) - return float((reference.inv() * observed).as_rotvec() @ axis) - - -def measure_joint_curve_observation( - fit: JointCurveFit, - *, - quaternion_xyzw: Sequence[float] | None = None, - image_relative_xy_px: Sequence[float] | None = None, -) -> float: - """Measure one observation in the same space as its fitted curve.""" - if fit.circle.get("space") == "relative_rotation_3d": - if quaternion_xyzw is None: - raise ValueError("rotation observation quaternion is missing") - return measure_rotation_joint_observation(fit, quaternion_xyzw) - if fit.circle.get("space") != "image_2d": - raise ValueError("unsupported joint curve observation representation") - if image_relative_xy_px is None: - raise ValueError("image curve observation point is missing") - point = np.asarray(image_relative_xy_px, dtype=float) - centre = np.asarray(fit.circle["center_xy_px"], dtype=float) - reference = np.asarray(fit.circle["reference_xy_px"], dtype=float) - vector = point - centre - if ( - point.shape != (2,) - or not np.all(np.isfinite(point)) - or float(np.linalg.norm(vector)) < 1.0e-9 - ): - raise ValueError("image curve observation point is invalid") - return float(fit.circle["orientation_sign"]) * math.atan2( - float(reference[0] * vector[1] - reference[1] * vector[0]), - float(reference @ vector), - ) - - -def rotation_curve_holdout_errors( - fit: JointCurveFit, - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, - zero_reference_maximum_distance_u8: int | None = ( - ZERO_REFERENCE_MAXIMUM_DISTANCE_U8 - ), -) -> tuple[float, ...]: - """Validate a fitted curve on an untouched scan cycle.""" - samples = [dict(record) for record in records] - if not samples: - raise ValueError("holdout records are empty") - canonical_zero_direction = fit.circle.get("canonical_zero_direction") - reference = Rotation.from_quat( - _baseline_reference( - _canonical_reference_records(samples, canonical_zero_direction), - zero_command_u8, - zero_reference_maximum_distance_u8, - ) - ) - axis = _vector(fit.circle["axis_xyz"], 3, name="rotation axis") - axis /= np.linalg.norm(axis) - errors: list[float] = [] - for record in samples: - observed = Rotation.from_quat(_relative_rotation(record)) - angle = float((reference.inv() * observed).as_rotvec() @ axis) - command = int(record["command_u8"]) - direction = str(record["direction"]) - expected_curve = ( - fit.decreasing_rad - if direction == "decreasing" - else fit.increasing_rad - ) - errors.append(angle - float(expected_curve[command])) - return tuple(errors) - - -def joint_curve_holdout_errors( - fit: JointCurveFit, - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, -) -> tuple[float, ...]: - """Validate either a rotation or fixed-parent image-circle curve.""" - if fit.circle.get("space") == "relative_rotation_3d": - return rotation_curve_holdout_errors( - fit, records, zero_command_u8=zero_command_u8 - ) - if fit.circle.get("space") != "image_2d": - raise ValueError("unsupported joint curve holdout representation") - if not records: - raise ValueError("holdout records are empty") - errors: list[float] = [] - for record in records: - observed = measure_joint_curve_observation( - fit, - image_relative_xy_px=record["image_relative_xy_px"], - ) - command = int(record["command_u8"]) - direction = str(record["direction"]) - expected_curve = ( - fit.decreasing_rad - if direction == "decreasing" - else fit.increasing_rad - ) - errors.append(observed - float(expected_curve[command])) - return tuple(errors) - - -@dataclass(frozen=True) -class JointAxisMeasurement: - joint: str - cycle: int - axis_common_xyz: tuple[float, float, float] - point_common_xyz_m: tuple[float, float, float] - condition_state_u8: tuple[float, ...] - plane_rms_m: float - radial_rms_m: float - rotation_circle_axis_difference_rad: float - # Optical-axis direction expressed in the shared calibration frame. It - # lets the phase solver discard the least reliable monocular-PnP depth - # component when the joint axis is viewed approximately end-on. Older - # recordings and synthetic callers may omit it and retain the 3-D path. - view_normal_common_xyz: tuple[float, float, float] | None = None - # Logical commands that produced the condition. Firmware feedback may - # saturate a few u8 short of an endpoint (for example right motor 10 reads - # 250 for command 255); kinematics must use the command-indexed curve while - # retaining condition_state_u8 for diagnostics and safety. - condition_command_u8: tuple[float, ...] | None = None - axis_direction_source: str = "unspecified" - circle_axis_observability: float = 0.0 - axis_point_source: str = "circle_center" - pose_axis_line_rms_m: float = 0.0 - # Unprojected residuals are evidence, not extra phase observations. The - # axial component lies in the null space of (I-R) for a revolute axis. - pose_axis_line_raw_rms_m: float | None = None - pose_axis_line_axial_rms_m: float | None = None - pose_axis_line_transverse_rms_m: float | None = None - axis_point_axial_component_separated: bool = False - # Measurement record(s) that supplied pose_axis_line_rms_m. A combined - # cross-view axis may keep the front direction but take its physical line - # point and line-quality residual from the side alias. Retry logic must - # clear the actual quality source instead of blindly rescanning ``joint``. - pose_axis_line_source_joints: tuple[str, ...] = () - # Camera centre that observed ``point_common_xyz_m``. When populated, - # the zero solver can use only the ray from this centre to the fitted axis - # point. That ray is the depth-free interpretation-plane observation of - # the physical axis; translating a monocular planar-PnP solution along - # its optical ray therefore cannot rotate the recovered palm frame. - axis_point_camera_center_common_xyz_m: ( - tuple[float, float, float] | None - ) = None - # Normal of the source camera's interpretation plane for this axis line. - # Unlike a 3-D PnP line point, the plane is unchanged by optical-depth - # error. Several named parallel root lines jointly recover their common - # physical direction as the null direction of these plane normals. - axis_point_interpretation_plane_normal_common_xyz: ( - tuple[float, float, float] | None - ) = None - - -@dataclass(frozen=True) -class PalmOrientationMeasurement: - """Direction-only joint observation from a partially visible sweep. - - The moving Tag may disappear before the motor reaches its far endpoint. - Only the relative SO(3) trajectory is retained, so fixed Tag translation - and mounting rotation cannot define the palm phase. - """ - - source_joint: str - model_joint: str - cycle: int - axis_common_xyz: tuple[float, float, float] - condition_state_u8: tuple[float, ...] - observed_arc_rad: float - rotation_orthogonal_rms_rad: float - axis_estimator: str = "baseline_relative_so3" - incremental_pair_count: int = 0 - - -PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 = 24 -PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD = math.radians(0.3) -PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT = 24 -PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE = 75.0 - - -def _incremental_common_rotation_axis( - samples_by_bin: Mapping[ - tuple[str, int], Sequence[Mapping[str, Any]] - ], - *, - command_separation_u8: int = ( - PALM_AXIS_INCREMENT_COMMAND_SEPARATION_U8 - ), - minimum_rotation_rad: float = ( - PALM_AXIS_INCREMENT_MINIMUM_ROTATION_RAD - ), - minimum_pair_count: int = PALM_AXIS_INCREMENT_MINIMUM_PAIR_COUNT, - consensus_percentile: float = ( - PALM_AXIS_INCREMENT_CONSENSUS_PERCENTILE - ), -) -> tuple[np.ndarray, int]: - """Fit one physical axis from robust local Tag rotations. - - A single baseline-to-endpoint logarithm is sensitive to smooth planar-PnP - curvature: the fitted axis then changes when the same Tag trajectory is - translated to another part of the image. Local finite rotations are - expressed directly in the common camera frame, and their undirected - weighted consensus estimates the physical revolute axis. The worst - quartile is discarded once more after the initial consensus, which is - enough to reject endpoint branch curvature without learning a - serial-specific yaw value or an image-position correction table. - """ - separation = int(command_separation_u8) - if separation < 4 or separation > 64: - raise ValueError("palm axis command separation must be in [4, 64]") - if not 0.0 < float(minimum_rotation_rad) < math.pi: - raise ValueError("palm axis minimum increment must be in (0, pi)") - if int(minimum_pair_count) < 6: - raise ValueError("palm axis minimum pair count must be at least six") - percentile = float(consensus_percentile) - if not 50.0 <= percentile <= 90.0: - raise ValueError("palm axis consensus percentile must be in [50, 90]") - - binned_common_rotations: dict[tuple[str, int], Rotation] = {} - for key, group in samples_by_bin.items(): - child_quaternions = [] - for record in group: - pose = record.get("child_pose_common") - if not isinstance(pose, Mapping): - continue - quaternion = pose.get("quaternion_xyzw") - if quaternion is not None: - child_quaternions.append(quaternion) - if not child_quaternions: - continue - binned_common_rotations[(str(key[0]), int(key[1]))] = ( - Rotation.from_quat( - robust_rotation_summary(child_quaternions)[0] - ) - ) - - directions: list[np.ndarray] = [] - weights: list[float] = [] - for direction in ("decreasing", "increasing"): - commands = sorted( - command - for candidate_direction, command in binned_common_rotations - if candidate_direction == direction - ) - for start_command in commands: - candidates = [ - command - for command in commands - if separation <= command - start_command <= separation + 2 - ] - if not candidates: - continue - end_command = candidates[0] - start = binned_common_rotations[(direction, start_command)] - end = binned_common_rotations[(direction, end_command)] - local_rotvec = (start.inv() * end).as_rotvec() - common_rotvec = start.apply(local_rotvec) - increment = float(np.linalg.norm(common_rotvec)) - if increment < float(minimum_rotation_rad): - continue - directions.append(common_rotvec / increment) - weights.append(increment) - - if len(directions) < int(minimum_pair_count): - raise ValueError( - "palm axis has too few full-stroke local rotation pairs: " - f"{len(directions)}/{int(minimum_pair_count)}" - ) - vectors = np.asarray(directions, dtype=float) - increments = np.asarray(weights, dtype=float) - - def consensus(selected: np.ndarray) -> np.ndarray: - scatter = np.einsum( - "n,ni,nj->ij", - increments[selected], - vectors[selected], - vectors[selected], - ) - eigenvalues, eigenvectors = np.linalg.eigh(scatter) - if not np.all(np.isfinite(eigenvalues)): - raise ValueError("palm axis local-rotation consensus is invalid") - axis = eigenvectors[:, -1] - return axis / np.linalg.norm(axis) - - selected = np.ones(len(vectors), dtype=bool) - axis = consensus(selected) - # Two deterministic refinement rounds prevent the initial scatter from - # being pulled toward a dense endpoint-bias cluster. - for _ in range(2): - deviations = np.arccos( - np.clip(np.abs(vectors @ axis), -1.0, 1.0) - ) - limit = float(np.percentile(deviations, percentile)) - selected = deviations <= limit - if int(np.count_nonzero(selected)) < int(minimum_pair_count): - raise ValueError("palm axis robust consensus retained too few pairs") - axis = consensus(selected) - return axis, int(np.count_nonzero(selected)) - - -def fit_partial_palm_orientation_measurement( - source_joint: str, - model_joint: str, - records: Sequence[Mapping[str, Any]], - *, - cycle: int, - zero_command_u8: int, - minimum_arc_rad: float = math.radians(15.0), - maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5), - maximum_command_distance_u8: int = 255, -) -> PalmOrientationMeasurement: - """Fit a physical axis within a configured window around encoder zero.""" - cycle_samples = [ - dict(record) - for record in records - if int(record.get("cycle", -1)) == int(cycle) - ] - if len(cycle_samples) < 12: - raise ValueError( - f"{source_joint} cycle {cycle + 1} has too few visible samples" - ) - if not 0.0 < float(minimum_arc_rad) < math.pi: - raise ValueError("palm orientation minimum arc must be in (0, pi)") - if float(maximum_rotation_orthogonal_rms_rad) <= 0.0: - raise ValueError( - "palm orientation rotation residual limit must be positive" - ) - command_distance = int(maximum_command_distance_u8) - if command_distance < 1 or command_distance > 255: - raise ValueError( - "palm orientation command distance must be in [1, 255]" - ) - zero = int(zero_command_u8) - samples = [ - record - for record in cycle_samples - if abs(int(record["command_u8"]) - zero) <= command_distance - ] - if len(samples) < 12: - raise ValueError( - f"{source_joint} cycle {cycle + 1} has too few zero-adjacent " - "visible samples" - ) - reference = Rotation.from_quat( - _baseline_reference(samples, int(zero_command_u8)) - ) - samples_by_bin: dict[tuple[str, int], list[Mapping[str, Any]]] = {} - for record in samples: - key = ( - str(record.get("direction", "")), - int(record["command_u8"]), - ) - samples_by_bin.setdefault(key, []).append(record) - if len(samples_by_bin) < 6: - raise ValueError( - f"{source_joint} cycle {cycle + 1} has too few visible command bins" - ) - binned_rotations = [ - Rotation.from_quat( - robust_rotation_summary( - [_relative_rotation(record) for record in group] - )[0] - ) - for group in samples_by_bin.values() - ] - vectors = [ - (reference.inv() * rotation).as_rotvec() - for rotation in binned_rotations - ] - axis_child = fit_rotation_axis( - vectors, [command for _direction, command in samples_by_bin] - ) - angles = np.asarray( - [float(vector @ axis_child) for vector in vectors], dtype=float - ) - observed_arc = float(np.ptp(angles)) - orthogonal_rms = float( - np.sqrt( - np.mean( - [ - np.linalg.norm( - vector - float(vector @ axis_child) * axis_child - ) - ** 2 - for vector in vectors - ] - ) - ) - ) - if observed_arc < float(minimum_arc_rad): - raise ValueError( - f"{source_joint} cycle {cycle + 1} visible rotation arc " - f"{math.degrees(observed_arc):.3f}deg is below " - f"{math.degrees(minimum_arc_rad):.3f}deg" - ) - if orthogonal_rms > float(maximum_rotation_orthogonal_rms_rad): - raise ValueError( - f"{source_joint} cycle {cycle + 1} zero-adjacent rotation " - f"residual {math.degrees(orthogonal_rms):.3f}deg exceeds " - f"{math.degrees(maximum_rotation_orthogonal_rms_rad):.3f}deg" - ) - zero_records = _near_zero_records(samples, int(zero_command_u8)) - if not zero_records: - raise ValueError( - f"{source_joint} cycle {cycle + 1} has no visible zero pose" - ) - parent_poses = np.asarray( - [_pose_matrix(record["parent_pose_common"]) for record in zero_records] - ) - parent_quaternion = robust_rotation_summary( - [ - Rotation.from_matrix(matrix[:3, :3]).as_quat() - for matrix in parent_poses - ] - )[0] - axis_parent = reference.apply(axis_child) - axis_common = Rotation.from_quat(parent_quaternion).apply(axis_parent) - axis_common /= np.linalg.norm(axis_common) - axis_estimator = "baseline_relative_so3" - incremental_pair_count = 0 - # Product recordings persist the selected moving-Tag pose in the common - # camera frame. Use the complete visible stroke only for a local-motion - # axis consensus; retain the zero-adjacent fit above as the unchanged arc - # and residual quality gate. Legacy/synthetic records without the common - # child pose keep their previous estimator exactly. - if any( - isinstance(record.get("child_pose_common"), Mapping) - for record in cycle_samples - ): - full_stroke_bins: dict[ - tuple[str, int], list[Mapping[str, Any]] - ] = {} - for record in cycle_samples: - key = ( - str(record.get("direction", "")), - int(record["command_u8"]), - ) - full_stroke_bins.setdefault(key, []).append(record) - incremental_axis, incremental_pair_count = ( - _incremental_common_rotation_axis(full_stroke_bins) - ) - if float(incremental_axis @ axis_common) < 0.0: - incremental_axis = -incremental_axis - axis_common = incremental_axis - axis_estimator = "robust_full_stroke_local_so3_v1" - state = np.median( - np.asarray( - [record["state_u8"] for record in zero_records], dtype=float - ), - axis=0, - ) - return PalmOrientationMeasurement( - source_joint=str(source_joint), - model_joint=str(model_joint), - cycle=int(cycle), - axis_common_xyz=tuple(float(value) for value in axis_common), - condition_state_u8=tuple(float(value) for value in state), - observed_arc_rad=observed_arc, - rotation_orthogonal_rms_rad=orthogonal_rms, - axis_estimator=axis_estimator, - incremental_pair_count=incremental_pair_count, - ) - - -def fit_partial_palm_orientation_measurements( - *, - sources: Mapping[str, str], - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], - motor_by_source: Mapping[str, int], - baseline_command_u8: Sequence[int], - cycles: Sequence[int], - minimum_sources: int, - minimum_arc_rad: float = math.radians(15.0), - maximum_rotation_orthogonal_rms_rad: float = math.radians(2.5), - maximum_command_distance_u8: int = 255, -) -> tuple[tuple[PalmOrientationMeasurement, ...], Mapping[str, str]]: - """Fit every usable optional source and require configured coverage.""" - source_map = {str(name): str(model) for name, model in sources.items()} - minimum = int(minimum_sources) - if not source_map: - if minimum != 0: - raise ValueError( - "palm orientation minimum is non-zero without sources" - ) - return (), {} - if minimum < 2 or minimum > len(source_map): - raise ValueError( - "palm orientation minimum source count must be between two " - "and the configured source count" - ) - missing_motors = sorted(set(source_map) - set(motor_by_source)) - if missing_motors: - raise ValueError( - "palm orientation sources are missing motor mappings: " - + ",".join(missing_motors) - ) - fitted: list[PalmOrientationMeasurement] = [] - rejected: dict[str, str] = {} - for cycle in (int(value) for value in cycles): - cycle_fitted: list[PalmOrientationMeasurement] = [] - for source_joint, model_joint in source_map.items(): - motor = int(motor_by_source[source_joint]) - try: - measurement = fit_partial_palm_orientation_measurement( - source_joint, - model_joint, - records_by_joint.get(source_joint, ()), - cycle=cycle, - zero_command_u8=int(baseline_command_u8[motor]), - minimum_arc_rad=minimum_arc_rad, - maximum_rotation_orthogonal_rms_rad=( - maximum_rotation_orthogonal_rms_rad - ), - maximum_command_distance_u8=( - maximum_command_distance_u8 - ), - ) - except Exception as error: - rejected[f"{source_joint}:cycle{cycle + 1}"] = str(error) - continue - cycle_fitted.append(measurement) - if len(cycle_fitted) < minimum: - cycle_reasons = { - key: value - for key, value in rejected.items() - if key.endswith(f":cycle{cycle + 1}") - } - raise ValueError( - f"palm orientation cycle {cycle + 1} has " - f"{len(cycle_fitted)}/{minimum} usable sources: " - + "; ".join( - f"{key}={value}" for key, value in cycle_reasons.items() - ) - ) - fitted.extend(cycle_fitted) - return tuple(fitted), rejected - - -def with_depth_free_axis_projection( - measurement: JointAxisMeasurement, - camera_center_common_xyz_m: Sequence[float], -) -> JointAxisMeasurement: - """Attach the source-camera interpretation plane of an axis line. - - The plane is defined by the camera centre and the fitted 3-D line, but is - only a projective observation: moving either fitted line point along its - optical ray leaves the plane unchanged. A fixed Tag mount changes the - moving point trajectory, not its recovered physical screw axis. - """ - camera_center = _vector( - camera_center_common_xyz_m, 3, name="axis camera centre" - ) - point = np.asarray(measurement.point_common_xyz_m, dtype=float) - axis = np.asarray(measurement.axis_common_xyz, dtype=float) - axis /= np.linalg.norm(axis) - ray = point - camera_center - ray_norm = float(np.linalg.norm(ray)) - if ray_norm <= 1.0e-6: - raise ValueError("axis point coincides with its source camera") - plane_normal = np.cross(ray / ray_norm, axis) - plane_norm = float(np.linalg.norm(plane_normal)) - if plane_norm <= 1.0e-6: - raise ValueError("axis projection is degenerate in its source camera") - plane_normal /= plane_norm - return replace( - measurement, - axis_point_camera_center_common_xyz_m=tuple( - float(value) for value in camera_center - ), - axis_point_interpretation_plane_normal_common_xyz=tuple( - float(value) for value in plane_normal - ), - ) - - -def cross_view_side_line_source( - measurement: JointAxisMeasurement, -) -> str | None: - """Return the side alias that supplied a fallback axis-line point.""" - sources = tuple( - str(source) - for source in getattr( - measurement, "pose_axis_line_source_joints", () - ) - ) - if ( - getattr(measurement, "axis_point_source", "") - in { - "side_circle_cross_view", - "side_circle_shared_radius_cross_view", - "side_interpretation_plane_cross_view", - } - and len(sources) == 1 - and sources[0].endswith("_side") - ): - return sources[0] - return None - - -def axis_line_uses_depth_free_interpretation_plane( - measurement: JointAxisMeasurement, -) -> bool: - """Return whether only the source-camera bearing is geometrically used.""" - return bool( - measurement.axis_point_source - in { - "side_interpretation_plane_cross_view", - "front_interpretation_plane_cross_view_validated", - } - and measurement.axis_point_camera_center_common_xyz_m is not None - ) - - -def refit_axis_line_group_with_shared_radius( - measurements: Sequence[JointAxisMeasurement], - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, - canonical_zero_direction: str | None, -) -> tuple[JointAxisMeasurement, ...]: - """Refit repeated cross-view axis lines with one physical radius. - - A side-view roll alias observes the same child Tag and the same physical - lever arm in every cycle. Fitting an independent radius to each short, - near-edge-on arc leaves radius and circle centre strongly correlated; - sub-pixel PnP noise can then move the reported axis line by several - millimetres even though every trajectory has a low radial residual. - - Keep a separate centre (and therefore an independent line-repeatability - check) for every cycle, but solve one shared radius from all cycles. This - is a physical constraint rather than a relaxed quality gate: the returned - lines are still checked against the unchanged cycle RMS limit, and each - cycle's radial residual remains an independent hard check. - """ - group = tuple(measurements) - if len(group) < 2: - return group - cycles = [int(measurement.cycle) for measurement in group] - if len(set(cycles)) != len(cycles): - raise ValueError("shared-radius axis group contains duplicate cycles") - - entries: list[dict[str, Any]] = [] - initial_parameters: list[float] = [] - initial_radii: list[float] = [] - for measurement in group: - cycle_records = [ - dict(record) - for record in records - if int(record.get("cycle", -1)) == int(measurement.cycle) - ] - motion_records = [ - record - for record in cycle_records - if str(record.get("kind", "sample")) != "baseline_hold_sample" - ] - if len(motion_records) < 6: - motion_records = cycle_records - if len(motion_records) < 6: - raise ValueError( - f"cycle {measurement.cycle + 1} has too few shared-radius samples" - ) - - reference_records = _canonical_reference_records( - cycle_records, canonical_zero_direction - ) - zero_records = _near_zero_records( - reference_records, int(zero_command_u8) - ) - if not zero_records: - raise ValueError( - f"cycle {measurement.cycle + 1} has no shared-radius zero pose" - ) - parent_poses = np.asarray( - [_pose_matrix(record["parent_pose_common"]) for record in zero_records] - ) - parent_translation = np.median(parent_poses[:, :3, 3], axis=0) - parent_quaternion = robust_rotation_summary( - [ - Rotation.from_matrix(matrix[:3, :3]).as_quat() - for matrix in parent_poses - ] - )[0] - parent_rotation = Rotation.from_quat(parent_quaternion) - common_axis = _vector( - measurement.axis_common_xyz, 3, name="shared-radius common axis" - ) - common_axis /= np.linalg.norm(common_axis) - parent_axis = parent_rotation.inv().apply(common_axis) - parent_axis /= np.linalg.norm(parent_axis) - - points = np.asarray( - [record["relative_translation_xyz_m"] for record in motion_records], - dtype=float, - ) - basis_x, basis_y = _plane_basis(parent_axis) - origin = np.mean(points, axis=0) - local = points - origin - points_xy = np.column_stack((local @ basis_x, local @ basis_y)) - initial = _fit_circle_with_axis(points, parent_axis) - initial_center = _vector( - initial["center_xyz_m"], 3, name="initial shared-radius centre" - ) - initial_parameters.extend( - [ - float((initial_center - origin) @ basis_x), - float((initial_center - origin) @ basis_y), - ] - ) - initial_radii.append(float(initial["radius_m"])) - entries.append( - { - "measurement": measurement, - "points": points, - "points_xy": points_xy, - "origin": origin, - "basis_x": basis_x, - "basis_y": basis_y, - "parent_axis": parent_axis, - "parent_rotation": parent_rotation, - "parent_translation": parent_translation, - } - ) - - median_radius = float(np.median(initial_radii)) - if median_radius <= 1.0e-6 or not math.isfinite(median_radius): - raise ValueError("shared-radius axis group has an invalid initial radius") - initial_value = np.asarray( - [*initial_parameters, median_radius], dtype=float - ) - - def residual(parameters: np.ndarray) -> np.ndarray: - radius = float(parameters[-1]) - return np.concatenate( - [ - np.linalg.norm( - entry["points_xy"] - - parameters[2 * index : 2 * index + 2], - axis=1, - ) - - radius - for index, entry in enumerate(entries) - ] - ) / 0.0005 - - lower = np.full(initial_value.shape, -np.inf, dtype=float) - upper = np.full(initial_value.shape, np.inf, dtype=float) - lower[-1] = max(1.0e-6, 0.5 * median_radius) - upper[-1] = 2.0 * median_radius - solution = least_squares( - residual, - initial_value, - bounds=(lower, upper), - loss="soft_l1", - f_scale=1.0, - max_nfev=3000, - ) - if not solution.success: - raise ValueError( - "shared-radius axis optimization failed: " + solution.message - ) - - shared_radius = float(solution.x[-1]) - if not math.isfinite(shared_radius): - raise ValueError("shared-radius axis optimization is non-finite") - result: list[JointAxisMeasurement] = [] - for index, entry in enumerate(entries): - center_xy = solution.x[2 * index : 2 * index + 2] - center_parent = ( - entry["origin"] - + float(center_xy[0]) * entry["basis_x"] - + float(center_xy[1]) * entry["basis_y"] - ) - # The coordinate along an infinite axis is a gauge. Retain the - # robust centre of this cycle's observed axial coordinates. - axial_offsets = ( - entry["points"] - center_parent - ) @ entry["parent_axis"] - center_parent += float(np.median(axial_offsets)) * entry["parent_axis"] - center_common = ( - entry["parent_rotation"].apply(center_parent) - + entry["parent_translation"] - ) - radial_residual = ( - np.linalg.norm( - entry["points_xy"] - center_xy, - axis=1, - ) - - shared_radius - ) - radial_rms = float(np.sqrt(np.mean(np.square(radial_residual)))) - measurement = entry["measurement"] - result.append( - replace( - measurement, - point_common_xyz_m=tuple( - float(value) for value in center_common - ), - radial_rms_m=max(float(measurement.radial_rms_m), radial_rms), - axis_point_source="side_circle_shared_radius_cross_view", - ) - ) - return tuple(result) - - -def maximum_axis_line_cycle_spread_m( - measurements: Sequence[JointAxisMeasurement], -) -> float: - """Measure repeatability of independently fitted near-parallel lines. - - Axis-line points have an arbitrary coordinate along their own direction. - Compare only the perpendicular displacement, symmetrically against both - fitted directions, so that the result remains meaningful with the small - allowed cycle-to-cycle direction variation. - """ - maximum = 0.0 - for left_index, left in enumerate(measurements): - left_axis = np.asarray(left.axis_common_xyz, dtype=float) - left_axis /= np.linalg.norm(left_axis) - left_point = np.asarray(left.point_common_xyz_m, dtype=float) - for right in measurements[left_index + 1 :]: - right_axis = np.asarray(right.axis_common_xyz, dtype=float) - right_axis /= np.linalg.norm(right_axis) - delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point - maximum = max( - maximum, - float(np.linalg.norm(np.cross(delta, left_axis))), - float(np.linalg.norm(np.cross(delta, right_axis))), - ) - return maximum - - -def axis_line_cycle_rms_m( - measurements: Sequence[JointAxisMeasurement], -) -> float: - """Return RMS line-position scatter about the four-cycle consensus. - - For parallel lines, the sum of squared pairwise distances divided by - ``n**2`` equals the mean squared distance from their centroid. Averaging - each pair's distance against both near-parallel directions preserves that - identity while avoiding an arbitrary choice of one cycle's direction. - """ - count = len(measurements) - if count < 2: - return 0.0 - squared_pairwise_sum = 0.0 - for left_index, left in enumerate(measurements): - left_axis = np.asarray(left.axis_common_xyz, dtype=float) - left_axis /= np.linalg.norm(left_axis) - left_point = np.asarray(left.point_common_xyz_m, dtype=float) - for right in measurements[left_index + 1 :]: - right_axis = np.asarray(right.axis_common_xyz, dtype=float) - right_axis /= np.linalg.norm(right_axis) - delta = np.asarray(right.point_common_xyz_m, dtype=float) - left_point - left_distance = float(np.linalg.norm(np.cross(delta, left_axis))) - right_distance = float(np.linalg.norm(np.cross(delta, right_axis))) - squared_pairwise_sum += 0.5 * ( - left_distance**2 + right_distance**2 - ) - return float(math.sqrt(squared_pairwise_sum / (count**2))) - - -def _fit_axis_point_from_pose_trajectory( - records: Sequence[Mapping[str, Any]], - *, - zero_command_u8: int, - axis_parent_xyz: Sequence[float], - angle_axis_parent_xyz: Sequence[float], - phase_reference_point_parent_xyz: Sequence[float], - view_normal_common_xyz: Sequence[float] | None, - canonical_zero_direction: str | None = None, - allow_axial_translation: bool = False, - residual_diagnostics: dict[str, float] | None = None, -) -> tuple[np.ndarray, float, str]: - """Fit the closest point on a revolute axis from full relative poses. - - If ``T(q)`` maps the moving Tag into its parent Tag frame, then - ``T(q) @ inv(T(0))`` is a rotation about the physical joint axis and its - translation obeys ``(I - R(q)) p = t(q)``. Solving this equation over the - complete trajectory uses both pose orientation and translation and avoids - treating a monocular Tag-centre depth arc as ground-truth geometry. - """ - samples = [dict(record) for record in records] - reference_samples = _canonical_reference_records( - samples, canonical_zero_direction - ) - zero_records = _near_zero_records(reference_samples, zero_command_u8) - if not zero_records: - raise ValueError( - f"axis-point fit has no record near baseline {zero_command_u8}" - ) - - reference_rotation = Rotation.from_quat( - _baseline_reference(reference_samples, zero_command_u8) - ).as_matrix() - reference_translation = np.median( - np.asarray( - [record["relative_translation_xyz_m"] for record in zero_records], - dtype=float, - ), - axis=0, - ) - axis = _vector(axis_parent_xyz, 3, name="axis-point direction") - axis /= np.linalg.norm(axis) - angle_axis = _vector( - angle_axis_parent_xyz, 3, name="axis-point angle direction" - ) - angle_axis /= np.linalg.norm(angle_axis) - phase_reference_point = _vector( - phase_reference_point_parent_xyz, - 3, - name="axis-point phase reference", - ) - helper = ( - np.asarray([1.0, 0.0, 0.0]) - if abs(float(axis[0])) < 0.8 - else np.asarray([0.0, 1.0, 0.0]) - ) - basis_first = np.cross(axis, helper) - basis_first /= np.linalg.norm(basis_first) - basis = np.column_stack( - (basis_first, np.cross(axis, basis_first)) - ) - - view_normal_common = None - use_image_plane_projection = False - if view_normal_common_xyz is not None: - view_normal_common = _vector( - view_normal_common_xyz, 3, name="view normal" - ) - view_normal_common /= np.linalg.norm(view_normal_common) - reference_parent_quaternion = robust_rotation_summary( - [ - Rotation.from_matrix( - _pose_matrix(record["parent_pose_common"])[:3, :3] - ).as_quat() - for record in zero_records - ] - )[0] - reference_parent_rotation = Rotation.from_quat( - reference_parent_quaternion - ) - reference_view_normal_parent = reference_parent_rotation.inv().apply( - view_normal_common - ) - reference_view_normal_parent /= np.linalg.norm( - reference_view_normal_parent - ) - use_image_plane_projection = abs( - float(axis @ reference_view_normal_parent) - ) >= math.cos(AXIS_POINT_IMAGE_PLANE_MAXIMUM_OBLIQUITY_RAD) - - matrices: list[np.ndarray] = [] - translations: list[np.ndarray] = [] - raw_matrices: list[np.ndarray] = [] - raw_translations: list[np.ndarray] = [] - used_image_plane_projection = False - raw_angles: list[float] = [] - translation_angles: list[float] = [] - reference_radial = reference_translation - phase_reference_point - reference_radial -= axis * float(reference_radial @ axis) - reference_radial_norm = float(np.linalg.norm(reference_radial)) - if reference_radial_norm < 1.0e-6: - raise ValueError("axis-point phase reference radius is degenerate") - reference_radial /= reference_radial_norm - for record in samples: - observed_rotation = Rotation.from_quat( - _relative_rotation(record) - ).as_matrix() - observed_delta_rotation = observed_rotation @ reference_rotation.T - raw_angle = float( - Rotation.from_matrix(observed_delta_rotation).as_rotvec() - @ angle_axis - ) - if abs(raw_angle) < AXIS_POINT_MINIMUM_ROTATION_RAD: - continue - observed_translation = _vector( - record["relative_translation_xyz_m"], - 3, - name="relative translation", - ) - observed_radial = observed_translation - phase_reference_point - observed_radial -= axis * float(observed_radial @ axis) - observed_radial_norm = float(np.linalg.norm(observed_radial)) - if observed_radial_norm <= 1.0e-6: - continue - observed_radial /= observed_radial_norm - raw_angles.append(raw_angle) - translation_angles.append( - math.atan2( - float(axis @ np.cross(reference_radial, observed_radial)), - float( - np.clip(reference_radial @ observed_radial, -1.0, 1.0) - ), - ) - ) - orientation_sign = 1.0 - if raw_angles and float( - np.sum(np.asarray(raw_angles) * np.asarray(translation_angles)) - ) < 0.0: - orientation_sign = -1.0 - - for record in samples: - observed_rotation = Rotation.from_quat( - _relative_rotation(record) - ).as_matrix() - observed_translation = _vector( - record["relative_translation_xyz_m"], - 3, - name="relative translation", - ) - observed_delta_rotation = observed_rotation @ reference_rotation.T - signed_angle = float( - Rotation.from_matrix(observed_delta_rotation).as_rotvec() - @ angle_axis - ) * orientation_sign - if abs(signed_angle) < AXIS_POINT_MINIMUM_ROTATION_RAD: - continue - # Direction and angle have deliberately separate sources. A trusted - # upstream/circle direction may replace a biased planar-PnP axis, while - # the relative-orientation trajectory still provides a repeatable - # scalar travel. Reconstruct the admissible revolute motion before - # solving its axis line instead of feeding a contradictory 3-D - # rotation into the translation equations. - # The rotation-trajectory axis is undirected. Align its scalar sign - # with the selected physical axis using the Tag-centre trajectory; - # this remains robust even when a trusted upstream direction replaces - # a badly biased planar-PnP orientation axis. - delta_rotation = Rotation.from_rotvec( - axis * signed_angle - ).as_matrix() - delta_translation = ( - observed_translation - - delta_rotation @ reference_translation - ) - # A screw-driven revolute joint may carry real translation along its - # axis. That component is in the null space of (I - R), contains no - # information about the perpendicular axis-line point, and must not - # inflate or bias the line fit as if the mechanism were pure rotary. - axis_projection = ( - np.eye(3) - np.outer(axis, axis) - if allow_axial_translation - else np.eye(3) - ) - projection = axis_projection - axis_point_matrix = (np.eye(3) - delta_rotation) @ basis - raw_matrices.append(axis_point_matrix) - raw_translations.append(delta_translation) - if use_image_plane_projection: - assert view_normal_common is not None - parent_rotation = Rotation.from_matrix( - _pose_matrix(record["parent_pose_common"])[:3, :3] - ) - view_normal_parent = parent_rotation.inv().apply( - view_normal_common - ) - view_normal_parent /= np.linalg.norm(view_normal_parent) - image_projection = np.eye(3) - np.outer( - view_normal_parent, view_normal_parent - ) - projection = image_projection @ axis_projection - # A single end-on camera cannot distinguish absolute depth from a - # shift along the revolute axis. Keep the observable image-plane - # equations only; adding a free baseline-depth variable makes the - # axis point rank-deficient and pretends that this gauge is - # observable. Parallel phase chains are later compared in the - # same view, where their common depth gauge cancels. - used_image_plane_projection = True - matrices.append(projection @ axis_point_matrix) - translations.append(projection @ delta_translation) - - if len(matrices) < 6: - raise ValueError("axis-point fit has too few observable pose samples") - matrix = np.vstack(matrices) - translation = np.concatenate(translations) - singular_values = np.linalg.svd(matrix, compute_uv=False) - if ( - singular_values.size < 2 - or singular_values[-1] <= 1.0e-9 - or singular_values[-1] / singular_values[0] < 1.0e-3 - ): - raise ValueError("axis-point pose trajectory is ill-conditioned") - - unknown_count = matrix.shape[1] - solution = least_squares( - lambda value: ( - matrix @ value - translation - ) / AXIS_POINT_RESIDUAL_SCALE_M, - np.zeros(unknown_count, dtype=float), - loss="soft_l1", - f_scale=1.0, - max_nfev=1000, - ) - if not solution.success: - raise ValueError( - "axis-point pose optimization failed: " + solution.message - ) - residual = matrix @ solution.x - translation - rms = float(np.sqrt(np.mean(np.square(residual)))) - if residual_diagnostics is not None: - raw_residual = ( - np.asarray(raw_matrices) @ solution.x - np.asarray(raw_translations) - ) - axial = raw_residual @ axis - transverse = raw_residual - axial[:, None] * axis - residual_diagnostics.update( - raw_rms_m=float(np.sqrt(np.mean(raw_residual ** 2))), - axial_rms_m=float(np.sqrt(np.mean(axial ** 2))), - transverse_rms_m=float(np.sqrt(np.mean(transverse ** 2))), - ) - source = ( - "pose_trajectory_image_plane" - if used_image_plane_projection - else "pose_trajectory_3d" - ) - return basis @ solution.x[:2], rms, source - - -def fit_joint_axis_measurement( - joint: str, - records: Sequence[Mapping[str, Any]], - *, - cycle: int, - zero_command_u8: int, - axis_common_constraint: Sequence[float] | None = None, - constrained_circle_joints: frozenset[str] = CONSTRAINED_CIRCLE_JOINTS, - view_normal_common_xyz: Sequence[float] | None = None, - canonical_zero_direction: str | None = None, - separate_axial_residual: bool = False, -) -> JointAxisMeasurement: - """Fit one physical screw axis from one complete scan cycle.""" - samples = [ - dict(record) for record in records if int(record["cycle"]) == int(cycle) - ] - if len(samples) < 12: - raise ValueError(f"{joint} cycle {cycle + 1} has too few samples") - points = np.asarray( - [record["relative_translation_xyz_m"] for record in samples], dtype=float - ) - free_circle_axis, free_plane_rms = _fit_plane_axis([points]) - singular_values = np.linalg.svd( - points - np.mean(points, axis=0), compute_uv=False - ) - circle_axis_observability = ( - 0.0 - if singular_values.size < 2 or singular_values[0] <= 1.0e-12 - else float(singular_values[1] / singular_values[0]) - ) - - reference_samples = _canonical_reference_records( - samples, canonical_zero_direction - ) - reference = Rotation.from_quat( - _baseline_reference(reference_samples, zero_command_u8) - ) - rotation_vectors = [ - (reference.inv() * Rotation.from_quat(_relative_rotation(record))).as_rotvec() - for record in samples - ] - commands = [int(record["command_u8"]) for record in samples] - rotation_axis_child = fit_rotation_axis(rotation_vectors, commands) - # reference maps the child Tag frame at baseline into the parent Tag - # frame. The quaternion delta axis is expressed in that child frame, - # while the fitted centre circle is expressed in the parent frame. This - # conversion is what makes arbitrary Tag mounting rotations harmless. - rotation_axis = reference.apply(rotation_axis_child) - if float(rotation_axis @ free_circle_axis) < 0.0: - free_circle_axis = -free_circle_axis - disagreement = math.acos( - float(np.clip(rotation_axis @ free_circle_axis, -1.0, 1.0)) - ) - - zero_records = _near_zero_records(reference_samples, zero_command_u8) - if not zero_records: - raise ValueError( - f"{joint} cycle has no record near baseline {zero_command_u8}" - ) - parent_poses = np.asarray( - [_pose_matrix(record["parent_pose_common"]) for record in zero_records] - ) - parent_translation = np.median(parent_poses[:, :3, 3], axis=0) - parent_quaternion = robust_rotation_summary( - [ - Rotation.from_matrix(matrix[:3, :3]).as_quat() - for matrix in parent_poses - ] - )[0] - parent_rotation = Rotation.from_quat(parent_quaternion) - axis_direction_source = "rotation" - - if axis_common_constraint is not None: - common_axis = _vector( - axis_common_constraint, 3, name="common axis constraint" - ) - common_axis /= np.linalg.norm(common_axis) - fitted_axis = parent_rotation.inv().apply(common_axis) - if float(fitted_axis @ rotation_axis) < 0.0: - fitted_axis = -fitted_axis - circle = _fit_circle_with_axis(points, fitted_axis) - plane_rms = float(circle["plane_rms_m"]) - axis_direction_source = "upstream_constraint" - elif str(joint) in constrained_circle_joints: - # The side/front views are close to end-on for these axes. Monocular - # planar-PnP depth bias can therefore tilt even an apparently smooth, - # low-residual Tag-centre circle. The relative orientation trajectory - # observes the screw-axis direction directly and is invariant to an - # arbitrary fixed Tag mounting rotation; use it to constrain the 3-D - # circle and estimate only the axis line. Never switch the direction - # source based on a rotation-vs-circle threshold: that discontinuity - # makes otherwise identical repeat cycles choose different models. - fitted_axis = rotation_axis - circle = _fit_circle_with_axis(points, fitted_axis) - plane_rms = float(circle["plane_rms_m"]) - else: - # Oblique trajectories with an observable 3-D motion plane retain the - # independent rotation/centre cross-check and fuse both estimates. - free_circle = _fit_circle_with_axis(points, free_circle_axis) - fitted_axis = rotation_axis + free_circle_axis - if float(np.linalg.norm(fitted_axis)) < 1.0e-9: - raise ValueError(f"{joint} rotation and centre axes are opposed") - fitted_axis /= np.linalg.norm(fitted_axis) - circle = free_circle - plane_rms = max( - float(free_plane_rms), float(circle["plane_rms_m"]) - ) - axis_direction_source = "rotation_circle_fusion" - axis_common = parent_rotation.apply(fitted_axis) - residual_diagnostics: dict[str, float] = {} - separate_axial = separate_axial_residual or str(joint).endswith("_side") - point_parent, pose_axis_line_rms_m, axis_point_source = ( - _fit_axis_point_from_pose_trajectory( - samples, - zero_command_u8=zero_command_u8, - axis_parent_xyz=fitted_axis, - angle_axis_parent_xyz=rotation_axis, - phase_reference_point_parent_xyz=circle["center_xyz_m"], - view_normal_common_xyz=view_normal_common_xyz, - canonical_zero_direction=canonical_zero_direction, - allow_axial_translation=separate_axial, - residual_diagnostics=residual_diagnostics, - ) - ) - point_common = parent_rotation.apply(point_parent) + parent_translation - state = np.median( - np.asarray([record["state_u8"] for record in zero_records], dtype=float), - axis=0, - ) - return JointAxisMeasurement( - joint=str(joint), - cycle=int(cycle), - axis_common_xyz=tuple(float(value) for value in axis_common), - point_common_xyz_m=tuple(float(value) for value in point_common), - condition_state_u8=tuple(float(value) for value in state), - plane_rms_m=float(plane_rms), - radial_rms_m=float(circle["radial_rms_m"]), - rotation_circle_axis_difference_rad=float(disagreement), - view_normal_common_xyz=( - None - if view_normal_common_xyz is None - else tuple( - float(value) - for value in _vector( - view_normal_common_xyz, 3, name="view normal" - ) - ) - ), - axis_direction_source=axis_direction_source, - circle_axis_observability=float(circle_axis_observability), - axis_point_source=axis_point_source, - pose_axis_line_rms_m=pose_axis_line_rms_m, - pose_axis_line_raw_rms_m=residual_diagnostics["raw_rms_m"], - pose_axis_line_axial_rms_m=residual_diagnostics["axial_rms_m"], - pose_axis_line_transverse_rms_m=residual_diagnostics["transverse_rms_m"], - axis_point_axial_component_separated=separate_axial, - pose_axis_line_source_joints=(str(joint),), - ) - - -@dataclass(frozen=True) -class _UrdfJoint: - name: str - parent: str - child: str - origin: np.ndarray - axis: np.ndarray - mimic_joint: str | None - mimic_multiplier: float - mimic_offset: float - - -class UrdfKinematicModel: - def __init__(self, source: str | Path) -> None: - self.source = Path(source).expanduser().resolve() - if not self.source.is_file(): - raise ValueError(f"source URDF does not exist: {self.source}") - root = ET.parse(self.source).getroot() - self.joints: dict[str, _UrdfJoint] = {} - self.parent_joint_by_child: dict[str, str] = {} - for element in root.findall("joint"): - if element.get("type") == "fixed": - pass - name = str(element.get("name")) - parent = element.find("parent") - child = element.find("child") - if parent is None or child is None: - continue - origin_node = element.find("origin") - xyz = _parse_triplet( - "0 0 0" if origin_node is None else origin_node.get("xyz", "0 0 0") - ) - rpy = _parse_triplet( - "0 0 0" if origin_node is None else origin_node.get("rpy", "0 0 0") - ) - origin = np.eye(4) - origin[:3, :3] = Rotation.from_euler("xyz", rpy).as_matrix() - origin[:3, 3] = xyz - axis_node = element.find("axis") - axis = _parse_triplet( - "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") - ) - axis /= np.linalg.norm(axis) - mimic = element.find("mimic") - model = _UrdfJoint( - name=name, - parent=str(parent.get("link")), - child=str(child.get("link")), - origin=origin, - axis=axis, - mimic_joint=None if mimic is None else str(mimic.get("joint")), - mimic_multiplier=( - 1.0 if mimic is None else float(mimic.get("multiplier", "1")) - ), - mimic_offset=( - 0.0 if mimic is None else float(mimic.get("offset", "0")) - ), - ) - self.joints[name] = model - self.parent_joint_by_child[model.child] = name - - def _chain(self, target_joint: str) -> list[_UrdfJoint]: - if target_joint not in self.joints: - raise ValueError(f"URDF is missing joint {target_joint}") - result: list[_UrdfJoint] = [] - current = self.joints[target_joint] - while True: - result.append(current) - parent_joint = self.parent_joint_by_child.get(current.parent) - if parent_joint is None: - break - current = self.joints[parent_joint] - result.reverse() - return result - - def link_transform( - self, - target_joint: str, - *, - zero_offsets: Mapping[str, float], - joint_angles: Mapping[str, float], - independent_mimic_angles: bool = False, - ) -> np.ndarray: - """Return base-to-child-link pose for calibration validation. - - ``independent_mimic_angles`` lets the product validator use the five - independently measured passive JSON curves while the emitted URDF's - original mimic XML remains untouched. - """ - transform = np.eye(4) - resolved = {str(name): float(value) for name, value in joint_angles.items()} - for joint in self._chain(target_joint): - transform = transform @ joint.origin @ _axis_rotation( - joint.axis, float(zero_offsets.get(joint.name, 0.0)) - ) - if independent_mimic_angles and joint.name in resolved: - angle = resolved[joint.name] - elif joint.mimic_joint is None: - angle = float(resolved.get(joint.name, 0.0)) - else: - angle = ( - joint.mimic_multiplier - * float(resolved.get(joint.mimic_joint, 0.0)) - + joint.mimic_offset - ) - resolved[joint.name] = angle - transform = transform @ _axis_rotation(joint.axis, angle) - return transform - - def axis_line( - self, - target_joint: str, - *, - zero_offsets: Mapping[str, float], - joint_angles: Mapping[str, float], - ) -> tuple[np.ndarray, np.ndarray]: - transform = np.eye(4) - resolved_angles = dict(joint_angles) - for joint in self._chain(target_joint): - corrected_origin = joint.origin @ _axis_rotation( - joint.axis, float(zero_offsets.get(joint.name, 0.0)) - ) - joint_frame = transform @ corrected_origin - if joint.name == target_joint: - return ( - joint_frame[:3, :3] @ joint.axis, - joint_frame[:3, 3].copy(), - ) - if joint.mimic_joint is None: - angle = float(resolved_angles.get(joint.name, 0.0)) - else: - angle = ( - joint.mimic_multiplier - * float(resolved_angles.get(joint.mimic_joint, 0.0)) - + joint.mimic_offset - ) - resolved_angles[joint.name] = angle - transform = joint_frame @ _axis_rotation(joint.axis, angle) - raise RuntimeError(f"could not resolve axis line for {target_joint}") - - -def _parse_triplet(value: str) -> np.ndarray: - result = np.asarray([float(item) for item in str(value).split()], dtype=float) - if result.shape != (3,) or not np.all(np.isfinite(result)): - raise ValueError(f"invalid URDF vector: {value}") - return result - - -def _axis_rotation(axis: Sequence[float], angle: float) -> np.ndarray: - result = np.eye(4) - result[:3, :3] = Rotation.from_rotvec( - _vector(axis, 3, name="joint axis") * float(angle) - ).as_matrix() - return result - - -@dataclass(frozen=True) -class ZeroSolveResult: - direct_offsets_rad: Mapping[str, float] - all_active_offsets_rad: Mapping[str, float] - base_translation_xyz_m: tuple[float, float, float] - base_quaternion_xyzw: tuple[float, float, float, float] - validation_errors_rad: tuple[float, ...] - validation_error_by_joint_rad: Mapping[str, float] - validation_line_error_by_joint_m: Mapping[str, float] - axis_line_rms_m: float - passed: bool - cycle_offsets_rad: Mapping[str, tuple[float, ...]] - offset_uncertainty_rad: Mapping[str, float] - offset_confidence_half_width_rad: Mapping[str, float] - training_cycles: tuple[int, ...] - validation_cycle: int - validation_original_error_by_joint_rad: Mapping[str, float] - validation_improvement_by_joint_rad: Mapping[str, float] - validation_improvement_confidence_lower_rad: Mapping[str, float] - observability_rank: int - observability_parameter_count: int - observability_condition_number: float - offset_covariance_rad2: Mapping[str, float] - axis_cone_mismatch_by_joint_rad: Mapping[str, float] - axis_cone_bias_classification_by_joint: Mapping[str, str] - failure_reasons: Mapping[str, str] - # Optional diagnostic evidence from an explicitly selected observation - # policy; does not change the generic solver's acceptance decision. - axis_residual_diagnostics: Mapping[str, Mapping[str, Any]] | None = None - - -def merge_right_19_thumb_zero_result( - *, - thumb_result: ZeroSolveResult, - preserved_offsets_rad: Mapping[str, float] | None = None, - companion_result: ZeroSolveResult | None = None, -) -> ZeroSolveResult: - """Merge an independent thumb solve without allowing reverse coupling. - - ``companion_result`` is the finger/full solver result whose thumb values - were fixed to ``thumb_result``. For a thumb-only run, the 12 certified - finger values are supplied through ``preserved_offsets_rad`` instead. - """ - thumb_names = { - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - } - if set(thumb_result.direct_offsets_rad) != thumb_names: - raise ValueError("independent thumb result must contain exactly four zeros") - full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) - direct_names = set(full_profile.direct_zero_joints) - preserved = { - str(name): float(value) - for name, value in (preserved_offsets_rad or {}).items() - } - if set(preserved) - (direct_names - thumb_names): - raise ValueError("preserved offsets contain a thumb or unknown joint") - if any(not math.isfinite(value) for value in preserved.values()): - raise ValueError("preserved offsets must be finite") - - if companion_result is None: - if set(preserved) != direct_names - thumb_names: - raise ValueError("thumb-only merge requires all 12 certified finger zeros") - direct = dict(preserved) - all_active = {name: 0.0 for name in full_profile.hand.active_joints} - all_active.update(preserved) - base_result = thumb_result - else: - if set(companion_result.direct_offsets_rad) != direct_names: - raise ValueError("full companion result must contain all 16 zeros") - direct = dict(companion_result.direct_offsets_rad) - all_active = dict(companion_result.all_active_offsets_rad) - base_result = companion_result - direct.update(thumb_result.direct_offsets_rad) - all_active.update(thumb_result.direct_offsets_rad) - - def merged_map(name: str) -> dict[str, Any]: - values = ( - {} - if companion_result is None - else dict(getattr(companion_result, name)) - ) - values.update(dict(getattr(thumb_result, name))) - return values - - failure_reasons = merged_map("failure_reasons") - passed = bool( - thumb_result.passed - and (companion_result is None or companion_result.passed) - ) - return replace( - base_result, - direct_offsets_rad=direct, - all_active_offsets_rad=all_active, - validation_errors_rad=( - tuple(thumb_result.validation_errors_rad) - if companion_result is None - else ( - *companion_result.validation_errors_rad, - *thumb_result.validation_errors_rad, - ) - ), - validation_error_by_joint_rad=merged_map( - "validation_error_by_joint_rad" - ), - validation_line_error_by_joint_m=merged_map( - "validation_line_error_by_joint_m" - ), - axis_line_rms_m=( - thumb_result.axis_line_rms_m - if companion_result is None - else max( - companion_result.axis_line_rms_m, - thumb_result.axis_line_rms_m, - ) - ), - passed=passed, - cycle_offsets_rad=merged_map("cycle_offsets_rad"), - offset_uncertainty_rad=merged_map("offset_uncertainty_rad"), - offset_confidence_half_width_rad=merged_map( - "offset_confidence_half_width_rad" - ), - validation_original_error_by_joint_rad=merged_map( - "validation_original_error_by_joint_rad" - ), - validation_improvement_by_joint_rad=merged_map( - "validation_improvement_by_joint_rad" - ), - validation_improvement_confidence_lower_rad=merged_map( - "validation_improvement_confidence_lower_rad" - ), - offset_covariance_rad2=merged_map("offset_covariance_rad2"), - axis_cone_mismatch_by_joint_rad=merged_map( - "axis_cone_mismatch_by_joint_rad" - ), - axis_cone_bias_classification_by_joint=merged_map( - "axis_cone_bias_classification_by_joint" - ), - failure_reasons=failure_reasons, - ) - - -def expand_right_19_thumb_zero_result_with_cad_fingers( - thumb_result: ZeroSolveResult, -) -> ZeroSolveResult: - """Build a standalone thumb result with every finger left at source CAD. - - This is intentionally different from inheriting a prior finger - calibration: the twelve non-thumb values are explicit zeros and no finger - observation or artifact is required. - """ - full_profile = get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT) - thumb_names = { - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - } - if set(thumb_result.direct_offsets_rad) != thumb_names: - raise ValueError("independent thumb result must contain exactly four zeros") - direct = {name: 0.0 for name in full_profile.direct_zero_joints} - direct.update(thumb_result.direct_offsets_rad) - all_active = {name: 0.0 for name in full_profile.hand.active_joints} - all_active.update(thumb_result.direct_offsets_rad) - return replace( - thumb_result, - direct_offsets_rad=direct, - all_active_offsets_rad=all_active, - ) - - -def _angles_from_state( - state_u8: Sequence[float], - *, - curves: Mapping[str, JointCurveFit], - motor_by_joint: Mapping[str, int], - inherited_zero_joints: Mapping[str, str] = INHERITED_ZERO_JOINTS, -) -> dict[str, float]: - state = np.asarray(state_u8, dtype=float) - result: dict[str, float] = {} - for joint, motor in motor_by_joint.items(): - source = inherited_zero_joints.get(joint, joint) - if source not in curves: - continue - command = int(np.clip(np.rint(state[int(motor)]), 0, 255)) - result[joint] = float(curves[source].angle_rad[command]) - return result - - -def solve_urdf_zero_offsets( - *, - source_urdf: str | Path, - measurements: Sequence[JointAxisMeasurement], - palm_orientation_measurements: Sequence[ - PalmOrientationMeasurement - ] = (), - curves: Mapping[str, JointCurveFit], - motor_by_joint: Mapping[str, int], - training_cycles: Sequence[int] = (0, 1), - validation_cycle: int = 2, - maximum_offset_rad: float = math.radians(20.0), - finger_maximum_offset_rad: float = math.radians(3.0), - joint_maximum_offset_rad: Mapping[str, float] | None = None, - maximum_cycle_difference_rad: float = math.radians(0.75), - minimum_applied_offset_rad: float = math.radians(0.3), - significance_sigma: float = 3.0, - maximum_validation_mae_rad: float = math.radians(1.0), - maximum_validation_p95_rad: float = math.radians(2.0), - maximum_validation_error_rad: float | None = None, - maximum_confidence_half_width_rad: float | None = None, - maximum_axis_cone_mismatch_rad: float = math.radians(5.0), - maximum_systematic_axis_cone_bias_rad: float | None = None, - maximum_pose_axis_line_rms_m: float = 0.001, - maximum_observability_condition_number: float = 1.0e10, - hand_type: str = "left", - tag_layout: str = "legacy_11", - fixed_direct_zero_offsets_rad: Mapping[str, float] | None = None, - static_output_zero_offsets_rad: Mapping[str, float] | None = None, - zero_profile: ZeroCalibrationProfile | None = None, -) -> ZeroSolveResult: - """Solve all directly observable zero offsets without fitting CAD errors. - - The product palm pose uses the robust common root-axis direction together - with independent partial-sweep MCP-pitch directions; root-line points - determine translation only. Legacy layouts retain their single observed - reference chain. Encoder zeros are then fitted from one angular residual - per joint. This prevents link length, along-axis Tag placement and - monocular depth bias from becoming encoder-zero corrections. - """ - profile = ( - get_zero_calibration_profile(hand_type, tag_layout) - if zero_profile is None - else zero_profile - ) - if profile.hand.side != str(hand_type).lower(): - raise ValueError("zero profile hand side does not match hand_type") - if profile.hand.layout_id != str(tag_layout): - raise ValueError("zero profile layout does not match tag_layout") - model = UrdfKinematicModel(source_urdf) - training = [m for m in measurements if m.cycle in set(training_cycles)] - validation = [m for m in measurements if m.cycle == int(validation_cycle)] - expected = set(profile.axis_joints) - if ( - {m.joint for m in training} != expected - or {m.joint for m in validation} != expected - ): - raise ValueError("axis measurements do not contain all required joints/cycles") - configured_palm_sources = { - str(name): str(model_joint) - for name, model_joint in ( - profile.hand.palm_orientation_sources or {} - ).items() - } - orientation_measurements = tuple(palm_orientation_measurements) - if configured_palm_sources: - minimum_orientation_sources = int( - profile.hand.minimum_palm_orientation_sources - ) - unknown_orientation_sources = sorted( - { - item.source_joint - for item in orientation_measurements - if item.source_joint not in configured_palm_sources - or configured_palm_sources[item.source_joint] - != item.model_joint - } - ) - if unknown_orientation_sources: - raise ValueError( - "palm orientation measurements do not match the product " - "profile: " - + ",".join(unknown_orientation_sources) - ) - required_orientation_cycles = { - *(int(value) for value in training_cycles), - int(validation_cycle), - } - for cycle in required_orientation_cycles: - cycle_sources = { - item.source_joint - for item in orientation_measurements - if int(item.cycle) == cycle - } - if len(cycle_sources) < minimum_orientation_sources: - raise ValueError( - f"palm orientation cycle {cycle + 1} contains " - f"{len(cycle_sources)}/{minimum_orientation_sources} " - "qualified sources" - ) - elif orientation_measurements: - raise ValueError( - "palm orientation measurements were supplied without a profile" - ) - orientation_training = tuple( - item - for item in orientation_measurements - if int(item.cycle) in set(int(value) for value in training_cycles) - ) - orientation_validation = tuple( - item - for item in orientation_measurements - if int(item.cycle) == int(validation_cycle) - ) - orientation_by_model_cycle: dict[ - tuple[str, int], PalmOrientationMeasurement - ] = {} - for item in orientation_measurements: - key = (str(item.model_joint), int(item.cycle)) - if key in orientation_by_model_cycle: - raise ValueError( - "palm orientation has multiple sources for " - f"{key[0]} cycle {key[1] + 1}" - ) - orientation_by_model_cycle[key] = item - if not 0.0 < finger_maximum_offset_rad <= maximum_offset_rad: - raise ValueError("finger maximum offset must be positive and no larger than thumb") - joint_limits = { - str(name): float(value) - for name, value in dict(joint_maximum_offset_rad or {}).items() - } - unknown_joint_limits = sorted(set(joint_limits) - set(profile.direct_zero_joints)) - if unknown_joint_limits: - raise ValueError( - "joint maximum offsets contain unknown direct joints: " - + ",".join(unknown_joint_limits) - ) - if any( - not math.isfinite(value) or not 0.0 < value <= math.radians(90.0) - for value in joint_limits.values() - ): - raise ValueError("joint maximum offsets must be finite and in (0, 90deg]") - if maximum_cycle_difference_rad <= 0.0: - raise ValueError("maximum cycle difference must be positive") - if minimum_applied_offset_rad < 0.0 or significance_sigma < 0.0: - raise ValueError("zero significance thresholds must be non-negative") - if maximum_axis_cone_mismatch_rad <= 0.0: - raise ValueError("maximum axis cone mismatch must be positive") - if ( - maximum_systematic_axis_cone_bias_rad is not None - and ( - maximum_systematic_axis_cone_bias_rad - <= maximum_axis_cone_mismatch_rad - or maximum_systematic_axis_cone_bias_rad > math.radians(90.0) - ) - ): - raise ValueError( - "maximum systematic axis cone bias must exceed the precision " - "limit and be at most 90deg" - ) - if maximum_pose_axis_line_rms_m <= 0.0: - raise ValueError("maximum pose axis-line RMS must be positive") - if maximum_observability_condition_number <= 1.0: - raise ValueError( - "maximum observability condition number must be greater than one" - ) - if ( - maximum_validation_error_rad is not None - and maximum_validation_error_rad <= 0.0 - ): - raise ValueError("maximum validation error must be positive") - if ( - maximum_confidence_half_width_rad is not None - and maximum_confidence_half_width_rad <= 0.0 - ): - raise ValueError("maximum confidence half width must be positive") - - def predicted_local( - measurement: JointAxisMeasurement, - offsets: Mapping[str, float], - ) -> tuple[np.ndarray, np.ndarray]: - angles = _angles_from_state( - ( - measurement.condition_state_u8 - if measurement.condition_command_u8 is None - else measurement.condition_command_u8 - ), - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=profile.inherited_zero_joints, - ) - return model.axis_line( - measurement.joint, - zero_offsets=offsets, - joint_angles=angles, - ) - - def predicted_palm_orientation_local( - measurement: PalmOrientationMeasurement, - offsets: Mapping[str, float], - ) -> np.ndarray: - angles = _angles_from_state( - measurement.condition_state_u8, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=profile.inherited_zero_joints, - ) - axis, _ = model.axis_line( - measurement.model_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - return axis / np.linalg.norm(axis) - - fixed_offsets = { - str(name): float(value) - for name, value in ( - profile.fixed_direct_zero_offsets_rad - if fixed_direct_zero_offsets_rad is None - else fixed_direct_zero_offsets_rad - ).items() - } - unknown_fixed_offsets = sorted( - set(fixed_offsets) - set(profile.direct_zero_joints) - ) - if unknown_fixed_offsets: - raise ValueError( - "fixed direct zero offsets contain unknown joints: " - + ",".join(unknown_fixed_offsets) - ) - if any( - not math.isfinite(value) or abs(value) > math.radians(90.0) - for value in fixed_offsets.values() - ): - raise ValueError( - "fixed direct zero offsets must be finite and within +/-90deg" - ) - output_offsets = { - str(name): float(value) - for name, value in ( - profile.static_output_zero_offsets_rad - if static_output_zero_offsets_rad is None - else static_output_zero_offsets_rad - ).items() - } - unknown_output_offsets = sorted( - set(output_offsets) - set(profile.direct_zero_joints) - ) - if unknown_output_offsets: - raise ValueError( - "static output zero offsets contain unknown joints: " - + ",".join(unknown_output_offsets) - ) - if any( - not math.isfinite(value) or abs(value) > math.radians(90.0) - for value in output_offsets.values() - ): - raise ValueError( - "static output zero offsets must be finite and within +/-90deg" - ) - zero_offsets = {name: 0.0 for name in profile.direct_zero_joints} - zero_offsets.update(fixed_offsets) - def fit_base_pose( - selected: Sequence[JointAxisMeasurement], - pose_offsets: Mapping[str, float] | None = None, - ) -> tuple[Rotation, np.ndarray]: - pose_zero_offsets = zero_offsets if pose_offsets is None else pose_offsets - anchors_by_joint = { - name: [item for item in selected if item.joint == name] - for name in profile.root_anchor_joints - } - if any(not items for items in anchors_by_joint.values()): - raise ValueError("zero solve is missing root axis anchors") - - def undirected_axis_average( - values: Sequence[Sequence[float]], - ) -> np.ndarray: - vectors = np.asarray(values, dtype=float) - reference = vectors[0] / np.linalg.norm(vectors[0]) - signs = np.where(vectors @ reference < 0.0, -1.0, 1.0) - result = np.sum(vectors * signs[:, None], axis=0) - norm = float(np.linalg.norm(result)) - if norm < 1.0e-9: - raise ValueError("root axis average is degenerate") - return result / norm - - root_names = tuple(sorted(profile.root_anchor_joints)) - predicted_axes: dict[str, np.ndarray] = {} - predicted_points: dict[str, np.ndarray] = {} - observed_axes: dict[str, np.ndarray] = {} - observed_points: dict[str, np.ndarray] = {} - for name in root_names: - items = anchors_by_joint[name] - predicted = [predicted_local(item, pose_zero_offsets) for item in items] - predicted_axes[name] = undirected_axis_average( - [axis for axis, _ in predicted] - ) - predicted_points[name] = np.median( - np.asarray([point for _, point in predicted]), axis=0 - ) - observed_axes[name] = undirected_axis_average( - [item.axis_common_xyz for item in items] - ) - observed_points[name] = np.median( - np.asarray( - [item.point_common_xyz_m for item in items], dtype=float - ), - axis=0, - ) - - if ( - profile.hand.layout_id == G20_RIGHT_19_LAYOUT - and profile.base_pose_strategy == "full_hand" - ): - # In the product layout none of the four finger-roll zeros is a - # mechanical prior. Using one finger's pitch axis to orient the - # palm would therefore absorb that finger's roll zero into the - # palm pose. The named positions of five parallel root lines do - # observe rotation about their common direction, independent of - # all five root-joint zeros. Fit that transverse line pattern and - # leave the unobservable translation along the common direction - # at zero; all zero-sensitive phase residuals use line-to-line - # differences and are invariant to that gauge. - predicted_common = undirected_axis_average( - [predicted_axes[name] for name in root_names] - ) - observed_common = undirected_axis_average( - [observed_axes[name] for name in root_names] - ) - predicted_center = np.mean( - np.asarray([predicted_points[name] for name in root_names]), - axis=0, - ) - observed_center = np.mean( - np.asarray([observed_points[name] for name in root_names]), - axis=0, - ) - predicted_pattern = { - name: ( - (delta := predicted_points[name] - predicted_center) - - predicted_common * float(delta @ predicted_common) - ) - for name in root_names - } - observed_pattern = { - name: ( - (delta := observed_points[name] - observed_center) - - observed_common * float(delta @ observed_common) - ) - for name in root_names - } - if max( - np.linalg.norm(value) for value in predicted_pattern.values() - ) < 0.003: - raise ValueError("root axis-line pattern is degenerate") - if max( - np.linalg.norm(value) for value in observed_pattern.values() - ) < 0.003: - raise ValueError("observed root axis-line pattern is degenerate") - - def align_axis(source: np.ndarray, target: np.ndarray) -> Rotation: - source = source / np.linalg.norm(source) - target = target / np.linalg.norm(target) - cross = np.cross(source, target) - cross_norm = float(np.linalg.norm(cross)) - dot = float(np.clip(source @ target, -1.0, 1.0)) - if cross_norm > 1.0e-10: - return Rotation.from_rotvec( - cross / cross_norm * math.atan2(cross_norm, dot) - ) - if dot > 0.0: - return Rotation.identity() - basis = np.eye(3)[int(np.argmin(np.abs(source)))] - axis = np.cross(source, basis) - axis /= np.linalg.norm(axis) - return Rotation.from_rotvec(axis * math.pi) - - candidates: list[tuple[float, Rotation, np.ndarray]] = [] - for sign in (1.0, -1.0): - signed_observed_axis = sign * observed_common - axis_rotation = align_axis( - predicted_common, signed_observed_axis - ) - mapped_pattern = { - name: axis_rotation.apply(predicted_pattern[name]) - for name in root_names - } - cosine = sum( - float(mapped_pattern[name] @ observed_pattern[name]) - for name in root_names - ) - sine = sum( - float( - signed_observed_axis - @ np.cross( - mapped_pattern[name], observed_pattern[name] - ) - ) - for name in root_names - ) - phase_rotation = Rotation.from_rotvec( - signed_observed_axis * math.atan2(sine, cosine) - ) - rotation = phase_rotation * axis_rotation - translation_samples = [] - for name in root_names: - delta = observed_points[name] - rotation.apply( - predicted_points[name] - ) - translation_samples.append( - delta - - signed_observed_axis - * float(delta @ signed_observed_axis) - ) - translation = np.median( - np.asarray(translation_samples), axis=0 - ) - - # Root-axis points recovered from planar Tags occasionally - # contain a large but repeatable depth/line-position outlier. - # A plain Procrustes sum lets one such point rotate the palm - # frame and then makes the same bias look like a common zero - # offset on all four finger-roll joints. Refine only the - # rigid palm pose with a millimetre-scale robust loss. At - # least three mutually consistent named root lines therefore - # determine the transverse pattern while an outlier remains - # visible in the line diagnostics below. - transverse_helper = np.eye(3)[ - int(np.argmin(np.abs(signed_observed_axis))) - ] - transverse_first = np.cross( - signed_observed_axis, transverse_helper - ) - transverse_first /= np.linalg.norm(transverse_first) - transverse_second = np.cross( - signed_observed_axis, transverse_first - ) - - def robust_root_pattern_residual( - value: np.ndarray, - ) -> np.ndarray: - candidate_rotation = ( - Rotation.from_rotvec( - signed_observed_axis * float(value[0]) - ) - * rotation - ) - candidate_translation = ( - translation - + transverse_first * float(value[1]) - + transverse_second * float(value[2]) - ) - residuals: list[float] = [] - for name in root_names: - delta = observed_points[name] - ( - candidate_rotation.apply(predicted_points[name]) - + candidate_translation - ) - residuals.extend( - ( - float(delta @ transverse_first) / 0.001, - float(delta @ transverse_second) / 0.001, - ) - ) - return np.asarray(residuals, dtype=float) - - root_pattern_solution = least_squares( - robust_root_pattern_residual, - np.zeros(3, dtype=float), - bounds=( - np.asarray([-math.pi, -0.25, -0.25]), - np.asarray([math.pi, 0.25, 0.25]), - ), - loss="soft_l1", - f_scale=1.0, - max_nfev=1000, - ) - if not root_pattern_solution.success: - raise ValueError("robust root axis-line pattern fit failed") - rotation = ( - Rotation.from_rotvec( - signed_observed_axis - * float(root_pattern_solution.x[0]) - ) - * rotation - ) - translation = ( - translation - + transverse_first * float(root_pattern_solution.x[1]) - + transverse_second * float(root_pattern_solution.x[2]) - ) - axis_errors: list[float] = [] - for item in selected: - predicted_axis, _ = predicted_local(item, pose_zero_offsets) - observed_axis = np.asarray(item.axis_common_xyz, dtype=float) - axis_errors.append( - math.acos( - abs( - float( - np.clip( - rotation.apply(predicted_axis) - @ observed_axis, - -1.0, - 1.0, - ) - ) - ) - ) - ) - line_errors = [] - for name in root_names: - predicted_point = ( - rotation.apply(predicted_points[name]) + translation - ) - delta = observed_points[name] - predicted_point - line_errors.append( - float( - np.linalg.norm( - delta - - signed_observed_axis - * float(delta @ signed_observed_axis) - ) - ) - ) - score = float(np.mean(np.square(axis_errors))) + float( - np.mean(np.square(np.asarray(line_errors) / 0.01)) - ) - candidates.append((score, rotation, translation)) - _, rotation, translation = min( - candidates, key=lambda item: item[0] - ) - non_root_items = [ - item - for item in selected - if item.joint not in profile.root_anchor_joints - ] - if not non_root_items: - raise ValueError( - "non-parallel axes are required for palm axial translation" - ) - - def axial_translation_residual(value: np.ndarray) -> np.ndarray: - candidate_translation = ( - translation + observed_common * float(value[0]) - ) - residuals: list[float] = [] - for item in non_root_items: - predicted_axis, predicted_point = predicted_local( - item, pose_zero_offsets - ) - predicted_axis = rotation.apply(predicted_axis) - predicted_point = ( - rotation.apply(predicted_point) - + candidate_translation - ) - observed_axis = np.asarray( - item.axis_common_xyz, dtype=float - ) - if float(predicted_axis @ observed_axis) < 0.0: - observed_axis = -observed_axis - delta = np.asarray( - item.point_common_xyz_m, dtype=float - ) - predicted_point - perpendicular = delta - observed_axis * float( - delta @ observed_axis - ) - residuals.extend( - float(component) / 0.001 - for component in perpendicular - ) - return np.asarray(residuals, dtype=float) - - axial_solution = least_squares( - axial_translation_residual, - np.asarray([0.0]), - bounds=(np.asarray([-1.0]), np.asarray([1.0])), - loss="soft_l1", - f_scale=1.0, - ) - if not axial_solution.success: - raise ValueError("palm axial translation fit failed") - translation = ( - translation - + observed_common * float(axial_solution.x[0]) - ) - return rotation, translation - - # Legacy layouts have only one observed finger chain. Their - # non-parallel reference-finger pitch axis remains the orientation - # anchor; the product profile above deliberately does not use it. - primary = max( - root_names, - key=lambda name: float( - np.ptp(np.asarray(curves[name].angle_rad)) - ), - ) - orientation_anchor = profile.orientation_anchor_joint or ( - "thumb_cmc_pitch" - if profile.base_pose_strategy == "thumb_serial" - else f"{profile.reference_finger}_mcp_pitch" - ) - orientation_items = [ - item for item in selected if item.joint == orientation_anchor - ] - if not orientation_items: - raise ValueError( - f"zero solve is missing palm orientation anchor " - f"{orientation_anchor}" - ) - predicted_orientation_axis = undirected_axis_average( - [ - predicted_local(item, pose_zero_offsets)[0] - for item in orientation_items - ] - ) - observed_orientation_axis = undirected_axis_average( - [item.axis_common_xyz for item in orientation_items] - ) - - def frame_from_two_axes( - primary_axis: np.ndarray, orientation_axis: np.ndarray - ) -> np.ndarray: - first = primary_axis / np.linalg.norm(primary_axis) - second = orientation_axis - first * float(orientation_axis @ first) - second_norm = float(np.linalg.norm(second)) - if second_norm < math.sin(ZERO_MINIMUM_AXIS_CONE_RAD): - raise ValueError("palm orientation axes are nearly parallel") - second /= second_norm - return np.column_stack((first, second, np.cross(first, second))) - - predicted_frame = frame_from_two_axes( - predicted_axes[primary], - predicted_orientation_axis, - ) - candidates: list[tuple[float, Rotation, np.ndarray]] = [] - for primary_sign in (1.0, -1.0): - for orientation_sign in (1.0, -1.0): - observed_frame = frame_from_two_axes( - primary_sign * observed_axes[primary], - orientation_sign * observed_orientation_axis, - ) - rotation = Rotation.from_matrix( - observed_frame @ predicted_frame.T - ) - translation = np.median( - np.asarray( - [ - observed_points[name] - - rotation.apply(predicted_points[name]) - for name in root_names - ] - ), - axis=0, - ) - - # Resolve both undirected-axis sign branches using every - # distinct measured joint. This selects a palm-frame branch; - # it does not fit link geometry or encoder offsets. - errors_by_joint: dict[str, list[float]] = {} - for item in selected: - predicted_axis, _ = predicted_local(item, pose_zero_offsets) - predicted_axis = rotation.apply(predicted_axis) - observed_axis = np.asarray(item.axis_common_xyz, dtype=float) - alignment = float( - np.clip(predicted_axis @ observed_axis, -1.0, 1.0) - ) - error = math.acos( - alignment - if item.joint in profile.directed_base_axis_joints - else abs(alignment) - ) - errors_by_joint.setdefault(item.joint, []).append(error) - branch_score = sum( - min(float(np.median(values)), math.radians(30.0)) ** 2 - for values in errors_by_joint.values() - ) - candidates.append((branch_score, rotation, translation)) - _, rotation, translation = min(candidates, key=lambda item: item[0]) - return rotation, translation - - def phase_error( - predicted_parent_axis: np.ndarray, - predicted_parent_point: np.ndarray, - predicted_child_point: np.ndarray, - observed_parent_axis: np.ndarray, - observed_parent_point: np.ndarray, - observed_child_point: np.ndarray, - view_normal_common: Sequence[float] | None = None, - ) -> float: - parent_axis = predicted_parent_axis / np.linalg.norm(predicted_parent_axis) - observed_axis = observed_parent_axis / np.linalg.norm(observed_parent_axis) - if float(parent_axis @ observed_axis) < 0.0: - observed_axis = -observed_axis - predicted_delta = predicted_child_point - predicted_parent_point - observed_delta = observed_child_point - observed_parent_point - predicted_radial = predicted_delta - parent_axis * float( - predicted_delta @ parent_axis - ) - observed_radial = observed_delta - observed_axis * float( - observed_delta @ observed_axis - ) - angle_axis = parent_axis - if view_normal_common is not None: - view_normal = np.asarray(view_normal_common, dtype=float) - normal_norm = float(np.linalg.norm(view_normal)) - if normal_norm > 1.0e-9: - view_normal /= normal_norm - # When looking approximately along the rotation axis, image - # x/y contains the complete radial phase while optical depth - # is both unnecessary and much noisier for a 16 mm planar - # Tag. For an edge-on axis the depth component is genuinely - # needed for observability, so retain the full 3-D residual. - if abs(float(parent_axis @ view_normal)) >= math.cos( - math.radians(45.0) - ): - # Use an image-plane phase for an end-on observation. - # Projection alone removes optical depth, but does NOT - # remove an arbitrary along-axis point coordinate when - # the camera is even slightly oblique to the axis. - # A closest point is defined relative to its parent Tag - # origin, not the physical bearing centre. Different Tag - # mounts therefore choose different along-axis gauges. - # Reusing delta here would reintroduce that arbitrary - # coordinate and turn it into a phase at oblique views. - predicted_image_input = ( - predicted_radial if profile.project_axis_gauge_before_image - else predicted_delta - ) - observed_image_input = ( - observed_radial if profile.project_axis_gauge_before_image - else observed_delta - ) - predicted_radial = predicted_image_input - view_normal * float( - predicted_image_input @ view_normal - ) - observed_radial = observed_image_input - view_normal * float( - observed_image_input @ view_normal - ) - angle_axis = view_normal - if float(angle_axis @ parent_axis) < 0.0: - angle_axis = -angle_axis - predicted_radius = float(np.linalg.norm(predicted_radial)) - observed_radius = float(np.linalg.norm(observed_radial)) - if min(predicted_radius, observed_radius) < 0.003: - raise ValueError("parallel-axis radial phase is not observable") - predicted_radial /= predicted_radius - observed_radial /= observed_radius - return math.atan2( - float(angle_axis @ np.cross(predicted_radial, observed_radial)), - float(np.clip(predicted_radial @ observed_radial, -1.0, 1.0)), - ) - - def robust_circular_location(values: Sequence[float]) -> float: - array = np.asarray(values, dtype=float) - if array.size == 0: - raise ValueError("circular residual set is empty") - centre = math.atan2( - float(np.mean(np.sin(array))), - float(np.mean(np.cos(array))), - ) - centred = np.arctan2(np.sin(array - centre), np.cos(array - centre)) - return float( - math.atan2( - math.sin(centre + float(np.median(centred))), - math.cos(centre + float(np.median(centred))), - ) - ) - - def same_view_axis_pair_errors( - offset_joint: str, - selected: Sequence[JointAxisMeasurement], - offsets: Mapping[str, float], - ) -> tuple[float, ...]: - """Return the camera-frame-invariant residual for a serial-axis pair. - - This observation intentionally has two mirror roots. The optimiser - disambiguates them with the original cross-view estimate, but the - pair residual alone determines the final numerical zero. - """ - pair = profile.same_view_axis_pair_by_offset.get(offset_joint) - if pair is None: - return () - anchor_joint, observer_joint = pair - - def angle(left: np.ndarray, right: np.ndarray) -> float: - cosine = float(left @ right) / float( - np.linalg.norm(left) * np.linalg.norm(right) - ) - return math.acos(abs(float(np.clip(cosine, -1.0, 1.0)))) - - errors: list[float] = [] - for cycle in sorted({int(item.cycle) for item in selected}): - anchor = orientation_by_model_cycle.get((anchor_joint, cycle)) - observer = orientation_by_model_cycle.get( - (observer_joint, cycle) - ) - if anchor is None or observer is None: - return () - angles = _angles_from_state( - observer.condition_state_u8, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=profile.inherited_zero_joints, - ) - predicted_anchor, _ = model.axis_line( - anchor_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - predicted_observer, _ = model.axis_line( - observer_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - observed_anchor = np.asarray( - anchor.axis_common_xyz, dtype=float - ) - observed_observer = np.asarray( - observer.axis_common_xyz, dtype=float - ) - errors.append( - angle(predicted_anchor, predicted_observer) - - angle(observed_anchor, observed_observer) - ) - return tuple(errors) - - def angular_error_samples( - offsets: Mapping[str, float], - selected: Sequence[JointAxisMeasurement], - base_rotation: Rotation, - base_translation: np.ndarray, - ) -> dict[str, tuple[float, ...]]: - by_key = {(item.joint, item.cycle): item for item in selected} - errors: dict[str, list[float]] = { - name: [] for name in profile.direct_zero_joints - } - for offset_joint, observer_joint in profile.offset_observer_joint.items(): - pair_errors = same_view_axis_pair_errors( - offset_joint, selected, offsets - ) - if pair_errors: - errors[offset_joint].extend(pair_errors) - continue - observer_items = [ - item for item in selected if item.joint == observer_joint - ] - if not observer_items: - raise ValueError(f"zero observer is missing: {observer_joint}") - for item in observer_items: - observer_axis, observer_point = predicted_local(item, offsets) - observer_axis = base_rotation.apply(observer_axis) - observer_point = ( - base_rotation.apply(observer_point) + base_translation - ) - observed_axis = np.asarray(item.axis_common_xyz, dtype=float) - if observer_joint in profile.axis_parent_joint: - parent_joint = profile.axis_parent_joint[observer_joint] - angles = _angles_from_state( - ( - item.condition_state_u8 - if item.condition_command_u8 is None - else item.condition_command_u8 - ), - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=profile.inherited_zero_joints, - ) - parent_axis, _ = model.axis_line( - parent_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - parent_axis = base_rotation.apply(parent_axis) - error = _zero_sensitive_axis_error_rad( - observer_axis, observed_axis, parent_axis - ) - else: - parent_joint = profile.phase_parent_joint[observer_joint] - observed_parent = by_key.get((parent_joint, item.cycle)) - if observed_parent is None: - raise ValueError( - f"phase parent is missing: {parent_joint} cycle {item.cycle}" - ) - angles = _angles_from_state( - ( - item.condition_state_u8 - if item.condition_command_u8 is None - else item.condition_command_u8 - ), - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=profile.inherited_zero_joints, - ) - parent_axis, parent_point = model.axis_line( - parent_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - parent_axis = base_rotation.apply(parent_axis) - parent_point = ( - base_rotation.apply(parent_point) + base_translation - ) - error = phase_error( - parent_axis, - parent_point, - observer_point, - np.asarray(observed_parent.axis_common_xyz, dtype=float), - np.asarray(observed_parent.point_common_xyz_m, dtype=float), - np.asarray(item.point_common_xyz_m, dtype=float), - item.view_normal_common_xyz, - ) - errors[offset_joint].append(float(error)) - return { - name: tuple(values) - for name, values in errors.items() - if values - } - - def angular_errors( - offsets: Mapping[str, float], - selected: Sequence[JointAxisMeasurement], - base_rotation: Rotation, - base_translation: np.ndarray, - ) -> dict[str, float]: - return { - name: robust_circular_location(values) - for name, values in angular_error_samples( - offsets, selected, base_rotation, base_translation - ).items() - } - - product_finger_rolls = tuple( - name - for name in profile.direct_zero_joints - if name.endswith("_mcp_roll") and not name.startswith("thumb_") - ) - offset_limits = np.asarray( - [ - joint_limits.get( - name, - ( - finger_maximum_offset_rad - if name.startswith(("index_", "middle_", "ring_", "pinky_")) - else maximum_offset_rad - ), - ) - for name in profile.direct_zero_joints - ], - dtype=float, - ) - # Do not make a configured safety limit the numerical optimizer's bound. - # Otherwise a genuine out-of-range estimate and a modelling failure both - # collapse to exactly +/-20 or +/-3 degrees, which hides the magnitude and - # encourages pointless rescans. Search farther for diagnostics, then keep - # the original configured limits as unchanged pass/fail gates below. - diagnostic_offset_limits = np.minimum( - math.radians(90.0), - np.maximum(3.0 * offset_limits, offset_limits + math.radians(5.0)), - ) - if profile.hand.layout_id == G20_RIGHT_19_LAYOUT: - # The absolute rotation of the fitted palm frame about the four - # parallel roll axes is a shared gauge. Each physical finger zero is - # only its deviation from the four-finger median, but the raw scalar - # solves include that common mode. Give those four diagnostic solves - # enough range for the unchanged global common-mode limit plus the - # unchanged per-finger deviation limit; applying the per-finger bound - # before removing the gauge clips every solve to the same value and - # destroys the observable deviations. - for index, name in enumerate(profile.direct_zero_joints): - if name in product_finger_rolls: - diagnostic_offset_limits[index] = min( - math.radians(90.0), - maximum_offset_rad + offset_limits[index], - ) - - def optimise_offsets( - selected: Sequence[JointAxisMeasurement], - base_rotation: Rotation, - base_translation: np.ndarray, - initial: Mapping[str, float] | None = None, - ) -> dict[str, float]: - # The observer graph is a pair of serial chains. Solve it in that - # dependency order so a bad distal phase cannot pull an already - # observable proximal zero toward a bound. Each scalar residual keeps - # the individual cycle values; robust loss then downweights one noisy - # cycle instead of hiding it in a seven-variable compromise. - result = {name: 0.0 for name in profile.direct_zero_joints} - if initial is not None: - result.update( - { - name: float(initial[name]) - for name in profile.direct_zero_joints - } - ) - result.update(fixed_offsets) - for name, diagnostic_limit, configured_limit in zip( - profile.direct_zero_joints, - diagnostic_offset_limits, - offset_limits, - ): - if name in fixed_offsets: - continue - has_axis_pair = name in profile.same_view_axis_pair_by_offset - limit = float( - configured_limit if has_axis_pair else diagnostic_limit - ) - - def residual(value: np.ndarray) -> np.ndarray: - candidate = dict(result) - candidate[name] = float(value[0]) - samples = angular_error_samples( - candidate, selected, base_rotation, base_translation - )[name] - return np.asarray( - [ - math.atan2(math.sin(item), math.cos(item)) - / math.radians(1.0) - for item in samples - ], - dtype=float, - ) - - starts = [0.0, -0.5 * limit, 0.5 * limit] - if initial is not None: - starts.append(float(initial[name])) - solutions = [ - least_squares( - residual, - np.asarray( - [np.clip(start, -limit + 1.0e-9, limit - 1.0e-9)] - ), - bounds=(np.asarray([-limit]), np.asarray([limit])), - loss="soft_l1", - f_scale=1.0, - max_nfev=1000, - ) - for start in starts - ] - solution = min( - solutions, key=lambda item: float(np.sum(np.square(item.fun))) - ) - if not solution.success: - raise ValueError( - f"URDF zero optimization failed for {name}: " - f"{solution.message}" - ) - result[name] = float(solution.x[0]) - return result - - def solve_selected( - selected: Sequence[JointAxisMeasurement], - initial: Mapping[str, float] | None = None, - ) -> tuple[Rotation, np.ndarray, dict[str, float]]: - rotation, translation = fit_base_pose(selected) - offsets = optimise_offsets( - selected, rotation, translation, initial=initial - ) - if profile.base_pose_strategy == "thumb_serial": - # The first pose estimate only supplies a branch for the invariant - # same-view yaw solve. Refit after yaw is known so that the - # downstream pitch phase is evaluated in the corrected serial - # thumb frame, then freeze that pose for holdout validation. - rotation, translation = fit_base_pose(selected, offsets) - offsets = optimise_offsets( - selected, rotation, translation, initial=offsets - ) - return rotation, translation, offsets - - base_rotation, base_translation, training_offsets = solve_selected(training) - - def observability_residual(parameters: np.ndarray) -> np.ndarray: - rotation = Rotation.from_rotvec(parameters[:3]) - translation = parameters[3:6] * 0.05 - offsets = { - name: float(value) - for name, value in zip( - profile.direct_zero_joints, parameters[6:] - ) - } - residuals: list[float] = [] - for item in training: - predicted_axis, predicted_point = predicted_local(item, offsets) - predicted_axis = rotation.apply(predicted_axis) - predicted_axis /= np.linalg.norm(predicted_axis) - predicted_point = rotation.apply(predicted_point) + translation - observed_axis = np.asarray(item.axis_common_xyz, dtype=float) - observed_axis /= np.linalg.norm(observed_axis) - if float(predicted_axis @ observed_axis) < 0.0: - observed_axis = -observed_axis - observed_point = np.asarray(item.point_common_xyz_m, dtype=float) - predicted_moment = np.cross(predicted_point, predicted_axis) - observed_moment = np.cross(observed_point, observed_axis) - residuals.extend(float(value) for value in predicted_axis - observed_axis) - residuals.extend( - float(value) / 0.05 - for value in predicted_moment - observed_moment - ) - return np.asarray(residuals, dtype=float) - - observability_parameters = np.asarray( - [ - *base_rotation.as_rotvec(), - *(base_translation / 0.05), - *( - training_offsets[name] - for name in profile.direct_zero_joints - ), - ], - dtype=float, - ) - observability_base_residual = observability_residual( - observability_parameters - ) - observability_jacobian = np.empty( - ( - observability_base_residual.size, - observability_parameters.size, - ), - dtype=float, - ) - finite_difference_step = 1.0e-6 - for column in range(observability_parameters.size): - positive = observability_parameters.copy() - negative = observability_parameters.copy() - positive[column] += finite_difference_step - negative[column] -= finite_difference_step - observability_jacobian[:, column] = ( - observability_residual(positive) - - observability_residual(negative) - ) / (2.0 * finite_difference_step) - singular_values = np.linalg.svd( - observability_jacobian, compute_uv=False - ) - singular_threshold = ( - 0.0 - if singular_values.size == 0 - else float(singular_values[0]) * 1.0e-7 - ) - observability_rank = int( - np.count_nonzero(singular_values > singular_threshold) - ) - observability_parameter_count = int(observability_parameters.size) - observability_condition_number = ( - float("inf") - if observability_rank < observability_parameter_count - else float(singular_values[0] / singular_values[-1]) - ) - residual_dof = max( - 1, - observability_base_residual.size - observability_parameter_count, - ) - residual_variance = float( - observability_base_residual @ observability_base_residual - ) / residual_dof - covariance = residual_variance * np.linalg.pinv( - observability_jacobian.T @ observability_jacobian, - rcond=1.0e-12, - ) - offset_covariance = { - name: max(0.0, float(covariance[6 + index, 6 + index])) - for index, name in enumerate(profile.direct_zero_joints) - } - - axis_cone_mismatch_by_joint: dict[str, float] = {} - axis_cone_bias_classification_by_joint: dict[str, str] = {} - - def zero_observation_failure_reasons( - selected: Sequence[JointAxisMeasurement], - offsets: Mapping[str, float], - rotation: Rotation, - ) -> dict[str, str]: - """Reject repeatable but geometrically inadmissible zero observers.""" - by_key = {(item.joint, item.cycle): item for item in selected} - failures: dict[str, str] = {} - for offset_joint, observer_joint in profile.offset_observer_joint.items(): - if offset_joint in fixed_offsets: - continue - observer_items = [ - item for item in selected if item.joint == observer_joint - ] - if observer_joint in profile.axis_parent_joint: - parent_joint = profile.axis_parent_joint[observer_joint] - cone_mismatches: list[float] = [] - for item in observer_items: - state = ( - item.condition_state_u8 - if item.condition_command_u8 is None - else item.condition_command_u8 - ) - angles = _angles_from_state( - state, - curves=curves, - motor_by_joint=motor_by_joint, - inherited_zero_joints=( - profile.inherited_zero_joints - ), - ) - predicted_axis, _ = model.axis_line( - observer_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - parent_axis, _ = model.axis_line( - parent_joint, - zero_offsets=offsets, - joint_angles=angles, - ) - cone_mismatches.append( - _axis_cone_mismatch_rad( - rotation.apply(predicted_axis), - item.axis_common_xyz, - rotation.apply(parent_axis), - ) - ) - maximum_cone_mismatch = max(cone_mismatches) - axis_cone_mismatch_by_joint[offset_joint] = ( - maximum_cone_mismatch - ) - if maximum_cone_mismatch > maximum_axis_cone_mismatch_rad: - cone_range = float(np.ptp(cone_mismatches)) - stable_product_bias = bool( - profile.hand.stable_cross_view_cone_bias - and maximum_systematic_axis_cone_bias_rad is not None - and len(cone_mismatches) >= 4 - and maximum_cone_mismatch - <= maximum_systematic_axis_cone_bias_rad - and cone_range <= maximum_cycle_difference_rad - ) - if stable_product_bias: - # A parent zero rotates the downstream direction - # around the parent axis and cannot change their cone - # angle. The zero-sensitive residual above already - # projects both directions onto the parent-normal - # plane, so a repeatable cross-camera/planar-PnP cone - # bias cannot corrupt the written encoder zero. Keep - # it visible in the result while retaining the gross - # gate for a wrong axis, loose Tag, or moved camera. - axis_cone_bias_classification_by_joint[offset_joint] = ( - "stable_cross_view_or_planar_pnp_bias" - ) - else: - failures[offset_joint] = ( - "zero_axis_cone_mismatch_too_large" - ) - else: - parent_joint = profile.phase_parent_joint[observer_joint] - phase_items: list[JointAxisMeasurement] = [] - propagated_angle_uncertainties: list[float] = [] - for item in observer_items: - phase_items.append(item) - parent_item = by_key.get((parent_joint, item.cycle)) - if parent_item is not None: - phase_items.append(parent_item) - parent_axis = np.asarray( - parent_item.axis_common_xyz, dtype=float - ) - parent_axis /= np.linalg.norm(parent_axis) - separation = ( - np.asarray(item.point_common_xyz_m, dtype=float) - - np.asarray( - parent_item.point_common_xyz_m, dtype=float - ) - ) - radial = separation - parent_axis * float( - separation @ parent_axis - ) - effective_distance = float(np.linalg.norm(radial)) - if effective_distance <= 1.0e-6: - propagated_angle_uncertainties.append(float("inf")) - else: - line_uncertainty = math.hypot( - item.pose_axis_line_rms_m, - parent_item.pose_axis_line_rms_m, - ) - propagated_angle_uncertainties.append( - math.atan2( - line_uncertainty, effective_distance - ) - ) - if profile.hand.layout_id == G20_RIGHT_19_LAYOUT: - angle_limit = ( - maximum_confidence_half_width_rad - if maximum_confidence_half_width_rad is not None - else math.radians(0.5) - ) - if ( - not propagated_angle_uncertainties - or max(propagated_angle_uncertainties) > angle_limit - ): - failures[offset_joint] = ( - "zero_phase_axis_line_angle_uncertainty_too_large" - ) - elif ( - profile.hand.layout_id != G20_RIGHT_19_LAYOUT - and any( - item.pose_axis_line_rms_m - > maximum_pose_axis_line_rms_m - for item in phase_items - ) - ): - failures[offset_joint] = ( - "zero_phase_axis_line_residual_too_large" - ) - return failures - - observation_failures = zero_observation_failure_reasons( - list(measurements), training_offsets, base_rotation - ) - cycle_values: dict[str, list[float]] = { - name: [] for name in profile.direct_zero_joints - } - cycle_models: dict[int, tuple[Rotation, np.ndarray]] = {} - training_cycle_ids = tuple(sorted({int(value) for value in training_cycles})) - for cycle in training_cycle_ids: - selected = [item for item in measurements if item.cycle == cycle] - # Keep one palm pose while comparing cycles. Refitting a base pose from - # only two nearly parallel root axes per cycle makes harmless root-line - # noise appear as a large encoder-zero change. - cycle_rotation, cycle_translation = base_rotation, base_translation - cycle_offsets = optimise_offsets( - selected, - cycle_rotation, - cycle_translation, - initial=training_offsets, - ) - cycle_models[cycle] = (cycle_rotation, cycle_translation) - for name, value in cycle_offsets.items(): - cycle_values[name].append(value) - uncertainties: dict[str, float] = {} - confidence_half_widths: dict[str, float] = {} - cycle_consistent = True - inconsistent_cycles: list[str] = [] - for name, values in cycle_values.items(): - array = np.asarray(values, dtype=float) - spread = float(np.max(array) - np.min(array)) - if spread > maximum_cycle_difference_rad: - cycle_consistent = False - inconsistent_cycles.append(name) - uncertainties[name] = ( - 0.0 - if array.size < 2 - else float(np.std(array, ddof=1) / math.sqrt(array.size)) - ) - confidence_half_widths[name] = ( - float("inf") - if array.size < 2 - else float( - student_t.ppf(0.975, df=array.size - 1) - * uncertainties[name] - ) - ) - - insignificant_large: list[str] = [] - applied_training: dict[str, float] = {} - for name, value in training_offsets.items(): - if name in fixed_offsets: - applied_training[name] = fixed_offsets[name] - continue - uncertainty = uncertainties[name] - confidence_half_width = confidence_half_widths[name] - if ( - abs(value) < minimum_applied_offset_rad - or abs(value) - <= max(significance_sigma * uncertainty, confidence_half_width) - ): - applied_training[name] = 0.0 - validated_zero_candidate = bool( - profile.accept_validated_zero_in_confidence_interval - and maximum_confidence_half_width_rad is not None - and abs(value) <= confidence_half_width - and confidence_half_width <= maximum_confidence_half_width_rad - ) - if abs(value) >= minimum_applied_offset_rad and not validated_zero_candidate: - insignificant_large.append(name) - else: - applied_training[name] = value - - candidate_errors = angular_errors( - applied_training, validation, base_rotation, base_translation - ) - original_errors = angular_errors( - zero_offsets, validation, base_rotation, base_translation - ) - validation_error_by_joint: dict[str, float] = {} - original_error_by_joint: dict[str, float] = {} - improvement_by_joint: dict[str, float] = {} - improvement_confidence_lower: dict[str, float] = {} - improvement_passed = True - if profile.same_view_axis_pair_by_offset: - # Validate the same camera/Tag-mount-invariant axis-pair angle used by - # the configured offset. This keeps the side channel out of every - # other joint, including the already stable thumb CMC roll solve. - palm_orientation_validation_errors = tuple( - abs(float(error)) - for offset_joint in profile.same_view_axis_pair_by_offset - for error in same_view_axis_pair_errors( - offset_joint, validation, applied_training - ) - ) - else: - palm_orientation_validation_errors = tuple( - math.acos( - abs( - float( - np.clip( - base_rotation.apply( - predicted_palm_orientation_local( - item, zero_offsets - ) - ) - @ np.asarray(item.axis_common_xyz, dtype=float), - -1.0, - 1.0, - ) - ) - ) - ) - for item in orientation_validation - ) - palm_orientation_validation_limit = ( - maximum_validation_error_rad - if maximum_validation_error_rad is not None - else maximum_axis_cone_mismatch_rad - ) - palm_orientation_validation_passed = bool( - not configured_palm_sources - or ( - palm_orientation_validation_errors - and float(np.median(palm_orientation_validation_errors)) - <= palm_orientation_validation_limit - ) - ) - for offset_joint, observer_joint in profile.offset_observer_joint.items(): - if offset_joint in fixed_offsets: - improvement_by_joint[offset_joint] = 0.0 - improvement_confidence_lower[offset_joint] = 0.0 - continue - candidate = abs(candidate_errors[offset_joint]) - original = abs(original_errors[offset_joint]) - validation_error_by_joint[observer_joint] = candidate - original_error_by_joint[observer_joint] = original - improvement_by_joint[offset_joint] = original - candidate - if applied_training[offset_joint] != 0.0 and not candidate < original: - improvement_passed = False - for offset_joint in profile.direct_zero_joints: - if offset_joint in fixed_offsets: - improvement_confidence_lower[offset_joint] = 0.0 - continue - if applied_training[offset_joint] == 0.0: - improvement_confidence_lower[offset_joint] = 0.0 - continue - cycle_improvements: list[float] = [] - for cycle in training_cycle_ids: - selected = [item for item in measurements if item.cycle == cycle] - cycle_rotation, cycle_translation = cycle_models[cycle] - candidate = abs( - angular_errors( - applied_training, - selected, - cycle_rotation, - cycle_translation, - )[offset_joint] - ) - original = abs( - angular_errors( - zero_offsets, - selected, - cycle_rotation, - cycle_translation, - )[offset_joint] - ) - cycle_improvements.append(original - candidate) - improvement_array = np.asarray(cycle_improvements, dtype=float) - improvement_se = ( - float("inf") - if improvement_array.size < 2 - else float( - np.std(improvement_array, ddof=1) - / math.sqrt(improvement_array.size) - ) - ) - lower = ( - float("-inf") - if not math.isfinite(improvement_se) - else float( - np.mean(improvement_array) - - student_t.ppf( - 0.975, df=improvement_array.size - 1 - ) - * improvement_se - ) - ) - improvement_confidence_lower[offset_joint] = lower - if lower <= 0.0: - improvement_passed = False - - validation_errors = np.asarray( - list(validation_error_by_joint.values()), dtype=float - ) - validation_line_samples: dict[str, list[float]] = {} - for item in validation: - predicted_axis, predicted_point = predicted_local( - item, applied_training - ) - predicted_axis = base_rotation.apply(predicted_axis) - predicted_axis /= np.linalg.norm(predicted_axis) - predicted_point = ( - base_rotation.apply(predicted_point) + base_translation - ) - observed_axis = np.asarray(item.axis_common_xyz, dtype=float) - observed_axis /= np.linalg.norm(observed_axis) - observed_point = np.asarray(item.point_common_xyz_m, dtype=float) - cross = np.cross(predicted_axis, observed_axis) - cross_norm = float(np.linalg.norm(cross)) - separation = observed_point - predicted_point - line_error = ( - abs(float(separation @ cross)) / cross_norm - if cross_norm > 1.0e-6 - else float( - np.linalg.norm( - separation - - predicted_axis * float(separation @ predicted_axis) - ) - ) - ) - validation_line_samples.setdefault(item.joint, []).append(line_error) - validation_line_error_by_joint = { - name: float(np.sqrt(np.mean(np.square(values)))) - for name, values in validation_line_samples.items() - } - all_validation_line_errors = np.asarray( - [ - value - for values in validation_line_samples.values() - for value in values - ], - dtype=float, - ) - axis_line_rms = ( - float("inf") - if all_validation_line_errors.size == 0 - else float( - np.sqrt(np.mean(np.square(all_validation_line_errors))) - ) - ) - finger_roll_common_mode = ( - float( - np.median( - [training_offsets[name] for name in product_finger_rolls] - ) - ) - if profile.hand.layout_id == G20_RIGHT_19_LAYOUT - and product_finger_rolls - else 0.0 - ) - configured_limit_exceeded: list[str] = [] - for name, limit in zip(profile.direct_zero_joints, offset_limits): - if name in fixed_offsets: - continue - # A post-solve mechanical endpoint datum is the value that will be - # published for this joint. The visual root-axis scalar remains a - # nuisance gauge used for holdout geometry and must not be compared - # with the safety bound of a different, endpoint-anchored output. - checked_offset = output_offsets.get(name, training_offsets[name]) - if name in product_finger_rolls: - # The four roll motors share the same electrical centre and the - # absolute palm axial datum is recovered from the root-line - # pattern. Protect the independently assembled finger-to-finger - # deviations with the strict finger bound; protect their shared - # common mode with the unchanged global zero bound. Treating the - # same common datum as four independent failures is both - # over-counting and sensitive to the palm-frame gauge. - checked_offset -= finger_roll_common_mode - if abs(checked_offset) > limit + math.radians(0.01): - configured_limit_exceeded.append(name) - if ( - product_finger_rolls - and abs(finger_roll_common_mode) - > maximum_offset_rad + math.radians(0.01) - ): - configured_limit_exceeded.append("finger_mcp_roll_common_mode") - diagnostic_bound_hits: list[str] = [] - for name, limit in zip( - profile.direct_zero_joints, diagnostic_offset_limits - ): - if name in fixed_offsets: - continue - checked_offset = output_offsets.get(name, training_offsets[name]) - if name in product_finger_rolls: - # Match the configured-limit and publication convention above. - # The raw common roll is a fitted-palm-frame gauge; only the - # finger-to-finger deviation is a physical zero correction. - checked_offset -= finger_roll_common_mode - if abs(checked_offset) >= limit - math.radians(0.01): - diagnostic_bound_hits.append(name) - failure_reasons: dict[str, str] = {} - if not palm_orientation_validation_passed: - failure_reasons["palm_orientation"] = ( - "palm_orientation_holdout_too_large" - ) - requires_full_observability = bool( - profile.hand.layout_id == G20_RIGHT_19_LAYOUT - and profile.base_pose_strategy == "full_hand" - ) - if ( - requires_full_observability - and observability_rank < observability_parameter_count - ): - failure_reasons["palm_and_static_zero"] = ( - "zero_observation_jacobian_rank_deficient" - ) - elif ( - requires_full_observability - and observability_condition_number - > maximum_observability_condition_number - ): - failure_reasons["palm_and_static_zero"] = ( - "zero_observation_jacobian_ill_conditioned" - ) - for name in configured_limit_exceeded: - failure_reasons[name] = "zero_offset_exceeds_configured_limit" - for name in diagnostic_bound_hits: - failure_reasons[name] = "zero_offset_reached_diagnostic_bound" - for name in inconsistent_cycles: - failure_reasons[name] = "zero_offset_cycle_difference_too_large" - for name in insignificant_large: - failure_reasons[name] = "zero_offset_not_statistically_significant" - if maximum_confidence_half_width_rad is not None: - for name, half_width in confidence_half_widths.items(): - if half_width > maximum_confidence_half_width_rad: - failure_reasons[name] = ( - "zero_offset_confidence_interval_too_wide" - ) - # This solver publishes rotational encoder zeros only. A post-fit CAD to - # measured axis-line displacement is invariant to the joint's own zero - # and cannot be repaired by changing that rotational parameter. Keep the - # per-joint and aggregate values in ZeroSolveResult for geometry audit, - # but do not misclassify a fixed link-origin/Tag-depth discrepancy as a - # failed rotational holdout. Axis-point *fit* quality is still guarded - # above for every phase observation that actually uses line position. - if not improvement_passed: - for name, value in applied_training.items(): - if ( - name not in fixed_offsets - and value != 0.0 - and improvement_confidence_lower.get(name, 0.0) <= 0.0 - ): - failure_reasons[name] = "zero_offset_did_not_improve_with_95pct_confidence" - # Geometry failures are the root cause and must not be hidden by the - # downstream validation symptom produced by the same bad observation. - failure_reasons.update(observation_failures) - passed = bool( - validation_errors.size - == len(profile.direct_zero_joints) - len(fixed_offsets) - and float(np.mean(validation_errors)) <= maximum_validation_mae_rad - and float(np.percentile(validation_errors, 95.0)) - <= maximum_validation_p95_rad - and ( - maximum_validation_error_rad is None - or float(np.max(validation_errors)) - <= maximum_validation_error_rad - ) - and cycle_consistent - and not insignificant_large - and not configured_limit_exceeded - and not diagnostic_bound_hits - and not observation_failures - and ( - not requires_full_observability - or ( - observability_rank == observability_parameter_count - and observability_condition_number - <= maximum_observability_condition_number - ) - ) - and not any( - reason == "zero_offset_confidence_interval_too_wide" - for reason in failure_reasons.values() - ) - and improvement_passed - and palm_orientation_validation_passed - ) - - # Never refit a model that has passed its holdout with the validation - # cycle. The published offsets are exactly the frozen training result - # that produced ``validation_errors`` above. - final_offsets = dict(applied_training) - # Apply independently validated assembly datums only after trajectory - # fitting and holdout validation. A mechanical prior must define the - # written artifact without perturbing downstream yaw/pitch estimates. - final_offsets.update(output_offsets) - if profile.hand.layout_id == G20_RIGHT_19_LAYOUT and product_finger_rolls: - # The camera solve observes the four roll axes in a fitted palm frame. - # Rotation of that frame about their shared datum is a gauge, not four - # independent finger assembly errors. The product command 127/CAD - # pose defines the common straight-ahead datum; publish only each - # finger's robust deviation from the four-finger median. Validation - # above remains in the observation gauge, so no measured residual is - # discarded. - for name in product_finger_rolls: - final_offsets[name] = ( - float(final_offsets[name]) - finger_roll_common_mode - ) - - # Every active joint must be present in the runtime payload/URDF writer, - # but absence of an absolute observation is not evidence for the - # reference finger's assembly offset. Preserve the source-CAD zero for - # those independent motors while continuing to share their dynamic curve. - all_offsets = { - name: 0.0 for name in profile.hand.active_joints - } - all_offsets.update(final_offsets) - for target, source in profile.inherited_static_zero_joints.items(): - all_offsets[target] = final_offsets[source] - return ZeroSolveResult( - direct_offsets_rad=final_offsets, - all_active_offsets_rad=all_offsets, - base_translation_xyz_m=tuple(float(value) for value in base_translation), - base_quaternion_xyzw=tuple(float(value) for value in base_rotation.as_quat()), - validation_errors_rad=tuple(float(value) for value in validation_errors), - validation_error_by_joint_rad=validation_error_by_joint, - validation_line_error_by_joint_m=validation_line_error_by_joint, - axis_line_rms_m=axis_line_rms, - passed=passed, - cycle_offsets_rad={ - name: tuple(float(value) for value in values) - for name, values in cycle_values.items() - }, - offset_uncertainty_rad=uncertainties, - offset_confidence_half_width_rad=confidence_half_widths, - training_cycles=training_cycle_ids, - validation_cycle=int(validation_cycle), - validation_original_error_by_joint_rad=original_error_by_joint, - validation_improvement_by_joint_rad=improvement_by_joint, - validation_improvement_confidence_lower_rad=( - improvement_confidence_lower - ), - observability_rank=observability_rank, - observability_parameter_count=observability_parameter_count, - observability_condition_number=observability_condition_number, - offset_covariance_rad2=offset_covariance, - axis_cone_mismatch_by_joint_rad=axis_cone_mismatch_by_joint, - axis_cone_bias_classification_by_joint=( - axis_cone_bias_classification_by_joint - ), - failure_reasons=failure_reasons, - ) - - -def write_zero_corrected_urdf( - *, - source_urdf: str | Path, - output_directory: str | Path, - serial_number: str, - offsets_rad: Mapping[str, float], - endpoint_anchored_offsets_rad: Mapping[str, float] | None = None, - timestamp: str | None = None, - correction_plan: UrdfCorrectionPlan | None = None, -) -> Path: - source = Path(source_urdf).expanduser().resolve() - output = Path(output_directory).expanduser().resolve() - if not source.is_file(): - raise ValueError(f"source URDF does not exist: {source}") - if not offsets_rad: - raise ValueError("offsets_rad must contain at least one joint") - offsets = {str(name): float(value) for name, value in offsets_rad.items()} - endpoint_offsets = { - str(name): float(value) - for name, value in dict(endpoint_anchored_offsets_rad or {}).items() - } - if not set(endpoint_offsets) <= set(offsets): - raise ValueError("endpoint-anchored offsets must be URDF zero targets") - if correction_plan is not None: - correction_plan.verify_source(source) - correction_plan.authorize_offsets(offsets) - if not set(endpoint_offsets).issubset( - correction_plan.endpoint_limit_joints - ): - raise ValueError( - "endpoint offsets are not authorized by the correction plan" - ) - if any( - not math.isfinite(value) or abs(value) > math.radians(90.0) - for value in offsets.values() - ): - raise ValueError("URDF zero offsets must be finite and within +/-90deg") - stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") - if re.fullmatch(r"\d{8}_\d{6}", stamp) is None: - raise ValueError("URDF zero timestamp must use YYYYMMDD_HHMMSS") - safe_serial = "".join( - character if character.isalnum() or character in "_.-" else "_" - for character in str(serial_number) - ) - if not safe_serial: - raise ValueError("serial_number must not be empty") - tree = ET.parse(source) - root = tree.getroot() - replacement_rpy: dict[str, str] = {} - replacement_upper: dict[str, str] = {} - replacement_mimic_offset: dict[str, str] = {} - found: set[str] = set() - for joint in root.findall("joint"): - name = str(joint.get("name")) - if name not in offsets: - mimic = joint.find("mimic") - source_joint = None if mimic is None else str(mimic.get("joint")) - if source_joint in endpoint_offsets: - multiplier = float(mimic.get("multiplier", "1")) - old_offset = float(mimic.get("offset", "0")) - calibrated_mimic_offset = ( - old_offset + multiplier * endpoint_offsets[source_joint] - ) - replacement_mimic_offset[name] = ( - f"{calibrated_mimic_offset:.15g}" - ) - continue - found.add(name) - if name in endpoint_offsets: - limit = joint.find("limit") - if limit is None or limit.get("upper") is None: - raise ValueError(f"joint {name} has no upper limit") - replacement_upper[name] = f"{float(limit.get('upper')) - endpoint_offsets[name]:.15g}" - # A statistically insignificant correction is represented as exact - # zero. Preserve that joint's source text byte-for-byte instead of - # serialising an equivalent Euler triplet. - if abs(offsets[name]) <= 1.0e-15: - continue - if joint.get("type") not in {"revolute", "continuous"}: - raise ValueError(f"joint {name} is not revolute") - axis_node = joint.find("axis") - axis = _parse_triplet( - "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0") - ) - axis_norm = float(np.linalg.norm(axis)) - if axis_norm <= 1.0e-12: - raise ValueError(f"joint {name} has a degenerate axis") - axis /= axis_norm - origin_node = joint.find("origin") - if origin_node is None: - raise ValueError( - f"joint {name} has no origin; refusing a non-minimal rewrite" - ) - rpy = _parse_triplet(origin_node.get("rpy", "0 0 0")) - original = Rotation.from_euler("xyz", rpy).as_matrix() - corrected = original @ Rotation.from_rotvec( - axis * offsets[name] - ).as_matrix() - corrected_rpy = Rotation.from_matrix(corrected).as_euler("xyz") - replacement_rpy[name] = " ".join( - f"{float(value):.15g}" for value in corrected_rpy - ) - missing = sorted(set(offsets) - found) - if missing: - raise ValueError("source URDF is missing target joints: " + ",".join(missing)) - patch_names = ( - set(replacement_rpy) - | set(replacement_upper) - | set(replacement_mimic_offset) - ) - joint_patches = { - name: UrdfJointPatch( - origin_rpy=replacement_rpy.get(name), - limit_upper=replacement_upper.get(name), - mimic_offset=replacement_mimic_offset.get(name), - ) - for name in sorted(patch_names) - } - destination = output / ( - f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf" - ) - return write_urdf_patches( - source_urdf=source, - destination_urdf=destination, - patches=UrdfPatchSet(joints=joint_patches), - forbidden_source_stem_patterns=( - r"zero_calibrated", - r"calibrated_20\d{6}", - ), - ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/__init__.py deleted file mode 100644 index 377e41b..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registered L6 calibration profiles.""" - -from ..registry import ProfileRegistry - - -def register_profiles(registry: ProfileRegistry) -> None: - from .left_transfer import build_profile as build_left_transfer_profile - from .profile import build_profile - - registry.register(build_profile()) - registry.register(build_left_transfer_profile()) - - -__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/fitting.py deleted file mode 100644 index b3d54c4..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/fitting.py +++ /dev/null @@ -1,741 +0,0 @@ -"""L6 curve, endpoint-zero, holdout, and mimic fitting.""" - -from __future__ import annotations - -from dataclasses import dataclass -import math -from pathlib import Path -from typing import Mapping, Sequence -import xml.etree.ElementTree as ET - -import numpy as np -from scipy.optimize import least_squares - -from ..g20.profile import ( - HandCalibrationProfile, - JointCurveFit, - JointSpec, -) -from ..g20.zero_solver import ( - ZeroCalibrationProfile, - ZeroSolveResult, - fit_joint_axis_measurement, - fit_rotation_joint_curve, - rotation_curve_holdout_errors, - solve_urdf_zero_offsets, - with_depth_free_axis_projection, -) -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - COMMAND_INDEX_BY_JOINT, - COMMAND_NAMES, - COUPLING_MODEL_BY_JOINT, - ENDPOINT_ANCHOR_BY_JOINT, - KEY, - MEASURED_PASSIVE_JOINTS, - MIMIC_SOURCE_BY_JOINT, -) - - -@dataclass(frozen=True) -class MimicFit: - source_joint: str - target_joint: str - model: str - coefficients: tuple[float, ...] - urdf_mimic_multiplier: float - urdf_mimic_policy: str - cycle_coefficients: tuple[tuple[float, ...], ...] - maximum_cycle_range: float - maximum_cycle_prediction_range_rad: float - residual_rms_rad: float - residual_p95_rad: float - residual_max_rad: float - - @property - def multiplier(self) -> float: - """Linear term retained for compatible diagnostics and artifacts.""" - return float(self.coefficients[0]) - - @property - def cycle_multipliers(self) -> tuple[float, ...]: - return tuple(float(values[0]) for values in self.cycle_coefficients) - - @property - def mujoco_polycoef(self) -> tuple[float, ...]: - """MuJoCo q_target = p0 + p1*q_source + ... coefficients.""" - return (0.0, *self.coefficients, *(0.0,) * (5 - len(self.coefficients))) - - -@dataclass(frozen=True) -class L6FitResult: - curves: Mapping[str, JointCurveFit] - zero_offsets_rad: Mapping[str, float] - travels_rad: Mapping[str, float] - mimic_fits: Mapping[str, MimicFit] - holdout_errors_rad: Mapping[str, tuple[float, ...]] - zero_method_by_joint: Mapping[str, str] - zero_fallback_reason_by_joint: Mapping[str, str] - thumb_zero_result: ZeroSolveResult | None = None - - -L6_THUMB_AXIS_JOINTS: tuple[str, ...] = ( - "rh_thumb_cmc_roll", - "rh_thumb_cmc_pitch", - "rh_thumb_dip", - "rh_pinky_mcp_pitch", -) - - -def _l6_thumb_zero_profile() -> ZeroCalibrationProfile: - """Describe the observable L6 thumb/palm axis graph to the G20 solver.""" - joint_specs = { - "rh_thumb_cmc_roll": JointSpec( - "rh_thumb_cmc_roll", 1, True, "top", "top_base", "thumb_roll" - ), - "rh_thumb_cmc_pitch": JointSpec( - "rh_thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_pitch" - ), - "rh_thumb_dip": JointSpec( - "rh_thumb_dip", 0, False, "front", "thumb_pitch", "thumb_dip" - ), - # This root axis fixes the palm-frame phase around the thumb roll axis. - # Its electrical zero is irrelevant because rotating a revolute joint - # does not change its own physical screw axis. - "rh_pinky_mcp_pitch": JointSpec( - "rh_pinky_mcp_pitch", 5, True, "side", "side_base", "pinky_pitch" - ), - } - hand = HandCalibrationProfile( - side="right", - reference_finger="pinky", - view_tags={}, - preflight_view_roles={}, - joint_specs=joint_specs, - sweep_specs=(), - image_trajectory_joints=frozenset(), - roll_clearance_commands={}, - thumb_pitch_clearance_commands={}, - layout_id=KEY.layout, - model="L6", - command_names=COMMAND_NAMES, - baseline_command=(255,) * 6, - directional_zero=True, - isolated_holdout=True, - ) - return ZeroCalibrationProfile( - hand=hand, - direct_zero_joints=( - "rh_thumb_cmc_roll", - "rh_thumb_cmc_pitch", - ), - axis_joints=L6_THUMB_AXIS_JOINTS, - inherited_zero_joints={}, - inherited_static_zero_joints={}, - constrained_circle_joints=frozenset(L6_THUMB_AXIS_JOINTS), - root_anchor_joints=frozenset({"rh_thumb_cmc_roll"}), - axis_parent_joint={ - "rh_thumb_cmc_pitch": "rh_thumb_cmc_roll", - }, - phase_parent_joint={ - "rh_thumb_dip": "rh_thumb_cmc_pitch", - }, - offset_observer_joint={ - "rh_thumb_cmc_roll": "rh_thumb_cmc_pitch", - "rh_thumb_cmc_pitch": "rh_thumb_dip", - }, - same_view_axis_pair_by_offset={}, - fixed_direct_zero_offsets_rad={}, - static_output_zero_offsets_rad={}, - base_pose_strategy="thumb_serial", - orientation_anchor_joint="rh_pinky_mcp_pitch", - ) - - -def curve_travel_rad(fit: JointCurveFit) -> float: - decreasing = float(fit.decreasing_rad[0] - fit.decreasing_rad[255]) - increasing = float(fit.increasing_rad[0] - fit.increasing_rad[255]) - travel = 0.5 * (decreasing + increasing) - if not math.isfinite(travel) or travel <= 0.0: - raise ValueError("L6 fitted travel must be finite and positive") - return travel - - -def derive_endpoint_zero_offsets( - source_urdf: str | Path, - curves: Mapping[str, JointCurveFit], - *, - endpoint_anchor_by_joint: Mapping[str, str] | None = None, - maximum_offset_rad: float = math.radians(15.0), -) -> tuple[dict[str, float], dict[str, float]]: - """Anchor each measured joint to its profile-selected CAD endpoint. - - Every published dynamic curve is zero at feedback 255. ``upper_at_end`` - rotates the static frame by ``source_upper - measured_travel`` so feedback - 0 lands on the CAD upper endpoint. ``lower_at_start`` rotates it by the - source lower value so feedback 255 lands on the CAD lower endpoint. - ``cad_range_center`` splits a source-vs-measured travel discrepancy equally - between the two endpoints when neither source endpoint is a trusted datum. - ``zero_at_start`` keeps the CAD joint frame itself at feedback 255. All - policies publish the normalized corrected coordinate [0, measured travel]. - """ - joints = { - str(joint.get("name")): joint - for joint in ET.parse(Path(source_urdf)).getroot().findall("joint") - } - offsets: dict[str, float] = {} - travels: dict[str, float] = {} - anchors = endpoint_anchor_by_joint or dict(ENDPOINT_ANCHOR_BY_JOINT) - if not anchors or not set(anchors).issubset(CALIBRATED_ACTIVE_JOINTS): - raise ValueError("L6 endpoint anchors must target measured active joints") - for name in sorted(CALIBRATED_ACTIVE_JOINTS): - if name not in curves: - raise ValueError(f"missing measured L6 curve: {name}") - joint = joints.get(name) - limit = None if joint is None else joint.find("limit") - if ( - limit is None - or limit.get("upper") is None - or limit.get("lower") is None - ): - raise ValueError(f"source URDF joint has incomplete limits: {name}") - travel = curve_travel_rad(curves[name]) - travels[name] = travel - if name not in anchors: - continue - policy = str(anchors[name]) - if policy == "upper_at_end": - offset = float(limit.get("upper")) - travel - elif policy == "lower_at_start": - offset = float(limit.get("lower")) - elif policy == "cad_range_center": - offset = 0.5 * ( - float(limit.get("lower")) - + float(limit.get("upper")) - - travel - ) - elif policy == "zero_at_start": - offset = 0.0 - else: - raise ValueError(f"unsupported L6 endpoint anchor: {policy}") - if not math.isfinite(offset) or abs(offset) > maximum_offset_rad: - raise ValueError( - f"{name} endpoint zero offset exceeds 15 degrees: " - f"{math.degrees(offset):.3f}" - ) - offsets[name] = offset - return offsets, travels - - -def _has_complete_axis_geometry( - records_by_joint: Mapping[str, Sequence[Mapping[str, object]]], -) -> bool: - required = { - "relative_translation_xyz_m", - "parent_pose_common", - "child_pose_common", - "view_normal_common_xyz", - "camera_center_common_xyz_m", - "state_u8", - } - return all( - rows and all(required.issubset(row) for row in rows) - for name in L6_THUMB_AXIS_JOINTS - for rows in (records_by_joint.get(name, ()),) - ) - - -def _fit_l6_thumb_axis_zero( - source_urdf: str | Path, - records_by_joint: Mapping[str, Sequence[Mapping[str, object]]], - curves: Mapping[str, JointCurveFit], - *, - fixed_direct_zero_offsets_rad: Mapping[str, float] | None = None, - require_passed: bool = True, -) -> ZeroSolveResult: - """Recover thumb roll/pitch zeros from four physical screw axes.""" - profile = _l6_thumb_zero_profile() - measurements = [] - by_key: dict[tuple[str, int], object] = {} - for cycle in range(4): - # Fit the two root/reference axes before the serial passive observer. - for name in ( - "rh_thumb_cmc_roll", - "rh_thumb_cmc_pitch", - "rh_pinky_mcp_pitch", - ): - rows = records_by_joint[name] - view_normal = rows[0]["view_normal_common_xyz"] - measurement = fit_joint_axis_measurement( - name, - rows, - cycle=cycle, - zero_command_u8=255, - constrained_circle_joints=profile.constrained_circle_joints, - view_normal_common_xyz=view_normal, - canonical_zero_direction="decreasing", - ) - measurement = with_depth_free_axis_projection( - measurement, - rows[0]["camera_center_common_xyz_m"], - ) - measurements.append(measurement) - by_key[(name, cycle)] = measurement - - dip_rows = records_by_joint["rh_thumb_dip"] - pitch_axis = by_key[("rh_thumb_cmc_pitch", cycle)] - dip = fit_joint_axis_measurement( - "rh_thumb_dip", - dip_rows, - cycle=cycle, - zero_command_u8=255, - axis_common_constraint=pitch_axis.axis_common_xyz, - constrained_circle_joints=profile.constrained_circle_joints, - view_normal_common_xyz=dip_rows[0]["view_normal_common_xyz"], - canonical_zero_direction="decreasing", - ) - dip = with_depth_free_axis_projection( - dip, - dip_rows[0]["camera_center_common_xyz_m"], - ) - measurements.append(dip) - - result = solve_urdf_zero_offsets( - source_urdf=source_urdf, - measurements=measurements, - curves=curves, - motor_by_joint={ - name: COMMAND_INDEX_BY_JOINT[ - MIMIC_SOURCE_BY_JOINT.get(name, name) - ] - for name in L6_THUMB_AXIS_JOINTS - }, - training_cycles=(0, 1, 2), - validation_cycle=3, - maximum_offset_rad=math.radians(15.0), - finger_maximum_offset_rad=math.radians(15.0), - joint_maximum_offset_rad={ - "rh_thumb_cmc_roll": math.radians(15.0), - "rh_thumb_cmc_pitch": math.radians(15.0), - }, - maximum_cycle_difference_rad=math.radians(0.75), - minimum_applied_offset_rad=math.radians(0.1), - maximum_validation_mae_rad=math.radians(1.0), - maximum_validation_p95_rad=math.radians(2.0), - maximum_validation_error_rad=math.radians(3.0), - maximum_confidence_half_width_rad=math.radians(1.0), - maximum_pose_axis_line_rms_m=0.0015, - hand_type="right", - tag_layout=KEY.layout, - fixed_direct_zero_offsets_rad=fixed_direct_zero_offsets_rad, - zero_profile=profile, - ) - if require_passed and not result.passed: - details = ",".join( - f"{name}={reason}" - for name, reason in sorted(result.failure_reasons.items()) - ) - raise ValueError("L6 thumb axis zero solve failed:" + details) - return result - - -def _coupling_regression( - active: Sequence[float], passive: Sequence[float], *, degree: int -) -> tuple[tuple[float, ...], np.ndarray]: - x = np.asarray(active, dtype=float) - y = np.asarray(passive, dtype=float) - if x.shape != y.shape or x.ndim != 1 or x.size < 16: - raise ValueError("mimic curves must be aligned finite vectors") - if not np.all(np.isfinite(x)) or not np.all(np.isfinite(y)): - raise ValueError("mimic curves must be finite") - if degree not in {1, 2}: - raise ValueError("L6 coupling degree must be one or two") - if float(x @ x) <= 1.0e-9: - raise ValueError("active mimic source has insufficient travel") - design = np.column_stack([x ** power for power in range(1, degree + 1)]) - initial = np.linalg.lstsq(design, y, rcond=None)[0] - scale = max( - math.radians(0.25), - float(np.median(np.abs(y - design @ initial))), - ) - fitted = least_squares( - lambda value: y - design @ value, - np.asarray(initial, dtype=float), - loss="soft_l1", - f_scale=scale, - ) - if not fitted.success or not np.all(np.isfinite(fitted.x)): - raise ValueError("robust through-origin mimic regression failed") - coefficients = tuple(float(value) for value in fitted.x) - grid = np.linspace(0.0, float(np.max(x)), 256) - derivative = np.full_like(grid, coefficients[0]) - if degree == 2: - derivative += 2.0 * coefficients[1] * grid - if float(np.min(derivative)) < -1.0e-7: - raise ValueError("L6 coupling model is not monotonic") - return coefficients, y - design @ fitted.x - - -def fit_coupling_model( - source_joint: str, - target_joint: str, - active_fit: JointCurveFit, - passive_fit: JointCurveFit, - *, - model: str, - cycle_curve_pairs: Sequence[ - tuple[Sequence[float], Sequence[float]] - ] = (), - minimum_multiplier: float = 0.5, - maximum_multiplier: float = 1.5, - maximum_cycle_range: float = 0.03, - maximum_cycle_prediction_range_rad: float = math.radians(1.0), - maximum_residual_p95_rad: float = math.radians(2.0), - maximum_residual_rad: float = math.radians(3.0), -) -> MimicFit: - if model not in { - "linear_mimic", "quadratic_runtime", "direction_aware_knots" - }: - raise ValueError(f"unsupported L6 coupling model: {model}") - if model == "direction_aware_knots": - # The passive joint already has independently fitted decreasing and - # increasing curves over the same motor-feedback domain. Those knots - # are the exact runtime coupling representation and preserve real - # tendon backlash/nonlinearity that a single polynomial cannot model. - # Standard URDF has no directional lookup, so its mimic element keeps - # the endpoint-equivalent linear fallback only. - source_travel = curve_travel_rad(active_fit) - target_travel = curve_travel_rad(passive_fit) - if abs(source_travel) <= 1.0e-9: - raise ValueError("active mimic source has insufficient travel") - multiplier = float(target_travel / source_travel) - if not minimum_multiplier <= multiplier <= maximum_multiplier: - raise ValueError( - f"{target_joint} coupling endpoint ratio is outside " - f"[{minimum_multiplier}, {maximum_multiplier}]" - ) - return MimicFit( - source_joint=source_joint, - target_joint=target_joint, - model=model, - coefficients=(multiplier,), - urdf_mimic_multiplier=multiplier, - urdf_mimic_policy="endpoint_linear_fallback", - cycle_coefficients=(), - maximum_cycle_range=0.0, - maximum_cycle_prediction_range_rad=0.0, - # Runtime residual is assessed by the passive curve's isolated - # holdout samples, not by an intentionally lossy URDF fallback. - residual_rms_rad=0.0, - residual_p95_rad=0.0, - residual_max_rad=0.0, - ) - degree = 1 if model == "linear_mimic" else 2 - active = np.concatenate( - ( - np.asarray(active_fit.decreasing_rad, dtype=float), - np.asarray(active_fit.increasing_rad, dtype=float), - ) - ) - passive = np.concatenate( - ( - np.asarray(passive_fit.decreasing_rad, dtype=float), - np.asarray(passive_fit.increasing_rad, dtype=float), - ) - ) - coefficients, residual = _coupling_regression( - active, passive, degree=degree - ) - multiplier = coefficients[0] - if not minimum_multiplier <= multiplier <= maximum_multiplier: - raise ValueError( - f"{target_joint} coupling linear term is outside [0.5, 1.5]" - ) - cycle_coefficients = tuple( - _coupling_regression( - active_cycle, passive_cycle, degree=degree - )[0] - for active_cycle, passive_cycle in cycle_curve_pairs - ) - cycle_multipliers = tuple(values[0] for values in cycle_coefficients) - cycle_range = ( - 0.0 - if len(cycle_multipliers) < 2 - else float(max(cycle_multipliers) - min(cycle_multipliers)) - ) - if cycle_range > maximum_cycle_range: - raise ValueError( - f"{target_joint} coupling linear-term cycle range exceeds 0.03" - ) - cycle_prediction_range = 0.0 - if len(cycle_coefficients) >= 2: - grid = np.linspace(0.0, float(np.max(active)), 256) - predictions = np.asarray( - [ - sum(value * grid ** (index + 1) for index, value in enumerate(values)) - for values in cycle_coefficients - ] - ) - cycle_prediction_range = float( - np.max(np.ptp(predictions, axis=0)) - ) - if cycle_prediction_range > maximum_cycle_prediction_range_rad: - raise ValueError( - f"{target_joint} coupling cycle prediction range exceeds 1 degree" - ) - absolute = np.abs(residual) - rms = float(np.sqrt(np.mean(np.square(residual)))) - p95 = float(np.percentile(absolute, 95.0)) - maximum = float(np.max(absolute)) - if p95 > maximum_residual_p95_rad or maximum > maximum_residual_rad: - raise ValueError( - "coupling_residual_exceeds:" - f"joint={target_joint}:model={model}:" - f"linear_term={multiplier:.6f}:" - f"p95_deg={math.degrees(p95):.3f}:" - f"maximum_deg={math.degrees(maximum):.3f}:" - f"p95_limit_deg={math.degrees(maximum_residual_p95_rad):.3f}:" - f"maximum_limit_deg={math.degrees(maximum_residual_rad):.3f}" - ) - return MimicFit( - source_joint=source_joint, - target_joint=target_joint, - model=model, - coefficients=coefficients, - # Standard URDF has only a linear mimic. For a nonlinear coupling, - # preserve the familiar editor/RViz linkage with a fallback line that - # is exact at both the open zero and measured closed endpoint. The - # direction-aware runtime curves and MuJoCo polynomial remain exact in - # between those endpoints. - urdf_mimic_multiplier=( - multiplier - if model == "linear_mimic" - else curve_travel_rad(passive_fit) / curve_travel_rad(active_fit) - ), - urdf_mimic_policy=( - "exact_linear" - if model == "linear_mimic" - else "endpoint_linear_fallback" - ), - cycle_coefficients=cycle_coefficients, - maximum_cycle_range=cycle_range, - maximum_cycle_prediction_range_rad=cycle_prediction_range, - residual_rms_rad=rms, - residual_p95_rad=p95, - residual_max_rad=maximum, - ) - - -def fit_mimic_multiplier( - source_joint: str, - target_joint: str, - active_fit: JointCurveFit, - passive_fit: JointCurveFit, - *, - cycle_curve_pairs: Sequence[ - tuple[Sequence[float], Sequence[float]] - ] = (), - minimum_multiplier: float = 0.5, - maximum_multiplier: float = 1.5, - maximum_cycle_range: float = 0.03, - maximum_residual_p95_rad: float = math.radians(2.0), - maximum_residual_rad: float = math.radians(3.0), -) -> MimicFit: - return fit_coupling_model( - source_joint, - target_joint, - active_fit, - passive_fit, - model="linear_mimic", - cycle_curve_pairs=cycle_curve_pairs, - minimum_multiplier=minimum_multiplier, - maximum_multiplier=maximum_multiplier, - maximum_cycle_range=maximum_cycle_range, - maximum_residual_p95_rad=maximum_residual_p95_rad, - maximum_residual_rad=maximum_residual_rad, - ) - - -def fit_l6_session( - source_urdf: str | Path, - records_by_joint: Mapping[str, Sequence[Mapping[str, object]]], - *, - require_thumb_axis_zero: bool = False, -) -> L6FitResult: - """Fit three training cycles and validate the isolated fourth cycle.""" - expected = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS - if set(records_by_joint) != expected: - raise ValueError("L6 records must contain exactly five measured joints") - curves: dict[str, JointCurveFit] = {} - holdout: dict[str, tuple[float, ...]] = {} - cycle_curves: dict[str, dict[int, JointCurveFit]] = {} - for name in sorted(expected): - rows = [dict(row) for row in records_by_joint[name]] - training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}] - validation = [row for row in rows if int(row["cycle"]) == 3] - if not training or not validation: - raise ValueError(f"{name} is missing training or holdout records") - fit = fit_rotation_joint_curve(training, zero_command_u8=255) - errors = rotation_curve_holdout_errors( - fit, validation, zero_command_u8=255 - ) - absolute = np.abs(np.asarray(errors, dtype=float)) - if ( - float(np.mean(absolute)) > math.radians(1.0) - or float(np.percentile(absolute, 95.0)) > math.radians(2.0) - or float(np.max(absolute)) > math.radians(3.0) - ): - raise ValueError(f"{name} isolated holdout failed") - correction_limit = math.radians( - 3.0 if name in MEASURED_PASSIVE_JOINTS else 2.0 - ) - if fit.maximum_monotonic_correction_rad > correction_limit: - raise ValueError(f"{name} monotonic correction exceeds limit") - curves[name] = fit - holdout[name] = errors - cycle_curves[name] = { - cycle: fit_rotation_joint_curve( - [row for row in training if int(row["cycle"]) == cycle], - zero_command_u8=255, - ) - for cycle in (0, 1, 2) - } - endpoint_offsets, travels = derive_endpoint_zero_offsets( - source_urdf, - curves, - endpoint_anchor_by_joint=ENDPOINT_ANCHOR_BY_JOINT, - ) - thumb_zero_result: ZeroSolveResult | None = None - offsets = { - "rh_thumb_cmc_roll": 0.0, - "rh_thumb_cmc_pitch": endpoint_offsets["rh_thumb_cmc_pitch"], - **endpoint_offsets, - } - pinky_endpoint_method = { - "upper_at_end": "mechanical_upper_endpoint", - "lower_at_start": "mechanical_lower_endpoint", - "cad_range_center": "cad_range_center", - "zero_at_start": "source_joint_zero", - }[ENDPOINT_ANCHOR_BY_JOINT["rh_pinky_mcp_pitch"]] - zero_methods = { - "rh_thumb_cmc_roll": "source_joint_zero_unpublished", - "rh_thumb_cmc_pitch": "cad_range_center_unpublished", - "rh_pinky_mcp_pitch": pinky_endpoint_method, - } - zero_fallback_reasons: dict[str, str] = {} - has_axis_geometry = _has_complete_axis_geometry(records_by_joint) - if require_thumb_axis_zero and not has_axis_geometry: - raise ValueError( - "L6 thumb absolute zero requires G20-compatible common-frame " - "Tag pose trajectories; this session must be reacquired" - ) - if has_axis_geometry: - geometric_result = _fit_l6_thumb_axis_zero( - source_urdf, - records_by_joint, - curves, - require_passed=False, - ) - thumb_zero_result = geometric_result - pitch_failure = geometric_result.failure_reasons.get( - "rh_thumb_cmc_pitch" - ) - endpoint_fallback_reasons = { - "zero_offset_exceeds_configured_limit", - "zero_offset_reached_diagnostic_bound", - } - if ( - not geometric_result.passed - and set(geometric_result.failure_reasons) - == {"rh_thumb_cmc_pitch"} - and pitch_failure in endpoint_fallback_reasons - ): - # L6_RIGHT_001 demonstrated a stable pitch-to-DIP axis-line phase - # beyond the diagnostic search bound. That phase includes - # physical link geometry and is not a safe encoder-zero observation - # when it contradicts both the reviewed endpoint and the +/-15 deg - # write limit. Keep roll geometric, but freeze pitch to its - # independent measured/CAD range-centre datum. Any roll, - # holdout, cone, confidence, or multi-joint failure remains a hard - # rejection. - zero_fallback_reasons["rh_thumb_cmc_pitch"] = str(pitch_failure) - thumb_zero_result = _fit_l6_thumb_axis_zero( - source_urdf, - records_by_joint, - curves, - fixed_direct_zero_offsets_rad={ - "rh_thumb_cmc_pitch": endpoint_offsets[ - "rh_thumb_cmc_pitch" - ] - }, - ) - elif not geometric_result.passed: - details = ",".join( - f"{name}={reason}" - for name, reason in sorted( - geometric_result.failure_reasons.items() - ) - ) - raise ValueError("L6 thumb axis zero solve failed:" + details) - offsets.update( - { - name: float(thumb_zero_result.direct_offsets_rad[name]) - for name in ( - "rh_thumb_cmc_roll", - "rh_thumb_cmc_pitch", - ) - } - ) - zero_methods.update( - { - "rh_thumb_cmc_roll": "urdf_serial_axis_geometry", - "rh_thumb_cmc_pitch": ( - "cad_range_center_after_geometry_rejection" - if "rh_thumb_cmc_pitch" in zero_fallback_reasons - else "urdf_serial_axis_geometry" - ), - } - ) - mimic_fits: dict[str, MimicFit] = {} - for target in sorted(MEASURED_PASSIVE_JOINTS): - source = MIMIC_SOURCE_BY_JOINT[target] - cycle_pairs = [] - for cycle in (0, 1, 2): - active = cycle_curves[source][cycle] - passive = cycle_curves[target][cycle] - cycle_pairs.append( - ( - tuple(active.decreasing_rad) + tuple(active.increasing_rad), - tuple(passive.decreasing_rad) + tuple(passive.increasing_rad), - ) - ) - mimic_fits[target] = fit_coupling_model( - source, - target, - curves[source], - curves[target], - model=COUPLING_MODEL_BY_JOINT[target], - cycle_curve_pairs=cycle_pairs, - ) - return L6FitResult( - curves=curves, - zero_offsets_rad=offsets, - travels_rad=travels, - mimic_fits=mimic_fits, - holdout_errors_rad=holdout, - zero_method_by_joint=zero_methods, - zero_fallback_reason_by_joint=zero_fallback_reasons, - thumb_zero_result=thumb_zero_result, - ) - - -__all__ = [ - "L6FitResult", - "MimicFit", - "curve_travel_rad", - "derive_endpoint_zero_offsets", - "L6_THUMB_AXIS_JOINTS", - "fit_coupling_model", - "fit_l6_session", - "fit_mimic_multiplier", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/motion.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/motion.py deleted file mode 100644 index 21c194f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/motion.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Safe six-channel motion helpers for the partial L6 profile.""" - -from __future__ import annotations - -import math -from typing import Sequence - -from ...core import CalibrationProfile, TaskSpec - - -def cosine_position_trajectory_u8( - start_u8: float, - target_u8: float, - elapsed_seconds: float, - full_range_duration_seconds: float, -) -> tuple[float, float, float]: - """Return a bounded, zero-end-velocity L6 command trajectory sample.""" - duration = ( - float(full_range_duration_seconds) - * abs(float(target_u8) - float(start_u8)) - / 255.0 - ) - if full_range_duration_seconds <= 0.0: - raise ValueError("full_range_duration_seconds must be positive") - if duration <= 0.0: - return float(target_u8), 1.0, 0.0 - phase = min(1.0, max(0.0, float(elapsed_seconds) / duration)) - blend = 0.5 - 0.5 * math.cos(math.pi * phase) - value = float(start_u8) + (float(target_u8) - float(start_u8)) * blend - return value, phase, duration - - -def build_calibration_motion_command( - task: TaskSpec, - command_u8: int, - *, - profile: CalibrationProfile, -) -> list[int]: - values = list(profile.command.baseline_u8) - for index, value in task.auxiliary_commands: - values[int(index)] = int(value) - values[int(task.command_index)] = int(command_u8) - return values - - -def build_calibration_preparation_waypoints( - task: TaskSpec, - *, - profile: CalibrationProfile, - current_command: Sequence[int] | None = None, -) -> tuple[tuple[int, ...], ...]: - del current_command - start = build_calibration_motion_command( - task, task.start_u8, profile=profile - ) - return (tuple(start),) - - -def build_calibration_return_waypoints( - target_command: Sequence[int] | None = None, - *, - profile: CalibrationProfile, - current_command: Sequence[int] | None = None, - **_: object, -) -> tuple[tuple[int, ...], ...]: - del current_command - target = ( - tuple(int(value) for value in target_command) - if target_command is not None - else tuple(profile.command.baseline_u8) - ) - if len(target) != profile.command.command_count: - raise ValueError("six-channel return command has the wrong channel count") - return (target,) - - -__all__ = [ - "build_calibration_motion_command", - "build_calibration_preparation_waypoints", - "build_calibration_return_waypoints", - "cosine_position_trajectory_u8", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/node.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/node.py deleted file mode 100644 index e82b2dd..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/node.py +++ /dev/null @@ -1,1779 +0,0 @@ -"""Online three-view acquisition node for L6/right/l6_right_8/v1.""" - -from __future__ import annotations - -from collections import deque -from dataclasses import dataclass -import json -import math -from pathlib import Path -import threading -import time -import traceback -from typing import Any - -import numpy as np -import rclpy -from apriltag_msgs.msg import AprilTagDetectionArray -from rclpy.callback_groups import MutuallyExclusiveCallbackGroup -from rclpy.node import Node -from rclpy.qos import qos_profile_sensor_data -from scipy.spatial.transform import Rotation -from sensor_msgs.msg import CameraInfo, JointState -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...acquisition import StateSample, interpolate_state_u8 -from ...extrinsics import ( - load_three_camera_extrinsics, - matrix_payload, - transform_matrix, -) -from ...pnp import SquareTagPose, SquareTagPoseTracker -from ...storage import append_jsonl, atomic_write_json -from ...runtime import ACQUISITION_POLICY_VERSION, CalibrationEngine -from ...runtime.adapters import ProfileSdkAdapter -from .motion import cosine_position_trajectory_u8 -from .pipeline import finalize_l6_session -from .profile import build_typed_profile - - -@dataclass(frozen=True) -class MotionStep: - phase: str - task_key: str | None - command_index: int | None - target_u8: float - speed_u8: float - cycle: int | None = None - direction: str | None = None - attempt: int = 1 - target_command: tuple[float, ...] | None = None - - @property - def recording(self) -> bool: - return self.direction in {"decreasing", "increasing"} - - -def _stamp_ns(stamp: Any) -> int: - return int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec) - - -class L6ThreeCameraCalibrationNode(Node): - """Own one reviewed six-channel partial-calibration session.""" - - @staticmethod - def _uses_isolated_motion_callbacks() -> bool: - """Legacy byte profiles retain their original callback behavior.""" - return False - - def __init__( - self, - *, - profile=None, - finalizer=None, - sample_kind: str = "l6_joint_sample", - ) -> None: - self.profile = build_typed_profile() if profile is None else profile - self.model_name = self.profile.key.model - self.command_names = tuple(self.profile.command.names) - self.command_count = self.profile.command.command_count - self.command_unit = self.profile.command.unit - self.baseline_command = tuple(self.profile.command.baseline_values) - self.command_lower = tuple(self.profile.command.minimum_values) - self.command_upper = tuple(self.profile.command.maximum_values) - self.feedback_lower = tuple( - self.profile.command.minimum_feedback_values - ) - self.feedback_upper = tuple( - self.profile.command.maximum_feedback_values - ) - self.sample_kind = str(sample_kind) - self.sweep_quality_kind = f"{self.model_name.lower()}_sweep_observation_quality" - self.finalize_session = finalizer or finalize_l6_session - self.calibration_engine = CalibrationEngine(self.profile) - self.sdk_adapter = ProfileSdkAdapter(self.profile.command) - super().__init__(f"{self.model_name.lower()}_calibration") - isolated_motion = self._uses_isolated_motion_callbacks() - self.motion_callback_group = ( - MutuallyExclusiveCallbackGroup() if isolated_motion else None - ) - # O12 receives several independent AprilTag streams while a motion - # timer keeps publishing the trajectory. Putting every camera in one - # mutually-exclusive group lets a high-rate view monopolize the group: - # the primary view can then retain hundreds of frames while the - # required cross view sees only a few dozen. Keep callbacks serialized - # *within* one camera (CameraInfo and detections share a group), but let - # independent camera streams run concurrently. Legacy L6/O6 profiles - # do not opt into isolated callbacks and therefore retain their exact - # executor behaviour. - self.vision_callback_groups = ( - { - view: MutuallyExclusiveCallbackGroup() - for view in self.profile.vision.view_names - } - if isolated_motion else {} - ) - # Retain the singular attribute for diagnostics and downstream code; - # subscriptions use the per-view mapping below. - self.vision_callback_group = next( - iter(self.vision_callback_groups.values()), None - ) - # Only O12 opts into parallel motion/vision callbacks. L6/O6 keep - # their established executor behavior and never use this barrier. - self.step_data_lock = threading.RLock() if isolated_motion else None - self.vision_callbacks_inflight = 0 - self._declare_parameters() - self._load_parameters() - self.session_dir.mkdir(parents=True, exist_ok=True) - self.raw_path = self.session_dir / "raw_samples.jsonl" - append_jsonl( - self.raw_path, - { - "kind": "session_start", - "sample_schema_version": self.profile.artifacts.output_schema_version, - "profile_id": self.profile.key.profile_id, - "serial_number": self.serial_number, - "curve_input_domain": f"feedback_{self.command_unit}", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - **self.protected_inputs, - "resume_checkpoint_requested": ( - self.resume_raw_samples_path is not None - ), - }, - ) - - self.command_publisher = self.create_publisher( - JointState, self.command_topic, 10 - ) - self.setting_publisher = self.create_publisher( - String, self.setting_topic, 10 - ) - self.status_publisher = self.create_publisher( - String, f"{self.profile.namespace}/status", 10 - ) - self.create_subscription( - JointState, self.state_topic, self._state_callback, 30, - callback_group=self.motion_callback_group, - ) - self.camera_matrices: dict[str, np.ndarray] = {} - self.image_sizes: dict[str, tuple[int, int]] = {} - self.last_view_valid_at: dict[str, float] = {} - self.base_corner_observations: dict[str, deque[np.ndarray]] = { - view: deque(maxlen=30) for view in self.profile.vision.view_names - } - self.base_corner_reference: dict[str, np.ndarray] = {} - self.base_drift_counts: dict[str, int] = {} - self.latest_base_drift_px: dict[str, float] = {} - self.trackers = { - view: SquareTagPoseTracker( - maximum_reprojection_error_px=self.pnp_maximum_reprojection_error_px, - reprojection_tie_px=self.pnp_reprojection_tie_px, - maximum_pose_jump_rad=math.radians(self.pnp_maximum_pose_jump_deg), - maximum_translation_jump_m=self.pnp_maximum_translation_jump_m, - maximum_tag_tilt_rad=math.radians(self.pnp_maximum_tag_tilt_deg), - reset_after_seconds=self.pnp_tracker_reset_seconds, - ) - for view in self.profile.vision.view_names - } - for view in self.profile.vision.view_names: - self.create_subscription( - CameraInfo, - self.camera_info_topics[view], - lambda message, selected=view: self._camera_info_callback( - selected, message - ), - qos_profile_sensor_data, - callback_group=self.vision_callback_groups.get(view), - ) - self.create_subscription( - AprilTagDetectionArray, - self.detection_topics[view], - ( - lambda message, selected=view: - self._guarded_detections_callback(selected, message) - ) if isolated_motion else ( - lambda message, selected=view: - self._detections_callback(selected, message) - ), - qos_profile_sensor_data, - callback_group=self.vision_callback_groups.get(view), - ) - self.create_service( - Trigger, f"{self.profile.namespace}/start", self._start, - callback_group=self.motion_callback_group, - ) - self.create_service( - Trigger, f"{self.profile.namespace}/abort", self._abort, - callback_group=self.motion_callback_group, - ) - - self.latest_state_u8: tuple[float, ...] = () - self.state_history: deque[StateSample] = deque(maxlen=2000) - self.state_receive_times: deque[float] = deque(maxlen=300) - self.command_publish_times: deque[float] = deque(maxlen=500) - self.raw_records: list[dict[str, Any]] = [] - self.steps: list[MotionStep] = [] - self.step_index = -1 - self.step_started_at = 0.0 - self.step_hold_since: float | None = None - self.step_command_sent = False - self.step_last_progress_at = 0.0 - self.step_last_feedback = float("nan") - self.step_last_distance_u8 = float("nan") - self.step_initial_distance_u8 = float("nan") - self.step_initial_feedback = float("nan") - self.step_speed_ready_at = 0.0 - self.step_requested_u8 = float("nan") - self.step_start_state_u8: tuple[float, ...] = () - self.step_start_feedback_u8: tuple[float, ...] = () - self.last_published_command_u8: tuple[float, ...] | None = None - self.step_last_command_u8: tuple[float, ...] | None = None - self.step_trajectory_phase = 0.0 - self.step_trajectory_blend = 0.0 - self.step_trajectory_duration_seconds = 0.0 - self.step_moving_indices: frozenset[int] = frozenset() - self.step_valid_frames = 0 - self.step_total_frames = 0 - self.step_required_roles: tuple[str, ...] = () - self.step_tag_seen_frames: dict[str, int] = {} - self.step_tag_quality_frames: dict[str, int] = {} - self.step_all_tags_quality_frames = 0 - self.step_pnp_valid_frames = 0 - self.step_state_sync_frames = 0 - self.step_rejection_counts: dict[str, int] = {} - self.latest_recognized_tag_ids: tuple[int, ...] = () - self.latest_unrecognized_tag_ids: tuple[int, ...] = () - self.state = "WAIT_DEVICES" - self.reason = "waiting_for_six_channel_feedback_and_three_camera_info" - self.started = False - self.commanded_speed: int | None = None - self.retry_counts: dict[tuple[str, int, str], int] = {} - self.create_timer( - 1.0 / float(self.command_rate_hz) - if self.command_unit == "rad" else 0.01, - self._tick, - callback_group=self.motion_callback_group, - ) - self.create_timer(0.5, self._publish_status) - - def _declare_parameters(self) -> None: - model = self.model_name.lower() - namespace = self.profile.namespace - speed_parameters = self.profile.motion.speed_parameters - defaults: dict[str, Any] = { - "serial_number": "UNSET", - "session_dir": "", - "source_urdf_path": "", - "source_urdf_expected_sha256": "", - "camera_extrinsics_file": "", - "camera_extrinsics_expected_sha256": "", - "calibration_config_expected_sha256": "", - "tag_config_expected_sha256": "", - "sdk_config_expected_sha256": "", - "resume_raw_samples_path": "", - "command_topic": f"/{model}/cb_right_hand_control_cmd", - "state_topic": f"/{model}/cb_right_hand_state", - "setting_topic": f"/{model}/cb_hand_setting_cmd", - "front_camera_info_topic": f"{namespace}/front/camera/camera_info", - "front_detections_topic": f"{namespace}/front/apriltag/detections", - "side_camera_info_topic": f"{namespace}/side/camera/camera_info", - "side_detections_topic": f"{namespace}/side/apriltag/detections", - "top_camera_info_topic": f"{namespace}/top/camera/camera_info", - "top_detections_topic": f"{namespace}/top/apriltag/detections", - "commands_enabled": True, - "baseline_speed_u8": int( - speed_parameters.get( - "baseline_u8", speed_parameters.get("preflight_u8", 1) - ) - ), - "preflight_speed_u8": int(speed_parameters.get("preflight_u8", 1)), - "formal_speed_u8": int(speed_parameters.get("formal_u8", 1)), - "speed_settle_seconds": float( - speed_parameters.get("speed_settle_seconds", 0.2) - ), - "command_trajectory_full_range_seconds": float( - speed_parameters.get("command_trajectory_full_range_seconds", 6.0) - ), - "torque_u8": int(speed_parameters.get("torque_u8", 80)), - "endpoint_tolerance_u8": 2.0, - "endpoint_hold_seconds": 1.0, - "motor_stall_timeout_seconds": 2.0, - "position_timeout_seconds": 30.0, - "sweep_timeout_seconds": 90.0, - "automatic_sweep_retry_limit": 1, - "non_target_motion_tolerance_u8": 3.0, - "minimum_sweep_frames": 40, - "minimum_state_span_u8": 240.0, - "minimum_sweep_bins": 32, - "maximum_bin_gap": 16, - "minimum_detection_rate": 0.95, - # Three independently detected Tags at 95% each can only yield - # 0.95**3 ~= 85.7% fully joined frames. Keep the per-Tag gate at - # 95%, and gate the downstream joined PnP/state samples separately. - "minimum_joint_frame_rate": 0.85, - "minimum_feedback_hz": 25.0, - "maximum_state_image_skew_ms": 50.0, - "maximum_hamming": 0, - "minimum_decision_margin": 30.0, - "minimum_edge_pixels": 30.0, - "fixed_base_maximum_corner_drift_px": 5.0, - "fixed_base_movement_confirmation_frames": 10, - "tag_size_m": 0.016, - "pnp_maximum_reprojection_error_px": 1.5, - "pnp_reprojection_tie_px": 1.5, - "pnp_maximum_pose_jump_deg": 35.0, - "pnp_maximum_translation_jump_m": 0.04, - "pnp_maximum_tag_tilt_deg": 75.0, - "pnp_tracker_reset_seconds": 5.0, - "command_rate_hz": float(speed_parameters.get("command_rate_hz", 100.0)), - "endpoint_tolerance_rad": 0.01, - "non_target_motion_tolerance_rad": 0.015, - "minimum_state_span_fraction": 0.90, - } - for name, default in defaults.items(): - self.declare_parameter(name, default) - - def _load_parameters(self) -> None: - value = lambda name: self.get_parameter(name).value - self.serial_number = str(value("serial_number")) - self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() - self.source_urdf = Path(str(value("source_urdf_path"))).expanduser().resolve() - self.camera_extrinsics_file = Path( - str(value("camera_extrinsics_file")) - ).expanduser().resolve() - if self.serial_number in {"", "UNSET"}: - raise ValueError("serial_number is required") - if not str(value("session_dir")) or not self.source_urdf.is_file(): - raise ValueError("session_dir and immutable source_urdf_path are required") - if not self.camera_extrinsics_file.is_file(): - raise ValueError( - f"camera_extrinsics_file is required for {self.model_name} zero solve" - ) - self.extrinsics = load_three_camera_extrinsics( - self.camera_extrinsics_file, - quality_limits=self.profile.vision.extrinsics_quality_limits, - minimum_capture_counts=self.profile.vision.minimum_capture_counts, - ) - self.protected_inputs = { - "source_urdf_sha256": str(value("source_urdf_expected_sha256")), - "camera_extrinsics_sha256": str( - value("camera_extrinsics_expected_sha256") - ), - "calibration_config_sha256": str( - value("calibration_config_expected_sha256") - ), - "tag_config_sha256": str(value("tag_config_expected_sha256")), - } - if "sdk_config_sha256" in self.profile.artifacts.protected_input_fields: - self.protected_inputs["sdk_config_sha256"] = str( - value("sdk_config_expected_sha256") - ) - resume_value = str(value("resume_raw_samples_path")).strip() - self.resume_raw_samples_path = ( - None - if not resume_value - else Path(resume_value).expanduser().resolve() - ) - if ( - set(self.protected_inputs) - != self.profile.artifacts.protected_input_fields - or any(len(item) != 64 for item in self.protected_inputs.values()) - ): - raise ValueError("all profile-protected SHA-256 values are required") - self.command_topic = str(value("command_topic")) - self.state_topic = str(value("state_topic")) - self.setting_topic = str(value("setting_topic")) - self.camera_info_topics = { - view: str(value(f"{view}_camera_info_topic")) - for view in self.profile.vision.view_names - } - self.detection_topics = { - view: str(value(f"{view}_detections_topic")) - for view in self.profile.vision.view_names - } - for name in ( - "commands_enabled", "baseline_speed_u8", "preflight_speed_u8", - "formal_speed_u8", - "speed_settle_seconds", "command_trajectory_full_range_seconds", - "torque_u8", "endpoint_tolerance_u8", "endpoint_hold_seconds", - "motor_stall_timeout_seconds", "position_timeout_seconds", - "sweep_timeout_seconds", "automatic_sweep_retry_limit", - "non_target_motion_tolerance_u8", "minimum_sweep_frames", - "minimum_state_span_u8", "minimum_sweep_bins", "maximum_bin_gap", - "minimum_detection_rate", "minimum_joint_frame_rate", - "minimum_feedback_hz", - "maximum_state_image_skew_ms", "maximum_hamming", - "minimum_decision_margin", "minimum_edge_pixels", - "fixed_base_maximum_corner_drift_px", - "fixed_base_movement_confirmation_frames", "tag_size_m", - "pnp_maximum_reprojection_error_px", "pnp_reprojection_tie_px", - "pnp_maximum_pose_jump_deg", "pnp_maximum_translation_jump_m", - "pnp_maximum_tag_tilt_deg", "pnp_tracker_reset_seconds", - "command_rate_hz", "endpoint_tolerance_rad", - "non_target_motion_tolerance_rad", "minimum_state_span_fraction", - ): - setattr(self, name, value(name)) - self.maximum_state_image_skew_ns = int( - float(self.maximum_state_image_skew_ms) * 1_000_000 - ) - if self.command_unit == "u8": - for name in ( - "baseline_speed_u8", "preflight_speed_u8", "formal_speed_u8" - ): - if not 1 <= int(getattr(self, name)) <= 255: - raise ValueError(f"{name} must be in [1, 255]") - else: - self.endpoint_tolerance_u8 = float(self.endpoint_tolerance_rad) - self.non_target_motion_tolerance_u8 = float( - self.non_target_motion_tolerance_rad - ) - self.minimum_state_span_u8 = float(self.minimum_state_span_fraction) - if not 1.0 <= float(self.command_rate_hz) <= 100.0: - raise ValueError("command_rate_hz must be in [1, 100]") - if not 0.0 < float(self.endpoint_tolerance_u8) <= 0.1: - raise ValueError("endpoint_tolerance_rad must be in (0, 0.1]") - if not 0.0 < float(self.minimum_state_span_u8) <= 1.0: - raise ValueError("minimum_state_span_fraction must be in (0, 1]") - if not 0.0 <= float(self.speed_settle_seconds) <= 5.0: - raise ValueError("speed_settle_seconds must be in [0, 5]") - if not 2.0 <= float(self.command_trajectory_full_range_seconds) <= 30.0: - raise ValueError( - "command_trajectory_full_range_seconds must be in [2, 30]" - ) - if not 0.0 < float(self.minimum_detection_rate) <= 1.0: - raise ValueError("minimum_detection_rate must be in (0, 1]") - if not 0.0 < float(self.minimum_joint_frame_rate) <= 1.0: - raise ValueError("minimum_joint_frame_rate must be in (0, 1]") - - def _build_steps(self) -> list[MotionStep]: - steps = [ - MotionStep( - "baseline", None, None, 255, int(self.baseline_speed_u8) - ) - ] - for task in self.profile.motion.tasks: - formal_speed = ( - task.formal_speed - if getattr(self, "command_unit", self.profile.command.unit) == "rad" - else self.formal_speed_u8 - ) - for cycle in (0, 1, 2, 3): - steps.extend( - [ - MotionStep("prepare", task.key, task.command_index, task.start_value, float(formal_speed), cycle), - MotionStep("sweep", task.key, task.command_index, task.end_value, float(formal_speed), cycle, "decreasing"), - MotionStep("prepare", task.key, task.command_index, task.end_value, float(formal_speed), cycle), - MotionStep("sweep", task.key, task.command_index, task.start_value, float(formal_speed), cycle, "increasing"), - ] - ) - return steps - - def _start(self, _request: Trigger.Request, response: Trigger.Response) -> Trigger.Response: - if self.started: - response.success = False - response.message = f"{self.model_name} calibration has already started" - return response - if self.state != "READY": - response.success = False - response.message = self.reason - return response - self.steps = self._build_steps() - self.step_index = 0 - self.started = True - self.state = "RUNNING" - self.reason = "moving_to_open_baseline" - self._publish_torque() - response.success = True - response.message = f"{self.model_name} partial calibration started" - return response - - def _abort(self, _request: Trigger.Request, response: Trigger.Response) -> Trigger.Response: - hold = ( - list(self.last_published_command_u8) - if self.command_unit == "rad" - and self.last_published_command_u8 is not None - else list(self.baseline_command) - ) - self._publish_command(hold) - self.state = "ABORTED" - self.reason = ( - "operator_abort_holding_current_position" - if self.command_unit == "rad" - else "operator_abort_returning_to_open_baseline" - ) - response.success = True - response.message = self.reason - return response - - def _camera_info_callback(self, view: str, message: CameraInfo) -> None: - from ...core.geometry.camera import rectified_camera_matrix - try: - matrix = rectified_camera_matrix(message.p) - if message.width <= 0 or message.height <= 0: - raise ValueError('invalid rectified image dimensions') - except ValueError: - self.camera_matrices.pop(view, None) - self.image_sizes.pop(view, None) - return - previous = self.camera_matrices.get(view) - self.camera_matrices[view] = matrix - self.image_sizes[view] = (int(message.width), int(message.height)) - model = { - 'kind': 'rectified_camera_model', 'view': view, - 'matrix_source': 'CameraInfo.P[:3,:3]', - 'width': int(message.width), 'height': int(message.height), - 'raw_k': list(message.k), 'raw_d': list(message.d), - 'rectification_r': list(message.r), 'projection_p': list(message.p), - 'camera_matrix': matrix.tolist(), 'input_is_rectified': True, - } - if not hasattr(self, 'camera_models'): - self.camera_models = {} - if self.camera_models.get(view) != model: - self.camera_models[view] = model - if hasattr(self, 'raw_path'): - append_jsonl(self.raw_path, model) - if previous is not None and not np.allclose(previous, matrix): - self.trackers[view].reset() - group = getattr(self, '_thumb_pose_group', None) - if view == 'front' and group is not None: - group.reset() - - def _state_callback(self, message: JointState) -> None: - adapter = getattr(self, "sdk_adapter", ProfileSdkAdapter(self.profile.command)) - state = adapter.parse_feedback(message.name, message.position) - if state is None: - return - violation = next(( - (index, value) - for index, value in enumerate(state) - if value < self.feedback_lower[index] - or value > self.feedback_upper[index] - ), None) - if violation is not None: - index, value = violation - # Preserve the offending observation for the operator diagnostic, - # while the pause path continues to hold the last safe command. - self.latest_state_u8 = state - self._pause( - "feedback_outside_registered_feedback_domain:" - f"channel={self.command_names[index]}:value={value:.9f}:" - f"lower={self.feedback_lower[index]:.9f}:" - f"upper={self.feedback_upper[index]:.9f}" - ) - return - stamp = _stamp_ns(message.header.stamp) - if stamp <= 0: - stamp = int(self.get_clock().now().nanoseconds) - if not self.state_history or stamp > self.state_history[-1].stamp_ns: - self.state_history.append(StateSample(stamp, state)) - self.latest_state_u8 = state - self.state_receive_times.append(time.monotonic()) - # Non-target motion is retained in every sample for diagnostics and - # coupling fitting. It is not a runtime stop condition: tendon hands - # legitimately back-drive neighbouring SDK coordinates. - - def _task(self, key: str): - return next(task for task in self.profile.motion.tasks if task.key == key) - - def _view(self, name: str): - return next(view for view in self.profile.vision.views if view.name == name) - - def _current_step(self) -> MotionStep | None: - if 0 <= self.step_index < len(self.steps): - return self.steps[self.step_index] - return None - - def _required_roles(self, view: str) -> tuple[str, ...]: - step = self._current_step() - if step is None or step.task_key is None: - return tuple(tag.role for tag in self._view(view).tags) - task = self._task(step.task_key) - if task.view != view: - return () - roles: list[str] = [] - for joint in task.joints: - measurement = self.profile.measurement.measurements[joint] - roles.extend((str(measurement.parent_role), str(measurement.child_role))) - return tuple(dict.fromkeys(roles)) - - def _reset_step_observation_counters( - self, roles: tuple[str, ...] = () - ) -> None: - self.step_required_roles = tuple(roles) - self.step_valid_frames = 0 - self.step_total_frames = 0 - self.step_tag_seen_frames = {role: 0 for role in roles} - self.step_tag_quality_frames = {role: 0 for role in roles} - self.step_all_tags_quality_frames = 0 - self.step_pnp_valid_frames = 0 - self.step_state_sync_frames = 0 - self.step_rejection_counts = {} - required_ids = tuple( - int(tag.tag_id) - for view in self.profile.vision.views - for tag in view.tags - if tag.role in roles - ) - self.latest_recognized_tag_ids = () - self.latest_unrecognized_tag_ids = tuple(sorted(required_ids)) - - def _count_step_rejection(self, reason: str) -> None: - self.step_rejection_counts[reason] = ( - self.step_rejection_counts.get(reason, 0) + 1 - ) - - def _step_observation_metrics(self) -> dict[str, Any]: - total = int(self.step_total_frames) - - def rate(count: int) -> float: - return float(count) / total if total else 0.0 - - seen_by_role = { - role: rate(self.step_tag_seen_frames.get(role, 0)) - for role in self.step_required_roles - } - quality_by_role = { - role: rate(self.step_tag_quality_frames.get(role, 0)) - for role in self.step_required_roles - } - return { - "tag_seen_rate_by_role": seen_by_role, - "tag_detection_rate_by_role": quality_by_role, - # The formal >=95% Tag gate applies to every required Tag, not to - # the product of several independent per-Tag probabilities. - "tag_detection_rate": min(quality_by_role.values(), default=0.0), - "all_tags_quality_rate": rate(self.step_all_tags_quality_frames), - "pnp_valid_rate": rate(self.step_pnp_valid_frames), - "state_sync_rate": rate(self.step_state_sync_frames), - "joint_frame_rate": rate(self.step_valid_frames), - "rejection_counts": dict(sorted(self.step_rejection_counts.items())), - } - - def _detections_callback(self, view: str, message: AprilTagDetectionArray) -> None: - roles = self._required_roles(view) - if not roles or view not in self.camera_matrices: - return - role_by_id = {tag.tag_id: tag.role for tag in self._view(view).tags} - corners: dict[str, np.ndarray] = {} - good: dict[str, bool] = {} - detected_roles: set[str] = set() - quality_failures: dict[str, tuple[str, ...]] = {} - width, height = self.image_sizes.get(view, (0, 0)) - for detection in message.detections: - role = role_by_id.get(int(detection.id)) - if role is None: - continue - detected_roles.add(role) - points = np.asarray( - [[float(point.x), float(point.y)] for point in detection.corners], - dtype=float, - ) - if points.shape != (4, 2): - quality_failures[role] = ("malformed_corners",) - continue - edges = np.linalg.norm(points - np.roll(points, -1, axis=0), axis=1) - full_border = bool( - width > 0 and height > 0 - and np.min(points[:, 0]) >= 2.0 - and np.max(points[:, 0]) <= width - 3.0 - and np.min(points[:, 1]) >= 2.0 - and np.max(points[:, 1]) <= height - 3.0 - ) - failures: list[str] = [] - if int(detection.hamming) > int(self.maximum_hamming): - failures.append("hamming") - if float(detection.decision_margin) < float(self.minimum_decision_margin): - failures.append("decision_margin") - if float(np.min(edges)) < float(self.minimum_edge_pixels): - failures.append("edge_pixels") - if not full_border: - failures.append("image_border") - candidate_good = not failures - # Prefer a good detection if a detector ever emits a duplicate ID. - if role not in corners or candidate_good or not good.get(role, False): - corners[role] = points - good[role] = candidate_good - quality_failures[role] = tuple(failures) - step = self._current_step() - recording_this_view = bool( - step is not None - and self.step_command_sent - and step.recording - and self._task(str(step.task_key)).view == view - ) - active_this_view = bool( - step is not None - and step.task_key is not None - and self._task(str(step.task_key)).view == view - ) - if active_this_view: - id_by_role = { - tag.role: int(tag.tag_id) for tag in self._view(view).tags - } - self.latest_recognized_tag_ids = tuple( - sorted(id_by_role[role] for role in roles if good.get(role, False)) - ) - self.latest_unrecognized_tag_ids = tuple( - sorted( - id_by_role[role] - for role in roles - if not good.get(role, False) - ) - ) - if recording_this_view: - self.step_total_frames += 1 - for role in roles: - if role in detected_roles: - self.step_tag_seen_frames[role] = ( - self.step_tag_seen_frames.get(role, 0) + 1 - ) - else: - self._count_step_rejection(f"tag:{role}:missing") - if good.get(role, False): - self.step_tag_quality_frames[role] = ( - self.step_tag_quality_frames.get(role, 0) + 1 - ) - else: - reasons = quality_failures.get( - role, - ("malformed_corners",) if role in detected_roles else (), - ) - for reason in reasons: - self._count_step_rejection(f"tag:{role}:{reason}") - if all(role in corners and good.get(role, False) for role in roles): - self.step_all_tags_quality_frames += 1 - base_role = next( - tag.role for tag in self._view(view).tags if tag.fixed_reference - ) - if base_role in corners and good.get(base_role, False): - if not recording_this_view: - observations = self.base_corner_observations[view] - observations.append(corners[base_role].copy()) - if len(observations) >= 5: - self.base_corner_reference[view] = np.median( - np.asarray(observations, dtype=float), axis=0 - ) - self.base_drift_counts[view] = 0 - self.latest_base_drift_px[view] = 0.0 - elif view in self.base_corner_reference: - reference = self.base_corner_reference[view] - drift = float( - np.max( - np.linalg.norm(corners[base_role] - reference, axis=1) - ) - ) - self.latest_base_drift_px[view] = drift - self.base_drift_counts[view] = ( - self.base_drift_counts.get(view, 0) + 1 - if drift > float(self.fixed_base_maximum_corner_drift_px) - else 0 - ) - if self.base_drift_counts[view] >= int( - self.fixed_base_movement_confirmation_frames - ): - append_jsonl( - self.raw_path, - { - "kind": "fixed_base_reference_moved", - "view": view, - "tag_id": next( - tag.tag_id - for tag in self._view(view).tags - if tag.fixed_reference - ), - "corner_drift_px": round(drift, 6), - "maximum_corner_drift_px": float( - self.fixed_base_maximum_corner_drift_px - ), - "confirmation_frames": self.base_drift_counts[view], - "task_name": step.task_key, - }, - ) - self._pause( - f"fixed_base_tag_moved:{view}:drift_px={drift:.3f}" - ) - return - # A model may freeze a fixed palm reference before a collision- - # clearance pose intentionally occludes that Tag. The default is an - # empty mapping, so legacy L6/O6 behaviour is byte-for-byte unchanged; - # O12 supplies only its front palm pose for the affected finger tasks. - locked_reference_hook = getattr( - self, "_locked_reference_poses_for_capture", None - ) - locked_reference_poses = ( - dict(locked_reference_hook(view, step) or {}) - if locked_reference_hook is not None - else {} - ) - locked_reference_poses = { - str(role): pose - for role, pose in locked_reference_poses.items() - if str(role) in roles - } - live_roles = tuple( - role for role in roles if role not in locked_reference_poses - ) - if not all( - role in corners and good.get(role, False) - for role in live_roles - ): - return - stamp = _stamp_ns(message.header.stamp) - selected: dict[str, SquareTagPose] = dict(locked_reference_poses) - # Optional model-specific articulated branch selector. Legacy models - # keep the exact independent selection below when the hook is absent. - pose_hook = getattr(self, "_select_articulated_capture_poses", None) - joint_selection = ( - pose_hook(view, step, live_roles, corners, stamp) - if pose_hook is not None else None - ) - if joint_selection is not None: - poses, reason = joint_selection - if poses is None: - if recording_this_view: - self._count_step_rejection(f"pnp:group:{reason}") - return - selected.update(poses) - for role in (() if joint_selection is not None else live_roles): - pose, pnp_reason = self.trackers[view].estimate( - role, - corners[role], - tag_size_m=float(self.tag_size_m), - camera_matrix=self.camera_matrices[view], - stamp_ns=stamp, - ) - if pose is None: - if recording_this_view: - self._count_step_rejection( - f"pnp:{role}:{pnp_reason or 'rejected'}" - ) - return - selected[role] = pose - evidence_hook = getattr(self, "_record_capture_pose_evidence", None) - if evidence_hook is not None: - evidence_hook(view, step, roles, corners, selected, stamp, - locked_roles=tuple(locked_reference_poses)) - if recording_this_view: - self.step_pnp_valid_frames += 1 - self.last_view_valid_at[view] = time.monotonic() - preflight_hook = getattr(self, "_observe_preflight_pose", None) - if preflight_hook is not None and step is not None and step.phase == "preflight": - preflight_hook(view, selected, step) - if step is None or not step.recording or self._task(step.task_key).view != view: - return - matched = interpolate_state_u8( - list(self.state_history), - stamp, - maximum_skew_ns=self.maximum_state_image_skew_ns, - ) - if matched is None: - self._count_step_rejection("state_sync:no_sample_within_limit") - return - state_u8, skew_ns = matched - self.step_state_sync_frames += 1 - task = self._task(step.task_key) - feedback = float(state_u8[task.command_index]) - lower = self.feedback_lower[task.command_index] - upper = self.feedback_upper[task.command_index] - if not lower <= feedback <= upper: - self._count_step_rejection("feedback:outside_registered_feedback_domain") - return - progress = float(np.clip( - self.profile.command.normalize(task.command_index, feedback), - 0.0, - 1.0, - )) - for joint in task.joints: - measurement = self.profile.measurement.measurements[joint] - parent = selected[str(measurement.parent_role)] - child = selected[str(measurement.child_role)] - relative_translation = Rotation.from_quat( - parent.quaternion_xyzw - ).inv().apply( - np.asarray(child.translation_xyz_m, dtype=float) - - np.asarray(parent.translation_xyz_m, dtype=float) - ) - common_from_view = self.extrinsics.transform(view) - parent_matrix = common_from_view @ transform_matrix( - parent.translation_xyz_m, parent.quaternion_xyzw - ) - child_matrix = common_from_view @ transform_matrix( - child.translation_xyz_m, child.quaternion_xyzw - ) - relative = ( - Rotation.from_matrix(parent_matrix[:3, :3]).inv() - * Rotation.from_matrix(child_matrix[:3, :3]) - ) - record = { - "kind": self.sample_kind, - "profile_id": self.profile.key.profile_id, - "task_name": task.key, - "view": view, - "joint": joint, - "sdk_channel": self.command_names[task.command_index], - "motor_index": task.command_index, - "cycle": int(step.cycle), - "direction": str(step.direction), - "attempt": int(step.attempt), - # Keep the established schema contract: this field labels the - # requested sweep endpoint. The live shaped set-point is a - # separate diagnostic and is never used as the fit domain. - "state_image_sync_error_ms": round(abs(skew_ns) / 1_000_000.0, 6), - "relative_quaternion_xyzw": [ - float(value) for value in relative.as_quat() - ], - # G20-compatible geometric observations. The fitted screw - # axes, rather than an electrical endpoint assumption, define - # the two thumb CMC static zeros. - "relative_translation_xyz_m": [ - float(value) for value in relative_translation - ], - "parent_pose_common": matrix_payload(parent_matrix), - "child_pose_common": matrix_payload(child_matrix), - "view_normal_common_xyz": [ - float(value) - for value in ( - common_from_view[:3, :3] - @ np.asarray([0.0, 0.0, 1.0], dtype=float) - ) - ], - "camera_center_common_xyz_m": [ - float(value) for value in common_from_view[:3, 3] - ], - "pnp_reprojection_error_px": round( - max(parent.reprojection_error_px, child.reprojection_error_px), 6 - ), - "image_stamp_ns": stamp, - } - if self.command_unit == "u8": - record.update({ - "requested_command_u8": int(step.target_u8), - "trajectory_command_u8": round(self.step_requested_u8, 6), - "feedback_u8": round(feedback, 6), - "state_u8": [round(float(value), 6) for value in state_u8], - }) - else: - record.update({ - "requested_command_rad": round(float(step.target_u8), 9), - "trajectory_command_rad": round(self.step_requested_u8, 9), - "command_rad": round(self.step_requested_u8, 9), - "feedback_rad": round(feedback, 9), - "state_rad": [round(float(value), 9) for value in state_u8], - "progress_01": round(progress, 9), - }) - self.raw_records.append(record) - append_jsonl(self.raw_path, record) - # This is a frame counter, not a joint-record counter. A frame can - # emit active and passive joint records from the same synchronized - # observation and must still contribute exactly once to the rate. - self.step_valid_frames += 1 - - def _guarded_detections_callback( - self, view: str, message: AprilTagDetectionArray - ) -> None: - with self.step_data_lock: - self.vision_callbacks_inflight += 1 - try: - self._detections_callback(view, message) - finally: - with self.step_data_lock: - self.vision_callbacks_inflight -= 1 - - def _feedback_hz(self) -> float: - if len(self.state_receive_times) < 2: - return 0.0 - elapsed = self.state_receive_times[-1] - self.state_receive_times[0] - return 0.0 if elapsed <= 0 else (len(self.state_receive_times) - 1) / elapsed - - def _command_hz(self) -> float: - if len(self.command_publish_times) < 2: - return 0.0 - elapsed = self.command_publish_times[-1] - self.command_publish_times[0] - return 0.0 if elapsed <= 0 else (len(self.command_publish_times) - 1) / elapsed - - def _publish_torque(self) -> None: - if not self.commands_enabled or self.command_unit != "u8": - return - message = String() - message.data = json.dumps( - { - "setting_cmd": "set_max_torque_limits", - "params": {"hand_type": "right", "torque": [int(self.torque_u8)] * 6}, - } - ) - self.setting_publisher.publish(message) - - def _publish_speed(self, speed: float) -> None: - if self.command_unit != "u8": - self.commanded_speed = float(speed) - return - if not self.commands_enabled or self.commanded_speed == int(speed): - return - message = String() - message.data = json.dumps( - { - "setting_cmd": "set_speed", - "params": {"hand_type": "right", "speed": [int(speed)] * 6}, - } - ) - self.setting_publisher.publish(message) - self.commanded_speed = int(speed) - - def _publish_command(self, values: list[float]) -> None: - if not self.commands_enabled: - return - if len(values) != self.command_count: - raise ValueError("command has the wrong channel count") - bounded = [ - float(np.clip(value, self.command_lower[index], self.command_upper[index])) - for index, value in enumerate(values) - ] - message = JointState() - message.header.stamp = self.get_clock().now().to_msg() - message.name = ( - [] if self.profile.command.feedback_by_index else list(self.command_names) - ) - message.position = bounded - self.command_publisher.publish(message) - self.last_published_command_u8 = tuple(bounded) - self.command_publish_times.append(time.monotonic()) - - def _target_command(self, step: MotionStep) -> tuple[float, ...]: - if step.target_command is not None: - if len(step.target_command) != self.command_count: - raise ValueError("motion-step target has the wrong channel count") - return tuple(float(value) for value in step.target_command) - target = [float(value) for value in self.baseline_command] - if step.task_key is not None: - for index, value in self._task(step.task_key).auxiliary_commands: - target[int(index)] = float(value) - if step.command_index is not None: - target[step.command_index] = float(step.target_u8) - return tuple(target) - - def _begin_step(self, step: MotionStep) -> None: - if not self._uses_isolated_motion_callbacks(): - self._begin_step_without_vision_callback(step) - return - assert self.step_data_lock is not None - with self.step_data_lock: - if not self.vision_callbacks_inflight: - self._begin_step_without_vision_callback(step) - - def _begin_step_without_vision_callback(self, step: MotionStep) -> None: - now = time.monotonic() - if self.commanded_speed != step.speed_u8: - self._publish_speed(step.speed_u8) - self.step_speed_ready_at = now + float(self.speed_settle_seconds) - self.reason = f"setting_speed:{step.speed_u8}" - return - if now < self.step_speed_ready_at: - return - if len(self.latest_state_u8) != self.command_count: - return - self.step_start_feedback_u8 = tuple( - float(value) for value in self.latest_state_u8 - ) - # A radian feedback value is an observation to calibrate, not the last - # command that was sent. O12 can report a stable non-zero feedback at - # command zero, so trajectories must remain entirely in command space. - self.step_start_state_u8 = ( - tuple(self.last_published_command_u8) - if self.command_unit == "rad" - and self.last_published_command_u8 is not None - else self.step_start_feedback_u8 - ) - self.step_started_at = now - self.step_last_progress_at = now - self.step_last_feedback = ( - float(self.latest_state_u8[step.command_index]) - if step.command_index is not None and len(self.latest_state_u8) == self.command_count - else ( - float(np.mean(self.latest_state_u8)) - if len(self.latest_state_u8) == self.command_count - else float("nan") - ) - ) - self.step_initial_feedback = self.step_last_feedback - self.step_requested_u8 = self.step_initial_feedback - target = list(self._target_command(step)) - errors = [ - abs(end - start) - for start, end in zip(self.step_start_state_u8, target) - ] - self.step_moving_indices = frozenset( - index for index, error in enumerate(errors) - if error > float(self.non_target_motion_tolerance_u8) - ) - command_distance = ( - float(sum(errors)) - if step.command_index is None - else float(errors[step.command_index]) - ) - self.step_last_distance_u8 = ( - 0.0 if self.command_unit == "rad" else command_distance - ) - self.step_initial_distance_u8 = command_distance - maximum_distance = max(errors) - if self.command_unit == "rad": - _, _, self.step_trajectory_duration_seconds = ( - self._radian_trajectory_fraction( - maximum_distance, 0.0, float(step.speed_u8) - ) - ) - else: - _, _, self.step_trajectory_duration_seconds = cosine_position_trajectory_u8( - 0.0, maximum_distance, 0.0, - float(self.command_trajectory_full_range_seconds), - ) - self.step_trajectory_phase = 0.0 - self.step_trajectory_blend = 0.0 - self.step_last_command_u8 = None - self.step_hold_since = None - roles: tuple[str, ...] = () - if step.recording and step.task_key is not None: - roles = self._required_roles(self._task(step.task_key).view) - self._reset_step_observation_counters(roles) - self.step_command_sent = True - self._advance_step_trajectory(step, now) - self.reason = ( - f"{step.phase}:{step.task_key or 'all'}:cycle={step.cycle}:" - f"direction={step.direction}:target={step.target_u8}:attempt={step.attempt}" - ) - - def _radian_trajectory_fraction( - self, distance: float, elapsed: float, maximum_speed: float - ) -> tuple[float, float, float]: - """Return blend, phase and duration for the default cosine profile.""" - if distance <= 0.0: - return 1.0, 1.0, 0.0 - duration = math.pi * float(distance) / (2.0 * float(maximum_speed)) - phase = min(1.0, max(0.0, float(elapsed) / duration)) - return 0.5 - 0.5 * math.cos(math.pi * phase), phase, duration - - def _advance_step_trajectory(self, step: MotionStep, now: float) -> None: - if hasattr(self, "_target_command"): - target_values = self._target_command(step) - else: - # Compatibility for the isolated legacy trajectory unit harness. - target = [255.0] * len(self.step_start_state_u8) - if step.command_index is not None: - target[step.command_index] = float(step.target_u8) - target_values = tuple(target) - elapsed = max(0.0, float(now) - self.step_started_at) - if getattr(self, "command_unit", "u8") == "rad": - maximum_distance = max( - abs(target - start) - for start, target in zip(self.step_start_state_u8, target_values) - ) - blend, phase, duration = self._radian_trajectory_fraction( - maximum_distance, elapsed, float(step.speed_u8) - ) - else: - duration = float( - getattr( - self, - "step_trajectory_duration_seconds", - self.command_trajectory_full_range_seconds, - ) - ) - phase = 1.0 if duration <= 0.0 else min(1.0, elapsed / duration) - blend = 0.5 - 0.5 * math.cos(math.pi * phase) - values = [ - start + (target - start) * blend - for start, target in zip(self.step_start_state_u8, target_values) - ] - command = tuple( - int(np.clip(round(value), 0, 255)) - if getattr(self, "command_unit", "u8") == "u8" - else float(np.clip(value, self.command_lower[index], self.command_upper[index])) - for index, value in enumerate(values) - ) - self.step_trajectory_phase = phase - self.step_trajectory_blend = blend - self.step_requested_u8 = ( - float(np.mean(values)) - if step.command_index is None - else float(values[step.command_index]) - ) - # O12 joint feedback is request-driven: every radian JointState command - # triggers both the control write and a fresh readback. Keep streaming - # the unchanged endpoint during holds so the next step never starts - # from a stale feedback sample. Legacy byte profiles retain their - # existing duplicate suppression. - if ( - getattr(self, "command_unit", "u8") == "rad" - or command != self.step_last_command_u8 - ): - self._publish_command(list(command)) - self.step_last_command_u8 = command - - def _qualify_recording_step(self, step: MotionStep) -> None: - task = self._task(str(step.task_key)) - rows = [ - row for row in self.raw_records - if row["task_name"] == task.key - and int(row["cycle"]) == int(step.cycle) - and row["direction"] == step.direction - and int(row["attempt"]) == step.attempt - and row["joint"] == task.joints[0] - ] - command_unit = getattr(self, "command_unit", "u8") - feedback_field = "feedback_u8" if command_unit == "u8" else "feedback_rad" - feedback = np.asarray([float(row[feedback_field]) for row in rows]) - if command_unit == "u8": - bins = sorted(set(int(round(value)) for value in feedback)) - span = float(np.ptp(feedback)) if feedback.size else 0.0 - required_span = float(self.minimum_state_span_u8) - gap = max((right - left for left, right in zip(bins, bins[1:])), default=256) - maximum_gap = float(self.maximum_bin_gap) - else: - normalized_bin_count = int( - getattr(self, "normalized_sweep_bin_count", 32) - ) - if normalized_bin_count < 32: - raise ValueError("normalized sweep bin count must be at least 32") - normalized = sorted( - set( - min( - normalized_bin_count - 1, - max( - 0, - int( - self.profile.command.normalize( - task.command_index, value - ) * normalized_bin_count - ), - ), - ) - for value in feedback - ) - ) - bins = normalized - span = ( - float(np.ptp([self.profile.command.normalize(task.command_index, value) for value in feedback])) - if feedback.size else 0.0 - ) - span_policy = getattr( - self, "_required_radian_feedback_span_fraction", None - ) - required_span = ( - float(self.minimum_state_span_u8) - if span_policy is None - else float(span_policy(task, step, feedback)) - ) - gap = max( - (right - left for left, right in zip(bins, bins[1:])), - default=normalized_bin_count, - ) - maximum_gap = float(self.maximum_bin_gap) - observation = self._step_observation_metrics() - detection_rate = float(observation["tag_detection_rate"]) - joint_frame_rate = float(observation["joint_frame_rate"]) - progress = ( - [self.profile.command.normalize(task.command_index, value) for value in feedback] - if command_unit == "rad" - else [float(value) / 255.0 for value in feedback] - ) - engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile)) - decision = engine.evaluate_sweep( - progress, - minimum_span=(required_span if command_unit == "rad" else required_span / 255.0), - total_frames=self.step_total_frames, - joint_frame_rate=joint_frame_rate, - feedback_hz=self._feedback_hz(), - detection_rate=detection_rate, - bin_count=( - int(getattr(self, "normalized_sweep_bin_count", 256)) - if command_unit == "rad" else 256 - ), - ) - failures = list(decision.failures) - append_jsonl( - self.raw_path, - { - "kind": getattr( - self, "sweep_quality_kind", "l6_sweep_observation_quality" - ), - "task_name": step.task_key, - "cycle": step.cycle, - "direction": step.direction, - "attempt": step.attempt, - "valid_frames": len(rows), - "total_frames": self.step_total_frames, - "feedback_bins": len(bins), - "feedback_span": round(span, 9), - "required_feedback_span": round(required_span, 9), - "maximum_bin_gap": gap, - **observation, - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "warnings": list(decision.warnings), - "failures": failures, - }, - ) - if failures: - raise ValueError(",".join(failures)) - - def _retry_step(self, step: MotionStep, reason: str) -> bool: - key = (str(step.task_key), int(step.cycle), str(step.direction)) - retries = self.retry_counts.get(key, 0) - if not CalibrationEngine.permits_retry("sweep_acquisition", retries): - return False - attempt = retries + 2 - self.retry_counts[key] = retries + 1 - task = self._task(str(step.task_key)) - start = task.start_value if step.direction == "decreasing" else task.end_value - engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile)) - retry_speed = engine.retry_speed(step.speed_u8, attempt) - # Preserve model-specific motion metadata on engine-generated retry - # steps. O12 extends MotionStep with clearance/probe fields; replacing - # it with the legacy L6 class makes the next common timer tick lose - # that contract and can terminate the node at the retry boundary. - step_type = type(step) - replacement = [ - step_type("retry_prepare", step.task_key, step.command_index, start, retry_speed, step.cycle, attempt=attempt), - step_type("sweep", step.task_key, step.command_index, step.target_u8, retry_speed, step.cycle, step.direction, attempt), - ] - self.steps[self.step_index + 1:self.step_index + 1] = replacement - append_jsonl( - self.raw_path, - { - "kind": "automatic_rescan", - "task_name": step.task_key, - "cycle": step.cycle, - "direction": step.direction, - "failed_attempt": step.attempt, - "next_attempt": attempt, - "reason": reason, - }, - ) - return True - - def _finish_step(self, step: MotionStep) -> None: - if not self._uses_isolated_motion_callbacks(): - self._finish_step_without_vision_callback(step) - return - assert self.step_data_lock is not None - with self.step_data_lock: - if not self.vision_callbacks_inflight: - self._finish_step_without_vision_callback(step) - - def _finish_step_without_vision_callback(self, step: MotionStep) -> None: - if step.recording: - try: - self._qualify_recording_step(step) - except ValueError as error: - if not self._retry_step(step, str(error)): - self._pause(f"sweep_quality_failed:{step.task_key}:{error}") - return - self.step_index += 1 - self.step_command_sent = False - if self.step_index >= len(self.steps): - self._finalize() - - def _radian_feedback_travel(self, step: MotionStep) -> float: - """Measure motion in feedback space without comparing it to commands.""" - if ( - len(self.latest_state_u8) != self.command_count - or len(self.step_start_feedback_u8) != self.command_count - ): - return 0.0 - if step.command_index is not None: - index = int(step.command_index) - return abs( - float(self.latest_state_u8[index]) - - float(self.step_start_feedback_u8[index]) - ) - return float(sum( - abs( - float(self.latest_state_u8[index]) - - float(self.step_start_feedback_u8[index]) - ) - for index in self.step_moving_indices - )) - - def _minimum_radian_feedback_travel(self, step: MotionStep) -> float: - """Return the safety evidence required before a non-recording move ends.""" - command_distance = float(self.step_initial_distance_u8) - if command_distance <= 0.005 or step.recording: - return 0.0 - if step.phase == "preflight": - # The model-specific visual hook performs the stronger axis and - # direction check after this electrical movement evidence. - return min(0.004, 0.25 * command_distance) - # Baseline/prepare/clearance/return must make most of their requested - # move, but no absolute command-vs-feedback equality is assumed. - return 0.60 * command_distance - - def _tick_radian_motion(self, step: MotionStep, now: float) -> None: - """Advance a radian step with command/feedback domains kept separate.""" - travel = self._radian_feedback_travel(step) - if travel >= float(self.step_last_distance_u8) + 0.001: - self.step_last_distance_u8 = travel - self.step_last_progress_at = now - self.step_hold_since = None - - command_distance = float(self.step_initial_distance_u8) - commanded_travel = command_distance * self.step_trajectory_blend - if self.step_trajectory_phase < 1.0: - # Only call it a stall while the command trajectory is demanding - # meaningful motion and feedback has provided almost none. - if ( - commanded_travel > 0.02 - and travel < 0.10 * commanded_travel - and now - self.step_last_progress_at - > float(self.motor_stall_timeout_seconds) - ): - self._pause(f"mechanical_stall:{self.reason}") - return - - minimum_travel = self._minimum_radian_feedback_travel(step) - if travel + 0.001 < minimum_travel: - if ( - now - self.step_last_progress_at - > float(self.motor_stall_timeout_seconds) - ): - self._pause(f"mechanical_stall:{self.reason}") - return - - if self.step_hold_since is None: - self.step_hold_since = now - return - if now - self.step_hold_since >= float(self.endpoint_hold_seconds): - self._finish_step(step) - - def _tick(self) -> None: - if self.state in {"PASSED", "PAUSED", "ABORTED"}: - return - state_publisher_count = self.count_publishers(self.state_topic) - if state_publisher_count > 1: - self._pause( - f"multiple_state_publishers:count={state_publisher_count}" - ) - return - command_publisher_count = self.count_publishers(self.command_topic) - if command_publisher_count > 1: - self._pause( - f"multiple_command_publishers:count={command_publisher_count}" - ) - return - if not self.started: - if len(self.latest_state_u8) == self.command_count and set(self.camera_matrices) == set( - self.profile.vision.view_names - ): - self.state = "READY" - self.reason = "ready_for_operator_start" - return - step = self._current_step() - if step is None: - return - if not self.step_command_sent: - self._begin_step(step) - return - now = time.monotonic() - self._advance_step_trajectory(step, now) - # Absolute duration is diagnostic only. A slow but continuously - # progressing joint is valid; the two-second no-progress watchdog is - # the motion safety gate. - if len(self.latest_state_u8) != self.command_count: - return - if self.command_unit == "rad": - self._tick_radian_motion(step, now) - return - target_state = list(self._target_command(step)) - endpoint_errors = [ - abs(value - target_state[index]) - for index, value in enumerate(self.latest_state_u8) - ] - actual = ( - max(endpoint_errors) - if step.command_index is None - else endpoint_errors[step.command_index] - ) - progress_distance = ( - float(sum(endpoint_errors)) - if step.command_index is None - else float(endpoint_errors[step.command_index]) - ) - feedback = ( - float(np.mean(self.latest_state_u8)) - if step.command_index is None - else float(self.latest_state_u8[step.command_index]) - ) - self.step_last_feedback = feedback - # Baseline recovery moves all six channels at once. Using their mean - # as the progress signal divides a one-count move by six and can turn - # slow, valid O6 motion into a false two-second stall. Distance to the - # final target also rejects movement in the wrong direction. - if ( - not math.isfinite(self.step_last_distance_u8) - or progress_distance <= self.step_last_distance_u8 - ( - 0.5 if self.command_unit == "u8" else 0.002 - ) - ): - self.step_last_distance_u8 = progress_distance - self.step_last_progress_at = now - if ( - actual > float(self.endpoint_tolerance_u8) - and now - self.step_started_at > 1.0 - and now - self.step_last_progress_at > float(self.motor_stall_timeout_seconds) - ): - self._pause(f"mechanical_stall:{self.reason}") - return - if actual > float(self.endpoint_tolerance_u8): - self.step_hold_since = None - return - if self.step_trajectory_phase < 1.0: - self.step_hold_since = None - return - if self.step_hold_since is None: - self.step_hold_since = now - return - if now - self.step_hold_since >= float(self.endpoint_hold_seconds): - self._finish_step(step) - - def _finalize(self) -> None: - try: - payload, _fit, correction = self.finalize_session( - session_dir=self.session_dir, - serial_number=self.serial_number, - source_urdf=self.source_urdf, - protected_inputs=self.protected_inputs, - records=self.raw_records, - publish=True, - ) - except BaseException as error: - atomic_write_json( - self.session_dir / "failure_diagnostic.json", - { - "profile_id": self.profile.key.profile_id, - "reason": str(error), - "traceback": traceback.format_exc(), - "source_urdf_unchanged": True, - }, - ) - self._pause(f"fit_or_publication_failed:{error}") - return - self._publish_command(list(self.baseline_command)) - self.state = "PASSED" - self.reason = ( - "partial_calibration_passed" - if self.profile.scope.default_scope != "full" - else "full_calibration_passed" - ) - self.final_json = str( - self.session_dir - / self.profile.artifacts.calibration_filename.format( - serial_number=self.serial_number - ) - ) - self.final_urdf = str(correction.path) - self.final_quality = payload["quality"] - - def _pause(self, reason: str) -> None: - if self.state in {"PAUSED", "ABORTED", "PASSED"}: - return - self.state = "PAUSED" - self.reason = str(reason) - # In radian mode, hold the last command rather than feeding an - # uncalibrated feedback value back into the command domain. - if ( - self.command_unit == "rad" - and self.last_published_command_u8 is not None - ): - self._publish_command(list(self.last_published_command_u8)) - elif len(self.latest_state_u8) == self.command_count: - self._publish_command( - [ - int(np.clip(round(value), 0, 255)) - if self.command_unit == "u8" else float(value) - for value in self.latest_state_u8 - ] - ) - step = self._current_step() - target = [] if step is None else list(self._target_command(step)) - errors = ( - [] - if len(target) != len(self.latest_state_u8) - else [ - abs(float(actual) - float(expected)) - for actual, expected in zip(self.latest_state_u8, target) - ] - ) - append_jsonl(self.raw_path, { - "kind": "paused", - "reason": self.reason, - "command_unit": self.command_unit, - f"latest_state_{self.command_unit}": list(self.latest_state_u8), - f"target_state_{self.command_unit}": target, - f"channel_errors_{self.command_unit}": errors, - "maximum_error_channel": ( - None - if not errors - else self.command_names[int(np.argmax(errors))] - ), - f"maximum_error_{self.command_unit}": ( - None if not errors else max(errors) - ), - }) - - def _status(self) -> dict[str, Any]: - step = self._current_step() - feedback = float("nan") - if len(self.latest_state_u8) == self.command_count: - feedback = ( - float(np.mean(self.latest_state_u8)) - if step is None or step.command_index is None - else float(self.latest_state_u8[step.command_index]) - ) - target_state: list[float] = [] - channel_errors: list[float] = [] - if step is not None: - target_state = list(self._target_command(step)) - if len(self.latest_state_u8) == self.command_count: - channel_errors = [ - abs(value - target_state[index]) - for index, value in enumerate(self.latest_state_u8) - ] - step_fraction = 0.0 - if step is not None and math.isfinite(feedback): - if self.command_unit == "rad": - step_fraction = self.step_trajectory_blend - else: - current_distance = ( - float(sum(channel_errors)) - if step.command_index is None - else channel_errors[step.command_index] - ) - initial_distance = self.step_initial_distance_u8 - if math.isfinite(initial_distance) and initial_distance > 0.0: - step_fraction = 1.0 - current_distance / initial_distance - elif current_distance <= float(self.endpoint_tolerance_u8): - step_fraction = 1.0 - step_fraction = float(np.clip(step_fraction, 0.0, 1.0)) - elif self.state == "PASSED": - step_fraction = 1.0 - observation = self._step_observation_metrics() - return { - "profile_id": self.profile.key.profile_id, - "state": self.state, - "reason": self.reason, - "serial_number": self.serial_number, - "session_dir": str(self.session_dir), - "step_index": self.step_index, - "step_count": len(self.steps), - "task_name": None if step is None else step.task_key, - "phase": None if step is None else step.phase, - "cycle": None if step is None else step.cycle, - "direction": None if step is None else step.direction, - "attempt": None if step is None else step.attempt, - "motor_index": None if step is None else step.command_index, - "command_unit": self.command_unit, - "speed_u8": ( - None if step is None or self.command_unit != "u8" else int(step.speed_u8) - ), - "speed_rad_s": ( - None if step is None or self.command_unit != "rad" else round(float(step.speed_u8), 6) - ), - "target_u8": ( - None if step is None or self.command_unit != "u8" else step.target_u8 - ), - "target_rad": ( - None if step is None or self.command_unit != "rad" else round(float(step.target_u8), 9) - ), - "current_command_u8": ( - None - if step is None or not math.isfinite(self.step_requested_u8) - else round(self.step_requested_u8, 3) - ), - "actual_u8": None if not math.isfinite(feedback) else round(feedback, 3), - "step_fraction": round(step_fraction, 6), - "valid_frames": self.step_valid_frames, - "total_frames": self.step_total_frames, - "tag_detection_rate": round( - float(observation["tag_detection_rate"]), 6 - ), - "tag_detection_rate_by_role": { - role: round(float(rate), 6) - for role, rate in observation["tag_detection_rate_by_role"].items() - }, - "tag_seen_rate_by_role": { - role: round(float(rate), 6) - for role, rate in observation["tag_seen_rate_by_role"].items() - }, - "recognized_tag_ids": list(self.latest_recognized_tag_ids), - "unrecognized_tag_ids": list(self.latest_unrecognized_tag_ids), - "all_tags_quality_rate": round( - float(observation["all_tags_quality_rate"]), 6 - ), - "pnp_valid_rate": round(float(observation["pnp_valid_rate"]), 6), - "state_sync_rate": round(float(observation["state_sync_rate"]), 6), - "joint_frame_rate": round(float(observation["joint_frame_rate"]), 6), - "observation_rejection_counts": observation["rejection_counts"], - "preflight_speed_u8": int(self.preflight_speed_u8), - "baseline_speed_u8": int(self.baseline_speed_u8), - "formal_speed_u8": int(self.formal_speed_u8), - "command_trajectory_full_range_seconds": float( - self.command_trajectory_full_range_seconds - ), - "command_trajectory_phase": round(self.step_trajectory_phase, 6), - "command_trajectory_duration_seconds": round( - self.step_trajectory_duration_seconds, 6 - ), - "base_corner_drift_px": { - view: round(value, 4) - for view, value in self.latest_base_drift_px.items() - }, - "base_corner_drift_limit_px": float( - self.fixed_base_maximum_corner_drift_px - ), - "feedback_hz": round(self._feedback_hz(), 3), - "command_publish_hz": round(self._command_hz(), 3), - "camera_info_views": sorted(self.camera_matrices), - "state_publisher_count": self.count_publishers(self.state_topic), - "command_publisher_count": self.count_publishers(self.command_topic), - "command_names": list(self.command_names), - f"latest_state_{self.command_unit}": list(self.latest_state_u8), - f"target_state_{self.command_unit}": [round(value, 6) for value in target_state], - f"current_command_state_{self.command_unit}": ( - [] - if self.step_last_command_u8 is None - else list(self.step_last_command_u8) - ), - f"channel_errors_{self.command_unit}": [round(value, 6) for value in channel_errors], - "maximum_error_channel": ( - None - if not channel_errors - else self.command_names[int(np.argmax(channel_errors))] - ), - f"maximum_error_{self.command_unit}": ( - None if not channel_errors else round(max(channel_errors), 3) - ), - "final_json": getattr(self, "final_json", ""), - "final_urdf": getattr(self, "final_urdf", ""), - "quality": getattr(self, "final_quality", {}), - } - - def _publish_status(self) -> None: - message = String() - message.data = json.dumps(self._status(), ensure_ascii=False) - self.status_publisher.publish(message) - - -def main(args: list[str] | None = None) -> None: - rclpy.init(args=args) - node: L6ThreeCameraCalibrationNode | None = None - try: - node = L6ThreeCameraCalibrationNode() - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - - -__all__ = ["L6ThreeCameraCalibrationNode", "MotionStep", "main"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/pipeline.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/pipeline.py deleted file mode 100644 index e66d348..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/pipeline.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Shared online/offline finalization path for one L6 partial session.""" - -from __future__ import annotations - -from datetime import datetime -import json -import math -from pathlib import Path -from typing import Any, Mapping, Sequence - -from ...runtime.engine import CalibrationEngine - -from .artifacts import ( - artifact_hashes, - atomic_write_json, - build_l6_runtime_payload, - build_l6_urdf_input_payload, - load_l6_urdf_input, - publish_partial_session, -) -from .fitting import L6FitResult, fit_l6_session -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - CORRECTED_PASSIVE_JOINTS, - MEASURED_PASSIVE_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, - build_typed_profile, -) -from .urdf import L6UrdfCorrection, write_l6_corrected_urdf - - -MEASURED_JOINTS = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS -ENDPOINT_SNAP_TOLERANCE_U8 = 2 - - -def canonical_feedback_command_u8(feedback_u8: float) -> int: - """Map a reached L6 feedback endpoint onto the curve's 0/255 domain.""" - value = int(round(float(feedback_u8))) - if value <= ENDPOINT_SNAP_TOLERANCE_U8: - return 0 - if value >= 255 - ENDPOINT_SNAP_TOLERANCE_U8: - return 255 - return value - - -def accepted_records_by_joint( - records: Sequence[Mapping[str, Any]], -) -> dict[str, list[dict[str, Any]]]: - """Select the newest attempt for every task/cycle/direction.""" - samples = [ - dict(row) - for row in records - if row.get("kind") == "l6_joint_sample" - and str(row.get("joint", "")) in MEASURED_JOINTS - ] - latest_attempt: dict[tuple[str, int, str], int] = {} - for row in samples: - key = ( - str(row["task_name"]), - int(row["cycle"]), - str(row["direction"]), - ) - latest_attempt[key] = max( - latest_attempt.get(key, 0), int(row.get("attempt", 1)) - ) - result = {name: [] for name in MEASURED_JOINTS} - for row in samples: - key = ( - str(row["task_name"]), - int(row["cycle"]), - str(row["direction"]), - ) - if int(row.get("attempt", 1)) != latest_attempt[key]: - continue - accepted = { - "cycle": int(row["cycle"]), - "direction": str(row["direction"]), - # The reusable fitter calls the independent variable - # command_u8; schema v6 deliberately supplies measured SDK - # feedback here, never the requested controller set-point. - "command_u8": canonical_feedback_command_u8( - float(row["feedback_u8"]) - ), - "feedback_u8": float(row["feedback_u8"]), - "relative_quaternion_xyzw": list( - row["relative_quaternion_xyzw"] - ), - } - # Schema-v6.1 adds the G20-compatible pose trajectory required for - # absolute thumb CMC zero recovery. Keep the projection here so the - # online and offline finalizers consume byte-equivalent fitting rows. - geometric_fields = ( - "relative_translation_xyz_m", - "parent_pose_common", - "child_pose_common", - "view_normal_common_xyz", - "camera_center_common_xyz_m", - "state_u8", - ) - if any(field in row for field in geometric_fields): - missing = [field for field in geometric_fields if field not in row] - if missing: - raise ValueError( - "L6 geometric sample is incomplete: " + ",".join(missing) - ) - for field in geometric_fields: - value = row[field] - accepted[field] = ( - dict(value) if isinstance(value, Mapping) else list(value) - ) - result[str(row["joint"])].append(accepted) - return result - - -def load_l6_raw_samples(path: str | Path) -> list[dict[str, Any]]: - source = Path(path).expanduser().resolve() - if not source.is_file(): - raise ValueError(f"raw L6 sample file does not exist: {source}") - rows: list[dict[str, Any]] = [] - with source.open("r", encoding="utf-8") as stream: - for line_number, line in enumerate(stream, 1): - if not line.strip(): - continue - try: - value = json.loads(line) - except json.JSONDecodeError as error: - raise ValueError( - f"invalid L6 JSONL record at line {line_number}" - ) from error - if not isinstance(value, Mapping): - raise ValueError(f"L6 JSONL line {line_number} is not an object") - rows.append(dict(value)) - return rows - - -def finalize_l6_session( - *, - session_dir: str | Path, - serial_number: str, - source_urdf: str | Path, - protected_inputs: Mapping[str, str], - records: Sequence[Mapping[str, Any]], - publish: bool = True, - timestamp: str | None = None, -) -> tuple[dict[str, Any], L6FitResult, L6UrdfCorrection]: - directory = Path(session_dir).expanduser().resolve() - directory.mkdir(parents=True, exist_ok=True) - result = fit_l6_session( - source_urdf, - accepted_records_by_joint(records), - require_thumb_axis_zero=True, - ) - CalibrationEngine(build_typed_profile()).result_from_fit( - result, - transfers={ - **TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - **TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, - }, - ) - # Validate the complete runtime schema before materializing any corrected - # URDF. A fit/schema rejection therefore leaves only the node's failure - # diagnostic and the immutable raw samples. - payload = build_l6_runtime_payload( - serial_number=serial_number, - source_urdf=source_urdf, - result=result, - protected_inputs=protected_inputs, - passed=True, - ) - urdf_input_path = ( - directory / f"l6_right_{serial_number}_urdf_correction_input.json" - ) - atomic_write_json( - urdf_input_path, - build_l6_urdf_input_payload( - serial_number=serial_number, - source_urdf=source_urdf, - result=result, - ), - ) - urdf_result = load_l6_urdf_input( - urdf_input_path, - source_urdf=source_urdf, - serial_number=serial_number, - ) - stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") - correction = write_l6_corrected_urdf( - source_urdf=source_urdf, - output_directory=directory, - serial_number=serial_number, - result=urdf_result, - timestamp=stamp, - ) - json_path = directory / f"l6_right_{serial_number}_partial_calibration.json" - atomic_write_json(json_path, payload) - summary = { - "schema_version": 1, - "profile_id": "L6/right/l6_right_8/v1", - "serial_number": str(serial_number), - "result": "PARTIAL_PASS", - "publication_pointer": "latest_partial_passed", - "calibrated_active_joints": sorted(CALIBRATED_ACTIVE_JOINTS), - "active_zero_methods": dict(sorted(result.zero_method_by_joint.items())), - "active_zero_fallback_reasons": dict( - sorted(result.zero_fallback_reason_by_joint.items()) - ), - "thumb_axis_zero": ( - None - if result.thumb_zero_result is None - else { - "offsets_rad": { - name: round(float(value), 10) - for name, value in sorted( - result.thumb_zero_result.direct_offsets_rad.items() - ) - }, - "axis_line_rms_m": round( - float(result.thumb_zero_result.axis_line_rms_m), 10 - ), - "validation_error_by_joint_rad": { - name: round(float(value), 10) - for name, value in sorted( - result.thumb_zero_result.validation_error_by_joint_rad.items() - ) - }, - } - ), - "measured_passive_joints": sorted(MEASURED_PASSIVE_JOINTS), - "transferred_active_joints": dict( - sorted(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items()) - ), - "transferred_passive_joints": dict( - sorted(TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items()) - ), - "passive_coupling": { - name: { - "model": fit.model, - "transferred_from_joint": ( - donor if donor != name else None - ), - "coefficients": [ - round(float(value), 10) for value in fit.coefficients - ], - "urdf_mimic_enabled": True, - "urdf_mimic_multiplier": round( - float(fit.urdf_mimic_multiplier), 10 - ), - "urdf_mimic_policy": fit.urdf_mimic_policy, - "residual_p95_deg": round( - math.degrees(float(fit.residual_p95_rad)), 6 - ), - "residual_max_deg": round( - math.degrees(float(fit.residual_max_rad)), 6 - ), - "cycle_prediction_range_deg": round( - math.degrees( - float(fit.maximum_cycle_prediction_range_rad) - ), - 6, - ), - } - for name, donor in sorted( - { - target: TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get( - target, target - ) - for target in CORRECTED_PASSIVE_JOINTS - }.items() - ) - for fit in (result.mimic_fits[donor],) - }, - "explicit_runtime_joints": sorted( - correction.explicit_runtime_joints - ), - "artifacts": { - "json": json_path.name, - "urdf": correction.path.name, - **artifact_hashes(json_path, correction.path), - }, - } - atomic_write_json(directory / "calibration_summary_zh.json", summary) - if publish: - publish_partial_session(directory.parent, directory) - return payload, result, correction - - -__all__ = [ - "ENDPOINT_SNAP_TOLERANCE_U8", - "MEASURED_JOINTS", - "accepted_records_by_joint", - "canonical_feedback_command_u8", - "finalize_l6_session", - "load_l6_raw_samples", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/profile.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/profile.py deleted file mode 100644 index 580acdd..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/profile.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Reviewed partial-calibration profile for the right L6 eight-Tag rig.""" - -from __future__ import annotations - -from ...core import ( - ArtifactPolicy, - CalibrationProfile, - CommandLayout, - MeasurementPolicy, - MeasurementSpec, - MotionPolicy, - ProfileKey, - QualityPolicy, - ScopePolicy, - TagSpec, - TaskSpec, - ViewSpec, - VisionRigSpec, - ZeroSolvePolicy, -) -from ..registry import EngineBindings, RegisteredProfile -from .motion import ( - build_calibration_motion_command, - build_calibration_preparation_waypoints, - build_calibration_return_waypoints, -) - - -KEY = ProfileKey("L6", "right", "l6_right_8", 1) - -COMMAND_NAMES: tuple[str, ...] = ( - "thumb_cmc_pitch", - "thumb_cmc_roll", - "index_mcp_pitch", - "middle_mcp_pitch", - "ring_mcp_pitch", - "pinky_mcp_pitch", -) - -ACTIVE_JOINTS: tuple[str, ...] = ( - "rh_thumb_cmc_pitch", - "rh_thumb_cmc_roll", - "rh_index_mcp_pitch", - "rh_middle_mcp_pitch", - "rh_ring_mcp_pitch", - "rh_pinky_mcp_pitch", -) -PASSIVE_JOINTS: tuple[str, ...] = ( - "rh_thumb_dip", - "rh_index_dip", - "rh_middle_dip", - "rh_ring_dip", - "rh_pinky_dip", -) -CALIBRATED_ACTIVE_JOINTS = frozenset( - { - "rh_thumb_cmc_pitch", - "rh_thumb_cmc_roll", - "rh_pinky_mcp_pitch", - } -) -MEASURED_PASSIVE_JOINTS = frozenset( - {"rh_thumb_dip", "rh_pinky_dip"} -) - -# The four L6 fingers use the same six-channel mechanism. This profile has -# visual Tags only on the pinky, so the remaining three fingers deliberately -# inherit the pinky's measured travel, feedback curves, and passive coupling -# while retaining their own CAD frames and geometry. The shared MCP zero is -# anchored at the observed open endpoint; it is not inferred by forcing the -# measured closed travel back onto the shorter CAD upper limit. -TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = { - "rh_index_mcp_pitch": "rh_pinky_mcp_pitch", - "rh_middle_mcp_pitch": "rh_pinky_mcp_pitch", - "rh_ring_mcp_pitch": "rh_pinky_mcp_pitch", -} -TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = { - "rh_index_dip": "rh_pinky_dip", - "rh_middle_dip": "rh_pinky_dip", - "rh_ring_dip": "rh_pinky_dip", -} -CORRECTED_ACTIVE_JOINTS = frozenset( - CALIBRATED_ACTIVE_JOINTS | TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.keys() -) -CORRECTED_PASSIVE_JOINTS = frozenset( - MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys() -) - -ENDPOINT_ANCHOR_BY_JOINT = { - # Thumb pitch normally uses serial-axis geometry. If that observation is - # incompatible with the source link geometry, align the centre of the - # measured physical range with the centre of the source CAD range. Real - # endpoint comparison showed lower_at_start slightly under-corrected and - # upper_at_end over-corrected this serial, so neither endpoint is an - # independently trustworthy absolute datum. - "rh_thumb_cmc_pitch": "cad_range_center", - # Feedback 255 is the repeatable open/lower endpoint. The measured travel - # is about 70.55 deg while the source CAD upper is 65 deg. Anchoring the - # closed endpoint therefore introduced a -5.55 deg offset into all four - # fingers and made intermediate pinch poses systematically under-flexed. - "rh_pinky_mcp_pitch": "lower_at_start", -} - -COMMAND_INDEX_BY_JOINT = { - "rh_thumb_cmc_pitch": 0, - "rh_thumb_cmc_roll": 1, - "rh_index_mcp_pitch": 2, - "rh_middle_mcp_pitch": 3, - "rh_ring_mcp_pitch": 4, - "rh_pinky_mcp_pitch": 5, -} - -MIMIC_SOURCE_BY_JOINT = { - "rh_thumb_dip": "rh_thumb_cmc_pitch", - "rh_index_dip": "rh_index_mcp_pitch", - "rh_middle_dip": "rh_middle_mcp_pitch", - "rh_ring_dip": "rh_ring_mcp_pitch", - "rh_pinky_dip": "rh_pinky_mcp_pitch", -} - -COUPLING_MODEL_BY_JOINT = { - "rh_thumb_dip": "linear_mimic", - # The L6 pinky transmission has a repeatable changing ratio over its - # travel. Keep the exact direction-aware lookup at runtime and use the - # quadratic centre relation only for MuJoCo's equality constraint. - "rh_pinky_dip": "quadratic_runtime", - "rh_index_dip": "quadratic_runtime", - "rh_middle_dip": "quadratic_runtime", - "rh_ring_dip": "quadratic_runtime", -} - - -def build_typed_profile() -> CalibrationProfile: - active = frozenset(ACTIVE_JOINTS) - passive = frozenset(PASSIVE_JOINTS) - frozen_active = active - CALIBRATED_ACTIVE_JOINTS - measurements = { - "rh_thumb_cmc_roll": MeasurementSpec( - "rh_thumb_cmc_roll", - "relative_rotation", - "top", - "top_base", - "thumb_roll", - ), - "rh_thumb_cmc_pitch": MeasurementSpec( - "rh_thumb_cmc_pitch", - "relative_rotation", - "front", - "front_base", - "thumb_pitch", - ), - "rh_thumb_dip": MeasurementSpec( - "rh_thumb_dip", - "relative_rotation", - "front", - "thumb_pitch", - "thumb_dip", - ), - "rh_pinky_mcp_pitch": MeasurementSpec( - "rh_pinky_mcp_pitch", - "relative_rotation", - "side", - "side_base", - "pinky_pitch", - ), - "rh_pinky_dip": MeasurementSpec( - "rh_pinky_dip", - "relative_rotation", - "side", - "pinky_pitch", - "pinky_dip", - ), - } - tasks = ( - TaskSpec( - "thumb_roll_top", - "top", - 1, - ("rh_thumb_cmc_roll",), - auxiliary_commands=((0, 255),), - preflight_speed_u8=1, - formal_speed_u8=1, - ), - TaskSpec( - "thumb_pitch_dip_front", - "front", - 0, - ("rh_thumb_cmc_pitch", "rh_thumb_dip"), - auxiliary_commands=((1, 255),), - preflight_speed_u8=1, - formal_speed_u8=1, - ), - TaskSpec( - "pinky_pitch_dip_side", - "side", - 5, - ("rh_pinky_mcp_pitch", "rh_pinky_dip"), - preflight_speed_u8=1, - formal_speed_u8=1, - ), - ) - coverage = { - **{ - name: ( - "measured_static_dynamic" - if name in CALIBRATED_ACTIVE_JOINTS - else "transferred_static_dynamic" - if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT - else "cad_nominal" - ) - for name in active - }, - **{ - name: ( - "measured_dynamic_cad_static" - if name in MEASURED_PASSIVE_JOINTS - else "transferred_dynamic_cad_static" - if name in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT - else "mimic_nominal" - ) - for name in passive - }, - } - return CalibrationProfile( - key=KEY, - namespace="/l6_calibration", - command=CommandLayout( - names=COMMAND_NAMES, - baseline_u8=(255,) * 6, - command_index_by_joint=COMMAND_INDEX_BY_JOINT, - urdf_joint_by_joint={name: name for name in ACTIVE_JOINTS}, - feedback_name_aliases={"thumb_cmc_yaw": "thumb_cmc_roll"}, - speed_slot_by_command_index={index: index for index in range(6)}, - ), - vision=VisionRigSpec( - views=( - ViewSpec( - "front", - ( - TagSpec("front_base", 0, fixed_reference=True), - TagSpec("thumb_pitch", 1), - TagSpec("thumb_dip", 2), - ), - ), - ViewSpec( - "side", - ( - TagSpec("side_base", 3, fixed_reference=True), - TagSpec("pinky_pitch", 4), - TagSpec("pinky_dip", 5), - ), - ), - ViewSpec( - "top", - ( - TagSpec("top_base", 6, fixed_reference=True), - TagSpec("thumb_roll", 7), - ), - ), - ), - common_frame="calibration_common", - extrinsic_reference_view="front", - extrinsics_quality_limits={ - "reprojection_rms_px": 1.2, - "maximum_rotation_repeatability_deg": 0.3, - "maximum_translation_repeatability_m": 0.0015, - }, - minimum_capture_counts={ - "front_side_captures": 15, - "front_top_captures": 15, - }, - ), - motion=MotionPolicy( - tasks=tasks, - precheck_sweeps=False, - steady_command_checkpoints=False, - speed_parameters={ - "preflight_u8": 1, - "formal_u8": 1, - "speed_settle_seconds": 0.2, - "command_trajectory_full_range_seconds": 6.0, - "torque_u8": 80, - "endpoint_hold_seconds": 1.0, - "stall_timeout_seconds": 2.0, - }, - ), - measurement=MeasurementPolicy( - measurements=measurements, - directional_zero=True, - ), - zero=ZeroSolvePolicy( - active_joints=active, - passive_joints=passive, - direct_zero_joints=tuple(sorted(CALIBRATED_ACTIVE_JOINTS)), - axis_joints=tuple( - sorted(CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS) - ), - mechanical_endpoint_joints=frozenset(ENDPOINT_ANCHOR_BY_JOINT), - post_solve_endpoint_joints=frozenset(), - mimic_source_by_joint=MIMIC_SOURCE_BY_JOINT, - cad_frozen_joints=passive, - endpoint_anchor_by_joint={ - name: ENDPOINT_ANCHOR_BY_JOINT[name] - for name in ENDPOINT_ANCHOR_BY_JOINT - }, - fitted_mimic_joints=MEASURED_PASSIVE_JOINTS, - coupling_model_by_joint=COUPLING_MODEL_BY_JOINT, - ), - quality=QualityPolicy( - training_cycles=(0, 1, 2), - holdout_cycle=3, - hard_threshold_keys=frozenset( - { - "minimum_detection_rate", - "maximum_state_image_skew_ms", - "maximum_validation_error_rad", - "maximum_mimic_residual_rad", - } - ), - isolated_holdout=True, - ), - scope=ScopePolicy( - calibrate_joints={"partial": CALIBRATED_ACTIVE_JOINTS}, - frozen_joints={"partial": frozen_active}, - default_scope="partial", - ), - artifacts=ArtifactPolicy( - output_schema_version=6, - calibration_filename=( - "l6_right_{serial_number}_partial_calibration.json" - ), - corrected_urdf_filename=( - "linkerhand_l6_right_{serial_number}_partial_zero_calibrated.urdf" - ), - protected_input_fields=frozenset( - { - "source_urdf_sha256", - "camera_extrinsics_sha256", - "calibration_config_sha256", - "tag_config_sha256", - } - ), - publication_pointer="latest_partial_passed", - session_compatibility_tokens=frozenset( - {"l6_partial_v1", "feedback_curves_v6"} - ), - publish_corrected_urdf=True, - ), - joint_coverage=coverage, - ) - - -def _run_cli(args: list[str] | None = None) -> None: - from .runner import main - - main(args) - - -def _run_node(args: list[str] | None = None) -> None: - from .node import main - - main(args) - - -def build_profile() -> RegisteredProfile: - typed = build_typed_profile() - return RegisteredProfile( - profile=typed, - engine=EngineBindings( - hand_profile=typed, - zero_profile=typed.zero, - motion_command=build_calibration_motion_command, - preparation_waypoints=build_calibration_preparation_waypoints, - return_waypoints=build_calibration_return_waypoints, - cli_main=_run_cli, - node_main=_run_node, - ), - ) - - -__all__ = [ - "ACTIVE_JOINTS", - "CALIBRATED_ACTIVE_JOINTS", - "COMMAND_NAMES", - "KEY", - "MEASURED_PASSIVE_JOINTS", - "MIMIC_SOURCE_BY_JOINT", - "PASSIVE_JOINTS", - "build_profile", - "build_typed_profile", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/l6/runner.py b/src/linkerhand_calibration/linkerhand_calibration/models/l6/runner.py deleted file mode 100644 index 8fcda1e..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/l6/runner.py +++ /dev/null @@ -1,716 +0,0 @@ -"""One-command online runner and deterministic offline replay for L6 right.""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import hashlib -import json -import os -from pathlib import Path -import re -import signal -import subprocess -import sys -import time -from typing import Any, Callable, Mapping - -import rclpy -from rclpy.node import Node -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...operator_report import ( - ProgressEstimator, - render_compact_progress_header_zh, -) -from ...product import ProductConfig, load_product_config -from ...storage import atomic_write_json -from .pipeline import finalize_l6_session, load_l6_raw_samples - - -_STATE_LABELS = { - "WAIT_DEVICES": "等待六通道反馈和三相机内参", - "READY": "设备就绪", - "RUNNING": "标定中", - "FINALIZING": "拟合、验证并生成 URDF", - "PASSED": "通过", - "PAUSED": "已暂停", - "ABORTED": "已中止", -} -_TASK_LABELS = { - "thumb_roll_top": "拇指 CMC roll(上方机位,ID6→ID7)", - "thumb_pitch_dip_front": "拇指 CMC pitch / DIP(正面机位,ID0→ID1→ID2)", - "pinky_pitch_dip_side": "小指 MCP pitch / DIP(侧面机位,ID3→ID4→ID5)", -} -_PHASE_LABELS = { - "baseline": "安全恢复基准形态", - "preflight": "任务运动预检", - "prepare": "扫描起点准备", - "retry_prepare": "自动重扫起点准备", - "resume_prepare": "断点恢复起点准备", - "sweep": "正式扫描", - "clearance": "手指避让", - "clearance_outer": "小指/无名指避让", - "clearance_splay": "侧摆避让", - "return_splay_zero": "侧摆回零", - "return_middle_open": "展开中指", - "return_outer_open": "展开小指/无名指", -} -_DIRECTION_LABELS = { - "decreasing": "递减", - "increasing": "递增", -} - - -def _progress_fraction(status: Mapping[str, Any]) -> tuple[float, int, int]: - state = str(status.get("state", "")) - step_count = max(0, int(status.get("step_count", 0) or 0)) - step_index = int(status.get("step_index", -1) or 0) - step_fraction = float(status.get("step_fraction", 0.0) or 0.0) - if state == "PASSED": - overall = 1.0 - elif step_count and step_index >= 0: - overall = min(1.0, max(0.0, (step_index + step_fraction) / step_count)) - else: - overall = 0.0 - current_step = min(step_count, max(0, step_index + 1)) if step_count else 0 - return overall, current_step, step_count - - -def _duration_zh(seconds: float | None) -> str: - if seconds is None or seconds < 0.0: - return "计算中" - value = int(round(seconds)) - return f"{value // 60}分{value % 60:02d}秒" - - -def _l6_reason_zh( - status: Mapping[str, Any], *, model_name: str = "L6" -) -> tuple[str, str, str]: - reason = str(status.get("reason", "unknown")) - if reason.startswith("fixed_base_tag_moved:"): - fields = reason.split(":") - view = fields[1] if len(fields) > 1 else "unknown" - labels = {"front": "正面", "side": "侧面", "top": "上方"} - match = re.search(r"drift_px=([0-9.]+)", reason) - drift = match.group(1) if match else "未知" - limit = float(status.get("base_corner_drift_limit_px", 2.0) or 2.0) - return ( - "OBS-BASE-DRIFT-105", - f"{labels.get(view, view)}机位的掌心固定基准 Tag 相对本方向扫描前" - f"锁定位置连续漂移,最大角点位移 {drift} px,超过 {limit:g} px。", - "检查手掌支架、相机和掌心基准 Tag 是否松动或被碰触;固定后重新开始。" - "程序已禁止发布本次结果。", - ) - if reason.startswith("sweep_quality_failed:"): - fields = reason.split(":", 2) - details = fields[2] if len(fields) > 2 else "未提供明细" - return ( - "OBS-SWEEP-QUALITY-104", - "当前方向经过自动重扫后仍未满足采集门限;具体未通过项:" - f"{details}。", - "查看会话诊断中的具体 frames/bins/maximum_gap/tag_rate;先处理遮挡或" - "反馈采样问题,再重新开始。程序已禁止发布本次结果。", - ) - if reason.startswith("multiple_state_publishers:"): - count = status.get("state_publisher_count", "?") - return ( - "DEVICE-DUPLICATE-SDK-203", - f"检测到 {count} 个 {model_name} 状态发布者;这通常表示已有 SDK/GUI 未退出。", - "先停止单独启动的 linker_hand_sdk 和 GUI,只保留本标定命令自动拉起的 SDK," - "再重新开始。程序已禁止同时控制同一只手。", - ) - if reason.startswith("multiple_command_publishers:"): - count = status.get("command_publisher_count", "?") - return ( - "DEVICE-COMMAND-CONFLICT-204", - f"检测到 {count} 个 {model_name} 控制命令发布者,标定节点之外还有程序在控制手。", - "停止 GUI、手动控制节点或其他标定进程,只保留当前标定命令后重新开始。", - ) - if reason.startswith("mechanical_stall:"): - return ( - "MOTION-STALL-303", - "目标电机连续两秒没有向目标推进,程序已保持当前位置。", - "检查碰撞、摩擦和机械端点;不要连续重启强推。", - ) - if reason.startswith("motion_timeout:"): - return ( - "MOTION-TIMEOUT-302", - "当前运动在规定时间内没有到达目标位置。", - "检查 CAN 反馈、电机状态和机械阻挡后重新开始。", - ) - if reason.startswith("fit_or_publication_failed:"): - if ( - "mimic_residual_exceeds:" in reason - or "coupling_residual_exceeds:" in reason - ): - joint_match = re.search(r"joint=([^:]+)", reason) - model_match = re.search(r"model=([^:]+)", reason) - multiplier_match = re.search( - r"(?:multiplier|linear_term)=([0-9.]+)", reason - ) - p95_match = re.search(r"p95_deg=([0-9.]+)", reason) - maximum_match = re.search(r"maximum_deg=([0-9.]+)", reason) - joint = joint_match.group(1) if joint_match else "未知关节" - model = model_match.group(1) if model_match else "linear_mimic" - multiplier = multiplier_match.group(1) if multiplier_match else "未知" - p95 = p95_match.group(1) if p95_match else "未知" - maximum = maximum_match.group(1) if maximum_match else "未知" - return ( - "FIT-MIMIC-503", - f"{joint} 的 {model} 耦合模型未达到精度门限:" - f"线性项 {multiplier}," - f"残差 P95={p95}°、最大={maximum}°。", - "原始视觉曲线已保留;不要放宽门限或发布错误 URDF。请检查 Tag 刚性、" - "遮挡与机械重复性后重新采集。", - ) - detail = reason.split(":", 1)[1] if ":" in reason else "未知" - return ( - "FIT-PUBLISH-501", - f"采集完成后的拟合、质量验证或 URDF 安全写回失败:{detail}。", - "保留本会话,不要修改源 URDF;复制下方诊断块给开发者。", - ) - if reason.startswith("operator_abort"): - return "OPERATOR-ABORT-001", "操作员主动中止了本次标定。", "排除现场问题后重新开始。" - return ( - f"{model_name}-CAL-500", - f"{model_name} 标定因未分类保护条件停止。", - "保留会话目录和运行日志,并复制下方诊断块给开发者。", - ) - - -def render_six_channel_progress_zh( - status: Mapping[str, Any], - *, - task_labels: Mapping[str, str], - reason_renderer: Callable[ - [Mapping[str, Any]], tuple[str, str, str] - ], - estimator: ProgressEstimator | None = None, -) -> str: - """Render one six-channel profile in the operator-oriented G20 layout.""" - state = str(status.get("state", "")) - overall, _current_step, _step_count = _progress_fraction(status) - task = status.get("task_name") - phase = status.get("phase") - if task: - task_text = task_labels.get(str(task), str(task)) - elif phase == "baseline": - task_text = "全手基准姿态" - elif state == "WAIT_DEVICES": - task_text = "等待设备连接" - elif state == "READY": - task_text = "等待开始" - else: - task_text = "无" - eta = _duration_zh(estimator.remaining(overall) if estimator else None) - cycle = status.get("cycle") - cycle_text = "-" if cycle is None else str(int(cycle) + 1) - direction = status.get("direction") - direction_text = _DIRECTION_LABELS.get(str(direction), "-") - command_unit = str(status.get("command_unit", "u8")) - target = status.get("target_rad") if command_unit == "rad" else status.get("target_u8") - requested = status.get("current_command_u8", target) - actual = status.get("actual_u8") - if command_unit == "rad": - requested = "未知" if requested is None else f"{float(requested):.3f} rad" - actual_text = "未知" if actual is None else f"{float(actual):.3f} rad" - else: - actual_text = "未知" if actual is None else f"{float(actual):.1f}" - valid = int(status.get("valid_frames", 0) or 0) - total = int(status.get("total_frames", 0) or 0) - rate = float(status.get("tag_detection_rate", 0.0) or 0.0) - joint_rate = float( - status.get( - "joint_frame_rate", - (float(valid) / total) if total else 0.0, - ) - or 0.0 - ) - recognized = [int(value) for value in status.get("recognized_tag_ids", [])] - unrecognized = [ - int(value) for value in status.get("unrecognized_tag_ids", []) - ] - recognized_text = "/".join(f"ID{value}" for value in recognized) or "无" - unrecognized_text = "/".join(f"ID{value}" for value in unrecognized) or "无" - attempt = int(status.get("attempt", 1) or 1) - lines = render_compact_progress_header_zh( - serial_number=str(status.get("serial_number", "?")), - progress=overall, - eta=eta, - stage=_PHASE_LABELS.get(str(phase), _STATE_LABELS.get(state, state)), - cycle=cycle_text, - repetitions=4, - task=task_text, - requested=requested, - actual=actual_text, - direction=direction_text, - tag_status=( - f"已识别 {recognized_text};未识别/不合格 {unrecognized_text};" - f"本方向各Tag最低 {rate:.1%};联合 {valid}/{total} 帧" - f"({joint_rate:.1%})" - ), - ready_cameras=3, - feedback_hz=float(status.get("feedback_hz", 0.0) or 0.0), - valid_frames=valid, - automatic_retry_count=max(0, attempt - 1), - ) - speed_u8 = status.get("speed_u8") - if speed_u8 is not None: - trajectory_seconds = float( - status.get("command_trajectory_full_range_seconds", 0.0) or 0.0 - ) - lines.append( - f"运动:速度档 {int(speed_u8)};全行程 {trajectory_seconds:.1f} 秒余弦轨迹" - ) - speed_rad_s = status.get("speed_rad_s") - if speed_rad_s is not None: - trajectory_seconds = float( - status.get("command_trajectory_duration_seconds", 0.0) or 0.0 - ) - lines.append( - f"运动:峰值 {float(speed_rad_s):.3f} rad/s;" - f"本段 {trajectory_seconds:.1f} 秒平滑限速轨迹" - ) - latest_state = status.get(f"latest_state_{command_unit}", []) - command_names = status.get("command_names", []) - if ( - isinstance(latest_state, (list, tuple)) - and isinstance(command_names, (list, tuple)) - and len(latest_state) == len(command_names) - and len(command_names) in {6, 12} - and (phase == "baseline" or state in {"PAUSED", "ABORTED"}) - ): - digits = 3 if command_unit == "rad" else 1 - suffix = " rad" if command_unit == "rad" else "" - feedback_text = ", ".join( - f"{name}={float(value):.{digits}f}{suffix}" - for name, value in zip(command_names, latest_state) - ) - maximum_error_channel = status.get("maximum_error_channel") - maximum_error = status.get(f"maximum_error_{command_unit}") - error_text = ( - "未知" - if maximum_error_channel is None or maximum_error is None - else ( - f"{maximum_error_channel}=" - f"{float(maximum_error):.{digits}f}{suffix}" - ) - ) - channel_label = "六路" if len(command_names) == 6 else "十二路" - error_label = "最大命令/反馈差" if command_unit == "rad" else "最大偏差" - lines.append( - f"{channel_label}反馈:{feedback_text} {error_label}:{error_text}" - ) - if state in {"PAUSED", "ABORTED"}: - _code, problem, suggestion = reason_renderer(status) - lines.extend((f"原因:{problem}", f"建议:{suggestion}")) - return "\n".join(lines) - - -def render_l6_progress_zh( - status: Mapping[str, Any], - estimator: ProgressEstimator | None = None, -) -> str: - """Render L6 progress in the same operator-oriented layout as G20.""" - return render_six_channel_progress_zh( - status, - task_labels=_TASK_LABELS, - reason_renderer=_l6_reason_zh, - estimator=estimator, - ) - - -class _ProgressConsole: - def __init__( - self, - renderer: Callable[ - [Mapping[str, Any], ProgressEstimator | None], str - ] = render_l6_progress_zh, - ) -> None: - self.last_text = "" - self.estimator = ProgressEstimator.start() - self.renderer = renderer - - def update(self, status: Mapping[str, Any]) -> None: - text = self.renderer(status, self.estimator) - if text == self.last_text: - return - self.last_text = text - if sys.stdout.isatty(): - sys.stdout.write("\x1b[2J\x1b[H" + text + "\n") - sys.stdout.flush() - else: - print(text, flush=True) - - -class _Monitor(Node): - def __init__(self, progress: _ProgressConsole) -> None: - super().__init__("l6_calibration_runner") - self.status: dict[str, Any] = {} - self.progress = progress - self.create_subscription( - String, "/l6_calibration/status", self._status_callback, 10 - ) - self.start_client = self.create_client(Trigger, "/l6_calibration/start") - self.abort_client = self.create_client(Trigger, "/l6_calibration/abort") - - def _status_callback(self, message: String) -> None: - try: - value = json.loads(message.data) - except json.JSONDecodeError: - return - if isinstance(value, dict): - self.status = value - self.progress.update(value) - - -def _launch_command( - config: ProductConfig, - session: Path, - *, - record_bag: bool, - commands_enabled: bool, - sdk_startup_speed_u8: int = 1, - resume_from: Path | None = None, -) -> list[str]: - arguments = { - "model": config.model, - "hand_type": config.side, - "tag_layout": config.tag_layout, - "serial_number": config.serial_number, - "source_urdf_path": str(config.source_urdf), - "source_urdf_expected_sha256": config.source_urdf_sha256, - "camera_extrinsics_file": str(config.camera_extrinsics), - "camera_extrinsics_expected_sha256": config.camera_extrinsics_sha256, - "calibration_config": str(config.calibration_config), - "calibration_config_expected_sha256": config.calibration_config_sha256, - "tag_config": str(config.tag_config), - "tag_config_expected_sha256": config.tag_config_sha256, - "vendor_sdk_config": "" if config.sdk_config is None else str(config.sdk_config), - "sdk_config_expected_sha256": config.sdk_config_sha256, - "output_root": str(config.output_root), - "session_dir": str(session), - "corrected_urdf_output_dir": str(session), - "recalibration_scope": config.calibration_contract.typed_profile.scope.default_scope, - "calibration_speed": str(int(sdk_startup_speed_u8)), - "index_roll_calibration_speed": "1", - "index_flex_calibration_speed": "1", - "commands_enabled": str(commands_enabled).lower(), - "record_bag": str(record_bag).lower(), - } - if resume_from is not None: - arguments["resume_raw_samples_path"] = str( - resume_from / "raw_samples.jsonl" - ) - # HCAN/ZLG vendor products do not have a Linux SocketCAN interface. - # Omitting the launch override also avoids the invalid token - # ``can_interface:=`` when the reviewed product value is intentionally empty. - if config.can_interface: - arguments["can_interface"] = config.can_interface - for view, camera in config.cameras.items(): - arguments[f"{view}_camera_serial"] = camera["serial_number"] - arguments[f"{view}_camera_name"] = camera["camera_name"] - arguments[f"{view}_camera_info_url"] = camera["camera_info"] - return [ - "ros2", "launch", "linkerhand_calibration", - "three_camera_calibration.launch.py", - *(f"{name}:={value}" for name, value in arguments.items()), - ] - - -def _wait_until( - monitor: _Monitor, - process: subprocess.Popen[Any], - predicate, - *, - timeout: float | None, -) -> bool: - started = time.monotonic() - while rclpy.ok(): - if process.poll() is not None: - return False - rclpy.spin_once(monitor, timeout_sec=0.2) - if predicate(monitor.status): - return True - if timeout is not None and time.monotonic() - started > timeout: - return False - return False - - -def _stop_stack(process: subprocess.Popen[Any]) -> None: - """Stop the whole launch process group without leaking child-node noise.""" - if process.poll() is not None: - return - try: - os.killpg(process.pid, signal.SIGINT) - except ProcessLookupError: - return - try: - process.wait(timeout=15.0) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGTERM) - except ProcessLookupError: - return - try: - process.wait(timeout=5.0) - except subprocess.TimeoutExpired: - try: - os.killpg(process.pid, signal.SIGKILL) - except ProcessLookupError: - return - process.wait(timeout=5.0) - - -def _l6_failure_report( - config: ProductConfig, - session: Path, - status: Mapping[str, Any], - log_path: Path, -) -> str: - code, problem, suggestion = _l6_reason_zh(status) - task_key = status.get("task_name") - task = "无" if task_key is None else _TASK_LABELS.get(str(task_key), str(task_key)) - metrics = { - "task": task, - "cycle": status.get("cycle"), - "direction": status.get("direction"), - "valid_frames": status.get("valid_frames", 0), - "detection_frames": status.get("total_frames", 0), - "detection_rate": status.get("tag_detection_rate", 0.0), - "detection_rate_by_role": status.get("tag_detection_rate_by_role", {}), - "tag_seen_rate_by_role": status.get("tag_seen_rate_by_role", {}), - "recognized_tag_ids": status.get("recognized_tag_ids", []), - "unrecognized_tag_ids": status.get("unrecognized_tag_ids", []), - "joint_frame_rate": status.get("joint_frame_rate", 0.0), - "all_tags_quality_rate": status.get("all_tags_quality_rate", 0.0), - "pnp_valid_rate": status.get("pnp_valid_rate", 0.0), - "state_sync_rate": status.get("state_sync_rate", 0.0), - "observation_rejection_counts": status.get( - "observation_rejection_counts", {} - ), - "actual_u8": status.get("actual_u8"), - "current_command_u8": status.get("current_command_u8"), - "base_corner_drift_px": status.get("base_corner_drift_px", {}), - "state_publisher_count": status.get("state_publisher_count"), - "command_publisher_count": status.get("command_publisher_count"), - "failure_reason": status.get("reason"), - } - payload = { - "schema_version": 1, - "profile_id": status.get("profile_id"), - "serial_number": config.serial_number, - "result": "FAIL", - "error_code": code, - "stage": status.get("phase") - or ("fit" if str(status.get("reason", "")).startswith( - "fit_or_publication_failed:" - ) else status.get("state", "unknown")), - "reason": status.get("reason", "unknown"), - "problem_zh": problem, - "automatic_action_zh": "已停止运动、保持当前位置并禁止发布标定 JSON/URDF", - "suggestion_zh": suggestion, - "metrics": metrics, - "feedback_hz": status.get("feedback_hz", 0.0), - "hashes": { - "product_config_sha256": hashlib.sha256( - config.path.read_bytes() - ).hexdigest(), - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "source_urdf_sha256": config.source_urdf_sha256, - }, - "session_dir": str(session), - "log_path": str(log_path), - "quality": {"passed": False}, - } - atomic_write_json(session / "calibration_summary_zh.json", payload) - return "\n".join( - [ - "========== 请复制以下内容给开发者 ==========", - f"会话编号:{config.serial_number}_{session.name}", - "结果:FAIL", - f"错误代码:{code}", - f"失败阶段:{payload['stage']}", - f"问题:{problem}", - f"自动处理:{payload['automatic_action_zh']}", - "关键指标:" - + json.dumps(metrics, ensure_ascii=False, separators=(",", ":")), - f"反馈状态:{float(payload['feedback_hz'] or 0.0):.1f} Hz", - f"配置哈希:{payload['hashes']['product_config_sha256']}", - f"外参哈希:{config.camera_extrinsics_sha256}", - f"源 URDF 哈希:{config.source_urdf_sha256}", - f"会话目录:{session}", - f"运行日志:{log_path}", - f"建议:{suggestion}", - "========== 复制结束 ==========", - ] - ) - - -def _run_online( - config: ProductConfig, - *, - record_bag: bool, - commands_enabled: bool, -) -> int: - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session = config.session_root / stamp - while session.exists(): - time.sleep(1.0) - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - session = config.session_root / stamp - session.mkdir(parents=True) - command = _launch_command( - config, session, record_bag=record_bag, commands_enabled=commands_enabled - ) - log_path = session / "calibration.log" - log_stream = log_path.open("a", encoding="utf-8", buffering=1) - process = subprocess.Popen( - command, - cwd=config.workspace, - stdout=log_stream, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - ) - rclpy.init() - monitor = _Monitor(_ProgressConsole()) - try: - ready = _wait_until( - monitor, - process, - lambda status: status.get("state") - in {"READY", "PAUSED", "ABORTED"}, - timeout=120.0, - ) - if ready and monitor.status.get("state") in {"PAUSED", "ABORTED"}: - print( - _l6_failure_report(config, session, monitor.status, log_path), - flush=True, - ) - return 3 - if not ready: - print("L6 启动失败:120秒内未收到六通道反馈和三相机内参。", flush=True) - return 2 - if not monitor.start_client.wait_for_service(timeout_sec=10.0): - print("L6 标定 /start 服务不可用。", flush=True) - return 2 - future = monitor.start_client.call_async(Trigger.Request()) - while rclpy.ok() and not future.done(): - rclpy.spin_once(monitor, timeout_sec=0.2) - response = future.result() - if response is None or not response.success: - print(f"L6 标定未启动:{getattr(response, 'message', '')}", flush=True) - return 2 - print( - "L6右手标定已启动:通道0/1/5,预检/正式速度1," - "全行程6秒余弦缓入缓出,torque 80。", - flush=True, - ) - finished = _wait_until( - monitor, - process, - lambda status: status.get("state") in {"PASSED", "PAUSED", "ABORTED"}, - timeout=None, - ) - if not finished: - print("L6 标定进程意外退出。", flush=True) - return 2 - status = monitor.status - if status.get("state") != "PASSED": - print(_l6_failure_report(config, session, status, log_path), flush=True) - return 3 - print( - "\n".join( - [ - "PASS:L6右手三主动关节与两条DIP实测通过;" - "小指结果已迁移到食指、中指和无名指。", - f"部分结果:{config.session_root / 'latest_partial_passed'}", - f"JSON:{status.get('final_json')}", - f"URDF:{status.get('final_urdf')}", - ] - ), - flush=True, - ) - return 0 - except KeyboardInterrupt: - if monitor.abort_client.wait_for_service(timeout_sec=2.0): - monitor.abort_client.call_async(Trigger.Request()) - rclpy.spin_once(monitor, timeout_sec=1.0) - return 130 - finally: - monitor.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - _stop_stack(process) - log_stream.flush() - os.fsync(log_stream.fileno()) - log_stream.close() - - -def main(args: list[str] | None = None) -> None: - parser = argparse.ArgumentParser( - description="L6 right partial three-camera calibration" - ) - parser.add_argument("--config", required=True) - parser.add_argument("--workspace", default=None) - parser.add_argument("--record-bag", action="store_true") - parser.add_argument("--commands-disabled", action="store_true") - parser.add_argument("--validate-only", action="store_true") - parser.add_argument("--offline-raw", default="") - parser.add_argument("--offline-output", default="") - parser.add_argument("--publish-offline", action="store_true") - selected = parser.parse_args(args) - config = load_product_config( - selected.config, - workspace=selected.workspace, - check_can=not bool(selected.validate_only or selected.offline_raw), - ) - if selected.validate_only: - print( - f"配置有效:{config.profile_key.profile_id},源URDF " - f"{config.source_urdf_sha256}", - flush=True, - ) - return - if selected.offline_raw: - output = ( - Path(selected.offline_output).expanduser().resolve() - if selected.offline_output - else config.session_root - / (datetime.now().strftime("%Y%m%d_%H%M%S") + "_offline") - ) - output.mkdir(parents=True, exist_ok=False) - payload, _fit, correction = finalize_l6_session( - session_dir=output, - serial_number=config.serial_number, - source_urdf=config.source_urdf, - protected_inputs={ - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "tag_config_sha256": config.tag_config_sha256, - }, - records=load_l6_raw_samples(selected.offline_raw), - publish=selected.publish_offline, - ) - print( - f"离线回放PASS:schema {payload['schema_version']},URDF {correction.path}", - flush=True, - ) - return - raise SystemExit( - _run_online( - config, - record_bag=selected.record_bag, - commands_enabled=not selected.commands_disabled, - ) - ) - - -__all__ = [ - "main", "render_l6_progress_zh", "render_six_channel_progress_zh" -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/__init__.py deleted file mode 100644 index 26fbdf3..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Registered O12 calibration profiles.""" - -from ..registry import ProfileRegistry - - -def register_profiles(registry: ProfileRegistry) -> None: - from .profile import build_profile - registry.register(build_profile()) - - -__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/fitting.py deleted file mode 100644 index 6271f2d..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/fitting.py +++ /dev/null @@ -1,688 +0,0 @@ -"""Direction-aware fitting for O12 feedback expressed in radians.""" - -from __future__ import annotations - -from dataclasses import dataclass, replace -import math -from pathlib import Path -from typing import Any, Mapping, Sequence - -import numpy as np - -from ..g20.profile import ( - HandCalibrationProfile, - JointCurveFit, - JointSpec, - cross_view_roll_diagnostic_metrics, -) -from ..g20.zero_solver import ( - ZeroCalibrationProfile, - ZeroSolveResult, - fit_joint_axis_measurement, - fit_rotation_joint_curve, - rotation_curve_holdout_errors, - solve_urdf_zero_offsets, - with_depth_free_axis_projection, -) -from ..l6.fitting import MimicFit, fit_coupling_model -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - COMMAND_INDEX_BY_JOINT, - COMMAND_NAMES, - GEOMETRIC_ZERO_JOINTS, - ROOT_GEOMETRIC_ZERO_JOINTS, - MEASURED_PASSIVE_JOINTS, - MIMIC_SOURCE_BY_JOINT, - SDK_TO_URDF_JOINT, - SDK_TO_URDF_SIGN, - STATIC_ZERO_EXCLUDED_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - build_typed_profile, -) - -@dataclass(frozen=True) -class O12FitResult: - curves: Mapping[str, JointCurveFit] - zero_offsets_rad: Mapping[str, float] - travels_rad: Mapping[str, float] - mimic_fits: Mapping[str, MimicFit] - holdout_errors_rad: Mapping[str, tuple[float, ...]] - cross_view_roll_metrics: Mapping[str, Mapping[str, float]] - visual_arc_diagnostics_rad: Mapping[str, float] - feedback_domains_rad: Mapping[str, tuple[float, float]] - zero_method_by_joint: Mapping[str, str] - thumb_root_zero_result: ZeroSolveResult | None - full_hand_zero_result: ZeroSolveResult | None = None - - -O12_THUMB_ROOT_AXIS_JOINTS: tuple[str, ...] = ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "pinky_mcp_pitch", -) - - -def _thumb_root_zero_profile() -> ZeroCalibrationProfile: - """Declare the spatial graph that observes O12 thumb roll/yaw zeros. - - A relative Tag rotation observes roll travel but not its constant phase. - The downstream yaw screw axis rotates with that phase. The independently - observed pinky MCP axis fixes palm orientation, leaving roll as the only - fitted roll coordinate. The pitch axis in turn observes yaw phase. - Parallel pitch/MCP axes do not identify their own phase from direction; - those origins retain CAD values instead of inferring zeros from travel. - """ - specs = { - name: JointSpec( - name, - COMMAND_INDEX_BY_JOINT[name], - True, - None, - None, - None, - pose_axis_line_required=False, - ) - for name in O12_THUMB_ROOT_AXIS_JOINTS - } - hand = HandCalibrationProfile( - side="right", - reference_finger="pinky", - view_tags={}, - preflight_view_roles={}, - joint_specs=specs, - sweep_specs=(), - image_trajectory_joints=frozenset(), - roll_clearance_commands={}, - thumb_pitch_clearance_commands={}, - layout_id="o12_right_16", - model="O12", - command_names=COMMAND_NAMES, - baseline_command=(255,) * len(COMMAND_NAMES), - directional_zero=True, - isolated_holdout=True, - # As in the G20 multi-view solver, a repeatable planar-PnP cone bias - # is diagnostic rather than a zero-phase failure. Acceptance still - # requires the per-cycle cone magnitude to be stable, the fitted axis - # lines to pass, and the independent holdout to improve. - stable_cross_view_cone_bias=True, - ) - return ZeroCalibrationProfile( - hand=hand, - direct_zero_joints=("thumb_cmc_roll", "thumb_cmc_yaw"), - axis_joints=O12_THUMB_ROOT_AXIS_JOINTS, - inherited_zero_joints={}, - inherited_static_zero_joints={}, - constrained_circle_joints=frozenset(O12_THUMB_ROOT_AXIS_JOINTS), - root_anchor_joints=frozenset({"thumb_cmc_roll"}), - axis_parent_joint={"thumb_cmc_yaw": "thumb_cmc_roll", "thumb_cmc_pitch": "thumb_cmc_yaw"}, - phase_parent_joint={}, - offset_observer_joint={"thumb_cmc_roll": "thumb_cmc_yaw", "thumb_cmc_yaw": "thumb_cmc_pitch"}, - same_view_axis_pair_by_offset={}, - fixed_direct_zero_offsets_rad={}, - static_output_zero_offsets_rad={}, - base_pose_strategy="thumb_serial", - orientation_anchor_joint="pinky_mcp_pitch", - directed_base_axis_joints=frozenset({ - "thumb_cmc_roll", "pinky_mcp_pitch", - }), - ) - - -def _task_by_joint() -> dict[str, Any]: - return { - joint: task - for task in build_typed_profile().motion.tasks - for joint in task.joints - } - - -def feedback_rad_to_curve_index( - joint: str, - feedback_rad: float, - feedback_domain_rad: tuple[float, float] | None = None, -) -> float: - """Map measured feedback to the fitter's normalized 255..0 axis.""" - task = _task_by_joint()[str(joint)] - if feedback_domain_rad is None: - start, end = float(task.start_value), float(task.end_value) - else: - lower, upper = (float(value) for value in feedback_domain_rad) - if task.end_value >= task.start_value: - start, end = lower, upper - else: - start, end = upper, lower - denominator = end - start - if abs(denominator) <= 1.0e-12: - raise ValueError(f"O12 task {task.key} has a degenerate range") - phase = (float(feedback_rad) - start) / denominator - return 255.0 * (1.0 - float(np.clip(phase, 0.0, 1.0))) - - -def curve_input_knots_rad( - joint: str, - count: int = 65, - feedback_domain_rad: tuple[float, float] | None = None, -) -> tuple[float, ...]: - task = _task_by_joint()[str(joint)] - if feedback_domain_rad is None: - lower, upper = sorted((task.start_value, task.end_value)) - else: - lower, upper = sorted(float(value) for value in feedback_domain_rad) - # Zero is the open/centred runtime command. Include it in the public - # knot domain while clamping the tiny unobserved offset to the measured - # endpoint instead of extrapolating a full SDK command range. - lower, upper = min(0.0, lower), max(0.0, upper) - return tuple( - float(value) - for value in np.linspace( - lower, - upper, - int(count), - ) - ) - - -def curve_values_at_rad( - joint: str, - fit: JointCurveFit, - inputs_rad: Sequence[float], - branch: str, - feedback_domain_rad: tuple[float, float] | None = None, -) -> tuple[float, ...]: - values = np.asarray(getattr(fit, branch), dtype=float) - indices = np.arange(256, dtype=float) - return tuple( - float(np.interp( - feedback_rad_to_curve_index(joint, value, feedback_domain_rad), - indices, - values, - )) - for value in inputs_rad - ) - - -def _virtual_records( - joint: str, - rows: Sequence[Mapping[str, Any]], - feedback_domain_rad: tuple[float, float] | None = None, - feedback_domains_rad: Mapping[str, tuple[float, float]] | None = None, -) -> list[dict[str, Any]]: - result = [] - for source in rows: - row = dict(source) - curve_index = feedback_rad_to_curve_index( - joint, float(row["feedback_rad"]), feedback_domain_rad - ) - row["command_u8"] = int(np.clip(round(curve_index), 0, 255)) - if "state_rad" in row: - state_rad = tuple(float(value) for value in row["state_rad"]) - if len(state_rad) != len(SDK_TO_URDF_JOINT): - raise ValueError("O12 state_rad has the wrong channel count") - state_u8 = [] - for channel, value in enumerate(state_rad): - state_joint = SDK_TO_URDF_JOINT[channel] - state_joint = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get( - state_joint, state_joint - ) - state_u8.append( - feedback_rad_to_curve_index( - state_joint, - value, - (feedback_domains_rad or {}).get(state_joint), - ) - ) - row["state_u8"] = state_u8 - result.append(row) - return result - - -def _measured_feedback_domain( - joint: str, rows: Sequence[Mapping[str, Any]] -) -> tuple[float, float]: - """Return the full-stroke feedback interval established by cycle zero.""" - values = [ - float(row["feedback_rad"]) - for row in rows - if int(row.get("cycle", -1)) == 0 - and math.isfinite(float(row.get("feedback_rad", math.nan))) - ] - if not values or max(values) - min(values) <= 1.0e-9: - raise ValueError(f"{joint} cycle 0 has no measurable feedback travel") - return float(min(values)), float(max(values)) - - -def _validate_cycle_repeatability( - joint: str, - rows: Sequence[Mapping[str, Any]], - feedback_domain_rad: tuple[float, float], -) -> None: - """Require later cycles to repeat measured travel, not command scale.""" - reference = feedback_domain_rad[1] - feedback_domain_rad[0] - for cycle in (1, 2, 3): - values = [ - float(row["feedback_rad"]) - for row in rows - if int(row.get("cycle", -1)) == cycle - and math.isfinite(float(row.get("feedback_rad", math.nan))) - ] - span = max(values) - min(values) if values else 0.0 - if span < 0.90 * reference: - raise ValueError( - f"{joint} cycle {cycle} repeats only " - f"{span / reference:.3f} of cycle-zero measured travel" - ) - - -def _uniform_curve_holdout_errors( - rows: Sequence[Mapping[str, Any]], errors: Sequence[float] -) -> tuple[float, ...]: - """Give every observed feedback bin equal holdout weight. - - O12 motor feedback can saturate before a passive tendon joint has fully - settled. A camera then records many frames at one identical feedback bin. - The curve fitter already uses one median per bin; validation must use the - same curve-space measure instead of letting endpoint dwell duration - multiply the P95 weight of that single coordinate. - """ - if len(rows) != len(errors): - raise ValueError("O12 holdout rows and errors have different lengths") - grouped: dict[tuple[str, int], list[float]] = {} - for row, error in zip(rows, errors): - grouped.setdefault( - (str(row["direction"]), int(row["command_u8"])), [] - ).append(float(error)) - if not grouped: - raise ValueError("O12 holdout has no observed feedback bins") - return tuple( - float(np.median(grouped[key])) for key in sorted(grouped) - ) - - -def _travel(fit: JointCurveFit) -> float: - return 0.5 * ( - float(fit.decreasing_rad[0] - fit.decreasing_rad[255]) - + float(fit.increasing_rad[0] - fit.increasing_rad[255]) - ) - - -def measured_curve_bounds( - name: str, - fit: JointCurveFit, - feedback_domain_rad: tuple[float, float], - *, - sign: float, -) -> tuple[float, float]: - """Return the zero-referenced public URDF range of one O12 curve.""" - inputs = curve_input_knots_rad(name, feedback_domain_rad=feedback_domain_rad) - branches = [] - for branch in ("decreasing_rad", "increasing_rad"): - values = np.asarray( - curve_values_at_rad(name, fit, inputs, branch, feedback_domain_rad), - dtype=float, - ) - values -= float(np.interp(0.0, np.asarray(inputs, dtype=float), values)) - branches.append(values) - average = 0.5 * (branches[0] + branches[1]) - direction = float(np.sign(average[-1] - average[0])) - if direction not in {0.0, float(sign)}: - branches = [-values for values in branches] - average = -average - values = np.concatenate([average, *branches]) - return float(np.min(values)), float(np.max(values)) - - -def _has_complete_thumb_root_geometry( - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], -) -> bool: - required = { - "relative_translation_xyz_m", - "parent_pose_common", - "child_pose_common", - "view_normal_common_xyz", - "camera_center_common_xyz_m", - "state_rad", - } - return all( - rows and all(required.issubset(row) for row in rows) - for name in O12_THUMB_ROOT_AXIS_JOINTS - for rows in (records_by_joint.get(name, ()),) - ) - - -def _fit_thumb_root_zero( - source_urdf: str | Path, - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], - curves: Mapping[str, JointCurveFit], - feedback_domains_rad: Mapping[str, tuple[float, float]], -) -> ZeroSolveResult: - """Fit the O12 root thumb phases with the shared G20 geometry kernel.""" - profile = _thumb_root_zero_profile() - measurements = [] - for cycle in range(4): - for name in O12_THUMB_ROOT_AXIS_JOINTS: - rows = _virtual_records( - name, - records_by_joint[name], - feedback_domains_rad[name], - feedback_domains_rad, - ) - measurement = fit_joint_axis_measurement( - name, - rows, - cycle=cycle, - zero_command_u8=255, - constrained_circle_joints=profile.constrained_circle_joints, - view_normal_common_xyz=rows[0]["view_normal_common_xyz"], - canonical_zero_direction="decreasing", - ) - measurements.append(with_depth_free_axis_projection( - measurement, - rows[0]["camera_center_common_xyz_m"], - )) - - result = solve_urdf_zero_offsets( - source_urdf=source_urdf, - measurements=measurements, - curves=curves, - motor_by_joint={ - name: COMMAND_INDEX_BY_JOINT[name] - for name in O12_THUMB_ROOT_AXIS_JOINTS - }, - training_cycles=(0, 1, 2), - validation_cycle=3, - maximum_offset_rad=math.radians(20.0), - finger_maximum_offset_rad=math.radians(20.0), - joint_maximum_offset_rad={ - name: math.radians(20.0) for name in ROOT_GEOMETRIC_ZERO_JOINTS - }, - maximum_cycle_difference_rad=math.radians(0.75), - minimum_applied_offset_rad=math.radians(0.1), - maximum_validation_mae_rad=math.radians(1.0), - maximum_validation_p95_rad=math.radians(2.0), - maximum_validation_error_rad=math.radians(3.0), - maximum_confidence_half_width_rad=math.radians(1.0), - # Axis-line displacement is retained as a diagnostic. Roll phase is - # determined by axis directions, so monocular depth/Tag placement is - # not allowed to reject an otherwise repeatable angular solution. - maximum_pose_axis_line_rms_m=0.0015, - maximum_systematic_axis_cone_bias_rad=math.radians(15.0), - hand_type="right", - tag_layout="o12_right_16", - zero_profile=profile, - ) - if not result.passed: - details = ",".join( - f"{name}={reason}" - for name, reason in sorted(result.failure_reasons.items()) - ) - raise ValueError("O12 thumb root spatial zero solve failed:" + details) - return result - - -def fit_o12_session( - source_urdf: str | Path, - records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]], - *, - cross_view_records_by_joint: Mapping[ - str, Sequence[Mapping[str, Any]] - ] | None = None, - require_cross_view: bool = False, - require_thumb_root_spatial_zero: bool = False, - require_full_hand_spatial_zero: bool = False, -) -> O12FitResult: - # SDK feedback supplies the continuous radian input domain; Tag relative - # rotation supplies the corresponding physical URDF coordinate and the - # shared G20 geometry kernel solves observable static thumb phase. - expected = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS - if set(records_by_joint) != expected: - missing = sorted(expected - set(records_by_joint)) - extra = sorted(set(records_by_joint) - expected) - raise ValueError(f"O12 records differ from profile: missing={missing} extra={extra}") - curves: dict[str, JointCurveFit] = {} - holdout: dict[str, tuple[float, ...]] = {} - cycle_curves: dict[str, dict[int, JointCurveFit]] = {} - feedback_domains = { - joint: _measured_feedback_domain(joint, records_by_joint[joint]) - for joint in expected - } - virtual_by_joint = { - joint: _virtual_records( - joint, - records_by_joint[joint], - feedback_domains[joint], - feedback_domains, - ) - for joint in expected - } - for joint in sorted(expected): - rows = virtual_by_joint[joint] - _validate_cycle_repeatability(joint, rows, feedback_domains[joint]) - training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}] - validation = [row for row in rows if int(row["cycle"]) == 3] - if not training or not validation: - raise ValueError(f"{joint} is missing training or holdout records") - fit = fit_rotation_joint_curve( - training, - zero_command_u8=255, - canonical_zero_direction="decreasing", - require_observed_domain_endpoints=False, - zero_reference_maximum_distance_u8=None, - ) - frame_errors = rotation_curve_holdout_errors( - fit, - validation, - zero_command_u8=255, - zero_reference_maximum_distance_u8=None, - ) - errors = _uniform_curve_holdout_errors(validation, frame_errors) - absolute = np.abs(np.asarray(errors, dtype=float)) - if ( - float(np.mean(absolute)) > math.radians(1.0) - or float(np.percentile(absolute, 95.0)) > math.radians(2.0) - or float(np.max(absolute)) > math.radians(3.0) - ): - raise ValueError(f"{joint} isolated holdout failed") - curves[joint] = fit - holdout[joint] = tuple(float(value) for value in errors) - cycle_curves[joint] = { - cycle: fit_rotation_joint_curve( - [row for row in training if int(row["cycle"]) == cycle], - zero_command_u8=255, - canonical_zero_direction="decreasing", - require_observed_domain_endpoints=False, - zero_reference_maximum_distance_u8=None, - ) - for cycle in (0, 1, 2) - } - - mimic_fits: dict[str, MimicFit] = {} - for target in sorted(MEASURED_PASSIVE_JOINTS): - source = MIMIC_SOURCE_BY_JOINT[target] - cycle_pairs = [ - ( - tuple(cycle_curves[source][cycle].decreasing_rad) - + tuple(cycle_curves[source][cycle].increasing_rad), - tuple(cycle_curves[target][cycle].decreasing_rad) - + tuple(cycle_curves[target][cycle].increasing_rad), - ) - for cycle in (0, 1, 2) - ] - mimic_fits[target] = fit_coupling_model( - source, - target, - curves[source], - curves[target], - # Retain the Tag-derived coupling as an independent diagnostic. - # Deployment uses the profile-declared vendor O12 polynomial. - model="direction_aware_knots", - cycle_curve_pairs=cycle_pairs, - minimum_multiplier=0.5, - maximum_multiplier=2.2, - ) - - cross_view_rows = { - str(name): tuple(values) - for name, values in (cross_view_records_by_joint or {}).items() - } - required_roll = {"middle_mcp_roll", "index_mcp_roll"} - if require_cross_view and set(cross_view_rows) != required_roll: - raise ValueError( - "O12 roll cross-view records are incomplete: " - f"missing={sorted(required_roll - set(cross_view_rows))} " - f"extra={sorted(set(cross_view_rows) - required_roll)}" - ) - cross_view_metrics: dict[str, dict[str, float]] = {} - for joint, source_rows in sorted(cross_view_rows.items()): - if joint not in required_roll: - raise ValueError(f"unexpected O12 roll cross-view joint: {joint}") - rows = _virtual_records( - joint, - source_rows, - feedback_domains[joint], - feedback_domains, - ) - training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}] - validation = [row for row in rows if int(row["cycle"]) == 3] - if not training or not validation: - raise ValueError(f"{joint} side view lacks training or holdout records") - secondary = fit_rotation_joint_curve( - training, - zero_command_u8=255, - canonical_zero_direction="decreasing", - require_observed_domain_endpoints=False, - zero_reference_maximum_distance_u8=None, - ) - frame_errors = rotation_curve_holdout_errors( - secondary, - validation, - zero_command_u8=255, - zero_reference_maximum_distance_u8=None, - ) - errors = _uniform_curve_holdout_errors(validation, frame_errors) - absolute = np.abs(np.asarray(errors, dtype=float)) - if ( - float(np.mean(absolute)) > math.radians(1.0) - or float(np.percentile(absolute, 95.0)) > math.radians(2.0) - or float(np.max(absolute)) > math.radians(3.0) - ): - raise ValueError(f"{joint} side-view isolated holdout failed") - metrics = cross_view_roll_diagnostic_metrics(curves[joint], secondary) - if metrics.get("direction_disagrees", 0.0): - raise ValueError(f"{joint} cross-view roll direction disagrees") - metrics.update({ - "holdout_mae_rad": float(np.mean(absolute)), - "holdout_p95_rad": float(np.percentile(absolute, 95.0)), - "holdout_max_rad": float(np.max(absolute)), - "maximum_hysteresis_rad": float( - secondary.maximum_hysteresis_rad - ), - "sample_count": float(len(rows)), - }) - cross_view_metrics[joint] = metrics - - visual_arcs = {joint: _travel(curves[joint]) for joint in expected} - travels = { - joint: visual_arcs[joint] for joint in CALIBRATED_ACTIVE_JOINTS - } - for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items(): - travels[target] = travels[donor] - offsets = {name: 0.0 for name in SDK_TO_URDF_JOINT} - zero_methods = {name: "source_cad_zero_not_measured" for name in offsets} - for name in STATIC_ZERO_EXCLUDED_JOINTS: - zero_methods[name] = "source_cad_zero_profile_excluded" - if ( - require_thumb_root_spatial_zero - and not _has_complete_thumb_root_geometry(records_by_joint) - ): - raise ValueError( - "O12 thumb root absolute zero requires common-frame roll, yaw, pitch " - "and palm-orientation axis trajectories" - ) - thumb_root_zero_result = None - full_hand_zero_result = None - spatial_error = None - if require_full_hand_spatial_zero: - from .zero import AXIS_JOINTS, O12SpatialZeroError, motor_index, solve_full_hand_zero - required = {"relative_translation_xyz_m", "parent_pose_common", "child_pose_common", - "view_normal_common_xyz", "camera_center_common_xyz_m", "state_rad"} - missing = [name for name in AXIS_JOINTS if not records_by_joint.get(name) - or any(not required.issubset(row) for row in records_by_joint[name])] - if missing: - raise O12SpatialZeroError( - "O12 full-hand spatial zero requires pose observations:" + ",".join(missing), - {"passed": False, "stage": "missing_geometry", "joints": missing}, - ) - # G20's geometry kernel uses a normalized 256-entry coordinate, not - # literal u8 motor commands. Rebase that private curve at SDK feedback - # zero and apply the same sign contract as the public radian mapper. - solver_curves = {} - for name in AXIS_JOINTS: - fit = curves[name] - domain = feedback_domains[name] - baseline = feedback_rad_to_curve_index(name, 0., domain) - lo = feedback_rad_to_curve_index(name, domain[0], domain) - hi = feedback_rad_to_curve_index(name, domain[1], domain) - values = np.asarray(fit.angle_rad, dtype=float) - direction = np.sign(np.interp(hi, np.arange(256), values) - - np.interp(lo, np.arange(256), values)) - multiplier = 1. if direction == SDK_TO_URDF_SIGN[motor_index(name)] else -1. - fields = {} - for field in ("angle_rad", "decreasing_rad", "increasing_rad"): - values = np.asarray(getattr(fit, field), dtype=float) - fields[field] = tuple(multiplier * (values - np.interp(baseline, np.arange(256), values))) - solver_curves[name] = replace(fit, **fields) - try: - full_hand_zero_result = solve_full_hand_zero( - source_urdf, - {name: _virtual_records(name, records_by_joint[name], feedback_domains[name], feedback_domains) - for name in AXIS_JOINTS}, - solver_curves, - ) - except O12SpatialZeroError as error: - if "result" not in error.diagnostics: - raise # Missing/unobservable geometry has no review estimate. - full_hand_zero_result = ZeroSolveResult(**error.diagnostics["result"]) - spatial_error = error - for name in GEOMETRIC_ZERO_JOINTS: - offsets[name] = float(full_hand_zero_result.direct_offsets_rad[name]) - zero_methods[name] = "urdf_serial_axis_geometry" - elif _has_complete_thumb_root_geometry(records_by_joint): - thumb_root_zero_result = _fit_thumb_root_zero( - source_urdf, - records_by_joint, - curves, - feedback_domains, - ) - for name in ROOT_GEOMETRIC_ZERO_JOINTS: - offsets[name] = float(thumb_root_zero_result.direct_offsets_rad[name]) - zero_methods[name] = "urdf_serial_axis_geometry" - # Transfer the scalar correction, never the donor's origin transform. - # The writer composes it with the ring's own CAD joint frame. - for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items(): - offsets[target] = offsets[donor] - zero_methods[target] = "transferred_static_zero_on_own_cad" - result = O12FitResult( - curves=curves, - zero_offsets_rad=offsets, - travels_rad=travels, - mimic_fits=mimic_fits, - holdout_errors_rad=holdout, - cross_view_roll_metrics=cross_view_metrics, - visual_arc_diagnostics_rad=visual_arcs, - feedback_domains_rad=feedback_domains, - zero_method_by_joint=zero_methods, - full_hand_zero_result=full_hand_zero_result, - thumb_root_zero_result=thumb_root_zero_result, - ) - if spatial_error is not None: - spatial_error.review_fit = result - raise spatial_error - return result - - -__all__ = [ - "O12FitResult", "O12_THUMB_ROOT_AXIS_JOINTS", - "curve_input_knots_rad", "curve_values_at_rad", - "feedback_rad_to_curve_index", "fit_o12_session", "measured_curve_bounds", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/motion.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/motion.py deleted file mode 100644 index 4f06d6f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/motion.py +++ /dev/null @@ -1,133 +0,0 @@ -"""Physical-radian motion helpers for O12 right calibration.""" - -from __future__ import annotations - -import math -from typing import Sequence - -from ...core import CalibrationProfile, TaskSpec - - -def cosine_position_trajectory_rad( - start_rad: float, - target_rad: float, - elapsed_seconds: float, - maximum_speed_rad_s: float, -) -> tuple[float, float, float]: - """Cosine trajectory whose peak velocity does not exceed the limit.""" - speed = float(maximum_speed_rad_s) - if not math.isfinite(speed) or speed <= 0.0: - raise ValueError("maximum_speed_rad_s must be positive") - distance = abs(float(target_rad) - float(start_rad)) - if distance <= 0.0: - return float(target_rad), 1.0, 0.0 - duration = math.pi * distance / (2.0 * speed) - phase = min(1.0, max(0.0, float(elapsed_seconds) / duration)) - blend = 0.5 - 0.5 * math.cos(math.pi * phase) - return ( - float(start_rad) + (float(target_rad) - float(start_rad)) * blend, - phase, - duration, - ) - - -def cosine_ramp_velocity_trajectory_rad( - start_rad: float, - target_rad: float, - elapsed_seconds: float, - maximum_speed_rad_s: float, - ramp_seconds: float, -) -> tuple[float, float, float]: - """Velocity-limited trajectory with cosine ramps and a constant-speed core.""" - speed = float(maximum_speed_rad_s) - ramp = float(ramp_seconds) - if not math.isfinite(speed) or speed <= 0.0: - raise ValueError("maximum_speed_rad_s must be positive") - if not math.isfinite(ramp) or ramp <= 0.0: - raise ValueError("ramp_seconds must be positive") - start = float(start_rad) - target = float(target_rad) - distance = abs(target - start) - if distance <= 0.0: - return target, 1.0, 0.0 - - # For very short moves there is no room for a constant-speed section; - # retain the bounded position-cosine trajectory. - if distance <= speed * ramp: - return cosine_position_trajectory_rad( - start, target, elapsed_seconds, speed - ) - - cruise_seconds = distance / speed - ramp - duration = 2.0 * ramp + cruise_seconds - elapsed = min(duration, max(0.0, float(elapsed_seconds))) - ramp_distance = 0.5 * speed * ramp - if elapsed < ramp: - travelled = speed * ( - 0.5 * elapsed - - ramp * math.sin(math.pi * elapsed / ramp) / (2.0 * math.pi) - ) - elif elapsed < ramp + cruise_seconds: - travelled = ramp_distance + speed * (elapsed - ramp) - else: - down = elapsed - ramp - cruise_seconds - travelled = ( - ramp_distance - + speed * cruise_seconds - + speed * ( - 0.5 * down - + ramp * math.sin(math.pi * down / ramp) / (2.0 * math.pi) - ) - ) - fraction = min(1.0, max(0.0, travelled / distance)) - return ( - start + (target - start) * fraction, - min(1.0, max(0.0, elapsed / duration)), - duration, - ) - - -def build_calibration_motion_command( - task: TaskSpec, - command_rad: float, - *, - profile: CalibrationProfile, -) -> list[float]: - values = list(profile.command.baseline_values) - for index, value in task.auxiliary_commands: - values[int(index)] = float(value) - values[int(task.command_index)] = float(command_rad) - return values - - -def build_calibration_preparation_waypoints( - task: TaskSpec, - *, - profile: CalibrationProfile, - current_command: Sequence[float] | None = None, -) -> tuple[tuple[float, ...], ...]: - del current_command - return (tuple(build_calibration_motion_command(task, task.start_value, profile=profile)),) - - -def build_calibration_return_waypoints( - target_command: Sequence[float] | None = None, - *, - profile: CalibrationProfile, - current_command: Sequence[float] | None = None, - **_: object, -) -> tuple[tuple[float, ...], ...]: - del current_command - target = tuple( - profile.command.baseline_values if target_command is None else target_command - ) - if len(target) != profile.command.command_count: - raise ValueError("O12 return command has the wrong channel count") - return (tuple(float(value) for value in target),) - - -__all__ = [ - "build_calibration_motion_command", "build_calibration_preparation_waypoints", - "build_calibration_return_waypoints", "cosine_position_trajectory_rad", - "cosine_ramp_velocity_trajectory_rad", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/node.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/node.py deleted file mode 100644 index 217fd7f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/node.py +++ /dev/null @@ -1,1758 +0,0 @@ -"""Safety-gated online acquisition node for O12 right.""" - -from __future__ import annotations - -from dataclasses import dataclass -import math -import time - -import numpy as np -import cv2 -import rclpy -from apriltag_msgs.msg import AprilTagDetectionArray -from rclpy.executors import MultiThreadedExecutor -from scipy.spatial.transform import Rotation -from sensor_msgs.msg import JointState -from std_msgs.msg import Empty, Int8MultiArray, Int16MultiArray - -from ...acquisition import interpolate_state_u8 -from ...extrinsics import matrix_payload, transform_matrix -from ...pnp import SquareTagPose, solve_square_tag_ippe -from ...storage import append_jsonl, append_jsonl_many -from ...runtime import CalibrationEngine -from ..l6.node import ( - L6ThreeCameraCalibrationNode, - MotionStep as LegacyMotionStep, - _stamp_ns, -) -from .pipeline import finalize_o12_session -from .health import ( - assess_o12_error_report, - decoded_faults, - historical_communication_latch_confirmed, -) -from .motion import cosine_ramp_velocity_trajectory_rad -from .pipeline import load_o12_raw_samples -from .quality import QUALITY_POLICY_VERSION -from .profile import ( - CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD, - CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION, - CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD, - EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION, - FORMAL_SPEED_CAP_RAD_S, - INITIAL_FEEDBACK_SPAN_FRACTION, - INDEX_CLEARANCE_MCP_RAD, - INDEX_CLEARANCE_RAD, - PARK_MIDDLE_MCP_RAD, - PARK_MIDDLE_PIP_RAD, - PARK_PINKY_MCP_RAD, - PARK_RING_MCP_RAD, - NORMALIZED_SWEEP_BIN_COUNT, - ROLL_CROSS_VIEW_BY_TASK, - build_typed_profile, -) -from .resume import build_resume_checkpoint_from_rows -from .pnp import O12ThumbPoseTracker, THUMB_ROLES - - -POSITION_MODE = 0 - - -@dataclass(frozen=True) -class MotionStep(LegacyMotionStep): - """O12-only motion step with a feedback-relative mapping probe.""" - - relative_feedback_delta: float | None = None - required_endpoint_indices: tuple[int, ...] = () - - -class O12ThreeCameraCalibrationNode(L6ThreeCameraCalibrationNode): - def _record_capture_pose_evidence(self, view, step, roles, corners, selected, stamp, *, locked_roles=()): - if (step is None or step.task_key is None - or step.task_key == 'thumb_mcp_dip_front'): - return # The articulated selector already records thumb evidence. - def payload(p): - return {'quaternion_xyzw': list(p.quaternion_xyzw), - 'translation_xyz_m': list(p.translation_xyz_m), - 'reprojection_error_px': float(p.reprojection_error_px)} - tracker = self.trackers[view] - ids = {t.role: t.tag_id for t in self._view(view).tags} - append_jsonl(self.raw_path, { - 'kind': 'o12_pnp_candidate_frame', 'diagnostic_schema': 1, - 'selection_policy': 'independent_provisional', - 'task_name': step.task_key, 'cycle': step.cycle, - 'direction': step.direction, 'attempt': step.attempt, - 'phase': step.phase, 'view': view, 'image_stamp_ns': stamp, - 'tag_size_m': float(self.tag_size_m), - 'camera_matrix': np.asarray(self.camera_matrices[view]).tolist(), - 'camera_matrix_source': 'CameraInfo.P[:3,:3]', - 'input_is_rectified': True, 'distortion_coefficients': [0.0]*4, - 'roles': {role: { - 'tag_id': ids[role], - 'corners_xy': np.asarray(corners[role]).tolist() if role in corners else None, - 'observation_source': 'locked_reference' if role in locked_roles else 'image', - 'candidates': [payload(p) for p in tracker.last_candidates_by_role.get(role, ())] - if role not in locked_roles else [], - 'selected': payload(selected[role]), - 'maximum_reprojection_error_px': tracker.maximum_reprojection_error_px, - } for role in roles}, - }) - - def _select_articulated_capture_poses(self, view, step, roles, corners, stamp): - if (view != 'front' or step is None - or step.task_key != 'thumb_mcp_dip_front' - or set(roles) != set(THUMB_ROLES)): - return None - if not hasattr(self, '_thumb_pose_group'): - self._thumb_pose_group = O12ThumbPoseTracker() - tracker = self.trackers[view] - candidates = {} - all_candidates = {} - for role in THUMB_ROLES: - try: - all_candidates[role] = solve_square_tag_ippe( - corners[role], tag_size_m=float(self.tag_size_m), - camera_matrix=self.camera_matrices[view]) - except (ValueError, cv2.error): - all_candidates[role] = () - candidates[role] = tuple( - p for p in all_candidates[role] - if p.reprojection_error_px <= tracker.maximum_reprojection_error_px) - selected, reason = self._thumb_pose_group.select(candidates, stamp_ns=stamp) - def payload(pose): - return {'quaternion_xyzw': list(pose.quaternion_xyzw), - 'translation_xyz_m': list(pose.translation_xyz_m), - 'reprojection_error_px': float(pose.reprojection_error_px)} - # Save once per image, including discarded frames. Existing joint - # samples / schemas / resume units are unchanged. - append_jsonl(self.raw_path, { - 'kind': 'o12_pnp_candidate_frame', 'diagnostic_schema': 1, - 'selection_policy': 'o12_thumb_parallel_axes_v1', - 'task_name': step.task_key, 'cycle': step.cycle, - 'direction': step.direction, 'attempt': step.attempt, - 'phase': step.phase, 'view': view, 'image_stamp_ns': stamp, - 'tag_size_m': float(self.tag_size_m), - 'camera_matrix': np.asarray(self.camera_matrices[view]).tolist(), - 'camera_matrix_source': 'CameraInfo.P[:3,:3]', - 'input_is_rectified': True, 'distortion_coefficients': [0.0]*4, - 'roles': {role: { - 'tag_id': next(t.tag_id for t in self._view(view).tags if t.role == role), - 'corners_xy': np.asarray(corners[role]).tolist(), - 'candidates': [payload(p) for p in all_candidates[role]], - 'eligible_candidate_count': len(candidates[role]), - 'maximum_reprojection_error_px': tracker.maximum_reprojection_error_px, - 'selected': None if selected is None else payload(selected[role]), - } for role in THUMB_ROLES}, - 'selection_reason': reason, - }) - return selected, reason - - @staticmethod - def _uses_isolated_motion_callbacks() -> bool: - return True - - def __init__(self) -> None: - super().__init__( - profile=build_typed_profile(), - finalizer=finalize_o12_session, - sample_kind="o12_joint_sample", - ) - self.mode_verified = False - self.error_verified = False - self.temperature_verified = False - self.temperature_fallback_active = False - self.health_check_started_at = time.monotonic() - self.latest_errors: tuple[int, ...] = () - self.error_health_classification = "awaiting_error_report" - self.error_report_count = 0 - self.error_report_first_matching_at = 0.0 - self.error_report_matching_count = 0 - self.error_report_matching_codes: tuple[int, ...] = () - self.confirmed_historical_communication_channels: tuple[int, ...] = () - self.error_health_audit_written = False - self.latest_temperatures: tuple[int, ...] = () - self.last_health_query_at = 0.0 - self.last_temperature_query_at = 0.0 - self.preflight_reference_by_task: dict[str, Rotation] = {} - self.preflight_maximum_rotation_by_task: dict[str, float] = {} - self.measured_direction_axis_by_task: dict[str, tuple[float, float, float]] = {} - self.locked_base_pose_by_view: dict[str, SquareTagPose] = {} - self.resolved_probe_target_by_step: dict[int, tuple[float, ...]] = {} - self.probe_feedback_origin_by_step: dict[int, float] = {} - self.resume_source_session = "" - self.resume_compatibility = "not_requested" - self.resumed_unit_keys: frozenset[tuple[str, int, str]] = frozenset() - self.resumed_task_keys: frozenset[str] = frozenset() - self.resume_skipped_step_count = 0 - self.normalized_sweep_bin_count = NORMALIZED_SWEEP_BIN_COUNT - self.latest_o12_feedback_coupling: dict[str, object] = {} - self.latest_o12_auxiliary_tracking: dict[str, object] = {} - self.o12_auxiliary_violation_since: dict[int, float] = {} - self._reset_o12_cross_view_counters() - if self.resume_raw_samples_path is not None: - self._restore_resume_checkpoint() - self.control_mode_publisher = self.create_publisher( - Int8MultiArray, "/o12/right/joint_control_mode_cmd", 10 - ) - self.error_query_publisher = self.create_publisher( - Empty, "/o12/right/joint_error_cmd", 10 - ) - self.temperature_query_publisher = self.create_publisher( - Empty, "/o12/right/joint_temperature_cmd", 10 - ) - self.create_subscription( - Int8MultiArray, - "/o12/right/joint_control_mode_states", - self._mode_callback, - 10, - ) - self.create_subscription( - Int16MultiArray, - "/o12/right/joint_error_states", - self._error_callback, - 10, - ) - self.create_subscription( - Int16MultiArray, - "/o12/right/joint_temperature_states", - self._temperature_callback, - 10, - ) - - def _roll_cross_view_observer(self, step=None): - selected = self._current_step() if step is None else step - if selected is None or selected.task_key is None: - return None - return ROLL_CROSS_VIEW_BY_TASK.get(str(selected.task_key)) - - def _reset_o12_cross_view_counters(self) -> None: - self.o12_cross_view_total_frames = 0 - self.o12_cross_view_valid_frames = 0 - self.o12_cross_view_tag_seen_frames: dict[str, int] = {} - self.o12_cross_view_tag_quality_frames: dict[str, int] = {} - self.o12_cross_view_pnp_valid_frames = 0 - self.o12_cross_view_state_sync_frames = 0 - self.o12_cross_view_rejection_counts: dict[str, int] = {} - self.o12_cross_view_recognized_tag_ids: tuple[int, ...] = () - self.o12_cross_view_unrecognized_tag_ids: tuple[int, ...] = () - - def _reset_step_observation_counters( - self, roles: tuple[str, ...] = () - ) -> None: - super()._reset_step_observation_counters(roles) - self._reset_o12_cross_view_counters() - self.latest_o12_auxiliary_tracking = {} - self.o12_auxiliary_violation_since = {} - observer = self._roll_cross_view_observer() - if observer is not None: - cross_roles = (observer.parent_role, observer.child_role) - self.o12_cross_view_tag_seen_frames = { - role: 0 for role in cross_roles - } - self.o12_cross_view_tag_quality_frames = { - role: 0 for role in cross_roles - } - id_by_role = { - tag.role: int(tag.tag_id) - for tag in self._view(observer.view).tags - } - self.o12_cross_view_unrecognized_tag_ids = tuple( - sorted(id_by_role[role] for role in cross_roles) - ) - - def _count_o12_cross_view_rejection(self, reason: str) -> None: - self.o12_cross_view_rejection_counts[reason] = ( - self.o12_cross_view_rejection_counts.get(reason, 0) + 1 - ) - - def _o12_cross_view_metrics(self, observer=None) -> dict[str, object]: - selected = observer or self._roll_cross_view_observer() - roles = ( - () - if selected is None - else (selected.parent_role, selected.child_role) - ) - total = int(self.o12_cross_view_total_frames) - - def rate(count: int) -> float: - return float(count) / total if total else 0.0 - - quality = { - role: rate(self.o12_cross_view_tag_quality_frames.get(role, 0)) - for role in roles - } - return { - "view": None if selected is None else selected.view, - "tag_detection_rate_by_role": quality, - "tag_detection_rate": min(quality.values(), default=0.0), - "pnp_valid_rate": rate(self.o12_cross_view_pnp_valid_frames), - "state_sync_rate": rate(self.o12_cross_view_state_sync_frames), - "joint_frame_rate": rate(self.o12_cross_view_valid_frames), - "rejection_counts": dict( - sorted(self.o12_cross_view_rejection_counts.items()) - ), - } - - def _state_callback(self, message: JointState) -> None: - """Record all twelve SDK coordinates without model-theory stop gates.""" - L6ThreeCameraCalibrationNode._state_callback(self, message) - step = self._current_step() - if ( - step is None - or step.task_key is None - or len(self.latest_state_u8) != self.command_count - ): - self.latest_o12_auxiliary_tracking = {} - return - task = self._task(str(step.task_key)) - target = self._target_command(step) - self.latest_o12_auxiliary_tracking = { - "active": bool(task.auxiliary_commands), - "task_name": task.key, - "decision": "diagnostic_only", - "channels": [ - { - "channel": self.command_names[int(index)], - "feedback_rad": round(float(self.latest_state_u8[int(index)]), 9), - "target_rad": round(float(target[int(index)]), 9), - "error_rad": round(abs( - float(self.latest_state_u8[int(index)]) - - float(target[int(index)]) - ), 9), - } - for index, _value in task.auxiliary_commands - ], - } - - def _apply_o12_tracking_diagnostics( - self, status: dict[str, object] - ) -> dict[str, object]: - """Compare O12 feedback with the command published this tick.""" - if ( - len(self.latest_state_u8) == self.command_count - and self.step_last_command_u8 is not None - and len(self.step_last_command_u8) == self.command_count - ): - errors = [ - abs(float(actual) - float(command)) - for actual, command in zip( - self.latest_state_u8, self.step_last_command_u8 - ) - ] - status["channel_errors_rad"] = [ - round(error, 6) for error in errors - ] - status["maximum_error_channel"] = self.command_names[ - int(np.argmax(errors)) - ] - status["maximum_error_rad"] = round(max(errors), 3) - return status - - def _detections_callback( - self, view: str, message: AprilTagDetectionArray - ) -> None: - step = self._current_step() - observer = self._roll_cross_view_observer(step) - if ( - observer is not None - and view == observer.view - and step is not None - and step.recording - and self.step_command_sent - ): - self._record_o12_roll_cross_view(observer, step, message) - return - super()._detections_callback(view, message) - - def _record_o12_roll_cross_view( - self, observer, step: MotionStep, message: AprilTagDetectionArray - ) -> None: - """Record the side camera as a required observer of one roll sweep.""" - view = str(observer.view) - if view not in self.camera_matrices: - return - roles = (observer.parent_role, observer.child_role) - role_by_id = {tag.tag_id: tag.role for tag in self._view(view).tags} - id_by_role = {tag.role: int(tag.tag_id) for tag in self._view(view).tags} - corners: dict[str, np.ndarray] = {} - good: dict[str, bool] = {} - detected: set[str] = set() - failures_by_role: dict[str, tuple[str, ...]] = {} - width, height = self.image_sizes.get(view, (0, 0)) - for detection in message.detections: - role = role_by_id.get(int(detection.id)) - if role not in roles: - continue - detected.add(str(role)) - points = np.asarray( - [[float(point.x), float(point.y)] for point in detection.corners], - dtype=float, - ) - if points.shape != (4, 2): - failures_by_role[str(role)] = ("malformed_corners",) - continue - edges = np.linalg.norm(points - np.roll(points, -1, axis=0), axis=1) - border_ok = bool( - width > 0 and height > 0 - and np.min(points[:, 0]) >= 2.0 - and np.max(points[:, 0]) <= width - 3.0 - and np.min(points[:, 1]) >= 2.0 - and np.max(points[:, 1]) <= height - 3.0 - ) - reasons: list[str] = [] - if int(detection.hamming) > int(self.maximum_hamming): - reasons.append("hamming") - if float(detection.decision_margin) < float( - self.minimum_decision_margin - ): - reasons.append("decision_margin") - if float(np.min(edges)) < float(self.minimum_edge_pixels): - reasons.append("edge_pixels") - if not border_ok: - reasons.append("image_border") - candidate_good = not reasons - if role not in corners or candidate_good or not good.get(role, False): - corners[str(role)] = points - good[str(role)] = candidate_good - failures_by_role[str(role)] = tuple(reasons) - - self.o12_cross_view_total_frames += 1 - for role in roles: - if role in detected: - self.o12_cross_view_tag_seen_frames[role] += 1 - else: - self._count_o12_cross_view_rejection(f"tag:{role}:missing") - if good.get(role, False): - self.o12_cross_view_tag_quality_frames[role] += 1 - else: - for reason in failures_by_role.get(role, ()): - self._count_o12_cross_view_rejection( - f"tag:{role}:{reason}" - ) - self.o12_cross_view_recognized_tag_ids = tuple( - sorted(id_by_role[role] for role in roles if good.get(role, False)) - ) - self.o12_cross_view_unrecognized_tag_ids = tuple( - sorted(id_by_role[role] for role in roles if not good.get(role, False)) - ) - if not all(role in corners and good.get(role, False) for role in roles): - return - - base_role = observer.parent_role - if base_role in self.base_corner_reference: - drift = float(np.max(np.linalg.norm( - corners[base_role] - self.base_corner_reference[view], axis=1 - ))) - self.latest_base_drift_px[view] = drift - self.base_drift_counts[view] = ( - self.base_drift_counts.get(view, 0) + 1 - if drift > float(self.fixed_base_maximum_corner_drift_px) - else 0 - ) - if self.base_drift_counts[view] >= int( - self.fixed_base_movement_confirmation_frames - ): - append_jsonl(self.raw_path, { - "kind": "fixed_base_reference_moved", - "view": view, - "tag_id": id_by_role[base_role], - "corner_drift_px": round(drift, 6), - "maximum_corner_drift_px": float( - self.fixed_base_maximum_corner_drift_px - ), - "confirmation_frames": self.base_drift_counts[view], - "task_name": step.task_key, - }) - self._pause( - f"fixed_base_tag_moved:{view}:drift_px={drift:.3f}" - ) - return - - stamp = _stamp_ns(message.header.stamp) - selected: dict[str, SquareTagPose] = {} - for role in roles: - pose, reason = self.trackers[view].estimate( - role, - corners[role], - tag_size_m=float(self.tag_size_m), - camera_matrix=self.camera_matrices[view], - stamp_ns=stamp, - ) - if pose is None: - self._count_o12_cross_view_rejection( - f"pnp:{role}:{reason or 'rejected'}" - ) - return - selected[role] = pose - self.o12_cross_view_pnp_valid_frames += 1 - self._record_capture_pose_evidence(view, step, roles, corners, selected, stamp) - self.last_view_valid_at[view] = time.monotonic() - - matched = interpolate_state_u8( - list(self.state_history), - stamp, - maximum_skew_ns=self.maximum_state_image_skew_ns, - ) - if matched is None: - self._count_o12_cross_view_rejection( - "state_sync:no_sample_within_limit" - ) - return - state_rad, skew_ns = matched - self.o12_cross_view_state_sync_frames += 1 - task = self._task(str(step.task_key)) - feedback = float(state_rad[task.command_index]) - progress = float(np.clip( - self.profile.command.normalize(task.command_index, feedback), - 0.0, - 1.0, - )) - parent = selected[observer.parent_role] - child = selected[observer.child_role] - common_from_view = self.extrinsics.transform(view) - parent_matrix = common_from_view @ transform_matrix( - parent.translation_xyz_m, parent.quaternion_xyzw - ) - child_matrix = common_from_view @ transform_matrix( - child.translation_xyz_m, child.quaternion_xyzw - ) - relative = ( - Rotation.from_matrix(parent_matrix[:3, :3]).inv() - * Rotation.from_matrix(child_matrix[:3, :3]) - ) - relative_translation = Rotation.from_quat( - parent.quaternion_xyzw - ).inv().apply( - np.asarray(child.translation_xyz_m, dtype=float) - - np.asarray(parent.translation_xyz_m, dtype=float) - ) - record = { - "kind": "o12_roll_cross_view_sample", - "profile_id": self.profile.key.profile_id, - "task_name": task.key, - "view": view, - "joint": observer.source_joint, - "model_joint": observer.model_joint, - "sdk_channel": self.command_names[task.command_index], - "motor_index": int(task.command_index), - "coupled_sdk_channel": self.command_names[ - observer.coupled_channel_index - ], - "cycle": int(step.cycle), - "direction": str(step.direction), - "attempt": int(step.attempt), - "requested_command_rad": round(float(step.target_u8), 9), - "trajectory_command_rad": round(self.step_requested_u8, 9), - "command_rad": round(self.step_requested_u8, 9), - "feedback_rad": round(feedback, 9), - "coupled_feedback_rad": round( - float(state_rad[observer.coupled_channel_index]), 9 - ), - "progress_01": round(progress, 9), - "state_rad": [round(float(value), 9) for value in state_rad], - "state_image_sync_error_ms": round( - abs(skew_ns) / 1_000_000.0, 6 - ), - "relative_quaternion_xyzw": [ - float(value) for value in relative.as_quat() - ], - "relative_translation_xyz_m": [ - float(value) for value in relative_translation - ], - "parent_pose_common": matrix_payload(parent_matrix), - "child_pose_common": matrix_payload(child_matrix), - "pnp_reprojection_error_px": round( - max( - parent.reprojection_error_px, - child.reprojection_error_px, - ), - 6, - ), - "image_stamp_ns": int(stamp), - } - self.raw_records.append(record) - append_jsonl(self.raw_path, record) - self.o12_cross_view_valid_frames += 1 - - def _qualify_recording_step(self, step: MotionStep) -> None: - """Apply only retained-data and required dual-view gates.""" - task = self._task(str(step.task_key)) - engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile)) - observer = self._roll_cross_view_observer(step) - if observer is not None: - rows = [ - row for row in self.raw_records - if row.get("kind") == "o12_roll_cross_view_sample" - and row.get("task_name") == observer.task_name - and int(row.get("cycle", -1)) == int(step.cycle) - and row.get("direction") == step.direction - and int(row.get("attempt", 1)) == int(step.attempt) - and row.get("joint") == observer.source_joint - ] - feedback = np.asarray( - [float(row["feedback_rad"]) for row in rows], dtype=float - ) - normalized = self._normalize_o12_feedback(task, step, feedback) - observation = self._o12_cross_view_metrics(observer) - decision = engine.evaluate_sweep( - normalized, - minimum_span=self._required_radian_feedback_span_fraction( - task, step, feedback - ), - total_frames=self.o12_cross_view_total_frames, - joint_frame_rate=float(observation["joint_frame_rate"]), - feedback_hz=self._feedback_hz(), - detection_rate=float(observation["tag_detection_rate"]), - bin_count=int(self.normalized_sweep_bin_count), - ) - append_jsonl(self.raw_path, { - "kind": "o12_roll_cross_view_quality", - "task_name": observer.task_name, - "source_joint": observer.source_joint, - "model_joint": observer.model_joint, - "view": observer.view, - "cycle": step.cycle, - "direction": step.direction, - "attempt": step.attempt, - "total_frames": self.o12_cross_view_total_frames, - **observation, - **decision.metrics, - "quality_policy_version": QUALITY_POLICY_VERSION, - "warnings": list(decision.warnings), - "failures": list(decision.failures), - "passed": decision.passed, - }) - # Run the side gate first. A failed side attempt must never leave - # behind a passing primary quality row that checkpoint recovery - # could mistake for a complete dual-view transaction. - if decision.failures: - raise ValueError(",".join( - f"side_{failure}" for failure in decision.failures - )) - self._qualify_o12_primary_recording_step(step) - - def _qualify_o12_primary_recording_step(self, step: MotionStep) -> None: - """Judge a radian sweep by retained fitting information, not drop rate.""" - task = self._task(str(step.task_key)) - rows = [ - row for row in self.raw_records - if row.get("kind", "o12_joint_sample") == "o12_joint_sample" - and row.get("task_name") == task.key - and int(row.get("cycle", -1)) == int(step.cycle) - and row.get("direction") == step.direction - and int(row.get("attempt", 1)) == int(step.attempt) - and row.get("joint") == task.joints[0] - and math.isfinite(float(row.get("feedback_rad", math.nan))) - ] - feedback = np.asarray( - [float(row["feedback_rad"]) for row in rows], dtype=float - ) - normalized = self._normalize_o12_feedback(task, step, feedback) - observation = self._step_observation_metrics() - engine = getattr(self, "calibration_engine", CalibrationEngine(self.profile)) - decision = engine.evaluate_sweep( - normalized, - minimum_span=self._required_radian_feedback_span_fraction( - task, step, feedback - ), - total_frames=self.step_total_frames, - joint_frame_rate=float(observation["joint_frame_rate"]), - feedback_hz=self._feedback_hz(), - detection_rate=float(observation["tag_detection_rate"]), - bin_count=int(self.normalized_sweep_bin_count), - ) - append_jsonl(self.raw_path, { - "kind": self.sweep_quality_kind, - "task_name": step.task_key, - "cycle": step.cycle, - "direction": step.direction, - "attempt": step.attempt, - "total_frames": self.step_total_frames, - **observation, - **decision.metrics, - "quality_policy_version": QUALITY_POLICY_VERSION, - "warnings": list(decision.warnings), - "failures": list(decision.failures), - "passed": decision.passed, - }) - if decision.failures: - raise ValueError(",".join(decision.failures)) - - def _required_radian_feedback_span_fraction( - self, - task, - step: MotionStep, - _feedback: np.ndarray, - ) -> float: - """Return the O12-only effective-travel gate for one sweep. - - O12 reports a continuous physical coordinate whose endpoint scale and - zero offset are part of the curve being calibrated. Cycle zero must - therefore establish the observed full-stroke reference instead of - requiring feedback to numerically equal the command endpoints. Every - later cycle must reproduce that same measured travel. - """ - floor = float(INITIAL_FEEDBACK_SPAN_FRACTION) - if step.cycle is None or int(step.cycle) == 0: - return floor - - reference = self._o12_cycle_zero_feedback_span_fraction( - task, str(step.direction) - ) - if reference is None: - # A normal run cannot reach a later cycle without an accepted - # cycle-zero unit. Retaining the absolute floor makes a damaged - # or legacy checkpoint fail safely without changing other hands. - return floor - return float(EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION) * reference - - def _normalize_o12_feedback( - self, task, step: MotionStep, feedback: np.ndarray - ) -> np.ndarray: - """Normalize against measured O12 travel, never command endpoints.""" - values = np.asarray(feedback, dtype=float) - if values.size == 0: - return values - if step.cycle is None or int(step.cycle) == 0: - lower, upper = float(np.min(values)), float(np.max(values)) - else: - bounds = self._o12_cycle_zero_feedback_bounds( - task, str(step.direction) - ) - if bounds is None: - return np.zeros_like(values) - lower, upper = bounds - span = upper - lower - if not math.isfinite(span) or span <= 1.0e-9: - return np.zeros_like(values) - return (values - lower) / span - - def _o12_cycle_zero_feedback_bounds( - self, task, direction: str - ) -> tuple[float, float] | None: - """Return the widest accepted/current cycle-zero feedback interval.""" - bounds: list[tuple[float, float]] = [] - attempts = sorted({ - int(row.get("attempt", 1)) - for row in self.raw_records - if row.get("kind", "o12_joint_sample") == "o12_joint_sample" - and row.get("task_name") == task.key - and int(row.get("cycle", -1)) == 0 - and row.get("direction") == direction - and row.get("joint") == task.joints[0] - }) - for attempt in attempts: - feedback = [ - float(row["feedback_rad"]) - for row in self.raw_records - if row.get("kind", "o12_joint_sample") == "o12_joint_sample" - and row.get("task_name") == task.key - and int(row.get("cycle", -1)) == 0 - and row.get("direction") == direction - and int(row.get("attempt", 1)) == attempt - and row.get("joint") == task.joints[0] - and math.isfinite(float(row.get("feedback_rad", math.nan))) - ] - if feedback: - bounds.append((min(feedback), max(feedback))) - if not bounds: - return None - return max(bounds, key=lambda item: item[1] - item[0]) - - def _o12_cycle_zero_feedback_span_fraction( - self, task, direction: str - ) -> float | None: - """Find the accepted cycle-zero physical span for this O12 direction.""" - bounds = self._o12_cycle_zero_feedback_bounds(task, direction) - if bounds is None: - return None - # Cycle-zero bounds define the normalized physical coordinate. - return 1.0 if bounds[1] - bounds[0] > 1.0e-9 else 0.0 - - def _declare_parameters(self) -> None: - super()._declare_parameters() - self.declare_parameter("maximum_temperature_c", 70) - self.declare_parameter("temperature_report_required", False) - self.declare_parameter("temperature_fallback_after_seconds", 5.0) - self.declare_parameter("motion_speed_scale", 1.0) - self.declare_parameter("trajectory_ramp_seconds", 0.4) - - def _load_parameters(self) -> None: - super()._load_parameters() - self.maximum_temperature_c = int( - self.get_parameter("maximum_temperature_c").value - ) - self.temperature_report_required = bool( - self.get_parameter("temperature_report_required").value - ) - self.temperature_fallback_after_seconds = float( - self.get_parameter("temperature_fallback_after_seconds").value - ) - self.motion_speed_scale = float( - self.get_parameter("motion_speed_scale").value - ) - self.trajectory_ramp_seconds = float( - self.get_parameter("trajectory_ramp_seconds").value - ) - if not 40 <= self.maximum_temperature_c <= 90: - raise ValueError("maximum_temperature_c must be in [40, 90]") - if not 1.0 <= self.temperature_fallback_after_seconds <= 30.0: - raise ValueError("temperature_fallback_after_seconds must be in [1, 30]") - if not 0.5 <= self.motion_speed_scale <= 4.0: - raise ValueError("motion_speed_scale must be in [0.5, 4.0]") - if not 0.2 <= self.trajectory_ramp_seconds <= 1.0: - raise ValueError("trajectory_ramp_seconds must be in [0.2, 1.0]") - - def _temperature_ready(self) -> bool: - return self.temperature_verified or self.temperature_fallback_active - - def _activate_temperature_fallback_if_allowed(self, now: float) -> None: - if ( - self.temperature_report_required - or self.temperature_verified - or self.temperature_fallback_active - or not self.error_verified - or now - self.health_check_started_at - < self.temperature_fallback_after_seconds - ): - return - self.temperature_fallback_active = True - append_jsonl(self.raw_path, { - "kind": "o12_temperature_capability_fallback", - "temperature_report_received": False, - "fallback_protection": "joint_error_states_bit1_overheat", - "sdk_config_sha256": self.protected_inputs["sdk_config_sha256"], - }) - self.get_logger().warning( - "O12 temperature report unavailable; continuing with error-code " - "bit1 overheat protection" - ) - - def _mode_callback(self, message: Int8MultiArray) -> None: - values = tuple(int(value) for value in message.data) - if len(values) != 12 or any(value != POSITION_MODE for value in values): - self._pause("control_mode_is_not_position") - return - self.mode_verified = True - - def _error_callback(self, message: Int16MultiArray) -> None: - now = time.monotonic() - assessment = assess_o12_error_report(message.data) - self.latest_errors = assessment.codes - self.error_report_count += 1 - if not assessment.valid: - self.error_verified = False - self.error_health_classification = "invalid_error_report" - self._pause("invalid_error_report_length") - return - if assessment.active_fault_channels or assessment.unknown_bit_channels: - self.error_verified = False - self.error_health_classification = "active_motor_fault" - self._pause("o12_active_motor_fault") - return - if assessment.clear: - self.error_verified = True - self.error_health_classification = "clear" - self.error_report_matching_codes = () - self.error_report_matching_count = 0 - self.error_report_first_matching_at = 0.0 - self.confirmed_historical_communication_channels = () - return - - # bit4 is a latched/diagnostic communication flag, not proof that the - # live command/feedback path has disappeared. This hand can raise a - # new bit4 while all 12 feedback channels continue at full rate. Do - # not terminate a scan from one report: repeated health queries - # classify a fresh-feedback flag as historical, while the independent - # feedback-age and no-progress watchdogs stop a real communication - # loss. Bits 0..3 above remain immediate hard faults. - if assessment.codes != self.error_report_matching_codes: - self.error_report_matching_codes = assessment.codes - self.error_report_matching_count = 1 - self.error_report_first_matching_at = now - self.error_verified = False - self.error_health_classification = "confirming_historical_communication" - append_jsonl(self.raw_path, { - "kind": "o12_communication_flag_observed", - "while_started": bool(self.started), - "error_codes": list(assessment.codes), - "communication_channels": [ - self.command_names[index] - for index in assessment.communication_channels - ], - "feedback_hz": round(float(self._feedback_hz()), 3), - "feedback_age_seconds": round( - float(self._feedback_age_seconds(now)), 6 - ), - "action": "observe_live_feedback_and_requery", - }) - else: - self.error_report_matching_count += 1 - self._update_error_health(now) - - def _feedback_age_seconds(self, now: float) -> float: - if not self.state_receive_times: - return math.inf - return max(0.0, now - float(self.state_receive_times[-1])) - - def _update_error_health(self, now: float) -> None: - assessment = assess_o12_error_report(self.latest_errors) - if assessment.clear or not assessment.communication_only: - return - elapsed = max(0.0, now - self.error_report_first_matching_at) - feedback_hz = float(self._feedback_hz()) - feedback_age = self._feedback_age_seconds(now) - if not historical_communication_latch_confirmed( - assessment=assessment, - matching_report_count=self.error_report_matching_count, - observation_seconds=elapsed, - feedback_hz=feedback_hz, - feedback_age_seconds=feedback_age, - minimum_feedback_hz=float(self.minimum_feedback_hz), - ): - self.error_verified = False - self.error_health_classification = "confirming_historical_communication" - return - self.error_verified = True - self.error_health_classification = "historical_communication_latch" - self.confirmed_historical_communication_channels = tuple( - assessment.communication_channels - ) - if self.error_health_audit_written: - return - self.error_health_audit_written = True - append_jsonl(self.raw_path, { - "kind": "o12_error_health_classification", - "policy_version": 1, - "classification": self.error_health_classification, - "error_codes": list(assessment.codes), - "communication_channels": [ - self.command_names[index] - for index in assessment.communication_channels - ], - "matching_report_count": self.error_report_matching_count, - "observation_seconds": round(elapsed, 6), - "feedback_hz": round(feedback_hz, 3), - "feedback_age_seconds": round(feedback_age, 6), - "decision_basis": ( - "vendor_documented_historical_bit4_plus_fresh_" - "command_triggered_feedback" - ), - }) - self.get_logger().warning( - "O12 commu_except bit4 is a confirmed historical latch; " - "active communication remains protected by feedback freshness" - ) - - def _temperature_callback(self, message: Int16MultiArray) -> None: - self.latest_temperatures = tuple(int(value) for value in message.data) - if len(self.latest_temperatures) != 12: - # This firmware commonly returns an empty/unsupported response. - # Error-code bit1 remains the over-temperature interlock. - self.temperature_verified = False - elif any(value >= self.maximum_temperature_c for value in self.latest_temperatures): - self._pause("o12_over_temperature") - else: - self.temperature_verified = True - self.temperature_fallback_active = False - - def _query_health(self) -> None: - if not self.commands_enabled: - return - mode = Int8MultiArray() - mode.data = [POSITION_MODE] * 12 - self.control_mode_publisher.publish(mode) - self.error_query_publisher.publish(Empty()) - now = time.monotonic() - # This O12 firmware returns an empty temperature report only after a - # long blocking timeout. In the default capability mode, do not put - # that unsupported request in front of error queries. Overheat stays - # protected by the independently queried error-report bit1. - if ( - self.temperature_report_required - and not self.temperature_verified - and now - self.last_temperature_query_at >= 5.0 - ): - self.temperature_query_publisher.publish(Empty()) - self.last_temperature_query_at = now - self.last_health_query_at = now - - def _restore_resume_checkpoint(self) -> None: - source = self.resume_raw_samples_path - assert source is not None - if ( - source.name != "raw_samples.jsonl" - or source.parent == self.session_dir - or source.parent.parent != self.session_dir.parent - ): - raise RuntimeError( - "O12 resume raw must come from an older direct sibling session" - ) - rows = load_o12_raw_samples(source) - starts = [row for row in rows if row.get("kind") == "session_start"] - if len(starts) != 1: - raise RuntimeError("O12 resume raw must contain one session_start") - start = starts[0] - if ( - start.get("profile_id") != self.profile.key.profile_id - or start.get("serial_number") != self.serial_number - or int(start.get("sample_schema_version", -1)) - != self.profile.artifacts.output_schema_version - ): - raise RuntimeError("O12 resume identity or schema differs") - recorded = { - key: str(start.get(key, "")) - for key in self.profile.artifacts.protected_input_fields - } - if all(recorded.values()): - if recorded != self.protected_inputs: - raise RuntimeError("O12 resume protected input hashes differ") - compatibility = "protected_hashes_v1" - elif any( - row.get("kind") == "o12_temperature_capability_fallback" - and row.get("sdk_config_sha256") - == self.protected_inputs["sdk_config_sha256"] - for row in rows - ): - # The product runner independently attests unchanged local files - # before it passes a legacy pre-checkpoint session to the node. - compatibility = "legacy_o12_local_inputs_unchanged" - else: - raise RuntimeError("O12 legacy resume checkpoint is not attested") - checkpoint = build_resume_checkpoint_from_rows( - self.profile, - source.parent, - rows, - compatibility=compatibility, - ) - self.resume_source_session = checkpoint.source_session.name - self.resume_compatibility = checkpoint.compatibility - self.resumed_unit_keys = frozenset(checkpoint.completed_units) - self.resumed_task_keys = frozenset(checkpoint.completed_tasks) - audit = { - "kind": "o12_resume_checkpoint_import", - "source_session": self.resume_source_session, - "compatibility": self.resume_compatibility, - "completed_unit_count": len(self.resumed_unit_keys), - "completed_task_keys": sorted(self.resumed_task_keys), - "first_incomplete_unit": next( - ( - [task.key, cycle, direction] - for task in self.profile.motion.tasks - for cycle in (0, 1, 2, 3) - for direction in ("decreasing", "increasing") - if (task.key, cycle, direction) - not in self.resumed_unit_keys - ), - None, - ), - } - # raw_records is the live fitting/quality sample set inherited from - # the acquisition engine. Keep checkpoint audit metadata in the - # append-only session file only: it has no task_name and must never be - # presented to the per-task sample qualifier. - persisted_records = [audit] - for record in checkpoint.imported_records: - copied = dict(record) - persisted_records.append(copied) - if copied.get("kind") in { - "o12_joint_sample", "o12_roll_cross_view_sample" - }: - self.raw_records.append(copied) - # A complete O12 checkpoint contains tens of thousands of records. - # One fsync per imported row can keep the constructor from creating - # its executor/status timer for minutes, which the runner then - # misreports as a device preflight failure. Persist the already - # validated checkpoint as one durable batch without changing which - # rows are imported or the resume compatibility contract. - append_jsonl_many(self.raw_path, persisted_records) - - @staticmethod - def _full_target(**values: float) -> tuple[float, ...]: - index = { - "thumb_roll": 0, "thumb_abad": 1, "thumb_mcp": 2, - "thumb_pip": 3, "index_abad": 4, "index_mcp": 5, - "index_pip": 6, "middle_abad": 7, "middle_mcp": 8, - "middle_pip": 9, "ring_mcp": 10, "pinky_mcp": 11, - } - target = [0.0] * 12 - for name, value in values.items(): - target[index[name]] = float(value) - return tuple(target) - - def _build_steps(self) -> list[MotionStep]: - def scaled(speed: float, maximum: float | None = None) -> float: - value = float(speed) * self.motion_speed_scale - return value if maximum is None else min(value, maximum) - - steps: list[MotionStep] = [ - MotionStep( - "baseline", None, None, 0.0, scaled(0.10, 0.20), - target_command=self._full_target(), - ) - ] - previous_group = "thumb" - for task in self.profile.motion.tasks: - group = task.key.split("_", 1)[0] - if group != previous_group: - if group == "middle": - outer = self._full_target( - ring_mcp=PARK_RING_MCP_RAD, - pinky_mcp=PARK_PINKY_MCP_RAD, - ) - steps.append(MotionStep( - "clearance_outer", None, None, 0.0, - scaled(0.10, 0.20), target_command=outer, - required_endpoint_indices=(10, 11), - )) - target = self._full_target( - index_abad=INDEX_CLEARANCE_RAD, - index_mcp=INDEX_CLEARANCE_MCP_RAD, - ring_mcp=PARK_RING_MCP_RAD, - pinky_mcp=PARK_PINKY_MCP_RAD, - ) - steps.append(MotionStep( - "clearance_splay", None, None, 0.0, - scaled(0.04, 0.12), target_command=target, - required_endpoint_indices=(4, 5, 10, 11), - )) - elif group == "index": - splay_zero = self._full_target( - ring_mcp=PARK_RING_MCP_RAD, - pinky_mcp=PARK_PINKY_MCP_RAD, - ) - steps.append(MotionStep( - "clearance_splay", None, None, 0.0, - scaled(0.04, 0.12), target_command=splay_zero, - required_endpoint_indices=(7, 10, 11), - )) - target = self._full_target( - middle_mcp=PARK_MIDDLE_MCP_RAD, - middle_pip=PARK_MIDDLE_PIP_RAD, - ring_mcp=PARK_RING_MCP_RAD, - pinky_mcp=PARK_PINKY_MCP_RAD, - ) - steps.append(MotionStep( - "clearance", None, None, 0.0, - scaled(0.10, 0.20), target_command=target, - required_endpoint_indices=(7, 8, 9, 10, 11), - )) - previous_group = group - engine = getattr(self, "calibration_engine", None) - if engine is None: - engine = CalibrationEngine(self.profile) - probe_delta = engine.mapping_probe_delta(task) - assert probe_delta is not None - for target, relative_delta in ( - (task.start_value, None), - (task.start_value + probe_delta, probe_delta), - (task.start_value, None), - ): - steps.append(MotionStep( - "preflight", task.key, task.command_index, target, - scaled(float(task.preflight_speed or 0.02), 0.06), - relative_feedback_delta=relative_delta, - )) - for cycle in (0, 1, 2, 3): - formal_speed = scaled( - float(task.formal_speed), - FORMAL_SPEED_CAP_RAD_S[int(task.command_index)], - ) - steps.extend(( - MotionStep("sweep", task.key, task.command_index, task.end_value, formal_speed, cycle, "decreasing"), - MotionStep("sweep", task.key, task.command_index, task.start_value, formal_speed, cycle, "increasing"), - )) - # Collision-aware return: side-swing neutral, middle open, then outer pair. - middle_and_outer_parked = self._full_target( - middle_mcp=PARK_MIDDLE_MCP_RAD, - middle_pip=PARK_MIDDLE_PIP_RAD, - ring_mcp=PARK_RING_MCP_RAD, pinky_mcp=PARK_PINKY_MCP_RAD - ) - outer_parked = self._full_target( - ring_mcp=PARK_RING_MCP_RAD, pinky_mcp=PARK_PINKY_MCP_RAD - ) - steps.append(MotionStep( - "return_splay_zero", None, None, 0.0, scaled(0.04, 0.12), - target_command=middle_and_outer_parked, - required_endpoint_indices=(7, 8, 9, 10, 11), - )) - steps.append(MotionStep( - "return_middle_open", None, None, 0.0, scaled(0.10, 0.20), - target_command=outer_parked, - required_endpoint_indices=(8, 9, 10, 11), - )) - steps.append(MotionStep( - "return_outer_open", None, None, 0.0, scaled(0.10, 0.20), - target_command=self._full_target(), - required_endpoint_indices=(10, 11), - )) - completed_units = set(getattr(self, "resumed_unit_keys", ())) - completed_tasks = set(getattr(self, "resumed_task_keys", ())) - if not completed_units: - return steps - filtered: list[MotionStep] = [] - skipped = 0 - for step in steps: - skip = ( - step.phase == "preflight" - and step.task_key in completed_tasks - ) or ( - step.recording - and ( - str(step.task_key), int(step.cycle), str(step.direction) - ) in completed_units - ) - if skip: - skipped += 1 - else: - filtered.append(step) - first_scan = next( - ( - (index, step) - for index, step in enumerate(filtered) - if step.recording - ), - None, - ) - if first_scan is not None and first_scan[1].direction == "increasing": - index, step = first_scan - task = self._task(str(step.task_key)) - # A recovered increasing sweep normally starts where its preceding - # decreasing sweep ended. Because that passed decreasing unit was - # skipped, recreate only the endpoint pose without recording. - filtered.insert(index, MotionStep( - "resume_prepare", - task.key, - task.command_index, - task.end_value, - step.speed_u8, - step.cycle, - attempt=step.attempt, - )) - self.resume_skipped_step_count = skipped - return filtered - - def _radian_trajectory_fraction( - self, distance: float, elapsed: float, maximum_speed: float - ) -> tuple[float, float, float]: - value, phase, duration = cosine_ramp_velocity_trajectory_rad( - 0.0, - float(distance), - float(elapsed), - float(maximum_speed), - self.trajectory_ramp_seconds, - ) - fraction = 1.0 if distance <= 0.0 else value / float(distance) - return fraction, phase, duration - - def _o12_measured_channel_travel_rad(self, index: int) -> float | None: - """Return cycle-zero feedback travel for one clearance actuator. - - Ring has no visual task, so its endpoint contract deliberately reuses - the pinky actuator's measured feedback travel while retaining ring's - own command and CAD geometry. - """ - source_index = 11 if int(index) == 10 else int(index) - groups: dict[tuple[str, int], list[float]] = {} - for row in self.raw_records: - if row.get("kind") != "o12_joint_sample": - continue - if int(row.get("cycle", -1)) != 0: - continue - if int(row.get("motor_index", -1)) != source_index: - continue - value = float(row.get("feedback_rad", math.nan)) - if not math.isfinite(value): - continue - key = ( - str(row.get("direction", "")), - int(row.get("attempt", 1)), - ) - groups.setdefault(key, []).append(value) - spans = [max(values) - min(values) for values in groups.values()] - travel = max(spans, default=0.0) - return float(travel) if travel > 1.0e-9 else None - - def _minimum_radian_feedback_travel(self, step: MotionStep) -> float: - """Avoid a second command-scale endpoint gate on O12 moves. - - Mapping probes retain the generic minimum-motion evidence. Clearance - and return waypoints are checked per axis below against measured O12 - travel; other positioning moves only need the live no-progress guard. - """ - if step.phase == "preflight": - return L6ThreeCameraCalibrationNode._minimum_radian_feedback_travel( - self, step - ) - return 0.0 - - def _tick_radian_motion(self, step: MotionStep, now: float) -> None: - if ( - self.step_trajectory_phase >= 1.0 - and getattr(step, "required_endpoint_indices", ()) - and len(self.latest_state_u8) == self.command_count - ): - travel = self._radian_feedback_travel(step) - if travel >= float(self.step_last_distance_u8) + 0.001: - self.step_last_distance_u8 = travel - self.step_last_progress_at = now - target = self._target_command(step) - outside = [] - for index in getattr(step, "required_endpoint_indices", ()): - index = int(index) - tolerance = ( - CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD - if index in {4, 7} - else CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD - ) - command_delta = ( - float(target[index]) - - float(self.step_start_state_u8[index]) - ) - if index in {4, 7}: - # ABAD feedback has only a small zero bias, so the explicit - # 0-rad clearance requirement can be checked directly. - error = abs( - float(self.latest_state_u8[index]) - - float(target[index]) - ) - if error > tolerance: - outside.append((index, "error", error, tolerance)) - continue - if abs(command_delta) <= 0.005: - # This flex axis was verified by the preceding waypoint; - # require it to remain there without assuming feedback and - # command radians have identical endpoint zero/scale. - drift = abs( - float(self.latest_state_u8[index]) - - float(self.step_start_feedback_u8[index]) - ) - if drift > tolerance: - outside.append((index, "drift", drift, tolerance)) - continue - feedback_delta = ( - float(self.latest_state_u8[index]) - - float(self.step_start_feedback_u8[index]) - ) - measured_travel = self._o12_measured_channel_travel_rad(index) - if measured_travel is None: - # This only applies to a small auxiliary pose before that - # actuator's visual scan. Check its endpoint directly; - # do not invent a feedback/command scale relationship. - error = abs( - float(self.latest_state_u8[index]) - - float(target[index]) - ) - if error > CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD: - outside.append(( - index, - "error", - error, - CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD, - )) - continue - progress = ( - feedback_delta * (1.0 if command_delta > 0.0 else -1.0) - / measured_travel - ) - minimum = float(CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION) - if progress < minimum: - outside.append(( - index, "measured_progress", progress, minimum - )) - if outside: - self.step_hold_since = None - if ( - now - self.step_last_progress_at - > float(self.motor_stall_timeout_seconds) - ): - index, metric, value, limit = outside[0] - self._pause( - "mechanical_stall:clearance_endpoint:" - f"channel={self.command_names[index]}:" - f"metric={metric}:value={value:.6f}:limit={limit:.6f}" - ) - return - super()._tick_radian_motion(step, now) - - def _target_command(self, step: LegacyMotionStep) -> tuple[float, ...]: - resolved = self.resolved_probe_target_by_step.get(id(step)) - return resolved if resolved is not None else super()._target_command(step) - - def _begin_step_without_vision_callback( - self, step: LegacyMotionStep - ) -> None: - relative_delta = getattr(step, "relative_feedback_delta", None) - ready_to_begin = ( - relative_delta is not None - and self.commanded_speed == step.speed_u8 - and time.monotonic() >= self.step_speed_ready_at - and len(self.latest_state_u8) == self.command_count - ) - if ready_to_begin and id(step) not in self.resolved_probe_target_by_step: - if step.command_index is None: - raise ValueError("O12 relative probe requires one command channel") - index = int(step.command_index) - origin = float(self.latest_state_u8[index]) - delta = float(relative_delta) - resolved_value = float(np.clip( - origin + delta, - self.command_lower[index], - self.command_upper[index], - )) - if abs(resolved_value - origin) + 1.0e-9 < abs(delta): - self._pause( - "feedback_relative_probe_outside_command_domain:" - f"{step.task_key}:origin={origin}:delta={delta}" - ) - return - target = list(super()._target_command(step)) - target[index] = resolved_value - self.resolved_probe_target_by_step[id(step)] = tuple(target) - self.probe_feedback_origin_by_step[id(step)] = origin - super()._begin_step_without_vision_callback(step) - if self.step_command_sent and relative_delta is not None: - resolved = self._target_command(step) - self.reason = ( - f"{step.phase}:{step.task_key or 'all'}:cycle={step.cycle}:" - f"direction={step.direction}:target=" - f"{resolved[int(step.command_index)]}:attempt={step.attempt}" - ) - - def _start(self, request, response): - if not ( - self.mode_verified - and self.error_verified - and self._temperature_ready() - and self._feedback_hz() >= float(self.minimum_feedback_hz) - ): - response.success = False - response.message = ( - "O12 POSITION/health/control-stream precheck is incomplete" - ) - return response - return super()._start(request, response) - - def _observe_preflight_pose(self, view, selected, step: MotionStep) -> None: - task = self._task(str(step.task_key)) - if task.view != view: - return - measurement = self.profile.measurement.measurements[task.joints[0]] - parent = Rotation.from_quat( - selected[str(measurement.parent_role)].quaternion_xyzw - ) - child = Rotation.from_quat( - selected[str(measurement.child_role)].quaternion_xyzw - ) - relative = parent.inv() * child - if abs(float(step.target_u8) - task.start_value) <= 1.0e-9: - self.preflight_reference_by_task.setdefault(task.key, relative) - return - reference = self.preflight_reference_by_task.get(task.key) - if reference is None: - return - vector = (reference.inv() * relative).as_rotvec() - magnitude = float(np.linalg.norm(vector)) - if magnitude > self.preflight_maximum_rotation_by_task.get(task.key, 0.0): - self.preflight_maximum_rotation_by_task[task.key] = magnitude - if magnitude > 1.0e-9: - axis = vector / magnitude - self.measured_direction_axis_by_task[task.key] = tuple( - float(value) for value in axis - ) - - def _locked_reference_poses_for_capture( - self, view: str, step: MotionStep | None - ) -> dict[str, SquareTagPose]: - """Reuse the unobstructed O12 palm pose after outer-finger clearance. - - Maximum ring/pinky flexion intentionally covers front Tag 0. The hand - base and cameras are fixed throughout one session, so a palm pose - solved from the pre-clearance median corners is the correct reference - for middle/index roll. Moving Tags remain live requirements. - """ - if str(view) != "front": - return {} - base_role = next( - tag.role for tag in self._view(view).tags if tag.fixed_reference - ) - locked = self.locked_base_pose_by_view.get(view) - if locked is None: - corners = self.base_corner_reference.get(view) - if corners is not None: - locked, reason = self.trackers[view].estimate( - base_role, - np.asarray(corners, dtype=float), - tag_size_m=float(self.tag_size_m), - camera_matrix=self.camera_matrices[view], - stamp_ns=int(self.get_clock().now().nanoseconds), - ) - if locked is not None: - self.locked_base_pose_by_view[view] = locked - append_jsonl(self.raw_path, { - "kind": "o12_fixed_base_reference_locked", - "view": view, - "role": base_role, - "tag_id": next( - int(tag.tag_id) for tag in self._view(view).tags - if tag.role == base_role - ), - "corner_sample_count": len( - self.base_corner_observations[view] - ), - "pnp_reprojection_error_px": round( - float(locked.reprojection_error_px), 6 - ), - }) - elif reason: - self._count_step_rejection( - f"locked_base_pnp:{base_role}:{reason}" - ) - if locked is None or step is None or step.task_key is None: - return {} - group = str(step.task_key).split("_", 1)[0] - if group not in {"middle", "index"}: - return {} - return {base_role: locked} - - def _finish_step(self, step: MotionStep) -> None: - previous_task = step.task_key - with self.step_data_lock: - if self.vision_callbacks_inflight: - return - if step.phase == "preflight" and step.task_key is not None: - task = self._task(step.task_key) - is_probe = abs(float(step.target_u8) - task.start_value) > 1.0e-9 - if is_probe: - rotation = self.preflight_maximum_rotation_by_task.get(task.key, 0.0) - expected_delta = float(step.relative_feedback_delta) - feedback_delta = ( - float(self.latest_state_u8[task.command_index]) - - float(self.probe_feedback_origin_by_step[id(step)]) - ) - projected_feedback_travel = feedback_delta * ( - 1.0 if expected_delta > 0.0 else -1.0 - ) - if projected_feedback_travel < min( - 0.004, 0.25 * abs(expected_delta) - ): - self._pause( - "mapping_preflight_wrong_feedback_direction:" - f"{task.key}" - ) - return - visual_verified = rotation >= math.radians(0.25) - measured_axis = self.measured_direction_axis_by_task.get( - task.key - ) - append_jsonl(self.raw_path, { - "kind": "o12_fixed_mapping_preflight", - "task_name": task.key, - "motor_index": task.command_index, - "sdk_channel": self.command_names[task.command_index], - "urdf_joint": task.joints[0], - "command_delta_rad": round( - float(step.relative_feedback_delta), 9 - ), - "feedback_origin_rad": round( - float(self.probe_feedback_origin_by_step[id(step)]), 9 - ), - "absolute_command_target_rad": round( - float(self._target_command(step)[ - int(task.command_index) - ]), - 9, - ), - "feedback_travel_rad": round( - float(self._radian_feedback_travel(step)), 9 - ), - "projected_feedback_travel_rad": round( - projected_feedback_travel, 9 - ), - "observed_rotation_rad": round(rotation, 9), - "observed_axis_tag_frame_xyz": ( - None if measured_axis is None else list(measured_axis) - ), - "visual_mapping_verified": visual_verified, - "verification_basis": ( - "feedback_and_visual" - if visual_verified - else "feedback_only_live_tag_unavailable" - ), - "channel_exchange_allowed": False, - }) - super()._finish_step(step) - if not self.step_command_sent: - self.resolved_probe_target_by_step.pop(id(step), None) - self.probe_feedback_origin_by_step.pop(id(step), None) - # The next step is already selected at this boundary. Clear - # the completed sweep counters immediately so status and - # safety logic cannot present or interpret stale scan state - # while waiting for the next timer tick to begin it. - self._reset_step_observation_counters(()) - next_step = self._current_step() - if self.state == "RUNNING" and ( - next_step is None - or next_step.phase.startswith("clearance") - or (previous_task and next_step.task_key and previous_task.split("_", 1)[0] != next_step.task_key.split("_", 1)[0]) - ): - self._query_health() - - def _finalize(self) -> None: - """Expose the long O12 spatial solve before running it synchronously. - - A complete 16-Tag session contains tens of thousands of pose records; - the spatial solve and guarded URDF write can legitimately take close - to a minute. Publish the phase transition immediately so the outer - process monitor does not apply its three-second *runtime* heartbeat - rule to this CPU-bound, non-motion stage. - """ - self.state = "FINALIZING" - self.reason = "fitting_validating_and_writing_artifacts" - self._publish_status() - super()._finalize() - - def _tick(self) -> None: - now = time.monotonic() - self._update_error_health(now) - if ( - self.started - and self.error_health_classification - == "confirming_historical_communication" - and now - self.last_health_query_at >= 0.5 - ): - self._query_health() - self._activate_temperature_fallback_if_allowed(now) - if ( - self.started - and self.state not in {"PAUSED", "ABORTED", "PASSED"} - and self._feedback_age_seconds(now) > 1.0 - ): - self._pause("o12_feedback_stream_timeout") - return - if not self.started and self.state not in {"PAUSED", "ABORTED", "PASSED"}: - if now - self.last_health_query_at >= 1.0: - self._query_health() - # The SDK's joint state is command-triggered. The configured zero - # stream supplies READY feedback and verifies the smooth cadence. - self._publish_command(list(self.baseline_command)) - super()._tick() - if ( - not self.started - and self.state == "READY" - and not ( - self.mode_verified - and self.error_verified - and self._temperature_ready() - and self._feedback_hz() >= float(self.minimum_feedback_hz) - ) - ): - self.state = "WAIT_DEVICES" - self.reason = "waiting_for_o12_position_health_and_smooth_stream" - - def _status(self): - value = super()._status() - # The common status payload retains the endpoint error used by legacy - # byte-command hands. During an O12 continuous trajectory that makes - # a healthy in-flight move look far from its final endpoint. Report - # O12 tracking against the command actually published this tick while - # keeping target_state_rad as the separate final destination. - value = self._apply_o12_tracking_diagnostics(value) - skipped = int(self.resume_skipped_step_count) - if skipped: - value["step_index"] = int(value["step_index"]) + skipped - value["step_count"] = int(value["step_count"]) + skipped - step = self._current_step() - locked_front_active = bool( - "front" in self.locked_base_pose_by_view - and step is not None - and step.task_key is not None - and str(step.task_key).split("_", 1)[0] in {"middle", "index"} - and self._task(str(step.task_key)).view == "front" - ) - value.update({ - "position_mode_verified": self.mode_verified, - "error_report_verified": self.error_verified, - "error_health_classification": self.error_health_classification, - "error_report_count": self.error_report_count, - "error_report_matching_count": self.error_report_matching_count, - "error_faults": list(decoded_faults(self.latest_errors)), - "confirmed_historical_communication_channels": [ - self.command_names[index] - for index in self.confirmed_historical_communication_channels - ], - "feedback_age_seconds": round( - self._feedback_age_seconds(time.monotonic()), 6 - ), - "temperature_report_verified": self.temperature_verified, - "temperature_fallback_active": self.temperature_fallback_active, - "temperature_safety_policy": ( - "direct_temperature_report" - if self.temperature_verified - else "error_bitmask_bit1_overheat" - if self.temperature_fallback_active - else "waiting_for_temperature_report" - ), - "latest_error_codes": list(self.latest_errors), - "latest_temperatures_c": list(self.latest_temperatures), - "motion_speed_scale": self.motion_speed_scale, - "trajectory_ramp_seconds": self.trajectory_ramp_seconds, - "locked_reference_tag_ids": [0] if locked_front_active else [], - "feedback_coupling": dict( - self.latest_o12_feedback_coupling - ), - "auxiliary_tracking": dict( - self.latest_o12_auxiliary_tracking - ), - "roll_cross_view": { - **self._o12_cross_view_metrics(), - "active": bool( - self._roll_cross_view_observer() is not None - and self._current_step() is not None - and self._current_step().recording - ), - "valid_frames": self.o12_cross_view_valid_frames, - "total_frames": self.o12_cross_view_total_frames, - "recognized_tag_ids": list( - self.o12_cross_view_recognized_tag_ids - ), - "unrecognized_tag_ids": list( - self.o12_cross_view_unrecognized_tag_ids - ), - }, - "resume": { - "used": bool(self.resumed_unit_keys), - "source_session": self.resume_source_session, - "compatibility": self.resume_compatibility, - "completed_unit_count": len(self.resumed_unit_keys), - "completed_task_keys": sorted(self.resumed_task_keys), - "skipped_step_count": skipped, - }, - }) - return value - - -def main(args: list[str] | None = None) -> None: - rclpy.init(args=args) - node: O12ThreeCameraCalibrationNode | None = None - try: - node = O12ThreeCameraCalibrationNode() - # One worker for motion/feedback and one for each camera. The camera - # subscriptions use separate callback groups in the shared node base, - # so a busy front detector cannot starve the side observer required by - # O12 roll calibration. - executor = MultiThreadedExecutor( - num_threads=1 + len(node.profile.vision.view_names) - ) - executor.add_node(node) - executor.spin() - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - - -__all__ = ["O12ThreeCameraCalibrationNode", "POSITION_MODE", "main"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/pipeline.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/pipeline.py deleted file mode 100644 index 86f2bf1..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/pipeline.py +++ /dev/null @@ -1,336 +0,0 @@ -"""Shared online/offline O12 session finalization.""" - -from __future__ import annotations - -from datetime import datetime -from dataclasses import asdict, replace -import json -import math -import os -from pathlib import Path -from typing import Any, Mapping, Sequence - -from ...product import sha256_file -from ...runtime.engine import CalibrationEngine -from ...storage import atomic_write_json -from .artifacts import ( - build_o12_runtime_payload, - validate_o12_runtime_payload_against_urdf, -) -from .fitting import O12FitResult, fit_o12_session -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - MEASURED_PASSIVE_JOINTS, - STATIC_ZERO_EXCLUDED_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - build_typed_profile, -) -from .urdf import O12UrdfCorrection, write_o12_corrected_urdf -from .zero import O12SpatialZeroError, SPATIAL_ZERO_POLICY - - -MEASURED_JOINTS = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS - - -def accepted_records_by_joint( - records: Sequence[Mapping[str, Any]], -) -> dict[str, list[dict[str, Any]]]: - samples = [ - dict(row) for row in records - if row.get("kind") == "o12_joint_sample" - and str(row.get("joint", "")) in MEASURED_JOINTS - ] - latest: dict[tuple[str, int, str], int] = {} - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1))) - result = {name: [] for name in MEASURED_JOINTS} - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - if int(row.get("attempt", 1)) != latest[key]: - continue - accepted: dict[str, Any] = { - "cycle": int(row["cycle"]), - "direction": str(row["direction"]), - "feedback_rad": float(row["feedback_rad"]), - "relative_quaternion_xyzw": list(row["relative_quaternion_xyzw"]), - } - for field in ( - "relative_translation_xyz_m", "parent_pose_common", - "child_pose_common", "view_normal_common_xyz", - "camera_center_common_xyz_m", "state_rad", - ): - if field in row: - value = row[field] - accepted[field] = dict(value) if isinstance(value, Mapping) else list(value) - result[str(row["joint"])].append(accepted) - return result - - -def accepted_roll_cross_view_records( - records: Sequence[Mapping[str, Any]], -) -> dict[str, list[dict[str, Any]]]: - """Select the latest complete-attempt O12 side observations by roll joint.""" - samples = [ - dict(row) for row in records - if row.get("kind") == "o12_roll_cross_view_sample" - and str(row.get("model_joint", "")) - in {"middle_mcp_roll", "index_mcp_roll"} - ] - latest: dict[tuple[str, int, str], int] = {} - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1))) - result = {"middle_mcp_roll": [], "index_mcp_roll": []} - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - if int(row.get("attempt", 1)) != latest[key]: - continue - result[str(row["model_joint"])].append({ - "cycle": int(row["cycle"]), - "direction": str(row["direction"]), - "feedback_rad": float(row["feedback_rad"]), - "relative_quaternion_xyzw": list( - row["relative_quaternion_xyzw"] - ), - }) - return {name: rows for name, rows in result.items() if rows} - - -def load_o12_raw_samples(path: str | Path) -> list[dict[str, Any]]: - source = Path(path).expanduser().resolve() - if not source.is_file(): - raise ValueError(f"raw O12 sample file does not exist: {source}") - rows: list[dict[str, Any]] = [] - with source.open("r", encoding="utf-8") as stream: - for line_number, line in enumerate(stream, 1): - if not line.strip(): - continue - try: - item = json.loads(line) - except json.JSONDecodeError as error: - raise ValueError(f"invalid O12 JSONL record at line {line_number}") from error - if not isinstance(item, Mapping): - raise ValueError(f"O12 JSONL line {line_number} is not an object") - rows.append(dict(item)) - return rows - - -def _publish(serial_root: Path, session: Path) -> Path: - if session.parent != serial_root or not session.is_dir(): - raise ValueError("O12 session must be a direct existing child") - destination = serial_root / "latest_passed" - temporary = serial_root / f".latest_passed.{os.getpid()}.tmp" - if temporary.exists() or temporary.is_symlink(): - temporary.unlink() - os.symlink(session.name, temporary, target_is_directory=True) - os.replace(temporary, destination) - return destination - - -def finalize_o12_session( - *, - session_dir: str | Path, - serial_number: str, - source_urdf: str | Path, - protected_inputs: Mapping[str, str], - records: Sequence[Mapping[str, Any]], - publish: bool = True, - timestamp: str | None = None, -) -> tuple[dict[str, Any], O12FitResult, O12UrdfCorrection]: - directory = Path(session_dir).expanduser().resolve() - directory.mkdir(parents=True, exist_ok=True) - if any(row.get("projection_reprocessing_scope") == "thumb_only_external_projection" - for row in records): - raise ValueError("Partial external camera override is diagnostic-only; " - "other task projections are unverified. Cannot finalize a whole-hand artifact.") - # Reconsider ambiguous online choices with the complete TRAINING trajectory. - # Fourth-cycle candidates are assigned using the frozen training choice; - # the original raw file is never rewritten and all quality gates remain. - from .observations import resolve_thumb_observations - try: - records, pose_selection = resolve_thumb_observations(records) - except ValueError as error: - atomic_write_json(directory / "pose_selection_diagnostics.json", { - "status": "failed", "reason": str(error), "publication_allowed": False, - }) - raise ValueError("O12 corner candidate resolution failed:" + str(error)) from error - atomic_write_json(directory / "pose_selection_diagnostics.json", pose_selection) - try: - result = fit_o12_session( - source_urdf, - accepted_records_by_joint(records), - cross_view_records_by_joint=accepted_roll_cross_view_records(records), - require_cross_view=True, - require_full_hand_spatial_zero=True, - ) - if pose_selection['status'] == 'legacy_projection_unverified': - # A successful fit cannot certify observations known to have an - # unverified rectified-pixel projection contract. Keep a review - # artifact, but never silently publish mixed K/P observations. - spatial = replace(result.full_hand_zero_result, passed=False, - failure_reasons={**result.full_hand_zero_result.failure_reasons, - 'camera_projection': 'legacy_projection_unverified'}) - error = O12SpatialZeroError( - 'O12 camera projection is unverified for legacy corner records; review only', - {'passed': False, 'stage': 'camera_projection', - 'result': asdict(spatial), 'pose_selection': pose_selection}) - error.review_fit = replace(result, full_hand_zero_result=spatial) - raise error - except O12SpatialZeroError as error: - # A review model is not a calibration PASS. Keep it out of the - # publication directory's artifact set and never produce runtime JSON. - if error.review_fit is not None: - review = directory / "review_only" - try: - if any(not math.isfinite(value) or abs(value) > math.radians(20) - for value in error.review_fit.zero_offsets_rad.values()): - raise ValueError("review zero estimate is outside the physical correction budget") - correction = write_o12_corrected_urdf( - source_urdf=source_urdf, output_directory=review, - serial_number=serial_number + "_REVIEW_ONLY", - timestamp=timestamp or datetime.now().strftime("%Y%m%d_%H%M%S"), - result=error.review_fit, - ) - atomic_write_json(review / "review_manifest.json", { - "status": "REVIEW_ONLY_NOT_CALIBRATION_PASS", - "publication_allowed": False, - "policy": SPATIAL_ZERO_POLICY, - "urdf": correction.path.name, - "source_urdf": str(Path(source_urdf).resolve()), - "source_urdf_sha256": sha256_file(source_urdf), - "candidate_urdf_sha256": sha256_file(correction.path), - "protected_inputs": dict(protected_inputs), - "static_origin_offsets_rad": dict(error.review_fit.zero_offsets_rad), - "spatial_validation": error.diagnostics, - "limitations": ["Not approved for hardware/control", - "Passive URDF mimic is a linear approximation", - "Fingertip contact accuracy not verified"], - }) - error.diagnostics = {**error.diagnostics, "review_urdf": str(correction.path)} - error.args = (str(error) + "; 仅供复核、未发布的 URDF:" + str(correction.path),) - except Exception as review_error: - # Preserve the original failure even if diagnostic export fails. - error.diagnostics = {**error.diagnostics, "review_export_error": str(review_error)} - atomic_write_json(directory / "spatial_zero_diagnostics.json", error.diagnostics) - raise - atomic_write_json(directory / "spatial_zero_diagnostics.json", { - "passed": True, "policy": SPATIAL_ZERO_POLICY, - "result": asdict(result.full_hand_zero_result), - "static_zero_exclusions": { - name: "immutable_source_cad" - for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS) - }, - }) - CalibrationEngine(build_typed_profile()).result_from_fit( - result, - transfers=TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - ) - payload = build_o12_runtime_payload( - serial_number=serial_number, - source_urdf=source_urdf, - result=result, - protected_inputs=protected_inputs, - passed=True, - ) - resume_rows = [ - row for row in records - if row.get("kind") == "o12_resume_checkpoint_import" - ] - resume = ( - { - "used": True, - "source_session": str(resume_rows[-1]["source_session"]), - "compatibility": str(resume_rows[-1]["compatibility"]), - "completed_unit_count": int( - resume_rows[-1]["completed_unit_count"] - ), - "completed_task_keys": list( - resume_rows[-1].get("completed_task_keys", ()) - ), - } - if resume_rows - else {"used": False} - ) - payload["quality"]["resume"] = resume - correction = write_o12_corrected_urdf( - source_urdf=source_urdf, - output_directory=directory, - serial_number=serial_number, - result=result, - timestamp=timestamp or datetime.now().strftime("%Y%m%d_%H%M%S"), - ) - validate_o12_runtime_payload_against_urdf( - payload, correction.path, source_urdf=source_urdf - ) - json_path = directory / f"o12_right_{serial_number}_calibration.json" - atomic_write_json(json_path, payload) - summary = { - "schema_version": 1, - "profile_id": "O12/right/o12_right_16/v1", - "serial_number": str(serial_number), - "result": "PASS", - "calibration_scope": payload["calibration_scope"], - "spatial_zero_policy": SPATIAL_ZERO_POLICY, - "static_origin_offsets_rad": dict(result.zero_offsets_rad), - "static_zero_methods": dict(result.zero_method_by_joint), - "static_zero_exclusions": { - name: { - "static_origin_offset_rad": result.zero_offsets_rad[name], - "source": "immutable_source_cad", - "dynamic_curve_and_holdout_passed": True, - } - for name in sorted(STATIC_ZERO_EXCLUDED_JOINTS) - }, - "measured_active_joints": sorted(CALIBRATED_ACTIVE_JOINTS), - "measured_passive_joints": sorted(MEASURED_PASSIVE_JOINTS), - "thumb_static_origin_offsets_rad": { - name: correction.origin_offsets_rad[name] - for name in ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - ) - }, - "thumb_static_zero_methods": { - name: result.zero_method_by_joint[name] - for name in ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - ) - }, - "mechanical_endpoint_origin_offsets_rad": { - name: correction.origin_offsets_rad[name] - for name in sorted(build_typed_profile().zero.endpoint_anchor_by_joint) - }, - "corrected_limits_rad": { - name: list(values) - for name, values in sorted(correction.corrected_limits_rad.items()) - }, - "ring_transfer": { - "source": "pinky_mcp_pitch", - "target": "ring_mcp_pitch", - "preserved_fields": list(correction.preserved_ring_fields), - }, - "resume": resume, - "artifacts": { - "json": json_path.name, - "urdf": correction.path.name, - "calibration_json_sha256": sha256_file(json_path), - "corrected_urdf_sha256": sha256_file(correction.path), - }, - } - atomic_write_json(directory / "calibration_summary_zh.json", summary) - if publish: - _publish(directory.parent, directory) - return payload, result, correction - - -__all__ = [ - "MEASURED_JOINTS", "accepted_records_by_joint", - "accepted_roll_cross_view_records", "finalize_o12_session", - "load_o12_raw_samples", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/profile.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/profile.py deleted file mode 100644 index 4943466..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/profile.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Reviewed O12 right-hand 16-Tag calibration profile.""" - -from __future__ import annotations - -from dataclasses import dataclass -import math - -from ...core import ( - AcquisitionPolicy, - ArtifactPolicy, - CalibrationProfile, - CommandLayout, - MeasurementPolicy, - MeasurementSpec, - MotionPolicy, - ProfileKey, - QualityPolicy, - ScopePolicy, - TagSpec, - TaskSpec, - ViewSpec, - VisionRigSpec, - ZeroSolvePolicy, -) -from ..registry import EngineBindings, RegisteredProfile -from .motion import ( - build_calibration_motion_command, - build_calibration_preparation_waypoints, - build_calibration_return_waypoints, -) - - -KEY = ProfileKey("O12", "right", "o12_right_16", 1) - -# sensor_msgs/JointState.position active-angle order from API_CPP_O12.md. -COMMAND_NAMES: tuple[str, ...] = ( - "thumb_roll", - "thumb_abad", - "thumb_mcp", - "thumb_pip", - "index_abad", - "index_mcp", - "index_pip", - "middle_abad", - "middle_mcp", - "middle_pip", - "ring_mcp", - "pinky_mcp", -) - -SDK_LOWER_RAD = ( - 0.0, -1.387536755335492, -0.8272860654453121, -1.2915436464758039, - -0.2617993877991494, 0.0, 0.0, -0.2617993877991494, - 0.0, 0.0, 0.0, 0.0, -) -SDK_UPPER_RAD = ( - 0.9424777960769379, 0.0, 0.0, 0.0, - 0.2617993877991494, 1.3526301702956054, 1.530653753999027, - 0.2617993877991494, 1.3578661580515883, 1.8151424220741028, - 1.53588974175501, 1.53588974175501, -) - -# The calibration domain is the vendor's physical O12 range. Restricting a -# scan to the source URDF would be circular: that URDF is precisely the model -# being corrected, and its smaller thumb/outer-finger limits previously hid -# the real hardware endpoints. "SAFE" is retained as the public profile name -# but now means the reviewed SDK hard range, not a CAD intersection. -SAFE_LOWER_RAD = SDK_LOWER_RAD -SAFE_UPPER_RAD = SDK_UPPER_RAD - -# Joint-state feedback can cross a commanded endpoint slightly because it is -# measured after servo tracking and encoder conversion. The envelope remains -# anchored to the reviewed SDK hard range, with an explicit two-degree -# observation allowance that is never used to generate a command. -FEEDBACK_DOMAIN_MARGIN_RAD = math.radians(2.0) -FEEDBACK_LOWER_RAD = tuple( - value - FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_LOWER_RAD -) -FEEDBACK_UPPER_RAD = tuple( - value + FEEDBACK_DOMAIN_MARGIN_RAD for value in SAFE_UPPER_RAD -) - -SDK_TO_URDF_JOINT: tuple[str, ...] = ( - "thumb_cmc_roll", - "thumb_cmc_yaw", - "thumb_cmc_pitch", - "thumb_mcp", - "index_mcp_roll", - "index_mcp_pitch", - "index_pip", - "middle_mcp_roll", - "middle_mcp_pitch", - "middle_pip", - "ring_mcp_pitch", - "pinky_mcp_pitch", -) - -# Fixed direction contract between vendor channels and the target CAD axes. -# The magnitude remains visually calibrated because O12's 12 active SDK -# coordinates include tendon/solver coordinates that are not one-to-one with -# all 19 physical URDF joints. -SDK_TO_URDF_SIGN: tuple[float, ...] = ( - 1.0, -1.0, -1.0, -1.0, - -1.0, 1.0, 1.0, -1.0, - 1.0, 1.0, 1.0, 1.0, -) - -ACTIVE_JOINTS = SDK_TO_URDF_JOINT -PASSIVE_JOINTS: tuple[str, ...] = ( - "thumb_dip", - "index_dip", - "middle_dip", - "ring_pip", - "ring_dip", - "pinky_pip", - "pinky_dip", -) -CALIBRATED_ACTIVE_JOINTS = frozenset(ACTIVE_JOINTS) - {"ring_mcp_pitch"} -MEASURED_PASSIVE_JOINTS = frozenset( - {"thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip"} -) -TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = { - "ring_mcp_pitch": "pinky_mcp_pitch", -} -MIMIC_SOURCE_BY_JOINT = { - "thumb_dip": "thumb_mcp", - "index_dip": "index_pip", - "middle_dip": "middle_pip", - "ring_pip": "ring_mcp_pitch", - "ring_dip": "ring_pip", - "pinky_pip": "pinky_mcp_pitch", - "pinky_dip": "pinky_pip", -} - -# SDK endpoints measure travel; they have not been surveyed as CAD contact -# datums. In particular CAD.upper - travel is NOT an encoder zero. The root -# thumb axes observe roll/yaw phase; parallel flexion zeros additionally need -# adjacent axis-line observations. The one declared exclusion below remains -# CAD-owned; all other observable active zeros are required for publication. -ROOT_GEOMETRIC_ZERO_JOINTS = frozenset({"thumb_cmc_roll", "thumb_cmc_yaw"}) -# ID2/ID3 are still used to fit and independently validate thumb MCP/DIP -# motion, but the curved-shell ID3 observation is not a reliable absolute -# axis-line datum for the thumb MCP assembly phase. Keep that one static -# origin at immutable source CAD until a rigid downstream fiducial is -# available. This is an explicit O12 profile contract, not an exception-path -# quality bypass; every other observable active zero remains mandatory. -STATIC_ZERO_EXCLUDED_JOINTS = frozenset({"thumb_mcp"}) -GEOMETRIC_ZERO_JOINTS = frozenset( - CALIBRATED_ACTIVE_JOINTS - STATIC_ZERO_EXCLUDED_JOINTS -) -ENDPOINT_ANCHOR_BY_JOINT: dict[str, str] = {} -COMMAND_INDEX_BY_JOINT = { - joint: index for index, joint in enumerate(SDK_TO_URDF_JOINT) -} - -# The outer pair is used only as collision clearance after the pinky's own -# full-range scan has already exercised the same safe endpoint. O12 has only -# one active coordinate on ring/pinky, so partial MCP flexion leaves both long -# passive chains inside the middle/index camera corridor. Park them at the -# reviewed CAD/SDK intersection instead of the former 65% pose. -OUTER_CLEARANCE_FRACTION = 1.0 -PARK_RING_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[10] -PARK_PINKY_MCP_RAD = OUTER_CLEARANCE_FRACTION * SAFE_UPPER_RAD[11] -# Compatibility name for downstream diagnostics; motion code uses the two -# channel-specific values above and never takes their minimum. -PARK_FINGER_RAD = PARK_PINKY_MCP_RAD -PARK_MIDDLE_MCP_RAD = SAFE_UPPER_RAD[8] -PARK_MIDDLE_PIP_RAD = SAFE_UPPER_RAD[9] -# Feedback radians are the quantity being calibrated and need not numerically -# equal the command endpoint. Once a finger has been scanned, clearance uses -# that measured feedback travel as its endpoint reference. Simultaneous MCP -# and PIP flexion may shorten the vendor solver coordinate slightly, so 90% -# of the isolated measured stroke plus a settled hold is the accepted physical -# maximum for the coupled clearance pose. -CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION = 0.90 -CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD = 0.06 -CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD = 0.03 -INDEX_CLEARANCE_RAD = -math.radians(10.0) -# O12's index ABAD/MCP and middle ABAD/MCP are coupled tendon coordinates. -# The vendor solver clamps MCP upward when a large ABAD command is paired -# with MCP=0. These reviewed values keep the requested pose inside the -# solver's feasible set instead of relying on an invisible SDK correction: -# - index ABAD -10 deg requires MCP >= 0.109846 rad; -# - either ABAD roll endpoint (+/-0.26 rad) requires MCP >= 0.163636 rad. -INDEX_CLEARANCE_MCP_RAD = 0.11 -ROLL_CLEARANCE_MCP_RAD = 0.17 -# O12 feedback is continuous radians. Use 64 possible normalized cells and -# require at least 32 of them; using exactly 32 possible cells accidentally -# required both mechanical endpoints despite the separate 90% span gate. -NORMALIZED_SWEEP_BIN_COUNT = 64 -# A feedback curve is the quantity being identified, so its unknown endpoint -# scale must never be judged against the command endpoint as if they were the -# same coordinate. Cycle 0 is normalized over its own measured endpoints and -# therefore establishes a unit-span physical reference. Later cycles must -# reproduce at least 90% of that measured full-stroke reference. -INITIAL_FEEDBACK_SPAN_FRACTION = 0.99 -EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION = 0.90 -# Both FRONT/TOP and FRONT/SIDE are close to orthogonal on the reviewed O12 -# rig. Their common planar checkerboard must be oblique in at least one view, -# so the G20 1.2 px (and O6 1.5 px) batch gates reject highly repeatable O12 -# solutions for image-geometry reasons. At the measured 3700--3800 px focal -# length, 2.0 px is about 0.030 degrees. Treat RMS as a gross-error gate and -# keep the independent 0.3 degree and 1.5 mm pose-repeatability gates as the -# final geometric consistency evidence. -MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX = 2.0 - - -@dataclass(frozen=True) -class RollCrossViewObserver: - """O12-only side-camera contract for one front-camera roll sweep.""" - - task_name: str - view: str - parent_role: str - child_role: str - source_joint: str - model_joint: str - coupled_channel_index: int - maximum_coupled_motion_rad: float - - -# The downstream PIP Tag is rigidly carried by MCP roll, so it supplies an -# independent side-view roll curve while the front Tag supplies the published -# curve. O12's tendon solver also has a small, deterministic PIP back-drive -# around centred ABAD: joint->actuator->joint reaches about 0.0124 rad even -# when PIP is commanded to zero. The reviewed 0.020 rad envelope includes -# encoder quantisation and applies only to roll-task cross-view integrity. -ROLL_CROSS_VIEW_OBSERVERS: tuple[RollCrossViewObserver, ...] = ( - RollCrossViewObserver( - "middle_roll_front", "side", "side_base", "middle_pip", - "middle_mcp_roll_side", "middle_mcp_roll", 9, 0.020, - ), - RollCrossViewObserver( - "index_roll_front", "side", "side_base", "index_pip", - "index_mcp_roll_side", "index_mcp_roll", 6, 0.020, - ), -) -ROLL_CROSS_VIEW_BY_TASK = { - observer.task_name: observer for observer in ROLL_CROSS_VIEW_OBSERVERS -} - -# Fast-calibration caps by fixed SDK channel. The thumb CMC pitch cap keeps -# explicit margin below its unusually low 0.11 rad/s source-URDF limit; the -# remaining caps are deliberately far below their corresponding CAD limits -# while keeping one visual sweep long enough for dense 30 Hz observations. -FORMAL_SPEED_CAP_RAD_S: tuple[float, ...] = ( - 0.16, 0.16, 0.10, 0.32, - # Index/middle ABAD at 0.16 rad/s repeatedly returned only 89.2--89.5% - # feedback coverage; 0.12 rad/s measured 91.2% on the physical hand. - 0.12, 0.32, 0.32, 0.12, - 0.32, 0.32, 0.32, 0.32, -) - - -def _task( - key: str, - view: str, - index: int, - joints: tuple[str, ...], - *, - start: float, - end: float, - speed: float, - auxiliary: tuple[tuple[int, float], ...] = (), -) -> TaskSpec: - return TaskSpec( - key, - view, - index, - joints, - auxiliary_commands=auxiliary, - start=start, - end=end, - preflight_speed=min(speed, 0.02), - formal_speed=speed, - ) - - -def build_typed_profile() -> CalibrationProfile: - middle_clearance = ( - (4, INDEX_CLEARANCE_RAD), - (5, INDEX_CLEARANCE_MCP_RAD), - (10, PARK_RING_MCP_RAD), - (11, PARK_PINKY_MCP_RAD), - ) - middle_roll_clearance = ( - *middle_clearance, - (8, ROLL_CLEARANCE_MCP_RAD), - ) - index_clearance = ( - (7, 0.0), - (8, PARK_MIDDLE_MCP_RAD), - (9, PARK_MIDDLE_PIP_RAD), - (10, PARK_RING_MCP_RAD), - (11, PARK_PINKY_MCP_RAD), - ) - index_roll_clearance = ( - *index_clearance, - (5, ROLL_CLEARANCE_MCP_RAD), - ) - tasks = ( - _task("thumb_pitch_front", "front", 2, ("thumb_cmc_pitch",), start=0.0, end=SAFE_LOWER_RAD[2], speed=0.03), - _task("thumb_roll_front", "front", 0, ("thumb_cmc_roll",), start=0.0, end=SAFE_UPPER_RAD[0], speed=0.04), - _task("thumb_mcp_dip_front", "front", 3, ("thumb_mcp", "thumb_dip"), start=0.0, end=SAFE_LOWER_RAD[3], speed=0.08), - _task("thumb_yaw_top", "top", 1, ("thumb_cmc_yaw",), start=0.0, end=SAFE_LOWER_RAD[1], speed=0.04), - _task("pinky_chain_side", "side", 11, ("pinky_mcp_pitch", "pinky_pip", "pinky_dip"), start=0.0, end=SAFE_UPPER_RAD[11], speed=0.08), - _task("middle_roll_front", "front", 7, ("middle_mcp_roll",), start=SAFE_UPPER_RAD[7], end=SAFE_LOWER_RAD[7], speed=0.04, auxiliary=middle_roll_clearance), - _task("middle_mcp_side", "side", 8, ("middle_mcp_pitch",), start=0.0, end=SAFE_UPPER_RAD[8], speed=0.08, auxiliary=middle_clearance), - _task("middle_pip_dip_side", "side", 9, ("middle_pip", "middle_dip"), start=0.0, end=SAFE_UPPER_RAD[9], speed=0.08, auxiliary=middle_clearance), - _task("index_roll_front", "front", 4, ("index_mcp_roll",), start=SAFE_UPPER_RAD[4], end=SAFE_LOWER_RAD[4], speed=0.04, auxiliary=index_roll_clearance), - _task("index_mcp_side", "side", 5, ("index_mcp_pitch",), start=0.0, end=SAFE_UPPER_RAD[5], speed=0.08, auxiliary=index_clearance), - _task("index_pip_dip_side", "side", 6, ("index_pip", "index_dip"), start=0.0, end=SAFE_UPPER_RAD[6], speed=0.08, auxiliary=index_clearance), - ) - measurements = { - "thumb_cmc_pitch": MeasurementSpec("thumb_cmc_pitch", "relative_rotation", "front", "front_base", "thumb_cmc"), - "thumb_cmc_roll": MeasurementSpec("thumb_cmc_roll", "relative_rotation", "front", "front_base", "thumb_cmc"), - "thumb_mcp": MeasurementSpec("thumb_mcp", "relative_rotation", "front", "thumb_cmc", "thumb_mcp"), - "thumb_dip": MeasurementSpec("thumb_dip", "relative_rotation", "front", "thumb_mcp", "thumb_dip", pose_axis_line_required=False), - "thumb_cmc_yaw": MeasurementSpec("thumb_cmc_yaw", "relative_rotation", "top", "top_base", "thumb_yaw"), - "pinky_mcp_pitch": MeasurementSpec("pinky_mcp_pitch", "relative_rotation", "side", "side_base", "pinky_mcp"), - "pinky_pip": MeasurementSpec("pinky_pip", "relative_rotation", "side", "pinky_mcp", "pinky_pip", pose_axis_line_required=False), - "pinky_dip": MeasurementSpec("pinky_dip", "relative_rotation", "side", "pinky_pip", "pinky_dip", pose_axis_line_required=False), - "middle_mcp_roll": MeasurementSpec("middle_mcp_roll", "relative_rotation", "front", "front_base", "middle_roll"), - "middle_mcp_pitch": MeasurementSpec("middle_mcp_pitch", "relative_rotation", "side", "side_base", "middle_pip"), - "middle_pip": MeasurementSpec("middle_pip", "relative_rotation", "side", "side_base", "middle_pip"), - "middle_dip": MeasurementSpec("middle_dip", "relative_rotation", "side", "middle_pip", "middle_dip", pose_axis_line_required=False), - "index_mcp_roll": MeasurementSpec("index_mcp_roll", "relative_rotation", "front", "front_base", "index_roll"), - "index_mcp_pitch": MeasurementSpec("index_mcp_pitch", "relative_rotation", "side", "side_base", "index_dip"), - "index_pip": MeasurementSpec("index_pip", "relative_rotation", "side", "side_base", "index_pip"), - "index_dip": MeasurementSpec("index_dip", "relative_rotation", "side", "index_pip", "index_dip", pose_axis_line_required=False), - } - active = frozenset(ACTIVE_JOINTS) - passive = frozenset(PASSIVE_JOINTS) - coverage = { - **{ - name: ( - "transferred_static_dynamic" - if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT - else "measured_static_dynamic" - if name in GEOMETRIC_ZERO_JOINTS - else "measured_dynamic_cad_static" - ) - for name in active - }, - **{ - name: ( - "measured_dynamic_cad_static" - if name in MEASURED_PASSIVE_JOINTS - else "mimic_nominal" - ) - for name in passive - }, - } - return CalibrationProfile( - key=KEY, - namespace="/o12_calibration", - command=CommandLayout( - names=COMMAND_NAMES, - baseline_u8=(), - baseline=(0.0,) * 12, - # The public command domain is the reviewed SDK/CAD intersection. - lower_bounds=SAFE_LOWER_RAD, - upper_bounds=SAFE_UPPER_RAD, - feedback_lower_bounds=FEEDBACK_LOWER_RAD, - feedback_upper_bounds=FEEDBACK_UPPER_RAD, - unit="rad", - feedback_by_index=True, - command_index_by_joint=COMMAND_INDEX_BY_JOINT, - urdf_joint_by_joint={name: name for name in ACTIVE_JOINTS}, - ), - vision=VisionRigSpec( - views=( - ViewSpec("front", ( - TagSpec("front_base", 0, True), TagSpec("thumb_cmc", 1), - TagSpec("thumb_mcp", 2), TagSpec("thumb_dip", 3), - TagSpec("middle_roll", 12), TagSpec("index_roll", 13), - )), - ViewSpec("side", ( - TagSpec("side_base", 4, True), TagSpec("pinky_mcp", 5), - TagSpec("pinky_pip", 6), TagSpec("pinky_dip", 7), - TagSpec("middle_pip", 8), TagSpec("middle_dip", 9), - TagSpec("index_pip", 10), TagSpec("index_dip", 11), - )), - ViewSpec("top", ( - TagSpec("top_base", 14, True), TagSpec("thumb_yaw", 15), - )), - ), - common_frame="calibration_common", - extrinsic_reference_view="front", - extrinsics_quality_limits={ - "reprojection_rms_px": MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX, - "maximum_rotation_repeatability_deg": 0.3, - "maximum_translation_repeatability_m": 0.0015, - }, - minimum_capture_counts={"front_side_captures": 15, "front_top_captures": 15}, - ), - motion=MotionPolicy( - tasks=tasks, - # O12 uses AcquisitionPolicy.mapping_probe_maximum_rad for one - # channel-mapping jog; it does not inherit legacy sweep prechecks. - precheck_sweeps=False, - steady_command_checkpoints=False, - speed_parameters={ - "command_rate_hz": 50.0, - "clearance_flex_rad_s": 0.10, - "clearance_splay_rad_s": 0.04, - "probe_travel_rad": math.radians(3.0), - "probe_speed_rad_s": 0.02, - "endpoint_hold_seconds": 1.0, - "stall_timeout_seconds": 2.0, - }, - ), - measurement=MeasurementPolicy(measurements=measurements, directional_zero=True), - zero=ZeroSolvePolicy( - active_joints=active, - passive_joints=passive, - direct_zero_joints=tuple(sorted(GEOMETRIC_ZERO_JOINTS)), - axis_joints=tuple(sorted( - CALIBRATED_ACTIVE_JOINTS - | (MEASURED_PASSIVE_JOINTS - {"thumb_dip"}) - )), - mechanical_endpoint_joints=frozenset(ENDPOINT_ANCHOR_BY_JOINT), - post_solve_endpoint_joints=frozenset(), - mimic_source_by_joint=MIMIC_SOURCE_BY_JOINT, - cad_frozen_joints=passive | (active - GEOMETRIC_ZERO_JOINTS - set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT)), - endpoint_anchor_by_joint=ENDPOINT_ANCHOR_BY_JOINT, - # AprilTags validate passive motion, but the deployed passive - # mapping is owned by the O12 SDK solver. A planar-PnP branch must - # never replace that product kinematic contract in the URDF. - fitted_mimic_joints=frozenset(), - coupling_model_by_joint={ - name: "vendor_o12_polynomial" - for name in MEASURED_PASSIVE_JOINTS - }, - ), - quality=QualityPolicy( - training_cycles=(0, 1, 2), - holdout_cycle=3, - hard_threshold_keys=frozenset({ - "minimum_detection_rate", "maximum_state_image_skew_ms", - "maximum_validation_error_rad", - "maximum_mimic_residual_rad", - }), - isolated_holdout=True, - ), - scope=ScopePolicy( - calibrate_joints={"full": active}, - frozen_joints={"full": frozenset()}, - default_scope="full", - ), - artifacts=ArtifactPolicy( - output_schema_version=7, - calibration_filename="o12_right_{serial_number}_calibration.json", - corrected_urdf_filename="linkerhand_o12_right_{serial_number}_zero_calibrated.urdf", - protected_input_fields=frozenset({ - "source_urdf_sha256", "camera_extrinsics_sha256", - "calibration_config_sha256", "tag_config_sha256", "sdk_config_sha256", - }), - publication_pointer="latest_passed", - session_compatibility_tokens=frozenset({ - "o12_right_16_v1", "feedback_rad_v1", "full_sdk_range_v2" - }), - publish_corrected_urdf=True, - ), - acquisition=AcquisitionPolicy( - mapping_probe_maximum_rad=math.radians(3.0), - physical_first_cycle_minimum_span_01=INITIAL_FEEDBACK_SPAN_FRACTION, - physical_repeat_minimum_fraction=EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION, - ), - joint_coverage=coverage, - ) - - -def _run_cli(args: list[str] | None = None) -> None: - from .runner import main - main(args) - - -def _run_node(args: list[str] | None = None) -> None: - from .node import main - main(args) - - -def build_profile() -> RegisteredProfile: - typed = build_typed_profile() - return RegisteredProfile( - typed, - EngineBindings( - hand_profile=typed, - zero_profile=typed.zero, - motion_command=build_calibration_motion_command, - preparation_waypoints=build_calibration_preparation_waypoints, - return_waypoints=build_calibration_return_waypoints, - cli_main=_run_cli, - node_main=_run_node, - ), - ) - - -__all__ = [ - "ACTIVE_JOINTS", "CALIBRATED_ACTIVE_JOINTS", "COMMAND_INDEX_BY_JOINT", - "CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD", - "CLEARANCE_SPLAY_ENDPOINT_TOLERANCE_RAD", "COMMAND_NAMES", - "GEOMETRIC_ZERO_JOINTS", "INDEX_CLEARANCE_RAD", "KEY", - "MEASURED_PASSIVE_JOINTS", - "EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION", - "INITIAL_FEEDBACK_SPAN_FRACTION", "MIMIC_SOURCE_BY_JOINT", - "NORMALIZED_SWEEP_BIN_COUNT", "OUTER_CLEARANCE_FRACTION", - "PARK_FINGER_RAD", "PARK_MIDDLE_MCP_RAD", "PARK_PINKY_MCP_RAD", - "PARK_RING_MCP_RAD", "CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION", - "PARK_MIDDLE_PIP_RAD", "PASSIVE_JOINTS", "SAFE_LOWER_RAD", "SAFE_UPPER_RAD", - "ROLL_CROSS_VIEW_BY_TASK", "ROLL_CROSS_VIEW_OBSERVERS", - "RollCrossViewObserver", - "SDK_LOWER_RAD", "SDK_TO_URDF_JOINT", "SDK_UPPER_RAD", - "STATIC_ZERO_EXCLUDED_JOINTS", - "TRANSFERRED_ACTIVE_SOURCE_BY_JOINT", "build_profile", "build_typed_profile", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/quality.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/quality.py deleted file mode 100644 index 421099f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/quality.py +++ /dev/null @@ -1,115 +0,0 @@ -"""O12-only sweep observability policy. - -The O12 records continuous radian feedback into a fixed normalized grid. A -raw per-frame Tag rate is useful for camera diagnostics, but it is not by -itself evidence that a fitted curve is unobservable: a long sweep may retain -hundreds of synchronized samples and dense travel coverage after a short Tag -occlusion. This module makes the actual fitting information the acceptance -contract and keeps the stricter camera targets as warnings. -""" - -from __future__ import annotations - -import math -from typing import Any, Mapping, Sequence - -import numpy as np - - -QUALITY_POLICY_VERSION = 5 -# G20 permits roughly one sixteenth of its 256-bin domain to be unobserved -# contiguously. Express that invariant as a fraction so O12's 64-bin grid is -# judged at the same physical scale instead of copying a raw bin count. -MAXIMUM_UNOBSERVED_BIN_FRACTION = 1.0 / 16.0 - - -def evaluate_o12_observation_quality( - normalized_feedback: Sequence[float], - observation: Mapping[str, Any], - *, - normalized_bin_count: int, - required_feedback_span: float, - minimum_sweep_frames: int, - minimum_sweep_bins: int, - minimum_joint_frame_rate: float, - minimum_feedback_hz: float, - feedback_hz: float, - target_detection_rate: float, - target_maximum_bin_gap: int, -) -> dict[str, Any]: - """Return one authoritative O12 acceptance decision and diagnostics.""" - count = int(normalized_bin_count) - if count < 32: - raise ValueError("normalized sweep bin count must be at least 32") - values = np.asarray(normalized_feedback, dtype=float) - values = values[np.isfinite(values)] - clipped = np.clip(values, 0.0, 1.0) - bins = sorted(set( - min(count - 1, max(0, int(value * count))) - for value in clipped - )) - span = float(np.ptp(clipped)) if clipped.size else 0.0 - maximum_gap = max( - (right - left - 1 for left, right in zip(bins, bins[1:])), - default=0 if bins else count, - ) - maximum_unobserved_bins = max( - 1, int(math.floor(count * MAXIMUM_UNOBSERVED_BIN_FRACTION)) - ) - allowed_maximum_gap = maximum_unobserved_bins - detection_rate = float(observation.get("tag_detection_rate", 0.0)) - joint_frame_rate = float(observation.get("joint_frame_rate", 0.0)) - - failures: list[str] = [] - warnings: list[str] = [] - if clipped.size < int(minimum_sweep_frames): - failures.append(f"frames={clipped.size}") - if clipped.size == 0 or span < float(required_feedback_span): - failures.append("feedback_span") - if len(bins) < int(minimum_sweep_bins): - failures.append(f"bins={len(bins)}") - if maximum_gap > allowed_maximum_gap: - failures.append(f"maximum_gap={maximum_gap}") - if joint_frame_rate < float(minimum_joint_frame_rate): - warnings.append(f"joint_frame_rate={joint_frame_rate:.3f}") - if float(feedback_hz) < float(minimum_feedback_hz): - warnings.append(f"feedback_hz={float(feedback_hz):.2f}") - - # These remain explicit operator diagnostics. They do not duplicate the - # observability gates above or force a complete rescan of otherwise dense - # data after a short, localized occlusion. - if detection_rate < float(target_detection_rate): - role_rates = observation.get("tag_detection_rate_by_role", {}) - worst_role = min(role_rates, key=role_rates.get, default="unknown") - warnings.append( - f"tag_rate_target[{worst_role}]={detection_rate:.3f}" - ) - if maximum_gap > int(target_maximum_bin_gap): - warnings.append(f"maximum_gap_target={maximum_gap}") - - return { - "quality_policy_version": QUALITY_POLICY_VERSION, - "decision_basis": "normalized_fit_observability", - "valid_frames": int(clipped.size), - "feedback_bins": len(bins), - "feedback_span": round(span, 9), - "required_feedback_span": round(float(required_feedback_span), 9), - "maximum_bin_gap": int(maximum_gap), - "allowed_maximum_bin_gap": int(allowed_maximum_gap), - "gap_scope": "observed_feedback_span", - "maximum_unobserved_bin_fraction": ( - MAXIMUM_UNOBSERVED_BIN_FRACTION - ), - "target_detection_rate": float(target_detection_rate), - "target_maximum_bin_gap": int(target_maximum_bin_gap), - "warnings": warnings, - "failures": failures, - "passed": not failures, - } - - -__all__ = [ - "MAXIMUM_UNOBSERVED_BIN_FRACTION", - "QUALITY_POLICY_VERSION", - "evaluate_o12_observation_quality", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/resume.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/resume.py deleted file mode 100644 index 30c9a5e..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/resume.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Durable, safety-checked O12 scan checkpoint recovery.""" - -from __future__ import annotations - -from dataclasses import dataclass -import json -from pathlib import Path -from typing import Any, Mapping, Sequence - -from ...product import ProductConfig -from ...runtime import ACQUISITION_POLICY_VERSION -from .pipeline import load_o12_raw_samples -from .profile import ROLL_CROSS_VIEW_BY_TASK - - -PROTECTED_INPUT_KEYS = ( - "source_urdf_sha256", - "camera_extrinsics_sha256", - "calibration_config_sha256", - "tag_config_sha256", - "sdk_config_sha256", -) - -ResumeUnit = tuple[str, int, str] - - -@dataclass(frozen=True) -class O12ResumeCheckpoint: - source_session: Path - compatibility: str - completed_units: tuple[ResumeUnit, ...] - completed_tasks: tuple[str, ...] - imported_records: tuple[dict[str, Any], ...] - - -def ordered_resume_units(profile) -> tuple[ResumeUnit, ...]: - return tuple( - (task.key, cycle, direction) - for task in profile.motion.tasks - for cycle in (0, 1, 2, 3) - for direction in ("decreasing", "increasing") - ) - - -def _quality_evidence_accepted( - row: Mapping[str, Any], valid: int, total: int -) -> bool: - """Use the decision recorded by the matching O12 quality-policy version.""" - if int(row.get("quality_policy_version", 0)) >= 2: - return bool( - row.get("passed") - and not list(row.get("failures", ())) - and valid >= 40 - and total > 0 - ) - # Preserve the exact contract used by sessions written before the O12 - # observability policy existed. This branch is compatibility only; it - # does not reinterpret a previously rejected sweep as passing. - return bool( - not list(row.get("failures", ())) - and valid >= 40 - and total > 0 - # Older sessions can contain one final synchronized callback after - # the total counter reset (for example 259/258); cap the ratio at one. - and valid / max(total, valid) >= 0.85 - and float(row.get("tag_detection_rate", 0.0)) >= 0.95 - and int(row.get("feedback_bins", 0)) >= 32 - and int(row.get("maximum_bin_gap", 999)) <= 2 - ) - - -def _session_start(rows: Sequence[Mapping[str, Any]]) -> Mapping[str, Any]: - starts = [row for row in rows if row.get("kind") == "session_start"] - if len(starts) != 1: - raise ValueError("resume raw must contain exactly one session_start") - return starts[0] - - -def _protected_values(config: ProductConfig) -> dict[str, str]: - return { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "tag_config_sha256": config.tag_config_sha256, - "sdk_config_sha256": config.sdk_config_sha256, - } - - -def _legacy_checkpoint_is_attested( - config: ProductConfig, - session: Path, - rows: Sequence[Mapping[str, Any]], -) -> bool: - """Accept pre-checkpoint O12 data only when its local inputs are unchanged.""" - raw_path = session / "raw_samples.jsonl" - try: - raw_time = raw_path.stat().st_mtime - protected_paths = ( - config.source_urdf, - config.camera_extrinsics, - config.calibration_config, - config.tag_config, - config.sdk_config, - ) - if any( - path is None or path.stat().st_mtime > raw_time - for path in protected_paths - ): - return False - log = (session / "calibration.log").read_text( - encoding="utf-8", errors="replace" - ) - except OSError: - return False - if str(config.source_urdf) not in log: - return False - return any( - row.get("kind") == "o12_temperature_capability_fallback" - and row.get("sdk_config_sha256") == config.sdk_config_sha256 - for row in rows - ) - - -def validate_resume_source( - config: ProductConfig, - session: str | Path, -) -> tuple[list[dict[str, Any]], str]: - candidate = Path(session).expanduser().resolve(strict=True) - root = config.session_root.expanduser().resolve(strict=True) - if candidate.parent != root or not candidate.is_dir(): - raise ValueError("resume session must be a direct child of the serial root") - summary_path = candidate / "calibration_summary_zh.json" - if summary_path.is_file(): - try: - summary = json.loads(summary_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise ValueError("resume session has an invalid summary") from error - if isinstance(summary, Mapping) and summary.get("result") == "PASS": - raise ValueError("a passed O12 session cannot be used as a checkpoint") - rows = load_o12_raw_samples(candidate / "raw_samples.jsonl") - start = _session_start(rows) - if ( - start.get("profile_id") != config.profile_key.profile_id - or start.get("serial_number") != config.serial_number - or int(start.get("sample_schema_version", -1)) - != config.calibration_contract.typed_profile.artifacts.output_schema_version - ): - raise ValueError("resume profile, serial number, or schema differs") - if start.get("acquisition_policy_version") != ACQUISITION_POLICY_VERSION: - raise ValueError( - "resume acquisition policy differs; unified_engine_v1 requires " - "a new full capture" - ) - expected = _protected_values(config) - recorded = {key: str(start.get(key, "")) for key in PROTECTED_INPUT_KEYS} - if all(recorded.values()): - if recorded != expected: - raise ValueError("resume protected input hashes differ") - compatibility = "protected_hashes_v1" - else: - raise ValueError("resume checkpoint has no protected input hashes") - return rows, compatibility - - -def build_resume_checkpoint( - config: ProductConfig, - session: str | Path, -) -> O12ResumeCheckpoint: - rows, compatibility = validate_resume_source(config, session) - return build_resume_checkpoint_from_rows( - config.calibration_contract.typed_profile, - session, - rows, - compatibility=compatibility, - ) - - -def build_resume_checkpoint_from_rows( - profile, - session: str | Path, - rows: Sequence[Mapping[str, Any]], - *, - compatibility: str, -) -> O12ResumeCheckpoint: - task_by_key = {task.key: task for task in profile.motion.tasks} - cross_view_passing: set[tuple[ResumeUnit, int]] = set() - for row in rows: - if row.get("kind") != "o12_roll_cross_view_quality": - continue - try: - unit = ( - str(row["task_name"]), - int(row["cycle"]), - str(row["direction"]), - ) - attempt = int(row.get("attempt", 1)) - valid = int(row.get("valid_frames", 0)) - total = int(row.get("total_frames", 0)) - accepted = ( - unit[0] in ROLL_CROSS_VIEW_BY_TASK - and unit[1] in (0, 1, 2, 3) - and unit[2] in {"decreasing", "increasing"} - and _quality_evidence_accepted(row, valid, total) - ) - except (KeyError, TypeError, ValueError, ZeroDivisionError): - continue - if accepted: - cross_view_passing.add((unit, attempt)) - - passing: dict[ResumeUnit, tuple[int, Mapping[str, Any]]] = {} - for row in rows: - if row.get("kind") != "o12_sweep_observation_quality": - continue - try: - unit = ( - str(row["task_name"]), - int(row["cycle"]), - str(row["direction"]), - ) - attempt = int(row.get("attempt", 1)) - valid = int(row.get("valid_frames", 0)) - total = int(row.get("total_frames", 0)) - accepted = ( - unit[0] in task_by_key - and unit[1] in (0, 1, 2, 3) - and unit[2] in {"decreasing", "increasing"} - and _quality_evidence_accepted(row, valid, total) - and ( - unit[0] not in ROLL_CROSS_VIEW_BY_TASK - or (unit, attempt) in cross_view_passing - ) - ) - except (KeyError, TypeError, ValueError, ZeroDivisionError): - continue - if accepted and attempt >= passing.get(unit, (0, {}))[0]: - passing[unit] = (attempt, row) - - ordered = ordered_resume_units(profile) - completed: list[ResumeUnit] = [] - for unit in ordered: - if unit not in passing: - break - completed.append(unit) - if not completed: - raise ValueError("resume session has no contiguous passed scan unit") - # A session created while resuming declares how many source units it - # intended to import. If startup was interrupted during persistence, its - # JSONL can contain that audit header but only a prefix of the associated - # samples. Do not let such a newer, truncated session shadow the older - # complete checkpoint during automatic selection. - declared_import_counts = [ - int(row.get("completed_unit_count", -1)) - for row in rows - if row.get("kind") == "o12_resume_checkpoint_import" - ] - if any(count < 0 or count > len(completed) for count in declared_import_counts): - raise ValueError("resume checkpoint import is incomplete or truncated") - - selected_attempt = { - unit: passing[unit][0] for unit in completed - } - imported: list[dict[str, Any]] = [] - for row in rows: - kind = row.get("kind") - if kind not in { - "o12_joint_sample", - "o12_pnp_candidate_frame", - "o12_sweep_observation_quality", - "o12_roll_cross_view_sample", - "o12_roll_cross_view_quality", - }: - continue - try: - unit = ( - str(row["task_name"]), - int(row["cycle"]), - str(row["direction"]), - ) - attempt = int(row.get("attempt", 1)) - except (KeyError, TypeError, ValueError): - continue - if unit not in selected_attempt or attempt != selected_attempt[unit]: - continue - copied = dict(row) - copied["resume_imported"] = True - copied["resume_source_session"] = Path(session).resolve().name - imported.append(copied) - - completed_set = set(completed) - completed_tasks = tuple( - task.key - for task in profile.motion.tasks - if all( - (task.key, cycle, direction) in completed_set - for cycle in (0, 1, 2, 3) - for direction in ("decreasing", "increasing") - ) - ) - for row in rows: - if ( - row.get("kind") == "o12_fixed_mapping_preflight" - and str(row.get("task_name", "")) in completed_tasks - ): - copied = dict(row) - copied["resume_imported"] = True - copied["resume_source_session"] = Path(session).resolve().name - imported.append(copied) - - primary_by_unit = { - unit: 0 for unit in completed - } - for row in imported: - if row.get("kind") != "o12_joint_sample": - continue - unit = ( - str(row["task_name"]), int(row["cycle"]), str(row["direction"]) - ) - task = task_by_key[unit[0]] - if row.get("joint") == task.joints[0]: - primary_by_unit[unit] += 1 - if any(count < 40 for count in primary_by_unit.values()): - raise ValueError("resume quality record lacks its primary joint samples") - - cross_samples_by_unit = { - unit: 0 for unit in completed if unit[0] in ROLL_CROSS_VIEW_BY_TASK - } - for row in imported: - if row.get("kind") != "o12_roll_cross_view_sample": - continue - unit = ( - str(row["task_name"]), int(row["cycle"]), str(row["direction"]) - ) - if unit in cross_samples_by_unit: - cross_samples_by_unit[unit] += 1 - if any(count < 40 for count in cross_samples_by_unit.values()): - raise ValueError("resume roll quality lacks its side-view samples") - - return O12ResumeCheckpoint( - source_session=Path(session).expanduser().resolve(), - compatibility=compatibility, - completed_units=tuple(completed), - completed_tasks=completed_tasks, - imported_records=tuple(imported), - ) - - -def automatic_resume_candidate(config: ProductConfig) -> O12ResumeCheckpoint | None: - root = config.session_root - try: - candidates = sorted( - ( - path for path in root.resolve(strict=True).iterdir() - if path.is_dir() and not path.name.startswith("latest_") - ), - key=lambda path: path.name, - reverse=True, - ) - except OSError: - return None - passed: Path | None = None - pointer = root / "latest_passed" - if pointer.exists(): - try: - passed = pointer.resolve(strict=True) - except OSError: - pass - for candidate in candidates: - if passed is not None and candidate.name <= passed.name: - continue - try: - return build_resume_checkpoint(config, candidate) - except (OSError, ValueError): - continue - return None - - -__all__ = [ - "O12ResumeCheckpoint", - "automatic_resume_candidate", - "build_resume_checkpoint", - "build_resume_checkpoint_from_rows", - "ordered_resume_units", - "validate_resume_source", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/runner.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/runner.py deleted file mode 100644 index b038106..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/runner.py +++ /dev/null @@ -1,576 +0,0 @@ -"""One-command O12 right runner with vendor Jazzy overlay loading.""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -import os -from pathlib import Path -import re -import subprocess -import time -from typing import Any - -import rclpy -from rclpy.node import Node -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...product import ProductConfig, load_product_config -from ..l6.runner import ( - _ProgressConsole, - _l6_reason_zh, - _launch_command, - _stop_stack, - render_six_channel_progress_zh, -) -from .pipeline import finalize_o12_session, load_o12_raw_samples -from .resume import automatic_resume_candidate, ordered_resume_units - - -_TASK_LABELS = { - "thumb_pitch_front": "拇指 CMC pitch(正面 ID0→ID1)", - "thumb_roll_front": "拇指 CMC roll(正面 ID0→ID1)", - "thumb_mcp_dip_front": "拇指 MCP / 被动 DIP(正面 ID1→ID2→ID3)", - "thumb_yaw_top": "拇指 CMC yaw(顶部 ID14→ID15)", - "pinky_chain_side": "小指 MCP / 被动 PIP/DIP(侧面 ID4→ID5→ID6→ID7)", - "middle_roll_front": "中指 MCP roll(正面 ID0→ID12 + 侧面 ID4→ID8)", - "middle_mcp_side": "中指 MCP pitch(侧面 ID4→ID8)", - "middle_pip_dip_side": "中指 PIP / 被动 DIP(侧面 ID8→ID9)", - "index_roll_front": "食指 MCP roll(正面 ID0→ID13 + 侧面 ID4→ID10)", - "index_mcp_side": "食指 MCP pitch(侧面 ID4→ID11)", - "index_pip_dip_side": "食指 PIP / 被动 DIP(侧面 ID10→ID11)", -} - - -def _o12_reason_zh(status): - reason = str(status.get("reason", "")) - if reason.startswith("sweep_quality_failed:"): - details = reason.split(":", 2)[-1] - if any( - item in details - for item in ("side_frames=", "side_bins=", "side_maximum_gap=") - ): - return ( - "O12-CROSS-VIEW-SAMPLING-104", - "侧摆任务的主视角数据已采到,但辅助侧面视角在本段轨迹内" - f"保留的时序覆盖不足:{details}。这不表示 Tag 被遮挡。", - "程序会保留已通过断点;重新启动后只重做该扫描单元。" - "若 Tag 持续可见,优先检查侧面 AprilTag 节点的发布频率和 CPU 调度。", - ) - if reason.startswith("mapping_preflight_no_target_motion:"): - task = reason.split(":", 1)[1] - return ( - "O12-MAPPING-301", - f"固定 SDK 映射点动时没有观察到目标关节运动:{task}。", - "检查对应 Tag、通道映射和机械连接;不要交换通道后强行继续。", - ) - if reason.startswith("mapping_preflight_wrong_feedback_direction:"): - task = reason.split(":", 1)[1] - return ( - "O12-MAPPING-302", - f"固定 SDK 映射点动的目标反馈方向不正确:{task}。", - "检查对应 SDK 通道和机械连接;不要交换通道后强行继续。", - ) - if reason.startswith("o12_active_motor_fault"): - return ( - "O12-HEALTH-201", - f"O12 返回活动电机故障:{status.get('error_faults', [])}。", - "检查对应电机的堵转、过流、过热或电机异常;排除后从断点启动。", - ) - if reason.startswith("o12_new_communication_fault"): - return ( - "O12-HEALTH-202", - f"运行中出现了新的 O12 通信异常:{status.get('error_faults', [])}。", - "检查 HCAN 线缆、供电和对应电机通信;程序已保持当前位置。", - ) - if reason.startswith("o12_feedback_stream_timeout"): - return ( - "O12-HEALTH-203", - "O12 命令触发反馈流中断超过 1 秒。", - "检查 HCAN 连接、SDK 节点和供电;恢复后从断点启动。", - ) - if reason.startswith("feedback_outside_registered_feedback_domain:"): - return ( - "O12-FEEDBACK-202", - "O12 反馈超出了已登记的物理反馈范围。", - "保持当前位置,检查诊断中的通道、反馈值和机械端点。", - ) - if reason == "calibration_node_status_timeout": - return ( - "O12-NODE-500", - "O12 标定节点已停止发布状态,运行栈将自动退出。", - "查看本次 calibration.log 中的 Python traceback,并从断点重启。", - ) - return _l6_reason_zh(status, model_name="O12") - - -def render_o12_progress_zh(status, estimator=None) -> str: - """Render O12 in the same operator-oriented layout as G20 and L6.""" - text = render_six_channel_progress_zh( - status, - task_labels=_TASK_LABELS, - reason_renderer=_o12_reason_zh, - estimator=estimator, - ) - temperature = ( - "直接温度回读" - if status.get("temperature_report_verified") - else "错误码 bit1 过热保护" - if status.get("temperature_fallback_active") - else "等待温度能力确认" - ) - resume = status.get("resume", {}) - resume_text = "" - if isinstance(resume, dict) and resume.get("used"): - resume_text = ( - "\n断点恢复:已复用 " - f"{int(resume.get('completed_unit_count', 0))} 个扫描单元;" - f"来源 {resume.get('source_session', '-')}" - ) - cross_view = status.get("roll_cross_view", {}) - cross_view_text = "" - if isinstance(cross_view, dict) and cross_view.get("active"): - recognized = "/".join( - f"ID{int(value)}" - for value in cross_view.get("recognized_tag_ids", ()) - ) or "无" - missing = "/".join( - f"ID{int(value)}" - for value in cross_view.get("unrecognized_tag_ids", ()) - ) or "无" - cross_view_text = ( - "\n侧摆双机位:侧面已识别 " - f"{recognized};未识别/不合格 {missing};联合 " - f"{int(cross_view.get('valid_frames', 0))}/" - f"{int(cross_view.get('total_frames', 0))} 帧(" - f"{float(cross_view.get('joint_frame_rate', 0.0)):.1%})" - ) - coupling = status.get("feedback_coupling", {}) - coupling_text = "" - if isinstance(coupling, dict) and coupling.get("active"): - stage = { - "training_observation": "训练采集", - "holdout_pending_full_sweep_validation": "第四轮整段验证", - "vendor_solver_motion_prior": "运动准备", - }.get(str(coupling.get("reference_kind", "")), "耦合观测") - coupling_text = ( - "\nO12耦合:" - f"{coupling.get('coupled_channel', '-')} 位移 " - f"{float(coupling.get('displacement_rad', 0.0)):.3f}/" - f"{float(coupling.get('hard_displacement_limit_rad', 0.0)):.3f} rad;" - f"{stage};vendor偏差 " - f"{float(coupling.get('residual_rad', 0.0)):.3f} rad(仅诊断)" - ) - auxiliary = status.get("auxiliary_tracking", {}) - auxiliary_text = "" - if isinstance(auxiliary, dict) and auxiliary.get("active"): - channels = list(auxiliary.get("channels", ())) - worst = max( - channels, - key=lambda item: float(item.get("error_rad", 0.0)), - default={}, - ) - stage = ( - "平滑过渡中" - if auxiliary.get("mode") == "transitioning" - else "已稳定保持" - if auxiliary.get("ready") - else "等待稳定" - ) - auxiliary_text = ( - "\nO12避让轴:" - f"{stage};最大偏差 {worst.get('channel', '-')}=" - f"{float(worst.get('error_rad', 0.0)):.3f} rad" - ) - locked_ids = list(status.get("locked_reference_tag_ids", ())) - locked_reference_text = "" - if locked_ids: - locked_reference_text = ( - "\n固定基准:" - + "/".join(f"ID{int(value)}" for value in locked_ids) - + " 已锁定,避让遮挡期间复用" - ) - error_health = str( - status.get("error_health_classification", "awaiting_error_report") - ) - if error_health == "historical_communication_latch": - channels = "/".join( - str(value) for value in status.get( - "confirmed_historical_communication_channels", () - ) - ) or "未知通道" - error_health_text = f"历史通信位已核验({channels})" - elif error_health == "confirming_historical_communication": - error_health_text = ( - "确认历史通信位 " - f"{int(status.get('error_report_matching_count', 0))}/3" - ) - elif error_health == "clear": - error_health_text = "正常" - else: - error_health_text = error_health - return ( - text + cross_view_text + coupling_text + auxiliary_text - + locked_reference_text + resume_text + ( - "\nO12安全:POSITION=" - f"{bool(status.get('position_mode_verified'))};" - f"错误码通道={bool(status.get('error_report_verified'))}" - f"({error_health_text});" - f"温度策略={temperature};速度倍率=" - f"{float(status.get('motion_speed_scale', 1.0)):.1f}x;" - f"控制发布={float(status.get('command_publish_hz', 0.0)):.1f} Hz" - ) - ) - - -class _Monitor(Node): - def __init__(self, progress: _ProgressConsole) -> None: - super().__init__("o12_calibration_runner") - self.status: dict[str, Any] = {} - self.last_status_at = 0.0 - self.progress = progress - self.create_subscription(String, "/o12_calibration/status", self._status, 10) - self.start_client = self.create_client(Trigger, "/o12_calibration/start") - self.abort_client = self.create_client(Trigger, "/o12_calibration/abort") - - def _status(self, message: String) -> None: - try: - value = json.loads(message.data) - except json.JSONDecodeError: - return - if isinstance(value, dict): - self.status = value - self.last_status_at = time.monotonic() - self.progress.update(value) - - -def _wait_until_o12( - monitor: _Monitor, - process: subprocess.Popen[Any], - predicate, - *, - timeout: float | None, - status_stale_after: float = 3.0, -) -> bool: - """Wait for O12 while also detecting a dead calibration child node.""" - started = time.monotonic() - while rclpy.ok(): - if process.poll() is not None: - return False - rclpy.spin_once(monitor, timeout_sec=0.2) - if predicate(monitor.status): - return True - now = time.monotonic() - stale_limit = ( - 180.0 - if monitor.status.get("state") == "FINALIZING" - else float(status_stale_after) - ) - if ( - monitor.last_status_at > 0.0 - and now - monitor.last_status_at > stale_limit - ): - status_publishers = monitor.count_publishers( - "/o12_calibration/status" - ) - monitor.status = { - **monitor.status, - "state": "ABORTED", - "reason": ( - "calibration_node_process_exited" - if status_publishers == 0 - else "calibration_node_status_timeout" - ), - } - return False - if timeout is not None and now - started > timeout: - return False - return False - - -def _log_exception_summary(log_path: Path) -> str: - """Return the final child exception instead of hiding it as status loss.""" - try: - lines = log_path.read_text( - encoding="utf-8", errors="replace" - ).splitlines() - except OSError: - return "" - prefixes = ( - "AttributeError:", "AssertionError:", "ImportError:", - "IndexError:", "KeyError:", "ModuleNotFoundError:", - "OSError:", "RuntimeError:", "TypeError:", "ValueError:", - ) - ansi = re.compile(r"\x1b\[[0-9;]*m") - for raw in reversed(lines[-300:]): - line = ansi.sub("", raw).strip() - payload = line.rsplit("] ", 1)[-1].strip() - if payload.startswith(prefixes): - return payload - return "" - - -def _overlay_environment(setup: Path) -> dict[str, str]: - completed = subprocess.run( - ["bash", "-c", 'source "$1" >/dev/null 2>&1; env -0', "bash", str(setup)], - check=True, - stdout=subprocess.PIPE, - ) - environment = dict(os.environ) - for item in completed.stdout.split(b"\0"): - if b"=" in item: - key, value = item.split(b"=", 1) - environment[key.decode()] = value.decode(errors="surrogateescape") - return environment - - -def _protected_inputs(config: ProductConfig) -> dict[str, str]: - return { - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "tag_config_sha256": config.tag_config_sha256, - "sdk_config_sha256": config.sdk_config_sha256, - } - - -def _finalize_completed_resume(config, resume, session: Path) -> int: - """Publish a complete checkpoint without touching calibration hardware.""" - expected = ordered_resume_units( - config.calibration_contract.typed_profile - ) - if tuple(resume.completed_units) != tuple(expected): - raise ValueError("completed-resume finalization requires every scan unit") - print( - "O12 完整断点已包含全部 " - f"{len(expected)} 个扫描单元;不启动 SDK、相机或避让运动," - "直接拟合、验证并生成 URDF(约需 1 分钟)。", - flush=True, - ) - try: - payload, _fit, correction = finalize_o12_session( - session_dir=session, - serial_number=config.serial_number, - source_urdf=config.source_urdf, - protected_inputs=_protected_inputs(config), - # The checkpoint builder imports only matching, passed scan - # transactions plus their PnP provenance. This also avoids - # recursively replaying prior session/audit wrapper records. - records=list(resume.imported_records), - publish=True, - ) - except BaseException as error: - print( - "O12 完整断点的拟合、验证或 URDF 写回失败:" - f"{error}", - flush=True, - ) - return 3 - print("\n".join(( - "PASS:O12 完整断点已通过拟合和独立 holdout;" - "thumb_mcp 静态零位保留原始 CAD。", - f"发布结果:{config.session_root / 'latest_passed'}", - f"JSON:{session / config.calibration_contract.typed_profile.artifacts.calibration_filename.format(serial_number=config.serial_number)}", - f"URDF:{correction.path}", - )), flush=True) - return 0 - - -def _run_online( - config: ProductConfig, *, record_bag: bool, commands_enabled: bool, - allow_resume: bool, -) -> int: - if config.sdk_setup is None or config.sdk_config is None: - raise ValueError("O12 vendor SDK overlay/config are required") - resume = automatic_resume_candidate(config) if allow_resume else None - session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S") - while session.exists(): - time.sleep(1.0) - session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S") - session.mkdir(parents=True) - expected_resume_units = ordered_resume_units( - config.calibration_contract.typed_profile - ) - if ( - resume is not None - and tuple(resume.completed_units) == tuple(expected_resume_units) - ): - return _finalize_completed_resume(config, resume, session) - log_path = session / "calibration.log" - log_stream = log_path.open("a", encoding="utf-8", buffering=1) - rclpy.init() - monitor = _Monitor(_ProgressConsole(renderer=render_o12_progress_zh)) - process: subprocess.Popen[str] | None = None - try: - # Discover already-running SDK/GUI/calibration publishers before this - # command starts its own vendor node. Otherwise a vendor open failure - # could be masked by feedback from the stale process holding HCAN. - discovery_deadline = time.monotonic() + 1.5 - while rclpy.ok() and time.monotonic() < discovery_deadline: - rclpy.spin_once(monitor, timeout_sec=0.1) - existing_state = monitor.count_publishers("/o12/right/joint_states") - existing_command = monitor.count_publishers("/o12/right/joint_cmd") - if existing_state or existing_command: - print( - "O12 启动前独占检查失败:检测到既有 SDK/GUI/标定进程" - f"(状态发布者={existing_state},命令发布者={existing_command})。" - "请先关闭它们,再只运行本标定命令。", - flush=True, - ) - return 2 - print(f"O12 标定环境正在启动;日志:{log_path}", flush=True) - if resume is not None: - print( - "O12 断点恢复:来源 " - f"{resume.source_session},复用 " - f"{len(resume.completed_units)} 个已通过扫描单元," - "启动后先恢复安全姿态。", - flush=True, - ) - process = subprocess.Popen( - _launch_command( - config, session, record_bag=record_bag, - commands_enabled=commands_enabled, - resume_from=( - None if resume is None else resume.source_session - ), - ), - cwd=config.workspace, - env=_overlay_environment(config.sdk_setup), - stdout=log_stream, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - ) - ready = _wait_until_o12( - monitor, process, - lambda status: status.get("state") in {"READY", "PAUSED", "ABORTED"}, - timeout=120.0, - ) - if not ready and not monitor.status: - exception = _log_exception_summary(log_path) - print( - "O12 标定节点初始化超时:设备进程已启动,但标定节点未发布状态;" - "请查看日志中的节点构造或断点导入阶段。" - f"日志:{log_path}", - flush=True, - ) - if exception: - print(f"标定节点异常:{exception}", flush=True) - return 2 - if not ready or monitor.status.get("state") != "READY": - print( - "O12 启动预检失败:请检查 HCAN、POSITION 模式、错误/温度回读、" - f"12路反馈和三相机。日志:{log_path}", flush=True, - ) - return 2 - if not monitor.start_client.wait_for_service(timeout_sec=10.0): - print("O12 标定 /start 服务不可用。", flush=True) - return 2 - future = monitor.start_client.call_async(Trigger.Request()) - while rclpy.ok() and not future.done(): - rclpy.spin_once(monitor, timeout_sec=0.2) - response = future.result() - if response is None or not response.success: - print(f"O12 标定未启动:{getattr(response, 'message', '')}", flush=True) - return 2 - print( - "O12 标定已自动开始:POSITION,50 Hz,平滑限速轨迹。", - flush=True, - ) - finished = _wait_until_o12( - monitor, process, - lambda status: status.get("state") in {"PASSED", "PAUSED", "ABORTED"}, - timeout=None, - ) - if not finished or monitor.status.get("state") != "PASSED": - exception = _log_exception_summary(log_path) - print( - "O12 标定已安全停止并保持当前位置:" - + str(monitor.status.get("reason", "process_exit")), - flush=True, - ) - if exception: - print(f"标定节点异常:{exception}", flush=True) - print(f"诊断日志:{log_path}", flush=True) - return 3 - print("\n".join(( - "PASS:O12 右手 11 个实测任务和独立 holdout 已通过;" - "thumb_mcp 静态零位保留原始 CAD。", - f"发布结果:{config.session_root / 'latest_passed'}", - f"JSON:{monitor.status.get('final_json')}", - f"URDF:{monitor.status.get('final_urdf')}", - )), flush=True) - return 0 - except KeyboardInterrupt: - if monitor.abort_client.wait_for_service(timeout_sec=2.0): - monitor.abort_client.call_async(Trigger.Request()) - rclpy.spin_once(monitor, timeout_sec=1.0) - return 130 - finally: - monitor.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - if process is not None: - _stop_stack(process) - log_stream.flush() - os.fsync(log_stream.fileno()) - log_stream.close() - - -def main(args: list[str] | None = None) -> None: - parser = argparse.ArgumentParser(description="O12 right 16-Tag calibration") - parser.add_argument("--config", required=True) - parser.add_argument("--workspace", default=None) - parser.add_argument("--record-bag", action="store_true") - parser.add_argument("--commands-disabled", action="store_true") - parser.add_argument("--validate-only", action="store_true") - parser.add_argument("--offline-raw", default="") - parser.add_argument("--offline-output", default="") - parser.add_argument("--publish-offline", action="store_true") - parser.add_argument( - "--no-resume", action="store_true", - help="忽略兼容的失败会话,从头开始采集", - ) - selected = parser.parse_args(args) - config = load_product_config( - selected.config, - workspace=selected.workspace, - check_can=False, - ) - if config.profile_key.profile_id != "O12/right/o12_right_16/v1": - raise ValueError("O12 runner received a different product profile") - if selected.validate_only: - print(f"配置有效:{config.profile_key.profile_id},SDK {config.sdk_config_sha256}") - return - if selected.offline_raw: - output = ( - Path(selected.offline_output).expanduser().resolve() - if selected.offline_output else config.session_root / - (datetime.now().strftime("%Y%m%d_%H%M%S") + "_offline") - ) - output.mkdir(parents=True, exist_ok=False) - payload, _fit, correction = finalize_o12_session( - session_dir=output, - serial_number=config.serial_number, - source_urdf=config.source_urdf, - protected_inputs=_protected_inputs(config), - records=load_o12_raw_samples(selected.offline_raw), - publish=selected.publish_offline, - ) - print( - f"离线回放PASS:schema {payload['schema_version']}," - f"thumb_mcp 静态零位保留原始 CAD,URDF {correction.path}" - ) - return - raise SystemExit(_run_online( - config, - record_bag=selected.record_bag, - commands_enabled=not selected.commands_disabled, - allow_resume=not selected.no_resume, - )) - - -__all__ = ["main", "render_o12_progress_zh"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o12/urdf.py b/src/linkerhand_calibration/linkerhand_calibration/models/o12/urdf.py deleted file mode 100644 index a1c1595..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o12/urdf.py +++ /dev/null @@ -1,351 +0,0 @@ -"""Auditable O12 URDF correction from radian-domain calibration results.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import datetime -import math -from pathlib import Path -import re -from typing import Mapping -import xml.etree.ElementTree as ET - -import numpy as np - -from ...core.urdf import ( - UrdfJointPatch, - UrdfPatchSet, - validate_urdf_mimic_ranges, - write_urdf_patches, -) -from .fitting import O12FitResult, measured_curve_bounds -from .kinematics import PASSIVE_SDK_SOURCE_BY_JOINT, vendor_passive_curve -from ..l6.urdf import _corrected_origin_rpy -from .profile import ( - ACTIVE_JOINTS, - CALIBRATED_ACTIVE_JOINTS, - COMMAND_INDEX_BY_JOINT, - MEASURED_PASSIVE_JOINTS, - MIMIC_SOURCE_BY_JOINT, - SDK_TO_URDF_SIGN, - STATIC_ZERO_EXCLUDED_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, -) - - -SPLAY_JOINTS = frozenset({"index_mcp_roll", "middle_mcp_roll"}) - - -def _measured_curve_bounds( - name: str, result: O12FitResult, *, sign: float -) -> tuple[float, float]: - donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name, name) - return measured_curve_bounds( - donor, - result.curves[donor], - result.feedback_domains_rad[donor], - sign=sign, - ) - - -def _source_reachable_upper( - name: str, - joints: Mapping[str, ET.Element], - cache: dict[str, float], -) -> float: - if name in cache: - return cache[name] - node = joints[name] - limit = node.find("limit") - if limit is None or limit.get("upper") is None: - raise ValueError(f"source URDF joint has no upper limit: {name}") - mimic = node.find("mimic") - if mimic is None: - value = float(limit.get("upper")) - else: - source_name = str(mimic.get("joint", "")) - if source_name not in joints: - raise ValueError(f"source URDF mimic source is missing: {source_name}") - value = ( - float(mimic.get("offset", "0")) - + float(mimic.get("multiplier", "1")) - * _source_reachable_upper(source_name, joints, cache) - ) - if not math.isfinite(value): - raise ValueError(f"source URDF reachable endpoint is invalid: {name}") - cache[name] = value - return value - - -def o12_active_ranges(result: O12FitResult) -> dict[str, tuple[float, float]]: - """Return the exact active ranges used by O12 JSON and URDF publication.""" - ranges: dict[str, tuple[float, float]] = {} - for name in sorted( - CALIBRATED_ACTIVE_JOINTS | set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT) - ): - if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT: - continue - measured_lower, measured_upper = _measured_curve_bounds( - name, - result, - sign=SDK_TO_URDF_SIGN[COMMAND_INDEX_BY_JOINT[name]], - ) - ranges[name] = ( - (measured_lower, measured_upper) - if name in SPLAY_JOINTS - else (min(0.0, measured_lower), max(0.0, measured_upper)) - ) - return ranges - - -def o12_endpoint_mimic_contract( - source_urdf: str | Path, - result: O12FitResult, -) -> dict[str, float]: - """Linear URDF mimics that preserve the source-CAD closed chain pose. - - O12 runtime uses the nonlinear vendor solver. URDF ``mimic`` is only a - linear visualization fallback, so choose its coefficient to reproduce the - reviewed CAD mechanical endpoint rather than fitting an arbitrary planar - Tag phase. Chained pinky DIP is resolved after pinky PIP. - """ - root = ET.parse(Path(source_urdf).expanduser().resolve()).getroot() - joints = { - str(node.get("name")): node - for node in root.findall("joint") - if node.get("type") == "revolute" - } - reachable_source: dict[str, float] = {} - desired = { - name: _source_reachable_upper(name, joints, reachable_source) - for name in MEASURED_PASSIVE_JOINTS - } - corrected_source_upper = { - name: values[1] for name, values in o12_active_ranges(result).items() - } - multipliers: dict[str, float] = {} - for name in ( - "thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip" - ): - node = joints[name] - mimic = node.find("mimic") - if mimic is None: - raise ValueError(f"O12 passive joint has no mimic element: {name}") - source_name = MIMIC_SOURCE_BY_JOINT[name] - source_endpoint = corrected_source_upper.get(source_name, desired.get(source_name)) - if source_endpoint is None or abs(source_endpoint) <= 1.0e-12: - raise ValueError(f"O12 mimic source endpoint is invalid: {source_name}") - offset = float(mimic.get("offset", "0")) - multiplier = (desired[name] - offset) / source_endpoint - if not math.isfinite(multiplier) or multiplier <= 0.0: - raise ValueError(f"O12 endpoint mimic is invalid: {name}") - multipliers[name] = multiplier - corrected_source_upper[name] = desired[name] - return multipliers - - -@dataclass(frozen=True) -class O12UrdfCorrection: - path: Path - origin_offsets_rad: Mapping[str, float] - corrected_limits_rad: Mapping[str, tuple[float, float]] - mimic_multipliers: Mapping[str, float] - preserved_ring_fields: tuple[str, ...] - - -def write_o12_corrected_urdf( - *, - source_urdf: str | Path, - output_directory: str | Path, - serial_number: str, - result: O12FitResult, - timestamp: str | None = None, -) -> O12UrdfCorrection: - """Write full-range visual joint limits and equivalent endpoint mimics. - - O12 SDK feedback is the continuous input coordinate, while the 16-Tag - trajectories identify the corresponding 19-joint URDF motion. The source - CAD is never allowed to truncate acquisition. Ring geometry and mimic - ratios remain CAD-owned; its active range remains constrained by that - preserved chain. - """ - source = Path(source_urdf).expanduser().resolve() - if not source.is_file(): - raise ValueError(f"source URDF does not exist: {source}") - if "calibrated" in source.stem.lower(): - raise ValueError("O12 source URDF must be immutable original CAD") - if set(result.travels_rad) != ( - CALIBRATED_ACTIVE_JOINTS | set(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT) - ): - raise ValueError("O12 travel result has the wrong active joint set") - if set(result.mimic_fits) != MEASURED_PASSIVE_JOINTS: - raise ValueError("O12 mimic result has the wrong passive joint set") - for name in STATIC_ZERO_EXCLUDED_JOINTS: - if ( - not math.isclose( - float(result.zero_offsets_rad.get(name, math.nan)), - 0.0, - rel_tol=0.0, - abs_tol=1.0e-12, - ) - or result.zero_method_by_joint.get(name) - != "source_cad_zero_profile_excluded" - ): - raise ValueError( - f"O12 {name} must retain immutable source-CAD static zero" - ) - for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items(): - if not math.isclose(result.zero_offsets_rad[target], result.zero_offsets_rad[donor], abs_tol=1.0e-9, rel_tol=0.0): - raise ValueError(f"{target} static zero differs from transfer donor {donor}") - - root = ET.parse(source).getroot() - joints = { - str(node.get("name")): node - for node in root.findall("joint") - if node.get("type") == "revolute" - } - required = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS | { - "ring_mcp_pitch", "ring_pip", "ring_dip" - } - if missing := required - set(joints): - raise ValueError("source URDF is missing O12 joints: " + ",".join(sorted(missing))) - - corrected_limits: dict[str, tuple[float, float]] = {} - patches: dict[str, UrdfJointPatch] = {} - active_targets = CALIBRATED_ACTIVE_JOINTS | set( - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT - ) - measured_active_ranges = o12_active_ranges(result) - active_ranges: dict[str, tuple[float, float]] = {} - for name in sorted(active_targets): - limit = joints[name].find("limit") - if limit is None: - raise ValueError(f"O12 source active joint has no limit: {name}") - lower_text = limit.get("lower") - upper_text = limit.get("upper") - if lower_text is None or upper_text is None: - raise ValueError(f"O12 source active joint has incomplete limits: {name}") - cad_lower = float(lower_text) - cad_upper = float(upper_text) - travel = abs(float(result.travels_rad[name])) - if not math.isfinite(travel) or travel <= math.radians(2.0): - raise ValueError(f"invalid measured O12 travel: {name}") - if not cad_lower < cad_upper: - raise ValueError(f"invalid corrected O12 limits: {name}") - if name in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT: - # The ring has no Tag. Preserve its own reviewed CAD range and - # passive-chain contract rather than transplanting pinky geometry. - lower, upper = cad_lower, cad_upper - else: - lower, upper = measured_active_ranges[name] - if not math.isfinite(lower) or not math.isfinite(upper) or lower >= upper: - raise ValueError(f"invalid measured O12 limits: {name}") - corrected_limits[name] = (lower, upper) - active_ranges[name] = (lower, upper) - origin_offset = float(result.zero_offsets_rad.get(name, 0.0)) - patches[name] = UrdfJointPatch( - origin_rpy=( - _corrected_origin_rpy(joints[name], origin_offset) - if abs(origin_offset) > 1.0e-12 else None - ), - limit_lower=f"{lower:.15g}", limit_upper=f"{upper:.15g}" - ) - - mimic_multipliers = o12_endpoint_mimic_contract(source, result) - for name in sorted(MEASURED_PASSIVE_JOINTS): - mimic = joints[name].find("mimic") - if mimic is None: - raise ValueError(f"O12 passive joint has no mimic element: {name}") - multiplier = float(mimic_multipliers[name]) - if not math.isfinite(multiplier) or not 0.5 <= multiplier <= 2.2: - raise ValueError(f"invalid O12 mimic multiplier: {name}") - patches[name] = UrdfJointPatch( - mimic_multiplier=f"{multiplier:.15g}" - ) - - # Passive limits remain CAD-owned unless the reviewed vendor polynomial - # itself has a small interior extremum outside that range (index DIP does). - # Never expand a physical limit from the noisier Tag-derived curve. - for name in ( - "thumb_dip", "index_dip", "middle_dip", "pinky_pip", "pinky_dip" - ): - node = joints[name] - limit = node.find("limit") - assert limit is not None - cad_lower = float(limit.get("lower", "nan")) - cad_upper = float(limit.get("upper", "nan")) - sdk_source = PASSIVE_SDK_SOURCE_BY_JOINT[name] - feedback_lower, feedback_upper = result.feedback_domains_rad[name] - inputs = np.linspace( - min(0.0, feedback_lower), max(0.0, feedback_upper), 257 - ) - motor = COMMAND_INDEX_BY_JOINT[sdk_source] - vendor_values = vendor_passive_curve( - name, - inputs, - sdk_to_urdf_sign=SDK_TO_URDF_SIGN[motor], - ) - lower = min(cad_lower, *vendor_values) - upper = max(cad_upper, *vendor_values) - if not lower < upper: - raise ValueError(f"invalid corrected O12 passive limits: {name}") - corrected_limits[name] = (lower, upper) - existing = patches[name] - patches[name] = UrdfJointPatch( - limit_lower=( - f"{lower:.15g}" if lower < cad_lower - 1.0e-12 else None - ), - limit_upper=( - f"{upper:.15g}" if upper > cad_upper + 1.0e-12 else None - ), - mimic_multiplier=existing.mimic_multiplier, - ) - - stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S") - if re.fullmatch(r"\d{8}_\d{6}", stamp) is None: - raise ValueError("O12 URDF timestamp must use YYYYMMDD_HHMMSS") - safe_serial = "".join( - char if char.isalnum() or char in "_.-" else "_" - for char in str(serial_number) - ) - if not safe_serial: - raise ValueError("serial number must not be empty") - output = Path(output_directory).expanduser().resolve() - output.mkdir(parents=True, exist_ok=True) - destination = output / f"{source.stem}_calibrated_{safe_serial}_{stamp}.urdf" - write_urdf_patches( - source_urdf=source, - destination_urdf=destination, - patches=UrdfPatchSet(joints=patches), - forbidden_source_stem_patterns=(r"calibrated",), - copy_complete_mesh_directory=True, - ) - try: - validate_urdf_mimic_ranges(destination, reference_urdf=source) - except Exception: - # Publication is transactional: a generated file that failed the - # physical mimic-chain gate must never remain as a plausible result. - destination.unlink(missing_ok=True) - raise - return O12UrdfCorrection( - path=destination, - origin_offsets_rad={ - name: float(result.zero_offsets_rad[name]) - for name in ACTIVE_JOINTS - }, - corrected_limits_rad=corrected_limits, - mimic_multipliers=mimic_multipliers, - preserved_ring_fields=( - "ring_mcp_pitch.origin.xyz", "ring_mcp_pitch.limit", - "ring_pip.origin", "ring_pip.limit", "ring_pip.mimic", - "ring_dip.origin", "ring_dip.limit", "ring_dip.mimic", - ), - ) - - -__all__ = [ - "O12UrdfCorrection", - "o12_active_ranges", - "o12_endpoint_mimic_contract", - "write_o12_corrected_urdf", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/__init__.py deleted file mode 100644 index bffbc3e..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Registered O6 calibration profiles.""" - -from ..registry import ProfileRegistry - - -def register_profiles(registry: ProfileRegistry) -> None: - from .left_transfer import build_profile as build_left_transfer_profile - from .profile import build_profile - - registry.register(build_profile()) - registry.register(build_left_transfer_profile()) - - -__all__ = ["register_profiles"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/fitting.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/fitting.py deleted file mode 100644 index 8386b0f..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/fitting.py +++ /dev/null @@ -1,334 +0,0 @@ -"""O6 curve, geometric-zero, holdout, and linear mimic fitting.""" - -from __future__ import annotations - -import math -from pathlib import Path -from typing import Mapping, Sequence -import xml.etree.ElementTree as ET - -import numpy as np - -from ..g20.profile import HandCalibrationProfile, JointCurveFit, JointSpec -from ..g20.zero_solver import ( - ZeroCalibrationProfile, - ZeroSolveResult, - fit_joint_axis_measurement, - fit_rotation_joint_curve, - rotation_curve_holdout_errors, - solve_urdf_zero_offsets, - with_depth_free_axis_projection, -) -from ..l6.fitting import L6FitResult, MimicFit, fit_coupling_model -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - GEOMETRIC_ZERO_JOINTS, - COMMAND_INDEX_BY_JOINT, - COMMAND_NAMES, - COUPLING_MODEL_BY_JOINT, - COUPLING_RESIDUAL_MAX_DEG, - COUPLING_RESIDUAL_P95_DEG, - ENDPOINT_ANCHOR_BY_JOINT, - KEY, - MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M, - MAXIMUM_HYSTERESIS_DEG, - MEASURED_PASSIVE_JOINTS, - MIMIC_SOURCE_BY_JOINT, -) - - -O6FitResult = L6FitResult -O6_THUMB_AXIS_JOINTS: tuple[str, ...] = ( - "rh_thumb_cmc_yaw", - "rh_thumb_cmc_pitch", - "rh_thumb_ip", - "rh_pinky_mcp_pitch", -) - - -def _zero_profile() -> ZeroCalibrationProfile: - specs = { - "rh_thumb_cmc_yaw": JointSpec( - "rh_thumb_cmc_yaw", 1, True, "top", "top_base", "thumb_yaw" - ), - "rh_thumb_cmc_pitch": JointSpec( - "rh_thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_pitch" - ), - "rh_thumb_ip": JointSpec( - "rh_thumb_ip", 0, False, "front", "thumb_pitch", "thumb_ip" - ), - "rh_pinky_mcp_pitch": JointSpec( - "rh_pinky_mcp_pitch", 5, True, "side", "side_base", "pinky_pitch" - ), - } - hand = HandCalibrationProfile( - side="right", - reference_finger="pinky", - view_tags={}, - preflight_view_roles={}, - joint_specs=specs, - sweep_specs=(), - image_trajectory_joints=frozenset(), - roll_clearance_commands={}, - thumb_pitch_clearance_commands={}, - layout_id=KEY.layout, - model="O6", - command_names=COMMAND_NAMES, - baseline_command=(255,) * 6, - directional_zero=True, - isolated_holdout=True, - ) - return ZeroCalibrationProfile( - hand=hand, - direct_zero_joints=("rh_thumb_cmc_yaw", "rh_thumb_cmc_pitch"), - axis_joints=O6_THUMB_AXIS_JOINTS, - inherited_zero_joints={}, - inherited_static_zero_joints={}, - constrained_circle_joints=frozenset(O6_THUMB_AXIS_JOINTS), - root_anchor_joints=frozenset({"rh_thumb_cmc_yaw"}), - axis_parent_joint={"rh_thumb_cmc_pitch": "rh_thumb_cmc_yaw"}, - phase_parent_joint={"rh_thumb_ip": "rh_thumb_cmc_pitch"}, - offset_observer_joint={ - "rh_thumb_cmc_yaw": "rh_thumb_cmc_pitch", - "rh_thumb_cmc_pitch": "rh_thumb_ip", - }, - same_view_axis_pair_by_offset={}, - fixed_direct_zero_offsets_rad={}, - static_output_zero_offsets_rad={}, - base_pose_strategy="thumb_serial", - orientation_anchor_joint="rh_pinky_mcp_pitch", - directed_base_axis_joints=frozenset({ - "rh_thumb_cmc_yaw", "rh_pinky_mcp_pitch" - }), - ) - - -def _travel(fit: JointCurveFit) -> float: - value = 0.5 * ( - float(fit.decreasing_rad[0] - fit.decreasing_rad[255]) - + float(fit.increasing_rad[0] - fit.increasing_rad[255]) - ) - if not math.isfinite(value) or value <= 0.0: - raise ValueError("O6 fitted travel must be finite and positive") - return value - - -def _endpoint_offsets( - source_urdf: str | Path, - curves: Mapping[str, JointCurveFit], -) -> tuple[dict[str, float], dict[str, float]]: - joints = { - str(joint.get("name")): joint - for joint in ET.parse(Path(source_urdf)).getroot().findall("joint") - } - offsets: dict[str, float] = {} - travels: dict[str, float] = {} - for name in sorted(CALIBRATED_ACTIVE_JOINTS): - joint = joints.get(name) - limit = None if joint is None else joint.find("limit") - if limit is None or limit.get("lower") is None: - raise ValueError(f"O6 source joint has incomplete limits: {name}") - travel = _travel(curves[name]) - offset = float(limit.get("lower")) - if ENDPOINT_ANCHOR_BY_JOINT[name] != "lower_at_start": - raise ValueError(f"unsupported O6 endpoint anchor: {name}") - if not math.isfinite(offset) or abs(offset) > math.radians(15.0): - raise ValueError(f"O6 endpoint zero offset exceeds 15 degrees: {name}") - offsets[name] = offset - travels[name] = travel - return offsets, travels - - -def _has_complete_geometry( - records: Mapping[str, Sequence[Mapping[str, object]]], -) -> bool: - required = { - "relative_translation_xyz_m", "parent_pose_common", "child_pose_common", - "view_normal_common_xyz", "camera_center_common_xyz_m", "state_u8", - } - return all( - rows and all(required.issubset(row) for row in rows) - for name in O6_THUMB_AXIS_JOINTS - for rows in (records.get(name, ()),) - ) - - -def _fit_thumb_zero( - source_urdf: str | Path, - records: Mapping[str, Sequence[Mapping[str, object]]], - curves: Mapping[str, JointCurveFit], -) -> ZeroSolveResult: - profile = _zero_profile() - measurements = [] - by_key: dict[tuple[str, int], object] = {} - for cycle in range(4): - for name in ( - "rh_thumb_cmc_yaw", "rh_thumb_cmc_pitch", "rh_pinky_mcp_pitch" - ): - rows = records[name] - measurement = fit_joint_axis_measurement( - name, - rows, - cycle=cycle, - zero_command_u8=255, - constrained_circle_joints=profile.constrained_circle_joints, - view_normal_common_xyz=rows[0]["view_normal_common_xyz"], - canonical_zero_direction="decreasing", - ) - measurement = with_depth_free_axis_projection( - measurement, rows[0]["camera_center_common_xyz_m"] - ) - measurements.append(measurement) - by_key[(name, cycle)] = measurement - rows = records["rh_thumb_ip"] - pitch_axis = by_key[("rh_thumb_cmc_pitch", cycle)] - measurement = fit_joint_axis_measurement( - "rh_thumb_ip", - rows, - cycle=cycle, - zero_command_u8=255, - axis_common_constraint=pitch_axis.axis_common_xyz, - constrained_circle_joints=profile.constrained_circle_joints, - view_normal_common_xyz=rows[0]["view_normal_common_xyz"], - canonical_zero_direction="decreasing", - ) - measurements.append(with_depth_free_axis_projection( - measurement, rows[0]["camera_center_common_xyz_m"] - )) - result = solve_urdf_zero_offsets( - source_urdf=source_urdf, - measurements=measurements, - curves=curves, - motor_by_joint={ - name: COMMAND_INDEX_BY_JOINT[MIMIC_SOURCE_BY_JOINT.get(name, name)] - for name in O6_THUMB_AXIS_JOINTS - }, - training_cycles=(0, 1, 2), - validation_cycle=3, - maximum_offset_rad=math.radians(15.0), - finger_maximum_offset_rad=math.radians(15.0), - joint_maximum_offset_rad={ - "rh_thumb_cmc_yaw": math.radians(15.0), - "rh_thumb_cmc_pitch": math.radians(15.0), - }, - maximum_cycle_difference_rad=math.radians(0.75), - minimum_applied_offset_rad=math.radians(0.1), - maximum_validation_mae_rad=math.radians(1.0), - maximum_validation_p95_rad=math.radians(2.0), - maximum_validation_error_rad=math.radians(3.0), - maximum_confidence_half_width_rad=math.radians(1.0), - maximum_pose_axis_line_rms_m=0.0015, - hand_type="right", - tag_layout=KEY.layout, - zero_profile=profile, - ) - if not result.passed: - details = ",".join( - f"{name}={reason}" for name, reason in sorted(result.failure_reasons.items()) - ) - raise ValueError("O6 thumb axis zero solve failed:" + details) - if ( - not math.isfinite(result.axis_line_rms_m) - or result.axis_line_rms_m > MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M - ): - raise ValueError( - "O6 thumb cross-view axis line RMS exceeds " - f"{MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M * 1000.0:.1f} mm: " - f"actual={result.axis_line_rms_m * 1000.0:.3f} mm; " - "recalibrate camera extrinsics or check the rigid checkerboard" - ) - return result - - -def fit_o6_session( - source_urdf: str | Path, - records_by_joint: Mapping[str, Sequence[Mapping[str, object]]], - *, - require_thumb_axis_zero: bool = False, -) -> O6FitResult: - expected = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS - if set(records_by_joint) != expected: - raise ValueError("O6 records must contain exactly five measured joints") - curves: dict[str, JointCurveFit] = {} - holdout: dict[str, tuple[float, ...]] = {} - cycle_curves: dict[str, dict[int, JointCurveFit]] = {} - for name in sorted(expected): - rows = [dict(row) for row in records_by_joint[name]] - training = [row for row in rows if int(row["cycle"]) in {0, 1, 2}] - validation = [row for row in rows if int(row["cycle"]) == 3] - if not training or not validation: - raise ValueError(f"{name} is missing training or holdout records") - fit = fit_rotation_joint_curve(training, zero_command_u8=255) - errors = rotation_curve_holdout_errors(fit, validation, zero_command_u8=255) - absolute = np.abs(np.asarray(errors, dtype=float)) - if ( - float(np.mean(absolute)) > math.radians(1.0) - or float(np.percentile(absolute, 95.0)) > math.radians(2.0) - or float(np.max(absolute)) > math.radians(3.0) - ): - raise ValueError(f"{name} isolated holdout failed") - correction_limit = math.radians( - 3.0 if name in MEASURED_PASSIVE_JOINTS else 2.0 - ) - if fit.maximum_monotonic_correction_rad > correction_limit: - raise ValueError(f"{name} monotonic correction exceeds limit") - curves[name] = fit - holdout[name] = errors - cycle_curves[name] = { - cycle: fit_rotation_joint_curve( - [row for row in training if int(row["cycle"]) == cycle], - zero_command_u8=255, - ) - for cycle in (0, 1, 2) - } - offsets, travels = _endpoint_offsets(source_urdf, curves) - if require_thumb_axis_zero and not _has_complete_geometry(records_by_joint): - raise ValueError( - "O6 thumb absolute zero requires common-frame Tag pose trajectories" - ) - thumb_result = None - zero_methods = { - name: "mechanical_lower_endpoint" for name in CALIBRATED_ACTIVE_JOINTS - } - if _has_complete_geometry(records_by_joint): - thumb_result = _fit_thumb_zero(source_urdf, records_by_joint, curves) - for name in GEOMETRIC_ZERO_JOINTS: - offsets[name] = float(thumb_result.direct_offsets_rad[name]) - zero_methods[name] = "urdf_serial_axis_geometry" - mimic_fits: dict[str, MimicFit] = {} - for target in sorted(MEASURED_PASSIVE_JOINTS): - source = MIMIC_SOURCE_BY_JOINT[target] - pairs = [] - for cycle in (0, 1, 2): - active = cycle_curves[source][cycle] - passive = cycle_curves[target][cycle] - pairs.append(( - tuple(active.decreasing_rad) + tuple(active.increasing_rad), - tuple(passive.decreasing_rad) + tuple(passive.increasing_rad), - )) - mimic_fits[target] = fit_coupling_model( - source, - target, - curves[source], - curves[target], - model=COUPLING_MODEL_BY_JOINT[target], - cycle_curve_pairs=pairs, - maximum_multiplier=2.2, - maximum_residual_p95_rad=math.radians( - COUPLING_RESIDUAL_P95_DEG - ), - maximum_residual_rad=math.radians(COUPLING_RESIDUAL_MAX_DEG), - ) - return O6FitResult( - curves=curves, - zero_offsets_rad=offsets, - travels_rad=travels, - mimic_fits=mimic_fits, - holdout_errors_rad=holdout, - zero_method_by_joint=zero_methods, - zero_fallback_reason_by_joint={}, - thumb_zero_result=thumb_result, - ) - - -__all__ = ["MimicFit", "O6FitResult", "O6_THUMB_AXIS_JOINTS", "fit_o6_session"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/node.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/node.py deleted file mode 100644 index 95e9219..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/node.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Online three-view acquisition node for O6/right/o6_right_8/v1.""" - -from __future__ import annotations - -import rclpy - -from ..l6.node import L6ThreeCameraCalibrationNode, MotionStep -from .pipeline import finalize_o6_session -from .profile import build_typed_profile - - -class O6ThreeCameraCalibrationNode(L6ThreeCameraCalibrationNode): - """O6 specialization of the shared six-channel acquisition node.""" - - def __init__(self) -> None: - super().__init__( - profile=build_typed_profile(), - finalizer=finalize_o6_session, - sample_kind="o6_joint_sample", - ) - - -def main(args: list[str] | None = None) -> None: - rclpy.init(args=args) - node: O6ThreeCameraCalibrationNode | None = None - try: - node = O6ThreeCameraCalibrationNode() - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - - -__all__ = ["MotionStep", "O6ThreeCameraCalibrationNode", "main"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/pipeline.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/pipeline.py deleted file mode 100644 index 2210b65..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/pipeline.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Shared online/offline finalization path for O6 right.""" - -from __future__ import annotations - -from datetime import datetime -import json -import math -from pathlib import Path -from typing import Any, Mapping, Sequence - -from ...runtime.engine import CalibrationEngine - -from .artifacts import ( - artifact_hashes, - atomic_write_json, - build_o6_runtime_payload, - build_o6_urdf_input_payload, - load_o6_urdf_input, - publish_partial_session, -) -from .fitting import O6FitResult, fit_o6_session -from .profile import ( - CALIBRATED_ACTIVE_JOINTS, - CORRECTED_PASSIVE_JOINTS, - KEY, - MEASURED_PASSIVE_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, - build_typed_profile, -) -from .urdf import O6UrdfCorrection, write_o6_corrected_urdf - - -MEASURED_JOINTS = CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS - - -def _canonical_feedback_u8(value: float) -> int: - command = int(round(float(value))) - if command <= 2: - return 0 - if command >= 253: - return 255 - return command - - -def accepted_records_by_joint( - records: Sequence[Mapping[str, Any]], -) -> dict[str, list[dict[str, Any]]]: - samples = [ - dict(row) for row in records - if row.get("kind") == "o6_joint_sample" - and str(row.get("joint", "")) in MEASURED_JOINTS - ] - latest: dict[tuple[str, int, str], int] = {} - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1))) - result = {name: [] for name in MEASURED_JOINTS} - geometric = ( - "relative_translation_xyz_m", "parent_pose_common", "child_pose_common", - "view_normal_common_xyz", "camera_center_common_xyz_m", "state_u8", - ) - for row in samples: - key = (str(row["task_name"]), int(row["cycle"]), str(row["direction"])) - if int(row.get("attempt", 1)) != latest[key]: - continue - accepted: dict[str, Any] = { - "cycle": int(row["cycle"]), - "direction": str(row["direction"]), - "command_u8": _canonical_feedback_u8(float(row["feedback_u8"])), - "feedback_u8": float(row["feedback_u8"]), - "relative_quaternion_xyzw": list(row["relative_quaternion_xyzw"]), - } - if any(field in row for field in geometric): - missing = [field for field in geometric if field not in row] - if missing: - raise ValueError("O6 geometric sample is incomplete: " + ",".join(missing)) - for field in geometric: - accepted[field] = dict(row[field]) if isinstance(row[field], Mapping) else list(row[field]) - result[str(row["joint"])].append(accepted) - return result - - -def load_o6_raw_samples(path: str | Path) -> list[dict[str, Any]]: - source = Path(path).expanduser().resolve() - if not source.is_file(): - raise ValueError(f"raw O6 sample file does not exist: {source}") - rows = [] - for number, line in enumerate(source.read_text(encoding="utf-8").splitlines(), 1): - if not line.strip(): - continue - try: - value = json.loads(line) - except json.JSONDecodeError as error: - raise ValueError(f"invalid O6 JSONL record at line {number}") from error - if not isinstance(value, Mapping): - raise ValueError(f"O6 JSONL line {number} is not an object") - rows.append(dict(value)) - return rows - - -def finalize_o6_session( - *, - session_dir: str | Path, - serial_number: str, - source_urdf: str | Path, - protected_inputs: Mapping[str, str], - records: Sequence[Mapping[str, Any]], - publish: bool = True, - timestamp: str | None = None, -) -> tuple[dict[str, Any], O6FitResult, O6UrdfCorrection]: - directory = Path(session_dir).expanduser().resolve() - directory.mkdir(parents=True, exist_ok=True) - result = fit_o6_session( - source_urdf, accepted_records_by_joint(records), require_thumb_axis_zero=True - ) - CalibrationEngine(build_typed_profile()).result_from_fit( - result, - transfers={ - **TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - **TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, - }, - ) - payload = build_o6_runtime_payload( - serial_number=serial_number, - source_urdf=source_urdf, - result=result, - protected_inputs=protected_inputs, - passed=True, - ) - correction_input = directory / f"o6_right_{serial_number}_urdf_correction_input.json" - atomic_write_json(correction_input, build_o6_urdf_input_payload( - serial_number=serial_number, source_urdf=source_urdf, result=result - )) - authenticated = load_o6_urdf_input( - correction_input, source_urdf=source_urdf, serial_number=serial_number - ) - correction = write_o6_corrected_urdf( - source_urdf=source_urdf, - output_directory=directory, - serial_number=serial_number, - result=authenticated, - timestamp=timestamp or datetime.now().strftime("%Y%m%d_%H%M%S"), - ) - json_path = directory / f"o6_right_{serial_number}_partial_calibration.json" - atomic_write_json(json_path, payload) - summary = { - "schema_version": 1, - "profile_id": KEY.profile_id, - "serial_number": str(serial_number), - "result": "PARTIAL_PASS", - "publication_pointer": "latest_partial_passed", - "calibrated_active_joints": sorted(CALIBRATED_ACTIVE_JOINTS), - "measured_passive_joints": sorted(MEASURED_PASSIVE_JOINTS), - "transferred_active_joints": dict(sorted(TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items())), - "transferred_passive_joints": dict(sorted(TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items())), - "passive_coupling": { - name: { - "model": result.mimic_fits[donor].model, - "coefficients": [ - round(float(value), 10) - for value in result.mimic_fits[donor].coefficients - ], - "urdf_mimic_enabled": True, - "urdf_mimic_policy": result.mimic_fits[donor].urdf_mimic_policy, - "mimic_multiplier": round( - float(result.mimic_fits[donor].urdf_mimic_multiplier), 10 - ), - "residual_p95_deg": round(math.degrees(float(result.mimic_fits[donor].residual_p95_rad)), 6), - "residual_max_deg": round(math.degrees(float(result.mimic_fits[donor].residual_max_rad)), 6), - } - for name, donor in sorted({ - target: TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(target, target) - for target in CORRECTED_PASSIVE_JOINTS - }.items()) - }, - "explicit_runtime_joints": sorted(correction.explicit_runtime_joints), - "artifacts": artifact_hashes(json_path, correction.path), - "quality": {"passed": True}, - } - atomic_write_json(directory / "calibration_summary.json", summary) - if publish: - publish_partial_session(directory.parent, directory) - return payload, result, correction - - -__all__ = [ - "accepted_records_by_joint", "finalize_o6_session", "load_o6_raw_samples" -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/profile.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/profile.py deleted file mode 100644 index 84112c2..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/profile.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Reviewed partial-calibration profile for the right O6 eight-Tag rig.""" - -from __future__ import annotations - -from ...core import ( - ArtifactPolicy, - CalibrationProfile, - CommandLayout, - MeasurementPolicy, - MeasurementSpec, - MotionPolicy, - ProfileKey, - QualityPolicy, - ScopePolicy, - TagSpec, - TaskSpec, - ViewSpec, - VisionRigSpec, - ZeroSolvePolicy, -) -from ..l6.motion import ( - build_calibration_motion_command, - build_calibration_preparation_waypoints, - build_calibration_return_waypoints, -) -from ..registry import EngineBindings, RegisteredProfile - - -KEY = ProfileKey("O6", "right", "o6_right_8", 1) - -BASELINE_SPEED_U8 = 80 -PREFLIGHT_SPEED_U8 = 60 -FORMAL_SPEED_U8 = 40 -MAXIMUM_HYSTERESIS_DEG = 3.5 -COUPLING_RESIDUAL_P95_DEG = 2.2 -COUPLING_RESIDUAL_MAX_DEG = 3.0 -MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX = 1.5 -MAXIMUM_CROSS_VIEW_AXIS_LINE_RMS_M = 0.020 - -COMMAND_NAMES: tuple[str, ...] = ( - "thumb_cmc_pitch", - "thumb_cmc_yaw", - "index_mcp_pitch", - "middle_mcp_pitch", - "ring_mcp_pitch", - "pinky_mcp_pitch", -) - -ACTIVE_JOINTS: tuple[str, ...] = ( - "rh_thumb_cmc_pitch", - "rh_thumb_cmc_yaw", - "rh_index_mcp_pitch", - "rh_middle_mcp_pitch", - "rh_ring_mcp_pitch", - "rh_pinky_mcp_pitch", -) -PASSIVE_JOINTS: tuple[str, ...] = ( - "rh_thumb_ip", - "rh_index_dip", - "rh_middle_dip", - "rh_ring_dip", - "rh_pinky_dip", -) -CALIBRATED_ACTIVE_JOINTS = frozenset( - {"rh_thumb_cmc_pitch", "rh_thumb_cmc_yaw", "rh_pinky_mcp_pitch"} -) -# Field validation on O6_RIGHT_001 showed that the yaw zero is reliably -# observable from cross-view axis directions, while the pitch phase depends -# on two monocular axis-line centres and can acquire a repeatable systematic -# bias. Keep the mechanically established CAD lower endpoint for pitch. -GEOMETRIC_ZERO_JOINTS = frozenset({"rh_thumb_cmc_yaw"}) -CAD_ENDPOINT_ZERO_JOINTS = frozenset( - {"rh_thumb_cmc_pitch", "rh_pinky_mcp_pitch"} -) -MEASURED_PASSIVE_JOINTS = frozenset({"rh_thumb_ip", "rh_pinky_dip"}) - -TRANSFERRED_ACTIVE_SOURCE_BY_JOINT = { - "rh_index_mcp_pitch": "rh_pinky_mcp_pitch", - "rh_middle_mcp_pitch": "rh_pinky_mcp_pitch", - "rh_ring_mcp_pitch": "rh_pinky_mcp_pitch", -} -TRANSFERRED_PASSIVE_SOURCE_BY_JOINT = { - "rh_index_dip": "rh_pinky_dip", - "rh_middle_dip": "rh_pinky_dip", - "rh_ring_dip": "rh_pinky_dip", -} -CORRECTED_ACTIVE_JOINTS = frozenset( - CALIBRATED_ACTIVE_JOINTS | TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.keys() -) -CORRECTED_PASSIVE_JOINTS = frozenset( - MEASURED_PASSIVE_JOINTS | TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.keys() -) - -ENDPOINT_ANCHOR_BY_JOINT = { - "rh_thumb_cmc_pitch": "lower_at_start", - "rh_thumb_cmc_yaw": "lower_at_start", - "rh_pinky_mcp_pitch": "lower_at_start", -} - -COMMAND_INDEX_BY_JOINT = { - "rh_thumb_cmc_pitch": 0, - "rh_thumb_cmc_yaw": 1, - "rh_index_mcp_pitch": 2, - "rh_middle_mcp_pitch": 3, - "rh_ring_mcp_pitch": 4, - "rh_pinky_mcp_pitch": 5, -} - -MIMIC_SOURCE_BY_JOINT = { - "rh_thumb_ip": "rh_thumb_cmc_pitch", - "rh_index_dip": "rh_index_mcp_pitch", - "rh_middle_dip": "rh_middle_mcp_pitch", - "rh_ring_dip": "rh_ring_mcp_pitch", - "rh_pinky_dip": "rh_pinky_mcp_pitch", -} - -COUPLING_MODEL_BY_JOINT = { - name: "quadratic_runtime" for name in PASSIVE_JOINTS -} - - -def build_typed_profile() -> CalibrationProfile: - active = frozenset(ACTIVE_JOINTS) - passive = frozenset(PASSIVE_JOINTS) - measurements = { - "rh_thumb_cmc_yaw": MeasurementSpec( - "rh_thumb_cmc_yaw", "relative_rotation", "top", "top_base", "thumb_yaw" - ), - "rh_thumb_cmc_pitch": MeasurementSpec( - "rh_thumb_cmc_pitch", "relative_rotation", "front", "front_base", "thumb_pitch" - ), - "rh_thumb_ip": MeasurementSpec( - "rh_thumb_ip", "relative_rotation", "front", "thumb_pitch", "thumb_ip" - ), - "rh_pinky_mcp_pitch": MeasurementSpec( - "rh_pinky_mcp_pitch", "relative_rotation", "side", "side_base", "pinky_pitch" - ), - "rh_pinky_dip": MeasurementSpec( - "rh_pinky_dip", "relative_rotation", "side", "pinky_pitch", "pinky_dip" - ), - } - tasks = ( - TaskSpec( - "thumb_yaw_top", "top", 1, ("rh_thumb_cmc_yaw",), - preflight_speed_u8=PREFLIGHT_SPEED_U8, - formal_speed_u8=FORMAL_SPEED_U8, - ), - TaskSpec( - "thumb_pitch_ip_front", "front", 0, - ("rh_thumb_cmc_pitch", "rh_thumb_ip"), - preflight_speed_u8=PREFLIGHT_SPEED_U8, - formal_speed_u8=FORMAL_SPEED_U8, - ), - TaskSpec( - "pinky_pitch_dip_side", "side", 5, - ("rh_pinky_mcp_pitch", "rh_pinky_dip"), - preflight_speed_u8=PREFLIGHT_SPEED_U8, - formal_speed_u8=FORMAL_SPEED_U8, - ), - ) - coverage = { - **{ - name: ( - "measured_static_dynamic" - if name in CALIBRATED_ACTIVE_JOINTS - else "transferred_static_dynamic" - ) - for name in active - }, - **{ - name: ( - "measured_dynamic_cad_static" - if name in MEASURED_PASSIVE_JOINTS - else "transferred_dynamic_cad_static" - ) - for name in passive - }, - } - return CalibrationProfile( - key=KEY, - namespace="/o6_calibration", - command=CommandLayout( - names=COMMAND_NAMES, - baseline_u8=(255,) * 6, - command_index_by_joint=COMMAND_INDEX_BY_JOINT, - urdf_joint_by_joint={name: name for name in ACTIVE_JOINTS}, - speed_slot_by_command_index={index: index for index in range(6)}, - ), - vision=VisionRigSpec( - views=( - ViewSpec("front", ( - TagSpec("front_base", 0, fixed_reference=True), - TagSpec("thumb_pitch", 1), - TagSpec("thumb_ip", 2), - )), - ViewSpec("side", ( - TagSpec("side_base", 3, fixed_reference=True), - TagSpec("pinky_pitch", 4), - TagSpec("pinky_dip", 5), - )), - ViewSpec("top", ( - TagSpec("top_base", 6, fixed_reference=True), - TagSpec("thumb_yaw", 7), - )), - ), - common_frame="calibration_common", - extrinsic_reference_view="front", - extrinsics_quality_limits={ - "reprojection_rms_px": MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX, - "maximum_rotation_repeatability_deg": 0.3, - "maximum_translation_repeatability_m": 0.0015, - }, - minimum_capture_counts={ - "front_side_captures": 15, - "front_top_captures": 15, - }, - ), - motion=MotionPolicy( - tasks=tasks, - precheck_sweeps=False, - steady_command_checkpoints=False, - speed_parameters={ - "baseline_u8": BASELINE_SPEED_U8, - "preflight_u8": PREFLIGHT_SPEED_U8, - "formal_u8": FORMAL_SPEED_U8, - "speed_settle_seconds": 0.2, - "command_trajectory_full_range_seconds": 6.0, - "torque_u8": 80, - "endpoint_hold_seconds": 1.0, - "stall_timeout_seconds": 2.0, - }, - ), - measurement=MeasurementPolicy( - measurements=measurements, - directional_zero=True, - ), - zero=ZeroSolvePolicy( - active_joints=active, - passive_joints=passive, - direct_zero_joints=tuple(sorted(CALIBRATED_ACTIVE_JOINTS)), - axis_joints=tuple(sorted(CALIBRATED_ACTIVE_JOINTS | MEASURED_PASSIVE_JOINTS)), - mechanical_endpoint_joints=frozenset(ENDPOINT_ANCHOR_BY_JOINT), - post_solve_endpoint_joints=frozenset(), - mimic_source_by_joint=MIMIC_SOURCE_BY_JOINT, - cad_frozen_joints=passive, - endpoint_anchor_by_joint=ENDPOINT_ANCHOR_BY_JOINT, - fitted_mimic_joints=MEASURED_PASSIVE_JOINTS, - coupling_model_by_joint=COUPLING_MODEL_BY_JOINT, - ), - quality=QualityPolicy( - training_cycles=(0, 1, 2), - holdout_cycle=3, - hard_threshold_keys=frozenset({ - "minimum_detection_rate", - "maximum_state_image_skew_ms", - "maximum_validation_error_rad", - "maximum_mimic_residual_rad", - }), - isolated_holdout=True, - ), - scope=ScopePolicy( - calibrate_joints={"partial": CALIBRATED_ACTIVE_JOINTS}, - frozen_joints={"partial": active - CALIBRATED_ACTIVE_JOINTS}, - default_scope="partial", - ), - artifacts=ArtifactPolicy( - output_schema_version=6, - calibration_filename="o6_right_{serial_number}_partial_calibration.json", - corrected_urdf_filename="linkerhand_o6_right_{serial_number}_partial_zero_calibrated.urdf", - protected_input_fields=frozenset({ - "source_urdf_sha256", - "camera_extrinsics_sha256", - "calibration_config_sha256", - "tag_config_sha256", - }), - publication_pointer="latest_partial_passed", - session_compatibility_tokens=frozenset({ - "o6_partial_v1", "feedback_curves_v6" - }), - publish_corrected_urdf=True, - ), - joint_coverage=coverage, - ) - - -def _run_cli(args: list[str] | None = None) -> None: - from .runner import main - - main(args) - - -def _run_node(args: list[str] | None = None) -> None: - from .node import main - - main(args) - - -def build_profile() -> RegisteredProfile: - typed = build_typed_profile() - return RegisteredProfile( - profile=typed, - engine=EngineBindings( - hand_profile=typed, - zero_profile=typed.zero, - motion_command=build_calibration_motion_command, - preparation_waypoints=build_calibration_preparation_waypoints, - return_waypoints=build_calibration_return_waypoints, - cli_main=_run_cli, - node_main=_run_node, - ), - ) - - -__all__ = [ - "ACTIVE_JOINTS", "CALIBRATED_ACTIVE_JOINTS", "COMMAND_NAMES", - "CORRECTED_ACTIVE_JOINTS", "CORRECTED_PASSIVE_JOINTS", - "COUPLING_MODEL_BY_JOINT", "ENDPOINT_ANCHOR_BY_JOINT", "KEY", - "MEASURED_PASSIVE_JOINTS", "MIMIC_SOURCE_BY_JOINT", "PASSIVE_JOINTS", - "TRANSFERRED_ACTIVE_SOURCE_BY_JOINT", - "TRANSFERRED_PASSIVE_SOURCE_BY_JOINT", "build_profile", "build_typed_profile", -] diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/o6/runner.py b/src/linkerhand_calibration/linkerhand_calibration/models/o6/runner.py deleted file mode 100644 index 8c96a6d..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/models/o6/runner.py +++ /dev/null @@ -1,225 +0,0 @@ -"""One-command online runner and deterministic offline replay for O6 right.""" - -from __future__ import annotations - -import argparse -from datetime import datetime -import json -import os -from pathlib import Path -import subprocess -import time -from typing import Any - -import rclpy -from rclpy.node import Node -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from ...product import ProductConfig, load_product_config -from ..l6.runner import ( - _ProgressConsole, - _l6_reason_zh, - _launch_command, - _stop_stack, - _wait_until, - render_six_channel_progress_zh, -) -from ...operator_report import ProgressEstimator -from .pipeline import finalize_o6_session, load_o6_raw_samples -from .profile import BASELINE_SPEED_U8 - - -_TASK_LABELS = { - "thumb_yaw_top": "拇指 CMC yaw(上面机位,ID6→ID7)", - "thumb_pitch_ip_front": "拇指 CMC pitch / IP(正面机位,ID0→ID1→ID2)", - "pinky_pitch_dip_side": "小指 MCP pitch / DIP(侧面机位,ID3→ID4→ID5)", -} - - -def render_o6_progress_zh( - status: dict[str, Any], - estimator: ProgressEstimator | None = None, -) -> str: - return render_six_channel_progress_zh( - status, - task_labels=_TASK_LABELS, - reason_renderer=lambda value: _l6_reason_zh( - value, model_name="O6" - ), - estimator=estimator, - ) - - -class _Monitor(Node): - def __init__(self) -> None: - super().__init__("o6_calibration_runner") - self.status: dict[str, Any] = {} - self.progress = _ProgressConsole(render_o6_progress_zh) - self.create_subscription(String, "/o6_calibration/status", self._status, 10) - self.start_client = self.create_client(Trigger, "/o6_calibration/start") - self.abort_client = self.create_client(Trigger, "/o6_calibration/abort") - - def _status(self, message: String) -> None: - try: - value = json.loads(message.data) - except json.JSONDecodeError: - return - if isinstance(value, dict): - self.status = value - self.progress.update(value) - - -def _run_online( - config: ProductConfig, *, record_bag: bool, commands_enabled: bool -) -> int: - session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S") - while session.exists(): - time.sleep(1.0) - session = config.session_root / datetime.now().strftime("%Y%m%d_%H%M%S") - session.mkdir(parents=True) - log_path = session / "calibration.log" - log_stream = log_path.open("a", encoding="utf-8", buffering=1) - print( - f"O6 标定环境正在启动(相机、SDK、标定节点);日志:{log_path}", - flush=True, - ) - process = subprocess.Popen( - _launch_command( - config, - session, - record_bag=record_bag, - commands_enabled=commands_enabled, - sdk_startup_speed_u8=BASELINE_SPEED_U8, - ), - cwd=config.workspace, - stdout=log_stream, - stderr=subprocess.STDOUT, - text=True, - start_new_session=True, - ) - rclpy.init() - monitor = _Monitor() - try: - ready = _wait_until( - monitor, process, - lambda status: status.get("state") in {"READY", "PAUSED", "ABORTED"}, - timeout=120.0, - ) - if not ready or monitor.status.get("state") != "READY": - detail = "" - try: - lines = log_path.read_text( - encoding="utf-8", errors="replace" - ).splitlines() - significant = [ - line for line in lines - if "[ERROR]" in line - or "Traceback" in line - or "ValueError:" in line - or "RuntimeError:" in line - ] - if significant: - detail = "\n启动日志摘要:\n" + "\n".join(significant[-6:]) - except OSError: - pass - print( - "O6 启动失败;请检查六通道反馈、三相机内参和状态信息。" - + detail - + f"\n完整日志:{log_path}", - flush=True, - ) - return 2 - if not monitor.start_client.wait_for_service(timeout_sec=10.0): - print("O6 标定 /start 服务不可用。", flush=True) - return 2 - future = monitor.start_client.call_async(Trigger.Request()) - while rclpy.ok() and not future.done(): - rclpy.spin_once(monitor, timeout_sec=0.2) - response = future.result() - if response is None or not response.success: - print(f"O6 标定未启动:{getattr(response, 'message', '')}", flush=True) - return 2 - finished = _wait_until( - monitor, process, - lambda status: status.get("state") in {"PASSED", "PAUSED", "ABORTED"}, - timeout=None, - ) - if not finished or monitor.status.get("state") != "PASSED": - print("O6 标定失败:" + str(monitor.status.get("reason", "process_exit")), flush=True) - return 3 - print( - "\n".join([ - "PASS:O6 右手三主动关节与两条被动关节实测通过;" - "小指结果已迁移到食指、中指和无名指。", - f"部分结果:{config.session_root / 'latest_partial_passed'}", - f"JSON:{monitor.status.get('final_json')}", - f"URDF:{monitor.status.get('final_urdf')}", - ]), - flush=True, - ) - return 0 - except KeyboardInterrupt: - if monitor.abort_client.wait_for_service(timeout_sec=2.0): - monitor.abort_client.call_async(Trigger.Request()) - rclpy.spin_once(monitor, timeout_sec=1.0) - return 130 - finally: - monitor.destroy_node() - if rclpy.ok(): - rclpy.shutdown() - _stop_stack(process) - log_stream.flush() - os.fsync(log_stream.fileno()) - log_stream.close() - - -def main(args: list[str] | None = None) -> None: - parser = argparse.ArgumentParser(description="O6 right partial calibration") - parser.add_argument("--config", required=True) - parser.add_argument("--workspace", default=None) - parser.add_argument("--record-bag", action="store_true") - parser.add_argument("--commands-disabled", action="store_true") - parser.add_argument("--validate-only", action="store_true") - parser.add_argument("--offline-raw", default="") - parser.add_argument("--offline-output", default="") - parser.add_argument("--publish-offline", action="store_true") - selected = parser.parse_args(args) - config = load_product_config( - selected.config, - workspace=selected.workspace, - check_can=not bool(selected.validate_only or selected.offline_raw), - ) - if selected.validate_only: - print(f"配置有效:{config.profile_key.profile_id},源URDF {config.source_urdf_sha256}") - return - if selected.offline_raw: - output = ( - Path(selected.offline_output).expanduser().resolve() - if selected.offline_output else config.session_root / - (datetime.now().strftime("%Y%m%d_%H%M%S") + "_offline") - ) - output.mkdir(parents=True, exist_ok=False) - payload, _fit, correction = finalize_o6_session( - session_dir=output, - serial_number=config.serial_number, - source_urdf=config.source_urdf, - protected_inputs={ - "source_urdf_sha256": config.source_urdf_sha256, - "camera_extrinsics_sha256": config.camera_extrinsics_sha256, - "calibration_config_sha256": config.calibration_config_sha256, - "tag_config_sha256": config.tag_config_sha256, - }, - records=load_o6_raw_samples(selected.offline_raw), - publish=selected.publish_offline, - ) - print(f"离线回放PASS:schema {payload['schema_version']},URDF {correction.path}") - return - raise SystemExit(_run_online( - config, - record_bag=selected.record_bag, - commands_enabled=not selected.commands_disabled, - )) - - -__all__ = ["main", "render_o6_progress_zh"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/node.py b/src/linkerhand_calibration/linkerhand_calibration/node.py deleted file mode 100644 index 07570a3..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/node.py +++ /dev/null @@ -1,2891 +0,0 @@ -"""ROS 2 hardware calibration node for a front-facing industrial camera.""" - -from __future__ import annotations - -from collections import deque -from dataclasses import replace -from datetime import datetime, timezone -import hashlib -import json -import math -from pathlib import Path -import random -import re -import time -from typing import Any - -import cv2 -from cv_bridge import CvBridge -import numpy as np -import rclpy -from apriltag_msgs.msg import AprilTagDetectionArray -from rclpy.node import Node -from rclpy.qos import qos_profile_sensor_data -from sensor_msgs.msg import CameraInfo, Image, JointState -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from .acquisition import ( - ContinuousSweepCollector, - Observation, - PointCollector, - StateSample, - TAG_PAIR_ROLES, - TagQuality, - aggregate_sweep_observations, - interpolate_state_u8, - tag_quality_is_valid, -) -from .compat.legacy.thumb_core import ( - BASELINE_COMMAND, - COMMAND_NAMES, - DIRECTION_DECREASING, - DIRECTION_INCREASING, - PAIR_IP, - PAIR_MCP, - PAIR_NAMES, - PAIR_ROOT, - PHASE_ROOT, - PHASE_TIP, - FitResult, - build_command, - create_final_payload, - fit_calibration_curves, - image_plane_tag_quaternion_xyzw, - maximum_non_target_drift_rad, - relative_quaternion_xyzw, - rotation_inlier_fraction, - rotation_rms_rad, - scan_targets, -) -from .diagnostics import ( - STATE_NAMES_ZH, - build_tag_quality_diagnostics, - pnp_rejection_zh, - render_status_text_zh, - status_guidance_zh, -) -from .pnp import ( - SquareTagGroupPoseTracker, - SquareTagPose, - SquareTagPoseTracker, - rotation_distance_rad, - select_rigid_group_trajectory, -) -from .storage import ( - append_jsonl, - atomic_write_json, - completed_scan_keys, - load_jsonl, -) -from .trajectory import ( - fit_center_trajectory_curves, - maximum_center_non_target_drift_rad, - measure_center_trajectory_phase_angles, -) - - -STATE_PREFLIGHT = "PREFLIGHT" -STATE_WAIT_ROOT = "WAIT_ROOT_CONFIRM" -STATE_SCAN_ROOT = "SCAN_ROOT" -STATE_WAIT_TIP = "WAIT_TIP_CONFIRM" -STATE_SCAN_TIP = "SCAN_TIP" -STATE_VALIDATING = "VALIDATING" -STATE_PAUSED = "PAUSED" -STATE_ABORTED = "ABORTED" -STATE_COMPLETE = "COMPLETE" - - -def _stamp_ns(stamp: Any) -> int: - return int(stamp.sec) * 1_000_000_000 + int(stamp.nanosec) - - -def _safe_name(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()) - return safe or "UNSET" - - -class G20ThumbCalibrationNode(Node): - """Drive isolated G20 thumb sweeps and aggregate AprilTag rotations.""" - - def __init__(self) -> None: - super().__init__("g20_thumb_calibration") - self._declare_parameters() - self._load_parameters() - - self.session_dir.mkdir(parents=True, exist_ok=True) - self.raw_path = self.session_dir / "raw_samples.jsonl" - self.checkpoint_path = self.session_dir / "checkpoint.json" - self.validation_path = self.session_dir / "validation.json" - self.manifest_path = self.session_dir / "session_manifest.json" - self.final_path = self.session_dir / ( - f"g20_left_{_safe_name(self.serial_number)}_thumb_angle.json" - ) - - self._validate_existing_session() - self.records = load_jsonl(self.raw_path) - self.validation_records: list[dict[str, Any]] = [] - self.state = STATE_PREFLIGHT - self.state_reason = "waiting_for_camera_tags_and_sdk" - self.paused_from: str | None = None - self.current_phase: str | None = None - self.task_queue: deque[dict[str, Any]] = deque() - self.active_task: dict[str, Any] | None = None - self.pending_aggregate: dict[str, Any] | None = None - self.pending_sweep_observations: list[Observation] | None = None - self.fit: FitResult | None = None - self.validation_zero: dict[str, dict[str, Any]] = {} - self.validation_errors: list[float] = [] - - self.latest_state_u8: tuple[float, ...] = () - self.state_history: deque[StateSample] = deque(maxlen=600) - self.latest_hand_info: dict[str, Any] = {} - self.camera_intrinsics: dict[str, Any] = {} - self.pnp_camera_matrix: np.ndarray | None = None - self.camera_info_valid = False - self.camera_frame = "" - self.preflight_detection_flags: deque[bool] = deque( - maxlen=self.preflight_frames - ) - self.preflight_detection_times: deque[float] = deque( - maxlen=self.preflight_frames - ) - self.preflight_pnp_flags: deque[bool] = deque( - maxlen=self.preflight_frames - ) - self.preflight_observations: deque[Observation] = deque( - maxlen=self.preflight_frames - ) - - self.quality_cache: dict[int, dict[str, TagQuality]] = {} - self.corner_cache: dict[int, dict[str, np.ndarray]] = {} - self.transform_cache: dict[int, dict[str, tuple[float, ...]]] = {} - self.translation_cache: dict[ - int, dict[str, tuple[float, float, float]] - ] = {} - self.pnp_candidate_cache: dict[ - int, dict[str, tuple[SquareTagPose, ...]] - ] = {} - self.emitted_stamps: set[int] = set() - self.latest_corners: dict[str, np.ndarray] = {} - self.latest_tag_qualities: dict[str, TagQuality] = {} - self.latest_pnp_rejections: dict[str, str] = {} - self.latest_pnp_errors_px: dict[str, float] = {} - self.latest_trajectory_branch_quality: dict[str, Any] = {} - self.trajectory_branch_reports: list[dict[str, Any]] = [] - self.pnp_tracker = SquareTagPoseTracker( - maximum_reprojection_error_px=( - self.pnp_maximum_reprojection_error_px - ), - reprojection_tie_px=self.pnp_reprojection_tie_px, - maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, - maximum_translation_jump_m=( - self.pnp_maximum_translation_jump_m - ), - maximum_tag_tilt_rad=self.pnp_maximum_tag_tilt_rad, - reset_after_seconds=self.pnp_tracker_reset_seconds, - ) - self.pnp_group_tracker = SquareTagGroupPoseTracker( - roles=("t0", "t3", "t4", "t5"), - adjacent_pairs=( - ("t0", "t3"), - ("t3", "t4"), - ("t4", "t5"), - ), - maximum_pose_jump_rad=self.pnp_maximum_pose_jump_rad, - maximum_translation_jump_m=( - self.pnp_maximum_translation_jump_m - ), - relative_rotation_scale_rad=( - self.pnp_group_relative_rotation_scale_rad - ), - relative_translation_scale_m=( - self.pnp_group_relative_translation_scale_m - ), - reprojection_scale_px=( - self.pnp_trajectory_reprojection_scale_px - ), - reprojection_weight=self.pnp_group_reprojection_weight, - reset_after_seconds=self.pnp_tracker_reset_seconds, - ) - - self.collector = PointCollector( - stable_frames=self.stable_frames, - capture_frames=self.capture_frames, - minimum_settle_seconds=self.minimum_settle_seconds, - maximum_stable_spread_rad=self.maximum_stable_spread_rad, - stability_mode=( - "translation" - if self.angle_estimation_mode == "trajectory_center_3d" - else "rotation" - ), - maximum_stable_translation_spread_m=( - self.maximum_stable_translation_spread_m - ), - settle_timeout_seconds=self.settle_timeout_seconds, - capture_timeout_seconds=self.capture_timeout_seconds, - ) - self.sweep_collector = ContinuousSweepCollector( - endpoint_tolerance_u8=self.continuous_endpoint_tolerance_u8, - endpoint_hold_seconds=self.continuous_endpoint_hold_seconds, - timeout_seconds=self.continuous_timeout_seconds, - invalid_timeout_seconds=self.continuous_invalid_timeout_seconds, - minimum_valid_frames=self.continuous_minimum_valid_frames, - minimum_state_span_u8=self.continuous_minimum_state_span_u8, - ) - - self.command_publisher = self.create_publisher( - JointState, self.command_topic, 1 - ) - self.status_publisher = self.create_publisher( - String, "~/status", 10 - ) - self.status_text_publisher = self.create_publisher( - String, "~/status_text", 10 - ) - self.debug_publisher = None - self.last_debug_publish = 0.0 - if self.publish_debug_image: - self.debug_publisher = self.create_publisher( - Image, "~/debug_image", qos_profile_sensor_data - ) - self.bridge = CvBridge() - - self.create_subscription( - JointState, self.state_topic, self._state_callback, 10 - ) - self.create_subscription( - String, self.info_topic, self._info_callback, 10 - ) - self.create_subscription( - CameraInfo, self.camera_info_topic, self._camera_info_callback, 10 - ) - self.create_subscription( - AprilTagDetectionArray, - self.detections_topic, - self._detections_callback, - qos_profile_sensor_data, - ) - if self.publish_debug_image: - self.create_subscription( - Image, - self.image_topic, - self._image_callback, - qos_profile_sensor_data, - ) - - self.create_service( - Trigger, "~/start", self._start_callback - ) - self.create_service( - Trigger, "~/confirm_root_full_range", self._confirm_root_callback - ) - self.create_service( - Trigger, "~/confirm_tip_full_range", self._confirm_tip_callback - ) - self.create_service(Trigger, "~/pause", self._pause_callback) - self.create_service(Trigger, "~/resume", self._resume_callback) - self.create_service(Trigger, "~/abort", self._abort_callback) - - self.last_status_publish = 0.0 - self.timer = self.create_timer(0.05, self._timer_callback) - self._write_manifest() - self.get_logger().info( - f"Calibration session: {self.session_dir}; commands_enabled={self.commands_enabled}" - ) - - def _declare_parameters(self) -> None: - self.declare_parameter("serial_number", "UNSET") - self.declare_parameter("session_dir", "calibration_output/session") - self.declare_parameter("commands_enabled", True) - self.declare_parameter("calibration_speed", 15) - self.declare_parameter("command_topic", "/g20/cb_left_hand_control_cmd") - self.declare_parameter("state_topic", "/g20/cb_left_hand_state") - self.declare_parameter("info_topic", "/g20/cb_left_hand_info") - self.declare_parameter( - "camera_info_topic", "/camera/camera/color/camera_info" - ) - self.declare_parameter( - "image_topic", "/camera/camera/color/image_rect" - ) - self.declare_parameter("detections_topic", "/apriltag/detections") - self.declare_parameter("tf_topic", "/tf") - self.declare_parameter( - "angle_estimation_mode", "trajectory_center_3d" - ) - self.declare_parameter("camera_serial_number", "") - self.declare_parameter("rosbag_path", "") - self.declare_parameter("publish_debug_image", False) - self.declare_parameter("debug_max_rate_hz", 10.0) - self.declare_parameter("debug_scale", 0.5) - self.declare_parameter("tag_roles", ["t0", "t3", "t4", "t5"]) - self.declare_parameter("tag_ids", [0, 1, 2, 3]) - self.declare_parameter("tag_frames", ["tag_t0", "tag_t3", "tag_t4", "tag_t5"]) - self.declare_parameter("tag_sizes_m", [0.02, 0.02, 0.02, 0.02]) - self.declare_parameter("repetitions", 1) - self.declare_parameter("command_step", 8) - self.declare_parameter("scan_mode", "continuous") - self.declare_parameter("continuous_motion_mode", "endpoint") - self.declare_parameter("auto_start_tip", True) - self.declare_parameter("maximum_state_image_skew_ms", 150.0) - self.declare_parameter("continuous_endpoint_tolerance_u8", 2.0) - self.declare_parameter("continuous_endpoint_hold_seconds", 1.0) - self.declare_parameter("continuous_timeout_seconds", 90.0) - self.declare_parameter("continuous_invalid_timeout_seconds", 3.0) - self.declare_parameter("continuous_minimum_valid_frames", 40) - self.declare_parameter("continuous_minimum_state_span_u8", 240.0) - self.declare_parameter("continuous_minimum_bins", 32) - self.declare_parameter("continuous_maximum_bin_gap", 16) - self.declare_parameter("continuous_segment_minimum_seconds", 0.1) - self.declare_parameter("continuous_segment_timeout_seconds", 5.0) - self.declare_parameter("continuous_prepare_timeout_seconds", 30.0) - self.declare_parameter("preflight_frames", 150) - self.declare_parameter("minimum_detection_rate", 0.95) - self.declare_parameter("minimum_detection_hz", 15.0) - self.declare_parameter("maximum_hamming", 0) - self.declare_parameter("minimum_decision_margin", 30.0) - self.declare_parameter("minimum_edge_pixels", 40.0) - self.declare_parameter("maximum_static_std_deg", 0.5) - self.declare_parameter("pose_outlier_threshold_deg", 5.0) - self.declare_parameter("minimum_pose_inlier_rate", 0.95) - self.declare_parameter("pnp_minimum_valid_rate", 0.95) - self.declare_parameter("pnp_maximum_reprojection_error_px", 1.5) - self.declare_parameter("pnp_reprojection_tie_px", 1.5) - self.declare_parameter("pnp_maximum_pose_jump_deg", 35.0) - self.declare_parameter("pnp_maximum_translation_jump_m", 0.04) - self.declare_parameter("pnp_maximum_tag_tilt_deg", 75.0) - self.declare_parameter("pnp_tracker_reset_seconds", 5.0) - self.declare_parameter( - "pnp_group_relative_rotation_scale_deg", 5.0 - ) - self.declare_parameter( - "pnp_group_relative_translation_scale_m", 0.01 - ) - self.declare_parameter("pnp_group_reprojection_weight", 0.05) - self.declare_parameter( - "pnp_trajectory_reprojection_scale_px", 0.1 - ) - self.declare_parameter("pnp_rigid_rotation_scale_deg", 5.0) - self.declare_parameter("pnp_rigid_translation_scale_m", 0.01) - self.declare_parameter( - "pnp_rigid_p95_accepted_drift_deg", 8.0 - ) - self.declare_parameter( - "pnp_rigid_maximum_accepted_drift_deg", 15.0 - ) - self.declare_parameter( - "pnp_rigid_p95_accepted_distance_drift_m", 0.003 - ) - self.declare_parameter( - "pnp_rigid_maximum_accepted_distance_drift_m", 0.006 - ) - self.declare_parameter("trajectory_maximum_plane_rms_m", 0.004) - self.declare_parameter("trajectory_maximum_radial_rms_m", 0.004) - self.declare_parameter("trajectory_minimum_radius_m", 0.005) - self.declare_parameter("trajectory_minimum_arc_deg", 15.0) - self.declare_parameter( - "trajectory_maximum_root_role_disagreement_deg", 5.0 - ) - self.declare_parameter( - "trajectory_maximum_anchor_drift_m", 0.005 - ) - self.declare_parameter("passive_ip_multiplier", 1.02) - self.declare_parameter( - "trajectory_static_translation_outlier_m", 0.005 - ) - self.declare_parameter( - "trajectory_maximum_static_translation_rms_m", 0.002 - ) - self.declare_parameter("stable_frames", 5) - self.declare_parameter("capture_frames", 8) - self.declare_parameter("minimum_settle_seconds", 0.4) - self.declare_parameter("maximum_stable_spread_deg", 1.0) - self.declare_parameter( - "maximum_stable_translation_spread_m", 0.003 - ) - self.declare_parameter("settle_timeout_seconds", 5.0) - self.declare_parameter("capture_timeout_seconds", 5.0) - self.declare_parameter("validation_command_count", 5) - self.declare_parameter("validation_approach_minimum_seconds", 0.2) - self.declare_parameter("validation_approach_timeout_seconds", 10.0) - self.declare_parameter("validation_position_tolerance_u8", 2.0) - self.declare_parameter("validation_seed", 20260727) - self.declare_parameter("maximum_validation_mae_deg", 2.0) - self.declare_parameter("maximum_validation_p95_deg", 3.0) - self.declare_parameter("maximum_coupling_drift_deg", 2.0) - self.declare_parameter("minimum_ip_coupling_r_squared", 0.98) - self.declare_parameter("maximum_monotonic_correction_deg", 2.0) - self.declare_parameter("maximum_hysteresis_deg", 5.0) - - def _load_parameters(self) -> None: - value = lambda name: self.get_parameter(name).value - self.serial_number = str(value("serial_number")) - if self.serial_number == "UNSET": - raise ValueError("serial_number must be set for a hardware calibration") - self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() - self.commands_enabled = bool(value("commands_enabled")) - self.calibration_speed = int(value("calibration_speed")) - self.command_topic = str(value("command_topic")) - self.state_topic = str(value("state_topic")) - self.info_topic = str(value("info_topic")) - self.camera_info_topic = str(value("camera_info_topic")) - self.image_topic = str(value("image_topic")) - self.detections_topic = str(value("detections_topic")) - self.tf_topic = str(value("tf_topic")) - self.angle_estimation_mode = str(value("angle_estimation_mode")) - if self.angle_estimation_mode not in { - "image_plane_2d", - "pnp_3d", - "trajectory_center_3d", - }: - raise ValueError( - "angle_estimation_mode must be image_plane_2d, pnp_3d, " - "or trajectory_center_3d" - ) - self.uses_pnp = self.angle_estimation_mode in { - "pnp_3d", - "trajectory_center_3d", - } - self.camera_serial_number = str(value("camera_serial_number")) - self.rosbag_path = str(value("rosbag_path")) - self.publish_debug_image = bool(value("publish_debug_image")) - self.debug_max_rate_hz = float(value("debug_max_rate_hz")) - self.debug_scale = float(value("debug_scale")) - if self.debug_max_rate_hz <= 0.0: - raise ValueError("debug_max_rate_hz must be positive") - if not 0.1 <= self.debug_scale <= 1.0: - raise ValueError("debug_scale must be in [0.1, 1.0]") - - roles = [str(item) for item in value("tag_roles")] - ids = [int(item) for item in value("tag_ids")] - frames = [str(item).lstrip("/") for item in value("tag_frames")] - sizes = [float(item) for item in value("tag_sizes_m")] - if len(roles) != 4 or set(roles) != {"t0", "t3", "t4", "t5"}: - raise ValueError("tag_roles must contain t0, t3, t4, t5") - if not (len(ids) == len(frames) == len(sizes) == 4): - raise ValueError("tag_ids, tag_frames and tag_sizes_m must have length 4") - if len(set(ids)) != 4 or len(set(frames)) != 4: - raise ValueError("tag IDs and frames must be unique") - if any(size <= 0.0 for size in sizes): - raise ValueError("all tag sizes must be measured positive values") - self.role_by_id = dict(zip(ids, roles)) - self.role_by_frame = dict(zip(frames, roles)) - self.tag_config = { - role: {"id": tag_id, "frame": frame, "size_m": size} - for role, tag_id, frame, size in zip(roles, ids, frames, sizes) - } - - self.repetitions = int(value("repetitions")) - self.command_step = int(value("command_step")) - self.scan_mode = str(value("scan_mode")) - self.continuous_motion_mode = str(value("continuous_motion_mode")) - self.auto_start_tip = bool(value("auto_start_tip")) - self.maximum_state_image_skew_ns = int( - float(value("maximum_state_image_skew_ms")) * 1_000_000.0 - ) - self.continuous_endpoint_tolerance_u8 = float( - value("continuous_endpoint_tolerance_u8") - ) - self.continuous_endpoint_hold_seconds = float( - value("continuous_endpoint_hold_seconds") - ) - self.continuous_timeout_seconds = float( - value("continuous_timeout_seconds") - ) - self.continuous_invalid_timeout_seconds = float( - value("continuous_invalid_timeout_seconds") - ) - self.continuous_minimum_valid_frames = int( - value("continuous_minimum_valid_frames") - ) - self.continuous_minimum_state_span_u8 = float( - value("continuous_minimum_state_span_u8") - ) - self.continuous_minimum_bins = int(value("continuous_minimum_bins")) - self.continuous_maximum_bin_gap = int( - value("continuous_maximum_bin_gap") - ) - self.continuous_segment_minimum_seconds = float( - value("continuous_segment_minimum_seconds") - ) - self.continuous_segment_timeout_seconds = float( - value("continuous_segment_timeout_seconds") - ) - self.continuous_prepare_timeout_seconds = float( - value("continuous_prepare_timeout_seconds") - ) - self.preflight_frames = int(value("preflight_frames")) - self.minimum_detection_rate = float(value("minimum_detection_rate")) - self.minimum_detection_hz = float(value("minimum_detection_hz")) - self.maximum_hamming = int(value("maximum_hamming")) - self.minimum_decision_margin = float(value("minimum_decision_margin")) - self.minimum_edge_pixels = float(value("minimum_edge_pixels")) - self.maximum_static_std_rad = math.radians( - float(value("maximum_static_std_deg")) - ) - self.pose_outlier_threshold_rad = math.radians( - float(value("pose_outlier_threshold_deg")) - ) - self.minimum_pose_inlier_rate = float( - value("minimum_pose_inlier_rate") - ) - self.pnp_minimum_valid_rate = float(value("pnp_minimum_valid_rate")) - self.pnp_maximum_reprojection_error_px = float( - value("pnp_maximum_reprojection_error_px") - ) - self.pnp_reprojection_tie_px = float( - value("pnp_reprojection_tie_px") - ) - self.pnp_maximum_pose_jump_rad = math.radians( - float(value("pnp_maximum_pose_jump_deg")) - ) - self.pnp_maximum_translation_jump_m = float( - value("pnp_maximum_translation_jump_m") - ) - self.pnp_maximum_tag_tilt_rad = math.radians( - float(value("pnp_maximum_tag_tilt_deg")) - ) - self.pnp_tracker_reset_seconds = float( - value("pnp_tracker_reset_seconds") - ) - self.pnp_group_relative_rotation_scale_rad = math.radians( - float(value("pnp_group_relative_rotation_scale_deg")) - ) - self.pnp_group_relative_translation_scale_m = float( - value("pnp_group_relative_translation_scale_m") - ) - self.pnp_group_reprojection_weight = float( - value("pnp_group_reprojection_weight") - ) - self.pnp_trajectory_reprojection_scale_px = float( - value("pnp_trajectory_reprojection_scale_px") - ) - self.pnp_rigid_rotation_scale_rad = math.radians( - float(value("pnp_rigid_rotation_scale_deg")) - ) - self.pnp_rigid_translation_scale_m = float( - value("pnp_rigid_translation_scale_m") - ) - self.pnp_rigid_p95_accepted_drift_rad = math.radians( - float(value("pnp_rigid_p95_accepted_drift_deg")) - ) - self.pnp_rigid_maximum_accepted_drift_rad = math.radians( - float(value("pnp_rigid_maximum_accepted_drift_deg")) - ) - self.pnp_rigid_p95_accepted_distance_drift_m = float( - value("pnp_rigid_p95_accepted_distance_drift_m") - ) - self.pnp_rigid_maximum_accepted_distance_drift_m = float( - value("pnp_rigid_maximum_accepted_distance_drift_m") - ) - self.trajectory_maximum_plane_rms_m = float( - value("trajectory_maximum_plane_rms_m") - ) - self.trajectory_maximum_radial_rms_m = float( - value("trajectory_maximum_radial_rms_m") - ) - self.trajectory_minimum_radius_m = float( - value("trajectory_minimum_radius_m") - ) - self.trajectory_minimum_arc_rad = math.radians( - float(value("trajectory_minimum_arc_deg")) - ) - self.trajectory_maximum_root_role_disagreement_rad = math.radians( - float( - value( - "trajectory_maximum_root_role_disagreement_deg" - ) - ) - ) - self.trajectory_maximum_anchor_drift_m = float( - value("trajectory_maximum_anchor_drift_m") - ) - self.passive_ip_multiplier = float( - value("passive_ip_multiplier") - ) - self.trajectory_static_translation_outlier_m = float( - value("trajectory_static_translation_outlier_m") - ) - self.trajectory_maximum_static_translation_rms_m = float( - value("trajectory_maximum_static_translation_rms_m") - ) - self.stable_frames = int(value("stable_frames")) - self.capture_frames = int(value("capture_frames")) - self.minimum_settle_seconds = float(value("minimum_settle_seconds")) - self.maximum_stable_spread_rad = math.radians( - float(value("maximum_stable_spread_deg")) - ) - self.maximum_stable_translation_spread_m = float( - value("maximum_stable_translation_spread_m") - ) - self.settle_timeout_seconds = float(value("settle_timeout_seconds")) - self.capture_timeout_seconds = float(value("capture_timeout_seconds")) - self.validation_command_count = int(value("validation_command_count")) - self.validation_approach_minimum_seconds = float( - value("validation_approach_minimum_seconds") - ) - self.validation_approach_timeout_seconds = float( - value("validation_approach_timeout_seconds") - ) - self.validation_position_tolerance_u8 = float( - value("validation_position_tolerance_u8") - ) - self.validation_seed = int(value("validation_seed")) - self.maximum_validation_mae_rad = math.radians( - float(value("maximum_validation_mae_deg")) - ) - self.maximum_validation_p95_rad = math.radians( - float(value("maximum_validation_p95_deg")) - ) - self.maximum_coupling_drift_rad = math.radians( - float(value("maximum_coupling_drift_deg")) - ) - self.minimum_ip_coupling_r_squared = float( - value("minimum_ip_coupling_r_squared") - ) - self.maximum_monotonic_correction_rad = math.radians( - float(value("maximum_monotonic_correction_deg")) - ) - self.maximum_hysteresis_rad = math.radians( - float(value("maximum_hysteresis_deg")) - ) - if self.repetitions < 1: - raise ValueError("repetitions must be positive") - if not 0 <= self.calibration_speed <= 255: - raise ValueError("calibration_speed must be in [0, 255]") - if self.scan_mode not in {"continuous", "point"}: - raise ValueError("scan_mode must be continuous or point") - if self.continuous_motion_mode not in {"endpoint", "paced"}: - raise ValueError( - "continuous_motion_mode must be endpoint or paced" - ) - if not 1 <= self.command_step <= 255: - raise ValueError("command_step must be in [1, 255]") - if ( - not math.isfinite(self.passive_ip_multiplier) - or not 0.5 <= self.passive_ip_multiplier <= 1.5 - ): - raise ValueError( - "passive_ip_multiplier must be finite and in [0.5, 1.5]" - ) - if self.maximum_state_image_skew_ns < 0: - raise ValueError("maximum_state_image_skew_ms must be non-negative") - if self.continuous_minimum_bins < 3: - raise ValueError("continuous_minimum_bins must be at least 3") - if self.continuous_maximum_bin_gap < 1: - raise ValueError("continuous_maximum_bin_gap must be positive") - if self.continuous_segment_minimum_seconds < 0.0: - raise ValueError( - "continuous_segment_minimum_seconds must be non-negative" - ) - if self.continuous_segment_timeout_seconds <= 0.0: - raise ValueError( - "continuous_segment_timeout_seconds must be positive" - ) - if self.continuous_prepare_timeout_seconds <= 0.0: - raise ValueError( - "continuous_prepare_timeout_seconds must be positive" - ) - if self.preflight_frames < 3: - raise ValueError("preflight_frames must be at least 3") - if not 0.0 <= self.minimum_detection_rate <= 1.0: - raise ValueError("minimum_detection_rate must be in [0, 1]") - if self.minimum_detection_hz <= 0.0: - raise ValueError("minimum_detection_hz must be positive") - if self.pose_outlier_threshold_rad <= 0.0: - raise ValueError("pose_outlier_threshold_deg must be positive") - if not 0.0 <= self.minimum_pose_inlier_rate <= 1.0: - raise ValueError("minimum_pose_inlier_rate must be in [0, 1]") - if not 0.0 <= self.pnp_minimum_valid_rate <= 1.0: - raise ValueError("pnp_minimum_valid_rate must be in [0, 1]") - if self.pnp_maximum_reprojection_error_px <= 0.0: - raise ValueError( - "pnp_maximum_reprojection_error_px must be positive" - ) - if self.pnp_reprojection_tie_px < 0.0: - raise ValueError("pnp_reprojection_tie_px must be non-negative") - if self.pnp_maximum_pose_jump_rad <= 0.0: - raise ValueError("pnp_maximum_pose_jump_deg must be positive") - if self.pnp_maximum_translation_jump_m <= 0.0: - raise ValueError( - "pnp_maximum_translation_jump_m must be positive" - ) - if not 0.0 < self.pnp_maximum_tag_tilt_rad < math.pi / 2.0: - raise ValueError("pnp_maximum_tag_tilt_deg must be in (0, 90)") - if self.pnp_tracker_reset_seconds <= 0.0: - raise ValueError("pnp_tracker_reset_seconds must be positive") - if self.pnp_group_relative_rotation_scale_rad <= 0.0: - raise ValueError( - "pnp_group_relative_rotation_scale_deg must be positive" - ) - if self.pnp_group_relative_translation_scale_m <= 0.0: - raise ValueError( - "pnp_group_relative_translation_scale_m must be positive" - ) - if self.pnp_group_reprojection_weight < 0.0: - raise ValueError( - "pnp_group_reprojection_weight must be non-negative" - ) - if self.pnp_trajectory_reprojection_scale_px <= 0.0: - raise ValueError( - "pnp_trajectory_reprojection_scale_px must be positive" - ) - if self.pnp_rigid_rotation_scale_rad <= 0.0: - raise ValueError( - "pnp_rigid_rotation_scale_deg must be positive" - ) - if self.pnp_rigid_translation_scale_m <= 0.0: - raise ValueError( - "pnp_rigid_translation_scale_m must be positive" - ) - if self.pnp_rigid_p95_accepted_drift_rad <= 0.0: - raise ValueError( - "pnp_rigid_p95_accepted_drift_deg must be positive" - ) - if self.pnp_rigid_maximum_accepted_drift_rad <= 0.0: - raise ValueError( - "pnp_rigid_maximum_accepted_drift_deg must be positive" - ) - if ( - self.pnp_rigid_p95_accepted_drift_rad - > self.pnp_rigid_maximum_accepted_drift_rad - ): - raise ValueError( - "pnp rigid p95 drift limit must not exceed maximum limit" - ) - if min( - self.pnp_rigid_p95_accepted_distance_drift_m, - self.pnp_rigid_maximum_accepted_distance_drift_m, - ) <= 0.0: - raise ValueError( - "pnp rigid distance drift limits must be positive" - ) - if ( - self.pnp_rigid_p95_accepted_distance_drift_m - > self.pnp_rigid_maximum_accepted_distance_drift_m - ): - raise ValueError( - "pnp rigid p95 distance drift limit must not exceed " - "maximum limit" - ) - if min( - self.trajectory_maximum_plane_rms_m, - self.trajectory_maximum_radial_rms_m, - self.trajectory_minimum_radius_m, - self.trajectory_minimum_arc_rad, - self.trajectory_maximum_root_role_disagreement_rad, - self.trajectory_maximum_anchor_drift_m, - self.trajectory_static_translation_outlier_m, - self.trajectory_maximum_static_translation_rms_m, - self.maximum_stable_translation_spread_m, - ) <= 0.0: - raise ValueError("trajectory centre-fit thresholds must be positive") - if not 1 <= self.validation_command_count <= 254: - raise ValueError("validation_command_count must be in [1, 254]") - if self.validation_approach_minimum_seconds < 0.0: - raise ValueError( - "validation_approach_minimum_seconds must be non-negative" - ) - if self.validation_approach_timeout_seconds <= 0.0: - raise ValueError( - "validation_approach_timeout_seconds must be positive" - ) - if self.validation_position_tolerance_u8 < 0.0: - raise ValueError( - "validation_position_tolerance_u8 must be non-negative" - ) - - def _camera_info_callback(self, message: CameraInfo) -> None: - camera_info_valid = ( - message.width > 0 - and message.height > 0 - and len(message.p) == 12 - and float(message.p[0]) > 0.0 - and float(message.p[5]) > 0.0 - ) - new_camera_matrix: np.ndarray | None = None - if camera_info_valid: - projection = np.asarray(message.p, dtype=float).reshape(3, 4) - new_camera_matrix = projection[:, :3].copy() - camera_info_valid = bool( - np.all(np.isfinite(new_camera_matrix)) - and new_camera_matrix[0, 0] > 0.0 - and new_camera_matrix[1, 1] > 0.0 - ) - if ( - new_camera_matrix is not None - and self.pnp_camera_matrix is not None - and not np.allclose( - new_camera_matrix, - self.pnp_camera_matrix, - rtol=1.0e-9, - atol=1.0e-9, - ) - ): - self.pnp_tracker.reset() - self.pnp_group_tracker.reset() - self.camera_info_valid = camera_info_valid - self.pnp_camera_matrix = ( - new_camera_matrix if camera_info_valid else None - ) - self.camera_frame = message.header.frame_id - self.camera_intrinsics = { - "frame_id": message.header.frame_id, - "width": int(message.width), - "height": int(message.height), - "distortion_model": message.distortion_model, - "d": [float(value) for value in message.d], - "k": [float(value) for value in message.k], - "p": [float(value) for value in message.p], - "rectified_camera_matrix": ( - [] - if self.pnp_camera_matrix is None - else [ - float(value) - for value in self.pnp_camera_matrix.reshape(-1) - ] - ), - "binning_x": int(message.binning_x), - "binning_y": int(message.binning_y), - "roi": { - "x_offset": int(message.roi.x_offset), - "y_offset": int(message.roi.y_offset), - "height": int(message.roi.height), - "width": int(message.roi.width), - "do_rectify": bool(message.roi.do_rectify), - }, - } - - def _state_callback(self, message: JointState) -> None: - if len(message.position) != 20: - return - if len(message.name) == 20 and set(message.name) == set(COMMAND_NAMES): - lookup = dict(zip(message.name, message.position)) - self.latest_state_u8 = tuple( - float(lookup[name]) for name in COMMAND_NAMES - ) - else: - self.latest_state_u8 = tuple(float(value) for value in message.position) - stamp_ns = _stamp_ns(message.header.stamp) - if stamp_ns <= 0: - stamp_ns = int(self.get_clock().now().nanoseconds) - if not self.state_history or stamp_ns > self.state_history[-1].stamp_ns: - self.state_history.append( - StateSample( - stamp_ns=stamp_ns, - position_u8=self.latest_state_u8, - ) - ) - - def _info_callback(self, message: String) -> None: - try: - value = json.loads(message.data) - except json.JSONDecodeError: - return - if isinstance(value, dict): - self.latest_hand_info = value - - def _detections_callback(self, message: AprilTagDetectionArray) -> None: - stamp = _stamp_ns(message.header.stamp) - qualities: dict[str, TagQuality] = {} - latest_corners: dict[str, np.ndarray] = {} - for detection in message.detections: - role = self.role_by_id.get(int(detection.id)) - if role is None: - continue - corners = np.asarray( - [[float(point.x), float(point.y)] for point in detection.corners], - dtype=float, - ) - if corners.shape != (4, 2): - continue - edges = np.linalg.norm(corners - np.roll(corners, -1, axis=0), axis=1) - qualities[role] = TagQuality( - hamming=int(detection.hamming), - decision_margin=float(detection.decision_margin), - edge_pixels=float(np.mean(edges)), - ) - latest_corners[role] = corners - - transforms: dict[str, tuple[float, ...]] = {} - translations: dict[str, tuple[float, float, float]] = {} - pose_candidates: dict[str, tuple[SquareTagPose, ...]] = {} - pnp_rejections: dict[str, str] = {} - pnp_errors: dict[str, float] = {} - if self.uses_pnp: - if self.pnp_camera_matrix is None: - pnp_rejections = { - role: "camera_info_not_ready" - for role in self.tag_config - } - else: - for role in self.tag_config: - corners = latest_corners.get(role) - if corners is None: - pnp_rejections[role] = "tag_not_detected" - continue - quality = qualities.get(role) - if quality is None or not self._quality_is_valid( - quality, - include_pnp=False, - ): - pnp_rejections[role] = "tag_quality_invalid" - continue - pose, reason = self.pnp_tracker.estimate( - role, - corners, - tag_size_m=float( - self.tag_config[role]["size_m"] - ), - camera_matrix=self.pnp_camera_matrix, - stamp_ns=stamp, - reprojection_tie_px=( - self._pnp_reprojection_tie_for_role(role) - ), - ) - pose_candidates[role] = ( - self.pnp_tracker.last_candidates_by_role.get( - role, - (), - ) - ) - if pose is None and not pose_candidates[role]: - pnp_rejections[role] = reason - if ( - set(pose_candidates) == set(self.tag_config) - and all(pose_candidates.values()) - ): - selected_group, group_reason = ( - self.pnp_group_tracker.select( - pose_candidates, - stamp_ns=stamp, - ) - ) - if selected_group is None: - pnp_rejections["_group"] = group_reason - else: - transforms.clear() - pnp_errors.clear() - pnp_rejections.clear() - for role, pose in selected_group.items(): - transforms[role] = pose.quaternion_xyzw - translations[role] = pose.translation_xyz_m - pnp_errors[role] = pose.reprojection_error_px - quality = qualities[role] - qualities[role] = replace( - quality, - reprojection_error_px=( - pose.reprojection_error_px - ), - ) - self.transform_cache[stamp] = transforms - self.translation_cache[stamp] = translations - self.pnp_candidate_cache[stamp] = pose_candidates - self.preflight_pnp_flags.append( - set(transforms) == set(self.tag_config) - ) - self.latest_pnp_rejections = pnp_rejections - self.latest_pnp_errors_px = pnp_errors - - self.latest_corners = latest_corners - self.latest_tag_qualities = dict(qualities) - self.quality_cache[stamp] = qualities - self.corner_cache[stamp] = latest_corners - detection_good = set(qualities) == set(self.tag_config) and all( - self._quality_is_valid(quality, include_pnp=False) - for quality in qualities.values() - ) - all_good = detection_good and all( - self._quality_is_valid(quality, include_pnp=True) - for quality in qualities.values() - ) - self.preflight_detection_flags.append(detection_good) - self.preflight_detection_times.append(time.monotonic()) - if not all_good and self.collector.active: - self.collector.mark_invalid_frame() - self._try_emit_observation(stamp) - self._trim_caches() - - def _pnp_reprojection_tie_for_role(self, role: str) -> float: - """Lock every visible tag to its temporally continuous IPPE branch.""" - del role - return self.pnp_reprojection_tie_px - - def _quality_is_valid( - self, - quality: TagQuality, - *, - include_pnp: bool = True, - ) -> bool: - return tag_quality_is_valid( - quality, - maximum_hamming=self.maximum_hamming, - minimum_decision_margin=self.minimum_decision_margin, - minimum_edge_pixels=self.minimum_edge_pixels, - maximum_reprojection_error_px=( - self.pnp_maximum_reprojection_error_px - if include_pnp and self.uses_pnp - else None - ), - ) - - def _try_emit_observation(self, stamp: int) -> None: - if stamp in self.emitted_stamps: - return - quality = self.quality_cache.get(stamp) - required = set(self.tag_config) - if quality is None: - return - if set(quality) != required: - return - if not all(self._quality_is_valid(value) for value in quality.values()): - return - if self.angle_estimation_mode == "image_plane_2d": - corners = self.corner_cache.get(stamp) - if corners is None or set(corners) != required: - return - transforms = { - role: image_plane_tag_quaternion_xyzw(points) - for role, points in corners.items() - } - else: - transforms = self.transform_cache.get(stamp) - if transforms is None or set(transforms) != required: - return - translations = self.translation_cache.get(stamp, {}) - if ( - self.angle_estimation_mode == "trajectory_center_3d" - and set(translations) != required - ): - return - relative = { - PAIR_ROOT: relative_quaternion_xyzw( - transforms["t0"], transforms["t3"] - ), - PAIR_MCP: relative_quaternion_xyzw( - transforms["t3"], transforms["t4"] - ), - PAIR_IP: relative_quaternion_xyzw( - transforms["t4"], transforms["t5"] - ), - } - matched_state = interpolate_state_u8( - list(self.state_history), - stamp, - maximum_skew_ns=self.maximum_state_image_skew_ns, - ) - if matched_state is None: - state_u8 = self.latest_state_u8 - state_sync_error_ns = None - state_stamp_ns = None - else: - state_u8, state_sync_error_ns = matched_state - state_stamp_ns = stamp - observation = Observation( - stamp_ns=stamp, - received_at=time.monotonic(), - relative_quaternion_xyzw=relative, - tag_quality=quality, - state_u8=state_u8, - state_stamp_ns=state_stamp_ns, - state_sync_error_ns=state_sync_error_ns, - tag_quaternion_xyzw=transforms, - tag_translation_xyz_m=translations, - tag_pose_candidates=self.pnp_candidate_cache.get(stamp, {}), - ) - self.emitted_stamps.add(stamp) - self.preflight_observations.append(observation) - if self.collector.active: - result = self.collector.add(observation, time.monotonic()) - if result is not None: - self.pending_aggregate = result - if self.sweep_collector.active: - result = self.sweep_collector.add(observation, time.monotonic()) - if result is not None: - self.pending_sweep_observations = result - - def _trim_caches(self) -> None: - all_stamps = sorted( - set(self.quality_cache) - | set(self.corner_cache) - | set(self.transform_cache) - | set(self.translation_cache) - | set(self.pnp_candidate_cache) - ) - for stamp in all_stamps[:-300]: - self.quality_cache.pop(stamp, None) - self.corner_cache.pop(stamp, None) - self.transform_cache.pop(stamp, None) - self.translation_cache.pop(stamp, None) - self.pnp_candidate_cache.pop(stamp, None) - self.emitted_stamps.discard(stamp) - - def _image_callback(self, message: Image) -> None: - if ( - self.debug_publisher is None - or self.debug_publisher.get_subscription_count() < 1 - ): - return - now = time.monotonic() - if now - self.last_debug_publish < 1.0 / self.debug_max_rate_hz: - return - self.last_debug_publish = now - try: - image = self.bridge.imgmsg_to_cv2(message, desired_encoding="bgr8") - except Exception as error: - self.get_logger().warning(f"debug image conversion failed: {error}") - return - if self.debug_scale != 1.0: - image = cv2.resize( - image, - None, - fx=self.debug_scale, - fy=self.debug_scale, - interpolation=cv2.INTER_AREA, - ) - colors = { - "t0": (0, 200, 0), - "t3": (0, 128, 255), - "t4": (200, 0, 200), - "t5": (0, 220, 255), - } - for role, corners in self.latest_corners.items(): - points = np.rint(corners * self.debug_scale).astype(np.int32) - cv2.polylines(image, [points], True, colors[role], 2) - origin = tuple(points[0]) - cv2.putText( - image, - role.upper(), - origin, - cv2.FONT_HERSHEY_SIMPLEX, - 0.7, - colors[role], - 2, - ) - cv2.putText( - image, - f"{self.state}: {self.state_reason}", - (20, 32), - cv2.FONT_HERSHEY_SIMPLEX, - 0.65, - (0, 0, 255) if self.state in {STATE_PAUSED, STATE_ABORTED} else (0, 255, 0), - 2, - ) - output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8") - output.header = message.header - self.debug_publisher.publish(output) - - def _timer_callback(self) -> None: - now = time.monotonic() - self.collector.poll(now) - self.sweep_collector.poll(now) - if self.collector.state == "failed" and self.state not in { - STATE_PAUSED, - STATE_ABORTED, - }: - self._pause(f"point_capture_failed:{self.collector.reason}") - if self.sweep_collector.state == "failed" and self.state not in { - STATE_PAUSED, - STATE_ABORTED, - }: - self._pause( - f"continuous_sweep_failed:{self.sweep_collector.reason}" - ) - - if self.pending_aggregate is not None: - aggregate = self.pending_aggregate - self.pending_aggregate = None - self._complete_active_task(aggregate) - if self.pending_sweep_observations is not None: - observations = self.pending_sweep_observations - self.pending_sweep_observations = None - self._complete_continuous_sweep(observations) - - if ( - self.active_task is not None - and self.active_task.get("kind") == "continuous_sweep" - and self.active_task.get("stage") == "prepare_move" - ): - self._poll_continuous_prepare(now) - - if ( - self.active_task is not None - and self.active_task.get("kind") == "continuous_sweep" - and self.active_task.get("stage") == "sweep" - and self.active_task.get("motion_mode") == "paced" - and self.sweep_collector.active - ): - self._poll_continuous_segment(now) - - if ( - self.state == STATE_VALIDATING - and self.active_task is not None - and self.active_task.get("kind") == "validation" - and self.active_task.get("stage") == "approach" - ): - self._poll_validation_approach(now) - - if self.state == STATE_PREFLIGHT: - passed, reason = self._preflight_status() - self.state_reason = reason - if passed: - self._select_post_preflight_state() - elif self.state in {STATE_SCAN_ROOT, STATE_SCAN_TIP, STATE_VALIDATING}: - if ( - self.active_task is None - and not self.collector.active - and not self.sweep_collector.active - ): - self._start_next_task() - - if now - self.last_status_publish >= 0.5: - self._publish_status() - self.last_status_publish = now - - def _preflight_status(self) -> tuple[bool, str]: - if not self.camera_info_valid: - return False, "camera_info_not_ready" - if len(self.latest_state_u8) != 20: - return False, "g20_state_not_ready" - if self.command_publisher.get_subscription_count() < 1: - return False, "g20_sdk_not_subscribed" - if self._has_other_command_publishers(): - return False, "another_command_publisher_exists" - if len(self.preflight_detection_flags) < self.preflight_frames: - return ( - False, - "collecting_detection_preflight:" - f"{len(self.preflight_detection_flags)}/{self.preflight_frames}", - ) - detection_rate = float(np.mean(self.preflight_detection_flags)) - if detection_rate < self.minimum_detection_rate: - return False, f"detection_rate_too_low:{detection_rate:.3f}" - detection_times = list(self.preflight_detection_times) - duration = detection_times[-1] - detection_times[0] - detection_hz = ( - (len(detection_times) - 1) / duration - if duration > 0.0 - else 0.0 - ) - if detection_hz < self.minimum_detection_hz: - return False, f"detection_hz_too_low:{detection_hz:.2f}" - if self.uses_pnp: - if len(self.preflight_pnp_flags) < self.preflight_frames: - return ( - False, - "collecting_pnp_preflight:" - f"{len(self.preflight_pnp_flags)}/{self.preflight_frames}", - ) - pnp_valid_rate = float(np.mean(self.preflight_pnp_flags)) - if pnp_valid_rate < self.pnp_minimum_valid_rate: - return False, f"pnp_valid_rate_too_low:{pnp_valid_rate:.3f}" - if len(self.preflight_observations) < self.preflight_frames: - return ( - False, - "collecting_static_preflight:" - f"{len(self.preflight_observations)}/{self.preflight_frames}", - ) - observations = list(self.preflight_observations) - if self.angle_estimation_mode == "trajectory_center_3d": - for pair, (parent, child) in TAG_PAIR_ROLES.items(): - vectors = np.asarray( - [ - np.asarray( - observation.tag_translation_xyz_m[child], - dtype=float, - ) - - np.asarray( - observation.tag_translation_xyz_m[parent], - dtype=float, - ) - for observation in observations - ], - dtype=float, - ) - reference = np.median(vectors, axis=0) - residuals = np.linalg.norm(vectors - reference, axis=1) - inliers = ( - residuals - <= self.trajectory_static_translation_outlier_m - ) - inlier_rate = float(np.mean(inliers)) - if inlier_rate < self.minimum_pose_inlier_rate: - return False, ( - f"{pair}_position_inlier_rate_too_low:" - f"{inlier_rate:.3f}" - ) - rms = float( - np.sqrt(np.mean(np.square(residuals[inliers]))) - ) - if ( - rms - > self.trajectory_maximum_static_translation_rms_m - ): - return False, ( - f"{pair}_static_position_rms_too_large:" - f"{1000.0 * rms:.3f}mm" - ) - else: - for pair in PAIR_NAMES: - quaternions = [ - observation.relative_quaternion_xyzw[pair] - for observation in observations - ] - inlier_rate = rotation_inlier_fraction( - quaternions, - outlier_threshold_rad=self.pose_outlier_threshold_rad, - ) - if inlier_rate < self.minimum_pose_inlier_rate: - return False, ( - f"{pair}_pose_inlier_rate_too_low:{inlier_rate:.3f}" - ) - rms = rotation_rms_rad( - quaternions, - outlier_threshold_rad=self.pose_outlier_threshold_rad, - ) - if rms > self.maximum_static_std_rad: - return False, ( - f"{pair}_static_rms_too_large:{rms:.6f}" - ) - return True, "preflight_passed" - - def _has_other_command_publishers(self) -> bool: - try: - publishers = self.get_publishers_info_by_topic(self.command_topic) - except Exception: - return True - return any( - endpoint.node_name != self.get_name() - or endpoint.node_namespace != self.get_namespace() - for endpoint in publishers - ) - - def _select_post_preflight_state(self) -> None: - observations = list(self.preflight_observations) - static_position_rms_m: dict[str, float] = {} - position_inlier_rate: dict[str, float] = {} - if self.angle_estimation_mode == "trajectory_center_3d": - for pair, (parent, child) in TAG_PAIR_ROLES.items(): - vectors = np.asarray( - [ - np.asarray( - observation.tag_translation_xyz_m[child], - dtype=float, - ) - - np.asarray( - observation.tag_translation_xyz_m[parent], - dtype=float, - ) - for observation in observations - ], - dtype=float, - ) - reference = np.median(vectors, axis=0) - residuals = np.linalg.norm(vectors - reference, axis=1) - inliers = ( - residuals - <= self.trajectory_static_translation_outlier_m - ) - position_inlier_rate[pair] = float(np.mean(inliers)) - static_position_rms_m[pair] = float( - np.sqrt(np.mean(np.square(residuals[inliers]))) - ) - self._write_manifest( - { - "preflight": { - "passed": True, - "detection_rate": float( - np.mean(self.preflight_detection_flags) - ), - "detection_hz": ( - (len(self.preflight_detection_times) - 1) - / ( - self.preflight_detection_times[-1] - - self.preflight_detection_times[0] - ) - ), - "pnp_valid_rate": ( - float(np.mean(self.preflight_pnp_flags)) - if self.uses_pnp - and self.preflight_pnp_flags - else None - ), - "maximum_reprojection_error_px": { - role: float( - max( - observation.tag_quality[ - role - ].reprojection_error_px - or 0.0 - for observation in observations - ) - ) - for role in self.tag_config - } - if self.uses_pnp - else {}, - "static_rms_rad": { - pair: rotation_rms_rad( - [ - observation.relative_quaternion_xyzw[pair] - for observation in observations - ], - outlier_threshold_rad=self.pose_outlier_threshold_rad, - ) - for pair in PAIR_NAMES - }, - "pose_inlier_rate": { - pair: rotation_inlier_fraction( - [ - observation.relative_quaternion_xyzw[pair] - for observation in observations - ], - outlier_threshold_rad=self.pose_outlier_threshold_rad, - ) - for pair in PAIR_NAMES - }, - "static_position_rms_m": static_position_rms_m, - "position_inlier_rate": position_inlier_rate, - } - } - ) - if self.final_path.exists(): - self.state = STATE_COMPLETE - self.state_reason = "existing_final_result" - elif not self._phase_complete(PHASE_ROOT): - self.state = STATE_WAIT_ROOT - self.state_reason = "call_start" - elif not self._phase_complete(PHASE_TIP): - self.state = STATE_WAIT_TIP - self.state_reason = "call_start_to_resume_tip" - else: - self._begin_validation() - - def _phase_complete(self, phase: str) -> bool: - if self.scan_mode == "continuous": - return all( - self._continuous_direction_complete(phase, direction) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ) - completed = completed_scan_keys(self.records) - return all( - (phase, cycle, direction, command) in completed - for cycle, direction, command in scan_targets( - self.repetitions, self.command_step - ) - ) - - def _continuous_direction_complete( - self, phase: str, direction: str - ) -> bool: - commands = sorted( - { - int(record["command_u8"]) - for record in self.records - if record.get("kind", "sample") == "sample" - and record.get("scan_mode") == "continuous" - and record.get("phase") == phase - and record.get("direction") == direction - } - ) - if ( - len(commands) < self.continuous_minimum_bins - or not commands - or commands[0] != 0 - or commands[-1] != 255 - ): - return False - return max(np.diff(commands), default=0) <= self.continuous_maximum_bin_gap - - def _start_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state not in {STATE_WAIT_ROOT, STATE_WAIT_TIP}: - response.success = False - response.message = f"current state is {self.state}" - return response - if not self.commands_enabled: - response.success = False - response.message = "commands_enabled=false; restart for hardware scan" - return response - phase = PHASE_ROOT if self.state == STATE_WAIT_ROOT else PHASE_TIP - self._begin_scan(phase) - response.success = True - response.message = ( - f"{self.scan_mode} calibration armed; root, tip and validation " - "run automatically" - if phase == PHASE_ROOT and self.auto_start_tip - else f"{phase} scan armed" - ) - return response - - def _confirm_root_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state != STATE_WAIT_ROOT: - response.success = False - response.message = f"current state is {self.state}" - return response - if not self.commands_enabled: - response.success = False - response.message = "commands_enabled=false; restart for hardware scan" - return response - self._begin_scan(PHASE_ROOT) - response.success = True - response.message = "root full-range scan armed" - return response - - def _confirm_tip_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state != STATE_WAIT_TIP: - response.success = False - response.message = f"current state is {self.state}" - return response - if not self.commands_enabled: - response.success = False - response.message = "commands_enabled=false; restart for hardware scan" - return response - self._begin_scan(PHASE_TIP) - response.success = True - response.message = "tip full-range scan armed" - return response - - def _pause_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state not in {STATE_SCAN_ROOT, STATE_SCAN_TIP, STATE_VALIDATING}: - response.success = False - response.message = f"cannot pause from {self.state}" - return response - self._pause("operator_pause") - response.success = True - response.message = "calibration paused; current command is held" - return response - - def _resume_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state != STATE_PAUSED or self.paused_from is None: - response.success = False - response.message = "calibration is not resumably paused" - return response - self.state = self.paused_from - self.state_reason = "resuming_current_task" - self.paused_from = None - if ( - self.active_task is not None - and self.active_task.get("kind") == "continuous_sweep" - ): - # An end-to-end command cannot resume halfway with correct - # direction/history. Return to its start endpoint and repeat only - # the interrupted sweep. - self.active_task["stage"] = "prepare" - self.active_task["command_u8"] = int( - self.active_task["start_u8"] - ) - self.active_task.pop("start_aggregate", None) - self._restart_active_task() - response.success = True - response.message = "calibration resumed" - return response - - def _abort_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - self.collector.state = "idle" - self.sweep_collector.state = "idle" - self.pending_aggregate = None - self.pending_sweep_observations = None - self.state = STATE_ABORTED - self.state_reason = "operator_abort_current_command_held" - self._write_checkpoint() - response.success = True - response.message = "aborted without sending a return command" - return response - - def _pause(self, reason: str) -> None: - if self.state == STATE_PAUSED: - return - self.paused_from = self.state - self.collector.state = "idle" - self.sweep_collector.state = "idle" - self.pending_aggregate = None - self.pending_sweep_observations = None - self.state = STATE_PAUSED - self.state_reason = reason - self._write_checkpoint() - - def _begin_scan(self, phase: str) -> None: - if self.scan_mode == "continuous": - tasks: list[dict[str, Any]] = [] - for direction, start, target in ( - (DIRECTION_DECREASING, 255, 0), - (DIRECTION_INCREASING, 0, 255), - ): - if self._continuous_direction_complete(phase, direction): - continue - tasks.append( - { - "kind": "continuous_sweep", - "scan_mode": "continuous", - "motion_mode": self.continuous_motion_mode, - "phase": phase, - "cycle": 0, - "direction": direction, - "start_u8": start, - "target_u8": target, - "command_u8": start, - "stage": "prepare", - } - ) - self.task_queue = deque(tasks) - else: - completed = completed_scan_keys(self.records) - self.task_queue = deque( - { - "kind": "sample", - "scan_mode": "point", - "phase": phase, - "cycle": cycle, - "direction": direction, - "command_u8": command, - } - for cycle, direction, command in scan_targets( - self.repetitions, self.command_step - ) - if (phase, cycle, direction, command) not in completed - ) - self.current_phase = phase - self.active_task = None - self.state = STATE_SCAN_ROOT if phase == PHASE_ROOT else STATE_SCAN_TIP - self.state_reason = "scan_armed" - self._write_checkpoint() - - def _start_next_task(self) -> None: - if not self.task_queue: - if self.state == STATE_SCAN_ROOT: - if self.auto_start_tip: - self._begin_scan(PHASE_TIP) - else: - self.state = STATE_WAIT_TIP - self.state_reason = "root_complete_call_start_for_tip" - self.current_phase = None - self._write_checkpoint() - elif self.state == STATE_SCAN_TIP: - self.current_phase = None - self._begin_validation() - elif self.state == STATE_VALIDATING: - self._finalize() - return - self.active_task = self.task_queue.popleft() - self.active_task.setdefault("stage", "target") - self._restart_active_task() - - def _restart_active_task(self) -> None: - if self.active_task is None: - return - task = self.active_task - if task.get("kind") == "continuous_sweep": - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - if task.get("stage") == "prepare": - command = int(task["start_u8"]) - task["command_u8"] = command - self._publish_hand_command( - build_command(motor_index, command) - ) - task["stage"] = "prepare_move" - task["prepare_started_at"] = time.monotonic() - self.collector.state = "idle" - self.state_reason = ( - f"{task['phase']}:continuous:prepare_move:u={command}" - ) - return - if task.get("stage") != "sweep": - raise RuntimeError( - f"unknown continuous stage {task.get('stage')}" - ) - now = time.monotonic() - self.sweep_collector.start( - now, - motor_index=motor_index, - start_u8=int(task["start_u8"]), - target_u8=int(task["target_u8"]), - ) - if task.get("motion_mode") == "endpoint": - command = int(task["target_u8"]) - task["command_u8"] = command - task.pop("segment_targets_u8", None) - task.pop("segment_index", None) - task.pop("segment_started_at", None) - self._publish_hand_command( - build_command(motor_index, command) - ) - self.state_reason = ( - f"{task['phase']}:continuous_endpoint:" - f"{task['direction']}:" - f"{task['start_u8']}->{task['target_u8']}" - ) - return - direction_targets = [ - command - for _, direction, command in scan_targets( - 1, self.command_step - ) - if direction == task["direction"] - ] - if not direction_targets or int(direction_targets[0]) != int( - task["start_u8"] - ): - raise RuntimeError("continuous segment grid has wrong start") - task["segment_targets_u8"] = [ - int(command) for command in direction_targets[1:] - ] - task["segment_index"] = 0 - command = int(task["segment_targets_u8"][0]) - task["command_u8"] = command - task["segment_started_at"] = now - self._publish_hand_command(build_command(motor_index, command)) - self.state_reason = ( - f"{task['phase']}:continuous_paced:{task['direction']}:" - f"{task['start_u8']}->{task['target_u8']}" - ) - return - - command = int(task["command_u8"]) - if task.get("kind") == "validation" and task.get("stage") == "approach": - command = int(task["approach_u8"]) - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - self._publish_hand_command(build_command(motor_index, command)) - if task.get("kind") == "validation" and task.get("stage") == "approach": - task["approach_started_at"] = time.monotonic() - self.collector.state = "idle" - self.state_reason = ( - f"{task['phase']}:validation:approach_wait:u={command}" - ) - return - point_tolerance = ( - self.validation_position_tolerance_u8 - if task.get("kind") == "validation" - else self.continuous_endpoint_tolerance_u8 - ) - self.collector.start( - time.monotonic(), - required_state_index=motor_index, - required_state_u8=command, - maximum_state_error_u8=point_tolerance, - ) - self.state_reason = ( - f"{task['phase']}:{task.get('kind')}:{task.get('stage')}:u={command}" - ) - - def _publish_hand_command(self, values: list[int]) -> None: - if not self.commands_enabled: - self.get_logger().warning( - f"dry-run: suppressed hardware command {values}" - ) - return - message = JointState() - message.header.stamp = self.get_clock().now().to_msg() - message.name = list(COMMAND_NAMES) - message.position = [float(value) for value in values] - self.command_publisher.publish(message) - - def _complete_active_task(self, aggregate: dict[str, Any]) -> None: - if self.active_task is None: - return - task = self.active_task - if task.get("kind") == "continuous_sweep": - if task.get("stage") != "prepare_capture": - raise RuntimeError( - "continuous point capture is only for prepare_capture" - ) - task["start_aggregate"] = aggregate - task["stage"] = "sweep" - task.pop("prepare_started_at", None) - self.collector.state = "idle" - self._restart_active_task() - return - if task.get("kind") == "sample": - record = { - "kind": "sample", - "scan_mode": task.get("scan_mode", "point"), - "phase": task["phase"], - "cycle": int(task["cycle"]), - "direction": task["direction"], - "command_u8": int(task["command_u8"]), - **aggregate, - } - append_jsonl(self.raw_path, record) - self.records.append(record) - self.active_task = None - self.collector.state = "idle" - self._write_checkpoint() - return - - if task.get("kind") != "validation": - raise RuntimeError(f"unknown task kind {task.get('kind')}") - if task.get("stage") == "approach": - raise RuntimeError("validation approach must not capture images") - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - state_median = aggregate.get("state_u8_median", []) - if len(state_median) != 20: - self._pause("validation_capture_missing_synchronised_state") - return - actual = float(state_median[motor_index]) - target = float(task["command_u8"]) - if ( - not math.isfinite(actual) - or abs(actual - target) - > self.validation_position_tolerance_u8 - ): - self._pause( - "validation_capture_position_mismatch:" - f"target={target:.1f},actual={actual:.1f}" - ) - return - if self.angle_estimation_mode == "trajectory_center_3d": - maximum_capture_spread = max( - ( - float(value) - for value in aggregate.get( - "maximum_translation_spread_m", {} - ).values() - ), - default=float("inf"), - ) - if ( - maximum_capture_spread - > self.maximum_stable_translation_spread_m - ): - self._pause( - "validation_capture_position_spread_too_large:" - f"{1000.0 * maximum_capture_spread:.2f}mm" - ) - return - else: - maximum_capture_spread = max( - ( - float(value) - for value in aggregate.get( - "maximum_spread_rad", {} - ).values() - ), - default=float("inf"), - ) - if maximum_capture_spread > self.maximum_stable_spread_rad: - self._pause( - "validation_capture_pose_spread_too_large:" - f"{math.degrees(maximum_capture_spread):.2f}deg" - ) - return - if task.get("zero_reference"): - self.validation_zero[task["phase"]] = aggregate - else: - self._record_validation(task, aggregate) - self.active_task = None - self.collector.state = "idle" - self._write_checkpoint() - - def _poll_validation_approach(self, now: float) -> None: - task = self.active_task - if task is None: - return - started = float(task.get("approach_started_at", now)) - elapsed = float(now) - started - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - at_target = ( - len(self.latest_state_u8) == 20 - and abs( - float(self.latest_state_u8[motor_index]) - - float(task["approach_u8"]) - ) - <= self.validation_position_tolerance_u8 - ) - if ( - elapsed >= self.validation_approach_minimum_seconds - and at_target - ): - task["stage"] = "target" - task.pop("approach_started_at", None) - self._restart_active_task() - elif elapsed > self.validation_approach_timeout_seconds: - self._pause("validation_approach_timeout") - - def _poll_continuous_prepare(self, now: float) -> None: - task = self.active_task - if task is None: - return - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - command = int(task["start_u8"]) - elapsed = float(now) - float(task.get("prepare_started_at", now)) - at_target = ( - len(self.latest_state_u8) == 20 - and abs(float(self.latest_state_u8[motor_index]) - command) - <= self.continuous_endpoint_tolerance_u8 - ) - if at_target: - task["stage"] = "prepare_capture" - self.collector.start( - float(now), - required_state_index=motor_index, - required_state_u8=command, - maximum_state_error_u8=( - self.continuous_endpoint_tolerance_u8 - ), - ) - self.state_reason = ( - f"{task['phase']}:continuous:prepare_capture:u={command}" - ) - elif elapsed > self.continuous_prepare_timeout_seconds: - actual = ( - float(self.latest_state_u8[motor_index]) - if len(self.latest_state_u8) == 20 - else float("nan") - ) - self._pause( - "continuous_prepare_timeout:" - f"target={command},actual={actual:.1f}" - ) - - def _poll_continuous_segment(self, now: float) -> None: - task = self.active_task - if task is None or not self.sweep_collector.observations: - return - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - command = int(task["command_u8"]) - observation = self.sweep_collector.observations[-1] - actual = float(observation.state_u8[motor_index]) - elapsed = float(now) - float(task.get("segment_started_at", now)) - at_target = ( - abs(actual - command) - <= self.continuous_endpoint_tolerance_u8 - ) - if ( - at_target - and elapsed >= self.continuous_segment_minimum_seconds - and command != int(task["target_u8"]) - ): - next_index = int(task["segment_index"]) + 1 - targets = task["segment_targets_u8"] - if next_index >= len(targets): - self._pause("continuous_segment_grid_exhausted") - return - next_command = int(targets[next_index]) - task["segment_index"] = next_index - task["command_u8"] = next_command - task["segment_started_at"] = float(now) - self._publish_hand_command( - build_command(motor_index, next_command) - ) - elif ( - not at_target - and elapsed > self.continuous_segment_timeout_seconds - ): - self._pause( - "continuous_segment_timeout:" - f"target={command},actual={actual:.1f}" - ) - - def _resolve_pnp_trajectory_branches( - self, - observations: list[Observation], - *, - phase: str, - ) -> tuple[list[Observation], dict[str, Any]]: - if not self.uses_pnp: - return observations, {} - if phase == PHASE_ROOT: - rigid_roles = ("t3", "t4", "t5") - fixed_pairs = (("t3", "t4"), ("t4", "t5")) - # Motor 0 moves T3/T4/T5 as one rigid chain. Do not freeze T3 to - # its online IPPE branch: if T3 is the ambiguous tag, fixing it - # forces the optimiser to flip T4/T5 merely to compensate and - # leaves a large rigid drift. The two rigid pairs provide enough - # information to resolve all three branches jointly. - fixed_online_roles = () - elif phase == PHASE_TIP: - rigid_roles = ("t0", "t3") - fixed_pairs = (("t0", "t3"),) - # T3 is the parent reference for the active MCP trajectory. Do - # not let a non-target root constraint rewrite that active curve. - fixed_online_roles = ("t3",) - else: - raise ValueError(f"unknown calibration phase {phase}") - - frames = [] - for observation in observations: - frame = dict(observation.tag_pose_candidates) - for role in fixed_online_roles: - online_quaternion = observation.tag_quaternion_xyzw.get( - role - ) - candidates = tuple(frame.get(role, ())) - if online_quaternion is None or not candidates: - raise ValueError( - f"trajectory frame is missing fixed role {role}" - ) - frame[role] = ( - min( - candidates, - key=lambda candidate: rotation_distance_rad( - online_quaternion, - candidate.quaternion_xyzw, - ), - ), - ) - frames.append(frame) - if any( - any(not frame.get(role) for role in rigid_roles) - for frame in frames - ): - raise ValueError("trajectory frame is missing PnP candidates") - selected_path, quality = select_rigid_group_trajectory( - frames, - roles=rigid_roles, - fixed_pairs=fixed_pairs, - reprojection_scale_px=( - self.pnp_trajectory_reprojection_scale_px - ), - rotation_scale_rad=self.pnp_rigid_rotation_scale_rad, - translation_scale_m=self.pnp_rigid_translation_scale_m, - pair_geometry=( - "distance" - if self.angle_estimation_mode == "trajectory_center_3d" - else "pose" - ), - ) - - corrected: list[Observation] = [] - corrected_role_poses = 0 - corrected_frames = 0 - for observation, selected in zip(observations, selected_path): - transforms = dict(observation.tag_quaternion_xyzw) - translations = dict(observation.tag_translation_xyz_m) - tag_quality = dict(observation.tag_quality) - frame_corrected = False - for role, pose in selected.items(): - original = transforms.get(role) - if ( - original is not None - and rotation_distance_rad( - original, - pose.quaternion_xyzw, - ) - > 1.0e-6 - ): - corrected_role_poses += 1 - frame_corrected = True - transforms[role] = pose.quaternion_xyzw - translations[role] = pose.translation_xyz_m - if role in tag_quality: - tag_quality[role] = replace( - tag_quality[role], - reprojection_error_px=( - pose.reprojection_error_px - ), - ) - if frame_corrected: - corrected_frames += 1 - relative = { - PAIR_ROOT: relative_quaternion_xyzw( - transforms["t0"], transforms["t3"] - ), - PAIR_MCP: relative_quaternion_xyzw( - transforms["t3"], transforms["t4"] - ), - PAIR_IP: relative_quaternion_xyzw( - transforms["t4"], transforms["t5"] - ), - } - corrected.append( - replace( - observation, - relative_quaternion_xyzw=relative, - tag_quality=tag_quality, - tag_quaternion_xyzw=transforms, - tag_translation_xyz_m=translations, - ) - ) - report: dict[str, Any] = { - **quality, - "phase": phase, - "frames": len(observations), - "corrected_frames": corrected_frames, - "corrected_role_poses": corrected_role_poses, - "fixed_online_roles": list(fixed_online_roles), - } - return corrected, report - - def _complete_continuous_sweep( - self, observations: list[Observation] - ) -> None: - task = self.active_task - if ( - task is None - or task.get("kind") != "continuous_sweep" - or task.get("stage") != "sweep" - ): - return - try: - observations, trajectory_report = ( - self._resolve_pnp_trajectory_branches( - observations, - phase=str(task["phase"]), - ) - ) - except Exception as error: - self.sweep_collector.state = "idle" - self._pause(f"pnp_trajectory_selection_failed:{error}") - return - if trajectory_report: - trajectory_report["direction"] = str(task["direction"]) - self.latest_trajectory_branch_quality = trajectory_report - maximum_rigid_drift = float( - trajectory_report.get( - "maximum_pair_rotation_drift_rad", - float("inf"), - ) - ) - p95_rigid_drift = float( - trajectory_report.get( - "p95_pair_rotation_drift_rad", - float("inf"), - ) - ) - if self.angle_estimation_mode == "trajectory_center_3d": - maximum_translation_drift = float( - trajectory_report.get( - "maximum_pair_distance_drift_m", - float("inf"), - ) - ) - p95_translation_drift = float( - trajectory_report.get( - "p95_pair_distance_drift_m", - float("inf"), - ) - ) - if ( - p95_translation_drift - > self.pnp_rigid_p95_accepted_distance_drift_m - or maximum_translation_drift - > self.pnp_rigid_maximum_accepted_distance_drift_m - ): - self.sweep_collector.state = "idle" - self._pause( - "pnp_trajectory_rigid_distance_drift_too_large:" - f"p95={1000.0 * p95_translation_drift:.2f}mm," - "max=" - f"{1000.0 * maximum_translation_drift:.2f}mm" - ) - return - elif ( - p95_rigid_drift - > self.pnp_rigid_p95_accepted_drift_rad - or maximum_rigid_drift - > self.pnp_rigid_maximum_accepted_drift_rad - ): - self.sweep_collector.state = "idle" - self._pause( - "pnp_trajectory_rigid_drift_too_large:" - f"p95={math.degrees(p95_rigid_drift):.2f}deg," - "max=" - f"{math.degrees(maximum_rigid_drift):.2f}deg" - ) - return - self.trajectory_branch_reports.append(trajectory_report) - motor_index = 0 if task["phase"] == PHASE_ROOT else 15 - aggregates = aggregate_sweep_observations( - observations, - motor_index=motor_index, - start_u8=int(task["start_u8"]), - target_u8=int(task["target_u8"]), - endpoint_tolerance_u8=self.continuous_endpoint_tolerance_u8, - ) - start_command = int(task["start_u8"]) - start_aggregate = task.get("start_aggregate") - if ( - isinstance(start_aggregate, dict) - and start_command not in aggregates - ): - aggregates[start_command] = start_aggregate - - commands = sorted(aggregates) - maximum_gap = max(np.diff(commands), default=0) - if ( - len(commands) < self.continuous_minimum_bins - or not commands - or commands[0] != 0 - or commands[-1] != 255 - or maximum_gap > self.continuous_maximum_bin_gap - ): - self.sweep_collector.state = "idle" - self._pause( - "continuous_sweep_insufficient_coverage:" - f"bins={len(commands)},max_gap={maximum_gap}" - ) - return - - for command in commands: - record = { - "kind": "sample", - "scan_mode": "continuous", - "phase": task["phase"], - "cycle": int(task["cycle"]), - "direction": task["direction"], - "command_u8": int(command), - "sweep_start_u8": int(task["start_u8"]), - "sweep_target_u8": int(task["target_u8"]), - "pnp_trajectory_quality": trajectory_report, - **aggregates[command], - } - append_jsonl(self.raw_path, record) - self.records.append(record) - self.active_task = None - self.sweep_collector.state = "idle" - self._write_checkpoint() - if trajectory_report: - self._write_manifest( - { - "pnp_trajectory_branch_reports": ( - self.trajectory_branch_reports - ) - } - ) - - def _fit_calibration_records(self) -> FitResult: - if self.angle_estimation_mode != "trajectory_center_3d": - return fit_calibration_curves(self.records) - return fit_center_trajectory_curves( - self.records, - passive_ip_multiplier=self.passive_ip_multiplier, - maximum_plane_rms_m=self.trajectory_maximum_plane_rms_m, - maximum_radial_rms_m=self.trajectory_maximum_radial_rms_m, - minimum_radius_m=self.trajectory_minimum_radius_m, - minimum_arc_rad=self.trajectory_minimum_arc_rad, - maximum_root_role_disagreement_rad=( - self.trajectory_maximum_root_role_disagreement_rad - ), - maximum_anchor_drift_m=( - self.trajectory_maximum_anchor_drift_m - ), - ) - - def _begin_validation(self) -> None: - try: - self.fit = self._fit_calibration_records() - except Exception as error: - self._pause(f"curve_fit_failed:{error}") - return - if self.fit.trajectory_quality: - self._write_manifest( - { - "trajectory_center_fit": { - "zero_command_u8": 255, - "models": self.fit.trajectory_models, - "quality": self.fit.trajectory_quality, - } - } - ) - generator = random.Random(self.validation_seed) - commands = sorted( - generator.sample(range(1, 255), self.validation_command_count) - ) - tasks: list[dict[str, Any]] = [] - for phase in (PHASE_ROOT, PHASE_TIP): - tasks.append( - { - "kind": "validation", - "phase": phase, - "direction": DIRECTION_DECREASING, - "command_u8": 255, - "zero_reference": True, - "stage": "target", - } - ) - for index, command in enumerate(commands): - direction = ( - DIRECTION_DECREASING - if index % 2 == 0 - else DIRECTION_INCREASING - ) - approach = ( - min(255, command + self.command_step) - if direction == DIRECTION_DECREASING - else max(0, command - self.command_step) - ) - tasks.append( - { - "kind": "validation", - "phase": phase, - "direction": direction, - "command_u8": command, - "approach_u8": approach, - "zero_reference": False, - "stage": "approach", - } - ) - tasks.append( - { - "kind": "validation", - "phase": phase, - "direction": DIRECTION_INCREASING, - "command_u8": 255, - "zero_reference": True, - "stage": "target", - } - ) - self.task_queue = deque(tasks) - self.active_task = None - self.validation_records = [] - self.validation_errors = [] - self.validation_zero = {} - self.state = STATE_VALIDATING - self.state_reason = "random_validation_started" - self._write_checkpoint() - - def _record_validation( - self, task: dict[str, Any], aggregate: dict[str, Any] - ) -> None: - if self.fit is None: - raise RuntimeError("validation requires fitted curves") - phase = str(task["phase"]) - direction = str(task["direction"]) - command = int(task["command_u8"]) - zero = self.validation_zero.get(phase) - if zero is None: - raise RuntimeError(f"missing validation zero for {phase}") - centre_measurements: dict[str, float] = {} - centre_zero: dict[str, float] = {} - if self.fit.measurement_mode == "trajectory_center_3d": - centre_measurements = measure_center_trajectory_phase_angles( - self.fit.trajectory_models, - aggregate["tag_translation_xyz_m"], - phase=phase, - ) - centre_zero = measure_center_trajectory_phase_angles( - self.fit.trajectory_models, - zero["tag_translation_xyz_m"], - phase=phase, - ) - measurements: dict[str, float] = {} - predictions: dict[str, float] = {} - errors: dict[str, float] = {} - joint_names = ( - ("thumb_cmc_pitch",) - if phase == PHASE_ROOT - else ("thumb_mcp", "thumb_ip") - ) - for joint_name in joint_names: - pair = { - "thumb_cmc_pitch": PAIR_ROOT, - "thumb_mcp": PAIR_MCP, - "thumb_ip": PAIR_IP, - }[joint_name] - if self.fit.measurement_mode == "trajectory_center_3d": - measurement = ( - centre_measurements[joint_name] - - centre_zero[joint_name] - ) - else: - measurement = self.fit.measure_from_reference( - joint_name, - aggregate["relative_quaternion_xyzw"][pair], - zero["relative_quaternion_xyzw"][pair], - ) - prediction = float( - self.fit.joints[joint_name]["angle_rad"][command] - ) - error = measurement - prediction - measurements[joint_name] = measurement - predictions[joint_name] = prediction - errors[joint_name] = error - self.validation_errors.append(error) - record = { - "kind": "validation", - "phase": phase, - "direction": direction, - "command_u8": command, - "measurements_rad": measurements, - "predictions_rad": predictions, - "errors_rad": errors, - **aggregate, - } - self.validation_records.append(record) - append_jsonl(self.raw_path, record) - - def _finalize(self) -> None: - if self.fit is None: - self.fit = self._fit_calibration_records() - errors = np.abs(np.asarray(self.validation_errors, dtype=float)) - mae = float(np.mean(errors)) if errors.size else float("inf") - p95 = float(np.percentile(errors, 95)) if errors.size else float("inf") - coupling_drift = ( - maximum_center_non_target_drift_rad( - self.records, - self.fit.trajectory_models, - ) - if self.fit.measurement_mode == "trajectory_center_3d" - else maximum_non_target_drift_rad(self.records, self.fit) - ) - passed = ( - errors.size > 0 - and mae <= self.maximum_validation_mae_rad - and p95 <= self.maximum_validation_p95_rad - and coupling_drift <= self.maximum_coupling_drift_rad - and self.fit.ip_coupling["r_squared"] - >= self.minimum_ip_coupling_r_squared - and self.fit.max_monotonic_correction_rad - <= self.maximum_monotonic_correction_rad - and self.fit.max_hysteresis_rad <= self.maximum_hysteresis_rad - ) - final_payload = create_final_payload( - serial_number=self.serial_number, - fit=self.fit, - validation_errors_rad=self.validation_errors, - passed=passed, - ) - validation_payload = { - "records": self.validation_records, - "summary": { - "passed": passed, - "mae_rad": mae, - "p95_rad": p95, - "maximum_non_target_drift_rad": coupling_drift, - "coupling_anomaly": ( - coupling_drift > self.maximum_coupling_drift_rad - ), - "ip_coupling_r_squared": self.fit.ip_coupling["r_squared"], - "maximum_monotonic_correction_rad": ( - self.fit.max_monotonic_correction_rad - ), - "maximum_hysteresis_rad": self.fit.max_hysteresis_rad, - }, - } - atomic_write_json(self.validation_path, validation_payload) - atomic_write_json(self.final_path, final_payload) - self.state = STATE_COMPLETE - self.state_reason = ( - "calibration_passed" if passed else "calibration_completed_quality_failed" - ) - self._write_checkpoint() - self._write_manifest( - { - "completed_at_utc": datetime.now(timezone.utc).isoformat(), - "quality": validation_payload["summary"], - "final_result": str(self.final_path), - } - ) - self.get_logger().info( - f"Calibration complete: passed={passed}; result={self.final_path}" - ) - - def _write_checkpoint(self) -> None: - payload = { - "state": self.state, - "reason": self.state_reason, - "updated_at_utc": datetime.now(timezone.utc).isoformat(), - "scan_records": len( - [ - record - for record in self.records - if record.get("kind", "sample") == "sample" - ] - ), - "active_task": self.active_task, - "remaining_tasks": len(self.task_queue), - } - atomic_write_json(self.checkpoint_path, payload) - - def _validate_existing_session(self) -> None: - if not self.manifest_path.exists(): - return - try: - existing = json.loads( - self.manifest_path.read_text(encoding="utf-8") - ) - except (OSError, json.JSONDecodeError) as error: - raise ValueError("existing session manifest is invalid") from error - if not isinstance(existing, dict): - raise ValueError("existing session manifest must be an object") - expected = { - "serial_number": self.serial_number, - "tag_config": self.tag_config, - "code_sha256": self._code_sha256(), - } - for key, value in expected.items(): - if existing.get(key) != value: - raise ValueError( - f"existing session {key} does not match; start a new session" - ) - sdk = existing.get("sdk", {}) - if sdk.get("baseline_command_u8") != list(BASELINE_COMMAND): - raise ValueError( - "existing session baseline does not match; start a new session" - ) - if int(sdk.get("calibration_speed_u8", -1)) != self.calibration_speed: - raise ValueError( - "existing session calibration speed does not match; " - "start a new session" - ) - existing_mode = existing.get("camera", {}).get( - "angle_estimation_mode", "pnp_3d" - ) - if existing_mode != self.angle_estimation_mode: - raise ValueError( - "existing session angle estimation mode does not match; " - "start a new session" - ) - capture = existing.get("capture", {}) - if capture.get("scan_mode", "point") != self.scan_mode: - raise ValueError( - "existing session scan_mode does not match; start a new session" - ) - if ( - capture.get("continuous_motion_mode", "paced") - != self.continuous_motion_mode - ): - raise ValueError( - "existing session continuous_motion_mode does not match; " - "start a new session" - ) - if int(capture.get("repetitions", -1)) != self.repetitions: - raise ValueError( - "existing session repetitions do not match; start a new session" - ) - if int(capture.get("command_step", -1)) != self.command_step: - raise ValueError( - "existing session command_step does not match; start a new session" - ) - - def _write_manifest(self, updates: dict[str, Any] | None = None) -> None: - existing: dict[str, Any] = {} - if self.manifest_path.exists(): - try: - value = json.loads(self.manifest_path.read_text(encoding="utf-8")) - if isinstance(value, dict): - existing = value - except (OSError, json.JSONDecodeError): - pass - payload = { - "schema_version": 1, - "session_kind": "g20_thumb_front_apriltag", - "created_at_utc": existing.get( - "created_at_utc", datetime.now(timezone.utc).isoformat() - ), - "serial_number": self.serial_number, - "code_sha256": self._code_sha256(), - "tag_config": self.tag_config, - "camera": { - "serial_number": self.camera_serial_number, - "camera_info_topic": self.camera_info_topic, - "image_topic": self.image_topic, - "depth_used_for_angle": False, - "angle_estimation_mode": self.angle_estimation_mode, - "pnp": { - "solver": "opencv_solvePnPGeneric_IPPE_SQUARE", - "input_is_rectified": True, - "uses_detection_corners_directly": True, - "maximum_reprojection_error_px": ( - self.pnp_maximum_reprojection_error_px - ), - "reprojection_tie_px": self.pnp_reprojection_tie_px, - "maximum_pose_jump_rad": ( - self.pnp_maximum_pose_jump_rad - ), - "maximum_translation_jump_m": ( - self.pnp_maximum_translation_jump_m - ), - "maximum_tag_tilt_rad": ( - self.pnp_maximum_tag_tilt_rad - ), - "tracker_reset_seconds": ( - self.pnp_tracker_reset_seconds - ), - "group_relative_rotation_scale_rad": ( - self.pnp_group_relative_rotation_scale_rad - ), - "group_relative_translation_scale_m": ( - self.pnp_group_relative_translation_scale_m - ), - "group_reprojection_weight": ( - self.pnp_group_reprojection_weight - ), - "trajectory_reprojection_scale_px": ( - self.pnp_trajectory_reprojection_scale_px - ), - "rigid_rotation_scale_rad": ( - self.pnp_rigid_rotation_scale_rad - ), - "rigid_translation_scale_m": ( - self.pnp_rigid_translation_scale_m - ), - "rigid_p95_accepted_drift_rad": ( - self.pnp_rigid_p95_accepted_drift_rad - ), - "rigid_maximum_accepted_drift_rad": ( - self.pnp_rigid_maximum_accepted_drift_rad - ), - "rigid_p95_accepted_distance_drift_m": ( - self.pnp_rigid_p95_accepted_distance_drift_m - ), - "rigid_maximum_accepted_distance_drift_m": ( - self.pnp_rigid_maximum_accepted_distance_drift_m - ), - }, - "trajectory_center_3d": { - "uses_tag_centres": True, - "palm_tag_is_translation_anchor": True, - "zero_command_u8": 255, - "passive_ip_estimation": "mcp_mimic_constraint", - "passive_ip_multiplier": self.passive_ip_multiplier, - "maximum_plane_rms_m": ( - self.trajectory_maximum_plane_rms_m - ), - "maximum_radial_rms_m": ( - self.trajectory_maximum_radial_rms_m - ), - "minimum_radius_m": self.trajectory_minimum_radius_m, - "minimum_arc_rad": self.trajectory_minimum_arc_rad, - "maximum_root_role_disagreement_rad": ( - self.trajectory_maximum_root_role_disagreement_rad - ), - "maximum_anchor_drift_m": ( - self.trajectory_maximum_anchor_drift_m - ), - "static_translation_outlier_m": ( - self.trajectory_static_translation_outlier_m - ), - "maximum_static_translation_rms_m": ( - self.trajectory_maximum_static_translation_rms_m - ), - }, - "debug_image_enabled": self.publish_debug_image, - "intrinsics": self.camera_intrinsics, - }, - "rosbag_path": self.rosbag_path, - "sdk": { - "command_topic": self.command_topic, - "state_topic": self.state_topic, - "baseline_command_u8": list(BASELINE_COMMAND), - "calibration_speed_u8": self.calibration_speed, - "commands_enabled": self.commands_enabled, - "latest_info": self.latest_hand_info, - }, - "capture": { - "scan_mode": self.scan_mode, - "continuous_motion_mode": self.continuous_motion_mode, - "repetitions": self.repetitions, - "command_step": self.command_step, - "auto_start_tip": self.auto_start_tip, - "stable_frames": self.stable_frames, - "capture_frames": self.capture_frames, - "minimum_settle_seconds": self.minimum_settle_seconds, - "maximum_stable_spread_rad": self.maximum_stable_spread_rad, - "maximum_stable_translation_spread_m": ( - self.maximum_stable_translation_spread_m - ), - "pose_outlier_threshold_rad": self.pose_outlier_threshold_rad, - "minimum_pose_inlier_rate": self.minimum_pose_inlier_rate, - "pnp_minimum_valid_rate": self.pnp_minimum_valid_rate, - "maximum_state_image_skew_ms": ( - self.maximum_state_image_skew_ns / 1_000_000.0 - ), - "continuous_endpoint_tolerance_u8": ( - self.continuous_endpoint_tolerance_u8 - ), - "continuous_endpoint_hold_seconds": ( - self.continuous_endpoint_hold_seconds - ), - "continuous_minimum_valid_frames": ( - self.continuous_minimum_valid_frames - ), - "continuous_minimum_bins": self.continuous_minimum_bins, - "continuous_maximum_bin_gap": ( - self.continuous_maximum_bin_gap - ), - "continuous_segment_minimum_seconds": ( - self.continuous_segment_minimum_seconds - ), - "continuous_segment_timeout_seconds": ( - self.continuous_segment_timeout_seconds - ), - "continuous_prepare_timeout_seconds": ( - self.continuous_prepare_timeout_seconds - ), - }, - } - existing.update(payload) - payload = existing - if updates: - payload.update(updates) - atomic_write_json(self.manifest_path, payload) - - @staticmethod - def _code_sha256() -> str: - digest = hashlib.sha256() - source_dir = Path(__file__).resolve().parent - for name in ( - "acquisition.py", - "core.py", - "diagnostics.py", - "node.py", - "pnp.py", - "storage.py", - "trajectory.py", - ): - path = source_dir / name - digest.update(name.encode("utf-8")) - digest.update(path.read_bytes()) - return digest.hexdigest() - - def _publish_status(self) -> None: - active = self.active_task or {} - completed_scan = len( - [ - record - for record in self.records - if record.get("kind", "sample") == "sample" - ] - ) - if self.scan_mode == "continuous": - completed_directions = sum( - self._continuous_direction_complete(phase, direction) - for phase in (PHASE_ROOT, PHASE_TIP) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ) - scan_progress = completed_directions / 4.0 - else: - total_scan = len( - scan_targets(self.repetitions, self.command_step) - ) * 2 - scan_progress = completed_scan / total_scan - tag_quality = build_tag_quality_diagnostics( - self.tag_config, - self.latest_tag_qualities, - self.latest_pnp_rejections, - self.latest_pnp_errors_px, - maximum_hamming=self.maximum_hamming, - minimum_decision_margin=self.minimum_decision_margin, - minimum_edge_pixels=self.minimum_edge_pixels, - maximum_reprojection_error_px=( - self.pnp_maximum_reprojection_error_px - ), - ) - reason_zh, action_zh = status_guidance_zh( - self.state, - self.state_reason, - tag_quality, - ) - payload = { - "state": self.state, - "state_zh": STATE_NAMES_ZH.get(self.state, self.state), - "reason": self.state_reason, - "reason_zh": reason_zh, - "action_zh": action_zh, - "commands_enabled": self.commands_enabled, - "calibration_speed_u8": self.calibration_speed, - "angle_estimation_mode": self.angle_estimation_mode, - "passive_ip_estimation": ( - "mcp_mimic_constraint" - if self.angle_estimation_mode == "trajectory_center_3d" - else None - ), - "passive_ip_multiplier": ( - self.passive_ip_multiplier - if self.angle_estimation_mode == "trajectory_center_3d" - else None - ), - "pnp_valid_rate": ( - float(np.mean(self.preflight_pnp_flags)) - if self.preflight_pnp_flags - else None - ), - "pnp_rejections": self.latest_pnp_rejections, - "pnp_rejections_zh": { - role: pnp_rejection_zh(reason) - for role, reason in self.latest_pnp_rejections.items() - }, - "pnp_reprojection_error_px": self.latest_pnp_errors_px, - "tag_quality": tag_quality, - "pnp_branch_corrections": ( - self.pnp_group_tracker.branch_correction_counts - ), - "pnp_trajectory_quality": ( - self.latest_trajectory_branch_quality - ), - "trajectory_center_quality": ( - {} if self.fit is None else self.fit.trajectory_quality - ), - "scan_mode": self.scan_mode, - "continuous_motion_mode": self.continuous_motion_mode, - "command_step": self.command_step, - "auto_start_tip": self.auto_start_tip, - "active_phase": active.get("phase"), - "active_command_u8": active.get("command_u8"), - "active_direction": active.get("direction"), - "active_cycle": active.get("cycle"), - "collector_state": self.collector.state, - "collector_reason": self.collector.reason, - "stable_frames_seen": self.collector.stable_frames_seen, - "stable_spread_deg": { - pair: math.degrees(spread) - for pair, spread in self.collector.stable_spread_rad.items() - }, - "stable_spread_mm": { - pair: 1000.0 * spread - for pair, spread in self.collector.stable_spread_m.items() - }, - "capture_frames_seen": self.collector.capture_frames_seen, - "sweep_collector_state": self.sweep_collector.state, - "sweep_collector_reason": self.sweep_collector.reason, - "sweep_valid_frames_seen": ( - self.sweep_collector.valid_frames_seen - ), - "sweep_state_span_u8": self.sweep_collector.state_span_u8, - "sweep_segment_index": active.get("segment_index"), - "sweep_segment_count": len( - active.get("segment_targets_u8", []) - ), - "scan_progress": scan_progress, - "scan_records": completed_scan, - "session_dir": str(self.session_dir), - } - message = String() - message.data = json.dumps(payload, ensure_ascii=False) - self.status_publisher.publish(message) - text_message = String() - text_message.data = render_status_text_zh( - payload["state_zh"], - reason_zh, - action_zh, - tag_quality, - ) - self.status_text_publisher.publish(text_message) - - -def main(args: list[str] | None = None) -> None: - rclpy.init(args=args) - node: G20ThumbCalibrationNode | None = None - try: - node = G20ThumbCalibrationNode() - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - rclpy.shutdown() diff --git a/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py b/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py index 36d6da7..f2938ba 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py +++ b/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py @@ -2,6 +2,6 @@ import sys -from .models.g20 import offline_replay as _implementation +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20 import offline_replay as _implementation sys.modules[__name__] = _implementation diff --git a/src/linkerhand_calibration/linkerhand_calibration/product.py b/src/linkerhand_calibration/linkerhand_calibration/product.py index 3865cb9..836a750 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/product.py +++ b/src/linkerhand_calibration/linkerhand_calibration/product.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass import hashlib +import math from pathlib import Path import re from typing import Any, Mapping @@ -24,38 +25,50 @@ from .compat import ( ) from .core import CalibrationProfile, ProfileKey from .core.geometry import camera_info_fingerprint, load_camera_extrinsics -from .models import RegisteredProfile, get_default_registry +from linkerhand_calibration.compat.legacy_diagnostic_tools.models import RegisteredProfile, get_default_registry +from .profiles import load_hand_profile @dataclass(frozen=True) class ProductCalibrationContract: """Compatibility facade over one typed, locally registered profile.""" - registered: RegisteredProfile + registered: RegisteredProfile | None = None + declarative: CalibrationProfile | None = None @property def model(self) -> str: - return self.registered.profile.key.model + return self.typed_profile.key.model @property def side(self) -> str: - return self.registered.profile.key.side + return self.typed_profile.key.side @property def layout_id(self) -> str: - return self.registered.profile.key.layout + return self.typed_profile.key.layout @property def typed_profile(self) -> CalibrationProfile: + if self.declarative is not None: + return self.declarative + if self.registered is None: + raise ValueError("product has no calibration profile") return self.registered.profile + def _legacy_binding(self): + # Transitional offline/API compatibility. Product validation itself + # never resolves a Python model or constructs its online state machine. + registered = self.registered or get_default_registry().get(self.typed_profile.key) + return registered.engine + @property def profile(self): - return self.registered.engine.hand_profile + return self._legacy_binding().hand_profile @property def zero_profile(self): - return self.registered.engine.zero_profile + return self._legacy_binding().zero_profile @property def required_tag_ids(self) -> frozenset[int]: @@ -218,6 +231,10 @@ class ProductConfig: sdk_setup: Path | None = None sdk_config: Path | None = None sdk_config_sha256: str = "" + sdk_python_package: Path | None = None + sdk_package_sha256: str = "" + profile_config: Path | None = None + profile_config_sha256: str = "" @property def session_root(self) -> Path: @@ -230,7 +247,7 @@ def load_product_config( workspace: str | Path | None = None, check_can: bool = True, ) -> ProductConfig: - """Load and verify a product against its registered calibration contract.""" + """Load a protected YAML contract without requiring a Python model plugin.""" source = Path(path).expanduser().resolve() if not source.is_file(): raise ValueError(f"product config does not exist: {source}") @@ -244,9 +261,31 @@ def load_product_config( model = profile_key.model side = profile_key.side layout = profile_key.layout - contract = get_product_calibration_contract( - model, side, layout, profile_key.revision - ) + profile_config: Path | None = None + profile_config_hash = "" + if raw.get("profile_config"): + profile_config = _resolve_path( + raw.get("profile_config"), workspace=root, name="profile_config" + ) + if not profile_config.is_file(): + raise ValueError(f"profile config does not exist: {profile_config}") + profile_config_hash = str(raw.get("profile_config_sha256", "")).lower() + if re.fullmatch(r"[0-9a-f]{64}", profile_config_hash) is None: + raise ValueError("profile config expected SHA-256 is invalid") + if sha256_file(profile_config) != profile_config_hash: + raise ValueError("profile config SHA-256 mismatch") + declarative = load_hand_profile(profile_config) + if declarative.key != profile_key: + raise ValueError("declarative profile identity differs from the product") + # Check again after decoding so an edited file cannot be paired with + # the hash of a previous version during a concurrent configuration edit. + if sha256_file(profile_config) != profile_config_hash: + raise ValueError("profile config changed while loading") + contract = ProductCalibrationContract(declarative=declarative) + else: + # Archived configurations without a profile path remain readable by + # offline diagnostics until their legacy format is retired. + contract = get_product_calibration_contract(model, side, layout, profile_key.revision) namespace = str( raw.get("namespace", contract.typed_profile.namespace) ).strip() @@ -267,6 +306,12 @@ def load_product_config( sdk_setup: Path | None = None sdk_config: Path | None = None sdk_config_hash = "" + sdk_python_package, sdk_package_hash = None, "" + if sdk_raw.get("python_package"): + sdk_python_package = _resolve_path(sdk_raw["python_package"], workspace=root, name="sdk.python_package") + sdk_package_hash = str(sdk_raw.get("package_sha256", "")).lower() + if re.fullmatch(r"[0-9a-f]{64}", sdk_package_hash) is None or not sdk_python_package.is_file() or sha256_file(sdk_python_package) != sdk_package_hash: + raise ValueError("vendor Python SDK package SHA-256 mismatch") if sdk_transport == "hcan": sdk_setup = _resolve_path( sdk_raw.get("setup"), workspace=root, name="sdk.setup" @@ -333,9 +378,15 @@ def load_product_config( if actual != expected: raise ValueError(f"{name} SHA-256 mismatch: expected={expected} actual={actual}") _validate_profile_urdf(contract.typed_profile, source_urdf) + if profile_config is not None: + from .profiles.validator import validate_executable_profile + validate_executable_profile(contract.typed_profile, source_urdf) expected_tag_ids = set(contract.required_tag_ids) tag_sizes = _tag_sizes_m_by_id(tag_config) + if profile_config is not None and any(not math.isclose(tag.size_m, tag_sizes.get(tag.tag_id, -1), abs_tol=1e-12) + for view in contract.typed_profile.vision.views for tag in view.tags): + raise ValueError("Profile Tag side lengths differ from the protected detector configuration") if set(tag_sizes) != expected_tag_ids: raise ValueError( f"{model}/{side}/{layout} Tag IDs differ from the registered " @@ -427,4 +478,8 @@ def load_product_config( sdk_setup=sdk_setup, sdk_config=sdk_config, sdk_config_sha256=sdk_config_hash, + sdk_python_package=sdk_python_package, + sdk_package_sha256=sdk_package_hash, + profile_config=profile_config, + profile_config_sha256=profile_config_hash, ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/__init__.py new file mode 100644 index 0000000..0f4e3c6 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/__init__.py @@ -0,0 +1,12 @@ +"""Declarative hand-profile loading and registration.""" + +from .export import dump_hand_profile, hand_profile_payload, write_hand_profile +from .loader import load_hand_profile, load_bundled_hand_profile + +__all__ = [ + "dump_hand_profile", + "hand_profile_payload", + "load_hand_profile", + "load_bundled_hand_profile", + "write_hand_profile", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/export.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/export.py new file mode 100644 index 0000000..cbdabe9 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/export.py @@ -0,0 +1,74 @@ +"""Canonical serialization for reviewed hand profiles. + +The runtime loader is deliberately independent of model Python modules. This +writer exists to migrate the four established profiles without maintaining a +second, hand-written representation of the same contract. +""" + +from __future__ import annotations + +from dataclasses import asdict +from pathlib import Path +from typing import Any, Mapping + +import yaml + +from ..core import CalibrationProfile + + +def _plain(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _plain(item) for key, item in value.items()} + if isinstance(value, (set, frozenset)): + return sorted(_plain(item) for item in value) + if isinstance(value, tuple): + return [_plain(item) for item in value] + if isinstance(value, list): + return [_plain(item) for item in value] + return value + + +def hand_profile_payload(profile: CalibrationProfile) -> dict[str, Any]: + """Return the complete YAML contract accepted by ``load_hand_profile``.""" + command = _plain(asdict(profile.command)) + vision = _plain(asdict(profile.vision)) + for view in vision["views"]: + for tag in view["tags"]: + tag["id"] = tag.pop("tag_id") + payload = { + "schema_version": 1, + "profile_id": profile.key.profile_id, + "namespace": profile.namespace, + "sdk_adapter": profile.sdk_adapter, + "command": command, + "vision": vision, + "motion": _plain(asdict(profile.motion)), + "measurement": _plain(asdict(profile.measurement)), + "zero": _plain(asdict(profile.zero)), + "quality": _plain(asdict(profile.quality)), + "scope": _plain(asdict(profile.scope)), + "artifacts": _plain(asdict(profile.artifacts)), + "acquisition": _plain(asdict(profile.acquisition)), + "urdf": { + "authorized_fields": _plain(profile.urdf_authorized_fields), + }, + "joint_coverage": _plain(profile.joint_coverage), + } + return payload + + +def dump_hand_profile(profile: CalibrationProfile) -> str: + return yaml.safe_dump( + hand_profile_payload(profile), + allow_unicode=True, + sort_keys=False, + default_flow_style=False, + ) + + +def write_hand_profile(profile: CalibrationProfile, path: str | Path) -> None: + """Explicit migration utility; online calibration never rewrites profiles.""" + Path(path).write_text(dump_hand_profile(profile), encoding="utf-8") + + +__all__ = ["dump_hand_profile", "hand_profile_payload", "write_hand_profile"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/loader.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/loader.py new file mode 100644 index 0000000..80d13e5 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/loader.py @@ -0,0 +1,329 @@ +"""Strict YAML loader for a model that reuses the unified engine.""" + +from __future__ import annotations + +from dataclasses import fields +from pathlib import Path +import re +from typing import Any, Mapping + +import yaml +from ..core.domain.profile import ReferenceWaypoint + +from ..core import ( + AcquisitionPolicy, + ArtifactPolicy, + CalibrationProfile, + CommandLayout, + MeasurementPolicy, + MeasurementSpec, + MotionPolicy, + ProfileKey, + QualityPolicy, + ScopePolicy, + TagSpec, + TaskSpec, + ViewSpec, + VisionRigSpec, + ZeroSolvePolicy, + validate_profile, +) + + +def _map(value: Any, name: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"{name} must be a mapping") + return value + + +def _set(value: Any) -> frozenset[str]: + return frozenset(str(item) for item in (value or ())) + + +def _keys(value, allowed, name): + unknown = set(_map(value, name)) - set(allowed) + if unknown: + raise ValueError(f"unknown {name} fields: {sorted(unknown)}") + + +def _validate_keys(payload): + # Misspelled safety/geometry fields must not silently select a default. + _keys(payload, {"schema_version", "profile_id", "namespace", "sdk_adapter", "command", + "vision", "motion", "measurement", "zero", "quality", "scope", "artifacts", + "acquisition", "urdf", "joint_coverage"}, "profile") + for name, contract in (("command", CommandLayout), ("vision", VisionRigSpec), + ("motion", MotionPolicy), ("measurement", MeasurementPolicy), ("zero", ZeroSolvePolicy), + ("quality", QualityPolicy), ("scope", ScopePolicy), ("artifacts", ArtifactPolicy), + ("acquisition", AcquisitionPolicy)): + _keys(payload.get(name, {}), {f.name for f in fields(contract)}, name) + _keys(payload.get("urdf", {}), {"authorized_fields"}, "urdf") + for view in payload.get("vision", {}).get("views", ()): + _keys(view, {"name", "tags"}, "view") + for tag in view.get("tags", ()): + _keys(tag, {"id", "role", "fixed_reference", "link", "size_m"}, "Tag") + for task in payload.get("motion", {}).get("tasks", ()): + _keys(task, {f.name for f in fields(TaskSpec)}, "task") + for waypoint in payload.get("motion", {}).get("resume_verification_waypoints", ()): + _keys(waypoint, {f.name for f in fields(ReferenceWaypoint)}, "reference waypoint") + for name, measurement in payload.get("measurement", {}).get("measurements", {}).items(): + _keys(measurement, {f.name for f in fields(MeasurementSpec)}, "measurement item") + if measurement.get("joint", name) != name: + raise ValueError("measurement key and joint field differ") + + +def load_bundled_hand_profile(layout: str) -> CalibrationProfile: + """Resolve installed or source-tree YAML without constructing a model. + + No cache: a product hash check and the subsequently loaded contract must + see the same on-disk inputs, not a previous in-process Python singleton. + """ + if re.fullmatch(r"[a-z0-9_]+", str(layout)) is None: + raise ValueError("invalid bundled profile layout name") + source = Path(__file__).resolve().parents[2] / "config" / "profiles" / f"{layout}.yaml" + if source.is_file(): + return load_hand_profile(source) + from ament_index_python.packages import get_package_share_directory + return load_hand_profile(Path(get_package_share_directory("linkerhand_calibration")) / "config" / "profiles" / f"{layout}.yaml") + + +def load_hand_profile(path: str | Path) -> CalibrationProfile: + """Load the complete extension contract; no Python model hook is used.""" + source = Path(path).expanduser().resolve() + payload = _map(yaml.safe_load(source.read_text(encoding="utf-8")), str(source)) + if int(payload.get("schema_version", 0)) != 1: + raise ValueError("hand profile schema_version must be 1") + _validate_keys(payload) + command = _map(payload.get("command"), "command") + vision = _map(payload.get("vision"), "vision") + motion = _map(payload.get("motion"), "motion") + measurement = _map(payload.get("measurement"), "measurement") + zero = _map(payload.get("zero"), "zero") + quality = _map(payload.get("quality"), "quality") + scope = _map(payload.get("scope"), "scope") + artifacts = _map(payload.get("artifacts"), "artifacts") + acquisition = _map(payload.get("acquisition", {}), "acquisition") + urdf = _map(payload.get("urdf", {}), "urdf") + + command_layout = CommandLayout( + names=tuple(str(value) for value in command.get("names", ())), + baseline_u8=tuple(int(value) for value in command.get("baseline_u8", ())), + command_index_by_joint={ + str(name): int(index) + for name, index in _map( + command.get("command_index_by_joint", {}), + "command.command_index_by_joint", + ).items() + }, + disabled_indices=frozenset( + int(index) for index in command.get("disabled_indices", ()) + ), + urdf_joint_by_joint={ + str(name): str(joint) + for name, joint in _map( + command.get("urdf_joint_by_joint", {}), + "command.urdf_joint_by_joint", + ).items() + }, + feedback_name_aliases={ + str(name): str(canonical) + for name, canonical in _map( + command.get("feedback_name_aliases", {}), + "command.feedback_name_aliases", + ).items() + }, + speed_slot_by_command_index={ + int(index): int(slot) + for index, slot in _map( + command.get("speed_slot_by_command_index", {}), + "command.speed_slot_by_command_index", + ).items() + }, + unit=str(command.get("unit", "u8")), + baseline=tuple(float(value) for value in command.get("baseline", ())), + lower_bounds=tuple(float(value) for value in command.get("lower_bounds", ())), + upper_bounds=tuple(float(value) for value in command.get("upper_bounds", ())), + feedback_lower_bounds=tuple( + float(value) for value in command.get("feedback_lower_bounds", ()) + ), + feedback_upper_bounds=tuple( + float(value) for value in command.get("feedback_upper_bounds", ()) + ), + feedback_by_index=bool(command.get("feedback_by_index", False)), + sdk_to_joint_direction=tuple(command.get("sdk_to_joint_direction", ())), + maximum_velocity=tuple(float(value) for value in command.get("maximum_velocity", ())), + ) + views = tuple( + ViewSpec( + str(view["name"]), + tuple( + TagSpec( + str(tag["role"]), + int(tag["id"]), + bool(tag.get("fixed_reference", False)), + None if tag.get("link") is None else str(tag["link"]), + float(tag.get("size_m", 0.016)), + ) + for tag in view.get("tags", ()) + ), + ) + for view in vision.get("views", ()) + ) + tasks = tuple( + TaskSpec( + key=str(task["key"]), + view=str(task["view"]), + command_index=int(task["command_index"]), + joints=tuple(str(name) for name in task.get("joints", ())), + auxiliary_commands=tuple( + (int(pair[0]), float(pair[1])) + for pair in task.get("auxiliary_commands", ()) + ), + validation_only=bool(task.get("validation_only", False)), + start_u8=int(task.get("start_u8", 255)), + end_u8=int(task.get("end_u8", 0)), + preflight_speed_u8=( + None if task.get("preflight_speed_u8") is None + else int(task["preflight_speed_u8"]) + ), + formal_speed_u8=( + None if task.get("formal_speed_u8") is None + else int(task["formal_speed_u8"]) + ), + start=None if task.get("start") is None else float(task["start"]), + end=None if task.get("end") is None else float(task["end"]), + preflight_speed=( + None if task.get("preflight_speed") is None + else float(task["preflight_speed"]) + ), + formal_speed=( + None if task.get("formal_speed") is None + else float(task["formal_speed"]) + ), + preparation_groups=tuple(tuple(int(index) for index in group) for group in task.get("preparation_groups", ())), + entry_waypoints=tuple(tuple((int(index), float(value)) for index, value in waypoint) + for waypoint in task.get("entry_waypoints", ())), + ) + for task in motion.get("tasks", ()) + ) + measurements = { + str(name): MeasurementSpec( + joint=str(name), + kind=str(value["kind"]), + view=None if value.get("view") is None else str(value["view"]), + parent_role=( + None if value.get("parent_role") is None + else str(value["parent_role"]) + ), + child_role=( + None if value.get("child_role") is None + else str(value["child_role"]) + ), + validation_source=( + None if value.get("validation_source") is None + else str(value["validation_source"]) + ), + pose_axis_line_required=bool( + value.get("pose_axis_line_required", True) + ), + ) + for name, value in _map( + measurement.get("measurements", {}), + "measurement.measurements", + ).items() + } + profile = CalibrationProfile( + key=ProfileKey.parse(str(payload["profile_id"])), + namespace=str(payload["namespace"]), + command=command_layout, + vision=VisionRigSpec( + views, + str(vision["common_frame"]), + str(vision["extrinsic_reference_view"]), + dict(vision.get("extrinsics_quality_limits", {})), + dict(vision.get("minimum_capture_counts", {})), + ), + motion=MotionPolicy( + tasks, + tuple(tuple(int(item) for item in row) for row in motion.get("preparation_waypoints_u8", ())), + tuple(tuple(int(item) for item in row) for row in motion.get("safe_return_waypoints_u8", ())), + dict(motion.get("speed_parameters", {})), + bool(motion.get("precheck_sweeps", False)), + bool(motion.get("steady_command_checkpoints", False)), + tuple(tuple(int(index) for index in group) for group in motion.get("return_groups", ())), + tuple(ReferenceWaypoint(str(row["key"]), tuple(map(float, row["command"])), + {str(view): tuple(map(int, ids)) for view, ids in row["tag_ids_by_view"].items()}) + for row in motion.get("resume_verification_waypoints", ())), + ), + measurement=MeasurementPolicy( + measurements, + dict(measurement.get("cross_view_sources", {})), + _set(measurement.get("image_curve_joints")), + bool(measurement.get("directional_zero", False)), + bool(measurement.get("cross_view_roll_curve", False)), + bool(measurement.get("stable_cross_view_cone_bias", False)), + _set(measurement.get("candidate_selection_tasks", ())), + str(measurement.get("input_domain", "")), + ), + zero=ZeroSolvePolicy( + _set(zero.get("active_joints")), + _set(zero.get("passive_joints")), + tuple(str(value) for value in zero.get("direct_zero_joints", ())), + tuple(str(value) for value in zero.get("axis_joints", ())), + _set(zero.get("mechanical_endpoint_joints")), + _set(zero.get("post_solve_endpoint_joints")), + dict(zero.get("mimic_source_by_joint", {})), + _set(zero.get("cad_frozen_joints")), + dict(zero.get("endpoint_anchor_by_joint", {})), + _set(zero.get("fitted_mimic_joints")), + dict(zero.get("coupling_model_by_joint", {})), + dict(zero.get("transferred_zero_sources", {})), + dict(zero.get("transferred_mimic_sources", {})), + dict(zero.get("spatial", {})), + ), + quality=QualityPolicy( + tuple(int(value) for value in quality.get("training_cycles", ())), + None if quality.get("holdout_cycle") is None else int(quality["holdout_cycle"]), + _set(quality.get("hard_threshold_keys")), + dict(quality.get("retry_metric_scope", {})), + bool(quality.get("isolated_holdout", False)), + ), + scope=ScopePolicy( + { + str(name): _set(values) + for name, values in _map(scope.get("calibrate_joints", {}), "scope.calibrate_joints").items() + }, + { + str(name): _set(values) + for name, values in _map(scope.get("frozen_joints", {}), "scope.frozen_joints").items() + }, + str(scope.get("default_scope", "full")), + ), + artifacts=ArtifactPolicy( + int(artifacts["output_schema_version"]), + str(artifacts["calibration_filename"]), + str(artifacts["corrected_urdf_filename"]), + _set(artifacts.get("protected_input_fields")), + str(artifacts.get("publication_pointer", "latest_passed")), + _set(artifacts.get("session_compatibility_tokens")), + bool(artifacts.get("publish_corrected_urdf", False)), + ), + acquisition=AcquisitionPolicy(**dict(acquisition)), + sdk_adapter=str(payload["sdk_adapter"]), + urdf_authorized_fields={ + str(name): _set(fields) + for name, fields in _map( + urdf.get("authorized_fields", {}), "urdf.authorized_fields" + ).items() + }, + joint_coverage={ + str(name): str(value) + for name, value in _map( + payload.get("joint_coverage", {}), "joint_coverage" + ).items() + }, + ) + validate_profile(profile) + return profile + + +__all__ = ["load_hand_profile", "load_bundled_hand_profile"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/observations.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/observations.py new file mode 100644 index 0000000..0670d71 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/observations.py @@ -0,0 +1,40 @@ +"""Compile one physical Tag-to-link binding from Profile and CAD topology.""" + +from __future__ import annotations + +from ..core.domain.profile import CalibrationProfile +from ..core.urdf.kinematics import UrdfKinematicModel + + +def compile_tag_links(profile: CalibrationProfile, model: UrdfKinematicModel) -> dict[str, str]: + """A shared Tag belongs to the deepest observed link on a single chain. + + Example: the same Tag can observe a root axis with distal axes held and a + distal axis with the root held. It still has only ONE physical mounting. + Ambiguous branches require an explicit ``TagSpec.link``; no name guessing. + """ + result = {} + for view in profile.vision.views: + for tag in view.tags: + if tag.link is not None: + if tag.link not in model.links: + raise ValueError(f"Tag references missing URDF link:{tag.role}:{tag.link}") + result[tag.role] = tag.link + continue + if tag.fixed_reference: + result[tag.role] = model.root_link + continue + candidates = { + profile.command.urdf_joint_by_joint.get(name, name) + for name, spec in profile.measurement.measurements.items() + if spec.view == view.name and spec.child_role == tag.role + and spec.kind != "axis_cross_view_validation" + } + if not candidates or not candidates <= model.joints.keys(): + raise ValueError(f"Tag link cannot be derived; declare link explicitly:{tag.role}") + descendants = [name for name in candidates + if candidates <= {joint.name for joint in model._chain(name)}] + if len(descendants) != 1: + raise ValueError(f"Tag is assigned to different kinematic branches:{tag.role}") + result[tag.role] = model.joints[descendants[0]].child + return result diff --git a/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py b/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py new file mode 100644 index 0000000..5e02ef6 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/profiles/validator.py @@ -0,0 +1,55 @@ +"""Validate executable mathematics and CAD bindings before opening hardware.""" + +from ..core.domain.profile import validate_profile +from ..core.fitting.session import compile_spatial_profile +from ..core.urdf.kinematics import UrdfKinematicModel +from .observations import compile_tag_links + + +def validate_executable_profile(profile, source_urdf): + validate_profile(profile) + if not profile.command.sdk_to_joint_direction: + raise ValueError("executable Profile must declare fixed SDK-to-joint directions") + if any(sum(tag.fixed_reference for tag in view.tags) != 1 for view in profile.vision.views): + raise ValueError("each capture view must declare exactly one fixed reference Tag") + model = UrdfKinematicModel(source_urdf) + spatial = compile_spatial_profile(profile) + links = compile_tag_links(profile, model) + for joint, observer in spatial.offset_observer_joint.items(): + if joint == observer: + raise ValueError(f"absolute_zero_unobservable:{joint}:a free Tag on its own hinge is not a CAD datum") + measured = {joint for task in profile.motion.tasks for joint in task.joints} + if not measured <= model.joints.keys() or not profile.zero.active_joints <= model.joints.keys(): + raise ValueError("Profile joint keys must name URDF joints; SDK names belong in command.names") + for name, spec in profile.measurement.measurements.items(): + if spec.kind not in {"relative_rotation", "urdf_axis_chain", "curve", "axis_cross_view_validation"}: + raise ValueError(f"unsupported measurement primitive:{name}:{spec.kind}") + if not set(profile.zero.axis_joints) <= measured: + raise ValueError("spatial observer axes require declared measured tasks") + if profile.artifacts.output_schema_version != 2: + raise ValueError("production calibration requires unified output_schema_version: 2; legacy formats are read-only") + for target, source in profile.zero.mimic_source_by_joint.items(): + if target not in model.joints or model.joints[target].mimic_joint != source: + raise ValueError(f"Profile mimic topology differs from CAD:{target}") + if not set(profile.urdf_authorized_fields) <= model.joints.keys(): + raise ValueError("URDF authorization references a missing joint") + conflicts = [] + for name, joint in model.joints.items(): + if joint.mimic_joint is None or joint.lower is None: + continue + parent = model.joints[joint.mimic_joint] + if parent.lower is None: + continue + lo, hi = sorted((parent.lower*joint.mimic_multiplier+joint.mimic_offset, + parent.upper*joint.mimic_multiplier+joint.mimic_offset)) + if lo < joint.lower-1e-9 or hi > joint.upper+1e-9: + conflicts.append({"joint": name, "source_joint": joint.mimic_joint, + "reachable_rad": [lo, hi], "cad_limits_rad": [joint.lower, joint.upper], + "measured_mimic_authorized": name in profile.zero.fitted_mimic_joints}) + return {"profile_id": profile.key.profile_id, "tag_links": links, + "zero_observers": dict(spatial.offset_observer_joint), + "cad_retained": sorted(profile.zero.cad_frozen_joints), + "transferred": dict(profile.zero.transferred_zero_sources), + "cad_constraint_conflicts": conflicts, + "observability": "structural_checks_only; numerical_rank_and_holdout_required", + "arbitrary_multiaxis_validated": False} diff --git a/src/linkerhand_calibration/linkerhand_calibration/publication.py b/src/linkerhand_calibration/linkerhand_calibration/publication.py index 4f836d4..0a2e6d7 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/publication.py +++ b/src/linkerhand_calibration/linkerhand_calibration/publication.py @@ -2,6 +2,6 @@ import sys -from .models.g20 import publication as _implementation +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20 import publication as _implementation sys.modules[__name__] = _implementation diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/__init__.py index d8ad4cf..eaf337e 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/__init__.py @@ -1,22 +1,39 @@ -"""Common calibration engine; model registry imports remain lazy.""" +"""Common calibration runtime, independent of model registries.""" -from .controller import ControllerSnapshot, SessionController, SessionState from .engine import ( ACQUISITION_POLICY_VERSION, CalibrationEngine, - CalibrationResult, ScanUnit, SweepQuality, ) - - -def build_session_controller(*args, **kwargs): - # Avoid a registry/runtime import cycle while preserving the public API. - from .runner import build_session_controller as build - return build(*args, **kwargs) +from .reference_lock import ReferenceLock, ReferenceMovement +from .resume import ( + ResumeDecision, + ResumeFingerprint, + ResumeVerifier, + TagPoseFingerprint, +) +from .safety import SafetyDecision, SafetyPolicy, SafetySample +from .session import CalibrationPhase, CalibrationSession, SessionAction +from .trajectory import ( + AvoidanceArrival, + avoidance_arrival, + build_calibration_motion_command, + build_calibration_preparation_waypoints, + build_calibration_return_waypoints, + cosine_position_trajectory_u8, + smoothstep_position, +) __all__ = [ - "ACQUISITION_POLICY_VERSION", "CalibrationEngine", "CalibrationResult", - "ControllerSnapshot", "ScanUnit", "SessionController", "SessionState", - "SweepQuality", "build_session_controller", + "ACQUISITION_POLICY_VERSION", "CalibrationEngine", + "CalibrationPhase", "CalibrationSession", + "ReferenceLock", "ReferenceMovement", "ResumeDecision", + "ResumeFingerprint", "ResumeVerifier", "SafetyDecision", "SafetyPolicy", + "SafetySample", "ScanUnit", "SessionAction", + "SweepQuality", "TagPoseFingerprint", + "AvoidanceArrival", "avoidance_arrival", "cosine_position_trajectory_u8", + "build_calibration_motion_command", "build_calibration_preparation_waypoints", + "build_calibration_return_waypoints", + "smoothstep_position", ] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/acquisition.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/acquisition.py new file mode 100644 index 0000000..0b7ad99 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/acquisition.py @@ -0,0 +1,123 @@ +"""Retained image selection shared by online finalizers and offline replay.""" + +from __future__ import annotations + +import math +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +from ..core.domain.profile import CalibrationProfile +from ..core.fitting.observed_motion import image_identity + + +def load_capture(path): + rows = [] + with Path(path).open(encoding="utf-8") as stream: + for number, line in enumerate(stream, 1): + if not line.strip(): + continue + try: + row = json.loads(line) + except json.JSONDecodeError as error: + raise ValueError(f"invalid calibration JSONL at line {number}") from error + if not isinstance(row, dict): + raise ValueError(f"calibration JSONL record is not an object at line {number}") + rows.append(row) + return rows + + +def accepted_joint_records(profile: CalibrationProfile, records: Sequence[Mapping[str, Any]], + *, directions_are_task_relative: bool = False, sample_phase="sweep"): + """Select newest attempt by scan unit, preserve complete native evidence. + + Never synthesize identities from row number, SDK value or a fitted curve. + The same camera frame can observe several joints, but only once per joint. + """ + expected = {name for name, spec in profile.measurement.measurements.items() + if spec.view is not None and spec.kind != "axis_cross_view_validation"} + samples = [row for row in records if row.get("joint") in expected and "relative_quaternion_xyzw" in row + and row.get("sample_phase", "sweep") == sample_phase] + def unit(row): + return str(row["task_name"]), int(row["cycle"]), str(row["direction"]) + latest = {} + for row in records: + if not all(key in row for key in ("task_name", "cycle", "direction")): + continue + key = unit(row) + latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1))) + selected = {name: [] for name in expected} + seen = set() + tasks = {task.key: task for task in profile.motion.tasks} + for raw in samples: + if int(raw.get("attempt", 1)) != latest[unit(raw)]: + continue + row = dict(raw) + task = tasks.get(str(row["task_name"])) + if task is None or row["joint"] not in task.joints: + raise ValueError("retained observation is not authorized by its task") + if directions_are_task_relative and task.end_value > task.start_value: + row["direction"] = {"increasing": "decreasing", "decreasing": "increasing"}[row["direction"]] + row["sample_id"] = image_identity(row) + identity = (row["joint"], row["sample_id"]) + if identity in seen: + raise ValueError(f"duplicate retained image:{identity}") + seen.add(identity) + if profile.command.unit == "u8": + value = float(row[profile.curve_input_domain]) + if not math.isfinite(value) or not 0 <= value <= 255: + raise ValueError("retained feedback outside byte domain") + # Raw command and feedback are immutable, distinct observations. + # Quantization belongs to the numerical primitive, not capture. + else: + value = float(row["feedback_rad"]) + index = task.command_index + if not math.isfinite(value) or not profile.command.minimum_feedback_values[index] <= value <= profile.command.maximum_feedback_values[index]: + raise ValueError("retained feedback outside physical SDK domain") + if profile.curve_input_domain.startswith("command_"): + command = float(row["command_rad"]) + if not math.isfinite(command) or not profile.command.minimum_values[index] <= command <= profile.command.maximum_values[index]: + raise ValueError("retained command outside SDK domain") + row["state_rad"] = list(row["command_vector_rad"]) + selected[str(row["joint"])].append(row) + return selected + + +def accepted_secondary_records(profile, records, *, directions_are_task_relative=False, sample_phase="sweep"): + """Select secondary observations without dropping their image/pose evidence. + + Secondary rows use ``observation_joint`` to identify the scanned joint; + they must never be mixed into its primary-view curve. + """ + tasks = {task.key: task for task in profile.motion.tasks} + samples = [dict(row) for row in records if row.get("observation_joint") in profile.measurement.cross_view_sources + and row.get("sample_phase", "sweep") == sample_phase] + latest = {} + for row in records: + if not all(key in row for key in ("task_name", "cycle", "direction")): + continue + key = (row["task_name"], int(row["cycle"]), row["direction"]) + latest[key] = max(latest.get(key, 0), int(row.get("attempt", 1))) + output, seen = {}, set() + for row in samples: + key = (row["task_name"], int(row["cycle"]), row["direction"]) + if int(row.get("attempt", 1)) != latest[key]: + continue + name = row["observation_joint"] + task = tasks.get(str(row["task_name"])) + if task is None or name not in task.joints: + raise ValueError("secondary observation is not authorized by its task") + spec = profile.measurement.measurements[profile.measurement.cross_view_sources[name]] + if row.get("view") != spec.view: + raise ValueError("secondary observation has a different camera view") + row["sample_id"] = image_identity(row) + identity = (name, row["sample_id"]) + if identity in seen: + raise ValueError("duplicate secondary observation image") + seen.add(identity) + if directions_are_task_relative and task.end_value > task.start_value: + row["direction"] = {"increasing": "decreasing", "decreasing": "increasing"}[row["direction"]] + if profile.curve_input_domain.startswith("command_"): + row[f"state_{profile.command.unit}"] = list(row[f"command_vector_{profile.command.unit}"]) + output.setdefault(name, []).append(row) + return output diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/__init__.py index f1ec941..cdff85b 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/__init__.py @@ -1,5 +1,13 @@ """Camera, detector, and hand-SDK runtime adapters.""" from .base import HardwareHealth, ProfileSdkAdapter, SdkAdapter +from .legacy_byte_sdk import LegacyByteSdkAdapter +from .o12_hcan_sdk import O12HcanSdkAdapter -__all__ = ["HardwareHealth", "ProfileSdkAdapter", "SdkAdapter"] +__all__ = [ + "HardwareHealth", + "LegacyByteSdkAdapter", + "O12HcanSdkAdapter", + "ProfileSdkAdapter", + "SdkAdapter", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/base.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/base.py index 5ed9b35..107aecc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/base.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/base.py @@ -19,10 +19,17 @@ class HardwareHealth: connected: bool position_mode: bool active_faults: tuple[str, ...] = () + feedback_fresh: bool = True + diagnostic: str = "" @property def safe(self) -> bool: - return self.connected and self.position_mode and not self.active_faults + return ( + self.connected + and self.position_mode + and self.feedback_fresh + and not self.active_faults + ) @runtime_checkable @@ -49,6 +56,8 @@ class SdkAdapter(Protocol): def set_speed(self, command_index: int, speed: float) -> None: ... + def initial_command(self, feedback: Sequence[float]) -> tuple[float, ...]: ... + class ProfileSdkAdapter: """Shared profile-driven parsing and physical-domain validation. @@ -61,12 +70,20 @@ class ProfileSdkAdapter: def __init__(self, command_layout: CommandLayout) -> None: self.command_layout = command_layout + def initial_command(self, feedback: Sequence[float]) -> tuple[float, ...]: + """Protocols must explicitly declare how to start from observed pose.""" + raise NotImplementedError("SDK adapter has no safe initial-command conversion") + def parse_feedback( self, names: Sequence[str], values: Sequence[float] ) -> tuple[float, ...] | None: layout = self.command_layout if len(values) != layout.command_count: return None + if names and not layout.feedback_by_index and ( + len(names) != len(values) or len(set(names)) != len(names) + ): + return None if names and not layout.feedback_by_index: by_name = dict(zip((str(name) for name in names), values)) for alias, canonical in layout.feedback_name_aliases.items(): @@ -74,9 +91,15 @@ class ProfileSdkAdapter: by_name[canonical] = by_name[alias] if any(name not in by_name for name in layout.names): return None - parsed = tuple(float(by_name[name]) for name in layout.names) + try: + parsed = tuple(float(by_name[name]) for name in layout.names) + except (ValueError, TypeError, OverflowError): + return None else: - parsed = tuple(float(value) for value in values) + try: + parsed = tuple(float(value) for value in values) + except (ValueError, TypeError, OverflowError): + return None return parsed if all(math.isfinite(value) for value in parsed) else None def validate_command(self, values: Sequence[float]) -> tuple[float, ...]: diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/legacy_byte_sdk.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/legacy_byte_sdk.py new file mode 100644 index 0000000..7ae71ff --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/legacy_byte_sdk.py @@ -0,0 +1,51 @@ +"""Common adapter for existing byte-command SDK transports.""" + +from __future__ import annotations + +from typing import Callable, Sequence +import math + +from ...core import CommandLayout +from .base import HardwareHealth, ProfileSdkAdapter + + +class LegacyByteSdkAdapter(ProfileSdkAdapter): + """Bind profile validation to arbitrary ROS/vendor transport callbacks.""" + + def __init__( + self, + command_layout: CommandLayout, + *, + publish: Callable[[tuple[float, ...]], None], + set_speed_callback: Callable[[int, float], None], + health_callback: Callable[[], HardwareHealth], + ) -> None: + if command_layout.unit != "u8": + raise ValueError("legacy byte adapter requires a u8 command layout") + super().__init__(command_layout) + self._publish = publish + self._set_speed = set_speed_callback + self._health = health_callback + + def publish_position(self, values: Sequence[float]) -> None: + validated = self.validate_command(values) + self._publish(tuple(float(round(value)) for value in validated)) + + def set_speed(self, command_index: int, speed: float) -> None: + index = int(command_index) + if not 0 <= index < self.command_layout.command_count: + raise ValueError("speed command index is out of range") + numeric = float(speed) + if not math.isfinite(numeric) or not 0 < numeric <= 255: + raise ValueError("byte SDK speed must be in (0, 255]") + slot = self.command_layout.speed_slot_by_command_index.get(index, index) + self._set_speed(int(slot), float(round(numeric))) + + def health(self) -> HardwareHealth: + return self._health() + + def initial_command(self, feedback): + return self.validate_command(tuple(float(round(v)) for v in feedback)) + + +__all__ = ["LegacyByteSdkAdapter"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_bridge.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_bridge.py new file mode 100644 index 0000000..0296257 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_bridge.py @@ -0,0 +1,118 @@ +"""Thin HCAN transport: independent readback, no calibration business logic. + +Only this process opens HCAN. Before the first explicit position message it +calls read methods only; it never moves to a presumed zero to obtain feedback. +""" + +import hashlib +import json +import math +from pathlib import Path +import time + + +ERROR_FIELDS = ("stalled", "overheat", "over_current", "motor_except", "commu_except") + + +def read_health(hand): + modes = tuple(int(v) for v in hand.get_all_control_modes()) + errors = tuple(hand.get_all_error_reports()) + if len(modes) != 12 or len(errors) != 12: + raise ValueError("SDK returned an incomplete health report") + codes = tuple(sum(1 << i for i, field in enumerate(ERROR_FIELDS) if bool(getattr(report, field))) + for report in errors) + return {"position_mode": all(mode == 0 for mode in modes), "modes": modes, + "error_codes": codes, "active_faults": [f"channel={i}:{ERROR_FIELDS[bit]}" + for i, code in enumerate(codes) for bit in range(4) if code & (1 << bit)], + "communication_latches": [i for i, code in enumerate(codes) if code & 16], + "temperature_policy": "vendor_error_bit1_overheat"} + + +def main(args=None): + import rclpy + from rclpy.node import Node + from sensor_msgs.msg import JointState + from std_msgs.msg import String + import yaml + from .vendor_package import load_vendor_sdk + + class Bridge(Node): + def __init__(self): + super().__init__("o12_sdk_bridge") + for name, default in {"vendor_config": "", "vendor_config_sha256": "", + "vendor_python_package": "", "vendor_package_sha256": "", "hand_type": "right", + "topic_prefix": "/o12/right", "readback_hz": 50.0}.items(): + self.declare_parameter(name, default) + value = lambda name: self.get_parameter(name).value + config = Path(value("vendor_config")) + if hashlib.sha256(config.read_bytes()).hexdigest() != value("vendor_config_sha256"): + raise ValueError("vendor connection configuration hash mismatch") + sdk = load_vendor_sdk(value("vendor_python_package"), value("vendor_package_sha256")) + side = str(value("hand_type")) + if side not in {"left", "right"}: + raise ValueError("invalid SDK hand side") + connection = yaml.safe_load(config.read_text())["/**"]["ros__parameters"][side+"_hand"] + if connection["connection_type"] != "hcan": + raise ValueError("HCAN adapter received a different transport") + self.hand = sdk.OmniHandPro2025.create_hand_by_hcan( + hand_type=sdk.HandType.LEFT if side == "left" else sdk.HandType.RIGHT, + hand_device_id=int(connection["hand_device_id"]), + canfd_device_id=int(connection["canfd_device_id"]), + canfd_channel_id=int(connection["canfd_channel_id"])) + self.hand.set_request_interval(int(connection.get("request_interval_ms", 0))) + self.hand.set_frame_recv_timeout(int(connection.get("frame_recv_timeout_ms", 100))) + if not self.hand.init(): + raise RuntimeError("HCAN initialization failed") + prefix = str(value("topic_prefix")).rstrip("/") + self.state_pub = self.create_publisher(JointState, prefix+"/joint_states", 10) + self.health_pub = self.create_publisher(String, prefix+"/calibration_health", 10) + self.create_subscription(JointState, prefix+"/joint_cmd", self.command, 1) + self.pending, self.pending_at, self.health_at = None, 0.0, 0.0 + self.health = None + self.invalid_health_since = None + self.create_timer(1.0/float(value("readback_hz")), self.tick) + + def command(self, message): + values = tuple(float(v) for v in message.position) + if len(values) != 12 or not all(math.isfinite(v) for v in values): + self.get_logger().error("rejected malformed SDK position vector") + return + self.pending, self.pending_at = values, time.monotonic() + + def tick(self): + now = time.monotonic() + if now-self.health_at >= .5: + try: + self.health = read_health(self.hand) + self.invalid_health_since = None + except (ValueError, TypeError, AttributeError) as error: + if self.invalid_health_since is None: + self.invalid_health_since = now + if now-self.invalid_health_since >= 1.0: + self.health = {"position_mode": False, "active_faults": ["health_report_unparseable"], "diagnostic": str(error)} + self.health_at = now + if self.health is not None: + report = String() + report.data = json.dumps(self.health) + self.health_pub.publish(report) + if (self.pending is not None and now-self.pending_at <= 1.0 and self.health is not None + and self.health["position_mode"] and not self.health["active_faults"]): + self.hand.set_all_active_joint_angles(list(self.pending)) + self.pending = None + values = tuple(float(v) for v in self.hand.get_all_active_joint_angles()) + if len(values) == 12 and all(math.isfinite(v) for v in values): + message = JointState() + message.header.stamp = self.get_clock().now().to_msg() + # Product-fixed order, intentionally no SDK-generated names. + message.position = list(values) + self.state_pub.publish(message) + + rclpy.init(args=args) + node = None + try: + node = Bridge() + rclpy.spin(node) + finally: + if node is not None: + node.destroy_node() + rclpy.shutdown() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_hcan_sdk.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_hcan_sdk.py new file mode 100644 index 0000000..0b904f4 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/o12_hcan_sdk.py @@ -0,0 +1,90 @@ +"""Physical-angle HCAN adapter used by O12 without leaking into the engine.""" + +from __future__ import annotations + +from typing import Callable, Sequence +import math +import time + +from ...core import CommandLayout +from .base import HardwareHealth, ProfileSdkAdapter + + +class O12HcanSdkAdapter(ProfileSdkAdapter): + def __init__( + self, + command_layout: CommandLayout, + *, + publish: Callable[[tuple[float, ...]], None], + set_speed_callback: Callable[[int, float], None], + health_callback: Callable[[], HardwareHealth], + clock: Callable[[], float] = time.monotonic, + health_timeout_seconds: float = 1.0, + ) -> None: + if command_layout.unit != "rad": + raise ValueError("O12 HCAN adapter requires a radian command layout") + if not command_layout.feedback_by_index: + raise ValueError("O12 feedback must use the fixed SDK array order") + super().__init__(command_layout) + self._publish = publish + self._set_speed = set_speed_callback + self._health = health_callback + if not math.isfinite(health_timeout_seconds) or health_timeout_seconds <= 0: + raise ValueError("health timeout must be finite and positive") + self._clock = clock + self._health_timeout_seconds = health_timeout_seconds + self._invalid_health_since: float | None = None + self._last_health = HardwareHealth(False, False, diagnostic="waiting") + + def publish_position(self, values: Sequence[float]) -> None: + self._publish(self.validate_command(values)) + + def initial_command(self, feedback): + # Vendor get/set_all_active_joint_angles use the same nominal radian + # coordinates. This initializes a smooth trajectory AFTER Start; + # it is not a baseline command sent during preview, nor a calibration + # correction inferred from command/feedback error. + layout = self.command_layout + if len(feedback) != layout.command_count or any(not math.isfinite(v) or not lo <= v <= hi + for v, lo, hi in zip(feedback, layout.minimum_feedback_values, layout.maximum_feedback_values)): + raise ValueError("cannot initialize motion from invalid physical feedback") + return self.validate_command(tuple(min(hi, max(lo, value)) + for value, lo, hi in zip(feedback, layout.minimum_values, layout.maximum_values))) + + def set_speed(self, command_index: int, speed: float) -> None: + index = int(command_index) + if not 0 <= index < self.command_layout.command_count: + raise ValueError("speed command index is out of range") + if not math.isfinite(float(speed)) or float(speed) <= 0.0: + raise ValueError("physical speed must be positive") + self._set_speed(index, float(speed)) + + def health(self) -> HardwareHealth: + """Retain good health through transient parse errors, using elapsed time. + + Three malformed packets in a single callback burst are not evidence of + a persistent fault. A valid active fault is still returned immediately. + """ + try: + health = self._health() + if not isinstance(health, HardwareHealth): + raise TypeError("SDK health callback returned an invalid report") + except (TypeError, ValueError, IndexError) as error: + now = self._clock() + if self._invalid_health_since is None: + self._invalid_health_since = now + if now - self._invalid_health_since < self._health_timeout_seconds: + return self._last_health + return HardwareHealth( + connected=self._last_health.connected, + position_mode=self._last_health.position_mode, + active_faults=("health_report_unparseable",), + feedback_fresh=self._last_health.feedback_fresh, + diagnostic=str(error), + ) + self._invalid_health_since = None + self._last_health = health + return health + + +__all__ = ["O12HcanSdkAdapter"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_binding.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_binding.py new file mode 100644 index 0000000..0c17292 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_binding.py @@ -0,0 +1,67 @@ +"""SDK binding with explicit feedback freshness, clock and health input ports.""" + +from dataclasses import dataclass +import json +from typing import Callable + +from .base import HardwareHealth +from .legacy_byte_sdk import LegacyByteSdkAdapter +from .o12_hcan_sdk import O12HcanSdkAdapter + + +@dataclass(frozen=True) +class SdkBindingPorts: + feedback_fresh: Callable[[], bool] + monotonic: Callable[[], float] + subscribe_health: Callable[[Callable[[str], None]], None] + + +def _byte(profile, ports, publish, set_speed): + return LegacyByteSdkAdapter(profile.command, publish=publish, set_speed_callback=set_speed, + health_callback=lambda: HardwareHealth(True, True, feedback_fresh=ports.feedback_fresh(), + diagnostic="position_protocol;no_separate_fault_telemetry")) + + +@dataclass +class HealthReport: + payload: dict | None = None + received_at: float = 0.0 + invalid: bool = False + + +def _hcan(profile, ports, publish, set_speed): + latest = HealthReport() + + def receive(payload: str): + try: + value = json.loads(payload) + if not isinstance(value.get("position_mode"), bool) or not isinstance(value.get("active_faults"), list): + raise ValueError("malformed SDK health") + latest.payload, latest.received_at, latest.invalid = value, ports.monotonic(), False + except (ValueError, TypeError, AttributeError): + latest.invalid = True + + ports.subscribe_health(receive) + + def health(): + value = latest.payload + if value is None: + return HardwareHealth(True, False, feedback_fresh=ports.feedback_fresh(), diagnostic="waiting_for_sdk_health") + if latest.invalid or ports.monotonic()-latest.received_at > 1.0: + raise ValueError("SDK health report is stale or unparseable") + return HardwareHealth(True, value["position_mode"], tuple(map(str, value["active_faults"])), + ports.feedback_fresh(), json.dumps(value, ensure_ascii=False)) + + return O12HcanSdkAdapter(profile.command, publish=publish, set_speed_callback=set_speed, + health_callback=health, clock=ports.monotonic) + + +FACTORIES = {"legacy_byte_sdk": _byte, "o12_hcan_sdk": _hcan} + + +def bind_ros_sdk(profile, ports: SdkBindingPorts, *, publish, set_speed): + try: + factory = FACTORIES[profile.sdk_adapter] + except KeyError as error: + raise ValueError(f"unregistered SDK protocol:{profile.sdk_adapter}") from error + return factory(profile, ports, publish, set_speed) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_topics.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_topics.py new file mode 100644 index 0000000..42ca38d --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/ros_topics.py @@ -0,0 +1,20 @@ +"""Topic spelling belongs to SDK protocols, not calibration scheduling.""" + +from dataclasses import dataclass + +from ...core.domain.profile import CalibrationProfile + + +@dataclass(frozen=True) +class SdkTopics: + command: str + feedback: str + + +def sdk_topics(profile: CalibrationProfile) -> SdkTopics: + prefix, side = f"/{profile.key.model.lower()}", profile.key.side + if profile.sdk_adapter == "legacy_byte_sdk": + return SdkTopics(f"{prefix}/cb_{side}_hand_control_cmd", f"{prefix}/cb_{side}_hand_state") + if profile.sdk_adapter == "o12_hcan_sdk": + return SdkTopics(f"{prefix}/{side}/joint_cmd", f"{prefix}/{side}/joint_states") + raise ValueError(f"SDK adapter has no ROS topic binding: {profile.sdk_adapter}") diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/vendor_package.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/vendor_package.py new file mode 100644 index 0000000..41fe9dd --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/adapters/vendor_package.py @@ -0,0 +1,36 @@ +"""Load the pinned, bundled vendor wheel without installing globally.""" + +import hashlib +import importlib +from pathlib import Path +import sys +import tempfile +import zipfile + + +def load_vendor_sdk(wheel, expected_sha256): + path = Path(wheel).resolve() + if hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha256: + raise ValueError("vendor SDK wheel changed before loading") + abi = f"cp{sys.version_info.major}{sys.version_info.minor}" + if f"-{abi}-{abi}-" not in path.name: + raise ValueError(f"vendor SDK wheel is not built for {abi}") + # Private per-process directory: never trust another run's writable cache + # of native libraries. The original wheel/SDK are never modified. + directory = tempfile.TemporaryDirectory(prefix="calibration-vendor-") + with zipfile.ZipFile(path) as archive: + for member in archive.infolist(): + target = (Path(directory.name)/member.filename).resolve() + if not target.is_relative_to(Path(directory.name)): + raise ValueError("unsafe path in vendor SDK wheel") + archive.extractall(directory.name) + if hashlib.sha256(path.read_bytes()).hexdigest() != expected_sha256: + directory.cleanup() + raise ValueError("vendor SDK wheel changed during loading") + sys.path.insert(0, directory.name) + module = importlib.import_module("omnihand") + if not Path(module.__file__).resolve().is_relative_to(Path(directory.name)): + raise ValueError("an unpinned vendor SDK was already imported") + # Keep libraries mapped from this directory until the process exits. + module._calibration_package_directory = directory + return module diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/__init__.py new file mode 100644 index 0000000..d6d6484 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/__init__.py @@ -0,0 +1,5 @@ +"""Common artifact serialization and atomic publication.""" + +from .publisher import ArtifactRelease, ArtifactPublisher + +__all__ = ["ArtifactPublisher", "ArtifactRelease"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/completion.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/completion.py new file mode 100644 index 0000000..b5b33ad --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/completion.py @@ -0,0 +1,46 @@ +"""Read back the committed release; never re-fit or re-publish in the runner.""" + +from __future__ import annotations + +import json +import hashlib +from pathlib import Path +from typing import Any, Mapping + +from ...product import ProductConfig + + +def finish_online_artifacts( + config: ProductConfig, session: Path, status: Mapping[str, Any], +) -> dict[str, str]: + profile = config.calibration_contract.typed_profile + outputs = status.get("outputs", {}) + json_path = Path(outputs.get("json") or status.get("final_json") or "") + urdf_path = Path(outputs.get("urdf") or status.get("final_urdf") or "") + for path in (json_path, urdf_path): + if not path.is_file() or not path.resolve().is_relative_to(session.resolve()): + raise ValueError(f"node completion refers to a missing/external artifact: {path}") + payload = json.loads(json_path.read_text(encoding="utf-8")) + unified = profile.artifacts.output_schema_version == 2 + if unified and (payload.get("format") != "unified_calibration_v2" or payload.get("schema_version") != 2): + raise ValueError("completed JSON format differs from selected Profile") + if not unified and payload.get("schema_version") != profile.artifacts.output_schema_version: + raise ValueError("completed JSON schema differs from selected Profile") + if not unified and payload.get("quality", {}).get("passed") is not True: + raise ValueError("completed JSON has no passing quality evidence") + manifest = json.loads((session/"release_manifest.json").read_text(encoding="utf-8")) + if unified and (manifest.get("validation", {}).get("final_file_spatial_replay") != "passed" + or not manifest.get("validation", {}).get("steady_command_tag_holdout")): + raise ValueError("completed release lacks independent command and feedback spatial evidence") + for path, name in ((json_path, "calibration_json"), (urdf_path, "corrected_urdf")): + if manifest.get(name) != path.name or manifest.get(name+"_sha256") != hashlib.sha256(path.read_bytes()).hexdigest(): + raise ValueError("completed artifact differs from the validated release manifest") + if unified: + from .reader import load_unified_mapper + load_unified_mapper(json_path, expected_side=profile.key.side) + if manifest.get("validation", {}).get("profile_id") != profile.key.profile_id: + raise ValueError("release evidence belongs to a different Profile") + pointer = config.session_root / profile.artifacts.publication_pointer + if not pointer.is_symlink() or pointer.resolve() != session.resolve(): + raise ValueError("node completion has no verified publication pointer to this session") + return {"JSON": str(json_path), "URDF": str(urdf_path), "发布指针": str(pointer)} diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/controller.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/controller.py new file mode 100644 index 0000000..0d82938 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/controller.py @@ -0,0 +1,76 @@ +"""Background preparation and one-time publication, without session ownership. + +The coordinator holds its state lock before entering this component. The worker +never takes the state lock: it reports stage events through a queue. This keeps +feedback live during fitting and orders cancellation against publication. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from pathlib import Path +from queue import Empty, SimpleQueue +from typing import Any, Callable, Mapping + +from .finalization import finalize_profile_session +from .publisher import ArtifactPublisher +from .worker import FinalizationWorker + + +@dataclass(frozen=True) +class FinalizationInputs: + session_dir: Path + serial_number: str + source_urdf: Path + protected_inputs: Mapping[str, str] + records: tuple[dict[str, Any], ...] + + +class FinalizationProtocolError(RuntimeError): + """The worker's event order does not match the session's acceptance stages.""" + + +class FinalizationController: + def __init__(self, profile, session_dir: Path, *, finalizer=finalize_profile_session): + self.worker = FinalizationWorker() + self._events: SimpleQueue[str] = SimpleQueue() + self._finalizer = partial(finalizer, profile=profile, phase_changed=self._events.put) + self._publisher = ArtifactPublisher(session_dir.parent, profile.artifacts.publication_pointer) + self.started = False + + def start(self, inputs: FinalizationInputs) -> None: + self.worker.start(self._finalizer, session_dir=inputs.session_dir, + serial_number=inputs.serial_number, source_urdf=inputs.source_urdf, + protected_inputs=dict(inputs.protected_inputs), records=inputs.records) + self.started = True + + def drain_events(self, on_event: Callable[[str], None]) -> None: + while True: + try: + event = self._events.get_nowait() + except Empty: + return + on_event(event) + + def poll(self, on_event: Callable[[str], None], authorize: Callable[[], None]): + with self.worker.lock: + try: + self.drain_events(on_event) + except (ValueError, RuntimeError) as error: + raise FinalizationProtocolError(str(error)) from error + + def commit(result): + # The final event may arrive between the first drain and + # Future.done(). Consume it before authorizing the pointer swap. + self.drain_events(on_event) + authorize() + self._publisher.commit(result[2].staged_release) + + return self.worker.finish_if_ready(commit) + + def cancel(self) -> None: + self.worker.cancel() + + def close(self) -> None: + self.worker.close() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/evidence.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/evidence.py new file mode 100644 index 0000000..5f73dab --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/evidence.py @@ -0,0 +1,150 @@ +"""Compile retained capture records into immutable, model-neutral evidence.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Sequence + + +from ...core.domain.profile import CalibrationProfile +from ...core.domain.result import JointMapping, evaluate_mappings +from ...core.fitting.tag_installation import ( + FrozenTagInstallation, TagTrainingPose, fit_tag_installations, matrix_tuple, register_base_translation, +) +from ...core.geometry.extrinsics import transform_matrix +from ...core.urdf.kinematics import UrdfKinematicModel +from ...core.urdf.plan import StandardUrdfPlan +from ...core.urdf.tag_acceptance import TagHoldout +from ...profiles.observations import compile_tag_links + + +@dataclass(frozen=True) +class TagReplayEvidence: + common_from_base: tuple[tuple[float, ...], ...] + installations: Mapping[str, FrozenTagInstallation] + holdout: tuple[TagHoldout, ...] + required_roles: tuple[str, ...] + + +def _pose(value): + if isinstance(value, Mapping): + return matrix_tuple(transform_matrix(value["translation_xyz_m"], value["quaternion_xyzw"])) + return matrix_tuple(value) + + +def prepare_command_holdout(profile, mappings, records): + """Raw steady fourth-cycle poses; no registration or mounting fit here.""" + specs = profile.measurement.measurements + tasks = {task.key: task for task in profile.motion.tasks} + seen, result = set(), [] + for row in records: + if row["cycle"] != 3: + continue + if row.get("sample_phase") != "steady": + raise ValueError("command holdout requires stable command observations") + spec = specs[row["joint"]] + task = tasks[row["task_name"]] + identity = (spec.child_role, row["sample_id"]) + if identity in seen: + continue + seen.add(identity) + directions = {joint: row["direction"] for joint, m in mappings.items() + if m.motor_index == task.command_index} + for joint, mapping in mappings.items(): + previous = row.get("command_direction_by_index", ()) + if mapping.motor_index != task.command_index and len(previous) > mapping.motor_index: + directions[joint] = previous[mapping.motor_index] + result.append(TagHoldout(row["sample_id"], spec.child_role, 3, + tuple(row[f"command_vector_{profile.command.unit}"]), directions, _pose(row["child_pose_common"]))) + return tuple(result) + + +def prepare_tag_replay( + *, profile: CalibrationProfile, source_model: UrdfKinematicModel, + plan: StandardUrdfPlan, output_mappings: Mapping[str, JointMapping], + common_from_base, records: Sequence[Mapping[str, Any]], + directions_are_task_relative: bool = False, +) -> TagReplayEvidence: + """Use fit results for training poses; final validation reads files anew. + + Capture records must already have passed direction selection/quality. + A legacy-label conversion is explicit at this ingestion boundary, never + guessed from model identity. No unknown/missing image becomes evidence. + """ + links = compile_tag_links(profile, source_model) + tasks = {task.key: task for task in profile.motion.tasks} + specs = profile.measurement.measurements + required = {spec.child_role for spec in specs.values() if spec.view is not None} + # Include fixed references when observed, but do not demand they remain + # visible after an avoidance pose occludes them. Their locked-frame + # fingerprint is independently protected by the session reference policy. + required.discard(None) + training, holdout = [], [] + unique = {} + for row in records: + name = str(row.get("joint", "")) + if name not in specs or specs[name].view is None: + continue + if int(row.get("cycle", -1)) not in {0, 1, 2, 3}: + continue + spec = specs[name] + if row.get("task_name") not in tasks: + raise ValueError("Tag replay record has no declared task") + task = tasks[str(row["task_name"])] + role = str(spec.child_role) + if row.get("view") != spec.view: + raise ValueError("Tag replay record has a different view") + identity = str(row.get("sample_id", "")) + if not identity and row.get("image_stamp_ns") is not None: + identity = f"{spec.view}:{int(row['image_stamp_ns'])}" + if not identity or "child_pose_common" not in row: + raise ValueError("Tag replay requires identified common-frame poses") + domain = {value.input_domain for value in output_mappings.values()} + if len(domain) != 1: + raise ValueError("Tag replay requires one declared SDK input domain") + input_domain = next(iter(domain)) + field = f"state_{profile.command.unit}" if input_domain.startswith("feedback_") else f"command_vector_{profile.command.unit}" + sdk = tuple(float(value) for value in row[field]) + direction = str(row["direction"]) + if directions_are_task_relative and task.end_value > task.start_value: + direction = {"increasing": "decreasing", "decreasing": "increasing"}[direction] + directions = {joint: direction for joint, mapping in output_mappings.items() + if mapping.motor_index == task.command_index} + pose = _pose(row["child_pose_common"]) + cycle = int(row["cycle"]) + key = (role, identity) + previous = unique.get(key) + signature = (cycle, sdk, tuple(sorted(directions.items())), pose) + if previous is not None: + if previous != signature: + raise ValueError(f"conflicting duplicate capture:{role}:{identity}") + continue + unique[key] = signature + if cycle == 3: + holdout.append(TagHoldout(identity, role, cycle, sdk, directions, pose)) + continue + angles = evaluate_mappings(output_mappings, sdk, directions) + # Resolve the FITTED linear relation in output coordinates, then + # transform every joint back to CAD exactly once. Using source-CAD + # mimic on output parent angles would miss a*delta_parent. + visiting = set() + def resolve(joint): + if joint in angles: + return angles[joint] + if joint in visiting: + raise ValueError("cyclic fitted mimic relation") + visiting.add(joint) + parent, multiplier, offset = plan.mimic_output[joint] + angles[joint] = multiplier * resolve(parent) + offset + visiting.remove(joint) + return angles[joint] + for joint in plan.mimic_output: + resolve(joint) + cad = {joint: angle + plan.zero_offsets_rad.get(joint, 0.0) for joint, angle in angles.items()} + training.append(TagTrainingPose(identity, role, cycle, pose, cad)) + selected_links = {role: links[role] for role in required} + registered_base = register_base_translation(source_model=source_model, common_from_base=common_from_base, + link_by_role=selected_links, observations=training) + mounts = fit_tag_installations(source_model=source_model, common_from_base=registered_base, + link_by_role=selected_links, observations=training) + return TagReplayEvidence(registered_base, mounts, tuple(holdout), tuple(sorted(required))) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/finalization.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/finalization.py new file mode 100644 index 0000000..f24af80 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/finalization.py @@ -0,0 +1,125 @@ +"""Shared retained-data -> fit -> standard URDF -> read-back -> publication.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Callable + +from ...core.artifacts.storage import atomic_write_json +from ...core.fitting.session import fit_profile_calibration +from ...core.fitting.command_mapping import fit_command_mappings +from ...core.geometry.candidate_selection import resolve_chain_observations +from ...core.geometry.extrinsics import transform_matrix +from ...core.urdf.kinematics import UrdfKinematicModel +from ...core.urdf.result_plan import prepare_standard_result +from ..acquisition import accepted_joint_records, accepted_secondary_records +from .evidence import prepare_tag_replay +from .publisher import ArtifactPublisher, FrozenTagArtifactValidator, StagedRelease +from .serializers.compact_v2 import from_report, REPORT_FILENAME +from .serializers.unified_v1 import serialize as serialize_report +from .standard_loader import validate_with_robot_state_publisher + + +@dataclass(frozen=True) +class CorrectedArtifact: + path: Path + staged_release: StagedRelease + + +def finalize_profile_session(*, profile, session_dir, serial_number, source_urdf, + protected_inputs, records, publish=True, timestamp=None, + standard_loader: Callable = validate_with_robot_state_publisher, + cancelled: Callable[[], bool] = lambda: False, + directions_are_task_relative: bool = False, + phase_changed: Callable[[str], None] = lambda _phase: None): + """No worker may publish after an operator abort. + + The runtime owns cancellation synchronization. This function also checks + immediately before publication and never returns the hand to a pose. + """ + del timestamp + if profile.artifacts.output_schema_version != 2: + raise ValueError("production calibration requires unified output_schema_version: 2; legacy formats are read-only") + directory, source = Path(session_dir).resolve(), Path(source_urdf).resolve() + directory.mkdir(parents=True, exist_ok=True) + def check_cancelled(): + if cancelled(): + raise ValueError("operator_abort:no_artifact_publication") + check_cancelled() + if any(row.get("projection_reprocessing_scope") for row in records): + raise ValueError("partial external projection is diagnostic-only; cannot certify a whole session") + pose_reports = {} + for task in profile.motion.tasks: + if task.key not in profile.measurement.candidate_selection_tasks: + continue + records, report = resolve_chain_observations(records, task_key=task.key, + role_pairs={name: (profile.measurement.measurements[name].parent_role, + profile.measurement.measurements[name].child_role) for name in task.joints}) + pose_reports[task.key] = report + atomic_write_json(directory / "pose_selection_diagnostics.json", pose_reports) + if report["status"] == "legacy_projection_unverified": + raise ValueError("camera_projection_unverified:no_artifact_publication") + accepted = accepted_joint_records(profile, records, directions_are_task_relative=directions_are_task_relative) + secondary = accepted_secondary_records(profile, records, directions_are_task_relative=directions_are_task_relative) + try: + fit = fit_profile_calibration(profile, source, accepted, cross_view_records=secondary) + steady = accepted_joint_records(profile, records, sample_phase="steady", + directions_are_task_relative=directions_are_task_relative) + fit = fit_command_mappings(profile, fit, steady) + except ValueError as error: + atomic_write_json(directory / "fit_diagnostics.json", { + "passed": False, "reason": str(error), "publication_allowed": False}) + raise + check_cancelled() + phase_changed("fit_complete") + prepared = prepare_standard_result(profile, source, protected_inputs["source_urdf_sha256"], fit) + zero = fit.spatial_zero + replay_records = [row for rows in accepted.values() for row in rows] + replay_records.extend(dict(row, joint=profile.measurement.cross_view_sources[name]) + for name, rows in secondary.items() for row in rows) + replay = prepare_tag_replay(profile=profile, source_model=UrdfKinematicModel(source), + plan=prepared.plan, output_mappings=fit.output_mappings, + common_from_base=transform_matrix(zero.base_translation_xyz_m, zero.base_quaternion_xyzw), + records=replay_records) + from .evidence import prepare_command_holdout + steady_secondary = accepted_secondary_records(profile, records, sample_phase="steady", + directions_are_task_relative=directions_are_task_relative) + steady_rows = [r for rows in steady.values() for r in rows] + steady_rows.extend(dict(r, joint=profile.measurement.cross_view_sources[name]) + for name, rows in steady_secondary.items() for r in rows) + command_holdout = prepare_command_holdout(profile, fit.command_mappings, steady_rows) + validator = FrozenTagArtifactValidator(profile.key.profile_id, source, + protected_inputs["source_urdf_sha256"], profile.urdf_authorized_fields, + fit.zero_offsets_rad, replay.common_from_base, replay.installations, + replay.holdout, replay.required_roles, standard_loader, protected_inputs=dict(protected_inputs), + command_observations=command_holdout) + report = serialize_report(profile, fit, prepared, serial_number=serial_number, protected_inputs=protected_inputs) + payload = from_report(report) + naming = {"serial_number": serial_number, "side": profile.key.side, "model": profile.key.model.lower()} + json_path = directory / profile.artifacts.calibration_filename.format(**naming) + urdf_path = directory / profile.artifacts.corrected_urdf_filename.format(**naming) + check_cancelled() + phase_changed("holdout_complete") + prepared.plan.write(source, urdf_path) + atomic_write_json(json_path, payload) + atomic_write_json(directory / REPORT_FILENAME, report) + atomic_write_json(directory / "frozen_tag_installations.json", { + "common_from_base": replay.common_from_base, + "installations": {name: asdict(mount) for name, mount in replay.installations.items()}}) + phase_changed("artifacts_built") + publisher = ArtifactPublisher(directory.parent, profile.artifacts.publication_pointer) + staged = publisher.prepare(session_directory=directory, calibration_json=json_path, + corrected_urdf=urdf_path, validate=validator, cancelled=cancelled) + import json + acceptance = json.loads(staged.validation_json) + atomic_write_json(directory / "standard_urdf_acceptance.json", acceptance) + check_cancelled() + phase_changed("urdf_validated") + if publish: + publisher.commit(staged, cancelled=cancelled) + atomic_write_json(directory / "calibration_summary_zh.json", { + "profile_id": profile.key.profile_id, "result": "PASS", + "publication_evidence": "release_manifest.json and the product publication pointer", + "artifacts": {"json": json_path.name, "urdf": urdf_path.name}, "acceptance": acceptance}) + return payload, fit, CorrectedArtifact(urdf_path, staged) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/publisher.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/publisher.py new file mode 100644 index 0000000..8622d47 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/publisher.py @@ -0,0 +1,307 @@ +"""Atomic JSON/URDF release shared by all output-schema serializers.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +import hashlib +import json +import os +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence +import uuid +import xml.etree.ElementTree as ET + +from ...core.artifacts.storage import atomic_write_json +from ...core.urdf.acceptance import ( + JointHoldout, validate_artifact_structure, validate_standard_urdf_holdout, +) +from ...core.fitting.tag_installation import FrozenTagInstallation +from ...core.urdf.tag_acceptance import TagHoldout, validate_serialized_tag_holdout +from ...core.domain.profile import ProfileKey +from .serializers.compact_v2 import FORMAT as COMPACT_FORMAT, REPORT_FILENAME, from_report + + +def _validate_identity(payload, profile_id): + if payload.get("schema_version") == 4 and "profile_id" not in payload: + # The exact deployed v4 shape has no layout/profile/hash fields. + # The protected caller fixes layout, source geometry and authorization; + # source/JSON/URDF hashes and full identity are kept in the manifest. + key = ProfileKey.parse(profile_id) + if (payload.get("model"), payload.get("side")) == (key.model, key.side): + return + elif payload.get("profile_id") == profile_id: + return + raise ValueError("serialized artifact belongs to another profile") + + +@dataclass(frozen=True) +class StandardArtifactValidator: + """Release evidence independent of the serializer and its fitted payload. + + Construct this from the locked profile and training-frozen measurement + model. A serializer cannot choose its own authorized fields, truth angles, + required joints or protected source hash. + """ + + profile_id: str + source_urdf: Path + source_sha256: str + authorized_fields: Mapping[str, Sequence[str]] + zero_offsets_rad: Mapping[str, float] + observations: tuple[JointHoldout, ...] + required_joints: tuple[str, ...] + required_pose_joints: tuple[str, ...] + standard_loader: Callable[[Path], None] + minimum_samples: int = 40 + + def __call__(self, json_path: Path, urdf_path: Path) -> Mapping[str, Any]: + payload = json.loads(json_path.read_text(encoding="utf-8")) + _validate_identity(payload, self.profile_id) + if not self.required_pose_joints: + raise ValueError("final URDF acceptance requires independent spatial observations") + changes = validate_artifact_structure(source_urdf=self.source_urdf, + corrected_urdf=urdf_path, source_sha256=self.source_sha256, + authorized_fields=self.authorized_fields, payload=payload, + zero_offsets_rad=self.zero_offsets_rad) + metrics = validate_standard_urdf_holdout(corrected_urdf=urdf_path, + payload=payload, observations=self.observations, + required_joints=self.required_joints, required_pose_joints=self.required_pose_joints, + minimum_samples=self.minimum_samples) + # Production injects the standard parser/robot_state_publisher check; + # offline tests inject an independent parser, never a hardware node. + self.standard_loader(urdf_path) + evidence = json.dumps([asdict(row) for row in self.observations], + sort_keys=True, separators=(",", ":"), allow_nan=False).encode() + return { + "acceptance": "serialized_standard_urdf_v1", + "profile_id": self.profile_id, + "source_urdf_sha256": self.source_sha256, + "holdout_evidence_sha256": hashlib.sha256(evidence).hexdigest(), + "holdout_cycle": 3, + "holdout_by_joint": {name: asdict(value) for name, value in metrics.items()}, + "spatial_holdout_joints": list(self.required_pose_joints), + "structured_changes": list(changes), + } + + +@dataclass(frozen=True) +class FrozenTagArtifactValidator: + """Final-file gate for raw Tag observations with training-frozen mounts.""" + + profile_id: str + source_urdf: Path + source_sha256: str + authorized_fields: Mapping[str, Sequence[str]] + zero_offsets_rad: Mapping[str, float] + common_from_base: tuple[tuple[float, ...], ...] + installations: Mapping[str, FrozenTagInstallation] + observations: tuple[TagHoldout, ...] + required_roles: tuple[str, ...] + standard_loader: Callable[[Path], None] + minimum_samples: int = 40 + protected_inputs: Mapping[str, str] = field(default_factory=dict) + command_observations: tuple[TagHoldout, ...] = () + + def __call__(self, json_path: Path, urdf_path: Path) -> Mapping[str, Any]: + payload = json.loads(json_path.read_text(encoding="utf-8")) + _validate_identity(payload, self.profile_id) + feedback_payload = payload + if payload.get("format") == COMPACT_FORMAT: + feedback_payload = json.loads((json_path.parent / REPORT_FILENAME).read_text(encoding="utf-8")) + _validate_identity(feedback_payload, self.profile_id) + if from_report(feedback_payload) != payload: + raise ValueError("compact JSON differs from the frozen calibration report") + validate_artifact_structure(source_urdf=self.source_urdf, + corrected_urdf=urdf_path, source_sha256=self.source_sha256, + authorized_fields=self.authorized_fields, payload=feedback_payload, + zero_offsets_rad=self.zero_offsets_rad) + changes = validate_artifact_structure(source_urdf=self.source_urdf, + corrected_urdf=urdf_path, source_sha256=self.source_sha256, + authorized_fields=self.authorized_fields, payload=payload, + zero_offsets_rad=self.zero_offsets_rad) + metrics = validate_serialized_tag_holdout(corrected_urdf=urdf_path, + payload=feedback_payload, common_from_base=self.common_from_base, + installations=self.installations, observations=self.observations, + required_roles=self.required_roles, minimum_samples=self.minimum_samples) + command_metrics = {} + if payload.get("format") in {"unified_calibration_v1", COMPACT_FORMAT}: + try: + command_metrics = validate_serialized_tag_holdout(corrected_urdf=urdf_path, + payload=payload, common_from_base=self.common_from_base, + installations=self.installations, observations=self.command_observations, + required_roles=self.required_roles, minimum_samples=3, input_kind="command") + except ValueError as error: + if payload.get("format") == COMPACT_FORMAT: + raise ValueError(f"compact_command_table_holdout_failed:{error}") from error + raise + self.standard_loader(urdf_path) + evidence = {"base": self.common_from_base, + "installations": {k: asdict(v) for k, v in self.installations.items()}, + "observations": [asdict(row) for row in self.observations]} + evidence["steady_command_observations"] = [asdict(row) for row in self.command_observations] + return {"acceptance": "serialized_standard_urdf_tag_replay_v1", + "profile_id": self.profile_id, "source_urdf_sha256": self.source_sha256, + "protected_inputs": dict(self.protected_inputs), + "holdout_cycle": 3, "tag_holdout": metrics, + "steady_command_tag_holdout": command_metrics, + "final_file_spatial_replay": "passed", + "holdout_evidence_sha256": hashlib.sha256(json.dumps(evidence, + sort_keys=True, separators=(",", ":"), allow_nan=False).encode()).hexdigest(), + "structured_changes": list(changes)} + + +@dataclass(frozen=True) +class ArtifactRelease: + calibration_json: Path + corrected_urdf: Path + manifest: Path + pointer: Path + sha256: Mapping[str, str] + + +@dataclass(frozen=True) +class StagedRelease: + session: Path + calibration_json: Path + corrected_urdf: Path + calibration_json_sha256: str + corrected_urdf_sha256: str + # Canonical immutable evidence; no mutable callback object crosses the + # worker/control-thread boundary. + validation_json: str + calibration_report: Path | None = None + calibration_report_sha256: str | None = None + + +class ArtifactPublisher: + """Validate both staged products before changing the public pointer.""" + + def __init__(self, release_root: str | Path, pointer_name: str) -> None: + self.release_root = Path(release_root).expanduser().resolve() + if not pointer_name or Path(pointer_name).name != pointer_name: + raise ValueError("publication pointer must be one filename") + self.pointer_name = str(pointer_name) + + def prepare( + self, + *, + session_directory: str | Path, + calibration_json: str | Path, + corrected_urdf: str | Path, + validate: Callable[[Path, Path], Mapping[str, Any] | None], + cancelled: Callable[[], bool] = lambda: False, + ) -> StagedRelease: + session = Path(session_directory).expanduser().resolve() + if cancelled(): + raise ValueError("operator_abort:no_artifact_publication") + json_path = Path(calibration_json).expanduser().resolve() + urdf_path = Path(corrected_urdf).expanduser().resolve() + if session.parent != self.release_root: + raise ValueError("published session must be a direct child of the release root") + if json_path.parent != session or urdf_path.parent != session: + raise ValueError("artifacts must be staged inside the session directory") + if not json_path.is_file() or not urdf_path.is_file(): + raise ValueError("both JSON and URDF must exist before publication") + if json_path == urdf_path or "release_manifest.json" in {json_path.name, urdf_path.name}: + raise ValueError("artifact names conflict with the release manifest") + hashes = { + "calibration_json_sha256": _sha256(json_path), + "corrected_urdf_sha256": _sha256(urdf_path), + } + value = json.loads(json_path.read_text(encoding="utf-8")) + if not isinstance(value, dict): + raise ValueError("calibration JSON root must be an object") + report_path = session / REPORT_FILENAME if value.get("format") == COMPACT_FORMAT else None + report_hash = None + if report_path is not None: + if report_path in {json_path, urdf_path} or not report_path.is_file() or report_path.resolve().parent != session: + raise ValueError("compact release requires a separate calibration report") + report_hash = _sha256(report_path) + ET.parse(urdf_path) + validation = validate(json_path, urdf_path) + if cancelled(): + raise ValueError("operator_abort:no_artifact_publication") + if hashes["calibration_json_sha256"] != _sha256(json_path) or hashes["corrected_urdf_sha256"] != _sha256(urdf_path): + raise ValueError("artifacts changed during validation") + if report_path is not None and report_hash != _sha256(report_path): + raise ValueError("calibration report changed during validation") + return StagedRelease(session, json_path, urdf_path, + hashes["calibration_json_sha256"], hashes["corrected_urdf_sha256"], + json.dumps(dict(validation or {}), sort_keys=True, allow_nan=False), report_path, report_hash) + + def commit(self, staged: StagedRelease, *, cancelled: Callable[[], bool] = lambda: False) -> ArtifactRelease: + """Fast pointer commit; call under the runtime's abort/commit lock.""" + session, json_path, urdf_path = staged.session, staged.calibration_json, staged.corrected_urdf + if session.parent != self.release_root or json_path.parent != session or urdf_path.parent != session: + raise ValueError("staged artifacts belong to another release root") + if cancelled(): + raise ValueError("operator_abort:no_artifact_publication") + hashes = {"calibration_json_sha256": staged.calibration_json_sha256, + "corrected_urdf_sha256": staged.corrected_urdf_sha256} + if hashes["calibration_json_sha256"] != _sha256(json_path) or hashes["corrected_urdf_sha256"] != _sha256(urdf_path): + raise ValueError("artifacts changed after validation") + if staged.calibration_report is not None: + if (staged.calibration_report != session / REPORT_FILENAME + or staged.calibration_report_sha256 != _sha256(staged.calibration_report)): + raise ValueError("calibration report changed after validation") + hashes["calibration_report_sha256"] = staged.calibration_report_sha256 + manifest = session / "release_manifest.json" + manifest_payload = { + "schema_version": 1, + "session": session.name, + "calibration_json": json_path.name, + "corrected_urdf": urdf_path.name, + **hashes, + "validation": json.loads(staged.validation_json), + } + if staged.calibration_report is not None: + manifest_payload["calibration_report"] = staged.calibration_report.name + # Refuse to mutate an already materialized release. A retry using the + # same frozen files/evidence is idempotent; a new fit needs a new session. + if manifest.exists(): + if json.loads(manifest.read_text(encoding="utf-8")) != manifest_payload: + raise ValueError("refusing to overwrite a different release manifest") + else: + json.dumps(manifest_payload, allow_nan=False) + atomic_write_json(manifest, manifest_payload) + # Verify after durable re-read, then atomically replace only the + # pointer. A crash cannot expose one artifact without the other. + persisted = json.loads(manifest.read_text(encoding="utf-8")) + if ( + persisted.get("calibration_json_sha256") != _sha256(json_path) + or persisted.get("corrected_urdf_sha256") != _sha256(urdf_path) + ): + raise ValueError("artifact hashes changed after staging") + if staged.calibration_report is not None and persisted.get("calibration_report_sha256") != _sha256(staged.calibration_report): + raise ValueError("calibration report changed after staging") + self.release_root.mkdir(parents=True, exist_ok=True) + pointer = self.release_root / self.pointer_name + temporary = self.release_root / f".{self.pointer_name}.{uuid.uuid4().hex}.tmp" + relative = os.path.relpath(session, self.release_root) + try: + os.symlink(relative, temporary, target_is_directory=True) + if cancelled(): + raise ValueError("operator_abort:no_artifact_publication") + os.replace(temporary, pointer) + descriptor = os.open(self.release_root, os.O_RDONLY | os.O_DIRECTORY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + finally: + temporary.unlink(missing_ok=True) + return ArtifactRelease(json_path, urdf_path, manifest, pointer, hashes) + + def publish(self, *, cancelled: Callable[[], bool] = lambda: False, **kwargs) -> ArtifactRelease: + return self.commit(self.prepare(cancelled=cancelled, **kwargs), cancelled=cancelled) + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +__all__ = ["ArtifactPublisher", "ArtifactRelease", "StandardArtifactValidator", "FrozenTagArtifactValidator", "StagedRelease"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/reader.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/reader.py new file mode 100644 index 0000000..298e756 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/reader.py @@ -0,0 +1,89 @@ +"""Model-neutral reader for a certified JSON/URDF pair; no ROS dependency.""" + +import hashlib +import json +import math +from pathlib import Path + +from ...core.urdf.acceptance import SerializedJointMapping, validate_compact_urdf_tables +from ...core.urdf.kinematics import UrdfKinematicModel +from .serializers.compact_v2 import FORMAT as COMPACT_FORMAT, from_report + + +class UnifiedCommandMapper: + def __init__(self, payload, corrected_urdf, *, input_kind="command", expected_side=None, report=None): + compact = payload.get("format") == COMPACT_FORMAT + if payload.get("format") not in {"unified_calibration_v1", COMPACT_FORMAT}: + raise ValueError("requires a unified calibration artifact") + if expected_side and payload["side"] != expected_side: + raise ValueError("calibration side does not match requested side") + if compact and (report is None or from_report(report) != payload): + raise ValueError("compact JSON requires its matching calibration report") + metadata = report if compact else payload + self.mapping = SerializedJointMapping(metadata if compact and input_kind == "feedback" else payload, input_kind=input_kind) + self.urdf = UrdfKinematicModel(corrected_urdf) + self.active = tuple(name for name, j in self.urdf.joints.items() if j.kind != "fixed" and j.mimic_joint is None) + if compact: + validate_compact_urdf_tables(payload, self.urdf) + if not self.mapping.compact and set(self.mapping.joints) != set(self.active): + raise ValueError("JSON active joints differ from the exported URDF") + self.urdf_joint_names = tuple(sorted(name for name, j in self.urdf.joints.items() if j.kind != "fixed")) + self.command_names = tuple(metadata["command_names"]) + if any(row["motor_index"] >= len(self.command_names) for row in self.mapping.joints.values()): + raise ValueError("JSON SDK channel exceeds the recorded SDK layout") + self.input_domain = self.mapping.domain + self.model, self.side = payload["model"], payload["side"] + self.profile_id, self.serial_number = payload["profile_id"], payload["serial_number"] + self.feedback_by_index = bool(metadata.get("feedback_by_index", False)) if input_kind == "feedback" else True + self.feedback_name_aliases = dict(metadata.get("feedback_name_aliases", {})) + self.previous = None + self.directions = [""]*len(self.command_names) + + def map_positions(self, positions, names=()): + values = tuple(float(v) for v in positions) + if len(values) != len(self.command_names) or not all(math.isfinite(v) for v in values): + raise ValueError("SDK input requires a complete finite channel vector") + if names and not self.feedback_by_index and self.input_domain.startswith("feedback_"): + names = tuple(self.feedback_name_aliases.get(n, n) for n in names) + if len(names) != len(values) or set(names) != set(self.command_names): + raise ValueError("feedback names differ from the serialized SDK layout") + named = dict(zip(names, values)) + values = tuple(named[n] for n in self.command_names) + directions = self.directions.copy() + if self.previous is not None: + for i, (old, new) in enumerate(zip(self.previous, values)): + if abs(new-old) > (0.5 if self.input_domain.endswith("u8") else 1e-9): + directions[i] = "increasing" if new > old else "decreasing" + active = self.mapping.evaluate(values, "", active_joints=self.active, + directions_by_joint={name: directions[row["motor_index"]] for name, row in self.mapping.joints.items()}) + result = self.urdf.resolve_angles(active) + for name, angle in result.items(): + joint = self.urdf.joints[name] + if joint.lower is not None and not joint.lower-1e-9 <= angle <= joint.upper+1e-9: + raise ValueError(f"mapped value exceeds exported URDF range:{name}") + self.previous, self.directions = values, directions + return tuple(result[name] for name in self.urdf_joint_names) + + +def load_unified_mapper(calibration_file, *, expected_side=None, input_kind="command"): + path = Path(calibration_file).expanduser().resolve() + manifest_path = path if path.name == "release_manifest.json" else path.parent/"release_manifest.json" + manifest = json.loads(manifest_path.read_text()) + def artifact(field): + name = manifest[field] + if Path(name).name != name: + raise ValueError("release artifact must be a sibling filename") + result = manifest_path.parent/name + if hashlib.sha256(result.read_bytes()).hexdigest() != manifest[field+"_sha256"]: + raise ValueError(f"released artifact SHA256 changed:{field}") + return result + json_path, urdf_path = artifact("calibration_json"), artifact("corrected_urdf") + if path not in {manifest_path, json_path.resolve()}: + raise ValueError("JSON is not the artifact certified by this manifest") + validation = manifest["validation"] + if validation.get("final_file_spatial_replay") != "passed" or not validation.get("steady_command_tag_holdout"): + raise ValueError("release lacks independent command and feedback URDF evidence") + payload = json.loads(json_path.read_text()) + report = json.loads(artifact("calibration_report").read_text()) if payload.get("format") == COMPACT_FORMAT else None + return UnifiedCommandMapper(payload, urdf_path, + input_kind=input_kind, expected_side=expected_side, report=report) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py new file mode 100644 index 0000000..c039cd2 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/replay.py @@ -0,0 +1,83 @@ +"""Offline use of exactly the online finalizer, for any declared Profile.""" + +from pathlib import Path +import hashlib +import json + +from ..acquisition import load_capture +from ..engine import CalibrationEngine +from ..resume import ResumeVerifier, fingerprint_from_mapping +from ..scan_quality import evaluate_capture_unit +from .finalization import finalize_profile_session + + +def validate_capture_provenance(profile, serial_number, hashes, records): + """An offline journal cannot substitute today's hashes for old evidence.""" + 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: + raise ValueError("capture_provenance:requires_unique_session_and_locked_reference") + header, reference = headers[0], references[0] + engine = CalibrationEngine(profile) + if (not engine.resume_compatible(header) or header.get("serial_number") != serial_number + or header.get("curve_input_domain") != profile.curve_input_domain): + raise ValueError("capture_provenance:obsolete_policy_or_coordinate_identity; fresh capture required") + for key, value in hashes.items(): + if header.get(key) != value or reference.get("protected_hashes", {}).get(key) != value: + raise ValueError(f"capture_provenance:protected_input_changed:{key}") + fingerprint = fingerprint_from_mapping(reference) + checker = ResumeVerifier(required_fixed_views=profile.vision.view_names, + required_fixed_poses=profile.vision.view_names, required_hashes=(*hashes, "intrinsics_sha256")) + evidence = checker.compare(fingerprint, fingerprint) + if not evidence.reuse or fingerprint.profile_id != profile.key.profile_id: + raise ValueError(f"capture_provenance:invalid_locked_reference:{evidence.incompatible_fields}") + models = {} + # Only preview models preceding the formal reference are relevant. Later + # camera changes are rejected online and cannot authorize a mixed replay. + for row in records: + if row is reference: + break + if row.get("kind") == "rectified_camera_model": + if row.get("matrix_source") != "CameraInfo.P[:3,:3]" or row.get("input_is_rectified") is not True: + raise ValueError("capture_provenance:unverified_rectified_projection") + models[row["view"]] = row["camera_matrix"] + intrinsic_hash = hashlib.sha256(json.dumps(dict(sorted(models.items())), sort_keys=True).encode()).hexdigest() + if set(models) != set(profile.vision.view_names) or intrinsic_hash != fingerprint.protected_hashes["intrinsics_sha256"]: + raise ValueError("capture_provenance:intrinsics_evidence_changed_or_missing") + + +def validate_capture_units(profile, records): + """Offline and resumed data must satisfy the actual post-sweep policy.""" + first_spans = {} + for unit in CalibrationEngine(profile).scan_units(): + key = (unit.task_key, unit.cycle, unit.direction) + rows = [row for row in records + if (row.get("task_name"), row.get("cycle"), row.get("direction")) == key] + attempt = max((int(row.get("attempt", 1)) for row in rows), default=1) + complete = any(row.get("kind") == "scan_unit_complete" and row.get("passed") is True + and int(row.get("attempt", 1)) == attempt for row in rows) + quality = evaluate_capture_unit(profile, unit, attempt, rows, first_cycle_spans=first_spans) + if not complete or not quality.passed: + raise ValueError(f"capture_incomplete:{key}:{quality.failures}") + + +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 + hashes = protected_inputs(config) + validate_capture_provenance(profile, config.serial_number, hashes, records) + validate_capture_units(profile, records) + reference = next(row for row in records if row.get("kind") == "fixed_base_reference_locked") + hashes["intrinsics_sha256"] = reference["protected_hashes"]["intrinsics_sha256"] + hashes["raw_samples_sha256"] = hashlib.sha256(Path(raw_path).read_bytes()).hexdigest() + if output is None: + output = create_session_directory(config.session_root) + else: + output = output.expanduser().resolve() + output.mkdir(parents=True, exist_ok=False) + payload, _fit, correction = finalize_profile_session( + profile=profile, + session_dir=output, serial_number=config.serial_number, source_urdf=config.source_urdf, + protected_inputs=hashes, records=records, publish=publish) + print(f"离线回放完成:schema {payload['schema_version']},URDF {correction.path}") diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/__init__.py new file mode 100644 index 0000000..871b95b --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/__init__.py @@ -0,0 +1 @@ +"""Format compatibility only. Mathematical choices precede serialization.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/compact_v2.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/compact_v2.py new file mode 100644 index 0000000..750610b --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/compact_v2.py @@ -0,0 +1,61 @@ +"""Small command lookup; the complete training result stays in the report. + +No extrapolation, new fit, zero shift or independent passive curve is allowed. +The exported single curve must subsequently pass final-file visual holdout. +""" + +import numpy as np + +from ....core.urdf.acceptance import SerializedJointMapping +from .unified_v1 import serialize as serialize_report + +FORMAT = "unified_calibration_v2" +REPORT_FILENAME = "calibration_report.json" + + +def from_report(report): + mapping = SerializedJointMapping(report, input_kind="command") + unit = report["command_unit"] + rows, visiting = {}, set() + + def resolve(name): + if name in rows: + return rows[name] + if name in visiting or name not in report["joints"]: + raise ValueError("invalid report mimic graph") + visiting.add(name) + source = report["joints"][name] + if source.get("passive"): + mimic = source["mimic"] + parent = resolve(mimic["joint"]) + row = {**parent, "angle_rad": [mimic["multiplier"]*v + mimic["offset_rad"] + for v in parent["angle_rad"]]} + else: + data = mapping.joints[name] + knots = np.asarray(data["curve_input_knots_rad"], dtype=float) + # Averaging is a declared training-only reduction. Never choose a + # better branch using holdout, or retain hidden direction fallback. + angles = (np.asarray(data["increasing_rad"]) + np.asarray(data["decreasing_rad"]))/2 + row = {"sdk_channel": data["motor_index"]} + if unit == "u8": + if knots[0] != 0 or knots[-1] != 255: + raise ValueError(f"compact_byte_requires_measured_full_command_domain:{name}") + angles = np.interp(np.arange(256), knots, angles) + else: + row["input_values"] = knots.tolist() + row["angle_rad"] = angles.tolist() + if not np.all(np.isfinite(row["angle_rad"])): + raise ValueError(f"nonfinite compact curve:{name}") + visiting.remove(name) + rows[name] = row + return row + + for name in sorted(report["joints"]): + resolve(name) + return {"format": FORMAT, "schema_version": 2, + **{key: report[key] for key in ("profile_id", "model", "side", "serial_number")}, + "input_unit": unit, "joints": {name: rows[name] for name in sorted(rows)}} + + +def serialize(profile, fit, prepared, **metadata): + return from_report(serialize_report(profile, fit, prepared, **metadata)) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/unified_v1.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/unified_v1.py new file mode 100644 index 0000000..73add70 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/serializers/unified_v1.py @@ -0,0 +1,47 @@ +"""Lossless dual-input format. No fitting, rebasing or passive curve bypass.""" + +FORMAT = "unified_calibration_v1" + + +def _mapping(mapping): + return {"input_domain": mapping.input_domain, "channel_index": mapping.motor_index, + "input_unit": mapping.input_domain.rsplit("_", 1)[1], "output_unit": "rad", + "knots": list(mapping.knots), "valid_input_range": [mapping.knots[0], mapping.knots[-1]], + "interpolation": "piecewise_linear", "extrapolation": "reject", + **{field: list(getattr(mapping, field)) for field in ("angle_rad", "increasing_rad", "decreasing_rad")}} + + +def serialize(profile, fit, prepared, *, serial_number, protected_inputs): + if set(fit.command_mappings) != set(fit.output_mappings): + raise ValueError("unified artifacts require independently measured command AND feedback mappings") + if any(not m.input_domain.startswith("feedback_") for m in fit.output_mappings.values()): + raise ValueError("unified geometry must use feedback, not a relabelled command curve") + rows = {} + for name in sorted(prepared.mappings): + row = {"urdf_joint": name, "calibration_status": profile.joint_coverage[name], + "zero_offset_rad": fit.zero_offsets_rad.get(name, 0.0), + "zero_method": fit.zero_method_by_joint.get(name, "source_cad_passive_zero")} + if name in prepared.plan.mimic_output: + parent, multiplier, offset = prepared.plan.mimic_output[name] + row.update(passive=True, runtime_source="exported_standard_urdf_mimic", + mimic={"joint": parent, "multiplier": multiplier, "offset_rad": offset}) + else: + row.update(passive=False, command_to_rad=_mapping(fit.command_mappings[name]), + feedback_to_rad=_mapping(fit.output_mappings[name]), + applicability=fit.command_applicability[name], + command_holdout=fit.command_holdout_metrics[name]) + donor = profile.zero.transferred_zero_sources.get(name) + if donor: + row.update(transferred_from_joint=donor, independently_measured=False) + rows[name] = row + return {"format": FORMAT, "schema_version": 1, "profile_id": profile.key.profile_id, + "serial_number": serial_number, "model": profile.key.model, "side": profile.key.side, + "angle_unit": "rad", "command_unit": profile.command.unit, + "command_names": list(profile.command.names), "baseline_command": list(profile.command.baseline_values), + "feedback_by_index": profile.command.feedback_by_index, + "feedback_name_aliases": dict(profile.command.feedback_name_aliases), + "protected_inputs": dict(protected_inputs), "joints": rows, + "coordinate_convention": "q_CAD=q_output+delta; JSON already emits q_output", + "quality": {"training_cycles": [0, 1, 2], "holdout_cycle": 3, + "command_and_feedback_fits_passed": True, + "final_file_acceptance": "release_manifest.json", "arbitrary_multiaxis_validated": False}} diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/standard_loader.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/standard_loader.py new file mode 100644 index 0000000..72f3b44 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/standard_loader.py @@ -0,0 +1,62 @@ +"""Bounded, isolated standard ROS URDF loader check. Never starts an SDK.""" + +from __future__ import annotations + +import os +from pathlib import Path +import selectors +import subprocess +import tempfile +import time +import uuid + +import yaml + + +def validate_with_robot_state_publisher(path: Path, *, timeout_seconds: float = 8.0) -> None: + """Require urdfdom/KDL/RSP to load the generated file successfully. + + All subscriptions and TF outputs use a fresh private namespace. The + process has no motor-command publisher or SDK connection. It is terminated + after initialization, not left running as another hand model publisher. + """ + from ament_index_python.packages import get_package_prefix + executable = Path(get_package_prefix("robot_state_publisher")) / "lib/robot_state_publisher/robot_state_publisher" + if not executable.is_file(): + raise ValueError("standard_urdf_loader_unavailable:robot_state_publisher") + namespace = "/calibration_verify_" + uuid.uuid4().hex + with tempfile.TemporaryDirectory(prefix="calibration_urdf_verify_") as directory: + parameters = Path(directory) / "parameters.yaml" + parameters.write_text(yaml.safe_dump({"/**": {"ros__parameters": { + "robot_description": path.read_text(encoding="utf-8"), + "publish_frequency": 1.0}}}), encoding="utf-8") + environment = dict(os.environ, ROS_LOG_DIR=directory, ROS_LOCALHOST_ONLY="1") + 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", "-r", f"joint_states:={namespace}/joint_states", + "--log-level", "info", "--disable-rosout-logs"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=environment) + output = bytearray() + try: + assert process.stdout is not None + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + for key, _ in selector.select(timeout=0.1): + output.extend(os.read(key.fileobj.fileno(), 65536)) + if b"Robot initialized" in output and process.poll() is None: + return + if process.poll() is not None: + break + raise ValueError("standard_urdf_loader_failed:" + output.decode(errors="replace")[-4000:]) + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + if process.stdout is not None: + process.stdout.close() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/worker.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/worker.py new file mode 100644 index 0000000..08a2b60 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/artifacts/worker.py @@ -0,0 +1,49 @@ +"""CPU-bound finalization without blocking the SDK or status callbacks. + +The worker only prepares artifacts. The controller commits them, under the +same lock as abort/pause, after observing successful completion. +""" + +from concurrent.futures import ThreadPoolExecutor +from threading import Event, RLock + + +class FinalizationWorker: + def __init__(self): + self.lock = RLock() + self._cancelled = Event() + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="calibration-fit") + self._future = None + self._consumed = False + + def start(self, finalizer, **kwargs): + with self.lock: + if self._future is not None or self._cancelled.is_set(): + raise RuntimeError("finalization_already_started_or_cancelled") + self._future = self._executor.submit( + finalizer, **kwargs, publish=False, cancelled=self._cancelled.is_set) + + def finish_if_ready(self, commit): + """Invoke commit at most once; an abort cannot race the pointer swap. + + The controller holds ``lock`` while also updating its terminal state. + A failed job is consumed too, so a failed publication is never retried. + """ + with self.lock: + if (self._cancelled.is_set() or self._consumed or + self._future is None or not self._future.done()): + return None + self._consumed = True + result = self._future.result() + commit(result) + return result + + def cancel(self): + with self.lock: + self._cancelled.set() + if self._future is not None: + self._future.cancel() + + def close(self): + self.cancel() + self._executor.shutdown(wait=False, cancel_futures=True) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/cameras.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/cameras.py new file mode 100644 index 0000000..e6832e1 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/cameras.py @@ -0,0 +1,71 @@ +"""Protected rectified camera identity and live pipeline readiness.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Callable + +import numpy as np + +from ..core.geometry.extrinsics import CameraExtrinsics, camera_info_fingerprint +from ..storage import append_jsonl +from .inputs import CameraModelInput + + +class CameraObservations: + def __init__(self, extrinsics: CameraExtrinsics, raw_path: Path, monotonic: Callable[[], float]): + self.extrinsics = extrinsics + self.raw_path = raw_path + self.monotonic = monotonic + self.matrices: dict[str, np.ndarray] = {} + self.image_sizes: dict[str, tuple[int, int]] = {} + self.info_received_at: dict[str, float] = {} + self.detections_received_at: dict[str, float] = {} + self.errors: dict[str, str] = {} + self.models: dict[str, dict] = {} + + def accept(self, view: str, message: CameraModelInput, *, started: bool, pause, trackers) -> None: + from ..core.geometry.camera import rectified_camera_matrix + try: + matrix = rectified_camera_matrix(message.p) + if message.width <= 0 or message.height <= 0: + raise ValueError('invalid rectified image dimensions') + identity = self.extrinsics.cameras[view] + fingerprint = camera_info_fingerprint( + width=message.width, height=message.height, camera_matrix=message.k, + distortion=message.d, rectification=message.r, projection=message.p) + if (message.width != identity.width or message.height != identity.height + or fingerprint != identity.intrinsics_sha256): + raise ValueError('camera_info_does_not_match_extrinsics') + except ValueError as error: + self.errors[view] = str(error) + self.info_received_at.pop(view, None) + if started: + pause(f"protected_camera_model_changed:{view}:{error}") + self.matrices.pop(view, None) + self.image_sizes.pop(view, None) + return + previous = self.matrices.get(view) + dimensions = (int(message.width), int(message.height)) + if started and previous is not None and ( + not np.array_equal(previous, matrix) or self.image_sizes.get(view) != dimensions + ): + pause(f"protected_camera_model_changed:{view}:intrinsics_or_dimensions") + return + self.matrices[view] = matrix + self.image_sizes[view] = (int(message.width), int(message.height)) + self.info_received_at[view] = self.monotonic() + self.errors.pop(view, None) + model = { + 'kind': 'rectified_camera_model', 'view': view, + 'matrix_source': 'CameraInfo.P[:3,:3]', + 'width': int(message.width), 'height': int(message.height), + 'raw_k': list(message.k), 'raw_d': list(message.d), + 'rectification_r': list(message.r), 'projection_p': list(message.p), + 'camera_matrix': matrix.tolist(), 'input_is_rectified': True, + } + if self.models.get(view) != model: + self.models[view] = model + append_jsonl(self.raw_path, model) + if previous is not None and not np.allclose(previous, matrix): + trackers[view].reset() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py new file mode 100644 index 0000000..f980ee2 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/capture.py @@ -0,0 +1,175 @@ +"""Profile observation capture shared by every SDK and ROS camera host.""" + +from collections import deque +from dataclasses import dataclass + +import numpy as np +from scipy.spatial.transform import Rotation + +from ..core.geometry.extrinsics import matrix_payload, transform_matrix +from ..core.geometry.pnp import SquareTagPose, SquareTagPoseTracker +from .scan_quality import observation_streams + + +@dataclass(frozen=True) +class CaptureFrame: + view: str + stamp_ns: int + camera_matrix: np.ndarray + corners: dict + feedback: tuple[float, ...] | None + command: tuple[float, ...] | None + skew_ns: int = 0 + command_directions: tuple[str, ...] = () + + +class ObservationCapture: + def __init__(self, profile, *, reference_lock, extrinsics, tag_size_m=0.016, trackers=None): + self.profile, self.reference_lock, self.extrinsics = profile, reference_lock, extrinsics + self.tag_size_m = float(tag_size_m) + self.trackers = trackers or {view.name: SquareTagPoseTracker() + for view in profile.vision.views} + self._seen = set() + self._poses = {} + self.locked_poses = {} + self.installations = {} + self.last_missing = {} + self.last_filtered = {} + + def reset(self): + self._seen.clear() + self._poses.clear() + self.locked_poses.clear() + self.installations.clear() + self.last_missing.clear() + self.last_filtered.clear() + for tracker in self.trackers.values(): + tracker.reset() + + @staticmethod + def _median_pose(poses): + return SquareTagPose( + quaternion_xyzw=tuple(Rotation.from_quat([p.quaternion_xyzw for p in poses]).mean().as_quat()), + translation_xyz_m=tuple(np.median([p.translation_xyz_m for p in poses], axis=0)), + reprojection_error_px=float(np.median([p.reprojection_error_px for p in poses]))) + + def consume(self, frame: CaptureFrame, motion=None, *, collect_installations=False): + identity = (frame.view, frame.stamp_ns) + if frame.stamp_ns <= 0 or identity in self._seen: + return (), None + self._seen.add(identity) + view = next(v for v in self.profile.vision.views if v.name == frame.view) + base = next(tag for tag in view.tags if tag.fixed_reference) + selected, filtered = {}, [] + for tag in view.tags: + points = frame.corners.get(tag.role) + if points is None: + continue + pose, reason = self.trackers[frame.view].estimate(tag.role, points, + tag_size_m=tag.size_m, camera_matrix=frame.camera_matrix, stamp_ns=frame.stamp_ns) + if pose is None: + filtered.append(tag.tag_id) + else: + selected[tag.role] = pose + self.last_missing[frame.view] = tuple(tag.tag_id for tag in view.tags if tag.role not in frame.corners) + self.last_filtered[frame.view] = tuple(filtered) + movement = None + if frame.view in self.reference_lock.references and base.role in frame.corners: + # Once locked, fixed-object drift is a CORNER measurement. A large + # real displacement may fail the temporal PnP jump filter and must + # not consequently evade the ten-frame movement protection. + movement = self.reference_lock.observe(frame.view, base.tag_id, frame.corners[base.role]) + if base.role in selected: + # Only independent frames with a valid pose may establish a lock. + if frame.view not in self.reference_lock.references: + movement = self.reference_lock.observe(frame.view, base.tag_id, frame.corners[base.role]) + if self.reference_lock.locking_enabled and frame.view not in self.locked_poses: + buffer = self._poses.setdefault(frame.view, deque(maxlen=30)) + buffer.append(selected[base.role]) + if frame.view in self.reference_lock.references and len(buffer) >= self.reference_lock.minimum_frames: + self.locked_poses[frame.view] = self._median_pose(buffer) + # Include the last locking frame exactly once; never update mounting + # fingerprints during task motion or while the worker is fitting. + if collect_installations and self.reference_lock.locking_enabled: + self._observe_installations(frame.view, base.role, selected, + prefix="" if collect_installations is True else str(collect_installations)+"/") + if movement is not None or motion is None or not motion.recording: + return (), movement + task = next(t for t in self.profile.motion.tasks if t.key == motion.task_key) + streams = [item for item in observation_streams(self.profile, task) if item[2].view == frame.view] + if not streams or frame.feedback is None or frame.command is None: + return (), movement + locked = [] + if base.role not in selected and frame.view in self.locked_poses: + selected[base.role] = self.locked_poses[frame.view] + locked.append(base.role) + rows = [] + common_from_view = self.extrinsics.transform(frame.view) + unit = self.profile.command.unit + for field, joint, spec in streams: + if spec.parent_role not in selected or spec.child_role not in selected: + continue + parent, child = selected[spec.parent_role], selected[spec.child_role] + p = common_from_view @ transform_matrix(parent.translation_xyz_m, parent.quaternion_xyzw) + c = common_from_view @ transform_matrix(child.translation_xyz_m, child.quaternion_xyzw) + relative = np.linalg.inv(p) @ c + row = {"kind": "joint_sample" if field == "joint" else "secondary_joint_sample", + "profile_id": self.profile.key.profile_id, field: joint, "task_name": task.key, + "view": frame.view, "image_stamp_ns": frame.stamp_ns, + "cycle": motion.cycle, "direction": motion.direction, "attempt": motion.attempt, + "sample_phase": "steady" if motion.phase == "steady" else "sweep", + "command_direction_by_index": list(frame.command_directions), + "motor_index": task.command_index, + f"feedback_{unit}": frame.feedback[task.command_index], + f"command_{unit}": frame.command[task.command_index], + f"state_{unit}": list(frame.feedback), f"command_vector_{unit}": list(frame.command), + "state_image_sync_error_ms": abs(frame.skew_ns)/1e6, + "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(p), "child_pose_common": matrix_payload(c), + "view_normal_common_xyz": common_from_view[:3, 2].tolist(), + "camera_center_common_xyz_m": common_from_view[:3, 3].tolist(), + "locked_reference_roles": locked, + "pnp_reprojection_error_px": max(parent.reprojection_error_px, child.reprojection_error_px)} + if motion.phase == "steady": + row.update(steady_index=motion.steady_index, + steady_target=motion.target[task.command_index]) + if spec.parent_role in frame.corners and spec.child_role in frame.corners: + row["image_relative_xy_px"] = (np.mean(frame.corners[spec.child_role], axis=0) + - np.mean(frame.corners[spec.parent_role], axis=0)).tolist() + elif joint in self.profile.measurement.image_curve_joints: + continue # Image-circle observations cannot be synthesized from a cached pose. + rows.append(row) + roles = {role for _, _, spec in streams for role in (spec.parent_role, spec.child_role)} + if task.key in self.profile.measurement.candidate_selection_tasks: + rows.append({"kind": "pnp_candidate_frame", "task_name": task.key, + "cycle": motion.cycle, "direction": motion.direction, "attempt": motion.attempt, + "view": frame.view, "image_stamp_ns": frame.stamp_ns, "tag_size_m": self.tag_size_m, + "camera_matrix": frame.camera_matrix.tolist(), "camera_matrix_source": "CameraInfo.P[:3,:3]", + "input_is_rectified": True, + "roles": {role: {"corners_xy": np.asarray(frame.corners[role]).tolist(), + "tag_size_m": next(tag.size_m for tag in view.tags if tag.role == role), + "maximum_reprojection_error_px": self.trackers[frame.view].maximum_reprojection_error_px} + for role in roles if role in frame.corners}}) + return tuple(rows), movement + + def _observe_installations(self, view, base_role, poses, *, prefix=""): + if base_role not in poses: + return + parent = poses[base_role] + p = transform_matrix(parent.translation_xyz_m, parent.quaternion_xyzw) + for role, pose in poses.items(): + if role == base_role: + continue + relative = np.linalg.inv(p) @ transform_matrix(pose.translation_xyz_m, pose.quaternion_xyzw) + buffer = self.installations.setdefault(f"{prefix}{view}:{role}", deque(maxlen=30)) + buffer.append(SquareTagPose( + quaternion_xyzw=tuple(Rotation.from_matrix(relative[:3, :3]).as_quat()), + translation_xyz_m=tuple(relative[:3, 3]), reprojection_error_px=pose.reprojection_error_px)) + + def fingerprint_poses(self): + def payload(pose): + return {"rotation_xyzw": list(pose.quaternion_xyzw), "translation_xyz_m": list(pose.translation_xyz_m)} + return ({view: payload(pose) for view, pose in self.locked_poses.items()}, + {name: payload(self._median_pose(rows)) for name, rows in self.installations.items() + if len(rows) >= self.reference_lock.minimum_frames}) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/controller.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/controller.py deleted file mode 100644 index 7c7ddba..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/controller.py +++ /dev/null @@ -1,160 +0,0 @@ -"""ROS-free session state machine shared by live and replay runners.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import Sequence - -from ..core import CalibrationProfile, SampleRecord, validate_profile -from ..core.solver import ( - SessionSolution, - SessionSolver, - TaskEvaluation, - TaskEvaluator, -) - - -class SessionState(str, Enum): - CREATED = "CREATED" - PREFLIGHT = "PREFLIGHT" - PREPARING = "PREPARING" - CAPTURING = "CAPTURING" - EVALUATING = "EVALUATING" - RESCAN = "RESCAN" - SOLVING = "SOLVING" - VALIDATING = "VALIDATING" - PUBLISHING = "PUBLISHING" - COMPLETE = "COMPLETE" - FAILED = "FAILED" - - -@dataclass(frozen=True) -class ControllerSnapshot: - state: SessionState - completed_task_keys: tuple[str, ...] - current_task_key: str | None - failure_reason: str | None - - -class SessionController: - """Advance one typed task graph without depending on ROS clocks/messages.""" - - def __init__( - self, - profile: CalibrationProfile, - evaluator: TaskEvaluator, - solver: SessionSolver, - ) -> None: - validate_profile(profile) - self.profile = profile - self.evaluator = evaluator - self.solver = solver - self.state = SessionState.CREATED - self._task_index = 0 - self._samples: list[SampleRecord] = [] - self.last_evaluation: TaskEvaluation | None = None - self.solution: SessionSolution | None = None - self.failure_reason: str | None = None - - @property - def current_task(self): - if self._task_index >= len(self.profile.motion.tasks): - return None - return self.profile.motion.tasks[self._task_index] - - @property - def samples(self) -> tuple[SampleRecord, ...]: - return tuple(self._samples) - - def snapshot(self) -> ControllerSnapshot: - completed = tuple( - task.key for task in self.profile.motion.tasks[: self._task_index] - ) - task = self.current_task - return ControllerSnapshot( - state=self.state, - completed_task_keys=completed, - current_task_key=None if task is None else task.key, - failure_reason=self.failure_reason, - ) - - def start(self) -> None: - self._require(SessionState.CREATED) - self.state = SessionState.PREFLIGHT - - def finish_preflight(self, *, passed: bool, reason: str = "") -> None: - self._require(SessionState.PREFLIGHT) - if not passed: - self._fail(reason or "static preflight failed") - return - self.state = SessionState.PREPARING - - def task_pose_ready(self) -> None: - if self.state not in {SessionState.PREPARING, SessionState.RESCAN}: - self._raise_transition("task pose can only follow prepare or rescan") - if self.current_task is None: - self._raise_transition("there is no task left to capture") - self.state = SessionState.CAPTURING - - def submit_task_samples( - self, samples: Sequence[SampleRecord] - ) -> TaskEvaluation: - self._require(SessionState.CAPTURING) - task = self.current_task - if task is None: - self._raise_transition("there is no active task") - rows = tuple(samples) - if not rows or any(row.task_key != task.key for row in rows): - raise ValueError("captured samples do not belong to the active task") - self.state = SessionState.EVALUATING - evaluation = self.evaluator.evaluate_task(self.profile, task, rows) - self.last_evaluation = evaluation - if not evaluation.accepted: - if evaluation.rescan_measurements or evaluation.rescan_cycles: - self.state = SessionState.RESCAN - else: - self._fail("task evaluation failed without a safe rescan scope") - return evaluation - self._samples.extend(rows) - self._task_index += 1 - self.state = ( - SessionState.SOLVING - if self.current_task is None - else SessionState.PREPARING - ) - return evaluation - - def solve(self) -> SessionSolution: - self._require(SessionState.SOLVING) - solution = self.solver.solve_session(self.profile, self.samples) - self.solution = solution - if solution.passed: - self.state = SessionState.VALIDATING - else: - self._fail("session solver rejected the captured samples") - return solution - - def finish_release_validation( - self, *, passed: bool, reason: str = "" - ) -> None: - self._require(SessionState.VALIDATING) - if not passed: - self._fail(reason or "release validation failed") - return - self.state = SessionState.PUBLISHING - - def finish_publication(self) -> None: - self._require(SessionState.PUBLISHING) - self.state = SessionState.COMPLETE - - def _fail(self, reason: str) -> None: - self.failure_reason = str(reason) - self.state = SessionState.FAILED - - def _require(self, expected: SessionState) -> None: - if self.state != expected: - self._raise_transition(f"expected {expected.value}") - - def _raise_transition(self, detail: str) -> None: - raise RuntimeError(f"invalid session transition from {self.state.value}: {detail}") diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py new file mode 100644 index 0000000..3152f4b --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/coordinator.py @@ -0,0 +1,645 @@ +"""Transport-neutral owner of the live calibration session. + +State transitions, capture submission and artifact commit share one RLock. +The lock order is always coordinator state, then finalization worker; fitting +and pose estimation never hold the state lock while doing expensive work. +""" + +from __future__ import annotations + +from collections import deque +import hashlib +import json +import threading +import traceback + +import numpy as np + +from ..acquisition import StateSample, interpolate_state_u8 +from ..core.domain.profile import CalibrationProfile +from ..storage import append_jsonl, atomic_write_json +from .engine import ACQUISITION_POLICY_VERSION +from .reference_lock import ReferenceLock +from .acquisition import load_capture +from .artifacts.controller import FinalizationController, FinalizationInputs, FinalizationProtocolError +from .cameras import CameraObservations +from .capture import CaptureFrame, ObservationCapture +from .execution import SessionExecution +from .inputs import AdapterFactory, CameraModelInput, DetectionInput, RuntimePorts, ServiceResult +from .motion_execution import MotionExecution +from .parameters import RuntimeParameters +from .resume import ResumeDecision, ResumeVerifier, fingerprint_from_mapping +from .safety import SafetyPolicy, SafetySample +from .session import CalibrationPhase as Phase +from .snapshot import DeviceReadiness, CameraReadiness, RuntimeSnapshot, build_snapshot, legacy_state + + +class CalibrationCoordinator: + def __init__(self, profile: CalibrationProfile, parameters: RuntimeParameters, + ports: RuntimePorts, adapter_factory: AdapterFactory, *, finalization=None): + self.profile, self.parameters, self.ports = profile, parameters, ports + self.step_data_lock = threading.RLock() + self.model_name = profile.key.model + self.command_names = tuple(profile.command.names) + self.command_count = profile.command.command_count + self.command_unit = profile.command.unit + self.feedback_lower = tuple(profile.command.minimum_feedback_values) + self.feedback_upper = tuple(profile.command.maximum_feedback_values) + self.execution = SessionExecution(profile) + self.safety = SafetyPolicy(profile) + self.reference_lock = self._new_reference_lock() + self.trackers = parameters.new_trackers(profile.vision.view_names) + self.capture = ObservationCapture(profile, reference_lock=self.reference_lock, + extrinsics=parameters.extrinsics, tag_size_m=parameters.tag_size_m, trackers=self.trackers) + self.latest_feedback: tuple[float, ...] = () + self.state_history: deque[StateSample] = deque(maxlen=2000) + self.state_receive_times: deque[float] = deque(maxlen=300) + self.command_history: deque[StateSample] = deque(maxlen=2000) + self.command_direction_history = deque(maxlen=2000) + self._command_directions = [""]*self.command_count + self.raw_records = [] + self.last_command = None + self.commanded_speed = None + self.step_speed_ready_at = 0.0 + self.reason = "waiting_for_feedback_and_camera_info" + self._started_at: float | None = None + self._observation_epoch = 0 + self._pause_effects_applied = False + self.segment = None + self._segment_number = 0 + self._motion = None + self._steady_capture_after_ns = None + self._resume_message = "not_requested" + self._resumed_count = 0 + self._unit_rows = [] + self._capture_unit_key = None + self._steady_rows = [] + self._reference_capture_key = None + self._reference_observation_started = None + self._current_fingerprint = {} + self.final_json = "" + self.final_urdf = "" + for path, key in ((parameters.source_urdf, "source_urdf_sha256"), + (parameters.camera_extrinsics_file, "camera_extrinsics_sha256")): + if hashlib.sha256(path.read_bytes()).hexdigest() != parameters.protected_inputs[key]: + raise ValueError(f"protected input changed:{key}") + from ..profiles.validator import validate_executable_profile + validate_executable_profile(profile, parameters.source_urdf) + parameters.session_dir.mkdir(parents=True, exist_ok=True) + self.raw_path = parameters.session_dir / "raw_samples.jsonl" + append_jsonl(self.raw_path, { + "kind": "session_start", "sample_schema_version": profile.artifacts.output_schema_version, + "profile_id": profile.key.profile_id, "serial_number": parameters.serial_number, + "curve_input_domain": profile.curve_input_domain, + "acquisition_policy_version": ACQUISITION_POLICY_VERSION, + **parameters.protected_inputs, + "resume_checkpoint_requested": parameters.resume_raw_samples_path is not None, + }) + self.cameras = CameraObservations(parameters.extrinsics, self.raw_path, ports.monotonic) + self.sdk_adapter = adapter_factory(self._write_position, self._write_speed, self.feedback_fresh) + self.finalization = finalization or FinalizationController(profile, parameters.session_dir) + + @property + def state(self) -> str: + return legacy_state(self.execution.session.phase) + + @property + def started(self) -> bool: + return self._started_at is not None + + def start(self) -> ServiceResult: + with self.step_data_lock: + if self.execution.session.phase != Phase.READY or not self.parameters.commands_enabled: + return ServiceResult(False, self.reason) + devices = self._device_status(self.ports.monotonic()) + if not devices.ready: + self.execution.session.devices_unavailable() + self.reason = "; ".join(devices.waiting_for) + return ServiceResult(False, self.reason) + # Swap complete capture objects: an in-flight preview PnP callback + # may finish later, but cannot populate the new formal reference. + self.reference_lock = self._new_reference_lock() + self.reference_lock.begin_session() + self.trackers = self.parameters.new_trackers(self.profile.vision.view_names) + self.capture = ObservationCapture(self.profile, reference_lock=self.reference_lock, + extrinsics=self.parameters.extrinsics, tag_size_m=self.parameters.tag_size_m, trackers=self.trackers) + self.state_history.clear() + self.command_history.clear() + self.command_direction_history.clear() + self._command_directions = [""]*self.command_count + self.raw_records.clear() + self.execution.session.start(resume_requested=self.parameters.resume_raw_samples_path is not None) + self._started_at = self.ports.monotonic() + self._observation_epoch += 1 + self.reason = "moving_to_safe_baseline" + self._publish_torque() + return ServiceResult(True, "标定已开始;安全基准后锁定参考") + + def abort(self) -> ServiceResult: + with self.step_data_lock: + if self.state == "PASSED": + return ServiceResult(False, "calibration_already_published") + self._pause("operator_abort_holding_current_position") + self.execution.session.abort() + self.reason = "operator_abort_holding_current_position" + return ServiceResult(True, self.reason) + + def receive_feedback(self, names, positions, stamp_ns: int) -> None: + with self.step_data_lock: + self._accept_feedback(names, positions, stamp_ns) + + def receive_camera_info(self, view: str, message: CameraModelInput) -> None: + with self.step_data_lock: + self.cameras.accept(view, message, started=self.started, pause=self._pause, trackers=self.trackers) + + def receive_detections(self, observation: DetectionInput) -> None: + view, stamp = observation.view, observation.stamp_ns + with self.step_data_lock: + self.cameras.detections_received_at[view] = self.ports.monotonic() + if view not in self.cameras.matrices or self.state in {"PASSED", "PAUSED", "ABORTED", "FAILED"}: + return + capture, motion = self.capture, self._motion + epoch = self._observation_epoch + steady_after = self._steady_capture_after_ns + locking = self._reference_capture_key or self.execution.session.phase == Phase.REFERENCE_LOCKING + matrix = self.cameras.matrices[view].copy() + width, height = self.cameras.image_sizes[view] + feedback_history = tuple(self.state_history) + command_history = tuple(self.command_history) + direction_history = tuple(self.command_direction_history) + roles = {tag.tag_id: tag.role for tag in self._view(view).tags} + corners = {} + for detection in observation.detections: + points = np.asarray(detection.corners, dtype=float) + if (detection.tag_id not in roles or points.shape != (4, 2) or not np.all(np.isfinite(points)) + or detection.hamming > self.parameters.detection.maximum_hamming or detection.decision_margin < self.parameters.detection.minimum_decision_margin + or np.min(np.linalg.norm(points-np.roll(points, -1, axis=0), axis=1)) < self.parameters.detection.minimum_edge_pixels + or np.min(points) < 2 or np.max(points[:, 0]) > width-3 or np.max(points[:, 1]) > height-3): + continue + corners[roles[detection.tag_id]] = points + matched = interpolate_state_u8(feedback_history, stamp, maximum_skew_ns=self.parameters.maximum_state_image_skew_ns) + # Commands are sample-and-held by the transport, NOT interpolated + # from a future setpoint or read from the current motion timer. + command = next((row.position_u8 for row in reversed(command_history) + if row.stamp_ns <= stamp and stamp-row.stamp_ns <= self.parameters.maximum_state_image_skew_ns), None) + 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), ())) + record_motion = motion + if motion is not None and motion.phase == "steady" and (steady_after is None or stamp < steady_after): + record_motion = None + # 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. + rows, movement = capture.consume(frame, record_motion, collect_installations=locking) + with self.step_data_lock: + if (epoch != self._observation_epoch or capture is not self.capture + or motion is not self._motion): + return + if motion is not None and motion.phase == "steady" and steady_after != self._steady_capture_after_ns: + return + if movement is not None: + self._pause(f"fixed_reference_moved:{view}:{movement.drift_px}") + return + for row in rows: + self.raw_records.append(row) + self._unit_rows.append(row) + if motion is not None and motion.phase == "steady": + self._steady_rows.append(row) + append_jsonl(self.raw_path, row) + + def tick(self) -> None: + with self.step_data_lock: + self._advance() + + def snapshot(self) -> RuntimeSnapshot: + with self.step_data_lock: + return build_snapshot(self) + + def feedback_fresh(self) -> bool: + with self.step_data_lock: + return bool(self.state_receive_times and self.ports.monotonic()-self.state_receive_times[-1] <= 1.0) + + def close(self) -> None: + with self.step_data_lock: + self._observation_epoch += 1 + self.finalization.close() + + def _advance(self): + if self.state in {"PASSED", "PAUSED", "ABORTED", "FAILED"}: + return + now = self.ports.monotonic() + session = self.execution.session + fresh = self.sdk_adapter.health() + if not self.started: + devices = self._device_status(now) + if devices.ready: + if session.phase == Phase.WAIT_DEVICE: + session.device_ready() + self.reason = "ready_for_operator_start" + else: + session.devices_unavailable() + self.reason = "; ".join(devices.waiting_for) + return + current = self.last_command + if current is None: + if not self.state_history: + if now-self._started_at > self.profile.acquisition.feedback_stale_seconds: + self._pause("feedback_stale:开始后超过一秒没有新鲜反馈") + return # Require a post-Start observation, not a preview sample. + current = self.sdk_adapter.initial_command(self.latest_feedback) + decision = self.safety.evaluate(SafetySample(now, + self.state_receive_times[-1] if self.state_receive_times else None, + tuple(current), tuple(self.latest_feedback), fresh, + competing_controller=self.ports.command_publisher_count() > 1, + motion_expected=self.segment.motion_expected if self.segment else False, + motion_id=self.segment.identity if self.segment else "", + motion_goals=self.segment.goals if self.segment else ())) + if not decision.safe: + self._pause(f"{decision.code}:{decision.reason}:{decision.details}") + return + if self.finalization.started: + self._publish_command(list(current)) + self._poll_finalization() + return + phase = session.phase + if phase == Phase.REFERENCE_LOCKING: + self._publish_command(list(current)) + if not self.reference_lock.locking_enabled: + self.reference_lock.start_locking() + self.reason = "locking_fixed_base_references:missing="+",".join(self.reference_lock.missing_views) + if self.reference_lock.locked and set(self.capture.locked_poses) == set(self.profile.vision.view_names): + session.reference_locked() + if session.phase != Phase.REFERENCE_POSES: + self._save_reference() + return + if phase == Phase.RESUME_VERIFY: + self._verify_resume() + return + if phase == Phase.EVALUATE: + action = self.execution.action + quality = self.execution.evaluate(self.raw_records) + append_jsonl(self.raw_path, {"kind": "scan_unit_complete", "task_name": action.scan_unit.task_key, + "cycle": action.scan_unit.cycle, "direction": action.scan_unit.direction, + "attempt": action.retry+1, "passed": quality.passed, + "failures": list(quality.failures), "metrics": dict(quality.metrics)}) + if session.phase == Phase.PAUSED: + self._pause("sweep_quality_failed:"+",".join(quality.failures)) + return + if phase == Phase.FIT: + self._motion = None + self._finalize() + return + motion = self.execution.motion(current) + if motion is None: + if phase == Phase.PREPARE and session.current_unit is None: + session.preparation_complete() + return + self._pause(f"unhandled_session_phase:{phase.value}") + return + if self.segment is None: + if self.commanded_speed != motion.speed: + self._publish_speed(motion.speed) + self.step_speed_ready_at = now+float(self.parameters.speed_settle_seconds) + return + if now < self.step_speed_ready_at: + return + self._segment_number += 1 + self._motion = motion + self._steady_capture_after_ns = None + self._reference_capture_key = None + self._reference_observation_started = None + self._begin_motion_capture(motion) + self.segment = MotionExecution(self.profile, motion, initial_command=current, + initial_feedback=self.latest_feedback, now=now, identity=str(self._segment_number)) + shaped = self.segment.sample(now) + self._publish_command(list(shaped)) + self.segment.observe(self.latest_feedback, stamp=self.state_receive_times[-1], now=now) + self.reason = motion.phase+":"+str(motion.task_key) + steady_complete = False + if motion.phase == "steady": + if self.segment.steady_ready(now): + if self._steady_capture_after_ns is None: + 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)) + else: + self._steady_capture_after_ns = None + self._steady_rows.clear() + if self.segment.arrived(now) or steady_complete: + if motion.phase == "reference_pose": + waypoint = next(w for w in self.profile.motion.resume_verification_waypoints if w.key == motion.task_key) + if self._reference_observation_started is None: + self._reference_observation_started = now + self._reference_capture_key = waypoint.key + required = [f"{waypoint.key}/{view.name}:{tag.role}" + for view in self.profile.vision.views for tag in view.tags + if tag.tag_id in waypoint.tag_ids_by_view.get(view.name, ())] + enough = all(len(self.capture.installations.get(key, ())) >= self.reference_lock.minimum_frames for key in required) + if not enough and now-self._reference_observation_started < 2.0: + return + # Missing installation evidence invalidates reuse, not the + # entire fresh capture. Never forge a matching fingerprint. + self._reference_capture_key = None + self.execution.motion_complete() + if phase == Phase.REFERENCE_POSES and session.phase != phase: + self._save_reference() + self.segment, self._motion = None, None + + def _begin_motion_capture(self, motion): + """Keep direction statistics across its separate steady checkpoints.""" + self._steady_rows = [] + if motion.recording: + key = (motion.task_key, motion.cycle, motion.direction, motion.attempt) + if key != self._capture_unit_key: + self._unit_rows = [] + self._capture_unit_key = key + + def _device_status(self, now): + """Readiness is live, and independent of whether a Tag is visible. + + The two-second camera window is only a pre-Start condition. During + scans, transient visual loss still uses the post-direction data gate. + """ + health = self.sdk_adapter.health() + waiting = [] + if not health.connected: + waiting.append("SDK 未连接") + if len(self.latest_feedback) != self.command_count or not health.feedback_fresh: + waiting.append("等待完整、新鲜的 SDK 反馈") + if not health.position_mode: + waiting.append("SDK 尚未确认位置控制模式") + if health.active_faults: + waiting.append("SDK 活动故障:" + ",".join(health.active_faults)) + if self.ports.command_publisher_count() > 1: + waiting.append("存在其他机械手指令发布者") + cameras = {} + for view in self.profile.vision.view_names: + info_at = self.cameras.info_received_at.get(view) + detection_at = self.cameras.detections_received_at.get(view) + info_ready = (view in self.cameras.matrices and info_at is not None + and 0 <= now-info_at <= 2.0) + detection_ready = detection_at is not None and 0 <= now-detection_at <= 2.0 + error = self.cameras.errors.get(view, "") + cameras[view] = CameraReadiness(info_ready, detection_ready, error) + if error: + waiting.append(f"{view} 相机内参与外参不匹配或无效:{error}") + elif not info_ready: + waiting.append(f"{view} 未收到有效、新鲜的 camera_info(检查相机是否出图)") + if not detection_ready: + waiting.append(f"{view} 未收到新鲜的 Tag 检测消息(检查图像、整流和检测节点)") + return DeviceReadiness(not waiting, tuple(waiting), cameras) + + def _accept_feedback(self, names, positions, stamp: int) -> None: + state = self.sdk_adapter.parse_feedback(names, positions) + if state is None: + return + violation = next(( + (index, value) + for index, value in enumerate(state) + if value < self.feedback_lower[index] + or value > self.feedback_upper[index] + ), None) + if violation is not None: + index, value = violation + # Preserve the offending observation for the operator diagnostic, + # while the pause path continues to hold the last safe command. + self.latest_feedback = state + self._pause( + "feedback_outside_registered_feedback_domain:" + f"channel={self.command_names[index]}:value={value:.9f}:" + f"lower={self.feedback_lower[index]:.9f}:" + f"upper={self.feedback_upper[index]:.9f}" + ) + return + if stamp <= 0: + stamp = self.ports.clock_ns() + if not self.state_history or stamp > self.state_history[-1].stamp_ns: + self.state_history.append(StateSample(stamp, state)) + self.latest_feedback = state + self.state_receive_times.append(self.ports.monotonic()) + + def _publish_command(self, values): + if self.parameters.commands_enabled: + self.sdk_adapter.publish_position(values) + + def _write_position(self, values): + if not self.parameters.commands_enabled: + return + bounded = self.sdk_adapter.validate_command(values) + self.ports.publish_position(bounded) + self.last_command = bounded + if self.command_history: + for index, (previous, current) in enumerate(zip(self.command_history[-1].position_u8, bounded)): + if abs(current-previous) > 1e-9: + self._command_directions[index] = "increasing" if current > previous else "decreasing" + stamp = self.ports.clock_ns() + self.command_history.append(StateSample(stamp, bounded)) + self.command_direction_history.append((stamp, tuple(self._command_directions))) + + def _publish_speed(self, speed): + indices = self.profile.command.command_index_by_joint.values() + # Position and speed slots are mapped inside the protocol adapter. + for index in sorted(set(indices)): + self.sdk_adapter.set_speed(index, speed) + + def _write_speed(self, _slot, speed): + if self.command_unit != "u8": + self.commanded_speed = speed + return + if not self.parameters.commands_enabled or self.commanded_speed == int(speed): + return + # Position and speed vectors have separate layouts in the SDK protocol. + count = max(self.profile.command.speed_slot_by_command_index.values(), default=self.command_count-1)+1 + self.ports.publish_setting({"setting_cmd": "set_speed", "params": { + "hand_type": self.profile.key.side, "speed": [int(speed)]*count}}) + self.commanded_speed = int(speed) + + def _publish_torque(self): + if self.parameters.commands_enabled and self.command_unit == "u8": + self.ports.publish_setting({"setting_cmd": "set_max_torque_limits", "params": { + "hand_type": self.profile.key.side, + "torque": [self.parameters.torque_u8]*self.command_count}}) + + def _pause(self, reason: str) -> None: + with self.step_data_lock: + if self.state in {"PASSED", "ABORTED"} or self._pause_effects_applied: + return + self.finalization.cancel() + self._observation_epoch += 1 + self._pause_effects_applied = True + if self.execution.session.phase != Phase.PAUSED: + from .reporting.reasons_zh import reason_zh + code, explanation, suggestion = reason_zh({"reason": str(reason)}, model_name=self.model_name) + self.execution.session.pause(code, explanation, {"raw_reason": str(reason)}, suggestion=suggestion) + self._pause_locked(reason) + + def _pause_locked(self, reason: str) -> None: + self.reason = str(reason) + # In radian mode, hold the last command rather than feeding an + # uncalibrated feedback value back into the command domain. + if self.last_command is not None: + self._publish_command(list(self.last_command)) + # No prior command means no movement was authorized. In particular, + # never echo out-of-domain feedback as a "hold" during preview. + step = self._motion + target = [] if step is None else list(step.target) + errors = ( + [] + if len(target) != len(self.latest_feedback) + else [ + abs(float(actual) - float(expected)) + for actual, expected in zip(self.latest_feedback, target) + ] + ) + append_jsonl(self.raw_path, { + "kind": "paused", + "reason": self.reason, + "command_unit": self.command_unit, + f"latest_state_{self.command_unit}": list(self.latest_feedback), + f"target_state_{self.command_unit}": target, + f"channel_errors_{self.command_unit}": errors, + "maximum_error_channel": ( + None + if not errors + else self.command_names[int(np.argmax(errors))] + ), + f"maximum_error_{self.command_unit}": ( + None if not errors else max(errors) + ), + }) + + def _new_reference_lock(self): + return ReferenceLock( + { + view.name: next( + tag.tag_id for tag in view.tags if tag.fixed_reference + ) + for view in self.profile.vision.views + }, + minimum_frames=self.profile.acquisition.fixed_reference_minimum_frames, + maximum_corner_drift_px=( + self.profile.acquisition.fixed_reference_maximum_drift_px + ), + confirmation_frames=( + self.profile.acquisition.fixed_reference_confirmation_frames + ), + ) + + def _save_reference(self): + fixed, moving = self.capture.fingerprint_poses() + self._current_fingerprint = {"profile_id": self.profile.key.profile_id, + "acquisition_policy_version": self.execution.session.engine.checkpoint_token, + "protected_hashes": {**self.parameters.protected_inputs, "intrinsics_sha256": hashlib.sha256( + json.dumps({v: m.tolist() for v, m in sorted(self.cameras.matrices.items())}, sort_keys=True).encode()).hexdigest()}, + "fixed_corners_by_view": self.reference_lock.fingerprint(), "fixed_poses": fixed, + "moving_tag_poses": moving} + append_jsonl(self.raw_path, {"kind": "fixed_base_reference_locked", **self._current_fingerprint}) + + def _verify_resume(self): + decision = ResumeDecision(False, "基准变化,已放弃旧断点并重新采集") + rows = [] + try: + rows = load_capture(self.parameters.resume_raw_samples_path) + headers = [r for r in rows if r.get("kind") == "session_start"] + references = [r for r in rows if r.get("kind") == "fixed_base_reference_locked"] + if len(headers) != 1 or len(references) != 1: + raise ValueError("missing unique checkpoint header/reference") + header = headers[0] + if header.get("serial_number") != self.parameters.serial_number or not self.execution.session.engine.resume_compatible(header): + raise ValueError("checkpoint identity/policy changed") + complete = {(r["task_name"], r["cycle"], r["direction"]) for r in rows + if r.get("kind") == "scan_unit_complete" and r.get("passed") is True} + used_tasks = {key[0] for key in complete} + from .scan_quality import observation_streams + required = {f"{spec.view}:{role}" for task in self.profile.motion.tasks if task.key in used_tasks + for _, _, spec in observation_streams(self.profile, task) + for role in (spec.parent_role, spec.child_role) + if not any(tag.role == role and tag.fixed_reference for tag in self._view(spec.view).tags)} + verifier = ResumeVerifier(required_fixed_views=self.profile.vision.view_names, + required_fixed_poses=self.profile.vision.view_names, required_moving_poses=required, + required_hashes=self._current_fingerprint["protected_hashes"]) + previous = {**references[0], "acquisition_policy_version": header["acquisition_policy_version"]} + from .resume import select_installation_evidence + old, new = select_installation_evidence(fingerprint_from_mapping(previous), + fingerprint_from_mapping(self._current_fingerprint), required) + decision = verifier.compare(old, new) + if decision.reuse: + from dataclasses import replace + decision = replace(decision, completed_units=tuple(sorted(complete))) + except (OSError, ValueError, KeyError, TypeError) as error: + decision = ResumeDecision(False, decision.reason, (str(error),)) + decision = self.execution.restore(decision, rows) + if decision.reuse: + used = set(decision.completed_units) + imported = [r for r in rows if (r.get("task_name"), r.get("cycle"), r.get("direction")) in used] + for row in imported: + self.raw_records.append(row) + append_jsonl(self.raw_path, row) + self._resumed_count = len(used) + self._resume_message = decision.reason + append_jsonl(self.raw_path, {"kind": "resume_verification", "reuse": decision.reuse, + "reason": decision.reason, "incompatible_fields": list(decision.incompatible_fields), + "completed_units": list(decision.completed_units)}) + + def _view(self, name: str): + return next(view for view in self.profile.vision.views if view.name == name) + + def _feedback_hz(self) -> float: + if len(self.state_receive_times) < 2: + return 0.0 + elapsed = self.state_receive_times[-1] - self.state_receive_times[0] + return 0.0 if elapsed <= 0 else (len(self.state_receive_times) - 1) / elapsed + + def _finalize(self) -> None: + if self.finalization.started or self.execution.session.phase != Phase.FIT: + return + self.reason = "fitting_validating_and_writing_artifacts" + self.finalization.start(FinalizationInputs( + self.parameters.session_dir, self.parameters.serial_number, self.parameters.source_urdf, + self._current_fingerprint.get("protected_hashes", self.parameters.protected_inputs), + tuple(self.raw_records), + )) + + def _poll_finalization(self) -> None: + try: + result = self.finalization.poll(self._finalizer_event, self._authorize_commit) + if result is None: + return + correction = result[2] + except FinalizationProtocolError as error: + self._pause(f"finalization_protocol_error:{error}") + return + except Exception as error: + atomic_write_json(self.parameters.session_dir / "failure_diagnostic.json", { + "profile_id": self.profile.key.profile_id, "reason": str(error), + "traceback": traceback.format_exc(), "source_urdf_unchanged": True, + }) + self._pause(f"fit_or_publication_failed:{error}") + return + self.execution.session.published() + self.reason = "partial_calibration_passed" if self.profile.scope.default_scope != "full" else "full_calibration_passed" + self.final_json = str(self.parameters.session_dir / self.profile.artifacts.calibration_filename.format( + serial_number=self.parameters.serial_number, side=self.profile.key.side, model=self.model_name.lower())) + self.final_urdf = str(correction.path) + + def _finalizer_event(self, event: str) -> None: + session = self.execution.session + if event == "fit_complete": + session.fit_complete() + elif event == "holdout_complete": + session.holdout_complete(passed=True) + elif event == "artifacts_built": + session.artifacts_built() + elif event == "urdf_validated": + session.urdf_validated(passed=True) + else: + raise ValueError(f"unknown finalizer event:{event}") + + def _authorize_commit(self) -> None: + if self.execution.session.phase != Phase.PUBLISH: + raise RuntimeError("finalization did not complete all acceptance stages") diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py index f24ddd9..c64d8b3 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass, field +from dataclasses import dataclass import math from typing import Any, Mapping, Sequence @@ -11,7 +11,7 @@ import numpy as np from ..core import CalibrationProfile, TaskSpec, validate_profile -ACQUISITION_POLICY_VERSION = "unified_engine_v1" +from ..core.domain.profile import ACQUISITION_POLICY_VERSION TRAINING_CYCLES = (0, 1, 2) HOLDOUT_CYCLE = 3 @@ -34,23 +34,90 @@ class SweepQuality: metrics: Mapping[str, Any] -@dataclass(frozen=True) -class CalibrationResult: - """Unified internal result; serializers retain each deployed schema.""" - profile_id: str - policy_version: str - curves: Mapping[str, Any] - zero_offsets_rad: Mapping[str, float] - travel: Mapping[str, float] - coupling: Mapping[str, Any] - transfers: Mapping[str, str] - quality: Mapping[str, Any] - metadata: Mapping[str, Any] = field(default_factory=dict) + +def evaluate_observability( + feedback_progress_01: Sequence[float], + *, + minimum_span: float, + minimum_valid_samples: int, + minimum_bins: int, + maximum_unobserved_fraction: float, + total_frames: int, + joint_frame_rate: float, + feedback_hz: float, + detection_rate: float, + bin_count: int = 256, + include_endpoint_gaps: bool = False, +) -> SweepQuality: + """Evaluate the one model-independent post-sweep data gate.""" + if int(bin_count) < 32: + raise ValueError("normalized sweep bin count must be at least 32") + values = np.asarray(feedback_progress_01, dtype=float) + if values.ndim != 1: + raise ValueError("normalized sweep samples must be a vector") + valid = np.isfinite(values) & (values >= 0.0) & (values <= 1.0) + invalid_count = int(np.count_nonzero(~valid)) + # Out-of-domain data is not an observation of the nearest endpoint. + values = values[valid] + bins = sorted(set( + min(bin_count - 1, max(0, int(value * bin_count))) + for value in values + )) + span = float(np.ptp(values)) if values.size else 0.0 + internal_missing_runs = [ + right - left - 1 for left, right in zip(bins, bins[1:]) + ] + gap_scope = ( + "full_command_domain" if include_endpoint_gaps + else "observed_feedback_span" + ) + missing_runs = ( + [bins[0], bin_count - 1 - bins[-1], *internal_missing_runs] + if bins and include_endpoint_gaps + else internal_missing_runs + if bins + else [bin_count] + ) + maximum_gap = max(missing_runs, default=0) + allowed_gap = max( + 1, int(math.floor(bin_count * maximum_unobserved_fraction)) + ) + failures: list[str] = [] + if values.size < int(minimum_valid_samples): + failures.append(f"frames={values.size}") + if span < float(minimum_span): + failures.append("feedback_span") + if len(bins) < int(minimum_bins): + failures.append(f"bins={len(bins)}") + if maximum_gap > allowed_gap: + failures.append(f"maximum_gap={maximum_gap}") + warnings: list[str] = [] + if detection_rate < 0.95: + warnings.append(f"tag_rate={detection_rate:.3f}") + if joint_frame_rate < 0.85: + warnings.append(f"joint_frame_rate={joint_frame_rate:.3f}") + if feedback_hz <= 0.0: + warnings.append("feedback_rate_unavailable") + metrics = { + "policy_version": ACQUISITION_POLICY_VERSION, + "valid_frames": int(values.size), + "discarded_domain_samples": invalid_count, + "total_frames": int(total_frames), + "feedback_span": span, + "feedback_bins": len(bins), + "maximum_bin_gap": maximum_gap, + "allowed_maximum_bin_gap": allowed_gap, + "gap_scope": gap_scope, + "joint_frame_rate": float(joint_frame_rate), + "feedback_hz": float(feedback_hz), + "tag_detection_rate": float(detection_rate), + } + return SweepQuality(not failures, tuple(failures), tuple(warnings), metrics) class CalibrationEngine: - """One policy kernel shared by all model-specific ROS/SDK wrappers.""" + """Schedule and post-direction quality policy used by the single session executor.""" def __init__(self, profile: CalibrationProfile) -> None: validate_profile(profile) @@ -64,14 +131,16 @@ class CalibrationEngine: units: list[ScanUnit] = [] for task in self.profile.motion.tasks: speed = self._formal_speed(task) + forward = "increasing" if task.end_value > task.start_value else "decreasing" + reverse = "decreasing" if forward == "increasing" else "increasing" for cycle in (*TRAINING_CYCLES, HOLDOUT_CYCLE): units.extend(( ScanUnit( - task.key, cycle, "decreasing", task.start_value, + task.key, cycle, forward, task.start_value, task.end_value, speed, ), ScanUnit( - task.key, cycle, "increasing", task.end_value, + task.key, cycle, reverse, task.end_value, task.start_value, speed, ), )) @@ -81,6 +150,8 @@ class CalibrationEngine: """Only physical-angle profiles perform a single <=3 degree jog.""" if self.profile.command.unit != "rad": return None + if self.profile.acquisition.mapping_probe_maximum_rad <= 0: + return None distance = task.end_value - task.start_value return math.copysign( min(abs(distance), self.profile.acquisition.mapping_probe_maximum_rad), @@ -109,70 +180,26 @@ class CalibrationEngine: bin_count: int = 256, ) -> SweepQuality: """Judge fitting observability; ideal rates are diagnostics only.""" - values = np.asarray(feedback_progress_01, dtype=float) - values = np.clip(values[np.isfinite(values)], 0.0, 1.0) - bins = sorted(set( - min(bin_count - 1, max(0, int(value * bin_count))) - for value in values - )) - span = float(np.ptp(values)) if values.size else 0.0 - internal_missing_runs = [ - right - left - 1 for left, right in zip(bins, bins[1:]) - ] - # Byte-command products have a known 0..255 feedback domain, so an - # unobserved endpoint is a real acquisition hole. A radian product's - # feedback endpoint scale is itself being calibrated: its separate - # span/repeatability gate proves effective travel, while this gate must - # only reject holes *inside* that observed physical stroke. - gap_scope = ( - "full_command_domain" - if self.profile.command.unit == "u8" - else "observed_feedback_span" - ) - missing_runs = ( - [bins[0], bin_count - 1 - bins[-1], *internal_missing_runs] - if bins and gap_scope == "full_command_domain" - else internal_missing_runs - if bins - else [bin_count] - ) - maximum_gap = max(missing_runs, default=0) policy = self.profile.acquisition - allowed_gap = max( - 1, int(math.floor(bin_count * policy.maximum_unobserved_fraction)) + quality = evaluate_observability( + feedback_progress_01, + minimum_span=minimum_span, + minimum_valid_samples=policy.minimum_valid_samples, + minimum_bins=policy.minimum_bins, + maximum_unobserved_fraction=policy.maximum_unobserved_fraction, + total_frames=total_frames, + joint_frame_rate=joint_frame_rate, + feedback_hz=feedback_hz, + detection_rate=detection_rate, + bin_count=bin_count, + include_endpoint_gaps=False, ) - failures: list[str] = [] - if values.size < policy.minimum_valid_samples: - failures.append(f"frames={values.size}") - if span < float(minimum_span): - failures.append("feedback_span") - if len(bins) < policy.minimum_bins: - failures.append(f"bins={len(bins)}") - if maximum_gap > allowed_gap: - failures.append(f"maximum_gap={maximum_gap}") - warnings: list[str] = [] - if detection_rate < 0.95: - warnings.append(f"tag_rate={detection_rate:.3f}") - if joint_frame_rate < 0.85: - warnings.append(f"joint_frame_rate={joint_frame_rate:.3f}") - # A rate target is useful to diagnose latency but valid synchronized - # samples, coverage, and gaps are the actual correctness evidence. - if feedback_hz <= 0.0: - warnings.append("feedback_rate_unavailable") - metrics = { - "policy_version": ACQUISITION_POLICY_VERSION, - "valid_frames": int(values.size), - "total_frames": int(total_frames), - "feedback_span": span, - "feedback_bins": len(bins), - "maximum_bin_gap": maximum_gap, - "allowed_maximum_bin_gap": allowed_gap, - "gap_scope": gap_scope, - "joint_frame_rate": float(joint_frame_rate), - "feedback_hz": float(feedback_hz), - "tag_detection_rate": float(detection_rate), - } - return SweepQuality(not failures, tuple(failures), tuple(warnings), metrics) + if policy.require_endpoint_observations: + values = np.asarray(feedback_progress_01, dtype=float) + values = values[np.isfinite(values) & (values >= 0) & (values <= 1)] + if not values.size or float(np.min(values)) > policy.endpoint_tolerance_01 or float(np.max(values)) < 1.0 - policy.endpoint_tolerance_01: + return SweepQuality(False, (*quality.failures, "endpoint_observation"), quality.warnings, quality.metrics) + return quality def resume_compatible(self, session_start: Mapping[str, Any]) -> bool: return ( @@ -181,129 +208,7 @@ class CalibrationEngine: and session_start.get("profile_id") == self.profile.key.profile_id ) - def result_from_fit( - self, - fit_result: Any, - *, - transfers: Mapping[str, str] | None = None, - holdout_errors_rad: Mapping[str, Sequence[float]] | None = None, - curves: Mapping[str, Any] | None = None, - zero_offsets_rad: Mapping[str, float] | None = None, - travel: Mapping[str, float] | None = None, - coupling: Mapping[str, Any] | None = None, - metadata: Mapping[str, Any] | None = None, - ) -> CalibrationResult: - """Normalize a model fitter result before its legacy serializer runs. - Model-specific serializers deliberately remain responsible for the - deployed v4/v6/v7 JSON shapes. This object is the common publication - gate between fitting and both JSON/URDF writers. - """ - holdout = ( - holdout_errors_rad - if holdout_errors_rad is not None - else getattr(fit_result, "holdout_errors_rad", {}) - ) - quality_by_joint: dict[str, Mapping[str, float]] = {} - fit_diagnostics_by_joint: dict[str, Mapping[str, float]] = {} - all_errors: list[float] = [] - for name, values in dict(holdout).items(): - absolute = np.abs(np.asarray(tuple(values), dtype=float)) - if absolute.size == 0 or not np.all(np.isfinite(absolute)): - raise ValueError(f"holdout evidence is missing or invalid: {name}") - mae = float(np.mean(absolute)) - p95 = float(np.percentile(absolute, 95.0)) - maximum = float(np.max(absolute)) - if ( - mae > math.radians(1.0) - or p95 > math.radians(2.0) - or maximum > math.radians(3.0) - ): - raise ValueError(f"holdout quality failed: {name}") - quality_by_joint[str(name)] = { - "mae_rad": mae, - "p95_rad": p95, - "maximum_rad": maximum, - } - all_errors.extend(float(value) for value in absolute) - if not quality_by_joint: - raise ValueError("independent holdout evidence is required") - normalized_curves = dict( - curves if curves is not None else getattr(fit_result, "curves", {}) - ) - for name, curve in normalized_curves.items(): - diagnostics: dict[str, float] = {} - for field_name in ( - "maximum_hysteresis_rad", - "maximum_monotonic_correction_rad", - ): - value = getattr(curve, field_name, None) - if value is None: - continue - numeric = float(value) - if not math.isfinite(numeric) or numeric < 0.0: - raise ValueError( - f"fit diagnostic is invalid: {name}.{field_name}" - ) - diagnostics[field_name] = numeric - if diagnostics: - fit_diagnostics_by_joint[str(name)] = diagnostics - offsets = dict( - zero_offsets_rad - if zero_offsets_rad is not None - else getattr(fit_result, "zero_offsets_rad", {}) - ) - normalized_travel = dict( - travel - if travel is not None - else getattr( - fit_result, - "travels_rad", - getattr(fit_result, "travel", {}), - ) - ) - if not normalized_curves or not offsets: - raise ValueError("fit result is missing curves or zero offsets") - result = CalibrationResult( - profile_id=self.profile.key.profile_id, - policy_version=ACQUISITION_POLICY_VERSION, - curves=normalized_curves, - zero_offsets_rad=offsets, - travel=normalized_travel, - coupling=dict( - coupling - if coupling is not None - else getattr(fit_result, "mimic_fits", {}) - ), - transfers=dict(transfers or {}), - quality={ - "passed": True, - "training_cycles": TRAINING_CYCLES, - "holdout_cycle": HOLDOUT_CYCLE, - "holdout_by_joint": quality_by_joint, - "holdout_sample_count": len(all_errors), - # Direction-aware curves explicitly compensate repeatable - # mechanical backlash. Hysteresis is therefore diagnostic - # evidence, not a standalone rejection criterion; isolated - # holdout accuracy remains the publication gate. - "fit_diagnostics_by_joint": fit_diagnostics_by_joint, - }, - metadata=dict(metadata or {}), - ) - self.validate_result(result) - return result - - def validate_result(self, result: CalibrationResult) -> None: - if result.profile_id != self.profile.key.profile_id: - raise ValueError("calibration result belongs to another profile") - if result.policy_version != ACQUISITION_POLICY_VERSION: - raise ValueError("calibration result uses an obsolete policy") - if not bool(result.quality.get("passed")): - raise ValueError("calibration result is not publishable") - if tuple(result.quality.get("training_cycles", ())) != TRAINING_CYCLES: - raise ValueError("calibration result must use three training cycles") - if int(result.quality.get("holdout_cycle", -1)) != HOLDOUT_CYCLE: - raise ValueError("calibration result must use cycle four as holdout") def _formal_speed(self, task: TaskSpec) -> float: if self.profile.command.unit == "rad": @@ -317,6 +222,6 @@ class CalibrationEngine: __all__ = [ - "ACQUISITION_POLICY_VERSION", "CalibrationEngine", "CalibrationResult", + "ACQUISITION_POLICY_VERSION", "CalibrationEngine", "HOLDOUT_CYCLE", "ScanUnit", "SweepQuality", "TRAINING_CYCLES", ] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/execution.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/execution.py new file mode 100644 index 0000000..7f29f0b --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/execution.py @@ -0,0 +1,154 @@ +"""Translate the single session's actions into declarative motion effects. + +No ROS dependency, hand-identity branch, alternate retry loop or serializer. +The same driver is usable with real SDK transport and a virtual clock. +""" + +from dataclasses import replace +import math + +from .session import CalibrationPhase as Phase, CalibrationSession +from .motion_execution import MotionCommand +from .scan_quality import evaluate_capture_unit +from .trajectory import (build_calibration_preparation_waypoints, + build_calibration_return_waypoints, build_calibration_motion_command) + + +class SessionExecution: + def __init__(self, profile): + self.profile = profile + self.session = CalibrationSession(profile) + self.first_cycle_spans = {} + self.completed_units = set() + self.last_quality = None + self._effects = [] + self._action_key = None + self._entered_task = None + + @property + def action(self): + return self.session.action() + + def motion(self, current_command): + """Return the next effect for the current phase, without advancing it.""" + action = self.action + key = (action.phase, action.scan_unit, action.retry) + if key != self._action_key: + self._effects = list(self._make_effects(current_command)) + self._action_key = key + return self._effects[0] if self._effects else None + + def _make_effects(self, current): + action = self.action + params = self.profile.motion.speed_parameters + baseline_speed = float(params.get("baseline_rad_s", 0.1) if self.profile.command.unit == "rad" + else params.get("baseline_u8", params.get("preflight_u8", 1))) + if action.phase == Phase.REFERENCE_POSES: + waypoints = self.profile.motion.resume_verification_waypoints + result = [MotionCommand("reference_pose", waypoint.command, baseline_speed, task_key=waypoint.key) + for waypoint in waypoints] + result.extend(MotionCommand("reference_return", target, baseline_speed) + for target in build_calibration_return_waypoints(profile=self.profile, current_command=waypoints[-1].command)) + return tuple(result) + if action.phase in {Phase.BASELINE, Phase.RETURN_BASELINE}: + return tuple(MotionCommand("baseline" if action.phase == Phase.BASELINE else "return", target, baseline_speed) + for target in build_calibration_return_waypoints(profile=self.profile, current_command=current)) + unit = action.scan_unit + if unit is None: + return () + task = next(t for t in self.profile.motion.tasks if t.key == unit.task_key) + common = dict(task_key=task.key, command_index=task.command_index, + cycle=unit.cycle, attempt=action.retry+1) + if action.phase == Phase.PREPARE: + entry = [] + if self._entered_task != task.key: + pose = list(current) + for waypoint in task.entry_waypoints: + for index, value in waypoint: + pose[index] = value + entry.append(MotionCommand("clearance", tuple(pose), unit.speed, **common)) + self._entered_task = task.key + current = tuple(pose) + return tuple(entry) + tuple(MotionCommand("prepare", target, unit.speed, **common) + for target in build_calibration_preparation_waypoints(task, profile=self.profile, + current_command=current, start_value=unit.start)) + if action.phase == Phase.MAPPING_PROBE: + delta = self.session.engine.mapping_probe_delta(task) + start = tuple(build_calibration_motion_command(task, unit.start, profile=self.profile)) + target = list(start) + # Probe toward this task's other endpoint. The complete two-leg + # motion is <=3 degrees from the already prepared pose. + target[task.command_index] += math.copysign(abs(delta), unit.end-unit.start) + speed = float(task.preflight_speed) + return (MotionCommand("mapping_probe", tuple(target), speed, **common), + MotionCommand("mapping_return", start, speed, **common)) + if action.phase in {Phase.SWEEP, Phase.RESCAN}: + target = tuple(build_calibration_motion_command(task, unit.end, profile=self.profile)) + from .steady import steady_targets + start = tuple(build_calibration_motion_command(task, unit.start, profile=self.profile)) + effects = [MotionCommand("sweep", target, unit.speed, direction=unit.direction, **common), + MotionCommand("steady_prepare", start, unit.speed, **common)] + effects.extend(MotionCommand("steady", + tuple(build_calibration_motion_command(task, value, profile=self.profile)), + unit.speed, direction=unit.direction, steady_index=index, **common) + for index, value in enumerate(steady_targets(self.profile, unit))) + return tuple(effects) + return () + + def motion_complete(self): + if not self._effects: + raise RuntimeError("cannot complete a motion that was not requested") + self._effects.pop(0) + if self._effects: + return + phase = self.session.phase + if phase == Phase.BASELINE: + self.session.baseline_complete() + elif phase == Phase.REFERENCE_POSES: + self.session.reference_poses_complete() + elif phase == Phase.RETURN_BASELINE: + self.session.return_complete() + elif phase == Phase.PREPARE: + self.session.preparation_complete() + elif phase == Phase.MAPPING_PROBE: + self.session.mapping_probe_complete() + elif phase in {Phase.SWEEP, Phase.RESCAN}: + self.session.sweep_complete() + else: + raise RuntimeError(f"unexpected completed motion:{phase.value}") + + def evaluate(self, records): + if self.session.phase != Phase.EVALUATE: + raise RuntimeError("sweep evaluation outside EVALUATE") + action = self.action + quality = evaluate_capture_unit(self.profile, action.scan_unit, action.retry+1, + records, first_cycle_spans=self.first_cycle_spans) + self.last_quality = quality + if quality.passed: + unit = action.scan_unit + self.completed_units.add((unit.task_key, unit.cycle, unit.direction)) + self.session.evaluation_complete(quality) + return quality + + def restore(self, decision, records): + """Recheck each durable unit using the current, hash-bound policy.""" + self.first_cycle_spans.clear() + completed = set(decision.completed_units) if decision.reuse else set() + valid = [] + for unit in self.session.engine.scan_units(): + key = (unit.task_key, unit.cycle, unit.direction) + if key not in completed: + continue + attempts = [int(r.get("attempt", 1)) for r in records + if (r.get("task_name"), r.get("cycle"), r.get("direction")) == key] + quality = evaluate_capture_unit(self.profile, unit, max(attempts, default=1), + records, first_cycle_spans=self.first_cycle_spans) + if quality.passed: + valid.append(key) + checked = replace(decision, completed_units=tuple(valid)) + self.session.resume_checked(checked) + self.completed_units = set(valid) + return checked + + +__all__ = ["SessionExecution"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/inputs.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/inputs.py new file mode 100644 index 0000000..c5ea961 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/inputs.py @@ -0,0 +1,54 @@ +"""Transport-neutral observations and explicit coordinator I/O ports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Mapping, Sequence + +from .adapters.base import SdkAdapter + + +@dataclass(frozen=True) +class CameraModelInput: + width: int + height: int + k: tuple[float, ...] + d: tuple[float, ...] + r: tuple[float, ...] + p: tuple[float, ...] + + +@dataclass(frozen=True) +class TagDetection: + tag_id: int + corners: tuple[tuple[float, float], ...] + hamming: int + decision_margin: float + + +@dataclass(frozen=True) +class DetectionInput: + view: str + stamp_ns: int + detections: tuple[TagDetection, ...] + + +@dataclass(frozen=True) +class ServiceResult: + success: bool + message: str + + +@dataclass(frozen=True) +class RuntimePorts: + clock_ns: Callable[[], int] + monotonic: Callable[[], float] + command_publisher_count: Callable[[], int] + publish_position: Callable[[Sequence[float]], None] + publish_setting: Callable[[Mapping[str, object]], None] + + +AdapterFactory = Callable[ + [Callable[[Sequence[float]], None], Callable[[int, float], None], Callable[[], bool]], + SdkAdapter, +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py new file mode 100644 index 0000000..9b1975a --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/motion_execution.py @@ -0,0 +1,143 @@ +"""Clock-driven trajectory execution, independent of ROS and hand identity. + +The session owns business transitions. This module executes one whole segment +and supplies its stable feedback-space safety goals; it never retries or fits. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +import math + +from .safety import MotionGoal +from .trajectory import cosine_position_trajectory_u8, cosine_ramp_velocity_trajectory + + +@dataclass(frozen=True) +class MotionCommand: + phase: str + target: tuple[float, ...] + speed: float + task_key: str | None = None + command_index: int | None = None + cycle: int | None = None + direction: str | None = None + attempt: int = 1 + steady_index: int | None = None + + @property + def recording(self): + return self.direction is not None + + +class MotionExecution: + def __init__(self, profile, command: MotionCommand, *, initial_command, + initial_feedback, now: float, identity: str): + self.profile, self.command, self.identity = profile, command, str(identity) + self.start = tuple(float(v) for v in initial_command) + self.start_feedback = tuple(float(v) for v in initial_feedback) + self.started_at = float(now) + self.last_feedback_stamp = None + self.history = deque(maxlen=10) + self.stamped_history = deque(maxlen=200) + self.finished_at = None + self.phase = 0.0 + self.duration = 0.0 + self.fraction = 0.0 + layout = profile.command + for vector in (self.start, command.target): + if len(vector) != layout.command_count or any(not math.isfinite(v) or not lo <= v <= hi + for v, lo, hi in zip(vector, layout.minimum_values, layout.maximum_values)): + raise ValueError("motion command outside declared command domain") + if len(self.start_feedback) != layout.command_count: + raise ValueError("motion starts without complete feedback") + self.resolution = 0.5 if layout.unit == "u8" else 0.001 + self.stability = 2.0 if layout.unit == "u8" else 0.02 + probe = command.phase in {"mapping_probe", "mapping_return"} + # A mapping jog proves direction and basic electrical movement, not + # servo accuracy. Baseline corrections no larger than the mapping + # jog are not a "significant movement" request: nominal encoder bias + # at an already-open stop must not manufacture a stall. + self.minimum_request = (2.0 if layout.unit == "u8" else self.resolution if probe + else max(profile.acquisition.mapping_probe_maximum_rad, math.radians(3))) + self.required_fraction = 0.10 if probe else 0.80 + self.deltas = tuple(end-start for start, end in zip(self.start, command.target)) + self.moving = tuple(i for i, delta in enumerate(self.deltas) + if abs(delta) > self.minimum_request and i not in layout.disabled_indices) + self.goals = tuple(MotionGoal(i, self.start_feedback[i], + self.start_feedback[i] + self.required_fraction*self.deltas[i], self.resolution, self.resolution) + for i in self.moving) + + def sample(self, now: float) -> tuple[float, ...]: + distance = max((abs(v) for v in self.deltas), default=0.0) + elapsed = max(0.0, float(now)-self.started_at) + params = self.profile.motion.speed_parameters + if self.profile.command.unit == "u8": + travelled, phase, duration = cosine_position_trajectory_u8(0, distance, + elapsed, float(params.get("command_trajectory_full_range_seconds", 6.0))) + else: + speed = self.command.speed + caps = self.profile.command.maximum_velocity + if caps and distance > 0: + speed = min([speed] + [caps[i]*distance/abs(delta) + for i, delta in enumerate(self.deltas) if delta != 0]) + travelled, phase, duration = cosine_ramp_velocity_trajectory(0, distance, + elapsed, speed, float(params.get("trajectory_ramp_seconds", 0.4))) + self.phase, self.duration = phase, duration + fraction = 1.0 if distance == 0 else travelled/distance + self.fraction = fraction + values = tuple(start+delta*fraction for start, delta in zip(self.start, self.deltas)) + return tuple(float(round(v)) for v in values) if self.profile.command.unit == "u8" else values + + def observe(self, feedback, *, stamp, now): + # A fast timer is not ten independent feedback samples. + if stamp == self.last_feedback_stamp: + return + self.last_feedback_stamp = stamp + self.history.append(tuple(feedback)) + self.stamped_history.append((float(now), tuple(feedback))) + if self.phase >= 1.0 and self.finished_at is None: + self.finished_at = float(now) + + @property + def motion_expected(self): + # A cosine ramp initially requests less than encoder resolution. Start + # the watchdog only after meaningful demand, then keep the segment ID. + return bool(self.moving and self.phase > 0 and + any(abs(self.deltas[i])*self.fraction > self.minimum_request for i in self.moving)) + + def arrived(self, now): + if self.command.phase == "steady": + # Data sufficiency is evaluated after the direction; never wait + # indefinitely for a Tag or exact command/feedback equality. + return (self.finished_at is not None and + now-self.finished_at >= self.profile.acquisition.steady_timeout_seconds) + if self.phase < 1 or self.finished_at is None or len(self.history) < 3: + return False + hold = float(self.profile.motion.speed_parameters.get("endpoint_hold_seconds", 0.25)) + if now-self.finished_at < hold: + return False + for i in self.moving: + projected = math.copysign(1, self.deltas[i])*(self.history[-1][i]-self.start_feedback[i]) + if projected+self.resolution < self.required_fraction*abs(self.deltas[i]): + return False + if max(row[i] for row in self.history)-min(row[i] for row in self.history) > self.stability: + return False + return True + + def steady_ready(self, now): + if self.command.phase != "steady" or self.finished_at is None: + return False + window = self.profile.acquisition.steady_window_seconds + rows = [(stamp, value) for stamp, value in self.stamped_history + if stamp >= max(self.finished_at, now-window-0.05)] + if len(rows) < 3 or rows[-1][0]-rows[0][0] < window: + return False + i = self.command.command_index + threshold = max(2*self.resolution, 0.005*(self.profile.command.maximum_feedback_values[i] + - self.profile.command.minimum_feedback_values[i])) + return max(v[i] for _, v in rows)-min(v[i] for _, v in rows) <= threshold + + +__all__ = ["MotionCommand", "MotionExecution"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py deleted file mode 100644 index 580fb6c..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""ROS message/service shells selected through the local profile registry.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py deleted file mode 100644 index b167647..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Generic ROS-node dispatcher selected by the registered profile identity.""" - -from __future__ import annotations - -import argparse -import sys - -from ...compat import legacy_default_profile_key -from ...core import ProfileKey -from ...models import get_default_registry - - -def main(args: list[str] | None = None) -> None: - arguments = list(sys.argv[1:] if args is None else args) - selector = argparse.ArgumentParser(add_help=False) - selector.add_argument("--profile-id", default=None) - selected, remaining = selector.parse_known_args(arguments) - key = ( - legacy_default_profile_key() - if selected.profile_id is None - else ProfileKey.parse(selected.profile_id) - ) - get_default_registry().get(key).engine.node_main(remaining) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/parameters.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/parameters.py new file mode 100644 index 0000000..6a341cb --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/parameters.py @@ -0,0 +1,51 @@ +"""Resolved runtime inputs, independent of ROS parameter and message types.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +from ..core.geometry.extrinsics import CameraExtrinsics +from ..core.geometry.pnp import PoseTrackingParameters, SquareTagPoseTracker + + +@dataclass(frozen=True) +class DetectionPolicy: + maximum_hamming: int + minimum_decision_margin: float + minimum_edge_pixels: float + + +@dataclass(frozen=True) +class RuntimeParameters: + serial_number: str + session_dir: Path + source_urdf: Path + camera_extrinsics_file: Path + extrinsics: CameraExtrinsics + protected_inputs: Mapping[str, str] + resume_raw_samples_path: Path | None + command_topic: str + state_topic: str + setting_topic: str + camera_info_topics: Mapping[str, str] + detection_topics: Mapping[str, str] + commands_enabled: bool + speed_settle_seconds: float + torque_u8: int + maximum_state_image_skew_ns: int + command_rate_hz: float + tag_size_m: float + detection: DetectionPolicy + tracking: PoseTrackingParameters + + def new_trackers(self, views): + return {view: SquareTagPoseTracker( + maximum_reprojection_error_px=self.tracking.maximum_reprojection_error_px, + reprojection_tie_px=self.tracking.reprojection_tie_px, + maximum_pose_jump_rad=self.tracking.maximum_pose_jump_rad, + maximum_translation_jump_m=self.tracking.maximum_translation_jump_m, + maximum_tag_tilt_rad=self.tracking.maximum_tag_tilt_rad, + reset_after_seconds=self.tracking.reset_after_seconds, + ) for view in views} diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reference_lock.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reference_lock.py new file mode 100644 index 0000000..73b5d4a --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/reference_lock.py @@ -0,0 +1,171 @@ +"""Session-scoped fixed-Tag locking shared by every hand profile.""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import Mapping, Sequence + +import numpy as np + + +@dataclass(frozen=True) +class ReferenceObservation: + view: str + tag_id: int + corners_px: tuple[tuple[float, float], ...] + + +@dataclass(frozen=True) +class ReferenceMovement: + view: str + tag_id: int + drift_px: float + confirmation_frames: int + + +class ReferenceLock: + """Discard preview data, robustly lock once, then never rewrite it.""" + + def __init__( + self, + fixed_tag_by_view: Mapping[str, int], + *, + minimum_frames: int = 10, + maximum_corner_drift_px: float = 5.0, + confirmation_frames: int = 10, + ) -> None: + if minimum_frames < 3: + raise ValueError("reference lock requires at least three frames") + if maximum_corner_drift_px <= 0.0 or confirmation_frames < 1: + raise ValueError("reference movement limits are invalid") + self.fixed_tag_by_view = { + str(view): int(tag_id) for view, tag_id in fixed_tag_by_view.items() + } + if not self.fixed_tag_by_view: + raise ValueError("at least one fixed reference Tag is required") + self.minimum_frames = int(minimum_frames) + self.maximum_corner_drift_px = float(maximum_corner_drift_px) + self.confirmation_frames = int(confirmation_frames) + self.preview_corners: dict[str, np.ndarray] = {} + self._buffers = { + view: deque(maxlen=max(self.minimum_frames, 30)) + for view in self.fixed_tag_by_view + } + self._references: dict[str, np.ndarray] = {} + self._drift_counts = {view: 0 for view in self.fixed_tag_by_view} + self._latest_drift = {view: 0.0 for view in self.fixed_tag_by_view} + self.session_started = False + self.locking_enabled = False + + @property + def locked(self) -> bool: + return set(self._references) == set(self.fixed_tag_by_view) + + @property + def missing_views(self) -> tuple[str, ...]: + return tuple( + view for view in self.fixed_tag_by_view if view not in self._references + ) + + @property + def references(self) -> Mapping[str, np.ndarray]: + return {view: value.copy() for view, value in self._references.items()} + + @property + def latest_drift_px(self) -> Mapping[str, float]: + return dict(self._latest_drift) + + def begin_session(self) -> None: + """Make all observations collected before Start preview-only.""" + self.session_started = True + self.locking_enabled = False + self._references.clear() + for buffer in self._buffers.values(): + buffer.clear() + for view in self._drift_counts: + self._drift_counts[view] = 0 + self._latest_drift[view] = 0.0 + + def start_locking(self) -> None: + """Start formal collection only after the baseline trajectory ends.""" + if not self.session_started: + raise RuntimeError("the calibration session has not started") + self.locking_enabled = True + self._references.clear() + for buffer in self._buffers.values(): + buffer.clear() + for view in self._drift_counts: + self._drift_counts[view] = 0 + self._latest_drift[view] = 0.0 + + def observe( + self, + view: str, + tag_id: int, + corners_px: Sequence[Sequence[float]], + ) -> ReferenceMovement | None: + selected_view = str(view) + if self.fixed_tag_by_view.get(selected_view) != int(tag_id): + return None + corners = np.asarray(corners_px, dtype=float) + if corners.shape != (4, 2) or not np.all(np.isfinite(corners)): + return None + if not self.session_started: + self.preview_corners[selected_view] = corners.copy() + return None + if not self.locking_enabled: + return None + reference = self._references.get(selected_view) + if reference is None: + buffer = self._buffers[selected_view] + buffer.append(corners.copy()) + if len(buffer) >= self.minimum_frames: + self._references[selected_view] = np.median( + np.asarray(tuple(buffer), dtype=float), axis=0 + ) + buffer.clear() + return None + drift = float(np.max(np.linalg.norm(corners - reference, axis=1))) + self._latest_drift[selected_view] = drift + self._drift_counts[selected_view] = ( + self._drift_counts[selected_view] + 1 + if drift > self.maximum_corner_drift_px + else 0 + ) + if self._drift_counts[selected_view] < self.confirmation_frames: + return None + return ReferenceMovement( + selected_view, + int(tag_id), + drift, + self._drift_counts[selected_view], + ) + + def fingerprint(self) -> dict[str, list[list[float]]]: + if not self.locked: + raise ValueError("fixed references have not been locked") + return { + view: [[float(value) for value in point] for point in corners] + for view, corners in sorted(self._references.items()) + } + + def compatible_with( + self, + fingerprint: Mapping[str, Sequence[Sequence[float]]], + ) -> bool: + if not self.locked or set(fingerprint) != set(self._references): + return False + for view, reference in self._references.items(): + previous = np.asarray(fingerprint[view], dtype=float) + if previous.shape != (4, 2) or not np.all(np.isfinite(previous)): + return False + drift = float( + np.max(np.linalg.norm(reference - previous, axis=1)) + ) + if drift > self.maximum_corner_drift_px: + return False + return True + + +__all__ = ["ReferenceLock", "ReferenceMovement", "ReferenceObservation"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/__init__.py index c926162..4c111bc 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/__init__.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/__init__.py @@ -1,3 +1,6 @@ -from .codes import CalibrationErrorCode +"""Shared calibration progress and diagnostic messages.""" -__all__ = ["CalibrationErrorCode"] +from .progress_zh import progress_fraction, render_progress_zh +from .reasons_zh import reason_zh + +__all__ = ["progress_fraction", "reason_zh", "render_progress_zh"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/codes.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/codes.py deleted file mode 100644 index 09d16c1..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/codes.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Model-independent runtime error categories.""" - -from enum import Enum - - -class CalibrationErrorCode(str, Enum): - PROFILE_INVALID = "profile_invalid" - PREFLIGHT_FAILED = "preflight_failed" - MOTION_FAILED = "motion_failed" - ACQUISITION_FAILED = "acquisition_failed" - TASK_QUALITY_FAILED = "task_quality_failed" - SESSION_SOLVE_FAILED = "session_solve_failed" - URDF_VALIDATION_FAILED = "urdf_validation_failed" - PUBLICATION_FAILED = "publication_failed" diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/progress_zh.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/progress_zh.py new file mode 100644 index 0000000..2c0513c --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/progress_zh.py @@ -0,0 +1,202 @@ +"""Unified Chinese console progress renderer for every hand profile.""" + +from __future__ import annotations + +from typing import Any, Callable, Mapping + +from ...operator_report import ( + ProgressEstimator, + render_compact_progress_header_zh, +) + + +STATE_LABELS = { + "WAIT_DEVICES": "等待设备反馈和相机内参", + "READY": "设备就绪", + "RUNNING": "标定中", + "FINALIZING": "拟合、验证并生成 URDF", + "PASSED": "通过", + "PAUSED": "已暂停", + "ABORTED": "已中止", +} +PHASE_LABELS = { + "baseline": "安全恢复基准形态", + "preflight": "任务运动预检", + "prepare": "扫描起点准备", + "retry_prepare": "自动重扫起点准备", + "resume_prepare": "断点恢复起点准备", + "sweep": "正式扫描", + "clearance": "手指避让", + "clearance_outer": "小指/无名指避让", + "clearance_splay": "侧摆避让", + "return_splay_zero": "侧摆回零", + "return_middle_open": "展开中指", + "return_outer_open": "展开小指/无名指", +} +DIRECTION_LABELS = {"decreasing": "递减", "increasing": "递增"} + + +def progress_fraction(status: Mapping[str, Any]) -> tuple[float, int, int]: + state = str(status.get("state", "")) + step_count = max(0, int(status.get("step_count", 0) or 0)) + step_index = int(status.get("step_index", -1) or 0) + step_fraction = float(status.get("step_fraction", 0.0) or 0.0) + if state == "PASSED": + overall = 1.0 + elif step_count and step_index >= 0: + overall = min(1.0, max(0.0, (step_index + step_fraction) / step_count)) + else: + overall = 0.0 + current = min(step_count, max(0, step_index + 1)) if step_count else 0 + return overall, current, step_count + + +def _duration_zh(seconds: float | None) -> str: + if seconds is None or seconds < 0.0: + return "计算中" + value = int(round(seconds)) + return f"{value // 60}分{value % 60:02d}秒" + + +def render_progress_zh( + status: Mapping[str, Any], + *, + task_labels: Mapping[str, str], + reason_renderer: Callable[ + [Mapping[str, Any]], tuple[str, str, str] + ], + estimator: ProgressEstimator | None = None, +) -> str: + """Render the shared progress/status contract with model labels injected.""" + state = str(status.get("state", "")) + overall, _current_step, _step_count = progress_fraction(status) + task = status.get("task_name") + phase = status.get("phase") + if task: + task_text = task_labels.get(str(task), str(task)) + elif phase == "baseline": + task_text = "全手基准姿态" + elif state == "WAIT_DEVICES": + task_text = "等待设备连接" + elif state == "READY": + task_text = "等待开始" + else: + task_text = "无" + eta = _duration_zh(estimator.remaining(overall) if estimator else None) + cycle = status.get("cycle") + cycle_text = "-" if cycle is None else str(int(cycle) + 1) + direction_text = DIRECTION_LABELS.get(str(status.get("direction")), "-") + command_unit = str(status.get("command_unit", "u8")) + target = ( + status.get("target_rad") + if command_unit == "rad" + else status.get("target_u8") + ) + requested = status.get("current_command_u8", target) + actual = status.get("actual_u8") + if command_unit == "rad": + requested = "未知" if requested is None else f"{float(requested):.3f} rad" + actual_text = "未知" if actual is None else f"{float(actual):.3f} rad" + else: + requested = "未知" if requested is None else f"{float(requested):.1f}" + actual_text = "未知" if actual is None else f"{float(actual):.1f}" + valid = int(status.get("valid_frames", 0) or 0) + total = int(status.get("total_frames", 0) or 0) + rate = float(status.get("tag_detection_rate", 0.0) or 0.0) + joint_rate = float( + status.get( + "joint_frame_rate", (float(valid) / total) if total else 0.0 + ) or 0.0 + ) + recognized = [int(value) for value in status.get("recognized_tag_ids", [])] + unrecognized = [ + int(value) for value in status.get("unrecognized_tag_ids", []) + ] + if str(status.get("reason", "")).startswith("locking_fixed_base_references"): + unrecognized = [ + int(value) + for value in status.get("reference_missing_tag_ids", unrecognized) + ] + recognized_text = "/".join(f"ID{value}" for value in recognized) or "无" + unrecognized_text = "/".join(f"ID{value}" for value in unrecognized) or "无" + attempt = int(status.get("attempt", 1) or 1) + lines = render_compact_progress_header_zh( + serial_number=str(status.get("serial_number", "?")), + progress=overall, + eta=eta, + stage=PHASE_LABELS.get(str(phase), STATE_LABELS.get(state, state)), + cycle=cycle_text, + repetitions=4, + task=task_text, + requested=requested, + actual=actual_text, + direction=direction_text, + tag_status=( + f"已识别 {recognized_text};未识别/不合格 {unrecognized_text};" + f"本方向各Tag最低 {rate:.1%};联合 {valid}/{total} 帧" + f"({joint_rate:.1%})" + ), + ready_cameras=3, + feedback_hz=float(status.get("feedback_hz", 0.0) or 0.0), + valid_frames=valid, + automatic_retry_count=max(0, attempt - 1), + ) + required_by_view = status.get("required_tag_ids_by_view", {}) + if isinstance(required_by_view, Mapping) and required_by_view: + lines.append( + "任务所需Tag:" + ";".join( + f"{view}=" + "/".join(f"ID{int(tag_id)}" for tag_id in ids) + for view, ids in required_by_view.items() + ) + ) + speed_u8 = status.get("speed_u8") + if speed_u8 is not None: + seconds = float( + status.get("command_trajectory_full_range_seconds", 0.0) or 0.0 + ) + lines.append(f"运动:速度档 {int(speed_u8)};全行程 {seconds:.1f} 秒余弦轨迹") + speed_rad_s = status.get("speed_rad_s") + if speed_rad_s is not None: + seconds = float( + status.get("command_trajectory_duration_seconds", 0.0) or 0.0 + ) + lines.append( + f"运动:峰值 {float(speed_rad_s):.3f} rad/s;" + f"本段 {seconds:.1f} 秒平滑限速轨迹" + ) + latest_state = status.get(f"latest_state_{command_unit}", []) + command_names = status.get("command_names", []) + if ( + isinstance(latest_state, (list, tuple)) + and isinstance(command_names, (list, tuple)) + and len(latest_state) == len(command_names) + and len(command_names) in {6, 12} + and (phase == "baseline" or state in {"PAUSED", "ABORTED"}) + ): + digits = 3 if command_unit == "rad" else 1 + suffix = " rad" if command_unit == "rad" else "" + feedback_text = ", ".join( + f"{name}={float(value):.{digits}f}{suffix}" + for name, value in zip(command_names, latest_state) + ) + error_channel = status.get("maximum_error_channel") + maximum_error = status.get(f"maximum_error_{command_unit}") + error_text = ( + "未知" + if error_channel is None or maximum_error is None + else f"{error_channel}={float(maximum_error):.{digits}f}{suffix}" + ) + channel_label = "六路" if len(command_names) == 6 else "十二路" + error_label = "最大命令/反馈差" if command_unit == "rad" else "最大偏差" + lines.append( + f"{channel_label}反馈:{feedback_text} {error_label}:{error_text}" + ) + if state in {"PAUSED", "ABORTED"}: + _code, problem, suggestion = reason_renderer(status) + lines.extend((f"原因:{problem}", f"建议:{suggestion}")) + if status.get("reference_locked"): + lines.append("基准已锁定:请勿移动手掌、相机、支架或Tag") + return "\n".join(lines) + + +__all__ = ["progress_fraction", "render_progress_zh"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py new file mode 100644 index 0000000..c1bd9ed --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/reporting/reasons_zh.py @@ -0,0 +1,168 @@ +"""Shared Chinese operator explanations for common calibration failures.""" + +from __future__ import annotations + +import re +from typing import Any, Mapping + + +def reason_zh( + status: Mapping[str, Any], *, model_name: str +) -> tuple[str, str, str]: + """Map a common runtime reason to a stable code, explanation and action.""" + reason = str(status.get("reason", "unknown")) + if "compact_byte_requires_measured_full_command_domain" in reason: + return ("FIT-COMPACT-DOMAIN-507", "实测指令范围没有完整覆盖 0–255,不能生成完整的 256 项查表。", + "核对扫描范围和端点稳态数据;不得用反馈表、外推或裁剪补齐缺失指令。") + if "compact_command_table_holdout_failed" in reason: + return ("FIT-COMPACT-HOLDOUT-508", "精简单张指令查表未通过独立视觉验收,已禁止发布。", + "查看具体角度和空间误差;若回差过大,单张表不能准确表达实机,不自动回退或重新调参。") + if "steady_" in reason and ("failed" in reason or "incomplete" in reason or "missing" in reason): + return ("FIT-COMMAND-MAPPING-506", "稳态指令与独立视觉观测不足或不一致,已禁止发布指令映射。", + "查看具体关节、方向、稳态点及诊断;反馈曲线不能替代缺失的指令标定。") + live_failures = { + "duplicate_controller": ("DEVICE-COMMAND-CONFLICT-204", "检测到另一个命令控制节点。", "退出 GUI 或其他控制节点,只保留本次标定。"), + "fixed_reference_moved": ("OBS-BASE-DRIFT-105", "已锁定的固定基准连续 10 帧漂移超过 5 px。", "检查手掌、相机、支架和固定 Tag;重新固定后开始新采集。"), + "sdk_disconnected": ("DEVICE-COMMUNICATION-202", "SDK 通信或健康报告持续失效。", "检查供电、连接及 SDK 日志;不要直接重复强推动作。"), + "wrong_control_mode": ("DEVICE-MODE-205", "SDK 未确认位置控制模式。", "确认 POSITION 模式,退出竞争控制进程后重新开始。"), + "hardware_fault": ("DEVICE-ACTIVE-FAULT-201", "SDK 报告活动硬件故障。", "根据下方原始错误码检查机械阻挡、温度和通信。"), + "feedback_stale": ("DEVICE-FEEDBACK-203", "SDK 反馈连续超过 1 秒未更新。", "检查 SDK 回读和总线;终端进度不变不等于反馈失联。"), + "physical_range_exceeded": ("DEVICE-PHYSICAL-RANGE-204", "命令或反馈越过声明的物理范围。", "核对通道、单位、编码器范围;不要通过扩大边界掩盖异常。"), + "protected_camera_model_changed": ("OBS-CAMERA-MODEL-106", "开始后相机投影模型或图像尺寸发生变化。", "中止后重新确认相机参数;涉及机位关系变化时重标外参。"), + "finalization_protocol_error": ("FIT-PROTOCOL-502", "拟合与发布阶段顺序异常,已禁止发布。", "保留当前会话和日志,检查后台验收的完整诊断。"), + "invalid_motion_target": ("MOTION-CONTRACT-304", "运动段缺少有效的反馈推进目标。", "检查 Profile 和轨迹诊断;不要继续发送未经验证的运动。"), + } + if reason.split(":", 1)[0] in live_failures: + return live_failures[reason.split(":", 1)[0]] + runner_failures = { + "calibration_node_process_exited": "标定状态发布进程已退出,不能继续本次采集。", + "calibration_stack_exited": "标定 ROS 进程栈已退出。", + "calibration_node_initial_status_timeout": "启动后未收到标定节点状态;这不是机械堵转诊断。", + "calibration_device_not_ready": "设备尚未满足启动条件,未自动开始任务运动。", + "calibration_start_service_unavailable": "设备报告就绪,但标定开始服务不可用。", + "calibration_start_response_timeout": "开始请求未收到确认,不能判断任务是否已启动;运行栈将退出。", + "calibration_start_rejected": "标定节点拒绝了开始请求。", + "ros_context_shutdown": "ROS 通信上下文已关闭。", + "independent_release_evidence_missing": "本次数据已通过旧格式会话校验,但缺少该格式要求的独立发布证据。", + "existing_calibration_publishers": "检测到已有 SDK 或控制进程,不能重复启动当前标定栈。", + } + if reason.split(":", 1)[0] in runner_failures: + return ( + "RUNTIME-LIFECYCLE-500", runner_failures[reason.split(":", 1)[0]], + "查看原始原因、runner_diagnostic.json 和 calibration.log;确认进程及设备状态后再启动。", + ) + # Legacy transport spellings are decoded here, not in a product runner. + if reason.startswith(("o12_feedback_stream_timeout", "feedback_stream_timeout")): + return ("DEVICE-FEEDBACK-203", "SDK 反馈连续超过 1 秒未更新。", + "检查供电、SDK 和实际总线连接;不要把终端进度不变当成反馈失联。") + if reason.startswith("o12_active_motor_fault"): + return ("DEVICE-ACTIVE-FAULT-201", f"SDK 返回活动电机故障:{status.get('error_faults', reason)}。", + "检查对应电机错误码、机械阻挡和温度;排除活动故障后再恢复。") + if reason.startswith("o12_new_communication_fault"): + return ("DEVICE-COMMUNICATION-202", f"SDK 运行中报告新的通信故障:{status.get('error_faults', reason)}。", + "检查 HCAN 连接、供电和相关电机通信。") + if reason.startswith(("feedback_outside_registered_feedback_domain", "command_outside_physical_range")): + return ("DEVICE-PHYSICAL-RANGE-204", f"命令或反馈超出登记的物理范围:{reason}。", + "核对通道、单位和硬件限位;不得扩大软件边界来掩盖异常。") + if reason.startswith(("mapping_preflight_no_target_motion", "mapping_preflight_wrong_feedback_direction")): + return ("DEVICE-MAPPING-301", f"固定通道点动未证实要求的目标反馈运动或方向:{reason}。", + "检查 SDK 通道与机械运动;不要自动交换映射或增加强推幅度。") + if "standard_urdf_mimic_not_expressive" in reason: + return ( + "FIT-STANDARD-MIMIC-504", + "独立视觉数据不能由同一条标准 URDF 线性 mimic 在规定精度内解释,已禁止发布。", + "检查 Tag 刚性和重复性;若机构确为非线性,应明确报告标准 URDF 表达能力不足,不能用 JSON 或端点比例补偿。", + ) + if "corrected range exceeds source mechanical range" in reason or "URDF mimic reachable range exceeds limits" in reason: + return ( + "URDF-PHYSICAL-RANGE-505", + f"修正范围或递归 mimic 与原始 CAD 机械限位不一致:{reason}。", + "保留原始 CAD 和测量数据,核实限位及坐标定义;不要扩大限位或裁剪曲线来制造通过结果。", + ) + if reason.startswith("fixed_base_tag_moved:"): + fields = reason.split(":") + view = fields[1] if len(fields) > 1 else "unknown" + view_label = str(status.get("view_label", view)) + match = re.search(r"drift_px=([0-9.]+)", reason) + drift = match.group(1) if match else "未知" + limit = float(status.get("base_corner_drift_limit_px", 5.0) or 5.0) + return ( + "OBS-BASE-DRIFT-105", + f"{view_label}机位的固定基准 Tag 连续漂移,最大角点位移 " + f"{drift} px,超过 {limit:g} px。", + "检查手掌支架、相机和固定基准 Tag 是否松动或被碰触;" + "固定后重新开始。程序已禁止发布本次结果。", + ) + if reason.startswith("sweep_quality_failed:"): + details = reason.split(":", 2)[-1] + return ( + "OBS-SWEEP-QUALITY-104", + "当前方向经过自动重扫后仍未满足采集门限;具体未通过项:" + f"{details}。", + "查看会话诊断中的有效样本、覆盖、分箱和数据空白;" + "处理持续遮挡或同步问题后从断点继续。", + ) + if reason.startswith("multiple_state_publishers:"): + count = status.get("state_publisher_count", "?") + return ( + "DEVICE-DUPLICATE-SDK-203", + f"检测到 {count} 个 {model_name} 状态发布者,可能已有 SDK/GUI 未退出。", + "停止单独启动的 SDK 和 GUI,只保留本次标定自动拉起的进程。", + ) + if reason.startswith("multiple_command_publishers:"): + count = status.get("command_publisher_count", "?") + return ( + "DEVICE-COMMAND-CONFLICT-204", + f"检测到 {count} 个 {model_name} 命令发布者。", + "停止 GUI、手动控制节点或其他标定进程,只保留当前标定命令。", + ) + if reason.startswith("mechanical_stall:"): + return ( + "MOTION-STALL-303", + "目标电机连续两秒没有向目标推进,程序已保持当前位置。", + "检查碰撞、摩擦和机械端点;不要连续重启强推。", + ) + if reason.startswith("motion_timeout:"): + return ( + "MOTION-TIMEOUT-302", + "当前运动在规定时间内没有完成。", + "检查 SDK 反馈、电机状态和机械阻挡后重新开始。", + ) + if reason.startswith("fit_or_publication_failed:"): + if "mimic_residual_exceeds:" in reason or "coupling_residual_exceeds:" in reason: + joint = re.search(r"joint=([^:]+)", reason) + model = re.search(r"model=([^:]+)", reason) + multiplier = re.search(r"(?:multiplier|linear_term)=([0-9.]+)", reason) + p95 = re.search(r"p95_deg=([0-9.]+)", reason) + maximum = re.search(r"maximum_deg=([0-9.]+)", reason) + return ( + "FIT-MIMIC-503", + f"{joint.group(1) if joint else '未知关节'} 的 " + f"{model.group(1) if model else 'linear_mimic'} " + "耦合模型未达到精度门限:" + f"线性项 {multiplier.group(1) if multiplier else '未知'}," + f"P95={p95.group(1) if p95 else '未知'}°、" + f"最大={maximum.group(1) if maximum else '未知'}°。", + "原始视觉曲线已保留;请检查 Tag 刚性、遮挡与机械重复性," + "不要放宽门限或发布错误 URDF。", + ) + detail = reason.split(":", 1)[1] if ":" in reason else "未知" + return ( + "FIT-PUBLISH-501", + f"拟合、holdout 或 URDF 安全写回失败:{detail}。", + "保留本会话和源 URDF;查看诊断日志。", + ) + if reason.startswith("operator_abort"): + return ( + "OPERATOR-ABORT-001", + "操作员主动中止了本次标定。", + "排除现场问题后重新开始。", + ) + return ( + "CALIBRATION-500", + f"{model_name} 标定停止,原始原因:{reason}。", + "保留会话目录和运行日志,并复制诊断信息给开发者。", + ) + + +__all__ = ["reason_zh"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py new file mode 100644 index 0000000..4c63766 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/resume.py @@ -0,0 +1,324 @@ +"""Reference- and input-bound checkpoint compatibility for every model.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +import math +from pathlib import Path +import re +from typing import Any, Mapping, Sequence + +import numpy as np + +from .engine import ACQUISITION_POLICY_VERSION + + +def discover_resume_candidate( + session_root: Path, *, profile_id: str, serial_number: str, + protected_hashes: Mapping[str, str], +) -> Path | None: + """Locate evidence, never authorize its reuse or publish from a checkpoint. + + The online owner must still verify the current physical reference and every + reused Tag installation. A newer empty/truncated attempt cannot shadow an + older complete capture. Old source files are never modified. + """ + if not protected_hashes or any(re.fullmatch(r"[0-9a-f]{64}", value) is None + for value in protected_hashes.values()): + raise ValueError("resume discovery requires valid protected hashes") + root = session_root.resolve() + if not root.is_dir(): + return None + released = { + pointer.resolve() for pointer in root.iterdir() + if pointer.name.startswith("latest_") and "passed" in pointer.name and pointer.is_symlink() + } + model, side, layout, _revision = profile_id.split("/") + for candidate in sorted(root.iterdir(), key=lambda path: path.name, reverse=True): + if candidate.is_symlink() or not candidate.is_dir() or candidate.name.startswith("latest_"): + continue + if candidate in released or any(candidate.name <= path.name for path in released if path.parent == root): + continue + start = None + reference_found = False + completed_found = False + try: + summary_path = candidate / "calibration_summary_zh.json" + if summary_path.exists(): + summary = json.loads(summary_path.read_text(encoding="utf-8")) + if not isinstance(summary, Mapping) or summary.get("result") != "FAIL": + # A successful capture awaiting a separate release proof + # is not a failed checkpoint. Reusing it cannot create an + # independent second calibration session. + continue + with (candidate / "raw_samples.jsonl").open(encoding="utf-8") as stream: + for line in stream: + if not line.strip(): + continue + row = json.loads(line) + if not isinstance(row, dict): + raise ValueError("invalid checkpoint record") + kind = str(row.get("kind", "")) + if kind == "session_start": + if start is not None: + raise ValueError("multiple checkpoint session headers") + start = row + if kind == "fixed_base_reference_locked": + reference_found = True + if kind == "scan_unit_complete" or kind.endswith("sweep_observation_quality"): + # Old direction records used an explicit empty failure + # list instead of a boolean. This merely finds a source; + # the node still validates every imported unit/view. + passed = row.get("passed") is True or ( + "passed" not in row and row.get("failures") == [] + and int(row.get("valid_frames", 0)) >= 40 + and int(row.get("feedback_bins", 0)) >= 32 + ) + completed_found = completed_found or passed + except (OSError, ValueError): + continue + if start is None or not reference_found or not completed_found: + continue + recorded_id = start.get("profile_id") + if recorded_id is None: + # Read-only legacy journal spelling; the profile hash remains + # mandatory and identifies the exact YAML contract. + if (start.get("model"), start.get("hand_type"), start.get("tag_layout")) != (model, side, layout): + continue + elif recorded_id != profile_id: + continue + if start.get("serial_number", root.name) != serial_number: + continue + if start.get("acquisition_policy_version") != ACQUISITION_POLICY_VERSION: + continue + recorded = start.get("protected_hashes", start) + if not isinstance(recorded, Mapping) or any(recorded.get(key) != value for key, value in protected_hashes.items()): + continue + return candidate + return None + + +@dataclass(frozen=True) +class TagPoseFingerprint: + rotation_xyzw: tuple[float, float, float, float] + translation_xyz_m: tuple[float, float, float] + + +@dataclass(frozen=True) +class ResumeFingerprint: + profile_id: str + protected_hashes: Mapping[str, str] + fixed_corners_by_view: Mapping[str, tuple[tuple[float, float], ...]] + fixed_poses: Mapping[str, TagPoseFingerprint] = field(default_factory=dict) + moving_tag_poses: Mapping[str, TagPoseFingerprint] = field(default_factory=dict) + acquisition_policy_version: str = ACQUISITION_POLICY_VERSION + + +@dataclass(frozen=True) +class ResumeDecision: + reuse: bool + reason: str + incompatible_fields: tuple[str, ...] = () + completed_units: tuple[tuple[str, int, str], ...] = () + + +def select_installation_evidence(previous, current, required_roles): + """Compare a reused Tag at the SAME baseline or named verification pose. + + Unrelated Tags do not authorize reuse, and a missing fingerprint cannot be + replaced with another pose's installation. Do not mutate either journal. + """ + from dataclasses import replace + old, new = {}, {} + shared = previous.moving_tag_poses.keys() & current.moving_tag_poses.keys() + for role in required_roles: + candidates = sorted(key for key in shared if key == role or key.endswith("/"+role)) + if not candidates: + continue # ResumeVerifier will reject the missing required role. + key = role if role in candidates else candidates[0] + old[role], new[role] = previous.moving_tag_poses[key], current.moving_tag_poses[key] + return replace(previous, moving_tag_poses=old), replace(current, moving_tag_poses=new) + + +class ResumeVerifier: + """A mismatch starts fresh; it is never a live safety pause.""" + + def __init__( + self, + *, + maximum_corner_drift_px: float = 5.0, + maximum_rotation_rad: float = math.radians(2.0), + maximum_translation_m: float = 0.005, + required_fixed_views: Sequence[str] = (), + required_fixed_poses: Sequence[str] = (), + required_moving_poses: Sequence[str] = (), + required_hashes: Sequence[str] = (), + ) -> None: + self.maximum_corner_drift_px = float(maximum_corner_drift_px) + self.maximum_rotation_rad = float(maximum_rotation_rad) + self.maximum_translation_m = float(maximum_translation_m) + if not all(math.isfinite(value) and value > 0 for value in ( + self.maximum_corner_drift_px, self.maximum_rotation_rad, self.maximum_translation_m + )): + raise ValueError("resume tolerances must be finite and positive") + self.required_fixed_views = frozenset(required_fixed_views) + self.required_fixed_poses = frozenset(required_fixed_poses) + self.required_moving_poses = frozenset(required_moving_poses) + self.required_hashes = frozenset(required_hashes) + + def compare( + self, previous: ResumeFingerprint, current: ResumeFingerprint + ) -> ResumeDecision: + failures: list[str] = [] + for label, fingerprint in (("previous", previous), ("current", current)): + if not fingerprint.profile_id: + failures.append(f"{label}.profile_id") + if not fingerprint.protected_hashes or not self.required_hashes <= fingerprint.protected_hashes.keys(): + failures.append(f"{label}.required_hashes") + if any(re.fullmatch(r"[0-9a-f]{64}", str(value)) is None for value in fingerprint.protected_hashes.values()): + failures.append(f"{label}.invalid_hash") + if not fingerprint.fixed_corners_by_view or not self.required_fixed_views <= fingerprint.fixed_corners_by_view.keys(): + failures.append(f"{label}.required_fixed_views") + if not fingerprint.fixed_poses or not self.required_fixed_poses <= fingerprint.fixed_poses.keys(): + failures.append(f"{label}.required_fixed_poses") + if not self.required_moving_poses <= fingerprint.moving_tag_poses.keys(): + failures.append(f"{label}.required_moving_poses") + if previous.acquisition_policy_version != ACQUISITION_POLICY_VERSION: + failures.append("acquisition_policy_version") + if current.acquisition_policy_version != ACQUISITION_POLICY_VERSION: + failures.append("current_acquisition_policy_version") + if previous.profile_id != current.profile_id: + failures.append("profile_id") + if dict(previous.protected_hashes) != dict(current.protected_hashes): + failures.append("protected_hashes") + self._compare_corners(previous, current, failures) + self._compare_poses( + "fixed_pose", previous.fixed_poses, current.fixed_poses, failures + ) + self._compare_poses( + "moving_tag_pose", + previous.moving_tag_poses, + current.moving_tag_poses, + failures, + ) + if failures: + return ResumeDecision( + False, + "基准变化,已放弃旧断点并重新采集", + tuple(sorted(set(failures))), + ) + return ResumeDecision(True, "断点基准和 Tag 安装关系验证通过") + + def _compare_corners( + self, + previous: ResumeFingerprint, + current: ResumeFingerprint, + failures: list[str], + ) -> None: + if set(previous.fixed_corners_by_view) != set( + current.fixed_corners_by_view + ): + failures.append("fixed_reference_views") + return + for view, previous_corners in previous.fixed_corners_by_view.items(): + old = np.asarray(previous_corners, dtype=float) + new = np.asarray(current.fixed_corners_by_view[view], dtype=float) + if old.shape != (4, 2) or new.shape != (4, 2): + failures.append(f"fixed_corners[{view}]") + continue + drift = float(np.max(np.linalg.norm(old - new, axis=1))) + if not math.isfinite(drift) or drift > self.maximum_corner_drift_px: + failures.append(f"fixed_corners[{view}]") + + def _compare_poses( + self, + prefix: str, + previous: Mapping[str, TagPoseFingerprint], + current: Mapping[str, TagPoseFingerprint], + failures: list[str], + ) -> None: + if set(previous) != set(current): + failures.append(f"{prefix}s") + return + for name, old in previous.items(): + new = current[name] + old_translation = np.asarray(old.translation_xyz_m, dtype=float) + new_translation = np.asarray(new.translation_xyz_m, dtype=float) + if (old_translation.shape != (3,) or new_translation.shape != (3,) + or not np.all(np.isfinite(old_translation)) or not np.all(np.isfinite(new_translation))): + failures.append(f"{prefix}[{name}].translation") + continue + translation = float( + np.linalg.norm( + old_translation - new_translation + ) + ) + rotation = _quaternion_distance_rad( + old.rotation_xyzw, new.rotation_xyzw + ) + if translation > self.maximum_translation_m: + failures.append(f"{prefix}[{name}].translation") + if rotation > self.maximum_rotation_rad: + failures.append(f"{prefix}[{name}].rotation") + + +def _quaternion_distance_rad( + left: Sequence[float], right: Sequence[float] +) -> float: + first = np.asarray(tuple(left), dtype=float) + second = np.asarray(tuple(right), dtype=float) + if first.shape != (4,) or second.shape != (4,): + return math.inf + if not np.all(np.isfinite(first)) or not np.all(np.isfinite(second)): + return math.inf + first_norm = float(np.linalg.norm(first)) + second_norm = float(np.linalg.norm(second)) + if first_norm <= 0.0 or second_norm <= 0.0: + return math.inf + dot = abs(float(np.dot(first / first_norm, second / second_norm))) + return 2.0 * math.acos(min(1.0, max(-1.0, dot))) + + +def fingerprint_from_mapping(payload: Mapping[str, Any]) -> ResumeFingerprint: + """Strictly decode persisted v2 data; incomplete v1 data cannot be reused.""" + + def poses(key: str) -> dict[str, TagPoseFingerprint]: + return { + str(name): TagPoseFingerprint( + tuple(float(value) for value in value["rotation_xyzw"]), + tuple(float(value) for value in value["translation_xyz_m"]), + ) + for name, value in dict(payload.get(key, {})).items() + } + + return ResumeFingerprint( + profile_id=str(payload.get("profile_id", "")), + protected_hashes={ + str(key): str(value) + for key, value in dict(payload.get("protected_hashes", {})).items() + }, + fixed_corners_by_view={ + str(view): tuple( + tuple(float(coordinate) for coordinate in point) + for point in corners + ) + for view, corners in dict( + payload.get("fixed_corners_by_view", {}) + ).items() + }, + fixed_poses=poses("fixed_poses"), + moving_tag_poses=poses("moving_tag_poses"), + acquisition_policy_version=str( + payload.get("acquisition_policy_version", "") + ), + ) + + +__all__ = [ + "ResumeDecision", + "ResumeFingerprint", + "ResumeVerifier", + "TagPoseFingerprint", + "fingerprint_from_mapping", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/__init__.py new file mode 100644 index 0000000..aa55c25 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/__init__.py @@ -0,0 +1 @@ +"""Thin ROS interfaces for the single calibration session engine.""" diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/calibration_node.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/calibration_node.py new file mode 100644 index 0000000..eab7d6f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/calibration_node.py @@ -0,0 +1,104 @@ +"""ROS identity, parameters and callback wiring for the shared coordinator.""" + +import time + +from apriltag_msgs.msg import AprilTagDetectionArray +from rclpy.callback_groups import MutuallyExclusiveCallbackGroup +from rclpy.executors import MultiThreadedExecutor +from rclpy.node import Node +from rclpy.qos import qos_profile_sensor_data +from sensor_msgs.msg import CameraInfo, JointState +from std_msgs.msg import String +from std_srvs.srv import Trigger + +from ..adapters.ros_binding import SdkBindingPorts, bind_ros_sdk +from ..coordinator import CalibrationCoordinator +from ..inputs import RuntimePorts +from .io import RosCalibrationIO, _stamp_ns, camera_input, detection_input +from .parameters import load_runtime_parameters, parameter_defaults + + +class UnifiedCalibrationNode(Node): + def __init__(self, profile): + super().__init__(f"{profile.key.model.lower()}_calibration") + for name, default in parameter_defaults(profile).items(): + self.declare_parameter(name, default) + parameters = load_runtime_parameters(profile, lambda name: self.get_parameter(name).value) + self.motion_callback_group = MutuallyExclusiveCallbackGroup() + self.vision_callback_groups = { + view: MutuallyExclusiveCallbackGroup() for view in profile.vision.view_names} + self.io = RosCalibrationIO(self, profile, parameters) + ports = RuntimePorts(lambda: int(self.get_clock().now().nanoseconds), time.monotonic, + lambda: self.count_publishers(parameters.command_topic), + self.io.publish_position, self.io.publish_setting) + + def subscribe_health(receive): + self.create_subscription(String, + parameters.command_topic.rsplit("/", 1)[0]+"/calibration_health", + lambda message: receive(message.data), 10, callback_group=self.motion_callback_group) + + def adapter_factory(publish, set_speed, feedback_fresh): + return bind_ros_sdk(profile, SdkBindingPorts(feedback_fresh, ports.monotonic, subscribe_health), + publish=publish, set_speed=set_speed) + + self.coordinator = CalibrationCoordinator(profile, parameters, ports, adapter_factory) + self.create_subscription(JointState, parameters.state_topic, self._state_callback, 30, + callback_group=self.motion_callback_group) + for view in profile.vision.view_names: + self.create_subscription(CameraInfo, parameters.camera_info_topics[view], + lambda message, selected=view: self._camera_info_callback(selected, message), + qos_profile_sensor_data, callback_group=self.vision_callback_groups[view]) + self.create_subscription(AprilTagDetectionArray, parameters.detection_topics[view], + lambda message, selected=view: self._detections_callback(selected, message), + qos_profile_sensor_data, callback_group=self.vision_callback_groups[view]) + self.create_service(Trigger, f"{profile.namespace}/start", self._start, + callback_group=self.motion_callback_group) + self.create_service(Trigger, f"{profile.namespace}/abort", self._abort, + callback_group=self.motion_callback_group) + self.create_timer(1.0/parameters.command_rate_hz, self._tick, + callback_group=self.motion_callback_group) + self.create_timer(0.5, self._publish_status) + + def _start(self, _request, response): + result = self.coordinator.start() + response.success, response.message = result.success, result.message + return response + + def _abort(self, _request, response): + result = self.coordinator.abort() + response.success, response.message = result.success, result.message + return response + + def _state_callback(self, message): + self.coordinator.receive_feedback(tuple(message.name), tuple(message.position), + _stamp_ns(message.header.stamp)) + + def _camera_info_callback(self, view, message): + self.coordinator.receive_camera_info(view, camera_input(message)) + + def _detections_callback(self, view, message): + self.coordinator.receive_detections(detection_input(view, message)) + + def _tick(self): + self.coordinator.tick() + + def _publish_status(self): + self.io.publish_status(self.coordinator.snapshot()) + + def destroy_node(self): + self.coordinator.close() + return super().destroy_node() + + +def run_profile_node(profile, args=None): + import rclpy + rclpy.init(args=args) + node = UnifiedCalibrationNode(profile) + executor = MultiThreadedExecutor(num_threads=4) + executor.add_node(node) + try: + executor.spin() + finally: + executor.shutdown() + node.destroy_node() + rclpy.shutdown() diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/entrypoint.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/entrypoint.py new file mode 100644 index 0000000..62c11bd --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/entrypoint.py @@ -0,0 +1,38 @@ +"""Protected Profile handoff to the online execution implementation.""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path +import sys + +from ...core import ProfileKey +from ...profiles import load_hand_profile + + +def run_profile_node(profile, args): + from .calibration_node import run_profile_node as run + return run(profile, args) + + +def main(args: list[str] | None = None) -> None: + arguments = list(sys.argv[1:] if args is None else args) + selector = argparse.ArgumentParser(add_help=False) + selector.add_argument("--profile-id", default=None) + selector.add_argument("--profile-config", default=None) + selector.add_argument("--profile-sha256", default=None) + selected, remaining = selector.parse_known_args(arguments) + if not all((selected.profile_id, selected.profile_config, selected.profile_sha256)): + raise ValueError("online calibration requires a protected YAML Profile; use calibrate_hand --config") + key = ProfileKey.parse(selected.profile_id) + path = Path(selected.profile_config) + def verify_hash(): + if hashlib.sha256(path.read_bytes()).hexdigest() != selected.profile_sha256: + raise ValueError("Profile changed between launch and calibration-node startup") + verify_hash() + declared = load_hand_profile(path) + verify_hash() + if declared.key != key: + raise ValueError("protected YAML Profile identity differs from --profile-id") + return run_profile_node(declared, remaining) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/io.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/io.py new file mode 100644 index 0000000..edba2c1 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/io.py @@ -0,0 +1,59 @@ +"""ROS message conversion and publication; no calibration business state.""" + +from __future__ import annotations + +import json + +from sensor_msgs.msg import JointState +from std_msgs.msg import String + +from ..inputs import CameraModelInput, DetectionInput, TagDetection +from ..status import normalize_status + + +def _stamp_ns(stamp) -> int: + return int(stamp.sec)*1_000_000_000 + int(stamp.nanosec) + + +def camera_input(message) -> CameraModelInput: + return CameraModelInput(int(message.width), int(message.height), + tuple(message.k), tuple(message.d), tuple(message.r), tuple(message.p)) + + +def detection_input(view: str, message) -> DetectionInput: + return DetectionInput(view, _stamp_ns(message.header.stamp), tuple( + TagDetection(int(tag.id), tuple((float(p.x), float(p.y)) for p in tag.corners), + int(tag.hamming), float(tag.decision_margin)) for tag in message.detections)) + + +class RosCalibrationIO: + """Explicit transport object composed by UnifiedCalibrationNode.""" + + def __init__(self, node, profile, parameters): + self.profile = profile + self.serial_number = parameters.serial_number + self.clock = node.get_clock() + self.command_publisher = node.create_publisher(JointState, parameters.command_topic, 10) + self.setting_publisher = node.create_publisher(String, parameters.setting_topic, 10) + self.status_publisher = node.create_publisher(String, f"{profile.namespace}/status", 10) + + def publish_position(self, values) -> None: + message = JointState() + message.header.stamp = self.clock.now().to_msg() + message.name = [] if self.profile.command.feedback_by_index else list(self.profile.command.names) + message.position = list(values) + self.command_publisher.publish(message) + + def publish_setting(self, payload) -> None: + message = String() + message.data = json.dumps(payload) + self.setting_publisher.publish(message) + + def publish_status(self, snapshot) -> None: + message = String() + message.data = json.dumps(normalize_status(snapshot.as_dict(), + profile=self.profile, serial_number=self.serial_number), ensure_ascii=False) + self.status_publisher.publish(message) + + +__all__ = ["RosCalibrationIO"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/parameters.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/parameters.py new file mode 100644 index 0000000..7ec295e --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/ros/parameters.py @@ -0,0 +1,160 @@ +"""Declare and convert ROS parameters into explicit coordinator inputs.""" + +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any, Callable + +from ...core.geometry.extrinsics import load_camera_extrinsics +from ...core.geometry.pnp import DEFAULT_POSE_TRACKING_PARAMETERS, PoseTrackingParameters +from ..parameters import DetectionPolicy, RuntimeParameters + + +def parameter_defaults(profile) -> dict[str, Any]: + model = profile.key.model.lower() + namespace = profile.namespace + speed_parameters = profile.motion.speed_parameters + defaults: dict[str, Any] = { + "serial_number": "UNSET", + "session_dir": "", + "source_urdf_path": "", + "source_urdf_expected_sha256": "", + "camera_extrinsics_file": "", + "camera_extrinsics_expected_sha256": "", + "calibration_config_expected_sha256": "", + "tag_config_expected_sha256": "", + "sdk_config_expected_sha256": "", + "sdk_package_expected_sha256": "", + "profile_config_expected_sha256": "", + "resume_raw_samples_path": "", + "command_topic": f"/{model}/cb_right_hand_control_cmd", + "state_topic": f"/{model}/cb_right_hand_state", + "setting_topic": f"/{model}/cb_hand_setting_cmd", + "commands_enabled": True, + "speed_settle_seconds": float( + speed_parameters.get("speed_settle_seconds", 0.2) + ), + "torque_u8": int(speed_parameters.get("torque_u8", 80)), + "maximum_state_image_skew_ms": 50.0, + "maximum_hamming": 0, + "minimum_decision_margin": 30.0, + "minimum_edge_pixels": 30.0, + "tag_size_m": 0.016, + "pnp_maximum_reprojection_error_px": DEFAULT_POSE_TRACKING_PARAMETERS.maximum_reprojection_error_px, + "pnp_reprojection_tie_px": DEFAULT_POSE_TRACKING_PARAMETERS.reprojection_tie_px, + "pnp_maximum_pose_jump_deg": math.degrees(DEFAULT_POSE_TRACKING_PARAMETERS.maximum_pose_jump_rad), + "pnp_maximum_translation_jump_m": DEFAULT_POSE_TRACKING_PARAMETERS.maximum_translation_jump_m, + "pnp_maximum_tag_tilt_deg": math.degrees(DEFAULT_POSE_TRACKING_PARAMETERS.maximum_tag_tilt_rad), + "pnp_tracker_reset_seconds": DEFAULT_POSE_TRACKING_PARAMETERS.reset_after_seconds, + "command_rate_hz": float(speed_parameters.get("command_rate_hz", 100.0)), + } + for view in profile.vision.view_names: + defaults[f"{view}_camera_info_topic"] = ( + f"{namespace}/{view}/camera/camera_info" + ) + defaults[f"{view}_detections_topic"] = ( + f"{namespace}/{view}/apriltag/detections" + ) + return defaults + + +def load_runtime_parameters(profile, value: Callable[[str], Any]) -> RuntimeParameters: + serial_number = str(value("serial_number")) + session_dir = Path(str(value("session_dir"))).expanduser().resolve() + source_urdf = Path(str(value("source_urdf_path"))).expanduser().resolve() + camera_extrinsics_file = Path( + str(value("camera_extrinsics_file")) + ).expanduser().resolve() + if serial_number in {"", "UNSET"}: + raise ValueError("serial_number is required") + if not str(value("session_dir")) or not source_urdf.is_file(): + raise ValueError("session_dir and immutable source_urdf_path are required") + if not camera_extrinsics_file.is_file(): + raise ValueError( + f"camera_extrinsics_file is required for {profile.key.model} zero solve" + ) + extrinsics = load_camera_extrinsics( + camera_extrinsics_file, + 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, + ) + protected_inputs = { + "source_urdf_sha256": str(value("source_urdf_expected_sha256")), + "camera_extrinsics_sha256": str( + value("camera_extrinsics_expected_sha256") + ), + "calibration_config_sha256": str( + value("calibration_config_expected_sha256") + ), + "tag_config_sha256": str(value("tag_config_expected_sha256")), + } + if "sdk_config_sha256" in profile.artifacts.protected_input_fields: + protected_inputs["sdk_config_sha256"] = str( + value("sdk_config_expected_sha256") + ) + if "sdk_package_sha256" in profile.artifacts.protected_input_fields: + protected_inputs["sdk_package_sha256"] = str(value("sdk_package_expected_sha256")) + if "profile_config_sha256" in profile.artifacts.protected_input_fields: + protected_inputs["profile_config_sha256"] = str( + value("profile_config_expected_sha256") + ) + resume_value = str(value("resume_raw_samples_path")).strip() + resume_raw_samples_path = ( + None + if not resume_value + else Path(resume_value).expanduser().resolve() + ) + if ( + set(protected_inputs) + != profile.artifacts.protected_input_fields + or any(len(item) != 64 for item in protected_inputs.values()) + ): + raise ValueError("all profile-protected SHA-256 values are required") + command_topic = str(value("command_topic")) + state_topic = str(value("state_topic")) + setting_topic = str(value("setting_topic")) + camera_info_topics = { + view: str(value(f"{view}_camera_info_topic")) + for view in profile.vision.view_names + } + detection_topics = { + view: str(value(f"{view}_detections_topic")) + for view in profile.vision.view_names + } + skew_ns = int(float(value("maximum_state_image_skew_ms"))*1_000_000) + rate = float(value("command_rate_hz")) + settle = float(value("speed_settle_seconds")) + if not 0 < skew_ns <= 50_000_000: + raise ValueError("image/feedback synchronization bound must be within 50 ms") + if not 0 < rate <= 200: + raise ValueError("command_rate_hz must be in (0, 200]") + if not 0 <= settle <= 5: + raise ValueError("speed_settle_seconds must be in [0, 5]") + return RuntimeParameters( + serial_number=serial_number, session_dir=session_dir, source_urdf=source_urdf, + camera_extrinsics_file=camera_extrinsics_file, extrinsics=extrinsics, + protected_inputs=protected_inputs, resume_raw_samples_path=resume_raw_samples_path, + command_topic=command_topic, state_topic=state_topic, setting_topic=setting_topic, + camera_info_topics=camera_info_topics, detection_topics=detection_topics, + commands_enabled=bool(value("commands_enabled")), speed_settle_seconds=settle, + torque_u8=int(value("torque_u8")), maximum_state_image_skew_ns=skew_ns, + command_rate_hz=rate, tag_size_m=float(value("tag_size_m")), + detection=DetectionPolicy(int(value("maximum_hamming")), + float(value("minimum_decision_margin")), float(value("minimum_edge_pixels"))), + tracking=tracking_parameters(value), + ) + + +def tracking_parameters(value: Callable[[str], Any]) -> PoseTrackingParameters: + """ROS exposes degrees; the shared pose core receives radians.""" + return PoseTrackingParameters( + maximum_reprojection_error_px=float(value("pnp_maximum_reprojection_error_px")), + reprojection_tie_px=float(value("pnp_reprojection_tie_px")), + maximum_pose_jump_rad=math.radians(float(value("pnp_maximum_pose_jump_deg"))), + maximum_translation_jump_m=float(value("pnp_maximum_translation_jump_m")), + maximum_tag_tilt_rad=math.radians(float(value("pnp_maximum_tag_tilt_deg"))), + reset_after_seconds=float(value("pnp_tracker_reset_seconds")), + ) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py index 298395b..fde6b92 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner.py @@ -1,58 +1,243 @@ -"""Generic assembly of a registered profile and the session controller.""" +"""One process lifecycle for every product; no delegation to model runners. + +ROS imports are lazy, allowing tests of the actual CLI and lifecycle without +hardware. Pending legacy status/artifact contracts are isolated in compat. +""" from __future__ import annotations import argparse +import os +from pathlib import Path +import subprocess +import time +from typing import Any, Callable, Mapping from ..compat import default_product_config_path -from ..core import ProfileKey -from ..core.solver import SessionSolver, TaskEvaluator -from ..models import ProfileRegistry, get_default_registry -from ..product import load_product_config -from .controller import SessionController +from ..product import ProductConfig, load_product_config +from ..storage import atomic_write_json +from .status import normalize_status, render_status_zh -def build_session_controller( - profile_key: ProfileKey, - evaluator: TaskEvaluator, - solver: SessionSolver, - *, - registry: ProfileRegistry | None = None, -) -> SessionController: - selected_registry = registry or get_default_registry() - registered = selected_registry.get(profile_key) - return SessionController(registered.profile, evaluator, solver) +TERMINAL_STATES = frozenset({"COMPLETE", "PAUSED", "ABORTED", "FAILED"}) -def build_registered_runtime( - profile_key: ProfileKey, - *, - registry: ProfileRegistry | None = None, -): - """Assemble the same engine/adapter pair for any registered hand.""" - selected_registry = registry or get_default_registry() - registered = selected_registry.get(profile_key) - return registered.build_engine(), registered.build_sdk_adapter() +def drive_session( + monitor: Any, process: Any, *, spin: Callable[[], None], + ok: Callable[[], bool], request_start: Callable[[], Any], + clock: Callable[[], float] = time.monotonic, auto_start: bool = True, + startup_timeout: float = 120.0, service_timeout: float = 10.0, +) -> dict[str, Any]: + """Send Start once; never infer an SDK fault from console/status age. + + Missing references after Start may wait indefinitely. Only startup/service + discovery have runner deadlines; hardware safety belongs to the node. + """ + launched = clock() + ready_since: float | None = None + requested_at: float | None = None + future = None + start_acknowledged = False + latest: dict[str, Any] = {} + + def failed(reason: str) -> dict[str, Any]: + return {**latest, "state": "FAILED", "reason": reason} + + while ok(): + spin() + latest = dict(monitor.status) + state = normalize_status(latest)["state"] + if state in TERMINAL_STATES: + return latest + if process.poll() is not None: + return failed(f"calibration_stack_exited:exit_code={process.returncode}") + now = clock() + if latest and now - monitor.last_status_at > 10.0: + if monitor.count_publishers(monitor.status_topic) == 0: + return failed("calibration_node_process_exited") + if not latest and now - launched > startup_timeout: + return failed("calibration_node_initial_status_timeout") + + if state == "READY" and auto_start and requested_at is None: + if ready_since is None: + ready_since = now + if monitor.start_client.service_is_ready(): + future = request_start() + requested_at = now + elif now - ready_since > service_timeout: + return failed("calibration_start_service_unavailable") + elif ready_since is None and requested_at is None: + if auto_start and now - launched > startup_timeout: + return failed("calibration_device_not_ready:" + str(latest.get("reason", state))) + + if future is not None and not start_acknowledged: + if future.done(): + response = future.result() + if response is None or not response.success: + return failed("calibration_start_rejected:" + str(getattr(response, "message", ""))) + start_acknowledged = True + elif now - float(requested_at) > service_timeout: + return failed("calibration_start_response_timeout") + return failed("ros_context_shutdown") + + +def _write_failure(config: ProductConfig, session: Path, status: Mapping[str, Any]) -> None: + from .runner_support import log_exception_summary, protected_inputs + + report = normalize_status(status, profile=config.calibration_contract.typed_profile) + report.update( + serial_number=config.serial_number, result="FAIL", + log_path=str(session / "calibration.log"), + protected_inputs=protected_inputs(config), + child_exception=log_exception_summary(session / "calibration.log"), + raw_node_status=dict(status), + ) + atomic_write_json(session / "runner_diagnostic.json", report) + print(render_status_zh(report), flush=True) + if report["child_exception"]: + print(f"节点异常:{report['child_exception']}", flush=True) + print(f"诊断日志:{report['log_path']}", flush=True) + + +def run_online( + config: ProductConfig, *, record_bag: bool = False, + commands_enabled: bool = True, allow_resume: bool = True, +) -> int: + import rclpy + from std_srvs.srv import Trigger + + from .artifacts.completion import finish_online_artifacts + from ..hikrobot_camera import configure_fastdds_large_image_transport + from .resume import discover_resume_candidate + from .adapters.ros_topics import sdk_topics + from .runner_support import ( + CalibrationMonitor, ProgressConsole, create_session_directory, + launch_command, overlay_environment, protected_inputs, stop_stack, + ) + + profile = config.calibration_contract.typed_profile + resume = discover_resume_candidate( + config.session_root, profile_id=profile.key.profile_id, + serial_number=config.serial_number, protected_hashes=protected_inputs(config), + ) if allow_resume and commands_enabled else None + session = create_session_directory(config.session_root) + from ..profiles.validator import validate_executable_profile + contract_report = validate_executable_profile(profile, config.source_urdf) + atomic_write_json(session/"measurement_contract.json", contract_report) + if contract_report["cad_constraint_conflicts"]: + print("原始 CAD 存在限位/mimic 冲突,未修改原文件;详见 measurement_contract.json。最终范围验证不通过时禁止发布。", flush=True) + log_path = session / "calibration.log" + configure_fastdds_large_image_transport() + process = None + monitor = None + owns_context = not rclpy.ok() + with log_path.open("a", encoding="utf-8", buffering=1) as log_stream: + try: + environment = overlay_environment(config.sdk_setup) if config.sdk_setup else dict(os.environ) + environment.setdefault("ROS_LOG_DIR", str(session / "ros_logs")) + if owns_context: + rclpy.init() + progress = ProgressConsole(lambda status, estimator: render_status_zh( + normalize_status(status, profile=profile, serial_number=config.serial_number), + estimator, + )) + monitor = CalibrationMonitor( + node_name="calibration_product_runner", namespace=profile.namespace, + progress=progress, + ) + # This runner owns and launches its SDK process. Do not open the + # same device a second time or accept another session's READY. + topics = sdk_topics(profile) + discovery_deadline = time.monotonic() + 1.5 + while rclpy.ok() and time.monotonic() < discovery_deadline: + rclpy.spin_once(monitor, timeout_sec=0.1) + occupied = {topic: monitor.count_publishers(topic) for topic in + (topics.command, topics.feedback, monitor.status_topic)} + if any(occupied.values()): + raise RuntimeError(f"existing_calibration_publishers:{occupied}") + print(f"{config.serial_number} 标定环境正在启动;日志:{log_path}", flush=True) + if resume is not None: + print(f"发现候选断点:{resume.name};尚未复用,启动后验证基准和 Tag 安装关系。", flush=True) + if not commands_enabled: + print("仅预览:不自动开始、不发送标定运动;Ctrl+C 退出。", flush=True) + process = subprocess.Popen( + launch_command(config, session, record_bag=record_bag, + commands_enabled=commands_enabled, resume_from=resume), + cwd=config.workspace, env=environment, stdout=log_stream, + stderr=subprocess.STDOUT, text=True, start_new_session=True, + ) + status = drive_session( + monitor, process, spin=lambda: rclpy.spin_once(monitor, timeout_sec=0.1), + ok=rclpy.ok, request_start=lambda: monitor.start_client.call_async(Trigger.Request()), + auto_start=commands_enabled, + ) + atomic_write_json(session / "node_status.json", status) + if normalize_status(status)["state"] != "COMPLETE": + _write_failure(config, session, status) + return 3 + if not commands_enabled: + raise RuntimeError("preview session cannot publish calibration artifacts") + outputs = finish_online_artifacts(config, session, status) + print("标定会话完成;结果已通过当前发布链的校验。", flush=True) + for name, path in outputs.items(): + print(f"{name}:{path}", flush=True) + return 0 + except KeyboardInterrupt: + if monitor is not None and monitor.abort_client.service_is_ready(): + monitor.abort_client.call_async(Trigger.Request()) + rclpy.spin_once(monitor, timeout_sec=0.2) + print(f"已请求中止;本次数据保留在 {session}。", flush=True) + return 130 + except Exception as error: + import traceback + traceback.print_exc(file=log_stream) + _write_failure(config, session, {"state": "FAILED", "reason": str(error)}) + return 2 + finally: + # No unsolicited fast opening is commanded by a CLI failure. + try: + if process is not None: + stop_stack(process) + finally: + if monitor is not None: + monitor.destroy_node() + if owns_context and rclpy.ok(): + rclpy.shutdown() + log_stream.flush() + os.fsync(log_stream.fileno()) def main(args: list[str] | None = None) -> None: - """Select the profile first, then delegate to its reviewed CLI strategy.""" - selector = argparse.ArgumentParser(add_help=False) - # Resolve the installed default lazily. A caller that supplies --config - # must also work directly from a source workspace before the package has - # been installed. - selector.add_argument("--config", default=None) - selector.add_argument("--workspace", default=None) - selected, _ = selector.parse_known_args(args) - config_path = ( - str(default_product_config_path()) - if selected.config is None - else selected.config + parser = argparse.ArgumentParser(description="配置驱动的多型号统一标定") + parser.add_argument("--config", default=None) + parser.add_argument("--workspace", default=None) + parser.add_argument("--validate-only", "--preflight-only", action="store_true") + 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("--offline-raw", default="") + parser.add_argument("--offline-output", default="") + parser.add_argument("--publish-offline", action="store_true") + selected = parser.parse_args(args) + 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), ) - product = load_product_config( - config_path, - workspace=selected.workspace, - check_can=False, - ) - registered = get_default_registry().get(product.profile_key) - registered.engine.cli_main(args) + if selected.validate_only: + import json + from ..profiles.validator import validate_executable_profile + print(json.dumps(validate_executable_profile(config.calibration_contract.typed_profile, + config.source_urdf), ensure_ascii=False, indent=2)) + print(f"配置与受保护输入一致:{config.profile_key.profile_id}。此检查不代表 URDF 精度或硬件验收通过。") + return + if selected.offline_raw: + from .artifacts.replay import replay_capture + replay_capture(config, Path(selected.offline_raw), + output=Path(selected.offline_output) if selected.offline_output else None, + publish=selected.publish_offline) + return + if selected.offline_output or selected.publish_offline: + parser.error("--offline-output/--publish-offline require --offline-raw") + raise SystemExit(run_online(config, record_bag=selected.record_bag, + commands_enabled=not selected.commands_disabled, + allow_resume=not selected.no_resume)) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/runner_support.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner_support.py new file mode 100644 index 0000000..05b0b21 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/runner_support.py @@ -0,0 +1,273 @@ +"""Model-independent process, status and session support for CLI runners.""" + +from __future__ import annotations + +from datetime import datetime +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import sys +import time +from typing import Any, Callable, Mapping + +import rclpy +from rclpy.node import Node +from std_msgs.msg import String +from std_srvs.srv import Trigger + +from ..operator_report import ProgressEstimator +from ..product import ProductConfig + + +class ProgressConsole: + """Render only changed status snapshots on either a TTY or a log stream.""" + + def __init__(self, renderer: Callable[..., str]) -> None: + self.last_text = "" + self.estimator = ProgressEstimator.start() + self.renderer = renderer + + def update(self, status: Mapping[str, Any]) -> None: + text = self.renderer(status, self.estimator) + if text == self.last_text: + return + self.last_text = text + if sys.stdout.isatty(): + sys.stdout.write("\x1b[2J\x1b[H" + text + "\n") + sys.stdout.flush() + else: + print(text, flush=True) + + +class CalibrationMonitor(Node): + """Observe the common status topic and expose common control services.""" + + def __init__( + self, + *, + node_name: str, + namespace: str, + progress: ProgressConsole, + ) -> None: + super().__init__(node_name) + base = namespace.rstrip("/") + self.status_topic = f"{base}/status" + self.status: dict[str, Any] = {} + self.last_status_at = 0.0 + self.progress = progress + self.create_subscription(String, self.status_topic, self._on_status, 10) + self.start_client = self.create_client(Trigger, f"{base}/start") + self.abort_client = self.create_client(Trigger, f"{base}/abort") + + def _on_status(self, message: String) -> None: + try: + value = json.loads(message.data) + except json.JSONDecodeError: + return + if isinstance(value, dict): + self.status = value + self.last_status_at = time.monotonic() + self.progress.update(value) + + +def create_session_directory(root: Path) -> Path: + """Create a unique timestamped session without overwriting prior evidence.""" + root.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + for number in range(10000): + session = root / (stamp if number == 0 else f"{stamp}_{number:04d}") + try: + session.mkdir() + except FileExistsError: + continue + return session + raise RuntimeError("cannot allocate a new calibration session") + + +def launch_command( + config: ProductConfig, + session: Path, + *, + record_bag: bool, + commands_enabled: bool, + sdk_startup_speed_u8: int | None = None, + resume_from: Path | None = None, +) -> list[str]: + """Build the single launch invocation entirely from the product contract.""" + arguments = { + "model": config.model, + "hand_type": config.side, + "tag_layout": config.tag_layout, + "serial_number": config.serial_number, + "source_urdf_path": str(config.source_urdf), + "source_urdf_expected_sha256": config.source_urdf_sha256, + "camera_extrinsics_file": str(config.camera_extrinsics), + "camera_extrinsics_expected_sha256": config.camera_extrinsics_sha256, + "calibration_config": str(config.calibration_config), + "calibration_config_expected_sha256": config.calibration_config_sha256, + "tag_config": str(config.tag_config), + "tag_config_expected_sha256": config.tag_config_sha256, + "vendor_sdk_config": "" if config.sdk_config is None else str(config.sdk_config), + "sdk_config_expected_sha256": config.sdk_config_sha256, + "profile_config_expected_sha256": getattr( + config, "profile_config_sha256", "" + ), + "profile_config": str(getattr(config, "profile_config", None) or ""), + "output_root": str(config.output_root), + "session_dir": str(session), + "commands_enabled": str(commands_enabled).lower(), + "vendor_sdk_python_package": str(getattr(config, "sdk_python_package", None) or ""), + "sdk_package_expected_sha256": getattr(config, "sdk_package_sha256", ""), + "record_bag": str(record_bag).lower(), + } + startup_speed = sdk_startup_speed_u8 + if startup_speed is None: + startup_speed = config.calibration_contract.typed_profile.motion.speed_parameters.get("baseline_u8") + if startup_speed is not None: + arguments["calibration_speed"] = str(int(startup_speed)) + if resume_from is not None: + arguments["resume_raw_samples_path"] = str( + resume_from / "raw_samples.jsonl" + ) + if config.can_interface: + arguments["can_interface"] = config.can_interface + for view, camera in config.cameras.items(): + arguments[f"{view}_camera_serial"] = camera["serial_number"] + arguments[f"{view}_camera_name"] = camera["camera_name"] + arguments[f"{view}_camera_info_url"] = camera["camera_info"] + return [ + "ros2", + "launch", + "linkerhand_calibration", + "unified_calibration.launch.py", + # ros2 launch rejects name:= with no value. Unused optional SDK + # arguments already have empty defaults in the launch description. + *(f"{name}:={value}" for name, value in arguments.items() if value != ""), + ] + + +def wait_until( + monitor: CalibrationMonitor, + process: subprocess.Popen[Any], + predicate: Callable[[Mapping[str, Any]], bool], + *, + timeout: float | None, + status_stale_after: float | None = None, + finalizing_stale_after: float = 180.0, +) -> bool: + """Wait for a status predicate while detecting process and status loss.""" + started = time.monotonic() + while rclpy.ok(): + if process.poll() is not None: + return False + rclpy.spin_once(monitor, timeout_sec=0.2) + if predicate(monitor.status): + return True + now = time.monotonic() + if status_stale_after is not None and monitor.last_status_at > 0.0: + stale_limit = ( + finalizing_stale_after + if monitor.status.get("state") == "FINALIZING" + else float(status_stale_after) + ) + if now - monitor.last_status_at > stale_limit: + status_publishers = monitor.count_publishers( + monitor.status_topic + ) + # A busy fit or a delayed console message is not loss of SDK + # feedback. The motion owner monitors feedback independently. + # Stop only when ROS discovery also says that owner is gone. + if status_publishers == 0: + monitor.status = { + **monitor.status, + "state": "FAILED", + "reason": "calibration_node_process_exited", + } + return False + if timeout is not None and now - started > timeout: + return False + return False + + +def stop_stack(process: subprocess.Popen[Any]) -> None: + """Stop the launch process group without leaking child processes.""" + try: + # The launch parent may have exited while its SDK/camera children still + # own this process group. The caller created it with start_new_session. + os.killpg(process.pid, signal.SIGINT) + except ProcessLookupError: + return + for timeout, stop_signal in ((15.0, signal.SIGTERM), (5.0, signal.SIGKILL)): + try: + process.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + try: + os.killpg(process.pid, stop_signal) + except ProcessLookupError: + return + process.wait(timeout=5.0) + + +def overlay_environment(setup: Path) -> dict[str, str]: + """Load a reviewed vendor overlay into a child-process environment.""" + completed = subprocess.run( + ["bash", "-c", 'source "$1" >/dev/null 2>&1 && env -0', "bash", str(setup)], + check=True, + stdout=subprocess.PIPE, + ) + environment = dict(os.environ) + for item in completed.stdout.split(b"\0"): + if b"=" in item: + key, value = item.split(b"=", 1) + environment[key.decode()] = value.decode(errors="surrogateescape") + return environment + + +def log_exception_summary(log_path: Path) -> str: + """Return the final child exception without flooding the operator view.""" + try: + lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return "" + prefixes = ( + "AttributeError:", "AssertionError:", "ImportError:", + "IndexError:", "KeyError:", "ModuleNotFoundError:", + "OSError:", "RuntimeError:", "TypeError:", "ValueError:", + ) + ansi = re.compile(r"\x1b\[[0-9;]*m") + for raw in reversed(lines[-300:]): + payload = ansi.sub("", raw).strip().rsplit("] ", 1)[-1].strip() + if payload.startswith(prefixes): + return payload + return "" + + +def protected_inputs(config: ProductConfig) -> dict[str, str]: + """Return exactly the immutable inputs available on a product contract.""" + values = { + "source_urdf_sha256": config.source_urdf_sha256, + "camera_extrinsics_sha256": config.camera_extrinsics_sha256, + "calibration_config_sha256": config.calibration_config_sha256, + "tag_config_sha256": config.tag_config_sha256, + "sdk_config_sha256": config.sdk_config_sha256, + "sdk_package_sha256": getattr(config, "sdk_package_sha256", ""), + "profile_config_sha256": getattr(config, "profile_config_sha256", ""), + } + return {key: value for key, value in values.items() if value} + + +__all__ = [ + "CalibrationMonitor", + "ProgressConsole", + "create_session_directory", + "launch_command", + "log_exception_summary", + "overlay_environment", + "protected_inputs", + "stop_stack", + "wait_until", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py new file mode 100644 index 0000000..27143ac --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/safety.py @@ -0,0 +1,194 @@ +"""Minimal, model-independent live safety policy. + +Only conditions that can damage hardware, corrupt the coordinate reference, or +make command ownership ambiguous are stops. Vision quality and normal tendon +coupling deliberately do not enter this module. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Sequence + +from ..core import CalibrationProfile +from .adapters import HardwareHealth + + +@dataclass(frozen=True) +class MotionGoal: + """One axis of a whole segment, in the Adapter's feedback coordinates.""" + + channel: int + start_feedback: float + end_feedback: float + completion_tolerance: float + progress_resolution: float + + +@dataclass(frozen=True) +class SafetySample: + now_seconds: float + feedback_timestamp_seconds: float | None + command: tuple[float, ...] + feedback: tuple[float, ...] | None + health: HardwareHealth + competing_controller: bool = False + reference_moved: bool = False + operator_abort: bool = False + motion_expected: bool = False + target_channel: int | None = None + target_value: float | None = None + motion_id: str = "" + motion_goals: tuple[MotionGoal, ...] = () + + +@dataclass(frozen=True) +class SafetyDecision: + safe: bool + code: str = "" + reason: str = "" + details: str = "" + + +class SafetyPolicy: + """Evaluate the complete and intentionally small list of live stops.""" + + def __init__(self, profile: CalibrationProfile) -> None: + self.profile = profile + self._motion_id: str | None = None + self._axis_progress: dict[int, tuple[float, float, float]] = {} + + def evaluate(self, sample: SafetySample) -> SafetyDecision: + if sample.operator_abort: + return self._stop("operator_abort", "操作者已中止标定") + if sample.competing_controller: + return self._stop( + "duplicate_controller", "检测到另一个命令控制节点" + ) + if sample.reference_moved: + return self._stop( + "fixed_reference_moved", + "已锁定的手掌、相机、支架或固定基准 Tag 发生移动", + ) + health = sample.health + if not health.connected: + return self._stop("sdk_disconnected", "SDK 通信已失联") + if not health.position_mode: + return self._stop("wrong_control_mode", "机械手不在位置控制模式") + if health.active_faults: + return self._stop( + "hardware_fault", + "SDK 报告活动硬件故障", + ",".join(health.active_faults), + ) + if (sample.feedback is None or not health.feedback_fresh + or sample.feedback_timestamp_seconds is None + or not math.isfinite(float(sample.now_seconds)) + or not math.isfinite(float(sample.feedback_timestamp_seconds)) + or float(sample.feedback_timestamp_seconds) > float(sample.now_seconds) + 1e-6 + or float(sample.now_seconds) - float(sample.feedback_timestamp_seconds) + > self.profile.acquisition.feedback_stale_seconds): + return self._stop("feedback_stale", "反馈连续超过允许时间未更新") + try: + self._validate_vector(sample.command, feedback=False) + if sample.feedback is not None: + self._validate_vector(sample.feedback, feedback=True) + except ValueError as error: + return self._stop("physical_range_exceeded", str(error)) + stall = self._evaluate_progress(sample) + if stall is not None: + return stall + return SafetyDecision(True) + + def _evaluate_progress( + self, sample: SafetySample + ) -> SafetyDecision | None: + """Track signed feedback advance, not a moving set-point's distance. + + Each segment has a stable identity and may move several avoidance axes. + Updating a streaming position command cannot reset its stall clock. + """ + if not sample.motion_expected or sample.feedback is None: + self._reset_progress() + return None + goals = sample.motion_goals + if not goals and sample.target_channel is not None and sample.target_value is not None: + # Compatibility for existing scalar callers. Live execution supplies + # explicit whole-segment goals, including feedback zero conventions. + index = int(sample.target_channel) + if not 0 <= index < self.profile.command.command_count: + return self._stop("invalid_motion_target", "运动目标通道不在 Profile 中") + span = self.profile.command.maximum_values[index] - self.profile.command.minimum_values[index] + tolerance = max(1e-4, 0.002*span) + goals = (MotionGoal(index, sample.feedback[index], float(sample.target_value), tolerance, tolerance),) + if not goals: + return self._stop("invalid_motion_target", "运动段缺少反馈推进目标") + motion_id = sample.motion_id or "legacy_scalar_motion" + if self._motion_id != motion_id: + self._axis_progress.clear() + self._motion_id = motion_id + now = float(sample.now_seconds) + seen: set[int] = set() + for goal in goals: + index = goal.channel + if (index in seen or not 0 <= index < self.profile.command.command_count + or not all(math.isfinite(v) for v in (goal.start_feedback, goal.end_feedback, goal.completion_tolerance, goal.progress_resolution)) + or goal.completion_tolerance < 0 or goal.progress_resolution <= 0): + return self._stop("invalid_motion_target", "运动段推进目标无效") + seen.add(index) + feedback = float(sample.feedback[index]) + if abs(goal.end_feedback - goal.start_feedback) <= goal.completion_tolerance: + continue + direction = math.copysign(1.0, goal.end_feedback - goal.start_feedback) + projected = direction * feedback + previous = self._axis_progress.get(index) + if previous is None or previous[2] != direction: + self._axis_progress[index] = (projected, now, direction) + continue + best, last_advance, _ = previous + # Passing the requested travel is completion, not a new stall + # while holding slightly beyond an encoder-biased endpoint. + if direction * (goal.end_feedback - feedback) <= goal.completion_tolerance or projected >= best + goal.progress_resolution: + self._axis_progress[index] = (projected, now, direction) + continue + if now - last_advance >= self.profile.acquisition.stall_timeout_seconds: + return self._stop("mechanical_stall", + "已要求明显运动,但目标反馈连续两秒没有向目标推进", + f"segment={motion_id},channel={index},feedback={feedback:.9g},goal={goal.end_feedback:.9g}") + return None + + def _reset_progress(self) -> None: + self._motion_id = None + self._axis_progress.clear() + + def _validate_vector( + self, values: Sequence[float], *, feedback: bool + ) -> None: + layout = self.profile.command + if len(values) != layout.command_count: + raise ValueError("命令或反馈通道数量与 Profile 不一致") + lower = ( + layout.minimum_feedback_values if feedback else layout.minimum_values + ) + upper = ( + layout.maximum_feedback_values if feedback else layout.maximum_values + ) + kind = "反馈" if feedback else "命令" + for index, value in enumerate(values): + numeric = float(value) + if ( + not math.isfinite(numeric) + or numeric < lower[index] + or numeric > upper[index] + ): + raise ValueError( + f"{kind}越过物理范围: channel={index}:value={numeric}" + ) + + @staticmethod + def _stop(code: str, reason: str, details: str = "") -> SafetyDecision: + return SafetyDecision(False, code, reason, details) + + +__all__ = ["MotionGoal", "SafetyDecision", "SafetyPolicy", "SafetySample"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py new file mode 100644 index 0000000..652893f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/scan_quality.py @@ -0,0 +1,75 @@ +"""One post-direction data policy for primary and secondary observations.""" + +from __future__ import annotations + +import math +from typing import Mapping, Sequence + +from .engine import CalibrationEngine, ScanUnit, SweepQuality + + +def observation_streams(profile, task): + """(record field, joint identity, measurement) required by one task.""" + for name in task.joints: + yield "joint", name, profile.measurement.measurements[name] + secondary = profile.measurement.cross_view_sources.get(name) + if secondary is not None: + yield "observation_joint", name, profile.measurement.measurements[secondary] + + +def evaluate_capture_unit(profile, unit: ScanUnit, attempt: int, + records: Sequence[Mapping], *, first_cycle_spans: dict[str, float]) -> SweepQuality: + """No rates, coupling, endpoint tracking error or Tag percentages stop a scan. + + Count unique images separately for EVERY required joint/view. A good + proximal Tag cannot hide missing distal or secondary-camera observations. + Reference travel is learned only from an accepted first training cycle. + """ + engine = CalibrationEngine(profile) + task = next(task for task in profile.motion.tasks if task.key == unit.task_key) + native = profile.command.unit + lower, upper = sorted((task.start_value, task.end_value)) + span = upper - lower + if span <= 0: + raise ValueError("a scan needs a nonzero declared range") + failures, warnings, metrics, references = [], [], {}, {} + for field, name, spec in observation_streams(profile, task): + identity = f"{field}:{name}:{spec.view}:{unit.direction}" + samples = {} + for row in records: + if row.get("sample_phase", "sweep") != "sweep": + continue + if (row.get(field) != name or row.get("view") != spec.view + or row.get("task_name") != unit.task_key + or row.get("cycle") != unit.cycle or row.get("direction") != unit.direction + or int(row.get("attempt", 1)) != attempt): + continue + stamp = row.get("image_stamp_ns") + if stamp is None or not isinstance(stamp, int) or stamp <= 0: + continue + value = row.get(f"feedback_{native}") + if value is None or not math.isfinite(float(value)): + 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. + if 0 <= progress <= 1: + samples[(spec.view, stamp)] = progress + policy = profile.acquisition + minimum = policy.legacy_minimum_span_01 if native == "u8" else ( + policy.physical_first_cycle_minimum_span_01 if unit.cycle == 0 else + policy.physical_repeat_minimum_fraction * first_cycle_spans.get(identity, 1.0)) + if native != "u8" and unit.cycle > 0 and identity not in first_cycle_spans: + failures.append(f"{identity}:missing_training_travel") + decision = engine.evaluate_sweep(tuple(samples.values()), minimum_span=minimum, + total_frames=len(samples), joint_frame_rate=1.0, feedback_hz=0.0, + detection_rate=1.0, bin_count=256) + failures.extend(f"{identity}:{reason}" for reason in decision.failures) + warnings.extend(f"{identity}:{reason}" for reason in decision.warnings) + metrics[identity] = dict(decision.metrics) + references[identity] = float(decision.metrics["feedback_span"]) + from .steady import steady_failures + failures.extend(steady_failures(profile, unit, attempt, records)) + if not failures and unit.cycle == 0: + first_cycle_spans.update(references) + return SweepQuality(not failures, tuple(failures), tuple(warnings), metrics) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py new file mode 100644 index 0000000..46f3e96 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/session.py @@ -0,0 +1,335 @@ +"""The single online calibration state machine. + +ROS and SDK adapters execute requested actions. This object owns ordering, +retry policy, pause semantics, progress and resume fallback for every profile. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Mapping + +from ..core import CalibrationProfile, CalibrationStatus, PauseStatus, TaskStatus +from .engine import CalibrationEngine, ScanUnit, SweepQuality +from .resume import ResumeDecision + + +class CalibrationPhase(str, Enum): + WAIT_DEVICE = "WAIT_DEVICE" + READY = "READY" + BASELINE = "BASELINE" + REFERENCE_LOCKING = "REFERENCE_LOCKING" + REFERENCE_POSES = "REFERENCE_POSES" + RESUME_VERIFY = "RESUME_VERIFY" + PREPARE = "PREPARE" + MAPPING_PROBE = "MAPPING_PROBE" + SWEEP = "SWEEP" + EVALUATE = "EVALUATE" + RESCAN = "RESCAN" + RETURN_BASELINE = "RETURN_BASELINE" + FIT = "FIT" + HOLDOUT_VALIDATE = "HOLDOUT_VALIDATE" + BUILD_ARTIFACTS = "BUILD_ARTIFACTS" + VALIDATE_URDF = "VALIDATE_URDF" + PUBLISH = "PUBLISH" + COMPLETE = "COMPLETE" + PAUSED = "PAUSED" + ABORTED = "ABORTED" + FAILED = "FAILED" + + +@dataclass(frozen=True) +class SessionAction: + phase: CalibrationPhase + scan_unit: ScanUnit | None = None + retry: int = 0 + + +class CalibrationSession: + """Deterministic orchestration kernel with no model-specific branches.""" + + def __init__(self, profile: CalibrationProfile) -> None: + self.profile = profile + self.engine = CalibrationEngine(profile) + self.phase = CalibrationPhase.WAIT_DEVICE + self._units = self.engine.scan_units() + self._unit_index = 0 + self._retry_by_unit: dict[tuple[str, int, str], int] = {} + self._mapping_probed_channels: set[int] = set() + self._reused_units: set[tuple[str, int, str]] = set() + self._resume_requested = False + self._resume_message = "" + self._pause = PauseStatus() + self._reference_locked = False + self._rescan_pending = False + + @property + def current_unit(self) -> ScanUnit | None: + if self._unit_index >= len(self._units): + return None + return self._units[self._unit_index] + + def device_ready(self) -> None: + self._require(CalibrationPhase.WAIT_DEVICE) + self.phase = CalibrationPhase.READY + + def devices_unavailable(self) -> None: + """Revoke readiness before Start without pausing a running scan.""" + self._require_any(CalibrationPhase.WAIT_DEVICE, CalibrationPhase.READY) + self.phase = CalibrationPhase.WAIT_DEVICE + + def start(self, *, resume_requested: bool = False) -> None: + self._require(CalibrationPhase.READY) + self._resume_requested = bool(resume_requested) + self._reference_locked = False + self.phase = CalibrationPhase.BASELINE + + def baseline_complete(self) -> None: + self._require(CalibrationPhase.BASELINE) + self.phase = CalibrationPhase.REFERENCE_LOCKING + + def reference_locked(self) -> None: + self._require(CalibrationPhase.REFERENCE_LOCKING) + self._reference_locked = True + if self.profile.motion.resume_verification_waypoints: + self.phase = CalibrationPhase.REFERENCE_POSES + return + self._after_reference_poses() + + def reference_poses_complete(self): + self._require(CalibrationPhase.REFERENCE_POSES) + self._after_reference_poses() + + def _after_reference_poses(self): + self.phase = ( + CalibrationPhase.RESUME_VERIFY + if self._resume_requested + else CalibrationPhase.PREPARE + ) + + def resume_checked(self, decision: ResumeDecision) -> None: + self._require(CalibrationPhase.RESUME_VERIFY) + self._resume_message = decision.reason + if not decision.reuse: + self._unit_index = 0 + self._retry_by_unit.clear() + self._reused_units.clear() + else: + valid = { + (unit.task_key, unit.cycle, unit.direction) + for unit in self._units + } + requested = { + (str(task), int(cycle), str(direction)) + for task, cycle, direction in decision.completed_units + } + if not requested.issubset(valid): + raise ValueError("resume contains an unknown scan unit") + self._reused_units = requested + self._advance_reused_units() + self.phase = CalibrationPhase.PREPARE + + def preparation_complete(self) -> None: + self._require(CalibrationPhase.PREPARE) + unit = self.current_unit + if unit is None: + self.phase = CalibrationPhase.RETURN_BASELINE + return + task = self._task(unit.task_key) + if ( + self.engine.mapping_probe_delta(task) is not None + and task.command_index not in self._mapping_probed_channels + ): + self.phase = CalibrationPhase.MAPPING_PROBE + else: + self.phase = CalibrationPhase.RESCAN if self._rescan_pending else CalibrationPhase.SWEEP + self._rescan_pending = False + + def mapping_probe_complete(self) -> None: + self._require(CalibrationPhase.MAPPING_PROBE) + unit = self.current_unit + if unit is None: + raise RuntimeError("mapping probe has no current task") + self._mapping_probed_channels.add(self._task(unit.task_key).command_index) + self.phase = CalibrationPhase.SWEEP + + def sweep_complete(self) -> None: + self._require_any(CalibrationPhase.SWEEP, CalibrationPhase.RESCAN) + self.phase = CalibrationPhase.EVALUATE + + def evaluation_complete(self, quality: SweepQuality) -> None: + self._require(CalibrationPhase.EVALUATE) + unit = self.current_unit + if unit is None: + self.fail("missing_scan_unit", "扫描单元状态丢失") + return + key = (unit.task_key, unit.cycle, unit.direction) + retries = self._retry_by_unit.get(key, 0) + if not quality.passed: + if self.engine.permits_retry("sweep_acquisition", retries): + self._retry_by_unit[key] = retries + 1 + self._rescan_pending = True + # Never reverse a failed direction by jumping straight back to + # its first set-point. Re-execute the ordinary safe preparation. + self.phase = CalibrationPhase.PREPARE + return + self.pause( + "sweep_quality_failed", + "当前方向同速重扫一次后仍没有足够有效数据", + {"failures": quality.failures, "metrics": dict(quality.metrics)}, + ) + return + self._unit_index += 1 + self._advance_reused_units() + if self._unit_index < len(self._units): + # A skipped checkpoint may leave a gap even within the same task. + # The executor elides already-satisfied waypoints without motion. + self.phase = CalibrationPhase.PREPARE + return + self.phase = CalibrationPhase.RETURN_BASELINE + + def return_complete(self) -> None: + self._require(CalibrationPhase.RETURN_BASELINE) + self.phase = CalibrationPhase.FIT + + def fit_complete(self) -> None: + self._require(CalibrationPhase.FIT) + self.phase = CalibrationPhase.HOLDOUT_VALIDATE + + def holdout_complete(self, *, passed: bool, reason: str = "") -> None: + self._require(CalibrationPhase.HOLDOUT_VALIDATE) + if not passed: + self.fail("holdout_failed", reason or "独立第四轮验证未通过") + return + self.phase = CalibrationPhase.BUILD_ARTIFACTS + + def artifacts_built(self) -> None: + self._require(CalibrationPhase.BUILD_ARTIFACTS) + self.phase = CalibrationPhase.VALIDATE_URDF + + def urdf_validated(self, *, passed: bool, reason: str = "") -> None: + self._require(CalibrationPhase.VALIDATE_URDF) + if not passed: + self.fail("urdf_validation_failed", reason or "URDF 校验失败") + return + self.phase = CalibrationPhase.PUBLISH + + def published(self) -> None: + self._require(CalibrationPhase.PUBLISH) + self.phase = CalibrationPhase.COMPLETE + + def pause( + self, code: str, reason: str, details: Mapping[str, object] | None = None, + *, suggestion: str = "排除原因后从安全基准恢复", + ) -> None: + self._pause = PauseStatus(code, reason, suggestion, details or {}) + self.phase = CalibrationPhase.PAUSED + + def abort(self) -> None: + self._pause = PauseStatus("operator_abort", "操作者已中止标定") + self.phase = CalibrationPhase.ABORTED + + def fail(self, code: str, reason: str) -> None: + self._pause = PauseStatus(code, reason) + self.phase = CalibrationPhase.FAILED + + def action(self) -> SessionAction: + unit = self.current_unit + retries = 0 + if unit is not None: + retries = self._retry_by_unit.get( + (unit.task_key, unit.cycle, unit.direction), 0 + ) + return SessionAction(self.phase, unit, retries) + + def status(self) -> CalibrationStatus: + unit = self.current_unit + task_phases = {CalibrationPhase.PREPARE, CalibrationPhase.MAPPING_PROBE, + CalibrationPhase.SWEEP, CalibrationPhase.EVALUATE, CalibrationPhase.RESCAN, + CalibrationPhase.PAUSED, CalibrationPhase.ABORTED} + if self.phase not in task_phases: + unit = None + task = next( + ( + value + for value in self.profile.motion.tasks + if unit is not None and value.key == unit.task_key + ), + None, + ) + progress = ( + 1.0 + if self.phase == CalibrationPhase.COMPLETE + else 0.90*self._unit_index / max(1, len(self._units)) + ) + required: dict[str, tuple[int, ...]] = {} + if self.phase in {CalibrationPhase.REFERENCE_LOCKING, CalibrationPhase.RESUME_VERIFY}: + required = {view.name: tuple(tag.tag_id for tag in view.tags if tag.fixed_reference) + for view in self.profile.vision.views} + if task is not None: + measurement_names = set(task.joints) + measurement_names.update( + self.profile.measurement.cross_view_sources[name] + for name in task.joints + if name in self.profile.measurement.cross_view_sources + ) + measurements = [ + self.profile.measurement.measurements[name] + for name in measurement_names + ] + for view in self.profile.vision.views: + roles = { + role for measurement in measurements if measurement.view == view.name + for role in (measurement.parent_role, measurement.child_role) if role is not None + } + ids = tuple(tag.tag_id for tag in view.tags if tag.role in roles) + if ids: + required[view.name] = ids + return CalibrationStatus( + state=self.phase.value, + phase=self.phase.value, + overall_progress_01=progress, + reference_locked=self._reference_locked, + reference_message=( + "基准已锁定:请勿移动手掌、相机、支架或Tag" + if self._reference_locked + else "" + ), + task=TaskStatus( + key="" if task is None else task.key, + label="" if task is None else " / ".join(task.joints), + cycle=None if unit is None else unit.cycle + 1, + direction="" if unit is None else unit.direction, + required_tag_ids_by_view=required, + ), + resume={"message": self._resume_message}, + pause=self._pause, + ) + + def _require(self, expected: CalibrationPhase) -> None: + if self.phase != expected: + raise RuntimeError( + f"invalid transition from {self.phase.value}; " + f"expected {expected.value}" + ) + + def _require_any(self, *expected: CalibrationPhase) -> None: + if self.phase not in expected: + allowed = ",".join(value.value for value in expected) + raise RuntimeError( + f"invalid transition from {self.phase.value}; expected {allowed}" + ) + + def _task(self, key: str): + return next(task for task in self.profile.motion.tasks if task.key == key) + + def _advance_reused_units(self) -> None: + while self._unit_index < len(self._units): + unit = self._units[self._unit_index] + if (unit.task_key, unit.cycle, unit.direction) not in self._reused_units: + break + self._unit_index += 1 + + +__all__ = ["CalibrationPhase", "CalibrationSession", "SessionAction"] diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py new file mode 100644 index 0000000..1ed083f --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/snapshot.py @@ -0,0 +1,127 @@ +"""Typed live status assembly; wire dictionaries are created only at the edge.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, replace +from typing import TYPE_CHECKING, Mapping + +from ..core.domain.status import CalibrationStatus, MotionStatus +from .scan_quality import evaluate_capture_unit +from .session import CalibrationPhase as Phase + +if TYPE_CHECKING: + from .coordinator import CalibrationCoordinator + + +_LEGACY_STATES = { + Phase.WAIT_DEVICE: "WAIT_DEVICES", Phase.READY: "READY", + Phase.COMPLETE: "PASSED", Phase.PAUSED: "PAUSED", + Phase.ABORTED: "ABORTED", Phase.FAILED: "FAILED", + **{phase: "FINALIZING" for phase in ( + Phase.FIT, Phase.HOLDOUT_VALIDATE, Phase.BUILD_ARTIFACTS, + Phase.VALIDATE_URDF, Phase.PUBLISH, + )}, +} + + +def legacy_state(phase: Phase) -> str: + return _LEGACY_STATES.get(phase, "RUNNING") + + +@dataclass(frozen=True) +class CameraReadiness: + camera_info_ready: bool + detections_ready: bool + error: str + + +@dataclass(frozen=True) +class DeviceReadiness: + ready: bool + waiting_for: tuple[str, ...] + cameras: Mapping[str, CameraReadiness] + + +@dataclass(frozen=True) +class RuntimeSnapshot: + calibration: CalibrationStatus + model: str + side: str + profile_id: str + serial_number: str + reason: str + session_dir: str + latest_base_drift_px: Mapping[str, float] + devices: DeviceReadiness | None + steady: bool = False + steady_node: int | None = None + + def as_dict(self) -> dict: + value = self.calibration.as_dict() + value.update(model=self.model, side=self.side, profile_id=self.profile_id, + serial_number=self.serial_number, reason=self.reason, + session_dir=self.session_dir, latest_base_drift_px=dict(self.latest_base_drift_px)) + if self.devices is not None: + value["devices"] = asdict(self.devices) + if self.steady: + value["acquisition"]["steady_node"] = self.steady_node + return value + + +def build_snapshot(host: CalibrationCoordinator) -> RuntimeSnapshot: + """Called under the coordinator lock; retains the public status fields.""" + session, profile, parameters = host.execution.session, host.profile, host.parameters + status = session.status() + task, motion = status.task, host._motion + if session.phase == Phase.REFERENCE_POSES and motion is not None: + waypoint = next((w for w in profile.motion.resume_verification_waypoints + if w.key == motion.task_key), None) + if waypoint is not None: + task = replace(task, required_tag_ids_by_view=waypoint.tag_ids_by_view, + label="Tag 安装验证姿态:"+waypoint.key) + required = task.required_tag_ids_by_view + required_ids = {tag for ids in required.values() for tag in ids} + missing = {tag for view, ids in required.items() + for tag in host.capture.last_missing.get(view, ids)} & required_ids + filtered = {tag for ids in host.capture.last_filtered.values() for tag in ids} & required_ids + steady = motion is not None and motion.phase in {"steady", "steady_prepare"} + steady_node = None + if steady and motion.steady_index is not None: + steady_node = motion.steady_index+1 + task = replace(task, label=task.label+f"(稳态点 {steady_node})") + index = None if motion is None else motion.command_index + health = host.sdk_adapter.health() + quality = host.execution.last_quality if session.phase == Phase.PAUSED else None + if session.phase in {Phase.SWEEP, Phase.RESCAN, Phase.EVALUATE}: + quality = evaluate_capture_unit(profile, session.current_unit, + host.execution.action.retry+1, tuple(host._unit_rows), + first_cycle_spans=dict(host.execution.first_cycle_spans)) + acquisition = replace(status.acquisition, retry_count=host.execution.action.retry) + if quality is not None and quality.metrics: + metrics = tuple(quality.metrics.values()) + acquisition = replace(acquisition, + valid_samples=min(m["valid_frames"] for m in metrics), + coverage_01=min(m["feedback_span"] for m in metrics), + bins=min(m["feedback_bins"] for m in metrics), + maximum_gap=max(m["maximum_bin_gap"] for m in metrics)) + status = replace(status, task=task, acquisition=acquisition, + phase=motion.phase if steady else status.phase, + recognized_tag_ids=tuple(sorted(required_ids-missing-filtered)), + missing_tag_ids=tuple(sorted(missing)), rejected_tag_ids=tuple(sorted(filtered)), + log_path=str(parameters.session_dir/"calibration.log"), + hardware={"feedback_hz": round(host._feedback_hz(), 2), "mode_verified": health.position_mode, + "faults": health.active_faults, "feedback_fresh": health.feedback_fresh, "diagnostic": health.diagnostic}, + motion=MotionStatus(channel="" if index is None else host.command_names[index], + unit=host.command_unit, speed=None if motion is None else motion.speed, + command=None if index is None or host.last_command is None else host.last_command[index], + feedback=None if index is None or not host.latest_feedback else host.latest_feedback[index]), + resume={"message": host._resume_message, "completed_unit_count": host._resumed_count, + "source_session": "" if parameters.resume_raw_samples_path is None + else parameters.resume_raw_samples_path.parent.name}, + outputs={"json": host.final_json, "urdf": host.final_urdf, + "publication_pointer": str(parameters.session_dir.parent/profile.artifacts.publication_pointer) + if session.phase == Phase.COMPLETE else ""}) + return RuntimeSnapshot(status, profile.key.model, profile.key.side, profile.key.profile_id, + parameters.serial_number, host.reason, str(parameters.session_dir), + dict(host.reference_lock.latest_drift_px), + None if host.started else host._device_status(host.ports.monotonic()), steady, steady_node) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py new file mode 100644 index 0000000..0d99792 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/status.py @@ -0,0 +1,155 @@ +"""One console/status contract, with a read-only bridge for legacy messages.""" + +from __future__ import annotations + +from typing import Any, Mapping + +from ..core.domain.profile import CalibrationProfile +from ..core.domain.status import CalibrationStatus +from .reporting.reasons_zh import reason_zh + + +_STATES = {"WAIT_START": "READY", "WAIT_DEVICES": "WAIT_DEVICE", "PASSED": "COMPLETE"} +_PHASES = { + "WAIT_DEVICE": "等待设备", "READY": "设备就绪", "BASELINE": "安全恢复基准", + "REFERENCE_LOCKING": "锁定基准(等待所需 Tag)", "RESUME_VERIFY": "验证断点基准", + "REFERENCE_POSES": "采集 Tag 安装验证姿态", + "PREPARE": "扫描起点准备", "MAPPING_PROBE": "固定通道映射点动", + "SWEEP": "正式扫描", "EVALUATE": "方向数据检查", "RESCAN": "同速重扫", + "RETURN_BASELINE": "按避让顺序安全收尾", + "FIT": "训练数据拟合", "HOLDOUT_VALIDATE": "第四轮独立验证", + "BUILD_ARTIFACTS": "生成产物", "VALIDATE_URDF": "校验导出 URDF", + "PUBLISH": "发布", "COMPLETE": "会话完成", "PAUSED": "已暂停", + "ABORTED": "已中止", "FAILED": "失败", "RUNNING": "标定中", + "FINALIZING": "拟合与产物校验", "FITTING": "拟合与验证", + "baseline": "安全恢复基准", "preflight": "固定通道映射点动", + "prepare": "扫描起点准备", "retry_prepare": "同速重扫起点准备", "sweep": "正式扫描", + "steady_prepare": "稳态指令采样起点准备", "steady": "稳态指令→rad 采样", +} + + +def required_tags(profile: CalibrationProfile, task_key: str) -> dict[str, list[int]]: + task = next((task for task in profile.motion.tasks if task.key == task_key), None) + if task is None: + return {} + names = set(task.joints) + names.update(profile.measurement.cross_view_sources[name] for name in task.joints + if name in profile.measurement.cross_view_sources) + measurements = [profile.measurement.measurements[name] for name in names] + result = {} + for view in profile.vision.views: + roles = {role for item in measurements if item.view == view.name + for role in (item.parent_role, item.child_role) if role is not None} + ids = [tag.tag_id for tag in view.tags if tag.role in roles] + if ids: + result[view.name] = ids + return result + + +def normalize_status( + value: Mapping[str, Any], *, profile: CalibrationProfile | None = None, + serial_number: str = "", +) -> dict[str, Any]: + """Normalize transport fields only; never invent quality or motion evidence.""" + result = CalibrationStatus().as_dict() + result.update(value) + state = _STATES.get(str(value.get("state", "WAIT_DEVICE")), str(value.get("state", "WAIT_DEVICE"))) + result["state"] = state + result["serial_number"] = serial_number or value.get("serial_number", "?") + if profile is not None: + result.update(model=profile.key.model, side=profile.key.side, + profile_id=profile.key.profile_id) + if isinstance(value.get("task"), Mapping) and "overall_progress_01" in value: + return result + active = value.get("active", {}) + active = active if isinstance(active, Mapping) else {} + row = {**value, **active} + key = str(row.get("task_name") or "") + selected = next((task for task in profile.motion.tasks if task.key == key), None) if profile else None + cycle = row.get("cycle") + # The old nested active contract is already one-based; old flat nodes are + # zero-based. The public v1 status uses one-based cycles everywhere. + cycle = None if cycle is None else int(cycle) + (0 if active else 1) + result["task"] = { + "key": key, "label": " / ".join(selected.joints) if selected else key, + "cycle": cycle, "cycle_count": 4, "direction": row.get("direction", ""), + "required_tag_ids_by_view": required_tags(profile, key) if profile and key + else value.get("required_tag_ids_by_view", {}), + } + unit = str(value.get("command_unit", profile.command.unit if profile else "u8")) + result["motion"] = { + "command": row.get("current_command_u8", row.get("current_motion_target_u8", row.get("target_rad", row.get("target_u8")))), + "feedback": row.get("actual_u8"), "unit": unit, + "speed": row.get("speed_rad_s", row.get("speed_u8")), + } + sample = row.get("sample", {}) + sample = sample if isinstance(sample, Mapping) else {} + result["acquisition"] = { + "valid_samples": row.get("valid_frames", 0), + "coverage_01": row.get("feedback_span_01", float(sample.get("span_u8", 0)) / 255.0 if unit == "u8" else 0.0), + "bins": row.get("feedback_bins", sample.get("bin_count", 0)), + "maximum_gap": row.get("maximum_bin_gap", sample.get("maximum_gap", 0)), + "retry_count": row.get("automatic_retry_count", max(0, int(row.get("attempt", 1) or 1) - 1)), + } + progress = value.get("progress") + if progress is None: + count = int(value.get("step_count", 0) or 0) + progress = (int(value.get("step_index", 0) or 0) + float(value.get("step_fraction", 0) or 0)) / count if count else 0.0 + result["overall_progress_01"] = 1.0 if state == "COMPLETE" else min(1.0, max(0.0, float(progress))) + result["phase"] = str(row.get("phase") or state) + result["missing_tag_ids"] = value.get("missing_tag_ids", value.get("unrecognized_tag_ids", ())) + result["hardware"] = { + "feedback_hz": value.get("feedback_hz"), + "mode_verified": value.get("position_mode_verified"), + "faults": value.get("error_faults", value.get("error_codes")), + "feedback_fresh": value.get("hand_state_fresh"), + } + if state in {"PAUSED", "ABORTED", "FAILED"}: + code, reason, suggestion = reason_zh(value, model_name=profile.key.model if profile else "机械手") + result["pause"] = {"code": code, "reason": reason, "suggestion": suggestion, + "details": {"raw_reason": str(value.get("reason", ""))}} + return result + + +def render_status_zh(value: Mapping[str, Any], estimator=None) -> str: + """Render v1 without assumptions about channel, task, or camera counts.""" + progress = float(value.get("overall_progress_01", 0.0)) + eta = value.get("estimated_remaining_seconds") + if eta is None and estimator is not None: + eta = estimator.remaining(progress) + eta_text = "计算中" if eta is None else f"{int(eta) // 60}分{int(eta) % 60:02d}秒" + task, motion, acquisition = (value.get(name, {}) for name in ("task", "motion", "acquisition")) + unit = motion.get("unit", "") + def angle(item): + return "未知" if item is None else f"{float(item):.3f} {unit}" + def tags(ids): + return "/".join(f"ID{int(item)}" for item in ids) or "无" + lines = [ + f"[{value.get('model', '未知型号')} / {value.get('side', '?')} / {value.get('serial_number', '?')}] {progress:6.1%} 预计剩余 {eta_text}", + f"阶段:{_PHASES.get(str(value.get('phase')), value.get('phase'))}(第 {task.get('cycle') or '-'}/{task.get('cycle_count', 4)} 轮)", + f"任务:{task.get('label') or task.get('key') or '无'};方向:{task.get('direction') or '-'}", + 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"有效样本 {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)} 次", + ] + waiting = value.get("devices", {}).get("waiting_for", ()) + if waiting: + lines.append("等待项:" + ";".join(waiting)) + if motion.get("speed") is not None: + lines.append(f"运动速度:{motion['speed']} {'rad/s' if unit == 'rad' else 'SDK速度档'}") + if value.get("reference_locked"): + lines.append("基准已锁定:请勿移动手掌、相机、支架或Tag") + resume = value.get("resume", {}) + if resume: + message = resume.get('message', resume.get('reason', '')) + message = {"not_requested": "本次完整重采,未请求恢复"}.get(message, message) + lines.append(f"断点:{message};复用 {resume.get('completed_unit_count', 0)} 单元;来源 {resume.get('source_session') or '-'}") + hardware = value.get("hardware", {}) + lines.append(f"SDK:反馈 {hardware.get('feedback_hz', '未知')} Hz;模式确认 {hardware.get('mode_verified', '未知')};故障 {hardware.get('faults', '未知')}") + pause = value.get("pause", {}) + if pause.get("reason"): + lines.extend((f"原因 [{pause.get('code', '')}]:{pause['reason']}", f"建议:{pause.get('suggestion', '')}")) + if pause.get("details"): + lines.append(f"诊断:{pause['details']}") + return "\n".join(lines) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/steady.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/steady.py new file mode 100644 index 0000000..9407f94 --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/steady.py @@ -0,0 +1,35 @@ +"""Bounded, direction-specific command observations, separate from sweeps.""" + +import math +import numpy as np + + +def steady_targets(profile, unit): + lo, hi = sorted((unit.start, unit.end)) + train = np.linspace(lo, hi, profile.acquisition.steady_training_nodes) + values = train if unit.cycle != 3 else np.r_[lo, (train[:-1]+train[1:])/2, hi] + if profile.command.unit == "u8": + values = np.floor(values+0.5) + values = sorted(set(float(v) for v in values)) + return tuple(values if unit.direction == "increasing" else reversed(values)) + + +def steady_failures(profile, unit, attempt, records): + from .scan_quality import observation_streams + task = next(t for t in profile.motion.tasks if t.key == unit.task_key) + failures = [] + selected = [r for r in records if r.get("sample_phase") == "steady" + and r.get("task_name") == unit.task_key and r.get("cycle") == unit.cycle + and r.get("direction") == unit.direction and int(r.get("attempt", 1)) == attempt] + for field, name, spec in observation_streams(profile, task): + for index, target in enumerate(steady_targets(profile, unit)): + images = {r.get("image_stamp_ns") for r in selected + if r.get("sample_phase") == "steady" and r.get(field) == name + and r.get("view") == spec.view and r.get("task_name") == unit.task_key + and r.get("cycle") == unit.cycle and r.get("direction") == unit.direction + and int(r.get("attempt", 1)) == attempt and r.get("steady_index") == index + and math.isclose(float(r.get("steady_target", float("nan"))), target, abs_tol=1e-9) + and r.get("image_stamp_ns", 0) > 0} + if len(images) < profile.acquisition.steady_minimum_samples: + failures.append(f"steady:{field}:{name}:{spec.view}:node={index}:samples={len(images)}") + return tuple(failures) diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/trajectory.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/trajectory.py new file mode 100644 index 0000000..d83aa0c --- /dev/null +++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/trajectory.py @@ -0,0 +1,271 @@ +"""Profile-driven trajectory and avoidance completion rules.""" + +from __future__ import annotations + +from dataclasses import dataclass +import math +from typing import Sequence + +import numpy as np + +from ..core import CalibrationProfile, TaskSpec + + +@dataclass(frozen=True) +class AvoidanceArrival: + arrived: bool + progress_01: float + direction_correct: bool + stable: bool + + +def smoothstep_position(start: float, end: float, progress_01: float) -> float: + """Endpoint-zero-velocity cosine interpolation.""" + if not all(math.isfinite(float(value)) for value in (start, end, progress_01)): + raise ValueError("trajectory inputs must be finite") + phase = min(1.0, max(0.0, float(progress_01))) + blend = 0.5 - 0.5 * math.cos(math.pi * phase) + return float(start) + blend * (float(end) - float(start)) + + +def cosine_position_trajectory_u8( + start_u8: float, + target_u8: float, + elapsed_seconds: float, + full_range_duration_seconds: float, +) -> tuple[float, float, float]: + """Return a bounded byte-command trajectory with zero endpoint speed.""" + if full_range_duration_seconds <= 0.0: + raise ValueError("full_range_duration_seconds must be positive") + duration = ( + float(full_range_duration_seconds) + * abs(float(target_u8) - float(start_u8)) + / 255.0 + ) + if duration <= 0.0: + return float(target_u8), 1.0, 0.0 + phase = min(1.0, max(0.0, float(elapsed_seconds) / duration)) + return smoothstep_position(start_u8, target_u8, phase), phase, duration + + +def cosine_position_trajectory( + start_value: float, + target_value: float, + elapsed_seconds: float, + maximum_speed: float, +) -> tuple[float, float, float]: + """Cosine trajectory whose peak velocity does not exceed the limit.""" + if not all(math.isfinite(float(value)) for value in (start_value, target_value, elapsed_seconds)): + raise ValueError("trajectory values and clock must be finite") + speed = float(maximum_speed) + if not math.isfinite(speed) or speed <= 0.0: + raise ValueError("maximum_speed must be positive") + distance = abs(float(target_value) - float(start_value)) + if distance <= 0.0: + return float(target_value), 1.0, 0.0 + duration = math.pi * distance / (2.0 * speed) + phase = min(1.0, max(0.0, float(elapsed_seconds) / duration)) + blend = 0.5 - 0.5 * math.cos(math.pi * phase) + return ( + float(start_value) + (float(target_value) - float(start_value)) * blend, + phase, + duration, + ) + + +def cosine_ramp_velocity_trajectory( + start_value: float, + target_value: float, + elapsed_seconds: float, + maximum_speed: float, + ramp_seconds: float, +) -> tuple[float, float, float]: + """Velocity-limited trajectory with cosine ramps and a constant-speed core.""" + if not all(math.isfinite(float(value)) for value in (start_value, target_value, elapsed_seconds)): + raise ValueError("trajectory values and clock must be finite") + speed = float(maximum_speed) + ramp = float(ramp_seconds) + if not math.isfinite(speed) or speed <= 0.0: + raise ValueError("maximum_speed must be positive") + if not math.isfinite(ramp) or ramp <= 0.0: + raise ValueError("ramp_seconds must be positive") + start = float(start_value) + target = float(target_value) + distance = abs(target - start) + if distance <= 0.0: + return target, 1.0, 0.0 + + # For very short moves there is no room for a constant-speed section; + # retain the bounded position-cosine trajectory. + if distance <= speed * ramp: + return cosine_position_trajectory( + start, target, elapsed_seconds, speed + ) + + cruise_seconds = distance / speed - ramp + duration = 2.0 * ramp + cruise_seconds + elapsed = min(duration, max(0.0, float(elapsed_seconds))) + ramp_distance = 0.5 * speed * ramp + if elapsed < ramp: + travelled = speed * ( + 0.5 * elapsed + - ramp * math.sin(math.pi * elapsed / ramp) / (2.0 * math.pi) + ) + elif elapsed < ramp + cruise_seconds: + travelled = ramp_distance + speed * (elapsed - ramp) + else: + down = elapsed - ramp - cruise_seconds + travelled = ( + ramp_distance + + speed * cruise_seconds + + speed * ( + 0.5 * down + + ramp * math.sin(math.pi * down / ramp) / (2.0 * math.pi) + ) + ) + fraction = min(1.0, max(0.0, travelled / distance)) + return ( + start + (target - start) * fraction, + min(1.0, max(0.0, elapsed / duration)), + duration, + ) + + +def build_calibration_motion_command( + task: TaskSpec, + command_value: float, + *, + profile: CalibrationProfile, +) -> list[float]: + """Build one task command from the profile baseline and avoidance values.""" + values = list(profile.command.baseline_values) + for index, value in task.auxiliary_commands: + values[int(index)] = float(value) + values[int(task.command_index)] = float(command_value) + if profile.command.unit == "u8": + return [int(value) for value in values] + return values + + +def build_calibration_preparation_waypoints( + task: TaskSpec, + *, + profile: CalibrationProfile, + current_command: Sequence[float] | None = None, + start_value: float | None = None, +) -> tuple[tuple[float, ...], ...]: + """Execute declarative avoidance groups, then the measured channel.""" + # A return sweep or its retry starts at the opposite endpoint. Never + # silently substitute the task's first endpoint and reverse/jump the hand. + start = task.start_value if start_value is None else float(start_value) + if start not in (task.start_value, task.end_value): + raise ValueError("scan preparation must target a declared sweep endpoint") + final = tuple(build_calibration_motion_command(task, start, profile=profile)) + return _grouped_waypoints(current_command, final, task.preparation_groups, + profile=profile, measured_channel=task.command_index) + + +def build_calibration_return_waypoints( + target_command: Sequence[float] | None = None, + *, + profile: CalibrationProfile, + current_command: Sequence[float] | None = None, + **_: object, +) -> tuple[tuple[float, ...], ...]: + """Return in the profile's reviewed channel-group order.""" + target = ( + tuple(float(value) for value in target_command) + if target_command is not None + else tuple(profile.command.baseline_values) + ) + if len(target) != profile.command.command_count: + raise ValueError("return command has the wrong channel count") + return _grouped_waypoints(current_command, target, profile.motion.return_groups, profile=profile) + + +def _grouped_waypoints(current_command, target, groups, *, profile, measured_channel=None): + current = list(profile.command.baseline_values if current_command is None else current_command) + final = list(target) + count = profile.command.command_count + if len(current) != count or len(final) != count: + raise ValueError("waypoint command count differs from profile") + for vector in (current, final): + if any(not math.isfinite(float(v)) or not lo <= v <= hi for v, lo, hi in zip(vector, profile.command.minimum_values, profile.command.maximum_values)): + raise ValueError("waypoint is outside command coordinates") + for index in profile.command.disabled_indices: + final[index] = current[index] + declared = [tuple(group) for group in groups] + if not declared: + return (tuple(final),) + handled = {index for group in declared for index in group} + remaining = tuple(index for index in range(count) if index not in handled and index != measured_channel and index not in profile.command.disabled_indices) + if remaining: + # Undeclared auxiliaries precede the target, never follow it. + if declared and measured_channel in declared[-1]: + declared.insert(len(declared)-1, remaining) + else: + declared.append(remaining) + if measured_channel is not None and measured_channel not in handled: + declared.append((measured_channel,)) + output = [] + for group in declared: + changed = False + for index in group: + if current[index] != final[index]: + current[index] = final[index] + changed = True + if changed: + output.append(tuple(current)) + return tuple(output or [tuple(final)]) + + +def avoidance_arrival( + *, + start_feedback: float, + requested_target: float, + feedback_history: Sequence[float], + trajectory_complete: bool, + minimum_requested_fraction: float = 0.80, + stability_tolerance: float = 0.02, +) -> AvoidanceArrival: + """Accept a safely reached waypoint without exact servo equality.""" + values = np.asarray(tuple(feedback_history), dtype=float) + requested = float(requested_target) - float(start_feedback) + if values.ndim != 1 or values.size == 0 or not np.all(np.isfinite(values)) or not math.isfinite(requested): + return AvoidanceArrival(False, 0.0, False, False) + if not 0 < minimum_requested_fraction <= 1 or not math.isfinite(stability_tolerance) or stability_tolerance <= 0: + raise ValueError("invalid avoidance completion policy") + stable = bool(values.size >= 3 and float(np.ptp(values[-min(10, values.size):])) <= float(stability_tolerance)) + if abs(requested) < 1e-9: + close = abs(float(values[-1]) - float(requested_target)) <= stability_tolerance + return AvoidanceArrival(bool(trajectory_complete and stable and close), 1.0 if close else 0.0, close, stable) + measured = float(values[-1]) - float(start_feedback) + progress = measured / requested + direction_correct = progress >= 0.0 + stable = bool( + values.size >= 3 + and float(np.ptp(values[-min(10, values.size):])) + <= float(stability_tolerance) + ) + return AvoidanceArrival( + bool( + trajectory_complete + and direction_correct + and progress >= float(minimum_requested_fraction) + and stable + ), + float(progress), + direction_correct, + stable, + ) + + +__all__ = [ + "AvoidanceArrival", + "avoidance_arrival", + "build_calibration_motion_command", + "build_calibration_preparation_waypoints", + "build_calibration_return_waypoints", + "cosine_position_trajectory_u8", + "smoothstep_position", +] diff --git a/src/linkerhand_calibration/linkerhand_calibration/three_camera_diagnostics.py b/src/linkerhand_calibration/linkerhand_calibration/three_camera_diagnostics.py index 41eeb86..c626bfa 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/three_camera_diagnostics.py +++ b/src/linkerhand_calibration/linkerhand_calibration/three_camera_diagnostics.py @@ -1,4 +1,4 @@ """Compatibility import for the installed Chinese model diagnostics.""" -from .models.g20.reporting_zh import * # noqa: F401,F403 -from .models.g20.reporting_zh import _task_text +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.reporting_zh import * # noqa: F401,F403 +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.reporting_zh import _task_text diff --git a/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py b/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py index 9465c69..f23027c 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py +++ b/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py @@ -1,7 +1,7 @@ -"""One-release module alias for the relocated model-specific ROS node.""" +"""Legacy module spelling of the only online calibration entry.""" import sys -from .models.g20 import node as _implementation +from .runtime.ros import entrypoint as _implementation sys.modules[__name__] = _implementation diff --git a/src/linkerhand_calibration/linkerhand_calibration/trajectory.py b/src/linkerhand_calibration/linkerhand_calibration/trajectory.py index 9a32ac8..cb3d9a7 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/trajectory.py +++ b/src/linkerhand_calibration/linkerhand_calibration/trajectory.py @@ -21,7 +21,6 @@ import math from typing import Any, Iterable, Mapping, Sequence import numpy as np -from scipy.optimize import least_squares from scipy.spatial.transform import Rotation from .core import ( @@ -40,11 +39,9 @@ _ROOT_ROLES = ("t3", "t4", "t5") DEFAULT_PASSIVE_IP_MULTIPLIER = 1.02 -def _vector3(value: Sequence[float], *, name: str) -> np.ndarray: - vector = np.asarray(value, dtype=float) - if vector.shape != (3,) or not np.all(np.isfinite(vector)): - raise ValueError(f"{name} must contain three finite values") - return vector +from .core.fitting.trajectory_geometry import ( + _vector3, _plane_basis, _fit_plane_axis, _fit_circle_with_axis, _project_radial, _signed_angle, _reference_radial, _angle_for_circle, _low_command_median, _orient_circle_positive, _fit_joint_curve, _regularize_coupled_zero_tail +) def _record_translations( @@ -64,204 +61,22 @@ def _record_translations( } -def _plane_basis(axis: Sequence[float]) -> tuple[np.ndarray, np.ndarray]: - normal = _vector3(axis, name="axis") - normal /= np.linalg.norm(normal) - candidates = np.eye(3) - seed = candidates[int(np.argmin(np.abs(candidates @ normal)))] - first = np.cross(normal, seed) - first /= np.linalg.norm(first) - second = np.cross(normal, first) - second /= np.linalg.norm(second) - return first, second -def _fit_plane_axis(point_sets: Sequence[np.ndarray]) -> tuple[np.ndarray, float]: - centred: list[np.ndarray] = [] - for points in point_sets: - array = np.asarray(points, dtype=float) - if array.ndim != 2 or array.shape[1] != 3 or len(array) < 3: - raise ValueError("each trajectory must contain at least three 3-D points") - # The arithmetic centroid of points in a plane remains in that plane - # and transforms correctly under a camera rotation. A component-wise - # median generally does neither. - centred.append(array - np.mean(array, axis=0)) - matrix = np.concatenate(centred, axis=0) - _, _, vh = np.linalg.svd(matrix, full_matrices=False) - axis = vh[-1] - axis /= np.linalg.norm(axis) - residuals = matrix @ axis - plane_rms = float(np.sqrt(np.mean(np.square(residuals)))) - return axis, plane_rms -def _fit_circle_with_axis( - points_xyz: Sequence[Sequence[float]], - axis_xyz: Sequence[float], -) -> dict[str, Any]: - points = np.asarray(points_xyz, dtype=float) - if ( - points.ndim != 2 - or points.shape[1] != 3 - or len(points) < 6 - or not np.all(np.isfinite(points)) - ): - raise ValueError("a circle requires at least six finite 3-D points") - axis = _vector3(axis_xyz, name="circle axis") - axis /= np.linalg.norm(axis) - basis_x, basis_y = _plane_basis(axis) - origin = np.mean(points, axis=0) - local = points - origin - xy = np.column_stack((local @ basis_x, local @ basis_y)) - - design = np.column_stack((2.0 * xy[:, 0], 2.0 * xy[:, 1], np.ones(len(xy)))) - target = np.sum(np.square(xy), axis=1) - initial_x, initial_y, constant = np.linalg.lstsq( - design, target, rcond=None - )[0] - initial_radius = math.sqrt( - max( - float(constant + initial_x * initial_x + initial_y * initial_y), - np.finfo(float).eps, - ) - ) - - def residual(parameters: np.ndarray) -> np.ndarray: - centre = parameters[:2] - radius = float(parameters[2]) - return np.linalg.norm(xy - centre, axis=1) - radius - - fitted = least_squares( - residual, - np.asarray([initial_x, initial_y, initial_radius], dtype=float), - loss="soft_l1", - f_scale=0.0005, - max_nfev=2000, - ) - centre_xy = fitted.x[:2] - radius = abs(float(fitted.x[2])) - radial_residuals = residual( - np.asarray([centre_xy[0], centre_xy[1], radius], dtype=float) - ) - centre_xyz = origin + centre_xy[0] * basis_x + centre_xy[1] * basis_y - plane_offsets = (points - centre_xyz) @ axis - centre_xyz += float(np.median(plane_offsets)) * axis - plane_residuals = (points - centre_xyz) @ axis - return { - "axis_xyz": [float(value) for value in axis], - "center_xyz_m": [float(value) for value in centre_xyz], - "radius_m": radius, - "radial_rms_m": float( - np.sqrt(np.mean(np.square(radial_residuals))) - ), - "plane_rms_m": float( - np.sqrt(np.mean(np.square(plane_residuals))) - ), - } -def _project_radial( - point_xyz: Sequence[float], - circle: Mapping[str, Any], -) -> np.ndarray: - point = _vector3(point_xyz, name="trajectory point") - centre = _vector3(circle["center_xyz_m"], name="circle centre") - axis = _vector3(circle["axis_xyz"], name="circle axis") - axis /= np.linalg.norm(axis) - radial = point - centre - radial -= float(radial @ axis) * axis - if float(np.linalg.norm(radial)) < 1.0e-9: - raise ValueError("trajectory point lies on the fitted rotation axis") - return radial -def _signed_angle( - reference_radial_xyz: Sequence[float], - observed_radial_xyz: Sequence[float], - axis_xyz: Sequence[float], -) -> float: - reference = _vector3(reference_radial_xyz, name="reference radial") - observed = _vector3(observed_radial_xyz, name="observed radial") - axis = _vector3(axis_xyz, name="angle axis") - axis /= np.linalg.norm(axis) - reference -= float(reference @ axis) * axis - observed -= float(observed @ axis) * axis - reference /= np.linalg.norm(reference) - observed /= np.linalg.norm(observed) - return math.atan2( - float(axis @ np.cross(reference, observed)), - float(np.clip(reference @ observed, -1.0, 1.0)), - ) -def _reference_radial( - records: Sequence[Mapping[str, Any]], - points: Sequence[np.ndarray], - circle: Mapping[str, Any], -) -> np.ndarray: - references = [ - _project_radial(point, circle) - for record, point in zip(records, points) - if int(record.get("command_u8", -1)) == 255 - ] - if not references: - raise ValueError("trajectory is missing command-255 reference points") - radial = np.median(np.asarray(references, dtype=float), axis=0) - axis = _vector3(circle["axis_xyz"], name="circle axis") - axis /= np.linalg.norm(axis) - radial -= float(radial @ axis) * axis - if float(np.linalg.norm(radial)) < 1.0e-9: - raise ValueError("command-255 reference is degenerate") - return radial -def _angle_for_circle( - point_xyz: Sequence[float], - circle: Mapping[str, Any], -) -> float: - return _signed_angle( - circle["reference_radial_xyz_m"], - _project_radial(point_xyz, circle), - circle["axis_xyz"], - ) -def _low_command_median( - records: Sequence[Mapping[str, Any]], - values: Sequence[float], -) -> float: - selected = [ - float(value) - for record, value in zip(records, values) - if int(record.get("command_u8", 255)) <= 16 - ] - if not selected: - selected = [ - float(value) - for _, value in sorted( - zip(records, values), - key=lambda item: int(item[0].get("command_u8", 255)), - )[: max(3, len(records) // 20)] - ] - return float(np.median(selected)) -def _orient_circle_positive( - circle: dict[str, Any], - records: Sequence[Mapping[str, Any]], - points: Sequence[np.ndarray], -) -> dict[str, Any]: - reference = _reference_radial(records, points, circle) - circle["reference_radial_xyz_m"] = [ - float(value) for value in reference - ] - values = [_angle_for_circle(point, circle) for point in points] - if _low_command_median(records, values) < 0.0: - axis = -_vector3(circle["axis_xyz"], name="circle axis") - circle["axis_xyz"] = [float(value) for value in axis] - values = [_angle_for_circle(point, circle) for point in points] - circle["observed_arc_rad"] = float(max(values) - min(values)) - return circle def _rotation_about_axis( @@ -629,145 +444,8 @@ def maximum_center_non_target_drift_rad( return float(maximum) -def _fit_joint_curve( - records: Sequence[Mapping[str, Any]], - values: Sequence[float], - *, - endpoint_reference: Mapping[str, Sequence[float]] | None = None, - preserve_direction_offset: bool = False, - require_observed_domain_endpoints: bool = True, -) -> tuple[dict[str, Any], float, float]: - by_direction: dict[str, list[list[float]]] = { - direction: [[] for _ in range(256)] for direction in DIRECTIONS - } - for record, value in zip(records, values): - direction = str(record["direction"]) - command = int(record["command_u8"]) - by_direction[direction][command].append(float(value)) - - fitted: dict[str, list[float]] = {} - maximum_correction = 0.0 - for direction in DIRECTIONS: - commands = np.asarray( - [ - command - for command, samples in enumerate(by_direction[direction]) - if samples - ], - dtype=int, - ) - if commands.size < 3: - raise ValueError( - f"{direction} centre trajectory requires at least 3 commands" - ) - if require_observed_domain_endpoints and ( - int(commands[0]) != 0 or int(commands[-1]) != 255 - ): - raise ValueError( - f"{direction} centre trajectory requires commands 0 and 255" - ) - raw = np.asarray( - [ - float(np.median(by_direction[direction][command])) - for command in commands - ], - dtype=float, - ) - if not preserve_direction_offset: - raw -= raw[-1] - projected_samples = isotonic_nonincreasing(raw) - if not preserve_direction_offset: - projected_samples -= projected_samples[-1] - maximum_correction = max( - maximum_correction, - float(np.max(np.abs(projected_samples - raw))), - ) - curve = np.interp( - np.arange(256, dtype=float), - commands.astype(float), - projected_samples, - ) - if not preserve_direction_offset: - curve -= curve[255] - if endpoint_reference is not None: - curve = _regularize_coupled_zero_tail( - curve, - endpoint_reference[f"{direction}_rad"], - ) - fitted[direction] = [ - round(float(value), 8) for value in curve - ] - - decreasing = np.asarray(fitted[DIRECTION_DECREASING], dtype=float) - increasing = np.asarray(fitted[DIRECTION_INCREASING], dtype=float) - hysteresis = float(np.max(np.abs(decreasing - increasing))) - combined = 0.5 * (decreasing + increasing) - if not preserve_direction_offset: - combined -= combined[255] - return ( - { - "angle_rad": [round(float(value), 8) for value in combined], - "decreasing_rad": fitted[DIRECTION_DECREASING], - "increasing_rad": fitted[DIRECTION_INCREASING], - }, - maximum_correction, - hysteresis, - ) -def _regularize_coupled_zero_tail( - values: Sequence[float], - reference_values: Sequence[float], - *, - maximum_tail_commands: int = 16, - zero_tolerance_rad: float = 1.0e-10, -) -> np.ndarray: - """Replace a short noise-created zero tail using a coupled joint shape. - - The passive IP and active MCP share motor 15. Close to command 255 the - IP centre trajectory is small enough that measurement noise can become - negative. Isotonic projection correctly prevents a negative angle, but - otherwise turns all remaining commands into an artificial zero plateau. - Continue the last resolved IP/MCP ratio over a short tail instead. This - changes neither the resolved part of the curve nor the exact 255 zero. - """ - curve = np.asarray(values, dtype=float).copy() - reference = np.asarray(reference_values, dtype=float) - if curve.shape != (256,) or reference.shape != (256,): - raise ValueError("endpoint curves must each contain 256 values") - if not np.all(np.isfinite(curve)) or not np.all(np.isfinite(reference)): - raise ValueError("endpoint curves must be finite") - maximum_tail = int(maximum_tail_commands) - tolerance = float(zero_tolerance_rad) - if maximum_tail < 2 or tolerance < 0.0: - raise ValueError("invalid endpoint regularization settings") - - anchor = 254 - while anchor >= 0 and abs(float(curve[anchor])) <= tolerance: - anchor -= 1 - tail_commands = 255 - anchor - if ( - anchor < 0 - or tail_commands < 2 - or tail_commands > maximum_tail - or curve[anchor] <= tolerance - or reference[anchor] <= tolerance - or abs(float(curve[255])) > tolerance - or abs(float(reference[255])) > tolerance - ): - curve[255] = 0.0 - return curve - - ratio = float(curve[anchor] / reference[anchor]) - continuation = np.maximum( - 0.0, - ratio * reference[anchor + 1 :], - ) - continuation = np.minimum.accumulate(continuation) - continuation = np.minimum(continuation, float(curve[anchor])) - continuation[-1] = 0.0 - curve[anchor + 1 :] = continuation - return curve def _derive_mimic_joint_curves( diff --git a/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py b/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py index edfb635..fc89a2f 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py +++ b/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py @@ -2,6 +2,6 @@ import sys -from .models.g20 import zero_solver as _implementation +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20 import zero_solver as _implementation sys.modules[__name__] = _implementation diff --git a/src/linkerhand_calibration/linkerhand_calibration/zero_calibration.py b/src/linkerhand_calibration/linkerhand_calibration/zero_calibration.py index 31aee58..c47ce68 100644 --- a/src/linkerhand_calibration/linkerhand_calibration/zero_calibration.py +++ b/src/linkerhand_calibration/linkerhand_calibration/zero_calibration.py @@ -7,20 +7,13 @@ from typing import Any, Mapping, Sequence import cv2 import numpy as np -from scipy.optimize import least_squares -def wrap_angle_rad(angle_rad: float) -> float: - """Wrap one finite angle to [-pi, pi).""" - angle = float(angle_rad) - if not math.isfinite(angle): - raise ValueError("angle must be finite") - return (angle + math.pi) % (2.0 * math.pi) - math.pi +from .core.fitting.circle_geometry import ( + wrap_angle_rad, signed_angle_difference_rad, circular_mean_rad, circular_median_rad, circular_std_rad, maximum_pairwise_angle_difference_rad, _fit_circle, _trajectory_arc_rad +) -def signed_angle_difference_rad(angle_rad: float, reference_rad: float) -> float: - """Return the wrapped signed rotation from reference to angle.""" - return wrap_angle_rad(float(angle_rad) - float(reference_rad)) def tag_x_axis_angle_rad( @@ -38,61 +31,12 @@ def tag_x_axis_angle_rad( ) -def circular_mean_rad(angles_rad: Sequence[float]) -> float: - """Return the circular mean of at least one finite angle.""" - angles = np.asarray(angles_rad, dtype=float) - if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)): - raise ValueError("angles_rad must contain finite angles") - vector = complex( - float(np.mean(np.cos(angles))), - float(np.mean(np.sin(angles))), - ) - if abs(vector) < 1.0e-12: - raise ValueError("angles do not have a unique circular mean") - return wrap_angle_rad(math.atan2(vector.imag, vector.real)) -def circular_median_rad(angles_rad: Sequence[float]) -> float: - """Return a robust circular median around the circular mean.""" - angles = np.asarray(angles_rad, dtype=float) - reference = circular_mean_rad(angles) - deltas = np.asarray( - [signed_angle_difference_rad(value, reference) for value in angles], - dtype=float, - ) - return wrap_angle_rad(reference + float(np.median(deltas))) -def circular_std_rad(angles_rad: Sequence[float]) -> float: - """Return circular standard deviation in radians.""" - angles = np.asarray(angles_rad, dtype=float) - if angles.ndim != 1 or angles.size == 0 or not np.all(np.isfinite(angles)): - raise ValueError("angles_rad must contain finite angles") - resultant = float( - math.hypot( - float(np.mean(np.cos(angles))), - float(np.mean(np.sin(angles))), - ) - ) - resultant = min(1.0, max(1.0e-15, resultant)) - return math.sqrt(max(0.0, -2.0 * math.log(resultant))) -def maximum_pairwise_angle_difference_rad( - angles_rad: Sequence[float], -) -> float: - """Return the largest wrapped distance between any two angles.""" - angles = [float(value) for value in angles_rad] - if not angles or not all(math.isfinite(value) for value in angles): - raise ValueError("angles_rad must contain finite angles") - return max( - ( - abs(signed_angle_difference_rad(left, right)) - for index, left in enumerate(angles) - for right in angles[index + 1:] - ), - default=0.0, - ) def detect_reference_alignment_line( @@ -253,72 +197,8 @@ def summarize_zero_frames( } -def _fit_circle(points_xy: np.ndarray) -> tuple[np.ndarray, float]: - """Fit a geometric circle with robust nonlinear least squares.""" - points = np.asarray(points_xy, dtype=float) - if points.ndim != 2 or points.shape[1] != 2 or len(points) < 3: - raise ValueError("circle fit requires at least three 2D points") - origin = np.mean(points, axis=0) - centered = points - origin - matrix = np.column_stack( - (2.0 * centered[:, 0], 2.0 * centered[:, 1], np.ones(len(points))) - ) - rhs = np.sum(np.square(centered), axis=1) - solution, _, rank, _ = np.linalg.lstsq(matrix, rhs, rcond=None) - if rank < 3: - raise ValueError("trajectory points are degenerate") - centre0 = solution[:2] + origin - radius0_squared = ( - float(solution[2]) + float(np.dot(solution[:2], solution[:2])) - ) - if radius0_squared <= 0.0: - raise ValueError("trajectory circle radius is invalid") - radius0 = math.sqrt(radius0_squared) - - def residual(parameters: np.ndarray) -> np.ndarray: - centre = parameters[:2] - radius = parameters[2] - return np.linalg.norm(points - centre, axis=1) - radius - - initial_error = residual(np.asarray([*centre0, radius0], dtype=float)) - robust_scale = max( - 0.25, - 1.4826 - * float( - np.median( - np.abs(initial_error - float(np.median(initial_error))) - ) - ), - ) - result = least_squares( - residual, - np.asarray([*centre0, radius0], dtype=float), - loss="soft_l1", - f_scale=robust_scale, - max_nfev=1000, - ) - centre = np.asarray(result.x[:2], dtype=float) - radius = float(abs(result.x[2])) - if not result.success or not np.all(np.isfinite(centre)): - raise ValueError("trajectory circle optimization failed") - if not math.isfinite(radius) or radius <= 0.0: - raise ValueError("trajectory circle radius is invalid") - return centre, radius -def _trajectory_arc_rad(points_xy: np.ndarray, centre_xy: np.ndarray) -> float: - angles = np.mod( - np.arctan2( - points_xy[:, 1] - centre_xy[1], - points_xy[:, 0] - centre_xy[0], - ), - 2.0 * math.pi, - ) - if len(angles) < 2: - return 0.0 - angles = np.sort(angles) - gaps = np.diff(np.r_[angles, angles[0] + 2.0 * math.pi]) - return float(2.0 * math.pi - np.max(gaps)) def fit_image_circle_trajectory( diff --git a/src/linkerhand_calibration/linkerhand_calibration/zero_node.py b/src/linkerhand_calibration/linkerhand_calibration/zero_node.py deleted file mode 100644 index 07bdfd1..0000000 --- a/src/linkerhand_calibration/linkerhand_calibration/zero_node.py +++ /dev/null @@ -1,1608 +0,0 @@ -"""ROS 2 node for trajectory-circle G20 CMC zero/travel measurement.""" - -from __future__ import annotations - -from collections import deque -import json -import math -from pathlib import Path -import re -import time -from typing import Any - -from apriltag_msgs.msg import AprilTagDetectionArray -import cv2 -from cv_bridge import CvBridge -import numpy as np -import rclpy -from rclpy.node import Node -from rclpy.qos import qos_profile_sensor_data -from sensor_msgs.msg import Image, JointState -from std_msgs.msg import String -from std_srvs.srv import Trigger - -from .compat.legacy.thumb_core import ( - BASELINE_COMMAND, - COMMAND_NAMES, - build_command, -) -from .storage import atomic_write_json -from .zero_calibration import ( - build_trajectory_zero_angle_payload, - build_trajectory_zero_travel_payload, - detect_reference_alignment_line, - fit_image_circle_trajectory, - measure_zero_from_circle, - summarize_zero_frames, - validate_zero_angle_payload, - validate_zero_travel_payload, -) - - -STATE_PREFLIGHT = "PREFLIGHT" -STATE_WAIT_START = "WAIT_START" -STATE_MOVING = "MOVING" -STATE_CAPTURING = "CAPTURING" -STATE_SWEEPING = "SWEEPING" -STATE_PAUSED = "PAUSED" -STATE_ABORTED = "ABORTED" -STATE_COMPLETE = "COMPLETE" - -STATE_ZH = { - STATE_PREFLIGHT: "标定前检查", - STATE_WAIT_START: "等待开始", - STATE_MOVING: "机械手运动中", - STATE_CAPTURING: "采集零位角", - STATE_SWEEPING: "采集圆心轨迹", - STATE_PAUSED: "标定已暂停", - STATE_ABORTED: "标定已终止", - STATE_COMPLETE: "标定完成", -} - -STAGE_ZH = { - "zero_before": "255零位(运动前)", - "sweep_out": "圆心轨迹(255到轨迹端点)", - "travel_endpoint": "行程端点", - "sweep_return": "圆心轨迹(返回255)", - "zero_after": "255零位(返回后)", -} - - -def _safe_name(value: str) -> str: - safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(value).strip()) - return safe or "UNSET" - - -class G20ThumbCmcTrajectoryNode(Node): - """Measure a CMC zero and optional travel from the T3-centre circle.""" - - def __init__(self) -> None: - super().__init__("g20_thumb_cmc_trajectory_calibration") - self._declare_parameters() - self._load_parameters() - - self.session_dir.mkdir(parents=True, exist_ok=True) - result_kind = "zero_travel" if self.measure_travel else "zero" - self.result_path = self.session_dir / ( - f"g20_left_{_safe_name(self.serial_number)}" - f"_{self.joint_name}_{result_kind}.json" - ) - - self.state = STATE_PREFLIGHT - self.reason = "waiting_for_t0_t3_and_hand_state" - self.latest_state_u8: tuple[float, ...] = () - self.latest_corners: dict[str, np.ndarray] = {} - self.latest_quality: dict[str, dict[str, Any]] = {} - self.latest_angles: dict[str, float] = {} - self.preflight_flags: deque[bool] = deque(maxlen=self.preflight_frames) - self.camera_alignment_measurements: deque[dict[str, Any] | None] = deque( - maxlen=self.camera_alignment_required_frames - ) - self.latest_camera_alignment: dict[str, Any] = {} - self.last_camera_alignment_update = 0.0 - self.session_detection_flags: list[bool] = [] - - self.round_index = 0 - self.rounds: list[dict[str, Any]] = [] - self.current_round: dict[str, Any] = {} - self.active_stage: str | None = None - self.active_target_u8: int | None = None - self.required_frames = 0 - self.stage_started_at = 0.0 - self.target_reached_at: float | None = None - self.capture_started_at = 0.0 - self.capture_frames: deque[dict[str, float]] = deque() - self.trajectory_frames: list[dict[str, float]] = [] - self.latest_trajectory_frames: list[dict[str, float]] = [] - self.latest_circle: dict[str, Any] = {} - self.latest_travel: dict[str, float] = {} - self.final_payload: dict[str, Any] | None = None - self.final_report: dict[str, Any] = {} - - self.command_publisher = self.create_publisher( - JointState, self.command_topic, 1 - ) - self.status_publisher = self.create_publisher(String, "~/status", 10) - self.status_text_publisher = self.create_publisher( - String, "~/status_text", 10 - ) - self.debug_publisher = None - self.bridge = CvBridge() - self.last_debug_publish = 0.0 - if self.publish_debug_image: - self.debug_publisher = self.create_publisher( - Image, "~/debug_image", qos_profile_sensor_data - ) - - self.create_subscription( - JointState, self.state_topic, self._state_callback, 10 - ) - self.create_subscription( - AprilTagDetectionArray, - self.detections_topic, - self._detections_callback, - qos_profile_sensor_data, - ) - if self.publish_debug_image or self.camera_alignment_enabled: - self.create_subscription( - Image, - self.image_topic, - self._image_callback, - qos_profile_sensor_data, - ) - - self.create_service(Trigger, "~/start", self._start_callback) - self.create_service(Trigger, "~/abort", self._abort_callback) - self.timer = self.create_timer(0.05, self._timer_callback) - self.status_timer = self.create_timer(0.5, self._publish_status) - self.get_logger().info( - f"{self.joint_name} calibration session: " - f"{self.session_dir}; result={self.result_path}" - ) - - def _declare_parameters(self) -> None: - self.declare_parameter("serial_number", "UNSET") - self.declare_parameter("session_dir", "calibration_output/session") - self.declare_parameter("commands_enabled", True) - self.declare_parameter( - "command_topic", "/g20/cb_left_hand_control_cmd" - ) - self.declare_parameter("state_topic", "/g20/cb_left_hand_state") - self.declare_parameter( - "detections_topic", "/apriltag/detections" - ) - self.declare_parameter( - "image_topic", "/camera/camera/color/image_rect" - ) - self.declare_parameter("publish_debug_image", True) - self.declare_parameter("debug_max_rate_hz", 10.0) - self.declare_parameter("debug_scale", 0.75) - self.declare_parameter("camera_alignment_enabled", False) - self.declare_parameter("camera_alignment_reference_y_ratio", 0.90) - self.declare_parameter("camera_alignment_roi_y_min_ratio", 0.55) - self.declare_parameter("camera_alignment_roi_y_max_ratio", 0.98) - self.declare_parameter("camera_alignment_minimum_line_length_ratio", 0.30) - self.declare_parameter("camera_alignment_max_candidate_angle_deg", 15.0) - self.declare_parameter("camera_alignment_max_angle_deg", 0.5) - self.declare_parameter("camera_alignment_max_vertical_offset_px", 12.0) - self.declare_parameter("camera_alignment_required_frames", 10) - self.declare_parameter("camera_alignment_minimum_detection_rate", 0.8) - self.declare_parameter("camera_alignment_max_age_seconds", 1.0) - self.declare_parameter("t0_id", 0) - self.declare_parameter("t3_id", 1) - self.declare_parameter("joint_name", "thumb_cmc_pitch") - self.declare_parameter("motor_index", 0) - self.declare_parameter("zero_command_u8", 255) - self.declare_parameter("measure_travel", False) - self.declare_parameter( - "baseline_command_u8", list(BASELINE_COMMAND) - ) - self.declare_parameter("repetitions", 3) - self.declare_parameter("zero_capture_frames", 30) - self.declare_parameter("trajectory_command_u8", 64) - self.declare_parameter("trajectory_bin_size_u8", 8.0) - self.declare_parameter("trajectory_minimum_frames", 45) - self.declare_parameter("trajectory_minimum_bins", 18) - self.declare_parameter("trajectory_minimum_state_span_u8", 160.0) - self.declare_parameter("trajectory_minimum_radius_px", 20.0) - self.declare_parameter("trajectory_minimum_arc_deg", 20.0) - self.declare_parameter("trajectory_maximum_radial_rms_px", 2.0) - self.declare_parameter( - "trajectory_maximum_p95_radial_error_px", 3.5 - ) - self.declare_parameter("trajectory_endpoint_settle_seconds", 0.3) - self.declare_parameter("trajectory_timeout_seconds", 30.0) - self.declare_parameter("settle_seconds", 0.5) - self.declare_parameter("move_timeout_seconds", 20.0) - self.declare_parameter("capture_timeout_seconds", 15.0) - self.declare_parameter("state_tolerance_u8", 2.0) - self.declare_parameter("preflight_frames", 60) - self.declare_parameter("minimum_detection_rate", 0.95) - self.declare_parameter("maximum_hamming", 0) - self.declare_parameter("minimum_decision_margin", 30.0) - self.declare_parameter("minimum_edge_pixels", 40.0) - self.declare_parameter("maximum_round_difference_deg", 1.0) - self.declare_parameter("maximum_travel_difference_deg", 1.0) - self.declare_parameter("minimum_travel_deg", 10.0) - self.declare_parameter("maximum_return_error_deg", 1.0) - self.declare_parameter("maximum_zero_radial_error_px", 4.0) - self.declare_parameter("maximum_static_position_rms_px", 1.5) - - def _load_parameters(self) -> None: - def value(name: str) -> Any: - return self.get_parameter(name).value - - self.serial_number = str(value("serial_number")) - if ( - not self.serial_number - or self.serial_number == "UNSET" - or re.fullmatch(r"[A-Za-z0-9_.-]+", self.serial_number) is None - ): - raise ValueError("serial_number is required and must be path-safe") - self.session_dir = Path(str(value("session_dir"))).expanduser().resolve() - self.commands_enabled = bool(value("commands_enabled")) - self.command_topic = str(value("command_topic")) - self.state_topic = str(value("state_topic")) - self.detections_topic = str(value("detections_topic")) - self.image_topic = str(value("image_topic")) - self.publish_debug_image = bool(value("publish_debug_image")) - self.debug_max_rate_hz = float(value("debug_max_rate_hz")) - self.debug_scale = float(value("debug_scale")) - self.camera_alignment_enabled = bool( - value("camera_alignment_enabled") - ) - self.camera_alignment_reference_y_ratio = float( - value("camera_alignment_reference_y_ratio") - ) - self.camera_alignment_roi_y_min_ratio = float( - value("camera_alignment_roi_y_min_ratio") - ) - self.camera_alignment_roi_y_max_ratio = float( - value("camera_alignment_roi_y_max_ratio") - ) - self.camera_alignment_minimum_line_length_ratio = float( - value("camera_alignment_minimum_line_length_ratio") - ) - self.camera_alignment_max_candidate_angle_rad = math.radians( - float(value("camera_alignment_max_candidate_angle_deg")) - ) - self.camera_alignment_max_angle_rad = math.radians( - float(value("camera_alignment_max_angle_deg")) - ) - self.camera_alignment_max_vertical_offset_px = float( - value("camera_alignment_max_vertical_offset_px") - ) - self.camera_alignment_required_frames = int( - value("camera_alignment_required_frames") - ) - self.camera_alignment_minimum_detection_rate = float( - value("camera_alignment_minimum_detection_rate") - ) - self.camera_alignment_max_age_seconds = float( - value("camera_alignment_max_age_seconds") - ) - self.t0_id = int(value("t0_id")) - self.t3_id = int(value("t3_id")) - self.role_by_id = {self.t0_id: "t0", self.t3_id: "t3"} - if self.t0_id == self.t3_id: - raise ValueError("t0_id and t3_id must differ") - self.joint_name = str(value("joint_name")) - self.motor_index = int(value("motor_index")) - self.zero_command_u8 = int(value("zero_command_u8")) - self.measure_travel = bool(value("measure_travel")) - self.baseline_command = tuple( - int(item) for item in value("baseline_command_u8") - ) - self.repetitions = int(value("repetitions")) - self.zero_capture_frames = int(value("zero_capture_frames")) - self.trajectory_command_u8 = int(value("trajectory_command_u8")) - self.trajectory_bin_size_u8 = float(value("trajectory_bin_size_u8")) - self.trajectory_minimum_frames = int( - value("trajectory_minimum_frames") - ) - self.trajectory_minimum_bins = int(value("trajectory_minimum_bins")) - self.trajectory_minimum_state_span_u8 = float( - value("trajectory_minimum_state_span_u8") - ) - self.trajectory_minimum_radius_px = float( - value("trajectory_minimum_radius_px") - ) - self.trajectory_minimum_arc_rad = math.radians( - float(value("trajectory_minimum_arc_deg")) - ) - self.trajectory_maximum_radial_rms_px = float( - value("trajectory_maximum_radial_rms_px") - ) - self.trajectory_maximum_p95_radial_error_px = float( - value("trajectory_maximum_p95_radial_error_px") - ) - self.trajectory_endpoint_settle_seconds = float( - value("trajectory_endpoint_settle_seconds") - ) - self.trajectory_timeout_seconds = float( - value("trajectory_timeout_seconds") - ) - self.settle_seconds = float(value("settle_seconds")) - self.move_timeout_seconds = float(value("move_timeout_seconds")) - self.capture_timeout_seconds = float(value("capture_timeout_seconds")) - self.state_tolerance_u8 = float(value("state_tolerance_u8")) - self.preflight_frames = int(value("preflight_frames")) - self.minimum_detection_rate = float(value("minimum_detection_rate")) - self.maximum_hamming = int(value("maximum_hamming")) - self.minimum_decision_margin = float( - value("minimum_decision_margin") - ) - self.minimum_edge_pixels = float(value("minimum_edge_pixels")) - self.maximum_round_difference_rad = math.radians( - float(value("maximum_round_difference_deg")) - ) - self.maximum_travel_difference_rad = math.radians( - float(value("maximum_travel_difference_deg")) - ) - self.minimum_travel_rad = math.radians( - float(value("minimum_travel_deg")) - ) - self.maximum_return_error_rad = math.radians( - float(value("maximum_return_error_deg")) - ) - self.maximum_zero_radial_error_px = float( - value("maximum_zero_radial_error_px") - ) - self.maximum_static_position_rms_px = float( - value("maximum_static_position_rms_px") - ) - self._validate_parameters() - - def _validate_parameters(self) -> None: - expected_motor_indices = { - "thumb_cmc_pitch": 0, - "thumb_cmc_roll": 5, - } - if self.joint_name not in expected_motor_indices: - raise ValueError( - "joint_name must be thumb_cmc_pitch or thumb_cmc_roll" - ) - if self.motor_index != expected_motor_indices[self.joint_name]: - raise ValueError( - f"{self.joint_name} requires motor_index " - f"{expected_motor_indices[self.joint_name]}" - ) - if self.measure_travel and self.joint_name != "thumb_cmc_roll": - raise ValueError( - "measure_travel is currently supported only for thumb_cmc_roll" - ) - if not ( - 0.0 - <= self.camera_alignment_roi_y_min_ratio - < self.camera_alignment_reference_y_ratio - < self.camera_alignment_roi_y_max_ratio - <= 1.0 - ): - raise ValueError( - "camera alignment ratios must satisfy " - "0 <= roi_min < reference < roi_max <= 1" - ) - if not ( - 0.0 < self.camera_alignment_minimum_line_length_ratio <= 1.0 - ): - raise ValueError( - "camera_alignment_minimum_line_length_ratio must be in (0, 1]" - ) - if not ( - self.camera_alignment_max_angle_rad - < self.camera_alignment_max_candidate_angle_rad - < math.pi / 2.0 - ): - raise ValueError( - "camera alignment acceptance angle must be smaller than " - "the candidate angle" - ) - if self.camera_alignment_required_frames < 3: - raise ValueError( - "camera_alignment_required_frames must be at least 3" - ) - if not 0.0 < self.camera_alignment_minimum_detection_rate <= 1.0: - raise ValueError( - "camera_alignment_minimum_detection_rate must be in (0, 1]" - ) - if len(self.baseline_command) != 20: - raise ValueError("baseline_command_u8 must contain 20 values") - if any(value < 0 or value > 255 for value in self.baseline_command): - raise ValueError("baseline command values must be in [0, 255]") - if not 0 <= self.zero_command_u8 <= 255: - raise ValueError("zero_command_u8 must be in [0, 255]") - if self.baseline_command[self.motor_index] != self.zero_command_u8: - raise ValueError( - "baseline command for the calibrated motor must equal " - "zero_command_u8" - ) - if self.repetitions != 3: - raise ValueError("CMC zero calibration requires exactly 3 repetitions") - if self.zero_capture_frames != 30: - raise ValueError("zero_capture_frames must be 30") - if not 0 <= self.trajectory_command_u8 <= 255: - raise ValueError("trajectory_command_u8 must be in [0, 255]") - if self.trajectory_command_u8 == self.zero_command_u8: - raise ValueError( - "trajectory_command_u8 must differ from zero_command_u8" - ) - available_span = abs( - self.zero_command_u8 - self.trajectory_command_u8 - ) - if self.trajectory_minimum_state_span_u8 > available_span: - raise ValueError( - "trajectory_minimum_state_span_u8 exceeds commanded span" - ) - if self.trajectory_minimum_frames < 12: - raise ValueError("trajectory_minimum_frames must be at least 12") - if self.trajectory_minimum_bins < 6: - raise ValueError("trajectory_minimum_bins must be at least 6") - if min( - self.debug_max_rate_hz, - self.debug_scale, - self.camera_alignment_max_angle_rad, - self.camera_alignment_max_vertical_offset_px, - self.camera_alignment_max_age_seconds, - self.trajectory_bin_size_u8, - self.trajectory_minimum_state_span_u8, - self.trajectory_minimum_radius_px, - self.trajectory_minimum_arc_rad, - self.trajectory_maximum_radial_rms_px, - self.trajectory_maximum_p95_radial_error_px, - self.trajectory_endpoint_settle_seconds, - self.trajectory_timeout_seconds, - self.settle_seconds, - self.move_timeout_seconds, - self.capture_timeout_seconds, - self.state_tolerance_u8, - self.minimum_decision_margin, - self.minimum_edge_pixels, - self.maximum_round_difference_rad, - self.maximum_travel_difference_rad, - self.minimum_travel_rad, - self.maximum_return_error_rad, - self.maximum_zero_radial_error_px, - self.maximum_static_position_rms_px, - ) <= 0.0: - raise ValueError("timing and quality thresholds must be positive") - if self.preflight_frames < 10: - raise ValueError("preflight_frames must be at least 10") - if not 0.0 < self.minimum_detection_rate <= 1.0: - raise ValueError("minimum_detection_rate must be in (0, 1]") - - def _state_callback(self, message: JointState) -> None: - if len(message.position) != 20: - return - if len(message.name) == 20 and set(message.name) == set(COMMAND_NAMES): - lookup = dict(zip(message.name, message.position)) - self.latest_state_u8 = tuple( - float(lookup[name]) for name in COMMAND_NAMES - ) - else: - self.latest_state_u8 = tuple( - float(item) for item in message.position - ) - - def _detections_callback( - self, message: AprilTagDetectionArray - ) -> None: - corners_by_role: dict[str, np.ndarray] = {} - quality_by_role: dict[str, dict[str, Any]] = {} - for detection in message.detections: - role = self.role_by_id.get(int(detection.id)) - if role is None: - continue - corners = np.asarray( - [ - [float(point.x), float(point.y)] - for point in detection.corners - ], - dtype=float, - ) - if corners.shape != (4, 2): - continue - edges = np.linalg.norm( - corners - np.roll(corners, -1, axis=0), - axis=1, - ) - quality_by_role[role] = { - "hamming": int(detection.hamming), - "decision_margin": float(detection.decision_margin), - "edge_pixels": float(np.mean(edges)), - } - corners_by_role[role] = corners - - good = set(corners_by_role) == {"t0", "t3"} and all( - self._quality_valid(quality_by_role[role]) - for role in ("t0", "t3") - ) - self.preflight_flags.append(good) - if self.state not in { - STATE_PREFLIGHT, - STATE_WAIT_START, - STATE_COMPLETE, - STATE_ABORTED, - }: - self.session_detection_flags.append(good) - self.latest_corners = corners_by_role - self.latest_quality = quality_by_role - if not good: - return - - t0_centre = np.mean(corners_by_role["t0"], axis=0) - t3_centre = np.mean(corners_by_role["t3"], axis=0) - frame = { - "t0_x_px": float(t0_centre[0]), - "t0_y_px": float(t0_centre[1]), - "t3_x_px": float(t3_centre[0]), - "t3_y_px": float(t3_centre[1]), - "state_u8": ( - float(self.latest_state_u8[self.motor_index]) - if len(self.latest_state_u8) == 20 - else float("nan") - ), - } - if self.latest_circle: - try: - live_summary = summarize_zero_frames([frame]) - self.latest_angles = measure_zero_from_circle( - self.latest_circle, live_summary - ) - except ValueError: - self.latest_angles = {} - - if self.state == STATE_SWEEPING: - if len(self.latest_state_u8) == 20: - self.trajectory_frames.append(frame) - return - if self.state != STATE_CAPTURING: - return - if not self._motor_at_target(): - return - self.capture_frames.append(frame) - while len(self.capture_frames) > self.required_frames: - self.capture_frames.popleft() - if len(self.capture_frames) < self.required_frames: - return - summary = summarize_zero_frames(list(self.capture_frames)) - if ( - summary["relative_position_rms_px"] - > self.maximum_static_position_rms_px - ): - self.reason = ( - f"{self.active_stage}_position_not_stable:" - f"{summary['relative_position_rms_px']:.2f}px" - ) - return - self._finish_capture(summary) - - def _quality_valid(self, quality: dict[str, Any]) -> bool: - return bool( - int(quality["hamming"]) <= self.maximum_hamming - and float(quality["decision_margin"]) - >= self.minimum_decision_margin - and float(quality["edge_pixels"]) >= self.minimum_edge_pixels - ) - - def _update_camera_alignment( - self, image: np.ndarray, now: float - ) -> None: - if not self.camera_alignment_enabled: - return - height = int(image.shape[0]) - reference_y = self.camera_alignment_reference_y_ratio * float( - height - 1 - ) - detected = detect_reference_alignment_line( - image, - reference_y_px=reference_y, - roi_y_min_ratio=self.camera_alignment_roi_y_min_ratio, - roi_y_max_ratio=self.camera_alignment_roi_y_max_ratio, - minimum_length_ratio=( - self.camera_alignment_minimum_line_length_ratio - ), - maximum_candidate_angle_rad=( - self.camera_alignment_max_candidate_angle_rad - ), - ) - self.last_camera_alignment_update = now - self.camera_alignment_measurements.append(detected) - valid_measurements = [ - measurement - for measurement in self.camera_alignment_measurements - if measurement is not None - ] - window_frames = len(self.camera_alignment_measurements) - detected_frames = len(valid_measurements) - detection_rate = ( - float(detected_frames) / float(window_frames) - if window_frames - else 0.0 - ) - if not valid_measurements: - self.latest_camera_alignment = { - "enabled": True, - "detected": False, - "sample_passed": False, - "window_frames": window_frames, - "detected_frames": 0, - "detection_rate": 0.0, - } - else: - latest_valid = valid_measurements[-1] - line_xyxy = np.median( - np.asarray( - [ - measurement["line_xyxy_px"] - for measurement in valid_measurements - ], - dtype=float, - ), - axis=0, - ) - angle_rad = float( - np.median( - [ - float(measurement["angle_rad"]) - for measurement in valid_measurements - ] - ) - ) - vertical_offset_px = float( - np.median( - [ - float(measurement["vertical_offset_px"]) - for measurement in valid_measurements - ] - ) - ) - sample_passed = bool( - abs(angle_rad) <= self.camera_alignment_max_angle_rad - and abs(vertical_offset_px) - <= self.camera_alignment_max_vertical_offset_px - ) - self.latest_camera_alignment = { - "enabled": True, - "detected": True, - "line_xyxy_px": [float(value) for value in line_xyxy], - "angle_rad": angle_rad, - "vertical_offset_px": vertical_offset_px, - "reference_y_px": float(latest_valid["reference_y_px"]), - "roi_y_px": list(latest_valid["roi_y_px"]), - "sample_passed": sample_passed, - "window_frames": window_frames, - "detected_frames": detected_frames, - "detection_rate": detection_rate, - } - self.latest_camera_alignment.update( - { - "required_frames": self.camera_alignment_required_frames, - "minimum_detection_rate": ( - self.camera_alignment_minimum_detection_rate - ), - "maximum_angle_rad": self.camera_alignment_max_angle_rad, - "maximum_vertical_offset_px": ( - self.camera_alignment_max_vertical_offset_px - ), - "ready": self._camera_alignment_ready(now), - } - ) - - def _camera_alignment_ready(self, now: float | None = None) -> bool: - if not self.camera_alignment_enabled: - return True - current_time = time.monotonic() if now is None else float(now) - return bool( - self.latest_camera_alignment.get("detected", False) - and current_time - self.last_camera_alignment_update - <= self.camera_alignment_max_age_seconds - and int(self.latest_camera_alignment.get("window_frames", 0)) - == self.camera_alignment_required_frames - and float(self.latest_camera_alignment.get("detection_rate", 0.0)) - >= self.camera_alignment_minimum_detection_rate - and bool( - self.latest_camera_alignment.get("sample_passed", False) - ) - ) - - def _start_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state not in {STATE_WAIT_START, STATE_PAUSED}: - response.success = False - response.message = f"current state is {self.state}" - return response - if not self.commands_enabled: - response.success = False - response.message = "commands_enabled is false" - return response - if len(self.latest_state_u8) != 20: - response.success = False - response.message = "hand state is not available" - return response - if self.command_publisher.get_subscription_count() < 1: - response.success = False - response.message = "hand SDK is not subscribed to command topic" - return response - if self.count_publishers(self.command_topic) > 1: - response.success = False - response.message = "another node is publishing hand commands" - return response - if self._preflight_rate() < self.minimum_detection_rate: - response.success = False - response.message = "T0/T3 detection rate is below threshold" - return response - self._reset_run() - self._begin_stage("zero_before") - response.success = True - response.message = f"{self.joint_name} calibration started" - return response - - def _abort_callback( - self, request: Trigger.Request, response: Trigger.Response - ) -> Trigger.Response: - del request - if self.state in {STATE_COMPLETE, STATE_ABORTED}: - response.success = False - response.message = f"current state is {self.state}" - return response - self.state = STATE_ABORTED - self.reason = "aborted_by_operator_no_motion_command_sent" - self.capture_frames.clear() - response.success = True - response.message = "aborted; no automatic hand motion was sent" - return response - - def _reset_run(self) -> None: - self.round_index = 0 - self.rounds = [] - self.current_round = {} - self.active_stage = None - self.active_target_u8 = None - self.capture_frames.clear() - self.trajectory_frames = [] - self.latest_trajectory_frames = [] - self.latest_circle = {} - self.latest_angles = {} - self.latest_travel = {} - self.session_detection_flags = [] - self.final_payload = None - self.final_report = {} - - def _begin_stage(self, stage: str) -> None: - allowed_stages = {"zero_before", "zero_after"} - if self.measure_travel: - allowed_stages.add("travel_endpoint") - if stage not in allowed_stages: - raise ValueError(f"unknown stage {stage}") - self.active_stage = stage - self.active_target_u8 = ( - self.trajectory_command_u8 - if stage == "travel_endpoint" - else self.zero_command_u8 - ) - self.required_frames = self.zero_capture_frames - self.capture_frames = deque(maxlen=self.required_frames) - self.stage_started_at = time.monotonic() - self.target_reached_at = None - self.capture_started_at = 0.0 - self.state = STATE_MOVING - self.reason = ( - f"round_{self.round_index + 1}:{stage}:" - f"target={self.active_target_u8}" - ) - self._publish_hand_command( - build_command( - self.motor_index, - self.active_target_u8, - self.baseline_command, - ) - ) - - def _begin_sweep(self, stage: str) -> None: - if stage not in {"sweep_out", "sweep_return"}: - raise ValueError(f"unknown sweep stage {stage}") - if stage == "sweep_out": - self.trajectory_frames = [] - target = self.trajectory_command_u8 - else: - target = self.zero_command_u8 - self.active_stage = stage - self.active_target_u8 = target - self.required_frames = self.trajectory_minimum_frames - self.stage_started_at = time.monotonic() - self.target_reached_at = None - self.state = STATE_SWEEPING - self.reason = ( - f"round_{self.round_index + 1}:{stage}:" - f"target={target}:collecting_trajectory" - ) - self._publish_hand_command( - build_command(self.motor_index, target, self.baseline_command) - ) - - def _fit_trajectory( - self, observations: list[dict[str, float]] - ) -> dict[str, Any]: - return fit_image_circle_trajectory( - observations, - bin_size_u8=self.trajectory_bin_size_u8, - minimum_frames=self.trajectory_minimum_frames, - minimum_bins=self.trajectory_minimum_bins, - minimum_state_span_u8=self.trajectory_minimum_state_span_u8, - minimum_radius_px=self.trajectory_minimum_radius_px, - minimum_arc_rad=self.trajectory_minimum_arc_rad, - maximum_radial_rms_px=self.trajectory_maximum_radial_rms_px, - maximum_p95_radial_error_px=( - self.trajectory_maximum_p95_radial_error_px - ), - ) - - def _publish_hand_command(self, values: list[int]) -> None: - message = JointState() - message.header.stamp = self.get_clock().now().to_msg() - message.name = list(COMMAND_NAMES) - message.position = [float(value) for value in values] - self.command_publisher.publish(message) - - def _motor_at_target(self) -> bool: - return bool( - self.active_target_u8 is not None - and len(self.latest_state_u8) == 20 - and abs( - float(self.latest_state_u8[self.motor_index]) - - float(self.active_target_u8) - ) - <= self.state_tolerance_u8 - ) - - def _timer_callback(self) -> None: - now = time.monotonic() - if self.state == STATE_PREFLIGHT: - if ( - len(self.preflight_flags) == self.preflight_frames - and len(self.latest_state_u8) == 20 - and self._preflight_rate() >= self.minimum_detection_rate - ): - self.state = STATE_WAIT_START - self.reason = "call_start_service" - elif ( - len(self.preflight_flags) == self.preflight_frames - and self._preflight_rate() < self.minimum_detection_rate - ): - self.reason = ( - "T0/T3 detection rate too low:" - f"{self._preflight_rate():.3f}" - ) - return - if self.state == STATE_MOVING: - if now - self.stage_started_at > self.move_timeout_seconds: - self._pause( - f"move_timeout:target={self.active_target_u8}:" - f"actual={self._motor_value_text()}" - ) - return - if not self._motor_at_target(): - self.target_reached_at = None - return - if self.target_reached_at is None: - self.target_reached_at = now - self.reason = ( - f"{self.active_stage}:settling:" - f"{self.settle_seconds:.1f}s" - ) - return - if now - self.target_reached_at >= self.settle_seconds: - self.state = STATE_CAPTURING - self.capture_started_at = now - self.capture_frames.clear() - self.reason = ( - f"{self.active_stage}:collecting:" - f"0/{self.required_frames}" - ) - return - if self.state == STATE_SWEEPING: - self.reason = ( - f"{self.active_stage}:trajectory_frames=" - f"{len(self.trajectory_frames)}:" - f"actual={self._motor_value_text()}:" - f"target={self.active_target_u8}" - ) - if now - self.stage_started_at > self.trajectory_timeout_seconds: - self._pause( - f"trajectory_timeout:{self.active_stage}:" - f"target={self.active_target_u8}:" - f"actual={self._motor_value_text()}:" - f"frames={len(self.trajectory_frames)}" - ) - return - if not self._motor_at_target(): - self.target_reached_at = None - return - if self.target_reached_at is None: - self.target_reached_at = now - return - if ( - now - self.target_reached_at - >= self.trajectory_endpoint_settle_seconds - ): - self._finish_sweep() - return - if self.state == STATE_CAPTURING: - self.reason = ( - f"{self.active_stage}:collecting:" - f"{len(self.capture_frames)}/{self.required_frames}" - ) - if not self._motor_at_target(): - self._pause( - f"motor_left_target_during_capture:" - f"target={self.active_target_u8}:" - f"actual={self._motor_value_text()}" - ) - return - if now - self.capture_started_at > self.capture_timeout_seconds: - self._pause( - f"capture_timeout:{self.active_stage}:" - f"{len(self.capture_frames)}/{self.required_frames}" - ) - - def _finish_capture(self, summary: dict[str, float]) -> None: - if self.active_stage is None: - return - stage = self.active_stage - self.current_round[stage] = summary - self.capture_frames.clear() - if stage == "zero_before": - self._begin_sweep("sweep_out") - return - if stage == "travel_endpoint": - self._begin_sweep("sweep_return") - return - - self.rounds.append(dict(self.current_round)) - self.current_round = {} - self.round_index += 1 - if self.round_index < self.repetitions: - self._begin_stage("zero_before") - return - self._complete_run() - - def _finish_sweep(self) -> None: - stage = self.active_stage - if stage == "sweep_out": - if self.measure_travel: - self._begin_stage("travel_endpoint") - else: - self._begin_sweep("sweep_return") - return - if stage != "sweep_return": - self._pause(f"unexpected_sweep_stage:{stage}") - return - try: - circle = self._fit_trajectory(self.trajectory_frames) - except ValueError as error: - self._pause(str(error)) - return - self.current_round["trajectory_circle"] = circle - self.current_round["trajectory_frames"] = list(self.trajectory_frames) - self.latest_trajectory_frames = list(self.trajectory_frames) - self.latest_circle = circle - self._begin_stage("zero_after") - - def _complete_run(self) -> None: - detection_rate = ( - float(np.mean(self.session_detection_flags)) - if self.session_detection_flags - else 0.0 - ) - all_trajectory_frames = [ - frame - for round_value in self.rounds - for frame in round_value["trajectory_frames"] - ] - try: - circle = self._fit_trajectory(all_trajectory_frames) - measured_rounds = [ - { - "zero_before": measure_zero_from_circle( - circle, round_value["zero_before"] - ), - **( - { - "travel_endpoint": measure_zero_from_circle( - circle, round_value["travel_endpoint"] - ) - } - if self.measure_travel - else {} - ), - "zero_after": measure_zero_from_circle( - circle, round_value["zero_after"] - ), - } - for round_value in self.rounds - ] - except ValueError as error: - self._pause(f"final_trajectory_fit_failed:{error}") - return - if self.measure_travel: - payload, report = build_trajectory_zero_travel_payload( - serial_number=self.serial_number, - joint_name=self.joint_name, - zero_command_u8=self.zero_command_u8, - travel_endpoint_command_u8=self.trajectory_command_u8, - rounds=measured_rounds, - trajectory_quality=circle, - maximum_round_difference_rad=( - self.maximum_round_difference_rad - ), - maximum_travel_difference_rad=( - self.maximum_travel_difference_rad - ), - minimum_travel_rad=self.minimum_travel_rad, - maximum_return_error_rad=self.maximum_return_error_rad, - maximum_zero_radial_error_px=( - self.maximum_zero_radial_error_px - ), - detection_rate=detection_rate, - minimum_detection_rate=self.minimum_detection_rate, - ) - validate_zero_travel_payload(payload) - else: - payload, report = build_trajectory_zero_angle_payload( - serial_number=self.serial_number, - rounds=measured_rounds, - trajectory_quality=circle, - maximum_round_difference_rad=( - self.maximum_round_difference_rad - ), - maximum_return_error_rad=self.maximum_return_error_rad, - maximum_zero_radial_error_px=( - self.maximum_zero_radial_error_px - ), - detection_rate=detection_rate, - minimum_detection_rate=self.minimum_detection_rate, - ) - validate_zero_angle_payload(payload) - atomic_write_json(self.result_path, payload) - self.final_payload = payload - self.final_report = report - self.latest_circle = circle - self.latest_trajectory_frames = all_trajectory_frames - self.latest_angles = { - "table_rad": payload["zero_angles"]["table_projected_zero_rad"], - } - self.latest_travel = ( - dict(payload["travel"]) if self.measure_travel else {} - ) - self.state = STATE_COMPLETE - self.reason = ( - "result_passed" - if payload["quality"]["passed"] - else "result_written_but_quality_failed" - ) - self.active_stage = None - self.active_target_u8 = self.zero_command_u8 - self.get_logger().info( - f"{self.joint_name} result written: {self.result_path}; " - f"passed={payload['quality']['passed']}" - ) - - def _pause(self, reason: str) -> None: - self.state = STATE_PAUSED - self.reason = reason - self.capture_frames.clear() - self.get_logger().error( - f"{self.joint_name} calibration paused: {reason}" - ) - - def _preflight_rate(self) -> float: - return ( - float(np.mean(self.preflight_flags)) - if self.preflight_flags - else 0.0 - ) - - def _motor_value_text(self) -> str: - if len(self.latest_state_u8) != 20: - return "unavailable" - return f"{self.latest_state_u8[self.motor_index]:.1f}" - - def _image_callback(self, message: Image) -> None: - if ( - not self.camera_alignment_enabled - and ( - self.debug_publisher is None - or self.debug_publisher.get_subscription_count() < 1 - ) - ): - return - now = time.monotonic() - if now - self.last_debug_publish < 1.0 / self.debug_max_rate_hz: - return - self.last_debug_publish = now - try: - image = self.bridge.imgmsg_to_cv2( - message, desired_encoding="bgr8" - ) - except Exception as error: - self.get_logger().warning(f"debug image conversion failed: {error}") - return - self._update_camera_alignment(image, now) - if ( - self.debug_publisher is None - or self.debug_publisher.get_subscription_count() < 1 - ): - return - if self.debug_scale != 1.0: - image = cv2.resize( - image, - None, - fx=self.debug_scale, - fy=self.debug_scale, - interpolation=cv2.INTER_AREA, - ) - - height, width = image.shape[:2] - reference_y = int( - round(self.camera_alignment_reference_y_ratio * (height - 1)) - ) - cv2.line( - image, - (20, reference_y), - (max(20, width - 20), reference_y), - (0, 0, 255), - 5, - ) - cv2.putText( - image, - "RED target / image +x", - (25, max(25, reference_y - 8)), - cv2.FONT_HERSHEY_SIMPLEX, - 0.55, - (0, 0, 255), - 2, - ) - if self.latest_camera_alignment.get("detected", False): - line = np.asarray( - self.latest_camera_alignment["line_xyxy_px"], dtype=float - ).reshape(2, 2) - line *= self.debug_scale - left = tuple(np.rint(line[0]).astype(int)) - right = tuple(np.rint(line[1]).astype(int)) - clipped, left, right = cv2.clipLine( - (0, 0, width, height), left, right - ) - if clipped: - cv2.line(image, left, right, (255, 0, 0), 2) - label_x = max(20, min(width - 260, left[0] + 10)) - label_y = max(25, min(height - 15, left[1] - 8)) - cv2.putText( - image, - "BLUE detected reference", - (label_x, label_y), - cv2.FONT_HERSHEY_SIMPLEX, - 0.55, - (255, 0, 0), - 2, - ) - colors = {"t0": (0, 210, 0), "t3": (0, 170, 255)} - for role in ("t0", "t3"): - corners = self.latest_corners.get(role) - if corners is None: - continue - points = np.rint(corners * self.debug_scale).astype(np.int32) - cv2.polylines(image, [points], True, colors[role], 2) - centre = np.mean(points, axis=0) - start = tuple(np.rint(centre).astype(int)) - cv2.putText( - image, - role.upper(), - (start[0] + 5, start[1] - 8), - cv2.FONT_HERSHEY_SIMPLEX, - 0.65, - colors[role], - 2, - ) - - if "t0" in self.latest_corners: - anchor = np.mean(self.latest_corners["t0"], axis=0) - trajectory = ( - self.trajectory_frames - if self.state == STATE_SWEEPING - else self.latest_trajectory_frames - ) - if trajectory: - stride = max(1, len(trajectory) // 300) - for frame in trajectory[::stride]: - relative = np.asarray( - [ - frame["t3_x_px"] - frame["t0_x_px"], - frame["t3_y_px"] - frame["t0_y_px"], - ], - dtype=float, - ) - point = tuple( - np.rint( - (anchor + relative) * self.debug_scale - ).astype(int) - ) - cv2.circle(image, point, 2, (255, 180, 0), -1) - if self.latest_circle: - centre_relative = np.asarray( - self.latest_circle["centre_relative_xy_px"], dtype=float - ) - centre = (anchor + centre_relative) * self.debug_scale - centre_point = tuple(np.rint(centre).astype(int)) - radius = max( - 1, - int( - round( - float(self.latest_circle["radius_px"]) - * self.debug_scale - ) - ), - ) - cv2.circle(image, centre_point, radius, (255, 0, 255), 2) - cv2.drawMarker( - image, - centre_point, - (255, 0, 255), - cv2.MARKER_CROSS, - 18, - 2, - ) - cv2.putText( - image, - "CMC circle centre", - (centre_point[0] + 8, centre_point[1] - 8), - cv2.FONT_HERSHEY_SIMPLEX, - 0.52, - (255, 0, 255), - 2, - ) - cv2.line( - image, - centre_point, - (centre_point[0] + 85, centre_point[1]), - (0, 0, 255), - 2, - ) - if "t3" in self.latest_corners: - t3_centre = ( - np.mean(self.latest_corners["t3"], axis=0) - * self.debug_scale - ) - cv2.line( - image, - centre_point, - tuple(np.rint(t3_centre).astype(int)), - (255, 0, 255), - 3, - ) - - cv2.putText( - image, - f"{self.state} round {min(self.round_index + 1, 3)}/3", - (20, 30), - cv2.FONT_HERSHEY_SIMPLEX, - 0.65, - (0, 255, 0) - if self.state not in {STATE_PAUSED, STATE_ABORTED} - else (0, 0, 255), - 2, - ) - angle_text_y = 58 - if self.camera_alignment_enabled: - alignment_ready = self._camera_alignment_ready(now) - if self.latest_camera_alignment.get("detected", False): - angle_deg = math.degrees( - float(self.latest_camera_alignment["angle_rad"]) - ) - offset_px = float( - self.latest_camera_alignment["vertical_offset_px"] - ) - alignment_text = ( - f"red-blue angle {angle_deg:+.2f} deg " - f"dy {offset_px:+.1f}px " - f"hit {self.latest_camera_alignment.get('detected_frames', 0)}/" - f"{self.camera_alignment_required_frames} " - f"{'ALIGNED' if alignment_ready else 'ADJUST'}" - ) - else: - alignment_text = "BLUE reference line not detected" - cv2.putText( - image, - alignment_text, - (20, angle_text_y), - cv2.FONT_HERSHEY_SIMPLEX, - 0.60, - (0, 255, 0) if alignment_ready else (0, 165, 255), - 2, - ) - if self.latest_angles: - cv2.putText( - image, - "table zero: " - f"{math.degrees(self.latest_angles['table_rad']):.2f} deg", - (20, 86 if self.camera_alignment_enabled else 58), - cv2.FONT_HERSHEY_SIMPLEX, - 0.62, - (0, 255, 255), - 2, - ) - elif self.state in { - STATE_MOVING, - STATE_CAPTURING, - STATE_SWEEPING, - }: - cv2.putText( - image, - "zero angle: waiting for trajectory circle", - (20, 86 if self.camera_alignment_enabled else 58), - cv2.FONT_HERSHEY_SIMPLEX, - 0.58, - (0, 255, 255), - 2, - ) - output = self.bridge.cv2_to_imgmsg(image, encoding="bgr8") - output.header = message.header - self.debug_publisher.publish(output) - - def _publish_status(self) -> None: - tag_status: dict[str, dict[str, Any]] = {} - for role in ("t0", "t3"): - quality = self.latest_quality.get(role) - if quality is None: - tag_status[role] = {"valid": False, "reason": "未检测到"} - else: - tag_status[role] = { - **quality, - "valid": self._quality_valid(quality), - } - camera_alignment = { - "enabled": self.camera_alignment_enabled, - **self.latest_camera_alignment, - "ready": self._camera_alignment_ready(), - } - payload = { - "state": self.state, - "state_zh": STATE_ZH[self.state], - "reason": self.reason, - "measurement_method": ( - "t3_center_to_circle_centre_image_trajectory" - ), - "joint": self.joint_name, - "motor_index": self.motor_index, - "zero_command_u8": self.zero_command_u8, - "measure_travel": self.measure_travel, - "cycle": min(self.round_index + 1, self.repetitions), - "cycles_total": self.repetitions, - "stage": self.active_stage, - "stage_zh": ( - None - if self.active_stage is None - else self._stage_zh(self.active_stage) - ), - "active_command_u8": self.active_target_u8, - "actual_command_u8": ( - None - if len(self.latest_state_u8) != 20 - else self.latest_state_u8[self.motor_index] - ), - "capture_frames_seen": len(self.capture_frames), - "capture_frames_required": self.required_frames, - "trajectory_frames_seen": len(self.trajectory_frames), - "trajectory_command_u8": self.trajectory_command_u8, - "trajectory_circle": self.latest_circle, - "preflight_detection_rate": self._preflight_rate(), - "tag_quality": tag_status, - "camera_alignment": camera_alignment, - "latest_angles_rad": self.latest_angles, - "latest_travel_rad": self.latest_travel, - "result_path": str(self.result_path), - "result": self.final_payload, - "quality_report": self.final_report, - } - message = String() - message.data = json.dumps(payload, ensure_ascii=False) - self.status_publisher.publish(message) - text = String() - text.data = self._status_text(payload) - self.status_text_publisher.publish(text) - - def _stage_zh(self, stage: str) -> str: - if stage == "zero_before": - return f"{self.zero_command_u8}零位(运动前)" - if stage == "sweep_out": - return ( - f"圆心轨迹({self.zero_command_u8}到" - f"{self.trajectory_command_u8})" - ) - if stage == "travel_endpoint": - return f"{self.trajectory_command_u8}行程端点" - if stage == "sweep_return": - return f"圆心轨迹(返回{self.zero_command_u8})" - if stage == "zero_after": - return f"{self.zero_command_u8}零位(返回后)" - return STAGE_ZH.get(stage, stage) - - def _status_text(self, payload: dict[str, Any]) -> str: - reason_zh = self._reason_zh() - if self.state == STATE_CAPTURING: - frame_text = ( - f"帧数:{len(self.capture_frames)}/{self.required_frames}" - ) - elif self.state == STATE_SWEEPING: - frame_text = ( - f"轨迹帧数:{len(self.trajectory_frames)}/" - f"至少{self.trajectory_minimum_frames}" - ) - else: - frame_text = "帧数:当前未采集" - lines = [ - f"状态:{payload['state_zh']}", - f"原因:{reason_zh}", - ( - f"关节:{self.joint_name},电机索引" - f"{self.motor_index}" - ), - ( - f"进度:第{payload['cycle']}/{self.repetitions}轮," - f"{payload['stage_zh'] or '无活动阶段'}" - ), - frame_text, - ] - if reason_zh != self.reason: - lines.append(f"诊断代码:{self.reason}") - for role, label in (("t0", "掌心T0(ID 0)"), ("t3", "根部T3(ID 1)")): - status = payload["tag_quality"][role] - if "edge_pixels" not in status: - lines.append(f"标签:{label} 未检测到") - continue - lines.append( - f"标签:{label} " - f"{'正常' if status['valid'] else '不合格'}," - f"边长{status['edge_pixels']:.1f}px," - f"置信度{status['decision_margin']:.1f}," - f"hamming={status['hamming']}" - ) - alignment = payload["camera_alignment"] - if alignment["enabled"]: - if alignment.get("detected", False): - angle_deg = math.degrees(float(alignment["angle_rad"])) - offset_px = float(alignment["vertical_offset_px"]) - lines.extend( - [ - "相机二维对齐:", - ( - f"- 红蓝线夹角 {angle_deg:+.3f}°,要求 " - "±" - f"{math.degrees(self.camera_alignment_max_angle_rad):.3f}°" - ), - ( - f"- 垂直偏差 {offset_px:+.1f}px,要求 " - f"±{self.camera_alignment_max_vertical_offset_px:.1f}px" - ), - ( - "- 状态 " - f"{'已对齐' if alignment['ready'] else '请调整相机'}" - "," - f"窗口 {alignment.get('window_frames', 0)}/" - f"{self.camera_alignment_required_frames}," - f"有效帧 {alignment.get('detected_frames', 0)}/" - f"{self.camera_alignment_required_frames}" - ), - ] - ) - else: - lines.append( - "相机二维对齐:未识别到下方物理参考直线" - ) - if self.latest_angles: - lines.extend( - [ - "内向径向零位角:", - ( - "- 桌面投影零位 " - f"{self.latest_angles['table_rad']:.6f} rad " - f"({math.degrees(self.latest_angles['table_rad']):.2f}°)" - ), - ] - ) - elif self.state in { - STATE_MOVING, - STATE_CAPTURING, - STATE_SWEEPING, - }: - lines.append("当前角度:等待完成T3中心轨迹圆拟合") - if self.latest_circle: - lines.extend( - [ - "轨迹圆质量:", - ( - f"- 半径 {self.latest_circle['radius_px']:.2f}px," - f"圆弧 {math.degrees(self.latest_circle['arc_rad']):.2f}°" - ), - ( - f"- 径向RMS {self.latest_circle['radial_rms_px']:.2f}px," - f"P95 {self.latest_circle['radial_p95_px']:.2f}px" - ), - ] - ) - if self.latest_travel: - lines.extend( - [ - "角行程:", - ( - f"- 有符号 {self.latest_travel['signed_rad']:.6f} rad " - f"({math.degrees(self.latest_travel['signed_rad']):.2f}°)" - ), - ( - f"- 行程大小 {self.latest_travel['range_rad']:.6f} rad " - f"({math.degrees(self.latest_travel['range_rad']):.2f}°)" - ), - ] - ) - if self.state == STATE_WAIT_START: - lines.append( - f"下一步:调用 /{self.get_name()}/start 开始标定" - ) - elif self.state == STATE_PAUSED: - lines.append("下一步:排除问题后再次调用 start,从第一轮重做") - elif self.state == STATE_COMPLETE: - lines.append(f"结果文件:{self.result_path}") - return "\n".join(lines) - - def _reason_zh(self) -> str: - reason = str(self.reason) - exact = { - "waiting_for_t0_t3_and_hand_state": "等待T0、T3和机械手状态", - "call_start_service": "预检通过,可以调用start开始", - "result_passed": "轨迹圆与三轮回零质量均通过", - "result_written_but_quality_failed": ( - "结果已保存,但三轮一致性或回零质量未通过" - ), - "aborted_by_operator_no_motion_command_sent": "已由用户终止", - } - if reason in exact: - return exact[reason] - prefixes = ( - ("trajectory_frames_too_few", "轨迹有效帧不足"), - ("trajectory_valid_frames_too_few", "轨迹有效数据不足"), - ("trajectory_state_span_too_small", "电机轨迹跨度不足"), - ("trajectory_bins_too_few", "轨迹覆盖的电机区间不足"), - ("trajectory_circle_quality_failed", "轨迹圆拟合质量不合格"), - ("trajectory_timeout", "轨迹运动或采集超时"), - ("final_trajectory_fit_failed", "三轮合并轨迹圆拟合失败"), - ("move_timeout", "机械手未在规定时间到达目标"), - ("capture_timeout", "静态零位帧采集超时"), - ("motor_left_target_during_capture", "静态采集时电机离开目标"), - ("unexpected_sweep_stage", "内部轨迹阶段异常"), - ) - for prefix, text in prefixes: - if reason.startswith(prefix): - return text - if "_position_not_stable" in reason: - return "T0与T3相对位置仍在抖动" - if "_not_stable" in reason: - return "标签方向仍在抖动" - if reason.startswith("round_"): - return "正在执行本轮零位或圆心轨迹阶段" - return reason - - -def main(args: list[str] | None = None) -> None: - rclpy.init(args=args) - node: G20ThumbCmcTrajectoryNode | None = None - try: - node = G20ThumbCmcTrajectoryNode() - rclpy.spin(node) - except KeyboardInterrupt: - pass - finally: - if node is not None: - node.destroy_node() - if rclpy.ok(): - rclpy.shutdown() diff --git a/src/linkerhand_calibration/setup.py b/src/linkerhand_calibration/setup.py index c1a9be1..aa41fa0 100644 --- a/src/linkerhand_calibration/setup.py +++ b/src/linkerhand_calibration/setup.py @@ -41,12 +41,8 @@ setup( ["resource/" + package_name], ), ("share/" + package_name, ["package.xml", "README.md"]), - ( - "share/" + package_name + "/config", - glob("config/*.yaml") + glob("config/*.xml"), - ), ("share/" + package_name + "/launch", glob("launch/*.launch.py")), - ] + package_data_tree("urdf"), + ] + package_data_tree("config") + package_data_tree("urdf"), install_requires=["setuptools", "numpy", "scipy", "PyYAML"], tests_require=["pytest"], zip_safe=True, @@ -61,18 +57,11 @@ setup( "hikrobot_camera_node = " "linkerhand_calibration.hikrobot_camera:main" ), - "calibration_node = linkerhand_calibration.node:main", - ( - "cmc_pitch_zero_node = " - "linkerhand_calibration.zero_node:main" - ), - ( - "cmc_roll_calibration_node = " - "linkerhand_calibration.zero_node:main" - ), + "calibration_node = linkerhand_calibration.runtime.ros.entrypoint:main", + "o12_sdk_bridge = linkerhand_calibration.runtime.adapters.o12_bridge:main", ( "three_camera_calibration_node = " - "linkerhand_calibration.runtime.nodes.calibration:main" + "linkerhand_calibration.runtime.ros.entrypoint:main" ), ( "three_camera_extrinsics_node = " @@ -84,7 +73,7 @@ setup( ), ( "validate_g20_goldens = " - "linkerhand_calibration.models.g20.golden_regression:main" + "linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.golden_regression:main" ), ( "camera_alignment_view = " diff --git a/src/linkerhand_calibration/test/engine_capture_fixture.py b/src/linkerhand_calibration/test/engine_capture_fixture.py new file mode 100644 index 0000000..5d4de0e --- /dev/null +++ b/src/linkerhand_calibration/test/engine_capture_fixture.py @@ -0,0 +1,108 @@ +"""Ideal camera/SDK transport for the REAL capture and session executor. + +Only the sensor's pose estimator is idealized; the state machine, observation +joins, unique-image quality, four-cycle selection and finalizer are production +code. Real pinhole/IPPE behavior is independently tested in observation_capture. +""" + +from collections import defaultdict +from types import SimpleNamespace + +import numpy as np +from scipy.spatial.transform import Rotation + +from linkerhand_calibration.core.geometry.extrinsics import transform_matrix +from linkerhand_calibration.core.geometry.pnp import SquareTagPose +from linkerhand_calibration.runtime.capture import CaptureFrame, ObservationCapture +from linkerhand_calibration.runtime.execution import SessionExecution +from linkerhand_calibration.runtime.reference_lock import ReferenceLock +from linkerhand_calibration.runtime.session import CalibrationPhase as Phase + + +def collect_with_engine(profile, observations): + # One fixed camera geometry, independent of the task/model names. + normal = np.ones(3)/np.sqrt(3) + x = np.cross([0, 1, 0], normal); x /= np.linalg.norm(x) + rotation = np.column_stack((x, np.cross(normal, x), normal)) + camera = np.eye(4); camera[:3, :3] = rotation; camera[:3, 3] = [0, 0, -.5] + camera_inverse = np.linalg.inv(camera) + fixed_roles = {view.name: next(t.role for t in view.tags if t.fixed_reference) for view in profile.vision.views} + fixed_poses = {} + frame_groups = defaultdict(lambda: defaultdict(list)) + for row in observations: + name = row.get("joint", row.get("observation_joint")) + spec_name = name if "joint" in row else profile.measurement.cross_view_sources[name] + spec = profile.measurement.measurements[spec_name] + if spec.parent_role == fixed_roles[spec.view]: + fixed_poses[spec.view] = row["parent_pose_common"] + key = (row["task_name"], row["cycle"], row["direction"], row.get("sample_phase", "sweep"), row.get("steady_index")) + frame_groups[key][row["sample_id"]].append((row, spec)) + poses = {view: {} for view in profile.vision.view_names} + class IdealPoseSensor: + maximum_reprojection_error_px = 1.5 + def __init__(self, view): self.view = view + def reset(self): pass + def estimate(self, role, corners, **kwargs): return poses[self.view][role], "" + def camera_pose(payload): + value = camera_inverse @ transform_matrix(payload["translation_xyz_m"], payload["quaternion_xyzw"]) + return SquareTagPose(tuple(Rotation.from_matrix(value[:3, :3]).as_quat()), tuple(value[:3, 3]), 0.) + 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 _: camera), + trackers={view: IdealPoseSensor(view) for view in profile.vision.view_names}) + execution = SessionExecution(profile) + execution.session.device_ready() + execution.session.start() + lock.begin_session() + current = profile.command.baseline_values + retained, stamp, phases = [], 0, set() + square = np.array([[-20, 20], [20, 20], [20, -20], [-20, -20]]) + while execution.session.phase != Phase.FIT: + phase = execution.session.phase + phases.add(phase) + if phase == Phase.REFERENCE_LOCKING: + lock.start_locking() + for view, role in fixed_roles.items(): + poses[view] = {role: camera_pose(fixed_poses[view])} + for _ in range(10): + stamp += 1 + capture.consume(CaptureFrame(view, stamp, np.eye(3), {role: square}, current, current)) + assert lock.locked + execution.session.reference_locked() + continue + if phase == Phase.EVALUATE: + quality = execution.evaluate(retained) + assert quality.passed, quality.failures + continue + motion = execution.motion(current) + assert motion is not None, phase + if motion.recording: + key = (motion.task_key, motion.cycle, motion.direction, motion.phase, motion.steady_index) + for rows in frame_groups[key].values(): + first = rows[0][0]; view = first["view"] + poses[view] = {} + centers = {} + for row, spec in rows: + poses[view][spec.parent_role] = camera_pose(row["parent_pose_common"]) + poses[view][spec.child_role] = camera_pose(row["child_pose_common"]) + centers.setdefault(spec.parent_role, np.zeros(2)) + centers[spec.child_role] = centers[spec.parent_role] + row["image_relative_xy_px"] + # Fixed references are physically stationary in this fixture; + # the image-circle test signal belongs only to moving Tags. + corners = {role: square+center for role, center in centers.items()} + if fixed_roles[view] in corners: + corners[fixed_roles[view]] = square + stamp += 1 + unit = profile.command.unit + frame = CaptureFrame(view, stamp, np.eye(3), corners, + tuple(first[f"state_{unit}"]), tuple(first[f"command_vector_{unit}"])) + captured, moved = capture.consume(frame, motion) + assert moved is None + # The IPPE candidate branch is covered by real projected-corner + # tests, not these deliberately ideal pose measurements. + retained.extend(row for row in captured if row["kind"] != "pnp_candidate_frame") + current = motion.target + execution.motion_complete() + assert len(execution.completed_units) == 8*len(profile.motion.tasks) + assert Phase.RETURN_BASELINE in phases + return execution, retained diff --git a/src/linkerhand_calibration/test/o12_artifact_fixture.py b/src/linkerhand_calibration/test/o12_artifact_fixture.py new file mode 100644 index 0000000..911d986 --- /dev/null +++ b/src/linkerhand_calibration/test/o12_artifact_fixture.py @@ -0,0 +1,32 @@ +"""Synthetic serialization envelope, NOT a replacement/review of real CAD. + +The older fitting fixture intentionally has travel beyond vendor CAD limits. +Those observations may test fitting, but cannot certify that real source file. +Use a clearly virtual envelope for coordinate/serializer unit tests; separate +tests require the immutable vendor CAD to reject out-of-range corrections. +""" +import xml.etree.ElementTree as ET +from pathlib import Path + + +def virtual_o12_source(directory: Path, original: Path) -> Path: + tree = ET.parse(original) + root = tree.getroot() + root.set("name", "virtual_o12_serialization_fixture_not_hardware") + for node in root.findall("joint"): + if node.get("type") != "revolute": + continue + limit = node.find("limit") + if node.find("mimic") is None: + limit.set("lower", "-1") + limit.set("upper", "3") + else: + limit.set("lower", "-4") + limit.set("upper", "4") + for mesh in root.iter("mesh"): + filename = mesh.get("filename") + if not filename.startswith(("package://", "file://")): + mesh.set("filename", str((original.parent / filename).resolve())) + destination = directory / "virtual_o12_source.urdf" + tree.write(destination) + return destination diff --git a/src/linkerhand_calibration/test/runtime_host_fixture.py b/src/linkerhand_calibration/test/runtime_host_fixture.py new file mode 100644 index 0000000..aeecac6 --- /dev/null +++ b/src/linkerhand_calibration/test/runtime_host_fixture.py @@ -0,0 +1,50 @@ +"""A real coordinator with in-memory ports; never creates a ROS node or SDK device.""" + +from pathlib import Path +from types import SimpleNamespace + +import yaml + +from linkerhand_calibration.product import load_product_config +from linkerhand_calibration.runtime.adapters.ros_binding import SdkBindingPorts, bind_ros_sdk +from linkerhand_calibration.runtime.coordinator import CalibrationCoordinator +from linkerhand_calibration.runtime.inputs import RuntimePorts +from linkerhand_calibration.runtime.ros.parameters import load_runtime_parameters, parameter_defaults +from linkerhand_calibration.runtime.runner_support import protected_inputs + + +def coordinator_fixture(tmp_path, *, model="o6", finalization=None): + 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 + declared = next(iter(yaml.safe_load(config.calibration_config.read_text()).values()))["ros__parameters"] + values = {**parameter_defaults(profile), **declared, + "serial_number": "OFFLINE_COORDINATOR", "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()}} + parameters = load_runtime_parameters(profile, values.__getitem__) + clock = SimpleNamespace(now=10., publishers=1, positions=[], settings=[], health_receiver=None) + ports = RuntimePorts(lambda: int(clock.now*1e9), lambda: clock.now, lambda: clock.publishers, + clock.positions.append, clock.settings.append) + + def subscribe_health(callback): + clock.health_receiver = callback + callback('{"position_mode": true, "active_faults": []}') + + def factory(publish, set_speed, fresh): + return bind_ros_sdk(profile, SdkBindingPorts(fresh, ports.monotonic, subscribe_health), + publish=publish, set_speed=set_speed) + + return CalibrationCoordinator(profile, parameters, ports, factory, finalization=finalization), clock + + +def ready(host, clock): + import numpy as np + host.receive_feedback(host.profile.command.names, host.profile.command.baseline_values, host.ports.clock_ns()) + 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() diff --git a/src/linkerhand_calibration/test/test_architecture.py b/src/linkerhand_calibration/test/test_architecture.py index 56bd721..8375f63 100644 --- a/src/linkerhand_calibration/test/test_architecture.py +++ b/src/linkerhand_calibration/test/test_architecture.py @@ -1,5 +1,6 @@ from pathlib import Path from types import SimpleNamespace +import ast from linkerhand_calibration.compat import product_profile_key from linkerhand_calibration.core import ( @@ -11,7 +12,6 @@ from linkerhand_calibration.core import ( MotionPolicy, ProfileKey, QualityPolicy, - SampleRecord, ScopePolicy, TagSpec, TaskSpec, @@ -20,23 +20,19 @@ from linkerhand_calibration.core import ( ZeroSolvePolicy, validate_profile, ) -from linkerhand_calibration.core.solver import ( - SessionSolution, - TaskEvaluation, -) from linkerhand_calibration.core.urdf import UrdfCorrectionPlan from linkerhand_calibration.extrinsics import ( validate_camera_extrinsics_payload, ) -from linkerhand_calibration.models import ( +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 SessionController, SessionState -from linkerhand_calibration.runtime.nodes import calibration as calibration_node +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" @@ -64,9 +60,9 @@ def _small_profile() -> CalibrationProfile: views=( ViewSpec( "oblique", - (TagSpec("base_a", 40, fixed_reference=True),), + (TagSpec("base_a", 40, fixed_reference=True), TagSpec("moving_a", 42)), ), - ViewSpec("wrist", (TagSpec("moving_b", 41),)), + ViewSpec("wrist", (TagSpec("base_b", 43, fixed_reference=True), TagSpec("moving_b", 41))), ), common_frame="fixture", extrinsic_reference_view="oblique", @@ -80,10 +76,10 @@ def _small_profile() -> CalibrationProfile: measurement=MeasurementPolicy( measurements={ "axis_a": MeasurementSpec( - "axis_a", "rotation", "oblique", "base_a", "base_a" + "axis_a", "rotation", "oblique", "base_a", "moving_a" ), "axis_b": MeasurementSpec( - "axis_b", "circle", "wrist", "moving_b", "moving_b" + "axis_b", "circle", "wrist", "base_b", "moving_b" ), } ), @@ -98,8 +94,8 @@ def _small_profile() -> CalibrationProfile: cad_frozen_joints=frozenset(), ), quality=QualityPolicy( - training_cycles=(0, 1), - holdout_cycle=2, + training_cycles=(0, 1, 2), + holdout_cycle=3, hard_threshold_keys=frozenset({"maximum_error"}), ), scope=ScopePolicy( @@ -132,15 +128,39 @@ def _bindings() -> EngineBindings: def test_core_has_no_ros_or_model_dependency() -> None: - text = _source_text(PACKAGE / "core") - for forbidden in ("rclpy", "cv_bridge", " G20", " L6", "..models"): - assert forbidden not in text + # 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: - text = _source_text(PACKAGE / "runtime") - for forbidden in ("G20", "L6", "RIGHT_19", '"front"', '"side"', '"top"'): - assert forbidden not in text + # 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"} + 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(item, ast.Constant) and isinstance(item.value, str) and item.value in forbidden + for comparison in ast.walk(node.test) if isinstance(comparison, ast.Compare) + for operand in comparison.comparators for item in ast.walk(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: @@ -204,10 +224,14 @@ def test_generic_extrinsics_validator_uses_profile_view_names() -> None: ) -def test_ros_node_dispatcher_uses_profile_registry_without_model_branch( - monkeypatch, +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( @@ -221,14 +245,14 @@ def test_ros_node_dispatcher_uses_profile_registry_without_model_branch( ) registry = ProfileRegistry() registry.register(RegisteredProfile(profile, bindings)) - monkeypatch.setattr( - calibration_node, "get_default_registry", lambda: registry - ) + 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", @@ -255,44 +279,15 @@ def test_retired_layout_alias_is_confined_to_compatibility_resolution() -> None: assert contract.typed_profile.key.layout == "g20_right_19" -def test_controller_uses_one_evaluator_and_solver_contract() -> None: - profile = _small_profile() - - class Evaluator: - def evaluate_task(self, selected, task, samples): - assert selected is profile - return TaskEvaluation(accepted=True) - - class Solver: - def solve_session(self, selected, samples): - assert selected is profile - assert {row.task_key for row in samples} == {"scan_a", "scan_b"} - return SessionSolution(True, {"ok": True}, {"axis_a": 0.0}) - - controller = SessionController(profile, Evaluator(), Solver()) - controller.start() - controller.finish_preflight(passed=True) - for index, task in enumerate(profile.motion.tasks): - controller.task_pose_ready() - controller.submit_task_samples( - ( - SampleRecord( - task.key, - task.joints[0], - task.view, - 0, - "decreasing", - 255, - index, - {}, - ), - ) - ) - assert controller.state == SessionState.SOLVING - assert controller.solve().passed - controller.finish_release_validation(passed=True) - controller.finish_publication() - assert controller.state == SessionState.COMPLETE +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: diff --git a/src/linkerhand_calibration/test/test_artifact_publication.py b/src/linkerhand_calibration/test/test_artifact_publication.py new file mode 100644 index 0000000..c1a733f --- /dev/null +++ b/src/linkerhand_calibration/test/test_artifact_publication.py @@ -0,0 +1,68 @@ +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_calibration_coordinator.py b/src/linkerhand_calibration/test/test_calibration_coordinator.py new file mode 100644 index 0000000..c135cde --- /dev/null +++ b/src/linkerhand_calibration/test/test_calibration_coordinator.py @@ -0,0 +1,223 @@ +"""Session/observation/publication races exercised through real coordinator ports.""" + +from concurrent.futures import ThreadPoolExecutor +from dataclasses import replace +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"]) +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("steady" if change == "steady" 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) + else: + with host.step_data_lock: + host._steady_capture_after_ns = None + release.set() + future.result(timeout=2) + assert host.raw_records == [] + 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) + + +def test_feedback_and_status_continue_during_fit_failure_never_starts_motion(tmp_path): + host, clock = coordinator_fixture(tmp_path) + 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.PAUSED + assert "fit_or_publication_failed:synthetic_fit_failure" == host.reason + 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() + + +@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_capture_provenance.py b/src/linkerhand_calibration/test/test_capture_provenance.py new file mode 100644 index 0000000..152480d --- /dev/null +++ b/src/linkerhand_calibration/test/test_capture_provenance.py @@ -0,0 +1,77 @@ +"""Old/edited journals cannot silently acquire current configuration hashes.""" + +import copy +import hashlib +import json + +import pytest + +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, CalibrationEngine +from linkerhand_calibration.runtime.scan_quality import observation_streams + + +def journal(): + profile = load_bundled_hand_profile("l6_right_8") + 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, + "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", "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 == "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_offline_units_use_live_quality_and_require_completed_last_attempt(): + profile, _, 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}) + 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) diff --git a/src/linkerhand_calibration/test/test_compact_calibration_json.py b/src/linkerhand_calibration/test/test_compact_calibration_json.py new file mode 100644 index 0000000..74ae63a --- /dev/null +++ b/src/linkerhand_calibration/test/test_compact_calibration_json.py @@ -0,0 +1,182 @@ +"""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_config.py b/src/linkerhand_calibration/test/test_config.py index c1f4572..040e06a 100644 --- a/src/linkerhand_calibration/test/test_config.py +++ b/src/linkerhand_calibration/test/test_config.py @@ -257,7 +257,7 @@ def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None: 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 parameters["pnp_reprojection_tie_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 diff --git a/src/linkerhand_calibration/test/test_declared_byte_direction.py b/src/linkerhand_calibration/test/test_declared_byte_direction.py new file mode 100644 index 0000000..59da0b5 --- /dev/null +++ b/src/linkerhand_calibration/test/test_declared_byte_direction.py @@ -0,0 +1,25 @@ +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_dual_mapping.py b/src/linkerhand_calibration/test/test_dual_mapping.py new file mode 100644 index 0000000..495835a --- /dev/null +++ b/src/linkerhand_calibration/test/test_dual_mapping.py @@ -0,0 +1,123 @@ +"""Command calibration uses visual truth, never feedback relabelled as command.""" + +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 + + +def independent_steady_fixture(layout): + profile = load_bundled_hand_profile(layout) + 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) + fit = CalibrationResult({name: curve}, {name: feedback}, {name: 0.}, {}, {}, {}, None, + reference_inputs={name: .8*baseline+offset}) + 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_finalization_worker.py b/src/linkerhand_calibration/test/test_finalization_worker.py new file mode 100644 index 0000000..ff861ba --- /dev/null +++ b/src/linkerhand_calibration/test/test_finalization_worker.py @@ -0,0 +1,58 @@ +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_frozen_rotation_reference.py b/src/linkerhand_calibration/test/test_frozen_rotation_reference.py new file mode 100644 index 0000000..601d6fa --- /dev/null +++ b/src/linkerhand_calibration/test/test_frozen_rotation_reference.py @@ -0,0 +1,47 @@ +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 new file mode 100644 index 0000000..0661248 --- /dev/null +++ b/src/linkerhand_calibration/test/test_frozen_tag_replay.py @@ -0,0 +1,135 @@ +"""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] + with pytest.raises(ValueError, match="not rigid"): + fit_tag_installations(source_model=UrdfKinematicModel(source), common_from_base=base, + link_by_role={"a": "proximal", "b": "distal"}, observations=slipped) + # 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_g20_right_product.py b/src/linkerhand_calibration/test/test_g20_right_product.py index e3d130e..1fe6fc0 100644 --- a/src/linkerhand_calibration/test/test_g20_right_product.py +++ b/src/linkerhand_calibration/test/test_g20_right_product.py @@ -1,23 +1,15 @@ from dataclasses import replace -from collections import deque import json import math from pathlib import Path from types import SimpleNamespace -from xml.etree import ElementTree as ET import numpy as np import pytest -from scipy.spatial.transform import Rotation import yaml -from linkerhand_calibration.core import ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, -) from linkerhand_calibration.full_hand import ( G20_COMBINATION_REQUIRED_TARGET_KEYS, - G20_REFERENCE_THUMB_CMC_JOINTS, G20_RIGHT_19_LAYOUT, JointCurveFit, build_compact_payload, @@ -29,7 +21,7 @@ from linkerhand_calibration.operator_report import ( classify_error, render_progress_zh, ) -from linkerhand_calibration.models.g20.runner import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.runner import ( _automatic_resume_candidate, _calibration_node_exited_before_status, _launch_command, @@ -41,40 +33,11 @@ from linkerhand_calibration.publication import ( ACTIVE_ZERO_JOINTS, PASSIVE_JOINTS, atomic_session_pointer, - build_mujoco_validation_commands, - finalize_session_artifacts, - session_artifact_paths, - standalone_thumb_offsets, - validate_runtime_curves_against_urdf_limits, - verify_corrected_urdf, - verify_partial_scope_preserves_certified_zeros, - verify_urdf_mesh_resources, ) from linkerhand_calibration.storage import atomic_write_json -from linkerhand_calibration.three_camera_node import ( - G20ThreeCameraCalibrationNode, - STEADY_COMMAND_CHECKPOINTS, - SweepItem, - _combination_joint_angles, - _combination_motor_directions, - _combination_validation_items, - _model_link_in_observer_base, - _partial_scope_frozen_zero_offsets, - _palm_axis_observer_schema, - _palm_axis_resume_policy, - build_standalone_thumb_payload, - recalibration_quality_joints, - recalibration_task_keys, - _steady_checkpoint_commands, - _unresolved_fit_failure_tasks, - combination_target_coverage, - resume_tasks_invalidated_by_tag_size_changes, - resumable_completed_task_prefix, -) from linkerhand_calibration.urdf_zero import ( RIGHT_19_MECHANICAL_ENDPOINT_JOINTS, UrdfKinematicModel, - get_zero_calibration_profile, write_zero_corrected_urdf, ) from linkerhand_calibration.runtime import ACQUISITION_POLICY_VERSION @@ -251,81 +214,6 @@ def test_schema_v2_loads_the_same_registered_product(tmp_path) -> None: assert config.calibration_contract.typed_profile.key == config.profile_key -def test_first_round_uses_nine_bidirectional_steady_commands() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - spec = profile.sweep_specs[0] - decreasing = _steady_checkpoint_commands( - profile, SweepItem(spec, 0, "decreasing") - ) - increasing = _steady_checkpoint_commands( - profile, SweepItem(spec, 0, "increasing") - ) - assert (255, *decreasing) == STEADY_COMMAND_CHECKPOINTS - assert (0, *increasing) == tuple(reversed(STEADY_COMMAND_CHECKPOINTS)) - assert _steady_checkpoint_commands( - profile, SweepItem(spec, 1, "decreasing") - ) == () - - -def test_combination_coverage_requires_observation_and_validation_per_target() -> None: - assert G20_REFERENCE_THUMB_CMC_JOINTS == { - "thumb_cmc_pitch", - "thumb_cmc_roll", - "thumb_cmc_yaw", - } - assert not any( - "thumb" in key for key in G20_COMBINATION_REQUIRED_TARGET_KEYS - ) - incomplete = combination_target_coverage({}, {}) - assert incomplete["coverage_passed"] is False - assert set(incomplete["missing_validation_targets"]) == set( - G20_COMBINATION_REQUIRED_TARGET_KEYS - ) - - complete = combination_target_coverage( - {key: 2 for key in G20_COMBINATION_REQUIRED_TARGET_KEYS}, - {key: 1 for key in G20_COMBINATION_REQUIRED_TARGET_KEYS}, - ) - assert complete["coverage_passed"] is True - - -def _complete_resume_task_rows(profile, spec) -> list[dict]: - rows: list[dict] = [] - for joint in spec.joints: - for cycle in range(4): - for direction in ("decreasing", "increasing"): - rows.extend( - { - "kind": "sample", - "attempt": 1, - "task_name": spec.key, - "joint": joint, - "cycle": cycle, - "direction": direction, - "requested_command_u8": ( - 0 if direction == "decreasing" else 255 - ), - "feedback_u8": command, - } - for command in (*range(32), 255) - ) - for direction in ("decreasing", "increasing"): - rows.extend( - { - "kind": "steady_command_sample", - "attempt": 1, - "task_name": spec.key, - "joint": joint, - "cycle": 0, - "direction": direction, - "requested_command_u8": command, - "feedback_u8": command, - } - for command in STEADY_COMMAND_CHECKPOINTS - ) - return rows - - _FIXED_BASE_CORNERS_BY_VIEW = { "front": ( (100.0, 100.0), @@ -372,590 +260,6 @@ def _resume_reference_views( } -def test_resume_reuses_only_a_fully_committed_task_prefix() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - first, second = profile.sweep_specs[:2] - rows = _complete_resume_task_rows(profile, first) - # A fragment of the following task must be discarded as a unit. - rows.append( - { - "kind": "sample", - "attempt": 1, - "task_name": second.key, - "joint": second.joints[0], - "cycle": 0, - "direction": "decreasing", - "feedback_u8": 255, - } - ) - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, - 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], - rows, - ) - - assert completed == (first.key,) - assert reusable - assert {row["task_name"] for row in reusable} == {first.key} - - -def test_resume_preserves_palm_axis_side_channel_without_requiring_it() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - spec = next( - item for item in profile.sweep_specs if item.key == "pinky_pitch_side" - ) - rows = _complete_resume_task_rows(profile, spec) - rows.append( - { - "kind": "palm_axis_sample", - "attempt": 1, - "task_name": spec.key, - "source_joint": "pinky_mcp_pitch_front_axis", - "cycle": 0, - "direction": "decreasing", - "feedback_u8": 240, - } - ) - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - allow_sparse=True, - ) - - assert completed == (spec.key,) - assert sum( - row.get("kind") == "palm_axis_sample" for row in reusable - ) == 1 - - completed_without_side_channel, _ = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - _complete_resume_task_rows(profile, spec), - allow_sparse=True, - ) - assert completed_without_side_channel == (spec.key,) - - -def test_checkpoint_reacquires_new_thumb_yaw_side_channel_once() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - old_capabilities = set(profile.capabilities) - { - "palm_axis_relative_motion_v3" - } - - compatible, invalidated = _palm_axis_resume_policy( - profile, {"capabilities": sorted(old_capabilities)} - ) - - assert compatible is False - assert set(invalidated) == {spec.key for spec in profile.sweep_specs} - - compatible, invalidated = _palm_axis_resume_policy( - profile, - { - "capabilities": sorted(profile.capabilities), - "palm_axis_observers": _palm_axis_observer_schema(profile), - }, - ) - assert compatible is True - assert invalidated == () - - -def test_sparse_resume_keeps_complete_tasks_after_failed_task() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - first, failed, later = profile.sweep_specs[:3] - rows = [ - *(_complete_resume_task_rows(profile, first)), - *(_complete_resume_task_rows(profile, failed)), - *(_complete_resume_task_rows(profile, later)), - { - "kind": "fit_failure", - "task_name": failed.key, - "view": failed.view, - "motor_index": failed.motor_index, - "joints": list(failed.joints), - "attempt": 1, - "failures": [ - { - "joint": failed.joints[0], - "metric": "monotonic_correction_deg", - "actual": 2.1, - "limit": 2.0, - } - ], - }, - ] - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - allow_sparse=True, - ) - - assert completed == (first.key, later.key) - assert {row["task_name"] for row in reusable} == { - first.key, - later.key, - } - - -def test_corrected_distal_tag_sizes_invalidate_only_four_pip_tasks() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - previous = { - tag_id: (0.010 if tag_id in {7, 14, 16, 18} else 0.016) - for tag_id in range(19) - } - current = {tag_id: 0.016 for tag_id in range(19)} - - changed, invalidated = resume_tasks_invalidated_by_tag_size_changes( - profile, previous, current - ) - - assert changed == (7, 14, 16, 18) - assert invalidated == ( - "pinky_pip_side", - "ring_pip_side", - "middle_pip_side", - "index_pip_side", - ) - assert len(profile.sweep_specs) - len(invalidated) == 12 - - -def test_sparse_resume_revalidates_retired_product_dynamic_hysteresis() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - task = next( - spec for spec in profile.sweep_specs - if spec.key == "thumb_mcp_ip_front" - ) - rows = [ - *_complete_resume_task_rows(profile, task), - { - "kind": "fit_failure", - "task_name": task.key, - "view": task.view, - "motor_index": task.motor_index, - "joints": list(task.joints), - "attempt": 1, - "failures": [ - { - "joint": "thumb_ip", - "metric": "hysteresis_deg", - "actual": 2.2, - "limit": 2.0, - }, - { - "joint": "thumb_mcp", - "metric": "command_direction_gap_deg", - "actual": 2.4, - "limit": 2.0, - }, - ], - }, - ] - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - allow_sparse=True, - ) - - assert completed == (task.key,) - assert {row["task_name"] for row in reusable} == {task.key} - - -def test_resume_migrates_completed_legacy_split_roll_without_rescanning() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - merged = next( - spec for spec in profile.sweep_specs - if spec.key == "pinky_roll_multiview" - ) - rows = [ - row - for spec in profile.sweep_specs[:4] - for row in _complete_resume_task_rows(profile, spec) - ] - for source in _complete_resume_task_rows(profile, merged): - row = dict(source) - row["task_name"] = ( - "pinky_roll_side" - if row["joint"] == "pinky_mcp_roll_side" - else "pinky_roll_front" - ) - rows.append(row) - for joint in merged.joints: - old_task = ( - "pinky_roll_side" - if joint == "pinky_mcp_roll_side" - else "pinky_roll_front" - ) - for cycle in range(4): - for direction in ("decreasing", "increasing"): - rows.append( - { - "kind": "baseline_hold_sample", - "attempt": 1, - "task_name": old_task, - "joint": joint, - "cycle": cycle, - "direction": direction, - "feedback_u8": 127, - } - ) - - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - - completed, reusable = resumable_completed_task_prefix( - profile, 4, baseline, rows - ) - - assert completed == tuple(spec.key for spec in profile.sweep_specs[:5]) - migrated = [ - row for row in reusable - if row["task_name"] == "pinky_roll_multiview" - ] - assert {row["joint"] for row in migrated} == set(merged.joints) - assert {row["resume_source_task_name"] for row in migrated} == { - "pinky_roll_front", - "pinky_roll_side", - } - - -def test_resume_falls_back_from_an_interrupted_retry_to_complete_attempt() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - pip = next( - spec for spec in profile.sweep_specs - if spec.key == "pinky_pip_side" - ) - pip_index = profile.sweep_specs.index(pip) - rows = [ - row - for spec in profile.sweep_specs[: pip_index + 1] - for row in _complete_resume_task_rows(profile, spec) - ] - # The operator stopped the unnecessary second retry after only two of the - # nine checkpoints. Those partial rows must not hide attempt 1. - for joint in pip.joints: - for command in (255, 224): - rows.append( - { - "kind": "steady_command_sample", - "attempt": 2, - "task_name": pip.key, - "joint": joint, - "cycle": 0, - "direction": "decreasing", - "requested_command_u8": command, - "feedback_u8": command, - } - ) - rows.append( - { - "kind": "fit_failure", - "task_name": pip.key, - "view": pip.view, - "motor_index": pip.motor_index, - "joints": list(pip.joints), - "attempt": 1, - "failures": [ - { - "joint": "pinky_pip", - "metric": "rotation_orthogonal_rms_deg", - "actual": 8.176357, - "limit": 2.5, - } - ], - } - ) - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - ) - - assert completed == tuple( - spec.key for spec in profile.sweep_specs[: pip_index + 1] - ) - checkpoints = [ - row - for row in reusable - if row["kind"] == "steady_command_sample" - and row["direction"] == "decreasing" - ] - assert checkpoints - assert {int(row.get("attempt", 1)) for row in checkpoints} == {1} - - -def test_resume_retry_tombstone_discards_superseded_rows() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - task = next( - spec - for spec in profile.sweep_specs - if spec.key == "pinky_roll_multiview" - ) - old_checkpoints = [] - for joint in task.joints: - old_checkpoints.extend( - { - "kind": "steady_command_sample", - "attempt": 1, - "task_name": task.key, - "joint": joint, - "motor_index": task.motor_index, - "cycle": 0, - "direction": "decreasing", - "requested_command_u8": command, - "feedback_u8": command, - "resume_test_generation": "superseded", - } - for command in STEADY_COMMAND_CHECKPOINTS - ) - rows = [ - *old_checkpoints, - { - # Legacy retry events had no task_name or attempt. Motor, joint, - # cycle and direction must still act as a durable tombstone. - "kind": "automatic_sweep_retry", - "view": task.view, - "motor_index": task.motor_index, - "joints": list(task.joints), - "cycle": 0, - "direction": "decreasing", - "retry": 1, - }, - *_complete_resume_task_rows(profile, task), - ] - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - allow_sparse=True, - ) - - assert completed == (task.key,) - restored_checkpoints = [ - row - for row in reusable - if row["kind"] == "steady_command_sample" - and row["direction"] == "decreasing" - ] - assert len(restored_checkpoints) == 2 * len(STEADY_COMMAND_CHECKPOINTS) - assert all( - row.get("resume_test_generation") != "superseded" - for row in restored_checkpoints - ) - - -def test_interrupted_retry_does_not_resurrect_rejected_sweep() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - task = next( - spec - for spec in profile.sweep_specs - if spec.key == "pinky_roll_multiview" - ) - rows = [ - *_complete_resume_task_rows(profile, task), - { - "kind": "automatic_sweep_retry", - "task_name": task.key, - "attempt": 1, - "view": task.view, - "motor_index": task.motor_index, - "joints": list(task.joints), - "cycle": 0, - "direction": "decreasing", - "retry": 1, - }, - ] - - completed, reusable = resumable_completed_task_prefix( - profile, - 4, - [255] * 20, - rows, - allow_sparse=True, - ) - - assert completed == () - assert reusable == () - - -def test_resume_rejects_unresolved_fit_but_accepts_retired_model_metric() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - first = profile.sweep_specs[0] - unresolved_rows = _complete_resume_task_rows(profile, first) - unresolved_rows.append( - { - "kind": "fit_failure", - "task_name": first.key, - "view": first.view, - "motor_index": first.motor_index, - "joints": list(first.joints), - "attempt": 1, - "failures": [ - { - "joint": first.joints[0], - "metric": "arc_deg", - "actual": 10.0, - "limit": 15.0, - } - ], - } - ) - completed, _ = resumable_completed_task_prefix( - profile, 4, [255] * 20, unresolved_rows - ) - assert completed == () - - pitch = next( - spec for spec in profile.sweep_specs if spec.key == "pinky_pitch_side" - ) - retired_rows = [ - { - "kind": "sample", - "task_name": pitch.key, - "attempt": 3, - }, - { - "kind": "fit_failure", - "view": pitch.view, - "motor_index": pitch.motor_index, - "joints": list(pitch.joints), - "attempt": 3, - "failures": [ - { - "joint": "pinky_mcp_pitch", - "metric": "rotation_circle_axis_difference_deg", - "actual": 4.0, - "limit": 1.0, - } - ], - }, - ] - assert _unresolved_fit_failure_tasks(profile, retired_rows) == set() - - -def test_resume_revalidates_retired_passive_dip_position_metrics() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - pip = next( - spec for spec in profile.sweep_specs if spec.key == "pinky_pip_side" - ) - rows = [ - { - "kind": "sample", - "task_name": pip.key, - "attempt": 3, - }, - { - "kind": "fit_failure", - "task_name": pip.key, - "view": pip.view, - "motor_index": pip.motor_index, - "joints": list(pip.joints), - "attempt": 3, - "failures": [ - { - "joint": "pinky_dip", - "metric": "axis_pose_line_rms_mm", - "actual": 1.9, - "limit": 1.0, - }, - { - "joint": "pinky_dip", - "metric": "rotation_circle_axis_difference_deg", - "actual": 15.5, - "limit": 1.0, - }, - ], - }, - ] - - assert _unresolved_fit_failure_tasks(profile, rows) == set() - - -def test_resume_revalidates_thumb_ip_position_diagnostic() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - task = next( - spec - for spec in profile.sweep_specs - if spec.key == "thumb_mcp_ip_front" - ) - rows = [ - { - "kind": "sample", - "task_name": task.key, - "attempt": 3, - }, - { - "kind": "fit_failure", - "task_name": task.key, - "view": task.view, - "motor_index": task.motor_index, - "joints": list(task.joints), - "attempt": 3, - "failures": [ - { - "joint": "thumb_ip", - "metric": "axis_pose_line_rms_mm", - "actual": 1.4, - "limit": 1.0, - } - ], - }, - ] - - assert _unresolved_fit_failure_tasks(profile, rows) == set() - - -def test_resume_retires_validation_only_cross_view_curve_failure() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - spec = next( - item for item in profile.sweep_specs - if item.key == "ring_roll_multiview" - ) - rows = [ - { - "kind": "sample", - "task_name": spec.key, - "attempt": 3, - }, - { - "kind": "fit_failure", - "task_name": spec.key, - "view": spec.view, - "motor_index": spec.motor_index, - "joints": list(spec.joints), - "attempt": 3, - "failures": [ - { - "joint": "ring_mcp_roll", - "metric": "cross_view_roll_curve", - "reason": ( - "cross_view_roll_curve_difference_too_large:" - "angle_rad:1.297957deg" - ), - } - ], - }, - ] - - assert _unresolved_fit_failure_tasks(profile, rows) == set() - - def test_automatic_resume_requires_failed_matching_geometry(tmp_path: Path) -> None: config = _config(tmp_path) session = config.session_root / "20260819_150000" @@ -1016,7 +320,8 @@ def test_thumb_scope_resolves_passed_base_and_launches_partial_mode( }, }, ) - atomic_session_pointer(config.session_root, "latest_passed", session) + # 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" @@ -1033,24 +338,6 @@ def test_thumb_scope_resolves_passed_base_and_launches_partial_mode( assert f"resume_raw_samples_path:={session / 'raw_samples.jsonl'}" in command -def test_fingers_scope_reuses_thumb_and_recollects_twelve_tasks( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - tasks = recalibration_task_keys(profile, "fingers") - command = _launch_command( - config, - config.session_root / "20260831_130000", - resume_from=config.session_root / "20260831_120000", - recalibration_scope="fingers", - ) - - assert len(tasks) == 12 - assert all("thumb_" not in name for name in tasks) - assert "recalibration_scope:=fingers" in command - - def test_standalone_thumb_launch_has_no_resume_dependency(tmp_path: Path) -> None: config = _config(tmp_path) command = _launch_command( @@ -1063,90 +350,6 @@ def test_standalone_thumb_launch_has_no_resume_dependency(tmp_path: Path) -> Non assert all("resume_raw_samples_path" not in value for value in command) -def test_partial_quality_gates_only_fresh_scope_joints() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - thumb = recalibration_quality_joints(profile, "thumb") - fingers = recalibration_quality_joints(profile, "fingers") - - assert thumb - assert fingers - assert all(name.startswith("thumb_") for name in thumb) - assert all(not name.startswith("thumb_") for name in fingers) - assert set(thumb) | set(fingers) == set(profile.measured_joints) - assert set(thumb).isdisjoint(fingers) - - -def test_standalone_thumb_payload_expands_non_thumb_to_cad_zero() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - curve = tuple(0.001 * (127 - value) for value in range(256)) - fits = { - name: JointCurveFit( - angle_rad=curve, - decreasing_rad=curve, - increasing_rad=curve, - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=0.0, - quality={}, - ) - for name in recalibration_quality_joints(profile, "thumb") - } - thumb_offsets = { - "thumb_cmc_roll": 0.01, - "thumb_cmc_yaw": -0.02, - "thumb_cmc_pitch": 0.03, - "thumb_mcp": -0.01, - } - payload = build_standalone_thumb_payload( - profile=profile, - serial_number="G20_RIGHT_001", - measured_fits=fits, - thumb_offsets_rad=thumb_offsets, - validation_errors_rad=(0.001, -0.002), - baseline_command_u8=profile.baseline_command, - source_urdf_sha256="a" * 64, - camera_extrinsics_sha256="b" * 64, - corrected_urdf_sha256="c" * 64, - ) - - expanded = standalone_thumb_offsets(payload) - assert set(expanded) == set(ACTIVE_ZERO_JOINTS) - assert all(expanded[name] == value for name, value in thumb_offsets.items()) - assert all( - value == 0.0 - for name, value in expanded.items() - if not name.startswith("thumb_") - ) - - -def test_partial_scopes_freeze_exactly_the_non_target_zeros() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - offsets = { - name: 0.001 * (index + 1) - for index, name in enumerate(sorted(ACTIVE_ZERO_JOINTS)) - } - payload = _payload(zero_offsets=offsets) - - thumb_frozen = _partial_scope_frozen_zero_offsets( - profile, "thumb", payload - ) - finger_frozen = _partial_scope_frozen_zero_offsets( - profile, "fingers", payload - ) - - assert len(thumb_frozen) == 12 - assert all(not name.startswith("thumb_") for name in thumb_frozen) - assert len(finger_frozen) == 4 - assert all(name.startswith("thumb_") for name in finger_frozen) - expected = { - name: float( - payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"] - ) - for name in ACTIVE_ZERO_JOINTS - } - assert {**thumb_frozen, **finger_frozen} == expected - - def test_thumb_scope_rejects_base_with_different_geometry(tmp_path: Path) -> None: config = _config(tmp_path) session = config.session_root / "20260830_120000" @@ -1235,298 +438,6 @@ def test_automatic_resume_skips_newer_attempt_without_start_checkpoint( assert _automatic_resume_candidate(config) == usable -def test_node_restores_complete_prefix_into_new_self_contained_raw( - tmp_path: Path, monkeypatch -) -> None: - # The synthetic rows carry no pose trajectories, so the import-time hard - # gate revalidation (covered separately in test_three_camera_retry) is - # bypassed here to test the restore plumbing itself. - monkeypatch.setattr( - G20ThreeCameraCalibrationNode, - "_revalidate_imported_tasks", - lambda self, completed, **_kwargs: (tuple(completed), []), - ) - config = _config(tmp_path) - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - source_raw = tmp_path / "old" / "raw_samples.jsonl" - source_raw.parent.mkdir() - rows = [ - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "view_tags": { - view: dict(tags) for view, tags in profile.view_tags.items() - }, - "baseline_command_u8": baseline, - "source_urdf_sha256": config.source_urdf_sha256, - "capabilities": sorted(profile.capabilities), - "palm_axis_observers": _palm_axis_observer_schema(profile), - }, - *_fixed_base_reference_rows(), - *_complete_resume_task_rows(profile, profile.sweep_specs[0]), - ] - for row in rows: - if row.get("task_name") == profile.sweep_specs[0].key: - row["attempt"] = 2 - source_raw.write_text( - "".join(json.dumps(row) + "\n" for row in rows) - ) - current_raw = tmp_path / "new" / "raw_samples.jsonl" - current_raw.parent.mkdir() - current_raw.touch() - sweep_items = [] - for spec in profile.sweep_specs: - sweep_items.extend( - SweepItem(spec, -1, direction, precheck=True) - for direction in ("decreasing", "increasing") - ) - sweep_items.extend( - SweepItem(spec, cycle, direction) - for cycle in range(4) - for direction in ("decreasing", "increasing") - ) - node = SimpleNamespace( - resume_raw_samples_path=source_raw, - raw_path=current_raw, - hand_type="right", - profile=profile, - baseline_command=tuple(baseline), - source_urdf_path=config.source_urdf, - repetitions=4, - minimum_sweep_bins=32, - records_by_joint={name: [] for name in profile.record_joints}, - baseline_records_by_joint={name: [] for name in profile.record_joints}, - command_records_by_joint={name: [] for name in profile.record_joints}, - palm_axis_records_by_source={ - observer.source_name: [] - for observer in profile.palm_axis_observers - }, - sweep_index=0, - sweep_items=sweep_items, - resumed_task_keys=(), - resume_source_session="", - views=_resume_reference_views(), - fixed_base_maximum_corner_drift_px=2.0, - recalibration_scope="full", - ) - - count = G20ThreeCameraCalibrationNode._restore_durable_task_checkpoint( - node - ) - - assert count == 1 - assert node.sweep_index == 10 - assert node.resumed_task_keys == (profile.sweep_specs[0].key,) - assert node.sweep_attempts[profile.sweep_specs[0].key] == 2 - restored = [ - json.loads(line) for line in current_raw.read_text().splitlines() - ] - assert restored[0]["kind"] == "resume_checkpoint_import" - assert restored[0]["imported_attempt_floor_by_task"] == { - profile.sweep_specs[0].key: 2 - } - assert any(row["kind"] == "sample" for row in restored) - - -def test_full_resume_discards_all_old_tasks_after_start_position_change( - tmp_path: Path, monkeypatch -) -> None: - monkeypatch.setattr( - G20ThreeCameraCalibrationNode, - "_revalidate_imported_tasks", - lambda self, completed, **_kwargs: (tuple(completed), []), - ) - config = _config(tmp_path) - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - source_raw = tmp_path / "old_position" / "raw_samples.jsonl" - source_raw.parent.mkdir() - rows = [ - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "model": "G20", - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "view_tags": { - view: dict(tags) for view, tags in profile.view_tags.items() - }, - "baseline_command_u8": baseline, - "source_urdf_sha256": config.source_urdf_sha256, - "capabilities": sorted(profile.capabilities), - "palm_axis_observers": _palm_axis_observer_schema(profile), - }, - *_fixed_base_reference_rows(), - *_complete_resume_task_rows(profile, profile.sweep_specs[0]), - ] - source_raw.write_text( - "".join(json.dumps(row) + "\n" for row in rows), - encoding="utf-8", - ) - current_raw = tmp_path / "new_position" / "raw_samples.jsonl" - current_raw.parent.mkdir() - current_raw.touch() - moved_corners = { - view: tuple((x + 18.0, y - 7.0) for x, y in corners) - for view, corners in _FIXED_BASE_CORNERS_BY_VIEW.items() - } - sweep_items = [ - SweepItem(spec, cycle, direction) - for spec in profile.sweep_specs - for cycle in range(4) - for direction in ("decreasing", "increasing") - ] - node = SimpleNamespace( - resume_raw_samples_path=source_raw, - raw_path=current_raw, - model="G20", - hand_type="right", - profile=profile, - baseline_command=tuple(baseline), - source_urdf_path=config.source_urdf, - repetitions=4, - minimum_sweep_bins=32, - records_by_joint={name: [] for name in profile.record_joints}, - baseline_records_by_joint={name: [] for name in profile.record_joints}, - command_records_by_joint={name: [] for name in profile.record_joints}, - palm_axis_records_by_source={ - observer.source_name: [] - for observer in profile.palm_axis_observers - }, - sweep_index=0, - sweep_items=sweep_items, - resumed_task_keys=(), - resume_source_session="", - recalibration_scope="full", - recalibration_task_keys=(), - views=_resume_reference_views(moved_corners), - fixed_base_maximum_corner_drift_px=2.0, - ) - - count = G20ThreeCameraCalibrationNode._restore_durable_task_checkpoint( - node - ) - - assert count == 0 - assert node.resumed_task_keys == () - assert node.sweep_index == 0 - assert node.resume_position_policy == ( - "discard_all_tasks_for_new_start_pose" - ) - assert node.resume_position_changed_views == ("front", "side", "top") - assert all(not values for values in node.records_by_joint.values()) - imported = json.loads(current_raw.read_text(encoding="utf-8").splitlines()[0]) - assert imported["start_position_policy"] == ( - "discard_all_tasks_for_new_start_pose" - ) - assert imported["start_position_invalidated_task_keys"] == [ - spec.key for spec in profile.sweep_specs - ] - assert imported["imported_record_count"] == 0 - - -def test_thumb_recalibration_imports_fingers_but_invalidates_all_thumb_tasks( - tmp_path: Path, monkeypatch -) -> None: - monkeypatch.setattr( - G20ThreeCameraCalibrationNode, - "_revalidate_imported_tasks", - lambda self, completed, **_kwargs: (tuple(completed), []), - ) - config = _config(tmp_path) - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - thumb_tasks = recalibration_task_keys(profile, "thumb") - thumb_spec = next(spec for spec in profile.sweep_specs if spec.key in thumb_tasks) - finger_spec = next( - spec - for spec in profile.sweep_specs - if spec.key == "pinky_pitch_side" - ) - source_raw = tmp_path / "passed" / "raw_samples.jsonl" - source_raw.parent.mkdir() - rows = [ - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "model": "G20", - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "view_tags": { - view: dict(tags) for view, tags in profile.view_tags.items() - }, - "baseline_command_u8": baseline, - "source_urdf_sha256": config.source_urdf_sha256, - "capabilities": sorted(profile.capabilities), - "palm_axis_observers": _palm_axis_observer_schema(profile), - }, - *_fixed_base_reference_rows(), - *_complete_resume_task_rows(profile, thumb_spec), - *_complete_resume_task_rows(profile, finger_spec), - ] - source_raw.write_text( - "".join(json.dumps(row) + "\n" for row in rows), - encoding="utf-8", - ) - current_raw = tmp_path / "new" / "raw_samples.jsonl" - current_raw.parent.mkdir() - current_raw.touch() - sweep_items = [ - SweepItem(spec, cycle, direction) - for spec in profile.sweep_specs - for cycle in range(4) - for direction in ("decreasing", "increasing") - ] - node = SimpleNamespace( - resume_raw_samples_path=source_raw, - raw_path=current_raw, - model="G20", - hand_type="right", - profile=profile, - baseline_command=tuple(baseline), - source_urdf_path=config.source_urdf, - repetitions=4, - minimum_sweep_bins=32, - records_by_joint={name: [] for name in profile.record_joints}, - baseline_records_by_joint={name: [] for name in profile.record_joints}, - command_records_by_joint={name: [] for name in profile.record_joints}, - palm_axis_records_by_source={ - observer.source_name: [] for observer in profile.palm_axis_observers - }, - sweep_index=0, - sweep_items=sweep_items, - resumed_task_keys=(), - resume_source_session="", - recalibration_scope="thumb", - recalibration_task_keys=thumb_tasks, - views=_resume_reference_views(), - fixed_base_maximum_corner_drift_px=2.0, - ) - - count = G20ThreeCameraCalibrationNode._restore_durable_task_checkpoint(node) - - assert count == 1 - assert node.resumed_task_keys == (finger_spec.key,) - assert node.sweep_index == 0 - assert all( - not node.records_by_joint[name] - for name in thumb_spec.joints - ) - assert all(node.records_by_joint[name] for name in finger_spec.joints) - imported = [ - json.loads(line) - for line in current_raw.read_text(encoding="utf-8").splitlines() - ][0] - assert imported["recalibration_scope"] == "thumb" - assert imported["scope_invalidated_task_keys"] == list(thumb_tasks) - - def test_runtime_json_is_minimal_v4_with_21_independent_midpoint_curves() -> None: payload = _payload() assert payload["schema_version"] == 4 @@ -1542,453 +453,24 @@ def test_runtime_json_is_minimal_v4_with_21_independent_midpoint_curves() -> Non ) -def test_publication_protects_passive_xml_and_requires_two_matching_sessions( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - first = _make_passed_session(config, "20260819_100000") - first_summary, first_ready = finalize_session_artifacts( - config, first, node_status=_passed_node_status() - ) - assert first_ready is False - assert first_summary["result"] == "PASS_AWAITING_SECOND_SESSION" - assert not (config.session_root / "latest_passed").exists() - - second = _make_passed_session(config, "20260819_110000") - second_summary, second_ready = finalize_session_artifacts( - config, second, node_status=_passed_node_status() - ) - assert second_ready is True - assert second_summary["formal_release"]["comparison_session"] == first.name - assert (config.session_root / "latest_passed").resolve() == second - paths = list(second.glob("*.urdf")) - assert len(paths) == 1 - changed = verify_corrected_urdf(config.source_urdf, paths[0]) - assert set(changed) == ACTIVE_ZERO_JOINTS - resources = verify_urdf_mesh_resources(paths[0]) - assert len(resources) == 22 - assert set(second_summary["hashes"]["mesh_resources_sha256"]) == set( - resources - ) - - commands = json.loads((second / "mujoco_validation_commands.json").read_text()) - assert commands["topic"] == "/g20/cb_right_hand_control_cmd" - assert len(commands["poses"]) == 8 -def test_publication_records_thumb_recalibration_provenance(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - base_session = _make_passed_session(config, "20260828_205005") - session = _make_passed_session(config, "20260830_140000") - status = _passed_node_status() - tasks = list( - recalibration_task_keys( - get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT), - "thumb", - ) - ) - status["resume"] = { - "used": True, - "source_session": str(base_session), - "recalibration_scope": "thumb", - "recalibration_task_keys": tasks, - } - - summary, ready = finalize_session_artifacts( - config, session, node_status=status - ) - - assert ready is True - assert summary["calibration_scope"] == "thumb" - assert summary["inherited_base_session"] == str(base_session) - assert summary["freshly_calibrated_task_keys"] == tasks - assert len(summary["preserved_certified_zero_joints"]) == 12 - assert all( - not name.startswith("thumb_") - for name in summary["preserved_certified_zero_joints"] - ) -def test_publication_accepts_standalone_thumb_without_base_session( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - thumb_offsets = { - "thumb_cmc_roll": 0.01, - "thumb_cmc_yaw": -0.02, - "thumb_cmc_pitch": 0.03, - "thumb_mcp": -0.01, - } - all_offsets = {name: 0.0 for name in ACTIVE_ZERO_JOINTS} - all_offsets.update(thumb_offsets) - session = _make_passed_session( - config, "20260831_150000", zero_offsets=all_offsets - ) - full_payload = _payload( - config.serial_number, zero_offsets=all_offsets - ) - standalone_payload = { - "schema_version": 1, - "artifact_type": "g20_right_standalone_thumb_calibration", - "model": "G20", - "side": "right", - "serial_number": config.serial_number, - "angle_unit": "rad", - "command_range": [0, 255], - "baseline_command_u8": full_payload["baseline_command_u8"], - "non_thumb_zero_policy": "source_cad_unchanged", - "joints": { - name: full_payload["joints"][name] - for name in (*thumb_offsets, "thumb_ip") - }, - "quality": { - "passed": True, - "validation_mae_rad": 0.001, - "validation_p95_rad": 0.002, - }, - "hashes": {}, - } - atomic_write_json( - session / f"g20_right_{config.serial_number}_calibration.json", - standalone_payload, - ) - status = _passed_node_status() - status["combination_validation"] = {"enabled": False} - status["resume"] = { - "used": False, - "source_session": "", - "recalibration_scope": "thumb", - "recalibration_task_keys": list( - recalibration_task_keys( - get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT), - "thumb", - ) - ), - } - - summary, ready = finalize_session_artifacts( - config, session, node_status=status - ) - - assert ready is True - assert summary["inherited_base_session"] is None - assert summary["non_thumb_zero_policy"] == "source_cad_unchanged" - assert summary["static_zero_calibrated_joints"] == sorted(thumb_offsets) - assert not (config.session_root / "latest_passed").exists() - assert (config.session_root / "latest_thumb_passed").resolve() == session - combined_offsets = dict(all_offsets) - combined_offsets["index_mcp_pitch"] = 0.02 - verify_partial_scope_preserves_certified_zeros( - scope="fingers", - source_session=session, - serial_number=config.serial_number, - current_offsets=combined_offsets, - ) -def test_publication_resolves_node_short_base_session_as_sibling( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - base_session = _make_passed_session(config, "20260828_205005") - session = _make_passed_session(config, "20260831_121828") - status = _passed_node_status() - tasks = list( - recalibration_task_keys( - get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT), - "thumb", - ) - ) - status["resume"] = { - "used": True, - # The calibration node intentionally publishes the durable sibling - # session identifier, not a machine-specific absolute path. - "source_session": base_session.name, - "recalibration_scope": "thumb", - "recalibration_task_keys": tasks, - } - - summary, ready = finalize_session_artifacts( - config, session, node_status=status - ) - - assert ready is True - assert summary["inherited_base_session"] == base_session.name -def test_partial_publication_rejects_any_non_target_zero_change( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - base_offsets = { - name: 0.001 * (index + 1) - for index, name in enumerate(sorted(ACTIVE_ZERO_JOINTS)) - } - base_session = _make_passed_session( - config, "20260831_100000", zero_offsets=base_offsets - ) - - thumb_result = dict(base_offsets) - thumb_result["thumb_cmc_yaw"] += 0.01 - verify_partial_scope_preserves_certified_zeros( - scope="thumb", - source_session=base_session, - serial_number=config.serial_number, - current_offsets=thumb_result, - ) - changed_finger = dict(thumb_result) - changed_finger["index_mcp_pitch"] += 0.001 - with pytest.raises(ValueError, match="non-target zeros"): - verify_partial_scope_preserves_certified_zeros( - scope="thumb", - source_session=base_session, - serial_number=config.serial_number, - current_offsets=changed_finger, - ) - - finger_result = dict(base_offsets) - finger_result["index_mcp_pitch"] += 0.01 - verify_partial_scope_preserves_certified_zeros( - scope="fingers", - source_session=base_session, - serial_number=config.serial_number, - current_offsets=finger_result, - ) - changed_thumb = dict(finger_result) - changed_thumb["thumb_mcp"] += 0.001 - with pytest.raises(ValueError, match="non-target zeros"): - verify_partial_scope_preserves_certified_zeros( - scope="fingers", - source_session=base_session, - serial_number=config.serial_number, - current_offsets=changed_thumb, - ) -def test_publication_records_fingers_scope_with_certified_thumb( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - base_session = _make_passed_session(config, "20260831_110000") - session = _make_passed_session(config, "20260831_120000") - tasks = list( - recalibration_task_keys( - get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT), - "fingers", - ) - ) - status = _passed_node_status() - status["resume"] = { - "used": True, - "source_session": str(base_session), - "recalibration_scope": "fingers", - "recalibration_task_keys": tasks, - } - - summary, ready = finalize_session_artifacts( - config, session, node_status=status - ) - - assert ready is True - assert summary["calibration_scope"] == "fingers" - assert summary["inherited_base_session"] == str(base_session) - assert summary["freshly_calibrated_task_keys"] == tasks - assert summary["preserved_certified_zero_joints"] == sorted( - name for name in ACTIVE_ZERO_JOINTS if name.startswith("thumb_") - ) -def test_publication_numerically_binds_json_offsets_and_urdf_limits( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_111000") - urdf = next(session.glob("*.urdf")) - offsets = {name: 0.01 for name in ACTIVE_ZERO_JOINTS} - - verify_corrected_urdf( - config.source_urdf, urdf, expected_offsets_rad=offsets - ) - wrong_offsets = dict(offsets) - wrong_offsets["thumb_mcp"] = 0.02 - with pytest.raises(ValueError, match="published zero offsets"): - verify_corrected_urdf( - config.source_urdf, - urdf, - expected_offsets_rad=wrong_offsets, - ) - - payload = _payload(config.serial_number) - validate_runtime_curves_against_urdf_limits(payload, config.source_urdf) - payload["joints"]["thumb_mcp"]["angle_rad"][0] = 2.0 - with pytest.raises(ValueError, match="exceeds runtime URDF limit"): - validate_runtime_curves_against_urdf_limits(payload, config.source_urdf) -def test_publication_uses_corrected_coordinates_for_negative_endpoint_zero( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - offsets = {name: 0.01 for name in ACTIVE_ZERO_JOINTS} - endpoint_offset = -0.00801549 - offsets["index_mcp_pitch"] = endpoint_offset - session = _make_passed_session( - config, - "20260826_102518", - zero_offsets=offsets, - ) - paths = session_artifact_paths(session, config.serial_number) - payload = json.loads(paths["json"].read_text(encoding="utf-8")) - curve_maximum = 1.22801310 - payload["joints"]["index_mcp_pitch"]["angle_rad"][0] = curve_maximum - atomic_write_json(paths["json"], payload) - - summary, release_ready = finalize_session_artifacts( - config, - session, - node_status=_passed_node_status(), - ) - - def upper_limit(path: Path) -> float: - joint = next( - node - for node in ET.parse(path).getroot().findall("joint") - if node.get("name") == "index_mcp_pitch" - ) - return float(joint.find("limit").get("upper")) - - source_upper = upper_limit(config.source_urdf) - corrected_upper = upper_limit(paths["urdf"]) - assert release_ready is True - assert summary["result"] == "PASS" - assert corrected_upper == pytest.approx(source_upper - endpoint_offset) - assert curve_maximum <= corrected_upper - assert curve_maximum + endpoint_offset <= source_upper -def test_publication_propagates_schema_v4_rounding_to_mimic_offset( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - offsets = {name: 0.01 for name in ACTIVE_ZERO_JOINTS} - full_precision_offset = -0.06200179363832 - offsets["middle_pip"] = full_precision_offset - session = _make_passed_session( - config, - "20260827_152432", - zero_offsets=offsets, - ) - paths = session_artifact_paths(session, config.serial_number) - payload = json.loads(paths["json"].read_text(encoding="utf-8")) - assert payload["joints"]["middle_pip"]["zero_angles"][ - "urdf_zero_offset_rad" - ] == -0.06200179 - - summary, release_ready = finalize_session_artifacts( - config, - session, - node_status=_passed_node_status(), - ) - - corrected = ET.parse(paths["urdf"]).getroot() - middle_dip = next( - node - for node in corrected.findall("joint") - if node.get("name") == "middle_dip" - ) - assert float(middle_dip.find("mimic").get("offset")) == pytest.approx( - 0.89 * full_precision_offset, - abs=1.0e-14, - ) - assert release_ready is True - assert summary["result"] == "PASS" -def test_publication_rejects_non_joint_urdf_changes(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_112000") - urdf = next(session.glob("*.urdf")) - text = urdf.read_text(encoding="utf-8") - urdf.write_text( - text.replace("hand_base_link", "tampered_base_link", 1), - encoding="utf-8", - ) - - with pytest.raises(ValueError, match="outside active origin.rpy"): - verify_corrected_urdf(config.source_urdf, urdf) - - -def test_publication_requires_every_combination_target(tmp_path: Path) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_113000") - status = _passed_node_status() - missing = G20_COMBINATION_REQUIRED_TARGET_KEYS[0] - status["combination_validation"]["validation_counts"][missing] = 0 - - with pytest.raises(ValueError, match="coverage is incomplete"): - finalize_session_artifacts(config, session, node_status=status) - - -def test_publication_accepts_formal_holdout_when_combination_diagnostic_disabled( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_113100") - status = _passed_node_status() - status["combination_validation"] = { - "enabled": False, - "completed": False, - "completed_poses": 0, - "total_poses": 0, - } - - _summary, release_ready = finalize_session_artifacts( - config, session, node_status=status - ) - - assert release_ready is True - - -def test_publication_accepts_half_lsb_schema_zero_quantisation( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_113200") - json_path = session / f"g20_right_{config.serial_number}_calibration.json" - payload = json.loads(json_path.read_text(encoding="utf-8")) - payload["joints"]["thumb_cmc_pitch"]["zero_angles"][ - "urdf_zero_offset_rad" - ] = 0.01 - 4.9e-9 - atomic_write_json(json_path, payload) - - _summary, release_ready = finalize_session_artifacts( - config, session, node_status=_passed_node_status() - ) - - assert release_ready is True - - -def test_publication_keeps_cmc_runtime_limit_independent_of_certified_zero( - tmp_path: Path, -) -> None: - config = _config(tmp_path, passes=1) - session = _make_passed_session(config, "20260819_113300") - json_path = session / f"g20_right_{config.serial_number}_calibration.json" - payload = json.loads(json_path.read_text(encoding="utf-8")) - payload["joints"]["thumb_cmc_yaw"]["angle_rad"][0] = 2.0 - atomic_write_json(json_path, payload) - - summary, release_ready = finalize_session_artifacts( - config, session, node_status=_passed_node_status() - ) - published = json.loads(json_path.read_text(encoding="utf-8")) - - assert release_ready is True - # CMC has a certified origin, not an assertion that its electrical - # endpoint equals the source-CAD upper coordinate. Its runtime coordinate - # limit therefore remains the protected source value. - assert published["joints"]["thumb_cmc_yaw"]["angle_rad"][0] == 1.57 - assert summary["runtime_limit_clipped_bins"]["thumb_cmc_yaw"] == 1 def test_latest_attempt_pointer_is_atomic_session_binding(tmp_path: Path) -> None: @@ -2153,27 +635,6 @@ def test_fitting_uses_long_watchdog_without_weakening_motion_timeout() -> None: assert _status_timeout_seconds({"state": "RETURN_BASELINE"}) == 90.0 -def test_fitting_announces_state_before_synchronous_model_work() -> None: - spec = SimpleNamespace() - published = [] - node = SimpleNamespace( - profile=SimpleNamespace(sweep_specs=(spec,)), - last_status_publish=0.0, - _publish_status=lambda now: published.append( - (node.state, node.reason, now) - ), - _provisional_fit_failures=lambda _spec: [{"metric": "test_failure"}], - _pause_for_provisional_fit_failure=lambda *_args, **_kwargs: None, - ) - - G20ThreeCameraCalibrationNode._fit_all_curves(node) - - assert len(published) == 1 - assert published[0][0] == "FITTING" - assert published[0][1] == "fitting_3d_axes_and_urdf_zero_offsets" - assert node.last_status_publish == published[0][2] - - def test_stopped_status_heartbeat_has_stable_communication_code() -> None: code, problem, suggestion = classify_error( "MOTION-COMM-303:calibration status stopped", {} @@ -2476,105 +937,6 @@ def test_multiview_progress_marks_occluded_front_base_as_locked() -> None: assert "Tag可见不等于三维位姿有效" in text -def test_device_preflight_defers_tag_gate_until_after_baseline_recovery() -> None: - now = 100.0 - state_times = deque( - (now - 0.98 + index * 0.02 for index in range(50)), maxlen=300 - ) - views = { - name: SimpleNamespace( - camera_info_valid=True, - extrinsics_valid=True, - last_message_at=now, - valid_flags=deque([False] * 30, maxlen=30), - detection_times=deque(maxlen=30), - ) - for name in ("front", "side", "top") - } - node = SimpleNamespace( - extrinsics=object(), - latest_state_u8=tuple([127.0] * 20), - last_state_at=now, - state_receive_times=state_times, - minimum_feedback_hz=25.0, - views=views, - ) - - assert G20ThreeCameraCalibrationNode._all_devices_ready(node, now) - - reset_views: list[str] = [] - node._reset_view_trackers = lambda runtime: reset_views.append(runtime.name) - for name, runtime in views.items(): - runtime.name = name - runtime.detection_times.extend([now - 0.1, now]) - G20ThreeCameraCalibrationNode._finish_startup_baseline_recovery(node) - - assert node.state == "PREFLIGHT" - assert node.startup_baseline_recovered is True - assert node.reason == "waiting_for_baseline_tags_after_recovery" - assert reset_views == ["front", "side", "top"] - assert all(not runtime.valid_flags for runtime in views.values()) - assert all(not runtime.detection_times for runtime in views.values()) - - -def test_startup_state_machine_moves_before_applying_tag_gate() -> None: - node = SimpleNamespace( - state="PREFLIGHT", - reason="waiting_for_devices_and_sdk", - started=False, - startup_baseline_recovered=False, - _all_devices_ready=lambda now: True, - _all_preflight_ready=lambda now: (_ for _ in ()).throw( - AssertionError("Tag gate ran before baseline recovery") - ), - ) - - G20ThreeCameraCalibrationNode._advance(node, 10.0) - - assert node.state == "WAIT_START" - assert node.reason == "call_start_for_baseline_recovery" - - node.state = "PREFLIGHT" - node.started = True - node.startup_baseline_recovered = True - node._all_preflight_ready = lambda now: True - started: list[bool] = [] - node._start_next_sweep = lambda: started.append(True) - - G20ThreeCameraCalibrationNode._advance(node, 11.0) - - assert started == [True] - - -def test_resume_import_waits_until_new_fixed_base_reference_is_locked( - tmp_path: Path, -) -> None: - source = tmp_path / "raw_samples.jsonl" - source.write_text("{}\n", encoding="utf-8") - calls: list[str] = [] - node = SimpleNamespace( - state="PREFLIGHT", - reason="waiting_for_baseline_tags_after_recovery", - started=True, - startup_baseline_recovered=True, - resume_checkpoint_pending=True, - resume_raw_samples_path=source, - _all_preflight_ready=lambda now: True, - _lock_fixed_base_references=lambda: calls.append("lock") or True, - _publish_status=lambda now: calls.append("status"), - _restore_durable_task_checkpoint=( - lambda: calls.append("restore") or 2 - ), - _start_next_sweep=lambda: calls.append("start"), - ) - - G20ThreeCameraCalibrationNode._advance(node, 11.0) - - assert calls == ["lock", "status", "restore", "start"] - assert node.resume_checkpoint_pending is False - assert node.state == "IMPORTING_BASE" - - def test_device_preflight_progress_says_tags_are_checked_after_recovery() -> None: status = { "state": "PREFLIGHT", @@ -2604,22 +966,6 @@ def test_device_preflight_progress_says_tags_are_checked_after_recovery() -> Non assert "本阶段必需" not in text -def test_validation_command_file_has_eight_safe_bounded_poses() -> None: - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - payload = build_mujoco_validation_commands(baseline) - assert len(payload["poses"]) == 8 - assert all( - len(pose["command_u8"]) == 20 - and all(0 <= value <= 255 for value in pose["command_u8"]) - for pose in payload["poses"] - ) - online = _combination_validation_items(baseline) - assert [list(item.command_u8) for item in online] == [ - pose["command_u8"] for pose in payload["poses"] - ] - - 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) @@ -2637,276 +983,3 @@ def test_combination_kinematics_can_use_independent_passive_curve() -> None: independent_mimic_angles=False, ) assert not np.allclose(independent, mimic) - - -def test_combination_angles_follow_baseline_to_target_direction() -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - decreasing = tuple(float(value) for value in range(256)) - increasing = tuple(float(value + 1000) for value in range(256)) - average = tuple(float(value + 500) for value in range(256)) - fits = { - name: JointCurveFit( - angle_rad=average, - decreasing_rad=decreasing, - increasing_rad=increasing, - circle={}, - maximum_monotonic_correction_rad=0.0, - maximum_hysteresis_rad=0.0, - quality={}, - ) - for name in profile.joint_specs - } - items = _combination_validation_items(baseline) - directions = _combination_motor_directions(items, 1, baseline) - - angles = _combination_joint_angles( - profile, fits, items[1].command_u8, directions - ) - - assert angles["thumb_cmc_pitch"] == 160.0 - assert angles["index_mcp_roll"] == 127.0 - assert angles["middle_mcp_roll"] == 127.0 - - -def test_combination_directions_include_previous_return_to_baseline() -> None: - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - items = _combination_validation_items(baseline) - - thumb_pose = _combination_motor_directions(items, 1, baseline) - index_pose = _combination_motor_directions(items, 2, baseline) - - assert thumb_pose[0] == DIRECTION_DECREASING - assert thumb_pose[1] == DIRECTION_DECREASING - assert index_pose[0] == DIRECTION_INCREASING - assert index_pose[5] == DIRECTION_INCREASING - assert index_pose[10] == DIRECTION_INCREASING - assert index_pose[15] == DIRECTION_INCREASING - assert index_pose[1] == DIRECTION_DECREASING - assert index_pose[16] == DIRECTION_DECREASING - - -def test_combination_model_link_is_transformed_into_observer_base() -> None: - model_base_common = np.eye(4) - model_base_common[:3, :3] = Rotation.from_euler( - "xyz", [0.4, -0.2, 0.7] - ).as_matrix() - model_base_common[:3, 3] = [0.3, -0.1, 0.8] - observer_mount = np.eye(4) - observer_mount[:3, :3] = Rotation.from_euler( - "xyz", [-0.3, 0.1, 0.2] - ).as_matrix() - observer_mount[:3, 3] = [0.05, 0.02, -0.01] - observer_base_common = model_base_common @ observer_mount - model_link = np.eye(4) - model_link[:3, :3] = Rotation.from_rotvec([0.0, 0.5, 0.0]).as_matrix() - model_link[:3, 3] = [0.08, 0.03, 0.01] - tag_mount = np.eye(4) - tag_mount[:3, 3] = [0.0, 0.0, 0.02] - observed = ( - np.linalg.inv(observer_base_common) - @ model_base_common - @ model_link - @ tag_mount - ) - - resolved = _model_link_in_observer_base( - observer_base_common, model_base_common, model_link - ) - - assert np.allclose(resolved @ tag_mount, observed) - assert not np.allclose(model_link @ tag_mount, observed) - - -def test_node_drops_imported_task_failing_hard_gates( - tmp_path: Path, -) -> None: - config = _config(tmp_path) - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - source_raw = tmp_path / "old_gates" / "raw_samples.jsonl" - source_raw.parent.mkdir() - rows = [ - { - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "hand_type": "right", - "tag_layout": G20_RIGHT_19_LAYOUT, - "view_tags": { - view: dict(tags) for view, tags in profile.view_tags.items() - }, - "baseline_command_u8": baseline, - "source_urdf_sha256": config.source_urdf_sha256, - "capabilities": sorted(profile.capabilities), - "palm_axis_observers": _palm_axis_observer_schema(profile), - }, - *_fixed_base_reference_rows(), - *_complete_resume_task_rows(profile, profile.sweep_specs[0]), - ] - source_raw.write_text( - "".join(json.dumps(row) + "\n" for row in rows) - ) - current_raw = tmp_path / "new_gates" / "raw_samples.jsonl" - current_raw.parent.mkdir() - current_raw.touch() - sweep_items = [] - for spec in profile.sweep_specs: - sweep_items.extend( - SweepItem(spec, cycle, direction) - for cycle in range(4) - for direction in ("decreasing", "increasing") - ) - node = SimpleNamespace( - resume_raw_samples_path=source_raw, - raw_path=current_raw, - hand_type="right", - profile=profile, - baseline_command=tuple(baseline), - source_urdf_path=config.source_urdf, - repetitions=4, - minimum_sweep_bins=32, - records_by_joint={name: [] for name in profile.record_joints}, - baseline_records_by_joint={ - name: [] for name in profile.record_joints - }, - command_records_by_joint={name: [] for name in profile.record_joints}, - palm_axis_records_by_source={ - observer.source_name: [] - for observer in profile.palm_axis_observers - }, - sweep_index=0, - sweep_items=sweep_items, - resumed_task_keys=(), - resume_source_session="", - views=_resume_reference_views(), - fixed_base_maximum_corner_drift_px=2.0, - recalibration_scope="full", - zero_profile=get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT), - trajectory_maximum_plane_rms_m=0.004, - trajectory_maximum_radial_rms_m=0.004, - trajectory_minimum_radius_m=0.003, - trajectory_minimum_arc_rad=math.radians(15.0), - image_trajectory_maximum_radial_rms_px=2.0, - image_trajectory_maximum_radial_p95_px=3.5, - image_trajectory_minimum_radius_px=20.0, - trajectory_maximum_cycle_travel_difference_rad=math.radians(3.0), - passive_maximum_cycle_travel_difference_rad=math.radians(10.0), - maximum_monotonic_correction_rad=math.radians(2.0), - maximum_hysteresis_rad=math.radians(5.0), - baseline_maximum_hysteresis_rad=math.radians(0.5), - passive_maximum_monotonic_correction_rad=math.radians(3.0), - passive_maximum_hysteresis_rad=math.radians(7.5), - axis_maximum_plane_rms_m=0.003, - passive_axis_maximum_plane_rms_m=0.004, - axis_maximum_radial_rms_m=0.003, - axis_maximum_pose_line_rms_m=0.001, - axis_maximum_rotation_circle_difference_rad=math.radians(1.0), - active_maximum_rotation_orthogonal_rms_rad=math.radians(2.5), - passive_maximum_rotation_orthogonal_rms_rad=math.radians(7.5), - zero_maximum_axis_cycle_difference_rad=math.radians(0.75), - maximum_state_image_skew_ns=50_000_000.0, - minimum_detection_rate=0.95, - ) - node._fit_joint_records = lambda name, records, relaxed=False: ( - G20ThreeCameraCalibrationNode._fit_joint_records( - node, name, records, relaxed=relaxed - ) - ) - node._fit_axis_measurement = lambda name, cycle: ( - G20ThreeCameraCalibrationNode._fit_axis_measurement(node, name, cycle) - ) - - count = G20ThreeCameraCalibrationNode._restore_durable_task_checkpoint( - node - ) - - # Trajectory-free synthetic rows cannot pass the hard gates: the task is - # dropped at import time instead of failing the final fit at the very - # end and dragging the session back to re-collect it. - assert count == 0 - assert node.sweep_index == 0 - assert node.resumed_task_keys == () - assert all( - not records for records in node.records_by_joint.values() - ) - restored = [ - json.loads(line) for line in current_raw.read_text().splitlines() - ] - import_record = next( - row - for row in restored - if row["kind"] == "resume_checkpoint_import" - ) - assert import_record["revalidation_dropped_tasks"] - assert ( - import_record["revalidation_dropped_tasks"][0]["task"] - == profile.sweep_specs[0].key - ) - - -def test_sparse_resume_revalidates_against_only_retained_dependencies( - monkeypatch: pytest.MonkeyPatch, -) -> None: - profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT) - pinky_key = "pinky_roll_multiview" - ring_key = "ring_roll_multiview" - pinky_spec = next( - spec for spec in profile.sweep_specs if spec.key == pinky_key - ) - ring_spec = next(spec for spec in profile.sweep_specs if spec.key == ring_key) - records_by_joint = {name: [] for name in profile.record_joints} - baseline_records_by_joint = {name: [] for name in profile.record_joints} - command_records_by_joint = {name: [] for name in profile.record_joints} - for joint_name in (*pinky_spec.joints, *ring_spec.joints): - records_by_joint[joint_name].append({"task_name": "fixture"}) - - node = SimpleNamespace( - state="", - profile=profile, - sweep_items=[ - SimpleNamespace(spec=pinky_spec), - SimpleNamespace(spec=ring_spec), - ], - records_by_joint=records_by_joint, - baseline_records_by_joint=baseline_records_by_joint, - command_records_by_joint=command_records_by_joint, - palm_axis_records_by_source={ - observer.source_name: [] - for observer in profile.palm_axis_observers - }, - ) - - def failures(_node, spec, *, include_view_validity): - assert include_view_validity is False - if spec.key == pinky_key: - return [{"joint": pinky_spec.joints[0], "metric": "fixture_failure"}] - pinky_records_remain = any( - records_by_joint[joint_name] for joint_name in pinky_spec.joints - ) - return [] if pinky_records_remain else [ - {"joint": ring_spec.joints[0], "metric": "missing_reference"} - ] - - monkeypatch.setattr( - G20ThreeCameraCalibrationNode, - "_provisional_fit_failures", - failures, - ) - - accepted, dropped = ( - G20ThreeCameraCalibrationNode._revalidate_imported_tasks( - node, - [pinky_key, ring_key], - allow_sparse=True, - ) - ) - - assert accepted == [] - assert [failure["task"] for failure in dropped] == [pinky_key, ring_key] - assert all( - not records_by_joint[joint_name] - for joint_name in (*pinky_spec.joints, *ring_spec.joints) - ) diff --git a/src/linkerhand_calibration/test/test_golden_regression.py b/src/linkerhand_calibration/test/test_golden_regression.py index f5b76ff..7e1a367 100644 --- a/src/linkerhand_calibration/test/test_golden_regression.py +++ b/src/linkerhand_calibration/test/test_golden_regression.py @@ -2,7 +2,7 @@ import hashlib import json from pathlib import Path -from linkerhand_calibration.models.g20.golden_regression import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.golden_regression import ( validate_golden_sessions, ) diff --git a/src/linkerhand_calibration/test/test_l6_right_profile.py b/src/linkerhand_calibration/test/test_l6_right_profile.py index feb3ecb..9d05339 100644 --- a/src/linkerhand_calibration/test/test_l6_right_profile.py +++ b/src/linkerhand_calibration/test/test_l6_right_profile.py @@ -1,57 +1,24 @@ from __future__ import annotations from pathlib import Path -import json import re -from types import SimpleNamespace -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, - default_input_topic, -) from linkerhand_calibration.core import validate_profile -from linkerhand_calibration.models.l6.artifacts import ( - atomic_write_json, - build_l6_left_transferred_runtime_payload, - build_l6_urdf_input_payload, - build_l6_runtime_payload, - load_l6_urdf_input, - validate_l6_runtime_payload, -) -from linkerhand_calibration.models.l6.fitting import fit_l6_session -from linkerhand_calibration.models.l6.motion import cosine_position_trajectory_u8 -from linkerhand_calibration.models.l6.node import ( - L6ThreeCameraCalibrationNode, - MotionStep, -) -from linkerhand_calibration.models.l6.pipeline import ( - accepted_records_by_joint, - canonical_feedback_command_u8, - finalize_l6_session, -) -from linkerhand_calibration.models.l6.profile import ( - ACTIVE_JOINTS, +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, - PASSIVE_JOINTS, - TRANSFERRED_ACTIVE_SOURCE_BY_JOINT, - TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, build_typed_profile, ) -from linkerhand_calibration.models.l6.runner import render_l6_progress_zh -from linkerhand_calibration.models.l6.urdf import ( - write_l6_corrected_urdf, - write_l6_left_from_right_calibration, -) +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.models.g20.zero_solver import UrdfKinematicModel +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.zero_solver import UrdfKinematicModel from linkerhand_calibration.product import load_product_config, sha256_file @@ -271,10 +238,10 @@ def test_l6_profile_declares_six_channels_eight_tags_and_partial_scope() -> None assert profile.artifacts.publication_pointer == "latest_partial_passed" assert profile.zero.coupling_model_by_joint == { "rh_thumb_dip": "linear_mimic", - "rh_pinky_dip": "quadratic_runtime", - "rh_index_dip": "quadratic_runtime", - "rh_middle_dip": "quadratic_runtime", - "rh_ring_dip": "quadratic_runtime", + "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 @@ -338,110 +305,6 @@ def test_l6_six_second_cosine_trajectory_is_monotonic_and_smooth() -> None: assert cosine_position_trajectory_u8(255.0, 127.5, 1.5, 6.0)[2] == pytest.approx(3.0) -def test_l6_node_streams_the_six_second_trajectory_without_command_jumps() -> None: - published: list[list[int]] = [] - fake = SimpleNamespace( - step_started_at=0.0, - step_start_state_u8=(255.0,) * 6, - command_trajectory_full_range_seconds=6.0, - step_last_command_u8=None, - step_trajectory_phase=0.0, - step_requested_u8=255.0, - _publish_command=lambda values: published.append(values), - ) - step = MotionStep("sweep", "thumb_pitch_dip_front", 0, 0, 1, 0, "decreasing") - for tick in range(601): - L6ThreeCameraCalibrationNode._advance_step_trajectory( - fake, step, tick / 100.0 - ) - - channel = [command[0] for command in published] - assert channel[0] == 255 - assert channel[-1] == 0 - assert max(abs(right - left) for left, right in zip(channel, channel[1:])) <= 1 - assert all(command[1:] == [255] * 5 for command in published) - - -def _l6_sweep_quality_fake(tmp_path: Path) -> tuple[SimpleNamespace, MotionStep]: - task_key = "pinky_pitch_dip_side" - feedback = np.linspace(255.0, 0.0, 188) - fake = SimpleNamespace( - raw_records=[ - { - "task_name": task_key, - "cycle": 3, - "direction": "decreasing", - "attempt": 3, - "joint": "rh_pinky_mcp_pitch", - "feedback_u8": float(value), - } - for value in feedback - ], - raw_path=tmp_path / "raw_samples.jsonl", - step_required_roles=("side_base", "pinky_pitch", "pinky_dip"), - step_total_frames=211, - step_tag_seen_frames={ - "side_base": 211, - "pinky_pitch": 210, - "pinky_dip": 208, - }, - # Every Tag independently clears 95%, while the fully joined frames - # match the real 20260901_181435 sweep at about 89.1%. - step_tag_quality_frames={ - "side_base": 209, - "pinky_pitch": 205, - "pinky_dip": 202, - }, - step_all_tags_quality_frames=190, - step_pnp_valid_frames=189, - step_state_sync_frames=188, - step_valid_frames=188, - step_rejection_counts={}, - minimum_sweep_frames=40, - minimum_state_span_u8=240.0, - minimum_sweep_bins=32, - maximum_bin_gap=16, - minimum_detection_rate=0.95, - minimum_joint_frame_rate=0.85, - minimum_feedback_hz=25.0, - profile=build_typed_profile(), - _task=lambda _key: SimpleNamespace( - key=task_key, - joints=("rh_pinky_mcp_pitch", "rh_pinky_dip"), - ), - _feedback_hz=lambda: 58.9, - ) - fake._step_observation_metrics = lambda: ( - L6ThreeCameraCalibrationNode._step_observation_metrics(fake) - ) - step = MotionStep( - "sweep", task_key, 5, 0, 1, 3, "decreasing", attempt=3 - ) - return fake, step - - -def test_l6_quality_gates_each_tag_separately_from_joined_frames( - tmp_path: Path, -) -> None: - fake, step = _l6_sweep_quality_fake(tmp_path) - metrics = L6ThreeCameraCalibrationNode._step_observation_metrics(fake) - assert metrics["tag_detection_rate"] == pytest.approx(202 / 211) - assert metrics["joint_frame_rate"] == pytest.approx(188 / 211) - - # This is a valid sweep: every individual Tag is >=95%, the joined frame - # rate is >=85%, and all existing trajectory coverage gates still pass. - L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step) - - -def test_l6_low_tag_rate_is_diagnostic_when_samples_are_observable(tmp_path: Path) -> None: - fake, step = _l6_sweep_quality_fake(tmp_path) - fake.step_tag_quality_frames["pinky_dip"] = 190 - L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step) - quality = json.loads(fake.raw_path.read_text().splitlines()[-1]) - assert quality["failures"] == [] - assert any(value.startswith("tag_rate=") for value in quality["warnings"]) - - def test_l6_operator_progress_shows_exact_failed_sweep_metric() -> None: text = render_l6_progress_zh( { @@ -480,505 +343,3 @@ def test_l6_product_config_and_immutable_source_hash_are_valid() -> None: assert product.camera_extrinsics_sha256 == ( "dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80" ) - - -def test_l6_synthetic_fit_recovers_travel_zero_and_mimic() -> None: - records = accepted_records_by_joint(_synthetic_records()) - result = fit_l6_session(SOURCE, records) - for name, expected in TRAVELS.items(): - assert result.travels_rad[name] == pytest.approx(expected, abs=2.0e-4) - source_limits = { - joint.get("name"): { - field: float(joint.find("limit").get(field)) - for field in ("lower", "upper") - } - for joint in ET.parse(SOURCE).getroot().findall("joint") - if joint.find("limit") is not None - } - for name, expected_offset in THUMB_ZERO_OFFSETS.items(): - assert result.zero_offsets_rad[name] == pytest.approx( - expected_offset, abs=2.0e-4 - ) - assert result.zero_method_by_joint[name] == ( - "urdf_serial_axis_geometry" - ) - assert result.zero_fallback_reason_by_joint == {} - assert result.zero_offsets_rad["rh_pinky_mcp_pitch"] == pytest.approx( - source_limits["rh_pinky_mcp_pitch"]["lower"], - abs=2.0e-4, - ) - assert result.zero_method_by_joint["rh_pinky_mcp_pitch"] == ( - "mechanical_lower_endpoint" - ) - for name, expected in MULTIPLIERS.items(): - assert result.mimic_fits[name].multiplier == pytest.approx(expected, abs=2.0e-4) - assert result.mimic_fits[name].maximum_cycle_range < 1.0e-6 - - -def test_l6_pitch_geometry_bound_centres_measured_range_in_cad_range() -> None: - records = accepted_records_by_joint( - _synthetic_records( - thumb_zero_offsets={ - "rh_thumb_cmc_roll": THUMB_ZERO_OFFSETS[ - "rh_thumb_cmc_roll" - ], - "rh_thumb_cmc_pitch": 1.2, - } - ) - ) - result = fit_l6_session(SOURCE, records) - source_pitch = next( - joint - for joint in ET.parse(SOURCE).getroot().findall("joint") - if joint.get("name") == "rh_thumb_cmc_pitch" - ) - expected = 0.5 * ( - float(source_pitch.find("limit").get("lower")) - + float(source_pitch.find("limit").get("upper")) - - TRAVELS["rh_thumb_cmc_pitch"] - ) - assert result.zero_offsets_rad["rh_thumb_cmc_pitch"] == pytest.approx( - expected, abs=2.0e-4 - ) - assert result.zero_method_by_joint["rh_thumb_cmc_pitch"] == ( - "cad_range_center_after_geometry_rejection" - ) - assert result.zero_fallback_reason_by_joint == { - "rh_thumb_cmc_pitch": "zero_offset_reached_diagnostic_bound" - } - # A pitch-only fallback must not replace the independently observed roll. - assert result.zero_offsets_rad["rh_thumb_cmc_roll"] == pytest.approx( - THUMB_ZERO_OFFSETS["rh_thumb_cmc_roll"], abs=2.0e-4 - ) - - -def test_l6_fit_recovers_stable_nonlinear_pinky_coupling( - tmp_path: Path, -) -> None: - result = fit_l6_session( - SOURCE, accepted_records_by_joint(_synthetic_nonlinear_pinky_records()) - ) - coupling = result.mimic_fits["rh_pinky_dip"] - assert coupling.model == "quadratic_runtime" - assert coupling.coefficients == pytest.approx((1.2, -0.2), abs=2.0e-4) - assert coupling.maximum_cycle_prediction_range_rad < 1.0e-6 - assert coupling.residual_p95_rad < 2.0e-5 - correction = write_l6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path, - serial_number="L6_NONLINEAR_TEST", - result=result, - timestamp="20260901_120002", - ) - root = ET.parse(correction.path).getroot() - pinky = next( - joint - for joint in root.findall("joint") - if joint.get("name") == "rh_pinky_dip" - ) - pinky_mimic = pinky.find("mimic") - assert pinky_mimic is not None - assert float(pinky_mimic.get("multiplier")) == pytest.approx( - (1.2 * TRAVELS["rh_pinky_mcp_pitch"] - - 0.2 * TRAVELS["rh_pinky_mcp_pitch"] ** 2) - / TRAVELS["rh_pinky_mcp_pitch"], - abs=2.0e-4, - ) - equality = next( - joint - for joint in root.findall("./mujoco/equality/joint") - if joint.get("joint1") == "rh_pinky_dip" - ) - assert [float(value) for value in equality.get("polycoef").split()] == ( - pytest.approx([0.0, 1.2, -0.2, 0.0, 0.0, 0.0], abs=2.0e-4) - ) - for finger in ("index", "middle", "ring"): - transferred = next( - joint - for joint in root.findall("./mujoco/equality/joint") - if joint.get("joint1") == f"rh_{finger}_dip" - ) - assert [ - float(value) for value in transferred.get("polycoef").split() - ] == pytest.approx( - [0.0, 1.2, -0.2, 0.0, 0.0, 0.0], abs=2.0e-4 - ) - - -def test_l6_feedback_endpoint_deadband_is_canonicalized_for_fitting() -> None: - assert [canonical_feedback_command_u8(value) for value in (0, 1, 2)] == [0] * 3 - assert canonical_feedback_command_u8(3) == 3 - assert canonical_feedback_command_u8(252) == 252 - assert [canonical_feedback_command_u8(value) for value in (253, 254, 255)] == [255] * 3 - - records = _synthetic_records() - for row in records: - if row["feedback_u8"] == 0.0: - row["feedback_u8"] = 1.0 - elif row["feedback_u8"] == 255.0: - row["feedback_u8"] = 254.0 - result = fit_l6_session(SOURCE, accepted_records_by_joint(records)) - for name, expected in TRAVELS.items(): - assert result.travels_rad[name] == pytest.approx(expected, abs=2.0e-4) - - -def test_l6_urdf_writer_changes_only_authorized_joint_fields(tmp_path: Path) -> None: - result = fit_l6_session(SOURCE, accepted_records_by_joint(_synthetic_records())) - correction = write_l6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path, - serial_number="L6_TEST", - result=result, - timestamp="20260901_120000", - ) - original = SOURCE.read_text(encoding="utf-8") - corrected = correction.path.read_text(encoding="utf-8") - original_root = ET.parse(SOURCE).getroot() - original_joints = { - joint.get("name"): joint for joint in original_root.findall("joint") - } - root = ET.parse(correction.path).getroot() - joints = {joint.get("name"): joint for joint in root.findall("joint")} - # The already reviewed DIP geometry and limits stay byte-equivalent; only - # the measured mimic multiplier and matching MuJoCo equality may change. - for passive_name in PASSIVE_JOINTS: - passive = joints[passive_name] - source_passive = original_joints[passive_name] - for element in ("origin", "axis", "limit", "parent", "child"): - assert passive.find(element).attrib == source_passive.find(element).attrib - assert passive.find("mimic").get("offset") == ( - source_passive.find("mimic").get("offset") - ) - for finger in ("index", "middle", "ring"): - active_name = f"rh_{finger}_mcp_pitch" - passive_name = f"rh_{finger}_dip" - active = joints[active_name] - source_active = original_joints[active_name] - assert active.find("origin").get("xyz") == source_active.find("origin").get("xyz") - assert active.find("origin").get("rpy") == source_active.find("origin").get("rpy") - assert active.find("axis").attrib == source_active.find("axis").attrib - assert active.find("parent").attrib == source_active.find("parent").attrib - assert active.find("child").attrib == source_active.find("child").attrib - assert float(active.find("limit").get("lower")) == 0.0 - assert float(active.find("limit").get("upper")) == pytest.approx( - TRAVELS["rh_pinky_mcp_pitch"], abs=2.0e-4 - ) - for field in ("effort", "velocity"): - assert active.find("limit").get(field) == source_active.find("limit").get(field) - passive = joints[passive_name] - source_passive = original_joints[passive_name] - for element in ("origin", "axis", "limit", "parent", "child"): - assert passive.find(element).attrib == source_passive.find(element).attrib - assert passive.find("mimic").get("joint") == active_name - assert float(passive.find("mimic").get("multiplier")) == pytest.approx( - MULTIPLIERS["rh_pinky_dip"], abs=2.0e-4 - ) - assert correction.origin_offsets_rad[active_name] == pytest.approx( - correction.origin_offsets_rad["rh_pinky_mcp_pitch"] - ) - assert correction.origin_offsets_rad[active_name] == pytest.approx(0.0) - pinky = joints["rh_pinky_mcp_pitch"] - source_pinky = original_joints["rh_pinky_mcp_pitch"] - assert pinky.find("origin").get("rpy") == source_pinky.find("origin").get("rpy") - assert correction.origin_offsets_rad["rh_pinky_mcp_pitch"] == pytest.approx(0.0) - assert result.zero_method_by_joint["rh_pinky_mcp_pitch"] == ( - "mechanical_lower_endpoint" - ) - roll = joints["rh_thumb_cmc_roll"] - assert float(roll.find("limit").get("lower")) == 0.0 - assert float(roll.find("limit").get("upper")) == pytest.approx(1.34, abs=2.0e-4) - assert correction.origin_offsets_rad["rh_thumb_cmc_roll"] == pytest.approx( - THUMB_ZERO_OFFSETS["rh_thumb_cmc_roll"], abs=2.0e-4 - ) - assert correction.origin_offsets_rad["rh_thumb_cmc_pitch"] == pytest.approx( - THUMB_ZERO_OFFSETS["rh_thumb_cmc_pitch"], abs=2.0e-4 - ) - assert roll.find("origin").get("xyz") == "0.0078133 0.030812 0.025678" - assert float( - joints["rh_thumb_dip"].find("mimic").get("multiplier") - ) == pytest.approx(MULTIPLIERS["rh_thumb_dip"]) - assert joints["rh_pinky_dip"].find("mimic") is not None - assert correction.explicit_runtime_joints == frozenset( - {"rh_index_dip", "rh_middle_dip", "rh_ring_dip", "rh_pinky_dip"} - ) - equalities = { - joint.get("joint1"): joint - for joint in root.findall("./mujoco/equality/joint") - } - assert float( - equalities["rh_thumb_dip"].get("polycoef").split()[1] - ) == pytest.approx(MULTIPLIERS["rh_thumb_dip"]) - pinky_polycoef = [ - float(value) - for value in equalities["rh_pinky_dip"].get("polycoef").split() - ] - assert pinky_polycoef == pytest.approx( - [0.0, MULTIPLIERS["rh_pinky_dip"], 0.0, 0.0, 0.0, 0.0], - abs=2.0e-4, - ) - assert len(list((tmp_path / "meshes").iterdir())) == 13 - assert sha256_file(SOURCE) == ( - "298c1fbf5189648911426f530b50bdbeea4830cab9c54e20f46c532485df4666" - ) - - -def test_l6_right_corrections_are_mirrored_onto_left_cad(tmp_path: Path) -> None: - result = fit_l6_session(SOURCE, accepted_records_by_joint(_synthetic_records())) - right = write_l6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path / "right", - serial_number="RIGHT_TEST", - result=result, - timestamp="20260903_120000", - ).path - left_path = write_l6_left_from_right_calibration( - source_left_urdf=LEFT_SOURCE, - source_right_urdf=SOURCE, - calibrated_right_urdf=right, - destination_urdf=tmp_path / "left" / "l6_left_transferred.urdf", - ) - original = { - str(joint.get("name")): joint - for joint in ET.parse(LEFT_SOURCE).getroot().findall("joint") - if joint.get("type") == "revolute" - } - transferred_root = ET.parse(left_path).getroot() - transferred = { - str(joint.get("name")): joint - for joint in transferred_root.findall("joint") - if joint.get("type") == "revolute" - } - corrected_right = { - str(joint.get("name")): joint - for joint in ET.parse(right).getroot().findall("joint") - if joint.get("type") == "revolute" - } - for left_name, joint in transferred.items(): - right_name = left_name.replace("lh_", "rh_", 1) - assert joint.find("origin").get("xyz") == original[left_name].find( - "origin" - ).get("xyz") - assert joint.find("axis").attrib == original[left_name].find("axis").attrib - assert joint.find("limit").get("lower") == corrected_right[ - right_name - ].find("limit").get("lower") - assert joint.find("limit").get("upper") == corrected_right[ - right_name - ].find("limit").get("upper") - if joint.find("mimic") is not None: - assert joint.find("mimic").get("multiplier") == corrected_right[ - right_name - ].find("mimic").get("multiplier") - for name, expected in THUMB_ZERO_OFFSETS.items(): - left_name = name.replace("rh_", "lh_", 1) - source_rotation = Rotation.from_euler( - "xyz", - [float(value) for value in original[left_name].find("origin").get( - "rpy" - ).split()], - ) - corrected_rotation = Rotation.from_euler( - "xyz", - [float(value) for value in transferred[left_name].find("origin").get( - "rpy" - ).split()], - ) - axis = np.asarray([ - float(value) - for value in original[left_name].find("axis").get("xyz").split() - ]) - axis /= np.linalg.norm(axis) - applied = float((source_rotation.inv() * corrected_rotation).as_rotvec() @ axis) - assert applied == pytest.approx(expected, abs=1.0e-6) - left_equalities = { - str(node.get("joint1")): node.get("polycoef") - for node in transferred_root.findall("./mujoco/equality/joint") - } - right_equalities = { - str(node.get("joint1")).replace("rh_", "lh_", 1): node.get("polycoef") - for node in ET.parse(right).getroot().findall("./mujoco/equality/joint") - } - assert left_equalities == right_equalities - - right_payload = build_l6_runtime_payload( - serial_number="RIGHT_TEST", - source_urdf=SOURCE, - result=result, - protected_inputs={ - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - }, - ) - left_payload = build_l6_left_transferred_runtime_payload( - right_payload=right_payload, - source_left_urdf=LEFT_SOURCE, - transferred_left_urdf=left_path, - serial_number="LEFT_TRANSFER_TEST", - ) - validate_l6_runtime_payload(left_payload) - mapper = CalibratedCommandMapper(left_payload, expected_side="left") - assert mapper.profile_id == "L6/left/l6_left_transferred_8/v1" - assert set(mapper.urdf_joint_names) == set(transferred) - assert left_payload["joints"]["lh_pinky_dip"]["source_joint"] == ( - "lh_pinky_mcp_pitch" - ) - assert left_payload["joints"]["lh_index_dip"][ - "transferred_from_joint" - ] == "lh_pinky_dip" - assert left_payload["quality"]["transfer_provenance"][ - "left_hand_measured" - ] is False - - -def test_l6_schema_v6_bridge_uses_feedback_and_rh_joint_names() -> None: - result = fit_l6_session(SOURCE, accepted_records_by_joint(_synthetic_records())) - hashes = { - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - } - payload = build_l6_runtime_payload( - serial_number="L6_TEST", - source_urdf=SOURCE, - result=result, - protected_inputs=hashes, - ) - validate_l6_runtime_payload(payload) - assert set(payload["joints"]) == set(ACTIVE_JOINTS + PASSIVE_JOINTS) - assert payload["joints"]["rh_thumb_dip"]["urdf_mimic_enabled"] is True - assert payload["joints"]["rh_pinky_dip"]["urdf_mimic_enabled"] is True - assert payload["joints"]["rh_pinky_dip"]["coupling_model"] == ( - "quadratic_runtime" - ) - assert payload["joints"]["rh_pinky_dip"]["urdf_mimic_policy"] == ( - "endpoint_linear_fallback" - ) - assert payload["joints"]["rh_pinky_dip"]["mimic_multiplier"] == ( - pytest.approx(MULTIPLIERS["rh_pinky_dip"], abs=2.0e-4) - ) - for target, donor in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items(): - assert payload["joints"][target]["calibration_status"] == ( - "transferred_static_dynamic" - ) - assert payload["joints"][target]["transferred_from_joint"] == donor - assert payload["joints"][target]["angle_rad"] == payload["joints"][donor]["angle_rad"] - for target, donor in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items(): - assert payload["joints"][target]["calibration_status"] == ( - "transferred_dynamic_cad_static" - ) - assert payload["joints"][target]["transferred_from_joint"] == donor - assert payload["joints"][target]["angle_rad"] == payload["joints"][donor]["angle_rad"] - mapper = CalibratedCommandMapper(payload, expected_side="right") - assert mapper.profile_id == KEY.profile_id - assert mapper.input_domain == "feedback_u8" - mapped = dict(zip(mapper.urdf_joint_names, mapper.map_positions([0] * 6))) - assert mapped["rh_thumb_cmc_roll"] == pytest.approx(TRAVELS["rh_thumb_cmc_roll"], abs=2.0e-4) - # Accept one-release feedback from an older SDK that mislabeled channel 2. - names = list(COMMAND_NAMES) - names[1] = "thumb_cmc_yaw" - assert mapper.map_positions([255] * 6, names) == mapper.map_positions([255] * 6) - assert default_input_topic("right", "feedback_u8", "L6") == ( - "/l6/cb_right_hand_state" - ) - - -def test_l6_online_and_offline_finalization_are_identical(tmp_path: Path) -> None: - records = _synthetic_records() - hashes = { - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - } - online = tmp_path / "online" - offline = tmp_path / "offline" - first, _, first_urdf = finalize_l6_session( - session_dir=online, - serial_number="L6_TEST", - source_urdf=SOURCE, - protected_inputs=hashes, - records=records, - publish=False, - timestamp="20260901_120001", - ) - second, _, second_urdf = finalize_l6_session( - session_dir=offline, - serial_number="L6_TEST", - source_urdf=SOURCE, - protected_inputs=hashes, - records=records, - publish=False, - timestamp="20260901_120001", - ) - assert first == second - assert first_urdf.path.read_bytes() == second_urdf.path.read_bytes() - - -def test_l6_json_handoff_preserves_existing_urdf_bytes(tmp_path: Path) -> None: - records = _synthetic_records() - result = fit_l6_session(SOURCE, accepted_records_by_joint(records)) - direct = write_l6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path / "direct", - serial_number="L6_JSON_HANDOFF", - result=result, - timestamp="20260902_140000", - ) - input_path = tmp_path / "l6_urdf_correction_input.json" - atomic_write_json( - input_path, - build_l6_urdf_input_payload( - serial_number="L6_JSON_HANDOFF", - source_urdf=SOURCE, - result=result, - ), - ) - reloaded = load_l6_urdf_input( - input_path, - source_urdf=SOURCE, - serial_number="L6_JSON_HANDOFF", - ) - via_json = write_l6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path / "via_json", - serial_number="L6_JSON_HANDOFF", - result=reloaded, - timestamp="20260902_140000", - ) - - assert direct.path.read_bytes() == via_json.path.read_bytes() - - -def test_l6_legacy_relative_only_session_cannot_publish_thumb_zero( - tmp_path: Path, -) -> None: - geometric_fields = { - "relative_translation_xyz_m", - "parent_pose_common", - "child_pose_common", - "view_normal_common_xyz", - "camera_center_common_xyz_m", - "state_u8", - } - records = [ - {key: value for key, value in row.items() if key not in geometric_fields} - for row in _synthetic_records() - ] - with pytest.raises(ValueError, match="session must be reacquired"): - finalize_l6_session( - session_dir=tmp_path / "legacy", - serial_number="L6_LEGACY", - source_urdf=SOURCE, - protected_inputs={ - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - }, - records=records, - publish=False, - timestamp="20260901_120003", - ) diff --git a/src/linkerhand_calibration/test/test_native_rotation_curve.py b/src/linkerhand_calibration/test/test_native_rotation_curve.py new file mode 100644 index 0000000..65fd756 --- /dev/null +++ b/src/linkerhand_calibration/test/test_native_rotation_curve.py @@ -0,0 +1,72 @@ +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 index b9dc674..7585f22 100644 --- a/src/linkerhand_calibration/test/test_o12_axis_residual_policy.py +++ b/src/linkerhand_calibration/test/test_o12_axis_residual_policy.py @@ -6,7 +6,7 @@ import numpy as np import pytest from scipy.spatial.transform import Rotation -from linkerhand_calibration.models.g20.zero_solver import ( +from linkerhand_calibration.core.fitting.spatial import ( _fit_axis_point_from_pose_trajectory, fit_joint_axis_measurement, ) diff --git a/src/linkerhand_calibration/test/test_o12_full_hand_zero.py b/src/linkerhand_calibration/test/test_o12_full_hand_zero.py index 82a9fbe..b724fb3 100644 --- a/src/linkerhand_calibration/test/test_o12_full_hand_zero.py +++ b/src/linkerhand_calibration/test/test_o12_full_hand_zero.py @@ -1,23 +1,23 @@ """Non-zero recovery through G20 geometry, not zero-only CAD fixtures.""" -from dataclasses import replace import numpy as np import pytest from scipy.spatial.transform import Rotation -from linkerhand_calibration.models.g20.profile import JointCurveFit -from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.models.o12.profile import ( +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.models.o12.zero import ( +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.models.o12.kinematics import PASSIVE_SDK_SOURCE_BY_JOINT +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 @@ -87,7 +87,7 @@ def test_profile_authorizes_every_observed_active_zero_without_endpoint_assumpti 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.models.g20.zero_solver import get_right_19_thumb_zero_profile + 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 not get_right_19_thumb_zero_profile().project_axis_gauge_before_image @@ -181,8 +181,8 @@ def test_active_parallel_axis_direction_bias_does_not_become_a_zero(): def test_rotation_only_data_cannot_be_published_as_full_spatial_calibration(): - from linkerhand_calibration.models.o12.fitting import fit_o12_session - with pytest.raises(O12SpatialZeroError, match="requires pose observations"): + 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) @@ -217,68 +217,10 @@ def test_independent_fourth_cycle_cannot_define_or_hide_a_bad_zero(): assert error.value.diagnostics["passed"] is False -def test_full_zero_writeback_and_ring_transfer_preserve_each_cad_frame(full_solution, tmp_path): - from linkerhand_calibration.models.o12.fitting import fit_o12_session - from linkerhand_calibration.models.o12.urdf import write_o12_corrected_urdf - from linkerhand_calibration.models.o12.artifacts import ( - build_o12_runtime_payload, validate_o12_runtime_payload_against_urdf, - ) - truth, solved = full_solution - offsets = { - **truth, - "thumb_mcp": 0.0, - "ring_mcp_pitch": truth["pinky_mcp_pitch"], - } - fit = replace(fit_o12_session(SOURCE_URDF, _synthetic_records()), - zero_offsets_rad=offsets, full_hand_zero_result=solved, - zero_method_by_joint={ - n: ( - "source_cad_zero_profile_excluded" - if n == "thumb_mcp" - else "urdf_serial_axis_geometry" - ) - for n in offsets - }) - written = write_o12_corrected_urdf(source_urdf=SOURCE_URDF, - output_directory=tmp_path, serial_number="FULL", result=fit) - before, after = UrdfKinematicModel(SOURCE_URDF), UrdfKinematicModel(written.path) - for q in ({}, {n: .3 for n in offsets}): - for name in offsets: - assert after.link_transform(name, zero_offsets={}, joint_angles=q) == pytest.approx( - before.link_transform(name, zero_offsets=offsets, joint_angles=q), abs=1.e-8) - hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields} - payload = build_o12_runtime_payload(serial_number="FULL", source_urdf=SOURCE_URDF, - result=fit, protected_inputs=hashes, passed=True) - validate_o12_runtime_payload_against_urdf(payload, written.path, source_urdf=SOURCE_URDF) - assert payload["calibration_scope"] == "full_dynamic_except_thumb_mcp_static_zero" - assert payload["quality"]["cad_static_joints_not_measured"] == ["thumb_mcp"] - assert set(payload["quality"]["static_zero_exclusions"]) == {"thumb_mcp"} - assert payload["joints"]["thumb_mcp"]["calibration_status"] == "measured_dynamic_cad_static" - assert payload["joints"]["thumb_mcp"]["static_urdf_origin_offset_rad"] == 0.0 - assert payload["quality"]["full_hand_spatial_zero"]["passed"] - invalid = replace( - fit, - zero_offsets_rad={**fit.zero_offsets_rad, "thumb_mcp": 0.01}, - ) - with pytest.raises(ValueError, match="retain immutable source-CAD"): - write_o12_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path / "invalid", - serial_number="INVALID", - result=invalid, - ) - with pytest.raises(ValueError, match="retain immutable source-CAD"): - build_o12_runtime_payload( - serial_number="INVALID", - source_urdf=SOURCE_URDF, - result=invalid, - protected_inputs=hashes, - passed=True, - ) def test_failed_spatial_solve_writes_evidence_without_replacing_published_result(monkeypatch, tmp_path): - from linkerhand_calibration.models.o12 import pipeline + from linkerhand_calibration.runtime.artifacts import finalization import json previous = tmp_path / "previous" previous.mkdir() @@ -286,57 +228,14 @@ def test_failed_spatial_solve_writes_evidence_without_replacing_published_result pointer.symlink_to(previous, target_is_directory=True) diagnostic = {"passed": False, "stage": "axis_observation", "joint": "thumb_mcp"} def fail(*args, **kwargs): - assert kwargs["require_full_hand_spatial_zero"] raise O12SpatialZeroError("unreliable geometry", diagnostic) - monkeypatch.setattr(pipeline, "fit_o12_session", fail) + monkeypatch.setattr(finalization, "fit_profile_calibration", fail) session = tmp_path / "new" with pytest.raises(O12SpatialZeroError): - pipeline.finalize_o12_session(session_dir=session, serial_number="FULL", + 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")) - assert json.loads((session / "spatial_zero_diagnostics.json").read_text()) == diagnostic - - -def test_identifiable_failed_fit_exports_only_review_model(monkeypatch, tmp_path, full_solution): - import json - from dataclasses import asdict - from linkerhand_calibration.models.o12 import pipeline - from linkerhand_calibration.models.o12.fitting import fit_o12_session - from linkerhand_calibration.models.o12.artifacts import build_o12_runtime_payload - truth, valid_zero = full_solution - invalid_zero = replace(valid_zero, passed=False, - failure_reasons={"index_pip": "zero_phase_axis_line_residual_too_large"}) - fit = fit_o12_session(SOURCE_URDF, _synthetic_records()) - offsets = { - **truth, - "thumb_mcp": 0.0, - "ring_mcp_pitch": truth["pinky_mcp_pitch"], - } - fit = replace(fit, zero_offsets_rad=offsets, full_hand_zero_result=invalid_zero) - error = O12SpatialZeroError("unreliable geometry", { - "passed": False, "stage": "spatial_solve", "result": asdict(invalid_zero)}) - error.review_fit = fit - def fail(*args, **kwargs): - raise error - monkeypatch.setattr(pipeline, "fit_o12_session", fail) - previous = tmp_path / "previous" - previous.mkdir() - pointer = tmp_path / "latest_passed" - pointer.symlink_to(previous, target_is_directory=True) - session = tmp_path / "new" - with pytest.raises(O12SpatialZeroError, match="仅供复核"): - pipeline.finalize_o12_session(session_dir=session, serial_number="FULL", - source_urdf=SOURCE_URDF, protected_inputs={}, records=[], publish=True, - timestamp="20260909_000000") - assert pointer.resolve() == previous - assert not list(session.glob("*.urdf")) - assert not list(session.rglob("*calibration.json")) - manifest = json.loads((session / "review_only/review_manifest.json").read_text()) - assert not manifest["publication_allowed"] - assert not manifest["spatial_validation"]["result"]["passed"] - assert "REVIEW_ONLY" in manifest["urdf"] - assert (session / "review_only" / manifest["urdf"]).is_file() - with pytest.raises(ValueError): - build_o12_runtime_payload(serial_number="FULL", source_urdf=SOURCE_URDF, - result=fit, protected_inputs={}, passed=True) + 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 index 8ae39d9..e3e0785 100644 --- a/src/linkerhand_calibration/test/test_o12_observation_resolution.py +++ b/src/linkerhand_calibration/test/test_o12_observation_resolution.py @@ -3,7 +3,7 @@ import numpy as np import pytest from scipy.spatial.transform import Rotation as R -from linkerhand_calibration.models.o12 import observations as module +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12 import observations as module from linkerhand_calibration.pnp import SquareTagPose @@ -87,31 +87,19 @@ def test_other_task_evidence_is_not_mixed_into_thumb(monkeypatch): def test_partial_external_projection_cannot_finalize_whole_hand(tmp_path): - from linkerhand_calibration.models.o12.pipeline import finalize_o12_session + 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='unused', 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 dataclasses import dataclass,field - from linkerhand_calibration.models.o12 import pipeline - from linkerhand_calibration.models.o12.zero import O12SpatialZeroError - @dataclass - class Spatial: - passed: bool=True - failure_reasons: dict=field(default_factory=dict) - @dataclass - class Fit: - full_hand_zero_result: Spatial=field(default_factory=Spatial) - zero_offsets_rad: dict=field(default_factory=dict) - monkeypatch.setattr(pipeline,'fit_o12_session',lambda *a,**k:Fit()) - def no_review(**kwargs): - raise RuntimeError('test omits review geometry') - monkeypatch.setattr(pipeline,'write_o12_corrected_urdf',no_review) - with pytest.raises(O12SpatialZeroError) as error: + 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='unused', protected_inputs={},records=[{'kind':'o12_pnp_candidate_frame','task_name':'thumb_mcp_dip_front','image_stamp_ns':1}]) - assert error.value.diagnostics['stage']=='camera_projection' - assert not error.value.review_fit.full_hand_zero_result.passed assert not (tmp_path/'latest_passed').exists() diff --git a/src/linkerhand_calibration/test/test_o12_readonly_bridge.py b/src/linkerhand_calibration/test/test_o12_readonly_bridge.py new file mode 100644 index 0000000..30b362a --- /dev/null +++ b/src/linkerhand_calibration/test/test_o12_readonly_bridge.py @@ -0,0 +1,37 @@ +"""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 index e12cdf4..e743258 100644 --- a/src/linkerhand_calibration/test/test_o12_recorded_replay.py +++ b/src/linkerhand_calibration/test/test_o12_recorded_replay.py @@ -9,8 +9,8 @@ from pathlib import Path import pytest -from linkerhand_calibration.models.o12.pipeline import finalize_o12_session, load_o12_raw_samples -from linkerhand_calibration.models.o12.zero import O12SpatialZeroError +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 diff --git a/src/linkerhand_calibration/test/test_o12_right_profile.py b/src/linkerhand_calibration/test/test_o12_right_profile.py index f7f0691..b876c20 100644 --- a/src/linkerhand_calibration/test/test_o12_right_profile.py +++ b/src/linkerhand_calibration/test/test_o12_right_profile.py @@ -1,67 +1,31 @@ from __future__ import annotations -from dataclasses import replace -import json -import math from pathlib import Path -import threading from types import SimpleNamespace -import xml.etree.ElementTree as ET -from collections import deque import numpy as np import pytest from scipy.spatial.transform import Rotation -from linkerhand_calibration.calibrated_joint_state_bridge import CalibratedCommandMapper -from linkerhand_calibration.models.o12.artifacts import ( - build_o12_runtime_payload, - validate_o12_runtime_payload, - validate_o12_runtime_payload_against_urdf, -) -from linkerhand_calibration.models.o12.fitting import ( - _uniform_curve_holdout_errors, - fit_o12_session, -) -from linkerhand_calibration.models.o12.health import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.health import ( assess_o12_error_report, decoded_faults, historical_communication_latch_confirmed, ) -from linkerhand_calibration.models.o12.motion import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.motion import ( cosine_position_trajectory_rad, cosine_ramp_velocity_trajectory_rad, ) -from linkerhand_calibration.models.o12.kinematics import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.kinematics import ( evaluate_vendor_passive_joint, ) -from linkerhand_calibration.models.o12.quality import ( - QUALITY_POLICY_VERSION, - evaluate_o12_observation_quality, -) -from linkerhand_calibration.models.l6.node import ( - L6ThreeCameraCalibrationNode, - MotionStep, -) -from linkerhand_calibration.models.o6.node import O6ThreeCameraCalibrationNode -from linkerhand_calibration.models.o12.node import O12ThreeCameraCalibrationNode -from linkerhand_calibration.models.o12.node import MotionStep as O12MotionStep -from linkerhand_calibration.pnp import SquareTagPose -from linkerhand_calibration.models.o12.runner import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.runner import ( _log_exception_summary, render_o12_progress_zh, ) -from linkerhand_calibration.models.o12 import runner as o12_runner -from linkerhand_calibration.models.l6.runner import _launch_command -from linkerhand_calibration.models.o12.resume import ( - automatic_resume_candidate, - build_resume_checkpoint_from_rows, - ordered_resume_units, -) -from linkerhand_calibration.models.o12.profile import ( +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, - CLEARANCE_FLEX_ENDPOINT_TOLERANCE_RAD, - CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION, COMMAND_NAMES, EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION, ENDPOINT_ANCHOR_BY_JOINT, @@ -70,7 +34,6 @@ from linkerhand_calibration.models.o12.profile import ( FEEDBACK_UPPER_RAD, FORMAL_SPEED_CAP_RAD_S, INITIAL_FEEDBACK_SPAN_FRACTION, - INDEX_CLEARANCE_MCP_RAD, MAXIMUM_EXTRINSICS_REPROJECTION_RMS_PX, MEASURED_PASSIVE_JOINTS, NORMALIZED_SWEEP_BIN_COUNT, @@ -81,31 +44,18 @@ from linkerhand_calibration.models.o12.profile import ( PARK_PINKY_MCP_RAD, PARK_RING_MCP_RAD, ROLL_CROSS_VIEW_BY_TASK, - ROLL_CLEARANCE_MCP_RAD, SAFE_UPPER_RAD, SDK_TO_URDF_JOINT, - SDK_TO_URDF_SIGN, build_typed_profile, ) -from linkerhand_calibration.runtime import ( - ACQUISITION_POLICY_VERSION, - CalibrationEngine, -) -from linkerhand_calibration.models.o12.urdf import write_o12_corrected_urdf + + ROOT = Path(__file__).resolve().parents[1] SOURCE_URDF = ROOT / "urdf/o12_right/linkerhand_o12_t3_right-0703.urdf" -def test_o12_motion_extensions_are_not_part_of_legacy_hand_contracts() -> None: - assert "relative_feedback_delta" not in MotionStep.__dataclass_fields__ - assert "relative_feedback_delta" in O12MotionStep.__dataclass_fields__ - assert not L6ThreeCameraCalibrationNode._uses_isolated_motion_callbacks() - assert not O6ThreeCameraCalibrationNode._uses_isolated_motion_callbacks() - assert O12ThreeCameraCalibrationNode._uses_isolated_motion_callbacks() - - def _synthetic_records(): profile = build_typed_profile() task_by_joint = { @@ -155,10 +105,11 @@ def _synthetic_records(): ("decreasing", np.linspace(0.0, 1.0, 65)), ("increasing", np.linspace(1.0, 0.0, 65)), ): - for phase in phases: + 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), @@ -198,14 +149,14 @@ def test_o12_fixed_mapping_tags_and_radian_domain() -> None: 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.03, 0.04, 0.08, 0.04, 0.08, 0.04, 0.08, 0.08, 0.04, 0.08, 0.08 + 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_dip") + ) == ("side", "side_base", "index_pip") side_tag_ids = { tag.role: tag.tag_id for view in profile.vision.views @@ -213,6 +164,7 @@ def test_o12_fixed_mapping_tags_and_radian_domain() -> None: 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) @@ -298,9 +250,12 @@ def test_o12_hcan_launch_does_not_emit_empty_socketcan_argument(tmp_path: Path) def test_o12_vendor_node_is_launched_in_documented_namespace() -> None: launch_text = (ROOT / "launch/three_camera_calibration.launch.py").read_text() - vendor = launch_text.split('package="omnihand_node"', 1)[1].split(")", 1)[0] - assert 'executable="omnihand_pro_2025_node"' in vendor - assert 'namespace="o12"' in vendor + 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: @@ -315,38 +270,6 @@ def test_o12_vendor_stream_is_not_artificially_throttled() -> None: assert "request_interval_ms: 0" in active_right -def test_o12_temperature_fallback_requires_verified_error_channel(tmp_path: Path) -> None: - warnings: list[str] = [] - fake = SimpleNamespace( - temperature_report_required=False, - temperature_verified=False, - temperature_fallback_active=False, - error_verified=False, - health_check_started_at=10.0, - temperature_fallback_after_seconds=5.0, - raw_path=tmp_path / "raw.jsonl", - protected_inputs={"sdk_config_sha256": "a" * 64}, - get_logger=lambda: SimpleNamespace(warning=warnings.append), - ) - - O12ThreeCameraCalibrationNode._activate_temperature_fallback_if_allowed( - fake, 16.0 - ) - assert not fake.temperature_fallback_active - - fake.error_verified = True - O12ThreeCameraCalibrationNode._activate_temperature_fallback_if_allowed( - fake, 16.0 - ) - assert fake.temperature_fallback_active - assert warnings == [ - "O12 temperature report unavailable; continuing with error-code " - "bit1 overheat protection" - ] - raw = (tmp_path / "raw.jsonl").read_text() - assert '"fallback_protection":"joint_error_states_bit1_overheat"' in raw - - def test_o12_error_health_separates_active_faults_from_historical_comm_latch( ) -> None: communication = assess_o12_error_report([0, 16, *([0] * 10)]) @@ -378,339 +301,6 @@ def test_o12_error_health_separates_active_faults_from_historical_comm_latch( ) -def test_o12_historical_comm_latch_requires_repeated_live_evidence( - tmp_path: Path, -) -> None: - pauses: list[str] = [] - warnings: list[str] = [] - fake = SimpleNamespace( - latest_errors=(), - error_verified=False, - error_health_classification="awaiting_error_report", - error_report_count=0, - error_report_first_matching_at=0.0, - error_report_matching_count=0, - error_report_matching_codes=(), - confirmed_historical_communication_channels=(), - error_health_audit_written=False, - started=False, - state_receive_times=deque([0.0, 1.0]), - minimum_feedback_hz=15.0, - command_names=COMMAND_NAMES, - raw_path=tmp_path / "raw.jsonl", - _feedback_hz=lambda: 50.0, - _feedback_age_seconds=lambda _now: 0.01, - _update_error_health=lambda now: O12ThreeCameraCalibrationNode._update_error_health( - fake, now - ), - _pause=pauses.append, - get_logger=lambda: SimpleNamespace(warning=warnings.append), - ) - report = SimpleNamespace(data=[0, 16, *([0] * 10)]) - O12ThreeCameraCalibrationNode._error_callback(fake, report) - O12ThreeCameraCalibrationNode._error_callback(fake, report) - assert not fake.error_verified - fake.error_report_first_matching_at -= 2.0 - O12ThreeCameraCalibrationNode._error_callback(fake, report) - assert fake.error_verified - assert fake.error_health_classification == "historical_communication_latch" - assert fake.confirmed_historical_communication_channels == (1,) - assert not pauses - assert warnings - audit = (tmp_path / "raw.jsonl").read_text() - assert '"classification":"historical_communication_latch"' in audit - assert '"communication_channels":["thumb_abad"]' in audit - - fake.started = True - O12ThreeCameraCalibrationNode._error_callback( - fake, SimpleNamespace(data=[16, 16, *([0] * 10)]) - ) - assert not pauses - assert fake.error_health_classification == "confirming_historical_communication" - assert not fake.error_verified - assert '"while_started":true' in (tmp_path / "raw.jsonl").read_text() - - -def test_o12_default_health_query_does_not_send_unsupported_temperature_request( -) -> None: - class Publisher: - def __init__(self) -> None: - self.messages = [] - - def publish(self, message) -> None: - self.messages.append(message) - - mode = Publisher() - error = Publisher() - temperature = Publisher() - fake = SimpleNamespace( - commands_enabled=True, - control_mode_publisher=mode, - error_query_publisher=error, - temperature_query_publisher=temperature, - temperature_report_required=False, - temperature_verified=False, - last_temperature_query_at=0.0, - last_health_query_at=0.0, - ) - O12ThreeCameraCalibrationNode._query_health(fake) - assert len(mode.messages) == 1 - assert len(error.messages) == 1 - assert not temperature.messages - - -def test_o12_required_temperature_report_disables_fallback(tmp_path: Path) -> None: - fake = SimpleNamespace( - temperature_report_required=True, - temperature_verified=False, - temperature_fallback_active=False, - error_verified=True, - health_check_started_at=10.0, - temperature_fallback_after_seconds=5.0, - ) - O12ThreeCameraCalibrationNode._activate_temperature_fallback_if_allowed( - fake, 30.0 - ) - assert not fake.temperature_fallback_active - - -def test_o12_motion_plan_has_collision_clearance_and_safe_return() -> None: - profile = build_typed_profile() - fake = SimpleNamespace( - profile=profile, - motion_speed_scale=4.0, - _full_target=O12ThreeCameraCalibrationNode._full_target, - ) - steps = O12ThreeCameraCalibrationNode._build_steps(fake) - clearance = [step for step in steps if step.phase.startswith("clearance")] - assert clearance[0].target_command[10:] == ( - PARK_RING_MCP_RAD, PARK_PINKY_MCP_RAD - ) - assert clearance[0].required_endpoint_indices == (10, 11) - assert clearance[0].speed_u8 == 0.20 - assert clearance[1].target_command[4] == pytest.approx(-math.radians(10.0)) - assert clearance[1].target_command[5] == pytest.approx( - INDEX_CLEARANCE_MCP_RAD - ) - assert clearance[1].speed_u8 == 0.12 - index_clearance = next( - step for step in clearance if step.phase == "clearance" - ) - assert index_clearance.target_command[7] == pytest.approx(0.0) - assert index_clearance.target_command[8] == pytest.approx(SAFE_UPPER_RAD[8]) - assert index_clearance.target_command[9] == pytest.approx(SAFE_UPPER_RAD[9]) - assert index_clearance.target_command[10] == pytest.approx(SAFE_UPPER_RAD[10]) - assert index_clearance.target_command[11] == pytest.approx(SAFE_UPPER_RAD[11]) - assert index_clearance.required_endpoint_indices == (7, 8, 9, 10, 11) - assert [step.phase for step in steps[-3:]] == [ - "return_splay_zero", "return_middle_open", "return_outer_open" - ] - assert not [step for step in steps if step.phase == "prepare"] - for task in profile.motion.tasks: - probes = [step for step in steps if step.task_key == task.key and step.phase == "preflight"] - assert max(abs(step.target_u8 - task.start_value) for step in probes) <= math.radians(3.0) + 1e-12 - relative = [ - step for step in probes - if step.relative_feedback_delta is not None - ] - assert len(relative) == 1 - assert relative[0].relative_feedback_delta == pytest.approx( - relative[0].target_u8 - task.start_value - ) - sweeps = [step for step in steps if step.task_key == task.key and step.phase == "sweep"] - expected_speed = min( - 4.0 * task.formal_speed, - FORMAL_SPEED_CAP_RAD_S[task.command_index], - ) - assert {step.speed_u8 for step in sweeps} == {expected_speed} - thumb_pitch_sweeps = [ - step for step in steps - if step.task_key == "thumb_pitch_front" and step.phase == "sweep" - ] - assert {step.speed_u8 for step in thumb_pitch_sweeps} == {0.10} - middle_roll = next( - task for task in profile.motion.tasks - if task.key == "middle_roll_front" - ) - index_roll = next( - task for task in profile.motion.tasks - if task.key == "index_roll_front" - ) - assert dict(middle_roll.auxiliary_commands)[5] == pytest.approx( - INDEX_CLEARANCE_MCP_RAD - ) - assert dict(middle_roll.auxiliary_commands)[8] == pytest.approx( - ROLL_CLEARANCE_MCP_RAD - ) - assert dict(index_roll.auxiliary_commands)[5] == pytest.approx( - ROLL_CLEARANCE_MCP_RAD - ) - assert dict(index_roll.auxiliary_commands)[7] == pytest.approx(0.0) - assert dict(index_roll.auxiliary_commands)[8] == pytest.approx( - SAFE_UPPER_RAD[8] - ) - assert dict(index_roll.auxiliary_commands)[9] == pytest.approx( - SAFE_UPPER_RAD[9] - ) - for task in profile.motion.tasks: - if not task.key.startswith("index_"): - continue - clearance_target = dict(task.auxiliary_commands) - assert clearance_target[7] == pytest.approx(0.0) - assert clearance_target[8] == pytest.approx(SAFE_UPPER_RAD[8]) - assert clearance_target[9] == pytest.approx(SAFE_UPPER_RAD[9]) - assert clearance_target[10] == pytest.approx(SAFE_UPPER_RAD[10]) - assert clearance_target[11] == pytest.approx(SAFE_UPPER_RAD[11]) - - -def test_o12_clearance_waits_until_all_required_fingers_are_parked( - monkeypatch, -) -> None: - delegated: list[bool] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_tick_radian_motion", - lambda _node, _step, _now: delegated.append(True), - ) - target = [0.0] * 12 - target[8] = PARK_MIDDLE_MCP_RAD - target[9] = PARK_MIDDLE_PIP_RAD - target[10] = PARK_RING_MCP_RAD - target[11] = PARK_PINKY_MCP_RAD - step = O12MotionStep( - "clearance", None, None, 0.0, 0.20, - target_command=tuple(target), - required_endpoint_indices=(7, 8, 9, 10, 11), - ) - node = object.__new__(O12ThreeCameraCalibrationNode) - node.step_trajectory_phase = 1.0 - node.command_count = 12 - node.command_names = COMMAND_NAMES - node.latest_state_u8 = tuple(target) - node.step_start_state_u8 = (0.0,) * 12 - node.step_start_feedback_u8 = (0.0,) * 12 - node.step_last_distance_u8 = 0.0 - node.step_last_progress_at = 10.0 - node.step_hold_since = 10.0 - node.motor_stall_timeout_seconds = 2.0 - node._target_command = lambda active_step: tuple( - active_step.target_command - ) - node._radian_feedback_travel = lambda _step: 1.0 - measured_travel = { - 8: target[8], - 9: 1.522766434, - 10: target[10], - 11: target[11], - } - node._o12_measured_channel_travel_rad = measured_travel.get - paused: list[str] = [] - node._pause = paused.append - - incomplete = list(target) - incomplete[8] = ( - 0.5 * CLEARANCE_MINIMUM_FEEDBACK_TRAVEL_FRACTION * target[8] - ) - node.latest_state_u8 = tuple(incomplete) - O12ThreeCameraCalibrationNode._tick_radian_motion(node, step, 10.5) - assert delegated == [] - assert paused == [] - assert node.step_hold_since is None - - # A stable SDK feedback endpoint may differ from the command endpoint; - # reaching most of the requested physical travel must still be accepted. - biased_endpoint = list(target) - biased_endpoint[8] = 0.90 * measured_travel[8] - # Exact field regression: full command is 1.815 rad, but simultaneous - # MCP/PIP clearance settles at 1.415 rad after the isolated scan measured - # 1.523 rad. This is 92.9% of physical travel and must not be compared to - # the command endpoint (78.0%). - biased_endpoint[9] = 1.415 - node.latest_state_u8 = tuple(biased_endpoint) - O12ThreeCameraCalibrationNode._tick_radian_motion(node, step, 10.6) - assert delegated == [True] - - # The same rule is symmetric when a parked finger returns toward zero. - return_step = O12MotionStep( - "return_middle_open", None, None, 0.0, 0.20, - target_command=(0.0,) * 12, - required_endpoint_indices=(8, 9), - ) - node.step_start_state_u8 = tuple(target) - node.step_start_feedback_u8 = tuple(biased_endpoint) - returned = list(biased_endpoint) - returned[8] = 0.0 - returned[9] = 0.0 - node.latest_state_u8 = tuple(returned) - node.step_last_distance_u8 = 0.0 - node.step_last_progress_at = 11.0 - O12ThreeCameraCalibrationNode._tick_radian_motion( - node, return_step, 11.1 - ) - assert delegated == [True, True] - - -def test_o12_clearance_has_no_second_command_scale_travel_gate() -> None: - node = object.__new__(O12ThreeCameraCalibrationNode) - step = O12MotionStep( - "clearance", None, None, 0.0, 0.20, - target_command=(0.0,) * 12, - required_endpoint_indices=(8, 9, 10, 11), - ) - assert node._minimum_radian_feedback_travel(step) == pytest.approx(0.0) - - -def test_o12_automatic_rescan_preserves_o12_motion_step_type( - tmp_path: Path, -) -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_pip_dip_side" - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.32, 0, "decreasing", 1, - ) - node = SimpleNamespace( - retry_counts={}, - calibration_engine=CalibrationEngine(profile), - profile=profile, - steps=[step], - step_index=0, - raw_path=tmp_path / "raw.jsonl", - _task=lambda _key: task, - ) - - assert L6ThreeCameraCalibrationNode._retry_step( - node, step, "maximum_gap=6" - ) - assert all(isinstance(item, O12MotionStep) for item in node.steps) - assert node.steps[1].phase == "retry_prepare" - assert node.steps[1].required_endpoint_indices == () - - -def test_o12_radian_tick_accepts_legacy_generated_step(monkeypatch) -> None: - delegated: list[MotionStep] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_tick_radian_motion", - lambda _node, active_step, _now: delegated.append(active_step), - ) - step = MotionStep( - "retry_prepare", "middle_pip_dip_side", 9, 0.0, 0.32, - 0, attempt=2, - ) - node = object.__new__(O12ThreeCameraCalibrationNode) - node.step_trajectory_phase = 1.0 - node.latest_state_u8 = (0.0,) * 12 - node.command_count = 12 - - node._tick_radian_motion(step, 10.0) - - assert delegated == [step] - - def test_o12_log_exception_summary_reports_child_traceback( tmp_path: Path, ) -> None: @@ -726,894 +316,16 @@ def test_o12_log_exception_summary_reports_child_traceback( ) -def test_o12_preflight_resolves_three_degree_probe_from_measured_feedback() -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "thumb_yaw_top" - ) - step = O12MotionStep( - "preflight", - task.key, - task.command_index, - task.start_value - math.radians(3.0), - 0.06, - relative_feedback_delta=-math.radians(3.0), - ) - node = object.__new__(O12ThreeCameraCalibrationNode) - node.command_unit = "rad" - node.command_count = 12 - node.profile = profile - node.command_lower = profile.command.minimum_values - node.command_upper = profile.command.maximum_values - node.baseline_command = profile.command.baseline_values - node.latest_state_u8 = (0.0, -0.023) + (0.0,) * 10 - node.last_published_command_u8 = profile.command.baseline_values - node.commanded_speed = step.speed_u8 - node.step_speed_ready_at = 0.0 - node.step_command_sent = False - node.resolved_probe_target_by_step = {} - node.probe_feedback_origin_by_step = {} - node.non_target_motion_tolerance_u8 = 0.015 - node.step_data_lock = threading.RLock() - node.vision_callbacks_inflight = 0 - node._current_step = lambda: step - node._task = lambda _key: task - node._advance_step_trajectory = lambda _step, _now: None - node._radian_trajectory_fraction = lambda *_args: (0.0, 0.0, 1.0) - - O12ThreeCameraCalibrationNode._begin_step(node, step) - - assert node.probe_feedback_origin_by_step[id(step)] == pytest.approx(-0.023) - assert node.resolved_probe_target_by_step[id(step)][1] == pytest.approx( - -0.023 - math.radians(3.0) - ) - assert node.resolved_probe_target_by_step[id(step)][1] < step.target_u8 - assert abs( - node.resolved_probe_target_by_step[id(step)][1] - - node.probe_feedback_origin_by_step[id(step)] - ) == pytest.approx(math.radians(3.0)) -def test_o12_locked_front_base_is_used_only_for_occluded_finger_tasks() -> None: - profile = build_typed_profile() - pose = SquareTagPose( - quaternion_xyzw=(0.0, 0.0, 0.0, 1.0), - translation_xyz_m=(0.0, 0.0, 0.5), - reprojection_error_px=0.2, - ) - node = object.__new__(O12ThreeCameraCalibrationNode) - node.profile = profile - node.locked_base_pose_by_view = {"front": pose} - - middle = SimpleNamespace(task_key="middle_roll_front") - index = SimpleNamespace(task_key="index_roll_front") - thumb = SimpleNamespace(task_key="thumb_roll_front") - assert node._locked_reference_poses_for_capture("front", middle) == { - "front_base": pose - } - assert node._locked_reference_poses_for_capture("front", index) == { - "front_base": pose - } - assert node._locked_reference_poses_for_capture("front", thumb) == {} - assert node._locked_reference_poses_for_capture("side", middle) == {} -def test_o12_mapping_probe_accepts_feedback_when_live_tag_is_occluded( - monkeypatch, tmp_path: Path -) -> None: - delegated: list[O12MotionStep] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_finish_step", - lambda _node, active_step: delegated.append(active_step), - ) - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "thumb_roll_front" - ) - delta = math.radians(3.0) - step = O12MotionStep( - "preflight", task.key, task.command_index, delta, 0.06, - relative_feedback_delta=delta, - ) - node = object.__new__(O12ThreeCameraCalibrationNode) - node.profile = profile - node.command_names = COMMAND_NAMES - node.preflight_maximum_rotation_by_task = {} - node.measured_direction_axis_by_task = {} - node.probe_feedback_origin_by_step = {id(step): 0.0} - node.latest_state_u8 = (0.04,) + (0.0,) * 11 - node.step_data_lock = threading.RLock() - node.vision_callbacks_inflight = 0 - node.step_command_sent = True - node.state = "PAUSED" - node.raw_path = tmp_path / "raw.jsonl" - node._task = lambda _key: task - node._target_command = lambda _step: (delta,) + (0.0,) * 11 - node._radian_feedback_travel = lambda _step: 0.04 - paused: list[str] = [] - node._pause = paused.append - node._current_step = lambda: None - - node._finish_step(step) - - assert paused == [] - assert delegated == [step] - row = json.loads(node.raw_path.read_text(encoding="utf-8")) - assert row["visual_mapping_verified"] is False - assert row["verification_basis"] == "feedback_only_live_tag_unavailable" - assert row["projected_feedback_travel_rad"] == pytest.approx(0.04) -def _resume_rows(profile, count: int) -> list[dict]: - units = ordered_resume_units(profile)[:count] - task_by_key = {task.key: task for task in profile.motion.tasks} - rows: list[dict] = [] - for task_name, cycle, direction in units: - task = task_by_key[task_name] - for sample in range(40): - rows.append({ - "kind": "o12_joint_sample", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "joint": task.joints[0], - "feedback_rad": float(sample), - }) - rows.append({ - "kind": "o12_sweep_observation_quality", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "valid_frames": 40, - "total_frames": 40, - "feedback_bins": 32, - "maximum_bin_gap": 1, - "tag_detection_rate": 1.0, - "failures": [], - }) - return rows -def test_o12_resume_accepts_legacy_one_frame_counter_boundary() -> None: - profile = build_typed_profile() - rows = _resume_rows(profile, 1) - quality = next( - row for row in rows - if row["kind"] == "o12_sweep_observation_quality" - ) - quality["valid_frames"] = 41 - quality["total_frames"] = 40 - - checkpoint = build_resume_checkpoint_from_rows( - profile, Path("legacy_frame_boundary"), rows, compatibility="test" - ) - - assert len(checkpoint.completed_units) == 1 -def test_o12_resume_uses_recorded_observability_decision() -> None: - profile = build_typed_profile() - rows = _resume_rows(profile, 1) - quality = next( - row for row in rows - if row["kind"] == "o12_sweep_observation_quality" - ) - quality.update({ - "quality_policy_version": QUALITY_POLICY_VERSION, - "passed": True, - "tag_detection_rate": 0.928, - "maximum_bin_gap": 5, - "warnings": [ - "tag_rate_target[middle_pip]=0.928", - "maximum_gap_target=5", - ], - }) - checkpoint = build_resume_checkpoint_from_rows( - profile, Path("observability_v2"), rows, compatibility="test" - ) - assert len(checkpoint.completed_units) == 1 - - quality["passed"] = False - quality["failures"] = ["maximum_gap=9"] - with pytest.raises(ValueError, match="no contiguous passed scan unit"): - build_resume_checkpoint_from_rows( - profile, Path("observability_v2"), rows, compatibility="test" - ) - - -def test_o12_continuous_feedback_uses_64_cells_for_32_bin_gate( - tmp_path: Path, -) -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_roll_front" - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.16, 0, "decreasing", 1, - ) - feedback = np.linspace(-0.228753502, 0.244652041, 125) - fake = SimpleNamespace( - raw_records=[{ - "task_name": task.key, "cycle": 0, - "direction": "decreasing", "attempt": 1, - "joint": task.joints[0], "feedback_rad": float(value), - } for value in feedback], - profile=profile, - command_unit="rad", - normalized_sweep_bin_count=NORMALIZED_SWEEP_BIN_COUNT, - minimum_state_span_u8=0.90, - minimum_sweep_frames=40, - minimum_sweep_bins=32, - maximum_bin_gap=2, - minimum_detection_rate=0.95, - minimum_joint_frame_rate=0.85, - minimum_feedback_hz=25.0, - step_total_frames=125, - raw_path=tmp_path / "quality.jsonl", - sweep_quality_kind="o12_sweep_observation_quality", - _task=lambda _key: task, - _step_observation_metrics=lambda: { - "tag_detection_rate": 1.0, - "joint_frame_rate": 1.0, - "tag_detection_rate_by_role": { - "front_base": 1.0, "middle_roll": 1.0, - }, - "tag_seen_rate_by_role": { - "front_base": 1.0, "middle_roll": 1.0, - }, - "all_tags_quality_rate": 1.0, - "pnp_valid_rate": 1.0, - "state_sync_rate": 1.0, - "rejection_counts": {}, - }, - _feedback_hz=lambda: 48.0, - ) - L6ThreeCameraCalibrationNode._qualify_recording_step(fake, step) - quality = json.loads(fake.raw_path.read_text().strip()) - assert quality["feedback_bins"] >= 32 - assert quality["maximum_bin_gap"] <= 2 - assert quality["failures"] == [] - - -def test_o12_quality_accepts_dense_fit_data_after_short_tag_occlusion( - tmp_path: Path, -) -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_pip_dip_side" - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.16, 1, "decreasing", 3, - ) - retained_bins = [ - value for value in range(3, 61) if value not in {30, 31, 32, 33} - ] - normalized = [ - (retained_bins[index % len(retained_bins)] + 0.25) - / NORMALIZED_SWEEP_BIN_COUNT - for index in range(324) - ] - feedback = [ - profile.command.denormalize(task.command_index, value) - for value in normalized - ] - observation = { - "tag_detection_rate": 0.928, - "joint_frame_rate": 0.928, - "tag_detection_rate_by_role": { - "side_base": 1.0, "middle_pip": 0.928, - "middle_dip": 1.0, - }, - "tag_seen_rate_by_role": { - "side_base": 1.0, "middle_pip": 0.928, - "middle_dip": 1.0, - }, - "all_tags_quality_rate": 0.928, - "pnp_valid_rate": 0.928, - "state_sync_rate": 0.928, - "rejection_counts": {"tag:middle_pip:missing": 25}, - } - fake = SimpleNamespace( - raw_records=[{ - "kind": "o12_joint_sample", - "task_name": task.key, - "cycle": 1, - "direction": "decreasing", - "attempt": 3, - "joint": task.joints[0], - "feedback_rad": float(value), - } for value in feedback], - profile=profile, - normalized_sweep_bin_count=NORMALIZED_SWEEP_BIN_COUNT, - minimum_sweep_frames=40, - minimum_sweep_bins=32, - maximum_bin_gap=2, - minimum_detection_rate=0.95, - minimum_joint_frame_rate=0.85, - minimum_feedback_hz=35.0, - step_total_frames=349, - raw_path=tmp_path / "quality.jsonl", - sweep_quality_kind="o12_sweep_observation_quality", - _task=lambda _key: task, - _required_radian_feedback_span_fraction=( - lambda _task, _step, _feedback: 0.85 - ), - _normalize_o12_feedback=( - lambda _task, _step, _feedback: np.asarray(normalized) - ), - _step_observation_metrics=lambda: observation, - _feedback_hz=lambda: 50.0, - ) - O12ThreeCameraCalibrationNode._qualify_o12_primary_recording_step( - fake, step - ) - quality = json.loads(fake.raw_path.read_text().strip()) - assert quality["quality_policy_version"] == QUALITY_POLICY_VERSION - assert quality["valid_frames"] == 324 - assert quality["feedback_bins"] == 54 - # Four consecutive normalized bins are unobserved; the metric reports - # missing bins, not the five-index distance between their neighbours. - assert quality["maximum_bin_gap"] == 4 - assert quality["allowed_maximum_bin_gap"] == 4 - assert quality["failures"] == [] - assert quality["passed"] is True - assert "tag_rate=0.928" in quality["warnings"] - - severe_gap = evaluate_o12_observation_quality( - [value for value in normalized if not 0.35 < value < 0.55], - observation, - normalized_bin_count=NORMALIZED_SWEEP_BIN_COUNT, - required_feedback_span=0.85, - minimum_sweep_frames=40, - minimum_sweep_bins=32, - minimum_joint_frame_rate=0.85, - minimum_feedback_hz=35.0, - feedback_hz=50.0, - target_detection_rate=0.95, - target_maximum_bin_gap=2, - ) - assert any( - failure.startswith("maximum_gap=") - for failure in severe_gap["failures"] - ) - - -def test_o12_feedback_scale_offset_is_not_counted_as_an_internal_gap( - tmp_path: Path, -) -> None: - """Reproduce the 1.815-command/1.541-feedback middle-PIP sweep.""" - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_pip_dip_side" - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.32, 0, "decreasing", 2, - ) - feedback = np.linspace(0.017880790, 1.540647224, 201) - fake = SimpleNamespace( - raw_records=[{ - "kind": "o12_joint_sample", - "task_name": task.key, - "cycle": 0, - "direction": "decreasing", - "attempt": 2, - "joint": task.joints[0], - "feedback_rad": float(value), - } for value in feedback], - profile=profile, - normalized_sweep_bin_count=NORMALIZED_SWEEP_BIN_COUNT, - step_total_frames=211, - raw_path=tmp_path / "quality.jsonl", - sweep_quality_kind="o12_sweep_observation_quality", - _task=lambda _key: task, - _required_radian_feedback_span_fraction=( - lambda _task, _step, _feedback: 0.99 - ), - _normalize_o12_feedback=( - lambda _task, _step, values: - (np.asarray(values) - np.min(values)) / np.ptp(values) - ), - _step_observation_metrics=lambda: { - "tag_detection_rate": 1.0, - "joint_frame_rate": 201 / 211, - "tag_detection_rate_by_role": { - "side_base": 1.0, "middle_pip": 1.0, - "middle_dip": 1.0, - }, - "tag_seen_rate_by_role": { - "side_base": 1.0, "middle_pip": 1.0, - "middle_dip": 1.0, - }, - "all_tags_quality_rate": 201 / 211, - "pnp_valid_rate": 201 / 211, - "state_sync_rate": 201 / 211, - "rejection_counts": {}, - }, - _feedback_hz=lambda: 50.0, - ) - - O12ThreeCameraCalibrationNode._qualify_o12_primary_recording_step( - fake, step - ) - - quality = json.loads(fake.raw_path.read_text(encoding="utf-8")) - assert quality["feedback_span"] == pytest.approx(1.0, abs=1e-6) - assert quality["feedback_bins"] == 64 - assert quality["maximum_bin_gap"] == 0 - assert quality["gap_scope"] == "observed_feedback_span" - assert quality["failures"] == [] - assert quality["passed"] is True - - -def test_o12_feedback_span_repeats_cycle_zero_effective_travel() -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_roll_front" - ) - cycle_zero_feedback = np.linspace(-0.233773934, 0.234931874, 126) - current_feedback = np.linspace(-0.229859, 0.234997, 307) - fake = SimpleNamespace( - profile=profile, - raw_records=[{ - "kind": "o12_joint_sample", - "task_name": task.key, - "cycle": 0, - "direction": "increasing", - "attempt": 1, - "joint": task.joints[0], - "feedback_rad": float(value), - } for value in cycle_zero_feedback], - ) - fake._o12_cycle_zero_feedback_span_fraction = ( - lambda selected_task, direction: - O12ThreeCameraCalibrationNode - ._o12_cycle_zero_feedback_span_fraction( - fake, selected_task, direction - ) - ) - fake._o12_cycle_zero_feedback_bounds = ( - lambda selected_task, direction: - O12ThreeCameraCalibrationNode._o12_cycle_zero_feedback_bounds( - fake, selected_task, direction - ) - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.start_value, - 0.12, 1, "increasing", 1, - ) - - reference = ( - O12ThreeCameraCalibrationNode - ._o12_cycle_zero_feedback_span_fraction( - fake, task, "increasing" - ) - ) - required = ( - O12ThreeCameraCalibrationNode - ._required_radian_feedback_span_fraction( - fake, task, step, current_feedback - ) - ) - normalized_current = ( - O12ThreeCameraCalibrationNode._normalize_o12_feedback( - fake, task, step, current_feedback - ) - ) - actual = float(np.ptp(normalized_current)) - - assert reference == pytest.approx(1.0, abs=1.0e-6) - assert required == pytest.approx( - EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION * reference - ) - assert actual == pytest.approx( - float(np.ptp(current_feedback)) / float(np.ptp(cycle_zero_feedback)), - abs=1.0e-6, - ) - assert actual >= required - assert actual / reference >= EFFECTIVE_TRAVEL_REPEATABILITY_FRACTION - - -def test_o12_feedback_span_gate_uses_cycle_zero_measured_reference() -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_roll_front" - ) - lower = profile.command.minimum_values[task.command_index] - upper = profile.command.maximum_values[task.command_index] - cycle_zero_feedback = np.linspace(lower, lower + 0.98 * (upper - lower), 64) - fake = SimpleNamespace( - profile=profile, - raw_records=[{ - "kind": "o12_joint_sample", - "task_name": task.key, - "cycle": 0, - "direction": "decreasing", - "attempt": 1, - "joint": task.joints[0], - "feedback_rad": float(value), - } for value in cycle_zero_feedback], - ) - fake._o12_cycle_zero_feedback_span_fraction = ( - lambda selected_task, direction: - O12ThreeCameraCalibrationNode - ._o12_cycle_zero_feedback_span_fraction( - fake, selected_task, direction - ) - ) - fake._o12_cycle_zero_feedback_bounds = ( - lambda selected_task, direction: - O12ThreeCameraCalibrationNode._o12_cycle_zero_feedback_bounds( - fake, selected_task, direction - ) - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.12, 2, "decreasing", 1, - ) - required = ( - O12ThreeCameraCalibrationNode - ._required_radian_feedback_span_fraction( - fake, task, step, np.asarray([]) - ) - ) - assert required == pytest.approx(0.90) - - -def test_o12_resume_requires_side_view_for_every_roll_unit() -> None: - profile = build_typed_profile() - rows = _resume_rows(profile, 41) - checkpoint = build_resume_checkpoint_from_rows( - profile, Path("pre_multiview_roll"), rows, compatibility="test" - ) - assert len(checkpoint.completed_units) == 40 - - task_name, cycle, direction = ordered_resume_units(profile)[40] - observer = ROLL_CROSS_VIEW_BY_TASK[task_name] - rows.extend({ - "kind": "o12_roll_cross_view_sample", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "joint": observer.source_joint, - "model_joint": observer.model_joint, - "feedback_rad": float(sample), - } for sample in range(40)) - rows.append({ - "kind": "o12_roll_cross_view_quality", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "valid_frames": 40, - "total_frames": 40, - "feedback_bins": 32, - "maximum_bin_gap": 1, - "tag_detection_rate": 1.0, - "failures": [], - }) - checkpoint = build_resume_checkpoint_from_rows( - profile, Path("multiview_roll"), rows, compatibility="test" - ) - assert len(checkpoint.completed_units) == 41 - assert len([ - row for row in checkpoint.imported_records - if row["kind"] == "o12_roll_cross_view_sample" - ]) == 40 - - -def test_o12_complete_resume_finalizes_without_starting_hardware( - tmp_path: Path, monkeypatch, -) -> None: - profile = build_typed_profile() - config = SimpleNamespace( - calibration_contract=SimpleNamespace(typed_profile=profile), - serial_number="O12_TEST", - source_urdf=SOURCE_URDF, - session_root=tmp_path, - source_urdf_sha256="source", - camera_extrinsics_sha256="extrinsics", - calibration_config_sha256="calibration", - tag_config_sha256="tags", - sdk_config_sha256="sdk", - ) - records = ({"kind": "passed_observation"},) - resume = SimpleNamespace( - completed_units=ordered_resume_units(profile), - imported_records=records, - ) - captured = {} - - def finalize(**kwargs): - captured.update(kwargs) - correction = SimpleNamespace(path=tmp_path / "result.urdf") - return {"quality": {"passed": True}}, object(), correction - - monkeypatch.setattr(o12_runner, "finalize_o12_session", finalize) - assert o12_runner._finalize_completed_resume( - config, resume, tmp_path - ) == 0 - assert captured["records"] == list(records) - assert captured["publish"] is True - - -def test_o12_resume_audit_is_not_inserted_into_live_sample_rows( - tmp_path: Path, monkeypatch, -) -> None: - from linkerhand_calibration.models.o12 import node as o12_node - from linkerhand_calibration.storage import append_jsonl_many as write_many - profile = build_typed_profile() - serial_root = tmp_path / "O12_RIGHT_001" - source_session = serial_root / "old" - current_session = serial_root / "new" - source_session.mkdir(parents=True) - current_session.mkdir() - protected = { - key: f"{key}_value" - for key in profile.artifacts.protected_input_fields - } - rows = [{ - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "sample_schema_version": profile.artifacts.output_schema_version, - "profile_id": profile.key.profile_id, - "serial_number": "O12_RIGHT_001", - **protected, - }, *_resume_rows(profile, 1)] - task_name, cycle, direction = ordered_resume_units(profile)[0] - rows.append({ - "kind": "o12_pnp_candidate_frame", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "image_stamp_ns": 123, - }) - source_raw = source_session / "raw_samples.jsonl" - source_raw.write_text( - "".join(json.dumps(row) + "\n" for row in rows), - encoding="utf-8", - ) - - node = object.__new__(O12ThreeCameraCalibrationNode) - node.profile = profile - node.serial_number = "O12_RIGHT_001" - node.resume_raw_samples_path = source_raw - node.session_dir = current_session - node.raw_path = current_session / "raw_samples.jsonl" - node.protected_inputs = protected - node.raw_records = [] - - batches = [] - def capture_batch(path, payloads): - batch = list(payloads) - batches.append(batch) - write_many(path, batch) - monkeypatch.setattr(o12_node, "append_jsonl_many", capture_batch) - - O12ThreeCameraCalibrationNode._restore_resume_checkpoint(node) - - assert len(batches) == 1 - assert batches[0][0]["kind"] == "o12_resume_checkpoint_import" - assert any( - row.get("kind") == "o12_pnp_candidate_frame" - for row in batches[0] - ) - assert node.raw_records - assert all(row.get("kind") == "o12_joint_sample" for row in node.raw_records) - written = node.raw_path.read_text(encoding="utf-8") - assert '"kind":"o12_resume_checkpoint_import"' in written - - -def test_o12_resume_reuses_only_contiguous_passed_units_and_safe_plan( - tmp_path: Path, -) -> None: - profile = build_typed_profile() - rows = _resume_rows(profile, 10) - # A later isolated PASS cannot be used across the first missing unit. - later = ordered_resume_units(profile)[11] - task = next(item for item in profile.motion.tasks if item.key == later[0]) - rows.extend(_resume_rows(profile, 12)[-41:]) - checkpoint = build_resume_checkpoint_from_rows( - profile, tmp_path / "old", rows, compatibility="test" - ) - assert checkpoint.completed_units == ordered_resume_units(profile)[:10] - assert checkpoint.completed_tasks == (profile.motion.tasks[0].key,) - assert len([ - row for row in checkpoint.imported_records - if row["kind"] == "o12_joint_sample" - ]) == 400 - assert task.key == profile.motion.tasks[1].key - - fake = SimpleNamespace( - profile=profile, - motion_speed_scale=4.0, - _full_target=O12ThreeCameraCalibrationNode._full_target, - resumed_unit_keys=frozenset(checkpoint.completed_units), - resumed_task_keys=frozenset(checkpoint.completed_tasks), - resume_skipped_step_count=0, - ) - steps = O12ThreeCameraCalibrationNode._build_steps(fake) - assert steps[0].phase == "baseline" - assert not [ - step for step in steps - if step.task_key == profile.motion.tasks[0].key - ] - second = [ - step for step in steps - if step.task_key == profile.motion.tasks[1].key - ] - assert len([step for step in second if step.phase == "preflight"]) == 3 - assert len([step for step in second if step.recording]) == 6 - assert fake.resume_skipped_step_count == 13 - - increasing_checkpoint = build_resume_checkpoint_from_rows( - profile, - tmp_path / "old_increasing", - _resume_rows(profile, 9), - compatibility="test", - ) - increasing_fake = SimpleNamespace( - profile=profile, - motion_speed_scale=4.0, - _full_target=O12ThreeCameraCalibrationNode._full_target, - _task=lambda key: next( - item for item in profile.motion.tasks if item.key == key - ), - resumed_unit_keys=frozenset(increasing_checkpoint.completed_units), - resumed_task_keys=frozenset(increasing_checkpoint.completed_tasks), - resume_skipped_step_count=0, - ) - increasing_steps = O12ThreeCameraCalibrationNode._build_steps( - increasing_fake - ) - first_scan_index = next( - index for index, step in enumerate(increasing_steps) if step.recording - ) - assert increasing_steps[first_scan_index].direction == "increasing" - assert increasing_steps[first_scan_index - 1].phase == "resume_prepare" - assert increasing_steps[first_scan_index - 1].target_u8 == pytest.approx( - profile.motion.tasks[1].end_value - ) - - -def test_o12_truncated_resume_import_cannot_shadow_complete_source() -> None: - profile = build_typed_profile() - rows = _resume_rows(profile, 10) - rows.insert(0, { - "kind": "o12_resume_checkpoint_import", - "completed_unit_count": 11, - }) - with pytest.raises(ValueError, match="incomplete or truncated"): - build_resume_checkpoint_from_rows( - profile, - Path("truncated_resume"), - rows, - compatibility="test", - ) - - -def test_o12_automatic_resume_requires_exact_protected_hashes( - tmp_path: Path, -) -> None: - profile = build_typed_profile() - serial_root = tmp_path / "O12_TEST" - session = serial_root / "20260907_120000" - session.mkdir(parents=True) - hashes = { - "source_urdf_sha256": "a" * 64, - "camera_extrinsics_sha256": "b" * 64, - "calibration_config_sha256": "c" * 64, - "tag_config_sha256": "d" * 64, - "sdk_config_sha256": "e" * 64, - } - rows = [{ - "kind": "session_start", - "acquisition_policy_version": ACQUISITION_POLICY_VERSION, - "sample_schema_version": 7, - "profile_id": profile.key.profile_id, - "serial_number": "O12_TEST", - **hashes, - }, *_resume_rows(profile, 1)] - (session / "raw_samples.jsonl").write_text( - "".join(json.dumps(row) + "\n" for row in rows), - encoding="utf-8", - ) - config = SimpleNamespace( - session_root=serial_root, - profile_key=profile.key, - serial_number="O12_TEST", - calibration_contract=SimpleNamespace(typed_profile=profile), - source_urdf_sha256=hashes["source_urdf_sha256"], - camera_extrinsics_sha256=hashes["camera_extrinsics_sha256"], - calibration_config_sha256=hashes["calibration_config_sha256"], - tag_config_sha256=hashes["tag_config_sha256"], - sdk_config_sha256=hashes["sdk_config_sha256"], - ) - checkpoint = automatic_resume_candidate(config) - assert checkpoint is not None - assert checkpoint.source_session == session.resolve() - config.sdk_config_sha256 = "f" * 64 - assert automatic_resume_candidate(config) is None - - -def test_o12_radian_motion_keeps_command_and_feedback_domains_separate() -> None: - fake = SimpleNamespace( - command_count=12, - latest_state_u8=(0.034,) + (0.0,) * 11, - step_start_feedback_u8=(0.0, -0.017302) + (0.0,) * 10, - step_moving_indices=frozenset({0}), - step_initial_distance_u8=0.052, - ) - preflight = MotionStep("preflight", "thumb_roll_front", 0, 0.052, 0.06) - sweep = MotionStep( - "sweep", "thumb_roll_front", 0, 0.73, 0.16, - cycle=0, direction="decreasing", - ) - travel = O12ThreeCameraCalibrationNode._radian_feedback_travel - minimum = O12ThreeCameraCalibrationNode._minimum_radian_feedback_travel - assert travel(fake, preflight) == pytest.approx(0.034) - assert minimum(fake, preflight) == pytest.approx(0.004) - assert minimum(fake, sweep) == 0.0 - - baseline = MotionStep("baseline", None, None, 0.0, 0.20) - fake.step_initial_distance_u8 = 0.0 - fake.step_moving_indices = frozenset() - fake.latest_state_u8 = fake.step_start_feedback_u8 - # A stable -0.017302 rad feedback at command zero is a valid settled - # baseline observation, not an endpoint error or a stall. - assert travel(fake, baseline) == 0.0 - assert minimum(fake, baseline) == 0.0 - - clearance = MotionStep("clearance", None, None, 0.0, 0.20) - fake.step_initial_distance_u8 = 1.0 - # O12 clearance is checked per axis against measured travel; the generic - # aggregate command-scale gate must not be applied a second time. - assert minimum(fake, clearance) == pytest.approx(0.0) - - -def test_o12_republishes_steady_endpoint_to_refresh_request_driven_feedback() -> None: - profile = build_typed_profile() - published: list[list[float]] = [] - fake = SimpleNamespace( - command_unit="rad", - step_started_at=0.0, - step_start_state_u8=(0.0,) * 12, - step_last_command_u8=None, - step_trajectory_phase=0.0, - step_trajectory_blend=0.0, - step_requested_u8=0.0, - command_lower=profile.command.minimum_values, - command_upper=profile.command.maximum_values, - _target_command=lambda selected: tuple( - selected.target_u8 if index == selected.command_index else 0.0 - for index in range(12) - ), - _radian_trajectory_fraction=lambda _distance, _elapsed, _speed: ( - 1.0, 1.0, 1.0 - ), - _publish_command=lambda values: published.append(values), - ) - step = MotionStep( - "preflight", "thumb_yaw_top", 1, -math.radians(3.0), 0.06 - ) - O12ThreeCameraCalibrationNode._advance_step_trajectory(fake, step, 2.0) - O12ThreeCameraCalibrationNode._advance_step_trajectory(fake, step, 2.1) - assert len(published) == 2 - assert published[0] == pytest.approx(published[1]) def test_o12_feedback_domain_contains_but_does_not_expand_command_domain() -> None: @@ -1638,427 +350,6 @@ def test_o12_feedback_domain_contains_but_does_not_expand_command_domain() -> No ) -def test_o12_state_accepts_endpoint_tracking_inside_feedback_domain() -> None: - command = build_typed_profile().command - paused: list[str] = [] - fake = SimpleNamespace( - command_count=command.command_count, - profile=SimpleNamespace(command=command), - command_names=command.names, - feedback_lower=command.minimum_feedback_values, - feedback_upper=command.maximum_feedback_values, - state_history=[], - latest_state_u8=(), - state_receive_times=[], - _pause=paused.append, - _current_step=lambda: None, - ) - state = list(command.baseline_values) - state[2] = command.minimum_values[2] - 0.002 - message = SimpleNamespace( - name=[], - position=state, - header=SimpleNamespace(stamp=SimpleNamespace(sec=1, nanosec=0)), - ) - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert fake.latest_state_u8[2] == pytest.approx(state[2]) - - state[2] = command.minimum_feedback_values[2] - 0.001 - message.position = state - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert len(paused) == 1 - assert paused[0].startswith( - "feedback_outside_registered_feedback_domain:channel=thumb_mcp:" - ) - - -def test_o12_optional_temperature_report_is_not_a_motion_interlock() -> None: - paused: list[str] = [] - fake = SimpleNamespace( - latest_temperatures=(), - temperature_verified=True, - temperature_fallback_active=False, - maximum_temperature_c=70, - _pause=paused.append, - ) - O12ThreeCameraCalibrationNode._temperature_callback( - fake, SimpleNamespace(data=[]) - ) - assert paused == [] - assert fake.temperature_verified is False - - O12ThreeCameraCalibrationNode._temperature_callback( - fake, SimpleNamespace(data=[71] * 12) - ) - assert paused == ["o12_over_temperature"] - - -@pytest.mark.skip(reason="unified_engine_v1 removed theoretical live coupling gates") -def test_o12_roll_coupling_envelope_is_task_local(monkeypatch) -> None: - captured: list[frozenset[int]] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_state_callback", - lambda node, _message: captured.append(node.step_moving_indices), - ) - step = O12MotionStep( - "retry_prepare", "middle_roll_front", 7, 0.26, 0.12 - ) - paused: list[str] = [] - command = [0.0] * 12 - command[8] = 0.17 - fake = SimpleNamespace( - command_count=12, - command_names=COMMAND_NAMES, - baseline_command=(0.0,) * 12, - step_start_feedback_u8=(0.0,) * 12, - step_start_state_u8=(0.0,) * 12, - step_last_command_u8=tuple(command), - step_trajectory_phase=1.0, - # Exercise the exact accepted-sweep -> retry_prepare boundary where - # the next step is selected but has not begun publishing yet. - step_command_sent=False, - step_moving_indices=frozenset({7}), - latest_state_u8=(), - _current_step=lambda: step, - _task=lambda _key: SimpleNamespace( - key="middle_roll_front", auxiliary_commands=((8, 0.17),) - ), - _target_command=lambda _step: tuple(command), - _pause=paused.append, - ) - message = SimpleNamespace(position=[0.0] * 12) - message.position[8] = 0.1793 - message.position[9] = 0.0128 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert captured == [frozenset({7, 8, 9})] - assert fake.step_moving_indices == frozenset({7}) - - message.position[9] = 0.0201 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused and paused[-1].startswith("o12_roll_coupling_exceeded:") - assert captured == [frozenset({7, 8, 9})] - - -@pytest.mark.skip(reason="auxiliary tracking is diagnostic-only in unified_engine_v1") -def test_o12_auxiliary_axis_lag_is_transition_then_timed_settle( - monkeypatch, -) -> None: - captured: list[frozenset[int]] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_state_callback", - lambda node, _message: captured.append(node.step_moving_indices), - ) - clock = [100.0] - monkeypatch.setattr( - "linkerhand_calibration.models.o12.node.time.monotonic", - lambda: clock[0], - ) - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "index_roll_front" - ) - step = O12MotionStep( - "preflight", task.key, task.command_index, task.start_value, 0.06 - ) - target = list(profile.command.baseline_values) - for index, value in task.auxiliary_commands: - target[index] = value - target[task.command_index] = task.start_value - feedback = list(target) - # Reproduce the live log while the shared trajectory is still moving. - feedback[4] = 0.216 - feedback[5] = 0.139214 - command = list(target) - command[4] = 0.245 - command[5] = 0.160011 - paused: list[str] = [] - fake = SimpleNamespace( - command_count=12, - command_names=COMMAND_NAMES, - step_last_command_u8=tuple(command), - step_start_state_u8=tuple(profile.command.baseline_values), - step_trajectory_phase=0.92, - step_moving_indices=frozenset({4}), - latest_state_u8=(), - motor_stall_timeout_seconds=2.0, - o12_auxiliary_violation_since={}, - _current_step=lambda: step, - _task=lambda _key: task, - _target_command=lambda _step: tuple(target), - _pause=paused.append, - ) - message = SimpleNamespace(position=feedback) - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert fake.latest_o12_auxiliary_tracking["mode"] == "transitioning" - assert captured == [frozenset({4, 5, 6, 7, 8, 9, 10, 11})] - - # At the endpoint the same lag enters a timed settling state rather than - # causing an immediate false stop. - fake.step_trajectory_phase = 1.0 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert fake.latest_o12_auxiliary_tracking["mode"] == "holding" - assert fake.latest_o12_auxiliary_tracking["ready"] is False - - # Normal convergence clears the timer and allows endpoint holding. - clock[0] += 0.3 - message.position[5] = 0.168 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert fake.latest_o12_auxiliary_tracking["ready"] is True - assert 5 not in fake.o12_auxiliary_violation_since - - # A genuine persistent failure still stops after the configured timeout. - message.position[5] = 0.139214 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - clock[0] += 2.1 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused[-1].startswith( - "o12_auxiliary_settle_timeout:task=index_roll_front:" - ) - - -@pytest.mark.skip(reason="endpoint no longer waits for exact auxiliary tracking") -def test_o12_endpoint_hold_waits_for_auxiliary_axes(monkeypatch) -> None: - delegated = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_tick_radian_motion", - lambda _node, _step, _now: delegated.append(True), - ) - monkeypatch.setattr( - O12ThreeCameraCalibrationNode, - "_roll_cross_view_observer", - lambda _node, _step: None, - ) - step = O12MotionStep( - "preflight", "index_roll_front", 4, 0.26, 0.06 - ) - fake = object.__new__(O12ThreeCameraCalibrationNode) - fake.latest_o12_auxiliary_tracking = { - "active": True, - "task_name": "index_roll_front", - "mode": "holding", - "ready": False, - } - fake.step_trajectory_phase = 1.0 - fake.step_hold_since = 10.0 - O12ThreeCameraCalibrationNode._tick_radian_motion(fake, step, 11.0) - assert fake.step_hold_since is None - assert delegated == [] - - fake.latest_o12_auxiliary_tracking["ready"] = True - O12ThreeCameraCalibrationNode._tick_radian_motion(fake, step, 11.1) - assert delegated == [True] - - -@pytest.mark.skip(reason="vendor cross-drive is captured, not registered as a stop gate") -def test_o12_mcp_sweep_registers_vendor_pip_cross_drive(monkeypatch) -> None: - captured: list[frozenset[int]] = [] - monkeypatch.setattr( - L6ThreeCameraCalibrationNode, - "_state_callback", - lambda node, _message: captured.append(node.step_moving_indices), - ) - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_mcp_side" - ) - step = O12MotionStep( - "sweep", task.key, task.command_index, task.end_value, - 0.32, 0, "decreasing", 1, - ) - live_command = list(profile.command.baseline_values) - for index, value in task.auxiliary_commands: - live_command[index] = value - live_command[task.command_index] = 0.403 - feedback = list(live_command) - feedback[task.command_index] = 0.389 - # This value matches the vendor solver's expected non-diagonal - # MCP->PIP round trip and must not be treated as an unrelated motor. - feedback[9] = 0.027529 - paused: list[str] = [] - fake = SimpleNamespace( - command_count=12, - command_names=COMMAND_NAMES, - step_last_command_u8=tuple(live_command), - step_moving_indices=frozenset({8}), - latest_state_u8=(), - raw_records=[], - _current_step=lambda: step, - _task=lambda _key: task, - _target_command=lambda _step: tuple(live_command), - _pause=paused.append, - ) - message = SimpleNamespace(position=feedback) - - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert captured == [frozenset({4, 5, 8, 9, 10, 11})] - assert fake.step_moving_indices == frozenset({8}) - - # Vendor mismatch is training data, not an in-flight stop condition. - message.position[9] = 0.040 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused == [] - assert len(captured) == 2 - - message.position[9] = 0.086 - O12ThreeCameraCalibrationNode._state_callback(fake, message) - assert paused and paused[-1].startswith( - "o12_sdk_feedback_coupling_hard_limit:task=middle_mcp_side:" - ) - assert len(captured) == 2 - - -@pytest.mark.skip(reason="theoretical SDK coupling envelope was removed") -def test_o12_mcp_coupling_uses_three_training_cycles_and_holdout() -> None: - profile = build_typed_profile() - task = next( - item for item in profile.motion.tasks - if item.key == "middle_mcp_side" - ) - contract = SDK_FEEDBACK_COUPLING_BY_TASK[task.key] - raw_records = [] - offsets = (-0.002, 0.0, 0.002, 0.005) - for cycle, offset in enumerate(offsets): - for direction in ("decreasing", "increasing"): - source_values = np.linspace(0.0, 1.326, 80) - if direction == "increasing": - source_values = source_values[::-1] - for source in source_values: - state = [0.0] * 12 - state[8] = float(source) - state[9] = float( - 0.030 * math.sin(math.pi * source / 1.326) + offset - ) - raw_records.append({ - "kind": "o12_joint_sample", - "task_name": task.key, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "joint": task.joints[0], - "trajectory_command_rad": float(source), - "feedback_rad": float(source), - "state_rad": state, - }) - - training = feedback_coupling_metrics( - raw_records, task, contract, 0, "decreasing", attempt=1 - ) - assert training["decision"] == "training" - assert training["failures"] == [] - - holdout = feedback_coupling_metrics( - raw_records, task, contract, 3, "decreasing", attempt=1 - ) - assert holdout["decision"] == "holdout" - assert holdout["failures"] == [] - assert holdout["holdout_residual_p95_rad"] < 0.012 - - for row in raw_records: - if int(row["cycle"]) == 3: - row["state_rad"][9] += 0.030 - failed = feedback_coupling_metrics( - raw_records, task, contract, 3, "decreasing", attempt=1 - ) - assert failed["failures"] == ["coupling_holdout_repeatability"] - - -@pytest.mark.skip(reason="publication validates visual passive coupling instead") -def test_o12_publication_requires_holdout_for_both_mcp_tasks_and_directions( -) -> None: - profile = build_typed_profile() - tasks = { - task.key: task for task in profile.motion.tasks - if task.key in SDK_FEEDBACK_COUPLING_BY_TASK - } - raw_records = [] - for task_name, contract in SDK_FEEDBACK_COUPLING_BY_TASK.items(): - task = tasks[task_name] - for cycle, offset in enumerate((-0.002, 0.0, 0.002, 0.004)): - for direction in ("decreasing", "increasing"): - source_values = np.linspace(0.0, 1.326, 64) - if direction == "increasing": - source_values = source_values[::-1] - for source in source_values: - state = [0.0] * 12 - state[task.command_index] = float(source) - state[contract.coupled_channel_index] = float( - 0.025 * math.sin(math.pi * source / 1.326) - + offset - ) - raw_records.append({ - "kind": "o12_joint_sample", - "task_name": task_name, - "cycle": cycle, - "direction": direction, - "attempt": 1, - "joint": task.joints[0], - "trajectory_command_rad": float(source), - "feedback_rad": float(source), - "state_rad": state, - }) - - quality = session_feedback_coupling_metrics( - raw_records, profile, SDK_FEEDBACK_COUPLING_BY_TASK - ) - assert set(quality) == {"middle_mcp_side", "index_mcp_side"} - assert all( - metrics["passed"] - for task_metrics in quality.values() - for metrics in task_metrics.values() - ) - - raw_records[:] = [ - row for row in raw_records - if not ( - row["task_name"] == "index_mcp_side" - and row["cycle"] == 3 - and row["direction"] == "increasing" - ) - ] - with pytest.raises(ValueError, match="index_mcp_side MCP/PIP coupling"): - session_feedback_coupling_metrics( - raw_records, profile, SDK_FEEDBACK_COUPLING_BY_TASK - ) - - -def test_o12_status_tracking_error_uses_live_trajectory_command( -) -> None: - command = [0.0] * 12 - command[8] = 0.205 - state = list(command) - state[8] = 0.185 - state[9] = 0.016 - fake = SimpleNamespace( - command_count=12, - command_names=COMMAND_NAMES, - latest_state_u8=tuple(state), - step_last_command_u8=tuple(command), - ) - status = { - "target_state_rad": [0.0] * 8 + [1.33, 0.0, 1.38, 1.38], - "maximum_error_channel": "middle_mcp", - "maximum_error_rad": 1.145, - } - status = O12ThreeCameraCalibrationNode._apply_o12_tracking_diagnostics( - fake, status - ) - assert status["target_state_rad"][8] == pytest.approx(1.33) - assert status["maximum_error_channel"] == "middle_mcp" - assert status["maximum_error_rad"] == pytest.approx(0.020) - assert status["channel_errors_rad"][9] == pytest.approx(0.016) - - def test_o12_operator_progress_uses_unified_layout_and_radians() -> None: text = render_o12_progress_zh({ "state": "RUNNING", @@ -2137,69 +428,6 @@ def test_o12_operator_progress_uses_unified_layout_and_radians() -> None: ) in coupling_text -def test_o12_synthetic_fit_schema7_bridge_and_ring_preservation(tmp_path: Path) -> None: - result = fit_o12_session(SOURCE_URDF, _synthetic_records()) - hashes = {name: "a" * 64 for name in build_typed_profile().artifacts.protected_input_fields} - payload = build_o12_runtime_payload( - serial_number="O12_TEST", - source_urdf=SOURCE_URDF, - result=result, - protected_inputs=hashes, - passed=True, - ) - assert payload["schema_version"] == 7 - assert len(payload["joints"]["thumb_cmc_yaw"]["angle_rad"]) == 65 - assert payload["joints"]["thumb_cmc_roll"]["raw_increasing_curve_branch"] == "decreasing" - assert payload["joints"]["thumb_cmc_yaw"]["raw_increasing_curve_branch"] == "increasing" - assert "command_range" not in payload - assert payload["joints"]["ring_mcp_pitch"]["transferred_from_joint"] == "pinky_mcp_pitch" - mapper = CalibratedCommandMapper(payload) - assert len(mapper.map_positions([0.0] * 12, ["wrong"] * 12)) == 19 - - maximum_feedback = [ - SAFE_UPPER_RAD[index] if SDK_TO_URDF_SIGN[index] > 0.0 - else build_typed_profile().command.lower_bounds[index] - for index in range(12) - ] - mapped = dict(zip( - mapper.urdf_joint_names, - mapper.map_positions(maximum_feedback, ["wrong"] * 12), - )) - assert mapped["thumb_cmc_roll"] == pytest.approx(0.720) - assert mapped["thumb_cmc_yaw"] == pytest.approx(0.987) - assert mapped["thumb_cmc_pitch"] == pytest.approx(0.588) - assert mapped["thumb_mcp"] == pytest.approx(1.184) - assert mapped["index_mcp_pitch"] == pytest.approx(1.290) - assert mapped["index_pip"] == pytest.approx(1.635) - assert mapped["middle_mcp_pitch"] == pytest.approx(1.318) - assert mapped["middle_pip"] == pytest.approx(1.604) - assert mapped["ring_mcp_pitch"] == pytest.approx(1.38) - assert mapped["pinky_mcp_pitch"] == pytest.approx(1.472) - # Passive runtime comes from the O12 vendor polynomial rather than the - # planar Tag fit or the linear URDF visualization fallback. - assert mapped["pinky_dip"] == pytest.approx(1.2909331373, abs=1.0e-8) - - correction = write_o12_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="O12_TEST", - result=result, - timestamp="20260904_120000", - ) - before = ET.parse(SOURCE_URDF).getroot() - after = ET.parse(correction.path).getroot() - by_name_before = {node.get("name"): node for node in before.findall("joint")} - by_name_after = {node.get("name"): node for node in after.findall("joint")} - for name in ("ring_mcp_pitch", "ring_pip", "ring_dip"): - assert ET.tostring(by_name_before[name]) == ET.tostring(by_name_after[name]) - # Rotation-only input has no static phase evidence. Travel differences - # must not create bent/uneven zero poses or a fabricated thumb origin. - assert all(value == 0.0 for value in correction.origin_offsets_rad.values()) - for name in CALIBRATED_ACTIVE_JOINTS: - assert ET.tostring(by_name_before[name].find("origin")) == ET.tostring( - by_name_after[name].find("origin") - ) - validate_o12_runtime_payload_against_urdf(payload, correction.path) def test_o12_vendor_passive_solver_regression() -> None: @@ -2214,148 +442,21 @@ def test_o12_vendor_passive_solver_regression() -> None: ) == pytest.approx(1.291363184, abs=1.0e-9) -def test_o12_rejects_direction_reversal_and_curve_urdf_mismatch( - tmp_path: Path, -) -> None: - result = fit_o12_session(SOURCE_URDF, _synthetic_records()) - hashes = { - name: "a" * 64 - for name in build_typed_profile().artifacts.protected_input_fields - } - payload = build_o12_runtime_payload( - serial_number="O12_TEST", - source_urdf=SOURCE_URDF, - result=result, - protected_inputs=hashes, - passed=True, - ) - payload["joints"]["thumb_cmc_roll"]["angle_rad"][-1] = 0.54 - with pytest.raises(ValueError, match="SDK/URDF direction"): - validate_o12_runtime_payload(payload) - - payload = build_o12_runtime_payload( - serial_number="O12_TEST", - source_urdf=SOURCE_URDF, - result=result, - protected_inputs=hashes, - passed=True, - ) - payload["joints"]["thumb_dip"]["angle_rad"][-1] = 2.47 - correction = write_o12_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="O12_TEST", - result=result, - timestamp="20260904_120001", - ) - with pytest.raises(ValueError, match="above the URDF physical limit"): - validate_o12_runtime_payload_against_urdf(payload, correction.path) -def test_o12_fit_does_not_require_fake_u8_endpoints() -> None: - records = _synthetic_records() - profile = build_typed_profile() - task_by_joint = { - joint: task for task in profile.motion.tasks for joint in task.joints - } - for joint, rows in records.items(): - task = task_by_joint[joint] - for row in rows: - phase = ( - float(row["feedback_rad"]) - task.start_value - ) / (task.end_value - task.start_value) - observed_phase = 0.02 + 0.96 * phase - row["feedback_rad"] = float( - task.start_value - + observed_phase * (task.end_value - task.start_value) - ) - - result = fit_o12_session(SOURCE_URDF, records) - - assert result.curves - assert all(math.isfinite(value) for value in result.travels_rad.values()) -def test_o12_holdout_endpoint_dwell_has_one_curve_bin_weight() -> None: - rows = [ - {"direction": "decreasing", "command_u8": 0} - for _ in range(40) - ] - errors = [math.radians(2.3)] * 40 - for command in range(1, 65): - rows.append({"direction": "decreasing", "command_u8": command}) - errors.append(math.radians(0.2)) - - uniform = np.degrees(np.abs( - _uniform_curve_holdout_errors(rows, errors) - )) - - assert len(uniform) == 65 - assert np.percentile(uniform, 95) == pytest.approx(0.2) - assert np.max(uniform) == pytest.approx(2.3) -def test_o12_fit_rejects_a_session_that_did_not_scan_the_full_sdk_domain() -> None: - records = _synthetic_records() - for row in records["thumb_cmc_yaw"]: - if int(row["cycle"]) > 0: - row["feedback_rad"] = 0.8 * float(row["feedback_rad"]) - - with pytest.raises(ValueError, match="repeats only.*cycle-zero"): - fit_o12_session(SOURCE_URDF, records) -def test_o12_endpoint_zero_is_written_to_the_urdf_origin( - tmp_path: Path, -) -> None: - fitted = fit_o12_session(SOURCE_URDF, _synthetic_records()) - offsets = dict(fitted.zero_offsets_rad) - offsets["thumb_cmc_yaw"] = -0.1 - fitted = replace(fitted, zero_offsets_rad=offsets) - hashes = { - name: "a" * 64 - for name in build_typed_profile().artifacts.protected_input_fields - } - payload = build_o12_runtime_payload( - serial_number="O12_TEST", - source_urdf=SOURCE_URDF, - result=fitted, - protected_inputs=hashes, - passed=True, - ) - correction = write_o12_corrected_urdf( - source_urdf=SOURCE_URDF, - output_directory=tmp_path, - serial_number="O12_TEST", - result=fitted, - timestamp="20260904_120002", - ) - before = ET.parse(SOURCE_URDF).getroot() - after = ET.parse(correction.path).getroot() - before_joint = before.find("joint[@name='thumb_cmc_yaw']/origin") - after_joint = after.find("joint[@name='thumb_cmc_yaw']/origin") - assert before_joint is not None and after_joint is not None - assert before_joint.get("rpy") != after_joint.get("rpy") - assert correction.origin_offsets_rad["thumb_cmc_yaw"] == pytest.approx(-0.1) - validate_o12_runtime_payload_against_urdf(payload, correction.path) -def test_o12_synthetic_roll_cross_view_is_independently_fitted() -> None: - records = _synthetic_records() - cross_view = { - joint: records[joint] - for joint in ("middle_mcp_roll", "index_mcp_roll") - } - result = fit_o12_session( - SOURCE_URDF, - records, - cross_view_records_by_joint=cross_view, - require_cross_view=True, - ) - assert set(result.cross_view_roll_metrics) == { - "middle_mcp_roll", "index_mcp_roll" - } - assert all( - metrics["holdout_max_rad"] < 1.0e-6 - for metrics in result.cross_view_roll_metrics.values() - ) + + + + +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_static_geometry.py b/src/linkerhand_calibration/test/test_o12_static_geometry.py deleted file mode 100644 index 2439c07..0000000 --- a/src/linkerhand_calibration/test/test_o12_static_geometry.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Absolute zero and transfer tests, independent of curve-fit repeatability.""" - -from dataclasses import replace -import math -import xml.etree.ElementTree as ET - -import numpy as np -import pytest -from scipy.spatial.transform import Rotation - -from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.models.o12.fitting import ( - O12_THUMB_ROOT_AXIS_JOINTS, _fit_thumb_root_zero, fit_o12_session, -) -from linkerhand_calibration.models.o12.profile import build_typed_profile -from linkerhand_calibration.models.o12.artifacts import ( - build_o12_runtime_payload, validate_o12_runtime_payload, - validate_o12_runtime_payload_against_urdf, -) -from linkerhand_calibration.models.o12.urdf import write_o12_corrected_urdf -from test_o12_right_profile import SOURCE_URDF, _synthetic_records - - -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 _pose(t): - return {"translation_xyz_m": t[:3, 3].tolist(), - "quaternion_xyzw": Rotation.from_matrix(t[:3, :3]).as_quat().tolist()} - - -@pytest.fixture(scope="module") -def rotation_fit(): - return fit_o12_session(SOURCE_URDF, _synthetic_records()) - - -def _geometry_records(fit, offsets, camera_rpy): - model = UrdfKinematicModel(SOURCE_URDF) - tasks = {j: t for t in build_typed_profile().motion.tasks for j in t.joints} - palm = _transform([.02, -.01, .7], camera_rpy) - records = {} - for i, name in enumerate(O12_THUMB_ROOT_AXIS_JOINTS): - task = tasks[name] - mounting = _transform([.012, -.009, .02], [.13 * i, -.24, .31]) - parent = palm @ _transform([-.01, .007, .003], [-.12, .1 * i, .2]) - rows = [] - for cycle in range(4): - for direction, phases in (("decreasing", np.linspace(0, 1, 65)), - ("increasing", np.linspace(1, 0, 65))): - for phase in phases: - state = [0.] * 12 - state[task.command_index] = task.start_value + phase * (task.end_value - task.start_value) - child = palm @ model.link_transform( - name, zero_offsets=offsets, - joint_angles={name: float(phase * fit.travels_rad[name])}, - ) @ mounting - relative = np.linalg.inv(parent) @ child - rows.append({ - "cycle": cycle, "direction": direction, - "feedback_rad": state[task.command_index], "state_rad": state, - "relative_quaternion_xyzw": _pose(relative)["quaternion_xyzw"], - "relative_translation_xyz_m": relative[:3, 3].tolist(), - "parent_pose_common": _pose(parent), "child_pose_common": _pose(child), - "view_normal_common_xyz": [0., 0., 1.], - "camera_center_common_xyz_m": [0., 0., 0.], - }) - records[name] = rows - return records - - -@pytest.mark.parametrize("camera_rpy", [[.2, -.3, .4], [-.3, .5, -.6]]) -def test_root_zeros_recover_known_geometry_with_arbitrary_tag_mounts(rotation_fit, camera_rpy, tmp_path): - truth = {"thumb_cmc_roll": .08, "thumb_cmc_yaw": -.12} - rows = _geometry_records(rotation_fit, truth, camera_rpy) - solved = _fit_thumb_root_zero(SOURCE_URDF, rows, rotation_fit.curves, - rotation_fit.feedback_domains_rad) - assert solved.passed - assert set(solved.direct_offsets_rad) == set(truth) - for name, value in truth.items(): - assert solved.direct_offsets_rad[name] == pytest.approx(value, abs=1.e-5) - result = replace(rotation_fit, zero_offsets_rad={ - **rotation_fit.zero_offsets_rad, **solved.direct_offsets_rad, - }) - corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF, - output_directory=tmp_path, serial_number="ROOT", result=result) - actual, original = UrdfKinematicModel(corrected.path), UrdfKinematicModel(SOURCE_URDF) - for state in ({}, {"thumb_cmc_roll": .72}, - {"thumb_cmc_roll": .36, "thumb_cmc_yaw": .4, "thumb_cmc_pitch": .3, "thumb_mcp": .5}): - assert actual.link_transform("thumb_mcp", zero_offsets={}, joint_angles=state) == pytest.approx( - original.link_transform("thumb_mcp", zero_offsets=truth, joint_angles=state), abs=1.e-5) - - -def test_root_holdout_rejects_changed_camera_pose(rotation_fit): - rows = _geometry_records(rotation_fit, {"thumb_cmc_roll": .08, "thumb_cmc_yaw": -.12}, [.2, -.3, .4]) - rotation = Rotation.from_rotvec([.15, .05, .1]) - for row in rows["thumb_cmc_yaw"]: - if row["cycle"] == 3: - for field in ("parent_pose_common", "child_pose_common"): - p = row[field] - p["translation_xyz_m"] = rotation.apply(p["translation_xyz_m"]).tolist() - p["quaternion_xyzw"] = (rotation * Rotation.from_quat(p["quaternion_xyzw"])).as_quat().tolist() - with pytest.raises(ValueError, match="spatial zero solve failed"): - _fit_thumb_root_zero(SOURCE_URDF, rows, rotation_fit.curves, - rotation_fit.feedback_domains_rad) - - -def test_rotation_only_records_cannot_pass_production_static_solve(): - with pytest.raises(ValueError, match="absolute zero requires"): - fit_o12_session(SOURCE_URDF, _synthetic_records(), require_thumb_root_spatial_zero=True) - - -def test_ring_transfers_nonzero_correction_without_copying_geometry(rotation_fit, tmp_path): - offsets = {**rotation_fit.zero_offsets_rad, "pinky_mcp_pitch": .06, "ring_mcp_pitch": .06} - result = replace(rotation_fit, zero_offsets_rad=offsets) - corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF, - output_directory=tmp_path, serial_number="TRANSFER", result=result) - original = ET.parse(SOURCE_URDF).getroot() - actual = ET.parse(corrected.path).getroot() - for name in ("pinky_mcp_pitch", "ring_mcp_pitch"): - before = original.find(f"joint[@name='{name}']/origin") - after = actual.find(f"joint[@name='{name}']/origin") - assert before.get("xyz") == after.get("xyz") - rb = Rotation.from_euler("xyz", [float(x) for x in before.get("rpy").split()]) - ra = Rotation.from_euler("xyz", [float(x) for x in after.get("rpy").split()]) - assert (rb.inv() * ra).as_rotvec() == pytest.approx([0., .06, 0.], abs=1.e-10) - for name in ("ring_pip", "ring_dip"): - assert ET.tostring(original.find(f"joint[@name='{name}']")) == ET.tostring(actual.find(f"joint[@name='{name}']")) - hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields} - payload = build_o12_runtime_payload(serial_number="TRANSFER", source_urdf=SOURCE_URDF, - result=result, protected_inputs=hashes, passed=True) - ring = payload["joints"]["ring_mcp_pitch"] - pinky = payload["joints"]["pinky_mcp_pitch"] - # Equal feedback must have equal corrections before the ring saturates. - for i, value in enumerate(pinky["angle_rad"]): - assert ring["angle_rad"][i] == pytest.approx(min(1.38, value)) - validate_o12_runtime_payload_against_urdf(payload, corrected.path, source_urdf=SOURCE_URDF) - payload["joints"]["ring_mcp_pitch"]["static_urdf_origin_offset_rad"] = 0. - with pytest.raises(ValueError, match="static zero differs"): - validate_o12_runtime_payload(payload) - - -def test_static_frame_validator_catches_correct_limits_but_wrong_origin(rotation_fit, tmp_path): - corrected = write_o12_corrected_urdf(source_urdf=SOURCE_URDF, - output_directory=tmp_path, serial_number="FRAME", result=rotation_fit) - hashes = {k: "a" * 64 for k in build_typed_profile().artifacts.protected_input_fields} - payload = build_o12_runtime_payload(serial_number="FRAME", source_urdf=SOURCE_URDF, - result=rotation_fit, protected_inputs=hashes, passed=True) - tree = ET.parse(corrected.path) - tree.getroot().find("joint[@name='thumb_cmc_pitch']/origin").set("rpy", "0 0.9259 -1.5708") - tree.write(corrected.path) - with pytest.raises(ValueError, match="thumb_cmc_pitch static origin disagrees"): - validate_o12_runtime_payload_against_urdf(payload, corrected.path, source_urdf=SOURCE_URDF) diff --git a/src/linkerhand_calibration/test/test_o12_thumb_pnp.py b/src/linkerhand_calibration/test/test_o12_thumb_pnp.py index d810799..9d95cef 100644 --- a/src/linkerhand_calibration/test/test_o12_thumb_pnp.py +++ b/src/linkerhand_calibration/test/test_o12_thumb_pnp.py @@ -1,11 +1,7 @@ -import math -from types import SimpleNamespace - -import numpy as np import pytest from scipy.spatial.transform import Rotation as R -from linkerhand_calibration.models.o12.pnp import O12ThumbPoseTracker, THUMB_ROLES +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o12.pnp import O12ThumbPoseTracker, THUMB_ROLES from linkerhand_calibration.pnp import SquareTagPose @@ -63,62 +59,3 @@ def test_missing_tag_discards_observation_and_gap_reinitializes(): 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 - - -def test_hook_only_applies_to_o12_thumb_task(): - from linkerhand_calibration.models.o12.node import O12ThreeCameraCalibrationNode - from linkerhand_calibration.models.l6.node import L6ThreeCameraCalibrationNode - from linkerhand_calibration.models.o6.node import O6ThreeCameraCalibrationNode - assert not hasattr(L6ThreeCameraCalibrationNode,'_select_articulated_capture_poses') - assert not hasattr(O6ThreeCameraCalibrationNode,'_select_articulated_capture_poses') - hook=O12ThreeCameraCalibrationNode._select_articulated_capture_poses - assert hook(SimpleNamespace(),'side',None,(),{},0) is None - assert hook(SimpleNamespace(),'front',SimpleNamespace(task_key='thumb_pitch_front'),(),{},0) is None - - -def test_hook_records_corners_all_candidates_and_rejected_frames(monkeypatch, tmp_path): - import json - from linkerhand_calibration.models.o12 import node as module - from linkerhand_calibration.models.o12.profile import build_typed_profile - profile=build_typed_profile() - front=next(v for v in profile.vision.views if v.name == 'front') - good=pose(R.identity()) - high_error=pose(R.from_euler('y',.5),3.) - monkeypatch.setattr(module,'solve_square_tag_ippe',lambda *a,**k:[good,high_error]) - raw=tmp_path/'raw.jsonl' - node=SimpleNamespace(trackers={'front':SimpleNamespace(maximum_reprojection_error_px=1.5)}, - camera_matrices={'front':np.diag([500.,500.,1.])},tag_size_m=.016, - raw_path=raw,_view=lambda view:front) - step=SimpleNamespace(task_key='thumb_mcp_dip_front',cycle=0,direction='decreasing',attempt=1,phase='sweep') - corners={role:np.array([[1.,1.],[2.,1.],[2.,2.],[1.,2.]]) for role in THUMB_ROLES} - hook=module.O12ThreeCameraCalibrationNode._select_articulated_capture_poses - for i in range(8): - selected,reason=hook(node,'front',step,THUMB_ROLES,corners,i*30_000_000) - assert selected is not None - records=[json.loads(line) for line in raw.read_text().splitlines()] - assert len(records)==8 - assert records[0]['roles']['thumb_dip']['selected'] is None - assert len(records[-1]['roles']['thumb_dip']['candidates'])==2 - assert records[-1]['roles']['thumb_dip']['eligible_candidate_count']==1 - assert records[-1]['roles']['thumb_dip']['tag_id']==3 - assert records[-1]['camera_matrix']==node.camera_matrices['front'].tolist() - assert records[-1]['roles']['thumb_mcp']['corners_xy']==corners['thumb_mcp'].tolist() - - -def test_other_task_evidence_distinguishes_locked_reference_from_pixels(tmp_path): - import json - from linkerhand_calibration.models.o12.node import O12ThreeCameraCalibrationNode - roles=('front_base','moving') - poses={r:pose(R.identity()) for r in roles} - node=SimpleNamespace(raw_path=tmp_path/'raw.jsonl',tag_size_m=.016, - camera_matrices={'front':np.eye(3)}, - trackers={'front':SimpleNamespace(last_candidates_by_role={r:(p,) for r,p in poses.items()},maximum_reprojection_error_px=1.5)}, - _view=lambda v:SimpleNamespace(tags=[SimpleNamespace(role=r,tag_id=i) for i,r in enumerate(roles)])) - step=SimpleNamespace(task_key='index_roll_front',cycle=0,direction='decreasing',attempt=1,phase='sweep') - corners={r:np.zeros((4,2)) for r in roles} - O12ThreeCameraCalibrationNode._record_capture_pose_evidence(node,'front',step,roles,corners,poses,1,locked_roles=('front_base',)) - record=json.loads(node.raw_path.read_text()) - assert record['camera_matrix_source']=='CameraInfo.P[:3,:3]' - assert record['roles']['front_base']['observation_source']=='locked_reference' - assert record['roles']['front_base']['candidates']==[] - assert record['roles']['moving']['observation_source']=='image' diff --git a/src/linkerhand_calibration/test/test_o6_right_profile.py b/src/linkerhand_calibration/test/test_o6_right_profile.py index 8d1c665..5976231 100644 --- a/src/linkerhand_calibration/test/test_o6_right_profile.py +++ b/src/linkerhand_calibration/test/test_o6_right_profile.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -from types import SimpleNamespace import xml.etree.ElementTree as ET import numpy as np @@ -11,17 +10,12 @@ 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.models.g20.zero_solver import UrdfKinematicModel -from linkerhand_calibration.models.l6.node import MotionStep -from linkerhand_calibration.models.o6.artifacts import ( - build_o6_left_transferred_runtime_payload, - build_o6_runtime_payload, +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.models.o6.fitting import fit_o6_session -from linkerhand_calibration.models.o6.fitting import _zero_profile -from linkerhand_calibration.models.o6.node import O6ThreeCameraCalibrationNode -from linkerhand_calibration.models.o6.profile import ( +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, @@ -38,11 +32,7 @@ from linkerhand_calibration.models.o6.profile import ( TRANSFERRED_PASSIVE_SOURCE_BY_JOINT, build_typed_profile, ) -from linkerhand_calibration.models.o6.runner import render_o6_progress_zh -from linkerhand_calibration.models.o6.urdf import ( - write_o6_corrected_urdf, - write_o6_left_from_right_calibration, -) +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.o6.runner import render_o6_progress_zh PACKAGE = Path(__file__).resolve().parents[1] @@ -73,7 +63,8 @@ def test_o6_profile_declares_reviewed_six_channel_contract() -> None: 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({"rh_thumb_cmc_yaw"}) + 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 @@ -90,7 +81,7 @@ def test_o6_profile_declares_reviewed_six_channel_contract() -> None: "thumb_yaw_top", "thumb_pitch_ip_front", "pinky_pitch_dip_side" ] assert profile.zero.coupling_model_by_joint == { - name: "quadratic_runtime" for name in PASSIVE_JOINTS + name: "linear_mimic" for name in PASSIVE_JOINTS } @@ -178,44 +169,6 @@ def test_o6_baseline_failure_identifies_the_slowest_channel() -> None: assert "原因:目标电机连续两秒没有向目标推进" in text -def test_o6_motion_keeps_all_non_target_channels_at_255() -> None: - published: list[list[int]] = [] - fake = SimpleNamespace( - baseline_command=(255,) * 6, - step_started_at=0.0, - step_start_state_u8=(255.0,) * 6, - command_trajectory_full_range_seconds=6.0, - step_last_command_u8=None, - step_trajectory_phase=0.0, - step_requested_u8=255.0, - _publish_command=lambda values: published.append(values), - ) - step = MotionStep("sweep", "thumb_yaw_top", 1, 0, 1, 0, "decreasing") - for tick in range(601): - O6ThreeCameraCalibrationNode._advance_step_trajectory(fake, step, tick / 100.0) - assert published[0] == [255] * 6 - assert published[-1] == [255, 0, 255, 255, 255, 255] - assert all(command[0] == 255 and command[2:] == [255] * 4 for command in published) - - -def test_o6_motion_plan_uses_o6_specific_speed_tiers() -> None: - fake = SimpleNamespace( - profile=build_typed_profile(), - baseline_speed_u8=BASELINE_SPEED_U8, - preflight_speed_u8=PREFLIGHT_SPEED_U8, - formal_speed_u8=FORMAL_SPEED_U8, - ) - steps = O6ThreeCameraCalibrationNode._build_steps(fake) - assert steps[0].phase == "baseline" - assert steps[0].speed_u8 == 80 - assert not [step for step in steps if step.phase == "preflight"] - assert { - step.speed_u8 - for step in steps - if step.phase in {"prepare", "sweep"} - } == {40} - - def _valid_payload() -> dict: profile = build_typed_profile() curves = list(np.linspace(1.0, 0.0, 256)) @@ -313,54 +266,6 @@ def _curve_records(travel: float) -> list[dict]: return rows -def test_o6_fit_artifact_bridge_and_urdf_patch_end_to_end(tmp_path: Path) -> None: - records = { - "rh_thumb_cmc_pitch": _curve_records(0.5), - "rh_thumb_cmc_yaw": _curve_records(1.2), - "rh_thumb_ip": _curve_records(0.5 * 1.86), - "rh_pinky_mcp_pitch": _curve_records(1.2), - "rh_pinky_dip": _curve_records(1.2 * 0.89), - } - result = fit_o6_session(SOURCE, records) - assert result.mimic_fits["rh_thumb_ip"].multiplier == pytest.approx(1.86) - assert result.mimic_fits["rh_pinky_dip"].multiplier == pytest.approx(0.89) - payload = build_o6_runtime_payload( - serial_number="O6_TEST", - source_urdf=SOURCE, - result=result, - protected_inputs={ - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - }, - ) - mapper = CalibratedCommandMapper(payload, expected_side="right") - assert payload["quality"]["maximum_hysteresis_limit_rad"] == pytest.approx( - np.deg2rad(3.5), abs=1e-8 - ) - assert set(payload["quality"]["maximum_hysteresis_by_joint_rad"]) == set( - ACTIVE_JOINTS[:2] + (ACTIVE_JOINTS[-1],) + PASSIVE_JOINTS[::4] - ) - mapped = dict(zip(mapper.urdf_joint_names, mapper.map_positions([0] * 6))) - assert mapped["rh_thumb_ip"] == pytest.approx(0.93, abs=2e-4) - assert mapped["rh_index_mcp_pitch"] == pytest.approx(1.2, abs=2e-4) - correction = write_o6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path, - serial_number="O6_TEST", - result=result, - ) - corrected = ET.parse(correction.path).getroot() - joints = {joint.get("name"): joint for joint in corrected.findall("joint")} - assert result.mimic_fits["rh_thumb_ip"].model == "quadratic_runtime" - assert result.mimic_fits["rh_pinky_dip"].model == "quadratic_runtime" - assert float(joints["rh_thumb_ip"].find("mimic").get("multiplier")) == pytest.approx(1.86) - assert float(joints["rh_pinky_dip"].find("mimic").get("multiplier")) == pytest.approx(0.89) - assert set(correction.mimic_multipliers) == set(PASSIVE_JOINTS) - assert correction.explicit_runtime_joints == frozenset(PASSIVE_JOINTS) - assert float(joints["rh_thumb_ip"].find("limit").get("upper")) == pytest.approx(0.93, abs=2e-4) - assert float(joints["rh_index_mcp_pitch"].find("limit").get("upper")) == pytest.approx(1.2) def _pose(xyz: list[float], rpy: list[float]) -> np.ndarray: @@ -470,98 +375,3 @@ def _geometric_records() -> dict[str, list[dict]]: "state_u8": state, }) return result - - -def test_o6_uses_geometric_yaw_and_cad_endpoint_pitch() -> None: - result = fit_o6_session( - SOURCE, _geometric_records(), require_thumb_axis_zero=True - ) - assert result.zero_offsets_rad["rh_thumb_cmc_yaw"] == pytest.approx(0.03, abs=1e-6) - assert result.zero_offsets_rad["rh_thumb_cmc_pitch"] == pytest.approx(0.0) - assert result.zero_method_by_joint["rh_thumb_cmc_yaw"] == ( - "urdf_serial_axis_geometry" - ) - assert result.zero_method_by_joint["rh_thumb_cmc_pitch"] == ( - "mechanical_lower_endpoint" - ) - assert result.zero_fallback_reason_by_joint == {} - assert result.thumb_zero_result.direct_offsets_rad[ - "rh_thumb_cmc_pitch" - ] == pytest.approx(-0.02, abs=1e-6) - - -def test_o6_right_corrections_are_mirrored_without_replacing_left_geometry( - tmp_path: Path, -) -> None: - result = fit_o6_session( - SOURCE, _geometric_records(), require_thumb_axis_zero=True - ) - right = write_o6_corrected_urdf( - source_urdf=SOURCE, - output_directory=tmp_path / "right", - serial_number="RIGHT_TEST", - result=result, - ).path - destination = write_o6_left_from_right_calibration( - source_left_urdf=LEFT_SOURCE, - source_right_urdf=SOURCE, - calibrated_right_urdf=right, - destination_urdf=tmp_path / "left" / "o6_left_transferred.urdf", - ) - original = _joint_elements(LEFT_SOURCE) - transferred = _joint_elements(destination) - corrected_right = _joint_elements(right) - for left_name, joint in transferred.items(): - right_name = left_name.replace("lh_", "rh_", 1) - assert joint.find("origin").get("xyz") == original[left_name].find( - "origin" - ).get("xyz") - assert joint.find("axis").attrib == original[left_name].find("axis").attrib - assert joint.find("limit").get("upper") == corrected_right[ - right_name - ].find("limit").get("upper") - if joint.find("mimic") is not None: - assert joint.find("mimic").get("multiplier") == corrected_right[ - right_name - ].find("mimic").get("multiplier") - assert transferred["lh_thumb_cmc_pitch"].find("origin").get("rpy") == ( - original["lh_thumb_cmc_pitch"].find("origin").get("rpy") - ) - left_yaw = Rotation.from_euler( - "xyz", - [float(value) for value in transferred["lh_thumb_cmc_yaw"].find( - "origin" - ).get("rpy").split()], - ) - assert left_yaw.as_rotvec()[2] == pytest.approx(0.03, abs=1e-6) - - right_payload = build_o6_runtime_payload( - serial_number="RIGHT_TEST", - source_urdf=SOURCE, - result=result, - protected_inputs={ - "source_urdf_sha256": "0" * 64, - "camera_extrinsics_sha256": "1" * 64, - "calibration_config_sha256": "2" * 64, - "tag_config_sha256": "3" * 64, - }, - ) - left_payload = build_o6_left_transferred_runtime_payload( - right_payload=right_payload, - source_left_urdf=LEFT_SOURCE, - transferred_left_urdf=destination, - serial_number="LEFT_TRANSFER_TEST", - ) - validate_o6_runtime_payload(left_payload) - mapper = CalibratedCommandMapper(left_payload, expected_side="left") - assert mapper.profile_id == "O6/left/o6_left_transferred_8/v1" - assert set(mapper.urdf_joint_names) == set(transferred) - mapped = dict(zip( - mapper.urdf_joint_names, - mapper.map_positions([0, 255, 255, 255, 255, 255]), - )) - assert mapped["lh_thumb_cmc_pitch"] == pytest.approx(0.5, abs=2.0e-4) - assert mapped["lh_thumb_ip"] == pytest.approx(0.93, abs=2.0e-4) - assert left_payload["quality"]["transfer_provenance"][ - "left_hand_measured" - ] is False diff --git a/src/linkerhand_calibration/test/test_observation_capture.py b/src/linkerhand_calibration/test/test_observation_capture.py new file mode 100644 index 0000000..4aa4c39 --- /dev/null +++ b/src/linkerhand_calibration/test/test_observation_capture.py @@ -0,0 +1,92 @@ +"""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, np.array([[1200, 0, 640], [0, 1200, 480], [0, 0, 1]]), + corners, values if feedback else None, 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 + for index in range(9): + 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(10): + 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(10): + 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 diff --git a/src/linkerhand_calibration/test/test_package_rename.py b/src/linkerhand_calibration/test/test_package_rename.py index 7a3a6a4..658cded 100644 --- a/src/linkerhand_calibration/test/test_package_rename.py +++ b/src/linkerhand_calibration/test/test_package_rename.py @@ -35,7 +35,7 @@ def test_new_and_legacy_executable_names_share_one_implementation() -> None: setup_text, ) assert ( - "linkerhand_calibration.runtime.nodes.calibration:main" + "linkerhand_calibration.runtime.ros.entrypoint:main" in setup_text ) diff --git a/src/linkerhand_calibration/test/test_pnp.py b/src/linkerhand_calibration/test/test_pnp.py index f66923e..cc6f166 100644 --- a/src/linkerhand_calibration/test/test_pnp.py +++ b/src/linkerhand_calibration/test/test_pnp.py @@ -140,54 +140,31 @@ def test_clear_reprojection_advantage_releases_stale_mirror_branch() -> None: assert selected == true_pose -def test_active_motion_can_prioritise_continuous_branch() -> None: - previous = 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, - ) - continuous = 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, - ) - discontinuous = 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, - ) - - selected, reason = select_continuous_pose( - [continuous, discontinuous], - previous=previous, - maximum_reprojection_error_px=1.5, - reprojection_tie_px=1.5, +@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), + maximum_translation_jump_m=0.04, maximum_tag_tilt_rad=np.deg2rad(75.0), ) - - assert reason == "" - assert selected == continuous + 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, - reprojection_tie_px=1.5, - maximum_pose_jump_rad=np.deg2rad(5.0), + 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) - second_rotation = Rotation.from_euler("y", 20.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) first, first_reason = tracker.estimate( "t0", _project(first_rotation, np.asarray([0.0, 0.0, 0.25])), @@ -237,7 +214,6 @@ def _pose( def test_group_tracker_receives_oblique_reprojection_valid_candidates() -> None: per_tag = SquareTagPoseTracker( maximum_reprojection_error_px=1.5, - reprojection_tie_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), diff --git a/src/linkerhand_calibration/test/test_pnp_runtime_policy.py b/src/linkerhand_calibration/test/test_pnp_runtime_policy.py new file mode 100644 index 0000000..f720942 --- /dev/null +++ b/src/linkerhand_calibration/test/test_pnp_runtime_policy.py @@ -0,0 +1,72 @@ +"""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,), + (_pose(54, 0.24), accurate), + # 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, expected in enumerate((stale, accurate, continued)): + 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, + ) + assert reason == "" + assert selected == expected + 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_profile_finalization.py b/src/linkerhand_calibration/test/test_profile_finalization.py new file mode 100644 index 0000000..684df26 --- /dev/null +++ b/src/linkerhand_calibration/test/test_profile_finalization.py @@ -0,0 +1,221 @@ +"""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): + base_layout = "l6_right_8" if layout == "virtual_reordered" else layout + profile = load_bundled_hand_profile(base_layout) + 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") + 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 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 new file mode 100644 index 0000000..f3c943f --- /dev/null +++ b/src/linkerhand_calibration/test/test_profile_loader.py @@ -0,0 +1,136 @@ +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_v4_dual_mapping} +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 new file mode 100644 index 0000000..6f5ffc4 --- /dev/null +++ b/src/linkerhand_calibration/test/test_progress_status.py @@ -0,0 +1,105 @@ +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, "verified", completed_units=completed) + ) + assert session.phase == CalibrationPhase.PREPARE + assert session.current_unit.task_key != first_task diff --git a/src/linkerhand_calibration/test/test_rectified_camera_contract.py b/src/linkerhand_calibration/test/test_rectified_camera_contract.py index 71dd358..16d4654 100644 --- a/src/linkerhand_calibration/test/test_rectified_camera_contract.py +++ b/src/linkerhand_calibration/test/test_rectified_camera_contract.py @@ -28,16 +28,46 @@ def test_invalid_p_cannot_silently_fall_back_to_k(bad): def test_shared_camera_callback_uses_p_saves_provenance_and_invalidates(tmp_path): import json - from linkerhand_calibration.models.l6.node import L6ThreeCameraCalibrationNode - node=SimpleNamespace(camera_matrices={},image_sizes={},raw_path=tmp_path/'raw.jsonl') 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) - callback=L6ThreeCameraCalibrationNode._camera_info_callback + node = camera_host(tmp_path, msg) + callback=accept_camera callback(node,'front',msg); callback(node,'front',msg) - np.testing.assert_allclose(node.camera_matrices['front'],np.array(p).reshape(3,4)[:,:3]) + 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.camera_matrices + 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 new file mode 100644 index 0000000..5494277 --- /dev/null +++ b/src/linkerhand_calibration/test/test_reference_lock.py @@ -0,0 +1,45 @@ +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 new file mode 100644 index 0000000..2d21a58 --- /dev/null +++ b/src/linkerhand_calibration/test/test_reference_waypoints.py @@ -0,0 +1,59 @@ +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") + 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 new file mode 100644 index 0000000..14caefa --- /dev/null +++ b/src/linkerhand_calibration/test/test_relative_standard_mimic.py @@ -0,0 +1,53 @@ +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_v2.py b/src/linkerhand_calibration/test/test_resume_v2.py new file mode 100644 index 0000000..830ca56 --- /dev/null +++ b/src/linkerhand_calibration/test/test_resume_v2.py @@ -0,0 +1,48 @@ +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_runtime_readiness.py b/src/linkerhand_calibration/test/test_runtime_readiness.py new file mode 100644 index 0000000..6aa334d --- /dev/null +++ b/src/linkerhand_calibration/test/test_runtime_readiness.py @@ -0,0 +1,96 @@ +"""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, + 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(): + host = SimpleNamespace(_steady_rows=[], _unit_rows=[], _capture_unit_key=None) + 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 == [] + + +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 new file mode 100644 index 0000000..a13ea3f --- /dev/null +++ b/src/linkerhand_calibration/test/test_safety_policy.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +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 + + +@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" diff --git a/src/linkerhand_calibration/test/test_session_execution.py b/src/linkerhand_calibration/test/test_session_execution.py new file mode 100644 index 0000000..4691521 --- /dev/null +++ b/src/linkerhand_calibration/test/test_session_execution.py @@ -0,0 +1,120 @@ +"""Virtual-clock tests use the same effect driver intended for the ROS host.""" + + +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 + + +@pytest.mark.parametrize("layout", ["l6_right_8", "o6_right_8", "o12_right_16", "g20_right_19"]) +def test_one_driver_executes_every_profile_and_one_same_speed_retry(layout): + profile = load_bundled_hand_profile(layout) + driver = SessionExecution(profile) + driver.session.device_ready() + driver.session.start() + command = profile.command.baseline_values + rows, stamp, failed_once, swept = [], 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 + command = motion.target + if motion.recording: + if motion.phase == "sweep": + swept.append(motion) + # The first direction fails once; no speed reduction or extra retry. + if not failed_once: + failed_once = True + else: + unit = driver.action.scan_unit + task = next(t for t in profile.motion.tasks if t.key == unit.task_key) + for field, joint, spec in observation_streams(profile, task): + for index in range(129 if motion.phase == "sweep" else 3): + stamp += 1 + 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}": unit.start+(unit.end-unit.start)*index/128}) + driver.motion_complete() + assert driver.session.phase == Phase.FIT + assert len(driver.completed_units) == len(profile.motion.tasks)*8 + assert len(swept) == len(profile.motion.tasks)*8+1 + assert swept[0].speed == swept[1].speed + assert swept[1].attempt == 2 + + +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_standard_correction_plan.py b/src/linkerhand_calibration/test/test_standard_correction_plan.py new file mode 100644 index 0000000..c1068fb --- /dev/null +++ b/src/linkerhand_calibration/test/test_standard_correction_plan.py @@ -0,0 +1,84 @@ +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 new file mode 100644 index 0000000..7c7152f --- /dev/null +++ b/src/linkerhand_calibration/test/test_standard_loader.py @@ -0,0 +1,26 @@ +"""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 new file mode 100644 index 0000000..986bff9 --- /dev/null +++ b/src/linkerhand_calibration/test/test_standard_urdf_acceptance.py @@ -0,0 +1,124 @@ +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(' list[SweepItem]: - return [ - SweepItem(spec, cycle, direction) - for spec in SWEEP_SPECS - for cycle in range(3) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING) - ] - - -def _validated_zero_result( - endpoint_offsets: dict[str, float], -) -> SimpleNamespace: - all_offsets = { - name: float(endpoint_offsets.get(name, 0.0)) - for name in RIGHT_19_HAND_PROFILE.active_joints - } - return SimpleNamespace( - passed=True, - direct_offsets_rad=dict(all_offsets), - all_active_offsets_rad=dict(all_offsets), - offset_confidence_half_width_rad={}, - cycle_offsets_rad={}, - training_cycles=(0, 1, 2), - validation_cycle=3, - axis_line_rms_m=0.0001, - validation_line_error_by_joint_m={}, - observability_rank=22, - observability_parameter_count=22, - observability_condition_number=100.0, - offset_covariance_rad2={}, - ) - - -def test_finalize_publishes_the_frozen_validated_endpoint_state( - tmp_path, monkeypatch -) -> None: - endpoint_offsets = { - name: 0.001 * (index + 1) - for index, name in enumerate( - sorted(RIGHT_19_MECHANICAL_ENDPOINT_JOINTS) - ) - } - zero_result = _validated_zero_result(endpoint_offsets) - session_dir = tmp_path / "20260826_100727" - session_dir.mkdir() - source_urdf = tmp_path / "source.urdf" - source_urdf.write_text("", encoding="utf-8") - extrinsics = tmp_path / "extrinsics.yaml" - extrinsics.write_text("test: true\n", encoding="utf-8") - corrected_urdf = tmp_path / "corrected.urdf" - captured: dict[str, object] = {} - - def fake_write_zero_corrected_urdf(**kwargs): - captured.update(kwargs) - corrected_urdf.write_text("", encoding="utf-8") - return corrected_urdf - - monkeypatch.setattr( - three_camera_node, - "write_zero_corrected_urdf", - fake_write_zero_corrected_urdf, - ) - monkeypatch.setattr( - three_camera_node, - "build_compact_payload", - lambda **kwargs: {"passed": kwargs["passed"]}, - ) - def fake_atomic_write_json(path, payload): - if str(path).endswith("_urdf_correction_input.json"): - path.write_text( - json.dumps(payload, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - return - captured.update({"final_path": path, "payload": payload}) - - monkeypatch.setattr( - three_camera_node, - "atomic_write_json", - fake_atomic_write_json, - ) - node = SimpleNamespace( - zero_result=zero_result, - standalone_thumb_calibration=False, - validated_endpoint_zero_offsets_rad=dict(endpoint_offsets), - validation_errors_rad=[], - combination_validation_enabled=False, - combination_validation_completed=False, - validation_enabled=False, - fit_quality_passed=True, - session_dir=session_dir, - profile=RIGHT_19_HAND_PROFILE, - zero_profile=get_zero_calibration_profile("right", "g20_right_19"), - source_urdf_path=source_urdf, - corrected_urdf_output_dir=tmp_path, - serial_number="G20_RIGHT_001", - measured_fits={}, - baseline_command=THREE_CAMERA_BASELINE_COMMAND, - hand_type="right", - camera_extrinsics_file=extrinsics, - final_path=session_dir / "calibration.json", - completed_payload=None, - state="RETURN_BASELINE", - reason="finalize", - get_logger=lambda: SimpleNamespace(info=lambda message: None), - ) - - G20ThreeCameraCalibrationNode._finalize(node) - - assert captured["endpoint_anchored_offsets_rad"] == endpoint_offsets - assert captured["payload"] == {"passed": True} - assert node.state == STATE_COMPLETE - assert node.corrected_urdf_path == corrected_urdf - - -def test_endpoint_state_must_match_the_validated_zero_solution() -> None: - endpoint_offsets = { - name: 0.001 - for name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS - } - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - zero_result=_validated_zero_result(endpoint_offsets), - validated_endpoint_zero_offsets_rad={ - name: value - for name, value in endpoint_offsets.items() - if name != "index_pip" - }, - ) - - with pytest.raises( - RuntimeError, match="validated_endpoint_zero_state_incomplete" - ): - G20ThreeCameraCalibrationNode._endpoint_zero_offsets_for_publication( - node - ) - - -def test_refit_invalidates_all_derived_calibration_artifacts() -> None: - node = SimpleNamespace( - measured_fits={"index_pip": object()}, - axis_measurements=[object()], - zero_result=object(), - validated_endpoint_zero_offsets_rad={"index_pip": 0.01}, - corrected_urdf_path=object(), - fit_quality_passed=True, - validation_errors_rad=[0.01], - cross_view_roll_metrics={"index_mcp_roll": {"rms": 0.01}}, - validation_only_fits={"index_mcp_roll_side": object()}, - joint_dynamic_diagnostics={"index_pip": {"holdout": 0.01}}, - ) - - G20ThreeCameraCalibrationNode._invalidate_fitted_calibration_state(node) - - assert node.measured_fits == {} - assert node.axis_measurements == [] - assert node.zero_result is None - assert node.validated_endpoint_zero_offsets_rad == {} - assert node.corrected_urdf_path is None - assert node.fit_quality_passed is False - assert node.validation_errors_rad == [] - assert node.cross_view_roll_metrics == {} - assert node.validation_only_fits == {} - assert node.joint_dynamic_diagnostics == {} - - -def test_right_19_plan_is_one_deterministic_transaction_per_task() -> None: - plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4) - - assert len(plan) == len(RIGHT_19_HAND_PROFILE.sweep_specs) * 8 - for spec_index, spec in enumerate(RIGHT_19_HAND_PROFILE.sweep_specs): - task = plan[spec_index * 8 : (spec_index + 1) * 8] - assert all(item.spec == spec for item in task) - assert [ - (item.precheck, item.cycle, item.direction) - for item in task - ] == [ - *[ - (False, cycle, direction) - for cycle in range(4) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ], - ] - transitions = [ - _sweep_plan_transition(RIGHT_19_HAND_PROFILE, left, right) - for left, right in zip(task, task[1:]) - ] - assert transitions == [ - "immediate_reverse", - "cycle_reset", - "immediate_reverse", - "cycle_reset", - "immediate_reverse", - "cycle_reset", - "immediate_reverse", - ] - task_boundaries = [ - (plan[index], plan[index + 1]) - for index in range(7, len(plan) - 1, 8) - ] - assert all( - _sweep_plan_transition(RIGHT_19_HAND_PROFILE, left, right) - == "task_change" - for left, right in task_boundaries - ) - - -def test_right_19_complete_plan_has_no_unobserved_immediate_handoff() -> None: - """Every continuous boundary inherits the endpoint it already observed.""" - plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4) - immediate_boundaries = 0 - cycle_resets = 0 - task_changes = 0 - - for completed, following in zip(plan, plan[1:]): - transition = _sweep_plan_transition( - RIGHT_19_HAND_PROFILE, completed, following - ) - if transition == "cycle_reset": - cycle_resets += 1 - continue - if transition == "task_change": - task_changes += 1 - continue - - immediate_boundaries += 1 - endpoint_state = list( - build_calibration_motion_command( - completed.spec, - completed.target_u8, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - ) - ) - terminal_frames = [ - _frame(view, endpoint_state) - for view in _sweep_views( - RIGHT_19_HAND_PROFILE, completed.spec - ) - ] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - sweep_frames=terminal_frames, - carried_sweep_start_frames=[], - preflight_frames=30, - raw_path=None, - synchronised_endpoint_tolerance_margin_u8=1.0, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - ) - - carried = ( - G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames( - node, completed, following - ) - ) - - assert carried == len(terminal_frames) - assert node.carried_sweep_start_frames == terminal_frames - assert _frames_cover_sweep_joints( - node.carried_sweep_start_frames, - following.spec, - minimum_per_joint=1, - ) - - # Four formal decreasing/increasing pairs per task; no full-range precheck. - assert immediate_boundaries == 16 * 4 - assert cycle_resets == 16 * 3 - assert task_changes == 15 - - -def test_right_19_initializes_pnp_once_per_normal_task() -> None: - plan = _build_sweep_plan(RIGHT_19_HAND_PROFILE, repetitions=4) - reset_items = [ - item - for item in plan - if _requires_pnp_tracker_reset_for_sweep( - RIGHT_19_HAND_PROFILE, - item, - is_fit_retry=False, - ) - ] - - assert len(reset_items) == len(RIGHT_19_HAND_PROFILE.sweep_specs) - assert all(not item.precheck and item.cycle == 0 for item in reset_items) - assert all( - item.direction == DIRECTION_DECREASING for item in reset_items - ) - - -def test_pnp_reset_policy_preserves_legacy_and_retry_recovery() -> None: - right_spec = RIGHT_19_HAND_PROFILE.sweep_specs[0] - legacy_spec = RIGHT_HAND_PROFILE.sweep_specs[0] - - assert not _requires_pnp_tracker_reset_for_sweep( - RIGHT_19_HAND_PROFILE, - SweepItem(right_spec, 2, DIRECTION_DECREASING), - is_fit_retry=False, - ) - assert _requires_pnp_tracker_reset_for_sweep( - RIGHT_19_HAND_PROFILE, - SweepItem(right_spec, 2, DIRECTION_DECREASING), - is_fit_retry=True, - ) - assert not _requires_pnp_tracker_reset_for_sweep( - RIGHT_19_HAND_PROFILE, - SweepItem(right_spec, 2, DIRECTION_DECREASING), - is_fit_retry=True, - preserve_retry_continuity=True, - ) - assert _requires_pnp_tracker_reset_for_sweep( - RIGHT_HAND_PROFILE, - SweepItem(legacy_spec, 2, DIRECTION_DECREASING), - is_fit_retry=False, - ) - - -def test_task_roles_remain_owned_during_intercycle_reset() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "ring_pitch_side" - ) - view = "side" - all_roles = tuple(RIGHT_19_HAND_PROFILE.view_tags[view]) - runtime = SimpleNamespace( - preflight_roles=all_roles, - roles=all_roles, - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={view: runtime}, - active_combination_validation=None, - active_sweep=None, - active_validation=None, - retry_sweep_spec=None, - pnp_task_spec=spec, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, view - ) - - assert required == ("side_base", "ring_pip") - assert required != all_roles - - -def _linear_curve_fit(offset_scale_rad: float = 0.0) -> JointCurveFit: - base = np.asarray( - [0.5 * (127 - command) / 127 for command in range(256)], - dtype=float, - ) - shape = np.abs(np.arange(256, dtype=float) - 127.0) / 128.0 - values = tuple(float(value) for value in base + offset_scale_rad * shape) - return JointCurveFit(values, values, values, {}, 0.0, 0.0, {}) - - -def test_cross_view_curve_stable_four_cycle_bias_is_not_rescanned() -> None: - primary = _linear_curve_fit() - biased = _linear_curve_fit(math.radians(1.8)) - - failure = _cross_view_curve_failure( - "pinky_mcp_roll", - "pinky_mcp_roll_side", - primary, - biased, - scope="all_cycles", - maximum_rms_difference_rad=math.radians(1.0), - maximum_branch_gap_difference_rad=math.radians(0.5), - cycle_fits=[(primary, biased)] * 4, - ) - - assert failure is not None - assert failure["systematic"] is True - assert failure["quality_source_joints"] == ["pinky_mcp_roll_side"] - assert "cycle" not in failure - assert _fit_failure_is_systematic([failure], 4) is True - - -def test_cross_view_curve_single_bad_cycle_localizes_side_retry() -> None: - primary = _linear_curve_fit() - good = _linear_curve_fit(math.radians(0.6)) - bad = _linear_curve_fit(math.radians(1.9)) - - failure = _cross_view_curve_failure( - "middle_mcp_roll", - "middle_mcp_roll_side", - primary, - bad, - scope="all_cycles", - maximum_rms_difference_rad=math.radians(1.0), - maximum_branch_gap_difference_rad=math.radians(0.5), - cycle_fits=[(primary, good)] * 3 + [(primary, bad)], - ) - - assert failure is not None - assert failure["cycle"] == 4 - assert failure.get("systematic", False) is False - - -def _frame(view: str, state_u8: list[float]) -> FrameObservation: - return FrameObservation( - stamp_ns=1, - received_at=2.0, - view=view, - state_u8=tuple(state_u8), - state_sync_error_ns=0, - joint_vectors_xyz_m={}, - image_vectors_xy_px={}, - joint_quaternions_xyzw={}, - parent_poses_common={}, - child_poses_common={}, - joint_reprojection_error_px={}, - ) - - -def _joint_frame( - view: str, state_u8: list[float], joint_name: str -) -> FrameObservation: - identity_pose = { - "translation_xyz_m": [0.0, 0.0, 0.0], - "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - } - return replace( - _frame(view, state_u8), - joint_vectors_xyz_m={joint_name: (0.02, 0.0, 0.0)}, - image_vectors_xy_px={joint_name: (20.0, 0.0)}, - joint_quaternions_xyzw={joint_name: (0.0, 0.0, 0.0, 1.0)}, - parent_poses_common={joint_name: identity_pose}, - child_poses_common={joint_name: identity_pose}, - joint_reprojection_error_px={joint_name: 0.05}, - ) - - -def test_right_roll_uses_one_motion_with_two_camera_observations() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - state = [255.0] * 20 - front = _joint_frame("front", state, "pinky_mcp_roll") - side = _joint_frame("side", state, "pinky_mcp_roll_side") - - assert _sweep_views(RIGHT_19_HAND_PROFILE, spec) == ("front", "side") - assert not _frames_cover_sweep_joints([front] * 3, spec, 3) - assert _frames_cover_sweep_joints([front] * 3 + [side] * 3, spec, 3) - - -def test_right_roll_requires_both_views_task_local_tags() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={ - "front": SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags["front"]), - preflight_roles=("front_base",), - ), - "side": SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags["side"]), - preflight_roles=("side_base",), - ), - }, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - - assert G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "front" - ) == ("front_base", "pinky_roll") - assert G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) == ("side_base", "pinky_pip") - - -def test_thumb_yaw_top_axis_observer_does_not_change_front_sweep_contract() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_pitch_front" - ) - observer = _palm_axis_observer_for_sweep( - RIGHT_19_HAND_PROFILE, spec, "top" - ) - assert observer is not None - assert observer.model_joint == "thumb_cmc_pitch" - assert _sweep_views(RIGHT_19_HAND_PROFILE, spec) == ("front",) - - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={ - view: SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags[view]), - preflight_roles=(f"{view}_base",), - ) - for view in ("front", "side", "top") - }, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - assert G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "top" - ) == ("top_base",) - - -def test_pitch_task_does_not_add_a_front_direction_observer() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_pitch_side" - ) - - assert _sweep_views(RIGHT_19_HAND_PROFILE, spec) == ("side",) - observer = _palm_axis_observer_for_sweep( - RIGHT_19_HAND_PROFILE, spec, "front" - ) - assert observer is None - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={ - view: SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags[view]), - preflight_roles=(f"{view}_base",), - ) - for view in ("front", "side", "top") - }, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - assert G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "front" - ) == ("front_base",) - assert G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) == ("side_base", "pinky_pip") - - -def test_disabled_palm_axis_side_channel_is_not_persisted(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_pitch_side" - ) - observer = _palm_axis_observer_for_sweep( - RIGHT_19_HAND_PROFILE, spec, "front" - ) - assert observer is None - observer = PalmAxisObserver( - source_name="pinky_mcp_pitch_front_axis", - task_name=spec.key, - view="front", - parent_role="front_base", - child_role="pinky_roll", - model_joint="pinky_mcp_pitch", - motor_index=9, - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=item, - sweep_attempts={spec.key: 1}, - palm_axis_records_by_source={observer.source_name: []}, - raw_path=tmp_path / "raw_samples.jsonl", - ) - selected = { - "front_base": SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.1 - ), - "pinky_roll": SquareTagPose( - tuple( - Rotation.from_euler("y", 12.0, degrees=True).as_quat() - ), - (0.02, 0.0, 1.0), - 0.2, - ), - } - state = list(THREE_CAMERA_BASELINE_COMMAND) - state[observer.motor_index] = 230.0 - - G20ThreeCameraCalibrationNode._record_palm_axis_sample( - node, - observer, - selected, - np.eye(4), - state, - sync_error_ns=100_000, - stamp_ns=123, - ) - - records = node.palm_axis_records_by_source[observer.source_name] - assert len(records) == 1 - assert records[0]["kind"] == "palm_axis_sample" - assert "joint" not in records[0] - assert records[0]["command_u8"] == 230 - G20ThreeCameraCalibrationNode._persist_palm_axis_samples(node, item) - assert not node.raw_path.exists() - - -def test_multiview_roll_uses_locked_front_base_but_live_moving_tag() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "middle_roll_multiview" - ) - locked_pose = SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.1 - ) - front = SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags["front"]), - preflight_roles=("front_base",), - locked_base_pose=locked_pose, - locked_base_center_xy_px=(100.0, 200.0), - locked_base_quality=TagQuality(0, 100.0, 30.0, 0.1), - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={"front": front}, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - node._locked_base_role_for_active_capture = lambda view: ( - G20ThreeCameraCalibrationNode._locked_base_role_for_active_capture( - node, view - ) - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "front" - ) - live = G20ThreeCameraCalibrationNode._live_required_roles_for_view( - node, "front", required - ) - - assert required == ("front_base", "middle_roll") - assert live == ("middle_roll",) - - -@pytest.mark.parametrize( - "task_key", - ( - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - "thumb_cmc_yaw_top", - ), -) -def test_thumb_top_tasks_freeze_tag8_but_keep_it_live(task_key: str) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == task_key - ) - top = SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags["top"]), - preflight_roles=("top_base", "thumb_yaw"), - locked_base_pose=SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.1 - ), - locked_base_center_xy_px=(100.0, 200.0), - locked_base_quality=TagQuality(0, 100.0, 30.0, 0.1), - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={"top": top}, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - node._locked_base_role_for_active_capture = lambda view: ( - G20ThreeCameraCalibrationNode._locked_base_role_for_active_capture( - node, view - ) - ) - - assert _sweep_uses_locked_base_reference( - RIGHT_19_HAND_PROFILE, spec, "top" - ) - assert ( - G20ThreeCameraCalibrationNode._locked_base_role_for_active_capture( - node, "top" - ) - == "top_base" - ) - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "top" - ) - live = G20ThreeCameraCalibrationNode._live_required_roles_for_view( - node, "top", required - ) - assert "top_base" in required - assert "top_base" in live - - -def test_fixed_base_corner_drift_uses_all_four_ordered_corners() -> None: - reference = np.asarray( - [[10.0, 10.0], [30.0, 10.0], [30.0, 30.0], [10.0, 30.0]] - ) - subpixel_noise = reference + np.asarray( - [[0.1, -0.1], [0.2, 0.0], [0.0, 0.2], [-0.1, 0.1]] - ) - moved = reference.copy() - moved[2] += [1.5, 2.0] - - assert _maximum_corner_drift_px( - reference, subpixel_noise - ) < 0.3 - assert _maximum_corner_drift_px(reference, moved) == pytest.approx(2.5) - - -def test_resume_start_position_requires_all_fixed_tags_to_match() -> None: - reference = ( - (10.0, 10.0), - (30.0, 10.0), - (30.0, 30.0), - (10.0, 30.0), - ) - rows = [ - { - "kind": "fixed_base_reference_locked", - "view": view, - "corner_reference_xy": reference, - } - for view in ("front", "side", "top") - ] - current = { - "front": np.asarray(reference) + [0.2, -0.1], - "side": np.asarray(reference) + [4.0, 0.0], - "top": None, - } - - drift, changed, unverifiable = ( - _resume_fixed_base_position_compatibility(rows, current, 2.0) - ) - - assert drift["front"] < 0.3 - assert drift["side"] == pytest.approx(4.0) - assert changed == ("side",) - assert unverifiable == ("top",) - - -def test_side_only_finger_task_keeps_occluded_inactive_front_base_locked() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "middle_pitch_side" - ) - front = SimpleNamespace( - roles=tuple(RIGHT_19_HAND_PROFILE.view_tags["front"]), - preflight_roles=("front_base",), - locked_base_pose=SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.1 - ), - locked_base_center_xy_px=(100.0, 200.0), - locked_base_quality=TagQuality(0, 100.0, 30.0, 0.1), - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - views={"front": front}, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - active_combination_validation=None, - ) - - assert ( - G20ThreeCameraCalibrationNode._locked_base_role_for_active_capture( - node, "front" - ) - == "front_base" - ) - - -def test_hidden_locked_base_uses_baseline_quality_without_becoming_live() -> None: - locked_pose = SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.0, 0.0, 1.0), 0.17 - ) - moving_pose = SquareTagPose( - (0.0, 0.0, 0.0, 1.0), (0.02, 0.0, 1.0), 0.23 - ) - live = {"middle_roll": TagQuality(0, 90.0, 32.0)} - - combined = _selected_pose_qualities( - {"front_base": locked_pose, "middle_roll": moving_pose}, - live, - locked_base_role="front_base", - locked_base_quality=TagQuality(0, 100.0, 35.0, 0.11), - ) - - assert set(live) == {"middle_roll"} - assert combined["front_base"].reprojection_error_px == 0.17 - assert combined["middle_roll"].reprojection_error_px == 0.23 - - -def test_baseline_preflight_locks_robust_fixed_tag_reference(tmp_path) -> None: - observations = deque(maxlen=60) - for index in range(30): - observations.append( - ( - SquareTagPose( - (0.0, 0.0, 0.0, 1.0), - (0.001 * (index % 2), 0.0, 1.0), - 0.1, - ), - (100.0 + index % 2, 200.0), - TagQuality(0, 100.0, 30.0, 0.1), - ) - ) - views = { - view: SimpleNamespace( - fixed_base_observations=deque(observations, maxlen=60), - fixed_base_corner_observations=deque( - ( - np.asarray( - [ - [90.0 + index % 2, 190.0], - [110.0 + index % 2, 190.0], - [110.0 + index % 2, 210.0], - [90.0 + index % 2, 210.0], - ] - ) - for index in range(30) - ), - maxlen=60, - ), - locked_base_pose=None, - locked_base_center_xy_px=None, - locked_base_corners_xy=None, - locked_base_quality=None, - view_tags={ - {"front": "front_base", "side": "side_base", "top": "top_base"}[ - view - ]: {"front": 0, "side": 4, "top": 8}[view] - }, - ) - for view in ("front", "side", "top") - } - node = SimpleNamespace( - preflight_frames=60, - views=views, - raw_path=tmp_path / "raw_samples.jsonl", - ) - - assert G20ThreeCameraCalibrationNode._lock_fixed_base_references(node) - assert views["front"].locked_base_pose is not None - assert views["front"].locked_base_center_xy_px == (100.5, 200.0) - assert views["front"].locked_base_corners_xy == ( - (90.5, 190.0), - (110.5, 190.0), - (110.5, 210.0), - (90.5, 210.0), - ) - events = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert {event["tag_id"] for event in events} == {0, 4, 8} - - -def test_right_roll_checkpoint_persists_each_camera_independently( - tmp_path, -) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - state = [255.0] * 20 - state[spec.motor_index] = 224.0 - frames = ( - [_joint_frame("front", state, "pinky_mcp_roll") for _ in range(3)] - + [ - _joint_frame("side", state, "pinky_mcp_roll_side") - for _ in range(3) - ] - ) - records = {name: [] for name in spec.joints} - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - sweep_attempts={spec.key: 1}, - command_records_by_joint=records, - raw_path=tmp_path / "raw_samples.jsonl", - ) - - G20ThreeCameraCalibrationNode._record_command_checkpoint( - node, item, 224, frames - ) - - assert [records[name][0]["view"] for name in spec.joints] == [ - "front", - "side", - ] - assert all(records[name][0]["valid_frames"] == 3 for name in spec.joints) - - -def test_final_steady_checkpoint_unlatches_checkpoint_mode() -> None: - node = SimpleNamespace( - sweep_checkpoint_commands=deque(), - sweep_checkpoint_target_u8=0, - sweep_checkpoint_mode=True, - ) - - advanced = G20ThreeCameraCalibrationNode._publish_next_checkpoint( - node, 1.0 - ) - - assert advanced is False - assert node.sweep_checkpoint_target_u8 is None - assert node.sweep_checkpoint_mode is False - - -def test_steady_checkpoint_accepts_stable_command_feedback_deadband() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "thumb_cmc_yaw_top" - ) - node = SimpleNamespace( - latest_state_u8=(), - baseline_command=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - endpoint_tolerance_u8=2.0, - thumb_yaw_zero_endpoint_tolerance_u8=4.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - steady_checkpoint_command_feedback_tolerance_u8=8.0, - steady_checkpoint_maximum_feedback_range_u8=2.0, - ) - node._endpoint_tolerance_for_spec = lambda selected, command: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, selected, command - ) - ) - state = list( - build_calibration_motion_command( - spec, - 32, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - ) - ) - state[10] = 35.0 - - assert not G20ThreeCameraCalibrationNode._motion_command_reached( - node, spec, 32, tuple(state) - ) - assert G20ThreeCameraCalibrationNode._steady_checkpoint_reached( - node, spec, 32, tuple(state) - ) - - state[10] = 41.0 - assert not G20ThreeCameraCalibrationNode._steady_checkpoint_reached( - node, spec, 32, tuple(state) - ) - - -def test_steady_checkpoint_requires_feedback_to_stop_moving() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "thumb_cmc_yaw_top" - ) - node = SimpleNamespace( - steady_checkpoint_maximum_feedback_range_u8=2.0 - ) - stable_frames = [] - moving_frames = [] - for value in (35.0, 35.5, 35.0): - state = [255.0] * 20 - state[10] = value - stable_frames.append(_frame("top", state)) - for value in (38.0, 36.0, 35.0): - state = [255.0] * 20 - state[10] = value - moving_frames.append(_frame("top", state)) - - assert G20ThreeCameraCalibrationNode._steady_checkpoint_feedback_is_stable( - node, spec, stable_frames - ) - assert not G20ThreeCameraCalibrationNode._steady_checkpoint_feedback_is_stable( - node, spec, moving_frames - ) - - -def test_right_thumb_pitch_requires_only_front_base_and_thumb_tag() -> None: - profile = RIGHT_HAND_PROFILE - spec = next(item for item in profile.sweep_specs if item.motor_index == 0) - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["front"]), - preflight_roles=profile.preflight_view_roles["front"], - ) - node = SimpleNamespace( - profile=profile, - views={"front": runtime}, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "front" - ) - - assert required == ("front_base", "thumb_cmc") - - -def test_right_side_preflight_requires_only_pinky_chain_tags() -> None: - profile = RIGHT_HAND_PROFILE - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["side"]), - preflight_roles=profile.preflight_view_roles["side"], - ) - node = SimpleNamespace( - profile=profile, - views={"side": runtime}, - active_sweep=None, - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) - - assert required == ("side_base", "pinky_mcp", "pinky_pip", "pinky_dip") - - -def test_right_19_preflight_requires_only_fixed_palm_tag() -> None: - profile = RIGHT_19_HAND_PROFILE - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["side"]), - preflight_roles=profile.preflight_view_roles["side"], - ) - node = SimpleNamespace( - profile=profile, - views={"side": runtime}, - active_sweep=None, - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) - - assert required == ("side_base",) - - -def test_right_19_combination_allows_occluded_neighbouring_fingers() -> None: - profile = RIGHT_19_HAND_PROFILE - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["side"]), - preflight_roles=profile.preflight_view_roles["side"], - ) - node = SimpleNamespace( - profile=profile, - views={"side": runtime}, - active_combination_validation=SimpleNamespace(name="index_middle"), - active_sweep=None, - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) - observable = _combination_observable_joints( - profile, - "side", - ("side_base", "index_pip", "index_dip"), - ) - - assert required == ("side_base",) - assert observable == ("index_pip", "index_dip") - - -def test_right_19_task_requires_only_target_chain_after_clearance_pose() -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item - for item in profile.sweep_specs - if item.task_name == "index_pip_side" - ) - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["side"]), - preflight_roles=profile.preflight_view_roles["side"], - ) - node = SimpleNamespace( - profile=profile, - views={"side": runtime}, - active_combination_validation=None, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) - - assert required == ("side_base", "index_pip", "index_dip") - - -def test_group_pnp_failure_event_persists_candidate_boundary_once( - tmp_path, -) -> None: - profile = RIGHT_19_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs if item.key == "index_pip_side" - ) - state_u8 = [255.0] * 20 - state_u8[spec.motor_index] = 195.0 - runtime = SimpleNamespace( - name="side", - tracker=SimpleNamespace( - last_candidate_diagnostics_by_role={ - "index_pip": { - "solved_candidate_count": 2, - "reprojection_candidate_count": 2, - "independent_tilt_candidate_count": 2, - }, - "index_dip": { - "solved_candidate_count": 2, - "reprojection_candidate_count": 0, - "independent_tilt_candidate_count": 0, - "minimum_reprojection_error_px": 1.7, - }, - } - ), - latest_group_missing_candidate_roles=("index_dip",), - last_pnp_diagnostic_signature=None, - view_tags=profile.view_tags["side"], - intrinsics_sha256="side-intrinsics", - camera_matrix=np.eye(3), - ) - node = SimpleNamespace( - profile=profile, - active_sweep=SweepItem( - spec, -1, DIRECTION_DECREASING, precheck=True - ), - state=STATE_SWEEP, - latest_state_u8=tuple(state_u8), - raw_path=tmp_path / "raw_samples.jsonl", - ) - arguments = dict( - runtime=runtime, - stamp_ns=123, - required_roles=("side_base", "index_pip", "index_dip"), - pose_roles=("index_pip", "index_dip"), - corners_by_role={ - "index_pip": np.zeros((4, 2)), - "index_dip": np.ones((4, 2)), - }, - qualities={ - "index_pip": TagQuality(0, 80.0, 40.0), - "index_dip": TagQuality(0, 70.0, 35.0), - }, - matched_tracking=(tuple(state_u8), 2_000_000), - selected=None, - pnp_rejections={ - "index_dip": "no_pose_within_reprojection_or_tilt_limit" - }, - group_pnp_reason="group_missing_pose_candidates", - ) - - G20ThreeCameraCalibrationNode._record_group_pnp_candidate_event( - node, **arguments - ) - G20ThreeCameraCalibrationNode._record_group_pnp_candidate_event( - node, **arguments - ) - - rows = [ - json.loads(line) - for line in node.raw_path.read_text(encoding="utf-8").splitlines() - ] - assert len(rows) == 1 - assert rows[0]["kind"] == "group_pnp_candidate_event" - assert rows[0]["feedback_u8"] == 195.0 - assert rows[0]["group_missing_candidate_roles"] == ["index_dip"] - assert rows[0]["candidate_diagnostics"]["index_dip"][ - "reprojection_candidate_count" - ] == 0 - - -def test_right_pip_sweep_keeps_side_base_as_pnp_branch_anchor() -> None: - profile = RIGHT_HAND_PROFILE - spec = next( - item for item in profile.sweep_specs if item.motor_index == 19 - ) - runtime = SimpleNamespace( - roles=tuple(profile.view_tags["side"]), - preflight_roles=profile.preflight_view_roles["side"], - ) - node = SimpleNamespace( - profile=profile, - views={"side": runtime}, - active_sweep=SweepItem(spec, 0, DIRECTION_DECREASING), - active_validation=None, - retry_sweep_spec=None, - ) - - required = G20ThreeCameraCalibrationNode._required_roles_for_view( - node, "side" - ) - - assert required == ( - "side_base", - "pinky_mcp", - "pinky_pip", - "pinky_dip", - ) - - -def test_prepare_sweep_retains_synchronised_start_endpoint_frame() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - item = SweepItem(spec, 2, DIRECTION_DECREASING) - baseline = [255] * 20 - endpoint_state = list(baseline) - endpoint_state[6] = 253.0 - endpoint_state[7:10] = [0.0, 0.0, 0.0] - outside_state = list(endpoint_state) - outside_state[6] = 252.0 - node = SimpleNamespace( - state=STATE_PREPARE_SWEEP, - active_sweep=item, - baseline_command=tuple(baseline), - endpoint_tolerance_u8=2.0, - preflight_frames=30, - latest_state_u8=tuple(endpoint_state), - sweep_start_frames=[], - ) - node._motion_command_reached = lambda selected, command, state=None: ( - G20ThreeCameraCalibrationNode._motion_command_reached( - node, selected, command, state - ) - ) - node._endpoint_tolerance_for_spec = lambda selected, endpoint: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, selected, endpoint - ) - ) - - endpoint_frame = _frame("front", endpoint_state) - G20ThreeCameraCalibrationNode._accept_frame(node, endpoint_frame) - G20ThreeCameraCalibrationNode._accept_frame( - node, _frame("front", outside_state) - ) - - assert node.sweep_start_frames == [endpoint_frame] - - -def test_begin_sweep_carries_start_endpoint_frame_into_sweep() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - item = SweepItem(spec, 2, DIRECTION_DECREASING) - baseline = [255] * 20 - endpoint_state = list(baseline) - endpoint_state[6] = 253.0 - endpoint_state[7:10] = [0.0, 0.0, 0.0] - endpoint_frame = _frame("front", endpoint_state) - published: list[list[int]] = [] - node = SimpleNamespace( - active_sweep=item, - baseline_command=tuple(baseline), - sweep_frames=[], - sweep_start_frames=[endpoint_frame], - _publish_command=lambda command: published.append(command), - _motion_command_error_u8=lambda selected, command: 255.0, - _reset_motion_progress=lambda now, error: None, - ) - - G20ThreeCameraCalibrationNode._begin_active_sweep(node, 10.0) - - assert node.state == STATE_SWEEP - assert node.sweep_frames == [endpoint_frame] - assert node.sweep_start_frames == [] - assert node.sweep_last_valid_at == 10.0 - assert published[0][6] == 0 - assert published[0][7:10] == [0, 0, 0] - - -def test_immediate_reverse_reuses_proven_terminal_endpoint_frames(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 1 - ) - completed = SweepItem( - spec, -1, DIRECTION_DECREASING, precheck=True - ) - following = SweepItem( - spec, -1, DIRECTION_INCREASING, precheck=True - ) - endpoint_state = list( - build_calibration_motion_command( - spec, - 0, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - ) - ) - endpoint_state[1] = 2.5 - outside_state = list(endpoint_state) - outside_state[1] = 3.01 - endpoint_frame = _frame("side", endpoint_state) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - sweep_frames=[endpoint_frame, _frame("side", outside_state)], - carried_sweep_start_frames=[], - preflight_frames=30, - raw_path=tmp_path / "raw_samples.jsonl", - synchronised_endpoint_tolerance_margin_u8=1.0, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - ) - - count = ( - G20ThreeCameraCalibrationNode._stage_immediate_reverse_start_frames( - node, completed, following - ) - ) - - assert count == 1 - assert node.carried_sweep_start_frames == [endpoint_frame] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "sweep_endpoint_frames_carried" - assert event["endpoint_u8"] == 0 - assert event["to_direction"] == DIRECTION_INCREASING - - -def test_right_19_roll_sweep_stops_at_settled_baseline_first() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - baseline = tuple(THREE_CAMERA_BASELINE_COMMAND) - endpoint_state = list(baseline) - endpoint_state[9] = 255.0 - published: list[list[int]] = [] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=item, - baseline_command=baseline, - sweep_frames=[], - sweep_start_frames=[_frame("front", endpoint_state)], - sweep_baseline_frames=[], - _publish_command=lambda command: published.append(command), - _motion_command_error_u8=lambda selected, command: 128.0, - _reset_motion_progress=lambda now, error: None, - ) - - G20ThreeCameraCalibrationNode._begin_active_sweep(node, 10.0) - - assert node.sweep_baseline_pending is True - assert node.sweep_baseline_hold_since is None - assert published[0][9] == 127 - - -def test_dedicated_baseline_buffer_ignores_moving_arrival_frame() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - baseline_state = build_calibration_motion_command( - spec, - 127, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - ) - moving_state = list(baseline_state) - moving_state[9] = 130.0 - node = SimpleNamespace( - state=STATE_SWEEP, - profile=RIGHT_19_HAND_PROFILE, - active_sweep=item, - baseline_command=THREE_CAMERA_BASELINE_COMMAND, - endpoint_tolerance_u8=2.0, - latest_state_u8=tuple(baseline_state), - sweep_frames=[], - sweep_baseline_frames=[], - sweep_baseline_pending=True, - sweep_baseline_hold_since=None, - baseline_hold_seconds=0.5, - sweep_last_valid_at=0.0, - ) - node._motion_command_reached = lambda selected, command, state=None: ( - G20ThreeCameraCalibrationNode._motion_command_reached( - node, selected, command, state - ) - ) - node._endpoint_tolerance_for_spec = lambda selected, endpoint: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, selected, endpoint - ) - ) - - G20ThreeCameraCalibrationNode._accept_frame( - node, _frame("front", baseline_state) - ) - assert node.sweep_baseline_frames == [] - - node.sweep_baseline_hold_since = 1.0 - G20ThreeCameraCalibrationNode._accept_frame( - node, _frame("front", moving_state) - ) - G20ThreeCameraCalibrationNode._accept_frame( - node, _frame("front", baseline_state) - ) - - assert len(node.sweep_baseline_frames) == 1 - assert node.sweep_baseline_frames[0].state_u8[9] == 127 - - -def test_dedicated_baseline_hold_is_persisted_separately(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - item = SweepItem(spec, 2, DIRECTION_INCREASING) - state = build_calibration_motion_command( - spec, - 127, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - ) - frames = [ - replace( - _frame("front", state), - joint_quaternions_xyzw={ - "pinky_mcp_roll": (0.0, 0.0, 0.0, 1.0) - }, - joint_vectors_xyz_m={"pinky_mcp_roll": (0.02, 0.0, 0.0)}, - image_vectors_xy_px={"pinky_mcp_roll": (20.0, 0.0)}, - parent_poses_common={ - "pinky_mcp_roll": { - "translation_xyz_m": [0.0, 0.0, 0.0], - "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - } - }, - child_poses_common={ - "pinky_mcp_roll": { - "translation_xyz_m": [0.02, 0.0, 0.0], - "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - } - }, - joint_reprojection_error_px={"pinky_mcp_roll": 0.05}, - ) - for _ in range(3) - ] - frames.extend( - replace( - frame, - view="side", - joint_quaternions_xyzw={ - "pinky_mcp_roll_side": (0.0, 0.0, 0.0, 1.0) - }, - joint_vectors_xyz_m={"pinky_mcp_roll_side": (0.03, 0.0, 0.0)}, - image_vectors_xy_px={"pinky_mcp_roll_side": (30.0, 0.0)}, - parent_poses_common={ - "pinky_mcp_roll_side": frame.parent_poses_common[ - "pinky_mcp_roll" - ] - }, - child_poses_common={ - "pinky_mcp_roll_side": frame.child_poses_common[ - "pinky_mcp_roll" - ] - }, - joint_reprojection_error_px={"pinky_mcp_roll_side": 0.05}, - ) - for frame in list(frames) - ) - records = {"pinky_mcp_roll": [], "pinky_mcp_roll_side": []} - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=THREE_CAMERA_BASELINE_COMMAND, - sweep_baseline_frames=frames, - minimum_baseline_hold_frames=3, - baseline_hold_seconds=0.5, - sweep_attempts={spec.key: 2}, - baseline_records_by_joint=records, - raw_path=tmp_path / "raw_samples.jsonl", - ) - - G20ThreeCameraCalibrationNode._record_dedicated_baseline_hold(node, item) - - assert len(records["pinky_mcp_roll"]) == 1 - record = records["pinky_mcp_roll"][0] - assert record["kind"] == "baseline_hold_sample" - assert record["cycle"] == 2 - assert record["direction"] == DIRECTION_INCREASING - assert record["command_u8"] == 127 - assert record["valid_frames"] == 3 - assert record["relative_translation_xyz_m"] == [0.02, 0.0, 0.0] - assert record["parent_pose_common"]["translation_xyz_m"] == [0.0] * 3 - - -def test_cross_view_roll_hysteresis_classification() -> None: - assert _classify_cross_view_roll_hysteresis( - [0.99, 1.00, 0.98, 0.99], - [0.91, 0.94, 0.93, 0.92], - limit_deg=0.5, - ) == "both_views_confirm_direction_dependent_pose" - assert _classify_cross_view_roll_hysteresis( - [0.99] * 4, - [0.20] * 4, - limit_deg=0.5, - ) == "front_only_difference_check_roll_tag_bracket_or_front_pnp" - - -def test_diagnostic_mode_uses_one_multiview_physical_task() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - node = SimpleNamespace( - cross_view_roll_diagnostic_finger="pinky", - ) - - role = G20ThreeCameraCalibrationNode._cross_view_roll_diagnostic_role( - node, spec - ) - - assert role == "multiview" - assert spec.joints == ("pinky_mcp_roll", "pinky_mcp_roll_side") - - -def test_diagnostic_completion_locks_publication(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - pauses: list[str] = [] - node = SimpleNamespace( - cross_view_roll_diagnostic_finger="pinky", - baseline_maximum_hysteresis_rad=math.radians(0.5), - raw_path=tmp_path / "raw_samples.jsonl", - fit_failure={}, - cross_view_roll_diagnostic_result={}, - _roll_baseline_hysteresis_degrees=lambda name: ( - [0.99, 1.00, 0.98, 0.99] - if name == "pinky_mcp_roll" - else [0.92, 0.94, 0.93, 0.91] - ), - _pause=lambda reason: pauses.append(reason), - ) - - G20ThreeCameraCalibrationNode._complete_cross_view_roll_diagnostic( - node, - spec, - [ - { - "joint": "pinky_mcp_roll_side", - "metric": "rotation_circle_axis_difference_deg", - "actual": 24.3, - "limit": 1.0, - } - ], - ) - - result = node.cross_view_roll_diagnostic_result - assert result["publication_locked"] is True - assert result["interpretation"] == ( - "both_views_confirm_direction_dependent_pose" - ) - assert result["side_quality_failures"][0]["actual"] == 24.3 - assert pauses == ["cross_view_roll_diagnostic_complete"] - - -def test_joint_specific_zero_endpoint_deadbands() -> None: - yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10) - index_roll = next(item for item in SWEEP_SPECS if item.motor_index == 6) - pinky_pip = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 19 - ) - node = SimpleNamespace( - endpoint_tolerance_u8=2.0, - thumb_yaw_zero_endpoint_tolerance_u8=4.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - ) - - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, yaw, 0 - ) == 4.0 - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, yaw, 255 - ) == 2.0 - node.profile = RIGHT_HAND_PROFILE - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, yaw, 255 - ) == 5.0 - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, index_roll, 0 - ) == 2.0 - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, pinky_pip, 0 - ) == 5.0 - assert G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, pinky_pip, 255 - ) == 2.0 - - -def test_pose_transition_uses_slow_roll_speed_for_motor_six() -> None: - state = [255.0] * 20 - target = [255] * 20 - state[6] = 172.0 - target[6] = 127 - node = SimpleNamespace( - latest_state_u8=tuple(state), - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - _normal_speed_profile=lambda: [15] * 5, - ) - - speeds = G20ThreeCameraCalibrationNode._transition_speed_profile( - node, target - ) - - assert speeds == [15, 5, 15, 15, 15] - - -def test_pose_transition_uses_flex_speed_for_pitch_and_pip() -> None: - state = [255.0] * 20 - target = [255] * 20 - target[3] = 0 - target[18] = 0 - node = SimpleNamespace( - latest_state_u8=tuple(state), - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - _normal_speed_profile=lambda: [15] * 5, - ) - - speeds = G20ThreeCameraCalibrationNode._transition_speed_profile( - node, target - ) - - assert speeds == [15, 15, 15, 10, 15] - - -def test_pinky_pip_command_zero_accepts_firmware_feedback_five() -> None: - pinky_pip = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 19 - ) - state = [255.0] * 20 - state[19] = 5.0 - node = SimpleNamespace( - latest_state_u8=tuple(state), - baseline_command=tuple([255] * 20), - endpoint_tolerance_u8=2.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - ) - node._endpoint_tolerance_for_spec = lambda spec, endpoint: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, spec, endpoint - ) - ) - - assert G20ThreeCameraCalibrationNode._motion_command_reached( - node, pinky_pip, 0, tuple(state) - ) - state[19] = 6.0 - assert not G20ThreeCameraCalibrationNode._motion_command_reached( - node, pinky_pip, 0, tuple(state) - ) - - -def test_right_baseline_and_auxiliary_accept_thumb_yaw_feedback_250() -> None: - state = [253.0] * 20 - state[0] = 254.0 - state[5] = 254.0 - state[6:10] = [127.0] * 4 - state[10] = 250.0 - state[15] = 255.0 - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - latest_state_u8=tuple(state), - baseline_command=tuple(baseline), - endpoint_tolerance_u8=2.0, - thumb_yaw_zero_endpoint_tolerance_u8=4.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - ) - node._motor_endpoint_tolerance = lambda motor, endpoint: ( - G20ThreeCameraCalibrationNode._motor_endpoint_tolerance( - node, motor, endpoint - ) - ) - node._endpoint_tolerance_for_spec = lambda spec, endpoint: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, spec, endpoint - ) - ) - - assert G20ThreeCameraCalibrationNode._baseline_reached(node) - details = G20ThreeCameraCalibrationNode._baseline_error_details(node) - assert details["motor_index"] == 10 - assert details["actual_u8"] == 250.0 - assert details["tolerance_u8"] == 5.0 - - thumb_pitch = next( - spec for spec in RIGHT_HAND_PROFILE.sweep_specs - if spec.motor_index == 0 - ) - state[0] = 255.0 - assert G20ThreeCameraCalibrationNode._motion_command_reached( - node, thumb_pitch, 255, tuple(state) - ) - - -def test_safe_waypoint_ignores_reserved_channels_and_reports_real_motor() -> None: - state = [ - 255.0, 254.0, 254.0, 254.0, 254.0, 254.0, - 127.0, 127.0, 127.0, 127.0, 253.0, - 0.0, 0.0, 0.0, 0.0, - 255.0, 253.0, 253.0, 253.0, 253.0, - ] - command = [int(round(value)) for value in state] - command[5] = 255 - # Reserved SDK feedback is zero even if an old full command contains 255. - command[11:15] = [255, 255, 255, 255] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - latest_state_u8=tuple(state), - endpoint_tolerance_u8=2.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - ) - - assert G20ThreeCameraCalibrationNode._command_vector_reached(node, command) - assert G20ThreeCameraCalibrationNode._command_vector_error_u8( - node, command - ) == 1.0 - - command[3] = 240 - details = G20ThreeCameraCalibrationNode._command_vector_error_details( - node, command, "prepare_motor_0" - ) - assert details["motor_index"] == 3 - assert details["actual_u8"] == 254.0 - assert details["target_u8"] == 240.0 - assert details["error_u8"] == 14.0 - - -def test_feedback_inside_endpoint_tolerance_does_not_create_noop_transition() -> None: - state = [ - 254.0, 254.0, 254.0, 254.0, 254.0, 254.0, - 127.0, 127.0, 127.0, 127.0, 250.0, - 0.0, 0.0, 0.0, 0.0, - 255.0, 254.0, 254.0, 254.0, 254.0, - ] - target = [255] * 20 - target[6:10] = [127] * 4 - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - endpoint_tolerance_u8=2.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - pinky_pip_zero_endpoint_tolerance_u8=5.0, - ) - - snapped = G20ThreeCameraCalibrationNode._snap_reached_state_to_command( - node, state, target - ) - - controlled = { - spec.motor_index for spec in RIGHT_19_HAND_PROFILE.joint_specs.values() - } - assert all(snapped[index] == target[index] for index in controlled) - assert snapped[11:15] == (0, 0, 0, 0) - - -def test_thumb_yaw_command_zero_accepts_feedback_four_only_for_swept_motor() -> None: - yaw = next(item for item in SWEEP_SPECS if item.motor_index == 10) - state = [255.0] * 20 - state[10] = 4.0 - state[5] = 143.0 - node = SimpleNamespace( - latest_state_u8=tuple(state), - baseline_command=tuple([255] * 20), - endpoint_tolerance_u8=2.0, - thumb_yaw_zero_endpoint_tolerance_u8=4.0, - ) - node._endpoint_tolerance_for_spec = lambda spec, endpoint: ( - G20ThreeCameraCalibrationNode._endpoint_tolerance_for_spec( - node, spec, endpoint - ) - ) - - assert G20ThreeCameraCalibrationNode._motion_command_reached( - node, yaw, 0, tuple(state) - ) - state[10] = 5.0 - assert not G20ThreeCameraCalibrationNode._motion_command_reached( - node, yaw, 0, tuple(state) - ) - state[10] = 4.0 - state[5] = 142.0 - assert not G20ThreeCameraCalibrationNode._motion_command_reached( - node, yaw, 0, tuple(state) - ) - - -def test_thumb_yaw_direction_recovery_keeps_motor_five_at_clearance() -> None: - yaw = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - for direction, expected_start in ( - (DIRECTION_DECREASING, 255), - (DIRECTION_INCREASING, 0), - ): - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - baseline_command=tuple(baseline), - active_sweep=SweepItem(yaw, 1, direction), - retry_sweep_items=[], - ) - - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "retry_sweep" - ) - - assert command[5] == 145 - assert command[10] == expected_start - assert all( - command[index] == baseline[index] - for index in range(20) - if index not in {5, 10} - ) - - -def test_thumb_yaw_cycle_and_manual_recovery_use_queued_direction_start() -> None: - yaw = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - other = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index != 10 - ) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - queued = SweepItem(yaw, 2, DIRECTION_INCREASING) - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - baseline_command=tuple(baseline), - active_sweep=SweepItem(other, 0, DIRECTION_DECREASING), - retry_sweep_items=[queued], - ) - - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "resume_sweep" - ) - - assert command[5] == 145 - assert command[10] == 0 - - -def test_thumb_yaw_clearance_is_released_only_for_normal_transition() -> None: - yaw = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - baseline_command=tuple(baseline), - active_sweep=SweepItem(yaw, 2, DIRECTION_INCREASING), - retry_sweep_items=[], - ) - - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "fit" - ) - - assert command == tuple(baseline) - assert command[5] == 255 - - -def test_next_cycle_keeps_side_clearance_pose_parked() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "ring_roll_multiview" - ) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - next_item = SweepItem(spec, 1, DIRECTION_DECREASING) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=tuple(baseline), - sweep_items=[next_item], - sweep_index=0, - retry_sweep_items=[], - ) - - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "next_cycle" - ) - - # Ring and pinky roll remain at their safe 127 baselines. The already- - # cleared pinky remains bent instead of being unfolded and bent again. - assert command[8] == 127 - assert command[4] == 0 - assert command[9] == 127 - assert command[19] == 0 - assert command[1] == 255 - assert command[6] == 255 - - -def test_middle_fit_retry_keeps_side_clearance_pose_parked() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "middle_roll_multiview" - ) - baseline = tuple(THREE_CAMERA_BASELINE_COMMAND) - queued = SweepItem(spec, 0, DIRECTION_DECREASING) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=baseline, - active_sweep=None, - retry_sweep_items=[queued], - ) - - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "resume_sweep" - ) - - expected = tuple( - build_calibration_motion_command( - spec, - queued.start_u8, - baseline=baseline, - profile=RIGHT_19_HAND_PROFILE, - ) - ) - assert command == expected - # Pinky and ring remain flexed out of both cameras instead of unfolding - # to the global baseline and immediately being flexed again. - assert command[3] == 0 and command[18] == 0 - assert command[4] == 0 and command[19] == 0 - assert command[8] == 127 and command[9] == 127 - assert command[7] == queued.start_u8 - - -def test_middle_fit_retry_has_no_unfold_refold_waypoint() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "middle_roll_multiview" - ) - baseline = tuple(THREE_CAMERA_BASELINE_COMMAND) - queued = SweepItem(spec, 0, DIRECTION_DECREASING) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=baseline, - active_sweep=None, - retry_sweep_items=[queued], - ) - current = list( - build_calibration_motion_command( - spec, - 255, - baseline=baseline, - profile=RIGHT_19_HAND_PROFILE, - ) - ) - - target = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "resume_sweep" - ) - returns = build_calibration_return_waypoints( - target, - current_command=current, - profile=RIGHT_19_HAND_PROFILE, - ) - after_return = returns[-1] - preparations = build_calibration_preparation_waypoints( - spec, - queued.start_u8, - current_command=after_return, - baseline=baseline, - profile=RIGHT_19_HAND_PROFILE, - ) - - for waypoint in (*returns, *preparations): - assert waypoint[3] == 0 and waypoint[18] == 0 - assert waypoint[4] == 0 and waypoint[19] == 0 - - -def test_same_finger_next_task_keeps_neighbour_clearance_parked() -> None: - ring_roll_multiview = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "ring_roll_multiview" - ) - ring_pitch_side = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "ring_pitch_side" - ) - baseline = tuple(THREE_CAMERA_BASELINE_COMMAND) - next_item = SweepItem( - ring_pitch_side, -1, DIRECTION_DECREASING, precheck=True - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=baseline, - sweep_items=[next_item], - sweep_index=0, - retry_sweep_items=[], - ) - - transition = ( - G20ThreeCameraCalibrationNode._transition_after_completed_spec( - node, ring_roll_multiview - ) - ) - command = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, transition - ) - - assert transition == "next_task_same_finger" - # Pinky was bent once for ring side visibility and stays parked through - # ring roll, MCP pitch and PIP instead of unfolding between tasks. - assert command[9] == 127 - assert command[4] == 0 - assert command[19] == 0 - assert command[8] == 127 - assert command[3] == 255 - assert command[18] == 255 - - -def test_changing_finger_still_requires_global_safe_transition() -> None: - ring_pip = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "ring_pip_side" - ) - middle_roll = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "middle_roll_multiview" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - sweep_items=[SweepItem(middle_roll, -1, DIRECTION_DECREASING, True)], - sweep_index=0, - retry_sweep_items=[], - ) - - assert ( - G20ThreeCameraCalibrationNode._transition_after_completed_spec( - node, ring_pip - ) - == "next_sweep" - ) - - -def test_retry_return_anchors_roll_order_on_failed_target_finger() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "pinky_roll_multiview" - ) - node = SimpleNamespace( - retry_sweep_items=[ - SweepItem(spec, 0, DIRECTION_DECREASING) - ], - sweep_items=[], - sweep_index=0, - active_sweep=None, - ) - - anchor = G20ThreeCameraCalibrationNode._return_anchor_roll_motor(node) - - assert anchor == 9 - - -def test_precheck_dense_coverage_selects_faster_formal_speed(tmp_path) -> None: - spec = next( - item - for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 0 - ) - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - active_sweep=None, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - adaptive_formal_speed_enabled=True, - adaptive_formal_speed_max_scale=1.5, - adaptive_formal_speed_minimum_bins=64, - adaptive_formal_speed_maximum_bin_gap=8, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - ) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - item = SweepItem(spec, -1, direction, precheck=True) - node.active_sweep = item - G20ThreeCameraCalibrationNode._record_precheck_speed_metric( - node, - item, - bin_count=205, - maximum_bin_gap=3, - valid_frames=320, - ) - - assert math.isclose(node.formal_speed_scales[spec.key], 22.0 / 15.0) - node.active_sweep = SweepItem(spec, 0, DIRECTION_DECREASING) - speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec(node, spec) - assert speeds[0] == 22 - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "task_formal_speed_selected" - assert event["base_speed"] == 15 - assert event["formal_speed"] == 22 - - -def test_right_19_dense_precheck_keeps_deterministic_formal_speed(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "thumb_cmc_pitch_front" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=None, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - adaptive_formal_speed_enabled=True, - adaptive_formal_speed_max_scale=1.5, - adaptive_formal_speed_minimum_bins=64, - adaptive_formal_speed_maximum_bin_gap=8, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - ) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - item = SweepItem(spec, -1, direction, precheck=True) - node.active_sweep = item - G20ThreeCameraCalibrationNode._record_precheck_speed_metric( - node, - item, - bin_count=220, - maximum_bin_gap=3, - valid_frames=380, - ) - - assert node.formal_speed_scales[spec.key] == 1.0 - node.active_sweep = SweepItem(spec, 0, DIRECTION_DECREASING) - speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec(node, spec) - assert speeds[0] == 15 - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["stability_speed_lock"] is True - assert event["ineligible_reason"] == ( - "g20_right_deterministic_acquisition_speed" - ) - - -def test_precheck_large_gap_keeps_conservative_formal_speed(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "thumb_cmc_yaw_top" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=None, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - adaptive_formal_speed_enabled=True, - adaptive_formal_speed_max_scale=1.5, - adaptive_formal_speed_minimum_bins=64, - adaptive_formal_speed_maximum_bin_gap=8, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - ) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - item = SweepItem(spec, -1, direction, precheck=True) - node.active_sweep = item - G20ThreeCameraCalibrationNode._record_precheck_speed_metric( - node, - item, - bin_count=220, - maximum_bin_gap=17, - valid_frames=640, - ) - - assert node.formal_speed_scales[spec.key] == 1.0 - node.active_sweep = SweepItem(spec, 0, DIRECTION_DECREASING) - speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec(node, spec) - assert speeds[0] == 15 - - -def test_roll_precheck_never_accelerates_strict_backlash_scan(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "pinky_roll_multiview" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=None, - active_sweep_is_fit_retry=False, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - adaptive_formal_speed_enabled=True, - adaptive_formal_speed_max_scale=1.5, - adaptive_formal_speed_minimum_bins=64, - adaptive_formal_speed_maximum_bin_gap=8, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - ) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - item = SweepItem(spec, -1, direction, precheck=True) - node.active_sweep = item - G20ThreeCameraCalibrationNode._record_precheck_speed_metric( - node, - item, - bin_count=100, - maximum_bin_gap=4, - valid_frames=140, - ) - - assert node.formal_speed_scales[spec.key] == 1.0 - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["eligible"] is False - assert event["ineligible_reason"] == ( - "g20_right_deterministic_acquisition_speed" - ) - - -def test_fit_retry_uses_base_speed_instead_of_adaptive_speed() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.task_name == "thumb_cmc_pitch_front" - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=SweepItem(spec, 1, DIRECTION_DECREASING), - active_sweep_is_fit_retry=True, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - formal_speed_scales={spec.key: 1.5}, - sweep_retry_counts={}, - ) - - speeds = G20ThreeCameraCalibrationNode._speed_profile_for_spec(node, spec) - - assert speeds[0] == 15 - - -def test_thumb_yaw_recovery_zero_accepts_feedback_four() -> None: - yaw = next( - item for item in RIGHT_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - baseline = [255] * 20 - baseline[6:10] = [127] * 4 - return_command = list(baseline) - return_command[5] = 145 - return_command[10] = 0 - state = [float(value) for value in return_command] - state[10] = 4.0 - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - baseline_command=tuple(baseline), - return_command_u8=tuple(return_command), - latest_state_u8=tuple(state), - endpoint_tolerance_u8=2.0, - thumb_yaw_zero_endpoint_tolerance_u8=4.0, - right_thumb_yaw_255_endpoint_tolerance_u8=5.0, - ) - - assert G20ThreeCameraCalibrationNode._baseline_reached(node) - details = G20ThreeCameraCalibrationNode._motion_command_error_details( - node, yaw, 0, "return_baseline" - ) - assert details["motor_index"] == 10 - assert details["actual_u8"] == 4.0 - assert details["tolerance_u8"] == 4.0 - - -def _image_cycle_records( - travels_rad: list[float], *, depth_slope: float = 0.0, command_step: int = 16 -) -> list[dict]: - commands = list(range(0, 256, command_step)) + [255] - records = [] - for cycle, travel in enumerate(travels_rad): - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - for command in commands: - angle = travel * (255.0 - command) / 255.0 - x = 0.03 * math.cos(angle) - y = 0.03 * math.sin(angle) - z = float(depth_slope) * (x - 0.03) - records.append( - { - "cycle": cycle, - "direction": direction, - "command_u8": command, - "relative_translation_xyz_m": [ - x, - y, - z, - ], - "image_relative_xy_px": [ - 100.0 * math.cos(angle), - 100.0 * math.sin(angle), - ], - "relative_quaternion_xyzw": Rotation.from_rotvec( - np.asarray([0.0, 0.0, angle]) - ).as_quat().tolist(), - "parent_pose_common": { - "translation_xyz_m": [0.0, 0.0, 0.0], - "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - }, - "child_pose_common": { - "translation_xyz_m": [ - x, - y, - z, - ], - "quaternion_xyzw": Rotation.from_rotvec( - np.asarray([0.0, 0.0, angle]) - ).as_quat().tolist(), - }, - "state_u8": [float(command)] + [255.0] * 19, - } - ) - return records - - -def _fit_check_node(records_by_joint: dict) -> SimpleNamespace: - node = SimpleNamespace( - records_by_joint=records_by_joint, - repetitions=3, - trajectory_maximum_plane_rms_m=0.004, - trajectory_maximum_radial_rms_m=0.004, - trajectory_minimum_radius_m=0.003, - trajectory_minimum_arc_rad=math.radians(15.0), - image_trajectory_maximum_radial_rms_px=2.0, - image_trajectory_maximum_radial_p95_px=3.5, - image_trajectory_minimum_radius_px=20.0, - trajectory_maximum_cycle_travel_difference_rad=math.radians(3.0), - passive_maximum_cycle_travel_difference_rad=math.radians(10.0), - maximum_monotonic_correction_rad=math.radians(2.0), - maximum_hysteresis_rad=math.radians(5.0), - baseline_maximum_hysteresis_rad=math.radians(0.5), - passive_maximum_monotonic_correction_rad=math.radians(3.0), - passive_maximum_hysteresis_rad=math.radians(7.5), - baseline_command=[255] * 20, - axis_maximum_plane_rms_m=0.003, - passive_axis_maximum_plane_rms_m=0.004, - axis_maximum_radial_rms_m=0.003, - axis_maximum_pose_line_rms_m=0.001, - axis_maximum_rotation_circle_difference_rad=math.radians(1.0), - active_maximum_rotation_orthogonal_rms_rad=math.radians(2.5), - passive_maximum_rotation_orthogonal_rms_rad=math.radians(7.5), - zero_maximum_axis_cycle_difference_rad=math.radians(0.75), - ) - node._fit_joint_records = lambda name, records, relaxed=False: ( - G20ThreeCameraCalibrationNode._fit_joint_records( - node, name, records, relaxed=relaxed - ) - ) - node._fit_axis_measurement = lambda name, cycle: ( - G20ThreeCameraCalibrationNode._fit_axis_measurement(node, name, cycle) - ) - return node - - -def test_right_19_end_on_flexion_uses_image_curve_not_planar_pnp_tilt() -> None: - records = _image_cycle_records([math.radians(100.0)] * 3) - # Reproduce a command-dependent out-of-axis PnP tilt while preserving the - # accurately observed projected Tag-centre circle. - for record in records: - command = int(record["command_u8"]) - angle = math.radians(100.0) * (255.0 - command) / 255.0 - rotation = Rotation.from_rotvec([0.0, 0.0, angle]) * Rotation.from_rotvec( - [math.radians(12.0) * math.sin(angle), 0.0, 0.0] - ) - record["relative_quaternion_xyzw"] = rotation.as_quat().tolist() - - node = _fit_check_node( - { - "pinky_mcp_roll": records, - "pinky_pip": records, - "pinky_dip": records, - } - ) - node.profile = RIGHT_19_HAND_PROFILE - roll_fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, "pinky_mcp_roll", records - ) - image_fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, "pinky_pip", records - ) - passive_fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, "pinky_dip", records - ) - - assert "pinky_pip" in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS - assert roll_fit.circle["space"] == "image_2d" - assert image_fit.circle["space"] == "image_2d" - assert image_fit.quality["radial_rms_px"] < 1.0e-8 - # DIP is measured against its moving PIP parent, so it must remain a - # relative-orientation curve and must never inherit the PIP image motion. - assert passive_fit.circle["space"] == "relative_rotation_3d" - - -def test_right_19_finger_rolls_share_reference_axis_direction() -> None: - pinky = _image_cycle_records([math.radians(47.0)] * 3) - ring = _image_cycle_records([math.radians(47.0)] * 3) - for record in ring: - command = int(record["command_u8"]) - angle = math.radians(47.0) * (255.0 - command) / 255.0 - record["relative_quaternion_xyzw"] = ( - Rotation.from_rotvec([angle, 0.0, 0.0]).as_quat().tolist() - ) - - node = _fit_check_node( - {"pinky_mcp_roll": pinky, "ring_mcp_roll": ring} - ) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", RIGHT_19_HAND_PROFILE.layout_id - ) - reference = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - node, "pinky_mcp_roll", 0 - ) - measured = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - node, "ring_mcp_roll", 0 - ) - - assert measured.axis_direction_source == "upstream_constraint" - assert abs( - float( - np.asarray(reference.axis_common_xyz) - @ np.asarray(measured.axis_common_xyz) - ) - ) > math.cos(math.radians(1.0e-4)) - - -def test_thumb_mcp_ip_group_uses_mimic_only_as_pnp_branch_prior() -> None: - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - pnp_maximum_pose_jump_rad=math.radians(35.0), - pnp_maximum_translation_jump_m=0.04, - pnp_group_initialization_frames=8, - pnp_group_normal_alignment_scale_rad=math.radians(5.0), - pnp_group_maximum_normal_alignment_rad=math.radians(15.0), - pnp_tracker_reset_seconds=5.0, - thumb_ip_pnp_coupling_multiplier=1.03, - thumb_ip_pnp_coupling_scale_rad=math.radians(3.0), - thumb_ip_pnp_maximum_coupling_residual_rad=math.radians(7.5), - ) - - tracker = G20ThreeCameraCalibrationNode._make_group_pose_tracker( - node, - "front", - ("front_base", "thumb_cmc", "thumb_mcp", "thumb_ip"), - ) - - assert tracker.coupled_rotation_pairs == ( - ("thumb_cmc", "thumb_mcp", "thumb_mcp", "thumb_ip", 1.03), - ) - assert tracker.maximum_coupled_rotation_residual_rad == pytest.approx( - math.radians(7.5) - ) - - -def test_side_joint_group_does_not_assume_tag_mounting_planes() -> None: - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - pnp_maximum_pose_jump_rad=math.radians(35.0), - pnp_maximum_translation_jump_m=0.04, - pnp_group_initialization_frames=8, - pnp_group_normal_alignment_scale_rad=math.radians(5.0), - pnp_group_maximum_normal_alignment_rad=math.radians(15.0), - pnp_tracker_reset_seconds=5.0, - thumb_ip_pnp_coupling_multiplier=1.03, - thumb_ip_pnp_coupling_scale_rad=math.radians(3.0), - thumb_ip_pnp_maximum_coupling_residual_rad=math.radians(7.5), - ) - - tracker = G20ThreeCameraCalibrationNode._make_group_pose_tracker( - node, - "side", - ("side_base", "middle_pip", "middle_dip"), - ) - - assert tracker.adjacent_pairs == ( - ("side_base", "middle_pip"), - ("middle_pip", "middle_dip"), - ) - assert tracker.normal_alignment_pairs == () - - -def test_fit_failure_preserves_main_progress_and_selects_retry_scope(tmp_path) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - node = SimpleNamespace( - sweep_items=_sweep_items(), - sweep_attempts={spec.motor_index: 1}, - retry_sweep_spec=None, - retry_resume_index=None, - fit_failure={}, - sweep_index=24, - raw_path=tmp_path / "raw_samples.jsonl", - repetitions=3, - ) - node._sweep_spec_start_index = lambda selected: ( - G20ThreeCameraCalibrationNode._sweep_spec_start_index(node, selected) - ) - node._pause = lambda reason: setattr(node, "paused_reason", reason) - - G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, - spec, - [ - { - "joint": "index_mcp_roll", - "metric": "arc_deg", - "actual": 3.98, - "limit": 15.0, - "comparison": "minimum", - } - ], - ) - - assert node.sweep_index == 24 - assert node.retry_sweep_spec == spec - assert node.retry_cycles == {0, 1, 2} - assert node.fit_failure["directions_to_rescan"] == 6 - assert node.paused_reason == "joint_fit_check_failed" - - -def test_zero_model_failure_never_schedules_an_automatic_rescan(tmp_path) -> None: - pauses: list[str] = [] - node = SimpleNamespace( - profile=RIGHT_HAND_PROFILE, - raw_path=tmp_path / "raw_samples.jsonl", - maximum_validation_p95_rad=math.radians(2.0), - zero_maximum_offset_rad=math.radians(20.0), - zero_joint_maximum_offsets_rad={}, - retry_sweep_spec=RIGHT_HAND_PROFILE.sweep_specs[2], - retry_resume_index=42, - retry_cycles={0, 1, 2}, - fit_failure={}, - _pause=lambda reason: pauses.append(reason), - ) - result = SimpleNamespace( - failure_reasons={ - "thumb_mcp": "zero_offset_exceeds_configured_limit" - }, - direct_offsets_rad={"thumb_mcp": math.radians(-39.81)}, - validation_error_by_joint_rad={ - "thumb_ip": math.radians(23.5) - }, - ) - - G20ThreeCameraCalibrationNode._pause_for_zero_model_failure(node, result) - - assert pauses == ["zero_model_validation_failed"] - assert node.retry_sweep_spec is None - assert node.retry_resume_index is None - assert node.retry_cycles == set() - assert node.fit_failure["recoverable_by_rescan"] is False - assert node.fit_failure["directions_to_rescan"] == 0 - assert node.fit_failure["failures"][0]["actual_deg"] == -39.81 - assert node.fit_failure["failures"][0]["limit_deg"] == 20.0 - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "zero_model_failure" - - -def test_resume_rejects_zero_model_failure_without_starting_motion() -> None: - node = SimpleNamespace( - state="PAUSED", - paused_reason="zero_model_validation_failed", - ) - response = SimpleNamespace(success=None, message="") - - result = G20ThreeCameraCalibrationNode._resume_callback( - node, SimpleNamespace(), response - ) - - assert result.success is False - assert "cannot repair" in result.message - - -def test_scan_completion_is_not_reported_as_calibration_completion() -> None: - assert _overall_progress("SWEEP", scan_progress=1.0) == 0.90 - assert _overall_progress("FITTING", scan_progress=1.0) == 0.92 - assert np.isclose( - _overall_progress( - "VALIDATION_CAPTURE", - scan_progress=1.0, - validation_index=5, - validation_total=10, - ), - 0.955, - ) - assert _overall_progress("COMPLETE", scan_progress=1.0) == 1.0 - - -def test_resume_discards_only_failed_specs_samples(tmp_path) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) - reset_runtimes = [] - front_runtime = SimpleNamespace( - pnp_invalid_since=12.0, - pnp_reset_count=0, - ) - records = { - "thumb_mcp": [ - {"cycle": 0, "keep": "mcp"}, - {"cycle": 1, "old": "mcp"}, - ], - "thumb_ip": [ - {"cycle": 0, "keep": "ip"}, - {"cycle": 1, "old": "ip"}, - ], - "thumb_cmc_pitch": [{"keep": True}], - } - node = SimpleNamespace( - retry_sweep_spec=spec, - records_by_joint=records, - sweep_attempts={spec.motor_index: 1}, - raw_path=tmp_path / "raw_samples.jsonl", - paused_reason="joint_fit_check_failed", - fit_failure={"failures": ["old"]}, - retry_cycles={1}, - repetitions=3, - sweep_items=_sweep_items(), - views={"front": front_runtime}, - _reset_view_trackers=lambda runtime: reset_runtimes.append(runtime), - ) - - selected = G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node) - - assert selected == spec - assert records["thumb_mcp"] == [{"cycle": 0, "keep": "mcp"}] - assert records["thumb_ip"] == [{"cycle": 0, "keep": "ip"}] - assert records["thumb_cmc_pitch"] == [{"keep": True}] - assert node.sweep_attempts[15] == 2 - assert node.retry_sweep_spec == spec - assert node.fit_failure == {} - assert reset_runtimes == [front_runtime] - assert front_runtime.pnp_invalid_since is None - assert front_runtime.pnp_reset_count == 1 - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event == { - "kind": "retry", - "view": "front", - "motor_index": 15, - "joints": ["thumb_mcp", "thumb_ip"], - "joints_to_rescan": ["thumb_ip", "thumb_mcp"], - "attempt": 2, - "reason": "joint_fit_check_failed", - "cycles": [2], - } - - -def test_thumb_yaw_retry_replaces_palm_axis_source_tasks_not_yaw_sweep( - tmp_path, -) -> None: - yaw_spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_yaw_top" - ) - source_specs = [ - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key - in {"thumb_cmc_pitch_front", "thumb_cmc_roll_front"} - ] - source_joints = { - joint for item in source_specs for joint in item.joints - } - records = { - **{ - joint: [ - {"cycle": 0, "keep": joint}, - {"cycle": 2, "old": joint}, - ] - for joint in source_joints - }, - "thumb_cmc_yaw": [{"cycle": 2, "keep": "yaw"}], - } - source_names = { - observer.source_name - for observer in RIGHT_19_HAND_PROFILE.palm_axis_observers - if observer.task_name in {item.key for item in source_specs} - } - palm_records = { - name: [ - {"cycle": 0, "keep": name}, - {"cycle": 2, "old": name}, - ] - for name in source_names - } - resets: list[str] = [] - - def runtime(name: str) -> SimpleNamespace: - return SimpleNamespace( - name=name, - pnp_invalid_since=1.0, - pnp_reset_count=0, - task_valid_frames=100, - task_total_frames=110, - ) - - views = {name: runtime(name) for name in ("front", "top")} - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - retry_sweep_spec=yaw_spec, - retry_joint_names={"thumb_cmc_yaw"}, - retry_source_failure_task_key=yaw_spec.key, - retry_source_task_keys=tuple(item.key for item in source_specs), - retry_cycles={2}, - records_by_joint=records, - baseline_records_by_joint={joint: [] for joint in source_joints}, - command_records_by_joint={joint: [] for joint in source_joints}, - palm_axis_records_by_source=palm_records, - sweep_attempts={yaw_spec.key: 1}, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - paused_reason="joint_fit_check_failed", - fit_failure={"failures": ["old"]}, - repetitions=4, - sweep_items=[ - SweepItem(item, cycle, direction) - for item in source_specs - for cycle in range(4) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ], - views=views, - _reset_view_trackers=( - lambda item, preserve_task_reference=False: resets.append( - item.name - ) - ), - ) - - G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node) - - assert records["thumb_cmc_yaw"] == [ - {"cycle": 2, "keep": "yaw"} - ] - assert all( - records[joint] == [{"cycle": 0, "keep": joint}] - for joint in source_joints - ) - assert all( - palm_records[name] == [{"cycle": 0, "keep": name}] - for name in source_names - ) - assert [(item.spec.key, item.cycle) for item in node.retry_sweep_items] == [ - (item.key, 2) - for item in source_specs - for _direction in range(2) - ] - assert resets == [] - assert node.retry_preserve_pnp_continuity is True - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["source_task_names"] == [item.key for item in source_specs] - assert event["source_attempts"] == {item.key: 2 for item in source_specs} - assert event["joints_to_rescan"] == sorted(source_joints) - assert event["cycles"] == [3] - assert event["pnp_reference_policy"] == "preserve_existing_generation" - assert all(runtime.task_valid_frames == 0 for runtime in views.values()) - assert all(runtime.task_total_frames == 0 for runtime in views.values()) - - -def test_multiview_retry_discards_only_failed_side_measurement(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "middle_roll_multiview" - ) - primary, validation = spec.joints - records = { - primary: [ - {"cycle": 0, "accepted": "front"}, - {"cycle": 1, "accepted": "front"}, - ], - validation: [ - {"cycle": 0, "old": "side"}, - {"cycle": 1, "accepted": "side"}, - ], - } - side_runtime = SimpleNamespace( - pnp_invalid_since=12.0, - pnp_reset_count=0, - task_valid_frames=4927, - task_total_frames=5212, - ) - resets: list[bool] = [] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - retry_sweep_spec=spec, - retry_joint_names={validation}, - records_by_joint=records, - baseline_records_by_joint={primary: [], validation: []}, - command_records_by_joint={primary: [], validation: []}, - sweep_attempts={spec.key: 1}, - raw_path=tmp_path / "raw_samples.jsonl", - paused_reason="joint_fit_check_failed", - fit_failure={"failures": ["old"]}, - retry_cycles={0}, - repetitions=4, - sweep_items=[ - SweepItem(spec, cycle, direction) - for cycle in range(4) - for direction in ( - DIRECTION_DECREASING, - DIRECTION_INCREASING, - ) - ], - views={"side": side_runtime}, - _reset_view_trackers=( - lambda runtime, preserve_task_reference=False: resets.append( - preserve_task_reference - ) - ), - ) - - G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node) - - assert records[primary] == [ - {"cycle": 0, "accepted": "front"}, - {"cycle": 1, "accepted": "front"}, - ] - assert records[validation] == [ - {"cycle": 1, "accepted": "side"} - ] - assert node.retry_joint_names == {validation} - assert side_runtime.task_valid_frames == 0 - assert side_runtime.task_total_frames == 0 - assert resets == [True] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["joints_to_rescan"] == [validation] - assert event["cycles"] == [1] - - -def test_recoverable_sweep_failure_retries_once_at_same_speed(tmp_path) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - item = SweepItem(spec, 0, DIRECTION_INCREASING) - transitions: list[str] = [] - pauses: list[str] = [] - holds: list[bool] = [] - node = SimpleNamespace( - active_sweep=item, - automatic_sweep_retry_limit=3, - sweep_retry_counts={}, - raw_path=tmp_path / "raw_samples.jsonl", - reason="", - _publish_hold_current=lambda: holds.append(True), - _begin_return_baseline=lambda after: transitions.append(after), - _pause=lambda reason: pauses.append(reason), - retry_speed_scales=(1.0,), - retry_endpoint_hold_seconds=(0.5,), - ) - - G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause( - node, "sweep_bin_gap_too_large" - ) - G20ThreeCameraCalibrationNode._retry_active_sweep_or_pause( - node, "sweep_bin_gap_too_large" - ) - assert transitions == ["retry_sweep"] - assert pauses == ["sweep_bin_gap_too_large"] - assert holds == [] - assert node.sweep_retry_counts[(6, 0, DIRECTION_INCREASING)] == 1 - events = [ - json.loads(line) - for line in (tmp_path / "raw_samples.jsonl").read_text().splitlines() - ] - assert [event["retry"] for event in events] == [1] - - -def test_visibility_precheck_does_not_require_dense_feedback_bins(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True) - # Preserve the field observation: complete endpoints and midpoint, but a - # 17-u8 feedback gap between 99 and 116. - commands = [*range(0, 100), *range(116, 256)] - frames = [] - for command in commands: - state = [255.0] * 20 - state[10] = float(command) - frames.append(_frame("top", state)) - transitions: list[str] = [] - retries: list[str] = [] - node = SimpleNamespace( - active_sweep=item, - sweep_frames=frames, - minimum_sweep_bins=32, - maximum_bin_gap=16, - minimum_detection_rate=0.95, - views={"top": SimpleNamespace(valid_rate=1.0)}, - sweep_detection_total_frames=644, - sweep_detection_valid_frames=644, - raw_path=tmp_path / "raw_samples.jsonl", - active_sweep_is_fit_retry=False, - sweep_index=0, - sweep_items=[item], - profile=RIGHT_19_HAND_PROFILE, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 5.0, - _retry_active_sweep_or_pause=lambda reason: retries.append(reason), - _begin_return_baseline=lambda after: transitions.append(after), - ) - - G20ThreeCameraCalibrationNode._finish_active_sweep(node) - - assert retries == [] - assert transitions == ["fit"] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "task_visibility_precheck" - assert event["passed"] is True - assert event["detection_rate"] == 1.0 - - -def test_visibility_precheck_accepts_interpolated_endpoint_fraction(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 1 - ) - item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True) - # Field regression: the motor reached zero with the physical 2-u8 - # deadband, while the last image-timestamped feedback was 2.502 u8. - commands = [2.5021463960247776, *range(3, 255)] - frames = [] - for command in commands: - state = [255.0] * 20 - state[1] = float(command) - frames.append(_frame("side", state)) - transitions: list[str] = [] - retries: list[str] = [] - node = SimpleNamespace( - active_sweep=item, - sweep_frames=frames, - synchronised_endpoint_tolerance_margin_u8=1.0, - minimum_sweep_bins=32, - maximum_bin_gap=16, - minimum_detection_rate=0.95, - views={"side": SimpleNamespace(valid_rate=1.0)}, - sweep_detection_total_frames=500, - sweep_detection_valid_frames=382, - raw_path=tmp_path / "raw_samples.jsonl", - active_sweep_is_fit_retry=False, - sweep_index=0, - sweep_items=[item], - profile=RIGHT_19_HAND_PROFILE, - normal_calibration_speed=15, - index_roll_calibration_speed=5, - index_flex_calibration_speed=10, - precheck_speed_metrics={}, - formal_speed_scales={}, - sweep_retry_counts={}, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - _retry_active_sweep_or_pause=lambda reason: retries.append(reason), - _begin_return_baseline=lambda after: transitions.append(after), - ) - - G20ThreeCameraCalibrationNode._finish_active_sweep(node) - - assert retries == [] - assert transitions == ["fit"] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["detection_rate"] == 0.764 - assert event["detection_rate_below_threshold_views"] == ["side"] - assert event["trajectory_coverage_override_views"] == ["side"] - - -def test_low_precheck_rate_still_retries_when_trajectory_is_sparse(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 1 - ) - item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True) - frames = [] - for command in (0, 127, 255): - state = [255.0] * 20 - state[1] = float(command) - frames.append(_frame("side", state)) - retries: list[str] = [] - node = SimpleNamespace( - active_sweep=item, - sweep_frames=frames, - minimum_sweep_bins=32, - maximum_bin_gap=16, - minimum_detection_rate=0.95, - sweep_detection_total_frames=100, - sweep_detection_valid_frames=3, - profile=RIGHT_19_HAND_PROFILE, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - _retry_active_sweep_or_pause=lambda reason: retries.append(reason), - ) - - G20ThreeCameraCalibrationNode._finish_active_sweep(node) - - assert retries == ["task_precheck_detection_rate_too_low:side"] - - -def test_visibility_precheck_rejects_sample_outside_sync_endpoint_margin() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 1 - ) - item = SweepItem(spec, -1, DIRECTION_DECREASING, precheck=True) - frames = [] - for command in [3.01, *range(4, 255)]: - state = [255.0] * 20 - state[1] = float(command) - frames.append(_frame("side", state)) - retries: list[str] = [] - node = SimpleNamespace( - active_sweep=item, - sweep_frames=frames, - synchronised_endpoint_tolerance_margin_u8=1.0, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - _retry_active_sweep_or_pause=lambda reason: retries.append(reason), - ) - - G20ThreeCameraCalibrationNode._finish_active_sweep(node) - - assert retries == ["sweep_missing_endpoint_bin"] - - -def test_synchronised_endpoint_margin_does_not_change_motion_deadband() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - node = SimpleNamespace( - synchronised_endpoint_tolerance_margin_u8=1.0, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 2.0, - ) - - assert node._endpoint_tolerance_for_spec(spec, 0) == 2.0 - assert ( - G20ThreeCameraCalibrationNode._synchronised_endpoint_tolerance_for_spec( - node, spec, 0 - ) - == 3.0 - ) - - -def test_formal_sweep_still_rejects_17_u8_feedback_gap(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 10 - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - commands = [*range(0, 100), *range(116, 256)] - frames = [] - for command in commands: - state = [255.0] * 20 - state[10] = float(command) - frames.append(_frame("top", state)) - retries: list[str] = [] - node = SimpleNamespace( - active_sweep=item, - sweep_frames=frames, - minimum_sweep_bins=32, - maximum_bin_gap=16, - _endpoint_tolerance_for_spec=lambda selected, endpoint: 5.0, - _retry_active_sweep_or_pause=lambda reason: retries.append(reason), - ) - - G20ThreeCameraCalibrationNode._finish_active_sweep(node) - - assert retries == ["sweep_bin_gap_too_large"] - - -def test_first_provisional_fit_failure_stops_without_more_motion(tmp_path) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) - transitions: list[str] = [] - pauses: list[str] = [] - prepared: list[bool] = [] - node = SimpleNamespace( - sweep_items=_sweep_items(), - sweep_attempts={spec.motor_index: 1}, - retry_sweep_spec=None, - retry_resume_index=None, - fit_failure={}, - sweep_index=18, - raw_path=tmp_path / "raw_samples.jsonl", - repetitions=3, - automatic_fit_retry_limit=1, - paused_reason="", - reason="", - _sweep_spec_start_index=lambda selected: ( - G20ThreeCameraCalibrationNode._sweep_spec_start_index(node, selected) - ), - _prepare_failed_sweep_retry=lambda: prepared.append(True), - _begin_return_baseline=lambda after: transitions.append(after), - _pause=lambda reason: pauses.append(reason), - ) - - G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, - spec, - [{"joint": "thumb_ip", "metric": "rotation_orthogonal_rms_deg"}], - ) - - assert prepared == [] - assert transitions == [] - assert pauses == ["joint_fit_check_failed"] - - -def test_repeatable_all_cycle_model_conflict_does_not_waste_full_retry( - tmp_path, -) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) - failures = [ - { - "joint": "thumb_ip", - "metric": "rotation_circle_axis_difference_deg", - "cycle": cycle, - "actual": actual, - "limit": 1.0, - "comparison": "maximum", - } - for cycle, actual in enumerate((3.9, 4.1, 4.0), start=1) - ] - assert _fit_failure_is_systematic(failures, 3) - transitions: list[str] = [] - pauses: list[str] = [] - node = SimpleNamespace( - sweep_items=_sweep_items(), - sweep_attempts={spec.motor_index: 1}, - retry_sweep_spec=None, - retry_resume_index=None, - fit_failure={}, - sweep_index=18, - raw_path=tmp_path / "raw_samples.jsonl", - repetitions=3, - automatic_fit_retry_limit=2, - paused_reason="", - reason="", - _begin_return_baseline=lambda after: transitions.append(after), - _pause=lambda reason: pauses.append(reason), - ) - - G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, spec, failures - ) - - assert transitions == [] - assert pauses == ["joint_fit_systematic_failure"] - assert node.fit_failure["recoverable_by_rescan"] is False - assert node.fit_failure["directions_to_rescan"] == 0 - - -def test_near_threshold_provisional_failure_stops_without_rescan(tmp_path) -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - node = SimpleNamespace( - raw_path=tmp_path / "raw_samples.jsonl", - provisional_warning_ratio=1.25, - reason="", - sweep_attempts={}, - retry_resume_index=None, - retry_sweep_spec=None, - retry_cycles=set(), - repetitions=3, - fit_failure={}, - sweep_index=0, - automatic_fit_retry_limit=2, - paused_reason="", - _prepare_failed_sweep_retry=lambda: None, - _begin_return_baseline=lambda after: None, - _pause=lambda reason: None, - ) - handled = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, - spec, - [ - { - "joint": "index_mcp_roll", - "metric": "hysteresis_deg", - "actual": 5.5, - "limit": 5.0, - "comparison": "maximum", - } - ], - allow_warning=True, - ) - - assert handled is True - events = [ - json.loads(line) - for line in (tmp_path / "raw_samples.jsonl").read_text().splitlines() - ] - kinds = [event["kind"] for event in events] - assert "provisional_fit_warning_retry_exhausted" in kinds - assert "fit_failure" in kinds - - -def test_motion_timeout_pauses_without_republishing(tmp_path) -> None: - commands: list[list[int]] = [] - pauses: list[str] = [] - holds: list[bool] = [] - node = SimpleNamespace( - motion_retry_counts={}, - automatic_motion_retry_limit=2, - raw_path=tmp_path / "raw_samples.jsonl", - state="RETURN_BASELINE", - baseline_command=tuple([255] * 20), - position_hold_since=1.0, - reason="", - _publish_hold_current=lambda: holds.append(True), - _publish_speed_profile=lambda profile: None, - _normal_speed_profile=lambda: [15] * 5, - _publish_command=lambda command: commands.append(command), - _pause=lambda reason: pauses.append(reason), - _baseline_error_u8=lambda: 50.0, - _reset_motion_progress=lambda now, error: None, - ) - - for now in (10.0, 20.0, 30.0): - G20ThreeCameraCalibrationNode._retry_motion_or_pause( - node, "return_baseline_timeout", now - ) - - assert commands == [] - assert holds == [] - assert pauses == ["return_baseline_timeout"] * 3 - - -def test_sweep_start_tag_timeout_does_not_block_motion(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "ring_pitch_side" - ) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - runtime = SimpleNamespace(pnp_invalid_since=10.0, pnp_reset_count=0) - resets: list[tuple[object, bool]] = [] - diagnostic_resets: list[object] = [] - commands: list[list[int]] = [] - starts: list[float] = [] - pauses: list[str] = [] - start_frames = [object()] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - active_sweep=item, - active_sweep_is_fit_retry=False, - views={"side": runtime}, - sweep_start_frames=start_frames, - motion_retry_counts={}, - automatic_motion_retry_limit=2, - raw_path=tmp_path / "raw_samples.jsonl", - state=STATE_PREPARE_SWEEP, - position_hold_since=1.0, - reason="", - preparation_command_u8=tuple([255] * 20), - baseline_command=tuple([255] * 20), - latest_state_u8=tuple([255] * 20), - _reset_view_trackers=lambda selected, preserve_task_reference=False: ( - resets.append((selected, preserve_task_reference)) - ), - _reset_view_pnp_diagnostics=lambda selected: ( - diagnostic_resets.append(selected) - ), - _command_vector_error_u8=lambda command: 0.0, - _normal_speed_profile=lambda: [15] * 5, - _publish_speed_profile=lambda profile: None, - _publish_command=lambda command: commands.append(command), - _reset_motion_progress=lambda now, error: None, - _pause=lambda reason: pauses.append(reason), - _begin_active_sweep=lambda now: starts.append(now), - ) - - G20ThreeCameraCalibrationNode._handle_sweep_start_timeout( - node, 20.0, True - ) - - assert starts == [20.0] - assert pauses == [] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "sweep_start_vision_timeout_warning" - - -def test_motion_stall_pauses_without_consuming_sweep_retries(tmp_path) -> None: - pauses: list[str] = [] - node = SimpleNamespace( - raw_path=tmp_path / "raw_samples.jsonl", - latest_state_u8=tuple([18.0] + [255.0] * 19), - motion_progress_reference_error_u8=18.0, - motion_last_progress_at=10.0, - motor_stall_timeout_seconds=8.0, - motor_stall_minimum_progress_u8=1.0, - _pause=lambda reason: pauses.append(reason), - _reset_motion_progress=lambda now, error: None, - ) - - assert not G20ThreeCameraCalibrationNode._pause_if_motion_stalled( - node, now=17.9, error_u8=18.0, context="sweep_motor_0" - ) - assert G20ThreeCameraCalibrationNode._pause_if_motion_stalled( - node, now=18.0, error_u8=18.0, context="sweep_motor_0" - ) - - assert pauses == [ - "motor_state_stalled:sweep_motor_0:timeout_seconds=8.000:" - "error_u8=18.000" - ] - event = json.loads((tmp_path / "raw_samples.jsonl").read_text()) - assert event["kind"] == "mechanical_motion_stall" - assert event["context"] == "sweep_motor_0" - - -def test_same_speed_sweep_retry_keeps_timeout() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) - item = SweepItem(spec, 0, DIRECTION_DECREASING) - key = (spec.motor_index, 0, DIRECTION_DECREASING) - node = SimpleNamespace( - active_sweep=item, - sweep_timeout_seconds=90.0, - sweep_retry_counts={key: 1}, - retry_speed_scales=(0.8, 0.6, 0.5), - ) - - assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds( - node - ) == 90.0 - node.sweep_retry_counts[key] = 3 - assert G20ThreeCameraCalibrationNode._active_sweep_timeout_seconds( - node - ) == 90.0 - - -def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) - node = _fit_check_node( - { - "thumb_cmc_pitch": _image_cycle_records( - [math.radians(47.0), math.radians(47.2), math.radians(55.0)] - ) - } - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - cycle_failure = next( - item - for item in failures - if item["metric"] == "cycle_travel_range_deg" - ) - assert cycle_failure["joint"] == "thumb_cmc_pitch" - assert cycle_failure["actual"] == 8.0 - assert cycle_failure["limit"] == 3.0 - - -def test_provisional_fit_accepts_constrained_circle_for_image_joint() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) - node = _fit_check_node( - { - "thumb_cmc_pitch": _image_cycle_records( - [math.radians(47.0)] * 3, - depth_slope=2.00, - ) - } - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] - in { - "rotation_circle_axis_difference_deg", - "axis_cycle_difference_deg", - "axis_plane_rms_mm", - "axis_radial_rms_mm", - } - ] - - -def test_axis_cycle_spread_localizes_one_outlier_for_four_rounds() -> None: - axes = [ - np.asarray([-0.05713560, -0.06623617, -0.99616680]), - np.asarray([-0.05620951, -0.05515776, -0.99689423]), - np.asarray([-0.05700579, -0.05346630, -0.99694117]), - np.asarray([-0.05589426, -0.05231845, -0.99706500]), - ] - - assert _isolated_axis_cycle_outliers( - axes, math.radians(0.75) - ) == {0} - - -def test_thumb_yaw_zero_spread_localizes_181154_second_cycle() -> None: - cycle_offsets = [ - math.radians(-2.518678), - math.radians(-3.098037), - math.radians(-2.544640), - ] - result = SimpleNamespace( - cycle_offsets_rad={"thumb_cmc_yaw": cycle_offsets}, - offset_confidence_half_width_rad={ - "thumb_cmc_yaw": math.radians(0.81295) - }, - ) - - assert _isolated_scalar_cycle_outlier( - cycle_offsets, math.radians(0.5) - ) == {1} - assert _thumb_yaw_zero_repeatability_failures( - result, - maximum_cycle_range_rad=math.radians(0.5), - maximum_confidence_half_width_rad=math.radians(0.75), - ) == [ - { - "joint": "thumb_cmc_yaw", - "metric": "zero_cycle_offset_range_deg", - "actual": 0.579359, - "limit": 0.5, - "comparison": "maximum", - "cycle_offset_deg": [-2.518678, -3.098037, -2.54464], - "cycle": 2, - "inlier_cycles": [1, 3], - } - ] - - -def test_thumb_yaw_zero_spread_localizes_142322_marginal_cycle() -> None: - cycle_offsets = [ - math.radians(-2.589022), - math.radians(-2.915619), - math.radians(-2.391267), - ] - result = SimpleNamespace( - cycle_offsets_rad={"thumb_cmc_yaw": cycle_offsets}, - offset_confidence_half_width_rad={ - "thumb_cmc_yaw": math.radians(0.658) - }, - ) - - assert _isolated_scalar_cycle_outlier( - cycle_offsets, math.radians(0.5) - ) == {1} - failures = _thumb_yaw_zero_repeatability_failures( - result, - maximum_cycle_range_rad=math.radians(0.5), - maximum_confidence_half_width_rad=math.radians(0.75), - ) - assert failures[0]["actual"] == 0.524352 - assert failures[0]["cycle"] == 2 - assert failures[0]["inlier_cycles"] == [1, 3] - - -def test_thumb_yaw_zero_spread_keeps_symmetric_drift_nonlocalized() -> None: - cycle_offsets = [ - math.radians(-2.76), - math.radians(-2.50), - math.radians(-2.24), - ] - - assert _isolated_scalar_cycle_outlier( - cycle_offsets, math.radians(0.5) - ) == set() - - -def test_thumb_yaw_zero_confidence_failure_requests_full_yaw_retry() -> None: - result = SimpleNamespace( - cycle_offsets_rad={ - "thumb_cmc_yaw": [ - math.radians(-2.50), - math.radians(-2.70), - math.radians(-2.30), - ] - }, - offset_confidence_half_width_rad={ - "thumb_cmc_yaw": math.radians(0.80) - }, - ) - - assert _thumb_yaw_zero_repeatability_failures( - result, - maximum_cycle_range_rad=math.radians(0.5), - maximum_confidence_half_width_rad=math.radians(0.75), - ) == [ - { - "joint": "thumb_cmc_yaw", - "metric": "zero_confidence_95_half_width_deg", - "actual": 0.8, - "limit": 0.75, - "comparison": "maximum", - } - ] - - -def test_thumb_yaw_zero_repeatability_accepts_171406_quality() -> None: - result = SimpleNamespace( - cycle_offsets_rad={ - "thumb_cmc_yaw": [ - math.radians(-2.53708), - math.radians(-2.69820), - math.radians(-2.29168), - ] - }, - offset_confidence_half_width_rad={ - "thumb_cmc_yaw": math.radians(0.50853) - }, - ) - - assert _thumb_yaw_zero_repeatability_failures( - result, - maximum_cycle_range_rad=math.radians(0.5), - maximum_confidence_half_width_rad=math.radians(0.75), - ) == [] - - -def test_thumb_yaw_zero_outlier_retries_only_localized_source_cycle( - tmp_path, -) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_yaw_top" - ) - failures = _thumb_yaw_zero_repeatability_failures( - SimpleNamespace( - cycle_offsets_rad={ - "thumb_cmc_yaw": [ - math.radians(-2.518678), - math.radians(-3.098037), - math.radians(-2.544640), - ] - }, - offset_confidence_half_width_rad={ - "thumb_cmc_yaw": math.radians(0.81295) - }, - ), - maximum_cycle_range_rad=math.radians(0.5), - maximum_confidence_half_width_rad=math.radians(0.75), - ) - calls: list[str] = [] - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - raw_path=tmp_path / "raw_samples.jsonl", - sweep_attempts={}, - retry_resume_index=None, - retry_sweep_spec=None, - retry_cycles=set(), - retry_joint_names=set(), - fit_failure={}, - sweep_index=96, - repetitions=3, - automatic_fit_retry_limit=2, - retry_source_failure_task_key=spec.key, - retry_source_task_keys=( - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - ), - retry_cycle_override=set(), - paused_reason="", - reason="", - _prepare_failed_sweep_retry=lambda: calls.append("prepare"), - _begin_return_baseline=lambda after: calls.append(after), - _pause=lambda reason: calls.append(f"pause:{reason}"), - ) - - handled = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, spec, failures - ) - - assert handled is True - assert node.retry_cycles == {1} - assert node.retry_joint_names == { - "thumb_cmc_pitch", - "thumb_cmc_roll", - } - assert node.fit_failure["cycles_to_rescan"] == [2] - assert node.fit_failure["directions_to_rescan"] == 4 - assert node.fit_failure["source_task_names"] == [ - "thumb_cmc_pitch_front", - "thumb_cmc_roll_front", - ] - assert calls == ["pause:joint_fit_check_failed"] - - -def test_previous_passed_joint_zero_offset_reads_formal_pointer( - tmp_path, -) -> None: - root = tmp_path / "G20_RIGHT_001" - previous = root / "20260830_171406" - current = root / "20260831_100000" - previous.mkdir(parents=True) - current.mkdir() - payload = { - "joints": { - "thumb_cmc_yaw": { - "zero_angles": {"urdf_zero_offset_rad": -0.04736713} - } - } - } - (previous / "g20_right_G20_RIGHT_001_calibration.json").write_text( - json.dumps(payload), encoding="utf-8" - ) - (root / "latest_passed").symlink_to(previous, target_is_directory=True) - - assert _previous_passed_joint_zero_offset( - current, "G20_RIGHT_001", "thumb_cmc_yaw" - ) == (previous, -0.04736713) - - -def test_axis_line_spread_localizes_one_outlier_for_four_rounds() -> None: - axes = [ - [-0.043565, 0.036698, -0.998376], - [-0.043462, 0.047539, -0.997923], - [-0.045335, 0.041627, -0.998104], - [-0.043689, 0.044394, -0.998058], - ] - points_mm = [ - [-43.981, 52.532, 1101.515], - [-43.999, 50.684, 1101.530], - [-44.080, 49.366, 1101.310], - [-44.252, 50.672, 1101.511], - ] - measurements = [ - SimpleNamespace( - axis_common_xyz=axis, - point_common_xyz_m=[0.001 * value for value in point], - ) - for axis, point in zip(axes, points_mm) - ] - - assert _isolated_axis_line_cycle_outliers( - measurements, 0.001 - ) == {0} - - -def test_axis_line_spread_does_not_localize_ambiguous_drift() -> None: - measurements = [ - SimpleNamespace( - axis_common_xyz=[0.0, 0.0, 1.0], - point_common_xyz_m=[0.0, offset_m, 0.0], - ) - for offset_m in (0.0, 0.001, 0.002, 0.003) - ] - - assert not _isolated_axis_line_cycle_outliers( - measurements, 0.001 - ) - - -def test_fit_retry_preserves_first_cycle_pnp_task_reference() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 6) - - assert _preserve_pnp_task_reference_for_sweep( - SweepItem(spec, 0, DIRECTION_DECREASING), - is_fit_retry=True, - has_precheck_anchor=False, - ) - assert _preserve_pnp_task_reference_for_sweep( - SweepItem(spec, 0, DIRECTION_DECREASING), - is_fit_retry=False, - has_precheck_anchor=True, - ) - assert not _preserve_pnp_task_reference_for_sweep( - SweepItem(spec, 0, DIRECTION_DECREASING), - is_fit_retry=False, - has_precheck_anchor=False, - ) - assert _preserve_pnp_task_reference_for_sweep( - SweepItem(spec, 1, DIRECTION_DECREASING), - is_fit_retry=False, - has_precheck_anchor=False, - ) - - -def test_provisional_axis_spread_marks_only_the_isolated_round() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) - records = _image_cycle_records([math.radians(47.0)] * 4) - node = _fit_check_node({"thumb_cmc_pitch": records}) - node.repetitions = 4 - axes = [ - [-0.05713560, -0.06623617, -0.99616680], - [-0.05620951, -0.05515776, -0.99689423], - [-0.05700579, -0.05346630, -0.99694117], - [-0.05589426, -0.05231845, -0.99706500], - ] - node._fit_axis_measurement = lambda name, cycle: SimpleNamespace( - axis_common_xyz=axes[cycle], - axis_direction_source="pose_rotation_3d", - radial_rms_m=0.0001, - pose_axis_line_rms_m=0.0001, - plane_rms_m=0.0001, - rotation_circle_axis_difference_rad=0.0, - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - spread = next( - failure - for failure in failures - if failure["metric"] == "axis_cycle_difference_deg" - ) - assert spread["cycle"] == 1 - assert spread["inlier_cycles"] == [2, 3, 4] - - -def test_side_roll_alias_does_not_reject_free_circle_axis_disagreement() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - name = "pinky_mcp_roll_side" - spec = replace(spec, joints=(name,)) - records = _image_cycle_records( - [math.radians(47.0)] * 3, - depth_slope=2.0, - ) - node = _fit_check_node({name: records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {name: records} - node.minimum_detection_rate = 0.95 - node.views = { - "side": SimpleNamespace( - valid_rate=0.9453, - task_valid_frames=2653, - task_total_frames=2807, - ) - } - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] - in { - "rotation_circle_axis_difference_deg", - "axis_plane_rms_mm", - "tag_valid_rate_percent", - } - ] - - -def test_thumb_ip_axis_uses_same_cycle_thumb_mcp_direction() -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _fit_check_node( - { - "thumb_cmc_pitch": records, - "thumb_mcp": records, - "thumb_ip": records, - } - ) - - upstream = node._fit_axis_measurement("thumb_mcp", 0) - passive = node._fit_axis_measurement("thumb_ip", 0) - - assert upstream.condition_command_u8 is not None - assert upstream.condition_command_u8[15] == 255.0 - assert abs( - float( - np.asarray(upstream.axis_common_xyz) - @ np.asarray(passive.axis_common_xyz) - ) - ) > math.cos(math.radians(0.01)) - - -def test_provisional_fit_uses_orientation_constrained_circle_for_yaw() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 10) - node = _fit_check_node( - { - "thumb_cmc_yaw": _image_cycle_records( - [math.radians(47.0)] * 3, - depth_slope=0.30, - ) - } - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] - in { - "rotation_circle_axis_difference_deg", - "axis_cycle_difference_deg", - "axis_plane_rms_mm", - "axis_radial_rms_mm", - } - ] - - -def test_right_19_side_flexion_uses_end_on_orientation_axis() -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_pitch_side" - ) - name = "pinky_mcp_pitch" - records = _image_cycle_records( - [math.radians(47.0)] * 3, - depth_slope=0.30, - ) - node = _fit_check_node({name: records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - - measurement = G20ThreeCameraCalibrationNode._fit_axis_measurement_raw( - node, name, 0 - ) - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert measurement.axis_direction_source == "rotation" - assert not [ - failure - for failure in failures - if failure["metric"] - in { - "rotation_circle_axis_difference_deg", - "axis_plane_rms_mm", - } - ] - - -def test_provisional_fit_uses_active_and_passive_axis_model_limits() -> None: - spec = next(item for item in SWEEP_SPECS if item.motor_index == 15) - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _fit_check_node( - { - "thumb_mcp": records, - "thumb_ip": records, - } - ) - residuals_deg = {"thumb_mcp": 2.0, "thumb_ip": 6.0} - - def fitted(name, selected, relaxed=False): - fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, name, selected, relaxed=relaxed - ) - quality = dict(fit.quality) - quality["rotation_orthogonal_rms_rad"] = math.radians( - residuals_deg[name] - ) - return replace(fit, quality=quality) - - node._fit_joint_records = fitted - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - assert not [ - failure - for failure in failures - if failure["metric"] == "rotation_orthogonal_rms_deg" - ] - - residuals_deg["thumb_mcp"] = 3.0 - residuals_deg["thumb_ip"] = 8.0 - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - axis_failures = [ - failure - for failure in failures - if failure["metric"] == "rotation_orthogonal_rms_deg" - ] - assert {(item["joint"], item["limit"]) for item in axis_failures} == { - ("thumb_mcp", 2.5), - ("thumb_ip", 7.5), - } - - -def _axis_cycle_records( - travel_rad: float, - axis_xyz: list[float], - *, - cycles: int = 3, - offset_xyz: list[float] | None = None, -) -> list[dict]: - """Synthetic sweeps rotating rigidly about an arbitrary axis.""" - commands = list(range(0, 256, 16)) + [255] - axis = np.asarray(axis_xyz, dtype=float) - axis = axis / np.linalg.norm(axis) - reference = ( - np.array([0.0, 0.0, 1.0]) - if abs(float(axis @ np.array([0.0, 0.0, 1.0]))) < 0.9 - else np.array([0.0, 1.0, 0.0]) - ) - u = np.cross(reference, axis) - u = u / np.linalg.norm(u) - v = np.cross(axis, u) - offset = ( - np.zeros(3) if offset_xyz is None else np.asarray(offset_xyz, float) - ) - records = [] - for cycle in range(cycles): - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING): - for command in commands: - angle = travel_rad * (255.0 - command) / 255.0 - rotation = Rotation.from_rotvec(axis * angle) - centre = ( - offset - + 0.03 * (u * math.cos(angle) + v * math.sin(angle)) - ) - records.append( - { - "cycle": cycle, - "direction": direction, - "command_u8": command, - "relative_translation_xyz_m": centre.tolist(), - "image_relative_xy_px": [ - 100.0 * math.cos(angle), - 100.0 * math.sin(angle), - ], - "relative_quaternion_xyzw": ( - rotation.as_quat().tolist() - ), - "parent_pose_common": { - "translation_xyz_m": [0.0, 0.0, 0.0], - "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0], - }, - "child_pose_common": { - "translation_xyz_m": centre.tolist(), - "quaternion_xyzw": ( - rotation.as_quat().tolist() - ), - }, - "state_u8": [255.0] * 9 - + [float(command)] - + [255.0] * 10, - } - ) - return records - - -def _cross_view_roll_node( - tmp_path, side_tilt_deg: float, side_offset_m: list[float] | None = None -) -> SimpleNamespace: - travel = math.radians(25.0) - front = _axis_cycle_records(travel, [0.0, 0.0, 1.0]) - tilt = math.radians(side_tilt_deg) - side = _axis_cycle_records( - travel, - [math.sin(tilt), 0.0, math.cos(tilt)], - offset_xyz=side_offset_m, - ) - node = _fit_check_node( - {"pinky_mcp_roll": front, "pinky_mcp_roll_side": side} - ) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = { - "pinky_mcp_roll": front, - "pinky_mcp_roll_side": side, - } - node.raw_path = tmp_path / "raw_samples.jsonl" - # The production node always owns validated camera extrinsics. Keep the - # synthetic camera centre away from the synthetic axis origin so the - # interpretation-plane geometry is well defined as it is on real data. - node.extrinsics = SimpleNamespace( - transform=lambda _view: np.asarray( - [ - [1.0, 0.0, 0.0, 0.1], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - dtype=float, - ) - ) - return node - - -def test_cross_view_roll_axis_disagreement_skips_fusion(tmp_path) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - # 11.4 deg disagreement was the stable signature of the side-view IPPE - # bias in session 20260820_105535: it must keep the trusted front axis - # and record a diagnostic instead of failing the joint. - assert measurement.axis_direction_source != "cross_view_weighted_fusion" - assert measurement.pose_axis_line_source_joints == ( - "pinky_mcp_roll", - ) - assert measurement.axis_point_source == ( - "front_interpretation_plane_cross_view_validated" - ) - assert axis_line_uses_depth_free_interpretation_plane(measurement) - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert diagnostics[-1]["kind"] == "cross_view_roll_axis_diagnostic" - assert diagnostics[-1]["decision"] == ( - "diagnostic_only_pose_disagreement_use_primary" - ) - assert 10.0 < diagnostics[-1]["axis_difference_deg"] < 12.5 - - -def test_cross_view_depth_free_axis_group_is_not_radius_refitted(tmp_path) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0) - measurements = [ - node._fit_axis_measurement("pinky_mcp_roll", cycle) - for cycle in range(node.repetitions) - ] - - refined = G20ThreeCameraCalibrationNode._refit_cross_view_axis_line_group( - node, "pinky_mcp_roll", measurements - ) - - assert refined == measurements - assert all( - axis_line_uses_depth_free_interpretation_plane(measurement) - for measurement in refined - ) - - -def test_cross_view_depth_free_axis_skips_point_repeatability_rescan( - tmp_path, monkeypatch -) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0) - original_fit_axis = node._fit_axis_measurement - - def displaced_side_line(name, cycle): - measurement = original_fit_axis(name, cycle) - if name == "pinky_mcp_roll" and cycle == node.repetitions - 1: - point = np.asarray(measurement.point_common_xyz_m, dtype=float) - point[0] += 0.0024 - return replace( - measurement, - point_common_xyz_m=tuple(float(value) for value in point), - ) - return measurement - - node._fit_axis_measurement = displaced_side_line - # A depth-free interpretation plane does not define one Euclidean line - # point, so point-only cycle RMS must not trigger a misleading rescan. - monkeypatch.setitem( - G20ThreeCameraCalibrationNode._provisional_fit_failures.__globals__, - "_isolated_axis_line_cycle_outliers", - lambda measurements, limit_m: pytest.fail( - "depth-free axes must not enter the line-point outlier gate" - ), - ) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - line_failures = [ - failure - for failure in failures - if failure["metric"] == "axis_line_cycle_rms_mm" - ] - - assert not line_failures - - -def test_cross_view_front_pose_residual_remains_a_hard_gate( - tmp_path, -) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0) - original_fit_axis = node._fit_axis_measurement - - def biased_but_repeatable_side_pose(name, cycle): - measurement = original_fit_axis(name, cycle) - if name == "pinky_mcp_roll": - return replace(measurement, pose_axis_line_rms_m=0.00125) - return measurement - - node._fit_axis_measurement = biased_but_repeatable_side_pose - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - pose_failures = [ - failure - for failure in failures - if failure["metric"] == "axis_pose_line_rms_mm" - ] - assert pose_failures - assert all( - failure["joint"] == "pinky_mcp_roll" - and failure["quality_source_joints"] == ["pinky_mcp_roll"] - for failure in pose_failures - ) - assert not [ - failure - for failure in failures - if failure["metric"] == "axis_line_cycle_rms_mm" - ] - - -def test_cross_view_roll_systematic_line_offset_skips_fusion(tmp_path) -> None: - # Ring session 20260824_155323: the front screw-driven pseudo-line and - # side PIP-link physical line sit 36.6-38.2 mm apart while both views are - # individually clean. This finger-dependent offset must remain a - # diagnostic and must not reject the trusted-front/side-line fallback. - node = _cross_view_roll_node( - tmp_path, side_tilt_deg=11.0, side_offset_m=[0.038, 0.0, 0.0] - ) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - assert measurement.axis_direction_source != "cross_view_weighted_fusion" - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert diagnostics[-1]["kind"] == "cross_view_roll_axis_diagnostic" - assert 34.0 < diagnostics[-1]["line_distance_mm"] < 42.0 - assert 10.0 < diagnostics[-1]["axis_difference_deg"] < 12.5 - - -def test_cross_view_roll_diagnostic_uses_real_node_logger(tmp_path) -> None: - # The live node exposes get_logger as a method returning the logger; - # recording the diagnostic must call it instead of treating the bound - # method itself as the logger (regression: session 20260820_133434 - # turned this AttributeError into four axis_fit failures). - node = _cross_view_roll_node(tmp_path, side_tilt_deg=11.0) - warnings: list[str] = [] - node.get_logger = lambda: SimpleNamespace(warning=warnings.append) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - assert measurement.axis_direction_source != "cross_view_weighted_fusion" - assert any("disagree" in message for message in warnings) - - -def test_cross_view_roll_axis_gross_disagreement_is_diagnostic_only( - tmp_path, -) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=20.0) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - assert measurement.axis_direction_source == "rotation" - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert diagnostics[-1]["decision"] == ( - "diagnostic_only_gross_pose_disagreement_use_primary" - ) - - -def test_cross_view_roll_gross_disagreement_does_not_rescan(tmp_path) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=20.0) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - gross_failures = [ - failure - for failure in failures - if str(failure.get("reason", "")).startswith( - "cross_view_roll_axis_gross_disagreement:" - ) - ] - - assert not gross_failures - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert any( - row.get("decision") - == "diagnostic_only_gross_pose_disagreement_use_primary" - for row in diagnostics - ) - - -def test_cross_view_roll_axis_agreement_keeps_published_primary(tmp_path) -> None: - node = _cross_view_roll_node(tmp_path, side_tilt_deg=0.2) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - assert measurement.axis_direction_source == "rotation" - assert measurement.pose_axis_line_source_joints == ( - "pinky_mcp_roll", - ) - assert measurement.axis_point_source == ( - "front_interpretation_plane_cross_view_validated" - ) - - -def test_cross_view_roll_side_pose_never_replaces_primary_direction( - tmp_path, monkeypatch -) -> None: - # Regression for session 20260824_171302. Three side-view cone - # residuals were just inside 5 deg and one was 5.037 deg. Selecting the - # camera per cycle mixed three side axes with one front axis and created a - # false 3.24 deg cycle spread although each camera was internally stable. - names = ( - "middle_mcp_roll", - "middle_mcp_roll_side", - "middle_mcp_pitch", - ) - node = SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - zero_profile=get_zero_calibration_profile( - "right", "g20_right_19" - ), - repetitions=4, - records_by_joint={ - name: [{"cycle": cycle} for cycle in range(4)] - for name in names - }, - source_urdf_path=tmp_path / "unused.urdf", - raw_path=tmp_path / "raw_samples.jsonl", - zero_maximum_axis_cycle_difference_rad=math.radians(0.75), - zero_maximum_axis_cone_mismatch_rad=math.radians(5.0), - axis_maximum_pose_line_rms_m=0.001, - cross_view_roll_maximum_axis_difference_rad=math.radians(15.0), - extrinsics=SimpleNamespace( - transform=lambda _view: np.asarray( - [ - [1.0, 0.0, 0.0, 0.1], - [0.0, 1.0, 0.0, 0.0], - [0.0, 0.0, 1.0, 0.0], - [0.0, 0.0, 0.0, 1.0], - ], - dtype=float, - ) - ), - ) - primary_degrees = (8.0078, 8.1710, 8.0712, 8.0236) - secondary_degrees = (4.9043, 4.9136, 5.0374, 4.9389) - - def axis(degrees: float) -> tuple[float, float, float]: - angle = math.radians(degrees) - return (math.sin(angle), 0.0, math.cos(angle)) - - def measurement(name: str, cycle: int) -> JointAxisMeasurement: - if name == "middle_mcp_roll": - direction = axis(primary_degrees[cycle]) - elif name == "middle_mcp_roll_side": - direction = axis(secondary_degrees[cycle]) - else: - direction = (1.0, 0.0, 0.0) - return JointAxisMeasurement( - joint=name, - cycle=cycle, - axis_common_xyz=direction, - point_common_xyz_m=(0.0, 0.0, 0.0), - condition_state_u8=(255.0,) * 20, - plane_rms_m=0.0001, - radial_rms_m=0.0001, - rotation_circle_axis_difference_rad=0.0, - axis_direction_source="rotation", - pose_axis_line_rms_m=0.0001, - ) - - class Model: - def __init__(self, unused_path) -> None: - pass - - def axis_line(self, name, *, zero_offsets, joint_angles): - direction = ( - np.asarray([0.0, 0.0, 1.0]) - if name == "middle_mcp_roll" - else np.asarray([1.0, 0.0, 0.0]) - ) - return direction, np.zeros(3) - - monkeypatch.setattr( - G20ThreeCameraCalibrationNode, - "_fit_axis_measurement_raw", - lambda unused_self, name, cycle: measurement(name, cycle), - ) - monkeypatch.setitem( - G20ThreeCameraCalibrationNode._fit_axis_measurement.__globals__, - "UrdfKinematicModel", - Model, - ) - - measured = [ - G20ThreeCameraCalibrationNode._fit_axis_measurement( - node, "middle_mcp_roll", cycle - ) - for cycle in range(4) - ] - - assert { - item.axis_direction_source for item in measured - } == {"rotation"} - maximum_spread = max( - math.acos( - abs( - float( - np.clip( - np.asarray(left.axis_common_xyz) - @ np.asarray(right.axis_common_xyz), - -1.0, - 1.0, - ) - ) - ) - ) - for left in measured - for right in measured - ) - assert math.degrees(maximum_spread) < 0.75 - - -def test_side_alias_branch_gap_range_is_diagnostic_only( - tmp_path, monkeypatch -) -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - gaps_deg = [0.10, 0.81, 0.10] # range 0.71 deg, maximum below 2 deg - monkeypatch.setattr( - "linkerhand_calibration.three_camera_node" - ".baseline_hysteresis_by_cycle_rad", - lambda records, zero_command_u8, axis_xyz: [ - math.radians(value) for value in gaps_deg - ], - ) - multiview = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - - def node_for(joints): - node = _fit_check_node({name: records for name in joints}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {name: records for name in joints} - node.raw_path = tmp_path / f"raw_{joints[0]}.jsonl" - return node - - alias_failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node_for(("pinky_mcp_roll_side",)), - replace(multiview, joints=("pinky_mcp_roll_side",)), - ) - assert not [ - failure - for failure in alias_failures - if failure["metric"] == "baseline_directional_gap_range_deg" - ] - alias_diagnostics = [ - json.loads(line) - for line in (tmp_path / "raw_pinky_mcp_roll_side.jsonl") - .read_text() - .splitlines() - ] - assert any( - row.get("kind") == "validation_only_quality_diagnostic" - and row.get("metric") == "baseline_directional_gap_range_deg" - and row.get("actual") == 0.71 - and row.get("reference_limit") == 0.3 - for row in alias_diagnostics - ) - - canonical_failures = ( - G20ThreeCameraCalibrationNode._provisional_fit_failures( - node_for(("pinky_mcp_roll",)), - replace(multiview, joints=("pinky_mcp_roll",)), - ) - ) - range_failure = next( - failure - for failure in canonical_failures - if failure["metric"] == "baseline_directional_gap_range_deg" - ) - assert range_failure["limit"] == 0.3 - - -def test_side_alias_axis_cycle_spread_is_diagnostic_only(tmp_path) -> None: - records = _image_cycle_records([math.radians(47.0)] * 4) - multiview = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - name = "pinky_mcp_roll_side" - node = _fit_check_node({name: records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.repetitions = 4 - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {name: records} - node.raw_path = tmp_path / "raw_axis_alias.jsonl" - wrapped = node._fit_axis_measurement - axes = [ - np.asarray([0.0, 0.0, 1.0]), - np.asarray( - [ - 0.0, - math.sin(math.radians(1.1)), - math.cos(math.radians(1.1)), - ] - ), - np.asarray([0.0, 0.0, 1.0]), - np.asarray([0.0, 0.0, 1.0]), - ] - - def spread_axis(joint_name, cycle): - return replace( - wrapped(joint_name, cycle), - axis_common_xyz=axes[cycle], - radial_rms_m=0.0001, - pose_axis_line_rms_m=0.0001, - plane_rms_m=0.0001, - rotation_circle_axis_difference_rad=0.0, - ) - - node._fit_axis_measurement = spread_axis - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, replace(multiview, joints=(name,)), include_view_validity=False - ) - - assert not [ - failure - for failure in failures - if failure["metric"] == "axis_cycle_difference_deg" - ] - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert any( - row.get("kind") == "validation_only_quality_diagnostic" - and row.get("metric") == "axis_cycle_difference_deg" - and row.get("actual") == 1.1 - and row.get("reference_limit") == 0.75 - for row in diagnostics - ) - - -def test_side_alias_skips_pose_line_rms_gate(tmp_path) -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - multiview = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_roll_multiview" - ) - - def node_for(joints): - node = _fit_check_node({name: records for name in joints}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {name: records for name in joints} - node.raw_path = tmp_path / f"raw_pose_{joints[0]}.jsonl" - wrapped = node._fit_axis_measurement - - def inflated(name, cycle): - measurement = wrapped(name, cycle) - if name in joints: - return replace(measurement, pose_axis_line_rms_m=0.002) - return measurement - - node._fit_axis_measurement = inflated - return node - - alias_failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node_for(("pinky_mcp_roll_side",)), - replace(multiview, joints=("pinky_mcp_roll_side",)), - ) - assert not [ - failure - for failure in alias_failures - if failure["metric"] == "axis_pose_line_rms_mm" - ] - - canonical_failures = ( - G20ThreeCameraCalibrationNode._provisional_fit_failures( - node_for(("pinky_mcp_roll",)), - replace(multiview, joints=("pinky_mcp_roll",)), - ) - ) - assert [ - failure - for failure in canonical_failures - if failure["metric"] == "axis_pose_line_rms_mm" - ] - - -def test_right_19_thumb_ip_pose_line_is_position_diagnostic_only( - tmp_path, -) -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_mcp_ip_front" - ) - node = _fit_check_node( - {"thumb_mcp": records, "thumb_ip": records} - ) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_19" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = { - "thumb_mcp": records, - "thumb_ip": records, - } - node.raw_path = tmp_path / "raw_thumb_position.jsonl" - - def axis_measurement(name, cycle): - return SimpleNamespace( - axis_common_xyz=(0.0, 0.0, 1.0), - axis_direction_source="upstream_constraint", - radial_rms_m=0.0002, - pose_axis_line_rms_m=( - 0.0014 if name == "thumb_ip" else 0.0002 - ), - plane_rms_m=0.0002, - rotation_circle_axis_difference_rad=0.0, - ) - - node._fit_axis_measurement = axis_measurement - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - assert not [ - failure - for failure in failures - if failure["joint"] == "thumb_ip" - and failure["metric"] == "axis_pose_line_rms_mm" - ] - diagnostics = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert len(diagnostics) == 3 - assert all( - row["kind"] == "position_invariant_quality_diagnostic" - and row["joint"] == "thumb_ip" - and row["metric"] == "axis_pose_line_rms_mm" - and row["actual"] == 1.4 - and row["reference_limit"] == 1.0 - and row["decision"] == "diagnostic_only" - for row in diagnostics - ) - - -def test_right_19_passive_dip_requires_axis_point_for_pip_zero_phase() -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _fit_check_node( - {"pinky_pip": records, "pinky_dip": records} - ) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_19" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = { - "pinky_pip": records, - "pinky_dip": records, - } - - def axis_measurement(name, cycle): - is_dip = name in RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS - return SimpleNamespace( - axis_common_xyz=(0.0, 0.0, 1.0), - axis_direction_source="upstream_constraint", - radial_rms_m=0.0002, - pose_axis_line_rms_m=0.002 if is_dip else 0.0002, - plane_rms_m=0.006 if is_dip else 0.0002, - rotation_circle_axis_difference_rad=( - math.radians(16.0) if is_dip else math.radians(0.1) - ), - ) - - node._fit_axis_measurement = axis_measurement - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "pinky_pip_side" - ) - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - pose_failures = [ - failure - for failure in failures - if failure["joint"] == "pinky_dip" - and failure["metric"] == "axis_pose_line_rms_mm" - ] - assert len(pose_failures) == 3 - assert not [ - failure - for failure in failures - if failure["joint"] == "pinky_dip" - and failure["metric"] - in {"axis_plane_rms_mm", "rotation_circle_axis_difference_deg"} - ] - - -def _ring_group_end_state() -> list[float]: - """Ring-group avoidance pose: pinky flexed, index/middle rolls parked.""" - state = [float(value) for value in THREE_CAMERA_BASELINE_COMMAND] - state[4] = 0.0 # pinky MCP pitch flexed - state[6] = 254.0 # index roll parked (feedback one count low) - state[7] = 253.0 # middle roll parked - state[19] = 0.0 # pinky PIP flexed - return state - - -def _cross_group_node() -> SimpleNamespace: - middle_roll = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "middle_roll_multiview" - ) - return SimpleNamespace( - profile=RIGHT_19_HAND_PROFILE, - baseline_command=list(THREE_CAMERA_BASELINE_COMMAND), - sweep_items=[SweepItem(middle_roll, 0, DIRECTION_DECREASING)], - sweep_index=0, - retry_sweep_items=[], - latest_state_u8=_ring_group_end_state(), - endpoint_tolerance_u8=2.0, - ) - - -def test_cross_group_return_keeps_next_group_clearance_motors() -> None: - node = _cross_group_node() - - target = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "next_sweep" - ) - - target_list = list(target) - assert target_list[4] == 0 - assert target_list[19] == 0 - assert target_list[6] == 255 - assert target_list[7] == 255 - # Motors the next group does not need still return to baseline. - assert target_list[8] == THREE_CAMERA_BASELINE_COMMAND[8] - assert target_list[3] == THREE_CAMERA_BASELINE_COMMAND[3] - assert target_list[18] == THREE_CAMERA_BASELINE_COMMAND[18] - - -def test_cross_group_transition_has_no_unfold_refold_redundancy() -> None: - node = _cross_group_node() - middle_roll = node.sweep_items[0].spec - - def moved_motors(waypoints, start): - moved: set[int] = set() - previous = list(start) - for waypoint in waypoints: - moved.update( - index - for index, (left, right) in enumerate(zip(previous, waypoint)) - if left != right - ) - previous = list(waypoint) - return moved - - for parallel in (True, False): - target = ( - G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "next_sweep" - ) - ) - returns = build_calibration_return_waypoints( - target, - current_command=node.latest_state_u8, - profile=RIGHT_19_HAND_PROFILE, - parallel=parallel, - ) - after_return = list(returns[-1]) if returns else node.latest_state_u8 - preparations = build_calibration_preparation_waypoints( - middle_roll, - 255, - current_command=after_return, - baseline=THREE_CAMERA_BASELINE_COMMAND, - profile=RIGHT_19_HAND_PROFILE, - parallel=parallel, - ) - return_moved = moved_motors(returns, node.latest_state_u8) - prep_moved = moved_motors(preparations, after_return) - assert not (return_moved & prep_moved), ( - f"parallel={parallel}: motors {sorted(return_moved & prep_moved)} " - "unfold to baseline and immediately re-flex" - ) - # The pinky stays flexed throughout the whole finger change. - assert 4 not in return_moved and 19 not in return_moved - assert 4 not in prep_moved and 19 not in prep_moved - - -def test_cross_group_return_falls_back_to_baseline_for_thumb() -> None: - thumb = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 0 - ) - node = _cross_group_node() - node.sweep_items = [SweepItem(thumb, 0, DIRECTION_DECREASING)] - - target = G20ThreeCameraCalibrationNode._return_command_for_transition( - node, "next_sweep" - ) - - assert list(target) == list(THREE_CAMERA_BASELINE_COMMAND) - - -def _task_validity_node(task_valid: int, task_total: int, window: float): - spec = next(item for item in SWEEP_SPECS if item.motor_index == 0) - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _fit_check_node({"thumb_cmc_pitch": records}) - node.minimum_detection_rate = 0.95 - node.views = { - "front": SimpleNamespace( - valid_rate=window, - task_valid_frames=task_valid, - task_total_frames=task_total, - ) - } - return node, spec - - -def test_task_validity_gate_ignores_post_task_window_pollution() -> None: - # Session 20260820_133904: the sweeps themselves were ~100% valid - # (28.9 Hz synchronised frames), but after the last sweep the - # required-role set flipped back to the full preflight set, the rolling - # window restarted on idle frames at 65%, and a healthy task failed. - node, spec = _task_validity_node( - task_valid=2990, task_total=3072, window=0.65 - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def test_task_validity_gate_fails_on_bad_task_counters() -> None: - node, spec = _task_validity_node( - task_valid=650, task_total=1000, window=0.99 - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - validity_failure = next( - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ) - assert validity_failure["actual"] == 65.0 - assert validity_failure["task_valid_frames"] == 650 - assert validity_failure["task_total_frames"] == 1000 - - -def test_right_19_task_validity_is_diagnostic_after_complete_sweeps() -> None: - node, _legacy_spec = _task_validity_node( - task_valid=650, task_total=1000, window=0.65 - ) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_19" - ) - node.records_by_joint["thumb_cmc_pitch"] = _image_cycle_records( - [math.radians(47.0)] * 3, - command_step=1, - ) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.motor_index == 0 - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def test_task_validity_gate_falls_back_to_window_without_counters() -> None: - node, spec = _task_validity_node( - task_valid=0, task_total=0, window=0.99 - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def _import_revalidation_node(records_by_joint: dict) -> SimpleNamespace: - node = _fit_check_node(records_by_joint) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", "g20_right_15" - ) - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = { - name: records for name, records in records_by_joint.items() - } - node.command_records_by_joint = { - name: [ - dict(record) - for record in records - if int(record.get("cycle", 0)) == 0 - ] - for name, records in records_by_joint.items() - } - node.palm_axis_records_by_source = { - observer.source_name: [] - for observer in RIGHT_19_HAND_PROFILE.palm_axis_observers - } - node.command_maximum_direction_gap_rad = math.radians(2.0) - node.sweep_items = [ - SweepItem(spec, 0, DIRECTION_DECREASING) - for spec in RIGHT_19_HAND_PROFILE.sweep_specs - ] - return node - - -def test_import_revalidation_drops_warning_band_task_and_prefix() -> None: - # thumb_ip hysteresis 2.18 deg provisionally passed in its source - # session (warning band) but fails the final hard gate; importing it - # anyway made session 20260820_133904 go back to the thumb after the - # four fingers were finished. The failing prefix task and everything - # behind it must be dropped at import time with records cleared. - bad = _image_cycle_records( - [math.radians(47.0), math.radians(47.2), math.radians(55.0)] - ) - good = _image_cycle_records([math.radians(47.0)] * 3) - node = _import_revalidation_node( - {"thumb_cmc_pitch": bad, "thumb_cmc_roll": good} - ) - - accepted, dropped = ( - G20ThreeCameraCalibrationNode._revalidate_imported_tasks( - node, - ["thumb_cmc_pitch_front", "thumb_cmc_roll_front"], - ) - ) - - assert accepted == [] - assert dropped[0]["task"] == "thumb_cmc_pitch_front" - assert dropped[0]["failures"][0]["metric"] == "cycle_travel_range_deg" - assert not node.records_by_joint["thumb_cmc_pitch"] - assert not node.records_by_joint["thumb_cmc_roll"] - - -def test_sparse_import_revalidation_keeps_clean_later_task() -> None: - bad = _image_cycle_records( - [math.radians(47.0), math.radians(47.2), math.radians(55.0)] - ) - good = _image_cycle_records([math.radians(47.0)] * 3) - node = _import_revalidation_node( - {"thumb_cmc_pitch": bad, "thumb_cmc_roll": good} - ) - - accepted, dropped = ( - G20ThreeCameraCalibrationNode._revalidate_imported_tasks( - node, - ["thumb_cmc_pitch_front", "thumb_cmc_roll_front"], - allow_sparse=True, - ) - ) - - assert accepted == ["thumb_cmc_roll_front"] - assert [item["task"] for item in dropped] == [ - "thumb_cmc_pitch_front" - ] - assert not node.records_by_joint["thumb_cmc_pitch"] - assert node.records_by_joint["thumb_cmc_roll"] - - -def test_sparse_resume_queue_skips_revalidated_later_tasks() -> None: - specs = RIGHT_19_HAND_PROFILE.sweep_specs[:3] - items = [ - SweepItem(spec, cycle, direction) - for spec in specs - for cycle in range(4) - for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING) - ] - node = SimpleNamespace( - sweep_items=items, - sweep_index=0, - resumed_task_keys=(specs[0].key, specs[2].key), - ) - - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(node) - assert node.sweep_items[node.sweep_index].spec == specs[1] - - node.sweep_index += 8 - G20ThreeCameraCalibrationNode._advance_past_resumed_sweeps(node) - assert node.sweep_index == len(items) - - -def test_provisional_gate_catches_final_command_direction_gap() -> None: - records = _image_cycle_records([math.radians(47.0)] * 4) - command_records = [ - dict(record) for record in records if int(record["cycle"]) == 0 - ] - for record in command_records: - if record["direction"] != DIRECTION_INCREASING: - continue - rotation = Rotation.from_quat(record["relative_quaternion_xyzw"]) - command = float(record["command_u8"]) - shift = math.radians(3.0) * (255.0 - command) / 255.0 - shifted = Rotation.from_rotvec([0.0, 0.0, shift]) * rotation - record["relative_quaternion_xyzw"] = shifted.as_quat().tolist() - record["child_pose_common"] = dict(record["child_pose_common"]) - record["child_pose_common"]["quaternion_xyzw"] = ( - shifted.as_quat().tolist() - ) - node = _fit_check_node({"thumb_cmc_yaw": records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", RIGHT_19_HAND_PROFILE.layout_id - ) - node.repetitions = 4 - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {"thumb_cmc_yaw": records} - node.command_records_by_joint = {"thumb_cmc_yaw": command_records} - node.command_maximum_direction_gap_rad = math.radians(2.0) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_yaw_top" - ) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - command_failure = next( - item - for item in failures - if item["metric"] == "feedback_direction_gap_deg" - ) - assert command_failure["actual"] == pytest.approx(3.0, abs=0.05) - assert command_failure["limit"] == 2.0 - - -def test_product_gate_separates_firmware_deadband_from_backlash() -> None: - records = _image_cycle_records([math.radians(47.0)] * 4) - command_records = _image_cycle_records([math.radians(120.0)]) - for record in command_records: - requested = int(record["command_u8"]) - if requested in {0, 255}: - feedback = requested - elif record["direction"] == DIRECTION_DECREASING: - feedback = min(254, requested + 3) - else: - feedback = max(1, requested - 3) - angle = math.radians(120.0) * (255.0 - feedback) / 255.0 - rotation = Rotation.from_rotvec([0.0, 0.0, angle]) - record["requested_command_u8"] = requested - record["feedback_u8"] = float(feedback) - record["relative_quaternion_xyzw"] = rotation.as_quat().tolist() - record["child_pose_common"] = dict(record["child_pose_common"]) - record["child_pose_common"]["quaternion_xyzw"] = ( - rotation.as_quat().tolist() - ) - node = _fit_check_node({"thumb_cmc_yaw": records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", RIGHT_19_HAND_PROFILE.layout_id - ) - node.repetitions = 4 - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {"thumb_cmc_yaw": records} - node.command_records_by_joint = { - "thumb_cmc_yaw": command_records - } - node.command_maximum_direction_gap_rad = math.radians(2.0) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_yaw_top" - ) - - requested_fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, "thumb_cmc_yaw", command_records - ) - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - - assert math.degrees(requested_fit.maximum_hysteresis_rad) > 2.0 - assert not [ - item - for item in failures - if item["metric"] == "feedback_direction_gap_deg" - ] - - -def test_product_gate_uses_settled_direction_gap_not_dynamic_sweep_lag() -> None: - records = _image_cycle_records([math.radians(70.0)] * 4) - for record in records: - if record["direction"] != DIRECTION_INCREASING: - continue - command = float(record["command_u8"]) - lag = math.radians(3.0) * (255.0 - command) / 255.0 - rotation = Rotation.from_quat(record["relative_quaternion_xyzw"]) - shifted = Rotation.from_rotvec([0.0, 0.0, lag]) * rotation - record["relative_quaternion_xyzw"] = shifted.as_quat().tolist() - record["child_pose_common"] = dict(record["child_pose_common"]) - record["child_pose_common"]["quaternion_xyzw"] = ( - shifted.as_quat().tolist() - ) - settled = _image_cycle_records([math.radians(70.0)])[0:] - node = _fit_check_node({"thumb_mcp": records}) - node.profile = RIGHT_19_HAND_PROFILE - node.zero_profile = get_zero_calibration_profile( - "right", RIGHT_19_HAND_PROFILE.layout_id - ) - node.repetitions = 4 - node.baseline_command = list(THREE_CAMERA_BASELINE_COMMAND) - node.baseline_records_by_joint = {"thumb_mcp": records} - node.command_records_by_joint = {"thumb_mcp": settled} - node.command_maximum_direction_gap_rad = math.radians(2.0) - node.maximum_hysteresis_rad = math.radians(2.0) - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_mcp_ip_front" - ) - - dynamic_fit = G20ThreeCameraCalibrationNode._fit_joint_records( - node, "thumb_mcp", records - ) - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, replace(spec, joints=("thumb_mcp",)), - include_view_validity=False, - ) - - assert math.degrees(dynamic_fit.maximum_hysteresis_rad) > 2.5 - assert not [ - item - for item in failures - if item["metric"] - in {"hysteresis_deg", "command_direction_gap_deg"} - ] - - -def test_import_revalidation_accepts_clean_prefix() -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _import_revalidation_node( - {"thumb_cmc_pitch": records, "thumb_cmc_roll": records} - ) - - accepted, dropped = ( - G20ThreeCameraCalibrationNode._revalidate_imported_tasks( - node, - ["thumb_cmc_pitch_front", "thumb_cmc_roll_front"], - ) - ) - - assert accepted == ["thumb_cmc_pitch_front", "thumb_cmc_roll_front"] - assert dropped == [] - assert len(node.records_by_joint["thumb_cmc_pitch"]) == len(records) - - -def test_provisional_fit_can_skip_view_validity_for_imports() -> None: - records = _image_cycle_records([math.radians(47.0)] * 3) - node = _import_revalidation_node({"thumb_cmc_pitch": records}) - node.minimum_detection_rate = 0.95 - node.views = { - "front": SimpleNamespace( - valid_rate=0.0, task_valid_frames=0, task_total_frames=0 - ) - } - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_pitch_front" - ) - - without_views = ( - G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=False - ) - ) - with_views = ( - G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec, include_view_validity=True - ) - ) - - assert not [ - failure - for failure in without_views - if failure["metric"] == "tag_valid_rate_percent" - ] - assert [ - failure - for failure in with_views - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def _warning_band_node(tmp_path, attempts: dict) -> SimpleNamespace: - calls: list[str] = [] - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_pitch_front" - ) - node = SimpleNamespace( - cross_view_roll_diagnostic_finger="", - raw_path=tmp_path / "raw.jsonl", - provisional_warning_ratio=1.25, - sweep_attempts=attempts, - retry_resume_index=None, - retry_sweep_spec=None, - retry_cycles=set(), - repetitions=4, - fit_failure={}, - sweep_index=3, - automatic_fit_retry_limit=2, - paused_reason="", - reason="", - _prepare_failed_sweep_retry=lambda: calls.append("prepare_retry"), - _begin_return_baseline=lambda after: calls.append(f"return_{after}"), - _pause=lambda reason: calls.append(f"pause_{reason}"), - _calls=calls, - _spec=spec, - ) - return node - - -def test_warning_band_failure_stops_in_place(tmp_path) -> None: - node = _warning_band_node(tmp_path, attempts={}) - failures = [ - { - "joint": "thumb_cmc_pitch", - "metric": "axis_cycle_difference_deg", - "actual": 0.779, - "limit": 0.75, - "comparison": "maximum", - } - ] - - paused = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, node._spec, failures, allow_warning=True - ) - - assert paused is True - assert node._calls == ["pause_joint_fit_check_failed"] - rows = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - kinds = {row["kind"] for row in rows} - assert "provisional_fit_warning_retry_exhausted" in kinds - assert "provisional_fit_warning" not in kinds - assert "fit_failure" in kinds - - -def test_second_warning_band_result_also_stops_without_motion(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_pitch_front" - ) - node = _warning_band_node( - tmp_path, attempts={spec.key: 2} - ) - failures = [ - { - "joint": "thumb_cmc_pitch", - "metric": "axis_cycle_difference_deg", - "actual": 0.779, - "limit": 0.75, - "comparison": "maximum", - } - ] - - paused = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, node._spec, failures, allow_warning=True - ) - - assert paused is True - assert node._calls == ["pause_joint_fit_check_failed"] - rows = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert [row["kind"] for row in rows] == [ - "provisional_fit_warning_retry_exhausted", - "fit_failure", - ] - assert rows[0]["attempt"] == 2 - assert rows[0]["attempt_limit"] == 1 - - -def test_third_warning_band_result_stops_at_current_joint(tmp_path) -> None: - spec = next( - item - for item in RIGHT_19_HAND_PROFILE.sweep_specs - if item.key == "thumb_cmc_pitch_front" - ) - node = _warning_band_node(tmp_path, attempts={spec.key: 3}) - failures = [ - { - "joint": "thumb_cmc_pitch", - "metric": "axis_cycle_difference_deg", - "actual": 0.751, - "limit": 0.75, - "comparison": "maximum", - } - ] - - paused = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, node._spec, failures, allow_warning=True - ) - - assert paused is True - assert node._calls == ["pause_joint_fit_check_failed"] - rows = [ - json.loads(line) - for line in node.raw_path.read_text().splitlines() - ] - assert [row["kind"] for row in rows] == [ - "provisional_fit_warning_retry_exhausted", - "fit_failure", - ] - assert rows[0]["attempt"] == 3 - assert rows[0]["attempt_limit"] == 1 - - -def test_repeated_branch_clusters_stop_before_third_full_rescan(tmp_path) -> None: - node = _warning_band_node(tmp_path, attempts={}) - - def failures(travels, orthogonal): - return [ - { - "joint": "thumb_mcp", - "metric": "rotation_orthogonal_rms_deg", - "actual": orthogonal, - "limit": 2.5, - "comparison": "maximum", - }, - { - "joint": "thumb_ip", - "metric": "cycle_travel_range_deg", - "actual": max(travels) - min(travels), - "limit": 10.0, - "comparison": "maximum", - "cycle_travel_deg": travels, - }, - ] - - paused = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, - node._spec, - failures([74.52, 88.36, 74.58, 74.51], 4.04), - allow_warning=True, - ) - assert paused is True - assert node._calls == ["pause_joint_fit_check_failed"] - - node._calls.clear() - node.sweep_attempts[node._spec.key] = 2 - paused = G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure( - node, - node._spec, - failures([74.43, 88.26, 74.54, 74.45], 3.92), - allow_warning=True, - ) - - assert paused is True - assert node._calls == ["pause_joint_fit_repeated_branch_failure"] - assert node.fit_failure["recoverable_by_rescan"] is False - assert node.fit_failure["directions_to_rescan"] == 0 - assert node.fit_failure["repeated_branch_clusters"] is True - - -def test_imported_task_without_capture_skips_view_rate_gate() -> None: - # A fully resumed session (16/16 imported, no live sweeps) has no - # task-scoped view counters; the rolling window then holds idle - # preflight frames judged against the full role set and would fail - # every task's validity gate at the final fit. - node, spec = _task_validity_node( - task_valid=0, task_total=0, window=0.65 - ) - node.resumed_task_keys = (spec.key,) - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert not [ - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def test_live_task_still_uses_window_when_not_resumed() -> None: - node, spec = _task_validity_node( - task_valid=0, task_total=0, window=0.65 - ) - node.resumed_task_keys = () - - failures = G20ThreeCameraCalibrationNode._provisional_fit_failures( - node, spec - ) - - assert [ - failure - for failure in failures - if failure["metric"] == "tag_valid_rate_percent" - ] - - -def test_cross_view_skip_keeps_front_depth_free_geometry(tmp_path) -> None: - # The side PIP-link pose validates the observation, but its planar-pose - # depth and orientation bias must not replace the trusted front geometry. - node = _cross_view_roll_node( - tmp_path, - side_tilt_deg=11.0, - side_offset_m=[0.021, 0.0, 0.0], - ) - - measurement = node._fit_axis_measurement("pinky_mcp_roll", 0) - - assert measurement.axis_direction_source != "cross_view_weighted_fusion" - assert measurement.axis_point_source == ( - "front_interpretation_plane_cross_view_validated" - ) - assert measurement.pose_axis_line_source_joints == ("pinky_mcp_roll",) - assert axis_line_uses_depth_free_interpretation_plane(measurement) - front_direction = np.asarray( - measurement.axis_common_xyz, dtype=float - ) - assert float(np.dot(front_direction, [0.0, 0.0, 1.0])) > math.cos( - math.radians(2.0) - ) - assert measurement.axis_point_camera_center_common_xyz_m is not None - assert ( - measurement.axis_point_interpretation_plane_normal_common_xyz - is not None - ) diff --git a/src/linkerhand_calibration/test/test_trajectory_policy.py b/src/linkerhand_calibration/test/test_trajectory_policy.py new file mode 100644 index 0000000..9d67457 --- /dev/null +++ b/src/linkerhand_calibration/test/test_trajectory_policy.py @@ -0,0 +1,39 @@ +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_unified_artifact_pair.py b/src/linkerhand_calibration/test/test_unified_artifact_pair.py new file mode 100644 index 0000000..73211d4 --- /dev/null +++ b/src/linkerhand_calibration/test/test_unified_artifact_pair.py @@ -0,0 +1,81 @@ +"""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 index cdc7e4b..58e22f6 100644 --- a/src/linkerhand_calibration/test/test_unified_engine.py +++ b/src/linkerhand_calibration/test/test_unified_engine.py @@ -2,12 +2,11 @@ from __future__ import annotations import math from pathlib import Path -from types import SimpleNamespace import numpy as np import pytest -from linkerhand_calibration.models import get_default_registry +from linkerhand_calibration.compat.legacy_diagnostic_tools.models import get_default_registry from linkerhand_calibration.core import ( ArtifactPolicy, CalibrationProfile, @@ -35,8 +34,8 @@ from linkerhand_calibration.runtime import ( ) from linkerhand_calibration.runtime.adapters import ProfileSdkAdapter from linkerhand_calibration.core.geometry.rotation import fit_rotation_axis -from linkerhand_calibration.models.g20.profile import JointCurveFit -from linkerhand_calibration.models.l6.fitting import fit_coupling_model +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: @@ -144,29 +143,6 @@ def test_rotation_axis_uses_observed_endpoints_for_physical_feedback() -> None: ) -def test_repeatable_hysteresis_is_diagnostic_when_holdout_passes() -> None: - profile = next( - item.profile for item in get_default_registry() - if item.profile.key.layout == "o12_right_16" - ) - curve = SimpleNamespace( - maximum_hysteresis_rad=math.radians(4.2), - maximum_monotonic_correction_rad=math.radians(0.2), - ) - fit = SimpleNamespace( - curves={"joint": curve}, - zero_offsets_rad={"joint": 0.0}, - travels_rad={"joint": 1.0}, - mimic_fits={}, - holdout_errors_rad={"joint": (0.0, math.radians(0.5))}, - ) - - result = CalibrationEngine(profile).result_from_fit(fit) - - assert result.quality["passed"] - assert result.quality["fit_diagnostics_by_joint"]["joint"][ - "maximum_hysteresis_rad" - ] == pytest.approx(math.radians(4.2)) def test_directional_lookup_does_not_force_passive_curve_into_polynomial() -> None: @@ -232,10 +208,10 @@ def test_old_checkpoint_policy_is_intentionally_incompatible() -> None: def test_removed_soft_stop_gates_are_not_in_live_nodes() -> None: - package = Path(__file__).resolve().parents[1] / "linkerhand_calibration/models" + package = Path(__file__).resolve().parents[1] / "linkerhand_calibration/runtime" live = "\n".join( (package / relative).read_text(encoding="utf-8") - for relative in ("l6/node.py", "o12/node.py") + for relative in ("ros/calibration_node.py", "ros/io.py", "coordinator.py", "safety.py") ) for removed in ( "non_target_motor_moved", diff --git a/src/linkerhand_calibration/test/test_unified_launch.py b/src/linkerhand_calibration/test/test_unified_launch.py new file mode 100644 index 0000000..176040a --- /dev/null +++ b/src/linkerhand_calibration/test/test_unified_launch.py @@ -0,0 +1,78 @@ +"""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 +from launch_ros.actions import Node +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, component container, SDK, calibration owner; none is executed. + assert sum(isinstance(action, Node) for action in actions) == 6 + 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) diff --git a/src/linkerhand_calibration/test/test_unified_ros_host.py b/src/linkerhand_calibration/test/test_unified_ros_host.py new file mode 100644 index 0000000..bf5aa75 --- /dev/null +++ b/src/linkerhand_calibration/test/test_unified_ros_host.py @@ -0,0 +1,83 @@ +"""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", ["l6", "o6", "g20", "o12"]) +def test_ros_host_constructs_and_start_discards_preview(tmp_path, monkeypatch, model): + 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()}} + 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 + 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() + node._state_callback(message) + host.cameras.matrices = {view: np.eye(3) for view in profile.vision.view_names} + import time + host.cameras.info_received_at = {view: time.monotonic() for view in profile.vision.view_names} + 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 profile.vision.view_names} + 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() + node._state_callback(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 new file mode 100644 index 0000000..471bb77 --- /dev/null +++ b/src/linkerhand_calibration/test/test_unified_runner.py @@ -0,0 +1,362 @@ +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.product import load_product_config +from linkerhand_calibration.runtime import runner +from linkerhand_calibration.runtime.engine import ACQUISITION_POLICY_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_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, + "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("model", ["g20", "l6", "o6", "o12"]) +@pytest.mark.parametrize("terminal", ["COMPLETE", "PAUSED"]) +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 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] + def init(): + 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 launched: + harness.spin() + 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 == (0 if terminal == "COMPLETE" else 3) + assert harness.starts == 1 + assert ("artifact_validation" in calls) == (terminal == "COMPLETE") + assert calls[-3:] == ["stop_owned_stack", "destroy_monitor", "shutdown"] + 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_validation.py b/src/linkerhand_calibration/test/test_urdf_validation.py new file mode 100644 index 0000000..5c88b08 --- /dev/null +++ b/src/linkerhand_calibration/test/test_urdf_validation.py @@ -0,0 +1,53 @@ +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 index 8830c16..1731717 100644 --- a/src/linkerhand_calibration/test/test_urdf_zero.py +++ b/src/linkerhand_calibration/test/test_urdf_zero.py @@ -40,11 +40,10 @@ from linkerhand_calibration.full_hand import ( JOINT_SPECS, MEASURED_JOINTS, PASSIVE_JOINTS, - RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS, JointCurveFit, get_hand_calibration_profile, ) -from linkerhand_calibration.models.g20.urdf_input import ( +from linkerhand_calibration.compat.legacy_diagnostic_tools.models.g20.urdf_input import ( build_g20_urdf_input_payload, load_g20_urdf_input, ) @@ -657,10 +656,9 @@ def test_urdf_writer_changes_only_the_16_active_zero_origins( 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) - # A zero calibration must not silently expand mechanical/CAD safety - # limits. Dynamic measured ranges remain in the calibration JSON. + # Numeric limits move with their coordinate zero; physical limits do not. for name in (*ACTIVE_JOINTS, *PASSIVE_JOINTS): - assert _joint_limit(destination, name) == pytest.approx( + assert np.asarray(_joint_limit(destination, name)) + offsets.get(name, 0.0) == pytest.approx( _joint_limit(SOURCE_URDF, name) ) @@ -1833,47 +1831,17 @@ def test_right_19_palm_pose_and_16_zero_observation_jacobian_is_full_rank() -> N assert np.linalg.matrix_rank(jacobian, tol=1.0e-7) == parameter_count -def test_right_19_urdf_writer_changes_only_the_16_static_targets( - tmp_path: Path, -) -> None: +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} - destination = write_zero_corrected_urdf( - source_urdf=RIGHT_SOURCE_URDF, - output_directory=tmp_path, - serial_number="G20_RIGHT_019", - offsets_rad=offsets, - timestamp="20260818_120000", - ) - - original = { - str(joint.get("name")): joint - for joint in ET.parse(RIGHT_SOURCE_URDF).getroot().findall("joint") - } - corrected = { - str(joint.get("name")): joint - for joint in ET.parse(destination).getroot().findall("joint") - } - for mesh in ET.parse(destination).getroot().findall(".//mesh"): - relative = Path(mesh.get("filename")) - copied = destination.parent / relative - source = RIGHT_SOURCE_URDF.parent / relative - assert copied.is_file() - assert copied.stat().st_size == source.stat().st_size - changed = set() - for name in original: - original_origin = original[name].find("origin") - corrected_origin = corrected[name].find("origin") - if original_origin is None or corrected_origin is None: - continue - if original_origin.get("rpy") != corrected_origin.get("rpy"): - changed.add(name) - assert original_origin.get("xyz") == corrected_origin.get("xyz") - assert changed == set(zero.direct_zero_joints) - assert "thumb_mcp" in changed - assert not set(get_hand_calibration_profile( - "right", "g20_right_19" - ).passive_joints) & changed + 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: diff --git a/src/linkerhand_calibration/test/test_virtual_control_contract.py b/src/linkerhand_calibration/test/test_virtual_control_contract.py new file mode 100644 index 0000000..f964df3 --- /dev/null +++ b/src/linkerhand_calibration/test/test_virtual_control_contract.py @@ -0,0 +1,84 @@ +"""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/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_141642.urdf b/src/linkerhand_calibration/urdf/o12_left/linkerhand_o12_t3_left_urdf-0703.urdf similarity index 60% rename from src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_141642.urdf rename to src/linkerhand_calibration/urdf/o12_left/linkerhand_o12_t3_left_urdf-0703.urdf index a668f76..bca7a82 100644 --- a/src/linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703_calibrated_O12_RIGHT_001_20260908_141642.urdf +++ b/src/linkerhand_calibration/urdf/o12_left/linkerhand_o12_t3_left_urdf-0703.urdf @@ -1,1234 +1,1231 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/linkerhand_calibration/urdf/o12_left/meshes/base_link.STL b/src/linkerhand_calibration/urdf/o12_left/meshes/base_link.STL new file mode 100644 index 0000000000000000000000000000000000000000..e00e9e0d3cf82d492e509d0d76f10c40cafd74e9 GIT binary patch literal 2637584 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

F)Zl8+JsyKt$?WEBQy$;NN4eA(`wk}HS_=}g(a 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Ʈ^Y%F<=H|B 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

~sZmv@IKTGMh-y0tx=D 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%0Do7MWETJpAe^=Ux`=rra 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 z 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�-uRk+amxH2 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

{4ChebG*8Lq-^}*PO*ZC z0k*kNF?Ci-jr(dZ<6?97q|^}_I((HIM_|{q3%T%Zlt=tk)<oMAf|kMzCE(Qojo6$s-6$~5}SmZBabP1 zLjpJgyYQW5a}#q7`j|`Sm7)Ru+ sZ$oru45oAEmw1K3_UJL6$uxW0fBri!7hCN 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

jHgP?x4e+Ie_w>J2{GvV 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

QY4pKeFh#76klCzB4R(@Pl& zdDU--e9QkVtq`h^$kFrVn-k8_1wS-$;^0!51nX(^lX>1fU`^aSG6V&QlS37<%6gML zC-^)kLU^5+KW&6_o%}?BK$mwrjdV*qA-_DMm)a4}P3+Cqa>ggS6p9KGcavpucl&uc ze288rHvhp_cenUyPHA^eAkg)$m`0{dI44))wGv-h!-z_D7n=O#Z%eKkUqrRM@eLYpOxm_7^CkOrM`X1o-F?57+ zMBp_+K_aWNne-YOXx_9uo)b~KOzdo%y++SEHv|G*4!qmk#Oi{%`CPpv)Bgw`PqgB3 zdw*Y{s30-qc05_qY`=Lzd;QDV?Kod&nO>H}+`KFh=vsD4Ca-UmmluxG^Hpa~xTKUN zt8QJUs31|#H=g9L%rXDJV*n?kO{`^nL(;n-OCZp7#6u?aUF*w5n#b}qhHmGh&cm2< zt4aV#N-I?0ExMcG_s)6n)tjmdWp*3Y+|JY zU&O!gxGfOqIy57J^eUPV-TA-?PJG^AVv&t^#qS$*hoXXnK;YqS)N9ck~hWwxqnjlCtjEM zo7l-;1D%iZwM%$aNc6QyAh&KFil002Bu``d9G-@p?VQ^Bi9nz$&;0_~S44?UU$4*O z447|X=jhY8fwdn~RFLrIt9N7;7yr4Ap2m1iZ0b|n=vMAA?KM7uEUr+>ygKCs9h#Ly zcDI;sZam=#9Xd9F1ZRATUznypUD?INnir_fc~hPU3KEz$UjxaB*|n3LE6)-MbS>cL z#96k=iBEW^rIDuSi)k)qPa8jtj zc?2`wTH4j97_sSfMO1)j7M4J4Bd8ocSASOZc+KGGS&#Hnt_lRY-mX!}pAV&Et4zHv zSk`EuQr?-^+MB6zlOxeWD;x=Io$(e~o{4QRy36OrDFT76@e32kuivq9R1rOw*k9r` zoTqAb>Uoi(g2b{ZiDZ9~&2mLY{e86EYhrc#H{PL@|8|zzTeWGs33t6R}BWj za|s=pX*_vZB+xaZUJ|h^HC*2ISC79tk+1B3dvv?g?<2n{&W5)P$RgiD)#$(@!NSZq z&%2f;W@Y8$TxR?)K|x|i=S-4i`7=JtNneX&$qC7~LR^}!NT90~y+r(mQ1h;in>pcH zo}W`atCzF%_$NgLiN?LMNx;n@^WSD$IFUBj#P;qyW8A;#F?BC-fw+uO%tLP+q;4~l z$%Lvu&EYc-Q;gBd+oHU_df2gp(P{i0iq8k5#fs1B$9jHN{OA70QGs_TDoBWFRCMR# z42D0(pm%u!fv(f}N#x0^>hf_#k0TtzYlhw3GmJr(A5v71z1uJ~ z*CuZT0$q3}GtY_6CiblPTjy3A-&0hO7?XODwBC3#KIELfJ2SY4iB+B5(m0A12n4$D zq-fq2<;1FoEsZH{3xxBik(latkw_sG%>%~$i)Pi&#J(B(8L!2?76^2Sv2ovxGO@

MN6*Ka*v`+}jc11EQ&f<^_s{1bolI=T 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

ly|gox4#YP=)b|c)zx( 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

36WtdD=0_~TYgN1%CH1lv&K1Lm_lzUYA2d__ zXB>WdF^sy2ue@822za#^`1a?XzH z#TH_p4CDLtV_-Ca^3xBGYT?g=Q7GXD|< z&!p2mDh`2KQufx8@XBUxq1URr#|tDTphU|2Th!pM=;9HmC1uaX1EnjukL+pfHd~%A z0VPuA-@f9n_UL;oaR}6svS*`rk^jQE=RD@N`FCeslt`I>Gl;(>q|B;u2-K3YXQTMo zo#Feh4{;wp-n=eKq|Cp4#NR|3Mmz$wr0m()zOP5P+xanWp)T`kqeRO58&3RfCq1#m zAy7-oo{cl7U(PCj??l(kxHT0eQs&>H26s#G2-K3YXG6bfajtINOs`?S(C_+bv)mKpsL%*AFOI*y!r$spGtO(T75pVrnGCD27?Lj4Dv9YN-E#ecXrQc0B{%H}8(7WURQKv=tO@opkq2D1y5G_ln(;_~BTG9{yw1{fe z;r|SIW8&!3BK|jd#a`e|ocs5$>a>XJ2NS3z*CX&I&P~hzm%RB=U$sI)#?3#^pn6{R zLv>n2T@S7cwZs>^w`BU=gxhY?d_Kvcyo4o4$n_7riE|H?_%1-8miV-1MiniBv4Dg zchPYzC_zHT%|9)|@15NzkEzol{N7ouWT+*+;Jt~{Z!O&~ORLi& z{<@2VTz~IPocL9psx4k~u>3Mk;Wed$Ysg~9Mk#X*T|E4)8 zL4xx&QtnShXXka6+@I19Y`f0zsQVF_UsMRxlCqa^QSy;-CTNsMnR}}BOZZTQub`nSsXtTG)kl_Hssq?yfDNkM2l%Idp4eWrjNaD z_l59_mFKE#p&}72wt?S#GK>L~gYIR@rT5+Oj+vsxb)goI0_9@WHZFB-WDiMe;5M3C zB?%=+JoEhQq?4=0JL~VCPkyx^XA#?2(b?@b`FRHk)Z(j|?N@nDmbSn9;XZfydquNQ zg2Y1)RZg1MVUqJ|%@@eV+AlX+lP?c*7kyq#A(@~lSFw^Hp|cR{>%sZqFcY*8sHJlw zYa5i!Dv$}9@+>P!EW+yto+HD$q|91errhD$MkFDUScGRo_ruQwjRcovtg^o{ru*S% zf)jR@S?%~<;_m=d&VH1{ zB0L*-J>*M0{B0L5%XePn@6j3MP!dTn?Gb!Jma{;!tq~1Qs(Sg0GhCLh(geRmg9J(< z38CrPfaZAt%BI_}Ud!!C^jr4hl(nkN(aM>e3#$ob$tthQ*%`Cy5K)|nUsoU6jv2G` zFX2_rE3C;dnPZ1CNnOUDg&e$r_Bc9bB2c^lLmj7vzK zDz1sko{hJuRhKE7?wbo%C>tWddbv;8IUsaDth__l1qjsQ5vfL5BRm^&H!$jYJr@ZQ zd~GPVh|U3_uZO)jeUKU}AyA92`86Uu8*=~nt7JEI1x12HFl$6J@~dK$ll+yM?*|Cf zI^3iF#+8(fq?WqoJ7(0fa*!t`knf@=qVLwFtP#h^rz@vuSFVpqGy9fm+v_48?}=I1ZvSpb0!R(OHcBPmZ({C2TG7w zMERE9p-gO^4f1xIz5U+8Wda0hk;nMC8@;>1_B;ivJa_u$9VkJ9%caN@JsZ60#3MwD zYhn-OoREg0`(gDfR7Tkl3D(Pfs!pEh*&uJX+1pb`d#w@zwRl9bvsvluVb2>@T=iTe zNbt3}PM#RJ9`^9uUvI~{P>ZiDdpqaIp?D(sRetisht7Yo9VJKvJyG^0?7K*y7W#4Y zx=ZuJw)!hpR#jo$+~;fr$^;(pa|5`cOq>=*4@IZF3KGz zir{OLoli#A-OnG~7a&ke-)prEeRsB#UO%L+Al=WE1PRPvV;GU^t`MlzY;}X$jVUiq z^ty|lh`w9bn}en|2W3Z==eI8czKdGaLY{+WipsGi>+XVlU(1YA5+wAT*EVF`tvI5% zT6d)`)S_#{Yl+SkChP9}XK$9ZL`jg)YnQg6*Q;mx{X|SMS}HmpXeT?ZRq=lRjWo#xf`guP>aV(vNcg;!n+UJH`hv* zJC4eMiv(XAy4MD;hwV7G1_;#RYo49cN!H!g&2N)Cr^@z(M9>rU{oG!m*IlU#wa||Z z<3IXV<2U7M)}CD|FrZ^A$=YNkyJxAdal40DjW`BAC%DH@<2$XPHq6SzK~g> z1POjZ%8nW|uDvz={F!1=1ZwfKk3?02nOj&QQBHg+!pLoqKnW7~+!mieEgX>`fo*3S z9Ho$_yZHSONhBdLhoB7{6E2GuM)ZONN|3-vNPGgdFwPhxP=W+Tbm9}Jh4IoLff6Jz zG8LadEv|{a2Boe|G$9fsxGXV;8xx|%BO(#+Ab}FBm;1!A4DX2^^SvU0T6|^c`%y6n zlpw*^hGQAgHjqFqoN2*+pd^xzSZ{oSY0<*@NWUJ)-O#>BU%b6W|4L+nV;LAVz$isD zArTKGxXgd|zLC4P{iUq4=?(0pj0P?~DdG-!^HVi9-j#o&vvFAqdZN$O?ow9ij0W~C zM4;riuXALi@7n03k`~rv7)QqM5BKX`#van>u7C|BwpN&*@p8V+il*#hniw8IpjPnN zdCQU8-4%6Gt)WDq1c{t$!Wr!z+^%hGAYwHUBO?gZ3dR5??rrS8{cN?mYy%}oaCy!{ zncBvU2+`8}UB`Pq-$<=mS|>F;O5314gOXsq+$a0h-P*<`BGwTxEP_BSz2DU~KBdv^ z-MfrCLib#vHAwKasc`N+ZQ~FTj}yUH5bHuMz2DU~a<{#dRf#;Lxb`cFUm-EC>D?Pw zFWaMS_ zpC7s1W=|YM1WJ(5`%-OVD-q-+%G;4Zt*dW_li1rm8{_siwzrY*@{B?W5_(^%ZQO{E zhzHlie(c$Jn_Bfcc_MoWO0ZtNFV!}75b+uj+;b#Qi~Gmk?%DW;M)wKw#3%Jwi3AC~ zFV!}d6VaE5i4g>9@s%x4-tO7>mHf&e52>d8N+d`GJ#l>7OQ8$oyUij9)IvWtjL3CY zp6+mm+-q@C?KhY0F}-y+C#<_D(KE`FbysSNUUx-;gr4(?j$U_#KrKE0O>e#0Osmfu zbZA7lS68%6e9ubM77)Io}D-tC5+R$B8+lX9ug+MKRuhspCTzCEW z71TRy?;W)p#h)}g%Je9m;~$YIdA5eJaf=1(T{`cE=u%_GI{01nzGm3v2;C9 zf`p#)iZ+Z4Sa*>?Exneg)<&K+CT{sIBxL?)M7e& zPmNa)B}i~Ndxu;K{0co0y^sG+dkO9BXafn<3eG_!bW7BILbpVf(O%H6P=W-m+LW0B z{0a%w!WITcS6^GzLtO3R6DUCfSEZnhNJ7@Z_+y3p1bsEtSmC`rXaglk;JC#nP>bp8 zJp*1rlpw+7>>a?f_3wA94=HHZ2@kXuA1R-4pl!a;-8GMd7O(28%GS!yL*-g6$Z+4x z6I#>v^N?X|`Sn4!PmSF6(|{-y=j6>NPFv?I$PNWA9vIzF(nK zX60qMLeJg%d1yo1*V4;Txq0fTP|17pXD(ZxE3}mUPVb{0eI&g2-7)q%fAtMWkQjL7 zSo-L4xkKywe?>%#0k4PuOCN53JoViGfm&}Z+mrFc$lRgf%EsLv6?H4sc)>X<_Q(AeVlB(ce$LK{&Pz^XGUUxKrI{v!+2;zTerdQ#qFm5_OeidM8kzQ zXCxHL7dn~7V>R-VPVT%XuUZ#s%?J>v6&&4}J6c&|7Y?;AzV?zeub`J_S~dIcWP4=qI`(iU6d+KmbMv0*Jx}KfmD}+n5xZ(Pu=BKTWKSCKcos^Kz^iE( zdHXlCpLsdaPWfu8i4r85-nD*n&u4Rn9+~+q+t`y}kExJk&pbIZ841+Ft4W#b2jsVl zzT4HVHey)_B}jbVV*Tbq4ReK#4?jsZ`gXXxzYz3--@0RpvxqkFyOcUF%(`q+o3 zRdqoAknhR`px#?+@W%B9HUlsy5n=J zN4`hxeJx)J5U7Q|U>N7;er!FHzlYs%;Hv>ILE?+nb?TMdnJ4sZ_oHOv`qzW3WuN!3 z+wMFSAW&=liy!TJ;@7;PpIWf*4rsr}YW?>>`|fI=X07k=)~>OMxkDq${}g)k%~u+) z&6_*a>z1EF=nFKXrWdv+7ksrb*fzFqk4nH;`>J0>)y^~T`4mwj1nX^ zwHudt-7TQk-KYN?Wxsk~#Zb-X=L87U>Rf(k#)&ryhCUd;t;)ZAq}{vQrL2L)W`$9L z#PB|2GKVG>3N27QclHmr$As<)Px|Zm0D)SgDh|jgu69<;l4%;#QuW?&d4 zNEFY}JG0z{B5D@US4q~kwwL~1(mi*zcYr{x#Utqb*EdB%9b2=F7p7me9=fTA`*^Rb zSzp}KGkwOgqM=)8MdmqZk149=V9odUrFWfpQ|Mw7CSL!whMlKFQ@8B;W?`%gZ3KxP z<8HDu|9HR+zfmEK5+uGlYh@PgQ#3S0U615VXD#=|-tLInc>@G$VQUTJ9=DB^{Q7h5 z{QiBjHdQQ@nR&XX@}HWtXE;RVwzOwJ+AwOpIy&_DjEQcZgjX|Bg2c~3DiQbG>ktgwQstp&%^3%IIdB++6Ax2pugv& zw|;wI(%;&KTqz`Q4K|ErSvT$aX?OZA>4y-gh1bR~*3T`T)ccKBn)1m3lpuk3JHsgW zRk@}kF2AP63JKJTxaNCr%NX`x%?$1bjtQ!V@xdi?b! z;_X-$Y6VC4_NzaqH>utt>1xF2B7y5X?MoK?(zI>IR;nLZ7i!^^HH>FoDPa$K`oZwi zliIqt3&&@inq7ZP=Vzb231864@bSlE?bIoo>hxOOB#iqVF7w>x9a3=b(=*pN`^v%= zNtwA4!zc+5L*CuR^rm*aTk7@kK>P54-s$gMKM)|+gI4Fx71BF4$QSCFTRlfD?P}kA z>$%MOxeK}|L4y0iHoSf`|F(}k@{QHb-IvaXkwC34R<%g~re(fRyKlJXmD}{N-|92O zDZJ5eQG!J0cL%7ks<~T@RqjN~{Oo+)uDQkqe3!jap0@sZF-5J3MV{lQyPrA~w06$T z<#c*;yo(Yf@H!dB!#NAue^0;He0WLM0D)TALfVUVzi$08<#65B-}i7)5=mrV^Q4Py z>>Y&{*WJCWLV#dew1Pb^U86unn&UQmJW7!GdFx3<_rAnqg#=2FK(CBXpcX#28%Bkv zTUk%MG1R`j^Gg;!DdO{B@Y&$pm1CiTKaQ~%U21OOHAfn~$1o}lZ)klvexO~w!!8RY zNCa)Hyt}jYU$4jQ{?+yb2-L!PLwDzA_nFQ5PPA9GpBf6Z3cL^P(cb5BozVTqCfE}` z*c3tuUK<>B!#F#vb7<2133iQ2PJlqI;OO%8KJlUR=E|?zG{Ti3@;ORY3GW1Uxl7d> zo*H~tBCZBV;FF$5%rX)hPJHcp1C-#?B-Z2+xAnFf_FnRQ1C$_v&$u3u_hPSxztD~b z3Dn|v0QZCA0UlBN#K4BtCT+M2B}m}2x<^dev*gY}3vSlM)minnU~A*}Houakk5c>^S&AG$C!rXI#VBO2pSS9%}UHfi_{3V7(Z5p&VO8BrfZ| zD~0-j1Zv^aB;{Kg^QYD5%$;th_e$Hi3&)-Q_G@=!@NRi%ub*kGhMvDUB(-KfB~;dY5RY?L5TuJp2uWortD?thbQlz+Xg zeW})*aGP%n1qjr_r#|{Ez?Yrv`e&^0^gg+5lps;7?D~v}7Yc@^Ze|-Tzv*J}DVPBcC&SRc;$4NG$tieMZII`9qbru#MI`y4uA~oHtvoD-a-1 z3!lObqgdOH_JQ#)SQT&m*Fp&rosBUW-SgxL9h<{8=Dpa?F4=3Tb!R!_xbn0)b-r{05N|4}d!!y{s=FP+P>^&b|v?hL4C`f=7#!=`i;MEJ*xo4EJ@3_63 zjS{REV@?$JX?Wghy&%CpSE`te5+s5v!{6;cwr+W)mi^S16$1on;oJ%aYY*N2yihAy8vL)ZK`5l{3BHGY~1lpuktgkgL}#Qo0=X!K|Hs?6 zyK^r+6|jK>KHU@XR3rPB7aO=IKAhrUU8r@(7K)2jEFQY7q6XEwH?kiZ)4)xvn=cC` zNMOvtFy3Bzm%TYpW4FW)W(Wz?x<0B-z3Xj?g*p%7sPh|5^4hI`e8inJ=YWY?H6Q&x zJ>OkLLmMZY3+psn>YbO`-4$OjI83TJIj~?be;Tz(OtFS#BL!SiQjr5_c3GY7MN?-yJdTT?-{h z)O=`bdaX%?LrdtLg<(`Xalooty@&hlZ?6Ri)Z+chhkJ|YICZP<23e1N(ZkKyddflx z66@!Ev}@wL!lAP&PQ5Kx7q{Qj|Al)zJHq09bm@kLL;Rj=JH7wna&vnBwXEfoj0Rnc zglay+Pb{x=dDty65M|n3x`IlcV5pH?&dDI<>zqo{8NGiXf57x zbH=Kng+pC7aL+5h_=x*T|MTIGt4_91f@6iY4Wr0_`(67~LHBsaz5yFZ;B}&X`*&5` zqZ3WH__Wpm0=4)lOyWU?@$%1Q+|>ozxDQvUrQUgo1PSyp!zed>X}Iq75$@})cZ9Gm z)WWEdVRR0U52uY7?cQ_O^bksrz*#`^<8;@oMXybC-+zCk86Y4!h*2YNFUtSt$QQ@n zRy8UZ+TZJdirjJet9pl3IoI|Ap zFPD3aIi$+GNA=!8M%p+qHFs$B(jS!8pYwBt@}{3w1nh;4W zf?IfFf@$eT*Auxzujl9a5!nxsu#HIK#U`G$ z7YV+yA1(VNh7GBUZHU&5TZPwS=R0T2#cjS){aE|aX>;%)UfUzbs^gD2Lf_T$uJiTT zMrhPcUYTpk`@%Od2$670km%a?n#naq+xWBE&t{igXFb27^8J}#t8&r8|Cn+7YSvZr zr*h|&)`1*FeXr#5mrMB#Z)88Pw>S!2*ZgE2%ky;%LL}S|B&2`QHX;cgAEvo~+^1+l zBw|B|8;_NYk7zL+f24<8{isrY4iZ=X0m3a?$-(`N68aY-X;d+r+Hljl4 ztw&W{R#=u zVtUEdn?nVv#qwQ|uoe>H$I)XYbB&4CvXdAePlYJW#{P)=NLnN#fAoP7V zwhfsRcz5PKW9<1M64es8pUXNHZ379>Vw(3Wan?k=V__{EH`(Dt+Ym`45r1@%5G|%< zw;XLlB&>x5dwc9_j)Z72E!QU6hO9EIg{#kuuP>RgR|b(p61?MxJ-SGU7SnQVqFW^r z)}yVMuvXAY>e4%})XYO~tG9Xu|N1SoY~fd`d}+Y%q5F=k&g9wL zv?}jjkw6I&d6u_P#LNf(h#>I&Ail@Lcb-8TNSvrMPqixL+uuUi+8|N#*6w~kkl@%E z_rrVtf&D-U5@pdI9CX~mK4Ss$_qF&LQY7am6DDT2?tdKx0`MbA!MFKtJz=_N1+B{wUVko|s zpacmi5B&LJM5|&Eto6#zP1Gp7^2((M0wq|lwDzavk-m!rYW4V{kh1Y|o+}YHP=bW` z?q@|KTZIH_C0BV(ja$=Ykv33*g!plfYLPaOK&?(q#;LOgyBkH?KnW5u)1v!<1ZoZH zl{bk!vDR;qtwISBo#*9IJ&(Q~NTAlaFCSMnMwg7df+#`a=j-cL&!hbc3Dm-Olfn6c z5+tO|p8vlI*1|O=NT3Akl|J#R9iKog8EIbggM|1JOK_dXwJS)V1c}>cZcy{1Zr8}Q z1PRpQIAiSf3MELasIgPon6rg{J%FnZ5~wBXl^3}~0wqY4_-l`{!Rv9*1`?=+E4X3Y z{OBONR)^f7ZJB$+7{OkD%ip`69$YxoqsEaC-xGPS<(>Db^2QT(%E2RM@0X8ci!lPFL1Zs8OGa#Mg;?>o8pW(~8+C7iesB+Y6!DD!)?^rE>4eZp zIwABEoe;t?;gJ>^N1aZw4$5yYoPVJ1<`oYI#tMm`jlXjjv|ElpnDO+ot^oqIcouL! zywg{U6#P0{sR30Cm&pgk$%%YGG@G z1WJ(DaP4cg!f|Wk6R3r6#)AY(km$6vva<31kw_azpcdcfd93_BgFm{wwzGt1l$3do z%X?q`yT3#B39NVhj!)DodSS}NP<$IGK|*S}wZ7iT_%@J0E#4DxtNi_yPoM+|8MkwJ z^^Pnifm*z`V;lZH(I-%Xgk1klDUmjiKrP;*vJHQ~>)SvH65_|vHjqFq-fOcBf8Xxg zKnW5u)1t=;3Dn|e2DafpfA}^~g2bkac@zEh{0|Z+K|<_B&p{+mOGY7jWx%<{PpRBnd9LNM_f`h)Q8H6mf&@PyGr`Z%!G0it zTDXD-36#Vl{C%Q-J&=$xYvG+J824eV&#J86xjf%ObziDec1`1(TYBXslV@abIhy$M z$gAeq_qr*fb%Auh%zuA9-^@4!OV}O~TrP6*vHt_XT1~CGsYY%TiM`A(BW! z-U8m35G}Fcc_O}>P?CRY2HGzDu<7rUK3Ec~=UT#aWY1$0@@8FHBI71+;-mW^l1M^M z6hsqi$1X9a{4q;igPp^cn%$rC%1pf3BUV4yN5vD%zO^A)u7CV%E_HFMx^0`E%=L14 z>Dm{PcV1i^W2~eu)%yH!X$@yn=E%fo+I~M%xexYat=0A#O~F7SrNm(S%4?3kfbad1hIRenb+i zB{pLFE)y&f8$xiO+7?W}GN-YxBLgM48x#Fy%NQjoQk!O5z{bv`&XqEUVYq2NF z92B39zUCr{BtEP1$IjR*H4>u5H2*#JJxV03g#?$4ho;8pMaCS*m8 zCgd*6TB8^KsCYB8enQKYLcK^EW0$i)mTGqX}M}dF^8@-VMl_$SZj4Yc7&V zVp}ic|3HWq({h)HZk0$_3kfdA_V!4EwWNhSyP|ENC!)u6%Xd1C4UurYNN_p!9xjrQ z{T%=Oe~&Kvl3WkDPSO1ki5gKLWNzM=5G|(pE?W9*tXV4()(Q}D@0>h~xGpAm){2kC zA1jeW5`3LvuO&!`7Sr)ZmuIT9D|qT1?B+QnU@cTNde%&)>Z!#=UcjgzH73PLm>P9~^B13DIIY{_a&ItcAoc-`{MW zD=|Ap&yf%B4I5gj=s4ljtwM4i)ncRi0(O`peopUj`DEXAu++Ho-#Vc>W4^J z3klh4McY6^w3y~oPqD{JB&>x5Kl_YY9_xt(3DIKu&$}ym?3`oR&~-5( zT4IBvBC!dHmjsB{-%EXaHAR$^z%Wg1wtVF_ENN_p!E+dkVU54y3=~urk?@N7{ z-d^p>ofzh*TI5rs{L(_6SfdG%aI27(ztgYD`;<@XMeo!sdTYrxM8brW`R_dEz26%| z5+Xr@%kp>1Df{K9w7)1f!wX~w>q0I5yTnnV36UVdW%;{d6i%C)_8R4w=uhK?b)gpj zo#(vwo->jV2@+hEzXxNM{Jx)mx5;ny`5XY3`LqE3iZV#>*%;R1vk_93(>wh4*imPZ zAW_mDlgWSoXD6Sn#b~W)u?>zu3(aR{xa^<(@FS@xL4srCOhm`qkw7gz=fH%2hQ+sm z5+pd*&qVZl10+z3&&)94pT+TQpacniXTe1D`wt{gORj8mKTv`Mzk6XD(eH7PKrQi@ z=<9(JB>1fp+lYQ2g#>Exc@`%8vpD`(p#%wj7sf>NyE`OMOaAVk#fh@P5+wM&A=~hN zk&C{Ib)lB@&p(Uf+dv5t{Oeq{;r&)0Z6JYK|9jSQKg9F7CGqDVN{|qr_D=};{Xhb> z;+Y804c!hM*QX{WB-$uJ0#|La5r;r6F0&2q{?Wa}P}e9}J9W&>J1vwT zfp;Inh)19nm)VAQ=PbOhjaC2E?K?i9>w(!Q*z-9D0B2a>nZ{-B&dJ2DE6eX>0_O+j zXTZ5>7<=fN7jLnDD`zV}2@;q=f^y0daf-Yol0dCstGXO$V-+kpy74w`L)wc3X2LLx z2dN)_PQAMQ;|Kz^a75I~utH9&V=e=p=aMf0Gm``flpuj49VB>ebG^I*@Eqha-^uuF z1DE}C0Dh~mEN9hq+f3Dn|q2Tb^v{d3Hn>mOHhIYpB7@;Kgr?SKmxVU^Mf`}f`r)fPjdJ+ zkU%Z;>7WgiAR!~-pZ)M{Ac0!q(>%L^HpH)3BG;emjX$GMf`s@&bkC7Mt@zg*B}nj@ znb=nl3DgqW^Ibe^fH^t%45XZ=#q*HC7yzHrlWZ-RF9lEUMHBqqnNO7>ftgU^6Q~ux zRVYCMGob`+Ac0yqBGJ1(9(UO%a-X=&^M=dbt}k+*C>=T7REnKyucYRE7T}be%%{IKXJ#wEY1Zv?rAHC~ig6l$pXEWRI z?go+jL?KWM=f7bj9eqBOcKdku#Vz@*fs}=kPpJ2#ER=Y19Z&fi#v@zXSO@PO=KeEj zzJ=LrFp~~urlBu2of~BhF4EV%CGY+~&Ke{z8;xOv*9^6?tVi96$In>!D*+_%*9w$f z?D4y-EmNO#PoBxLP=W-umU223u4NtVJlLId@T~xWTEU*rC^R{=bMypv!_7NFC_!R7 zWCt>g;w9&VI?ovIPXGBzkN_%!j~#3xXK1pbmBXafnsECew;5;8JyplTx=@S%&NIq8a~Da71PLz7-wk78u0*TtXXjHNr{A-1 zUHnYOr!l2oF5@pMg9J(<3AW*#FfEv;pndae`%@>;uWXRO^P>2>OUeWd1WFavW z%V_tooAg_L7wcj|wD|AUzR4FlU~v|x6|)LtHs@dO(3=L7Ai-sRZ{X#PiWQ;f97`X6 z)ILu3q)39_4{&B9B)BYpH;gs(ZlV^o=qdefLL{gaY*i#7?L~si@^{1db<}-nIlpY^ zo~@hczyIJKq85%wkU$9%T+SX{%v{ezzJlvjUM<#=GXGt2VFferqXY>q^ZO4k|5c<7 zsSCAutmN-O8z_k+vTfk=xe%NyOj;P3H!Qfe3&HJ0Ldsr0Zc3Y*(23p^4WPNm1ZMG& zOgcb|;qMp-P8JRDLlwu;L^LRuwSNK2XS=e^1&Jo#!@cV(`EHrilXw74u; zLvBon7S|+Y!zgoNRKg4NChikj=}?08a#=>?#)N2bO;V;eiZHq;!Fu`c*`rH-mB4uu z_z8@)M8ajso)n)z2@>pMLMu=7Z6JYK{Pz#9f1>j(VXIJr1eddk?d|6$EUox%)&>RXf+F$gZ1{{tVBm)5}H)*4txKJLl-{KbnQg@vk{%?4F-lmb|13 zc?n98;Id>kGmQEL7B@chS#@{!ux3$&Xua2! zGs;Q+_KM6&DG#_OXWSA+h*oIFy-u|*Kbbp^u#IAWy_q~Y=Qy_~{Sp)6 zp;b+*xzCSl9z}>&i!N21($+SFlR73buz{IHD_A*#Oz@K0=0sz!nK60OLi%?A!TfBaJ@na64*k+ zXtsE6!i8TNTUCyXwR_LH)3K|R3H3a7PTdF5hLkC@>ZZ3dY?>P#^nFkyY)^7*VNH|+ zd*j*WkhoSTUM-t%hlP?gf$s^n{ljy1KG zb?al}6d@8MxSXBAjBF%m8@AZsd|gs+w+ddCP00vl7z_VObUvS4%c}mEw_6fPBq150 z4CB82vlALMTWHlt7^Q4*tGHe!L`z!bWtzIOguRF>f*P}rYEsR2Iie)mSFMDQ%0nv}QFaQ6qEKn0G=oY_^PTUZh$NoM zR(P^T5-kX&e)s3T&voW=&iPKy^ZR37bG^=Wy|3%u@B1v@`&MN&arWU5Ay!BgO5N<; zrs^Z#tqcn&XLxfnt zQ*n<{Z8zU-yP6OU`-Lpm)g8-wkL0htcy@eC=EM*oRv@G|s}s{zuU**ic+n(PC(uYA zL`p=)^{szb(LGIlLHB!h;>~+oB&(=i%Niyi%ieuDM!p+EmoEIuhy%YEafCLzq>Vq8 zooI1?i)3jv`mjdg2_ZW%dO1%b=WGR8tRz)tw>P(R9W^rO7-Gc)r{yq6Oj9UR^4swRaLwC@wySt%sHmD zGa*)pAv=|-6|(734tf;(f-F{k!tJ#VdM#_1fZU==4gbjCblCi#ydWV~aveLMT9!Nd zI2e7{m&A~LQe_WHZ-jF&!m%$VAfpt_DEjz~vlF{jS#6qQMp5y_R`7%}G`lpb`p9zY zL$`ME1wyR+ggYBJmV#waVmbLBL9$D9w zZ);CKnXTKESi!GY`3bir+G=FbH7Hh0Kz?B8Ur}Fcx23f+Ay$YXdkT9!%3Cvt75s{o zp9roQ#EJ>XpYHuLntIK^gjh+8)N2N@k{GgPfTz@J2GKA98Tm7726q&-WfWyXtfW57 zn!z2ZQ6GA27c2NhNyExd%)YKi{_E*&?N*jqGl&%vkkJ;*nql2-vlE?EyBguH8JG|& z#6WxV&1z`74r|yi*@=-Bl?adi8~hYn~EsjS#_RTExjI{oy^ZNYX(-$7Wh;1+y??`N2Jr<#to%f9%^+4x zKrS3rri3*E6JmuJvQKh&%^+6rD^`9YxMmP5CLqsVQl^A80~2B;F;cG?#7bhwngN~+ z;ocQ_YX;FU0U7!8jiTO~feEpa`bfQI5G(jaNz1O;BCBT^<_^+ZGl+%>$Y={@#>rHx zzFSqh>g>)qOo$ayRotUg+s$&@uAV1vv$M?vWU=C%-_(ieM6f1EfHr&3ebMzMXU4vcc?|}A5N86iSn)ngmc;~Xf&^%@ z2c7yh*h$Mzta@90E6m@j@HZ^{ot!=o(m8PT8?S9v1Z$Y!*oH_~F@gkJ!4rNrVZM%5 z=c_qPh=vKs{(P)HIJFJ(!M@lEDTF8U^$uc`b5q5dAb}W$*sjhd&pH16G2@*(^A)ZLfu!n4ZH*emu{x%c*OAGw612N36QkY;(kU$JW zEWde#{o~1N^Xe}+9A^TuSmD=Ph+%$R!vt%B1ZYFxeHy;*Q@uBz3Hg;6tmL}+l~5`o z_>4dJFLQ{^MczSv6gXMXL(1Z#o>@?i+KbTd_bI7~>+VI}oseznB}Yk~w~7{YBO znW|kmOh^sFO4^(G^%)ba2@;55h_fGPk!*MVfy^`&g9&Mgu)^daD_xIZO^_(A4^@MXYP-75nUG&s!7qNzg&1Bd@d(xg3B>SP zqNAGb-I#fpkY91w?F>VbRn)~WHA3HeSNR`{wMp3L{{Ot2@Ot2#9B;QT4$D|9SwwJwOaogG{g{NFatGPE%u{ zeZittOCM+vXM%szz&p9BRX7o>2@;55e$7!v5vO0UsO1f6{AD8e+YIEx{1$)-Nfk6f z0x=BX<~&oCmBWOj3RY5v=Jz;EuqH?#h9TS<%v5#mFd?N2D`^YncSTIFCP*NLA+T0Z z-{NGdq1RzTS_!PAO@|5A1c~ApZjZ`Teb8Y-T08to-!wUAf;B;+xUAffAybVd4inOA z;aA2OQ-e&fCP*NLA*!e`@ztzFE&sW%MUn{_ePG37rDZX}njnD~rtNZl;G3d>&CBT> zS|Hy|Zq*Tnpv%a*sh%^Rp=h=#3n3~#TO5aDW=(6UFgQTt_F zp}u~&%KeI!_iGawF^YHb2oj=UzmVm+{&p}=eQjvV_q(EDE5sEy*=4HF_?^=?=7cIm$nu#$X)W3Yz(E<0FB=jqk0eFHH#w@p?y(N^_#X!#QUS6H@1+hEEN| zV1lhg4#&8xVSAn4YH7E`K7OvXmhU?_*2_o7yRQtykQ_q8#7oGP;_X+7Z{#a4$WpQUq)7E?088YU9G zO&<*Bg9)~h>*2P`8YU#(w>ORtlobDugBC73GKFd^+KT)IrKRmp9aHB8*q zrGrjYxRo%$Rzjmy`b$^(2gVW^akgC8E5;+ZpI{9W&%JMEgUErNff!7%RsZpIbUwnf z8f%!a($CfeQs_?=6KusZoS*P%$Zs;QnvM~kqnKc;l4nlVh>Vd_dRllaVGR=!FFfNg z!B*0{!ZQwQn2>S{&p1r5mGrJ~44=j=UHFyST-6-=A@h9B1Y1cPGpXWd09Y-_PIbI; zLW#(A|2M*{2@+VHnff@C_gL0vY1#3%YIVsmg0$?!r(y&M(J%p7u3Oe^>Nh48)i3DI zd3K#|p1m=FyUkmb5=B@1w;jbFd8Mr72QKZa^o9>JPagj?tI>l*a)w59WOYXB`WuFF{$ z{&vM0CM0(17~+eqkV3ickHMNCfwD3&IOmx4;R`D-%;%6ZhV<2=PjL(;Bp(=&MTTFF z9j*`Y#W95T#&)khSi=NX8Sr#xCwDEHl3**0`5?kESi=OyF%Z93b>|N+1`}+BxeEmD zWc)d24HFp0QpaF|t*{~ofjcCB4AwA#6$glLSuw#@SdoM9R{CDfS;GX@3?R^A{HbDs zt+4vg`H+>q7eoAx-g#wAtYj4q;`4-iH{lWD7aAr+_UU@K0opI{9W z(pOMVB@=8Vp2DLKYnYHeAFgvI*owz7f2z1$VI?aiEw$;bkiGg~4HLL~1s$$ICfKUm z@0~QUxR!fw;Kji61o*-eh)?f4N6XV@-5Ha%(Di)xKFT9l!$fRD6HR~pfqNenBG9g| zB163G-;D9K7=KxDTVeuhEzp-d<35@2Vlcs0Ty)B{RC^6kb3g&oxPkh!B!%B&z3xbTZYI;Vact8HB3m(!>xn~wra51wB0K@yLZA~ zs#wE>lyuXX!M4i;TdjY<%xW!02U{X*n2`Ek`$;ec6Kqv-YiA7;(vHLVV1liWys$1sArLJy?7sbwbJ(yqG1BET(_*R)!qr8tKSJPP`?{xUu=czaw1BY z5DgQM<+@(C<9WM-=k4r^t#Dm-(+(4&VFI#T_nx;qc;3#w*b3Lt&rOfw{W4g?1mt2O z_*Q1sh3%vA?uP3_WW7T6-o)YEjhr_CnQ6K1ClFmUK|<(~3D9DN{D~(&!I~gZoR8p} zAMq8W<;;fgn;+3I0a>nl?aD?=L`p=%R=6%_PlO54FacSvd+o}5^CKFzLJT<_BTR^f z3CMEYYl+@_9MP~9V#tpFVL~)aK$h!z{-`f+e#95nI3lBkXKbsYV|Z&Hk6;ZGCC6Zb ztwavTzIJrV!AGzdWvxf6h#hTMEZlrTN?2~RDed-uYH2crvnm+A= zet{U068K`PlJmhDCcYi}jgHY`Uhw?~6Kut0sQaL|mj$PaX~;O)#pFErY$;==>+^!W$7g97_v-JryGhGoLVR%wK}c*r!5Sw1w|ubGu@Pk= zsbjFlN2vCex~!N$s>Ev3^=XleyG!T|C~+Zr8qj^L3io-#M~o#JQ2% zm8P&?%SiUfNF-R&{(NwWGI8Yn^CD}Obkuao1Y4C{A6)0N+t!KHzV5I8N2-{Zzx1rg z?ypb$msGJ;$t@8%mtM=Y$t6;9&Y5VVO1-$Bmx}jj+xwrzZBZZWi>+EvJ^5p-s#Yd3 zEa#m9oFDVkf6@|Jv5WP&hSDF+xiUOnv4)8DEv4#oAa<;HPMvxFITo>80CN3DCzg~TBH%)ze2Mx{%OKbV0{*4;W^QxPe z9lLSX*o4TIHTv6vyrHYc**nWD*1w|{Uz`%i#pij=>03VkFZJEvf#(h<*%#u8UtBLH zI8{tQ<`jx-S#`gEEdOP-YeYNs?H%WX2`#Jh)gJGJ(>6~@xK`Q|6IpHyz3-r$$R>Vq zZX0qgXRYZNe!@M4jR{GiIVVo#Jj<;Q6N8DAXSf9kIfae=LYC{6rRpQgtq;9NqiEPl zYS5ep6(r=`HYOm;b^of5eX*5XH>W}I8!Xl^A+jO3C8i=^g&LF=s&>c!*VB=SR}Hj> zj4-DUiH7}htF){W-#wO}tG?qbE$zx}4IoheXiFe)(jWRwm=FyUkmb792Oa5yqG2l? zL!Cv4{+NPrHB4yPBktc?FaM0I2RcP=&T(d#^lg!K4DYP1J0|sstil(Qs@JlH{c2gA z^X01#2O|UOW0dQQX??w9!qqV0kHPO)*cV&Lb<=iJ5zsIpvahu}YP{0@L(V>9D;-0X zp*csmx+Nej&?s|BR#4>()7BIm3->(@(G_NC<6NJ3DmG+^(K0wf?(}t`9j~9JI)w<$T33 zAsQwi%XNM4j5oY^?xV{}G;Da=>|o@lq{agckv6{2V_jH|=crw!6Y4=-Ao2@1%>nDy}rcOzp6(laq z*b|+7yr)yHW;MNMrubr_$r$H~p&NZMm{>HVR$}&y^w^xw zP`b`-Pmg|<)ZC>-NFE;da8b0T5yKKe%#Jd)gQitNKVS>xe|2_&<(ym07Qz~&C`$+o<^mEaqB0&2IlnIE7Pk%Pn>0Be1I^$X0 z&&5+}0@0b6r20IF=Ndd)LJSaW#i{kj;9e{FKo3qfsi|XZTQffpgEdT)9D@nAdTC%? z9RsD{PZevJ;5=!BGmu9}lVGaA0>&TOT=7h#R)?AklTcH%>x}RW8kVsut zSp%Bt7-&BeUTvo3+tqBqvBUY0=&6Wln{irmFy|nkVWQ_!6Ys|RFjAL9uvLDxN$ve= z)b2KBc4qE z;8wyKCM1R4xa-w96Ko|V5{|(dCNR!R3jH}}f~~lx>0ax-eU((9ZXu(mLKaV^&eaOp zBUlq8gbt?)nyKeM=8aJt57+&K_$`Srsj8-ach|X8c6@^S9RQ#FF24XoYDHG3#1O$6 z$V`i@o&&4z0C1m%`!%FWG)zF2lkHDUh!s4E?0=Wa8ukkr`~5!t=1f&q{JXm#Ay&99 zvj1HwYuGPjsi#vDVg*kkTUPb&AIn;BPm5$r_1jUbWVZj7o6~*mG~KR5#`RIVSL96G zIZfpwNQef0g@7#AEo+y0X4yvl?sb=XmchQ*N?%|4bxyGqLNx3bvRt>Ulj_;))#`Wp_;oz{Vk>?9 z!gJhwga}u|gqA(R&ABaQwVer9MvP1E8Qj6tN01N=`-LpmEo)!@^7&tl9bupL!eL$K zqG2n<*m>@eb*7aB3DGbCS+4s>YWBrea^3U~o;g{=gvf?asmgNmq5B}BL&H|^g#4L) z9wbD=1Z27HjSRMoC8A*~#6Ufn@yZ=bJVG=~K$h#eU1hoLN{?5fVJjWO9V=51Jc_z0 z(R4}znt2m9MKJEdugJK*d)WGj$%ksYUaDBb1mt3Za~>qb3fF6|ToLh&ySl7c!+s$b z6YeN#W84iAVx_Nt_kw@iEhbzI`_-~Xa7$!gY$eydao4wotF3%N!-UAbaTlpdvM;vM z*Hu~h#@%AV)i9xDkKmDkeX$j;qi%fTt{#0@!vy4Ff=9R@Ay&AKHfF}%AR!v|3t6uF zM{4%PR{HwP58Qn4*v=XzwCoW)UBX~uoV=&>$ zxIRzSjc?r5t3KASU&zG-uPlRvSn2ETSm|GjvWER?*(3O_2%7yDSJdahL6+0Yu%?#l z{yQ($1PP%ntE{?XnXR7I4p#R#Ao#p9mIn@B9BsMbL$&rf6(LseB(fqtpP0W~ee3*& zy2oJ+QYBW9+f8^bn)@=IS)PgzD|ixFon52$7IDu+(lJECejy)S@l3RI(;X_tsR*$` z43WJ%7X7U=YuGR3b<3ZQ9-6gN#o%w9gM?V&y2#$!J{#``p?ToBH>1zI^Oc?rMApQ@ zjnS1u_ox`|8%2*;=W37=A#|!dLghS5m6aV=_nqvEt#JM5sLj!HzQJiwK|(Z4K$h#? z*=IIROJiSbh3hr?y&wJfS3I+H&sXyZ(J%p7uIrYVrP^*>wOt)UG;Di`&`itXy0vP<_&J@pE`SdZ%da2?#r|6}+d7IZRi?fCa$i?TY zyzV`hKeKGj_Fs45G> z7W)Xc;&Z;#33*4yIdAOx*P02JFY*!Xu8JqxeO@(I>g;b~b?e0vr7x%v>oxZqRfBC6 zQT~SJYi?HrKd0uj4;|MoG5*!su@?()5)Fuf@x`leQv~~EqTUnnMB2}1#Xc>M7)aH= zo-5XjQv^pJ_dz7FZhFnwplaWG&w$Lca1a}woVj{ZARkO@U67OLIjB~w?*ree7}<&_ zRn)WXT>*lv{P}3Fh$$D|(e`>pu;$%&+awBG)X}>?bBU;TcZ#UHHEZo-ieL>Bu_mn& zZC2NgW!;X`RgkLRw^d$yZ-8JcJ}b$T)uCkr)=X3cYnb?I^ZAJf;tU)gl zf=|0Lb-pk0-D`IQ2(~(BliKA*)n?-ch=EozxbcgtL9m7iKJCh~qKde2(yq3>0t8!y zb3WyUHEaH*2-Yycr(LNYrDD9aWAK_A0|Z-fsjFUla-37Jp;5tCwHL+5To+3Wyy>s# z#N2OU&-9BYnpXQKTB^}EF@GO?!W!?~wK%c9OyxzsGxeB2|Jd{QiKyux9aW6sO{*0g zt>q`!ihGx-!J`}JtVSzg4HM{_3&xbvecrT^xvlE!vSNa*&^JeKEaQ$07pt<$?D^x` zOcev|YV^!fx?PPsd0yhNd8fseH#coePMI^Uz0Bp8w4J61v?b9nF{pL3gfrluXl@Oh zkcav>|HR?7Hv|Z_;#20-Q%*(nxpw~=^eEOaF=0aUME#Y2Ml+vBO9V0awB&2|1qinC zpI(PvTk*Op*B~FPVS>-|Q>U5#GtRm8he-uh8v9!bw~R4rw40pvM|6l9?JVoO%f>r9 z=DruXdEkOLYnb2@64jZLDpeO2?O*$pN);1qmH%E&qQ>LDMbDp%80hDZe)FcD4LI#* ztDd?}rKE}Kss4PR*EVlHat(SdYnXWZkQ#jk{}yeLhtUUR_2sAQTHhTY*osd}G^u)H z)vVQ%6~P)N+SG5A=(6Ha^phzVMUjsezZo7P*b3t@POLO%(IHjyw?Ez*vjJ$w&q_0DU`=rJ6%+4QY5VrbzQUG1u6-EsF4MVvf(clLQI25XqWSo!OR zW!?D$WmW0>C2OZDg69wP+NSAMy?!n;gF2O65ew({Ydb^{JZCV0{&=+3SuxYkk&lW` z)W}1-VuG!B#4x4X`0|feqjXur1n0?&aNjk#boH$Pf~|NQGcoe2&RdNY0Be}wQa5u{ zE~Eq9lfZLUSF|>39N(WcPdBg ztC8IruEprX1Y2QT*)zXl%#2s)=iPrP&}&iFFyZeXXuHX=YuBKEFu_(BA19B?h+#xE zE%8wHvun^2S;GXkG0SSMVm!Ta)fz_;T;~|)`zpVfGq}B(^_7eatYHG{6{qo;F|1e2 zD7wGg{i|_r;3LS2R|aNme{JEM?CFYN4g1Baro~^CVps=RRtuGnxqH8CyEZ_umH*zL z-i9_#+J-jv9o>G@E7tmUh1i%kzSFX_KKzcQ_;klneY2g?{j!}`55ML+kqx7StlL4z zDQfCG$k$ps%?h%epP&C-6QW@P@|myK*0C+?==2MmwP&=m%fGl*6A~SM<@`wyl3L4J zaUk0{FrlS==~cb-cqJMpAma>2bArq57q)h898t?&dgpr~LaY#D_4tg~=Bk*_-`$_- z^qADZJ~QEj&d!vxI{n0*DpgNavTXdZ1}PB&vYa4lSp!w7R<_KGZ~3HQh!87yYVmia z*l!I{=XHM0bWW-it(w?C6QW_ika5zZImzr%m8y}0dc~(qY^l#(5)G{6>{Ne@msFj9 zcI35~(>FU5LkQ4vHl$_Ub!nC}d0>8CvpG2-LaY#@^s%aLJ_=Nf2mg4jXt9dHz97r_ zbADpE%Ex7cdL^$_`Cttbkc-c|tC!o#`RZ!wslw#$ zV|8_^#0q}ptUf>C*14{C)-VBC&hxXZ(kfLSY^v=X?eo5Fi4vWy;HkKOBvq=eI#$;? zI;T(*qG1BEoJ;8S4@de36JiBI`le;=E@`c@%PJQze52{pEu4Fmo&axcVASFUTmJ>uRYm7?O1(|utM?Yy8 zBE$-w(4!t!&mYuSqEhvC!b#?;l(B~WLPnc5Cz-vVQuWEuUdeM*d9envoXN_bEQ@+= zD25QA<#bnXWXMz_Lx>P7#K1Ua@X#J;nuk=buPY`fGlSVTh`tw zt(=SBs%@X_{l0FAOo$a?6!(v>RI1XB*R?NwvQQJEVZV^&EMu>K*wR0k5GxSUH!bU+ z8W|o|Ylg?8X3dbc=ulMF401;YLQZ6~tc_}9cv<}ocGnDYA{){!Yf&A;JCSXi8W}!c zpY43H%&ZyYd^IK@PwrCAT{HB%=K^P_S~I-1+^iWSI{eCMk{~3t>eo)Hl{~A~4Brhm zYX;FU0a?zww5-*t4}N!bE&GP4X3fBaSRqEc@_$6ln&BqZ2g|6LxpLC18N>>H<>XC2 zF-)cEzN(hJqoP?eh%Y7}%h{meH3Ji3g&0k1{1Z*RW)Kbgg)AqQT2^q)AR1W7*{S{* z)N5_lFacT4hV(`TZ_U7|5-WHbJ)=|!YX-4`UparyPpna;o9xvq`M0}f5MN9{mQ(H2 znLuh}7^&6_f4gf2Cd3LczO7u=T{HZw`ru!43X{#xm^Fj+TBM!F5;=L*vK~|;L%o7*`;|Fn%^(^k zAfs=lUNcB^NxQ5WB(>g{=&cz4|im6 z)X1RM3}OYps6kmX6cg{LR`UIsmh**bDeQ|0$Z|HQWzAHnx}#-Ya=yD}5MOKsPiWI- zu05*mab8m?>f^2%M8gDRv}v&A>5)09|~Ft9v)$ ztr?gQE5yKvk$TM_R`4rl*7}Lynn8Rq0a;GM4X+uP5G%yMc$|98AXe}zC-?e^QL1!L zQ?o&*3{&UgiwVeb2C!xIRwKjHn`+y$`j|BX6JmuJ#r-3#X&a}J8mscvn8h0Q3t7%G zwyeKZs@AHRbN4W_rWRjp1zGy0W&Kj7t@D@q+q&*>CZ1*BT$!mwGxd8%Ib#N~ajp!m z`w7+vExVZ&pG5Oam*Yu$;P$)*Zw=p?P z_LoFF$k?3_GIq2!I~8WP_&d4b;E;kYL$9*g7k0@5f&C>pwq<2p)Wf-cTyBRIOKbR2 z#RSJzb8WYt&LemHwEo|1tNRGH;&VDItLdM2IJY+Yp#6x`E|0T@34cBYUUocLQI*wC zn}+-H!DYpJ`Kwh%2?Sd~Mk$z+1WKuVWHg(Z_r!(OZPqZsd-+>dNd#L#MhtV3K(@;H z_dAPLjkYe2vxW)mJBk`Kdzh9)uoYy)Fa*8{m$LzbR`83xaZgQ%FQJ(*`|k#0FacQz z?C_U5RicrUFkxa`t-e=!qxnlME30ppu=6ndVi)5eA$hVaCOGY&VFj8!S=JNkd!-qx zr)3^g-!8F+2|hQaWP+_A>r&ABI{%@3pZ(l>nUAVitYLyrO0cYw2)2TZ7-oO!W2Lf_ zy>|YbIYyNkYnbqzlwg%auoYy)F#Bxtw`y#KU5-$?usSs%zL&v}EPC8KcS&SiM+$KsQSXV{fj6eiTkq^w~Adv2qw%qcM{hK(3!sTkNPU1DIbV)4so%vjdzBXaGtM&_Hf~~M`HByCho-C`w>22-r>a57iE7R5vTEQ=@{KOiSnpyXL zkXK8kjx~^lz~0D^u}io5ZHC&V=aW~L7M`Yd7-NF1u>UqvWlmRFADJDmFluk!$EwVN zR`3feKe6(>SVfWI`OjRG%RDA@k z;1@OMCyuC9ev`T@^3;EuHIRkC-qDavyV}0AzjM~grG;_Tu9#pee~*f88sgln+U^~y zwXh~gAcpCKt+FOMZB_p$t@;NOkj097vt>SS*u$7FirUu=SI#Z3* zPS8r~!%w)gL8h7w9M(VuEJBw?6+sW-0Z%ROB3^W$;8UsV8$fSBtvGV}GjoV}=@2M1#}aAcKZ1bz@G{ zxkkm9b=?((ucJ^x?ykL3z-vr1Y1cxxdhF%tYJdx#+;Ayec23q z(W0e=71b}jS;GWM8hJ9OzEv#U);?HoMc(IS+xiH$lDaX(WHr;Q-8?C;rb-=an2@?L z=gL%4Gyih6E_p|-1X#laY7nV1CwlT~i3zrnIyVHb;aJ0j%o65q8#DiwJ1Zn!p7caM zYnTw(oB^6@#(^)kf{a#ahy^O=r(4%$#?C*=m2Y0Eli;}CoM`&$aX?N-`h z4HHr#=3E)>A55^7KUFo=YU!@p#3WAc344;wQCM zICt~AnOCddxHAD+thhuh>nzot25#F@SZ?DGhc!${n>MHR@OZ@pTXDIm@2~0}FG|(V zWi~+H<{oTWs-GvNpRtoi zb>ih}6^oS&2=oJ4m!Q3JYSs6mW}T>4GOP&_NR>GcGPo|0oXZ*|R0@{r=MMV$-M7rj zl2l>Lko6pTuw3{0xwqZK7&$p&An<=NMnb3L~e) zHX|IbkXaKX5YHPEy?bXSBvrvuuq-u4C1sA1y4CZYTj#ok`&xVO{sTD|E2IRnv@tVB z?J1k#I185+o~h<2X^BBAE)mPxT&k_p`s@{X4bs}$tYN=W|7MQ5Q?;vwYL3cKV+s>& z#pR~%CVqP?t8A%e$qsks>Rrs2^K&0p#xpW`Rud*fgXb4a%XRfW>Vw>@Z6{AA_pTnM zV~7TX$cRz=+$Ttgh6%`W-Ln4ka)+!64|jI{`KXbOAsV(q40$FOCPc#oWVvqno}}{g zPkv5`=dgHY8Gc&J1Y7Y_T>lec)-ZwR$%tW26Xa*eOt2L{jrKp;W(^Z~E{+)HG(moP z&IDWWQ+59nde$(3C-*uZY9uO|U@LwjpuR&c;T?-u;dzR++18 zJ_n1>t6Ftev&6b)FGsiQbFh*>-r@AGG`C=5>nq~yi%;s}_dAwVv9O2p#%Zr~*zr$w zn>9>uZ1ug*6+NA%?c*CBQ}3ggU@Lx4Wm$KuJ(hg)yM{TV4-EIc!D7On54`7UdCR>Rm1!rXuGy#cTEA&XyG zA)A?Vy}GOW^og#K)74!bYnZ_Lx;RE~c9yjBEaOiV&rz&lg6AvC`be$z+F!IJa*H&c1h%A^~{n9w!&HhsWSJ@ zyvhh#!7r@*1h3m!!)p{-YnxNqpHZwKvvu@v{#*!VXpC46EYi0a^h6(9u=5G5?#i{lkjUS5qRZt)+Nqyk1$WQRSGi!nbQe|Rneg1H~;Io~XNpl}WeVFE1?w0Yj{wk!QcQDWfDq4>o+NTJOeMZ{=t zV(4ec@D(H^hI!lPws!rVll@AcfR&#J&IX7fR>%`%>0Rax?<6&*7pNM1MUC<7i>=V- z;mN%1<9EVAE5v}6pWrvutdX$hf~_zcAXTOh z2ImiH?J{S;%1`iHebz7`>ql>7a4<5=ocM%(hAh$9N<5kID%E{0d`S#g`HA2iBw~ma z@&s8*!Hl9*R{BdPA=paF(2VVFeR$txz%P7BeZb03xOJ|-7GVt&(qhbvL+wgGdqt|? z*JtJHQEuDyzF`4@z7`~qTGI#J^_8AErR{2syIO)Q{l@fj-oq46CUBbdz}p|!UysYl z*7$N=uKRcFWsT7IzFn?EwyZPXNnd;9{9NbP0j>0R`rYp+9UXY{3>^dK7iTvx-_t{G zt7Ns$UFEKM|0v?G{;iVmB^t;~i>!9**;glj#@H*JH#d~izcCRF6Odb8lopNTBUPs& z#0q&5*|PfeoS5HZQ+X%e&^3?_Q~Ga@f%+ zIv=88zmVm0@KY0Fg%~1R*0x^dk{xgUC3F4yI~|;uET{GI>B%@%8Q0C(%S^B)NT8k! zak{lTdGzI7nRzeV;4s0bHRG&j#4u+_Gr^i5ff$C^ufE5~th*xfxcbiopCOGis}TdI zTw4|stO*i`VTesC{e!;fmbvQWt|SwD7B)`RMhtV_HWRD~5{O}lwW=PD*1Ef8_1@)@ zOz>&lcsGFh7n0>Qsp@SmC?@p(V9m&Qs=tiQ--5Pn8d`LOe-rI0j1B+soVC4;O^Ag%ku? z$u7O8Cd3!4m>8^f9yYrI2V04caIGZ9qO^xpOAO{*#za`js0O)Z`;6#4M;=Z=pgs^C zR&s6vWT~fc4ABG$&}L`uU_OF`?CUMPz>DGa4{l4cBR=B&Z>@w0+0ovMk+NNhmBcXp zJXpHYpJBxWQfqc;4iX0*JS|#f$4uXR4qAG1I91{cRS4Gt%Q3m_Z5tf zW=HE_szd|7Oh}1%W#z?SLaac`PWc|;wJXuUiiwg-mkF`bF}(fsgZbcI%LIB<>Xzsu zTr0$oJ|E5p$_MLDSjn0Za>;F1G(kdYHXMTqvC=Wz{o+#)t_D_2gj$JLAJ~UnW*M;p zUA(J&uyjQOD<)7PsmEO=#7f8Tc9jpsa5bxeWX^h2*9VU)yO;eMSyKUL zI|XBi1~HhBQum1B*6#WOAy!(Be-}x;%9!VBf&^N=A)>w2`L$nkjPD*k#XdHoOk`$_ z8qp3N2gV@d}V%z2n_jg`bt|}MZ z8tZDG)BbGz4U1^-6^am$4=k@3UGNKj8P=oWmcquRo5c@IF<pSH`zEoDZa32w$o`IIy&E{ki+cl;M z(Qt14`A~K4``sPqHe;scm7937tVbT1nAPLf@^-}mCRL(gzerX0_NAg}&tkoMWBo@G z7d}wNo_TDrPL*hwfGqJWYsgOzX1#y?tMNhS+!7+h3Nb{stU)io5Px~`%=ioE-)PTY zdRAoj*C!&Fg=e>|6RCaOUs@imol7+Eh=8Dt+wZ08G~H=n&XkSHPK4$%gD9sE=h}( z`8F%g8YbH8@2u&!YvGrA*Hp>1@7z%?_DXj@!B$$f|BSr7ZGa-4xvjiyT|OmV_DFY| zbLh*H%Ey6GR&>}DpEXqF}>;fv`EI?rv5GK*$Qpr7rgU+{Jjp> z+pJ;2U*~y;lF>dhCMu?*8TMzs~6AZ@bi%biBhAqV*k=Zo?B7 z#2-{d#WVYvoVyy1!6{U2H$C0%y=Pzi_ix(z2)07aj_sZvU6g?sQx!2<5l0olnjleJ zx_MU*v}dh&D*pP`ALC3w7Aw?$M$6NqAO49Noc(ciyV}CK@nf~G@V7*2eN0CITITXo%bCB01e zTjIisx%O>M-i$r}o1b7S$lIRdsxumDBNBR1o>W|rL2I&51 zS(jJMwJ&^o`Koyjw2oi$Ye%iwHtot7{K5*oNXJv9V0zRqt#a)JCvPg)a4aj%8sr0I zgQYg;(-g9f%buRckp4&9bF6`Gbl07n_%Vhu1_L1z{B9YPREAJ!k zpBGuPq@!C_qncH=qeuG3w_bFG%^D^+w%SQ&@R@eYNms@9%{4I^v%_H|<{ ze4(aYb>tbb&3(JutYN~RkCT6vwL891-TrlZU0*(0s2W@|;@rsXYSiWwTGrqjo7>}G zuVp{^?Kg4OFabHcPc+hO2YT%VyZYFF)EyANGz*58*y|2IB{iciJk2ERct3kHn)?-=g`rc;$J-ux_uT67b+ht_nu7-TRq<4w6EH|aNXY6!NPk~xh>leZFl|09~S;F5xw@U_{5@ppT3qH ze$Bsp1Y5zAL zpRV)0Zf#oDMUUMZwM+MOTF#-k|0=>%W+GlT&H%Hcg0ziMBUiv!UC%(S;ojqg_pD*tT7pdnPzFme2JO zY{jLZzHiS@Yu{zY1n0gnhLAd7;;HAZ-_R}Nros--BF2J;s%>~_^%UpG?i?LM2)6Q< z?yegBoqhkwa5jBBG^&W8}@iPu|4hdMPcKFv9A$Mc1(VS-EAvKsFj>8xG%Rr0dae@rmJR$TvTulsXy zozpK^9Q#k3dU5WleZRiDU6YHx(W4LdF7=kI@>J*94fhwlRkX8+HB7ABH)H+vw-jjF zvikgWr*nARKSepmRz;a$D{j-OwfDWl`QqvU$u+GT6taeiPm*UBTtD+=H^#{xlbkv? zKa%`#SDPpkY~{~+)B59`Ej<<{^S=15kTp!~zw7gYy~k&{F}}RyYNy)9Ka&4m@^F+1 zw&GIPqp0`Z;Ko^F6QkaHH|P1Z*)c6oeKgPBu9d_{U4tN`4#Y1A#7kX+KEk!qG1Rz{x;{p^8uqIRRU)bD zoC%a0tTdtef9m=eWYE zS1z5XowGBtbl6<|v{qz&{gH(FhHUA*Jrc@KVKuSb=`0=9#hXC-AkQ8^a?+11lyHL zQDf}>628R|U+^ndkfjX6F+>w2YTWi?313d$r$F$B^hjLSlz=&V*Qj#+SgU-_eT(R!o$f4<^J)VwjxoPHP-* zH~gs!t0Z#mX@4|I*vFd3zFhQ7tmB`J5_<-v$M!3EQnJ3j-f`A5sz?3#MWgtZ>@(VZ zP?T%4hKb3FIC5Ef>~c-KJ#Afl+nlfRPCs$8K1o1k1169v8Rso)$Ix~0oaG>xbZ%$ESX?UkN|Cn8tV5r)$7h$dFxHJqf9^+s|l}~ z7`HdU%907z1PRcF=y>5Z_MnaI@L|)k#y#xegPY67&MZc&-6b#RO}D1Y#KC zu}98NZc#CQQ8Ad1-UX|d2G*r~Fu|H2QJjy)?_@ZYd%l~wQk5>@b(XT?1HB9WDUM(@dQTf=i z@6l_2I)0bauS|`?fvOL(hKc7tHK{Va_Nu$4I!mrRvM%khFJnxwRRu*1IguXw^h4r`>t|s`*)?n_tfaa8YbeGnUXf+Rjc%V z&JUUW)@P49(`JIL=3QDdk-scGcB>u}f9=)M8M^zD^*7!awOPZ&q9L^svuC8o=6r@x z^xuW)&hTYcVKX&~vWAJ#_nS6`c||>^uKZiF|LAV(2b3S-BiL&5w7Q9J)$DUf6RYg$ z$!Q%QUw3HsWScciOns|fqG`|cSiiZ5@o`$?gmh3KTA>MqJ;y)NCEDfl+0l`cKhd(#9^sX4w?|49Zh7H7ZMFFA`#an){NGxh zd-+EjTBp-U?Gd6uN|=y(3ddkVtU%9weeQ;rAAL7PD-jK>d_;-uN^%GSvW#7!vhwmF znjir>^>`H|WQ4=@@N+o$6^+CY0x`m0V5cHrh3hg_hD%p85<`e%2kXTif0Lg3csXZ6 ztU${c?8UHTEXlZ_RdnsBPjyNT3_ZtdS8KAHM85o*#uAT^F#~>wee+4dd38S4w2U!c z3@=rp2@;?;Ox~HIl>`Y`ftEHMjv*R}Ap}zERNavxhL3QqKugOH$KXE5t#aM+r=y2v zQ9it~%Kqfmh?Gxux0@muYY_uaxgTx+VTx4AlV(`SlWoZI#5O?%4P0dSfD&uC!y1@LHm1 z;FpQ)hL=XBWK(NTO}JK|L;b@eWG;f0yy1r|qg^;vq6rdNDZc|?Uz%{OAj{kpjv<;L zffR<{BC{_|xK@y5Mh(Z1C-ktA-~2$9UnO~jSA+62@(?9BfR?X5$;9@IhT8?Fd>>CAtS0sc==#LtU${>RhSSB zteBA1vPXFN;I!k72}Wn8J;F;B`(*;}bcWrYYHVjhtPrE*`GZ$_O%KiVW@_Qpf? zx$}e3dslp#qV0+=q(rPBmpsCWCP>VDFyfEll@$|W1zJ`g;e7C{#`6`f`|h^A8Wauu zG9mXRUW}B4Sb^TH-i(Kz8n6a36PQ1+zD^Ot^#ww#v?uj09eu?kyt2Z3XX!;uV7~IL zWQy~_glmNuC9g6>qfR(zLt63FiZKfbXneC0__ZF8QuVG(jTtrIVL) zCd3MKXuK*eUH6xhuwp{Ktn%{V#SmYbaKG4sETgMOcro}n46o-(ekLOt_+_HxRiBS= zztNC9g}+Z^jgCPC-i)u2FH*d(6M}?T>FX)KZetB(CLrURfN)t!xeEa+$f45p>SNo0 z)1xgnn9+IOi6aq=wIF0Y7mgtsSTTWbbW)GhOo$a|=>=X4FIDX(JQvM<+1z0tUGYq` zbkiL=RRf1F)~WRfua$@fewiqFHef=mK&O>|DeB9ImnzY~iV2y0!l^pY{46gYT+(BU zYU(_Bgcn0Jh{1%E;i(C+0)1dp?Go;zL<1`(JagXw1hz5R{=#q4zTWiRfqu%`_+VMJxlVw{x1aD z7_2}H+`LeG3fG`$*sqpV>q#BMN4QoX2G9Q^k~$xvVZV^ku6$+X<(vtz0`bG-r6trL zw~`wD-j9C#>la?Rp?8II&TW^8cTR4Pmd*d-zYt=DR7vj&#}EyynE3RMul+H+w#$TA zNsQE~!b@G0T}l^~yU#pHiyc3`sMY00 z{(edIBD~q~Vt52w&Fq_Av^UE`%RzlSg5Vlc6K<&;yzkf-uu1$t-4(SE`!D{i~o z#zJM~5u$-#Cbr!6R>IfXz0zeutUyaWh0AL7wxM46tiG#hBHgWS62KsWBkz5b)P`I@qLDrP=lh;Rzy_Es^ss3UJNG03Ur$p^>m+4 zQM!~5NxLoubv^Y6hp|L*&V3N$if{DsQY9MrWkUMDm#UP6Sb@g4vi{Lj&r3uDD<--Q z{2`Hg+?9R+V#@_v67q&d#>#N2L=z-Hr+!`%B;0~7?tl1yAjC>yq@HmQ!<%JXzaT*WZ-mguU+Ni0H0)RM^xp~O99AGO zulU}Wcx5FT_6u27$b+f3io1&hW$d8dxmffCd3Lv$@d?kVZV?Eb^mwb z7_UXWR52k|AmrXBoGP9HuBuOkiD_dW2&_tU$l-j$`3ei3V0oU}i`i zg9)($Eu&pH#=hV367o!USL2rw@~rnEcZCxsL<7G}?0fJ9O`n<&E6_5=gbC5Wiis`j zp4F-K2uoHnOo$a|+)bu_ULqP;G10Z^Gl|sCmY|WRr$>GpnUE)*wfa1rNc}`uG>E|j z2M6uz{*FccI=DcwGy;fdD4uQFZmEp|0MBuOkm{m)wvgg39$l=6sB(NqJb3?D67=@U_z`wzchGt z3H?Jfuwp{WP49$|r}j>8)ZPjD{15rU6kqd;3|hVf4ilnb!v9+UwWCA+)$<2B@zcvY z?2D~%z4)t_AR!tiAj@^jTDE&)ehcM)jEcj)*b3L>i<>YZ8YUpib#JD@Nj8YUpib#)#~hwgdXmH*a%x6$<>Z$n`vGGfR#o?${XOhA_FdWQ{P zeTasw5JSG|2@|4W0d6#{a7yFzTt8z6dgSg|3!(r|mIb0S2DRsMT9iN&v1jGaIE zTSe@6I=$$P2m0I1k5sf-!+s&J9-k50TouHgmrtyJx1hbd=j@_0Z#f)~Tr$FD4HJ-C{9P&b zTSE|EU)P}E#aip*AAWUnh!Cr1`o$A1{;Cu!)d zD|I$vT=9OJ$okjbNS10j)@BX+ty?daD1AYVSg*Mty05BJv~%3yu(*I<8;3x%VrJx-MSzr(Q{C( zSlgb+kS!&O)%yc`~sj@CJM2HporpT7{*rT@< z{P?4t+&XEBGw$ShiKgjQW6PWW6#FCEEK&M+)mUzopL7pKZ9c4I%X)O(uQ`oXs(zT( zLZ1MJv{#<^LoC*$Rie%6+Oe$Lf6zp6jBEFH&im?$Tqk#COHGJ|34f~2`TnsDE7Y0W zlhjGE$e~0JrV6#WzQI0~sv{j9Os<~!Msj?+YaO;~+_HIM^!Dnp^n!glRmDV~zZZ9? z{X`XKRaS2uLo`gBv#CYmQ`Jfu>lhFHXZnWom-Tm=mCNuEY$bJr60xlGo)eS3+s2%# zEgEV<%8H4vHlLq(AYLohC;6iy%FQ~x@Y1Z|&dJ^*OK8mPfK6B3DP(+_68_{&`mYnXtHamCd6q$xw9%ho&b*@MQ02(g+l zp?RX|TW7>FpGSS%)2T|bRjq^ZE!D4bSi^oHV?;GIIC{y34x`VmW&d_w_YfggXRG>{ ztj66Yx<0B*dys{Z@&>ODe=ZnSwf>`JY%Mp=Py7>O@S|uwTeB!&z3w zMP2gVkLTJ86PY1GtawIM`_=~L+I4FWMrE!Q88hlT%ML}&T-$Eo(>deSY2(du&0H%Q zq(lhFX^Rd;&0PE0u;m**R&(v#gqdrZ5G%~6H6H&hYUbK&=cE^PQ*-T??p!Mx_6xaP z`9Gqm=UOJj3Nz~Dv_GO|uKnSq&)QFXs*2sKrI~9*!+tTN4!r5FsF`c;`}6Fg%4)8S zxpS>(n1I~0#y?Rr*FJrHgMzYk*2ibKb1f5Mg&DPJwSS_i=UUOQU(Bd`{yq^+J=cnc z3CN>olqzAaWkRelqb?Xzs)V^#H0&2M>gbJS+_`qbiaJFnZ#|qW*V)XqqG1B^x0TDv zYU#N@+P7A7?K$pT%Y;~AMy+49tUK5KG}vSI&@prVgIZu&Ij&XD;g#+C)%o&n7Ou8sk=73pyt}i>I7fp zTnM(}5(&?>sArTF6PQJR{jh8abFC0;#pM>BYo!K}5-B&#sLp_YqN(Rv(J%oS<4Wqe zmI<-Kj9P!?pV8EFt!UUUWQ?e$1~-&?wZpi2wd}lRX0ByItT3alQ{%3gYYP_NU2t5@ zwGX*-t!UUU=EnyH{}wfKZU6O&qNzpw>{&OPxmGkxK*n5R=Gxce0}EeKbM50j&0Ncb zSYbx(vf@zG%(XMJ?Z|~{R%?)J=33FPU&u1U=@oLGS|QtNg{=?xNcmQ*+fP7IJ3$hpYHCkX`eAsWPBLdq?54D2^Agp@8krGMHuMb1SN zBHVfk$G}c(SH0U+$H3lgEH9YUIYkVv!ERLlQa9lkqCu*dkW%*u{R|maOo)|SFFMw$ zI8{CZ8dxzg^TvMBk(JvQ6L;f14y@9a&huIcNHy`A7(Xm}+>=X=A(|kue8TkTtk#`UC~xW0>53t*?0eiaIFweN+e8(MyHC1#rG4-sP3IkG)x)x|eNPu_}_c*WS)Vh^>?i}kNLRiBk2 zrHB$;{?bFyX3b6Sl2W&<-_qt3ZolNAc#DOTeD%S^BmWwk^Uk_y(fl*eqi)^SG+Lu{ zd3)v41APQr`SY>jS||2*n}hLoA6~5qNjnq2-!Lam!tvX!?&hnv?Os)hSMp^br(;iF2=iC}-w? zJ5t3Uf~}BR=_~$q32T^;x-tDcm@4tbR$BIY?cfoW>_yl2waY&{J&$`7_Z7L`_lZ8L zK6-4eZP(s=h5gXZb{km31or}!kBw99C);j`O&|D3zN7@LPh_N>>tCIsv@h3Qm2r0b z%U3T=u!adP1h+$_dsm!L?~wlRI2% zuYGfW{Dbvfd<0whTjJc@&UU?T8`+=C_&CBECeSLSy{VJTE;+}3eNZQRcdR%H>-K>&N!$gbz zIgy#y^p1Y{eLqFiT-DI7-~Lj&)#`I%tl7W0V`OGVujp#4pU;zJeY>!MeRumV_R>0^ zML9YXqt|wdteVj?di#*RD#p?~&b9}oceR~Fc^|=6+YvSzbpDcbBAz?yx@nAy7^N7TCle4b* zHq9>k`;|Hd6Cxvq?2WB<1r89RVZV^&x@FZ}o4)q9mg?&SyOn+$j#Ux%EP)kdoF|PK zvTKNCE&B7ayc3GBTtYNVK$h#4_3pkp`5ney>0DA+PQTR`4O=0G>@yN3M8gDRxvt*b z^_-aBV^eu2-q3uVAR4wp4EYUzm=FyUkmb5%wR-Wf{O*<7CWjrJqVpjdwn7Zqiy%yh zh6%`WUC&YZYOc-1JpyYW#W0acU=s`kgb-tQ9BloT3o3RgG4xbt-Cxv2UXlb1l z-8LSh&l%-&?Sjb0*ubx<>k~aibEKR)RrRbY(fvj0WI5jntF?L!w9^ZoiWi;y(RY?9 z6BQ1(j?C;@ExN4%*3|J41MN|3o{D$h}*{p zlJR==Xx}meRg9hLY}xz2+ZV6$wVz-s|B1KP@4p~^VZUfWf%$nBKkj9flR`~Mg_@AxQ+_m3}~Py|7QNbfy?BtVk8+Z7@79(os$j+B59>Ag$U zPz41<6bMLYm$_RyB=laSN|W9}1mQQ&-aW@>?=JcN_=DH;n&)}XGv%4t+1bks-{LIS zqOx^cU(94%RKAj_b&Wod?*=rZV!O#N?vf}eiSd#^jWODk{Bk5 zP)VT1n;^PzFN%^j4=by0Z>wsW8ADA3(QIYzj|s8|JKs^&oRUC|H$k*()#rUbC+@JY zOfAj3BF3*pmeGi%RWfR5mB|Flt{s-jQ)>i$CwK1jZ_69cC_epy?kFgb_iUg1UrG>URQ+DI|!5YJd+se2f$#ZsFY6PA$!m^@#KY2{>_N7nN z%>BPKe6R-dHDvdxp8tb@vp@Od9`m}LYRDSQmt;N%GBhG zL-w2~mns1#%6&M4uJVE!>)%Tsx8zqD!@JuG^JRao6XjAR;6ynpt&6yStVn!w`TgUq z{Qki;Bn{?EGPR-WUb55(w&MP95wg$Y-FmEprfxLa4_{-AO0x#<+_$c{VhxquBzsOBN|k^Uns`Q)t-F3!@VQK3Y882#8Lg1; z)NGb1ozI>0C0IjcXM$u-SCk5W4hfD)pGEsi-ui}lp%ta`X_&qQYmgwB(-r0KFAD|_ zk@**tx65H(XhrFK4yZ4|8YD>Obn_iWY_suxqh7$ zeCv}cY8koHNg6zfi}OW)@>egp>INwlM`(%C2j*qaaB~{_R+QB@^9PUb-&$L_HM7x! ztUI9z(Y(29KU+1S1WYmgwB)6HvY*3kVp zEYnRAc+-xVKn)UHD%TAaxY9=gt?VT(?sA6PG%oSpq05)8|7DkCa?NiX1^b;^_ue_5&w>wWlEpfJ)Pr`@mJW5h764C zC4W!!pZql-YRDfGB=g^JE6U3ov*ORomFsi4x@0T*J9GBueb$dJ!RPxRK{BT+%7tYy z@gee!=)7EAvUmDRc(kH)KI_MqU=0!^bGm#>jm{GPseB_Ezf}Hi8}dRcO6RkFdre+VP0s(>8`)q_L&pirwbv$vWplNoh6vw?6%1_Jj@HND4qX?Us1dX z)*wMLrz^^~Wii2>WPb?xriyu?6{UZO5^sVvNRZ6wa%Uo8R&aq5p=uks^M!e#l|g%A zciDjX;iDTZtVS(NnmbJ##$UyfeBSXuP=r_Sd^0gt+w1;o*=)PzO_weDCk|{jT0;#I zB=gw-@^58!?cNg7XK5Ju@Fv)b(&_X9*I&!xYKiK>RwVO?GW$RRHQof%ijw#)b}N7F zLxN)uoV%U?iuG!ni)t#HRsuY zWlHB$r+f+4aJw7H?})69T*5y4iJ&ZlSj60AXjWKNgg&s$`PuP%RSxMcZLC+3A# zl+LHd_!6u^f@DrN&+V8OT5-B(X7KD^p#}+-U4*qomf#Rsj}yzEs{RPFqI9ac>tqUV zf;E^g$(-()8FZQ%FfX*Cbm|+|i9qg|!9%bH36eS8Gc!18X287A%JA(T^YVMG>#64C zry9=&NRZ59Q&H&2+4NQaM1FE2!B*shW%C{YX=vPWyt2#$rTY>*Ca5G#aH(9x2Dwx8 zuN+OS+4l=*Z)MM;fdE6Ryw^ZpfTs3dGfGM_t{I>AyFkh1C#8ubdRHsI;73IXT`JEFrn6DvwP7B3(*N1Rh8R;IvT`HC9foF=M%;%YU zYVMa{EAqjzr*(}TVboCB*@|R7mo;^Qttcm!HG-`uCzg#K zjF+)XJ!tq~4dzQSpJALj!B*shW%JB{8qC*_-Dep855jF_{2~w3>x*o(~VK4 zQR`}})CjgBA1oV@Nc};(=Y|i~P}xzYdBxO&ehIcBA1oU)+(7dOd?4^k18S%qY(+Aiso**lI5mQ;C?}RZvk&#Th8oP5 zWInMXb%L!ZCzgHZUDjZ}hCk1l9NzOT5`4mjQCbhNT0TEMm+v=aNu6LT z^1-qv+R2A8K3IeKvOm`tl(9=A$}d4B-p^m=7m<2{(_WlIQv1nW>SWu}$q``1TO)r7MsNjou(JFSMd`J{i-OU=0!^bGq-|5^KK0iZw_W{?27E z_L*F7ki7Sbd7%}_oRj&S(83C(M`9b8l+n-to#K@6aAnx4$J{ zE!VyeYmgv0)Kib+fZXftO_pUjYod~W|$~DJQVf_^4+m>T& zTR-ogxNDSaXPfgvE26u{tWI?8Y;XHsIv->hb|p=T~}((J?K3`JDXYE&Yp4~NAeqyBccWN?R^;QunVIgraL?ptI_+#@UT9^cdrtCn z?}dLL+!`bd*+bwL6U+;(INdY*nD(#yohiz+i*WB#>$J0td7+h&?m1c2hj43?>|b$SXhk`FAaEr^8XBn_?JP6l+KuuiFv6K&f4&5L=i(EkNXDDxINiJ=qr6z- zO)#w}x#eF}txg-J4sf4;#Tswo!|iq61Z$8WnbQ>|N}j8>L4K>v>psVeHE2bpg|Co8<&k!2&@b5|qA z6>7*I6Nc;|Fxq_xx0R9ZxvSBKfSVWj)=0yLhrn@;d7%}hf4EuBn_vwRBy+mC2Qe?S zGSWTw-Qj$N8YB$aLwJ8h#@~2x>rzf6uWC!Dm?ME2%GaB4-ROw~ow7$(;omGae*57q z(~Z|=ADk~~&>zdb1Zr5Ova|1$>p^*;70Xmw^Ora=IJfZ^9M0nVjrSV*dkm5}Jy+Dc z6bRB#otR+Rb$1P}mM|~0qID_JzCNh7{Ffmt(_gG-KkOw^{@7RgpQv*mQtE}&T zGt$3WNq=REr9utUL}d7)m{F<@XBv1BSavQI)i&jNpazMQ>wyGXak_8KQG>+wet#MD zD8HzUS3QtGD{KXGTcHMtvX^HYKGGbazc|F!MFOqNt-E6F0!!ZvVa~X;uH6s57hqKG zOyW>jvBX)qT(?j0`P=fncJ%D{z%e?u(B^+>dYV zVKZqidA{|t#H4groaF{|HxXz>(Zlsq6n)8D%OUL_Xa5_WT!i~}1tcQsW)+97=1AQB zC;51pW`q6A%}vf`>-w7rw8BNLjSym|J@9S^w1cA+iyxx&Nu^vbmvU~1Q zl&PgEukRc%QZLaizc1mo;#rAiJM)^Mwv3;++zEEN1;ZFCZrL1H_-=q2BuJ*+1lK)3 zNKkp{cO-n%2H(>`1gD!zg&Lxns2dR`+Ia2~LLb#5iikklMZ<#d)C>jvI5SP=iDhEuWY-Bhrr# zB+%+{sX*~;S-2nK+@A7cO_uA1W_96mVrsd+3>o{$Tq@Kc(J@ykvGDIJetaN-Rz12F z7v~#X_Cuhiy;@u(RdHEiPAQiPiED?8in{eL`SF2yp%u;;W*^v!*@_nsF=a!Ie6iKd z1Zt2daI=8O{jjp1QXzp>g*O!zp?bytL6mG%O2qUjX;|IrSXw*_E#Z%V+48S8mBpG{ zHw`On5pz9IgG4~hAknMk4L?4RKr4(lvk$CcSiT}+UWlu<*dk^EHAoyNCVvrMt*oC? zA%RxdZe}0YimUBqMd*T}M!uMnnLrH^S2vUsF|&&Nk5Zx4>?;*Ts9MkuLC-$C^MzM> z?Pbq$y17)SA({y+ZOQ~%Jzg3lqK6moQ!3OTVXlY1Pu?WZvXc6;f@{5tx218}v9$8s z^MDuD<8{-knq#Dnn%1(mp?!;sy_sAqWX?%Zetf-6tUPo^ZP~b|vA$vr5+t9z5F&;Z zre+S!H>6#()D_1zm)mYmvCE=k7o~6@%_pJ(#M`Z z!foB}w$$1#&7z|Q^CkI1JxcAh#)n1K&<;&6=|iv;<;1e0^zPp|KKhU1+Wr$Y4T3e8 zFA>~s##@p38>G9f3@fj8^SnFz5TrqZWzV~Fssvfl`zQ5{ua8-~UL=xc&GOO4de>V{ z3~eV{7k59As~x4dzSpG1bXM;0oD@40oF_P3=SJd*=t`BsD)_Qy8`r|yIDVuI>Htw4mY57Z!$ay^hhD^Bgz$4H6{t9k7Zr zCw!#!sxz(rX^!?j1Y0#GA2-j67nkX6yFiZs+wPT_^<#}&>!`teN&avn+xjtW;$Jw* z>D`>F55ZPVqz{(m7v60V@&ApD(sx}hrlSV)Z6=Abf1eQb$I`r;D_XO)ztvJ-z9@^1 z8YD>OJLBZJQ2~ua(eyp^hZ8P3y$QBzL8ZEKR7|-`&yVQ56Rkf!9Im&`QN@WG%$H;y zU5c_bI(WSjI8xuaGth@%D;{aC`<-#rQeOOaN3p~*y`_@u`pXw2P=f@^zQozEM@C-J zp?7U8?;h~n*tsPily2TtqkUY~AhC9OJ0qQFGr^^*FyNf+r$dhoe<9K5ZN>k5ZO8)y zE~G&4uPn$P(bytpAE-fpDf>VItvKCvZ#cFtYLGZFIE_*Bjk1wxw}J}!iNmkKpVuX|kbv0QzdRY$AE z4;qWQ+YXA>le~#R`M%WmZXO@m?D8`w=7q$lUmA;rRSt?Y-%x*yST$Uq+@nY0xwKXj zfmYbF@`S&1U+If~ZDnn=^@j1wGm2M=A0*zbXe`cGIw*P%As_ih4bwZexLvVH`EN`F zT9FUFn_k|o@_4wObyg*H`>k>g)F4rRRYS3+z&>&J1l>iqJ7SbRW7>Gz#2=DO1X>+C zFMpr7dbfBIPDIboN9h5%_SsteI?;g|B$B5!6nD1l6_xi9k!Jr${n*j_*1m1hIgvoC zpZ{whBKGeRK|P6xRC4IwWGawQsAY2<tTt}JLssX z@wlakU35|$8F#^kImr{V0*mX}cQlXK)HKTE1BoMZnu|NDj*Fn06uV{qXrbHB7qbVv z%w{6c3Zq9+)}0K|SL;#MGi9pjs6pcEubYdh6OM~CpOcT*0TKG^Iwh_4>y=CdTHz=# zull-8w|VXZBpL7RV_8w&eqUq#C-Rq6|H$95P?Yekx3l!0FTollNal1!NjP34 z{_3h0`tS0#8_Wx>D4p*`_a#_^1j(GPD3`|#jK6p=hu-&4wBds_XhlBwenmyuJad9$ zM}eo#_!pfGf;C8x%y%lv{wOjvetr9RXUPczd$QG-O491TV6vz@{+i$>JexI3bkHLDi1 zsilsZjL&2rZ)dQ{(^pI9}D~3@{OBWp3RC zKi62lSpJ4S*v}8R@2U1CSc3$~oGxbu`PWYShlW36eS8 zi1uK2w7c302(}_0G(KFnnalBEB3Og@lFaFfBFA8md(;|n#Tv9CA0J}Zn_vwRBy+lX zg-jZJ_HiVV4^B4|s9~DM3a67SfARZtr2g=sqUNt$)f(fFtGgL{MdQa8Y%S(D6z7w7 zdG3&=8P0VR=8=eDda(iDs(UO;f+=6tuuOOFVor*(tI0@x_vagJi6`$nP=f@{sPewM zq<;FGNj;puME>AJ0^Dl^^zfAJ^`g%ls6hgs42lxi@3ix` zKYD7lGxP|i71`@I$zo^J1KSdNTM=+5Sl>??>S$z@2Y(iy)A+X z(jbwoPa6?9Bw2_V_a(7m;eXBy-8yO?MJ|X$0l8^5i7tlX-)YFDeP7Fr^t@a*lA=X4BiI)4wN9OjW^gHvu z(EdF&X*+6=xYnz=D7*B4IC|=yB;LNMsSg@cT-$Rc(1HY7VL!=Jnyc5=-)%3V-T3Ta zBx;ao)uoBC(*L?U`FMV|kG`tdes$aLi|t6D6-K3bzT#M-7)2|3Z{s*N6R07%ZmZRP zq8(>A^nnD)YvxXJkB`&{wxXQ)K5=uYP=ooBocnmE6nt=AXhp5ypSbt+g2cd6zZiLq zpEV;f;Ov^FvU+civAiG*rU# zc_te&(QV#N_d}ot3G&S?ZLS9rXho$;+Wnm$AE-eB`^M}83A9SN=BPn}WdHr)O|TWV zx*UT+qx9?fZaM<8m2&*_uZ*~NUBr#u=WN~2$-jvj+D)9gc+U2TeA}Kkr<2IHjAqfz z));+qY(@2xOp~0bLE?1v#^R(Z_sKTV`(WLdBlVfj6z7ugsm9ZQBC*Od*De=Fr8yFd zZm{WfkCxXRf9PUsndb6ab93=a&x^K$4WdO%qZZ;#@I_+;e~8_01MBETmKN3z@0=+x zFC@Ck-Ih&nT8g!MXm4rGl~($+?*sIEOLChCw8|oPTcTgK5&vW$V)V?Bdb(V{I(p~2 z?!emec%(X+Yu=_(tbQ@BxAU2Gjs-PHU}@zS#lm0dJKNuIJ_((ehy+?qSld|4>)Tdz z|C?I(()lm-{8^tkcf_nqL=6(=dgMBtORtd7RDaeY%F-yLg@_&!Ehay>XRFhrjhHv8 ziO4FSOY|h#^mSwL#CWRRK6}quDWa=hs^~7`sm2;4?0>Ws(I*;gS5rsfc-@6{ph^RZ$*YD6ebVZ2Gnl)vQ=5wxW`3so7lQZqi(|Iz~@}Otlp~d1?oJ z_w8R2QG>+fM=eF}T+Kw%J9?^BEm&Od(t^d?wCvC9O>obLMu$r`jGA9r$0bngLp@8ZNcAK9|)b7Yx$-Wk!myFqZiM*FyDRGgLMZf(LO$D;jDoWnA?d@zAlIOofo9cmSD z?D{T)-YTfAj+(56m!jrU*A5HkB=<6=juo$0=GI%}YicSL63qh|icO_EiK;PF z^D>zxZtilwxZeNgnkE9R%x#4`6BtV*b1YF?u`GXED|gkp9m=8Ql)GxEL4xFkxyFjq z*QiwQW=wGGEbvs_D0kJo3AQr&vI1}!@SUn(kXUby9V9_ zYmgwB(>*&Ao*fp}pcVO`R`htU)XC@ga8IapfUc zg9OQ(Zr(2;4L+AR63GXrn+en~%{wei%dceDN9!N|)UCqOdd~z|9V(wbk#QSeUtf%Jd|J7%i9joS`p6S7XI0V*)~RP{*fB!iBfp=;h3vFf zl;6*s@;g^tG2Na;e&=eEL%vNks3&gkpi(VXs_DO8%V>#B7pkMx1o^c$?&w;31NpVr zOjLSaL%-BESL8TRLPre}59Aw5T>828P@~_D&wbSd@8NO{~_4GPwkigQ) zC(gSr`u*VUk(;;QH}xP|6M*E>S@*mMMm}0Onr_7`j#gGWht(I^58>8e zzJ}}}9*-XwKRh{y-u|%b%V-!9Zkc=}gq5;fGS+bSrc7`o$uFbZ++RjngZYxoZ75fL zat$|5ei^Ok{xXULTNyswt!`eCp$7A%^bh?J9vHm6%%`Qaxktayao)w5n&=gKCq=m0 z>Y?_D$bBYI%e>vozMNQ3zrvWM{g&S@gtR^)@my#IAO zYcOAuIo)^N&Kk5L9~9I6*X^u9f@DtjynlGUbFv1l$Opxn|8+ZSkRX}Ueb?=*K`Zh> zqrm^Voi#|1%;~=CcGjSk;lmTV-s^UXaE?S1?IG~DgOo2n$ynx9IH&ur+gam}AXm2AEozb`n|2q*i8=9Ac6Cv zd^d<5s*j1!>U`9#vl9ul`eCws8dTgCad;f9ujWk{rQdFJ#I~bYpaV5X;5_g9oin&{ zMLU1z3=S=A=kJ`9?*BU{Yv?;C6ZAbYxJ3cG>pSP>CI30gcI~L`sPFpD$pl*Ud)86} zmrZ9sVtnV!*e9F5RDS0?H)Gj$)F46MBRh=xcf0F5XSHeq6ego^R(DTG+Wck*Gm}zDIUgziYeeJLlup_4N103TVgQ?6M<)R#y%; z5kq3HY`fKkz8lntsjWwiD53>#47Z~O3HpNhO~kZquJ4=+^LNv$_V`=98kIW|3ADmk zP?Yx<`sjCZ?^nkUdSFKl610E4;^xK(*Ejl?FGlLSA1mrtkv%L(pcO}@i@>jz^xdiL z;+GbVc9to9(V{Izx_M4S4du&3>OMFxw4$7P6+L2%G+!UAK?3WPGJ#g)<3n3v&FL#* zujn&|clsXL>&0_JrtfO(+w221NYIzYu#UO>v=tI)Mc?!i7MAftP`(##t~E4N!q2`- zFl3@Xd%oNcff^*pH@CXE9!Q`SmFlxI-}~`_8YD0l%s!AntCVYw8YD>e-yhxtTVbm! z%9@`C>2vdZT4i|673z*X4aC-~%dA7JuDQL{zU5Ym{2Zb3yd_6k>%u|wJ?h+wLHg6~ zf$Im^R;Z|_Sq5EO06H;(l$}Wx`I*G6^kF+kd`M?NaAJxu#g?XM5MFZ$ieXzgY62bF5fpMM&q!o1L`Q>-fbbqKcBobgze>Xjra z)hb$Ti*<#H8YCL5t0%HYX10Evj_R>j5+(n;5cZd>2NGz7tsqZOmc-pc2e$NudfL2%Kcx@)8#Rs}jH{{di;IRgOIfdVp}5*DeXQ+WIP9JzP=mx*@nuEBv_aPE zQUAD!1`%I|-S;BU>SVi;qT+@8)*;tkOM-gPx%SV>*Cl}(BoY$KiwD<=TXT$mB?)S) zdj*e&QClH_R>KaK77ZKcwr+h*rJ^x-?m*|z3z9$$67AWo+Z%CfCO4yyeNtI ztk$+|h@hI67W+2bki%>r_52#KJm-KVD70o595`GG$xdaKE zSCDqi3_+W>88ZWFkih715#@KD4t*wlAc0mCF{M|HwbI<>>htzJ0@u^1MGX=p-wv7L zp5cu7szScaG+%iWY(*s;>YQZEm9E$gQLb-#C<)YHzO!eP5R+few8jQev{NKrIC3cL zsuzJ))P}VePB&WJ)gN`zpG&wU3Dh8ATUbg=&N9zBt`9{!_28zjvu?iYMWEF*8HqG% z7kVObQMvTT!&?Jj<%D<+%Ft!1$1x zl0Xd-oBpXFO1EBMt&yFgo%*Aun7iq@7lBr5ZIwl)@pG(&hrW;m&56wpcB^tm5~x9f zqMhbMSF{uH^~t9RrzL@7qE_R|V%@$#>-+&PZ07N?Qxe~fxg7dZ5~xAq&8861Fnypk z(|!6IMVjHZo{LJL`3ecNqS=+kplhb4D4CG)rz$jGp#}-Dv#Mx#D$v@a_cM2&@2b(d z3PlMLXk~tWP?W4Y8eHYJBv6CItF)0~U0@;W{;;Rg2d$QN9@aP0Y6%In!j*w}E}>^e zcD1qbWT7Wq#V-AgH3P1kT~ms?az`0B zj}P*}1Zt2VAM5J3agTGF8OB_wkw862b4kODEsR-)MsUN9-Hm6TnV@z1`HX>8k|co| zBxnR@AKTR!^R6h_HMmF>@___eVQF1a^69_b{-9?qJ|XGpT=COBR(k5=^U?J@&ziS* zSQj07#$}&EOUHG?2wlv5P(HdMo1AMGX=ZV-4H1amOz8Ah#|OXodYR@1m1Li5bsAE6KLP zxr9a+)t1(uI7_%@AN$%i3ABbo4H8&FR}b!9ema5Ha7dsP&2ZG4^zP#>)zs`&s8pyy z0^891woPxNlb7x^-evJEmEN5>-Atgyo8Wg`MgAM3(f{=Il8EfrMp|!jMe4BD&#g#d^hf zul4pZ`S~m(FS4Tc;Zjk0mh1E@R(}L(ya}4yW4qB)Ej5Czh~_r*LtshVy-7sM{c*O< z1W$jUkD+fCSu+{$wce#-4dsgjw}Nk}kYFpL6&^25sde2N!wTw@ay__Bh~QW<=>J!L zAkpx(YP5)NJ-BZvU$!EdBF2B8v&NgCy&C_0js#l~y{=SP3Z;5AC(c@D&nqLYwjP*g zJ)*ugWEx$UAJeMOy9ZfA`699U@=R-?%JgoK8o^dXQ@f2gLF-*_f;D7?1i!tcPOufx z)Y3n7qIJ8s4~$)mvCG*?n8KWh1Y41hl%F41^X$Va8u5yC z@~t_x4-$vhmQ5iNkzgzK;d{`Hx*B*ox>6 zv#7k&B1qn@qRZP=)X;lTmIl?@CkC7?ZllxRO>)z2i51fnl|A@tM9uC0mZ`4)t(4K; z?Cxv1HFa#_pXm$P?%nNVIaKYN#CA^#8T2m=$0iodR!9<;6WUuBjM4rb6cSX36iR%s}p01{_-n71Zt2-*#{D6MdRFmT%iVui5n+I=FVTpPpObVD~vaD>!JpU(;vN# z@ZVNQpcTfk*$2iC5#0-%G)65(sF^?w5>&!pavyWgB{&9=Kr0-ZW*?|Qf?AqOm@-P-gYfl%1X^*rZ_QDI1j{a>UA`*niv9Q3E3bxYZ@x@#nR%mw_4aQq zZ6sH$RoswIhU7Qo#=Tm~dh~5NMr}6oxn<^wp(X;YevdA0shFjubzu+@UG`+r7W6)y zc%x7&4K+w)eHvo;s5ZETB!VX7)pqL^`-;|$Oaxl}SwF;*{cbbs%uz(_t~f%wxN_CH zRy8ZB&oX~u!q9IBldg^?d-`*5m6UQXh@)yxvlWMmPRVCigDDE z%E)n_DtPC4_a9~(@sR?UlwUj;4+D3B1mnAK+@^&sF4pr-&=(-1sh+Q8? zY;(?C7=G}sw5nCtlJ!|D!;1W!-&V_z!xCCr_6D}{Q!3U_zDRIsee2;(&|GWOBlrCj ze6)3I*oTKO)qGr+8sT-COx=d{$WN`1q*@-VYHg~Mt5nG=i-td{)nyy!OSMG;+tA&* zRpx}PA6G6E3ACbgYTfrM8@StQYvAIr$MTB_YWxu&`hz^tHx@n#n9mWO**%?u?kT2p z+uIBd*WI!hC8!~JW^{mqZl=YYT=huN2M`q_vN~M1>U#U26JohkY{fq4q+|a+NJCag zko;TUlm8Ebthi3Mf=~M)&O06$_iB?B-L=FejGg0|~YwdTPGQ#@*|_1Z&6&iP!Om#H2LG{rEtF zt=NbE{vd+;0Ex1j4vWEkPo&_33AQ31RKFB@(5)dWBuMrjSDY6U9Anh8mFk`GQz{x0 zI7R6{h{VB zE)UhxC}Y%xb4M6+3Ew|PRvgE4wmRQ-r6@?C#vehW%UmiZNP}m#bGqwZGHff?X|yKlX_gXk-T;DN{`$l%UWyGr)44` z{s+NUMCU!S&f0mYKY}%6g~ZVOYphLgUG?Jw3AQqPEZ<-q`wo0~YM$4vAuA*%-(PQC zx!(OX+*ETU*ox>^CpVdWc-o3JWQD}!$(zkSJf$)bZY!d1uiBDATd~IQ0YqHuL~~nt zd?3MAM91~oX6|zj!5YKLoiE9y-#X2G?w4RIBDU_|X2h5g?WRa%4dzR7ZKwYKAlQls zD(x4S=&VT@=q4Ym!F<_Yu6qBApn8xM5!8kuA^r%~V7??%OMh{P{@TgAR7kKD5flr# zj?>>@c@wO`d`YIbdQ`-p4$oK4%T)OES$Xrat%hK!UA^;JMPbKUjnLlFT!zFA>;nt>Afg zP1{W#`E`c1!vDO>8p;=m*ad$G|MM;qY(;e4FMbpL=Uvv26%u!zuNVI3T_o7b@G)Su z@IUX?b!*59iSxzR2>!_$MYZVg!>ac9cUW*=iXc9CE!!$Xx;^w< z2G4tK?%QfB)~b!8fbfpZw?AjX3n_fJEN+p>+RI!WzmKiCcT;Ci>r7LV~S` z?jEx`k#?qhqXbJ8yiBtn$mZIOVt>8_YbakNfKKw@s5!}y6knBH7yb0dRp!)fb5*pjQe-YC@gRxUUrQ+!E zZC%!o6%wWT{F>;0F9Qj-B0A-Iu!gLVposAwyGXDV(I0BQd~+J@UP5~)8j7LH4J0 zeVN#y`-|=Aexvbmdwyx{`$^TcLKiOEQNuKiRCB5NTyCaKEB&!Hb6tc53A9=kqKMNa z4{QyOAs-9%*4mrE%$lSB7AtCy!1~FvMK<=)8lT&*-aFDE9tpH!+2y0EY^%9(q3Y<8 z6XQ|CvTs|ZL>R5>Zc&z9J}jF?YmG*)PJH)il;f+fZYIX3AEdvLSx@^W2*8h?cU{s`VZ&^|?Xx2WF3{`8<* z-rUK)_%DAo4|cZ+`3o)`WAET}6YlY!dh419x9|T!U_gam{Ijyy**SHN`ZG{AC{1KjgQBPda$hn*Aqiu9gEy)!>`=uJo_hh=W_B2JP$2dPkuW=h9$vc&**H616*&pX*?hmqJjW@xiO`TvXq8F{6Y1#Vpdq1UO4Ot

C94#+x`ESu1jOO@FOxBHUI)_i|1&{Q3Gw_-};ev(FD3 zB{>fB?u8XIE%Wwzk3q_dHAbm`;Af(*4-)}T&I=o4EVQM&esNATe7H4+4^t3}S4 zI7R6&AsVMu!gLV zpxE^vS4glG(J4m>Yskt(c%uDORKuXw=|*VVQXSc^Ws*TDu3mT8qAkujvhrxjbJOviDgCUb74sE2$_Gl^n)7Amt>By)CsntoLE+r z-0`!5SEmhA2gvq94d!dep4d&5fY|*I?LB4Q&C`WB>~K5QFeL?i@~`?+_=dmnQ~GT`WpcHTY&x7dsSmA(xgw4 z^zl)4k})TFQ><)R*@OD>EK9ed28p|uGl^i^7X~d)Y2KYht6#L0UgKtoa3s*`?lDPp zsBV-{QARClr7g>tMc+5B^fuHWfi0paod&elYH!J`XWcV490{~4mQ}XlqTSnF>ykkE zd$^I&m7Ha7G~b3AB&cUUwC=rApQ>@;%bndr2k32!W)z3Aq)*IxCfSxZcUp0%U7o~c z`3~A5>Sh&(uI5PG{wK}t-IrESf1cCKdF;S9I%<#@l{=F-6jm&8RxX;`zpcALjrgpg z=n_3Zj|o>qgB>{%4^%v8J1O6`4zwV&( zMTHa7S0Nt@hZRwGHPW2hqK26Wv@(~fXy_-Zo^zJCS7W4(8YH4Ry-PgwTlvIO;pAiQ zlKiUU`dv%u3}ynY>Na_sc<5xUMAypl$IF$qm*eH#GD6z;eE2L{o;4Yw%;@iBn=`tH^Y4PqO?{38_NJngI+9r{c(}B)UM(|$Rv2%JGHH6a*5T41$GyFk z^sPN)^qpI1uYcDZiDp837NUi2-0kSmw~Aiq=*J?i(*^sEBFVO#ij=qiWj~-M+c0|M zos%;j**0zt(|4Z<(G$<*5OI4B*=s#dvLT&RTh7|`ci6v)Pm(>Tc0Z?1dz9qtalMHy z7H1W4dr#QYRY|tJdn;qNz)t&>M#(mF54PKJMomBbu=B>CCZ=e|KF6HouZkzmQrj<2 zqpN$&K9Il|Q6I9O?%F9!Uy&So)miR(S`j?7xxMV(WLw%fvK2Z_v&UXe zwvG8Wort?V+1_m|)%?H4L$q!4Us&F?n(K_ZC5fX=?eBLd+mJr8Eu9FC?PSm1hlqcF z9;WsBwyLAer2bBNat0soVBcwTz2orvPGCk!Y^`h`kU&JS<3oC#L7afuZch_bANoN#ATM@#kT8#^uq`FE`~4d_FKAZNs6{KBV-)3SP#0}>KwjtU#n+vv zK>|mxygT#LXe~>rB96f$bDCx!w8EKBt^gVg(MFB_$oc5s(WaRJePHw`%3m*PXlqY5 zbAI!20Ub3+U@OR3bXiwz$RCN$n1~ssxI!x&1@iA}f6A$48F$J#WL8xjHArA@D$1F= zVOpK|ta_igTc+`WRyZ~lCG4Xg)PEZW=Ops}8U5jYKgV{H zU;EyLs4Yvx>T@6EbM=S254;I})0g)_E(_3tJG9UvUZ*t?lrLLhyD7>bNjzxZLND=e zTBAR>4}1vE`}vX0t+a_XGV9msuXbQwhU~V&c2krhTU%*0t7q1`Hec;(D|b(M6Cd8s zXXhWGC7cX)hCVneFfWqX3R_yftI7U&P@|G_P2RHtHQvOBH+tMpd)!dgOtHrF>((eUy#fQftOsv7B8I zsus#P!bG5zk(2uioxF{kcc1tT7K1EZvVOw)x1K;N7k$rGHX-zv^EiFWz^hL zkDm1#P1(}a1Wblexg{Su5AIVT2pRzJ?BC%!rmtD^=9+@DdDiwzTO#rJHnj8R6L z2(+3S@XmyxcbCP)}3qB7r+$igNQ}8tvM@l_QTVZEqsb3R_xH>OHHe-5hqxHlR~c{ktrM zMU1>TaF_flb2e{LLtfS_*>=585fQqhmON9R)`>}{PuXe=YG<{?jnc#N6%q47DjMHU zpJXc{V$uYOLh@Vc*ds+n?k|Exg0ZXiy7Vwx?H@APe(pM2e_Se1JX;oS5HHK;7xR`x zh}P8(+OicdAY#gf8tIDi&6#QH*-W`@KUu%g+b{l1MAwWGmd_5_nrQjNycv;()xVGP ziqIcwioBuJA6aXzw&_RTS#23c>!?BE%H}-cWR>b}AJKib+v@i|VtsLGl!-vAD(62D zn;O-06G4-rZClEuwe_AnTF3HYJqp|`AaXyf>?ST3>!=2$ondQHFIGnl5-sfo#j}f% zqDe8T$JyaeY|HQbWYc<%G!bZ3cvE2!s=HcU{<5aq7+d8EHEkkvw5dO^4-OP7E@G;c zb(d;$QhW78^eWpog=2NpAmNx$Tx|Nbl87!vrCQSHoUOvF1l!@+BTWQam26Z>-XUMo z?IUK)O;C;4zP}VVkTyT)#If>B6@fMw~uU7_Sq)v++*9d zXr$ggB1}9x@u{IftLIrOi<8ks#J)1*LyHMkzh7U|R^sk(9W_XxKSf!Z{wv$nbLG_j zX-1d`w0iq@u!#PnxR@jN#pT^KuPUio@?=qq?u*q?g9NsMJgukF9CdWn>1smnJ|+UK zR^69vCC>~$k&%2n{`(WH&*dBHr>`sOhhCKt&pOE3|C(&O{GzM~&0R%Y8kKCjn5LX~ z_9#pYH}*w;p8JV5Xx~k>_sfbpS|Oc%dRcL@QB_fO0L=^&tM}C2MlMk&hs}51m|R>$ zPp>UHr%ATOEi55IkJlEDXC&DwsU^k9Ej2}v&9rMUCxvSI`KQ^VBZ>ZB=i}Onqc_2{9s96Cu&KDE|~8YIlE zo32t#ty6~M>Z2F`nFzFc6<H< zRkx3>s3$IdWy|vBD?PMyL9yv>l<4y$*@mV(7NgBJ|r(arOkY zRpW_Hbw%}@+S6lJ9W_Yc=%STnW^Gj=hdN^U7bXI&*8NyPoIF=X+zp_bkH1=2`>D@P zwamL99W_Xp$N4uQi_}KzcBl^;bT$!awee6zG4DoMG5aCaBl?tPZFxPwTIsdRpfw*+E^*8qXr3_$K;ovwWT%N%tZBB zWix?RIBpcB2D<~e()p^Iyq;tC0TuE?jss>0gzY13>M zI+_WzGC!mGE=#8^z3jAgjB2Z+1_^wwD9X>*+u7zEt7R+Re6)^Z?N-Oq;#p`3W8C3r zR+NWnJK0u6)v%S51Zt2luaIwz46^mzom1Uhd8COzD{N^w1{ZBo*LBLH)<4qMH2WZ7 zp0D1|SfXkjTC2U+^fM7?g)OZp0l!^QbL8)-o;=@CM-37rwr+5S~DPlv$mpi9DP!KYssU2TCBT?Kr387$g>k#wNP{S+hE(# zGuHG5i3GldDazoPKiQV9`NbyMj4}~ug>RGcw>ZC#w*AugvGtyOuSE?K_`WNNo|V;{ zkM0LOFXCEdOnsRwXvKnS6k26ax@(nz1ZupA59^ZVQKPiJO*&QF^78`+t%@lxT1Vpw zIZ&>(DgCNkYs)h;CGl7C`c3y&K5(E030%p_^=|DU+VA^cg~TkG>qG*r=vhKOXyq(N zlq4?h3kW(T3DkHKR1X(XcVsv1=$qC-(p~G_Z6WEkBYD1y_&K7T zv3g|<66P5$E7o@XsMJ--}hs%pQw=RfX+mmfW6UvFe)_)3% z@f+drC*fMdcGsOhtk0wuKU+rBopDACkgM?j(w7#m<4=m%Y{|AO4@!xE&rXZa+fxs2 zoIOxmwY9l3_v`V_FFTbGWxqWtUYtm>)jM87Z0~hcOkA8~D@N6Ork3xOOw>y7Rl+=2BrvroAjH0>aOUm-5Y9QNC(k zO+EB{l(X({F?!mk!6M+rRZ&{LFFlzWBq<0WwGYgO`}EY%6*MCt!^9g!S}(USHLxqLGGO?O8KePZEpugIu6PHz&;45 z86#>zn(mR9a;|Fv7q7n#TTepWA2J&?Fo zu824va8}eY-h?;QdS$Crdza(*%Mm65t*#v|D(cq1uJ^O>GwEtY#pJa28rqs zMMR+OqMNw0be45zw=!a=oEdO7$epEtSd;g>VP&2fw&eN7I$%X{(dvbq8DP#p!tBF7 z=a%jDuj3qV&W$t?XqBZyArbi9S&_-)SWi zXNPsyb2ZE-UOzl6-pO6Sf4(j#VrLx|na;~!e4FOoDXTJT8Na&h$iJbbX%ozNC>Omy3)hQ-o_a7C1b)eS0Kc$Tp{jh^GUyhef z%nPlsPKr|O&*s|gz!+zn9~2!mNUZy{xL7#$K42@2nMN%cS9I`T;*VXRPUMBG77Hv(h4B^F^`3_*S&`?}FOu;XgQ! z+>6vvgT#$YCB)Q+r$vs#-{V0@qsj43@tZ(YmSg7LIdvyfnb{ ztVIG_L{ZZGHpcc)TK!hg zS!wbJ6M732a08ww<=RI_KaE$Bjv`CIYSSsV#R0k6yBM zeA>}@tjRFb^Bf6te>~2iXltu=a2EKrrHMc*e74J*tryqUivMv){!ag;6W6%77R4Bo zzlWRFTg$C1bq*~w!HF6q%;S8)PZhOyoens&h8Nb6Kr4(fIX>Qhsr9M&z4OdU;Y1A* zIIk#5aGcyJ+Pl@cuaHGY0-IVxsa2}f#W`kWNgXvv;H)j@tMtFAOBM`p9=X`dM4** zJy92IA7>#@HMw9Ob>`8NMvUNRubjV?sGHDZ|2PdjNLW{v8+%rgOC}Fi3l6&Yk+ZE-J@}VOB$_SyNX>ntw%Y9t?P#2` zvAyzBn*H+nwVH)M75=_2p1g9slGAm+CU>p#iH4=czjIql{z=(a^4l5L<;8y}8hVhx zzvGE{_k-8+qUkr~y<21pfhy}?8eD}6D0@Cp<$p`ou(Sup!@nVjJ7;hed0NBY^Uiyn1vdl`LitH)_OR5Jm(Hid0zv{}ne@`PvPNm`ygll*UVe|hZ95f%bfSUz#e^kOUJcJdkd(D;KM zY+Gz|YwZqn2Pox&3oAWt<+1#|7741i+y@nP)^6%odmH3Ys$I#Y#MZJBsKS~Sr#XgK zmb3J$q|C}!#nR?TP+dL#OI8=Hp%{hYwj>n!vbIw6aVZOdDzbSri@U*;Dstk1Xyx0C znLOl4deJ}~mj9qW&ZDZ=SMTmQq>u9|QX~DkH~~7diu|o0T7GRc#U{xUc0bYEoNJV5R#|JeXqjQk`}sbT2W6=3K@SpmClaey z@dlkwzj{zn?0MFxHzXf8sk*Cl(*I&Yql;|XTg%t21bT=@f^F-24TxY5*#)z#Q-`IS zq{j$(vPmtSbE4?GxC7fxunJ@Q5}aRBA#rEj*DKhfdR0+rswK&r*g zm8MPC@7$i^CkA`$1kwMEAQjOCFU(h8jhpNz275RL6Um>f`VR!Fh)&KvM{gTHwQG2E zm65KVE2A~vO<)bbAUb2KskZS!Dmy_cx=v+k7&0vt zg5Dctrzy9-Z)hSFd+bE&F_2&t(bOKkZ?i%V*+@{2d2-ZWIkw;KuD28sG(NZ=({=xK z#U49BwD(ORI8&3#PLPVOw?5u0mD*(w$6#Xn+5!Ke97IzOXWBak?YY9zB0(`|bourx zBv?f;{@bWUBBs(%wbf34F*q$smsPrLjCtQ&ah-5!xvq#VeA{1}v&T+QZ2v8Y1gkh# z{`(d8I8q@&a}2$e&9@v#u!`uE-j4I`^wl4!tq(QT^NZzJF;}eT{%vTK0}1pX!Osld zk|RN@7tA}`be&et{t5QjOP(@5>@kpFRl|#7uk#PB{KP;HmIG_Vd|Qta^zrnHSaK{a z+w`;~NteW1Zc5hvSk60WS{$tn`8k72ODeX#C4Z2=nw&v=>uzS{Ni{GnS|Rf@6~>mN zv1+_y^mBhx6=$qc0i_WDfe1S3{3Y<^ZojK{_YqYom5DW zZQjoe3$+`7y4?Dp*PVp?OS4(@a2TRMgxrTcu!DEHjjApLP z5rs1jKYwrx|8<3VM}k*1W(>1EEQGN_q8O>yE_+DD>5|QBE#DYOu!;zu_ApD%vka$8 z>m9NwCI2%ud+Y?SqWtHI`x2{&rW*0zg6ts`61f`7%)PxVPR7mh|C+{X$#WBn_PqiF8ClbLm;v0iKq(Xw{&Z*y|*L5Dm_=ms>U}4^mO^TCIWPtisG?^$E|YHzu2H&Yu#GAC z5}XziWN-NDkWss-?-aeEUf1_0UeEY2A@{8s32C2= z)6iolc+b8hja+%w>CBc^8*#0p)|_J8IIlka^{C}60LGT2)e}3*ceSaWB^icVa)o>R ztUKbrI5u3KoMWWt;%;j>P=z^^q>HUz$zSxo;i=nDv7EHPU6a(ZGczZv1N8kR9j3pO z$LG1^`D#{e{cqdsK>~Mdirt2*GbzJMik)X$KCuv}!kvebbT%lDQufbW+Qw#iECl5w zcZ1Id*36%7Fy+o{(mz)E@f&l7)^|%D|%MJh7fb9!v{W zxED|4YEOjnPrgH*e_fhommU(--x~dTQoSN}z(~@*E&Y_WIW~KKIXl)upbB^FiMhtJC6^>>}`ce?H1X0`AIlyyKA{~ z4v4K{_fC^a(OS%>`7H#haMqTjqKgVBX_~2?wXW)x^HDg?DISkB@otfJv*qw^S3T1M zJ6XmD5;(_*+2>)rv%`QYn!BiYV=ttID!R_2OT5eb<0tB@jpei-zW45QVh<86sN`AC zslSOGX_EB);D+*pxLR7nJ;n4GOrWZjAj;l8tu~oN`(*M~nxK^0&@#cja#RBLQS9N^ z{^I0)y)jDTjFrT0xb>FaZYNU4V26njO4Pm=o)sItyA!yNQo5|d9wSLz#D0d1o)@0h zfeSK3Wby8@;C@9ktFXsNQVp>$V{_d)S`|T{$4+n^ihZv!8RevQ zy|vM;lPv_c8ul?sI@}<;oIkObcCz9EZX{gt71yYOE2{A!w(I( z=p>uI6;Afb;}7)fm0Kr^7DNI)c7kXxad&C7^7rPmTV8eVDI-BPtLW`={IzyTLIOQ@ zf@tqo-;kg;<560>S6$M-$3ZHxPY+9@U;k0ND9V8ZdWc4XY%hT&M}lltVI5lQ3O#m$ zXsT)J(*)&&-m=$X@m0MXjUv{Y_KR1?)Yeu0^h3OeJFafJ`>RgG-#nsL>hrr<3;u}> zD-No^b-3##79CMDC3)?4#4w^$4&#C7F#4Fk?xdMV!L{p4?_3ry2s7@%qdJHJX zf6NsSi(l_oC$=}f<8D4jadeKsD!R_K?%N(%SDX_fl8^0IM~2+>Qx5jn<8f>H#z2Bq zM3e152DUlY8;yd5iN?3dEhT3UrHcgFG**0bWhZ!a@ksOjHpcs}u(ao^{_V!P;&{FU zdypWTuUm6Po_e{`CIn5m=R_6RA;q7lblsO=kDcHgzMEhb(H%0~R8RH#%TEmUkP3-_ zYFE^v>O(&Sd+bD`u9wsUL!S5{*kdOs-hU%VMKs0kSNb16G1$W~n20=cTwQbM{}HSr znrdX%*q44{u!mGg9Nm9T-8x7{qn|i4KT(QSa4C<6QfpX{@0|{0UU9DI^g8$lO52=utw(q?J|Fw$* ztBCIMMPP#eUcw$yA+cpo-UR=>1PN9Voe-5h!GGUn52=u#nc@Evtm1O`AA^)D@{kIN z)Mp<%LH81(sSf?m4D8_;Oz=D=-thOjSj9gVjnncr%O-E0-ASLFdE8;HxE9_`u!>S* zTauEN1O)$fu!r_*hlhF$_E4_S=GJ^S!77Tuws@bu)-QO{FzU)ON#8v_Ye5l!Q! z>Q;X-*h4BiVcu&kwTlFgN1_*}f2jH|2YWatl&)@@V^DMuOAHHvqxOrds{i)5V|XxK zvUzlwb!GM|Bv?g6qX*|y|NV+Rm@e5ox_o0G!73sO1|_Ae6D=iY52j1D&lohzfds3F zpx87v{pX5DEfQp#&x4j+aax=cQjtw{*mu3L(zg)ou@e;I!FuDV$U@*;LSu!}C7S0I z-*T{rR7lV$@GLT(&n+>KU=`5?m2+xYF?adKKo#|NS~I+x;Iv4E1odfet{%P>Z@0;` z!WEf4vkTKAn^ht6M6P(5V;Cg>L zQ;dnoL^bm0Wm^jl5k%0ciDA1UH-}^9K2QT=Ady4F zh#Y@Py_bz*v==c7oS7GPW6l&yIZ%alD1Nys&_V0t*e0KdIqq3{zq#6IPomm>db0Cu zkGAUKi6_+Vi!M6*tZJ*yoOVpz*_2{TJJLYAJ#~s>arzImE7x19@!L+S-`z-d77uT& zmhJY7s=R;E`ST}j)Pl`_Rx{M07M$L=miFDtYR)+$%V_99V({QL>ddL9)HI(EQ6*D# z?R@`I4~HOxDfd~-lYb#cFwYR!g}tA2^qv^uvdDSy|yl~8+02ereRM0H%Y%TC9W zcIt~wN7YGhFFOw=wpBNT997#Lq89A6OME@)V18xq;+Gyw3u9nP;+NEgCq0{jODUo6 zmC;azt!X7re_d63Ik%wlZQFew^dNyfMtmo(Mow+?%qGgiOPwqPs<2Gr*Yl<6wCKIf zm6%HNJm^6J`>}YN)tlT}yQa;R(dvl=Bv3{DjmM27y;n7#wlDu5&IxV9HS+K{$G%kO zK|3{iNs@YUGPU5U+=aAfn_`@emNwDQgG33Xy?V63X*FaM5qD;k)fzRw;MlNTu@I;_ z`DGjR&e~II#kxc!eixx->XFXz=2d`(eHVKr_G9rqmibM!>JvYdGY99<(1Qf_P?4*h zb+rap>&wpy6}Awl!W@df8>}g(eeMW$j-D!O=t08T+c!s*(TaSNCLzzLS{4FT){>8G zGfeyK@8Ztcqhmcy)`qJa3LjGcJbl6WW_5El`Qt>j|L|mIue&YO_yI@M4{}mn^{O~T zo7Q`kb9wY;4|5p2VaXK|-`@^bXIB47ol%NnG%q?<>zY{2S=e3PLZIr{tZ?$o2ns) z_p25A5piwJXIf;!Cg+l?TRm7O*vGIA#rgC1`)d;){_Wh|_>iTpkg(38jm!7a8l-c{ z!|%?r5U9d>6Ys`-+)dl?%RRZ~H@Q9NL85#@8#TGmQFZ@5niJ<7DWv@xKUrQ=x|W9H z4rf&Cp`y(Lb7;?w?2%(jDVCmy1kRP>El{f(YL`C`m-k2Kvk<7lktT>cA=-;f0m{Tn zmp$k~0_RHc=EzQMwGT$Q z+}n9B0SQ!DYxnk)j#_-dbjssD4~Vz+rPMnTQ3+x^&N-+ye?VjK>Cc_DT_<14xm{^I zNT3Q^R?MQ=hiEY;f0TPP%&MXXiDk3H)%f`R>Q-^r7T>APJwj`JVT`=K;oAfxP=#Yf zya^$4oEGschg`q%w<>y&$oROaS}^xMb=-CuA76XMX&pkYIs-nAO+W%wIJ&GW%U-jc zYQQ8_?G_~FC}GEUcdM(4o!9Mx?gQ$WsuTmCib%yCJF#JmM;*B0-2Wk1MRfEhN7Rzz z{SoXTm4z6!SKXF;)=vy1q74<%M=KpwKYHIE!5%#)KrEK_{Re_o9K$s-$xp5*26;$@ z1lj+MV4BLO>(8rAyqwUudAp255X#( z7X58X@1Ii${dWfRkd4HKi7xd{4}USJ?^0Ssu!`)mO?Oyh=ufHWu@f_!?y|-(bH!;9 z!78!~=Gu~4T^Sxb@#33p*19rda9TvLitG(@zPGlyNwCLGlx_Hfwaxt!tRgyc>3VBV zGzs$1$RX9@<8h`DE-BdN?jhKE#0%xt;MI{TP0I4vSr zMK-l8&3wKy9DD3U^uV3^{P=EyRYddb>PxVPR7foDvq#-f>%5;_A;Bu5dDixgf$dB! z$TP#c+niNMkUg}~nUq|awaaNyPFO`Y_a5I|vByrlSQDp)Z-W>U>Gxesi)>ag?Va1l z)_57DP9Lva8ti>?=iiX{Z_xBh(YLOSYQXVh>YQ`*OVQNg)!g~Sj@+Z^tIFs>f^6QY zAW17al~;cgU-hh;Ttc}T*+wn9?1=jFnY+$5!`i8lV~?tX^54^UDx?t4BbE4`)jw7K z-S)SPX(5qqKnJzp@MEevhjJC!eP7L5Ukz7|tX*IsP=&b_EB#8t;=74cpmf?4W01}J zGBCH2v@W<|$a-;Rbg!V$g9O>U&q2IR_hv2k`W-oyS7TfG5Uh&$w1xicGq?}!fOwFr zMp!Fx$~2v*Vf0|SWb-};@rAs^dBIQ8wUO^cjrJi})plVx5V_;#2h)CP+E-1U+^4$f zdm|`av|E?%sPAnNZ*M*Nap?I(hiA;UV|)lx^{TlVb)|#&3on&pXRWrbHR-d6?=D9u z5_l*E6J%EjYoqU(k)-?AzmLxmnpGQqrkxMLs$F&4sJUCTQ6qk#828n)HBOxl)y`!I zR?vg#HmlrLZ9TlTI^#YOUsWmTuDUcrDezwiE>;cZZkQRB5=*8b+S6Re`&IM^0DWy=OEr}ZeE+!{XOvYYn9bjx&I zA9uV@=HaxX`t|Ec*F>(6Ko8}FiLB>W>jdqxN}WJeRvDNl!G)pA6wbRmJNphI)q z@&BwbYWJJb?+8L=*j!*bY5e)PaRqvS;VG{L>=;r)lz zNyQ!{B8=;2^ftdRA)gX9tf}&Qd^)*awb}0Y&NI~{;h~(wH<+R4ePr2KcR=+SYS0JN zg6e?0N|wvbm4aPfy3s@BxbyZ4JtvrwBn=wWUfHobvoa>&uQ2o=K{egs^=zXY-HZ2B zUc7%#j%eIkMFLgl-%fFNI5S7jt;kimvC5{lAIk&UXL6ti39J!G>QQmLQexXHuXNN5lM205q19Q80F^7ZI-%1f<{4d z-Wf*8Z;4UcqC|w!Bl{0wm=>zA-t?Ke7_~d?gQOXbaz%3=&52~w%t^Bn+2-tH66isK zZF6paCqXJs$-Jxi5ahvhIk$B8v6h4DhiaE*ILbTCgM8h$9OyxUW>lihncB=15~$*G z_{KmF5_D&v7<4bOmIDb?ammell$k5`P@OQrD-MeB?%G9yb7-zh%ov<2N()t7Bfc@v zg9O*Kxh^qdAb~2r?p>GQ3`e8>IEyTZtUH*5HBN1$z1MZ#h;cFASj_=0Jc@{}vLMnJQTB3hOX(`(X^U)5>D@8-$%_bnZhJSuDk3;8B4vxm|}g6#iBkV-Gd>Bjm= z65n#4}=i zoBnGQJ&iW)oxx4JbjXgmT~p=VhEZM889Qr)So^y|o|?Zbou%(cVNaOYO_RG*Gd1c* z@2;1mHYJRGO9#YmkyAHHxi9=ZQr|7Y9y>v0dWid$^xxqqCrt43GO5VUJ-tRM`>)tz zCn$zFi&DwW7*T)`GAyMUOjg%7#mbyZMRYX%?@V`HiJ)}Y+sbN0-w1Pf9EHRK^711|q zU3B~3XUQH?*$MyqERi6acTL_rvc>Jc9PHton8d>wZu3lor9CW!p(6VD_HJ(f?ZF;B z1`?A4+~%1EOAI8A8!Dok{+-3`zhALO&j}F0Ne304suyD8b_zqhl8R7lX> z`9Bbbip$}DpC#pr_gQkfdOTb$`IG|*9<@}5{`Xn32h(NS|2|8e6G=t=igz^f>}vLQ zzw<5<)O%8PG~N?C8h05x8aXXJhM}U}7qR6|xKegB&WaD*v&h)d$Q~rf=JH9>b1_rb z9x}u=dcg4z^6(wtiP+JY`--BjeB`||aQVbL8fk9NmN)E8i<*08eZHGwFoCKbf{2>jP(3h-h}R;<&V{*MS<3AUK@aVXd)%_I8mD^y zF2X5!V+59%TeIS+G}n-Jk5ClE=Q7@8aE`oRYV$l*xl?&h#AAcYm@6 z395xy&ExXF`;!S&S$pEv?A5iyRo>VBmUnruMzKA@#6HWUn@!Ya+o;X+S0AUjdUZ}1 z8J^Aa?~`zK`~J@Ap|Z)&HUYve(@m`&b-|gnXt)}au7_H88SUg~C5Vs~LlZW>pT&bJ zq#N}V>ywx^>X^dxm(e!s25VbJ&P;go-YyS%koas<3$=B@R_ep!L|ls;q+N|lP)C>5 zECi|=ZEmhsxzSARHj;?W3!7PfX%j{I7S94*!DO&hDX_9iPpNXLJ)*b&|Oy*BFoyO*3ui_>SFDrj1gL%S2v zDgjlyKN3B0KpS;kzsok_`{y5O(Fba2e@riH@gVWdAdxGPLcM6pRlVV5wBaMfn=Bru zu@I=TmLv0{C~fYBC7$KAFRFRO4%3(~yQu}1i+$m>nyX2&IGM2hqI2j!E!6E3+p51* zrM~;&r6Jm~t{Xh1>wleq9werUF&HzjquS>-5jnp3OuJej&g1%FvxPua7O^M2^|KD@ z^9(ei+>^#>{l2=NaAMQ41e|47zHBPKoA{~v$AJq@oFydbe6w*{=&hWd^S>vm=s^O@ zC%!;DJX)(cFTzv3@rnc_P=)h~cxTP%q1uWcLOpM@&-0)M39J!GD)6kQmKZ{{&JldjeA6j}ls<2N>(r|Hl;IKG3 za5dc&6+K8Kig`DxSzGm0F!kMZ;yZVxTpwy{lFC}f2dc2=i?^yAE2RBiLYz6+H`#+@ z8}D$|F}N>(4(+|-ineI?9uInu!1-95H(1?J8$UXqR<38bg+LY7p~%(U5bff{%bu1Q z12ps?f%CEWYetv0TGx|jJf6WWOYNcxTUOlF^3>AyuAl2E^?3meJxJjFN|Hh@b<--B z$m97d-+fCjK^69CF``Db(4KdilW=ZkZVf$1;2l-GU95CJt!lvZgqM}hT6#OGtaoQx zi_(IW_u_2dG{)R7ntMRBxnJ~7j%lgv*kTXu*g+y`#RTh)Ei(oZtRlM6qM51eJYx^3 zEX0XP)}3c&3?v#EDxzmjpOwn)Pxk0J0V1;PmEom1`@0yn);jn{i5t4 z6%t7sM_TuCnlX@I712qx23TX51bcLqk*=Pr+MiitNIX7}*lwtF;zZ|E>T0{;!F0)v zy42TNS7r<(SVhF+{jIHSZW8Rlbaf)6r?t)f5|0g)PXFH0+7nG8N_cp`==R#5nl{Du zzh9I+l&*zn)zdm^%@{~LHdHz->d^mwQK~EQ=%q!XX zcOZ!NzhBf&kcw#TJ-)eO56564=5dcy_KPCHDyF^rMLT7vuAOSMOP(6$J=Mrh?)XIi zuXx&y?K#&~M?clr=JGhL=bMYp0;k2h4gYScp1PegboQnT&Mle4)zx|b4w?4F1?Pop z;c7j1rI2+W&~t6(!-KVR8+OWLw@gXc`)v!={kl+mnXwn0mBkZwz15$DOsjm+MvM#< z?{p{}DrZ+GBw$)dd_6sfkQPrYy-s@Gt5Hfhm*0B=lz+Lw-+HXXvPD(3V)F|sW7_TWpa%*3)kwT? zu|iJmL`)N<^qP=PEB{M#WrBF(Mh_DBTbB40=W%YWcAMtPs{8ppQnj)o3VY4xax>=n`_0X&)7-7yUvSKpVr@&rd7$ORW9&{ z^SU@QiXJ2=2LE0v-VnRJu6FreeR*YJ@pjcu+p58b$GL`x^T%Pe+NiO|m%8$b)5I8C zoJC$;PHQnO*jam;tf9&yP7}wL&|F!>X<{o;bEr6Lc`{8xj?uL&HhsBs(B4fR^dM1ug`lrjc1;*YV{q1wpJ{jg z-st@4`>hrNRWrr2bMWrEt~UC~u#i>bv=srJo!JY$O~Co%%jZqi)s+%LUVmzzOL`3) zr@dNz#<6*Lb`OqoB(QwqP0+E?TI=&)Ih%})un?%iSxKBl-ZWH;96LwuH$BXQ9we|v z#M$t=y|g1E!F`Lm4(TV>qf7SJ295g5nX~pG4|Q*>Dn4(W zZF0c{$1Mb^u!l-gC2vvc&hDUK`R`58S(Ockf_V}IW4YO4Qc*}hRNZA-c8&K;x5SO`>M&lle& zKU_$g>Y6OC`p|o3l>at?zair&kff8Db7-HP+9Rj=&^kVlz}Z!t+g{mF`)oqE944Nh zF)dVCYgfG;qWv5ozAr1zjG_k#oL$AaiS})^z$q^Iy;El_1gfwvNK$qPj?Kq5U9dCwYicpS4&mT4GrTJa@FvUQ|zYw!~Cz1s~R3!y;z8B6~Zj%AX8!> zVW@Pvc>OT{E99z%M^_DWXRSlIML(F`ZHcuf8kFEk@$B+tPmNxfGuu7-J z4DP=tT2x|g=blJ&zJ*AMVR-a(kr1~3XJ<}}34Rjh`-<-@${sthV`il=|7R_cU=`Ee zac)0rNlzxULqy-DXV`Q`F}O+@m;bYt>_I}e%^kn?vzA@9?vLlQmR-ibjOVkKy!%%? zrP|I~vWL!EGC`*(gWDE%`9EvP1gfm%u%ESLg7VHgm+3?$=hpYEC3}z{oA*}xp0z}R zRdk|qM98c?{?A&n2h$~+cmMjHwM2qdbfS`NeGmO|x+BoJJK7OXyNYSAJne5Mn@??# zZSK=I35w1hBxvV4T{q7H_$5$9yVW@*Yp&3P1nqSv!vCo)P776>lK0dWo{8hU^ZtLL zdCxxC=FWbz9GnyKa4itQF}!=WF$NN-;vAYg`^^~WL4s}nCk`pUR1R)UK5@upvequ< z9SJ%;LWFrL#mp5FsN(DI?j=aD?f=9fMW?h-#X0nz=J;-Z7G->~2(8xN-g9@6PoL-n zo#2UVeNMOObXDZgbtp-vH@8#X z8<|yGG{AfAjtNxJ>AJ{5PmJ1aJpMi9WUJPi=S%OoJN6)9ExDL^lw0ByU8bkrb9Y=? zIt^Cn+@df#Imfl&EjbZ$JM~=0=kB=Vc7kXxQB0h>D>A65Qg>u}{oGy0&o_nfxw|EY zw(GT-I67{!uZ@B*;J!g zBjPJc8TFH_%FR35EOkZWg9#dsh34%rN`6P2yK5!R-F;IzE)3H`71oU=OLnUq}u*sfQ^^(x`zm z+#SWcEE0hEbTQ_NUcjs`e_tmdt^kBN}r!`aq8)S8S@eAeZ zb%~ztM`_1uapGMT=s|+)`*B$v^z0)^^$(?~_$qR|_IBBIr=4KcAB`FSp>~*G@o?Eu z%KkxPWcTex>ftXJJEEK3bCRmYqHpwcDF)l(?`o6MJ3`WoRfeUjs=pnEJ)}CHZ?RrK zn37oOcV6N?up~mcC~6lyNRU19)e@u4)3qADr^%>r<>vMRJ_M^S1T|4_JzwJ3sJHo! z#zkvxeq3Dnt9E?_J(%uySDUDrKKa&hc>(pSz{%gcgGG)qik^rbB*>oeV42>J#d}!R zXN$jH_l}(YdQTsMRXc|_RrCL~%yCBae6bU->B>Fp&-a&W1w||9!F2cj)KuO5`*O!{ zhfb`l?`|yKS@T?sDD)sfcEq6-4)2`UYX6tP1xvPd)>uEmhhPHBK8Du3a)KlG*kZNRz1D(E()#Mi9^dWc4X zZ9P}ZMf&>lAmxO5I7P=EPATzqD}f$=gm*;YQ&EQR8oKA6E9^L&B+23D*66l#?0x6b zm@6*2{@g>~NsG7aE zhaRIsMtcnOAi*~Ed~4tJN04e>|B4?|U3CkkuUcVmM-QgUW%8YUkU$mJwD0VL9&U3Q z=a^FJxk3VGjMNEKHMtU`mpsU0f2R|AkT^TIw%&qAmf7Dl#bbg>i1RMe)?A?niQ+Ri z>f^lf7Fv;Edmw?T4D%(O_~NDgJznTRVtLu!IU${ZlWzVuW~i+j+6x#rrwYd=Dpk(5^s7 z!=Gq;%$@vx*ysJmYOU^;^C4KZwMKdM#Ed{k(H%rwE??W7U%a)hjCgAudN5r*UcVw3u0!j@6{@A0<=l7^j@WC?Y)Yf?tLKd_b$!K$QArPZK|?>mNHrChxy-aGiOc<T}K zJ9;o(vUeYeF5~1=m$61qT;ci6G3DPz%G;tvdGS>fGI}uG z?awQyW!it^s1rzi_v*REj^1y^D9zJUmC=I)*|a+MuEHA+Js8w6?*!%ehg+O>f>pSN z*JlRvmkF9bW;~hipm~YrE3#=Gq#1*3b7n9J^dLbqDiO3=w#L}x7G z0s8e91C2YU83R3-F0BTMrq#JM1`?>^*z*q93G^UAt3irkt}M-5A%Uu_-`;i+F{+}m z_Av?cAVI4^JqE8V{Sv5JTJ;A#Muh?P80bNQR)ZA7Tv?hikU&-2X5CYXfgU7iHApec zm8BU22~=%-^Q%ty))jh?pw%G7Fjtml3?xw1W_w9JhHsmr2MJmYQVertX~y6-r@qUr z`K+CH#X*E`Z^!GzTa_+8Ub%H$_&u$5p2XzI7W1@M@~++>##?9Zzr0r zi@(Y|sH&EqUS3Y$)w|MXkDZ_xHzpCWegK|ab62U6Ym6v!HJ36>P zcsga|fll6)zL6FZdOXJ{$Km^WjQ%ld$25V;n`Lc%h`EM}VhpN0(vc;{W09*P{hI~X zOJ7Y{nW36DSB8qxWtElqZg<|`7a~V;({y?aRw1F=Nl}g^2Pj5~-Z84DcMoNF%p?78 z6BfcyQH-FzQI78)5Ha!I`iiS64p(;6iIFiavRP#%LKAXSoE3!5&PPY`!i@r;g2zs~H+2=lFQI55X#(HpjUt zM(y4ruM?(=b|(*yLCOi+bUk+M6n&&g((Gqd)s~aXdxFHMMGq2WpYJf$7`6Ft%#T|* zrM#!&r(^Y8k%#6CCTJ$&*knsm=}IHi5&2#xRLp3duQ(pfQ5?fN`xqtnaLG9a5@hpr zN!qhKDz00mKuuleZ4dUKO3&esGmKs`<;(4AhI;9=N8(NYs3JRmg&BG~TM46=XxvN6 z7-=CvHeVO}6I*AA%lBCiZOXDodf#Oaswl?cuV*^EBkJso?dphKowV|G)9JO#9wf+K z-+iVrK8z8iiJ8HJ9$CT4~N^dLcY#JQQ)nL!IV^y^kT!79G)ov)19hhpfx zfmIxXR93>6;iw$?oJe^m6%u4q$t|;}#)yHDn5i|IsWC0GS!E@RyMab` z1N0z4HuV_G9Y^Ck4yTJMN{M=?<*ugjU5!0Rkj>Z4JEw>4oR}7>$W9sOVrGaFGlNsi z40>-5To7We8Q3NwXnr+g&2U59&u>jEFFTDj1A8bQ6J&4su&S|UFlHZ$q1P^{c+EgG zuNnBdIs3>o`*6Dc2#Vo<&A{nW-dV-Dved4*W?&T(ddVkOw6>tRW}v-0AVD^j&;ObM3084?_+K-yN>5j`AgQc`ea%4a zK`JE3roP~R&42`}CB4udr(E?xWB$$Dr*KLPAscyt{GAY`QFy@W+B*v>5@&?E#>fZ%(P68fds24hwC<8HDVa$&C>D zx5bn7y5h7@MK#^0aFP)N3H10QyfKVkqSYE(CJqU`ff!e*e2Dc4Y|U&4)xw{66okb z)I{I)h`t+)1ly!~e@FQ+?hBHH1bX}t-WaquQS`@&bS@PMwmC-1_^=b~@kdz7A&b7N z$3TK?Qc(_5#<@`roj?!8uoK=GMtdlt@9K5MX)#SIs_B&Z3JLW1BfK$;UZRM;8;k_o zq@o_fbEQ5fQoqs(^!OvZF|c;acRlP%>f2W>+c}4mZJIyG4sYMwLD%)Ugj!HPEsGw1 zgm?DAoi(#x@6%&6Xz|*?Hsz$s-FjgZ+nXyS*uyy?724ihALV zbh3+aA54uv72Dnz^kvoVV&77o_LJh!!#3sWal@#v);)R~F;XK?#kM!bGx7JRc4Gfk zDRahEThd3ync)M6l)tz~%qAdKWtkQa2r?BwDZ5;Dk6?5i3Ff43Kj!uq{ z1x1`~ul*Jhbf&%Iv)n}mF*kqF_~i$p6i@GOS*D~wmVW`CmBBBzC_PEix}*~c5h`D3!9Y22sEj;Z%$ zEIF1I%Ou|3dh43HX+UEod46F9JxH`kd{A@Hk8K=D9gB%vC4Kf&NTq$fl$Z-QWF%09 zWfG?|8_o~jmZOgRy7?FdTZ-mjZZ&LKNy@cGQXhR%T&{cE+U7`ur(!Vefp}N++qgG@ncvR ztB62V`QN^dKhd?N!#h51UmU9)Pxpa*W&Yw|tRJ>Xg=LZ?MU2?W8LG;wKRXhN9wf@W z>K{7yU@s%aun|4Ar+@w_uU@~)g#@aw7R0-}#mrMo{B>83S{a5OB=WAm9uoery%D49 zwS}Iye-BgoT>qm6mKOVxwd9{4x#9WbU@v9(smXiLg9P?UNjf9Wk;RI0MXSnh3_}7{ z)?77t@PW27Zv(}h|9nmKAYpCuJ0)vr?u;KPvx1ZMAb~2Z5lPA@c4o$id9kBt_sB2r zyYI9}P_MQ=sh@mdo31Z@yY=Ii3UYM&MfZX)h%-Nc;-(?S(pe-U?F{Kf8+h+!w#g9O=pUCh)W;T4la zJw16*MaHyHMc2!2KcNmAcv{4;6YN2PY`$*Y$$_5J!_w%gAGM3ll9yiSF@^@v+xKw) z6{?V?UkxY*eFx1-pa%)Iz282=82$+Uk{QMDB{07$k3Z1!Ub%I$-pBrb$&ujNtuTbX z&4Ib%{8GK6ifwOuoSRjr;&}1vW2Ld>Wg>PT-s3ph_g81XF9(@6U5}02dRq8PWPdypWTuS-(i854tFt$5@q zBYxAsv`|IY|4G{J$aRX|7HTKhg9O=p-Fm_e(?S(p&%fdaBL+P=;wd`zAVD@?7jLZ- z<@n{>M{@noyyajIs`MBqw-|NhLl_<;blW71+Eutc@*`o`6r*pJ%|@Hs3HD&RWb<{i zmnht?*n=vH(O~l?y}$Vq>_LKTzOEBnMc<_u9!v{WT#lBH=u4QQ?^+1T6?u^0cwR!3 zJWljosw)lCLKR)7I_$peH*epy5bQyMY`(6yd7RPadJOiUN{=D>)ucBRL$tY-Fg!@; zwn@AM8z3nP>;bLR8b5XwcffCW6(md2MMzI zx;`^d?b2ML$6ybtCtvUpIRR&53%yVh^e)2G5ng1bdJmo3HDHW!~M; zaJkC!F5492=(^>q|9O`^lr9rw^L5`@ls%}T7}5P#s{ZF)_8>tvUpMC~56@TZL6siE zu}1aIyT*K#LKq$-blW8CvuHFDhD|XV4Owr*uoLXTbjjxHW;x7xmp!P`W7OVg)RleS zZDe?m&~1~j&!QH>P*IE*88#bjZYS7->5|RY&38isxOV`MNpI&3TtSoGYTaJ^atRRJ-Isf^5F-JBzXhReB83ul&!u z)Jw>Ngl?OJeHOJ4hKgcPpY}iRvIo;8o3ESYpb@3diR?j@9z%?CZ(SMpj}*f2AfekP zVcZAJS(L^(*Dl)>gXS3j^DcWZU9$PQ*{{rbmp!PW7(Cng66`^OY`!i@S;W(wB%bcN zi>H?5l_m9T=_9*qMtPs^(C+ebU;K&~db;~D!WkAWo}Jf=XDNyvyj*b|$@a6prVhSQ z!}aiAFP%b+@0Hb2W=t+++=G@n!5&(9Gokc+=I+wxX?(hk6r<>=G-|8rz2z6=&Q_=C;X!4ifui&RmQrq9-}8nI}vPnC|6AAIW&peJ&FVm z7o{#Do**$TRO#1^8WHbdu@hKV5RdCn{4%kiRdCm4m6UY;Pea7Lu(~}fl9XG_qU*)!*g_LKT zzHa?xf_h@r<{yOdU5#zJekyI9P`d6*u!nNO1lfE~d^f?WdX3Yl^{!_P@r{8VOxJo> zyHx56`E62^oTcPv+CST0xnnOccP(y`=-ij@jXQSRcdom&5}mdBzI4YXE^uA#o+!$Z zD=5Eme?vKC;icQ2;u*#LHL1QUabKdd$cv}$;OIbC+8+{~CprmxX-3!WUx+AFa))!K zyOJ~WPvf~%s}kopqB+>HG9Kzj};WIYP6SN_0NnmR7B|GmA#p!c@^7q4Qnku2GD6|Fl#} z<*MTd9F$8#4-)AYrBQ+TNf`l@sn_CD}jcFljH_r}N#|nB+{A-H5 zwD%Kd#u39c^dMoa-I%EFwRbR; zy!yg4@`7$zl$w9F(a?iLn)}b(v6Et46$TQq`Fbg5;FVydj25EJ{p&AxtTxv*X-=ZE z^~HPc*v#`>_2wozF}C<#(VF>=ubbCa+JF0@*1Pu&ckJF-uA*xaouy-Mx??xbay?%y zW@>SUH+JYuSJ+01@#bw$=lxZcl;4il(9na#@O-!3u^nf*ItW_q5Q<&yocw-%rNP#4 z3xO&O~?J_WWILx-m?NEt5e*4-&eq^>sZ;B;r_DR%LIU z;)?5jpoaHUr12gsNojxoO}^MSOsSz}(9nZ~HAbaR=E>!D?U#F<>0u#Ig|#4d4y^mf z+5c(=W&N874LwNc_JN_UFElF0c1I2Q^DGDCr#1We5{61A%-Ri)n;Y$^HSVSl-UH#Bwq7?{t0OK6Q8b^i8~X?Gusy17%;=+DhKZAs+M~fqh!6cMsK99>f(>&cC-i z0SQz!xc1uZelj$E@IlJe;|0|fZC+O8`r4}=^dN!d6TdBKic;)Fc4fq*T^0gWIYLCq zKb;%@-V%yYuSZ$s^Sd|XH1P#B^dMoatDXtrO4TaM<;d%4ECi}fiFNzk(^dDp=twaZ z&KjmTua1%Pg@5G1^Hp04kMU}qFG}gi5J;IGAh;Ksq-=_K7Km-wC>v^7U*XCLX-PWTTaq(9kd*gMb+oLOkg%?=`c=sw=X1Q2hh6Aw zAy8$_mE*@c@?y_Yx$2$48hVhh-f^}ZE-Np3tjNnQjW;??r!@qH}>s_@PrNuMqIQSN!ZzC8S9e+@lI;2l+x;-;REH&kCO zulTu>g+LYF8N^pyi=1;INwLGz9SIhGtK22czi*@_%Ve+33$ICnG4bjkpgmu(D|7e)J=EP_@dZCp-6}G=P zak%0;`Pb}oohuG}uAv7BoFydbM&f39O!?Q&bGHXu2vlL)i1(haejsOC)5snAtcPWN zg#^xBl9Xo8ExG;E9CGVHRsvPnHsbD_AxLRG>WO3HG>3*BBybLvq{wO|m3;qZlGoI) zZ6Q#Fttp7&&6JN~zj3x(nomOy5_rE7&ma4HE4yyym#3~f<3R#d)?V@^{XnI5zdrJa zzAHWGK?3i)lC+~kD`nTmyX8xzuUiOI;aWnHPGs(`?0$Pj9++dC2R%sO%0PVUw{a)s z%G$H?=D1lCPWjqXr}|&v^#}%B#?c+#ZgC>X!OMdZ(FT>V$Jf_zu*~i3hxj=O`?t4e{1bV|QRGIz;$>zWO`VzeJj8weei|kX^Cg@)S^Cj40C#Y?_I|uB!vJi%f=o!oQ8SnnI z6YSAr0Kq%Cd~<~atBB@(S-u3;F4rg#hu@xZWQ?Ponf6?f*IEVRC)%U-hfvH*zgA1ABZ3{Nk;Z5Z`2^uOAXY@#u?=Nc)!Dk`o~K zJCW}uSf$5EeSF}UFurA}=McZ0X(`7=Ao$yvM5I2VP8nY#CDke8>!)P%-=lrYfpa3+ zpMQNpuPdIxeF^qZx=7@2bIj2_4gK}i-mj2gm7Xheud|(?oq)6(k5ou-uk?+91gnTn z*$HTm!5&f}!84I>3?x{^AMJ45uN%tXAh~6pc!NF z;otqlK!R07^Bm(RSH|85QsErT{mqwPkDW+;rbdEQMDy2gePghPR7mh&N#9MdN{@lx z$hC~YNW()aB=~Q)eqsQ@Dx!G=`x5NYRrnX>E#YC2mFcg@_FiHk3>DGum>FO;pszLa zoS6E|z#d8$3I5izZ?5bF&%t!vyZ6Ih4qWx&+NZ)S4s=lqnMJ(MdX=-%$#TWgPD zAz;^inTK;7J0s8giNPK{1`xcr-nZmPu!`uB^}cXS-RLg{*P>p##uvKDPQ9+!V<-3< z;=Z}E5XKkGi4J}{z_sZv?bEl{6?^m;Kt$p<)-B@$*FN_&tlQol0}0Lv5!^PuC1($&OZMC^!cu8LBv?g6>TQ1iKq2$FX3(+1=JQU~ z)K@ZwhtfrYYudLQoE8zRBD-p{bYZKC`Rj@7u@l@jeqtDD5y2|5BVV2i-B8P44EETG z)yWG(Cbp;Vf7sg`30CPbQtgS{M@fZ5m#sJAEA{brA4GyxMDr-{%@wW_3(Z!;=GFY! zyUP4eEr>*+(4;W`Pr}%OtfE|T@A1vm{c<_==RQ&mN+_T|{gQp6U4~SiIoV?;QjdWI ztB79m=5Z?JU=OK~;4)cvI+2RcaMGSDw&^Cv4c*W z33lIE{-e5K#RUEDwQSS%kSCke(m5xJo@gi7Lw~7cf^5DnNfpGI(I|1o_L|s1hiRdT zt{2R*P92tRl89j^*nZ@^+MGRw)ut~5739|XRxpP^g zz0Q~xs_6P+=Sua??I|LLvDeuo*nZ)andm`+?V2N&{s)3o z)UPO4zA?~)>GE}N4ExEK4wn|Fm@e5w^L17TA*~%Hp@|rW>PwV zM(L7@JZw|0nD!;mg9OL+mK@K%B7rKlExknFL(c?xP({}ZUYKw6UED*D9wf+4A#g{$ zonRGRPtHEa7=w0#J(w=peBB(i3XgO4po(I|mzr+OCB`^63HBgCHeWYqADQPX_MnPl z1Vm5N=P_S`JxGwv*Ci?6jrqaLr<9k2#FTaR!0XZ=wUCsqU+C9J=Yh~GJt zpiCeB^^rtCqxn+y2~(<)pdR)bM7~NuW?T8!d7MDaq0xLHGd-LcJkBN5sFkgU>2rU+ z3g(iKKo3Hrg!LB^W|Wi!GLm&UTIyC#cbUAv4{4KR3VTC^hGD3=U#Z%*eGm z%(SJV3?G>DH%^>su8%lD4e})cnd#>2vkyme~IR$R!GNv_o;2OjSt=NL^Vo4t|XEo0OX}s$cYh77>h=t48}gp7jz{Nf98DT z_7}6m4|>7+fXu!@)DJoT$xHj7_Ic-qL}4G)pd@j^kFVmT8ui#q*;K2SY^gx7gj0Tf zHCwIOD+#5dhCWCPYt+-`lwS`eXa#!MtgZ!o;K>Bz2Ud83VBg@W$RE4$dQcN5Dx>gS z5wrqbxId_Yl@gGXTQ^R?3N*(;ep^vPA0*JT$v%{z6=?Qsz7J|(r36RJt_fO!=7`BB z^qSzcO@IEgLA*b7>?*-ICf|n=w1N*_H(>;jGqI?R8Adf(V`BqYQO0#X>f)z@`*{#E zR1$$k2`h)h_4f{2_P5!`VzJrJLi189q<`@Hir{`8C#X>ZGSl<#=hUbbd{nQtyukgO z8YLhzJ^x9V8nvPi|Lz>WpM&7tIZpdT{O%klct1z|lJDo#uvGNNbo0bAZ&=Br)oXgK z&3-RzcSZoTLOS}~zn{kmYS>n!nI1l|V3b%N)PSIDeVFlvXP*Qj&?sT$kO(4xwAoeO z_n|}}!^hyQ*`OYAf*Q>iGSkD@Cwz{gMy=qZZoj7sJV#NZ1Z1Y$v5OeaHL<9T;iyq7 z_}JX_+2A=U7$qS=jS`TVp8p(0jat!1@^cgjXq2EFiI7n$8&k&#Sh21A=O|MTMByQU znnR=cLS}mSY!E(2QKMGyfqqJUj-o~h$V|79C7y9GuIzk8jatD6=f`}48YLhzJ&Y`K zh()2{ebDwn5bq$JckO(F8YLhzJ&Y_p#G;y)S|OdWL_R@{5|Ei5MwT9S-_yL*3h9hd z@(F5`fXwtTvb1kGH7~V7IwOvJf*K_tGu^%&4EK$~s{2m89?IerN62^=4jDcuM+s=~ z&YOhFkK8`Vrbmgr-+LfK&3BjImHD8~WNUTP^jk8U|G7DVfTsGl3#|r3-M1H6c}tav znK@e)BoL}m0=`+lXsMJ?t1D{Vl&O2veF;8PqlC6$)Q1vkh4fdRyEVawwtKv#lk1`R z#tF81v{Xt!rd8omsYVIy*{BaC)C#TMd*{@InyW^M=qTCHwoCbXGmjkq#|ie=%iN0* zHK&XcVn0j&yK|hNMhVDFH@`j`&~TaA*V#3@HQRH^s8K8Uz`mUR*S(;eJhX`tSBS>{F`f59FpCdKv1IuWTrdL zfHxbJEHv+^Z!+&jH7~V7ddowrSFFG22je47P@@E7raR81X0ORl@7*I6;jPkeME~uE+kMMy=qZddp9;exC>Z5faoW0h#IHxbirzs8K8U=+y6{tUq>x zkr)!xC;^%2)KXxMoN(GG)^yd@(-fXmNk$Im#-kbxXd8rlB z(S}cs`NQ~#6VxaHndx>UE;C~neQteFqgL>NvCwp_iLAtzk5ep|%}YLtM?^sseZ_6Id;1s|AwB0U%q)F=U& z>EXD-oM^`tHEIPPoFDTEYLtM?bo0B^Su>WM)p?)Xj@e^vTWvmUXSPN8-}Y`m89wGz z-I{&*jK58v#|dh1Hy{C->5lXLs_U2i-Dhj=_80vAphm6WWAn`$vxm0BxQY|hC}I7D z#4{z$mL1u!v$y)jdVW0ujatD+r=Q-&ko7yv}=lSYQ7- z5Asqg_^4a7A(%_z1T{)PX1euJ9Qd&JL2A^B_2_W)Po^HmM}$Cmp;3bVeBywX*dvW7>~0;snMkG)h>1A#t1W@m=SA(mw}2l%NbB zv*%Z-@aqvLsL^~OGd-+%nr%gmTEWNAetTE={SmZPNKm5$WTuBb=(5kLQ7ibUSiFBl zWR!#iHA+BcdN>kYbA8ycOO0B=M~_=-+1M_hphgMEOt%ECLHMxOAT?^mdid9Qa1Dk8 zN(GG)^cR^KT+Ss*P-cJl^HngHgald_8qF6n)594q%{h@8wSo_f1%KWR=ERVoMhVDF zcbpk!Za=uoKI!ej+)j;J!3SsUe1aM!ATvGsY;e)kvg{n^nJm_uQ|7vGrbh|;T(b)T zwBrn&)xP*J^K3A8LX$M}<(hWpi}dOfpU!^s41Nt5C#X?>keTi{Q_Qo$H1lk*$UGZp zUTTH(wk>kmi#Fn9hd4ov5|EkhILDZ0gYomXr8}Bu1I+LpMhVDFx1;?&^KH$Z8K!xu71OWk z^tS19{f?s=B`EvX$NxvbYIDl3N0;SsAF9!O>D%}5^#I6;jPkeTi{Rm{7kJUCL{k_oCGNS5%z9 zos6ShX{JYK2E0{Uad}r8z2b?EGSkC%ec|k*8YL))Z|-(UzzPun{N?*ljpoaA-$#Qf zL%hE}KkJFDZPulyKQuFQ>^)PmZxw%4cIU)9GOJIUoc;BM|CUWL>92HIXwx0%o=IK2 zYJ&!(8sBlSs~RQR-moB3_pV7<_x&%;%rJ7rka+QEgD{Td{_s-*|d1I#zEq%GD zk*gXd`WD@p>9lfD_L$}WHKl5^%{d;1Q^vO-P6xTNI$=JGI z{k=JCUC1M@`PgsW*ESm;N~ngdOTxCBCGIq_+qo~kTzsc_Po@N9TFv}myY+F&+us-; zN~k7IfcA-uiQOLjXJGMr<~^AbkZCn>hGW0!%4IwTp@eGU z1bq0!x=Bsa?V4^WuVJ1Klwc3SisKDeu-S)22-U<1`0$A*9~B5+jejGgH0QRrX63-x1KkI%?Cc%U zzG|*R%c+^R(-vlbG500*0q#*c+nJs5&a2~|@pV`4fy^JJs?l%hnv;2&7<#U|@~OSN zG1Y&_s749>O71xQ8xM4UtN(p&;OS?llu)bp{<$eLr_O@x&!&Wq^Yw9K+_$k8=-OzP;kT@rOL6gj)47#IfcZ{dVi)?aiIiJ$GE^Z5v&(j6H~Z z5oN4Y&=zr=6C1QlfBeg6Z`9n!B5kFFt|Bn+$h;cvIfwP~cKo_Dqj{;7wwvR;^~mw= zb2E;}U9{wQZ?{u!$$U`d{w%JwqvubtwS|m2p&tGfHR0P0>Cbo8^X}ZM*keCKqt{^3 zi??J}|7PM6dkwz3rJ8&9ppSAFA64q9MhW%pI4_=fhn4l z10R1~)YScb(aws!=d|-wqeQeGZ|>33?OA_u@qtxadZ;Z{I-s`bHOOz9vTG}teY~o+ zbO*iEtd;q135;^~nO^oo^YL!;<@Q?E?d{@_}7#rZFl zszwRUqJBO8ZLZo|8~1Ww`{*AyyJ<}#is3uN;_D|H&(}$FROe@p_XQlXOJ1L=>I04!x zf>DBXmi9bINJB==ak7iQjx0`4qXcB8n?3vcH(WN@yn~x>-oM?Nk`M?&jsrMs8K8Uz*z9VpT`Mmlz_~1 zb9RmSM*qE82f%Oi)Tov9VaBw7SIZ*;jS^Ol5V{TkBOH0D6?_akze=#4A-)cP8YLhz zJ%1elHEIPP8_qc_SeFrB2SAMykeQyp4uBf9f{)shTH945`2;meKxTS4cJamCj$LZh z%C^-$Zsj@|tYHa>VSz>o$V?BfC=hlX05xjGdL*v{K&iM6Kxw9jD}=(Y`qZ#gpxGbE z>j2QYTnC^u)AQE>Py-*7ZCjafmAnoBqXZfyY%7FBd>w!il;MN3c6c`kR}N64`9fy8 z`fv@O^;sr^?$a&d4r&5o;iKMbHX4Q8w>5@2*~QtGRd7?7P+a zpav1E$|r1WoqhV>SVy)if>!WB*>To-mzLaM?j7A4c6L<*AGCtJq2qzsDRcfYK6XXW z3OP}BoY!XDT5`bfzPavZ#H&X0g^bp9&v(+hCTNA6C_B!Z#>eI6`s)+;phokx^47^2 zzaG0J0xRnyBu1OI`qbPzst0YgDbQ%Xkk{T=J>&Pst_WJe2j#E_UG_OOnlI!M_tG`~ zxC;6_k)RcPP&QwxcOF{&ty$ky=S+X>Qlt4=Id}^De~7@!`Ur{mH~JMP53p+rY&;jp z_&$jA@9nI=4gdr+u$sC3 z!f+h`b8?*L4Kd3QC0TzR05wX`hhOvH%c#e1sq_&q)%LRn2H$Jr1T{)PW_tcQ0BY3A zma6k51=azq2sBDqe<9J}!x78AHosxnZq^%UUTTH(VK)v9zMsblYLtM?bjPW0o(<05 ztwnBpunvG4wStcg*9;5R0mKPvlz`0i{B;1-s1j0=x0y5K0Eb6zF%eJCM zt!zE!j}Q7IPHYG?N?3m(5v=`ix%NW|%J9)?-3`IGiWAgmzL1%ozYc&JwSteqJ#H+p z4uBdZATvF>f{uA{%^j^sAU#T`hBVh5kPcTmg=_Ah;hHENNUsyKDM_V81#9ZphgMEOn02Um$ol%bKw}T*<61uCpBv2x0Qc?%qMU+;7C-O z>CtsE$V>Nyr_A)^do8SZ=Olp=LQcNd#tB&AUW@ePdo4AZFH7b7h~I1J9abx+G`IOi zeZ=pz^q~ZOB;RZ4Bd=89p8fHAt?t<$p+h3R<}N}6R<=}?JFCXl-04245jrH|cg_eA zSXm$8-ne;$>~tHULn6L{PIuF#3?JOjHoS9&tMsU`ttyq{A8g0l4y?u{GV z&nt;QqlA@1B7Wx_tVAHgN9AtQae^An7c$es_$s{DQlnPZN3b7u0wFtPN9d42K0k$t^`eb)2uwcJmgG)iLqg+78g(dC>-f>xow zU|;uePOKyf?D)=eA;P)c<=jq<^%2mvgu$s22}FVOCb*Y>c#jJ2wbWQ20SzDA%Rir> zMhVDF&%f7FqgK{OxXXWheY@`RAE84ce&>u3ffamkXa2Ar;k}j`TPi&xAUyvgzFJ)g z%J9MSM#5+*T;EO&e2{?5^e~1C*SAxnR<<6&DJb~_=5yX_m1er*9Aeg4ex3Ow`@xJ) z()dO@_^J=uasB4tRe!g`y3SwB%FKEbpDb-*S7tWb+S5Dj1LuhqHTQN^gH@U&uzuph z;fqT_JI-})b?~x(pO}8*nEDYyt>Am^iz`a~@6Nh16RVr%&V90!YdHrue!U&naw0u> zEhjagm4KYQmXiclI?)Q>)sddOmXn$|0oo^eUti?i@!!YFy_CO}lLYdmmA0W-2VmB6 zuIf0nbiG;2sTw7)er9mD_uKhvIgd7LInOzKNw(zbt!X9HN_#fCGLx;#m1igwWUQvM zEt0sFlNz=z3EOUoYdJ|EUs_>RAkvf9a#9l~Kqs%|B!PTsh1HEnPhQJOO`HIoyq1#$ z@}(7hB(LQJ!L^)9Ku%uE87H`w6Y0roIjLc(NWe$(T22zIIoEO`J$WrBHE{wylGk#Q zU=MOFC(@JGa#9l~;KL`LGHW?MAM#0RLa>$-a|YHeVfMj{gH=yhAvpNj*V|RDVnXb&#YeKIs&JxLMIc-mwRi1GIEt0&JlLYdm z6=#X$wVWWJi4&lc*K(5JJP503>kiMH<~3h^d1h5(e=R55N{NGxyED_L=E_pnuI21% z)^cvDwIns7m%o;igj(@#;J5CM@dvscF7NCWo#C(L9Q^Db?YNec_bZ+4%=_Jbb==ag zx_Vs~`)fHlTVj3D;9q{Wr^#X68gt5D%SnwASc}!7U#(KC_cHNSqk-;Ohkl>Cs>ol< zNkXl#Zf#}5x~2YF&Wu^hxui$4T<<~tT25+|zzVUIbB+moj5ljJ`!C*JvAEq&PikIj zg_Uj~{I#4fzuPI@`H$&i3a-s*hmJ>49a+Q z?bqP5-wjUTYxrCZ_moDxJf~{8UCT*At+d_D+2o6lcSqiKM9%!T>-ttMIlx}2Seb{i z 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 Ss!j8b!b{c#m4T!?r!Wtu`v+Xvzf8Gu>-rs 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Ï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 ?y>9`> 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

#&R!8J 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%0Dv6m+8{%OY?Z~dCr~oUNz^nen4$32Y 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

)% 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 M@d=^|pN%B_1EYg~n*aa+ literal 0 HcmV?d00001 diff --git a/src/linkerhand_calibration/urdf/o12_left/meshes/index_middle.STL b/src/linkerhand_calibration/urdf/o12_left/meshes/index_middle.STL new file mode 100644 index 0000000000000000000000000000000000000000..1c6cf46a16de56b466ea07e56d89485a6d89254c GIT binary patch literal 496284 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

8)m?B4P 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

    $gg(Qi>y`&d0~o%&*%y0=nRSjVjgN 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

    (icr!aYhaAul^~o89tn6NC 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 z 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 U&3&H05!l6#!gr6ELYsEFsa|z- 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 EA0#&sDgXcg literal 0 HcmV?d00001 diff --git a/src/linkerhand_calibration/urdf/o12_left/meshes/middle_metacarpals.STL b/src/linkerhand_calibration/urdf/o12_left/meshes/middle_metacarpals.STL new file mode 100644 index 0000000000000000000000000000000000000000..7779af0c59c9abbc5bc292e6507f0607dbb31aa5 GIT binary patch literal 124184 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

    cLNRoL25 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%0Ag&>(^JMv~H`G&4?>4D|ka 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

    Yqs34KJn_}F##CB&9Gg_}J`9|~3 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�Wwq|F?WYGaapkp2R$6fh@3 z%8G%q^EO+$|O%n4e6QA9xrpnXfrkL8hE^WFo zkiz~9W5E8%_CX%bl-<(m(|2hd`H=yX0?Z%#XLMMxthcbCVW|Ne0bQ_nvGwkvI!dz> zHniw3e+m^ywC!d`PMw^DNAFTchFevVl;HRu%A6jd906UhKe9ceekDqVryK2)SChiJ zO+l<>)Sic{_v|OawxH46P1MjstLD>TvzIq)ud2?1RPg1 znwdL_mDy!pbpByW3TGMEgZXyll=n)xKD-6hcvquPfduTEY)t&xQE@xigm(X`!`mHYSM(h8p*eINbogS`X*k{$!ug8-HPiU&;{EVd#6*oG$n?8weSrVxLykh zz6K44ZBdF2JJNg3J{$pEuvM~uOv|U_7`D0X8(+81iEfWJsNp_n*tR`@N0&v-e;5> z-5hBz>_#C0U9cUquR*Q8sa%ONXYb6=a`Ojt!SxDzqeH_9v}4wOxzd6KIymD%7o5A; zw=)w4Q`_bKQqHV09aJCz=R~#(8;7F>v3qpKyY5uXjj9q0`!qb_SG2gYv@%(cum)GL z8Ox3H>?@VY=4f$RFp_SBDvj$u!gYJ!!Uumwi}MFR#0whS!sdfw#C*ex_+#`FJnB*m z8?O$g_d~tE-_YfKU#GlwtVFW*WaEPYW5o@wX5_`89DLe(1Q(C}yVN5TeIEBn*ZJ0V z1*QZNpPp7D@3wBh{#af0X$B5JA9tSA#bji21a!eMpT*eH3r%|bR#zO8!KKRUkO{e3 zX9q40;#0`p4;R!EEgt<**CjJUfhmE+A)N_v-n$cD2_4H)<#@a^y1AkXaygyO5zu9M zQ$u{i6pY{f#fTWct|;PkWi<57Vg|nF;?V>mC-sBW@(GgFI%iY1roh$ zeZ!IdN3p4*rfNYmA2jE%3+i5y$Pv($;Q9f3{yK{vo{M6{_u;LOk+Cb%8Kx*ufyAsv z?{Vt>Gq~RuHOB0}>!Z~(1ypuzDn~#UY&{yym&_(;!7~Z@1k~c%6(n-wpWyHl*YT+? zYP&jwr?<59J8xf~}Ix&c-8=jj~Kv`Pyc#wZoQ?msE`ms6Go{cpc4R*zO#P z3Y%=!eG+mMs6YZ<0kAjeOpZj+T_@>kRo=}J&}G`8Iw>4F6?;EaW8_SaLf!AS)m84g zLxBn;&KK4oK{x`pIHMBcm%q@%wf%K9qH`6fKmuN6uoYR`F=*h8M!J&J9FBl4c+J4B zqwb7Fk&hOLzY6v#DX%R^ekV6S1N=AeD zd_aP~{>VH#7A>$=qzWHH=RB9-#%ccova5-4;*+P{Vav8>x%SkhKa)$Y7>WmEnG>3~fvg$g9B z109J$Z*!qeW_Lz>?XXih9`;Ofo;{W$peyRO0~ta+gjXxo7}k{^DN}^!Qn%s=3Kd9n zTj5CRA2t`-pHgGwI-1fQv2UcrQGGZ9x}N(ul5u}|3dgRiG3Mt*()o#B{APw<)AgwC zL>j~uX`i}w7w;fP(qpWraPnD@xHpdxkIjX|7ix^+(S2#Ia#Fe+Ux`8m5}|t>$&v@= zLe={!arowNy47Nyboums1;&8%;>u3!Z9YZX8cr&a=@>>Eom?S3>{*FI1rmIWn3XN5 zv3<6L{aSGZbY1x7NE!?%(mGhGF;+|+NP~TkNWQk^3QRjJA4qF77q>N_0(vZ2Y-&%T z0tr~s>`Rz!o#|>hM{3!GC!mW@RmS>fO84`*(x@pB6e^H__0MMK=%>obwYid9#1qif zHrtr4zH1!GQJ+F> z=k00?t9D%!2klm=b)!iXDv;p&;J%(QsBBAZefg+d<>(s=^1hRs5X;8IpC2qp{mrwz z^arBF@Xj^KrASY~tBX1&+H4z#rkyL%#{JAzpaO{=+4acf7aLpDIj3ITMbcPQf>ZH@ z6`ME$x*D=kbjg4=!j_6r)atJ|6#T4EpWA)40$m1KcI4=&IFH)eZsO66cBIB~55HYf zHx{GT=r|NVVzu6+-D(9YFt_kb>>u6^$@ zK^LD7PgYij3tMSZSy{pIfwK>vs+&R#`rNuMF}RV-r3w;oCSvu$%BsViwc31ERkSWP=N$Le>|O@LVu)1%H>w4#7GxMvMtdEH?j&AJ3n?HH75Jx zep$g{^WzQ#c?aVy4(gq|j@KH{QX55yY3felH9*pM2SQW&W6QE0;?zkFWcHkXSiY+g z?=MBtZ{ubw7f=>=9}c?sYvv2Ng){ z+sg8^G6XvhQd4#K+Z5{JTrQUu9wm@~E|^00tu5yr(z9jhitqVY3KdAedjssvcHV-&?)6~;O}_6y`skbEeQ7}=tWCZK-}N!0oex%4TwaIL zEiD~MT3-*`_EeDA;kyI5@u4}^W(0{tG91XMyPa|8;p&~#_BF37mtUDH#qWkws6Yb7 zW-H`!tbD&wMRA@tnj@gA$u)c8_@g5}WvIru<2_Yrlv*mE{xX3=1rmHdJaa}Xy&`Os z)ei9-0bOt%#OCwnLGWUofsF0ykDI3PZ8zD{k$ib7V4oIhs-9l0P7R~Y zm22rexUzz!0MEqk*3LuJe3(WlE^N#Z(6v$LNNSAqz>BY{tBhSIgN40UOXT()k||Um z!KZ5NwP92-yD0xy^gw|Gbe(I@`evjDMi0~&DBplqE;LYV*!}j2-i~BdL`$qG4-%mZ z_Gb3H&zg8z>bg%3vz)F#1rjhGyURIhGX4HAOP&zZoFkwM_Aa*4zu$>^&kj~n=GUdL zPe2ze1@`QD2Rmf*s;M&6w-ZM|7wjwS4Q4){m0N5k=;qOjyAKBmSexv==!|Mqn7>T1 zYUa-o&;{=SX*3C@^OWO5S1a4bkD^e41Z>k9&Gv$$%4y>z%I%>;I0Cx(drKWtTPc4x zNmCLRkE2k51mD`r@)jvW8n7n;2aMqe=z>=k>nHU@z;>*Hh<@9s!Zc%wKvTs^>(S*UOP~Xi!%>`vpJi!`V)w zxralQkG9T|zA?Y{f%7?B8L%~V@dvrRSCC|MZwiG9j0aayY?X0pm11 z>uC$Rs4?WeFBS3A04Xmvf+L^{u4*+JQ*i{{xWq+jcJICdSNiecP9$}VjW(}upa`!S z*qys7Luq!e%Vg)5w@Oz}C*n|JgMM{kp!mp}9b0VBx49H3PEK$l%cJ5vHv6fmD*Dl% zE^_THZxaluK~pF4({-Qza}`$4S|_3yZP1^28Ys4I#g5H3=yUI?gpJ_8Zr5^pX2@oWv2(RXWOvkQz1hxlt-H@>U8x@O&pp8GWC? z7|;dtq|tmkqeVlLyvPhw4+<4X=o6jDtQ+t1Gqh?9yYS}dd0GfD7$I;3bn#_X_2me< z!f&KJ+2xJ``vg~RT>r40*oCgy8zaa2+EdtbAOZ8I(Jb!y7dq!ZLEmimWd#z@1^XuZ z53*?&uk3oi2Hl8Xc?kbq^#_HBKz9Z=QfII1;hl z9L|GK?Q!q)Zizn?s6gUBJt}h&efj#j;GYq!g9LQFZ0SVWjmXs2x~1ma=Bgj6RC|}` z`pbht0=oDy(HeWxf~@XR;Z1Mu?gt!e;hEU8I7uF~v|X^&^{pQl0}^o5W_Jw=TT!pR z@#H}(Z;pU2SWg;FBl(e1bz>@#rwr%n0}^m9(P*xne6CDvH-miA4&w;ug6)m9-Tv#8 zDbJ>peox19EfErMPSj{FUCL0}_M1k|x$y*a!G6PbfX-d5ENM24OzRWJ^$$q!D}Vz7 zJ1G+nd?Q^=CvyaJ!QRaNT{0~c3w$3bEtU?aMjJzM_}FqBeKSrp?DRw5ZRamsF2swA zAHLLon{GsQ+a41-kgvGwZwq=}(~Cj{mK7{RwzD{RuDIiCXZqoU zBS%1&`>vS`NgDEI~eQD0gEi=Y5YZeHgu*4&%~}doA*Pn zlM3`-Uzkv+K;ruGWFhk2W&A9428&VPKM0-CIFoK~esHORF5`fy6)DU;suGHQEo9dyX_bdScmH!g|)~rTz z-7y(?g45jDODym-BrNWRRHSxc z>MKUnH1Cf5=dBkP-nK^JT>>hU^+9jM{pW&(58j9{wQOX#RvCo~4gFRX^x^UWi3NTELT=7wEcJN9QZ-lBqV4(?1@rAZ zI0Cw0o-~>^&qI}0S;KsN`%OY*sEaV5`z^fNu0-s&z(aUvbrWw2ED^u-b*V^g7xp`Q zAHYN`r?l}GOP(hqs6Zmj#!GNMa|4(6PzmyNn(TISs(5UC3P(T}ED<)ge@K)H_q!gt zIV*)LD@Y7$tQE?vuH%8M4A}~~&PLs`nA@%I|BB@Z=z`_Oo;=f>R_?V5KD0Gy0ad3MAaIwQ%^>9c+3)C7iYord3{C5Eff~)j+`zoLBf%K?M@yLpxN^PH)sF;$aL(Ko=}^o`5qaq~Wa1 z6HtM~pJPA*y5Q=Aj{y}(z;;|Qb5h3x{s;Q9w~RrT6>H&YpHN#kY%#wHHaolbX9QIK zOZ;DtDtwhpe{A$W(B6&6&;{d#t!pbhsdNvAxT`%%e>;(yUl=3I^o>!V0tr|*Y&OUl zPX~TFj4!@S=LqPsDe5R3%DRi2EL7_}$tQ?5DtaQ!=~0J51ro5uuxp&}9q5aOZSjt& z4LJh3W=-oZxMbbI7e#dqSEJrnMG6`$49gx&p#llmrrDTi|5@?Nv=F9D4(AByDr(zL z=w@&mzmHbeS3!-Q$*uwBn1m-&s6Ya?G4>u7%}=?>f_yilN<0Bw>hb@r$YB4lYTgX1 zYihr#3&ekR)L?aY-ak8S@bBG#kVvWHRYCN5z^?dy?@`0*9<_gV3E|&+;vms?ZIQaS z3%5U-D*m&l1*`jG{@F2t)qR(IYljLXU~2zNK-WJzl<@D}nJ`sQfyAG44hiV`XCD*( zy*m@e*gd;q=PiE4cHRaae_l}vS-UbtScZH)pu!Qq_rBuaJ6T~2Nc^*}tilEBhK~Vr ztL}VK>qgy+4C|jKpaKaP`_BY){j;wO|K6Pm^8pn|@a<~nk_5W>-a@~(!PAw|hOKdS zpCbH=?VX0Hg85_poDm!EEi6z8Rbe})Dq8zLd%h}i4$q{~3|^Q(%LbOZuOG+9fais0 zV$T{lbfZ6ZpLZWT&4%p^`@a~FfbICt1azs#YHEM)B`Yr0P>)A*-A8#1tk@a$uL23! z)6{={{x<<#>altzwQsV2+^d)>!#Ys6RmY}Ks6Ybt%|8>+r5>v>e)seL?y3G)fduSn z|Nk!Re+lSPkJaD*_b%*z6-e;C_P@KZ|0SSHJyw7J-@CB?RUiRJiT{5W_P+#lsmJQ? z|9cnqzX~K^-(>eJ|FduVUjn++WA*p{y$kza1ro4t{{OqM|0SSHJyw7J-@CB?RUpCl z^Z)L`{+EC*^;rG=fA7NnSAhgSCjNI9_P+#lsmJQ?|9cnqzX~Ma7|fot{LjAae+lSP zkJaCQkL_;Hr_lNj7y5NNYp8?c5*)A8@lhqzIheg+1w5@otBz zm(9SNi+BRM9Iky9CVGnd4&GH`P&<26b;e13Lx%tg6-dA`)M(oF^FSpB%t-uHEk{6? z+5At!yYRBTPHAck)AbhUSKMR$0jU>-3M60|vS%iY>`^t#g+d#z0D6rPt$TSAOZ8I(WF1>h$0`RhzAEWpr2ZQ5-f!% zLC;!Z`ngYn*P)n*R(K}sQY&Z?rJ4*1pWhf?|NfvkhHAHxq zQ!cc#NfLJ~=_8m`{UNM>KADx(X}8Ui&%I=Hw??2eV3?oa7HKSQcTN^pj2JB#_cIdv z3n`-Eu%1HHBO|f-%49~YSXN25_r4YdT zxEh26%pZHsa;TARW}Y>QSlX2%pbOR$doDx&K*~0&hirnna`gcTzO1gazb|QTc_A}X zAC7=7*m~Gg7L`(Hn)y^Ie14*I#I+eYu8q|WkF$`LcW+7zUp&+PinfsId~HrP1@G5- zJpRGzBjid7ZJs|vjIlS9PmgdX#?#ts^LH*4-B-JlZZUBl>((z7gG)Tftk(1MeKPVG zQIeQKXZH%1mzA5zAHv*;c*tLmcP$lfoOUD1oqYYw%S**(4Lr#G;(UG91(g`NcM5H5 zVxly>ZX)LgxRIZ0pX)!DE)@srnvy2NT4Eu2nb>W<8)?ms4Tl_LglW_iddpX%cnY(n zZ=o(Eh$#d5T1ev=aevzzeLt2DWlGZuVo(P&_U6cyniM+6cfCCAkr$^x;>O{o4psaV#`gA7gEuMInwCk6+(5yL~zw4NW;_rpD@n@o+DznA9veIihSL{HY* zk3_T-ez*3lJyU2k!9=(1nu!bv=!#%1e=j4tvc96xcz7jK!x`748Ba%Y{TcT21FV0j zG1gY}4{%QEUEaN2$a@xJ zGwYk|?Z#~1#Ea#rBuGwrXc{?xP`M%nt zvS+5yA$_`wi%!RJ3M8KOV&mAHhFTLwGr}~P)?WQgs$aPmm#SpeQ`OwIU_F%|;VuZt z)M(2y>B1WmuB;%jiS;Ozepb<=%-oXcPXAZZ*_q}X0bL(jc#=)S_G_;`Q2XHZuE})F z-B(iRYY!1BkeJPSlp14rMUOh%CYg@jSt70Oq~i$a+OoluOi$RaJ$ha3wIMB%Y0Xw| zrJn6K6R1D}wlR%n0DIbgPWmgU)1vhRDv%hQ_q*IWy*k8VEUKAI*B8B)^0pbtkbo}O zD%nUKn?g^vt1KBl9x1_^g0l}_=ke?<=`IX$fMMywbes3=_%X)tP zC8E~Ho7*Y0uaAM`Iu+3N;C@EV)Hy5vV`{ z)|2|oT?(D{qq1}}>@!C|7o6wWJeZI|kJpTl?wRr{07$@*k6oX)Nuj+@wwFgYisR-% z*yrI4$9C7mu{A?;19^M3Q4&-j!MBn@1u3*7$v`e^QH3L*3(jyFP3!y=YGz<8A1JIU zK?M@9X4wvD_5}9urN(mnNu3A@=z{YK+p&Hlh34KemA};JEJ6hmux8neb3TP?Ei21c zmSu4>4s^l!k-g2Th!H(4-?M?v)zw z+VBW_`@F;ES+%6xw98m@`-Y=*wWKq{&*9wL-*AVy>eoDmlG93;1TB4#QUmQ+{~TYn zd4-pF)s}X3e2LA%U*jn^YDw@+Y!zO(Q<=m%&9XPX&)Fi~n`NmqUzCI{uy?KG`ut+g*Vd8-1s=h^ z0iW@i$F(FFkG*%W?pCQmp_y{;b`pXLB!=Xi!dVujc&eeAs*@SHQj6j$O6$!$0bMYK z>`u|=ENSIvD`nJdK2i6f`?;*;UV zWYwmM7zZOr{@|TT!&c+Cw%fYwE6x@#VO?AuN!z9zzu0>P`}I-3&Cz7^H>KSyS9-gb zC7QOl#x?Afiv;Umq5KM{J#4_CN@i}~VLLDjdgND56aSV_8rV?*DUDfTG z5h{OO7K!AJCS3o3wY2Ag2|3iH06VR-{txjrG);$XyC`=S#~^qvNDLTOi5w5s zc=Y-4OM*{URE&{zxhMwKqyZ1A9WDefCljE}RqmY>yzq+L;(jA%i_E$eV|6eM0w zc#jL^CwQKtI_?%uy{?pIw4(K`EI9(Y;JnMew6XZP^5sNxdhC@Ig8dE>u+&-qI9;lE z4D_VOpI6~(5E3wj>}mA{aZ00Xb4o+Ha|Cq3_Qu{Q7d~1!zP&Qd%H~@lB;XvZ(LCPb zDbFvIm0Q!sa4iwK;Ec+i%eZ5LG*z3)Wu;-rW4$$*y*U^soU@Ud9 zYjR^(dz^FMhLvvelQP}UbgjJfufZGvT}S)dkc_Q0F&b*ii2kmX(6G*e{PAEIg6D zhpb4qm#J8HOC`?48==UKqvhdl{gDPJkVx8OLBz;3d>~dOI%k=p_Cx*TZs+Hy>*jj7>WOfUfUeDxs*tFZEL<{EC8CYWb%)rvYc_5Wf(j&- zRI5Us7;eSO#;e5q=B8-&V=odqBn&|X5^xM=Z(!@Ujsq zQ7tQ&F5YoisgwRt2Ng&dt*J*mwvEHpzN^HMO*N=9-mB~%+y+4f5qXt`dC||18;t1$E5obf*UKoZCHB|{u{ZQJndP^lscUQNrssq_`!Wtj@Y9k$( zY)@wQcERgz+DKv%dmr*qYy8Vr?X@?5b)YqcK1%0s8w3?dd@;8tl)ZoAoubA_Ut&vt zjTxzYdDf02pbMsuy-#N20J@3Io@L7(>7W9MvBB(()+>7BTK&}+L6gI1^&YF0{#74v z1a$E^zYsQzdcQSR=I*=4_9 zd-S0rRYGYqk*;bsTz(rlR0j#@8k^xr#2Lf%b|ET}6xE$Z*C->QKGp~-kk~QFk(8Mf z6f8(m3BTMZT68gp1bjQHg9LQh9CIXw1IzZl7@!hi)B4fmc0R(_Atneaka#f8kr>A| z(q>IkiLrBsQpb8{1>^C5>mUJL_z-(Hgi}>*?|v%b7C44h^7E3~pHp;DfkfbI2eK*o zoHn2$Rny8x(Due|l3VT_j)1PoZjNO7)^pnDerk-;Yn4!?qm|?a@WlsNOXE{LpCIw(6^bV#u!w)T=#tcPIC6hAdY~p_;gz` z=++MX>TN3V*u5(H;=c>8^6rhG0*Qn1wxn$3g@O?kL{M%eWaGC5x7;7b5ztln3nOCl z3+7-oM&4ml6cfsJikpWas6fJU8cWs3@RsS@RU-6;5vmgN&2Ljoe~y5z4{vQrs;z_e zWr0dm53Y_vx3v)r&xRnVKw@2ETQYR;M(yhrDzP}*5KX^nC>k~#z!A{(Xsj)1Q01i7 za)U}VH8n%y8ajwSZ}&w|fy9}&Hl#u139WfWeLVOkQM)r*X}wo7G$_S^G$3cSTUi^0 zt;E^HksQx-*7jPh#xVaN&}LMWc8+pKP=N$&mFx{oM?|{iLUUpGTQ`n?t}WGAAAEeM zz+$Z$L#`##VB#x;?sP{`fduSbY-jN&flfX7h3wtP6VTOauLBu$dAGjhOf|;nry@<6 z`IW3(=Z2sH2{=lycfIV_QqSuT*VF4>@t)Ro`)&~d9qDWkky5mX=n$9(o5OI9;_ z=VYE@ajG##K-VviI;2grxp>V1b>?(m;Yw!?&Qr#Ib3ssn1e`_LyG170(B*AED4Ro_ zIRd(N*0vy5M{U7l=BjIk)N6IjM(p=fVkdtz9xNE^AyR)T#+x?@jLOY3W2 zDr%kU@4ZlJn>3f+>LUr=&wVPe51xY-z|FUZLH5eO=f zfc=KO+33wv#d+je`D7qZKv$_~M+R4|gUch-7;iSdRbHRHBqtmQM^J$T>^JNUnA-bF z+JwFe_2dcY+L>67tU@7p))}>b%z5)f8DF)((zDMX1QkfYe#82?Ymt&&zD2oVIe;Ue zYpF$T;`DJguKiT)AH&C-Q|P^|NqwElGDN*IU5mX=n`;B@foTkhuD_7p+^yCQWf_Gaqnr;UdD?=9lP=W({ zBB($D_8azXz*$RV+kG18gIc%RE{;cqYdkuf}jEk z*l*alyKafxJ#-JT>B4_WxUCXZ4B$<6? z;i6Kte;965$hODvN_co8f(j&HzhUpoNY4=+4y{u@UKr02(6#YIHKO^s5vN~N`^Rpt z#bS@`>5AR%@dzrAfc=I&^|HQ`biDR%W%`&Hj({$B$5W%(@ykZqeQB>U>;6~-6-dB- z!*(M1FVxi%*NWjr@!X0G_7%7qRNt$%K)0^dT5+>|Jh$qD1nf6#6wRNnYu9?AcC9~8 zKo?xqvTsyfou>$7mI3!@dVgJMhFVqct=p*+in!pjz1+OwRn%EC>bqn-b`B>X{?rH)Ou-~w?=%U5C zYKBMUh@o*D0bTI!7yF`T{vsXza!ekuF^;dNWc<2VAk;Jqxi z6~JSqZrc4C%I#XQ2r7_({f5nIQiiU{jpj;d-?1D4UGUBndoIIft!~(1Z^iTFSOgVF zz<$Htx%_&auKPqkrOhy&fG&71i@h(r{bJp&YF!oAUNHzNkbwP$?UhONMP2oS#8(Mk zsD`PD_^_9`(74(sF>Jk|_)u#mObhxfUifGv9(^@e>vQT8drszafDTpLw@K(PHA6)a z#$wL%jV=CO^GU4ps*)&*w)!gK$NvyF3;odXu>-ZHPM!#!3lhF(D~ZPUgSEc?A6bmr z`vv5ajl`|hn{xzo!PK&U;xfh})4`#V(sG{;Dv+3Y*i>wguu?mD$p;pr>z-(2xU{3x zDmI@Zpewg^C2?Gns=}MZQbx?|nuIn6q)Q)u+327Gi3PnFF|n#(Sx+VAbcsb{%_frF z4Y%kPH8vB)&kp)tqdthQJDZ8`uZ}5rJm>>gZW_&m@))%FeL7yH?9##VfyDkmGqF!y zW#6)aYCdL5V^Q6dzQVGW2RQ<|U@5Q}CpZ=@UlT1_lxOLn0*TIMX5w*&URv)2HAaL} zEIL=$QZnzjg(IK~mI8aKxL`bTyd}zwZm!kcjWrQ}%{IVR7e9(o%PNT;B}Ulu=YMk^ z8#fx&TN^HGf1Tv=0g2`5rebp4Y<+sVnyR~2v8ZoCHMuZz3r9c~EOqwJXzN&XYGfN_ z+s9eDHdT$qJ&QVF?-!-w#Zw02+r#~E2lvlnVUU4HugBtm2Wm?k|&f*B@%1-z$+`O|854HKsh$Bx%AjeBf6c=%; z4l0mH+4oU6+-?K)MN1(oO= z$J(gwgB)Sl`(ms-HjsY0tQYF8e2$$58cEYkHwezNU*UDH4B46?ExsqxZr9Kqf6vju zbKR_dU$|J8V9o9CV%zTbg}RGn{JBg`)n9+NLm4+}(u-uJ4l0nC-}ZvgA*%?x2K;1% zepW|xz}u95o0G>8(3RiulCb=I5nfSWeTvm#XbaRJts!-Lwp9leNUW$yg}%40W6wz% zmX9fKebLOjW_0nKVvc|=yz!`zn|>XeFaE`1477JfPZl<%J!&4)K?M@94B1LQ!X335 zWk%=UH9(Mnu9{Z+gykL&aZ?uqmMRqHh|=yk(qjwX>!1P&zCL_zIU&2M_2`EOKRE)r z4wkGJqPo4tH7=<&INYHIT77G)d_&s{9dOJMLISVjjB$pNQK!{{|CR^1SzTl4|yXaT%veYR)HTS)pB@56R=%x0m3#plfiREG#&57RNiNL_k~}H0OGu9Jo3VK?M>Y z51kQem7T;hZ>crdMq39tEc-T@B~n64IOI;T=a*LgVFtLfh<-eQNn5s6b-zwdX?J z@!7ad=kJU-nBa)2+&C%+o$AC9(ADVIW5KseHeOP#oDuo8oRF2 z#N1nfUni;~!=qWwsKe^L^3wb5I0CwAxAhPa(p{0I_e%NV6&*)F*ZS|Dgf}l{VT0;wsw_@4K(A)pA-fVgBB(%O zE7ph^3;W{O*Xl@}lH-PEcM6xw6Ge`Iu13Skg;P66;>z#UareibMku%IAwn$LBd9>) z++L07xLL#+bCvjT-xrxR*+`ljc_FAkVqm$EcpQ;^a(FL(;UHd2_(#)7>R?1 zFVjCer=}{ZnuI2ke#B?F8%IDFpQ`uIwW!=Ggbip!Y|1N#(g~pC6R(96j&&@b+R)cd1d%CTlEAm3em6N9LbWnkW4ZbVn z?$BeSJhgvV_GpWoFFsLxmVDv}=z?>JM)P8b4r%_rp)@>WjGzLE^B2zvVtFB+;jE4& zKU2KXo`oNj*|kkM0=nQ_!rq5G)eHUVP^?UxXoa8x3Hv#+5cl#bUhq_%)#ll{qLu+4 zm3a}hI0CxhT*B%jsxc~AbYE$*(2-jKK;lc=T|%1!_wll>>iqF2)(Opvf31X?)#nK4 zg6jvi_VKkrkNdAtvPMYU`~h8XW?x8Q68LRef|#zgRgH#;$e1Tm%VN3ha5&wT)2Ywy8?U?$#UuU2tY#GfoFrRCUEt zrSeQK1QkfY+GOjioaX5DscA~{xgHz=U2tY#JHtX+pc9)HDB>{_hI$!j& z(OBhYcoU9*E;uu=b$eR@E%Q%OY>ziUP=N&OU2LClqqgXoQCG#@&50wR3(gE0O^d7T z&?B2*Wyt}11QkfYwJ)1EFLXu`kL{JZb!|BUy5P*fcIsL9qcuYvlxNrmK?M?U4bR?& zsPBsU9eF6PA5)hjpbL)k>>01J05sLAShkv24?zVI@LGi3RSOA3n`W$(KkKbI0=nSL zz^+o~#-M3kEES*089Jyy0$wd?G~SsBh-|+skBJ+@5zqxkaJElajz(3kK9Qg5_i)#P zkQh?4l6c{IEqupKy>?Dr8;jO<*dq^~xSk`R3tk(t`Fy@TEllfzCVj0V*Ze$B*tPC3 zZr|~=xcBcBLiv$={FhuL`fpnyxNIO8mF{IRqWAq$Mjs!Deq0J59YaOb?7gzAQZUQUWN)J;@8Cs<=0Q(N=w!K zQO8@F(ltUDdV~x)0=me=-om@2BHUq<8l!bvd)kVHmi%#{@+V7akxhTXKFr@rjm8=CBwK z{*vgmfTk#F@Hx5rU#o=70E}C|$mLpw%g&8Lc(;9c&9hyMI1ueao84@I8VNHv0=oDZ zrZYU~=2}|RJjz*u3MA$ij&5EXK!;Y(!cVWP5d4#f;PP;98R>ZK|U`OvH2hap^8bbvVw;rt!e8&+% zzM#e!Fw>4cUJ^hrRJzC!&}IK}g)lXW2tUrKgte7DEq4x}%j=)w2vmM`89+B!zT*h!+WcvSU=cxtna5S4 znyWp{unM4~44!cWbPcPvQV0(wLTZsp>}zdL$C?JvwZm>>s6gWR_Z33$AR?SNtP%w; z>}cl>0rXw@XO4ib7_*f^aR?E*7pO#iHd0%j>_W{=N;m?#zBgJa%#nz2t|CUWA@;P% zvo5q+9a91oNX)IjQh3*v2z_>_G4_<$({YJiXqLv9BcLm<(@G)InF!DKtHeBFPwQ;x zLQTA_2~;4F^_tBR+p$pOr4qBloanS!o$2mz|;Os=t@99kI z+)O4=fkayDDj~N$7J5F+WyG?<4JkG6NDt&K%0*Uuc(}gy%`ND$S-Ha$%-;$HTag$>%!@3jXJP66lLJtkea=$My@E^TCc$jd77>;L93R=<*Hmx%YV865p~+v zj@Dc_Sb_v}@#F4YeM{QCx(_{5SVx8mB)AgMq$a!5Qw>GxA6iHt0bMpL(}iM>eBmTf z=fQ1j8`Cae|F}BucrL#; z{=dzP>=dOT4Ut3|oO9nNX)2*bT9l$RD6+j(G*udk7VV);!h0NPX)9^(MSCfu^*i_H z@q7G!*ZKbE;d(va=iKK$<2u(FH}3!Q3JRGXfyg5{7D_In2Ax#oX19{1@jibb&3Ix* z*MEMYfTasdp_5htGF_I3eRRz^In9!aO&X-St|f5{P0b!Br2s=T`yz5cL+5$YNK z^rM#rz22>33A8|BWxNsvmv2DUuhucbKOm4Mv50U%{0;>qpz6F`IeJ#S63HDlGNSvy zAi6}QLrrgcVQ7Jb|BbWgI9-O~Vx%;Ra)W52gAOgNYNLb}NJJ{npt5JHP-BMlorW%n zp>)TmH|k?;a!McpRs0(~?=z1YKRKn|^PdG!RnP(n_%>y-tq}p#<)J<`kLbk_P}O17NhE8z8_hnkg{5)gTL3-j zs84TwZJ~k|NWfajyw|TIso}7e^x7C#j)1Cy566&f;9hhuR;r0Jupceh-h!_7u~b0| zBw*`eetU)&-S(#w&HHD-5l{tvM%JQq=2B(6Qr&lKumZk8*v@qlZ=rQD-e|v`bXS-` zMiSl8y1jU&ag^ZN>^Tyhcb8uAO2v;lzd_=^>e86DP%Q0J}dGID=>Uffkc`~8T_8fFz{Bfic zFB4F}CRBF&1lq}Fee5|FI?~K&fhuprtDpw}M`{~~{ixsK!>HTYlPrxmA4l4lF3>RT zG!?W!&k6b~Y$iO)kzQXS&{~5f9064@ZJBJ{*zR;tYk~fgH&<=?%DioQ8jVo?zqu@u z=CUwtHaF<+NWZTX=-9xeTpItkr2ix4oy|h`mMc+ijpUbvAxF9|L!f!{mT?4B!5IL% zsgM!bjQBN!C!h+>09f>#5xI=GJ!_c?TA)YHKgWxKj&$S-_CB)Hxm-a4dd@N#W>2oQ zN}v}K_$P-d_-;gP zPY|{I_r)C&P{rr!`FtBXBF~%lh`gwP7D)VQTY&tkb|BB4QX17Rt*Ktn2pSz@%Mnne z8B@es^EOoSSX#SV_^u~Cq3upB^i43dKw^&i0xFKpKpv~5$adlbU;5V1lInzUZ@I*4|Qqid~i?)e5OE893XY#+g~rzn`CS7D(9bxQ)K* z#-bjT(zAmKdi8S>8^g?8MQ zRvP@$$5YuuU3zS-l6wxQf;nVs6Yg?4H7`-6_1;UkAMy<)9M9`JBIrCmb*~8x+~DYO zyi)_7wCfAf?h)qUIqE!1<8fs&{qEhQ+H=rdfEGv`y3vTvwTSXqJ5G9omtzxYr%oQ~ zh6OwURU6{pqs8Z6cr3?PWDo+?~>`2|h(sV!~RwHBWVtViw2j+chsY``$L z>>SIQL~1#>k9cXpIPN(h;eE9cDIZ6cjt-K_%5iNn{jgV4?0M?e*PCYfwpi}`fb z$30@FJ)`>$xqYYoMun~fOemaWP-MbikJEt>l{bI$>Zi1hF1 zNyewr152g%(c*e6JwG8?oK@4EBcKXC6I&~~HjQ33ek<-u_>I>$+(%({!HV{*)t;_> zhK7XtDVAUPjbUzC(msZ0kew|DA|0xnH)Vkc0 zLlt}`ne1ZhC~CUbj3hqUi*55Bpo>n^6f2>f{HA-(V^|mV=(5)}op6iMSyB45lr*|j<_B7zK zz9pzODMvAFkA}b#?d84b%_V)vAx{eoEszKuT!EggS*h4NPwEq2wirUIKXo9!SDzzD zKo!g_i+D}eqN@rf5SPKjlrSm_Bk3PnZACp_A5%>8lAdGkkY==(?|9OmPEkS&Bp#L= zK&H$0De}Ha>w`n{TF|$1{YXslQ3MI7f+fP%4N9%4Md1iyX*Q@7S|CyU=>$s6%~ee5 zC#A8dOJ}-eix;Ux-4&34Dp+o8d5^{Xn_PQL)MoF&UDHe57PPTRweX`p|=dvW+$XAI?Tha9-bAcGhn`oU-*ZzHqic5J4_4)({Kejheaq%S)8Gh>m_ve zX`JFozVx-55nU$Hu`gvr=hb5FcVT)wF?sh?>e}#8Jhx;H_gj$Y8B~pExW8g|YiY(Z zrznEDx40-So2ezh--0TbTQ>8Go=aEnDiPgk8!@y%qN?LlM1}T>nVwP_%26?Ne^I7b z(#lDI1XTUje1QJBS}S^Ym1bVDM+<48L7EtHc%cAiTW}TzX?9-iWFkGF-&b6yYb`(v zBr-ofL{Hc1D|DQtH0Cahqjx^c7yCVO;0UPVbLEpQr@I&J!yU@U30dVYQO*r7*PkO( z@%TkAQC!x=l2<;d|1%#P(n?Mr)TIa;NVouJijc_3e}R6F9a);}E#+!&ixe7m@Ubu_ zizlE8J`)>pT;=rHvhl*h;z%xyTesh%x}FpJ$|84Rm|M03Ta?p1fx}eAmh-vifJ9F6 zJ2Vha^k|wTy}>cd*-eF!)yk}XQ#b;u`~qGgy~cm;16e7swbY~}`s836^>*7d0a_q2 zJLnD4G{5g*!{*WK{NREVYW=oCHT|78M?h7VRnO33%@ZE4Jf$=o8xyEOv8Oub%QOL6 zAhFo_A@aOqf~>nsM1_4U&G28Mj-8AIXn{oj%%>=1X9r|Oq+E?|9Y=rI#i=X3`f&tQ zJz#ZL!_6A`{8x96ZJS6lDl}+KyKD@jQ84Ckt>-25Ok)wc{8WndC1^%a@2bn{9k;VE zv_OK7OcX7gO6x5@sgF7v3y^@S;)B&_=dn>}wWahN>y75q1uyriyRO$1pal}aUboR1 zi~gvF^-9d&oyTtEK4?RCY?{HvEMYYAQCKOGz1xKR4@l4P1AEgu-)v}J+I<8qkbqG` z*82!!XeVnEno|^pApuqJ`Pf~0m7{6lSreL}{Q^M?Bw(D8_0&HDX?ayM`aC3tD_y99 z?}?pCsq9H(^xdh$`w}IL8V|I6^opuCp z1XPV?eo%NxHX3CpjW}y-H0Zd@iBy>u!p(&L2D0@bzXH_U`7?%dT(4)y)=&VPCBcKY-aoI|PdwUueJ(SK@&O*=vi8IR! zQJn2|cD6#AbH3VOL&KJO(@#U5as*VtIWDUuxBJn-9bD*^%H{}KAi>XT&pvXct*raf zZBH^VB%q3)J78BOnCGVQ@VQEXo}7%l(0lI*=lH<&uMf-ehtG? zU{7AtnhrfToGP|tD4_)s8$0enl|e_)CfjqYtc?D&qotdN(*6rKa0FDrQeZoT5_{3$ zRefkvFP&0ofyClx!0GOEdZ-%cjvS zKi{hRO*9pt1rq#on0^kYc3QX9D^6d*kbtTd;djxxRzXPlRw}FLH{)rdt1c~1e1M?^ z60qFZo;xo&ecoe~%32;OzT=bCQZY#R~=iSLnU{-kt6e<=-%*7K=T;~p_{a1V}vDt@;8%^{k0J(ew+ zRkY&f&X73V>?O+m*-5day_81RbJOVPm$l;R`1TwDRdA-y;tZ{4(qmn!#Ow`kxflQ> z&WGJaLtFzDYrjghv_WF1X zEs$7JQGsf|E>TQ8Ev3<{tvk&<)03!P@8$@o>an*FrRi^16o^t9W9@=y;VT^?{Myb% z4Payf#sg%s%}au4Cr=%sWVCf;x9>P23yLa0FDrnLgVUR_{w+5=$~n zF^Y>CKmwKsi&;K)rJq~%C7ohda|BeuIW_xQ8jIA{e(Xu+wY=v6Es)^L>hazd^xRB8 za^9x_K?15^Y=E7jyWmDMd-Nt*dya6k?VT%5qcNv*6yEdCVK_r(5y-!8w7{@8>16Ng z0WFZ==e2DDM7q<;kpz7z;^xj!1?TPTKK)3ME^O{dI_r<;X7rGN^Jo@<+>hvowmrxn z;}#qNRs1Shrv=*7-gp9u@6d~j$-we~@dvi=)uBDLUpSQHDwlF)1qoQvY#tTXo)-BI zB}2|D5d1Bug5}0`-5(rEf7G`l9cs%;p#>7Kt+2hE`-jpQP3_3|L2e32Kou-E)@q}? z>BZwVB)&_b2ed%qPK#3XJ9m@9_n_p3&k7z+RS9i~y zi`ypy_crjpsIqVf9p})2W-Iz5Xn_Ru=UIgQ{7BmLxg}kZSK$E(sB-Ic33Yh881?d& z{N4R*Zt${|32nC{5C~>jul_(oY#BYVR$D9065uZph9-#?PhQ154DWbDIm$0ttTR6*4@A zF4~=`9#-$c5m3d?Imfo@K+E!m(Df0Yl+c@m>#EQLWIY_)16jD%n?{7KQbG$P;7o>n zUu|$N8h)@3wX8qM5l{s^K$#4+66m>g-RXgvUJ7V|1gr~e&vx|?x=+@DhFsmw5m3eZ zK8tRQqz50gq^nQ6V`zZ{tUWAJ^lCgEG)b2R7PsaIsDgeMyM;D?GQBqTxBAFo73Yyb z0@fZDN&Om5x0u~l2Q)vy5l{uaL7D8|`v_Xs{DOMM30=;!gaoWT?EHsqG^Or2YRfVW zj({rOL)PsZOaDw?qOOm#`P6-a=QNRc)@yIKLJ`G0V8@c*}kMidRpNw)`q)t1XMwvQ6}>^l0@UY z+l%Q(?F49n1dM*NJ524Osm0E0@oh~Pj({rYGqM>=mst99cA7ZIPFsK$NWge0J5y~j zlMXhj65F0J;0UOKJ|l}+Ce5Yu%1gwX*NZW6k z>}=YFFe(N3VXxy>m#wGL*@7F1MlB*PHh4Yo~(01y%6)l4Y9*REwh_Z97r)Cw zJEJA(HP2R|GV&#G4+D%Ez?)Fmez@;u%87`Y!5dWz~sM&IV z0xgh$yHnU~dsquHA^VVG;bA|HfT}=qcDIaPYlPFKGrY4;wjh}?M(ER2KLRa~fRQP7 zBT%L#sm(a7_+9SH5l{v1uVMaGm?hCIs4m|9+?PNLBw*}}?KL=ONn*O^C`N7P38>=V zpx)`0q|<1vl4GAna(C810`5*>U%^=3l9bq)C}K=~IRdI+?P1?znc9-{UvRe6s=F_N zbqU6@_*|X#Y)MX>$}4^1#is#@?cFu-Pps{xdqH|1Zns*JU3oR7XCCm+0aftcAGYW2 ztT8EknTn^xj3J*7K1boP`Ka@}-}qz59rSEjF`7T{FBd76$<7}zB2Cjb;C9Yq32b+e zxYXei>KStm-RvdpPI$GfIoZ{I6Hfie6Hvvs+6kg5S#2>NKZqGkU`vH97^cm3f4Z8I zvX$$Q?b6X)t{^ey!+T`*?{W}ClY9ZBNvc)TaXayf)8h%WKmv}5Y$eXjfEZQp$8Qz~ za0FD{JGUQYe!Pd)J&|^cJj~H2dff|f$h80hEs%gaHreXZBt!B-do4b7VjM?66<>D? z-|3Tw4v+D_wE+ZLAOUw%vKzCC49WELqu6f#IF5iSINHf%TV~%DKR>KT{ojWZ&HZ1C zty*j0f>L9l?$u-u_O*A^qQ1FMq}u4wbN^rTZ$@+WSolV zXOE}IQ)S4AElFR+7tL+)zv2*%fGXGu*sjUgkK)pb(|BmkN&BT(L4(& zjZGWAiRHe&_-aH5M?e*9U2Oc=xm`?+?br8m<^rOxt}bYU4$B2BCZGUM%Kc<#i#&n?iAg-vDl`9eqL*M;&)T_Pu^$=o!dl zWe3?k$hRoQ+X&|YKq7XI44+v(3H@#+c^Tu!b|%05D{)4E7e_!9^l;d{IF8+jp_RRG z?mpo>8A#MlXhOlimmrVslDE`(wH>)Xs-vJhI*=ov3ik7CEp=p9a{YIb(6^l@=aE5T z--fR!+&mk-J1FgF929Izj@=Iya?xOpfGRjLupL`Z?a1c~dxcjUy$G~GVrtYIbYS5j zL>5c?6K@r^BQuVy5dtiRas*WIBTk)Ld*ajjjId5?2sb)IBGc+VirZU?8jneL2yLuu zLl#sY5-z_O#t~2jM{0IsuYPNydG4Z6`Nf++3nX@YyoBs$R-j(y(s;i5gcT{eSSn2F zJc1*j3Xbipr&d~$KX1AEO1Aq|so1gc%w3^@wm|tv^RV6&xkljdJ}>$a0$# z!X(Yn+-Lxa#}1oN!;M#{d2eY{TUBU8yzgxku1_1o5l{t3JGRnr$dE*}Pz$B*W4Tcc z674rkMS7NBk)N?Np4Vg=kk0|R!pO1XI0CBp@%-jlE#g@-SLoRk#Es67*m$HRvO3d* z-d9PU%#1zSWPHCY!J!*ZKovh4lr8=tj+--ESl2C>>)RnA%iZkJ^EtagX{e0>Fk`L#!WG2aQIxjBEG!bvT{)Lv`%0O3jf8tJ6-_SFwJmku5 z^-HL$L#aMn&;t{xhl|=Vk*xK(Bi0tL7oi0b%ROsRnehqqW`xvVMf(I3r*^gC_%&TQ z0;(!%-lE8Vhtcwy|MqOB`4Nq?GBQMGqX;dKQ29PWnU5$MHdyMfK8E;{nXINb99Y5; zP~}nb2>ESRp@EjtnT8<`29TAj%t>O~Z6dTlVwvs@)NuDI(uH5 z>j6hV)ru$;3V(4I-P|tqaPLjKk?r{%h_QK<2rZB(9C8c=Uw(lygQfmTdr()h+TWbq z>im}@psG#x!zlCOb5yJ+^*#x$+LK*DZAn&fg9t5<_?WN_>HU0<9(|IC-&Ji1EifiI zfyM+9P&L2L7F4G34r!!F=ZZ$KoAJ&aZ%J&ejR~|s!p$um<^8Qg-zug4%JqE{RGISOS?$Lx4W>Wk3+u4w~PBS1yQ#)}4RKb?U zT9hiBd_1~N{7|x$tL>c@{6@!3($TXy^%%A^c7uRh6j4oIAnrdK#kWB~yiU=;8dIjA zkfGAtIg5R3Qqy#vcq{)nM?lraNgCL{?|d}yk#xW4o!l4_l4&nab)O|d3nV-)YvFay zy^zg(>0ULjU9-uH0!J}2{uoC<74O5vcZnq@rd<-|xWtOk0tt9$E}PwD%qC+h-U&y` zN;v|m;0?IU_nBWW-ru50G;I5DeIo2RVQXSv&oX! z|D&R_(2paa3bq0kBdf3={npotsfA8lZHI)<+-!7Ubqz9qDYdB0E!q>k88^jB3rCKC zD%due_iEpk7~?PE_Qf5!RtpJ}8HZ5hwx{Uze5swUSlE@Eet%CqFrx!UKo#tj*lNbR z&g5Wbqo~WiG6pS>Sg2B<%4T=a4nwKG3jFIro-chUhUHsu1XRJ^mBn5sxsamT2Jvu! z9)T7}xH()!reiLnhzP0QE$k)|+iumOUO!!qfGRjjuswH6M6&qzd$HrPdTum;L^H34 z=$W??Evl17oKv>!hCj!%Vr|Vgj({pS+Oe3v^$5~3_omp><|Q|(K_Vif79D?{k5-?T zB1HzR14yTf`^1V*k2wOW;Hb?ASs*zQdqk|YtLFMENJQ@cj>1Rgpv4~~4`8<#Odcnu zixaQk;0UOKy&daCjedy>o!5v_iK7W@chFmcZAB(C6n=}_<731O`*DP`056R5&e*rf z^~ki_W5x6N{u}{Su&uBiDOdH#+-H%Z(;Z&|Es%g-CX0)*FJR7d@)tt`MsNgF!M4Jp zQJc)znp(Oz?8*=VEs%iTvP|YltcXX>EV1k0K^y^9u&uB&ybG+zNAH#5h-01vS|9dv_JyR!dR5EV-M2lW|H{iSWk|CD%e)op44GINvlnnVrruU zffh)>*(AF|Xm(%nTRTD=yT_g*pbEAXHsTy}Cnk3niA~jZ?4IiX-#Q@yXT{PPll~-W zgtvGh)s`cm3brn`>*azc(f%?{44Tu9Kno<`?4I>k>cOP^N(b@RKpT#LD!$eBj2%KY z@9iXhW;Yc=3nXBaLMHoM&*tT?-wI2JHAg@d^f=hOw#=KjxYP#Yg2KmtZvSQO{| z2vS$SRfu!7;s~gM9tWEn^cX>G)^8Rv`dbrdfdq^ivE3rhK4i`qe_{Vp))s+R1Xa*S zVfTK#aU)|@ozRYsy}9ojKmx8hu@hCj`w`D4MY!xJ|3w6-f@?_ZL~fCVa&~4iX_4Dp z46^@=o~>0PVOB9N>)(j(vA>UxEyiKKjVQ1{ftI~2VLkP+X-M%pG@0x#G~)=U((U^f zMeb4{+^m!l=cW`Z-+M107hdiVp#>7_k{VHFM+I^jEfGaueXxmj7_oPH#1T;SG5jBj zoK%bku&dYDPA9M1O8wVUiJ|g?2rZCU9@m5_9UV>*)+e+Lj~fkgi;%_b z6{LatKi`8?J)|^_y+~7?{QgM@IK>lCm3Lk1Kf>EfB6eO)7tHhCAN;dGP7~_2@t3r{ zsM*_-_{b8?|Nc(6eGbCm-dzFgb-5NYo{3V!xexkt?Iw zDTrDkY+T_gwz-nX5m2?MpBAn>cm!SSCG8zdIks1*`R6FsFJ^ntfdvwKAIb300Y^|$ zONsb>+fAt3RVPl~63h`$l`Q{@j{hz|TYgH!odio9kdRD{wlGn{b_d&|k!=&o%vYe# z=ZY9%Jo%>5v~>z`o$_4;Es)T-`WNZdDUg=UX+|_}rYTHFTR{*H z?Zc!r`i$R8ADcW6t7Dz1Lu7~xiGSSv+Qm)>fiz2a; z_KW*!O*jIooc`$H;?T9Ii;a{%(uWT04(K;`V8-14}pbC~7TU|)?s!b}Qtcq;~EzXbv$6QmQ>F zas+7klCFcBm+Vq(`OI!;;?mgKaUMZgh3W@-AzT`e_~_dV*Y(U%jL?-zH#s7TBz!oa zHXYQ1BcSR8dxQ43G8NJPy}QN+hYoGsg)5I}H(THa zzwIUc*#B89wqGo*P-< zC!VWIy?07!>5H3(c|GW@G7@cTyTHT;#;)=yp{b<6Mq_D#d7}hw8i%)}klgjxRlDBb!_WeWUSVc9#qm$+{&7+ouPjnXtbL=Z^C4f3fGSwh zY$hC$Ooo`6sHcsm7+N4OC!Ko$Q6pDm9gTL;V% zzg4VPb+9tR8h0k6ZwbX1_K8BGA$D0ChrZsF#=&TZXcCpTS#(?IFF*?>|Y^a8E%B1@0o&5kCjTdhJAgn;KC~LOstUrEs%h@Wna|k8c$~J2^R+q zFysiRYW~m&-+Ui{c2AJfSfYq0V|q*$Ei^3zXo1A+3&z;<2tmJkNTqvqbRyB*=qcuX zKZGFxRd@HB;xl<|QPY1d%Ew0`U$7Jh0YKno=H5i`8bR|^#rsg{@;CzEn5EAc^> zj~EhAW$bE(&D>1TfaTJ2JU*2|1kKArbnbc#Es%Kj)EqDNU*vJVK}th`QpmQiuLRS9 zW&$LjYO~55m-jgC;lEu<RKZJVslFBMtBaGxnKM>Q3;{<4d1oRnMT=Zlt zxzILATz4`|fT_b=Wj!>&p3_rMYLk>JjkIXuI%bn-RoqU17D(_dN_)ZrVlil&IBirI zM?lrb0nKn~QWh$(m7Zh%t~q4fM3tCoZ!16xBzP}FZaSCP$0|gFW`-OARj@?Z7mkL7 zku}5bi}N-vpu5{t75!MBntmDRc%b)csJ{g0|ZWBl*YV1@tr63*jfD-EZ@$pliRerX&f3_qrU z>&gohmk$(TIN!;>t%WyQ?^hfhBK2^;-NMPGDb?zBd!mJ`E&tGnlO+l?w;1;~lHm$c zq8R6Q8uP^9_hBSw{2g_l@S&N%$@yr;GfGSvq>>SI`SRyxFqIRyc=5ht=Rni_K zJSBRBqNSw!&S#VBQRlfT}jTO!1UkhKfP| zjW|_#iDW|0-s<04zFcjGUN}z}^+_gobj;K-(VYZnfdusW+5Qs46yh@bg^FfR;RvWY zm~4(y_OCB>{w$TQSz-#=y}42qwA4m`7D&L^3-e@p%gL4(F> zIk~Y$PxY{L9G3y1xGlUEZ8xcgrC~1KDcazu>M{%+`a93 zMU87IH$VS8(GWX3#wu*}q@H1W-)M3^ZnL_n!!Q9_VEh8k+u5mg++1x#FE9Q zqSP~tw+qk$3BIfjjf^Kb0io)VD_l4Ns$j%ICc9gZu5!QHMs;YQoWPL<#=@chz`ly; zw^+4T(@OQjM^2yx5~?g+?B}x_g<48IL*kbes_}XQt*D<=jx7fGX%^vNl+?Qq>Wwl!^d3ffh)tu4eQ0 zr!7$8+y8peU8$-X@BiS}BcN(aTlleg$~vWI2HrNWeay?d80& zSk=yCA|4RU6HwL8*cAVLpIMszKuRO|FS~y}Y67m9$LcPyK!We7N%m4z-?YtWM-)#$ zl@m3^v}%F#)@msY&#UPwV^ua9vOrFt1rq!_-_D4A7blk1{|^CGKBrmyYUgD4lK;}^ zwJb|D+PqS6&R0&L1ro51vHfsgS(MY&S!ADV6daXC*mivf^zYv->=9{n7H_(U5|^m9-ZBf^~%NMqnAQ7?9^04nq6tPZ9V`XF<$*!FzhRu!;pal}wSFm2RhapC5T!q@9RPfiS|R{Q-x_lnApZOL_L+ne}6BYv7IhJ%gezHDEvw>GG1NvKe@s^ zr;#Nl@5H4}kz5*(i0RgdWY3FG?q{i%oDB>me>%PvuYX$15m2>$=5J*G_89tmS$gNi zS7wl-?H`KYf_P%}ei^p%K8zlgUcxyC{vf?m1*pdPG9wJlLrG25Yw=+1Y5`jM*|Sw$ z%~JFPUHPBq_`GK-$t`*(Zt9xLt$KRrezOJzms*m)JSTpd=!9vK3w1E#T6CL}QT&woTDR>CL2fzBnL>xaZi4Nq$ir0acb;7_r*` z{mhbXjhOH@oa}5_CEA&K39v+AF9Az|t!8|gN2Wi?7iBqR0<=H^_82mmLwF?lwED2v z;^qpDfGWOpWrt(P)8lJIvD`|47D(`YNlwRTlD=k>`1{;qj)1CwX9n2+=M41eSp|D? zr_^}zIX+lS%JUGQ1rq%G7_>Z|yfO$DGi>{D1XSs5U~4RUI-?Uyr8JgWC6Sq}9YoVx zqXlSz1phwT{z)JKH{8Ub5hpnUs$!kl8#HT$N-j!ibarLq$Nfxk!Z<$x_O)>QfX~Ec z6A^LbZ$qSbW5g5zS|9<(E9Q}HjVFDVgox$g?=U2w3O*Cdm16?&D;q7olzVYC5fX6R zWxHO6B#{#XI*Wzp133b!VC!O017lm|3(I7ZNv%aVesr1n<-c)oW1AZojtndUIdD?x zg)zy*E8aqc7D(KR{(_#Jqo}_^@&G(0R4F41B1wyTM>qni;8?=$-uZSy`EGU;iTHR> zgceB5IrRnU=~DFEOB$nYXltn)*GwUwKQ(X!RKbymt%hH`tK2d&m}E`+DMAY*yi2~K zxD+LFohyxMzC*^T+V@68X5E880;=G6#dbRFDo|a_>P0#mv-3q@?1O~Z$o6cfo<>bd zX;drvno+!`ut6+8H-&Vr`iXi5pFoc{-Nvw==L!9kVwLaa8{(Iz;RISB0mnp{>_Z_@ zUAb#R9yJW(2&jU&Wp@sktx%2XutuC05X0r_!`XUNIsO>hQpV?yeKmC5NLB0sUGdEP zWCC*niP>gNs3PDHI`&N(o!2iKt&BVTQ8-RgI0C9*@5*+6X6;ptDcqvUnv+7H1rn}T zej&+I%XurM;a8KU%FOzpa#AOA1XRI3nC;kl{935+m`rA7)vMtcUL0A6@`4m-?4m2U zE&B>eg9Am)&9AXEHrAvHfBLQvcehzc;W)S`>^G9U44-}f>t*!UJ|wK1u}tjU5JRDt z0f`!|M%3V5fXa=eF{-_Zg|NJZ8Ch05iX)&3=9b-ghT_B)N?juE@uScJiT?TZNJdYg zffJ=PUeEvTVZ%-|4zeH25l{t7fqiMi`KgEMt(_epm%9kl=kEt7opN;`n$n#Xm$138>=V$2IQ=q5SLuvT{p~8d@L$eJz>n z`)vT4y2^JqBpmdP>$ zud9|-cc+_MAdY~l6+N3{e-9&6*G1~v*IN8Sn$KEL*ZzJ4S|9=A0c;5Rf@%i`0M^QDD|F{MtS^455ue^^~aWR1X>^gBNoi}aY7BytJ+mfrPE{IiqHay7j{N?SEqUp zZ$D{nP~B94G*3lSjftB$0;*ulf!#E9pjg?iS3HgMn<+vIB(5PN{NhWU$Cpy6X9%@f zq5R^QLWjQlDnJ6Ncu&SNE=M(!-L<{&xDSFBNI1-4X_VA?n4gno6Dxi>2>0Sss72fB zDo8*ToMo_c^ydH23)eV0LzSS07D&vGG{RFo>OFRJmeMGUju#5ggwZm5mm{DG&PAA? zSh`8Lx+9zx&AO$A7Dz;g8R7E5|2$ghU14ctkMH0y@$di|PP$M?Koy+5u)bt(7eVPU zlxlWwPoV`8)?O@z%w|Qh?^1u&{`YWUMM`IS{q+EjfGX%;v#&u#&KESU+S5Z%2!$3% ztaN7kuO{gu-VEs-Z{PP$SaR4&ywRq6IYrN2Iz zKvg>13+^&)ChD0}g?VD%xir;r?<&=?Rw)EpAn~uf8TPE1jDnM-T)Fj6SB2=DQC;!l z38;e6PZkT`?xuQ}d_eW&c?y9RNbo)Nyf1000m2^DfL%NRRj{9D^@>C)y&B}CS>Yyh zTxU&5h8cZ^bAV^TUg@`-=v+H=g-Vg)-a-8KLbGvBrdGe zS3Fv4g@1)oMvT`=S5ByBJM}6mN+1DMx(<~k%4XKMrCchj!B18z9e>J6P-K}qv_RsL zp{XJ=%?b~zuV#d?)oNvbMhqAn?Ewj>!Y?com9bX%i4w9 zBV(5pew}|BJH2MNq`kJn+f{j(xJ7tmiq<%8OD-cUglyc%_75Igl8r9;6OYPZD||7( z4&N#{?QuNa3U_qBfnnNgZjhFZUo+x$&^81ukbr4355R90c4(B7jf1s00;*scvQ^|x zv+*nT-|i82eTX7XJHv#vvyxFKcB=l@`tSHs zMLc?>ZGvAHe#T;^4~k~{&yzoVV)e@3EQvfB)?M7yfe5fahoiyJLKe%Ew?s?!N~@M5 z*uIPw%8|m=%r`2yQkC(=1WzSfJ<5KmFkIPUwd6uFS<(H6Fg0R|013GI%BP|EE|eVK z_gviaHBU&oSBEOl8MIeUF+7O|PrI>x*DaLvc6%+(?~*A%3p{rRe~G>G$zsifcSkK&)o1df0zcwUjs4YEVX_Mup#nU+1WzSh@cWHYX)!9#EMV{B-R`Nxf5iu}?btv8{uVrs z1=E(vgjdsudsB_rbZh{ZD@ee!WwL_aQ%TUNkD|X{d@F^KU&Cb4^PH_@3cggJ%h4vTo_qNpJ5bVoZF204DoHcU*s`3J*D5sc`-l4iGmPfR3l zAG5)+%vFFENbqx<R)GBnjF-VSFOw~M zx`4PZ%4WN~69s621bjZWOYdzA37eQ9);nX4fGXHau>17aCXi_nL&Ry`O9W_v1S}D@ zdnYP^w0t~R+-==>NW2HKouVmF8wr> z#IV)y?gJJJ&;nz~e6Hrt2_bH!TZGv@k#&F0$e45t3bzleL~OdC|at2pAnJT)5z?RZ^bzG z9RjpK0C~aT_?bpesRlZ>|5d0X#cBw z7_Q2(Ip_BP(sH^Uae28yfEGx=+_Jb&+GH}dv`MtCSj7=g1y|)*ETeNU(YskE7QfFB zpal~AbKvqY($(OuSaExi0L%SDBwHWqx)1r(-NEoZv7G~D5u`}(yg17;g?odL=rceA zS7dKO>uyQ$lIw=E$(uG8#BOssD&cQI6^yp9IcJJK={-JxvrUY{y;M4>k!1Ep>iZ%L?AO{zc-fy`8Es*H=;5tgRt3kUeUoZk| z_9d0=T*>&^Yd8X`?w_kf6~bH8X{VG%_lrY`{=K#&M3o^x3nW6FpP&xos!`s*CyaR3 zei+#w-Ik<0Sk4hp1?w0~qiP)aZE8Te{aGtO3nad?``arYoI!6UJY;FK?Hx!g7U+?r zGJlSMDp<$ZUbX#G32}TU7VJ61wJ1oy`o>Ck^;CjO-izk!)?WBqPzBpR+kx#AMj~_W zivtZ(xt;+MuwP;42irXrTN%zE-WL9Xa_(|uKiCAjEW3lRnPeij6azeF%ohyn8}pEp zeu_D7*_!j)Fs>#-VlX>9*w9fQzZv|QmDTYzhQ!Ns9C5Q;$PrKlYbDz=p?6q(^(m6P zU)YkHZNnM1&lX>ls$-7(=G3z^>WjieS;u(t+QC797D(8PZ-)*PSYnT6()@gb-a@ft zQXConQl`EhocI zW^n{m!H5pK*=SgXP#i2LZ=3vZ{dN(V*0#c}ba!zvG8kuK_ls(z36$-`UFh$Ip~YvT zEvl<-g}*12a=*k@GeXV8p#jMxe0VWufy9jjf3(fKC4Lbv-3WB$rL8!uB#C59$ma;C zYFj=F*;uy3c9V89A~H8pTwfSRLZAM@&;p5}E0a-LkOi(8E8W(!s47IPSrbnzh>idW zsA@Wrh`v}_;vGMvG@?5n5sj`yl1^jmF|t=Y$_Pvalpz%^v446)Anzt691rqPP_Mu1Y3H2Y{s7FvuWP2GA6&;p6l&c*0aoi+|1B1NMDN;F7Jzlo%y zYdA+h)u%P5QR?$%xNG=9M)dIMM3%ktB2StR6QBhW2Hz`CT;VSirgaR6Q)Xh`xkq;J#t`j0mo9C)f8lli7Ry1ZaW8$`kk47gpaPY9-C+(`LGm+3LO| zYvM|dfU4QbJLp;5cl0Xu2qQ? zU!nfzo+Him$5?HJ~M;J znG$WXU~&XUKo!g(n{zTxf9K6V;tcX!xnx;_9w2PO$Sb}=NR{A4B0o^tsL0!g=>%?K^#M`uXz&(Ypy7#YMofZDMu zxzQP_pfAB@kaLEUBcofB5l_~0J_97+`)4aZW_^iEdsosJwt^#|3i?E>7Y%SH^q@02 zc{P*seINnrF}t~5WlK`a29ea@B^&`&(6?i|cW&E}mOs2m;FcAf4+ja@9%ZtsZTh4> zAb_k*jN}NY;yr--vkl3|4&z9xk_2u%hXm|bSd_EvQ_-4v8CyF0a|BdD?}L3$_|J3k ztM7E8wjR&|Py7>RRyh5Jw6125YbM&UvDOY9!O#K;IPS`1*N(0bdb7Bt z&G`Nt0ab7{i+#IhQkvk&Jbkj@v_JyZ1vX=G zn<>t+V|$SewKxK*pa&?EE$lZ}6sqD#)I|dUS|9=6EbB{Vn~H-&*j;@sPGU$v74!g^ z*Jp1o2C`Zr7N5q@0tr|bWU@DTD}*8Jo$nmw$q`TmeMYuovU!Cti4nQ(o)}sn!Pk=B zlaon-ZM4eHPDdOy*bGnZy`r@2?^WC^#tbKgMU*ywbQRw_WrhWIGe!PDX@^kGC^;E* z$y#OdD@A}7NTh!^!(T`DDjAR>?UU(SBqyyh-YDMM_vZ+xdedNrhp;rRB}l}P?QD&c zT~wLVe2SnQZif3YOAq5)IC`=feqr*WSYza^|H;+HJUNMZK1%7fejJwuB+SRLT>Y3{ zGA~rxO|^YDTR~1RSG7IVfg_*_mI&KfGh0qVL;9#HFZc*hRiiY;?F^Te_DH>jdE)Y| z6w-acUe(~eqXcMygnFJSp4@kL>G9Q4u12M#5Z$#Es#Qe-M?h64dsF=6!sAkdo6??C zu{fD5TlQ5oFf>_!7Dx=Xf7AmRSq01vSbP>^_O@A<`~IC6JXq}u$>I00H9G2)v6t~vOB zxO(fbs-Ext`zV5hlmcRRHx{sGW*OM1ScoEms9=JWB8`foqF`cx7^s*SD6!Ak2fGy% zyRj1k6_aQ7`}FU0X76|Mzy82r>-{7#E@?ZSQG%kz&xR<1}U(9zBK)>~3e}xq1aX>El zT0%r(;FjstQ@9Xq}KTtmE_~o0V|l;@BB^Le;`MW@>SDlpzo%9s&1=y>NG?ku&Z$S4{2++ z0$CHMrqMfizwX;}OTB*ENWcmvjt%)DP5W9P8&j1CbG)K$*SeS9rFt8Iz^**2?^5_e zom{Y7&F$^lOjTAk9jD(^^o?N!6K9{7OGz^d<#mnKe06j|CcQ9dl)h~(OTeyrzMrI| z5qi1ka4``H|M)xFh{-ecTdtpHSi!`cpJkH$^g`LOhMGq1#C_VS86o<>H)#TaU5Tg4 zr5e`?<;r5Ud~7ne(j`s`*B6cnV_3n&wPR&cVay46`zN*3PMzA9Iv#_Ah*_K~rIiThnYNpoUP$Y;FN+UN9@X2hc;Mn5^}qz)6< zRnX;|R1$bXj&oOuoa#kV&XjO{(}VLAtYG4w=DXy!pitg3U9CkY?y9HTw=+aP=j~<% zE0{R)^0SmSRWJYAr?ydPD-TgCGFe}=?Ug`a*D&u2>A9&+?sG$3%c(nLOg#g~>Fw_s z>9K1`)K|&aKqn8>ULqnf^0))-6x2(asU|v=MnV^c`=G(PIS@XIX{hk(VReHB*Uq(-+XJ6@QeG zwI>P$c5U_iD`k$$kuy7}#DO;il_Gjd*(V@*<`Gago5TX0tTp4kzZGREe&03hDJ#b#<+? zWAqN!YmnrFwsH;M^Cawz1&Qghr(jh3^Q8MZYw`%<3L5uNSATjAb*1`SqqWboV)aZ)0ev`H;VR*!w&T8(j-IE%t#|kFQAMh_6N6*fmQA;JBT}q(Sdft}T28#rC z-5Xx>KjKF#mFV)KJ+10@L#xjguPT)94Up_!MV9$J;rrvcM3X@?4EyUweGu;)6R(2J z$-KTq?v<&gF~(>FEe#*4`!_aLcvjd|J;a3gHykSuIHnSPBh%=Y)C65XPK+KanAlO@ zlz4v+lIxeNM9S*1G-C5I-BF8JfxxcMwG7Gmw5767UG)v-xTVr(7czC1=kf0*kb;Tf zm4>9b*J^pLn@XIuoI;H+?dR)WMFP7zg;q*#F*{}N_>3;yK1PofOo;ia z^4lW1$Krx6+EFC13+GDyC6fO%2%c1;c(#e6xYh6a;;&T0`H-C2;TpNyL_;no@0MLn z)sb-Dm(!tZ?W@Yw7XB0~nD{->fIM%PCcpge8+WVvv!SvUUyM6_vp`_ivwTAm-Xclf z(pzn7I}8Yi9n(V<+uehO-V*KyRcK8~YAtU${H$8A712pb*q=Cx6-&`i9}W@Rhr{XN{v+>MdWXZWUp|Vz zMX}IN#6$ac=| zezGO`clvw5I3qO;=k!Q0OMR;gc~FaC0=sa3o_|Hh`-epnF6#WsS_)$W?83DYFXzEA zU>k5#S9`Z5#R?{Hndkj>zHZ4&-Yb8+-I-dBs7XBd$g4Q!Ji+6${-bM>(m`+X=1{fN zHY|*V<2zo-sq-BuRxp7_dVEc4<5;lCT&Wv3qq}e)_zLl8k`rOEu?1CFqC2i6F86Y6+-VKA@EUWW#;Gh0)bt) z4(2Osc&y}M^C?Or-{}-9n80O)x94u*ka{Xq89l70KwuZHgZZ~h_4wImQi>8aKaOGr z6Jj|J3J!tfxoJx3vc3X=UAPYBbC8*SFfaeU(q!90iu(+>-o^DJ@5#8%f+cW5S<-0( z#R?|GH2mt!0P6`?l^VNk1p>QpjmqaHDg&YX>_O$?@nsY%n85eM&ob>Y2Pzw8D_1*? z76|MTYw8)lf}m4irqbj|0L2O>aE;2>Ms@LpMf2V(*9x}?_knxMxF5{xtBT3s;PO(r z{~}3vy7;-_{v(fUPxXa?U*9QVEjLiCU_wkodOaETpMS0#3e6D+?85zEKK|Iv*ZaiY zSC-R6q29a314t z@K7w6&h;ma#`mU;%xjXwqkEj1UA{;rJhCQ)b{b9Rr?GHpd24OfmiHt)#+q0r4svp- zQ%rC)^20`cg`*ERE#9g2OLlFHgzdn^_bF8Pr7L6SgVSDM1p zuqtxu^iCb6IvtLM4-co2HZ6KltY8A)6JHU}_Yoe@I)s#sa~25f^0>m&$iD8R>93~I zibreTm4}j$7)Oc~OyDQYXG{Xu&|_nJf%C^!dc3|FzfHWNnXeo;&|0ooGXPvhHP&MV z6Zk#xy^#7B(5~mI!NYCi1OmInH`s4r0&V%|C>_kl2DnAW<0!FpuIQIQn?F5D`*Q*- zm}NnU_Fd7k-93I=JN!X`4F*c4=QI_i9|Xm1l$EY3s58c=I_&!3ria6D;}n zgNoCv*=n32p~YePqDe6HaNH^o*d^wY6XW?QO39J1KCXv?6--na&)3jrU3Z$-Ma_x- zOqNUT6Zk&Zz9~9PU>DAze5F&H82D^LslD(#KAZ8d za0-1M#^|hJJoT!@PV0|dAvg_j&Gv1+>)vG}FY1`zKv;o|34BjH;>G)-znkzYDl`!Y z?7~l)&qsO1Lf+U(H2FqX;i~YJi1%U9I|gR$xJTo@0>ug@#C74D__+Y_9q!ZeT`mHF zUHBRD@%h0ua&lBGJavl~>Jt18@KfNsq#c+dW%7P?-3zW1EBHy{dW`R&yzZUsGC2lX z7`zbDz=Yipo)eoEIV~(vb4kl5d$d1TDD?lgf#Ki6E?gh;5zBIS9bH%rN;1Yn&vFYg zjej4p?Psx2&)?^J0$REaa_ViQMpCnO*4MSZItbQxZva@qErXbb^T)cnJ0}Oh)qWy@ zUEBCBf{6mZ95B80Lex(-V^Y^Lw zrNgfiJU6xbtpzFVS(+E+q{gY;YbNO4M4q8@Ie`^S;2AT%H&+>N`e2+2b@Sb84a(Ej_RtdyhyQviStj) z$fI@N3oI9?U*z6;7zOu(4V1_8j|l{J&9F8noqkxzo!lOBqH0tmR3F-0`EcMg#R?`? z4X8nek8dI`h*yb3zRPBt^qR`He@7`+FfrELl=!b}EB9*nniKWiBcO*vKV{L7-2#DK zNt;c{SF zH8UZ@KaG=pIpR@YB)e40hEf{Ef*Mg*$NmfsA2!-@A(=faEJ1m#l1dV#>M zGx60)Wv3u{$R;(7JtiU0R7z6}I&P#`!9;1ps${~#m9k^NTTYOHAx+-o!HY#bDAO$*d>8^VbMN0|FK$MHJs`PQ-9u9dbCOv2<*bO9bf6xaS8;#eXgV)-zX6O zZhe<7CmxWSS-vHnDc_{#OApDreAJv6`eQP9j(nlC`ME=Qy7-Bv?ffdadL5S+4O45M zOv5SAl|S8z?x_?jm=M#D+fIhQ1+SHQjkX8`c0FoZE^Qz=@(5$K4F1TO0l}sh6n*eo zigU)=P5-27wbJDLX7B!|ZokofI$XMVO{r?PiQ@c&i8a8_QQWjc-rHQwiKCuRhxp}} zm2tI10=w`%@e_3%{h*V@10{F;PT}cdqH(nfsq@{v^5{6V3{KWf0XeKx2}#=`5ZHy^ z4PQt0IYBq-)IaimR4m}%#rL5(`ctaUpF(p>^*+|#IxUmJF4X2kEMO(l?uXR;#Ws0F z!*>M#67N%gUa0e%c}Z8hYmATvCKmX7mn!0R$kx_s8V^1#*6F&XEBk7M2n2TFd*V;m z@x1ImELF)$4h5`WqUWivl9k^cdGKcSS#5lz)AhbvO+RDVIDx<}@mbZ%_0!F`TB%eS z>;+iCL}D4=8ExqSdHpXnx0kIPpbOKq(U(32fxs@DEBUPcnkQO+sfE6BT3^5lCbTA> zrCB?&<*SR;G#+Q{*KG+Mt~U>~5eV!Ob9=%3XWCxtT=nt!bpR`vh#g)gO{tk9+x%BP zJmZXX+s20KCl{wOOkfu-n|vRy&{8>}Z-~AiD1%`I6MMBECGD`|^6rspIlo$`yLRIF zSbb?yQ=!yi7p{-__c)8|$SwzB^=XTT3Uvu4LQTu0#AV0js%>BJG&-;OF7-Jcqqp9C zS%-fMyKt?|SL1#Pl-f>=(U+cisly5;rpzppp7cI0Z(a7Br%^tp7Oki=SKs>La$aZs zzcvHAa6QlG4fdbVKHM}%KQHv4f)z~U_Wvx6zIjv*yQQ`UDJ}A~6At&)zx~`?f4@z+ zR9GupzUB9n;JRHTuKtT5n)9yuGpp<9v4RQQuJDs+mN%iE<9zjwqdzK`z%G2Xe5P6- zOShffpad6%=ogIoDQzgvlsh?zz2bqxe@IVS?UBDMQd^u;RqoMMKMIszCuZrff@@T< z#n~6MTPj>4+pn&!MeR4r)Pl+4vqJrL+`{oSUY~E#E0!(w!4Td-J2JjO6|!~n8fo`l zwf1SgaIWt0y_w3R9pQi#OpJd1Pm0Sxu0BHxPM2nCd-L_TT6x&}SfnAqw8#vFgcKuB^BTeQSNWp8= zv4LG}AKidZ$EQV_3;Ad z7XQ>5?(x_V-G29#x~LB^fE7%9E-@ud>%}`Ky-|s-4HoDkt*>b__<0kUz%D$R@6DCqu9P2-s3c3s%-jkIzek29e0D|X?L9-m!`oT$6-Go2p29|Ksy#P#|nME|Cm zym9e=@7yLwx8(DCI&NUJKwuXh>1i}8UPtRp_kN*Mk46JlF!5qTbpjb}WyMr2A2oMH z>%OlsVWk~K0=w`?kDnj4bBxYwk0sMKhz6_(gqIPiUfD;s4^q>}%1qOh&u`886-Ej~ zBy!=Aoo5I;o%jV&=TY(-UayK$6kvsSH3GJ37iC6tY1EpN^j2<*b+ zHXawv8=$LcGLn5|5r7p;)ZO=23fHZcHLKM+(ZXSgZuz=d>}zbeKwuZ{&+`%Fmt{C)7#>-W4$r(2v0DONC{%(WoC zahIHbmaB1}j*p_DZQ}!UnC6^7U>B}kIdLuuVhRn}xoyWORxok$UJbH}+DIki)doe^=aKAuc7j7l^+VhVQ5c;kQyR+mZ#R?`|Ld}S^w}W)^keWu1^^x#(TuV0g zRE|Jk7jEq|n$bt*L)DWb+4z3jDR!OkH6_;f`$_qq9ukpAH=76FHpMct&Y2V|m>AZ* zItgkQCI#0H4=7F}?Joempqd;I6Zs#?c?Kgv=dr>-jaB>aB3ML#~s*#lXNm7}M z8evJ=8U)ExGuece83KV_xWB^J?-tF3lBK6v{Mv;SE11B2A|8i}oCV7bPcUDjWfUuz z*zum9w4vWEUH1DgGBIZwoH4q=RvB*=2<$qu`|d}-elSBV zgLTW>((FO?lzv4~daPgq$4hzNXV+pn<6l>0ZAhdZD>z1qe~GW;7#=`FTzf0|gtY8Ay+I$y}()RRKqdj!R3z5Jso!*QrNO{>t5oFwqELA}NQJ*&$JRQZ!BwF!yUV+9kqhojNhpGlww zm-cDXriui1CGvIeTjCZKWF1h`2rpPk`c598?fN-Zj}=UaGgyCr-leZ{N9cxhj}Zv$ zIzF)md6bh~V1G$XW2sFpZMur-CLD>;V+9j^TOuC1aZZx;#d!ZjcN63LXWN|^Rdce84YP$yyn&usD8z84GWwR9^b z?}b>4Vi&F-HJXpSKhpDAFZSh0g#L|Tg>+uGTWUM<3BheiWo8x9%VdT0&QYy*hqg(g z>u&UB=T1iGv4RQQN6~1!tHjaL$_6ZYc9cM1*M&YtWYt(7$@RavJ91e=2b`H{^> ziUfAyI+*w2nk{8+?p9K+d-233C6)Y`yn0TeTIb-)HMFQvR1r%eq`pmy!BYY z1nzzCb7h`J(MA1sv(1Pzq z^7$a;jm?)jC91vGwU!#PtriwY^lOU?Y$C%S-QKWpP1u}Ndkdg zxL3=+b{bhnw?3(o)!*s`Si!_6voBKN)&0`19cmh9dtcE7ZSi9HY2gBaUAT9y(cCq$ z((Uh&!aTym0K0|;e3R_w?3BWJBwZw~_#V;CsG6ng$k*Os1rs=u&gUQ(CFr{UJ*o?y zD-zg+$D({^?)LnQ-yl!wXAws68YsLf46l0QPnVy6?&PwUzTTWHtZu`-Oxzpf>t5sj zv5@F-aJrxybDSR|MU65i=a*Fw>&ykxlm(`w;Qb$R*I~6Z@Md-5KfQuK-L^aiZYZxO z-8&kxqeG3!SbOEj8m}(|$6f~*8%(!_k|PP`1jiGqdt>m7yeSLU;;;nd25gp z4LSFF(t1}l3=`OuP*|NbowwdOa+R7!rW6eiUT0CGAr%xWn7~nP{{6>Xp~%&#F?*ZHdEtHNOD8j;TO0=7{+gR$1D3K`KeSaNw#PVlS)-xnt}9O|E) z!XD@Cr&z(nP}^!G{#HNf&TBP|2YfBgjg!+@yS0Y|0=w|618<${MM0mwHQCj@7b#XS zA>K#yrbt-Pv_5O~q(mUF3(q?6vD$?w*!JfYEtvb1Vg(cUx$$1#;#rWeLdVt*JwtI0 zZ+_#8^u&Iv*Fmm>A-fyo!@z=DfSAawsPmX)0#J!xEp{8Oq@Of2fRPU;-@TWT6yg%ec<*M%KM zOy|I5>`8rTX}uWJph8|xWXFmd>wUUE#h zBiUA{SM_FHH@MWbIhX}*5(w;a+jLs$-1Lz&cknMxOx)8IE_G@F8Ot{@tYBh^(KV^! z_GQU%n=CeGDy5G6^0i}M>ngxIy%|)hI2I=K-i=}h80ZchCh%} z9_Xb({eN>}_{PC7`Hd~?+1yniuxs?l$C7c^Q_`c_-#GDi;BZhvEg{0+fnfy`iM?M- zg%*dUm##lKF{`ILbnIpf$a20fy*QR zYRhR7H2C;|O*6bgF@asfZkJ0wZMRFAxu19%34yb~+fZTqo1CFo!33@`co|&%jxFrq z3rT;E(zD0cNZL*&WNXqFa;-8#%4}vqmK1y^xb5Pt+O0}lzTRksmqH zC$*xhsq#R?{3x}B2pN}?BUW=mk($dX|KyH?(od2at9In-5qOIEK4oIcSG%D=6nSiwZ0XR&mr;IVX}i5l6i z?=C?DoA%H(>ZL$n7mL3l>3&RP|B+;k<81~81fp2v2b;y32a=vkzxfCF>T*UTSw@nYPRZC zEp!wxSJ{v*j0J&9p4qYP;xO>=RcgMOoZ~&zgSf5REiZ$;PDRMbD5udI;P!s zR`F{E#R?vI;a}ok{T}pz7j|FS+ct}ZG%$h3yL?vv$0T@k^&@+9Y^Fe9SN}A=mxZ)f z8ZhY|zmLpPUszlF9gBV*PO*XsJVNG?)Ovnkqda6weU}OZcH!|Ze}fzSVR_VTwz6>| z#R`su;I#QSabstJWwVnkvO%1X1}1P+gntuvW+p6mKFt!PWP!l0fJ;?~X4q=Us+zjn z^mvzGSif;Qiy)yCE11YIFe33Y=St%Ts`~--_!t7a+oZCC(~~JyFoEMJyqs_2XWoY; zu~&RIL`+~8KBtN6!`e8C6-?lBxA>Zg*Wuva&5w2Oy^vxB6Jku}1V7oD zHSl5^o(Bm8cHxt{_=?|~5fB*Go9UM&P^@4A$H@7!S`Y~%T^(4}8;JseUHF_x-VVk^ z!SY`AtiAs#iWN-Y(FlO>=Y$wncPiG1QcHt2+U*8_+1M_ZwV%|nqDUL?$Is8`gn9n0npWYE1LEvSu%m;ex z{=nXJctNp(2^<^X=i_{u01LeSv5l1l6h~EX6bH8qd~cDO6QRiQH|yDle>Z_*NI146 z62I6)Xz}b9d*il^Vg(a8`o(j*>qNNc@SAm;w^ksq3%5l4Sw(w;WutG*AXpKe6}~6@ z6!>>V;a*^P<_`;+aExLF6ZmR*kL=7?uwTMYUAS>ZAh4@j>&KGj{RJs!mD-b8w$lUF z{WFA|L6<02FoD~4K4Y@O6{^3mgHG!{3IukY^S>_L`Ey6IoBWxlF?G){C@ZN22jYKH ztY8B7QTV#oHND{U@#b)Dm^H%$cCA^+#~*1gC1cNWp2nM$zHm_27r&llB!)4%(I_tpS`z^+X@ z*GhBZ3`mr$j=cWa)`uNydV^+s2*U~{aF3jS!DC?pOWcNnQQ8uLz^>a@7fCJ=Mr2vK zx^ulU*$&P@ANXE9g<%B~IQqqB0mw9{^6@$=Xp=3(GH|R9k7P6&^KVmNOYjqB6MI}3 z<6uIpOV;+E1VxD-*vHF70)bt4e5KKZ6s*e!h& z2<*ZmOTMPaVH_M9Y&5PT5ZHxB$b7%Pu;H-ektOs_ zZzGI^F@Z}O?^7QjF!4nTFyHJV5ZHxB`h1l|pMH@2vJQ;6;~|XbF@eh_-$5X^GvB|x zBk0b~6$tFY5evRo*bQfRaij@EeN7M|6PUndldt#L+X9wI-QZ^7I)T709C_iVj9GMs z-%nb>sn84|!h#80Hu?8a{`KI-nx1fXbhbcX7mgtDZ+`Nc!>nE%U{sr2AyR}1TsHYC zq|r5D_!uW>r<@T8?80#^K1#K&58i&Az{vix5b?qUE}J~6cBhP`+4X^A>@};uz(ab! z)Ql9lRHeIDHkYP7wjdjwt5IsyPP%j5m|SyLPr{VXY-Iy`%z-oQZCF{C+ETU8He{uy zlB~?HCskCdMGk7J($(Q3q|i&2B=@NrTe^Dr5z}4o0)uzfgo5%6=V}d&NQZf5blnqU zDQ=(vS(;%&Yq+m=UR%wKY#nOK)7a$kfZ6?R58mDD09G*3_xmU3EDtlX*w&a65uT^m zL%A2Q!6pKMUB3*MJKH;%lg5WlIFbG;k39lc=+*KE!wM!0XT~@iGb<93r>0TAJdgR` zc7?VrzA>y|BEIt@=U&d%i z)d8S6w~9@_f6lpD3tKYslL2jUrd%GvV54rcNsq}5ZEQw48y*EXAxuC z!-8l_!1W-0|F|aN-@b;Hu~++=K)72&zzQbto8>1EzbIw}bq9mdvo{PA*oE(p$HKo~ zVOOUP04I;{!hK)@wGKJ5n=Xp0M2+S7lVY{z=CPl7Q`HnfOqz*$%h`YZ5d0n|o>ZdCq zB5|*I+I~Q%Q zP1@d4XUR4+cBj8K#De|iomzad6n5dWt2CO;U-(HI8|J{lt|O^O`8+9eiv?+)^o`(? zx`su%N>&YOl6xc7IhMSL5Y~_HhLAhrIl&4h@EKe@zT0IcYu|D{cr_@KFo9jG`Zbb1 z<=c>2-~aG5ET^|;d!9u?qpiUdE0_@P{(h{n}1a?ht_SAVwq8&NeT^$vz z&G<<}4@86f%AaBd6ZpAlG*1tVg1mm_(7Ey-M zSP-mWLOdt#Mu#SFs(L>dUi&V^1a=*A%aq)5s*#h0A9xy_%&NhNVV-c^&xT+H6Zl*@ zz7Bc23D7KexE_9tVgkF0kEKZVZA{6({c7~XG_Ra_M*F~w{NWTUn80V6@YRMd9~?3;0pE$qUjOQR`H zn*ehgX<+U3J-RtHzDOG^b0xFi_lRScztW*&+oXux+XSCp$WJEyIu)X=FSB)Nlwk!E z_$)&HHQ<6kcy%zFO}w<1;u;R0eu-;4K4UVI_bMM}v$Y4ZDONCn&(Y*FaSa2ZWOg>o z9ePzDunS)y|B|)IOn_Dwn0t;B#R?|y>7M+XhAGqF=d$}1{4t9yNO9_Lf!1YdBzYJ(#8g&-$)EAn839w z-<2zh!rHg(z<-XJ4ingg=Zg7PLc5#+uD1c3Y3&(SFoA1V9)T=r4{L4^=#(-{iwW$) zbKX2AQ@15dsta)Ow;jU@CUEV_W3M&qLZ<*%XxPz~U;?}FOgxX!FSmf&bR>A!uM}qW zF@fu2_3M5^xHxG%M650qRtR7hUbVpY3!nLhWqbR=bIWVO>I6*S+LfR5*Z2|ZGT9H3 zT67Tz>=H+*39E8g*OLJ-^wegG6-?kB4&U)4>?ljO4TOXn$pV30c!bRNPG}U*8qb{v zCHHy=V`og@9u7anwb~N4rS@ECH+hFZU>A-D@$*s}bY@pgqafj3b&3^C;64#w!?F%o z|Z7A`bc9NMZ={ldWs3`5@Q(|OLve7 zPhw%G@hL4-4s+hm&|)}*Mb85M~_BUh#A{BVeJHDy@AMB;-L z^5}K7h{;wpjjqaI<;;a}sQ6G-Ah7FV)N;A;VjI%XRgJ?@|MNPBGm#MTtscV)CgA5# zIc}^S88}2Gh6SC|L3#v4ZSE-$*yYl2lKh_c!ow!1k-Kd#>+5QV#ekptb&3^CoT+Os zC%M)p_a&9M@u#j%$G?)khFdTYr1vTdw93FUS65`^#7uORf-{m+Gpcm zR-z1r+0+O&#`qQZR52q5t5{OYBRP3n*P0NYYv$C|VoiR=EIxZGob7v>eeOQlsd!MpW*dx zu#R=j=>=bX&7ce}Ii)nPAor`8QP2C})UCppm`*gOIO5H}|A^ho!mYZ)j2(@HSU8Sy z<6q+IuQK+q+`)aISG=W=1}1R)nn#3NrLcjv;~}wbDZ{^oUHJa^46k_v`}lJhtgUGR zSiuC2c=In`>c_A)S3RL5>xJ;FunRvojiym6H&(a82Ldb~GW-VdTf5?LqTv00{_Wj0 zOa2CRCEsZFT6D@|H)ou3*22&}W#m`TY|MW5;HSg4<-7hYb zQ~sHd_31TfL(3G|>bg0(-%-7)YRlT{0vm=wbhFhAE0~yIGe$l%(~Quk)|_}YW}7Zz zb|82hI3y6*wPNpVIm_9Kc=S`R%Av%89y1Ar2#bvjE11Cd$Il+3OazUzC>R}Di!^MawjYHh)0I3D2!3amGu+DH_A0N|LHYTe z8st}|dLL;?4)ljnIQVX!&v5;L>lIvgX*4_5{U+W&!r}VRSqv+f$am;n@QKfu+<2+h z)USgF&{`IJf0Tdi1OmHoZO0EjVHqso(r{?h=r6-{6s{R?&gV0u%{H*OH-o^7&m?07 z6Sy?<^(AfRvy1U#A>qJxh6(KId`!uo^T~qj@1eerUkig+Vq;IZ##jDg1rxaZ^R+mQ z8?z7xf4FnvvQP$bNyVj$@5ynX9oyP-2CUR*8CEcXua@sXbGZ%MesczlADu4{*oAWp zU!QoKuvyj~@S;ouSiuCoKfcEnZNhr*62(7Y~#iwkMqiA(fzh5niYdf(-$rF1kr+Wp!pn$CmE11AFA0MmPQ03y} zK-hG9lR#h>F3r4OGWD*m>aZ|4{bz|#&M|>&K7Jb1$Xd$xKLJpsPL@Dm7w$9i8g8kn z?$)V!P|r1%VFeSo=Ht6G5BAXo^a=%c-Fkt*F0o&-@?#b`(0DGG&P-xh!33`P_R;B}2_&S*`*OiC!JissiAoD7@ zEf2g?g6wAJ$K^7%Hur0rnC(=cdLrpOz|!Ow~FSibA}m#(-w^3`Xr_OLRO zv$D3ohFtqsi{c!E^8#OU_x!B#vb`tI?FSitCNVze<@0kYr5>g>6kj2ae)tzCu9XvE z>bWf8>0$z>tCJ9Zz#3MRz+h_u+HWSGnV zwG4`y#8w$Rxlyv_T7t06$8G1*^qlj*}jdX@_Bw@Yp%W8 zH|bj~wclR7__tD4ulnc$qhkgN1a`HEFwnYncqi3-s6H!)^wqkj zD=xFY-={<0{8O?=-Jep8hc7R9wFF7te!iRe<@nQFdpWbYfcHf%YMIvJB6j$;4ne`?X<3p&Tb;m|Iq z76=6Do#C|k-qEp5m64U6Fzb1Ra8>wsajnhwFiq>LjIQnlr|ilE0=w`%@yMM)7h0-+ z$AW(OLe<7uvYXC;d}wP!&u-WyC-Suivpj6*ImczP%f#yB;6n9TnGP9BH__*;{L&P_ z3MRy-yC?St?bc=%JJxod@IJ7sbM;ht+P131te)C_xIVZ~FP~e;4Daw2Zb-odPLHn_ zz4eYZYiz}c94QdkW$+?P&au&u@nLEjLu+rLo-yX^<(ViUU!62QDJOpaCOv&=Lo>(e z{O(AOjqIqm_M<#7=(+T4wwlJGc7942HHEJ`h67eGA(p|RUDK7< zTZTcleHFm13~tq)wmB!eS^tyTO;ew4U2T?f=+F?*)UOIy!31tm`97(E+m*0(&fs&s zo3Wm=J3Q^O=p6)&1H+$!lkUz%Kj@ z`7E+mpfbO?E3}_(Av`Ngh&9~vLov$g(%~?#Wp#nTF8pTseRQ9vTRG+wyVGPA;GBVT zzL-m@v>v6KJzmeg+sp>6U_vY(4Nm#%t{&t&A&eCX?82=C-^a`R6TJ|7j`~%N7Ros$ z#8TV+NhV$IV#!i6#hL-Ta4o@OeG_gdyDi&6bZ|o`^ZL!trMoXZO0=c8bcwm-ckxSQ z$Bg!{^Sdoz1rxZW@ffnnP32^vBj~o63j}uIJk7tNGmTUNq*}25@&F<4Vgf&Pz9x0> zSS7}&4m>dHFA&%z<||9PSxUoN4Iw|Fmyk;^fm>>g=G?Cair0qLFn9$K2<#He$C@qH zx>=`gG16n2klQgKzH_6@#=4}7ciFOe{sMtrxMkooKa2T3ug4m&7mcF;E0_>lgT8w= z(w)m&uwySH1p>Ro7H6Yp3Hx=`7KTo8fVLYloHNS}i0ub6VXok$QEJ|X{>J1=8#N-_ zZ0S|DC8IGo#J2^k;MoNnDdgom@D6k9zla7-_lM)Uwa%S)|C1{H_*f<*)w%Oi4QVu{ z2CcgLbDsJg{m}w7jgJlPve`Y4(X+B22nr&$gYcMrX zAlhAal$)CylPzOwQhc@ii}ms_*7w{cI@4jEa37f1y1-BN7+al4HPzly&6(jW*uD?@ zm=Yoo*!69;m;C9RA+f2V66x>U*q>p1H=xPf47z!`)$yg`841=5F-M+ zaNk#>IUf?F%fB-P5~7|7^B=gy!802gP0@|!TC;Xu;PgTRSi!`}IWuIJhNfiLMs>t; z{eX-1$AFnokbQw+0=w|c2H&&BdnNs|VLVuxRs*bH0`!u6sLYgne5j6C{C2LRi&jsE zQQfXGOkfwD+0bad*X_#&U+V*Dry2rQFcCaRDTvx(O6qk~^HsewF3d3rpht33fxs?2 zPsP7iTbs=u?(Yc)de#G6I`NDEF3r5B-yxkf8|Va2yEhcdIVSMz8D9xlw1w5TY7bKy zwigKO!dJ+nIG1OzlHMaBCf^XSf(ab0=ie^b4Pbr`N5bt1#sYy|;+=c#8B70qIYOp= z7h#Np#|ijt^6v-3;wjwg3cg|80V|leH*%W%{<$&v@J!8Dn_vH-uY2`?te>3)0=w|r z)My^=G+~oUIs=JWHTp?dWKfxUSwCgLpb4*-o zl9}hBHzG+PYE3Q9BR7Z3cGXdY+$;Q@fPq zEZ@uyH}n9kU;?MdcRg*G%T5LEVg4(;1p>QpozLg!ZF1P8s%Mz{Tt2^zS{6*;r@&WW zyDwwfju%;nOFjaDUAWfftL_&hupH-S>_LPlUn8~WcHw%SuNE;iWEdTEH(0|K1kUL>s#_QYoxWL65%xs7!&_wB){5<5!*xs98Q*Ez<16`28;HQJuBJ0( z^_RYOS6j7VNedY3JVIWubRJ*@6S(c-C%@-TW!KLAb&j7uUm&n+=ZJ;>rLl3dn#K{y zpUtR~=GPSbq#`#Erz^;kD>9S*kM^aC!j)Xlv9cHaM**ZTrodc`GD&#}M zcS@Rhc646wS9!zneNupAM>|iekUN)Oky69#I5A-KTGsrkmG*H}9xp)(Cgu!%C4VZ& zlXhHCN5bJFcCx_^dq}x+us~oJjydp}Av=m4@<`H7Hwy=>U?TbFW4Tvry_CF4O~X!3 zWVG7?Y4p*#0)bsPO2K#Et3Q_YGt4a5J}3gPf{C;?_hpaoC#9`C+M>}cxaY~*Y&FY0 zTwNrvON@TF>;&fN>zrp8!6QW|a)SxzeqG)`FGyz2Y8s8kGiKxSGGAUQ64-^uhWve` zH(}v}nmMOmi~_7+BE$ZqJgwJtDfE^4&PyIOW^Zj~Iyqh!3G5QboDJM-vKod9oyK`b z16DB6v$IYKbU$Yaf2H1l>-l*+m%KM z1a{%5A&>7)?!h|ztuDWPB}NA^fy*Ob9evjv+I(Gipk2K&P-<+ZZQ>r<=|`|VZ8_3Z zdwEP~rnzr?WrIOB1C7efVK-(rt+a|&bu(GPNf%emYmD1q8wg2a;t}Oh%GnS-MV_5c;VgzBCJL_uR19b*kv7GPdl;Pr)%0Cm5BaT zAIxsoa=Pm{7*;1(X$#Aer5>~EP|poE+AhYS(y^X(|L3Y&KWhv}jjKql8xIh!3KJvq zYHO=B?k9D*UzewG!MG`$sT?A?bP=x#yYLg?QRfUBNQou+gXg#jSB0NKv74E8tKClN zLadtG-5c3JLVEMu)0f-;E119~O{3Z0#}Y=>e4OXjX0$+H7cMt^4dh!3IQ;8z-p47U zg`9{9wFLjaQd_;XH5Bf6<>>!tq(ER7F3mj3xx6+^9yAPUD^a8@<%C>Ocvddy5khv~ zIwwzQc2YiL5JFb0xgdA*y()h*Q`--}furFdzp~dq?vb?n|Kvm8Hp?q7+fkz>6><%- zQ~o;Fj&7^@Ro32KCYxji@o&kdO!0-S$3C)pr%w{BU}A0kxALM#o8{v7D$#`whHZSe z={?U+60Bh2|L+aRYX5=n)-!A*^uKEj!=n{}z^>Nap33{RS#po=!90zA9h{-%)=u!? z>Oq1POqlF^A*VSO$Yxsg&Z7+n!rDWP_{lvx1p>P|lsu9{bN9(B2C8XX;4AL$9dIPe z+~1Mn$V$0WN@IBg3nJ?KQ2$oz+K}K%d7f1(`4#`aMsxUPEZiD!m<)aYmS6=FUcF4U zsjWK586(u3IE0_qclYFB`o#6UKw#I8TYu#;16R3|tD44ZzRUE0A$RDTKAfBD#lG_M3o(IRud{#3xyeDY6riTDtp0rX&|?OxJM}ri3MQs> zt*%YY4UwzdQftwLFbCQVJj7m}d?*mug>w)8D&<-r?8!R93dZvj%TSKPM9pUg+8UnA z<#bOqjf^h7(0oQ2Ta;NO5ZHzDG@mh<$#+_q*a=J<>?8Qy;&)y=w^%m*nlF1EQ{VZe z>CM5QoeRvJnM$yNi4G?B<;E7LmOi!@hwP(Y=X$}M{m@r$tUAC%yTkg{}loOM#Ww7R5LtxuWOM(?l9RJJL z6rH&*>*uIfb$oj&8yp`BSpgOTfn7^7R>{xDKal(D)HG5@_hmUlBEdZDfP@uHJbAWG zcJ2RIPP9?em>A^1yltZ3RPtVdz%F_(Mt-pNu^ciYjHfZtX(5IAu`oe%%NZ+}*zkWN zNcsOpkeLf7(S>QTU_1MoKwy_kHE(syDcjvp)9{$SnoT`!OXj(T0nV?uPV6$jLUw(2 zMSk$#Sgp$KY^F3nm|rw|4qycnxYp*i=$9+3Ph41jpwA3}z^<)dG}`wgPRTQVsdanj z$d_zz#yYKO(iFf7CUpG_wHxZ@$s<;%<-ElBCR-eHge2rm1FT>|EaytF2F~Ov+Se1j z1OmHOnVV^!SKTGA2~q3B`Z*P>&yix{zTX?Lf(cx&@N<{i8o-3bMbh9~o&tegxbEWn zU!@zvkHR$P8?hdM6NK#q*BiIL{Ar?e64v%9j4D zcssLVD{+j2F7Y}3|3*dsw{E*-WTmH9C zeJ8R4ZQglb_UK(=UlfVJ4`sCUxRcpUd5@gOGJaQ}LB*S@^Zf%UDsOLjXn!|5sh2za zZyI?$E7IUjL)7{oS_(a6B#NGQ({AkyPN3!O|SN;mz`6;jqt0GFlCk2+P|El zlG{cudH=(cPOkYjtMQ+<0)Z~fJ$yHybrq63(q22w=e7s#I<0PW_@pLVxF}e+UrD;5 z`h0t(7VluRgR50{l2fOctd^fZQ9%ORE0c+hC**XMUuw;%;{^g;ZWedd$*B?a89%ZJI0@H-X|;L*nw@9qOKje^rY~Mq69b+JQKCKi*_gj7XphTWUV9*2SIt zdTz>oT6F?N1&K%B($($Di)edV7-_t2SAkSLc)|bt#YBNX7q-;=PVYH>WWdvEs%yDK ziV6~6wYln#IYqTM`9`$%XOH%z_jgz6%{y#3hRmelZTEX{kLKv6;BL+A`O z+rd(cJ7uJ??e<`D@18k(P24zvKv$!A!~XRdyz3jpm7WX9+1{nIT)K|sr~KW>wt2;4 zBRt;T@A$viyLy}domRG{+BL{Z^Yk<3qpob8L8`F6@}1eS6cr@IG#af*A)Vge@qfNG zP9V@#q(`XQeN!>5iM#QuG77>-?U6~@HmTz%DoEfn@jG8gC<)$Po9*pZmo9&FGkf>2 z651x!L1|@IO`USAgjV#XqcZM;kJ{X^l(v12F<%?hZLppk-|_*%8E7nSkNJoS3n<0LX%y)m8Evk$Wz915vSKr8B z^0$=M(ZcxVD@Ii(ryKNOu9d6>0$owsz3iJ;OKJ05j5N+J`)#(B2ePG~UXg%#>FN~4 zT1(=4mu$~0QX3`}*ZLKAQYLL%s8+sUt=%7De2+n!R+@h*VXWQKrvw!wBt2CPzFa~p zw#z6{sl(>0x8HVS(aY^A66pF-ZixE4c1f+*T!T1MRMNKQ4P@a@3J5AltP2>bPW@I= zi~i3bp7(w#A8r@QwpDm75a@b6H%M*Ux0F`mo$;;`7uMDPbP8rOZGRCg2ly7m+|Kid zzO;Tv=2OL)qJjiIACGJ=USxj!=s)(wy@^1e3riZm@iw-+@@i@m7RP79Q9%OV2tVoe z`yeIrm>&!0t00g-*ZD1BYS6dh+K!vXyBfS^25I)_yj~%7fRGb0?_#~eS3o?TL6#QY z)0>9(p{O8%WrepNHbF$4e@Fl3HBcbXbv${H+I@sIFONpPIyW(aybAEt%hni6Q9%Oh zN`7MR=aytySS6iZixUWRVJ*Q|i!^9P>WmtuuNxObQ9(kicV9;Ol8}*=^sSZ02?V;t z+GpEBUH(+?T;CHOMzL1IS`^dfJ0}<2kkcdc^lyI#3uz#MU)E%*>QRI|_x_~!`QA?; z(1q`ik5Z=;BY~qH>%I6c9#oJJbBXh@j-+Y3S9;%Fe2qJNDRg08;AI+c0&^vwEAnXENPcuwN-QIf#Xtttcu;h~@m{m-Whq|3cY@ zsC`xxhbG@M?ZiQAkZ>`oOkK?R9p9^w8mYH6*N+1O3)?j1wi`u1nX zL+%I!y2M}A$vjC(mnX1N_SOWy+FH-@{@xC5+LynU|MLY)zO*E%>2WN#V}Ic}kjUg` z3g=aK)z0uUg?Yc-w*o2NAc}1qwoD+2((^W?Kox z%ugvOxyMmE`|p&3>6x!p*GpsBGt)eR3KF%}DQed}uG+Al#utpNc~QQdGnP#cpDPgP za+v9@X4iAm>i0E>Yt9z>yLM5mSB-Si`2H%j_-#im*SVC^tY@k^H`G~c5?oTjU(0vV z+3qyIcr}LA?6*XC4kRYLPf_i5Icswj<6HxWRUzgpwZ^h>2j&X|y6~C!>6FEYL)Ni@dI%fMYNpK+2y`X? z-KTDyYNrMLJ9Wrb`eS}MCx$%>n<3ObNMJw6WODRut6$kVoNZdZlc0hG{zBgOIX%vN zz%qt)ShGwZ(1krGzW*a=uQ?+yial++iJ*dn_yzxqZmBg|9L=H=HwXl}uny)sa*I~f zd^bh2$rUybRFJ?N%2&O-Cd#*!ku1OAE`dOoShrW;`?f3oieer4zU?G_F3#g|E?R$n zE)JHiC*AC_BV(L2yMO0pc!bv_Kk`FatEgK96>CjDe6_$%o5s(5I5cTd_MoQrn)|;U zhbPacDCKX&uYZg)qqT05G# zsyhS%UHh#Iv)h-m*T(!ihb60alu~Toc=pP*zR*Tt9gJ^*&pTVcR9aswf z3%`xYw7WY$w`6!PcKQ8Rf(jC1TU#=>ISIAv%hby61p;08rg>yyTMx44Mn85t@F_tB z3324LEx|;(FB-}=#GMuhbctUu?nV((ZTV2=Gn_Q*C1uVSp9AP;jHz- zH3ESythMZ+VLY8TQZ5Dg2b_qGwR}QCAI4Q#y7aiOKfpm zn?KSQ-R{dac}9`Pmv5<$ZxzvQr8p>$T^_2PxBXR%ZgWr~XTDTB`xntp@zVtO981ha z{ZxK$Howj;GVk;yHMhYp^`)<~vS;2^Rj&C%<){7#w8_-y+ZEk=U=aH~I)mW<#q{t$ z@wK(SS$dv*f42Yeb|DQUW+t9eM}01)_46@e8O4uh>ix?0XW=Dx2n4!Po|)D4r7g6) zTgI64Ti-N2@Y`UvGeGdv z)Y-|l3O*C2&ChRtSC)l7YtF88Y)=l3E2>%k_gYmR*eXb$8*b8mt$n2qn`o4eU$0BC zb;CQd&#^-YDoBWFc*okXV$a&LmCAI1Kv(p)->S`{m+H#NMpW%|r=R+w9_`qIu9?D< z#d;2ENvJuIs=S zygVwTfkb!#-)rraug3k0w{JOU&D;xHGqcl1fk2n|962lNvBW(!SkSy!QsJ|$Ry^;T z`tXjOGPqxHEz9ST8q(fQ8Ix+MIWKsi=9`SD+WD2$*gm($tbXJuf(jD&olK_6x9hMI zVRcw|$N+&r7k(#xzgp>f%za8tc60U^;a6dL=Vq4B;+I}kKg}_I)fiJ{wxeB5Hh`K5 zDoE^i_^TG4yrp^`GUo1*ij`-fZR)a$2?qoMT`5b8YOB^?Qdib6RzP%FY|B!KKMQ+u zncz1-0^c+*wbo_XV%K^sV24UjK>~jvj}BUTF)x=!>`225fk2n|1>4`Qz>3;6X02~8 zBB&rCzTMY((JbEYKmEP!bn>BTIV~x)o;v-DyOJ@xnr0nVPkq7XRsIB2({4HYtM_LZ zx!u23B-`Q^s`E?A1p-~g{K{!tc8^irw;6A@@X|;YZ9iHMEw_k#D_K@!JdFaqjDqQn z?(C_tB|TN=5Myq;#fOp1_HmScz;yvZ1qm^Y@BN3e#ij-N=F~KSK-bBbGTQsgVQN-& zBaQdx!rArOYji8O6$BL|iZ1ui{@W6z+Ws_V!wW_YVkeqq>0eBI=O`#hRJrY{ji@nG zT@-E*N9GP)+GsHs2%==xXBYtQ9X@ zrVhVrta@>}?#J%>S7Ryi3W9l5URXkF|3FuF{c%;ImX_9Hv`qC&w$THa^r12HFYU?R z?cYvNL1OBB8?F25gX)rT#_FAp2ivij?mzXC>kbM8y6_kB$i$_#%wuT@w!pNQpn}BX zrlqy|iEGtIiNhxLv8M0m=&?P?kwubdt?<-|k$3436$wk;v%bpw*mvQ9b5q^j^pOmDrFM71-IJM1epT)|I@CD$|eorrgneGbR!5 zao*a~H7nF#nu8L*$xADA+@MaH>Y(5o;is5i4rWn}p6k0#O%a|Pi5N#OEqTc%_3yuP zapraC!G^8=rf+IGLm<$F&&1dJ4DZeg6MpO8@+S*l5Q#VTZrZQwS!(n9#*;7V)r#Fe zPm=Q=7r->YdK z(tV_}iyW1yb1P~eLhDHmK1Tad^KA?(XZu;{^<)}B1quHcUv1E$xBk8xjbCM5E{26} zF)Q77FBAxLCGy&}0#DnO88uwe?=kGoMlbSMO(m!x(dwYLW>N01|LAhYN~e?dvFvSF zva)UQ41x+0lftTNQ>Ksd_uOv$s)apcS%amSO5WVb0)ejaHOpy}Hed9A(Zoo@$tRYD zN0m?<=glIhAn_u(y4K>hzklk#G~!4s%SYCiGRd&X|=UAT@%hE=*&<0f&33QEKQ%;j7hh=+pH_{lP#Ik{n zhnr8lm`+eZqHuz**6-P?>;wODNfrLCw!a%@p8ZrL&=vH=TO*C%W;d;Fq_Md~B&%Pw zg}yO)vCy7lOMPH`S#4TRA9ZD}k*~I9#<2JjMfCBfrV;$V*k=&axDgb?0%EN75qZ-I zDo9}4&d1J;W7+t=U(6n*69ocYD{Z{BjFWEa@$bg3>hm*-wIAZ6H}5&0pn`<>92uSN z>*WqqrLXx8g7}J-T4#DrI3BQ#S>mul|+|pXk@>x>LV;&0b zH@V)}PV)&^D-C;Yv}*G%T_k_Yd{*?K5fl|9#62dCS1*u;Uf-2wvqb`3Pu3OJBKPi> zR(~;C=ihA)l5ejZ$?)_M6cr?J7Zh*R`taLZEUzgw`wkZfbagLbp+#IcDTP`YX*j%` zM{<<%q+wVzMFk1m1;yjkhnJH_>93U%5h8&uJcozRRPP#2ULNxz%dBFB)2xsXcU0Z! ztdNZ-d`ZopaRPxZ+)K*asI=nrT*n&xK93-Z`;`X2chRCc4U-m+Eu-MBBtBpJ)q>{d zv{dp}hfq|In7-Xz8HLZ*iRiBlhhZ=m6A8zU;5a<$jJN4o-iKpw9Qg%-a5q5?l zQ7y5I7L{NxISn<^SkrDk$^X<`DZ|%zrMY-&DV-gp(PKQ7gQdK*iZxFAJM+C=k4JlI z6-%A+cTE5Ht6mRglUtrt{4(wewP=Ue-rD+!7yaAvS`F)b-a5B$%hsLzq}V<6pr{}* z`AJ!A!5nAlegz|q|J>r3R;BV8SZ&nGRLLfv}2^3K%gt;TuCkdQl?aL zqd{DJ@5~~v+W zh7I!3 zM@c;+4Z?HaQGNgDEF$*}76^1m zZr9tEx<_J;_Y??pVP4=T*ALjO@0`T%mHpB~_~u9)>2I$^^j|NzUNv&bhHs1X7QWxf zd7EwmfiBF`eBDz15`E0#?|j{MS0V2r5zb0z`Id*I{r8RXVX8k#ANi&jjgmVH1iG+n z@)^bRlXd5sRLpLR{~aaVkc%;(%*7L;R_;x^&{Vd zHLnUA7sKn_!m0v+F3h1OQ{%5y*?`bixb%z> zdP_)PUyI+*S$aN^Qg$k34vGZ2dh$NQ(Tzk>?TuR0Z{;##xAc>8b9FRD1&P7X%jEmK zGM122{l6-C4@3f8MW?!IS56L>8cj56(dF`1(qtheOaF|Zs33v8TE1HS*hSLnSe`O0 zJ3=7Pg(DdrXDE4-bPc?v9R3q7jB${_a{^4Jr@O9@?~UFnBexC_2z23i$7H(kG{PJ= zb+fWYn@F+k!`>^lS9~?@lO^VY_)W@?aT6&jNQmD&Ky6_zw%U=b@R%SF=)(4j*Kl8x z&5s+DCS~p?QdE$@FKaSopM0gcOI=BPWP(7T3)?Hc^FFJbxyk5mo$6igpOQ9%O7yZoC!Y^&5t+e!BAjuZ%VVS8mVJ?vja8QFI?$ru+& zQ9%O7yF7-xpeLWX*iTIT_+3BHrlJemE8f=b@l;m1W|G?vM^RLe!11ohesX+Q&}eek4Xx+@KtW2cmR zZ>x3AyCyBlvs2zqvee?|J&>~a&JKQ)a+(jjc-EgTnJE)gka(49qwUGOChcux%#3!N zT!Bq&%Flc4u~;DRWOh7ro$r?I;m6i~|Bp_K=`WnEj_0Xk+B~=St-_-2H=*Scql7e& zn66rBg=?NlkE7Fm}FE zr<|6udyLeK&k170oA0k(H=6wy!%wC-J6%WviE$e$YgeuhlPvoh(ORphD3;N)H!1OF zl0cvf$M0}kKa~BA zUQb#SP9~@z(MGGJU1=0AIkq)^Rbk_itiLvkus%}+0$unEdDOXWcXnpTPf}&mbkfM$ zO>255ONxGCuWVc8rR}M{LCTropy03N<=nq7TeapsDKcO(LD%b7Wwl&owIuKPU*hAi z{%q=^>%=W(vhd_c42tvCBG;~vY8^3ZhIlEIJz9E<z9F^qwaW=)zLKXY~gLu#6xNy5B2aC?81R^O;P4Tl=%p zo6AtYpv?k-E-cM_)#TVFEHktW^?tuyD78q4@9KFMUshyVRl3?otQpXSbqud}eR{G| z<-USzl>%r2OKO-rfHVU~N2{GXkJ&LmfIy$C8uVExF?3%?r87M`3-(hqnD1iC)PSJt97#7k41j5MnLH=2dy zY$7fb-3cm4w0EhXo$*{CWtfe*?VfyJ#v5yXm*TOX3Mxp5HC%*U1na%+G+CWjg&={h ztDC&Fh+eCuS#d@hHNuCp136|Azq%Mf7uKR8(WS)@mbm*eDV< z>U81zeKO6`y@^I2&b#seb~p7S3C=pEpn?R}s3wzEvO7CCxD>t6z?vW8`v3kHy1F-V z)f(IA(v$H<8mD`=Wxt2J(T=v22r5YI>}s#&m%S>HEMvb~fYgn-mba%Jf;TJZ!uCTX z`lJN1>~G#QKz*g4f&{i*e4Ty|h0%r8X)TxI0)eiBan@R4%u^}x->TvhJ6f=IQ!7y= zrKW-k5@LV1_K(JFwDcdX=Xy#Y(1pEazB-}XO8t;k6b+GGg&Vr@7I3^Xj?a>!*e+@0^6;wQ1`SM{W|;q*aiTY?0-@MbU`Lw0ZbcceY6udubLf%*PNbMg|l$EjKH!sDFoy}@b8&ohex$eH&q<(LBE^|i0~4xiNBOFt6)lP@*v|9wmikm;J=|MThp5Bq!qgwCPlG>_qrPAEt@>B(yzaZy zJb8tjG{j2V_*?SFu$u_Cqx8^xbIrc@^l6(p+k=WBTnzLXxETh57sCXLw0lXYo# zvz+uO`$roTy+C&Zf-G zBdrIn7YKB1-d|DM+9h48GCGqJ%j}1Rxv>RFF73s-jjfaH$lSyo3{)RV1sr zVlNq+c0wS~wRU(lt?}O}(lgI2PE=2gW`UhIkmq(UG*po2-l2-t)Fo9ad3rG?f@{aI z!9%Bz<=0;e1iBV_)zG^1?IyL{Zlv+BEZ?{EVhXwG`$|IviL7hYv0$5}kLbsie8=kav<0$b^BgLP`wK_}UA=qkBx_-u8+Iv4qV);`(qPpazG+f|OeWu(#bRx!Qv zg$dNH<6Q|AB*gkE$?k@^@2ZKkf7RLkNT93kt2U~ale=6qY&B2g+KIvDA4`&GoR7PV z3KBLatE+h_?sAXK29Z83*-R!R(H2pUB~*~W8r5W4zqX{=^-dBUd?G|3&{d`9k8Fph z?sBA6I!_~OL%3dd;5d5NVUhf{%SN>@%~7smTT(ffI7=-*)mip#UqXp(vskS|oaH|0 ze>gG!th0WxUH6jX5Zf};!`v-EDv3b*;uz5Ij@B+ykDW2v1P|3)g`#7N`)f_luaRzq6rW(5Tm zB(SH(Zy8Ig$;zLVX_c*21Oi>y?=qQ`uEA`(Pcgb+kD@eo^WvvDWJvG%6XMtq-w0nP zlNiKWITfcK+gl3n3JDx1@^!E4JF}(RoM=7EFo8f9zBhitpBm0s*?VMl+hFC-+zQ(K zZfVl|M#U8ztK+Zb>&W&GVYO@JlB5Qmgy%p4$J9KkcB~&;7ypCI?GY^y=)z~>H}@uu zXJ?}8lhmJyLJtxnI@sgn5&8}BY~tCWWL>M7LSGaKjMebdtbW9@5&@HmNsAT;bWOii zMJu0`Ed7;@Tyk9<%Y0pDlL-S86jYGFSPkF#;t|OllXjDqkERF&y6~+V?MF1*wrL$X z**8e|9!OxUhR++sj9_k;juNtatU#a(zYRZ$&1#nUYD5x!((UbG`LUCl6X!1PoBKk> zUjLLjQL5JiSNU8)F0bJ#cbZ|QBa&$Ir~nBSB(R0!XL;MZ>k+;a=!Hd7n;?O%G>3(1 z+Gl4u_<)gy@6lp<(MuEP2dfkb6(q22=i|ZTHTsF>{LE#o^Wn_OJJddQrR9qAa%JpU zg6q#vv~jP^Qi&PD=V2Q%)dZwiXb zV>cUVegKrvh52a4Jq)Q z&Xx?K&9m1@s33uTM&3_+V9PGM^`I+SC@K=@!Vwv7KknJG5AHpvcaN&VSPcp6)$%nH zgI(G6DxK(?SLHPPU+BVd8=rA+9nYL6k0Yig3sr}#n%bipt)vS@&dZKzwX`C4CF#`N zdvde>zS@G&L}|~AbNtB{hL2;@(!)uc*q$0HNQ_ukUE8~1wDe-)Jx-YSMzPIDwvst# z8mUO2E8tgUt#!v`(%{c$IZSwjU0byihv{*L+5s^Uf(4xth3!Ks_% zY_)pXNT4fiUOBDx>D|)3HbxqoJ`Q6WmGh+SH$_7QiP|G8XvwX%N(B?|a-vDKK5Y0V z3+g!am_HKe>gDaB9Z5PNeOPpc6M0kmvy9o_iJNyN4HYCtH1X8(#^}<$mv=aE&Y=UV zK|H9kIz&POU2XR{YQ+cUN>lHj=0vwDU0CtePPDtJn1%`xjlVc)Yb~xx_q!Tt{D_lS zZnxU>@yav_33T=8&1ZOfzmmf18fj$wrEGLpU;1L(c@-5TR_-XJojLPNnseedCo1i( z#-@#KMu)9BAR&RS8x>9boR=Sx!|GFJlYs37rin1vR<`m409wUI`X zI&sYY$P}{JW4kbd#C(jSSN_d;ZXaciBjGu3g;6RJm_zxQfTl?1`+hGO{CS>0pbJOg zJVJjqimlnQg(!{>h0!(=m_zwGfKP*2lNWg;*m96SpbMi3e9mie1e-Vi25GtXtPnLo z0&^&zF)0XQE1z1^8uu#+1iCOP!guOL_F;EQS{s=c^kM$>Z2Vv7!rGOe9sRQ!ds?j-?YW_eQ12pvd795O zJa=T%g1gc#89TG_f1wLo2_7R$aAj^~I?-2M7YHp55@PvSm72g_)M`(h%5_#Ij;i@D zB3SqKZyBS5Ydclf7Ch`N^$h#VOH{KD32bqf7GzvND+LuK#9m+Q%dxCx)-19mJV+qW z75Jp8=3XmR>d??gnafFsvA;KOU_*- zwfJj{2d%>*S?P7VN#4Y|3Mxp5y}t1AVa#d9Gg5zfTY*5A)mASpZ{vQc<#8j8fQRAi zdxLxAN34&63KC+!z1px|Y|e!ev{@k)2y{80bkUNVos~9BGtzJh4`zjRi_u0WycATB z5JxP1d$wW~mGZP|U|oSgSNTj^Z9wpSX>V5}4e4rI*6^bz-89l(K?MmM!|{EPo$52U zs0Osm=L!OWE*!V>2uu0KZ2wL_dgG!=K?MmM^%?bUWv29RK`VT=5eRhQ=$^+D-&SO^ zI<}-9ZiPam010tCxUpFR^YZOMVy_of)~~GfuY5fJ`C7)Z67{IMmSPnq6?J*X-_=MS zf!sZ(BoHC2J zZ+s=Af&`Wu-kzTr!4{l5N``E3QIJ5_!QK_M9C^KTqrZ{HkmsY=kC;qSt@%S46(q3S z@U;ft2eR1b?@4C8(h3sjDnG;7o{rGj5NmV@5L6WC1|B~r)5--z;eTTWGe#L-4B)N_WMN@ zB+zA5(N=RG{z&>=-biD^l2**6QF%JTH%mqZ2`o4K6hWyD`!lTx4IS`7AkfwMX;CfS z`jcdH?ln)NL~?!BEVcoi;l4#i1qm!SCR3@6W!S10?da&dn*xEZMg2ah2U-@9N6awN zi2CKt8m6_TMaD0ZQ9%MDVSH|)QBh_xhtMsv4hsaj9R2UB&QGmn*TF^_A$_e`BJEAT zADtkhf&|9ScyDRoO?~6Y2)gk73V}dZ`OPQQ%H3_{!hS{?RX^U>LwAPLzoe^-3KAGQ z2M+Dj3x7n?<1c+=RFJ@kCqL6EYKi%E zrzAQf;hKaoRvg`9OqQRSd3m|{XG?wy*uC9Cv=#{*xAUmM8y|hi1b%yD%oKq@7si(P z9;{C8de2u{f_LAP(cFcEcn>@T9Q8eQ5~JPgce0PCYw9%> z2y|h7h^PGrzmQNt0^cE@cdorzrejQ%6x|3VkGc6`^?a>}eSeW`uJr9#Vz1di@`8Y{aq=cRVE@Tf^3(1oow zpCvPQW^cWnspruZLJt55j8gDj@!bcpj*Z@vSl=Q7fiCQQ@L%QIpGEHbLEZ$f5PC95 zV6=tjMAwmQipNos^{t3NpbL9Zd`@`BFqS#^0twi$ROpc*fl(tqj`|V9n$BBH{J&TW z1iG*{$Zt4mJ%)LCt|6XP7YaQ~Bry8LYq+oDSmKpFqb?b!eNuOmE7`Sg51XKQTCfb#pFXB$H}8>IjNTYOUNhk zCUWAv@aEaVn%W999`=c~!VMPy}ttQ=l*k$QlyZCKzwffMt; zmR5AjPV`e}S2lKHg+`jf*S*Ci%dXuTHV)(IHS`%TW7@pbzUpf3*C>g;_#0`)8F!pZ zFIT*ZS~%QIR<0S(vE1vkRwXWpPF07QQ9(kSKVP-Evus_e3%#H@2~Unsi@%nS4Wd6w zyZFkpZc9zVb0D$W%T0|)u$5!KP2#`m!MOL*bN{~7>!L`YE2hu;Ou4QgiPIRe$ z!@ryDP&)tNH60Zs0`HemFT8hNo_flPI!J*AkeinVouhDOh?)E@H9?T zJKa`&Ws0Un3peYiAQ3UzTdl*KO`xNMO@|Y-51IDpZHn#Q_Aae zKF3qfq&hk(NF=gRYW@^wxec3ZJo#cTz2>xW^ysy!0)ehWb5^P4$2-a|2O7lg*Dm^{ z+c9)ayA&N2B)0q>rbZ4ZEx#LPR_&pia<;sq;4!{Bg<2bavrw*iMh+D2)g0y|3E6XmR=6Yoiv8%sp%#lG=}+xmBnmfiAJ$of(&IuD+-PE!)zK zp@M{o-=dV3T2wBxDUPSH$|2eOtZ5H=E6YY8(1mqAzyD*+Mf2B?iF9^WcOfTYt&Mq% z?^jzm*X(hABAwH6rWq9^uypZqzHX9vQ^9!JE6GJT}K58 ztR?tb9M93_+WX_^jdkM%0$rHLc=UYnD06;B9PNC6yp9SISljWHPRDwg&o_^vqg}QM z1iCPf@iT;~_cBjZqUgSc+jLZrz*^g6DppQ0Pwf*yyDqsQ5a_}@#^bxo{LP<81U>A1 zQ%3~}Y<+l?)1j<+&a*x=plA_>1iFMgW=b6BYIc~~hgRKPgrR~2wxTA}vHNed$$dJ} zk{?_ILU0Lr_sxbCn#26|)asT8!~BW__GS6m5$+JCi)ctEeGb%_-ToWce4R)31Z#C9HRTq4yy%4iFRHkB@9yUFSMR>|L16Uiap zO?I*poW+}A_QNT3Vbc|JE$^sZFZGKrqLz0ZuUIx~9v=T>r;OGGS@MPidp zW7+>eBAqg*l8y=zy$^)>H(li_|JrBtWTLh91pa1@jO~ z>BOuO@`BkZGWLqGkIG9_Muc+5z7wr`!-Zk*9*Iv!ZuvJ2DJJWu68W#{bRkSpV>;4R zdt4bRNMPDL-hOSf^7lnsx^ZtAfk2m7YUiFhtvEI9M1AWxGgOecnBGQex~#Z-y{FMS zcirSgo_1|R-_`RF2y|hM!Ph4Sjv!lH$I%8^JJcV$#!Ce*E^_*p4Kn6%G4HyM9!i!q z9Y+s+{Gg(O1eO&(AN9kXROWXHN4y^*A%QL|H$0aN^dZ0Qji=|`7no5&0!uLOw^y%0 z`d=PTXPlTJ5a{}THBjnv)Kxw`-`Jth>P#bYK4ctyR?SvN1qrN)Os3ef4M{6bNFnwD zfv(T3+ezJhUF1#K3wavn%Qhp0!^hIuUcovlNMMc1`z6=>$?+Cr>4v(41Oi>D=Q~IT zK9-jK>KMJ(xCRH6c^>i9f8}r;mGUVT(pE2LxpuKRGN#RIpAXqezzN=Gu#FVbK;q8+eqlK}1 z;N`$r<^H53+WYljp{7O`*4q4xl-ub_mTeMk+se(13KF%RSxG_9+~w8gwLFco>$BzE zq0!X0(?(%Di1ihYUipfYA}&hl;7HmqFhdxnBH`$1DQUz}?*3#tPoqjbAEh2YzqH!M z8v=nYKeMIee%MxycSz;L<_1@kp_7Nu!_}|rs35UBxTBO<)N79NiQ_ zW9A+g2y`9Gjg$(LO39s<7-O7T$rDMPKcTdu#c>@KBy6rtmu3f)kS#YE#J$3?WQl7C ztv3IaK%i@E<5a2XoMQ6$14b>n`8Of0-*ltHqO94uzA4hqAPc!{W|Gjq5{Z!I-AJu* zooGaN8-@z@$i#Zr_vb*eHYS*^yZJ#!0$uogd`(d!H`3{4KU%y>f$$tiU_Ea#?N6&m zx_ssAR)HSXA=HXqCZkAl+*F-9{-6Qm`(1krG-pjauSibpVBK@^-j~Nvt zuk{eya}{+|kia^a_hf22E1Of|>Fi2l z1Oi=H6Y+K772K5z;qkQYz8D=9B(N>vXD*xSDdjFiQPa-N0)Z~9iTGJaj%Ss+j!`r+ zXQPe^64=_AOc6JAWybR;T5ZExfj}45L?+Xz;Fe_ev{7`_?A1CdNMO6m_j#3WL>@MY zq}N6+69{x+O~k)P)>Ps)dpK=2bFq#J5;!8`GgwaiKC-4^wCF9fK%fijF1`lRr9W-# z`-3d9s;exT@I}h`dPaJ9WV_rZ_JicJ_oB3P>t1>1%5PHf;A4{2VPn7A(wzgTd*OES z>*02Na?5v8f%7_P$)6PY;K6s&nb!LxW$9G;>+Ua-gUdQ8?TJx~Huyc5=Iq}?E<4WB zQ9)v0+GiF3lrl{q(DgdwrxfJ6UMkkZ=;`ZyhSA0^_mQ-~ z_Btv^crGrKl7AnR$ate)a&*ZsI<)pa;-j_|2y`uf{8#E+ek1SM8a=Y~$Psi+^DOeE zgSCzd63@2&kaF7`mi+n}{lp44hEw;^*PcU5DQdTGoEsvkc> zx?NmoMgsALnUC0$uS}O|nDu9x1EACQf`DIfQPT zd51(CKO&=o1m-uBX|X(*roMVY`kS+|kwDk@^gq(t^h46-`a5|VIWIzK(Ug4ha=E>N z3KC-8ZI&8J2ROYV726aS2z1@D`ymCznx*AF`*|8=b}lDxYQ)g^>jQ;x4UTs%3|c68 zU2>HBuQr}Mhu_4usaZ5#+vPzcR63Z_q@B4Aa+NY`h5y9&T29+Z&aH{0f35qQaU_Jq z%Hyk~Um^DLo%P0uzUad}q?L6fO{iNzAkc*o3;sQhW|1RbN6^QCFNMei66rhFNUbN^ z$_*op-OJ63Ws{1xN6^ZP@&p217)Rl=8Lt^Bxg~-Q+$aeV79_f;Yo*y8OUZPS5d&EE z&`f-^2%6ilxj>)`BS<_BmvNMs!$Rquh*TlAghbZ+HPQuharu3&QJ3`Huad_-LupmJ zxdMSMtRHz9j5U)k7JccvJsCp83yEuOR!haN6q5sEjM=4isryLK*gmw&AhSTAORRTS zP1#3Q&Feu^vhV1qAhGUjn$&c!Uw4{*{*6GO3wwin%y~MO)a?>T zSB@{xt8LsQ1&#bBwOf%am!xZ?yyZWnu=r#dX?_OwK$Y~;ThU6li!fA>c=&#i20-S4RR};;-^3xr+3u+L89EYa#S^kq8(yQ%bH^M4ngDNF%!L z1kx_7BVD(wgg~GR``Y{jWzvIAx3#5@3py%W_dJ!Fo_Hi#EX)vEwN=rtq_v~&@}2kF zcpA5>2h)ZdicwQpdj%D22eDo;nWhy4)1al6wBn$pG7{)Ix8Gl-ro`;LUB z+M7{90_%B`>DAvsv{l$k^6dI*fk0QB^#>{M+IeaFC}Xs}rEC~I5b>0Ryxnd_1qp1g zcx3xZDDBbZ0m=Dmrz3%`LcjM?e&89&>&U;F;c_T_*XIFw`OZ#91qp0-c^l<4h>p5` zlk|SoQy|dwJm@tatDThU^fGFO1FZ+rezotBH6uFes33v;2R@>o-j{ZDx<)!bS|AYU zYL)m*%HMokx>?WY``8Bcp|!r=CsQ^~*HJ+N`$2qf-`SQlz_KE}b)lm&Lc1yD#eb5X z#_f_ZACG4Dr9JoFNP5T?p2p_-9cbnacbemBsi1-c=1{&jqhv=~{(&3)j~_6K1iC_F z9!s6;ypU#it>bCP7TxI9PY!f{Hya5RBru2a(|W3Pr{`Vl>6YIwH6+kwF8NH#E&o^w zD`!MlR=?~?M{6bN$jtI)RFJ?t&3EKp=tT=YThlUUGXw%%Nt2#Qt#{v-NSM*8&E6bD zrwz5D9h0-ns33tklt(QOz&y=& zxUK0-i?E+$O6k=CfiAzgcO;YLb?KvS#AN(zc`e=0ibnRHtD}Mh=4n0?_p2@)wz?sG zlhjhVyZ^LQFtCW+Xvhv3^YPwBm!%`Weo4=!8NDT|SeXWT)}mRad>IuaFo*I}fk!Dc zsd`Q7b*z&>pv$Apb*c6JZ&Ll#H9U=WN&sECxC&LfbWu@30&^%|ZFsl^Z85779n#*- zj0C#&SHC3{I((8+ju~@Bd6}(f9KU&}U`2B?Do9|SHknFWw4p=zJt+l)&I<&(67SuT za+bZ5Zct;+tIy&#^!++dT5VH~85JZjhw?tdyEe3vjT`+LUPng)UGe>HN@t>8N!5P; zThEZxnmSDLpu0=^=%^rpd74LYyj#(-J)CH8w-kXu*M>b;rM&d#Qt}TYI;fOuL3N(S zuWciARFJ?t&2JLu*p%KG=SYuz*dY*t>)HjWe7Psmtwu)PEm=*bZSp+n?Tcx;2^1tS zPn%4`ZOhWK_uJ6_hSyQv&CQgOA6d)QBDTqxk53%YrOpS7$yat7GjUOV)hIdDj82To zmQg_h^E6*G5$!|i$)@y?-E0X7bY=WFF0GnSR6detjP4ffsZASsNmO~$Ttfv3%%Qxd zURsCFf8LnZ9?`*!1iIQ;otE5N6p_bwH^!U|SJtB;CmT?|ma%43kib06*A#^`pcd8Y z(#~HB1Oi>@Pfkn8j(?<&*2WC4yu1PZb;@Is7^7w&6>O>HoE~&)xfmH0Brt~>cL~|iietOezWePpB+%tIWUsWiL`ivqztI;heC+k4;QvLJA~9qKhN^e zXY%=QfBNy+0f9glMnX)co8Jn_)xQ1ckaNe(s33tkl-G%Ye@JV;U|PGVkB$VoFcQK~ zP`3C@;&%m8%h^?QRFJ?t&F3ITnW&vzck0_MNg&XLkr3YB-SvY6zUoO=&?p@hBrs3& zb8EGqWMah*bWX}{fj}2VLij$&QXfd!GTrEdMQe3bkib06SKU{8M}jA}r1^~=2n4z? zV!_u_7kNnT%;`kik~2aq0}0I2d`;1!yX0O}AbpYYRv^%Ykr#gdN3b=$m|U06z4%Rt z$si$?k1fF_`uue>`qTcVK%i@XyF=3a>Tjh(AB}j)PLJ}`v0f#b8TwpD1qreJFb^q1 z%}?sm;Fl)^0$nv{os{1iH%k=Sye0?2*)bBaK#x z1L&iAyUEtvT{dKAHSuz?+ACK(Y63Q`0C zQL$pjf}kiJ0TCPahN6Iq9Xpcj1ngbFE((eT6bsl;vD}IGuJyfh*1G?(c%I*WC&~1g zOeR+H&C|!rmtL+%mz_VO(8yT)wdoR6box7rxe|k??oUJcu7A+w8_F)dGnO%YRjXbE z@9iXC8aWd^aMQw5+pz1-EYV%jC0sP52DG@ymD|4RGbTE5`wGv4p?D0#HkcC8QOX6B>KUEZO6y_7O&e5aA%6K=~zHd1ipL+^9=qBJ5zGW!m^m*h^6pvY=Nas5* z&)G-6Nb4;_1rpQmT}P_2YiP(SB~_N5@A%TWr}@l9ECF3Gwe*|bS>Ji@4_o-s`@T$! zAFrPvvm!OxY?+8*YU%Da`fUSy|0F*6)==g;ATi^5HA>Lgk0x>W5v1gAp$qdDOr|a%A zR3LFL>^g`&T+gpjTIo6SA{~E^+E$$&# z46Z|}Hk;7DXA|++_}3_}^8plbFcDw6SBGroY(n0(N{q%fuB5zs0&nN{j|>$^%-5<% zJCiq|8I1~&J<*gDM}FmRZ0RERPdbOD4Sa?Qzb9Z=YZqK9LupQ>=-yz3@JMb;inJc` zwv*e*P=N%jkLf!NFRaPysb~3CCwemkbWKdIMEROBQY9%d@~si6yQJnpo_h` zPLBggX|u(AQm-!(R3Kr~ydJ4mZbDkR$}S}R(EI#zu7V%=!9!l7a}14t_8v9ak$_=u zfN8Z=A#-2x3Afvm4r5x$&;@C>Cu9CkBwyyJM|y1VkzuV5*KuI2P0z;lh~@``{N!_v zjbz#nNWdCZr7B+*!8gpT=ePQfVhHGh?IV32bCdXqjnDa3@@S^zgaoYf>H9cO*YRXe z6H-af=Y%PNYhIA1Z<)T#<-0p;lO?tuGE^YJ-Yd(VdHmKjpLyL)J`4d}u%4&&uImxL zw^<|K*KxQE6-dBmL-%F0D&Z6Dp7QsGvjlX(vPpL#oz)_(@@&Y@6Rnt13yJ^a5;J{r z^UQO;_lmv@0bQ^Jt5nftbnIL+nD=Nqkg0uOeFe)6J!|r?Jz3&EjIJ&brhGsG){pdi zkRz-KKWI7MeewW?fG$}6>8bK3Y)AoG!GE)NWy(1uV9P*riJuW^^&p?0qH<#h=z{ea z&57ys3mB?=er2&6Q(r*>wvY6y(-DpQp&_Mw5fyFo(ydDgZf@X`s>P&-8`kwV1GUVr)S&A=EH`_`_}{_eWPD! zdv!F1J-ye11JSgUMsy%q>GciG&l58;b>tPpgJq~d0`?i{`yYFJ#GnBcQkN}L7y`Ps zaDgaoNF&Nzr^MKos>{t?5hyKcy;x3bSc1AeraP+E#Nyc+f#`wjcT{;b2D8N1+d5qR z;2`O;)nXYc{tJ>(-h&3zFeVnmU!pw#-}^$*WiM%30UHAn53XdQl8ql|Td(vJKL%6_ z(or9&d`uKy_x%;ZGpDzDMk8|@f@lw#AbCTwvNlZ?J1eibwSKQT^*3M62Ep1x(etX6&AeY@0ncOKKzhXlOSDpie^p%i@fxRl#Dj3J;4j!Woy z0J(d`AwTqG+ulJ;zJhrd<`}w9f7WMpu(OrC=Eq1p!h)wVX`4r?Xq^F`ie12Kv&ZNPte@; z`N-TsA?|*?DK6gOsE$4oFGB?qUGwjuxo6IxGtCv^<_M zd1JLtU75NuI!=ZPB-nhVk(ep@p4%>s*)@eBpbNHebS<^ZJ1HgDOMNhDqU@0P79G77 zi3YnSV3;cpSiD80E8@{tPPy`?O$2Gpw=wF@HDNMTAOUkIUFU52QW~S)sdh1&%n;DU zjvy;5w@POxuUDH7pD#lN5-^9-mAJ1bqXx6v7y`QBc$dDLacZUX>e5}c(eemp zTnh=9Lun7W;|&QHb(E$bn!*s!1xMkuoIi-8pM}zww6;bv<8w&B97=b8UO6a@-ZxnK zc5@~}Ko>jD;68SWu0dpvQr+R9d)PB?w>CJ;MhJY?O+d|iCZ*kHP&#BUj+YvHUAi?I{3(eI0 z%&rHq-~BMz_~aMlYafAjYA4~$8(&cORkP3rjU?Qr=Vz30FcmGWQ^w~5Z3dEWt&_Q@ zVp|z1koe;K1(~B+sPMKzIJnu6bH{se2{FT&@&R-Hkrnmm!RZvVF+d@RzN{m*58A-RA!Flf;!4S{|@48B5XSaZqIrQU?^ZpW?PcW|ij8wi$P=LXI zpJ4g0K4fFZ72Nh`J(wp53CF!(&>FJ^DDdyhgx$|JWXQ;McLcg~os|&txef?b-0=nQhS~;29n7q?0Z81~FvI8+%EA{8om5E~{B)nC!F> zPBtnr3Wvv%u4PZv??|y4Dv;>5%M{yoeo}brlyc?Yg&0!(y-^+Y?IuG&*AJCB&Pvf0 z4x1}GmuK9MBU^fSsdLPCtDypkygV~3-qb2Ynpu?S^Dc&5?QmS3K3P+O1a!suT438B zTMMTTQeymxizOEdoW)VoOeE;aJ!^))V6$HNO|o!t6Z5}+SLp7f#L2ukqKf(>nulCc z!{35*cD)6*v0Zn{WxNvOSXLbAv~QoNdF7TGDv)4f45HUz-|~an!I34P>(M6*nkxNX z?f=HG36Cab*}eF-FP5keOfkmKM!TSXG{^OCZ-B?UB%sHh+wpjsL%R)Mf*f}$d$z~6 ziX$7Mj&p;C9Z(<7FvVv{m5bqt9e5ft#Yx70TrOnpz~^V1Vf$$|=*e&8nV)JGOYUy} zz;!Ga)lh*1j7{g^4o4H8MP2xw&DSynbe$S&j9pVh(5F>OjP)ND6V0_Qe1E?kYN$Yh zy^i7MqsX=;bNHdz8Vmtl4KW6||JPL1vy&2IFn#8i1BT%GsU{M}z*Qf% zxm+5o7AQ#EN{9GSREgX>>@Q4JMH{3n-ek0aMi_Ty=*OBe#W z;2ohmIc_c_BOXQa2UcxR!#fJ|)o@#V++Q;TRo_+iMn&{pK*q00;~Rrx)KJO!u7@{k zuR*@IGI3z4KGwHbi+XTMPCPOvoUB=%!+%b1qCWma8@oH^Ay1ba>@ZUYKiIPyd1~zb z53yi;IC*wAhrjP+qK3Z(iD?fh9g&0bOO@TYMgzl%G&PrBJh3T5Kv&pUUA$A=iQJa% zp+v93aB}qZF8<$W4K-BuF4x05&9)-@6?^}as^^LGNm;;lKKh@RB1{z|R&LhEX~)(f z<632Zd#45Ti|ofdWaqk393-F%-a~qp-sDKq#wLTV3-@B~E+p98{&`gdc~-uGH?Qu< z5YPpmEIm(uaX7g;Cx@Sk8^!ev+SrHeLECR+;jmlUIBnrx^n2yb|6I9qcsRLRn8Vxs zTgzNIB*F@{@mxL+WsKcLul&%0aPl=ghxeYN%{&k2f_Z_SW;Gy^3|YRG_o|B!Pj1k` zm#^(YZo_t9n8ImEx;TI0HgpatJ4{1ggp;ubJNYNaT+~p31ib5XHtKo=IeK_4|6p}E zLqJ!=bUo~#vjL4QQ*!(F>PX^pJ)O5}h*3iY5-{h}eE?z#*Se?yTL#3-P`Q_)^S6AQ zFU-X7m*^aT?|QE7f+Bp$HC~1aBzpbP#wz;u%|by*Rei_=t}=NkpTBScLqHe2BXrk` zmlLm19>nv03uNdTlcJ3sEH|SEm6@0&QvWRAEQAC6pM!H{s6ZmwKpWT1-G3yDr^Clo8@Z zPb#!#2(pI-UGi(JRtA*8~`R)z{BGPY`ByOq09lPOATaNX~-Ft=S8Nx4%bK?1s9 z&R3~EJ3qtkzfULO?zbeUKw@{WCQgXji@GmTdKp)LF2M`GMUgZqj3J;4mKC~>tod#1 z{%;id(tWW6U1{!`*sNV1n$l($W(lhq8KSB-o;}kq4zI zE@-+9^%}DmvxMW(^XkTZ$Ofo6`5-+cOf_FbwVfO)tP)xJq_;ai#_Sv-?^|*5kPu!!0pKjfS^0O3TE&b~I zoI07mnel^BAaQ7kCaz1~jTTdyo{MvHHp$C6&7)059Q-Zlf~ln|)$gW~#Q6{Ssw1ax zZ2WJO|MNK7`t>-I!izpXQG#56&SofYVMkd{BZrLc^XHs8a8QBQ1m6IsXV6U!CC=eh z{8ZRH^bJW|7-Y=lM#biwG)^U}J2}3nPY>65p!pCJYJag74SU(cMW( zl&kqR+tQeK^&tUE7yZ)4?=bRZm&mVn>CU{X4_&a#(|PL9ndENfWnOjnj|gi{c$xsL z?dZ(ihnd7o^CCYrSX&JhNU+yo=MY8~CKmChm+liG0bQ`RqwmqnVWd=0^G^Mzico6oGKF)WnAHXfCmzk+|`b(tXTK zDkNY$I``UY2I;rliSN8qUxLa$qj8dU=G)eU>Ld1rl(smyXpkW|6S6IBvd0 zvGl~`H(E1nK6=q=DTcGaEYV99LGo+X^3sm;Oum9Wa+qW22{i2^$)UjIJlHt`DC{)@m5=vlG-3OfS50cztwrsSEvr ztCIzPdE6@I${_)B3~g%-=aE>g4tx*$O$-5DBQrFy#)JsuaYxC!%PuS+%Xd5RZHM_t zP=N%@`E+eUj37381NoCXCou$c1z{arAH5Krs!__|lVMS$e3=)YSZpXk1rqSC)AL?~ z7LYkpC-IsO>=**Nq7C)%gl#dX!)WFG+Hu7T$@YOWdCS5wHB=zMK93E~3(0MHE^qMk z8ACu<_))#T^M2lam62DwkZ>|MF_%BFdOy>@f_*C3?$X-FFPsecn#US+Uoty^A)w2k+7NgDGYmb+RARL398Ii#58?|>w-lfPi9_v8@VrD@bbh9C9WA%T z5YpI=H+9`9LIS${z8T~CT{g(RMu|~w5l7}v`o_I}vP^^uB&vIw;j@QNx%e+tVrXoR zBkPu5rH2xnT#di%@~YueRnmZS?kj$JZ+{ zA`iwB&pz6m{MnYy-2Lyjpi4i&4A(9y=(*WLi4k@$o*eq(jL*(CP(uY0jy=rrn?;*@ zhHp?}^m`Cbio6WOA9Gj&x{4jl@wZFqXU_di)o_}sfl=D(@ZYvf4CtEw*c4aPY%4tT z_sX;P#FO;&4(eaFx@zc}z1+Z0D0UcaVBR=R3PK-ae)#@H#;MhJYZJo9B^7m$g*>C%K(^?1~01FV-jPN<^g zU_Koc&6>PgaHXRnk&cS)yh#_XPy?J0tpzKo~+t6hWPW{qzjR$0wkcT_O$^XmmDH|iBhg()~G1beP)zY zo|Pj&1rqFav?z)q+VAE``dX_HB%o^$t$magvsac%jG8McoNt5|zCJr%uA?()MrX2x ztsjozwia4{|28f^hTnPX;m_T+2%fsi^YGfA!g&UGW34&yGE^Wj_@Op_+<%LZRj-T( z&mKwPo~|2!H~)u#uKvID|2~_W|0prg)f8^PGgmyF5>SBze46wWC`z2DZjW>3vIKOg z#~9;R3HI!3|+^GOyNp=v(P&l0}{|B?6trl2Tv7V*`~y(-JQ&}x}A;I zN5;!gfdtIc^i7MTCEURe!DvDhOF-8%V@q7&y0UQCE+xj^vrD-9B_q+Zh4C^}AOTCT zN)<)>Zjq7 z70wdS^=r2!zWvFr=byhZj$cpVrZ(yBqC7#UKmyi}^mG_XT=Q;I_~SnWbgjy?#Azkz z{eu3+7~z=0wQ2qK%wYOFpaKb4Yty;#3CUdIV*}yZ0+xWTO==5#)-m73ce`@0R$oiw z{#8DYz~9J7tW=9%#_ zR3HIcQQA6>P2+-(eiY8lqR$-kKa9$aacONJ${BqevxMos6mIwCPr^d_1fc?n`+LoB z@y=H0$KMj=`Ywg5uRbfR{tp3NFtv0fOl!DT2YtjNw1$H-P>bmbjWTDY_xc#qx1;YU z?nvR1H~5G~wDy7XRFIg}gsx_s-io?vDbM5h#uQG+VSrfn9|F2y?~J}pPV1{do&&_m zlz<8(y3#sPnZ@hSQi)MTiRq#3#XR~1Apu?RuG240%}eDv6}1z6X$+`9!fF~_^UfWI zPLEV#EZmmL1S2L#1S*iYOuss(eSf!L-%6rH;g0WWH;rT|Erfon z0sIzpxrF~gC8Y<2w3SK>kM?V%(Z!==AEAnWl=}zGdRQRr_;v;xPE_HhyG{!8jtTg= z#xE2l9usV0L`oEYnyv2ebf7%#QyT&mNNC+t;g*ZWc$cWd!txul&t4;4t1Y^CQ9#+(s!S}QSb zdzPvDTg1vH1FF;v0bKo!RroSFBLw)Iro^~wDcqSLBXJXLKVV-3_G)@Q)5I;0<_L`s zm41m+%T&(ZrG*$qOB7TfVeYGg$DGd*;_0k9T_60jnDemnkhEqkW(eqtai=+dzyV>r zlTxDkZcO7|RryG-^%u)ffkgWPEgTVWKoABgMDFS!`t=BHdB^i$hJdbCbN-;F&yEVY zZB9_4%jL;juOe-E;;dj9Dv%h{Srgk(qPw#~%sx!Gq%A$3l0bTNf-{{WS<3h{z z3ehI9Kes-=hn(@Yj|>$^cpTBdpDA%QPay_{+j6heUh>gB%@_i@LLUD_b_Y%g1uITc z;+>B%_oi~VJnfaW3>8Qu)u?dthEu}5O$zZ_s>K7&hROe8wFC+1;sP5{l<67awvLiZ zjuZ{%bQaB#{RbVFpaKatU#%Dtfa^LgmYe(zVhHHsIyRz`VP}N2p-PO{2iACXb-e5} zyG(=%B-q?edc~5$@vqdx@SNJ##0>wg%_vNHq{dctrZ|q@R=86`!j;A*cwBE&VM~ot zGgRKY&tH8sRlYO2FK3my7Cyd1ER zTQD$QmR&dC2|Hi*D{5yWj&RrHhCCYRl0L~sT(R^Mb_u%g;<(aU?5zEc5}PtMa03U^ z*1y?$hJdczV#y`FskIo@QpM4IOYO~guL-fTqau?1ZZ~fehOW_XrlT$@Gf^Gz1+&Dg<@EcXdh_MC1^;qTfke!MA;@RBg*e&m8;y}D zPvf)NE|UA1n=%A+!E2(u*V9q_4K7Na_3QzL3M88NJD{uMn~HPFe$W_6D}4A%NsHwO z-Jc8rUF^LY)=Fq$G?v<~LJ%t}KxgH1)x<@d0gOD5t&4aALo?_+VsdXzN1i8wI! z1C3#s(S$q*3X~117jRI4M2owJQB{bx`0?6(N}OnAK+=B%(A^J<7y`QbPS}TXJ+#Db zd@UuG7-90UV^_JA-)L@4o15t1^J>BER}~($@Cq8U?X57X#S45opa_+?{Sa;qdO!)+ zUcE@0U%lj8cb9WefyB-2BFc09CR{YDriALKJ!w}wNOrPV$`H^6Q>ap{tZYZF^mCI# zgoPYbATduX9~Eh8h^OzqrZKGe79@19hrDgs5{7^-_R1$4IFp#xJ>;nw>~*X;SAt@= z*MjTr2N>QFdWxXQP!cr1m0U1lHFK{ZvC8TxN?UbTIA5dOt7nexWZ-^V+4$;GhJY?p zqY6}zcvtY%ucU-+(F7ug)=RAx>Emwp-%-ug{X+Jx`*`&Gk0^h@DWPuKL!8~?JxXXR z3pvIQDUo9~p6oCGES>(ij)Mv$>cZY4JM&_p!nvFh6FLVGn{a*kSI0370bMYKbR--a zNFx24$a|Zla!`T9$y=4E&iS_RXk7)3;ZW{F_W!Vu!$Vdw1az@i-m+=}$!Psv>h)|b zchch{n)azs(C2RBgDzjt!9yp6n2C4(bLIM*Cy|o1ucWIV^O);^g!aG&^f)+Qn0WmT zO;wi`^!p-TYb4KAI~W4G;9aNliN8b1p@0hM?28l*x?Ucnr_~1?67GDu!#pQ??qJ$f zvaI<7N$*h{2Y(9^-EaRub*J-$_dk_XopPK;bi3Y_wysQK2jKI5$@+3X$K?zG zU9c3;HLpJ*q_63BsrT}9=9xp{pNVy7g|94>993dm)frDV2Yit-O_wtSbio`-ccges zB8EY)rQ^L4xr_Wa6jX3RXx8>BK6d&ua?lb4AE#@$#r@9+qheusicDjKU71Yw9D6P~ zcAL)-&~-8T8=5@ugmAdoRZ3i#GKJ_*d?HO;IDvx-By#E+QO%G;Lj9pDl!!E(LcGsb zND&oNIjBG)Gf{<$n&t^T)0I>;@t#iF1eZudyT&pEbU8)PuP(&x7RD`CV)XN#K#pFi zliIgQ;o#LSjroN1wbX*U_}?oxYBiqtcm5)sn~=)EltALxt9q0fDhodjT&FS22M3dR z$F*gX%`5?3zQNVVJ@&qkJzlvEC$Wtrq}ka4t}AzLWrIr<9bWF|1xu# zFusLy9anEhlH`snq+yvM98@474>7>iX2HUHdnJbdzDTk^b)^*EJb)pfOK*M?Jk>T( z7;^G5CGyTJCeMT2rJ(1Y98@52`M5EzjB6&$JfMuWhpk*pQV)1aQ>XI`0bM=HjPUBi zR)Wz)CB~6+@g%!&kKi2P$PJ*Y8R@s@JGsrcj3b_!;;P}V3#uG0;}bq+IEVfp+nu9? z1|^I~`-(4Q2M#KbFs3UF_5NXJW>_jQI#NP2Dpb7lqys}hS6A5#%QVKeyGjqao~ASkiXcs6Ybd7}~uO7SnU?`%8J18#zcoSL^`;?36T8DEX~C z54HXRa_HI$X;tsX7%Gr}IiK#uX|jNv4P7Q}-I2;c0=imt)WtTR(}hwSCGSqunnQk7 zo{;t@hcjgm)+Laps{r1!$%Y@trNbfDIH*7Z&P>r0fx6EnZZnTa!*&j22ffRqXA45PFoExAuT>nt=)UQg~>Fmd(3XY0lJUVL-J(=wCd?|g^7|lTi5^%gr zXYSTcB5CJqq`^NYvIH0}vm@b4ODB=j(`%&im_!cFK*3rY&Kl9(ROJ&%WA9oC|60gF zg(1MK5q;BQ?PQWx_FOvqdnFSC&ig@{=H1hwq;c63sjU4mru~2foYAAb*PWqc#pDVJ zjmTpN=z@8Sj-#??(66Oml{So+#6blTaK@9K1l@HOY4Yl<l;5e52+Wkj5lAFvB&^7emcj#^X z9bxx}e42MxU-BTogIdVfb_H`#fkf?*2WW2n8{wd>(hhbQ>`i_Tvy#J_E@cSl8uX_U zjd!RK?%qF4iNnSHNb@~Ta@m5B98@6TS$`QBooE!soK@6n8CUn1WkP_9y+mRh_+~m>_cMd9$C~sSU4u94bJ%%eWW)^iM^wI)rwdC6sq0ywBZqM3XeO)1e6uZ8WSgh7@eDQOiTU(p-SK?M>u zu6xj_asx4_NU5opk|t!zkwDqIaU?@P*QYOs(N@7g{5d<15(!!jyg}_adBVShg9;=D zFW!wZB8^jLX!$?`%H*PE`2F8A=jP=Ul(RO2!}$42b8P?Z5@Nti>I- zZ_^k*o-g4}(AC|<6c-E?NWc`*xuvldd}p6nc~0XYhJdc~4*gK4M^@rLkCjpC(@o}l zyTDj^(uum*sRS`HO~A zymd*|?OkB+n&;{rH=sCjE`;ol;PO>$fXMhSMU<*gzyw@K}(!yKG({8O{2qRtPB66(bitaB*6pLrcyGFnGdKSvQ^P?5uIbR7 z1Afc6k5l92;-CE)0=i&*Ousl4m(ImBX%$&((cuYpHM$(XCY~XnOPXVbL!5I8^H(awFwJ!J=Ct?N(L0_%1rog*jWM5< zQSY6laqi7o0=hg(=*bi+JptcQ2>sR`>PcB=QS06 z-cty#e^S)H@|JKL-o-HlbY1Z<_UF0bMbIE_U7;CPd^ZsruK$L0x&fmK)VHjz9$xW^{*;=lWzJ z#7v1X|LS42(6^fNnH9?r(DhnN4+kEeE)3kR#Mt&^x4ON5HTQZ+EP)Cn*n3sEKSgc5 zwS>#JjbjMta+|4-Z$0uCj!jl#JThOR){i*DJ)9Xw=QRGOKmtA+`qq}y1ftdNy>ut; zD(8Ie6Ed5!PZ+lC4K|@O&3SFJ1?!?Z?0)MFiU>L`*pz;xF>(erBY{hr%a?gWa_Zf7 zG|RJAFfG;OzV=K(bw54}!KK>VxV0$=m;4a!eAS|>$o3mrlP;g|BhN(W(w_06{2+i4Dzbg zC27zyky8O>5&fQQyFuH8&)WY@RWyAY@YSy(srJVOCI%!XazD|F-Wvs*qBk_gK;JN; z{&iY1%_wIG=xT9*?sQVG6;x{#VrZfrN%HC_kG@gH&mXh_J)PSqSVwAbI*)dvgWo?2 zL+OE{?A_hew+mT0)=t*maD|5oBqr`ZiB$NtVB7i^jd8JK2ePwMC)s_vnjxSIK3O^& zg`CO9)6L~^YfmxH91`VQ&ZEiJ6~cI5Wi>pJ#&B-zE)QwGgCU?x*mV}=XWkPGGX7BF z{Sa3&f0eb|X;cOe6-cO;mLdN+rNYlj=t&|scb4DVL@@+(ogP$**5AA;Y_M0Z z+_twTaqn&>Hyj?rLj@9KL?t@k>71~*^%qJ6|8OPq#@NZGK8PWp%hjs_*<`4Nuium_ zHz9!pkJXkhIkw}W0tvPZn$GYcQ`VWuFQyqV1a$cyd5v(xVd0V0cN*iz(FsKF+H2`X z;Aajhkbvceo}RR2EE#FtDCM8M$q>-B;MGUet07D9TcOXfHb($fdtG|&pKC)`C(5vPXC6+WslN0TmOKWe-98@4N!BQ9By4O@F z@1hXHOc#>}Zb%C9e8E8l5-&IFVUpEJkPMVmE%J{gGn!SZyBZZU1a!?>r;DB5X$jqa zD|LIrp=eSQZYAw_^NfS8vX)J-{90Ssl3I;f!Za|Jj7SyL)jg^>s6e7ysR0gomQ>ig z_7#ocGA5S97G$Xp7=L95=vtqthvRJW3Ntg6R5{*^CGziKBG+AqzdG6w*U&Yg`hr*Z z_;7t3Xd@k4H})gG?PiFhc0D+wE>>c!u8Jl5YtIONE?PWPAW=I+AJ^XrJM%n5iE$(; zmYmV+tWJ8N#}Lp3Q%Kjgo5hiBeKJLE&sPpAkXY40ALFmKg?=-X7}kek$wu9t>PwEA zJS3otz49qrLdfvjKcpMp3Eb(y^~nG3aUt?v6`nlv1A6LuO4ynH0{e7*gT5hIxaati z#<*V^Lhsc#>EP1s98@3?VekQ^rJoWsJ1NAh)*NQyu$I` zAJAlr6GBt{8ocD#8x%9+jKFKWr9^hbcrwDZUWzC<&p`zeVv~9_E9Z!CGgT>rE377v zk4EpMr1f_g0=m*Z)T5|nhlGtk6e6KvBKbD@jZ_(x#toSD0ZqGBC=4@tg3CL8Mn#rK zgnLt-VR%RA*&? zj`)NMtWOBp@0DFIS|6s8VXFJmjfh}|fUYFJ?`ZSqT*0ZI5<@k63hA)=vDDEpg@Z1O zghn(lGFKR#{1CImkg2oCkdR{O{rhwdDv$`6p@D52)(c((?$a21n$04uEY3+TmP;7| zy3($x@WG@t!s8j0lxTl*4*8AqrRO$D9CTg2sfkr+rSR|gdzd9wl*}h@u5XfB;x!yp zAOU-|^ki;&dQzw8T@tz##t_i8!b}Hy8_yFwQYvVw=4&h@LF1C78+(&Es6YbtYU!EF zzZR0BZSy6M;~5MAUB|BK<4vRc2vZL}p)qcjN0EogQ>5xAsT@=w!S;}A7etfLh+a~` z>LiAMu88dhxblaIa1<#qPQ6`BzI5ZI2%nW4R3HJzRJ4YB7(=>#H0Qz1sU;>3|cn`@70 zj1nP^jM$m3PI_U0sS5G5Wjq$AdL-q(=#_u6>)$5YXk~ zW`e_Lj1~Qr5>+xSo=i8#;%vQT98@5&_l*gza;_?f=~qUH?epWwezUGZRz(U!K$r7p z6FiN^*!5l6Av7g+9N8>>l~UGkVd{2RYr~p|?)V)NKzw=`%3AlLm^u*>??Wn(nN5ih z(OT&NycsZ>91YNvy~__V1aw)%K0}+2+!EebDD_pwXb)1+zlD4#b~^_ZNTf|DMMc?_ z!jyi>sHn@^;Y5sTCg-+3$`H_1d7%`YFRc`CWRU9Os>wC8?C`8f|Yoe4?MF*IWd-Htdmu;KzP=SPN z?FJN+r7vo?R(djnh#4u$^^r3|==cLDkdVn*lzK@|EQnS1Qzt+A!*6yUEx);}#SqYC zpR@%1_+TWaWGmOx`&Ya zzrIUby^b@zK1jfx8XX%%29e}QT{$e^JVQViEc0|V<4YiUrKcyGcfZL&1rm!pR-!xQ zSA_CiN*UC3^dh$_n#!H5Y8e8$U(s@xWW3puBFj@U; zDGwD$Xsk#^CcX8jlAaJdG=R9)>B+~Q>+(>61f0>Kt#e;5ved~^K7ZJP zA)pK9N_uAI;$ft1pJwve!tOj&AOUBlXl`HRM!e%}<&39@A)pK9N}8|c^W@$zd-;&w zWF9JzfHQ3LgxDi}$wT{pWy8o>3;|s*SJIJiZ4a`^tfPEq;|d-skbpCVbQpE7D{)Nf zDnA*Y!4S{|b0r-?YB-Q-K8|v6*d87#kbpCuv@R)VLlV7u$kIZ(P6$d4birIn*9}&+ zBA%^!$fn!WJX9dT*6kak>6gq*`p8ZpXBh&z*xcT6X;Wfd-disEbB%`zBw$-Y&o!{# z%v-!0E1P|}&O4{fby*>ri@^^1+}Y++Tp|Puafn)vd)>3bWkx@9v6F=^Em4a?L->cS z=gL2V()su^1uUUHe;&Ppt*Q|9#B?{(&HOAF-K zOGA06Kw{@Vb}qJN*5a2x+LY)uvXXCT>?N;CHX)}wS|hJmO)Z>{`R=aL_2DBiNt=U{BQt62S-Us%;yo;uHpNfjg-bUUL*rTSvaK;>Rpk5Lhw z!ENOi2Ur5Su0;l;w~0T6w}%wMe1j%AhTF?w%ghN>AOWvWrP?)CmsE^yE1UK`@hWA8e*UREe#5BQa?^!& zJX9djt8N&YW@I6%Uny-=Ps&2|ZA-D8h9(E^ zh)UJmdk#NlN2EM|sy%bBAOXt_{Wf6yMBce*fjq9!njxTTr`dTIuba)p;u*>+nb52Y zKgM^F{E2JHLxmxjGB|C~PcA1TMt<3^oPz``snbfn^xN5BExy{Il=H2j%lV2{Q{<+X zj_|PlfVBi$mwY&$&Ld%}JZbM<9x9N4DWrL~U=QE7b*S8}X(mHJ*QeF(P+EnF_`^l1 zOJ@AZ;eUOfC_lcrpN9%0;GL%H1`luYj||4j5B6?l2i zX^oM4PS56{0tuL>={ing1Anm4Pk!@wEki&TtikD7_m&NOU8Inl3Y*Ov0S&4$VC-oIuD z=z?_@{g%+r8@%a+A+j*>D-RV&z~@AFK>V)bZ&kR;g_o-t0=i(`MTrS@e77a8a>vZq zJX9b7pA-G|z#cu~`NTz@Y5IgApbNGobU$2+Cd9rCmhbD_<)H!z_6cg)-{70>?<6Nx z+mVi0=lUIt*B7rF8Z)h$Lv5H#+6EnQ#w;V6ubP<6;+rn@m&ey@kmfJ^3v z?)+)Q+2BboTd}67^HNFGlcV?fRTB=Vn>kG-P=SQ|@*B>B3pK@daVC_AIdq?2*zBY_ z(SjwQD?Gh>-=th^amI9o;9kkR7P+c6_%)M21rqT3==l7Q%=gT$Qa_+?9zp`Tq@N=S z{nzS>H_w^U8070-{^yks>Z*o01S*hV@0Aq!fj_+Dmb&orc;;TgyRh5vzRP*nM&VI_ zA&rq`{FZn7zFA#9W+H(KB-&_~yFBgkM>vKQ;!ptwfBV(hJdbwEGwkH=c^!8 zD}A4WFIuE;v4hlPa3FySB)la*)W7v7VMC@KCE7-6kuxi{s&|zJGX!)EY3@tky#FA4 zEK&%Q0Da=VH&s1LCx}1=5)MKVD#@=EOwQ;~qU&;fVzxh9y?0|ELqOLDE)gZat`>5t z6e6va5z*YdM%`}lNCFi|e6HVu=6XFB#?&Y+PIQtHiCuV7z2S;KLqM0-;LXT=ccswT zMrjRJ^t2+XbGGt~S-Q-QltjH9sNK>x!nQZ;?i5OtTa!P|2D1568v^?{khn5971b@S z7h;<#*Kv4(De=rTlt(Ua%@EK9`(1RN+S`;Y->NP9tZmQquOKm}MKEgW_g%2pRi4Ly zT{XOQkh#26a3ZkIfIVbQkLNC)Z!|?C(SQ>EL*MZHP<#1hmK}i#B-sA$+ejUv*S}I) zcLgy7bg`-07_39gL<9NtT?YacNWfk#{Q_~JAxVCrC(mtY&k)eX-mBeibNC${d}Pab zeFE#gn!65#nmGny%mEV))|GS~E;)}sII5p~((xar7KH??kLkD19`4}#TpcKDJDD;B zbbYK_(Dzuqo;doGF}=G(r(EavXW7eddUhgEfdqU`v=(i$fj?E-R_^kxH`(&+Ug6;x zO~l=T8B@3aN*__EKTKa-wLz(`ggqPhWVMZ4;7eYxLqJze zz^uNulXb-oE=s+7CG9jnTdRp&c*~7I1rqT3RH_&MKH!5-YRiYl^9%u9CM{;Tl>O8a zeUB^cM^5ki{Q5H=r6SY81S*hV@0HxDp3mM@BYj-w$`H`C?@gtPzORP3Sx2cw509?n z9eo~4CKCn`s6Yai0(yqfRUML{x+yJ~)t@1tYp`!1>K6D_7!jFX1o zKm`)86wtH0L(RyMoO9Cc$%rAKYsu+k6m;sn@OX|=mvnG9r{&^~^wFUYfeIvGEkWPl zS!GQMzFn67?cR$apzF88c4V`$T4*;`sYQP!Sd)ZuL)k;HCQyL{tnKIHn5XGkVOi&Rx9caQ&so6?0bQ`2r*A>FHwYjfqZTkYwdWzZ3&{6VL@)YP#n2#)Mq^Jy#la zz=J>q5-?BGb)0h+#Psn5sYr7eLqHd7+v!`s7B0gnCVi$MIO2PT4AqV@x^u6sBRs8YQJp-it25^J7>xO|IzDzs@(YPg3b z6@1X*Y?oazQy2oeU~ieegEjRbuRHdr%fyRQ2~;5Qd{6JcG3F0cTAVD)GTzoJuW(G{ zbcTQ~*juLiOO{;WjUIQwr$@{rP=Q3l?K@|b>$urnX>s)P3wgJ-&4lmcW-|nI!QL`m z;T?W}e?`C1HV)5Y#vhOfId`{EiSdw*MQFc7e>Z>c+4s}k>E1i|ThIkZFZ8^=xmx5W zIVXm19!n%JTPOF$Rw&(l<$`^NK;kA)g(9Mf-y#H~AeXiV2~q4RX5*Qa~-3m@0s zMATS2o*|$Mj!WnnLY4LWyMC($&s`Ik@&O6|ifWhs@lS-gmP%V&75|n`i)br&8c$*f z=z?X1_Fgv`lSca8xH)}Cl5r>1Xhp$wVS%j%H+k@7^mJ9auzQY*D{?-AYGSqslX8{$ zE5m_CB+zA#*u*-3K&9aQQIwFCC5-K>$-!TuZx0+WBslAkIDb(969W?EXY!C)+8$x& zM`aer;jD&4%vrs_uY6-V__)i>avszy}RQz)?f*nqcp(ZFA^b}!wF%I*YI z+KdoYm9YPz7l8^Su1?a%hpKn=c)vy2GqEhW8A&o(mG$b z&zh0Z6P?kvTV4bzkZ8T{2g0rU6&`U?c5bR?D}^kKABtS7yJ#ggomv)F`OHRPG)-%s6e7Pr~%m)%t7Q#iV#4}l6K{3gFaL%MeoraLIRUfMcXk@l`BE>$Kh0bT7U|3sG#Ock2Y{6^1W z^|d6qCWnQ4qkRcfAaT0pDH<}_M@YM-l&C)mmZWEXA7M6P3Fv}3pT1pFYfhd7T8U5m zMi8h#!hYu?bg65o(4kx@=i)eXViz7Pe#mDD=weIM;oOF!l9@;-NVoo6B_{SW`* zr8sEo)xUiax3kH3p!T@G|8I6XlE#1pR3K3|E$A;j;`@W`gLP zR<7fN>5CqafUdl&QGbbzTa-0Ocz2-!iJz4&e~Crcx6l~>KLWZwO`=-fuyxDij-@mtPv*d=KuP#*XRqjFM3z9GfR3PEJdd6ScwwL99t^*R#75{ws z-x$t)m9rCI45&Z?_I>~V1a!gvHA}$WJseHIyUr5uUd1=-{`YDFD>pIsjV0jyfkcFs z^I!V6?b`obIV7M9mK!$4|Do$F!?Jk7HU5% zVh3UiHnuRkGX^L&Dq@1z-C`?e*7M;!^S`e1?Y*x1{=Ksk&+P2X>?~9uQK?03z4DZ+At^&}EdfyDn`x{!dbY_Ajl;ygM?KQ9Be5~x5zZ0%!nJF(|l zOZ>LCtkpSq6uH;WASW9}NuLjGK{H2bW4<*y~H8J_>r#?|%xyB8Ll zvxj>};C81Ts!)N1{nv$P?}n44Yxnb9eh3vN0wC3AOT%4e|(nnrz6|k&jaVX zm}yXfM6dK!sBxM~@aKa(jkWFUScb=NQA5YPonfxlN&(u7THhq1?g7Y!4y>a`LUP9@vzd00T^vWj-5?%=< z&GS*sgc&s)iEDRTs=*S4#No>WQBJFCBkFm@w*H!fq*U;&3rv7A%`YU@WV|vpC`7)0jT5nLNan(th8rrFq&z7 zlhofID~Wf5R`svZymeD?Vf7;_R3K596N!9_u9IhR`ncU@cM1Lc$_Kmrc`Fdm1@A-# z!=BGUwCmnExLSs!!X76fW&mou{T|7e=KrVnc@(mMo>>=yC%HV%f@KAX3!S}DKclCh`Qv9NEZIOyp9f*@;sY{NAaRCHL0@uik-r`FH1@il zpntE;z*pxd3Z)2Lu+;f(L;I^*?*2&J_Q7=muL_R(VHEK1A~(OP&E&+2r`HKoAOS}; zd=+4IWjfD%9@g?og;ot+;(X%KZOK~DgedITX&HeEB;eSWKW%H{*wj&9lyCKGYioOJ zsMnOWg6**iwfGo!EAY@nCf6{nTGL&^$VS@wC4-v+2kVwougkJ35N|wLW zpNh(bjAX?wCitM^0D*um@v2Ot`>?Z1tK$bDNoteq{is*_9VD&hc4^UtOl0b_m6Xoi z!M_o36xsbsB^9IfF`S!uKju5BD*l^qp+N$=VE*`sEYXiuynjYH*}%lDew*{?)`K~u zcu#^f^!r8Ba>{gKcRxxBym1~So(?8!FGTS)CROUk+@1NE`WHJ3Wd+lKrNH+HTMc2J z1$LN3EmxreiKlG{Dkxh?$dy!{#)MTvS(9=VaA&i@8YG|#mKz_1&m6&eRx5|=e>p;+ z0*N=3Poon(7Lu>p3ZBNRv@t9n{Zh6_<1|P>mslSjPbae1=%q6E)#@y$KmwK_KbtCV zDmy;&in6Olpau!(f^C}b*w}(~!TjqTQ>^Grp|wL7j1~Uw z((@QO(X)9w%ZMn}a8U`0H9qL}q;s?s+wvajTxg4`xI{~Vxi3-5>0snPC7PF2#rphA zz#z*Li^HQBR3LHJ_%1SXt%CYD(uucUW90p@V~@S~4*^}{x)z{Q_U`Dptxha|A0yWY zHtX<>6HqY=zm8IZ`=E4-|6Y}UX^gxld%>~S|49Q9?q4sW@jlZL=Ixk|$joEqvQ7iC z^8Q0W*M8pGukoB`|7-27cp9hAmFw_^6YKUoLcfO%$e!0HS{f1a7|mIAF6&AEXepGJ zRRE8V;s46&%;{LU#^ACJy~6m3h(LkFs?(3rx~pB=d>x?YJW7j^KbM_rKbqGEB%lkH zI{yxDMvPo@uxW=xPCx|`13e!jiyvj3y74RI5%nxap0LBKUHShI&;{E9pF@5ZBR6_{ zzr#&q2BRL_)nVTo)VhriS}8?KCKhjy(S=}Ce~?c6S@D2wUG3xM z(|S+tVvLZ#>987wd5EOLEzov1^ir0k4blxlUI7^Or>Rn}fYF5NGY zIdOVlGPls1w)YNET1^OIP=UnYz$+-uI**LJtrMe1B+-p?;+39D{RINL5?uK^)$`91 z>)kqWqF@fKzG0-Ys6`Ni3M3lNDMY46RdUW+ufdyzr>J@TW&CT+;{^h`=FPi@T(%t} zd;jQZ4DS*{?TXtd{kP3xP=UmIr3e|8XOe#ZTFLXNm*}`6PbJ)Ml0ZOLttpR@`}`fG zc`f~_T(;MzEiavu($++Y>AL?9rO~_ z$I)bokDkWD27y}GxmA+){3r$$NWipte1v;yr;e?W{;m@V=xQ+S8S)ESPP7qv8pAWw zG>f)za>>C+W?^*~Dyp|YK?M?G4YI2FwElv5@`X`T z1p>NYZ^!rPKTe})Z<_3y93&9F_gPH^mh~a50 zwQq@QZ3@(;HgRK6frR6(QdEnNQJl>6XXhJDJaNLXyV~Qjattba`6)oPHz$*3Rs zzI{MXY}S&EX0g)uq7Nv~WG$KZBSwNH&7bHOD|qkxdeqC%kU<3!t$jYAxZE|Q?7Ci7 z>a^zgfSVNATx zsgcNuM+X9NEvp{d`X4(50=mSUx2Y74qn&SSqq;YyN1K$F(z<-hKCx@HlvCYCN^ANk z+chmsO5^9&h7NTjDVF-W^XaAGc&@Tmd+???g)T@JcIKx74=Tu>lc5t|c7|irVW3u= z;!2?ciCh~iDPqX1?5dCS(k-|aj$6HS*G87K76|CdXvt4EZd@^Y%WFN2tyv-XVhtzt zQbZE1`r2Glx6H`i+IFdA_1#>0H}_=QDV9s6{1#SxPmzUdNV=ZJM#EU#OG+R(;1{iV z^DCO$N;-7xevAZL%%DwQ(cgw|vtys=5hWj(jLW|^sJBXsC{!RJmTtW*lX0F)th&{{ zSRe!!=)w8h{R{l?*^<^;YVv6c`*ujc+T>?6-uJ_v7O&Nz`%ei3bisa}zge8ZYp@+w z*Nv2E{(uU+Od7jZD@~JNiHc=4B|03p3z(!1TCGs1KmyhcFRO;3*wJIYirU8u1a!fA z;`5?KA^2CDE?Uy)c%cR%0b30JDoH^o9$jFoh1o|61avL$XD-o&pRy-9>T{N_&xT?D zzOCrh!3CP#!E#bw$7nLR-9`zoCi=aVz>ob_hf>MkMyU1AM-*9*W2CL`31X~%^6fCTJ=`L{Di1mM9_UDd6N zj|v2I!3gGScYZT*@q=J_Z_|BrMnf~nXh8>bx_F7yvA?<0W!WdUxlT)^e!Oot?)K5m z|6kvJIX4U|(_1ME{SjUM@wgm-wMod_-n*v=j}h zGaGH@BeEjiQ>XNqh5omvUe9|UtA_Dt@PB%rDx1F|p#lk5(tM9_*lav`xFpZF zS}l|nET08U%S*Anzq#e@(fi#yy!UC~-Ad{*N~Z1FRgmHuo4D0^mnOlU2=+02#XTY% z4{JC{vU@J}KCo`!KN$>8FT?S?mV@LK+gb=|KtjB#AxFY-El(_uS?($j(B;~~Mlv=O zx$WAfw~`7T;dt>(O*Y=pltKj(r}&s5#ss0Gz4V-~S`m&TPTrF9Z&ecr=n`Avu`YA) zu;SZt#k%9E7axm!3=c*6SOm8GVSJQfG=4Vv-%&>H-T+)MB}s0&Gm}CE60kP;xj2ny z;|-Sv%8d@M5eVoK+phD($@t@x>azLRVhR;Vz*foUscZP-1Fd4DBLyb~0=i(_@ZCH8 z!*G{54$9n4)hLWwSU#{s_*oXc!|>WxPD+t+RiUgP0ZW>H`)pq*u44aC-r_b@AfOAD z8^1gE<~tK!9g$be^A&m@NWl8%XIV@R#f@6W$#qGjKtPumQM*b*@zBttQnl216e^H_ z(Z!#<1_a<%gAYmD`8PTt0bMYf`5mWbyqwkMihQ6Ee|C1Tki2>-|uTA zRVu1q{@?xaLGO*c?Lq=7kbrkoUb-XVtLKfZ{b zju#0yhTDGHPAZk`Tr(~8G`biPWc2MVDh!NbP=N&OwfXu-S-hO#`k0jchk&lQZFZ8W zcSW}~20e`-qr>IWA18??pSgnyBw#%m43W7BvUSi(wF&R;31-|y!)Q-$#Z z??nLtM;4H9Z&3c0r27wo%uOH53ZBh;Om$LdH16-exv zY$H7!>4rM}i>Sh1vGPUBaax=+U$q2d9O!~ApYLbLJ}fWF8K;?Tj$}}QgfFs{Vr|OY za#QuvJsFoEkLlk|tJa3k2!lH$bcv;#YaW7Q8ZM^x^=fF9=9QPMOB2Yf#u*X~vy@7{ zB@s`Q*|@eT}|G*=+nab2*LP&{2a5Bw##vf z@-%iI55&#Cr_AMrcJozrbH(TkiPXo1G zd(EWkPCSQJX+rrN2{V&s2W=(xBlUKbbtDj{x}?*E8(lT1z%&*=G?h;B?=xR5(fbnn zaJr%%l%%c96jOJN|Q3*AoNr+@Beg zSpE>|91;%;O{JWn8RWtw{i}!%lLB$?(hM3j`@29uSK4V)DQR;C-v^{e)B>Ky)?XR4 z?Wi9rRDyPyN^6g1kR!Xd{O78wMg(G|)-EcKHy3gaiQbp^RgFm}yWKbQG)(zbjoy<% zyKQz52+Lp3N>@lRARKnwKcu~3#gRo8Wy=p;AG@CtCjo{y?`u@{K zYUxv%c)rY#ei|xDo8ryM{))S#S^Rt3#Vsm>9#lj_SNq`q-th{1h1B#RJePV)^ne;_F{~f4@fxr*-3bFWpeaa22W$W-7GC2 z`J0+BLnNRJ=81o~w_&`tuwa%}Vu-?%tC&kKcmN1bS~C0?_Y2gv2?hy-*U8*L+XP8fqe zFVTtn;Yr$#oqc8d8c`U!Mi!Qr2E84N>_Rgnk#JeLP)jyxAxE}|!cc)k`R7(r>#{zm zC{<4*FwI}Hb*n4a;%yfa(3La8PO9Tu85RBgcU2oxwC?vR$W7WrVW>c2$afp*HqX}q;>e`zW04vfN3frQvI47w1n`S(ne zY^I9@bZz}uNvd3P#WA;kX;^zDYHi0?P&@NDhYBQMPsHENcoV0MEVo;8UJ)t8`LXwA z|3)t!m&BMi7(RK%YmUz{w2rI9eissOgv0khYVq26vP)~WTqK|ijvow$PT%}-TRW9b z^fH#~RWgyRv$hhOdxxa94~?bj2e*-5cMeGtcA7{f>$i~1P5S#d)`tUeOnL@g`@6Q( zB->O{TcwgEI9+=5(NrpUvz}P{r2mJQn(T)is>Wys?M_j+Y6<7R;k-ESOQr|o%L5nC zm%gVp_$>R})=Y}-ypj|hN|kzrn@Sc1%SqSMYxz~p9~+8Grw7r8hN}VrUGRyO?`KF0 z#j6HSqsw=e2(!+Rfb+@xN%&O=9^85|y_stxtVcm&u(63`XS9r{;riQWqxXd3_IcD45Cnh#N=HD$qp?c^%v_aSmmz<<6n21)6_;21p>O@ z+7us`cm!dqpN;4*w6N}&P?xMIrZ;f_wnv$Ni6#aUD!pbNGhKBrG6W0#GIbbj;!S}m{?mDp_|aYquQ zKHcA;HxB&w-xvw5<8tC-5I(bf9qoVXtOncCzFJ07PVIDZ+kEYR+SQSvLHz4OtLfB^ zt}q*5>J;t9xM>h1#6S9zJB({>EohlpU8z2Dv*F>$b0HP{@7-A z7&Ws`76|BqwaNF~9i57==LXS4hust^kPz!5+GrZSRbvpnH}0@NKo_ikK2!8109TrR zfSza;tEI;pOC^nVlGbsXq}l;SQl_+rr1+;v6`PqzzeBc=z5Gdre>p2Y0K0X~q+ix< z(x3tfSetzPyjLLpx@HG`P;Re4K-b-xrqcF@>xr|CK5KAi;4EBtb`>r1zokJ160p4) z3>nn}@W7@==_TV{LOzm47)u$W(}|pDk+;qE@~iR<@x#FlbLf?t1qixeK15>u#p!tM zm9wbcicoU#8J-I-QZ zhDwcqGUV{^AlY8`i11ImK5kCIYuenV&)?RS;Z;Fm@a5lV?UCceGhI)k+bSPiIpZOH z^`wSCK-Vq)%~7uqjjU^|6XE^=_&~rBTJM>;Fv@^A?{UXey6?V@Z2EYJ6T`;_;U)3g zX(-+#K?M?OOgX9G)k3m7SRa>o)d<3ktL&sDXBG@fA#ue`$QtEbKRJBelD=QyAkw0zSj>lb|OA;lb(KX^^44KtLC~LVot= zhS_+lP&dnmlW!oK}{>?`C@VGo({S>KoO_~Ll{=;a>j)MFQg z3MAGaeTVXT9U{$F>!XZW&Bo&Se#hvF7JCH(y4u>lLUHx>65~&L8oM`mV#h@{>4{hS zsY}FD^knB5lG`>xdUm@6`EH@)(A5|T()>&(Cr|v*=^nLQv715#5<$D4qnxD&$?KPT z8pXz=ai}$;8DI7Z1ayg4)ymQmn=El-Blg~eFbckJT~sCEzXD|1(7$`#Yd(-)o8x#IPrRJ)lZ58%@T^A^Dv;>xkc-~9JtVe$^{cAew;sOf)r9qVeO@4- z>*@7N$f4F_a<+s1j{Agp&GGvU)tU9nd_;s)0pSn1zT8Ku!li>qz@EG9C0Z|+s)6As2n|w6{Gs#RTqrdkS_-W z0=i($8Vr3u^~a&zzS7w<4+(V+i8A97w7C39(&dt##+6SVxZ0xkw8j9DfG$}72E*SG z23%n1#)7=A2sH@n#-iFTWJoO~{)hD)jg3PrFq+wp1+INVp#llFZ+p;Q%imtb z+sV%r=dNAYv??D20=jlA+kvFSZzOkDu zAD?@QWZi@9`ty!L1riI-uSKp&#;V&;{i^yL%2rl=?8#=HH)4>0u46w}AdCKGB=g_C zaJ$pt%AdFa?D{fGHo0RwYLm=Q5UCZp z$(UBzsl09&#EfgC5U4<+zhN!ff7?!NP<1mW8Z9VMj@V6R^HMUAS+BileTkKN`PLR` zRR2^o_=K(cBz~tP66Ex0Wz#V~c46fbL4icJmV(r`71cLO^f6p;$6bo)#z6Moaibgj zFX)0-%OmRPXk6>#A8NIKs0@4k-i_X(Hy;bgj`x`oyi4$JT38IhYhT&1+Q$PBR3Op4 z>;W3r_yxInYd^oL_a2^j$UY0!&*hMK#{sU?pjT+~jCx?zW~IZ} zj&i;dR3P!WbU#{8%B#O;=rebef7L21Z5YdWogfg<1zRY8{@D0aF{(F~HJ)7{K?M>$ z!gio#vu)ITf4y`agB~jP3w>BogLgt}hc4KT`S&?%A5v;=9>|EtTWWM83B`7`RDW+z zkYK-ZuJ=Y{xMrrdo1Mth2*0*P>C<%tJ5%Qlg$g8K{PR7KjovB8V!N|3Zp8utU0$sZ zqDhww>aHP4JdOT_2TJ)R9_*`}p6$(9d+#L_Dv%Jz3|UE| zaU1&^w85hkfqC`5%|d-}ThGV$9*2|~BgV42DF-N2AOYjXV5qMBQjF{NXYsN71p>O(jW~jCPc%_m zz1C-=Vyr$YwWZ`!#tl!SW=~vim_5rI5+e}MwdLMpwD$HLV)t6#>)gIpH@qvc3TwB=k3t0!FmCw# z)sW%1Uf5r{Y0XrDfUftg`S;d)7SRw1p>P4SAItke~ywC;rcA+&R*lOSBv{}m;V3?6-dCiF&KKzoQ9{x9in^O`w0Ye z!5L4!ViG(J_ZV`BzFN?aLIn~qZur`S&uJyXd;;q`Gnsl1U4-)6*s5ggQVGV&AhH=H z4X{!>#Oh^Lr7lsn9h$_t-j1hGfdq^jes`W>z)dR*WkrLc1OmGL1{_7LE}E&$a`dvA zvj3-I^=de?bPuFZfdq^jel}HD4V<&9GYgwAQ6Qiz_3=3rQSmQ%Rc-}Of1^nperi&DeAVrh_slril?zYsu!-vOODwYH+KO zP|3JAo)T7_y`6ATAfStte~JofKO&=QZQyAPT-h5NZ>`4Cx94k6fdq^jKFYY{i649^ z$5!VZ5D4g+e&++K?V3+=Cg{7V*6sAd!BfpyPSgnvDv*G2!{2r)n}7o+y`(1#HVOoE zsf9n$?Zq0IxpZVZM)4W{EqchAz@?^X*0bis8IKHi<@ zhm~MPr=7_V);}Nt30C0=n8yIf#~9mREz_^!j-G?YnY$ z^cZ&J!f6dEkbrT+*CwztHoDrMg(-^!0=hDPo3RNyS4@uNWi$^-yeKG7LU99jdm%{Q6T|c zTWkD4(PvMSzh(Lpecr4I_;HPQG$+wkg9;>I-0(EE`C>n>o3zuJQz|5&OI#&$_%sa< zTXKPpimo86_CZ37s71eaD@JyK%yWv124}fotiU-ZzDL;YkP^5#fDQOKSc3{AVBGMv zy8};@($Y!n`Ra2jB%lkDU0tpy5{A-(+D_|TvlvUqvt3m?0 z;5>@K@bR?+wy8UiJ-=~Ig$g8K-0*d^pvKrG*PXp^T8tn8U2t}XuhD0=z#i2Rn_1FD zm??q;j2r&_s2MWe+|-2`UG$LPe?b?VrQ+*qa#y@$Wdqiv`yc`pNWduI?=+Mjfa?}J zv)Zc{N|1mqINQee!x<03>SsIV;$w}V0tpxeeD}@=Z#-(XDGQmjT7m?0!FfjhHekiE z*sJv~+UVR$VFnTsFbepa_o(l~aHWRww`ltRjxi9|hb(`KvPC-zCgcwoV zCiKM>{m;^fN$=^o?+;L_>B)cRFbseA0JZwei1&#YDf-(>RN%Omgg(~SChVVVksED{ zms;?XDd5~RoO>7Nsqedelz%@TMVB;~!{8kIr_s~Uv~eb?Y1=4i+~Wo4OKF@HY3Vv9j3w5~EgFyunMh~8&)cw0j>;8IKy}W6Sjk2R@`TKnZ0=leT z6rmSBlo;yi#EyM$l`VcJX}Q7ug|m?0DMy`~Uqc=J@=3xAeciyV_YdX#l|%IMmwpT? zkbvhG@ipO#7WiQGeYEr79s&Vf;#GxYn_>U(RJ!j%Uj`LO!1Igv?1x`fT+nwuojJU- zKtLBPL%zFh##@E1ETOr!-G!XPJnc-)Ld#wilL~htdCubp+*U?ud?(e(;S4H}Fg!hl z<_s<&9;td+xxCF&7OVH^nZF(a0bScu4O--x|J| zs3hwxQTPc&d?fQOQ@P-Niz>Op1p>N;r0zjap1mL~2J2}QCT~{U`#hsvorW{0Kw_rT zMr6_FI|)?v_*fdfM(JezfNGmY2n2M!p1uLykNQXokIdu5*z6!>{r4C2qdJm71rmos z79*+gH)330CoVmlsUWwv)F;cEK?M?($0ebHtTJ-qnVt`)VO^DJZJtq=Nn-^9x|&Sk zr<+gyLBJVehGB#25w@NT6L) z(3qP>>Q{UHs$P42mv2Qqp$mUb5D4h1=Qj@dX8k689rZLq@lWOYK#A?D|3O$wg{!1y zZ4V(s*?SURtk?Oq6Ri~`r!nogbPj_GB;fj|!LWUeu@YRkU)%OALLi_E_NctyU6-z$ z?L3FhxHOf$J9h?k9Q}wac8`$;H@Jav!(YAjJXK=RwyfcV92cjDf#AU}X@{>sJP^dsc>~~lE7>=C|?x&GmZwmx; z!QB`9d^nH3c+SF=wC_O+1{FxaexC2~YGQ{2A1|Pd484SWz%_Gtg$Bd2X}xiuku%<9p7qU%wpf(5(wymCC%TvGc#36`AND7yXP^eKmzUw;QM5{HOKL< zP1%DrW*8FCCDx!|VJkj_2n2M&GUPo&Lxd|N|DyA!f1+??0bO;iZ}6`% zTqolr^fabk=!D0&{Xx-(?-VMKfa&p``eOs!;E)4bQsuWmK-aU(Gia^+i2P`-r(w6G z9{zKqB6~f`ltBd&uoO74&KkQEHDp$g%mf0u9@jpE`jxyV?$`95q4f$?DQ;)MM!0mv zFb3g18CXyJd9e3qIjC7*?NISNAe68*CUPU~ebpeAv+vu8E^fIvW( zI2K(xJVj|1(}^`aU7JA#5^z@spD$Svq+E-ZS&~B?fq*VJ4(9jskPbNYc>q1tv@wIH zWWkYae$q?idu0#ls_AXl@w6L0yDFIO|51-Y1ro4-y61rjj+`4>GW+TngRe$pKQO$7qF5{8^X{cb)a#TI%#exA0$uM0lY zZueU-s6Yb7zrnC%eW~)>*qT}IY$*`XwLI@2T0HeV*}Ye9iQlKbS2~oKv#N*NF{nTS zMggBaAD*jJ(VW=V2ON+rx%0!dK*04>Im)Y*;#m2g9MBlgJGn}U&Yh-7G;aN z2!!AQPcM9LRF@yh0hfHb{bhFspM)R*OU6KAg zp``=*GN?cT#tnZntnq&3yQ>kKSxY3K3qFhRy&nU2DSN(uqL&H=GN?cT#tpyMTE!~G zzUD0alZQY+7kskd>mUA+isg`>v`)q03@VU-QNYgxjB!zt%q>{6g+m1by5O@2e=Fsn zi(+=szHYCDBx9op$;sAEv;tez`%!ApIF9@QRx}dRtSrJ4|8c?zHRZWM!^CbP1Q5?~Tv8WNEIJdk`iq%8Acf zpKqqJJz<>qU1zY|e6YfNk2Yne>bkf!E;dsSoD7#B9e3wQr=hpa)D6S*@Aj45?JrL` zU5Ckr9tMmj%-Gphd@A=ag`&ToHm=OC;8R#s~Ry{{=Uz^LLx-J#%?ex2;sd{6Wp7V*)4EeyHA{y*EnF$K` z-@!lDX62+BRPV2$oM>V5TAt|W!emA4;b5=!XO#=8u+~)l^+bQ#e)r&t>=pWjHV+)j zU`@gL_f2p_qvA}}sm*jEj^2?Q^eD%gZ}DVMfrQvfCi&czALLbJP8kCQ0=kYVm^w!_Q?;NWcI|hnLt2SJ7j_;JDaKLb@eoUY&tI6 zS{KsL=~EbNul-XzQ8(*9B=vdtf7RR3HJPfS-AGdAZ#9`FEOCIz}L%3${1@>CBERr}@{apA>9fCLe+3CKqSE)aB|{Du zDXym`vn4$|QFPod@-i+$3XnZfYR_L}(TWJkI5ZCB-TOx5QF^=biylaWUT#oUxz1ql z++%pc{G+;u(aI-}h-0BXE-9{JMmr^rQL1NzGN?cT&NK2SuL}#MzVWs3$FV&bY)h~u z!mH)|Rmef@bKY&m_ugdIgdr#ToiGhO|R3HK8&iLMs z9rskb2e$a>)*%7`UE)=B8e3USOUqT}HuMup7ZPx`jgJ}Hlvmk&J`(RYL?ECGmIyzm zXi)^6-Z@{qayj;v6b{wnqhoSME$W=RGNEy}KtPvRgT?(M zO+ML0>0%Qu)CVN4K0k;|TRtRD%jwS_1>ZMlGrzhhN{UE87mQ}!u99!7QAY~pn^BQM zoI?VhhtBWL@z>7eiZT`*64?e0acH2sJh?q6Ok zD_A}Qz7FVg+ti?baL`Aut?dp=v&MU1_rHz|Dv%KGoWo`%%9dJHd_w9g5YPon#9)Y> zvOwPWqY2JSSA?>HgmI5Q+5PsEk>4nUU)2NkoNN-;4exDKS0JDZmK(p4bT~3^^vRf#TmHf=<)K{EQ1cM4B zU?0QZ!uISYKf7>V=~p^UAfOBO7zRU0;sbfo%R<6HR8vYzF5YQ#I#O`d7+;-I)W&g+tLc4+lED=8M6Szo@Y4~1= zJTP7$pbN$gf1h)en|#dny|USXuMdJ)gM?UCGv3#c=N-1diS4`u0=i&d$-mEO5#e^I z^%MH4sSksF6pV?n?kObBs*KE;tM@)>SE`)&UbUDm$(YQb0*T3r7ukQvNSzk1w_Ppk zgf`apA)R(^5_>=T06DVfJ4snGTRPC^7`Z+6I~jX(w&ZHHo2;$+nGAd##EIw@OSIYx zKGS32;}}#R5q$p;slMm~dAwXFJj2J)W!Q&}@;FPsTW%$(iDhI}!w|{g^)WIq=Lb3E z6D(;lnIy;O7r7m(=VRtib9!i!6YDm)AA<@cV0!!%kAM@}%#Y@*!(cCgfG(InzVmEV zHEqtfCd|GSX60v&AY*!)sBZ&9CFgY`h;gimdU<=O^m_U-^2Xapy|h&?E3ajD)e3(c z*&Qzrfq<^Fz9YzTY@(iYBQwu^QSWkRcv11)| z&66@(`NU|U1|cD)(em9~HNBo0JDTP#5YV-+?sB61GN>QN==rEO*Hk+{rZzM1=MShr z0_KUYpIaT#M&D@5yso%6{wYpvg~*R;ITM1g=V7-@X>&XQ)DYq>A< z*Tr!{3_=1%0e@G&btT!%%7fYTc4BZ|49;KWysO<|yHZ{?3Dsxl$?gW~!LI5I$BjBfec`A!;a`>D5v8?IYpjf7uL>q>koal5 zB&(oD6?Mb2G-;CO&uqIVmDK^}X`DFup|yJCUu-!a z=zN)+o;rsmjt-?zfkb{lO%lfJ)dqL;cj7)pza!7n=CEf!W(fpzU0zyp;#jnU`uU3f z*4Bva&g6?p5OaIJlR^a&mx`*AE+6dFz+C<8gzf`93F;fff_m%_2V=7C3a$O*x>qF}M>vc{fpi68eTf3Z9 zEoOMIE6*I6@6FM~-PA(u@OGZiUyb-8L|ff!B%9I1SV#jBFOw#a!L_Z` zM+tiAjvjqYD`z!^*-ZFB;eSEbk2|Z#-({BSwWd0;xbT7IIdn41;kzuL0*Ur;+S& zl~n)fdTakXx;*tO31Hn`Y!?XVnznE~DV%Go207})dSpk3ItQ~!&M_1!khs8RkV^3m zY6jEa)Wbzi^vjB1_D~HG2ZmBoNSb*(;4?->sz1eyN}KmQlSCefKb&4L-9{g9;=pqvMJ1 z1ShpgQ7R{(YTj}XD_CGJ^zATG#TYEAF;H4qa}eve#hyV05-{fZ(}eM3wQalp?D^x$0s&nx zy7=nS6en$7(kNE8%Sh-MAOU+K-Vz;ZXb+x`V%^IO0s&nxy7;(cotx(8?#nzX7gDG| z0`{nUC9a9AjTz(18YbTq2*#YcT@Jhfd)AbZww7ln#I2zQ*(xt`jEu7T{Jtw_N8 z2aGPhw|4amEiyBd85e|8s6YbV;dss~2WZ<4hqA);A^}}6y7>I=`WVgoWjMPwrYVIA zBw!0Q7`|d;*yL$dgta@kvJp1L6lE;3ROc?%_e`wxU!a_JozA{?Tq(Sln&>|Z z*~M2>{eCQwN+--hgD+H2r)<;v#CbpGDcvGNS>CC6T19g|bico&ni#cKg6~bhH!uu_ zbO0D$o+^BC!QA(Mq4|{N4d@CNh;QQ|U%MMPn71Uw`bG7&+oHGH5j@PE6 z#u?>Q|7-fa_WQO;O4FcGY}UP(0s&p{%n9ChTR)dO`1fOZqnz2kwBD%U8VmKz@K_11 z-N6-1gJI}}uJTj|54Op~iNSslj*#K{m%-4m^#r-6HjuFdd!Z%5)(8KIpLO4MsGONK zl5IX{ETjR6`kE;!&azQkRn*UPvUcb$-|!jDHW!ys_+QWk$9DXDxbW8UWM5ylcEL>w z6-cC1HAN2Y6;;!x`Ux9VYBZED`%GnJgRTh#bipwy|K{XAJ9*>%K(_kYPGKwx2^M9H z`qkto_mtC55O}f9LT)fIh#Br|6A0*nV{Ja(^?f3pnl*<#ZxSqwsUb1`PMKT6P$$(W zOn=k6+m}1ihRiuE!9P$SpbI`T7!04|7olCBqFBz7Eh>CU$@|dH&8}P(^=Z(0si?&Q zw<{g0s6*){o`&PeMCtjXD0cns1r;igI7QyOeb1|`j_s?zGwK_XDQ$fg#r||!CJ@kd z?x{KQe#+Oaw9TBjdOKRaJt~TAi7?in0*S)0?r1-8R+F>zamoBs$@06QQ7my*h6)Mj zS~;&HdgV}AoqK-^C)Vw*ss!22V^Z8I0u@NuuAGEkjdW619?(Y_+nX4ajpygF?xuA# zNI=)Z>Rr&ZOU`O+&8?iMw=6>@Px`VUqxgvnV4WPsAZ+=3HRIPg+33{(RzKZ=K?M>p zn)MMGlVAPyX2T{L3j}n*md~F#Z(Nnq{loqOCp(w`_h)u6ZMrH8hzi5YPo-#tQGUj6PLlGhb|9-8F_1z967X(kFl?yOSy>nm%)UGrArR2@ zynA2tx2dC=MfH*XFNgL@5Wf#zwXvm8frNM;G>%G_x7Lkdo!zPn1aw_J{oRc|a#ri2 z)jSP#vaBpBKa`#EDJQ(=1@AKA{llsA5GCH#gZ({a&7cAac)#Lr@Kmm*gd85kd^g(& z1a!ex$xl3K>#R(CHHK+*K2WGY0^UUphFa^}DvdsmV6nCS2n2M&R>{x!t>mIy%kX9I zqs~yMKmv{icuRcZuHf}Ptp9^c0s&pHRq~z74jq&zc_!;Ld<}&PBw*{|Z@!G`uB=!z zgL%)|C=k#E+p)p0@0y#ku~#rN{WzXN1ro6J@U^1zS@M}mb67x==>h>=Vtm+~+bKV* z8o?&DsX?Iv39((dCG3?3?~Y(!2iFq_=o0Ty+ljGU(LJ2`t?MkzUqPZ~#0a;h4Jxa{ z{Pj~KYCCn5a!y4slS*|30=nS*93KH(>8vCU4`+>15>$Bl9_-J>@oqr*(aNWdb6LQ> z!7@}J0q+capG>D&%C;_Z*u)_|!rRNx1xK<5!?3=MmA((c*`SDJ!YCXP@P5U|a8;Wr zvDd=c(CP~W0=i(#^DpX@E|bgV@UKmk?$V$F33%V-@0e6vA!o)$vbp}-1p>NY-S9WV z=n3ia??`52oT)(t5^(&$pVj7NNx6R_+58O$1OmEXZSwKe2oue}Ad;oYCp4%)0*-L_ zYIw>D^kRJ!8@6|XKtPw+N*4P+SNA=QVjCyeqft%rvnU@SEs4A#J@i|jeS3OkbwwvL zc}`Fj@~X&Ljs9sa^AYl!Xtm@UAIXFo+;;b}Bu!H)t9Rn5^xD!Y%iE!fdiCRRsZs?y z5)xNgC0}pyw)?DXiE8|nkCx1TWkCfJFl~OSW6fmMofFpMrnx}^x|~nilihPFtJ?~m z@iaz^u~pN4@h_!4`G%kZ2{9k`rY5p>CyM#gZqoS8PdlBgUs)a8%UE7>;=}PaUX|6+ ztxe>fce}YQoLE_1ocNiiv99O2BBrZ(iQVjn6g|NUK+*bpLIe|fdsrlgW+w_ROtl2 zs?~cI3j}n1MK9dOZL6$y3O&xts=9@rl*?O5eJ2|UDv*F>$j?dLy+(S(pAC!xzavOM z7aVKz9oR)Q`qKO;S+Vaf zzbc=(UU+F63wFuSR_@~W9{FC(CVi}nq)PK1qO}LEl6S==lCx_G>SuSIoV@dZ6Wul@ zD(%;Vu!ue#WY-UUkgs(mReSbR+T6AU%DZB(hPwTcdiHPj@5$?Vx!;_qSY?`$c5x1i zz3wYR1rikoRYt3yI;h)T|K-G}byw-K3ck!S>Z5#mWFA>Lq`cbH&02mjG@lHZWue}l zVl9s!aDzybEz~`otazMfEIm&5{0~=e9@o?J{{KJQ6iFKjZ6p;*5vp^Z=VUDu*+VEL zAta%ao$T57ec$&j%{=EKTlSr-*|UTw`}UjT_50r5*L?na-fq|9{+u)C%v^KLHFM^` zQ22P?OCYdn!rudATUI3+bwDG=O{Xj?b^wIe?W$oQc zE?s;tSTM09b2F(v)RrErQh}E-ee*ch(K8ix+1FR_e_>VN!&SuMt}QL7tXRO=P*3fuMn2tm zYZiJvHQxd+R_CeAWE! z`N;O9f;<+^+f`MtC&Q{C8|#re>mBI1p5~mWvilraG;AE$9eAS07EIX8bRoHoc-FbM z1t;8>yr4TSjfG@ptY8AGdP7Unr=bJgdRHssZ2SAPoNgTC%>1s$7EIJ%uP2@MRi%S| zYGpLo(cjRh<5<|+%!02<{qMi9s`ci0Qfo+6+IqM~T&ogmpcBW!ir>ZxwqU}pbw3hv zvkFb!u06pQt6v*#{~iM?SC$tDtin%Ir`tE~slhI4EQH=9dThbO*bfs)rQMZjwqASY z{hTb=>Ahp1#dw{939J%ZNyE$L`a7-1!wk!r!YGq?^>fg=uMYI(ViTeD@Kc(HuaTRS zjE8F`bp^kRi3zVAbA19G>AYEH{Hng+h?Gb0Z;ZAr9VPr1R^c|L)5S9neYM@=Ve`~7 z8INRmOvL5!3}pQ^`mG-0;mncSGPYo1(+L(lv$6wCbJFpvGF&~X&pJL1qT@aFn82!a zHm8EyU#?0|3^L-xjPtvddi9b)oiA zdcR`RY$&)FmKd77T`Xm;wV2yoioK%iaPl8mN1%d?= zMMh(!Pqix1L*xthqT`>OP;O32f`q62gsZ|Te1+Wm9QRV=Ua8P{REj<@?znWZq?8=6 zc`av={nGPu{KW5`#qz~x`=qo{I(nnpTVBSSY*+O}R44>FEYxEQCK`;}E&Uj5MCZ18 z#|en4pf(Bb4yl!|3;%^xUv4du4!M@6-Jg_jBBi&5x?)Qd*!ew@umuy}{gz98m^pow z_K_21C)%o?ovXv!MOXBA^unXsjjHFRQ^VepIx#OeG0#C(qoREvYW8*mwqT<0^fl?! zwWp+O(Q{7BNe@vQmU%)Chb%oNu4oT`v+dTHT^;A73u&Uc2BiYew5ozE24kz+<4pVCk+|T@7 zlJ(eviTumPvQC;qJY%#nde$1QuDQ08Ik~o>*n){nQciBVVhBmyc7qcGYNe`e>SVFK zvm6vmVAX3AbGiGTfu!YXjo800UTtptob}8bt;g|>U(N5*tMfa^hV^&;PwbOvH%L8J zaGITa`&D=zm^e1#r?jlj77|vf)x#=%u==4@E^9Ejs)Fw*R^dAF2;jqDwc5**EbrSC zJ+@$CS|20%V$~&N%88r2jG+Tk)N-jC*yQ}K0)bWdF7WZ(kAG3;*-~~ivWbE%n5buL zET{chKoX+0GD1dnQ@3BRge~!7B;4X~U%~a`r!;%?RAbwj!H`$ognNaF!&eHWlBPGv zy@ii>8A{7OYGU6a*6(yj;a*`Cj#2qer*8r3>@fPW5p+(3oSfqN{*B+h@pD7Snh3^6%ou>z? zmfiiJeD-QR_8Hg*i#4DBsir#Hxiyr=e4*Ha3G9#gmuSuhs)y?NfWg&M!30*}yUuq< zyVq9Fm$iTbix}Z~U}Ct*KIyoz5j}eAHLrQ2?>UO;n4vIT^3?ZwGf?u;*-%5b&vKQl z45|IpO0>znQeo`k@l~Z_B~l*?8J&uSF$xpd=kplu@Ka^+jCj~(w^Jan3XiD#?V2UV z>V`GFz|3!~9$PRWjvw;mH%iLuSlHL-z8+tRzvVpX(D(}UK(`VZ)BFt6yJbq%_!t9h?fA*uiZ(i*+)`Z)4Q&T!Zp;uyXRH#R;9dI+rJ#Bmc-1gB;D`m!+r+q}Y}{TY zyxvfV>T9N83nnrPLZ!%Ab~L`lH-1%f_H0y|)JuT}FM1145UX(O;U~8k)l@w8X2R;3 z#(Eri;fPF}K{{-5S1gBQLdET>9$PRm`c@T57gB|GYp%^wpN;CKT&a-(UB-C`1XkfX z@s^n0O;I_q#i@=kuf;_9E?&}k{`HVy*MIOb*0st|oZR^OVDvj-j*M0KF7Wg^4GEcHL`@E9Gss}w6DBW;gRMm- z!n_vGoxlIRNosvDr}dj_#DX99*od(Hu(bLQJ+@#1m(3%Yx$oGImND>PTsZ|3Sam<* z5n=tz=-c618ASnqm=YBQ^Nrdm*n$bM9+i$dgX`fi$XUNqzv9L>vTeXulGWWxub=gs zJYP^sW>2uvkE>ur9UqpE-~B7{GFG~IK<6LLVE_8_f&~+Y)AEFkK=6F3Jy}~OA|HShpOMM`swgj<@?g$o4d@r$}mCrsVNk_CYG8_2A zdAC5g`FfNhj=hF0E**|2Ec}_IdFp_s}L=PWXSyfX&}^Y@(f$@}sL4?GWFHL`T`^7oPE? zcVdGG`Cy|Tq12_1wif619BRXfyG_!;YWQcvoQzNfTQGskR)xGG=>HbACsP=4kh7In4l?{@GCCRY%#2ESSJ|fzQ?Y z@V9*v8nDQMmI8rQe_HT6T{)OMoS~IbPL~RYmW^aRUdRfzU?Q`#J9V;~MBc8{>LK}M zK%a;xRzb~@F@aS%n`_h2CkBzF(OMakE+&9W>`hj`_@_Q?vlIPhyOB)FveCPwyU^!r zSCC)SMlXf9(aetviDkG}hRMbOV88M>yV@vR!4^!E)7PX?=Cg^lrB=qa&qDx=_ORs1 zb^?J__zL+dazPTj@4k*ToYG6d7EJj3aHk(Gr;@bxS{YfTBcSbq>Fiy$y+B}mQTdozi8?L1L9$y{($_4rq85*DkivVF^n1J7P@inRtG$LbFJtYuws4nK zgUqWp^q9aZu@ClY)fVRa+rznWJrr!gM6tC4{dF^kG=Hd-vDYphPBtrI&AeI|Fo9LL z|MSx?euRMjQ7x$2)Jws>1pALUGx$D(j^~KMTJsFO-pY`E&>NzL8Y$R2_03btSZ-x0n~=3r|8FE@CavCe=AtP-zk#*a2oA)-2LYh1?PLi^wKVglb0 z{w?hJ-5|KM0yMtfOCYeSac4&w^5zI}&DCmtV|OQbwa*mNYYbJe1ryk#a$>|lNUNC7 znhe(|R|{&=2e)UFCO5716GEKnybT-4*9dFD2lMsXck%H1^dolc{cXX5iDx=zy6Xu4 zK5J|3nfp1!z?kGuteHi=KwuTVTAj{F$$+MP8n6)SUwXk)JA2Wk3j&F8SJ7+pb2;79 zVE&D0R#y3w;LjH#Vt#)Q3RMP^ejZvsANV{O%BPQFhD{?B>~XLP_f0T?Jb(A@;$PJ)@v{-O23Ms#SV?N0*s;)1z}clH1KJ z^mFfc(D0lIBv00I;jt0vFt%ERyb}AzZ#b#`s47h_Dd1%^>}C$Z0X^VvudA}ljxv&V^)K0C z|3|hN{D@@UE=LdW-#Dt}s{oPE7Ih8S{aRQUS+4Y41|h5F38w| z3B2CMzf$@2AzQgV0XoEN69}xrcbY%JRj*m=oHz(sdR%x0n85QA{%zS-Uzk_-7&yN4 zwm@K&_ynsT=xgY-YCJ5>{z~u@#H(3&)ro(VWcX~u;r`>{(U;o6*n-!M@IUEv?cKoe z;St}LQRXbP5=`JVB!2eih^j2bZ!A2snj{cdgC45(8zHaH?<6EUr2Pwh7xknt|~ehITlpY>JfuABq>nYY>U(6Bml96UF&lCcF7 zct1Vgle*H3!IiO4WD=2!39JfhFo1Mv;Xq^GYGn)?v4C9&9|^@T4oFEm3CY>X*I4!( zkRSgzK;{M6(km{jWPG(eTl;$kyPP!&YF4Z(VGAb2w`;zpHfMeLE+kzM6$q@tb>eSx z%zV#YPVNWJo~uaI^_L`V8~-Mwd{Ay@xIi9FuSnM(UMZX2yhajyEotEstsdjUb6EE6 zp>XZ#UTsA+k*Vq{Br$xiF9&?4O!oxf5tMC=Ufe9Y4NAEhe})}j|Xt|9M+9+jtWt4{Ne=8#E2OJ#huI$iz5o^WfV8BE!; zOsEGY+8wPzbIi_>%C6csk#6O+hx?zZLhz321pgOS;X3gsynHgO=)9i=ulqn35B8*q zf2WWmHFD&-uU+V=ZClCgPD^Bbwfr36ApW*t{X6XC@*_e$F!4_B!sjJB$lbIh{HoT! zisRomb(=`HqI3gQqZ<_l8b)sz=ZE?o2A}$dM}y@Srw{Q^?Wdi)4JY ze6REB6j*22z~&~s66%48^r5w=Puvosj$6#HYW}_?XmKZx#W!0a5Lkul#NRP-&xDr7 zzHET0nV~n&CwTJMM;f($s)0 zm{3o6)24eB(rE2MP6U~afzp1hnbSL+KwuTVBYZR%kO8KBJMs4|O%2$Bi9I(w>FDzD zWWL)XUPd3k(GVOxgL)2pmn~_?oZdln#U1NqbmH>m_<@!G!7Dnv`~4Mk1SPW%#M7ket4hjaZ}# z1Xkhrnnz172g7&2Q>>x)Bt5oZ0?&7N440Pxmufv=f$KX8b0|cL zod-baj(hCY@Ad+LRd|NPS2HfeLMQ%xk)>Oc_1J<5aUR7Yd%`zQGdT2A)nf}L>Lym9 zul!GwNI&hWx=!r|v;J0qUG-xG0;}-sm1hS}w1qj(YrvN@e_@u23DYT7^wrV36}kCGn}19_ zp8$)z9x$hctpx(BaLeasM=yGlqRBlUl4hN>GRivyg3%g(I2+(95Lks*+v(58QXluK*RSf*n*D_DYoEI6pwa%2Sj)pgy_@Qi&{qo0;`(# z^`;-?{yi72#5iJ{W(BY+snKJrC<1&|;%G82w&Gu>})2 z4(2<_!y>@&-Vrt*a}o%wYH`4pI>r`|)(_9}GDwFuFv`&dygrSg*n$Z$rf%fc6t+EW z2)=zY2_~=#&oXqnc0o;{-kF9lu9lTB|G)%};rZ8x#`wX~!9lRkToDMY!m}lw<(#yZ zy>B)G`Z`RN-i=?b8cnJjX4py_0mSWJj-MYuTuTv<0ku#7J1#cUmyEU;@u~ z`HJG{1U6@2I_#tqBurq{IhzG!L0M&LbVw_snrS4P$a4Tn%EDl5!33VS^Hnk*NA}5c zEKJ?mMZyGD;hY0MrFokf3+Fk2))!-gu>})i4q#LFWCP9Sd+7Ifl`w%-VlKnE&P2ni zG2@}$zF!iyU;^hH_!B%o+z>XK6DtY?0;_PIkVFEn7|zOkkCmNwwx4ZiPiUTU8oFu>}*@kMVIZ zWdv;Qv4*|bx`to^s}9VnLyzyAN5U<%Y;AyHI7oxGvGw)qQ*6Nm_G3ETgJ*+a+L8;* z<$XJX39RxMQj=~9-A2}pyU)uQmzD^hqi?e&)vHo$!36eW{0R=}3!%l|S(C7(xtPGJ zxS$$Tm&(ZO*IEwQD>NFsl8j;fy-x&NFoFFT-+^u35e}TUhnbH8gE4_sexL2>A>$il zPA9F5y@3(1+`Jm}x}8I?1ryjS@!acU6@HBN2D6Q}5+<-}=}t>}GyetApVD@JUVj=0 z<16{Y+I%C5Etn8JoI@)g=-OKkrEQMqVgjo^4Kk*gtG|#QVOkkAd$_}u8m*wgfOQ00 zFo82K{B4eDRUz881MDtWE*KM7Rgzsow(T~i^rhx~!gv;^glBQqh3g5nU_#8|q$T`e zao&BPK~q}^6IgY6?>+L0@6B-9qLm>Hf6u~a^#jw0S-IGP37ntP=}y!f1$CCpVuf4n z^f<;H_M|TTP%fVAto&ZaaVd|;E~G;5@{3tFi!Xu&6F8W`$N$v@4cXY_Lq0oBf zQ8s;^wI2T$R^hmm&$bJb;Ln;9EbG8q8Cx*1uSYHVP2NDp_SMSB-X9N!q!(<*SxY@8 zunNbeI$e`${lIYa0~-X7Wo*HO(*P%$b|#1XEiL9{G+)^rTuzmTa{=Y_n7}F=m+Ewf z`*eYfiivmC@dl1XkflmcM)!^i6I()(570!E9%U{%%?fxs#;LN?2-4k!4z1_S7FVP1=g^?mr>{Z>Y_;tZ_} zlOE=9BCrQUw>TsaSS7BpJZ)hDKF|x?q{+gH2`1_UKO~i(o73I#S{Vgj9@Fh@XD4>caO(c%|k}O zjdOn`Y{3MMiTD?e_GPko{uXxYCsl2$N=78nL~9}mxF>=OvA!mC;Q?6$=# z4dKnk!}%&#By7P1UM1D(a*kv`dT>j2^5bM7bBFVLIGdo;J$;=4H#@XsZH7k*c?L{m z4E--JTKDd6-mX4R&VZc|&h}MkBM?}H{V^YF2WG&IhMicS*Cv9e#spqb*6Gq!zK=7d zE3+T169}xrUYoyb`6V4@U5jQ}E^acmU;;-`{9YBOLO;VI7GW|)Ag~I1ZN9s$=Lo3$ zU=2I(Qb)!XOyDSrXQNDpz`#x?Sk)?70)bW7Yx7-`rv|}shl}jqSa%s)FoB~e?&0Kq z;F9`*)m=VaAg~I1ZT?n#+rIF+_Yan}z+J``OyDSruPBc10&@;jf*N&a2n1GPug%|& zdL9WEzt}_4u9`BoU;;-`JWjmR0v2Aa4M#k)1OltD*XHx66Dn9P@rHmzCmCBXfukty z8It^9^2i_<_jJ5KU={Y-{O;E9ftc}nFu7b=#uiNAD2kt2ce*-UdeRO~EE^&aScSbd z?;lB3;cd+hpj0d`V+$s56vboFHYPBDj|RIBcNGY%!d{#2jSBz6F8TI>`JSI8Y{3MM zyLeneFSFaegJAde7BVKV3VUsy7rlO&?cv11-Lb;l853d*cQy1l``mm8jQW}#g#Qbx z@I0D(pUC6vTgVVt+u4|43ns+%S_|(rEInfcD1%xFE7e$q*PwYHOc=x0id@;c#rF-@ z`+Cu)mMzJw5!2;XfjsxR$)41#JzXB1?@84?_Qd)}I`@eSZ)bphX|^Hlw90yHbEVe) zwxo+o2l-M9SGqN^0Z|J($oA#jspkciqh^0AISZ}&xtJ3xlzUYLGIFo_M8YmnFdY=Oy!g{!x_2kL64P~9}W$SlgEs9ql=PK z8*B`UkaN@AXx5#NN7n{NaAHE=bg(;om2NjqV5NiHD7CiF-Io+0XN0-YHMX;IZBIwY zxNM%Y^h}2-{#J&7If=qmVPaFT8)YVIaxc8l$~a}04kxW#3@=-X^}s4zCw{_T?ie^8 z?r5miy@^ndLr=Zvn)s8sd6DUI<`i#UzY2%{9@5Uh_HQ^2Lcf_C&i#C4z!pqM!|TyU zH&5oiy{?_uYxi(0h4iHBc=&7jj;^aPO(3x9 zK}vl(dE+$y`P;M;l%Jj+4?$_`>6iYF25iAZeZE61JUO+&%GFvK#Z$(D<7+dveY|Qo z+SZ5uSy7bxrF4PNKkynW|H|=_Ojz^HiA~7uE3Cfa9))`vKO>%>^Aa<)0W&@mDG*p? zf!1z$_I%+OD9&oa_Lhblumux%U6rpvmZrhF-HC>@Q3Dw62e=1|Z8v-hKeKlAS%X}y zz0h_sfqOF_)xKmvhTAEF(=k7Rz$(#~G?|qFkJj6Et9Sr$~+zleGn74RdQd_WgM(La>>xzqPIX`74DBblR9G@+^W6TV6|nE z0b4ME`wgEvdu2ei*PU7Fgq8-pl8@V~_yilZ$becMjv4xHZ7z%-cm(i0g%?#MruM6Aa-AX6}|L?}$b!gv5gGl6$>73Z|E)Ckfk7D|qd;_*%0=F^#rnf}~ z1iOT?H@}Yw1Xk?{t4+5q8%$=FY2P@U{%JH^3kzrS+B-6A!31vkJnz#!gP)J$$wvJt zClG>aaZNfcFo8S>N#JE17|VBMy4GWz|N1c5M?d~4$o&u9LZDiX{dKvI86ucLs`ZafI(|$TRd)XJ{1b6r=O88wx(oH9_W$H!>699_)n6?SCUf(a?EI*ng6 zgT&SA$%#49$*?SK4?8=em2g$~N{+2@qc^9oApX9yIMKCuC|nLZ!1fg!G++xRejKYw zbvve$wfn|#V(0Y1kWhAlwK{WAAh7CXoD01;a0QupS^JVeXQzSC_V*dq-Nc4r3np;= z_z7m)`@uB+{lT>_Jp=-)YUbCVuO2NXb|FJ}85g4ZfJL*HY*$2AhAo&7?^VY|ZDC|3 z8+dymRUoixQeZV&F*ciQDC@<`xScZ++72(_mtpv9W{ABq#cZEZtIGblcjyDya zIld~~Lis71dxk@Ygyl?cY9*9`34Gu9UIVKXxRAV*)e5&12&}?AO{d%5E)~W+na?`Z zIA*{WOyK9Q)1{qFh5cW$*^PyV1OltD$Kd-*YL9}voio|WY*p|Kn81C7x5W3OprSIB zIm!M4fmPU7>U66+wS-yTZg6CR9Sbq7L?f%+BNY$Mm0PCS)7q~ukVBWUvR-k*YI#8vun{ZY5N}^|5(w61#km5~R zocR4{6G&U=2_M|XGi<>GE}MT@F4h;mCi%kDIx_?UtLOs@Dqp%x+W*r0Zp~0PSZ3!7 za|fJc*n$bM9$Q*fh6LxPU>A@t5LlIMUXJcMeS&l-)M}o)vNj}*^n=x}cCdGo?5We2 zbtLU%Klz7hO9S1vlEeA&a>su5bd~ubvNK>5C(g8~1ta4c!oE{G8Ma`;d1WP9`QvV~ z@1Ew{2QIA%y{#L;&E+D2RrtyBwW1+SAbPJmw7#*BO>b9)mQ7hnu3wFjou*f&mF?G% z3wg2f;hzrFZO~>Co|wYRaJcUSw|3Ttu;z;xwqOF+kFWeZ)Wh}ySE&0bL+~Zoe=KB< z)aUzLGJ94uFJsvQJviNSg?604mKi%7sMq3!q{EZmGWKKqyd_TT;lz)RVi}mQF>|06 zu$VO1)93#$qcSJv|DUVEDtt$@cQZnv(J&h*UnN_(SNJZ7SM_E=XSlk{9CTBf3pK~Y z^Ci{k^uIeu_9^Ww??0Kn;oGiHY|vUyfxs#}O7IcqY%DB}c*9co7csF#AYMDs{QWyg z0so*pU-P<~09MoQuw8%i1>z-A;n9wNU#;#ysCoJtdtenQjB1z=cbMkYNP?n0r&!{u z`T~JfI5Ob*EB-$9eQy_lypaa%ukbEk?7MgbAVom`bVnFuXm7w4OyK>({3M;SC`kKR z4nA3o5eTdj_wT-MD1k#uXZUt*snBb2f5cCd=Y0xVLf27zEp>66&_6H{ZdsN3UCSk= zVN>|?=+(O;6#l6ImLV+!0;}-T^_bjoz3iCri;Oy=-1-njLK zDJy=msPE4Vn7}IhH2M2c^?hJ$q$e!M-7ky=*eBvT@l!Ud1VL>J59raGpDc$w878pT z<|hJ`wt#sHt3%IcaRPx=xK2Eav#KpTvUG&(2lfnGFoD|w-yUn!jbGofQMNU;^(d z=kZng0GQeAE^AyPR3NYl_b#4wo;jKIemV-aE}LNR_S;7ujj*A6k1mk$_h0baBRozV zmd0}CroxtIKEiipFkuw8hs1kX(>6`DQ?swWAJ5|Yq(Zv}br`l_0+-ECBe}SdO+Pmj zme;N;5Ljh7ERS4P`6>UCv<%CTF7FIy_?OH(ceDlkEi(L7GyGK|zJg_J!^W0%g41Iw z3ST&CxMLHscxFQVcW27@pZKn@oOaAAnrEGZtb{T!v2o22a-^FPZS0fDugd>Q2-}n0 z32yW+FA!LTzkSDdl7E}bHg@d*-=CHPY{5kMiPPkeNg2_tOyy-v9=D!(pX&%U|CTYV z%DsG^_$C-pJxa{5toEMe+|~VWEBhqw4lO41PyK{xZJj z0F{na5BC?4f=O@4&L$%`QF!bQ3q2hM-+f*)Y{7)zm^UQ+X$iSAN}J={Qg5&kbK1hy zYDEHpRXfNVl4?{)_V*pmiJ}o-nBULlP~Q0w!>an{zL7&sUy{%X<7JUpT3`n5mcbD2 z{G4G6CZfuj(EO}>WGjs2W%xw?X1`~*gxASe1p=#@jQdIa;2QC$G>j8VO)7)Y#6~b? z`7MSmn85KPf7jBv68O)NVCcGY0)bUxoVci)0?LlM5a-$5fJZVsCgL4?Jj)p)K~!FS z_|r1gfGwDacC(6DN2W&G~azB*;(Tf@J^bB0D-oKgeAZ?uKy1&Ic1!32K0iof++ z&KY7Bw}Ej@(ggym#H;FZrw%lk)eN>pq#3XU6XK4>F)w^!anoQpnLSw`unONp{w~?g zX>36BDCj;d#elsfUM0c4i|-3BTFFeij)37KGX=kk3B2CIcUipN%AU0x4y#S33ItY( zK5=-LBh2LbP&l0wZNL^xi0{N5+I5))FB=FAM@|`Q6H~&)A)k zcqkhYW552r^Gut7S zFvho&bI5|S~ zXE~{zA*|MEhOZqH{ih!zCC^OhlQY_Au@ zUSQaQ35)ctr0p7WTH&`=#);gv?9;mbu&noafxs%U=9h*Cu_bTgA!X?`;k$j9z+V~U zYuk6dVSY|S7;{d{0bsw|@`xGj684P5#cL~A%a+sv_iIhz=;`?kTQGtByiV72OAY9^ zNDtYw76=4ZEvWW~q;>y5t~hHsELzY18r`B5~5GTcpv=zN$mOfe$mz`Y`EWWXy`dY@Nk&GGXVaj@uH=y z&*CIlK4g?YU={YqJhwDuFSBnr7^WNy6FeLy@My;~cOQ;3k39q6gm+tkz$)B&`0mg4 z`Rw?F1gP+=xzI{5fk!*Og7x?XD~{<4!z#5B2&}@rfX~&Q6*JR!y}|p3Ug#f~z+)GG z^WMP}PX6i&u18gYz$)CE`I)kp%%Qe@7x-xxDD-no;JAzLbQ)9{W<<9G%N0QafmPTm z@plvx9KrKZ7`*lO7rYN9aNNaDP-gBRZBfCnrlCM!751(?i{t7A5Btb4uBx}-MKOWn zQte#;fA~A25$sB+D-c+PGsQfHyV3+|J<`MFz$E?vse>i!M&p8w9 zY4V$OyG6)lZ(~QHdt?*iBM~B*+~{RicU4 zc9RqPwQTM7bz2Q#^**w<1u=lXdH(RqX>zmY2NLBzLKeTuZf&~NFh^g^HhA>|Y{A5s z-DgOu*=ypRrOl)MPCaXAbmbF^{@WLP$C}gphdJbm|3KNcKt}_6FhWWO%5$3YlL~p6 zp2kU>IJES;;g-!uR%vfvz!pr5_Wnft{$3{`|sNfmLg~E$FM9-Q=8u_ROP(oMW|z_`*;V7rp?u#`PIvZ8t_f@6RDr-MTqk}eV8LECCBYqf_VEL}c5}dB zLH#-ra$OlDJ;)+^^lCxfnOWxRL0YWOhs1IvIIfxs&9?zX6!Z}?N=J?pK;0=8h{ zeBuR?eDNi*@YC+q{vE_%ysVgcUKI(f5=YJ_9+#S{qwV=uQxnxt>KpBss zAH0p}vZrUsn=9Hln6uNBP4G5_DvP@VwqOE}s5}FC*N4SEHiF|_qXYu0`fUF~yh3i0 zTrbUEo$ley-uE_w3Rj~5TQGq~RGrSF%1Cy_#|}%J8cC5t8hE!Ez#h?j(jkJNt>epTQDKstBCEc?0A6=)-CEG5LkuVF(1{Ylo)bv z7qh}IF@PkJ>ylar_c{ z?BfH!s<;Z{IVNx{!QV3N@R(io^o4l7`yT%nR^ise_nv?2&4$l%0Mlcwg?5Dr9Cz{e z)z0@~Htii?W~ESpz$)Cvbh=A5KN-B=l!q=ey9#{}6F5@idmz93Fih-K0h}B~0;_O; z)ak6(yBpRX{=r6m?;}LWm=I&Q%%ppUh;_eM!LMk6z$zRq^DilXY^AdO&y?#Uidc?f zMr-r6_#0h^$^JQJ^k&r^5Y>j7H}@gRo?)_Qk~OtXjwc)XX~d-oSqe`8sRNd{LV)ECvaEV3iF}(P%piKL z-%B2BEg`;sTFfw^c3tIdw2_)~CJHix_LAeCA4tHq5%TvMi^!tJpUK5Zsq%*wi%9XM zFQmMgwjMQnz2uZ&Mm18l*>_JdDJWYA3#RzV|lewsQf(Yu6*64uW2Y#jNh)9qCI%FK zC6V=xlL;{z@gveliFtoVf9qw2KwwqD#V;hWF_7@78nNv*F#SBxTgOp19w(1wZ$PfsuI(yHA zCY|0&R{Ye6!`GWB88>F?&!5f!Y{5i_2jyt|q*Y}5d5y5PU8yu|{#xHJI0di;6Ia(( zpuZ|+6N`}=VF+xYEIAvkFDcFt2&`KEr#!v5WDeQ5R3nC72~)ZhyXbvxWdOEdBIJBU z8oEA}R2!rbG2RQ5ZM%l)_dH7lY{5ia+e#ER#gQ*T8ZqpC8)cyD9ohIhE_Am8^tPh6$`PZ&``v_nSZho@ixwIC`rA zHno+mXC=TEOnf|SNxcfQNx*W=?^+)TRDZq?QLfFcD-c+9IMa$oSIQz+-fCr-GzwJ% zQVNx#Hbo4N0B>E((M@}|kt1#Y?`ZICT&TLGRi3i1*I$Om2}~?0El+2UUQR5lYgg52 zvAdd7ajs&N))X*-Rrm^dR;@-SwOX$YN|nx~3|lZ!!K5O!ah^*O{%ZAT{-T?@xoDcQ z>(VEIz$&rkddDVe&6b~(+)8GE$3c9B86|&6>JUnd*J||`*rul1==oj6s$Ctx7EHKp z`%W_NoFJDIwU$^gwy|o~+)i~ra)V(4tGZSGK@!9Ah&j{BI6AzZ`gWGH8hqs@!xl_r zd3+>O4qhNVziPxmPjfY(zNy+_tq))eCdSlzPJXqzP4@oPu4+eBSG7%^pK3nWz%YST z`$jz{F{f{nvu!lu_<=&@;*3buv(rh2XC`>Y;@jc^nLXeoX}e$ZqH~{=C~;-&)Bq)q zVGAb2$np#Osth~TNF@i%025e+uaK_~x_na_nFXkyea!${Ffps|6EeHjT~eWsR*#VG z_Uf@F!74xaonZp2#G2p#wM<#dpGUo%V89Uz&UpU0ex97_|AN@{(Z<2ETN|lk?GyA~ zmm33)kg)HXEVAPkq_fM;TE+Kp?OR z_Z$9>yK^1&<->VOuWJE-Etp6&;Spfn?Zh-wE2BK~R!_PwR*p>b69}xrEuX(JGN_u` zW#vU>>q38_pJO8S**CJQ`U&FnM=PWBzOx#(;--=rSWh6Z3b$kag`*kf>b!gt^%-yN z*n$cF>}TX^>@6~Loz@Z~`dX@OEXu16T|5K=t8iQ3YZE6QD|H-QR69FQpC8%ADb)tu)MpLuCyeKq!0`j$d*1u4lD_JWei#!Y0Ib68m}i}9 zK2j_L8!PAd+61;>0!L0f_Br`XnLAce4h@YF2&}^Gn6JLJxS~j#Cn!VgV*y(*fn!FU zZa2B56gp)oSC97*2&}^GnD0WGbWBMM*`hSw))%k^6F6Gt5x`PVHow}fNJY^CfmOI2 z^Y!!YtCj5i`HI)C-heHb!1DyYL+H+WrL_Mw<(5Y;fxs%6p?~DUqwFbDy*o z2&}@hSN+I+Cxuw<3#8wX#zyqDql^Uo(6 zT9d!&n|tX0#-jOA1Js<27lMp}?lT-sV&dI18ya60(rEfkE%qrL*>0fK?^3_P@G-RZWs*k+6F2 zr3C+O+thkorr>=rQF^fw{oOCMLHB==jN>g2rB|#HShPkYunPN1zDM{{gmNxnO3np7 zgM7%n=*Lz*(k}jAm{3xz>Br_i($P9v%{#vHQra$e&$Z`Qg)NxCSIBq#4yQ_o9xsD7 znGY2RtlGZXnoi_pOs}Gq5zX(_pW(JapZLAP7EIuKsMEb%$T#qm%n5ARr?)_0RZvwM zYQ)Q!ctwjWNAPDp=3Vn(Xa3Bw1rzxB^EY392B^Vzta71JV}Zb`h0AT|#1=kMK)6;$ z7;lMvJf;Qr;4KkbFo9br_Y5~ie!W}9Fo9Kh#WwU~Yac0v&-D1a0K7+~>g;n< z1Mf3z!36GI{DkrGIyLXEgQ0d4@m}G(VD`2WePSOkmFII&{yxssQssVdYv{N47Ta%h zmE;e7E=}eu3fic@Zoo?^m`BAy-)<7etS3^;KJ5vf^eR&FAJm49HCzB&Fp zrM3U!_6o+9>dI9X5dYHy-1$6X-klSYfzLBu`;-x{J^9kxXTxNX(7U;*UO(!=qIwq? zwqPR3_63P)cUyYdO1r9(HWk&i8O`BS$U%X?Dtxv4T+UUY>MHj~%%xTlGfON-U+vo} z`GyXY>+yWz2jU!9*)Q^GXiOlPvztyc#d8udZ%U z0~)NlDG*qN@0(6HJD|DxH2WYkTTl^j&G8kMe^r4N6fcsfl~#{NJkn29Rxq!Kx_~X1 z_{?YYA%)pe?SC_RIlQy_vg#^!r(G$-1Xfw_nXs4hTxr0+nQ-OuQRAQv5}kKyf^_>|p5b>DZ`H)EHmiS50&KxV1D@aQm>e(tm*1^hy_XtmXvCUV{mw9f zRluyN(cX9|_g}uGcv5fm;L0C{sV}}UY{A4V*@m8I-CeqTQtP!VLlm`}*HDB1rn-PF zn85v??+VL^QLjbyG~}%*W0=4y-2eGWA-5xx(>0q?&pdvz!yzkbyC_zw-z!^k0_qP0yAJe4A(p;rU%mU{xN^a!xqC zRa*Hk%jxJKD@DO;4BH=O0JdPF%{?7;Yq(#U>7&in3_E8lbNah5?~f^fEtnX|Gs4OB zj!XY#gy+rJ>j+4Dzp>M5J1y)#r^pCJ%fRlu|7 zCFVDz|FY+#sk73joH48Ni07%11rwR;?~uUG_av9eTAqPS@2prj3}hxNM*y~9Lg#y) zEVFzr{kPI!l2%z+X>Y@(=VS;3Rt@GW4KbUZO8>1iyqRF49K7wwD*Vm>!Giu5LXMJo zhu%onW@_>7>TFkKWA!2I!Htn1SP(IpukSqe{2*!TJN!F}ZXfi)uiV)ko~OnHRt5aG z#v=W<#u6TOQO_d0**f1bfGwB^bXY_zDt?xl&eYb5+HO9rpZ3FvojEBISf#BY*~~1J zv^6B1&bLUfgzahwKP&t5Q}`B#y8HB$NgVO#yp2)zRE!M zT`ZP@Dy7IG(d^%Uya|0A#p$16JBr!$Y0#XM44VdV+ff{8$$_bG|JC1w1}`|OLgRI6E*2k77-5LksH z1HN}~mbrTMya}vW9(KSNYXXNEQF(uXaClR=w8VWHF8F2?SQ*v7MjfIk=jd zwe}(lpD#u-m>9P-M!Fd;_Bg*zLm z-S74>INxY25LksrYM%Gm8=$64h&9Z;6d=qEFo7d-o`E#0sdQialv(Qf0q%FWpW}X` z(;buRC~qeGWLA}X3H<{T*st(iFBLMB?&IFGVA?||8L!qWwI}kgRPtPg@L$M_;#SGuX&A6t*?l3OrIhvt!GZ{3WYDeK2}0xK;9<C`jDcf7=cc;*fhScO|9KLf(#k>cslnC6OxG$W24xpniZYn7|Pr&(L3fuUH(gH9T%15?F<2`ux43 zfm8{MyH4#s4H0Jan7}bA-``$mm|B#aWN6yzkPso`s5mCno?c7zll6a|3n?1XkgAo=2}%$?ED4Ck=;mJB4~+;(ci~S~Gfp6w|d6 zFXMLhP_@Qk3)Vhomq1_@o|o`_$n%D(wOnCTKqektLr3;HY^D>5<7_83T z+JfzPw_6~v3eT4KN<)jzYTe)xw!CZ|>v-0J7WnOwI`8i<4vhfl-8^4`l3nuV9k)P)FDaXYs$ z61$UO3nuW)lD{i6xSuMW*upAR7YVFNTyIYu*UXWE=V>Q2?Tk!NPn?>`jOD!yTQDKs zD^_EGx_MO=D`~e!Ah7C;T~#`v!&J$vT@)`Plnhow;#;yCcXu;v!32Ib{Hm-1)pjPH z&~M^MhUfY?2Vi^9h!)JFlG$wSqZFwy$cGcwP%KDqaGr^0?w(kyw}-WXlChH*wqQccMl~B~q25~?1v}FBNZ5jjD(kNko4-$` z#{YJ`%(efkeBam^s@$2%Fo9J#qo>pLY|fbPvij=LF16q{KX+_hzig?-*Isg4aG?86 zE|lzk^p>aGt41I9n47nDol?WYe&E3ZIt>Rh~s6@^QYKpQ(v|JYrfBte$CdeX@);Wt-+fz|BxziP}FTs6&D&Ie|E_}VTeM&!GMp)gB zaK*@=%*lJoe)+r5_3dM&e&agHL(e+Xj%^1?Cd)g^PYlkqX6Hdt@C?l->dJ<(GS^Q^ z$KgF-Unw6m+76eN{pu_)tmaJT-|i#vaX=1w#($rVk=6%lp4x1a8B2(Ns;sXU2iTuu z0{dY8UCaAbS-*Pklmj_^0b4ME%hu_7oLFc0Sk72&u%eehV3p_@9-3wvK4n!?XQ<(T zEttSQhOd|exf)`9%d1;ocL$FWTRP8WqqIYclii9fY2@q!($M<@WU-&$+3U#9nW(8& zp4J@R+Et-f53H2*c`N+<&p<0GtT$n2fc z1|#jApUv^J*spH2Rip7u1p=$^74rQh8={#*e<$^?Sqs1xOjPY#k(S5@rBltdnBh!J zdGO5hQvVc`6Z!}46UJ86X;}GL(uN~i|7g{~1ghJ+s}+*$0b4N9b6*XbXf|0YrrkNQ z_(>n=wbGG3z424Q1Xk6);6&H=PnRr?b>+m8%y4+I{hpHWxq#s|ira31hci9#c7(L% zRu@jJTpSHy)AN;^!J8SjVB&*2Z@V_B(t-OL;nvfRIo_~QAH3`Un82#rM{CfWhm)kx zy;?oCPKbgouMR1Z$1e&!>UfX~^$(4eyyl3#i)WqtkA{%hla;gmDp35t_^QO3pU-(? zAV;j#npZmlCa_Aps`j7z!Yuc8B*OHYf-RW9cbc!5c)O|(x6D+Q)|LSiScUHh&rGy3 z2YtGo`fR?NaIY}&+s2s=zdJ%I1nur-%(H`Tt9~mE%{>JItMHTMdvVN+6z3mzl(zT# z1GZqIyqgQ1FU3msgEa3m%fbz6eYvMhtK%sUScN^GPS<_VME2`=nNr-nJ77=N?~)VU z-+P3VG^2}*y#!y?5AtR6$Jwci`bG#o5fjn;v{1K26D9RdH-1%B{6_14P&4&`LG+?n zC3>GBdEMoJj*jY(+z7xHOpI(;l}^7tUy_QobMDBT@%nrIj_UOwk-#b(squ4bXEtF4 z!F5#HLJu<+IMAql3#DmSdds^d2YR)`Ldkz#Z~5D)YSe4uEXfkIet!1IVMFy3F6ynB zp@1!zco$KFCg)6&lp$LGn6`czTRYZWtyLB%5Lne`Sq-}F*Cc8G7L8c2=(awnz)DRW z)EV$dhOb>5)q0ODr*B=wP7Sl}1lWQJd>6QHPwHxL$giQA9&RfTScU7v_u@#hVK?{G z+0f?yW9+TNs_4Rh?~ODnV0Ra&C>FJ5X4wt4fMQ@`f~a(f9oU84osWvZ>^%#+8&vEr z>=x@?v!C-^=es!X?_B4u_l|O-{L0Xxqwd1p-x=3jDOU z7d5rOy~XI@F@6j!NMIS`yA4;a)#|k?Me~X@7YI~|sk?eo6RpmV+BEL0B;+~vR~SED zN+Nn`-R8N_5;eSqcpxG6YR$6W8}CGvrM;7V1p-x=PyF1E)(NKFw~Em!{Cq03AR+d( zch7#75?8%fyl(^x1gdaM#NXFGJg@cI@m1NesGU&Hv1Pz?GZ^NMyQIxH_El;2h`$$x z)&L1?iTJ9)r0rVmhNY;@A6Xz!CFaMEa(}eQ4JBxF)!Ga#NMMV~cb=ub(6%qFNt@iQ zBoL^=y2;lQe{Izo+ts8E_cs#iAQIS9^Bs*PGql9#_37d{H3b4y*kbUV{bh5t#cSNC z|JHIsxk3WR*L-f^b4^>*rxD#dl7Gbmtq-cOjPaJb`x-5>jT>!bRZkdSA%SCUzUth4 zwU&Cxjo$yKu0Ws)$69=R6@F0L6Yoxo7OKwBf`m8`PEJ1SB1PY- zY-};517TacO3yv27{`rpC&#XL;Y7v&FMiXwwURNu0YjDLnaai^tvV3Tg$0ROMl1Gc zp@UMiW&p!yA(1hsvhm@u_QW|vUyW*9$(pU(;-H*)?k^Ci8qGr(%o>tcOZ8ASb~&ug z=~`6r2_4PQg2eHxO2**awnVeheUvG)MLTlER*5|pDiEk@?^M}XtZW0apoQ)urgyAX z_m+*~cW)v?3lg|K$M47;H%RN$@009%AyObvRq0j*qeV|wV!6_f`!F?Zrv;tQR4SGo z!Eo+`voKu6aqQRmq4M>cb(9;7*P{JhNqnVl(s6!< z{*|P@`4_ph5}D$bB3G^5*n}1&Ftq%x_|l2&sOvm= z{rwgKfhtTlp67YK?8t|aN=)Y>3@u2AF+Web&^6&{@`!mQ1p-x=(tHIn$leriXphoA zHjJSK2~27JO`Kg2>oz}C@p*ntm=B@~=Z3s(H^wrjAB~k*oe8Ys**$XU4uj1%ZTx3JqHCA>#zN`OeB3`Tzl{|lCDTLC8i6v-5`NC?eG<^W{K>(kE5K@c)k!1 z47*6=RZU>!+t!lne|aI?!GnZ;TS@YeV?O##MEqWWck%3qje~r*TE0M_3U4Li<3zhS zc4S6rxyjsy8d{Lh?@dWAqWS1|J{b%%Psg%P$-ktc!5#vED)G)2N6%u;*^bL!HdJ?4z`LwpsJf=Rb%xD zXMLW!>U~ryznyDOwGzrXztRGs-x5=8PYI&m`nB4%wy{N6YqGVxexClXPq8e?#aUUy zPYgi|5^eeYVGlQzAo~4b27}e=Sk^nfit_U58G%5RSfjT0h-W3P+bAuoh6!~LRbu_P znj6PPmaL#iQB_Q6K?42pZ$?`tu-R2E%KQC71p-xAj`;|g->CC))K$4l?SF67`LEU@ zfvLdX>hoKBX7Xj3@BG#td={#(9P@I;Z&CW1drZD~_1~L;{;N4iU|HZf_$`LDsC!&? z+AI>N!jf+={8XaYuJdQ*%A1CmZ)*Clgd-uA63IH69geIcUpV^jy=4CpsKSzOFjQ<`&*Zg$jd3{++zYmJjitYk|DqM%< z-`Cb3!I~aCsciY~q;<@!W^BG=2AO>zK?j}?sbTP-Cg!}WJ}w!H?h*N>hk_Te1`0#%qId>8AkP=4aqK4o>n9Mf2c;oMOG(?LB@(8aNW9rTjy+qqRhhY^jtMPDU>fp06D1?qfaF z2fteafvRgZHpZwUN63iVx{oh)-Pw^&_35Kiv$U}#3LDG5&LN%-Q>8r(?2VTTT_)D= z;)OWzb?Uw@Y~1Pw^g-rE4MT#&Xnw=dwsRLrp?rNrR<%eCRz9!+-JG8;5U9cs8Vp|i zwwthORj9?vBPNFxjz%xXTjZqOLMe{lCDp#%Epm(BB^Ar>k}CW19GQJ_F8ASAz75k} zSD}u+)*4y_;$abEdgoknKX^7Ll6tmfuQyeqFW(mzh*(gK=XX?vEjvTHn(wIkFkNDf zH)_+OS>762kO-Jl*l1^egB A4UDWnNzrQYpo$j4}PuakAveG9Hg5yFys$y4RFbbK^~=UsW`& z+rN|SojXTpcd?b=?e47Mtj6CQrSzmdCTwew#`cl7?Qy}3j?Ysbet2d=3li7)T|!ZR zcaYxZyM)>-4rVp7UMZe~UJC@OuthZ(hF|Q-UVpZsR*6lu0DkL}-H6TP^3ZSzdmORd z-L$D2OLn)XJ$rQ5P=&PEM>XWW@-(~VX-x>$@k?DxQZAbi(-izdw1st%0{ z6A4sd2>II5RX^6VMQIw+B+%5t)7j`!{w6v1dbxzPODtE;Mqf7RO&R(lswqDj@PC&$ zB(SFOzSgQ2v-2oQTW%aJ5U9d3&A;Gj(3M?k=}ZqlE2*Ic39M=SeziB9*r6U}Xw7*R z0)Z---*Du7C6ke5U9eEZ!p{|)0=(NEa}9xEi|+sA(r->m;o%Q$U9}nGG~E6l~|+t z9q?k~!)wve(-MTX9b0WoH-55F32*kvz7`E0mLRlONQi0G{9`NjcTsuz?bTp`K$Vyu zZpZ!DKORNslKBHPv><^g!f!d|H%=XYVoRG%vJwbXVd>$k=hgbL3(J2hXFop{T2Umh z?c(1-x(;NcKYUTvxAGMTRAC#;%hmc2_AxkH`4X;}(1L{6?vA@Pl4a$eRaQN96bMw| zXppZCj?uK4KZnveZ9}y&>$23*4n-uB!(3_KEKPN%4$|CXGo@v(FR58mY^0RQ`poO? zCO6ivZxedu+#n&(vBya|Y;APz^q33|(eEBOFfBw2`|U@UeRE)Q79LTTU;9EnJ)Iz} z+?b|539^u`dd5j5`s`A(ivA|6ZcOAv{>84^mkr%%i;K1lEl7OYvq_!t(o$NRuFp9i zDJ8Yi#k$cFR!#zes-RNKRF~@pDIr3?f6#r}0BvN~QS|HOty)FDa&STuR^r^%j4+#p5Yo=kmxgHj~eIdB>DR2V}@Um z<22v3VKgNxP9RW~matdedwTd$@c zDj`{oSS1|}Sfg&cRa|QNQePLX9x_=AzY{@yW=}Ptv-ytG)*8-^u;~AE&=vZY-`=e#^o2ZlHW(r2PMV{1geUjyRMdfVK2Q-)_sf# z_^ibY97wYpC7aNK#KDX*D_`->#6xw8AD$cS+Ai5+beAO_$eNF?X~m22UD*S zR|Nu9({h)n0rTypx^?v%J-0vFq@9lMM;o6$uKmMLt%@3HA^FBlk}!mA=VYj(Usy>M z&+GT&jQcZPtG-|my_s?cvj-iL_(dR4g{6n@Te9D*&70bl=6?7plq)3e{LN9T7Jp4*o)@$ydpgpEZ}K#>ATfQ=OSQSh0}?z}|89G3 zzg+Fy><+Z|`zHc{s-}sr)PPs-Nw+wCt*^oSFIr2-R`h4iJq;~LV4dgZK#u*Y?f=x0 z-jYQERgEruS7*>?WYp|L?&JD1dzMpNqDI$i8d{LRmWZGDu2p8eKX}kT+Ge4J!*)<4 z*6pdvk|VvT;obrbEl6OCYA~$NFU{_^Zb8pqS||{x!qD>mBeOhf=`GXC^_C0qK;lOQ ze&bZ1cjQzx{k@vgXIpmbc1L=8L#jZa3R9i0Oym@1Tk5oVp zs`RDKqeKE#V*MDo?}ip|%Ad9f*(sEEB*bxh;P4|_N&9}ZcQcVd6}G{AuGTqSa}5cm zli#lvdK@Hhp1|t|Tcp)$8A4xgUL_Ey!rq6UG?lbm`(0}cZC-e^h884nUc&bQj9;bY zWRIfCvIv1d6^^g@2zf-JcDu|3+PABlh9hJgdE?lZpZNYbO7qH{KqHpa($Ip0=%d)> zjauf}vD7=?RUlA>V_*Ii-RX7Ou+5=#L-pz!T9Cjv@s*+?XSD^>htor4Y6}FaaO`U^ zOwGyG7H;4dr5tFbp#=%dP5xfZ|F!mhS3i19Y9kP+!m%&!2a}#@JvR=Z!5w7{El6Og z(U zPuypT?+`j}w>EyaJ4!~K*4Ok~+&-&4m0W3hr5X$^NZ|VfJ_4u|r6oj_rXyFhVe1B4 z8x1p#kgLg~gm0H{S16D9#%$B1`R|mmd-%N>aQ+p3MR$DXbv0_(bMn@pznh4+y=NMB z^^G#k5X{hm1b(&0&sHz~)>J_{Vt)LrTM0yc^{QZtp+f(Ac0?o z^6_rgN7KQpACz0mMFLgwJS$`KBaBo&Ka%@6vgoX-Put+*E5lLK zv+S43rtm=wElBJtb4;D#@PX_fr;o^L=B+oaef>d6KQvGvP=%+4@b5X>Z!od%ua)uE zgBV(nXf%G0`hD1U5+6Q+`}kfv%2e%S9#377KvjX$NdD<+%CGiLiTD*PoT`GvH=9(| z#rHR{sH(3jjCO{imoE{S8v+ z1>J{1zM}4!`AC_$aHv3_>eRes)$T4|HK?S={Kd}R+KWatY2I6jMgF>>x~zRk$frPd|C6phR6%+P`auD0F@@KJMtK-JQxscP2qza-?T?jypr zyJ>1{S-Q!?m!Sm-oRJv}T~1Xo#awcxNA7hO2voiFn5vfjZXwM)tFI0|yBKV0{jeb| z9P7={f`rm;s_I;^kktB(PQ<)Dp)Hxk-vV?i$8hA0BmEjrU#Vpi?vW@LeKs*%JE~cg zu1P(1*JapR;=CVQSH6m4IIi`1Ux%KbQd?+ukwD-4dmN9Wn(xFq^v%>d0)eW}PkyS- z?=O?2qx!y#lXtdhxzp;<&F&2tT96RqF*#wJriGQF8Bf{@`}8sF*ybAyHVH`@8B&}+ z`Or@24UjAi z)GEJ~K%fe1Gw+=rL}-hDRG~x4C_+6)0%xrJ+wcWVwKEILQr|i41p-xA=lSlPz1hah zX_i!;-d8B?*!o~iGZ^mYUolQRW1ux^_Y-Oq64=`D{^Luu>Gif>O0kOl1p-yrV(|N; z@{&xwYgkf4o4!KJfCRSM{2TXrr%Y}Y4Kzv$5(rdb%g4W#wZ3S&x5<)za_A$pa7bY9 zV=&Yy``6Up*@AvLArhz(bFj)|OYO%wOM3Q4fRG5Z8l6sH!7wQKR zI9}o3<2X0a0wqg&aJ)#MN^BXDKQ`2Qrd!fzCx4-yBO#7tuDX@d*37k|Hom;%fkpPMa>h`l*y0-%lV=h2Qz{7H--k)2}+c>FMr; zS#rcUwP{8nsX@(1N$4}?2B^!YT1w?NNAk8L`gj9VMn8A@o_I45$M>dw$Z^C%V7()vZY3qBcNwL4ks5|=D=T2ftk~Qguvd%L^AW$X7{N3AU zWVqppGNjp1h885m5n0dpTJmCxLbPF%MdMP>6zq!12&O_z!B#}T> z%J1u$hT;Y(Y?hw7_ufgyy3Wn1GjSDiutS-LnXd;~OKE=55{8z4clXXluJxum{nM_E z5Dz4VZ;Z(>*xE>K?#FN+#d}njJ(fFCoBllo0#z6%-bapzH7KV_I)e5$+4zDpsI8a53eCS&pll9J$H9p;^ofjO(kx~ zaF#Q6i;v4;OKDB3Sn26MTYSD(HAsz@iY1?al`?&*ytKhX#oBKu!_t5RmScXd+Q_c5 zRewjC6V#JoS;COullXVSb7JK}k8NlJ%Rs>g5*R05uDT}6!x#QkF7)p&5U9fVal&Pb z{C@EdMV;GUh&d9N(tHQR#ND!K!cC>;&tU?ADoh1_7Sg8;@-q2}a*_^VXhC8@x;yDw z?GNcuL(dNv_nXqQBmCrxO6?e0b}aBGc13=Z)z4$4m;5Z`kf}e(&MA7HvpoTF$fN*m z`psxI*216MZ1IEKKN}~-kKOF!UgsCtyf;QlmHo+#c0b9gMLO|gS3lX$zoypEH;$nN ziQwa#eYR!%CXw^>mf>NI%Ch6(``Qx6VFH0FF;rgLTx6$*XEX$6(gi;)aNf^o7c>+9{Z^$%$qu74S}edTny^YLwlYXLC8MQ`sZMItuiqqXMTCUy3V(y;$mEp)U0JIwv^#D1_{yg^wOR*$(1A`C4^ z;8~gmLs}I(rPG0?Y-gp$0)Z-Vh2?X`X8DC%745iv9E-Xfq@5JaLoX z6>qUcUfZLZ=ElF?LQ7hye(H?aAH+?aDB+X%SxD|Hle!7;{v_Tw03ldl=`N*d9SiAU)ex@c@S^rWy zK6?;D3ldn4`5b4^1Nr^g7uwynB7rKbkNkwDulMEhb)RW{KZ|GMB7rrH?|LbeEbnr! z*W^lUBCC90J!v0gA}=NbsF%#4^5CJWY*pGr4;5Mjf~Q;d$}8$_o^E`+%VYi_^HVcD z=12?$Rc)TS_0!&{*UhQB%{EzHeb>?RBqz{<#HHu^iB-dg>Qh^NufZ3qWVuV;%e3>H zKnoI|{#j4Lf=uc&p0oVzE6>62_iCpdDL|m=-oE3+uF@6t;X2(%Mv-K>Lclc-BPY;; zM78~gNb{Q8)U4V%5y|7>=he{TQ~?52bBbLiulMlx6TfxBjmP6d+|aZOoInc_c7<<| z%jXZM1M28IIi6Z4%Vk5KdYt71T99Zrg^}Y{OVtw*x({cYWVxi z>V%fMkHQMme2m{&SM-)wsIX15Tg? ziQR_}k@O1N$TxH99^pBtRmb&sTwXE^4Ih$cykuZ)=Ihj)`1PTdho0wXLE?Yq zEcrLjs}@a`Kh3l_tQH_pC59@F=OC^B@Q|K^XhGt^;0vUx_bGDtxtw*KdIJxKaRMz!h^6EOFNw!8w=_M?iE+Fv zEjT)njNxTz50A$c{yUS$qj}0xGNZ^+(!96s<7~-fIpo`e!@D?v79_C5@NX13L6$de zT9821=-rRWgF~yxYCqjal`_e4)Soklw{jn7K?2J(Uwd^+mOrkT;uXUQY+te66~`qV zERyBEf78>iaRMz!boa0&-zPpGJ9xjsdxIyFJ^OG1ElA+)$9#52lV#Va z)bw5j2vmK${#$)#*;#GaS@&_JPO_XI-|6sFPM`$|yoHgM_NK}5LAo-1Q~?52tz3Ma<+ zTYBmQraR^bj+}Wsc&LUx{&h(24bXx_N6P`Kp6BJS>2sW#y!ClHd8xVeK>}6c+@Rx- zaQR51mfp2e5`~dI64`Si)zk0plfM&nAJtQn<^4Y@sAFS90#!JB;p=esBjxVfuBfx8 z@w9@wM3DIDJWpNz`3^BY(pNb*BumO7PO%w@K;r%9je*QK|-m=o!IF6?kT9DYd zZ>`$C_D%9JSog8^_%u1K>wR@hm`I=sZ(ZT*yJM%w)1qvR%O)f;RIUB9O+6izO?H%> zAc=%jVY(bJ=#x4oG*P(01&Mp9`_%{hZJ+U)zSqES=?wX6tG8;O(ISB=;l3F|%%fSd zqqU~mh9n9%(I9cA(@}Mwa*|ke)P2yFGv!qqj;oQQL;_WKZw|lP>3oV@OCGCs8k8v9 zkAuX?8>iHy8b`^t^16>p17^y{W^Y#KaUb|BRN-wtyxpBSR}Sm9JkvfnQMl&^2~*@1 zb>PPg^5%rTPv)`140*qOPxZxEkw6vRO2l_G#>|mDvgZ){p^3unLr5fN-&5bG?Ibbn zbsqBsR^wWf7` zsrsrK&Sd!358mUP$}Hin_c&-l;$?m(?;@pst3}N-`V`*6xke_W=`9=*sA_FxZsF9@ z<`%9q9{~(rJ9_&WPGI|5u-)C&cS6H|+ufzyhZH>gkUm<{iT^A};Qf>QJY-HJy&Ju~ zAb~3VNsZ=LCI9*u#zSRnI<(n;q598)1m2v+OCoQnYs9bi(q~2ZEL5GX{YNc!pdH!J zLC?X!D#>!h@~wxKasn+#i1({CX_zcm>7C*gQGh_z$MX5=`b}fW*^#=BAzsOH=pOa( zU{0U~3A{y(?+f=#mdCZddU$970#)Zne^OsK%p%R!>pm{=QD3_YIq73Lffgk27B&7B zph2?SwzqAD9uFi?C6@NL)sp3(^R^sbzzMV5Kvds<5Wc97Sg%sv<18Gwl zD?p&C*pdCJRj149lEb=>dA#h>WRrPrfEFae`Aud)n;xnE-el&(TZUO#_D#=mA4s6; z-TWi!_pFm@wukPcDxcTN&%@J9oInc_S#Qp(@6SF^eQ)V4+&_OO$q#Ek_P9}iK-Jdk zr_>=ej;bdtbsrsgyIVMZx`#emLJJZP_-$}6Y_F;R-V>L_YgD9T$HSQg2vl8(xT4y9 z%24|r(nEEMr|u@L>S2u&XhEU^eW|{_dS2c4NiVy7cuQUVY++A*u7(7v+<5;n;qXp1 z#oT{H@R{&}z|tP+oInc_H+h>M(u}E{&A0G9;i1Z{SJ+bz6%wd&;r+)h<2Kd9+<&Ns zWV!L(51!eaKnoHN^YYcMzM1N=!FoJCe4ZqKIXlVgbpZlZiz~k3-)5~;ue{cYGnJC% zy~kF0rg8!;NSxw#9F9J*Rb64e<1mNkM^?f~FFikyKo!n1_}*Gx&u=a5?iI-iv>?&t zj*W3h@+x)vS$*G9iAH?IE31)LcmV=cI2Yk32=LS`8{%tDU97d!*Dg?>^tq$9HSsJ$hQ*IJB#nDK@Gc9_1}#Wn zzstXAc#=qeG5@~#!&y#zJlp)+(`!QkAB*2GMQJNkU8M6QoA z|Mo7u`2Rwn3d@2RDzqT+u1XKHk6>^8TYZc<5~#v5EfQD{Ft%84L;@{HV7mR!1ghf8 zOg86WqqW6(>x1z?3ldEnMwy9E%}V@V2vlwE_OFlBl?4e5duBkoxt=G5ua_`q#ZaLI ziBE&(nOls+_%#Lc0|``Ni4lEZNyHFt+q}gbLjR&p1!9gCB+$3$0|``pe$wAuu1bC@ z!hJ+nxnx?qCxKnRc~wOfmN8tv(;YJ9jNZ0qTpXzRs?n_6k^q4~Rq;_RNS57QqK=)$3ER`XwESK%Z1U@N!)v4wEl9L~Q;ZDUbccAyyyB^Q z>3D=@H#&-~?3OGLsJhU+9O=68HnGaf<3wJaLs~cgQ1&PBj*QqnH`0fDgxIaSLi#SZ<1r^SQnZn_5v=X>4Mu#HOSzh4py3vYdG@+MJa$Y< z)hgGXz&h;75<-Q{7;PW&cL8E`Qq#1gg#-wjlvMZj)=_4m?!S zg3a1_w{Vu6e#nRxBrtxwEjhDI`@MP`TW4hw2vmLcbtBHX+2mzKd+wuHcD8o;NC@*7 zlVwB;5@K4-8STIZKkd!NR2yKrTCyi`8Go6?nO;eS$NH0j=PnUi@uf6yc^nCNa-2;5 z`-I25P0(4bmh&j~wpg|i^I&0c2XgxERgyC7b%Ffo;qy@Ies(Bx8~50V-jP7x{LJOw zAGI-!gW34E`2vBeoZv3x^4#;p?};ss`OOGxcCu?9w!`bE5iLlF@z~nIiY3_hWhXqo z2n4D=NB1TV;?9!h8+0G1n>n-TU3##s6HA%!&1n0rONh%FBcY%6OTl}lnA5F^g+5Nq z$S=j_ckaR#)VDUF1&K0WXOXOxS>#;nGn^wsm&mTfX$9UQ&$24aaz6KFp2PE1Oe-W7pPo#d<1dq#4*H2F?RykuL;Lq)b_qv~ zNT3S)E>0YHt{tj3gdN!W!iW|ma#kdhb@i{1k}LGy;A@8mT5GFeY{S-P0)Z;*gL&#& zHD&GhHe>fp4NcA8uOVKZhe%xWT~hg_tH^_fhsjCf?gBBd@VNmSINyg=tl%z`5+vq@ zFC+U}sigbEeLPfUKD)Ajnw0sn`T~KfO?gX6)OMAeZM2^guIIhk)3k;xxSDLjx8Z$` z?IP>=yZ@QTHWUcewH2+{%!f5u>F=clA4s^U+sI|FU8Kyu&D_T$8;PA7>c%P_swWVr z!W7|WdGmNYu0h!z9uExf%xX)?`xZvxzhi%aP_6pz%DPmgtmrW@=17FuEhP{5IhTGJ zdd%A&cV(abD2rSw5~zCqa|!9%-$+hm>BO7xy6n+E3Tt2A&4iYXUzQN(!A3Imc)?J0 z%5h~EC}qnwi9V1R@NNk?J={pzoYs8=Uw36W3T4wbiv+4Fy<9?)MjOfRb2{P*>=tD+Ai%3fYV1|}NGn_Ie%8qD#Edn;@$Z=We#SAWt{t(e?J+P zERFBg+Wfof>w@b(XhA}s@96RPcOHeILIPD7LNOj_L88%+iDnos*lZg!HXXpcGVf=pQTF*>ntU}fBfe8#PNMI`Plj~c%YiovNlI`{51Oin! zhvPTul>MPuMdql^K%fd|di>l$&n_%ZDWmr9UYel= z3Ct&hVb)GRme8@4I=6Tkfj||`4f)qjx1!k1#?8onuP_ZQNMJtk_k%f+tSo8MbZe(1 zfj||`EDZ*qKZ*1R`?g#C_MYJQ3TqdRocKxUx8vzp_Zmj+q@{)yB(UD_y|s&D>0`^v z-m}7Gfj||GocK5H^+wT;R)^EmI-k|hf&`A7_XJtBY|x`FITyV?4tE|x9s~xOgN**mKx{kd>`_q1m zurDzfEK_5dyXg@r{8>_vKo!pJ4TfH&2QsheHHzjUt#!|E(fpc|!j#slKrjcO@#+MxuDO!-g(FgxdIJ&&- zEjy^>ib$Xe=k0uTFy^X~p0$|F?>B;?1qmDn@e@xBClz&yr+UBSXn{Z#&hGhaBE1uB zqBSO?Z7NW-Ac12dzAwB~J6dAWNp;WLsse#3oZa*NaHXSZ(4+aprQK)+El7y{M9;a&a*2_Hl|99y8CsCo z`1%)dVOz=hVLH(zGuc$L+91WgRx~vn_(3X`Sf$o<%99qa_(QDt?~EFGQnX_}so7?w z`q@ff9XxO&*%WyrSo!V}P0@lxjjw-5R_m4Ota3W>>EIO8T1N|I$;f!x=vqE`e|Vev z_E)wPocWhHr*BhZiseXUgMO0p^R}w&OExDCs#8n@3Rx(5oInc_xz!AkPIPw2;Y1AI z^Sf(HbLu;Dpq$up6}j+_oiz0QHJ^2!tN6UhPVzl^+{eGhN>a4Gz4UiOjt~Fh@Pi|D z?9r1R9JWM43la}sFC{7K9i%LFk`ob4t!SV6eW+EdoiY-r@@%)1_#SqUj$FLN3B$9t zN?yrWdcEFaBU+Hi*)yNGwJ0oYEqjI&{pU85W`0Ser89?0kE9Uth8C83r?n^ZD-JiS zwl`}}Laz=p|E6K>3I8fS#aU@OJBqF)ZI!RVLy2$s!jivtH?qk%gt(O{EG>E8jr3dg<;&FcmO@Kw zSgP8r11GA+4V2|BiPYy?I~fU7RVz1w?Di=vB|q1R;jf&P9^P@ZitPy*!;Z8Vk9rob zWxrDKv|7)TGFp%jeOz1cS{_w2o*r;LEfA;@b1?hzFS&pHc$%NLRYnUEPOn19m?niK zmpPqyJaW>W$q~8}~$cF*JxyEgK`063l-rF$Tkk?|YSfQ^(K^ z(IpkMAR(5L@KN=ZfPJy_pO^(Q5~#vdHyF;9tgn<7OkpjXvhnYScRN#@|{h-1Cgj;Xmt0QD;jj=<88l2kTl6qv2m}D>E!J zWK<`)3O^aIn?lL?y7ZrIR7L_-*p3+t zTYA-}?4~DOIjgCH^{zwPmBiQIPI|Kbt&d2|IMsyqN^qx%XPPN!LE@708j|8@D~0!a z#pAI$s0BUkTa#W;Z3O~V7+QWheua4I*`$~<*RGujQxVf`Y2yv#f|G@mU-c{ZVO=SX zj(plsacgnGh!!N`a@P^x#a2?$ijO%_V^17?@!_G|=)zkQ5~wQqWj#q*TS%&N`3EP8 zT#2CHA52$%yY5oaf<*DQ>&ck=meT7Fzc^9y_YgWK?XmLAlL&c^u|=AH^}8dO{{HYr zX_>V{Mhg;{Zv3=`(*tR#+nJ?_miB@@yY0lgxG^dS|f)*sO#PC(;;pOQq zr*^dR_htfts`H~)k}(TxrB_#VAK(5uQr9oOwEB8K1yxv%v6V0w)_fxJc|!v2HSmp$ zJ=s6qTa%O}4ic#tM($blAw_39NY7t~a34)~y_R=J#?pdk;`Lf z)~ISin~224X=BKRRt}Q2X#n@p+^2?eYhpD0W~eR@sKPd%ucT)8Q#P!eNbkJzQqY1# zR*6J1=7PO+I=LtJvF2O2^5WbC+O>N_fj|}Zs0Kr)GOLx5oySs_Mx7K?y$LTL1*+%O1qywpTG?iQ!Y$KW4*CAy} zFCo4cZKcu6YVlBgxB8`cj_*tBJ}j@G1&MaOSCW(;Hc~g{#EG^wH%sn(%<%L{1sTiI zvN!F?7+#j%T^(1TT`xsX0gxu*_JpJvz zOCV5%`On`$4ydoxFvifi*B;AgLE_8_YI$Iv!GcLf4fSQZS1 zOzRZo`TPmg)Z?~{79>jK&n79oY^C)pMspv=pOciJfQfXW(OM{1sKOG%`;TZ%852C3 zhV?iqqXh}fS$(9Rt~A~-l0HA+t{{ObEMxqOkslY7_mRWtf)XVZv><_Hf$tnB^+mCX z=tKLD?;#MV!cxiK{LHhb))%_dsY`u?@h%c#x%zeAo?6uDL0e1i0)Z;AwEO+~-hi)S zQ7?Jao;S*DRi6X&Xt_Ln#kPNcJC&I1({}qT^LTgvggY7ht1TexR;)D><&M`StNnMH zf9Kh^AQLS9{k>{R3!Z~Wpk?Kd9cCiQ)PmrXL;@{HX!Qr1>9=)S74U%ssycOBY$hIj zwBv-kYrDgG%t!7R+kQy#6NwRhXM%>Y@b+ocsLG1gdamArff8 zaWwiC3A7*~j;S%`NTBL}F1uLTQFZ^+EOQPf96DB@w4((H`yn%lo|_J_Ql=OWBv4iD zYm%AR-!rEG@jPvnxBjei+h?mtKYh8*NB^G{pQegF(1OIcKK5oGK^MjsAkh1do_v1J zpS#Vfn&40LfzL(4D~g!uQwd!P_&@?x|8uAkc6UPp%)jGK7genfRy6;P zH4QJ|11(5g{CO@@f6~>S$pr{Z-Ol+gW>t-$v;H4rj>P}$17nV=uq#u{@%S@vM}a&? z3lbP7e#iR98*-03@$`(eT>d#aiKLXWlm=X`AXV-VMN+C*Nr}Imr9Ezb#P^bwwB%T6 zz8bZ;R}Fr8QX<`$7b&9!`|}aYI+EQvHd5vMJi_m;sWM0Y^&o+|MkmW?K>|k{{8o~) zuG04jiFDHX$pV2Y9O3Zq?wVhei`Gh@B|A=&G3-U#gprgP*3zEP-vmR;cLJ{3E8kd} zK<^e8j;lX4f=BUCr@li&0ZRMW|akS9+2pKI% zEOL(|7lztO&S%oN59_0@O30sBI{x?sfj||GYWNrHp?#G>v!m(rB8f6ukO;1vOx_%{ zmAtntmCg4XeFCBz(Ki!@)S_T8-{4GE*q z`bNrVL1LJ+fTXtiOO__Ma~~^~Z)RwZH0kmcNp>lTne@N=t z*CgRyD=FS{7YY3Mp0svtA><9;@tgcsncQ>`JyNKJjJbw{+II~JbofE8ob=|QYM63f zS<`PME&HOSK%ffa#OEK4o+%wB4B<1#$^tRC_fC@AD36S|njm3l`HFB&d;0#7KlOKZ z7vh10R{tOg;ge^PH5Q)Rq%=_sY}IMV;tj z%Q`Yzkmw<6B=^o;(tEV-Js?y|pBz1NsnUK_t6Ga_I(%l~} zDt3MEN@zi%s`8waTd;+k^6J5f!LMTItj8UdNX=fN6Vf?kury$i_UIr`~hubxEHsxA8~r@yZtXu(v#(DL43cmz#kvy^F* z_%Y4!EL06W{G0^V*g#S%>aFPZ?-QuwqQ%PXUDYMDAR*>QYCLMMo;SC9FJDYsz-kKA$5)$dr{)uvC;%|Z$ zBt##VCnwUDFDA(T<;EJ3KvnIP|Ay-M*H+xeWc!Os?2ZuH>&gQeXYts-_AIf0q#Ur3 z9=Zkb-nqN&4Q2fJVYGivo17qPJR4ndC8WpT{}ZT3leoEa-tt! zr_K)M#M3E$^qq|@wGR~uRE?Uyl3Zw1Na{1FD<_iXkD)JL%F4~i%?etOC_9mlGEV#< z$KDL$#H1}_sDB|@Nh2bGs@xf?NZ{B%N1~1Hr=_83LOx{XkJ z*lkwOg2ej8OG$3|za%$q0{7vtI+S)PE-ULMqzVM8Fr|5~My+XIdw=@TqLPBEL%3$? z7ruhOskV{24ywa_B$g>pzZYpwPxo;V*4uGC37^E*MSEu|bEQ#q?ehwP4}31J_4D&& zx;CI%OHXRmy0YK{3ASMkDe7t^Ica*R8kcBJxAdt^x0n4Plqn45#<9uPV)_vVCIvlgW>0kVEUrpb*0X%zJd=V zFx~j~gQfT?>Tnyn&VIN+pbFbco>phw==w(mxt4ZsT>Oee^Kewdi{ppM@&yyZAX1d95->4o;+h zVp8OW<%f~oZiS`I*`0;m8@RT}cbH!P;+;4!k*+*9SH?H_xH|!#WH7wT+L}3RSR$QS zagN{v3EU0A-;y2A@?J40k^0{fLxn0_jpSd;o~V|2AT*KIxHesQV}kG4#CY6S8f6+s zCQ{!Tvt+a&A?`JB^lzRSH#CuY&z>U?s1jpd-Nh-h4<}l!6l0DA?rks_N(I_xI&-3j zNhDB(sm|Y8`p0LqiA91Bu*S`C&!$k0vF?sbVY_iJZi)c}dkceeTL9$}E4rRnFlTPn5X z`aI7Y1rHMlRN)E&pR3(JA={rH%Ru1gd6trs{1Uqve_LmDSyIt)*SswC3L&}lMSkeK$Tt{Qmg581sXf)lT**O6v#O{7gzyULiNm~QwaelAYQ5>o5! ziM05bE`kpvFb(-Bf}8soKWrr0#z74{>EfTka6tnL`t8I5n_%6rXj!M(5a-U zZ9pRZy)#ZAP=%?$zfjmP$<(N3BJCB{Q$`CC)2kj*ee(-Ro0sV8qNdAnri(B6Tm9>^ z1p-xK>K-`t#1z{;o=)wo$!I}h;=Dy_VDaxHX19K#oM~GrW2Fe*drp`vJ4PN=Q;ZIh zQX^dr}-~);6rbFuP9}dz^ zTYcPacd3ZclE)*DpI3y>LKUV6?+r@En3nubpeL?QHsM=De2@2~!frLCYGG+*lEi)Z z=RGoo-;1TTcbqh|AR#^3sHQLn$uqD9Cu;sat6iVCIWL zF4pEgoW{m5ml{o!w2@CtYaCXYEf#*sJ{RV!HvfL@bJ%By?MibRHtdtk-@ZC>q75fj zaRMz!%$~Q(OhlAT=Y%|`H%m>jr!Dp!69`m!Da*_$oiSba;m3(sPCVoUT9DX#XStck z>Zx*~$VPkiq@6$QQZiN`Q03Kgfw}D7zNq`K;>1f%d@d1dLe;ENi_EIcgR*=?!v0tj z?Zfw2TKVfnBU+Hax66E7!io2suqi;G3g5Bv`T349%yWNZ=5U9cw;k^MTYH(r$C(we# znB8m4`LVsuZ|-AWZXq_DkHTMn^%4kF{eO(TbzBus`~QCg5nF5!14U76z=AnDV>fmk z6U^IW^LGqba^ zvvXm&(Qb%Dv?ZeE*BUA+NQ~(jXIz`wclDKL7Uy#6!H?mhjD46L33Open@mNBI6}l& z+b}ySNZ=X-%8R_UrS^tO_rR-c8kQ*bC0Gh3Q-2~35@90(6(q0)Q+`e@K=Y!~t@r#b z?FI4wt`xek6ilY7MEDS4CIS^CuqUGV#Jf+_mQ=bA^DL7HbYUsbNG9!x+Jy)k5vU-6 zJ-Eqay;DFvN~Qa{^C>$L=#oqK9ubF#s2_65jtUZZ_o271f5Np6S*!3;{S;f$%4J5a zZhK*U()%^bjrLJxL3|SSW3Dsc;;A!CR3~}Tx;_tfFCQ$?e{$Y zUbVh$Q`M8Bf z?WuOlmO^uVL|BQa=0Kq9&ag#B9ZSul5-}_~RQrdB?cdL+s34KNaEcLQU)>^BlW8y! zO~3f_)eZ!@c8r*6#JJt4C=uJ6G}f{ZF{n!c4HYDc`}QzmWE{|jh^<7}iRj@#psPiE zPb0?0vu%l3*L9K_PUmsXGD3T{j-}|MrPWK~tX+OROwmV6dlt^O&W?Iu{C~{4c|=5J zIjw$v5x{po2-Q$QqIM@&BSxL4vxx|7_)yJMbPXT*t(`=mtF5V`aULfZMiG&x&@Xk# zya3)obktBmB7ezBMvQ{}qlswhQBrf$OSiR<2y|g7(3zLYr%hO7=UH5WG*pl%8Q0o~ z(dbNXic$WJpLX?i0FNK*FA?aHOZRg|SF!kN0IzwjjW%L)0Mko!;eiR(%@aM0zY|JN zx0)uoGriP{)SpH%UN$?C1?C z)_9t7CVLe=XIQv~3KF-rt~O$H&7;TIX)kGS|Ew;5<|Gs7!Wv3n30?8C|Kk$C-(Cop z>Mjz??wE~woVG+is=ZX(*U_2V9cnweupZO9yIF2x)rdQM!^Z&W3gWf7HKnC-HS1gT z)l_Tl{eUx<1gXjefxTLRFDX%MyrtMXrmtAp`)5X!~-I-b&v^k;c7~nPs~-!AR7B^ zx1)l@uQh{=7@H@Rrn=jMh=&xTiUWZzTytqMc{*q2kBC^{`__&M5_JpJF=Ety(4Jy6 zB;r01CmjfM;VMlcqOL0XnX8-gsHh;3<3mm(MvJYH6r(E7Qxz2zB*Jc2O3`CvGwI`!V?^8~qMieRE?jL&GpVP{ zcAa3U6;)J_h-rM)h_P+Ces_L<(rnkS;LjT}fi7HaO0&)lHXB6Q!d@yWNF?X?w(94x zugEZp5lMvJqMmum1iEmQuF2$d;iW@VRIJPQ9&X{&`cvn zA^+XfmvkaRzaGOJ2z22}V5(R9{froGmu9o0f&@Ov&@AV_h~^Fix^R6mWh?mCQ_&)N zDnbPb`Kf3Y_3gpbx7TPO_wCr9V~m7GqTzzcDk?}cXdPz6aBp0ZuK9l>(1krJ%{m8O)QH+wC z*YO@i^mHK5g*__mnOVG}LCk*oM@0pR%uRO$>EjjuvU;Dmkcg2KqniVPF6>e1iN$hK z8AZgq4w*GnkXT{g9H_^rTO*cYgSQ;@i>Zciil=J9CRSig*_^*@rv53HX)+i$jd4! zNQABKY{Ym|Q~#Rd3K1QMaB(2eg*_^L3otoNwGgo)bc2ct5(Aq}G-5Ow=|M3z5~24D zV;u-|VUJ4B2D5*wJ&9Nr(oIDLiLz;PjTqj|lBh4~@GoMe1A#8=QE3H)Yi2E!V$|Dr z&5jBZOIrV(Bm0(qnZ6P_Nko4l{2T~$VUJ4lSFf{ZEvOguYjaVWjY0zF%V^JKBAO8~ z#eqN<&SlZ67kd7fPtPAN0rK+)K3QNNOe+VjH5c~KEMjk$0V*m;;4=fwGZ3K?vCn}( z7xuv>)7pKCNFrj~yz?q5NZ@lDt-2@T5D{Y?2y|f|Ol#) ziwGY&s#pgCUDyZH+QG-CRBs|~edZb}NZ=eG?NUHQc_OYk5a_}_m@;9m&aRaqV*e*^ z4HYDC4v?ONiO|pdu>*lF?1Slxkxmt~Cqyi}WztYV0%vZiXCUGw5wQ*gy08zX`^U6; z+H)cj&g@W8K>}x>=}iL>ABkAyK%fizV0xPP)LQ#V#E(cH6%{0KCY?roMBFE$*m#*h z7xuxlO0QpgZ4dRLGj^4*qk;s^_0#tsMBFA~)DD?I7xvn;=UEWVAJdzktY@c6a~U}M zfnT-K7YbR%i9*ZkbFUmWJMQmt0QQi+Ie}%=(%`T$r?4BG83<%g~$A{`78;-ba1g)69;FM8FIZeU&mN zhp2k9jX0)-NCeIS;m%exTFTylc6kdI3u{iZp@M{5x@~FCHm8~e{Pmp|yB10_zFl}$ zJ<7an%ik{sI)+D?D;~MRJbEo*=X2Et%Q+yrs3(*NP5%ae7 z_zwbI*ZyeCBWfR8KTiKjs3{Sfrnx2RF;GDwCg(Ng(eW6|n^%wFO++91p3sp%7k)8B zPxM6C`s6fXpn~5nVQiZ5A|iOe;ysQrkif62=$$YTr>5sLN*8|%UDLK~V5xO4u)uiz z+oj?}T<Sff&_lMMk{5B=sz{ve-P+uP-QlY8*rE1yQ+UH z)1HXk$*+y`Km`f>f{)(n6A{Aq8s~uoy6~$_T3JIxj+VQOqbir?Y)gF;#rjgtxzkhA zt#R8Iv)Ytzx%H^5HcOdBEcZM;Pu#_~(e_SfdG)VUOWJjJv#p%o*gv__P_ucxtF06L z?W7JhCv|qSCAKQZ9?}14h9$>D`<8xd6Dhx%Km`d#bO7Zg(`kAKnQNlGaHo0)D1*I3 zpsQ<1iczO7n-HKA6X`q-WiMr%2P#M$-IB?c*uNFKbWSH46VYS5Q&qiQ;Tc*-WwUwQ zn8OZD7;456q31y&Tz~QZC@UmR)y-*3^bBK8t@IfEiMZ2i&MrL${ua9Aqv}S4=c}jy zoj?VNpH*_%0)F;nDYf+&!-&`(6>MB{B+!K=Vlr_e&URQ}992N8E7sJAbBu2sZXSO5 zp*4LNiOxjy&t1W&?WiDezv?fmN07>DTXdo&5ec8V8D)h8y09M8 z+gBp`to1esRFEj~JEP4qc?3*5a^Qo68(;|srJIXdfi0@iA_x> zG0Woztn+L=Mm-|-yY>7J0$tdT{p}NXOj_wESu`(0bD9%+x* zw`U_LhTdu~t@1YrRFGKR>4-J1*bAnV(TNd6yg&c94I+WAxeGOGT((;*a|4~ILqtTJ zmk|RMBvM|Vwpw~TVM|`n}1u4+KvReuxao=CE?n_6SvD9ggUy6}uvc(`!L81&F zZ%xhqj=gNH=jUAbJKCN$relp;R7;RR7p{S({Q$C2JFk;Dkg~W6RFFuy7h=_0TH-%? zjHE0y0;p{YJnTTAOJ1!`yJg!qZ+uWgFI`lSkXp0Jw;=UbDV35oIS}Z=mGU$vLoqzv z-1V%X0u>~r_HQ~jWQe#MyNCBUGeW^Ha`DSq9G%fBy+1ufkt%ojm*gTG6(r>EYiG`E zE%qVPglH9rUfKMtf6L{IU3j zF$&Kxpq?4O+r{sBW#WYAixmC)<1Kr9m6ccXS@pl~9Pm{pw)kQEzje9Nl-r5+5c+qF z=w>yH7}Mr^E3a3SHvaC|q`2~I<_zO+@B0ND2vm>=KRv-9>i5wzvSAE7Dm;?Pti5qm z=jW7lAoA{+8>Aom+UmZFOJ}bd`rn;n$}8!siW&c(xzF)DP(k9q$3OyIC3YtV>hVH` zlyW$#bCXi7x{_sFA!S>RC&u6WiHCC9=bZ6(lqJ6dfeI4&+};?(`ive91Re>N)qg)K zRFJ6p&po4b>%aDPh=ByUj%PY=oO$tgB^(I6=7W-_GvnIa@KUh$$k!YdB>ZblG3ekO zj-`tPx~?9nY7mFrd>oDn6(lBJ3pa?t56b)x0$psWmqGNZ>`0)3M809|4I=zcIfocX zpv!6RPowTG8|>>qpn^nLmU2dn?bj>(4+33rMUELUrmS#`feI4m3O_f9d;2On#6SXF z*f!;BjtUYZ3%4+0oT}~^0||6t3zlPCD1X+f*S=2O3n|zhOV>DT{QvrS#}mnpmc9#ybo&wvEFV(YImh-xl+zHRJ9Q99Wqgek- z`kW;m)wIN222pU(Tl22_e~)C-t(WF=u4j$^XUwAK^Fjg@B(yEF3?k~H{#<*t_nhSK zbX2wbbqzuSUAsD-X93zM=2l(L&V@1XY&$&eWc+T2KPE*!b0knf;&IjxgZ{iNgG1>e zfv*2Pvc$17j;V3}T0Rdf-JALTmh^nLcT!2q1ePcg{%dy{=MmfBokLk6fiA3}atu_E zsI&NQd-PxJ7y}7(VLg^(U=M)(IrbPbfeI3MXZY^~x;nI7Z?uob3yL{hb5xMPx**3u z0$qn*{%wO@dpX8H1&RM&uaH33`mL_UHLrcjF$O9~;1iO39{3c8^%$S}WC9f={(Egl z0$tcE(ewGaUqScv{_3q$GAc-%&3%g{QqR!btoK)1p1sNGM0k~RPeKA+*xS)NuZ~ZH zo)Gb;<(wMW_eGw(WR&RH8qsF_CHhuI&CG5Qaa2jDfeI3Gf0cLelIr@ibCZ`{q@zNY z&)|bbybi}|(RuW+9iy+kqO_R96_QXv0{ch#dLV(Wc9Z`0D=${*`>J7|hzb&+nWBxO zD%#KTKL~Vn$iK`W?ma2zK%jy|^YMT0wY7d${T~Fny7|NyF+BEpI1s2Hv8>DAd+?Dd zUjKtYS7@!NMvU|V=??dERFEhU)WjfM`e${Bfdsl}D{6zdcDh{*FeJ`jHkiDIsQ_cpAZ zsPCUx`*b1eMIzcfij@d-;og1pUFy?Fy)O}2N)`=#978*N(LRYI>Xb204&Gw??Nqde z!%?;GKE)u?_so-GAaUd9F@xT6T;EGC_vLm*Sv~bTA`$4qGBmE>zxRl5-t)QfTib8` zVZ7)^<8M~)HLI&mHU574`OM!d_-|t62QRaJ9*Y+jGJEVUW&B-oe_r#Q#RZMOy=-~@ z*4=*-s36gPL}7#I`#y^UfiaNIcGuG&tYwQi5U3zAr$jD;cwWw(h-(eD8vC~wc;%Ul z1iG*;$Yq6Pf@PJs#MOvV;LZt$vWl7i_Y5OWr?V9u{+88r=NGK;^S@~7 zPAg`_n7jPu{~*v+FZS=5TMj&MAn+RWnpMX5-Rrk0zt?}g9;hHOwVkg)KgsRla6OPf z7nX=zx~L%0Z_D2!O!xlfa2`mY3(HN8fmd+T>M2GIUzzxbVJnacRFEjrV46YKEuP8Y z%#lFX1E0SwEqbhDZAS$O{Z6E}w13~D{@tRGKv(au4+e3on`0Y91qtkx&~-WKZ(q`&_63I+s33v81nmy9w7AHZsg7vinqOV9dNoUJ@P#$5SjB7!q@A)0 z|7P#5`k2#7#ju!BCUfMc(t2&*evFqaIZ%w+nbC%OdE-vmGBLXKFwy*aYd$O2RCU_# zS+raEFE*f5A#?VT3t8&oZ_Fj5kJ+e|K41P-+c$)d5G!|=<%z|osHh+jP+}3Yoc_R^ z`{+B2Zy(fG{F=pi#>NL#RFLTHyNt!Xc+2wq&_|Xt>h%=%3&3fycH}?dp6{il5%ONAd!7O?IV8u z3CkCz5bR6a$@;i#!I=?&`?1l+sP{|uEP<=?Tsjga@k8v3`|mT-l!)L==FxQ%%l@V*#b_`*j~G|Euaa$SONl@ijeI z)Ko0(H=m(`1ny{0y9maV6NjS};Zd@S9SL+rH95rMhTLPf!}NO9B(bPyJifjdx#^37 z3KFKRFJ?rZ8FVjoLg8UTZu2`N_HgB)#>I= z7B}JzTN13#6j_S6iRX=43sJ>iK?Mn{)3k3?#1Zbbe4udK)+HH70C?AzbBq+KpUT(m z?IRMK+_a&BM4ge-SdM-fDDz?=I;syXN{h9_YKlsE_S#WF0>>zn!DWk|*#Et%$W-Ko zM4-!a+YENDp2-|VPp`B~^RnZ-<);B+!Ha%Y9KGQ6$9N`FxfKujms-6=-hyjws33uL zfpTa1l@`8-%;MyY$|@4*!f}_$)b~kA@%_0XR^H;$hztq5vL@5`+vUZTgsP&yM>B~) zmruq$%<}yOD^W<$nKxZiK@1!jATAxJZ)QM20_y_h866fNc8{$jiXUwx5$JkWh4O#Y zf5N)G*Xz}=hyJvqbp;XMvyX}j5^}xTRE3F@2Bk$-9=2DFk0bgig-mZjP+vljmPy|zMzdSd_K{Gw{1 zNh&HxU|&hQwS`X2q#2!9!?FtnYB=Am6SI|6A^#2ge3k+B!5$M7> zIg_b+&t9U*#2MVIO?bTQQsSi5$O+kuHm;;RFJ^-Z!$f7Qctip&v>(s4zfESgebV;aM9F z;HV&hc{=FMS$~+=+bWdXD@95Kx-b_Aee)AGMx;13KVg0&f zr+(M>(+S^nM-A^NS)#UotTO9Z+mXP?4yv^SY! zf9rFvv9-g*dhf~n>i8rZDo7Y}q!bIOMn_S!JVfNa zxkW(*3C#LuGMzZQnj!%M?_Qz?_Pd^FDPY zU;kjZSdkbb5$FobJC4PycQIe?tY7oo@$o$WmtkU5;rD$ zv2*iir%JyZ)S^!HT+b6G4j0o7KQyC)1U~!FE`qi+{-S@VIP&tWM4$_weJDRp!7}2p zw^_7GsK7Bt5!T9q9cQzc;5-FU2nBX^@@lF*BS_I|4RiGB(R3kH@xMFidFs%h0Fd;5`nG)N1|DdBhKa)P4t?m zzA7qyuWTUp)ZVC|f&|u3+6AgcF|oH?Ls9cgd5J)mrR+qu{jigH-x$3n=H_1FNS6kp zaY|_g6(q2R(tGFRVq!?HhGNx;uR%zlEB9gQMR#W~pH}s|LGH6&;+ua15#MY~4OEc8 z8cJWdohc@2r8W>T6@S@~Ko^dE>0ABt0m5Fks_6G-g&h?nNh>l|3MrT@U zuuZ|=LKog2>05nMC$ZtGlW19It8^bk0_!w=g*~E^IKA6htXpd}<8Pr0@7mOhdUX~h z7C4FTjdq(+K>}+it$=9TNi16LEV555W=8^DI9{PUj&o;GEzF5>7W+u!5{Zy%`@Mo4 z#hgvfVta|x5`py?-#XDcfbSMD>W;IxGUAjS6(q1u(=N2DMv9B(GdnJRUcxc=mHv0! z#g|5oJ2?;5@X#@|*Fa|b>1Ly(JXlDyKD*YS3;)nFB21YPF3PzL2wdBRO9Z+wM;Ybd znK)kTtGYF5z}edhDo9{%FPeLOHbDeEUT3@WbCDSpB=ihm`Z@jZ()a9-rS&V_L%;9v zqI}&*pi9mfR(j(W`=0}m;^wuhcFYno+MZ+$o|n~J!Mi^DI_aqOb%(6xlSf<7nSVZ7 z)>dTR1kt$900jxmN-{FTRwLi`ipjNz=-v5>%}bjg)(y_5pn~~E@Rw*e#1(t&_m7Vk zUCsnZM}>r(&7;*gXLUrrNbwUjZ zH9kA%yWe+~2z22Q(k=z_rDoDr~T$)ktg#Bysi@b|{B?4V|gp~OtYj?YU?GUlyce;vK4X?R; z1;6hKwP*R+Sxlh$5>$}DIz~_Q3GQmhn9gGJ@oy>;=#pD)=;9E2d*|_@O0&myRFJ?{ zKsgRmsAo7HDXP`HC=uwATkWn%71b6;!$i$UMam3>*G6uG-;&mv_@0Qz^x@|A3&V9;jo~HTQ5{va_@6YEq zkEv3|7FQ{T^?jS4h@6!MX|@*4`G?ST{NoN!TjJ1~Y_3Ohb4YeiTR;iHuKBh!%X@Mp zhY!>Ii|1EfzfIt%AW>w!n=Rl}Ip#8Y2*s#$W3YB(v9nTW<^hR77w)z~Z?F!G(k8{^ z|kDXor^0?BFbKvwJshTR?+W%*F!D+Mpt~#BSr+!Vvwv^P)PVw0!=jmCm*q z92F$`zbtACh-ko87u1REAVVZJ2_?mLKrDySe49ki>XoyCKd`8X;_?8xkGvvfSi z>d)4{iQ{eCY2{sA#rI=*B?4VBH z;WfKjCX|Sq|M+UTt_O*h*UE5Ikm!=v(`NbnkxhxviIvR@Y8iGn65Y!9aa52<88^B4)ivRe!$kDPEK-B@yTfjmcyScFtsWZ=uIH)n$RIb{Hs%6nt_9h?Saa0OVrF^t7AY1>=n$L^NytaKQM+J9&!X2R~FF;^p?aa$8V$Ac!5`iwf zHnhL!KSQ;}P0|$40rApZ4fpZFd$7q=VD2C-SKVs-;<`=Jy%q`FH;kTfs`b$ZZsfi5gJlWFGcF{@??oXSM*SCTU`DG zcB7r1#dWHApqAnGVm_fk6h{RKxvbK+wbm?8()r}flO+OO(J7v`xVHyc)}MNeyEi*% zrJlXx2c050Do9{`qjed{)wT3urG>x$7>PjF{IJ}%xQEx-*|U1?<@n!> zeD;~z?OPY|DxOOOy8L>)vBp`xvU+>mgJ})fvtc>eVGSqNiE~~&k;2w z0$n(Aq9;iEAZ^R84ZQqQYG{|{D(+f0&xRuQeNB!E64(l8Z>mfW)e~ObMZS@K5`iup)zEuqr|0U* zM^!rY=nji68qby z88I?$*XJ^J`fXs;^X@1|-)pR+;nXsg&hd&t<8X&--9G z<1wwR9T%>Ro}X8lmCY>W;YFfbmo*nJ9e15qUpK{qG0$uV^P2Sr^bImD~Rv&X{s33uPZ7I7- zn_(KSyUgY}e~XF)x-c`b$rS!`f|ii-rs9acHEmcvm?>1x?x~lwo}cwE5xnH9L7?JD z=o#St5=9P7(DwU&toWx`EjtqW-~Z>rOrJDskafJ~5&hlv>dPmIK$p|}_+39^-t4pNBX*uwQ9%N8oYL4Q(_roSmajpLM%yF;T{BlrHO?bqzn)>W=%`@rWUiEy zTuJU4DoDurIe#QJ&_2wrXHVH(Od`;wXZO_0>TeFvTxu02Y!P20l_`NFT8N~d90dWhMe&*%tUZwUijq)g-KYiXh=8bJQr1u;^qjML3_a&8CBkgsCAO^ox}(IB1`>fT%u-9U2Ho7m&DpPaH0sk@%6^K( zo)P7Y7~?iiq!>?2Wf6&$F0tZeI!FY%FqbW@3tw}K_nlqdejzS)mOUiz&+G)3DM+FINoAgZ_5q&Afmt8V}F1Zc%Ip)JX z{`3;B%3k9=>qjz=tJm1HIZ@`!tH!dpdRJM;1<~dVLD9^k)MZw!p}zCi!jw_G_lRbq zpe3tN+YMl;cT?G~tWoAif&EzAiho$44AEx%C6g)hq+xtFt@%k>_(_U^#N)vum}S*9 zcC}VC#dsLehDSf{CjM!4TO!bfM@aW`YYD!zN)^$xhM#m)cqCt3qL^i1DjVrFfnro! zUz)oeEi2~d3X%wP9c?m*#Z|h^s=Uy%E_^t)Ux}`HpT`~QB~U@4=gw%BS}={hc%<*E zR^D@u($4mnx7yQFBGA>N&@g7nbf3M9(~0N~hlBiSuD#*J2r*>g3}y+r$vV{sm- z-U1aQf{IS0FLKz|GeJja=@Km$}bb>!ZM`QhDX0D-l?~E zyCS`%vO)r{Cgl?HJ+EAxlUcM#ZzmDx!g8aGA#pu<$m5D)P9A@O3KCc=^*Q|#{By-R zVsWgOM4$^xn!c!7Zsp3CE~5L|O&k>@jsN_ zN9uD_ka(LgiKX7T$ll!2dxlZ<7xVXbhKQjh>q!K&f0HR_#t8ndMtR}gDL`uHNX$6ao>{E7 zSrm8ERG*#anty1iJ7DO{O;?&3ODj{l)7eJ2@&y$XBpm zy@`DE^gg0jJDEV2eCC-Wj`8W`TZ&vgZgEtQc$*`H#U-V(b@}vKGVtaOUbAUS5!dat zM4$`nG|iFaG%J_lC<}PL>ij@)7nVBe9&4H!ZH|ccVW|%}m6X?S0Pua{py?JbvF`_{jm)bcJSkg4gDCEu4h!{6qCeVewgvpeF zuHa?5f&mU!5MyM!>B2n5-e-k|=vOfG=@9dZ4(&zjZdnB?NMI?@v)Y9r=318aqHR~1 zKv&yknV6;aW47akKE67?ZlCS&`z+$XBBDS+03EB}&Z zanaBKl#_?us!6v1vRlnvzl_|%N$@(wiGZCmDfpvk7D!|2F<8CLBcGF29fvzr# zyI3u=AF&B5$54zsbKUIiD5u`!WXd%P3KH^p)coLQPu>+Ik}p)02y~@HPz z#T)eeIObIywRdqQ@#17w9=++E)$(p0TVow-&XnPaHSV8vtf%KV^T!zaJ90fcUM`G? zzGJNR`F`P|^|cDp^}upF(0ZEH5|hT>nDzECvBMYpj$-4**caR^DR9+x-ysaTH_qsD|3`8=m59gp)*|>(V`1+md%TOPH#cZl`56wGQozi3_hfb5xKh;CkKa z@pB(*zfq47-@b}AVP=4MQSh#U1iH4by%6Q<}M92F#XJw9r+ z>^sWdR@Yn9^}B^NJ>yNR(=!DLbk$yR$Z9Eil8p`3>u$+r`L*fCn~Ez}+&C&otUY|# znyMUU9~$W~+GKIoykpyo%E=WZ0$o>H@3C4cUt&F<=`m7|X4JZ`YcK583UE}AsJLXe zHEz{Kmj9+6d^3H^T$hLgy)lt25~2Jn0ZW~i!Go=L$;;hFf-=Yqx;~g8tUXe1x6y-?Hl%8-hY0u?0WUiAIS`SwN`s&G@MaEU+{-r?wb zgR;qLr|lzoaAK(FJ?O19uH+V$^V2Z%pqekOaYZ*VU%wIN8&4iuEtis5u2y>K9vPpk zroR};o2?2Js339A^PbhC=|1*hww_;YOS{eLpV--azjq&rK-ZV;)H8fP#-7;q7}|!- z>YjtMdB$9Q1S&`zY<mL89JP>PtGDV?PFuBqFlUBz02Wt-OA@z7m11Oj!6#v+O5!B- zT>Gv3Q{KJ;6(lOFd##oW*ID@wdW=8*8PyZ7kMajU`w6T|ckeE+#zj436|#&r89Q`NT02W8-i-V%W>r?=Es1`S~MM(gz|xy4=e)9b}bt!2FhDo9|DL91AQ zR?r5W|DiaS36uzQHSo<~3vjE?Ze7-|c~Z+tS|!UaC6f{;P(ecOeUeV()27YdWv*JQ zg+!ohYau722mjSukC8q8uzJ-impHoGES&o%T2migVLrV_o3SqWq#w3=tUt|4KGb`k zJ!KB7iy!3_WnY^GDo9|hG?^+Md8k@KoyFA$Od`;Ax7{VH$1IJ7571+LZvId`9po$u zmoy7hkigbOIrZ{q(6Wk`+`kf&2y~^~xMQ^xNo3y6dW>H;Oj^;OFL?dIw6_x|NMJ8P zyQ;L!sdd?TmQSA(BoXK;e(;$!m2Y6>6ZAG{E|F7fH}x!Ed@x9$f&}(<)XuwlXuQfc z-p@%U(3PI>$?CB+mTh^c*TjUK$JN32g8BSfp#l{ous^2N>V1x@uX5kjCxUQir^XvZjVVWFxcck#I~|(oVBC~t|cS_T}>M-uv&uev2|nhF7K z{iI?dxDA&GbXBetZ}sp=V~fV=wWLE=e>HQk#@)^R1S&{i?@I6PPWh?YJ(c&j$OO7Z zJ=tKjlz7MvHPLIyv27>qt;;=C4i}3Qs33u336p6|wp4q=B6}47#WH~|%qB{2cu#iL zR>bvEcAhc`>;Z5D=p37FO>9(;?P#a>?Fl2tY0aOESB8~t%uzu?9w$a-8>jWpS(870 zd{sdLU3mh3Sra|`vUVf%7(SgwYB>w^=Bd{jaa53aneDqZF=Y(9KTscw=KMKa%e-Pa z_wSIPAc3ylr)hor%gJoPT0KV0z9Cv_mIOXHbGm{G5`9a5v3ksy%p#`iF~Vl{)qa*Z z%R|a#;7FhgpA)HXKh<5!G4&n)<5|d#3O*s>FVXDzr3PA;>1TLW(^n}568OYQ`;iT3 zr_K9kGM`oEi$tIcON72~Yt&Br`fM^ky!eY$x=7#?vdNU`XlJd=$da@t=VysP7hYM~ z*{Izm^~~;)eEZkl0{aYn!p2^So}J5;(9H2OcyBdO>aUQ%Bc$FZ#8vYf6U{^VH zVed*`tY>Pd<#svCuf6)rQ9%Mrn$`iR{#sP=OrqA|?-GG7yi3s2_78vUNtsNditBfd z3KIA%N-KUF7S~L<3Wit&6^o z%3e{6*M2Aq-Q`gqx^TovyWAJ|)xOPW%-wp(BTFQ(bGg4%H9Waafyd8Cg7 zj!|fKa7R&f#*>c9EP6|ZzlAQ$2u~S^r@E`V#>`ae$3zHJkichA>UVDzQLi3cqU<)y z1iEs#JhG<#va{Mn^%2>-9NE?K-(nTp{Rn{y68J1iIlbNTtM%{hR~pk7Xh@(-&H~=g z-=r=HjaO>WTYXfJz-Lj~nc}RgI(OtnMeQ9S5$KY~413Kl>@M?nDa|`a3RIB5XHohp zrPgcvj@VDi*ufDJfi4{3&>hD&M0IJjpYPN9N@E`+@X3OD>JuUAuXX$R>Ufzz7mn0u zPNvst^+6qXG0CP#V>l$RSE4>KOSn4wA$>=e)=DDKg(FUr=}gcTwMFq@(PZ-vTQg!6oMPznuqIPS%Mi^o z;!I$<_4|0c?rye~q$F1wYQ``6(=54d%jy5tHG_x<2pyu$$(a_|tLr|F3KF8e{tlWqX@f+dtK3OXTLi`UQc&Mbx39-=ZRdn1f$Im*7v-QJfjbt^tU>-!S|z_+ ztktuH5`nJ8y^Gk6S(oj3YGg$ClrU0Tcrrd{;GQKM6(r=Z`!$M@p66FkHHv`>?gxOs zM4wvS^aQDm)s~izV>|D3lL&OxDpT6_onqvS@unEt294Fc57cBG zCx&uVkic&uO{RW*!?j}0wOQX>wIl*v;qm2crkxuTvsc#lZmV%FLR&VuIWx5>&hd-0 zZY3+)^4XT{DKYdb!(B0G1mGU2)l9y`<|cSZyDcCg$4EF3PTzU`3TiVkNFvaMUxw1E z7gM;_Ghg8px7LlMZ$^>8bE48+Fw6A$j`eo&Ty|OL9UWtV@I?nK%+64L860dh< zO&2FKswK&h+UQJ^?9tjA1r;Rl3qF&n{q^zMhhYh3wTmhd=)&D3Xg$N!@mldXH}irs zH>IQ6+Ley5yc4^X@q%=OG-enuUh~O#j9q^BK*1wH;%MH=w%%iw1U#5@#X@!{Fv?u<$Q9<%YY97_Bg(vg zhLxqh+RXxQ=qpUS=Gd()T6S3ZMFc8HT>GOjkEnfY{WzUC(0#1ZYgq=~Z?sIHE6jTx zOFem-^~j3p1iIu^lOHlW zDc>}AZXp5{ByfEy)x`3-Mfnd+c(J&q0&{X=c26ur%JWn71kW2)kv}`$M=D(;JIy0D~apY6?c_=0~{D19c66R048SxV^{Cr7gR(8i3)qq7qv0$rF1 zmDZPZYAG`Ha22};_2s_KM_HHCyT-eIo1NBvtmG(M|Az#{vPdVC3>nw3?Wtq6;lzr+5D}5uH zh?+!f{@g}v@o39YL1ObN`u?N&VRlXENA=H}@?w6gDx!9^t`dPR`KZ2ylo3Z0x!6+k zu^km8%GKM%EOidB_9OHd@A6j{SNHphTFE^)66nGbp%q)jD~Nkpp7DZP@(C=TK{-z` zkGAvKr~J`o{3Y7$jflxvpYbW1^9fXtsC-~Qv-s~|_0Q<5kfLAY5GgYXiCo+BNCdjD z3{9ri&vS^VX@$hhU3mm5NCY)n#Vnr3+4(+tj6$Ysd|mHqB4Lx8M4$`T+|sxt!!_Qa zUp3Kdotr=f30%uf@1x!hZYGmwsq;L?r$zi`#V!(oF1at+<9(iQ`>=@j ztJFp68IZv1PpcE0pYVYr8uBHj=?iA)+tG#n7`58(LQI{!f)Dm9AoXxaU=5{f{wz>z zDL9Y+THqxS=)&B>RNLuoe&wEr_{RQ&IVwnCA44tbL{ITO_W{0?&6fytVSab2?Jd`; z!wv@UherpCFDDA7#MPO_7X2)%bj-Nj_}l%nuX4ikMM~n~QEbZ|U&_#({b>nx=^)iS zdTWG01&KVKr&Hpt&t{?Lbz=RsYxY8u52Yl>jTfl2EBrLYvSkkQ+V2=+_TgB2zVGF& zOPfrPVj$7x*RvGM={d}0pC03BS{b#_wQVWu#>+>AuAc>8q{Q8t!)7Mx#Gcp=>dhf% z>@%N_kz!P4?TxcNKc}pMB|=%oe*aY0ofxF}bnGIP6%sC;y=v%X)j6g-#VF~_4*r^=pOfbuoyfkcszID;S=ELFx~@e&HDZ{TdJz%7vnnr2F)p{NYC{Ey zi3z`r7d6%0}x~s0}M76Y> zMvRYZ?n?x^!rzuQV)))FL_~ig3ei!OT6^D)3KAo?RWf2+?dnOyq3-7totV?SfQkgV z@?>jc#K`#Fod}m6=M;A$b`XII63sq!Fk&oO;Xy>6Rc(}lL>%<2C=uu?Gk=m1V`O?k zB1WxjV;ogD5vU-syvJlChD)M;RJXIQOwrGyZkB2ifv(v4tBe@cTy$ba-jyl(70g5g zDoC8_zS4-%FG(k={|d3|#ICLtBm!M~_8%}}9M7Vk`Tr4nh(Onb%KMDpt-sAfkqN)Z z{q|*l!nIDDJyleYSmdG^^xj_jHLp!X91-0d2y~UMao9Mj>*w{OI{M^~x@C2M>NeP- z75SND^fFf`cq&a}lC1%4yRqE8J(ax!b{YS#eOHfB{OKQc;MxH7&LE413KH&ji6ELE z)`=_o;?(8Y{M8j6LnQ)Tvvw~xVtmZ16Ss-@KtwDNs2~w~lL(6O@r8aK{1dlp-~H8F z$EiPo-$GY~L$m(&q91hP{^;A*?6bz(Z%mk=p@PJdlT(Zsm7nOu_-Z+YdlrB7L75hs z&vUb(9IIVex!0H*zdO2PVdZs4>Z$3ddhONEqZttc=sX@0feI4CSr3C=c1b60)mX>{ z5zUGWlL&OlNA-t@kgWb{Ya&oVqUjqt54x|^%B07*zVec?|8antf73XLKo^z>=|Db6|n=w?k?=+JZpG8*FF4>-|PIhu6^I1c~;NtnKf%vB+!MmPFLaA zI`ER!?;C?g71p%}60J|s3`Zr;=qzT@&DHAjgA2TsUfDHt1iG+o=#8q@6ZoffZOw7l z$4K)QJ8C6tKBAiO-Q~mEr>2`8`_!M6v)R>d(0nU$>si@Ic=WqV!ZXSwwCuBi7rO05xNIJ#Cj^nS|U(E zV*7})8vUrZxMn!)H;m7_=BGx}{as`10YB@@up~ceR8u^w&N|S61gk zD|#u%0t_4#B(QuI7Eay;`Ho*j)w{BjjzHJz9gR&1YX`6s{^I)T{M=vCy-JnU7d1+8 zRFJ4(U?%UoLzr(BK^&=fLwR$;R{c@gPsN$@V!X+e;5w99<|%8$*^YK%Bf{~dt$K+F zRFIgx@PI}i=`65Is#odyINRm zB;o}T-H1R1iTMuQwHURUi@G{l^rQ5Mh)3(abOgHevri%sSw!3;0u?0YD85<@uNUI_ z>e5M9Uf_X^dbvn`9f2;K`Dh*4DObLdV%#MH6(q)fE2G7zT3gJb&kMEU>xsCuY?W@_ zMHkK(^wl0Bz7p}22vm@m*vC$b@#KlP-tG20kc(d8IWbT-`=ATwF}kNu#4RFDPYBe_ za7b*8wANx6&xkp34SnhF1Q9PgwAIa`=)yV1!eTKIw~3I6Kn00*ONwbRR<0GZ&zB1I z`9&gPZr;@q=)w_9|F($`y(E$dRFG&9Q$vg4=P3S1b@}1Q%k8sO$J8sOn?=#3pBa3v z%u`y93skGkd!yjk#%nknkF-{eh?+#KA_5g8@S2g{UXFKFeC~Hq_s+_zB7rU(kMvFh z5ne>>Cju2D@T!)+YguljNyLb*T0lpj3&$h9YguWei4!rC2vm^3D{|Tab7m#UNW|w7 z`E&%ja6HnUHADmwahV8IkidTybXV=y8mTi8<4!+TkU$rXN4kb1q6rZ(M4*BM{zIaj zLI&TE8W52cx<^N#3&$g^5gv3yE5~&rP(cF!>Cw&S5zQ#ZA|g;h0`HyBory^& zrQorF>ct3EN1#hTGxVKvN-F%WvwE;>s)Py>cz1`&vGBCCp{|#ba-+XG$9!MA^6D7u zWbA8tqov<%DrdW(075t}&bBTY#Y3)Df@*;wGRw%+) z?RuCh4|h|x6@IQMu2aew_Y``f{m+!>zf+6g8`|$t?%I8Z!V$(wM0_Ix6(r71&(vt! zN#g9>W3Q_$h&N+=bp*Qfxw4OUl|`=B5`hX5=iSa|F+zHY80r6&{2i4X33OqN(Eq`n zX;SxuHtN2UkCZ*0ue4czzjImRb?Yx$I_RyVvH8OfCU3{ltn^xOCF3{VfnP}ft{jZ2 zu427c`ut66gMx-K#`tGnOx{}uvCi6mwfRQIL*AyDEfqJlU+QPA)dt-tW4xw(*U}5y zmoZL=r1XdoR-%%I70tYN;0|wY;whaWcldx~ZktPjNDiKS62giA#Dq zQ8{+JJg5v>Z=SODay}kOXs33uTj8>$4jFxPuJt{5o*AeK#kw)vU znik+I^V_O!L#nE%AfX>o_fP$lURS80j$dJ~BhZB-n0AQGt|;wZFhMoj)l-TWd85r4 zoAWyAW;p#gf84LMWEUEycCPc>j0(mY@RJ%x^3)_iy@$he9ZZ3!=LAv%p0>=vN&yX?DJgSGM@|(sv z9t&MK{^^~D{g;#>(Y2N6!u@nJ0}?ob>1hTL4VKqdVsa7a!h4zYRA!}Fnvs97nt9bl zSG(tSeKsW+N3wF09F22Zyr(yd$Fjp^#7ynpvKc>9f1YBd?@r*H7zoymH#RnHxb zIJ;U{EIaMaKUB)A*4UR%H}4`5(TUy|SuvQ|9~3cO*R99=~CLbGS7Q$tUR(SZ5&qRt(NYVA?}{{tyMv4b!)QP{P{3B zdR~sp*rAk;I5vyUqTXX!sTSfXK;^@~jCK|g>a1}i4X9uYj7{r(<|RnGM~qOrRcmBM z0$nd(e>EkzjA5*)h!Il!w6tZ;V0Fpkg=SQc_;%&H)?@Alh;p16^HGZ1)>G{8e!%l#&n;`yx6uT>f8>a&8Q%8sLF3sLdl_Qt)Cz!9B|x&rC&%5%Q)veTkR-4SIAhCW3y=6*0 z-+P*f@nd@rUa0&RMV%L>BhXc?bRIcjR(ICZOT?J-qbGk9KUdlAn5L||Ovn1!l{KDO z+KA)B>B=vYcd0;D@~9Xeh7|#PPWdiMzNa=SDoEhiq_t6N+wx{_(#&7-m(UUD!Ye0Q zTkF|Knw2s^P5e+qH@D;X(D&W><|pPX*J0{`*M}8UkkHR1AAD~cqiK9JaA|Hv0$te0 z=zLzuENv|`Sbg5KhJp$bIR0rzIbSpF_BL2mB|U*I>@n1Wjmz>M?p*zSV4i{s5;$Yf zx3F864_m%i`L-RZ|~nmyEYlUf>a4B}YX zc9O2)+Ye`lixs85yE=U^e|{xRG3Oa!#ACfJLi<-lb!P<~?Tw3UXw& z2Oo9etKzY*rH()s#-ncuWp?5_Hx^P&h5#cfNRC0g||GhM= zSueH0&B8`hkihwv-bEf#i8tHWRE^3guOrZfeZj(_X8-o`dS`FrZ&Rc?#ac0CzFNnw z&Maa4JaeB`R~c~n(d@wIRYK&U*nOTDB_2FVbp^ ziV6}3L(iHr6JuFWps1^92agy_C#_5Iyd9w<(1o>5Pp~rXC{60DtLYQgUwv-5T5FHe z&c%%)EmpdS^uf!!wcqPk5i#aXJF65+TWd%fGFU|g32aUJU%PR(a&L=i?})?Wbp*OZ zTJ#0c{cv~}9;KIFSCBxLzT~N4k!lT_wRO7NOf##y=4mCIav@yT9t(}Vv>5d~pHqx~ zi8yAJV$ZWLRF1VhY?C72PBmlno%^U9{dTciduAE*F+|C`cRY3|JXh@^ zfw3(t6e8lOt~M9Yw+FgJ3{gJOPW0?9JW|b0Z=N#g%M`5z|0qc4%MtKySH5=D5$M|UYPJ?5skV5_G`vZ;+RkN}VZ1|e2^A#twL91lp+1>qS?gP# zcSa=8rLU{?%Oll`ZL>Y<_8e@i|_w+tQN74&B36)o?RxxJ>DlGTU9>g>ogn;L@7D z#}Y?haEW=-S}W~s=?ueih~H!HU18$)|4aN)kPvCn-$cFT5bZx~Ik;s|P7EaexTb$< zrNsy@UxbL!l&fkP6@By8YABFT2I!$k&BhXb+5G%K^ z;iUvIn}|&fL+Xug%Vn&SH+NgIOn;Rf@vd&bI;7`=M3_JJswYk5y4pozp>r$dI6aN+ zyj+oDj3VM;)Ggo2jT`C+bWQQ3l8@fO8cqLA#QDn^x8n=+c*5#Hh95obSRG4P;c12wgzA>VJuCPc2O`CKE9{-v!?y9rOgc zu$}0AwMU=i<+ZxSO?VKgW+o2R+BVT%W_PYn(bC0Kdee!17o$u3{(p%-3KCkH);bHC zR$x<%cec|L{>0D`|GDZMO4VW<*el*}8$*PT({mp|pn}K!H_+N3)>H49e3%GM#QOir zfdsn5@7|8RSx$`H#2*C-kruz_#F!P;S+N=Sb8n{}lXSVlCk)4z!C3IB~imq?2kIYbw_k{L=@G8^ei1{EZ@ z!yGNfg|w#><8K7IL|VkiA$nAePsPpi87hj2vRn zxo|bLtW~YG$MZ=&t1ZwJ$3u<=bcx?Z3yR-qMM^H>kAeiov#^-&J4!vBZBy%0=x$vM zJZ?~lRa%UDPij((zY*vXX;F?GVoSgF>W4_{S`Xujb5xM%GJT&GBX%$DoE%{{x^GY^I-FyNZ_O~^8*cbU9o_wy8OZlU*zz|g0*&|%4!+g)I(Y@l zEWCsHEf@cB(ry`eoxX0o?a@X?RFJ^;je+His33vY@bq6TwG{vP-G}cTn<`;xu@;cFuvk8{G%vEV4evX?ssR-w zW~`uG(SL?k_Toyvyj5AgV2D2-zum`(1iJLOy0@$hA9vZGPYy0;L#-%yOnyPr$_68rGgClhtYLf7K6O<8P> zi>$+XQS#ni$cqa#U@;159W&)D@JXRygH8NJ!ZW$giou=9OE+C z;g)X<&OLtlIhMK2>}%#V>WLRurbyG(P@bB%mH`zcrlyx-nOT`E^`p2a<9z&x^tIqH zzI5et9f2;)Ej=p=SS~I4IF@(VpQOuGy=oO$@SYpY@5^gl4lOL&*-n$DEeYkp|IX1V zNbD+Dlm)N5#on%cMJ1nl$tc;+595_O)X@>>+L3+3FLv7>WR|qBX^sX>yQ?HV^0b zN3AfRg2a_Q^d$k?JM7%JXB4C5P?PcA%LtxyN?}N#3wM~OH>!X@1&JlAeYF@zyUwB* zpPzI$DxQ)2jn^|q_)x!Q zMpTf%vmM<@UE$4b6Y9hr98s5ttn1Ij75&0G*#?mo*Cis|I6CJF*=zkfim^1kD$hH2 z0E;Tzm~Wo#$sBF=uu2OojT5^!V6hu_v7d+X7$*#F&w_iTvD(+f%wXttPI9e#P|oxn z%)9nnZ*shPg_WLP!uU`9ZKljR=UJO)C5-Rca+Bli>+DXJxU>Cwa+x?T>$n8dQwF?6|DtO#h z#XD#*f(I2NV))!AQr0DB!_&ombp*P6SC!CW_>K}!eo`%8N+W((W0gks;iw>SYC=UV z#=L8S_)*-Fhn!C_%?azKBhXbN`c1MZ$ISc%iKv_QOFG$dxxuAXca91Y=iARn5w-AZ zWFaC37cb1?yA73pz3ikT&^7*8VLuVW-rtspA;$~w1YXa_SgEA>WlB?y3KABb z3bELOSD8zQh_UHWd0xeRsoZXN3mt(jeXi~%Rp-t-@79|;pdrUm)Nx=t7A$RJfsZY6 zjVRx8u6$COWl{sr1{`yO#Fby2S?rHZY|Csh20g#H@(~#q>PL^@Is#oE3?cNDnDxw! zz7Rs!B`4juU-b*@$Rh*4ytfaFEf&MZ&doNoY&MGphtLk}XTBTs#7$ohZnZ4lv}Xz9 zs35WR)^HZP@CaMfL_7ruu&c?>cT4cgoG9rCbYc1EU+NMs?&ons-rvrb)>vB87<9tPFX5dtcoQhs5zU)iSoS3TBjw7I*XHNL53{9*>gfn{ zVU1W=G@nwNUuknpUfI2#uC9<6?YE3&?%Bas?D#@4&g}B!m5aBKYf1HW1iG-^Xdkcj zq1^uWrMPd;r%FMOL$sMX=(mMYq{U1v(oahC(0-@;kJN%lppu&qbPlmBt2Ot!J|%9( zKu3;*NdIx+S;E2s2~=_uf6gWJgqL~r#*R$A}w4Zjpfr5 zsN^ODEoy;QH~9lYv?m4@=NXW z$H$cKq2O^bhhe89wH!7ZP=E-vRVe-Eyku_kd$fWI64T#|(c+zm6!TT*8J z9e!Okqk;tHmfj67L%R)4wNz?&Y|;_v8XZI5oTNH*9xm2QlxjSgpI`509Q?{fK?MnY z$?t!Q;CaGc$H_kvjb06BYUOAXS5?;r;&%~4{7%mYa}j?OtOaaMIuF*I$lnHEi|h1# zu!J%2xOtz@T~&&)_*Zp`v2&aiUok3hZ=Kz=4~s2*p_i1s8SX}r7Bwx>2iDS8=ID3R zI`QMBhV$%B1R6;KpDrU zQH^L6FGyidCtro|Zkz=dM8X*HC;S(p^3Xc`Hv|Sa4L3IFvm^qf5>g?^>=N!g;ysL2-M3xatUW=}Z1&kF^@gwmkQyyzlTT*TI}_)vYLaRDAWDx z?nHF-e6QRZHqjhp*^8rs#8{{H+SC3tUlAkzV6sy8(T6RIl*=KZYGb1lZbLn6kxE0$8leXbr)o`iE$auXs(4$-#RSM%n#OFgzbMd%1Y z|8ZdsX}9Sraq`AJ&6E`mVH_1C0@Jr^^nzU?SG_ZPDrbi5^jLaiqK-h9zT{(?4UtO6 z?T$;XHc|ceWu{-h!x#N}^sQhNY0(}cy?M8_)^{7FRiGGuBhV$%;z&8fw0329{F@JP z_ZqiWQ9;8LVUhfsRaTs*^)w?YNL)EqM~iW^hp?>x8e92F!i5B|{Ff7NjDcIMv*bcwWx zkwXltkzvffTgM}AWduhBiP&9 zs31{ke{y{>9{(93&b5Cd&?V9$MhbvgLcU4r72cf8>w=u*z zEob@Y$zg{JHipctt63_)$2!-w(tfWz_c=Z7E8qK}aXS&Ui9iL3;g*&fy}RE7BFf%5 zA?2&*#WNR_(GlqSvF~r@*kMUA#3`Yh>2&Mc}*gk zecmr2fv!1w-f1!N^|he?QE5cH@b%&@M4*C1yFSmg7<+I0rWlux`SNE(Os{HZKmuJJ zVL36j|thO zpn^od(Dzz7ZuTrj#HC{ABqt)Cx|LRuK$qn;ORdd2OejvoSt3LWTDg~2Q9&YUK_0Em zi&=^F40)DYO53QHEDiS75$MY9k<;G{lS&X#m56vEqKH5RiQS!YdgU!iJQJqAYyPkA z>Iii0cvDC#$KF^`4(hw+|N5?q3K9z|*=yx^UQ?8#$ej~Pek%E`1!Z&ux~5(&uEqH5 zB39N^A>uc+;9Vk6LE?6FDJ_PxEZQUSjI;WUh+3cbD@dR#>0D_oM)G&jujUd_j&j9` zKn01wF-}^HfmxzoZ93+wmLZ}?RXZ68boG0h6XQdw7#~QWg2aL)IWZ=m7V7{yT(6{- zpcuPDc1cK}YqMhstsL7dJ`mBJh@VvQ$3&olM62gHb(Q)$9Wca{J8u5K1Z0_R71 zH{(Pk-yHWVu8wm}^Y-;Kwb8jY;Stl)1Aj7+F4sIt`@KoU{L~(+h*PR(#$=I(Xh7I#Z2NT5rsh7hx8&MJ#FHNv^iHLG~vx1|(R{tr>@ z^M6FAOLEGjgOU7lL=$tHd$U;V($%bN-_xv#GD|z|+fV5%A;wzki6w8J6m6cg(sVCo zBHzEKkAeicZX2i^n^v)-O-~Tv`FxK_e6zUrJKDtz6eM0up*2NBFGy3fi7IV zMf=G-oy13Edr84(j+jT(_SEvb)%F{EnXk5%wsg&Bh>EJMt?`;&BOmp|wcon%`{QhR zXsxa0^e!G+Ew(=UjdiM6OG^h-&SyZHcFg)<%S%gvJkhF?xx=WMTH9v*{?0nPxoPQP zW%3!2rqylUK2pk;FkWE7TzSwg7p=!-SN_Rnb#c|wPm1OBE-9> zMjUB;)2vhAxP}=OwHOJDGKh#JLZTS&auMi?kE)=>ShV^E5rtk|H44Jl!$Cm>i9t4w zT8#YcG!fTdUNtr#qEapbU6+z_X6ojR&J&UM=rAKEqT#2S3Mxo^E^n{JxYY9)5ikB7 zrp2h2i$K@t*uq*lPJB2;#4(?q1{1ZQb-0g$3KBSi>B(rFo(9oNs^lWjh4TuX2m3n6 zmA`vS&Uq%QI3{pJZ7yW1_4eIk#frw5DXWabn)j1Jk4#WeK_bYih&EPkTobD%2l^D} z@5^{e&)@i~HD6TG6!YfutpBvK+Hq@`j<8A1%4)ycZTy#r!_sN#%W7}w9_dv^QVl5Wrj;;t#{;&sP&MtiGb1lBtPy&T<+m5V z|Jz$SFrc=su8{cEq^d?Ae<9Xc_MqAwRrdeZF1oPZXbjQ}H=Aa-7Bs`*OxAm!r*_h&O-$(@&Bv!1brP0sFzN1{(-|WjP?pP!3+`L^!pbKk+cHloc zfS;=GEmf&0 zGUNP;v$k%wv#?oX$;ZFw!WRW`1r;Pxhu6^N`Dqg#iS{6(ArbR(5jq#lcJyA6)g7rR z5hXTmS8(P;VslJoEk@_SOB5rJh~7ke-=HVZg|i*)**|Zy6hcIGyZZ_%NDS>?MvD>p z^Ekz5Nkj)C>g6KPg|i(!cRpT6YEQ)Y@m4A-NYveBuf5J(Vft z&wqTd;p>hsS5QF$`!wzNPK5PG8@@3Yfi7%+x{I^Mjav}$u%o4l3KIIcq!$r3L_Ekv zpbOidR!z3~EtMo<_h%0k6(n#zrhlnKe5R5g%tfFJ+n>&Z0SlxrMARM9T15p3oR8^k z@(v56OGLEDMW74YpYG7hGmYb^1)Bu~s;D4==PMc?L`)+>%0-|Hdkn>R8ptiTcuRBg zTJrqfHMDuDcQGf!k*_&()YZ^ZhD9@T=KMYe@y19I-!Q&wg}2nZ?k3}Whny?O(I@S6 zSCiP^=&QPO-Wz*-4dbp|@=I&qJ8Rbl(a~iL1@4yB(xw^}3`+}F)~+(*Cpl4!6-0y( zF@*?JkeD~OoJItV6YrRm+LtVOH4fxO6VB=gbR{>es*T!BW6Kj^NrXQU<^yLXRFD|b zqJl=$$Q0jQ3QQ=%M~B<;j{eRZ33U1Gs-~SErzTbSkC;eATOv?FBBP(PMqKP!m57~> zUHF0`-ja(W(-G*}Zbw(IbpEJpFW!~eM8v=L-jW{?s31}6fU`y{O%pL(|Mix-QSH9@ zvE6`I-hJogT#;woDv;|cygLzHiMalAyG}u3UAvrr02yK8`%(7mZFw!K-JNTu81cFt zU3l)IuYj+!eNRj#q9+lbN1rmH zf<&bPIscX}%o8#0939UGScgi%Yo6!`bm1tVwI7M0eE2hODbKX}5-Lcnos)BoQfG&V z@n}vB>HhmLzW+_A5o^?Of6jl7Dv1sTtOc5{hGS#z(fLxN~=Xf+ZhZ zDv+;-Ahu8f6Dpune}aX)2+*N1iG*m=*<@* zqKT+Y1S&}AXP#UD z#2Ur93u}S)jUwVP5vQu3Gope7&STWuBOXbjc3&s0m5@Le)`EqFUF0Jvgb4fOwGt{w z;H*Tu+}y0q7f|g+&H1Dw(1o=?&%B6ON5n`XP(cFcG1}d@L@VxoVzG2DyZ}c6U3gxh z{o9u{<;~)}rIb)7-5Cc7oX2RbLH!c6Yjz2#*w)s3;HK9sxEZY}>d?f1vsy9B*YrKF zMXX(wCe#y;9xcwp=?;tkP=AgJ5;#B7n$&Okcxc~e(&KgYbOgG%(<>I+C7QKLYD6){ z*U6T`=}ytQG{w0fj6vJ+R6L0XG-nviv5!sIK zYxiD41&N7OUNXmfv5f8FM3lX-S*l9+$i8~qkx)Sb&v5jOT;p)5WsM=+W6owBfvyH` zU$EHyt60~?62h|c*^Z{- z9W1*a1^70y@YUSh0CV}oPY3oLf8%8plTLB#i3 z7Tomj0K?qH|EQ?Un019^h9he-^J953YwHMf_3g8VIR-pt&x7g{ zvGIvN&+1af)a7&;6%{0Q@{KIG&MW4ZU7Lv7wAR4m;dn#uj@BwFNIYH;qvdK$V;3Uw z-s{Fk&i}@S4a}n>(ACeCzLxglEi3lDCJ{w<4dlP37c|{ja#cYE3GYhFS@7xi%xZwR zl35-*hQBkPVQYG?RZu|!?~z+rY->G^H!Eio&%&1K2y|)b|M%{BxgwFgM|?GT(B-{G zycdr5^zlwQJ#jZj@=t^4o1``B8&N?*OH-c~&k$%2)2s;Y@W?11ADg6$fp^l8rgtr8 zMDXi;wj4e-(})TZc#oWRH(nOWy)S3Q-7Y#rN1#ij#e3;F_uG#Qi{vTyznikIMHx{k zqR?IW6}Q>GrvK`mh0wpE@{zonjV!NzzeyJZiR$fUv*0q1S%W}v$E$621dp_PFQ4DL zOGlvVU7fis(>9w~osT18@B46G@!NgV9`8I7DoA{7M(Zq#zF^%f4iUjE!g&wh1%4fx zJLw2?8P?9F_tjpqOP%%*(V*@q9_@21jz{j6GH0)0!NY&EjI{x*^tf1-nfjX@d)=Ff z-vuFlr?&xHjp07^TN)Ja^&0UeU2y|f`(%uRFp8UjUA9cT@ zhb~vu`cU2*J!VIXwP794Y}88b^t~Fz7(&EbBB~RC3KD6pS8KGnSydt~F07*wHvKyq zkU&?A*Cs8-cjwQ3G$#^~e~gdXt9M5ODoE7ZlM|zC^j9Kw59%Oi$dUX=YHj1&(|1f~ z!|V(NM)YCX=O{hK&fwXmKdZF(wyDr~JA>J;KjrFBP#ft*{t3L>vEm96=&GB1$5hD4 z&XBS-mw@nKM=Q9kc)|8zePwCc{hL-V-SYqHE)9Y52hP-(6!&Fq3j3w8ge1j-pZ%)f1VP0#7wTDBo#-4nB^*r*6Tvi*HCmbUj2DhHKz z!^&W$FL~4H{p3B-|L~CN7Ah)8=wqzDU^G3;_=h)}nXMp!uAr57Ozxd+3|4JLuI8;i zXo~JwgBNw;Dk?}|nP@-#e-9h_-|3`wD^*zCoa`b8U*F9}o(yE0;#}m|v%A@_=Ygzr z=NfWw@@7{2#t4d$cHxGyKG{>1Mwd`gK_YBV4LNqz3Re2)EF#8CsHm#_%c!q^ep6iD z)RZ$@$FpliI)YTUqI4Bia4-?sDeH{VeNkXCiK;I;x>V%Bu@v@~EgF5k1O7 zcHFX^m1{7Dh+z&b)ES$fEBVf3DoCI!=Cix(_~if#9U4ePtZR@O-7QtA8lfntAYpr` zmYi99H|xJ5l!%w*d#UesY*mhC9MBQy3Ry(CvN^;at`faH(_^B#sk3Zun=ep71qnA- zPdPI+julOvOvI6qk_cl zL=QQ3y3D+jdJ_?~K3tvt-BvP{TrN*=uPw*+*u=^{UBDJt)s`Kzx3ITU7qNwtDObmK zv)wMUiAa4oNez|?DM4$CDSj1e%bDw^vjs0UGyFoc`?1<`aMfrw>cDCuCfd_3H$BE0 z=MD%nqJl*KJw#kt!Mwf;;uw!qzqD9rOrK+AMgm=!LpmG$6QLg6^1xWpJ;8tqmI;qU z-(6}wiS~fJVopEhr=Wtw^CV9>_We2*`E@Sks=9fcTH>3hQvG8u9f2;a5&9BM{vh>U zsRPPOkBhpxLZbgz>Q~2Cv-FXoc6~myREM>>q*xbzp(D^`Gpv>z{Cx_$F=zu3d|+v{ zW}vOQvAm;-3K9jDQg0s?%ch+cW3a*50%}ZL5p_}P@;U-tg{ynWnR%u#AA0wP*3f6n zQ?3TNs4LChDk?}U?o?BDti6)8_YpCM3{O^^oSoGZFFbVwy1Ev0mlNzGSwP2iL|EEP zk-{$4R$Ioosi+|FrDRPxIB^jRD7T1+WdVhhHbq_4(2oWkfv#b`HRXg25p3PnwL}=( z98^j@lhoa5MO2)9`tEm?GcDuT!bbtRnTYnYoY7c0p3z=CdbEIwGY%3?V_ap&V|$oo zOEF)?CNEa1jB2K?+FV3OpbO_pddFmCtoik=0M#+5sBRWT;(+8T2anpr`Z{)@82=`3 zl^@RPs5UB7L`R?tXK-4VF=v#TfAMCz%X!fFY7@2XW0|EJ`!Jjh^yB>f`blbfQhRB{ zys9QtkidC`?jbAT>K&VWQj5J;WhBsrqlOE0?mVDeC(jh=W1qqxV>3n{AqPin8 z!CYZf8y$fz99^_~l4GD6|0r2;_ByAaf&|X~XZ-I;jmsCrH+uEr(n33TD;qHECx^Ofh;Os!nrSw#g2eZSfexmX#T z-%kw-(i7;y(M79X!q%Jndo)$u3RF^2K|+5%KXkmH*}SyAn(6GRBhZE8k|%4!NVnFrI2hF^XH(Q8(nRqh2UvBWG<`tsTo}>6WC< z4ms^nXzThUY$tk3M#M}aCKR!eQ9%ORhE{BGA9dPfAN9-Gz2?+)0C0BKnrH>k8F-t)OiMi`$Y4qg8awb}(N5q)_ z4+33%KTOeb8&#JjOiV6~qLaenIji1gZqCOF#9LsVM=qkA*j}~K+MHCTB-Uce+L`=R) zJFM6w$99$E+e^6cO`?7@3Pem-REB6fuHVI4vjQw%Okto#>-R-}6yXK_bX; zNIel_La#MM%u)T#1Bf`1i$GUF-m9L7ar<&K5jEp)8kYU^QGdVmQBgtS=I-_NMT{QB zM2r?Ula2SHB=ve<_y4Pa;M2vv+#YA+w{K8m$xsSTLsJDu)U$Gnf#Bp0VuQ2I} z@Ruzm=WHJ}{+7Fn3KCVKQ#5*tQIw?Wrrgv#+jt&QV%zy z8hH4Zf&{vrY#6NNs==K4MEDP?&EK~3QTq=rr?`Bhdy|xt^1nYOpBuegbFFlLnd}|A zRQuhn$(0nEcZqmJL@y#xLBe_1{G1qL(}}p%#+gTd_f|ck_vi?8xn7u{#W){rpj@rE z;>g46_^Hq0cPgkLp)W_Z#rC|DRUNh6yjwZ~UC-W))natm(2!!3xcXgE3;U>UH?34u zkidGQ|55dQ_&%CNThEU&<2@O?4}*0`cOZ$FNJLE{P(cFgjqVQq{OUo^d3inKkhtWc zRwl8V&9e5-4F2yz>WlQ~;{O^Fw$#>s5B@3k#X$lUB)&P%Nfz`4&l9Q7q96WvADR{9R>+>9hty1SDOEC!`}&1kl6FOlSU`X zUvt$i66h+uDo7)ab{9KSV6LW5%*jdnDkfb{N}l!Ah$hq2Tm&i@@9!~?Kv$)p@>-0b zqN%xJpn}A;sP-ChxU|?q2+M&4x+)%eto4#fV^8HGP(cDmvpxnA=<0tfLW>b|D|ZZ3 zkofz|aPZ_{KQW%yPKq@Y-M2xTeH`wrHyo(zuf>oe;&YV)6(p|ADyc)7l8^AHba~=LLR^Fe-P+uG{9RU zn%zmvMWBKNjsks~qk@FD(V)e6G<`*`7)YQC$BI4%Do7+ppSvq2}t0T!QTmV)tVcp%@PC8$+>cc3KCd8eGDYfl~!$u7Gvzlq+BsjL886Y z3+=k3=luh@2&~1KOXg@bTBP#w|DV@BNZ?$lF9+rXU4PFNDoEh$O7|HaMXGP6eDdhf z^`-3nF5Be2*qM1$pJ)(iX?80wksdVJP8Pqr9v9!iLIRcC#Ki@zymAQYiE(r-s?xQn z$I$s{rpxnNdC3EvjK}2TCNrg{(bq54#b-@-iMX*`>_&n_jH{h|jg%)H${0nwKgZpi znn#{Mzn^X)z5s>Ub~5JfMN_(kk~)wlW8UqQ?hass30M}ap_5f_}Z>M z1`_D1diJAf0TDZPl*>h6OD!#N!}OC})f>4Pv1Ro!P(h;HrMIS13tM@04G}wRV9Aj{ z7v@k;pn^n9m#?Ng6l2TkD!F1Hfv&%o-1Vf5e3eRj=0zDJ_R8~f^UG=Ud%l3&y#y5` zu*c}jfdsmq1?QDhC`S6|+%Zr=0{gT+295(9A4~V#HAT{xFF7_QXNLQKM=dHy;F#CP zKmuJ?*j>{Iig956a*8q8I-j?g8Jd1gJlwO2hg@7}e$UK`BfO}(YfYt<9PuPq%87k99f-O2T{Z4E<1m+JDjxNQlA7gRP}o?|Cl)9;Rz z3sa0!hnYsiRV#8B6(qv8)RZmaw|6&*b`2^A!;d=?f-m1p`2qI)_2L?qC4bH0O|OfjUr=@er?gLWE`&*oDiDoE(- z3P)6zUPa`!RPV{PMjGw}7nN62+O%Pq!LZp{4xzNAGBQ`2qk=@j84I~K)o$sM9{+~3pZ&(<2z23C(T^xpkeD8R$5euHWwT;- zt{6z5OFss&mwf11QyxwE?cOk5HwIlUxXW|t_iGm7jYG9+sl%f0hCMrR7!@3~*ehuj z>%j*aakFijjzE{?IS)C8VyrY=pcvmT>`xLgn!Qdsj0zGdpKHnSMA%i&Bm&EU1iGHj zt|f0E;>`>379A4U!)u-@D^I2x{amV@0edBVsp;b`ZD#0yWL`Z~kcjSAPEIwgI`Gsi z*1cBz7O0Kd`Ke11kw6#DW3(PFppF(};*%s_RFD`^p{o3aX1m)lZWO~Y)lDOYEi9{> z6VZjU9X&TlO;H@DI;gu1O!tf)pS}+p;U;_A4`xe#Zl$_vS2IE#Q{=eWx7bp1C%@Wq zLPihPIyzam_9Lq`{oZOUn|OaO5rJxi>TcL#PVP6aIn`56_*R`=s4U*8E^;SAUA|$N+4Amq9f7U~W{PL7#X?)2AY$J6 z2(?wmvF3aySD8^kqMV1P+|Tj1-{3>yjPvSqg!+%&2=n|G^K=BdcK4tdZkFt3Uh!^t ziK3C}mYFrpjq0s5qk=^LceUhx+jsgYl|^rNr>_X^{m0!rtKuFVfv)2Yl&c9be$Ovo zC8BFkq&lyuNlrgF*Nh4h1J2fx(+gMk3+;882m0ecFSwZ_i|rr9eBC`h0S$0L0)M1E*?xq3hu>M>m1 zyqQ*Pl&Q$#d#A8VHEYP;r<_@r(kbk1yP9&p(;xg!?h@~YpKL!!asRSI={9|^iV6}T zM_lE8#kFj>H9-<1iG?{QjD}se(xTOUed^8n(}l`TV-CA(JCrP zgiUpo(|7guo4@}Q5wGk{%B;DgQrs?FN1*HYn;LR@U1z^J`9!~REf}e^FSfw^x=pBx z3KBS@((@?KlFFvg?&e9ElXV2Ta0aJ!WGn3z@3!UD)Q?Sd>tt|k%*<*vW$#`S*}i9D zy^md+C*~4PrB#QA{yG9(`kBG=%Q$7rghHxE+tw;7NDSOet$D5udm1m+A#Xa=#T*%B zqjs$vq$AM9^Sj9j8=El;=lw*~hKD6kQ4G&WNiziP_7bO9-Et0OjkY|2I&ZN zVXvfpoktc_uMV|R`@M6~t-r#RRrS|<$ll+Eur(XDQ;bf9Pb*IcI;cI`_^PNNfwfN8 z)JM~mX`gM1iBpRQVgFKEb7JqieddeO?kM;Lak=mR7C{|taV!Vnw+k5@y$?t z^9JY$bS=E>E_(-6VyE22YV}HXr2z31}?=Gj0uIT6cOkC+V+v00 zy7Z>1Y@G2`w>}Y99`5Q$XXn);Xh)!3RF0&+Uh0y0uaphc>rp`h`!ww+SKM3edG@Tb zxIrl$fv&|bYRTT*pOwEX-cSe%@lxO2JEzPymr_wd0{b-Gzbfplmj1LyslzJj2y|7X z>qPIgD$Js#N-;e9d#UBG?^OyVS5#3!0{b+5gU8NW4X-;#3EbeSBhaDWTD&p`#`)+7bX~dZA*Vm6>^I#{T>I=u@KQ@Sy*4*{ z?4zQB1omlKNq*ByjqX{}eDQf*9f2;qo~Ji*`*L;cwZ-zS53VXINMN6){bb%wQk&1S zQ0(?PDY*6+*X-goD!rpPWPqAHF-B3d*D0tVfqj~K;+p}g--xNoN$M#`pbM{L=}p$D z1Jteurzusd#Ve>Ffqj~0>bC>b`kz9SB1ckm1iJ7_mcDiqH9%cauDcRVp8!Dx3GCB! zuB90D3VJGY3Y^dp=)x;mI^(PypkCNeTlwAeq=E_(*r(~M(^OZbBC^d%WzXvfbm5gO z?a>%FKrL^wP>L11prC>T_Gx-2?!^E#tN2Ru%rRGW1iJ7_mf9nAfExZ|rTIm^33FT1omm#Kk;T;x#RN5Jic=^ zY2z(xlVjW}b}7KcIB@=+6tO2<{gp1pll!t$#Cx9m){DJyo2_0fPyP|cd!O-^P(i}l ze}_hVe=LZq`Bk$W?HlED(^0DWF|58g?zPC8#`J=T^~G`BvuYT}$V#$EcdjJ%_;vh` za3=zlEoqOF1zquks}YYxD}?GVRkqZlT_fhb)Wtx;v5|F(*n{rNNU>8oC*mLx^KudB z^7xg%z92Rg7yHmxzWPwH8fMGmrucGHkZ9>&JVoq5*XfRkQJsj4p|<>7E&^S*#*M5e zhzDatj22mWRl|EP>2kF$92F!!pDdLk_L*4zL&RuKgdGtXxd?ROF6}fv&doC4rCgQh zMBnfGxlgMT=MJvM?$xbKj;o@XdtFzf)s+OT92L_>YG>TIkcse@4^Hffv&E3_iE)R{Yem6qwLM5tbyEN#bsSj+*Hrj6gxPU z<=v_8m9#Tnz+Jg$ivaG^)PZB~Lt>+yjVUwlN;dAfcna`-o3;5Yy{jL)$WBL~3u}Z% zRG)BjOt(7R`G>czu8_EulFyWx9>XdZ7v;!*bDp_Q=?c8lndUkIU084Qg^J3(&1Y$s zf}~+?y55eX2>UTzU;RhyAp#X7utw-Dp*-%&(UO7u&c}*ZFB^>upiUA8M|gF{fOv51S&{if204k#nvi6mU~IJqekcmbYah@ zGw0AviZsGnnl)e~M+FIePpn;fu{5A+VtxDVrrjy5a1cgp}z&Bl+4Z#Z6)-@!N-8jVH^#ND=8>2V9LmO1(&l zy}6LhO%TNJ?p4jd>d^k#v5_2=*E`;)I8I!|^6Yap;*sbLo707y(Q2NCO|XGJSX#uFDm=uuK*fHnk5M#~o>Gi2WGBQ9)vT)lw$MtZ3$8 z5;2sh-hz0sL+$0UnW{`Ui>}j zSh3Y|#~=MTDoAXIkI>qDOL;LevMupZ21m534XrYN_F z9A^A|E`Xzg#2)iBQ}E~wOd2j?gi1H0UY0)Q?`;O?2z2e~)yw4lyAwNME53Y^TBQ=d z*64Qf$uiCO^l`II!7*#uM=N(@R)HC&ewXg}ty$=1M4HxYJJCL2-P8RXE(UQ_kQhTT z1f8nINE)4=&lq{Ej^ReIjzE__SHEAm^NBN>vTxUzE>{6p=bJJsuVF`K>T^i#aom@e zd{D|TzOEO?oFH-X=wg$&W>{n`zMEk`vJStS^_aC?;iDtaRla|WR*ucL1hFy1pMPk# z&}2WQEJp>2(8X)C7&AWABI2LP0sM63*>SE_9drb`M7rOB+kQF3`jf*Y?@}ub=EoD% z;KP*y5pi3O7U(2l`x}oIipC6-Y3%cT1ndP$uzE#XHTwe&(oe2-<`ph%vzv#R;hjEWyb5Xgv-FVfp(lJeW;HqCohAW5Y>vfJ1JS^a% z_sz23%(ylNSHOt0cTzR>TU!-2{q0qy`=jyol9tom+7~CaI$0CqWW@TxdZT-mQIUK_ z;hu&XQKQUwti3BO<%C{!m^`$UZcQ2eD=ImTPi(TxB;Q%4V6Kp;e$axRu{LGy-^J>- zL4$(%2*-K`n~K&d66nJ9Ui9Wmcn}XBT-jh;ZKI-s#5Lu+R=$PWS?xyjYh~EK4)Uvi zd#hMNtZA$f+LJskMln3^>KC4j-(dlA91Y%vq&3 zd45O9Yv#7N`jaN<2togGVU5tHq&5N1)68)=!i7lm7G_Uhy8wH>*fqBrD47Ck-~Dg2djF zcT5SfoOP=$)~bc3Pvj-51S&~qCmB(xUgxGM!MQuTRVdnkN20qp`N!}RZ`UfZ{X%sy zkQlHk)09wi06RHTtW~?RVkl4c{Z~1dI#NfVEC1PVChujTEN=zz&Ej%#LwS?aN0feN zM;cK<;#{c=YR&QNT%TnW<80d=e6{>lsWGvSjzCvpmk%b#HnZ8gaz!bIQ>QlkUBlAq zDYd&16(l^Czc$6rUd+}X5rp;g?!0o#@5dB4GbOgG5zq~ZXMlEFyPera4HfX@V ztaMSGKQuO?g2X?e$4tRrmNV-H3n<2EpE}%Of{%L0-&aSVYstF@rp!01S>jUho$7mc zOYp}2?bKvm$%qOPP2XQJIr?v7rrP3dCx1uUL0K}YPwH1OqJl)bj6{>;jP-2%JF))i z;O}DG)H^_Z5nfzJpeuV`oGJM77S`Whyl+rs)Gt?R6h~NO}`ILOn5KgDzJ{$kA&|j>f(0=;A39!!}KkTo(^ht!p(+ z#$%z&bYzw3|KaPrC5jePRKKl z3{)TOny(|!wPog7vuWdY_NtVqs~3h7^1d?z)t%i`BPvK>P1DN5&tJ*8TzjewBH!r< zbdBneL@l_P&2BoCVoVBfCzh{i+=&qkojvWse+h~sNTUfs(bsaQXUt5@6o zP*6c4qRKfK*M23SPJR{y&{NF~a*BS&UJ(?<-Qp(pw zYBf|bySLcE_Khhb#hl!!ZO2v@M1^4)ic5We#pg>8UCCeAuQc0UC$M5=^tC|OkcVa{ zHADQBFe0#ikl1@=yG939aHSYECQei2R{_eS`;j^VU0oUZ z@8N2VqE0F*NMLJH{~#jy{~*xyxT2zEN$b}luWEjBR&M8VQpeP5r=o%c);hht{Lxw2 zOT^M_1iGH(SF}7H)?3tW(K+Rn7DZM(})TZ`FuW^O*^NtZBgQQ{=DWO z9viz_S@&hIf&{vvy@?2(%x2~j<#@Gk826}~qPXXHX2!ijT0e&iviplW)LE;v-Da?& zg2epXS>`?7y;=LaVrF^Z@F>30ZiezLGR}eoy7cqiV?)aG$)DdyS5JoMvLwR&qdDME z3>((3uv9JXgLzM@SQZi^%HeUaJ^xa1opt3AM->$$h8_Asv)4#wJu2$z?unlKa?hU1 z6YsMM66o4|f@YR2hp^Pbf|yb)zdYiBlhUY0w2E21cl0^4ZGSv#AK@xh%5})>K5`?Q z-%DisgSJ)VUOoJj%z;rVDoEVualvdFwVE{v6vUz}b7fo5UBxb@qmDqAdy`9M_dM&_ zG@Ixj-)pP#z&1XrI=q59OuB3~wcNzwJGe-BDy5s_y%SlT$1akdc=SG5zSYY^t$Db% ziV70r4j(p~+_$k5N3mBglIF^7OWapF8#?L;bYc1EIe?DW4R?vUFXv+3`-%=4be-6zgnx%2vy%Ddp@3Mxoo52p4g z)QLN$K2+RCPtp_qhY;HONUD&czyCutU$Dr!!UXOjcHbfqCM=VmF7>+ z1**f(Cg=!sVTRIdDSsLMz#O9fKJTyKF$!J!qe0wB4_>lcs9Jm82`ef{U_5$aE5*p4 zysV<$nP|2kfi8WUPng({7iwKd^;1bc4El z_g1s=vqoMO6(q1VX-vFRhYy~!L3vi8h>k!P_8Ws?WY=ACj?+u5&Vz@lICA1V0rQPo za6@7K-2SxkH8W6`iAdm#gT4#!-oRHknW#9%w$Tyj!aXz?sx2+Y6XTr~kBK2FDoEfA z$6#po(}$m3;H4CKUt33@OJDN$k^Wp+SHie(W}t2?K?09m^z8Y)R{YRXe`WC6ygCA1 zIId9tI7-*@pIWTdE__u`K|+5V>^^KTub4hbnS6Y{{)hke1~ z9H;YfgYGWYX6BEpHl^8qCK>^>YU&b%&n605_kY`a3~w=zPleD|@AKo_3F=?PSj z82$Quus9#Ieit#y6nn4Kvd^QgKI*R`fi653H5lB8s7J(dB2Yo%@18e;#o251F7^~- z^pkw*hrBhE93NWh2z23jo52uE#PvKi6lWq(LBhJl7A$7^&SON3k88@R!%77xWBS)s zr)+m&B6>uyK`K((jS1KLmIeuFgW(eqH;5Qc1S&}AW27&NQmd2>PzHSTRCYe-s{QYl zj912y-@>)+m*YMdH{@ui#ZDgdfvy1zd_7dn^Eg0h*>Ht|3KIG2wAARc9-oPLc)Y6` zJa(f}Vse^}Kv#>mOp9^&!*?Q5y7yA|4h~SlYhP1PL8A07NsIA!;U7d4t8?PuvE@KCE=zdyqa&ch=6(o$0+OhaCiOg&DYa-sY z>!@0t?kl1DX6p!aHI3`QOc`6)ql+R&pF2O518JSq5zW3@_kL~6Z1qyu=0|Ug7tc|v z^-f})*&E}x)aFd|D5;8Q^Ib_tt>uS|SG^{5v(8y+WMVW(9-e93x;BKl7u>;y8Z(WT zTZS;v&ljI~Peg?CbF2AVw0iqy9tC?UjtuRNI%-7X5mCF{iAW>jDG{h3(X&yOHWE!g zo<+p0s|A&zAEVXHqiN3RG8S8Di9Y$31iey0F%1CFrO;>e6ve>VUl! zb^RR2D}A=lA)@4XCpAA2s36fR6+b>QmNUN1zLP8eJnJqAd}1 zi9iL3Ih|{2G32qLpHCm#NbN$z%8<>vevU58d^)2i!i|XgM4*C%K6gj8Z>##Oby9nK z71t5y!oEUJvA*+B?-pcg)vzB{RFJ^FLRUYMb}4=hBUHcDR~4*L%zUf`dh?!$VnoCe zfeI3s({z<{%^fARc!b*4Ym1IR7uEu;xlF`yBB~OB3KE#p^u9q!gL;aH`U~3W2y|gB z&^<^Z4iV9x2vm^3UO*$9oJ&0(6QOoL`N)a{x^Pr77@oD6p|t2YN{#UCt{bnAz>$ca zJ@*{LPdB@flGt#dyl^G;HK)5Qy~8x4woPM~*q&XUX??I{%oNJRDmzB;N~15D9j?sK z5$LMF#K>%kci7w#g4iF@jL*;UlywdB;HV(+Vsm3H#$G;2#5mD}H~RS(J2JbZjzHJn zgBvmTrMFqbZi1M2rwH$#yf>}wkCq%2Bq}TkV!72d|`Tr>iXBGOmyWok;z2aq7 ztFed?81q!AvTdwo==@$f0$ujeQGfDnqKMI;X&%+RrZa0kguZDC3KF>!#<2JXS6PQe zg2<8QpfdmV`m`3ihv*1&VNTPN%Wn=T#;G%cx|JQQ%XTE54Ec+h60WhUbwv#Ry`kb4 z*C^<5sc0R6F3fy`p=GI&s{bdNS+W6g*YVx8IcM{qHI1%cqP6X>$*qhbKgMYD#1bc4 zQ4C)qj(rJGg0m6ma+%dtBRZF?MTD2qO3nAhN$oedv5X25>*hylN9T-6t%;C{c=7o^ z33MG|?KNWmDlJB+qZ+$7LY3PXWK@vY`n``fBb%2lVuTXWpNOW}2z33t)J!9`^b928 zX%%0klYNZp*4s;3a=)&Y4SmYhHlpjvosQb?U)r`I!lSCMVj{wy2vm^3c=SBn20z7t zh$-H0jYyy?v*U0rMtF!=x#io{+SZ>D-YS6E=ni9}R zjTyC7LInvN`REB#nvG3e9-+pi4bhFeNF-FI5suC#7ETo9_|bWoS|xd#l9XiC5$M8E zn?A4BaIE?zXAQ-%!D}h$^$e{~9Po=Ys)_XND>^@$aB-Y*{j2F(wl543F^17v13nHN ztf%7LWK@v2zHv4)wOY%3vc?gSbJ|!n^}46>b%Q}hC1d7n=3a9>J8^D&wiqE#Ko`~mea$?rp<1nU8FlFGwGt{w zY}-l4uHK34c9M85BRXeUHLg=bwNJH)Is#q#+6|^`x1(%dL)nhmhuMy;X)w(9$*rDU z5TOoln<}A#1onA4^CIFR5ks>P=)&^RS}%!h)obsa)aBjrgW*fu5H;7!ROP|0`Z@w# zd&22D!}?9EOl5HmAmUU{HEP5Y<-_!yR#cGCUo}`c&r9w5arQy)IdxT~N=p`B;u8DX z^o{Xca%*Orbdjxe)L%iMYx-1G zl!^)x`l}x+gJvk(Vk@SlAJY@)dTe7Xe#JF5XOxJM;8Ra|*(^uUy0B;!6(pWs`g1-w zSR?w?o@~9~lTbTiOstMT*NdOkS^SdDbKMv#uAaSDk?~HkF2Q88SXb0 z#QavZWvN#{(DF;sIs#qeCJ~WhW2sF9v7yBw`Tb(2z;1O1tEeDR+p{z?CEa4dU*8b% zvU4tOv+raaR@bDWg2b?ag|)q!curhTT+pvLf07nsJ~X(gjzCvpzCtwLz0KZgS0FF> zbm2Wdt!JG({!&muqG97)T8!si#0a-q>BRd#{KO`&vr~~kS7cCbW=p=q@+yL;vV1%* z>wCw1rt)6Kf&{v>ZMrt_hv*$NLd|%+OsW2* zt2A|8C^IeD$h@748p{PY)n*wv2jnp}G5)!-y*8u_#h4Z_TK%{tK*`mwzHUzaDu~X> z>1zGQ)@6)1pQkfOB7749lvYHbf`mSX@%3No&|h&%!HOMpdxb7Hv9}a&NYSFSS9!0F zRdap|Q2Iw6kx)SbPCr@zgyQvEpyCC9a+&^ zS8|;7BTerc^lPNP-Q}S+-smHvg2eT=O_-^FBJ;l@zV#bBrMsG=_FE;k>Iw-7bm{l1 z^Q3A@UYb)^sOcx+>>ke(T>bxCzs^6)pJFW8H_6(va;%yoFw}}lL;&4gXu5-?M)>Q_ zEa<$pSpy~6HdZZQXs3&T#5Jde%vLdlMeXyY7^&SHRrf^^YS@n~9f2;a1$s^<@~d*@ zQx|nP-!GwpMD2}DSp1+QwxY6FOYg_of~s$NOSSK;pE?3vSZ{P3Jm#-F92~70rq8#= z`c>AxS8_XNL8D~`U7_WVSr6xe#?`Cn+bp!*+UQ0xJhtywy0sakp4w7aK?RAnP5hW` z=L2@^y$2C)Hv@C{aGTSa4cJ0kMjVIFTp zUe$NHBu(GZRc$`l?>se1|( z(h=ywJ*2yqlkO;?Q=ch@q((#1uM@5ITl~P`;Ig5&OKyszsBFDwg}j6;zOzS0%AT`5b%yKQ6CsofhY@9W&M#@=njOd;o4 z-#O)tHNxqRS?-(c(Q}s^xA7ba;t%3!_}1y?^;noLhV$SnMU1b z4U38xE0=hwdEL1Bqs(U=fvyo9LYV2uZC1B|h*9}TxLR<(lX|{wf)!m@4m~lWQ3tiP zM-FxK$}Lt@kidGQwMCYi)NyX_l!}md2^`00eTK;=ly)0(s!l9KN1!XMOLb=2d4wrl#c?n;-(@B0%X_6A zjftorfnyhCd$(vce?(cO$MoyE9)&#>YnocHdbIk$KBqFcNMQvPB(TrZRZiKY4$5y) z?!Qad5$Mvl;6+!HdZOlV<#W-G3Mxq8*hO=O!F|=Ck0X?N0~8&BuHqf2N3A=+9xoMj zC6}+Dwz*kBS?^j$MFj~QgJ~6yk)CS)4(UqJSZ^JHF5FwXt4~CbDP8$K##==N32Yl$ zll)~x)lfUP+9xWnjzAao8+u0gOGP!dT5h%3UwKtjkiZ*;0hcX=7;l&_ox33TC+K~JE9gx7OA zkD_DkRVHHO`yH*ep!!M}AX$(=m;P9b1S&`@%WS3{o$tsZMvLg?O6%$NDSw|Ht4=;h z*LUc+ebd*stBMr|(x)O`YCOw^vByZWXT4Gd>@pz4|u-U1D35?++mr zX|FoDEUwn9iIa*75?DU^7WTgo=n~r^hNzMMtgC+%B=mLFV?`jpRNsEbyWak4{nHbf z>B#~%Db>R6-kiY1%4mgE^Pg>sA-3sS-(<$;1v%{a*tnXSYr%9a`krs;Ol-UVjALS3 zqyLv!P{qijA}RzwSXfm>1&L>YQ<>@ZGFp*T)b8{5j6Yao2=0AJPoN9;mYxKx{odle zwW0DZXS5oVcOtX7XRx-D(sX-*IYxIa?^m?m_#L2pJR7Z|(rC*dtxqh=XJ+_M21Bjn zp_aor@~EBY{v9ev6dO!5%@a@7OQmvbn|sz0awWf7AXhsbfv)n26IuM|`|Rh-{Y2co zHqDasq^(-j!&yZIiG=-=SiHkywsnzMbNO}Z8T0x$dg>)t9u*ZNY80m4Md!7hItXI+ z{Q%3|gG1DQ=ME@HpzGV6Nz4}dn02@9B%)pCzGjI=(OX#o3MxqCHV@QB)Mh)j6XBkJ zn|Vj|7`5;lUmbz2dY)4>Vq9H8;41%)r>?R9_a3a}jkj#Yp%iU<>mr32iayu2qncdL zMxcU3&3(r-Vt4Yv|3aW^P`tfS#9omp))B_N@*HwV`>*!L>KdayJkYk=4J>Z#6LnqN z4jR{hW->^ig2b0HSsLNg1s30-skf%lz{j1P_A<#9ah@VD8 zp2(ApKm~~^4J&EH-$xw(3xTfX%Y8NC#fW#=vIG?*nwIg_h+4b9{ucsWi}L>|`P5kP z?E$P^RFF92^QQ%`Jk9to1iDracGLE1X(Ms0#Tcj{F>y&@jqv^|R>%K00$m&OJ88uB z5n@b40u?0wy$>RRuEP&qkoDPu*#n>$lr) z_U{BLNbn*vHTvSIXW7~u33TC@udiKHka*I3xfWycSMjwQ%qt|&g+~TjHR7PF#gg17 zrCwI7idiDI#oR~a66TwpKqWgN=s(#$xJ@hmGbLRbX)44Ms%&HN3um)uci$V=tlP}u zv*xh;X&;RBmZoSkOaC?Rs68@$hH&p!i{&}h`pVPy?9`N^gWnoYg>7S|t8}mbY9w)8xy|A!*ZlcXN1)4p-3~4G@okuIl{0J_vzj;vkg<($p_-SiGr*l-}TXhPk3RJ zkwBMzuMX|3&8PYK^1^d&N~j<4(ZyG ze+Qms_gucZd#a2XjTx9e4!`kxu zMMGp%kie0MR>vQ;KyG6k!FMH}w<3WqeXn&Wv`;SEJ%V>`c1gxj=DIvBIDXb$)-yhr zZd9TxMFsZB&OIV{-Ox)ijvq+mi@O*cKk*K;&k!TrEXz%~Z_9Rkf6@Iq0$n(+(7Mk3 zpUG*BoAZ7JPs*quF>zo%W-E4!bu1-DxQgeC@xqQZTEKdvu{}=?-p}gHvnF|RRFJ?L zF&O+i7UtXY<>!&>it7k;>DxoO=)#w8cI5ptOL0_?z#5?#1-$u)R6G96xqyy9*Q;67 z2Zx|ZEom!TzEu!C4&Ih|o9_H>r=~KF8Q33jj4>Fx?s4D&qndEv zzA3u#3JJ^wdUAQJ7hluZo(~8r!12G(g`=y%Fm7xmE@!=xH#RNGQ9%N8fo90HgL&5W z4B4-!la4?a9wq2Wr$@m&rr1S!#pZk*6(leh=$X4o^?7rCMDBIhK}Vnqk9M?@&f5BX z=A;Ai-HEw4Do9{1(0fJ8n)7?Vx5^dme(8>b=)$8mednigb3XlZf*g6ro}+>U<^p{c zv2Htl+%`v!IQ&_6JVzJKCFqIYjrI8}X`9q(b~%m;68gMa^SUVybg3+RoOjU?=+e)^ zk44Awrw;E^dgPuY;Ztn*bQ`|;M)$mcK;=J(|9L--&LC%9lG{8Fpia`8AH257Z0fX` z1v$48L;6RY#!97F%EY z&d(&Mmn_yP zsz}62ijldyq>Ks@YbP}}ix_UJ#7a8W^rc$K+g>WKBhV$kS);{hdA2AK4~eKsbv2R* zRFDWRzFv!Qs)8V%HcgZ>7e??!y=^kqsPn#BOnl8@zrBNmwLoXvM7$?r77?f*QEPTw zu%Hjr7VR;pRlMAEZUpx@{6t5f3u}SKT_VDWa3KN}BO<>FIefPjzE{bc1y16Eq|c8 zYD5GoNceW&sKqEaN~}EG=SL2n#H|Cw_|vRFJ?POka=g z|5je}&WW$+5uhW`g|$HM2@{b(#7ZJiK>|l2`c7QjMJ>kObfb2yrtGgsrA;O+OhVZ=p*kXm~C}$Fc#w~RnE7?YiYw%r%mhWCOPb*qnc z_r>$_N^5_P3KIHql$&!v%G|bFww)QGBhXd$@2zH2&r5WjOvG?{GhS)^$dS)?YROSS z0_%-d1bS84`qlZPtQPIABhXdv)i$#&>k^B*FJc`0E1&XwR8!upeNpaLV4c~$?JbtK zsjGC^uqo8u~B#?V#g$p_^RfiEQEr9m7OBtEbuX7?F4 z+1>YIuf{doBlp}^SH5B!pd--L@Zb`&&5*%nKNE!e>}S>mBgXO1;oBwWv&-qrYxkJ5 zpNoXO>qyvgv+3tu*08N0j>n&}E-gNaPhCG*Mg z+i#scBY7PQ;k|q+=m>P__i9LJAvx(mO`dN=b&d)Wwz#EcTi_j*q=<4Hd7NMQYOKar z`qb8qaM)|Hr%_&Y+$SA*?7%;>4!V*ff#ZsSK5SXv8g|5m*Z9#wN1zL9fv$xQuP$GC zZIy?u?9Wj_0&|*T>>4K*cE2Q-d)`e)pbKk(-tEhnDBqrXUhZGGJ4XcxeYQ6kA0wBk z=*&llH`Nj7($}u>qh!r!AItZ#E|RzZWV3DLeYU`*h_0_-FQ9XSk_kq>Fq)s*ZIqEf z*Lve5v$)3+5iaJt9oo7{6&%L!v1s<1 zR)`o+#+{Obo;Q*=fA7t4G&nbCf>~U%Ty?pKgtb6VU>`pv&&gRs&hf4fM+J#ZaTCnq z>gVm_BF6Pw335!Ug53DIo{m75zIJc%b@ImBPCRds#vBzS^yN62JX($$7sLnDs;DE- zh5d%+y9wTMMYoRJ-Q}l@3KCdv^!7ktVcy!ghCHb5pEF2NKjLf@uh!!AOPVcxc_Tpz$z%tS5#x>LB;h&xO#o2W@DoAu3T2#vtiHU0fd-g`j7iiwzc<~n*33Oq;(OdCE zJS8GG5$IaF%U#Rx)NchOJ+c1DI;pl}3|}~@j4s=e_>oau>uK)}h_lqaL}mAQ&+gMw5D~q`xX5^K(=@qAn&`nN+U1q-4g@`Ixa+jkfQWHXF1mXYNSsof(nPQQ zv?wp_)$*My<(za@G|Ba`jzAY)BR3c>5%GZP>JbsBAkn?#?}MV(9=~~AD`&`&@11yvHAqLG3p13CgFYE@4k9KJfeI2>Z!{)G%#ls=Blwbg zZ*-bpBh)aI44MudQN6cL&`SFT4^) zZ!dqY%okq@kZ1V>alCqlXY>K3>M_T@NDaGt<^JY-DK>V}a`;xrEOMKE>m(gc*mKk2HNJ?(jC*uEcO&wo-Rp?jnJ` zfY!5|Qk*w=9L(Jsu9fk>(52t2hQ+J%xx;<=u$?1hRFKg3sQm@~_~dwBZi?!wBhVFp zvL)r%N_MEN$X%sWQ=V%_0e;V8xQq%CPu*KH_ry5%@sS{Q7VXNXmHZ}8vs-EPni$SZ zE0-~2S9|G0!H&%IbRi2Mmt!dti~qcc)omw;J2Qv!8MC&^y}sEgs36gBS4S3qZUJ+? zEQplTb@;877bO1yp1QokK7koZ@4hZ>&o}wbklmKOmQg`srl84q|p%|Z&MC~qI!}#-g zRr%K;{d6se?R;r<8|L0Mj=3a@{?W5~S?;^0Ax}MA&WZ{Wm@x*!Ull6w!cXe)cM}Kc z2z0SfTEE6Vo^87*Vw8+_;m$K#^251Lm{CCjk0taiIzvdf)K z(1oKbtvRr-1b^2vFTZf6Fh>OmJnqtbH@G+-Fu{rUFX5&m(1l|@-Nz|*Rywm~JpbB4 zwcyB!V{n!GZM1t~V_u6pao1|Sl$~C8=ab{^Susm6GiH42!s6GjV7HD7qQu#Sa|oh)Nj&@~>zpLw85M9_dRPspyaxmTJ^iLRMw7nm$^&ID(rKLrm`)k{o!z)TV zf<|inN2f{$j`RSW5s@e{SNyLU5DG}C&$wL+J3*^SWrP??v{R9ygn(S z&8rhJj)*DQ2z0r&>lG)694iAfqH&cz7F3WpwI@=GF=ML8-GM}mp%?+#2y_KH zjn`sa9WRLURcmNO*0ou|s33uLNKXzB5lt~FU)2-nI@w^H7UTUu(SkLoJ%Xq`icx!@ zf`q<37Or&SYv`)MgB~>{B+&IRW`Z{QB&-rGxNc`FZp(7w$=~(k6^=yM3+UPNJ#BfP zw@&=R5NBPsBQbh&tQOQb+M_cOJ%~8zqbJa%@3nR> z!ufu>?_6c$TwSk4Vmqf8G`cQK6z#FTax`D^DnK4ObCZrh7mgV8?D-Z)?zkd?ciQ+t zcT~gPg>6Gm&;{n^`GgP3KG~0=sQ0|BodK&QBR-?+lHQ?qaL-H zdemF$QK%rH?@<3KA>mfqXGiznkYnL|GzM z5D}P-K$ojjP9us^Ef@?@?rGAo&(VB%OmP_%BywyD)W-HAzbg~*fQTbRtjb29>*kV@ z8nOPH*sC2Ot@%7Ed2Hi`@}kEyl2N@+``{&EtK~Z$rInnY@*?6U5i$|RCJkj&kihn* zZ>;}x9c5-LdGI8XETF4g6uRPtP{J#_@S z!m@g3C4V0(Vi<@Jwfn7=r-TX;I2)jnhmYi4ZurY*JNK1wT*8v)c>3op`~_Je#&;rW zQSKUvKm`e`b$YKTp&u`KF+g_za9Bs6Ytpr7t*;Du=uI)A`h@Y<9=qhT`##I4AhGZF zK#g#lBeEplgK~Tp#rWB>4#$4yGCV?aMgQ{2)(1;_`|^?LOXZ@DJ{)@y616(hOrNsn zQ@kVz`Vp@{h&<`F=+)jx~y{hP6O%jEw2R8`bld_q)H5Q9%ORpRQkB zmH8B^-5*`g=m>OSEztEVBDN55jtEqcz}`%2v3B|_8>w~|DU);ry08}LT#bmvMEper zDo9}er`4v1meJbd`j12j33Taew>uF(sXcQ3Oq5VT0{cJRi92hMJIK6kLS?(N92-f(zyXSv}~U{q8hbeMwd+%&ys2K^dUoZ1iB*5Y|;K{ZlWN5 z_OMq=@Aa2&rA6?Zoyyb}F*?nyBE@+Y(OeI1R+Vm#99CQOsE2=vqe0%D_G5$JOCtraX>c5ACh zdg4@^*&6&Qnm6<-E~A1(`?SW|6TiKFh!}T>s7=I(Yy`T_cB5DxfTC=K*3KIHxiPz=*iszXCdDQIz95Z}G ziI!UbI2Bk;!mOlOc+w>0yYDJF@WB9%3KE5yY}NWlk6EG*zPi^}S@SwKAKt5_jzAY? zD81oEL=q7Lh(HC2e5Dh${!zNHh|#aSt1|vcIRBj6QAeN)^Vnc0O~e8sZWDnD5)HGG zw7ya|T(sb_qV=qsUq|zw?R;e<(1n>#cgc#?v#uo~Cfrv>1&Ny09a;;PT`yWNv3qAF zq+)M{VBgQ>cyBUG)bp*Pw7HFP0 z?}%|25stOnb5xMPenV>l&WkqArP>`;D6ft{7uEue?F*xgTZm{*1S&{iFQB}dy^)F9 zt@yg8j0C#$wfkxQMkZQt)2o^?Do9|z(XX9gzxQWw@6=1dD~42(;!7UWwx_PDByHH9 zi;3SuPKh&mTy3Gq?)jQ)U0@}teY*|XcIcwgI$BTQ{~~etl})3&_R1au33UDYULAKl zA0+m-?7MQ($z4qQ-w7AXNac1N)PCZ_G+D77YAAF_1tdJ0a*l#OtiC{M56xDN#?q z%1DUqe_S(@-mx#De7y{G#~le&vJ-;-Lqz|K<$T%K6wA;$MkK`cKdx%g@0e?BsQ;O` z+!O-|RI(F-{zDAE(SuiCnw*bXJ0+sB9p#KnyrWfF; zS+7$L{%E5jA-4Z<+261?zNNZq9pyqXkU%9nA?QCuiLQZi@vyZiTW&|INQmu!T)(^K zHlCmu#<8vx0|`{J6N3IjP$nvryD5~rDiUJ*AJ^8*{Kj~SksL3|fdnf5K~Ri8#KuW^ z)YE?Rt3C~lP?6BK|Hn1)nv1aw#rWbaRvtzImF$Gr!#_l>UtQJj&(>Be_VlZQgxLPa zb>Mt4qdUbo_(S9s5~yS+1pSAgOjId%t5NP+kPzGdxOzV;X8bWO)c@l#(LaztB|9PL zKg8@CJygF%PQHi2-pELZ?SEVw++B@X6l1gDBlUA6P{~dR`VY}v(b&rv}FS7@Wv zzjrp~zuS#S`9*K{BY`flEn@#!?QLtxY4T3E?vFDgH;s>j-p-Z4vv=3QdL~=VY%97gA!! z4bbgXpGn0vS7q}d-5%1@xDQ=<-K_^`^@K2u5UT}>av+gazoJGTeJR$aYMm!9uYY=p zCH85!jzAZ#=|6h_s8`*0$sTBADyN8#PZsynJJZzZj`dS#2$*31NFqowbA^b+r5-7!;G{p$Nyhn zP;CE0te*L&Of|QPwcCnr8_cgA@U<4nWz`Yr!oEUpHD(OuzR%ZMQ#+){s339Z+n@Hh zHd_1gNmxf-J0UK`Rxk%g0$ut(xYEvz4+=k>Y>I5b#X68;JzTM-B(4eRxiVAx{n?j# zRE{6V+_?R(Pib2og>h7nz)^zEYtOoIx5@d^&fV1$=<2ur4Kp>QXQNuzrx=E-Zaj6} z`Q*j6FpdfmVq4VopVcX|>el2tV``{x(kn=~j;L|!;Ev>K)9LFU8e7@7L*8 zRzubt}XoQBa|^ zc$eQ{I~yFP7`?~T;>RC6Qnnw>qa)CT>)Fy*TvLMig!e8=9UmXvy0u8)+P`!U(m#k7 z8Bj)XEmuWHpbOXjrCCNtU%qrz4RsH_0_xdwnYI#adg12GHe;36g4b8KV%W0u)O`nE zzLSV4M4*BMwx<3Z$8D>f*%tDIjWtX+d(`=KP;6h%{FOFgaY~xlPMminO;6-6jBX3wfd^r2vm@Gx^{p@*O?h)*2h2sT^U2ZX)y+edu5A( z3KC@o&-oK$#F_s>psQR#2aOn*>sX+EuTVkad?d|csN``~Umg5+0$p|be9_9`_QgL~ zPhflGdR`+<{O|G-OLT3cC$QC!$j7_a7WADfOS9E366nH~)yF^uiCQ<}wQ{sNvmjdx zB+!NJua9x2w}VCOue`bia~bhx`^42S)^Fflv-n*)*@Aj4UNt}hUCRo7(}>SQ=4T_C z^!cLIsNsu$TCb1vHW>Zy^m0Yb|DNrrAmOz$RHHjq>y)kJNT3T_R$q<-^YbvVCs#Wc zHWr%RS*y`?-fqU=I-i2X@0T)vWy=y&kmy|gh1P>l?-E~X!n#5NU0DSkwHnzuI4fHW zRFGKx^?^p@_);WW3?$GMUp7RmD|bidY{a7>-?aal6z&!LqtqU4+pn)0?D;uSBODre zW^2J*4UcKckq(U*y7DwUrTyM@?zLjI<(lEt>j3c>)%IF>_s2)to9W){>_pwxc3{;S4SL9EO{F~Ms_;&(bc$Cl+ zs338y_Cjs1`rnomXjoRc#WsCdN-E8%@FxF@eZ9D8@CqJ}c-kClYTeBVPM!~ie zL!H)D-L-XtJb#cy@d~JBF-^6ytIs0XtNB$e@yo5O`;S&^utzmZ{97|?WfVk}AiBQr zHkbVPN)Z(mB+8hoSls(t*`ZQRi5RzMh*}`^fNXub)wq2>{num*3pmh&yqVQTqfk@A=_c~vaon}t;@@fI_i@jWzK zU4V-3X@ycW=Lj8vuKkWw zj-9(%$J=7B>fP^Zd{A_SlE3%}6%{1(<@mf}W3b=zEG40N7xm004@*F312(hrE;f30 zWlKQ42JC2=U94x#;+8$Lo3nN2Qz*vEzqbY-H~dnTH|(OKf<(U!M4xZYp6w9C#{wOL zkB=>=2DfXYBhXcRUnxsK&KUOWMFJ5=2c-ocPM>5A92={qTq$Bnn-=Q7)_R)N9ahQG zV|u9nt>n|JTq_sL8Dp#9rv*Ym>(ef=Dk?}sPpo7)lh`A8&Mxtlm=U4d z(wfFLP@?^!bp*P2x~rx4x(mT}_D6}Z9@@o9FKVgyt{bbOf<(tdl`OqW*@8cY9wDOM z@!aNm0UMQCVZ;zSMMU?*(?+K2h{o9h~>uKe};{m!hN1{)a%9cGj%CfM$B8IEm=HNE= z50$Wey>$e-u>I+}Pk~ZWmtW1)`#Ig!^(o$#_;cIY;1?0Bb%>Y6J=b;?-mwFVPO5B) zU$LAOOP@wDPG$PZ?>dI5H7*xaQ9)u^x~Ih?ZD!G{`w?-l_5x{%xv6UR#a&0BtM9Lh z7TfW)Y~hg6L<~)7Z3 zg9Cjmd)AI;3D;IrjH*{s)ABX*QWK8V)e-2*dQ!$>%RQSlNnT9E;$`iP&2##w)={#G z3KC!4X!O}XpS3C}t{S|V7GU}ExVAdS$xlb1%R9D$CH_zxyEkeo5p$jAn?nl*t4UXU zRaB6`TBmOVx&*UBw?ouWM^7Drt~?DZT1;zJv8FF4P>g`MJ;vG98>zqcl~GYaLf;-G zw-i^R@<#Dymm4Xa&nKAUZ`@(0E4b)Z1k$fYQZZqLVs#q9`K7#;n}he7ZTD}mX}^j{ z-=k?|G*brqRmeq3_-nP<hbDYT4v(@|^aWmI@gLi*3v@R=kU=)UL6? z65lY6MPG7}UgiC6-jf!^9t;{o#EQNh)k{eZd}+iN3o1ywNdMbxs+hofToSAG)UVr4 zP3V=M?`UOjMFL$-wtP36wy$S(M+u@+|K95TEyu2 zbvkWvw0lZfvYz!UKGryK%R95_-2%4OJjST6-3?FrtL|qm$k&q(T2MivpVt?&`^+gU zW`<~UlX1A(vCww;@4IVt1iG+g>HD>H2B|wvACbdAf$??|g4r zP(eZ;<8ZDhb=do8dE(&*Is#q!#yMF6I<#atPP$X=u6`D!o=6!iuYU5xf(jC;p1;fi zcbl`y!J_0*cCqTIHHi}M@j^$StA9)$OTdCMtkO{tgS*G7x4N&Bz9qc0pn^oeyWeI( zRPz!s?hI_T-GA<5 z5t{|!_qM*eZd7?5w{5=#6(sVkd}%hl+{>bti@lnk;;HtRL%4KyxsE{B&L>CBrhP}* z+rVgw5qB^^Rch7Z*T1A%P(fm7!c((*#sPM4jfhdk*;PGXtR)YM9IhkKwJtr~Y?F?& z4^2c2=Ng{suT3F5Z|wvNy53gKFxxh#v$*|5Bt21XQc<;cSQtOkezpY_Bn~vWuEof6 zNbFT~jT~x)>K*w?-@kMOx~eVPWsXm{$XsuUy}Fy4PrY%W9ryk^%Yq6LkL@p*-SeDb zch89!cb$(bxeE;9?dQ4a2z2R>1`Cy6$^?fleD8;47F3W32t8qrAA5n-yDef2|9L^l z^SnR*`*1@Yfi64}(W)x>j#zJBqj&Xt2AJ{a)&6r=Z6&TXImR3DXh-j*F5PUkddBj| zs?XCoI)ek$Yw&(1piDgW-LpW=idhvAoaZU1n5} zSZ=q-?B4A*JNi%@IqwXbt2pK!#g87%VL<|2ScmkD+_>@l{Aarz*D7DP7F*Gt)^$x_ z>0LeI zoU_Pv&-&%Pe*c|opZhcSsW~%ecIKQzqXq79sxjNy0SS!!MG$QvY`WXYH6}iZ8&fYx zLxQK%Rh~bbxJMY@%>xOH{6!FLA&Szf__r!ei=#E~H6(aCT@}15F^SdT)SPTJ5+pG4 z7eTaz$T9hfYHM?BN1?br8WKF6uDX=32KNZ;&sH5m0waGBL|ceYwVP;DW=-@CZC+SI zf~V6}POf2ztQ-%UeJ2kjF!C2cw1wE=IY!&j^FZZF9L35@(j5N#o-Cu-Dpy{YfYNbq#JD(co6?y)=JDS04)k-rF{Erhpy zg!y)*Y2IwV1|)boU6q)Zo`q9;EN;O(kif`a1ko0PdZI*qw>82O7J+CorIl&SB=QQwu3;OTVLwdpOm$H78uPdy|s@)tq0g`l1&Q{RoFzAGWY z)9ET4D|z3|ATaV5K{Ojt^`q#NF_y8fMJdI|)1+c)m$QDn9CaczD*_{b5ky-E>WMP- z-CfjoRU~*iT~(q(QSQ+p?=kW~0waGBL|ce_H15`=ad!}nyGZbKy2@k!Bi?t%jyOpk zNMPhIf@lkYqbL$QovsSKHGz99y~R9`z{p<&(H6p$#@!{qj_xQx<1P|Bov!*;d?p`D z&R0549!Oy1FM?L9Yyk#j54ycYx7+^z3)Un{`;!2(N+XTtO-V2 zh;MX%wUF+wy3zd=5-j~km3Q~r8q7QY?KR|q1V*e0Mq7x*Tb#5D!?#t;`KYzVh(D38 z4#oUg`bDR8Mm!R|FZ%M0I`yJzdXujQC;qr2QT_aY1V+Cc)|~oN#~12%kJVOrwX*G9T_l&op+=Nn*!}9=5?~=YEFgWXs<5#*c4m^50MOWVt$G zh!C^R*=0I3V4{W*BrvxILx-mM)oW!v;zHVu*Ab{Q(zUJHvO4;pO-f zK|)^+lVd@tYvGorO%=!M%5i&i0zYmM2Ys37jrb-rrNssI#cf&>reWT#3Fggef##fp zcS&LAkHl@9G(<-*`j1MSaEGU7)?!4%p?31te?81Ue|OME9L}yV)%V1;M*IHmd?suP zTWwsB-N1hzQwF$eun1fV`zSk z1WW%>C0wq_J%;CAL>`Q&D(%?K2#i=0jJ6QF>3+A{1&7KGbia!POaD>5Fjwauj}Oct z4@M-?9Wo;@VofmGLOiGY_U?4wK7#JskznaRs;?e-xW|yzk>r5{Myv@&TL>?@4pvP& zT0Re52a#auKdOk!JNPJ(kQPB6NMOX8V6=tk1J`pTSo)7@$(vn?Y;?UfVG4O5fe~wh z(H0^L-IoY-UowjBOORmcKdQ3fHvAfOB568#Ab}BUg3%UYQu_UsME6%nu=F3*m>!e3 z$C{n<$O8$CSQCu45Y!Vj>bpMF6GbFg`i}~)!MyKg5E!v07|pKQ^p8oDKFv93-q+&E4iM|al-bG6)m&2#RPlJpRB)m@8 zo30Rj=6PXy?vojTDwbx?+bzVV^98gB9Ck>-N+*|0#z){ zJS>EmJXoE+Db~C5{n0W;ka!xD)s#dYDZ`jYW(2BOnt51=bM$QN*0wJG$FfA2W%rK? zTW9xd6L+y$14?UALW2$N;?j0s`R^N7lp>ESM1(h=l^_`+%ossp&-9N<^wGJ3OYtH^ zFpriko*q0&9!Q|-!FKX9%n;gyFoHd?dz$5)w?Joa>9Ze|xm3cfTZ{0V*vxBGh2@0( z?%O>?V88!O{E0BXKLmPPh`SYStMyLzK!Vi*^YBUesKk#SC7j*JJWf!qJchJC(AO@) zj1fGpz8qDYeAV$C)_B*?9WIYruu=)x`)|P1Pd*|`vvpBeI@kWKN{H*FfQa2b7sAg>KFBzEmL<~=&6 zzAdaKT`1R2#t0HCJPs)#p^F2iu3#Ru>Xz0n?P_K$IJUOD$oYnnM(t51u!5*9IIG;D z^o^*BV&`uu%92OdY8*>o>t$>jmZq*V4-y6+43_a&=uzDEgpzWuZ9tjg%;Q<^JX-wC zCdN(6TgwY*6H-M6$@SzCo`t6dM83Xdr-VFZc5Utg5yVlhIO z=j{A=7#OCVzi8(({O)WCdmqx+W9aVnbhs9voixS9730L8-iL(Vqt>ADT7_NRl`2LP-BYed%HpsMv%B&kIwTl(L(%oMidV#shytm&HH55 zx-!-x)-Lu1T3sxEEA8`9JD*6}(-b2}urje0wA5~91gfwW=;}ORs8;2Ry-)Y1+jZ@M zM8P|sxJS?Go>YztA4Y4DW$K$cHXfuSP=$Sgo*L{LFX!3z-h8FVWbugUsA8Ves7i#< zU34vRL|L-`LXG=g?z)~&E8PIUgZB#-IT@Gl{wVYVnJ4y+;cst(4{=R;T9ViR?IIeKa8lU-LLf1h{r+? z9LH!ZDfe3H(WHmAGfzzsBS@e(jc_^cN^=}SHUFnsbp)z#Jf@NQQc3CN-f*pT<#+)j zNa)KkD5aj{K=aM0I@ffgKC194VK5}o)Bjo5{LE(1!PuvZ z)YFANC@tyjzlDwK8RO3wOqD5J$i>?@h0?y?$%E1c!whMpJpT7^|Lv)ZRE+#A@WcD<{RzQ+)h_bOfsW zL!K%TltY{M!9rITmk$P;l*Y?)>IhU7oqSI@O1b*_V-yi{8%>aH4qo+pC%iOc z1c|;=?kT?4JcUso8R0y8qTKZ84FAION9YJtO&Nb%ai$iuUp9k?l0;PQ663#O(g-s~ zkVu|*Tk%ct6pT@faQQk>ZZhJP|Dvb~MkG*mS-7JNCXc;y*nN8oBHW)%Jm~j)tPvwf zv?jv$kf(5RCL{JNnka|X%jVa6%m5JyROx$S+LDR#hhxhV#@_EEVgw27)AY{vsB!YG zi&8c9f~?-0li!p`Z?Q@{>D>iN$2*h}s!}>FI*x7(oKhO?q-Sf0(?#aOlBl2RG;lRN*<#zyI*2d8F(w_K(9g!f{P<{R)51ciC4t zljLTF3M4+gn54LFULmxp;VR-T*KRR!{OYU~W^d3v%r)1a5!cSo_3t?N3n#7}r`_dm z(H$AxC(K`8NL&^3il_5-W^d^YK3Sa;jgR~JV+4trmESATM0Y;OW_R=*OGY^`zgUjp zCN+`ywJpI`3q=AWNZ{)4nG>kOHR<)l$h#iAg#CTobo;8~F6VmUz!e8h+&2^v@wj*- zJ%JG1e@vDWnjMv&023XJW61gfxy>OE$3pf^{kZ9{05@}-7l z?v(dn1rcW!dSd@c+B=hoZ=VZ_LzRg`W22o2ljqUV#mEwpqOqX($^J^ zI7kexf1lI8TBon2mr++ppbE!hy$42+nBjMad-!ciUlTCH0|`{wHQvgJ?~kl|2}Y1u z5%`W1=80_1iRT~^s5-IxHz#(lvG%~#DRD(gTt!sxfe|G1>%C&GkU$lVu6hrQAc5;| zXHK9BuM!4>gYSu?aO&-KM-5T1x8LrxjrYFmEeq-HALx67Ws$Tp$0RwWOY=m$vp^#1 z>Qdg@pFUu{9ZQY`s&G8k6Bt2aO73agW7j)t4C9hqu4$L-y>o^XuFDna6-_s}nGS#OteXc{##bv+?TfYhUhh zOj>jh2~^$A{)l^|>|rab)=sfiQmH3SyV@xLBS=gV9&wM*6X|QQW{f2`hJU?j8PAiA zB}5$a_3eQXBu;d-(2bW`kBLa23UjFUzz7naoh{d>Bx7+ZIrb|gP^B-qZ}JUu_nzTW z;Ic6JUbXv4u89kTs?lCzKDX0J(O2gPq368Bj#+mmv9+lRePY-1go!Jp&WGyAwq^3j zueQEVavd{Yu-WY;R@&mAxJE_^kNbFud6M!fLB3If(>_K#`&dpY6;e#Tv%j{CDx`0A z%&NHdSRza?GUEGkS-ccdNPf|wsV-N+WwI!)F-wGs)xAVLk>+79m0VR=?p3j&E>}Ma zK2M6iIZv1sZ%wSv>13`mNp3 zkCe8(p)8Mfm2V|wQ&Llw3MC78iR&EfmDH@ug^|z7ik(+edwg9c>~Ufq1G6*~9S()c z+Zx}J*8AsBqWeV)S+|uH*WI#HT;pPdi!IBFV|zF%sU>2B;7RQ1xl@~K(y11C!_ zneQuetBsAkZfgVC?S6uU5hSpD2E&=GqBLs$P&uDzj*dXpw=r}Uyjvk09Les;8ebeL z#m4lP4{TZ^VFU>*A3Z60(?f7_87n)V?WiMArN8UT=~rIe<`gd9HM9^hf&`vj`jNVa zd5{wAyI!dDuB7;?VK`40X+*9D$9AvA+j!*ETMzNf?3+A&t9=Qvm)mxp zemK+m4jCgz%&DEqiJvX3=Qv29>gth=oCyEL{$0XcVFU@6xhb459c8^eGXhnGXUA}2 z$tUYvVFU^1SMi)kk=ULV=z#>Pvi@Gki8B1(B_uF{#NGBwIg!0B-=_hdMj?T!qP1so z;%Fi3*#t(ASi5TiCpH$f_CNwv*kkl1#|RRpgSEIv@j&*^50)GWRAt^1G1B^RUVhx; zpG%9_r}Z8fL86G!fzw51S@&HeQ1#AzASViRC}C9&j3Du*fg>k!ezCq=LIPE}&Wz$j z_ycPKBS>s^D$9w+joAGz)-DpLI$Si86FpyA--}`diGII2aH7=~>*u0Kpz7-Od7QYg z!`cHQNW7Rmn-j%nSwD?J0#$10GEP+evxh>)JvByTKpMD^LDjZ$)Er<~$IuE$UJsO?2u3aQhg<~-Hh@)ANMo;Lyue*=p z|C!$CMs<-d2{n_gEL1V);i^x>+jZHtHtJ~rd&i|x~UVg`Z#RO0`& z?{ZG%#L9W;qfZ8b5hThynZ}9Nz0=2%|4E=~c=zs{IF=)Qq|P8Pg2epW79wN-yL+WG z>VFcbDo`nadzk856Bt1vXi`m144Rkz6eq(22~?dp>cWZji>!Crzz7n}YgOQc?DSs* zsv2a;&xsp>=`+ZTTww%>{SV7Izq??F6KYYhv{(JHB?0qjn?Y^3NuE%iBLkuUJGdAqCaafhX%u;rbVRoCx&b5^G}yB;^#M62|0Enpl#d2 zqV7nBIg1?RW;fbs1*;oWj35zMbh;9U)o7XpepSi5j#!< zWc|g)_6ExW{!)sr)nkaI@@Ue;wE2_3u1Em}QO=PfKIA%Ut!^=B&~ z?mGigJ}{3C_dClrj&IZ@wU9BJ_@4x-Lc@+)a<$dk10zVVIRo>^m`!AOAc3mf{SR>>se`qL!|V+_ zGMoAmyMJKEW%nY*KPK?BFwuG}!3YxU&VbPwcNrPELIPD-Z~D;(BS^421LnczaG4XR z!g|va*!!>_V~ywuj3BXa{w$uWy?d?e3JFwUz3DwLf`m=psoW!=qV;_Vwk@{#&61WD z>|V~g%`t)moAt5U&6q)Ev>+0w`dD%?C-#rD_P_`dY}Us-GG>q&9!Q{SS;!Pl>kNT90DuI`+ub}4<%nL%I#2{!9v9_*Qez6Fs$)s%ycIk7uD{TV=p z2S$)!b2#R~o^j|skU*79g+`pnal-oizz7m-4#zw)=7SlzLIPDM`gh?()*$OU8H^yo z=5Wj-V?LPSfds0)*@SW8OrfGyCC3O7Y!1gfGUkIB9!Q`n^D_z~NU%8^^T?PFW_Tch zDm3Q))t$?2P4V5U38n!B#TupP?Zz6lg!?ZGie>g0Oz~Ii3KhSz zH-vhZ94-fq-DPz%aVp>O`_OwD)#PkXMRc@q~fuVaKHRh@h#Y1 za5>Db=Z{acmPef&E7tguTSEd>*VONd@2@t(JepI{`@2=f$a|yOtGO2?h*%DMOA~WT zZ^YM_D2FYcV~&h;He&>dF=`82Rq* zX6nW=+szn3!n2T#DLyh-=vIaOYdL42ET^n5rX8EqSAAdpt`Z%zM92~2XRJK@lHz(| zksy6o$fvOD~zbL-#hYPb)DiIOnF+k4w zPqKP+=_+$i*Q-i=ui-+rWmApIa@@GAAIFyUO!#pE%#XIFXn$8TzlxS>XjAW`)FIVHM5r10!5`zG#r zy-sqOLRq!*{T*}!s-}H9tGHg8A>2(`K*Yus4dev-V%miD^@S!=jw!CC776*TOfq&E zbwmmJ5+&HxoNGjydg237j+$6rt1V^`F@nVIIkcwJz6HXtmF(-YakCo8OY;}g%mWAO zj)f|HuFSuS%PpO2YLAw-(DlUS{N? zD<2(!s>lP>k7o}Rw$cBI{R>NK^GX%c5vXeIo}#!GTPTcA z=tCZZf0UN{T$45Dg*jA=Afa!M>&Lw05!WSccf<@c5~xyICn?ckD}@WE*cw}oOG?Qe z&ULgJuks1auO3ibr>+sg-p-Sp)94NCF?Hn*wk0%=&-#+13fswGNNpb^AHAGS z3+uPaj1eSoT%mv5dvumhJTYi%Coj>BHOtk_W{e=AA9u&7edSLT z&FUJhlCCGB3eOu_?{$}>+|j44wyw%Rd>A$ygema#%Y(fYWn;x=QTz+? z8=;epEeESSy?;!8(eHdTtzjD{cr|ApLwf8Ie>qOn#x*-FU<8RF0m({G-d#er5p1Qx z(WN4!t@B1{Zeyn!kwBH{-4X6Feh?$7KE6h4LRQkw@AuOjybdeTWw#489~KZFmR6PM zi0wkFmj%S}$>ia>O^D0xOoZQtrN-t@a%f%Gwbw9$#1ltV30l5U7!tw=V}rV)L+fnX z#>yRa1ggC3=qwnyQJCM05y7=D8&_WNRqMSOr(pz%Np>n<9p=h3Mzn2sQ~Z#lomSqQ zL+jC)B8}sOqCWa^VEGJ&r}6W}tEsKE<;!yGO1|K6vXXjyr%=B}0TGWxE8V80iECSo z(KH;wLB`$QO9c9@@hH)RoI#aL&wtF%?*-UX$i?rx?J`6Myq!kV}*s0 z1w{0tYi;}tY4+aE+Se(!RE!|;dh`*+b=Xd!8P$lvaB%fdX+_cq&A6?KjzAUW&|sKw zs-bu`W|TJFbE1k7B-)&yyR)Kk!s%4juO@7rDi!S?ruDtK-i!pQ^gR)uCr+&GA+T@q zC*=5Q>^`hCPv33u+W5Y^A5Z7*{f^ELBrt-+;|l^O28O-<4+2$lmKZs)bEq}(Y;yx1 z*(XOB{bKX-^%)w^s9`+Ze>Pu#pujDED~}=l^KoMG*_XybU7vGR?x!D&^%~gm-=7Av zRqL=E7(wFNrOTWS9r*G;2vl8;&Bi^>jeKfFU<8StF>ATU+y#%U2rN04wuW67?lHFj z8?Ue&7(rsSr#B}`xK{lS0#)-0R_27^37e~-N6+a3&u`u}myFky5jo7Pqy z7(pWQ7DNJ7QFm)`kI)jl2(9vES8-z`KksH7$1> zAABYoaWxvf2S$)sVzAKXY(`jlAb~2|#>;rFdgo_%eOL~R)LCOujV#>r|CuW!ux<1n zm@8D_8aa9bBS>KH$(%qHu92fBFoFbLOEM==g=^&K30&b#YPOP(iE(qb@U`1mzp||M zhV6l?y&;iyH6N$%PhxAHWk#S1SANqIcs=h~|0h50^y5Eu*LFRD*Kj0qzPrcih>L6; zVayd0sKRTop1=qaUeBL#k89b!Sa~3UD!fwbJ#e+JV#_P>b7Jkeb$tCZ)*hCXzpxy* zb{7(b&UN9mO}R~0xk3U}xI&jcSG}{<<;SZ2+)iw?v@lP9+hs34EWeSbLkH%gdpOJ$ zUTb&cO5pWlm~l>b-PIF#eMMsB(QlltUhIgmhA=JzESLE@&TlfY^>_Q4@54WC6g{w=pL*NycHSBLUu}-P0g1sgE+w(rb#$_~>Lo~^DmBY~-sWA78>|S7Akl2k zc22n4{1<^L93}K6#|RQzr<~&+yOV!f)fEz`!cju+ful3tEoVMbV+4uWB`q_SBH>f5 za)kt{a5Xc1t}ucG_P5LlRN?BRdIBRzWZn~zKowrQ^d1;NB3IqpyagNVh_$LKBv6G{ zIK7AM?3w(WShX_6cy?5Oo))`ZFz(tul&4+i{cGic5hP|6ufd5Q>#zI=fvPEe1x_?6 ze#45u2oeVm{7z!$;Gk&hxdBFysNicMsu%h%0#%vMKju6g!}FWwm|eu@7bh0d?h%x$ zh+bK&&U1_)ar@djPE06beV*ez9InxbceVN!#0V0&ie%;lsxrTF;`P<}=477tMhV-D znG+a6qKwx9PJ0{KD$O`jBY`R`pPs-75~uS-bB{6o_gi@&fvS(US8$?LnItO$?-_8M z$G)J?6-JNc9B38_60qG z5hSF++quWl?jNi?kU&-DEr<~$F7LCEUcKx)o@mjeVPc7dyAS}mz)v`T^d9y>0}$?t-5>&)|~8Ls+$5U3a+>A z2tP^`H9GI1XI9DAgyP#u8~gV!DnwsT6+9}Hrd$=O>LzbkHcbs~SySsY>AVvCX{T^a zb1~+weO+;Fx~Z6J05&tl3b^ZE zIn~`16uwdj+|TwupSf#*936FC3>vJc7(oK}^D`L6cIzU)ZJb?7@cpVb+UdlPmC)*z zpdHA~{dVTKDd3UlySp|Wc(XI7v*%|!Vb;pmL(UVOUE00!u`XAr z!txmmWgq&>Jsz6O(l{TjX7Xnxb^J?z;?eBR$9jjS&S)*-YC(^TES_%1J%{b z>NVtFBO}#+2ba_^g2Zl{S4xn_df}hTIf>{su!g+YuBF&3YbBk>sAs?EU-o6fjqSEN zKl(pvSWWryS#NRasInR!7m4f@e=E_cONG4=w&aoBzoFdz^CM$7*P=QCRaon^Kj-X* zvdxDr;+>)H8b**P^Cdg4-6jLyQth^nXe!TGo?R;Wh*oih7DN@c4eflgwv2r8)m*iW zO;rth9QH&l@(1sU-uG;%9OAf1=JgFl?M_U2?d&ygA$9ImL7m`f9COx3NL_qJSn{pB z(PnxXft??A7b_AmuJ;htx}FA+D! zg{y~-Em9}^8lYhW34ESxFr0O$q_zoOq~>lnR7apH_Ux|jV*!JzXX&Dikaioqw)xn&3lbA=5lguNLh;>A$L|_C7oMjjc*H8Tv=Mu59)^Htx zD(`%byv^HnsYV`;h!{o08X_=)1U{Lfm7xEbDNP~b@R0sG0##k64C5Y+-?3iu?c7W$ zkqCtdj39x}OAUr5|DKl;h$#ELi;h6mvHaV($AUd2$fGL}-H50{1V)g+=cNY2vJpAt zRzyTqYOEtrh35@j4O+aF0zdpvNA3}HXA~0pXRrG{=acs*E>+JiudO3cg;xoKVe8{$ zQ-J$KZN$P2=82Wd@xASDWy@n+wX(brEk6`m8C{GC^Bwr_m%HSnl1D~uP&$^MpcSqA z(Tou!e4Xa0%bOfrplzf)RJWlUn9>dxODlAv?h`LWgMJfnyWR#)yl%ToN1)1~9<8@Ub=7t56(T+o!OHQC2#g@%vFHl-$a0IVhB%>@ zi4&u5e*ICVyq2pxec+!37KJUcsB z4v6cl?i1(oJe1gH0q8x4?8Hmst4iQT0}+PZP~ZG~e6+_M^KgJJK75V?S(tR^cd zDn^jd?`-}0&H#DYcUf(jvQIlPa4RlsKT`9X)#B-8sD@TxEb(q8~w1jC1PP%<4(>7I@+I=f>t9)JLd0(|r-lTou%_8OsGt`X6UJWdmV5hSX|)4S*7(LKb8uAB}F)1>A^B*zBm z2vp%6r@`=hL7K!`@DLFgL85u+2JYdruLyYzdZ0;Ni5T%le=mwEymK}f1`yFBoxlhZ zwY&A>9@!R`C6CH}Z5g0*YxJzUJ^EegC-WIv4c&Ocon2^v#N1zI4mNXwEg0=ZXA~1pk&X8&TG3buC zYsDjVLgAh|0#!KQrFFK)t}%aDXsdN^+(|bRMnXS#KC-E_{Hc;r+v(n1#iv{aM!TAV zmR=LwUp2OR;x*z)Nx5kiQH!10PQ~#935OysCicYZifss;gHuyICr3_v8(EeC_Z;dQHUIq%<9Y zDjX#YhF?UyBw{@g7(pU3ir&&C5C6XQ)OVXrO6J7vtWFvdsKQagU}y>iMvzEb7{fjG zzGE|%{#h1FFDO_3lDmcks&JH`Cy+$2UScEyBS;)e=)yfxYZNDsZNF=A!aJd~jzATT z611l`5iD0;L|_Do0D}$pm}y&vJc8Em6J#pbAF`dY_tz$3#pe0wYK~K9@C# zwTF8Nw(j|T$2lfOj9XPfN1zHv30hr@2zGvqB?2Qz7$U|8Fppl-yvU>8#V=+?OuFr* zBT$8-1f3s5u#%4_LKlJK>Yr?{@!xD7J;bOLv9F*bP=!woY45X=dDNRTn`u=Z=hHn; zL;|0r)2^Y{?nw;V3~P91$xhSB$_25;&Wry;8of z;>6IJqjdzTaFn300fE2>5;!Y17^=LO!9DKSEmo006^;_LYY!1IRF1nuU<3)A-5U)1 zN_XcT2l{N*5vamZg7y<8g0)~g5g0)NpHdhMQ*xK*#IBV6Is#QVO3+g^B6xccfe|F| zX$$RRl6X&KM99TMIs#QVO3*z65v;EI5`hsU@Trl(&^NU?_sCP@gpNR!Zj>To2miCiO19$h+4tGAIBT$9^=@|@p_FXlP-8NENv1OX>-##Sp zsj6isOtyFC1lkF+LOm~|uWJt>?MQYbPt%vzEZw+xStI)jyZYWQbO-Qfw@qsTRXj~c zvJi9A_W;dJdw^mDi9_X!bB{}pZ<0r51gdzNJS;>u+GE{Fd#s1i9_tuEVscbI?osy3 zY4XU7Kow7uhlOyX{e5m#nij`)%EJf}1EOuYN6hP!Q4lYoBh;f?$P2JyX(u0 zKow8Zku1b6+C%yv?IG<pn9ARXj}|7J_=Bnfk6b+p7vANGzP4oA=#ePfC$TW(2Bu znmjB7^+bvKZh7jvGDeUHcP+v_vIMaISTZ9}#na?rA*d(H)OX{k@5&fKV$2~=?on`f zUh>F{Kow7uhlOA}_bRk=uMO?oixDJne6;NK`@aZO;pj@MO;`4mz76w{QjU(66R&Ji zf;w*&{Ia`+t`LEdz_mL$ebdK9#3Rui zSt%!}2<=t9yG}cu2NGxgC7Ra$J-U^7$evEprXhCn6DtB$KHcfNR3h?(F=ATFM6v(N zwsP_w2N@$sybO(AUciwP|f#VJIrvm#Ix;I^ItokxzB+aKGZr0zW{cvCMy71HztkI6%|S-eSFaD9+m`vvX6 z^YenR{I0Wzv`5-jC8*~GA?PLh%5?pkY^EP~x=EkRV`PjVq4$`WTFlflvb(f4UQeJZ zCi_;(?RjCtJLVB~(NUUF>yDWH*Eks?NMOC`-`MgV_f~M8Nc-(Bw>KVe`6}e2bpP8< z#_FG+2?Hs;ZMF4RLLpBI8NZKjROQ1lgK>kav+*aTHyb_(_q^PV8z^02(jB_0;d^8l zL1NGoC*w0BwAUA`2z19>Efj5xWhhr&d$VT%=z&DinH$1*QWd`HXvFgA35+1|=Ik{g zl;{dUS*<*fK-J9Hx5DQMv@dcmgB5`hB-Thbh3e#SCFPS<$&om39vDF)^In1ks=hbPZS)|I)HBxQzz7o9EA_RDeRqvo(zuxVYookh1Uw7$1V)g^ zCY3gBCwg%<_8&dAAQGrbI9JH%O65rW&Y#@DdaoEkB5qnq;|=n-Jes|0fF4MoYVwr= z#@6KVxh&ha4+)GQv39$gF_4IF`S{x+kSiomwe_{5F`9^-(FLqLFoJ|!)5(}b#KeWx z=Q$Fn`rXyuc!CJySoQ=G%YhLjG9OEjK$Yz^TjLw@Xf@ip92h|Y$1b`KF3V@G^Jttl z_<0r8Ug@+96cUH-Nvr)4Agwp~0gdo_<_Cp3aZpT5js^E910b4Ud_3JqqBF z2fq&sPIW4A+CO`;;AvN%|874bk%(|2(unwn2#g>x=#9kbw`2DcVe|U5nGpjAx~fQ^ zN}sE=R~Dix5g0+D*P*uDWBwcVPHI?s$wyGhkw6vJ2>r)$uZ<=zve)L;E~>V7ZYl)L zxgp%?c3dd)v=L8V7S0G4+SV3=!tM&gDxD#Z&-ZA(fq|9PM-BberLUU^(RVKk|4L_c zM9`0B{P&8=aU#AD5jCi?dZB^8iV^fcZ@MF+6=)lltE4s+?KC7%W#6(L_n7hN5P2*- zQ&Am5Jx6?jsu-CBnVGgmKs3)cq zABn&S5|~?q;e2e2x}kDAt?`3rx;94@_BZ;!mWUW41`>f0B=qITwezLAoQQ1Qmg)#p z;dw*rWw_YvGed}P>Gl${^V=lQB^^oa=1W?odT0zW%fI-b#uC3>P15%q{L6M+#NOZ2r{%p-?( zf^rq}Fhxh83Uf zNyFzVNd`k^1gcn?9mzsm_}*H}A27#z@N_2)BS=hI9LGJj&pSdMnGvXBY35-evNnIE zx<1%jVa&B28b**PT=OG;!}RLwU*wS)fhv|}9u`9FUDH@1-_W>mKPGAzLBcU_cK)X9 zfq_5CBQpY3EX_PDgwx|M(%4aV;&S}zs$m2Pn_}_Yqk3(*`BT&WC%)>%ZPgJSzdQ;z(F@nVRqVe3L za&L`1G9ysM(#*p`oNClwD>~KQ=R$;oj1eRr4EoAFBHEQ9kIV>Eu{86r5YujlYg);y zKFziklQ4n=j*oPom$OB7 z_(pxf%gf8;dg6faWOhRu+Yej`lV%=zY2F_frd8Tc5r0MKc&eVTCAF+6by|{;@{gXT z)x~y9kpixqFqbbLrr~kX506A|g|+QsocH^x`PTdiIu9hg?v^t}+awAJmFiQje$UQs zu6i%j++)N<9f7LJlZm*a2yHLaBckQc?P|AtXN-#9Pz@tU6#HG?5u!1StqzE&A&rL z4U9tzd#Qe-!gaj_iGCNTc5@#Pnw4R7Rch4}Auzd=8o5uFC&gM4NpgTx|ZV zfsQ~GwhjHq;TRo141=?8yDY0w@)l%at+)g^lHW4R)6PET`Ib}sAjvZwuTWT z@SSvnLAl;SD(qWC%UMZJplZXBvZmS-83qSqcTQ>ty8Fz;bY%2h}K8|h&4g4)(kjdcX7 z^mVnm(<>=kXhZG5{_`qEkce|9k0HB+wprPC6Ar#BCHr=6+LOnEu69v{?L^O(Iu;gv z>owH^w-wUx>w)!gR10T!2m_zBxB8}`^N1c|tBP&31~YSO*fWs0Z>Dj+POPvvf_(*1 zF}AtbZ&!V7>2_Ml9KMf071kl`?s)#C^!l}r<`wFst1BeRB~uQAV}*m&na8p%Vt-sHP(bH_#O8dtOun8I1iys)OF*0+eLpmvbI6c}L2>TR!KWLQXEQ~Zi{!sEji1@tx;u8E!GCnpyv zqswL0c_48hSALUk2}!uv{VaLxtk*^Ew9hB0waK6(P<4Awep9?d8KLUV6e8M5-DNkw z)(3-IeNdf>6f*hta1#<6D!QD=hn-C!>;4I_v1Q+FH@@6Oo>NWo8#({CiV@7a-owQu zSibf+Ptu6(zjOquO0+CsN-?$!c$|m5Tf5NQSza|De$Ug*HX25dxJl(mjBHdz;^nyC zwX~oR$YW!!Uh;=hd6b&#p6Uox>A!J*HL0K6 zWB&1g&t1;yzV1iDZGTadZ?Q>kGa?a@OQn!8dW%}NgLKewcEV6{k)Mu4&sgtv)CzkGV z*18?8Cx5;)L`R_NW!z6CdgcltmmB*pb@vSyEv{cZ`Q4(v5=M}~9MV@QWvgf-Hdc`* zE~%&^Q02Ylm*ScfBMji{A^Z1guGL=bEJxIaJV0AUWenYKn#iH`6`B#J?<7`YpN23Iz$whqOYA~hNUoIS1UBps1DLrPX z;1g8!85~y13ZRwRahp{(Od|njP>};CJ zpW^Pd%wK; zZ|89NVu2I^dkK~a`y1_XcrQq;_G66fw8GJh5hQ}_X-pitLMY(Kb_TlpW}kV+?+Nl$ zj}i(Js50OBqNHwGA(rz+&v<;U^7R9DX8-@K{{4Q#CoIq_SlDN?jt?qsU>YBj3BX1{HeIcE*D;R{X0n8;;}KL>!xjp)Qe8zoVf*E=8WP-C?kAp?FoHzAd%u;`Y>R|D zrC9Cu$yz|8g$?8*lXmI|RJpCEks)HSaLvGoE$#)>ads8t{jY1t7(t?xCynh3W($4q zF=FOylX|JoDe2^v_A<6JwtwmzI-`C?3W*ij?sfjP7pw1PY?Ay=hsqd1qLvM{`R7T( z_P&f*v2C{+5g#m-Huse=f<)|h+U@G>Xkk}pMueQ0tiBqOLyD9}>IhWj=|npM^=;X?s3BRZ;aUJ z608ks^i{gp<-Umh6?3TXyFWj7)+SB91E9})FKuhlA?~4mytjfwkEA0&?%2*-)gc{v|&TtSc1ex zT64fRWQ-8mjJ4p7oTt^A_q$1MeR}B#RAKwmo#m?)>VbQor3wvO>3SmeaO~5x8`POE zYW3!Ba`T}cy55cio+}2!(mcP^jXw&@H=7mH5val%q20MQJ7`&|6qU<8&a0~{B=DR! z81^3*wW-zpY$0Sl$;S3}oknV1i-bu7CKQn{VpD=tSy~7kgA0pzB!gl3hPK+o zhApHX&RJxPAmRCsoypg(xe)uBowdV`w$%cww~*rR+3E;XVLzrlMjD5z7w^0?UTi%^ z)(&Pf#m{RhM74Ai59OuN;GMtlcDS3^J~!=N*F8ii70!CQHoCBOKha4#|FMCL$3hS6 z`LqX5?fhD;_6BMHt7bArkU(!5sn^|Aw+hF_TKjwH2vq6Ms8BUR{W7YdSf%nP86!yO z%dyODk-A#(7Pq>O)DfuCkJOE|!RoY`w#IgMN6SxxsD2g{7gC1l>*}9bH15*x*{BZb zp22Ra8nHjh*uKdq86#M`m_vi1_^txlvQy>7)kRzA2vm8Uu;(6Up0hST9P6T;yW}T^ zZEPlE1PQEZx&w&rtW~`*SA4qDpd(PV)XRZ;JZ{B2ri|*Od99ft4&P-fV+0B8mGrbW zEL^*Of4f*^Vm=88RP_zFcz7LU=ZEAKt{u6#SxifElQ4n=o;UpMHgDP&K=v2=$5p!L z!FRO@_5EO6*QNkZ&mH&H7{9SHPY0!??--InU<8T8yz~$M%hkf-7Edj2&}9$?kI_ji zQmyJ2<9x4WJe~XHH{+#zHF!F(|8FY-BS-}AO60`UoHkT)dP^_E0|_VButetfu-SJb zdg}>{Ao1Zi>|D*_`(oKk#v4j*q%-&ZE197v$*XvKNl z<6I$Y0wYKyAI{5(i*D&VxMX-Bfhsl6K~AhF_r|Im7(pWH)*4RiPOxrsBv6&VRw^e( zZF_Cyfe|D!?^j5mYGmB;=^ zoESU8+5_{8<*0u@g%chwyR1Br2<+|4dq%z62aH%F`r5?^5>v)l`fiI-ij@ZvsLItn zJNK(lK7GyQjGl-QBwWt=aiUIyX61nds_ymt!b{${z#%IFBS@6!P?URg$b0NR2vj|t zbdGz>v$bwPj3AMD3nGE4MJ-~v$KEN{9vDFadyKvXvF|p1+o=Y-A`dH&Y{c_MPhbQI zpILF7?sLv$RaZ!$YF3T!{2ZKpBFUP72ok3}7jlmgLlXXjKvk90GL|;Keb9=)2of!_ zJm>c%-R`CDw2{#hkw8`HHDkC(xwreRJTQX9-s+7xAx*G82a!P4)8DH(k$teW2S$*{ z{5(ejRS|EJxyPAp)*cu^0>>4apXZJ+FReVyJCVMxW%G}ux%9mn%|Fk+-hRX-bdYB5{`NW^f_N?wH zzP|dFr^8yii|y6VO7xf&!nowpbieD;)K@KM7cM8x7fcvIV)xf~wDaN$p=?WcBBo`r z(;l}8l5gD3VMYQ~4r}O%@SzyN-IsZ6y_H=nGcZWruq}rfRg+w3Wi(RVZ{{xQ2~*7~ zT1YQH`R4s5$dl;ZF+wuY^sFzTuI5&}ggmuWLmh#tz+YdK=wdNKm*=I3$aSHg zwr}z&so9IMW{e zU6%`Io4AWh+Yv$OjVl=OEUk_9Vt}Lku6j!|9{1R7%6m+-(1>Wghg@-x)}>COH2K#M zGe(d=Z(6yOFZA{DOrhX?_6(rl-uBwC zrv;>c4`-E;KvnCIuS)#YFrjMz^H>mES(6Wxk&=)3$~hg=6xWN>g?|0q#5IL~D875b zgnp0QL_Kje|6|oh?IR_W?;;mpMQgSD4g9KKNy|xb8x4!MA1t(hQ2vlJXXDXr-`NcZd&39LiPRqrM0q4%Gq=EGX+2vp&7ScAcHVH4@rw_@_ds@=X;cnz;gv@gP~RU54C@XjgX7u!Uk6{*JAp&FL{wrFp@o6n$Wn9R${_YX<&$t6wvRZ5>zN! z*x21oEVAIE673r;ysYUame@r{nzcmOeTNZ6C->D(CGVF0ZPr7=2oksU)0OCCv|yaW zh-Q_0X+!)>QtIL9Is#RgTZ7?o@I*tr;SY3=g)`xND$)6hri*1X~wpf_Al9 z4^g#^)r}uW?3qqwIyzTyDPDwf)o}U*?abs=V&dH8Is#SA8jznBB{bN;Murh9>5G@w zx#bdmw{*3OHHtMt|B~&jrfuqUOL}pdD5ymwa3rF)CO`MrijSWzwJ5hxS68UQb}|^s zHw)4B)R`+inY35ef=J-_XfT9c9;nb!u%ozpxA|lfg*Nd0CsRVyLMo=7@%Tzi~T+`==I*i^}fIV&c%Iycy?!IpPikV zomIQfDcBKJeOOQb|8;|B89-n4fa-Kwa8Wfm+c5$KY0N!>42 zdfTU^*cGdi((Ee|7|&pMW1Fnaz15UWIR8h*|Aj8hV>HkCqlungGaq~YGD^xCMK={0IrVWGj3B?4V?40%OpUu3-I_Z5WQ>F8t#P7Jc($dvz zFdPXF)(VewG_4vxj&+&#Teu8y@|{!eA>Um*K`YzK&2%?qEJFqFA9%l_?`B+c)y^(U zHlFAcE)nR$`=h~-G9XMp6XR}a*Tg9GOR)cd=SOcZcMH@@)fr|wJj{-vf&{i{+E?v< zPkqIqLq_Y|nJNAT7$UT5{-o^DFdJmIcqP0Yz z3rn58l`<=@?q_E(WnS$h)dv#T9g9n8Ij7KTSFuQU065t#Sm&Gv;D2Br%)^5 zEn^-%JMor*t%R4zbox7QJu-nxcH&=qqqh6Dr+%%!ef3TyvP$>g|H6{yWjK>!WR)%w zsAMO2j4a~Mu;TjW(jPqD4azFrfBy^XhL>(M#o%q4-bw)imFxtMkws)~3Dzcu$9wGG zly!al`(IdV_%uoDEP+5JJHcaQ%_x>?*psp0hqUIwacaR;XUt3YU9MYUldI|1>C@u( zy&kTnllP9A&#v$8YZK&3=h3jb8yop?xn|eioqg_fQn-R1RWS`1b3BWPt77_CQ#YS2 z)7rP<-YP_-9LjIWY*<6PkRzO>`>iqepRv?8NB1hGPtJ?Y{hO`w-4{~Dbi|8(Pv7Hf zS;C3)Z;Dxx#<**{%7!ykPEB2F?muX|@A~moO!%MZJJtI)83!&cr!6=o$3WtUeWJPl z#<{))yYU$Bx9>4F_~52RHjoK)l|Q%D+<)jK-z7~raqZ)OrfF+aEsq^1GE|W8xO~Wb z_E|08vW+>h`N3{e`km5R;g4Ywfv$GLcAL-cH2K>2aAKnSOjFc|5aWTt5eyY1@&z3h zC7sln6K@MuS1qdyn(7uR5$L+(d{7X*t8yYEb1*a8=-Sko&gwj$6rsE=RMmv_U!&A< z^VyW%zTL`JC8D*~kImk2TpLn-xQYrAaw}PBtFQ$@mGmUXJ`#a0SMz>z|Ah&@F}^%T zoSnj&5aFD!kBSNsht2!U{kv@UJv^Pa#GT_Fs~;Zp&|ht7BN6E8vUj7o|KV%CEqn49 zJ;y#)Ul7qgp^b_P64&jCxN*t%)K(tj&4WDZ#KOze+7I3KJ(t2`baH9I{!HGk?a6J;@VdjR3rm4^>|Lp_an};H z-rb5ZRFIIbK~}Z|JCf$0H>grrBG84UK)Hm78$=9sDa=qo!vDu^bN{Yue67y&b~TqB zRMjd>zv-P{BG84UK<{~}2i2lPG^~}Mp@Ky0jP>UJwU7EvdBbCzs%0^K8`w#2GboQl zpi3^@2gNO>xBWWl%ZBA)s31{j6xGL|v%Ym4czry|v}Td>M`Y0`1aFgw26ZT09Y`Ab}Z`c2I7cZtT%GmtJ8-XNf?UT)HR6 zr5n?m~#h*$u?}=ebx0=yL`|aswFRijtV|28! zgF?R#S5{8XUP9y4HAjwTLkkYjntiBaTxNgA>=hiSglN-^F17BM_mu0Q+;v!L#Q#Ls zplc`_^}Vw;^@~celG%TT^q*+dpvgF9yEsU5G;Fe93?zp6X3$YF<;JX` zbX3D`jAK=7dTRy0ZKyr)warP)7ip*T$JGHT5JjSkH7OBlB(OGVoVs2(3r>u&T)R+8BG9$G!XvZS-O9?0 zZM>D34n(j#-Fz%B+J#GP7q1Cyl{AvNFM?f|Q^!)ZW2g}oB#uO+nZ3?dR*Xw#QH-zC zCa{#sk1Q82ZM7hQE<8dSNgWlzS~q-boY`fx1r;O)Pkmtax>;En`-#4KX1KXz0<*rf z+>(FeONl_2eCB!YII{^kn0`J+(eP`VajB2YF=y5)PnwoCraX9S4$QkuNx#LvV={DH zQD*P_(31Y7m7d@7+U(>wOX(8rWn8rEh1n@CQYjtoZTwK}zBwjvsbasPHW5#2+p?2S zI_kT}HrCcPJ#2Q`xJ6lK-N3lK&P95&W34jsv(ac<{Dz3PVI=?l$1CQ{N*$$_3JZ_Y zP(ebDG3#qtR_T;cFWF+MM4+o?*$3i0q72>?!{TCOFOHPgpJj$=s33voM5BWjboFP@ z5WQ+z5p91(i#hORywahWw{c{b1anMAyi#Cf6JzbDM4HWrSKd!*LNPu?9#Oy94AH9> zE~cRp(rcGF@W2jbhffnD{wErHU4C2*Ydu)6_@KBH1Bn@}cbii??@(@4XT5B} z2HFrSn`GYlX5P+e#4oIlokMT9QQQ68BPX4CzN22uE~bXAPV&}JkqG!|EY)DEYAm{4 zUES$@HHpA4#o~8u>1~cd-Py7yFSM5H+iLmp(ptyn!AjvCxs3Sk>(K+WB0ge<;*&EM z9n}D%KYMJp)*T-=(@;SI-@B!qTTZuO@qP|^l8c{2pbJZc#u=j9v%_Jz^ol{vrLsZ- z-vFlfglY6YZDn~q@sf{3pbN{5?rP=6vYTPvTC1XsG<srLHiIkRYL^{Y;XU60$uwLSc$T-xy5&zzFDs;i%fW~-OL=Lp@IaqP=nzD5!;B! z$VQ;++DUrHj9R<%WFEu#tQA|hHm~06-9QZ$B;>a1PsCs%`eh@~Rk;hzvC{QFx+9OV zpQ$Y8o(lSpmi`(lNZ|EP;|xSN5|OKwOrT4?4_Z&G%9>oSqkqp+PeTO>IWx4}T#gl9 zP+u?ANRZthCE(`bh-3u#M3yfb&o`@8w2qN%byLYexdFG#b+1BG82ymA{*DUH#(LU+;LeoQ8Ku?4!u{!DCO} zs<-p^)Ju96mF~4jV1I?)UApjA^;_3N4?b2_BG4t@Ih&2I!2WBd>IcI+OZPz}u)jjN zgoxfm?8-)<3tK4lEbAIskCgKIfODZzOGHBM`$Q4Z{r^Fr3$HZl^?hl|1{}+;XFi-R zU4uwq|B;>tH@0BCj^xot$Ip-mbYaGzzR!ApM&ox{eTRioEWX0h8pSSsXPC=uww`wD$8wajQ1rR=w~YPMLq z;~;@geFnpiRioL-%v;HN_h^Ygm;7`WlDnPWBh7|2>_1(a;l-Ki^UY{=%G?!7O)-Do zY&z}hbN8nDeD`hBeqWk1ofo6b7+%s;?KiC!DzrkmHdLNVHyD(-oO<3@omqiK4klb1 zRk`*@bIg|&%Edt?O}OfbvS@+oIt`e!^dh+>1&I@zX?@YW70MG@sbVn9`R=K=xa7m; z9xNac=)%=W^nAXyjqcH^D08W%m{36i&xziUp4ds>z9lcaFtoiypbOV9(Nk)JL3++A zH`Ld=y-cVefn`X~I17jCM`(o5Z>g_DpbOV984SURVfwPJUh0Ebe`%E#x^M=YcA!}{ zR{!`ULXCG-rI~6ZFdnT=m^NB(aBhm)v7?iU{|jC69DPaiX#I89@oJe(F4BxU61aYX zX2b7V>)Q$ku+?kos<_?*SBvCnLaRlZ#VU7q@zo*&b3W0wpXkZ@HHG;*2-7}(sgcd-n z!eV2UBU|`Ug@muw64#Dm{_C%skU-asHMG8@Ypl|9DPKpnCttL-Iw+X!7<0~q3KDpJ zv|CTgH?5j=SGIUlO%(}rJ*h)00W()9`br+-YlnjRnADamSMkOwDoDs>wcJuwUs%(d z9Z^Fi0$n&WPCNY7s;Zwddb8BA5h^OU(gkBvPyc6g-K|7^wlr{@6axu4`&i%3)VkVs zWzMla5`iu(5gHNhP)7GE){u?v?I)EL5~ZU*nN!_jm7Ed0&R71~uT8ZY#_oOpA=L-E zux4o|Xt!G0))!%H+wo;aTvdu~3^O0iF6G)~v9pR`{$uAFQ9(kEvGiFB{oH9s_Mp*8 zDT|^D^CP`eotUKEDm#pot#ZtS3KDou)Jnco)s@S>Y>rhi6$x}=Nhmz zWs0b%Ab}-KyVkXBuJ=zb%I><=l?ZfUel!?zRtV4+jk0DRO(qo;B=CMk-_9&JQh#4( zjvAiWQzFnMXP*MwE?Txt>c|fC%**OMi#9twUa8FUaX0M?ixyvop-&*0UM$`s{{Hl; z8?}-VM4TdGHW8>G;az02pbymGM3son+8HA1Ip&ZEbUkf&K*XqR$BAl0TqB}75vU+h z@5o*eqxr~cM2t49)ou_mHRP3w1iHGlJ|SY%xWVV{LWxKrVh0hZAn`uqh=_5o11H>m zozu<}u`cPPM4+ot$;%=}LR&t&l#hrfM06km6(kD1IVWO_d&=j$`nGDl(fG@45u;L1PShywpg)-0k(q+yO-P`t_O^#2M)445BI*!vmxzHxpn`;F z*Lxzy;-Q?l(I=m7)H|}3mqweBKv$U+SurA-RUqOh5zUEc^kB3Z6(lOO%!<*XBPX_B zUuqe1tRq`o&Oy39+FhS(PIX?X9G)&;D+WWa-m6Wm9u8uA9$ixLnm}S|$SiYU^eW|( zjXT8{;(bIJxsJvfLLC_r=z0`K1bwM#%`AR>9EpuHxesl{>b5S1qsYCbbZuYZn;Iojg-0)fv&H0 zlSGVy2l$!yAmR=YD~Lb^3CtL@!ZgnVL9}UJSt8JNq0<==}M z$54N_N&$@%kw@}K1iHrOx*=kmI>&R#OCqil@rnpkkiZ;6z3@G*+6^LX*4{87fv$+t z=^{qIbe>}bb5$%6(PeKI=%rW%!?9E3_8#i=h9YgZ!zg>dOfe%(H zEps`Uf*d2oZ%^t+_a8yO={hggp7&mtzja{Ses^T`Z?7wK#9yGPS0KL zZ1NnM_4gq^`B5bjv66^0*H;)(LE_!WuY&ge#R>Nm2eyQcDu1US3liuWb=zLVZuqhs z5mrR(A!2-oAPXu;G(7cN#0WUWiKKv>Y#$M6)?OMC=!$P%M8t6WP=<)pL~JFZkd2py z3KGrdAYQy8W*4*<45(~Mn?i&4@-^_ zF*0^=qUxcPWZrhq4a%ou8^yNklxL+Nk_K}kEh#0Lm+n&{P(h;n9U`b5haTonkWa4_ z(s)a3sM$*dxj^L21wh6)nC*J+NG&f|UnC)Qf+*R~UJaMT@%Kv(_vQzAwi@g~wp zBFsdrBLWp9{HL82G4dbdt)$6?``RHQ{`e`wEW0R#U5q*i!h$u@0DoC_0mnLGIoyLi3r*rH3 ziI}KZO9Z-H@81Y*wB{+El8lNa<{B|ZIY4a-En^2+U`?yk8*Xjpn?RJA@%eJ zFl}$XFjjU%kVK$slJZ#GYX_X-HMs4ps?{nJ#;z{wV?hOpG`q*Nej-*myNeTLQyOW< z^M|r!0Tv4?NMMVhHNqDwX>R$$S<~Y)Bm!OcT+>Air$@Z)Moz4!4O%^p)pRbVp@Iaq ze0pMOUO;=;J)E6=l4L;wU6Y1g7cmOj@VmjqiY2vjl|z~BE=LU&B#vD`pzGO{BoU)yU7iyc-P~bGS{ugNg}7*_ zAc47y-bL=T#j?*alqLOXCK2dL>q_5Cr5e1|j%VtH%NkhhW5ZZxq>qLQ5}1Q&*0O3D z%LtE9RxP-bM4)TQjcC!Xo@MYIFekOJO`f|Wj9u|lG*pnldkH-W_uZ%@j|^q@H9ATJ zx~ArvE@C9U=P`1(jx`ON8_v2;E~0fEqYCBWUMJJX&OOENBkLSZ*i)k(z{DU^^>LxB zPGAcS6(mNPBLr>9!^>*N^`FKhx(3UP@{tI1;Sm}PGY)(*h9`!x?>p;gs35WL^i&b! z+ZTQwn}(h;ZtWAsLfxuje;bcU7Rwu;P}2eZyI`)06n~S?x{EChX7A+QE)+*xhGAo_`~ z%Bo8=%Da2A5#Yl99PQT^;i7i!9?Gt4%b}ry|2O15?W9E|9aNp)&o3^@sm?eS#)|s= zvLJyj?9b8I(&Sv~+JI0t?M+1u6(q1t(>|%Su9%uoOH2rJln8WT50Jj$Ha*EydGt6| zETW%=3KDn~&^QD2EZ5WB+2)6hjs&{op5;4wDq2fKS9&T!1qtkN8Vq;J?K54zK9OBh z3Yxx+w>77}k5vwYxR_4u{3BY%&NVKk*aq*#Z%agFDqW8-Kh-sV7#n%VNqTlVIyr|J zf$GjGo3I_zw*gl+Qm6h2WpAsOk`yH57iW2qT+er|CI@kgt{s#7{;1ETq6B1B=897O9FIM zt?8&*(NW?5LKhw_?R(xiFKgJWBP$o3WD%xaP)K8LsJJ~>Px&~=u^MfsQJ zYbEd)f!32OKdrpg7DS+e1b%g%dP_}$v`z`zJ>n{lXE(OZr}x3vE0?#IHSyp4EiwN4 zXqg$}?|onKyo&@X*$GZ(5tG`K(Bt2H^my2?B}0P${^!bjZmftAp22&sNT8CP;B*#Y zoiIpuh_|mkWb$wT$VB1VzUeDnheRI(GC&LU2PMd&YW-g`LYnqonM|NiId zV;L@D)OgQFqmV!)JHhEJVr1h$-MQ>ikNmxJ>PYb4|6H$|g^L)aTk%mfBv8pta5{_l z)%CY#_twpQky{TP3I6+^t4zb$BF1q?o<)&BB|E|CEW+9KhNZ*(S_!+CPt=j%zyG;% z{D~4VTxc&FTDc4aD%lB6XAyx-g4E^-+Y`1|9 zTDB>{=DqotVDHybM}q(U=eluhqlgjyoA1Md1S;7HPG=DX5(Y79yNT5%U)PY}zyG=V zmE0y`Jetkh6%wdqCpeu&9HZTauGzd#7-m1kgarTn&*j=;tB7&t9q+>-fl79Q(^M64nL6(mX#pazA zN0oe>m+riQPWp*i-t3aiQ59V{?<^Dc?X2}HL^Rp)Q$+;{oEbM5)*V`|#n4eXIPI4R zbQOKQM;z5zmCwZ08F5xipctpy7h~wcS$&zvTsK?WPDH{-7lsNFxbA?SitO#Qm2^~& zwvSXK&~-l`S{#*KCq4)1l_Q51$m?UgCqo4ZocX0U6n6WYhQ0S)|(>c{3Sj+f>?hWzdDb{j;xz3KBT$L^E*xAX70X+G*pnlStrVgJ94mKBG%siDiP?yIV5^#v{s~L1Rd3$ zsSh=rV@VjlU6fBrmlCFzk&)texjB4vaACm<>Q0LBF|??T{|jeX~7SCc)}$H<4CIx0xWV*ruGtt>V~Eb389 zBG82;LTj)8_-X+}TpU_mnq9(KAS^ffhQhSXf|%K{yp9SII3q+~SGgaqHK#M*?W}7^ zpet?2Nl_m`z4;ij@%&S55tVL|C6A6SoCT7JJ1+V4C?c-4N!L(80_S&Ved4X7S{phl zrxv3m0$tsP-V{gG{1We%JUrs8zn~cEv6UJsNZ|Yq^-F4d>fu$qSu3TWM4$`rcC?b* zx~RU_yA}I=Esur@5;$8!ZzweJ)~8VR`5iICf&{wozH2ZHwozG7yq9K)s>J*fdy5@N zYF({tIy9k+_+6$=WmCk%&gTA^dwlQo;m;o#Fg+i+27Q~pIg#P9us`Mh)YQy;js&_wcJvi7>KxI8j`gwsL^E+z`gHyr#fh3k z93ui1B&Hr{A!6(d=fsW=p=^nPh~VQUB+!MWPCG6VaqmwJt@-HVCRC6}oYhps@EgrX z4F=X6&-}N0X}8Z6lWGuMST{6pu%-;_S9P1>_ok`T60sDNo7CQDkDO=Qc@NpUdKK20 zYH;vfg`tAP&J=2u)OOdN;>7G5=hZyaN}iP%AQ9+V)__e*O?OiOzf@ea!<2bnQP#1f54nUrtnC+|lH%dururlpYl%MxYM4)Ry8s!+;9j5il8fs&f|Gm zPMmAuta4)2v`r=?(AB2Cn~1^k^XCt%mdSfJD#7GsS%{={s+`cisKe)>HiT3D>M8 z0$tcDX|*8{jfjXO0u?0orVSG@{HpU9#rh?w4T*^Hyrd$5E~!-->JU+Zh(AQ2g2eCp z?L~}@J9rOZN`r4|IU;gw36}`T1+CIx$WKHkB0g*iSFwd7F=bK%5o6UBetitfmxl!q zvGqWp2?=yztE77^5e&N_vmJcDm|L#C9T3K>~YD6eDz< zI-_cUUUks}i9na!+O5OZsndz5Mg%HIV1JH!WP56x))&a7r_7P}DZ)Drwn~E`^?GAd zVo!U0=+*#+3KDpSqqhOeELVLy=F+DVd`dK0ny=`j@vbYZKc`9UJ85b>M{RFJ@) z8tp>6Ie^t9BJ4_>2?=yztE4N6h|WY@y&Pvk1qtli84UI@ZCOtuj!zk)A%QMzmGoXJ z5q~=5(*21*1qtkH)1Kr--B~`0F|Dh$js&`}RZ=b?!tpPG3KG~mr*+{|ey9Zl?DcQk zs_RIgOMd=%d#DimcFt3)@uj7X3KDX^-6g3I8*bFf-xX?8Z8OuHomwpMLn+^56Vy^PJV+m~6~vk{ec^DD{w$;C*c#FRR}k_PhxDTG`1m(x$DB z(Xl@8XlZQeacveKV4e7G$Ky0q@YxCf6J?(=6WE3fn?yHvCk+)O z@F|Sm9@rGYx@DBMWF9Lhp0NKNFS_`X9{+pR6J*HgiR@hGR|$7VpE6~4@$%s=nW!7s zkF9wbm#}m6Qxz3_g2X3QgW=ZFZtUHI(v}``tQZpL;v*LPsQ8El-J`7PvocS*CG2*u z!m_*gQE`_{Jfde#ryF16KmUE^{P(|bBm|!<4TgJzbFeyb?JYlccM(si{|I!+Epbww z%W5^v$8GD*!Qy$46TFRPC-@O&wcUsHhO7QJAGi&|=nNT8CP;B*$TYEXIhxJhP$_uj@j68!f+7v>d%0SQ#H6P(VhGbrF|wI&%L7deK|h@}w9@S#4M4+qr8Y;KJJCv@moUplY zLhaLIpuQ{GNcrmj?u>>zrr~a82E*byN7NQ^L-fO+>@-x6Xz3eIUlQ4-Y@XGLVl3RC zsop+8`Xw)!Ko{--L*IA4ept<68KN6s+ev%3;cjn88w_=KC8;r)L3&VuoEj=fVC$iL zwf3qS{cfP%Yh02A33MIl7iW$M-KtoY^`rAhu5w%rtrw){qR#`Mf&|tR?a&l zNMEwXRU*)JqS8il;D@cs^d-R*W8w;HrWfg^ry8y#qk@E7g9{oRRY#-_)iWxkDM+Ae z%j~u0z>V9Lmt`hXjB?&?rW+?B^h>*3l`Zv_n4Q`rDj%y)HoCXY`h9!AWaG}m^yZ{P zqOxMuFd|%+Myfw;L-eA{Pg}4=Bid{+r+!XQ6swj-ECt$`qF}6AclUq#viMULbp6Up zSL)9MWl&6~Y{UrPNvhIktX}ugNa;F9!p=X|oSGw1Sv9FQ9hJ+9CF=8fqxE*F(Gr0! zyygvtq6rJs^${U@kG2&ps30Mq`F{hasckEc)qh>Kmk4y>)l4Hrg%+u4?LzcE&*w|m zITBdXH0RZ#y4tHwxSr>+n}YufU6_^Vy@`4qR3~Go{_ImNsXiRsslB#NRJwRHHe#vM zO4;=Lrpta4b^9qLr20UjL(A2oEjZ2UNTqwQ%3+h`=tTY2oVUsNztDwsLp!%brkF0o zPt*r5JCuwH5`Jrzi5MXzc%~k=^Mt9XHc@YWX17G33+slSImaZK{yd$i7yK_g85Ja~ zTV%x;SeRe6BjOWH&2mTRvuf0l2y|iH@NcDDHywO>Nk!?TAsy|dUIq8b)^$d!&35LPDch7eb{mXc%DRZ(ErU1Gc}!1O zrnx+Sq-`(IS??8PYYx1#MKMQiFy7X3m;;w@R0_Y}VEkxTQT!g+b`uc;+Kty!hIwhl z4j0x+O^+1ceS9)F$#`|YF+me(U%CX;a=KXy|9J~5-*+R2)av4 zPVB5YOrMkCsXa;FCK2db7&ckND3FhTXEcrox5u8^79vnV;&NYlW1Y^)@BL09-c0DA zmmuOlXL}t9bTuC}M8rt%u!D%VM67@0sf7`N3KHe4hKd*~t8!x39uIvk5uNtbk_dFo z`q)gw@ERIN#7-h=5YdeYRFH^wZy{oAsKbesV{_~K)_7^pmbRA&bTzJ<^_9|}^EML^ zGSXU)PM>3GzpSf{3KDXice(OVGu`#lhUM=q5$O8une}zx=t$m5YJc3UrBj`cyEIHk z1qp0p217R@DiINyjX>8DW7aokFI4Akw|&bImd-!J^dTdvY8%Jp71!N^I%|zqYpCV7 zpg!aBb;h#GYly$Ey|9jmPDBI{F@*?JkZAC^nxJnvZQ$pT?~o<`moRYR}CMJ^UGMrfS4Nb`?fs0L4; zyqb&zx;~}FiWu!(`1exZ62V(Zqf=LtQ9;7hhu#dM^XQ>+qU@LVmhdVg^b(zRXxK6^ zqhf7Rk1VIRR=-Pt-te)tjtUZT-u=rtYSG5ldqUp<15`iwPP5O=r5vdfTJrSrN@$=>=5hGyXc8U?Z`FFZI%IQ2)mmhDfbE4iIMIz9JwMlc^L|h_b0uiVn z5piaoi1DQezvGNgqaA_!2Iz(BuD2k8F1eKqBBBNn2kWf2pn^o{HVZ|J0sg#QxxJaH zmA+n1|MFVV@s5o5U93&oW6O1#W;?`Queza?jtUZ(QE6X;&3Cl}-R$+;=DHGrF09Qg z;sg-}B2YmB?+gaR-a|$8<3uz$R8k_)g|$g{H6rd2QHuyvkia_}eLt#xEkT49c&QXtUBm!Mno3zU&5g&*++F_A~3KH1kFc=0+ z>ZBLkZm)03`^$m^y5v^UmxvNXgc5-Y64-O1yqn%z3;gP(O^*#>Bd2HGAtMi8HAW2j zAih(4q3|{1BhQaXyzkSq!!>G&yK+yWJ+?fxK8wN_Do7MAGe;2q8YhZ3^49to!u2}6 zuO?v@#gO0$tXTvqg+mE6)<)K?Fal zCPbiugip#e5o5=V6e8Xnv=_vtO|K*ZUFXLJi5R9{mx;Jc#4L(2jR;hbNO;*-#OT!S z5)tt+XDyt_A6QI90$p>;)fF**tiMjgaUytGl^_BYBsLfH7BMRJ;B`L!*$_cQU92V% z=yLP=lf>)8$MF^s6`v2WOr;pNi9iL3<0rl+@q2BqIFN*5Cn z=&J7f*q6snY#>XWBT?LPB7BNb$ zI!{D25xhSBBLWp9w&y)4Vi;y!AmYCA(Zq@9D|RXp=z5!8Uc|5jUm?QJ_|e4c<2ezi zAklZGB4QL=bCrl8w>JnP*}j5ApsVqgnumB7-G`54 zZKlxuW5O~O33P=dJrFVM6n+g3BjN>}`5q!rLE?7uJrUzYbxss+9LheN^3uXQUYn3W z*WC+GM2xp{`TfI+h#o0k+RK}jUfP_~p$ruy?k^f3=pUVV7Hw9$mFjgO%5t`%OrWdd!^lIt&Tj|t zYcSWQfoi`}UfRSqAq*8HUid^E;zzjIgA=S9Bsk_dERdovgsjlZIvvCc5Q zY%o}AiAXFe5GYz@azB2LnpMq~jY#v-)GwVR0$tcDDYx(3qxpZRWce^*gw!v=9!KER zs$%qGOeT--CPqKTd-4qT^|g)uq>Gm}>4?8Xplgb2QxRk3 zu8TzIgYxU2n^mzKKi7ewg2WPQ+W(5`U!)vf8wTX$WUL-5h=aY(5Ydu|Xgc##M4*Di_;%yPQ5BrViTfM->g$Lo6nRTU z0$m*pGewNliN}bzMMQaO?Inmn1&In@XNnk!i#QQce4Oq{M5(Qw5`nI2tFy)c4;1BB z)B+-Y(wRSq^HkAQXxwpoV#MUwry_wa>@(7ia=Wi+6Np$#1S&|(&)ZGJNb%v<;3!M1 zHj#)_>n9R{F6=YXegH%S6Y+)!RFEil)HVg@L;eDR^?ewm} zG9uiFKn00dmoG`YK5XmrzR$Vh4wgkkERW4C5$MAH3dJB|J`t;lKn00z1!s#G1$Ocn zcZ}(#B}BB!mrEkhh5asiQ;&!_M06(t6(qhsA90AEM|@k+)88Jdaw7WC*K9qq|Lem3 zBkhh)#6lvb5rGQ+U-vROeR+&|3eW9b^ChWKMAUC^K_bwFy;_>1Ct^GiGl@V2iROov zh!~~gcyFoTzUL~h^McON5`ix4&(q8g5j;i#B2Yo%?D>l## z(1lMK2E%Y7MiX(52vm^Bv-zEfv9KTS``q!iXLBk0%(a^$J+Yt*pO)wuhlp7`UlD-{ z5NT3Td5xq@L#10~w5P=F3cyFigdG-6CZ6jjEZ%>Ip7iJpHB1}7;t4N>=GZF0@MZ|g{+7f{Z68O|dL=)z1yy8;t&kcjd` zpn?QG_0iKrEq@k8*=OmW6cZBYk~7?YMD!-&s^P2&6(sOEk><9&8FQxI>(NFP^?VoB ziRW{5`$J=7g)ZVb({bxlW5doJ#1rJGb5ALY?#w;O^pbkvy%vS(i-Lb7@u!As!|xi8 z^jIW*pH01MtnIp3JP&qrx<^FqTUGSSM5MVZdPd?@(c8XX{;{!Y(_Z3t$f5hjFBNEi zKzh1+{^mXrB^rn73Dn>1=>FO=u;>b*bl&*USjI6{5WZ_a8rNQ3F8*Hc*E=E{iLj?dwiQm2&b05y3=QsH|d%Km`ex z7f~Wci|w2kSHNHQzv8KVC~m7Gfv)FAX(W|m81FnLqT<_5`ioAlE%`^#H3A9}ScdfG zWJCkKmYuhDvtBugK$qW(DI&(z7Cc5Bs*eKnjB}gn0~I9X`uIY1?oVYkfa)CUc1yJ% zqMpMN?;5c!&^NsuPg!cx2z|`yP^pz5QL-Y^V6DMsmT4W+9ViOixq>hRK?7xsXT>d>J4%#FqX&YbHY5$G!VAxVtM z?E0QY#P`=WtUZl0%sAq&qk_bxYDdMG%pPxk=FbakP;b%o@qFfRi9i=-3HqAg&OK_W zkCiN6CXCQgL1M1Mc+tkz4COUAAicNxirUr8*bs?87q(D(pOf~u^QSSgj1z^8JLl~X zS*`b}FUI3j;zU+!cxZnrPcQO`Vl*Wp=!}>4=6qpEK_b`FEIPP2Cqj#lV=br# z`)&2q@PDBzwfPDWJ9NcsB32S{exj%LGR{*&1&M?a(c%c(C2->T#=fi-m9BT>Er~!E zW^LNTbjUzvH~F5$;o?;d6(sr`>M3IE82pT46f$*UU2}MA#oF5GNT3T_D6Ks6^A@ui z?|NPJb$#(%jH}z3%T2E&0$sPv6-10z=lB(s ziwHiZpFspFNKA;#A!1y+!|$B;W6qlR++EGUVj2?Ynm(<>AwD-zea;;sY*(BWGYx)3 zpn}Au|ANH)hwVk4sZTx|B8W2=t4Rd9#`l}T$cT?|!j_T!x=R}kuf|u?ArHcg>BoX_3rPQqWlXph}ig-m_`IDNJI}@AY!;~dPu}`<)eik)xIls8WQMA zsTU;9W9T8C+u#2s%tWAqMB=tlB8Jm^o_E{a-eBP^ah-hyi9lD6gxVrT&au2*c@V*` z!2v{|g2dFpiipv&9ItblB@+bAl1T)*s#g3hT1mJKCuo*T92L!yX{aD^V1L$p?eLht zvt-7kf&|Wg(JXRNvysn|c|N33VQAIp!mGew_*Nx1 zn@wXGkww=lmiASF?Z2-;)qBlqHyLq1r;P-6iya&ls&&k#qZ3;*3g(t zi}d3bB+!Mqi=NMkm`OxcB2YmhWnWy@2uoXjkLsKHU5u(-UNu}p0$rH9Xw-m+2#Rr+ z2vm?bGd)f8^SIMGYfjOOAaKfiBEl^mhNQE9z<@#u0%E5=o^~>hh7hUSD`` zsih@WjV2<-`iVrK3v(Cssfpk*W)Xo364zd@5hIY7%kyjSTcVe`f{4mPZFD5ig{_B1 zop*YPqpC^-DoErRvLK0{N2OQ%8l=%$)8Elri9i=#H)6Eb^mnvYM+J%IlYWXAB_{9~ zG+Jxcej;O9Z;`Zb$b) zBBm4ZoCs8qcr}mS^P+YYbDG~dlfCWrwUm9v(>uvXpbPKXv>KO))jY!yfeI3X(&^1G zig9Zp@Ab{!9l-j~SjMBYp9*F*?89RwqOYG4;X*`*&p#DZkig!#!BA4~$oy$c=Bl@o zh6K7W6VdK_T1S>hgkvoy4HYD?KTq%c_%sw_OLn^sNd&qu6VVuQt%mF)#mGqnDo9}O zoK`@5ab_=xFs9|zkw6z_BDxz85llpTB2Ym>e&&4M?}Iv)h(o_UB?4WTiRkS!BKR2c zZX!@Y0-r2tEaQ5lI-iI zj*0R1;zXc=1degg7YkDgh%x>9Q_4vMx-b(N40c5DG2~1lP(cF6nP}BydM(;3o{YDd7oBG-$;Zb5vU-6<7Kou+P$^Dj)=f#lO+OOn2Bh8I}v;y$Bzh9kic<2 zFHYIB;Xwo{NL-CwDV`?!CjOxq&mNZ2mQjq1 z)B7a?U8kZJi5OG**-(u4MDU|}K?EvD6e>Pn#PI!WO~mWlzb%~T=5M7Ufv&mF#)}v~ z9qfoWM8t9;niGKv68^=4MGWf+wnW@_+%1THzGWl=T?1_!i5NaFauSga1S&|ZyHroa z*jb+ww#}Mb4BzQ3q1Emx66k6kT|&h8mY9o(L?V7sE7?f|Do7-RI*J%Oe(@O7zRlI9 z_o&Y}&*l1|b$fRv*_5*F1uAM!eD}CHjoj-Qs8m%rbr|DoD(EMXS7tuDIw2 zwcXMlWA*U2XOdkuOp^$7P2aLu997KW-$Z!MA5QCoy|kni>s3^csC#gch_QVOC)_Uw z>i#qW`R>Foi9pxHVKYRGG#}pDixRPqRy10l{H3CT#DTT+Z6`X9KNUD}=tT{E0}+#= zyciPbl1q0#5$FFBs2~yKH9*8D6U6JI^<#Qxg6h28=k^QW*CrL@7k8uu;f@| z#2keg74r&R=R|ZS;)K*V?z#WTawrzNyLRD+v8=3q#mOU_q~hzKEK9ucS@al1(|5o2FN zp5a!X3DB?4v)6mZWf-w#;2jxj(_lD2L;?|gH)j}8K>{-dJr$j5s}HDTuU9D0OGN@* zSerCDNJJzNK185`1ZE6c_j=k_5ZhLzN(8#FHmMg*L|2NDNCYZK;Pp>W^!thn;`R?G zh6K8>Ht7p4LZ{_uIh+cSH&o~L;hT*XC7AK zjvT)um+AO88*#7w@SV5BOd>`T@hBUCt{*=4#kgq9I8OZe(^);&&tLEM(ZEna;>aCa z(b^Y_K0_Bnp!${w$7}?;97eqs<30&@_%&FmQk**7IhTI&dJ~2U5-wmBSg9BKA0DoXsijA8y+@5xMY~TFzWU z3mP_vb#TiRGl$)}*_rVF%0xdR_EU`UM4*BM9wCi7zp^%#q!^>`!9(lbr0}-eoQLWu9G5fmc%1^r27IS>BHnh&IFFWoj5$M7; zOL1O3Wg<_m~eC#iZv8>|?bx>@8?wm)!AwK|5dlP?f<)_G+r%7XDJ%Z0#zwoU zyw01L=9==ne;|}8SH2tLrd|-gcOLm=EW6$;j%r>eFWtdcJ(&w#Q9Vx8WLO8wzAO`S z&5Oc+825LI74chQ0kFldeQx-+_G2_f86%{0~Ht8*FB6<;Fl_C@9 zD!zG>Xjh2^d5kT82C=9^HM9>`538skfvu8yWJD~W+@3!hfv!67>qI;Lk%PyWd*4&N z&)i1ePIX1|m8U@iiNP zE*w=i7&2aVV)Ih$^>d?XrzV(pL;}l@dKpA?BjRp|OrQ(L-f1Q-V?1j{&$V-%Y*bW` zkV|(N5!2~u`l_Z?)@BdPBPFKr_A}i)+NEw=1ad5wC^Tho}Z~EcaIV zei`%q2bxhqVxxW5o4Q4-aN)d8oJP{R2 zIS8U@qYQ~am)d%zh>o((N85$NifyKEg^ z=P$eReQ~}u9;g1;+({4f`=zR@1{1SQY$^+u#==lZ&p)cn_m z>#mnJYN#Nw`~JNH{HQMOSWhuF(oyl9TjJ@ckU*DwRDKU0sMSVzX~FG!>xHIl60311 zp5AN>D6mSb1ibiXvvF39{9=_}$=CeM>k(o4OQ3>8O|_(0rRV3$ck9XdD~wGW>7`v5 zxGovj0^@t4Rl^>OEZT4oZ|(L(bRi;;2vm^R7yLlv#4p1+vANI~wu5$>`W?|lLjql6 zwaZzVI+eHfD?}vFdWkMXpn}AT7w1KcM{hW>zG`1~n}`ZyZc7BZ9yH!2Vhlc*K*W3^ zKGPmsb%;O(39H-m#yX97S>NHrvvz*Wn)aqz=2%8Y0$saW_YpD1Hrqx-j`n_R0Ugx> zB2YoXw5FSgv3@Kkp171`Pl$NExv@l`3)_Oh5Wc!HbMoD+JP&LjwGt$X+U6E9O4aAr z;D#IyY!2;aH9NhnM4$^>4851yDK{(Cmqu~CYbIkJ#8u^(yXekIgf|gS>eNg|1&I!k zpF|J9rwwl<<9g;~X*B|LpF!^|NT3UI7d^2Mag_)cB2YmhU%vYy#-KU8l`Mb%O}$RU z^6n!wB+!Mqi*`dK;xQ3(i9iL3=0y*P7zJzdR`SJgOMOH{)Q5P9Ko{mNx}vOZsr<~B z6M+g6Q47b47qQ=)$_8CrBbb5nL$bLvQ-3+smZyF}a|qB{|&Ab~lCW=y{25Jb0nRV4ylSephz zb0Q8?j6Ou5f`puX#t%QD@;dkaSWhC*CAX6HMEoJ5DiNq4f%h(YleOR^wdl4*THfPB zbyru{WT#X6m8X%-l%JVZlAUfIQhMBOqIfT?oSfQapYqt)N}*Z(+evEcQ5Cc;cZTSw zAh9L2N^`#ZImY|2@-aV;@#_2q_0{V6TB(9P zbySczf7UtKY0gQavWZ^iSVMalX~tK9jM z1CKpW4lAvcr==Z}V}{&U?B~@dBHx9jYR|2C^{~LEIx0xu(Ne!%->K$q=%9b@+(07G zwJyym*=gH7UtUZauzFJslM!*=T<@g%7Pwe~xTQzj?M`OH+yUMQt+OH!q>>Syj0o+^B5O%4>AqD;H_`m>@5-KsyoCc zIp)$!W&R;fwEmfyK;L)J>zIN(-k!gyc>V6ExCQ1guC-hgzmKl4Hm*_ch~L-Ge$Gaq zf`r|fYl3Kd^X>m2&~@%W z4vn!E#ELhk{|AAt;ZO4mLVuGz1}aEQUQ$R9RlcTVi-82X9=MhggxjR_Yy>Ju9IjSQ z5Lchv`5y$j)@aUxXkPGZHUbqST9m6I2%p{mi$IsHT1gO#ZEdNyge?&jB-|Ri3gYK6 zyZ=F;D`9%pd0ZQpFB^dh65|h77BPwk7yKUty4G$hDTr@{N@OEYK_c9#v>LZQ`ailGww(S^nM4$_gO-ht&8l)AQ?izn(syjyo2{B?sQw`f#;)p;OjxjVl@Z(f< z&bZ{bZ{NCeRFDwk*fXkO8xtK7=)#eY=209JU^L|(5f}O;f}?_j7{S|zH}$cN?T!d^ zNn^gjztt;NI%Rj|LDze6RFDv7qVrV4c8+pHpbO7k^gm~MAouW(^cpZL2S)`7aYn5f z(bUIwu60D93(vvyOVRC6{xK!l%e#9mh6)m*hxVl!;ymw|Ko|CDz3+1ByWZ4yRaB4= zM_L%wupNWccmI<>7ap5>-{sVIy{YeNs30Lmj3HFRHkMG|{Z9g2IL6R(WRFu>#JFUy zOy9d}s30N6vGP>IHYPeE(1jzP-ghn3cfF|ZYN#L~M({+@clEKI`tE-c=)y6d-d=9? zN~ONLgZi$93KHT>RF`Vl&QXpCbm6&+vOm)UwQqiranoky&`?1_oKbJmac(=;IwH`8 z=U{r$>2|1=?{RWmk8bqeHk`MS!12*Bfi4_f=^m$lfU)e)v1GB+8*_^#PR2`(>ni(# z?wVgU&0utnwY9|+vS`zjH6H?unOBrJFq%dmyt;2ND4!)bLyF>hKR}6{memqG>Z4M; zxwxiYF($xx;?nSa6Nx|tiPxVCSc07&Dh&?@5V2u-Q)8D39TL;hWddClmKU@Hk9nXZ zCJG{|Z-B9C`t&_wOtfE*U(4P-?yGs8hrzh~lUS+&0|JaqM>nY<2vo$H|03jjgL$;A zvL^Tb7lE!z?dksGa;nm0q^NOH2{6uSUTS|B)j$OaycaPT&P@+6y1yv4Uu-KR(1qKO zo``P~V7zzt_x+QIKm`fBL!zf%-0K*}2KgKqWk;Y3_pE$A|IOo6g2&#wO2gdD|65L% zMA6kDCr&wi@sn$yg2cn2X*w~r zLwUOzNT93wilaKA99yzqu7L^?#d2mei#1PlJ@!8cbnQEPK(8^gwo9U10~I8i^!=n0 zhEG2KgFx5z*Hd-k{kFw+1S&{OUJ|Dh+vXqn9|XE`R^6}nh2p;4jz9&8LIEFj;@a!) z|ARo+W{*>TV(T``&FlzNkjR$H#a|HnhJ5=U1iESl&D4pIc?;|aRFH5g{-;jV>}S8` zNT91qV4_Z}ar%-V?{id;xHU9QCx&z=Z?{xPpsUh~qdIZ##FAQa4OEaQl{2Fvw$-4_ z$NmR_uGq7bpiD$)Z5MyJ1}aDd^!=n0`9Jym4+34$uczw7w{45<2vm^xZAqL?Y@C1O ze-P;Ms=8nASDwE1qYo-b_y>H{iA%4)+tokVb5xU6%I#gEMwj77Sd`x&?Z@-8 zCRC8Xtw8f4_sGP5xCCjJM-*0(Kv&h>RKu`J3E3xVSQ`yed&F&z+gNS_Key;{qW(q& z`(hOB2fsD-DMM|yp3eHyp9Yh7jtmJ@?1>*l+latcg|x-5Kg4CM-<%^M+P_>gFVmYZ z^q=tgmY=Bx5~$b{9Q}ulsE=M#4J1&p zCst4m8}Zijn(1zG4X+(5!ZjpB`orz+zM&dOpkhx1Q4JeGJ&{x2^`gG3AtBnoTt}0R-wMjkr3@)t}+{r=ry`d6XO*UsMr&XYS@VBp4Y6OlWWAqtqA8xi1sfR zj+OML9uTP56Te1ON-1bKNcEv{cQ=i@i(Bu|mv{HH!$#3|p4HSxw4Y8suYW#2!(mzv zBv7#@N)c@%deFGLlE&Tidd)c!qW#NtKO|MJ(IxLes(}P5_Jl$;Y(%D?<8CJ!caae7 zU#_e@UGy=rawRL(KmrweLeMq>M^Pk1`&`2~_NfOyQ6G1y@2W_M_Al2QzjXb% zzPTq7sRk0L*b_aehK-<}$myEFi>?{0NQm|?*PJyO_4PRNSsWh{0g62lN;PamHyU@H zY22+$<1T)eX#aAZyy&ObSiMUe=SZMpPi&(aHbSLww=s>o5j5^1A=9`{7;H8D2-}TJG$E zShI)g4rO|d{3gBK^pxICdU43?(fpv2>v2B1LY_m!hXpxm2m+NS!%vu#@>rFtH}l!( zpWj5!y=I2qud?QdsF6T5oc+h{vtKH7o#}i>U)maRg6I`I zQCqv#m+hPW)QWSr<6QBtSI;*0nZ3(@qgBiuVO7>wy&&B(9zOO($M9 z5H)txtf6h|<;%-g+9wg{n)hLvxlhZ>{*R}M8fQP$(cTR#&0X4NVW=Qcw-_zex-X22Fc!NdP%}Ai@%+fLDK9}$MCzTd8vRG=UxsxevVb?wjDoETYGEOIM z_7F8@PU@zmEYHMhH~+|-a!oh)Y4V4^f0d#p-2Yo|(6*v|ek5DbpTrX)Y7v1764)0E zhABH5Yc0yBu;U$XO9Z+GXPc_;!M1mcP>m%-tSFbl0)lTdRFJ^FK+o@bl+eb|K5uv? zULw%dYG$@{=hgOF2;caumqegz=LFiiw9nhu z5;bCpXimg?B2YmB_pHG%@=kU&$})*xxx3nm1iB_|AExiY5*NijANrFxLIf&EU|%p8 zJU$oGd}~)@f9$EpKRb=p_xag^#Z0*Wzh|a?MMrJ_uVSB{FI8NdG{ct_9#M~@f&}&j z`aep|t)=aJ#ZEP>A`$3X@NJ~N&+{A-HA-g4tyQL865qBeM+FJ&3-m;Mm2`FgtTMcK zjsg;au8^K1^rN=kVo~G!y*Fy$C137bS-g+@kAejD1$ytU`v%p&bP$hmyU&n7*MQhT z`cXUTrP$~1Gp)irh$LcoMD6WNCdhT)aa}4^YJZ2jlUjU zFo|CBF8dcNDo9{opgjGEPU;zB8*}$U6ZwEwWfGG{#whJRMjIQJES{KTh*9Q!k2cnr z>zo)>Z?00sN4%kM?|p<;yy=%X^V?_(zA+%$!X@UV!l(S{`NWkGT1?ZkmUc~=TTwyc zaof8(T`65$U%6kKpw&Nh!MN^PSB3<-R!VOV+;$nJRZclz?bUA=Lj{T3MYriS{Jq6o zzlnSLYGD`Mj63e!kqC6*Q~oq2_GqMy{!!GjZ+;1m3KICtKIO248MW#+cUVJ{D{)ki z5YOz3?Phzj-)(I^PufzY=;pB;_gbNA+Y_Ra)+%o6 zV(j){tH-hG*KUg}1+2q4?pq|}cTBSVbz6=+7)J#O zc|E+JuVYm|&$3<}I#Qp_T&%5_86D>-$E`r=_;*+W$u8Ia z+uSN~qC}ueUaHnERe6O?1~xrUEorWJoCRLY*8Xd0=^e$5S^1}&E35qdxV^L<_JsJg z);9Bd`+kp^bKM@5oBYvRB5)RXTqio$9x2Oj*BETMmbIQlnEDLXm*n)i9mb|32I{{W zBnOK3?v5Pl#WUrfY#bc%ilKr;dekhPPI$A2h`bj9d1M7omb0UqwC3nq7&=9-ku&=) zB3e+galepzUUwT;XJV!=u}vNiNHpTCUq~Aa%?gC^7Bk(g;a~GHRFIfcbhcijQfDzE zblx+=d9BkIjCF!zB?4XYQk^O^jL#dm%UXHtE@`&38~x|#OE@o$8!PRfsJFw9oTM7x zYKLol(6hKp&)%AFJjXKw&R$6KS|*2Tn>uH-mZ(-;MFL&@3S800#2*=sP>sng!g!wG zENYL@O-;BYI0GDRLpsj4Pvlp;&Rd&Ludbqk1kTh(@7;Zi;Ac`&y$l8NTaZ8(ZfSZq zOHdc|FoTw$moH^I${HyK1N)fiqSb48C!_v|@f~#y10BsYswJ zZNfZ#TfJ%{=FW5*T1mT_y35idi>jf5ggnP#Ro*kKO}5mP2y}@V67>;PpGWay z1Ny&UM_6o|pkU43C5}`v+oDXQ&AY6=u2-n??cf0#Dme2Z&f7@Wi7VHs*G-Am&Ywm| z1iHj*l47ZBvsBJ$Fi0(yE2YW_`i0)!C6-FKWMZ~@+Vt>QZLe)STtfwCsl-_^=^w=h zQ>;Y>*0VON79kPn67yG!rJ76gSJRyr5$}Hz=)$==Wx_PF5Az+g%_}BsfflQc^u&Oa&FIGs?EM~qMNsxY#lR09c|S(4syKVszTs3$HcURfg0C67Mw zCF}A(sJG8ORZc?%37lEdV8~joA8)mIzA^mXEfooL;V7Yx4BFn5)VS1A`HeW61HPrM zx9NXeG53VQurxW0?>+UztJ(gHOwXVrMa*`Cd35PRR_Ooy)*dbXS6lHVj2F03&01>L zTPrF^;G8yeW!ZK-&oaA&Y0QQ(3<-2K@TAl_s$I!b{O48FG??ccHbrfew3MNO1kPzg z*Kmyrb4%qU*1Ak(jx(I-GoR4+iuqA+z8QLRvQHj9^1xQLOM6Y4i3W+eS;O?D3e5kA zmTFRLeO~(GVe7W6r6dAfIByOmrDqS|TeEfY>Qb+wG(XP22wFnh96USv1@rGg%e=N# z$}AE3_x^OrYd-0CAU`vvm^z}qyEM-a64i1p(AOi+IWaFlw(o=ZzH>B3j{8ArE+KT` zR;LW1hMBn<8&)MCDEQx7myT_*b#c4Vb7b=`v(j}7?YI^^fTNEs6SimQIZnb$8uDV!1;LSEjsSY)>O_JH_&C0M4(G= z)0Vc)3`FCumB!sT8h1G=NZ`CeG+SCmXKR&aZ>(2}$OO7v-XBjCz1=-lB#fsF3^f(` zFjXD#bu32(33*FbeQPnC~Zu}kexw4|DE&tQ%U5;(gKC1x#prta#v zkQwuIln8X`bN?)(jsoEm{M(UF2(i{~e>Pa)1Rk$0dP0#So z;u(=cBG9GJd_y&C^W*GT-BFty=Ng}NxGP8HUj%Ih8!@|UUu_aS`!TOgGD8Aq)G2LU zsISMXO=2dR#OL%oLpLY&bMO_03KBSj4_(969jiHCK4I+_J5?gkWzH3&*H~R$JO@zj z=qRnk&?#!)x!w#FB=Fp2FbpgduAQ7x!c=J93o8=niX1?5BGI;L5h()oAOUxET+8$c+>3J^!eltBJFKd$~;ygkDGdK z@hA-yBrt21Qn~8?#eQ2ml0DnmLn6?nt?r`NSan_GS+4ryIxC>{V29fFlu~Fhg;XZA zV_CQ|Wrk_qmG&AcNMN2R&93sL9N+5Jl%;f1Bm!N3mY$|BRsR_x$uo<)4{x)`O)ayx zx`ql8n6gUGQ{Rr@cN4yP#izPRNw;{#ff=?mf7HzgzSVc2c}#?x2^A!kji8)daeVX> zIjMUJhw{2n#g#v5G*Xd37iQSfd#QOt`Jg44y#Kt~NJZse1l6#m>2_&7l4ri)>b<~o zqeSTRPZws`(mU>^5xna03f8-B+f-DL2&H-Z#Cj|*F0xopF6zQ-d|zpr5oXYkK$pCA zv)<~=D`s)?US2k%hRVMPs$omh?Vhm_k3E*jJJqG6MCkNS7w%d5H{-`GwdaT9+csA0 zr`5bqcMY4DDy`PKm_*KD@Pmbl$T`H!L%QdTIIdpy-)&s=Y>p=ZO@2*HpQ|!?WCo`_1L)b zaiWLEd}aAJk?UN-JBK#KZFB6Dn(g#NIkC1P2N2Wc0z+E)d)$sx7CjQ_d!O>=(3ake z@GIMxYJh@7oxl`7kNR_!-*1S0p8I=tZK1V(!lIyd8dk60`bMG$k5pFd%w)H9KUK=1 zjftG)7hPLk4$3GfD)yMEh4?px=G2glYYkJc;8KGpTsDg-a-(asEZn+KPKJB=q*rtw8sjYmQhu znoe7q91Q2Tt`xI)l)Ix8YE@S$?_1Oo{PM1{ctss$_=7T*;2)Qid{rCJJ0|D4B(hV7 z7O3|+hDZdu^5rUP3GR1A@#!H_BOXSiu*O>_sf#{z=BOa?Z9ipMoIasE4is4yud8R| z%{`W?$!RSm0$rDfRJ25eomB3xYeGb5U>061xsO`-O-qgn60J*DvIO_BD7~hM8ku4; z@u#spu4+sebl$5inDBu#!3Xbs_vx(k=kaZ z($MsU25B0PW-Q)O*tw^U=jx9 zA$NaH*Cd5_gPi3h0$sR8=qaT1vFeN28(7uj1Ep<+1SWdWxEt}idOvtQGiK-~5$MA0 zMtMt?*=oyTf3wH$+Dpk4m^p##XD~ReTA;S5<;nx6HJ8={2|1Z!+?)AoT+Y0_jDLNJ zKo@Rxn(?+qd(+g6&TQJWFkWgg<$H90pp-TTDBaY87LU|>%4*L#O4;xtmZ-b8mB$^# zTU!r(znR=O&10KBjpC>vfq5YGAIrt;YEbfe_Q`*^l>32+9{5f4blcM`YW#|2tWBGd zQVk^JZRNcuzdAN)5qlmg6X@#ugHl|^TvW1@5s4?$nueR=#}#K|dQ75U4gOJ(kbB9q zgNsc)EP0s67MVa-ilw|IY3XsL^gA*7w0-^FxcK==>(fo)92F$+NTbx#osCSl^3Ae( zm5z`IbWNG!X^BcUEA20dBWkgAvL!NYh~@j+2#yL8@-aAo4P{AxZZy5gJ6c!!{PaZEAR9w6s)6;Mhe8O}l?%mB+s@rM;60bm3N~JC@M?Z1?F%)7V|(rTu{f zj=^+YGGAkdl3tnC?w=qL=#u-@#C|ue%glZj_nF~Rzd{1fCG_0ufe`DA0>_Lovmzt{ zU3lCW3{U%PHF+P8Wx0}vNknF9Y`HQ7p|Ydz>9S;PGzmkhdxJ0 zYmS7xt;#>z%T6Y4Ra^7!(iK2rQb|kjvD?avHw~4b7DX%`6`v_fTZ?mTw=6+?!>TK4 zY}%jf#eyo9sO46rXL=jD%J8xT&!Fe}o&+gP>6 zah6Z!YyoaYC3im}=2W?4T{$#di@I3Zgao>NoTnP2+>KRR^d;hWgXV1M3d(#vang$4 zdvLbFlI^d|#%l9BD@fDtqOQ@b{?=hy=+uHLDo7kT;%XVPyGG$Vnoj2&`WTHG}ls}~S8j@8b}D?Vwj9lYpeMFoixw+dLI?x!hQ4pGB=EDx{0 zuC-RFbD#+cbal%^*Cl^{P-aXLTX)~XvV2r(9WC|18Y?PD_)es2)LE~Tgz24#s640= zKl99_&8(D1BG9#FK`~3zmRHKv3Zh1zk}AJnsgmaHmd}a`5(}GAO4_n}%HGSOCq4}J z=U4K0X@fKDkqC618%+ zHe3zQsqwEbt*9U&A0It?Qxbcio7VfrOo>3(l+2zMkA4>wb!=~{G0|fvuXoI>jyVx; z?6k(q;*ry;{Mgk)dAY&Ml9Xam&X_`!$&0F3JX#)BTKq1KDCd$Pe9*BJ^>fx+R{Sok zArqMzhVVS*+iI|DS(;VnpYK8fk9nFSSMASzW6rAYy1PgOx^QXfnY*Ed_=`tLYM$82 zeB^-AmZ+M^%9_rt6uh%=HC3<#=enfSJuY&*p3s$Ru2qNBFEPFx6(sPUfu5&6U7!2+ zv#NC$XOakX$@d1Ib8NKc%KO!HwPqMkGiS2|?@v=QnCmJya_*l-DQVMEl)s9KtMJiB zQ>zBa|uzx${lf#)kqByC%p&1h>Qo8`Rvmg^Lp0bOsA~C%paAnJH(lDMd=Cb(wAfEsRJPrfvzjglqgu~oATVLF%en38?u$W+iSXuRKYKO-1K1Ua3 zT+sDy$a+hSm3h_PrZA}|B7u9;VEE%;s^v=I&#HAscYbl!U308QW@E-@jTP)MQ3c+Z ztITjQCLR%MUdCGA+C1%qx~AhGjtUZ3kDfwOcUb?r`bb^zJwzhVb*0k}bGC=h#=Y}J zjZTHLv*==X)PSgJD<_(D+P45-UHPW!F~pmr zf&`9ylx?`njc54&McrOYCeVd_jE?gg`Ar%aqenrMbaaZk$$PML*=Y7E zpQpAmR~3#55*q_uE!na<8zWwc8oxan%qDmA)#5KymI!oV?=cvv?i<3sJgBCbR4=Jt zNknafW!^~_NZ{2OJsmyVi%0nv({k=PDG}(xo=-{XTfBIO=;B(R zen%K8NZ@rTUCDIy;P=~9*FL^FED`9!o=>x-b@$+ts?^Y0?BNU*B=Gv0(pwihad+27 zTGLW8fiCR%^af9)6E9S|krqCPF;tMi`viL8xBLY*G(!ii@FlB6pbL9G-LZ_n!0r!g zuesCnC8!{Q_dE2QW#i?nV6L9pt^Qmh(1ksp<`W*Ug2l&o*VLy+87fHNeH&eiM)|R+ zN&U1<0WyIu?D_O$e|42H)<=uKeU_nu1m0Ovy4co_mcr);Y7+;XkqC6jM^w?g>#QHY zcF|@8{a~mdf%nujHwUHGrBP~KZc42asY6MX{!m075hf5}QjlW;U7{_%$(A~Fh0^!( zQ~KUuO5Z~T2}}y2H<26==n`#F!d^6@>6F(foe~u!F!zUk{cu2_OSDA|Tk6m(O4($TvRQ;uHc>$WlY;1900#uRL|fFb zr4CWzqV_%|IZo+bOGO0+04GRxQYrAI6fK-jtF$&=qe{PVb%v`iv)*7Cak0Ege!lE z=PWU$85Ja2B`rvNLB!Lj6gvW4n33X`Km`d*agmn_33PqGk=YzVxgSRh*e?|-NMIU` zTmuPo#pbMQenmAV4Sj02=BOZnsXB5EB+#{PM}Koa$|-t(Njz1I`vVmuFdfJ-fv%B9 zMw&NJjfjdOfd*@!f&``Lo8W+tQnjNe#_`6+J5q_ z5syu|1}aEYy7;@_9-@n=;)!@%4eF-8PP?s($u^jzQ{JzdIf?d1P|yiG0u>}W9QZqNJ`r`6hW3-C1#@( zkFnjwn|er~f&^x=I400_Y4~4>GpR=4o$YotaH-lwR`EYT+sA+Yej{!}xdtjo41TWp zUnhE7Myp*7B+!LruC@ZS!AT(w!-g17v`nN1S&|hEn9nkB-Myz z;%z{zfdslRFGVI$L81*Gl$f7-NuPXY?P?%_F3hQtYoLOJ&y6R05J_&7%bU6>&y*FXgc99QU$ ze)UmyZ2KK`-SO7i#o?#TNjVQI#dhU0Z7F`v>~Ze6qSel4YWnh_IZ4~AJa8A;J1Hl> zGxzl!wbvf?RNtm2%uy3BDD`gVF+Iv~!R+CFM!9<~kLls=^XA~UrNWpwtq~$F`f3cArzcT=JVbE!{;4sOOb&5A_V8p@rGdu`%l6=EF2pkf`@!t2yc0 zC1u4uK~(n)XXl$|&^G;OD-r0bt^8?D>T*RnGDr|rRu^ZtPLxrrhfk2U6($>DMv}o$ zf2=XFb%%TAV830;iRt-Fm}Z12NA&hUr-RIL?00qehaTGCb1%)oUp6VZ z8|O2XNqu4tp1(!uHX)x0zlr`a=`odsg_+dJUq?zckZ?Kjz#Lp>n=*WrSdTHKO0X1D zQFZ-sd8yEaOGxi|jZ}H)l9JlP>pj$(T`3`UdbCo%YhKg#!jH|tul6f1JLI+7gNs~M zzVB#pEq&hv756O??yH`ggP-qF#xEE9{A54_UV3XOZNs%z(s!W?dym0TvtL%8wzjo4 z^0=mA?>o@snmNgITyZ~W-xHVk=Hm+nHPBMZO;fRtBJsxamN{wDVP(ZWu~ezM3-F}O z^vXo*RT6}-G zh9dQ~zTvsFMUk7#9u2N5)#l_gom&x2>0&n(L%n=<>oF`~D03e)Mhi?RuVM|{3b=j- z!`k)pS*qJmEnnra(wZY7Z>tA?%wy~64T%1ow@3uKj%MFr_NaPG*;-5-QPXEFVukty zYwXTL6%{0Ko#>y2>0g*zt4nHdN`38UhIi(q)f<(LZuv}j%**5Mz`Mz;a;+n3-|4M2 zRFIHIhR!XIvV50st9x>{kqC6*m``sBm9NWYKlM}(?j9$Na7f7GRcNEitk}CEYVF4I zn20VMAL$99y@Oec4%xM8A+~i&)-e&9qmaT{TpYz%5N%H|rTTcu9cP zZnuj>pbL+G8b!<8V=Y?u)Ox!arQ;k4Jh}{qq`GIBPsdJL>Ha$;0$q51q-VD0m*(j) zS+sI@-8CFVasT6pVK7W+T!B9skyU$K$5}%K3G55>Z+LchzAmYPw$IH)BG4tT`SeNd zym-lqT3b^l4HYEtYJmQ)&3A^4f2nD+R%ez7bm4xY3_9e?1Tm!!gUE-cv zeAB;wXFwNbNI51@K?3u()dshj}VDRa%E4n(lOAB^MEn>I}h&Ksu`njUS$ z??sxP((CT8{;1H$nyF2wR0F>kGY<`hvg@0x#_(a*KSL%;X^2P+sFRT3ksPDE>L>Cn z2Yzp>es_0gYO(P$0WM5NG#I|#xNPn&;zjRRk@Y<}DoDutBO?5W_3Mmk{J`a!5`nH#kB22B zm5Nn*Q;#tiV)pf8Sv`aJ(p+B|_7d#**x%^AiLF_!eUFXd<9+8#JrM~!3g~(2Tvx3P z@ATlypT3p|bg5In9*7zrt6cpedSdBSR+InZ0sQ5L(+m|P1o4&c;mP;3cvfP3de1-`%}pTkK@~wG4t&A$JjD0jFE1Y`SM7Uv~`h~U7&tK z)E`@w?QWu%Y+kj^6y;Qm?`qOmBG83>!C+{8F5a}N!edr;O^DP>ka)RzQi4af7-i8G zQA3-tPW}A73L92w7{{DU?0qtEBjY6X<6b8=?#CF83KDW}SI=nbkQrCm=;t94fi8JH zM&#LI%9`;gyS-qLv>r&{(M8W8pJ{K3sxpx!-ybIvFs@+FHyG|Lx@NqaYX}SZIG&?| z-;1M!!B9JIQERoWuh_8Jy(9u%c&yMfcQr;@+iq*msz05;Q9%MnC3-XL+Z=1uu#N0- z%m|4<7mlv<{@|Z$Ef3oyGT%N!rEwPtd4vniyIOs+#g`3?k@q?7&EKl#NC;jLqZEE6 zddaKJOVy~UpIQGaZ8<7PU>_6j<9MpQI=k{i!`n#&y5u9OT7|Caww*Qj$gPz)DoEh5 zLci}WA7<*?rZvCmmRBOsg-0_zS(Dbr`u=7ehxpfYS zK$kqi<(eB~Z5i5{*DaNYqk;tX1xo8VZe$M!<>W)$T1o`E@VrZjS&hrGBSrIa%l76R z6(sPyOYiCr_hy*}mgL>f){zKw$>;M76=ztd9NoZ@%Z`*1VoPs7o0zm?_tI%jyhj zf$LLP`SCJ=E_tb{ZO*C|KAXrg@In0Uc$)W-uA_>&%U3c;(>`AjsRk9JOb}}T&s^lJ zPHf)2!Fas^%^B4+R_S(jgV7Y8tbabcjyS8$@6bd&bEyoU(9*7)J$(E+QQomi?S`U8 z>eB`43$-Qx5S~dQ(6!LTU=~|sLV&1|e{xYZr-c&IzIJ0VMEN?E*|Jq7Sc%dTIOYXo zj&Q+6FB5}{ZB*C>vCrGBE6fIa`?9di6F4eJ++@yXF(&4DCy2mz&g@K~M7FTvV2ME2 z#glImqegF5atss1jjXZO(={Hlze0L*RFLSgD3dw()@H>#R}f(>{PrN= z^6#Lndwi>slvfa?mmITJ3ogMAyENpeAmOyh#T+$tn=;2m5c9n&v1Qde@bsTN{a#;* z^I5_kR2?G`=)xnI-n8iW)tIf>2DWqiNRA2;xYa2;!TF@A#+@guKyojMK$lYWLt^lT zt;+e5Vp}a}?qu@w{J^r8>B&(+0=FThU${J0EPvPKw;z|42z1GPH?HDW(@u9I?|s#i zqk@FIKT6H}&3e&i7{6ZqPlg1#@VGG;BHYHS^G8)74Uq=IX$rCAqaxfV3Xy!fi;o&Q7gVx2q+1{q_OUwn73&BFY`?_{j7* z(1Ra!Y9M5Xy4SJYK|MWwK_%VYvw^4RV>kciKJ zGv?Eoqk;sE`IND>V7gkN`waGX;h_?NEu($d6r0& zzUrGOYCP$BmugHVq8cq#K05+knD9yO59T)UN*8@u?hWN^xwVv9B@%5h>2}nLP_w8p zrrsl}@#%&K&r3@+aZe+T3KEzcOL=`s9(*S4b9(|^n>x4BYdkmHqZ+4{?Pb%5C|zX` zM+FH?j-{twh^R?K89M@9!SxF3H9Cadp&IW$R%N{|__7a$$8%JWz~oqi;cmv&)@IbN zN+yqx2y`X;p3wJ4!Yh#w+a@`Kbqn>~WzR!7Do9{*Eaf2|X{COp^)SVcl?Zg<-lQ{5 z^ZaW2Ye(3sSpzsKNMLfT!LV%O@9HPo=dqKAO9Z;)Ua~%WLq3pdENtZ@^%C6wm_kfX z)+o*S6k3lqr}A)AkeKZ`U*8{PUWlje&x{?wO|-2heX%km&?ToEuYJ^;zb_rll8djG z(x#DEGin5$M9CYlC6en+be>v@h$i!%527Mpsp$*Tkw919X3NYTITtE7_KO;>`P}%c@j<+< zI^Wu`9IzwMm7z(1UL&y6Ln3y5nX1<~7hRE| zg2bzL`Slu6HG>POi0ed%Z53ljpsVPLa{9lo);!`}nQLdN>4f+4f>uq7@#ZewkR3KCB*%(0D$ou5;U3PjBK4+32{ zuF!R21~Dz5`G(- z=`{*n6-U%ABBoG{&UOU4a9p9;BU&_O(}9mh^5)m1eyK_{K$mDr4QJmH0CW|Ywsi%Cb3y4VhT_(_lOBix57DW>s6jI$%qh2x6B(6IJ&HH?Vf8$L2rkZ`L0=RUC>Uq^_u zL31KT5i!G#Ko^cH^pswmqv~iPJTe?(s2~xwGrzy6k=^SF)u>B^*t!Ng0$n(+(0x>i zkE&Sn3*~1pRFLRADNL_%B9|C_77)>oh%`F_T{y1L^SkdeYXgY5+wqwd6(kPVU8dJa zUnj;AZz6^g!R-ii;kZKczAEn8JsKIBSY{=ng2c&Awi-v`#aMFM-(7Q_5X37EkqLC+ zNJQ!QbVc@(uE_Em?ppDh3a`j;T%r5Hug!SD3~s#t@&ybPB=EY_U@#IsRFJ^yYx+Nmh^$0}+Y#u(afN;n{+yBLBZ9T6#8E*4@5tymOCl-~ zalnp17mh0i!=j_BSZN|!SetNEkia`K8sUg=CZdZSfi4_Z=nm3tt~DDGUFQXHRFJ?s zGJ0D5C(*@@Ko^cH^iPAIkLpG>)~*iZs33uNWOQ#pL{1`B+7al&afN#O#&fDz45n}6>wjvbg&6(r=}$c_+^Ok>HD>oS2Z9EoVD*gH$z#1MYD?=uxs zA~TsVFWzAA^B%`1(miK)bA8+V2J|bAnE3!_M3_5#g?^Xn-(AeQ(CcX^ zkF4g)X7|W!LInw&vw^Pqh`3(WmksGD6X?RbSA$_#sgAr|jYljwXoa*?SP$nqpsNf% zlEVDcc^*LP!KxXhnKqEv zAJ$o~5oHl6|7VD(N(8qf(1r7YP-dp*4c3W>G!`Pw|AEAwWfk=r^J-pKL~W; zj41S8(c=r&$x+R?+mC|M{02Dd!jho!`gP*fBl@fh%L=lq-3s%nHydfFAc1p4P~zf| zCDt9wL-@i;PgLyT*z*&wmDcZsbL0>;syCTzjGs7>zkSkHZCEfxzf;Ue^RD3h2J-RI zF1&NT$^Yt2m*~JJqhzNaul#>Z`W%0HB z5|%kRm598oI(zUrlozOfSV08|{Jo6+871N#5vLo-1iJ9oHu|0Vk8{?7K0!QlU03Od z!ea&Zzrk=}z))5xwFCd#>x+sC5(8TX>Br!@AknYx5K)+jirzATF5LfgKL2)r6(Hi> zf(sPP^-qsO;^y0My~a=)E9u`QBE(W1up`ig`=4gvyYqroA>v+*B`PXN%$Ye)udzB> z^pX`s6eq%|rc9s<_rJj~;!Y+mh;`rcsi+{aBz~!0W3wiD$w(rK60y;aKo{Lk z3ehjNTQ-i9=HNjB$9c-koNi=Y_PX(BE80o~y0DKK44#7%*u~6ld`G8x(%6m!o_%b@ zNg{UJ5$M7`Mk&q7Irs%4ej8F!Lj?&ui_-tKL~J2qgB^h`>|+MQgW|4#n;r67U7+{-oayU-;vi^MmHlp~oy1quA+ z-Z6nLJkn$W6(sPNd&dO2@OYF7k>e%SP-KGPzgZ$fOnm;|d1Sv7B=CPKxdwh0x^U0R z1S&}2|5T0%bYUNp36T{h)WN9EGJy&b znAho;Ko=&i$^_@h$nGl8wPV!kPzeXKbe`12z22HPC3G^Q%$$4 z&WJk}8o^OP0#hp;6X?S83cbfN~9w9TVunvjpY0mz%)1H^1%`+-ML(1qm_o{gZ&{h(H&Pm3mL))OWq9@0w6S z0&^}M6X?RT1pOjhxf`EY*Uh_Jm+vYnNQf)We=;*25$M8eRK4$V>bqXlcQsUyz+_Cv z1iJ9bS?`Hf>bqXlcQsUyz$`__1iJ7JK<|kv_1#L;cQsUy5cehjWKB|U|4#y4cqgOx zM2-4x9Q9od6(qzR**}??)Z71)Ko{P_(X&zICTQm7*W;WT4N_4-0>?+k1iEl^l}Ay` zH4yDznTeRC=$Jqk<~GQqC@M&ZY>0mnFma?t0$rFUA&`XxyEZc656d8h24a0(12o6X?RX{%MXw8h1Op>%P3w#MFk1`zusV2m#d*xYH4wv ze*B3E33SOz_4@EQ?ah84mTRM*G-Emv_@)j0uhwU{R=j--t7rUb!tX+tJkxmN>ON}O zkD+{iFBcR3%89?+_{4YCpZ;ujNBojn_b0KC2vm^3ZAeen(AHf`TX&G%*2S%W-$ehb zeaxtJUm3(Jr5tIw2-O`G#I^$T%SknlW2bhjPK{+b<#_}F=+vHXY@ z+_0@6@4c&{B`HIkGXGH<1?v?VMKzA>P%<=WPekUW#o4rxHTkXr-W(Mqur|F*)^fM? zY1Tmg$TNpTpli2U(Gomjhtk_aER{>|7na1pK%TWlPL2u^@_PJX{9yU}dON<&J-bAp zD>{K@ki4;78Ga&&YBah!ijCRSniuoS%uzw&fwh9gqy7$M$yBja)6=#X+g51LKkvvU z5$M_wM_c#Yc4f#jQRCyjf_(URqZYb0S`ARr%~6qSl`6mIH9aq%ZcfU#R=ID2>RSFh{Nek0+Ln-|Dk?}|Mk_tN^LQZN zTlTb?=YVQL0$rHiYcTw!HRiwV%&k=pnJ6X1BJpihnmPE+dgaCdu~gY2tMfeVJ+;GY zCaS0)flEmHd}?<7>3Ml=!Q>1Y66or-{=L~_<$9%LfXL+>IqN+uRH&J@{O}PK6(n$P z(py_4zq5-Lqt-t5xkRAr<)e@0s3vQa#;ZjQQ`fa@^t=#lcJUo5Do7MvMoGv0|4^=U z7DSKk%UPDx{#unYOCB$#_fZSLnh%MsMnUKE}$w+N)N(9V{ge8`nQIdn}Ar2Je#E$I#k3H&y zx&t&!8%81^*F$rZIYv>>i8cS+bw1k_AE_p<8zd3v!W3eI;Y;70)}*zOYBSF<8Y)QC z8GOea++~vzd0y1;IaiL2_V88LE*&os=#tZo^K2>1cI@t{6*}}teR1`fIm&yja*CbvkP51s4q!Jht(erze*eU9~3X4brAIaFqyjo!Lt3 zF>;+Urje*oaeOgux#g~X>6=SR3&ozl=MW{wE{j&q6%%Vd>PHd&@>~w>kMh|yRFJ@< zfZiS$c7wGVl0!@HR8u0*b@1UUbMUkcivKiGBWS=emblxbO|WLwP(cEZ0)yfAUePQ% zxx99&g11DV3%5GW478{to2m@ZzIyMK_6HJp6wvcN&D*nmo;|g<&bK52UAS-PZH{^? ztUl$#w3J5~RaB6W$KB@>Us!yd!nA@R9uk2r9P=sl^z9;Rs=;0JxzRzxeTzL2dk>vC z-`8d_!G*P=PUST3Czi*>#GM9_%7#%YCD<<9KQ=)n9ECd^vhPWd8Zz+#M(i;_Q6V) zC}poQsb&jh{F_Ra;QMsbY|$@jYrj_E3Z7e zl`E&3Ddo!071Eh_rAe=rO5A?BQo9|m94jQ|!oF*6YuenlBJVR#mAMa5Tsy9{eo9J1!;ls<^EI(Efo8_;mXyl`r$|Hp3qATvMiYPTEuQ;&A4VtlXRM0e#UO)k2FzZYY}hW zH6;tb+&h(_f<&W8A~Nn$PQDlO3FkVNU2Wo-fk(~?<^y-qzOB4l`PjLcg8dl#8_nC- zcb9d=V<(<{VrPj!*XT^Nb$jeqssuEn8cx@}__l{1+4^?bI4VfUJ#pFQ(X3pbTD;=L z@)ChATqk-`c8C{u4$jIwnx`>TkSNlDj@tS=mGmK^w@1|Q<}EK|;hkHjN(8#(ZMAUs zCUxQMO3b&$P^q`$w!(f){|{Oks$~tGn5FC(jtUaE)x{Cj(3HDK2Nv!+Q6kWV{n%i* zT)nGt;EB#`U51Gq6(n%0(@e2hOS9)yTQm1I<0JxI*pF%E_i6NQU%z&&bk{K)6(n%0 z8w`KftI5aAOtpT@TTLR+g+1S3C|b&!_r65q)znmm3KDn}&|T4u2yMINW8A)-*-g&b ziW;A6TxI_F(%C55&%&Dei1t$FqQ+O$c1q4fzhyWg&?Va9n`}fqK1o|r@?l)Ufl?|e zNKEZq#OM^>)MxN#H>%-?K$mEX8a84`yJ1>PCLUMTx>rR7iCgyz84FX5ytlJa4MzmJ zL|fFb5zn%B)c(x6B(6|vHw_geyf+s#`cVz%Zn>z2BLZEbEo#_^@(*ii_1x;ly(;9R zp@KyE;e5u z5^Yh#MtpgFRE_!TY~`9Q2WzMxu`w*WaRb$eY+8_NI3my`+M(=V_oYc6^XdNn{p1&Ow2zA6Q& zM#vbk&m9ry5^Yh#MpVg@kC$Bi*6Ur-)*32EjB)#v_%aYQSL?rUuXX4HRW=4D=J9*74TFkO*L3VMylb6K$mEX8a9H?IGoN=UUZIP zs34KH_?eQOYHT|FovwWx5$FK|sfHs0U7{^&*a$k~a5_hM(K(8vf<&gH zpOn2+4U=o}SC=O~T}5_L=DH$J8sm$!<(>xe*?Xp0&)g3dS^oulIE9K}&VqSgC? z##>Zl(@}8@IwH^|+MWDoEh{j=|uFKo{OE84TvXBY2z4U%WyxEHeIGaF=<| zzDo()AC)qR_GaG=X3?&h)7BnXsx#Gc>_OJ z?^_qMX{aFK7qL~Zv1nmgs^N%0muQO`HX`IyH6C-!;QhT?Z4DJ9N)3$BYeZHlM>QM~ z=n`#F!$y3n;=(^{f9$o|xs!$p5~cgD(Q9Oi5j7kU=n`#F!$vfU+roTL%-;xxI(5z$|dIW5hZ*ipt1Kve-y4ED*tiU6KS# z3fj9v9WvTpA_Fn39XfakcIXso&@v(D&-Z!1m*>!X`@-{l_%O!r_iTUOEyouJd!sKa zIl~DPcZ+wt<82L}BSdfuHylcgot#VLR;MCo9*i1Jm?-=l_l}f>BSdfuHyldj8tcu8 zh$%F;oHLv-VVB0d<5C|SA%a`D;ZUOIV#J*7@32kDKEnwUJ*g4z7+k>f5hA#S8xAGX zr6DsvHEBmyb{kHZxEITKN6QyH=OKbyxZzOZbMmrzJ^#W^tS=`xVWML#?H$+m!4V?3 zg&Phfc5P%$zO8BxZFMI&VIn_p-aG1Jc<+V?ZsCSQiOfpg9NVnfv^^Z>go*d<>Nx%T zpg2MVw{XLugsg#){jjnhuQ*|1uuC0N4vrAPE!=P@vHJC<`M9T{xIf-n=7fpP3;KP; zs_o(k5!}KJhZ3>|M)sp9`%&hEi4V7i{r5531V@PA7H&9{kTo!}A4S;@#|abtsjJ?R z89E@25Wy|na3~>bU}QgvvLB8UCPwo&yyMFAHgSXqZsCSQ30VUp`(b5294AaH9oP3& zEe=PB;1+H;l#n$@$bMMa561};&EF>d=g8Jt#StR7g&Phfo;>^!-#am5OPx8#2@}KL zr`53zM~L7SZtw6!tg>91In`?mm7L>*iR7M>%M~L7SZa9>XH7Lt|6l6afCrli9 zHs>7+UHCpi1h;U*p@gh~ll_RuemG8;Xk1(Hj_F5mga~fohC>Ni11I|tmHlv>Fk${a z_Kv3sJRc!~Te#s+Le{{^ene$I94AaP@6_LkpSIx$5!}KJhZ3>|PWB@z`%&hEiGerz zd-wJV-X$S|Te#s+V#h|-U2CgG$G5u6oG@{}L4UVzFXFu$BDjSc4kcs_oa{$b_M^lJ S6Z}8H@5g@$Zt=g2y1IX}zK^^B literal 0 HcmV?d00001 diff --git a/src/linkerhand_calibration/urdf/o12_left/meshes/middle_distal.STL b/src/linkerhand_calibration/urdf/o12_left/meshes/middle_distal.STL new file mode 100644 index 0000000000000000000000000000000000000000..2759f69690b659018c9d763fbc7939277a104d04 GIT binary patch literal 769984 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Ξ{ZGJBd6-e;uDF2)wzFLrs&J`Ty z2(PC(bXU!CM9*YDuIyYyJ^)3Rq1>F8{BX6g7E87h!~HHJO;X|P609uS2Fy}GA? z1avisWaIAS>v;cT-CeA|o=lKdyog2iCFU|zAOUMWyPx6bc_}$%8nS4m79jy$dduzP zCLJ%}M@v4lFml#zmC9ewK?z%`)lh*1Y{9H1o~etH^~Rv*@ry-BK-UQ$joj|pajb4# z&B8d*s!B2(8-gaCn4p0QBw*jgp1408AidchhpMBNa`O^6lGP+S%Yi4)Vmf^;E043i zU8ULkDtPQ!>C}X9l=ZwyfCO~G9ARg1#-EcOcMC^_u8Y)AfdoGT_$g;ghWqBC?jPGI zkbo{&7g#&bvy(PFh(~Wc-^*}D2Iq`$uEn0p{O2hhc*#yjhb@qy0tvqNndlxRwO`5J z?r+;3Ljt;B`Lm~%(Y{j3k~lPN{Q(&&kbpe~I~^?*OXo&LpwHhgbE^jEg1OG-3~}qE z4$C5u)9pPNDv*FZ2HRW8_$$@*pMcKHn#Pqmbivxg-n39%l`0~`P(Z8;SFa!edkl6; zuVFKkG-Eh==Q^FMyU+!1S!LfVayCLQ#`>cZWzRKGfduSh*nP;J9nkO+cDt%Dfm;

@Va91Xb0DPl5}3f?#QLrQ~-%;OYK| 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