diff --git a/README.md b/README.md index 1a71e9a..d8cc45c 100644 --- a/README.md +++ b/README.md @@ -86,12 +86,23 @@ for first connection, CLI commands, deployment layout and current limits. my-robot-project/ ├── main.py # Fixed four-line entry point ├── controller.py # Project declarations and control expressions -├── config/ # Optional: declaration data split into yaml files -│ └── runtime.yaml # referenced canonically via read_config("config/runtime.yaml") -├── robot.preset.yaml # Joint order, PD gains, initial state, and calibration -└── assets/ # Project-relative MJCF, ONNX, meshes, and other assets +├── config/ # Declaration data, referenced via read_config("config/.yaml") +│ ├── runtime.yaml # name, control_hz, physics_hz +│ ├── parameters.yaml # tunable parameters (label/default/range/step/unit) +│ ├── keyboard.yaml # keyboard bindings (gamepad.yaml optional) +│ ├── watch.yaml # Watch panel curves ┐ fed via project(panels={...}) +│ ├── metrics.yaml # Metrics panel readouts ┘ +│ ├── control.yaml # optional control_profile for external control +│ └── robot.preset.yaml # Joint order, PD gains, initial state, and calibration +│ # (one .preset.yaml per calibrated resource) +└── assets/ # Project-relative MJCF (+ meshes), ONNX, and scene XML ``` +See [docs/project-setup.md](docs/project-setup.md) for the step-by-step scaffold: +write the skeleton, drop assets, calibrate presets, declare resources, then the +three-section `create_controller` (observation → policy → output). Root-level +`*.preset.yaml` files are a legacy form: read-compatible, never created new. + `main.py` must use the standard four-line entry point required by compiled controller releases: @@ -122,7 +133,7 @@ def create_controller(sim): ]) action = actor(observation).action() target = robot.default_angles + robot.action_scale * action[robot.motor_order] - return PolicyController(robot, control=target) + return PolicyController(robot, target=target) ``` See the BotLab repository's `docs/api/` directory for the complete API @@ -180,7 +191,7 @@ with unused legacy files. Use a browser runtime that supports `project(panels=.. | `botlab ui fetch` | Install the browser runtime built by the BotLab frontend: `--archive ` imports an offline archive, `--url ` downloads from a mirror (or set `BOTLAB_RUNTIME_URL`). | | `botlab ui export --botlab --out ` | Export the separate version-matched browser runtime distribution from a BotLab build. | | `botlab ui status` | Show the status of the bundled UI snapshot. | -| `botlab docs` | Print where the bundled docs (component reference, decision table, error guide) live in this install. | +| `botlab docs` | Print where the bundled docs (project setup guide, component reference, decision table, error guide) live in this install. | Resource names are identities: `Sensor(robot, name="Depth")` refers to the robot's declared ID even when `Robot(..., name="G1")` has a separate MJCF path. @@ -266,6 +277,10 @@ existing clients. ## Documentation +- [Project setup guide](https://github.com/OpenLegged/botlab-python/blob/main/docs/project-setup.md) + — scaffold a runnable project from scratch: skeleton, assets, presets, + declarations, three-section controller, config split, and the validate/run + loop. - [Component reference](https://github.com/OpenLegged/botlab-python/blob/main/docs/component-reference.md) — all public SDK symbols and their contracts. - [Decision table](https://github.com/OpenLegged/botlab-python/blob/main/docs/decision-table.md) diff --git a/docs/project-setup.md b/docs/project-setup.md new file mode 100644 index 0000000..004ed0b --- /dev/null +++ b/docs/project-setup.md @@ -0,0 +1,184 @@ +# 项目搭建指南(botlab-python / CLI 视角) + +一个 BotLab 项目 = 两个固定入口 + `config/` 声明文件 + `assets/` 资产。浏览器编辑器与 +本 CLI 消费同一份契约;本页讲本地从零搭一个可 `botlab run` 的项目。API 语义详见 +BotLab 仓库的 `docs/api/`;组件速查见 +[component-reference.md](./component-reference.md)。 + +## 标准骨架 + +```text +my-robot-project/ +├── main.py # 固定四行入口,不改动 +├── controller.py # 声明段 + create_controller(observation/policy/output 三段) +├── config/ # 声明数据(受限 YAML 子集),read_config("config/.yaml") 引用 +│ ├── runtime.yaml # name、control_hz、physics_hz +│ ├── parameters.yaml # 可调参数:label / default / range / step / unit +│ ├── keyboard.yaml # 键盘绑定;gamepad.yaml 可选 +│ ├── watch.yaml # Watch 面板曲线 +│ ├── metrics.yaml # Metrics 面板读数 +│ ├── control.yaml # 外部控制面 control_profile(接 botlab control 才需要) +│ └── <设备名>.preset.yaml # 按资源标定:robot、depth-camera、scene…… +├── assets/ +│ ├── robot/<机器人名>/*.xml + meshes/ # MJCF 与网格 +│ ├── models/*.onnx # 策略网络 +│ └── scenes/*.xml # 场景 +└── <辅助模块>.py # 复杂策略拆分;controller.py 显式 import +``` + +规则: + +- preset 住 `config/`(`config/.preset.yaml`);根级 `*.preset.yaml` 是旧工程 + 形态,读取兼容但不新建。 +- 长向量(关节顺序、PD、初始姿态、相机外参)只进 preset,用通用 camelCase 字段名 + (`policyJointOrder`、`motorJointOrder`、`actionScale`、`defaultJointPositions`、 + `kp`、`kd`、`effortLimit`、`initialQpos`),不复制进 Python。 +- 取决于策略训练方式的量必须显式声明:`history(..., layout=)` 恒显式(`"by-frame"` = + IsaacGym、`"by-term"` = IsaacLab),观测顺序即列表顺序,减参考位姿是算法选择。 +- 资源构造器用**字面量**路径;循环或 f-string 会把资源从工作区树里藏掉。 +- 算法拓扑不进 preset:observation 顺序、history、归一化写在项目代码里。 + +### 目录规则 + +| 位置 | 规则 | +| --- | --- | +| 根级 | 只放 `main.py`、`controller.py` 与辅助 `*.py` 模块(显式 import,顶层段不得叫 `botlab`);说明文档可放根级 | +| `config/` | 只接受一级 `config/.yaml` 与 `config/.preset.yaml`——无子目录、无点文件 | +| `assets/robot/` | **每台机器人一个以机器人名命名的文件夹**(含 `meshes/`):相对引用按 XML 所在目录解析,命名文件夹隔离各机器人的 mesh 集;按类型分目录(`xml/`、`meshes/` 混放)是旧形态 | +| `assets/models/` | 单文件 ONNX 平铺;一组模型带附属文件时按模型组一个文件夹 | +| 其他资产 | 场景 XML 连同贴图放同目录;`motions/`、`meshes/`、`data/` 沿用惯例目录 | +| `import_tools/` | 仅动作导入类项目放 `file_rules.json` 等处理器声明 | +| 不要建 | `manifest.json`(平台模板专属)、根级 `*.preset.yaml`、`botlab.yaml`(旧形态) | + +## 搭建步骤 + +1. **建骨架**:手工创建上面的目录;`main.py` 固定为: + + ```python + from botlab import Simulation + from controller import create_controller + + sim = Simulation.from_project() + sim.run(create_controller(sim)) + ``` + +2. **放资产**:MJCF(连同 `meshes/`)、ONNX、场景 XML 放进 `assets/`。机器人 XML 的 + 相对 include、mesh、纹理按其所在目录解析;所有资源必须在项目目录内(自包含)。 + +3. **写 preset**:`config/robot.preset.yaml` 以 `kind: "robot"` 开头,填关节布局、PD、 + 初始状态;深度相机/场景同理(`kind: "sensor"` / `"scene"`)。 + +4. **controller.py 声明段**:`read_config` + `project(...)` + 资源构造器 + (`Robot` / `Sensor` / `Model` / `Motion` / `Scene`,字面量路径 + `preset=`)。 + +5. **create_controller 三段式**:observation(`robot.*` 观测 + `history`)→ policy + (`Model(...)` 调用 + `.clip(...).action()`)→ output(`PolicyController`)。 + `target=` 走位置 PD;`control=` 直接写原生 MuJoCo 执行器控制,二者恰选其一。 + +6. **抽离声明数据**:超过几个键的声明进 `config/*.yaml`,源码只留结构与连线。 + 命令轴在 `robot.commands(...)` 调用点声明一次;`keyboard.yaml` 只放绑定表。 + 各类文件的最小形状: + + ```yaml + # config/runtime.yaml + name: My Robot + control_hz: 50 + physics_hz: 200 + ``` + + ```yaml + # config/parameters.yaml —— 每项 label/default 必填,交互轴必须带 range + target_velocity: + label: {en: Forward speed, zh: 前进速度} + default: 0.4 + range: [0, 0.5] + step: 0.05 + unit: m/s + ``` + + ```yaml + # config/keyboard.yaml —— 轴绑定 / 离散映射 / 复活键,按需取用 + velocity_command: + bindings: + target_velocity: {increase: KeyW, decrease: KeyS} + respawn: + key: KeyR + ``` + + ```yaml + # config/gamepad.yaml —— stick: L|R 与 axis: 0|1;手柄复活键是 button: + velocity_command: + bindings: + target_velocity: {stick: L, axis: 1} + deadzone: 0.06 + ``` + + ```yaml + # config/watch.yaml / config/metrics.yaml —— 面板展示,经 project(panels={...}) 接入 + watch: [metric:forward_velocity] + ``` + + ```yaml + # config/control.yaml —— 事件值必须与 commands 映射节点里的编码一致 + robot: MicroDuck + state_node: state-machine + vx: forward_velocity + actions: + - {name: sit, event_value: 1, allowed_states: [0], target_states: [2]} + ``` + + 完整字段见 BotLab 仓库 `docs/api/`(parameters / controller / outputs / control 各篇)。 + +7. **复杂算法拆模块**:同目录 `.py`(顶层段不得叫 `botlab`),显式 import;辅助模块与 + 显式表达式必须生成同一份 plan。要外部控制再加 + `declare_control(control_profile(**read_config("config/control.yaml")))`。 + +## 接线总览 + +项目里没有连线文件——每条线都是 `controller.py` 里的一次显式引用: + +| 线 | 从 | 接到 | 在哪里接 | +| --- | --- | --- | --- | +| 资源标定 | `config/<设备>.preset.yaml` | 资源句柄 | 构造器字面量:`Robot(..., preset="config/robot.preset.yaml")` | +| 指令轴 | `parameters.yaml` 各参数的 `range` | `robot.commands(...)` 对应轴 | 调用点按参数名写轴;常量维写字面量 `0` | +| 设备输入 | `keyboard.yaml` / `gamepad.yaml` 绑定表 | 指令轴 | `robot.commands(..., keyboard=read_config(...)["<节>"], gamepad=...)` | +| 复活键 | `keyboard.yaml` / `gamepad.yaml` 的 `respawn` 键 | 项目声明 | `project(respawn={"keyboard": ..., "gamepad": ...})` | +| 面板 | 控制器 `metrics={...}` 命名 Signal | Watch / Metrics 面板 | `project(panels={...})`;yaml 用 `metric:<名字>` 引用 | +| 控制面 | `control.yaml` | `botlab control` / AI 控制 | `declare_control(control_profile(**read_config(...)))` | + +三条红线:**字面量、无中间层**(preset 经构造器 `preset=` 字面量接线,不建装配 +yaml);**设备文件是纯绑定表**(只说哪个键驱动哪一维,互不镜像,向量只在 `commands` +调用点声明一次);**名字即接线**(参数名、`metric:` 名、`event_value` 编码写错都在 +编译期报错,无静默回退)。 + +## 命名与 label 体系 + +| 名字 | 写在哪 | 作用 | 形态 | +| --- | --- | --- | --- | +| `name=` | 资源构造器 | 资源 ID;同源多资源必须显式区分 | 字符串 | +| `_label` | `config/*.yaml` 顶层保留键 | 工程树叶名(展示名);`read_config` 返回前剥离,不进声明数据 | 字符串或 `{en, zh}`(`en` 必填),非法形态硬失败 | +| `label`(preset 内) | `*.preset.yaml` | 资源语义字段,随 config 消费——与 `_label` 是两个键 | 字符串 | +| `label`(面板项) | `parameters.yaml` 每项、`metrics.yaml`、keyboard `mappings` | 显示文字,跟随界面语言 | 字符串或 `{en, zh}`(`en` 必填);纯字符串所有语言原样显示 | + +规范:面向用户的显示名一律写 `{en, zh}` 译文表并至少给 `en`;只有不需要本地化的 +内部标识(preset `label`)用纯字符串。译文表 key 只能是 `en`、`zh`,缺译文回落 `en`。 + +## 验证环 + +```bash +botlab validate # 收集声明、编译 plan、内存打包,不起服务 +botlab run # 浏览器调试环:本地目录实时装载,刷新生效 +botlab run --native # 原生 CPython 执行(mujoco + onnxruntime),浏览器只读显示 +botlab run --headless # 无头跑(CI/冒烟),BOTLAB_STEPS= 限定步数 +botlab build # 导出 release.zip(与浏览器发布同格式) +``` + +编译失败会点名缺失文件与字段;修完再验。动作导入类资产(NPZ/CSV)先在浏览器编辑器 +走 `prepare_asset` 转换,再回到 CLI 迭代。 + +## 参考 + +- BotLab 仓库 `docs/concepts/project-setup.md` — 浏览器侧同一搭建路径与模板对照表 +- [component-reference.md](./component-reference.md) — `botlab` 包全部公开符号 +- [decision-table.md](./decision-table.md) — 需求到组件的映射与常见错误 +- [onnx-export.md](./onnx-export.md) — 把策略网络导出为单文件 ONNX diff --git a/hatch_build.py b/hatch_build.py index 9306c8e..107fcff 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -22,6 +22,7 @@ "decision-table.md", "error-guide.md", "onnx-export.md", + "project-setup.md", ) diff --git a/src/botlab/_runtime/_observations.py b/src/botlab/_runtime/_observations.py index 2fdbd6d..0061fec 100644 --- a/src/botlab/_runtime/_observations.py +++ b/src/botlab/_runtime/_observations.py @@ -1638,6 +1638,81 @@ def _pose_velocity_command_vector(config, node_id, output, context): return output +def _magnitude_axis_flags(config, dim): + """``commandMagnitudeFlags`` (keyboardCommands.ts) 的本地镜像。 + + ``None`` 表示无幅值轴(巡航语义:静息 = 参数值)。 + """ + raw = config.get("magnitude") + if raw is None or raw == "": + return None + flags = list(raw) + if len(flags) != dim or not all(isinstance(flag, bool) for flag in flags): + raise _error( + "mujocoObservations", + "magnitude must be a boolean list matching the command dim", + ) + return flags + + +def _magnitude_binding_index(key, config, dim, names): + """``commandBindingIndex`` (keyboardCommands.ts):数字串是索引,否则查参数名。""" + if isinstance(key, bool): + raise _error("mujocoObservations", f"commandBindings has invalid axis {key!r}") + if isinstance(key, (int, float)) or ( + isinstance(key, str) and key.isdigit() and (key == "0" or not key.startswith("0")) + ): + index = int(key) + if index >= dim: + raise _error( + "mujocoObservations", + f"commandBindings axis {index} is outside dim {dim}", + ) + return index + try: + return names.index(key) + except ValueError: + raise _error( + "mujocoObservations", f'commandBindings has unknown parameter "{key}"' + ) from None + + +def _apply_magnitude_axes(config, output, flags): + """幅值轴:静息 0,increase/decrease 目标 ±参数当前值(夹在轴区间)。 + + 镜像 ``magnitudeAxisTarget`` + ``keyboardCommandVector`` 的无绑定幅值轴 + 归零(keyboardCommands.ts):本地无摇杆,摇杆侧本就归 0;斜坡推进是 + 浏览器输入平滑,物理回归直接取目标值。 + """ + pressed = _LOCAL_COMMAND_INPUT_STATE["pressed_keys"] + names = list(config.get("parameterNames") or ()) + minimum = config.get("cmdMin") + maximum = config.get("cmdMax") + bound = set() + bindings = config.get("commandBindings") + if isinstance(bindings, dict): + for key, rule in bindings.items(): + if not isinstance(rule, dict): + continue + index = _magnitude_binding_index(key, config, len(output), names) + bound.add(index) + if not flags[index]: + continue + increase = rule.get("increase") + decrease = rule.get("decrease") + positive = isinstance(increase, str) and increase in pressed + negative = isinstance(decrease, str) and decrease in pressed + if positive != negative: + target = float(output[index]) if positive else -float(output[index]) + output[index] = _clamp(target, minimum[index], maximum[index]) + else: + output[index] = 0.0 + for index, flag in enumerate(flags): + if flag and index not in bound: + # 无键盘绑定的幅值轴(只接摇杆)也按幅值语义静息在 0。 + output[index] = 0.0 + + def _command_vector(config, node_id, context): config = config or {} command_mode = config.get("commandMode") @@ -1647,12 +1722,6 @@ def _command_vector(config, node_id, context): "keyboard-gamepad-buttons", ): return np.zeros(1, dtype=np.float32) - if command_mode in _INTERACTIVE_COMMAND_MODES: - # Explicit native-host input state is neutral. The declared project - # command therefore stays unchanged, matching browser no-input state. - assert not _LOCAL_COMMAND_INPUT_STATE["pressed_keys"] - assert not _LOCAL_COMMAND_INPUT_STATE["pressed_buttons"] - assert not _LOCAL_COMMAND_INPUT_STATE["axis_active"] dim = configured_dim(config, DEFAULT_COMMAND_DIM) raw = _first_present(config, ("values", "commands", "command", "cmd", "cmd_init")) @@ -1661,6 +1730,16 @@ def _command_vector(config, node_id, context): else: output = vector_from(raw if raw is not None else [], dim, "obs-commands") + if command_mode in _INTERACTIVE_COMMAND_MODES: + flags = _magnitude_axis_flags(config, dim) + if flags and any(flags): + _apply_magnitude_axes(config, output, flags) + else: + # 巡航轴的按键推进没有本地实现:显式中性输入护栏保持不变。 + assert not _LOCAL_COMMAND_INPUT_STATE["pressed_keys"] + assert not _LOCAL_COMMAND_INPUT_STATE["pressed_buttons"] + assert not _LOCAL_COMMAND_INPUT_STATE["axis_active"] + if config.get("commandEncoding") in ("one-hot", "mapped") or config.get("encoding") in ( "one-hot", "mapped", diff --git a/src/botlab/sdk/_config.py b/src/botlab/sdk/_config.py index 9428e3c..8fa9b57 100644 --- a/src/botlab/sdk/_config.py +++ b/src/botlab/sdk/_config.py @@ -367,7 +367,15 @@ def parse_document(self): self._error(first.number, "a document marker here would start a second document") if self._is_sequence_entry(first.content): self._error(first.number, "the YAML document root must be a mapping") - root = self._parse_mapping(first) + if first.content[0] in "{[": + # A flow mapping at the root is a JSON document — the format pyodide + # file-rule handlers emit (Pyodide ships no PyYAML), and the TS end + # accepts it as YAML 1.2, so this end must read the same documents. + root = self._parse_flow(first, first.content) + if not isinstance(root, dict): + self._error(first.number, "the YAML document root must be a mapping") + else: + root = self._parse_mapping(first) trailing = self._peek() if trailing is not None: if trailing.content == "...": diff --git a/src/botlab/sdk/_robot.py b/src/botlab/sdk/_robot.py index faabff7..d75e4ff 100644 --- a/src/botlab/sdk/_robot.py +++ b/src/botlab/sdk/_robot.py @@ -496,9 +496,9 @@ def commands( command layout (e.g. a ``(forward, lateral, yaw)`` policy driven with lateral fixed at zero takes ``("forward_velocity", 0, "yaw_rate")``) without declaring a dead parameter for it; the plan carries it as an - empty-string axis name. Constant axes clamp keys, sticks, and touch - to the constant, and need the named axes' parameter ranges so the - plan emits one bounds vector. + empty-string axis name. Constant axes clamp keys, sticks, touch, and + waypoint tracking to the constant, and need the named axes' parameter + ranges so the plan emits one bounds vector. ``magnitude=[names]`` marks axes as magnitude axes: they rest at zero and interactive inputs (keys, gamepad stick, touch) command plus or @@ -651,8 +651,8 @@ def commands( if waypoint is not None: if not isinstance(waypoint, dict): raise TypeError("commands waypoint must come from waypoint_tracking()") - if has_constants: - raise ValueError("commands waypoint cannot combine with constant axes") + # 常量轴与 waypoint 兼容:跟踪器按 cmdMin/cmdMax 钉住该轴(如横移恒 + # 0 的航点巡航策略),布局槽位照样不占参数。 data.update(waypoint) return Signal("obs-commands", "commands", data)