No.3 单摆控制仿真

本节介绍倒立摆(Inverted Pendulum)的 MuJoCo 建模与闭环控制,包括关节电机、传感器配置,以及两种控制模式(力矩模式 vs 伺服模式)。


文件说明

本节的示例文件位于 mujoco/No_3/ 目录下:

mujoco/No_3/
├── no_3.py              # 最小主脚本(使用 viewer.launch_passive)
├── control_pendulum.py   # 完整交互脚本(使用 GLFW)
└── pendulum.xml         # MuJoCo XML 模型文件

一、pendulum.xml 详解(对比 No.2 的 ball.xml)

No.3 pendulum.xml 完整代码

<mujoco>
    <!-- 全局仿真选项:重力加速度 -->
    <option gravity="0 0 -9.81">
    </option>

    <worldbody>
        <!-- 场景光源 -->
        <light diffuse=".5 .5 .5" pos="0 0 3" dir="0 0 -1"/>

        <!-- 红色平面地面 -->
        <geom type="plane" size="1 1 0.1" rgba=".9 0 0 1"/>

        <!-- 摆杆 body:固定在 (0, 0, 2),绕 y 轴旋转 -->
        <body pos="0 0 2" euler="0 180 0">
            <!--
                hinge 关节:约束为只能绕一个轴旋转
                对比 No.2:No.2 使用 free 关节(6 自由度)
                          No.3 使用 hinge 关节(1 自由度)
            -->
            <joint name="pin" type="hinge" axis="0 -1 0" pos="0 0 0.5"/>
            <!-- 绿色圆柱几何体,半径 0.05,长度 0.5,质量 1 -->
            <geom type="cylinder" size=".05 .5" rgba="0 .9 0 1" mass="1"/>
        </body>
    </worldbody>

    <!--
        actuator 驱动器:【No.3 新增】
        对比 No.2:No.2 没有 actuator,只有纯物理仿真
                  No.3 新增了 3 种驱动器用于闭环控制
    -->
    <actuator>
        <!-- 力矩电机:直接控制关节力矩 -->
        <motor joint="pin" name="torque" gear="1" ctrllimited="true" ctrlrange="-100 100"/>
        <!-- 位置伺服:使用 PD 控制跟踪目标位置 -->
        <position name="position_servo" joint="pin" kp="10"/>
        <!-- 速度伺服:使用 PD 控制跟踪目标速度 -->
        <velocity name="velocity_servo" joint="pin" kv="0"/>
    </actuator>

    <!--
        sensor 传感器:【No.3 新增】
        对比 No.2:No.2 没有 sensor
                  No.3 新增了位置和速度传感器用于闭环反馈
    -->
    <sensor>
        <!-- 关节位置传感器,带噪声 -->
        <jointpos joint="pin" noise="0.2"/>
        <!-- 关节速度传感器,带噪声 -->
        <jointvel joint="pin" noise="1"/>
    </sensor>
</mujoco>

No.2 ball.xml 完整代码(对比参考)

<mujoco>
    <worldbody>
        <!-- 场景光源 -->
        <light diffuse=".5 .5 .5" pos="0 0 3" dir="0 0 -1"/>

        <!-- 红色平面地面 -->
        <geom type="plane" size="10 1 0.1" rgba=".9 0 0 1"/>

        <!-- 绿色小球,初始位置 z=1 -->
        <body pos="0 0 1">
            <!-- free 关节:6 自由度,可自由平移和旋转 -->
            <joint type="free"/>
            <geom type="sphere" size=".1" rgba="0 .9 0 1"/>
        </body>
    </worldbody>
    <!-- No.2 没有 actuator 和 sensor -->
</mujoco>

XML 配置对比表

配置项No.2 (ball.xml)No.3 (pendulum.xml)
关节类型free(6 自由度)hinge(1 自由度)
关节名称name="pin"
几何体sphere(球)cylinder(圆柱)
actuator3 个(motor、position、velocity)
sensor2 个(jointpos、jointvel)
mass 定义无(自动计算)mass="1" 显式指定

关节类型详解

关节类型自由度适用场景No.2No.3
free6自由运动物体(球、无人机)
hinge1旋转关节(摆、门)
slide1滑动关节(活塞)--
ball3球形关节(机械臂)--

