fix(training): release V0.9.2 自定义地图自动同步
This commit is contained in:
@@ -267,7 +267,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
|
||||
const fetchMock = customServer();
|
||||
render(<LocalTrainingPanel onPolicyReady={vi.fn()} />);
|
||||
await connectCustom();
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
|
||||
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' } });
|
||||
@@ -296,7 +296,7 @@ describe('LocalTrainingPanel 自定义任务', () => {
|
||||
});
|
||||
|
||||
describe('LocalTrainingPanel 配套策略交接', () => {
|
||||
it('同步权威布局并上传完整boxes,不再重跑预设种子', async () => {
|
||||
it('选择custom_boxes后无需手动同步,启动时自动编译并上传完整布局', async () => {
|
||||
const fetchMock = customServer();
|
||||
render(
|
||||
<LocalTrainingPanel
|
||||
@@ -322,11 +322,10 @@ describe('LocalTrainingPanel 配套策略交接', () => {
|
||||
/>,
|
||||
);
|
||||
await connectCustom();
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
|
||||
fireEvent.change(screen.getByLabelText('训练地形'), { target: { value: 'custom_boxes' } });
|
||||
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.queryByLabelText('出生 X')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('目标 Y')).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/旋转障碍会膨胀/)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: '发起本地训练' }));
|
||||
await waitFor(() =>
|
||||
@@ -374,7 +373,7 @@ describe('LocalTrainingPanel 配套策略交接', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;安全区报错不删障碍', async () => {
|
||||
it('custom_boxes拒绝未应用草稿,且训练时自动重新编译最新地图而不显示起终点', async () => {
|
||||
const fetchMock = customServer();
|
||||
const assets = [
|
||||
{
|
||||
@@ -387,13 +386,7 @@ it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;
|
||||
},
|
||||
];
|
||||
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 compileScene = vi.fn(() => structuredClone(current));
|
||||
const { rerender } = render(
|
||||
<LocalTrainingPanel
|
||||
onPolicyReady={vi.fn()}
|
||||
@@ -403,27 +396,25 @@ it('custom_boxes拒绝草稿、过时编译场景和未重新同步的坐标;
|
||||
/>,
|
||||
);
|
||||
await connectCustom();
|
||||
fireEvent.click(screen.getByRole('button', { name: '同步当前场景地图' }));
|
||||
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: '同步当前场景地图' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '检查当前场景地图(可选)' }));
|
||||
expect(screen.queryByLabelText('出生 X')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('目标 X')).not.toBeInTheDocument();
|
||||
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);
|
||||
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.boxes[1].pos).toEqual([1, 3, 0.5]);
|
||||
expect(compileScene).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('显式选择multi48传入训练请求,默认与切换任务仍single32', async () => {
|
||||
|
||||
@@ -81,7 +81,6 @@ export function LocalTrainingPanel({
|
||||
|
||||
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',
|
||||
@@ -96,7 +95,6 @@ export function LocalTrainingPanel({
|
||||
const selectTask = (id: string) => {
|
||||
setTaskId(id);
|
||||
setCustomTerrainBoxes(undefined);
|
||||
setSyncedScene(undefined);
|
||||
setRewardPresetId('');
|
||||
setTerrainParams({});
|
||||
setSensorCfg({});
|
||||
@@ -109,20 +107,10 @@ export function LocalTrainingPanel({
|
||||
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,
|
||||
);
|
||||
const layout = validateCustomTerrain(compileScene());
|
||||
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));
|
||||
@@ -235,20 +223,18 @@ export function LocalTrainingPanel({
|
||||
const source = event.source as Window;
|
||||
if (data.type === 'mujoco-tuning-ready') {
|
||||
try {
|
||||
let resolvedCustomTerrain = customTerrainBoxes;
|
||||
let resolvedTerrainParams = terrainParams;
|
||||
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('碰撞场景已过时,请重新同步');
|
||||
if (sceneDirty) throw new Error('请先应用地图草稿,再打开调参');
|
||||
if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图');
|
||||
resolvedCustomTerrain = validateCustomTerrain(compileScene());
|
||||
resolvedTerrainParams = {
|
||||
size: resolvedCustomTerrain.size,
|
||||
friction: resolvedCustomTerrain.friction,
|
||||
};
|
||||
setCustomTerrainBoxes(resolvedCustomTerrain);
|
||||
setTerrainParams(resolvedTerrainParams);
|
||||
}
|
||||
source.postMessage(
|
||||
{
|
||||
@@ -263,10 +249,12 @@ export function LocalTrainingPanel({
|
||||
? {
|
||||
taskConfig: {
|
||||
terrainPreset,
|
||||
terrainParams,
|
||||
terrainParams: resolvedTerrainParams,
|
||||
sensorType: 'raycast',
|
||||
sensorCfg: { ...sensorCfg, sensorMode },
|
||||
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
|
||||
...(terrainPreset === 'custom_boxes'
|
||||
? { customTerrainBoxes: resolvedCustomTerrain }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
@@ -332,7 +320,6 @@ export function LocalTrainingPanel({
|
||||
sensorCfg,
|
||||
sensorMode,
|
||||
customTerrainBoxes,
|
||||
syncedScene,
|
||||
sceneMaps,
|
||||
sceneDirty,
|
||||
compileScene,
|
||||
@@ -387,22 +374,18 @@ export function LocalTrainingPanel({
|
||||
throw new Error('障碍物最小高度不能超过最大高度');
|
||||
if ((sensorCfg.safetyDistance ?? 0.5) >= (sensorCfg.maxDistance ?? 4))
|
||||
throw new Error('安全距离必须小于探测距离');
|
||||
let resolvedCustomTerrain = customTerrainBoxes;
|
||||
let resolvedTerrainParams = terrainParams;
|
||||
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('已编译碰撞场景已过时,请重新同步');
|
||||
if (sceneDirty) throw new Error('请先应用地图草稿,再训练');
|
||||
if (!compileScene || !sceneMaps.length) throw new Error('没有已应用的碰撞地图');
|
||||
resolvedCustomTerrain = validateCustomTerrain(compileScene());
|
||||
resolvedTerrainParams = {
|
||||
size: resolvedCustomTerrain.size,
|
||||
friction: resolvedCustomTerrain.friction,
|
||||
};
|
||||
setCustomTerrainBoxes(resolvedCustomTerrain);
|
||||
setTerrainParams(resolvedTerrainParams);
|
||||
}
|
||||
const next = await new LocalTrainingClient(endpoint, token).start({
|
||||
taskId,
|
||||
@@ -415,8 +398,8 @@ export function LocalTrainingPanel({
|
||||
wandbMode,
|
||||
rewardPresetId: taskId === 'Unitree-Go2-Flat' ? rewardPresetId || undefined : undefined,
|
||||
...(pretrainedSourceId ? { pretrainedSourceId } : {}),
|
||||
...(terrainPreset ? { terrainPreset, terrainParams } : {}),
|
||||
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes } : {}),
|
||||
...(terrainPreset ? { terrainPreset, terrainParams: resolvedTerrainParams } : {}),
|
||||
...(terrainPreset === 'custom_boxes' ? { customTerrainBoxes: resolvedCustomTerrain } : {}),
|
||||
...(taskId === OBSTACLE_TASK_ID
|
||||
? { sensorType: 'raycast' as const, sensorCfg: { ...sensorCfg, sensorMode } }
|
||||
: {}),
|
||||
@@ -572,7 +555,6 @@ export function LocalTrainingPanel({
|
||||
onChange={(e) => {
|
||||
setTerrainPreset(e.target.value);
|
||||
setCustomTerrainBoxes(undefined);
|
||||
setSyncedScene(undefined);
|
||||
setTerrainParams({});
|
||||
}}
|
||||
>
|
||||
@@ -585,47 +567,16 @@ export function LocalTrainingPanel({
|
||||
</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、地下结构、混合摩擦明确拒绝。
|
||||
启动训练时会自动从全部已应用实例重新编译并校验世界AABB,无需预先同步。旋转障碍会膨胀,底板标准化为z=[-0.2,0];仅保证训练与浏览器使用相同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 === 'custom_boxes' && customTerrainBoxes && !sceneDirty && (
|
||||
<p role="status">
|
||||
已读取视口中 {customTerrainBoxes.actualObstacleCount}{' '}
|
||||
个自定义障碍物;启动训练时会自动重新编译并校验地图
|
||||
</p>
|
||||
)}
|
||||
{terrainPreset && terrainPreset !== 'custom_boxes' && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
||||
Reference in New Issue
Block a user