新增o6/l6左手原始urdf

This commit is contained in:
lxp
2026-09-03 17:39:37 +08:00
parent 8de69c34a1
commit e1fb458eff
39 changed files with 2717 additions and 41 deletions
+1
View File
@@ -71,6 +71,7 @@ Thumbs.db
# Device-specific robot descriptions derived from local calibration runs
# Includes full/partial zero-calibration outputs and local copies.
/src/linkerhand_calibration/urdf/*/*_zero_calibrated*.urdf
/src/linkerhand_calibration/urdf/*/*_transferred_from_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf
@@ -4,9 +4,11 @@ from ..registry import ProfileRegistry
def register_profiles(registry: ProfileRegistry) -> None:
from .left_transfer import build_profile as build_left_transfer_profile
from .profile import build_profile
registry.register(build_profile())
registry.register(build_left_transfer_profile())
__all__ = ["register_profiles"]
@@ -2,6 +2,7 @@
from __future__ import annotations
from copy import deepcopy
import json
import hashlib
import math
@@ -557,6 +558,41 @@ def load_l6_urdf_input(
def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
from ...core import ProfileKey
key = ProfileKey.parse(str(payload.get("profile_id", "")))
if key == KEY:
profile = build_typed_profile()
active_joints = ACTIVE_JOINTS
passive_joints = PASSIVE_JOINTS
active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
else:
from .left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
if key != LEFT_TRANSFER_KEY:
raise ValueError(f"unsupported L6 runtime profile: {key.profile_id}")
profile = build_left_transfer_profile()
active_joints = tuple(left_joint_name(name) for name in ACTIVE_JOINTS)
passive_joints = tuple(left_joint_name(name) for name in PASSIVE_JOINTS)
active_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items()
}
passive_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items()
}
passive_set = frozenset(passive_joints)
all_joints = frozenset(active_joints + passive_joints)
mimic_sources = profile.zero.mimic_source_by_joint
coupling_models = profile.zero.coupling_model_by_joint
command_indices = profile.command.command_index_by_joint
required_top = {
"schema_version", "profile_id", "layout_id", "model", "side",
"serial_number", "calibration_scope", "publication_pointer",
@@ -568,13 +604,14 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError("schema v6 calibration has unexpected top-level fields")
if (
payload["schema_version"] != 6
or payload["profile_id"] != KEY.profile_id
or payload["profile_id"] != profile.key.profile_id
or payload["model"] != "L6"
or payload["side"] != "right"
or payload["side"] != profile.key.side
or payload["layout_id"] != profile.key.layout
or payload["calibration_scope"] != "partial"
):
raise ValueError("schema v6 identity is invalid")
if payload["publication_pointer"] != "latest_partial_passed":
if payload["publication_pointer"] != profile.artifacts.publication_pointer:
raise ValueError("L6 partial result has the wrong publication pointer")
if payload["curve_input_domain"] != "feedback_u8":
raise ValueError("schema v6 must be indexed by feedback_u8")
@@ -582,9 +619,9 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError("schema v6 must retain both motion directions")
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
raise ValueError("schema v6 units are invalid")
if tuple(payload["command_names"]) != COMMAND_NAMES:
if tuple(payload["command_names"]) != profile.command.names:
raise ValueError("schema v6 command channel order is invalid")
if payload["baseline_command_u8"] != [255] * 6:
if tuple(payload["baseline_command_u8"]) != profile.command.baseline_u8:
raise ValueError("schema v6 baseline must be six open commands")
protected = payload["protected_inputs"]
expected_hashes = {
@@ -600,21 +637,20 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
):
raise ValueError("schema v6 protected input hash is invalid")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != ALL_REVOLUTE_JOINTS:
if not isinstance(joints, Mapping) or set(joints) != all_joints:
raise ValueError("schema v6 must contain all 11 L6 revolute joints")
profile = build_typed_profile()
for name in ACTIVE_JOINTS + PASSIVE_JOINTS:
for name in active_joints + passive_joints:
joint = joints[name]
motor = COMMAND_INDEX_BY_JOINT[
MIMIC_SOURCE_BY_JOINT.get(name, name)
motor = command_indices[
mimic_sources.get(name, name)
]
if joint.get("urdf_joint") != name or int(joint.get("motor_index", -1)) != motor:
raise ValueError(f"{name} has an invalid URDF/SDK mapping")
if joint.get("sdk_channel") != COMMAND_NAMES[motor]:
if joint.get("sdk_channel") != profile.command.names[motor]:
raise ValueError(f"{name} has an invalid SDK channel")
if joint.get("calibration_status") != profile.joint_coverage[name]:
raise ValueError(f"{name} has an invalid coverage status")
if joint.get("passive") is not (name in PASSIVE_JOINTS):
if joint.get("passive") is not (name in passive_set):
raise ValueError(f"{name} passive flag is invalid")
if joint.get("zero_command_u8") != 255:
raise ValueError(f"{name} zero command must be 255")
@@ -624,11 +660,11 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError(f"{name}.{field} must contain 256 finite values")
if np.any(np.diff(curve) > 1.0e-7):
raise ValueError(f"{name}.{field} must be non-increasing")
if name in PASSIVE_JOINTS:
if joint.get("source_joint") != MIMIC_SOURCE_BY_JOINT[name]:
if name in passive_set:
if joint.get("source_joint") != mimic_sources[name]:
raise ValueError(f"{name} mimic source is invalid")
model = str(joint.get("coupling_model", ""))
expected_model = COUPLING_MODEL_BY_JOINT.get(
expected_model = coupling_models.get(
name, "linear_mimic"
)
if model != expected_model:
@@ -647,7 +683,7 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
"endpoint_linear_fallback"
if model == "quadratic_runtime"
else "exact_linear"
if name in MEASURED_PASSIVE_JOINTS
if name in profile.zero.fitted_mimic_joints
else "cad_nominal"
)
# Early schema-v6 linear artifacts predate the explicit policy
@@ -671,8 +707,8 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
)) > 1.0e-7:
raise ValueError(f"{name} coupling offset is inconsistent")
transferred_from = (
TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name)
or TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name)
active_transfers.get(name)
or passive_transfers.get(name)
)
if transferred_from is not None:
if joint.get("transferred_from_joint") != transferred_from:
@@ -688,6 +724,110 @@ def validate_l6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError("schema v6 quality.passed must be true")
def build_l6_left_transferred_runtime_payload(
*,
right_payload: Mapping[str, Any],
source_left_urdf: str | Path,
transferred_left_urdf: str | Path,
serial_number: str,
) -> dict[str, Any]:
"""Create a validated L6 left runtime JSON paired with a mirrored URDF."""
from .left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
validate_l6_runtime_payload(right_payload)
if str(right_payload["side"]) != "right":
raise ValueError("L6 left transfer requires a measured right payload")
left_source = Path(source_left_urdf).expanduser().resolve()
left_urdf = Path(transferred_left_urdf).expanduser().resolve()
if not left_source.is_file() or not left_urdf.is_file():
raise ValueError("L6 left source and transferred URDF files are required")
profile = build_left_transfer_profile()
source_joint_names = {
str(joint.get("name"))
for joint in ET.parse(left_source).getroot().findall("joint")
if joint.get("type") == "revolute"
}
if source_joint_names != profile.zero.active_joints | profile.zero.passive_joints:
raise ValueError("L6 left source URDF differs from transferred profile")
payload = deepcopy(dict(right_payload))
payload.update({
"profile_id": LEFT_TRANSFER_KEY.profile_id,
"layout_id": LEFT_TRANSFER_KEY.layout,
"side": "left",
"serial_number": str(serial_number),
"publication_pointer": profile.artifacts.publication_pointer,
})
payload["protected_inputs"] = {
**dict(right_payload["protected_inputs"]),
"source_urdf_sha256": _sha256_file(left_source),
}
left_joints: dict[str, dict[str, Any]] = {}
for right_name, source_item in right_payload["joints"].items():
left_name = left_joint_name(str(right_name))
item = deepcopy(dict(source_item))
item["urdf_joint"] = left_name
item["calibration_status"] = profile.joint_coverage[left_name]
item["mirrored_from_joint"] = str(right_name)
if "source_joint" in item:
item["source_joint"] = left_joint_name(str(item["source_joint"]))
if "transferred_from_joint" in item:
item["transferred_from_joint"] = left_joint_name(
str(item["transferred_from_joint"])
)
zero = item.get("zero_angles")
if isinstance(zero, Mapping):
zero = deepcopy(dict(zero))
if "transferred_from_joint" in zero:
zero["transferred_from_joint"] = left_joint_name(
str(zero["transferred_from_joint"])
)
item["zero_angles"] = {
**zero,
"mirrored_from_joint": str(right_name),
"transfer_kind": "right_measurement_mirrored_to_left",
}
left_joints[left_name] = item
payload["joints"] = left_joints
quality = deepcopy(dict(right_payload["quality"]))
thumb = quality.get("thumb_axis_zero")
if isinstance(thumb, Mapping):
thumb = deepcopy(dict(thumb))
for field in (
"offsets_rad",
"cycle_offsets_rad",
"geometry_fallback_reasons",
"validation_error_by_joint_rad",
):
values = thumb.get(field)
if isinstance(values, Mapping):
thumb[field] = {
left_joint_name(str(name)): value
for name, value in values.items()
}
quality["thumb_axis_zero"] = thumb
canonical_right = json.dumps(
right_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
quality["transfer_provenance"] = {
"kind": "right_measurement_mirrored_to_left",
"source_profile_id": str(right_payload["profile_id"]),
"source_serial_number": str(right_payload["serial_number"]),
"source_payload_sha256": hashlib.sha256(canonical_right).hexdigest(),
"left_source_urdf_sha256": _sha256_file(left_source),
"transferred_left_urdf_sha256": _sha256_file(left_urdf),
"left_hand_measured": False,
}
payload["quality"] = quality
validate_l6_runtime_payload(payload)
return payload
def atomic_write_json(path: str | Path, payload: Mapping[str, Any]) -> Path:
destination = Path(path).resolve()
destination.parent.mkdir(parents=True, exist_ok=True)
@@ -730,6 +870,7 @@ __all__ = [
"ALL_REVOLUTE_JOINTS",
"artifact_hashes",
"atomic_write_json",
"build_l6_left_transferred_runtime_payload",
"build_l6_urdf_input_payload",
"build_l6_runtime_payload",
"load_l6_urdf_input",
@@ -0,0 +1,155 @@
"""Runtime-only L6 left profile derived from reviewed right-hand calibration."""
from __future__ import annotations
from dataclasses import replace
from ...core import ProfileKey
from ..registry import EngineBindings, RegisteredProfile
from .profile import build_typed_profile as build_right_typed_profile
KEY = ProfileKey("L6", "left", "l6_left_transferred_8", 1)
def left_joint_name(name: str) -> str:
value = str(name)
if not value.startswith("rh_"):
raise ValueError(f"L6 right joint name cannot be mirrored: {value}")
return "lh_" + value[3:]
def build_typed_profile():
right = build_right_typed_profile()
command_index = {
left_joint_name(name): index
for name, index in right.command.command_index_by_joint.items()
}
active = frozenset(left_joint_name(name) for name in right.zero.active_joints)
passive = frozenset(left_joint_name(name) for name in right.zero.passive_joints)
measurements = {
left_joint_name(name): replace(spec, joint=left_joint_name(name))
for name, spec in right.measurement.measurements.items()
}
tasks = tuple(
replace(task, joints=tuple(left_joint_name(name) for name in task.joints))
for task in right.motion.tasks
)
return replace(
right,
key=KEY,
namespace="/l6_left_transferred",
command=replace(
right.command,
command_index_by_joint=command_index,
urdf_joint_by_joint={name: name for name in active},
),
motion=replace(right.motion, tasks=tasks),
measurement=replace(
right.measurement,
measurements=measurements,
cross_view_sources={
left_joint_name(name): left_joint_name(source)
for name, source in right.measurement.cross_view_sources.items()
},
image_curve_joints=frozenset(
left_joint_name(name)
for name in right.measurement.image_curve_joints
),
),
zero=replace(
right.zero,
active_joints=active,
passive_joints=passive,
direct_zero_joints=tuple(
left_joint_name(name) for name in right.zero.direct_zero_joints
),
axis_joints=tuple(
left_joint_name(name) for name in right.zero.axis_joints
),
mechanical_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.mechanical_endpoint_joints
),
post_solve_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.post_solve_endpoint_joints
),
mimic_source_by_joint={
left_joint_name(name): left_joint_name(source)
for name, source in right.zero.mimic_source_by_joint.items()
},
cad_frozen_joints=passive,
endpoint_anchor_by_joint={
left_joint_name(name): policy
for name, policy in right.zero.endpoint_anchor_by_joint.items()
},
fitted_mimic_joints=frozenset(
left_joint_name(name) for name in right.zero.fitted_mimic_joints
),
coupling_model_by_joint={
left_joint_name(name): model
for name, model in right.zero.coupling_model_by_joint.items()
},
),
scope=replace(
right.scope,
calibrate_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.calibrate_joints.items()
},
frozen_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.frozen_joints.items()
},
),
artifacts=replace(
right.artifacts,
calibration_filename=(
"l6_left_{serial_number}_transferred_calibration.json"
),
corrected_urdf_filename=(
"linkerhand_l6_left_{serial_number}_transferred.urdf"
),
publication_pointer="latest_transferred",
session_compatibility_tokens=frozenset(
{"l6_left_transfer_v1", "feedback_curves_v6"}
),
),
joint_coverage={
left_joint_name(name): status
for name, status in right.joint_coverage.items()
},
)
def _unsupported(_args=None) -> None:
raise ValueError(
"L6 left transferred profile is runtime-only; calibrate the left hand "
"with a dedicated measured profile before treating it as measured"
)
def build_profile() -> RegisteredProfile:
from .motion import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
typed = build_typed_profile()
return RegisteredProfile(
profile=typed,
engine=EngineBindings(
hand_profile=typed,
zero_profile=typed.zero,
motion_command=build_calibration_motion_command,
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_unsupported,
node_main=_unsupported,
),
)
__all__ = ["KEY", "build_profile", "build_typed_profile", "left_joint_name"]
@@ -71,6 +71,138 @@ def _corrected_origin_rpy(joint: ET.Element, offset: float) -> str:
)
def _revolute_joints(root: ET.Element) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in root.findall("joint")
if joint.get("type") == "revolute"
}
def _origin_rotation(joint: ET.Element) -> Rotation:
origin = joint.find("origin")
if origin is None or origin.get("rpy") is None:
raise ValueError(f"joint {joint.get('name')} has no origin.rpy")
return Rotation.from_euler("xyz", _triplet(origin.get("rpy", "0 0 0")))
def write_l6_left_from_right_calibration(
*,
source_left_urdf: str | Path,
source_right_urdf: str | Path,
calibrated_right_urdf: str | Path,
destination_urdf: str | Path,
) -> Path:
"""Mirror a reviewed L6 right correction onto the original left CAD."""
source_left = Path(source_left_urdf).expanduser().resolve()
source_right = Path(source_right_urdf).expanduser().resolve()
calibrated_right = Path(calibrated_right_urdf).expanduser().resolve()
destination = Path(destination_urdf).expanduser().resolve()
if not source_left.is_file() or not source_right.is_file():
raise ValueError("original L6 left and right URDF files are required")
if not calibrated_right.is_file():
raise ValueError("calibrated L6 right URDF is required")
if (
"calibrated" in source_left.stem.lower()
or "calibrated" in source_right.stem.lower()
):
raise ValueError("L6 transfer sources must be immutable original URDF files")
left_root = ET.parse(source_left).getroot()
right_source_root = ET.parse(source_right).getroot()
right_corrected_root = ET.parse(calibrated_right).getroot()
left = _revolute_joints(left_root)
right_source = _revolute_joints(right_source_root)
right_corrected = _revolute_joints(right_corrected_root)
expected_right = {name.replace("lh_", "rh_", 1) for name in left}
if set(right_source) != expected_right or set(right_corrected) != expected_right:
raise ValueError("L6 left/right revolute topology is not mirror-compatible")
joint_patches: dict[str, UrdfJointPatch] = {}
for left_name, left_joint in sorted(left.items()):
right_name = left_name.replace("lh_", "rh_", 1)
source_joint = right_source[right_name]
corrected_joint = right_corrected[right_name]
source_axis_node = source_joint.find("axis")
source_axis = _triplet(
"1 0 0"
if source_axis_node is None
else source_axis_node.get("xyz", "1 0 0")
)
source_axis /= np.linalg.norm(source_axis)
relative = _origin_rotation(source_joint).inv() * _origin_rotation(
corrected_joint
)
rotation_vector = relative.as_rotvec()
offset = float(rotation_vector @ source_axis)
if np.linalg.norm(rotation_vector - offset * source_axis) > 1.0e-7:
raise ValueError(
f"right L6 correction is not about its declared axis: {right_name}"
)
if abs(offset) > math.radians(15.0):
raise ValueError(f"right L6 correction exceeds transfer bound: {right_name}")
corrected_limit = corrected_joint.find("limit")
if corrected_limit is None:
raise ValueError(f"right L6 joint has no limit: {right_name}")
values: dict[str, str] = {
"limit_lower": str(corrected_limit.get("lower")),
"limit_upper": str(corrected_limit.get("upper")),
}
if abs(offset) > 1.0e-12:
values["origin_rpy"] = _corrected_origin_rpy(left_joint, offset)
left_mimic = left_joint.find("mimic")
right_mimic = corrected_joint.find("mimic")
if (left_mimic is None) != (right_mimic is None):
raise ValueError(f"L6 passive topology differs: {left_name}")
if left_mimic is not None and right_mimic is not None:
expected_source = str(right_mimic.get("joint")).replace("rh_", "lh_", 1)
if left_mimic.get("joint") != expected_source:
raise ValueError(f"L6 mimic source is not mirrored: {left_name}")
values["mimic_multiplier"] = str(right_mimic.get("multiplier"))
values["mimic_offset"] = str(right_mimic.get("offset", "0"))
joint_patches[left_name] = UrdfJointPatch(**values)
right_equalities = {
str(node.get("joint1")): node
for node in right_corrected_root.findall("./mujoco/equality/joint")
}
equality_patches: dict[str, MujocoEqualityPatch] = {}
for left_equality in left_root.findall("./mujoco/equality/joint"):
left_target = str(left_equality.get("joint1", ""))
left_source_name = str(left_equality.get("joint2", ""))
right_target = left_target.replace("lh_", "rh_", 1)
right_equality = right_equalities.get(right_target)
if right_equality is None:
raise ValueError(f"right L6 equality is missing: {right_target}")
expected_left_source = str(right_equality.get("joint2", "")).replace(
"rh_", "lh_", 1
)
if left_source_name != expected_left_source:
raise ValueError(f"L6 equality source is not mirrored: {left_target}")
equality_name = str(left_equality.get("name", ""))
if not equality_name:
raise ValueError(f"left L6 equality has no name: {left_target}")
equality_patches[equality_name] = MujocoEqualityPatch(
polycoef=str(right_equality.get("polycoef", "")),
expected_joint1=left_target,
expected_joint2=left_source_name,
)
if len(equality_patches) != len(right_equalities):
raise ValueError("L6 left/right equality topology differs")
write_urdf_patches(
source_urdf=source_left,
destination_urdf=destination,
patches=UrdfPatchSet(
joints=joint_patches,
mujoco_equalities=equality_patches,
),
forbidden_source_stem_patterns=(r"calibrated",),
copy_complete_mesh_directory=True,
)
return destination
def _validate_passive_ranges(
joints: Mapping[str, ET.Element], result: L6FitResult
) -> None:
@@ -290,4 +422,8 @@ def write_l6_corrected_urdf(
)
__all__ = ["L6UrdfCorrection", "write_l6_corrected_urdf"]
__all__ = [
"L6UrdfCorrection",
"write_l6_corrected_urdf",
"write_l6_left_from_right_calibration",
]
@@ -4,9 +4,11 @@ from ..registry import ProfileRegistry
def register_profiles(registry: ProfileRegistry) -> None:
from .left_transfer import build_profile as build_left_transfer_profile
from .profile import build_profile
registry.register(build_profile())
registry.register(build_left_transfer_profile())
__all__ = ["register_profiles"]
@@ -2,6 +2,7 @@
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
import math
@@ -240,6 +241,41 @@ def build_o6_runtime_payload(
def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
from ...core import ProfileKey
key = ProfileKey.parse(str(payload.get("profile_id", "")))
if key == KEY:
profile = build_typed_profile()
active_joints = ACTIVE_JOINTS
passive_joints = PASSIVE_JOINTS
active_transfers = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT
passive_transfers = TRANSFERRED_PASSIVE_SOURCE_BY_JOINT
else:
from .left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
if key != LEFT_TRANSFER_KEY:
raise ValueError(f"unsupported O6 runtime profile: {key.profile_id}")
profile = build_left_transfer_profile()
active_joints = tuple(left_joint_name(name) for name in ACTIVE_JOINTS)
passive_joints = tuple(left_joint_name(name) for name in PASSIVE_JOINTS)
active_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.items()
}
passive_transfers = {
left_joint_name(name): left_joint_name(source)
for name, source in TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.items()
}
passive_set = frozenset(passive_joints)
all_joints = frozenset(active_joints + passive_joints)
mimic_sources = profile.zero.mimic_source_by_joint
coupling_models = profile.zero.coupling_model_by_joint
command_indices = profile.command.command_index_by_joint
required = {
"schema_version", "profile_id", "layout_id", "model", "side",
"serial_number", "calibration_scope", "publication_pointer", "angle_unit",
@@ -249,16 +285,17 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
if set(payload) != required:
raise ValueError("O6 schema v6 has unexpected top-level fields")
if (
payload["schema_version"] != 6 or payload["profile_id"] != KEY.profile_id
or payload["model"] != "O6" or payload["side"] != "right"
or payload["layout_id"] != KEY.layout
payload["schema_version"] != 6
or payload["profile_id"] != profile.key.profile_id
or payload["model"] != "O6" or payload["side"] != profile.key.side
or payload["layout_id"] != profile.key.layout
or payload["calibration_scope"] != "partial"
or payload["publication_pointer"] != "latest_partial_passed"
or payload["publication_pointer"] != profile.artifacts.publication_pointer
or payload["curve_input_domain"] != "feedback_u8"
or payload["runtime_curve_policy"] != "direction_aware"
or payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]
or tuple(payload["command_names"]) != COMMAND_NAMES
or payload["baseline_command_u8"] != [255] * 6
or tuple(payload["command_names"]) != profile.command.names
or tuple(payload["baseline_command_u8"]) != profile.command.baseline_u8
):
raise ValueError("O6 schema v6 identity or command contract is invalid")
protected = payload["protected_inputs"]
@@ -268,17 +305,16 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
} or any(len(str(value)) != 64 for value in protected.values()):
raise ValueError("O6 protected inputs are invalid")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != ALL_REVOLUTE_JOINTS:
if not isinstance(joints, Mapping) or set(joints) != all_joints:
raise ValueError("O6 schema v6 must contain all 11 revolute joints")
profile = build_typed_profile()
for name in ACTIVE_JOINTS + PASSIVE_JOINTS:
for name in active_joints + passive_joints:
item = joints[name]
motor = COMMAND_INDEX_BY_JOINT[MIMIC_SOURCE_BY_JOINT.get(name, name)]
motor = command_indices[mimic_sources.get(name, name)]
if (
item.get("urdf_joint") != name or item.get("motor_index") != motor
or item.get("sdk_channel") != COMMAND_NAMES[motor]
or item.get("sdk_channel") != profile.command.names[motor]
or item.get("calibration_status") != profile.joint_coverage[name]
or item.get("passive") is not (name in PASSIVE_JOINTS)
or item.get("passive") is not (name in passive_set)
or item.get("zero_command_u8") != 255
):
raise ValueError(f"O6 joint contract is invalid: {name}")
@@ -288,10 +324,10 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError(f"{name}.{field} must contain 256 finite values")
if np.any(np.diff(values) > 1e-7):
raise ValueError(f"{name}.{field} must be non-increasing")
if name in PASSIVE_JOINTS:
if name in passive_set:
if (
item.get("source_joint") != MIMIC_SOURCE_BY_JOINT[name]
or item.get("coupling_model") != COUPLING_MODEL_BY_JOINT[name]
item.get("source_joint") != mimic_sources[name]
or item.get("coupling_model") != coupling_models[name]
or item.get("urdf_mimic_enabled") is not True
or item.get("urdf_mimic_policy") != "endpoint_linear_fallback"
):
@@ -310,7 +346,7 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
multiplier = float(item.get("mimic_multiplier", "nan"))
if not math.isfinite(multiplier) or multiplier <= 0.0:
raise ValueError(f"O6 passive mimic multiplier is invalid: {name}")
donor = TRANSFERRED_ACTIVE_SOURCE_BY_JOINT.get(name) or TRANSFERRED_PASSIVE_SOURCE_BY_JOINT.get(name)
donor = active_transfers.get(name) or passive_transfers.get(name)
if donor is not None:
if item.get("transferred_from_joint") != donor:
raise ValueError(f"O6 transfer provenance is invalid: {name}")
@@ -321,6 +357,112 @@ def validate_o6_runtime_payload(payload: Mapping[str, Any]) -> None:
raise ValueError("O6 quality.passed must be true")
def build_o6_left_transferred_runtime_payload(
*,
right_payload: Mapping[str, Any],
source_left_urdf: str | Path,
transferred_left_urdf: str | Path,
serial_number: str,
) -> dict[str, Any]:
"""Create a validated left runtime JSON paired with a mirrored URDF."""
from .left_transfer import (
KEY as LEFT_TRANSFER_KEY,
build_typed_profile as build_left_transfer_profile,
left_joint_name,
)
validate_o6_runtime_payload(right_payload)
if str(right_payload["side"]) != "right":
raise ValueError("O6 left transfer requires a measured right payload")
left_source = Path(source_left_urdf).expanduser().resolve()
left_urdf = Path(transferred_left_urdf).expanduser().resolve()
if not left_source.is_file() or not left_urdf.is_file():
raise ValueError("O6 left source and transferred URDF files are required")
profile = build_left_transfer_profile()
source_joint_names = {
str(joint.get("name"))
for joint in ET.parse(left_source).getroot().findall("joint")
if joint.get("type") == "revolute"
}
if source_joint_names != profile.zero.active_joints | profile.zero.passive_joints:
raise ValueError("O6 left source URDF differs from transferred profile")
payload = deepcopy(dict(right_payload))
payload.update({
"profile_id": LEFT_TRANSFER_KEY.profile_id,
"layout_id": LEFT_TRANSFER_KEY.layout,
"side": "left",
"serial_number": str(serial_number),
"publication_pointer": profile.artifacts.publication_pointer,
})
payload["protected_inputs"] = {
**dict(right_payload["protected_inputs"]),
"source_urdf_sha256": _sha256(left_source),
}
left_joints: dict[str, dict[str, Any]] = {}
for right_name, source_item in right_payload["joints"].items():
left_name = left_joint_name(str(right_name))
item = deepcopy(dict(source_item))
item["urdf_joint"] = left_name
item["calibration_status"] = profile.joint_coverage[left_name]
item["mirrored_from_joint"] = str(right_name)
if "source_joint" in item:
item["source_joint"] = left_joint_name(str(item["source_joint"]))
if "transferred_from_joint" in item:
item["transferred_from_joint"] = left_joint_name(
str(item["transferred_from_joint"])
)
zero = item.get("zero_angles")
if isinstance(zero, Mapping):
item["zero_angles"] = {
**dict(zero),
"mirrored_from_joint": str(right_name),
"transfer_kind": "right_measurement_mirrored_to_left",
}
left_joints[left_name] = item
payload["joints"] = left_joints
quality = deepcopy(dict(right_payload["quality"]))
hysteresis = quality.get("maximum_hysteresis_by_joint_rad")
if isinstance(hysteresis, Mapping):
quality["maximum_hysteresis_by_joint_rad"] = {
left_joint_name(str(name)): value
for name, value in hysteresis.items()
}
thumb = quality.get("thumb_axis_zero")
if isinstance(thumb, Mapping):
thumb = deepcopy(dict(thumb))
for field in (
"offsets_rad",
"candidate_geometry_offsets_rad",
"policy_by_joint",
"validation_error_by_joint_rad",
"validation_line_error_by_joint_m",
):
values = thumb.get(field)
if isinstance(values, Mapping):
thumb[field] = {
left_joint_name(str(name)): value
for name, value in values.items()
}
quality["thumb_axis_zero"] = thumb
canonical_right = json.dumps(
right_payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")
).encode("utf-8")
quality["transfer_provenance"] = {
"kind": "right_measurement_mirrored_to_left",
"source_profile_id": str(right_payload["profile_id"]),
"source_serial_number": str(right_payload["serial_number"]),
"source_payload_sha256": hashlib.sha256(canonical_right).hexdigest(),
"left_source_urdf_sha256": _sha256(left_source),
"transferred_left_urdf_sha256": _sha256(left_urdf),
"left_hand_measured": False,
}
payload["quality"] = quality
validate_o6_runtime_payload(payload)
return payload
def build_o6_urdf_input_payload(
*, serial_number: str, source_urdf: str | Path, result: O6FitResult
) -> dict[str, Any]:
@@ -444,7 +586,8 @@ def load_o6_urdf_input(
__all__ = [
"ALL_REVOLUTE_JOINTS", "artifact_hashes", "atomic_write_json",
"build_o6_runtime_payload", "build_o6_urdf_input_payload", "load_o6_urdf_input",
"build_o6_left_transferred_runtime_payload", "build_o6_runtime_payload",
"build_o6_urdf_input_payload", "load_o6_urdf_input",
"publish_partial_session", "validate_o6_runtime_payload",
"validate_o6_urdf_input_payload",
]
@@ -0,0 +1,159 @@
"""Runtime-only O6 left profile derived from reviewed right-hand calibration."""
from __future__ import annotations
from dataclasses import replace
from ...core import ProfileKey
from ..registry import EngineBindings, RegisteredProfile
from .profile import build_typed_profile as build_right_typed_profile
KEY = ProfileKey("O6", "left", "o6_left_transferred_8", 1)
def left_joint_name(name: str) -> str:
value = str(name)
if not value.startswith("rh_"):
raise ValueError(f"O6 right joint name cannot be mirrored: {value}")
return "lh_" + value[3:]
def build_typed_profile():
right = build_right_typed_profile()
command_index = {
left_joint_name(name): index
for name, index in right.command.command_index_by_joint.items()
}
active = frozenset(left_joint_name(name) for name in right.zero.active_joints)
passive = frozenset(left_joint_name(name) for name in right.zero.passive_joints)
measurements = {
left_joint_name(name): replace(spec, joint=left_joint_name(name))
for name, spec in right.measurement.measurements.items()
}
tasks = tuple(
replace(task, joints=tuple(left_joint_name(name) for name in task.joints))
for task in right.motion.tasks
)
return replace(
right,
key=KEY,
namespace="/o6_left_transferred",
command=replace(
right.command,
command_index_by_joint=command_index,
urdf_joint_by_joint={name: name for name in active},
),
motion=replace(right.motion, tasks=tasks),
measurement=replace(
right.measurement,
measurements=measurements,
cross_view_sources={
left_joint_name(name): left_joint_name(source)
for name, source in right.measurement.cross_view_sources.items()
},
image_curve_joints=frozenset(
left_joint_name(name)
for name in right.measurement.image_curve_joints
),
),
zero=replace(
right.zero,
active_joints=active,
passive_joints=passive,
direct_zero_joints=tuple(
left_joint_name(name) for name in right.zero.direct_zero_joints
),
axis_joints=tuple(
left_joint_name(name) for name in right.zero.axis_joints
),
mechanical_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.mechanical_endpoint_joints
),
post_solve_endpoint_joints=frozenset(
left_joint_name(name)
for name in right.zero.post_solve_endpoint_joints
),
mimic_source_by_joint={
left_joint_name(name): left_joint_name(source)
for name, source in right.zero.mimic_source_by_joint.items()
},
cad_frozen_joints=passive,
endpoint_anchor_by_joint={
left_joint_name(name): policy
for name, policy in right.zero.endpoint_anchor_by_joint.items()
},
fitted_mimic_joints=frozenset(
left_joint_name(name) for name in right.zero.fitted_mimic_joints
),
coupling_model_by_joint={
left_joint_name(name): model
for name, model in right.zero.coupling_model_by_joint.items()
},
),
scope=replace(
right.scope,
calibrate_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.calibrate_joints.items()
},
frozen_joints={
scope: frozenset(left_joint_name(name) for name in names)
for scope, names in right.scope.frozen_joints.items()
},
),
artifacts=replace(
right.artifacts,
calibration_filename=(
"o6_left_{serial_number}_transferred_calibration.json"
),
corrected_urdf_filename=(
"linkerhand_o6_left_{serial_number}_transferred.urdf"
),
publication_pointer="latest_transferred",
session_compatibility_tokens=frozenset(
{"o6_left_transfer_v1", "feedback_curves_v6"}
),
),
joint_coverage={
name: (
"transferred_static_dynamic"
if name in active
else "transferred_dynamic_cad_static"
)
for name in active | passive
},
)
def _unsupported(_args=None) -> None:
raise ValueError(
"O6 left transferred profile is runtime-only; calibrate the left hand "
"with a dedicated measured profile before treating it as measured"
)
def build_profile() -> RegisteredProfile:
from ..l6.motion import (
build_calibration_motion_command,
build_calibration_preparation_waypoints,
build_calibration_return_waypoints,
)
typed = build_typed_profile()
return RegisteredProfile(
profile=typed,
engine=EngineBindings(
hand_profile=typed,
zero_profile=typed.zero,
motion_command=build_calibration_motion_command,
preparation_waypoints=build_calibration_preparation_waypoints,
return_waypoints=build_calibration_return_waypoints,
cli_main=_unsupported,
node_main=_unsupported,
),
)
__all__ = ["KEY", "build_profile", "build_typed_profile", "left_joint_name"]
@@ -9,9 +9,10 @@ from typing import Mapping
import xml.etree.ElementTree as ET
import numpy as np
from scipy.spatial.transform import Rotation
from ...core.urdf import UrdfJointPatch, UrdfPatchSet, write_urdf_patches
from ..l6.urdf import L6UrdfCorrection, _corrected_origin_rpy
from ..l6.urdf import L6UrdfCorrection, _corrected_origin_rpy, _triplet
from .fitting import O6FitResult
from .profile import (
CALIBRATED_ACTIVE_JOINTS,
@@ -27,6 +28,111 @@ from .profile import (
O6UrdfCorrection = L6UrdfCorrection
def _joint_map(root: ET.Element) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in root.findall("joint")
if joint.get("type") == "revolute"
}
def _origin_rotation(joint: ET.Element) -> Rotation:
origin = joint.find("origin")
if origin is None or origin.get("rpy") is None:
raise ValueError(f"joint {joint.get('name')} has no origin.rpy")
return Rotation.from_euler("xyz", _triplet(origin.get("rpy", "0 0 0")))
def write_o6_left_from_right_calibration(
*,
source_left_urdf: str | Path,
source_right_urdf: str | Path,
calibrated_right_urdf: str | Path,
destination_urdf: str | Path,
) -> Path:
"""Mirror reviewed right-hand scalar corrections onto left-hand CAD.
This is an explicitly transferred preview, not a left-hand measurement.
Left mesh, inertia, topology and joint origins remain authoritative. The
measured right-hand ranges and passive ratios are scalar mechanism data;
the yaw origin correction is reapplied about the left joint's mirrored
local axis instead of copying the right-hand Euler angle.
"""
source_left = Path(source_left_urdf).expanduser().resolve()
source_right = Path(source_right_urdf).expanduser().resolve()
calibrated_right = Path(calibrated_right_urdf).expanduser().resolve()
destination = Path(destination_urdf).expanduser().resolve()
if not source_left.is_file() or not source_right.is_file():
raise ValueError("original O6 left and right URDF files are required")
if not calibrated_right.is_file():
raise ValueError("calibrated O6 right URDF is required")
if "calibrated" in source_left.stem.lower() or "calibrated" in source_right.stem.lower():
raise ValueError("O6 transfer sources must be immutable original URDF files")
left_root = ET.parse(source_left).getroot()
right_source_root = ET.parse(source_right).getroot()
right_corrected_root = ET.parse(calibrated_right).getroot()
left = _joint_map(left_root)
right_source = _joint_map(right_source_root)
right_corrected = _joint_map(right_corrected_root)
expected_right = {name.replace("lh_", "rh_", 1) for name in left}
if set(right_source) != expected_right or set(right_corrected) != expected_right:
raise ValueError("O6 left/right revolute topology is not mirror-compatible")
right_yaw_source = right_source["rh_thumb_cmc_yaw"]
right_yaw_corrected = right_corrected["rh_thumb_cmc_yaw"]
axis = _triplet(right_yaw_source.find("axis").get("xyz", "0 0 -1"))
axis /= np.linalg.norm(axis)
relative = _origin_rotation(right_yaw_source).inv() * _origin_rotation(
right_yaw_corrected
)
rotation_vector = relative.as_rotvec()
yaw_offset = float(rotation_vector @ axis)
if np.linalg.norm(rotation_vector - yaw_offset * axis) > 1.0e-8:
raise ValueError("right O6 yaw correction is not about its declared axis")
if abs(yaw_offset) > math.radians(15.0):
raise ValueError("right O6 yaw correction exceeds transfer safety bound")
source_pitch_rpy = right_source["rh_thumb_cmc_pitch"].find("origin").get("rpy")
corrected_pitch_rpy = right_corrected["rh_thumb_cmc_pitch"].find("origin").get("rpy")
if source_pitch_rpy != corrected_pitch_rpy:
raise ValueError("right O6 calibration does not use the reviewed CAD pitch zero")
patches: dict[str, UrdfJointPatch] = {}
for left_name, left_joint in sorted(left.items()):
right_name = left_name.replace("lh_", "rh_", 1)
right_joint = right_corrected[right_name]
right_limit = right_joint.find("limit")
if right_limit is None:
raise ValueError(f"right O6 joint has no limit: {right_name}")
values: dict[str, str] = {
"limit_lower": str(right_limit.get("lower")),
"limit_upper": str(right_limit.get("upper")),
}
if left_name == "lh_thumb_cmc_yaw":
values["origin_rpy"] = _corrected_origin_rpy(left_joint, yaw_offset)
left_mimic = left_joint.find("mimic")
right_mimic = right_joint.find("mimic")
if (left_mimic is None) != (right_mimic is None):
raise ValueError(f"O6 passive topology differs: {left_name}")
if left_mimic is not None and right_mimic is not None:
expected_source = str(right_mimic.get("joint")).replace("rh_", "lh_", 1)
if left_mimic.get("joint") != expected_source:
raise ValueError(f"O6 mimic source is not mirrored: {left_name}")
values["mimic_multiplier"] = str(right_mimic.get("multiplier"))
values["mimic_offset"] = str(right_mimic.get("offset", "0"))
patches[left_name] = UrdfJointPatch(**values)
write_urdf_patches(
source_urdf=source_left,
destination_urdf=destination,
patches=UrdfPatchSet(joints=patches),
forbidden_source_stem_patterns=(r"calibrated",),
copy_complete_mesh_directory=True,
)
return destination
def _validate_passive_ranges(
joints: Mapping[str, ET.Element], result: O6FitResult
) -> dict[str, tuple[float, float]]:
@@ -169,4 +275,8 @@ def write_o6_corrected_urdf(
)
__all__ = ["O6UrdfCorrection", "write_o6_corrected_urdf"]
__all__ = [
"O6UrdfCorrection",
"write_o6_corrected_urdf",
"write_o6_left_from_right_calibration",
]
@@ -145,7 +145,7 @@ def test_runtime_has_no_concrete_model_or_view_assumption() -> None:
def test_every_registered_profile_passes_static_integrity_checks() -> None:
registry = get_default_registry()
assert len(registry) == 5
assert len(registry) == 7
for registered in registry:
validate_profile(registered.profile)
assert registered.profile.zero.active_joints
@@ -16,6 +16,7 @@ from linkerhand_calibration.calibrated_joint_state_bridge import (
from linkerhand_calibration.core import validate_profile
from linkerhand_calibration.models.l6.artifacts import (
atomic_write_json,
build_l6_left_transferred_runtime_payload,
build_l6_urdf_input_payload,
build_l6_runtime_payload,
load_l6_urdf_input,
@@ -44,7 +45,10 @@ from linkerhand_calibration.models.l6.profile import (
build_typed_profile,
)
from linkerhand_calibration.models.l6.runner import render_l6_progress_zh
from linkerhand_calibration.models.l6.urdf import write_l6_corrected_urdf
from linkerhand_calibration.models.l6.urdf import (
write_l6_corrected_urdf,
write_l6_left_from_right_calibration,
)
from linkerhand_calibration.extrinsics import matrix_payload, transform_matrix
from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel
from linkerhand_calibration.product import load_product_config, sha256_file
@@ -52,6 +56,7 @@ from linkerhand_calibration.product import load_product_config, sha256_file
PACKAGE = Path(__file__).resolve().parents[1]
SOURCE = PACKAGE / "urdf/l6_right/linkerhand_l6v3.1_right.urdf"
LEFT_SOURCE = PACKAGE / "urdf/l6_left/linkerhand_l6v3.1_left.urdf"
PRODUCT = PACKAGE / "config/l6_right_product.yaml"
TRAVELS = {
@@ -714,6 +719,116 @@ def test_l6_urdf_writer_changes_only_authorized_joint_fields(tmp_path: Path) ->
)
def test_l6_right_corrections_are_mirrored_onto_left_cad(tmp_path: Path) -> None:
result = fit_l6_session(SOURCE, accepted_records_by_joint(_synthetic_records()))
right = write_l6_corrected_urdf(
source_urdf=SOURCE,
output_directory=tmp_path / "right",
serial_number="RIGHT_TEST",
result=result,
timestamp="20260903_120000",
).path
left_path = write_l6_left_from_right_calibration(
source_left_urdf=LEFT_SOURCE,
source_right_urdf=SOURCE,
calibrated_right_urdf=right,
destination_urdf=tmp_path / "left" / "l6_left_transferred.urdf",
)
original = {
str(joint.get("name")): joint
for joint in ET.parse(LEFT_SOURCE).getroot().findall("joint")
if joint.get("type") == "revolute"
}
transferred_root = ET.parse(left_path).getroot()
transferred = {
str(joint.get("name")): joint
for joint in transferred_root.findall("joint")
if joint.get("type") == "revolute"
}
corrected_right = {
str(joint.get("name")): joint
for joint in ET.parse(right).getroot().findall("joint")
if joint.get("type") == "revolute"
}
for left_name, joint in transferred.items():
right_name = left_name.replace("lh_", "rh_", 1)
assert joint.find("origin").get("xyz") == original[left_name].find(
"origin"
).get("xyz")
assert joint.find("axis").attrib == original[left_name].find("axis").attrib
assert joint.find("limit").get("lower") == corrected_right[
right_name
].find("limit").get("lower")
assert joint.find("limit").get("upper") == corrected_right[
right_name
].find("limit").get("upper")
if joint.find("mimic") is not None:
assert joint.find("mimic").get("multiplier") == corrected_right[
right_name
].find("mimic").get("multiplier")
for name, expected in THUMB_ZERO_OFFSETS.items():
left_name = name.replace("rh_", "lh_", 1)
source_rotation = Rotation.from_euler(
"xyz",
[float(value) for value in original[left_name].find("origin").get(
"rpy"
).split()],
)
corrected_rotation = Rotation.from_euler(
"xyz",
[float(value) for value in transferred[left_name].find("origin").get(
"rpy"
).split()],
)
axis = np.asarray([
float(value)
for value in original[left_name].find("axis").get("xyz").split()
])
axis /= np.linalg.norm(axis)
applied = float((source_rotation.inv() * corrected_rotation).as_rotvec() @ axis)
assert applied == pytest.approx(expected, abs=1.0e-6)
left_equalities = {
str(node.get("joint1")): node.get("polycoef")
for node in transferred_root.findall("./mujoco/equality/joint")
}
right_equalities = {
str(node.get("joint1")).replace("rh_", "lh_", 1): node.get("polycoef")
for node in ET.parse(right).getroot().findall("./mujoco/equality/joint")
}
assert left_equalities == right_equalities
right_payload = build_l6_runtime_payload(
serial_number="RIGHT_TEST",
source_urdf=SOURCE,
result=result,
protected_inputs={
"source_urdf_sha256": "0" * 64,
"camera_extrinsics_sha256": "1" * 64,
"calibration_config_sha256": "2" * 64,
"tag_config_sha256": "3" * 64,
},
)
left_payload = build_l6_left_transferred_runtime_payload(
right_payload=right_payload,
source_left_urdf=LEFT_SOURCE,
transferred_left_urdf=left_path,
serial_number="LEFT_TRANSFER_TEST",
)
validate_l6_runtime_payload(left_payload)
mapper = CalibratedCommandMapper(left_payload, expected_side="left")
assert mapper.profile_id == "L6/left/l6_left_transferred_8/v1"
assert set(mapper.urdf_joint_names) == set(transferred)
assert left_payload["joints"]["lh_pinky_dip"]["source_joint"] == (
"lh_pinky_mcp_pitch"
)
assert left_payload["joints"]["lh_index_dip"][
"transferred_from_joint"
] == "lh_pinky_dip"
assert left_payload["quality"]["transfer_provenance"][
"left_hand_measured"
] is False
def test_l6_schema_v6_bridge_uses_feedback_and_rh_joint_names() -> None:
result = fit_l6_session(SOURCE, accepted_records_by_joint(_synthetic_records()))
hashes = {
@@ -14,6 +14,7 @@ from linkerhand_calibration.extrinsics import matrix_payload, transform_matrix
from linkerhand_calibration.models.g20.zero_solver import UrdfKinematicModel
from linkerhand_calibration.models.l6.node import MotionStep
from linkerhand_calibration.models.o6.artifacts import (
build_o6_left_transferred_runtime_payload,
build_o6_runtime_payload,
validate_o6_runtime_payload,
)
@@ -38,14 +39,26 @@ from linkerhand_calibration.models.o6.profile import (
build_typed_profile,
)
from linkerhand_calibration.models.o6.runner import render_o6_progress_zh
from linkerhand_calibration.models.o6.urdf import write_o6_corrected_urdf
from linkerhand_calibration.models.o6.urdf import (
write_o6_corrected_urdf,
write_o6_left_from_right_calibration,
)
PACKAGE = Path(__file__).resolve().parents[1]
SOURCE = PACKAGE / "urdf/o6_right/linkerhand_o6_right.urdf"
LEFT_SOURCE = PACKAGE / "urdf/o6_left/linkerhand_o6_left.urdf"
KINEMATIC_MODEL = UrdfKinematicModel(SOURCE)
def _joint_elements(path: Path) -> dict[str, ET.Element]:
return {
str(joint.get("name")): joint
for joint in ET.parse(path).getroot().findall("joint")
if joint.get("type") == "revolute"
}
def test_o6_profile_declares_reviewed_six_channel_contract() -> None:
profile = build_typed_profile()
validate_profile(profile)
@@ -475,3 +488,80 @@ def test_o6_uses_geometric_yaw_and_cad_endpoint_pitch() -> None:
assert result.thumb_zero_result.direct_offsets_rad[
"rh_thumb_cmc_pitch"
] == pytest.approx(-0.02, abs=1e-6)
def test_o6_right_corrections_are_mirrored_without_replacing_left_geometry(
tmp_path: Path,
) -> None:
result = fit_o6_session(
SOURCE, _geometric_records(), require_thumb_axis_zero=True
)
right = write_o6_corrected_urdf(
source_urdf=SOURCE,
output_directory=tmp_path / "right",
serial_number="RIGHT_TEST",
result=result,
).path
destination = write_o6_left_from_right_calibration(
source_left_urdf=LEFT_SOURCE,
source_right_urdf=SOURCE,
calibrated_right_urdf=right,
destination_urdf=tmp_path / "left" / "o6_left_transferred.urdf",
)
original = _joint_elements(LEFT_SOURCE)
transferred = _joint_elements(destination)
corrected_right = _joint_elements(right)
for left_name, joint in transferred.items():
right_name = left_name.replace("lh_", "rh_", 1)
assert joint.find("origin").get("xyz") == original[left_name].find(
"origin"
).get("xyz")
assert joint.find("axis").attrib == original[left_name].find("axis").attrib
assert joint.find("limit").get("upper") == corrected_right[
right_name
].find("limit").get("upper")
if joint.find("mimic") is not None:
assert joint.find("mimic").get("multiplier") == corrected_right[
right_name
].find("mimic").get("multiplier")
assert transferred["lh_thumb_cmc_pitch"].find("origin").get("rpy") == (
original["lh_thumb_cmc_pitch"].find("origin").get("rpy")
)
left_yaw = Rotation.from_euler(
"xyz",
[float(value) for value in transferred["lh_thumb_cmc_yaw"].find(
"origin"
).get("rpy").split()],
)
assert left_yaw.as_rotvec()[2] == pytest.approx(0.03, abs=1e-6)
right_payload = build_o6_runtime_payload(
serial_number="RIGHT_TEST",
source_urdf=SOURCE,
result=result,
protected_inputs={
"source_urdf_sha256": "0" * 64,
"camera_extrinsics_sha256": "1" * 64,
"calibration_config_sha256": "2" * 64,
"tag_config_sha256": "3" * 64,
},
)
left_payload = build_o6_left_transferred_runtime_payload(
right_payload=right_payload,
source_left_urdf=LEFT_SOURCE,
transferred_left_urdf=destination,
serial_number="LEFT_TRANSFER_TEST",
)
validate_o6_runtime_payload(left_payload)
mapper = CalibratedCommandMapper(left_payload, expected_side="left")
assert mapper.profile_id == "O6/left/o6_left_transferred_8/v1"
assert set(mapper.urdf_joint_names) == set(transferred)
mapped = dict(zip(
mapper.urdf_joint_names,
mapper.map_positions([0, 255, 255, 255, 255, 255]),
))
assert mapped["lh_thumb_cmc_pitch"] == pytest.approx(0.5, abs=2.0e-4)
assert mapped["lh_thumb_ip"] == pytest.approx(0.93, abs=2.0e-4)
assert left_payload["quality"]["transfer_provenance"][
"left_hand_measured"
] is False
@@ -0,0 +1,956 @@
<?xml version="1.0" encoding="utf-8"?>
<robot
name="linkerhand_l6v3.1_left">
<!-- 手掌基座,作为整只手的根链接 -->
<link
name="lh_hand_base_link">
<inertial>
<origin
xyz="0.00199 -0.00091 0.058381"
rpy="0 0 0" />
<mass
value="0.4185" />
<inertia
ixx="0.0004998"
ixy="0.000012853"
ixz="-0.000008317"
iyy="0.0003567"
iyz="0.000010354"
izz="0.0002301" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/hand_base_link.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/hand_base_link.STL" />
</geometry>
</collision>
</link>
<link
name="lh_thumb_metacarpals_base1">
<inertial>
<origin
xyz="0.0018663 -0.0066406 0.001533"
rpy="0 0 0" />
<mass
value="0.01" />
<inertia
ixx="3.3724E-07"
ixy="1.11E-07"
ixz="-3.6039E-08"
iyy="2.9457E-07"
iyz="5.7181E-08"
izz="4.903E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals_base1.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals_base1.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_thumb_cmc_roll"
type="revolute">
<origin
xyz="0.0078133 -0.029898 0.026692"
rpy="-0.034907 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_thumb_metacarpals_base1" />
<axis
xyz="0 0 1" />
<limit
lower="-0.087266"
upper="1.256637"
effort="0.588"
velocity="5.95" />
<dynamics damping="0.02" friction="0.05416667"/>
</joint>
<link
name="lh_thumb_metacarpals">
<inertial>
<origin
xyz="0.00769270907060086 0.0859662184722988 0.0235283086675235"
rpy="0 0 0" />
<mass
value="0.065" />
<inertia
ixx="8.407E-06"
ixy="-1.1338E-09"
ixz="1.1136E-08"
iyy="8.3899E-06"
iyz="3.1171E-07"
izz="2.0091E-06" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_thumb_cmc_pitch"
type="revolute">
<origin
xyz="0.0057298 -0.01332 0.0027456"
rpy="1.1083 0 0.40143" />
<parent
link="lh_thumb_metacarpals_base1" />
<child
link="lh_thumb_metacarpals" />
<axis
xyz="-1 0 0" />
<limit
lower="0"
upper="0.837758"
effort="1.008"
velocity="3.47" />
<dynamics damping="0.02" friction="0.0375"/>
</joint>
<link
name="lh_thumb_distal">
<inertial>
<origin
xyz="0.00769270907081708 0.0798662184721594 -0.0591716913325555"
rpy="0 0 0" />
<mass
value="0.018" />
<inertia
ixx="1.5546E-06"
ixy="2.4423E-09"
ixz="2.8147E-09"
iyy="1.7108E-06"
iyz="-7.2756E-08"
izz="9.1709E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_distal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_distal.STL" />
</geometry>
</collision>
</link>
<link
name="lh_thumb_tip">
<inertial>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<mass
value="1e-06" />
<inertia
ixx="1e-09"
ixy="0"
ixz="0"
iyy="1e-09"
iyz="0"
izz="1e-09" />
</inertial>
</link>
<joint
name="lh_thumb_tip_fixed"
type="fixed">
<origin
xyz="0 0.0035 0.026"
rpy="0 0 1.5708" />
<parent
link="lh_thumb_distal" />
<child
link="lh_thumb_tip" />
</joint>
<joint
name="lh_thumb_dip"
type="revolute">
<origin
xyz="0 0.0061 0.0827"
rpy="0 0 0" />
<parent
link="lh_thumb_metacarpals" />
<child
link="lh_thumb_distal" />
<axis
xyz="-1 0 0" />
<limit
lower="0"
upper="0.963287"
effort="0"
velocity="1.7" />
<mimic
joint="lh_thumb_cmc_pitch"
multiplier="1.226495"
offset="0" />
</joint>
<link
name="lh_index_proximal">
<inertial>
<origin
xyz="0.001776 -0.00011 0.012556"
rpy="0 0 0" />
<mass
value="0.008553" />
<inertia
ixx="0.000000797543"
ixy="-0.000000001275"
ixz="0.000000138601"
iyy="0.000000800499"
iyz="0.000000000939"
izz="0.000000357001" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_proximal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_index_mcp_pitch"
type="revolute">
<origin
xyz="-0.00066821 -0.028967 0.11722"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_index_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.134464"
effort="0.574"
velocity="6.10" />
<dynamics damping="0.01" friction="0.04"/>
</joint>
<link
name="lh_index_distal">
<inertial>
<origin
xyz="0.0134 0.002262 0.016992"
rpy="0 0 0" />
<mass
value="0.018408" />
<inertia
ixx="0.000002488712"
ixy="0.000000008249"
ixz="-0.000001523059"
iyy="0.000003723754"
iyz="0.000000014019"
izz="0.000001813582" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_distal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_distal.STL" />
</geometry>
</collision>
</link>
<link
name="lh_index_tip">
<inertial>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<mass
value="1e-06" />
<inertia
ixx="1e-09"
ixy="0"
ixz="0"
iyy="1e-09"
iyz="0"
izz="1e-09" />
</inertial>
</link>
<joint
name="lh_index_tip_fixed"
type="fixed">
<origin
xyz="0.0341 0 0.034"
rpy="0 0 0" />
<parent
link="lh_index_distal" />
<child
link="lh_index_tip" />
</joint>
<joint
name="lh_index_dip"
type="revolute">
<origin
xyz="0.00093749 0 0.028002"
rpy="0 0 0" />
<parent
link="lh_index_proximal" />
<child
link="lh_index_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.277039"
effort="0"
velocity="3.1" />
<mimic
joint="lh_index_mcp_pitch"
multiplier="1.125676"
offset="0" />
</joint>
<link
name="lh_middle_proximal">
<inertial>
<origin
xyz="0.001776 -0.00011 0.012556"
rpy="0 0 0" />
<mass
value="0.008553" />
<inertia
ixx="0.000000797543"
ixy="-0.000000001275"
ixz="0.000000138601"
iyy="0.000000800499"
iyz="0.000000000939"
izz="0.000000357001" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_proximal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_middle_mcp_pitch"
type="revolute">
<origin
xyz="-0.00066821 -0.0089013 0.12322"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_middle_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.134464"
effort="0.574"
velocity="6.10" />
<dynamics damping="0.01" friction="0.02708333"/>
</joint>
<link
name="lh_middle_distal">
<inertial>
<origin
xyz="0.0134 0.002262 0.016992"
rpy="0 0 0" />
<mass
value="0.018408" />
<inertia
ixx="0.000002488712"
ixy="0.000000008249"
ixz="-0.000001523059"
iyy="0.000003723754"
iyz="0.000000014019"
izz="0.000001813582" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_distal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_distal.STL" />
</geometry>
</collision>
</link>
<link
name="lh_middle_tip">
<inertial>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<mass
value="1e-06" />
<inertia
ixx="1e-09"
ixy="0"
ixz="0"
iyy="1e-09"
iyz="0"
izz="1e-09" />
</inertial>
</link>
<joint
name="lh_middle_tip_fixed"
type="fixed">
<origin
xyz="0.0341 0 0.034"
rpy="0 0 0" />
<parent
link="lh_middle_distal" />
<child
link="lh_middle_tip" />
</joint>
<joint
name="lh_middle_dip"
type="revolute">
<origin
xyz="0.00093749 0 0.028002"
rpy="0 0 0" />
<parent
link="lh_middle_proximal" />
<child
link="lh_middle_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.277039"
effort="0"
velocity="3.1" />
<mimic
joint="lh_middle_mcp_pitch"
multiplier="1.125676"
offset="0" />
</joint>
<link
name="lh_ring_proximal">
<inertial>
<origin
xyz="0.001776 -0.00011 0.012556"
rpy="0 0 0" />
<mass
value="0.008553" />
<inertia
ixx="0.000000797543"
ixy="-0.000000001275"
ixz="0.000000138601"
iyy="0.000000800499"
iyz="0.000000000939"
izz="0.000000357001" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_proximal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_ring_mcp_pitch"
type="revolute">
<origin
xyz="-0.00067368 0.010717 0.11822"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_ring_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.134464"
effort="0.574"
velocity="6.10" />
<dynamics damping="0.01" friction="0.02291667"/>
</joint>
<link
name="lh_ring_distal">
<inertial>
<origin
xyz="0.0134 0.002262 0.016992"
rpy="0 0 0" />
<mass
value="0.018408" />
<inertia
ixx="0.000002488712"
ixy="0.000000008249"
ixz="-0.000001523059"
iyy="0.000003723754"
iyz="0.000000014019"
izz="0.000001813582" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_distal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_distal.STL" />
</geometry>
</collision>
</link>
<link
name="lh_ring_tip">
<inertial>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<mass
value="1e-06" />
<inertia
ixx="1e-09"
ixy="0"
ixz="0"
iyy="1e-09"
iyz="0"
izz="1e-09" />
</inertial>
</link>
<joint
name="lh_ring_tip_fixed"
type="fixed">
<origin
xyz="0.0341 0 0.034"
rpy="0 0 0" />
<parent
link="lh_ring_distal" />
<child
link="lh_ring_tip" />
</joint>
<joint
name="lh_ring_dip"
type="revolute">
<origin
xyz="0.00093749 0 0.028002"
rpy="0 0 0" />
<parent
link="lh_ring_proximal" />
<child
link="lh_ring_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.277039"
effort="0"
velocity="3.1" />
<mimic
joint="lh_ring_mcp_pitch"
multiplier="1.125676"
offset="0" />
</joint>
<link
name="lh_pinky_proximal">
<inertial>
<origin
xyz="0.001776 -0.00011 0.012556"
rpy="0 0 0" />
<mass
value="0.008553" />
<inertia
ixx="0.000000797543"
ixy="-0.000000001275"
ixz="0.000000138601"
iyy="0.000000800499"
iyz="0.000000000939"
izz="0.000000357001" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_proximal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_pinky_mcp_pitch"
type="revolute">
<origin
xyz="-0.00067852 0.029814 0.11424"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_pinky_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.134464"
effort="0.574"
velocity="6.10" />
<dynamics damping="0.01" friction="0.01"/>
</joint>
<link
name="lh_pinky_distal">
<inertial>
<origin
xyz="0.0134 0.002262 0.016992"
rpy="0 0 0" />
<mass
value="0.018408" />
<inertia
ixx="0.000002488712"
ixy="0.000000008249"
ixz="-0.000001523059"
iyy="0.000003723754"
iyz="0.000000014019"
izz="0.000001813582" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_distal.STL" />
</geometry>
<material
name="">
<color
rgba="0.796078431372549 0.823529411764706 0.937254901960784 1" />
</material>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_distal.STL" />
</geometry>
</collision>
</link>
<link
name="lh_pinky_tip">
<inertial>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<mass
value="1e-06" />
<inertia
ixx="1e-09"
ixy="0"
ixz="0"
iyy="1e-09"
iyz="0"
izz="1e-09" />
</inertial>
</link>
<joint
name="lh_pinky_tip_fixed"
type="fixed">
<origin
xyz="0.0341 0 0.034"
rpy="0 0 0" />
<parent
link="lh_pinky_distal" />
<child
link="lh_pinky_tip" />
</joint>
<joint
name="lh_pinky_dip"
type="revolute">
<origin
xyz="0.00093749 0 0.028002"
rpy="0 0 0" />
<parent
link="lh_pinky_proximal" />
<child
link="lh_pinky_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.277039"
effort="0"
velocity="3.1" />
<mimic
joint="lh_pinky_mcp_pitch"
multiplier="1.125676"
offset="0" />
</joint>
<transmission name="trans_thumb_cmc_roll">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_thumb_cmc_roll">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_thumb_cmc_roll">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0084</mechanicalReduction>
</actuator>
</transmission>
<transmission name="trans_index_mcp_pitch">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_index_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_index_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0082</mechanicalReduction>
</actuator>
</transmission>
<transmission name="trans_middle_mcp_pitch">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_middle_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_middle_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0082</mechanicalReduction>
</actuator>
</transmission>
<transmission name="trans_ring_mcp_pitch">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_ring_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_ring_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0082</mechanicalReduction>
</actuator>
</transmission>
<transmission name="trans_pinky_mcp_pitch">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_pinky_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_pinky_mcp_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0082</mechanicalReduction>
</actuator>
</transmission>
<transmission name="trans_thumb_cmc_pitch">
<type>transmission_interface/SimpleTransmission</type>
<joint name="lh_thumb_cmc_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
</joint>
<actuator name="motor_thumb_cmc_pitch">
<hardwareInterface>hardware_interface/EffortJointInterface</hardwareInterface>
<mechanicalReduction>0.0144</mechanicalReduction>
</actuator>
</transmission>
<mujoco>
<compiler meshdir="meshes/" balanceinertia="true" discardvisual="false"/>
<default>
<joint armature="3.4707e-08" damping="5.0" frictionloss="0.5"/>
</default>
<equality>
<!-- 四指DIP关节的mimic约束 - 统一使用1.125676 -->
<!-- polycoef格式: c0 c1 c2 c3 c4 c5,关系: q1 = c0 + c1*q2 + c2*q2^2 + ... -->
<!-- 对于 mimic: q_dip = 0 + 1.125676*q_mcp + 0*q_mcp^2 + ... -->
<joint name="lh_couple_index" joint1="lh_index_dip" joint2="lh_index_mcp_pitch" polycoef="0 1.125676 0 0 0 0"/>
<joint name="lh_couple_middle" joint1="lh_middle_dip" joint2="lh_middle_mcp_pitch" polycoef="0 1.125676 0 0 0 0"/>
<joint name="lh_couple_ring" joint1="lh_ring_dip" joint2="lh_ring_mcp_pitch" polycoef="0 1.125676 0 0 0 0"/>
<joint name="lh_couple_pinky" joint1="lh_pinky_dip" joint2="lh_pinky_mcp_pitch" polycoef="0 1.125676 0 0 0 0"/>
<!-- 大拇指DIP关节的mimic约束 - 使用1.226495 -->
<joint name="lh_couple_thumb" joint1="lh_thumb_dip" joint2="lh_thumb_cmc_pitch" polycoef="0 1.226495 0 0 0 0"/>
</equality>
</mujoco>
<gazebo reference="lh_hand_base_link">
<mu1>1.5</mu1>
<mu2>1.5</mu2>
<selfCollide>true</selfCollide>
</gazebo>
<gazebo reference="lh_thumb_distal">
<mu1>1.5</mu1>
<mu2>1.5</mu2>
<selfCollide>true</selfCollide>
</gazebo>
<gazebo reference="lh_index_distal">
<mu1>1.1</mu1>
<mu2>1.1</mu2>
<selfCollide>true</selfCollide>
</gazebo>
<gazebo reference="lh_middle_distal">
<mu1>1.1</mu1>
<mu2>1.1</mu2>
<selfCollide>true</selfCollide>
</gazebo>
<gazebo reference="lh_ring_distal">
<mu1>1.1</mu1>
<mu2>1.1</mu2>
<selfCollide>true</selfCollide>
</gazebo>
<gazebo reference="lh_pinky_distal">
<mu1>1.1</mu1>
<mu2>1.1</mu2>
<selfCollide>true</selfCollide>
</gazebo>
</robot>
@@ -0,0 +1,666 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This URDF was automatically created by SolidWorks to URDF Exporter! Originally created by Stephen Brawner (brawner@gmail.com)
Commit Version: 1.6.0-4-g7f85cfe Build Version: 1.6.7995.38578
For more information, please see http://wiki.ros.org/sw_urdf_exporter -->
<robot
name="linkerhand_o6_left">
<material name="silver">
<color rgba="0.5 0.5 0.52 1.0"/>
</material>
<material name="BlackAccent">
<color rgba="0.15 0.15 0.15 1.0"/>
</material>
<link
name="lh_hand_base_link">
<inertial>
<origin
xyz="-0.00157788568070457 -0.00034876725935859 0.0538452219801211"
rpy="0 0 0" />
<mass
value="0.0672935238166485" />
<inertia
ixx="5.22525212160954E-05"
ixy="3.06955729584715E-07"
ixz="-3.90341166950521E-07"
iyy="3.33522349207724E-05"
iyz="-7.76680063574627E-07"
izz="2.28469461105385E-05" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/hand_base_link.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/hand_base_link.STL" />
</geometry>
</collision>
</link>
<link
name="lh_thumb_metacarpals_base2">
<inertial>
<origin
xyz="0.00162800681276937 -0.0027668017246415 -0.00184234438641236"
rpy="0 0 0" />
<mass
value="0.00138588296544333" />
<inertia
ixx="5.26292160685909E-08"
ixy="-3.92888681640568E-09"
ixz="-2.43767371295222E-09"
iyy="4.82620491570326E-08"
iyz="3.71869784206147E-09"
izz="2.88550286112821E-08" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals_base2.STL" />
</geometry>
<material name="BlackAccent"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals_base2.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_thumb_cmc_yaw"
type="revolute">
<origin
xyz="0.011508 -0.022975 0.032794"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_thumb_metacarpals_base2" />
<axis
xyz="0 0 1" />
<limit
lower="0"
upper="1.3"
effort="100"
velocity="1" />
</joint>
<link
name="lh_thumb_metacarpals">
<inertial>
<origin
xyz="4.24816204999817E-05 1.74183907812875E-05 0.0228341640631254"
rpy="0 0 0" />
<mass
value="0.0123381586619931" />
<inertia
ixx="1.10292537368909E-06"
ixy="-5.99642232777877E-10"
ixz="-1.14233573940545E-07"
iyy="1.76757772551884E-06"
iyz="-4.03963570823777E-10"
izz="9.36347792411098E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_metacarpals.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_thumb_cmc_pitch"
type="revolute">
<origin
xyz="0.0061649 -0.010678 -0.004891"
rpy="0 -1.1529 2.0944" />
<parent
link="lh_thumb_metacarpals_base2" />
<child
link="lh_thumb_metacarpals" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="0.58"
effort="100"
velocity="1" />
</joint>
<link
name="lh_thumb_distal">
<inertial>
<origin
xyz="-0.00413725702921841 -5.90704520436747E-08 0.023011401589736"
rpy="0 0 0" />
<mass
value="0.00763332957284057" />
<inertia
ixx="1.3057487813702E-06"
ixy="-1.19781508112714E-11"
ixz="-7.19551697268541E-08"
iyy="1.2248908905341E-06"
iyz="4.55347974652679E-12"
izz="2.77271910592615E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_distal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/thumb_distal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_thumb_ip"
type="revolute">
<origin
xyz="0.0037776 0 0.045368"
rpy="0 0 0" />
<parent
link="lh_thumb_metacarpals" />
<child
link="lh_thumb_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.08"
effort="100"
velocity="1" />
<mimic
joint="lh_thumb_cmc_pitch"
multiplier="2.29"
offset="0" />
</joint>
<link
name="lh_index_proximal">
<inertial>
<origin
xyz="-0.00208656556336452 2.53838713251686E-06 0.0172734941980264"
rpy="0 0 0" />
<mass
value="0.00253174651934627" />
<inertia
ixx="2.65988714157799E-07"
ixy="-1.67174578825824E-11"
ixz="-4.31047725546034E-08"
iyy="2.72914069166233E-07"
iyz="1.8756709532167E-10"
izz="8.56017305636748E-08" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_proximal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_index_mcp_pitch"
type="revolute">
<origin
xyz="0.0024758 -0.02419 0.098779"
rpy="0.05236 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_index_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.60"
effort="100"
velocity="1" />
</joint>
<link
name="lh_index_distal">
<inertial>
<origin
xyz="0.00658986616416549 5.13855642059191E-06 0.0194393600039178"
rpy="0 0 0" />
<mass
value="0.00376951085886838" />
<inertia
ixx="3.22347106085043E-07"
ixy="1.3801788663582E-11"
ixz="1.14352857317951E-07"
iyy="3.65328133721509E-07"
iyz="-2.11505011705634E-10"
izz="1.24287996386442E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_distal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/index_distal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_index_dip"
type="revolute">
<origin
xyz="-0.0052516 0 0.036625"
rpy="0 0 0" />
<parent
link="lh_index_proximal" />
<child
link="lh_index_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.43"
effort="100"
velocity="1" />
<mimic
joint="lh_index_mcp_pitch"
multiplier="0.89"
offset="0" />
</joint>
<link
name="lh_middle_proximal">
<inertial>
<origin
xyz="-0.00208660654138235 2.56918180119303E-06 0.0172735011723351"
rpy="0 0 0" />
<mass
value="0.00253177375206983" />
<inertia
ixx="2.65989021304483E-07"
ixy="-1.69816046635662E-11"
ixz="-4.31046683215273E-08"
iyy="2.72914402745606E-07"
iyz="1.87484652324395E-10"
izz="8.5602213431865E-08" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_proximal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_middle_mcp_pitch"
type="revolute">
<origin
xyz="0.00052576 -0.00634 0.1027"
rpy="0 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_middle_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.60"
effort="100"
velocity="1" />
</joint>
<link
name="lh_middle_distal">
<inertial>
<origin
xyz="0.00658986516319194 5.10862924271716E-06 0.0194393315699782"
rpy="0 0 0" />
<mass
value="0.00376952789210256" />
<inertia
ixx="3.22348591068053E-07"
ixy="1.37122394856588E-11"
ixz="1.1435291391668E-07"
iyy="3.65329535087755E-07"
iyz="-2.10453810291991E-10"
izz="1.24288425862546E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_distal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/middle_distal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_middle_dip"
type="revolute">
<origin
xyz="-0.0052516 0 0.036625"
rpy="0 0 0" />
<parent
link="lh_middle_proximal" />
<child
link="lh_middle_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.43"
effort="100"
velocity="1" />
<mimic
joint="lh_middle_mcp_pitch"
multiplier="0.89"
offset="0" />
</joint>
<link
name="lh_ring_proximal">
<inertial>
<origin
xyz="-0.00208667931345812 2.63000419462034E-06 0.0172734926448377"
rpy="0 0 0" />
<mass
value="0.00253182223645066" />
<inertia
ixx="2.65989756076584E-07"
ixy="-1.74912952703656E-11"
ixz="-4.31043325594253E-08"
iyy="2.7291510093393E-07"
iyz="1.87158326740895E-10"
izz="8.56031522765909E-08" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_proximal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_ring_mcp_pitch"
type="revolute">
<origin
xyz="0.0010258 0.011135 0.098767"
rpy="-0.05236 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_ring_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.60"
effort="100"
velocity="1" />
</joint>
<link
name="lh_ring_distal">
<inertial>
<origin
xyz="0.00658986648895491 5.05883528165163E-06 0.019439397215532"
rpy="0 0 0" />
<mass
value="0.00376949588316609" />
<inertia
ixx="3.2234524157181E-07"
ixy="1.35766976056011E-11"
ixz="1.14353011012842E-07"
iyy="3.65326570102147E-07"
iyz="-2.08672149138738E-10"
izz="1.24287770218135E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_distal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/ring_distal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_ring_dip"
type="revolute">
<origin
xyz="-0.0052516 0 0.036625"
rpy="0 0 0" />
<parent
link="lh_ring_proximal" />
<child
link="lh_ring_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.43"
effort="100"
velocity="1" />
<mimic
joint="lh_ring_mcp_pitch"
multiplier="0.89"
offset="0" />
</joint>
<link
name="lh_pinky_proximal">
<inertial>
<origin
xyz="-0.00208673599799501 2.68062251165824E-06 0.0172734848131236"
rpy="0 0 0" />
<mass
value="0.00253185993162408" />
<inertia
ixx="2.6599033687183E-07"
ixy="-1.79030790016555E-11"
ixz="-4.31040829565781E-08"
iyy="2.72915617381577E-07"
iyz="1.86906787420486E-10"
izz="8.56039175034871E-08" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_proximal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_proximal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_pinky_mcp_pitch"
type="revolute">
<origin
xyz="0.0024758 0.028372 0.092741"
rpy="-0.087266 0 0" />
<parent
link="lh_hand_base_link" />
<child
link="lh_pinky_proximal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.60"
effort="100"
velocity="1" />
</joint>
<link
name="lh_pinky_distal">
<inertial>
<origin
xyz="0.00658987588903027 5.03087534178825E-06 0.0194394491065091"
rpy="0 0 0" />
<mass
value="0.00376948071276134" />
<inertia
ixx="3.22343583730488E-07"
ixy="1.35768173270357E-11"
ixz="1.14353289593012E-07"
iyy="3.65325180884183E-07"
iyz="-2.0744198062439E-10"
izz="1.24287567374229E-07" />
</inertial>
<visual>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_distal.STL" />
</geometry>
<material name="silver"/>
</visual>
<collision>
<origin
xyz="0 0 0"
rpy="0 0 0" />
<geometry>
<mesh
filename="meshes/pinky_distal.STL" />
</geometry>
</collision>
</link>
<joint
name="lh_pinky_dip"
type="revolute">
<origin
xyz="-0.0052516 0 0.036625"
rpy="0 0 0" />
<parent
link="lh_pinky_proximal" />
<child
link="lh_pinky_distal" />
<axis
xyz="0 1 0" />
<limit
lower="0"
upper="1.43"
effort="100"
velocity="1" />
<mimic
joint="lh_pinky_mcp_pitch"
multiplier="0.89"
offset="0" />
</joint>
</robot>