Files
acRealman_xr/xr_rm_teleop/test/ik_method_comparison.py
T

1003 lines
34 KiB
Python

from __future__ import annotations
import csv
import json
import math
import time
from dataclasses import asdict, dataclass, replace
from pathlib import Path
import h5py
import matplotlib
import numpy as np
import yaml
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from xr_rm_teleop.placo_ik_solver import (
QP_MAX_ITERATIONS,
QP_ORIENTATION_TOLERANCE_RAD,
QP_POSITION_TOLERANCE_M,
PlacoIkSolver,
_validated_transform,
)
from xr_rm_teleop.single_arm_velocity_teleop import (
SingleArmVelocityTeleop,
_matrix_to_quaternion,
_quaternion_to_matrix,
_so3_log,
)
METHOD_STYLE = {
"pinv": ("Jacobian 伪逆", "#D55E00", "-"),
"dls": ("DLS", "#0072B2", "--"),
"qp": ("优化 QP", "#009E73", "-."),
}
plt.rcParams.update(
{
"font.family": "sans-serif",
"font.sans-serif": [
"Noto Sans CJK JP",
"WenQuanYi Micro Hei",
"DejaVu Sans",
],
"axes.unicode_minus": False,
"figure.dpi": 120,
"savefig.dpi": 300,
}
)
@dataclass(frozen=True)
class EpisodeTrajectory:
source_path: Path
times_s: np.ndarray
target_poses: np.ndarray
initial_joints: np.ndarray
@dataclass(frozen=True)
class MethodSummary:
method: str
damping: float | None
success_rate: float
position_rmse_m: float
orientation_rmse_rad: float
max_joint_speed_deg_s: float
min_joint_margin: float = 0.0
mean_solve_ms: float = 0.0
max_solve_ms: float = 0.0
failure_count: int = 0
longest_failure_streak: int = 0
command_limited_count: int = 0
@dataclass(frozen=True)
class ReplayResult:
method: str
times_s: np.ndarray
target_poses: np.ndarray
actual_poses: np.ndarray
joints: np.ndarray
velocities: np.ndarray
position_errors_m: np.ndarray
orientation_errors_rad: np.ndarray
joint_margins: np.ndarray
solve_durations_ms: np.ndarray
success: np.ndarray
command_limited: np.ndarray
def _normalized_quaternion(values: np.ndarray) -> np.ndarray:
quaternion = np.asarray(values, dtype=float)
if quaternion.shape != (4,) or not np.isfinite(quaternion).all():
raise ValueError("quaternion must contain 4 finite values")
norm = float(np.linalg.norm(quaternion))
if norm <= 1e-12:
raise ValueError("quaternion norm must be positive")
return quaternion / norm
def _slerp_quaternion(
start: np.ndarray,
end: np.ndarray,
fraction: float,
) -> np.ndarray:
first = _normalized_quaternion(start)
second = _normalized_quaternion(end)
dot = float(np.dot(first, second))
if dot < 0.0:
second = -second
dot = -dot
dot = float(np.clip(dot, -1.0, 1.0))
if dot > 1.0 - 1e-8:
return _normalized_quaternion(
first + float(fraction) * (second - first)
)
angle = float(np.arccos(dot))
sine = float(np.sin(angle))
return _normalized_quaternion(
np.sin((1.0 - fraction) * angle) / sine * first
+ np.sin(fraction * angle) / sine * second
)
def resample_trajectory(
trajectory: EpisodeTrajectory,
sample_rate_hz: float,
) -> EpisodeTrajectory:
if not np.isfinite(sample_rate_hz) or sample_rate_hz <= 0.0:
raise ValueError("sample_rate_hz must be finite and positive")
source_times = np.asarray(trajectory.times_s, dtype=float)
poses = np.asarray(trajectory.target_poses, dtype=float)
if source_times.ndim != 1 or poses.shape != (source_times.size, 7):
raise ValueError("trajectory must contain N timestamps and N x 7 poses")
if source_times.size < 2 or np.any(np.diff(source_times) <= 0.0):
raise ValueError("trajectory timestamps must be strictly increasing")
duration = float(source_times[-1] - source_times[0])
count = int(round(duration * sample_rate_hz)) + 1
target_times = np.linspace(source_times[0], source_times[-1], count)
target_poses = np.empty((count, 7), dtype=float)
for axis in range(3):
target_poses[:, axis] = np.interp(
target_times,
source_times,
poses[:, axis],
)
for index, timestamp in enumerate(target_times):
right = int(np.searchsorted(source_times, timestamp, side="right"))
right = min(max(right, 1), source_times.size - 1)
left = right - 1
interval = source_times[right] - source_times[left]
fraction = float((timestamp - source_times[left]) / interval)
target_poses[index, 3:] = _slerp_quaternion(
poses[left, 3:], poses[right, 3:], fraction
)
target_poses[0] = poses[0]
target_poses[-1] = poses[-1]
return EpisodeTrajectory(
source_path=trajectory.source_path,
times_s=target_times - target_times[0],
target_poses=target_poses,
initial_joints=np.asarray(trajectory.initial_joints, dtype=float).copy(),
)
def _longest_true_run(mask: np.ndarray) -> slice:
values = np.asarray(mask, dtype=bool)
best_start = best_stop = start = 0
for index, enabled in enumerate(np.r_[values, False]):
if enabled:
continue
if index - start > best_stop - best_start:
best_start, best_stop = start, index
start = index + 1
if best_stop - best_start < 2:
raise ValueError("episode has no valid teleoperation run")
return slice(best_start, best_stop)
def load_episode(path: Path) -> EpisodeTrajectory:
source = Path(path).expanduser().resolve()
if not source.is_file() or source.suffix.lower() not in (".h5", ".hdf5"):
raise FileNotFoundError(f"episode not found: {source}")
with h5py.File(source, "r") as handle:
if str(handle.attrs.get("arm", "")) != "right_rm75":
raise ValueError("episode arm must be right_rm75")
if str(handle.attrs.get("pose_order", "")) != "x,y,z,qx,qy,qz,qw":
raise ValueError("episode pose_order is unsupported")
required = (
"debug/tcp/final_target_pose",
"debug/timestamps/control_monotonic_ns",
"debug/control/teleop_active",
"debug/control/action_valid",
"debug/control/command_sent",
"observations/qpos",
)
missing = [name for name in required if name not in handle]
if missing:
raise ValueError(f"episode datasets missing: {missing}")
poses = np.asarray(handle[required[0]], dtype=float)
timestamps = np.asarray(handle[required[1]], dtype=np.int64)
mask = np.logical_and.reduce(
[
np.asarray(handle[required[2]], dtype=bool),
np.asarray(handle[required[3]], dtype=bool),
np.asarray(handle[required[4]], dtype=bool),
]
)
qpos = np.asarray(handle[required[5]], dtype=float)
if poses.shape != (timestamps.size, 7) or qpos.shape != (timestamps.size, 8):
raise ValueError("episode arrays have inconsistent shapes")
if not np.isfinite(poses).all() or not np.isfinite(qpos).all():
raise ValueError("episode contains NaN/Inf")
selected = _longest_true_run(mask)
selected_times = timestamps[selected]
if np.any(np.diff(selected_times) <= 0):
raise ValueError("episode timestamps must be strictly increasing")
selected_poses = poses[selected].copy()
selected_poses[:, 3:] = np.asarray(
[_normalized_quaternion(value) for value in selected_poses[:, 3:]]
)
return EpisodeTrajectory(
source_path=source,
times_s=(selected_times - selected_times[0]) * 1e-9,
target_poses=selected_poses,
initial_joints=qpos[selected.start, :7].copy(),
)
def load_right_config(path: Path) -> dict[str, float]:
with Path(path).open("r", encoding="utf-8") as stream:
document = yaml.safe_load(stream)
parameters = document["single_arm_velocity_teleop"]["ros__parameters"]
names = (
"qp_j3_reference_deg",
"qp_j3_weight",
"qp_j4_min_deg",
"qp_j4_warn_deg",
"qp_j4_weight",
"qp_manipulability_sigma_stop",
"qp_manipulability_sigma_warn",
"qp_manipulability_weight",
"joint_max_speed",
"joint_max_acc",
)
return {name: float(parameters[name]) for name in names}
def _rotation_z(angle: float) -> np.ndarray:
cosine, sine = math.cos(angle), math.sin(angle)
return np.asarray(
[
[cosine, -sine, 0.0],
[sine, cosine, 0.0],
[0.0, 0.0, 1.0],
]
)
def orientation_error_rad(actual: np.ndarray, target: np.ndarray) -> float:
delta = np.asarray(target) @ np.asarray(actual).T
cosine = float(np.clip((np.trace(delta) - 1.0) * 0.5, -1.0, 1.0))
return float(math.acos(cosine))
def normalized_joint_margin(
joints: np.ndarray,
lower: np.ndarray,
upper: np.ndarray,
) -> float:
values = np.asarray(joints, dtype=float)
lower_values = np.asarray(lower, dtype=float)
upper_values = np.asarray(upper, dtype=float)
span = upper_values - lower_values
if np.any(span <= 0.0):
raise ValueError("joint limits must have positive spans")
margins = np.minimum(
values - lower_values,
upper_values - values,
) / span
return float(np.min(margins))
def choose_dls_damping(candidates: list[MethodSummary]) -> float:
if not candidates or any(value.damping is None for value in candidates):
raise ValueError("DLS candidates must contain damping values")
selected = min(
candidates,
key=lambda value: (
-value.success_rate,
value.position_rmse_m / 0.002
+ value.orientation_rmse_rad / 0.005,
value.max_joint_speed_deg_s,
),
)
return float(selected.damping)
def _longest_failure_streak(success: np.ndarray) -> int:
longest = current = 0
for value in np.asarray(success, dtype=bool):
current = 0 if value else current + 1
longest = max(longest, current)
return longest
def summarize_result(
result: ReplayResult,
damping: float | None = None,
) -> MethodSummary:
return MethodSummary(
method=result.method,
damping=damping,
success_rate=float(np.mean(result.success)),
position_rmse_m=float(
np.sqrt(np.mean(result.position_errors_m**2))
),
orientation_rmse_rad=float(
np.sqrt(np.mean(result.orientation_errors_rad**2))
),
max_joint_speed_deg_s=float(
np.max(np.abs(np.degrees(result.velocities)))
),
min_joint_margin=float(np.min(result.joint_margins)),
mean_solve_ms=float(np.mean(result.solve_durations_ms)),
max_solve_ms=float(np.max(result.solve_durations_ms)),
failure_count=int(np.count_nonzero(~result.success)),
longest_failure_streak=_longest_failure_streak(result.success),
command_limited_count=int(np.count_nonzero(result.command_limited)),
)
def limit_joint_command(
*,
target: np.ndarray,
previous_target: np.ndarray,
previous_velocity: np.ndarray,
max_speed: float,
max_acceleration: float,
dt: float,
) -> tuple[np.ndarray, np.ndarray, bool]:
limited_target, limited_velocity = (
SingleArmVelocityTeleop._limit_joint_command_step(
target=np.asarray(target, dtype=float).tolist(),
previous_target=np.asarray(previous_target, dtype=float).tolist(),
previous_velocity=np.asarray(previous_velocity, dtype=float).tolist(),
max_speed=max_speed,
max_acceleration=max_acceleration,
dt=dt,
)
)
target_array = np.asarray(limited_target, dtype=float)
velocity_array = np.asarray(limited_velocity, dtype=float)
return (
target_array,
velocity_array,
not np.allclose(target_array, target, atol=1e-12, rtol=0.0),
)
def _pose_to_transform(pose: np.ndarray) -> np.ndarray:
values = np.asarray(pose, dtype=float)
transform = np.eye(4)
transform[:3, 3] = values[:3]
transform[:3, :3] = _quaternion_to_matrix(tuple(values[3:]))
return transform
def _transform_to_pose(transform: np.ndarray) -> np.ndarray:
quaternion = _matrix_to_quaternion(transform[:3, :3])
return np.asarray([*transform[:3, 3], *quaternion], dtype=float)
class DifferentialIkSolver:
def __init__(
self,
urdf_path: Path,
dt: float,
method: str,
damping: float = 0.0,
) -> None:
if method not in ("pinv", "dls"):
raise ValueError("method must be pinv or dls")
if method == "dls" and damping <= 0.0:
raise ValueError("DLS damping must be positive")
self._kinematics = PlacoIkSolver(str(urdf_path), dt, "right")
self._dt = dt
self._method = method
self._damping = float(damping)
self._actual_joints: np.ndarray | None = None
@property
def joint_limits(self) -> np.ndarray:
return self._kinematics._joint_limits.copy()
def update_joint_state(self, joints: list[float]) -> np.ndarray:
self._actual_joints = np.asarray(joints, dtype=float).copy()
return self._kinematics.update_joint_state(joints)
def _set_internal_joints(self, joints: np.ndarray) -> None:
robot = self._kinematics._robot
robot.state.q[self._kinematics._q_offsets] = joints
robot.update_kinematics()
def _errors(self, target: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
robot = self._kinematics._robot
world_base = robot.get_T_world_frame(self._kinematics._base_frame)
world_tcp = robot.get_T_world_frame(self._kinematics._tcp_frame)
world_target = world_base @ target
position = world_target[:3, 3] - world_tcp[:3, 3]
orientation = _so3_log(
world_target[:3, :3] @ world_tcp[:3, :3].T
)
return position, orientation
def solve(self, target: np.ndarray) -> list[float]:
if self._actual_joints is None:
raise RuntimeError("joint state must be initialized before IK solve")
target = _validated_transform(target)
actual = self._actual_joints.copy()
result = actual.copy()
try:
for _ in range(QP_MAX_ITERATIONS):
self._set_internal_joints(result)
position, orientation = self._errors(target)
if (
np.linalg.norm(position) <= QP_POSITION_TOLERANCE_M
and np.linalg.norm(orientation)
<= QP_ORIENTATION_TOLERANCE_RAD
):
return result.tolist()
jacobian = self._kinematics._active_tcp_jacobian()
desired_twist = np.r_[position, orientation] / self._dt
if self._method == "pinv":
joint_velocity = np.linalg.pinv(jacobian) @ desired_twist
else:
system = (
jacobian @ jacobian.T
+ self._damping**2 * np.eye(6)
)
joint_velocity = jacobian.T @ np.linalg.solve(
system, desired_twist
)
candidate = result + joint_velocity * self._dt
self._kinematics._validate_result(candidate, result)
result = candidate
raise RuntimeError(
f"{self._method} did not converge after "
f"{QP_MAX_ITERATIONS} iterations"
)
except Exception:
self._set_internal_joints(actual)
raise
class QpSolverAdapter:
def __init__(self, solver: PlacoIkSolver) -> None:
self._solver = solver
@property
def joint_limits(self) -> np.ndarray:
return self._solver._joint_limits.copy()
def update_joint_state(self, joints: list[float]) -> np.ndarray:
return self._solver.update_joint_state(joints)
def solve(self, target: np.ndarray) -> list[float]:
return self._solver.solve(target)
def make_qp_solver(
urdf_path: Path,
dt: float,
config: dict[str, float],
) -> QpSolverAdapter:
return QpSolverAdapter(
PlacoIkSolver(
str(urdf_path),
dt,
"right",
j3_reference_deg=config["qp_j3_reference_deg"],
j3_weight=config["qp_j3_weight"],
j4_min_deg=config["qp_j4_min_deg"],
j4_warn_deg=config["qp_j4_warn_deg"],
j4_weight=config["qp_j4_weight"],
manipulability_sigma_stop=config[
"qp_manipulability_sigma_stop"
],
manipulability_sigma_warn=config[
"qp_manipulability_sigma_warn"
],
manipulability_weight=config["qp_manipulability_weight"],
)
)
def run_replay(
method: str,
solver,
trajectory: EpisodeTrajectory,
*,
max_speed: float,
max_acceleration: float,
measure_time: bool = True,
) -> ReplayResult:
count = trajectory.times_s.size
dt = float(np.median(np.diff(trajectory.times_s)))
joints = np.empty((count, 7))
velocities = np.zeros((count, 7))
actual_poses = np.empty((count, 7))
position_errors = np.empty(count)
orientation_errors = np.empty(count)
margins = np.empty(count)
durations = np.zeros(count)
success = np.zeros(count, dtype=bool)
command_limited = np.zeros(count, dtype=bool)
current = trajectory.initial_joints.copy()
previous_velocity = np.zeros(7)
lower, upper = solver.joint_limits.T
for index, pose in enumerate(trajectory.target_poses):
solver.update_joint_state(current.tolist())
started = time.perf_counter_ns()
try:
candidate = np.asarray(
solver.solve(_pose_to_transform(pose)), dtype=float
)
success[index] = True
except Exception:
candidate = current.copy()
previous_velocity = np.zeros(7)
durations[index] = (
(time.perf_counter_ns() - started) * 1e-6 if measure_time else 0.0
)
if success[index]:
current, previous_velocity, command_limited[index] = (
limit_joint_command(
target=candidate,
previous_target=current,
previous_velocity=previous_velocity,
max_speed=max_speed,
max_acceleration=max_acceleration,
dt=dt,
)
)
actual_transform = solver.update_joint_state(current.tolist())
actual_pose = _transform_to_pose(actual_transform)
joints[index] = current
velocities[index] = previous_velocity
actual_poses[index] = actual_pose
position_errors[index] = np.linalg.norm(pose[:3] - actual_pose[:3])
orientation_errors[index] = orientation_error_rad(
_quaternion_to_matrix(tuple(actual_pose[3:])),
_quaternion_to_matrix(tuple(pose[3:])),
)
margins[index] = normalized_joint_margin(current, lower, upper)
return ReplayResult(
method=method,
times_s=trajectory.times_s.copy(),
target_poses=trajectory.target_poses.copy(),
actual_poses=actual_poses,
joints=joints,
velocities=velocities,
position_errors_m=position_errors,
orientation_errors_rad=orientation_errors,
joint_margins=margins,
solve_durations_ms=durations,
success=success,
command_limited=command_limited,
)
DLS_DAMPING_CANDIDATES = (0.001, 0.003, 0.01, 0.03, 0.1, 0.3)
def evaluate_methods(
trajectory: EpisodeTrajectory,
urdf_path: Path,
config: dict[str, float],
timing_repeats: int,
) -> tuple[dict[str, ReplayResult], dict[str, MethodSummary], float]:
if timing_repeats < 1:
raise ValueError("timing_repeats must be positive")
dt = float(np.median(np.diff(trajectory.times_s)))
speed = math.radians(config["joint_max_speed"])
acceleration = math.radians(config["joint_max_acc"])
damping_results = []
for damping in DLS_DAMPING_CANDIDATES:
replay = run_replay(
"dls",
DifferentialIkSolver(urdf_path, dt, "dls", damping),
trajectory,
max_speed=speed,
max_acceleration=acceleration,
measure_time=False,
)
damping_results.append(summarize_result(replay, damping))
selected_damping = choose_dls_damping(damping_results)
factories = {
"pinv": lambda: DifferentialIkSolver(urdf_path, dt, "pinv"),
"dls": lambda: DifferentialIkSolver(
urdf_path, dt, "dls", selected_damping
),
"qp": lambda: make_qp_solver(urdf_path, dt, config),
}
results = {}
summaries = {}
for name, factory in factories.items():
run_replay(
name,
factory(),
trajectory,
max_speed=speed,
max_acceleration=acceleration,
)
result = run_replay(
name,
factory(),
trajectory,
max_speed=speed,
max_acceleration=acceleration,
)
timed = []
for _ in range(timing_repeats):
repeated = run_replay(
name,
factory(),
trajectory,
max_speed=speed,
max_acceleration=acceleration,
)
if not np.allclose(
repeated.joints,
result.joints,
atol=1e-10,
rtol=0.0,
):
raise RuntimeError(f"{name} replay is not deterministic")
timed.append(repeated.solve_durations_ms)
timing_matrix = np.stack(timed)
result = replace(
result,
solve_durations_ms=np.mean(timing_matrix, axis=0),
)
results[name] = result
summaries[name] = replace(
summarize_result(
result,
selected_damping if name == "dls" else None,
),
mean_solve_ms=float(np.mean(timing_matrix)),
max_solve_ms=float(np.max(timing_matrix)),
)
return results, summaries, selected_damping
def _save_figure(fig, output_dir: Path, name: str) -> None:
fig.savefig(output_dir / f"{name}.svg", bbox_inches="tight")
fig.savefig(
output_dir / f"{name}.png",
dpi=300,
bbox_inches="tight",
facecolor="white",
)
plt.close(fig)
def _style_axes(axes) -> None:
for axis in np.asarray(axes).flat:
axis.grid(True, alpha=0.2, linewidth=0.6)
axis.spines["top"].set_visible(False)
axis.spines["right"].set_visible(False)
def plot_tracking_error(
results: dict[str, ReplayResult],
output_dir: Path,
) -> None:
fig, axes = plt.subplots(
2, 1, figsize=(7.2, 5.2), sharex=True, constrained_layout=True
)
for name, result in results.items():
label, color, linestyle = METHOD_STYLE[name]
values = (
result.position_errors_m * 1000.0,
np.degrees(result.orientation_errors_rad),
)
for axis, series in zip(axes, values):
axis.plot(
result.times_s,
series,
color=color,
linestyle=linestyle,
linewidth=1.15,
label=label,
)
failed = ~result.success
axis.scatter(
result.times_s[failed],
series[failed],
color=color,
marker="x",
s=10,
linewidths=0.7,
zorder=3,
)
axes[0].set_ylabel("位置误差 (mm)")
axes[1].set_ylabel("姿态误差 (°)")
axes[1].set_xlabel("时间 (s)")
axes[0].legend(frameon=False, ncol=3, loc="upper right")
_style_axes(axes)
_save_figure(fig, output_dir, "figure_2_11_tracking_error")
def plot_joint_constraints(
results: dict[str, ReplayResult],
output_dir: Path,
) -> None:
fig, axes = plt.subplots(
2, 1, figsize=(7.2, 5.2), sharex=True, constrained_layout=True
)
for name, result in results.items():
label, color, linestyle = METHOD_STYLE[name]
speed = np.max(np.abs(np.degrees(result.velocities)), axis=1)
axes[0].plot(
result.times_s,
speed,
color=color,
linestyle=linestyle,
linewidth=1.15,
label=label,
)
axes[1].plot(
result.times_s,
result.joint_margins,
color=color,
linestyle=linestyle,
linewidth=1.15,
label=label,
)
axes[0].axhline(
180.0,
color="#B2182B",
linestyle=":",
linewidth=1.0,
label="速度上限 180°/s",
)
axes[0].set_ylabel("最大关节速度 (°/s)")
axes[1].set_ylabel("最小归一化关节裕度")
axes[1].set_xlabel("时间 (s)")
axes[0].legend(frameon=False, ncol=2, loc="upper right")
_style_axes(axes)
_save_figure(fig, output_dir, "figure_2_12_joint_constraints")
def _annotate_bars(axis, bars) -> None:
for bar in bars:
value = float(bar.get_height())
axis.annotate(
f"{value:.3g}",
(bar.get_x() + bar.get_width() / 2.0, value),
xytext=(0, 3 if value >= 0.0 else -9),
textcoords="offset points",
ha="center",
va="bottom" if value >= 0.0 else "top",
fontsize=6.5,
)
def plot_summary(
summaries: dict[str, MethodSummary],
output_dir: Path,
) -> None:
names = list(METHOD_STYLE)
labels = [METHOD_STYLE[name][0] for name in names]
colors = [METHOD_STYLE[name][1] for name in names]
values = [
[summaries[name].position_rmse_m * 1000.0 for name in names],
[np.degrees(summaries[name].orientation_rmse_rad) for name in names],
[summaries[name].max_joint_speed_deg_s for name in names],
[summaries[name].min_joint_margin for name in names],
[summaries[name].success_rate * 100.0 for name in names],
]
titles = [
"(a) 位置 RMSE (mm)",
"(b) 姿态 RMSE (°)",
"(c) 最大关节速度 (°/s)",
"(d) 最小归一化关节裕度",
"(f) 求解成功率 (%)",
]
fig, axes = plt.subplots(2, 3, figsize=(8.2, 5.4), constrained_layout=True)
flat_axes = axes.flat
for axis, data, title in zip(
[flat_axes[0], flat_axes[1], flat_axes[2], flat_axes[3], flat_axes[5]],
values,
titles,
):
bars = axis.bar(
labels,
data,
color=colors,
edgecolor="black",
linewidth=0.5,
hatch=["//", "\\\\", ".."],
)
axis.set_title(title, fontsize=9)
axis.tick_params(axis="x", labelrotation=18, labelsize=7)
_annotate_bars(axis, bars)
timing_axis = flat_axes[4]
x = np.arange(len(names))
width = 0.36
mean_bars = timing_axis.bar(
x - width / 2.0,
[summaries[name].mean_solve_ms for name in names],
width,
color=colors,
edgecolor="black",
linewidth=0.5,
label="平均",
)
max_bars = timing_axis.bar(
x + width / 2.0,
[summaries[name].max_solve_ms for name in names],
width,
color=colors,
alpha=0.45,
edgecolor="black",
linewidth=0.5,
label="最大",
)
timing_axis.set_title("(e) 单周期求解时间 (ms)", fontsize=9)
timing_axis.set_xticks(x, labels, rotation=18)
timing_axis.tick_params(axis="x", labelsize=7)
timing_axis.legend(frameon=False, fontsize=7)
_annotate_bars(timing_axis, mean_bars)
_annotate_bars(timing_axis, max_bars)
_style_axes(axes)
_save_figure(fig, output_dir, "figure_2_13_summary")
def _write_samples(
path: Path,
results: dict[str, ReplayResult],
) -> None:
pose_names = [
"x", "y", "z", "qx", "qy", "qz", "qw",
]
fields = (
["method", "time_s"]
+ [f"target_{name}" for name in pose_names]
+ [f"actual_{name}" for name in pose_names]
+ ["position_error_m", "orientation_error_rad"]
+ [f"q{index}" for index in range(1, 8)]
+ [f"qd{index}" for index in range(1, 8)]
+ ["joint_margin", "solve_ms", "success", "command_limited"]
)
with path.open("w", encoding="utf-8", newline="") as stream:
writer = csv.writer(stream)
writer.writerow(fields)
for name, result in results.items():
for index, timestamp in enumerate(result.times_s):
writer.writerow(
[name, timestamp]
+ result.target_poses[index].tolist()
+ result.actual_poses[index].tolist()
+ [
result.position_errors_m[index],
result.orientation_errors_rad[index],
]
+ result.joints[index].tolist()
+ result.velocities[index].tolist()
+ [
result.joint_margins[index],
result.solve_durations_ms[index],
bool(result.success[index]),
bool(result.command_limited[index]),
]
)
def _method_list_at_extreme(
summaries: dict[str, MethodSummary],
attribute: str,
*,
maximum: bool,
) -> str:
values = {name: getattr(summary, attribute) for name, summary in summaries.items()}
extreme = (max if maximum else min)(values.values())
return "、".join(
METHOD_STYLE[name][0]
for name, value in values.items()
if math.isclose(value, extreme, rel_tol=1e-9, abs_tol=1e-12)
)
def _write_analysis(
path: Path,
summaries: dict[str, MethodSummary],
selected_damping: float,
source_path: Path,
sample_rate_hz: float,
) -> None:
rows = []
for name in METHOD_STYLE:
summary = summaries[name]
rows.append(
"| "
+ " | ".join(
[
METHOD_STYLE[name][0],
f"{summary.position_rmse_m:.6f}",
f"{summary.orientation_rmse_rad:.6f}",
f"{summary.max_joint_speed_deg_s:.3f}",
f"{summary.min_joint_margin:.4f}",
f"{summary.mean_solve_ms:.3f} / {summary.max_solve_ms:.3f}",
f"{summary.success_rate * 100.0:.2f}%",
]
)
+ " |"
)
position_best = _method_list_at_extreme(
summaries, "position_rmse_m", maximum=False
)
orientation_best = _method_list_at_extreme(
summaries, "orientation_rmse_rad", maximum=False
)
success_best = _method_list_at_extreme(
summaries, "success_rate", maximum=True
)
text = f"""# 2.3.4 三种逆运动学方法对比补充分析
本结果是基于真实遥操作目标轨迹的离线运动学对比,不代表真机闭环实验。数据来自
`{source_path}`,目标位姿以 {sample_rate_hz:.1f} Hz 重采样;三种方法使用同一初始
关节状态、同一 URDF、相同收敛阈值和共同的输出速度/加速度限制。
DLS 扫描的固定阻尼候选为 {", ".join(map(str, DLS_DAMPING_CANDIDATES))},本轨迹选定
`{selected_damping:g}`。该参数是在当前评价轨迹上选优,不应解释为跨轨迹最优参数。
| 方法 | 位置 RMSE (m) | 姿态 RMSE (rad) | 最大关节速度 (°/s) | 最小归一化裕度 | 平均/最大求解时间 (ms) | 成功率 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
{chr(10).join(rows)}
图 2-11 三种逆运动学方法的末端位置与姿态跟踪误差。曲线来自统一时间轴,叉号表示该
周期数值求解失败并保持上一安全关节状态。
图 2-12 三种逆运动学方法的最大关节速度与最小归一化关节安全裕度。红色虚线表示
180°/s 输出速度上限,裕度越大表示离关节位置边界越远。
图 2-13 三种逆运动学方法的综合性能对比,包括误差、关节运动、求解时间和成功率。
按本次单轨迹数值比较,位置 RMSE 最低的方法为{position_best},姿态 RMSE 最低的方法为
{orientation_best},成功率最高的方法为{success_best}。这些结论只描述本次离线复放,
未进行统计显著性检验。
当前优化 QP 除六维末端主任务外,还保留项目中的 J3 参考软任务、J4 硬下界与软缓冲,
以及按最小奇异值动态激活的六维可操作度任务;伪逆和 DLS 基线不包含这些附加任务。
"""
path.write_text(text, encoding="utf-8")
def write_outputs(
output_dir: Path,
results: dict[str, ReplayResult],
summaries: dict[str, MethodSummary],
*,
selected_damping: float,
source_path: Path,
git_commit: str,
) -> None:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
sample_rate_hz = 1.0 / float(
np.median(np.diff(next(iter(results.values())).times_s))
)
_write_samples(output_dir / "samples.csv", results)
payload = {
"source_episode": str(source_path),
"git_commit": git_commit,
"sample_rate_hz": sample_rate_hz,
"selected_dls_damping": selected_damping,
"methods": {
name: asdict(summary) for name, summary in summaries.items()
},
}
(output_dir / "summary.json").write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
plot_tracking_error(results, output_dir)
plot_joint_constraints(results, output_dir)
plot_summary(summaries, output_dir)
_write_analysis(
output_dir / "analysis_2.3.4.md",
summaries,
selected_damping,
source_path,
sample_rate_hz,
)