diff --git a/.gitignore b/.gitignore index ef1d986..8517934 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .idea .vscode output* -demo_* .DS_Store __pycache__/ *.py[cod] diff --git a/backend/main.py b/backend/main.py index 06e0d76..2af11d0 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,7 +20,7 @@ from backend import api from backend.api import router as api_router from backend.config import config -from backend.sio_handlers import SimNamespace, RealNamespace, MujocoNamespace, start_game_loop, set_act_runtime as set_sio_act_runtime, start_mujoco_game_loop +from backend.sio_handlers import SimNamespace, RealNamespace, start_game_loop, set_act_runtime as set_sio_act_runtime from backend.utils import set_broadcast_sio, setup_socket_logging # 配置日志 - 生产环境只记录关键事件 @@ -54,7 +54,6 @@ async def lifespan(app: FastAPI): # 启动两个命名空间的状态广播 start_game_loop(sio, namespace="/sim") start_game_loop(sio, namespace="/real") - start_mujoco_game_loop(sio, namespace="/mujoco") yield @@ -87,7 +86,6 @@ async def lifespan(app: FastAPI): # 注册三个独立的命名空间 sio.register_namespace(SimNamespace("/sim")) sio.register_namespace(RealNamespace("/real")) -sio.register_namespace(MujocoNamespace("/mujoco")) # 设置sio_server到api模块 api.set_sio_server(sio) diff --git a/backend/requirements.txt b/backend/requirements.txt index 59dfc43..e7b6214 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -23,5 +23,3 @@ pytest>=8.0.0 python-multipart einops redis>=5.0.0 - -mujoco diff --git a/backend/services/mujoco_renderer/__init__.py b/backend/services/mujoco_renderer/__init__.py deleted file mode 100644 index 2929fc5..0000000 --- a/backend/services/mujoco_renderer/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from backend.services.mujoco_renderer.renderer import MujocoRenderer -from backend.services.mujoco_renderer.state import MujocoState - -__all__ = ["MujocoRenderer", "MujocoState"] \ No newline at end of file diff --git a/backend/services/mujoco_renderer/renderer.py b/backend/services/mujoco_renderer/renderer.py deleted file mode 100644 index 3922d5c..0000000 --- a/backend/services/mujoco_renderer/renderer.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -import logging -import os -from typing import Tuple - -import mujoco -import numpy as np - -logger = logging.getLogger(__name__) - - -class MujocoRenderer: - """Dual-view MuJoCo renderer for top-down and first-person views.""" - - def __init__(self, xml_path: str | None = None): - if xml_path is None: - xml_path = os.path.join( - os.path.dirname(__file__), "..", "..", "..", "mujoco", "car_arm.xml" - ) - self._xml_path = os.path.abspath(xml_path) - - self._model = mujoco.MjModel.from_xml_path(self._xml_path) - self._data = mujoco.MjData(self._model) - - self._renderer_topdown = mujoco.Renderer(self._model, width=640, height=480) - self._renderer_firstperson = mujoco.Renderer(self._model, width=640, height=480) - - self._cam_topdown = mujoco.MjvCamera() - self._cam_firstperson = mujoco.MjvCamera() - mujoco.mjv_defaultCamera(self._cam_topdown) - mujoco.mjv_defaultCamera(self._cam_firstperson) - - self._cam_state = {"azimuth": 0.0, "elevation": -70.0, "distance": 6.0} - self._setup_cameras() - logger.info(f"MujocoRenderer initialized with {self._xml_path}") - def _camera_name_to_id(self, name: str) -> int: - """Convert camera name to ID using mujoco.mj_name2id.""" - return mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_CAMERA, name) - - def _actuator_name_to_id(self, name: str) -> int: - """Convert actuator name to ID using mujoco.mj_name2id.""" - return mujoco.mj_name2id(self._model, mujoco.mjtObj.mjOBJ_ACTUATOR, name) - - def _setup_cameras(self) -> None: - """Configure top-down (free orbit) and first-person (fixed) cameras.""" - self._cam_topdown.type = mujoco.mjtCamera.mjCAMERA_FREE - self._cam_topdown.lookat = np.array([0, 0, 0.3]) - self._cam_topdown.distance = self._cam_state["distance"] - self._cam_topdown.elevation = self._cam_state["elevation"] - self._cam_topdown.azimuth = self._cam_state["azimuth"] - - self._cam_firstperson.type = mujoco.mjtCamera.mjCAMERA_FIXED - self._cam_firstperson.fixedcamid = self._camera_name_to_id("firstperson") - - def update_topdown_camera(self, delta_azimuth: float, delta_elevation: float) -> None: - """Rotate the top-down camera by mouse drag deltas.""" - sensitivity = 0.3 - self._cam_state["azimuth"] += delta_azimuth * sensitivity - self._cam_state["elevation"] += delta_elevation * sensitivity - self._cam_state["elevation"] = max(-89.0, min(89.0, self._cam_state["elevation"])) - - self._cam_topdown.azimuth = self._cam_state["azimuth"] - self._cam_topdown.elevation = self._cam_state["elevation"] - - def update_topdown_distance(self, delta: float) -> None: - """Zoom the top-down camera by scroll delta.""" - self._cam_state["distance"] += delta * 0.5 - self._cam_state["distance"] = max(0.5, min(50.0, self._cam_state["distance"])) - self._cam_topdown.distance = self._cam_state["distance"] - - def get_topdown_image(self) -> np.ndarray: - """Render top-down view.""" - self._renderer_topdown.update_scene(self._data, self._cam_topdown) - return self._renderer_topdown.render() - - def get_firstperson_image(self) -> np.ndarray: - """Render first-person view from camera on car.""" - self._renderer_firstperson.update_scene(self._data, self._cam_firstperson) - return self._renderer_firstperson.render() - - def step(self, arm_action: dict | None = None) -> None: - """Step the simulation.""" - if arm_action is not None: - for actuator_name, torque in arm_action.items(): - try: - actuator_id = self._actuator_name_to_id(actuator_name) - if actuator_id >= 0: - self._data.actuator_force[actuator_id] = torque - except Exception: - pass - - mujoco.mj_step(self._model, self._data) - - def set_wheel_torques(self, left_torque: float, right_torque: float) -> None: - """Apply torques to wheels for differential drive. - - Args: - left_torque: torque for left wheels (fl, rl) - right_torque: torque for right wheels (fr, rr) - """ - wheel_torques = { - "motor_wheel_fl": left_torque, - "motor_wheel_rl": left_torque, - "motor_wheel_fr": right_torque, - "motor_wheel_rr": right_torque, - } - for actuator_name, torque in wheel_torques.items(): - try: - actuator_id = self._actuator_name_to_id(actuator_name) - if actuator_id >= 0: - self._data.actuator_force[actuator_id] = torque - except Exception: - pass - - def get_state(self) -> dict: - """Get current state.""" - car_body_id = mujoco.mj_name2id( - self._model, mujoco.mjtObj.mjOBJ_BODY, "car" - ) - return { - "car_pos": self._data.xpos[car_body_id].copy(), - "car_quat": self._data.xquat[car_body_id].copy(), - "arm_qpos": self._data.qpos[11:].copy(), - "arm_qvel": self._data.qvel[10:].copy(), - } - - def close(self) -> None: - """Clean up renderers.""" - self._renderer_topdown.close() - self._renderer_firstperson.close() \ No newline at end of file diff --git a/backend/services/mujoco_renderer/service.py b/backend/services/mujoco_renderer/service.py deleted file mode 100644 index 25d5bd7..0000000 --- a/backend/services/mujoco_renderer/service.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -MuJoCo 渲染服务 - 提供双视角渲染能力 -""" -from __future__ import annotations - -import base64 -import logging -from io import BytesIO -from typing import Optional - -import mujoco -import numpy as np -from PIL import Image - -logger = logging.getLogger(__name__) - -_renderer: Optional["MujocoRenderer"] = None -_mujoco_service: Optional["MujocoService"] = None - - -def get_renderer() -> "MujocoRenderer": - """获取全局 MuJoCo 渲染器单例""" - global _renderer - if _renderer is None: - from backend.services.mujoco_renderer.renderer import MujocoRenderer - _renderer = MujocoRenderer() - return _renderer - - -def get_mujoco_service() -> "MujocoService": - """获取全局 MuJoCo 服务单例""" - global _mujoco_service - if _mujoco_service is None: - _mujoco_service = MujocoService() - return _mujoco_service - - -def close_mujoco_service() -> None: - """关闭 MuJoCo 服务""" - global _mujoco_service - if _mujoco_service: - _mujoco_service.close() - _mujoco_service = None - - -class MujocoService: - """MuJoCo 服务 - 管理渲染器和状态""" - - def __init__(self): - self._renderer = get_renderer() - self._interval_ms = 50 - - @property - def interval_ms(self) -> int: - return self._interval_ms - - def set_arm_action(self, yaw: float, pitch: float, roll: float) -> None: - """设置机械臂动作(仅设定力矩,由 game loop 统一步进)""" - action = { - "motor_yaw": yaw * 50, - "motor_pitch": pitch * 50, - "motor_roll": roll * 30, - } - for actuator_name, torque in action.items(): - try: - actuator_id = mujoco.mj_name2id( - self._renderer._model, mujoco.mjtObj.mjOBJ_ACTUATOR, actuator_name - ) - if actuator_id >= 0: - self._renderer._data.actuator_force[actuator_id] = torque - except Exception: - pass - - def set_car_action(self, vel_left: float, vel_right: float) -> None: - """设置小车差速动作""" - self._renderer.set_wheel_torques(vel_left, vel_right) - - def step(self) -> None: - """单步模拟""" - self._renderer.step() - - def update_topdown_camera(self, delta_azimuth: float, delta_elevation: float) -> None: - """旋转俯视相机""" - self._renderer.update_topdown_camera(delta_azimuth, delta_elevation) - - def update_topdown_distance(self, delta: float) -> None: - """缩放俯视相机""" - self._renderer.update_topdown_distance(delta) - - def render(self) -> tuple[str, str, dict]: - """ - 渲染双视角图像 - Returns: (topdown_b64, firstperson_b64, state) - """ - topdown_img = self._renderer.get_topdown_image() - firstperson_img = self._renderer.get_firstperson_image() - - topdown_b64 = self._image_to_b64(topdown_img) - firstperson_b64 = self._image_to_b64(firstperson_img) - state = self._renderer.get_state() - - return topdown_b64, firstperson_b64, state - - def _image_to_b64(self, img: np.ndarray) -> str: - """numpy 图像转 base64""" - if img.shape[2] == 3: - pil_img = Image.fromarray(img) - else: - pil_img = Image.fromarray(img[:, :, :3]) - buffer = BytesIO() - pil_img.save(buffer, format="JPEG", quality=85) - return base64.b64encode(buffer.getvalue()).decode("utf-8") - - def close(self) -> None: - """关闭渲染器""" - global _renderer - if _renderer: - _renderer.close() - _renderer = None \ No newline at end of file diff --git a/backend/services/mujoco_renderer/state.py b/backend/services/mujoco_renderer/state.py deleted file mode 100644 index e19709d..0000000 --- a/backend/services/mujoco_renderer/state.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -import mujoco - -if TYPE_CHECKING: - from backend.services.mujoco_renderer.renderer import MujocoRenderer - - -class MujocoState: - """Manages MuJoCo scene state and car+arm control.""" - - def __init__(self, renderer: MujocoRenderer): - self._renderer = renderer - - def set_arm_position(self, qpos: list[float]) -> None: - """Set arm joint positions [yaw, pitch, roll, wrist...].""" - if len(qpos) >= 1: - self._renderer._data.qpos[11] = qpos[0] - if len(qpos) >= 2: - self._renderer._data.qpos[12] = qpos[1] - if len(qpos) >= 3: - self._renderer._data.qpos[13] = qpos[2] - - def get_state(self) -> dict: - """Get current state dict.""" - return self._renderer.get_state() - - def reset(self) -> None: - """Reset to initial state.""" - mujoco.mj_resetData(self._renderer._model, self._renderer._data) \ No newline at end of file diff --git a/backend/sio_handlers/__init__.py b/backend/sio_handlers/__init__.py index a271f8b..d514674 100644 --- a/backend/sio_handlers/__init__.py +++ b/backend/sio_handlers/__init__.py @@ -11,8 +11,6 @@ from backend.sio_handlers.core.namespace import SimNamespace as _SimNamespace from backend.sio_handlers.core.runtime import SioRuntimeState from backend.sio_handlers.core.tasks import game_loop_task -from backend.sio_handlers.domains.mujoco import MujocoEventsMixin -from backend.sio_handlers.core.base import BaseSimNamespace logger = logging.getLogger(__name__) @@ -77,27 +75,6 @@ def set_act_runtime(runtime): _real_runtime_state.set_act_runtime(runtime) -class MujocoNamespace( - MujocoEventsMixin, - BaseSimNamespace, -): - """MuJoCo 仿真页面专用命名空间 - /mujoco""" - - def __init__( - self, - namespace: str | None = "/mujoco", - runtime: SioRuntimeState | None = None, - sim_controller: SimController | None = None, - episode_service: EpisodeService | None = None, - ): - super().__init__( - namespace=namespace, - runtime=runtime or _sim_runtime_state, - sim_controller=sim_controller or _get_sim_controller(), - episode_service=episode_service or _get_sim_episode_service(), - ) - - class SimNamespace(_SimNamespace): """Sim 页面专用命名空间 - /sim""" def __init__( @@ -150,48 +127,3 @@ def start_game_loop( controller = sim_controller or _get_sim_controller() asyncio.create_task(game_loop_task(sio_server, runtime_state, controller, namespace=namespace)) - - -async def mujoco_game_loop_task(sio_server, runtime: SioRuntimeState, namespace: str = "/mujoco"): - """MuJoCo 渲染循环 - 持续渲染并推送状态给连接的客户端""" - from backend.services.mujoco_renderer.service import get_mujoco_service - - logger.info(f"[mujoco_game_loop] 任务已启动, namespace={namespace}") - service = get_mujoco_service() - frame_count = 0 - - while True: - try: - frame_count += 1 - if frame_count % 100 == 0: - logger.info(f"[mujoco_game_loop] frame={frame_count}, clients={len(runtime.connected_clients)}") - - if runtime.connected_clients: - # 每帧都渲染并推送(~50ms 间隔) - topdown, firstperson, state = service.render() - # Convert ndarray values in state to lists - serializable_state = { - k: v.tolist() if hasattr(v, "tolist") else v - for k, v in state.items() - } - payload = { - "topdown": topdown, - "firstperson": firstperson, - "state": serializable_state, - } - # 广播给所有连接的客户端 - await sio_server.emit("mujoco_state_update", payload, namespace=namespace) - - # 物理步进 - service.step() - - except Exception as exc: - logger.error(f"[mujoco_game_loop] 错误: {exc}") - - await asyncio.sleep(0.05) - - -def start_mujoco_game_loop(sio_server, namespace: str = "/mujoco"): - """启动 MuJoCo 游戏循环""" - # 使用 _sim_runtime_state 作为 runtime(MujocoNamespace 也用它) - asyncio.create_task(mujoco_game_loop_task(sio_server, _sim_runtime_state, namespace=namespace)) diff --git a/backend/sio_handlers/domains/mujoco/__init__.py b/backend/sio_handlers/domains/mujoco/__init__.py deleted file mode 100644 index 5b6b422..0000000 --- a/backend/sio_handlers/domains/mujoco/__init__.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import annotations - -import logging - -from backend.services.mujoco_renderer.service import get_mujoco_service - -logger = logging.getLogger(__name__) - - -class MujocoEventsMixin: - """MuJoCo 相关 Socket.IO 事件处理""" - - async def on_connect(self, sid: str, environ: dict, auth: dict | None = None): - """客户端连接""" - self.runtime.connected_clients.add(sid) - logger.info(f"[mujoco] 客户端连接: {sid}") - await self.emit("connected", {"sid": sid}) - - async def on_disconnect(self, sid: str): - """客户端断开""" - self.runtime.connected_clients.discard(sid) - logger.info(f"[mujoco] 客户端断开: {sid}") - - async def on_mujoco_action(self, sid: str, data: dict): - """ - 处理 MuJoCo 机械臂控制动作 - data: { yaw: float, pitch: float, roll: float } - """ - logger.info(f"[mujoco_action] sid={sid}, data={data}") - service = get_mujoco_service() - yaw = data.get("yaw", 0) - pitch = data.get("pitch", 0) - roll = data.get("roll", 0) - service.set_arm_action(yaw, pitch, roll) - - async def on_mujoco_car_action(self, sid: str, data: dict): - """ - 处理 MuJoCo 小车差速控制动作 - data: { vel_left: float, vel_right: float } - """ - logger.info(f"[mujoco_car_action] sid={sid}, data={data}") - service = get_mujoco_service() - vel_left = data.get("vel_left", 0) - vel_right = data.get("vel_right", 0) - service.set_car_action(vel_left, vel_right) - - async def on_mujoco_camera_move(self, sid: str, data: dict): - """ - 处理鼠标拖拽旋转俯视相机 - data: { delta_azimuth: float, delta_elevation: float } - """ - service = get_mujoco_service() - delta_azimuth = data.get("delta_azimuth", 0) - delta_elevation = data.get("delta_elevation", 0) - service.update_topdown_camera(delta_azimuth, delta_elevation) - - async def on_mujoco_camera_zoom(self, sid: str, data: dict): - """ - 处理鼠标滚轮缩放 - data: { delta: float } - """ - service = get_mujoco_service() - service.update_topdown_distance(data.get("delta", 0)) - - async def on_get_mujoco_state(self, sid: str): - """请求当前 MuJoCo 状态和图像""" - service = get_mujoco_service() - topdown, firstperson, state = service.render() - serializable_state = { - k: v.tolist() if hasattr(v, "tolist") else v - for k, v in state.items() - } - await self.emit("mujoco_state_update", { - "topdown": topdown, - "firstperson": firstperson, - "state": serializable_state, - }) \ No newline at end of file diff --git a/docs/src/MuJoCo/No_11.md b/docs/src/MuJoCo/No_11.md new file mode 100644 index 0000000..92d8402 --- /dev/null +++ b/docs/src/MuJoCo/No_11.md @@ -0,0 +1,596 @@ +# No.11 抛射体轨迹优化(NLopt 非线性规划) + +本节介绍一个**完全不同的控制范式** —— **离线轨迹优化**(trajectory optimization)。前 9 节都是「**在线**」控制器:每步读状态、算控制量。No.11 是「**离线**」求解:先用 NLopt 找最优初始速度(v, θ),然后**开环播放**让小球飞向目标。 + +> **核心思想**:把仿真器当作「约束求值器」,扔给优化器,**让算法自己找参数**。 + +--- + +## 文件说明 + +``` +mujoco/No_11/ +├── ball.xml # MuJoCo XML 模型文件(地面 + 圆柱 + 球 + 目标盒) +└── projectile_opt.py # 完整脚本:含 NLopt 优化、simulator、开环播放 +``` + +> No.11 **没有**最小脚本(`no_11.py`)。要看效果必须跑 `projectile_opt.py`。 + +--- + +## 一、ball.xml 详解 + +```xml + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### 场景布局 + +``` + ┌──┐ ← 目标盒 (5, 0, 2.1) + │ │ + │ │ + ┌──────────┴──┴──────────┐ + │ cylinder (5, 0, 1) │ ← 视觉参考柱 + │ size="0.2 1" │ + └──────────┬──────────────┘ + │ +══════════════════╪═══════════════ ← 地面 + ● ← 抛射体 (0, 0, 0.1) + ball (mass=1) +``` + +| 物体 | 位置 | 作用 | +|------|------|------| +| 地面 | z=0 | 球碰到就停 | +| 圆柱 | (5, 0, 1),半径 0.2,高 2 | **视觉**参考柱(无质量)| +| **球** | (0, 0, 0.1),mass=1 | **抛射体**,free 关节 | +| **盒** | (5, 0, 2.1),mass=0.1 | **目标**,需要被击中 | + +> **关键设计**:圆柱**只是视觉**(无质量),目标**是盒**(不是柱顶)。这个反直觉设计让优化目标**精确**(不用算柱顶坐标)。 + +--- + +## 二、核心:弹道优化(NLopt) + +### 2.1 优化问题定义 + +``` + min_X 0 + subject to 0.1 ≤ v ≤ 10000 + 0.1 ≤ θ ≤ π/2 - 0.1 + 0.1 ≤ T ≤ 10000 + x(T) = 5.0 ← 落点 x + z(T) = 2.1 ← 落点 z +``` + +**3 个决策变量**:`X = [v, θ, T]`(初速度大小、发射角、飞行时间) + +**目标函数**:恒为 0(**可行性问题**,找满足约束的参数即可) + +**2 个等式约束**:飞行 T 秒后,位置 (x, z) 必须等于目标 (5.0, 2.1) + +### 2.2 为什么需要优化? + +经典抛物线公式的**解析解**存在(`v = sqrt(g·R² / (2·cos²θ·(tanθ - H/R))`),但: +- 仿真器**有空气阻力、接触、积分误差**等复杂因素 +- 想在「**真实仿真**」里命中目标,解析解**不准确** +- 用**仿真器本身**作为约束求值器,可以自动处理这些细节 + +### 2.3 优化算法:COBYLA + +```python +opt = nlopt.opt(nlopt.LN_COBYLA, 3) +``` + +| 属性 | 值 | 含义 | +|------|----|------| +| 算法 | `LN_COBYLA` | **无导数**、**约束**优化算法 | +| 维度 | 3 | 决策变量数 = 3 | + +**为什么 COBYLA**: +- **不需要梯度**(仿真器的约束没法解析求导) +- **支持等式/不等式约束** +- 对**小规模、低维**问题效率够用 + +### 2.4 完整代码 + +```python +def optimize_ic(x): + opt = nlopt.opt(nlopt.LN_COBYLA, 3) + opt.set_lower_bounds([0.1, 0.1, 0.1]) + opt.set_upper_bounds([10000.0, np.pi/2 - 0.1, 10000.0]) + opt.set_min_objective(cost_func) # cost = 0 + opt.add_equality_mconstraint(equality_constraints, tol=[1e-4, 1e-4]) + opt.set_xtol_rel(1e-4) + sol = opt.optimize(x) + return sol + +def cost_func(x, grad): + return 0.0 # 可行性问题,cost 恒为 0 + +def equality_constraints(result, x, grad): + pos = simulator(x) # 仿真 → 终态位置 + result[0] = pos[0] - 5.0 # x 误差 + result[1] = pos[1] - 2.1 # z 误差 +``` + +> **`simulator(x)` 是核心**:把决策变量 `(v, θ, T)` 转成「**仿真 T 秒后**」的位置。**这个函数是优化器的「黑箱」**。 + +--- + +## 三、simulator 函数详解:仿真器当作约束求值器 + +```python +def simulator(x): + v, theta, time_of_flight = x[0], x[1], x[2] + + # 1. 设置初速度 + data.qvel[0] = v * np.cos(theta) # x 方向 + data.qvel[2] = v * np.sin(theta) # z 方向 + + # 2. 仿真 T 秒 + while data.time < time_of_flight: + mj.mj_step(model, data) + + # 3. 读终态位置 + pos = np.array([data.qpos[0], data.qpos[2]]) + + # 4. 重置 data(为下一次调用准备) + mj.mj_resetData(model, data) + + return pos +``` + +### 3.1 输入 → 输出 + +| 输入 | 含义 | +|------|------| +| `x[0]` = v | 速度大小 | +| `x[1]` = θ | 发射角(与水平面夹角)| +| `x[2]` = T | 飞行时间 | + +| 输出 | 含义 | +|------|------| +| `pos[0]` | T 秒后的 x 坐标 | +| `pos[1]` | T 秒后的 z 坐标 | + +### 3.2 为什么用 `data.time` 作为循环条件? + +```python +while data.time < time_of_flight: + mj.mj_step(model, data) +``` + +`data.time` 由 MuJoCo 内部维护,**每一步 mj_step 增加 `model.opt.timestep`**。当 `data.time` 达到 `time_of_flight` 时停止。 + +### 3.3 为什么 `mj_resetData` 放在最后? + +每次 `simulator` 调用都会**修改**全局 `data`(设初速度、跑仿真)。如果不重置,下一次调用会从「**上一次终态**」开始 → 结果完全错。 + +`mj_resetData` 把 `data.qpos` 和 `data.qvel` 复位到 XML 里的初始值。 + +> **潜在 bug**:`simulator` 假设初始 `qpos` 和 `qvel` 就是 XML 默认值。如果你在调用 `simulator` 之前**先**动了 `data`,状态会污染。 + +### 3.4 `qvel[0]`、`qvel[2]` 的索引 + +`` 给 body **6 个 DOF**(3 平移 + 3 旋转): + +| `qvel` 索引 | 含义 | No.11 是否用到 | +|------------|------|---------------| +| 0 | x 方向线速度 | ✅ | +| 1 | y 方向线速度 | ❌ | +| 2 | z 方向线速度 | ✅ | +| 3 | 绕 x 角速度 | ❌ | +| 4 | 绕 y 角速度 | ❌ | +| 5 | 绕 z 角速度 | ❌ | + +所以 `qvel[0] = v·cos(θ)`、`qvel[2] = v·sin(θ)`。 + +--- + +## 四、init_controller:找最优 v, θ, T + +```python +def init_controller(model, data): + # 初始猜测 + v = 10.0 + theta = np.pi / 4 + time_of_flight = 2.0 + + if NLOPT_IMPORTED: + sol = optimize_ic(np.array([v, theta, time_of_flight])) + else: + sol = np.array([9.398687489285555, 1.2184054599970882, 1.5654456340479144]) + + v_sol, theta_sol = sol[0], sol[1] + simend = sol[2] + 2 # 仿真时间稍长于飞行时间 + + data.qvel[0] = v_sol * np.cos(theta_sol) + data.qvel[2] = v_sol * np.sin(theta_sol) +``` + +### 4.1 Fallback 机制 + +```python +try: + import nlopt +except ImportError: + print("nlopt not imported, switching to pre-computed solution") + NLOPT_IMPORTED = False +``` + +**鲁棒性设计**:如果没装 nlopt,**用预计算解** `sol = [9.40, 1.22, 1.57]`。 + +预计算解 = 优化器对初始猜测 `[10, π/4, 2]` 跑出来的解。 + +### 4.2 初始猜测的物理意义 + +| 变量 | 初始值 | 物理含义 | +|------|--------|---------| +| v | 10.0 m/s | 较快速度(够得着 5m 远) | +| θ | π/4 ≈ 45° | 经典最优抛射角(无空气阻力时)| +| T | 2.0 s | 估计飞行时间 | + +### 4.3 simend 的设计 + +```python +simend = sol[2] + 2 # 仿真时间比飞行时间长 2 秒 +``` + +飞行 T 秒后球**已经落地**。多给 2 秒让球**在地面滚/停**,方便看效果。 + +--- + +## 五、controller 详解:空函数 + +```python +def controller(model, data): + pass +``` + +**这是 No.11 最大的特点**: + +| 之前 No.4-9 | No.11 | +|-------------|-------| +| controller 每步算 `u` | controller **什么都不做** | + +**为什么**?因为: +- 优化器**已经算好**了 v 和 θ +- `init_controller` 已经把 `qvel[0]` 和 `qvel[2]` 设成最优值 +- 之后**开环播放**,让物理引擎自己把球送过去 +- **不需要任何反馈** + +> **这就是「开环控制」的极致形式** —— 控制器**不存在**。 + +--- + +## 六、主循环:开环播放 + +```python +init_controller(model, data) # 离线找最优 v, θ +mj.set_mjcb_control(controller) # 注册空 controller + +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 + + # 相机跟随球 + cam.lookat[0] = data.qpos[0] + mj.mjv_updateScene(...) + mj.mjr_render(...) + glfw.swap_buffers(window) + glfw.poll_events() +``` + +整个主循环**没有**读状态、算控制量、写 actuator —— **纯粹的物理仿真 + 渲染**。 + +--- + +## 七、跟 No.4-9 的本质区别 + +| 维度 | No.4-9 在线控制 | **No.11 离线优化** | +|------|-----------------|-------------------| +| **决策时机** | 每步 | **一次性**(init_controller) | +| **反馈** | 必须有 | **不需要**(开环)| +| **控制律** | `u = K(x)`, `Δq = J⁻¹·Δx` 等 | **没有控制律** | +| **目标** | 跟踪/镇定/轨迹 | **一次性命中目标** | +| **方法** | PD、IK、LQR、FSM | **非线性规划(NLopt)** | +| **仿真器角色** | 物理引擎 | **约束求值器** | +| **失败恢复** | 可以反馈纠错 | **不行**(开环)| +| **计算成本** | 每步 O(n²) ~ O(n³) | 离线 O(N) 次仿真 | +| **鲁棒性** | 中(取决于控制器)| 差(参数不准就 miss) | + +### 控制思想对比 + +``` +No.4-9: 「**在线**」 + 每步: 读 x → 算 u → 写 actuator + 优点: 鲁棒于扰动和参数误差 + 缺点: 需要设计控制器、调参 + +No.11: 「**离线**」 + 一次性: 找参数 (v, θ, T) → 设初速度 → 开环播放 + 优点: 不需要控制器,理论最优 + 缺点: 完全开环,扰动即失败 +``` + +**No.11 是「**模型预测 + 开环执行**」的最简形式**。 + +--- + +## 八、整体流程图 + +``` +启动 ─────────────────────────────────────────── + │ + ├─ 加载 ball.xml + ├─ 创建 model, data + │ + ├─ init_controller: + │ ├─ 设初始猜测 (v=10, θ=π/4, T=2) + │ │ + │ └─ 调 optimize_ic: + │ │ + │ └─ 循环(COBYLA 内部): + │ │ + │ ├─ 提议新参数 (v', θ', T') + │ │ + │ ├─ 调 simulator(v', θ', T'): + │ │ ├─ 设 qvel + │ │ ├─ while data.time < T': mj_step + │ │ ├─ 读 (qpos[0], qpos[2]) + │ │ └─ mj_resetData + │ │ + │ └─ 算约束违反: (pos - target) + │ + ├─ 解: v_sol, θ_sol, T_sol + ├─ data.qvel[0] = v_sol * cos(θ_sol) + ├─ data.qvel[2] = v_sol * sin(θ_sol) + └─ simend = T_sol + 2 + +主循环 ─────────────────────────────────────────── + 每帧 (60Hz): + 内层 1000Hz: mj_step(**无控制**) + 外层: 渲染 + 相机跟随 + + 最终: 球**精准**落在目标盒 (5, 0, 2.1) 附近 +``` + +--- + +## 九、运行方法 + +```bash +# 1. 安装依赖 +pip install nlopt numpy + +# 2. 运行 +cd mujoco/No_11/ +mjpython projectile_opt.py +``` + +预期效果: +- 球从 (0, 0, 0.1) 出发 +- 按优化器算的 (v, θ) 抛射 +- **精准**落在 (5, 0, 2.1) 的目标盒上 +- 相机自动跟随 + +> ⚠️ **第一次启动会卡 1-3 秒** —— NLopt 在跑优化(大约 50-200 次 `simulator` 调用)。这是**离线代价**。 + +--- + +## 十、调参 / 玩转 + +### 1. 改目标位置 + +```python +# 改 XML + → + +# 改约束 +result[0] = pos[0] - 5.0 → result[0] = pos[0] - 7.0 +result[1] = pos[1] - 2.1 → result[1] = pos[1] - 3.0 +``` + +### 2. 加快优化速度 + +```python +opt.set_xtol_rel(1e-4) → opt.set_xtol_rel(1e-2) # 粗糙一点 +tol = [1e-4, 1e-4] → tol = [1e-2, 1e-2] # 约束放宽 +``` + +### 3. 改用更快的算法 + +```python +opt = nlopt.opt(nlopt.LN_COBYLA, 3) # 慢但稳 +opt = nlopt.opt(nlopt.LN_NELDERMEAD, 3) # 单纯形,无约束 +opt = nlopt.opt(nlopt.GN_AGS, 3) # 全局优化(慢但能找到全局最优) +``` + +### 4. 加成本函数 + +```python +def cost_func(x, grad): + # 最小化发射能量 + return 0.5 * x[0]**2 + +# 注意:这样 min 不再是 0,是 0.5·v² +# 优化器会找「**最省力**」的命中方式 +``` + +--- + +## 十一、常见问题 + +### 1. `ImportError: No module named nlopt` + +**解决**: +```bash +# macOS +brew install nlopt +pip install nlopt + +# 或 conda +conda install -c conda-forge nlopt +``` + +装不上就**用预计算解**(代码里已经 fallback)。 + +### 2. 球飞出去没命中目标 + +**可能原因**: +- 数值精度不够(`tol=[1e-4, 1e-4]` 太松) +- 初始猜测离真实解太远,COBYLA 陷入局部 +- 仿真器本身有 bug(mj_resetData 顺序错) + +**调试**: +```python +def simulator(x): + print(f" try v={x[0]:.2f} θ={x[1]:.2f} T={x[2]:.2f}") + v, theta, time_of_flight = x[0], x[1], x[2] + data.qvel[0] = v * np.cos(theta) + data.qvel[2] = v * np.sin(theta) + while data.time < time_of_flight: + mj.mj_step(model, data) + pos = np.array([data.qpos[0], data.qpos[2]]) + mj.mj_resetData(model, data) + print(f" → pos=({pos[0]:.2f}, {pos[1]:.2f})") + return pos +``` + +### 3. 优化要好几秒 + +**原因**:COBYLA 是**无梯度**算法,需要**很多次** `simulator` 调用(每次 ~1000 步仿真)。 + +**加速**: +- 用有限差分给 `grad` 参数填值,配合 `LD_MMA`(需要梯度) +- 用更小的 `tol` +- 减少决策变量数 + +### 4. 球飞完砸穿了地面 + +**原因**:积分器在大 v 下不稳定。 + +**解决**: +- 减小 `timestep`(XML 里的) +- 增大阻尼(无中生有……这个例子没有) + +### 5. `simulator` 重置 data 之后,初始条件变了? + +**原因**:`mj_resetData` 把 qpos 恢复到 XML 里的值。如果**之后**再调 simulator,初始 v 又是基于这个新 qpos。 + +**验证**: +```python +print(data.qpos) # 调用 simulator 后 +# 应该是 [0, 0, 0.1, 1, 0, 0, 0] (位置+四元数) +``` + +### 6. 为什么目标盒是 free 关节? + +因为**目标盒也要被重力影响**(mass=0.1)。如果用固定关节,撞上去会**刚性弹开**。free 关节让它**能跟着被撞飞**,物理上更真实。 + +### 7. 跟 MPC 什么关系? + +| | No.11 离线优化 | MPC(在线) | +|---|---|---| +| 求解时机 | 一次性 | **每个控制步** | +| 计算预算 | 多 | 少 | +| 反应扰动 | ❌ | ✅ | +| 实现复杂度 | 简单 | 复杂 | + +**No.11 是 MPC 的「最简离线版」**。 + +### 8. `data.qvel[0]` 是线速度还是广义速度? + +对 free joint,**是线速度**(单位 m/s)。对 hinge joint,是**角速度**(rad/s)。这是**约定**。 + +### 9. 怎么改成「**加空气阻力**」? + +XML 里加: +```xml +