Add project sources for Mujoco LinkerHand ROS2
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""O6 单指位置控制:从最小扫到最大,记录并绘制 角度/速度/力矩。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import mujoco
|
||||
import mujoco.viewer
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_XML = (
|
||||
ROOT
|
||||
/ "src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
|
||||
/ "urdf/O6/linker_hand_o6_left/linker_hand_o6_left.xml"
|
||||
)
|
||||
|
||||
# 四指:根节 mcp_pitch + 远端 dip(mimic 0.89)
|
||||
# ROS 6 维索引:0拇指弯 1拇指横摆 2食指 3中指 4无名指 5小指
|
||||
FINGER_CFG = {
|
||||
"index": {
|
||||
"mcp": "index_mcp_pitch",
|
||||
"dip": "index_dip",
|
||||
"mcp_act": "index_mcp_pitch_pos",
|
||||
"dip_act": "index_dip_pos",
|
||||
"mimic": 0.89,
|
||||
"ros_idx": 2,
|
||||
},
|
||||
"middle": {
|
||||
"mcp": "middle_mcp_pitch",
|
||||
"dip": "middle_dip",
|
||||
"mcp_act": "middle_mcp_pitch_pos",
|
||||
"dip_act": "middle_dip_pos",
|
||||
"mimic": 0.89,
|
||||
"ros_idx": 3,
|
||||
},
|
||||
"ring": {
|
||||
"mcp": "ring_mcp_pitch",
|
||||
"dip": "ring_dip",
|
||||
"mcp_act": "ring_mcp_pitch_pos",
|
||||
"dip_act": "ring_dip_pos",
|
||||
"mimic": 0.89,
|
||||
"ros_idx": 4,
|
||||
},
|
||||
"pinky": {
|
||||
"mcp": "pinky_mcp_pitch",
|
||||
"dip": "pinky_dip",
|
||||
"mcp_act": "pinky_mcp_pitch_pos",
|
||||
"dip_act": "pinky_dip_pos",
|
||||
"mimic": 0.89,
|
||||
"ros_idx": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--xml", type=Path, default=DEFAULT_XML)
|
||||
parser.add_argument("--hand", choices=["left", "right"], default="left")
|
||||
parser.add_argument(
|
||||
"--finger",
|
||||
choices=list(FINGER_CFG.keys()),
|
||||
default="middle",
|
||||
help="要扫的手指(默认 middle)",
|
||||
)
|
||||
parser.add_argument("--duration", type=float, default=8.0, help="单向扫过时长 (s)")
|
||||
parser.add_argument("--out", type=Path, default=None)
|
||||
parser.add_argument("--viewer", action="store_true", help="打开 MuJoCo viewer")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = FINGER_CFG[args.finger]
|
||||
if args.out is None:
|
||||
args.out = ROOT / f"reports/O6_{args.finger}_sweep"
|
||||
|
||||
if args.hand == "right":
|
||||
args.xml = (
|
||||
ROOT
|
||||
/ "src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
|
||||
/ "urdf/O6/linker_hand_o6_right/linker_hand_o6_right.xml"
|
||||
)
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
model = mujoco.MjModel.from_xml_path(str(args.xml))
|
||||
data = mujoco.MjData(model)
|
||||
|
||||
mcp_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, cfg["mcp"])
|
||||
dip_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, cfg["dip"])
|
||||
mcp_act = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, cfg["mcp_act"])
|
||||
dip_act = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, cfg["dip_act"])
|
||||
if min(mcp_jnt, dip_jnt, mcp_act, dip_act) < 0:
|
||||
raise RuntimeError(f"找不到 {args.finger} joint/actuator,请检查 MJCF")
|
||||
|
||||
mcp_qadr = int(model.jnt_qposadr[mcp_jnt])
|
||||
mcp_dadr = int(model.jnt_dofadr[mcp_jnt])
|
||||
mcp_lo, mcp_hi = map(float, model.jnt_range[mcp_jnt])
|
||||
dip_lo, dip_hi = map(float, model.jnt_range[dip_jnt])
|
||||
mimic = float(cfg["mimic"])
|
||||
dt = float(model.opt.timestep)
|
||||
|
||||
half = args.duration
|
||||
total_t = 2.0 * half
|
||||
n_steps = int(total_t / dt)
|
||||
t_arr = np.arange(n_steps) * dt
|
||||
|
||||
def cmd_at(t: float) -> float:
|
||||
if t <= half:
|
||||
return mcp_lo + (mcp_hi - mcp_lo) * (t / half)
|
||||
u = (t - half) / half
|
||||
return mcp_hi + (mcp_lo - mcp_hi) * u
|
||||
|
||||
data.ctrl[:] = 0.0
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
logs = {
|
||||
"t": [],
|
||||
"cmd_mcp": [],
|
||||
"q_mcp": [],
|
||||
"v_mcp": [],
|
||||
"tau_mcp": [],
|
||||
"q_dip": [],
|
||||
"tau_dip": [],
|
||||
}
|
||||
|
||||
viewer = None
|
||||
if args.viewer:
|
||||
viewer = mujoco.viewer.launch_passive(model, data)
|
||||
|
||||
for i in range(n_steps):
|
||||
t = t_arr[i]
|
||||
q_cmd = cmd_at(t)
|
||||
dip_cmd = float(np.clip(q_cmd * mimic, dip_lo, dip_hi))
|
||||
data.ctrl[mcp_act] = q_cmd
|
||||
data.ctrl[dip_act] = dip_cmd
|
||||
mujoco.mj_step(model, data)
|
||||
|
||||
logs["t"].append(t)
|
||||
logs["cmd_mcp"].append(q_cmd)
|
||||
logs["q_mcp"].append(float(data.qpos[mcp_qadr]))
|
||||
logs["v_mcp"].append(float(data.qvel[mcp_dadr]))
|
||||
logs["tau_mcp"].append(float(data.actuator_force[mcp_act]))
|
||||
logs["q_dip"].append(float(data.qpos[int(model.jnt_qposadr[dip_jnt])]))
|
||||
logs["tau_dip"].append(float(data.actuator_force[dip_act]))
|
||||
|
||||
if viewer is not None:
|
||||
viewer.sync()
|
||||
|
||||
if viewer is not None:
|
||||
viewer.close()
|
||||
|
||||
for k in logs:
|
||||
logs[k] = np.asarray(logs[k], dtype=float)
|
||||
|
||||
csv_path = args.out / f"{args.finger}_sweep_{args.hand}.csv"
|
||||
header = "t_s,cmd_mcp_rad,q_mcp_rad,v_mcp_rad_s,tau_mcp_Nm,q_dip_rad,tau_dip_Nm"
|
||||
np.savetxt(
|
||||
csv_path,
|
||||
np.column_stack(
|
||||
[
|
||||
logs["t"],
|
||||
logs["cmd_mcp"],
|
||||
logs["q_mcp"],
|
||||
logs["v_mcp"],
|
||||
logs["tau_mcp"],
|
||||
logs["q_dip"],
|
||||
logs["tau_dip"],
|
||||
]
|
||||
),
|
||||
delimiter=",",
|
||||
header=header,
|
||||
comments="",
|
||||
)
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
|
||||
fig.suptitle(
|
||||
f"O6 {args.hand} {cfg['mcp']} position sweep "
|
||||
f"range=[{mcp_lo:.2f}, {mcp_hi:.2f}] rad",
|
||||
fontsize=13,
|
||||
)
|
||||
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["cmd_mcp"]), "k--", lw=1.2, label="cmd")
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["q_mcp"]), "C0", lw=1.8, label="q")
|
||||
axes[0].set_ylabel("angle (deg)")
|
||||
axes[0].legend(loc="best")
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
axes[1].plot(logs["t"], np.rad2deg(logs["v_mcp"]), "C1", lw=1.8, label="v")
|
||||
axes[1].set_ylabel("velocity (deg/s)")
|
||||
axes[1].legend(loc="best")
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
axes[2].plot(logs["t"], logs["tau_mcp"], "C3", lw=1.8, label="tau (actuator_force)")
|
||||
axes[2].set_ylabel("torque (N·m)")
|
||||
axes[2].set_xlabel("time (s)")
|
||||
axes[2].legend(loc="best")
|
||||
axes[2].grid(True, alpha=0.3)
|
||||
|
||||
fig.tight_layout()
|
||||
png_path = args.out / f"{args.finger}_sweep_{args.hand}_qvt.png"
|
||||
fig.savefig(png_path, dpi=140)
|
||||
plt.close(fig)
|
||||
|
||||
# ROS 示例:只动该指
|
||||
pos = [255.0] * 6
|
||||
pos_open = pos.copy()
|
||||
pos_close = pos.copy()
|
||||
pos_close[cfg["ros_idx"]] = 0.0
|
||||
|
||||
print("=" * 60)
|
||||
print(f"finger: {args.finger} joint: {cfg['mcp']}")
|
||||
print(f"model: {args.xml}")
|
||||
print(
|
||||
f"range: [{mcp_lo:.3f}, {mcp_hi:.3f}] rad "
|
||||
f"= [{np.rad2deg(mcp_lo):.1f}, {np.rad2deg(mcp_hi):.1f}] deg"
|
||||
)
|
||||
print(f"mimic: dip = mcp * {mimic}")
|
||||
print(f"sweep: {mcp_lo:.3f} -> {mcp_hi:.3f} -> {mcp_lo:.3f} total {total_t:.1f}s")
|
||||
print(f"CSV: {csv_path}")
|
||||
print(f"plot: {png_path}")
|
||||
print(
|
||||
"peak |q|={:.3f} rad |v|={:.3f} rad/s |tau|={:.3f} N·m".format(
|
||||
np.max(np.abs(logs["q_mcp"])),
|
||||
np.max(np.abs(logs["v_mcp"])),
|
||||
np.max(np.abs(logs["tau_mcp"])),
|
||||
)
|
||||
)
|
||||
print("=" * 60)
|
||||
print(f"ROS 只弯{args.finger}(索引 {cfg['ros_idx']})握紧示例 position:")
|
||||
print(f" {pos_close}")
|
||||
print(f"张开示例: {pos_open}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+241
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env python3
|
||||
"""手部曲线录制 + 画图(单文件,仿真/真机通用)。
|
||||
|
||||
依赖(两边都要):
|
||||
- 已 source ROS2(jazzy)
|
||||
- pip: numpy matplotlib
|
||||
- ros: rclpy sensor_msgs std_msgs
|
||||
|
||||
用法示例:
|
||||
python3 hand_curve_recorder_standalone.py --hand left --channel 3 --label sim
|
||||
# 对端发 /cb_left_hand_control_cmd
|
||||
# Ctrl+C → 当前目录下 reports/hand_curves/ 生成 csv + png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
CHANNEL_NAMES = [
|
||||
"thumb_bend",
|
||||
"thumb_yaw",
|
||||
"index",
|
||||
"middle",
|
||||
"ring",
|
||||
"pinky",
|
||||
]
|
||||
|
||||
|
||||
def _pad(seq, n, fill=float("nan")):
|
||||
out = [fill] * n
|
||||
for i, v in enumerate(list(seq)[:n]):
|
||||
out[i] = float(v)
|
||||
return out
|
||||
|
||||
|
||||
def plot_curves(rows, ch, ch_name, label, have_state, have_current, png_path):
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
t = np.array([r["t_s"] for r in rows], dtype=float)
|
||||
cmd = np.array([r["cmd"][ch] for r in rows], dtype=float)
|
||||
joint = np.array([r["joint"][ch] for r in rows], dtype=float)
|
||||
current = np.array([r["current"][ch] for r in rows], dtype=float)
|
||||
|
||||
y = joint.copy()
|
||||
if np.all(np.isnan(y)):
|
||||
y = cmd.copy()
|
||||
v = np.gradient(y, t)
|
||||
if len(v) >= 5:
|
||||
v = np.convolve(v, np.ones(5) / 5.0, mode="same")
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
|
||||
src = "joint" if have_state else "cmd(as joint)"
|
||||
fig.suptitle(
|
||||
f"recorder ch={ch}:{ch_name} label={label} src={src}",
|
||||
fontsize=12,
|
||||
)
|
||||
|
||||
axes[0].plot(t, cmd, "k--", lw=1.2, label="command_u8")
|
||||
axes[0].plot(t, joint, "C0", lw=1.6, label="joint_u8")
|
||||
axes[0].set_ylabel("position (0-255)")
|
||||
axes[0].legend(loc="best")
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
axes[1].plot(t, v, "C1", lw=1.6, label="d(joint)/dt")
|
||||
axes[1].set_ylabel("velocity (u8/s)")
|
||||
axes[1].legend(loc="best")
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
if have_current:
|
||||
axes[2].plot(t, current, "C3", lw=1.6, label="current/effort")
|
||||
else:
|
||||
axes[2].text(
|
||||
0.5,
|
||||
0.5,
|
||||
"no current / state.effort",
|
||||
ha="center",
|
||||
va="center",
|
||||
transform=axes[2].transAxes,
|
||||
)
|
||||
axes[2].set_ylabel("current / effort")
|
||||
axes[2].set_xlabel("time (s)")
|
||||
axes[2].legend(loc="best")
|
||||
axes[2].grid(True, alpha=0.3)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(png_path, dpi=140)
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Hand curve recorder (ROS2, single file)")
|
||||
parser.add_argument("--hand", default="left", choices=["left", "right"])
|
||||
parser.add_argument("--channel", type=int, default=3, help="0拇指弯..3中指..5小指")
|
||||
parser.add_argument("--label", default="run", help="sim / real / ...")
|
||||
parser.add_argument("--hz", type=float, default=100.0)
|
||||
parser.add_argument("--n", type=int, default=6)
|
||||
parser.add_argument("--out", default="reports/hand_curves")
|
||||
parser.add_argument("--cmd-topic", default="", help="空则 /cb_{hand}_hand_control_cmd")
|
||||
parser.add_argument("--state-topic", default="", help="空则 /cb_{hand}_hand_state")
|
||||
parser.add_argument(
|
||||
"--current-topic",
|
||||
default="",
|
||||
help="可选 Float32MultiArray;空则用 state.effort",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cmd-as-joint",
|
||||
action="store_true",
|
||||
help="没有 state 时不要用 cmd 代替 joint",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.environ.setdefault("MPLCONFIGDIR", str(Path.cwd() / ".mplconfig"))
|
||||
Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
from std_msgs.msg import Float32MultiArray
|
||||
|
||||
cmd_topic = args.cmd_topic or f"/cb_{args.hand}_hand_control_cmd"
|
||||
state_topic = args.state_topic or f"/cb_{args.hand}_hand_state"
|
||||
out_dir = Path(args.out).expanduser()
|
||||
if not out_dir.is_absolute():
|
||||
out_dir = Path.cwd() / out_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
n = args.n
|
||||
ch = args.channel
|
||||
use_cmd_as_joint = not args.no_cmd_as_joint
|
||||
|
||||
class Recorder(Node):
|
||||
def __init__(self):
|
||||
super().__init__("hand_curve_recorder_py")
|
||||
self.cmd = [float("nan")] * n
|
||||
self.joint = [float("nan")] * n
|
||||
self.current = [float("nan")] * n
|
||||
self.have_state = False
|
||||
self.have_current = False
|
||||
self.rows = []
|
||||
self.t0 = time.perf_counter()
|
||||
|
||||
self.create_subscription(JointState, cmd_topic, self.on_cmd, 50)
|
||||
self.create_subscription(JointState, state_topic, self.on_state, 50)
|
||||
if args.current_topic:
|
||||
self.create_subscription(
|
||||
Float32MultiArray, args.current_topic, self.on_current, 50
|
||||
)
|
||||
self.create_timer(1.0 / max(1.0, args.hz), self.on_timer)
|
||||
self.get_logger().info(
|
||||
f"recording | cmd={cmd_topic} | state={state_topic} | "
|
||||
f"current={args.current_topic or 'state.effort'} | "
|
||||
f"ch={ch} | out={out_dir}"
|
||||
)
|
||||
self.get_logger().info("Ctrl+C to stop and plot")
|
||||
|
||||
def on_cmd(self, msg: JointState):
|
||||
self.cmd = _pad(msg.position, n)
|
||||
|
||||
def on_state(self, msg: JointState):
|
||||
self.have_state = True
|
||||
self.joint = _pad(msg.position, n)
|
||||
if msg.effort:
|
||||
self.have_current = True
|
||||
self.current = _pad(msg.effort, n)
|
||||
|
||||
def on_current(self, msg: Float32MultiArray):
|
||||
self.have_current = True
|
||||
self.current = _pad(msg.data, n)
|
||||
|
||||
def on_timer(self):
|
||||
joint = list(self.joint)
|
||||
if (not self.have_state) and use_cmd_as_joint:
|
||||
joint = list(self.cmd)
|
||||
self.rows.append(
|
||||
{
|
||||
"t_s": time.perf_counter() - self.t0,
|
||||
"cmd": list(self.cmd),
|
||||
"joint": joint,
|
||||
"current": list(self.current),
|
||||
}
|
||||
)
|
||||
|
||||
rclpy.init()
|
||||
node = Recorder()
|
||||
try:
|
||||
rclpy.spin(node)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
rows = node.rows
|
||||
have_state = node.have_state
|
||||
have_current = node.have_current
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
if not rows:
|
||||
print("no samples recorded")
|
||||
return
|
||||
|
||||
ch_name = CHANNEL_NAMES[ch] if 0 <= ch < len(CHANNEL_NAMES) else f"ch{ch}"
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
stem = f"{args.label}_{ch_name}_{stamp}"
|
||||
csv_path = out_dir / f"{stem}.csv"
|
||||
png_path = out_dir / f"{stem}_qvt.png"
|
||||
|
||||
with csv_path.open("w", newline="") as f:
|
||||
w = csv.writer(f)
|
||||
header = ["t_s"]
|
||||
for i in range(n):
|
||||
name = CHANNEL_NAMES[i] if i < len(CHANNEL_NAMES) else f"ch{i}"
|
||||
header += [f"cmd_{name}", f"joint_{name}", f"current_{name}"]
|
||||
w.writerow(header)
|
||||
for row in rows:
|
||||
line = [f"{row['t_s']:.6f}"]
|
||||
for i in range(n):
|
||||
line += [
|
||||
f"{row['cmd'][i]:.6f}",
|
||||
f"{row['joint'][i]:.6f}",
|
||||
f"{row['current'][i]:.6f}",
|
||||
]
|
||||
w.writerow(line)
|
||||
|
||||
plot_curves(rows, ch, ch_name, args.label, have_state, have_current, png_path)
|
||||
print(f"CSV -> {csv_path} ({len(rows)} samples)")
|
||||
print(f"plot -> {png_path}")
|
||||
print(f"state={'yes' if have_state else 'no'} current={'yes' if have_current else 'no'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""O6 中指 step 控制指令(真机 / 仿真共用,时序完全一致)。
|
||||
|
||||
时序(默认):
|
||||
pre_wait=3s 保持张开
|
||||
hold=3s 中指握紧 (middle u8=0)
|
||||
hold=3s 中指张开 (middle u8=255)
|
||||
|
||||
用法(单独发令,配合录制脚本):
|
||||
export ROS_DOMAIN_ID=31
|
||||
python3 tools/middle_step_cmd_pub.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import time
|
||||
|
||||
# 与 run_middle_step_experiment.py 共用,改这里两边一起变
|
||||
DEFAULT_PRE_WAIT = 3.0
|
||||
DEFAULT_HOLD = 3.0
|
||||
DEFAULT_RATE_HZ = 10.0
|
||||
FINGER_ROS_IDX = {"index": 2, "middle": 3, "ring": 4, "pinky": 5}
|
||||
|
||||
|
||||
def hold_finger_pose(pub, hand: str, finger: str, u8: float, duration: float, rate_hz: float):
|
||||
from sensor_msgs.msg import JointState
|
||||
|
||||
idx = FINGER_ROS_IDX[finger]
|
||||
end = time.perf_counter() + duration
|
||||
dt = 1.0 / max(1.0, rate_hz)
|
||||
while time.perf_counter() < end:
|
||||
msg = JointState()
|
||||
msg.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
|
||||
msg.position[idx] = float(u8)
|
||||
pub.publish(msg)
|
||||
time.sleep(dt)
|
||||
|
||||
|
||||
def run_step_sequence(
|
||||
node,
|
||||
*,
|
||||
hand: str = "left",
|
||||
finger: str = "middle",
|
||||
pre_wait: float = DEFAULT_PRE_WAIT,
|
||||
hold: float = DEFAULT_HOLD,
|
||||
rate_hz: float = DEFAULT_RATE_HZ,
|
||||
cmd_topic: str | None = None,
|
||||
):
|
||||
from sensor_msgs.msg import JointState
|
||||
|
||||
topic = cmd_topic or f"/cb_{hand}_hand_control_cmd"
|
||||
pub = node.create_publisher(JointState, topic, 10)
|
||||
time.sleep(0.3)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
node.get_logger().info(
|
||||
f"step cmd | finger={finger} | pre={pre_wait}s hold={hold}s | topic={topic}"
|
||||
)
|
||||
|
||||
# 阶段0:保持张开
|
||||
node.get_logger().info(f"[t={time.perf_counter()-t0:.2f}s] baseline open (255)")
|
||||
hold_finger_pose(pub, hand, finger, 255.0, pre_wait, rate_hz)
|
||||
|
||||
# 阶段1:握紧
|
||||
node.get_logger().info(f"[t={time.perf_counter()-t0:.2f}s] CLOSE middle u8=0")
|
||||
hold_finger_pose(pub, hand, finger, 0.0, hold, rate_hz)
|
||||
|
||||
# 阶段2:张开
|
||||
node.get_logger().info(f"[t={time.perf_counter()-t0:.2f}s] OPEN middle u8=255")
|
||||
hold_finger_pose(pub, hand, finger, 255.0, hold, rate_hz)
|
||||
|
||||
node.get_logger().info(f"[t={time.perf_counter()-t0:.2f}s] sequence done")
|
||||
return t0
|
||||
|
||||
|
||||
def total_duration(pre_wait: float, hold: float, record_after: float = 0.0) -> float:
|
||||
return pre_wait + hold + hold + record_after + 1.0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="O6 middle finger step command publisher")
|
||||
parser.add_argument("--hand", default="left", choices=["left", "right"])
|
||||
parser.add_argument("--finger", default="middle", choices=list(FINGER_ROS_IDX))
|
||||
parser.add_argument("--pre-wait", type=float, default=DEFAULT_PRE_WAIT)
|
||||
parser.add_argument("--hold", type=float, default=DEFAULT_HOLD)
|
||||
parser.add_argument("--rate", type=float, default=DEFAULT_RATE_HZ)
|
||||
parser.add_argument("--cmd-topic", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
|
||||
rclpy.init()
|
||||
node = Node("middle_step_cmd_pub")
|
||||
try:
|
||||
run_step_sequence(
|
||||
node,
|
||||
hand=args.hand,
|
||||
finger=args.finger,
|
||||
pre_wait=args.pre_wait,
|
||||
hold=args.hold,
|
||||
rate_hz=args.rate,
|
||||
cmd_topic=args.cmd_topic or None,
|
||||
)
|
||||
finally:
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""真机 vs 仿真 q/v/力矩(或电流) 对比图。
|
||||
|
||||
时间原点 = 首次收到「握紧」控制指令 (cmd_u8: 255→0) 的时刻 − 1 s。
|
||||
|
||||
用法:
|
||||
python3 tools/plot_real_sim_compare.py \\
|
||||
reports/O6_middle_ros/ros_real_middle_left_20260716_172221.csv \\
|
||||
reports/O6_middle_ros/ros_sim_middle_left_20260716_114515.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
def load_csv(path: Path):
|
||||
hdr = path.read_text().splitlines()[0].strip().split(",")
|
||||
data = np.loadtxt(path, delimiter=",", skiprows=1)
|
||||
col = {name: i for i, name in enumerate(hdr)}
|
||||
return hdr, col, data
|
||||
|
||||
|
||||
def first_close_cmd_time(t: np.ndarray, cmd_u8: np.ndarray) -> float:
|
||||
"""首次 cmd 从 255 阶跃到 0(握紧)的时刻。"""
|
||||
diff = np.diff(cmd_u8)
|
||||
idx = np.where(diff < -100)[0]
|
||||
if len(idx) == 0:
|
||||
raise ValueError("未检测到 cmd 255→0 阶跃")
|
||||
return float(t[idx[0] + 1])
|
||||
|
||||
|
||||
def align_series(t, *arrays, t_cmd: float, t_pre: float = 1.0):
|
||||
t0 = t_cmd - t_pre
|
||||
t_rel = t - t0
|
||||
return (t_rel, *arrays)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("real_csv", type=Path)
|
||||
parser.add_argument("sim_csv", type=Path)
|
||||
parser.add_argument("-o", "--out", type=Path, default=None)
|
||||
parser.add_argument("--t-pre", type=float, default=1.0)
|
||||
parser.add_argument("--t-max", type=float, default=8.0, help="相对原点后的最大时间(s)")
|
||||
args = parser.parse_args()
|
||||
|
||||
_, cr, dr = load_csv(args.real_csv)
|
||||
_, cs, ds = load_csv(args.sim_csv)
|
||||
|
||||
t_r, cmd_r = dr[:, cr["t_s"]], dr[:, cr["cmd_u8"]]
|
||||
t_s, cmd_s = ds[:, cs["t_s"]], ds[:, cs["cmd_u8"]]
|
||||
|
||||
t_cmd_r = first_close_cmd_time(t_r, cmd_r)
|
||||
t_cmd_s = first_close_cmd_time(t_s, cmd_s)
|
||||
|
||||
q_r = dr[:, cr["q_rad"]]
|
||||
cmd_rad_r = dr[:, cr["cmd_rad"]]
|
||||
v_r = dr[:, cr["v_rad_s"]]
|
||||
q_s = ds[:, cs["q_rad"]]
|
||||
cmd_rad_s = ds[:, cs["cmd_rad"]]
|
||||
v_s = ds[:, cs["v_rad_s"]]
|
||||
|
||||
cur_r = dr[:, cr["current"]] if "current" in cr else np.full_like(q_r, np.nan)
|
||||
tau_s = ds[:, cs["tau_Nm"]] if "tau_Nm" in cs else np.full_like(q_s, np.nan)
|
||||
|
||||
tr, qr, cmdr, vr, cur_r = align_series(
|
||||
t_r, q_r, cmd_rad_r, v_r, cur_r, t_cmd=t_cmd_r, t_pre=args.t_pre
|
||||
)
|
||||
ts, qs, cmds, vs, tau_s = align_series(
|
||||
t_s, q_s, cmd_rad_s, v_s, tau_s, t_cmd=t_cmd_s, t_pre=args.t_pre
|
||||
)
|
||||
|
||||
mask_r = (tr >= -0.05) & (tr <= args.t_max)
|
||||
mask_s = (ts >= -0.05) & (ts <= args.t_max)
|
||||
|
||||
out = args.out
|
||||
if out is None:
|
||||
out = args.real_csv.parent / "ros_real_vs_sim_middle_left_compare_qvt.png"
|
||||
|
||||
import os
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
os.environ.setdefault("MPLCONFIGDIR", str(root / ".mplconfig"))
|
||||
Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(11, 9), sharex=True)
|
||||
fig.suptitle(
|
||||
"O6 middle_mcp_pitch real vs sim\n"
|
||||
f"t=0 = {args.t_pre:.0f}s before first close command (cmd_u8: 255->0)",
|
||||
fontsize=12,
|
||||
)
|
||||
|
||||
# --- angle ---
|
||||
ax = axes[0]
|
||||
# 控制指令:真机/仿真时序一致,只画一条黑色虚线
|
||||
cmd_t = ts[mask_s] if np.any(mask_s) else tr[mask_r]
|
||||
cmd_y = np.rad2deg(cmds[mask_s]) if np.any(mask_s) else np.rad2deg(cmdr[mask_r])
|
||||
ax.plot(cmd_t, cmd_y, color="black", ls="--", lw=1.2, label="cmd")
|
||||
ax.plot(ts[mask_s], np.rad2deg(qs[mask_s]), color="#1f77b4", lw=1.8, label="sim q")
|
||||
ax.plot(tr[mask_r], np.rad2deg(qr[mask_r]), color="#ff7f0e", lw=1.8, label="real q")
|
||||
ax.axvline(1.0, color="gray", ls=":", lw=1.0, alpha=0.8)
|
||||
ax.set_ylabel("angle (deg)")
|
||||
ax.legend(loc="best", fontsize=9)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# --- velocity ---
|
||||
ax = axes[1]
|
||||
ax.plot(ts[mask_s], np.rad2deg(vs[mask_s]), color="#1f77b4", lw=1.8, label="sim v")
|
||||
ax.plot(tr[mask_r], np.rad2deg(vr[mask_r]), color="#ff7f0e", lw=1.8, label="real v")
|
||||
ax.axvline(1.0, color="gray", ls=":", lw=1.0, alpha=0.8)
|
||||
ax.set_ylabel("velocity (deg/s)")
|
||||
ax.legend(loc="best")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# --- effort: sim tau vs real current ---
|
||||
ax = axes[2]
|
||||
ax.plot(ts[mask_s], tau_s[mask_s], color="#1f77b4", lw=1.8, label="sim τ (N·m)")
|
||||
if np.any(np.isfinite(cur_r[mask_r])):
|
||||
ax.plot(tr[mask_r], cur_r[mask_r], color="#ff7f0e", lw=1.8, label="real current")
|
||||
ax.set_ylabel("τ (N·m) / current")
|
||||
else:
|
||||
ax.plot([], [], color="#ff7f0e", label="real current (no data)")
|
||||
ax.set_ylabel("torque (N·m)")
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
"real current not recorded",
|
||||
transform=ax.transAxes,
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=10,
|
||||
color="#666",
|
||||
)
|
||||
ax.axvline(1.0, color="gray", ls=":", lw=1.0, alpha=0.8)
|
||||
ax.set_xlabel("time (s) [t=0: 1s before first close cmd]")
|
||||
ax.legend(loc="best")
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
fig.tight_layout()
|
||||
fig.savefig(out, dpi=150)
|
||||
plt.close(fig)
|
||||
|
||||
print(f"plot -> {out}")
|
||||
print(f"real cmd↓ at abs t={t_cmd_r:.3f}s -> origin t0={t_cmd_r - args.t_pre:.3f}s")
|
||||
print(f"sim cmd↓ at abs t={t_cmd_s:.3f}s -> origin t0={t_cmd_s - args.t_pre:.3f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+339
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ROS 控制指令 → 本地 MuJoCo → 记录并绘制 角度/速度/力矩。
|
||||
|
||||
订 /cb_<hand>_hand_control_cmd(0~255),用与仿真节点相同的 O6 映射写 ctrl,
|
||||
从 MuJoCo 读 q / v / actuator_force,Ctrl+C 存 CSV + 三曲线图。
|
||||
|
||||
用法:
|
||||
# 终端1(可先不开 run_sim.sh,本脚本自带 MuJoCo)
|
||||
python3 tools/ros_mujoco_record_qvt.py --hand left --finger middle --viewer
|
||||
|
||||
# 终端2 发 ROS 指令,或加 --self-sweep 自动发中指慢扫
|
||||
python3 tools/ros_mujoco_record_qvt.py --hand left --finger middle --viewer --self-sweep
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG = (
|
||||
ROOT
|
||||
/ "src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
|
||||
)
|
||||
sys.path.insert(0, str(PKG.parent))
|
||||
|
||||
from linker_hand_mujoco_ros2.utils.mapping import ( # noqa: E402
|
||||
O6_JOINT_MAP,
|
||||
O6_MIMIC,
|
||||
apply_mimic,
|
||||
range_to_arc_left,
|
||||
range_to_arc_right,
|
||||
)
|
||||
|
||||
FINGER_CFG = {
|
||||
"index": {
|
||||
"mcp": "index_mcp_pitch",
|
||||
"mcp_act": "index_mcp_pitch_pos",
|
||||
"ros_idx": 2,
|
||||
},
|
||||
"middle": {
|
||||
"mcp": "middle_mcp_pitch",
|
||||
"mcp_act": "middle_mcp_pitch_pos",
|
||||
"ros_idx": 3,
|
||||
},
|
||||
"ring": {
|
||||
"mcp": "ring_mcp_pitch",
|
||||
"mcp_act": "ring_mcp_pitch_pos",
|
||||
"ros_idx": 4,
|
||||
},
|
||||
"pinky": {
|
||||
"mcp": "pinky_mcp_pitch",
|
||||
"mcp_act": "pinky_mcp_pitch_pos",
|
||||
"ros_idx": 5,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def map_position_array(position, joint_map):
|
||||
mapped = [0.0] * len(joint_map)
|
||||
for target_idx, source_idx in joint_map.items():
|
||||
if source_idx < len(position):
|
||||
mapped[target_idx] = position[source_idx]
|
||||
return mapped
|
||||
|
||||
|
||||
def u8_to_ctrl(position_u8, hand_type: str):
|
||||
if hand_type == "left":
|
||||
tmp = range_to_arc_left(position_u8, "O6")
|
||||
else:
|
||||
tmp = range_to_arc_right(position_u8, "O6")
|
||||
res = map_position_array(tmp, O6_JOINT_MAP)
|
||||
from linker_hand_mujoco_ros2.utils.mapping import O6_JOINT_ARC
|
||||
|
||||
res = apply_mimic(res, O6_MIMIC, O6_JOINT_ARC)
|
||||
return res
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--hand", choices=["left", "right"], default="left")
|
||||
parser.add_argument("--finger", choices=list(FINGER_CFG.keys()), default="middle")
|
||||
parser.add_argument("--cmd-topic", default="")
|
||||
parser.add_argument("--out", type=Path, default=None)
|
||||
parser.add_argument("--viewer", action="store_true")
|
||||
parser.add_argument(
|
||||
"--self-sweep",
|
||||
action="store_true",
|
||||
help="本进程自动往控制话题发 255→0→255 慢扫(方便自测)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--exit-after-sweep",
|
||||
action="store_true",
|
||||
help="配合 --self-sweep:扫完自动结束并画图",
|
||||
)
|
||||
parser.add_argument("--duration", type=float, default=8.0, help="self-sweep 单向时长")
|
||||
parser.add_argument(
|
||||
"--record-duration",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help=">0 时录满该秒数自动结束(配合 middle_step_cmd_pub)",
|
||||
)
|
||||
parser.add_argument("--label", default="ros_sim")
|
||||
args = parser.parse_args()
|
||||
if args.self_sweep and not args.exit_after_sweep:
|
||||
# 自测默认扫完就退出,避免一直挂着
|
||||
args.exit_after_sweep = True
|
||||
|
||||
cfg = FINGER_CFG[args.finger]
|
||||
cmd_topic = args.cmd_topic or f"/cb_{args.hand}_hand_control_cmd"
|
||||
out_dir = args.out or (ROOT / f"reports/O6_{args.finger}_ros")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
xml = (
|
||||
PKG
|
||||
/ f"urdf/O6/linker_hand_o6_{args.hand}/linker_hand_o6_{args.hand}.xml"
|
||||
)
|
||||
|
||||
import mujoco
|
||||
import mujoco.viewer
|
||||
|
||||
model = mujoco.MjModel.from_xml_path(str(xml))
|
||||
data = mujoco.MjData(model)
|
||||
model.opt.disableflags = 0
|
||||
|
||||
mcp_jnt = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, cfg["mcp"])
|
||||
mcp_act = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_ACTUATOR, cfg["mcp_act"])
|
||||
if mcp_jnt < 0 or mcp_act < 0:
|
||||
raise RuntimeError(f"joint/actuator not found for {args.finger}")
|
||||
qadr = int(model.jnt_qposadr[mcp_jnt])
|
||||
dadr = int(model.jnt_dofadr[mcp_jnt])
|
||||
mcp_lo, mcp_hi = map(float, model.jnt_range[mcp_jnt])
|
||||
|
||||
ctrl_lock = threading.Lock()
|
||||
ctrl_values = np.zeros(model.nu)
|
||||
last_cmd_u8 = [255.0] * 6
|
||||
running = True
|
||||
sweep_done = threading.Event()
|
||||
logs = {"t": [], "cmd_rad": [], "q": [], "v": [], "tau": [], "cmd_u8": []}
|
||||
t0 = time.perf_counter()
|
||||
t_stop = (
|
||||
t0 + args.record_duration if args.record_duration > 0 else None
|
||||
)
|
||||
|
||||
import signal
|
||||
|
||||
def _stop(*_):
|
||||
nonlocal running
|
||||
running = False
|
||||
|
||||
signal.signal(signal.SIGINT, _stop)
|
||||
signal.signal(signal.SIGTERM, _stop)
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
|
||||
class CmdNode(Node):
|
||||
def __init__(self):
|
||||
super().__init__("ros_mujoco_record_qvt")
|
||||
self.create_subscription(JointState, cmd_topic, self.on_cmd, 50)
|
||||
self.get_logger().info(
|
||||
f"MuJoCo+ROS record | topic={cmd_topic} | finger={args.finger} | "
|
||||
f"joint={cfg['mcp']} range=[{mcp_lo:.2f},{mcp_hi:.2f}]"
|
||||
)
|
||||
self.get_logger().info("Ctrl+C to stop and plot")
|
||||
|
||||
def on_cmd(self, msg: JointState):
|
||||
nonlocal last_cmd_u8
|
||||
pos = list(msg.position)
|
||||
if len(pos) < 6:
|
||||
pos = pos + [255.0] * (6 - len(pos))
|
||||
last_cmd_u8 = pos[:6]
|
||||
mapped = u8_to_ctrl(last_cmd_u8, args.hand)
|
||||
with ctrl_lock:
|
||||
n = min(len(mapped), model.nu)
|
||||
ctrl_values[:n] = mapped[:n]
|
||||
|
||||
rclpy.init()
|
||||
node = CmdNode()
|
||||
|
||||
def spin_ros():
|
||||
while running and rclpy.ok():
|
||||
rclpy.spin_once(node, timeout_sec=0.01)
|
||||
|
||||
ros_thread = threading.Thread(target=spin_ros, daemon=True)
|
||||
ros_thread.start()
|
||||
|
||||
pub = None
|
||||
if args.self_sweep:
|
||||
pub = node.create_publisher(JointState, cmd_topic, 10)
|
||||
time.sleep(0.3)
|
||||
|
||||
def sweep_pub():
|
||||
nonlocal running
|
||||
half = args.duration
|
||||
ros_i = cfg["ros_idx"]
|
||||
# 255 -> 0
|
||||
t_start = time.time()
|
||||
while running and time.time() - t_start < half:
|
||||
u = (time.time() - t_start) / half
|
||||
mid = 255.0 * (1.0 - u)
|
||||
m = JointState()
|
||||
m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
|
||||
m.position[ros_i] = mid
|
||||
pub.publish(m)
|
||||
time.sleep(0.01)
|
||||
# 0 -> 255
|
||||
t_start = time.time()
|
||||
while running and time.time() - t_start < half:
|
||||
u = (time.time() - t_start) / half
|
||||
mid = 255.0 * u
|
||||
m = JointState()
|
||||
m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
|
||||
m.position[ros_i] = mid
|
||||
pub.publish(m)
|
||||
time.sleep(0.01)
|
||||
print("self-sweep finished", flush=True)
|
||||
sweep_done.set()
|
||||
if args.exit_after_sweep:
|
||||
running = False
|
||||
|
||||
threading.Thread(target=sweep_pub, daemon=True).start()
|
||||
|
||||
viewer = None
|
||||
if args.viewer:
|
||||
viewer = mujoco.viewer.launch_passive(model, data)
|
||||
|
||||
try:
|
||||
while running:
|
||||
if viewer is not None and not viewer.is_running():
|
||||
break
|
||||
if t_stop is not None and time.perf_counter() >= t_stop:
|
||||
break
|
||||
with ctrl_lock:
|
||||
data.ctrl[:] = ctrl_values
|
||||
mujoco.mj_step(model, data)
|
||||
if viewer is not None:
|
||||
viewer.sync()
|
||||
|
||||
t = time.perf_counter() - t0
|
||||
q = float(data.qpos[qadr])
|
||||
v = float(data.qvel[dadr])
|
||||
tau = float(data.actuator_force[mcp_act])
|
||||
cmd_rad = float(ctrl_values[mcp_act]) if mcp_act < len(ctrl_values) else 0.0
|
||||
logs["t"].append(t)
|
||||
logs["cmd_rad"].append(cmd_rad)
|
||||
logs["q"].append(q)
|
||||
logs["v"].append(v)
|
||||
logs["tau"].append(tau)
|
||||
logs["cmd_u8"].append(float(last_cmd_u8[cfg["ros_idx"]]))
|
||||
time.sleep(0.001)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
running = False
|
||||
if viewer is not None:
|
||||
viewer.close()
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
if len(logs["t"]) < 2:
|
||||
print("no samples")
|
||||
return
|
||||
|
||||
for k in logs:
|
||||
logs[k] = np.asarray(logs[k], dtype=float)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
stem = f"{args.label}_{args.finger}_{args.hand}_{stamp}"
|
||||
csv_path = out_dir / f"{stem}.csv"
|
||||
png_path = out_dir / f"{stem}_qvt.png"
|
||||
|
||||
np.savetxt(
|
||||
csv_path,
|
||||
np.column_stack(
|
||||
[
|
||||
logs["t"],
|
||||
logs["cmd_u8"],
|
||||
logs["cmd_rad"],
|
||||
logs["q"],
|
||||
logs["v"],
|
||||
logs["tau"],
|
||||
]
|
||||
),
|
||||
delimiter=",",
|
||||
header="t_s,cmd_u8,cmd_rad,q_rad,v_rad_s,tau_Nm",
|
||||
comments="",
|
||||
)
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("MPLCONFIGDIR", str(ROOT / ".mplconfig"))
|
||||
Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
|
||||
fig.suptitle(
|
||||
f"ROS->MuJoCo {args.hand} {cfg['mcp']} "
|
||||
f"range=[{mcp_lo:.2f}, {mcp_hi:.2f}] rad",
|
||||
fontsize=12,
|
||||
)
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["cmd_rad"]), "k--", lw=1.2, label="cmd")
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["q"]), "C0", lw=1.6, label="q")
|
||||
axes[0].set_ylabel("angle (deg)")
|
||||
axes[0].legend(loc="best")
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
axes[1].plot(logs["t"], np.rad2deg(logs["v"]), "C1", lw=1.6, label="v")
|
||||
axes[1].set_ylabel("velocity (deg/s)")
|
||||
axes[1].legend(loc="best")
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
axes[2].plot(logs["t"], logs["tau"], "C3", lw=1.6, label="tau (actuator_force)")
|
||||
axes[2].set_ylabel("torque (N·m)")
|
||||
axes[2].set_xlabel("time (s)")
|
||||
axes[2].legend(loc="best")
|
||||
axes[2].grid(True, alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(png_path, dpi=140)
|
||||
plt.close(fig)
|
||||
|
||||
print(f"CSV -> {csv_path}")
|
||||
print(f"plot -> {png_path}")
|
||||
print(f"samples={len(logs['t'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env python3
|
||||
"""真机 ROS 录制:订 control_cmd + hand_state,画与仿真相同的三曲线(deg / deg/s / current)。
|
||||
|
||||
配合 step 指令(先握中指再张开):
|
||||
python3 tools/ros_real_record_qvt.py --hand left --finger middle --auto-step
|
||||
|
||||
手动:终端1 本脚本;终端2 发 ros2 topic pub ...
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
PKG = (
|
||||
ROOT
|
||||
/ "src/linkerhand-sim/linker_hand_mujoco_ros2/linker_hand_mujoco_ros2"
|
||||
)
|
||||
sys.path.insert(0, str(PKG.parent))
|
||||
|
||||
from linker_hand_mujoco_ros2.utils.mapping import ( # noqa: E402
|
||||
range_to_arc_left,
|
||||
range_to_arc_right,
|
||||
)
|
||||
|
||||
FINGER_CFG = {
|
||||
"index": {"ros_idx": 2, "mcp": "index_mcp_pitch", "lo": 0.0, "hi": 1.60},
|
||||
"middle": {"ros_idx": 3, "mcp": "middle_mcp_pitch", "lo": 0.0, "hi": 1.60},
|
||||
"ring": {"ros_idx": 4, "mcp": "ring_mcp_pitch", "lo": 0.0, "hi": 1.60},
|
||||
"pinky": {"ros_idx": 5, "mcp": "pinky_mcp_pitch", "lo": 0.0, "hi": 1.60},
|
||||
}
|
||||
|
||||
|
||||
def u8_to_rad(u8: float, hand: str, ros_idx: int) -> float:
|
||||
pos = [255.0] * 6
|
||||
pos[ros_idx] = float(u8)
|
||||
if hand == "left":
|
||||
arc = range_to_arc_left(pos, "O6")
|
||||
else:
|
||||
arc = range_to_arc_right(pos, "O6")
|
||||
return float(arc[ros_idx])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--hand", choices=["left", "right"], default="left")
|
||||
parser.add_argument("--finger", choices=list(FINGER_CFG.keys()), default="middle")
|
||||
parser.add_argument("--cmd-topic", default="")
|
||||
parser.add_argument("--state-topic", default="")
|
||||
parser.add_argument("--info-topic", default="")
|
||||
parser.add_argument("--out", type=Path, default=None)
|
||||
parser.add_argument("--label", default="ros_real")
|
||||
parser.add_argument("--hz", type=float, default=100.0)
|
||||
parser.add_argument(
|
||||
"--auto-step",
|
||||
action="store_true",
|
||||
help="自动发 255..255,0,255..255 再张开,录完退出",
|
||||
)
|
||||
parser.add_argument("--pre-wait", type=float, default=2.0, help="开录后等待(s)")
|
||||
parser.add_argument("--hold", type=float, default=3.0, help="每步保持(s)")
|
||||
parser.add_argument("--record-after", type=float, default=1.0, help="第二步后多录(s)")
|
||||
parser.add_argument(
|
||||
"--duration",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help=">0 时录满该秒数自动结束(配合外部 middle_step_cmd_pub)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = FINGER_CFG[args.finger]
|
||||
ros_i = cfg["ros_idx"]
|
||||
cmd_topic = args.cmd_topic or f"/cb_{args.hand}_hand_control_cmd"
|
||||
state_topic = args.state_topic or f"/cb_{args.hand}_hand_state"
|
||||
info_topic = args.info_topic or f"/cb_{args.hand}_hand_info"
|
||||
out_dir = args.out or (ROOT / f"reports/O6_{args.finger}_ros")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
import rclpy
|
||||
from rclpy.node import Node
|
||||
from sensor_msgs.msg import JointState
|
||||
from std_msgs.msg import String
|
||||
|
||||
logs = {
|
||||
"t": [],
|
||||
"cmd_u8": [],
|
||||
"joint_u8": [],
|
||||
"cmd_rad": [],
|
||||
"q_rad": [],
|
||||
"v_rad_s": [],
|
||||
"current": [],
|
||||
}
|
||||
t0 = time.perf_counter()
|
||||
running = True
|
||||
state_lock = threading.Lock()
|
||||
last_cmd_u8 = [255.0] * 6
|
||||
last_joint_u8 = [float("nan")] * 6
|
||||
last_current = [float("nan")] * 6
|
||||
have_state = False
|
||||
have_current = False
|
||||
|
||||
class Recorder(Node):
|
||||
def __init__(self):
|
||||
super().__init__("ros_real_record_qvt")
|
||||
self.pub = self.create_publisher(JointState, cmd_topic, 10)
|
||||
self.create_subscription(JointState, cmd_topic, self.on_cmd, 50)
|
||||
self.create_subscription(JointState, state_topic, self.on_state, 50)
|
||||
self.create_subscription(String, info_topic, self.on_info, 10)
|
||||
self.create_timer(1.0 / max(1.0, args.hz), self.on_timer)
|
||||
self.get_logger().info(
|
||||
f"real record | cmd={cmd_topic} | state={state_topic} | "
|
||||
f"finger={args.finger} idx={ros_i}"
|
||||
)
|
||||
|
||||
def on_cmd(self, msg: JointState):
|
||||
nonlocal last_cmd_u8
|
||||
pos = list(msg.position)
|
||||
if len(pos) >= 6:
|
||||
with state_lock:
|
||||
last_cmd_u8 = [float(x) for x in pos[:6]]
|
||||
|
||||
def on_state(self, msg: JointState):
|
||||
nonlocal have_state, last_joint_u8, have_current, last_current
|
||||
with state_lock:
|
||||
pos = list(msg.position)
|
||||
if len(pos) >= 6 and all(float(x) >= 0 for x in pos[:6]):
|
||||
have_state = True
|
||||
last_joint_u8 = [float(x) for x in pos[:6]]
|
||||
if msg.effort and any(abs(float(x)) > 1e-6 for x in msg.effort):
|
||||
have_current = True
|
||||
last_current = [float(x) for x in msg.effort[:6]]
|
||||
|
||||
def on_info(self, msg: String):
|
||||
nonlocal have_current, last_current
|
||||
try:
|
||||
info = json.loads(msg.data)
|
||||
cur = info.get("current")
|
||||
if isinstance(cur, list) and len(cur) >= 6:
|
||||
if any(float(x) >= 0 for x in cur[:6]):
|
||||
with state_lock:
|
||||
have_current = True
|
||||
last_current = [float(x) for x in cur[:6]]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
def publish_pose(self, middle_u8: float):
|
||||
nonlocal last_cmd_u8
|
||||
m = JointState()
|
||||
m.position = [255.0, 255.0, 255.0, 255.0, 255.0, 255.0]
|
||||
m.position[ros_i] = float(middle_u8)
|
||||
with state_lock:
|
||||
last_cmd_u8 = list(m.position)
|
||||
self.pub.publish(m)
|
||||
|
||||
def hold_pose(self, middle_u8: float, duration: float, rate_hz: float = 10.0):
|
||||
"""持续重发控制帧(SDK 只响应订阅到的 cmd;单发 --once 可能丢)。"""
|
||||
end = time.perf_counter() + duration
|
||||
dt = 1.0 / max(1.0, rate_hz)
|
||||
while time.perf_counter() < end and running:
|
||||
self.publish_pose(middle_u8)
|
||||
time.sleep(dt)
|
||||
|
||||
def on_timer(self):
|
||||
nonlocal running
|
||||
if not running:
|
||||
return
|
||||
with state_lock:
|
||||
joint_u8 = list(last_joint_u8)
|
||||
current = list(last_current)
|
||||
cmd_u8 = float(last_cmd_u8[ros_i])
|
||||
if have_state and not np.isnan(joint_u8[ros_i]):
|
||||
q_u8 = float(joint_u8[ros_i])
|
||||
else:
|
||||
q_u8 = cmd_u8
|
||||
t = time.perf_counter() - t0
|
||||
q_rad = u8_to_rad(q_u8, args.hand, ros_i)
|
||||
cmd_rad = u8_to_rad(cmd_u8, args.hand, ros_i)
|
||||
cur = current[ros_i] if len(current) > ros_i else float("nan")
|
||||
logs["t"].append(t)
|
||||
logs["cmd_u8"].append(cmd_u8)
|
||||
logs["joint_u8"].append(q_u8)
|
||||
logs["cmd_rad"].append(cmd_rad)
|
||||
logs["q_rad"].append(q_rad)
|
||||
logs["current"].append(cur)
|
||||
if len(logs["t"]) >= 2:
|
||||
dt = logs["t"][-1] - logs["t"][-2]
|
||||
if dt > 1e-9:
|
||||
v = (logs["q_rad"][-1] - logs["q_rad"][-2]) / dt
|
||||
else:
|
||||
v = 0.0
|
||||
else:
|
||||
v = 0.0
|
||||
logs["v_rad_s"].append(v)
|
||||
|
||||
rclpy.init()
|
||||
node = Recorder()
|
||||
|
||||
def spin():
|
||||
while running and rclpy.ok():
|
||||
rclpy.spin_once(node, timeout_sec=0.01)
|
||||
|
||||
threading.Thread(target=spin, daemon=True).start()
|
||||
|
||||
if args.auto_step:
|
||||
deadline = time.perf_counter() + max(5.0, args.pre_wait)
|
||||
while time.perf_counter() < deadline and not have_state:
|
||||
time.sleep(0.05)
|
||||
if not have_state:
|
||||
node.get_logger().warn(
|
||||
"仍未收到 hand_state(可能 SDK 未跑 / Domain 不对 / 全是 -1)"
|
||||
)
|
||||
time.sleep(max(0.5, args.pre_wait))
|
||||
node.get_logger().info("step1: middle close (u8=0)")
|
||||
node.hold_pose(0.0, args.hold)
|
||||
node.get_logger().info("step2: middle open (u8=255)")
|
||||
node.hold_pose(255.0, args.hold + args.record_after)
|
||||
running = False
|
||||
else:
|
||||
node.get_logger().info(
|
||||
"listen mode: 外部发令 (middle_step_cmd_pub) 或 Ctrl+C 结束"
|
||||
)
|
||||
t_end = (
|
||||
time.perf_counter() + args.duration if args.duration > 0 else None
|
||||
)
|
||||
try:
|
||||
while running:
|
||||
if t_end is not None and time.perf_counter() >= t_end:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
except KeyboardInterrupt:
|
||||
running = False
|
||||
|
||||
time.sleep(0.2)
|
||||
node.destroy_node()
|
||||
if rclpy.ok():
|
||||
rclpy.shutdown()
|
||||
|
||||
if len(logs["t"]) < 2:
|
||||
print("no samples")
|
||||
return 1
|
||||
|
||||
for k in logs:
|
||||
logs[k] = np.asarray(logs[k], dtype=float)
|
||||
|
||||
if len(logs["v_rad_s"]) >= 5:
|
||||
logs["v_rad_s"] = np.convolve(
|
||||
logs["v_rad_s"], np.ones(5) / 5.0, mode="same"
|
||||
)
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
stem = f"{args.label}_{args.finger}_{args.hand}_{stamp}"
|
||||
csv_path = out_dir / f"{stem}.csv"
|
||||
png_path = out_dir / f"{stem}_qvt.png"
|
||||
|
||||
np.savetxt(
|
||||
csv_path,
|
||||
np.column_stack(
|
||||
[
|
||||
logs["t"],
|
||||
logs["cmd_u8"],
|
||||
logs["joint_u8"],
|
||||
logs["cmd_rad"],
|
||||
logs["q_rad"],
|
||||
logs["v_rad_s"],
|
||||
logs["current"],
|
||||
]
|
||||
),
|
||||
delimiter=",",
|
||||
header="t_s,cmd_u8,joint_u8,cmd_rad,q_rad,v_rad_s,current",
|
||||
comments="",
|
||||
)
|
||||
|
||||
import os
|
||||
|
||||
os.environ.setdefault("MPLCONFIGDIR", str(ROOT / ".mplconfig"))
|
||||
Path(os.environ["MPLCONFIGDIR"]).mkdir(parents=True, exist_ok=True)
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
mcp_lo, mcp_hi = cfg["lo"], cfg["hi"]
|
||||
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
|
||||
fig.suptitle(
|
||||
f"ROS->Real {args.hand} {cfg['mcp']} "
|
||||
f"range=[{mcp_lo:.2f}, {mcp_hi:.2f}] rad "
|
||||
f"(255=open/0deg, 0=close/~92deg)",
|
||||
fontsize=11,
|
||||
)
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["cmd_rad"]), "k--", lw=1.2, label="cmd")
|
||||
axes[0].plot(logs["t"], np.rad2deg(logs["q_rad"]), "C0", lw=1.6, label="q")
|
||||
axes[0].set_ylabel("angle (deg)")
|
||||
axes[0].legend(loc="best")
|
||||
axes[0].grid(True, alpha=0.3)
|
||||
|
||||
axes[1].plot(logs["t"], np.rad2deg(logs["v_rad_s"]), "C1", lw=1.6, label="v")
|
||||
axes[1].set_ylabel("velocity (deg/s)")
|
||||
axes[1].legend(loc="best")
|
||||
axes[1].grid(True, alpha=0.3)
|
||||
|
||||
if have_current and np.any(np.isfinite(logs["current"])):
|
||||
axes[2].plot(
|
||||
logs["t"], logs["current"], "C3", lw=1.6, label="current (hand_info)"
|
||||
)
|
||||
axes[2].set_ylabel("current")
|
||||
else:
|
||||
axes[2].text(
|
||||
0.5,
|
||||
0.5,
|
||||
"no current (subscribe hand_info or state.effort)",
|
||||
ha="center",
|
||||
va="center",
|
||||
transform=axes[2].transAxes,
|
||||
)
|
||||
axes[2].set_ylabel("current")
|
||||
axes[2].set_xlabel("time (s)")
|
||||
axes[2].legend(loc="best")
|
||||
axes[2].grid(True, alpha=0.3)
|
||||
fig.tight_layout()
|
||||
fig.savefig(png_path, dpi=140)
|
||||
plt.close(fig)
|
||||
|
||||
print(f"CSV -> {csv_path}")
|
||||
print(f"plot -> {png_path}")
|
||||
print(f"samples={len(logs['t'])} state={'yes' if have_state else 'NO'}")
|
||||
cmd_span = float(np.max(logs["cmd_u8"]) - np.min(logs["cmd_u8"]))
|
||||
joint_span = float(np.max(logs["joint_u8"]) - np.min(logs["joint_u8"]))
|
||||
print(f"cmd_u8 span={cmd_span:.1f} joint_u8 span={joint_span:.1f}")
|
||||
if cmd_span < 1.0:
|
||||
print("[warn] cmd 全程几乎不变 → 控制指令可能没发出去或未写入录制")
|
||||
if not have_state:
|
||||
print("[warn] 未收到有效 state → q 只能用 cmd 代替,且真机可能没动")
|
||||
if cmd_span >= 1.0 and joint_span < 3.0 and have_state:
|
||||
print("[warn] 有 cmd 变化但 joint 几乎不动 → 查 CAN / SDK / 力矩限制")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+231
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""O6 中指 step 实验:真机 / 仿真共用同一套控制时序。
|
||||
|
||||
时序(默认,两边完全一致):
|
||||
3s 张开 → 3s 握紧 → 3s 张开 → 2s 余量录制
|
||||
|
||||
用法:
|
||||
export ROS_DOMAIN_ID=31
|
||||
|
||||
# 真机(需 can0 UP + SDK,或脚本尝试自动启 SDK)
|
||||
python3 tools/run_middle_step_experiment.py real
|
||||
|
||||
# 仿真(自带 MuJoCo,无需 run_sim.sh)
|
||||
python3 tools/run_middle_step_experiment.py sim
|
||||
|
||||
# 两次跑完后出对比图(自动找目录下最新 real/sim csv)
|
||||
python3 tools/run_middle_step_experiment.py compare
|
||||
|
||||
# 指定 csv
|
||||
python3 tools/run_middle_step_experiment.py compare \\
|
||||
--real-csv reports/O6_middle_ros/ros_real_....csv \\
|
||||
--sim-csv reports/O6_middle_ros/ros_sim_....csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUT_DIR = ROOT / "reports/O6_middle_ros"
|
||||
|
||||
# 与 middle_step_cmd_pub.py 保持一致
|
||||
PRE_WAIT = 3.0
|
||||
HOLD = 3.0
|
||||
RECORD_AFTER = 2.0
|
||||
RATE_HZ = 10.0
|
||||
|
||||
|
||||
def record_duration() -> float:
|
||||
return PRE_WAIT + HOLD + HOLD + RECORD_AFTER + 1.5
|
||||
|
||||
|
||||
def setup_ros_env():
|
||||
os.environ.setdefault("ROS_DOMAIN_ID", "31")
|
||||
os.environ.setdefault("ROS_LOCALHOST_ONLY", "0")
|
||||
|
||||
|
||||
def setup_mujoco_pythonpath():
|
||||
venv_site = ROOT / "venv/lib/python3.12/site-packages"
|
||||
if venv_site.is_dir():
|
||||
os.environ["PYTHONPATH"] = f"{venv_site}{os.pathsep}{os.environ.get('PYTHONPATH', '')}"
|
||||
|
||||
|
||||
def run_real_experiment(args):
|
||||
setup_ros_env()
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dur = record_duration()
|
||||
|
||||
rec_cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools/ros_real_record_qvt.py"),
|
||||
"--hand",
|
||||
args.hand,
|
||||
"--finger",
|
||||
args.finger,
|
||||
"--label",
|
||||
"ros_real",
|
||||
"--out",
|
||||
str(OUT_DIR),
|
||||
"--duration",
|
||||
str(dur),
|
||||
]
|
||||
print(f"[real] 启动录制 {dur:.1f}s ...")
|
||||
rec = subprocess.Popen(rec_cmd, cwd=str(ROOT))
|
||||
time.sleep(1.0)
|
||||
|
||||
pub_cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools/middle_step_cmd_pub.py"),
|
||||
"--hand",
|
||||
args.hand,
|
||||
"--finger",
|
||||
args.finger,
|
||||
"--pre-wait",
|
||||
str(PRE_WAIT),
|
||||
"--hold",
|
||||
str(HOLD),
|
||||
"--rate",
|
||||
str(RATE_HZ),
|
||||
]
|
||||
print("[real] 发送统一 step 指令 ...")
|
||||
pub = subprocess.run(pub_cmd, cwd=str(ROOT))
|
||||
if pub.returncode != 0:
|
||||
rec.send_signal(signal.SIGINT)
|
||||
rec.wait()
|
||||
return pub.returncode
|
||||
|
||||
print("[real] 等待录制结束 ...")
|
||||
try:
|
||||
rec.wait(timeout=dur + 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
rec.send_signal(signal.SIGINT)
|
||||
rec.wait()
|
||||
return rec.returncode
|
||||
|
||||
|
||||
def run_sim_experiment(args):
|
||||
setup_ros_env()
|
||||
setup_mujoco_pythonpath()
|
||||
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
dur = record_duration()
|
||||
|
||||
rec_cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools/ros_mujoco_record_qvt.py"),
|
||||
"--hand",
|
||||
args.hand,
|
||||
"--finger",
|
||||
args.finger,
|
||||
"--label",
|
||||
"ros_sim",
|
||||
"--out",
|
||||
str(OUT_DIR),
|
||||
"--record-duration",
|
||||
str(dur),
|
||||
]
|
||||
if args.viewer:
|
||||
rec_cmd.append("--viewer")
|
||||
|
||||
print(f"[sim] 启动 MuJoCo 录制 {dur:.1f}s ...")
|
||||
rec = subprocess.Popen(rec_cmd, cwd=str(ROOT))
|
||||
time.sleep(1.0)
|
||||
|
||||
pub_cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools/middle_step_cmd_pub.py"),
|
||||
"--hand",
|
||||
args.hand,
|
||||
"--finger",
|
||||
args.finger,
|
||||
"--pre-wait",
|
||||
str(PRE_WAIT),
|
||||
"--hold",
|
||||
str(HOLD),
|
||||
"--rate",
|
||||
str(RATE_HZ),
|
||||
]
|
||||
print("[sim] 发送统一 step 指令 ...")
|
||||
pub = subprocess.run(pub_cmd, cwd=str(ROOT))
|
||||
if pub.returncode != 0:
|
||||
rec.send_signal(signal.SIGINT)
|
||||
rec.wait()
|
||||
return pub.returncode
|
||||
|
||||
print("[sim] 等待录制结束 ...")
|
||||
try:
|
||||
rec.wait(timeout=dur + 10)
|
||||
except subprocess.TimeoutExpired:
|
||||
rec.send_signal(signal.SIGINT)
|
||||
rec.wait()
|
||||
return rec.returncode
|
||||
|
||||
|
||||
def latest_csv(pattern: str) -> Path | None:
|
||||
files = sorted(glob.glob(str(OUT_DIR / pattern)), key=os.path.getmtime)
|
||||
return Path(files[-1]) if files else None
|
||||
|
||||
|
||||
def run_compare(args):
|
||||
real_csv = Path(args.real_csv) if args.real_csv else latest_csv("ros_real_*middle*.csv")
|
||||
sim_csv = Path(args.sim_csv) if args.sim_csv else latest_csv("ros_sim_*middle*.csv")
|
||||
if not real_csv or not real_csv.is_file():
|
||||
print("[error] 找不到真机 csv,先跑: python3 tools/run_middle_step_experiment.py real")
|
||||
return 1
|
||||
if not sim_csv or not sim_csv.is_file():
|
||||
print("[error] 找不到仿真 csv,先跑: python3 tools/run_middle_step_experiment.py sim")
|
||||
return 1
|
||||
|
||||
out = OUT_DIR / "ros_real_vs_sim_middle_left_compare_qvt.png"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools/plot_real_sim_compare.py"),
|
||||
str(real_csv),
|
||||
str(sim_csv),
|
||||
"-o",
|
||||
str(out),
|
||||
]
|
||||
print(f"[compare] real={real_csv.name}")
|
||||
print(f"[compare] sim ={sim_csv.name}")
|
||||
return subprocess.call(cmd, cwd=str(ROOT))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="O6 middle step experiment (real/sim/compare)")
|
||||
parser.add_argument(
|
||||
"mode",
|
||||
choices=["real", "sim", "compare"],
|
||||
help="real=真机录制 | sim=MuJoCo录制 | compare=对比图",
|
||||
)
|
||||
parser.add_argument("--hand", default="left")
|
||||
parser.add_argument("--finger", default="middle")
|
||||
parser.add_argument("--viewer", action="store_true", help="sim 模式打开 MuJoCo 窗口")
|
||||
parser.add_argument("--real-csv", default="")
|
||||
parser.add_argument("--sim-csv", default="")
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print("O6 middle step 实验时序(真机 / 仿真相同)")
|
||||
print(f" pre_wait={PRE_WAIT}s hold={HOLD}s record_after={RECORD_AFTER}s")
|
||||
print(f" ROS_DOMAIN_ID={os.environ.get('ROS_DOMAIN_ID', '31')}")
|
||||
print("=" * 60)
|
||||
|
||||
if args.mode == "real":
|
||||
print("\n[提示] 真机请先: sudo ip link set can0 up type can bitrate 1000000")
|
||||
print(" 并运行 ./run_real_hand.sh(或本脚本前自行启动 SDK)\n")
|
||||
return run_real_experiment(args) or 0
|
||||
if args.mode == "sim":
|
||||
return run_sim_experiment(args) or 0
|
||||
return run_compare(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user