二、no_3.py 详解(最小脚本)

完整代码

import mujoco
import mujoco.viewer
import time

# 加载 XML 模型
model = mujoco.MjModel.from_xml_path('pendulum.xml')
data = mujoco.MjData(model)

# 使用 viewer.launch_passive 启动被动查看器
# 对比 No.2:No.2 的 template_mujoco.py 使用完整 GLFW 窗口(170 行)
#           No.3 的 no_3.py 使用简化 viewer(仅 12 行核心代码)
with mujoco.viewer.launch_passive(model, data) as viewer:
    while viewer.is_running():
        mujoco.mj_step(model, data)
        viewer.sync()
        time.sleep(1/500)  # ~60 Hz real-time,约 500ms 休眠实现实时仿真

核心 API 对比

APINo.1 (no_1.py)No.3 (no_3.py)
模型加载MjModel.from_xml_path()MjModel.from_xml_path()
可视化viewer.launch_passive()viewer.launch_passive()
仿真步进mj_step()mj_step()
同步方式viewer.sync()viewer.sync()
主循环条件viewer.is_running()viewer.is_running()
帧率控制time.sleep(1/500)

三、control_pendulum.py 详解(完整脚本)

完整代码

import mujoco as mj
from mujoco.glfw import glfw
import numpy as np
import os

# XML 模型文件路径
xml_path = 'pendulum.xml'
# 仿真结束时间(秒),simend=5 表示仿真运行 5 秒后自动停止
simend = 5

# ============================================================
# 鼠标状态变量(用于鼠标拖动交互)
# 【与 No.2 完全相同】
# ============================================================
button_left = False   # 鼠标左键是否按下
button_middle = False # 鼠标中键是否按下
button_right = False  # 鼠标右键是否按下
lastx = 0             # 上一次鼠标 x 位置
lasty = 0             # 上一次鼠标 y 位置

# ============================================================
# controller 回调:每一步仿真前调用,可在此写入控制指令
# 【核心差异】No.2 的 controller 为空 pass,No.3 实现 PD 控制
# ============================================================
def controller(model, data):
    """
    PD 控制器实现

    对比 No.2:
    - No.2 的 controller 为空 pass,无任何控制逻辑
    - No.3 的 controller 根据 actuator_type 实现两种控制模式
    """
    if actuator_type == "torque":
        # 力矩模式:直接写入控制量
        # 对比 No.2:No.2 没有 actuator_gainprm 配置
        model.actuator_gainprm[0, 0] = 1
        # PD 控制:ctrl = -kp * pos_error - kv * vel_error
        data.ctrl[0] = -10 * \
            (data.sensordata[0] - 0.0) - \
            1 * (data.sensordata[1] - 0.0)
    elif actuator_type == "servo":
        # 伺服模式:配置增益参数
        kp = 10.0
        model.actuator_gainprm[1, 0] = kp
        model.actuator_biasprm[1, 1] = -kp
        data.ctrl[1] = -0.5

        kv = 1.0
        model.actuator_gainprm[2, 0] = kv
        model.actuator_biasprm[2, 2] = -kv
        data.ctrl[2] = 0.0

# ============================================================
# keyboard 回调:键盘事件处理
# 【与 No.2 完全相同】
# ============================================================
def keyboard(window, key, scancode, act, mods):
    if act == glfw.PRESS and key == glfw.KEY_BACKSPACE:
        mj.mj_resetData(model, data)
        mj.mj_forward(model, data)

# ============================================================
# mouse_button 回调:记录鼠标按键状态
# 【与 No.2 完全相同】
# ============================================================
def mouse_button(window, button, act, mods):
    global button_left, button_middle, button_right
    button_left = (glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_LEFT) == glfw.PRESS)
    button_middle = (glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_MIDDLE) == glfw.PRESS)
    button_right = (glfw.get_mouse_button(window, glfw.MOUSE_BUTTON_RIGHT) == glfw.PRESS)

