feat(training): release V0.9.1 避障训练与基础策略迁移
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled

This commit is contained in:
2026-09-08 10:50:13 +08:00
parent fa5485049a
commit 438e56bcc8
113 changed files with 15027 additions and 539 deletions
+80
View File
@@ -0,0 +1,80 @@
# 自定义训练与前视射线避障
1. 启动仓库本地训练服务,在「控制台 → 强化学习任务」输入令牌并连接。
2. 选择「前视射线避障导航」,设置地形、种子、障碍物参数、FOV、探测/安全距离及避障奖励权重,发起训练。界面显示进度、最近价值/策略/熵损失和原始日志。
3. 「同步当前场景地图」从全部**已应用**实例的已编译MuJoCo静态碰撞几何导出权威 `custom_boxes/boxes-v1`,支持多实例、平移/旋转、工程地图路径及嵌套body。成功显示“已将视口中 N 个自定义障碍物编译为训练地图布局”。不重新运行随机预设,不同步草稿/过时场景。初始姿态取场景加载时机器人位姿,出生高度标准化为.32m;目标建议为初始朝向前方3m,不自动寻找安全点。出生/目标XY可修改,必须重新同步,并保留与所有障碍XY AABB不相交的.5m圆形安全区;绝不删除或移动障碍来避开安全区。
4. 训练完成后,先导入并加载有对应12个关节的Go2模型(Go2-W仅为非同构实验)。点击「导入策略」会校验作业配置与ONNX内嵌部署契约,在候选场景中完成真实ORT初始化、graph与机器人绑定检查后才事务替换当前**物理**地形、复位到出生点,启动策略与仿真。当前编辑器地图保留,重新加载模型恢复它。失败保留原场景、策略、物理状态及原暂停/播放状态。旧Flat无配套地形时仍按原方式加载后手动启用;新服务的默认Flat作业允许旧外部训练器无metadata导出,但真实graph必须固定47→12;显式自定义地图/避障不允许此降级。
5. 避障任务自动导航到配套目标,目标半径0.5m内停止速度指令。摄像头PiP自动打开,可单独隐藏;「显示避障射线」可切换红色命中/绿色未命中线段。
## 交互式导航目标与训练趋势
- 避障策略加载后,主视口显示青色呼吸目标信标,按当前训练碰撞地形的顶部高度落位。ONNX 面板显示当前目标坐标及水平剩余距离。
- 点击「设定目标」,再在主视口地形单击:目标限制在地图边缘内缩0.5m的区域,并自动退出设定模式。Esc/取消可退出;空白、拖动及摄像头PiP不会更新目标。模式使用捕获阶段拦截输入,不选择刚体或操纵地图Gizmo。
- 暂停或停用策略时也可更改目标,但不会自动启动仿真或策略。「复位目标点」只恢复训练地图默认目标;「重置仿真」同时恢复默认目标与出生状态。换目标不修改部署JSON、不重建ONNX会话,下一次观测仍保持该策略的81或97维。信标仅表示目标位置,不保证该点可到达或路径无障碍。
- 训练卡片中的「训练指标趋势」可折叠,提供价值/策略损失及综合页;综合页的熵、平均奖励、平均回合长度各用独立纵轴。复用自调参工作台的轻量Canvas图表,EMA系数0.4仅用于曲线,悬停图例和摘要保留原始值,支持缩放。
- 按日志中的 `Learning iteration` 合并指标,重复轮询不重复入点,同迭代分批日志可补全;内存最多保留最近500个有指标的迭代。换作业/清除作业会清空。服务器仅保留日志尾部,断线重连不能恢复已丢失历史;没有迭代标题或已知重叠上下文的孤立指标会跳过,不猜测step。缺失指标不显示,原始日志仍可展开。
## 自定义场景导出边界
- 使用实际碰撞几何而非Three装饰包围盒;box的世界AABB半尺寸为abs(R)×halfsize,sphere/capsule/ellipsoid/cylinder有精确解析bounds。mesh/hfield暂明确拒绝,其他未声明静态碰撞也报错,不能漏掉。既有工程地图include导入限制不变。
- **AABB会膨胀旋转/非box形状,底板标准化可能填补支撑面之间空白**;custom总是approximation=true,仅保证训练和浏览器部署相同boxes,不保证原OBB/原场景几何同构。仅明确命名且顶面z≈0的水平支撑归并为floor z=[-.2,0];地下/坑底、倾斜或非零高度plane拒绝,不静默填坑。
- 固定floor加最多256障碍,超量报错不截断。保持世界坐标、覆盖范围8–24m,超出时明确拒绝,不clamp/平移障碍。所有半尺寸须严格正值;统一friction=.2–2且其余摩擦分量为.005/.0001,混合摩擦需先在地图中显式统一。
- 服务和训练入口都验证完整JSON白名单、尺寸/位置、标准floor、count/approximation一致性及起终点安全区;`terrainPreset=custom_boxes`配套`customTerrainBoxes`布局全程传入任务JSON/deployment/ONNX,不降级预设。旧服务无custom_boxes能力时同步按钮报升级提示。
## 明确边界
- 默认32条水平前向物理射线;显式multi模式为3×16=48条(pitch 0/-20/-45度),**不是真实camera_depth或RGB视觉策略**。PiP是观察窗口,不作为网络输入。低障碍、跌落与切片外障碍存在感知盲区。
- 配套`boxes-v1`布局直接来自训练器,使用相同世界坐标、半尺寸、摩擦、出生点和目标;`rough/wave/pyramid_stairs`明确为训练专用box近似,不等于编辑器同名高度场。
- 浏览器对**编译后的**静态训练box `geom_xpos/geom_xmat/geom_size`做解析slab求交,包括地板。它与MuJoCo box几何求交等价;完整机身姿态旋转射线,按命名与group2仅选训练地形,排除整个机器人。不接受额外无限plane、未声明静态几何或非box训练地形。地图组合先用`mj_saveLastXML`展开include,避免原地面残留。
- 使用原47维本体观测+32归一化距离+2目标误差,共81维;multi改为47+48+2=97维。保持单in-flight、50Hz异步held-action,不阻塞WASM子步。不另加动态观测归一化。导出器Actor内已有归一化。
- 原Go2自定义地图部署按`go2_constants.py`补齐hip/thigh/calf armature=0.01/0.01/0.02、零关节阻尼/摩擦损失、非足端condim1/priority0、足端condim3/priority1/solimp宽度0.023,碰撞contype1/conaffinity0关闭自碰撞;足端摩擦使用配套地形。旧无自定义地图的Flat路径不覆盖这些参数。
- 初始关节姿态写入`MjData.qpos`,不改变模型`qpos0`的关节参考角;重置会恢复出生位姿、初始关节、零速度/历史动作及步态时间。
- 浏览器评测在20秒、机身高度低于0.12m或越界时停止并报错,需重置后重新启用;**不是训练端的接触/姿态终止自动reset**。到达目标不会强制结束episode。
- 旧`Unitree-Go2-Rough`的234维观测未在浏览器实现,可训练但禁止一键导入。原Flat、Go2-W速度策略、Python控制器、Flat自调参工作台保持兼容。
- 模型拓扑及PD配置校验不等于动力学完全一致。短PPO和零动作fixture只能验证链路,不能证明避障收敛或Sim2Sim导航成功。
## 验证入口
```bash
npm run typecheck
npm run check
npm run test:e2e -- web_platform/e2e/obstacle.spec.ts
# 可选:加载后端1iteration实际导出的模型(无需复制模型进仓库)
GO2_SMOKE_POLICY=/tmp/go2-obstacle-train-smoke/policy.onnx npm run test:e2e -- web_platform/e2e/obstacle.spec.ts
```
`src/rl/fixtures/obstacleRayGolden.json`保存CPU MuJoCo `mj_ray`参考距离;单测覆盖yaw/pitch/roll、地板命中、box内部/表面/平行/边缘、range边界、miss;真实WASM单测编译Go2碰撞/惯量模型并逐元素验证32条射线。E2E默认模型为`fixtures/obstacle/zero-action.onnx`(测试专用Constant零动作,非训练策略),检查真实浏览器WASM+ORT、地图联动、PiP、射线开关、metadata不一致拒绝。
完整后端字段定义见[部署契约](../training_server/OBSTACLE_AVOIDANCE.md)。
## Obstacle 自调参
自调参工作台新建Session可选Flat或Obstacle,选择Obstacle自动绑定四个专属标量/范围和五项固定客观权重(success .4/time .2/clearance .2/smooth .1/no-fall .1)。目标速度位于`params.target_velocity`,是导航command而非奖励。Approval/Automatic及运行时切换、护栏revision保持兼容,Loss看板不变。
从训练面板打开工作台时传递当前任务、seed、terrain/sensor(包括已验证custom_boxes快照);未同步/过时场景不发送凭据或配置。独立打开时可直接选任务,在“避障场景配置JSON”输入与训练API同构的terrain/sensor配置;服务再次严格验证。自定义地图明确为同一权威地图/起终点上的三个固定seed重复评估,不冒充三地形。Agent上下文按32/48ray实际模式描述侧后、层间与高度盲区、目标导航、擦碰/绕行/动作抖动约束。
Obstacle最佳策略通过ONNX内嵌metadata事务导入,导航速度随部署契约应用(.3–1.2),旧81维.6m/s模型与Flat导入保持原行为。客观评分、成功/碰撞/跌倒定义及3seed串行隔离评估详见[部署契约](../training_server/OBSTACLE_AVOIDANCE.md#obstacle-deepseek-自调参obstacle-v1)。小GPU smoke只验证链路和指标,不代表学会导航。
## 多层射线与性能(阶段5)
避障高级设置的“传感器模式”可选默认水平32ray/81obs或显式三层48ray/97obs。旧服务未公布multi能力时选项禁用;加载策略以后按metadata动态分配PiP射线,不截断到32条。前47维及heading/π符号不变,Click-to-Navigate目标/面板、Loss曲线、custom_boxes、Obstacle调参速度与Flat保留。旧81 graph不能通过97部署shape校验。
**不是局部高程图**:尚无网格/用途定义,本阶段未实现。下倾仍可能漏掉层间矮物、遮挡及坑;标准地图底板会填内部空洞。floor真实命中进入观测,只有multi近障奖励使用已验证标准底板顶面的10微米几何分类过滤,不把floor测距伪装成miss。与floor顶面共面的其他几何无法区分。完整字段、公式和分类限制见[多层契约](../training_server/OBSTACLE_AVOIDANCE.md#显式多层射线阶段5不是局部高程图)。
`multiRingGolden.json`来自CPU mj_ray,覆盖全部48条完整姿态、5cm低障碍、有界地板跌落边缘和层序;真实WASM绑定逐元素比对。`multi-zero-action.onnx`为测试Constant零动作97维fixture,非训练策略。真实短训模型可用:
```bash
GO2_MULTI_SMOKE_POLICY=/tmp/go2-multi-ring-stage5/train/policy.onnx npm run test:e2e -- web_platform/e2e/multiRing.spec.ts
```
`src/rl/tasks/raycastBenchmark.ts`导出`benchmarkForwardRays()`:每帧真实调用生产sampleForwardRays与slab,预计算方向/静态box,包含完整48ray全部box求交、姿态旋转和depth/线段分配;不测单ray、不用平均值代替尾延迟。默认25box和257box的16×16最坏数量布局分别warmup3000帧、采样10000帧,逐帧改变姿态/位置,不缓存命中结果。仅报告数量最坏布局,不声称穷尽所有空间排布或整浏览器帧耗时。
本机Intel i7-14700F/Ubuntu24.04,Node24.19:25box p50/p95约.019/.020ms,257box约.169/.187ms;HeadlessChromium151(同机,cross-origin-isolated高精度计时)约.015/.020ms与.160/.165ms,测得均低于完整48ray .2ms目标。初版通用旋转slab的257box p95≈.219ms未达标;优化为严格单位旋转快速slab后达标,保留原日志。不隔离浏览器计时分辨率约.1ms,p95量化为.2ms,不能用该读数证明严格小于目标;高精度基准不改变生产隔离/时序配置。
结果随硬件、GC、浏览器调度变化,不是实时保证;只测解析感知,不含渲染/ORT/物理步。复现打包脚本、Node/浏览器原始分位数与硬件说明位于`/tmp/go2-multi-ring-stage5/run_benchmark.mjs`及`perf-*.json`;日志与独立阶段incremental.diff同目录,后续review可直接读取。
## 复审修复:preset与导航拖动
Flat奖励菜单只展示服务根据来源session确认`taskId=Unitree-Go2-Flat`的preset;Obstacle及未声明身份项不展示。历史Flat的任务身份由服务恢复并完整验证schema,跨任务/损坏preset在创建作业前返回400,不推迟到训练器失败。旧服务没有返回任务身份时需升级服务才能使用这些preset;不增加Obstacle普通训练preset入口。
设定导航目标时超过5px即永久标记本次手势为拖动,即使返回起点也不会设置目标;画布外的同pointer移动也记录。pointercancel、Esc、失焦、退出模式、clear/dispose会清空手势。取消后旧pointerup不生效,重新按下的正常点击仍可设定。
+153
View File
@@ -0,0 +1,153 @@
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const fixture = JSON.parse(
readFileSync(resolve('web_platform/src/rl/fixtures/multiRingDeployment.json'), 'utf8'),
);
// Original Go2 collision/inertia model, visual meshes omitted to keep the smoke fixture lightweight.
const go2 = readFileSync(
resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'),
'utf8',
)
.replace(/<mesh\b[^>]*\/>/g, '')
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
let model = readFileSync(
process.env.GO2_MULTI_SMOKE_POLICY ??
resolve('web_platform/fixtures/obstacle/multi-zero-action.onnx'),
);
test('训练作业一键导入:真实WASM地图+97维ORT+PiP+射线开关', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('mujoco-local-training-job-id', 'a'.repeat(32));
sessionStorage.setItem('mujoco-local-training-token', 'test');
});
const job = {
id: 'a'.repeat(32),
taskId: 'Unitree-Go2-ObstacleAvoidance',
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [
'Learning iteration 0 / 1',
'Mean value loss: 0.9',
'Mean surrogate loss: -0.1',
'Mean entropy loss: -1',
'Mean reward: 2',
'Mean episode length: 20',
'Learning iteration 1 / 1',
'Mean value loss: 0.5',
'Mean surrogate loss: -0.2',
'Mean entropy loss: -0.8',
'Mean reward: 3',
'Mean episode length: 30',
],
message: '测试专用零动作策略',
deployment: fixture,
};
await page.route('http://127.0.0.1:8765/**', (route) => {
const url = route.request().url();
if (url.endsWith('/policy.onnx'))
return route.fulfill({ contentType: 'application/octet-stream', body: model });
if (url.endsWith('/health'))
return route.fulfill({
json: { ready: true, trainerRoot: '/test', tasks: [job.taskId], activeJobId: job.id },
});
if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } });
return route.fulfill({ json: job });
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await tools.getByRole('button', { name: /强化学习任务/ }).click();
await tools.getByRole('button', { name: '连接', exact: true }).click();
await expect(tools.getByRole('button', { name: '导入策略' })).toBeEnabled();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible();
const rays = page.getByRole('checkbox', { name: '显示避障射线' });
await expect(rays).toBeChecked();
await rays.uncheck();
await rays.check();
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
await expect(tools.getByText('97 / 12')).toBeVisible();
await expect(tools.getByText('Go2 前视射线避障导航', { exact: true })).toBeVisible();
const count = tools.getByText('推理次数', { exact: true }).locator('..');
await expect
.poll(async () => Number((await count.textContent())?.replace(/\D/g, '')))
.toBeGreaterThan(0);
const pause = page.getByRole('button', { name: '⏸ 暂停' });
if (await pause.isVisible()) await pause.click();
const stop = tools.getByRole('button', { name: '停止', exact: true });
if (await stop.isVisible()) await stop.click();
const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..');
const initialTarget = await targetRow.textContent();
const selection = () => page.locator('[aria-selected="true"]').allTextContents();
const beforeSelection = await selection();
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
await page.keyboard.press('Escape');
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute(
'aria-pressed',
'false',
);
await expect(targetRow).toHaveText(initialTarget!);
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
const canvas = page.locator('.viewport-shell canvas').first();
const bounds = (await canvas.boundingBox())!;
await canvas.click({ position: { x: bounds.width * 0.5, y: bounds.height * 0.55 } });
await expect(targetRow).not.toHaveText(initialTarget!);
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute(
'aria-pressed',
'false',
);
expect(await selection()).toEqual(beforeSelection);
await expect(tools.getByRole('button', { name: '启用', exact: true })).toBeVisible();
await tools.getByRole('button', { name: '复位目标点' }).click();
await expect(targetRow).toHaveText(initialTarget!);
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByRole('alert')).toHaveCount(0);
// Switch real valid graph/metadata pairs in both directions, without relaxing transactions.
const multiModel = model;
job.deployment = JSON.parse(
readFileSync(resolve('web_platform/src/rl/fixtures/obstacleDeployment.json'), 'utf8'),
);
model = readFileSync(resolve('web_platform/fixtures/obstacle/zero-action.onnx'));
await tools.getByRole('button', { name: '连接', exact: true }).click();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByText('81 / 12')).toBeVisible();
job.deployment = fixture;
model = multiModel;
await tools.getByRole('button', { name: '连接', exact: true }).click();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByText('97 / 12')).toBeVisible();
const pauseAgain = page.getByRole('button', { name: '⏸ 暂停' });
if (await pauseAgain.isVisible()) await pauseAgain.click();
const stopAgain = tools.getByRole('button', { name: '停止', exact: true });
if (await stopAgain.isVisible()) await stopAgain.click();
// A real 81-feature ONNX graph with valid 97 metadata must fail transactionally.
model = readFileSync(resolve('web_platform/fixtures/obstacle/multi-wrong-graph.onnx'));
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByRole('alert')).toContainText('维度');
await expect(tools.getByText('97 / 12')).toBeVisible();
await expect(targetRow).toHaveText(initialTarget!);
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
await tools.getByRole('button', { name: '卸载', exact: true }).click();
await expect(targetRow).toHaveCount(0);
await expect(tools.locator('.uplot')).toHaveCount(0);
await tools.getByRole('button', { name: /训练指标趋势/ }).click();
await expect(tools.locator('.uplot canvas')).toHaveCount(1);
await tools.getByRole('tab', { name: '综合', exact: true }).click();
await expect(tools.locator('.uplot canvas')).toHaveCount(5);
await tools.getByRole('button', { name: /训练指标趋势/ }).click();
await expect(tools.locator('.uplot')).toHaveCount(0);
});
+291
View File
@@ -0,0 +1,291 @@
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const fixture = JSON.parse(
readFileSync(resolve('web_platform/src/rl/fixtures/obstacleDeployment.json'), 'utf8'),
);
// Original Go2 collision/inertia model, visual meshes omitted to keep the smoke fixture lightweight.
const go2 = readFileSync(
resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'),
'utf8',
)
.replace(/<mesh\b[^>]*\/>/g, '')
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
const model = readFileSync(
process.env.GO2_SMOKE_POLICY ?? resolve('web_platform/fixtures/obstacle/zero-action.onnx'),
);
test('训练作业一键导入:真实WASM地图+81维ORT+PiP+射线开关', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('mujoco-local-training-job-id', 'a'.repeat(32));
sessionStorage.setItem('mujoco-local-training-token', 'test');
});
const job = {
id: 'a'.repeat(32),
taskId: 'Unitree-Go2-ObstacleAvoidance',
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [
'Learning iteration 0 / 1',
'Mean value loss: 0.9',
'Mean surrogate loss: -0.1',
'Mean entropy loss: -1',
'Mean reward: 2',
'Mean episode length: 20',
'Learning iteration 1 / 1',
'Mean value loss: 0.5',
'Mean surrogate loss: -0.2',
'Mean entropy loss: -0.8',
'Mean reward: 3',
'Mean episode length: 30',
],
message: '测试专用零动作策略',
deployment: fixture,
};
await page.route('http://127.0.0.1:8765/**', (route) => {
const url = route.request().url();
if (url.endsWith('/policy.onnx'))
return route.fulfill({ contentType: 'application/octet-stream', body: model });
if (url.endsWith('/health'))
return route.fulfill({
json: { ready: true, trainerRoot: '/test', tasks: [job.taskId], activeJobId: job.id },
});
if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } });
return route.fulfill({ json: job });
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await tools.getByRole('button', { name: /强化学习任务/ }).click();
await tools.getByRole('button', { name: '连接', exact: true }).click();
await expect(tools.getByRole('button', { name: '导入策略' })).toBeEnabled();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible({
timeout: 30_000,
});
await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible();
const rays = page.getByRole('checkbox', { name: '显示避障射线' });
await expect(rays).toBeChecked();
await rays.uncheck();
await rays.check();
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
await expect(tools.getByText('81 / 12')).toBeVisible();
await expect(tools.getByText('Go2 前视射线避障导航', { exact: true })).toBeVisible();
const count = tools.getByText('推理次数', { exact: true }).locator('..');
await expect
.poll(async () => Number((await count.textContent())?.replace(/\D/g, '')))
.toBeGreaterThan(0);
const pause = page.getByRole('button', { name: '⏸ 暂停' });
if (await pause.isVisible()) await pause.click();
const stop = tools.getByRole('button', { name: '停止', exact: true });
if (await stop.isVisible()) await stop.click();
const targetRow = tools.getByText('当前目标 (X, Y)', { exact: true }).locator('..');
const initialTarget = await targetRow.textContent();
const selection = () => page.locator('[aria-selected="true"]').allTextContents();
const beforeSelection = await selection();
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
await page.keyboard.press('Escape');
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute(
'aria-pressed',
'false',
);
await expect(targetRow).toHaveText(initialTarget!);
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
const canvas = page.locator('.viewport-shell canvas').first();
const bounds = (await canvas.boundingBox())!;
await canvas.click({ position: { x: bounds.width * 0.5, y: bounds.height * 0.55 } });
await expect(targetRow).not.toHaveText(initialTarget!);
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveAttribute(
'aria-pressed',
'false',
);
expect(await selection()).toEqual(beforeSelection);
await expect(tools.getByRole('button', { name: '启用', exact: true })).toBeVisible();
await tools.getByRole('button', { name: '复位目标点' }).click();
await expect(targetRow).toHaveText(initialTarget!);
await tools.getByRole('button', { name: '设定目标', exact: true }).click();
await tools.getByRole('button', { name: '卸载', exact: true }).click();
await expect(targetRow).toHaveCount(0);
await expect(tools.locator('.uplot')).toHaveCount(0);
await tools.getByRole('button', { name: /训练指标趋势/ }).click();
await expect(tools.locator('.uplot canvas')).toHaveCount(1);
await tools.getByRole('tab', { name: '综合', exact: true }).click();
await expect(tools.locator('.uplot canvas')).toHaveCount(5);
await tools.getByRole('button', { name: /训练指标趋势/ }).click();
await expect(tools.locator('.uplot')).toHaveCount(0);
});
test('下载metadata与作业不匹配时拒绝,未更换地图或启用策略', async ({ page }) => {
await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test'));
const job = {
id: 'b'.repeat(32),
taskId: fixture.taskId,
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [],
message: '',
deployment: { ...fixture, sensorCfg: { ...fixture.sensorCfg, fov: 60 } },
};
await page.route('http://127.0.0.1:8765/**', (route) => {
const url = route.request().url();
if (url.endsWith('/policy.onnx'))
return route.fulfill({ contentType: 'application/octet-stream', body: model });
if (url.endsWith('/health'))
return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } });
if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } });
return route.fulfill({ json: job });
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await tools.getByRole('button', { name: /强化学习任务/ }).click();
await tools.getByRole('button', { name: '连接', exact: true }).click();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(page.getByText('下载的策略与训练作业部署配置不一致').first()).toBeVisible();
await expect(page.getByText('训练配套物理地图', { exact: false })).toHaveCount(0);
await expect(page.getByLabel('摄像头画面', { exact: true })).toHaveCount(0);
});
for (const failure of ['wrong-graph', 'ort-init-failure']) {
test(`事务导入${failure}时保留旧地图、策略和暂停状态`, async ({ page }) => {
await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test'));
let bytes = model;
const job = {
id: 'c'.repeat(32),
taskId: fixture.taskId,
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [],
message: '',
deployment: fixture,
};
await page.route('http://127.0.0.1:8765/**', (route) => {
const url = route.request().url();
if (url.endsWith('/policy.onnx'))
return route.fulfill({ contentType: 'application/octet-stream', body: bytes });
if (url.endsWith('/health'))
return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } });
if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } });
return route.fulfill({ json: job });
});
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(go2) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await tools.getByRole('button', { name: /强化学习任务/ }).click();
await tools.getByRole('button', { name: '连接', exact: true }).click();
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible();
const pause = page.getByRole('button', { name: '⏸ 暂停' });
if (await pause.isVisible()) await pause.click();
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
await expect(tools.getByText('81 / 12')).toBeVisible();
const inference = tools.getByText('推理次数', { exact: true }).locator('..');
// Stop policy as well to invalidate any pending inference, leaving a stable baseline.
const stop = tools.getByRole('button', { name: '停止', exact: true });
if (await stop.isVisible()) await stop.click();
const before = await inference.textContent();
bytes = readFileSync(resolve(`web_platform/fixtures/obstacle/${failure}.onnx`));
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByRole('alert')).toContainText(
failure === 'wrong-graph' ? '维度' : 'MissingOperatorForTransactionTest',
);
await expect(tools.getByText('81 / 12')).toBeVisible();
await expect(inference).toHaveText(before!);
await expect(page.getByLabel('摄像头画面', { exact: true })).toBeVisible();
await expect(page.getByText('训练配套物理地图', { exact: false })).toBeVisible();
await expect(page.getByRole('button', { name: '▶ 播放' })).toBeVisible();
});
}
test('新server默认Flat作业兼容旧无metadata47维ONNX,错误graph拒绝且保留策略', async ({ page }) => {
await page.addInitScript(() => sessionStorage.setItem('mujoco-local-training-token', 'test'));
const flat = {
...fixture,
taskId: 'Unitree-Go2-Flat',
observationSize: 47,
observationTerms: fixture.observationTerms.slice(0, 7),
terrain: undefined,
terrainPreset: undefined,
terrainParams: undefined,
sensorCfg: undefined,
navigation: undefined,
};
let bytes = readFileSync(resolve('web_platform/fixtures/obstacle/legacy-flat.onnx'));
const job = {
id: 'd'.repeat(32),
taskId: flat.taskId,
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [],
message: '',
deployment: flat,
};
await page.route('http://127.0.0.1:8765/**', (route) => {
const url = route.request().url();
if (url.endsWith('/policy.onnx'))
return route.fulfill({ contentType: 'application/octet-stream', body: bytes });
if (url.endsWith('/health'))
return route.fulfill({ json: { ready: true, tasks: [job.taskId], activeJobId: job.id } });
if (url.includes('/presets')) return route.fulfill({ json: { presets: [] } });
return route.fulfill({ json: job });
});
const actuators =
'<actuator>' +
fixture.jointNames
.map((name: string) => `<motor name="${name}_motor" joint="${name}"/>`)
.join('') +
'</actuator>';
await page.goto('/');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({
name: 'go2.xml',
mimeType: 'text/xml',
buffer: Buffer.from(go2.replace('</mujoco>', actuators + '</mujoco>')),
});
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
await tools.getByRole('button', { name: /强化学习任务/ }).click();
await tools.getByRole('button', { name: '连接', exact: true }).click();
await tools.getByRole('button', { name: '导入策略' }).click();
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
await expect(tools.getByText('47 / 12')).toBeVisible();
bytes = readFileSync(resolve('web_platform/fixtures/obstacle/legacy-wrong-shape.onnx'));
await tools.getByRole('button', { name: '导入策略' }).click();
await expect(tools.getByRole('alert')).toContainText('维度');
await expect(tools.getByText('47 / 12')).toBeVisible();
await expect(page.getByText('训练配套物理地图', { exact: false })).toHaveCount(0);
});
@@ -0,0 +1,209 @@
import { expect, test } from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
// Opt-in real source-derived actor, not a constant fixture. Run against the Vite dev server.
const policyPath = process.env.GO2_PRETRAINED_NAV_POLICY;
test('真实warm-start策略连续行走超过20秒并换目标,不重载策略或自动启用', async ({
page,
}, testInfo) => {
test.skip(
!policyPath,
'设置GO2_PRETRAINED_NAV_POLICY为真实47→81/97 warm-start导出,并运行Vite dev :4173',
);
const xml = readFileSync(
resolve('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml'),
'utf8',
)
.replace(/<mesh\b[^>]*\/>/g, '')
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
const policy = Array.from(readFileSync(policyPath!));
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
const result = await page.evaluate(
async ({ xml, policy }) => {
const adapterPath = '/src/simulation/PhysicsAdapter.ts';
const deploymentPath = '/src/rl/deployment.ts';
const navigationPath = '/src/rl/tasks/go2ObstacleAvoidance.ts';
const { MainThreadPhysicsAdapter } = (await import(
adapterPath
)) as typeof import('../src/simulation/PhysicsAdapter');
const { readPolicyDeployment } = (await import(
deploymentPath
)) as typeof import('../src/rl/deployment');
const { obstacleNavigation } = (await import(
navigationPath
)) as typeof import('../src/rl/tasks/go2ObstacleAvoidance');
const bytes = new Uint8Array(policy),
data = new TextEncoder().encode(xml);
const deployment = readPolicyDeployment(bytes)!;
const adapter = new MainThreadPhysicsAdapter();
try {
await adapter.load(
{
id: 'real-pretrained',
name: 'go2',
files: [
{ path: 'go2.xml', data, size: data.length, source: 'file', mimeType: 'text/xml' },
],
entries: [{ path: 'go2.xml', format: 'mjcf', label: 'Go2' }],
maps: [],
totalBytes: data.length,
},
'go2.xml',
{
trainingDeployment: deployment,
trainingPolicy: { data: bytes, path: 'user-warmstart.onnx' },
},
);
const session = adapter.session!;
const originalPolicy = (session as unknown as { rlPolicy: object }).rlPolicy;
const initial = adapter.snapshot()!;
const samples: {
time: number;
x: number;
y: number;
z: number;
enabled?: boolean;
error?: string;
}[] = [];
const run = async (seconds: number) => {
const target = Number(session.data.time) + seconds;
while (Number(session.data.time) < target) {
for (let i = 0; i < 10; i++) adapter.singleStep();
await new Promise((resolve) => setTimeout(resolve, 0));
if (!adapter.snapshot()?.rlPolicy?.enabled) break;
}
const s = adapter.snapshot()!;
samples.push({
time: s.time,
x: s.qpos[0],
y: s.qpos[1],
z: s.qpos[2],
enabled: s.rlPolicy?.enabled,
error: s.rlPolicy?.error,
});
return s;
};
const before = await run(21);
const navBefore = obstacleNavigation(
before.qpos.slice(0, 3),
before.qpos.slice(3, 7),
before.rlPolicy!.navigation!.target,
deployment.terrain!.size,
deployment.navigation?.speed,
);
// Nearby diagonal target exercises both heading and velocity command; no pose teleport.
adapter.setNavigationTarget([before.qpos[0] - 2, before.qpos[1] + 2]);
const changed = adapter.snapshot()!;
const navChanged = obstacleNavigation(
changed.qpos.slice(0, 3),
changed.qpos.slice(3, 7),
changed.rlPolicy!.navigation!.target,
deployment.terrain!.size,
deployment.navigation?.speed,
);
const after = await run(4);
adapter.setRLPolicyEnabled(false);
adapter.setNavigationTarget([0, 0]);
const disabled = adapter.snapshot()!;
return {
initial: { time: initial.time, qpos: initial.qpos },
before: {
time: before.time,
qpos: before.qpos,
ctrl: before.ctrl,
status: before.rlPolicy,
},
after: { time: after.time, qpos: after.qpos, ctrl: after.ctrl, status: after.rlPolicy },
samples,
navBefore,
navChanged,
sameSession: adapter.session === session,
samePolicy: (session as unknown as { rlPolicy: object }).rlPolicy === originalPolicy,
heldControlUnchangedOnTarget: changed.ctrl.every((v, i) => v === before.ctrl[i]),
countBeforeTarget: before.rlPolicy!.inferenceCount,
countAfterTarget: changed.rlPolicy!.inferenceCount,
disabledAfterNewTarget: !disabled.rlPolicy!.enabled,
};
} finally {
adapter.dispose();
}
},
{ xml, policy },
);
const evidence = testInfo.outputPath('real-navigation-measurements.json');
writeFileSync(evidence, JSON.stringify(result, null, 2));
await testInfo.attach('real-navigation-measurements.json', {
path: evidence,
contentType: 'application/json',
});
expect(result.before.time).toBeGreaterThan(20);
expect(result.before.status?.enabled).toBe(true);
expect(result.after.status?.enabled).toBe(true);
expect(
Math.hypot(
result.before.qpos[0] - result.initial.qpos[0],
result.before.qpos[1] - result.initial.qpos[1],
),
).toBeGreaterThan(0.5);
expect(result.navChanged.command).not.toEqual(result.navBefore.command);
expect(result.navChanged.targetError).not.toEqual(result.navBefore.targetError);
expect(result.after.ctrl).not.toEqual(result.before.ctrl);
expect(result.after.status!.navigation!.distance).toBeLessThan(2 * Math.SQRT2);
expect(result.countAfterTarget).toBe(result.countBeforeTarget);
expect(result.after.status!.inferenceCount).toBeGreaterThan(result.countBeforeTarget);
expect(result.sameSession).toBe(true);
expect(result.samePolicy).toBe(true);
expect(result.heldControlUnchangedOnTarget).toBe(true);
expect(result.disabledAfterNewTarget).toBe(true);
});
test('用户原始47维ONNX通过普通Flat面板加载,不新增点击导航模式', async ({ page }) => {
const source = process.env.GO2_PRETRAINED_FLAT_POLICY;
const robotXml = process.env.GO2_PRETRAINED_FLAT_XML;
test.skip(!source || !robotXml, '设置原始47维ONNX与同源带actuator的机器人XML路径');
const xml = readFileSync(robotXml!, 'utf8')
.replace(/<mesh\b[^>]*\/>/g, '')
.replace(/<geom\b[^>]*\bmesh="[^"]*"[^>]*\/>/g, '');
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
await page
.locator('input[type="file"]')
.first()
.setInputFiles({ name: 'go2.xml', mimeType: 'text/xml', buffer: Buffer.from(xml) });
await expect(page.getByText('WASM 已加载')).toBeVisible({ timeout: 30_000 });
await page.getByRole('tab', { name: '控制台' }).click();
const tools = page.getByRole('tabpanel', { name: '控制台' });
const section = tools.getByRole('button', { name: /ONNX 策略运行/ });
if ((await section.getAttribute('aria-expanded')) === 'false') await section.click();
const chooser = page.waitForEvent('filechooser');
await tools.getByRole('button', { name: '导入 ONNX' }).click();
await (await chooser).setFiles(source!);
await expect
.poll(
async () =>
(await tools.getByText('47 / 12').count()) +
(await page.getByRole('button', { name: '技术详情', exact: true }).count()),
)
.toBeGreaterThan(0);
const details = page.getByRole('button', { name: '技术详情', exact: true });
if (await details.isVisible()) {
await details.click();
throw new Error((await page.getByRole('alert').allTextContents()).join('\n'));
}
await expect(tools.getByText('47 / 12')).toBeVisible({ timeout: 30_000 });
const enable = tools.getByRole('button', { name: '启用', exact: true });
if (await enable.isVisible()) await enable.click();
const play = page.getByRole('button', { name: '▶ 播放', exact: true });
if (await play.isVisible()) await play.click();
await expect
.poll(async () =>
Number(
(await tools.getByText('推理次数', { exact: true }).locator('..').textContent())?.replace(
/\D/g,
'',
),
),
)
.toBeGreaterThan(2);
await expect(tools.getByRole('button', { name: '设定目标', exact: true })).toHaveCount(0);
});
+177
View File
@@ -0,0 +1,177 @@
import { expect, test } from '@playwright/test';
import { spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const realRoot = process.env.GO2_UPLOAD_REAL_DIR;
const token = 'local-upload-browser-test-token';
async function unusedPort() {
const socket = createServer();
await new Promise<void>((done) => socket.listen(0, '127.0.0.1', done));
const address = socket.address();
const port = typeof address === 'object' && address ? address.port : 0;
await new Promise<void>((done) => socket.close(() => done()));
return port;
}
for (const panel of ['ordinary', 'tuning'] as const) {
for (const format of ['pt', 'onnx'] as const) {
test(`${panel}真实单${format}文件:默认服务空目录上传并选择内容ID`, async ({
page,
}, testInfo) => {
test.skip(!realRoot, '设置GO2_UPLOAD_REAL_DIR;Vite dev提供真实组件,启动独立默认训练服务');
const root = mkdtempSync(join(tmpdir(), `go2-browser-upload-${panel}-${format}-`));
const port = await unusedPort();
const endpoint = `http://127.0.0.1:${port}`;
const backend = spawn(
resolve('.venv/bin/python'),
[
'-u',
'training_server/server.py',
'--port',
String(port),
'--token',
token,
'--tuning-data-root',
root,
],
{
cwd: process.cwd(),
env: { ...process.env, DEEPSEEK_API_KEY: '', WANDB_MODE: 'disabled' },
},
);
let log = '';
backend.stdout.on('data', (data: Buffer) => {
log += data.toString();
});
backend.stderr.on('data', (data: Buffer) => {
log += data.toString();
});
try {
await expect
.poll(
async () => {
try {
return (
await fetch(`${endpoint}/api/training/health`, {
headers: { Authorization: `Bearer ${token}` },
})
).status;
} catch {
return 0;
}
},
{ timeout: 30_000 },
)
.toBe(200);
await page.goto(process.env.GO2_PRETRAINED_DEV_URL ?? 'http://127.0.0.1:4174');
// Real production panel components, isolated from unrelated heavy scene import.
await page.evaluate(async (panel) => {
localStorage.clear();
sessionStorage.clear();
const entry = await (await fetch('/src/main.tsx')).text();
const reactPath = entry.match(/from "([^"]+\/react\.js[^"]*)"/)![1];
const domPath = entry.match(/from "([^"]+\/react-dom_client\.js[^"]*)"/)![1];
const { createElement } = (await import(reactPath)).default;
const { createRoot } = (await import(domPath)).default;
const host = document.createElement('div');
host.id = 'upload-browser-panel';
host.style.cssText =
'position:fixed;inset:0;z-index:10000;background:#111;overflow:auto;padding:20px';
document.body.append(host);
if (panel === 'ordinary') {
const path = '/src/training/LocalTrainingPanel.tsx';
const { LocalTrainingPanel } = await import(path);
createRoot(host).render(createElement(LocalTrainingPanel, { onPolicyReady: () => {} }));
} else {
const path = '/src/tuning/TuningApp.tsx';
const { TuningApp } = await import(path);
createRoot(host).render(createElement(TuningApp));
}
}, panel);
const ui = page.locator('#upload-browser-panel');
await ui
.getByLabel(panel === 'ordinary' ? '本地训练服务地址' : '训练服务地址', { exact: true })
.fill(endpoint);
await ui
.getByLabel(panel === 'ordinary' ? '训练服务访问令牌' : '访问令牌(仅当前标签页)', {
exact: true,
})
.fill(token);
await ui
.getByRole('button', { name: panel === 'ordinary' ? /^连接$/ : '连接/刷新' })
.click();
await expect(ui.getByLabel('确认Go2 legacy47模板')).toBeEnabled();
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue('');
await ui.getByLabel('确认Go2 legacy47模板').check();
const path = join(realRoot!, format === 'pt' ? 'model_10000.pt' : 'policy.onnx');
const sha = createHash('sha256').update(readFileSync(path)).digest('hex');
const responsePromise = page.waitForResponse((response) =>
response.url().startsWith(`${endpoint}/api/training/pretrained-sources/upload?`),
);
await ui.getByLabel('选择基础策略文件').setInputFiles(path);
const response = await responsePromise;
expect(response.status()).toBe(201);
const catalog = await (
await fetch(`${endpoint}/api/training/health`, {
headers: { Authorization: `Bearer ${token}` },
})
).json();
const record = catalog.pretrainedSources[0];
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
await expect(ui.getByText(/原文件 SHA256/)).toContainText(sha);
await expect(ui.getByText(/上传格式/)).toContainText(format);
await expect(ui.getByText(/已选择.*仅继承策略权重/)).toBeVisible();
if (format === 'onnx')
await expect(ui.getByText(/ONNX统计count合成/)).toContainText('1000000');
// Intercept only train dispatch; uploads and catalog are real authenticated HTTP.
// Never dispatch 4096-env training or an Agent/three-seed evaluation from this test.
let submitted: Record<string, unknown> | undefined;
await page.route(
`${endpoint}${panel === 'ordinary' ? '/api/training/jobs' : '/api/tuning/sessions'}`,
async (route) => {
if (route.request().method() !== 'POST') return route.continue();
submitted = route.request().postDataJSON() as Record<string, unknown>;
await route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: '浏览器验收只截获启动请求,未训练或调用Agent' }),
});
},
);
if (panel === 'tuning') await ui.getByLabel('Agent 失败时允许 Optuna fallback').check();
await ui
.getByRole('button', { name: panel === 'ordinary' ? '发起本地训练' : '启动自调参' })
.click();
await expect.poll(() => submitted?.pretrainedSourceId).toBe(record.id);
if (panel === 'tuning') expect(submitted?.mode).toBe('approval');
await expect(ui.getByLabel('基础策略', { exact: true })).toHaveValue(record.id);
const health = await (
await fetch(`${endpoint}/api/training/health`, {
headers: { Authorization: `Bearer ${token}` },
})
).json();
expect(health.pretrainedSources).toHaveLength(1);
expect(createHash('sha256').update(readFileSync(path)).digest('hex')).toBe(sha);
await testInfo.attach('upload-contract', {
body: JSON.stringify(
{ root, endpoint, record, submitted, sourceUnchanged: true },
null,
2,
),
contentType: 'application/json',
});
await page.screenshot({ path: testInfo.outputPath('upload.png') });
} finally {
backend.kill('SIGTERM');
await new Promise<void>((done) => {
if (backend.exitCode !== null) done();
else backend.once('exit', () => done());
});
writeFileSync(join(root, 'browser-server.log'), log);
}
});
}
}
+9
View File
@@ -0,0 +1,9 @@
# 测试专用避障契约模型
`zero-action.onnx`:ONNX opset17/IR9,输入float32[1,81],Constant节点输出float32[1,12]全0,携带seed42避障deployment。仅验浏览器地图/感知/推理闭环,**不是已训练或可导航策略**。由onnx.helper.make_model/make_graph/make_node(Constant)生成,metadata来自src/rl/fixtures/obstacleDeployment.json。
事务回归fixture:`wrong-graph.onnx`包含合法避障metadata但实际输入47维;`ort-init-failure.onnx`包含合法81维metadata但不存在的算子(故意无法初始化ORT);`legacy-flat.onnx`为无metadata的47→12零动作旧导出;`legacy-wrong-shape.onnx`为无metadata的81→12零动作。均由相同onnx.helper入口生成,仅供成功/失败分支测试。
`multi-zero-action.onnx`同样为Constant零动作,仅将实际输入shape设为[1,97]并携带`multiRingDeployment.json`。不是训练策略,也没有将81网络冒充97。真实短训导出只保留在/tmp。
`multi-wrong-graph.onnx`携带合法97维metadata但真实输入81维,用于浏览器ORT明确shape拒绝及旧会话保留回归。
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+155 -52
View File
@@ -1,3 +1,4 @@
import { resolvePolicyDeployment, type PolicyDeployment } from '../rl/deployment';
/* Zustand 的 action 引用稳定;初始化 viewer 与导入回调有意只创建一次。 */
/* eslint-disable react-hooks/exhaustive-deps */
import {
@@ -267,6 +268,7 @@ export function App() {
const manifest = useRef<ProjectManifest | null>(null),
notificationId = useRef(0),
loadInFlight = useRef(false),
policyLoadInFlight = useRef(false),
importInFlight = useRef(false),
adapter = useRef(new MainThreadPhysicsAdapter()),
root = useRef<HTMLDivElement>(null),
@@ -319,6 +321,9 @@ export function App() {
[controllerStatus, setControllerStatus] = useState<ControllerStatus>(),
[selectedPolicyPath, setSelectedPolicyPath] = useState<string>(),
[policyStatus, setPolicyStatus] = useState<RLPolicyStatus>(),
[navigationTargetMode, setNavigationTargetMode] = useState(false),
[trainingDeployment, setTrainingDeployment] = useState<PolicyDeployment>(),
[showPerceptionRays, setShowPerceptionRays] = useState(true),
[projectMaps, setProjectMaps] = useState<MapEntry[]>([]),
[editorDocument, setEditorDocument] = useState<EditableMapDocument | null>(null),
[editorDrafts, setEditorDrafts] = useState<Map<string, EditableMapDocument>>(() => new Map()),
@@ -444,6 +449,13 @@ export function App() {
setEditorSelection(selection ? { kind: 'body', bodyId: selection.bodyId } : null);
if (selection) setRightOpen(true);
},
onNavigationMode: setNavigationTargetMode,
onNavigationTarget: (target) => {
adapter.current.setNavigationTarget(target);
const snapshot = adapter.current.snapshot();
state.setSnapshot(snapshot ?? undefined);
setPolicyStatus(snapshot?.rlPolicy);
},
onFrame: (frame, fps, snapshot) => {
const memory = (performance as Performance & { memory?: { usedJSHeapSize: number } })
.memory?.usedJSHeapSize;
@@ -550,8 +562,14 @@ export function App() {
viewer.current?.setMapDisplay(showVisualMap, showMapCollision);
}, [showVisualMap, showMapCollision]);
useEffect(() => {
viewer.current?.setParametricMapAssets(placedMapAssets, mapSceneDraft.changedIds);
}, [placedMapAssets, mapSceneDraft.changedIds]);
viewer.current?.setParametricMapAssets(
trainingDeployment ? [] : placedMapAssets,
mapSceneDraft.changedIds,
);
}, [placedMapAssets, mapSceneDraft.changedIds, trainingDeployment]);
useEffect(() => {
viewer.current?.setShowPerceptionRays(showPerceptionRays);
}, [showPerceptionRays]);
useEffect(() => {
if (window.innerWidth < 900) return;
try {
@@ -585,6 +603,8 @@ export function App() {
path: string,
requestedMode?: UrdfLoadMode,
requestedSceneAssets?: readonly PlacedMapAsset[],
requestedDeployment?: PolicyDeployment,
requestedPolicy?: { data: Uint8Array; path: string },
) => {
if (!manifest.current || loadInFlight.current) return false;
// 普通模型重载始终使用上次成功应用的地图基线。只有场景提交入口可以显式
@@ -623,6 +643,8 @@ export function App() {
baseMode: baseModeRef.current,
enhancements: urdfEnhancementsRef.current,
mapAssets: sceneAssets,
trainingDeployment: requestedDeployment,
trainingPolicy: requestedPolicy,
onProgress: ({ value, label }) =>
setImportProgress({
title: '正在准备仿真',
@@ -670,9 +692,10 @@ export function App() {
await activeViewer.setVisualMaps([]);
let visualMapWarning: string | undefined;
try {
const assets = manifest.current
? visualMapAssets(manifest.current, placedMapAssetsRef.current)
: [];
const assets =
manifest.current && !requestedDeployment
? visualMapAssets(manifest.current, placedMapAssetsRef.current)
: [];
await activeViewer.setVisualMaps(assets);
} catch (error) {
visualMapWarning = `视觉地图加载失败:${error instanceof Error ? error.message : String(error)}`;
@@ -704,11 +727,12 @@ export function App() {
setAppliedMapAssets(committedMapAssets);
}
activeViewer.setParametricMapAssets(
placedMapAssetsRef.current,
requestedDeployment ? [] : placedMapAssetsRef.current,
summarizeMapSceneDraft(placedMapAssetsRef.current, appliedMapAssetsRef.current)
.changedIds,
);
adapter.current.releaseRetired();
setTrainingDeployment(requestedDeployment);
sessionSwapped = false;
return true;
} catch (error) {
@@ -734,6 +758,7 @@ export function App() {
if (retained && previousEntry) state.setEntry(previousEntry);
adapter.current.setPaused(previousPaused);
state.setPaused(previousPaused);
state.setSnapshot(retained ?? undefined);
setControllerStatus(retained?.controller);
setPolicyStatus(retained?.rlPolicy);
return false;
@@ -874,6 +899,7 @@ export function App() {
setSelectedControllerPath(undefined);
setControllerStatus(undefined);
setSelectedPolicyPath(undefined);
setTrainingDeployment(undefined);
setPolicyStatus(undefined);
setProjectMaps([]);
setCommittedEditorDocuments(new Map());
@@ -1962,26 +1988,62 @@ export function App() {
setControllerStatus(undefined);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
};
const loadPolicyBytes = async (data: Uint8Array, path: string) => {
const loadPolicyBytes = async (data: Uint8Array, path: string, expected?: PolicyDeployment) => {
if (policyLoadInFlight.current || loadInFlight.current)
throw new Error('模型/策略正在加载,请稍后重试');
viewer.current?.setNavigationTargetMode(false);
const previousPaused = useAppStore.getState().paused;
const previousSession = adapter.current.session;
policyLoadInFlight.current = true;
state.setLoading(true);
setImportProgress({
title: '正在加载强化学习策略',
label: '初始化 ONNX Runtime',
detail: path,
value: 0.55,
});
state.setDiagnostic(undefined);
adapter.current.setPaused(true);
state.setPaused(true);
try {
const status = await adapter.current.loadRLPolicy(data, path);
setPolicyStatus(status);
const deployment = resolvePolicyDeployment(data, expected);
if (deployment?.terrain) {
const entry = useAppStore.getState().selectedEntry;
if (!entry || !manifest.current) throw new Error('请先导入并加载Go2机器人');
if (!(await loadEntry(entry, 'mjcf', undefined, deployment, { data, path })))
throw new Error(
useAppStore.getState().diagnostic?.detail ?? '配套训练地图加载失败,策略未启用',
);
}
setImportProgress({
title: '正在加载强化学习策略',
label: '初始化 ONNX Runtime',
detail: path,
value: 0.55,
});
state.setLoading(true);
const session = adapter.current.session;
const status = deployment?.terrain
? adapter.current.snapshot()!.rlPolicy!
: await adapter.current.loadRLPolicy(data, path, deployment);
if (session !== adapter.current.session) throw new Error('模型已切换,策略加载取消');
if (deployment?.terrain) {
adapter.current.setPaused(false);
state.setPaused(false);
setShowSensorCamera(true);
viewer.current?.setShowSensorCamera(true);
}
setPolicyStatus(adapter.current.snapshot()?.rlPolicy ?? status);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
notify(
'ONNX 策略已加载',
`${status.taskName} · ${status.observationSize} → ${status.actionSize}`,
);
} catch (error) {
if (adapter.current.session === previousSession) {
adapter.current.setPaused(previousPaused);
state.setPaused(previousPaused);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
setPolicyStatus(adapter.current.snapshot()?.rlPolicy);
}
state.setDiagnostic(diagnostic('仿真', error, path));
throw error;
} finally {
policyLoadInFlight.current = false;
setImportProgress(undefined);
state.setLoading(false);
}
@@ -1993,45 +2055,49 @@ export function App() {
return;
}
setSelectedPolicyPath(path);
void loadPolicyBytes(file.data, path);
void loadPolicyBytes(file.data, path).catch(() => {});
};
const importPolicy = (file: File) => {
void (async () => {
try {
if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
const path = normalizeProjectPath(file.name),
data = new Uint8Array(await file.arrayBuffer());
if (manifest.current) {
const index = manifest.current.files.findIndex((candidate) => candidate.path === path),
files = manifest.current.files.slice(),
entry = {
path,
data,
size: data.byteLength,
source: 'file' as const,
mimeType: file.type || 'application/octet-stream',
};
if (index >= 0) files[index] = entry;
else files.push(entry);
const totalBytes = files.reduce((total, item) => total + item.size, 0);
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
manifest.current = { ...manifest.current, files, totalBytes };
state.setProject(
manifest.current.name,
files.map(({ path: filePath, size }) => ({ path: filePath, size })),
manifest.current.entries,
manifest.current.selectedEntry,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
}
setSelectedPolicyPath(path);
await loadPolicyBytes(data, path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
const importPolicy = async (file: File, expected?: PolicyDeployment) => {
try {
if (!/\.onnx$/i.test(file.name)) throw new Error('请选择 .onnx 文件');
if (file.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
const project = manifest.current;
const path = normalizeProjectPath(file.name),
data = new Uint8Array(await file.arrayBuffer());
if (project !== manifest.current) throw new Error('工程已切换,策略导入取消');
const projectedBytes =
(project?.files
.filter((item) => item.path !== path)
.reduce((total, item) => total + item.size, 0) ?? 0) + data.byteLength;
if (projectedBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
await loadPolicyBytes(data, path, expected);
if (project && manifest.current?.id === project.id) {
const files = manifest.current.files.filter((candidate) => candidate.path !== path);
files.push({
path,
data,
size: data.byteLength,
source: 'file',
mimeType: file.type || 'application/octet-stream',
});
const totalBytes = files.reduce((total, item) => total + item.size, 0);
if (totalBytes > DEFAULT_IMPORT_LIMITS.maxTotalBytes)
throw new Error('加入 ONNX 后工程总大小超过 512 MiB');
manifest.current = { ...manifest.current, files, totalBytes };
state.setProject(
manifest.current.name,
files.map(({ path, size }) => ({ path, size })),
manifest.current.entries,
useAppStore.getState().selectedEntry,
);
state.setSnapshot(adapter.current.snapshot() ?? undefined);
}
})();
setSelectedPolicyPath(path);
} catch (error) {
state.setDiagnostic(diagnostic('仿真', error, file.name));
throw error;
}
};
const togglePolicy = (enabled: boolean) => {
adapter.current.setRLPolicyEnabled(enabled);
@@ -2372,8 +2438,29 @@ export function App() {
onSelectPolicyPath={setSelectedPolicyPath}
onLoadPolicyPath={loadPolicyPath}
onImportPolicy={importPolicy}
compileTrainingScene={(coordinates) => {
if (
mapSceneDirty ||
trainingDeployment ||
useAppStore.getState().loading ||
loadInFlight.current
)
throw new Error('请先应用地图草稿;训练部署/加载中的场景不能同步');
return adapter.current.exportTrainingTerrain(appliedMapAssets, coordinates);
}}
trainingSceneMaps={appliedMapAssets}
trainingSceneDirty={mapSceneDirty || Boolean(trainingDeployment)}
onTogglePolicy={togglePolicy}
onPolicyCommand={setPolicyCommand}
navigationTargetMode={navigationTargetMode}
onNavigationTargetMode={(active) => viewer.current?.setNavigationTargetMode(active)}
onResetNavigationTarget={() => {
viewer.current?.setNavigationTargetMode(false);
adapter.current.resetNavigationTarget();
const snapshot = adapter.current.snapshot();
state.setSnapshot(snapshot ?? undefined);
setPolicyStatus(snapshot?.rlPolicy);
}}
onRemovePolicy={removePolicy}
onDataRecorderConfigure={configureDataRecorder}
onDataRecordingStart={startDataRecording}
@@ -2570,6 +2657,22 @@ export function App() {
progress={importProgress}
/>
<ToastViewport item={toast} onDismiss={() => setToast(undefined)} />
{trainingDeployment && (
<div className="absolute top-16 left-4 z-20 rounded bg-app p-2 text-xs">
训练配套物理地图(编辑器地图未更改,重载模型恢复)。
{trainingDeployment.terrain?.approximation && '训练专用离散近似。'}
{(policyStatus?.observationSize === 81 || policyStatus?.observationSize === 97) && (
<label>
<input
type="checkbox"
checked={showPerceptionRays}
onChange={(e) => setShowPerceptionRays(e.target.checked)}
/>
显示避障射线
</label>
)}
</div>
)}
{Boolean(state.snapshot?.model.ncam) &&
(showSensorCamera ? (
<div
@@ -1,3 +1,6 @@
import type { TrainingSceneCompiler } from '../../map/trainingMap';
import type { PolicyDeployment } from '../../rl/deployment';
import type { PlacedMapAsset } from '../../map/types';
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
import { PythonControllerPanel } from '../../controller/PythonControllerPanel';
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
@@ -63,8 +66,14 @@ export function WorkspaceToolsPanel({
onSelectPolicyPath,
onLoadPolicyPath,
onImportPolicy,
compileTrainingScene,
trainingSceneMaps,
trainingSceneDirty,
onTogglePolicy,
onPolicyCommand,
navigationTargetMode,
onNavigationTargetMode,
onResetNavigationTarget,
onRemovePolicy,
onDataRecorderConfigure,
onDataRecordingStart,
@@ -85,6 +94,9 @@ export function WorkspaceToolsPanel({
policyPaths: string[];
selectedPolicyPath?: string;
policyStatus?: RLPolicyStatus;
navigationTargetMode?: boolean;
onNavigationTargetMode?: (active: boolean) => void;
onResetNavigationTarget?: () => void;
onResetJoints: () => void;
onToggleJointLimits: () => void;
onToggleAdvanced: () => void;
@@ -101,7 +113,10 @@ export function WorkspaceToolsPanel({
onRemoveController: () => void;
onSelectPolicyPath: (path: string) => void;
onLoadPolicyPath: (path: string) => void;
onImportPolicy: (file: File) => void;
onImportPolicy: (file: File, deployment?: PolicyDeployment) => void | Promise<void>;
compileTrainingScene?: TrainingSceneCompiler;
trainingSceneMaps?: readonly PlacedMapAsset[];
trainingSceneDirty?: boolean;
onTogglePolicy: (enabled: boolean) => void;
onPolicyCommand: (command: RLCommand) => void;
onRemovePolicy: () => void;
@@ -251,9 +266,14 @@ export function WorkspaceToolsPanel({
loading={loading}
onSelectPath={onSelectPolicyPath}
onLoadPath={onLoadPolicyPath}
onImport={onImportPolicy}
onImport={(file) => {
void Promise.resolve(onImportPolicy(file)).catch(() => {});
}}
onToggle={onTogglePolicy}
onCommand={onPolicyCommand}
navigationTargetMode={navigationTargetMode}
onNavigationTargetMode={onNavigationTargetMode}
onResetNavigationTarget={onResetNavigationTarget}
onRemove={onRemovePolicy}
/>
</CollapsibleSection>
@@ -262,7 +282,12 @@ export function WorkspaceToolsPanel({
defaultOpen={false}
badge={<ConsoleSectionBadges category="训练" />}
>
<LocalTrainingPanel onPolicyReady={onImportPolicy} />
<LocalTrainingPanel
onPolicyReady={onImportPolicy}
compileScene={compileTrainingScene}
sceneMaps={trainingSceneMaps}
sceneDirty={trainingSceneDirty}
/>
</CollapsibleSection>
</>
) : (
@@ -0,0 +1,47 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { ScalarChart } from './ScalarChart';
import { ScalarChart as TuningChart } from '../../tuning/ScalarChart';
import type uPlot from 'uplot';
const spies = vi.hoisted(() => ({
create: vi.fn(),
destroy: vi.fn(),
setData: vi.fn(),
setScale: vi.fn(),
}));
vi.mock('uplot', () => ({
default: class {
over = document.createElement('div');
scales = { x: { min: 0, max: 10 }, y: { min: 0, max: 10 } };
constructor(
options: unknown,
public data: unknown,
) {
spies.create(options, data);
}
setScale = spies.setScale;
setData = spies.setData;
setSize() {}
destroy = spies.destroy;
},
}));
it('共享原tuning API、EMA只修改曲线、悬停保留原值、缩放及卸载清理', () => {
expect(TuningChart).toBe(ScalarChart);
const points = [
{ step: 1, wallTime: 0, value: 0 },
{ step: 2, wallTime: 0, value: 10 },
];
const view = render(<ScalarChart series={[{ tag: 'loss', points }]} smoothing={0.4} />);
const [options, data] = spies.create.mock.lastCall!;
expect(data).toEqual([
[1, 2],
[0, 6],
]);
expect(options.series[1].value({} as uPlot, 6, 1, 1)).toBe('10');
expect(points[1].value).toBe(10);
fireEvent.click(screen.getByRole('button', { name: '训练与评估 Scalars 放大' }));
expect(spies.setScale).toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '训练与评估 Scalars 重置缩放' }));
expect(spies.setData).toHaveBeenCalled();
view.unmount();
expect(spies.destroy).toHaveBeenCalled();
});
@@ -0,0 +1,216 @@
import { useEffect, useMemo, useRef } from 'react';
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import type { ScalarSeries } from '../../training/types';
const COLORS = ['#38d39f', '#60a5fa', '#f59e0b', '#f472b6', '#a78bfa', '#fb7185'];
function smooth(values: (number | null)[], factor: number): (number | null)[] {
if (factor <= 0) return values;
let previous: number | undefined;
return values.map((value) => {
if (value === null) return null;
previous = previous === undefined ? value : factor * previous + (1 - factor) * value;
return previous;
});
}
export function ScalarChart({
series,
smoothing,
title = '训练与评估 Scalars',
xLabel = 'Step',
}: {
series: ScalarSeries[];
smoothing: number;
title?: string;
xLabel?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const trackZoom = useRef(false);
const zoomRanges = useRef<Partial<Record<'x' | 'y', { min: number; max: number }>>>({});
const prepared = useMemo(() => {
const steps = Array.from(
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
).sort((a, b) => a - b);
const columns: uPlot.AlignedData = [steps];
for (const item of series) {
const byStep = new Map(item.points.map((point) => [point.step, point.value]));
columns.push(
smooth(
steps.map((step) => byStep.get(step) ?? null),
smoothing,
),
);
}
return columns;
}, [series, smoothing]);
useEffect(() => {
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
const element = host.current;
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
trackZoom.current = false;
const style = getComputedStyle(element);
const axisColor = style.getPropertyValue('--ui-text-tertiary').trim() || '#8fa0b5';
const gridColor = style.getPropertyValue('--ui-border').trim() || '#213044';
const chart = new uPlot(
{
width,
height: 280,
cursor: { drag: { x: true, y: true, setScale: true } },
scales: { x: { time: false } },
hooks: {
setScale: [
(instance, key) => {
if (!trackZoom.current || (key !== 'x' && key !== 'y')) return;
const scale = instance.scales[key];
if (typeof scale.min === 'number' && typeof scale.max === 'number')
zoomRanges.current[key] = { min: scale.min, max: scale.max };
},
],
},
axes: [
{ stroke: axisColor, grid: { stroke: gridColor } },
{ stroke: axisColor, grid: { stroke: gridColor } },
],
series: [
{ label: xLabel },
...series.map((item, index) => ({
label: item.tag,
stroke: COLORS[index % COLORS.length],
width: 2,
spanGaps: true,
value: (_chart: uPlot, _value: number, _seriesIndex: number, index: number | null) => {
if (index === null) return '—';
const raw = item.points.find((point) => point.step === prepared[0][index]);
return raw ? String(raw.value) : '—';
},
})),
],
},
prepared,
element,
);
chartRef.current = chart;
trackZoom.current = true;
for (const key of ['x', 'y'] as const) {
const range = zoomRanges.current[key];
if (range) chart.setScale(key, range);
}
const wheelZoom = (event: WheelEvent) => {
if (!event.deltaY) return;
event.preventDefault();
event.stopPropagation();
const bounds = chart.over.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
const xRatio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
const yRatio = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
const factor = Math.min(2, Math.max(0.5, Math.exp(event.deltaY * unit * 0.002)));
for (const key of ['x', 'y'] as const) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const ratio = key === 'x' ? xRatio : 1 - yRatio;
const anchor = scale.min + (scale.max - scale.min) * ratio;
chart.setScale(key, {
min: anchor - (anchor - scale.min) * factor,
max: anchor + (scale.max - anchor) * factor,
});
}
};
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
let frame = 0;
let lastWidth = width;
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 280 });
}
});
});
observer.observe(element);
return () => {
observer.disconnect();
chart.over.removeEventListener('wheel', wheelZoom);
window.cancelAnimationFrame(frame);
trackZoom.current = false;
chartRef.current = null;
chart.destroy();
};
}, [prepared, series, xLabel]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
for (const key of ['x', 'y']) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const center = (scale.min + scale.max) / 2;
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
chart.setScale(key, { min: center - radius, max: center + radius });
}
};
const resetZoom = () => {
const chart = chartRef.current;
if (!chart) return;
zoomRanges.current = {};
trackZoom.current = false;
chart.setData(chart.data, true);
trackZoom.current = true;
};
if (series.length === 0)
return (
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
当前 trial 尚无 scalar 数据
</div>
);
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
{title}
</h3>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label={`${title} 放大`}
title="放大"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 缩小`}
title="缩小"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 重置缩放`}
title="重置缩放"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={resetZoom}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</header>
<div ref={host} className="min-w-0 w-full overflow-hidden" />
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
悬停图例显示原始值;曲线可平滑。滚轮或拖拽缩放,右上角复位。
</p>
</section>
);
}
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import {
collisionHalfBounds,
trainingTerrainFromCompiledScene,
type CompiledMapGeometry,
} from './trainingMap';
import { validateCustomTerrain, validatePolicyDeployment } from '../rl/deployment';
import deployment from '../rl/fixtures/obstacleDeployment.json';
import shared from '../../../training_server/tests/fixtures/custom-boxes.json';
const identity = [1, 0, 0, 0, 1, 0, 0, 0, 1];
function geometry(patch: Partial<CompiledMapGeometry> = {}): CompiledMapGeometry {
return {
name: '__platform_map_one__obstacle',
type: 6,
position: [1, 2, 0.5],
rotation: identity,
size: [0.4, 0.3, 0.5],
friction: [0.8, 0.005, 0.0001],
collision: true,
static: true,
map: true,
...patch,
};
}
const pose = [...shared.spawn, ...shared.spawnQuaternion];
const coords = { spawn: [-2, -1] as [number, number], target: [2, -1] as [number, number] };
describe('customTrainingMap compiled collision export', () => {
it('共享payload逐字段roundtrip,不从种子生成布局,custom metadata严格一致', () => {
const layout = trainingTerrainFromCompiledScene([geometry()], pose, 6, coords);
expect(validateCustomTerrain(layout)).toEqual(shared);
const d = {
...deployment,
terrainPreset: 'custom_boxes',
terrain: layout,
terrainParams: { size: layout.size, friction: layout.friction },
};
expect(validatePolicyDeployment(d).terrain).toEqual(shared);
expect(() =>
validatePolicyDeployment({ ...d, terrainParams: { size: 8, friction: 0.8 } }),
).toThrow();
expect(() => validatePolicyDeployment({ ...d, terrain: undefined })).toThrow();
});
it('abs(R)*halfsize支持实例旋转/平移;排除机器人和装饰,所有碰撞障碍保留', () => {
const c = Math.SQRT1_2,
r = [c, -c, 0, c, c, 0, 0, 0, 1];
const layout = trainingTerrainFromCompiledScene(
[
geometry({
name: '__platform_map_one__ground',
position: [0, 0, -0.05],
size: [5, 5, 0.05],
}),
geometry({ position: [2, 2, 0.5], rotation: r }),
geometry({ position: [-2, 2, 0.5] }),
geometry({ static: false, map: false, position: [0, 0, 0.32], size: [20, 20, 20] }),
geometry({ collision: false, type: 7, size: [30, 30, 30] }),
],
pose,
4,
coords,
);
expect(layout.boxes).toHaveLength(3);
expect(layout.size).toBe(10);
expect(layout.boxes[1].pos).toEqual([2, 2, 0.5]);
expect(layout.boxes[1].size[0]).toBeCloseTo(0.7 * c, 12);
expect(layout.boxes[1].size[1]).toBeCloseTo(0.7 * c, 12);
expect(layout.approximation).toBe(true);
validateCustomTerrain(layout);
});
it('primitive bounds有解析证明;mesh/hfield、不明静态/坑底/混合摩擦拒绝', () => {
expect(collisionHalfBounds(2, [0.4, 0, 0], identity)).toEqual([0.4, 0.4, 0.4]);
expect(collisionHalfBounds(3, [0.2, 0.5, 0], identity)).toEqual([0.2, 0.2, 0.7]);
expect(collisionHalfBounds(4, [0.2, 0.3, 0.4], identity)).toEqual([0.2, 0.3, 0.4]);
expect(collisionHalfBounds(5, [0.2, 0.5, 0], identity)).toEqual([0.2, 0.2, 0.5]);
for (const patch of [
{ type: 1 },
{ type: 7 },
{ map: false },
{ position: [1, 2, -1] },
{ friction: [0.8, 0.01, 0.0001] },
{ position: [15, 2, 0.5] },
{ size: [0, 0.3, 0.5] },
{ size: [NaN, 0.3, 0.5] },
{ name: 'ground', type: 0, position: [0, 0, 1] },
{ name: 'ground', type: 0, position: [0, 0, 0], rotation: [1, 0, 0, 0, 0, -1, 0, 1, 0] },
])
expect(() => trainingTerrainFromCompiledScene([geometry(patch)], pose, 6, coords)).toThrow();
expect(() =>
trainingTerrainFromCompiledScene(
[geometry(), geometry({ friction: [1, 0.005, 0.0001] })],
pose,
6,
coords,
),
).toThrow(/混合摩擦/);
expect(() =>
trainingTerrainFromCompiledScene(
Array.from({ length: 257 }, () => geometry()),
pose,
6,
coords,
),
).toThrow(/256/);
// Only explicitly named horizontal surface at z=0 may become standard floor.
expect(
trainingTerrainFromCompiledScene(
[geometry({ name: 'floor', type: 0, position: [0, 0, 0], map: false })],
pose,
6,
coords,
).boxes,
).toHaveLength(1);
});
it('严格有限数值/正半尺寸/字段与数量校验,不扩尺寸、不偷偷清除安全区障碍', () => {
for (const bad of [NaN, Infinity, -Infinity, -1, 0, -0, true]) {
const t = structuredClone(shared);
t.boxes[1].size[0] = bad as number;
expect(() => validateCustomTerrain(t)).toThrow();
}
for (const patch of [
{ approximation: false },
{ actualObstacleCount: 5 },
{ path: '../../etc/passwd' },
{ spawnQuaternion: [0, 0, 0, 0] },
{ target: undefined },
{ size: 25 },
])
expect(() => validateCustomTerrain({ ...shared, ...patch })).toThrow();
expect(() => validateCustomTerrain({ ...shared, target: [1.9, 2] })).toThrow(/安全区/);
validateCustomTerrain({ ...shared, target: [1.91, 2] });
validateCustomTerrain({ ...shared, target: [1.8, 2.7] });
const t = trainingTerrainFromCompiledScene([geometry()], pose, 6, { target: [1, 2] });
expect(t.boxes).toHaveLength(2);
expect(() => validateCustomTerrain(t)).toThrow(/安全区/);
});
});
+191
View File
@@ -0,0 +1,191 @@
import dynamics from '../rl/fixtures/go2CompiledDynamics.json';
import { readFileSync } from 'node:fs';
import loadMujoco from '@mujoco/mujoco';
import { describe, it, expect } from 'vitest';
import { composeTrainingMap } from './trainingMap';
import fixture from '../rl/fixtures/obstacleDeployment.json';
import golden from '../rl/fixtures/obstacleRayGolden.json';
import multiGolden from '../rl/fixtures/multiRingGolden.json';
import multiFixture from '../rl/fixtures/multiRingDeployment.json';
import { validatePolicyDeployment } from '../rl/deployment';
import { SimulationSession } from '../simulation/SimulationSession';
import { Go2ObstacleAvoidanceBindings } from '../rl/runtime/Go2ObstacleAvoidanceBindings';
const deployment = validatePolicyDeployment(fixture);
const simple = new TextEncoder().encode(
'<mujoco><worldbody><geom type="plane" size="0 0 1"/><body name="base_link"><freejoint/><geom type="box" size=".1 .1 .1" mass="1"/>' +
deployment.jointNames
.map((name) => `<body><joint name="${name}"/><geom size=".01" mass=".01"/></body>`)
.join('') +
'</body></worldbody><keyframe><key name="old"/></keyframe></mujoco>',
);
describe('trainingMap', () => {
it('替换无限地面,保留精确halfSize/世界坐标/摩擦,添加PiP camera', () => {
const xml = new TextDecoder().decode(composeTrainingMap(simple, deployment));
const doc = new DOMParser().parseFromString(xml, 'application/xml');
expect(doc.querySelector('[type="plane"], keyframe')).toBeNull();
expect(doc.querySelectorAll('geom[name^="__training_terrain_"]')).toHaveLength(25);
expect(doc.querySelector('geom[name="__training_terrain_0"]')?.getAttribute('size')).toBe(
'6 6 0.1',
);
expect(doc.querySelector('camera[name="__platform_camera__"]')).not.toBeNull();
expect(doc.querySelector('body')?.getAttribute('pos')).toBe('-5 0 0.32');
});
it('真实WASM编译Go2+共享地图;出生姿态、81维观测、32ray golden、reset和资源生命周期', async () => {
const module = await loadMujoco({
wasmBinary: readFileSync('node_modules/@mujoco/mujoco/mujoco.wasm'),
});
const original = readFileSync(
'training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml',
'utf8',
);
const doc = new DOMParser().parseFromString(original, 'application/xml');
// Strip visual meshes only for the lightweight physics test; collision/inertia/joints remain original Go2.
doc.querySelectorAll('mesh, geom[mesh]').forEach((e) => e.remove());
const bytes = composeTrainingMap(
new TextEncoder().encode(new XMLSerializer().serializeToString(doc)),
deployment,
);
const path = '/training-map-test.xml';
module.FS.writeFile(path, bytes);
const session = new SimulationSession(module, path);
try {
session.configureDeployment(deployment);
expect(Array.from(session.data.qpos.slice(0, 7))).toEqual([-5, 0, 0.32, 1, 0, 0, 0]);
expect(session.model.ncam).toBeGreaterThan(0);
for (const [name, expected] of Object.entries(dynamics.joints)) {
const joint = session.model.jnt(name);
try {
const address = Number(joint.dofadr);
expect(Number(session.model.dof_armature[address])).toBe(expected.dof_armature);
expect(Number(session.model.dof_damping[address])).toBe(expected.dof_damping);
expect(Number(session.model.dof_frictionloss[address])).toBe(expected.dof_frictionloss);
} finally {
joint.delete();
}
}
for (const [name, expected] of Object.entries(dynamics.geoms)) {
const geom = session.model.geom(name);
try {
expect(Number(geom.contype)).toBe(expected.geom_contype);
expect(Number(geom.conaffinity)).toBe(expected.geom_conaffinity);
expect(Number(geom.condim)).toBe(expected.geom_condim);
expect(Number(geom.priority)).toBe(expected.geom_priority);
expect(Number(geom.group)).toBe(expected.geom_group);
expect(Array.from(geom.solimp)).toEqual(expected.geom_solimp);
expect(Array.from(geom.friction)).toEqual(
name.includes('_foot_')
? [deployment.terrain!.friction, 0.005, 0.0001]
: expected.geom_friction,
);
} finally {
geom.delete();
}
}
expect(Array.from(session.model.qpos0.slice(7))).toEqual(Array(12).fill(0));
expect(Array.from(session.data.qpos.slice(7))).toEqual(deployment.defaultJointPosition);
const bindings = new Go2ObstacleAvoidanceBindings(
session.model,
session.data,
(id, value) => session.setActuator(id, value),
deployment,
);
for (const item of golden.cases) {
session.data.qpos.set([...item.position, ...item.quaternion]);
module.mj_forward(session.model, session.data);
const obs = bindings.observe(0, new Float32Array(12));
expect(obs).toHaveLength(81);
expect(obs.every(Number.isFinite)).toBe(true);
item.depth.forEach((distance, i) => expect(obs[47 + i]).toBeCloseTo(distance, 5));
expect(Array.from(obs.slice(11, 23))).toEqual(Array(12).fill(0));
}
session.reset();
const original = [...deployment.terrain!.target];
const target: [number, number] = [-5, 0];
bindings.setNavigationTarget(target);
target[0] = 999;
expect(bindings.navigationStatus().target).toEqual([-5, 0]);
expect(bindings.observe(0, new Float32Array(12)).slice(6, 9)).toEqual(new Float32Array(3));
bindings.setNavigationTarget([-5, 3]);
const changed = bindings.observe(0, new Float32Array(12));
expect(changed[79]).toBeCloseTo(0.5);
expect(changed[80]).toBeCloseTo(3 / deployment.terrain!.size);
expect(changed[8]).toBe(1);
expect(bindings.navigationStatus().distance).toBeCloseTo(3);
bindings.setNavigationTarget([999, -999]);
expect(bindings.navigationStatus().target).toEqual([5.5, -5.5]);
for (const value of [NaN, Infinity, -Infinity])
expect(() => bindings.setNavigationTarget([value, 0])).toThrow(/有限/);
const status = bindings.navigationStatus();
status.defaultTarget[0] = 999;
bindings.resetNavigationTarget();
expect(bindings.navigationStatus().target).toEqual(original);
expect(deployment.terrain!.target).toEqual(original);
bindings.setNavigationTarget([0, 2]);
bindings.reset(0);
expect(bindings.navigationStatus().target).toEqual(original);
const box = deployment.terrain!.boxes[1];
bindings.setNavigationTarget([box.pos[0], box.pos[1]]);
expect(bindings.navigationStatus().targetHeight).toBeCloseTo(box.pos[2] + box.size[2]);
bindings.apply(new Float32Array(12));
module.mj_step(session.model, session.data);
session.reset();
bindings.reset(0);
expect(Array.from(session.data.qpos.slice(0, 3))).toEqual(deployment.terrain!.spawn);
expect(bindings.observe(21, new Float32Array(12))).toHaveLength(81);
expect(bindings.rays).toHaveLength(32);
session.data.qpos[2] = 0.1;
module.mj_forward(session.model, session.data);
expect(() => bindings.observe(22, new Float32Array(12))).toThrow(/导航安全停止/);
} finally {
session.dispose();
module.FS.unlink(path);
}
}, 60_000);
});
it('真实WASM完整48ray/97obs:CPU完整姿态/低障碍/坑边golden,绑定缓存按模型隔离', async () => {
const module = await loadMujoco({
wasmBinary: readFileSync('node_modules/@mujoco/mujoco/mujoco.wasm'),
});
const original = readFileSync(
'training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml',
'utf8',
);
const doc = new DOMParser().parseFromString(original, 'application/xml');
doc.querySelectorAll('mesh, geom[mesh]').forEach((e) => e.remove());
const robot = new TextEncoder().encode(new XMLSerializer().serializeToString(doc));
for (const layout of ['default', 'low', 'edge'] as const) {
const boxes = multiGolden.layouts[layout];
const d = validatePolicyDeployment({
...multiFixture,
terrain: { ...multiFixture.terrain, boxes, actualObstacleCount: boxes.length - 1 },
});
const path = '/multi-ring-wasm.xml';
module.FS.writeFile(path, composeTrainingMap(robot, d));
const session = new SimulationSession(module, path);
try {
session.configureDeployment(d);
const bindings = new Go2ObstacleAvoidanceBindings(session.model, session.data, () => {}, d);
for (const c of multiGolden.cases.filter((c) => c.layout === layout)) {
session.data.qpos.set([...c.position, ...c.quaternion]);
module.mj_forward(session.model, session.data);
const obs = bindings.observe(0, new Float32Array(12));
expect(obs).toHaveLength(97);
expect(bindings.rays).toHaveLength(48);
c.depth.forEach((v, i) => expect(obs[47 + i]).toBeCloseTo(v, 5));
expect(Array.from(obs.slice(11, 23))).toEqual(Array(12).fill(0));
}
bindings.setNavigationTarget([-5, 3]);
session.reset();
const obs = bindings.observe(0, new Float32Array(12));
expect(obs[95]).toBeCloseTo(0.5);
expect(obs[96]).toBeCloseTo(0.25);
bindings.clear();
expect(bindings.rays).toEqual([]);
} finally {
session.dispose();
module.FS.unlink(path);
}
}
}, 60_000);
+199
View File
@@ -0,0 +1,199 @@
import {
validatePolicyDeployment,
type PolicyDeployment,
type TrainingTerrain,
} from '../rl/deployment';
/** Input must be flattened MJCF (mj_saveLastXML), so included ground cannot survive composition. */
export function composeTrainingMap(source: Uint8Array, input: PolicyDeployment): Uint8Array {
const deployment = validatePolicyDeployment(input),
terrain = deployment.terrain;
if (!terrain) throw new Error('缺少训练地图');
const doc = new DOMParser().parseFromString(new TextDecoder().decode(source), 'application/xml');
if (doc.querySelector('parsererror, include')) throw new Error('训练地图需要已展开的有效MJCF');
const world = doc.querySelector('mujoco > worldbody');
if (!world) throw new Error('缺少worldbody');
const robots = Array.from(world.children).filter(
(e) => e.tagName === 'body' && e.querySelector('freejoint, joint[type="free"]'),
);
if (robots.length !== 1) throw new Error('训练评测需要唯一浮动基座机器人');
const robot = robots[0];
for (const child of Array.from(world.children))
if (child !== robot && child.tagName !== 'light') child.remove();
doc.querySelector('keyframe')?.remove();
for (const key of ['euler', 'axisangle', 'xyaxes', 'zaxis']) robot.removeAttribute(key);
robot.setAttribute('pos', terrain.spawn.join(' '));
robot.setAttribute('quat', terrain.spawnQuaternion.join(' '));
// Match go2_constants.py FULL_COLLISION + obstacle terrain startup foot friction.
// Raw go2.xml does not contain these mjlab Entity compilation overrides.
for (const geom of robot.querySelectorAll('geom')) {
const name = geom.getAttribute('name') ?? '';
if (!name.endsWith('_collision')) continue;
const foot = /^[FR][LR]_foot_collision$/.test(name);
for (const [key, value] of Object.entries({
contype: '1',
conaffinity: '0',
condim: foot ? '3' : '1',
priority: foot ? '1' : '0',
group: '3',
friction: `${foot ? terrain.friction : 1} 0.005 0.0001`,
solimp: `0.9 0.95 ${foot ? 0.023 : 0.001} 0.5 2`,
}))
geom.setAttribute(key, value);
}
let actuators = doc.querySelector('mujoco > actuator');
if (!actuators) {
actuators = doc.createElement('actuator');
doc.documentElement.append(actuators);
}
for (const name of deployment.jointNames) {
const joint = robot.querySelector(`joint[name="${name}"]`);
if (!joint) throw new Error(`机器人缺少训练关节:${name}`);
joint.setAttribute('armature', name.includes('_calf_') ? '0.02' : '0.01');
joint.setAttribute('damping', '0');
joint.setAttribute('frictionloss', '0');
if (!actuators.querySelector(`[joint="${name}"]`)) {
const motor = doc.createElement('motor');
motor.setAttribute('name', `${name}_motor`);
motor.setAttribute('joint', name);
motor.setAttribute('gear', '1');
actuators.append(motor);
}
}
for (let i = 0; i < terrain.boxes.length; i++) {
const box = terrain.boxes[i],
geom = doc.createElement('geom');
for (const [key, value] of Object.entries({
name: `__training_terrain_${i}`,
type: 'box',
pos: box.pos.join(' '),
size: box.size.join(' '),
quat: '1 0 0 0',
group: '2',
contype: '1',
conaffinity: '1',
friction: `${terrain.friction} 0.005 0.0001`,
priority: '1',
condim: '3',
rgba: i === 0 ? '0.35 0.4 0.45 1' : '0.65 0.35 0.2 1',
}))
geom.setAttribute(key, value);
world.append(geom);
}
// Camera's -Z optical axis looks along robot +X, +Y is image-up along robot +Z.
doc.querySelector('camera[name="__platform_camera__"]')?.remove();
const camera = doc.createElement('camera');
for (const [key, value] of Object.entries({
name: '__platform_camera__',
pos: '0.3 0 0.05',
xyaxes: '0 -1 0 0 0 1',
fovy: '60',
}))
camera.setAttribute(key, value);
robot.append(camera);
return new TextEncoder().encode(new XMLSerializer().serializeToString(doc));
}
export interface TrainingSceneCoordinates {
spawn?: [number, number];
target?: [number, number];
}
export type TrainingSceneCompiler = (coordinates?: TrainingSceneCoordinates) => TrainingTerrain;
export interface CompiledMapGeometry {
name: string;
type: number;
position: number[];
rotation: number[];
size: number[];
friction: number[];
collision: boolean;
static: boolean;
map: boolean;
}
/** Exact analytic world bounds for primitive MuJoCo collision shapes (not visual bounds). */
export function collisionHalfBounds(
type: number,
s: readonly number[],
r: readonly number[],
): number[] {
if (![2, 3, 4, 5, 6].includes(type))
throw new Error('不支持的静态碰撞形状:mesh/hfield无法精确提取;请改用box等基本几何');
return [0, 1, 2].map((i) => {
const row = r.slice(i * 3, i * 3 + 3);
if (type === 2) return s[0]; // sphere
if (type === 3) return s[0] + Math.abs(row[2]) * s[1]; // capsule, axial half-length
if (type === 4) return Math.hypot(...row.map((v, j) => v * s[j])); // ellipsoid
if (type === 5) return Math.hypot(row[0], row[1]) * s[0] + Math.abs(row[2]) * s[1];
return row.reduce((sum, v, j) => sum + Math.abs(v) * s[j], 0); // box: abs(R)*halfsize
});
}
/** Uses already compiled, applied physics. Deliberately does not run terrain RNG or parse XML.
* Returns a candidate so the UI can expose unsafe suggested coordinates for explicit correction.
* validateCustomTerrain is mandatory before declaring synchronization/upload success.
*/
export function trainingTerrainFromCompiledScene(
geometries: readonly CompiledMapGeometry[],
initialPose: readonly number[],
mapExtent: number,
coordinates: TrainingSceneCoordinates = {},
): TrainingTerrain {
const obstacles: TrainingTerrain['boxes'] = [];
let friction: number | undefined;
let extent = Math.max(4, mapExtent);
for (const g of geometries) {
if (!g.static || !g.collision) continue;
if (![...g.position, ...g.rotation, ...g.size, ...g.friction].every(Number.isFinite))
throw new Error(`静态碰撞几何 ${g.name} 含非有限数值`);
const supportName = /(?:^|[_-])(?:floor|ground|flat)(?:[_-]|$)/i.test(g.name);
if (!g.map && !(g.type === 0 && supportName))
throw new Error(`发现未声明为已应用地图的静态碰撞几何 ${g.name},不能静默漏障碍`);
if (
g.friction.length !== 3 ||
Math.abs(g.friction[1] - 0.005) > 1e-9 ||
Math.abs(g.friction[2] - 0.0001) > 1e-9 ||
(friction !== undefined && Math.abs(g.friction[0] - friction) > 1e-9)
)
throw new Error(
'地图混合摩擦无法用单一friction表示;请先显式统一各碰撞几何摩擦为 [f,0.005,0.0001]',
);
friction = g.friction[0];
const horizontal = Math.abs(Math.abs(g.rotation[8]) - 1) < 1e-9;
if (g.type === 0) {
if (!supportName || !horizontal || g.rotation[8] < 0 || Math.abs(g.position[2]) > 1e-6)
throw new Error('仅支持明确命名floor/ground的水平z=0支撑plane;倾斜/非零高度plane无法导出');
continue;
}
const half = collisionHalfBounds(g.type, g.size, g.rotation);
if (half.some((v) => !Number.isFinite(v) || v <= 0))
throw new Error('碰撞半尺寸必须严格大于零');
extent = Math.max(extent, ...[0, 1].map((i) => Math.abs(g.position[i]) + half[i]));
const top = g.position[2] + half[2],
bottom = g.position[2] - half[2];
if (g.type === 6 && supportName && horizontal && Math.abs(top) <= 1e-6 && bottom < 0) continue;
if (bottom < -1e-6)
throw new Error(`几何 ${g.name} 含地下/坑底结构;标准floor会填平,拒绝同步`);
obstacles.push({ pos: [...g.position], size: half, yaw: 0 });
if (obstacles.length > 256) throw new Error('自定义障碍物超过256,不能截断');
}
if (friction === undefined) throw new Error('已应用地图没有可导出的静态碰撞几何');
if (extent > 12 + 1e-6) throw new Error('世界地图范围超过24m上限;不平移或裁剪几何');
const size = Math.max(8, extent * 2);
const spawn = coordinates.spawn ?? [initialPose[0], initialPose[1]];
const q = initialPose.slice(3, 7);
const yaw = Math.atan2(2 * (q[0] * q[3] + q[1] * q[2]), 1 - 2 * (q[2] ** 2 + q[3] ** 2));
const target = coordinates.target ?? [spawn[0] + 3 * Math.cos(yaw), spawn[1] + 3 * Math.sin(yaw)];
return {
representation: 'boxes-v1',
approximation: true,
size,
friction,
boxes: [{ pos: [0, 0, -0.1], size: [size / 2, size / 2, 0.1], yaw: 0 }, ...obstacles],
spawn: [spawn[0], spawn[1], 0.32],
spawnQuaternion: q,
target: [...target],
actualObstacleCount: obstacles.length,
};
}
@@ -0,0 +1,64 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { RLPolicyPanel } from './RLPolicyPanel';
import type { RLPolicyStatus } from './types';
it('仅避障策略显示实时导航状态,支持暂停时设定/复位', () => {
const status: RLPolicyStatus = {
taskId: 'Unitree-Go2-ObstacleAvoidance',
taskName: '避障',
path: 'policy.onnx',
loaded: true,
enabled: false,
controlHz: 50,
observationSize: 81,
actionSize: 12,
inputName: 'in',
outputName: 'out',
command: { linearX: 0, linearY: 0, angularZ: 0 },
inferenceCount: 0,
lastInferenceMs: 0,
navigation: { target: [2, -1], defaultTarget: [5, 0], distance: 3.25, targetHeight: 0 },
};
const props = {
paths: [],
loading: false,
status,
onSelectPath: vi.fn(),
onLoadPath: vi.fn(),
onImport: vi.fn(),
onToggle: vi.fn(),
onCommand: vi.fn(),
onRemove: vi.fn(),
onNavigationTargetMode: vi.fn(),
onResetNavigationTarget: vi.fn(),
};
const view = render(<RLPolicyPanel {...props} />);
expect(screen.getByText('(2.00, -1.00)')).toBeInTheDocument();
expect(screen.getByText('3.25 m')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^设定目标$/ }));
expect(props.onNavigationTargetMode).toHaveBeenCalledWith(true);
fireEvent.click(screen.getByRole('button', { name: '复位目标点' }));
expect(props.onResetNavigationTarget).toHaveBeenCalledOnce();
expect(props.onToggle).not.toHaveBeenCalled();
expect(screen.getByText(/策略未启用:设定目标不会自动启动/)).toBeVisible();
view.rerender(<RLPolicyPanel {...props} status={{ ...status, enabled: true }} />);
expect(screen.getByText(/仿真已暂停/)).toBeVisible();
view.rerender(<RLPolicyPanel {...props} status={{ ...status, error: '跌倒' }} />);
expect(screen.getByText(/导航安全停止:请重置并重新启用/)).toBeVisible();
view.rerender(
<RLPolicyPanel
{...props}
navigationTargetMode
status={{ ...status, navigation: { ...status.navigation!, distance: 1.1 } }}
/>,
);
expect(screen.getByText('1.10 m')).toBeInTheDocument();
expect(screen.getByText(/Esc 取消/)).toBeInTheDocument();
view.rerender(
<RLPolicyPanel
{...props}
status={{ ...status, taskId: 'Unitree-Go2-Flat', observationSize: 47 }}
/>,
);
expect(screen.queryByText('复位目标点')).not.toBeInTheDocument();
});
+79 -30
View File
@@ -1,6 +1,7 @@
import { useRef, type ChangeEvent } from 'react';
import { BrainCircuit, FileUp, Power, RotateCw, Trash2 } from 'lucide-react';
import type { RLCommand, RLPolicyStatus } from './types';
import { useAppStore } from '../stores/useAppStore';
import { Badge, Button, PropertyRow, Select } from '../components/ui';
export interface RLPolicyPanelProps {
@@ -14,6 +15,9 @@ export interface RLPolicyPanelProps {
onToggle(enabled: boolean): void;
onCommand(command: RLCommand): void;
onRemove(): void;
navigationTargetMode?: boolean;
onNavigationTargetMode?(active: boolean): void;
onResetNavigationTarget?(): void;
}
export function RLPolicyPanel({
@@ -27,8 +31,12 @@ export function RLPolicyPanel({
onToggle,
onCommand,
onRemove,
navigationTargetMode = false,
onNavigationTargetMode,
onResetNavigationTarget,
}: RLPolicyPanelProps) {
const input = useRef<HTMLInputElement>(null);
const paused = useAppStore((state) => state.paused);
const importFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) onImport(file);
@@ -98,36 +106,77 @@ export function RLPolicyPanel({
/>
<PropertyRow label="推理次数" value={status.inferenceCount} />
<PropertyRow label="上次推理" value={`${status.lastInferenceMs.toFixed(2)} ms`} />
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
<CommandInput
label="前向 m/s"
value={command.linearX}
min={-0.5}
max={1}
onChange={(linearX) => onCommand({ ...command, linearX })}
/>
<CommandInput
label="侧向 m/s"
value={command.linearY}
min={-0.5}
max={0.5}
onChange={(linearY) => onCommand({ ...command, linearY })}
/>
<CommandInput
label="偏航 rad/s"
value={command.angularZ}
min={-1}
max={1}
onChange={(angularZ) => onCommand({ ...command, angularZ })}
/>
<Button
className="mt-1 w-full"
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
>
停止移动
</Button>
</div>
{status.taskId === 'Unitree-Go2-ObstacleAvoidance' && status.navigation && (
<div className="mt-3 border-t border-border pt-3">
<PropertyRow
label="当前目标 (X, Y)"
value={`(${status.navigation.target[0].toFixed(2)}, ${status.navigation.target[1].toFixed(2)})`}
/>
<PropertyRow label="剩余距离" value={`${status.navigation.distance.toFixed(2)} m`} />
<p role="status" className="text-xs text-text-secondary">
{status.error
? '导航安全停止:请重置并重新启用策略。'
: !status.enabled
? '策略未启用:设定目标不会自动启动,请手动启用。'
: paused
? '仿真已暂停:目标已设定,请恢复仿真后运动。'
: '导航控制运行中(不保证到达);可持续更换目标。'}
</p>
<div className="mt-2 grid grid-cols-2 gap-2">
<Button
disabled={loading}
aria-pressed={navigationTargetMode}
onClick={() => onNavigationTargetMode?.(!navigationTargetMode)}
>
{navigationTargetMode ? '取消设定目标' : '设定目标'}
</Button>
<Button disabled={loading} onClick={onResetNavigationTarget}>
复位目标点
</Button>
</div>
{navigationTargetMode && (
<p className="mt-2 text-xs text-accent">
点击主视口地形设定目标,Esc 取消;不会选择或移动地图对象。
</p>
)}
</div>
)}
{status.observationSize === 81 || status.observationSize === 97 ? (
<p className="mt-3 text-xs">
自动导航到配套目标,到达后停止指令。评测20秒/跌倒/越界时停止(非训练端自动重置);请重置后启用。水平射线有矮障碍/跌落盲区,Go2-W不是Go2同构模型。
</p>
) : (
<div className="mt-3 border-t border-border pt-3">
<p className="mb-2 text-[10px] text-text-tertiary">速度指令(机身坐标系)</p>
<CommandInput
label="前向 m/s"
value={command.linearX}
min={-0.5}
max={1}
onChange={(linearX) => onCommand({ ...command, linearX })}
/>
<CommandInput
label="侧向 m/s"
value={command.linearY}
min={-0.5}
max={0.5}
onChange={(linearY) => onCommand({ ...command, linearY })}
/>
<CommandInput
label="偏航 rad/s"
value={command.angularZ}
min={-1}
max={1}
onChange={(angularZ) => onCommand({ ...command, angularZ })}
/>
<Button
className="mt-1 w-full"
onClick={() => onCommand({ linearX: 0, linearY: 0, angularZ: 0 })}
>
停止移动
</Button>
</div>
)}
{status.error && (
<p
role="alert"
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import fixture from './fixtures/obstacleDeployment.json';
import {
readPolicyDeployment,
validatePolicyDeployment,
resolvePolicyDeployment,
} from './deployment';
function varint(value: number): number[] {
const b: number[] = [];
do {
const part = value % 128;
value = Math.floor(value / 128);
b.push(part | (value ? 128 : 0));
} while (value);
return b;
}
function field(tag: number, content: number[]): number[] {
return [...varint(tag * 8 + 2), ...varint(content.length), ...content];
}
const string = (s: string) => Array.from(new TextEncoder().encode(s));
export function metadataModel(value: unknown): Uint8Array {
return new Uint8Array(
field(14, [
...field(1, string('platform_deployment')),
...field(2, string(JSON.stringify(value))),
]),
);
}
describe('policy deployment metadata', () => {
it('解析ONNX ModelProto的部署元数据且忽略graph,不依赖ORT', () => {
const bytes = metadataModel(fixture);
expect(readPolicyDeployment(bytes)).toEqual(validatePolicyDeployment(fixture));
expect(readPolicyDeployment(new Uint8Array([8, 9]))).toBeUndefined();
expect(() => readPolicyDeployment(bytes.slice(0, -1))).toThrow(/截断|长度/);
expect(() => readPolicyDeployment(new Uint8Array([...bytes, ...bytes]))).toThrow(/重复/);
});
it.each([
['version', 2],
['taskId', 'Unitree-Go2-Rough'],
['browserCompatible', false],
['observationSize', 47],
['actionSize', 16],
['controlHz', 200],
['seed', NaN],
['jointNames', []],
['effortLimits', []],
['observationTerms', []],
])('拒绝不兼容%s', (key, value) =>
expect(() => validatePolicyDeployment({ ...fixture, [key]: value })).toThrow(),
);
it('拒绝不支持sensor、超限box、不有限数字及出生点偏移', () => {
expect(() =>
validatePolicyDeployment({ ...fixture, sensorCfg: { ...fixture.sensorCfg, rayCount: 64 } }),
).toThrow();
expect(() =>
validatePolicyDeployment({
...fixture,
sensorCfg: { ...fixture.sensorCfg, type: 'camera_depth' },
}),
).toThrow();
expect(() =>
validatePolicyDeployment({
...fixture,
terrain: { ...fixture.terrain, boxes: Array(258).fill(fixture.terrain.boxes[0]) },
}),
).toThrow();
expect(() =>
validatePolicyDeployment({ ...fixture, terrain: { ...fixture.terrain, friction: Infinity } }),
).toThrow();
expect(() =>
validatePolicyDeployment({
...fixture,
terrain: { ...fixture.terrain, spawn: [0, 0, 0.32] },
}),
).toThrow();
});
});
it('新server默认Flat允许旧无metadata导出,仅返回需要真实graph47→12校验的预期契约', () => {
const { terrain, terrainPreset, terrainParams, sensorCfg, navigation, ...base } =
validatePolicyDeployment(fixture);
const flat = validatePolicyDeployment({
...base,
taskId: 'Unitree-Go2-Flat',
observationSize: 47,
observationTerms: base.observationTerms.slice(0, 7),
});
const legacy = new Uint8Array([8, 9]);
expect(resolvePolicyDeployment(legacy, flat)).toEqual(flat);
expect(() => resolvePolicyDeployment(legacy, validatePolicyDeployment(fixture))).toThrow(
/不一致/,
);
expect(() =>
resolvePolicyDeployment(legacy, { ...flat, terrain, terrainPreset, terrainParams }),
).toThrow(/不一致/);
expect(() => resolvePolicyDeployment(legacy, { ...flat, sensorCfg })).toThrow(/不一致/);
expect(() => resolvePolicyDeployment(legacy, { ...flat, navigation })).toThrow(/不一致/);
});
it('custom_boxes ONNX metadata保持完整布局且绝不降级旧Flat或preset', async () => {
const { default: terrain } =
await import('../../../training_server/tests/fixtures/custom-boxes.json');
const custom = validatePolicyDeployment({
...fixture,
terrainPreset: 'custom_boxes',
terrain,
terrainParams: { size: terrain.size, friction: terrain.friction },
});
const bytes = metadataModel(custom); // ModelProto metadata fixture, not a trained graph.
expect(readPolicyDeployment(bytes)).toEqual(custom);
expect(resolvePolicyDeployment(bytes, custom)).toEqual(custom);
expect(() => resolvePolicyDeployment(new Uint8Array([8, 9]), custom)).toThrow(/不一致/);
expect(() => resolvePolicyDeployment(metadataModel(fixture), custom)).toThrow(/不一致/);
});
it('调参导航速度有限且在专属范围,旧0.6契约仍有效', () => {
for (const speed of [0.3, 0.6, 0.9, 1.2]) {
const value = structuredClone(fixture);
value.navigation.speed = speed;
expect(readPolicyDeployment(metadataModel(value))?.navigation?.speed).toBe(speed);
}
for (const speed of [0, 1.21, NaN, Infinity]) {
const value = structuredClone(fixture);
value.navigation.speed = speed;
expect(() => validatePolicyDeployment(value)).toThrow();
}
});
+406
View File
@@ -0,0 +1,406 @@
import { GO2W_VELOCITY_TASK } from './tasks/go2wVelocity';
export const OBSTACLE_TASK_ID = 'Unitree-Go2-ObstacleAvoidance';
export interface TrainingTerrain {
representation: 'boxes-v1';
approximation: boolean;
size: number;
friction: number;
boxes: { pos: number[]; size: number[]; yaw: number }[];
spawn: number[];
spawnQuaternion: number[];
target: number[];
actualObstacleCount: number;
}
export interface ObstacleSensorConfig {
type: 'raycast';
fov: number;
maxDistance: number;
safetyDistance: number;
avoidanceWeight: number;
rayCount: 32 | 48;
sensorMode?: 'single_ring_raycast' | 'multi_ring_raycast';
pitchAngles?: number[];
yawCount?: number;
yawAngles?: number[];
angleUnit?: 'deg';
rayOrder?: 'layer-major';
offset: number[];
alignment: 'base';
terrainOnly: true;
includeGround: true;
}
export interface PolicyDeployment {
version: 1;
taskId: string;
browserCompatible: true;
observationSize: number;
actionSize: 12;
controlHz: 50;
gaitPeriod: number;
jointNames: string[];
defaultJointPosition: number[];
actionScale: number[];
stiffness: number[];
damping: number[];
effortLimits: number[];
observationTerms: string[];
seed: number;
terrainPreset?: string;
terrainParams?: Record<string, number>;
terrain?: TrainingTerrain;
sensorCfg?: ObstacleSensorConfig;
navigation?: {
speed: number;
arrivalRadius: number;
distanceScale: number;
headingScale: number;
yawGain: number;
maxYawRate: number;
episodeSeconds: number;
onArrival: 'stop';
onReset: 'respawn';
};
}
function requireValue(condition: unknown, label: string): asserts condition {
if (!condition) throw new Error(`不支持或无效的策略部署配置:${label}`);
}
function record(value: unknown): Record<string, unknown> {
requireValue(value !== null && typeof value === 'object' && !Array.isArray(value), '对象');
return value as Record<string, unknown>;
}
function number(value: unknown, min: number, max: number): asserts value is number {
requireValue(
typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max,
'有限数值范围',
);
}
function vector(value: unknown, length: number, min = -100, max = 100): asserts value is number[] {
requireValue(Array.isArray(value) && value.length === length, `向量长度 ${length}`);
value.forEach((v) => number(v, min, max));
}
function canonical(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
if (value !== null && typeof value === 'object')
return `{${Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, v]) => `${JSON.stringify(key)}:${canonical(v)}`)
.join(',')}}`;
return JSON.stringify(value);
}
export function policyDeploymentsMatch(a: PolicyDeployment, b: PolicyDeployment): boolean {
return canonical(validatePolicyDeployment(a)) === canonical(validatePolicyDeployment(b));
}
function equal(actual: unknown, expected: unknown, label: string) {
requireValue(canonical(actual) === canonical(expected), label);
}
/** Strict custom layout boundary shared by scene export, upload and ONNX metadata. */
export function validateCustomTerrain(value: unknown): TrainingTerrain {
const t = record(value);
const keys = (v: Record<string, unknown>, expected: string[]) =>
equal(Object.keys(v).sort(), expected.sort(), '自定义布局字段(不接受路径/MJCF)');
keys(t, [
'representation',
'approximation',
'size',
'friction',
'boxes',
'spawn',
'spawnQuaternion',
'target',
'actualObstacleCount',
]);
equal(t.representation, 'boxes-v1', '地图表示');
equal(t.approximation, true, 'custom_boxes 必须标记AABB/底板标准化近似');
number(t.size, 8, 24);
number(t.friction, 0.2, 2);
requireValue(
Array.isArray(t.boxes) && t.boxes.length >= 1 && t.boxes.length <= 257,
'最多256障碍物',
);
for (const item of t.boxes) {
const b = record(item);
keys(b, ['pos', 'size', 'yaw']);
vector(b.pos, 3, -12, 12);
vector(b.size, 3, 0, 12);
requireValue(
b.size.every((v) => v > 0),
'半尺寸必须严格大于零',
);
equal(b.yaw, 0, 'box yaw');
for (let i = 0; i < 2; i++)
requireValue(Math.abs(b.pos[i]) + b.size[i] <= t.size / 2 + 1e-6, '世界地图边界');
requireValue(b.pos[2] - b.size[2] >= -0.2 - 1e-6 && b.pos[2] + b.size[2] <= 12, 'box高度边界');
}
equal(
t.boxes[0],
{ pos: [0, 0, -0.1], size: [t.size / 2, t.size / 2, 0.1], yaw: 0 },
'标准floor',
);
equal(t.actualObstacleCount, t.boxes.length - 1, '实际障碍数');
vector(t.spawn, 3, -12, 12);
equal(t.spawn[2], 0.32, '出生高度');
vector(t.target, 2, -12, 12);
vector(t.spawnQuaternion, 4, -1, 1);
requireValue(
Math.abs(t.spawnQuaternion.reduce((sum, v) => sum + v * v, 0) - 1) <= 1e-6,
'归一化出生四元数',
);
for (const point of [t.spawn, t.target]) {
requireValue(
point.slice(0, 2).every((v) => Math.abs(v) <= (t.size as number) / 2 - 0.5),
'起终点0.5m安全区越界',
);
for (const item of t.boxes.slice(1)) {
const b = item as TrainingTerrain['boxes'][number];
const distanceSquared = [0, 1].reduce(
(sum, i) => sum + Math.max(Math.abs(point[i] - b.pos[i]) - b.size[i], 0) ** 2,
0,
);
requireValue(
distanceSquared > 0.25,
'障碍物侵占起终点0.5m圆形安全区;请修改坐标,不会清除障碍',
);
}
}
return structuredClone(t) as unknown as TrainingTerrain;
}
/** A single spelling for the supported angular patterns; legacy metadata defaults to 32. */
export function obstacleSensorPattern(mode: unknown, fov: unknown) {
requireValue(
mode === undefined || mode === 'single_ring_raycast' || mode === 'multi_ring_raycast',
'sensorMode',
);
number(fov, 30, 120);
const multi = mode === 'multi_ring_raycast';
const yawCount = multi ? 16 : 32;
return {
sensorMode: multi ? ('multi_ring_raycast' as const) : ('single_ring_raycast' as const),
rayCount: multi ? (48 as const) : (32 as const),
pitchAngles: multi ? [0, -20, -45] : [0],
yawCount,
yawAngles: Array.from({ length: yawCount }, (_, i) => -fov / 2 + (i * fov) / (yawCount - 1)),
angleUnit: 'deg' as const,
rayOrder: 'layer-major' as const,
};
}
/** v1 only: reject incompatible contracts before altering the simulation or allocating ORT. */
export function validatePolicyDeployment(value: unknown): PolicyDeployment {
const d = structuredClone(record(value));
requireValue(JSON.stringify(d).length <= 100_000, '配置大小');
requireValue(
d.version === 1 && d.browserCompatible === true,
'版本/浏览器兼容性(旧 Rough 不支持)',
);
requireValue(d.taskId === OBSTACLE_TASK_ID || d.taskId === 'Unitree-Go2-Flat', '任务');
if (d.taskId !== OBSTACLE_TASK_ID) equal(d.observationSize, 47, '观测维数');
equal(d.actionSize, 12, '动作维数');
equal(d.controlHz, 50, '控制频率');
equal(d.gaitPeriod, 0.6, '步态周期');
for (const key of [
'jointNames',
'defaultJointPosition',
'actionScale',
'stiffness',
'damping',
] as const)
equal(d[key], GO2W_VELOCITY_TASK[key], key);
equal(
d.effortLimits,
[23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45],
'力矩限幅',
);
equal(
d.observationTerms,
[
'base_ang_vel',
'projected_gravity',
'command',
'phase',
'joint_pos',
'joint_vel',
'actions',
...(d.taskId === OBSTACLE_TASK_ID ? ['forward_depth', 'target_error'] : []),
],
'观测顺序',
);
number(d.seed, 0, 2147483647);
requireValue(Number.isInteger(d.seed), 'seed');
if (d.terrain !== undefined) {
if (d.terrainPreset === 'custom_boxes') {
const t = validateCustomTerrain(d.terrain);
equal(d.terrainParams, { size: t.size, friction: t.friction }, 'custom_boxes参数与布局一致');
} else {
const t = record(d.terrain);
equal(t.representation, 'boxes-v1', '地图表示');
requireValue(typeof t.approximation === 'boolean', '近似标记');
number(t.size, 8, 24);
number(t.friction, 0.2, 2);
requireValue(
Array.isArray(t.boxes) && t.boxes.length >= 1 && t.boxes.length <= 257,
'box数量',
);
for (const item of t.boxes) {
const box = record(item);
vector(box.pos, 3, -12, 12);
vector(box.size, 3, 0.0001, 12);
equal(box.yaw, 0, 'box yaw');
for (let i = 0; i < 2; i++)
requireValue(Math.abs(box.pos[i]) + box.size[i] <= t.size / 2 + 1e-6, 'box边界');
}
equal(t.boxes[0], { pos: [0, 0, -0.1], size: [t.size / 2, t.size / 2, 0.1], yaw: 0 }, '地板');
equal(t.spawn, [-t.size / 2 + 1, 0, 0.32], '出生点');
equal(t.spawnQuaternion, [1, 0, 0, 0], '出生姿态');
equal(t.target, [t.size / 2 - 1, 0], '目标');
equal(t.actualObstacleCount, t.boxes.length - 1, '实际障碍数');
requireValue(
['plane', 'discrete_obstacles', 'rough', 'wave', 'pyramid_stairs'].includes(
String(d.terrainPreset),
),
'地形预设',
);
const params = record(d.terrainParams);
for (const value of Object.values(params)) number(value, 0, 100);
equal(params.size, t.size, '地图尺寸');
equal(params.friction, t.friction, '摩擦');
}
} else {
requireValue(d.terrainPreset !== 'custom_boxes', '缺失custom_boxes布局');
}
if (d.taskId === OBSTACLE_TASK_ID) {
requireValue(d.terrain, '避障地图');
const s = record(d.sensorCfg);
equal(s.type, 'raycast', '传感器');
const pattern = obstacleSensorPattern(s.sensorMode, s.fov);
const allowed = new Set([
'type',
'fov',
'maxDistance',
'safetyDistance',
'avoidanceWeight',
'offset',
'alignment',
'terrainOnly',
'includeGround',
...Object.keys(pattern),
]);
requireValue(
Object.keys(s).every((key) => allowed.has(key)),
'传感器未知字段',
);
for (const [key, expected] of Object.entries(pattern)) {
if (s[key] === undefined) continue;
if (key === 'yawAngles') {
vector(s[key], (expected as number[]).length, -60, 60);
requireValue(
(s[key] as number[]).every((v, i) => Math.abs(v - (expected as number[])[i]) <= 1e-10),
'yawAngles与FOV矛盾',
);
} else equal(s[key], expected, `sensorCfg.${key}`);
}
equal(s.rayCount, pattern.rayCount, '射线数量');
equal(d.observationSize, 49 + pattern.rayCount, '观测维数');
d.sensorCfg = { ...s, ...pattern };
equal(s.offset, [0.3, 0, 0.05], '射线偏移');
equal(s.alignment, 'base', '射线姿态');
equal(s.terrainOnly, true, '仅地形');
equal(s.includeGround, true, '包含地面');
number(s.fov, 30, 120);
number(s.maxDistance, 1, 5);
number(s.safetyDistance, 0.1, 1);
number(s.avoidanceWeight, 0, 10);
requireValue(s.safetyDistance < s.maxDistance, '安全距离');
const n = record(d.navigation);
number(n.speed, 0.3, 1.2);
for (const [key, expected] of Object.entries({
arrivalRadius: 0.5,
distanceScale: record(d.terrain).size,
headingScale: Math.PI,
yawGain: 1,
maxYawRate: 1,
episodeSeconds: 20,
onArrival: 'stop',
onReset: 'respawn',
}))
equal(n[key], expected, `导航 ${key}`);
}
return structuredClone(d) as unknown as PolicyDeployment;
}
/** Read only ModelProto.metadata_props (field 14); skip graph/tensors without decoding them. */
export function readPolicyDeployment(bytes: Uint8Array): PolicyDeployment | undefined {
requireValue(bytes.length <= 64 * 1024 * 1024, 'ONNX超过64MiB');
const entries: Uint8Array[] = [];
function fields(data: Uint8Array, visit: (field: number, value: Uint8Array) => void) {
let p = 0;
const varint = () => {
let value = 0;
for (let i = 0; i < 10; i++) {
requireValue(p < data.length, 'ONNX截断');
const b = data[p++];
value += (b & 127) * 2 ** (i * 7);
requireValue(Number.isSafeInteger(value), 'ONNX整数');
if (!(b & 128)) return value;
}
throw new Error('无效ONNX varint');
};
while (p < data.length) {
const tag = varint(),
field = Math.floor(tag / 8),
wire = tag % 8;
requireValue(field > 0, 'ONNX字段');
if (wire === 0) {
varint();
continue;
}
const length = wire === 2 ? varint() : wire === 1 ? 8 : wire === 5 ? 4 : -1;
requireValue(length >= 0 && p + length <= data.length, 'ONNX字段长度');
if (wire === 2) visit(field, data.subarray(p, p + length));
p += length;
}
}
fields(bytes, (field, value) => {
if (field === 14) {
requireValue(value.length < 100_000 && entries.length < 100, 'ONNX metadata大小');
entries.push(value);
}
});
let deployment: PolicyDeployment | undefined;
const decoder = new TextDecoder('utf-8', { fatal: true });
for (const entry of entries) {
let key = '',
value = '';
fields(entry, (field, bytes) => {
if (field === 1) key = decoder.decode(bytes);
if (field === 2) value = decoder.decode(bytes);
});
if (key === 'platform_deployment') {
requireValue(!deployment, '重复deployment');
deployment = validatePolicyDeployment(JSON.parse(value));
}
}
return deployment;
}
/** Only the original non-custom Flat job may import older external-trainer ONNX without metadata.
* Runtime.load still validates the real graph, not this JSON declaration. */
export function resolvePolicyDeployment(
bytes: Uint8Array,
expected?: PolicyDeployment,
): PolicyDeployment | undefined {
const embedded = readPolicyDeployment(bytes);
if (!expected) return embedded;
const validated = validatePolicyDeployment(expected);
if (embedded && policyDeploymentsMatch(validated, embedded)) return embedded;
const legacyFlat =
validated.taskId === 'Unitree-Go2-Flat' &&
validated.terrain === undefined &&
validated.terrainPreset === undefined &&
validated.terrainParams === undefined &&
validated.sensorCfg === undefined &&
validated.navigation === undefined;
if (!embedded && legacyFlat) return validated;
throw new Error('下载的策略与训练作业部署配置不一致');
}
@@ -0,0 +1,279 @@
{
"source": "Entity(get_go2_robot_cfg()).compile(); go2_constants.py FULL_COLLISION and armature. Foot friction is overridden by custom terrain startup.",
"joints": {
"floating_base_joint": {
"dof_armature": 0.0,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FL_hip_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FL_thigh_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FL_calf_joint": {
"dof_armature": 0.02,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FR_hip_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FR_thigh_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"FR_calf_joint": {
"dof_armature": 0.02,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RL_hip_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RL_thigh_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RL_calf_joint": {
"dof_armature": 0.02,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RR_hip_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RR_thigh_joint": {
"dof_armature": 0.01,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
},
"RR_calf_joint": {
"dof_armature": 0.02,
"dof_damping": 0.0,
"dof_frictionloss": 0.0
}
},
"geoms": {
"base1_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"base2_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"base3_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FL_hip_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FL_thigh_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FL_calf1_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FL_calf2_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FL_foot_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 3,
"geom_priority": 1,
"geom_solimp": [0.9, 0.95, 0.023, 0.5, 2.0],
"geom_friction": [0.6, 0.005, 0.0001],
"geom_group": 3
},
"FR_hip_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FR_thigh_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FR_calf1_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FR_calf2_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"FR_foot_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 3,
"geom_priority": 1,
"geom_solimp": [0.9, 0.95, 0.023, 0.5, 2.0],
"geom_friction": [0.6, 0.005, 0.0001],
"geom_group": 3
},
"RL_hip_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RL_thigh_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RL_calf1_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RL_calf2_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RL_foot_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 3,
"geom_priority": 1,
"geom_solimp": [0.9, 0.95, 0.023, 0.5, 2.0],
"geom_friction": [0.6, 0.005, 0.0001],
"geom_group": 3
},
"RR_hip_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RR_thigh_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RR_calf1_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RR_calf2_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 1,
"geom_priority": 0,
"geom_solimp": [0.9, 0.95, 0.001, 0.5, 2.0],
"geom_friction": [1.0, 0.005, 0.0001],
"geom_group": 3
},
"RR_foot_collision": {
"geom_contype": 1,
"geom_conaffinity": 0,
"geom_condim": 3,
"geom_priority": 1,
"geom_solimp": [0.9, 0.95, 0.023, 0.5, 2.0],
"geom_friction": [0.6, 0.005, 0.0001],
"geom_group": 3
}
}
}
@@ -0,0 +1,221 @@
{
"version": 1,
"taskId": "Unitree-Go2-ObstacleAvoidance",
"browserCompatible": true,
"observationSize": 97,
"actionSize": 12,
"controlHz": 50,
"gaitPeriod": 0.6,
"jointNames": [
"FL_hip_joint",
"FL_thigh_joint",
"FL_calf_joint",
"FR_hip_joint",
"FR_thigh_joint",
"FR_calf_joint",
"RL_hip_joint",
"RL_thigh_joint",
"RL_calf_joint",
"RR_hip_joint",
"RR_thigh_joint",
"RR_calf_joint"
],
"defaultJointPosition": [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8, -0.1, 0.9, -1.8, 0.1, 0.9, -1.8],
"actionScale": [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25],
"stiffness": [20, 20, 40, 20, 20, 40, 20, 20, 40, 20, 20, 40],
"damping": [1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1, 2],
"effortLimits": [23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45],
"observationTerms": [
"base_ang_vel",
"projected_gravity",
"command",
"phase",
"joint_pos",
"joint_vel",
"actions",
"forward_depth",
"target_error"
],
"seed": 42,
"terrainPreset": "discrete_obstacles",
"terrainParams": {
"size": 12,
"obstacle_count": 24,
"obstacle_height_min": 0.2,
"obstacle_height_max": 0.6,
"spacing": 1.2,
"friction": 0.8,
"roughness": 0.06,
"step_height": 0.08,
"wave_amplitude": 0.08
},
"terrain": {
"representation": "boxes-v1",
"approximation": false,
"size": 12,
"friction": 0.8,
"boxes": [
{
"pos": [0, 0, -0.1],
"size": [6.0, 6.0, 0.1],
"yaw": 0
},
{
"pos": [0.666666666666667, 1.875, 0.261425654654876],
"size": [0.2, 0.2, 0.261425654654876],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 4.375, 0.24594635733876358],
"size": [0.2, 0.2, 0.24594635733876358],
"yaw": 0
},
{
"pos": [0.666666666666667, -4.375, 0.20724561829094013],
"size": [0.2, 0.2, 0.20724561829094013],
"yaw": 0
},
{
"pos": [-2.0, -1.875, 0.29462315279587414],
"size": [0.2, 0.2, 0.29462315279587414],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 1.875, 0.17570687544167068],
"size": [0.2, 0.2, 0.17570687544167068],
"yaw": 0
},
{
"pos": [-2.0, -3.125, 0.2104081262546454],
"size": [0.2, 0.2, 0.2104081262546454],
"yaw": 0
},
{
"pos": [0.666666666666667, 3.125, 0.265880932850599],
"size": [0.2, 0.2, 0.265880932850599],
"yaw": 0
},
{
"pos": [2.0, 4.375, 0.2237039504728492],
"size": [0.2, 0.2, 0.2237039504728492],
"yaw": 0
},
{
"pos": [-2.0, 0.625, 0.27234138006215547],
"size": [0.2, 0.2, 0.27234138006215547],
"yaw": 0
},
{
"pos": [-3.3333333333333335, -0.625, 0.21547042905135239],
"size": [0.2, 0.2, 0.21547042905135239],
"yaw": 0
},
{
"pos": [2.0, -0.625, 0.24091436724298468],
"size": [0.2, 0.2, 0.24091436724298468],
"yaw": 0
},
{
"pos": [-3.3333333333333335, 0.625, 0.10916487673113245],
"size": [0.2, 0.2, 0.10916487673113245],
"yaw": 0
},
{
"pos": [3.333333333333333, -0.625, 0.14557965513030938],
"size": [0.2, 0.2, 0.14557965513030938],
"yaw": 0
},
{
"pos": [0.666666666666667, -1.875, 0.15787759272042143],
"size": [0.2, 0.2, 0.15787759272042143],
"yaw": 0
},
{
"pos": [-2.0, -0.625, 0.11595839538472551],
"size": [0.2, 0.2, 0.11595839538472551],
"yaw": 0
},
{
"pos": [2.0, -3.125, 0.14655817727220605],
"size": [0.2, 0.2, 0.14655817727220605],
"yaw": 0
},
{
"pos": [0.666666666666667, 0.625, 0.12020028588194583],
"size": [0.2, 0.2, 0.12020028588194583],
"yaw": 0
},
{
"pos": [3.333333333333333, -3.125, 0.15559472062201843],
"size": [0.2, 0.2, 0.15559472062201843],
"yaw": 0
},
{
"pos": [-0.6666666666666665, -1.875, 0.22713688885288003],
"size": [0.2, 0.2, 0.22713688885288003],
"yaw": 0
},
{
"pos": [0.666666666666667, 4.375, 0.17296643579401685],
"size": [0.2, 0.2, 0.17296643579401685],
"yaw": 0
},
{
"pos": [3.333333333333333, 3.125, 0.17403619342337653],
"size": [0.2, 0.2, 0.17403619342337653],
"yaw": 0
},
{
"pos": [2.0, -4.375, 0.14190140615429753],
"size": [0.2, 0.2, 0.14190140615429753],
"yaw": 0
},
{
"pos": [2.0, 0.625, 0.15339556440982266],
"size": [0.2, 0.2, 0.15339556440982266],
"yaw": 0
},
{
"pos": [0.666666666666667, -3.125, 0.2873309175424988],
"size": [0.2, 0.2, 0.2873309175424988],
"yaw": 0
}
],
"spawn": [-5.0, 0, 0.32],
"spawnQuaternion": [1, 0, 0, 0],
"target": [5.0, 0],
"actualObstacleCount": 24
},
"sensorCfg": {
"fov": 90,
"maxDistance": 4,
"safetyDistance": 0.5,
"avoidanceWeight": 2,
"type": "raycast",
"sensorMode": "multi_ring_raycast",
"rayCount": 48,
"pitchAngles": [0, -20, -45],
"yawCount": 16,
"yawAngles": [
-45.0, -39.0, -33.0, -27.0, -21.0, -15.0, -9.0, -3.0, 3.0, 9.0, 15.0, 21.0, 27.0, 33.0, 39.0,
45.0
],
"angleUnit": "deg",
"rayOrder": "layer-major",
"offset": [0.3, 0, 0.05],
"alignment": "base",
"terrainOnly": true,
"includeGround": true
},
"navigation": {
"speed": 0.6,
"arrivalRadius": 0.5,
"distanceScale": 12,
"headingScale": 3.141592653589793,
"yawGain": 1.0,
"maxYawRate": 1.0,
"episodeSeconds": 20,
"onArrival": "stop",
"onReset": "respawn"
}
}
@@ -0,0 +1,336 @@
{
"source": "CPU MuJoCo 3.5.0 mj_ray",
"layouts": {
"default": [
{
"pos": [0, 0, -0.1],
"size": [6.0, 6.0, 0.1],
"yaw": 0
},
{
"pos": [0.666666666666667, 1.875, 0.261425654654876],
"size": [0.2, 0.2, 0.261425654654876],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 4.375, 0.24594635733876358],
"size": [0.2, 0.2, 0.24594635733876358],
"yaw": 0
},
{
"pos": [0.666666666666667, -4.375, 0.20724561829094013],
"size": [0.2, 0.2, 0.20724561829094013],
"yaw": 0
},
{
"pos": [-2.0, -1.875, 0.29462315279587414],
"size": [0.2, 0.2, 0.29462315279587414],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 1.875, 0.17570687544167068],
"size": [0.2, 0.2, 0.17570687544167068],
"yaw": 0
},
{
"pos": [-2.0, -3.125, 0.2104081262546454],
"size": [0.2, 0.2, 0.2104081262546454],
"yaw": 0
},
{
"pos": [0.666666666666667, 3.125, 0.265880932850599],
"size": [0.2, 0.2, 0.265880932850599],
"yaw": 0
},
{
"pos": [2.0, 4.375, 0.2237039504728492],
"size": [0.2, 0.2, 0.2237039504728492],
"yaw": 0
},
{
"pos": [-2.0, 0.625, 0.27234138006215547],
"size": [0.2, 0.2, 0.27234138006215547],
"yaw": 0
},
{
"pos": [-3.3333333333333335, -0.625, 0.21547042905135239],
"size": [0.2, 0.2, 0.21547042905135239],
"yaw": 0
},
{
"pos": [2.0, -0.625, 0.24091436724298468],
"size": [0.2, 0.2, 0.24091436724298468],
"yaw": 0
},
{
"pos": [-3.3333333333333335, 0.625, 0.10916487673113245],
"size": [0.2, 0.2, 0.10916487673113245],
"yaw": 0
},
{
"pos": [3.333333333333333, -0.625, 0.14557965513030938],
"size": [0.2, 0.2, 0.14557965513030938],
"yaw": 0
},
{
"pos": [0.666666666666667, -1.875, 0.15787759272042143],
"size": [0.2, 0.2, 0.15787759272042143],
"yaw": 0
},
{
"pos": [-2.0, -0.625, 0.11595839538472551],
"size": [0.2, 0.2, 0.11595839538472551],
"yaw": 0
},
{
"pos": [2.0, -3.125, 0.14655817727220605],
"size": [0.2, 0.2, 0.14655817727220605],
"yaw": 0
},
{
"pos": [0.666666666666667, 0.625, 0.12020028588194583],
"size": [0.2, 0.2, 0.12020028588194583],
"yaw": 0
},
{
"pos": [3.333333333333333, -3.125, 0.15559472062201843],
"size": [0.2, 0.2, 0.15559472062201843],
"yaw": 0
},
{
"pos": [-0.6666666666666665, -1.875, 0.22713688885288003],
"size": [0.2, 0.2, 0.22713688885288003],
"yaw": 0
},
{
"pos": [0.666666666666667, 4.375, 0.17296643579401685],
"size": [0.2, 0.2, 0.17296643579401685],
"yaw": 0
},
{
"pos": [3.333333333333333, 3.125, 0.17403619342337653],
"size": [0.2, 0.2, 0.17403619342337653],
"yaw": 0
},
{
"pos": [2.0, -4.375, 0.14190140615429753],
"size": [0.2, 0.2, 0.14190140615429753],
"yaw": 0
},
{
"pos": [2.0, 0.625, 0.15339556440982266],
"size": [0.2, 0.2, 0.15339556440982266],
"yaw": 0
},
{
"pos": [0.666666666666667, -3.125, 0.2873309175424988],
"size": [0.2, 0.2, 0.2873309175424988],
"yaw": 0
}
],
"low": [
{
"pos": [0, 0, -0.1],
"size": [6.0, 6.0, 0.1],
"yaw": 0
},
{
"pos": [0.9, 0, 0.025],
"size": [0.2, 0.5, 0.025],
"yaw": 0
}
],
"edge": [
{
"pos": [0, 0, -0.1],
"size": [6.0, 6.0, 0.1],
"yaw": 0
}
]
},
"cases": [
{
"layout": "default",
"position": [-5, 0, 0.32],
"quaternion": [1.0, 0.0, 0.0, 0.0],
"distances": [
-1.0, 3.2168989147329183, 1.3910905083086054, 1.309380610573421, 1.2496691592432005, -1.0,
-1.0, -1.0, -1.0, 2.716792619137356, 2.5881904510252074, 5.534249133791317, -1.0,
7.750361403433658, -1.0, 5.904341622907672, 1.0818076280603424, 1.0818076280603424,
1.0818076280603424, 1.0818076280603424, 1.0818076280603424, 1.0818076280603424,
1.0818076280603424, 1.0818076280603424, 1.0818076280603424, 1.0818076280603424,
1.0818076280603424, 1.0818076280603424, 1.0818076280603424, 1.0818076280603424,
1.0818076280603424, 1.0818076280603424, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452
],
"hitIds": [
-1, 4, 10, 10, 10, -1, -1, -1, -1, 9, 9, 1, -1, 8, -1, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
"depth": [
1, 0.8042247286832296, 0.34777262707715134, 0.32734515264335523, 0.31241728981080014, 1, 1,
1, 1, 0.679198154784339, 0.6470476127563018, 1, 1, 1, 1, 1, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.2704519070150856, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.2704519070150856, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.2704519070150856, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.2704519070150856, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113
]
},
{
"layout": "default",
"position": [-3, 1, 0.42],
"quaternion": [
0.9542159228792829, 0.07102055199624979, -0.17699854315088892, 0.23043343819907972
],
"distances": [
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, 5.4074561027806505, 4.629641840762559, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.1610973299449852, 1.2130908885760254,
1.2644878853056216, 1.313884872400026, 1.3596758729124838, 1.400132832042811,
1.4335269836666904, 1.4582821425665438, 1.4731397038952894, 1.4773064832243956,
1.4705548683639014, 1.4532525276398263, 1.426314634300804, 1.3910898658423474,
1.349205630860882, 1.3024034756284684
],
"hitIds": [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 23, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
"depth": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0.2902743324862463, 0.30327272214400636, 0.3161219713264054, 0.3284712181000065,
0.33991896822812095, 0.35003320801070276, 0.3583817459166726, 0.36457053564163594,
0.36828492597382234, 0.3693266208060989, 0.36763871709097534, 0.36331313190995657,
0.356578658575201, 0.34777246646058685, 0.3373014077152205, 0.3256008689071171
]
},
{
"layout": "default",
"position": [-2, -2, 0.5],
"quaternion": [
0.920495563973526, -0.20509659844134395, 0.07256269404109637, -0.32458890530384493
],
"distances": [
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 4.141121232325999, 2.46859904634721,
2.3854692510759445, 2.821623442591415, 2.353034357115619, 2.037057192180342,
1.813411398667459, -1.0, -1.0, -1.0, -1.0, 3.2639344664632044, 2.6365745703702133,
2.2014351861213233, 1.8851396055702525, 1.647197681451036, 1.463506304912354,
1.3188672797050087, 1.2032560687123115, 1.1098187807096116, 1.0337317580203136,
0.9715203831532319, 0.9206357398775848, 0.8370986440649361, 1.207568318572572,
1.1431256747550447, 1.081361321440165, 1.0230591974383043, 0.9686938988212619,
0.9185003934090735, 0.8725376029719634, 0.8307419413609812, 0.7929698985660093,
0.7590304425905215, 0.7287087687688034, 0.7017831189893099, 0.6780362858286884,
0.657263179268657, 0.6392755644084297
],
"hitIds": [
-1, -1, -1, -1, -1, -1, -1, -1, -1, 22, 24, 24, 0, 0, 0, 0, -1, -1, -1, -1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
"depth": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.6171497615868025, 0.5963673127689861, 0.7054058606478537,
0.5882585892789047, 0.5092642980450856, 0.45335284966686473, 1, 1, 1, 1, 0.8159836166158011,
0.6591436425925533, 0.5503587965303308, 0.4712849013925631, 0.411799420362759,
0.3658765762280885, 0.3297168199262522, 0.3008140171780779, 0.2774546951774029,
0.2584329395050784, 0.24288009578830796, 0.2301589349693962, 0.20927466101623401,
0.301892079643143, 0.28578141868876117, 0.27034033036004124, 0.2557647993595761,
0.24217347470531547, 0.22962509835226838, 0.21813440074299084, 0.2076854853402453,
0.19824247464150233, 0.1897576106476304, 0.18217719219220085, 0.17544577974732747,
0.1695090714571721, 0.16431579481716424, 0.15981889110210742
]
},
{
"layout": "low",
"position": [0, 0, 0.32],
"quaternion": [1.0, 0.0, 0.0, 0.0],
"distances": [
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, 1.0818076280603424, 1.0818076280603424, 0.9356174080521878, 0.9356174080521878,
1.0818076280603424, 1.0818076280603424, 1.0818076280603424, 1.0818076280603424,
1.0818076280603424, 1.0818076280603424, 1.0818076280603424, 1.0818076280603424,
0.9356174080521878, 0.9356174080521878, 1.0818076280603424, 1.0818076280603424,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452,
0.5232590180780452, 0.5232590180780452, 0.5232590180780452, 0.5232590180780452
],
"hitIds": [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 0, 1, 1, 0, 0, 0, 0, 0,
0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
"depth": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.2704519070150856, 0.2704519070150856,
0.23390435201304696, 0.23390435201304696, 0.2704519070150856, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.2704519070150856, 0.2704519070150856,
0.2704519070150856, 0.2704519070150856, 0.23390435201304696, 0.23390435201304696,
0.2704519070150856, 0.2704519070150856, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113, 0.1308147545195113, 0.1308147545195113,
0.1308147545195113, 0.1308147545195113
]
},
{
"layout": "low",
"position": [0, 0, 0.32],
"quaternion": [
0.9927681027239722, 0.09709860284347692, 0.054650688238454904, -0.04468397715907572
],
"distances": [
1.5463415120082626, 1.593685768444766, 1.6628142035077924, 1.7583500416114888,
1.8874730476566093, 2.06143867460523, 2.298467554987033, 2.6296404516571896,
3.112149423056716, 3.863359519926606, 5.167240524912454, -1.0, -1.0, -1.0, -1.0, -1.0,
0.62209731944295, 0.629163205894759, 0.5724502740761562, 0.5541151601654627,
0.567642029183309, 0.5840261942036087, 0.6035165215332615, 0.626413352751139,
0.6530744139420617, 0.6839211881333935, 0.7194452948914949, 0.7602140509721573,
0.8068737942201454, 0.8601486324595742, 1.0831765236086466, 1.1642551456388435,
0.3961557091780092, 0.39829918966870376, 0.4012471161917819, 0.40500178050690006,
0.4095651162997735, 0.4149379498546247, 0.4211190461681518, 0.4281039291887044,
0.4358834560016674, 0.44444212949443834, 0.4537561437057185, 0.4637911723119031,
0.4744999352392512, 0.48581961273489255, 0.4976692212346268, 0.5099471205068027
],
"hitIds": [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
],
"depth": [
0.38658537800206566, 0.3984214421111915, 0.4157035508769481, 0.4395875104028722,
0.4718682619141523, 0.5153596686513074, 0.5746168887467582, 0.6574101129142974,
0.778037355764179, 0.9658398799816516, 1, 1, 1, 1, 1, 1, 0.1555243298607375,
0.15729080147368976, 0.14311256851903906, 0.13852879004136567, 0.14191050729582724,
0.14600654855090217, 0.15087913038331538, 0.15660333818778474, 0.16326860348551542,
0.17098029703334838, 0.17986132372287372, 0.19005351274303933, 0.20171844855503634,
0.21503715811489355, 0.27079413090216164, 0.29106378640971087, 0.0990389272945023,
0.09957479741717594, 0.10031177904794547, 0.10125044512672501, 0.10239127907494337,
0.10373448746365617, 0.10527976154203796, 0.1070259822971761, 0.10897086400041685,
0.11111053237360959, 0.11343903592642962, 0.11594779307797577, 0.1186249838098128,
0.12145490318372314, 0.1244173053086567, 0.12748678012670067
]
},
{
"layout": "edge",
"position": [5.45, 0, 0.32],
"quaternion": [1.0, 0.0, 0.0, 0.0],
"distances": [
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0,
-1.0, -1.0, -1.0
],
"hitIds": [
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
-1, -1
],
"depth": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
]
}
]
}
@@ -0,0 +1,212 @@
{
"version": 1,
"taskId": "Unitree-Go2-ObstacleAvoidance",
"browserCompatible": true,
"observationSize": 81,
"actionSize": 12,
"controlHz": 50,
"gaitPeriod": 0.6,
"jointNames": [
"FL_hip_joint",
"FL_thigh_joint",
"FL_calf_joint",
"FR_hip_joint",
"FR_thigh_joint",
"FR_calf_joint",
"RL_hip_joint",
"RL_thigh_joint",
"RL_calf_joint",
"RR_hip_joint",
"RR_thigh_joint",
"RR_calf_joint"
],
"defaultJointPosition": [-0.1, 0.9, -1.8, 0.1, 0.9, -1.8, -0.1, 0.9, -1.8, 0.1, 0.9, -1.8],
"actionScale": [0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25],
"stiffness": [20, 20, 40, 20, 20, 40, 20, 20, 40, 20, 20, 40],
"damping": [1, 1, 2, 1, 1, 2, 1, 1, 2, 1, 1, 2],
"effortLimits": [23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45, 23.5, 23.5, 45],
"observationTerms": [
"base_ang_vel",
"projected_gravity",
"command",
"phase",
"joint_pos",
"joint_vel",
"actions",
"forward_depth",
"target_error"
],
"seed": 42,
"terrainPreset": "discrete_obstacles",
"terrainParams": {
"size": 12,
"obstacle_count": 24,
"obstacle_height_min": 0.2,
"obstacle_height_max": 0.6,
"spacing": 1.2,
"friction": 0.8,
"roughness": 0.06,
"step_height": 0.08,
"wave_amplitude": 0.08
},
"terrain": {
"representation": "boxes-v1",
"approximation": false,
"size": 12,
"friction": 0.8,
"boxes": [
{
"pos": [0, 0, -0.1],
"size": [6.0, 6.0, 0.1],
"yaw": 0
},
{
"pos": [0.666666666666667, 1.875, 0.261425654654876],
"size": [0.2, 0.2, 0.261425654654876],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 4.375, 0.24594635733876358],
"size": [0.2, 0.2, 0.24594635733876358],
"yaw": 0
},
{
"pos": [0.666666666666667, -4.375, 0.20724561829094013],
"size": [0.2, 0.2, 0.20724561829094013],
"yaw": 0
},
{
"pos": [-2.0, -1.875, 0.29462315279587414],
"size": [0.2, 0.2, 0.29462315279587414],
"yaw": 0
},
{
"pos": [-0.6666666666666665, 1.875, 0.17570687544167068],
"size": [0.2, 0.2, 0.17570687544167068],
"yaw": 0
},
{
"pos": [-2.0, -3.125, 0.2104081262546454],
"size": [0.2, 0.2, 0.2104081262546454],
"yaw": 0
},
{
"pos": [0.666666666666667, 3.125, 0.265880932850599],
"size": [0.2, 0.2, 0.265880932850599],
"yaw": 0
},
{
"pos": [2.0, 4.375, 0.2237039504728492],
"size": [0.2, 0.2, 0.2237039504728492],
"yaw": 0
},
{
"pos": [-2.0, 0.625, 0.27234138006215547],
"size": [0.2, 0.2, 0.27234138006215547],
"yaw": 0
},
{
"pos": [-3.3333333333333335, -0.625, 0.21547042905135239],
"size": [0.2, 0.2, 0.21547042905135239],
"yaw": 0
},
{
"pos": [2.0, -0.625, 0.24091436724298468],
"size": [0.2, 0.2, 0.24091436724298468],
"yaw": 0
},
{
"pos": [-3.3333333333333335, 0.625, 0.10916487673113245],
"size": [0.2, 0.2, 0.10916487673113245],
"yaw": 0
},
{
"pos": [3.333333333333333, -0.625, 0.14557965513030938],
"size": [0.2, 0.2, 0.14557965513030938],
"yaw": 0
},
{
"pos": [0.666666666666667, -1.875, 0.15787759272042143],
"size": [0.2, 0.2, 0.15787759272042143],
"yaw": 0
},
{
"pos": [-2.0, -0.625, 0.11595839538472551],
"size": [0.2, 0.2, 0.11595839538472551],
"yaw": 0
},
{
"pos": [2.0, -3.125, 0.14655817727220605],
"size": [0.2, 0.2, 0.14655817727220605],
"yaw": 0
},
{
"pos": [0.666666666666667, 0.625, 0.12020028588194583],
"size": [0.2, 0.2, 0.12020028588194583],
"yaw": 0
},
{
"pos": [3.333333333333333, -3.125, 0.15559472062201843],
"size": [0.2, 0.2, 0.15559472062201843],
"yaw": 0
},
{
"pos": [-0.6666666666666665, -1.875, 0.22713688885288003],
"size": [0.2, 0.2, 0.22713688885288003],
"yaw": 0
},
{
"pos": [0.666666666666667, 4.375, 0.17296643579401685],
"size": [0.2, 0.2, 0.17296643579401685],
"yaw": 0
},
{
"pos": [3.333333333333333, 3.125, 0.17403619342337653],
"size": [0.2, 0.2, 0.17403619342337653],
"yaw": 0
},
{
"pos": [2.0, -4.375, 0.14190140615429753],
"size": [0.2, 0.2, 0.14190140615429753],
"yaw": 0
},
{
"pos": [2.0, 0.625, 0.15339556440982266],
"size": [0.2, 0.2, 0.15339556440982266],
"yaw": 0
},
{
"pos": [0.666666666666667, -3.125, 0.2873309175424988],
"size": [0.2, 0.2, 0.2873309175424988],
"yaw": 0
}
],
"spawn": [-5.0, 0, 0.32],
"spawnQuaternion": [1, 0, 0, 0],
"target": [5.0, 0],
"actualObstacleCount": 24
},
"sensorCfg": {
"fov": 90,
"maxDistance": 4,
"safetyDistance": 0.5,
"avoidanceWeight": 2,
"type": "raycast",
"rayCount": 32,
"offset": [0.3, 0, 0.05],
"alignment": "base",
"terrainOnly": true,
"includeGround": true
},
"navigation": {
"speed": 0.6,
"arrivalRadius": 0.5,
"distanceScale": 12,
"headingScale": 3.141592653589793,
"yawGain": 1.0,
"maxYawRate": 1.0,
"episodeSeconds": 20,
"onArrival": "stop",
"onReset": "respawn"
}
}
@@ -0,0 +1,90 @@
{
"source": "CPU MuJoCo 3.5.0 mj_ray; task_config seed42 discrete_obstacles; distances normalized by4",
"cases": [
{
"position": [-5, 0, 0.32],
"quaternion": [1, 0, 0, 0],
"depth": [
1, 1, 0.8064353265688073, 0.7754070525042633, 0.3493131891686196, 0.33844992227063814,
0.32906116691883763, 0.3209809620250947, 0.31407497396875733, 0.3285019206818616,
0.3862286490933719, 1, 1, 1, 1, 1, 1, 1, 1, 0.634959321852795, 0.6416072675585701,
0.650082300931107, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
]
},
{
"position": [-2, 1, 0.32],
"quaternion": [0.9689124217106447, 0, 0.24740395925452294, 0],
"depth": [
0.16227742543146714, 0.154643351828059, 0.1480582588705743, 0.1423615934562481,
0.13742675650547637, 0.1331529312359637, 0.12945920813695258, 0.12628029481538672,
0.12356334175298786, 0.12126556878831947, 0.11935247679185605, 0.11779649499930897,
0.11657595910035341, 0.11567434601043744, 0.1150797130480744, 0.1147843050751654,
0.1147843050751654, 0.1150797130480744, 0.11567434601043744, 0.11657595910035341,
0.11779649499930897, 0.11935247679185605, 0.12126556878831947, 0.12356334175298787,
0.12628029481538672, 0.12945920813695258, 0.1331529312359637, 0.13742675650547637,
0.14236159345624813, 0.14805825887057428, 0.154643351828059, 0.16227742543146714
]
},
{
"position": [0, -1, 0.32],
"quaternion": [0.955336489125606, 0.29552020666133955, 0, 0],
"depth": [
0.2262087980617112, 0.23859992736340305, 0.2531146297321152, 0.27024831583146797,
0.2906703480610164, 0.31530672670613974, 0.34547502041327477, 0.3831145628697801,
0.431200828086753, 0.49454232783018703, 0.5814468749554897, 0.707609556942636,
0.9066658372041383, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
]
},
{
"position": [2, 2, 0.32],
"quaternion": [0.8253356149096783, 0, 0, 0.5646424733950354],
"depth": [
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0.47425413995403354,
0.4738672755338355, 0.4746985930390542, 0.47675881629202255, 0.48007471719272266, 1, 1, 1,
1, 1
]
}
],
"boxEdges": [
{
"origin": [0, 0, 0],
"direction": [1, 0, 0],
"distance": 1.0
},
{
"origin": [1, 0, 0],
"direction": [1, 0, 0],
"distance": 0.0
},
{
"origin": [1, 0, 0],
"direction": [-1, 0, 0],
"distance": -0.0
},
{
"origin": [1, 0, 0],
"direction": [0, 1, 0],
"distance": 1.0
},
{
"origin": [2, 0, 0],
"direction": [0, 1, 0],
"distance": -1.0
},
{
"origin": [2, 0, 0],
"direction": [-1, 0, 0],
"distance": 1.0
},
{
"origin": [1, 1, 0],
"direction": [0, 0, 1],
"distance": 1.0
},
{
"origin": [2, 2, 0],
"direction": [1, 0, 0],
"distance": -1.0
}
]
}
@@ -0,0 +1,55 @@
import { expect, it, vi } from 'vitest';
import type { MjData, MjModel } from '@mujoco/mujoco';
import fixture from '../fixtures/obstacleDeployment.json';
import { validatePolicyDeployment } from '../deployment';
vi.mock('./Go2wPolicyBindings', () => ({
Go2wPolicyBindings: class {
baseBodyId = 0;
constructor(
public model: MjModel,
public data: MjData,
) {}
clear() {}
observe(
_time: number,
_last: Float32Array,
command: { linearX: number; linearY: number; angularZ: number },
) {
const obs = new Float32Array(47);
obs.set([command.linearX, command.linearY, command.angularZ], 6);
return obs;
}
},
}));
import { Go2ObstacleAvoidanceBindings } from './Go2ObstacleAvoidanceBindings';
it('浏览器连续导航超过20秒仍观察;换点改变command/目标误差,跌倒越界仍明确停止', () => {
const original = validatePolicyDeployment(fixture);
const deployment = { ...original, terrain: { ...original.terrain!, boxes: [] } };
const data = {
time: 0,
xpos: new Float64Array([0, 0, 0.32]),
xquat: new Float64Array([1, 0, 0, 0]),
} as unknown as MjData;
const bindings = new Go2ObstacleAvoidanceBindings(
{ ngeom: 0 } as MjModel,
data,
vi.fn(),
deployment,
);
bindings.setNavigationTarget([3, 0]);
const before = bindings.observe(21, new Float32Array(12));
bindings.setNavigationTarget([0, 3]);
const after = bindings.observe(22, new Float32Array(12));
expect(before[6]).toBeGreaterThan(0);
expect(after[6]).toBeCloseTo(0);
expect(after[8]).toBe(1);
expect(after[79]).not.toBe(before[79]);
data.xpos[2] = 0.1;
expect(() => bindings.observe(23, new Float32Array(12))).toThrow(/跌倒\/越界/);
bindings.setNavigationTarget([2, 0]);
expect(() => bindings.observe(24, new Float32Array(12))).toThrow(/安全停止/);
data.xpos[2] = 0.32;
data.xpos[0] = 100;
expect(() => bindings.observe(25, new Float32Array(12))).toThrow(/安全停止/);
});
@@ -0,0 +1,165 @@
import type { MjModel, MjData } from '@mujoco/mujoco';
import type { NavigationStatus } from '../types';
import type { PolicyDeployment } from '../deployment';
import {
buildGo2ObstacleAvoidanceObservation,
obstacleNavigation,
rayBoxDistance,
sampleForwardRays,
forwardRayDirections,
type PerceptionRay,
} from '../tasks/go2ObstacleAvoidance';
import { Go2wPolicyBindings } from './Go2wPolicyBindings';
/** Only named boxes from the compiled training scene are sensed: never robot visual/collision geoms. */
export class Go2ObstacleAvoidanceBindings extends Go2wPolicyBindings {
private readonly terrainIds: number[] = [];
private readonly boxes: {
center: number[];
half: number[];
rotation: number[];
axisAligned: boolean;
}[] = [];
private readonly directions: number[][];
private startTime: number;
private readonly defaultTarget: [number, number];
private currentTarget: [number, number];
rays: PerceptionRay[] = [];
constructor(
model: MjModel,
data: MjData,
setActuator: (id: number, value: number) => void,
private readonly deployment: PolicyDeployment,
) {
super(model, data, setActuator, deployment.effortLimits);
if (!deployment.terrain || !deployment.sensorCfg)
throw new Error('避障策略缺少地图/传感器配置');
this.directions = forwardRayDirections(deployment.sensorCfg);
this.defaultTarget = [deployment.terrain.target[0], deployment.terrain.target[1]];
this.currentTarget = [...this.defaultTarget];
this.startTime = Number(data.time);
for (let id = 0; id < model.ngeom; id++) {
const geom = model.geom(id);
try {
if (!geom.name.startsWith('__training_terrain_')) continue;
if (
Number(model.geom_type[id]) !== 6 ||
Number(model.geom_bodyid[id]) !== 0 ||
Number(model.geom_group[id]) !== 2
)
throw new Error('训练地图必须是静态group2 box');
this.terrainIds.push(id);
this.boxes.push({
center: Array.from(data.geom_xpos.subarray(id * 3, id * 3 + 3), Number),
half: Array.from(model.geom_size.subarray(id * 3, id * 3 + 3), Number),
rotation: Array.from(data.geom_xmat.subarray(id * 9, id * 9 + 9), Number),
axisAligned: Array.from(data.geom_xmat.subarray(id * 9, id * 9 + 9), Number).every(
(v, i) => v === (i % 4 === 0 ? 1 : 0),
),
});
} finally {
geom.delete();
}
}
if (this.terrainIds.length !== deployment.terrain.boxes.length)
throw new Error('请先加载策略配套训练地图');
}
setNavigationTarget(target: [number, number]): void {
if (target.length !== 2 || !target.every(Number.isFinite))
throw new Error('导航目标必须是两个有限坐标');
const limit = this.deployment.terrain!.size / 2 - 0.5;
this.currentTarget = target.map((v) => Math.max(-limit, Math.min(limit, v))) as [
number,
number,
];
}
resetNavigationTarget(): void {
this.currentTarget = [...this.defaultTarget];
}
navigationStatus(): NavigationStatus {
const [x, y] = this.currentTarget;
let height = 0;
for (const id of this.terrainIds) {
const distance = rayBoxDistance(
[x, y, 10000],
[0, 0, -1],
this.data.geom_xpos.subarray(id * 3, id * 3 + 3),
this.model.geom_size.subarray(id * 3, id * 3 + 3),
this.data.geom_xmat.subarray(id * 9, id * 9 + 9),
);
if (distance >= 0) height = Math.max(height, 10000 - distance);
}
return {
target: [...this.currentTarget],
defaultTarget: [...this.defaultTarget],
targetHeight: height,
distance: Math.hypot(
x - Number(this.data.xpos[this.baseBodyId * 3]),
y - Number(this.data.xpos[this.baseBodyId * 3 + 1]),
),
};
}
reset(time: number) {
this.resetNavigationTarget();
this.startTime = time;
this.rays = [];
}
override clear() {
super.clear();
this.rays = [];
}
override observe(time: number, lastAction: Float32Array): Float32Array {
const terrain = this.deployment.terrain!,
sensor = this.deployment.sensorCfg!;
const position = Array.from(
this.data.xpos.subarray(this.baseBodyId * 3, this.baseBodyId * 3 + 3),
Number,
);
const quaternion = Array.from(
this.data.xquat.subarray(this.baseBodyId * 4, this.baseBodyId * 4 + 4),
Number,
);
// Interactive navigation has no episode deadline. Training/evaluation retain 20s.
// Safety failures still require an explicit reset/re-enable, never a new target.
if (
position[2] < 0.12 ||
Math.abs(position[0]) > terrain.size / 2 - 0.3 ||
Math.abs(position[1]) > terrain.size / 2 - 0.3
)
throw new Error('导航安全停止(跌倒/越界),请重置仿真后重新启用');
const navigation = obstacleNavigation(
position,
quaternion,
this.currentTarget,
terrain.size,
this.deployment.navigation?.speed,
);
const sample = sampleForwardRays(
position,
quaternion,
sensor,
(origin, direction) => {
let nearest = -1;
for (const box of this.boxes) {
const distance = rayBoxDistance(
origin,
direction,
box.center,
box.half,
box.rotation,
box.axisAligned,
);
if (distance >= 0 && (nearest < 0 || distance < nearest)) nearest = distance;
}
return nearest;
},
this.directions,
);
this.rays = sample.rays;
return buildGo2ObstacleAvoidanceObservation(
super.observe(time - this.startTime, lastAction, navigation.command),
sample.depth,
navigation.targetError,
);
}
}
@@ -27,15 +27,16 @@ function rotateInverse(
/** 将 mjlab Go2 velocity 的 47 维 actor 观测和 12 维关节位置动作映射到 MuJoCo。 */
export class Go2wPolicyBindings implements PolicyRuntimeBindings {
private readonly joints: BoundJoint[];
private readonly baseBodyId: number;
protected readonly baseBodyId: number;
private readonly baseFreeJointId: number;
private readonly gyroSensorId?: number;
private readonly wheelActuatorIds: number[];
constructor(
private readonly model: MjModel,
private readonly data: MjData,
protected readonly model: MjModel,
protected readonly data: MjData,
private readonly setActuator: (id: number, value: number) => void,
private readonly effortLimits?: readonly number[],
) {
const jointIds = new Map<string, number>(),
actuatorIds = new Map<string, number>(),
@@ -199,7 +200,11 @@ export class Go2wPolicyBindings implements PolicyRuntimeBindings {
GO2W_VELOCITY_TASK.damping[index] * Number(this.data.qvel[item.qvelAddress]);
this.setActuator(
item.actuatorId,
item.positionActuator ? target : torque / item.controlScale,
item.positionActuator
? target
: (this.effortLimits
? Math.max(-this.effortLimits[index], Math.min(this.effortLimits[index], torque))
: torque) / item.controlScale,
);
}
for (const id of this.wheelActuatorIds) this.setActuator(id, 0);
@@ -0,0 +1,217 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import fixture from '../fixtures/obstacleDeployment.json';
import { validatePolicyDeployment } from '../deployment';
const mock = vi.hoisted(() => ({
create: vi.fn(),
tensors: [] as { dispose: ReturnType<typeof vi.fn> }[],
}));
vi.mock('onnxruntime-web/wasm', () => ({
env: { wasm: {} },
InferenceSession: { create: mock.create },
Tensor: class {
dispose = vi.fn();
constructor(
public type: string,
public data: Float32Array,
public dims: number[],
) {
mock.tensors.push(this);
}
},
}));
import { OnnxPolicyRuntime } from './OnnxPolicyRuntime';
const deployment = validatePolicyDeployment(fixture);
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
function setup(size = 81) {
const session = {
inputNames: ['obs'],
outputNames: ['actions'],
inputMetadata: [{ isTensor: true, type: 'float32', shape: [1, size] }],
outputMetadata: [{ isTensor: true, type: 'float32', shape: [1, 12] }],
run: vi.fn(),
release: vi.fn().mockResolvedValue(undefined),
};
const bindings = {
observe: vi.fn(() => new Float32Array(size)),
apply: vi.fn(),
clear: vi.fn(),
reset: vi.fn(),
};
mock.create.mockResolvedValue(session);
return { session, bindings };
}
beforeEach(() => {
vi.clearAllMocks();
mock.tensors.length = 0;
});
describe('OnnxPolicyRuntime held-action', () => {
it('动态目标转发不重建ORT、不启用策略或打断in-flight,status实时读取', async () => {
const { session, bindings } = setup();
let finish!: (outputs: Record<string, unknown>) => void;
session.run.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const navigation = {
target: [1, 2] as [number, number],
defaultTarget: [5, 0] as [number, number],
distance: 3,
targetHeight: 0,
};
const targetBindings = {
...bindings,
navigationStatus: () => navigation,
setNavigationTarget: vi.fn(),
resetNavigationTarget: vi.fn(),
};
const runtime = await OnnxPolicyRuntime.load(
new Uint8Array(),
'goal.onnx',
targetBindings,
deployment,
);
runtime.setNavigationTarget([2, 3]);
expect(targetBindings.setNavigationTarget).toHaveBeenCalledWith([2, 3]);
expect(runtime.status().enabled).toBe(false);
expect(runtime.status().navigation).toBe(navigation);
runtime.setEnabled(true, 0);
runtime.step(0);
runtime.setNavigationTarget([3, 4]);
runtime.step(0.02);
expect(session.run).toHaveBeenCalledOnce();
expect(mock.create).toHaveBeenCalledOnce();
runtime.resetNavigationTarget();
expect(targetBindings.resetNavigationTarget).toHaveBeenCalledOnce();
finish({ actions: { type: 'float32', data: new Float32Array(12), dispose: vi.fn() } });
await flush();
runtime.dispose();
expect(runtime.status().navigation).toBeUndefined();
});
it('81维契约单in-flight,持有最新动作,不阻塞物理步;reset拒绝旧promise', async () => {
const { session, bindings } = setup();
let finish!: (outputs: Record<string, unknown>) => void;
session.run.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const runtime = await OnnxPolicyRuntime.load(
new Uint8Array([1]),
'policy.onnx',
bindings,
deployment,
);
runtime.setEnabled(true, 0);
runtime.step(0);
runtime.step(0.02);
runtime.step(0.04);
expect(session.run).toHaveBeenCalledOnce();
expect(bindings.apply).toHaveBeenCalledTimes(3);
const output = { type: 'float32', data: new Float32Array(12).fill(0.3), dispose: vi.fn() };
finish({ actions: output });
await flush();
runtime.step(0.06);
expect(bindings.apply.mock.lastCall?.[0][0]).toBeCloseTo(0.3);
expect(session.run).toHaveBeenCalledTimes(2);
expect(output.dispose).toHaveBeenCalledOnce();
runtime.reset(0);
finish({ actions: { ...output, data: new Float32Array(12).fill(0.9) } });
await flush();
runtime.step(0);
expect(bindings.apply.mock.lastCall?.[0][0]).toBe(0);
expect(bindings.reset).toHaveBeenCalledWith(0);
runtime.dispose();
expect(session.release).not.toHaveBeenCalled();
finish({ actions: output });
await flush();
expect(session.release).toHaveBeenCalledOnce();
expect(mock.tensors.every((t) => t.dispose.mock.calls.length === 1)).toBe(true);
});
it('81策略不能默认为47、Rough维度不能加载,失败释放session', async () => {
const { session, bindings } = setup();
await expect(OnnxPolicyRuntime.load(new Uint8Array(), 'wrong.onnx', bindings)).rejects.toThrow(
/维度/,
);
expect(session.release).toHaveBeenCalledOnce();
session.inputMetadata[0].shape = [1, 234];
await expect(
OnnxPolicyRuntime.load(new Uint8Array(), 'rough.onnx', bindings, deployment),
).rejects.toThrow(/维度/);
});
it('原47维Flat保持向后兼容,非法观测/输出失败关闭并清动作', async () => {
const { session, bindings } = setup(47);
session.run.mockResolvedValue({
actions: { type: 'float32', data: new Float32Array(12).fill(NaN), dispose: vi.fn() },
});
const runtime = await OnnxPolicyRuntime.load(new Uint8Array(), 'flat.onnx', bindings);
expect(runtime.status().observationSize).toBe(47);
runtime.setEnabled(true, 0);
runtime.step(0);
await flush();
expect(runtime.status().enabled).toBe(false);
expect(runtime.status().error).toMatch(/非有限/);
runtime.reset(0);
bindings.observe.mockReturnValue(new Float32Array(81));
runtime.setEnabled(true, 0);
runtime.step(0);
expect(runtime.status().error).toMatch(/维度/);
runtime.dispose();
await flush();
});
});
it('默认Flat作业的兼容契约仍检查真实graph,拒绝81维及动态特征', async () => {
const flat = validatePolicyDeployment({
...fixture,
taskId: 'Unitree-Go2-Flat',
observationSize: 47,
observationTerms: fixture.observationTerms.slice(0, 7),
terrain: undefined,
terrainPreset: undefined,
terrainParams: undefined,
sensorCfg: undefined,
navigation: undefined,
});
const { session, bindings } = setup(47);
const runtime = await OnnxPolicyRuntime.load(new Uint8Array(), 'legacy.onnx', bindings, flat);
expect(runtime.status().observationSize).toBe(47);
runtime.dispose();
await flush();
session.inputMetadata[0].shape = [1, 81];
await expect(
OnnxPolicyRuntime.load(new Uint8Array(), 'legacy-wrong.onnx', bindings, flat),
).rejects.toThrow(/维度/);
session.inputMetadata[0].shape = [1, -1];
await expect(
OnnxPolicyRuntime.load(new Uint8Array(), 'legacy-dynamic.onnx', bindings, flat),
).rejects.toThrow(/固定/);
});
it('97 metadata不能冒充81 graph;真实97 shape沿用single-flight生命周期', async () => {
const multi = validatePolicyDeployment({
...fixture,
observationSize: 97,
sensorCfg: { ...fixture.sensorCfg, sensorMode: 'multi_ring_raycast', rayCount: 48 },
});
const { session, bindings } = setup(81);
await expect(
OnnxPolicyRuntime.load(new Uint8Array([1]), 'multi.onnx', bindings, multi),
).rejects.toThrow(/维度/);
expect(session.release).toHaveBeenCalledOnce();
const next = setup(97);
const runtime = await OnnxPolicyRuntime.load(
new Uint8Array([1]),
'multi.onnx',
next.bindings,
multi,
);
expect(runtime.status().observationSize).toBe(97);
runtime.dispose();
await flush();
expect(next.session.release).toHaveBeenCalledOnce();
});
@@ -1,6 +1,8 @@
import * as ort from 'onnxruntime-web/wasm';
import { GO2W_VELOCITY_TASK, clampGo2wCommand } from '../tasks/go2wVelocity';
import type { RLCommand, RLPolicyStatus } from '../types';
import type { PolicyDeployment } from '../deployment';
import { GO2_OBSTACLE_AVOIDANCE_TASK } from '../tasks/go2ObstacleAvoidance';
import type { NavigationStatus, RLCommand, RLPolicyStatus } from '../types';
ort.env.wasm.numThreads = 1;
ort.env.wasm.proxy = false;
@@ -9,6 +11,10 @@ export interface PolicyRuntimeBindings {
observe(time: number, lastAction: Float32Array, command: RLCommand): Float32Array;
apply(action: Float32Array): void;
clear(): void;
reset?(time: number): void;
navigationStatus?(): NavigationStatus;
setNavigationTarget?(target: [number, number]): void;
resetNavigationTarget?(): void;
}
function message(error: unknown): string {
@@ -38,13 +44,25 @@ export class OnnxPolicyRuntime {
private readonly path: string,
private readonly inputName: string,
private readonly outputName: string,
private readonly task: {
id: string;
name: string;
observationSize: number;
actionSize: number;
controlHz: number;
},
) {}
static async load(
model: Uint8Array,
path: string,
bindings: PolicyRuntimeBindings,
deployment?: PolicyDeployment,
): Promise<OnnxPolicyRuntime> {
const task =
deployment?.taskId === GO2_OBSTACLE_AVOIDANCE_TASK.id
? { ...GO2_OBSTACLE_AVOIDANCE_TASK, observationSize: deployment.observationSize }
: GO2W_VELOCITY_TASK;
const session = await ort.InferenceSession.create(model.slice(), {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
@@ -71,28 +89,19 @@ export class OnnxPolicyRuntime {
throw new Error(`策略输入 batch 必须为 1 或动态维度,实际为 ${inputBatch}`);
if (typeof outputBatch === 'number' && outputBatch !== -1 && outputBatch !== 1)
throw new Error(`策略输出 batch 必须为 1 或动态维度,实际为 ${outputBatch}`);
if (
typeof fixedInput === 'number' &&
fixedInput > 0 &&
fixedInput !== GO2W_VELOCITY_TASK.observationSize
)
throw new Error(
`策略观测维度不匹配:模型 ${fixedInput},任务 ${GO2W_VELOCITY_TASK.observationSize}`,
);
if (
typeof fixedOutput === 'number' &&
fixedOutput > 0 &&
fixedOutput !== GO2W_VELOCITY_TASK.actionSize
)
throw new Error(
`策略动作维度不匹配:模型 ${fixedOutput},任务 ${GO2W_VELOCITY_TASK.actionSize}`,
);
if (typeof fixedInput === 'number' && fixedInput > 0 && fixedInput !== task.observationSize)
throw new Error(`策略观测维度不匹配:模型 ${fixedInput},任务 ${task.observationSize}`);
if (typeof fixedOutput === 'number' && fixedOutput > 0 && fixedOutput !== task.actionSize)
throw new Error(`策略动作维度不匹配:模型 ${fixedOutput},任务 ${task.actionSize}`);
if (deployment && (fixedInput !== task.observationSize || fixedOutput !== task.actionSize))
throw new Error('部署策略必须声明固定的观测/动作特征维度');
return new OnnxPolicyRuntime(
session,
bindings,
path,
session.inputNames[0],
session.outputNames[0],
task,
);
} catch (error) {
await session.release();
@@ -102,22 +111,32 @@ export class OnnxPolicyRuntime {
status(): RLPolicyStatus {
return {
taskId: GO2W_VELOCITY_TASK.id,
taskName: GO2W_VELOCITY_TASK.name,
taskId: this.task.id,
taskName: this.task.name,
path: this.path,
loaded: !this.disposed,
enabled: this.enabled,
controlHz: GO2W_VELOCITY_TASK.controlHz,
observationSize: GO2W_VELOCITY_TASK.observationSize,
actionSize: GO2W_VELOCITY_TASK.actionSize,
controlHz: this.task.controlHz,
observationSize: this.task.observationSize,
actionSize: this.task.actionSize,
inputName: this.inputName,
outputName: this.outputName,
command: { ...this.commandValue },
inferenceCount: this.inferenceCount,
lastInferenceMs: this.lastInferenceMs,
navigation: this.navigationStatus(),
error: this.error,
};
}
navigationStatus(): NavigationStatus | undefined {
return this.disposed ? undefined : this.bindings.navigationStatus?.();
}
setNavigationTarget(target: [number, number]): void {
if (!this.disposed) this.bindings.setNavigationTarget?.(target);
}
resetNavigationTarget(): void {
if (!this.disposed) this.bindings.resetNavigationTarget?.();
}
setCommand(command: RLCommand): void {
this.commandValue = clampGo2wCommand(command);
}
@@ -138,6 +157,7 @@ export class OnnxPolicyRuntime {
this.nextInferenceTime = time;
this.error = undefined;
this.bindings.clear();
this.bindings.reset?.(time);
}
step(time: number): void {
@@ -147,12 +167,14 @@ export class OnnxPolicyRuntime {
let observation: Float32Array;
try {
observation = this.bindings.observe(time, this.action, this.commandValue);
if (observation.length !== this.task.observationSize || !observation.every(Number.isFinite))
throw new Error('策略观测维度错误或包含非有限数');
} catch (error) {
this.fail(error);
return;
}
this.inFlight = true;
this.nextInferenceTime = time + 1 / GO2W_VELOCITY_TASK.controlHz;
this.nextInferenceTime = time + 1 / this.task.controlHz;
const started = performance.now(),
epoch = this.epoch;
const input = new ort.Tensor('float32', observation, [1, observation.length]);
@@ -163,9 +185,9 @@ export class OnnxPolicyRuntime {
const output = outputs[this.outputName];
if (!output || output.type !== 'float32')
throw new Error(`找不到 float32 输出:${this.outputName}`);
if (output.data.length !== GO2W_VELOCITY_TASK.actionSize)
if (output.data.length !== this.task.actionSize)
throw new Error(
`策略动作维度错误:期望 ${GO2W_VELOCITY_TASK.actionSize},实际 ${output.data.length}`,
`策略动作维度错误:期望 ${this.task.actionSize},实际 ${output.data.length}`,
);
const next = Float32Array.from(output.data as Float32Array, Number);
for (const value of next)
@@ -0,0 +1,114 @@
import { describe, it, expect } from 'vitest';
import deploymentJSON from '../fixtures/obstacleDeployment.json';
import golden from '../fixtures/obstacleRayGolden.json';
import { validatePolicyDeployment } from '../deployment';
import {
buildGo2ObstacleAvoidanceObservation,
obstacleNavigation,
rayBoxDistance,
sampleForwardRays,
} from './go2ObstacleAvoidance';
const deployment = validatePolicyDeployment(deploymentJSON);
const identity = [1, 0, 0, 0, 1, 0, 0, 0, 1];
describe('go2ObstacleAvoidance', () => {
it('严格47+32+2顺序及有限值、归一化范围校验', () => {
const base = Array.from({ length: 47 }, (_, i) => i),
depth = Array(32).fill(0.75);
const result = buildGo2ObstacleAvoidanceObservation(base, depth, [0.25, 0.5]);
expect(result).toBeInstanceOf(Float32Array);
expect(result).toHaveLength(81);
expect(Array.from(result.slice(0, 47))).toEqual(base);
expect(Array.from(result.slice(47, 79))).toEqual(depth);
expect(Array.from(result.slice(79))).toEqual([0.25, 0.5]);
expect(() => buildGo2ObstacleAvoidanceObservation([], depth, [0, 0])).toThrow(/维度/);
expect(() => buildGo2ObstacleAvoidanceObservation(base, depth, [NaN, 0])).toThrow(/非有限/);
expect(() =>
buildGo2ObstacleAvoidanceObservation(base, Array(32).fill(Infinity), [0, 0]),
).toThrow(/非有限/);
expect(() => buildGo2ObstacleAvoidanceObservation(base, Array(32).fill(1.1), [0, 0])).toThrow(
/0~1/,
);
});
it('目标前向、身后、yaw旋转、到达停止及离开恢复与后端公式一致', () => {
expect(obstacleNavigation([-5, 0, 0.32], [1, 0, 0, 0], [5, 0], 12)).toEqual({
command: { linearX: 0.6, linearY: 0, angularZ: 0 },
targetError: [0, 10 / 12],
});
expect(obstacleNavigation([5, 0, 0.32], [1, 0, 0, 0], [-5, 0], 12).command).toEqual({
linearX: 0,
linearY: 0,
angularZ: 1,
});
expect(obstacleNavigation([4.8, 0, 0.32], [0, 0, 0, 1], [5, 0], 12).command.linearX).toBe(0);
expect(obstacleNavigation([4.4, 0, 0.32], [1, 0, 0, 0], [5, 0], 12).command.linearX).toBe(0.6);
expect(
obstacleNavigation([0, 0, 0], [Math.SQRT1_2, 0, 0, Math.SQRT1_2], [0, 2], 12).targetError[0],
).toBeCloseTo(0);
});
it('动态换目标维持81维末尾归一化误差,到达后恢复转向', () => {
const position = [0, 0, 0.32],
quaternion = [1, 0, 0, 0];
expect(obstacleNavigation(position, quaternion, [0, 0], 12).command.angularZ).toBe(0);
for (const [target, heading] of [
[[0, 3], 0.5],
[[0, -3], -0.5],
] as const) {
const navigation = obstacleNavigation(position, quaternion, target, 12);
const observation = buildGo2ObstacleAvoidanceObservation(
Array(47).fill(0),
Array(32).fill(1),
navigation.targetError,
);
expect(observation).toHaveLength(81);
expect(Array.from(observation.slice(79))).toEqual([heading, 0.25]);
expect(navigation.command.angularZ).toBe(Math.sign(heading));
}
expect(obstacleNavigation(position, quaternion, [24, 0], 12).targetError[1]).toBe(1);
});
it('全部32射线与CPU mj_ray golden对照:完整yaw/pitch/roll且包含地面命中', () => {
for (const fixture of golden.cases) {
const result = sampleForwardRays(
fixture.position,
fixture.quaternion,
deployment.sensorCfg!,
(origin, direction) => {
const hits = deployment
.terrain!.boxes.map((box) =>
rayBoxDistance(origin, direction, box.pos, box.size, identity),
)
.filter((d) => d >= 0);
return hits.length ? Math.min(...hits) : -1;
},
);
result.depth.forEach((d, i) => expect(d).toBeCloseTo(fixture.depth[i], 6));
expect(result.rays).toHaveLength(32);
}
});
it('box内部/表面/平行/边缘/miss与CPU mj_ray一致', () => {
golden.boxEdges.forEach((item) =>
expect(
rayBoxDistance(item.origin, item.direction, [0, 0, 0], [1, 1, 1], identity),
).toBeCloseTo(item.distance, 8),
);
});
it('按右到左含端点采样,maxRange等号视为命中,越界/miss为1', () => {
let count = 0;
const sample = sampleForwardRays(
[0, 0, 0],
[1, 0, 0, 0],
deployment.sensorCfg!,
() => [4, 4.01, -1, 0][count++ % 4],
);
expect(sample.depth.slice(0, 4)).toEqual([1, 1, 1, 0]);
expect(sample.rays.slice(0, 4).map((r) => r.hit)).toEqual([true, false, false, true]);
expect(sample.rays[0].end[1]).toBeLessThan(0);
expect(sample.rays[30].end[1]).toBeGreaterThan(0);
});
});
it('使用部署中的target_velocity命令幅值,不改变81维目标误差或到达停机', () => {
const result = obstacleNavigation([0, 0, 0.32], [1, 0, 0, 0], [5, 0], 12, 0.9);
expect(result.command.linearX).toBe(0.9);
expect(result.targetError).toEqual([0, 5 / 12]);
expect(obstacleNavigation([4.8, 0, 0.32], [1, 0, 0, 0], [5, 0], 12, 1.2).command.linearX).toBe(0);
});
@@ -0,0 +1,160 @@
import { GO2W_VELOCITY_TASK } from './go2wVelocity';
import { OBSTACLE_TASK_ID, obstacleSensorPattern, type ObstacleSensorConfig } from '../deployment';
import type { RLCommand } from '../types';
export const GO2_OBSTACLE_AVOIDANCE_TASK = {
...GO2W_VELOCITY_TASK,
id: OBSTACLE_TASK_ID,
name: 'Go2 前视射线避障导航',
observationSize: 81,
};
export interface PerceptionRay {
origin: number[];
end: number[];
hit: boolean;
}
export function rotateVector(q: readonly number[], v: readonly number[]): number[] {
const [w, x, y, z] = q,
[vx, vy, vz] = v;
const tx = 2 * (y * vz - z * vy),
ty = 2 * (z * vx - x * vz),
tz = 2 * (x * vy - y * vx);
return [
vx + w * tx + y * tz - z * ty,
vy + w * ty + z * tx - x * tz,
vz + w * tz + x * ty - y * tx,
];
}
export function obstacleNavigation(
position: readonly number[],
quaternion: readonly number[],
target: readonly number[],
size: number,
speed = 0.6,
): { command: RLCommand; targetError: number[] } {
const [w, x, y, z] = quaternion;
const yaw = Math.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z));
const dx = target[0] - position[0],
dy = target[1] - position[1],
distance = Math.hypot(dx, dy);
const delta = Math.atan2(dy, dx) - yaw;
const heading = distance < 0.5 ? 0 : Math.atan2(Math.sin(delta), Math.cos(delta));
return {
command:
distance < 0.5
? { linearX: 0, linearY: 0, angularZ: 0 }
: {
linearX: speed * Math.max(0, Math.cos(heading)),
linearY: 0,
angularZ: Math.max(-1, Math.min(1, heading)),
},
targetError: [heading / Math.PI, Math.min(1, distance / size)],
};
}
/** Slab intersection with a physical box in its local frame. Inside hits the exit, as mj_ray does. */
export function rayBoxDistance(
origin: readonly number[],
direction: readonly number[],
center: ArrayLike<number>,
halfSize: ArrayLike<number>,
rotation: ArrayLike<number>,
axisAligned = false,
): number {
if (axisAligned) {
let near = -Infinity,
far = Infinity;
for (let i = 0; i < 3; i++) {
const o = origin[i] - center[i],
d = direction[i],
h = halfSize[i];
if (Math.abs(d) < 1e-12) {
if (Math.abs(o) > h) return -1;
continue;
}
let a = (-h - o) / d,
b = (h - o) / d;
if (a > b) {
const t = a;
a = b;
b = t;
}
if (a > near) near = a;
if (b < far) far = b;
if (near > far || far < 0) return -1;
}
return near >= 0 ? near : far;
}
const x = origin[0] - center[0],
y = origin[1] - center[1],
z = origin[2] - center[2];
let near = -Infinity,
far = Infinity;
for (let i = 0; i < 3; i++) {
const o = rotation[i] * x + rotation[3 + i] * y + rotation[6 + i] * z;
const d =
rotation[i] * direction[0] + rotation[3 + i] * direction[1] + rotation[6 + i] * direction[2];
if (Math.abs(d) < 1e-12) {
if (Math.abs(o) > halfSize[i]) return -1;
continue;
}
const a = (-halfSize[i] - o) / d,
b = (halfSize[i] - o) / d;
near = Math.max(near, Math.min(a, b));
far = Math.min(far, Math.max(a, b));
if (near > far || far < 0) return -1;
}
return near >= 0 ? near : far;
}
/** Per-binding directions are precomputed, avoiding trig and temporary arrays per ray/box. */
export function forwardRayDirections(sensor: ObstacleSensorConfig): number[][] {
const pattern = obstacleSensorPattern(sensor.sensorMode, sensor.fov);
return pattern.pitchAngles.flatMap((pitch) =>
pattern.yawAngles.map((yaw) => {
const p = (pitch * Math.PI) / 180,
y = (yaw * Math.PI) / 180;
return [Math.cos(p) * Math.cos(y), Math.cos(p) * Math.sin(y), Math.sin(p)];
}),
);
}
export function sampleForwardRays(
position: readonly number[],
quaternion: readonly number[],
sensor: ObstacleSensorConfig,
cast: (origin: number[], direction: number[]) => number,
localDirections = forwardRayDirections(sensor),
): { depth: number[]; rays: PerceptionRay[] } {
const offset = rotateVector(quaternion, sensor.offset),
origin = position.map((v, i) => v + offset[i]);
const rays: PerceptionRay[] = [],
depth: number[] = [];
for (const local of localDirections) {
const direction = rotateVector(quaternion, local);
const distance = cast(origin, direction);
if (!Number.isFinite(distance)) throw new Error('物理射线返回非有限距离');
const hit = distance >= 0 && distance <= sensor.maxDistance;
const length = hit ? distance : sensor.maxDistance;
depth.push(Math.max(0, Math.min(1, length / sensor.maxDistance)));
rays.push({ origin, end: direction.map((v, axis) => origin[axis] + v * length), hit });
}
return { depth, rays };
}
export function buildGo2ObstacleAvoidanceObservation(
base: ArrayLike<number>,
depth: ArrayLike<number>,
targetError: ArrayLike<number>,
): Float32Array {
if (
base.length !== 47 ||
(depth.length !== 32 && depth.length !== 48) ||
targetError.length !== 2
)
throw new Error('避障观测维度必须为47+(32或48)+2');
const result = Float32Array.from([
...Array.from(base),
...Array.from(depth),
...Array.from(targetError),
]);
if (!result.every(Number.isFinite)) throw new Error('避障观测包含非有限数');
if (Array.from(depth).some((v) => v < 0 || v > 1)) throw new Error('归一化深度必须在0~1之间');
return result;
}
@@ -0,0 +1,90 @@
import { expect, it } from 'vitest';
import legacy from '../fixtures/obstacleDeployment.json';
import fixture from '../fixtures/multiRingDeployment.json';
import golden from '../fixtures/multiRingGolden.json';
import { policyDeploymentsMatch, validatePolicyDeployment } from '../deployment';
import {
buildGo2ObstacleAvoidanceObservation,
forwardRayDirections,
rayBoxDistance,
sampleForwardRays,
} from './go2ObstacleAvoidance';
const deployment = validatePolicyDeployment(fixture);
const rotation = [1, 0, 0, 0, 1, 0, 0, 0, 1];
it('legacy缺字段与规范化metadata语义相等,multi拒绝矛盾及未知组合', () => {
expect(
policyDeploymentsMatch(
legacy as unknown as typeof deployment,
validatePolicyDeployment(legacy),
),
).toBe(true);
expect(deployment.observationSize).toBe(97);
for (const patch of [
{ rayCount: 32 },
{ sensorMode: 'unknown' },
{ pitchAngles: [0, -45, -20] },
{ yawCount: 32 },
{ yawAngles: Array(16).fill(0) },
{ angleUnit: 'rad' },
{ rayOrder: 'yaw-major' },
{ garbage: 1 },
])
expect(() =>
validatePolicyDeployment({ ...fixture, sensorCfg: { ...fixture.sensorCfg, ...patch } }),
).toThrow();
expect(() => validatePolicyDeployment({ ...fixture, observationSize: 81 })).toThrow(/观测维数/);
expect(() =>
validatePolicyDeployment({
...fixture,
sensorCfg: { ...fixture.sensorCfg, sensorMode: undefined },
}),
).toThrow();
});
it('所有48ray CPU mj_ray golden:完整姿态、5cm障碍、坑边/地板终点、层序', () => {
for (const c of golden.cases) {
const boxes = golden.layouts[c.layout as keyof typeof golden.layouts];
const sample = sampleForwardRays(c.position, c.quaternion, deployment.sensorCfg!, (o, d) => {
let nearest = -1;
for (const b of boxes) {
const t = rayBoxDistance(o, d, b.pos, b.size, rotation);
if (t >= 0 && (nearest < 0 || t < nearest)) nearest = t;
}
return nearest;
});
expect(sample.rays).toHaveLength(48);
c.depth.forEach((d, i) => expect(sample.depth[i]).toBeCloseTo(d, 6));
const obs = buildGo2ObstacleAvoidanceObservation(
Array(47).fill(0.2),
sample.depth,
[-0.5, 0.3],
);
expect(obs).toHaveLength(97);
expect(obs[95]).toBe(-0.5);
expect(obs[96]).toBeCloseTo(0.3);
}
const low = golden.cases.find((c) => c.layout === 'low')!;
expect(low.hitIds.slice(0, 16)).not.toContain(1);
expect(low.hitIds.slice(16)).toContain(1);
const rays = forwardRayDirections(deployment.sensorCfg!);
for (const [layer, pitch] of [0, -20, -45].entries()) {
expect(rays[layer * 16][2]).toBeCloseTo(Math.sin((pitch * Math.PI) / 180));
expect(rays[layer * 16][1]).toBeLessThan(0);
expect(rays[layer * 16 + 15][1]).toBeGreaterThan(0);
}
});
it('48ray完整range边界、miss、有限值,floor实际距离不隐藏', () => {
let i = 0;
const result = sampleForwardRays(
[0, 0, 0.32],
[1, 0, 0, 0],
deployment.sensorCfg!,
() => [-1, 0, 4, 4 + 1e-9][i++ % 4],
);
expect(result.depth).toEqual(Array.from({ length: 48 }, (_, j) => [1, 0, 1, 1][j % 4]));
expect(result.rays.map((r) => r.hit)).toEqual(
Array.from({ length: 48 }, (_, j) => [false, true, true, false][j % 4]),
);
expect(() =>
sampleForwardRays([0, 0, 0.32], [1, 0, 0, 0], deployment.sensorCfg!, () => NaN),
).toThrow();
});
@@ -0,0 +1,60 @@
/** Reproducible full-frame benchmark, not a single-ray microbenchmark. See TRAINING.md. */
import { forwardRayDirections, rayBoxDistance, sampleForwardRays } from './go2ObstacleAvoidance';
import { validatePolicyDeployment } from '../deployment';
import fixture from '../fixtures/multiRingDeployment.json';
export function benchmarkForwardRays(warmup = 3000, samples = 10000) {
const deployment = validatePolicyDeployment(fixture),
sensor = deployment.sensorCfg!;
const directions = forwardRayDirections(sensor);
const rotation = [1, 0, 0, 0, 1, 0, 0, 0, 1];
const defaultBoxes = deployment.terrain!.boxes;
// 16x16 grid + floor, same maximal box count as rough/wave deployment.
const worstBoxes = [
defaultBoxes[0],
...Array.from({ length: 256 }, (_, i) => ({
pos: [-3.75 + Math.floor(i / 16) * 0.5, -5.625 + (i % 16) * 0.75, 0.1],
size: [0.25, 0.375, 0.1],
yaw: 0,
})),
];
return [defaultBoxes, worstBoxes].map((boxes) => {
const cast = (o: number[], d: number[]) => {
let nearest = -1;
for (const box of boxes) {
const t = rayBoxDistance(o, d, box.pos, box.size, rotation, true);
if (t >= 0 && (nearest < 0 || t < nearest)) nearest = t;
}
return nearest;
};
let checksum = 0;
const frame = (i: number) => {
// Change pose each frame: no memoized results. Full attitude, origin, all intersections + output.
const yaw = (i % 100) * 0.004,
q = [
Math.cos(yaw / 2) * Math.cos(0.1),
Math.sin(0.1) * Math.cos(yaw / 2),
Math.sin(0.1) * Math.sin(yaw / 2),
Math.sin(yaw / 2) * Math.cos(0.1),
];
const r = sampleForwardRays([-5 + (i % 10) * 0.05, 0, 0.32], q, sensor, cast, directions);
checksum += r.depth[i % 48];
};
for (let i = 0; i < warmup; i++) frame(i);
const times = [];
for (let i = 0; i < samples; i++) {
const start = performance.now();
frame(i);
times.push(performance.now() - start);
}
times.sort((a, b) => a - b);
return {
boxes: boxes.length,
rays: 48,
warmup,
samples,
p50Ms: times[Math.floor(samples * 0.5)],
p95Ms: times[Math.floor(samples * 0.95)],
checksum,
};
});
}
+9 -1
View File
@@ -4,8 +4,15 @@ export interface RLCommand {
angularZ: number;
}
export interface NavigationStatus {
target: [number, number];
defaultTarget: [number, number];
distance: number;
targetHeight: number;
}
export interface RLPolicyStatus {
taskId: 'unitree-go2w-velocity';
taskId: string;
taskName: string;
path: string;
loaded: boolean;
@@ -18,6 +25,7 @@ export interface RLPolicyStatus {
command: RLCommand;
inferenceCount: number;
lastInferenceMs: number;
navigation?: NavigationStatus;
error?: string;
}
@@ -0,0 +1,259 @@
import { readFileSync } from 'node:fs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ProjectManifest } from '../project/types';
import fixture from '../rl/fixtures/obstacleDeployment.json';
import { validatePolicyDeployment } from '../rl/deployment';
const ort = vi.hoisted(() => ({ create: vi.fn() }));
vi.mock('onnxruntime-web/wasm', () => ({
env: { wasm: {} },
InferenceSession: { create: ort.create },
}));
vi.mock('@mujoco/mujoco', async (original) => {
const actual = await original<typeof import('@mujoco/mujoco')>();
return {
...actual,
default: () =>
actual.default({ wasmBinary: readFileSync('node_modules/@mujoco/mujoco/mujoco.wasm') }),
};
});
import { MainThreadPhysicsAdapter } from './PhysicsAdapter';
const deployment = validatePolicyDeployment(fixture);
function graph(size = 47) {
return {
inputNames: ['obs'],
outputNames: ['actions'],
inputMetadata: [{ isTensor: true, type: 'float32', shape: [1, size] }],
outputMetadata: [{ isTensor: true, type: 'float32', shape: [1, 12] }],
release: vi.fn().mockResolvedValue(undefined),
};
}
function project(): ProjectManifest {
const doc = new DOMParser().parseFromString(
readFileSync('training_server/rl/src/assets/robots/unitree_go2/xmls/go2.xml', 'utf8'),
'application/xml',
);
doc.querySelectorAll('mesh, geom[mesh]').forEach((e) => e.remove());
const actuators = doc.createElement('actuator');
for (const name of deployment.jointNames) {
const motor = doc.createElement('motor');
motor.setAttribute('joint', name);
motor.setAttribute('name', `${name}_motor`);
actuators.append(motor);
}
doc.documentElement.append(actuators);
const data = new TextEncoder().encode(new XMLSerializer().serializeToString(doc));
return {
id: 'transaction',
name: 'go2',
files: [{ path: 'go2.xml', data, size: data.length, source: 'file', mimeType: 'text/xml' }],
entries: [{ path: 'go2.xml', format: 'mjcf', label: 'go2' }],
maps: [],
selectedEntry: 'go2.xml',
totalBytes: data.length,
};
}
async function existing() {
const adapter = new MainThreadPhysicsAdapter(),
source = project(),
oldGraph = graph();
await adapter.load(source, 'go2.xml');
ort.create.mockResolvedValueOnce(oldGraph);
await adapter.loadRLPolicy(new Uint8Array([1]), 'old-flat.onnx');
adapter.setRLPolicyEnabled(true);
const old = adapter.session!;
old.data.time = 7;
old.data.qvel[0] = 0.123;
old.data.qpos[0] = 1.25;
old.data.ctrl[0] = 0.3;
adapter.setPaused(false);
return { adapter, source, old, oldGraph, before: adapter.snapshot()! };
}
beforeEach(() => {
vi.clearAllMocks();
ort.create.mockReset();
});
describe('PhysicsAdapter training transaction', () => {
it.each(['wrong graph', 'ORT initialization', 'binding'] as const)(
'%s失败保留原session/workspace/策略及物理状态',
async (failure) => {
const { adapter, source, old, oldGraph, before } = await existing();
const workspace = adapter.workspace,
dispose = vi.spyOn(old, 'dispose');
const candidate = graph(47);
if (failure === 'ORT initialization')
ort.create.mockRejectedValueOnce(new Error('ORT init failed'));
else ort.create.mockResolvedValueOnce(candidate);
if (failure === 'binding') {
const text = new TextDecoder()
.decode(source.files[0].data)
.replace('name="FL_hip_joint_motor"', 'name="incompatible_name"');
source.files[0].data = new TextEncoder().encode(text);
}
try {
await expect(
adapter.load(source, 'go2.xml', {
trainingDeployment: deployment,
trainingPolicy: { data: new Uint8Array([2]), path: 'candidate.onnx' },
}),
).rejects.toThrow(
failure === 'wrong graph' ? /维度/ : failure === 'binding' ? /驱动器/ : /ORT init/,
);
expect(adapter.session).toBe(old);
expect(adapter.workspace).toBe(workspace);
expect(adapter.snapshot()).toEqual(before);
expect(dispose).not.toHaveBeenCalled();
expect(oldGraph.release).not.toHaveBeenCalled();
if (failure === 'wrong graph') expect(candidate.release).toHaveBeenCalledOnce();
} finally {
adapter.dispose();
}
},
);
it('完成ORT初始化前不发布候选,成功后仍保留旧资源以供viewer失败回滚', async () => {
const { adapter, source, old, oldGraph, before } = await existing();
let finish!: (value: ReturnType<typeof graph>) => void;
let started!: () => void;
const initialized = new Promise<void>((resolve) => {
started = resolve;
});
ort.create.mockImplementationOnce(() => {
started();
return new Promise((resolve) => {
finish = resolve;
});
});
const candidate = graph(81);
try {
const loading = adapter.load(source, 'go2.xml', {
trainingDeployment: deployment,
trainingPolicy: { data: new Uint8Array([2]), path: 'new.onnx' },
});
await initialized;
expect(adapter.session).toBe(old);
expect(oldGraph.release).not.toHaveBeenCalled();
finish(candidate);
await loading;
expect(adapter.session).not.toBe(old);
expect(adapter.snapshot()?.rlPolicy).toMatchObject({
path: 'new.onnx',
observationSize: 81,
enabled: true,
});
expect(oldGraph.release).not.toHaveBeenCalled();
adapter.rollbackRetired();
expect(adapter.session).toBe(old);
expect(adapter.snapshot()).toEqual(before);
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(candidate.release).toHaveBeenCalledOnce();
expect(oldGraph.release).not.toHaveBeenCalled();
} finally {
adapter.dispose();
}
});
});
it('候选ORT等待期间另一场景完成加载,迟到候选不得覆盖最新场景', async () => {
const { adapter, source } = await existing();
let finish!: (value: ReturnType<typeof graph>) => void;
let started!: () => void;
const initialized = new Promise<void>((resolve) => {
started = resolve;
});
ort.create.mockImplementationOnce(() => {
started();
return new Promise((resolve) => {
finish = resolve;
});
});
const candidate = graph(81);
try {
const stale = adapter.load(source, 'go2.xml', {
trainingDeployment: deployment,
trainingPolicy: { data: new Uint8Array([2]), path: 'stale.onnx' },
});
await initialized;
await adapter.load(source, 'go2.xml');
const latest = adapter.session,
before = adapter.snapshot();
const rejected = expect(stale).rejects.toThrow(/取消/);
finish(candidate);
await rejected;
expect(adapter.session).toBe(latest);
expect(adapter.snapshot()).toEqual(before);
for (let i = 0; i < 10; i++) await Promise.resolve();
expect(candidate.release).toHaveBeenCalledOnce();
} finally {
adapter.dispose();
}
});
describe('PhysicsAdapter custom_boxes applied scene compiler', () => {
it('真实WASM读取工程路径/嵌套body变换/旋转多实例;与共享CPU布局halfsize和原点一致', async () => {
const { default: shared } =
await import('../../../training_server/tests/fixtures/custom-boxes.json');
const source = project();
const descriptor = {
schemaVersion: 1,
id: 'warehouse',
name: '仓库',
coordinateSystem: { units: 'm', up: 'Z', forward: '+X' },
physics: { source: 'physics/world.xml' },
spawnPoints: [],
};
for (const [path, text] of [
['maps/warehouse/map.json', JSON.stringify(descriptor)],
[
'maps/warehouse/physics/world.xml',
'<mujoco><worldbody><body name="nested" pos="0 1 .5"><geom name="wall" type="box" size=".4 .3 .5" friction=".8 .005 .0001"/><geom name="decoration" type="sphere" size="10" contype="0" conaffinity="0"/></body></worldbody></mujoco>',
],
]) {
const data = new TextEncoder().encode(text);
source.files.push({ path, data, size: data.length, source: 'file', mimeType: 'text/xml' });
}
const assets = [
{
id: 'one',
name: 'one',
selection: {
kind: 'project' as const,
descriptorPath: 'maps/warehouse/map.json',
positionX: 1,
positionY: 1,
},
},
];
const adapter = new MainThreadPhysicsAdapter();
try {
await adapter.load(source, 'go2.xml', { mapAssets: assets });
const initial = adapter.exportTrainingTerrain(assets, { spawn: [-2, -1], target: [2, -1] });
// Geometry from actual WASM matches the JSON consumed by CPU TerrainGenerator tests.
expect(initial.boxes[1]).toEqual(shared.boxes[1]);
expect(initial.actualObstacleCount).toBe(1);
adapter.session!.data.qpos[0] = 8;
expect(adapter.exportTrainingTerrain(assets).spawn[0]).not.toBe(8);
expect(() => adapter.exportTrainingTerrain([])).toThrow(/过时/);
const multi = [
...assets,
{
id: 'two',
name: 'two',
selection: { ...assets[0].selection, positionX: -2, positionY: 1, yawDeg: 45 },
},
];
await adapter.load(source, 'go2.xml', { mapAssets: multi });
const layout = adapter.exportTrainingTerrain(multi, { spawn: [-2, -1], target: [2, -1] });
expect(layout.boxes).toHaveLength(3);
expect(layout.boxes[2].pos[0]).toBeCloseTo(-2 - Math.SQRT1_2, 12);
expect(layout.boxes[2].pos[1]).toBeCloseTo(1 + Math.SQRT1_2, 12);
expect(layout.boxes[2].size[0]).toBeCloseTo(0.7 * Math.SQRT1_2, 12);
expect(layout.boxes[2].size[1]).toBeCloseTo(0.7 * Math.SQRT1_2, 12);
adapter.rollbackRetired();
expect(adapter.exportTrainingTerrain(assets).actualObstacleCount).toBe(1);
// A deployment replacement cannot pretend to be the original applied editor map.
await adapter.load(source, 'go2.xml', { mapAssets: assets, trainingDeployment: deployment });
expect(() => adapter.exportTrainingTerrain(assets)).toThrow(/已应用/);
} finally {
adapter.dispose();
}
});
});
+146 -4
View File
@@ -1,3 +1,10 @@
import {
composeTrainingMap,
trainingTerrainFromCompiledScene,
type CompiledMapGeometry,
type TrainingSceneCoordinates,
} from '../map/trainingMap';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
import type { MainModule } from '@mujoco/mujoco';
import type { ProjectFile, ProjectManifest } from '../project/types';
import { prepareProjectForMujoco } from '../project/importer';
@@ -36,6 +43,9 @@ export interface PhysicsLoadProgress {
}
export interface PhysicsLoadOptions {
trainingDeployment?: PolicyDeployment;
/** Candidate policy is initialized/validated before replacing the active session. */
trainingPolicy?: { data: Uint8Array; path: string };
urdfMode?: UrdfLoadMode;
baseMode?: UrdfBaseMode;
enhancements?: UrdfEnhancementOptions;
@@ -68,9 +78,15 @@ export interface PhysicsAdapter {
setControllerEnabled(enabled: boolean): void;
sendControllerCommand(command: ControllerCommand): void;
removeController(): void;
loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus>;
loadRLPolicy(
model: Uint8Array,
path: string,
deployment?: PolicyDeployment,
): Promise<RLPolicyStatus>;
setRLPolicyEnabled(enabled: boolean): void;
setRLCommand(command: RLCommand): void;
setNavigationTarget(target: [number, number]): void;
resetNavigationTarget(): void;
removeRLPolicy(): void;
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus | undefined;
startDataRecording(): DataRecorderStatus | undefined;
@@ -82,6 +98,10 @@ export interface PhysicsAdapter {
releaseRetired(): void;
rollbackRetired(): void;
exportMjcf(): Uint8Array;
exportTrainingTerrain(
assets: readonly PlacedMapAsset[],
coordinates?: TrainingSceneCoordinates,
): TrainingTerrain;
dispose(): void;
}
@@ -108,6 +128,15 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
session: SimulationSession | null = null;
workspace: MemfsWorkspace | null = null;
private supportFiles: ProjectFile[] = [];
private trainingScenes = new WeakMap<
SimulationSession,
{
assets: string;
geometries: CompiledMapGeometry[];
initialPose: number[];
extent: number;
}
>();
private loadGeneration = 0;
private disposed = false;
private retiredSession: SimulationSession | null = null;
@@ -196,7 +225,7 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
intermediate.dispose();
}
}
if (hasMaps) {
if (hasMaps && !options.trainingDeployment?.terrain) {
report(0.72, '组合机器人与物理地图');
if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('原生 URDF 模式暂不支持地图,请切换为转换模式');
@@ -232,13 +261,102 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
modelRelativePath = mapPath;
modelPath = workspace.path(modelRelativePath);
}
if (options.trainingDeployment?.terrain) {
if (entry?.format === 'urdf' && urdfMode === 'native')
throw new Error('训练地图需要URDF转换模式');
const intermediate = new SimulationSession(module, modelPath);
try {
const flatPath = `${modelRelativePath}.training.xml`;
if (!module.mj_saveLastXML(workspace.path(flatPath), intermediate.model))
throw new Error('无法展开训练场景');
workspace.writeGenerated(
flatPath,
composeTrainingMap(
new TextEncoder().encode(workspace.readText(flatPath)),
options.trainingDeployment,
),
);
modelPath = workspace.path(flatPath);
} finally {
intermediate.dispose();
}
warnings.push('已用策略配套训练布局替换场景地形;编辑器地图未更改。Go2-W动力学不等同Go2。');
}
report(0.84, '编译模型与物理数据');
console.info('[MuJoCo] 编译模型', modelPath);
nextSession = new SimulationSession(module, modelPath, warnings);
if (options.trainingDeployment) nextSession.configureDeployment(options.trainingDeployment);
if (entry?.format === 'urdf' && urdfMode === 'native') {
const offset = nextSession.alignLowestPointToGround();
warnings.push(`原生 URDF 已整体平移 ${offset.toFixed(4)} m,使最低点位于 z=0`);
}
if (placedMapAssets?.length && !options.trainingDeployment) {
const sanitize = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, '_');
const prefixes = placedMapAssets.map((asset) =>
asset.selection.kind === 'builtin'
? `__platform_map_${sanitize(asset.id)}__`
: `__platform_map_${sanitize(`${resolveProjectMap(prepared.manifest, asset.selection.descriptorPath).definition.id}_${asset.id}`)}_`,
);
const model = nextSession.model,
data = nextSession.data;
const geometries: CompiledMapGeometry[] = [];
for (let id = 0; id < model.ngeom; id++) {
const geom = model.geom(id);
try {
const name = geom.name;
geometries.push({
name,
type: Number(model.geom_type[id]),
position: Array.from(data.geom_xpos.slice(id * 3, id * 3 + 3)),
rotation: Array.from(data.geom_xmat.slice(id * 9, id * 9 + 9)),
size: Array.from(model.geom_size.slice(id * 3, id * 3 + 3)),
friction: Array.from(model.geom_friction.slice(id * 3, id * 3 + 3)),
collision: Boolean(model.geom_contype[id] || model.geom_conaffinity[id]),
static: Number(model.body_weldid[Number(model.geom_bodyid[id])]) === 0,
map:
prefixes.some((prefix) => name.startsWith(prefix)) ||
name === '__platform_map_ground__',
});
} finally {
geom.delete();
}
}
const roots = Array.from({ length: model.njnt }, (_, id) => id).filter(
(id) => Number(model.jnt_type[id]) === 0,
);
if (roots.length === 1) {
const address = Number(model.jnt_qposadr[roots[0]]);
this.trainingScenes.set(nextSession, {
assets: JSON.stringify(placedMapAssets),
geometries,
initialPose: Array.from(data.qpos.slice(address, address + 7)),
extent: Math.max(
4,
...placedMapAssets.map((asset) =>
asset.selection.kind === 'builtin'
? Math.max(
Math.abs(asset.selection.config.positionX),
Math.abs(asset.selection.config.positionY),
) +
asset.selection.config.size / 2
: 0,
),
),
});
}
}
if (options.trainingPolicy) {
if (!options.trainingDeployment?.terrain) throw new Error('事务策略加载需要配套训练地图');
report(0.9, '校验候选场景的 ONNX 策略');
await nextSession.loadRLPolicy(
options.trainingPolicy.data,
options.trainingPolicy.path,
options.trainingDeployment,
);
// Configure/reset already supplied the correct spawn. Enable while still paused;
// the candidate cannot advance until the viewer transaction commits.
nextSession.setRLPolicyEnabled(true);
}
if (warnings.length) console.info('[MuJoCo] 兼容与地图处理', warnings);
console.info('[MuJoCo] 模型编译完成');
report(0.94, '生成初始仿真状态');
@@ -263,6 +381,20 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
);
}
}
exportTrainingTerrain(
assets: readonly PlacedMapAsset[],
coordinates?: TrainingSceneCoordinates,
): TrainingTerrain {
const scene = this.session && this.trainingScenes.get(this.session);
if (!scene || scene.assets !== JSON.stringify(assets))
throw new Error('没有匹配的已应用碰撞场景/唯一浮动机器人,或场景已过时;请先应用地图');
return trainingTerrainFromCompiledScene(
scene.geometries,
scene.initialPose,
scene.extent,
coordinates,
);
}
advance(now: number): FrameResult {
return this.session?.advance(now) ?? { steps: 0, stepMs: 0, overBudget: false };
}
@@ -315,13 +447,23 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
removeController(): void {
this.session?.removeController();
}
async loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus> {
async loadRLPolicy(
model: Uint8Array,
path: string,
deployment?: PolicyDeployment,
): Promise<RLPolicyStatus> {
if (!this.session) throw new Error('请先加载模型');
return this.session.loadRLPolicy(model, path);
return this.session.loadRLPolicy(model, path, deployment);
}
setRLPolicyEnabled(enabled: boolean): void {
this.session?.setRLPolicyEnabled(enabled);
}
setNavigationTarget(target: [number, number]): void {
this.session?.setNavigationTarget(target);
}
resetNavigationTarget(): void {
this.session?.resetNavigationTarget();
}
setRLCommand(command: RLCommand): void {
this.session?.setRLCommand(command);
}
@@ -0,0 +1,71 @@
import { describe, it, expect, vi } from 'vitest';
const pending = vi.hoisted(() => ({
resolvers: [] as (() => void)[],
runtimes: [] as { dispose: ReturnType<typeof vi.fn>; status: ReturnType<typeof vi.fn> }[],
}));
vi.mock('../rl/runtime/Go2wPolicyBindings', () => ({
Go2wPolicyBindings: class {
constructor(
_model: unknown,
_data: unknown,
private write: (id: number, value: number) => void,
) {}
clear() {
this.write(0, 0);
}
},
}));
vi.mock('../rl/runtime/OnnxPolicyRuntime', () => ({
OnnxPolicyRuntime: {
load: (_model: unknown, _path: string, bindings: { clear(): void }) =>
new Promise((resolve) => {
const runtime = {
dispose: vi.fn(() => bindings.clear()),
status: vi.fn(() => ({ loaded: true })),
};
pending.runtimes.push(runtime);
pending.resolvers.push(() => resolve(runtime));
}),
},
}));
import { SimulationSession } from './SimulationSession';
async function flush() {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
function session(): SimulationSession {
return Object.assign(Object.create(SimulationSession.prototype) as object, {
model: {},
data: { ctrl: new Float64Array(1) },
rlPolicyLoadGeneration: 0,
disposed: false,
setActuator: vi.fn(),
}) as unknown as SimulationSession;
}
describe('SimulationSession policy loading race', () => {
it('模型释放后迟到ORT session必须release,不能写已释放MjData', async () => {
pending.resolvers.length = pending.runtimes.length = 0;
const s = session();
const load = s.loadRLPolicy(new Uint8Array(), 'late.onnx');
await flush();
Object.assign(s, { disposed: true });
pending.resolvers[0]();
await expect(load).rejects.toThrow(/取消/);
expect(pending.runtimes[0].dispose).toHaveBeenCalledOnce();
expect(s.setActuator).not.toHaveBeenCalled();
});
it('同一模型两次加载只接受最新一份,旧clear不能清新策略动作', async () => {
pending.resolvers.length = pending.runtimes.length = 0;
const s = session();
const first = s.loadRLPolicy(new Uint8Array(), 'first.onnx');
await flush();
const second = s.loadRLPolicy(new Uint8Array(), 'second.onnx');
await flush();
pending.resolvers[1]();
await expect(second).resolves.toMatchObject({ loaded: true });
pending.resolvers[0]();
await expect(first).rejects.toThrow(/取消/);
expect(pending.runtimes[0].dispose).toHaveBeenCalledOnce();
expect(pending.runtimes[1].dispose).not.toHaveBeenCalled();
expect(s.setActuator).not.toHaveBeenCalled();
});
});
@@ -1,3 +1,10 @@
import { Go2ObstacleAvoidanceBindings } from '../rl/runtime/Go2ObstacleAvoidanceBindings';
import {
OBSTACLE_TASK_ID,
policyDeploymentsMatch,
validatePolicyDeployment,
type PolicyDeployment,
} from '../rl/deployment';
import type { MainModule, MjData, MjModel, MjvPerturb, MjvScene } from '@mujoco/mujoco';
import { meshIdFromSceneDataId } from './geometry';
import { PythonControllerRuntime } from '../controller/PythonControllerRuntime';
@@ -106,6 +113,9 @@ export class SimulationSession {
private pythonController?: PythonControllerRuntime;
private controllerLoadGeneration = 0;
private rlPolicy?: OnnxPolicyRuntime;
private obstacleBindings?: Go2ObstacleAvoidanceBindings;
private deployment?: PolicyDeployment;
private deploymentInitialQpos?: Float64Array;
private rlPolicyLoadGeneration = 0;
private dataRecorder!: DataRecorder;
@@ -189,6 +199,7 @@ export class SimulationSession {
reset(): void {
this.setPaused(true);
this.module.mj_resetData(this.model, this.data);
if (this.deploymentInitialQpos) this.data.qpos.set(this.deploymentInitialQpos);
this.module.mj_forward(this.model, this.data);
this.clearExternalForce();
this.data.ctrl.fill(0);
@@ -253,20 +264,132 @@ export class SimulationSession {
if (!enabled) this.data.ctrl.fill(0);
}
async loadRLPolicy(model: Uint8Array, path: string): Promise<RLPolicyStatus> {
navigationStatus() {
return this.rlPolicy?.navigationStatus();
}
setNavigationTarget(target: [number, number]): void {
this.rlPolicy?.setNavigationTarget(target);
}
resetNavigationTarget(): void {
this.rlPolicy?.resetNavigationTarget();
}
perceptionRays() {
return this.obstacleBindings?.rays ?? [];
}
configureDeployment(input: PolicyDeployment): void {
const d = validatePolicyDeployment(input);
if (!d.terrain) return;
const seen = new Set<number>();
for (let id = 0; id < this.model.ngeom; id++) {
const geom = this.model.geom(id);
try {
if (Number(this.model.geom_bodyid[id]) !== 0) {
if (geom.name.startsWith('__training_terrain_'))
throw new Error('机器人几何不能使用训练地图保留名称');
continue;
}
const match = /^__training_terrain_(\d+)$/.exec(geom.name);
const index = match ? Number(match[1]) : -1;
const box = d.terrain.boxes[index];
if (
!box ||
seen.has(index) ||
Number(this.model.geom_type[id]) !== 6 ||
Number(this.model.geom_group[id]) !== 2
)
throw new Error('配套地图只支持声明的静态box,不允许额外plane或其他形状');
seen.add(index);
for (let axis = 0; axis < 3; axis++) {
if (
Math.abs(Number(this.data.geom_xpos[id * 3 + axis]) - box.pos[axis]) > 1e-6 ||
Math.abs(Number(this.model.geom_size[id * 3 + axis]) - box.size[axis]) > 1e-6
)
throw new Error('编译后的训练地图坐标/半尺寸不匹配');
}
} finally {
geom.delete();
}
}
if (seen.size !== d.terrain.boxes.length) throw new Error('配套训练地图缺少box');
// Construction validates robot topology before changing the initial pose.
new Go2wPolicyBindings(this.model, this.data, (id, value) => this.setActuator(id, value));
const initialQpos = Float64Array.from(this.model.qpos0);
let freeCount = 0;
for (let id = 0; id < this.model.njnt; id++) {
const joint = this.model.jnt(id);
try {
const address = Number(joint.qposadr);
if (Number(this.model.jnt_type[id]) === 0) {
freeCount++;
initialQpos.set([...d.terrain.spawn, ...d.terrain.spawnQuaternion], address);
} else {
const index = d.jointNames.indexOf(joint.name);
if (index >= 0) initialQpos[address] = d.defaultJointPosition[index];
}
} finally {
joint.delete();
}
}
if (freeCount !== 1) throw new Error('训练评测需要唯一浮动基座');
for (let id = 0; id < this.model.nactuator; id++) {
const actuator = this.model.actuator(id);
try {
const jointId = Number(actuator.trnid[0]);
if (jointId < 0 || jointId >= this.model.njnt) continue;
const joint = this.model.jnt(jointId);
try {
const index = d.jointNames.indexOf(joint.name);
if (index >= 0) {
actuator.forcelimited = 1;
actuator.forcerange[0] = -d.effortLimits[index];
actuator.forcerange[1] = d.effortLimits[index];
}
} finally {
joint.delete();
}
} finally {
actuator.delete();
}
}
this.deployment = d;
// qpos0 contains hinge reference angles, not the desired joint pose. Do not mutate it.
this.deploymentInitialQpos = initialQpos;
this.reset();
}
async loadRLPolicy(
model: Uint8Array,
path: string,
deployment?: PolicyDeployment,
): Promise<RLPolicyStatus> {
const generation = ++this.rlPolicyLoadGeneration;
const bindings = new Go2wPolicyBindings(this.model, this.data, (id, value) =>
this.setActuator(id, value),
);
if (deployment) deployment = validatePolicyDeployment(deployment);
if (
deployment?.terrain &&
(!this.deployment || !policyDeploymentsMatch(deployment, this.deployment))
)
throw new Error('策略配套地图尚未加载或配置不匹配');
let cancelled = false;
const writeActuator = (id: number, value: number) => {
if (!this.disposed && !cancelled) this.setActuator(id, value);
};
const bindings =
deployment?.taskId === OBSTACLE_TASK_ID
? new Go2ObstacleAvoidanceBindings(this.model, this.data, writeActuator, deployment)
: new Go2wPolicyBindings(this.model, this.data, writeActuator, deployment?.effortLimits);
const { OnnxPolicyRuntime: Runtime } = await import('../rl/runtime/OnnxPolicyRuntime');
const runtime = await Runtime.load(model, path, bindings);
const runtime = await Runtime.load(model, path, bindings, deployment);
if (this.disposed || generation !== this.rlPolicyLoadGeneration) {
cancelled = true;
runtime.dispose();
throw new Error('模型已切换,ONNX 策略加载已取消');
}
this.data.ctrl.fill(0);
this.rlPolicy?.dispose();
this.rlPolicy = runtime;
this.obstacleBindings = bindings instanceof Go2ObstacleAvoidanceBindings ? bindings : undefined;
return runtime.status();
}
@@ -306,6 +429,7 @@ export class SimulationSession {
this.rlPolicyLoadGeneration += 1;
this.rlPolicy?.dispose();
this.rlPolicy = undefined;
this.obstacleBindings = undefined;
this.data.ctrl.fill(0);
}
@@ -148,3 +148,66 @@ describe('LocalTrainingClient', () => {
expect(() => new LocalTrainingClient('http://localhost:8765', '')).toThrow('访问令牌');
});
});
it('LocalTrainingClient完整透传自定义任务与嵌套地形/传感器参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ id: 'job' }));
vi.stubGlobal('fetch', fetchMock);
const request = {
taskId: 'Unitree-Go2-ObstacleAvoidance',
numEnvs: 16,
maxIterations: 1,
seed: 42,
runName: 'avoid',
device: 'gpu' as const,
gpuIds: [0],
wandbMode: 'disabled' as const,
terrainPreset: 'discrete_obstacles',
terrainParams: { friction: 0.9, obstacle_count: 12 },
sensorType: 'raycast' as const,
sensorCfg: { fov: 60, maxDistance: 3 },
};
await new LocalTrainingClient('http://127.0.0.1:8765', 'test').start(request);
expect(JSON.parse(String(fetchMock.mock.calls[0][1].body))).toEqual(request);
});
it('上传使用File原始body、显式模板、Bearer及AbortSignal,不发送JSON/base64/服务器路径', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'source' }), { status: 201 }));
vi.stubGlobal('fetch', fetchMock);
const file = new File(['weights'], '单个 actor.ONNX');
const controller = new AbortController();
await new LocalTrainingClient('http://localhost:8765', 'secret-token').uploadPretrained(
file,
'go2-legacy47-v1',
controller.signal,
);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
const query = new URL(url).searchParams;
expect(query.get('format')).toBe('onnx');
expect(query.get('template')).toBe('go2-legacy47-v1');
expect(query.get('name')).toBe(file.name);
expect(url).not.toContain('secret-token');
expect(init.body).toBe(file);
expect(init.signal).toBe(controller.signal);
expect(new Headers(init.headers).get('Content-Type')).toBe('application/octet-stream');
expect(new Headers(init.headers).get('Authorization')).toBe('Bearer secret-token');
expect(new Headers(init.headers).has('Content-Length')).toBe(false); // Browser owns this forbidden header.
});
it('上传前拒绝ZIP、空文件和超过各格式上限,仍由服务验证实际模型', () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
const client = new LocalTrainingClient('http://localhost:8765', 'token');
for (const [name, size] of [
['model.zip', 1],
['model.pt', 0],
['model.pt', 256 * 1024 ** 2 + 1],
['model.onnx', 64 * 1024 ** 2 + 1],
] as const) {
const file = new File(['x'], name);
Object.defineProperty(file, 'size', { value: size });
expect(() => client.uploadPretrained(file, 'go2-legacy47-v1')).toThrow();
}
expect(fetchMock).not.toHaveBeenCalled();
});
@@ -1,5 +1,6 @@
import type {
ParameterConstraint,
PretrainedSource,
RewardPreset,
TuningCapability,
TuningCreateRequest,
@@ -57,6 +58,26 @@ export class LocalTrainingClient {
health(): Promise<TrainingServerInfo> {
return this.json('/api/training/health');
}
uploadPretrained(
file: File,
template: 'go2-legacy47-v1',
signal?: AbortSignal,
): Promise<PretrainedSource> {
const format = file.name.split('.').pop()?.toLowerCase();
if (format !== 'pt' && format !== 'onnx')
throw new Error('请选择单个.pt或.onnx文件,不支持ZIP/目录');
const limit = (format === 'pt' ? 256 : 64) * 1024 ** 2;
if (!file.size || file.size > limit)
throw new Error(`文件不能为空,${format}上限为${limit / 1024 ** 2}MiB`);
if (template !== 'go2-legacy47-v1') throw new Error('请先确认Go2 legacy47模板');
const query = new URLSearchParams({ format, template, name: file.name });
return this.json(`/api/training/pretrained-sources/upload?${query}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: file,
signal,
});
}
start(request: TrainingRequest): Promise<TrainingJob> {
return this.json('/api/training/jobs', {
method: 'POST',
@@ -1,3 +1,8 @@
import customLayout from '../../../training_server/tests/fixtures/custom-boxes.json';
import { validateCustomTerrain, validatePolicyDeployment } from '../rl/deployment';
import fixture from '../rl/fixtures/obstacleDeployment.json';
import { DEFAULT_PHYSICAL_MAP_CONFIG } from '../map/types';
import { TRAINING_JOB_KEY } from './storage';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
@@ -161,3 +166,308 @@ describe('LocalTrainingPanel', () => {
);
});
});
const customTasks = ['Unitree-Go2-Flat', 'Unitree-Go2-ObstacleAvoidance', 'Unitree-Go2-Rough'];
const customMetadata = customTasks.map((id) => ({
id,
name: id,
browserCompatible: id !== 'Unitree-Go2-Rough',
terrainPresets: [
'plane',
'discrete_obstacles',
'rough',
'pyramid_stairs',
'wave',
'custom_boxes',
],
terrainParameters: {
size: { min: 8, max: 24, default: 12 },
friction: { min: 0.2, max: 2, default: 0.8 },
obstacle_count: { min: 1, max: 100, default: 24, integer: true },
},
sensorTypes: id.includes('Obstacle') ? ['raycast'] : [],
sensorModes: ['single_ring_raycast', 'multi_ring_raycast'],
sensorParameters: id.includes('Obstacle')
? { fov: { min: 30, max: 120, default: 90 }, maxDistance: { min: 1, max: 5, default: 4 } }
: {},
mapSyncScope: '单块预设参数',
}));
function customServer(job?: unknown) {
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
if (url.endsWith('/health'))
return Response.json({
ready: true,
tasks: customTasks,
taskMetadata: customMetadata,
trainerRoot: '/local',
});
if (url.includes('/presets')) return Response.json({ presets: [] });
if (url.endsWith('/policy.onnx')) return new Response(new Uint8Array([8, 9]));
if (init?.method === 'POST' || job)
return Response.json(
job ?? {
id: 'c'.repeat(32),
taskId: 'Unitree-Go2-ObstacleAvoidance',
state: 'queued',
progress: 0,
iteration: 0,
maxIterations: 2,
message: '',
logs: [],
artifactReady: false,
},
);
return Response.json({ error: 'not found' }, { status: 404 });
});
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
async function connectCustom() {
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), { target: { value: 'token' } });
fireEvent.click(screen.getByRole('button', { name: '连接' }));
await screen.findByText('/local');
}
describe('LocalTrainingPanel 自定义任务', () => {
it('动态任务/地图/传感器配置传入payload,切换任务清掉sensor与reward状态', async () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.change(screen.getByLabelText('训练任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(screen.getByLabelText('训练地形')).toHaveValue('discrete_obstacles');
expect(screen.getByLabelText('奖励配置 preset')).toBeDisabled();
fireEvent.change(screen.getByLabelText('感知角 FOV'), { target: { value: '60' } });
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'rough' } });
expect(screen.getByText(/训练专用 box/)).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('训练任务'), { target: { value: 'Unitree-Go2-Flat' } });
expect(screen.queryByLabelText('感知角 FOV')).not.toBeInTheDocument();
expect(screen.getByLabelText('训练地形')).toHaveValue('');
fireEvent.change(screen.getByLabelText('训练任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(screen.getByLabelText('感知角 FOV')).toHaveValue(90);
fireEvent.change(screen.getByLabelText('感知角 FOV'), { target: { value: '60' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() =>
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true),
);
const payload = JSON.parse(
String(fetchMock.mock.calls.find((call) => call[1]?.method === 'POST')?.[1]?.body),
);
expect(payload).toMatchObject({
taskId: 'Unitree-Go2-ObstacleAvoidance',
terrainPreset: 'discrete_obstacles',
sensorType: 'raycast',
sensorCfg: { fov: 60 },
});
expect(payload.rewardPresetId).toBeUndefined();
});
it('无地图/多实例明确报错而不提交;超限参数阻止请求', async () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('没有已应用');
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'plane' } });
fireEvent.change(screen.getByLabelText('地图尺寸 m'), { target: { value: '100' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('超出允许范围'));
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false);
});
it('旧Rough作业禁用导入,不误当Flat', async () => {
customServer({
id: 'r'.repeat(32),
taskId: 'Unitree-Go2-Rough',
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: [],
message: '',
});
localStorage.setItem(TRAINING_JOB_KEY, 'r'.repeat(32));
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
expect(await screen.findByRole('button', { name: '导入策略' })).toBeDisabled();
expect(screen.getByText(/234维 Rough/)).toBeInTheDocument();
});
});
describe('LocalTrainingPanel 配套策略交接', () => {
it('同步权威布局并上传完整boxes,不再重跑预设种子', async () => {
const fetchMock = customServer();
render(
<LocalTrainingPanel
compileScene={() => validateCustomTerrain(customLayout)}
onPolicyReady={vi.fn()}
sceneMaps={[
{
id: 'one',
name: 'one',
selection: {
kind: 'builtin',
config: {
...DEFAULT_PHYSICAL_MAP_CONFIG,
preset: 'discrete_obstacles',
size: 16,
friction: 1.2,
seed: 123,
obstacleCount: 17,
},
},
},
]}
/>,
);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
expect(screen.getByLabelText('训练地形')).toHaveValue('custom_boxes');
expect(screen.getByText('已将视口中 1 个自定义障碍物编译为训练地图布局')).toBeInTheDocument();
expect(screen.getByLabelText('出生 X')).toHaveValue(-2);
expect(screen.getByLabelText('目标 Y')).toHaveValue(-1);
expect(screen.getByText(/旋转障碍会膨胀/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() =>
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(true),
);
const request = JSON.parse(
String(fetchMock.mock.calls.find((call) => call[1]?.method === 'POST')?.[1]?.body),
);
expect(request.customTerrainBoxes).toEqual(customLayout);
expect(request.terrainPreset).toBe('custom_boxes');
expect(request.terrainParams).toEqual({ size: 12, friction: 0.8 });
});
it('等待地图/策略异步回调完成,失败显示错误,不提前解除busy', async () => {
localStorage.setItem(TRAINING_JOB_KEY, 'd'.repeat(32));
customServer({
id: 'd'.repeat(32),
taskId: fixture.taskId,
state: 'succeeded',
artifactReady: true,
progress: 1,
iteration: 1,
maxIterations: 1,
logs: ['Mean value loss: 0.0141'],
message: '',
deployment: fixture,
});
let reject!: (error: Error) => void;
const ready = vi.fn(
() =>
new Promise<void>((_resolve, failure) => {
reject = failure;
}),
);
render(<LocalTrainingPanel onPolicyReady={ready} />);
await connectCustom();
const button = await screen.findByRole('button', { name: '导入策略' });
fireEvent.click(button);
await waitFor(() => expect(ready).toHaveBeenCalledOnce());
expect(ready.mock.calls[0]).toEqual([expect.any(File), validatePolicyDeployment(fixture)]);
expect(button).toBeDisabled();
expect(screen.getByText('价值损失')).toBeInTheDocument();
reject(new Error('配套地图失败'));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('配套地图失败'));
expect(button).toBeEnabled();
});
});
it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;安全区报错不删障碍', async () => {
const fetchMock = customServer();
const assets = [
{
id: 'one',
name: 'one',
selection: {
kind: 'builtin' as const,
config: { ...DEFAULT_PHYSICAL_MAP_CONFIG, preset: 'flat' as const },
},
},
];
let current = validateCustomTerrain(customLayout);
const compileScene = vi.fn(
(coordinates?: import('../map/trainingMap').TrainingSceneCoordinates) => ({
...structuredClone(current),
...(coordinates?.spawn ? { spawn: [...coordinates.spawn, 0.32] } : {}),
...(coordinates?.target ? { target: [...coordinates.target] } : {}),
}),
);
const { rerender } = render(
<LocalTrainingPanel
onPolicyReady={vi.fn()}
sceneMaps={assets}
sceneDirty
compileScene={compileScene}
/>,
);
await connectCustom();
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('草稿');
expect(compileScene).not.toHaveBeenCalled();
rerender(
<LocalTrainingPanel onPolicyReady={vi.fn()} sceneMaps={assets} compileScene={compileScene} />,
);
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
fireEvent.change(screen.getByLabelText('目标 X'), { target: { value: 1 } });
fireEvent.change(screen.getByLabelText('目标 Y'), { target: { value: 2 } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('重新同步'));
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
expect(screen.getByRole('alert')).toHaveTextContent('安全区');
expect(current.boxes).toHaveLength(2);
fireEvent.change(screen.getByLabelText('目标 X'), { target: { value: 2 } });
fireEvent.change(screen.getByLabelText('目标 Y'), { target: { value: -1 } });
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
current = { ...current, boxes: [current.boxes[0], { ...current.boxes[1], pos: [1, 3, 0.5] }] };
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('过时'));
expect(fetchMock.mock.calls.some((call) => call[1]?.method === 'POST')).toBe(false);
});
it('显式选择multi48传入训练请求,默认与切换任务仍single32', async () => {
const fetchMock = customServer();
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
fireEvent.change(screen.getByLabelText('训练任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(screen.getByLabelText('传感器模式')).toHaveValue('single_ring_raycast');
fireEvent.change(screen.getByLabelText('传感器模式'), {
target: { value: 'multi_ring_raycast' },
});
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
await waitFor(() => expect(fetchMock.mock.calls.some((c) => c[1]?.method === 'POST')).toBe(true));
const payload = JSON.parse(
String(fetchMock.mock.calls.find((c) => c[1]?.method === 'POST')?.[1]?.body),
);
expect(payload.sensorCfg.sensorMode).toBe('multi_ring_raycast');
});
it('Flat奖励菜单只展示服务确认归属Flat的preset,Obstacle和无身份项不展示', async () => {
const entries = [
{ id: 'a'.repeat(32), name: '合法历史Flat', taskId: 'Unitree-Go2-Flat' },
{ id: 'b'.repeat(32), name: 'Obstacle不能混入', taskId: 'Unitree-Go2-ObstacleAvoidance' },
{ id: 'c'.repeat(32), name: '缺少权威身份' },
];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => {
if (url.endsWith('/health'))
return Response.json({
ready: true,
tasks: customTasks,
taskMetadata: customMetadata,
trainerRoot: '/local',
});
if (url.endsWith('/presets')) return Response.json({ presets: entries });
return Response.json({ error: 'not found' }, { status: 404 });
}),
);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
await connectCustom();
expect(await screen.findByRole('option', { name: '合法历史Flat' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'Obstacle不能混入' })).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: '缺少权威身份' })).not.toBeInTheDocument();
});
+451 -23
View File
@@ -1,4 +1,17 @@
import { useEffect, useState, type ReactNode } from 'react';
import { PretrainedIdentity, PretrainedSourceSelect } from './PretrainedSourceSelect';
import { pretrainedSelectionError } from './pretrainedSelection';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
import { trainingLosses } from './trainingLosses';
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
import {
OBSTACLE_TASK_ID,
validatePolicyDeployment,
validateCustomTerrain,
readPolicyDeployment,
} from '../rl/deployment';
import type { TrainingSceneCompiler } from '../map/trainingMap';
import type { PlacedMapAsset } from '../map/types';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { Download, ExternalLink, Link, Play, Server, Square } from 'lucide-react';
import { Badge, Button, ProgressBar, PropertyRow, Select } from '../components/ui';
import { LocalTrainingClient } from './LocalTrainingClient';
@@ -33,7 +46,17 @@ function stateLabel(state: TrainingJob['state']): string {
}[state];
}
export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File): void }) {
export function LocalTrainingPanel({
onPolicyReady,
compileScene,
sceneMaps = [],
sceneDirty = false,
}: {
onPolicyReady(file: File, deployment?: PolicyDeployment): void | Promise<void>;
compileScene?: TrainingSceneCompiler;
sceneMaps?: readonly PlacedMapAsset[];
sceneDirty?: boolean;
}) {
const [endpoint, setEndpoint] = useState(() =>
localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
);
@@ -42,6 +65,9 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
const [job, setJob] = useState<TrainingJob>();
const [presets, setPresets] = useState<RewardPreset[]>([]);
const [rewardPresetId, setRewardPresetId] = useState('');
const [pretrainedSourceId, setPretrainedSourceId] = useState('');
const [uploading, setUploading] = useState(false);
const [connectionRevision, setConnectionRevision] = useState(0);
const [busy, setBusy] = useState(false),
[error, setError] = useState<string>();
const [taskId, setTaskId] = useState('Unitree-Go2-Flat'),
@@ -53,15 +79,85 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
[gpuIds, setGpuIds] = useState('0'),
[wandbMode, setWandbMode] = useState<WandbMode>('offline');
const [terrainPreset, setTerrainPreset] = useState('');
const [customTerrainBoxes, setCustomTerrainBoxes] = useState<TrainingTerrain>();
const [syncedScene, setSyncedScene] = useState<string>();
const [terrainParams, setTerrainParams] = useState<Record<string, number>>({});
const [sensorMode, setSensorMode] = useState<'single_ring_raycast' | 'multi_ring_raycast'>(
'single_ring_raycast',
);
const [sensorCfg, setSensorCfg] = useState<Record<string, number>>({});
const metadata = server?.taskMetadata?.find((item) => item.id === taskId);
const sourceSelectionError = pretrainedSelectionError(
server?.pretrainedSources,
taskId,
pretrainedSourceId,
);
const selectTask = (id: string) => {
setTaskId(id);
setCustomTerrainBoxes(undefined);
setSyncedScene(undefined);
setRewardPresetId('');
setTerrainParams({});
setSensorCfg({});
setSensorMode('single_ring_raycast');
setTerrainPreset(id === OBSTACLE_TASK_ID ? 'discrete_obstacles' : '');
};
const syncMap = () => {
try {
if (sceneDirty) throw new Error('请先应用地图草稿,再同步训练地图');
if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图');
if (!metadata?.terrainPresets.includes('custom_boxes'))
throw new Error('当前服务/任务不支持custom_boxes,请升级训练服务');
setSyncedScene(undefined);
const layout = compileScene(
customTerrainBoxes
? {
spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]],
target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]],
}
: undefined,
);
setCustomTerrainBoxes(layout);
setTerrainPreset('custom_boxes');
setTerrainParams({ size: layout.size, friction: layout.friction });
validateCustomTerrain(layout);
setSyncedScene(JSON.stringify(sceneMaps));
setError(undefined);
} catch (value) {
setError(errorText(value));
}
};
const connectionEpoch = useRef(0);
const connected = () => {
connectionEpoch.current += 1;
setConnectionRevision(connectionEpoch.current);
setServer(undefined);
setJob(undefined);
setPresets([]);
setRewardPresetId('');
setError(undefined);
};
useEffect(
() => () => {
connectionEpoch.current += 1;
},
[],
);
const connect = async () => {
setBusy(true);
setError(undefined);
const epoch = ++connectionEpoch.current;
setConnectionRevision(epoch);
try {
const client = new LocalTrainingClient(endpoint, token),
info = await client.health();
if (epoch !== connectionEpoch.current) return;
setServer(info);
try {
setPresets(await client.presets());
const nextPresets = await client.presets();
if (epoch !== connectionEpoch.current) return;
setPresets(nextPresets);
} catch {
setPresets([]);
}
@@ -70,11 +166,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
} catch {
/* 当前会话仍可连接 */
}
if (info.tasks.length && !info.tasks.includes(taskId)) setTaskId(info.tasks[0]);
if (info.tasks.length && !info.tasks.includes(taskId)) selectTask(info.tasks[0]);
const remembered = info.activeJobId ?? localStored(TRAINING_JOB_KEY);
if (remembered) {
try {
const recovered = await client.job(remembered);
if (epoch !== connectionEpoch.current) return;
setJob(recovered);
try {
localStorage.setItem(TRAINING_JOB_KEY, recovered.id);
@@ -129,10 +226,57 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
typeof event.data !== 'object'
)
return;
const data = event.data as { type?: string; sessionId?: string; policy?: unknown };
const data = event.data as {
type?: string;
sessionId?: string;
policy?: unknown;
taskId?: string;
};
const source = event.source as Window;
if (data.type === 'mujoco-tuning-ready') {
source.postMessage({ type: 'mujoco-tuning-credentials', endpoint, token }, event.origin);
try {
if (taskId === OBSTACLE_TASK_ID && terrainPreset === 'custom_boxes') {
if (
sceneDirty ||
syncedScene !== JSON.stringify(sceneMaps) ||
!compileScene ||
!customTerrainBoxes
)
throw new Error('自定义地图已过时,请重新同步后打开调参');
const current = compileScene({
spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]],
target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]],
});
if (JSON.stringify(current) !== JSON.stringify(customTerrainBoxes))
throw new Error('碰撞场景已过时,请重新同步');
}
source.postMessage(
{
type: 'mujoco-tuning-credentials',
endpoint,
token,
trainingContext: {
taskId: taskId === OBSTACLE_TASK_ID ? taskId : 'Unitree-Go2-Flat',
seed,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(taskId === OBSTACLE_TASK_ID
? {
taskConfig: {
terrainPreset,
terrainParams,
sensorType: 'raycast',
sensorCfg: { ...sensorCfg, sensorMode },
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
},
}
: {}),
},
},
event.origin,
);
} catch (value) {
setError(errorText(value));
}
}
if (data.type === 'mujoco-tuning-import-policy' && data.sessionId) {
const reply = (ok: boolean, message?: string) => {
@@ -159,7 +303,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
if (!(policy instanceof File) || !/\.onnx$/i.test(policy.name))
throw new Error('调参工作台返回的 ONNX 策略无效');
if (policy.size > 64 * 1024 * 1024) throw new Error('ONNX 策略不能超过 64 MiB');
onPolicyReady(policy);
if (data.taskId === OBSTACLE_TASK_ID) {
const deployment = readPolicyDeployment(new Uint8Array(await policy.arrayBuffer()));
if (deployment?.taskId !== OBSTACLE_TASK_ID)
throw new Error('避障最佳策略缺少匹配部署契约');
await onPolicyReady(policy, deployment);
} else await onPolicyReady(policy);
reply(true);
} catch (value) {
const message = errorText(value);
@@ -171,7 +320,23 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
};
window.addEventListener('message', receive);
return () => window.removeEventListener('message', receive);
}, [endpoint, onPolicyReady, token]);
}, [
endpoint,
pretrainedSourceId,
onPolicyReady,
token,
taskId,
seed,
terrainPreset,
terrainParams,
sensorCfg,
sensorMode,
customTerrainBoxes,
syncedScene,
sceneMaps,
sceneDirty,
compileScene,
]);
const openTuningDashboard = () => {
rememberTrainingConnection(endpoint, token);
@@ -179,9 +344,20 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
};
const start = async () => {
if (uploading) return;
if (sourceSelectionError) {
setError(sourceSelectionError);
return;
}
setBusy(true);
setError(undefined);
try {
if (
taskId === OBSTACLE_TASK_ID &&
sensorMode === 'multi_ring_raycast' &&
!metadata?.sensorModes?.includes(sensorMode)
)
throw new Error('训练服务不支持multi_ring_raycast,请升级服务');
const ids =
device === 'gpu'
? gpuIds
@@ -191,6 +367,43 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
: [];
if (ids.some((id) => !Number.isInteger(id) || id < 0))
throw new Error('GPU 编号必须是非负整数');
for (const [values, schema] of [
[terrainParams, metadata?.terrainParameters],
[sensorCfg, metadata?.sensorParameters],
] as const) {
for (const [key, value] of Object.entries(values)) {
const bounds = schema?.[key];
if (
!bounds ||
!Number.isFinite(value) ||
value < bounds.min ||
value > bounds.max ||
(bounds.integer && !Number.isInteger(value))
)
throw new Error(`参数 ${key} 超出允许范围`);
}
}
if ((terrainParams.obstacle_height_min ?? 0.2) > (terrainParams.obstacle_height_max ?? 0.6))
throw new Error('障碍物最小高度不能超过最大高度');
if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4))
throw new Error('安全距离必须小于探测距离');
if (terrainPreset === 'custom_boxes') {
if (
sceneDirty ||
!syncedScene ||
syncedScene !== JSON.stringify(sceneMaps) ||
!compileScene ||
!customTerrainBoxes
)
throw new Error('自定义地图未同步或场景/坐标已更改,请重新同步');
validateCustomTerrain(customTerrainBoxes);
const current = compileScene({
spawn: [customTerrainBoxes.spawn[0], customTerrainBoxes.spawn[1]],
target: [customTerrainBoxes.target[0], customTerrainBoxes.target[1]],
});
if (JSON.stringify(current) !== JSON.stringify(customTerrainBoxes))
throw new Error('已编译碰撞场景已过时,请重新同步');
}
const next = await new LocalTrainingClient(endpoint, token).start({
taskId,
numEnvs,
@@ -200,7 +413,13 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
device,
gpuIds: ids,
wandbMode,
rewardPresetId: rewardPresetId || undefined,
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
...(terrainPreset ? { terrainPreset, terrainParams } : {}),
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
...(taskId === OBSTACLE_TASK_ID
? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } }
: {}),
});
setJob(next);
try {
@@ -231,7 +450,11 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
setBusy(true);
setError(undefined);
try {
onPolicyReady(await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id));
if (job.taskId !== 'Unitree-Go2-Flat' && !job.deployment)
throw new Error('该任务缺少浏览器部署契约');
const deployment = job.deployment ? validatePolicyDeployment(job.deployment) : undefined;
const file = await new LocalTrainingClient(endpoint, token).downloadPolicy(job.id);
await onPolicyReady(file, deployment);
} catch (value) {
setError(errorText(value));
} finally {
@@ -249,7 +472,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
aria-label="本地训练服务地址"
className="field h-7 min-w-0 flex-1 px-2 text-xs text-text-primary"
value={endpoint}
onChange={(event) => setEndpoint(event.target.value)}
disabled={busy}
onChange={(event) => {
if (busy) return;
connected();
setEndpoint(event.target.value);
}}
/>
<Button
icon={<Link className="h-3.5 w-3.5" />}
@@ -268,7 +496,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
autoComplete="off"
className="field h-7 w-full px-2 text-xs text-text-primary"
value={token}
onChange={(event) => setToken(event.target.value)}
disabled={busy}
onChange={(event) => {
if (busy) return;
connected();
setToken(event.target.value);
}}
/>
</label>
<div className="mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-2 py-1.5 text-[10px] text-text-tertiary">
@@ -288,21 +521,166 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
打开自调参 Agent 工作台
</Button>
{server?.ready && !job && (
<div className="mt-3 space-y-2">
<fieldset disabled={busy} className="mt-3 space-y-2">
<Field label="训练任务">
<Select
aria-label="训练任务"
className="w-full"
value={taskId}
onChange={(event) => setTaskId(event.target.value)}
onChange={(event) => selectTask(event.target.value)}
>
{server.tasks.map((task) => (
<option key={task} value={task}>
{task}
{server.taskMetadata?.find((item) => item.id === task)?.name ?? task}
</option>
))}
</Select>
</Field>
<PretrainedSourceSelect
sources={server.pretrainedSources}
taskId={taskId}
value={pretrainedSourceId}
onChange={setPretrainedSourceId}
disabled={busy || uploading}
upload={{
endpoint,
token,
revision: connectionRevision,
enabled: Boolean(server.pretrainedUpload?.enabled),
onBusyChange: setUploading,
onUploaded: (source) => {
setServer(
(current) =>
current && {
...current,
pretrainedSources: [
...(current.pretrainedSources ?? []).filter((s) => s.id !== source.id),
source,
],
},
);
setPretrainedSourceId(source.id);
},
}}
/>
{metadata && (
<>
<Field label="训练地形">
<Select
aria-label="训练地形"
value={terrainPreset}
onChange={(e) => {
setTerrainPreset(e.target.value);
setCustomTerrainBoxes(undefined);
setSyncedScene(undefined);
setTerrainParams({});
}}
>
{taskId !== OBSTACLE_TASK_ID && <option value="">原任务默认地形</option>}
{metadata.terrainPresets.map((preset) => (
<option key={preset} value={preset}>
{TERRAIN_LABELS[preset] ?? preset}
</option>
))}
</Select>
</Field>
<Button disabled={busy} onClick={syncMap}>
同步当前场景地图
</Button>
<p className="text-[10px] text-text-tertiary">
从全部已应用实例的实际碰撞几何编译世界AABB;旋转障碍会膨胀,底板标准化为z=[-0.2,0],出生高度标准化为0.32m。仅保证训练与浏览器使用相同boxes,不等于原OBB。mesh/hfield、地下结构、混合摩擦明确拒绝。
</p>
{terrainPreset === 'custom_boxes' && customTerrainBoxes && (
<>
<div className="grid grid-cols-2 gap-2">
{(['spawn', 'target'] as const).flatMap((key) =>
[0, 1].map((i) => (
<NumberField
key={`${key}${i}`}
label={`${key === 'spawn' ? '出生' : '目标'} ${i === 0 ? 'X' : 'Y'}`}
value={customTerrainBoxes[key][i]}
min={-12}
max={12}
step={0.1}
onChange={(value) => {
setSyncedScene(undefined);
setCustomTerrainBoxes(
(old) =>
old && {
...old,
[key]: old[key].map((v, j) => (i === j ? value : v)),
},
);
}}
/>
)),
)}
</div>
<p>
此处起终点仅用于固定评估和部署初始演示,训练会在同一连通自由区域内逐episode随机采样。参考点须保留0.5m圆形安全区;修改后请重新同步。
</p>
{syncedScene === JSON.stringify(sceneMaps) && !sceneDirty && (
<p role="status">
已将视口中 {customTerrainBoxes.actualObstacleCount}{' '}
个自定义障碍物编译为训练地图布局
</p>
)}
</>
)}
{terrainPreset && terrainPreset !== 'custom_boxes' && (
<div className="grid grid-cols-2 gap-2">
{Object.entries(metadata.terrainParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={terrainParams[key] ?? bounds.default}
min={bounds.min}
max={bounds.max}
step={bounds.integer ? 1 : 0.01}
onChange={(value) => setTerrainParams((old) => ({ ...old, [key]: value }))}
/>
))}
</div>
)}
{['rough', 'wave', 'pyramid_stairs'].includes(terrainPreset) && (
<p>训练专用 box 离散近似布局,不等于编辑器高度场。</p>
)}
{taskId === OBSTACLE_TASK_ID && (
<details open>
<summary>避障传感器高级设置</summary>
<Field label="传感器模式">
<Select
aria-label="传感器模式"
value={sensorMode}
onChange={(event) => setSensorMode(event.target.value as typeof sensorMode)}
>
<option value="single_ring_raycast">水平32射线 / 81维(默认)</option>
<option
value="multi_ring_raycast"
disabled={!metadata?.sensorModes?.includes('multi_ring_raycast')}
>
三层48射线 / 97维(非高程图)
</option>
</Select>
</Field>
{Object.entries(metadata.sensorParameters).map(([key, bounds]) => (
<NumberField
key={key}
label={PARAMETER_LABELS[key] ?? key}
value={sensorCfg[key] ?? bounds.default}
min={bounds.min}
max={bounds.max}
step={0.01}
onChange={(value) => setSensorCfg((old) => ({ ...old, [key]: value }))}
/>
))}
</details>
)}
{!metadata.browserCompatible && (
<p>此任务可训练,但浏览器不支持其观测契约,不能一键部署。</p>
)}
</>
)}
<div className="grid grid-cols-2 gap-2">
<NumberField
label="并行环境"
@@ -358,17 +736,20 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
</div>
<Field label="奖励配置">
<Select
disabled={taskId !== 'Unitree-Go2-Flat'}
aria-label="奖励配置 preset"
className="w-full"
value={rewardPresetId}
onChange={(event) => setRewardPresetId(event.target.value)}
>
<option value="">仓库默认奖励</option>
{presets.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
{presets
.filter((preset) => preset.taskId === 'Unitree-Go2-Flat')
.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.name}
</option>
))}
</Select>
</Field>
<Field label="实验记录">
@@ -387,7 +768,7 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
variant="primary"
className="w-full"
icon={<Play className="h-3.5 w-3.5" />}
disabled={busy}
disabled={busy || uploading || Boolean(sourceSelectionError)}
onClick={() => void start()}
>
发起本地训练
@@ -396,7 +777,7 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
训练使用本地 mjlab
任务资产,不会把浏览器中的模型上传到网络。服务一次只运行一个训练任务。
</p>
</div>
</fieldset>
)}
{job && (
<div className="mt-3 rounded-lg border border-border bg-surface p-2.5">
@@ -416,11 +797,26 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
{stateLabel(job.state)}
</Badge>
</div>
<PretrainedIdentity source={job.pretrained} />
{job.taskId === 'Unitree-Go2-Rough' && (
<p>234维 Rough 策略仅支持后端评测,浏览器不可加载。</p>
)}
{job.deployment?.terrain && (
<p className="text-[10px] text-text-tertiary">
导入将替换当前物理地图并启动配套策略;
{job.deployment.terrain.approximation ? '训练专用近似布局' : '配套碰撞布局'}
。请先保存场景。
</p>
)}
<ProgressBar value={job.progress} label="训练进度" />
<div className="mt-2">
<PropertyRow label="迭代" value={`${job.iteration} / ${job.maxIterations}`} />
<PropertyRow label="状态" value={job.message} />
{trainingLosses(job.logs).map(({ label, value }) => (
<PropertyRow key={label} label={label} value={value} />
))}
</div>
<TrainingMetricsPanel key={job.id} jobId={job.id} logs={job.logs} />
{job.logs.length > 0 && (
<details className="mt-2">
<summary className="cursor-pointer text-[10px] text-text-secondary">最近日志</summary>
@@ -443,7 +839,12 @@ export function LocalTrainingPanel({ onPolicyReady }: { onPolicyReady(file: File
) : (
<>
<Button
disabled={busy || !job.artifactReady}
disabled={
busy ||
!job.artifactReady ||
job.taskId === 'Unitree-Go2-Rough' ||
(job.deployment && !job.deployment.browserCompatible)
}
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => void importResult()}
>
@@ -492,11 +893,13 @@ function NumberField({
min,
max,
onChange,
step = 1,
}: {
label: string;
value: number;
min: number;
max: number;
step?: number;
onChange(value: number): void;
}) {
return (
@@ -504,6 +907,7 @@ function NumberField({
<input
aria-label={label}
type="number"
step={step}
className="field h-7 w-full px-2 text-xs text-text-primary"
value={value}
min={min}
@@ -513,3 +917,27 @@ function NumberField({
</Field>
);
}
const TERRAIN_LABELS: Record<string, string> = {
custom_boxes: '自定义场景碰撞布局(AABB近似)',
plane: '平地',
discrete_obstacles: '离散障碍物',
rough: '崎岖地面',
pyramid_stairs: '金字塔台阶',
wave: '波浪地形',
};
const PARAMETER_LABELS: Record<string, string> = {
size: '地图尺寸 m',
obstacle_count: '障碍物数量',
obstacle_height_min: '最小障碍高度 m',
obstacle_height_max: '最大障碍高度 m',
spacing: '障碍物间距 m',
friction: '地面摩擦',
roughness: '崎岖高度 m',
step_height: '台阶高度 m',
wave_amplitude: '波浪幅度 m',
fov: '感知角 FOV',
maxDistance: '探测距离 m',
safetyDistance: '安全距离 m',
avoidanceWeight: '避障权重',
};
@@ -0,0 +1,358 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, it, vi } from 'vitest';
import { LocalTrainingPanel } from './LocalTrainingPanel';
import { TuningApp } from '../tuning/TuningApp';
import { useTuningStore } from '../tuning/tuningStore';
import type { PretrainedSource } from './types';
import { PretrainedSourceSelect } from './PretrainedSourceSelect';
const source: PretrainedSource = {
id: 'base',
label: '用户基础行走策略',
ready: true,
compatibleTasks: ['Unitree-Go2-Flat', 'Unitree-Go2-ObstacleAvoidance'],
observationSizes: [47, 81, 97],
initialization: {
sourceId: 'a'.repeat(64),
registeredId: 'base',
label: '用户基础行走策略',
manifest: {
source_iteration: 10000,
source_actor_dim: 47,
normalization: 'preserve-source-count/unit-new-features',
artifacts: Object.fromEntries(
['checkpoint', 'onnx', 'env', 'agent'].map((key) => [
key,
{ name: key === 'checkpoint' ? 'model_10000.pt' : key, sha256: 'b'.repeat(64), bytes: 1 },
]),
) as NonNullable<PretrainedSource['initialization']>['manifest']['artifacts'],
},
},
};
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
localStorage.clear();
sessionStorage.clear();
useTuningStore.setState({
sessionId: undefined,
sessions: [],
capability: undefined,
connectionState: 'idle',
error: undefined,
});
});
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), { status, headers: { 'Content-Type': 'application/json' } });
it('普通训练可选来源、显示resolved checkpoint/SHA,任务切换保留选择,服务器错误不退回随机', async () => {
const requests: Record<string, unknown>[] = [];
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: RequestInit) => {
if (url.endsWith('/health'))
return Promise.resolve(
json({
ready: true,
tasks: ['Unitree-Go2-Flat', 'Unitree-Go2-Rough'],
pretrainedSources: [source],
}),
);
if (url.endsWith('/presets')) return Promise.resolve(json({ presets: [] }));
requests.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
return Promise.resolve(json({ error: '基础策略快照SHA不匹配' }, 400));
}),
);
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
fireEvent.change(screen.getByLabelText('训练服务访问令牌'), { target: { value: 'token' } });
fireEvent.click(screen.getByRole('button', { name: /^连接$/ }));
const select = await screen.findByLabelText('基础策略');
fireEvent.change(select, { target: { value: 'base' } });
expect(screen.getByText(/model_10000.pt/)).toBeVisible();
expect(screen.getByText(/checkpoint SHA256/)).toHaveTextContent('b'.repeat(64));
fireEvent.change(screen.getByLabelText('训练任务'), { target: { value: 'Unitree-Go2-Rough' } });
expect(select).toHaveValue('base');
expect(screen.getByRole('button', { name: '发起本地训练' })).toBeDisabled();
expect(screen.getByRole('option', { name: /任务不兼容/ })).toBeDisabled();
fireEvent.change(screen.getByLabelText('训练任务'), { target: { value: 'Unitree-Go2-Flat' } });
fireEvent.change(select, { target: { value: 'base' } });
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
expect(await screen.findByRole('alert')).toHaveTextContent('SHA不匹配');
expect(requests).toHaveLength(1);
expect(requests[0].pretrainedSourceId).toBe('base');
expect(requests[0]).not.toHaveProperty('pretrainedCheckpoint');
expect(select).toHaveValue('base');
});
it('自调参面板选择来源只提交注册ID,切换任务保留来源且保持approval模式', async () => {
const requests: Record<string, unknown>[] = [];
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: RequestInit) => {
if (url.endsWith('/capabilities'))
return Promise.resolve(
json({ ready: true, configured: true, model: 'stub', pretrainedSources: [source] }),
);
if (init?.method === 'POST') {
requests.push(JSON.parse(String(init.body)) as Record<string, unknown>);
return Promise.resolve(json({ error: '基础策略快照文件失效' }, 400));
}
return Promise.resolve(json({ sessions: [] }));
}),
);
render(<TuningApp />);
fireEvent.change(screen.getByLabelText('访问令牌(仅当前标签页)'), {
target: { value: 'token' },
});
fireEvent.click(screen.getByRole('button', { name: '连接/刷新' }));
await screen.findByRole('option', { name: /用户基础行走策略/ });
const select = screen.getByLabelText('基础策略');
fireEvent.change(select, { target: { value: 'base' } });
fireEvent.change(screen.getByLabelText('调参任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(select).toHaveValue('base');
fireEvent.change(select, { target: { value: 'base' } });
fireEvent.click(screen.getByRole('button', { name: '启动自调参' }));
await waitFor(() => expect(requests).toHaveLength(1));
expect(requests[0]).toMatchObject({
pretrainedSourceId: 'base',
mode: 'approval',
taskId: 'Unitree-Go2-ObstacleAvoidance',
});
expect(await screen.findByText('基础策略快照文件失效')).toBeVisible();
});
it('缺checkpoint注册条目显式错误,不作为可用来源', () => {
render(
<PretrainedSourceSelect
taskId="Unitree-Go2-Flat"
value=""
onChange={vi.fn()}
sources={[
{
id: 'bad',
label: '错误来源',
ready: false,
compatibleTasks: [],
error: '缺少.pt,请配置匹配checkpoint',
},
]}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent('缺少.pt');
expect(screen.getByRole('option', { name: /验证失败/ })).toBeDisabled();
});
const sourceA: PretrainedSource = { ...source, id: 'a'.repeat(64) };
const sourceB: PretrainedSource = {
...source,
id: 'c'.repeat(64),
// Same registration alias/label is deliberately retained; content B is not A.
initialization: { ...source.initialization!, sourceId: 'c'.repeat(64) },
};
const invalidCatalogs: [string, PretrainedSource[] | undefined][] = [
['同别名A替换为B', [sourceB]],
['A变为not-ready', [{ ...sourceA, ready: false }, sourceB]],
['目录缺项', undefined],
['A变为任务不兼容', [{ ...sourceA, compatibleTasks: ['Unitree-Go2-Rough'] }, sourceB]],
];
for (const panel of ['普通训练', '自调参'] as const) {
for (const [scenario, invalidCatalog] of invalidCatalogs) {
for (const resolution of ['明确从头训练', '明确选择B'] as const) {
it(`${panel}真实刷新:${scenario}保留A并阻止请求,${resolution}后恢复`, async () => {
let catalog: PretrainedSource[] | undefined = [sourceA];
const requests: Record<string, unknown>[] = [];
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: RequestInit) => {
if (url.endsWith('/health'))
return Promise.resolve(
json({
ready: true,
tasks: ['Unitree-Go2-Flat'],
pretrainedSources: catalog,
}),
);
if (url.endsWith('/capabilities'))
return Promise.resolve(
json({
ready: true,
configured: true,
model: 'stub',
pretrainedSources: catalog,
}),
);
if (url.endsWith('/presets')) return Promise.resolve(json({ presets: [] }));
if (init?.method === 'POST') {
requests.push(JSON.parse(String(init.body)) as Record<string, unknown>);
return Promise.resolve(json({ error: '测试截获请求,未启动训练' }, 400));
}
return Promise.resolve(json({ sessions: [] }));
}),
);
const ordinary = panel === '普通训练';
render(ordinary ? <LocalTrainingPanel onPolicyReady={vi.fn()} /> : <TuningApp />);
fireEvent.change(
screen.getByLabelText(ordinary ? '训练服务访问令牌' : '访问令牌(仅当前标签页)'),
{ target: { value: 'token' } },
);
const refresh = screen.getByRole('button', { name: ordinary ? /^连接$/ : '连接/刷新' });
fireEvent.click(refresh);
await screen.findByRole('option', { name: /用户基础行走策略/ });
const select = screen.getByLabelText('基础策略');
fireEvent.change(select, { target: { value: sourceA.id } });
const start = screen.getByRole('button', {
name: ordinary ? '发起本地训练' : '启动自调参',
});
await waitFor(() => expect(start).toBeEnabled());
catalog = invalidCatalog;
fireEvent.click(refresh);
await screen.findByText(/所选基础策略已失效:/);
await waitFor(() => expect(refresh).toBeEnabled());
expect(select).toHaveValue(sourceA.id);
expect(start).toBeDisabled();
fireEvent.click(start);
expect(requests).toHaveLength(0);
if (resolution === '明确选择B') {
catalog = [sourceB];
fireEvent.click(refresh);
await screen.findByRole('option', { name: /用户基础行走策略.*已验证/ });
await waitFor(() => expect(refresh).toBeEnabled());
expect(select).toHaveValue(sourceA.id);
expect(start).toBeDisabled();
expect(requests).toHaveLength(0);
fireEvent.change(select, { target: { value: sourceB.id } });
} else fireEvent.change(select, { target: { value: '' } });
expect(screen.queryByText(/所选基础策略已失效:/)).not.toBeInTheDocument();
await waitFor(() => expect(start).toBeEnabled());
fireEvent.click(start);
await waitFor(() => expect(requests).toHaveLength(1));
if (resolution === '明确选择B') expect(requests[0].pretrainedSourceId).toBe(sourceB.id);
else expect(requests[0]).not.toHaveProperty('pretrainedSourceId');
if (!ordinary) expect(requests[0].mode).toBe('approval');
});
}
}
}
const uploadCapability = {
enabled: true,
templateId: 'go2-legacy47-v1',
formats: { pt: 256 * 1024 ** 2, onnx: 64 * 1024 ** 2 },
endpoint: '/api/training/pretrained-sources/upload',
};
for (const ordinary of [true, false]) {
for (const outcome of ['pt', 'onnx', 'failure', 'cancel', 'task', 'connection'] as const) {
it(`${ordinary ? '普通' : '自调参'}上传${outcome}:阻止未完成启动并隔离旧epoch,保留旧选择`, async () => {
let finish!: (response: Response) => void;
const uploads: { url: string; init?: RequestInit }[] = [];
const starts: Record<string, unknown>[] = [];
const format = outcome === 'onnx' ? 'onnx' : 'pt';
const uploaded: PretrainedSource = {
...sourceB,
label: `single.${format}`,
initialization: {
...sourceB.initialization!,
label: `single.${format}`,
manifest: {
...sourceB.initialization!.manifest,
sourceFormat: format,
source_iteration: format === 'onnx' ? null : 10000,
contract: 'go2-legacy47-v1',
derived_fields: { normalizer_count: { policy: 'synthetic', value: 1000000 } },
artifacts: {
checkpoint: source.initialization!.manifest.artifacts.checkpoint,
upload: { name: `upload.${format}`, sha256: 'd'.repeat(64), bytes: 3 },
},
},
},
};
vi.stubGlobal(
'fetch',
vi.fn((url: string, init?: RequestInit) => {
if (url.includes('/pretrained-sources/upload?')) {
uploads.push({ url, init });
return new Promise<Response>((resolve) => {
finish = resolve;
});
}
if (url.endsWith('/health') || url.endsWith('/capabilities'))
return Promise.resolve(
json({
ready: true,
configured: true,
tasks: ['Unitree-Go2-Flat', 'Unitree-Go2-Rough'],
pretrainedSources: [sourceA],
pretrainedUpload: uploadCapability,
}),
);
if (url.endsWith('/presets')) return Promise.resolve(json({ presets: [] }));
if (init?.method === 'POST') {
starts.push(JSON.parse(String(init.body)) as Record<string, unknown>);
return Promise.resolve(json({ error: '测试禁止真实训练' }, 400));
}
return Promise.resolve(json({ sessions: [] }));
}),
);
render(ordinary ? <LocalTrainingPanel onPolicyReady={vi.fn()} /> : <TuningApp />);
fireEvent.change(
screen.getByLabelText(ordinary ? '训练服务访问令牌' : '访问令牌(仅当前标签页)'),
{ target: { value: 'token' } },
);
const connect = () =>
fireEvent.click(screen.getByRole('button', { name: ordinary ? /^连接$/ : '连接/刷新' }));
connect();
await screen.findByRole('option', { name: /用户基础行走策略/ });
fireEvent.change(screen.getByLabelText('基础策略'), { target: { value: sourceA.id } });
const input = screen.getByLabelText('选择基础策略文件');
expect(input).toBeDisabled();
fireEvent.click(screen.getByLabelText('确认Go2 legacy47模板'));
const file = new File(['abc'], `single.${format}`);
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(uploads).toHaveLength(1));
expect(uploads[0].init?.body).toBe(file);
expect(uploads[0].url).toContain('template=go2-legacy47-v1');
const startName = ordinary ? '发起本地训练' : '启动自调参';
expect(screen.getByRole('button', { name: startName })).toBeDisabled();
expect(starts).toHaveLength(0);
if (outcome === 'cancel') fireEvent.click(screen.getByRole('button', { name: '取消上传' }));
if (outcome === 'task')
fireEvent.change(screen.getByLabelText(ordinary ? '训练任务' : '调参任务'), {
target: { value: ordinary ? 'Unitree-Go2-Rough' : 'Unitree-Go2-ObstacleAvoidance' },
});
if (outcome === 'connection') {
fireEvent.change(screen.getByLabelText(ordinary ? '本地训练服务地址' : '训练服务地址'), {
target: { value: 'http://127.0.0.1:9999' },
});
connect();
await screen.findByRole('option', { name: /用户基础行走策略/ });
}
finish(outcome === 'failure' ? json({ error: '不支持该模型' }, 400) : json(uploaded, 201));
if (outcome === 'pt' || outcome === 'onnx') {
await waitFor(() => expect(screen.getByLabelText('基础策略')).toHaveValue(sourceB.id));
expect(screen.getByText(/原文件 SHA256/)).toHaveTextContent('d'.repeat(64));
if (outcome === 'onnx')
expect(screen.getByText(/ONNX统计count合成/)).toHaveTextContent('1000000');
fireEvent.click(screen.getByRole('button', { name: startName }));
await waitFor(() => expect(starts).toHaveLength(1));
expect(starts[0].pretrainedSourceId).toBe(sourceB.id);
if (!ordinary) expect(starts[0].mode).toBe('approval');
} else {
if (outcome === 'failure') {
await screen.findByText(/不支持该模型/);
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => expect(uploads).toHaveLength(2));
fireEvent.click(screen.getByRole('button', { name: '取消上传' }));
finish(json(uploaded, 201));
}
await waitFor(() => expect(screen.getByLabelText('基础策略')).toHaveValue(sourceA.id));
expect(uploads[0].init?.signal?.aborted).toBe(outcome !== 'failure');
expect(starts).toHaveLength(0);
}
});
}
}
@@ -0,0 +1,123 @@
import { Select } from '../components/ui';
import type { PretrainedInitialization, PretrainedSource } from './types';
import { pretrainedSelectionError } from './pretrainedSelection';
import { PretrainedUpload, type PretrainedUploadConnection } from './PretrainedUpload';
export function PretrainedIdentity({ source }: { source?: PretrainedInitialization }) {
if (!source)
return <p className="text-xs text-text-tertiary">初始化:随机新策略(未选择基础策略)</p>;
return (
<div className="break-all text-xs text-text-secondary" aria-label="基础策略身份">
<p>
初始化:{source.label} · {source.manifest.artifacts.checkpoint.name}
</p>
<p>checkpoint SHA256:{source.manifest.artifacts.checkpoint.sha256}</p>
{source.manifest.artifacts.upload ? (
<>
<p>
上传格式:{source.manifest.sourceFormat} · 原文件:{source.label}
</p>
<p>原文件 SHA256:{source.manifest.artifacts.upload.sha256}</p>
<p>
模板:{source.manifest.contract}
(用户确认缺失的物理语义);继承actor权重,非完整resume。
</p>
{source.manifest.sourceFormat === 'onnx' && (
<p>
ONNX统计count合成:{source.manifest.derived_fields?.normalizer_count?.value ?? '未知'}
; 探索std使用新训练默认,critic/optimizer重新初始化。
</p>
)}
</>
) : (
<p>ONNX SHA256:{source.manifest.artifacts.onnx?.sha256 ?? '未知'}</p>
)}
<p>
来源迭代 {source.manifest.source_iteration ?? '未知'}
;新训练从0开始,critic/optimizer重新初始化;同trial续训保留自身checkpoint。
</p>
</div>
);
}
export function PretrainedSourceSelect({
sources = [],
taskId,
value,
onChange,
disabled = false,
upload,
}: {
sources?: PretrainedSource[];
taskId: string;
value: string;
onChange(value: string): void;
disabled?: boolean;
upload?: PretrainedUploadConnection;
}) {
const selected = sources.find((source) => source.id === value);
const selectionError = pretrainedSelectionError(sources, taskId, value);
return (
<div className="space-y-1">
<label className="block text-xs text-text-secondary">
基础策略
<Select
aria-label="基础策略"
className="w-full"
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
>
<option value="">不选择(随机初始化)</option>
{value && !selected && (
<option value={value} disabled>
所选基础策略已失效({value})
</option>
)}
{sources.map((source) => (
<option
key={source.id}
value={source.id}
disabled={!source.ready || !source.compatibleTasks.includes(taskId)}
>
{source.label}
{!source.ready
? '(验证失败)'
: !source.compatibleTasks.includes(taskId)
? '(任务不兼容)'
: '(已验证)'}
</option>
))}
</Select>
</label>
{upload && (
<PretrainedUpload
key={JSON.stringify([upload.endpoint, upload.token, upload.revision, taskId])}
connection={upload}
disabled={disabled}
/>
)}
{!sources.length && (
<p className="text-xs">尚无基础策略,可直接上传单个文件;不选择则随机初始化。</p>
)}
{sources
.filter((source) => source.error)
.map((source) => (
<p role="alert" key={source.id}>
{source.label}:{source.error}
</p>
))}
{selected && (
<p className="text-xs">
兼容:{selected.compatibleTasks.join(' / ')};观测 {selected.observationSizes?.join('/')}{' '}
→ 12动作。只继承actor权重,新训练从0开始。
</p>
)}
{selectionError ? (
<p role="alert">{selectionError}</p>
) : (
<PretrainedIdentity source={selected?.initialization} />
)}
</div>
);
}
@@ -0,0 +1,111 @@
import { useEffect, useRef, useState } from 'react';
import { Button } from '../components/ui';
import { LocalTrainingClient } from './LocalTrainingClient';
import type { PretrainedSource } from './types';
export interface PretrainedUploadConnection {
endpoint: string;
token: string;
revision: number;
enabled: boolean;
onUploaded(source: PretrainedSource): void;
onBusyChange(busy: boolean): void;
}
/** Remounted for each connection/task epoch by the shared source selector. */
export function PretrainedUpload({
connection,
disabled,
}: {
connection: PretrainedUploadConnection;
disabled: boolean;
}) {
const [confirmed, setConfirmed] = useState(false);
const [pending, setPending] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const request = useRef<AbortController | null>(null);
const onBusyChange = connection.onBusyChange;
useEffect(
() => () => {
request.current?.abort();
onBusyChange(false);
},
[onBusyChange],
);
const upload = async (file: File) => {
if (!confirmed || disabled || pending || !connection.enabled) return;
const controller = new AbortController();
request.current = controller;
setPending(true);
onBusyChange(true);
setError('');
setMessage(`正在上传并验证 ${file.name},请稍候…`);
try {
const source = await new LocalTrainingClient(
connection.endpoint,
connection.token,
).uploadPretrained(file, 'go2-legacy47-v1', controller.signal);
if (controller.signal.aborted) return;
if (!source.ready || !source.initialization)
throw new Error(source.error ?? '上传来源未通过验证');
connection.onUploaded(source);
setMessage(`已选择 ${source.label};仅继承策略权重,不是完整训练resume。`);
} catch (value) {
if (!controller.signal.aborted) {
setMessage('');
setError(
`${value instanceof Error ? value.message : String(value)};保留原基础策略选择,未启动训练。`,
);
}
} finally {
if (!controller.signal.aborted) {
request.current = null;
setPending(false);
onBusyChange(false);
}
}
};
const cancel = () => {
request.current?.abort();
request.current = null;
setPending(false);
onBusyChange(false);
setMessage('已取消等待,保留原基础策略选择;服务若已完成验证,可刷新目录查看。');
setError('');
};
return (
<div className="space-y-2 rounded border border-border p-2 text-xs">
<p>上传基础策略:单个.pt(≤256MiB)或.onnx(≤64MiB),无需服务器路径或配套文件。</p>
<label className="flex gap-2">
<input
type="checkbox"
aria-label="确认Go2 legacy47模板"
checked={confirmed}
disabled={disabled || pending || !connection.enabled}
onChange={(event) => setConfirmed(event.target.checked)}
/>
按Go2 legacy47观测及FL/FR/RL/RR关节顺序解释文件;缺失的物理语义由我确认。
</label>
<p>
仅支持47维Go2行走actor;ONNX仅继承推理网络,统计count合成,探索/价值网络/优化器重新初始化。
</p>
<input
type="file"
accept=".pt,.onnx"
aria-label="选择基础策略文件"
disabled={!confirmed || disabled || pending || !connection.enabled}
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = ''; // Same-file retry must still dispatch change.
if (file) void upload(file);
}}
/>
{!connection.enabled && <p>请先连接支持单文件上传的训练服务。</p>}
{pending && <Button onClick={cancel}>取消上传</Button>}
{message && <p role="status">{message}</p>}
{error && <p role="alert">{error}</p>}
</div>
);
}
@@ -0,0 +1,63 @@
import { TrainingMetricHistory } from './TrainingMetricHistory';
const header = (i: number) => `Learning iteration ${i} / 1000`;
it('解析多个迭代全部指标、重复poll去重、同迭代分批补全', () => {
const h = new TrainingMetricHistory();
const logs = [
header(1),
'Mean value loss: 1e-3',
'Mean surrogate loss: -0.1',
header(2),
'Mean value loss: 2',
];
expect(h.update('a', logs)).toBe(true);
expect(h.update('a', logs)).toBe(false);
expect(h.series()[0].points.map((p) => [p.step, p.value])).toEqual([
[1, 0.001],
[2, 2],
]);
expect(
h.update('a', [...logs, 'Mean entropy loss: -3', 'Mean reward: 4', 'Mean episode length: 50']),
).toBe(true);
expect(h.series()).toHaveLength(5);
expect(h.series().find((s) => s.tag === '平均奖励')!.points[0]).toEqual({
step: 2,
value: 4,
wallTime: 0,
});
});
it('滚动截断按重叠上下文补全,无header/overlap不猜迭代,忽略非有限/缺失/其它数字', () => {
const h = new TrainingMetricHistory();
h.update('a', [header(3), 'Mean value loss: 3']);
h.update('a', ['Mean value loss: 3', 'Mean reward: 7']);
expect(h.series()[1].points[0].step).toBe(3);
h.update('a', ['Mean entropy loss: 5']);
expect(h.series()).toHaveLength(2);
h.update('a', [
header(4),
'Mean value loss: NaN',
'Mean reward: Infinity',
'Mean reward: 1e999',
'Mean reward: 3 ms',
'Total timesteps: 40',
'Iteration time: 2',
]);
expect(h.series()[0].points).toHaveLength(1);
h.update('b', ['Mean reward: 1']);
expect(h.series()).toEqual([]);
h.update('b', [header(0), 'Mean reward: .5']);
expect(h.series()[0].points[0].step).toBe(0);
});
it('容量和去重索引有界,旧重发不挤掉最新迭代,job切换清空', () => {
const h = new TrainingMetricHistory();
for (let i = 0; i < 650; i++) h.update('a', [header(i), `Mean reward: ${i}`]);
const points = h.series()[0].points;
expect(points).toHaveLength(500);
expect(points[0].step).toBe(150);
expect(points.at(-1)!.step).toBe(649);
h.update('a', [header(1), 'Mean reward: 999']);
expect(h.series()[0].points).toEqual(points);
h.update('new-job', [header(0), 'Mean value loss: 1']);
expect(h.series()[0].points).toHaveLength(1);
expect(() => new TrainingMetricHistory(1)).toThrow();
});
@@ -0,0 +1,93 @@
import type { ScalarSeries } from './types';
export const TRAINING_METRICS = {
value: '价值损失',
surrogate: '策略损失',
entropy: '熵损失',
reward: '平均奖励',
episodeLength: '平均回合长度',
} as const;
type Metric = keyof typeof TRAINING_METRICS;
const METRIC_PATTERN =
/^\s*Mean (value loss|surrogate loss|entropy loss|reward|episode length):\s*([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[-+]?\d+)?)\s*$/i;
const KEYS: Record<string, Metric> = {
'value loss': 'value',
'surrogate loss': 'surrogate',
'entropy loss': 'entropy',
reward: 'reward',
'episode length': 'episodeLength',
};
/** Bounded iteration merge. Headerless lines are accepted only with proven suffix/prefix overlap.
* On reconnect without overlap, orphan scalars are skipped rather than guessed onto a new step. */
export class TrainingMetricHistory {
private readonly rows = new Map<number, Partial<Record<Metric, number>>>();
private previous: { line: string; iteration?: number }[] = [];
private jobId?: string;
constructor(private readonly capacity = 500) {
if (!Number.isInteger(capacity) || capacity < 2) throw new Error('指标容量必须至少为2');
}
update(jobId: string, logs: readonly string[]): boolean {
let changed = false;
if (this.jobId !== jobId) {
this.rows.clear();
this.previous = [];
this.jobId = jobId;
changed = true;
}
const lines = logs
.flatMap((line) => line.split('\n'))
.slice(-1000)
// rsl_rl uses ANSI SGR colors around iteration headers.
// eslint-disable-next-line no-control-regex
.map((line) => line.replace(/\u001b\[[0-9;]*m/g, '').trim());
let iteration: number | undefined;
for (let overlap = Math.min(lines.length, this.previous.length); overlap > 0; overlap--) {
const start = this.previous.length - overlap;
if (lines.slice(0, overlap).every((line, i) => this.previous[start + i].line === line)) {
iteration = this.previous[start].iteration;
break;
}
}
const contexts: typeof this.previous = [];
for (const line of lines) {
const header = /\bLearning iteration\s+(\d+)\s*\/\s*\d+/i.exec(line);
if (header) {
const step = Number(header[1]);
iteration = Number.isSafeInteger(step) ? step : undefined;
}
contexts.push({ line, iteration });
const scalar = METRIC_PATTERN.exec(line);
if (iteration === undefined || !scalar) continue;
const value = Number(scalar[2]);
if (!Number.isFinite(value)) continue;
const key = KEYS[scalar[1].toLowerCase()];
if (!this.rows.has(iteration) && this.rows.size >= this.capacity) {
const oldest = Math.min(...this.rows.keys());
if (iteration < oldest) continue;
this.rows.delete(oldest);
}
const row = this.rows.get(iteration) ?? {};
if (row[key] !== value) {
row[key] = value;
changed = true;
}
this.rows.set(iteration, row);
}
this.previous = contexts;
return changed;
}
series(): ScalarSeries[] {
const rows = [...this.rows].sort(([a], [b]) => a - b);
return Object.entries(TRAINING_METRICS)
.map(([key, tag]) => ({
tag,
points: rows.flatMap(([step, row]) =>
row[key as Metric] === undefined
? []
: [{ step, wallTime: 0, value: row[key as Metric]! }],
),
}))
.filter((series) => series.points.length > 0);
}
}
@@ -0,0 +1,36 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TrainingMetricsPanel } from './TrainingMetricsPanel';
import { ScalarChart } from '../components/charts/ScalarChart';
vi.mock('../components/charts/ScalarChart', () => ({
ScalarChart: vi.fn(({ title }: { title: string }) => (
<div data-testid="metric-chart">{title}曲线</div>
)),
}));
it('折叠不挂载图表,价值/策略/综合独立缩放,job切换清空历史', () => {
const logs = [
'Learning iteration 1 / 10',
'Mean value loss: 1',
'Mean surrogate loss: -2',
'Mean entropy loss: -3',
'Mean reward: 4',
'Mean episode length: 50',
];
const view = render(<TrainingMetricsPanel jobId="a" logs={logs} />);
expect(screen.queryByTestId('metric-chart')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /训练指标趋势/ }));
expect(screen.getByText('价值损失曲线')).toBeInTheDocument();
vi.mocked(ScalarChart).mockClear();
view.rerender(<TrainingMetricsPanel jobId="a" logs={[...logs]} />);
expect(ScalarChart).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('tab', { name: '策略损失' }));
expect(screen.getByText('策略损失曲线')).toBeInTheDocument();
fireEvent.click(screen.getByRole('tab', { name: '综合' }));
expect(screen.getAllByTestId('metric-chart')).toHaveLength(5);
expect(screen.getByText('平均回合长度曲线')).toBeInTheDocument();
view.rerender(<TrainingMetricsPanel jobId="b" logs={[]} />);
expect(screen.queryByTestId('metric-chart')).not.toBeInTheDocument();
expect(screen.getByText(/尚无带迭代编号/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /训练指标趋势/ }));
expect(screen.queryByRole('tabpanel')).not.toBeInTheDocument();
});
@@ -0,0 +1,81 @@
import { memo, useEffect, useState } from 'react';
import { ScalarChart } from '../components/charts/ScalarChart';
import { TrainingMetricHistory, TRAINING_METRICS } from './TrainingMetricHistory';
import type { ScalarSeries } from './types';
const MetricChart = memo(function MetricChart({ series }: { series: ScalarSeries }) {
return (
<div>
<p className="mb-1 text-xs text-text-secondary">
{series.tag} · 最新原值 {series.points.at(-1)!.value.toPrecision(4)}
</p>
<ScalarChart series={[series]} smoothing={0.4} title={series.tag} xLabel="Iteration" />
</div>
);
});
export const TrainingMetricsPanel = memo(function TrainingMetricsPanel({
jobId,
logs,
}: {
jobId: string;
logs: readonly string[];
}) {
const [history] = useState(() => new TrainingMetricHistory());
const [series, setSeries] = useState<ScalarSeries[]>([]);
const [open, setOpen] = useState(false);
const [tab, setTab] = useState('value');
useEffect(() => {
if (history.update(jobId, logs)) setSeries(history.series());
}, [history, jobId, logs]);
const selected = series.filter(
(item) =>
tab === 'all' ||
item.tag === (tab === 'value' ? TRAINING_METRICS.value : TRAINING_METRICS.surrogate),
);
return (
<section className="mt-3 min-w-0 rounded-lg border border-border">
<button
type="button"
className="w-full p-2 text-left text-xs text-text-primary"
aria-expanded={open}
onClick={() => setOpen(!open)}
>
{open ? '▾' : '▸'} 训练指标趋势
</button>
{open && (
<div className="min-w-0 space-y-2 p-2 pt-0">
<div role="tablist" aria-label="训练指标" className="flex gap-2">
{[
['value', '价值损失'],
['surrogate', '策略损失'],
['all', '综合'],
].map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={tab === id}
className={`rounded px-2 py-1 text-xs ${tab === id ? 'bg-accent/10 text-accent' : 'text-text-secondary'}`}
onClick={() => setTab(id)}
>
{label}
</button>
))}
</div>
<div role="tabpanel" aria-label="训练指标曲线" className="space-y-2">
{selected.length ? (
selected.map((item) => <MetricChart key={item.tag} series={item} />)
) : (
<p className="text-xs text-text-tertiary">尚无带迭代编号的指标日志</p>
)}
</div>
<p className="text-[10px] text-text-tertiary">
各指标独立纵轴;EMA 0.4
仅用于曲线,悬停显示原值。最多保留最近500个有指标的迭代,重连仅恢复服务端日志尾部。
</p>
</div>
)}
</section>
);
});
@@ -0,0 +1,21 @@
import type { PretrainedSource } from './types';
/** A nonempty selection is an initialization intent, never a random-training fallback. */
export function pretrainedSelectionError(
sources: readonly PretrainedSource[] | undefined,
taskId: string,
selectedId: string,
): string | undefined {
if (!selectedId) return undefined;
const source = sources?.find((item) => item.id === selectedId);
const reason = !source
? '目录缺少该内容ID'
: !source.ready
? '来源未通过验证'
: !source.compatibleTasks.includes(taskId)
? '来源与当前任务不兼容'
: undefined;
return reason
? `所选基础策略已失效:${reason}。请明确选择其他有效基础策略,或选择“不选择(随机初始化)”;不会自动退回随机初始化。`
: undefined;
}
@@ -0,0 +1,18 @@
import { expect, it } from 'vitest';
import { trainingLosses } from './trainingLosses';
it('提取最新有限PPO损失,兼容无指标旧日志', () => {
expect(
trainingLosses([
'Mean value loss: 1.2',
'Mean surrogate loss: -0.03',
'Mean entropy loss: 1e-3',
'Mean value loss: 0.1',
'Mean value loss: Infinity',
]),
).toEqual([
{ label: '价值损失', value: 0.1 },
{ label: '策略损失', value: -0.03 },
{ label: '熵损失', value: 0.001 },
]);
expect(trainingLosses(['Starting training'])).toEqual([]);
});
@@ -0,0 +1,14 @@
/** rsl_rl console scalars, newest finite value per loss; old servers need no new endpoint. */
export function trainingLosses(logs: readonly string[]): { label: string; value: number }[] {
const values = new Map<string, number>();
for (const line of logs) {
const match = /Mean (value|surrogate|entropy) loss:\s*([-+\d.eE]+)/.exec(line);
if (match && Number.isFinite(Number(match[2]))) values.set(match[1], Number(match[2]));
}
const labels: Record<string, string> = {
value: '价值损失',
surrogate: '策略损失',
entropy: '熵损失',
};
return Array.from(values, ([key, value]) => ({ label: labels[key], value }));
}
+85 -10
View File
@@ -1,13 +1,75 @@
import type { PolicyDeployment, TrainingTerrain } from '../rl/deployment';
export interface TrainingParameter {
min: number;
max: number;
default: number;
integer?: boolean;
}
export interface TrainingTaskMetadata {
id: string;
name: string;
browserCompatible: boolean;
terrainPresets: string[];
terrainParameters: Record<string, TrainingParameter>;
sensorTypes: string[];
sensorModes?: string[];
sensorParameters: Record<string, TrainingParameter>;
mapSyncScope: string;
}
export type TrainingJobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
export type TrainingDevice = 'cpu' | 'gpu';
export type WandbMode = 'offline' | 'online' | 'disabled';
export interface PretrainedInitialization {
sourceId: string;
registeredId: string;
label: string;
manifest: {
source_iteration: number | null;
sourceFormat?: 'pt' | 'onnx';
contract?: string;
derived_fields?: {
normalizer_count?: { policy: string; value: number };
exploration_std?: { policy: string; value: number };
};
source_actor_dim: number;
normalization: string;
artifacts: { checkpoint: PretrainedArtifact } & Partial<
Record<'upload' | 'onnx' | 'env' | 'agent', PretrainedArtifact>
>;
};
}
export interface PretrainedArtifact {
name: string;
sha256: string;
bytes: number;
}
export interface PretrainedUploadCapability {
enabled: boolean;
templateId: 'go2-legacy47-v1';
formats: { pt: number; onnx: number };
endpoint: string;
}
export interface PretrainedSource {
id: string;
label: string;
ready: boolean;
compatibleTasks: string[];
observationSizes?: number[];
initialization?: PretrainedInitialization;
error?: string;
}
export interface TrainingServerInfo {
pretrainedSources?: PretrainedSource[];
pretrainedUpload?: PretrainedUploadCapability;
version: string;
ready: boolean;
trainerRoot: string;
python: string;
tasks: string[];
taskMetadata?: TrainingTaskMetadata[];
activeJobId?: string;
resourceOwner?: string;
tuning?: TuningCapability;
@@ -15,6 +77,11 @@ export interface TrainingServerInfo {
}
export interface TrainingRequest {
customTerrainBoxes?: TrainingTerrain;
terrainPreset?: string;
terrainParams?: Record<string, number>;
sensorType?: 'raycast';
sensorCfg?: Partial<import('../rl/deployment').ObstacleSensorConfig>;
taskId: string;
numEnvs: number;
maxIterations: number;
@@ -24,9 +91,12 @@ export interface TrainingRequest {
gpuIds: number[];
wandbMode: WandbMode;
rewardPresetId?: string;
pretrainedSourceId?: string;
}
export interface TrainingJob {
pretrained?: PretrainedInitialization;
deployment?: PolicyDeployment;
id: string;
state: TrainingJobState;
taskId: string;
@@ -60,16 +130,11 @@ export interface RewardConfiguration {
params: Record<string, number>;
}
export interface ObjectiveWeights {
velocity_tracking: number;
action_smoothness: number;
posture_stability: number;
fall_avoidance: number;
foot_slip: number;
energy: number;
}
export type ObjectiveWeights = Record<string, number>;
export interface TuningCapability {
pretrainedSources?: PretrainedSource[];
pretrainedUpload?: PretrainedUploadCapability;
ready: boolean;
configured: boolean;
apiKeyConfigured: boolean;
@@ -79,7 +144,12 @@ export interface TuningCapability {
}
export interface TuningCreateRequest {
taskId: 'Unitree-Go2-Flat';
pretrainedSourceId?: string;
taskId: 'Unitree-Go2-Flat' | 'Unitree-Go2-ObstacleAvoidance';
taskConfig?: Pick<
TrainingRequest,
'terrainPreset' | 'terrainParams' | 'sensorType' | 'sensorCfg' | 'customTerrainBoxes'
> & { seed?: number };
mode: TuningMode;
runName: string;
numEnvs: number;
@@ -160,7 +230,11 @@ export interface TuningSession {
mode: TuningMode;
createdAt: string;
updatedAt: string;
config: TuningCreateRequest & { rungs: number[]; promote: number[] };
config: TuningCreateRequest & {
rungs: number[];
promote: number[];
pretrained?: PretrainedInitialization;
};
objectiveWeights: ObjectiveWeights;
message: string;
currentTrialId?: string;
@@ -194,6 +268,7 @@ export interface TuningMetricsResponse {
export interface RewardPreset {
id: string;
taskId: string;
name: string;
sessionId: string;
trialId: string;
@@ -25,6 +25,7 @@ import {
formatMetric,
mergeRewardPatch,
OBJECTIVE_META,
OBSTACLE_OBJECTIVE_META,
PARAMETER_BY_PATH,
rewardConfigurationDiff,
} from './domain';
@@ -223,7 +224,9 @@ function ExpectedImpact({ proposal }: { proposal: TuningProposal }) {
return (
<div className="grid gap-1 sm:grid-cols-2">
{values.map(([key, value]) => {
const label = OBJECTIVE_META.find((item) => item.key === key)?.label ?? key;
const label =
[...OBJECTIVE_META, ...OBSTACLE_OBJECTIVE_META].find((item) => item.key === key)?.label ??
key;
return (
<div key={key} className="rounded border border-border bg-app px-2 py-1.5">
<p className="text-[8px] uppercase tracking-wider text-text-tertiary">{label}</p>
@@ -4,7 +4,7 @@ import uPlot from 'uplot';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Select } from '../components/ui';
import type { ScalarPoint, TuningTrial } from '../training/types';
import { formatMetric, OBJECTIVE_META } from './domain';
import { formatMetric, objectiveMeta } from './domain';
import { useTuningStore } from './tuningStore';
const LINE_COLORS = ['#60a5fa', '#a78bfa', '#f472b6', '#2dd4bf', '#fb7185', '#94a3b8'];
@@ -275,6 +275,7 @@ function MultiTrialPlot({
}
function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
const taskId = useTuningStore((state) => state.sessionConfig?.taskId);
const currentComponents = current?.evaluation?.score?.components ?? {};
const bestComponents = best?.evaluation?.score?.components ?? {};
return (
@@ -282,7 +283,11 @@ function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: Tunin
<header className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-xs font-semibold">Score Breakdown</h3>
<p className="mt-0.5 text-[9px] text-text-tertiary">相对基线改善 · 中线为 0</p>
<p className="mt-0.5 text-[9px] text-text-tertiary">
{taskId === 'Unitree-Go2-ObstacleAvoidance'
? '固定客观归一化 · 0–1(越高越好)'
: '相对基线改善 · 中线为 0'}
</p>
</div>
{best && (
<Badge tone="warning">
@@ -291,7 +296,7 @@ function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: Tunin
)}
</header>
<div className="grid gap-x-4 gap-y-2 lg:grid-cols-2">
{OBJECTIVE_META.map(({ key, label }) => {
{objectiveMeta(taskId).map(({ key, label }) => {
const currentValue = currentComponents[key] ?? 0;
const bestValue = bestComponents[key] ?? 0;
const currentWidth = Math.min(50, Math.abs(currentValue) * 50);
@@ -332,15 +337,20 @@ function ScoreBreakdown({ current, best }: { current?: TuningTrial; best?: Tunin
}
function MetricKpis({ current, best }: { current?: TuningTrial; best?: TuningTrial }) {
const taskId = useTuningStore((state) => state.sessionConfig?.taskId);
const currentMetrics = current?.evaluation?.metrics ?? {};
const bestMetrics = best?.evaluation?.metrics ?? {};
return (
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 xl:grid-cols-6">
{OBJECTIVE_META.map(({ key, shortLabel, metric }) => {
{objectiveMeta(taskId).map(({ key, shortLabel, metric }) => {
const currentValue = currentMetrics[metric];
const bestValue = bestMetrics[metric];
const worse =
currentValue !== undefined && bestValue !== undefined && currentValue > bestValue;
currentValue !== undefined &&
bestValue !== undefined &&
(taskId === 'Unitree-Go2-ObstacleAvoidance'
? currentValue < bestValue
: currentValue > bestValue);
return (
<div key={key} className="rounded-lg border border-border bg-surface px-2.5 py-2">
<p className="truncate text-[9px] uppercase tracking-wider text-text-tertiary">
+1 -205
View File
@@ -1,205 +1 @@
import { useEffect, useMemo, useRef } from 'react';
import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react';
import uPlot from 'uplot';
import type { ScalarSeries } from '../training/types';
const COLORS = ['#38d39f', '#60a5fa', '#f59e0b', '#f472b6', '#a78bfa', '#fb7185'];
function smooth(values: (number | null)[], factor: number): (number | null)[] {
if (factor <= 0) return values;
let previous: number | undefined;
return values.map((value) => {
if (value === null) return null;
previous = previous === undefined ? value : factor * previous + (1 - factor) * value;
return previous;
});
}
export function ScalarChart({
series,
smoothing,
title = '训练与评估 Scalars',
}: {
series: ScalarSeries[];
smoothing: number;
title?: string;
}) {
const host = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
const trackZoom = useRef(false);
const zoomRanges = useRef<Partial<Record<'x' | 'y', { min: number; max: number }>>>({});
const prepared = useMemo(() => {
const steps = Array.from(
new Set(series.flatMap((item) => item.points.map((point) => point.step))),
).sort((a, b) => a - b);
const columns: uPlot.AlignedData = [steps];
for (const item of series) {
const byStep = new Map(item.points.map((point) => [point.step, point.value]));
columns.push(
smooth(
steps.map((step) => byStep.get(step) ?? null),
smoothing,
),
);
}
return columns;
}, [series, smoothing]);
useEffect(() => {
if (!host.current || series.length === 0 || prepared[0].length === 0) return;
const element = host.current;
const width = Math.max(280, Math.floor(element.getBoundingClientRect().width));
trackZoom.current = false;
const chart = new uPlot(
{
width,
height: 280,
cursor: { drag: { x: true, y: true, setScale: true } },
scales: { x: { time: false } },
hooks: {
setScale: [
(instance, key) => {
if (!trackZoom.current || (key !== 'x' && key !== 'y')) return;
const scale = instance.scales[key];
if (typeof scale.min === 'number' && typeof scale.max === 'number')
zoomRanges.current[key] = { min: scale.min, max: scale.max };
},
],
},
axes: [
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
{ stroke: '#8fa0b5', grid: { stroke: '#213044' } },
],
series: [
{ label: 'Step' },
...series.map((item, index) => ({
label: item.tag,
stroke: COLORS[index % COLORS.length],
width: 2,
spanGaps: true,
})),
],
},
prepared,
element,
);
chartRef.current = chart;
trackZoom.current = true;
for (const key of ['x', 'y'] as const) {
const range = zoomRanges.current[key];
if (range) chart.setScale(key, range);
}
const wheelZoom = (event: WheelEvent) => {
if (!event.deltaY) return;
event.preventDefault();
event.stopPropagation();
const bounds = chart.over.getBoundingClientRect();
if (!bounds.width || !bounds.height) return;
const xRatio = Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width));
const yRatio = Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height));
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
const factor = Math.min(2, Math.max(0.5, Math.exp(event.deltaY * unit * 0.002)));
for (const key of ['x', 'y'] as const) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const ratio = key === 'x' ? xRatio : 1 - yRatio;
const anchor = scale.min + (scale.max - scale.min) * ratio;
chart.setScale(key, {
min: anchor - (anchor - scale.min) * factor,
max: anchor + (scale.max - anchor) * factor,
});
}
};
chart.over.addEventListener('wheel', wheelZoom, { passive: false });
let frame = 0;
let lastWidth = width;
const observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(() => {
const nextWidth = Math.max(280, Math.floor(element.getBoundingClientRect().width));
if (nextWidth !== lastWidth) {
lastWidth = nextWidth;
chart.setSize({ width: nextWidth, height: 280 });
}
});
});
observer.observe(element);
return () => {
observer.disconnect();
chart.over.removeEventListener('wheel', wheelZoom);
window.cancelAnimationFrame(frame);
trackZoom.current = false;
chartRef.current = null;
chart.destroy();
};
}, [prepared, series]);
const zoom = (factor: number) => {
const chart = chartRef.current;
if (!chart) return;
for (const key of ['x', 'y']) {
const scale = chart.scales[key];
if (typeof scale.min !== 'number' || typeof scale.max !== 'number') continue;
const center = (scale.min + scale.max) / 2;
const radius = ((scale.max - scale.min) * factor) / 2 || 1;
chart.setScale(key, { min: center - radius, max: center + radius });
}
};
const resetZoom = () => {
const chart = chartRef.current;
if (!chart) return;
zoomRanges.current = {};
trackZoom.current = false;
chart.setData(chart.data, true);
trackZoom.current = true;
};
if (series.length === 0)
return (
<div className="grid h-[280px] place-items-center rounded-lg border border-border bg-app text-xs text-text-tertiary">
当前 trial 尚无 scalar 数据
</div>
);
return (
<section className="min-w-0 overflow-hidden rounded-lg border border-border bg-app">
<header className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<h3 className="min-w-0 truncate text-[10px] font-semibold" title={title}>
{title}
</h3>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
aria-label={`${title} 放大`}
title="放大"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(0.7)}
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 缩小`}
title="缩小"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={() => zoom(1.4)}
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<button
type="button"
aria-label={`${title} 重置缩放`}
title="重置缩放"
className="rounded p-1 text-text-tertiary hover:bg-element-hover hover:text-text-primary"
onClick={resetZoom}
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
</header>
<div ref={host} className="min-w-0 w-full overflow-hidden" />
<p className="border-t border-border px-3 py-1.5 text-[9px] text-text-tertiary">
图表区域滚轮以指针位置缩放;也可拖拽框选,或使用右上角按钮缩放和复位。
</p>
</section>
);
}
export { ScalarChart } from '../components/charts/ScalarChart';
+155 -12
View File
@@ -3,9 +3,11 @@ import { Bot, FlaskConical, Play, RefreshCw, Wifi } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Select } from '../components/ui';
import { LocalTrainingClient } from '../training/LocalTrainingClient';
import { PretrainedSourceSelect } from '../training/PretrainedSourceSelect';
import { pretrainedSelectionError } from '../training/pretrainedSelection';
import { rememberTrainingConnection } from '../training/storage';
import type { ObjectiveWeights, TuningCreateRequest, TuningMode } from '../training/types';
import { OBJECTIVE_META, STATE_META } from './domain';
import { objectiveMeta, OBSTACLE_OBJECTIVES, STATE_META } from './domain';
import { TuningConsole } from './TuningConsole';
import { useTuningStore } from './tuningStore';
@@ -32,13 +34,24 @@ export function TuningApp() {
] as const,
),
);
const [trainingContext, setTrainingContext] =
useState<Pick<TuningCreateRequest, 'taskId' | 'taskConfig' | 'seed' | 'pretrainedSourceId'>>();
const [testingAgent, setTestingAgent] = useState(false);
useEffect(() => {
const receive = (event: MessageEvent) => {
if (event.origin !== location.origin || typeof event.data !== 'object') return;
const data = event.data as { type?: string; endpoint?: string; token?: string };
const data = event.data as {
type?: string;
endpoint?: string;
token?: string;
trainingContext?: Pick<
TuningCreateRequest,
'taskId' | 'taskConfig' | 'seed' | 'pretrainedSourceId'
>;
};
if (data.type === 'mujoco-tuning-credentials' && data.endpoint && data.token) {
setTrainingContext(data.trainingContext);
useTuningStore.getState().setConnection(data.endpoint, data.token);
rememberTrainingConnection(data.endpoint, data.token);
}
@@ -129,7 +142,11 @@ export function TuningApp() {
</section>
)}
{sessionId ? <TuningConsole /> : <NewSessionView />}
{sessionId ? (
<TuningConsole />
) : (
<NewSessionView key={JSON.stringify(trainingContext)} trainingContext={trainingContext} />
)}
{error && (
<button
@@ -146,21 +163,72 @@ export function TuningApp() {
);
}
function NewSessionView() {
function NewSessionView({
trainingContext,
}: {
trainingContext?: Pick<
TuningCreateRequest,
'taskId' | 'taskConfig' | 'seed' | 'pretrainedSourceId'
>;
}) {
const [taskId, setTaskId] = useState<TuningCreateRequest['taskId']>(
trainingContext?.taskId ?? 'Unitree-Go2-Flat',
);
const [taskConfigText, setTaskConfigText] = useState(
JSON.stringify(
trainingContext?.taskConfig ?? {
terrainPreset: 'discrete_obstacles',
sensorType: 'raycast',
sensorCfg: { avoidanceWeight: 2 },
},
null,
2,
),
);
const obstacle = taskId === 'Unitree-Go2-ObstacleAvoidance';
const [mode, setMode] = useState<TuningMode>('approval');
const [pretrainedSourceId, setPretrainedSourceId] = useState(
trainingContext?.pretrainedSourceId ?? '',
);
const [runName, setRunName] = useState('go2-auto-tune');
const [numEnvs, setNumEnvs] = useState(4096);
const [trialCount, setTrialCount] = useState(12);
const [gpuIds, setGpuIds] = useState('0');
const [fallbackEnabled, setFallbackEnabled] = useState(false);
const [objectives, setObjectives] = useState<ObjectiveWeights>(DEFAULT_OBJECTIVES);
const [sessions, connectionState, capability] = useTuningStore(
useShallow((state) => [state.sessions, state.connectionState, state.capability] as const),
const [objectives, setObjectives] = useState<ObjectiveWeights>(
trainingContext?.taskId === 'Unitree-Go2-ObstacleAvoidance'
? OBSTACLE_OBJECTIVES
: DEFAULT_OBJECTIVES,
);
const [sessions, connectionState, capability, endpoint, token, connectionRevision] =
useTuningStore(
useShallow(
(state) =>
[
state.sessions,
state.connectionState,
state.capability,
state.endpoint,
state.token,
state.connectionRevision,
] as const,
),
);
const [uploading, setUploading] = useState(false);
const busy = connectionState === 'connecting';
const sourceSelectionError = pretrainedSelectionError(
capability?.pretrainedSources,
taskId,
pretrainedSourceId,
);
const objectiveTotal = Object.values(objectives).reduce((sum, value) => sum + value, 0);
const start = async () => {
if (uploading) return;
if (sourceSelectionError) {
useTuningStore.setState({ error: sourceSelectionError });
return;
}
const ids = gpuIds
.split(/[\s,]+/)
.filter(Boolean)
@@ -169,12 +237,21 @@ function NewSessionView() {
useTuningStore.setState({ error: 'GPU 编号必须是非负整数' });
return;
}
let taskConfig: TuningCreateRequest['taskConfig'];
try {
if (obstacle) taskConfig = JSON.parse(taskConfigText) as TuningCreateRequest['taskConfig'];
} catch {
useTuningStore.setState({ error: '场景配置必须是合法JSON' });
return;
}
const request: TuningCreateRequest = {
taskId: 'Unitree-Go2-Flat',
taskId,
...(obstacle ? { taskConfig } : {}),
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
mode,
runName,
numEnvs,
seed: 42,
seed: trainingContext?.seed ?? 42,
gpuIds: ids,
trialCount,
initialIterations: 300,
@@ -194,7 +271,7 @@ function NewSessionView() {
<section className="rounded-xl border border-border bg-surface p-5 shadow-[0_24px_70px_rgb(0_0_0/0.18)]">
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<h2 className="text-sm font-semibold">新建 Unitree-Go2-Flat 调参 Session</h2>
<h2 className="text-sm font-semibold">新建 {taskId} 调参 Session</h2>
<p className="mt-1 text-[10px] text-text-tertiary">
白名单 Reward Patch · 3-seed 固定协议评估 · 非破坏性 Preset
</p>
@@ -202,6 +279,67 @@ function NewSessionView() {
<Badge tone="accent">RTX 5080 Profile</Badge>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<Field label="调参任务">
<Select
aria-label="调参任务"
value={taskId}
onChange={(event) => {
const task = event.target.value as TuningCreateRequest['taskId'];
setTaskId(task);
setObjectives(
task === 'Unitree-Go2-ObstacleAvoidance'
? OBSTACLE_OBJECTIVES
: DEFAULT_OBJECTIVES,
);
}}
>
<option value="Unitree-Go2-Flat">Flat 速度跟踪</option>
<option value="Unitree-Go2-ObstacleAvoidance">Obstacle 前视射线目标导航</option>
</Select>
</Field>
<PretrainedSourceSelect
sources={capability?.pretrainedSources}
taskId={taskId}
value={pretrainedSourceId}
onChange={setPretrainedSourceId}
disabled={busy || uploading}
upload={{
endpoint,
token,
revision: connectionRevision,
enabled:
connectionState === 'ready' && Boolean(capability?.pretrainedUpload?.enabled),
onBusyChange: setUploading,
onUploaded: (source) => {
useTuningStore.setState((state) => ({
capability: state.capability && {
...state.capability,
pretrainedSources: [
...(state.capability.pretrainedSources ?? []).filter(
(s) => s.id !== source.id,
),
source,
],
},
}));
setPretrainedSourceId(source.id);
},
}}
/>
{obstacle && (
<Field label="避障场景配置JSON(terrain/sensor)">
<textarea
aria-label="避障场景配置JSON"
className="field w-full text-xs"
rows={6}
value={taskConfigText}
onChange={(e) => setTaskConfigText(e.target.value)}
/>
<p className="text-xs">
32ray存在侧后/高度盲区;固定3seed、1000步;custom固定原地图与起终点,不代表三地形泛化。target_velocity为导航速度而非奖励。
</p>
</Field>
)}
<Field label="运行名称">
<input
aria-label="运行名称"
@@ -251,7 +389,9 @@ function NewSessionView() {
</p>
<div className="mb-2 mt-5 flex items-center justify-between">
<h3 className="text-xs font-semibold">六维目标权重</h3>
<h3 className="text-xs font-semibold">
{obstacle ? '固定五维客观评估权重' : '六维目标权重'}
</h3>
<span
className={`font-mono text-[10px] ${Math.abs(objectiveTotal - 1) < 1e-6 ? 'text-success' : 'text-danger'}`}
>
@@ -259,7 +399,7 @@ function NewSessionView() {
</span>
</div>
<div className="grid gap-2.5 sm:grid-cols-2">
{OBJECTIVE_META.map(({ key, label }) => (
{objectiveMeta(taskId).map(({ key, label }) => (
<label
key={key}
className="grid grid-cols-[88px_1fr_38px] items-center gap-2 rounded border border-border bg-app px-2 py-2 text-[9px] text-text-secondary"
@@ -268,6 +408,7 @@ function NewSessionView() {
<input
className="control-slider"
type="range"
disabled={obstacle}
min="0"
max="1"
step="0.05"
@@ -287,6 +428,8 @@ function NewSessionView() {
icon={<Play className="h-4 w-4" />}
disabled={
busy ||
uploading ||
Boolean(sourceSelectionError) ||
(!capability?.configured && !fallbackEnabled) ||
Math.abs(objectiveTotal - 1) > 1e-6 ||
!runName.trim()
@@ -1,3 +1,5 @@
import { PretrainedIdentity } from '../training/PretrainedSourceSelect';
import { useTuningStore } from './tuningStore';
import { AgentDecisionTimeline } from './AgentDecisionTimeline';
import { MetricsComparisonBoard } from './MetricsComparisonBoard';
import { TuningControlToolbar } from './TuningControlToolbar';
@@ -7,9 +9,13 @@ import { useTuningPolling } from './useTuningPolling';
export function TuningConsole() {
useTuningPolling();
const pretrained = useTuningStore((state) => state.sessionConfig?.pretrained);
return (
<div className="flex min-h-0 flex-1 flex-col">
<TuningControlToolbar />
<div className="px-3">
<PretrainedIdentity source={pretrained} />
</div>
<div className="grid grid-cols-1 xl:min-h-0 xl:flex-1 xl:grid-cols-[286px_minmax(520px,1fr)_390px]">
<TuningSessionRail />
<main className="h-[920px] min-w-0 overflow-auto bg-app p-3 panel-scroll xl:h-auto">
@@ -13,7 +13,7 @@ import {
import { useShallow } from 'zustand/react/shallow';
import { Badge, Button, Dialog, Select } from '../components/ui';
import type { ParameterConstraint, RewardConfiguration } from '../training/types';
import { ACTIVE_SESSION_STATES, PARAMETER_DEFINITIONS, STATE_META, parameterValue } from './domain';
import { ACTIVE_SESSION_STATES, parameterDefinitions, STATE_META, parameterValue } from './domain';
import { useTuningStore } from './tuningStore';
const RewardConfigDiffEditor = lazy(() =>
@@ -32,9 +32,10 @@ interface ConstraintDraft {
function createDrafts(
constraints: Record<string, ParameterConstraint>,
current: RewardConfiguration | undefined,
taskId?: string,
): Record<string, ConstraintDraft> {
return Object.fromEntries(
PARAMETER_DEFINITIONS.map((definition) => {
parameterDefinitions(taskId).map((definition) => {
const constraint = constraints[definition.path];
const value = parameterValue(current, definition.path);
if (constraint?.kind === 'range')
@@ -72,9 +73,10 @@ function createDrafts(
function serializeConstraints(
drafts: Record<string, ConstraintDraft>,
taskId?: string,
): Record<string, ParameterConstraint> {
const constraints: Record<string, ParameterConstraint> = {};
for (const definition of PARAMETER_DEFINITIONS) {
for (const definition of parameterDefinitions(taskId)) {
const draft = drafts[definition.path];
if (!draft || draft.mode === 'free') continue;
if (draft.mode === 'fixed') {
@@ -146,30 +148,31 @@ function ParameterConstraintDialog({
close(): void;
current?: RewardConfiguration;
}) {
const taskId = useTuningStore((state) => state.sessionConfig?.taskId);
const constraints = useTuningStore((state) => state.parameterConstraints);
const revision = useTuningStore((state) => state.constraintsRevision);
const effectiveAfterCurrent = useTuningStore((state) => state.constraintsEffectiveAfterCurrent);
const busy = useTuningStore((state) => state.busyOperations.includes('constraints'));
const [drafts, setDrafts] = useState(() => createDrafts(constraints, current));
const [drafts, setDrafts] = useState(() => createDrafts(constraints, current, taskId));
const [query, setQuery] = useState('');
const [problem, setProblem] = useState<string>();
const filtered = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return PARAMETER_DEFINITIONS;
return PARAMETER_DEFINITIONS.filter(
if (!normalized) return parameterDefinitions(taskId);
return parameterDefinitions(taskId).filter(
(definition) =>
definition.label.toLowerCase().includes(normalized) ||
definition.path.toLowerCase().includes(normalized),
);
}, [query]);
}, [query, taskId]);
const update = (path: string, patch: Partial<ConstraintDraft>) =>
setDrafts((value) => ({ ...value, [path]: { ...value[path], ...patch } }));
const save = async () => {
try {
const value = serializeConstraints(drafts);
const value = serializeConstraints(drafts, taskId);
setProblem(undefined);
await useTuningStore.getState().saveParameterConstraints(value);
if (!useTuningStore.getState().error) close();
@@ -152,7 +152,12 @@ export function TuningSessionRail() {
const opener = window.opener;
if (opener && !opener.closed) {
opener.postMessage(
{ type: 'mujoco-tuning-import-policy', sessionId, policy },
{
type: 'mujoco-tuning-import-policy',
sessionId,
policy,
taskId: useTuningStore.getState().sessionConfig?.taskId,
},
location.origin,
);
setPolicyImportState('sent');
+30 -2
View File
@@ -93,8 +93,36 @@ export const PARAMETER_DEFINITIONS: readonly ParameterDefinition[] = [
parameter('stand_still.command_threshold', '静止命令阈值', 0.02, 0.3, 0.1),
] as const;
export const OBSTACLE_PARAMETERS: readonly ParameterDefinition[] = [
weight('avoidance_weight', '近障避让', 0.5, 5, 2, false),
weight('collision_penalty', '非法接触惩罚', -10, -0.5, -5, false),
weight('action_smoothness', '动作平滑', -0.05, -0.001, -0.05, false),
parameter('target_velocity', '目标导航速度(m/s,非奖励)', 0.3, 1.2, 0.6),
];
export const parameterDefinitions = (taskId?: string) =>
taskId === 'Unitree-Go2-ObstacleAvoidance' ? OBSTACLE_PARAMETERS : PARAMETER_DEFINITIONS;
export const OBSTACLE_OBJECTIVES: ObjectiveWeights = {
success: 0.4,
time: 0.2,
clearance: 0.2,
smooth: 0.1,
no_fall: 0.1,
};
export const OBSTACLE_OBJECTIVE_META = [
{ key: 'success', label: '无碰撞到达率', shortLabel: '成功', metric: 'success' },
{ key: 'time', label: '成功到达时间', shortLabel: '时间', metric: 'time' },
{ key: 'clearance', label: '障碍净距', shortLabel: '净距', metric: 'clearance' },
{ key: 'smooth', label: '动作平滑', shortLabel: '平滑', metric: 'smooth' },
{ key: 'no_fall', label: '不跌倒', shortLabel: '防跌', metric: 'no_fall' },
];
export const objectiveMeta = (taskId?: string) =>
taskId === 'Unitree-Go2-ObstacleAvoidance' ? OBSTACLE_OBJECTIVE_META : OBJECTIVE_META;
export const PARAMETER_BY_PATH = new Map(
PARAMETER_DEFINITIONS.map((definition) => [definition.path, definition]),
[...PARAMETER_DEFINITIONS, ...OBSTACLE_PARAMETERS].map((definition) => [
definition.path,
definition,
]),
);
export const OBJECTIVE_META: ReadonlyArray<{
@@ -134,7 +162,7 @@ export const OBJECTIVE_META: ReadonlyArray<{
export const STATE_META = {
queued: { label: '分析排队', phase: 0, tone: 'neutral' },
running: { label: '策略训练中', phase: 1, tone: 'accent' },
evaluating: { label: '固定步态评估中', phase: 2, tone: 'warning' },
evaluating: { label: '固定协议评估中', phase: 2, tone: 'warning' },
awaiting_approval: { label: '等待人工审批', phase: 3, tone: 'warning' },
paused: { label: '安全暂停', phase: 3, tone: 'neutral' },
interrupted: { label: '服务已中断', phase: 3, tone: 'warning' },
@@ -0,0 +1,93 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TuningApp } from './TuningApp';
import { useTuningStore } from './tuningStore';
import { OBSTACLE_OBJECTIVES, parameterDefinitions, objectiveMeta } from './domain';
beforeEach(() => {
useTuningStore.setState({
sessionId: undefined,
sessions: [],
error: undefined,
capability: {
ready: true,
configured: true,
apiKeyConfigured: true,
frameworkInstalled: true,
model: 'mock',
baseUrl: 'https://example.invalid',
},
});
});
describe('Obstacle tuning task contract', () => {
it('绑定专属范围与客观权重,不把目标速度当奖励', () => {
const definitions = parameterDefinitions('Unitree-Go2-ObstacleAvoidance');
expect(definitions.map((d) => [d.path, d.minimum, d.maximum])).toEqual([
['weights.avoidance_weight', 0.5, 5],
['weights.collision_penalty', -10, -0.5],
['weights.action_smoothness', -0.05, -0.001],
['params.target_velocity', 0.3, 1.2],
]);
expect(parameterDefinitions('Unitree-Go2-Flat').some((d) => d.key === 'target_velocity')).toBe(
false,
);
expect(objectiveMeta('Unitree-Go2-ObstacleAvoidance').map((d) => d.key)).toEqual(
Object.keys(OBSTACLE_OBJECTIVES),
);
});
it('选择任务携带场景配置,固定权重,Approval/Automatic均保留', async () => {
const start = vi.spyOn(useTuningStore.getState(), 'startSession').mockResolvedValue();
render(<TuningApp />);
fireEvent.change(screen.getByLabelText('调参任务'), {
target: { value: 'Unitree-Go2-ObstacleAvoidance' },
});
expect(screen.getAllByRole('slider')).toHaveLength(5);
for (const slider of screen.getAllByRole('slider')) expect(slider).toBeDisabled();
fireEvent.change(screen.getByLabelText('避障场景配置JSON'), {
target: {
value: JSON.stringify({
terrainPreset: 'wave',
sensorType: 'raycast',
sensorCfg: { fov: 60, avoidanceWeight: 3 },
}),
},
});
fireEvent.change(screen.getByLabelText('Agent 决策模式'), { target: { value: 'automatic' } });
fireEvent.change(screen.getByLabelText('Agent 决策模式'), { target: { value: 'approval' } });
fireEvent.click(screen.getByRole('button', { name: /启动|开始|创建/ }));
await waitFor(() => expect(start).toHaveBeenCalled());
expect(start.mock.calls[0][0]).toMatchObject({
taskId: 'Unitree-Go2-ObstacleAvoidance',
mode: 'approval',
evalSteps: 1000,
objectiveWeights: OBSTACLE_OBJECTIVES,
taskConfig: { terrainPreset: 'wave', sensorCfg: { fov: 60, avoidanceWeight: 3 } },
});
start.mockRestore();
});
it('主面板消息绑定任务/seed及权威custom配置,不默认回Flat', async () => {
render(<TuningApp />);
const taskConfig = {
terrainPreset: 'custom_boxes',
customTerrainBoxes: { format: 'boxes-v1' },
};
await act(async () =>
window.dispatchEvent(
new MessageEvent('message', {
origin: location.origin,
data: {
type: 'mujoco-tuning-credentials',
endpoint: 'http://localhost:8767',
token: 'mock',
trainingContext: { taskId: 'Unitree-Go2-ObstacleAvoidance', seed: 7, taskConfig },
},
}),
),
);
expect(screen.getByLabelText('调参任务')).toHaveValue('Unitree-Go2-ObstacleAvoidance');
expect(screen.getByLabelText('避障场景配置JSON')).toHaveValue(
JSON.stringify(taskConfig, null, 2),
);
});
});
+19 -4
View File
@@ -73,6 +73,7 @@ export interface SessionSlice {
token: string;
capability?: TuningCapability;
connectionState: 'idle' | 'connecting' | 'ready' | 'error';
connectionRevision: number;
sessions: TuningSession[];
sessionId?: string;
sessionState?: TuningSessionState;
@@ -164,6 +165,7 @@ function createSessionSlice(set: Parameters<Slice<SessionSlice>>[0], get: () =>
endpoint: localStored(TRAINING_ENDPOINT_KEY, DEFAULT_TRAINING_ENDPOINT),
token: sessionStored(TRAINING_TOKEN_KEY),
connectionState: 'idle' as const,
connectionRevision: 0,
sessions: [],
sessionMessage: '',
consecutiveNoImprove: 0,
@@ -173,22 +175,35 @@ function createSessionSlice(set: Parameters<Slice<SessionSlice>>[0], get: () =>
proposalsById: {},
proposalIds: [],
audit: [],
setConnection: (endpoint: string, token: string) => set({ endpoint, token }),
setConnection: (endpoint: string, token: string) =>
set((state) => ({
endpoint,
token,
capability: undefined,
connectionState: 'idle',
connectionRevision: state.connectionRevision + 1,
})),
connect: async () => {
set({ connectionState: 'connecting', error: undefined });
const revision = get().connectionRevision + 1;
set({ connectionState: 'connecting', connectionRevision: revision, error: undefined });
try {
const api = clientFrom(get());
const [capability, sessions] = await Promise.all([
api.tuningCapability(),
api.tuningSessions(),
]);
if (get().connectionRevision !== revision) return;
rememberTrainingConnection(api.endpoint, api.token);
set({ capability, sessions, connectionState: 'ready' });
const remembered = localStored(TUNING_SESSION_KEY);
const target = sessions.find((item) => item.id === remembered) ?? sessions[0];
if (target) apply(await api.tuningSession(target.id));
if (target) {
const session = await api.tuningSession(target.id);
if (get().connectionRevision === revision) apply(session);
}
} catch (value) {
set({ connectionState: 'error', error: errorText(value) });
if (get().connectionRevision === revision)
set({ connectionState: 'error', error: errorText(value) });
}
},
openSession: async (id: string) => {
@@ -0,0 +1,36 @@
import { expect, it, vi } from 'vitest';
import * as THREE from 'three';
import { MuJoCoViewer } from './MuJoCoViewer';
import type { PerceptionRay } from '../rl/tasks/go2ObstacleAvoidance';
it('感知线32→48→32重建buffer并dispose,最后一层不截断,隐藏不分配', () => {
const rays = (n: number): PerceptionRay[] =>
Array.from({ length: n }, (_, i) => ({ origin: [0, 0, 0], end: [i, 1, 2], hit: true }));
const session = { perceptionRays: vi.fn(() => rays(32)) };
const viewer = Object.assign(Object.create(MuJoCoViewer.prototype), {
session,
scene: new THREE.Scene(),
showPerceptionRays: true,
}) as {
updatePerceptionLines(): void;
showPerceptionRays: boolean;
perceptionLines: THREE.LineSegments<THREE.BufferGeometry, THREE.LineBasicMaterial>;
};
viewer.updatePerceptionLines();
const old = viewer.perceptionLines,
geometryDispose = vi.spyOn(old.geometry, 'dispose'),
materialDispose = vi.spyOn(old.material, 'dispose');
session.perceptionRays.mockReturnValue(rays(48));
viewer.updatePerceptionLines();
expect(geometryDispose).toHaveBeenCalledOnce();
expect(materialDispose).toHaveBeenCalledOnce();
expect(viewer.perceptionLines.geometry.getAttribute('position').count).toBe(96);
expect(viewer.perceptionLines.geometry.getAttribute('position').getX(95)).toBe(47);
session.perceptionRays.mockReturnValue(rays(32));
viewer.updatePerceptionLines();
expect(viewer.perceptionLines.geometry.getAttribute('position').count).toBe(64);
viewer.showPerceptionRays = false;
viewer.updatePerceptionLines();
expect(viewer.perceptionLines.visible).toBe(false);
viewer.perceptionLines.geometry.dispose();
viewer.perceptionLines.material.dispose();
});
+104
View File
@@ -1,4 +1,5 @@
import * as THREE from 'three';
import { NavigationGoal } from './NavigationGoal';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import type { MjvGeom, MjvOption, MjvCamera, MjvScene } from '@mujoco/mujoco';
import type {
@@ -37,6 +38,8 @@ interface ViewerCallbacks {
onSelection(selection: ViewerSelection | null): void;
onFrame(frame: FrameResult, fps: number, snapshot?: SimulationSnapshot): void;
onError(error: Error): void;
onNavigationTarget?(target: [number, number]): void;
onNavigationMode?(active: boolean): void;
onMapEditorSelect(id: string | null): void;
onMapEditorPreviewSelect(mapAssetId: string, objectId: string): void;
onParametricMapSelect(id: string | null): void;
@@ -63,6 +66,8 @@ class CapsuleGeometry extends THREE.BufferGeometry {
}
export class MuJoCoViewer {
private readonly navigationGoal: NavigationGoal;
private readonly trainingTerrainIds = new Set<number>();
readonly scene = new THREE.Scene();
readonly camera = new THREE.PerspectiveCamera(45, 1, 0.01, 2_000);
private readonly sensorCamera = new THREE.PerspectiveCamera(45, 4 / 3, 0.01, 2_000);
@@ -76,6 +81,8 @@ export class MuJoCoViewer {
private showVisualMap = true;
private showMapCollision = false;
private showSensorCamera = true;
private showPerceptionRays = true;
private perceptionLines?: THREE.LineSegments<THREE.BufferGeometry, THREE.LineBasicMaterial>;
private sensorCameraId = -1;
private modelExtent = 2;
private session: SimulationSession | null = null;
@@ -155,6 +162,21 @@ export class MuJoCoViewer {
host.append(this.renderer.domElement);
this.camera.up.set(0, 0, 1);
this.camera.position.set(3, -3, 2);
this.navigationGoal = new NavigationGoal(this.scene, this.camera, this.renderer.domElement, {
navigation: () => this.session?.navigationStatus(),
terrain: () =>
this.meshes.filter((mesh) => this.trainingTerrainIds.has(Number(mesh.userData.geomId))),
excluded: (event) => {
if (!this.showSensorCamera || this.sensorCameraId < 0) return false;
const bounds = this.renderer.domElement.getBoundingClientRect();
const width = Math.max(120, Math.min(320, bounds.width * 0.32));
const x = event.clientX - bounds.left,
y = bounds.bottom - event.clientY;
return x >= 16 && x <= 16 + width && y >= 16 && y <= 16 + (width * 3) / 4;
},
onTarget: (target) => this.callbacks.onNavigationTarget?.(target),
onMode: (active) => this.callbacks.onNavigationMode?.(active),
});
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.addEventListener('start', () => {
@@ -233,6 +255,19 @@ export class MuJoCoViewer {
this.snapshotDirty = false;
this.session = session;
if (!session) return;
for (let id = 0; id < session.model.ngeom; id++) {
const geom = session.model.geom(id);
try {
if (
geom.name.startsWith('__training_terrain_') &&
Number(session.model.geom_bodyid[id]) === 0 &&
Number(session.model.geom_group[id]) === 2
)
this.trainingTerrainIds.add(id);
} finally {
geom.delete();
}
}
this.option = new session.module.MjvOption();
session.module.mjv_defaultOption(this.option);
this.applyGeomVisibility();
@@ -252,7 +287,12 @@ export class MuJoCoViewer {
this.fitCamera(session);
this.visualizationHelpers.attach(session, this.modelExtent);
}
setNavigationTargetMode(active: boolean): void {
this.stopDrag();
this.navigationGoal.setActive(active);
}
setMode(mode: InteractionMode): void {
this.navigationGoal.setActive(false);
this.mode = mode;
if (mode !== 'select') {
this.mapEditorLayer.selectObject(null, true);
@@ -344,6 +384,9 @@ export class MuJoCoViewer {
this.visualMapLayer.visible = showVisual && this.displayOptions.showVisual;
this.applyGeomVisibility();
}
setShowPerceptionRays(value: boolean): void {
this.showPerceptionRays = value;
}
setShowSensorCamera(value: boolean): void {
this.showSensorCamera = value;
}
@@ -566,6 +609,8 @@ export class MuJoCoViewer {
this.updateJointMarker();
this.visualizationHelpers.update();
}
this.updatePerceptionLines();
this.navigationGoal.update(now);
this.renderer.setScissorTest(false);
this.renderer.render(this.scene, this.camera);
if (this.showSensorCamera && this.updateSensorCamera()) this.renderSensorCamera();
@@ -591,6 +636,56 @@ export class MuJoCoViewer {
this.frame = requestAnimationFrame(this.animate);
};
private updatePerceptionLines(): void {
const rays = this.session?.perceptionRays() ?? [];
if (!this.showPerceptionRays || !rays.length) {
if (this.perceptionLines) this.perceptionLines.visible = false;
return;
}
if (
this.perceptionLines &&
this.perceptionLines.geometry.getAttribute('position').count !== rays.length * 2
) {
this.scene.remove(this.perceptionLines);
this.perceptionLines.geometry.dispose();
this.perceptionLines.material.dispose();
this.perceptionLines = undefined;
}
if (!this.perceptionLines) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute(
'position',
new THREE.BufferAttribute(new Float32Array(rays.length * 6), 3),
);
geometry.setAttribute(
'color',
new THREE.BufferAttribute(new Float32Array(rays.length * 6), 3),
);
this.perceptionLines = new THREE.LineSegments(
geometry,
new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
opacity: 0.55,
depthWrite: false,
}),
);
this.perceptionLines.frustumCulled = false;
this.scene.add(this.perceptionLines);
}
this.perceptionLines.visible = true;
const positions = this.perceptionLines.geometry.getAttribute('position'),
colors = this.perceptionLines.geometry.getAttribute('color');
rays.forEach((ray, i) => {
positions.setXYZ(i * 2, ray.origin[0], ray.origin[1], ray.origin[2]);
positions.setXYZ(i * 2 + 1, ray.end[0], ray.end[1], ray.end[2]);
const color = ray.hit ? [1, 0.15, 0.1] : [0.1, 1, 0.3];
colors.setXYZ(i * 2, color[0], color[1], color[2]);
colors.setXYZ(i * 2 + 1, color[0], color[1], color[2]);
});
positions.needsUpdate = colors.needsUpdate = true;
}
private updateSensorCamera(): boolean {
if (!this.session || this.sensorCameraId < 0 || this.sensorCameraId >= this.session.model.ncam)
return false;
@@ -1074,6 +1169,14 @@ export class MuJoCoViewer {
if (mesh.userData.ownsGeometry) mesh.geometry.dispose();
}
private releaseModel(): void {
this.navigationGoal.clear();
this.trainingTerrainIds.clear();
if (this.perceptionLines) {
this.scene.remove(this.perceptionLines);
this.perceptionLines.geometry.dispose();
this.perceptionLines.material.dispose();
this.perceptionLines = undefined;
}
this.stopDrag();
for (const mesh of this.meshes) {
this.scene.remove(mesh);
@@ -1116,6 +1219,7 @@ export class MuJoCoViewer {
window.removeEventListener('pointerup', this.onPointerUp);
window.removeEventListener('pointercancel', this.onPointerUp);
window.removeEventListener('blur', this.onPointerUp);
this.navigationGoal.dispose();
this.controls.dispose();
this.orientationGizmo.dispose();
this.visualizationHelpers.dispose();
@@ -0,0 +1,178 @@
import * as THREE from 'three';
import { NavigationGoal } from './NavigationGoal';
import type { NavigationStatus } from '../rl/types';
function setup() {
const scene = new THREE.Scene(),
camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);
camera.updateMatrixWorld();
const canvas = document.createElement('canvas');
document.body.append(canvas);
vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({
left: 0,
top: 0,
right: 100,
bottom: 100,
width: 100,
height: 100,
} as DOMRect);
const terrain = new THREE.Mesh(new THREE.BoxGeometry(12, 12, 0.2), new THREE.MeshBasicMaterial());
terrain.position.z = -0.1;
let navigation: NavigationStatus | undefined = {
target: [1, 2],
defaultTarget: [1, 2],
distance: 3,
targetHeight: 0.4,
};
const onTarget = vi.fn(),
onMode = vi.fn(),
excluded = vi.fn(() => false);
const goal = new NavigationGoal(scene, camera, canvas, {
navigation: () => navigation,
terrain: () => [terrain],
onTarget,
onMode,
excluded,
});
const event = (type: string, x = 50, y = 50) => {
const e = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true, cancelable: true });
Object.defineProperty(e, 'pointerId', { value: 1 });
return canvas.dispatchEvent(e);
};
const dispose = () => {
goal.dispose();
canvas.remove();
terrain.geometry.dispose();
terrain.material.dispose();
};
return {
scene,
goal,
canvas,
onTarget,
onMode,
excluded,
event,
dispose,
removePolicy: () => {
navigation = undefined;
},
};
}
describe('NavigationGoal', () => {
it('信标贴合地形、复用资源、卸载和dispose释放全部资源', () => {
const s = setup();
s.goal.update(0);
const marker = s.scene.getObjectByName('__navigation_goal__')!;
expect(marker.position.toArray()).toEqual([1, 2, 0.42500000000000004]);
const meshes = marker.children as THREE.Mesh[];
const release = meshes.flatMap((m) => [
vi.spyOn(m.geometry, 'dispose'),
vi.spyOn(m.material as THREE.Material, 'dispose'),
]);
s.goal.update(1000);
expect(s.scene.getObjectByName('__navigation_goal__')).toBe(marker);
s.goal.setActive(true);
s.removePolicy();
s.goal.update(1001);
expect(s.scene.getObjectByName('__navigation_goal__')).toBeUndefined();
expect(s.onMode).toHaveBeenLastCalledWith(false);
release.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1));
s.dispose();
release.forEach((spy) => expect(spy).toHaveBeenCalledTimes(1));
});
it('capture优先于选择/Orbit/Transform监听器,拾取后一次退出,Esc不改目标', () => {
const s = setup(),
other = vi.fn();
s.canvas.addEventListener('pointerdown', other);
s.canvas.addEventListener('pointerup', other);
s.goal.setActive(true);
s.event('pointerdown');
s.event('pointerup');
expect(s.onTarget).toHaveBeenCalledWith([0, 0]);
expect(other).not.toHaveBeenCalled();
expect(s.onMode).toHaveBeenLastCalledWith(false);
s.goal.setActive(true);
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(s.onMode).toHaveBeenLastCalledWith(false);
expect(s.onTarget).toHaveBeenCalledTimes(1);
s.dispose();
});
it('PiP、miss和拖动不修改目标;清理后恢复普通输入', () => {
const s = setup();
s.goal.setActive(true);
s.excluded.mockReturnValue(true);
s.event('pointerdown');
s.event('pointerup');
s.excluded.mockReturnValue(false);
s.event('pointerdown');
s.event('pointerup', 70);
s.event('pointerdown', -100);
s.event('pointerup', -100);
expect(s.onTarget).not.toHaveBeenCalled();
s.dispose();
});
});
it('超过5px后拖回起点不可恢复click,下一次普通click正常', () => {
const s = setup();
try {
s.goal.setActive(true);
s.event('pointerdown', 50, 50);
s.event('pointermove', 80, 50);
s.event('pointermove', 50, 50);
s.event('pointerup', 50, 50);
expect(s.onTarget).not.toHaveBeenCalled();
expect(s.onMode).toHaveBeenLastCalledWith(true);
s.event('pointerdown');
s.event('pointermove', 55);
s.event('pointerup');
expect(s.onTarget).toHaveBeenCalledExactlyOnceWith([0, 0]);
} finally {
s.dispose();
}
});
it.each(['pointercancel', 'Escape', 'clear', 'disable', 'blur'])(
'%s清掉全部gesture,旧up无效,新click正常',
(cancel) => {
const s = setup();
try {
s.goal.setActive(true);
s.event('pointerdown');
s.event('pointermove', 80);
if (cancel === 'pointercancel') s.event('pointercancel');
else if (cancel === 'Escape')
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
else if (cancel === 'clear') s.goal.clear();
else if (cancel === 'disable') s.goal.setActive(false);
else window.dispatchEvent(new Event('blur'));
s.goal.setActive(true);
s.event('pointerup');
expect(s.onTarget).not.toHaveBeenCalled();
s.event('pointerdown');
s.event('pointerup');
expect(s.onTarget).toHaveBeenCalledExactlyOnceWith([0, 0]);
} finally {
s.dispose();
}
},
);
it('画布外移动也记录拖动,dispose移除window监听且旧up不设目标', () => {
const s = setup();
s.goal.setActive(true);
s.event('pointerdown');
const move = new MouseEvent('pointermove', { clientX: 150, clientY: 50 });
Object.defineProperty(move, 'pointerId', { value: 1 });
window.dispatchEvent(move);
s.event('pointerup');
expect(s.onTarget).not.toHaveBeenCalled();
s.event('pointerdown');
s.dispose();
s.event('pointerup');
expect(s.onTarget).not.toHaveBeenCalled();
});
+170
View File
@@ -0,0 +1,170 @@
import * as THREE from 'three';
import type { NavigationStatus } from '../rl/types';
/** Owns only the navigation overlay and capture-phase input; never selects scene objects. */
export class NavigationGoal {
private marker?: THREE.Group;
private ring?: THREE.Mesh<THREE.RingGeometry, THREE.MeshBasicMaterial>;
private active = false;
private pointerId?: number;
private start?: [number, number];
private dragged = false;
private readonly raycaster = new THREE.Raycaster();
private readonly pointer = new THREE.Vector2();
constructor(
private readonly scene: THREE.Scene,
private readonly camera: THREE.Camera,
private readonly canvas: HTMLCanvasElement,
private readonly options: {
navigation(): NavigationStatus | undefined;
terrain(): THREE.Mesh[];
excluded(event: PointerEvent): boolean;
onTarget(target: [number, number]): void;
onMode(active: boolean): void;
},
) {
canvas.addEventListener('pointerdown', this.down, true);
window.addEventListener('pointermove', this.move, true);
window.addEventListener('pointerup', this.up, true);
window.addEventListener('pointercancel', this.cancel, true);
window.addEventListener('keydown', this.key, true);
window.addEventListener('blur', this.cancel);
}
private resetGesture(): void {
this.pointerId = undefined;
this.start = undefined;
this.dragged = false;
}
setActive(active: boolean): void {
this.resetGesture();
this.active = active && Boolean(this.options.navigation());
this.canvas.style.cursor = this.active ? 'crosshair' : '';
this.options.onMode(this.active);
}
update(now: number): void {
const navigation = this.options.navigation();
if (!navigation) {
this.clear();
return;
}
if (!this.marker) {
this.marker = new THREE.Group();
this.marker.name = '__navigation_goal__';
const material = new THREE.MeshBasicMaterial({
color: 0x22ffcc,
transparent: true,
opacity: 0.35,
depthWrite: false,
});
const column = new THREE.Mesh(new THREE.CylinderGeometry(0.12, 0.18, 0.7, 24), material);
column.rotation.x = Math.PI / 2;
column.position.z = 0.35;
this.ring = new THREE.Mesh(
new THREE.RingGeometry(0.24, 0.32, 40),
new THREE.MeshBasicMaterial({
color: 0x22ffcc,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
this.marker.add(column, this.ring);
this.scene.add(this.marker);
}
this.marker.position.set(...navigation.target, navigation.targetHeight + 0.025);
const pulse = (Math.sin(now / 450) + 1) / 2;
this.ring!.scale.setScalar(1 + pulse * 0.15);
this.ring!.material.opacity = 0.5 + pulse * 0.4;
}
clear(): void {
this.resetGesture();
if (this.active) this.setActive(false);
if (!this.marker) return;
this.scene.remove(this.marker);
this.marker.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
(object.material as THREE.Material).dispose();
}
});
this.marker = undefined;
this.ring = undefined;
}
private consume(event: Event): void {
event.preventDefault();
event.stopImmediatePropagation();
}
private down = (event: PointerEvent): void => {
if (!this.active) return;
this.consume(event);
if (event.button !== 0 || this.options.excluded(event)) return;
this.resetGesture();
this.pointerId = event.pointerId;
this.start = [event.clientX, event.clientY];
};
private move = (event: PointerEvent): void => {
if ((this.active && event.target === this.canvas) || this.pointerId === event.pointerId)
this.consume(event);
if (
this.pointerId === event.pointerId &&
this.start &&
Math.hypot(event.clientX - this.start[0], event.clientY - this.start[1]) > 5
)
this.dragged = true;
};
private up = (event: PointerEvent): void => {
if (this.pointerId === undefined || this.pointerId !== event.pointerId) return;
this.consume(event);
const start = this.start,
dragged = this.dragged;
this.resetGesture();
if (
!this.active ||
!start ||
dragged ||
Math.hypot(event.clientX - start[0], event.clientY - start[1]) > 5 ||
this.options.excluded(event)
)
return;
const bounds = this.canvas.getBoundingClientRect();
if (
event.clientX < bounds.left ||
event.clientX > bounds.right ||
event.clientY < bounds.top ||
event.clientY > bounds.bottom
)
return;
this.pointer.set(
((event.clientX - bounds.left) / bounds.width) * 2 - 1,
(-(event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
this.camera.updateMatrixWorld();
this.raycaster.setFromCamera(this.pointer, this.camera);
const terrain = this.options.terrain();
for (const mesh of terrain) mesh.updateWorldMatrix(true, false);
const hit = this.raycaster.intersectObjects(terrain, false)[0];
if (!hit || !this.options.navigation()) return;
this.options.onTarget([hit.point.x, hit.point.y]);
this.setActive(false);
};
private cancel = (): void => {
this.setActive(false);
};
private key = (event: KeyboardEvent): void => {
if (this.active && event.key === 'Escape') {
this.consume(event);
this.setActive(false);
}
};
dispose(): void {
this.clear();
this.canvas.removeEventListener('pointerdown', this.down, true);
window.removeEventListener('pointermove', this.move, true);
window.removeEventListener('pointerup', this.up, true);
window.removeEventListener('pointercancel', this.cancel, true);
window.removeEventListener('keydown', this.key, true);
window.removeEventListener('blur', this.cancel);
}
}