exoskeleton/code/core/estimation_signals.py

426 lines
15 KiB
Python
Raw Normal View History

"""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)