# ============================================================
# mouse_move 回调:鼠标拖动改变视角
# 【与 No.2 完全相同】
# ============================================================
def mouse_move(window, xpos, ypos):
    global lastx, lasty, button_left, button_middle, button_right
    dx = xpos - lastx
    dy = ypos - lasty
    lastx = xpos
    lasty = ypos

    if not button_left and not button_middle and not button_right:
        return

    width, height = glfw.get_window_size(window)
    mod_shift = (glfw.get_key(window, glfw.KEY_LEFT_SHIFT) == glfw.PRESS or
                 glfw.get_key(window, glfw.KEY_RIGHT_SHIFT) == glfw.PRESS)

    if button_right:
        action = mj.mjtMouse.mjMOUSE_MOVE_H if mod_shift else mj.mjtMouse.mjMOUSE_MOVE_V
    elif button_left:
        action = mj.mjtMouse.mjMOUSE_ROTATE_H if mod_shift else mj.mjtMouse.mjMOUSE_ROTATE_V
    else:
        action = mj.mjtMouse.mjMOUSE_ZOOM

    mj.mjv_moveCamera(model, action, dx/height, dy/height, scene, cam)

# ============================================================
# scroll 回调:滚轮缩放视角
# 【与 No.2 完全相同】
# ============================================================
def scroll(window, xoffset, yoffset):
    mj.mjv_moveCamera(model, mj.mjtMouse.mjMOUSE_ZOOM, 0.0, -0.05*yoffset, scene, cam)

# ============================================================
# 模型加载
# 【与 No.2 完全相同】
# ============================================================
dirname = os.path.dirname(os.path.abspath(__file__))
abspath = os.path.join(dirname, xml_path)

model = mj.MjModel.from_xml_path(abspath)
data = mj.MjData(model)
cam = mj.MjvCamera()
opt = mj.MjvOption()

# ============================================================
# GLFW 初始化和窗口创建
# 【与 No.2 完全相同】
# ============================================================
glfw.init()
window = glfw.create_window(1200, 900, "Demo", None, None)
glfw.make_context_current(window)
glfw.swap_interval(1)

# ============================================================
# MuJoCo 可视化数据结构初始化
# 【与 No.2 完全相同】
# ============================================================
mj.mjv_defaultCamera(cam)
mj.mjv_defaultOption(opt)
scene = mj.MjvScene(model, maxgeom=10000)
context = mj.MjrContext(model, mj.mjtFontScale.mjFONTSCALE_150.value)

# ============================================================
# 注册 GLFW 回调和 MuJoCo 控制回调
# 【与 No.2 完全相同】
# ============================================================
glfw.set_key_callback(window, keyboard)
glfw.set_cursor_pos_callback(window, mouse_move)
glfw.set_mouse_button_callback(window, mouse_button)
glfw.set_scroll_callback(window, scroll)
mj.set_mjcb_control(controller)

# ============================================================
# 初始条件设置
# 【核心差异】
# No.2:data.qvel[0] = 2.0(设置初速度)
# No.3:data.qpos[0] = np.pi/2(设置初始角度)
# ============================================================
data.qpos[0] = np.pi/2  # 摆杆初始角度 90°(朝上位置)

# 设置相机视角
cam.azimuth = 90.0
cam.distance = 5.0
cam.elevation = -5
cam.lookat = np.array([0.012768, -0.000000, 1.254336])

# ============================================================
# 主仿真循环
# 【与 No.2 完全相同】
# ============================================================
while not glfw.window_should_close(window):
    simstart = data.time

    while (data.time - simstart < 1.0/60.0):
        mj.mj_step(model, data)

    if (data.time >= simend):
        break

    # 获取窗口帧缓冲区大小
    viewport_width, viewport_height = glfw.get_framebuffer_size(window)
    viewport = mj.MjrRect(0, 0, viewport_width, viewport_height)

    # 更新场景并渲染
    mj.mjv_updateScene(model, data, opt, None, cam,
                       mj.mjtCatBit.mjCAT_ALL.value, scene)
    mj.mjr_render(viewport, scene, context)

    # 交换 OpenGL 缓冲区
    glfw.swap_buffers(window)
    # 处理 GUI 事件
    glfw.poll_events()

glfw.terminate()

控制模式详解

模式 1:力矩模式(torque)

model.actuator_gainprm[0, 0] = 1
data.ctrl[0] = -10 * (data.sensordata[0] - 0.0) - 1 * (data.sensordata[1] - 0.0)
参数说明来源
sensordata[0]关节位置(rad)<sensor><jointpos>
sensordata[1]关节速度(rad/s)<sensor><jointvel>
kp=10位置增益手动设置
kv=1速度增益手动设置
目标位置0.0手动设置

