exoskeleton/code/core/haptic_render.py

659 lines
26 KiB
Python
Raw Permalink Normal View History

# haptic_render.py
# -*- coding: utf-8 -*-
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
try:
import pinocchio as pin
except ModuleNotFoundError: # Pure supervisor tests do not require Pinocchio.
pin = None
# ===== Final applied-port energy supervision =====
@dataclass
class TankParams:
E_min: float = 0.5
E_max: float = 20.0
alpha_floor: float = 0.0
alpha_ceil: float = 1.0
power_epsilon: float = 1e-12
# Legacy fields are kept so existing configuration/ablation code can load.
# They are deliberately not used for energy accounting: filtering, a power
# dead-zone, or alpha smoothing at this stage would make the tank account a
# different signal from the torque actually applied at the master port.
force_alpha: float = 0.2
vel_alpha: float = 0.2
alpha_smooth: float = 0.1
power_deadzone: float = 0.0
@dataclass(frozen=True)
class AppliedPortDiagnostics:
"""Snapshot of one final-port projection/accounting step."""
tau_candidate: np.ndarray
tau_applied: np.ndarray
rho: float
E_before: float
E_preclip: float
E_after: float
candidate_power: float
power: float
fail_safe_active: bool
class AppliedPortEnergySupervisor:
"""Passivity supervisor at the final master haptic-torque port.
Positive ``tau_app @ qd_m`` is power delivered by the device to the
operator and therefore discharges the tank. Negative power charges the
tank. The candidate torque passed to :meth:`apply` must
already include all nominal scaling, filtering, rate limiting, and actuator
saturation. The returned torque is final: downstream code must not filter,
rescale, rate-limit, or saturate it.
The projection is radial, ``tau_app = alpha * tau_candidate``. This keeps a
previously saturated candidate inside its actuator limits while imposing
the exact one-step energy budget. ``alpha_floor`` is never allowed to
override the energy-safe upper bound.
"""
def __init__(self, tank: TankParams | None = None, E0: float = 2.0):
self.tp = TankParams() if tank is None else tank
if not np.isfinite(self.tp.E_min) or not np.isfinite(self.tp.E_max):
raise ValueError("E_min and E_max must be finite")
if self.tp.E_max < self.tp.E_min:
raise ValueError("E_max must be greater than or equal to E_min")
if not 0.0 <= self.tp.alpha_ceil <= 1.0:
raise ValueError("alpha_ceil must lie in [0, 1]")
if not 0.0 <= self.tp.alpha_floor <= self.tp.alpha_ceil:
raise ValueError("alpha_floor must lie in [0, alpha_ceil]")
if self.tp.power_epsilon < 0.0:
raise ValueError("power_epsilon must be non-negative")
self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max))
self.alpha = 1.0
self.last_alpha = 1.0
self.last_power = 0.0
self.last_energy_before = self.E
self.last_energy_after = self.E
self.last_tau_candidate = np.empty(0, dtype=float)
self.last_tau_applied = np.empty(0, dtype=float)
self.fail_safe_active = False
self.last_diagnostics: AppliedPortDiagnostics | None = None
def reset(self, E0: float | None = None):
if E0 is None:
E0 = self.tp.E_min
self.E = float(np.clip(E0, self.tp.E_min, self.tp.E_max))
self.alpha = 1.0
self.last_alpha = 1.0
self.last_power = 0.0
self.last_energy_before = self.E
self.last_energy_after = self.E
self.last_tau_candidate = np.empty(0, dtype=float)
self.last_tau_applied = np.empty(0, dtype=float)
self.fail_safe_active = False
self.last_diagnostics = None
def _record(self,
tau_candidate: np.ndarray,
tau_applied: np.ndarray,
rho: float,
E_before: float,
E_preclip: float,
E_after: float,
candidate_power: float,
power: float,
fail_safe_active: bool) -> AppliedPortDiagnostics:
"""Record one result while retaining legacy scalar/state attributes."""
diagnostics = AppliedPortDiagnostics(
tau_candidate=tau_candidate.copy(),
tau_applied=tau_applied.copy(),
rho=float(rho),
E_before=float(E_before),
E_preclip=float(E_preclip),
E_after=float(E_after),
candidate_power=float(candidate_power),
power=float(power),
fail_safe_active=bool(fail_safe_active),
)
self.alpha = diagnostics.rho
self.last_alpha = diagnostics.rho
self.last_power = diagnostics.power
self.last_energy_before = diagnostics.E_before
self.last_energy_after = diagnostics.E_after
self.last_tau_candidate = diagnostics.tau_candidate.copy()
self.last_tau_applied = diagnostics.tau_applied.copy()
self.fail_safe_active = diagnostics.fail_safe_active
self.last_diagnostics = diagnostics
return diagnostics
def _zero_fail_safe(
self,
tau_candidate: np.ndarray,
) -> tuple[np.ndarray, AppliedPortDiagnostics]:
tau_zero = np.zeros_like(tau_candidate, dtype=float)
diagnostics = self._record(
tau_candidate=tau_candidate,
tau_applied=tau_zero,
rho=0.0,
E_before=self.E,
E_preclip=self.E,
E_after=self.E,
candidate_power=0.0,
power=0.0,
fail_safe_active=True,
)
return tau_zero, diagnostics
def apply(self,
tau_raw: np.ndarray,
qd: np.ndarray,
dt: float) -> tuple[np.ndarray, AppliedPortDiagnostics]:
"""Return the final safe torque and diagnostics, accounting exactly once.
``tau_raw`` is the already shaped and actuator-limited candidate at the
master joint port. It may come directly from a mapping such as
``A.T @ tau_s`` or ``J_m.T @ F``. ``qd`` is the velocity at that same
port. The returned ``tau_app`` must be sent unchanged to the haptic
output (apart from an emergency zero-output fail-safe).
"""
tau_candidate = np.asarray(tau_raw, dtype=float).reshape(-1,)
qd_m = np.asarray(qd, dtype=float).reshape(-1,)
if tau_candidate.shape != qd_m.shape:
raise ValueError(
"tau_raw and qd must have identical one-dimensional shapes"
)
if (
tau_candidate.size == 0
or not np.isfinite(dt)
or dt <= 0.0
or not np.all(np.isfinite(tau_candidate))
or not np.all(np.isfinite(qd_m))
):
return self._zero_fail_safe(tau_candidate)
energy_before = self.E
nominal_power = float(np.dot(tau_candidate, qd_m))
if not np.isfinite(nominal_power):
return self._zero_fail_safe(tau_candidate)
rho = self.tp.alpha_ceil
# Only device-to-human power consumes stored energy. alpha_floor is a
# preference, not a safety constraint, and cannot force an unsafe gain.
if nominal_power > 0.0:
available = max(0.0, energy_before - self.tp.E_min)
rho_energy = available / (nominal_power * dt)
rho = min(rho, max(0.0, rho_energy))
tau_app = rho * tau_candidate
applied_power = float(np.dot(tau_app, qd_m))
# This is the sole tank update. It uses the exact torque returned to
# the caller and the exact master velocity supplied for this sample.
energy_preclip = energy_before - applied_power * dt
self.E = float(np.clip(energy_preclip, self.tp.E_min, self.tp.E_max))
diagnostics = self._record(
tau_candidate=tau_candidate,
tau_applied=tau_app,
rho=rho,
E_before=energy_before,
E_preclip=energy_preclip,
E_after=self.E,
candidate_power=nominal_power,
power=applied_power,
fail_safe_active=False,
)
return tau_app, diagnostics
def project_and_account(self,
tau_candidate: np.ndarray,
qd_m: np.ndarray,
dt: float) -> tuple[float, np.ndarray]:
"""Compatibility wrapper around :meth:`apply`.
Returns ``(alpha, tau_app)``. The wrapper invokes ``apply`` exactly
once, so it cannot introduce a second tank update.
"""
tau_app, diagnostics = self.apply(tau_candidate, qd_m, dt)
return diagnostics.rho, tau_app
def observe_master(self, *_args, **_kwargs):
"""Reject the former second accounting path."""
raise RuntimeError(
"observe_master() is disabled: use apply() exactly once "
"at the final applied master-torque port"
)
def enforce(self, *_args, **_kwargs):
"""Reject wrench/twist-port enforcement from the former implementation."""
raise RuntimeError(
"enforce(CF, VC, dt) is disabled: passivity is enforced on final "
"master tau_app and qd_m via apply()"
)
# Backward-compatible class name for existing imports.
EnergyTankPOPC = AppliedPortEnergySupervisor
# ===== 主端胸腔雅可比 =====
class ChestJacobianMaster:
def __init__(self, model: pin.Model,
chest_frame_name: str,
ee_frame_name: str):
if pin is None:
raise ModuleNotFoundError(
"Pinocchio is required to construct ChestJacobianMaster"
)
self.model = model
self.data = model.createData()
self.fid_C = model.getFrameId(chest_frame_name)
self.fid_EE = model.getFrameId(ee_frame_name)
def chest_jacobian(self, q_m, qd_m):
pin.forwardKinematics(self.model, self.data, q_m, qd_m)
pin.updateFramePlacements(self.model, self.data)
# LOCAL_WORLD_ALIGNED expresses the twist at the EE point in axes
# parallel to the world frame. The spatial-vector convention used by
# this project is [linear; angular].
Jw = pin.computeFrameJacobian(self.model, self.data,
q_m, self.fid_EE,
pin.ReferenceFrame.LOCAL_WORLD_ALIGNED)
# Rotate both vector blocks into chest axes, but do not translate the
# reference point: the wrench paired with this Jacobian still acts at
# the EE point. A full spatial adjoint here would introduce a spurious
# moment-arm term between the chest origin and EE point.
oTC = self.data.oMf[self.fid_C] # ^wT_C
R_cw = oTC.rotation.T
rotate_axes = np.zeros((6, 6))
rotate_axes[:3, :3] = R_cw
rotate_axes[3:, 3:] = R_cw
CJ_m = rotate_axes @ Jw
return CJ_m
# ===== 主端力反馈渲染 =====
class HapticRenderer:
"""Render interaction wrench as final, passivity-supervised master torque.
The haptic path has one strict ordering:
``J_m.T @ F`` (direct baseline)
-> optional nominal low-pass
-> nominal sign/strength scaling
-> torque-rate limiting
-> actuator torque saturation
-> final applied-port energy projection and single accounting update.
No filtering or limiting is performed after the energy projection. The
second returned torque is therefore the exact ``tau_app`` used in
``tau_app @ qd_m`` for the tank update.
"""
def __init__(self,
master_model: pin.Model,
chest_frame_name: str,
ee_frame_name: str,
feedback_strength: float,
E_init: float,
E_max: float,
alpha_floor: float,
alpha_ceil: float,
E0: float = 2.0,
torque_limit: float | np.ndarray | None = None,
torque_rate_limit: float | np.ndarray | None = None,
tau_filter_alpha: float = 0.2):
# 约定 feedback_strength > 0反作用时乘上一个负号
self.feedback_strength = float(feedback_strength)
if not np.isfinite(self.feedback_strength):
raise ValueError("feedback_strength must be finite")
self.CJ_master = ChestJacobianMaster(master_model,
chest_frame_name,
ee_frame_name)
tank_params = TankParams(
E_min=E_init,
E_max=E_max,
alpha_floor=alpha_floor,
alpha_ceil=alpha_ceil,
)
self.tank = AppliedPortEnergySupervisor(tank_params, E0)
# Nominal shaping state. All of this is upstream of the energy
# projection. Public tau_alpha is retained for existing scripts.
self.tau_alpha = float(tau_filter_alpha)
if not 0.0 <= self.tau_alpha <= 1.0:
raise ValueError("tau_filter_alpha must lie in [0, 1]")
self.torque_limit = torque_limit
self.torque_rate_limit = torque_rate_limit
self._tau_fb_state = np.zeros(master_model.nv)
self._tau_applied_prev = np.zeros(master_model.nv)
self.last_rate_limit_active = False
self.last_torque_saturation_active = False
def reset_tank(self, E0: float = None):
self.tank.reset(E0)
self._tau_fb_state[:] = 0.0
self._tau_applied_prev[:] = 0.0
self.last_rate_limit_active = False
self.last_torque_saturation_active = False
# ---------- 主端逆动力学:计算 tau_master_current ----------
def _inverse_dynamics(self,
q_m: np.ndarray,
qd_m: np.ndarray,
qdd_m: np.ndarray,
tau_ff_fric: np.ndarray | None = None) -> np.ndarray:
"""
主端动力学模型
τ = M(q) qdd + C(q,qd) qd + g(q) + τ_ff_fric
"""
q_m = np.asarray(q_m, dtype=float).reshape(-1,)
qd_m = np.asarray(qd_m, dtype=float).reshape(-1,)
qdd_m = np.asarray(qdd_m, dtype=float).reshape(-1,)
if tau_ff_fric is not None:
tau_ff_fric = np.asarray(tau_ff_fric, dtype=float).reshape(-1,)
model = self.CJ_master.model
data = self.CJ_master.data
# 惯量矩阵 M(q)
M = pin.crba(model, data, q_m)
M = (M + M.T) - np.diag(M.diagonal()) # 数值对称化
# 非线性项(科氏/离心 + 重力)
nle = pin.nonLinearEffects(model, data, q_m, qd_m) # = C(q,qd)qd + g(q)
g = pin.computeGeneralizedGravity(model, data, q_m)
Cqd = nle - g
tau = M @ qdd_m + Cqd + g
if tau_ff_fric is not None:
tau = tau + tau_ff_fric
return tau
def compute_tau_master_current(self,
q_m: np.ndarray,
qd_m: np.ndarray,
qdd_m: np.ndarray,
tau_ff_fric: np.ndarray | None = None) -> np.ndarray:
"""
对外接口给定主端 (q, qd, qdd)计算
τ_master_current = M qdd + C qd + g + τ_ff_fric
"""
return self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric)
# ---------- 从端交互力 → 主端反馈扭矩 ----------
@staticmethod
def _symmetric_limit_vector(value: float | np.ndarray | None,
size: int,
name: str) -> np.ndarray:
if value is None:
return np.full(size, np.inf, dtype=float)
limit = np.asarray(value, dtype=float)
if limit.ndim == 0:
limit = np.full(size, float(limit), dtype=float)
else:
limit = limit.reshape(-1,)
if limit.size != size:
raise ValueError(f"{name} must be scalar or have {size} entries")
if np.any(np.isnan(limit)) or np.any(limit < 0.0):
raise ValueError(f"{name} must contain non-negative values")
return limit
def direct_from_CF(self,
q_m: np.ndarray,
qd_m: np.ndarray,
CF_int_slave_C: np.ndarray) -> np.ndarray:
"""Return the unmodified baseline mapping ``J_m.T @ F``.
This interface intentionally applies no sign convention, nominal gain,
filter, rate limit, saturation, or energy supervision.
"""
CF = np.asarray(CF_int_slave_C, dtype=float).reshape(6,)
CJ_m = self.CJ_master.chest_jacobian(q_m, qd_m)
return np.asarray(CJ_m.T @ CF, dtype=float).reshape(-1,)
# Explicitly named alias for experiment/baseline code.
render_direct_from_CF = direct_from_CF
def _compute_feedback_tau(self,
q_m: np.ndarray,
qd_m: np.ndarray,
CF_int_slave_C: np.ndarray,
V_slave_C: np.ndarray | None,
dt: float):
"""Legacy extension hook.
The base implementation returns the direct mapping and ``None`` to
request final applied-port supervision. Existing no-tank subclasses
that override this method and return a numeric alpha continue to bypass
only the energy projection; all upstream nominal actuator shaping still
applies. ``V_slave_C`` is retained solely for call compatibility and is
never used for tank accounting.
"""
del V_slave_C, dt
return self.direct_from_CF(q_m, qd_m, CF_int_slave_C), None
def _shape_and_supervise(self,
tau_direct: np.ndarray,
qd_m: np.ndarray,
dt: float,
bypass_energy_projection: bool = False
) -> tuple[np.ndarray, float]:
"""Apply the strictly ordered haptic output pipeline."""
tau_saturated, valid = self._shape_candidate(tau_direct, qd_m, dt)
if not valid:
tau_zero, diagnostics = self.tank.apply(tau_direct, qd_m, dt)
self._tau_applied_prev = tau_zero.copy()
return tau_zero, diagnostics.rho
# Final energy projection and the only accounting update.
if bypass_energy_projection:
tau_app = tau_saturated.copy()
alpha = 1.0
else:
tau_app, diagnostics = self.tank.apply(tau_saturated, qd_m, dt)
alpha = diagnostics.rho
# Nothing may modify tau_app after projection. Only state capture and
# addition of the independent inverse-dynamics command occur downstream.
self.commit_applied(tau_app)
return tau_app, float(alpha)
def _shape_candidate(
self,
tau_direct: np.ndarray,
qd_m: np.ndarray,
dt: float,
) -> tuple[np.ndarray, bool]:
"""Return the final upstream candidate without applying a supervisor.
This split is used by the formal PO/PC baseline. The caller must pass
the candidate through exactly one supervisor and then invoke
:meth:`commit_applied`; no additional filtering or limiting is allowed.
"""
tau_direct = np.asarray(tau_direct, dtype=float).reshape(-1,)
qd_m = np.asarray(qd_m, dtype=float).reshape(-1,)
if tau_direct.shape != qd_m.shape:
raise ValueError("direct haptic torque and qd_m must have equal shapes")
if self._tau_fb_state.shape != tau_direct.shape:
self._tau_fb_state = np.zeros_like(tau_direct)
if self._tau_applied_prev.shape != tau_direct.shape:
self._tau_applied_prev = np.zeros_like(tau_direct)
# Invalid samples must not contaminate nominal filter/rate-limit state.
if (
not np.isfinite(dt)
or dt <= 0.0
or not np.all(np.isfinite(tau_direct))
or not np.all(np.isfinite(qd_m))
):
self.last_rate_limit_active = False
self.last_torque_saturation_active = False
return np.zeros_like(tau_direct), False
# 1) Optional nominal torque filtering.
a = float(self.tau_alpha)
if not 0.0 <= a <= 1.0:
raise ValueError("tau_alpha must lie in [0, 1]")
self._tau_fb_state = (1.0 - a) * self._tau_fb_state + a * tau_direct
# 2) Nominal sign and strength scaling.
tau_nominal = -self.feedback_strength * self._tau_fb_state
# 3) Per-joint rate limiting relative to the previously applied output.
rate_limit = self._symmetric_limit_vector(
self.torque_rate_limit, tau_nominal.size, "torque_rate_limit"
)
max_delta = rate_limit * dt
tau_rate_limited = np.clip(
tau_nominal,
self._tau_applied_prev - max_delta,
self._tau_applied_prev + max_delta,
)
self.last_rate_limit_active = bool(
np.any(np.abs(tau_rate_limited - tau_nominal) > 1e-12)
)
# 4) Per-joint symmetric actuator saturation.
torque_limit = self._symmetric_limit_vector(
self.torque_limit, tau_nominal.size, "torque_limit"
)
tau_saturated = np.clip(tau_rate_limited, -torque_limit, torque_limit)
self.last_torque_saturation_active = bool(
np.any(np.abs(tau_saturated - tau_rate_limited) > 1e-12)
)
return tau_saturated, True
def shape_mapped_reaction_candidate(
self,
tau_environment_on_master: np.ndarray,
qd_m: np.ndarray,
dt: float,
) -> tuple[np.ndarray, bool]:
"""Prepare the common upstream candidate for an external supervisor."""
reaction = np.asarray(
tau_environment_on_master,
dtype=float,
).reshape(-1)
return self._shape_candidate(-reaction, qd_m, dt)
def commit_applied(self, tau_applied: np.ndarray) -> None:
"""Record the exact supervisor output used at the master port."""
applied = np.asarray(tau_applied, dtype=float).reshape(-1)
if applied.shape != self._tau_applied_prev.shape:
raise ValueError("tau_applied has an unexpected shape")
if not np.all(np.isfinite(applied)):
raise ValueError("tau_applied must contain only finite values")
self._tau_applied_prev = applied.copy()
# ---------- 高层接口:算 tau_master_current + 反馈 ----------
def render_mapped_reaction(
self,
tau_environment_on_master: np.ndarray,
qd_m: np.ndarray,
dt: float,
*,
supervise_energy: bool = True,
) -> tuple[np.ndarray, float]:
"""Shape an arbitrary generalized reaction such as ``A.T @ tau_s``.
``tau_environment_on_master`` already has the desired physical sign:
it is the environment-on-device reaction and should oppose penetration.
The historical renderer pipeline stores robot-on-environment action
internally and inserts its own leading minus sign, so the conversion is
performed exactly once here. The returned torque is the final applied
haptic output; callers must not filter or limit it downstream.
"""
reaction = np.asarray(
tau_environment_on_master,
dtype=float,
).reshape(-1,)
return self._shape_and_supervise(
-reaction,
qd_m,
dt,
bypass_energy_projection=not supervise_energy,
)
def render_tau(self,
q_m: np.ndarray,
qd_m: np.ndarray,
qdd_m: np.ndarray,
CF_int_slave_C: np.ndarray,
V_slave_C: np.ndarray | None,
dt: float,
tau_ff_fric: np.ndarray | None = None,
):
"""
高层接口给定 (q, qd, qdd) 和从端交互力返回最终主端总扭矩
输入
- q_m, qd_m, qdd_m: 主端关节位置/速度/加速度用于逆动力学
- CF_int_slave_C: 从端 InteractionEstimator 估计出的 C F_int6×1
- V_slave_C: 仅为旧调用兼容而保留能量只按主端
tau_app^T qd_m 计算
- tau_ff_fric: 主端摩擦前馈可为 None
- dt: 控制周期
输出
- tau_cmd_m: 主端最终下发的总扭矩 = tau_master_current + tau_app
- tau_app: 最终反馈扭矩也是能量记账使用的精确力矩
- alpha: 最终能量投影的径向缩放系数
"""
# 1) 主端逆动力学扭矩
tau_master_current = self._inverse_dynamics(q_m, qd_m, qdd_m, tau_ff_fric)
tau_master_current = np.asarray(tau_master_current, dtype=float).reshape(-1,)
# 2) Render and account the haptic contribution exactly once.
tau_app, alpha = self.render_from_CF(
q_m, qd_m,
CF_int_slave_C=CF_int_slave_C,
V_slave_C=V_slave_C,
dt=dt,
)
# 3) tau_app is not modified after projection.
tau_cmd_m = tau_master_current + tau_app
return tau_cmd_m, tau_app, alpha
# 兼容旧接口:只关心反馈扭矩
def render_from_CF(self,
q_m: np.ndarray,
qd_m: np.ndarray,
CF_int_slave_C: np.ndarray,
V_slave_C: np.ndarray | None,
dt: float):
"""
兼容旧接口返回最终施加的反馈扭矩与能量投影系数
"""
tau_direct, legacy_alpha = self._compute_feedback_tau(
q_m,
qd_m,
CF_int_slave_C,
V_slave_C,
dt,
)
return self._shape_and_supervise(
tau_direct,
qd_m,
dt,
bypass_energy_projection=legacy_alpha is not None,
)