300 lines
11 KiB
Python
300 lines
11 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
import numpy as np
|
||
|
|
import pinocchio as pin
|
||
|
|
|
||
|
|
from .wrench_solver import WrenchSolveResult, WrenchSolver
|
||
|
|
|
||
|
|
|
||
|
|
def checked_frame_id(model: pin.Model, frame_name: str) -> int:
|
||
|
|
"""Resolve a Pinocchio frame name and reject its not-found sentinel."""
|
||
|
|
if not isinstance(frame_name, str) or not frame_name:
|
||
|
|
raise ValueError("frame_name must be a non-empty string")
|
||
|
|
|
||
|
|
frame_id = int(model.getFrameId(frame_name))
|
||
|
|
if frame_id < 0 or frame_id >= model.nframes:
|
||
|
|
available = ", ".join(frame.name for frame in model.frames)
|
||
|
|
raise ValueError(
|
||
|
|
f"Frame not found: {frame_name!r}. "
|
||
|
|
f"Expected an id in [0, {model.nframes}), got {frame_id}. "
|
||
|
|
f"Available frames: {available}"
|
||
|
|
)
|
||
|
|
return frame_id
|
||
|
|
|
||
|
|
|
||
|
|
def checked_joint_id(model: pin.Model, joint_name: str) -> int:
|
||
|
|
"""Resolve a movable joint name and reject universe/not-found sentinels."""
|
||
|
|
if not isinstance(joint_name, str) or not joint_name:
|
||
|
|
raise ValueError("joint_name must be a non-empty string")
|
||
|
|
|
||
|
|
joint_id = int(model.getJointId(joint_name))
|
||
|
|
if joint_id <= 0 or joint_id >= model.njoints:
|
||
|
|
available = ", ".join(model.names[1:])
|
||
|
|
raise ValueError(
|
||
|
|
f"Movable joint not found: {joint_name!r}. "
|
||
|
|
f"Expected an id in [1, {model.njoints}), got {joint_id}. "
|
||
|
|
f"Available movable joints: {available}"
|
||
|
|
)
|
||
|
|
return joint_id
|
||
|
|
|
||
|
|
|
||
|
|
def _as_vector(value, size: int, name: str) -> np.ndarray:
|
||
|
|
vector = np.asarray(value, dtype=float).reshape(-1)
|
||
|
|
if vector.shape != (size,):
|
||
|
|
raise ValueError(f"{name} must have shape ({size},), got {vector.shape}")
|
||
|
|
if not np.all(np.isfinite(vector)):
|
||
|
|
raise ValueError(f"{name} must contain only finite values")
|
||
|
|
return vector
|
||
|
|
|
||
|
|
|
||
|
|
def _as_sample_matrix(value, width: int, name: str) -> np.ndarray:
|
||
|
|
samples = np.asarray(value, dtype=float)
|
||
|
|
if samples.ndim == 1:
|
||
|
|
samples = samples.reshape(1, -1)
|
||
|
|
if samples.ndim != 2 or samples.shape[1] != width:
|
||
|
|
raise ValueError(
|
||
|
|
f"{name} must have shape (n_samples, {width}), got {samples.shape}"
|
||
|
|
)
|
||
|
|
if samples.shape[0] == 0:
|
||
|
|
raise ValueError(f"{name} must contain at least one sample")
|
||
|
|
if not np.all(np.isfinite(samples)):
|
||
|
|
raise ValueError(f"{name} must contain only finite values")
|
||
|
|
return samples
|
||
|
|
|
||
|
|
|
||
|
|
class InteractionEstimator:
|
||
|
|
"""
|
||
|
|
Estimate an external wrench at the end-effector point.
|
||
|
|
|
||
|
|
Conventions are explicit throughout this class:
|
||
|
|
|
||
|
|
* twists are ``[linear_velocity; angular_velocity]``;
|
||
|
|
* wrenches are ``[force; moment]``;
|
||
|
|
* both are expressed along the chest-frame axes, but at the EE point;
|
||
|
|
* ``tau_int = tau_meas - tau_model - tau_bias``.
|
||
|
|
|
||
|
|
``estimate`` keeps the original three-value return contract. Its first
|
||
|
|
result is the bias-corrected residual. The raw and corrected residuals are
|
||
|
|
also available through ``last_tau_residual_raw`` and
|
||
|
|
``last_tau_residual_corrected``.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
model: pin.Model,
|
||
|
|
chest_frame_name: str,
|
||
|
|
ee_frame_name: str,
|
||
|
|
lambda_damp: float = 1e-3,
|
||
|
|
wrench_solver: Optional[WrenchSolver] = None,
|
||
|
|
):
|
||
|
|
damping = float(lambda_damp)
|
||
|
|
if not np.isfinite(damping) or damping <= 0.0:
|
||
|
|
raise ValueError("lambda_damp must be a finite positive scalar")
|
||
|
|
|
||
|
|
self.model = model
|
||
|
|
self.data = model.createData()
|
||
|
|
self.lambda_damp = damping
|
||
|
|
self.wrench_solver = wrench_solver
|
||
|
|
self.fid_C = checked_frame_id(model, chest_frame_name)
|
||
|
|
self.fid_EE = checked_frame_id(model, ee_frame_name)
|
||
|
|
|
||
|
|
self._tau_bias = np.zeros(model.nv, dtype=float)
|
||
|
|
self._last_tau_residual_raw: Optional[np.ndarray] = None
|
||
|
|
self._last_tau_residual_corrected: Optional[np.ndarray] = None
|
||
|
|
self._last_wrench_solve: Optional[WrenchSolveResult] = None
|
||
|
|
|
||
|
|
@staticmethod
|
||
|
|
def _rotation6(rotation: np.ndarray) -> np.ndarray:
|
||
|
|
"""Apply one 3-D rotation to both linear and angular blocks."""
|
||
|
|
rotation = np.asarray(rotation, dtype=float)
|
||
|
|
if rotation.shape != (3, 3):
|
||
|
|
raise ValueError(f"rotation must have shape (3, 3), got {rotation.shape}")
|
||
|
|
rotation6 = np.zeros((6, 6), dtype=float)
|
||
|
|
rotation6[:3, :3] = rotation
|
||
|
|
rotation6[3:, 3:] = rotation
|
||
|
|
return rotation6
|
||
|
|
|
||
|
|
@property
|
||
|
|
def tau_bias(self) -> np.ndarray:
|
||
|
|
"""Current no-contact joint-torque bias (copy)."""
|
||
|
|
return self._tau_bias.copy()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def last_tau_residual_raw(self) -> Optional[np.ndarray]:
|
||
|
|
"""Latest ``tau_meas - tau_model`` sample, before bias removal."""
|
||
|
|
if self._last_tau_residual_raw is None:
|
||
|
|
return None
|
||
|
|
return self._last_tau_residual_raw.copy()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def last_tau_residual_corrected(self) -> Optional[np.ndarray]:
|
||
|
|
"""Latest raw residual minus the calibrated bias."""
|
||
|
|
if self._last_tau_residual_corrected is None:
|
||
|
|
return None
|
||
|
|
return self._last_tau_residual_corrected.copy()
|
||
|
|
|
||
|
|
@property
|
||
|
|
def last_wrench_solve(self) -> Optional[WrenchSolveResult]:
|
||
|
|
"""Latest formal scaled-solver result, if one was configured."""
|
||
|
|
return self._last_wrench_solve
|
||
|
|
|
||
|
|
def calibrate_bias(self, tau_residual_raw_samples) -> np.ndarray:
|
||
|
|
"""
|
||
|
|
Calibrate a constant joint-torque bias from offline no-contact samples.
|
||
|
|
|
||
|
|
Parameters
|
||
|
|
----------
|
||
|
|
tau_residual_raw_samples:
|
||
|
|
One sample with shape ``(nv,)`` or a batch with shape
|
||
|
|
``(n_samples, nv)``. Every row must already be the raw residual
|
||
|
|
``tau_meas - tau_model`` collected under a no-contact condition.
|
||
|
|
|
||
|
|
Returns
|
||
|
|
-------
|
||
|
|
numpy.ndarray
|
||
|
|
A copy of the calibrated mean bias.
|
||
|
|
"""
|
||
|
|
samples = _as_sample_matrix(
|
||
|
|
tau_residual_raw_samples, self.model.nv, "tau_residual_raw_samples"
|
||
|
|
)
|
||
|
|
self._tau_bias = np.mean(samples, axis=0)
|
||
|
|
return self.tau_bias
|
||
|
|
|
||
|
|
def calibrate_bias_from_measurements(
|
||
|
|
self,
|
||
|
|
q_samples,
|
||
|
|
qd_samples,
|
||
|
|
qdd_samples,
|
||
|
|
tau_meas_samples,
|
||
|
|
tau_ff_fric_samples=None,
|
||
|
|
) -> np.ndarray:
|
||
|
|
"""
|
||
|
|
Compute and calibrate bias from an offline no-contact measurement batch.
|
||
|
|
|
||
|
|
Each argument is a row-major sample matrix. ``tau_ff_fric_samples`` is
|
||
|
|
optional and, when supplied, must have the same ``(n_samples, nv)``
|
||
|
|
shape as the velocity/torque batches.
|
||
|
|
"""
|
||
|
|
q_batch = _as_sample_matrix(q_samples, self.model.nq, "q_samples")
|
||
|
|
qd_batch = _as_sample_matrix(qd_samples, self.model.nv, "qd_samples")
|
||
|
|
qdd_batch = _as_sample_matrix(qdd_samples, self.model.nv, "qdd_samples")
|
||
|
|
tau_batch = _as_sample_matrix(
|
||
|
|
tau_meas_samples, self.model.nv, "tau_meas_samples"
|
||
|
|
)
|
||
|
|
|
||
|
|
sample_count = q_batch.shape[0]
|
||
|
|
batches = (qd_batch, qdd_batch, tau_batch)
|
||
|
|
if any(batch.shape[0] != sample_count for batch in batches):
|
||
|
|
raise ValueError("all calibration batches must have the same sample count")
|
||
|
|
|
||
|
|
friction_batch = None
|
||
|
|
if tau_ff_fric_samples is not None:
|
||
|
|
friction_batch = _as_sample_matrix(
|
||
|
|
tau_ff_fric_samples, self.model.nv, "tau_ff_fric_samples"
|
||
|
|
)
|
||
|
|
if friction_batch.shape[0] != sample_count:
|
||
|
|
raise ValueError(
|
||
|
|
"tau_ff_fric_samples must match the calibration sample count"
|
||
|
|
)
|
||
|
|
|
||
|
|
residuals = np.empty((sample_count, self.model.nv), dtype=float)
|
||
|
|
for sample_index in range(sample_count):
|
||
|
|
friction = (
|
||
|
|
None if friction_batch is None else friction_batch[sample_index]
|
||
|
|
)
|
||
|
|
tau_model = self._tau_model(
|
||
|
|
q_batch[sample_index],
|
||
|
|
qd_batch[sample_index],
|
||
|
|
qdd_batch[sample_index],
|
||
|
|
friction,
|
||
|
|
)
|
||
|
|
residuals[sample_index] = tau_batch[sample_index] - tau_model
|
||
|
|
return self.calibrate_bias(residuals)
|
||
|
|
|
||
|
|
def clear_bias(self) -> None:
|
||
|
|
"""Clear the calibrated joint-torque bias."""
|
||
|
|
self._tau_bias.fill(0.0)
|
||
|
|
|
||
|
|
def _chest_jacobian(self, q, qd):
|
||
|
|
"""
|
||
|
|
Return the EE-point Jacobian expressed along chest-frame axes.
|
||
|
|
|
||
|
|
``LOCAL_WORLD_ALIGNED`` is essential here: unlike ``WORLD``, its
|
||
|
|
translational rows are the linear velocity of the EE origin. Rotating
|
||
|
|
the two 3-D blocks changes only their coordinate axes; no translational
|
||
|
|
adjoint term is used, so the wrench/twist reference point stays at EE.
|
||
|
|
"""
|
||
|
|
q = _as_vector(q, self.model.nq, "q")
|
||
|
|
qd = _as_vector(qd, self.model.nv, "qd")
|
||
|
|
|
||
|
|
pin.forwardKinematics(self.model, self.data, q, qd)
|
||
|
|
pin.updateFramePlacements(self.model, self.data)
|
||
|
|
|
||
|
|
jacobian_lwa = pin.computeFrameJacobian(
|
||
|
|
self.model,
|
||
|
|
self.data,
|
||
|
|
q,
|
||
|
|
self.fid_EE,
|
||
|
|
pin.ReferenceFrame.LOCAL_WORLD_ALIGNED,
|
||
|
|
)
|
||
|
|
|
||
|
|
rotation_world_from_chest = self.data.oMf[self.fid_C].rotation
|
||
|
|
rotation_chest_from_world = rotation_world_from_chest.T
|
||
|
|
return self._rotation6(rotation_chest_from_world) @ jacobian_lwa
|
||
|
|
|
||
|
|
def _tau_model(self, q, qd, qdd, tau_ff_fric=None):
|
||
|
|
"""Return ``M qdd + C qd + g + tau_ff_fric``."""
|
||
|
|
q = _as_vector(q, self.model.nq, "q")
|
||
|
|
qd = _as_vector(qd, self.model.nv, "qd")
|
||
|
|
qdd = _as_vector(qdd, self.model.nv, "qdd")
|
||
|
|
|
||
|
|
mass_matrix = pin.crba(self.model, self.data, q)
|
||
|
|
mass_matrix = (
|
||
|
|
mass_matrix + mass_matrix.T - np.diag(mass_matrix.diagonal())
|
||
|
|
)
|
||
|
|
nonlinear = pin.nonLinearEffects(self.model, self.data, q, qd)
|
||
|
|
tau_model = mass_matrix @ qdd + nonlinear
|
||
|
|
|
||
|
|
if tau_ff_fric is not None:
|
||
|
|
tau_model = tau_model + _as_vector(
|
||
|
|
tau_ff_fric, self.model.nv, "tau_ff_fric"
|
||
|
|
)
|
||
|
|
return tau_model
|
||
|
|
|
||
|
|
def estimate(self, q, qd, qdd, tau_meas, tau_ff_fric=None):
|
||
|
|
"""
|
||
|
|
Estimate the bias-corrected joint residual and chest-axis EE wrench.
|
||
|
|
|
||
|
|
A fixed damped least-squares solve is used:
|
||
|
|
|
||
|
|
``F = (J J.T + lambda**2 I)^-1 J tau_int``.
|
||
|
|
"""
|
||
|
|
tau_meas = _as_vector(tau_meas, self.model.nv, "tau_meas")
|
||
|
|
tau_model = self._tau_model(q, qd, qdd, tau_ff_fric)
|
||
|
|
|
||
|
|
tau_residual_raw = tau_meas - tau_model
|
||
|
|
tau_residual_corrected = tau_residual_raw - self._tau_bias
|
||
|
|
self._last_tau_residual_raw = tau_residual_raw.copy()
|
||
|
|
self._last_tau_residual_corrected = tau_residual_corrected.copy()
|
||
|
|
|
||
|
|
chest_jacobian = self._chest_jacobian(q, qd)
|
||
|
|
if self.wrench_solver is None:
|
||
|
|
normal_matrix = chest_jacobian @ chest_jacobian.T
|
||
|
|
normal_matrix = normal_matrix + (
|
||
|
|
self.lambda_damp**2
|
||
|
|
) * np.eye(6, dtype=float)
|
||
|
|
wrench_chest = np.linalg.solve(
|
||
|
|
normal_matrix, chest_jacobian @ tau_residual_corrected
|
||
|
|
)
|
||
|
|
self._last_wrench_solve = None
|
||
|
|
else:
|
||
|
|
solve = self.wrench_solver.solve(
|
||
|
|
chest_jacobian, tau_residual_corrected
|
||
|
|
)
|
||
|
|
wrench_chest = solve.wrench.copy()
|
||
|
|
self._last_wrench_solve = solve
|
||
|
|
|
||
|
|
return tau_residual_corrected, wrench_chest, chest_jacobian
|