feat(training): release V0.9.1 避障训练与基础策略迁移
This commit is contained in:
@@ -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不生效,重新按下的正常点击仍可设定。
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+155
-52
@@ -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(/安全区/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 +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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user