"""Causal signal and calibration components for interaction estimation. These components are deliberately independent of the Pinocchio model. A locked experiment can therefore replay the exact same measured signals through the nominal, no-bias, and no-friction conditions without changing its dynamics or sample selection. """ from __future__ import annotations from dataclasses import dataclass from enum import Enum from typing import Optional import numpy as np from .wrench_solver import ( ScaledDLSSolver, WrenchSolveResult, WrenchSolver, ) def _finite_vector(value, name: str, size: Optional[int] = None) -> np.ndarray: vector = np.asarray(value, dtype=float).reshape(-1) if size is not None and vector.shape != (size,): raise ValueError(f"{name} must have shape ({size},), got {vector.shape}") if vector.size == 0 or not np.all(np.isfinite(vector)): raise ValueError(f"{name} must be a non-empty finite vector") return vector def _immutable_vector(value, name: str, size: Optional[int] = None) -> np.ndarray: vector = _finite_vector(value, name, size).copy() vector.setflags(write=False) return vector class AccelerationEstimateStatus(str, Enum): INITIALIZING = "initializing" VALID = "valid" INVALID_DT = "invalid_dt" @dataclass(frozen=True) class AccelerationEstimatorConfig: """Frozen one-pole differentiator configuration.""" cutoff_hz: float minimum_dt_s: float maximum_dt_s: float def __post_init__(self) -> None: values = (self.cutoff_hz, self.minimum_dt_s, self.maximum_dt_s) if any(not np.isfinite(value) or value <= 0.0 for value in values): raise ValueError("acceleration settings must be finite and positive") if self.minimum_dt_s >= self.maximum_dt_s: raise ValueError("minimum_dt_s must be smaller than maximum_dt_s") @property def nominal_filter_delay_s(self) -> float: """Continuous one-pole time constant, logged as a delay descriptor.""" return 1.0 / (2.0 * np.pi * self.cutoff_hz) @dataclass(frozen=True) class AccelerationEstimate: acceleration: np.ndarray status: AccelerationEstimateStatus dt_s: float raw_acceleration: np.ndarray def __post_init__(self) -> None: acceleration = _immutable_vector(self.acceleration, "acceleration") raw = _immutable_vector( self.raw_acceleration, "raw_acceleration", acceleration.size ) if not np.isfinite(self.dt_s): raise ValueError("dt_s must be finite") if ( self.status is not AccelerationEstimateStatus.INVALID_DT and self.dt_s < 0.0 ): raise ValueError("dt_s must be non-negative for a valid estimate") object.__setattr__(self, "acceleration", acceleration) object.__setattr__(self, "raw_acceleration", raw) class CausalAccelerationEstimator: """Backward difference followed by a causal one-pole low-pass filter.""" def __init__( self, joint_count: int, config: AccelerationEstimatorConfig, ) -> None: if int(joint_count) <= 0: raise ValueError("joint_count must be positive") self.joint_count = int(joint_count) self.config = config self.reset() def reset(self) -> None: self._previous_velocity: Optional[np.ndarray] = None self._previous_time_s: Optional[float] = None self._filtered = np.zeros(self.joint_count, dtype=float) def update( self, joint_velocity: np.ndarray, timestamp_s: float ) -> AccelerationEstimate: velocity = _finite_vector( joint_velocity, "joint_velocity", self.joint_count ) timestamp = float(timestamp_s) if not np.isfinite(timestamp): raise ValueError("timestamp_s must be finite") if self._previous_velocity is None: self._previous_velocity = velocity.copy() self._previous_time_s = timestamp zeros = np.zeros(self.joint_count, dtype=float) return AccelerationEstimate( acceleration=zeros, raw_acceleration=zeros, status=AccelerationEstimateStatus.INITIALIZING, dt_s=0.0, ) dt = timestamp - float(self._previous_time_s) if dt < self.config.minimum_dt_s or dt > self.config.maximum_dt_s: # Invalid timestamps are observable and do not contaminate state. return AccelerationEstimate( acceleration=self._filtered, raw_acceleration=np.zeros(self.joint_count), status=AccelerationEstimateStatus.INVALID_DT, dt_s=dt, ) raw = (velocity - self._previous_velocity) / dt alpha = 1.0 - np.exp(-2.0 * np.pi * self.config.cutoff_hz * dt) self._filtered = self._filtered + alpha * (raw - self._filtered) self._previous_velocity = velocity.copy() self._previous_time_s = timestamp return AccelerationEstimate( acceleration=self._filtered, raw_acceleration=raw, status=AccelerationEstimateStatus.VALID, dt_s=dt, ) @dataclass(frozen=True) class JointFrictionCalibration: """Per-joint smooth Coulomb plus viscous friction coefficients.""" coulomb_nm: np.ndarray viscous_nm_per_rad_s: np.ndarray smoothing_velocity_rad_s: float = 0.02 def __post_init__(self) -> None: coulomb = _immutable_vector(self.coulomb_nm, "coulomb_nm") viscous = _immutable_vector( self.viscous_nm_per_rad_s, "viscous_nm_per_rad_s", coulomb.size, ) if np.any(coulomb < 0.0) or np.any(viscous < 0.0): raise ValueError("friction magnitudes must be non-negative") smoothing = float(self.smoothing_velocity_rad_s) if not np.isfinite(smoothing) or smoothing <= 0.0: raise ValueError( "smoothing_velocity_rad_s must be finite and positive" ) object.__setattr__(self, "coulomb_nm", coulomb) object.__setattr__(self, "viscous_nm_per_rad_s", viscous) object.__setattr__(self, "smoothing_velocity_rad_s", smoothing) @property def joint_count(self) -> int: return int(self.coulomb_nm.size) def torque(self, joint_velocity: np.ndarray) -> np.ndarray: velocity = _finite_vector( joint_velocity, "joint_velocity", self.joint_count ) return ( self.coulomb_nm * np.tanh(velocity / self.smoothing_velocity_rad_s) + self.viscous_nm_per_rad_s * velocity ) @classmethod def zeros( cls, joint_count: int, *, smoothing_velocity_rad_s: float = 0.02, ) -> "JointFrictionCalibration": if int(joint_count) <= 0: raise ValueError("joint_count must be positive") zeros = np.zeros(int(joint_count), dtype=float) return cls(zeros, zeros, smoothing_velocity_rad_s) @classmethod def fit( cls, velocity_samples: np.ndarray, friction_torque_samples: np.ndarray, *, smoothing_velocity_rad_s: float = 0.02, ) -> "JointFrictionCalibration": """Fit independent smooth-Coulomb/viscous models by least squares.""" velocity = np.asarray(velocity_samples, dtype=float) torque = np.asarray(friction_torque_samples, dtype=float) if velocity.ndim != 2 or torque.shape != velocity.shape: raise ValueError( "velocity_samples and friction_torque_samples must share " "shape (n_samples, n_joints)" ) if velocity.shape[0] < 2 or velocity.shape[1] < 1: raise ValueError("at least two samples and one joint are required") if not np.all(np.isfinite(velocity)) or not np.all(np.isfinite(torque)): raise ValueError("friction calibration samples must be finite") smoothing = float(smoothing_velocity_rad_s) if not np.isfinite(smoothing) or smoothing <= 0.0: raise ValueError( "smoothing_velocity_rad_s must be finite and positive" ) coulomb = np.zeros(velocity.shape[1], dtype=float) viscous = np.zeros(velocity.shape[1], dtype=float) for joint in range(velocity.shape[1]): design = np.column_stack( ( np.tanh(velocity[:, joint] / smoothing), velocity[:, joint], ) ) coefficients, _, _, _ = np.linalg.lstsq( design, torque[:, joint], rcond=None ) # Negative coefficients are non-physical and signal insufficient # excitation; clipping makes that decision explicit and stable. coulomb[joint], viscous[joint] = np.maximum(coefficients, 0.0) return cls(coulomb, viscous, smoothing) @dataclass(frozen=True) class WrenchEstimatorCalibration: """Immutable parameters selected only from the calibration split.""" joint_bias_nm: np.ndarray friction: JointFrictionCalibration characteristic_length_m: float damping: float relative_rank_tolerance: float = 1e-9 calibration_id: str = "unversioned" def __post_init__(self) -> None: bias = _immutable_vector( self.joint_bias_nm, "joint_bias_nm", self.friction.joint_count ) positive = ( self.characteristic_length_m, self.damping, self.relative_rank_tolerance, ) if any(not np.isfinite(value) or value <= 0.0 for value in positive): raise ValueError("wrench calibration scalars must be positive") if self.relative_rank_tolerance >= 1.0: raise ValueError("relative_rank_tolerance must be smaller than one") if not isinstance(self.calibration_id, str) or not self.calibration_id: raise ValueError("calibration_id must be a non-empty string") object.__setattr__(self, "joint_bias_nm", bias) def build_dls_solver(self) -> ScaledDLSSolver: return ScaledDLSSolver( self.characteristic_length_m, self.damping, relative_rank_tolerance=self.relative_rank_tolerance, ) @dataclass(frozen=True) class ResidualAblation: """Explicit H2 switches; both default to the nominal estimator.""" use_bias_correction: bool = True use_friction_compensation: bool = True @classmethod def no_bias(cls) -> "ResidualAblation": return cls(use_bias_correction=False) @classmethod def no_friction(cls) -> "ResidualAblation": return cls(use_friction_compensation=False) @dataclass(frozen=True) class ResidualTorqueBreakdown: residual_nm: np.ndarray raw_residual_nm: np.ndarray applied_bias_nm: np.ndarray applied_friction_nm: np.ndarray ablation: ResidualAblation def __post_init__(self) -> None: residual = _immutable_vector(self.residual_nm, "residual_nm") size = residual.size object.__setattr__( self, "raw_residual_nm", _immutable_vector(self.raw_residual_nm, "raw_residual_nm", size), ) object.__setattr__( self, "applied_bias_nm", _immutable_vector(self.applied_bias_nm, "applied_bias_nm", size), ) object.__setattr__( self, "applied_friction_nm", _immutable_vector( self.applied_friction_nm, "applied_friction_nm", size ), ) object.__setattr__(self, "residual_nm", residual) class ResidualTorqueModel: """Apply frozen bias/friction calibration with replayable ablations.""" def __init__(self, calibration: WrenchEstimatorCalibration) -> None: self.calibration = calibration def compute( self, measured_torque_nm: np.ndarray, rigid_body_torque_nm: np.ndarray, joint_velocity_rad_s: np.ndarray, *, ablation: ResidualAblation = ResidualAblation(), ) -> ResidualTorqueBreakdown: size = self.calibration.friction.joint_count measured = _finite_vector( measured_torque_nm, "measured_torque_nm", size ) rigid = _finite_vector( rigid_body_torque_nm, "rigid_body_torque_nm", size ) velocity = _finite_vector( joint_velocity_rad_s, "joint_velocity_rad_s", size ) raw = measured - rigid bias = ( self.calibration.joint_bias_nm if ablation.use_bias_correction else np.zeros(size) ) friction = ( self.calibration.friction.torque(velocity) if ablation.use_friction_compensation else np.zeros(size) ) return ResidualTorqueBreakdown( residual_nm=raw - bias - friction, raw_residual_nm=raw, applied_bias_nm=bias, applied_friction_nm=friction, ablation=ablation, ) @dataclass(frozen=True) class CalibratedWrenchEstimate: residual: ResidualTorqueBreakdown solve: WrenchSolveResult class CalibratedResidualWrenchEstimator: """Compose residual conditioning and a selectable H2 wrench solver.""" def __init__( self, calibration: WrenchEstimatorCalibration, solver: Optional[WrenchSolver] = None, ) -> None: self.calibration = calibration self.residual_model = ResidualTorqueModel(calibration) self.solver: WrenchSolver = ( calibration.build_dls_solver() if solver is None else solver ) if not np.isclose( self.solver.characteristic_length_m, calibration.characteristic_length_m, ): raise ValueError( "solver and calibration must use the same characteristic length" ) if not np.isclose( self.solver.relative_rank_tolerance, calibration.relative_rank_tolerance, ): raise ValueError( "solver and calibration must use the same rank tolerance" ) def estimate( self, jacobian: np.ndarray, measured_torque_nm: np.ndarray, rigid_body_torque_nm: np.ndarray, joint_velocity_rad_s: np.ndarray, *, ablation: ResidualAblation = ResidualAblation(), ) -> CalibratedWrenchEstimate: residual = self.residual_model.compute( measured_torque_nm, rigid_body_torque_nm, joint_velocity_rad_s, ablation=ablation, ) solve = self.solver.solve(jacobian, residual.residual_nm) return CalibratedWrenchEstimate(residual=residual, solve=solve)