feat(web-platform): release V0.7.1 数据模块
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (push) Has been cancelled
web-platform-ci / Playwright E2E (push) Has been cancelled
web-platform-ci / TypeScript, lint, unit, build (pull_request) Has been cancelled
web-platform-ci / Playwright E2E (pull_request) Has been cancelled
This commit is contained in:
@@ -2,6 +2,16 @@
|
||||
|
||||
本项目的重要变更记录在此文件中,版本标签沿用仓库现有的 `V主版本.次版本[.修订版本]` 格式。
|
||||
|
||||
## [0.7.1] - 2026-09-01
|
||||
|
||||
### 新增
|
||||
|
||||
- 增加浏览器内仿真遥测记录模块,可配置记录 Body、采样频率和样本上限。
|
||||
- 记录位置、移动速度、机身侧倾/俯仰/偏航角、角速度、累计里程、接触与驱动指标。
|
||||
- 增加实时数据与摘要面板,并支持导出稳定列结构的 CSV 和 Schema V1 JSON。
|
||||
- 提供 `TelemetrySource`、`registerDataChannel` 及记录生命周期接口,便于扩展业务指标和其他仿真后端。
|
||||
- 仿真重置使用数据分段,避免跨重置计算错误速度;达到样本上限时自动停止。
|
||||
|
||||
## [0.6.1] - 2026-08-28
|
||||
|
||||
### 新增
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mujoco-web-platform",
|
||||
"version": "0.7.0",
|
||||
"version": "0.7.1",
|
||||
"description": "基于 MuJoCo WebAssembly 的本地机器人仿真与控制平台",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 导入单文件 `.py` 控制器,通过本地 Pyodide 在 `mj_step` 前按仿真时间同步执行
|
||||
- 导入 mjlab 导出的 `policy.onnx`,在浏览器本地执行 Go2-W 平衡/速度策略推理
|
||||
- 从图形界面向本机训练桥接服务发起 mjlab 强化学习训练、查看进度/日志、停止任务并导入训练生成的 ONNX
|
||||
- 可配置仿真遥测记录,实时查看速度、机身姿态、位置、驱动力等指标并导出 CSV/JSON
|
||||
- FPS、物理耗时和主线程步进预算提示
|
||||
|
||||
## 开发
|
||||
@@ -97,6 +98,12 @@ V3 可编辑地图使用 `schemaVersion: 2`,并增加 `"authoring": { "source"
|
||||
|
||||
物理地图超过 2000 个 geom 会产生性能警告,超过 10000 个会被拒绝;GLB 超过 100 万三角面会警告,超过 300 万会被拒绝。当前原生 URDF 模式不支持地图,请切换到“转换为 MJCF”。
|
||||
|
||||
## 数据记录
|
||||
|
||||
加载模型后打开右侧“数据”标签,可以选择需要跟踪的 Body、采样频率和样本上限。内置通道包括世界系位置/速度、水平与三维速度、机身侧倾/俯仰/偏航角及角速度、累计里程、接触数、控制输入 RMS、驱动力 RMS、绝对驱动功率和广义速度 RMS。仿真重置不会删除已有数据,而是创建新分段,避免跨重置计算出错误速度;切换记录 Body 或采样配置会清空不兼容的旧数据。
|
||||
|
||||
记录只保留在当前浏览器会话中,达到样本上限后自动停止,可导出带稳定列名的 CSV 或包含通道元数据、摘要和样本的 Schema V1 JSON。`SimulationSession`/`PhysicsAdapter` 保留 `configureDataRecorder`、`startDataRecording`、`stopDataRecording`、`clearDataRecording`、`exportDataRecording` 接口;还可通过 `registerDataChannel({ key, label, unit, read })` 在开始记录前注册业务自定义标量通道。数据源通过 `TelemetrySource` 抽象与 MuJoCo 解耦,后续可复用于 Worker 或远端仿真。
|
||||
|
||||
## Python 控制器
|
||||
|
||||
Python 控制器是可信的单文件脚本,必须同步定义 `step(ctx, state)`;可选定义 `NAME`、`CONTROL_HZ`(限制为 1–500 Hz)、`init(api)`、`command(name, state)`、`reset(state)` 和 `dispose(state)`。`init` 可用 `api.joint(name)`、`api.actuator(name)`、`api.sensor(name)`、`api.body(name)` 预解析 ID;`step` 可用 `ctx.qpos(id)`、`ctx.qvel(id)`、`ctx.sensor(id)`、`ctx.body_quat(id)`、`ctx.body_position(id)` 读取状态,并用 `ctx.set_control(id, value)` 写入经过有限值检查和 actuator 限幅的控制量。定义 `command` 后,界面会显示停止、前进、后退、左转、右转和起跳按钮,并分别传入 `stop`、`forward`、`backward`、`turn_left`、`turn_right`、`jump`。所有回调都必须同步;异常会自动停止控制器或显示诊断,运行期异常还会暂停仿真并清零 `ctrl`。
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type UrdfLoadMode,
|
||||
} from '../simulation/PhysicsAdapter';
|
||||
import type { ActuatorParameters } from '../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig } from '../simulation/DataRecorder';
|
||||
import type { ControllerCommand, ControllerStatus } from '../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../rl/types';
|
||||
import type { MuJoCoViewer, InteractionMode, ViewerTheme } from '../viewer/MuJoCoViewer';
|
||||
@@ -1328,6 +1329,42 @@ export function App() {
|
||||
setPolicyStatus(undefined);
|
||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||
};
|
||||
const configureDataRecorder = (patch: Partial<DataRecorderConfig>) => {
|
||||
try {
|
||||
adapter.current.configureDataRecorder(patch);
|
||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||
} catch (error) {
|
||||
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
|
||||
}
|
||||
};
|
||||
const startDataRecording = () => {
|
||||
adapter.current.startDataRecording();
|
||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||
};
|
||||
const stopDataRecording = () => {
|
||||
adapter.current.stopDataRecording();
|
||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||
};
|
||||
const clearDataRecording = () => {
|
||||
adapter.current.clearDataRecording();
|
||||
state.setSnapshot(adapter.current.snapshot() ?? undefined);
|
||||
};
|
||||
const exportDataRecording = (format: 'csv' | 'json') => {
|
||||
try {
|
||||
const stem =
|
||||
(manifest.current?.name ?? 'simulation')
|
||||
.replace(/\.(?:zip|xml|urdf)$/i, '')
|
||||
.replace(/[^\p{L}\p{N}._-]+/gu, '_') || 'simulation';
|
||||
downloadBytes(
|
||||
adapter.current.exportDataRecording(format),
|
||||
`${stem}-telemetry.${format}`,
|
||||
format === 'csv' ? 'text/csv' : 'application/json',
|
||||
);
|
||||
notify(`遥测 ${format.toUpperCase()} 已导出`, `${stem}-telemetry.${format}`);
|
||||
} catch (error) {
|
||||
state.setDiagnostic(diagnostic('仿真', error, state.selectedEntry));
|
||||
}
|
||||
};
|
||||
const notify = (
|
||||
title: string,
|
||||
detail: string,
|
||||
@@ -1769,6 +1806,11 @@ export function App() {
|
||||
setLeftOpen(true);
|
||||
setProjectSidebarTab('assets');
|
||||
}}
|
||||
onDataRecorderConfigure={configureDataRecorder}
|
||||
onDataRecordingStart={startDataRecording}
|
||||
onDataRecordingStop={stopDataRecording}
|
||||
onDataRecordingClear={clearDataRecording}
|
||||
onDataRecordingExport={exportDataRecording}
|
||||
/>
|
||||
</div>
|
||||
{pendingUrdfPath && (
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import type { DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
|
||||
const status: DataRecorderStatus = {
|
||||
recording: false,
|
||||
limitReached: false,
|
||||
sampleCount: 2,
|
||||
segmentCount: 1,
|
||||
config: { bodyId: 1, sampleRateHz: 50, maxSamples: 30_000 },
|
||||
body: { id: 1, name: 'base' },
|
||||
latest: {
|
||||
sequence: 1,
|
||||
segment: 0,
|
||||
simulationTime: 0.02,
|
||||
values: {
|
||||
speed_horizontal: 1.25,
|
||||
speed_3d: 1.3,
|
||||
velocity_x: 1.25,
|
||||
velocity_y: 0,
|
||||
velocity_z: 0.1,
|
||||
pitch: 0.1,
|
||||
roll: -0.05,
|
||||
yaw: 0.2,
|
||||
height: 0.45,
|
||||
position_x: 0.1,
|
||||
position_y: 0,
|
||||
contact_count: 4,
|
||||
control_rms: 2,
|
||||
actuator_force_rms: 3,
|
||||
actuator_power_abs: 8,
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
duration: 0.02,
|
||||
distanceHorizontal: 0.1,
|
||||
maxHorizontalSpeed: 1.25,
|
||||
maxAbsRoll: 0.05,
|
||||
maxAbsPitch: 0.1,
|
||||
minHeight: 0.44,
|
||||
maxHeight: 0.46,
|
||||
},
|
||||
};
|
||||
|
||||
describe('DataRecordingPanel', () => {
|
||||
it('展示实时遥测并提供配置、记录和导出操作', () => {
|
||||
const configure = vi.fn(),
|
||||
start = vi.fn(),
|
||||
exportData = vi.fn();
|
||||
render(
|
||||
<DataRecordingPanel
|
||||
status={status}
|
||||
bodies={[
|
||||
{ id: 0, name: 'world', parentId: 0 },
|
||||
{ id: 1, name: 'base', parentId: 0 },
|
||||
{ id: 2, name: 'payload', parentId: 1 },
|
||||
]}
|
||||
onConfigure={configure}
|
||||
onStart={start}
|
||||
onStop={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
onExport={exportData}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText('1.250 m/s').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('5.73°').length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByLabelText('记录 Body'), { target: { value: '2' } });
|
||||
expect(configure).toHaveBeenCalledWith({ bodyId: 2 });
|
||||
fireEvent.click(screen.getByRole('button', { name: '开始记录' }));
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出 CSV' }));
|
||||
expect(exportData).toHaveBeenCalledWith('csv');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Circle, Download, Square, Trash2 } from 'lucide-react';
|
||||
import type { BodyInfo } from '../../simulation/SimulationSession';
|
||||
import type { DataRecorderConfig, DataRecorderStatus } from '../../simulation/DataRecorder';
|
||||
import { Badge, Button, PropertyRow, Select } from '../../components/ui';
|
||||
|
||||
const SAMPLE_RATES = [10, 20, 50, 100, 200];
|
||||
const radiansToDegrees = 180 / Math.PI;
|
||||
|
||||
function number(value: number | undefined, digits = 3): string {
|
||||
return Number.isFinite(value) ? value!.toFixed(digits) : '—';
|
||||
}
|
||||
|
||||
function degrees(value: number | undefined): string {
|
||||
return Number.isFinite(value) ? `${(value! * radiansToDegrees).toFixed(2)}°` : '—';
|
||||
}
|
||||
|
||||
export function DataRecordingPanel({
|
||||
status,
|
||||
bodies,
|
||||
onConfigure,
|
||||
onStart,
|
||||
onStop,
|
||||
onClear,
|
||||
onExport,
|
||||
}: {
|
||||
status: DataRecorderStatus;
|
||||
bodies: BodyInfo[];
|
||||
onConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onClear: () => void;
|
||||
onExport: (format: 'csv' | 'json') => void;
|
||||
}) {
|
||||
const values = status.latest?.values,
|
||||
availableBodies = bodies.filter(
|
||||
(body) => body.id > 0 && !body.name.startsWith('__platform_map_'),
|
||||
);
|
||||
return (
|
||||
<div className="space-y-3 p-3">
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-text-primary">仿真遥测记录</h3>
|
||||
<p className="mt-0.5 text-[10px] text-text-tertiary">
|
||||
数据仅保存在当前浏览器会话,可导出 CSV 或 JSON。
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone={status.recording ? 'success' : status.limitReached ? 'warning' : 'neutral'}>
|
||||
{status.recording ? '记录中' : status.limitReached ? '已达上限' : '已停止'}
|
||||
</Badge>
|
||||
</div>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">记录 Body</span>
|
||||
<Select
|
||||
aria-label="记录 Body"
|
||||
className="w-full"
|
||||
value={status.config.bodyId}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ bodyId: Number(event.target.value) })}
|
||||
>
|
||||
{(availableBodies.length ? availableBodies : [status.body]).map((body) => (
|
||||
<option key={body.id} value={body.id}>
|
||||
{body.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">采样频率</span>
|
||||
<Select
|
||||
aria-label="采样频率"
|
||||
className="w-full"
|
||||
value={status.config.sampleRateHz}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => onConfigure({ sampleRateHz: Number(event.target.value) })}
|
||||
>
|
||||
{SAMPLE_RATES.map((rate) => (
|
||||
<option key={rate} value={rate}>
|
||||
{rate} Hz
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<label className="block text-xs text-text-secondary">
|
||||
<span className="mb-1 block">样本上限</span>
|
||||
<input
|
||||
aria-label="样本上限"
|
||||
type="number"
|
||||
min={100}
|
||||
max={1_000_000}
|
||||
step={1000}
|
||||
className="field h-8 w-full px-2 text-xs"
|
||||
value={status.config.maxSamples}
|
||||
disabled={status.recording}
|
||||
onChange={(event) => {
|
||||
const value = Number(event.target.value);
|
||||
if (Number.isFinite(value) && value >= 100) onConfigure({ maxSamples: value });
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
{status.recording ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Square className="h-3 w-3 fill-current" />}
|
||||
onClick={onStop}
|
||||
>
|
||||
停止记录
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Circle className="h-3 w-3 fill-current" />}
|
||||
onClick={onStart}
|
||||
disabled={status.limitReached}
|
||||
>
|
||||
开始记录
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="danger"
|
||||
icon={<Trash2 className="h-3.5 w-3.5" />}
|
||||
onClick={onClear}
|
||||
disabled={!status.sampleCount}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-border pt-2">
|
||||
<PropertyRow label="样本" value={`${status.sampleCount} / ${status.config.maxSamples}`} />
|
||||
<PropertyRow label="有效时长" value={`${number(status.summary.duration, 2)} s`} />
|
||||
<PropertyRow label="分段" value={status.segmentCount} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">实时运动状态</h3>
|
||||
<PropertyRow label="水平速度" value={`${number(values?.speed_horizontal)} m/s`} />
|
||||
<PropertyRow label="三维速度" value={`${number(values?.speed_3d)} m/s`} />
|
||||
<PropertyRow
|
||||
label="世界系速度 XYZ"
|
||||
value={`${number(values?.velocity_x)}, ${number(values?.velocity_y)}, ${number(values?.velocity_z)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="俯仰角" value={degrees(values?.pitch)} />
|
||||
<PropertyRow label="侧倾角" value={degrees(values?.roll)} />
|
||||
<PropertyRow label="偏航角" value={degrees(values?.yaw)} />
|
||||
<PropertyRow label="机身高度" value={`${number(values?.height)} m`} />
|
||||
<PropertyRow
|
||||
label="世界系位置 XY"
|
||||
value={`${number(values?.position_x)}, ${number(values?.position_y)} m`}
|
||||
/>
|
||||
<PropertyRow label="场景接触数" value={number(values?.contact_count, 0)} />
|
||||
<PropertyRow label="控制输入 RMS" value={number(values?.control_rms)} />
|
||||
<PropertyRow label="驱动力 RMS" value={number(values?.actuator_force_rms)} />
|
||||
<PropertyRow label="绝对驱动功率" value={`${number(values?.actuator_power_abs)} W`} />
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-border bg-surface p-3">
|
||||
<h3 className="mb-2 text-xs font-semibold text-text-primary">本次记录摘要</h3>
|
||||
<PropertyRow
|
||||
label="累计水平里程"
|
||||
value={`${number(status.summary.distanceHorizontal)} m`}
|
||||
/>
|
||||
<PropertyRow
|
||||
label="最大水平速度"
|
||||
value={`${number(status.summary.maxHorizontalSpeed)} m/s`}
|
||||
/>
|
||||
<PropertyRow label="最大俯仰角" value={degrees(status.summary.maxAbsPitch)} />
|
||||
<PropertyRow label="最大侧倾角" value={degrees(status.summary.maxAbsRoll)} />
|
||||
<PropertyRow
|
||||
label="高度范围"
|
||||
value={`${number(status.summary.minHeight)} – ${number(status.summary.maxHeight)} m`}
|
||||
/>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('csv')}
|
||||
>
|
||||
导出 CSV
|
||||
</Button>
|
||||
<Button
|
||||
icon={<Download className="h-3.5 w-3.5" />}
|
||||
disabled={!status.sampleCount}
|
||||
onClick={() => onExport('json')}
|
||||
>
|
||||
导出 JSON
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
<p className="text-[10px] leading-4 text-text-tertiary">
|
||||
每次仿真重置会创建新分段,避免跨重置计算出错误速度。开发接口支持注册额外标量通道,导出列名保持稳定。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Box, FolderTree, Info, Map as MapIcon, Settings2, SlidersHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Box,
|
||||
Database,
|
||||
FolderTree,
|
||||
Info,
|
||||
Map as MapIcon,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import type { MapEntry, ModelEntry } from '../../project/types';
|
||||
import {
|
||||
countProjectSearchResults,
|
||||
@@ -16,6 +24,7 @@ import type {
|
||||
SimulationSnapshot,
|
||||
} from '../../simulation/SimulationSession';
|
||||
import type { UrdfBaseMode, UrdfLoadMode } from '../../simulation/PhysicsAdapter';
|
||||
import type { DataRecorderConfig } from '../../simulation/DataRecorder';
|
||||
import type { ViewerSelection } from '../../viewer/MuJoCoViewer';
|
||||
import type { ControllerCommand, ControllerStatus } from '../../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../../rl/types';
|
||||
@@ -34,6 +43,7 @@ import { ProjectBreadcrumb } from './ProjectBreadcrumb';
|
||||
import { PythonControllerPanel } from './PythonControllerPanel';
|
||||
import { RLPolicyPanel } from './RLPolicyPanel';
|
||||
import { LocalTrainingPanel } from './LocalTrainingPanel';
|
||||
import { DataRecordingPanel } from './DataRecordingPanel';
|
||||
import { PhysicalMapPanel } from './PhysicalMapPanel';
|
||||
import { MapAssetLibrary } from './MapAssetLibrary';
|
||||
import {
|
||||
@@ -297,9 +307,14 @@ interface ModelControlsProps {
|
||||
onEditorSnapping: (translation: number | null, rotationDegrees: number | null) => void;
|
||||
onMapDisplay: (visual: boolean, collision: boolean) => void;
|
||||
onMapTabOpen?: () => void;
|
||||
onDataRecorderConfigure: (patch: Partial<DataRecorderConfig>) => void;
|
||||
onDataRecordingStart: () => void;
|
||||
onDataRecordingStop: () => void;
|
||||
onDataRecordingClear: () => void;
|
||||
onDataRecordingExport: (format: 'csv' | 'json') => void;
|
||||
}
|
||||
export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'map'>('properties'),
|
||||
const [tab, setTab] = useState<'properties' | 'controls' | 'data' | 'map'>('properties'),
|
||||
s = props.snapshot;
|
||||
if (!s)
|
||||
return (
|
||||
@@ -535,6 +550,22 @@ export function ModelControlsSidebar(props: ModelControlsProps) {
|
||||
icon: <SlidersHorizontal className="h-3.5 w-3.5" />,
|
||||
content: controls,
|
||||
},
|
||||
{
|
||||
value: 'data',
|
||||
label: '数据',
|
||||
icon: <Database className="h-3.5 w-3.5" />,
|
||||
content: (
|
||||
<DataRecordingPanel
|
||||
status={s.telemetry}
|
||||
bodies={s.bodies}
|
||||
onConfigure={props.onDataRecorderConfigure}
|
||||
onStart={props.onDataRecordingStart}
|
||||
onStop={props.onDataRecordingStop}
|
||||
onClear={props.onDataRecordingClear}
|
||||
onExport={props.onDataRecordingExport}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'map',
|
||||
label: '地图',
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DataRecorder, type TelemetrySource } from './DataRecorder';
|
||||
|
||||
function sourceFixture() {
|
||||
let time = 0,
|
||||
position: [number, number, number] = [0, 0, 0.5],
|
||||
quaternion: [number, number, number, number] = [1, 0, 0, 0];
|
||||
const source: TelemetrySource = {
|
||||
simulationTime: () => time,
|
||||
bodyPose: () => ({ position, quaternion }),
|
||||
controls: () => [3, 4],
|
||||
actuatorForces: () => [2, -2],
|
||||
actuatorVelocities: () => [3, -1],
|
||||
generalizedVelocities: () => [0, 2],
|
||||
contactCount: () => 4,
|
||||
};
|
||||
return {
|
||||
source,
|
||||
move(nextTime: number, nextPosition: [number, number, number]) {
|
||||
time = nextTime;
|
||||
position = nextPosition;
|
||||
},
|
||||
rotate(next: [number, number, number, number]) {
|
||||
quaternion = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('DataRecorder', () => {
|
||||
it('按采样率记录位姿、移动速度、姿态和控制指标', () => {
|
||||
const fixture = sourceFixture(),
|
||||
recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }], {
|
||||
sampleRateHz: 10,
|
||||
});
|
||||
recorder.start();
|
||||
fixture.move(0.05, [0.05, 0, 0.5]);
|
||||
recorder.capture();
|
||||
expect(recorder.status().sampleCount).toBe(1);
|
||||
|
||||
fixture.move(0.1, [0.1, 0, 0.6]);
|
||||
fixture.rotate([Math.cos(Math.PI / 8), 0, Math.sin(Math.PI / 8), 0]);
|
||||
recorder.capture();
|
||||
const status = recorder.status(),
|
||||
values = status.latest!.values;
|
||||
expect(status.sampleCount).toBe(2);
|
||||
expect(values.speed_horizontal).toBeCloseTo(1);
|
||||
expect(values.velocity_z).toBeCloseTo(1);
|
||||
expect(values.pitch).toBeCloseTo(Math.PI / 4);
|
||||
expect(values.distance_horizontal).toBeCloseTo(0.1);
|
||||
expect(values.control_rms).toBeCloseTo(Math.sqrt(12.5));
|
||||
expect(values.actuator_force_rms).toBe(2);
|
||||
expect(values.actuator_power_abs).toBe(8);
|
||||
expect(values.contact_count).toBe(4);
|
||||
expect(status.summary.maxAbsPitch).toBeCloseTo(Math.PI / 4);
|
||||
});
|
||||
|
||||
it('仿真重置后新建分段且不跨分段计算速度', () => {
|
||||
const fixture = sourceFixture(),
|
||||
recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }]);
|
||||
recorder.start();
|
||||
fixture.move(0.02, [0.1, 0, 0.5]);
|
||||
recorder.capture(true);
|
||||
recorder.simulationReset();
|
||||
fixture.move(0, [0, 0, 0.5]);
|
||||
recorder.capture(true);
|
||||
expect(recorder.status().segmentCount).toBe(2);
|
||||
expect(recorder.status().latest?.values.speed_horizontal).toBe(0);
|
||||
});
|
||||
|
||||
it('支持自定义通道并导出稳定的 CSV/JSON 接口', () => {
|
||||
const fixture = sourceFixture(),
|
||||
recorder = new DataRecorder(fixture.source, [{ id: 1, name: 'base' }]);
|
||||
recorder.registerChannel({
|
||||
key: 'stability_score',
|
||||
label: '稳定性',
|
||||
unit: '',
|
||||
read: ({ pose }) => pose.position[2] * 2,
|
||||
});
|
||||
recorder.start();
|
||||
const csv = new TextDecoder().decode(recorder.toCsv()),
|
||||
json = JSON.parse(new TextDecoder().decode(recorder.toJson())) as {
|
||||
schemaVersion: number;
|
||||
channels: { key: string }[];
|
||||
samples: { values: Record<string, number> }[];
|
||||
};
|
||||
expect(csv).toContain('simulation_time_s');
|
||||
expect(csv).toContain('stability_score');
|
||||
expect(json.schemaVersion).toBe(1);
|
||||
expect(json.channels.some((channel) => channel.key === 'stability_score')).toBe(true);
|
||||
expect(json.samples[0].values.stability_score).toBe(1);
|
||||
});
|
||||
|
||||
it('修改采样对象或频率时清空旧数据,并在达到上限时停止', () => {
|
||||
const fixture = sourceFixture(),
|
||||
recorder = new DataRecorder(
|
||||
fixture.source,
|
||||
[
|
||||
{ id: 1, name: 'base' },
|
||||
{ id: 2, name: 'payload' },
|
||||
],
|
||||
{ maxSamples: 100 },
|
||||
);
|
||||
recorder.start();
|
||||
recorder.stop();
|
||||
recorder.configure({ bodyId: 2, sampleRateHz: 20 });
|
||||
expect(recorder.status()).toMatchObject({
|
||||
sampleCount: 0,
|
||||
body: { id: 2 },
|
||||
config: { sampleRateHz: 20 },
|
||||
});
|
||||
recorder.start();
|
||||
for (let index = 1; index < 100; index += 1) {
|
||||
fixture.move(index / 20, [index / 20, 0, 0.5]);
|
||||
recorder.capture(true);
|
||||
}
|
||||
expect(recorder.status()).toMatchObject({
|
||||
recording: false,
|
||||
limitReached: true,
|
||||
sampleCount: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,455 @@
|
||||
export interface TelemetryBody {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface TelemetryPose {
|
||||
position: readonly [number, number, number];
|
||||
/** MuJoCo 的 w、x、y、z 四元数顺序。 */
|
||||
quaternion: readonly [number, number, number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据源接口刻意与 MuJoCo 类型解耦,后续可接入 Worker、远端仿真或自定义指标。
|
||||
*/
|
||||
export interface TelemetrySource {
|
||||
simulationTime(): number;
|
||||
bodyPose(bodyId: number): TelemetryPose;
|
||||
controls(): ArrayLike<number>;
|
||||
actuatorForces(): ArrayLike<number>;
|
||||
actuatorVelocities(): ArrayLike<number>;
|
||||
generalizedVelocities(): ArrayLike<number>;
|
||||
contactCount(): number;
|
||||
}
|
||||
|
||||
export interface TelemetryChannelContext {
|
||||
source: TelemetrySource;
|
||||
body: TelemetryBody;
|
||||
simulationTime: number;
|
||||
pose: TelemetryPose;
|
||||
}
|
||||
|
||||
/** 注册自定义通道时使用的稳定扩展接口。 */
|
||||
export interface TelemetryChannel {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
read(context: TelemetryChannelContext): number;
|
||||
}
|
||||
|
||||
export interface DataRecorderConfig {
|
||||
bodyId: number;
|
||||
sampleRateHz: number;
|
||||
maxSamples: number;
|
||||
}
|
||||
|
||||
export interface TelemetrySample {
|
||||
sequence: number;
|
||||
segment: number;
|
||||
simulationTime: number;
|
||||
values: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface TelemetrySummary {
|
||||
duration: number;
|
||||
distanceHorizontal: number;
|
||||
maxHorizontalSpeed: number;
|
||||
maxAbsRoll: number;
|
||||
maxAbsPitch: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
export interface DataRecorderStatus {
|
||||
recording: boolean;
|
||||
limitReached: boolean;
|
||||
sampleCount: number;
|
||||
segmentCount: number;
|
||||
config: DataRecorderConfig;
|
||||
body: TelemetryBody;
|
||||
latest?: TelemetrySample;
|
||||
summary: TelemetrySummary;
|
||||
}
|
||||
|
||||
const BUILTIN_CHANNELS: readonly Omit<TelemetryChannel, 'read'>[] = [
|
||||
{ key: 'position_x', label: '位置 X', unit: 'm' },
|
||||
{ key: 'position_y', label: '位置 Y', unit: 'm' },
|
||||
{ key: 'height', label: '机身高度', unit: 'm' },
|
||||
{ key: 'velocity_x', label: '速度 X', unit: 'm/s' },
|
||||
{ key: 'velocity_y', label: '速度 Y', unit: 'm/s' },
|
||||
{ key: 'velocity_z', label: '速度 Z', unit: 'm/s' },
|
||||
{ key: 'speed_horizontal', label: '水平移动速度', unit: 'm/s' },
|
||||
{ key: 'speed_3d', label: '三维速度', unit: 'm/s' },
|
||||
{ key: 'roll', label: '侧倾角', unit: 'rad' },
|
||||
{ key: 'pitch', label: '俯仰角', unit: 'rad' },
|
||||
{ key: 'yaw', label: '偏航角', unit: 'rad' },
|
||||
{ key: 'angular_velocity_roll', label: '侧倾角速度', unit: 'rad/s' },
|
||||
{ key: 'angular_velocity_pitch', label: '俯仰角速度', unit: 'rad/s' },
|
||||
{ key: 'angular_velocity_yaw', label: '偏航角速度', unit: 'rad/s' },
|
||||
{ key: 'distance_horizontal', label: '水平累计里程', unit: 'm' },
|
||||
{ key: 'contact_count', label: '场景接触数', unit: '' },
|
||||
{ key: 'control_rms', label: '控制输入 RMS', unit: '' },
|
||||
{ key: 'actuator_force_rms', label: '驱动力 RMS', unit: '' },
|
||||
{ key: 'actuator_power_abs', label: '驱动器绝对功率和', unit: 'W' },
|
||||
{ key: 'joint_velocity_rms', label: '广义速度 RMS', unit: '' },
|
||||
];
|
||||
|
||||
const DEFAULT_MAX_SAMPLES = 30_000;
|
||||
|
||||
function finite(value: number, fallback = 0): number {
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value: number, minimum: number, maximum: number): number {
|
||||
return Math.round(Math.min(maximum, Math.max(minimum, finite(value, minimum))));
|
||||
}
|
||||
|
||||
function rootMeanSquare(values: ArrayLike<number>): number {
|
||||
if (!values.length) return 0;
|
||||
let sum = 0;
|
||||
for (let index = 0; index < values.length; index += 1) {
|
||||
const value = finite(Number(values[index]));
|
||||
sum += value * value;
|
||||
}
|
||||
return Math.sqrt(sum / values.length);
|
||||
}
|
||||
|
||||
function absoluteActuatorPower(forces: ArrayLike<number>, velocities: ArrayLike<number>): number {
|
||||
let total = 0;
|
||||
for (let index = 0; index < Math.min(forces.length, velocities.length); index += 1)
|
||||
total += Math.abs(finite(Number(forces[index])) * finite(Number(velocities[index])));
|
||||
return total;
|
||||
}
|
||||
|
||||
function eulerFromQuaternion(
|
||||
quaternion: readonly [number, number, number, number],
|
||||
): [number, number, number] {
|
||||
const [w, x, y, z] = quaternion.map((value) => finite(Number(value))) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
norm = Math.hypot(w, x, y, z) || 1,
|
||||
qw = w / norm,
|
||||
qx = x / norm,
|
||||
qy = y / norm,
|
||||
qz = z / norm,
|
||||
roll = Math.atan2(2 * (qw * qx + qy * qz), 1 - 2 * (qx * qx + qy * qy)),
|
||||
pitch = Math.asin(Math.min(1, Math.max(-1, 2 * (qw * qy - qz * qx)))),
|
||||
yaw = Math.atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz));
|
||||
return [roll, pitch, yaw];
|
||||
}
|
||||
|
||||
function angleDelta(next: number, previous: number): number {
|
||||
return Math.atan2(Math.sin(next - previous), Math.cos(next - previous));
|
||||
}
|
||||
|
||||
function csvCell(value: string | number): string {
|
||||
const text = String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
|
||||
}
|
||||
|
||||
function initialSummary(): TelemetrySummary {
|
||||
return {
|
||||
duration: 0,
|
||||
distanceHorizontal: 0,
|
||||
maxHorizontalSpeed: 0,
|
||||
maxAbsRoll: 0,
|
||||
maxAbsPitch: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export class DataRecorder {
|
||||
private configValue: DataRecorderConfig;
|
||||
private samplesValue: TelemetrySample[] = [];
|
||||
private customChannels = new Map<string, TelemetryChannel>();
|
||||
private recordingValue = false;
|
||||
private limitReachedValue = false;
|
||||
private nextSampleTime?: number;
|
||||
private previousTime?: number;
|
||||
private previousPosition?: [number, number, number];
|
||||
private previousEuler?: [number, number, number];
|
||||
private segment = 0;
|
||||
private pendingSegment = false;
|
||||
private summaryValue = initialSummary();
|
||||
|
||||
constructor(
|
||||
private readonly source: TelemetrySource,
|
||||
private readonly bodies: readonly TelemetryBody[],
|
||||
config?: Partial<DataRecorderConfig>,
|
||||
) {
|
||||
if (!bodies.length) throw new Error('数据记录器至少需要一个可记录 Body');
|
||||
const requestedBody = bodies.some((body) => body.id === config?.bodyId)
|
||||
? config!.bodyId!
|
||||
: bodies[0].id;
|
||||
this.configValue = {
|
||||
bodyId: requestedBody,
|
||||
sampleRateHz: clampInteger(config?.sampleRateHz ?? 50, 1, 1000),
|
||||
maxSamples: clampInteger(config?.maxSamples ?? DEFAULT_MAX_SAMPLES, 100, 1_000_000),
|
||||
};
|
||||
}
|
||||
|
||||
channels(): readonly Omit<TelemetryChannel, 'read'>[] {
|
||||
return [...BUILTIN_CHANNELS, ...this.customChannels.values()].map(({ key, label, unit }) => ({
|
||||
key,
|
||||
label,
|
||||
unit,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册业务自定义标量。key 会成为 CSV/JSON 的稳定列名;开始记录后不可变更列结构。
|
||||
*/
|
||||
registerChannel(channel: TelemetryChannel): () => void {
|
||||
if (this.recordingValue || this.samplesValue.length)
|
||||
throw new Error('已有记录时不能修改数据通道,请先停止并清空记录');
|
||||
if (!/^[a-z][a-z0-9_]*$/i.test(channel.key))
|
||||
throw new Error(`数据通道 key 无效:${channel.key}`);
|
||||
if (
|
||||
BUILTIN_CHANNELS.some((item) => item.key === channel.key) ||
|
||||
this.customChannels.has(channel.key)
|
||||
)
|
||||
throw new Error(`数据通道 key 重复:${channel.key}`);
|
||||
this.customChannels.set(channel.key, channel);
|
||||
return () => {
|
||||
if (this.recordingValue || this.samplesValue.length)
|
||||
throw new Error('已有记录时不能移除数据通道,请先停止并清空记录');
|
||||
this.customChannels.delete(channel.key);
|
||||
};
|
||||
}
|
||||
|
||||
configure(patch: Partial<DataRecorderConfig>): DataRecorderStatus {
|
||||
if (this.recordingValue) throw new Error('请先停止记录再修改采样配置');
|
||||
const next: DataRecorderConfig = {
|
||||
bodyId: patch.bodyId ?? this.configValue.bodyId,
|
||||
sampleRateHz: clampInteger(patch.sampleRateHz ?? this.configValue.sampleRateHz, 1, 1000),
|
||||
maxSamples: clampInteger(patch.maxSamples ?? this.configValue.maxSamples, 100, 1_000_000),
|
||||
};
|
||||
if (!this.bodies.some((body) => body.id === next.bodyId))
|
||||
throw new Error(`无法记录不存在的 Body:${next.bodyId}`);
|
||||
const changed = Object.keys(next).some(
|
||||
(key) =>
|
||||
next[key as keyof DataRecorderConfig] !== this.configValue[key as keyof DataRecorderConfig],
|
||||
);
|
||||
this.configValue = next;
|
||||
if (changed && this.samplesValue.length) this.clear();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
start(): DataRecorderStatus {
|
||||
if (this.samplesValue.length >= this.configValue.maxSamples) return this.status();
|
||||
this.recordingValue = true;
|
||||
this.limitReachedValue = false;
|
||||
this.resetSamplingClock();
|
||||
this.capture(true);
|
||||
return this.status();
|
||||
}
|
||||
|
||||
stop(): DataRecorderStatus {
|
||||
this.recordingValue = false;
|
||||
return this.status();
|
||||
}
|
||||
|
||||
clear(): DataRecorderStatus {
|
||||
this.samplesValue = [];
|
||||
this.recordingValue = false;
|
||||
this.limitReachedValue = false;
|
||||
this.segment = 0;
|
||||
this.pendingSegment = false;
|
||||
this.summaryValue = initialSummary();
|
||||
this.resetSamplingClock();
|
||||
return this.status();
|
||||
}
|
||||
|
||||
/** 仿真 reset 后开启新分段,保留之前的数据且不跨分段计算速度。 */
|
||||
simulationReset(): void {
|
||||
this.pendingSegment = this.samplesValue.length > 0;
|
||||
this.resetSamplingClock();
|
||||
}
|
||||
|
||||
capture(force = false): void {
|
||||
if (!this.recordingValue) return;
|
||||
if (this.samplesValue.length >= this.configValue.maxSamples) {
|
||||
this.recordingValue = false;
|
||||
this.limitReachedValue = true;
|
||||
return;
|
||||
}
|
||||
const time = finite(this.source.simulationTime());
|
||||
if (this.pendingSegment) {
|
||||
this.segment += 1;
|
||||
this.pendingSegment = false;
|
||||
}
|
||||
if (this.previousTime !== undefined && time + 1e-9 < this.previousTime) {
|
||||
this.segment += 1;
|
||||
this.resetSamplingClock();
|
||||
}
|
||||
if (!force && this.nextSampleTime !== undefined && time + 1e-9 < this.nextSampleTime) return;
|
||||
|
||||
const body = this.body(),
|
||||
pose = this.source.bodyPose(body.id),
|
||||
position: [number, number, number] = [
|
||||
finite(Number(pose.position[0])),
|
||||
finite(Number(pose.position[1])),
|
||||
finite(Number(pose.position[2])),
|
||||
],
|
||||
euler = eulerFromQuaternion(pose.quaternion),
|
||||
dt = this.previousTime === undefined ? 0 : Math.max(0, time - this.previousTime),
|
||||
sameSegment =
|
||||
dt > 1e-9 && this.previousPosition !== undefined && this.previousEuler !== undefined,
|
||||
velocity: [number, number, number] = sameSegment
|
||||
? (position.map((value, axis) => (value - this.previousPosition![axis]) / dt) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
])
|
||||
: [0, 0, 0],
|
||||
angularVelocity: [number, number, number] = sameSegment
|
||||
? (euler.map((value, axis) => angleDelta(value, this.previousEuler![axis]) / dt) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
])
|
||||
: [0, 0, 0],
|
||||
horizontalDelta = sameSegment
|
||||
? Math.hypot(
|
||||
position[0] - this.previousPosition![0],
|
||||
position[1] - this.previousPosition![1],
|
||||
)
|
||||
: 0;
|
||||
|
||||
this.summaryValue.distanceHorizontal += horizontalDelta;
|
||||
const values: Record<string, number> = {
|
||||
position_x: position[0],
|
||||
position_y: position[1],
|
||||
height: position[2],
|
||||
velocity_x: velocity[0],
|
||||
velocity_y: velocity[1],
|
||||
velocity_z: velocity[2],
|
||||
speed_horizontal: Math.hypot(velocity[0], velocity[1]),
|
||||
speed_3d: Math.hypot(...velocity),
|
||||
roll: euler[0],
|
||||
pitch: euler[1],
|
||||
yaw: euler[2],
|
||||
angular_velocity_roll: angularVelocity[0],
|
||||
angular_velocity_pitch: angularVelocity[1],
|
||||
angular_velocity_yaw: angularVelocity[2],
|
||||
distance_horizontal: this.summaryValue.distanceHorizontal,
|
||||
contact_count: Math.max(0, Math.round(finite(this.source.contactCount()))),
|
||||
control_rms: rootMeanSquare(this.source.controls()),
|
||||
actuator_force_rms: rootMeanSquare(this.source.actuatorForces()),
|
||||
actuator_power_abs: absoluteActuatorPower(
|
||||
this.source.actuatorForces(),
|
||||
this.source.actuatorVelocities(),
|
||||
),
|
||||
joint_velocity_rms: rootMeanSquare(this.source.generalizedVelocities()),
|
||||
};
|
||||
const context: TelemetryChannelContext = {
|
||||
source: this.source,
|
||||
body,
|
||||
simulationTime: time,
|
||||
pose,
|
||||
};
|
||||
for (const channel of this.customChannels.values()) {
|
||||
try {
|
||||
values[channel.key] = finite(channel.read(context), Number.NaN);
|
||||
} catch {
|
||||
values[channel.key] = Number.NaN;
|
||||
}
|
||||
}
|
||||
const sample: TelemetrySample = {
|
||||
sequence: this.samplesValue.length,
|
||||
segment: this.segment,
|
||||
simulationTime: time,
|
||||
values,
|
||||
};
|
||||
this.samplesValue.push(sample);
|
||||
if (sameSegment) this.summaryValue.duration += dt;
|
||||
this.summaryValue.maxHorizontalSpeed = Math.max(
|
||||
this.summaryValue.maxHorizontalSpeed,
|
||||
values.speed_horizontal,
|
||||
);
|
||||
this.summaryValue.maxAbsRoll = Math.max(this.summaryValue.maxAbsRoll, Math.abs(values.roll));
|
||||
this.summaryValue.maxAbsPitch = Math.max(this.summaryValue.maxAbsPitch, Math.abs(values.pitch));
|
||||
this.summaryValue.minHeight =
|
||||
this.summaryValue.minHeight === undefined
|
||||
? values.height
|
||||
: Math.min(this.summaryValue.minHeight, values.height);
|
||||
this.summaryValue.maxHeight =
|
||||
this.summaryValue.maxHeight === undefined
|
||||
? values.height
|
||||
: Math.max(this.summaryValue.maxHeight, values.height);
|
||||
this.previousTime = time;
|
||||
this.previousPosition = position;
|
||||
this.previousEuler = euler;
|
||||
this.nextSampleTime = time + 1 / this.configValue.sampleRateHz;
|
||||
if (this.samplesValue.length >= this.configValue.maxSamples) {
|
||||
this.recordingValue = false;
|
||||
this.limitReachedValue = true;
|
||||
}
|
||||
}
|
||||
|
||||
status(): DataRecorderStatus {
|
||||
const latest = this.samplesValue.at(-1);
|
||||
return {
|
||||
recording: this.recordingValue,
|
||||
limitReached: this.limitReachedValue,
|
||||
sampleCount: this.samplesValue.length,
|
||||
segmentCount: this.samplesValue.length ? this.segment + 1 : 0,
|
||||
config: { ...this.configValue },
|
||||
body: { ...this.body() },
|
||||
latest: latest ? { ...latest, values: { ...latest.values } } : undefined,
|
||||
summary: { ...this.summaryValue },
|
||||
};
|
||||
}
|
||||
|
||||
samples(): readonly TelemetrySample[] {
|
||||
return this.samplesValue;
|
||||
}
|
||||
|
||||
toCsv(): Uint8Array {
|
||||
const keys = this.channels().map((channel) => channel.key),
|
||||
rows = [
|
||||
['sequence', 'segment', 'simulation_time_s', ...keys].map(csvCell).join(','),
|
||||
...this.samplesValue.map((sample) =>
|
||||
[
|
||||
sample.sequence,
|
||||
sample.segment,
|
||||
sample.simulationTime,
|
||||
...keys.map((key) => sample.values[key] ?? Number.NaN),
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(','),
|
||||
),
|
||||
];
|
||||
return new TextEncoder().encode(`${rows.join('\n')}\n`);
|
||||
}
|
||||
|
||||
toJson(): Uint8Array {
|
||||
return new TextEncoder().encode(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
body: this.body(),
|
||||
config: this.configValue,
|
||||
channels: this.channels(),
|
||||
summary: this.summaryValue,
|
||||
samples: this.samplesValue,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
private body(): TelemetryBody {
|
||||
return this.bodies.find((body) => body.id === this.configValue.bodyId) ?? this.bodies[0];
|
||||
}
|
||||
|
||||
private resetSamplingClock(): void {
|
||||
this.nextSampleTime = undefined;
|
||||
this.previousTime = undefined;
|
||||
this.previousPosition = undefined;
|
||||
this.previousEuler = undefined;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from './SimulationSession';
|
||||
import type { ControllerCommand, ControllerStatus } from '../controller/types';
|
||||
import type { RLCommand, RLPolicyStatus } from '../rl/types';
|
||||
import type { DataRecorderConfig, DataRecorderStatus, TelemetryChannel } from './DataRecorder';
|
||||
import { composePhysicalMap } from '../map/physicalMap';
|
||||
import { composeProjectMap } from '../map/MapComposer';
|
||||
import { resolveProjectMap } from '../map/MapLoader';
|
||||
@@ -64,6 +65,12 @@ export interface PhysicsAdapter {
|
||||
setRLPolicyEnabled(enabled: boolean): void;
|
||||
setRLCommand(command: RLCommand): void;
|
||||
removeRLPolicy(): void;
|
||||
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus | undefined;
|
||||
startDataRecording(): DataRecorderStatus | undefined;
|
||||
stopDataRecording(): DataRecorderStatus | undefined;
|
||||
clearDataRecording(): DataRecorderStatus | undefined;
|
||||
registerDataChannel(channel: TelemetryChannel): (() => void) | undefined;
|
||||
exportDataRecording(format: 'csv' | 'json'): Uint8Array;
|
||||
cachedSupportFiles(): ProjectFile[];
|
||||
releaseRetired(): void;
|
||||
rollbackRetired(): void;
|
||||
@@ -305,6 +312,25 @@ export class MainThreadPhysicsAdapter implements PhysicsAdapter {
|
||||
removeRLPolicy(): void {
|
||||
this.session?.removeRLPolicy();
|
||||
}
|
||||
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus | undefined {
|
||||
return this.session?.configureDataRecorder(config);
|
||||
}
|
||||
startDataRecording(): DataRecorderStatus | undefined {
|
||||
return this.session?.startDataRecording();
|
||||
}
|
||||
stopDataRecording(): DataRecorderStatus | undefined {
|
||||
return this.session?.stopDataRecording();
|
||||
}
|
||||
clearDataRecording(): DataRecorderStatus | undefined {
|
||||
return this.session?.clearDataRecording();
|
||||
}
|
||||
registerDataChannel(channel: TelemetryChannel): (() => void) | undefined {
|
||||
return this.session?.registerDataChannel(channel);
|
||||
}
|
||||
exportDataRecording(format: 'csv' | 'json'): Uint8Array {
|
||||
if (!this.session) throw new Error('请先加载模型');
|
||||
return this.session.exportDataRecording(format);
|
||||
}
|
||||
cachedSupportFiles(): ProjectFile[] {
|
||||
return this.supportFiles.map((file) => ({ ...file, data: file.data.slice() }));
|
||||
}
|
||||
|
||||
@@ -5,6 +5,13 @@ import type { ControllerBindings, ControllerCommand, ControllerStatus } from '..
|
||||
import { Go2wPolicyBindings } from '../rl/runtime/Go2wPolicyBindings';
|
||||
import type { OnnxPolicyRuntime } from '../rl/runtime/OnnxPolicyRuntime';
|
||||
import type { RLCommand, RLPolicyStatus } from '../rl/types';
|
||||
import {
|
||||
DataRecorder,
|
||||
type DataRecorderConfig,
|
||||
type DataRecorderStatus,
|
||||
type TelemetryBody,
|
||||
type TelemetryChannel,
|
||||
} from './DataRecorder';
|
||||
|
||||
export interface ActuatorParameters {
|
||||
gear: number;
|
||||
@@ -63,6 +70,7 @@ export interface SimulationSnapshot {
|
||||
warnings: string[];
|
||||
controller?: ControllerStatus;
|
||||
rlPolicy?: RLPolicyStatus;
|
||||
telemetry: DataRecorderStatus;
|
||||
model: {
|
||||
nbody: number;
|
||||
njnt: number;
|
||||
@@ -99,6 +107,7 @@ export class SimulationSession {
|
||||
private controllerLoadGeneration = 0;
|
||||
private rlPolicy?: OnnxPolicyRuntime;
|
||||
private rlPolicyLoadGeneration = 0;
|
||||
private dataRecorder!: DataRecorder;
|
||||
|
||||
constructor(
|
||||
readonly module: MainModule,
|
||||
@@ -131,6 +140,36 @@ export class SimulationSession {
|
||||
}
|
||||
});
|
||||
module.mj_forward(model, data);
|
||||
const bodies = this.telemetryBodies();
|
||||
this.dataRecorder = new DataRecorder(
|
||||
{
|
||||
simulationTime: () => Number(this.data.time),
|
||||
bodyPose: (bodyId) => {
|
||||
const positionAddress = bodyId * 3,
|
||||
quaternionAddress = bodyId * 4;
|
||||
return {
|
||||
position: [
|
||||
Number(this.data.xpos[positionAddress]),
|
||||
Number(this.data.xpos[positionAddress + 1]),
|
||||
Number(this.data.xpos[positionAddress + 2]),
|
||||
],
|
||||
quaternion: [
|
||||
Number(this.data.xquat[quaternionAddress]),
|
||||
Number(this.data.xquat[quaternionAddress + 1]),
|
||||
Number(this.data.xquat[quaternionAddress + 2]),
|
||||
Number(this.data.xquat[quaternionAddress + 3]),
|
||||
],
|
||||
};
|
||||
},
|
||||
controls: () => this.data.ctrl,
|
||||
actuatorForces: () => this.data.actuator_force,
|
||||
actuatorVelocities: () => this.data.actuator_velocity,
|
||||
generalizedVelocities: () => this.data.qvel,
|
||||
contactCount: () => Number(this.data.ncon),
|
||||
},
|
||||
bodies,
|
||||
{ bodyId: this.defaultTelemetryBody(bodies) },
|
||||
);
|
||||
} catch (error) {
|
||||
perturb?.delete();
|
||||
data?.delete();
|
||||
@@ -155,11 +194,13 @@ export class SimulationSession {
|
||||
this.data.ctrl.fill(0);
|
||||
this.pythonController?.reset(Number(this.data.time));
|
||||
this.rlPolicy?.reset(Number(this.data.time));
|
||||
this.dataRecorder.simulationReset();
|
||||
}
|
||||
singleStep(): void {
|
||||
this.runController();
|
||||
this.applyForce();
|
||||
this.module.mj_step(this.model, this.data);
|
||||
this.dataRecorder.capture();
|
||||
}
|
||||
|
||||
advance(now: number): FrameResult {
|
||||
@@ -182,6 +223,7 @@ export class SimulationSession {
|
||||
this.runController();
|
||||
this.applyForce();
|
||||
this.module.mj_step(this.model, this.data);
|
||||
this.dataRecorder.capture();
|
||||
this.accumulator -= dt;
|
||||
steps++;
|
||||
}
|
||||
@@ -240,6 +282,26 @@ export class SimulationSession {
|
||||
setRLCommand(command: RLCommand): void {
|
||||
this.rlPolicy?.setCommand(command);
|
||||
}
|
||||
|
||||
configureDataRecorder(config: Partial<DataRecorderConfig>): DataRecorderStatus {
|
||||
return this.dataRecorder.configure(config);
|
||||
}
|
||||
startDataRecording(): DataRecorderStatus {
|
||||
return this.dataRecorder.start();
|
||||
}
|
||||
stopDataRecording(): DataRecorderStatus {
|
||||
return this.dataRecorder.stop();
|
||||
}
|
||||
clearDataRecording(): DataRecorderStatus {
|
||||
return this.dataRecorder.clear();
|
||||
}
|
||||
registerDataChannel(channel: TelemetryChannel): () => void {
|
||||
return this.dataRecorder.registerChannel(channel);
|
||||
}
|
||||
exportDataRecording(format: 'csv' | 'json'): Uint8Array {
|
||||
return format === 'csv' ? this.dataRecorder.toCsv() : this.dataRecorder.toJson();
|
||||
}
|
||||
|
||||
removeRLPolicy(): void {
|
||||
this.rlPolicyLoadGeneration += 1;
|
||||
this.rlPolicy?.dispose();
|
||||
@@ -553,6 +615,31 @@ export class SimulationSession {
|
||||
this.data.xfrc_applied[offset + 2] += this.force[2];
|
||||
}
|
||||
|
||||
private telemetryBodies(): TelemetryBody[] {
|
||||
const bodies = Array.from({ length: this.model.nbody }, (_, id) => {
|
||||
const body = this.model.body(id);
|
||||
try {
|
||||
return { id, name: body.name || (id === 0 ? 'world' : `body_${id}`) };
|
||||
} finally {
|
||||
body.delete();
|
||||
}
|
||||
}).filter((body) => body.id > 0 && !body.name.startsWith('__platform_map_'));
|
||||
return bodies.length ? bodies : [{ id: 0, name: 'world' }];
|
||||
}
|
||||
|
||||
private defaultTelemetryBody(bodies: readonly TelemetryBody[]): number {
|
||||
for (const body of bodies) {
|
||||
if (Number(this.model.body_parentid[body.id]) !== 0) continue;
|
||||
const firstJoint = Number(this.model.body_jntadr[body.id]),
|
||||
jointCount = Number(this.model.body_jntnum[body.id]);
|
||||
for (let offset = 0; offset < jointCount; offset += 1)
|
||||
if (this.jointLimits[firstJoint + offset]?.type === 0) return body.id;
|
||||
}
|
||||
return (
|
||||
bodies.find((body) => Number(this.model.body_parentid[body.id]) === 0)?.id ?? bodies[0].id
|
||||
);
|
||||
}
|
||||
|
||||
/** 用有限几何的包围球估算视图中心与范围,忽略地面等无限平面。 */
|
||||
geometryBounds(): { center: [number, number, number]; extent: number } {
|
||||
const lower = [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY];
|
||||
@@ -825,6 +912,7 @@ export class SimulationSession {
|
||||
warnings: this.warnings,
|
||||
controller: this.pythonController?.status(),
|
||||
rlPolicy: this.rlPolicy?.status(),
|
||||
telemetry: this.dataRecorder.status(),
|
||||
model: {
|
||||
nbody: this.model.nbody,
|
||||
njnt: this.model.njnt,
|
||||
|
||||
Reference in New Issue
Block a user