Files
JointModule/scripts/simulate_report.py
T
chenshijue 4240bb889a 初始提交:关节模组仿真平台
- 三接口契约:自包含 MJCF / 配置 schema / 报告计算规范
- Python 流水线:urdf_to_mjcf → generate_schema → simulate_report(validate_module 一键编排)
- 输入案例 urdf + 生成产物 output(自包含 MJCF/schema/报告/网格副本)
- 详细架构说明 docs/architecture.md
2026-08-28 11:37:47 +08:00

396 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
关节模组通用仿真 + 报告脚本(边可视化边计算)
====================================================================
对任意一个关节模组 MJCF 跑仿真并产出报告。报告逻辑固定(见 report_spec.md),
随构型变的只有:输入/输出关节名、减速比、限位、仿真参数 —— 这些统一从 schema JSON
读入(schema 由 generate_schema.py 生成)。
两种用法:
1) 通用(读 schema):
python3 simulate_report.py --schema <模块>.json --headless --plot
2) demo(不传 --schema,回退到本案例默认值,保持兼容):
python3 simulate_report.py # 弹窗 + 打印报告 + timeseries.csv
python3 simulate_report.py --headless # 无窗口
python3 simulate_report.py --plot # 追加 report_curves.png
也可用 --xml / --input / --output / --ratio 覆盖 schema 里的个别字段(快速调试用)。
报告测三类数据(report_spec.md 第 3 节):
1. 运动学 —— 输入/输出位置、速度、加速度、传动比实测
2. 动力学 —— 输入/输出力矩、功率、效率
3. 安全性 —— 位置余量、力矩余量、过载判定
输出文件写在 MJCF 所在目录:timeseries.csv(逐时间步)、report.txt(仿真报告)、
report_curves.png--plot)。
依赖:mujoco、numpy(绘图需 matplotlib)。
"""
import argparse
import json
import os
import sys
import time
import numpy as np
import mujoco
import mujoco.viewer # noqa: F401 (确保 viewer 子模块可用)
# ------------------------------ demo 默认值(不传 --schema 时用) ------------------------------
DEMO = {
"xml": "planetary_joint_split_motor_demo.xml",
"input_joint": "sun_input_joint",
"output_joint": "carrier_output_joint",
"planet_joint": "planet_0_spin_joint",
"gear_ratio": 6.0,
"duration": 4.0,
"dt": 0.001,
"kp": 20.0,
"kd": 0.3,
"amplitude": 2.0 * np.pi,
"frequency": 0.25,
"load_torque": -3.0,
"damping": 0.01,
"mode": "normal",
"pos_lim": None, # None → 从 MJCF 读
"trq_lim": None,
}
# 参考轨迹平滑启动时长 [s]:让参考从 0 位置、0 速度起跳,消除 t=0 的微分项冲击
RAMP_TIME = 0.5
# ------------------------------ 工具函数 ------------------------------
def jid(m, name):
return mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_JOINT, name)
def aid(m, name):
return mujoco.mj_name2id(m, mujoco.mjtObj.mjOBJ_ACTUATOR, name)
def margin(limit, peak):
"""安全余量 = (限位值 - |峰值|) / 限位值 × 100%"""
if limit is None or abs(limit) < 1e-12:
return float("nan")
return (abs(limit) - abs(peak)) / abs(limit) * 100.0
def _setup_cjk_font(plt):
"""配置中文字体,避免图中中文标签显示成方框(tofu)。"""
candidates = [
"Noto Sans CJK SC", "Noto Sans CJK JP", "AR PL UMing CN",
"AR PL UKai CN", "Droid Sans Fallback",
]
import matplotlib.font_manager as fm
available = {f.name for f in fm.fontManager.ttflist}
chosen = next((c for c in candidates if c in available), None)
if chosen is not None:
plt.rcParams["font.sans-serif"] = [chosen, "DejaVu Sans"]
print(f"已启用中文字体:{chosen}")
else:
print("警告:未找到中文字体,图中中文可能显示为方框")
plt.rcParams["axes.unicode_minus"] = False
def load_config(args):
"""从 schema / CLI 参数合成一份运行配置。返回 (cfg, schema_dir)。"""
cfg = dict(DEMO)
schema_dir = None
if args.schema:
with open(args.schema, encoding="utf-8") as f:
s = json.load(f)
schema_dir = os.path.dirname(os.path.abspath(args.schema))
cfg["xml"] = s.get("model", cfg["xml"])
cfg["input_joint"] = s.get("input_joint", cfg["input_joint"])
cfg["output_joint"] = s.get("output_joint", cfg["output_joint"])
cfg["gear_ratio"] = float(s.get("gear_ratio", cfg["gear_ratio"]))
lim = s.get("limits", {})
pos = lim.get("position")
trq = lim.get("torque")
cfg["pos_lim"] = float(pos[1]) if pos else None
cfg["trq_lim"] = float(trq[1]) if trq else None
sim = s.get("simulation", {})
cfg["duration"] = float(sim.get("duration", cfg["duration"]))
cfg["dt"] = float(sim.get("timestep", cfg["dt"]))
cfg["kp"] = float(sim.get("kp", cfg["kp"]))
cfg["kd"] = float(sim.get("kd", cfg["kd"]))
cfg["amplitude"] = float(sim.get("amplitude", cfg["amplitude"]))
cfg["frequency"] = float(sim.get("frequency", cfg["frequency"]))
cfg["load_torque"] = float(sim.get("load_torque", cfg["load_torque"]))
cfg["damping"] = float(sim.get("damping", cfg["damping"]))
cfg["mode"] = sim.get("mode", cfg["mode"])
# schema 不含行星轮(可选观测),通用模式默认不记录行星轮
cfg["planet_joint"] = None
# CLI 覆盖
if args.xml:
cfg["xml"] = args.xml
if args.input:
cfg["input_joint"] = args.input
if args.output:
cfg["output_joint"] = args.output
if args.ratio is not None:
cfg["gear_ratio"] = args.ratio
if args.planet:
cfg["planet_joint"] = args.planet
if args.mode:
cfg["mode"] = args.mode
if args.load_torque is not None:
cfg["load_torque"] = args.load_torque
# XML 路径:相对路径相对 schema 所在目录(无 schema 则相对 CWD)解析
xml = cfg["xml"]
if not os.path.isabs(xml):
base = schema_dir if schema_dir else os.getcwd()
xml = os.path.abspath(os.path.join(base, xml))
cfg["xml"] = xml
cfg["out_dir"] = os.path.dirname(xml)
return cfg
# ------------------------------ 主流程 ------------------------------
def main():
ap = argparse.ArgumentParser(description="关节模组通用仿真 + 报告")
ap.add_argument("--schema", default=None, help="schema JSON 路径")
ap.add_argument("--xml", default=None, help="MJCF XML 路径(覆盖 schema.model")
ap.add_argument("--input", default=None, help="输入关节名(覆盖 schema")
ap.add_argument("--output", default=None, help="输出关节名(覆盖 schema")
ap.add_argument("--ratio", type=float, default=None, help="减速比(覆盖 schema")
ap.add_argument("--planet", default=None, help="可选:要记录的行星轮关节名")
ap.add_argument("--mode", choices=["normal", "overload"], default=None,
help="仿真模式(normal / overload,默认读 schema")
ap.add_argument("--load-torque", type=float, default=None,
help="输出端负载 [N·m](覆盖 schema")
ap.add_argument("--headless", action="store_true", help="无窗口")
ap.add_argument("--fast", action="store_true", help="弹窗但不按真实时间步进")
ap.add_argument("--plot", action="store_true", help="导出 PNG 曲线(需 matplotlib")
args = ap.parse_args()
cfg = load_config(args)
m = mujoco.MjModel.from_xml_path(cfg["xml"])
d = mujoco.MjData(m)
in_j, out_j = jid(m, cfg["input_joint"]), jid(m, cfg["output_joint"])
if in_j < 0 or out_j < 0:
sys.exit("找不到输入/输出关节,请检查 schema 的 input_joint / output_joint")
in_dof = m.jnt_dofadr[in_j]
out_dof = m.jnt_dofadr[out_j]
in_m, load_m = aid(m, "input_motor"), aid(m, "load_motor")
if in_m < 0 or load_m < 0:
sys.exit("找不到 input_motor / load_motor 作动器(应由 urdf_to_mjcf.py 生成)")
p_j = jid(m, cfg["planet_joint"]) if cfg["planet_joint"] else -1
p_dof = m.jnt_dofadr[p_j] if p_j >= 0 else -1
has_planet = p_dof >= 0
# 限位:优先 schemacfg.pos_lim/trq_lim),否则从 MJCF 读(demo 回退路径)
pos_lim = cfg["pos_lim"] if cfg["pos_lim"] is not None else m.jnt_range[in_j][1]
trq_lim = cfg["trq_lim"] if cfg["trq_lim"] is not None else m.actuator_ctrlrange[in_m][1]
DT = cfg["dt"]
nsteps = int(cfg["duration"] / DT)
t_arr = np.zeros(nsteps)
q_in, qd_in, qacc_in = np.zeros(nsteps), np.zeros(nsteps), np.zeros(nsteps)
q_out, qd_out, qacc_out = np.zeros(nsteps), np.zeros(nsteps), np.zeros(nsteps)
q_p, qd_p = np.zeros(nsteps), np.zeros(nsteps)
q_ref_arr, qd_ref_arr = np.zeros(nsteps), np.zeros(nsteps)
tau_in_arr, tau_out_arr, power_arr = np.zeros(nsteps), np.zeros(nsteps), np.zeros(nsteps)
mujoco.mj_resetData(m, d)
viewer = None
if not args.headless:
try:
viewer = mujoco.viewer.launch_passive(m, d)
print("已打开可视化窗口(关闭窗口可提前结束仿真)")
except Exception as e:
print(f"无法打开可视化窗口(可能缺显示器/X11):{e}")
print("退回无头模式继续计算…")
viewer = None
w = 2.0 * np.pi * cfg["frequency"]
t_start = time.perf_counter()
try:
for k in range(nsteps):
t = k * DT
# 升余弦包络平滑启动:参考从 0 位置、0 速度起跳,避免 t=0 速度跳变
if t < RAMP_TIME:
env = 0.5 * (1.0 - np.cos(np.pi * t / RAMP_TIME))
denv = 0.5 * np.pi / RAMP_TIME * np.sin(np.pi * t / RAMP_TIME)
else:
env = 1.0
denv = 0.0
q_ref = cfg["amplitude"] * np.sin(w * t) * env
qd_ref = cfg["amplitude"] * (w * np.cos(w * t) * env + np.sin(w * t) * denv)
tau_in = cfg["kp"] * (q_ref - d.qpos[in_dof]) + cfg["kd"] * (qd_ref - d.qvel[in_dof])
d.ctrl[in_m] = tau_in
d.ctrl[load_m] = cfg["load_torque"]
mujoco.mj_step(m, d)
t_arr[k] = t
q_in[k] = d.qpos[in_dof]; qd_in[k] = d.qvel[in_dof]; qacc_in[k] = d.qacc[in_dof]
q_out[k] = d.qpos[out_dof]; qd_out[k] = d.qvel[out_dof]; qacc_out[k] = d.qacc[out_dof]
if has_planet:
q_p[k] = d.qpos[p_dof]; qd_p[k] = d.qvel[p_dof]
q_ref_arr[k] = q_ref; qd_ref_arr[k] = qd_ref
tau_in_arr[k] = d.actuator_force[in_m]
tau_out_arr[k] = d.qfrc_constraint[out_dof]
power_arr[k] = tau_out_arr[k] * qd_out[k]
if viewer is not None:
viewer.sync()
if not args.fast:
elapsed = time.perf_counter() - t_start
sim_t = (k + 1) * DT
if elapsed < sim_t:
time.sleep(sim_t - elapsed)
if not viewer.is_running():
print("窗口已关闭,提前结束仿真")
nsteps = k + 1
break
finally:
if viewer is not None:
viewer.close()
t_arr = t_arr[:nsteps]; q_in = q_in[:nsteps]; qd_in = qd_in[:nsteps]; qacc_in = qacc_in[:nsteps]
q_out = q_out[:nsteps]; qd_out = qd_out[:nsteps]; qacc_out = qacc_out[:nsteps]
q_p = q_p[:nsteps]; qd_p = qd_p[:nsteps]
q_ref_arr = q_ref_arr[:nsteps]; qd_ref_arr = qd_ref_arr[:nsteps]
tau_in_arr = tau_in_arr[:nsteps]; tau_out_arr = tau_out_arr[:nsteps]; power_arr = power_arr[:nsteps]
# ------------------------------ 写 CSV(写到 MJCF 所在目录) ------------------------------
cols = [t_arr, q_in, qd_in, qacc_in, q_ref_arr, qd_ref_arr,
q_out, qd_out, qacc_out, tau_in_arr, tau_out_arr, power_arr]
header = ("time,input_q,input_qd,input_qacc,q_ref,qd_ref,"
"output_q,output_qd,output_qacc,tau_in,tau_out,power_out")
if has_planet:
cols.insert(9, q_p); cols.insert(10, qd_p)
header = header.replace("tau_in", "planet_q,planet_qd,tau_in")
data = np.column_stack(cols)
csv_path = os.path.join(cfg["out_dir"], "timeseries.csv")
np.savetxt(csv_path, data, delimiter=",", header=header, comments="", fmt="%.8f")
# ------------------------------ 汇总报告 ------------------------------
half = nsteps // 2
tau_in_ss = np.abs(tau_in_arr[half:]).mean()
tau_out_ss = np.abs(tau_out_arr[half:]).mean()
N = cfg["gear_ratio"]
efficiency = (tau_out_ss / (N * tau_in_ss)) * 100.0 if tau_in_ss > 1e-9 else float("nan")
# ------------------------------ 汇总报告(打印到终端 + 写 report.txt ------------------------------
rm = np.sqrt(np.mean((q_in - q_ref_arr) ** 2))
vm = np.sqrt(np.mean((qd_in - qd_ref_arr) ** 2))
# 传动比实测:用带截距的最小二乘斜率估计 q_out = k·q_in + b 的 k。
# 不能逐点 q_out/q_in 再取均值——q_in 过零处软约束相位滞后会让比值爆表甚至变号,
# 把均值带偏(如 0.112368 vs 真实 0.111111)。带截距斜率对相位滞后与负载静偏置
# (q_out 恒滞后一个常数角)都不敏感,能精确还原 1/N。
ratio_measured = float(np.polyfit(q_in[half:], q_out[half:], 1)[0])
mgn_pos = margin(pos_lim, abs(q_in).max())
mgn_trq = margin(trq_lim, abs(tau_in_arr).max())
rep = []
rep.append("=" * 64)
rep.append("关节模组仿真报告")
rep.append("=" * 64)
rep.append(f"模型 : {os.path.basename(cfg['xml'])}")
rep.append(f"输入端 : {cfg['input_joint']} 输出端: {cfg['output_joint']}")
rep.append(f"标称减速比 1 : {N:.4f}(输出 = 输入/{N:.4f}")
rep.append(f"仿真模式 : {cfg['mode']}")
rep.append(f"负载力矩 : {cfg['load_torque']:.4f} N·m 阻尼: {cfg['damping']}")
rep.append("")
rep.append("[1] 运动学(跟踪精度)")
rep.append(f" 输入位置峰值 : {abs(q_in).max():.4f} rad (参考 {cfg['amplitude']:.4f})")
rep.append(f" 位置跟踪误差 (RMS) : {rm:.4f} rad")
rep.append(f" 速度跟踪误差 (RMS) : {vm:.4f} rad/s")
rep.append(f" 输出位置峰值 : {abs(q_out).max():.4f} rad (应为 {cfg['amplitude']/N:.4f})")
rep.append(f" 传动比实测 : {ratio_measured:.6f} (期望 {1/N:.6f})")
rep.append("")
rep.append("[2] 动力学(力矩与功率)")
rep.append(f" 输入力矩 (稳态均值) : {tau_in_ss:.4f} N·m")
rep.append(f" 输出力矩 (稳态均值) : {tau_out_ss:.4f} N·m")
rep.append(f" 理想输出 = 输入×{N:.4f} : {N*tau_in_ss:.4f} N·m")
rep.append(f" 力矩损失 : {N*tau_in_ss - tau_out_ss:.4f} N·m")
rep.append(f" 效率 η = 输出/(输入×{N:.4f}) : {efficiency:.2f} %")
rep.append(f" 输入力矩峰值 (瞬态) : {abs(tau_in_arr).max():.4f} N·m")
rep.append(f" 输出功率峰值 : {abs(power_arr).max():.4f} W")
rep.append("")
rep.append("[3] 安全性(安全余量)")
rep.append(f" 位置余量 (限位 ±{pos_lim:.4f} rad) : {mgn_pos:.2f} %")
rep.append(f" 力矩余量 (限位 ±{trq_lim:.4f} N·m) : {mgn_trq:.2f} %")
rep.append(f" 过载判定 : {'⚠ 余量 < 20%,存在过载风险' if mgn_trq < 20 else '✓ 余量充足'}")
rep.append("")
rep.append("=" * 64)
rep.append(f"已写出 {csv_path}")
rep.append("=" * 64)
report_text = "\n".join(rep) + "\n"
print("\n" + report_text)
report_path = os.path.join(cfg["out_dir"], "report.txt")
with open(report_path, "w", encoding="utf-8") as f:
f.write(report_text)
print(f"已写出报告 {report_path}")
# overload 模式:额外产出一份过载报告
if cfg["mode"] == "overload":
tau_peak = float(np.abs(tau_in_arr).max())
if mgn_trq <= 0:
verdict = "⚠ 已过载(输入力矩达到额定限位)"
elif mgn_trq < 20.0:
verdict = "⚠ 接近过载(力矩余量 < 20%"
else:
verdict = "✓ 未过载"
orep = [
"=" * 48,
"过载仿真报告",
"=" * 48,
f"模型 : {os.path.basename(cfg['xml'])}",
f"仿真模式 : {cfg['mode']}",
f"额定力矩限位 : ±{trq_lim:.4f} N·m",
f"施加负载力矩 : {cfg['load_torque']:.4f} N·m",
f"输入力矩峰值 : {tau_peak:.4f} N·m",
f"力矩余量 : {mgn_trq:.2f} %",
f"位置跟踪误差 : {rm:.4f} rad (RMS)",
f"过载判定 : {verdict}",
"=" * 48,
]
orep_text = "\n".join(orep) + "\n"
orep_path = os.path.join(cfg["out_dir"], "overload_report.txt")
with open(orep_path, "w", encoding="utf-8") as f:
f.write(orep_text)
print(f"\n已写出过载报告 {orep_path}")
print(orep_text)
if args.plot:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
_setup_cjk_font(plt)
fig, ax = plt.subplots(3, 1, figsize=(9, 10), sharex=True)
ax[0].plot(t_arr, q_in, label="input")
ax[0].plot(t_arr, q_out, label="output")
if has_planet:
ax[0].plot(t_arr, q_p, label="planet")
ax[0].set_ylabel("位置 [rad]"); ax[0].legend()
ax[1].plot(t_arr, tau_in_arr, label="tau_in")
ax[1].plot(t_arr, tau_out_arr, label="tau_out")
ax[1].set_ylabel("力矩 [N·m]"); ax[1].legend()
ax[2].plot(t_arr, power_arr, label="power_out")
ax[2].set_ylabel("功率 [W]"); ax[2].set_xlabel("时间 [s]"); ax[2].legend()
fig.tight_layout()
png_path = os.path.join(cfg["out_dir"], "report_curves.png")
fig.savefig(png_path, dpi=120)
print(f"已写出 {png_path}")
except ImportError:
print("未安装 matplotlib,跳过绘图")
if __name__ == "__main__":
main()