From 7f84225ba85c921b4db8298c678d6ab2743b4cd2 Mon Sep 17 00:00:00 2001
From: lxp <2770281812@qq.com>
Date: Mon, 31 Aug 2026 19:22:51 +0800
Subject: [PATCH] refactor: dispatch formal calibration through model profiles
---
src/linkerhand_calibration/README.md | 10 +
.../launch/three_camera_calibration.launch.py | 6 +-
.../linkerhand_calibration/compat/__init__.py | 8 +-
.../compat/config_v1.py | 17 +
.../compat/legacy/__init__.py | 3 +
.../compat/legacy/session_v1.py | 24 +
.../core/domain/profile.py | 7 +
.../core/geometry/__init__.py | 16 +
.../core/geometry/extrinsics.py | 212 +
.../linkerhand_calibration/extrinsics.py | 203 +-
.../linkerhand_calibration/full_hand.py | 2242 +--
.../models/g20/_adapter.py | 18 +-
.../models/g20/artifacts.py | 3 +
.../models/g20/golden_regression.py | 144 +
.../models/g20/legacy_11.py | 2 +-
.../models/g20/motion.py | 2 +-
.../linkerhand_calibration/models/g20/node.py | 13060 +++++++++++++++
.../models/g20/offline_replay.py | 2032 +++
.../models/g20/profile.py | 2241 +++
.../models/g20/publication.py | 914 ++
.../models/g20/right_19.py | 2 +-
.../models/g20/runner.py | 2 +-
.../models/g20/zero_policy.py | 4 +-
.../models/g20/zero_solver.py | 4635 ++++++
.../linkerhand_calibration/models/registry.py | 1 +
.../linkerhand_calibration/offline_replay.py | 1942 +--
.../linkerhand_calibration/product.py | 47 +-
.../linkerhand_calibration/publication.py | 915 +-
.../runtime/nodes/__init__.py | 2 +-
.../runtime/nodes/calibration.py | 23 +
.../three_camera_node.py | 13061 +---------------
.../linkerhand_calibration/urdf_zero.py | 4636 +-----
src/linkerhand_calibration/setup.py | 8 +-
.../test/test_architecture.py | 88 +
.../test/test_g20_right_product.py | 31 +-
.../test/test_golden_regression.py | 72 +
.../test/test_offline_replay.py | 17 +
.../test/test_package_rename.py | 14 +-
38 files changed, 23710 insertions(+), 22954 deletions(-)
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/compat/legacy/session_v1.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/core/geometry/extrinsics.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py
create mode 100644 src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py
create mode 100644 src/linkerhand_calibration/test/test_golden_regression.py
diff --git a/src/linkerhand_calibration/README.md b/src/linkerhand_calibration/README.md
index 75ce488..f51fc83 100644
--- a/src/linkerhand_calibration/README.md
+++ b/src/linkerhand_calibration/README.md
@@ -34,6 +34,16 @@ Tag、零位目标、URDF关节和文件哈希校验。v1 配置原文不改;v
`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
diff --git a/src/linkerhand_calibration/launch/three_camera_calibration.launch.py b/src/linkerhand_calibration/launch/three_camera_calibration.launch.py
index 3d53e8a..06c0831 100644
--- a/src/linkerhand_calibration/launch/three_camera_calibration.launch.py
+++ b/src/linkerhand_calibration/launch/three_camera_calibration.launch.py
@@ -88,7 +88,7 @@ def _launch_stack(context):
expected_source_hash = LaunchConfiguration(
"source_urdf_expected_sha256"
).perform(context).strip().lower()
- if contract.profile.supports("urdf_zero_publication"):
+ if contract.typed_profile.artifacts.publish_corrected_urdf:
if re.fullmatch(r"[0-9a-f]{64}", expected_source_hash) is None:
raise RuntimeError(
"G20 right product layout requires source_urdf_expected_sha256 confirmed "
@@ -276,6 +276,10 @@ def _launch_stack(context):
name="g20_calibration",
output="screen",
emulate_tty=True,
+ arguments=[
+ "--profile-id",
+ contract.typed_profile.key.profile_id,
+ ],
parameters=[
LaunchConfiguration("calibration_config"),
{
diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/__init__.py
index 1065fa8..74b7c19 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/compat/__init__.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/compat/__init__.py
@@ -1,11 +1,17 @@
"""Compatibility adapters for one-release calibration migrations."""
-from .config_v1 import product_profile_key
+from .config_v1 import (
+ legacy_default_profile_key,
+ product_profile_key,
+ resolve_legacy_profile_alias,
+)
from .defaults import default_product_config_path
from .paths import resolve_renamed_package_path
__all__ = [
"default_product_config_path",
+ "legacy_default_profile_key",
"product_profile_key",
+ "resolve_legacy_profile_alias",
"resolve_renamed_package_path",
]
diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/config_v1.py b/src/linkerhand_calibration/linkerhand_calibration/compat/config_v1.py
index 4de28f9..a54cffc 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/compat/config_v1.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/compat/config_v1.py
@@ -28,3 +28,20 @@ def product_profile_key(raw: Mapping[str, Any]) -> ProfileKey:
if not layout and (model, side) == ("G20", "right"):
layout = "g20_right_19"
return ProfileKey(model, side, layout, 1)
+
+
+def legacy_default_profile_key() -> ProfileKey:
+ """Preserve the former no-argument executable for one release."""
+ return ProfileKey("G20", "right", "g20_right_19", 1)
+
+
+def resolve_legacy_profile_alias(key: ProfileKey) -> ProfileKey:
+ """Map retired layout identifiers to their reviewed physical profile."""
+ if (
+ key.model == "G20"
+ and key.side == "right"
+ and key.layout == "g20_right_15"
+ and key.revision == 1
+ ):
+ return ProfileKey("G20", "right", "g20_right_19", 1)
+ return key
diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/__init__.py
index 75ffb11..eab30d7 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/__init__.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/__init__.py
@@ -1 +1,4 @@
"""Legacy single-camera algorithms retained for one compatibility release."""
+from .session_v1 import uses_coupled_full_hand_zero_solver
+
+__all__ = ["uses_coupled_full_hand_zero_solver"]
diff --git a/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/session_v1.py b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/session_v1.py
new file mode 100644
index 0000000..daa5103
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/compat/legacy/session_v1.py
@@ -0,0 +1,24 @@
+"""Version selection for replaying durable pre-v3 hardware sessions."""
+
+from __future__ import annotations
+
+from typing import Any, Mapping
+
+
+def uses_coupled_full_hand_zero_solver(
+ session_start: Mapping[str, Any],
+) -> bool:
+ """Return the solver contract recorded by the legacy session header.
+
+ Capabilities are not consulted by the live runtime. This adapter reads
+ the durable v1 header only so offline replay can reproduce an artifact
+ created before the independent thumb solver was introduced.
+ """
+ capabilities = {
+ str(value) for value in session_start.get("capabilities", ())
+ }
+ return (
+ int(session_start.get("sample_schema_version", 1)) == 1
+ and "palm_axis_side_channel_v2" in capabilities
+ and "palm_axis_relative_motion_v3" not in capabilities
+ )
diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py b/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py
index de37899..9ed7a59 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/core/domain/profile.py
@@ -23,6 +23,8 @@ class ProfileKey:
object.__setattr__(self, "revision", int(self.revision))
if not self.model or not self.side or not self.layout:
raise ValueError("profile identity fields must be non-empty")
+ if self.side not in {"left", "right"}:
+ raise ValueError("profile side must be left or right")
if self.revision < 1:
raise ValueError("profile revision must be positive")
@@ -74,6 +76,10 @@ class VisionRigSpec:
views: tuple[ViewSpec, ...]
common_frame: str
extrinsic_reference_view: str
+ extrinsics_quality_limits: Mapping[str, float] = field(
+ default_factory=dict
+ )
+ minimum_capture_counts: Mapping[str, int] = field(default_factory=dict)
@property
def view_names(self) -> tuple[str, ...]:
@@ -168,6 +174,7 @@ class ArtifactPolicy:
protected_input_fields: frozenset[str]
publication_pointer: str = "latest_passed"
session_compatibility_tokens: frozenset[str] = frozenset()
+ publish_corrected_urdf: bool = False
@dataclass(frozen=True)
diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/__init__.py
index 6691cb1..2bc55d4 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/__init__.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/__init__.py
@@ -1,5 +1,14 @@
"""Pure geometry used by online and offline calibration."""
+from .extrinsics import (
+ CameraCalibrationIdentity,
+ CameraExtrinsics,
+ camera_info_fingerprint,
+ load_camera_extrinsics,
+ matrix_payload,
+ transform_matrix,
+ validate_camera_extrinsics_payload,
+)
from .rotation import (
delta_rotation_vector,
fit_rotation_axis,
@@ -13,13 +22,20 @@ from .rotation import (
)
__all__ = [
+ "CameraCalibrationIdentity",
+ "CameraExtrinsics",
+ "camera_info_fingerprint",
"delta_rotation_vector",
"fit_rotation_axis",
"image_plane_tag_quaternion_xyzw",
+ "load_camera_extrinsics",
+ "matrix_payload",
"normalize_quaternion_xyzw",
"relative_quaternion_xyzw",
"robust_rotation_summary",
"rotation_inlier_fraction",
"rotation_rms_rad",
"rotation_spread_rad",
+ "transform_matrix",
+ "validate_camera_extrinsics_payload",
]
diff --git a/src/linkerhand_calibration/linkerhand_calibration/core/geometry/extrinsics.py b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/extrinsics.py
new file mode 100644
index 0000000..6825976
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/core/geometry/extrinsics.py
@@ -0,0 +1,212 @@
+"""Camera-extrinsic data model shared by calibration and runtime nodes."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping, Sequence
+
+import numpy as np
+import yaml
+from scipy.spatial.transform import Rotation
+
+
+def camera_info_fingerprint(
+ *,
+ width: int,
+ height: int,
+ camera_matrix: Sequence[Sequence[float]] | Sequence[float],
+ distortion: Sequence[float] = (),
+ rectification: Sequence[float] = (),
+ projection: Sequence[float] = (),
+) -> str:
+ """Return a stable fingerprint for rectified image geometry."""
+ matrix = np.asarray(camera_matrix, dtype=float).reshape(3, 3)
+ payload = {
+ "width": int(width),
+ "height": int(height),
+ "camera_matrix": [round(float(value), 12) for value in matrix.flat],
+ "distortion": [round(float(value), 12) for value in distortion],
+ "rectification": [round(float(value), 12) for value in rectification],
+ "projection": [round(float(value), 12) for value in projection],
+ }
+ encoded = json.dumps(
+ payload, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def transform_matrix(
+ translation_xyz_m: Sequence[float],
+ quaternion_xyzw: Sequence[float],
+) -> np.ndarray:
+ translation = np.asarray(translation_xyz_m, dtype=float)
+ quaternion = np.asarray(quaternion_xyzw, dtype=float)
+ if translation.shape != (3,) or not np.all(np.isfinite(translation)):
+ raise ValueError("translation_xyz_m must contain three finite values")
+ if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
+ raise ValueError("quaternion_xyzw must contain four finite values")
+ norm = float(np.linalg.norm(quaternion))
+ if norm < 1.0e-12:
+ raise ValueError("quaternion_xyzw has zero norm")
+ result = np.eye(4, dtype=float)
+ result[:3, :3] = Rotation.from_quat(quaternion / norm).as_matrix()
+ result[:3, 3] = translation
+ return result
+
+
+def matrix_payload(matrix: Sequence[Sequence[float]]) -> dict[str, list[float]]:
+ value = np.asarray(matrix, dtype=float)
+ if value.shape != (4, 4) or not np.all(np.isfinite(value)):
+ raise ValueError("transform must be a finite 4x4 matrix")
+ return {
+ "translation_xyz_m": [float(item) for item in value[:3, 3]],
+ "quaternion_xyzw": [
+ float(item) for item in Rotation.from_matrix(value[:3, :3]).as_quat()
+ ],
+ }
+
+
+@dataclass(frozen=True)
+class CameraCalibrationIdentity:
+ serial_number: str
+ width: int
+ height: int
+ intrinsics_sha256: str
+
+
+@dataclass(frozen=True)
+class CameraExtrinsics:
+ """Transforms every declared camera into one Profile-selected reference."""
+
+ cameras: Mapping[str, CameraCalibrationIdentity]
+ reference_view: str
+ reference_from_view: Mapping[str, np.ndarray]
+ quality: Mapping[str, float]
+
+ def transform(self, view: str) -> np.ndarray:
+ if view not in self.reference_from_view:
+ raise KeyError(f"extrinsics do not contain view {view}")
+ return np.asarray(self.reference_from_view[view], dtype=float).copy()
+
+
+def validate_camera_extrinsics_payload(
+ payload: Mapping[str, Any],
+ *,
+ required_views: Sequence[str],
+ reference_view: str,
+ quality_limits: Mapping[str, float] | None = None,
+ minimum_capture_counts: Mapping[str, int] | None = None,
+) -> None:
+ if int(payload.get("schema_version", -1)) != 1:
+ raise ValueError("camera extrinsics schema_version must be 1")
+ reference = str(reference_view)
+ if payload.get("reference_view") != reference:
+ raise ValueError(
+ "camera extrinsics reference_view differs from the Profile"
+ )
+ cameras = payload.get("cameras")
+ transforms = payload.get(f"{reference}_from_view")
+ quality = payload.get("quality")
+ views = tuple(str(view) for view in required_views)
+ if not views or len(set(views)) != len(views):
+ raise ValueError("required extrinsic views must be non-empty and unique")
+ if reference not in views:
+ raise ValueError("extrinsic reference view is not required")
+ if not isinstance(cameras, Mapping) or set(cameras) != set(views):
+ raise ValueError("camera extrinsics differ from the Profile views")
+ if not isinstance(transforms, Mapping) or set(transforms) != set(views):
+ raise ValueError("camera transforms differ from the Profile views")
+ if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
+ raise ValueError("camera extrinsics quality is not passed")
+ for key, limit in dict(quality_limits or {}).items():
+ value = float(quality.get(key, float("inf")))
+ if not np.isfinite(value) or value > limit:
+ raise ValueError(
+ f"camera extrinsics {key}={value} exceeds {limit}"
+ )
+ for key, minimum in dict(minimum_capture_counts or {}).items():
+ if int(quality.get(key, 0)) < int(minimum):
+ raise ValueError(
+ f"camera extrinsics {key} must be at least {minimum}"
+ )
+ for view in views:
+ identity = cameras[view]
+ if not isinstance(identity, Mapping):
+ raise ValueError(f"{view} camera identity must be an object")
+ if not str(identity.get("serial_number", "")):
+ raise ValueError(f"{view} camera serial_number is missing")
+ if int(identity.get("width", 0)) <= 0 or int(identity.get("height", 0)) <= 0:
+ raise ValueError(f"{view} camera image dimensions are invalid")
+ fingerprint = str(identity.get("intrinsics_sha256", ""))
+ if len(fingerprint) != 64:
+ raise ValueError(f"{view} camera intrinsics fingerprint is invalid")
+ transform = transforms[view]
+ if not isinstance(transform, Mapping):
+ raise ValueError(f"{view} transform must be an object")
+ matrix = transform_matrix(
+ transform.get("translation_xyz_m", ()),
+ transform.get("quaternion_xyzw", ()),
+ )
+ if view == reference and not np.allclose(
+ matrix, np.eye(4), atol=1.0e-9
+ ):
+ raise ValueError("reference-view transform must be identity")
+ serials = [str(cameras[view]["serial_number"]) for view in views]
+ if len(set(serials)) != len(views):
+ raise ValueError("camera extrinsics serial numbers must be unique")
+
+
+def load_camera_extrinsics(
+ path: str | Path,
+ *,
+ required_views: Sequence[str],
+ reference_view: str,
+ quality_limits: Mapping[str, float] | None = None,
+ minimum_capture_counts: Mapping[str, int] | None = None,
+) -> CameraExtrinsics:
+ source = Path(path).expanduser().resolve()
+ if not source.is_file():
+ raise ValueError(f"camera extrinsics file does not exist: {source}")
+ with source.open("r", encoding="utf-8") as stream:
+ payload = yaml.safe_load(stream)
+ if not isinstance(payload, Mapping):
+ raise ValueError("camera extrinsics file must contain an object")
+ validate_camera_extrinsics_payload(
+ payload,
+ required_views=required_views,
+ reference_view=reference_view,
+ quality_limits=quality_limits,
+ minimum_capture_counts=minimum_capture_counts,
+ )
+ views = tuple(str(view) for view in required_views)
+ transform_key = f"{reference_view}_from_view"
+ cameras = {
+ view: CameraCalibrationIdentity(
+ serial_number=str(payload["cameras"][view]["serial_number"]),
+ width=int(payload["cameras"][view]["width"]),
+ height=int(payload["cameras"][view]["height"]),
+ intrinsics_sha256=str(
+ payload["cameras"][view]["intrinsics_sha256"]
+ ),
+ )
+ for view in views
+ }
+ transforms = {
+ view: transform_matrix(
+ payload[transform_key][view]["translation_xyz_m"],
+ payload[transform_key][view]["quaternion_xyzw"],
+ )
+ for view in views
+ }
+ return CameraExtrinsics(
+ cameras=cameras,
+ reference_view=str(reference_view),
+ reference_from_view=transforms,
+ quality={
+ str(key): float(value) if isinstance(value, (int, float)) else value
+ for key, value in payload["quality"].items()
+ },
+ )
diff --git a/src/linkerhand_calibration/linkerhand_calibration/extrinsics.py b/src/linkerhand_calibration/linkerhand_calibration/extrinsics.py
index 3cea37b..7003789 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/extrinsics.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/extrinsics.py
@@ -1,83 +1,35 @@
-"""Camera-extrinsic data model shared by calibration and runtime nodes."""
+"""Three-view compatibility policy over generic camera extrinsics."""
from __future__ import annotations
-import hashlib
-import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping, Sequence
import numpy as np
import yaml
-from scipy.spatial.transform import Rotation
+
+from .core.geometry.extrinsics import (
+ CameraCalibrationIdentity,
+ CameraExtrinsics,
+ camera_info_fingerprint,
+ load_camera_extrinsics,
+ matrix_payload,
+ transform_matrix,
+ validate_camera_extrinsics_payload,
+)
VIEWS: tuple[str, ...] = ("front", "side", "top")
-
-
-def camera_info_fingerprint(
- *,
- width: int,
- height: int,
- camera_matrix: Sequence[Sequence[float]] | Sequence[float],
- distortion: Sequence[float] = (),
- rectification: Sequence[float] = (),
- projection: Sequence[float] = (),
-) -> str:
- """Return a stable fingerprint for rectified image geometry."""
- matrix = np.asarray(camera_matrix, dtype=float).reshape(3, 3)
- payload = {
- "width": int(width),
- "height": int(height),
- "camera_matrix": [round(float(value), 12) for value in matrix.flat],
- "distortion": [round(float(value), 12) for value in distortion],
- "rectification": [round(float(value), 12) for value in rectification],
- "projection": [round(float(value), 12) for value in projection],
- }
- encoded = json.dumps(
- payload, sort_keys=True, separators=(",", ":")
- ).encode("utf-8")
- return hashlib.sha256(encoded).hexdigest()
-
-
-def transform_matrix(
- translation_xyz_m: Sequence[float],
- quaternion_xyzw: Sequence[float],
-) -> np.ndarray:
- translation = np.asarray(translation_xyz_m, dtype=float)
- quaternion = np.asarray(quaternion_xyzw, dtype=float)
- if translation.shape != (3,) or not np.all(np.isfinite(translation)):
- raise ValueError("translation_xyz_m must contain three finite values")
- if quaternion.shape != (4,) or not np.all(np.isfinite(quaternion)):
- raise ValueError("quaternion_xyzw must contain four finite values")
- norm = float(np.linalg.norm(quaternion))
- if norm < 1.0e-12:
- raise ValueError("quaternion_xyzw has zero norm")
- result = np.eye(4, dtype=float)
- result[:3, :3] = Rotation.from_quat(quaternion / norm).as_matrix()
- result[:3, 3] = translation
- return result
-
-
-def matrix_payload(matrix: Sequence[Sequence[float]]) -> dict[str, list[float]]:
- value = np.asarray(matrix, dtype=float)
- if value.shape != (4, 4) or not np.all(np.isfinite(value)):
- raise ValueError("transform must be a finite 4x4 matrix")
- return {
- "translation_xyz_m": [float(item) for item in value[:3, 3]],
- "quaternion_xyzw": [
- float(item) for item in Rotation.from_matrix(value[:3, :3]).as_quat()
- ],
- }
-
-
-@dataclass(frozen=True)
-class CameraCalibrationIdentity:
- serial_number: str
- width: int
- height: int
- intrinsics_sha256: str
+_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,
+}
@dataclass(frozen=True)
@@ -113,92 +65,27 @@ class ThreeCameraExtrinsics:
def validate_extrinsics_payload(payload: Mapping[str, Any]) -> None:
- if int(payload.get("schema_version", -1)) != 1:
- raise ValueError("camera extrinsics schema_version must be 1")
- if payload.get("reference_view") != "front":
- raise ValueError("camera extrinsics reference_view must be front")
- cameras = payload.get("cameras")
- transforms = payload.get("front_from_view")
- quality = payload.get("quality")
- if not isinstance(cameras, Mapping) or set(cameras) != set(VIEWS):
- raise ValueError("camera extrinsics must contain front/side/top cameras")
- if not isinstance(transforms, Mapping) or set(transforms) != set(VIEWS):
- raise ValueError("camera extrinsics must contain all three transforms")
- if not isinstance(quality, Mapping) or not bool(quality.get("passed")):
- raise ValueError("camera extrinsics quality is not passed")
- quality_limits = {
- "reprojection_rms_px": 1.2,
- "maximum_rotation_repeatability_deg": 0.3,
- "maximum_translation_repeatability_m": 0.0015,
- }
- for key, limit in quality_limits.items():
- value = float(quality.get(key, float("inf")))
- if not np.isfinite(value) or value > limit:
- raise ValueError(
- f"camera extrinsics {key}={value} exceeds {limit}"
- )
- for key in ("front_side_captures", "front_top_captures"):
- if int(quality.get(key, 0)) < 15:
- raise ValueError(f"camera extrinsics {key} must be at least 15")
- for view in VIEWS:
- identity = cameras[view]
- if not isinstance(identity, Mapping):
- raise ValueError(f"{view} camera identity must be an object")
- if not str(identity.get("serial_number", "")):
- raise ValueError(f"{view} camera serial_number is missing")
- if int(identity.get("width", 0)) <= 0 or int(identity.get("height", 0)) <= 0:
- raise ValueError(f"{view} camera image dimensions are invalid")
- fingerprint = str(identity.get("intrinsics_sha256", ""))
- if len(fingerprint) != 64:
- raise ValueError(f"{view} camera intrinsics fingerprint is invalid")
- transform = transforms[view]
- if not isinstance(transform, Mapping):
- raise ValueError(f"{view} transform must be an object")
- matrix = transform_matrix(
- transform.get("translation_xyz_m", ()),
- transform.get("quaternion_xyzw", ()),
- )
- if view == "front" and not np.allclose(matrix, np.eye(4), atol=1.0e-9):
- raise ValueError("front_from_view.front must be identity")
- serials = [str(cameras[view]["serial_number"]) for view in VIEWS]
- if len(set(serials)) != len(VIEWS):
- raise ValueError("camera extrinsics serial numbers must be unique")
+ validate_camera_extrinsics_payload(
+ payload,
+ required_views=VIEWS,
+ reference_view="front",
+ quality_limits=_QUALITY_LIMITS,
+ minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
+ )
def load_three_camera_extrinsics(path: str | Path) -> ThreeCameraExtrinsics:
- source = Path(path).expanduser().resolve()
- if not source.is_file():
- raise ValueError(f"camera extrinsics file does not exist: {source}")
- with source.open("r", encoding="utf-8") as stream:
- payload = yaml.safe_load(stream)
- if not isinstance(payload, Mapping):
- raise ValueError("camera extrinsics file must contain an object")
- validate_extrinsics_payload(payload)
- cameras = {
- view: CameraCalibrationIdentity(
- serial_number=str(payload["cameras"][view]["serial_number"]),
- width=int(payload["cameras"][view]["width"]),
- height=int(payload["cameras"][view]["height"]),
- intrinsics_sha256=str(
- payload["cameras"][view]["intrinsics_sha256"]
- ),
- )
- for view in VIEWS
- }
- transforms = {
- view: transform_matrix(
- payload["front_from_view"][view]["translation_xyz_m"],
- payload["front_from_view"][view]["quaternion_xyzw"],
- )
- for view in VIEWS
- }
+ generic = load_camera_extrinsics(
+ path,
+ required_views=VIEWS,
+ reference_view="front",
+ quality_limits=_QUALITY_LIMITS,
+ minimum_capture_counts=_MINIMUM_CAPTURE_COUNTS,
+ )
return ThreeCameraExtrinsics(
- cameras=cameras,
- front_from_view=transforms,
- quality={
- str(key): float(value) if isinstance(value, (int, float)) else value
- for key, value in payload["quality"].items()
- },
+ cameras=generic.cameras,
+ front_from_view=generic.reference_from_view,
+ quality=generic.quality,
)
@@ -225,3 +112,19 @@ def dump_three_camera_extrinsics(
with temporary.open("w", encoding="utf-8") as stream:
yaml.safe_dump(payload, stream, allow_unicode=True, sort_keys=False)
temporary.replace(destination)
+
+
+__all__ = [
+ "CameraCalibrationIdentity",
+ "CameraExtrinsics",
+ "ThreeCameraExtrinsics",
+ "VIEWS",
+ "camera_info_fingerprint",
+ "dump_three_camera_extrinsics",
+ "load_camera_extrinsics",
+ "load_three_camera_extrinsics",
+ "matrix_payload",
+ "transform_matrix",
+ "validate_camera_extrinsics_payload",
+ "validate_extrinsics_payload",
+]
diff --git a/src/linkerhand_calibration/linkerhand_calibration/full_hand.py b/src/linkerhand_calibration/linkerhand_calibration/full_hand.py
index b808e7a..71a0664 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/full_hand.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/full_hand.py
@@ -1,2241 +1,7 @@
-"""Pure three-camera G20 calibration model and compact runtime schema.
+"""One-release module alias for the relocated model profile implementation."""
-The hardware node records one parent/child AprilTag trajectory for each
-directly observable joint. This module deliberately contains no ROS imports:
-curve fitting, baseline centring, inheritance, and schema validation
-remain deterministic and unit-testable without connected cameras or a hand.
-"""
+import sys
-from __future__ import annotations
+from .models.g20 import profile as _implementation
-from dataclasses import dataclass, replace
-import math
-from pathlib import Path
-import re
-from typing import Any, Iterable, Mapping, Sequence
-import xml.etree.ElementTree as ET
-
-import numpy as np
-
-from .models.g20.command_layout import G20_COMMAND_NAMES as COMMAND_NAMES
-from .trajectory import (
- _angle_for_circle,
- _fit_circle_with_axis,
- _fit_joint_curve,
- _fit_plane_axis,
- _orient_circle_positive,
-)
-from .zero_calibration import (
- _fit_circle,
- _trajectory_arc_rad,
- circular_median_rad,
- maximum_pairwise_angle_difference_rad,
- wrap_angle_rad,
-)
-
-
-THREE_CAMERA_BASELINE_COMMAND: tuple[int, ...] = (
- 255, 255, 255, 255, 255, 255,
- 127, 127, 127, 127,
- 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
-)
-
-
-@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
-
- @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
-
-
-@dataclass(frozen=True)
-class HandCalibrationProfile:
- side: str
- reference_finger: str
- view_tags: Mapping[str, Mapping[str, int]]
- preflight_view_roles: Mapping[str, tuple[str, ...]]
- joint_specs: Mapping[str, JointSpec]
- sweep_specs: tuple[SweepSpec, ...]
- image_trajectory_joints: frozenset[str]
- roll_clearance_commands: Mapping[int, int]
- thumb_pitch_clearance_commands: Mapping[int, int]
- layout_id: str = "legacy_11"
- measurement_specs: Mapping[str, JointSpec] | None = None
- axis_validation_sources: Mapping[str, str] | None = None
- palm_axis_observers: tuple[PalmAxisObserver, ...] = ()
- minimum_palm_orientation_sources: int = 0
- palm_orientation_minimum_arc_rad: float = math.radians(15.0)
- palm_orientation_maximum_command_distance_u8: int = 255
- # Hardware and algorithm differences belong to the product profile, not
- # to the acquisition state machine. Keeping these fields here lets a new
- # model (or the mirrored hand) reuse the same recorder/fitter by supplying
- # a different declarative profile.
- model: str = "G20"
- command_names: tuple[str, ...] = COMMAND_NAMES
- baseline_command: tuple[int, ...] = THREE_CAMERA_BASELINE_COMMAND
- capabilities: frozenset[str] = frozenset()
- precheck_sweeps: bool = False
- steady_command_checkpoints: bool = False
- directional_zero: bool = False
- isolated_holdout: bool = False
- cross_view_roll_curve: bool = False
- stable_cross_view_cone_bias: bool = False
-
- @property
- def command_count(self) -> int:
- return len(self.command_names)
-
- def supports(self, capability: str) -> bool:
- return str(capability) in self.capabilities
-
- @property
- def measured_joints(self) -> tuple[str, ...]:
- return tuple(
- name for name, spec in self.joint_specs.items() if spec.measured
- )
-
- @property
- def record_specs(self) -> Mapping[str, JointSpec]:
- return self.joint_specs if self.measurement_specs is None else self.measurement_specs
-
- @property
- def record_joints(self) -> tuple[str, ...]:
- return tuple(
- name for name, spec in self.record_specs.items() if spec.measured
- )
-
- @property
- def palm_orientation_sources(self) -> Mapping[str, str]:
- return {
- observer.source_name: observer.model_joint
- for observer in self.palm_axis_observers
- }
-
- @property
- def palm_axis_motor_by_source(self) -> Mapping[str, int]:
- return {
- observer.source_name: int(observer.motor_index)
- for observer in self.palm_axis_observers
- }
-
- @property
- def active_joints(self) -> tuple[str, ...]:
- return tuple(
- name for name, spec in self.joint_specs.items() if spec.active
- )
-
- @property
- def passive_joints(self) -> tuple[str, ...]:
- return tuple(
- name for name, spec in self.joint_specs.items() if not spec.active
- )
-
- @property
- def reference_roll_motor(self) -> int:
- return int(self.joint_specs[f"{self.reference_finger}_mcp_roll"].motor_index)
-
- @property
- def reference_pitch_motor(self) -> int:
- return int(self.joint_specs[f"{self.reference_finger}_mcp_pitch"].motor_index)
-
- @property
- def reference_pip_motor(self) -> int:
- return int(self.joint_specs[f"{self.reference_finger}_pip"].motor_index)
-
- @property
- def reference_speed_slot(self) -> int:
- return {"index": 1, "middle": 2, "ring": 3, "pinky": 4}[
- self.reference_finger
- ]
-
-
-_FINGERS: tuple[str, ...] = ("index", "middle", "ring", "pinky")
-
-# The side camera looks almost along these physical flexion axes. The child
-# Tag centre therefore traces a high-resolution image-plane circle against the
-# fixed palm Tag, while monocular planar-PnP tilt is not accurate enough to be
-# the dynamic angle source. Passive DIP is intentionally excluded because
-# its parent Tag moves with PIP; a raw image-centre difference would mix both
-# joint angles.
-RIGHT_19_END_ON_IMAGE_CURVE_JOINTS = frozenset(
- {
- *(f"{finger}_mcp_pitch" for finger in _FINGERS),
- *(f"{finger}_pip" for finger in _FINGERS),
- }
-)
-# These joints have no independent actuator. Their distal Tags directly
-# measure the passive dynamic curve, while their fitted axes serve only as
-# downstream phase observers for the corresponding active PIP.
-RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS = frozenset(
- f"{finger}_dip" for finger in _FINGERS
-)
-_MOTOR_BY_JOINT: dict[str, int] = {
- **{f"{finger}_mcp_pitch": index + 1 for index, finger in enumerate(_FINGERS)},
- **{f"{finger}_mcp_roll": index + 6 for index, finger in enumerate(_FINGERS)},
- **{f"{finger}_pip": index + 16 for index, finger in enumerate(_FINGERS)},
-}
-
-
-def _build_hand_profile(side: str) -> HandCalibrationProfile:
- hand_side = str(side).lower()
- if hand_side not in {"left", "right"}:
- raise ValueError("hand side must be left or right")
- reference = "index" if hand_side == "left" else "pinky"
- roll_role = f"{reference}_roll"
- mcp_role = f"{reference}_mcp"
- pip_role = f"{reference}_pip"
- dip_role = f"{reference}_dip"
- view_tags: dict[str, dict[str, int]] = {
- "front": {
- "front_base": 0,
- "thumb_cmc": 1,
- "thumb_mcp": 2,
- "thumb_ip": 3,
- roll_role: 10,
- },
- "side": {
- "side_base": 4,
- mcp_role: 5,
- pip_role: 6,
- dip_role: 7,
- },
- "top": {"top_base": 8, "thumb_yaw": 9},
- }
- preflight_view_roles = {
- view: tuple(tags) for view, tags in view_tags.items()
- }
- joint_specs: dict[str, JointSpec] = {}
- # Both hands use the front base/Tag 1 pair for motor 0. Keeping the
- # complete thumb root chain in one calibrated view avoids introducing a
- # cross-camera axis-line phase into the URDF zero solve.
- thumb_pitch_view = "front"
- thumb_pitch_parent = "front_base"
- joint_specs["thumb_cmc_pitch"] = JointSpec(
- "thumb_cmc_pitch",
- 0,
- True,
- thumb_pitch_view,
- thumb_pitch_parent,
- "thumb_cmc",
- zero_kind="urdf_axis_chain",
- )
- for finger in _FINGERS:
- name = f"{finger}_mcp_pitch"
- source = None if finger == reference else f"{reference}_mcp_pitch"
- joint_specs[name] = JointSpec(
- name,
- _MOTOR_BY_JOINT[name],
- True,
- "side" if source is None else None,
- "side_base" if source is None else None,
- mcp_role if source is None else None,
- source_joint=source,
- zero_kind="urdf_axis_chain" if source is None else "inherited",
- )
- joint_specs["thumb_cmc_roll"] = JointSpec(
- "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
- zero_kind="urdf_axis_chain",
- )
- for finger in _FINGERS:
- name = f"{finger}_mcp_roll"
- source = None if finger == reference else f"{reference}_mcp_roll"
- joint_specs[name] = JointSpec(
- name,
- _MOTOR_BY_JOINT[name],
- True,
- "front" if source is None else None,
- "front_base" if source is None else None,
- roll_role if source is None else None,
- source_joint=source,
- zero_kind="urdf_axis_chain" if source is None else "inherited",
- )
- joint_specs["thumb_cmc_yaw"] = JointSpec(
- "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
- zero_kind="urdf_axis_chain",
- )
- joint_specs["thumb_mcp"] = JointSpec(
- "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
- zero_kind="urdf_axis_chain",
- )
- for finger in _FINGERS:
- name = f"{finger}_pip"
- source = None if finger == reference else f"{reference}_pip"
- joint_specs[name] = JointSpec(
- name,
- _MOTOR_BY_JOINT[name],
- True,
- "side" if source is None else None,
- mcp_role if source is None else None,
- pip_role if source is None else None,
- source_joint=source,
- zero_kind="urdf_axis_chain" if source is None else "inherited",
- )
- joint_specs["thumb_ip"] = JointSpec(
- "thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
- )
- for finger in _FINGERS:
- name = f"{finger}_dip"
- source = None if finger == reference else f"{reference}_dip"
- joint_specs[name] = JointSpec(
- name,
- _MOTOR_BY_JOINT[f"{finger}_pip"],
- False,
- "side" if source is None else None,
- pip_role if source is None else None,
- dip_role if source is None else None,
- source_joint=source,
- )
- sweep_specs = (
- SweepSpec(thumb_pitch_view, 0, ("thumb_cmc_pitch",)),
- SweepSpec("front", 5, ("thumb_cmc_roll",)),
- SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")),
- SweepSpec("front", _MOTOR_BY_JOINT[f"{reference}_mcp_roll"], (
- f"{reference}_mcp_roll",
- )),
- SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_mcp_pitch"], (
- f"{reference}_mcp_pitch",
- )),
- SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_pip"], (
- f"{reference}_pip", f"{reference}_dip",
- )),
- SweepSpec("top", 10, ("thumb_cmc_yaw",)),
- )
- image_joints = frozenset(
- {
- "thumb_cmc_pitch",
- "thumb_cmc_roll",
- "thumb_mcp",
- "thumb_ip",
- f"{reference}_mcp_roll",
- f"{reference}_mcp_pitch",
- f"{reference}_pip",
- }
- )
- measured_roll = _MOTOR_BY_JOINT[f"{reference}_mcp_roll"]
- # The roll channels are mirrored mechanically between hands. Command 0
- # moves the three non-reference fingers to the camera-right clearance pose
- # on the left hand, while the same physical pose is command 255 on the
- # right hand. Using 0 for both sides made the right index/middle/ring
- # fingers lean left and could obstruct or load the pinky roll sweep.
- roll_clearance_command = 0 if hand_side == "left" else 255
- clearance = {
- _MOTOR_BY_JOINT[f"{finger}_mcp_roll"]: roll_clearance_command
- for finger in _FINGERS
- if finger != reference
- }
- assert measured_roll not in clearance
- # Motor 0 is observed from the front with both other thumb-root channels
- # fully open. Store the commands explicitly for the right hand so a
- # caller-provided baseline cannot silently restore the obsolete 50/120
- # pose and hide Tag 1 from the front camera.
- thumb_pitch_clearance = {10: 255, 5: 255} if hand_side == "right" else {}
- return HandCalibrationProfile(
- side=hand_side,
- reference_finger=reference,
- view_tags=view_tags,
- preflight_view_roles=preflight_view_roles,
- joint_specs=joint_specs,
- sweep_specs=sweep_specs,
- image_trajectory_joints=image_joints,
- roll_clearance_commands=clearance,
- thumb_pitch_clearance_commands=thumb_pitch_clearance,
- )
-
-
-LEFT_HAND_PROFILE = _build_hand_profile("left")
-RIGHT_HAND_PROFILE = _build_hand_profile("right")
-
-
-# ``g20_right_15`` remains accepted as a read/CLI compatibility alias, but a
-# newly recorded product session always persists the physical 19-Tag layout
-# identifier. Together with the exact ``view_tags`` resume check this keeps a
-# former 15-Tag checkpoint from being imported after the four distal Tags are
-# installed again.
-G20_RIGHT_15_LAYOUT = "g20_right_15"
-G20_RIGHT_19_LAYOUT = "g20_right_19"
-
-# The three CMC axes keep the calibration domain that was proven on this hand
-# in a609d521: dense relative-rotation sweeps with an isolated-cycle holdout.
-# Monocular Tag translation is useful as a diagnostic, but is not an
-# independent quality measurement for these short, strongly coupled links.
-G20_REFERENCE_THUMB_CMC_JOINTS: frozenset[str] = frozenset(
- {"thumb_cmc_pitch", "thumb_cmc_roll", "thumb_cmc_yaw"}
-)
-
-G20_COMBINATION_REQUIRED_TARGET_KEYS: tuple[str, ...] = tuple(
- sorted(
- {
- "front:index_mcp_roll",
- "front:middle_mcp_roll",
- "front:ring_mcp_roll",
- "front:pinky_mcp_roll",
- "side:index_pip",
- "side:middle_pip",
- "side:ring_pip",
- "side:pinky_pip",
- }
- )
-)
-
-MIMIC_DERIVED_FINGER_DIPS: Mapping[str, str] = {
- f"{finger}_dip": f"{finger}_pip" for finger in _FINGERS
-}
-
-
-def canonical_zero_direction(
- profile: HandCalibrationProfile, joint_name: str
-) -> str | None:
- """Return the repeatable approach direction used as physical zero.
-
- A mid-range encoder value does not uniquely determine a linkage pose when
- the transmission has backlash. The right-hand product four-finger roll profile
- therefore defines command 127 after a 255->127 approach as the sole
- physical zero. The opposite branch remains offset in the published
- direction-aware curve instead of being silently re-zeroed.
- """
- name = str(joint_name)
- if profile.layout_id != G20_RIGHT_19_LAYOUT:
- return None
- canonical = {
- *(f"{finger}_mcp_roll" for finger in _FINGERS),
- *(f"{finger}_mcp_roll_side" for finger in _FINGERS),
- }
- return "decreasing" if name in canonical else None
-
-
-def _right_19_auxiliary_commands(
- finger: str, *, side_view: bool, target_kind: str
-) -> tuple[tuple[int, int], ...]:
- """Return the reviewed right-hand fan/occlusion parking pose."""
- order = ("pinky", "ring", "middle", "index")
- target_index = order.index(finger)
- commands: dict[int, int] = {}
- for other_index, other in enumerate(order):
- if other == finger:
- continue
- roll_motor = _MOTOR_BY_JOINT[f"{other}_mcp_roll"]
- if other_index < target_index:
- commands[roll_motor] = 127 if side_view else 0
- else:
- commands[roll_motor] = 255
- if side_view and other_index < target_index:
- commands[_MOTOR_BY_JOINT[f"{other}_mcp_pitch"]] = 0
- commands[_MOTOR_BY_JOINT[f"{other}_pip"]] = 0
- if target_kind in {"pitch", "pip"}:
- commands[_MOTOR_BY_JOINT[f"{finger}_mcp_roll"]] = 127
- if target_kind == "roll":
- commands[_MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]] = 255
- commands[_MOTOR_BY_JOINT[f"{finger}_pip"]] = 255
- elif target_kind == "pitch":
- commands[_MOTOR_BY_JOINT[f"{finger}_pip"]] = 255
- elif target_kind == "pip":
- commands[_MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]] = 255
- return tuple(sorted((int(index), int(value)) for index, value in commands.items()))
-
-
-def _build_right_19_profile() -> HandCalibrationProfile:
- """Build the 19-Tag/16-motion-task right-hand profile."""
- view_tags: dict[str, dict[str, int]] = {
- "front": {
- "front_base": 0,
- "thumb_cmc": 1,
- "thumb_mcp": 2,
- "thumb_ip": 3,
- "pinky_roll": 10,
- "ring_roll": 11,
- "middle_roll": 12,
- "index_roll": 13,
- },
- "side": {
- "side_base": 4,
- "ring_pip": 5,
- "pinky_pip": 6,
- "pinky_dip": 7,
- "ring_dip": 14,
- "middle_pip": 15,
- "middle_dip": 16,
- "index_pip": 17,
- "index_dip": 18,
- },
- "top": {"top_base": 8, "thumb_yaw": 9},
- }
- # Four adjacent fingers inevitably occlude some Tags in the
- # common open-hand pose. Startup therefore proves only the three fixed
- # palm references. Every moving Tag is still mandatory, but is checked
- # by its task-local visibility sweep after the other fingers have moved to
- # that task's clearance pose. Requiring all 15 at one instant would make
- # a physically correct 19-Tag installation impossible to start.
- preflight_view_roles = {
- "front": ("front_base",),
- "side": ("side_base",),
- "top": ("top_base",),
- }
- joint_specs: dict[str, JointSpec] = {
- "thumb_cmc_pitch": JointSpec(
- "thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc",
- zero_kind="urdf_axis_chain",
- ),
- "thumb_cmc_roll": JointSpec(
- "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
- zero_kind="urdf_axis_chain",
- ),
- "thumb_cmc_yaw": JointSpec(
- "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
- zero_kind="urdf_axis_chain",
- ),
- "thumb_mcp": JointSpec(
- "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
- zero_kind="urdf_axis_chain",
- ),
- "thumb_ip": JointSpec(
- "thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
- ),
- }
- for finger in ("index", "middle", "ring", "pinky"):
- roll = f"{finger}_mcp_roll"
- pitch = f"{finger}_mcp_pitch"
- pip = f"{finger}_pip"
- dip = f"{finger}_dip"
- joint_specs[roll] = JointSpec(
- roll, _MOTOR_BY_JOINT[roll], True, "front", "front_base",
- f"{finger}_roll", zero_kind="urdf_axis_chain",
- )
- # The side PIP Tag is on the middle phalanx after PIP. With PIP held
- # at 255 it observes MCP pitch; with MCP pitch held at 255 it observes
- # PIP itself.
- joint_specs[pitch] = JointSpec(
- pitch, _MOTOR_BY_JOINT[pitch], True, "side", "side_base",
- f"{finger}_pip", zero_kind="urdf_axis_chain",
- )
- joint_specs[pip] = JointSpec(
- pip, _MOTOR_BY_JOINT[pip], True, "side", "side_base",
- f"{finger}_pip", zero_kind="urdf_axis_chain",
- )
- # DIP is passive, but no longer unobserved. The PIP sweep moves the
- # complete linkage once while the middle/distal Tag pair directly
- # measures DIP. Its dynamic curve is therefore visual truth; its
- # absolute static URDF zero deliberately remains source CAD because an
- # arbitrary distal-Tag mounting angle is indistinguishable from a DIP
- # zero offset without a mechanical zero fixture.
- joint_specs[dip] = JointSpec(
- dip,
- _MOTOR_BY_JOINT[pip],
- False,
- "side",
- f"{finger}_pip",
- f"{finger}_dip",
- )
-
- measurement_specs = dict(joint_specs)
- axis_validation_sources: dict[str, str] = {}
- for finger in ("pinky", "ring", "middle", "index"):
- canonical = f"{finger}_mcp_roll"
- alias = f"{canonical}_side"
- measurement_specs[alias] = JointSpec(
- alias,
- _MOTOR_BY_JOINT[canonical],
- True,
- "side",
- "side_base",
- f"{finger}_pip",
- zero_kind="axis_cross_view_validation",
- )
- axis_validation_sources[canonical] = alias
-
- sweeps: list[SweepSpec] = [
- SweepSpec(
- "front", 0, ("thumb_cmc_pitch",), "thumb_cmc_pitch_front",
- ((5, 255), (10, 255)),
- ),
- SweepSpec("front", 5, ("thumb_cmc_roll",), "thumb_cmc_roll_front"),
- SweepSpec(
- "front", 15, ("thumb_mcp", "thumb_ip"), "thumb_mcp_ip_front"
- ),
- SweepSpec(
- "top", 10, ("thumb_cmc_yaw",), "thumb_cmc_yaw_top", ((5, 145),)
- ),
- ]
- for finger in ("pinky", "ring", "middle", "index"):
- roll_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_roll"]
- pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
- pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
- sweeps.extend(
- (
- SweepSpec(
- "front",
- roll_motor,
- (
- f"{finger}_mcp_roll",
- f"{finger}_mcp_roll_side",
- ),
- f"{finger}_roll_multiview",
- _right_19_auxiliary_commands(
- finger, side_view=True, target_kind="roll"
- ),
- ),
- SweepSpec(
- "side",
- pitch_motor,
- (f"{finger}_mcp_pitch",),
- f"{finger}_pitch_side",
- _right_19_auxiliary_commands(
- finger, side_view=True, target_kind="pitch"
- ),
- ),
- SweepSpec(
- "side",
- pip_motor,
- (f"{finger}_pip", f"{finger}_dip"),
- f"{finger}_pip_side",
- _right_19_auxiliary_commands(
- finger, side_view=True, target_kind="pip"
- ),
- ),
- )
- )
- image_joints = frozenset(
- {
- "thumb_cmc_pitch",
- "thumb_cmc_roll",
- "thumb_mcp",
- "thumb_ip",
- *(f"{finger}_mcp_roll" for finger in ("index", "middle", "ring", "pinky")),
- }
- )
- return HandCalibrationProfile(
- side="right",
- reference_finger="pinky",
- view_tags=view_tags,
- preflight_view_roles=preflight_view_roles,
- joint_specs=joint_specs,
- sweep_specs=tuple(sweeps),
- image_trajectory_joints=image_joints,
- roll_clearance_commands={},
- thumb_pitch_clearance_commands={10: 255, 5: 255},
- layout_id=G20_RIGHT_19_LAYOUT,
- measurement_specs=measurement_specs,
- axis_validation_sources=axis_validation_sources,
- # Observe the zero-adjacent roll and pitch axes from Tag 9 while the
- # existing front sweeps run. Their same-view angle identifies the
- # intervening yaw zero without adding a motion task or reference URDF.
- palm_axis_observers=(
- PalmAxisObserver(
- source_name="thumb_cmc_pitch_top_axis",
- task_name="thumb_cmc_pitch_front",
- view="top",
- parent_role="top_base",
- child_role="thumb_yaw",
- model_joint="thumb_cmc_pitch",
- motor_index=joint_specs["thumb_cmc_pitch"].motor_index,
- ),
- PalmAxisObserver(
- source_name="thumb_cmc_roll_top_axis",
- task_name="thumb_cmc_roll_front",
- view="top",
- parent_role="top_base",
- child_role="thumb_yaw",
- model_joint="thumb_cmc_roll",
- motor_index=joint_specs["thumb_cmc_roll"].motor_index,
- ),
- ),
- minimum_palm_orientation_sources=2,
- palm_orientation_minimum_arc_rad=math.radians(10.0),
- palm_orientation_maximum_command_distance_u8=64,
- capabilities=frozenset(
- {
- "precheck_sweeps",
- "steady_command_checkpoints",
- "directional_zero",
- "isolated_holdout",
- "cross_view_roll_curve",
- "stable_cross_view_cone_bias",
- "measured_passive_dips",
- "urdf_zero_publication",
- "palm_axis_relative_motion_v3",
- }
- ),
- precheck_sweeps=True,
- steady_command_checkpoints=True,
- directional_zero=True,
- isolated_holdout=True,
- cross_view_roll_curve=True,
- stable_cross_view_cone_bias=True,
- )
-
-
-RIGHT_19_HAND_PROFILE = _build_right_19_profile()
-
-
-def get_hand_calibration_profile(
- side: str, layout_id: str = "legacy_11"
-) -> HandCalibrationProfile:
- value = str(side).lower()
- layout = str(layout_id).lower()
- if layout in {G20_RIGHT_19_LAYOUT, G20_RIGHT_15_LAYOUT}:
- if value != "right":
- raise ValueError("G20 right product layout requires hand side right")
- return RIGHT_19_HAND_PROFILE
- if layout != "legacy_11":
- raise ValueError(f"unknown G20 tag layout: {layout_id}")
- if value == "left":
- return LEFT_HAND_PROFILE
- if value == "right":
- return RIGHT_HAND_PROFILE
- raise ValueError("hand side must be left or right")
-
-
-def derive_mimic_passive_fits(
- source_urdf: str | Path,
- fits: Mapping[str, JointCurveFit],
- *,
- profile: HandCalibrationProfile,
-) -> dict[str, JointCurveFit]:
- """Complete legacy non-visual DIP curves using the URDF mimic contract.
-
- The runtime curves are relative to the baseline command, so a URDF mimic
- offset cancels while the multiplier scales the measured PIP motion. The
- URDF itself remains byte-for-byte protected by publication checks.
- """
- result = dict(fits)
- if profile.layout_id != G20_RIGHT_19_LAYOUT:
- return result
- visually_measured = all(
- profile.joint_specs[name].source_joint is None
- for name in MIMIC_DERIVED_FINGER_DIPS
- )
- if visually_measured:
- missing = sorted(set(profile.joint_specs) - set(result))
- extra = sorted(set(result) - set(profile.joint_specs))
- if missing or extra:
- raise ValueError(
- f"runtime fit set is incomplete: missing={missing} extra={extra}"
- )
- return result
- path = Path(source_urdf).expanduser().resolve()
- if not path.is_file():
- raise ValueError(f"source URDF does not exist: {path}")
- joints = {
- str(node.get("name")): node
- for node in ET.parse(path).getroot().findall("joint")
- }
- for passive_name, source_name in MIMIC_DERIVED_FINGER_DIPS.items():
- spec = profile.joint_specs[passive_name]
- if spec.source_joint != source_name:
- raise ValueError(f"{passive_name} has the wrong curve source")
- if source_name not in result:
- raise ValueError(f"missing measured source curve {source_name}")
- joint = joints.get(passive_name)
- mimic = None if joint is None else joint.find("mimic")
- if mimic is None or mimic.get("joint") != source_name:
- raise ValueError(
- f"source URDF {passive_name} must mimic {source_name}"
- )
- multiplier = float(mimic.get("multiplier", "1"))
- offset = float(mimic.get("offset", "0"))
- if not math.isfinite(multiplier) or multiplier <= 0.0:
- raise ValueError(f"source URDF {passive_name} mimic multiplier is invalid")
- if not math.isfinite(offset):
- raise ValueError(f"source URDF {passive_name} mimic offset is invalid")
- source = result[source_name]
-
- def scaled(values: Sequence[float]) -> tuple[float, ...]:
- return tuple(multiplier * float(value) for value in values)
-
- result[passive_name] = JointCurveFit(
- angle_rad=scaled(source.angle_rad),
- decreasing_rad=scaled(source.decreasing_rad),
- increasing_rad=scaled(source.increasing_rad),
- circle={
- "curve_source": source_name,
- "source_urdf_mimic_multiplier": multiplier,
- "source_urdf_mimic_offset_rad": offset,
- "visual_measurement": False,
- },
- maximum_monotonic_correction_rad=(
- abs(multiplier) * source.maximum_monotonic_correction_rad
- ),
- maximum_hysteresis_rad=(
- abs(multiplier) * source.maximum_hysteresis_rad
- ),
- quality={
- "mimic_multiplier": multiplier,
- "mimic_offset_rad": offset,
- "visual_measurement": 0.0,
- },
- zero_offset_rad=multiplier * source.zero_offset_rad,
- )
- if set(result) != set(profile.joint_specs):
- missing = sorted(set(profile.joint_specs) - set(result))
- extra = sorted(set(result) - set(profile.joint_specs))
- raise ValueError(
- f"runtime fit set is incomplete: missing={missing} extra={extra}"
- )
- return result
-
-
-def clamp_runtime_fits_to_urdf_limits(
- source_urdf: str | Path,
- fits: Mapping[str, JointCurveFit],
- *,
- endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
-) -> dict[str, JointCurveFit]:
- """Saturate published curves at protected physical limits.
-
- Raw trajectory fits and all fit/zero diagnostics remain untouched. The
- returned transfer functions are the commands a URDF/MuJoCo runtime can
- actually represent safely. An endpoint-anchored joint uses the corrected
- coordinate upper limit ``CAD_upper - origin_offset``; its physical maximum
- remains exactly the original CAD maximum after the origin correction.
- """
- path = Path(source_urdf).expanduser().resolve()
- joints = {
- str(node.get("name")): node
- for node in ET.parse(path).getroot().findall("joint")
- }
- result: dict[str, JointCurveFit] = {}
- endpoint_offsets = {
- str(name): float(value)
- for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
- }
- for name, fit in fits.items():
- joint = joints.get(name)
- limit = None if joint is None else joint.find("limit")
- if (
- limit is None
- or limit.get("lower") is None
- or limit.get("upper") is None
- ):
- raise ValueError(f"source URDF joint {name} has no finite position limit")
- lower = float(limit.get("lower"))
- upper = float(limit.get("upper"))
- if name in endpoint_offsets:
- upper -= endpoint_offsets[name]
- if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
- raise ValueError(f"source URDF joint {name} has invalid position limits")
-
- clipped_counts: dict[str, int] = {}
-
- def clipped(values: Sequence[float], branch: str) -> tuple[float, ...]:
- source = np.asarray(values, dtype=float)
- bounded = np.clip(source, lower, upper)
- clipped_counts[branch] = int(np.count_nonzero(bounded != source))
- return tuple(float(value) for value in bounded)
-
- circle = dict(fit.circle)
- bounded_angle = clipped(fit.angle_rad, "angle_rad")
- bounded_decreasing = clipped(fit.decreasing_rad, "decreasing_rad")
- bounded_increasing = clipped(fit.increasing_rad, "increasing_rad")
- if any(clipped_counts.values()):
- circle["runtime_urdf_limit_clipped_bins"] = clipped_counts
- circle["runtime_urdf_limits_rad"] = [lower, upper]
- result[name] = replace(
- fit,
- angle_rad=bounded_angle,
- decreasing_rad=bounded_decreasing,
- increasing_rad=bounded_increasing,
- circle=circle,
- )
- return result
-
-
-# Backwards-compatible aliases keep the existing left-hand API stable.
-VIEW_TAGS = LEFT_HAND_PROFILE.view_tags
-JOINT_SPECS = LEFT_HAND_PROFILE.joint_specs
-SWEEP_SPECS = LEFT_HAND_PROFILE.sweep_specs
-MEASURED_JOINTS = LEFT_HAND_PROFILE.measured_joints
-ACTIVE_JOINTS = LEFT_HAND_PROFILE.active_joints
-PASSIVE_JOINTS = LEFT_HAND_PROFILE.passive_joints
-SPLAY_JOINTS: tuple[str, ...] = (
- "index_mcp_roll",
- "middle_mcp_roll",
- "ring_mcp_roll",
- "pinky_mcp_roll",
-)
-
-# These directly actuated motions have a fixed parent link during their sweep
-# and a motion plane that is close to the active camera's image plane. Their
-# projected circles are substantially more repeatable than the difference of
-# two independently estimated planar-Tag PnP depths. thumb_ip remains projected
-# because its front-view PnP depth is not repeatable enough for a 3-D fit. The
-# side-view index_dip and oblique top-view yaw remain parent-relative full
-# SE(3) measurements; their circle directions are constrained by the more
-# repeatable relative-orientation screw axis in urdf_zero.py.
-IMAGE_TRAJECTORY_JOINTS = LEFT_HAND_PROFILE.image_trajectory_joints
-
-# Keep the three unmeasured finger-roll motors away from the front camera's
-# line of sight while index_mcp_roll is measured.
-INDEX_ROLL_CLEARANCE_COMMANDS = LEFT_HAND_PROFILE.roll_clearance_commands
-
-# Hold thumb CMC roll at a camera-friendly pose while thumb CMC yaw is
-# measured. This keeps the moving top-view tag sufficiently front-facing.
-THUMB_YAW_CLEARANCE_COMMANDS: dict[int, int] = {
- 5: 145,
-}
-
-
-def calibration_auxiliary_commands(
- spec: SweepSpec,
- *,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
-) -> dict[int, int]:
- """Return motors that must remain fixed throughout one calibration task."""
- if spec.auxiliary_commands:
- return {int(index): int(value) for index, value in spec.auxiliary_commands}
- if spec.motor_index == 0:
- return dict(profile.thumb_pitch_clearance_commands)
- if spec.motor_index == profile.reference_roll_motor:
- return dict(profile.roll_clearance_commands)
- if spec.motor_index == 10:
- return dict(THUMB_YAW_CLEARANCE_COMMANDS)
- return {}
-
-
-def build_full_hand_command(
- motor_index: int,
- command_u8: int,
- baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
-) -> list[int]:
- if len(baseline) != 20:
- raise ValueError("baseline must contain exactly 20 values")
- motor = int(motor_index)
- if motor not in {spec.motor_index for spec in profile.joint_specs.values()}:
- raise ValueError("motor_index is not a controlled G20 calibration channel")
- command = int(command_u8)
- if not 0 <= command <= 255:
- raise ValueError("command_u8 must be in [0, 255]")
- result = [int(value) for value in baseline]
- if any(not 0 <= value <= 255 for value in result):
- raise ValueError("baseline values must be in [0, 255]")
- result[motor] = command
- return result
-
-
-def build_calibration_motion_command(
- spec: SweepSpec,
- command_u8: int,
- baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
-) -> list[int]:
- """Build a sweep command, including any required clearance pose."""
- result = build_full_hand_command(
- spec.motor_index,
- command_u8,
- baseline=baseline,
- profile=profile,
- )
- for motor_index, auxiliary_command in calibration_auxiliary_commands(
- spec, profile=profile
- ).items():
- result[motor_index] = auxiliary_command
- return result
-
-
-def _append_single_motor_waypoint(
- waypoints: list[tuple[int, ...]], current: list[int], motor: int, value: int
-) -> None:
- target = int(value)
- if current[int(motor)] == target:
- return
- current[int(motor)] = target
- waypoints.append(tuple(current))
-
-
-def build_calibration_preparation_waypoints(
- spec: SweepSpec,
- command_u8: int,
- *,
- current_command: Sequence[int],
- baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
- parallel: bool = True,
-) -> tuple[tuple[int, ...], ...]:
- """Enter a task pose in the reviewed safe order.
-
- ``parallel`` merges the sequential entry into joint-class groups: all
- neighbouring roll axes open together, then the clearance fingers flex
- their PIPs together, then their MCP pitches, then any remaining auxiliary
- motors, and the measured roll channel moves last. Every roll still opens
- before any finger flexes, each finger's PIP still flexes before its
- pitch, and the roll targets are unchanged, so no axis is forced across
- its coupled mechanical boundary.
- """
- if len(current_command) != 20:
- raise ValueError("current_command must contain exactly 20 values")
- final = build_calibration_motion_command(
- spec, command_u8, baseline=baseline, profile=profile
- )
- if profile.layout_id != G20_RIGHT_19_LAYOUT:
- return (tuple(final),)
- current = [int(np.clip(round(float(value)), 0, 255)) for value in current_command]
- controlled_motors = {
- int(joint.motor_index) for joint in profile.joint_specs.values()
- }
- # G20 feedback channels 11..14 are reserved and remain zero in the SDK.
- # Preserve their reported values in transition vectors; waiting for the
- # nominal 255 baseline on a non-existent actuator would deadlock before the
- # first real motor is allowed to move.
- for motor in range(20):
- if motor not in controlled_motors:
- final[motor] = current[motor]
- auxiliary = calibration_auxiliary_commands(spec, profile=profile)
- finger_order = ("pinky", "ring", "middle", "index")
- handled: set[int] = set()
- capture_views = {
- profile.record_specs[joint_name].view for joint_name in spec.joints
- }
- target_roll_motor: int | None = None
- lower_roll_motors: list[int] = []
- if "side" in capture_views:
- target_finger = str(spec.joints[0]).split("_", 1)[0]
- target_roll_motor = _MOTOR_BY_JOINT[f"{target_finger}_mcp_roll"]
- lower_roll_motors = list(range(9, target_roll_motor, -1))
- if parallel:
- roll_group: list[int] = []
- pip_group: list[int] = []
- pitch_group: list[int] = []
- if target_roll_motor is not None:
- for motor in range(6, target_roll_motor):
- if auxiliary.get(motor) == 255:
- roll_group.append(motor)
- handled.add(motor)
- for roll_motor in lower_roll_motors:
- roll_group.append(roll_motor)
- handled.add(roll_motor)
- finger = finger_order[9 - roll_motor]
- pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
- pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
- if auxiliary.get(pip_motor) == 0:
- pip_group.append(pip_motor)
- handled.add(pip_motor)
- if auxiliary.get(pitch_motor) == 0:
- pitch_group.append(pitch_motor)
- handled.add(pitch_motor)
- other_group = [
- motor
- for motor in sorted(controlled_motors | set(auxiliary))
- if motor not in handled
- and motor != spec.motor_index
- and current[motor] != final[motor]
- ]
- waypoints: list[tuple[int, ...]] = []
- _append_class_waypoint(waypoints, current, final, roll_group)
- _append_class_waypoint(waypoints, current, final, pip_group)
- _append_class_waypoint(waypoints, current, final, pitch_group)
- _append_class_waypoint(waypoints, current, final, other_group)
- # The measured channel moves last, after the swept volume is clear.
- _append_single_motor_waypoint(
- waypoints, current, spec.motor_index, final[spec.motor_index]
- )
- return tuple(waypoints or [tuple(final)])
- waypoints = []
- if "side" in capture_views:
- # Side-view clearance never forces a neighbouring roll axis across its
- # coupled mechanical boundary. Real sessions showed neighbouring
- # roll axes saturating at feedback 64 (motor 9) and 58 (motor 8) when
- # commanded to 0 in a coupled avoidance pose. Keep those roll axes at
- # neutral 127 and remove occlusion only by flexing the neighbour.
- for motor in range(6, target_roll_motor):
- if auxiliary.get(motor) == 255:
- _append_single_motor_waypoint(
- waypoints, current, motor, final[motor]
- )
- handled.add(motor)
- for roll_motor in lower_roll_motors:
- if roll_motor in auxiliary:
- _append_single_motor_waypoint(
- waypoints, current, roll_motor, final[roll_motor]
- )
- handled.add(roll_motor)
- finger = finger_order[9 - roll_motor]
- pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
- pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
- # Once the finger has rolled away from its neighbour, flex it out
- # of the camera line of sight using the reviewed PIP->pitch order.
- if auxiliary.get(pip_motor) == 0:
- _append_single_motor_waypoint(
- waypoints, current, pip_motor, final[pip_motor]
- )
- handled.add(pip_motor)
- if auxiliary.get(pitch_motor) == 0:
- _append_single_motor_waypoint(
- waypoints, current, pitch_motor, final[pitch_motor]
- )
- handled.add(pitch_motor)
- # Move the measured roll only after its neighbours have flexed out of
- # its swept volume. Pitch/PIP tasks leave this roll neutral throughout.
- if target_roll_motor == spec.motor_index:
- _append_single_motor_waypoint(
- waypoints,
- current,
- target_roll_motor,
- final[target_roll_motor],
- )
- handled.add(target_roll_motor)
- for motor in sorted(auxiliary):
- if motor not in handled and motor != spec.motor_index:
- _append_single_motor_waypoint(
- waypoints, current, motor, final[motor]
- )
- if spec.motor_index not in handled:
- _append_single_motor_waypoint(
- waypoints, current, spec.motor_index, final[spec.motor_index]
- )
- if not waypoints or waypoints[-1] != tuple(final):
- # Normally every difference has already been visited. Keep this
- # assertion-style fallback explicit so a future profile field cannot
- # silently bypass the one-motor transition policy.
- differing = [
- index for index, (left, right) in enumerate(zip(current, final))
- if index in controlled_motors and left != right
- ]
- for motor in differing:
- _append_single_motor_waypoint(
- waypoints, current, motor, final[motor]
- )
- return tuple(waypoints or [tuple(final)])
-
-
-def _append_class_waypoint(
- waypoints: list[tuple[int, ...]],
- current: list[int],
- target: Sequence[int],
- motors: Iterable[int],
-) -> None:
- """Move one joint-class group of motors in a single parallel waypoint."""
- moved = False
- for motor in motors:
- if current[int(motor)] != target[int(motor)]:
- current[int(motor)] = target[int(motor)]
- moved = True
- if moved:
- waypoints.append(tuple(current))
-
-
-def build_calibration_return_waypoints(
- target_command: Sequence[int],
- *,
- current_command: Sequence[int],
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
- anchor_roll_motor: int | None = None,
- parallel: bool = True,
-) -> tuple[tuple[int, ...], ...]:
- """Leave a product-layout avoidance pose using the inverse safe sequence.
-
- ``parallel`` merges the reviewed sequential order into joint-class groups:
- all differing rolls return to neutral together, then all differing MCP
- pitches, then all differing PIPs, then the remaining controlled motors.
- Every finger still unfolds only after every roll is back at neutral, each
- finger's pitch still moves before its PIP, and simultaneous same-direction
- roll motion removes the "one finger sweeps across parked neighbours"
- stall scenario that motivated the anchored one-motor sequence.
- """
- if len(current_command) != 20 or len(target_command) != 20:
- raise ValueError("return commands must contain exactly 20 values")
- target = [int(value) for value in target_command]
- if profile.layout_id != G20_RIGHT_19_LAYOUT:
- return (tuple(target),)
- current = [int(np.clip(round(float(value)), 0, 255)) for value in current_command]
- controlled_motors = {
- int(joint.motor_index) for joint in profile.joint_specs.values()
- }
- for motor in range(20):
- if motor not in controlled_motors:
- target[motor] = current[motor]
- if parallel:
- waypoints: list[tuple[int, ...]] = []
- _append_class_waypoint(waypoints, current, target, range(6, 10))
- _append_class_waypoint(waypoints, current, target, range(1, 5))
- _append_class_waypoint(waypoints, current, target, range(16, 20))
- _append_class_waypoint(
- waypoints,
- current,
- target,
- sorted(
- motor
- for motor in controlled_motors
- if motor not in set(range(6, 10))
- | set(range(1, 5))
- | set(range(16, 20))
- ),
- )
- return tuple(waypoints or [tuple(target)])
- waypoints = []
- handled: set[int] = set()
- # All four roll channels return to neutral before any bent clearance
- # finger is unfolded. In the reviewed product sequence the current/next
- # target finger must move first, then neighbouring fingers move outward.
- # Returning index first from an all-255 pinky task makes it converge into
- # three still-parked fingers and can mechanically stall around command175.
- roll_order: Sequence[int] = tuple(range(6, 10))
- anchor = None if anchor_roll_motor is None else int(anchor_roll_motor)
- if profile.layout_id == G20_RIGHT_19_LAYOUT:
- if anchor in range(6, 10):
- roll_order = tuple(
- sorted(
- range(6, 10),
- key=lambda motor: (abs(motor - anchor), -motor),
- )
- )
- else:
- roll_order = tuple(reversed(range(6, 10)))
- for motor in roll_order:
- if current[motor] != target[motor]:
- _append_single_motor_waypoint(
- waypoints, current, motor, target[motor]
- )
- handled.add(motor)
- # Reverse the entry order, one finger at a time: roll, pitch, then PIP.
- for finger in ("index", "middle", "ring", "pinky"):
- for motor in (
- _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"],
- _MOTOR_BY_JOINT[f"{finger}_pip"],
- ):
- if current[motor] != target[motor]:
- _append_single_motor_waypoint(
- waypoints, current, motor, target[motor]
- )
- handled.add(motor)
- for motor in sorted(controlled_motors):
- if motor not in handled and current[motor] != target[motor]:
- _append_single_motor_waypoint(
- waypoints, current, motor, target[motor]
- )
- return tuple(waypoints or [tuple(target)])
-
-
-def build_calibration_speed_profile(
- spec: SweepSpec,
- *,
- normal_speed: int,
- index_roll_speed: int,
- index_flex_speed: int,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
-) -> list[int]:
- """Return G20's five per-finger speeds for one calibration task."""
- normal = int(normal_speed)
- index_roll = int(index_roll_speed)
- index_flex = int(index_flex_speed)
- if not all(
- 0 <= speed <= 255 for speed in (normal, index_roll, index_flex)
- ):
- raise ValueError("calibration speeds must be in [0, 255]")
- speeds = [normal] * 5
- if spec.motor_index in range(6, 10):
- speeds[spec.motor_index - 5] = index_roll
- elif spec.motor_index in range(1, 5):
- speeds[spec.motor_index] = index_flex
- elif spec.motor_index in range(16, 20):
- speeds[spec.motor_index - 15] = index_flex
- return speeds
-
-
-def _record_vector(record: Mapping[str, Any]) -> np.ndarray:
- value = np.asarray(record.get("relative_translation_xyz_m"), dtype=float)
- if value.shape != (3,) or not np.all(np.isfinite(value)):
- raise ValueError("record relative_translation_xyz_m must contain 3 values")
- return value
-
-
-def _record_image_vector(record: Mapping[str, Any]) -> np.ndarray:
- value = np.asarray(record.get("image_relative_xy_px"), dtype=float)
- if value.shape != (2,) or not np.all(np.isfinite(value)):
- raise ValueError("record image_relative_xy_px must contain 2 values")
- return value
-
-
-def fit_joint_center_curve(
- records: Sequence[Mapping[str, Any]],
- *,
- maximum_plane_rms_m: float = 0.004,
- maximum_radial_rms_m: float = 0.004,
- minimum_radius_m: float = 0.003,
- minimum_arc_rad: float = math.radians(15.0),
-) -> JointCurveFit:
- """Fit one command-indexed curve from a parent-frame centre trajectory."""
- samples = [dict(record) for record in records]
- if len(samples) < 12:
- raise ValueError("joint trajectory requires at least 12 samples")
- points = np.asarray([_record_vector(record) for record in samples])
- axis, common_plane_rms = _fit_plane_axis([points])
- circle = _fit_circle_with_axis(points, axis)
- circle = _orient_circle_positive(circle, samples, list(points))
- values = [_angle_for_circle(point, circle) for point in points]
- curves, correction, hysteresis = _fit_joint_curve(samples, values)
- quality = {
- "plane_rms_m": max(
- float(common_plane_rms), float(circle["plane_rms_m"])
- ),
- "radial_rms_m": float(circle["radial_rms_m"]),
- "radius_m": float(circle["radius_m"]),
- "arc_rad": float(circle["observed_arc_rad"]),
- }
- failures: list[str] = []
- if quality["plane_rms_m"] > float(maximum_plane_rms_m):
- failures.append("plane_rms")
- if quality["radial_rms_m"] > float(maximum_radial_rms_m):
- failures.append("radial_rms")
- if quality["radius_m"] < float(minimum_radius_m):
- failures.append("radius")
- if quality["arc_rad"] < float(minimum_arc_rad):
- failures.append("arc")
- if failures:
- raise ValueError("joint_trajectory_quality_failed:" + ",".join(failures))
- 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=dict(circle),
- maximum_monotonic_correction_rad=float(correction),
- maximum_hysteresis_rad=float(hysteresis),
- quality=quality,
- )
-
-
-def _signed_image_circle_angle(
- point_xy_px: Sequence[float], circle: Mapping[str, Any]
-) -> float:
- point = np.asarray(point_xy_px, dtype=float)
- centre = np.asarray(circle["center_xy_px"], dtype=float)
- reference = np.asarray(circle["reference_xy_px"], dtype=float)
- vector = point - centre
- if point.shape != (2,) or not np.all(np.isfinite(point)):
- raise ValueError("image point must contain 2 finite values")
- if float(np.linalg.norm(vector)) < 1.0e-9:
- raise ValueError("image point lies at the fitted circle centre")
- return float(circle["orientation_sign"]) * math.atan2(
- float(reference[0] * vector[1] - reference[1] * vector[0]),
- float(np.dot(reference, vector)),
- )
-
-
-def fit_joint_image_curve(
- records: Sequence[Mapping[str, Any]],
- *,
- maximum_radial_rms_px: float = 2.0,
- maximum_radial_p95_px: float = 3.5,
- minimum_radius_px: float = 20.0,
- minimum_arc_rad: float = math.radians(15.0),
-) -> JointCurveFit:
- """Fit angular phase from a stable projected parent/child circle."""
- samples = [dict(record) for record in records]
- if len(samples) < 12:
- raise ValueError("joint image trajectory requires at least 12 samples")
- points = np.asarray([_record_image_vector(record) for record in samples])
- centre, radius = _fit_circle(points)
- radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
- quality = {
- "radial_rms_px": float(np.sqrt(np.mean(np.square(radial_error)))),
- "radial_p95_px": float(np.percentile(radial_error, 95.0)),
- "radius_px": float(radius),
- "arc_rad": float(_trajectory_arc_rad(points, centre)),
- }
- failures: list[str] = []
- if quality["radial_rms_px"] > float(maximum_radial_rms_px):
- failures.append("radial_rms")
- if quality["radial_p95_px"] > float(maximum_radial_p95_px):
- failures.append("radial_p95")
- if quality["radius_px"] < float(minimum_radius_px):
- failures.append("radius")
- if quality["arc_rad"] < float(minimum_arc_rad):
- failures.append("arc")
- if failures:
- raise ValueError(
- "joint_image_trajectory_quality_failed:" + ",".join(failures)
- )
-
- endpoint_points = np.asarray(
- [
- point
- for record, point in zip(samples, points)
- if int(record["command_u8"]) == 255
- ]
- )
- if endpoint_points.size == 0:
- raise ValueError("joint image trajectory is missing command 255")
- reference = np.median(endpoint_points, axis=0) - centre
- reference_norm = float(np.linalg.norm(reference))
- if reference_norm < 1.0e-9:
- raise ValueError("joint image trajectory endpoint is degenerate")
- reference /= reference_norm
- circle: dict[str, Any] = {
- "space": "image_2d",
- "center_xy_px": [float(value) for value in centre],
- "reference_xy_px": [float(value) for value in reference],
- "orientation_sign": 1.0,
- "radius_px": float(radius),
- }
- values = [_signed_image_circle_angle(point, circle) for point in points]
- command_zero_values = [
- value
- for record, value in zip(samples, values)
- if int(record["command_u8"]) == 0
- ]
- if not command_zero_values:
- raise ValueError("joint image trajectory is missing command 0")
- if float(np.median(command_zero_values)) < 0.0:
- circle["orientation_sign"] = -1.0
- values = [-value for value in values]
- curves, correction, hysteresis = _fit_joint_curve(samples, values)
- 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=circle,
- maximum_monotonic_correction_rad=float(correction),
- maximum_hysteresis_rad=float(hysteresis),
- quality=quality,
- )
-
-
-def fit_measured_joint_curve(
- joint_name: str,
- records: Sequence[Mapping[str, Any]],
- *,
- maximum_plane_rms_m: float = 0.004,
- maximum_radial_rms_m: float = 0.004,
- minimum_radius_m: float = 0.003,
- minimum_arc_rad: float = math.radians(15.0),
- image_maximum_radial_rms_px: float = 2.0,
- image_maximum_radial_p95_px: float = 3.5,
- image_minimum_radius_px: float = 20.0,
- profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
-) -> JointCurveFit:
- """Select the stable trajectory representation for one measured joint."""
- if str(joint_name) in profile.image_trajectory_joints:
- return fit_joint_image_curve(
- records,
- maximum_radial_rms_px=image_maximum_radial_rms_px,
- maximum_radial_p95_px=image_maximum_radial_p95_px,
- minimum_radius_px=image_minimum_radius_px,
- minimum_arc_rad=minimum_arc_rad,
- )
- return fit_joint_center_curve(
- records,
- maximum_plane_rms_m=maximum_plane_rms_m,
- maximum_radial_rms_m=maximum_radial_rms_m,
- minimum_radius_m=minimum_radius_m,
- minimum_arc_rad=minimum_arc_rad,
- )
-
-
-def center_splay_curve(fit: JointCurveFit) -> tuple[JointCurveFit, int, float]:
- """Centre a measured 255->0 splay travel on its angular midpoint."""
- raw = np.asarray(fit.angle_rad, dtype=float)
- if raw.shape != (256,) or not np.all(np.isfinite(raw)):
- raise ValueError("splay curve must contain 256 finite values")
- if np.any(np.diff(raw) > 1.0e-7):
- raise ValueError("raw splay curve must be non-increasing")
- travel_midpoint = 0.5 * float(raw[0] + raw[255])
- if travel_midpoint <= 0.0:
- raise ValueError("splay travel must be positive")
- centred = raw - travel_midpoint
- # Make the public endpoint symmetry exact after decimal rounding.
- endpoint = round(0.5 * float(raw[0] - raw[255]), 8)
- centred[0] = endpoint
- centred[255] = -endpoint
- centred = np.asarray([round(float(value), 8) for value in centred])
- zero_command = int(np.argmin(np.abs(centred)))
- return (
- replace(
- fit,
- angle_rad=tuple(float(value) for value in centred),
- zero_offset_rad=round(float(travel_midpoint), 8),
- ),
- zero_command,
- round(float(travel_midpoint), 8),
- )
-
-
-def measure_joint_vector(fit: JointCurveFit, vector_xyz_m: Sequence[float]) -> float:
- """Measure one vector with a fitted model, including splay centring."""
- return float(
- _angle_for_circle(vector_xyz_m, fit.circle) - fit.zero_offset_rad
- )
-
-
-def measure_joint_observation(
- fit: JointCurveFit,
- *,
- vector_xyz_m: Sequence[float],
- image_vector_xy_px: Sequence[float],
-) -> float:
- """Measure an observation in the same space used to fit its curve."""
- if fit.circle.get("space") == "image_2d":
- return float(
- _signed_image_circle_angle(image_vector_xy_px, fit.circle)
- - fit.zero_offset_rad
- )
- return measure_joint_vector(fit, vector_xyz_m)
-
-
-def compare_cross_view_roll_curves(
- primary: JointCurveFit,
- secondary: JointCurveFit,
- *,
- maximum_rms_difference_rad: float = math.radians(1.0),
- maximum_branch_gap_difference_rad: float = math.radians(0.3),
- allow_projection_scale: bool = False,
- maximum_projection_scale_ratio: float = 1.5,
-) -> dict[str, float]:
- """Validate two independently fitted roll curves before axis fusion.
-
- The G20 right roll primary is an image-plane centre trajectory while its
- side validation is a 3-D rotation trajectory. A fixed Tag mounting tilt
- changes the image-plane angular scale without changing the mechanism. In
- projection-scale mode, remove that one constant scale before comparing
- curve shape. Direction, scale bounds, direction-dependent branch gap and
- every single-view quality/holdout gate remain independent hard checks.
- """
- if maximum_rms_difference_rad <= 0.0:
- raise ValueError("cross-view RMS limit must be positive")
- if maximum_branch_gap_difference_rad <= 0.0:
- raise ValueError("cross-view branch-gap limit must be positive")
- if maximum_projection_scale_ratio <= 1.0:
- raise ValueError("cross-view projection-scale ratio must exceed one")
-
- fields: dict[str, tuple[np.ndarray, np.ndarray]] = {}
- for field_name in ("angle_rad", "decreasing_rad", "increasing_rad"):
- left = np.asarray(getattr(primary, field_name), dtype=float)
- right = np.asarray(getattr(secondary, field_name), dtype=float)
- if left.shape != (256,) or right.shape != (256,):
- raise ValueError("cross-view curves must contain 256 values")
- if not np.all(np.isfinite(left)) or not np.all(np.isfinite(right)):
- raise ValueError("cross-view curves must be finite")
- fields[field_name] = (left, right)
-
- primary_travel = float(
- fields["angle_rad"][0][0] - fields["angle_rad"][0][255]
- )
- secondary_travel = float(
- fields["angle_rad"][1][0] - fields["angle_rad"][1][255]
- )
- if primary_travel * secondary_travel <= 0.0:
- raise ValueError("cross_view_roll_curve_direction_disagrees")
-
- primary_zero = int(primary.circle.get("zero_command_u8", 127))
- secondary_zero = int(secondary.circle.get("zero_command_u8", 127))
- if not 0 <= primary_zero <= 255 or not 0 <= secondary_zero <= 255:
- raise ValueError("cross-view zero command must be in [0, 255]")
- primary_anchor = float(primary.decreasing_rad[primary_zero])
- secondary_anchor = float(secondary.decreasing_rad[secondary_zero])
- projection_scale = 1.0
- if allow_projection_scale:
- primary_values = np.concatenate(
- [left - primary_anchor for left, _ in fields.values()]
- )
- secondary_values = np.concatenate(
- [right - secondary_anchor for _, right in fields.values()]
- )
- denominator = float(secondary_values @ secondary_values)
- if denominator <= np.finfo(float).eps:
- raise ValueError("cross_view_roll_projection_scale_unobservable")
- projection_scale = float(
- (primary_values @ secondary_values) / denominator
- )
- if projection_scale <= 0.0 or not math.isfinite(projection_scale):
- raise ValueError("cross_view_roll_curve_direction_disagrees")
- scale_ratio = max(projection_scale, 1.0 / projection_scale)
- if scale_ratio > float(maximum_projection_scale_ratio):
- raise ValueError(
- "cross_view_roll_projection_scale_ratio_too_large:"
- f"{scale_ratio:.6f}"
- )
-
- metrics: dict[str, float] = {
- "projection_scale": projection_scale,
- "projection_scale_ratio": max(
- projection_scale, 1.0 / projection_scale
- ),
- }
- for field_name, (left, right) in fields.items():
- metrics[f"raw_{field_name}_rms_difference_rad"] = float(
- np.sqrt(np.mean(np.square(left - right)))
- )
- if allow_projection_scale:
- left = left - primary_anchor
- right = projection_scale * (right - secondary_anchor)
- rms = float(np.sqrt(np.mean(np.square(left - right))))
- metrics[f"{field_name}_rms_difference_rad"] = rms
- if rms > maximum_rms_difference_rad:
- raise ValueError(
- "cross_view_roll_curve_difference_too_large:"
- f"{field_name}:{math.degrees(rms):.6f}deg"
- )
- metrics["travel_difference_rad"] = abs(primary_travel - secondary_travel)
- primary_gap = float(
- primary.increasing_rad[primary_zero]
- - primary.decreasing_rad[primary_zero]
- )
- secondary_gap = float(
- secondary.increasing_rad[secondary_zero]
- - secondary.decreasing_rad[secondary_zero]
- )
- metrics["raw_baseline_branch_gap_difference_rad"] = abs(
- primary_gap - secondary_gap
- )
- branch_gap_difference = abs(
- primary_gap - projection_scale * secondary_gap
- )
- metrics["baseline_branch_gap_difference_rad"] = branch_gap_difference
- # In projection-scale mode the primary is an image-circle phase while the
- # secondary is a 3-D rotation angle. A fixed Tag tilt/offset changes the
- # image projection of the two motion branches independently, so their
- # absolute gap is not a cross-view invariant. Both views retain their
- # own hard absolute-gap and four-cycle gap-range checks in the acquisition
- # quality gate. Keep this value as a diagnostic, but apply the cross-view
- # absolute-gap gate only when both curves live in comparable coordinates.
- metrics["baseline_branch_gap_gate_applied"] = float(
- not allow_projection_scale
- )
- if (
- not allow_projection_scale
- and branch_gap_difference > maximum_branch_gap_difference_rad
- ):
- raise ValueError(
- "cross_view_roll_branch_gap_difference_too_large:"
- f"{math.degrees(branch_gap_difference):.6f}deg"
- )
- return metrics
-
-
-def cross_view_roll_diagnostic_metrics(
- primary: JointCurveFit,
- secondary: JointCurveFit,
-) -> dict[str, float]:
- """Compare a validation-only roll view without creating a retry gate.
-
- G20-right publishes the front image-plane roll curve. The side PIP-link
- observation supplies an independently quality-gated physical axis-line
- position, but its angle includes projection and linkage effects and is
- therefore diagnostic rather than a second measurement of the published
- curve. Live fitting and offline replay must use the same non-blocking
- comparison; otherwise a stable view bias can recall an already complete
- task even though reacquiring the same motion cannot change the result.
- """
- try:
- return compare_cross_view_roll_curves(
- primary,
- secondary,
- maximum_rms_difference_rad=float("inf"),
- maximum_branch_gap_difference_rad=float("inf"),
- allow_projection_scale=True,
- maximum_projection_scale_ratio=float("inf"),
- )
- except ValueError as error:
- if str(error) == "cross_view_roll_curve_direction_disagrees":
- return {"direction_disagrees": 1.0}
- raise
-
-
-def fit_projected_zero(
- records: Sequence[Mapping[str, Any]],
- *,
- minimum_radius_px: float = 20.0,
- minimum_arc_rad: float = math.radians(15.0),
- maximum_radial_rms_px: float = 2.0,
- maximum_radial_p95_px: float = 3.5,
- maximum_round_difference_rad: float = math.radians(1.0),
-) -> float:
- """Return the image/table-projected zero from three sweep rounds."""
- samples = [dict(record) for record in records]
- points = np.asarray(
- [record.get("image_relative_xy_px") for record in samples], dtype=float
- )
- if points.ndim != 2 or points.shape[1] != 2 or len(points) < 12:
- raise ValueError("projected zero requires 2-D trajectory samples")
- if not np.all(np.isfinite(points)):
- raise ValueError("projected zero samples must be finite")
- centre, radius = _fit_circle(points)
- radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
- radial_rms = float(np.sqrt(np.mean(np.square(radial_error))))
- radial_p95 = float(np.percentile(radial_error, 95.0))
- arc = _trajectory_arc_rad(points, centre)
- if radius < minimum_radius_px:
- raise ValueError("projected_zero_radius_too_small")
- if arc < minimum_arc_rad:
- raise ValueError("projected_zero_arc_too_small")
- if radial_rms > maximum_radial_rms_px:
- raise ValueError("projected_zero_radial_rms_too_large")
- if radial_p95 > maximum_radial_p95_px:
- raise ValueError("projected_zero_radial_p95_too_large")
-
- cycles = sorted({int(record["cycle"]) for record in samples})
- if len(cycles) < 3:
- raise ValueError("projected zero requires three sweep rounds")
- angles: list[float] = []
- for cycle in cycles:
- zero_points = np.asarray(
- [
- record["image_relative_xy_px"]
- for record in samples
- if int(record["cycle"]) == cycle
- and int(record["command_u8"]) == 255
- ],
- dtype=float,
- )
- if zero_points.size == 0:
- raise ValueError("projected zero is missing command 255")
- point = np.median(zero_points, axis=0)
- inward = centre - point
- if float(np.linalg.norm(inward)) < 1.0e-9:
- raise ValueError("projected zero point is degenerate")
- angles.append(
- wrap_angle_rad(math.atan2(-float(inward[1]), float(inward[0])))
- )
- if maximum_pairwise_angle_difference_rad(angles) > maximum_round_difference_rad:
- raise ValueError("projected_zero_round_difference_too_large")
- return round(float(circular_median_rad(angles)), 8)
-
-
-def build_compact_payload(
- *,
- serial_number: str,
- measured_fits: Mapping[str, JointCurveFit],
- urdf_zero_offsets_rad: Mapping[str, float],
- validation_errors_rad: Sequence[float],
- passed: bool,
- baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
- side: str = "left",
- layout_id: str = "legacy_11",
- zero_uncertainty_rad: Mapping[str, float] | None = None,
- zero_cycle_offsets_rad: Mapping[str, Sequence[float]] | None = None,
- zero_observers: Mapping[str, str] | None = None,
- artifact_hashes: Mapping[str, str] | None = None,
- cross_view_roll_metrics: Mapping[str, Mapping[str, float]] | None = None,
- joint_dynamic_diagnostics: Mapping[str, Mapping[str, Any]] | None = None,
- zero_geometry_diagnostics: Mapping[str, Any] | None = None,
-) -> dict[str, Any]:
- profile = get_hand_calibration_profile(side, layout_id)
- expected_fits = (
- set(profile.joint_specs)
- if profile.layout_id == G20_RIGHT_19_LAYOUT
- else set(profile.measured_joints)
- )
- if set(measured_fits) != expected_fits:
- raise ValueError("measured_fits has the wrong runtime joint set")
- expected_active = {
- name for name, spec in profile.joint_specs.items() if spec.active
- }
- if set(urdf_zero_offsets_rad) != expected_active:
- raise ValueError("urdf_zero_offsets_rad has the wrong active-joint set")
- if len(baseline) != 20:
- raise ValueError("baseline must contain exactly 20 commands")
-
- if profile.layout_id == G20_RIGHT_19_LAYOUT:
- return _build_right_19_compact_payload(
- serial_number=serial_number,
- measured_fits=measured_fits,
- urdf_zero_offsets_rad=urdf_zero_offsets_rad,
- validation_errors_rad=validation_errors_rad,
- passed=passed,
- baseline=baseline,
- profile=profile,
- zero_uncertainty_rad=zero_uncertainty_rad or {},
- zero_cycle_offsets_rad=zero_cycle_offsets_rad or {},
- zero_observers=zero_observers or {},
- artifact_hashes=artifact_hashes or {},
- cross_view_roll_metrics=cross_view_roll_metrics or {},
- joint_dynamic_diagnostics=joint_dynamic_diagnostics or {},
- zero_geometry_diagnostics=zero_geometry_diagnostics or {},
- )
-
- joints: dict[str, dict[str, Any]] = {}
- for name, spec in profile.joint_specs.items():
- source_name = spec.source_joint or name
- fit = measured_fits[source_name]
- joint: dict[str, Any] = {
- "motor_index": int(spec.motor_index),
- "angle_rad": [round(float(value), 8) for value in fit.angle_rad],
- }
- if spec.active:
- joint["zero_command_u8"] = int(baseline[spec.motor_index])
- joint["zero_angles"] = {
- "urdf_zero_offset_rad": round(
- float(urdf_zero_offsets_rad[name]), 8
- )
- }
- else:
- joint["passive"] = True
- if spec.source_joint is not None:
- joint["source_joint"] = spec.source_joint
- joints[name] = joint
-
- errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
- mae = float(np.mean(errors)) if errors.size else float("nan")
- p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
- payload = {
- "schema_version": 4,
- "model": "G20",
- "side": profile.side,
- "serial_number": str(serial_number),
- "angle_unit": "rad",
- "command_range": [0, 255],
- "baseline_command_u8": [int(value) for value in baseline],
- "joints": joints,
- "quality": {
- "passed": bool(passed),
- "validation_mae_rad": (
- None if not math.isfinite(mae) else round(mae, 8)
- ),
- "validation_p95_rad": (
- None if not math.isfinite(p95) else round(p95, 8)
- ),
- },
- }
- validate_compact_payload(payload)
- return payload
-
-
-def _build_right_19_compact_payload(
- *,
- serial_number: str,
- measured_fits: Mapping[str, JointCurveFit],
- urdf_zero_offsets_rad: Mapping[str, float],
- validation_errors_rad: Sequence[float],
- passed: bool,
- baseline: Sequence[int],
- profile: HandCalibrationProfile,
- zero_uncertainty_rad: Mapping[str, float],
- zero_cycle_offsets_rad: Mapping[str, Sequence[float]],
- zero_observers: Mapping[str, str],
- artifact_hashes: Mapping[str, str],
- cross_view_roll_metrics: Mapping[str, Mapping[str, float]],
- joint_dynamic_diagnostics: Mapping[str, Mapping[str, Any]],
- zero_geometry_diagnostics: Mapping[str, Any],
-) -> dict[str, Any]:
- # The public runtime artifact intentionally keeps the established schema-v4
- # contract. Directional curves, uncertainty, hashes and geometry metrics
- # are calibration diagnostics and belong in calibration_summary_zh.json;
- # exposing them here made it too easy for a runtime to apply a feedback
- # curve or a zero offset twice.
- del (
- zero_uncertainty_rad,
- zero_cycle_offsets_rad,
- zero_observers,
- artifact_hashes,
- cross_view_roll_metrics,
- joint_dynamic_diagnostics,
- zero_geometry_diagnostics,
- )
- joints: dict[str, dict[str, Any]] = {}
- for name, spec in profile.joint_specs.items():
- fit = measured_fits[name]
- zero_command = int(baseline[spec.motor_index])
- decreasing = np.asarray(fit.decreasing_rad, dtype=float)
- increasing = np.asarray(fit.increasing_rad, dtype=float)
- if decreasing.shape != (256,) or increasing.shape != (256,):
- raise ValueError(f"{name} directional curves must contain 256 values")
- # A single schema-v4 curve is the steady-state midpoint of both
- # approach directions. Publication separately rejects a branch gap
- # above 2 degrees, bounding the midpoint's direction error to 1 degree.
- curve = 0.5 * (decreasing + increasing)
- curve -= float(curve[zero_command])
- joint: dict[str, Any] = {
- "motor_index": int(spec.motor_index),
- "angle_rad": [round(float(value), 8) for value in curve],
- }
- if spec.active:
- joint["zero_command_u8"] = zero_command
- joint["zero_angles"] = {
- "urdf_zero_offset_rad": round(
- float(urdf_zero_offsets_rad[name]), 8
- )
- }
- else:
- joint["passive"] = True
- joints[name] = joint
-
- errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
- mae = float(np.mean(errors)) if errors.size else float("nan")
- p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
- payload = {
- "schema_version": 4,
- "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],
- "joints": joints,
- "quality": {
- "passed": bool(passed),
- "validation_mae_rad": (
- None if not math.isfinite(mae) else round(mae, 8)
- ),
- "validation_p95_rad": (
- None if not math.isfinite(p95) else round(p95, 8)
- ),
- },
- }
- validate_compact_payload(payload)
- return payload
-
-
-def infer_compact_payload_layout(payload: Mapping[str, Any]) -> str:
- """Infer the layout for schema-v4 while honoring explicit newer schemas."""
- explicit = str(payload.get("layout_id", "")).strip().lower()
- if explicit:
- return explicit
- raw_joints = payload.get("joints", {})
- independent_right = bool(
- str(payload.get("side", "")).lower() == "right"
- and isinstance(raw_joints, Mapping)
- and raw_joints
- and all(
- not isinstance(value, Mapping) or "source_joint" not in value
- for value in raw_joints.values()
- )
- )
- return G20_RIGHT_19_LAYOUT if independent_right else "legacy_11"
-
-
-def validate_compact_payload(payload: Mapping[str, Any]) -> None:
- if payload.get("schema_version") == 5:
- _validate_right_19_compact_payload(payload)
- return
- expected_top = {
- "schema_version", "model", "side", "serial_number", "angle_unit",
- "command_range", "baseline_command_u8", "joints", "quality",
- }
- if set(payload) != expected_top:
- raise ValueError("compact calibration has unexpected top-level fields")
- if payload["schema_version"] != 4:
- raise ValueError("schema_version must be 4")
- if payload["model"] != "G20" or payload["side"] not in {"left", "right"}:
- raise ValueError("payload must describe a left or right G20")
- # schema-v4 has no layout field. A right-hand payload without inherited
- # source_joint fields is the independent right-hand product layout; legacy
- # payloads retain their source_joint markers and remain readable.
- layout_id = infer_compact_payload_layout(payload)
- independent_right = layout_id == G20_RIGHT_19_LAYOUT
- profile = get_hand_calibration_profile(
- str(payload["side"]),
- layout_id,
- )
- if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
- raise ValueError("payload angle or command units are invalid")
- baseline = payload["baseline_command_u8"]
- if not isinstance(baseline, list) or len(baseline) != 20:
- raise ValueError("baseline_command_u8 must contain 20 values")
- joints = payload["joints"]
- if not isinstance(joints, Mapping) or set(joints) != set(profile.joint_specs):
- raise ValueError("payload must contain exactly 21 G20 joints")
-
- for name, spec in profile.joint_specs.items():
- joint = joints[name]
- allowed = {"motor_index", "angle_rad"}
- allowed.add("zero_command_u8" if spec.active else "passive")
- if spec.active:
- allowed.add("zero_angles")
- if spec.source_joint is not None and not independent_right:
- allowed.add("source_joint")
- if set(joint) != allowed:
- raise ValueError(f"{name} has unexpected fields")
- if int(joint["motor_index"]) != spec.motor_index:
- raise ValueError(f"{name} has the wrong motor_index")
- curve = np.asarray(joint["angle_rad"], dtype=float)
- if curve.shape != (256,) or not np.all(np.isfinite(curve)):
- raise ValueError(f"{name}.angle_rad must contain 256 finite values")
- if np.any(np.diff(curve) > 1.0e-7):
- raise ValueError(f"{name}.angle_rad must be non-increasing")
- if spec.active:
- zero = joint["zero_command_u8"]
- if not isinstance(zero, int) or not 0 <= zero <= 255:
- raise ValueError(f"{name}.zero_command_u8 is invalid")
- zero_angles = joint.get("zero_angles")
- if not isinstance(zero_angles, Mapping) or set(zero_angles) != {
- "urdf_zero_offset_rad"
- }:
- raise ValueError(
- f"{name}.zero_angles must contain urdf_zero_offset_rad"
- )
- offset = float(zero_angles["urdf_zero_offset_rad"])
- if not math.isfinite(offset):
- raise ValueError(f"{name}.urdf_zero_offset_rad is invalid")
- if abs(float(curve[zero])) > 1.0e-6:
- raise ValueError(
- f"{name}.angle_rad must be zero at zero_command_u8"
- )
- elif joint.get("passive") is not True:
- raise ValueError(f"{name} must be marked passive")
- if spec.source_joint is not None and not independent_right:
- if joint.get("source_joint") != spec.source_joint:
- raise ValueError(f"{name} has the wrong source_joint")
- source = joints[spec.source_joint]
- if joint["angle_rad"] != source["angle_rad"]:
- raise ValueError(f"{name} must copy its source curve exactly")
- if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]:
- raise ValueError(f"{name} must copy its source zero command")
- # source_joint denotes a dynamic command-angle curve source. The
- # absolute URDF zero belongs to an independent motor/assembly and
- # must remain per-joint unless it was independently observed.
-
- quality = payload["quality"]
- if set(quality) != {
- "passed", "validation_mae_rad", "validation_p95_rad"
- }:
- raise ValueError("quality must contain only compact summary fields")
-
-
-def _validate_right_19_compact_payload(payload: Mapping[str, Any]) -> None:
- expected_top = {
- "schema_version", "layout_id", "model", "side", "serial_number",
- "angle_unit", "command_range", "curve_input_domain",
- "runtime_curve_policy", "baseline_command_u8",
- "static_zero_targets", "frozen_static_zero_joints", "joints",
- "cross_view_roll", "static_zero_validation", "artifacts", "quality",
- }
- if set(payload) != expected_top:
- raise ValueError("schema v5 calibration has unexpected top-level fields")
- if (
- payload["layout_id"] not in {"g20_right_19", G20_RIGHT_15_LAYOUT}
- or payload["model"] != "G20"
- or payload["side"] != "right"
- ):
- raise ValueError("schema v5 must describe a G20 right product layout")
- profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
- baseline = payload["baseline_command_u8"]
- if not isinstance(baseline, list) or len(baseline) != 20:
- raise ValueError("baseline_command_u8 must contain 20 values")
- if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
- raise ValueError("payload angle or command units are invalid")
- if payload["curve_input_domain"] != "feedback_u8":
- raise ValueError("schema v5 curves must be feedback-indexed")
- runtime_curve_policy = payload["runtime_curve_policy"]
- if runtime_curve_policy not in {
- "direction_aware",
- "direction_aware_canonical_zero",
- }:
- raise ValueError("schema v5 runtime curve policy must be direction-aware")
- # v5 is a read-only legacy contract from the earlier 15-zero product.
- # Keep its historical scope stable even though new v4 publications now
- # calibrate thumb_mcp as the 16th active static zero.
- static_targets = {
- name
- for name, spec in profile.joint_specs.items()
- if spec.zero_kind == "urdf_axis_chain"
- } - {"thumb_mcp"}
- frozen = {"thumb_mcp", *profile.passive_joints}
- if set(payload["static_zero_targets"]) != static_targets:
- raise ValueError("schema v5 must contain exactly 15 static-zero targets")
- if set(payload["frozen_static_zero_joints"]) != frozen:
- raise ValueError("schema v5 frozen static-zero joint set is invalid")
- static_validation = payload["static_zero_validation"]
- training_cycles = static_validation.get("training_cycles", [])
- holdout_cycle = static_validation.get("holdout_cycle")
- expected_cycle_count = len(training_cycles) + 1
- joints = payload["joints"]
- if not isinstance(joints, Mapping) or set(joints) != set(profile.joint_specs):
- raise ValueError("schema v5 must contain exactly 21 independent joints")
- for name, spec in profile.joint_specs.items():
- joint = joints[name]
- expected_fields = {
- "motor_index", "zero_command_u8", "angle_rad", "decreasing_rad",
- "increasing_rad", "dynamic_quality", "static_zero_status",
- "zero_angles" if spec.active else "passive",
- }
- if set(joint) != expected_fields:
- raise ValueError(f"{name} has unexpected schema v5 fields")
- if int(joint["motor_index"]) != spec.motor_index:
- raise ValueError(f"{name} has the wrong motor_index")
- zero_command = joint["zero_command_u8"]
- if zero_command != int(baseline[spec.motor_index]):
- raise ValueError(f"{name} has the wrong zero command")
- for field_name in ("angle_rad", "decreasing_rad", "increasing_rad"):
- curve = np.asarray(joint[field_name], dtype=float)
- if curve.shape != (256,) or not np.all(np.isfinite(curve)):
- raise ValueError(f"{name}.{field_name} must contain 256 values")
- if np.any(np.diff(curve) > 1.0e-7):
- raise ValueError(f"{name}.{field_name} must be non-increasing")
- allow_directional_offset = (
- runtime_curve_policy == "direction_aware_canonical_zero"
- and canonical_zero_direction(profile, name) is not None
- and field_name == "increasing_rad"
- )
- if (
- not allow_directional_offset
- and abs(float(curve[zero_command])) > 1.0e-6
- ):
- raise ValueError(f"{name}.{field_name} must be zero at baseline")
- expected_status = (
- "static_zero_calibrated"
- if name in static_targets
- else "cad_static_zero_retained_front_only"
- if name == "thumb_mcp"
- else "dynamic_measured_static_unobservable"
- )
- if joint["static_zero_status"] != expected_status:
- raise ValueError(f"{name} has the wrong static-zero status")
- dynamic_quality = joint["dynamic_quality"]
- if set(dynamic_quality) != {
- "maximum_monotonic_correction_rad",
- "maximum_hysteresis_rad",
- "fit",
- "cycle_travel_rad",
- "cycle_travel_range_rad",
- "baseline_hysteresis_by_cycle_rad",
- "holdout_cycle",
- "holdout_cycle_mae_rad",
- "holdout_cycle_max_rad",
- }:
- raise ValueError(f"{name}.dynamic_quality fields are invalid")
- if (
- dynamic_quality["holdout_cycle"] != holdout_cycle
- or len(dynamic_quality["cycle_travel_rad"]) != expected_cycle_count
- or len(
- dynamic_quality["baseline_hysteresis_by_cycle_rad"]
- ) != expected_cycle_count
- ):
- raise ValueError(f"{name} must record all dynamic cycles")
- if spec.active:
- zero = joint["zero_angles"]
- if set(zero) != {
- "urdf_zero_offset_rad",
- "confidence_interval_95_half_width_rad",
- "cycle_offsets_rad",
- "observer_joint",
- }:
- raise ValueError(f"{name}.zero_angles fields are invalid")
- if not math.isfinite(float(zero["urdf_zero_offset_rad"])):
- raise ValueError(f"{name} zero offset is invalid")
- if name in static_targets:
- confidence = zero["confidence_interval_95_half_width_rad"]
- if (
- confidence is None
- or not math.isfinite(float(confidence))
- or not 0.0 <= float(confidence) <= math.radians(0.5)
- ):
- raise ValueError(f"{name} confidence interval is invalid")
- if len(zero["cycle_offsets_rad"]) != len(training_cycles):
- raise ValueError(
- f"{name} must record training-only zero estimates"
- )
- if not isinstance(zero["observer_joint"], str):
- raise ValueError(f"{name} must record its zero observer")
- if name == "thumb_mcp" and float(zero["urdf_zero_offset_rad"]) != 0.0:
- raise ValueError("thumb_mcp static zero must remain source CAD")
- elif joint.get("passive") is not True:
- raise ValueError(f"{name} must be marked passive")
- artifacts = payload["artifacts"]
- expected_hashes = {
- "source_urdf_sha256", "camera_extrinsics_sha256",
- "corrected_urdf_sha256",
- }
- if set(artifacts) != expected_hashes or any(
- re.fullmatch(r"[0-9a-f]{64}", str(value)) is None
- for value in artifacts.values()
- ):
- raise ValueError("schema v5 artifact SHA-256 values are invalid")
- if set(payload["cross_view_roll"]) != {
- f"{finger}_mcp_roll" for finger in ("index", "middle", "ring", "pinky")
- }:
- raise ValueError("schema v5 requires four cross-view roll diagnostics")
- if set(static_validation) != {
- "training_cycles", "holdout_cycle", "axis_line_rms_m",
- "axis_line_rms_by_joint_m", "observability_rank",
- "observability_parameter_count", "observability_condition_number",
- "offset_covariance_rad2",
- }:
- raise ValueError("schema v5 static-zero validation fields are invalid")
- training_cycles = static_validation["training_cycles"]
- holdout_cycle = static_validation["holdout_cycle"]
- if (
- not isinstance(training_cycles, list)
- or len(training_cycles) < 3
- or any(not isinstance(value, int) for value in training_cycles)
- or not isinstance(holdout_cycle, int)
- or holdout_cycle in training_cycles
- ):
- raise ValueError("schema v5 training/holdout cycle split is invalid")
- axis_line_rms = float(static_validation["axis_line_rms_m"])
- if not math.isfinite(axis_line_rms) or axis_line_rms < 0.0:
- raise ValueError("schema v5 axis-line RMS is invalid")
- line_by_joint = static_validation["axis_line_rms_by_joint_m"]
- if not isinstance(line_by_joint, Mapping) or any(
- not math.isfinite(float(value)) or float(value) < 0.0
- for value in line_by_joint.values()
- ):
- raise ValueError("schema v5 per-joint axis-line RMS is invalid")
- parameter_count = int(static_validation["observability_parameter_count"])
- rank = int(static_validation["observability_rank"])
- condition = float(static_validation["observability_condition_number"])
- if parameter_count != 21 or rank != parameter_count:
- raise ValueError("schema v5 zero-observation Jacobian is rank deficient")
- if not math.isfinite(condition) or condition < 1.0:
- raise ValueError("schema v5 observability condition number is invalid")
- offset_covariance = static_validation["offset_covariance_rad2"]
- if set(offset_covariance) != static_targets or any(
- not math.isfinite(float(value)) or float(value) < 0.0
- for value in offset_covariance.values()
- ):
- raise ValueError("schema v5 offset covariance is invalid")
- quality = payload["quality"]
- if set(quality) != {
- "passed", "all_15_static_targets_required", "validation_mae_rad",
- "validation_p95_rad",
- } or quality["all_15_static_targets_required"] is not True:
- raise ValueError("schema v5 quality summary is invalid")
+sys.modules[__name__] = _implementation
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py
index 64c18b0..a030e19 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/_adapter.py
@@ -17,6 +17,7 @@ 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 (
@@ -24,7 +25,6 @@ from .motion import (
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
-from .zero_policy import MIMIC_DERIVED_FINGER_DIPS
_HARD_THRESHOLD_KEYS = frozenset(
@@ -45,6 +45,12 @@ def _run_cli(args: list[str] | None = None) -> None:
main(args)
+def _run_node(args: list[str] | None = None) -> None:
+ from .node import main
+
+ main(args)
+
+
def adapt_profile(
*,
key: ProfileKey,
@@ -127,6 +133,15 @@ def adapt_profile(
views=views,
common_frame="calibration_common",
extrinsic_reference_view=views[0].name,
+ 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,
@@ -196,5 +211,6 @@ def adapt_profile(
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_run_cli,
+ node_main=_run_node,
),
)
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py
index 932f36d..d7d5cc3 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/artifacts.py
@@ -21,6 +21,9 @@ def build_artifact_policy(
}
),
session_compatibility_tokens=frozenset(compatibility_tokens),
+ publish_corrected_urdf=(
+ "urdf_zero_publication" in compatibility_tokens
+ ),
)
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py
new file mode 100644
index 0000000..d2de771
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/golden_regression.py
@@ -0,0 +1,144 @@
+"""Read-only regression checks for the reviewed hardware sessions."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from pathlib import Path
+from typing import Any
+
+_PASS_SESSIONS = {
+ "20260830_181154": "full",
+ "20260831_141123": "thumb",
+ "20260831_163843": "thumb",
+}
+_FAIL_SESSIONS = {
+ "20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"),
+ "20260831_142322": ("VAL-QUALITY-501", "zero_model_validation_failed"),
+}
+
+
+def _sha256_file(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()
+
+
+def _summary(session: Path) -> dict[str, Any]:
+ value = json.loads(
+ (session / "calibration_summary_zh.json").read_text(encoding="utf-8")
+ )
+ if not isinstance(value, dict):
+ raise ValueError(f"invalid golden summary: {session}")
+ return value
+
+
+def validate_golden_sessions(
+ serial_root: str | Path,
+ *,
+ replay_full_session: bool = True,
+) -> dict[str, Any]:
+ """Prove PASS/FAIL, hashes, partial freezes, and failure non-publication."""
+ root = Path(serial_root).expanduser().resolve()
+ results: dict[str, Any] = {}
+ for session_id, scope in _PASS_SESSIONS.items():
+ session = root / session_id
+ summary = _summary(session)
+ if summary.get("result") != "PASS":
+ raise ValueError(f"golden PASS changed: {session_id}")
+ if summary.get("calibration_scope") != scope:
+ raise ValueError(f"golden scope changed: {session_id}")
+ artifacts = summary["artifacts"]
+ hashes = summary["hashes"]
+ calibration_json = session / artifacts["json"]
+ corrected_urdf = session / artifacts["urdf"]
+ actual_json_hash = _sha256_file(calibration_json)
+ actual_urdf_hash = _sha256_file(corrected_urdf)
+ if actual_json_hash != hashes["calibration_json_sha256"]:
+ raise ValueError(f"golden JSON hash changed: {session_id}")
+ if actual_urdf_hash != hashes["corrected_urdf_sha256"]:
+ raise ValueError(f"golden URDF hash changed: {session_id}")
+ if session_id == "20260831_141123" and len(
+ summary.get("preserved_certified_zero_joints", ())
+ ) != 12:
+ raise ValueError("merged thumb no longer freezes all finger zeros")
+ if session_id == "20260831_163843":
+ payload = json.loads(calibration_json.read_text(encoding="utf-8"))
+ if payload.get("artifact_type") != (
+ "g20_right_standalone_thumb_calibration"
+ ):
+ raise ValueError("standalone thumb artifact type changed")
+ results[session_id] = {
+ "result": "PASS",
+ "json_sha256": actual_json_hash,
+ "urdf_sha256": actual_urdf_hash,
+ }
+
+ for session_id, (error_code, reason) in _FAIL_SESSIONS.items():
+ session = root / session_id
+ summary = _summary(session)
+ if (
+ summary.get("result") != "FAIL"
+ or summary.get("error_code") != error_code
+ or summary.get("reason") != reason
+ ):
+ raise ValueError(f"golden failure decision changed: {session_id}")
+ results[session_id] = {
+ "result": "FAIL",
+ "error_code": error_code,
+ "reason": reason,
+ }
+
+ failure_ids = set(_FAIL_SESSIONS)
+ for pointer_name in ("latest_passed", "latest_thumb_passed"):
+ pointer = root / pointer_name
+ if pointer.exists() and pointer.resolve().name in failure_ids:
+ raise ValueError(f"failure session was published through {pointer_name}")
+
+ if replay_full_session:
+ from .offline_replay import replay_session
+
+ session_id = "20260830_181154"
+ replay = replay_session(root / session_id, write_outputs=False)
+ expected = results[session_id]
+ if replay["computed_final_json_sha256"] != expected["json_sha256"]:
+ raise ValueError(
+ "full-session replay JSON is not byte-identical: "
+ f"computed={replay['computed_final_json_sha256']} "
+ f"expected={expected['json_sha256']}"
+ )
+ if replay["corrected_urdf_sha256"] != expected["urdf_sha256"]:
+ raise ValueError(
+ "full-session replay URDF is not byte-identical: "
+ f"computed={replay['corrected_urdf_sha256']} "
+ f"expected={expected['urdf_sha256']}"
+ )
+ results[session_id]["offline_replay"] = "byte_identical"
+ return results
+
+
+def main(args: list[str] | None = None) -> None:
+ parser = argparse.ArgumentParser(
+ description="Validate the five reviewed calibration sessions"
+ )
+ parser.add_argument("serial_root")
+ parser.add_argument("--no-replay", action="store_true")
+ selected = parser.parse_args(args)
+ print(
+ json.dumps(
+ validate_golden_sessions(
+ selected.serial_root,
+ replay_full_session=not selected.no_replay,
+ ),
+ ensure_ascii=False,
+ indent=2,
+ sort_keys=True,
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py
index 004cf51..9a5223e 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/legacy_11.py
@@ -1,7 +1,7 @@
"""One-release typed wrappers for the legacy 11-Tag layouts."""
from ...core import ProfileKey
-from ...full_hand import get_hand_calibration_profile
+from .profile import get_hand_calibration_profile
from ...urdf_zero import get_zero_calibration_profile
from ..registry import RegisteredProfile
from ._adapter import adapt_profile
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py
index 0be31a0..465bd0f 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/motion.py
@@ -1,6 +1,6 @@
"""Reviewed motion and safe-waypoint strategy exports."""
-from ...full_hand import (
+from .profile import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py
new file mode 100644
index 0000000..0bf3dda
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/node.py
@@ -0,0 +1,13060 @@
+"""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 ...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 .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 _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 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", 2.0)
+ self.declare_parameter("fixed_base_movement_confirmation_frames", 5)
+ 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", 5.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", 3)
+ self.declare_parameter("automatic_fit_retry_limit", 2)
+ self.declare_parameter("automatic_motion_retry_limit", 2)
+ 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", [0.8, 0.6, 0.5])
+ self.declare_parameter(
+ "retry_endpoint_hold_seconds", [0.75, 1.0, 1.25]
+ )
+ 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.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.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)
+ if len(message.position) != len(command_names):
+ return
+ if (
+ len(message.name) == len(command_names)
+ and set(message.name) == set(command_names)
+ ):
+ lookup = dict(zip(message.name, message.position))
+ state = tuple(float(lookup[name]) for name in command_names)
+ else:
+ state = tuple(float(value) for value in message.position)
+ 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]
+ 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"
+ )
+ 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)
+ 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)
+ self.resume_source_session = source.parent.name
+ 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
+ ),
+ "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.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",
+ "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:
+ 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())
+ restored_tasks = self._restore_durable_task_checkpoint()
+ except Exception as error:
+ self.started = 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 restored_tasks == 0
+ else f"calibration resumed with {restored_tasks} completed tasks"
+ )
+ 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.retry_speed_scales[min(retry, len(self.retry_speed_scales)) - 1]
+ 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)
+ scale = self.retry_speed_scales[
+ min(retry, len(self.retry_speed_scales)) - 1
+ ]
+ return float(self.sweep_timeout_seconds) / float(scale)
+
+ 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:
+ 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 = int(
+ getattr(self, "automatic_fit_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 = int(
+ getattr(self, "automatic_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 = getattr(self, "automatic_sweep_retry_limit", 0)
+ if retries >= retry_limit:
+ 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", (0.8, 0.6, 0.5)
+ )
+ endpoint_holds = getattr(
+ self, "retry_endpoint_hold_seconds", (0.75, 1.0, 1.25)
+ )
+ # 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
+
+ for joint_name, bins in joint_bins.items():
+ 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:
+ 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()
+ }
+ 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
+ },
+ )
+ 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 _retry_motion_or_pause(self, reason: str, now: float) -> None:
+ 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 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():
+ 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 now - self.motion_stage_started_at > self.position_timeout_seconds:
+ self._retry_motion_or_pause("return_baseline_timeout", now)
+ 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 now - self.motion_stage_started_at > self.position_timeout_seconds:
+ self._retry_motion_or_pause(
+ (
+ "sweep_start_tag_timeout"
+ if reached
+ else "sweep_start_position_timeout"
+ ),
+ now,
+ )
+ 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
+ if now - self.sweep_started_at > self._active_sweep_timeout_seconds():
+ self._retry_active_sweep_or_pause("sweep_timeout")
+ return
+ stale_views = [
+ view
+ for view in _sweep_views(
+ self.profile, self.active_sweep.spec
+ )
+ if now
+ - getattr(self, "sweep_last_valid_at_by_view", {}).get(
+ view, self.sweep_started_at
+ )
+ > self.invalid_timeout_seconds
+ ]
+ if stale_views:
+ self._retry_active_sweep_or_pause(
+ "synchronised_tag_state_timeout:"
+ + ",".join(stale_views)
+ )
+ return
+ 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()
+ and _frames_cover_sweep_motion(
+ self.sweep_frames,
+ self.active_sweep.spec,
+ motor_index=motor,
+ minimum_per_joint=self.minimum_sweep_frames,
+ minimum_span_u8=self.minimum_state_span_u8,
+ )
+ ):
+ 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
+ if now - self.validation_stage_started_at > self.validation_timeout_seconds:
+ self._retry_combination_validation_or_pause(
+ "combination_validation_move_timeout", now
+ )
+ 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
+ if now - self.validation_stage_started_at > self.validation_timeout_seconds:
+ self._retry_validation_or_pause("validation_move_timeout", now)
+ 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),
+ "source_session": self.resume_source_session,
+ "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/offline_replay.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py
new file mode 100644
index 0000000..7f7a9ef
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/offline_replay.py
@@ -0,0 +1,2032 @@
+"""Safely replay a complete three-camera session without moving the hand."""
+
+from __future__ import annotations
+
+import argparse
+from collections import defaultdict
+from dataclasses import replace
+import hashlib
+import json
+import math
+import os
+from pathlib import Path
+import re
+import tempfile
+from typing import Any, Mapping, Sequence
+import xml.etree.ElementTree as ET
+
+import numpy as np
+from scipy.spatial.transform import Rotation
+import yaml
+
+from ...compat.legacy import uses_coupled_full_hand_zero_solver
+from ...extrinsics import load_three_camera_extrinsics
+from .profile import (
+ G20_REFERENCE_THUMB_CMC_JOINTS,
+ G20_RIGHT_19_LAYOUT,
+ RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
+ HandCalibrationProfile,
+ JointCurveFit,
+ build_calibration_motion_command,
+ build_compact_payload,
+ canonical_zero_direction,
+ clamp_runtime_fits_to_urdf_limits,
+ cross_view_roll_diagnostic_metrics,
+ derive_mimic_passive_fits,
+ fit_joint_image_curve,
+ 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 .zero_solver import (
+ JointAxisMeasurement,
+ RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
+ UrdfKinematicModel,
+ _angles_from_state,
+ anchor_right_19_mechanical_endpoint_curves,
+ axis_line_uses_depth_free_interpretation_plane,
+ axis_line_cycle_rms_m,
+ 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,
+ get_zero_calibration_profile,
+ get_right_19_thumb_zero_profile,
+ derive_right_19_mechanical_endpoint_offsets,
+ joint_curve_holdout_errors,
+ refit_axis_line_group_with_shared_radius,
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
+ RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
+ merge_right_19_thumb_zero_result,
+ select_cross_view_roll_direction_source,
+ solve_urdf_zero_offsets,
+ with_depth_free_axis_projection,
+ write_zero_corrected_urdf,
+)
+
+
+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()
+
+
+def _payload_sha256(payload: Mapping[str, Any]) -> str:
+ encoded = (
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+def _legacy_published_quality(
+ session: Path, *, side: str, serial_number: str
+) -> dict[str, Any]:
+ """Recover v1 random-validation aggregates that raw JSONL omitted."""
+ path = session / f"g20_{side}_{serial_number}_calibration.json"
+ if not path.is_file():
+ raise ValueError(
+ "legacy replay requires its published JSON because v1 raw "
+ "samples omitted random-validation observations"
+ )
+ payload = json.loads(path.read_text(encoding="utf-8"))
+ quality = payload.get("quality")
+ if not isinstance(quality, Mapping) or set(quality) != {
+ "passed",
+ "validation_mae_rad",
+ "validation_p95_rad",
+ }:
+ raise ValueError("legacy published JSON has an invalid quality block")
+ if not bool(quality["passed"]):
+ raise ValueError("legacy published JSON did not pass validation")
+ values = (
+ float(quality["validation_mae_rad"]),
+ float(quality["validation_p95_rad"]),
+ )
+ if any(not math.isfinite(value) or value < 0.0 for value in values):
+ raise ValueError("legacy published quality contains an invalid error")
+ return dict(quality)
+
+
+def _output_suffix(output_tag: str | None) -> str:
+ """Return a filename-safe suffix for a non-destructive replay variant."""
+ if output_tag is None:
+ return ""
+ tag = str(output_tag)
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", tag):
+ raise ValueError(
+ "output tag must be 1-64 filename-safe characters, beginning "
+ "with a letter or digit"
+ )
+ return f"_{tag}"
+
+
+def _load_parameters(path: Path) -> dict[str, Any]:
+ payload = yaml.safe_load(path.read_text(encoding="utf-8"))
+ return dict(payload["g20_calibration"]["ros__parameters"])
+
+
+def _steady_records_in_feedback_domain(
+ records: Sequence[Mapping[str, Any]],
+) -> list[dict[str, Any]]:
+ """Mirror the live mechanical-backlash domain for offline replay."""
+ return fitting_sample_records(
+ records,
+ domain="feedback",
+ allow_legacy_command=True,
+ snap_requested_endpoints=True,
+ )
+
+
+def _latest_attempt_records(
+ rows: Sequence[Mapping[str, Any]],
+ *,
+ kind: str = "sample",
+) -> dict[str, list[dict[str, Any]]]:
+ """Reproduce the online retry buffer from append-only raw samples.
+
+ Online retry clears only the failed joint/cycle/direction from memory,
+ while JSONL deliberately retains every attempt for audit. Offline replay
+ must therefore select the greatest attempt independently for each logical
+ trajectory rather than mixing rejected attempts into the final fit.
+ """
+ samples = [dict(row) for row in rows if row.get("kind") == kind]
+ latest_attempt: dict[tuple[str, int, str], int] = {}
+ for row in samples:
+ key = (
+ str(row["joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ latest_attempt[key] = max(
+ latest_attempt.get(key, 0), int(row.get("attempt", 1))
+ )
+ records: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in samples:
+ key = (
+ str(row["joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ if int(row.get("attempt", 1)) == latest_attempt[key]:
+ records[key[0]].append(row)
+ return dict(records)
+
+
+def _latest_palm_axis_records(
+ rows: Sequence[Mapping[str, Any]],
+) -> dict[str, list[dict[str, Any]]]:
+ """Select the latest accepted append-only palm side-channel attempts."""
+ samples = [
+ dict(row) for row in rows if row.get("kind") == "palm_axis_sample"
+ ]
+ latest_attempt: dict[tuple[str, int, str], int] = {}
+ for row in samples:
+ key = (
+ str(row["source_joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ latest_attempt[key] = max(
+ latest_attempt.get(key, 0), int(row.get("attempt", 1))
+ )
+ records: dict[str, list[dict[str, Any]]] = defaultdict(list)
+ for row in samples:
+ key = (
+ str(row["source_joint"]),
+ int(row["cycle"]),
+ str(row["direction"]),
+ )
+ if int(row.get("attempt", 1)) == latest_attempt[key]:
+ records[key[0]].append(row)
+ return dict(records)
+
+
+def _load_raw_session(
+ session_dir: Path,
+) -> tuple[
+ dict[str, Any],
+ dict[str, list[dict[str, Any]]],
+ dict[str, list[dict[str, Any]]],
+ dict[str, list[dict[str, Any]]],
+ dict[str, list[dict[str, Any]]],
+ Path,
+]:
+ raw_path = session_dir / "raw_samples.jsonl"
+ if not raw_path.is_file():
+ raise ValueError(f"raw session does not exist: {raw_path}")
+ rows = [
+ json.loads(line)
+ for line in raw_path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ # Both online fitting and replay use the same canonical-to-fit projection.
+ # raw_samples.jsonl itself never contains the ambiguous compatibility key.
+ for row in rows:
+ if row.get("kind") in {
+ "sample",
+ "baseline_hold_sample",
+ "steady_command_sample",
+ "palm_axis_sample",
+ }:
+ projected = fitting_sample_record(
+ row, allow_legacy_command=True
+ )
+ row.clear()
+ row.update(projected)
+ starts = [row for row in rows if row.get("kind") == "session_start"]
+ if len(starts) != 1:
+ raise ValueError("raw session must contain exactly one session_start")
+ return (
+ starts[0],
+ _latest_attempt_records(rows),
+ _latest_attempt_records(rows, kind="baseline_hold_sample"),
+ _latest_attempt_records(rows, kind="steady_command_sample"),
+ _latest_palm_axis_records(rows),
+ raw_path,
+ )
+
+
+def _fit_curve(
+ name: str,
+ records: Sequence[Mapping[str, Any]],
+ *,
+ profile: HandCalibrationProfile,
+ baseline: Sequence[int],
+) -> JointCurveFit:
+ motor = profile.record_specs[name].motor_index
+ if (
+ profile.layout_id == G20_RIGHT_19_LAYOUT
+ and (
+ name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
+ or (
+ name.endswith("_mcp_roll")
+ and not name.startswith("thumb_")
+ )
+ )
+ ):
+ return fit_joint_image_curve(records)
+ return fit_rotation_joint_curve(
+ records,
+ zero_command_u8=int(baseline[motor]),
+ canonical_zero_direction=canonical_zero_direction(profile, name),
+ )
+
+
+def _physical_rotation_axis_for_curve(
+ name: str,
+ records: Sequence[Mapping[str, Any]],
+ fit: JointCurveFit,
+ *,
+ profile: HandCalibrationProfile,
+ baseline: Sequence[int],
+) -> Sequence[float]:
+ """Keep baseline pose hysteresis independent of the dynamic curve space."""
+ axis = fit.circle.get("axis_xyz")
+ if axis is not None:
+ return axis
+ motor = profile.record_specs[name].motor_index
+ diagnostic_fit = fit_rotation_joint_curve(
+ records,
+ zero_command_u8=int(baseline[motor]),
+ canonical_zero_direction=canonical_zero_direction(profile, name),
+ )
+ return diagnostic_fit.circle["axis_xyz"]
+
+
+def _fit_axes(
+ *,
+ records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
+ profile: HandCalibrationProfile,
+ baseline: Sequence[int],
+ extrinsics_file: Path,
+ source_urdf: Path,
+ repetitions: int,
+) -> list[JointAxisMeasurement]:
+ zero_profile = get_zero_calibration_profile(
+ profile.side, profile.layout_id
+ )
+ extrinsics = load_three_camera_extrinsics(extrinsics_file)
+ cache: dict[tuple[str, int], JointAxisMeasurement] = {}
+ cross_view_direction_source_cache: dict[str, str] = {}
+ upstream_by_joint = {
+ "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")
+ },
+ }
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ reference_roll = f"{profile.reference_finger}_mcp_roll"
+ upstream_by_joint.update(
+ {
+ f"{finger}_mcp_roll": reference_roll
+ for finger in ("index", "middle", "ring", "pinky")
+ if f"{finger}_mcp_roll" != reference_roll
+ }
+ )
+
+ def fit_raw(name: str, cycle: int) -> JointAxisMeasurement:
+ key = (name, cycle)
+ if key in cache:
+ return cache[key]
+ upstream = upstream_by_joint.get(name)
+ constraint = None if upstream is None else fit_raw(upstream, cycle).axis_common_xyz
+ spec = profile.record_specs[name]
+ view_normal = extrinsics.transform(spec.view)[:3, :3] @ np.asarray(
+ [0.0, 0.0, 1.0], dtype=float
+ )
+ result = fit_joint_axis_measurement(
+ name,
+ records_by_joint[name],
+ cycle=cycle,
+ zero_command_u8=int(baseline[spec.motor_index]),
+ axis_common_constraint=constraint,
+ constrained_circle_joints=(
+ zero_profile.constrained_circle_joints
+ | ({name} if name.endswith("_side") else set())
+ ),
+ view_normal_common_xyz=view_normal,
+ canonical_zero_direction=canonical_zero_direction(profile, name),
+ )
+ sweep_spec = next(
+ sweep for sweep in profile.sweep_specs if name in sweep.joints
+ )
+ condition = build_calibration_motion_command(
+ sweep_spec,
+ int(baseline[spec.motor_index]),
+ baseline=baseline,
+ profile=profile,
+ )
+ result = replace(
+ result,
+ condition_command_u8=(
+ None
+ if profile.layout_id == G20_RIGHT_19_LAYOUT
+ else tuple(float(value) for value in condition)
+ ),
+ view_normal_common_xyz=tuple(float(value) for value in view_normal),
+ )
+ result = with_depth_free_axis_projection(
+ result, extrinsics.transform(spec.view)[:3, 3]
+ )
+ cache[key] = result
+ return result
+
+ def fit_one(name: str, cycle: int) -> JointAxisMeasurement:
+ primary = fit_raw(name, cycle)
+ validation_name = (profile.axis_validation_sources or {}).get(name)
+ if validation_name is None:
+ return primary
+ secondary = fit_raw(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
+ 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)
+ )
+ )
+ if (
+ profile.record_specs[validation_name].zero_kind
+ == "axis_cross_view_validation"
+ ):
+ point_view = profile.record_specs[name].view
+ if point_view is None:
+ raise ValueError(
+ f"{name}: primary point source has no view"
+ )
+ point_camera_center = extrinsics.transform(point_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
+ ),
+ pose_axis_line_rms_m=primary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(name,),
+ axis_point_source=(
+ "front_interpretation_plane_cross_view_validated"
+ ),
+ 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
+ ),
+ )
+ # Mirror the live node's tiered policy: the side-view planar-tag
+ # IPPE bias makes sub-degree cross-view agreement unreachable, so
+ # disagreement above the fusion gate keeps the front-only axis while
+ # only a gross direction error (wrong link / loose tag) still fails.
+ # The front screw-driven roll trajectory yields a displaced
+ # pseudo-line, so its distance to the side physical line is retained
+ # only as a diagnostic. The live node likewise keeps only the gross
+ # direction gate here and validates side-line quality independently.
+ if difference > math.radians(15.0):
+ raise ValueError(f"{name}: cross-view axis gross disagreement")
+ if difference > math.radians(0.75) or line_distance > 0.001:
+ selected_direction = primary_axis
+ selected_source = primary.axis_direction_source
+ observer_name = next(
+ (
+ observer
+ for observer, parent in zero_profile.axis_parent_joint.items()
+ if parent == name
+ ),
+ None,
+ )
+ if observer_name is not None:
+ model = UrdfKinematicModel(source_urdf)
+ parent_axis, _ = model.axis_line(
+ 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: np.ndarray, measured_observer: np.ndarray
+ ) -> float:
+ measured_cone = math.acos(
+ abs(
+ float(
+ np.clip(
+ candidate @ measured_observer, -1.0, 1.0
+ )
+ )
+ )
+ )
+ return abs(measured_cone - expected_cone)
+
+ if name not in cross_view_direction_source_cache:
+ primary_residuals: list[float] = []
+ secondary_residuals: list[float] = []
+ for group_cycle in range(repetitions):
+ group_observer = np.asarray(
+ fit_raw(
+ observer_name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ )
+ primary_residuals.append(
+ cone_residual(
+ np.asarray(
+ fit_raw(
+ name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ ),
+ group_observer,
+ )
+ )
+ secondary_residuals.append(
+ cone_residual(
+ np.asarray(
+ fit_raw(
+ validation_name, group_cycle
+ ).axis_common_xyz,
+ dtype=float,
+ ),
+ group_observer,
+ )
+ )
+ cross_view_direction_source_cache[name] = (
+ select_cross_view_roll_direction_source(
+ primary_residuals,
+ secondary_residuals,
+ math.radians(5.0),
+ )
+ )
+ if cross_view_direction_source_cache[name] == "secondary":
+ selected_direction = secondary_axis
+ selected_source = "cross_view_cone_selected_secondary"
+ # Mirror the live node: use the view whose direction satisfies
+ # the zero-invariant parent/child cone, and take the physical line
+ # position from the side PIP-link circle.
+ return replace(
+ primary,
+ axis_common_xyz=tuple(
+ float(value) for value in selected_direction
+ ),
+ point_common_xyz_m=tuple(
+ float(value)
+ for value in secondary.point_common_xyz_m
+ ),
+ pose_axis_line_rms_m=secondary.pose_axis_line_rms_m,
+ pose_axis_line_source_joints=(validation_name,),
+ axis_direction_source=selected_source,
+ axis_point_source="side_circle_cross_view",
+ )
+ 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
+ fused_axis = (
+ primary_weight * primary_axis + secondary_weight * secondary_axis
+ )
+ fused_axis /= np.linalg.norm(fused_axis)
+ delta = secondary_point - primary_point
+ perpendicular = delta - fused_axis * float(delta @ fused_axis)
+ fused_point = primary_point + (
+ secondary_weight / (primary_weight + secondary_weight)
+ ) * perpendicular
+ 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),
+ 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=(name, validation_name),
+ axis_direction_source="cross_view_weighted_fusion",
+ )
+
+ result: list[JointAxisMeasurement] = []
+ for name in zero_profile.axis_joints:
+ group = [fit_one(name, cycle) for cycle in range(repetitions)]
+ side_sources = {
+ source
+ for measurement in group
+ if (source := cross_view_side_line_source(measurement)) is not None
+ }
+ if (
+ len(side_sources) == 1
+ and all(
+ cross_view_side_line_source(measurement) is not None
+ for measurement in group
+ )
+ and not any(
+ axis_line_uses_depth_free_interpretation_plane(measurement)
+ for measurement in group
+ )
+ ):
+ source = next(iter(side_sources))
+ source_spec = profile.record_specs[source]
+ group = list(
+ refit_axis_line_group_with_shared_radius(
+ group,
+ records_by_joint[source],
+ zero_command_u8=int(
+ baseline[source_spec.motor_index]
+ ),
+ canonical_zero_direction=canonical_zero_direction(
+ profile, source
+ ),
+ )
+ )
+ result.extend(group)
+ return result
+
+
+def _maximum_undirected_axis_difference(axes: Sequence[Sequence[float]]) -> float:
+ maximum = 0.0
+ for left in axes:
+ for right in axes:
+ maximum = max(
+ maximum,
+ math.acos(
+ abs(
+ float(
+ np.clip(
+ np.asarray(left, dtype=float)
+ @ np.asarray(right, dtype=float),
+ -1.0,
+ 1.0,
+ )
+ )
+ )
+ ),
+ )
+ return maximum
+
+
+def _quality_failures(
+ *,
+ records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
+ baseline_records_by_joint: Mapping[
+ str, Sequence[Mapping[str, Any]]
+ ] | None = None,
+ profile: HandCalibrationProfile,
+ baseline: Sequence[int],
+ fits: Mapping[str, JointCurveFit],
+ axes: Sequence[JointAxisMeasurement],
+ parameters: Mapping[str, Any],
+) -> list[str]:
+ failures: list[str] = []
+ repetitions = int(parameters["repetitions"])
+ zero_profile = get_zero_calibration_profile(
+ profile.side, profile.layout_id
+ )
+ axis_by_key = {(item.joint, item.cycle): item for item in axes}
+ expected_directions = {"decreasing", "increasing"}
+ for name in profile.measured_joints:
+ records = list(records_by_joint.get(name, ()))
+ if not records:
+ failures.append(f"{name}: no samples")
+ continue
+ attempts_by_direction: dict[tuple[int, str], set[int]] = defaultdict(set)
+ for record in records:
+ attempts_by_direction[
+ (int(record["cycle"]), str(record["direction"]))
+ ].add(int(record.get("attempt", 1)))
+ for cycle in range(repetitions):
+ for direction in expected_directions:
+ selected = [
+ record
+ for record in records
+ if int(record["cycle"]) == cycle
+ and str(record["direction"]) == direction
+ ]
+ attempts = attempts_by_direction.get((cycle, direction), set())
+ if len(attempts) != 1:
+ failures.append(
+ f"{name} cycle {cycle + 1} {direction}: ambiguous attempts {sorted(attempts)}"
+ )
+ continue
+ commands = sorted({int(record["command_u8"]) for record in selected})
+ if len(selected) < int(parameters["minimum_sweep_frames"]):
+ failures.append(f"{name} cycle {cycle + 1} {direction}: too few frames")
+ if not commands or max(commands) - min(commands) < float(
+ parameters["minimum_state_span_u8"]
+ ):
+ failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient span")
+ if len(commands) < int(parameters["minimum_sweep_bins"]):
+ failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient bins")
+ if 0 not in commands or 255 not in commands:
+ failures.append(f"{name} cycle {cycle + 1} {direction}: endpoint missing")
+ if commands and max(np.diff(commands), default=0) > int(
+ parameters["maximum_bin_gap"]
+ ):
+ failures.append(f"{name} cycle {cycle + 1} {direction}: bin gap")
+
+ sync_p95 = float(
+ np.percentile(
+ [float(record.get("state_image_sync_error_ms", 0.0)) for record in records],
+ 95.0,
+ )
+ )
+ if sync_p95 > float(parameters["maximum_state_image_skew_ms"]):
+ failures.append(f"{name}: state/image sync p95 {sync_p95:.3f}ms")
+ spec = profile.joint_specs[name]
+ fit = fits[name]
+ if fit.circle.get("space") == "image_2d":
+ if float(fit.quality["radial_rms_px"]) > float(
+ parameters["image_trajectory_maximum_radial_rms_px"]
+ ):
+ failures.append(f"{name}: image radial RMS")
+ if float(fit.quality["radial_p95_px"]) > float(
+ parameters["image_trajectory_maximum_radial_p95_px"]
+ ):
+ failures.append(f"{name}: image radial P95")
+ if float(fit.quality["radius_px"]) < float(
+ parameters["image_trajectory_minimum_radius_px"]
+ ):
+ failures.append(f"{name}: image radius")
+ else:
+ orthogonal_limit = math.radians(
+ float(
+ parameters[
+ "active_maximum_rotation_orthogonal_rms_deg"
+ if spec.active
+ else "passive_maximum_rotation_orthogonal_rms_deg"
+ ]
+ )
+ )
+ if (
+ float(fit.quality["rotation_orthogonal_rms_rad"])
+ > orthogonal_limit
+ ):
+ failures.append(f"{name}: rotation orthogonal RMS")
+ if float(fit.quality["arc_rad"]) < math.radians(
+ float(parameters["trajectory_minimum_arc_deg"])
+ ):
+ failures.append(f"{name}: trajectory arc")
+ monotonic_limit = math.radians(
+ float(
+ parameters[
+ "maximum_monotonic_correction_deg"
+ if spec.active
+ else "passive_maximum_monotonic_correction_deg"
+ ]
+ )
+ )
+ hysteresis_limit = math.radians(
+ float(
+ parameters[
+ "maximum_hysteresis_deg"
+ if spec.active
+ else "passive_maximum_hysteresis_deg"
+ ]
+ )
+ )
+ if fit.maximum_monotonic_correction_rad > monotonic_limit:
+ failures.append(f"{name}: monotonic correction")
+ if (
+ profile.layout_id != G20_RIGHT_19_LAYOUT
+ and fit.maximum_hysteresis_rad > hysteresis_limit
+ ):
+ failures.append(f"{name}: hysteresis")
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ hysteresis_records = records
+ dedicated = (baseline_records_by_joint or {}).get(name, ())
+ if int(baseline[spec.motor_index]) not in {0, 255} and dedicated:
+ hysteresis_records = list(dedicated)
+ baseline_hysteresis = baseline_hysteresis_by_cycle_rad(
+ hysteresis_records,
+ zero_command_u8=int(baseline[spec.motor_index]),
+ axis_xyz=_physical_rotation_axis_for_curve(
+ name,
+ records,
+ fit,
+ profile=profile,
+ baseline=baseline,
+ ),
+ )
+ if canonical_zero_direction(profile, name) is not None:
+ gap_limit = math.radians(
+ float(
+ parameters.get(
+ "directional_zero_maximum_branch_gap_deg", 1.5
+ )
+ )
+ )
+ gap_range_limit = math.radians(
+ float(
+ parameters.get(
+ "directional_zero_maximum_branch_gap_range_deg",
+ 0.3,
+ )
+ )
+ )
+ if max(baseline_hysteresis) > gap_limit:
+ failures.append(f"{name}: baseline directional gap")
+ if (
+ max(baseline_hysteresis) - min(baseline_hysteresis)
+ > gap_range_limit
+ and spec.zero_kind != "axis_cross_view_validation"
+ ):
+ failures.append(
+ f"{name}: baseline directional gap repeatability"
+ )
+ elif max(baseline_hysteresis) > math.radians(
+ float(parameters.get("baseline_maximum_hysteresis_deg", 0.5))
+ ):
+ failures.append(f"{name}: baseline hysteresis")
+
+ cycle_travels: list[float] = []
+ cycle_axes: list[Sequence[float]] = []
+ cycle_axis_sources: list[str] = []
+ cycle_axis_measurements: list[JointAxisMeasurement] = []
+ for cycle in range(repetitions):
+ cycle_fit = _fit_curve(
+ name,
+ [record for record in records if int(record["cycle"]) == cycle],
+ profile=profile,
+ baseline=baseline,
+ )
+ cycle_travels.append(
+ abs(float(cycle_fit.angle_rad[0]) - float(cycle_fit.angle_rad[255]))
+ )
+ axis = axis_by_key[(name, cycle)]
+ cycle_axes.append(axis.axis_common_xyz)
+ cycle_axis_sources.append(axis.axis_direction_source)
+ cycle_axis_measurements.append(axis)
+ if axis.radial_rms_m > float(parameters["axis_maximum_radial_rms_m"]):
+ failures.append(f"{name} cycle {cycle + 1}: radial RMS")
+ if (
+ profile.record_specs[name].zero_kind
+ != "axis_cross_view_validation"
+ and cross_view_side_line_source(axis) is None
+ and axis.pose_axis_line_rms_m
+ > float(parameters["axis_maximum_pose_line_rms_m"])
+ ):
+ failures.append(
+ f"{name} cycle {cycle + 1}: pose axis-line RMS"
+ )
+ if not circle_direction_is_constrained(
+ name, zero_profile.constrained_circle_joints
+ ):
+ plane_limit = float(
+ parameters[
+ "axis_maximum_plane_rms_m"
+ if spec.active
+ else "passive_axis_maximum_plane_rms_m"
+ ]
+ )
+ if axis.plane_rms_m > plane_limit:
+ failures.append(f"{name} cycle {cycle + 1}: plane RMS")
+ if (
+ axis.rotation_circle_axis_difference_rad > math.radians(
+ float(parameters["axis_maximum_rotation_circle_difference_deg"])
+ )
+ ):
+ failures.append(f"{name} cycle {cycle + 1}: axis disagreement")
+ 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) == 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
+ )
+ and axis_line_cycle_rms_m(cycle_axis_measurements)
+ > float(parameters["axis_maximum_pose_line_rms_m"])
+ ):
+ source = next(iter(side_line_sources))
+ failures.append(f"{source}: axis-line cycle RMS")
+ travel_limit = math.radians(
+ float(
+ parameters[
+ "trajectory_maximum_cycle_travel_difference_deg"
+ if spec.active
+ else "passive_maximum_cycle_travel_difference_deg"
+ ]
+ )
+ )
+ if max(cycle_travels) - min(cycle_travels) > travel_limit:
+ failures.append(f"{name}: cycle travel difference")
+ if (
+ not all(
+ source == "upstream_constraint"
+ for source in cycle_axis_sources
+ )
+ and spec.zero_kind != "axis_cross_view_validation"
+ and _maximum_undirected_axis_difference(cycle_axes) > math.radians(
+ float(parameters["zero_maximum_axis_cycle_difference_deg"])
+ )
+ ):
+ failures.append(f"{name}: cycle axis difference")
+ return failures
+
+
+def _joint_xml(path: Path) -> dict[str, ET.Element]:
+ return {
+ str(joint.get("name")): joint
+ for joint in ET.parse(path).getroot().findall("joint")
+ }
+
+
+def _triplet(value: str) -> np.ndarray:
+ return np.asarray([float(item) for item in value.split()], dtype=float)
+
+
+def _validate_corrected_urdf(
+ *,
+ source: Path,
+ corrected: Path,
+ offsets: Mapping[str, float],
+ endpoint_anchored_offsets: Mapping[str, float],
+ axes: Sequence[JointAxisMeasurement],
+ curves: Mapping[str, JointCurveFit],
+ motor_by_joint: Mapping[str, int],
+ inherited_zero_joints: Mapping[str, str],
+) -> dict[str, float]:
+ source_joints = _joint_xml(source)
+ corrected_joints = _joint_xml(corrected)
+ if set(source_joints) != set(corrected_joints):
+ raise ValueError("corrected URDF changed the joint set")
+ maximum_origin_rotation_error = 0.0
+ maximum_origin_translation_error = 0.0
+ for name, original_joint in source_joints.items():
+ corrected_joint = corrected_joints[name]
+ original_origin = original_joint.find("origin")
+ corrected_origin = corrected_joint.find("origin")
+ if original_origin is None or corrected_origin is None:
+ continue
+ original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
+ corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
+ maximum_origin_translation_error = max(
+ maximum_origin_translation_error,
+ float(np.linalg.norm(corrected_xyz - original_xyz)),
+ )
+ original_rotation = Rotation.from_euler(
+ "xyz", _triplet(original_origin.get("rpy", "0 0 0"))
+ )
+ corrected_rotation = Rotation.from_euler(
+ "xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
+ )
+ expected = original_rotation
+ if name in offsets:
+ axis_node = original_joint.find("axis")
+ axis = _triplet(
+ "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")
+ )
+ axis /= np.linalg.norm(axis)
+ expected = original_rotation * Rotation.from_rotvec(
+ axis * float(offsets[name])
+ )
+ error = float((expected.inv() * corrected_rotation).magnitude())
+ maximum_origin_rotation_error = max(maximum_origin_rotation_error, error)
+ original_limit = original_joint.find("limit")
+ corrected_limit = corrected_joint.find("limit")
+ if original_limit is not None and corrected_limit is not None:
+ expected_lower = float(original_limit.get("lower"))
+ expected_upper = float(original_limit.get("upper"))
+ if name in endpoint_anchored_offsets:
+ expected_upper -= float(endpoint_anchored_offsets[name])
+ if (
+ abs(float(corrected_limit.get("lower")) - expected_lower) > 1.0e-12
+ or abs(float(corrected_limit.get("upper")) - expected_upper) > 1.0e-12
+ ):
+ raise ValueError(f"corrected URDF unexpectedly changed {name} limits")
+ original_mimic = original_joint.find("mimic")
+ corrected_mimic = corrected_joint.find("mimic")
+ if original_mimic is not None and corrected_mimic is not None:
+ source_joint = str(original_mimic.get("joint"))
+ multiplier = float(original_mimic.get("multiplier", "1"))
+ expected_mimic_offset = float(original_mimic.get("offset", "0"))
+ expected_mimic_offset += multiplier * float(
+ endpoint_anchored_offsets.get(source_joint, 0.0)
+ )
+ if abs(
+ float(corrected_mimic.get("offset", "0"))
+ - expected_mimic_offset
+ ) > 1.0e-12:
+ raise ValueError(f"corrected URDF has invalid {name} mimic offset")
+ if maximum_origin_translation_error > 1.0e-12:
+ raise ValueError("corrected URDF changed a joint origin translation")
+ if maximum_origin_rotation_error > 1.0e-10:
+ raise ValueError("corrected URDF does not implement T_original * Rot(axis, offset)")
+ original_model = UrdfKinematicModel(source)
+ corrected_model = UrdfKinematicModel(corrected)
+ maximum_axis_error = 0.0
+ maximum_point_error = 0.0
+ for measurement in axes:
+ state = (
+ measurement.condition_state_u8
+ if measurement.condition_command_u8 is None
+ else measurement.condition_command_u8
+ )
+ angles = _angles_from_state(
+ state,
+ curves=curves,
+ motor_by_joint=motor_by_joint,
+ inherited_zero_joints=inherited_zero_joints,
+ )
+ expected_axis, expected_point = original_model.axis_line(
+ measurement.joint,
+ zero_offsets=offsets,
+ joint_angles=angles,
+ )
+ actual_axis, actual_point = corrected_model.axis_line(
+ measurement.joint,
+ zero_offsets={},
+ joint_angles=angles,
+ )
+ maximum_axis_error = max(
+ maximum_axis_error,
+ math.acos(float(np.clip(expected_axis @ actual_axis, -1.0, 1.0))),
+ )
+ maximum_point_error = max(
+ maximum_point_error,
+ float(np.linalg.norm(expected_point - actual_point)),
+ )
+ if maximum_axis_error > 1.0e-7 or maximum_point_error > 1.0e-10:
+ raise ValueError("written URDF kinematics differ from the solved correction")
+ return {
+ "maximum_origin_rotation_error_rad": maximum_origin_rotation_error,
+ "maximum_origin_translation_error_m": maximum_origin_translation_error,
+ "maximum_axis_equivalence_error_rad": maximum_axis_error,
+ "maximum_axis_point_equivalence_error_m": maximum_point_error,
+ }
+
+
+def replay_session(
+ session_dir: str | Path,
+ *,
+ serial_number: str | None = None,
+ config_file: str | Path | None = None,
+ write_outputs: bool = False,
+ output_tag: str | None = None,
+) -> dict[str, Any]:
+ session = Path(session_dir).expanduser().resolve()
+ package_root = Path(__file__).resolve().parents[3]
+ config = (
+ package_root / "config" / "three_camera_calibration.yaml"
+ if config_file is None
+ else Path(config_file).expanduser().resolve()
+ )
+ parameters = _load_parameters(config)
+ (
+ start,
+ records_by_joint,
+ baseline_records_by_joint,
+ command_records_by_joint,
+ palm_axis_records_by_source,
+ raw_path,
+ ) = _load_raw_session(session)
+ legacy_coupled_zero_solver = uses_coupled_full_hand_zero_solver(start)
+ model = str(start.get("model", "G20")).strip().upper()
+ side = str(start["hand_type"]).lower()
+ requested_layout_id = str(
+ start.get("tag_layout", "legacy_11")
+ ).lower()
+ contract = get_product_calibration_contract(
+ model, side, requested_layout_id
+ )
+ profile = contract.profile
+ # Normalise the former g20_right_15 compatibility alias immediately so
+ # every downstream product guard uses the actual physical 19-Tag profile
+ # instead of silently falling through legacy-11 logic.
+ layout_id = profile.layout_id
+ zero_profile = contract.zero_profile
+ baseline = tuple(int(value) for value in start["baseline_command_u8"])
+ if len(baseline) != profile.command_count:
+ raise ValueError(
+ "session baseline length differs from the registered product schema"
+ )
+ if (
+ layout_id == G20_RIGHT_19_LAYOUT
+ and write_outputs
+ and bool(parameters.get("combination_validation_enabled", False))
+ ):
+ replay_rows = [
+ json.loads(line)
+ for line in raw_path.read_text(encoding="utf-8").splitlines()
+ if line.strip()
+ ]
+ combinations = [
+ row
+ for row in replay_rows
+ if row.get("kind") == "combination_validation_sample"
+ and bool(row.get("passed"))
+ ]
+ expected_poses = {
+ "all_open", "thumb_middle", "index_middle", "middle_middle",
+ "ring_middle", "pinky_middle", "half_grip", "light_pinch",
+ }
+ if {str(row.get("pose_name")) for row in combinations} != expected_poses:
+ raise ValueError(
+ "incomplete session cannot publish: eight combination poses are required"
+ )
+ if any(
+ float(row.get("position_p95_m", float("inf"))) > 0.003
+ or float(row.get("orientation_p95_rad", float("inf")))
+ > math.radians(2.0)
+ for row in combinations
+ ):
+ raise ValueError("combination pose validation exceeds product limits")
+ required_record_joints = set(profile.record_joints)
+ if (
+ not required_record_joints.issubset(records_by_joint)
+ or not set(records_by_joint).issubset(set(profile.record_joints))
+ ):
+ raise ValueError(
+ "raw session does not contain the required task record set"
+ )
+
+ def hysteresis_records(
+ name: str,
+ ) -> Sequence[Mapping[str, Any]]:
+ spec = profile.record_specs[name]
+ zero = int(baseline[spec.motor_index])
+ if (
+ layout_id == G20_RIGHT_19_LAYOUT
+ and zero not in {0, 255}
+ and start.get("baseline_hysteresis_source")
+ == "dedicated_mid_sweep_hold"
+ ):
+ records = baseline_records_by_joint.get(name, [])
+ if not records:
+ raise ValueError(
+ f"{name} is missing dedicated baseline hold samples"
+ )
+ return records
+ return records_by_joint[name]
+
+ def model_records(
+ name: str, *, require_full_pose: bool = False
+ ) -> list[dict[str, Any]]:
+ """Merge settled baseline holds into directional model records."""
+ records = [
+ dict(record) for record in records_by_joint.get(name, ())
+ ]
+ if canonical_zero_direction(profile, name) is None:
+ return records
+ required = {
+ "relative_translation_xyz_m",
+ "parent_pose_common",
+ }
+ records.extend(
+ dict(record)
+ for record in baseline_records_by_joint.get(name, ())
+ if not require_full_pose or required.issubset(record)
+ )
+ return records
+
+ curve_records_by_joint = {
+ name: model_records(name) for name in profile.record_joints
+ }
+ axis_records_by_joint = {
+ name: model_records(name, require_full_pose=True)
+ for name in profile.record_joints
+ }
+ source_urdf = Path(start["source_urdf_path"]).expanduser().resolve()
+ extrinsics_file = Path(start["camera_extrinsics_file"]).expanduser().resolve()
+ if not source_urdf.is_file() or not extrinsics_file.is_file():
+ raise ValueError("session source URDF or camera extrinsics is missing")
+ if "zero_calibrated" in source_urdf.stem.lower():
+ raise ValueError("offline replay requires the original CAD URDF")
+ hand_serial = str(serial_number or session.parent.name)
+ output_suffix = _output_suffix(output_tag)
+ repetitions = int(
+ parameters.get("g20_right_19_repetitions", 4)
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else parameters["repetitions"]
+ )
+
+ measured_fits = {
+ name: _fit_curve(
+ name,
+ curve_records_by_joint[name],
+ profile=profile,
+ baseline=baseline,
+ )
+ for name in profile.measured_joints
+ }
+ validation_cycle = repetitions - 1
+ training_cycles = tuple(range(validation_cycle))
+ training_cycle_set = set(training_cycles)
+ if layout_id == G20_RIGHT_19_LAYOUT and len(training_cycles) < 3:
+ raise ValueError(
+ "g20_right_19 replay requires at least 3 training cycles and "
+ "one isolated holdout"
+ )
+ training_fits = {
+ name: _fit_curve(
+ name,
+ [
+ record
+ for record in curve_records_by_joint[name]
+ if int(record["cycle"]) in training_cycle_set
+ ],
+ profile=profile,
+ baseline=baseline,
+ )
+ for name in profile.measured_joints
+ }
+ command_fits = dict(training_fits)
+ if layout_id == G20_RIGHT_19_LAYOUT:
+ if set(command_records_by_joint) != required_record_joints:
+ raise ValueError(
+ "g20_right_19 session is incomplete: missing steady command checkpoints"
+ )
+ command_fits = {}
+ for name in profile.measured_joints:
+ if name in G20_REFERENCE_THUMB_CMC_JOINTS:
+ command_fits[name] = measured_fits[name]
+ continue
+ records = [
+ dict(record)
+ for record in command_records_by_joint[name]
+ if int(record["cycle"]) == 0
+ ]
+ if canonical_zero_direction(profile, name) is not None:
+ records.extend(
+ dict(record)
+ for record in baseline_records_by_joint.get(name, ())
+ if int(record["cycle"]) == 0
+ )
+ if len(records) < 18:
+ raise ValueError(
+ f"{name} has {len(records)}/18 steady command checkpoints"
+ )
+ zero_command = int(
+ baseline[profile.joint_specs[name].motor_index]
+ )
+ if name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS:
+ command_fits[name] = fit_joint_image_curve(
+ records,
+ maximum_radial_rms_px=float(
+ parameters["image_trajectory_maximum_radial_rms_px"]
+ ),
+ maximum_radial_p95_px=float(
+ parameters["image_trajectory_maximum_radial_p95_px"]
+ ),
+ minimum_radius_px=float(
+ parameters["image_trajectory_minimum_radius_px"]
+ ),
+ minimum_arc_rad=math.radians(
+ float(parameters["trajectory_minimum_arc_deg"])
+ ),
+ )
+ else:
+ command_fits[name] = fit_rotation_joint_curve(
+ records,
+ zero_command_u8=zero_command,
+ canonical_zero_direction=canonical_zero_direction(
+ profile, name
+ ),
+ )
+ command_gap_limit = math.radians(
+ float(parameters.get("command_maximum_direction_gap_deg", 2.0))
+ )
+ command_feedback_fits = {
+ name: _fit_curve(
+ name,
+ _steady_records_in_feedback_domain(
+ [
+ dict(record)
+ for record in command_records_by_joint[name]
+ if int(record["cycle"]) == 0
+ ]
+ ),
+ profile=profile,
+ baseline=baseline,
+ )
+ for name in profile.measured_joints
+ if name not in G20_REFERENCE_THUMB_CMC_JOINTS
+ }
+ failed_command_gaps = {
+ name: fit.maximum_hysteresis_rad
+ for name, fit in command_feedback_fits.items()
+ if fit.maximum_hysteresis_rad > command_gap_limit
+ }
+ if failed_command_gaps:
+ raise ValueError(
+ "command-direction gap exceeds 2deg: "
+ + ", ".join(
+ f"{name}={math.degrees(value):.3f}deg"
+ for name, value in failed_command_gaps.items()
+ )
+ )
+ command_fits = derive_mimic_passive_fits(
+ source_urdf,
+ command_fits,
+ profile=profile,
+ )
+ command_fits = anchor_right_19_mechanical_endpoint_curves(
+ command_fits,
+ {
+ name: (
+ [
+ record
+ for record in curve_records_by_joint[name]
+ if int(record["cycle"]) in training_cycle_set
+ ]
+ if name == "thumb_cmc_roll"
+ else command_records_by_joint[name]
+ )
+ for name in RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
+ },
+ maximum_direction_difference_rad=command_gap_limit,
+ feedback_endpoint_joints=frozenset({"thumb_cmc_roll"}),
+ )
+ cross_view_roll_metrics: dict[str, dict[str, float]] = {}
+ for name, validation_name in (
+ profile.axis_validation_sources or {}
+ ).items():
+ validation_training = _fit_curve(
+ validation_name,
+ [
+ record
+ for record in curve_records_by_joint[validation_name]
+ if int(record["cycle"]) in training_cycle_set
+ ],
+ profile=profile,
+ baseline=baseline,
+ )
+ validation_all = _fit_curve(
+ validation_name,
+ curve_records_by_joint[validation_name],
+ profile=profile,
+ baseline=baseline,
+ )
+ training_metrics = cross_view_roll_diagnostic_metrics(
+ training_fits[name], validation_training
+ )
+ final_metrics = cross_view_roll_diagnostic_metrics(
+ measured_fits[name], validation_all
+ )
+ cross_view_roll_metrics[name] = {
+ **{
+ f"training_{key}": value
+ for key, value in training_metrics.items()
+ },
+ **{
+ f"final_{key}": value
+ for key, value in final_metrics.items()
+ },
+ }
+ axes = _fit_axes(
+ records_by_joint=axis_records_by_joint,
+ profile=profile,
+ baseline=baseline,
+ extrinsics_file=extrinsics_file,
+ source_urdf=source_urdf,
+ repetitions=repetitions,
+ )
+ palm_records_for_fit = palm_axis_records_by_source
+ if legacy_coupled_zero_solver:
+ palm_records_for_fit = {
+ name: [
+ {
+ key: value
+ for key, value in record.items()
+ if key != "child_pose_common"
+ }
+ for record in records
+ ]
+ for name, records in palm_axis_records_by_source.items()
+ }
+ palm_orientation_measurements, palm_orientation_rejections = (
+ fit_partial_palm_orientation_measurements(
+ sources=profile.palm_orientation_sources,
+ records_by_joint=palm_records_for_fit,
+ motor_by_source=profile.palm_axis_motor_by_source,
+ baseline_command_u8=baseline,
+ cycles=range(repetitions),
+ minimum_sources=int(
+ profile.minimum_palm_orientation_sources
+ ),
+ minimum_arc_rad=(
+ profile.palm_orientation_minimum_arc_rad
+ ),
+ maximum_rotation_orthogonal_rms_rad=math.radians(
+ float(
+ parameters[
+ "active_maximum_rotation_orthogonal_rms_deg"
+ ]
+ )
+ ),
+ maximum_command_distance_u8=(
+ profile.palm_orientation_maximum_command_distance_u8
+ ),
+ )
+ )
+ failures = _quality_failures(
+ records_by_joint=records_by_joint,
+ baseline_records_by_joint=(
+ baseline_records_by_joint
+ if start.get("baseline_hysteresis_source")
+ == "dedicated_mid_sweep_hold"
+ else None
+ ),
+ profile=profile,
+ baseline=baseline,
+ fits=measured_fits,
+ axes=axes,
+ parameters=parameters,
+ )
+ if failures:
+ raise ValueError("offline trajectory/axis validation failed: " + "; ".join(failures))
+
+ holdout_by_joint = {
+ name: joint_curve_holdout_errors(
+ training_fits[name],
+ [
+ record
+ for record in curve_records_by_joint[name]
+ if int(record["cycle"]) == validation_cycle
+ ],
+ zero_command_u8=int(
+ baseline[profile.joint_specs[name].motor_index]
+ ),
+ )
+ for name in profile.measured_joints
+ }
+ joint_dynamic_diagnostics: dict[str, dict[str, Any]] = {}
+ for name in profile.measured_joints:
+ cycle_travel: list[float] = []
+ for cycle in range(repetitions):
+ cycle_fit = _fit_curve(
+ name,
+ [
+ record
+ for record in records_by_joint[name]
+ if int(record["cycle"]) == cycle
+ ],
+ profile=profile,
+ baseline=baseline,
+ )
+ 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(
+ hysteresis_records(name),
+ zero_command_u8=int(
+ baseline[profile.joint_specs[name].motor_index]
+ ),
+ axis_xyz=_physical_rotation_axis_for_curve(
+ name,
+ records_by_joint[name],
+ measured_fits[name],
+ profile=profile,
+ baseline=baseline,
+ ),
+ )
+ joint_dynamic_diagnostics[name] = {
+ "cycle_travel_rad": [float(value) for value in cycle_travel],
+ "cycle_travel_range_rad": max(cycle_travel) - min(cycle_travel),
+ "baseline_hysteresis_by_cycle_rad": [
+ float(value) for value in baseline_hysteresis
+ ],
+ "holdout_cycle": validation_cycle,
+ "holdout_cycle_mae_rad": float(np.mean(holdout)),
+ "holdout_cycle_max_rad": float(np.max(holdout)),
+ }
+ trajectory_errors = np.abs(
+ np.asarray(
+ [value for values in holdout_by_joint.values() for value in values],
+ dtype=float,
+ )
+ )
+ maximum_validation_mae = math.radians(
+ float(parameters["maximum_validation_mae_deg"])
+ )
+ maximum_validation_p95 = math.radians(
+ float(parameters["maximum_validation_p95_deg"])
+ )
+ trajectory_mae = float(np.mean(trajectory_errors))
+ trajectory_p95 = float(np.percentile(trajectory_errors, 95.0))
+ if (
+ trajectory_mae > maximum_validation_mae
+ or trajectory_p95 > maximum_validation_p95
+ or (
+ layout_id == G20_RIGHT_19_LAYOUT
+ and float(np.max(trajectory_errors))
+ > math.radians(
+ float(parameters.get("maximum_validation_error_deg", 1.5))
+ )
+ )
+ or (
+ layout_id == G20_RIGHT_19_LAYOUT
+ and any(
+ float(np.mean(np.abs(np.asarray(values, dtype=float))))
+ > maximum_validation_mae
+ or float(np.max(np.abs(np.asarray(values, dtype=float))))
+ > math.radians(
+ float(
+ parameters.get("maximum_validation_error_deg", 1.5)
+ )
+ )
+ for values in holdout_by_joint.values()
+ )
+ )
+ ):
+ raise ValueError("isolated trajectory holdout failed")
+
+ motor_by_joint = {
+ name: int(spec.motor_index) for name, spec in profile.joint_specs.items()
+ }
+ measured_endpoint_zero_offsets = (
+ derive_right_19_mechanical_endpoint_offsets(
+ source_urdf,
+ command_fits,
+ maximum_offset_rad=math.radians(
+ float(
+ parameters.get(
+ "mechanical_endpoint_maximum_offset_deg", 5.0
+ )
+ )
+ ),
+ )
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else {}
+ )
+ 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
+ }
+ command_fits = clamp_runtime_fits_to_urdf_limits(
+ source_urdf,
+ command_fits,
+ endpoint_anchored_offsets_rad=endpoint_zero_offsets,
+ )
+ joint_limits: dict[str, float] = {}
+ solve_arguments = {
+ "source_urdf": source_urdf,
+ "measurements": axes,
+ "palm_orientation_measurements": palm_orientation_measurements,
+ "motor_by_joint": motor_by_joint,
+ "maximum_offset_rad": math.radians(float(parameters["zero_maximum_offset_deg"])),
+ "finger_maximum_offset_rad": math.radians(
+ float(parameters.get("zero_finger_maximum_offset_deg", 3.0))
+ ),
+ "joint_maximum_offset_rad": joint_limits,
+ "maximum_cycle_difference_rad": math.radians(
+ float(parameters["zero_maximum_axis_cycle_difference_deg"])
+ ),
+ "maximum_axis_cone_mismatch_rad": math.radians(
+ float(parameters["zero_maximum_axis_cone_mismatch_deg"])
+ ),
+ "maximum_systematic_axis_cone_bias_rad": math.radians(
+ float(
+ parameters.get(
+ "cross_view_roll_maximum_axis_difference_deg", 15.0
+ )
+ )
+ ),
+ "maximum_observability_condition_number": float(
+ parameters.get(
+ "zero_maximum_observability_condition_number", 1.0e10
+ )
+ ),
+ "maximum_pose_axis_line_rms_m": float(
+ parameters["axis_maximum_pose_line_rms_m"]
+ ),
+ "maximum_validation_mae_rad": maximum_validation_mae,
+ "maximum_validation_p95_rad": maximum_validation_p95,
+ "maximum_validation_error_rad": (
+ math.radians(
+ float(parameters.get("maximum_validation_error_deg", 1.5))
+ )
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else None
+ ),
+ "maximum_confidence_half_width_rad": (
+ math.radians(
+ float(
+ parameters.get(
+ "zero_maximum_confidence_half_width_deg", 0.5
+ )
+ )
+ )
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else None
+ ),
+ "hand_type": side,
+ "tag_layout": layout_id,
+ "training_cycles": training_cycles,
+ "validation_cycle": validation_cycle,
+ "fixed_direct_zero_offsets_rad": {
+ **zero_profile.fixed_direct_zero_offsets_rad,
+ **endpoint_zero_offsets,
+ },
+ "static_output_zero_offsets_rad": {
+ **zero_profile.static_output_zero_offsets_rad,
+ **post_solve_endpoint_offsets,
+ },
+ }
+ def solve_with_product_partition(
+ fits: Mapping[str, JointCurveFit],
+ arguments: Mapping[str, Any],
+ ):
+ if layout_id != G20_RIGHT_19_LAYOUT or legacy_coupled_zero_solver:
+ return solve_urdf_zero_offsets(curves=fits, **arguments)
+ thumb_profile = get_right_19_thumb_zero_profile()
+ thumb_names = set(thumb_profile.direct_zero_joints)
+ thumb = solve_urdf_zero_offsets(
+ curves=fits,
+ **{
+ **arguments,
+ "measurements": [
+ item
+ for item in arguments["measurements"]
+ if item.joint in thumb_profile.axis_joints
+ ],
+ "joint_maximum_offset_rad": {
+ name: value
+ for name, value in arguments[
+ "joint_maximum_offset_rad"
+ ].items()
+ if name in thumb_names
+ },
+ "fixed_direct_zero_offsets_rad": {
+ name: value
+ for name, value in arguments[
+ "fixed_direct_zero_offsets_rad"
+ ].items()
+ if name in thumb_names
+ },
+ "static_output_zero_offsets_rad": {
+ name: value
+ for name, value in arguments[
+ "static_output_zero_offsets_rad"
+ ].items()
+ if name in thumb_names
+ },
+ "zero_profile": thumb_profile,
+ },
+ )
+ companion = solve_urdf_zero_offsets(
+ curves=fits,
+ **{
+ **arguments,
+ "fixed_direct_zero_offsets_rad": {
+ **arguments["fixed_direct_zero_offsets_rad"],
+ **thumb.direct_offsets_rad,
+ },
+ },
+ )
+ return merge_right_19_thumb_zero_result(
+ thumb_result=thumb,
+ companion_result=companion,
+ )
+
+ holdout_zero = solve_with_product_partition(training_fits, solve_arguments)
+ if not holdout_zero.passed:
+ failure = {
+ "reasons": dict(holdout_zero.failure_reasons),
+ "fitted_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in holdout_zero.direct_offsets_rad.items()
+ },
+ "cycle_offsets_deg": {
+ name: [math.degrees(value) for value in values]
+ for name, values in holdout_zero.cycle_offsets_rad.items()
+ },
+ }
+ raise ValueError(
+ "isolated zero/URDF holdout failed: "
+ + json.dumps(failure, ensure_ascii=False, sort_keys=True)
+ )
+ # Keep the model frozen after the holdout decision. Re-fitting with the
+ # held-out cycle would make the published artifact differ from the model
+ # that was independently evaluated above.
+ final_zero = holdout_zero
+ measured_fits = command_fits
+ for target, source_name in zero_profile.inherited_static_zero_joints.items():
+ if final_zero.all_active_offsets_rad[target] != final_zero.direct_offsets_rad[source_name]:
+ raise ValueError(f"inherited static zero mismatch: {target} <- {source_name}")
+ for target in (
+ set(zero_profile.inherited_zero_joints)
+ - set(zero_profile.inherited_static_zero_joints)
+ ):
+ if final_zero.all_active_offsets_rad[target] != 0.0:
+ raise ValueError(f"unobserved static zero must retain source CAD: {target}")
+
+ validation_errors = [
+ float(value) for values in holdout_by_joint.values() for value in values
+ ]
+ validation_errors.extend(float(value) for value in holdout_zero.validation_errors_rad)
+ stamp = session.name
+ final_json = session / (
+ f"g20_{side}_{hand_serial}_calibration{output_suffix}.json"
+ )
+ expected_urdf_name = (
+ f"{source_urdf.stem}_zero_calibrated_{hand_serial}_{stamp}"
+ f"{output_suffix}.urdf"
+ )
+ final_urdf = session / expected_urdf_name
+ report_path = session / (
+ f"g20_{side}_{hand_serial}_offline_validation{output_suffix}.json"
+ )
+ if write_outputs:
+ existing = [path for path in (final_json, final_urdf, report_path) if path.exists()]
+ if existing:
+ raise ValueError(
+ "refusing to overwrite replay outputs: "
+ + ", ".join(str(path) for path in existing)
+ )
+
+ source_hash_before = _sha256(source_urdf)
+ published_zero_offsets = {
+ name: round(float(value), 8)
+ for name, value in final_zero.all_active_offsets_rad.items()
+ }
+ urdf_offsets = (
+ {
+ name: published_zero_offsets[name]
+ for name in zero_profile.direct_zero_joints
+ }
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else published_zero_offsets
+ )
+ with tempfile.TemporaryDirectory(prefix="offline_replay_", dir=session) as temporary:
+ candidate = write_zero_corrected_urdf(
+ source_urdf=source_urdf,
+ output_directory=temporary,
+ serial_number=hand_serial,
+ offsets_rad=urdf_offsets,
+ endpoint_anchored_offsets_rad=endpoint_zero_offsets,
+ timestamp=stamp,
+ )
+ urdf_checks = _validate_corrected_urdf(
+ source=source_urdf,
+ corrected=candidate,
+ offsets=urdf_offsets,
+ endpoint_anchored_offsets=endpoint_zero_offsets,
+ axes=axes,
+ curves=training_fits,
+ motor_by_joint=motor_by_joint,
+ inherited_zero_joints=zero_profile.inherited_zero_joints,
+ )
+ residual_zero = solve_with_product_partition(
+ # Re-check the corrected URDF in the exact frozen trajectory
+ # domain that produced the accepted zero offsets. Runtime
+ # requested-command curves are a separate transfer function and
+ # differ slightly at settled checkpoints; using them here creates
+ # a fictitious residual zero after a structurally correct write.
+ training_fits,
+ {
+ **solve_arguments,
+ "static_output_zero_offsets_rad": {
+ name: 0.0
+ for name in (
+ set(zero_profile.static_output_zero_offsets_rad)
+ | set(post_solve_endpoint_offsets)
+ )
+ },
+ "source_urdf": candidate,
+ "fixed_direct_zero_offsets_rad": {
+ name: 0.0
+ for name in (
+ set(zero_profile.fixed_direct_zero_offsets_rad)
+ | set(endpoint_zero_offsets)
+ )
+ },
+ },
+ )
+ maximum_residual_offset = max(
+ abs(float(value)) for value in residual_zero.direct_offsets_rad.values()
+ )
+ if not residual_zero.passed or maximum_residual_offset > math.radians(0.3):
+ raise ValueError(
+ "written URDF retains a significant zero correction: "
+ + json.dumps(
+ {
+ "passed": residual_zero.passed,
+ "residual_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in (
+ residual_zero.direct_offsets_rad.items()
+ )
+ },
+ "failure_reasons": dict(
+ residual_zero.failure_reasons
+ ),
+ },
+ ensure_ascii=False,
+ sort_keys=True,
+ )
+ )
+ candidate_hash = _sha256(candidate)
+ payload = build_compact_payload(
+ serial_number=hand_serial,
+ measured_fits=command_fits,
+ urdf_zero_offsets_rad=published_zero_offsets,
+ validation_errors_rad=validation_errors,
+ passed=True,
+ baseline=baseline,
+ side=side,
+ layout_id=layout_id,
+ zero_uncertainty_rad=(
+ final_zero.offset_confidence_half_width_rad
+ ),
+ zero_cycle_offsets_rad=final_zero.cycle_offsets_rad,
+ zero_observers=zero_profile.offset_observer_joint,
+ artifact_hashes=(
+ {
+ "source_urdf_sha256": source_hash_before,
+ "camera_extrinsics_sha256": _sha256(extrinsics_file),
+ "corrected_urdf_sha256": candidate_hash,
+ }
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else None
+ ),
+ cross_view_roll_metrics=cross_view_roll_metrics,
+ joint_dynamic_diagnostics=joint_dynamic_diagnostics,
+ zero_geometry_diagnostics=(
+ {
+ "training_cycles": final_zero.training_cycles,
+ "validation_cycle": final_zero.validation_cycle,
+ "axis_line_rms_m": final_zero.axis_line_rms_m,
+ "validation_line_error_by_joint_m": (
+ final_zero.validation_line_error_by_joint_m
+ ),
+ "observability_rank": final_zero.observability_rank,
+ "observability_parameter_count": (
+ final_zero.observability_parameter_count
+ ),
+ "observability_condition_number": (
+ final_zero.observability_condition_number
+ ),
+ "offset_covariance_rad2": (
+ final_zero.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 palm_orientation_measurements
+ ],
+ "palm_orientation_rejections": dict(
+ palm_orientation_rejections
+ ),
+ }
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else None
+ ),
+ )
+ payload, _clipped_runtime_joints = (
+ clamp_compact_payload_to_urdf_limits(payload, candidate)
+ )
+ legacy_quality_source = None
+ if legacy_coupled_zero_solver:
+ payload["quality"] = _legacy_published_quality(
+ session,
+ side=side,
+ serial_number=hand_serial,
+ )
+ legacy_quality_source = (
+ "published_v1_artifact_random_validation_omitted_from_raw"
+ )
+ validate_compact_payload(payload)
+ if write_outputs:
+ if _sha256(source_urdf) != source_hash_before:
+ raise ValueError("source URDF changed during offline replay")
+ os.replace(candidate, final_urdf)
+ try:
+ # As in the live path, the atomically written JSON is the
+ # commit marker for the already validated URDF/JSON pair.
+ atomic_write_json(final_json, payload)
+ except Exception:
+ if layout_id == G20_RIGHT_19_LAYOUT and final_urdf.exists():
+ rejected = final_urdf.with_suffix(".urdf.rejected")
+ final_urdf.replace(rejected)
+ raise
+
+ if _sha256(source_urdf) != source_hash_before:
+ raise ValueError("source URDF changed during offline replay")
+
+ report: dict[str, Any] = {
+ "passed": True,
+ "session_dir": str(session),
+ "side": side,
+ "serial_number": hand_serial,
+ "output_tag": output_tag,
+ "raw_samples_sha256": _sha256(raw_path),
+ "source_urdf": str(source_urdf),
+ "source_urdf_sha256": source_hash_before,
+ "joint_limits_deg": {
+ "finger_default": float(
+ parameters.get("zero_finger_maximum_offset_deg", 3.0)
+ ),
+ "thumb_default": float(parameters["zero_maximum_offset_deg"]),
+ },
+ "static_zero_policy": (
+ "visual_axes_plus_measured_mechanical_endpoint"
+ if layout_id == G20_RIGHT_19_LAYOUT
+ else "direct_measurements_only"
+ ),
+ "fixed_zero_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in zero_profile.fixed_direct_zero_offsets_rad.items()
+ },
+ "endpoint_anchored_zero_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in endpoint_zero_offsets.items()
+ },
+ "static_output_zero_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in {
+ **zero_profile.static_output_zero_offsets_rad,
+ **post_solve_endpoint_offsets,
+ }.items()
+ },
+ "direct_offsets_deg": {
+ name: math.degrees(value)
+ for name, value in final_zero.direct_offsets_rad.items()
+ },
+ "model_base_translation_xyz_m": list(
+ final_zero.base_translation_xyz_m
+ ),
+ "model_base_quaternion_xyzw": list(
+ final_zero.base_quaternion_xyzw
+ ),
+ "cycle_offsets_deg": {
+ name: [math.degrees(value) for value in values]
+ for name, values in holdout_zero.cycle_offsets_rad.items()
+ },
+ "axis_cone_mismatch_by_joint_deg": {
+ name: math.degrees(value)
+ for name, value in (
+ holdout_zero.axis_cone_mismatch_by_joint_rad.items()
+ )
+ },
+ "axis_cone_bias_classification_by_joint": dict(
+ holdout_zero.axis_cone_bias_classification_by_joint
+ ),
+ "offset_uncertainty_deg": {
+ name: math.degrees(value)
+ for name, value in holdout_zero.offset_uncertainty_rad.items()
+ },
+ "trajectory_holdout_mae_deg": math.degrees(trajectory_mae),
+ "trajectory_holdout_p95_deg": math.degrees(trajectory_p95),
+ "zero_holdout_error_deg": {
+ name: math.degrees(value)
+ for name, value in holdout_zero.validation_error_by_joint_rad.items()
+ },
+ "zero_original_error_deg": {
+ name: math.degrees(value)
+ for name, value in holdout_zero.validation_original_error_by_joint_rad.items()
+ },
+ "zero_improvement_95pct_lower_deg": {
+ name: math.degrees(value)
+ for name, value in (
+ holdout_zero.validation_improvement_confidence_lower_rad.items()
+ )
+ },
+ "corrected_urdf_checks": {
+ **urdf_checks,
+ "maximum_residual_zero_offset_deg": math.degrees(maximum_residual_offset),
+ },
+ "corrected_urdf_sha256": candidate_hash,
+ "computed_final_json_sha256": _payload_sha256(payload),
+ "legacy_quality_source": legacy_quality_source,
+ "final_json": str(final_json) if write_outputs else None,
+ "corrected_urdf": str(final_urdf) if write_outputs else None,
+ }
+ if write_outputs:
+ report["final_json_sha256"] = _sha256(final_json)
+ if _sha256(final_urdf) != candidate_hash:
+ raise ValueError("formal corrected URDF differs from validated candidate")
+ atomic_write_json(report_path, report)
+ report["validation_report"] = str(report_path)
+ return report
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Replay and validate a registered hand-calibration session."
+ )
+ parser.add_argument("session_dir")
+ parser.add_argument("--serial-number", default=None)
+ parser.add_argument("--config-file", default=None)
+ parser.add_argument("--write", action="store_true")
+ parser.add_argument(
+ "--output-tag",
+ default=None,
+ help="safe suffix for a replay variant; existing outputs are never overwritten",
+ )
+ arguments = parser.parse_args()
+ result = replay_session(
+ arguments.session_dir,
+ serial_number=arguments.serial_number,
+ config_file=arguments.config_file,
+ write_outputs=arguments.write,
+ output_tag=arguments.output_tag,
+ )
+ print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py
new file mode 100644
index 0000000..90ded41
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/profile.py
@@ -0,0 +1,2241 @@
+"""Pure three-camera G20 calibration model and compact runtime schema.
+
+The hardware node records one parent/child AprilTag trajectory for each
+directly observable joint. This module deliberately contains no ROS imports:
+curve fitting, baseline centring, inheritance, and schema validation
+remain deterministic and unit-testable without connected cameras or a hand.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+import math
+from pathlib import Path
+import re
+from typing import Any, Iterable, Mapping, Sequence
+import xml.etree.ElementTree as ET
+
+import numpy as np
+
+from .command_layout import G20_COMMAND_NAMES as COMMAND_NAMES
+from ...trajectory import (
+ _angle_for_circle,
+ _fit_circle_with_axis,
+ _fit_joint_curve,
+ _fit_plane_axis,
+ _orient_circle_positive,
+)
+from ...zero_calibration import (
+ _fit_circle,
+ _trajectory_arc_rad,
+ circular_median_rad,
+ maximum_pairwise_angle_difference_rad,
+ wrap_angle_rad,
+)
+
+
+THREE_CAMERA_BASELINE_COMMAND: tuple[int, ...] = (
+ 255, 255, 255, 255, 255, 255,
+ 127, 127, 127, 127,
+ 255, 255, 255, 255, 255, 255, 255, 255, 255, 255,
+)
+
+
+@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
+
+ @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
+
+
+@dataclass(frozen=True)
+class HandCalibrationProfile:
+ side: str
+ reference_finger: str
+ view_tags: Mapping[str, Mapping[str, int]]
+ preflight_view_roles: Mapping[str, tuple[str, ...]]
+ joint_specs: Mapping[str, JointSpec]
+ sweep_specs: tuple[SweepSpec, ...]
+ image_trajectory_joints: frozenset[str]
+ roll_clearance_commands: Mapping[int, int]
+ thumb_pitch_clearance_commands: Mapping[int, int]
+ layout_id: str = "legacy_11"
+ measurement_specs: Mapping[str, JointSpec] | None = None
+ axis_validation_sources: Mapping[str, str] | None = None
+ palm_axis_observers: tuple[PalmAxisObserver, ...] = ()
+ minimum_palm_orientation_sources: int = 0
+ palm_orientation_minimum_arc_rad: float = math.radians(15.0)
+ palm_orientation_maximum_command_distance_u8: int = 255
+ # Hardware and algorithm differences belong to the product profile, not
+ # to the acquisition state machine. Keeping these fields here lets a new
+ # model (or the mirrored hand) reuse the same recorder/fitter by supplying
+ # a different declarative profile.
+ model: str = "G20"
+ command_names: tuple[str, ...] = COMMAND_NAMES
+ baseline_command: tuple[int, ...] = THREE_CAMERA_BASELINE_COMMAND
+ capabilities: frozenset[str] = frozenset()
+ precheck_sweeps: bool = False
+ steady_command_checkpoints: bool = False
+ directional_zero: bool = False
+ isolated_holdout: bool = False
+ cross_view_roll_curve: bool = False
+ stable_cross_view_cone_bias: bool = False
+
+ @property
+ def command_count(self) -> int:
+ return len(self.command_names)
+
+ def supports(self, capability: str) -> bool:
+ return str(capability) in self.capabilities
+
+ @property
+ def measured_joints(self) -> tuple[str, ...]:
+ return tuple(
+ name for name, spec in self.joint_specs.items() if spec.measured
+ )
+
+ @property
+ def record_specs(self) -> Mapping[str, JointSpec]:
+ return self.joint_specs if self.measurement_specs is None else self.measurement_specs
+
+ @property
+ def record_joints(self) -> tuple[str, ...]:
+ return tuple(
+ name for name, spec in self.record_specs.items() if spec.measured
+ )
+
+ @property
+ def palm_orientation_sources(self) -> Mapping[str, str]:
+ return {
+ observer.source_name: observer.model_joint
+ for observer in self.palm_axis_observers
+ }
+
+ @property
+ def palm_axis_motor_by_source(self) -> Mapping[str, int]:
+ return {
+ observer.source_name: int(observer.motor_index)
+ for observer in self.palm_axis_observers
+ }
+
+ @property
+ def active_joints(self) -> tuple[str, ...]:
+ return tuple(
+ name for name, spec in self.joint_specs.items() if spec.active
+ )
+
+ @property
+ def passive_joints(self) -> tuple[str, ...]:
+ return tuple(
+ name for name, spec in self.joint_specs.items() if not spec.active
+ )
+
+ @property
+ def reference_roll_motor(self) -> int:
+ return int(self.joint_specs[f"{self.reference_finger}_mcp_roll"].motor_index)
+
+ @property
+ def reference_pitch_motor(self) -> int:
+ return int(self.joint_specs[f"{self.reference_finger}_mcp_pitch"].motor_index)
+
+ @property
+ def reference_pip_motor(self) -> int:
+ return int(self.joint_specs[f"{self.reference_finger}_pip"].motor_index)
+
+ @property
+ def reference_speed_slot(self) -> int:
+ return {"index": 1, "middle": 2, "ring": 3, "pinky": 4}[
+ self.reference_finger
+ ]
+
+
+_FINGERS: tuple[str, ...] = ("index", "middle", "ring", "pinky")
+
+# The side camera looks almost along these physical flexion axes. The child
+# Tag centre therefore traces a high-resolution image-plane circle against the
+# fixed palm Tag, while monocular planar-PnP tilt is not accurate enough to be
+# the dynamic angle source. Passive DIP is intentionally excluded because
+# its parent Tag moves with PIP; a raw image-centre difference would mix both
+# joint angles.
+RIGHT_19_END_ON_IMAGE_CURVE_JOINTS = frozenset(
+ {
+ *(f"{finger}_mcp_pitch" for finger in _FINGERS),
+ *(f"{finger}_pip" for finger in _FINGERS),
+ }
+)
+# These joints have no independent actuator. Their distal Tags directly
+# measure the passive dynamic curve, while their fitted axes serve only as
+# downstream phase observers for the corresponding active PIP.
+RIGHT_19_VISUALLY_MEASURED_PASSIVE_DIPS = frozenset(
+ f"{finger}_dip" for finger in _FINGERS
+)
+_MOTOR_BY_JOINT: dict[str, int] = {
+ **{f"{finger}_mcp_pitch": index + 1 for index, finger in enumerate(_FINGERS)},
+ **{f"{finger}_mcp_roll": index + 6 for index, finger in enumerate(_FINGERS)},
+ **{f"{finger}_pip": index + 16 for index, finger in enumerate(_FINGERS)},
+}
+
+
+def _build_hand_profile(side: str) -> HandCalibrationProfile:
+ hand_side = str(side).lower()
+ if hand_side not in {"left", "right"}:
+ raise ValueError("hand side must be left or right")
+ reference = "index" if hand_side == "left" else "pinky"
+ roll_role = f"{reference}_roll"
+ mcp_role = f"{reference}_mcp"
+ pip_role = f"{reference}_pip"
+ dip_role = f"{reference}_dip"
+ view_tags: dict[str, dict[str, int]] = {
+ "front": {
+ "front_base": 0,
+ "thumb_cmc": 1,
+ "thumb_mcp": 2,
+ "thumb_ip": 3,
+ roll_role: 10,
+ },
+ "side": {
+ "side_base": 4,
+ mcp_role: 5,
+ pip_role: 6,
+ dip_role: 7,
+ },
+ "top": {"top_base": 8, "thumb_yaw": 9},
+ }
+ preflight_view_roles = {
+ view: tuple(tags) for view, tags in view_tags.items()
+ }
+ joint_specs: dict[str, JointSpec] = {}
+ # Both hands use the front base/Tag 1 pair for motor 0. Keeping the
+ # complete thumb root chain in one calibrated view avoids introducing a
+ # cross-camera axis-line phase into the URDF zero solve.
+ thumb_pitch_view = "front"
+ thumb_pitch_parent = "front_base"
+ joint_specs["thumb_cmc_pitch"] = JointSpec(
+ "thumb_cmc_pitch",
+ 0,
+ True,
+ thumb_pitch_view,
+ thumb_pitch_parent,
+ "thumb_cmc",
+ zero_kind="urdf_axis_chain",
+ )
+ for finger in _FINGERS:
+ name = f"{finger}_mcp_pitch"
+ source = None if finger == reference else f"{reference}_mcp_pitch"
+ joint_specs[name] = JointSpec(
+ name,
+ _MOTOR_BY_JOINT[name],
+ True,
+ "side" if source is None else None,
+ "side_base" if source is None else None,
+ mcp_role if source is None else None,
+ source_joint=source,
+ zero_kind="urdf_axis_chain" if source is None else "inherited",
+ )
+ joint_specs["thumb_cmc_roll"] = JointSpec(
+ "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
+ zero_kind="urdf_axis_chain",
+ )
+ for finger in _FINGERS:
+ name = f"{finger}_mcp_roll"
+ source = None if finger == reference else f"{reference}_mcp_roll"
+ joint_specs[name] = JointSpec(
+ name,
+ _MOTOR_BY_JOINT[name],
+ True,
+ "front" if source is None else None,
+ "front_base" if source is None else None,
+ roll_role if source is None else None,
+ source_joint=source,
+ zero_kind="urdf_axis_chain" if source is None else "inherited",
+ )
+ joint_specs["thumb_cmc_yaw"] = JointSpec(
+ "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
+ zero_kind="urdf_axis_chain",
+ )
+ joint_specs["thumb_mcp"] = JointSpec(
+ "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
+ zero_kind="urdf_axis_chain",
+ )
+ for finger in _FINGERS:
+ name = f"{finger}_pip"
+ source = None if finger == reference else f"{reference}_pip"
+ joint_specs[name] = JointSpec(
+ name,
+ _MOTOR_BY_JOINT[name],
+ True,
+ "side" if source is None else None,
+ mcp_role if source is None else None,
+ pip_role if source is None else None,
+ source_joint=source,
+ zero_kind="urdf_axis_chain" if source is None else "inherited",
+ )
+ joint_specs["thumb_ip"] = JointSpec(
+ "thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
+ )
+ for finger in _FINGERS:
+ name = f"{finger}_dip"
+ source = None if finger == reference else f"{reference}_dip"
+ joint_specs[name] = JointSpec(
+ name,
+ _MOTOR_BY_JOINT[f"{finger}_pip"],
+ False,
+ "side" if source is None else None,
+ pip_role if source is None else None,
+ dip_role if source is None else None,
+ source_joint=source,
+ )
+ sweep_specs = (
+ SweepSpec(thumb_pitch_view, 0, ("thumb_cmc_pitch",)),
+ SweepSpec("front", 5, ("thumb_cmc_roll",)),
+ SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")),
+ SweepSpec("front", _MOTOR_BY_JOINT[f"{reference}_mcp_roll"], (
+ f"{reference}_mcp_roll",
+ )),
+ SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_mcp_pitch"], (
+ f"{reference}_mcp_pitch",
+ )),
+ SweepSpec("side", _MOTOR_BY_JOINT[f"{reference}_pip"], (
+ f"{reference}_pip", f"{reference}_dip",
+ )),
+ SweepSpec("top", 10, ("thumb_cmc_yaw",)),
+ )
+ image_joints = frozenset(
+ {
+ "thumb_cmc_pitch",
+ "thumb_cmc_roll",
+ "thumb_mcp",
+ "thumb_ip",
+ f"{reference}_mcp_roll",
+ f"{reference}_mcp_pitch",
+ f"{reference}_pip",
+ }
+ )
+ measured_roll = _MOTOR_BY_JOINT[f"{reference}_mcp_roll"]
+ # The roll channels are mirrored mechanically between hands. Command 0
+ # moves the three non-reference fingers to the camera-right clearance pose
+ # on the left hand, while the same physical pose is command 255 on the
+ # right hand. Using 0 for both sides made the right index/middle/ring
+ # fingers lean left and could obstruct or load the pinky roll sweep.
+ roll_clearance_command = 0 if hand_side == "left" else 255
+ clearance = {
+ _MOTOR_BY_JOINT[f"{finger}_mcp_roll"]: roll_clearance_command
+ for finger in _FINGERS
+ if finger != reference
+ }
+ assert measured_roll not in clearance
+ # Motor 0 is observed from the front with both other thumb-root channels
+ # fully open. Store the commands explicitly for the right hand so a
+ # caller-provided baseline cannot silently restore the obsolete 50/120
+ # pose and hide Tag 1 from the front camera.
+ thumb_pitch_clearance = {10: 255, 5: 255} if hand_side == "right" else {}
+ return HandCalibrationProfile(
+ side=hand_side,
+ reference_finger=reference,
+ view_tags=view_tags,
+ preflight_view_roles=preflight_view_roles,
+ joint_specs=joint_specs,
+ sweep_specs=sweep_specs,
+ image_trajectory_joints=image_joints,
+ roll_clearance_commands=clearance,
+ thumb_pitch_clearance_commands=thumb_pitch_clearance,
+ )
+
+
+LEFT_HAND_PROFILE = _build_hand_profile("left")
+RIGHT_HAND_PROFILE = _build_hand_profile("right")
+
+
+# ``g20_right_15`` remains accepted as a read/CLI compatibility alias, but a
+# newly recorded product session always persists the physical 19-Tag layout
+# identifier. Together with the exact ``view_tags`` resume check this keeps a
+# former 15-Tag checkpoint from being imported after the four distal Tags are
+# installed again.
+G20_RIGHT_15_LAYOUT = "g20_right_15"
+G20_RIGHT_19_LAYOUT = "g20_right_19"
+
+# The three CMC axes keep the calibration domain that was proven on this hand
+# in a609d521: dense relative-rotation sweeps with an isolated-cycle holdout.
+# Monocular Tag translation is useful as a diagnostic, but is not an
+# independent quality measurement for these short, strongly coupled links.
+G20_REFERENCE_THUMB_CMC_JOINTS: frozenset[str] = frozenset(
+ {"thumb_cmc_pitch", "thumb_cmc_roll", "thumb_cmc_yaw"}
+)
+
+G20_COMBINATION_REQUIRED_TARGET_KEYS: tuple[str, ...] = tuple(
+ sorted(
+ {
+ "front:index_mcp_roll",
+ "front:middle_mcp_roll",
+ "front:ring_mcp_roll",
+ "front:pinky_mcp_roll",
+ "side:index_pip",
+ "side:middle_pip",
+ "side:ring_pip",
+ "side:pinky_pip",
+ }
+ )
+)
+
+MIMIC_DERIVED_FINGER_DIPS: Mapping[str, str] = {
+ f"{finger}_dip": f"{finger}_pip" for finger in _FINGERS
+}
+
+
+def canonical_zero_direction(
+ profile: HandCalibrationProfile, joint_name: str
+) -> str | None:
+ """Return the repeatable approach direction used as physical zero.
+
+ A mid-range encoder value does not uniquely determine a linkage pose when
+ the transmission has backlash. The right-hand product four-finger roll profile
+ therefore defines command 127 after a 255->127 approach as the sole
+ physical zero. The opposite branch remains offset in the published
+ direction-aware curve instead of being silently re-zeroed.
+ """
+ name = str(joint_name)
+ if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ return None
+ canonical = {
+ *(f"{finger}_mcp_roll" for finger in _FINGERS),
+ *(f"{finger}_mcp_roll_side" for finger in _FINGERS),
+ }
+ return "decreasing" if name in canonical else None
+
+
+def _right_19_auxiliary_commands(
+ finger: str, *, side_view: bool, target_kind: str
+) -> tuple[tuple[int, int], ...]:
+ """Return the reviewed right-hand fan/occlusion parking pose."""
+ order = ("pinky", "ring", "middle", "index")
+ target_index = order.index(finger)
+ commands: dict[int, int] = {}
+ for other_index, other in enumerate(order):
+ if other == finger:
+ continue
+ roll_motor = _MOTOR_BY_JOINT[f"{other}_mcp_roll"]
+ if other_index < target_index:
+ commands[roll_motor] = 127 if side_view else 0
+ else:
+ commands[roll_motor] = 255
+ if side_view and other_index < target_index:
+ commands[_MOTOR_BY_JOINT[f"{other}_mcp_pitch"]] = 0
+ commands[_MOTOR_BY_JOINT[f"{other}_pip"]] = 0
+ if target_kind in {"pitch", "pip"}:
+ commands[_MOTOR_BY_JOINT[f"{finger}_mcp_roll"]] = 127
+ if target_kind == "roll":
+ commands[_MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]] = 255
+ commands[_MOTOR_BY_JOINT[f"{finger}_pip"]] = 255
+ elif target_kind == "pitch":
+ commands[_MOTOR_BY_JOINT[f"{finger}_pip"]] = 255
+ elif target_kind == "pip":
+ commands[_MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]] = 255
+ return tuple(sorted((int(index), int(value)) for index, value in commands.items()))
+
+
+def _build_right_19_profile() -> HandCalibrationProfile:
+ """Build the 19-Tag/16-motion-task right-hand profile."""
+ view_tags: dict[str, dict[str, int]] = {
+ "front": {
+ "front_base": 0,
+ "thumb_cmc": 1,
+ "thumb_mcp": 2,
+ "thumb_ip": 3,
+ "pinky_roll": 10,
+ "ring_roll": 11,
+ "middle_roll": 12,
+ "index_roll": 13,
+ },
+ "side": {
+ "side_base": 4,
+ "ring_pip": 5,
+ "pinky_pip": 6,
+ "pinky_dip": 7,
+ "ring_dip": 14,
+ "middle_pip": 15,
+ "middle_dip": 16,
+ "index_pip": 17,
+ "index_dip": 18,
+ },
+ "top": {"top_base": 8, "thumb_yaw": 9},
+ }
+ # Four adjacent fingers inevitably occlude some Tags in the
+ # common open-hand pose. Startup therefore proves only the three fixed
+ # palm references. Every moving Tag is still mandatory, but is checked
+ # by its task-local visibility sweep after the other fingers have moved to
+ # that task's clearance pose. Requiring all 15 at one instant would make
+ # a physically correct 19-Tag installation impossible to start.
+ preflight_view_roles = {
+ "front": ("front_base",),
+ "side": ("side_base",),
+ "top": ("top_base",),
+ }
+ joint_specs: dict[str, JointSpec] = {
+ "thumb_cmc_pitch": JointSpec(
+ "thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc",
+ zero_kind="urdf_axis_chain",
+ ),
+ "thumb_cmc_roll": JointSpec(
+ "thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
+ zero_kind="urdf_axis_chain",
+ ),
+ "thumb_cmc_yaw": JointSpec(
+ "thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
+ zero_kind="urdf_axis_chain",
+ ),
+ "thumb_mcp": JointSpec(
+ "thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
+ zero_kind="urdf_axis_chain",
+ ),
+ "thumb_ip": JointSpec(
+ "thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
+ ),
+ }
+ for finger in ("index", "middle", "ring", "pinky"):
+ roll = f"{finger}_mcp_roll"
+ pitch = f"{finger}_mcp_pitch"
+ pip = f"{finger}_pip"
+ dip = f"{finger}_dip"
+ joint_specs[roll] = JointSpec(
+ roll, _MOTOR_BY_JOINT[roll], True, "front", "front_base",
+ f"{finger}_roll", zero_kind="urdf_axis_chain",
+ )
+ # The side PIP Tag is on the middle phalanx after PIP. With PIP held
+ # at 255 it observes MCP pitch; with MCP pitch held at 255 it observes
+ # PIP itself.
+ joint_specs[pitch] = JointSpec(
+ pitch, _MOTOR_BY_JOINT[pitch], True, "side", "side_base",
+ f"{finger}_pip", zero_kind="urdf_axis_chain",
+ )
+ joint_specs[pip] = JointSpec(
+ pip, _MOTOR_BY_JOINT[pip], True, "side", "side_base",
+ f"{finger}_pip", zero_kind="urdf_axis_chain",
+ )
+ # DIP is passive, but no longer unobserved. The PIP sweep moves the
+ # complete linkage once while the middle/distal Tag pair directly
+ # measures DIP. Its dynamic curve is therefore visual truth; its
+ # absolute static URDF zero deliberately remains source CAD because an
+ # arbitrary distal-Tag mounting angle is indistinguishable from a DIP
+ # zero offset without a mechanical zero fixture.
+ joint_specs[dip] = JointSpec(
+ dip,
+ _MOTOR_BY_JOINT[pip],
+ False,
+ "side",
+ f"{finger}_pip",
+ f"{finger}_dip",
+ )
+
+ measurement_specs = dict(joint_specs)
+ axis_validation_sources: dict[str, str] = {}
+ for finger in ("pinky", "ring", "middle", "index"):
+ canonical = f"{finger}_mcp_roll"
+ alias = f"{canonical}_side"
+ measurement_specs[alias] = JointSpec(
+ alias,
+ _MOTOR_BY_JOINT[canonical],
+ True,
+ "side",
+ "side_base",
+ f"{finger}_pip",
+ zero_kind="axis_cross_view_validation",
+ )
+ axis_validation_sources[canonical] = alias
+
+ sweeps: list[SweepSpec] = [
+ SweepSpec(
+ "front", 0, ("thumb_cmc_pitch",), "thumb_cmc_pitch_front",
+ ((5, 255), (10, 255)),
+ ),
+ SweepSpec("front", 5, ("thumb_cmc_roll",), "thumb_cmc_roll_front"),
+ SweepSpec(
+ "front", 15, ("thumb_mcp", "thumb_ip"), "thumb_mcp_ip_front"
+ ),
+ SweepSpec(
+ "top", 10, ("thumb_cmc_yaw",), "thumb_cmc_yaw_top", ((5, 145),)
+ ),
+ ]
+ for finger in ("pinky", "ring", "middle", "index"):
+ roll_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_roll"]
+ pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
+ pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
+ sweeps.extend(
+ (
+ SweepSpec(
+ "front",
+ roll_motor,
+ (
+ f"{finger}_mcp_roll",
+ f"{finger}_mcp_roll_side",
+ ),
+ f"{finger}_roll_multiview",
+ _right_19_auxiliary_commands(
+ finger, side_view=True, target_kind="roll"
+ ),
+ ),
+ SweepSpec(
+ "side",
+ pitch_motor,
+ (f"{finger}_mcp_pitch",),
+ f"{finger}_pitch_side",
+ _right_19_auxiliary_commands(
+ finger, side_view=True, target_kind="pitch"
+ ),
+ ),
+ SweepSpec(
+ "side",
+ pip_motor,
+ (f"{finger}_pip", f"{finger}_dip"),
+ f"{finger}_pip_side",
+ _right_19_auxiliary_commands(
+ finger, side_view=True, target_kind="pip"
+ ),
+ ),
+ )
+ )
+ image_joints = frozenset(
+ {
+ "thumb_cmc_pitch",
+ "thumb_cmc_roll",
+ "thumb_mcp",
+ "thumb_ip",
+ *(f"{finger}_mcp_roll" for finger in ("index", "middle", "ring", "pinky")),
+ }
+ )
+ return HandCalibrationProfile(
+ side="right",
+ reference_finger="pinky",
+ view_tags=view_tags,
+ preflight_view_roles=preflight_view_roles,
+ joint_specs=joint_specs,
+ sweep_specs=tuple(sweeps),
+ image_trajectory_joints=image_joints,
+ roll_clearance_commands={},
+ thumb_pitch_clearance_commands={10: 255, 5: 255},
+ layout_id=G20_RIGHT_19_LAYOUT,
+ measurement_specs=measurement_specs,
+ axis_validation_sources=axis_validation_sources,
+ # Observe the zero-adjacent roll and pitch axes from Tag 9 while the
+ # existing front sweeps run. Their same-view angle identifies the
+ # intervening yaw zero without adding a motion task or reference URDF.
+ palm_axis_observers=(
+ PalmAxisObserver(
+ source_name="thumb_cmc_pitch_top_axis",
+ task_name="thumb_cmc_pitch_front",
+ view="top",
+ parent_role="top_base",
+ child_role="thumb_yaw",
+ model_joint="thumb_cmc_pitch",
+ motor_index=joint_specs["thumb_cmc_pitch"].motor_index,
+ ),
+ PalmAxisObserver(
+ source_name="thumb_cmc_roll_top_axis",
+ task_name="thumb_cmc_roll_front",
+ view="top",
+ parent_role="top_base",
+ child_role="thumb_yaw",
+ model_joint="thumb_cmc_roll",
+ motor_index=joint_specs["thumb_cmc_roll"].motor_index,
+ ),
+ ),
+ minimum_palm_orientation_sources=2,
+ palm_orientation_minimum_arc_rad=math.radians(10.0),
+ palm_orientation_maximum_command_distance_u8=64,
+ capabilities=frozenset(
+ {
+ "precheck_sweeps",
+ "steady_command_checkpoints",
+ "directional_zero",
+ "isolated_holdout",
+ "cross_view_roll_curve",
+ "stable_cross_view_cone_bias",
+ "measured_passive_dips",
+ "urdf_zero_publication",
+ "palm_axis_relative_motion_v3",
+ }
+ ),
+ precheck_sweeps=True,
+ steady_command_checkpoints=True,
+ directional_zero=True,
+ isolated_holdout=True,
+ cross_view_roll_curve=True,
+ stable_cross_view_cone_bias=True,
+ )
+
+
+RIGHT_19_HAND_PROFILE = _build_right_19_profile()
+
+
+def get_hand_calibration_profile(
+ side: str, layout_id: str = "legacy_11"
+) -> HandCalibrationProfile:
+ value = str(side).lower()
+ layout = str(layout_id).lower()
+ if layout in {G20_RIGHT_19_LAYOUT, G20_RIGHT_15_LAYOUT}:
+ if value != "right":
+ raise ValueError("G20 right product layout requires hand side right")
+ return RIGHT_19_HAND_PROFILE
+ if layout != "legacy_11":
+ raise ValueError(f"unknown G20 tag layout: {layout_id}")
+ if value == "left":
+ return LEFT_HAND_PROFILE
+ if value == "right":
+ return RIGHT_HAND_PROFILE
+ raise ValueError("hand side must be left or right")
+
+
+def derive_mimic_passive_fits(
+ source_urdf: str | Path,
+ fits: Mapping[str, JointCurveFit],
+ *,
+ profile: HandCalibrationProfile,
+) -> dict[str, JointCurveFit]:
+ """Complete legacy non-visual DIP curves using the URDF mimic contract.
+
+ The runtime curves are relative to the baseline command, so a URDF mimic
+ offset cancels while the multiplier scales the measured PIP motion. The
+ URDF itself remains byte-for-byte protected by publication checks.
+ """
+ result = dict(fits)
+ if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ return result
+ visually_measured = all(
+ profile.joint_specs[name].source_joint is None
+ for name in MIMIC_DERIVED_FINGER_DIPS
+ )
+ if visually_measured:
+ missing = sorted(set(profile.joint_specs) - set(result))
+ extra = sorted(set(result) - set(profile.joint_specs))
+ if missing or extra:
+ raise ValueError(
+ f"runtime fit set is incomplete: missing={missing} extra={extra}"
+ )
+ return result
+ path = Path(source_urdf).expanduser().resolve()
+ if not path.is_file():
+ raise ValueError(f"source URDF does not exist: {path}")
+ joints = {
+ str(node.get("name")): node
+ for node in ET.parse(path).getroot().findall("joint")
+ }
+ for passive_name, source_name in MIMIC_DERIVED_FINGER_DIPS.items():
+ spec = profile.joint_specs[passive_name]
+ if spec.source_joint != source_name:
+ raise ValueError(f"{passive_name} has the wrong curve source")
+ if source_name not in result:
+ raise ValueError(f"missing measured source curve {source_name}")
+ joint = joints.get(passive_name)
+ mimic = None if joint is None else joint.find("mimic")
+ if mimic is None or mimic.get("joint") != source_name:
+ raise ValueError(
+ f"source URDF {passive_name} must mimic {source_name}"
+ )
+ multiplier = float(mimic.get("multiplier", "1"))
+ offset = float(mimic.get("offset", "0"))
+ if not math.isfinite(multiplier) or multiplier <= 0.0:
+ raise ValueError(f"source URDF {passive_name} mimic multiplier is invalid")
+ if not math.isfinite(offset):
+ raise ValueError(f"source URDF {passive_name} mimic offset is invalid")
+ source = result[source_name]
+
+ def scaled(values: Sequence[float]) -> tuple[float, ...]:
+ return tuple(multiplier * float(value) for value in values)
+
+ result[passive_name] = JointCurveFit(
+ angle_rad=scaled(source.angle_rad),
+ decreasing_rad=scaled(source.decreasing_rad),
+ increasing_rad=scaled(source.increasing_rad),
+ circle={
+ "curve_source": source_name,
+ "source_urdf_mimic_multiplier": multiplier,
+ "source_urdf_mimic_offset_rad": offset,
+ "visual_measurement": False,
+ },
+ maximum_monotonic_correction_rad=(
+ abs(multiplier) * source.maximum_monotonic_correction_rad
+ ),
+ maximum_hysteresis_rad=(
+ abs(multiplier) * source.maximum_hysteresis_rad
+ ),
+ quality={
+ "mimic_multiplier": multiplier,
+ "mimic_offset_rad": offset,
+ "visual_measurement": 0.0,
+ },
+ zero_offset_rad=multiplier * source.zero_offset_rad,
+ )
+ if set(result) != set(profile.joint_specs):
+ missing = sorted(set(profile.joint_specs) - set(result))
+ extra = sorted(set(result) - set(profile.joint_specs))
+ raise ValueError(
+ f"runtime fit set is incomplete: missing={missing} extra={extra}"
+ )
+ return result
+
+
+def clamp_runtime_fits_to_urdf_limits(
+ source_urdf: str | Path,
+ fits: Mapping[str, JointCurveFit],
+ *,
+ endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
+) -> dict[str, JointCurveFit]:
+ """Saturate published curves at protected physical limits.
+
+ Raw trajectory fits and all fit/zero diagnostics remain untouched. The
+ returned transfer functions are the commands a URDF/MuJoCo runtime can
+ actually represent safely. An endpoint-anchored joint uses the corrected
+ coordinate upper limit ``CAD_upper - origin_offset``; its physical maximum
+ remains exactly the original CAD maximum after the origin correction.
+ """
+ path = Path(source_urdf).expanduser().resolve()
+ joints = {
+ str(node.get("name")): node
+ for node in ET.parse(path).getroot().findall("joint")
+ }
+ result: dict[str, JointCurveFit] = {}
+ endpoint_offsets = {
+ str(name): float(value)
+ for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
+ }
+ for name, fit in fits.items():
+ joint = joints.get(name)
+ limit = None if joint is None else joint.find("limit")
+ if (
+ limit is None
+ or limit.get("lower") is None
+ or limit.get("upper") is None
+ ):
+ raise ValueError(f"source URDF joint {name} has no finite position limit")
+ lower = float(limit.get("lower"))
+ upper = float(limit.get("upper"))
+ if name in endpoint_offsets:
+ upper -= endpoint_offsets[name]
+ if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
+ raise ValueError(f"source URDF joint {name} has invalid position limits")
+
+ clipped_counts: dict[str, int] = {}
+
+ def clipped(values: Sequence[float], branch: str) -> tuple[float, ...]:
+ source = np.asarray(values, dtype=float)
+ bounded = np.clip(source, lower, upper)
+ clipped_counts[branch] = int(np.count_nonzero(bounded != source))
+ return tuple(float(value) for value in bounded)
+
+ circle = dict(fit.circle)
+ bounded_angle = clipped(fit.angle_rad, "angle_rad")
+ bounded_decreasing = clipped(fit.decreasing_rad, "decreasing_rad")
+ bounded_increasing = clipped(fit.increasing_rad, "increasing_rad")
+ if any(clipped_counts.values()):
+ circle["runtime_urdf_limit_clipped_bins"] = clipped_counts
+ circle["runtime_urdf_limits_rad"] = [lower, upper]
+ result[name] = replace(
+ fit,
+ angle_rad=bounded_angle,
+ decreasing_rad=bounded_decreasing,
+ increasing_rad=bounded_increasing,
+ circle=circle,
+ )
+ return result
+
+
+# Backwards-compatible aliases keep the existing left-hand API stable.
+VIEW_TAGS = LEFT_HAND_PROFILE.view_tags
+JOINT_SPECS = LEFT_HAND_PROFILE.joint_specs
+SWEEP_SPECS = LEFT_HAND_PROFILE.sweep_specs
+MEASURED_JOINTS = LEFT_HAND_PROFILE.measured_joints
+ACTIVE_JOINTS = LEFT_HAND_PROFILE.active_joints
+PASSIVE_JOINTS = LEFT_HAND_PROFILE.passive_joints
+SPLAY_JOINTS: tuple[str, ...] = (
+ "index_mcp_roll",
+ "middle_mcp_roll",
+ "ring_mcp_roll",
+ "pinky_mcp_roll",
+)
+
+# These directly actuated motions have a fixed parent link during their sweep
+# and a motion plane that is close to the active camera's image plane. Their
+# projected circles are substantially more repeatable than the difference of
+# two independently estimated planar-Tag PnP depths. thumb_ip remains projected
+# because its front-view PnP depth is not repeatable enough for a 3-D fit. The
+# side-view index_dip and oblique top-view yaw remain parent-relative full
+# SE(3) measurements; their circle directions are constrained by the more
+# repeatable relative-orientation screw axis in urdf_zero.py.
+IMAGE_TRAJECTORY_JOINTS = LEFT_HAND_PROFILE.image_trajectory_joints
+
+# Keep the three unmeasured finger-roll motors away from the front camera's
+# line of sight while index_mcp_roll is measured.
+INDEX_ROLL_CLEARANCE_COMMANDS = LEFT_HAND_PROFILE.roll_clearance_commands
+
+# Hold thumb CMC roll at a camera-friendly pose while thumb CMC yaw is
+# measured. This keeps the moving top-view tag sufficiently front-facing.
+THUMB_YAW_CLEARANCE_COMMANDS: dict[int, int] = {
+ 5: 145,
+}
+
+
+def calibration_auxiliary_commands(
+ spec: SweepSpec,
+ *,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+) -> dict[int, int]:
+ """Return motors that must remain fixed throughout one calibration task."""
+ if spec.auxiliary_commands:
+ return {int(index): int(value) for index, value in spec.auxiliary_commands}
+ if spec.motor_index == 0:
+ return dict(profile.thumb_pitch_clearance_commands)
+ if spec.motor_index == profile.reference_roll_motor:
+ return dict(profile.roll_clearance_commands)
+ if spec.motor_index == 10:
+ return dict(THUMB_YAW_CLEARANCE_COMMANDS)
+ return {}
+
+
+def build_full_hand_command(
+ motor_index: int,
+ command_u8: int,
+ baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+) -> list[int]:
+ if len(baseline) != 20:
+ raise ValueError("baseline must contain exactly 20 values")
+ motor = int(motor_index)
+ if motor not in {spec.motor_index for spec in profile.joint_specs.values()}:
+ raise ValueError("motor_index is not a controlled G20 calibration channel")
+ command = int(command_u8)
+ if not 0 <= command <= 255:
+ raise ValueError("command_u8 must be in [0, 255]")
+ result = [int(value) for value in baseline]
+ if any(not 0 <= value <= 255 for value in result):
+ raise ValueError("baseline values must be in [0, 255]")
+ result[motor] = command
+ return result
+
+
+def build_calibration_motion_command(
+ spec: SweepSpec,
+ command_u8: int,
+ baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+) -> list[int]:
+ """Build a sweep command, including any required clearance pose."""
+ result = build_full_hand_command(
+ spec.motor_index,
+ command_u8,
+ baseline=baseline,
+ profile=profile,
+ )
+ for motor_index, auxiliary_command in calibration_auxiliary_commands(
+ spec, profile=profile
+ ).items():
+ result[motor_index] = auxiliary_command
+ return result
+
+
+def _append_single_motor_waypoint(
+ waypoints: list[tuple[int, ...]], current: list[int], motor: int, value: int
+) -> None:
+ target = int(value)
+ if current[int(motor)] == target:
+ return
+ current[int(motor)] = target
+ waypoints.append(tuple(current))
+
+
+def build_calibration_preparation_waypoints(
+ spec: SweepSpec,
+ command_u8: int,
+ *,
+ current_command: Sequence[int],
+ baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+ parallel: bool = True,
+) -> tuple[tuple[int, ...], ...]:
+ """Enter a task pose in the reviewed safe order.
+
+ ``parallel`` merges the sequential entry into joint-class groups: all
+ neighbouring roll axes open together, then the clearance fingers flex
+ their PIPs together, then their MCP pitches, then any remaining auxiliary
+ motors, and the measured roll channel moves last. Every roll still opens
+ before any finger flexes, each finger's PIP still flexes before its
+ pitch, and the roll targets are unchanged, so no axis is forced across
+ its coupled mechanical boundary.
+ """
+ if len(current_command) != 20:
+ raise ValueError("current_command must contain exactly 20 values")
+ final = build_calibration_motion_command(
+ spec, command_u8, baseline=baseline, profile=profile
+ )
+ if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ return (tuple(final),)
+ current = [int(np.clip(round(float(value)), 0, 255)) for value in current_command]
+ controlled_motors = {
+ int(joint.motor_index) for joint in profile.joint_specs.values()
+ }
+ # G20 feedback channels 11..14 are reserved and remain zero in the SDK.
+ # Preserve their reported values in transition vectors; waiting for the
+ # nominal 255 baseline on a non-existent actuator would deadlock before the
+ # first real motor is allowed to move.
+ for motor in range(20):
+ if motor not in controlled_motors:
+ final[motor] = current[motor]
+ auxiliary = calibration_auxiliary_commands(spec, profile=profile)
+ finger_order = ("pinky", "ring", "middle", "index")
+ handled: set[int] = set()
+ capture_views = {
+ profile.record_specs[joint_name].view for joint_name in spec.joints
+ }
+ target_roll_motor: int | None = None
+ lower_roll_motors: list[int] = []
+ if "side" in capture_views:
+ target_finger = str(spec.joints[0]).split("_", 1)[0]
+ target_roll_motor = _MOTOR_BY_JOINT[f"{target_finger}_mcp_roll"]
+ lower_roll_motors = list(range(9, target_roll_motor, -1))
+ if parallel:
+ roll_group: list[int] = []
+ pip_group: list[int] = []
+ pitch_group: list[int] = []
+ if target_roll_motor is not None:
+ for motor in range(6, target_roll_motor):
+ if auxiliary.get(motor) == 255:
+ roll_group.append(motor)
+ handled.add(motor)
+ for roll_motor in lower_roll_motors:
+ roll_group.append(roll_motor)
+ handled.add(roll_motor)
+ finger = finger_order[9 - roll_motor]
+ pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
+ pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
+ if auxiliary.get(pip_motor) == 0:
+ pip_group.append(pip_motor)
+ handled.add(pip_motor)
+ if auxiliary.get(pitch_motor) == 0:
+ pitch_group.append(pitch_motor)
+ handled.add(pitch_motor)
+ other_group = [
+ motor
+ for motor in sorted(controlled_motors | set(auxiliary))
+ if motor not in handled
+ and motor != spec.motor_index
+ and current[motor] != final[motor]
+ ]
+ waypoints: list[tuple[int, ...]] = []
+ _append_class_waypoint(waypoints, current, final, roll_group)
+ _append_class_waypoint(waypoints, current, final, pip_group)
+ _append_class_waypoint(waypoints, current, final, pitch_group)
+ _append_class_waypoint(waypoints, current, final, other_group)
+ # The measured channel moves last, after the swept volume is clear.
+ _append_single_motor_waypoint(
+ waypoints, current, spec.motor_index, final[spec.motor_index]
+ )
+ return tuple(waypoints or [tuple(final)])
+ waypoints = []
+ if "side" in capture_views:
+ # Side-view clearance never forces a neighbouring roll axis across its
+ # coupled mechanical boundary. Real sessions showed neighbouring
+ # roll axes saturating at feedback 64 (motor 9) and 58 (motor 8) when
+ # commanded to 0 in a coupled avoidance pose. Keep those roll axes at
+ # neutral 127 and remove occlusion only by flexing the neighbour.
+ for motor in range(6, target_roll_motor):
+ if auxiliary.get(motor) == 255:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, final[motor]
+ )
+ handled.add(motor)
+ for roll_motor in lower_roll_motors:
+ if roll_motor in auxiliary:
+ _append_single_motor_waypoint(
+ waypoints, current, roll_motor, final[roll_motor]
+ )
+ handled.add(roll_motor)
+ finger = finger_order[9 - roll_motor]
+ pip_motor = _MOTOR_BY_JOINT[f"{finger}_pip"]
+ pitch_motor = _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"]
+ # Once the finger has rolled away from its neighbour, flex it out
+ # of the camera line of sight using the reviewed PIP->pitch order.
+ if auxiliary.get(pip_motor) == 0:
+ _append_single_motor_waypoint(
+ waypoints, current, pip_motor, final[pip_motor]
+ )
+ handled.add(pip_motor)
+ if auxiliary.get(pitch_motor) == 0:
+ _append_single_motor_waypoint(
+ waypoints, current, pitch_motor, final[pitch_motor]
+ )
+ handled.add(pitch_motor)
+ # Move the measured roll only after its neighbours have flexed out of
+ # its swept volume. Pitch/PIP tasks leave this roll neutral throughout.
+ if target_roll_motor == spec.motor_index:
+ _append_single_motor_waypoint(
+ waypoints,
+ current,
+ target_roll_motor,
+ final[target_roll_motor],
+ )
+ handled.add(target_roll_motor)
+ for motor in sorted(auxiliary):
+ if motor not in handled and motor != spec.motor_index:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, final[motor]
+ )
+ if spec.motor_index not in handled:
+ _append_single_motor_waypoint(
+ waypoints, current, spec.motor_index, final[spec.motor_index]
+ )
+ if not waypoints or waypoints[-1] != tuple(final):
+ # Normally every difference has already been visited. Keep this
+ # assertion-style fallback explicit so a future profile field cannot
+ # silently bypass the one-motor transition policy.
+ differing = [
+ index for index, (left, right) in enumerate(zip(current, final))
+ if index in controlled_motors and left != right
+ ]
+ for motor in differing:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, final[motor]
+ )
+ return tuple(waypoints or [tuple(final)])
+
+
+def _append_class_waypoint(
+ waypoints: list[tuple[int, ...]],
+ current: list[int],
+ target: Sequence[int],
+ motors: Iterable[int],
+) -> None:
+ """Move one joint-class group of motors in a single parallel waypoint."""
+ moved = False
+ for motor in motors:
+ if current[int(motor)] != target[int(motor)]:
+ current[int(motor)] = target[int(motor)]
+ moved = True
+ if moved:
+ waypoints.append(tuple(current))
+
+
+def build_calibration_return_waypoints(
+ target_command: Sequence[int],
+ *,
+ current_command: Sequence[int],
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+ anchor_roll_motor: int | None = None,
+ parallel: bool = True,
+) -> tuple[tuple[int, ...], ...]:
+ """Leave a product-layout avoidance pose using the inverse safe sequence.
+
+ ``parallel`` merges the reviewed sequential order into joint-class groups:
+ all differing rolls return to neutral together, then all differing MCP
+ pitches, then all differing PIPs, then the remaining controlled motors.
+ Every finger still unfolds only after every roll is back at neutral, each
+ finger's pitch still moves before its PIP, and simultaneous same-direction
+ roll motion removes the "one finger sweeps across parked neighbours"
+ stall scenario that motivated the anchored one-motor sequence.
+ """
+ if len(current_command) != 20 or len(target_command) != 20:
+ raise ValueError("return commands must contain exactly 20 values")
+ target = [int(value) for value in target_command]
+ if profile.layout_id != G20_RIGHT_19_LAYOUT:
+ return (tuple(target),)
+ current = [int(np.clip(round(float(value)), 0, 255)) for value in current_command]
+ controlled_motors = {
+ int(joint.motor_index) for joint in profile.joint_specs.values()
+ }
+ for motor in range(20):
+ if motor not in controlled_motors:
+ target[motor] = current[motor]
+ if parallel:
+ waypoints: list[tuple[int, ...]] = []
+ _append_class_waypoint(waypoints, current, target, range(6, 10))
+ _append_class_waypoint(waypoints, current, target, range(1, 5))
+ _append_class_waypoint(waypoints, current, target, range(16, 20))
+ _append_class_waypoint(
+ waypoints,
+ current,
+ target,
+ sorted(
+ motor
+ for motor in controlled_motors
+ if motor not in set(range(6, 10))
+ | set(range(1, 5))
+ | set(range(16, 20))
+ ),
+ )
+ return tuple(waypoints or [tuple(target)])
+ waypoints = []
+ handled: set[int] = set()
+ # All four roll channels return to neutral before any bent clearance
+ # finger is unfolded. In the reviewed product sequence the current/next
+ # target finger must move first, then neighbouring fingers move outward.
+ # Returning index first from an all-255 pinky task makes it converge into
+ # three still-parked fingers and can mechanically stall around command175.
+ roll_order: Sequence[int] = tuple(range(6, 10))
+ anchor = None if anchor_roll_motor is None else int(anchor_roll_motor)
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ if anchor in range(6, 10):
+ roll_order = tuple(
+ sorted(
+ range(6, 10),
+ key=lambda motor: (abs(motor - anchor), -motor),
+ )
+ )
+ else:
+ roll_order = tuple(reversed(range(6, 10)))
+ for motor in roll_order:
+ if current[motor] != target[motor]:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, target[motor]
+ )
+ handled.add(motor)
+ # Reverse the entry order, one finger at a time: roll, pitch, then PIP.
+ for finger in ("index", "middle", "ring", "pinky"):
+ for motor in (
+ _MOTOR_BY_JOINT[f"{finger}_mcp_pitch"],
+ _MOTOR_BY_JOINT[f"{finger}_pip"],
+ ):
+ if current[motor] != target[motor]:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, target[motor]
+ )
+ handled.add(motor)
+ for motor in sorted(controlled_motors):
+ if motor not in handled and current[motor] != target[motor]:
+ _append_single_motor_waypoint(
+ waypoints, current, motor, target[motor]
+ )
+ return tuple(waypoints or [tuple(target)])
+
+
+def build_calibration_speed_profile(
+ spec: SweepSpec,
+ *,
+ normal_speed: int,
+ index_roll_speed: int,
+ index_flex_speed: int,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+) -> list[int]:
+ """Return G20's five per-finger speeds for one calibration task."""
+ normal = int(normal_speed)
+ index_roll = int(index_roll_speed)
+ index_flex = int(index_flex_speed)
+ if not all(
+ 0 <= speed <= 255 for speed in (normal, index_roll, index_flex)
+ ):
+ raise ValueError("calibration speeds must be in [0, 255]")
+ speeds = [normal] * 5
+ if spec.motor_index in range(6, 10):
+ speeds[spec.motor_index - 5] = index_roll
+ elif spec.motor_index in range(1, 5):
+ speeds[spec.motor_index] = index_flex
+ elif spec.motor_index in range(16, 20):
+ speeds[spec.motor_index - 15] = index_flex
+ return speeds
+
+
+def _record_vector(record: Mapping[str, Any]) -> np.ndarray:
+ value = np.asarray(record.get("relative_translation_xyz_m"), dtype=float)
+ if value.shape != (3,) or not np.all(np.isfinite(value)):
+ raise ValueError("record relative_translation_xyz_m must contain 3 values")
+ return value
+
+
+def _record_image_vector(record: Mapping[str, Any]) -> np.ndarray:
+ value = np.asarray(record.get("image_relative_xy_px"), dtype=float)
+ if value.shape != (2,) or not np.all(np.isfinite(value)):
+ raise ValueError("record image_relative_xy_px must contain 2 values")
+ return value
+
+
+def fit_joint_center_curve(
+ records: Sequence[Mapping[str, Any]],
+ *,
+ maximum_plane_rms_m: float = 0.004,
+ maximum_radial_rms_m: float = 0.004,
+ minimum_radius_m: float = 0.003,
+ minimum_arc_rad: float = math.radians(15.0),
+) -> JointCurveFit:
+ """Fit one command-indexed curve from a parent-frame centre trajectory."""
+ samples = [dict(record) for record in records]
+ if len(samples) < 12:
+ raise ValueError("joint trajectory requires at least 12 samples")
+ points = np.asarray([_record_vector(record) for record in samples])
+ axis, common_plane_rms = _fit_plane_axis([points])
+ circle = _fit_circle_with_axis(points, axis)
+ circle = _orient_circle_positive(circle, samples, list(points))
+ values = [_angle_for_circle(point, circle) for point in points]
+ curves, correction, hysteresis = _fit_joint_curve(samples, values)
+ quality = {
+ "plane_rms_m": max(
+ float(common_plane_rms), float(circle["plane_rms_m"])
+ ),
+ "radial_rms_m": float(circle["radial_rms_m"]),
+ "radius_m": float(circle["radius_m"]),
+ "arc_rad": float(circle["observed_arc_rad"]),
+ }
+ failures: list[str] = []
+ if quality["plane_rms_m"] > float(maximum_plane_rms_m):
+ failures.append("plane_rms")
+ if quality["radial_rms_m"] > float(maximum_radial_rms_m):
+ failures.append("radial_rms")
+ if quality["radius_m"] < float(minimum_radius_m):
+ failures.append("radius")
+ if quality["arc_rad"] < float(minimum_arc_rad):
+ failures.append("arc")
+ if failures:
+ raise ValueError("joint_trajectory_quality_failed:" + ",".join(failures))
+ 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=dict(circle),
+ maximum_monotonic_correction_rad=float(correction),
+ maximum_hysteresis_rad=float(hysteresis),
+ quality=quality,
+ )
+
+
+def _signed_image_circle_angle(
+ point_xy_px: Sequence[float], circle: Mapping[str, Any]
+) -> float:
+ point = np.asarray(point_xy_px, dtype=float)
+ centre = np.asarray(circle["center_xy_px"], dtype=float)
+ reference = np.asarray(circle["reference_xy_px"], dtype=float)
+ vector = point - centre
+ if point.shape != (2,) or not np.all(np.isfinite(point)):
+ raise ValueError("image point must contain 2 finite values")
+ if float(np.linalg.norm(vector)) < 1.0e-9:
+ raise ValueError("image point lies at the fitted circle centre")
+ return float(circle["orientation_sign"]) * math.atan2(
+ float(reference[0] * vector[1] - reference[1] * vector[0]),
+ float(np.dot(reference, vector)),
+ )
+
+
+def fit_joint_image_curve(
+ records: Sequence[Mapping[str, Any]],
+ *,
+ maximum_radial_rms_px: float = 2.0,
+ maximum_radial_p95_px: float = 3.5,
+ minimum_radius_px: float = 20.0,
+ minimum_arc_rad: float = math.radians(15.0),
+) -> JointCurveFit:
+ """Fit angular phase from a stable projected parent/child circle."""
+ samples = [dict(record) for record in records]
+ if len(samples) < 12:
+ raise ValueError("joint image trajectory requires at least 12 samples")
+ points = np.asarray([_record_image_vector(record) for record in samples])
+ centre, radius = _fit_circle(points)
+ radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
+ quality = {
+ "radial_rms_px": float(np.sqrt(np.mean(np.square(radial_error)))),
+ "radial_p95_px": float(np.percentile(radial_error, 95.0)),
+ "radius_px": float(radius),
+ "arc_rad": float(_trajectory_arc_rad(points, centre)),
+ }
+ failures: list[str] = []
+ if quality["radial_rms_px"] > float(maximum_radial_rms_px):
+ failures.append("radial_rms")
+ if quality["radial_p95_px"] > float(maximum_radial_p95_px):
+ failures.append("radial_p95")
+ if quality["radius_px"] < float(minimum_radius_px):
+ failures.append("radius")
+ if quality["arc_rad"] < float(minimum_arc_rad):
+ failures.append("arc")
+ if failures:
+ raise ValueError(
+ "joint_image_trajectory_quality_failed:" + ",".join(failures)
+ )
+
+ endpoint_points = np.asarray(
+ [
+ point
+ for record, point in zip(samples, points)
+ if int(record["command_u8"]) == 255
+ ]
+ )
+ if endpoint_points.size == 0:
+ raise ValueError("joint image trajectory is missing command 255")
+ reference = np.median(endpoint_points, axis=0) - centre
+ reference_norm = float(np.linalg.norm(reference))
+ if reference_norm < 1.0e-9:
+ raise ValueError("joint image trajectory endpoint is degenerate")
+ reference /= reference_norm
+ circle: dict[str, Any] = {
+ "space": "image_2d",
+ "center_xy_px": [float(value) for value in centre],
+ "reference_xy_px": [float(value) for value in reference],
+ "orientation_sign": 1.0,
+ "radius_px": float(radius),
+ }
+ values = [_signed_image_circle_angle(point, circle) for point in points]
+ command_zero_values = [
+ value
+ for record, value in zip(samples, values)
+ if int(record["command_u8"]) == 0
+ ]
+ if not command_zero_values:
+ raise ValueError("joint image trajectory is missing command 0")
+ if float(np.median(command_zero_values)) < 0.0:
+ circle["orientation_sign"] = -1.0
+ values = [-value for value in values]
+ curves, correction, hysteresis = _fit_joint_curve(samples, values)
+ 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=circle,
+ maximum_monotonic_correction_rad=float(correction),
+ maximum_hysteresis_rad=float(hysteresis),
+ quality=quality,
+ )
+
+
+def fit_measured_joint_curve(
+ joint_name: str,
+ records: Sequence[Mapping[str, Any]],
+ *,
+ maximum_plane_rms_m: float = 0.004,
+ maximum_radial_rms_m: float = 0.004,
+ minimum_radius_m: float = 0.003,
+ minimum_arc_rad: float = math.radians(15.0),
+ image_maximum_radial_rms_px: float = 2.0,
+ image_maximum_radial_p95_px: float = 3.5,
+ image_minimum_radius_px: float = 20.0,
+ profile: HandCalibrationProfile = LEFT_HAND_PROFILE,
+) -> JointCurveFit:
+ """Select the stable trajectory representation for one measured joint."""
+ if str(joint_name) in profile.image_trajectory_joints:
+ return fit_joint_image_curve(
+ records,
+ maximum_radial_rms_px=image_maximum_radial_rms_px,
+ maximum_radial_p95_px=image_maximum_radial_p95_px,
+ minimum_radius_px=image_minimum_radius_px,
+ minimum_arc_rad=minimum_arc_rad,
+ )
+ return fit_joint_center_curve(
+ records,
+ maximum_plane_rms_m=maximum_plane_rms_m,
+ maximum_radial_rms_m=maximum_radial_rms_m,
+ minimum_radius_m=minimum_radius_m,
+ minimum_arc_rad=minimum_arc_rad,
+ )
+
+
+def center_splay_curve(fit: JointCurveFit) -> tuple[JointCurveFit, int, float]:
+ """Centre a measured 255->0 splay travel on its angular midpoint."""
+ raw = np.asarray(fit.angle_rad, dtype=float)
+ if raw.shape != (256,) or not np.all(np.isfinite(raw)):
+ raise ValueError("splay curve must contain 256 finite values")
+ if np.any(np.diff(raw) > 1.0e-7):
+ raise ValueError("raw splay curve must be non-increasing")
+ travel_midpoint = 0.5 * float(raw[0] + raw[255])
+ if travel_midpoint <= 0.0:
+ raise ValueError("splay travel must be positive")
+ centred = raw - travel_midpoint
+ # Make the public endpoint symmetry exact after decimal rounding.
+ endpoint = round(0.5 * float(raw[0] - raw[255]), 8)
+ centred[0] = endpoint
+ centred[255] = -endpoint
+ centred = np.asarray([round(float(value), 8) for value in centred])
+ zero_command = int(np.argmin(np.abs(centred)))
+ return (
+ replace(
+ fit,
+ angle_rad=tuple(float(value) for value in centred),
+ zero_offset_rad=round(float(travel_midpoint), 8),
+ ),
+ zero_command,
+ round(float(travel_midpoint), 8),
+ )
+
+
+def measure_joint_vector(fit: JointCurveFit, vector_xyz_m: Sequence[float]) -> float:
+ """Measure one vector with a fitted model, including splay centring."""
+ return float(
+ _angle_for_circle(vector_xyz_m, fit.circle) - fit.zero_offset_rad
+ )
+
+
+def measure_joint_observation(
+ fit: JointCurveFit,
+ *,
+ vector_xyz_m: Sequence[float],
+ image_vector_xy_px: Sequence[float],
+) -> float:
+ """Measure an observation in the same space used to fit its curve."""
+ if fit.circle.get("space") == "image_2d":
+ return float(
+ _signed_image_circle_angle(image_vector_xy_px, fit.circle)
+ - fit.zero_offset_rad
+ )
+ return measure_joint_vector(fit, vector_xyz_m)
+
+
+def compare_cross_view_roll_curves(
+ primary: JointCurveFit,
+ secondary: JointCurveFit,
+ *,
+ maximum_rms_difference_rad: float = math.radians(1.0),
+ maximum_branch_gap_difference_rad: float = math.radians(0.3),
+ allow_projection_scale: bool = False,
+ maximum_projection_scale_ratio: float = 1.5,
+) -> dict[str, float]:
+ """Validate two independently fitted roll curves before axis fusion.
+
+ The G20 right roll primary is an image-plane centre trajectory while its
+ side validation is a 3-D rotation trajectory. A fixed Tag mounting tilt
+ changes the image-plane angular scale without changing the mechanism. In
+ projection-scale mode, remove that one constant scale before comparing
+ curve shape. Direction, scale bounds, direction-dependent branch gap and
+ every single-view quality/holdout gate remain independent hard checks.
+ """
+ if maximum_rms_difference_rad <= 0.0:
+ raise ValueError("cross-view RMS limit must be positive")
+ if maximum_branch_gap_difference_rad <= 0.0:
+ raise ValueError("cross-view branch-gap limit must be positive")
+ if maximum_projection_scale_ratio <= 1.0:
+ raise ValueError("cross-view projection-scale ratio must exceed one")
+
+ fields: dict[str, tuple[np.ndarray, np.ndarray]] = {}
+ for field_name in ("angle_rad", "decreasing_rad", "increasing_rad"):
+ left = np.asarray(getattr(primary, field_name), dtype=float)
+ right = np.asarray(getattr(secondary, field_name), dtype=float)
+ if left.shape != (256,) or right.shape != (256,):
+ raise ValueError("cross-view curves must contain 256 values")
+ if not np.all(np.isfinite(left)) or not np.all(np.isfinite(right)):
+ raise ValueError("cross-view curves must be finite")
+ fields[field_name] = (left, right)
+
+ primary_travel = float(
+ fields["angle_rad"][0][0] - fields["angle_rad"][0][255]
+ )
+ secondary_travel = float(
+ fields["angle_rad"][1][0] - fields["angle_rad"][1][255]
+ )
+ if primary_travel * secondary_travel <= 0.0:
+ raise ValueError("cross_view_roll_curve_direction_disagrees")
+
+ primary_zero = int(primary.circle.get("zero_command_u8", 127))
+ secondary_zero = int(secondary.circle.get("zero_command_u8", 127))
+ if not 0 <= primary_zero <= 255 or not 0 <= secondary_zero <= 255:
+ raise ValueError("cross-view zero command must be in [0, 255]")
+ primary_anchor = float(primary.decreasing_rad[primary_zero])
+ secondary_anchor = float(secondary.decreasing_rad[secondary_zero])
+ projection_scale = 1.0
+ if allow_projection_scale:
+ primary_values = np.concatenate(
+ [left - primary_anchor for left, _ in fields.values()]
+ )
+ secondary_values = np.concatenate(
+ [right - secondary_anchor for _, right in fields.values()]
+ )
+ denominator = float(secondary_values @ secondary_values)
+ if denominator <= np.finfo(float).eps:
+ raise ValueError("cross_view_roll_projection_scale_unobservable")
+ projection_scale = float(
+ (primary_values @ secondary_values) / denominator
+ )
+ if projection_scale <= 0.0 or not math.isfinite(projection_scale):
+ raise ValueError("cross_view_roll_curve_direction_disagrees")
+ scale_ratio = max(projection_scale, 1.0 / projection_scale)
+ if scale_ratio > float(maximum_projection_scale_ratio):
+ raise ValueError(
+ "cross_view_roll_projection_scale_ratio_too_large:"
+ f"{scale_ratio:.6f}"
+ )
+
+ metrics: dict[str, float] = {
+ "projection_scale": projection_scale,
+ "projection_scale_ratio": max(
+ projection_scale, 1.0 / projection_scale
+ ),
+ }
+ for field_name, (left, right) in fields.items():
+ metrics[f"raw_{field_name}_rms_difference_rad"] = float(
+ np.sqrt(np.mean(np.square(left - right)))
+ )
+ if allow_projection_scale:
+ left = left - primary_anchor
+ right = projection_scale * (right - secondary_anchor)
+ rms = float(np.sqrt(np.mean(np.square(left - right))))
+ metrics[f"{field_name}_rms_difference_rad"] = rms
+ if rms > maximum_rms_difference_rad:
+ raise ValueError(
+ "cross_view_roll_curve_difference_too_large:"
+ f"{field_name}:{math.degrees(rms):.6f}deg"
+ )
+ metrics["travel_difference_rad"] = abs(primary_travel - secondary_travel)
+ primary_gap = float(
+ primary.increasing_rad[primary_zero]
+ - primary.decreasing_rad[primary_zero]
+ )
+ secondary_gap = float(
+ secondary.increasing_rad[secondary_zero]
+ - secondary.decreasing_rad[secondary_zero]
+ )
+ metrics["raw_baseline_branch_gap_difference_rad"] = abs(
+ primary_gap - secondary_gap
+ )
+ branch_gap_difference = abs(
+ primary_gap - projection_scale * secondary_gap
+ )
+ metrics["baseline_branch_gap_difference_rad"] = branch_gap_difference
+ # In projection-scale mode the primary is an image-circle phase while the
+ # secondary is a 3-D rotation angle. A fixed Tag tilt/offset changes the
+ # image projection of the two motion branches independently, so their
+ # absolute gap is not a cross-view invariant. Both views retain their
+ # own hard absolute-gap and four-cycle gap-range checks in the acquisition
+ # quality gate. Keep this value as a diagnostic, but apply the cross-view
+ # absolute-gap gate only when both curves live in comparable coordinates.
+ metrics["baseline_branch_gap_gate_applied"] = float(
+ not allow_projection_scale
+ )
+ if (
+ not allow_projection_scale
+ and branch_gap_difference > maximum_branch_gap_difference_rad
+ ):
+ raise ValueError(
+ "cross_view_roll_branch_gap_difference_too_large:"
+ f"{math.degrees(branch_gap_difference):.6f}deg"
+ )
+ return metrics
+
+
+def cross_view_roll_diagnostic_metrics(
+ primary: JointCurveFit,
+ secondary: JointCurveFit,
+) -> dict[str, float]:
+ """Compare a validation-only roll view without creating a retry gate.
+
+ G20-right publishes the front image-plane roll curve. The side PIP-link
+ observation supplies an independently quality-gated physical axis-line
+ position, but its angle includes projection and linkage effects and is
+ therefore diagnostic rather than a second measurement of the published
+ curve. Live fitting and offline replay must use the same non-blocking
+ comparison; otherwise a stable view bias can recall an already complete
+ task even though reacquiring the same motion cannot change the result.
+ """
+ try:
+ return compare_cross_view_roll_curves(
+ primary,
+ secondary,
+ maximum_rms_difference_rad=float("inf"),
+ maximum_branch_gap_difference_rad=float("inf"),
+ allow_projection_scale=True,
+ maximum_projection_scale_ratio=float("inf"),
+ )
+ except ValueError as error:
+ if str(error) == "cross_view_roll_curve_direction_disagrees":
+ return {"direction_disagrees": 1.0}
+ raise
+
+
+def fit_projected_zero(
+ records: Sequence[Mapping[str, Any]],
+ *,
+ minimum_radius_px: float = 20.0,
+ minimum_arc_rad: float = math.radians(15.0),
+ maximum_radial_rms_px: float = 2.0,
+ maximum_radial_p95_px: float = 3.5,
+ maximum_round_difference_rad: float = math.radians(1.0),
+) -> float:
+ """Return the image/table-projected zero from three sweep rounds."""
+ samples = [dict(record) for record in records]
+ points = np.asarray(
+ [record.get("image_relative_xy_px") for record in samples], dtype=float
+ )
+ if points.ndim != 2 or points.shape[1] != 2 or len(points) < 12:
+ raise ValueError("projected zero requires 2-D trajectory samples")
+ if not np.all(np.isfinite(points)):
+ raise ValueError("projected zero samples must be finite")
+ centre, radius = _fit_circle(points)
+ radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
+ radial_rms = float(np.sqrt(np.mean(np.square(radial_error))))
+ radial_p95 = float(np.percentile(radial_error, 95.0))
+ arc = _trajectory_arc_rad(points, centre)
+ if radius < minimum_radius_px:
+ raise ValueError("projected_zero_radius_too_small")
+ if arc < minimum_arc_rad:
+ raise ValueError("projected_zero_arc_too_small")
+ if radial_rms > maximum_radial_rms_px:
+ raise ValueError("projected_zero_radial_rms_too_large")
+ if radial_p95 > maximum_radial_p95_px:
+ raise ValueError("projected_zero_radial_p95_too_large")
+
+ cycles = sorted({int(record["cycle"]) for record in samples})
+ if len(cycles) < 3:
+ raise ValueError("projected zero requires three sweep rounds")
+ angles: list[float] = []
+ for cycle in cycles:
+ zero_points = np.asarray(
+ [
+ record["image_relative_xy_px"]
+ for record in samples
+ if int(record["cycle"]) == cycle
+ and int(record["command_u8"]) == 255
+ ],
+ dtype=float,
+ )
+ if zero_points.size == 0:
+ raise ValueError("projected zero is missing command 255")
+ point = np.median(zero_points, axis=0)
+ inward = centre - point
+ if float(np.linalg.norm(inward)) < 1.0e-9:
+ raise ValueError("projected zero point is degenerate")
+ angles.append(
+ wrap_angle_rad(math.atan2(-float(inward[1]), float(inward[0])))
+ )
+ if maximum_pairwise_angle_difference_rad(angles) > maximum_round_difference_rad:
+ raise ValueError("projected_zero_round_difference_too_large")
+ return round(float(circular_median_rad(angles)), 8)
+
+
+def build_compact_payload(
+ *,
+ serial_number: str,
+ measured_fits: Mapping[str, JointCurveFit],
+ urdf_zero_offsets_rad: Mapping[str, float],
+ validation_errors_rad: Sequence[float],
+ passed: bool,
+ baseline: Sequence[int] = THREE_CAMERA_BASELINE_COMMAND,
+ side: str = "left",
+ layout_id: str = "legacy_11",
+ zero_uncertainty_rad: Mapping[str, float] | None = None,
+ zero_cycle_offsets_rad: Mapping[str, Sequence[float]] | None = None,
+ zero_observers: Mapping[str, str] | None = None,
+ artifact_hashes: Mapping[str, str] | None = None,
+ cross_view_roll_metrics: Mapping[str, Mapping[str, float]] | None = None,
+ joint_dynamic_diagnostics: Mapping[str, Mapping[str, Any]] | None = None,
+ zero_geometry_diagnostics: Mapping[str, Any] | None = None,
+) -> dict[str, Any]:
+ profile = get_hand_calibration_profile(side, layout_id)
+ expected_fits = (
+ set(profile.joint_specs)
+ if profile.layout_id == G20_RIGHT_19_LAYOUT
+ else set(profile.measured_joints)
+ )
+ if set(measured_fits) != expected_fits:
+ raise ValueError("measured_fits has the wrong runtime joint set")
+ expected_active = {
+ name for name, spec in profile.joint_specs.items() if spec.active
+ }
+ if set(urdf_zero_offsets_rad) != expected_active:
+ raise ValueError("urdf_zero_offsets_rad has the wrong active-joint set")
+ if len(baseline) != 20:
+ raise ValueError("baseline must contain exactly 20 commands")
+
+ if profile.layout_id == G20_RIGHT_19_LAYOUT:
+ return _build_right_19_compact_payload(
+ serial_number=serial_number,
+ measured_fits=measured_fits,
+ urdf_zero_offsets_rad=urdf_zero_offsets_rad,
+ validation_errors_rad=validation_errors_rad,
+ passed=passed,
+ baseline=baseline,
+ profile=profile,
+ zero_uncertainty_rad=zero_uncertainty_rad or {},
+ zero_cycle_offsets_rad=zero_cycle_offsets_rad or {},
+ zero_observers=zero_observers or {},
+ artifact_hashes=artifact_hashes or {},
+ cross_view_roll_metrics=cross_view_roll_metrics or {},
+ joint_dynamic_diagnostics=joint_dynamic_diagnostics or {},
+ zero_geometry_diagnostics=zero_geometry_diagnostics or {},
+ )
+
+ joints: dict[str, dict[str, Any]] = {}
+ for name, spec in profile.joint_specs.items():
+ source_name = spec.source_joint or name
+ fit = measured_fits[source_name]
+ joint: dict[str, Any] = {
+ "motor_index": int(spec.motor_index),
+ "angle_rad": [round(float(value), 8) for value in fit.angle_rad],
+ }
+ if spec.active:
+ joint["zero_command_u8"] = int(baseline[spec.motor_index])
+ joint["zero_angles"] = {
+ "urdf_zero_offset_rad": round(
+ float(urdf_zero_offsets_rad[name]), 8
+ )
+ }
+ else:
+ joint["passive"] = True
+ if spec.source_joint is not None:
+ joint["source_joint"] = spec.source_joint
+ joints[name] = joint
+
+ errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
+ mae = float(np.mean(errors)) if errors.size else float("nan")
+ p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
+ payload = {
+ "schema_version": 4,
+ "model": "G20",
+ "side": profile.side,
+ "serial_number": str(serial_number),
+ "angle_unit": "rad",
+ "command_range": [0, 255],
+ "baseline_command_u8": [int(value) for value in baseline],
+ "joints": joints,
+ "quality": {
+ "passed": bool(passed),
+ "validation_mae_rad": (
+ None if not math.isfinite(mae) else round(mae, 8)
+ ),
+ "validation_p95_rad": (
+ None if not math.isfinite(p95) else round(p95, 8)
+ ),
+ },
+ }
+ validate_compact_payload(payload)
+ return payload
+
+
+def _build_right_19_compact_payload(
+ *,
+ serial_number: str,
+ measured_fits: Mapping[str, JointCurveFit],
+ urdf_zero_offsets_rad: Mapping[str, float],
+ validation_errors_rad: Sequence[float],
+ passed: bool,
+ baseline: Sequence[int],
+ profile: HandCalibrationProfile,
+ zero_uncertainty_rad: Mapping[str, float],
+ zero_cycle_offsets_rad: Mapping[str, Sequence[float]],
+ zero_observers: Mapping[str, str],
+ artifact_hashes: Mapping[str, str],
+ cross_view_roll_metrics: Mapping[str, Mapping[str, float]],
+ joint_dynamic_diagnostics: Mapping[str, Mapping[str, Any]],
+ zero_geometry_diagnostics: Mapping[str, Any],
+) -> dict[str, Any]:
+ # The public runtime artifact intentionally keeps the established schema-v4
+ # contract. Directional curves, uncertainty, hashes and geometry metrics
+ # are calibration diagnostics and belong in calibration_summary_zh.json;
+ # exposing them here made it too easy for a runtime to apply a feedback
+ # curve or a zero offset twice.
+ del (
+ zero_uncertainty_rad,
+ zero_cycle_offsets_rad,
+ zero_observers,
+ artifact_hashes,
+ cross_view_roll_metrics,
+ joint_dynamic_diagnostics,
+ zero_geometry_diagnostics,
+ )
+ joints: dict[str, dict[str, Any]] = {}
+ for name, spec in profile.joint_specs.items():
+ fit = measured_fits[name]
+ zero_command = int(baseline[spec.motor_index])
+ decreasing = np.asarray(fit.decreasing_rad, dtype=float)
+ increasing = np.asarray(fit.increasing_rad, dtype=float)
+ if decreasing.shape != (256,) or increasing.shape != (256,):
+ raise ValueError(f"{name} directional curves must contain 256 values")
+ # A single schema-v4 curve is the steady-state midpoint of both
+ # approach directions. Publication separately rejects a branch gap
+ # above 2 degrees, bounding the midpoint's direction error to 1 degree.
+ curve = 0.5 * (decreasing + increasing)
+ curve -= float(curve[zero_command])
+ joint: dict[str, Any] = {
+ "motor_index": int(spec.motor_index),
+ "angle_rad": [round(float(value), 8) for value in curve],
+ }
+ if spec.active:
+ joint["zero_command_u8"] = zero_command
+ joint["zero_angles"] = {
+ "urdf_zero_offset_rad": round(
+ float(urdf_zero_offsets_rad[name]), 8
+ )
+ }
+ else:
+ joint["passive"] = True
+ joints[name] = joint
+
+ errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
+ mae = float(np.mean(errors)) if errors.size else float("nan")
+ p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
+ payload = {
+ "schema_version": 4,
+ "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],
+ "joints": joints,
+ "quality": {
+ "passed": bool(passed),
+ "validation_mae_rad": (
+ None if not math.isfinite(mae) else round(mae, 8)
+ ),
+ "validation_p95_rad": (
+ None if not math.isfinite(p95) else round(p95, 8)
+ ),
+ },
+ }
+ validate_compact_payload(payload)
+ return payload
+
+
+def infer_compact_payload_layout(payload: Mapping[str, Any]) -> str:
+ """Infer the layout for schema-v4 while honoring explicit newer schemas."""
+ explicit = str(payload.get("layout_id", "")).strip().lower()
+ if explicit:
+ return explicit
+ raw_joints = payload.get("joints", {})
+ independent_right = bool(
+ str(payload.get("side", "")).lower() == "right"
+ and isinstance(raw_joints, Mapping)
+ and raw_joints
+ and all(
+ not isinstance(value, Mapping) or "source_joint" not in value
+ for value in raw_joints.values()
+ )
+ )
+ return G20_RIGHT_19_LAYOUT if independent_right else "legacy_11"
+
+
+def validate_compact_payload(payload: Mapping[str, Any]) -> None:
+ if payload.get("schema_version") == 5:
+ _validate_right_19_compact_payload(payload)
+ return
+ expected_top = {
+ "schema_version", "model", "side", "serial_number", "angle_unit",
+ "command_range", "baseline_command_u8", "joints", "quality",
+ }
+ if set(payload) != expected_top:
+ raise ValueError("compact calibration has unexpected top-level fields")
+ if payload["schema_version"] != 4:
+ raise ValueError("schema_version must be 4")
+ if payload["model"] != "G20" or payload["side"] not in {"left", "right"}:
+ raise ValueError("payload must describe a left or right G20")
+ # schema-v4 has no layout field. A right-hand payload without inherited
+ # source_joint fields is the independent right-hand product layout; legacy
+ # payloads retain their source_joint markers and remain readable.
+ layout_id = infer_compact_payload_layout(payload)
+ independent_right = layout_id == G20_RIGHT_19_LAYOUT
+ profile = get_hand_calibration_profile(
+ str(payload["side"]),
+ layout_id,
+ )
+ if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
+ raise ValueError("payload angle or command units are invalid")
+ baseline = payload["baseline_command_u8"]
+ if not isinstance(baseline, list) or len(baseline) != 20:
+ raise ValueError("baseline_command_u8 must contain 20 values")
+ joints = payload["joints"]
+ if not isinstance(joints, Mapping) or set(joints) != set(profile.joint_specs):
+ raise ValueError("payload must contain exactly 21 G20 joints")
+
+ for name, spec in profile.joint_specs.items():
+ joint = joints[name]
+ allowed = {"motor_index", "angle_rad"}
+ allowed.add("zero_command_u8" if spec.active else "passive")
+ if spec.active:
+ allowed.add("zero_angles")
+ if spec.source_joint is not None and not independent_right:
+ allowed.add("source_joint")
+ if set(joint) != allowed:
+ raise ValueError(f"{name} has unexpected fields")
+ if int(joint["motor_index"]) != spec.motor_index:
+ raise ValueError(f"{name} has the wrong motor_index")
+ curve = np.asarray(joint["angle_rad"], dtype=float)
+ if curve.shape != (256,) or not np.all(np.isfinite(curve)):
+ raise ValueError(f"{name}.angle_rad must contain 256 finite values")
+ if np.any(np.diff(curve) > 1.0e-7):
+ raise ValueError(f"{name}.angle_rad must be non-increasing")
+ if spec.active:
+ zero = joint["zero_command_u8"]
+ if not isinstance(zero, int) or not 0 <= zero <= 255:
+ raise ValueError(f"{name}.zero_command_u8 is invalid")
+ zero_angles = joint.get("zero_angles")
+ if not isinstance(zero_angles, Mapping) or set(zero_angles) != {
+ "urdf_zero_offset_rad"
+ }:
+ raise ValueError(
+ f"{name}.zero_angles must contain urdf_zero_offset_rad"
+ )
+ offset = float(zero_angles["urdf_zero_offset_rad"])
+ if not math.isfinite(offset):
+ raise ValueError(f"{name}.urdf_zero_offset_rad is invalid")
+ if abs(float(curve[zero])) > 1.0e-6:
+ raise ValueError(
+ f"{name}.angle_rad must be zero at zero_command_u8"
+ )
+ elif joint.get("passive") is not True:
+ raise ValueError(f"{name} must be marked passive")
+ if spec.source_joint is not None and not independent_right:
+ if joint.get("source_joint") != spec.source_joint:
+ raise ValueError(f"{name} has the wrong source_joint")
+ source = joints[spec.source_joint]
+ if joint["angle_rad"] != source["angle_rad"]:
+ raise ValueError(f"{name} must copy its source curve exactly")
+ if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]:
+ raise ValueError(f"{name} must copy its source zero command")
+ # source_joint denotes a dynamic command-angle curve source. The
+ # absolute URDF zero belongs to an independent motor/assembly and
+ # must remain per-joint unless it was independently observed.
+
+ quality = payload["quality"]
+ if set(quality) != {
+ "passed", "validation_mae_rad", "validation_p95_rad"
+ }:
+ raise ValueError("quality must contain only compact summary fields")
+
+
+def _validate_right_19_compact_payload(payload: Mapping[str, Any]) -> None:
+ expected_top = {
+ "schema_version", "layout_id", "model", "side", "serial_number",
+ "angle_unit", "command_range", "curve_input_domain",
+ "runtime_curve_policy", "baseline_command_u8",
+ "static_zero_targets", "frozen_static_zero_joints", "joints",
+ "cross_view_roll", "static_zero_validation", "artifacts", "quality",
+ }
+ if set(payload) != expected_top:
+ raise ValueError("schema v5 calibration has unexpected top-level fields")
+ if (
+ payload["layout_id"] not in {"g20_right_19", G20_RIGHT_15_LAYOUT}
+ or payload["model"] != "G20"
+ or payload["side"] != "right"
+ ):
+ raise ValueError("schema v5 must describe a G20 right product layout")
+ profile = get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT)
+ baseline = payload["baseline_command_u8"]
+ if not isinstance(baseline, list) or len(baseline) != 20:
+ raise ValueError("baseline_command_u8 must contain 20 values")
+ if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
+ raise ValueError("payload angle or command units are invalid")
+ if payload["curve_input_domain"] != "feedback_u8":
+ raise ValueError("schema v5 curves must be feedback-indexed")
+ runtime_curve_policy = payload["runtime_curve_policy"]
+ if runtime_curve_policy not in {
+ "direction_aware",
+ "direction_aware_canonical_zero",
+ }:
+ raise ValueError("schema v5 runtime curve policy must be direction-aware")
+ # v5 is a read-only legacy contract from the earlier 15-zero product.
+ # Keep its historical scope stable even though new v4 publications now
+ # calibrate thumb_mcp as the 16th active static zero.
+ static_targets = {
+ name
+ for name, spec in profile.joint_specs.items()
+ if spec.zero_kind == "urdf_axis_chain"
+ } - {"thumb_mcp"}
+ frozen = {"thumb_mcp", *profile.passive_joints}
+ if set(payload["static_zero_targets"]) != static_targets:
+ raise ValueError("schema v5 must contain exactly 15 static-zero targets")
+ if set(payload["frozen_static_zero_joints"]) != frozen:
+ raise ValueError("schema v5 frozen static-zero joint set is invalid")
+ static_validation = payload["static_zero_validation"]
+ training_cycles = static_validation.get("training_cycles", [])
+ holdout_cycle = static_validation.get("holdout_cycle")
+ expected_cycle_count = len(training_cycles) + 1
+ joints = payload["joints"]
+ if not isinstance(joints, Mapping) or set(joints) != set(profile.joint_specs):
+ raise ValueError("schema v5 must contain exactly 21 independent joints")
+ for name, spec in profile.joint_specs.items():
+ joint = joints[name]
+ expected_fields = {
+ "motor_index", "zero_command_u8", "angle_rad", "decreasing_rad",
+ "increasing_rad", "dynamic_quality", "static_zero_status",
+ "zero_angles" if spec.active else "passive",
+ }
+ if set(joint) != expected_fields:
+ raise ValueError(f"{name} has unexpected schema v5 fields")
+ if int(joint["motor_index"]) != spec.motor_index:
+ raise ValueError(f"{name} has the wrong motor_index")
+ zero_command = joint["zero_command_u8"]
+ if zero_command != int(baseline[spec.motor_index]):
+ raise ValueError(f"{name} has the wrong zero command")
+ for field_name in ("angle_rad", "decreasing_rad", "increasing_rad"):
+ curve = np.asarray(joint[field_name], dtype=float)
+ if curve.shape != (256,) or not np.all(np.isfinite(curve)):
+ raise ValueError(f"{name}.{field_name} must contain 256 values")
+ if np.any(np.diff(curve) > 1.0e-7):
+ raise ValueError(f"{name}.{field_name} must be non-increasing")
+ allow_directional_offset = (
+ runtime_curve_policy == "direction_aware_canonical_zero"
+ and canonical_zero_direction(profile, name) is not None
+ and field_name == "increasing_rad"
+ )
+ if (
+ not allow_directional_offset
+ and abs(float(curve[zero_command])) > 1.0e-6
+ ):
+ raise ValueError(f"{name}.{field_name} must be zero at baseline")
+ expected_status = (
+ "static_zero_calibrated"
+ if name in static_targets
+ else "cad_static_zero_retained_front_only"
+ if name == "thumb_mcp"
+ else "dynamic_measured_static_unobservable"
+ )
+ if joint["static_zero_status"] != expected_status:
+ raise ValueError(f"{name} has the wrong static-zero status")
+ dynamic_quality = joint["dynamic_quality"]
+ if set(dynamic_quality) != {
+ "maximum_monotonic_correction_rad",
+ "maximum_hysteresis_rad",
+ "fit",
+ "cycle_travel_rad",
+ "cycle_travel_range_rad",
+ "baseline_hysteresis_by_cycle_rad",
+ "holdout_cycle",
+ "holdout_cycle_mae_rad",
+ "holdout_cycle_max_rad",
+ }:
+ raise ValueError(f"{name}.dynamic_quality fields are invalid")
+ if (
+ dynamic_quality["holdout_cycle"] != holdout_cycle
+ or len(dynamic_quality["cycle_travel_rad"]) != expected_cycle_count
+ or len(
+ dynamic_quality["baseline_hysteresis_by_cycle_rad"]
+ ) != expected_cycle_count
+ ):
+ raise ValueError(f"{name} must record all dynamic cycles")
+ if spec.active:
+ zero = joint["zero_angles"]
+ if set(zero) != {
+ "urdf_zero_offset_rad",
+ "confidence_interval_95_half_width_rad",
+ "cycle_offsets_rad",
+ "observer_joint",
+ }:
+ raise ValueError(f"{name}.zero_angles fields are invalid")
+ if not math.isfinite(float(zero["urdf_zero_offset_rad"])):
+ raise ValueError(f"{name} zero offset is invalid")
+ if name in static_targets:
+ confidence = zero["confidence_interval_95_half_width_rad"]
+ if (
+ confidence is None
+ or not math.isfinite(float(confidence))
+ or not 0.0 <= float(confidence) <= math.radians(0.5)
+ ):
+ raise ValueError(f"{name} confidence interval is invalid")
+ if len(zero["cycle_offsets_rad"]) != len(training_cycles):
+ raise ValueError(
+ f"{name} must record training-only zero estimates"
+ )
+ if not isinstance(zero["observer_joint"], str):
+ raise ValueError(f"{name} must record its zero observer")
+ if name == "thumb_mcp" and float(zero["urdf_zero_offset_rad"]) != 0.0:
+ raise ValueError("thumb_mcp static zero must remain source CAD")
+ elif joint.get("passive") is not True:
+ raise ValueError(f"{name} must be marked passive")
+ artifacts = payload["artifacts"]
+ expected_hashes = {
+ "source_urdf_sha256", "camera_extrinsics_sha256",
+ "corrected_urdf_sha256",
+ }
+ if set(artifacts) != expected_hashes or any(
+ re.fullmatch(r"[0-9a-f]{64}", str(value)) is None
+ for value in artifacts.values()
+ ):
+ raise ValueError("schema v5 artifact SHA-256 values are invalid")
+ if set(payload["cross_view_roll"]) != {
+ f"{finger}_mcp_roll" for finger in ("index", "middle", "ring", "pinky")
+ }:
+ raise ValueError("schema v5 requires four cross-view roll diagnostics")
+ if set(static_validation) != {
+ "training_cycles", "holdout_cycle", "axis_line_rms_m",
+ "axis_line_rms_by_joint_m", "observability_rank",
+ "observability_parameter_count", "observability_condition_number",
+ "offset_covariance_rad2",
+ }:
+ raise ValueError("schema v5 static-zero validation fields are invalid")
+ training_cycles = static_validation["training_cycles"]
+ holdout_cycle = static_validation["holdout_cycle"]
+ if (
+ not isinstance(training_cycles, list)
+ or len(training_cycles) < 3
+ or any(not isinstance(value, int) for value in training_cycles)
+ or not isinstance(holdout_cycle, int)
+ or holdout_cycle in training_cycles
+ ):
+ raise ValueError("schema v5 training/holdout cycle split is invalid")
+ axis_line_rms = float(static_validation["axis_line_rms_m"])
+ if not math.isfinite(axis_line_rms) or axis_line_rms < 0.0:
+ raise ValueError("schema v5 axis-line RMS is invalid")
+ line_by_joint = static_validation["axis_line_rms_by_joint_m"]
+ if not isinstance(line_by_joint, Mapping) or any(
+ not math.isfinite(float(value)) or float(value) < 0.0
+ for value in line_by_joint.values()
+ ):
+ raise ValueError("schema v5 per-joint axis-line RMS is invalid")
+ parameter_count = int(static_validation["observability_parameter_count"])
+ rank = int(static_validation["observability_rank"])
+ condition = float(static_validation["observability_condition_number"])
+ if parameter_count != 21 or rank != parameter_count:
+ raise ValueError("schema v5 zero-observation Jacobian is rank deficient")
+ if not math.isfinite(condition) or condition < 1.0:
+ raise ValueError("schema v5 observability condition number is invalid")
+ offset_covariance = static_validation["offset_covariance_rad2"]
+ if set(offset_covariance) != static_targets or any(
+ not math.isfinite(float(value)) or float(value) < 0.0
+ for value in offset_covariance.values()
+ ):
+ raise ValueError("schema v5 offset covariance is invalid")
+ quality = payload["quality"]
+ if set(quality) != {
+ "passed", "all_15_static_targets_required", "validation_mae_rad",
+ "validation_p95_rad",
+ } or quality["all_15_static_targets_required"] is not True:
+ raise ValueError("schema v5 quality summary is invalid")
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py
new file mode 100644
index 0000000..0650610
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/publication.py
@@ -0,0 +1,914 @@
+"""Validate and atomically publish one inseparable G20 JSON/URDF session."""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+import copy
+import json
+import math
+import os
+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 .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 (
+ RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
+ get_zero_calibration_profile,
+)
+
+
+PASSIVE_JOINTS = frozenset(
+ {"thumb_ip", "index_dip", "middle_dip", "ring_dip", "pinky_dip"}
+)
+VISUALLY_MEASURED_FINGER_DIPS = frozenset(
+ {"index_dip", "middle_dip", "ring_dip", "pinky_dip"}
+)
+ACTIVE_ZERO_JOINTS = frozenset(
+ get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT).direct_zero_joints
+)
+RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
+ get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
+) - ACTIVE_ZERO_JOINTS
+SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
+
+
+def _load_json(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as stream:
+ payload = json.load(stream)
+ if not isinstance(payload, dict):
+ raise ValueError(f"{path} must contain a JSON object")
+ return payload
+
+
+def _joint_blocks(text: str) -> dict[str, str]:
+ pattern = re.compile(
+ r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
+ re.DOTALL,
+ )
+ return {match.group("name"): match.group(0) for match in pattern.finditer(text)}
+
+
+def _mask_origin_rpy(block: str) -> str:
+ return re.sub(
+ r"(]*\brpy\s*=\s*)([\"'])[^\"']*\2",
+ r"\1\2__CALIBRATED_RPY__\2",
+ block,
+ count=1,
+ )
+
+
+def _mask_active_origin_rpy_fields(text: str) -> str:
+ pattern = re.compile(
+ r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
+ re.DOTALL,
+ )
+
+ def replace(match: re.Match[str]) -> str:
+ block = match.group(0)
+ if match.group("name") not in ACTIVE_ZERO_JOINTS:
+ return block
+ masked = _mask_origin_rpy(block)
+ if masked == block:
+ raise ValueError(
+ f"active joint {match.group('name')} has no origin.rpy"
+ )
+ return masked
+
+ return pattern.sub(replace, text)
+
+
+def _mask_endpoint_coordinate_fields(text: str) -> str:
+ """Mask only limit/mimic fields induced by endpoint zero coordinates."""
+ pattern = re.compile(
+ r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
+ re.DOTALL,
+ )
+
+ def replace(match: re.Match[str]) -> str:
+ block = match.group(0)
+ name = match.group("name")
+ if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
+ return re.sub(
+ r"(]*\bupper\s*=\s*)([\"'])[^\"']*\2",
+ r"\1\2__CALIBRATED_UPPER__\2",
+ block,
+ count=1,
+ )
+ mimic = re.search(
+ r"]*\bjoint\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>",
+ block,
+ re.DOTALL,
+ )
+ if (
+ mimic is not None
+ and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
+ ):
+ return re.sub(
+ r"(]*\boffset\s*=\s*)([\"'])[^\"']*\2",
+ r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
+ block,
+ count=1,
+ )
+ return block
+
+ return pattern.sub(replace, text)
+
+
+def _triplet(value: str) -> np.ndarray:
+ result = np.asarray([float(item) for item in value.split()], dtype=float)
+ if result.shape != (3,) or not np.all(np.isfinite(result)):
+ raise ValueError(f"invalid URDF triplet: {value}")
+ return result
+
+
+def _joint_elements(path: str | Path) -> dict[str, ET.Element]:
+ return {
+ str(joint.get("name")): joint
+ for joint in ET.parse(Path(path)).getroot().findall("joint")
+ }
+
+
+def _verify_expected_origin_offsets(
+ source: str | Path,
+ corrected: str | Path,
+ expected_offsets_rad: Mapping[str, float],
+) -> None:
+ offsets = {str(name): float(value) for name, value in expected_offsets_rad.items()}
+ if set(offsets) != ACTIVE_ZERO_JOINTS or any(
+ not math.isfinite(value) for value in offsets.values()
+ ):
+ raise ValueError("expected offsets must contain all finite active static-zero values")
+ before = _joint_elements(source)
+ after = _joint_elements(corrected)
+ maximum_rotation_error = 0.0
+ for name, original_joint in before.items():
+ corrected_joint = after[name]
+ original_origin = original_joint.find("origin")
+ corrected_origin = corrected_joint.find("origin")
+ if original_origin is None or corrected_origin is None:
+ if original_origin is not corrected_origin:
+ raise ValueError(f"corrected URDF changed origin presence for {name}")
+ continue
+ original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
+ corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
+ if not np.allclose(original_xyz, corrected_xyz, atol=1.0e-12, rtol=0.0):
+ raise ValueError(f"corrected URDF changed origin.xyz for {name}")
+ original_rotation = Rotation.from_euler(
+ "xyz", _triplet(original_origin.get("rpy", "0 0 0"))
+ )
+ corrected_rotation = Rotation.from_euler(
+ "xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
+ )
+ expected_rotation = original_rotation
+ if name in offsets:
+ axis_node = original_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"source URDF joint {name} has a degenerate axis")
+ expected_rotation = expected_rotation * Rotation.from_rotvec(
+ axis / norm * offsets[name]
+ )
+ error = float((expected_rotation.inv() * corrected_rotation).magnitude())
+ maximum_rotation_error = max(maximum_rotation_error, error)
+ # Schema v4 intentionally stores zero offsets at eight decimal places.
+ # Older sessions wrote the corrected URDF from the full-precision solve,
+ # so comparing that file with the published JSON necessarily permits half
+ # of one last-place unit. This is about 2.9e-7 degrees and is far below
+ # any calibration or URDF numerical significance.
+ if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
+ raise ValueError(
+ "corrected URDF origin.rpy does not match the published zero offsets"
+ )
+
+
+def verify_corrected_urdf(
+ source: str | Path,
+ corrected: str | Path,
+ *,
+ expected_offsets_rad: Mapping[str, float] | None = None,
+ endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
+ correction_plan: UrdfCorrectionPlan | None = None,
+) -> tuple[str, ...]:
+ """Prove that only active-joint origin.rpy attributes changed.
+
+ Passive joint blocks, including their mimic elements, are compared as raw
+ UTF-8 text so formatting and numeric spelling are protected as well.
+ """
+ source_text = Path(source).read_text(encoding="utf-8")
+ corrected_text = Path(corrected).read_text(encoding="utf-8")
+ if correction_plan is not None:
+ correction_plan.verify_source(source)
+ if expected_offsets_rad is None:
+ raise ValueError(
+ "correction-plan validation requires expected offsets"
+ )
+ correction_plan.authorize_offsets(expected_offsets_rad)
+ before = _joint_blocks(source_text)
+ after = _joint_blocks(corrected_text)
+ if set(before) != set(after):
+ raise ValueError("corrected URDF changed the joint set")
+ # Text patchers may normalize only the final newline. It has no URDF/XML
+ # semantics; every joint block and every non-rpy byte remains guarded
+ # below.
+ masked_source = _mask_endpoint_coordinate_fields(
+ _mask_active_origin_rpy_fields(source_text)
+ ).rstrip("\r\n")
+ masked_corrected = _mask_endpoint_coordinate_fields(
+ _mask_active_origin_rpy_fields(corrected_text)
+ ).rstrip("\r\n")
+ if masked_source != masked_corrected:
+ raise ValueError(
+ "corrected URDF changed content outside active origin.rpy or "
+ "endpoint-calibrated fields"
+ )
+ changed: list[str] = []
+ for name in before:
+ if before[name] == after[name]:
+ continue
+ if name not in ACTIVE_ZERO_JOINTS and name not in PASSIVE_JOINTS:
+ raise ValueError(f"corrected URDF changed protected joint {name}")
+ if name in ACTIVE_ZERO_JOINTS:
+ changed.append(name)
+ if expected_offsets_rad is not None:
+ _verify_expected_origin_offsets(source, corrected, expected_offsets_rad)
+ endpoint_offsets = {
+ str(name): float(value)
+ for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
+ }
+ if endpoint_offsets:
+ if correction_plan is not None and not set(endpoint_offsets).issubset(
+ correction_plan.endpoint_limit_joints
+ ):
+ raise ValueError(
+ "endpoint offsets are not authorized by the correction plan"
+ )
+ source_joints = _joint_elements(source)
+ corrected_joints = _joint_elements(corrected)
+ for name, offset in endpoint_offsets.items():
+ original_limit = source_joints[name].find("limit")
+ corrected_limit = corrected_joints[name].find("limit")
+ expected_upper = float(original_limit.get("upper")) - offset
+ # Schema v4 stores the corresponding zero at eight decimal
+ # places, while the URDF is written from the full-precision solve.
+ # Match the half-last-place tolerance used for origin rotations.
+ if (
+ abs(float(corrected_limit.get("upper")) - expected_upper)
+ > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ ):
+ raise ValueError(f"corrected URDF has invalid {name} upper limit")
+ for name in PASSIVE_JOINTS:
+ original_mimic = source_joints[name].find("mimic")
+ corrected_mimic = corrected_joints[name].find("mimic")
+ if original_mimic is None or corrected_mimic is None:
+ continue
+ source_name = str(original_mimic.get("joint"))
+ multiplier = float(original_mimic.get("multiplier", "1"))
+ expected = float(original_mimic.get("offset", "0"))
+ expected += multiplier * endpoint_offsets.get(source_name, 0.0)
+ # The active endpoint offset comes from schema-v4 JSON rounded to
+ # eight decimal places, while the URDF mimic was written from the
+ # full-precision solve. Propagate exactly the same accepted
+ # quantization through the mimic multiplier; retain a much
+ # smaller allowance for XML decimal formatting itself.
+ tolerance = (
+ abs(multiplier)
+ * SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ + 1.0e-12
+ )
+ if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
+ raise ValueError(f"corrected URDF has invalid {name} mimic offset")
+ return tuple(sorted(changed))
+
+
+def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
+ """Return every session-local mesh after proving it resolves safely."""
+ path = Path(urdf).expanduser().resolve()
+ root = ET.parse(path).getroot()
+ resources: dict[str, Path] = {}
+ for mesh in root.findall(".//mesh"):
+ filename = str(mesh.get("filename", "")).strip()
+ if not filename or "://" in filename or filename.startswith("package:"):
+ continue
+ relative = Path(filename)
+ if relative.is_absolute() or ".." in relative.parts:
+ raise ValueError(f"URDF has an unsafe local mesh path: {filename}")
+ resolved = (path.parent / relative).resolve()
+ try:
+ resolved.relative_to(path.parent)
+ except ValueError as error:
+ raise ValueError(f"URDF mesh escapes the session: {filename}") from error
+ if not resolved.is_file():
+ raise ValueError(f"URDF mesh resource is missing: {filename}")
+ resources[relative.as_posix()] = resolved
+ return dict(sorted(resources.items()))
+
+
+def validate_runtime_curves_against_urdf_limits(
+ payload: Mapping[str, Any], runtime_urdf: str | Path
+) -> None:
+ """Reject a curve whose q leaves its runtime URDF coordinate limits.
+
+ Curves in schema v4 are expressed in the corrected URDF joint coordinate,
+ not in the source-CAD coordinate. Endpoint zero calibration can therefore
+ move a corrected coordinate limit while preserving the same physical CAD
+ endpoint; callers publishing a calibrated pair must pass that corrected
+ URDF here.
+ """
+ validate_compact_payload(payload)
+ runtime_joints = _joint_elements(runtime_urdf)
+ for name, calibration in payload["joints"].items():
+ joint = runtime_joints.get(str(name))
+ if joint is None:
+ raise ValueError(f"runtime URDF is missing joint {name}")
+ limit = joint.find("limit")
+ if limit is None or limit.get("lower") is None or limit.get("upper") is None:
+ raise ValueError(f"runtime URDF joint {name} has no finite position limit")
+ lower = float(limit.get("lower"))
+ upper = float(limit.get("upper"))
+ if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
+ raise ValueError(f"runtime URDF joint {name} has invalid position limits")
+ curve = np.asarray(calibration["angle_rad"], dtype=float)
+ minimum = float(np.min(curve))
+ maximum = float(np.max(curve))
+ tolerance = 1.0e-7
+ if minimum < lower - tolerance or maximum > upper + tolerance:
+ raise ValueError(
+ f"runtime curve exceeds runtime URDF limit for {name}: "
+ f"[{minimum:.9g}, {maximum:.9g}] not within "
+ f"[{lower:.9g}, {upper:.9g}]"
+ )
+
+
+def clamp_compact_payload_to_urdf_limits(
+ payload: Mapping[str, Any], runtime_urdf: str | Path
+) -> tuple[dict[str, Any], dict[str, int]]:
+ """Return a schema-preserving payload bounded in its runtime coordinates."""
+ result = copy.deepcopy(dict(payload))
+ validate_compact_payload(result)
+ runtime_joints = _joint_elements(runtime_urdf)
+ clipped_by_joint: dict[str, int] = {}
+ for name, calibration in result["joints"].items():
+ joint = runtime_joints.get(str(name))
+ limit = None if joint is None else joint.find("limit")
+ if (
+ limit is None
+ or limit.get("lower") is None
+ or limit.get("upper") is None
+ ):
+ raise ValueError(f"runtime URDF joint {name} has no finite position limit")
+ lower = float(limit.get("lower"))
+ upper = float(limit.get("upper"))
+ if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
+ raise ValueError(f"runtime URDF joint {name} has invalid position limits")
+ source = np.asarray(calibration["angle_rad"], dtype=float)
+ bounded = np.clip(source, lower, upper)
+ count = int(np.count_nonzero(bounded != source))
+ if count:
+ clipped_by_joint[str(name)] = count
+ calibration["angle_rad"] = [
+ round(float(value), 8) for value in bounded
+ ]
+ validate_compact_payload(result)
+ return result, clipped_by_joint
+
+
+def build_mujoco_validation_commands(
+ baseline: Sequence[int],
+) -> dict[str, Any]:
+ if len(baseline) != 20:
+ raise ValueError("baseline must contain exactly 20 commands")
+ base = [int(value) for value in baseline]
+ if any(not 0 <= value <= 255 for value in base):
+ raise ValueError("baseline commands must be in [0, 255]")
+
+ def pose(name: str, label: str, changes: Mapping[int, int]) -> dict[str, Any]:
+ values = list(base)
+ for index, value in changes.items():
+ values[int(index)] = int(value)
+ return {"name": name, "label_zh": label, "command_u8": values}
+
+ return {
+ "schema_version": 1,
+ "topic": "/g20/cb_right_hand_control_cmd",
+ "serial_number": "G20_RIGHT_001",
+ "poses": [
+ pose("all_open", "全开", {}),
+ pose("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}),
+ pose("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}),
+ pose("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}),
+ pose("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}),
+ pose("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}),
+ pose(
+ "half_grip",
+ "四指半握",
+ {1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160},
+ ),
+ pose(
+ "light_pinch",
+ "轻捏",
+ {0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176},
+ ),
+ ],
+ }
+
+
+def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path:
+ parent = Path(root).resolve()
+ target = Path(session).resolve()
+ if target.parent != parent:
+ raise ValueError("session pointer target must be a direct child of the serial root")
+ if not target.is_dir():
+ raise ValueError(f"session directory does not exist: {target}")
+ if name not in {"latest_attempt", "latest_passed", "latest_thumb_passed"}:
+ raise ValueError("unsupported session pointer name")
+ parent.mkdir(parents=True, exist_ok=True)
+ destination = parent / name
+ temporary = parent / f".{name}.{os.getpid()}.tmp"
+ if temporary.is_symlink():
+ temporary.unlink()
+ elif temporary.exists():
+ raise ValueError(f"temporary pointer path is occupied: {temporary}")
+ os.symlink(target.name, temporary, target_is_directory=True)
+ os.replace(temporary, destination)
+ return destination
+
+
+def active_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
+ validate_compact_payload(payload)
+ result = {
+ name: float(payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"])
+ for name in ACTIVE_ZERO_JOINTS
+ }
+ if set(result) != ACTIVE_ZERO_JOINTS or any(
+ not math.isfinite(value) for value in result.values()
+ ):
+ raise ValueError(
+ "payload does not contain all finite observable active zero offsets"
+ )
+ return result
+
+
+def standalone_thumb_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
+ """Validate a standalone thumb artifact and expand CAD finger zeros."""
+ if (
+ payload.get("schema_version") != 1
+ or payload.get("artifact_type")
+ != "g20_right_standalone_thumb_calibration"
+ or payload.get("side") != "right"
+ or payload.get("non_thumb_zero_policy") != "source_cad_unchanged"
+ or not bool(payload.get("quality", {}).get("passed"))
+ ):
+ raise ValueError("standalone thumb JSON contract is invalid")
+ joints = payload.get("joints")
+ expected_joint_names = {
+ "thumb_cmc_roll",
+ "thumb_cmc_yaw",
+ "thumb_cmc_pitch",
+ "thumb_mcp",
+ "thumb_ip",
+ }
+ if not isinstance(joints, Mapping) or set(joints) != expected_joint_names:
+ raise ValueError("standalone thumb JSON has the wrong joint set")
+ thumb_names = expected_joint_names - {"thumb_ip"}
+ result = {name: 0.0 for name in ACTIVE_ZERO_JOINTS}
+ for name in thumb_names:
+ try:
+ value = float(joints[name]["zero_angles"]["urdf_zero_offset_rad"])
+ except (KeyError, TypeError, ValueError) as error:
+ raise ValueError(
+ f"standalone thumb JSON is missing zero for {name}"
+ ) from error
+ curve = np.asarray(joints[name].get("angle_rad", ()), dtype=float)
+ if curve.shape != (256,) or not np.all(np.isfinite(curve)):
+ raise ValueError(f"standalone thumb JSON curve is invalid: {name}")
+ result[name] = value
+ passive_curve = np.asarray(
+ joints["thumb_ip"].get("angle_rad", ()), dtype=float
+ )
+ if (
+ passive_curve.shape != (256,)
+ or not np.all(np.isfinite(passive_curve))
+ or not bool(joints["thumb_ip"].get("passive"))
+ or any(not math.isfinite(value) for value in result.values())
+ ):
+ raise ValueError("standalone thumb JSON passive curve is invalid")
+ return result
+
+
+def verify_partial_scope_preserves_certified_zeros(
+ *,
+ scope: str,
+ source_session: str | Path,
+ serial_root: str | Path | None = None,
+ serial_number: str,
+ current_offsets: Mapping[str, float],
+) -> None:
+ """Reject a partial artifact that changes any certified non-target zero."""
+ selected = str(scope).strip().lower()
+ if selected not in {"thumb", "fingers"}:
+ return
+ source = Path(source_session).expanduser()
+ if not source.is_absolute():
+ if serial_root is None:
+ raise ValueError(
+ "relative partial base session requires its serial root"
+ )
+ source = Path(serial_root).expanduser() / source
+ source = source.resolve()
+ base_payload = _load_json(
+ source / f"g20_right_{serial_number}_calibration.json"
+ )
+ base_offsets = (
+ standalone_thumb_offsets(base_payload)
+ if base_payload.get("artifact_type")
+ == "g20_right_standalone_thumb_calibration"
+ else active_offsets(base_payload)
+ )
+ preserve_thumb = selected == "fingers"
+ changed = {
+ name: abs(float(current_offsets[name]) - float(base_offsets[name]))
+ for name in ACTIVE_ZERO_JOINTS
+ if name.startswith("thumb_") == preserve_thumb
+ and abs(float(current_offsets[name]) - float(base_offsets[name]))
+ > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
+ }
+ if changed:
+ raise ValueError(
+ f"partial {selected} calibration changed certified non-target "
+ "zeros: "
+ + ",".join(sorted(changed))
+ )
+
+
+def compare_session_offsets(
+ first: Mapping[str, Any],
+ second: Mapping[str, Any],
+ *,
+ maximum_difference_rad: float,
+) -> dict[str, float]:
+ first_standalone = (
+ first.get("artifact_type")
+ == "g20_right_standalone_thumb_calibration"
+ )
+ second_standalone = (
+ second.get("artifact_type")
+ == "g20_right_standalone_thumb_calibration"
+ )
+ if first_standalone != second_standalone:
+ raise ValueError("cannot compare standalone thumb and full-hand artifacts")
+ left = (
+ standalone_thumb_offsets(first)
+ if first_standalone
+ else active_offsets(first)
+ )
+ right = (
+ standalone_thumb_offsets(second)
+ if second_standalone
+ else active_offsets(second)
+ )
+ differences = {
+ name: abs(left[name] - right[name])
+ for name in sorted(left)
+ }
+ failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
+ if failed:
+ details = ", ".join(
+ f"{name}={math.degrees(value):.3f}deg" for name, value in failed.items()
+ )
+ raise ValueError(f"independent-session static-zero mismatch: {details}")
+ return differences
+
+
+def session_artifact_paths(session: str | Path, serial_number: str) -> dict[str, Path]:
+ directory = Path(session).resolve()
+ urdfs = sorted(directory.glob(f"*zero_calibrated_{serial_number}_*.urdf"))
+ if len(urdfs) != 1:
+ raise ValueError("session must contain exactly one corrected URDF")
+ return {
+ "json": directory / f"g20_right_{serial_number}_calibration.json",
+ "urdf": urdfs[0],
+ "summary": directory / "calibration_summary_zh.json",
+ "commands": directory / "mujoco_validation_commands.json",
+ "raw": directory / "raw_samples.jsonl",
+ "log": directory / "calibration.log",
+ }
+
+
+def find_compatible_prior_session(
+ config: ProductConfig,
+ current: str | Path,
+ current_payload: Mapping[str, Any],
+) -> tuple[Path | None, dict[str, float]]:
+ current_path = Path(current).resolve()
+ for candidate in sorted(config.session_root.glob("20??????_??????"), reverse=True):
+ if candidate.resolve() == current_path:
+ continue
+ json_path = candidate / f"g20_right_{config.serial_number}_calibration.json"
+ summary_path = candidate / "calibration_summary_zh.json"
+ if not json_path.is_file() or not summary_path.is_file():
+ continue
+ try:
+ summary = _load_json(summary_path)
+ if not bool(summary.get("quality", {}).get("passed")):
+ continue
+ payload = _load_json(json_path)
+ differences = compare_session_offsets(
+ payload,
+ current_payload,
+ maximum_difference_rad=config.static_repeatability_rad,
+ )
+ except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
+ continue
+ return candidate, differences
+ return None, {}
+
+
+def _verify_combination_validation(combination: Mapping[str, Any]) -> None:
+ # The formal product uses the independently held-out fourth sweep cycle.
+ # The optional eight-pose check remains a developer diagnostic because the
+ # axis-line zero solve does not establish an absolute Cartesian hand-base
+ # transform. When explicitly enabled, retain its strict coverage and
+ # error checks.
+ if not bool(combination.get("enabled")):
+ return
+ position_p95 = float(combination.get("position_p95_m", float("inf")))
+ orientation_p95 = float(
+ combination.get("orientation_p95_rad", float("inf"))
+ )
+ required = {str(value) for value in combination.get("required_targets", ())}
+ expected = set(G20_COMBINATION_REQUIRED_TARGET_KEYS)
+ observations = combination.get("observation_counts")
+ validations = combination.get("validation_counts")
+ if required != expected:
+ raise ValueError("combination validation has the wrong required-target set")
+ if not isinstance(observations, Mapping) or not isinstance(validations, Mapping):
+ raise ValueError("combination validation is missing per-target coverage")
+ missing_observations = sorted(
+ key for key in expected if int(observations.get(key, 0)) < 2
+ )
+ missing_validations = sorted(
+ key for key in expected if int(validations.get(key, 0)) < 1
+ )
+ if missing_observations or missing_validations:
+ raise ValueError(
+ "combination validation target coverage is incomplete: "
+ f"observations={','.join(missing_observations) or '-'}; "
+ f"validations={','.join(missing_validations) or '-'}"
+ )
+ if (
+ not bool(combination.get("completed"))
+ or int(combination.get("completed_poses", 0)) != 8
+ or position_p95 > 0.003
+ or orientation_p95 > math.radians(2.0)
+ ):
+ raise ValueError(
+ "eight-pose four-finger combination validation is incomplete or failed"
+ )
+
+
+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
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py
index 074653f..4948065 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/right_19.py
@@ -1,7 +1,7 @@
"""Independent reviewed profile for the right 19-Tag product layout."""
from ...core import ProfileKey
-from ...full_hand import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile
+from .profile import G20_RIGHT_19_LAYOUT, get_hand_calibration_profile
from .zero_policy import (
RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py
index ebcf339..e9169d7 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/runner.py
@@ -27,7 +27,7 @@ from ...operator_report import (
render_progress_zh,
)
from ...product import ProductConfig, load_product_config, sha256_file
-from ...publication import atomic_session_pointer, finalize_session_artifacts
+from .publication import atomic_session_pointer, finalize_session_artifacts
from ...storage import atomic_write_json
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py
index f2e6650..c2a4bbf 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_policy.py
@@ -1,7 +1,7 @@
"""Reviewed static-zero, endpoint, and mimic topology exports."""
-from ...full_hand import MIMIC_DERIVED_FINGER_DIPS
-from ...urdf_zero import (
+from .profile import MIMIC_DERIVED_FINGER_DIPS
+from .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/models/g20/zero_solver.py b/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py
new file mode 100644
index 0000000..be258b8
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/g20/zero_solver.py
@@ -0,0 +1,4635 @@
+"""Three-dimensional joint-axis fitting and URDF zero correction."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from datetime import datetime
+import math
+import os
+from pathlib import Path
+import re
+import shutil
+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
+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"
+
+ @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
+) -> 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 max(lower_distance, upper_distance) <= ZERO_REFERENCE_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 abs(nearest_command - zero) <= ZERO_REFERENCE_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
+) -> 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
+ )
+ )
+ is not None
+ ]
+ if not values:
+ raise ValueError(
+ "joint records have no samples within "
+ f"{ZERO_REFERENCE_MAXIMUM_DISTANCE_U8} commands of zero "
+ f"{zero_command_u8}"
+ )
+ 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,
+) -> 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.
+ """
+ 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,
+ )
+ )
+ 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,
+ )
+ 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,
+ )
+ ],
+ "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,
+) -> 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,
+ )
+ )
+ 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
+ # 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,
+) -> 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] = []
+ 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
+ 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))))
+ 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,
+) -> 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)
+ 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=str(joint).endswith("_side"),
+ )
+ )
+ 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_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]
+
+
+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 = (
+ "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)
+ error = math.acos(
+ abs(
+ float(
+ np.clip(
+ predicted_axis @ observed_axis, -1.0, 1.0
+ )
+ )
+ )
+ )
+ 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)
+ ):
+ # Orthographic image-plane displacement is invariant to
+ # independent optical-depth bias on the two planar Tags.
+ # With an end-on axis, along-axis placement also projects
+ # to zero (or a small component for a mildly oblique view).
+ predicted_radial = predicted_delta - view_normal * float(
+ predicted_delta @ view_normal
+ )
+ observed_radial = observed_delta - view_normal * float(
+ observed_delta @ 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
+ if abs(value) >= minimum_applied_offset_rad:
+ 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 _files_have_identical_contents(left: Path, right: Path) -> bool:
+ if left.stat().st_size != right.stat().st_size:
+ return False
+ with left.open("rb") as left_stream, right.open("rb") as right_stream:
+ while True:
+ left_chunk = left_stream.read(1024 * 1024)
+ right_chunk = right_stream.read(1024 * 1024)
+ if left_chunk != right_chunk:
+ return False
+ if not left_chunk:
+ return True
+
+
+def _materialize_relative_mesh_assets(
+ *, source: Path, output: Path, urdf_root: ET.Element
+) -> tuple[Path, ...]:
+ """Copy relative mesh resources so a session-local URDF remains loadable."""
+ filenames = sorted(
+ {
+ str(mesh.get("filename", "")).strip()
+ for mesh in urdf_root.findall(".//mesh")
+ if str(mesh.get("filename", "")).strip()
+ }
+ )
+ materialized: list[Path] = []
+ for filename in filenames:
+ # URI-backed resources are resolved by the URDF consumer. Only local
+ # relative resources need to follow a URDF copied to a session folder.
+ if "://" in filename or filename.startswith("package:"):
+ continue
+ relative = Path(filename)
+ if relative.is_absolute() or ".." in relative.parts:
+ raise ValueError(
+ f"URDF mesh path must be a safe relative path or URI: {filename}"
+ )
+ source_asset = (source.parent / relative).resolve()
+ if not source_asset.is_file():
+ raise ValueError(f"URDF mesh resource does not exist: {source_asset}")
+ destination_asset = (output / relative).resolve()
+ try:
+ destination_asset.relative_to(output)
+ except ValueError as error:
+ raise ValueError(
+ f"URDF mesh destination escapes output directory: {filename}"
+ ) from error
+ if destination_asset == source_asset:
+ materialized.append(destination_asset)
+ continue
+ destination_asset.parent.mkdir(parents=True, exist_ok=True)
+ if destination_asset.exists():
+ if not destination_asset.is_file() or not _files_have_identical_contents(
+ source_asset, destination_asset
+ ):
+ raise ValueError(
+ f"refusing to overwrite a different mesh resource: "
+ f"{destination_asset}"
+ )
+ materialized.append(destination_asset)
+ continue
+ temporary_asset = destination_asset.with_name(
+ f".{destination_asset.name}.{os.getpid()}.tmp"
+ )
+ if temporary_asset.exists():
+ raise ValueError(f"temporary mesh path is occupied: {temporary_asset}")
+ try:
+ shutil.copy2(source_asset, temporary_asset)
+ os.replace(temporary_asset, destination_asset)
+ finally:
+ if temporary_asset.exists():
+ temporary_asset.unlink()
+ materialized.append(destination_asset)
+ return tuple(materialized)
+
+
+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 (
+ "zero_calibrated" in source.stem.lower()
+ or re.search(r"calibrated_20\d{6}", source.stem.lower())
+ ):
+ raise ValueError(
+ "source_urdf must be the original CAD URDF, not a calibrated URDF"
+ )
+ 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")
+ output.mkdir(parents=True, exist_ok=True)
+ 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")
+ destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
+ if destination == source or destination.exists():
+ raise ValueError(f"refusing to overwrite URDF: {destination}")
+ tree = ET.parse(source)
+ root = tree.getroot()
+ original_text = source.read_text(encoding="utf-8")
+ 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))
+ joint_pattern = re.compile(
+ r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>"
+ r".*?",
+ re.DOTALL,
+ )
+ edits: list[tuple[int, int, str]] = []
+ for match in joint_pattern.finditer(original_text):
+ name = match.group("name")
+ if name not in replacement_rpy:
+ if name not in replacement_upper and name not in replacement_mimic_offset:
+ continue
+ block = match.group(0)
+ if name in replacement_rpy:
+ origin_match = re.search(
+ r"]*\brpy\s*=\s*([\"'])(?P[^\"']*)\1",
+ block,
+ re.DOTALL,
+ )
+ if origin_match is None:
+ raise ValueError(f"joint {name} origin has no rpy attribute")
+ edits.append((
+ match.start() + origin_match.start("rpy"),
+ match.start() + origin_match.end("rpy"),
+ replacement_rpy[name],
+ ))
+ if name in replacement_upper:
+ limit_match = re.search(
+ r"]*\bupper\s*=\s*([\"'])(?P[^\"']*)\1",
+ block,
+ re.DOTALL,
+ )
+ if limit_match is None:
+ raise ValueError(f"joint {name} limit has no upper attribute")
+ edits.append((
+ match.start() + limit_match.start("upper"),
+ match.start() + limit_match.end("upper"),
+ replacement_upper[name],
+ ))
+ if name in replacement_mimic_offset:
+ mimic_match = re.search(
+ r"]*\boffset\s*=\s*([\"'])(?P[^\"']*)\1",
+ block,
+ re.DOTALL,
+ )
+ if mimic_match is None:
+ raise ValueError(f"joint {name} mimic has no offset attribute")
+ edits.append((
+ match.start() + mimic_match.start("offset"),
+ match.start() + mimic_match.end("offset"),
+ replacement_mimic_offset[name],
+ ))
+ expected_edit_count = (
+ len(replacement_rpy)
+ + len(replacement_upper)
+ + len(replacement_mimic_offset)
+ )
+ if len(edits) != expected_edit_count:
+ raise ValueError("could not locate every target joint field in source URDF text")
+ corrected_text = original_text
+ for start, end, value in reversed(edits):
+ corrected_text = corrected_text[:start] + value + corrected_text[end:]
+ _materialize_relative_mesh_assets(source=source, output=output, urdf_root=root)
+ temporary = destination.with_suffix(".urdf.tmp")
+ with temporary.open("w", encoding="utf-8") as stream:
+ stream.write(corrected_text)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, destination)
+ return destination
diff --git a/src/linkerhand_calibration/linkerhand_calibration/models/registry.py b/src/linkerhand_calibration/linkerhand_calibration/models/registry.py
index 2ec12a6..7944db5 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/models/registry.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/models/registry.py
@@ -18,6 +18,7 @@ class EngineBindings:
preparation_waypoints: Callable[..., tuple[tuple[int, ...], ...]]
return_waypoints: Callable[..., tuple[tuple[int, ...], ...]]
cli_main: Callable[[list[str] | None], None]
+ node_main: Callable[[list[str] | None], None]
@dataclass(frozen=True)
diff --git a/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py b/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py
index c625e1a..36d6da7 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/offline_replay.py
@@ -1,1941 +1,7 @@
-"""Safely replay a complete three-camera session without moving the hand."""
+"""One-release module alias for the relocated model replay implementation."""
-from __future__ import annotations
+import sys
-import argparse
-from collections import defaultdict
-from dataclasses import replace
-import hashlib
-import json
-import math
-import os
-from pathlib import Path
-import re
-import tempfile
-from typing import Any, Mapping, Sequence
-import xml.etree.ElementTree as ET
+from .models.g20 import offline_replay as _implementation
-import numpy as np
-from scipy.spatial.transform import Rotation
-import yaml
-
-from .extrinsics import load_three_camera_extrinsics
-from .full_hand import (
- G20_REFERENCE_THUMB_CMC_JOINTS,
- G20_RIGHT_19_LAYOUT,
- RIGHT_19_END_ON_IMAGE_CURVE_JOINTS,
- HandCalibrationProfile,
- JointCurveFit,
- build_calibration_motion_command,
- build_compact_payload,
- canonical_zero_direction,
- clamp_runtime_fits_to_urdf_limits,
- cross_view_roll_diagnostic_metrics,
- derive_mimic_passive_fits,
- fit_joint_image_curve,
- 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 .urdf_zero import (
- JointAxisMeasurement,
- RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS,
- UrdfKinematicModel,
- _angles_from_state,
- anchor_right_19_mechanical_endpoint_curves,
- axis_line_uses_depth_free_interpretation_plane,
- axis_line_cycle_rms_m,
- 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,
- get_zero_calibration_profile,
- get_right_19_thumb_zero_profile,
- derive_right_19_mechanical_endpoint_offsets,
- joint_curve_holdout_errors,
- refit_axis_line_group_with_shared_radius,
- RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
- RIGHT_19_POST_SOLVE_ENDPOINT_JOINTS,
- merge_right_19_thumb_zero_result,
- select_cross_view_roll_direction_source,
- solve_urdf_zero_offsets,
- with_depth_free_axis_projection,
- write_zero_corrected_urdf,
-)
-
-
-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()
-
-
-def _output_suffix(output_tag: str | None) -> str:
- """Return a filename-safe suffix for a non-destructive replay variant."""
- if output_tag is None:
- return ""
- tag = str(output_tag)
- if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,63}", tag):
- raise ValueError(
- "output tag must be 1-64 filename-safe characters, beginning "
- "with a letter or digit"
- )
- return f"_{tag}"
-
-
-def _load_parameters(path: Path) -> dict[str, Any]:
- payload = yaml.safe_load(path.read_text(encoding="utf-8"))
- return dict(payload["g20_calibration"]["ros__parameters"])
-
-
-def _steady_records_in_feedback_domain(
- records: Sequence[Mapping[str, Any]],
-) -> list[dict[str, Any]]:
- """Mirror the live mechanical-backlash domain for offline replay."""
- return fitting_sample_records(
- records,
- domain="feedback",
- allow_legacy_command=True,
- snap_requested_endpoints=True,
- )
-
-
-def _latest_attempt_records(
- rows: Sequence[Mapping[str, Any]],
- *,
- kind: str = "sample",
-) -> dict[str, list[dict[str, Any]]]:
- """Reproduce the online retry buffer from append-only raw samples.
-
- Online retry clears only the failed joint/cycle/direction from memory,
- while JSONL deliberately retains every attempt for audit. Offline replay
- must therefore select the greatest attempt independently for each logical
- trajectory rather than mixing rejected attempts into the final fit.
- """
- samples = [dict(row) for row in rows if row.get("kind") == kind]
- latest_attempt: dict[tuple[str, int, str], int] = {}
- for row in samples:
- key = (
- str(row["joint"]),
- int(row["cycle"]),
- str(row["direction"]),
- )
- latest_attempt[key] = max(
- latest_attempt.get(key, 0), int(row.get("attempt", 1))
- )
- records: dict[str, list[dict[str, Any]]] = defaultdict(list)
- for row in samples:
- key = (
- str(row["joint"]),
- int(row["cycle"]),
- str(row["direction"]),
- )
- if int(row.get("attempt", 1)) == latest_attempt[key]:
- records[key[0]].append(row)
- return dict(records)
-
-
-def _latest_palm_axis_records(
- rows: Sequence[Mapping[str, Any]],
-) -> dict[str, list[dict[str, Any]]]:
- """Select the latest accepted append-only palm side-channel attempts."""
- samples = [
- dict(row) for row in rows if row.get("kind") == "palm_axis_sample"
- ]
- latest_attempt: dict[tuple[str, int, str], int] = {}
- for row in samples:
- key = (
- str(row["source_joint"]),
- int(row["cycle"]),
- str(row["direction"]),
- )
- latest_attempt[key] = max(
- latest_attempt.get(key, 0), int(row.get("attempt", 1))
- )
- records: dict[str, list[dict[str, Any]]] = defaultdict(list)
- for row in samples:
- key = (
- str(row["source_joint"]),
- int(row["cycle"]),
- str(row["direction"]),
- )
- if int(row.get("attempt", 1)) == latest_attempt[key]:
- records[key[0]].append(row)
- return dict(records)
-
-
-def _load_raw_session(
- session_dir: Path,
-) -> tuple[
- dict[str, Any],
- dict[str, list[dict[str, Any]]],
- dict[str, list[dict[str, Any]]],
- dict[str, list[dict[str, Any]]],
- dict[str, list[dict[str, Any]]],
- Path,
-]:
- raw_path = session_dir / "raw_samples.jsonl"
- if not raw_path.is_file():
- raise ValueError(f"raw session does not exist: {raw_path}")
- rows = [
- json.loads(line)
- for line in raw_path.read_text(encoding="utf-8").splitlines()
- if line.strip()
- ]
- # Both online fitting and replay use the same canonical-to-fit projection.
- # raw_samples.jsonl itself never contains the ambiguous compatibility key.
- for row in rows:
- if row.get("kind") in {
- "sample",
- "baseline_hold_sample",
- "steady_command_sample",
- "palm_axis_sample",
- }:
- projected = fitting_sample_record(
- row, allow_legacy_command=True
- )
- row.clear()
- row.update(projected)
- starts = [row for row in rows if row.get("kind") == "session_start"]
- if len(starts) != 1:
- raise ValueError("raw session must contain exactly one session_start")
- return (
- starts[0],
- _latest_attempt_records(rows),
- _latest_attempt_records(rows, kind="baseline_hold_sample"),
- _latest_attempt_records(rows, kind="steady_command_sample"),
- _latest_palm_axis_records(rows),
- raw_path,
- )
-
-
-def _fit_curve(
- name: str,
- records: Sequence[Mapping[str, Any]],
- *,
- profile: HandCalibrationProfile,
- baseline: Sequence[int],
-) -> JointCurveFit:
- motor = profile.record_specs[name].motor_index
- if (
- profile.layout_id == G20_RIGHT_19_LAYOUT
- and (
- name in RIGHT_19_END_ON_IMAGE_CURVE_JOINTS
- or (
- name.endswith("_mcp_roll")
- and not name.startswith("thumb_")
- )
- )
- ):
- return fit_joint_image_curve(records)
- return fit_rotation_joint_curve(
- records,
- zero_command_u8=int(baseline[motor]),
- canonical_zero_direction=canonical_zero_direction(profile, name),
- )
-
-
-def _physical_rotation_axis_for_curve(
- name: str,
- records: Sequence[Mapping[str, Any]],
- fit: JointCurveFit,
- *,
- profile: HandCalibrationProfile,
- baseline: Sequence[int],
-) -> Sequence[float]:
- """Keep baseline pose hysteresis independent of the dynamic curve space."""
- axis = fit.circle.get("axis_xyz")
- if axis is not None:
- return axis
- motor = profile.record_specs[name].motor_index
- diagnostic_fit = fit_rotation_joint_curve(
- records,
- zero_command_u8=int(baseline[motor]),
- canonical_zero_direction=canonical_zero_direction(profile, name),
- )
- return diagnostic_fit.circle["axis_xyz"]
-
-
-def _fit_axes(
- *,
- records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
- profile: HandCalibrationProfile,
- baseline: Sequence[int],
- extrinsics_file: Path,
- source_urdf: Path,
- repetitions: int,
-) -> list[JointAxisMeasurement]:
- zero_profile = get_zero_calibration_profile(
- profile.side, profile.layout_id
- )
- extrinsics = load_three_camera_extrinsics(extrinsics_file)
- cache: dict[tuple[str, int], JointAxisMeasurement] = {}
- cross_view_direction_source_cache: dict[str, str] = {}
- upstream_by_joint = {
- "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")
- },
- }
- if profile.layout_id == G20_RIGHT_19_LAYOUT:
- reference_roll = f"{profile.reference_finger}_mcp_roll"
- upstream_by_joint.update(
- {
- f"{finger}_mcp_roll": reference_roll
- for finger in ("index", "middle", "ring", "pinky")
- if f"{finger}_mcp_roll" != reference_roll
- }
- )
-
- def fit_raw(name: str, cycle: int) -> JointAxisMeasurement:
- key = (name, cycle)
- if key in cache:
- return cache[key]
- upstream = upstream_by_joint.get(name)
- constraint = None if upstream is None else fit_raw(upstream, cycle).axis_common_xyz
- spec = profile.record_specs[name]
- view_normal = extrinsics.transform(spec.view)[:3, :3] @ np.asarray(
- [0.0, 0.0, 1.0], dtype=float
- )
- result = fit_joint_axis_measurement(
- name,
- records_by_joint[name],
- cycle=cycle,
- zero_command_u8=int(baseline[spec.motor_index]),
- axis_common_constraint=constraint,
- constrained_circle_joints=(
- zero_profile.constrained_circle_joints
- | ({name} if name.endswith("_side") else set())
- ),
- view_normal_common_xyz=view_normal,
- canonical_zero_direction=canonical_zero_direction(profile, name),
- )
- sweep_spec = next(
- sweep for sweep in profile.sweep_specs if name in sweep.joints
- )
- condition = build_calibration_motion_command(
- sweep_spec,
- int(baseline[spec.motor_index]),
- baseline=baseline,
- profile=profile,
- )
- result = replace(
- result,
- condition_command_u8=(
- None
- if profile.layout_id == G20_RIGHT_19_LAYOUT
- else tuple(float(value) for value in condition)
- ),
- view_normal_common_xyz=tuple(float(value) for value in view_normal),
- )
- result = with_depth_free_axis_projection(
- result, extrinsics.transform(spec.view)[:3, 3]
- )
- cache[key] = result
- return result
-
- def fit_one(name: str, cycle: int) -> JointAxisMeasurement:
- primary = fit_raw(name, cycle)
- validation_name = (profile.axis_validation_sources or {}).get(name)
- if validation_name is None:
- return primary
- secondary = fit_raw(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
- 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)
- )
- )
- if (
- profile.record_specs[validation_name].zero_kind
- == "axis_cross_view_validation"
- ):
- point_view = profile.record_specs[name].view
- if point_view is None:
- raise ValueError(
- f"{name}: primary point source has no view"
- )
- point_camera_center = extrinsics.transform(point_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
- ),
- pose_axis_line_rms_m=primary.pose_axis_line_rms_m,
- pose_axis_line_source_joints=(name,),
- axis_point_source=(
- "front_interpretation_plane_cross_view_validated"
- ),
- 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
- ),
- )
- # Mirror the live node's tiered policy: the side-view planar-tag
- # IPPE bias makes sub-degree cross-view agreement unreachable, so
- # disagreement above the fusion gate keeps the front-only axis while
- # only a gross direction error (wrong link / loose tag) still fails.
- # The front screw-driven roll trajectory yields a displaced
- # pseudo-line, so its distance to the side physical line is retained
- # only as a diagnostic. The live node likewise keeps only the gross
- # direction gate here and validates side-line quality independently.
- if difference > math.radians(15.0):
- raise ValueError(f"{name}: cross-view axis gross disagreement")
- if difference > math.radians(0.75) or line_distance > 0.001:
- selected_direction = primary_axis
- selected_source = primary.axis_direction_source
- observer_name = next(
- (
- observer
- for observer, parent in zero_profile.axis_parent_joint.items()
- if parent == name
- ),
- None,
- )
- if observer_name is not None:
- model = UrdfKinematicModel(source_urdf)
- parent_axis, _ = model.axis_line(
- 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: np.ndarray, measured_observer: np.ndarray
- ) -> float:
- measured_cone = math.acos(
- abs(
- float(
- np.clip(
- candidate @ measured_observer, -1.0, 1.0
- )
- )
- )
- )
- return abs(measured_cone - expected_cone)
-
- if name not in cross_view_direction_source_cache:
- primary_residuals: list[float] = []
- secondary_residuals: list[float] = []
- for group_cycle in range(repetitions):
- group_observer = np.asarray(
- fit_raw(
- observer_name, group_cycle
- ).axis_common_xyz,
- dtype=float,
- )
- primary_residuals.append(
- cone_residual(
- np.asarray(
- fit_raw(
- name, group_cycle
- ).axis_common_xyz,
- dtype=float,
- ),
- group_observer,
- )
- )
- secondary_residuals.append(
- cone_residual(
- np.asarray(
- fit_raw(
- validation_name, group_cycle
- ).axis_common_xyz,
- dtype=float,
- ),
- group_observer,
- )
- )
- cross_view_direction_source_cache[name] = (
- select_cross_view_roll_direction_source(
- primary_residuals,
- secondary_residuals,
- math.radians(5.0),
- )
- )
- if cross_view_direction_source_cache[name] == "secondary":
- selected_direction = secondary_axis
- selected_source = "cross_view_cone_selected_secondary"
- # Mirror the live node: use the view whose direction satisfies
- # the zero-invariant parent/child cone, and take the physical line
- # position from the side PIP-link circle.
- return replace(
- primary,
- axis_common_xyz=tuple(
- float(value) for value in selected_direction
- ),
- point_common_xyz_m=tuple(
- float(value)
- for value in secondary.point_common_xyz_m
- ),
- pose_axis_line_rms_m=secondary.pose_axis_line_rms_m,
- pose_axis_line_source_joints=(validation_name,),
- axis_direction_source=selected_source,
- axis_point_source="side_circle_cross_view",
- )
- 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
- fused_axis = (
- primary_weight * primary_axis + secondary_weight * secondary_axis
- )
- fused_axis /= np.linalg.norm(fused_axis)
- delta = secondary_point - primary_point
- perpendicular = delta - fused_axis * float(delta @ fused_axis)
- fused_point = primary_point + (
- secondary_weight / (primary_weight + secondary_weight)
- ) * perpendicular
- 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),
- 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=(name, validation_name),
- axis_direction_source="cross_view_weighted_fusion",
- )
-
- result: list[JointAxisMeasurement] = []
- for name in zero_profile.axis_joints:
- group = [fit_one(name, cycle) for cycle in range(repetitions)]
- side_sources = {
- source
- for measurement in group
- if (source := cross_view_side_line_source(measurement)) is not None
- }
- if (
- len(side_sources) == 1
- and all(
- cross_view_side_line_source(measurement) is not None
- for measurement in group
- )
- and not any(
- axis_line_uses_depth_free_interpretation_plane(measurement)
- for measurement in group
- )
- ):
- source = next(iter(side_sources))
- source_spec = profile.record_specs[source]
- group = list(
- refit_axis_line_group_with_shared_radius(
- group,
- records_by_joint[source],
- zero_command_u8=int(
- baseline[source_spec.motor_index]
- ),
- canonical_zero_direction=canonical_zero_direction(
- profile, source
- ),
- )
- )
- result.extend(group)
- return result
-
-
-def _maximum_undirected_axis_difference(axes: Sequence[Sequence[float]]) -> float:
- maximum = 0.0
- for left in axes:
- for right in axes:
- maximum = max(
- maximum,
- math.acos(
- abs(
- float(
- np.clip(
- np.asarray(left, dtype=float)
- @ np.asarray(right, dtype=float),
- -1.0,
- 1.0,
- )
- )
- )
- ),
- )
- return maximum
-
-
-def _quality_failures(
- *,
- records_by_joint: Mapping[str, Sequence[Mapping[str, Any]]],
- baseline_records_by_joint: Mapping[
- str, Sequence[Mapping[str, Any]]
- ] | None = None,
- profile: HandCalibrationProfile,
- baseline: Sequence[int],
- fits: Mapping[str, JointCurveFit],
- axes: Sequence[JointAxisMeasurement],
- parameters: Mapping[str, Any],
-) -> list[str]:
- failures: list[str] = []
- repetitions = int(parameters["repetitions"])
- zero_profile = get_zero_calibration_profile(
- profile.side, profile.layout_id
- )
- axis_by_key = {(item.joint, item.cycle): item for item in axes}
- expected_directions = {"decreasing", "increasing"}
- for name in profile.measured_joints:
- records = list(records_by_joint.get(name, ()))
- if not records:
- failures.append(f"{name}: no samples")
- continue
- attempts_by_direction: dict[tuple[int, str], set[int]] = defaultdict(set)
- for record in records:
- attempts_by_direction[
- (int(record["cycle"]), str(record["direction"]))
- ].add(int(record.get("attempt", 1)))
- for cycle in range(repetitions):
- for direction in expected_directions:
- selected = [
- record
- for record in records
- if int(record["cycle"]) == cycle
- and str(record["direction"]) == direction
- ]
- attempts = attempts_by_direction.get((cycle, direction), set())
- if len(attempts) != 1:
- failures.append(
- f"{name} cycle {cycle + 1} {direction}: ambiguous attempts {sorted(attempts)}"
- )
- continue
- commands = sorted({int(record["command_u8"]) for record in selected})
- if len(selected) < int(parameters["minimum_sweep_frames"]):
- failures.append(f"{name} cycle {cycle + 1} {direction}: too few frames")
- if not commands or max(commands) - min(commands) < float(
- parameters["minimum_state_span_u8"]
- ):
- failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient span")
- if len(commands) < int(parameters["minimum_sweep_bins"]):
- failures.append(f"{name} cycle {cycle + 1} {direction}: insufficient bins")
- if 0 not in commands or 255 not in commands:
- failures.append(f"{name} cycle {cycle + 1} {direction}: endpoint missing")
- if commands and max(np.diff(commands), default=0) > int(
- parameters["maximum_bin_gap"]
- ):
- failures.append(f"{name} cycle {cycle + 1} {direction}: bin gap")
-
- sync_p95 = float(
- np.percentile(
- [float(record.get("state_image_sync_error_ms", 0.0)) for record in records],
- 95.0,
- )
- )
- if sync_p95 > float(parameters["maximum_state_image_skew_ms"]):
- failures.append(f"{name}: state/image sync p95 {sync_p95:.3f}ms")
- spec = profile.joint_specs[name]
- fit = fits[name]
- if fit.circle.get("space") == "image_2d":
- if float(fit.quality["radial_rms_px"]) > float(
- parameters["image_trajectory_maximum_radial_rms_px"]
- ):
- failures.append(f"{name}: image radial RMS")
- if float(fit.quality["radial_p95_px"]) > float(
- parameters["image_trajectory_maximum_radial_p95_px"]
- ):
- failures.append(f"{name}: image radial P95")
- if float(fit.quality["radius_px"]) < float(
- parameters["image_trajectory_minimum_radius_px"]
- ):
- failures.append(f"{name}: image radius")
- else:
- orthogonal_limit = math.radians(
- float(
- parameters[
- "active_maximum_rotation_orthogonal_rms_deg"
- if spec.active
- else "passive_maximum_rotation_orthogonal_rms_deg"
- ]
- )
- )
- if (
- float(fit.quality["rotation_orthogonal_rms_rad"])
- > orthogonal_limit
- ):
- failures.append(f"{name}: rotation orthogonal RMS")
- if float(fit.quality["arc_rad"]) < math.radians(
- float(parameters["trajectory_minimum_arc_deg"])
- ):
- failures.append(f"{name}: trajectory arc")
- monotonic_limit = math.radians(
- float(
- parameters[
- "maximum_monotonic_correction_deg"
- if spec.active
- else "passive_maximum_monotonic_correction_deg"
- ]
- )
- )
- hysteresis_limit = math.radians(
- float(
- parameters[
- "maximum_hysteresis_deg"
- if spec.active
- else "passive_maximum_hysteresis_deg"
- ]
- )
- )
- if fit.maximum_monotonic_correction_rad > monotonic_limit:
- failures.append(f"{name}: monotonic correction")
- if (
- profile.layout_id != G20_RIGHT_19_LAYOUT
- and fit.maximum_hysteresis_rad > hysteresis_limit
- ):
- failures.append(f"{name}: hysteresis")
- if profile.layout_id == G20_RIGHT_19_LAYOUT:
- hysteresis_records = records
- dedicated = (baseline_records_by_joint or {}).get(name, ())
- if int(baseline[spec.motor_index]) not in {0, 255} and dedicated:
- hysteresis_records = list(dedicated)
- baseline_hysteresis = baseline_hysteresis_by_cycle_rad(
- hysteresis_records,
- zero_command_u8=int(baseline[spec.motor_index]),
- axis_xyz=_physical_rotation_axis_for_curve(
- name,
- records,
- fit,
- profile=profile,
- baseline=baseline,
- ),
- )
- if canonical_zero_direction(profile, name) is not None:
- gap_limit = math.radians(
- float(
- parameters.get(
- "directional_zero_maximum_branch_gap_deg", 1.5
- )
- )
- )
- gap_range_limit = math.radians(
- float(
- parameters.get(
- "directional_zero_maximum_branch_gap_range_deg",
- 0.3,
- )
- )
- )
- if max(baseline_hysteresis) > gap_limit:
- failures.append(f"{name}: baseline directional gap")
- if (
- max(baseline_hysteresis) - min(baseline_hysteresis)
- > gap_range_limit
- and spec.zero_kind != "axis_cross_view_validation"
- ):
- failures.append(
- f"{name}: baseline directional gap repeatability"
- )
- elif max(baseline_hysteresis) > math.radians(
- float(parameters.get("baseline_maximum_hysteresis_deg", 0.5))
- ):
- failures.append(f"{name}: baseline hysteresis")
-
- cycle_travels: list[float] = []
- cycle_axes: list[Sequence[float]] = []
- cycle_axis_sources: list[str] = []
- cycle_axis_measurements: list[JointAxisMeasurement] = []
- for cycle in range(repetitions):
- cycle_fit = _fit_curve(
- name,
- [record for record in records if int(record["cycle"]) == cycle],
- profile=profile,
- baseline=baseline,
- )
- cycle_travels.append(
- abs(float(cycle_fit.angle_rad[0]) - float(cycle_fit.angle_rad[255]))
- )
- axis = axis_by_key[(name, cycle)]
- cycle_axes.append(axis.axis_common_xyz)
- cycle_axis_sources.append(axis.axis_direction_source)
- cycle_axis_measurements.append(axis)
- if axis.radial_rms_m > float(parameters["axis_maximum_radial_rms_m"]):
- failures.append(f"{name} cycle {cycle + 1}: radial RMS")
- if (
- profile.record_specs[name].zero_kind
- != "axis_cross_view_validation"
- and cross_view_side_line_source(axis) is None
- and axis.pose_axis_line_rms_m
- > float(parameters["axis_maximum_pose_line_rms_m"])
- ):
- failures.append(
- f"{name} cycle {cycle + 1}: pose axis-line RMS"
- )
- if not circle_direction_is_constrained(
- name, zero_profile.constrained_circle_joints
- ):
- plane_limit = float(
- parameters[
- "axis_maximum_plane_rms_m"
- if spec.active
- else "passive_axis_maximum_plane_rms_m"
- ]
- )
- if axis.plane_rms_m > plane_limit:
- failures.append(f"{name} cycle {cycle + 1}: plane RMS")
- if (
- axis.rotation_circle_axis_difference_rad > math.radians(
- float(parameters["axis_maximum_rotation_circle_difference_deg"])
- )
- ):
- failures.append(f"{name} cycle {cycle + 1}: axis disagreement")
- 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) == 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
- )
- and axis_line_cycle_rms_m(cycle_axis_measurements)
- > float(parameters["axis_maximum_pose_line_rms_m"])
- ):
- source = next(iter(side_line_sources))
- failures.append(f"{source}: axis-line cycle RMS")
- travel_limit = math.radians(
- float(
- parameters[
- "trajectory_maximum_cycle_travel_difference_deg"
- if spec.active
- else "passive_maximum_cycle_travel_difference_deg"
- ]
- )
- )
- if max(cycle_travels) - min(cycle_travels) > travel_limit:
- failures.append(f"{name}: cycle travel difference")
- if (
- not all(
- source == "upstream_constraint"
- for source in cycle_axis_sources
- )
- and spec.zero_kind != "axis_cross_view_validation"
- and _maximum_undirected_axis_difference(cycle_axes) > math.radians(
- float(parameters["zero_maximum_axis_cycle_difference_deg"])
- )
- ):
- failures.append(f"{name}: cycle axis difference")
- return failures
-
-
-def _joint_xml(path: Path) -> dict[str, ET.Element]:
- return {
- str(joint.get("name")): joint
- for joint in ET.parse(path).getroot().findall("joint")
- }
-
-
-def _triplet(value: str) -> np.ndarray:
- return np.asarray([float(item) for item in value.split()], dtype=float)
-
-
-def _validate_corrected_urdf(
- *,
- source: Path,
- corrected: Path,
- offsets: Mapping[str, float],
- endpoint_anchored_offsets: Mapping[str, float],
- axes: Sequence[JointAxisMeasurement],
- curves: Mapping[str, JointCurveFit],
- motor_by_joint: Mapping[str, int],
- inherited_zero_joints: Mapping[str, str],
-) -> dict[str, float]:
- source_joints = _joint_xml(source)
- corrected_joints = _joint_xml(corrected)
- if set(source_joints) != set(corrected_joints):
- raise ValueError("corrected URDF changed the joint set")
- maximum_origin_rotation_error = 0.0
- maximum_origin_translation_error = 0.0
- for name, original_joint in source_joints.items():
- corrected_joint = corrected_joints[name]
- original_origin = original_joint.find("origin")
- corrected_origin = corrected_joint.find("origin")
- if original_origin is None or corrected_origin is None:
- continue
- original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
- corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
- maximum_origin_translation_error = max(
- maximum_origin_translation_error,
- float(np.linalg.norm(corrected_xyz - original_xyz)),
- )
- original_rotation = Rotation.from_euler(
- "xyz", _triplet(original_origin.get("rpy", "0 0 0"))
- )
- corrected_rotation = Rotation.from_euler(
- "xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
- )
- expected = original_rotation
- if name in offsets:
- axis_node = original_joint.find("axis")
- axis = _triplet(
- "1 0 0" if axis_node is None else axis_node.get("xyz", "1 0 0")
- )
- axis /= np.linalg.norm(axis)
- expected = original_rotation * Rotation.from_rotvec(
- axis * float(offsets[name])
- )
- error = float((expected.inv() * corrected_rotation).magnitude())
- maximum_origin_rotation_error = max(maximum_origin_rotation_error, error)
- original_limit = original_joint.find("limit")
- corrected_limit = corrected_joint.find("limit")
- if original_limit is not None and corrected_limit is not None:
- expected_lower = float(original_limit.get("lower"))
- expected_upper = float(original_limit.get("upper"))
- if name in endpoint_anchored_offsets:
- expected_upper -= float(endpoint_anchored_offsets[name])
- if (
- abs(float(corrected_limit.get("lower")) - expected_lower) > 1.0e-12
- or abs(float(corrected_limit.get("upper")) - expected_upper) > 1.0e-12
- ):
- raise ValueError(f"corrected URDF unexpectedly changed {name} limits")
- original_mimic = original_joint.find("mimic")
- corrected_mimic = corrected_joint.find("mimic")
- if original_mimic is not None and corrected_mimic is not None:
- source_joint = str(original_mimic.get("joint"))
- multiplier = float(original_mimic.get("multiplier", "1"))
- expected_mimic_offset = float(original_mimic.get("offset", "0"))
- expected_mimic_offset += multiplier * float(
- endpoint_anchored_offsets.get(source_joint, 0.0)
- )
- if abs(
- float(corrected_mimic.get("offset", "0"))
- - expected_mimic_offset
- ) > 1.0e-12:
- raise ValueError(f"corrected URDF has invalid {name} mimic offset")
- if maximum_origin_translation_error > 1.0e-12:
- raise ValueError("corrected URDF changed a joint origin translation")
- if maximum_origin_rotation_error > 1.0e-10:
- raise ValueError("corrected URDF does not implement T_original * Rot(axis, offset)")
- original_model = UrdfKinematicModel(source)
- corrected_model = UrdfKinematicModel(corrected)
- maximum_axis_error = 0.0
- maximum_point_error = 0.0
- for measurement in axes:
- state = (
- measurement.condition_state_u8
- if measurement.condition_command_u8 is None
- else measurement.condition_command_u8
- )
- angles = _angles_from_state(
- state,
- curves=curves,
- motor_by_joint=motor_by_joint,
- inherited_zero_joints=inherited_zero_joints,
- )
- expected_axis, expected_point = original_model.axis_line(
- measurement.joint,
- zero_offsets=offsets,
- joint_angles=angles,
- )
- actual_axis, actual_point = corrected_model.axis_line(
- measurement.joint,
- zero_offsets={},
- joint_angles=angles,
- )
- maximum_axis_error = max(
- maximum_axis_error,
- math.acos(float(np.clip(expected_axis @ actual_axis, -1.0, 1.0))),
- )
- maximum_point_error = max(
- maximum_point_error,
- float(np.linalg.norm(expected_point - actual_point)),
- )
- if maximum_axis_error > 1.0e-7 or maximum_point_error > 1.0e-10:
- raise ValueError("written URDF kinematics differ from the solved correction")
- return {
- "maximum_origin_rotation_error_rad": maximum_origin_rotation_error,
- "maximum_origin_translation_error_m": maximum_origin_translation_error,
- "maximum_axis_equivalence_error_rad": maximum_axis_error,
- "maximum_axis_point_equivalence_error_m": maximum_point_error,
- }
-
-
-def replay_session(
- session_dir: str | Path,
- *,
- serial_number: str | None = None,
- config_file: str | Path | None = None,
- write_outputs: bool = False,
- output_tag: str | None = None,
-) -> dict[str, Any]:
- session = Path(session_dir).expanduser().resolve()
- package_root = Path(__file__).resolve().parents[1]
- config = (
- package_root / "config" / "three_camera_calibration.yaml"
- if config_file is None
- else Path(config_file).expanduser().resolve()
- )
- parameters = _load_parameters(config)
- (
- start,
- records_by_joint,
- baseline_records_by_joint,
- command_records_by_joint,
- palm_axis_records_by_source,
- raw_path,
- ) = _load_raw_session(session)
- model = str(start.get("model", "G20")).strip().upper()
- side = str(start["hand_type"]).lower()
- requested_layout_id = str(
- start.get("tag_layout", "legacy_11")
- ).lower()
- contract = get_product_calibration_contract(
- model, side, requested_layout_id
- )
- profile = contract.profile
- # Normalise the former g20_right_15 compatibility alias immediately so
- # every downstream product guard uses the actual physical 19-Tag profile
- # instead of silently falling through legacy-11 logic.
- layout_id = profile.layout_id
- zero_profile = contract.zero_profile
- baseline = tuple(int(value) for value in start["baseline_command_u8"])
- if len(baseline) != profile.command_count:
- raise ValueError(
- "session baseline length differs from the registered product schema"
- )
- if (
- layout_id == G20_RIGHT_19_LAYOUT
- and write_outputs
- and bool(parameters.get("combination_validation_enabled", False))
- ):
- replay_rows = [
- json.loads(line)
- for line in raw_path.read_text(encoding="utf-8").splitlines()
- if line.strip()
- ]
- combinations = [
- row
- for row in replay_rows
- if row.get("kind") == "combination_validation_sample"
- and bool(row.get("passed"))
- ]
- expected_poses = {
- "all_open", "thumb_middle", "index_middle", "middle_middle",
- "ring_middle", "pinky_middle", "half_grip", "light_pinch",
- }
- if {str(row.get("pose_name")) for row in combinations} != expected_poses:
- raise ValueError(
- "incomplete session cannot publish: eight combination poses are required"
- )
- if any(
- float(row.get("position_p95_m", float("inf"))) > 0.003
- or float(row.get("orientation_p95_rad", float("inf")))
- > math.radians(2.0)
- for row in combinations
- ):
- raise ValueError("combination pose validation exceeds product limits")
- required_record_joints = set(profile.record_joints)
- if (
- not required_record_joints.issubset(records_by_joint)
- or not set(records_by_joint).issubset(set(profile.record_joints))
- ):
- raise ValueError(
- "raw session does not contain the required task record set"
- )
-
- def hysteresis_records(
- name: str,
- ) -> Sequence[Mapping[str, Any]]:
- spec = profile.record_specs[name]
- zero = int(baseline[spec.motor_index])
- if (
- layout_id == G20_RIGHT_19_LAYOUT
- and zero not in {0, 255}
- and start.get("baseline_hysteresis_source")
- == "dedicated_mid_sweep_hold"
- ):
- records = baseline_records_by_joint.get(name, [])
- if not records:
- raise ValueError(
- f"{name} is missing dedicated baseline hold samples"
- )
- return records
- return records_by_joint[name]
-
- def model_records(
- name: str, *, require_full_pose: bool = False
- ) -> list[dict[str, Any]]:
- """Merge settled baseline holds into directional model records."""
- records = [
- dict(record) for record in records_by_joint.get(name, ())
- ]
- if canonical_zero_direction(profile, name) is None:
- return records
- required = {
- "relative_translation_xyz_m",
- "parent_pose_common",
- }
- records.extend(
- dict(record)
- for record in baseline_records_by_joint.get(name, ())
- if not require_full_pose or required.issubset(record)
- )
- return records
-
- curve_records_by_joint = {
- name: model_records(name) for name in profile.record_joints
- }
- axis_records_by_joint = {
- name: model_records(name, require_full_pose=True)
- for name in profile.record_joints
- }
- source_urdf = Path(start["source_urdf_path"]).expanduser().resolve()
- extrinsics_file = Path(start["camera_extrinsics_file"]).expanduser().resolve()
- if not source_urdf.is_file() or not extrinsics_file.is_file():
- raise ValueError("session source URDF or camera extrinsics is missing")
- if "zero_calibrated" in source_urdf.stem.lower():
- raise ValueError("offline replay requires the original CAD URDF")
- hand_serial = str(serial_number or session.parent.name)
- output_suffix = _output_suffix(output_tag)
- repetitions = int(
- parameters.get("g20_right_19_repetitions", 4)
- if layout_id == G20_RIGHT_19_LAYOUT
- else parameters["repetitions"]
- )
-
- measured_fits = {
- name: _fit_curve(
- name,
- curve_records_by_joint[name],
- profile=profile,
- baseline=baseline,
- )
- for name in profile.measured_joints
- }
- validation_cycle = repetitions - 1
- training_cycles = tuple(range(validation_cycle))
- training_cycle_set = set(training_cycles)
- if layout_id == G20_RIGHT_19_LAYOUT and len(training_cycles) < 3:
- raise ValueError(
- "g20_right_19 replay requires at least 3 training cycles and "
- "one isolated holdout"
- )
- training_fits = {
- name: _fit_curve(
- name,
- [
- record
- for record in curve_records_by_joint[name]
- if int(record["cycle"]) in training_cycle_set
- ],
- profile=profile,
- baseline=baseline,
- )
- for name in profile.measured_joints
- }
- command_fits = dict(training_fits)
- if layout_id == G20_RIGHT_19_LAYOUT:
- if set(command_records_by_joint) != required_record_joints:
- raise ValueError(
- "g20_right_19 session is incomplete: missing steady command checkpoints"
- )
- command_fits = {}
- for name in profile.measured_joints:
- if name in G20_REFERENCE_THUMB_CMC_JOINTS:
- command_fits[name] = measured_fits[name]
- continue
- records = [
- dict(record)
- for record in command_records_by_joint[name]
- if int(record["cycle"]) == 0
- ]
- if canonical_zero_direction(profile, name) is not None:
- records.extend(
- dict(record)
- for record in baseline_records_by_joint.get(name, ())
- if int(record["cycle"]) == 0
- )
- if len(records) < 18:
- raise ValueError(
- f"{name} has {len(records)}/18 steady command checkpoints"
- )
- command_fits[name] = _fit_curve(
- name, records, profile=profile, baseline=baseline
- )
- command_gap_limit = math.radians(
- float(parameters.get("command_maximum_direction_gap_deg", 2.0))
- )
- command_feedback_fits = {
- name: _fit_curve(
- name,
- _steady_records_in_feedback_domain(
- [
- dict(record)
- for record in command_records_by_joint[name]
- if int(record["cycle"]) == 0
- ]
- ),
- profile=profile,
- baseline=baseline,
- )
- for name in profile.measured_joints
- if name not in G20_REFERENCE_THUMB_CMC_JOINTS
- }
- failed_command_gaps = {
- name: fit.maximum_hysteresis_rad
- for name, fit in command_feedback_fits.items()
- if fit.maximum_hysteresis_rad > command_gap_limit
- }
- if failed_command_gaps:
- raise ValueError(
- "command-direction gap exceeds 2deg: "
- + ", ".join(
- f"{name}={math.degrees(value):.3f}deg"
- for name, value in failed_command_gaps.items()
- )
- )
- command_fits = derive_mimic_passive_fits(
- source_urdf,
- command_fits,
- profile=profile,
- )
- command_fits = anchor_right_19_mechanical_endpoint_curves(
- command_fits,
- {
- name: (
- [
- record
- for record in curve_records_by_joint[name]
- if int(record["cycle"]) in training_cycle_set
- ]
- if name == "thumb_cmc_roll"
- else command_records_by_joint[name]
- )
- for name in RIGHT_19_ENDPOINT_MEASUREMENT_JOINTS
- },
- maximum_direction_difference_rad=command_gap_limit,
- feedback_endpoint_joints=frozenset({"thumb_cmc_roll"}),
- )
- cross_view_roll_metrics: dict[str, dict[str, float]] = {}
- for name, validation_name in (
- profile.axis_validation_sources or {}
- ).items():
- validation_training = _fit_curve(
- validation_name,
- [
- record
- for record in curve_records_by_joint[validation_name]
- if int(record["cycle"]) in training_cycle_set
- ],
- profile=profile,
- baseline=baseline,
- )
- validation_all = _fit_curve(
- validation_name,
- curve_records_by_joint[validation_name],
- profile=profile,
- baseline=baseline,
- )
- training_metrics = cross_view_roll_diagnostic_metrics(
- training_fits[name], validation_training
- )
- final_metrics = cross_view_roll_diagnostic_metrics(
- measured_fits[name], validation_all
- )
- cross_view_roll_metrics[name] = {
- **{
- f"training_{key}": value
- for key, value in training_metrics.items()
- },
- **{
- f"final_{key}": value
- for key, value in final_metrics.items()
- },
- }
- axes = _fit_axes(
- records_by_joint=axis_records_by_joint,
- profile=profile,
- baseline=baseline,
- extrinsics_file=extrinsics_file,
- source_urdf=source_urdf,
- repetitions=repetitions,
- )
- palm_orientation_measurements, palm_orientation_rejections = (
- fit_partial_palm_orientation_measurements(
- sources=profile.palm_orientation_sources,
- records_by_joint=palm_axis_records_by_source,
- motor_by_source=profile.palm_axis_motor_by_source,
- baseline_command_u8=baseline,
- cycles=range(repetitions),
- minimum_sources=int(
- profile.minimum_palm_orientation_sources
- ),
- minimum_arc_rad=(
- profile.palm_orientation_minimum_arc_rad
- ),
- maximum_rotation_orthogonal_rms_rad=math.radians(
- float(
- parameters[
- "active_maximum_rotation_orthogonal_rms_deg"
- ]
- )
- ),
- maximum_command_distance_u8=(
- profile.palm_orientation_maximum_command_distance_u8
- ),
- )
- )
- failures = _quality_failures(
- records_by_joint=records_by_joint,
- baseline_records_by_joint=(
- baseline_records_by_joint
- if start.get("baseline_hysteresis_source")
- == "dedicated_mid_sweep_hold"
- else None
- ),
- profile=profile,
- baseline=baseline,
- fits=measured_fits,
- axes=axes,
- parameters=parameters,
- )
- if failures:
- raise ValueError("offline trajectory/axis validation failed: " + "; ".join(failures))
-
- holdout_by_joint = {
- name: joint_curve_holdout_errors(
- training_fits[name],
- [
- record
- for record in curve_records_by_joint[name]
- if int(record["cycle"]) == validation_cycle
- ],
- zero_command_u8=int(
- baseline[profile.joint_specs[name].motor_index]
- ),
- )
- for name in profile.measured_joints
- }
- joint_dynamic_diagnostics: dict[str, dict[str, Any]] = {}
- for name in profile.measured_joints:
- cycle_travel: list[float] = []
- for cycle in range(repetitions):
- cycle_fit = _fit_curve(
- name,
- [
- record
- for record in records_by_joint[name]
- if int(record["cycle"]) == cycle
- ],
- profile=profile,
- baseline=baseline,
- )
- 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(
- hysteresis_records(name),
- zero_command_u8=int(
- baseline[profile.joint_specs[name].motor_index]
- ),
- axis_xyz=_physical_rotation_axis_for_curve(
- name,
- records_by_joint[name],
- measured_fits[name],
- profile=profile,
- baseline=baseline,
- ),
- )
- joint_dynamic_diagnostics[name] = {
- "cycle_travel_rad": [float(value) for value in cycle_travel],
- "cycle_travel_range_rad": max(cycle_travel) - min(cycle_travel),
- "baseline_hysteresis_by_cycle_rad": [
- float(value) for value in baseline_hysteresis
- ],
- "holdout_cycle": validation_cycle,
- "holdout_cycle_mae_rad": float(np.mean(holdout)),
- "holdout_cycle_max_rad": float(np.max(holdout)),
- }
- trajectory_errors = np.abs(
- np.asarray(
- [value for values in holdout_by_joint.values() for value in values],
- dtype=float,
- )
- )
- maximum_validation_mae = math.radians(
- float(parameters["maximum_validation_mae_deg"])
- )
- maximum_validation_p95 = math.radians(
- float(parameters["maximum_validation_p95_deg"])
- )
- trajectory_mae = float(np.mean(trajectory_errors))
- trajectory_p95 = float(np.percentile(trajectory_errors, 95.0))
- if (
- trajectory_mae > maximum_validation_mae
- or trajectory_p95 > maximum_validation_p95
- or (
- layout_id == G20_RIGHT_19_LAYOUT
- and float(np.max(trajectory_errors))
- > math.radians(
- float(parameters.get("maximum_validation_error_deg", 1.5))
- )
- )
- or (
- layout_id == G20_RIGHT_19_LAYOUT
- and any(
- float(np.mean(np.abs(np.asarray(values, dtype=float))))
- > maximum_validation_mae
- or float(np.max(np.abs(np.asarray(values, dtype=float))))
- > math.radians(
- float(
- parameters.get("maximum_validation_error_deg", 1.5)
- )
- )
- for values in holdout_by_joint.values()
- )
- )
- ):
- raise ValueError("isolated trajectory holdout failed")
-
- motor_by_joint = {
- name: int(spec.motor_index) for name, spec in profile.joint_specs.items()
- }
- measured_endpoint_zero_offsets = (
- derive_right_19_mechanical_endpoint_offsets(
- source_urdf,
- command_fits,
- maximum_offset_rad=math.radians(
- float(
- parameters.get(
- "mechanical_endpoint_maximum_offset_deg", 5.0
- )
- )
- ),
- )
- if layout_id == G20_RIGHT_19_LAYOUT
- else {}
- )
- 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
- }
- command_fits = clamp_runtime_fits_to_urdf_limits(
- source_urdf,
- command_fits,
- endpoint_anchored_offsets_rad=endpoint_zero_offsets,
- )
- joint_limits: dict[str, float] = {}
- solve_arguments = {
- "source_urdf": source_urdf,
- "measurements": axes,
- "palm_orientation_measurements": palm_orientation_measurements,
- "motor_by_joint": motor_by_joint,
- "maximum_offset_rad": math.radians(float(parameters["zero_maximum_offset_deg"])),
- "finger_maximum_offset_rad": math.radians(
- float(parameters.get("zero_finger_maximum_offset_deg", 3.0))
- ),
- "joint_maximum_offset_rad": joint_limits,
- "maximum_cycle_difference_rad": math.radians(
- float(parameters["zero_maximum_axis_cycle_difference_deg"])
- ),
- "maximum_axis_cone_mismatch_rad": math.radians(
- float(parameters["zero_maximum_axis_cone_mismatch_deg"])
- ),
- "maximum_systematic_axis_cone_bias_rad": math.radians(
- float(
- parameters.get(
- "cross_view_roll_maximum_axis_difference_deg", 15.0
- )
- )
- ),
- "maximum_observability_condition_number": float(
- parameters.get(
- "zero_maximum_observability_condition_number", 1.0e10
- )
- ),
- "maximum_pose_axis_line_rms_m": float(
- parameters["axis_maximum_pose_line_rms_m"]
- ),
- "maximum_validation_mae_rad": maximum_validation_mae,
- "maximum_validation_p95_rad": maximum_validation_p95,
- "maximum_validation_error_rad": (
- math.radians(
- float(parameters.get("maximum_validation_error_deg", 1.5))
- )
- if layout_id == G20_RIGHT_19_LAYOUT
- else None
- ),
- "maximum_confidence_half_width_rad": (
- math.radians(
- float(
- parameters.get(
- "zero_maximum_confidence_half_width_deg", 0.5
- )
- )
- )
- if layout_id == G20_RIGHT_19_LAYOUT
- else None
- ),
- "hand_type": side,
- "tag_layout": layout_id,
- "training_cycles": training_cycles,
- "validation_cycle": validation_cycle,
- "fixed_direct_zero_offsets_rad": {
- **zero_profile.fixed_direct_zero_offsets_rad,
- **endpoint_zero_offsets,
- },
- "static_output_zero_offsets_rad": {
- **zero_profile.static_output_zero_offsets_rad,
- **post_solve_endpoint_offsets,
- },
- }
- def solve_with_product_partition(
- fits: Mapping[str, JointCurveFit],
- arguments: Mapping[str, Any],
- ):
- if layout_id != G20_RIGHT_19_LAYOUT:
- return solve_urdf_zero_offsets(curves=fits, **arguments)
- thumb_profile = get_right_19_thumb_zero_profile()
- thumb_names = set(thumb_profile.direct_zero_joints)
- thumb = solve_urdf_zero_offsets(
- curves=fits,
- **{
- **arguments,
- "measurements": [
- item
- for item in arguments["measurements"]
- if item.joint in thumb_profile.axis_joints
- ],
- "joint_maximum_offset_rad": {
- name: value
- for name, value in arguments[
- "joint_maximum_offset_rad"
- ].items()
- if name in thumb_names
- },
- "fixed_direct_zero_offsets_rad": {
- name: value
- for name, value in arguments[
- "fixed_direct_zero_offsets_rad"
- ].items()
- if name in thumb_names
- },
- "static_output_zero_offsets_rad": {
- name: value
- for name, value in arguments[
- "static_output_zero_offsets_rad"
- ].items()
- if name in thumb_names
- },
- "zero_profile": thumb_profile,
- },
- )
- companion = solve_urdf_zero_offsets(
- curves=fits,
- **{
- **arguments,
- "fixed_direct_zero_offsets_rad": {
- **arguments["fixed_direct_zero_offsets_rad"],
- **thumb.direct_offsets_rad,
- },
- },
- )
- return merge_right_19_thumb_zero_result(
- thumb_result=thumb,
- companion_result=companion,
- )
-
- holdout_zero = solve_with_product_partition(training_fits, solve_arguments)
- if not holdout_zero.passed:
- failure = {
- "reasons": dict(holdout_zero.failure_reasons),
- "fitted_offsets_deg": {
- name: math.degrees(value)
- for name, value in holdout_zero.direct_offsets_rad.items()
- },
- "cycle_offsets_deg": {
- name: [math.degrees(value) for value in values]
- for name, values in holdout_zero.cycle_offsets_rad.items()
- },
- }
- raise ValueError(
- "isolated zero/URDF holdout failed: "
- + json.dumps(failure, ensure_ascii=False, sort_keys=True)
- )
- # Keep the model frozen after the holdout decision. Re-fitting with the
- # held-out cycle would make the published artifact differ from the model
- # that was independently evaluated above.
- final_zero = holdout_zero
- measured_fits = command_fits
- for target, source_name in zero_profile.inherited_static_zero_joints.items():
- if final_zero.all_active_offsets_rad[target] != final_zero.direct_offsets_rad[source_name]:
- raise ValueError(f"inherited static zero mismatch: {target} <- {source_name}")
- for target in (
- set(zero_profile.inherited_zero_joints)
- - set(zero_profile.inherited_static_zero_joints)
- ):
- if final_zero.all_active_offsets_rad[target] != 0.0:
- raise ValueError(f"unobserved static zero must retain source CAD: {target}")
-
- validation_errors = [
- float(value) for values in holdout_by_joint.values() for value in values
- ]
- validation_errors.extend(float(value) for value in holdout_zero.validation_errors_rad)
- stamp = session.name
- final_json = session / (
- f"g20_{side}_{hand_serial}_calibration{output_suffix}.json"
- )
- expected_urdf_name = (
- f"{source_urdf.stem}_zero_calibrated_{hand_serial}_{stamp}"
- f"{output_suffix}.urdf"
- )
- final_urdf = session / expected_urdf_name
- report_path = session / (
- f"g20_{side}_{hand_serial}_offline_validation{output_suffix}.json"
- )
- if write_outputs:
- existing = [path for path in (final_json, final_urdf, report_path) if path.exists()]
- if existing:
- raise ValueError(
- "refusing to overwrite replay outputs: "
- + ", ".join(str(path) for path in existing)
- )
-
- source_hash_before = _sha256(source_urdf)
- published_zero_offsets = {
- name: round(float(value), 8)
- for name, value in final_zero.all_active_offsets_rad.items()
- }
- urdf_offsets = (
- {
- name: published_zero_offsets[name]
- for name in zero_profile.direct_zero_joints
- }
- if layout_id == G20_RIGHT_19_LAYOUT
- else published_zero_offsets
- )
- with tempfile.TemporaryDirectory(prefix="offline_replay_", dir=session) as temporary:
- candidate = write_zero_corrected_urdf(
- source_urdf=source_urdf,
- output_directory=temporary,
- serial_number=hand_serial,
- offsets_rad=urdf_offsets,
- endpoint_anchored_offsets_rad=endpoint_zero_offsets,
- timestamp=stamp,
- )
- urdf_checks = _validate_corrected_urdf(
- source=source_urdf,
- corrected=candidate,
- offsets=urdf_offsets,
- endpoint_anchored_offsets=endpoint_zero_offsets,
- axes=axes,
- curves=training_fits,
- motor_by_joint=motor_by_joint,
- inherited_zero_joints=zero_profile.inherited_zero_joints,
- )
- residual_zero = solve_with_product_partition(
- # Re-check the corrected URDF in the exact frozen trajectory
- # domain that produced the accepted zero offsets. Runtime
- # requested-command curves are a separate transfer function and
- # differ slightly at settled checkpoints; using them here creates
- # a fictitious residual zero after a structurally correct write.
- training_fits,
- {
- **solve_arguments,
- "static_output_zero_offsets_rad": {
- name: 0.0
- for name in (
- set(zero_profile.static_output_zero_offsets_rad)
- | set(post_solve_endpoint_offsets)
- )
- },
- "source_urdf": candidate,
- "fixed_direct_zero_offsets_rad": {
- name: 0.0
- for name in (
- set(zero_profile.fixed_direct_zero_offsets_rad)
- | set(endpoint_zero_offsets)
- )
- },
- },
- )
- maximum_residual_offset = max(
- abs(float(value)) for value in residual_zero.direct_offsets_rad.values()
- )
- if not residual_zero.passed or maximum_residual_offset > math.radians(0.3):
- raise ValueError(
- "written URDF retains a significant zero correction: "
- + json.dumps(
- {
- "passed": residual_zero.passed,
- "residual_offsets_deg": {
- name: math.degrees(value)
- for name, value in (
- residual_zero.direct_offsets_rad.items()
- )
- },
- "failure_reasons": dict(
- residual_zero.failure_reasons
- ),
- },
- ensure_ascii=False,
- sort_keys=True,
- )
- )
- candidate_hash = _sha256(candidate)
- payload = build_compact_payload(
- serial_number=hand_serial,
- measured_fits=command_fits,
- urdf_zero_offsets_rad=published_zero_offsets,
- validation_errors_rad=validation_errors,
- passed=True,
- baseline=baseline,
- side=side,
- layout_id=layout_id,
- zero_uncertainty_rad=(
- final_zero.offset_confidence_half_width_rad
- ),
- zero_cycle_offsets_rad=final_zero.cycle_offsets_rad,
- zero_observers=zero_profile.offset_observer_joint,
- artifact_hashes=(
- {
- "source_urdf_sha256": source_hash_before,
- "camera_extrinsics_sha256": _sha256(extrinsics_file),
- "corrected_urdf_sha256": candidate_hash,
- }
- if layout_id == G20_RIGHT_19_LAYOUT
- else None
- ),
- cross_view_roll_metrics=cross_view_roll_metrics,
- joint_dynamic_diagnostics=joint_dynamic_diagnostics,
- zero_geometry_diagnostics=(
- {
- "training_cycles": final_zero.training_cycles,
- "validation_cycle": final_zero.validation_cycle,
- "axis_line_rms_m": final_zero.axis_line_rms_m,
- "validation_line_error_by_joint_m": (
- final_zero.validation_line_error_by_joint_m
- ),
- "observability_rank": final_zero.observability_rank,
- "observability_parameter_count": (
- final_zero.observability_parameter_count
- ),
- "observability_condition_number": (
- final_zero.observability_condition_number
- ),
- "offset_covariance_rad2": (
- final_zero.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 palm_orientation_measurements
- ],
- "palm_orientation_rejections": dict(
- palm_orientation_rejections
- ),
- }
- if layout_id == G20_RIGHT_19_LAYOUT
- else None
- ),
- )
- validate_compact_payload(payload)
- if write_outputs:
- if _sha256(source_urdf) != source_hash_before:
- raise ValueError("source URDF changed during offline replay")
- os.replace(candidate, final_urdf)
- try:
- # As in the live path, the atomically written JSON is the
- # commit marker for the already validated URDF/JSON pair.
- atomic_write_json(final_json, payload)
- except Exception:
- if layout_id == G20_RIGHT_19_LAYOUT and final_urdf.exists():
- rejected = final_urdf.with_suffix(".urdf.rejected")
- final_urdf.replace(rejected)
- raise
-
- if _sha256(source_urdf) != source_hash_before:
- raise ValueError("source URDF changed during offline replay")
-
- report: dict[str, Any] = {
- "passed": True,
- "session_dir": str(session),
- "side": side,
- "serial_number": hand_serial,
- "output_tag": output_tag,
- "raw_samples_sha256": _sha256(raw_path),
- "source_urdf": str(source_urdf),
- "source_urdf_sha256": source_hash_before,
- "joint_limits_deg": {
- "finger_default": float(
- parameters.get("zero_finger_maximum_offset_deg", 3.0)
- ),
- "thumb_default": float(parameters["zero_maximum_offset_deg"]),
- },
- "static_zero_policy": (
- "visual_axes_plus_measured_mechanical_endpoint"
- if layout_id == G20_RIGHT_19_LAYOUT
- else "direct_measurements_only"
- ),
- "fixed_zero_offsets_deg": {
- name: math.degrees(value)
- for name, value in zero_profile.fixed_direct_zero_offsets_rad.items()
- },
- "endpoint_anchored_zero_offsets_deg": {
- name: math.degrees(value)
- for name, value in endpoint_zero_offsets.items()
- },
- "static_output_zero_offsets_deg": {
- name: math.degrees(value)
- for name, value in {
- **zero_profile.static_output_zero_offsets_rad,
- **post_solve_endpoint_offsets,
- }.items()
- },
- "direct_offsets_deg": {
- name: math.degrees(value)
- for name, value in final_zero.direct_offsets_rad.items()
- },
- "model_base_translation_xyz_m": list(
- final_zero.base_translation_xyz_m
- ),
- "model_base_quaternion_xyzw": list(
- final_zero.base_quaternion_xyzw
- ),
- "cycle_offsets_deg": {
- name: [math.degrees(value) for value in values]
- for name, values in holdout_zero.cycle_offsets_rad.items()
- },
- "axis_cone_mismatch_by_joint_deg": {
- name: math.degrees(value)
- for name, value in (
- holdout_zero.axis_cone_mismatch_by_joint_rad.items()
- )
- },
- "axis_cone_bias_classification_by_joint": dict(
- holdout_zero.axis_cone_bias_classification_by_joint
- ),
- "offset_uncertainty_deg": {
- name: math.degrees(value)
- for name, value in holdout_zero.offset_uncertainty_rad.items()
- },
- "trajectory_holdout_mae_deg": math.degrees(trajectory_mae),
- "trajectory_holdout_p95_deg": math.degrees(trajectory_p95),
- "zero_holdout_error_deg": {
- name: math.degrees(value)
- for name, value in holdout_zero.validation_error_by_joint_rad.items()
- },
- "zero_original_error_deg": {
- name: math.degrees(value)
- for name, value in holdout_zero.validation_original_error_by_joint_rad.items()
- },
- "zero_improvement_95pct_lower_deg": {
- name: math.degrees(value)
- for name, value in (
- holdout_zero.validation_improvement_confidence_lower_rad.items()
- )
- },
- "corrected_urdf_checks": {
- **urdf_checks,
- "maximum_residual_zero_offset_deg": math.degrees(maximum_residual_offset),
- },
- "corrected_urdf_sha256": candidate_hash,
- "final_json": str(final_json) if write_outputs else None,
- "corrected_urdf": str(final_urdf) if write_outputs else None,
- }
- if write_outputs:
- report["final_json_sha256"] = _sha256(final_json)
- if _sha256(final_urdf) != candidate_hash:
- raise ValueError("formal corrected URDF differs from validated candidate")
- atomic_write_json(report_path, report)
- report["validation_report"] = str(report_path)
- return report
-
-
-def main() -> None:
- parser = argparse.ArgumentParser(
- description="Replay and validate a registered hand-calibration session."
- )
- parser.add_argument("session_dir")
- parser.add_argument("--serial-number", default=None)
- parser.add_argument("--config-file", default=None)
- parser.add_argument("--write", action="store_true")
- parser.add_argument(
- "--output-tag",
- default=None,
- help="safe suffix for a replay variant; existing outputs are never overwritten",
- )
- arguments = parser.parse_args()
- result = replay_session(
- arguments.session_dir,
- serial_number=arguments.serial_number,
- config_file=arguments.config_file,
- write_outputs=arguments.write,
- output_tag=arguments.output_tag,
- )
- print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
-
-
-if __name__ == "__main__":
- main()
+sys.modules[__name__] = _implementation
diff --git a/src/linkerhand_calibration/linkerhand_calibration/product.py b/src/linkerhand_calibration/linkerhand_calibration/product.py
index 72c56a5..77d8934 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/product.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/product.py
@@ -17,9 +17,13 @@ import xml.etree.ElementTree as ET
import yaml
-from .compat import product_profile_key, resolve_renamed_package_path
+from .compat import (
+ product_profile_key,
+ resolve_legacy_profile_alias,
+ resolve_renamed_package_path,
+)
from .core import CalibrationProfile, ProfileKey
-from .extrinsics import camera_info_fingerprint, load_three_camera_extrinsics
+from .core.geometry import camera_info_fingerprint, load_camera_extrinsics
from .models import RegisteredProfile, get_default_registry
@@ -65,8 +69,11 @@ class ProductCalibrationContract:
def get_product_calibration_contract(
model: str, side: str, layout_id: str, revision: int = 1
) -> ProductCalibrationContract:
+ key = resolve_legacy_profile_alias(
+ ProfileKey(model, side, layout_id, revision)
+ )
return ProductCalibrationContract(
- get_default_registry().resolve(model, side, layout_id, revision)
+ get_default_registry().get(key)
)
@@ -133,12 +140,18 @@ def _custom_pnp_tag_sizes_m_by_id(
) -> dict[int, float]:
with path.open("r", encoding="utf-8") as stream:
payload = _mapping(yaml.safe_load(stream), str(path))
- parameters = _mapping(
- _mapping(payload.get("g20_calibration"), "g20_calibration").get(
- "ros__parameters"
- ),
- "ros__parameters",
- )
+ candidates = [
+ _mapping(node, str(name)).get("ros__parameters")
+ for name, node in payload.items()
+ if isinstance(node, Mapping)
+ and isinstance(node.get("ros__parameters"), Mapping)
+ and "tag_size_m" in node["ros__parameters"]
+ ]
+ if len(candidates) != 1:
+ raise ValueError(
+ "calibration config must contain exactly one Tag-size parameter set"
+ )
+ parameters = _mapping(candidates[0], "ros__parameters")
default = float(parameters.get("tag_size_m", 0.0))
ids = [int(value) for value in parameters.get("tag_size_override_ids", [])]
sizes = [float(value) for value in parameters.get("tag_size_overrides_m", [])]
@@ -181,6 +194,7 @@ class ProductConfig:
workspace: Path
schema_version: int
profile_key: ProfileKey
+ namespace: str
model: str
side: str
tag_layout: str
@@ -228,6 +242,11 @@ def load_product_config(
contract = get_product_calibration_contract(
model, side, layout, profile_key.revision
)
+ namespace = str(
+ raw.get("namespace", contract.typed_profile.namespace)
+ ).strip()
+ if not namespace.startswith("/") or namespace.endswith("/"):
+ raise ValueError("namespace must be an absolute ROS namespace")
can_interface = str(raw.get("can_interface", "")).strip()
if not can_interface:
raise ValueError("can_interface is required")
@@ -301,7 +320,14 @@ def load_product_config(
required_views = tuple(contract.views)
if set(camera_raw) != set(required_views):
raise ValueError(f"cameras must contain {'/'.join(required_views)}")
- loaded_extrinsics = load_three_camera_extrinsics(extrinsics)
+ vision = contract.typed_profile.vision
+ loaded_extrinsics = load_camera_extrinsics(
+ extrinsics,
+ required_views=required_views,
+ reference_view=vision.extrinsic_reference_view,
+ quality_limits=vision.extrinsics_quality_limits,
+ minimum_capture_counts=vision.minimum_capture_counts,
+ )
cameras: dict[str, dict[str, str]] = {}
serials: set[str] = set()
for view in required_views:
@@ -344,6 +370,7 @@ def load_product_config(
workspace=root,
schema_version=int(raw["schema_version"]),
profile_key=profile_key,
+ namespace=namespace,
model=model,
side=side,
tag_layout=layout,
diff --git a/src/linkerhand_calibration/linkerhand_calibration/publication.py b/src/linkerhand_calibration/linkerhand_calibration/publication.py
index 9ce7085..4f836d4 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/publication.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/publication.py
@@ -1,914 +1,7 @@
-"""Validate and atomically publish one inseparable G20 JSON/URDF session."""
+"""One-release module alias for the relocated model publication policy."""
-from __future__ import annotations
+import sys
-from datetime import datetime, timezone
-import copy
-import json
-import math
-import os
-from pathlib import Path
-import re
-from typing import Any, Mapping, Sequence
-import xml.etree.ElementTree as ET
+from .models.g20 import publication as _implementation
-import numpy as np
-from scipy.spatial.transform import Rotation
-
-from .full_hand 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 .urdf_zero import (
- RIGHT_19_MECHANICAL_ENDPOINT_JOINTS,
- get_zero_calibration_profile,
-)
-
-
-PASSIVE_JOINTS = frozenset(
- {"thumb_ip", "index_dip", "middle_dip", "ring_dip", "pinky_dip"}
-)
-VISUALLY_MEASURED_FINGER_DIPS = frozenset(
- {"index_dip", "middle_dip", "ring_dip", "pinky_dip"}
-)
-ACTIVE_ZERO_JOINTS = frozenset(
- get_zero_calibration_profile("right", G20_RIGHT_19_LAYOUT).direct_zero_joints
-)
-RETAINED_ACTIVE_ZERO_JOINTS = frozenset(
- get_hand_calibration_profile("right", G20_RIGHT_19_LAYOUT).active_joints
-) - ACTIVE_ZERO_JOINTS
-SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD = 5.1e-9
-
-
-def _load_json(path: Path) -> dict[str, Any]:
- with path.open("r", encoding="utf-8") as stream:
- payload = json.load(stream)
- if not isinstance(payload, dict):
- raise ValueError(f"{path} must contain a JSON object")
- return payload
-
-
-def _joint_blocks(text: str) -> dict[str, str]:
- pattern = re.compile(
- r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
- re.DOTALL,
- )
- return {match.group("name"): match.group(0) for match in pattern.finditer(text)}
-
-
-def _mask_origin_rpy(block: str) -> str:
- return re.sub(
- r"(]*\brpy\s*=\s*)([\"'])[^\"']*\2",
- r"\1\2__CALIBRATED_RPY__\2",
- block,
- count=1,
- )
-
-
-def _mask_active_origin_rpy_fields(text: str) -> str:
- pattern = re.compile(
- r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
- re.DOTALL,
- )
-
- def replace(match: re.Match[str]) -> str:
- block = match.group(0)
- if match.group("name") not in ACTIVE_ZERO_JOINTS:
- return block
- masked = _mask_origin_rpy(block)
- if masked == block:
- raise ValueError(
- f"active joint {match.group('name')} has no origin.rpy"
- )
- return masked
-
- return pattern.sub(replace, text)
-
-
-def _mask_endpoint_coordinate_fields(text: str) -> str:
- """Mask only limit/mimic fields induced by endpoint zero coordinates."""
- pattern = re.compile(
- r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>.*?",
- re.DOTALL,
- )
-
- def replace(match: re.Match[str]) -> str:
- block = match.group(0)
- name = match.group("name")
- if name in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS:
- return re.sub(
- r"(]*\bupper\s*=\s*)([\"'])[^\"']*\2",
- r"\1\2__CALIBRATED_UPPER__\2",
- block,
- count=1,
- )
- mimic = re.search(
- r"]*\bjoint\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>",
- block,
- re.DOTALL,
- )
- if (
- mimic is not None
- and mimic.group("source") in RIGHT_19_MECHANICAL_ENDPOINT_JOINTS
- ):
- return re.sub(
- r"(]*\boffset\s*=\s*)([\"'])[^\"']*\2",
- r"\1\2__CALIBRATED_MIMIC_OFFSET__\2",
- block,
- count=1,
- )
- return block
-
- return pattern.sub(replace, text)
-
-
-def _triplet(value: str) -> np.ndarray:
- result = np.asarray([float(item) for item in value.split()], dtype=float)
- if result.shape != (3,) or not np.all(np.isfinite(result)):
- raise ValueError(f"invalid URDF triplet: {value}")
- return result
-
-
-def _joint_elements(path: str | Path) -> dict[str, ET.Element]:
- return {
- str(joint.get("name")): joint
- for joint in ET.parse(Path(path)).getroot().findall("joint")
- }
-
-
-def _verify_expected_origin_offsets(
- source: str | Path,
- corrected: str | Path,
- expected_offsets_rad: Mapping[str, float],
-) -> None:
- offsets = {str(name): float(value) for name, value in expected_offsets_rad.items()}
- if set(offsets) != ACTIVE_ZERO_JOINTS or any(
- not math.isfinite(value) for value in offsets.values()
- ):
- raise ValueError("expected offsets must contain all finite active static-zero values")
- before = _joint_elements(source)
- after = _joint_elements(corrected)
- maximum_rotation_error = 0.0
- for name, original_joint in before.items():
- corrected_joint = after[name]
- original_origin = original_joint.find("origin")
- corrected_origin = corrected_joint.find("origin")
- if original_origin is None or corrected_origin is None:
- if original_origin is not corrected_origin:
- raise ValueError(f"corrected URDF changed origin presence for {name}")
- continue
- original_xyz = _triplet(original_origin.get("xyz", "0 0 0"))
- corrected_xyz = _triplet(corrected_origin.get("xyz", "0 0 0"))
- if not np.allclose(original_xyz, corrected_xyz, atol=1.0e-12, rtol=0.0):
- raise ValueError(f"corrected URDF changed origin.xyz for {name}")
- original_rotation = Rotation.from_euler(
- "xyz", _triplet(original_origin.get("rpy", "0 0 0"))
- )
- corrected_rotation = Rotation.from_euler(
- "xyz", _triplet(corrected_origin.get("rpy", "0 0 0"))
- )
- expected_rotation = original_rotation
- if name in offsets:
- axis_node = original_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"source URDF joint {name} has a degenerate axis")
- expected_rotation = expected_rotation * Rotation.from_rotvec(
- axis / norm * offsets[name]
- )
- error = float((expected_rotation.inv() * corrected_rotation).magnitude())
- maximum_rotation_error = max(maximum_rotation_error, error)
- # Schema v4 intentionally stores zero offsets at eight decimal places.
- # Older sessions wrote the corrected URDF from the full-precision solve,
- # so comparing that file with the published JSON necessarily permits half
- # of one last-place unit. This is about 2.9e-7 degrees and is far below
- # any calibration or URDF numerical significance.
- if maximum_rotation_error > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD:
- raise ValueError(
- "corrected URDF origin.rpy does not match the published zero offsets"
- )
-
-
-def verify_corrected_urdf(
- source: str | Path,
- corrected: str | Path,
- *,
- expected_offsets_rad: Mapping[str, float] | None = None,
- endpoint_anchored_offsets_rad: Mapping[str, float] | None = None,
- correction_plan: UrdfCorrectionPlan | None = None,
-) -> tuple[str, ...]:
- """Prove that only active-joint origin.rpy attributes changed.
-
- Passive joint blocks, including their mimic elements, are compared as raw
- UTF-8 text so formatting and numeric spelling are protected as well.
- """
- source_text = Path(source).read_text(encoding="utf-8")
- corrected_text = Path(corrected).read_text(encoding="utf-8")
- if correction_plan is not None:
- correction_plan.verify_source(source)
- if expected_offsets_rad is None:
- raise ValueError(
- "correction-plan validation requires expected offsets"
- )
- correction_plan.authorize_offsets(expected_offsets_rad)
- before = _joint_blocks(source_text)
- after = _joint_blocks(corrected_text)
- if set(before) != set(after):
- raise ValueError("corrected URDF changed the joint set")
- # Text patchers may normalize only the final newline. It has no URDF/XML
- # semantics; every joint block and every non-rpy byte remains guarded
- # below.
- masked_source = _mask_endpoint_coordinate_fields(
- _mask_active_origin_rpy_fields(source_text)
- ).rstrip("\r\n")
- masked_corrected = _mask_endpoint_coordinate_fields(
- _mask_active_origin_rpy_fields(corrected_text)
- ).rstrip("\r\n")
- if masked_source != masked_corrected:
- raise ValueError(
- "corrected URDF changed content outside active origin.rpy or "
- "endpoint-calibrated fields"
- )
- changed: list[str] = []
- for name in before:
- if before[name] == after[name]:
- continue
- if name not in ACTIVE_ZERO_JOINTS and name not in PASSIVE_JOINTS:
- raise ValueError(f"corrected URDF changed protected joint {name}")
- if name in ACTIVE_ZERO_JOINTS:
- changed.append(name)
- if expected_offsets_rad is not None:
- _verify_expected_origin_offsets(source, corrected, expected_offsets_rad)
- endpoint_offsets = {
- str(name): float(value)
- for name, value in dict(endpoint_anchored_offsets_rad or {}).items()
- }
- if endpoint_offsets:
- if correction_plan is not None and not set(endpoint_offsets).issubset(
- correction_plan.endpoint_limit_joints
- ):
- raise ValueError(
- "endpoint offsets are not authorized by the correction plan"
- )
- source_joints = _joint_elements(source)
- corrected_joints = _joint_elements(corrected)
- for name, offset in endpoint_offsets.items():
- original_limit = source_joints[name].find("limit")
- corrected_limit = corrected_joints[name].find("limit")
- expected_upper = float(original_limit.get("upper")) - offset
- # Schema v4 stores the corresponding zero at eight decimal
- # places, while the URDF is written from the full-precision solve.
- # Match the half-last-place tolerance used for origin rotations.
- if (
- abs(float(corrected_limit.get("upper")) - expected_upper)
- > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
- ):
- raise ValueError(f"corrected URDF has invalid {name} upper limit")
- for name in PASSIVE_JOINTS:
- original_mimic = source_joints[name].find("mimic")
- corrected_mimic = corrected_joints[name].find("mimic")
- if original_mimic is None or corrected_mimic is None:
- continue
- source_name = str(original_mimic.get("joint"))
- multiplier = float(original_mimic.get("multiplier", "1"))
- expected = float(original_mimic.get("offset", "0"))
- expected += multiplier * endpoint_offsets.get(source_name, 0.0)
- # The active endpoint offset comes from schema-v4 JSON rounded to
- # eight decimal places, while the URDF mimic was written from the
- # full-precision solve. Propagate exactly the same accepted
- # quantization through the mimic multiplier; retain a much
- # smaller allowance for XML decimal formatting itself.
- tolerance = (
- abs(multiplier)
- * SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
- + 1.0e-12
- )
- if abs(float(corrected_mimic.get("offset", "0")) - expected) > tolerance:
- raise ValueError(f"corrected URDF has invalid {name} mimic offset")
- return tuple(sorted(changed))
-
-
-def verify_urdf_mesh_resources(urdf: str | Path) -> dict[str, Path]:
- """Return every session-local mesh after proving it resolves safely."""
- path = Path(urdf).expanduser().resolve()
- root = ET.parse(path).getroot()
- resources: dict[str, Path] = {}
- for mesh in root.findall(".//mesh"):
- filename = str(mesh.get("filename", "")).strip()
- if not filename or "://" in filename or filename.startswith("package:"):
- continue
- relative = Path(filename)
- if relative.is_absolute() or ".." in relative.parts:
- raise ValueError(f"URDF has an unsafe local mesh path: {filename}")
- resolved = (path.parent / relative).resolve()
- try:
- resolved.relative_to(path.parent)
- except ValueError as error:
- raise ValueError(f"URDF mesh escapes the session: {filename}") from error
- if not resolved.is_file():
- raise ValueError(f"URDF mesh resource is missing: {filename}")
- resources[relative.as_posix()] = resolved
- return dict(sorted(resources.items()))
-
-
-def validate_runtime_curves_against_urdf_limits(
- payload: Mapping[str, Any], runtime_urdf: str | Path
-) -> None:
- """Reject a curve whose q leaves its runtime URDF coordinate limits.
-
- Curves in schema v4 are expressed in the corrected URDF joint coordinate,
- not in the source-CAD coordinate. Endpoint zero calibration can therefore
- move a corrected coordinate limit while preserving the same physical CAD
- endpoint; callers publishing a calibrated pair must pass that corrected
- URDF here.
- """
- validate_compact_payload(payload)
- runtime_joints = _joint_elements(runtime_urdf)
- for name, calibration in payload["joints"].items():
- joint = runtime_joints.get(str(name))
- if joint is None:
- raise ValueError(f"runtime URDF is missing joint {name}")
- limit = joint.find("limit")
- if limit is None or limit.get("lower") is None or limit.get("upper") is None:
- raise ValueError(f"runtime URDF joint {name} has no finite position limit")
- lower = float(limit.get("lower"))
- upper = float(limit.get("upper"))
- if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
- raise ValueError(f"runtime URDF joint {name} has invalid position limits")
- curve = np.asarray(calibration["angle_rad"], dtype=float)
- minimum = float(np.min(curve))
- maximum = float(np.max(curve))
- tolerance = 1.0e-7
- if minimum < lower - tolerance or maximum > upper + tolerance:
- raise ValueError(
- f"runtime curve exceeds runtime URDF limit for {name}: "
- f"[{minimum:.9g}, {maximum:.9g}] not within "
- f"[{lower:.9g}, {upper:.9g}]"
- )
-
-
-def clamp_compact_payload_to_urdf_limits(
- payload: Mapping[str, Any], runtime_urdf: str | Path
-) -> tuple[dict[str, Any], dict[str, int]]:
- """Return a schema-preserving payload bounded in its runtime coordinates."""
- result = copy.deepcopy(dict(payload))
- validate_compact_payload(result)
- runtime_joints = _joint_elements(runtime_urdf)
- clipped_by_joint: dict[str, int] = {}
- for name, calibration in result["joints"].items():
- joint = runtime_joints.get(str(name))
- limit = None if joint is None else joint.find("limit")
- if (
- limit is None
- or limit.get("lower") is None
- or limit.get("upper") is None
- ):
- raise ValueError(f"runtime URDF joint {name} has no finite position limit")
- lower = float(limit.get("lower"))
- upper = float(limit.get("upper"))
- if not math.isfinite(lower) or not math.isfinite(upper) or lower > upper:
- raise ValueError(f"runtime URDF joint {name} has invalid position limits")
- source = np.asarray(calibration["angle_rad"], dtype=float)
- bounded = np.clip(source, lower, upper)
- count = int(np.count_nonzero(bounded != source))
- if count:
- clipped_by_joint[str(name)] = count
- calibration["angle_rad"] = [
- round(float(value), 8) for value in bounded
- ]
- validate_compact_payload(result)
- return result, clipped_by_joint
-
-
-def build_mujoco_validation_commands(
- baseline: Sequence[int],
-) -> dict[str, Any]:
- if len(baseline) != 20:
- raise ValueError("baseline must contain exactly 20 commands")
- base = [int(value) for value in baseline]
- if any(not 0 <= value <= 255 for value in base):
- raise ValueError("baseline commands must be in [0, 255]")
-
- def pose(name: str, label: str, changes: Mapping[int, int]) -> dict[str, Any]:
- values = list(base)
- for index, value in changes.items():
- values[int(index)] = int(value)
- return {"name": name, "label_zh": label, "command_u8": values}
-
- return {
- "schema_version": 1,
- "topic": "/g20/cb_right_hand_control_cmd",
- "serial_number": "G20_RIGHT_001",
- "poses": [
- pose("all_open", "全开", {}),
- pose("thumb_middle", "拇指中位", {0: 160, 5: 160, 10: 160, 15: 160}),
- pose("index_middle", "食指中位", {1: 160, 6: 127, 16: 160}),
- pose("middle_middle", "中指中位", {2: 160, 7: 127, 17: 160}),
- pose("ring_middle", "无名指中位", {3: 160, 8: 127, 18: 160}),
- pose("pinky_middle", "小指中位", {4: 160, 9: 127, 19: 160}),
- pose(
- "half_grip",
- "四指半握",
- {1: 160, 2: 160, 3: 160, 4: 160, 16: 160, 17: 160, 18: 160, 19: 160},
- ),
- pose(
- "light_pinch",
- "轻捏",
- {0: 176, 5: 176, 10: 176, 15: 176, 1: 176, 6: 127, 16: 176},
- ),
- ],
- }
-
-
-def atomic_session_pointer(root: str | Path, name: str, session: str | Path) -> Path:
- parent = Path(root).resolve()
- target = Path(session).resolve()
- if target.parent != parent:
- raise ValueError("session pointer target must be a direct child of the serial root")
- if not target.is_dir():
- raise ValueError(f"session directory does not exist: {target}")
- if name not in {"latest_attempt", "latest_passed", "latest_thumb_passed"}:
- raise ValueError("unsupported session pointer name")
- parent.mkdir(parents=True, exist_ok=True)
- destination = parent / name
- temporary = parent / f".{name}.{os.getpid()}.tmp"
- if temporary.is_symlink():
- temporary.unlink()
- elif temporary.exists():
- raise ValueError(f"temporary pointer path is occupied: {temporary}")
- os.symlink(target.name, temporary, target_is_directory=True)
- os.replace(temporary, destination)
- return destination
-
-
-def active_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
- validate_compact_payload(payload)
- result = {
- name: float(payload["joints"][name]["zero_angles"]["urdf_zero_offset_rad"])
- for name in ACTIVE_ZERO_JOINTS
- }
- if set(result) != ACTIVE_ZERO_JOINTS or any(
- not math.isfinite(value) for value in result.values()
- ):
- raise ValueError(
- "payload does not contain all finite observable active zero offsets"
- )
- return result
-
-
-def standalone_thumb_offsets(payload: Mapping[str, Any]) -> dict[str, float]:
- """Validate a standalone thumb artifact and expand CAD finger zeros."""
- if (
- payload.get("schema_version") != 1
- or payload.get("artifact_type")
- != "g20_right_standalone_thumb_calibration"
- or payload.get("side") != "right"
- or payload.get("non_thumb_zero_policy") != "source_cad_unchanged"
- or not bool(payload.get("quality", {}).get("passed"))
- ):
- raise ValueError("standalone thumb JSON contract is invalid")
- joints = payload.get("joints")
- expected_joint_names = {
- "thumb_cmc_roll",
- "thumb_cmc_yaw",
- "thumb_cmc_pitch",
- "thumb_mcp",
- "thumb_ip",
- }
- if not isinstance(joints, Mapping) or set(joints) != expected_joint_names:
- raise ValueError("standalone thumb JSON has the wrong joint set")
- thumb_names = expected_joint_names - {"thumb_ip"}
- result = {name: 0.0 for name in ACTIVE_ZERO_JOINTS}
- for name in thumb_names:
- try:
- value = float(joints[name]["zero_angles"]["urdf_zero_offset_rad"])
- except (KeyError, TypeError, ValueError) as error:
- raise ValueError(
- f"standalone thumb JSON is missing zero for {name}"
- ) from error
- curve = np.asarray(joints[name].get("angle_rad", ()), dtype=float)
- if curve.shape != (256,) or not np.all(np.isfinite(curve)):
- raise ValueError(f"standalone thumb JSON curve is invalid: {name}")
- result[name] = value
- passive_curve = np.asarray(
- joints["thumb_ip"].get("angle_rad", ()), dtype=float
- )
- if (
- passive_curve.shape != (256,)
- or not np.all(np.isfinite(passive_curve))
- or not bool(joints["thumb_ip"].get("passive"))
- or any(not math.isfinite(value) for value in result.values())
- ):
- raise ValueError("standalone thumb JSON passive curve is invalid")
- return result
-
-
-def verify_partial_scope_preserves_certified_zeros(
- *,
- scope: str,
- source_session: str | Path,
- serial_root: str | Path | None = None,
- serial_number: str,
- current_offsets: Mapping[str, float],
-) -> None:
- """Reject a partial artifact that changes any certified non-target zero."""
- selected = str(scope).strip().lower()
- if selected not in {"thumb", "fingers"}:
- return
- source = Path(source_session).expanduser()
- if not source.is_absolute():
- if serial_root is None:
- raise ValueError(
- "relative partial base session requires its serial root"
- )
- source = Path(serial_root).expanduser() / source
- source = source.resolve()
- base_payload = _load_json(
- source / f"g20_right_{serial_number}_calibration.json"
- )
- base_offsets = (
- standalone_thumb_offsets(base_payload)
- if base_payload.get("artifact_type")
- == "g20_right_standalone_thumb_calibration"
- else active_offsets(base_payload)
- )
- preserve_thumb = selected == "fingers"
- changed = {
- name: abs(float(current_offsets[name]) - float(base_offsets[name]))
- for name in ACTIVE_ZERO_JOINTS
- if name.startswith("thumb_") == preserve_thumb
- and abs(float(current_offsets[name]) - float(base_offsets[name]))
- > SCHEMA_V4_OFFSET_QUANTIZATION_TOLERANCE_RAD
- }
- if changed:
- raise ValueError(
- f"partial {selected} calibration changed certified non-target "
- "zeros: "
- + ",".join(sorted(changed))
- )
-
-
-def compare_session_offsets(
- first: Mapping[str, Any],
- second: Mapping[str, Any],
- *,
- maximum_difference_rad: float,
-) -> dict[str, float]:
- first_standalone = (
- first.get("artifact_type")
- == "g20_right_standalone_thumb_calibration"
- )
- second_standalone = (
- second.get("artifact_type")
- == "g20_right_standalone_thumb_calibration"
- )
- if first_standalone != second_standalone:
- raise ValueError("cannot compare standalone thumb and full-hand artifacts")
- left = (
- standalone_thumb_offsets(first)
- if first_standalone
- else active_offsets(first)
- )
- right = (
- standalone_thumb_offsets(second)
- if second_standalone
- else active_offsets(second)
- )
- differences = {
- name: abs(left[name] - right[name])
- for name in sorted(left)
- }
- failed = {name: value for name, value in differences.items() if value > maximum_difference_rad}
- if failed:
- details = ", ".join(
- f"{name}={math.degrees(value):.3f}deg" for name, value in failed.items()
- )
- raise ValueError(f"independent-session static-zero mismatch: {details}")
- return differences
-
-
-def session_artifact_paths(session: str | Path, serial_number: str) -> dict[str, Path]:
- directory = Path(session).resolve()
- urdfs = sorted(directory.glob(f"*zero_calibrated_{serial_number}_*.urdf"))
- if len(urdfs) != 1:
- raise ValueError("session must contain exactly one corrected URDF")
- return {
- "json": directory / f"g20_right_{serial_number}_calibration.json",
- "urdf": urdfs[0],
- "summary": directory / "calibration_summary_zh.json",
- "commands": directory / "mujoco_validation_commands.json",
- "raw": directory / "raw_samples.jsonl",
- "log": directory / "calibration.log",
- }
-
-
-def find_compatible_prior_session(
- config: ProductConfig,
- current: str | Path,
- current_payload: Mapping[str, Any],
-) -> tuple[Path | None, dict[str, float]]:
- current_path = Path(current).resolve()
- for candidate in sorted(config.session_root.glob("20??????_??????"), reverse=True):
- if candidate.resolve() == current_path:
- continue
- json_path = candidate / f"g20_right_{config.serial_number}_calibration.json"
- summary_path = candidate / "calibration_summary_zh.json"
- if not json_path.is_file() or not summary_path.is_file():
- continue
- try:
- summary = _load_json(summary_path)
- if not bool(summary.get("quality", {}).get("passed")):
- continue
- payload = _load_json(json_path)
- differences = compare_session_offsets(
- payload,
- current_payload,
- maximum_difference_rad=config.static_repeatability_rad,
- )
- except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError):
- continue
- return candidate, differences
- return None, {}
-
-
-def _verify_combination_validation(combination: Mapping[str, Any]) -> None:
- # The formal product uses the independently held-out fourth sweep cycle.
- # The optional eight-pose check remains a developer diagnostic because the
- # axis-line zero solve does not establish an absolute Cartesian hand-base
- # transform. When explicitly enabled, retain its strict coverage and
- # error checks.
- if not bool(combination.get("enabled")):
- return
- position_p95 = float(combination.get("position_p95_m", float("inf")))
- orientation_p95 = float(
- combination.get("orientation_p95_rad", float("inf"))
- )
- required = {str(value) for value in combination.get("required_targets", ())}
- expected = set(G20_COMBINATION_REQUIRED_TARGET_KEYS)
- observations = combination.get("observation_counts")
- validations = combination.get("validation_counts")
- if required != expected:
- raise ValueError("combination validation has the wrong required-target set")
- if not isinstance(observations, Mapping) or not isinstance(validations, Mapping):
- raise ValueError("combination validation is missing per-target coverage")
- missing_observations = sorted(
- key for key in expected if int(observations.get(key, 0)) < 2
- )
- missing_validations = sorted(
- key for key in expected if int(validations.get(key, 0)) < 1
- )
- if missing_observations or missing_validations:
- raise ValueError(
- "combination validation target coverage is incomplete: "
- f"observations={','.join(missing_observations) or '-'}; "
- f"validations={','.join(missing_validations) or '-'}"
- )
- if (
- not bool(combination.get("completed"))
- or int(combination.get("completed_poses", 0)) != 8
- or position_p95 > 0.003
- or orientation_p95 > math.radians(2.0)
- ):
- raise ValueError(
- "eight-pose four-finger combination validation is incomplete or failed"
- )
-
-
-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
+sys.modules[__name__] = _implementation
diff --git a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py b/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py
index 5a82088..580fb6c 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/__init__.py
@@ -1 +1 @@
-"""ROS message/service shells are migrated here after controller extraction."""
+"""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
new file mode 100644
index 0000000..b167647
--- /dev/null
+++ b/src/linkerhand_calibration/linkerhand_calibration/runtime/nodes/calibration.py
@@ -0,0 +1,23 @@
+"""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/three_camera_node.py b/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py
index e7ffa2d..9465c69 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/three_camera_node.py
@@ -1,13060 +1,7 @@
-"""One-command three-camera calibration for either complete G20 hand."""
+"""One-release module alias for the relocated model-specific ROS node."""
-from __future__ import annotations
+import sys
-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
+from .models.g20 import node as _implementation
-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 .extrinsics import (
- ThreeCameraExtrinsics,
- camera_info_fingerprint,
- load_three_camera_extrinsics,
- matrix_payload,
- transform_matrix,
-)
-from .full_hand 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 .models.g20.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 .three_camera_diagnostics import render_three_camera_status_text_zh
-from .urdf_zero 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 _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 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", 2.0)
- self.declare_parameter("fixed_base_movement_confirmation_frames", 5)
- 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", 5.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", 3)
- self.declare_parameter("automatic_fit_retry_limit", 2)
- self.declare_parameter("automatic_motion_retry_limit", 2)
- 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", [0.8, 0.6, 0.5])
- self.declare_parameter(
- "retry_endpoint_hold_seconds", [0.75, 1.0, 1.25]
- )
- 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.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.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)
- if len(message.position) != len(command_names):
- return
- if (
- len(message.name) == len(command_names)
- and set(message.name) == set(command_names)
- ):
- lookup = dict(zip(message.name, message.position))
- state = tuple(float(lookup[name]) for name in command_names)
- else:
- state = tuple(float(value) for value in message.position)
- 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]
- 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"
- )
- 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)
- 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)
- self.resume_source_session = source.parent.name
- 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
- ),
- "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.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",
- "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:
- 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())
- restored_tasks = self._restore_durable_task_checkpoint()
- except Exception as error:
- self.started = 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 restored_tasks == 0
- else f"calibration resumed with {restored_tasks} completed tasks"
- )
- 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.retry_speed_scales[min(retry, len(self.retry_speed_scales)) - 1]
- 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)
- scale = self.retry_speed_scales[
- min(retry, len(self.retry_speed_scales)) - 1
- ]
- return float(self.sweep_timeout_seconds) / float(scale)
-
- 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:
- 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 = int(
- getattr(self, "automatic_fit_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 = int(
- getattr(self, "automatic_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 = getattr(self, "automatic_sweep_retry_limit", 0)
- if retries >= retry_limit:
- 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", (0.8, 0.6, 0.5)
- )
- endpoint_holds = getattr(
- self, "retry_endpoint_hold_seconds", (0.75, 1.0, 1.25)
- )
- # 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
-
- for joint_name, bins in joint_bins.items():
- 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:
- 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()
- }
- 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
- },
- )
- 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 _retry_motion_or_pause(self, reason: str, now: float) -> None:
- 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 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():
- 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 now - self.motion_stage_started_at > self.position_timeout_seconds:
- self._retry_motion_or_pause("return_baseline_timeout", now)
- 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 now - self.motion_stage_started_at > self.position_timeout_seconds:
- self._retry_motion_or_pause(
- (
- "sweep_start_tag_timeout"
- if reached
- else "sweep_start_position_timeout"
- ),
- now,
- )
- 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
- if now - self.sweep_started_at > self._active_sweep_timeout_seconds():
- self._retry_active_sweep_or_pause("sweep_timeout")
- return
- stale_views = [
- view
- for view in _sweep_views(
- self.profile, self.active_sweep.spec
- )
- if now
- - getattr(self, "sweep_last_valid_at_by_view", {}).get(
- view, self.sweep_started_at
- )
- > self.invalid_timeout_seconds
- ]
- if stale_views:
- self._retry_active_sweep_or_pause(
- "synchronised_tag_state_timeout:"
- + ",".join(stale_views)
- )
- return
- 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()
- and _frames_cover_sweep_motion(
- self.sweep_frames,
- self.active_sweep.spec,
- motor_index=motor,
- minimum_per_joint=self.minimum_sweep_frames,
- minimum_span_u8=self.minimum_state_span_u8,
- )
- ):
- 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
- if now - self.validation_stage_started_at > self.validation_timeout_seconds:
- self._retry_combination_validation_or_pause(
- "combination_validation_move_timeout", now
- )
- 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
- if now - self.validation_stage_started_at > self.validation_timeout_seconds:
- self._retry_validation_or_pause("validation_move_timeout", now)
- 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),
- "source_session": self.resume_source_session,
- "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()
+sys.modules[__name__] = _implementation
diff --git a/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py b/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py
index e092521..edfb635 100644
--- a/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py
+++ b/src/linkerhand_calibration/linkerhand_calibration/urdf_zero.py
@@ -1,4635 +1,7 @@
-"""Three-dimensional joint-axis fitting and URDF zero correction."""
+"""One-release module alias for the relocated model zero solver."""
-from __future__ import annotations
+import sys
-from dataclasses import dataclass, replace
-from datetime import datetime
-import math
-import os
-from pathlib import Path
-import re
-import shutil
-from typing import Any, Mapping, Sequence
-import xml.etree.ElementTree as ET
+from .models.g20 import zero_solver as _implementation
-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
-from .full_hand 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"
-
- @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
-) -> 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 max(lower_distance, upper_distance) <= ZERO_REFERENCE_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 abs(nearest_command - zero) <= ZERO_REFERENCE_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
-) -> 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
- )
- )
- is not None
- ]
- if not values:
- raise ValueError(
- "joint records have no samples within "
- f"{ZERO_REFERENCE_MAXIMUM_DISTANCE_U8} commands of zero "
- f"{zero_command_u8}"
- )
- 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,
-) -> 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.
- """
- 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,
- )
- )
- 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,
- )
- 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,
- )
- ],
- "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,
-) -> 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,
- )
- )
- 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
- # 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,
-) -> 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] = []
- 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
- 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))))
- 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,
-) -> 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)
- 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=str(joint).endswith("_side"),
- )
- )
- 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_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]
-
-
-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 = (
- "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)
- error = math.acos(
- abs(
- float(
- np.clip(
- predicted_axis @ observed_axis, -1.0, 1.0
- )
- )
- )
- )
- 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)
- ):
- # Orthographic image-plane displacement is invariant to
- # independent optical-depth bias on the two planar Tags.
- # With an end-on axis, along-axis placement also projects
- # to zero (or a small component for a mildly oblique view).
- predicted_radial = predicted_delta - view_normal * float(
- predicted_delta @ view_normal
- )
- observed_radial = observed_delta - view_normal * float(
- observed_delta @ 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
- if abs(value) >= minimum_applied_offset_rad:
- 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 _files_have_identical_contents(left: Path, right: Path) -> bool:
- if left.stat().st_size != right.stat().st_size:
- return False
- with left.open("rb") as left_stream, right.open("rb") as right_stream:
- while True:
- left_chunk = left_stream.read(1024 * 1024)
- right_chunk = right_stream.read(1024 * 1024)
- if left_chunk != right_chunk:
- return False
- if not left_chunk:
- return True
-
-
-def _materialize_relative_mesh_assets(
- *, source: Path, output: Path, urdf_root: ET.Element
-) -> tuple[Path, ...]:
- """Copy relative mesh resources so a session-local URDF remains loadable."""
- filenames = sorted(
- {
- str(mesh.get("filename", "")).strip()
- for mesh in urdf_root.findall(".//mesh")
- if str(mesh.get("filename", "")).strip()
- }
- )
- materialized: list[Path] = []
- for filename in filenames:
- # URI-backed resources are resolved by the URDF consumer. Only local
- # relative resources need to follow a URDF copied to a session folder.
- if "://" in filename or filename.startswith("package:"):
- continue
- relative = Path(filename)
- if relative.is_absolute() or ".." in relative.parts:
- raise ValueError(
- f"URDF mesh path must be a safe relative path or URI: {filename}"
- )
- source_asset = (source.parent / relative).resolve()
- if not source_asset.is_file():
- raise ValueError(f"URDF mesh resource does not exist: {source_asset}")
- destination_asset = (output / relative).resolve()
- try:
- destination_asset.relative_to(output)
- except ValueError as error:
- raise ValueError(
- f"URDF mesh destination escapes output directory: {filename}"
- ) from error
- if destination_asset == source_asset:
- materialized.append(destination_asset)
- continue
- destination_asset.parent.mkdir(parents=True, exist_ok=True)
- if destination_asset.exists():
- if not destination_asset.is_file() or not _files_have_identical_contents(
- source_asset, destination_asset
- ):
- raise ValueError(
- f"refusing to overwrite a different mesh resource: "
- f"{destination_asset}"
- )
- materialized.append(destination_asset)
- continue
- temporary_asset = destination_asset.with_name(
- f".{destination_asset.name}.{os.getpid()}.tmp"
- )
- if temporary_asset.exists():
- raise ValueError(f"temporary mesh path is occupied: {temporary_asset}")
- try:
- shutil.copy2(source_asset, temporary_asset)
- os.replace(temporary_asset, destination_asset)
- finally:
- if temporary_asset.exists():
- temporary_asset.unlink()
- materialized.append(destination_asset)
- return tuple(materialized)
-
-
-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 (
- "zero_calibrated" in source.stem.lower()
- or re.search(r"calibrated_20\d{6}", source.stem.lower())
- ):
- raise ValueError(
- "source_urdf must be the original CAD URDF, not a calibrated URDF"
- )
- 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")
- output.mkdir(parents=True, exist_ok=True)
- 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")
- destination = output / f"{source.stem}_zero_calibrated_{safe_serial}_{stamp}.urdf"
- if destination == source or destination.exists():
- raise ValueError(f"refusing to overwrite URDF: {destination}")
- tree = ET.parse(source)
- root = tree.getroot()
- original_text = source.read_text(encoding="utf-8")
- 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))
- joint_pattern = re.compile(
- r"]*\bname\s*=\s*([\"'])(?P[^\"']+)\1[^>]*>"
- r".*?",
- re.DOTALL,
- )
- edits: list[tuple[int, int, str]] = []
- for match in joint_pattern.finditer(original_text):
- name = match.group("name")
- if name not in replacement_rpy:
- if name not in replacement_upper and name not in replacement_mimic_offset:
- continue
- block = match.group(0)
- if name in replacement_rpy:
- origin_match = re.search(
- r"]*\brpy\s*=\s*([\"'])(?P[^\"']*)\1",
- block,
- re.DOTALL,
- )
- if origin_match is None:
- raise ValueError(f"joint {name} origin has no rpy attribute")
- edits.append((
- match.start() + origin_match.start("rpy"),
- match.start() + origin_match.end("rpy"),
- replacement_rpy[name],
- ))
- if name in replacement_upper:
- limit_match = re.search(
- r"]*\bupper\s*=\s*([\"'])(?P[^\"']*)\1",
- block,
- re.DOTALL,
- )
- if limit_match is None:
- raise ValueError(f"joint {name} limit has no upper attribute")
- edits.append((
- match.start() + limit_match.start("upper"),
- match.start() + limit_match.end("upper"),
- replacement_upper[name],
- ))
- if name in replacement_mimic_offset:
- mimic_match = re.search(
- r"]*\boffset\s*=\s*([\"'])(?P[^\"']*)\1",
- block,
- re.DOTALL,
- )
- if mimic_match is None:
- raise ValueError(f"joint {name} mimic has no offset attribute")
- edits.append((
- match.start() + mimic_match.start("offset"),
- match.start() + mimic_match.end("offset"),
- replacement_mimic_offset[name],
- ))
- expected_edit_count = (
- len(replacement_rpy)
- + len(replacement_upper)
- + len(replacement_mimic_offset)
- )
- if len(edits) != expected_edit_count:
- raise ValueError("could not locate every target joint field in source URDF text")
- corrected_text = original_text
- for start, end, value in reversed(edits):
- corrected_text = corrected_text[:start] + value + corrected_text[end:]
- _materialize_relative_mesh_assets(source=source, output=output, urdf_root=root)
- temporary = destination.with_suffix(".urdf.tmp")
- with temporary.open("w", encoding="utf-8") as stream:
- stream.write(corrected_text)
- stream.flush()
- os.fsync(stream.fileno())
- os.replace(temporary, destination)
- return destination
+sys.modules[__name__] = _implementation
diff --git a/src/linkerhand_calibration/setup.py b/src/linkerhand_calibration/setup.py
index 83b6b88..837b3ec 100644
--- a/src/linkerhand_calibration/setup.py
+++ b/src/linkerhand_calibration/setup.py
@@ -26,7 +26,7 @@ setup(
zip_safe=True,
maintainer="lxp",
maintainer_email="support@linker-robotics.com",
- description="Profile-driven three-camera AprilTag hand calibration",
+ description="Profile-driven hand calibration and validated URDF correction",
license="MIT",
entry_points={
"console_scripts": [
@@ -45,7 +45,7 @@ setup(
),
(
"three_camera_calibration_node = "
- "linkerhand_calibration.three_camera_node:main"
+ "linkerhand_calibration.runtime.nodes.calibration:main"
),
(
"three_camera_extrinsics_node = "
@@ -55,6 +55,10 @@ setup(
"offline_replay = "
"linkerhand_calibration.offline_replay:main"
),
+ (
+ "validate_g20_goldens = "
+ "linkerhand_calibration.models.g20.golden_regression:main"
+ ),
(
"camera_alignment_view = "
"linkerhand_calibration.alignment_view:main"
diff --git a/src/linkerhand_calibration/test/test_architecture.py b/src/linkerhand_calibration/test/test_architecture.py
index 5d264b0..032c573 100644
--- a/src/linkerhand_calibration/test/test_architecture.py
+++ b/src/linkerhand_calibration/test/test_architecture.py
@@ -25,13 +25,18 @@ from linkerhand_calibration.core.solver import (
TaskEvaluation,
)
from linkerhand_calibration.core.urdf import UrdfCorrectionPlan
+from linkerhand_calibration.extrinsics import (
+ validate_camera_extrinsics_payload,
+)
from linkerhand_calibration.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
PACKAGE = Path(__file__).resolve().parents[1] / "linkerhand_calibration"
@@ -122,6 +127,7 @@ def _bindings() -> EngineBindings:
preparation_waypoints=no_waypoints,
return_waypoints=no_waypoints,
cli_main=lambda args=None: None,
+ node_main=lambda args=None: None,
)
@@ -157,6 +163,81 @@ def test_registry_accepts_different_view_and_command_counts() -> None:
assert selected.vision.view_names == ("oblique", "wrist")
+def test_generic_extrinsics_validator_uses_profile_view_names() -> None:
+ identity = {
+ "translation_xyz_m": [0.0, 0.0, 0.0],
+ "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
+ }
+ payload = {
+ "schema_version": 1,
+ "reference_view": "oblique",
+ "cameras": {
+ "oblique": {
+ "serial_number": "A",
+ "width": 640,
+ "height": 480,
+ "intrinsics_sha256": "a" * 64,
+ },
+ "wrist": {
+ "serial_number": "B",
+ "width": 800,
+ "height": 600,
+ "intrinsics_sha256": "b" * 64,
+ },
+ },
+ "oblique_from_view": {
+ "oblique": identity,
+ "wrist": {
+ "translation_xyz_m": [0.1, 0.0, 0.0],
+ "quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ "quality": {"passed": True, "rms": 0.2, "pair_captures": 4},
+ }
+
+ validate_camera_extrinsics_payload(
+ payload,
+ required_views=("oblique", "wrist"),
+ reference_view="oblique",
+ quality_limits={"rms": 0.5},
+ minimum_capture_counts={"pair_captures": 3},
+ )
+
+
+def test_ros_node_dispatcher_uses_profile_registry_without_model_branch(
+ monkeypatch,
+) -> None:
+ profile = _small_profile()
+ forwarded: list[list[str] | None] = []
+ bindings = _bindings()
+ bindings = EngineBindings(
+ hand_profile=bindings.hand_profile,
+ zero_profile=bindings.zero_profile,
+ motion_command=bindings.motion_command,
+ preparation_waypoints=bindings.preparation_waypoints,
+ return_waypoints=bindings.return_waypoints,
+ cli_main=bindings.cli_main,
+ node_main=forwarded.append,
+ )
+ registry = ProfileRegistry()
+ registry.register(RegisteredProfile(profile, bindings))
+ monkeypatch.setattr(
+ calibration_node, "get_default_registry", lambda: registry
+ )
+
+ calibration_node.main(
+ [
+ "--profile-id",
+ profile.key.profile_id,
+ "--ros-args",
+ "-p",
+ "sample_rate:=30",
+ ]
+ )
+
+ assert forwarded == [["--ros-args", "-p", "sample_rate:=30"]]
+
+
def test_schema_v2_selects_profile_only_by_typed_profile_id() -> None:
key = product_profile_key(
{
@@ -167,6 +248,13 @@ def test_schema_v2_selects_profile_only_by_typed_profile_id() -> None:
assert key == ProfileKey("TEST", "right", "two_view_three_command", 3)
+def test_retired_layout_alias_is_confined_to_compatibility_resolution() -> None:
+ contract = get_product_calibration_contract(
+ "G20", "right", "g20_right_15"
+ )
+ assert contract.typed_profile.key.layout == "g20_right_19"
+
+
def test_controller_uses_one_evaluator_and_solver_contract() -> None:
profile = _small_profile()
diff --git a/src/linkerhand_calibration/test/test_g20_right_product.py b/src/linkerhand_calibration/test/test_g20_right_product.py
index 42b2a1b..7d0c46b 100644
--- a/src/linkerhand_calibration/test/test_g20_right_product.py
+++ b/src/linkerhand_calibration/test/test_g20_right_product.py
@@ -9,6 +9,7 @@ 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,
@@ -190,13 +191,13 @@ def test_product_config_locks_three_cameras_tags_and_artifact_hashes() -> None:
G20_RIGHT_19_LAYOUT,
)
assert config.calibration_contract.profile.command_count == 20
- assert config.calibration_contract.profile.supports(
- "urdf_zero_publication"
- )
- assert config.calibration_contract.profile.supports(
- "stable_cross_view_cone_bias"
+ assert config.calibration_contract.typed_profile.artifacts.publish_corrected_urdf
+ assert (
+ config.calibration_contract.typed_profile.measurement
+ .stable_cross_view_cone_bias
)
assert config.serial_number == "G20_RIGHT_001"
+ assert config.namespace == "/g20_calibration"
assert config.required_independent_passes == 1
assert set(config.cameras) == {"front", "side", "top"}
assert len({camera["serial_number"] for camera in config.cameras.values()}) == 3
@@ -214,6 +215,26 @@ def test_product_config_locks_three_cameras_tags_and_artifact_hashes() -> None:
assert "src/g20_thumb_apriltag_calibration/config/" in product_text
+def test_schema_v2_loads_the_same_registered_product(tmp_path) -> None:
+ payload = yaml.safe_load(PRODUCT.read_text(encoding="utf-8"))
+ payload["schema_version"] = 2
+ payload["profile_id"] = "G20/right/g20_right_19/v1"
+ payload.pop("model")
+ payload.pop("side")
+ payload.pop("tag_layout")
+ config_path = tmp_path / "product_v2.yaml"
+ config_path.write_text(
+ yaml.safe_dump(payload, allow_unicode=True, sort_keys=False),
+ encoding="utf-8",
+ )
+
+ config = load_product_config(config_path, workspace=REPO, check_can=False)
+
+ assert config.schema_version == 2
+ assert config.profile_key.profile_id == "G20/right/g20_right_19/v1"
+ assert config.calibration_contract.typed_profile.key == config.profile_key
+
+
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]
diff --git a/src/linkerhand_calibration/test/test_golden_regression.py b/src/linkerhand_calibration/test/test_golden_regression.py
new file mode 100644
index 0000000..f5b76ff
--- /dev/null
+++ b/src/linkerhand_calibration/test/test_golden_regression.py
@@ -0,0 +1,72 @@
+import hashlib
+import json
+from pathlib import Path
+
+from linkerhand_calibration.models.g20.golden_regression import (
+ validate_golden_sessions,
+)
+
+
+def _write(path: Path, value: str) -> str:
+ path.write_text(value, encoding="utf-8")
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
+
+
+def test_static_golden_validation_covers_pass_fail_and_partial_scopes(
+ tmp_path: Path,
+) -> None:
+ passed = {
+ "20260830_181154": ("full", {}),
+ "20260831_141123": (
+ "thumb",
+ {"preserved_certified_zero_joints": [f"joint_{i}" for i in range(12)]},
+ ),
+ "20260831_163843": ("thumb", {"standalone": True}),
+ }
+ for session_id, (scope, extras) in passed.items():
+ session = tmp_path / session_id
+ session.mkdir()
+ payload = (
+ {"artifact_type": "g20_right_standalone_thumb_calibration"}
+ if extras.pop("standalone", False)
+ else {"artifact_type": "full"}
+ )
+ json_text = json.dumps(payload) + "\n"
+ json_hash = _write(session / "calibration.json", json_text)
+ urdf_hash = _write(session / "corrected.urdf", f"<{session_id}/>\n")
+ summary = {
+ "result": "PASS",
+ "calibration_scope": scope,
+ "artifacts": {"json": "calibration.json", "urdf": "corrected.urdf"},
+ "hashes": {
+ "calibration_json_sha256": json_hash,
+ "corrected_urdf_sha256": urdf_hash,
+ },
+ **extras,
+ }
+ (session / "calibration_summary_zh.json").write_text(
+ json.dumps(summary), encoding="utf-8"
+ )
+
+ failures = {
+ "20260831_111837": ("FIT-MODEL-401", "joint_fit_check_failed"),
+ "20260831_142322": (
+ "VAL-QUALITY-501",
+ "zero_model_validation_failed",
+ ),
+ }
+ for session_id, (code, reason) in failures.items():
+ session = tmp_path / session_id
+ session.mkdir()
+ (session / "calibration_summary_zh.json").write_text(
+ json.dumps(
+ {"result": "FAIL", "error_code": code, "reason": reason}
+ ),
+ encoding="utf-8",
+ )
+
+ (tmp_path / "latest_passed").symlink_to("20260830_181154")
+ result = validate_golden_sessions(tmp_path, replay_full_session=False)
+
+ assert result["20260830_181154"]["result"] == "PASS"
+ assert result["20260831_111837"]["error_code"] == "FIT-MODEL-401"
diff --git a/src/linkerhand_calibration/test/test_offline_replay.py b/src/linkerhand_calibration/test/test_offline_replay.py
index 8bc0f98..6bff284 100644
--- a/src/linkerhand_calibration/test/test_offline_replay.py
+++ b/src/linkerhand_calibration/test/test_offline_replay.py
@@ -1,5 +1,8 @@
import pytest
+from linkerhand_calibration.compat.legacy import (
+ uses_coupled_full_hand_zero_solver,
+)
from linkerhand_calibration.offline_replay import (
_latest_attempt_records,
_latest_palm_axis_records,
@@ -67,3 +70,17 @@ def test_output_suffix_is_safe_and_explicit() -> None:
for invalid in ("", "../escape", "/absolute", "contains space", "x" * 65):
with pytest.raises(ValueError, match="output tag"):
_output_suffix(invalid)
+
+
+def test_v1_session_solver_selection_is_confined_to_compatibility() -> None:
+ legacy = {
+ "sample_schema_version": 1,
+ "capabilities": ["palm_axis_side_channel_v2"],
+ }
+ current = {
+ "sample_schema_version": 1,
+ "capabilities": ["palm_axis_relative_motion_v3"],
+ }
+
+ assert uses_coupled_full_hand_zero_solver(legacy)
+ assert not uses_coupled_full_hand_zero_solver(current)
diff --git a/src/linkerhand_calibration/test/test_package_rename.py b/src/linkerhand_calibration/test/test_package_rename.py
index 2c9c551..1ff600f 100644
--- a/src/linkerhand_calibration/test/test_package_rename.py
+++ b/src/linkerhand_calibration/test/test_package_rename.py
@@ -1,13 +1,21 @@
import importlib
from pathlib import Path
import re
+import sys
import pytest
def test_legacy_python_package_forwards_only_public_config_api() -> None:
with pytest.warns(DeprecationWarning, match="linkerhand_calibration"):
- legacy = importlib.import_module("g20_thumb_apriltag_calibration")
+ if "g20_thumb_apriltag_calibration" in sys.modules:
+ legacy = importlib.reload(
+ sys.modules["g20_thumb_apriltag_calibration"]
+ )
+ else:
+ legacy = importlib.import_module(
+ "g20_thumb_apriltag_calibration"
+ )
current = importlib.import_module("linkerhand_calibration.product")
assert legacy.ProductConfig is current.ProductConfig
@@ -24,3 +32,7 @@ def test_new_and_legacy_executable_names_share_one_implementation() -> None:
r'"linkerhand_calibration\.runtime\.runner:main"',
setup_text,
)
+ assert (
+ "linkerhand_calibration.runtime.nodes.calibration:main"
+ in setup_text
+ )