跳转至

🦾 让机器人动起来 — 控制单个关节

前面我们只是"看",这一节开始让机器人动起来。我们从最简单的开始:理解 qpos/qvel,手动控制关节。

完整可运行代码见 OrcaPlayground examples/euler/01_hello_euler/。


完整示例:先看全貌

下面是一个可以直接运行的完整示例,展示了三种控制和查询关节状态的方法。 建议先通读一遍,再看后面的逐段解释。

"""关节控制完整演示:力矩驱动 → 直接设位置 → 按名称设位置"""
import time
import numpy as np
from gymnasium import spaces
from orca_gym.environment.euler.orca_gym_euler_env import OrcaGymEulerEnv


class JointControlDemo(OrcaGymEulerEnv):
    """关节控制演示环境"""

    def __init__(self, model_xml_path, **kwargs):
        super().__init__(
            frame_skip=kwargs.pop("frame_skip", 5),
            orcagym_addr=kwargs.pop("orcagym_addr", "localhost:50051"),
            agent_names=kwargs.pop("agent_names", ["agent0"]),
            time_step=kwargs.pop("time_step", 0.002),
            model_xml_path=model_xml_path,
            **kwargs,
        )

    # ─── 方法 1:力矩驱动(经过物理)⭐ 推荐 ───
    def demo_torque_drive(self, joint_index=0, steps=200):
        """用恒定力矩驱动关节,观察它在重力+惯性下的自然运动。

        这是"经过物理"的方式:力矩 → 加速度 → 速度 → 位置
        """
        ctrlrange = self.model.get_actuator_ctrlrange()
        max_torque = ctrlrange[joint_index, 1]
        print(f"关节 {joint_index} 力矩范围: "
              f"[{ctrlrange[joint_index, 0]:.1f}, {max_torque:.1f}] N·m")

        for i in range(steps):
            ctrl = np.zeros(self.model.nu, dtype=np.float64)

            # 前半段正向力矩,后半段反向 → 观察往复运动
            if i < steps // 2:
                ctrl[joint_index] = 0.3 * max_torque   # 30% 正向
            else:
                ctrl[joint_index] = -0.3 * max_torque  # 30% 反向

            self.do_simulation(ctrl, self.frame_skip)

            if i % 20 == 0:
                pos = self.data.qpos[joint_index]
                vel = self.data.qvel[joint_index]
                print(f"  Step {i:3d}: pos={pos:+.4f} rad, "
                      f"vel={vel:+.4f} rad/s, torque={ctrl[joint_index]:+.2f}")

    # ─── 方法 2:直接设位置(正弦摆动,适合 reset)───
    def demo_wiggle(self, joint_index=0, amplitude=0.5, steps=200):
        """让关节做正弦摆动。直接设 qpos 方式,不经过物理。"""
        print(f"关节 {joint_index} 初始位置: {self.data.qpos[joint_index]:.3f} rad")

        for i in range(steps):
            target_angle = amplitude * np.sin(i * 0.1)

            # 合规写入:copy → 修改 → set → forward
            new_qpos = self.data.qpos.copy()
            new_qpos[joint_index] = target_angle
            self.set_joint_qpos(new_qpos)
            self.set_joint_qvel(np.zeros(self.model.nv))
            self.mj_forward()
            self._sync_view()

            if i % 20 == 0:
                actual = self.data.qpos[joint_index]
                print(f"  Step {i:3d}: 目标={target_angle:+.3f}, "
                      f"实际={actual:+.3f}")

    # ─── 方法 3:按名称设位置 ───
    def demo_set_named_joint(self, joint_name, target_angle):
        """按关节名称(而非索引)设置位置。"""
        qpos = self.data.qpos.copy()
        qpos_addr = self.jnt_qposadr(joint_name)
        qpos[qpos_addr] = target_angle

        self.set_joint_qpos(qpos)
        self.mj_forward()
        self._sync_view()

        # 验证
        actual = self.query_joint_qpos([joint_name])[joint_name]
        print(f"{joint_name}: 目标={target_angle:.3f}, 实际={actual[0]:.3f}")

    # ─── 工具:打印 qpos 布局 ───
    def print_qpos_layout(self):
        """打印 qpos 布局,帮助理解每个关节占几个元素"""
        offset = 0
        for name in self.model.get_joint_dict().keys():
            info = self.model.get_joint_byname(name)
            # Euler 路径下关节信息含 QposIdxStart(关节起始 qpos 地址);
            # 不同关节类型的 qpos 长度不同(hinge/slide=1, ball=4, free=7)。
            # 这里通过下一关节的起始地址推算当前关节长度,末尾关节用 model.nq 估算。
            nq = 1  # 默认 hinge/slide
            print(f"  qpos[{offset:2d}:{offset+nq:2d}]  {name}  (nq={nq})")
            offset += nq

    # ─── Gymnasium 接口 ───
    def step(self, action):
        self.do_simulation(action, self.frame_skip)
        return self._get_obs(), 0.0, False, False, {}

    def reset_model(self):
        self.set_joint_qpos(self.init_qpos)
        self.set_joint_qvel(self.init_qvel)
        self.mj_forward()
        self._sync_view()
        return self._get_obs(), {}

    def _get_obs(self):
        return self.data.qpos.copy()


