exoskeleton/code/test/ablation_haptic.py

512 lines
16 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
"""
ablation_haptic.py
Quantitative ablation for the haptic rendering module.
Modes:
- baseline : full method (energy tank + F/V filtering + alpha smoothing + torque LPF)
- no_tank : energy tank disabled (no passivity enforcement)
- no_filter : tank enabled but F/V filtering + alpha smoothing + torque LPF disabled
Run:
python test/ablation_haptic.py
from the project root, with config/config.yaml following the same
structure as demo.py and test_sew.py.
"""
import os
import sys
import time
from typing import Dict
import numpy as np
import pinocchio as pin
import matplotlib.pyplot as plt
from omegaconf import OmegaConf
# Make sure project root is on sys.path
THIS_DIR = os.path.dirname(__file__)
PROJECT_ROOT = os.path.abspath(os.path.join(THIS_DIR, ".."))
if PROJECT_ROOT not in sys.path:
sys.path.append(PROJECT_ROOT)
from core.sew_mapper import SEWMapper
from core.interaction_estimater import InteractionEstimator
from core.haptic_render import HapticRenderer
# -------------------- Utilities --------------------
def dummy_master_measure(step: int, model: pin.Model) -> np.ndarray:
"""
Synthetic master joint measurement used for offline ablation.
Start from neutral and add small sinusoidal motions on the first few joints.
"""
q = pin.neutral(model)
n_use = min(model.nq, 6)
for i in range(n_use):
q[i] = 0.5 * np.sin(0.01 * step + 0.7 * i)
return q
def dummy_slave_dynamics(q_cmd: np.ndarray, model: pin.Model) -> np.ndarray:
"""
Simple slave "execution" model: follow the commanded joint positions
with a small Gaussian disturbance, to emulate interaction / noise.
"""
q_cmd = np.asarray(q_cmd, dtype=float).reshape(-1,)
noise = 0.001 * np.random.randn(model.nq)
return q_cmd + noise
def load_config():
"""
Load config/config.yaml in the project root (same style as demo.py / test_sew.py).
"""
cand = os.path.join(PROJECT_ROOT, "config", "config.yaml")
if not os.path.exists(cand):
raise FileNotFoundError(f"config.yaml not found at: {cand}")
conf = OmegaConf.load(cand)
print(f"[INFO] Loaded config from: {cand}")
return conf
def build_modules(conf):
# Build models from URDF
master_model = pin.buildModelFromUrdf(str(conf.master_urdf))
slave_model = pin.buildModelFromUrdf(str(conf.slave_urdf))
# SEW mapper
sew = SEWMapper(
master_model=master_model,
slave_model=slave_model,
m_shoulder_frame=conf.m_shoulder_frame,
m_elbow_frame=conf.m_elbow_frame,
m_wrist_frame=conf.m_wrist_frame,
m_ee_frame=conf.m_ee_frame,
s_shoulder_frame=conf.s_shoulder_frame,
s_elbow_frame=conf.s_elbow_frame,
s_wrist_frame=conf.s_wrist_frame,
s_ee_frame=conf.s_ee_frame,
slave_joint_names=conf.sew_mapper.slave_joint_names,
up_dir=np.array(conf.sew_mapper.up_dir, dtype=float),
eps_clip=float(conf.sew_mapper.eps_clip),
)
# Interaction estimator on slave side
estimator = InteractionEstimator(
model=slave_model,
chest_frame_name=conf.s_base_frame,
ee_frame_name=conf.s_ee_frame,
lambda_damp=float(conf.interaction_est.lambda_damp),
)
return master_model, slave_model, sew, estimator
# -------------------- Haptic renderer variants --------------------
class HapticRendererNoTank(HapticRenderer):
"""
Variant that completely disables the energy tank:
- directly maps interaction wrench to master joint torques through CJ_m^T
- no passivity enforcement
"""
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):
CF = np.asarray(CF_int_slave_C, dtype=float).reshape(6,)
# Chest Jacobian on master side
CJ_m = self.CJ_master.chest_jacobian(q_m, qd_m)
tau_fb_m_raw = CJ_m.T @ CF
alpha = 1.0
return tau_fb_m_raw, alpha
def make_renderer(conf, master_model: pin.Model, mode: str) -> HapticRenderer:
"""
Construct different haptic renderers for ablation.
"""
common_kwargs = dict(
master_model=master_model,
chest_frame_name=conf.m_base_frame,
ee_frame_name=conf.m_ee_frame,
feedback_strength=float(conf.haptic_render.feedback_strength),
E_init=float(conf.haptic_render.E_init),
E_max=float(conf.haptic_render.E_max),
alpha_floor=float(conf.haptic_render.alpha_floor),
alpha_ceil=float(conf.haptic_render.alpha_ceil),
E0=float(conf.haptic_render.E0),
)
if mode == "baseline":
renderer = HapticRenderer(**common_kwargs)
# baseline能量罐 + 强滤波 + 平滑 + 扭矩低通(“管得很严”)
renderer.tank.tp.force_alpha = 0.05
renderer.tank.tp.vel_alpha = 0.05
renderer.tank.tp.alpha_smooth = 0.02
renderer.tank.tp.power_deadzone = 0.0
renderer.tau_alpha = 0.05
elif mode == "no_tank":
renderer = HapticRendererNoTank(**common_kwargs)
# no_tank完全不做能量控制也不做扭矩低通暴露最差情况
renderer.tau_alpha = 1.0
elif mode == "no_filter":
renderer = HapticRenderer(**common_kwargs)
# no_filter保留能量罐但不给它任何滤波/平滑能力
renderer.tank.tp.force_alpha = 1.0
renderer.tank.tp.vel_alpha = 1.0
renderer.tank.tp.alpha_smooth = 1.0
renderer.tank.tp.power_deadzone = 0.0
renderer.tau_alpha = 1.0
else:
raise ValueError(f"Unknown ablation mode: {mode}")
return renderer
# -------------------- Teleoperation simulation (offline) --------------------
def simulate_teleop(conf,
master_model: pin.Model,
slave_model: pin.Model,
sew: SEWMapper,
estimator: InteractionEstimator,
renderer: HapticRenderer,
Ts: float = 0.002,
steps: int = 800) -> Dict[str, np.ndarray]:
"""
Offline simulation similar to demo.py, but without real-time delays.
Returns logs for computing quantitative metrics.
"""
# States
q_slave = pin.neutral(slave_model)
dq_slave = np.zeros(slave_model.nv)
q_slave_prev = q_slave.copy()
q_m_init = dummy_master_measure(0, master_model)
pre_q_m = q_m_init.copy()
pre_qd_m = np.zeros_like(q_m_init)
# Logs
log_tau_fb = []
log_alpha = []
log_E = []
log_tau_int_norm = []
log_qd_m = []
ee_traj_world = []
# Harder virtual wall to highlight differences
d_wall = 0.35
k_wall = 20000.0
for k in range(steps):
# --- master state ---
q_m = dummy_master_measure(k, master_model)
if k == 0:
qd_m = np.zeros_like(q_m)
qdd_m = np.zeros_like(q_m)
else:
qd_m = (q_m - pre_q_m) / Ts
qdd_m = (qd_m - pre_qd_m) / Ts
pre_q_m = q_m
pre_qd_m = qd_m
log_qd_m.append(qd_m.copy())
# --- SEW retargetting: master -> desired slave ---
q_des_slave, _ = sew.retargetting(q_m, q_slave)
# --- slave motion (with small noise) ---
q_slave = dummy_slave_dynamics(q_des_slave, slave_model)
dq_slave = (q_slave - q_slave_prev) / Ts
q_slave_prev = q_slave.copy()
qdd_slave = np.zeros(slave_model.nv)
# --- virtual wall on slave side ---
CJ_env = estimator._chest_jacobian(q_slave, dq_slave)
oTC = estimator.data.oMf[estimator.fid_C] # ^wT_C
oTEE = estimator.data.oMf[estimator.fid_EE] # ^wT_EE
R_wc = oTC.rotation
p_wc = oTC.translation
R_cw = R_wc.T
p_cw = -R_cw @ p_wc
p_we = oTEE.translation
ee_traj_world.append(p_we.copy())
p_ce = R_cw @ p_we + p_cw
x_ce = float(p_ce[0])
if x_ce > d_wall:
delta = x_ce - d_wall
Fx = -k_wall * delta
CF_env = np.array([Fx, 0, 0, 0, 0, 0], dtype=float)
else:
CF_env = np.zeros(6, dtype=float)
tau_env = CJ_env.T @ CF_env
# --- interaction estimation on slave ---
tau_model = estimator._tau_model(q_slave, dq_slave, qdd_slave)
tau_meas = tau_model + tau_env
tau_int, CF_int, CJ = estimator.estimate(q_slave, dq_slave, qdd_slave, tau_meas)
log_tau_int_norm.append(np.linalg.norm(tau_int))
V_slave_C = CJ @ dq_slave # 6 x 1
# --- haptic rendering on master side ---
tau_cmd_m, tau_fb_m, alpha = renderer.render_tau(
q_m=q_m,
qd_m=qd_m,
qdd_m=qdd_m,
CF_int_slave_C=CF_int,
V_slave_C=V_slave_C,
tau_ff_fric=None,
dt=Ts,
)
log_tau_fb.append(tau_fb_m.copy())
log_alpha.append(alpha)
# not all variants really use the tank, but querying E is safe
log_E.append(getattr(renderer.tank, "E", 0.0))
# stack
log_tau_fb = np.vstack(log_tau_fb)
log_alpha = np.asarray(log_alpha)
log_E = np.asarray(log_E)
log_tau_int_norm = np.asarray(log_tau_int_norm)
log_qd_m = np.vstack(log_qd_m)
ee_traj_world = np.vstack(ee_traj_world)
return dict(
tau_fb=log_tau_fb,
alpha=log_alpha,
E=log_E,
tau_int_norm=log_tau_int_norm,
qd_m=log_qd_m,
ee_traj_world=ee_traj_world,
)
# -------------------- Metric computation --------------------
def compute_metrics(tau_fb: np.ndarray,
qd_m: np.ndarray,
E: np.ndarray,
Ts: float) -> Dict[str, float]:
"""
Compute quantitative metrics for ablation:
- rms / peak torque
- torque spike count / rate
- positive power injection ratio
- (optional) energy range if tank is used
"""
# torque norms
tau_norm = np.linalg.norm(tau_fb, axis=1)
rms_tau = float(np.sqrt(np.mean(tau_norm ** 2)))
peak_tau = float(np.max(tau_norm))
# torque spikes: ||Δτ||_∞ > threshold
dtau = np.diff(tau_fb, axis=0)
dtau_inf = np.max(np.abs(dtau), axis=1)
spike_th = 5.0 # Nm, can be adjusted to your system scale
n_spikes = int(np.sum(dtau_inf > spike_th))
spike_rate = n_spikes / (len(dtau_inf) * Ts)
# positive power injection ratio: tau_fb^T * qd_m > 0
P = np.sum(tau_fb * qd_m, axis=1)
P_inject_ratio = float(np.mean(P > 0.0))
metrics = dict(
rms_tau=rms_tau,
peak_tau=peak_tau,
n_spikes=n_spikes,
spike_rate=spike_rate,
P_inject_ratio=P_inject_ratio,
)
if np.any(E != 0.0):
metrics["E_min"] = float(np.min(E))
metrics["E_max"] = float(np.max(E))
return metrics
def plot_ablation_results(all_logs, all_metrics, Ts):
modes = ["baseline", "no_tank", "no_filter"]
colors = {
"baseline": "tab:blue",
"no_tank": "tab:red",
"no_filter": "tab:green",
}
# -------------------------------------------
# 1) Torque feedback trajectories (norm vs time)
# -------------------------------------------
plt.figure(figsize=(8,4))
for mode in modes:
tau_norm = np.linalg.norm(all_logs[mode]["tau_fb"], axis=1)
t = np.arange(len(tau_norm)) * Ts
plt.plot(t, tau_norm, label=mode, color=colors[mode])
plt.xlabel("Time [s]")
plt.ylabel(r"$\|\tau_{\mathrm{fb}}(t)\|\ \mathrm{[N\cdot m]}$")
plt.title("Feedback Torque Norm Over Time")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("logs_ablation/fig_tau_fb_norm.png", dpi=300)
# -------------------------------------------
# 2) Alpha(t) (energy tank scaling)
# -------------------------------------------
plt.figure(figsize=(8,4))
for mode in modes:
alpha = all_logs[mode]["alpha"]
t = np.arange(len(alpha)) * Ts
plt.plot(t, alpha, label=mode, color=colors[mode])
plt.xlabel("Time [s]")
plt.ylabel(r"$\alpha(t)$")
plt.title("Energy Tank Scaling Factor")
plt.ylim([-0.1, 1.1])
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("logs_ablation/fig_alpha.png", dpi=300)
# -------------------------------------------
# 3) Tank Energy E(t)
# -------------------------------------------
plt.figure(figsize=(8,4))
for mode in modes:
E = all_logs[mode]["E"]
t = np.arange(len(E)) * Ts
plt.plot(t, E, label=mode, color=colors[mode])
plt.xlabel("Time [s]")
plt.ylabel(r"$E(t)$")
plt.title("Energy Tank Level")
plt.legend()
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("logs_ablation/fig_energy.png", dpi=300)
# -------------------------------------------
# 4) Bar charts: RMS / Peak / Spikes / P>0
# -------------------------------------------
metrics_list = ["rms_tau", "peak_tau", "n_spikes", "P_inject_ratio"]
metric_names = [
"RMS Torque",
"Peak Torque",
"# Torque Spikes",
"Positive Power Ratio",
]
plt.figure(figsize=(9,4))
for i, key in enumerate(metrics_list):
plt.subplot(1,4,i+1)
vals = [all_metrics[m][key] for m in modes]
plt.bar(modes, vals, color=[colors[m] for m in modes])
plt.title(metric_names[i])
plt.xticks(rotation=45)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("logs_ablation/fig_metrics_barchart.png", dpi=300)
print("[INFO] All plots saved to logs_ablation/")
# -------------------- Main --------------------
def main():
conf = load_config()
master_model, slave_model, sew, estimator = build_modules(conf)
Ts = 0.002
steps = 800
modes = ["baseline", "no_tank", "no_filter"]
all_logs = {}
all_metrics = {}
print(f"dt = {Ts:.4f} s, steps = {steps}")
for mode in modes:
print(f"\n========== Running haptic ablation: {mode} ==========")
renderer = make_renderer(conf, master_model, mode)
renderer.reset_tank(float(conf.haptic_render.E0))
# fix random seed so that each mode sees the same slave noise
np.random.seed(0)
t0 = time.time()
logs = simulate_teleop(
conf,
master_model,
slave_model,
sew,
estimator,
renderer,
Ts=Ts,
steps=steps,
)
elapsed = time.time() - t0
all_logs[mode] = logs
metrics = compute_metrics(
tau_fb=logs["tau_fb"],
qd_m=logs["qd_m"],
E=logs["E"],
Ts=Ts,
)
all_metrics[mode] = metrics
print(
f"[{mode}] done in {elapsed:.3f} s | "
f"rms_tau={metrics['rms_tau']:.3f}, "
f"peak_tau={metrics['peak_tau']:.3f}, "
f"spikes={metrics['n_spikes']}, "
f"spike_rate={metrics['spike_rate']:.3f} 1/s, "
f"P>0={metrics['P_inject_ratio']:.3f}, "
f"E_range="
f"{'[{:.2f},{:.2f}]'.format(metrics['E_min'], metrics['E_max']) if 'E_min' in metrics else 'N/A'}"
)
# Save logs for further plotting if needed
save_dir = os.path.join(PROJECT_ROOT, "logs_ablation")
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, "haptics_ablation_results.npz")
# pack as a single dict and rely on pickle when loading
np.savez(
save_path,
all_logs=all_logs,
all_metrics=all_metrics,
Ts=Ts,
)
print(f"\n[INFO] Ablation logs and metrics saved to:\n {save_path}")
plot_ablation_results(all_logs, all_metrics, Ts)
if __name__ == "__main__":
main()