234 lines
7.2 KiB
Python
234 lines
7.2 KiB
Python
|
|
"""Canonical model contract for the heterogeneous teleoperation prototype.
|
||
|
|
|
||
|
|
The repository contains several historical URDF/config combinations. This
|
||
|
|
module defines the only combination used by the new simulation:
|
||
|
|
|
||
|
|
* master: ``config/master_7dof.urdf``
|
||
|
|
* slave: ``config/real_slave_7dof.urdf``
|
||
|
|
|
||
|
|
The physical slave URDF currently ends at the final wrist body and does not
|
||
|
|
contain a calibrated tool/force-sensor frame. For simulation only, an
|
||
|
|
operational frame named ``R_EE_SIM`` is added at a documented fixed offset.
|
||
|
|
Hardware experiments must replace that offset with the measured TCP transform.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Iterable, Sequence
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pinocchio as pin
|
||
|
|
|
||
|
|
|
||
|
|
CODE_ROOT = Path(__file__).resolve().parents[1]
|
||
|
|
CONFIG_ROOT = CODE_ROOT / "config"
|
||
|
|
MASTER_URDF = CONFIG_ROOT / "master_7dof.urdf"
|
||
|
|
SLAVE_URDF = CONFIG_ROOT / "real_slave_7dof.urdf"
|
||
|
|
|
||
|
|
MASTER_JOINT_NAMES = (
|
||
|
|
"master_shoulder_pitch_joint",
|
||
|
|
"master_shoulder_yaw_joint",
|
||
|
|
"master_shoulder_roll_joint",
|
||
|
|
"master_elbow_flex_joint",
|
||
|
|
"master_wrist_roll_joint",
|
||
|
|
"master_wrist_yaw_joint",
|
||
|
|
"master_wrist_pitch_joint",
|
||
|
|
)
|
||
|
|
|
||
|
|
SLAVE_JOINT_NAMES = (
|
||
|
|
"R_SHOULDER_P",
|
||
|
|
"R_SHOULDER_R",
|
||
|
|
"R_SHOULDER_Y",
|
||
|
|
"R_ELBOW_R",
|
||
|
|
"R_WRIST_P",
|
||
|
|
"R_WRIST_Y",
|
||
|
|
"R_WRIST_R",
|
||
|
|
)
|
||
|
|
|
||
|
|
MASTER_FRAMES = {
|
||
|
|
"base": "master_base",
|
||
|
|
"shoulder": "master_shoulder",
|
||
|
|
"elbow": "master_forearm",
|
||
|
|
"wrist": "master_wrist",
|
||
|
|
"ee": "master_ee",
|
||
|
|
}
|
||
|
|
|
||
|
|
SLAVE_FRAMES = {
|
||
|
|
"base": "PELVIS_S",
|
||
|
|
"shoulder": "R_SHOULDER_R_S",
|
||
|
|
"elbow": "R_ELBOW_R_S",
|
||
|
|
"wrist": "R_WRIST_R_S",
|
||
|
|
"ee": "R_EE_SIM",
|
||
|
|
}
|
||
|
|
|
||
|
|
# Approximate simulated TCP taken from the terminal marker in right_arm.mjcf.
|
||
|
|
# This is deliberately not presented as a physical calibration.
|
||
|
|
SLAVE_SIM_TCP_OFFSET = np.array([-0.01212, -0.17655, 0.07506], dtype=float)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass(frozen=True)
|
||
|
|
class TeleoperationModels:
|
||
|
|
master: pin.Model
|
||
|
|
slave: pin.Model
|
||
|
|
|
||
|
|
|
||
|
|
def require_frame(model: pin.Model, name: str) -> int:
|
||
|
|
"""Resolve a frame and reject Pinocchio's not-found sentinel."""
|
||
|
|
fid = int(model.getFrameId(name))
|
||
|
|
if fid < 0 or fid >= model.nframes:
|
||
|
|
raise ValueError(
|
||
|
|
f"Frame {name!r} not found in model {model.name!r}; "
|
||
|
|
f"available={[frame.name for frame in model.frames]}"
|
||
|
|
)
|
||
|
|
return fid
|
||
|
|
|
||
|
|
|
||
|
|
def require_joint(model: pin.Model, name: str) -> int:
|
||
|
|
"""Resolve an actuated joint and reject universe/not-found sentinels."""
|
||
|
|
jid = int(model.getJointId(name))
|
||
|
|
if jid <= 0 or jid >= model.njoints:
|
||
|
|
raise ValueError(
|
||
|
|
f"Joint {name!r} not found in model {model.name!r}; "
|
||
|
|
f"available={list(model.names)[1:]}"
|
||
|
|
)
|
||
|
|
return jid
|
||
|
|
|
||
|
|
|
||
|
|
def joint_q_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray:
|
||
|
|
indices = []
|
||
|
|
for name in names:
|
||
|
|
jid = require_joint(model, name)
|
||
|
|
joint = model.joints[jid]
|
||
|
|
if joint.nq != 1 or joint.nv != 1:
|
||
|
|
raise ValueError(f"Expected a scalar revolute joint, got {name!r}")
|
||
|
|
indices.append(int(joint.idx_q))
|
||
|
|
return np.asarray(indices, dtype=int)
|
||
|
|
|
||
|
|
|
||
|
|
def joint_v_indices(model: pin.Model, names: Sequence[str]) -> np.ndarray:
|
||
|
|
indices = []
|
||
|
|
for name in names:
|
||
|
|
jid = require_joint(model, name)
|
||
|
|
joint = model.joints[jid]
|
||
|
|
if joint.nq != 1 or joint.nv != 1:
|
||
|
|
raise ValueError(f"Expected a scalar revolute joint, got {name!r}")
|
||
|
|
indices.append(int(joint.idx_v))
|
||
|
|
return np.asarray(indices, dtype=int)
|
||
|
|
|
||
|
|
|
||
|
|
def validate_contract(
|
||
|
|
model: pin.Model,
|
||
|
|
joint_names: Sequence[str],
|
||
|
|
frame_names: Iterable[str],
|
||
|
|
) -> None:
|
||
|
|
if model.nq != 7 or model.nv != 7:
|
||
|
|
raise ValueError(
|
||
|
|
f"Expected a fixed-base 7-DoF model, got nq={model.nq}, nv={model.nv}"
|
||
|
|
)
|
||
|
|
joint_q_indices(model, joint_names)
|
||
|
|
for frame_name in frame_names:
|
||
|
|
require_frame(model, frame_name)
|
||
|
|
|
||
|
|
|
||
|
|
def add_fixed_operational_frame(
|
||
|
|
model: pin.Model,
|
||
|
|
*,
|
||
|
|
name: str,
|
||
|
|
parent_frame_name: str,
|
||
|
|
translation: np.ndarray,
|
||
|
|
rotation: np.ndarray | None = None,
|
||
|
|
) -> int:
|
||
|
|
"""Add an operational frame at a transform relative to an existing frame."""
|
||
|
|
existing = int(model.getFrameId(name))
|
||
|
|
if 0 <= existing < model.nframes:
|
||
|
|
return existing
|
||
|
|
|
||
|
|
parent_fid = require_frame(model, parent_frame_name)
|
||
|
|
parent = model.frames[parent_fid]
|
||
|
|
relative = pin.SE3(
|
||
|
|
np.eye(3) if rotation is None else np.asarray(rotation, dtype=float),
|
||
|
|
np.asarray(translation, dtype=float).reshape(3),
|
||
|
|
)
|
||
|
|
placement_in_parent_joint = parent.placement * relative
|
||
|
|
return int(
|
||
|
|
model.addFrame(
|
||
|
|
pin.Frame(
|
||
|
|
name,
|
||
|
|
parent.parentJoint,
|
||
|
|
parent_fid,
|
||
|
|
placement_in_parent_joint,
|
||
|
|
pin.FrameType.OP_FRAME,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def load_models(*, add_simulated_tcp: bool = True) -> TeleoperationModels:
|
||
|
|
"""Load and validate the canonical master/slave dynamics models."""
|
||
|
|
master = pin.buildModelFromUrdf(str(MASTER_URDF))
|
||
|
|
slave = pin.buildModelFromUrdf(str(SLAVE_URDF))
|
||
|
|
|
||
|
|
if add_simulated_tcp:
|
||
|
|
add_fixed_operational_frame(
|
||
|
|
slave,
|
||
|
|
name=SLAVE_FRAMES["ee"],
|
||
|
|
parent_frame_name=SLAVE_FRAMES["wrist"],
|
||
|
|
translation=SLAVE_SIM_TCP_OFFSET,
|
||
|
|
)
|
||
|
|
|
||
|
|
validate_contract(master, MASTER_JOINT_NAMES, MASTER_FRAMES.values())
|
||
|
|
validate_contract(slave, SLAVE_JOINT_NAMES, SLAVE_FRAMES.values())
|
||
|
|
return TeleoperationModels(master=master, slave=slave)
|
||
|
|
|
||
|
|
|
||
|
|
def finite_joint_limits(
|
||
|
|
model: pin.Model,
|
||
|
|
joint_names: Sequence[str],
|
||
|
|
) -> tuple[np.ndarray, np.ndarray]:
|
||
|
|
qidx = joint_q_indices(model, joint_names)
|
||
|
|
lower = np.asarray(model.lowerPositionLimit[qidx], dtype=float)
|
||
|
|
upper = np.asarray(model.upperPositionLimit[qidx], dtype=float)
|
||
|
|
if not np.all(np.isfinite(lower)) or not np.all(np.isfinite(upper)):
|
||
|
|
raise ValueError("All teleoperation joints must have finite position limits")
|
||
|
|
if np.any(lower >= upper):
|
||
|
|
raise ValueError("Invalid joint limits in teleoperation model")
|
||
|
|
return lower, upper
|
||
|
|
|
||
|
|
|
||
|
|
def safe_configuration(
|
||
|
|
model: pin.Model,
|
||
|
|
joint_names: Sequence[str],
|
||
|
|
*,
|
||
|
|
fraction: float = 0.5,
|
||
|
|
margin: float = 1e-3,
|
||
|
|
) -> np.ndarray:
|
||
|
|
"""Return a configuration strictly inside all declared joint limits."""
|
||
|
|
if not 0.0 <= fraction <= 1.0:
|
||
|
|
raise ValueError("fraction must lie in [0, 1]")
|
||
|
|
lower, upper = finite_joint_limits(model, joint_names)
|
||
|
|
span = upper - lower
|
||
|
|
q7 = lower + fraction * span
|
||
|
|
q7 = np.minimum(np.maximum(q7, lower + margin), upper - margin)
|
||
|
|
q = pin.neutral(model)
|
||
|
|
q[joint_q_indices(model, joint_names)] = q7
|
||
|
|
return q
|
||
|
|
|
||
|
|
|
||
|
|
def clip_configuration(
|
||
|
|
model: pin.Model,
|
||
|
|
q: np.ndarray,
|
||
|
|
joint_names: Sequence[str],
|
||
|
|
*,
|
||
|
|
margin: float = 1e-6,
|
||
|
|
) -> tuple[np.ndarray, bool]:
|
||
|
|
"""Clip scalar teleoperation joints and report whether clipping occurred."""
|
||
|
|
q_clipped = np.asarray(q, dtype=float).copy()
|
||
|
|
qidx = joint_q_indices(model, joint_names)
|
||
|
|
lower, upper = finite_joint_limits(model, joint_names)
|
||
|
|
clipped7 = np.minimum(np.maximum(q_clipped[qidx], lower + margin), upper - margin)
|
||
|
|
changed = bool(np.any(np.abs(clipped7 - q_clipped[qidx]) > 1e-12))
|
||
|
|
q_clipped[qidx] = clipped7
|
||
|
|
return q_clipped, changed
|