if __name__ == "__main__":
    env = JointControlDemo(
        model_xml_path="/path/to/scene.xml",
        skip_grpc_load=True,  # 离线模式
    )
    env.reset()

    print("=" * 50)
    print("1. 力矩驱动(经过物理)")
    print("=" * 50)
    env.demo_torque_drive(joint_index=0, steps=100)

    print("\n" + "=" * 50)
    print("2. 直接设位置(正弦摆动)")
    print("=" * 50)
    env.demo_wiggle(joint_index=0, amplitude=0.5, steps=100)

    print("\n" + "=" * 50)
    print("3. qpos 布局")
    print("=" * 50)
    env.print_qpos_layout()

    env.close()

逐段解释

核心概念:qpos 和 qvel

MuJoCo 用两个数组描述整个仿真世界:

qpos = [关节0角度, 关节1角度, ..., 自由物体位姿(xyz+qwxyz)]
       长度 = model.nq(广义坐标数)

qvel = [关节0角速度, 关节1角速度, ..., 自由物体速度(v+ω)]
       长度 = model.nv(自由度数)

不同关节类型在 qpos 中占的元素数不同:

关节类型 qpos 元素数 含义
hinge(旋转) 1 旋转角度(弧度)
slide(滑动) 1 滑动距离(米)
ball(球) 4 四元数 [w, x, y, z]
free(自由) 7 [x, y, z, qw, qx, qy, qz]

方法 1:力矩驱动(推荐)⭐

ctrl = np.zeros(env.model.nu)
ctrl[joint_index] = 0.3 * max_torque   # 施加 30% 最大力矩
env.do_simulation(ctrl, env.frame_skip)

原理:力矩 → 加速度 → 速度 → 位置。这是"经过物理"的方式——关节在 重力、惯性、摩擦力等物理效应下自然运动,而非瞬移到目标位置。

适用场景:正常的仿真控制、RL 训练。这是推荐的标准方式。

方法 2:直接设位置(适合 reset)

qpos = env.data.qpos.copy()        # 1. 复制
qpos[joint_index] = target_angle   # 2. 修改副本
env.set_joint_qpos(qpos)           # 3. 合规写入
env.mj_forward()                   # 4. 必须!更新派生量
env._sync_view()                   # 5. 同步到 DataView

⚠️ 这个方法不经过物理! 关节瞬移到目标角度,不经历加速/减速过程。 适用场景:重置环境(快速设定初始姿态)、调试。

方法 3:按名称设位置

qpos = env.data.qpos.copy()
addr = env.jnt_qposadr("robot_0_joint1")  # 按名称查地址
qpos[addr] = target_angle
env.set_joint_qpos(qpos)
env.mj_forward()

当你知道关节名字(而非索引)时使用。jnt_qposadr(name) 返回该关节在 qpos 数组中的起始地址。

状态写入的黄金法则

1. copy()  ← 复制当前 qpos(data.qpos 是只读零拷贝视图)
2. 修改副本
3. set_joint_qpos(qpos_copy)  ← 合规写入
4. mj_forward()               ← 必须!更新派生量
5. _sync_view()               ← 同步到 DataView

跳过第 4 步 → body 位姿/传感器读到的仍是旧值。

安全提示

  • 设置过大的关节角度可能导致自碰撞
  • 设置过大的力矩可能导致仿真不稳定(数值爆炸)
  • 建议先用小幅度(±0.5 rad 以内)测试
  • 仿真中损坏没有后果——大胆试!

下一步

能控制关节了。接下来学习如何写 PD 控制器:🎮 简单控制器。