模式 2:伺服模式(servo)

kp = 10.0
model.actuator_gainprm[1, 0] = kp
model.actuator_biasprm[1, 1] = -kp
data.ctrl[1] = -0.5  # 目标位置

使用 XML 中定义的 position_servo,通过配置增益参数实现 PD 控制。


四、No.2 与 No.3 完整代码对比

代码结构对比

模块No.2 (template_mujoco.py)No.3 (control_pendulum.py)
import
全局变量(鼠标状态)
controller 回调pass(空)PD 控制器
keyboard 回调
mouse_button 回调
mouse_move 回调
scroll 回调
模型加载
GLFW 初始化
可视化结构
回调注册
初始条件qvel 设置速度qpos 设置角度
主循环
glfw.terminate

关键差异代码片段

1. 初始条件设置

No.2(template_mujoco.py):

# No initial velocity in template, but in projectile.py:
data.qvel[0] = 2.0  # vx
data.qvel[2] = 5.0  # vz

No.3(control_pendulum.py):

data.qpos[0] = np.pi/2  # 摆杆初始角度 90°(朝上)

2. 控制回调

No.2(template_mujoco.py):

def controller(model, data):
    """控制回调,每 mj_step 前自动调用"""
    pass  # 无任何控制逻辑

No.3(control_pendulum.py):

actuator_type = "torque"  # 或 "servo"

def controller(model, data):
    if actuator_type == "torque":
        model.actuator_gainprm[0, 0] = 1
        data.ctrl[0] = -10 * (data.sensordata[0] - 0.0) - 1 * (data.sensordata[1] - 0.0)
    elif actuator_type == "servo":
        kp = 10.0
        model.actuator_gainprm[1, 0] = kp
        model.actuator_biasprm[1, 1] = -kp
        data.ctrl[1] = -0.5

五、运行方法

mujoco/No_3/ 目录下执行:

# 最小脚本(仅可视化,无控制)
python no_3.py

# 完整脚本(带控制)
python control_pendulum.py

运行效果:

  • no_3.py:摆杆从 90° 位置自由摆动(无控制),仅做可视化
  • control_pendulum.py:摆杆在 PD 控制下稳定在目标位置(0 rad)

六、与 No.2 的整体对比总结

功能特性对比

特性No.2 (弹球)No.3 (倒立摆)
模型类型弹球 + 地面单摆 + 地面
关节类型free(6 自由度)hinge(1 自由度)
驱动器3 个(motor、position、velocity)
传感器2 个(jointpos、jointvel)
控制方式无(被动仿真)力矩控制 + 伺服控制
初始状态设置data.qvel(速度)data.qpos(角度)
主脚本行数170 行(GLFW)12 行(viewer)/ 171 行(GLFW)
控制器复杂度PD 控制实现

学习路径

No.1: 基础建模 + viewer 可视化(被动窗口)
  ↓
No.2: GLFW 窗口 + 鼠标交互 + 回调机制 + 弹球物理
  ↓
No.3: 关节控制 + 传感器读取 + 闭环控制 + 倒立摆

代码复用情况

代码模块No.2 → No.3
鼠标状态变量完全相同
keyboard 回调完全相同
mouse_button 回调完全相同
mouse_move 回调完全相同
scroll 回调完全相同
GLFW 初始化完全相同
可视化结构完全相同
回调注册完全相同
主循环完全相同
controller 回调完全不同(空 → PD)
初始条件完全不同(速度 → 角度)
XML 模型完全不同(弹球 → 倒立摆)

七、常见问题

1. 摆杆直接穿透地面

原因:初始位置或关节配置错误。

解决:检查 euler="0 180 0" 确保摆杆朝下,检查 joint axis 方向。

2. 控制不稳定

原因

  • 比例增益(kp)过大
  • 缺少重力补偿
  • 传感器噪声过大

解决:调整 kpkv 参数,或启用重力补偿。

3. AttributeError: 'NoneType' object has no attribute '...'

原因:GLFW 窗口未正确创建。

解决glfw.create_window() 返回 None 时,检查显示器配置或降低分辨率。