chore(release): v0.1.1 USD文件初步校验
原因:记录 L20 USD 初步校验、严格浮动覆盖层、受限动力学回放及 HDF5 交付契约,包版本更新为 0.1.1。 验证:68 项 CPU/USD 回归测试通过,Ruff/format 与暂存 diff 检查通过;已核验单环境 small 2x480、合成 HDF5 2x960 步明确 PASS。独立暂存审查未发现问题。完整 pre-commit 因模块缺失未执行,干净环境安装未验证。 兼容性:Cartpole 及任务 ID 不变,原始 USD/URDF 未改;旧 prepared 覆盖层需重新生成。仅合成轨迹初步校验,不代表真实专家回放、训练或硬件验收。
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
# L20 左手专家轨迹 HDF5 交付要求
|
||||
|
||||
**接口版本:`l20_tracking_v1`**
|
||||
|
||||
**交付对象:视频重定向/示教数据同事**
|
||||
|
||||
**当前用途:浮动腕部与全部手指参考关节的轨迹回放,后续用于学习策略。**
|
||||
|
||||
本文是可独立分享的数据交付说明,与当前仓库校验器一致。它不是通用 HDF5 标准,也不是完整 DexSchema。
|
||||
请先交付 **1 条短样例**,确认坐标、尺度、关节映射后再批量导出。
|
||||
|
||||
## 1. 要交什么
|
||||
|
||||
交付重定向后的 **L20 左手机器人参考状态轨迹**:
|
||||
|
||||
- 腕部/手根的三维位置与姿态。
|
||||
- 约定模型的全部手指关节角,包括从动关节。
|
||||
- 同步时间戳与逐帧有效标记。
|
||||
- 模型身份、坐标变换、尺度和数据来源说明。
|
||||
|
||||
不是原始人手关键点、MANO 参数或电机实测指令。关节状态不能直接等同于执行器控制命令。
|
||||
本阶段不要求图像、物体位姿、奖励、接触标签、速度、加速度、电流或力矩;缺少这些内容不需要补零伪造。
|
||||
|
||||
## 2. 文件结构(必填)
|
||||
|
||||
文件扩展名建议使用 `.hdf5`;HDF5 根属性通过 `file.attrs` 保存,**不要创建名为 `attrs` 的 group**。
|
||||
|
||||
```text
|
||||
demonstrations.hdf5
|
||||
├── 根属性:schema_version、embodiment、hand_side、asset_sha256、root_link、
|
||||
│ provenance、source_description、metric_scale_provenance、scale_to_meters
|
||||
├── metadata/
|
||||
│ ├── joint_names UTF-8 [J]
|
||||
│ └── world_from_source float64 [4, 4]
|
||||
└── episodes/
|
||||
├── demo_000000/
|
||||
│ ├── time float64 [T]
|
||||
│ ├── wrist_position float32 [T, 3]
|
||||
│ ├── wrist_quaternion float32 [T, 4]
|
||||
│ ├── joint_position float32 [T, J]
|
||||
│ └── valid bool [T]
|
||||
└── demo_000001/
|
||||
└── 同样结构,可以有不同帧数 T
|
||||
```
|
||||
|
||||
- 至少一个 episode;名称必须为 `demo_` 加六位数字,如 `demo_000012`。
|
||||
- 每条 episode 至少两帧;各字段的 T 必须相同。
|
||||
- 一个文件共享模型、关节顺序及源世界标定;标定、模型或关节布局改变时另建文件。
|
||||
- 当前约定资产的 **J=21**,不是从“L20”名称推断出的自由度。21 个状态关节不代表 21 个独立执行器。
|
||||
|
||||
## 3. 根属性
|
||||
|
||||
除 `scale_to_meters` 外,下列属性均为非空 UTF-8 字符串。
|
||||
|
||||
| 属性 | 内容与要求 |
|
||||
| --- | --- |
|
||||
| `schema_version` | 固定为 `l20_tracking_v1` |
|
||||
| `embodiment` | 固定为 `L20` |
|
||||
| `hand_side` | 固定为 `left` |
|
||||
| `asset_sha256` | 双方约定的原始模型依赖包哈希,64 位小写十六进制;见第 6 节 |
|
||||
| `root_link` | 固定为当前模型的 `hand_base_link` |
|
||||
| `provenance` | 实际专家重定向数据填 `expert_retargeted`;程序生成的测试数据填 `synthetic` |
|
||||
| `source_description` | 视频/轨迹标识、重定向工具及版本、世界原点与 X/Y 朝向、源坐标约定、腕点到机器人根部的映射依据、质量限制 |
|
||||
| `metric_scale_provenance` | 米制尺度如何获得,标定方法/版本或估算方法、可信程度与已知误差;不得把估算写成实测 |
|
||||
| `scale_to_meters` | **有限正浮点标量**,记录已应用的源位置尺度系数;不是要求读取方再缩放 |
|
||||
|
||||
例:源位置以毫米表示,则 `scale_to_meters=0.001`;输出位置仍须已经是米。
|
||||
源数据已经是米时可填 `1.0`,但必须有实际依据,不能仅为通过校验填写。
|
||||
|
||||
## 4. 字段与坐标语义
|
||||
|
||||
| 路径 | 类型 / shape | 单位及语义 |
|
||||
| --- | --- | --- |
|
||||
| `metadata/joint_names` | HDF5 UTF-8 string `[J]` | `joint_position` 每列对应的模型关节名称,严格使用第 5 节顺序 |
|
||||
| `metadata/world_from_source` | `float64 [4,4]` | 已应用的源世界系到交付世界系刚体变换;矩阵平移部分单位 m |
|
||||
| `episodes/.../time` | `float64 [T]` | 秒;每条轨迹从 0 开始,严格递增 |
|
||||
| `episodes/.../wrist_position` | `float32 [T,3]` | `hand_base_link` 的 **link 原点**在交付世界系中的 `[x,y,z]`,单位 m |
|
||||
| `episodes/.../wrist_quaternion` | `float32 [T,4]` | **`[w,x,y,z]`**;将根 link 局部向量主动旋转到交付世界系 |
|
||||
| `episodes/.../joint_position` | `float32 [T,J]` | 参考关节角,单位 rad;零位和正方向与约定模型一致 |
|
||||
| `episodes/.../valid` | `bool [T]` | 整帧是否有效;不是每关节标记,不使用 0/1 整数数组代替 bool |
|
||||
|
||||
### 坐标与姿态
|
||||
|
||||
1. 交付世界系采用 **右手系、Z 向上**,在一个 episode 内固定,不随腕部移动。
|
||||
2. 根部位姿不是人手腕点、机器人质心或 USD default prim 位姿。重定向端必须先完成到
|
||||
`hand_base_link` 坐标系的映射,并说明映射依据。
|
||||
3. 记录的尺度和刚体变换满足:
|
||||
|
||||
```text
|
||||
p_world = R_world_from_source @ (scale_to_meters * p_source) + t_world_from_source
|
||||
```
|
||||
|
||||
此式用于源位置点;人手腕点到机器人根部的额外映射仍须由重定向端完成。
|
||||
导出的 `wrist_position` 和 `wrist_quaternion` **已经在交付世界系**。
|
||||
接收端不会再次应用该矩阵或尺度。
|
||||
4. `world_from_source` 最后一行为 `[0,0,0,1]`,旋转部分正交且行列式为 +1,不能混入尺度或镜像。
|
||||
已统一世界系时可以使用单位矩阵,但不能用单位矩阵掩盖未知标定。
|
||||
5. 有效四元数必须单位化,范数容差 `1e-4`;相邻两帧都有效时,四元数点积必须非负。
|
||||
`q` 与 `-q` 表示同一旋转,导出时应统一相邻符号;不能混用 `xyzw` 或欧拉角。
|
||||
6. 不要为了适配当前合成测试而把真实轨迹自动平移到 `z=0.4`,或把起始姿态强制设为单位四元数。
|
||||
场景对齐必须有明确、可追溯的坐标约定。
|
||||
|
||||
## 5. 关节顺序与联动
|
||||
|
||||
当前 `joint_names` 必须严格等于下列列表;这是数据清单顺序,**不是仿真运行时 DOF 顺序**。
|
||||
|
||||
```python
|
||||
joint_names = [
|
||||
"index_dip", "index_mcp_pitch", "index_mcp_roll", "index_pip",
|
||||
"middle_dip", "middle_mcp_pitch", "middle_mcp_roll", "middle_pip",
|
||||
"pinky_dip", "pinky_mcp_pitch", "pinky_mcp_roll", "pinky_pip",
|
||||
"ring_dip", "ring_mcp_pitch", "ring_mcp_roll", "ring_pip",
|
||||
"thumb_cmc_pitch", "thumb_cmc_roll", "thumb_cmc_yaw", "thumb_ip", "thumb_mcp",
|
||||
]
|
||||
```
|
||||
|
||||
- 不允许重名、缺列、多列、静默重排或用其他手型的关节代替。
|
||||
- 每个有效帧必须满足清单 `joints[].lower_rad/upper_rad`,校验容差为 `1e-6 rad`。
|
||||
精确限位以随交付约定的 JSON 清单为准,避免使用文档四舍五入值作为限位。
|
||||
- 当前源模型定义以下联动;数据中仍须包含这些从动关节列:
|
||||
|
||||
```text
|
||||
thumb_ip = 1.02 * thumb_mcp
|
||||
index_dip = 0.89 * index_pip
|
||||
middle_dip = 0.89 * middle_pip
|
||||
ring_dip = 0.89 * ring_pip
|
||||
pinky_dip = 0.89 * pinky_pip
|
||||
```
|
||||
|
||||
偏置均为 0 rad,有效帧等式残差绝对值不得超过 `1e-3 rad`。
|
||||
- 联动关系和各关节限位必须同时满足;不能单独裁剪从动关节而破坏联动。
|
||||
- 这些是约定模型的约束,不是已经核实的硬件电机协议。重定向映射有疑问时先确认,不要擅自独立控制从动关节。
|
||||
|
||||
## 6. 模型身份与随附清单
|
||||
|
||||
本仓库当前清单:
|
||||
|
||||
[`assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json`](assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json)
|
||||
|
||||
当前原始模型依赖包 `asset_sha256`:
|
||||
|
||||
```text
|
||||
6c8f35358f481cf604ee802588017f30c1661c0f38e1187c49e5340db3830538
|
||||
```
|
||||
|
||||
这是**当前版本快照**。请由仿真方提供同版 `tracking_manifest.json` 与约定模型,数据方确认使用该模型后填写哈希。
|
||||
不得把上述哈希复制到实际使用了其他模型的数据中。
|
||||
|
||||
- 哈希不是 HDF5 文件自身哈希,不是仅入口 USD 的哈希,也不是浮动控制覆盖层的哈希。
|
||||
- 它由完整解析资源包的相对路径与各文件 SHA-256 汇总生成,使用仓库工具获取,不自行猜测算法。
|
||||
- 模型依赖内容或名称变化时必须重新对齐。分享本文给同事时,**请同时附上同版清单**,不要只发送失效的仓库相对链接。
|
||||
|
||||
## 7. 时间同步、无效帧与缺失数据
|
||||
|
||||
- `time[0]` 必须精确等于 `0.0`,之后严格递增;不得有重复、倒序时间戳。
|
||||
- 允许非均匀采样,不强制视频帧率等于仿真控制频率。保留实际时间间隔,不通过伪造时间戳“拉齐”数据。
|
||||
- 同一帧腕部、姿态和关节角必须已经同步;异步数据须由交付方先明确对齐方式。
|
||||
- 任一必需部分缺失或不可信,整帧 `valid=false`。至少一帧有效;全部无效的 episode 会被拒绝。
|
||||
- **所有数值都必须有限,包括无效帧,不能出现 NaN/Inf。** 无效帧可以用明确的有限占位值,
|
||||
但必须保留 `valid=false`,并在来源说明中解释。无效帧四元数不要求单位化。
|
||||
- 必填 dataset 不能省略;禁止把缺失帧补零后标成有效,或把插值/估算值冒充测量。
|
||||
- 当前重采样 CLI 只接受全有效 episode,不跨无效片段插值。建议交付方将连续有效片段显式切成
|
||||
独立 episode,并为每段重新设置从 0 开始的时间;原片段来源需可追溯。
|
||||
|
||||
## 8. 导出注意事项
|
||||
|
||||
使用 `h5py` 时,字符串必须显式声明 UTF-8,数值类型按契约转换:
|
||||
|
||||
```python
|
||||
# 仅展示 dtype 写法;不是完整数据生成器,不包含真实轨迹。
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
utf8 = h5py.string_dtype(encoding="utf-8")
|
||||
# metadata.create_dataset("joint_names", data=joint_names, dtype=utf8)
|
||||
# group.create_dataset("time", data=np.asarray(time, dtype=np.float64))
|
||||
# group.create_dataset("wrist_position", data=np.asarray(position, dtype=np.float32))
|
||||
# group.create_dataset("wrist_quaternion", data=np.asarray(quaternion_wxyz, dtype=np.float32))
|
||||
# group.create_dataset("joint_position", data=np.asarray(q, dtype=np.float32))
|
||||
# group.create_dataset("valid", data=np.asarray(valid, dtype=np.bool_))
|
||||
```
|
||||
|
||||
不要用 `dtype="S"` 或普通 ASCII bytes 数组写 `joint_names`。不要用 float64 替代契约中的 float32,
|
||||
也不要用整数时间戳或整数 `valid` 代替约定类型。
|
||||
|
||||
## 9. 交付及验收流程
|
||||
|
||||
### 数据方交付
|
||||
|
||||
1. 一份包含一条短轨迹的 `.hdf5` 样例,优先选择连续、全有效、缓慢运动的片段。
|
||||
2. 对应模型清单/版本确认。
|
||||
3. 明确的标定与重定向说明;写入根属性,也可附独立说明文件。
|
||||
4. 可选:对应原视频片段或可视化,便于人工核对方向、尺度与动作,不是本格式必填字段。
|
||||
|
||||
### 仿真方校验
|
||||
|
||||
从仓库根目录运行:
|
||||
|
||||
```bash
|
||||
export PYTHONPATH="$PWD/source/dex_workbench${PYTHONPATH:+:$PYTHONPATH}"
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli validate /path/to/demonstrations.hdf5 \
|
||||
--manifest assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json
|
||||
```
|
||||
|
||||
该校验不启动仿真。仿真方本地的 `python.sh` 路径不是数据文件的依赖;具备项目模块、NumPy、h5py
|
||||
的普通 Python 环境也可执行同一 `-m` 命令。
|
||||
|
||||
- **PASS**:schema、模型身份、关节顺序、限位和已记录联动一致。
|
||||
- **FAIL**:修正具体报错后重新交付,不绕过校验器或放宽断言。
|
||||
- 不加 `--manifest` 只检查格式,不能证明模型/关节兼容。
|
||||
- 校验通过后仍需人工核对坐标和标定,再做受限动力学回放。格式通过不等于重定向正确、动力学可执行、
|
||||
策略已经可训练或真机可运行。
|
||||
|
||||
**注意:** 当前诊断脚本要求腕部及全部五组联动都有足够运动,用于发现失效约束;这是诊断用例要求,
|
||||
不是 HDF5 格式要求。合法的静止或局部手指轨迹不应为了满足诊断而添加伪造动作。
|
||||
|
||||
仿真实现与已有验证结果另见 [`L20_TRACKING.md`](L20_TRACKING.md)。本文只约定数据交付,不要求数据方运行 Isaac Sim。
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
# L20 左手轨迹跟踪:数据交付与准备入口
|
||||
|
||||
## 本次可用范围与验证状态
|
||||
|
||||
提供 **CPU 数据接口、资产清单、浮动控制覆盖层和实验性动力学跟踪入口**,不是完成的 RL 环境。
|
||||
覆盖层和控制数学有 CPU/USD 静态验证;**注册 schema 覆盖层的单环境合成轨迹动力学 smoke 已 PASS**。
|
||||
此前启动顺序、异常退出码和 schema 默认值保留问题已修复;后续授权重测完成两轮各480步。
|
||||
后续还完成4秒/10mm/0.1rad的合成 HDF5 文件加载与两轮各960步验收,并消除了旧世界 anchor 的
|
||||
CreateJoint 警告(见第4节)。通过范围仅限指定资产、后端、参数及两种合成参考,不代表真实专家轨迹、
|
||||
任意运动或 RL 环境验收。
|
||||
保留 `Template-Dex-Workbench-v0` / Cartpole 及其 checkpoint。没有新训练任务、物体、奖励、策略或训练结果。
|
||||
|
||||
实际组合资产检查发现:
|
||||
|
||||
- 当前入口 `assets/robots/dex_hand/linkerhand_g20_left/linkerhand_g20_left/linkerhand_g20_left.usda`
|
||||
是**世界固定基座**,尽管转换缓存写 `fix_base: false`。
|
||||
- 22 个刚体、21 个 revolute 关节,另有一个世界 fixed joint;根 link 为 `hand_base_link`。
|
||||
**21 是导入状态关节数,不是独立执行器数**。实际名称、排序、限位、驱动属性、质量惯量见
|
||||
[`tracking_manifest.json`](assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json)。
|
||||
- 原始 URDF(清单记录其 SHA-256,未修改)含 5 个 mimic:
|
||||
`thumb_ip = 1.02 * thumb_mcp`;`index/middle/ring/pinky` 各自 `dip = 0.89 * pip`;offset 均为 0 rad。
|
||||
- **纠正前轮结论**:安装的 Isaac Sim 6.0.1 / PhysX 110.1.13 原生解析 `NewtonMimicAPI`,
|
||||
旧 `PhysxMimicJointAPI` 已弃用。原资产的五个 Newton 方程正确,不能因缺少 Physx 前缀就认定缺约束。
|
||||
依据包括安装 schema、PhysX migration checker/tests,以及 PhysX 插件中的 `parseNewtonMimicJoints` 与
|
||||
对应解析诊断。它们是支持性证据,**不是本资产的动力学执行证明**。
|
||||
清单改为 `MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED`,仍 `dynamic_replay_ready=false`。
|
||||
- 原资产五个从动关节同时有独立 angular drive。新 `prepared.py` 覆盖层保留原生 Newton 约束,
|
||||
去掉这五个 DriveAPI 并将其 stiffness/damping/maxForce 置零;仅 16 个模型独立关节接收目标。
|
||||
不新增第二套约束、不修改原始 USD/URDF,不改变关节限位、质量、惯量、碰撞或变换。
|
||||
16 是根据该模型推导的实验控制映射,**不是已确认的硬件电机数**。
|
||||
- `require_dynamic_replay_ready()` 仍拒绝仅凭数据 JSON 启动控制。实验入口 `track_l20.py` 另行检查实际
|
||||
覆盖层、源包哈希、逐项结构与约束、已启用后端版本、运行时从动增益和被动联动响应;不是改状态位绕过。
|
||||
|
||||
旧 `--floating-overlay` 仍仅是拓扑诊断,不应直接 Play。新 `prepared` 覆盖层也不是自带悬浮能力的资产:
|
||||
必须配合实验控制器才有受限重力补偿;本次有合成参考的仿真跟踪指标,但不宣称可训练或 Sim2Real 有效。
|
||||
|
||||
## 1. 给数据同事的 HDF5 契约:`l20_tracking_v1`
|
||||
|
||||
可直接分享的独立说明见 [`HDF5_REQUIREMENTS.md`](HDF5_REQUIREMENTS.md),发送时请同时附同版模型清单。
|
||||
|
||||
这是本仓库的跟踪专用接口,不宣称已实现全项目 DexSchema。交付**重定向后的机器人参考状态**,
|
||||
不是原始人手关键点,不是实测执行器命令。先交一条短样例,不要求物体、图片、力矩或速度。
|
||||
|
||||
```text
|
||||
demonstrations.hdf5
|
||||
├── attrs
|
||||
│ ├── schema_version = "l20_tracking_v1"
|
||||
│ ├── embodiment = "L20"
|
||||
│ ├── hand_side = "left"
|
||||
│ ├── asset_sha256 = "<双方约定的清单 asset_sha256>"
|
||||
│ ├── root_link = "hand_base_link"
|
||||
│ ├── provenance = "expert_retargeted" 或 "synthetic"
|
||||
│ ├── source_description = "采集/重定向版本、世界原点/朝向、校准来源、质量说明"
|
||||
│ ├── metric_scale_provenance = "米制尺度如何获得,是否估算及可信程度"
|
||||
│ └── scale_to_meters = 1.0 # 浮点标量,已应用的源尺度系数,不一定是1
|
||||
├── metadata
|
||||
│ ├── joint_names UTF-8 [J]
|
||||
│ └── world_from_source float64 [4,4]
|
||||
└── episodes
|
||||
└── demo_000000 # demo_ + 六位数字;可有多条
|
||||
├── time float64 [T]
|
||||
├── wrist_position float32 [T,3]
|
||||
├── wrist_quaternion float32 [T,4]
|
||||
├── joint_position float32 [T,J]
|
||||
└── valid bool [T]
|
||||
```
|
||||
|
||||
### 坐标、单位和身份
|
||||
|
||||
- 所有字符串非空 UTF-8。`joint_names` 必须是 HDF5 UTF-8 string dtype,不能使用 ASCII byte arrays。
|
||||
- `J` 来自约定的机器人清单,不从 L20 名称推断。`joint_names` 必须**逐项等于清单 `joints[].name` 的排序**。
|
||||
此排序为名字字典序,不是 Isaac 的 DOF 顺序;后续 Adapter 必须再显式按名称映射。
|
||||
- 根部位置对应 `hand_base_link` **link 原点**(不是质量中心、人手腕点或模型 default prim 原点)。
|
||||
人手腕点到机器人根 link 的变换由重定向端应用;不能交付后再隐式猜测。
|
||||
- 位置单位 **m**,关节角 **rad**,时间 **s**;世界系右手、Z 向上。
|
||||
世界原点、X/Y 朝向及源相机/重建坐标约定必须在 `source_description` 中注明并与仿真场景对齐。
|
||||
一个文件共享一个源世界校准;相机/校准改变时另建文件,世界系不随腕部移动。
|
||||
- 四元数排列 **wxyz**,表示 root-link 局部向量到世界向量的主动旋转。
|
||||
有效帧必须单位化(范数容差 `1e-4`);相邻有效帧四元数点积必须非负,避免符号翻转。
|
||||
- `world_from_source` 表示已经应用的刚体变换:
|
||||
`p_world = R * (scale_to_meters * p_source) + t`。该矩阵不包含尺度;平移单位为 m。
|
||||
导出数据已在世界系,读取器**不会再次应用矩阵或尺度**。姿态同样已转换。
|
||||
`scale_to_meters` 必须为有限正浮点数;不能把估算尺度写成实测。
|
||||
- `asset_sha256` 是**完整本地 USD 依赖包**的内容身份,不是仅入口文件 SHA。
|
||||
清单按相对入口目录的 POSIX 文件名排序,对每个 `relative_path + NUL + file_sha256 + LF` 的 UTF-8
|
||||
记录累积 SHA-256。包含 USD 层和解析到的资源,重定位整个包身份不变;任何依赖内容/名称变化会改变身份。
|
||||
清单本身不属于 USD 依赖;独立记录源 URDF SHA。不要把固定版与浮动 overlay 的包哈希混用。
|
||||
|
||||
### 时间、缺失和状态语义
|
||||
|
||||
- 每条至少 2 帧,`time[0] == 0`,严格递增;所有字段使用同一时间轴、同一 T。
|
||||
允许非均匀采样,保留真实时间戳。不同步数据必须先由交付方明确对齐。
|
||||
- `joint_position` 包含所有列出的参考关节状态,包括 mimic 从动关节;不据此认定全部可独立驱动。
|
||||
带清单校验会检查有效帧限位(容差 `1e-6` rad)以及已记录的 URDF mimic 等式(容差 `1e-3` rad)。
|
||||
不静默换序、裁剪角度、补关节或归零修复。新数据误差超容差时应确认重定向质量,而非放宽验收。
|
||||
- 缺失/低可信帧必须 `valid=false`。本版本所有数值 payload 均要求有限(包括无效帧);无效帧可用有限占位,
|
||||
但绝不能标成有效数据。无效四元数不要求单位化。至少一帧有效;完全缺失字段不能省略或静默补真值。
|
||||
- 读取器拒绝 NaN/Inf、重复名字、错误 shape/dtype、非单调时间、不合法刚体矩阵、未知版本/来源类别等。
|
||||
元数据文字存在不证明标定正确,仍需人工核对来源。
|
||||
- API `sample()` 对腕位置/关节角线性插值,对姿态 shortest-arc SLERP;不外推。
|
||||
输出四元数重新保持相邻符号连续;这不能恢复降采样丢失的旋转圈数或高频运动,频率需按轨迹带宽选择。
|
||||
**任何查询范围跨过/触及无效帧均拒绝**,包括仅查询无效间隙两侧的稀疏点。必须显式分割有效片段。
|
||||
CLI 重采样仅接受全有效 episode,不自动分段;输出从 0 开始的等间隔网格,离网格的尾帧省略。
|
||||
- 速度/加速度非必填。本次不估算并保存速度,不把未来派生值冒充硬件测量。
|
||||
|
||||
## 2. 本地命令
|
||||
|
||||
在仓库根目录运行。独立 namespace 包 `dex_workbench_tracking` **不导入** `dex_workbench` 的任务注册,
|
||||
CPU 验证不要求 Kit/GPU。普通 Python 需要 numpy/h5py;本机系统 Python 缺 h5py,以下用已有 Isaac 启动器。
|
||||
安装环境可选 `pip install -e 'source/dex_workbench[tracking]'`,本次没有安装/升级任何依赖。
|
||||
也可不安装,使用:
|
||||
|
||||
```bash
|
||||
export PYTHONPATH="$PWD/source/dex_workbench${PYTHONPATH:+:$PYTHONPATH}"
|
||||
ASSET=assets/robots/dex_hand/linkerhand_g20_left/linkerhand_g20_left/linkerhand_g20_left.usda
|
||||
MANIFEST=assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json
|
||||
|
||||
# 复查真实组合资产;输出必须是尚不存在的路径
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.asset "$ASSET" --output /tmp/l20-inspected.json
|
||||
# 如有原始 URDF,用显式本地路径对照(不是运行时必需依赖)
|
||||
# ... --source-urdf "$SOURCE_URDF" --output /tmp/l20-with-urdf.json
|
||||
|
||||
# 生成明确标为 synthetic 的 2 秒参考样例,121 帧;不是专家数据或动态控制验收
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli synthetic \
|
||||
--manifest "$MANIFEST" --output /tmp/l20-synthetic.hdf5
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli validate \
|
||||
/tmp/l20-synthetic.hdf5 --manifest "$MANIFEST"
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli resample \
|
||||
/tmp/l20-synthetic.hdf5 --manifest "$MANIFEST" --hz 240 --output /tmp/l20-resampled.hdf5
|
||||
|
||||
# 动态准备门禁:预期非零退出且 BLOCKED;直接 Python 返回2,本机 python.sh 将非零映射为1
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli replay-check --manifest "$MANIFEST"
|
||||
|
||||
# 不提供 manifest 仅作结构检查,输出 asset_compatibility=NOT_CHECKED
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli validate /path/to/demonstrations.hdf5
|
||||
|
||||
# 生成诊断层:保留原入口、fixed_base_setup.usda 和其他依赖原始字节
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.asset "$ASSET" \
|
||||
--floating-overlay /tmp/l20-floating.usda --output /tmp/l20-floating-manifest.json
|
||||
|
||||
# 仅在没有冲突的 GPU/Kit 作业时运行;本次因已有 GUI 实例而未执行
|
||||
# 此入口只验证 Isaac Lab 引用后 root/关节/初始有限变换,不运行物理、reset 或 replay
|
||||
# --help 的 AppLauncher 参数以本地 Isaac Lab 为准;默认 headless,单场景,seed=42
|
||||
timeout 300s ~/isaacsim/python.sh scripts/tracking/inspect_l20_scene.py /tmp/l20-floating.usda --headless
|
||||
|
||||
# CPU 单测;第二组还需要 pxr(已有 Isaac Python 提供)
|
||||
~/isaacsim/python.sh -m unittest discover -s source/dex_workbench/tests -p 'test_tracking_trajectory.py' -v
|
||||
~/isaacsim/python.sh -m unittest discover -s source/dex_workbench/tests -p 'test_tracking_asset.py' -v
|
||||
```
|
||||
|
||||
所有生成入口拒绝覆盖已有文件。HDF5 CLI 先在目标目录的临时文件中写入并校验,成功后以硬链接发布;
|
||||
文件系统须支持硬链接,否则明确失败,不回退为覆盖写入。校验失败不留下目标文件。
|
||||
示例输出位置是临时产物,不应加入 Git;分享给同事时标清 synthetic。
|
||||
清单 snapshot 绑定当前依赖字节,资产变化后必须重新生成、审查并与数据方更新版本,不直接沿用旧哈希。
|
||||
普通 `validate` 的 PASS 只证明契约;即使带清单 PASS,`dynamic_replay` 仍为 BLOCKED。
|
||||
|
||||
## 3. 实验性 PhysX 浮动跟踪(单环境合成轨迹 smoke PASS)
|
||||
|
||||
```bash
|
||||
# 调用方显式选择本机插件;这些路径不是可移植资产依赖,其他安装应替换。
|
||||
# 多版本安装须人工选定一个 PhysX 插件,不能把多条路径合并为一个参数。
|
||||
PHYSX_PLUGIN=$(find "$HOME/isaacsim/extscache" -path '*/plugins/PhysxSchema/resources/plugInfo.json' -print)
|
||||
NEWTON_PLUGIN="$HOME/isaacsim/exts/omni.usd.schema.newton/usd/schema/newton/newton_usd_schemas/plugInfo.json"
|
||||
# 必须用原始数据/source manifest;覆盖层身份另外记录,不能静默改 HDF5 asset_sha256
|
||||
# 必须用新输出路径;旧覆盖层需重新生成,不覆盖已有文件。
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.prepared "$ASSET" \
|
||||
--manifest "$MANIFEST" --output /tmp/l20-tracking-registered.usda \
|
||||
--schema-plugin-path "$PHYSX_PLUGIN" --schema-plugin-path "$NEWTON_PLUGIN"
|
||||
# 对已生成的实际 USD 重新检查;使用同样的插件上下文,不是读取 ready 状态位
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.prepared /tmp/l20-tracking-registered.usda \
|
||||
--manifest "$MANIFEST" --schema-plugin-path "$PHYSX_PLUGIN" --schema-plugin-path "$NEWTON_PLUGIN"
|
||||
|
||||
# 先协调关闭/释放用户 GUI 会话;注册 schema 覆盖层已通过一次限定的合成轨迹验收。
|
||||
timeout 300s ~/isaacsim/python.sh scripts/tracking/track_l20.py /tmp/l20-tracking-registered.usda \
|
||||
--manifest "$MANIFEST" --execute-experimental --headless --steps 480
|
||||
# 默认使用明确标注的 synthetic 轨迹;已有合格 HDF5 时可加:
|
||||
# --hdf5 /path/to/demonstrations.hdf5 --episode demo_000000
|
||||
|
||||
~/isaacsim/python.sh -m unittest discover -s source/dex_workbench/tests -p 'test_tracking_*.py' -v
|
||||
```
|
||||
|
||||
### 修复过程记录(历史失败保留)
|
||||
|
||||
- 旧覆盖层启动命令(单手、seed42、`--steps 480`、两次回放目标、外部 `timeout 300s`)实际执行失败,
|
||||
没有进入 scene/reset/physics loop,没有跟踪误差指标或 PASS JSON。
|
||||
- 初次启动:`pxr` 在 App 前导入,Kit 报 USD registry/free 崩溃,启动器退出1。
|
||||
两个诊断入口均已改为先启动 App,再在 `try/finally` 内进行 USD 检查;资产门禁仍早于 scene/控制。
|
||||
- 第一次重试:App 启动成功但退出0且**没有 PASS**。确认 `SimulationApp.close()` 的 fast shutdown
|
||||
会掩盖进行中的异常;该次是失败,不是通过。已添加异常 traceback 和 `close(exit_code=1)`。
|
||||
- 第二次重试:退出1,明确报告旧 anchor 的 `physxArticulation:sleepThreshold` 默认值不一致。
|
||||
注册 PhysX 的纯 CPU 复现表明:移除 API 后旧 anchor 的三个未显式写入的默认值消失;
|
||||
root 默认值与原值一致,但严格保留检查正确拒绝旧 anchor 差异。没有通过放宽检查来修复。
|
||||
- 修复:移除 API 前快照全部已解析 articulation 属性(含 schema fallback),在旧 anchor 和新 root
|
||||
显式保留原值;记录 authoring schema context。跨上下文检查拒绝并要求重新生成。
|
||||
不带插件选项的纯静态输出标为 `STATIC_ONLY_SCHEMA_UNREGISTERED`,不能推荐用于运行时。
|
||||
- **CPU 回归 60 项通过**(原57项加启动/异常退出与隔离注册 schema 测试),无跳过;
|
||||
新注册覆盖层生成和复查通过,source bundle SHA 不变。该修复交付内未做第三次 GPU 重试,增益和断言未放宽。
|
||||
- 本机诊断日志:`/tmp/l20-runtime-PpD1Ah/{smoke,retry1,retry2,schema-default-repro,tests}.log`;
|
||||
新覆盖层 `/tmp/l20-runtime-PpD1Ah/registered-tracking.usda` 当时仅静态验证。它们是临时制品,不提交。
|
||||
修复交付后另获用户授权的 GPU 验收结果如下。
|
||||
|
||||
### 修正覆盖层的授权重测:PASS
|
||||
|
||||
实际命令(仓库根目录,未更改增益或断言):
|
||||
|
||||
```bash
|
||||
PYTHONPATH=source/dex_workbench timeout 300s ~/isaacsim/python.sh \
|
||||
scripts/tracking/track_l20.py /tmp/l20-runtime-PpD1Ah/registered-tracking.usda \
|
||||
--manifest assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json \
|
||||
--execute-experimental --headless --steps 480
|
||||
```
|
||||
|
||||
- Isaac Sim 6.0.1 / PhysX 110.1.13、RTX5080;seed42、num_envs1、240Hz、synthetic、两轮各480步。
|
||||
- 退出0且有完整 PASS JSON;两次 reset、位姿/关节/速度重复性、非零运动、有限值、限位与联动断言通过。
|
||||
- 两轮输出相同的汇总指标(这不承诺跨运行 GPU 确定性):
|
||||
|
||||
| 误差 | 最大值 | RMS |
|
||||
| --- | ---: | ---: |
|
||||
| 腕部位置 | 0.000492342 m | 0.000299964 m |
|
||||
| 腕部姿态 | 0.003755143 rad | 0.002314660 rad |
|
||||
| 各帧最大关节误差 | 0.008628675 rad | 0.005612279 rad |
|
||||
| 各帧最大 mimic 残差 | 0.000221643 rad | 0.000059707 rad |
|
||||
|
||||
- 源包 SHA `6c8f35358f481cf604ee802588017f30c1661c0f38e1187c49e5340db3830538`;
|
||||
覆盖层 SHA `ca874b29e4ee4f7c211d17c3e190487b0d6cc103dbe57035371d49fa64e98269`。
|
||||
- 日志 `/tmp/l20-registered-retest-N90YMI/smoke.log`,SHA256
|
||||
`b763f3abe35c780304a5a4bb587cdc639f63ab4fc4469e6ccde8551b10ff436d`;退出记录同目录 `result.txt`。
|
||||
测试后未发现 Kit 进程,GPU占用739MiB(测试前708MiB);这不证明所有资源位级恢复。
|
||||
- 仍有警告:旧 fixed joint 的 body transforms 不重合、TGS external-force iteration 配置、
|
||||
16/21 actuator 数量、protobuf 重复注册和 visualizer 配置缺失。16/21对应有意保留的5个被动关节;
|
||||
其余警告未靠关闭检查消除,尤其 fixed joint 警告需在扩大运动范围前进一步核验。
|
||||
- 仅为2mm平移、0.01rad转动、小幅手指合成参考的 smoke;未测试真实 HDF5、大范围运动、接触、
|
||||
多环境、训练或硬件。静态清单仍保留 runtime-unverified;本次证据不自动授权任意后续运行。
|
||||
|
||||
### 模型/版本门禁
|
||||
|
||||
- 验证 source manifest 的完整依赖包、关节列表、限位、质量记录与当前源 USD 一致;源 URDF 哈希和
|
||||
五个方程必须为本轮已核验版本。重新检查五个实际 Newton leader 路径、系数、启用状态和单自由度轴;
|
||||
拒绝重复 legacy mimic、未知约束、独立从动电机以及覆盖层额外物理编辑。
|
||||
本诊断仅接受静态资产:所有属性(含允许修改默认值的字段)均不得带时间采样。
|
||||
- `NewtonMimicAPI`: `q_follower = coef0 + coef1*q_leader`。revolute 的 coef0 用**度**,换为接口 rad;
|
||||
coef1 无量纲。单 DOF 的轴由各自 RevoluteJoint 定义,不新增 rotX/rotY token。
|
||||
旧 Physx 约定为 `q_follower + gearing*q_leader + offset = 0`,不能把正 multiplier 直接当 gearing。
|
||||
- 静态检查默认允许尚未注册的 codeless schema 元数据;运行时必须真正注册 NewtonMimicAPI、启用
|
||||
`omni.physx`,版本严格为 **110.1.13**。其他版本须重新核验,不静默降级/重复约束。
|
||||
- 当前 Isaac Lab 使用 `ProxyArray.torch`、root pose **xyzw**、index 写入方法;HDF5 始终 **wxyz**,
|
||||
在运行入口显式转换。旧版 Lab 未验证,不承诺兼容。
|
||||
- 本地依据(相对于 Isaac Sim 安装目录;未复制供应商实现):
|
||||
`extscache/omni.usd.schema.physx-*/plugins/PhysxSchema/resources/schema.usda` 的 PhysxMimicJointAPI;
|
||||
`exts/omni.usd.schema.newton/usd/schema/newton/newton_usd_schemas/generatedSchema.usda` 的 NewtonMimicAPI;
|
||||
`extscache/omni.physx.asset_validator-*/omni/physxassetvalidator/scripts/newtonMigrationChecker.py` 与
|
||||
`tests/newtonMigrationCheckerTest.py`;`extscache/omni.physx-*/bin/libomni.physx.plugin.so` 的 native parser 标识。
|
||||
本轮读取了上述代码/二进制字符串,**未执行供应商 GPU 集成测试**。
|
||||
|
||||
### 控制与验收边界
|
||||
|
||||
- 单环境、seed42、240Hz;默认两次各480步,单次允许100–1200步,外部 timeout 300秒。
|
||||
只创建手,无物体、桌面、任务注册或训练。不逐步 teleport;仅 reset 写 root/joint 初值并清零速度、
|
||||
wrench 与目标缓冲、轨迹时间,之后只发 master q_target 和 root COM wrench。
|
||||
- 世界系 PD 跟踪 link 原点;输出作用在 root COM,补上 `(p_link-p_rootCOM)×F_PD`。
|
||||
全手重力补偿为 `sum(-m*g)` 和 `sum((p_bodyCOM-p_rootCOM)×(-m*g))`,重力维持 `(0,0,-9.81)`。
|
||||
不是对整个手逐刚体取消重力;根部吸收补偿,手指仍承受物理重力/反作用。
|
||||
- **所有参数待校准**:位置 P/D=100 N/m、10 N·s/m,姿态 P/D=0.2 N·m/rad、0.02 N·m·s/rad;
|
||||
总力/力矩范数上限20N/1N·m(含重力项),需留至少20%静态重力余量。
|
||||
16 个 master 的 implicit drive P/D=3/0.1,仿真 effort cap=0.2N·m、velocity cap=0.5rad/s;
|
||||
不改变 follower 限位或增加 armature。不是实测安全参数。
|
||||
- 可用 `--limits` 指定 `control.Limits` 字段 JSON(有限正值,SI),默认参考速度限制
|
||||
0.05m/s、0.5rad/s、各关节0.5rad/s;位置不超过起点0.1m。超限/无效片段直接拒绝,不裁剪成成功。
|
||||
- 每帧检查有限值、速度、限位(0.01rad容差)、五条 mimic 残差<0.002rad,腕误差<0.05m/0.5rad、
|
||||
关节误差<0.2rad。这些是预设诊断失败阈值,不是高质量跟踪指标。输出各项 max/RMS 原始指标。
|
||||
每条 mimic 的 leader/follower 都必须有>0.002rad真实运动,根部需>0.5mm平移和>0.002rad转动,
|
||||
避免静止关节让缺约束假通过。输入需激励全部 mimic leader,纯静止/局部手指数据不适合此诊断。
|
||||
- 两次 reset 初值误差<=1e-6;重放的根部位姿/关节位置逐项差<=1e-3,
|
||||
根部线速度/角速度及关节速度逐项差<=1e-3(m/s 或 rad/s);不承诺 GPU 位级确定性。
|
||||
任一失败非零退出,`finally` 关闭本次 App,不修改其他进程或原资产。
|
||||
- **仍需扩展验证**:本次小幅合成参考的被动联动响应、跟踪与重复性已通过;真实轨迹、更大运动、
|
||||
接触/自碰撞及跨运行稳定性尚未验证。任何失败需保留日志并诊断,不能放宽阈值充作通过。
|
||||
后续再做少量并行环境、真实 HDF5、BC/PPO;硬件可控映射仍需外部确认。
|
||||
|
||||
本轮仅有上述合成参考的动力学误差实测,没有训练质量声明。没有提交/推送、版本升级或硬件动作。
|
||||
|
||||
## 4. 扩范围 HDF5 诊断(本轮预先固定的测试条件)
|
||||
|
||||
- 新覆盖层仅将原有、已禁用的**叶节点世界 fixed joint**设为 inactive,阻止它继续进入活动关节解析;
|
||||
根部仍为 `hand_base_link`。旧 anchor 的属性、关系和 schema 默认值保留,`TraverseAll` 仍逐项检查;
|
||||
其他节点的活动状态、树结构与时间采样门禁不得改变。原始 USD 未修改,旧覆盖层需重新生成。
|
||||
旧 `asset --floating-overlay` 仍仅禁用 anchor,用于拓扑诊断,不是本轮推荐的 prepared 控制资产。
|
||||
- 原警告与 world/local joint frames 不重合有关:原 world frame 为原点,spawn 后手根在 z=0.4m;
|
||||
`jointEnabled=false` 不阻止解析阶段报告该警告。去激活不是改变局部变换以掩盖警告。
|
||||
- 新命名合成 profile `range_4s`:60Hz,241帧,4秒,`s(t)=sin²(pi*t/4)` 往返;
|
||||
X向位移 `0.01*s` 米、Z保持0.4米、世界Z轴转角 `0.1*s` rad。
|
||||
独立关节正向幅度 `min(0.1rad, 0.25*upper_limit)`,从动关节按源 mimic 计算。
|
||||
解析曲线端点速度为0;采样后的线性/SLERP插值不保证连续加速度。明确为 synthetic,非专家。
|
||||
- 默认 `small` profile 保持原来的2秒/2mm/0.01rad和关节幅度,未改变默认参考或控制器。
|
||||
- 测试前固定:第1次新覆盖层 small,2x480步;第2次从磁盘载入 `range_4s` HDF5,2x960步。
|
||||
每次单环境、seed42、240Hz、timeout300秒;不调增益/阈值,不将失败后减小幅度算通过。
|
||||
- 新入口在 spawn 后要求旧 anchor inactive、仅21个活动 revolute joints;运行时仍断言非固定基座、
|
||||
22刚体、21状态关节、16独立控制目标与5个被动从动响应。PASS另记录HDF5 SHA/来源和活动关节数。
|
||||
|
||||
```bash
|
||||
# 使用第3节显式插件选项重新生成新的覆盖层,例如 /tmp/l20-range/tracking.usda
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli synthetic --manifest "$MANIFEST" \
|
||||
--profile range_4s --output /tmp/l20-range/range.hdf5
|
||||
~/isaacsim/python.sh -m dex_workbench_tracking.cli validate /tmp/l20-range/range.hdf5 --manifest "$MANIFEST"
|
||||
timeout 300s ~/isaacsim/python.sh scripts/tracking/track_l20.py /tmp/l20-range/tracking.usda \
|
||||
--manifest "$MANIFEST" --execute-experimental --headless --steps 960 \
|
||||
--hdf5 /tmp/l20-range/range.hdf5 --episode demo_000000
|
||||
```
|
||||
|
||||
### 本轮结果:两个限定 GPU 用例均 PASS
|
||||
|
||||
- 第1次命令:上述 `track_l20.py /tmp/l20-range/tracking.usda`,不传 `--hdf5`,
|
||||
`--steps 480`,其余参数相同。第2次完整执行上方 `--steps 960 --hdf5 ...` 命令。
|
||||
两次均退出0、有明确 PASS JSON、两轮期望步数及有限指标;没有第三次运行,没有调参或放宽断言。
|
||||
- 两次均输出 `world_anchor_inactive=true`、`active_state_joint_count=21`、
|
||||
`runtime_is_fixed_base=false`;两轮 reset/状态/速度重复性、非零运动与 mimic 断言通过。
|
||||
两份日志均不再出现 `CreateJoint` / `disjointed body transforms`。小幅用例 max/RMS 汇总与
|
||||
第3节历史基线逐值相同,支持此叶节点去激活在本用例的动力学等效性,不承诺任意场景等效。
|
||||
- HDF5 用例确实从磁盘读取 `range_4s`,输出 `reference_source=hdf5`、`provenance=synthetic`;
|
||||
60Hz的241帧按原时间戳插值至240Hz、961个参考点,执行960步/轮。
|
||||
- **68项 CPU/USD 回归通过,无跳过**,包括活动树/anchor/time samples、注册上下文、
|
||||
实际L20清单下新profile的schema/限位/mimic/速度/来源与拒绝覆盖。
|
||||
|
||||
| 最大误差(两轮各自相同汇总) | 小幅基线 | range_4s HDF5 |
|
||||
| --- | ---: | ---: |
|
||||
| 腕部位置 | 0.000492342 m | 0.001044980 m |
|
||||
| 腕部姿态 | 0.003755143 rad | 0.009153232 rad |
|
||||
| 各帧最大关节误差 | 0.008628675 rad | 0.010852285 rad |
|
||||
| 各帧最大 mimic 残差 | 0.000221643 rad | 0.000195338 rad |
|
||||
|
||||
HDF5用例 RMS 依次为 `0.000646705 m`、`0.006378398 rad`、`0.007675343 rad`、`0.000050099 rad`。
|
||||
所有控制/参考上限与基线一致;这只是受限诊断误差,不是训练成功率或真机指标。
|
||||
|
||||
制品(临时,不提交):
|
||||
|
||||
- `/tmp/l20-range/{small,hdf5}.log`、`{small,hdf5}-metrics.json`、对应 `*-result.txt`、
|
||||
`acceptance-evidence.log`、CPU `tests-final.log`、各次 `preflight*.log` / `postflight*.log`。
|
||||
- 新覆盖层完整包 SHA `6179feb7a112c74b73bc003be6b0159757ed6ab3a9e2cb7015333e09225dc319`;
|
||||
原始源包身份仍为第3节的 `6c8f...0538`。
|
||||
- `/tmp/l20-range/range.hdf5` SHA256
|
||||
`9678a19a7bb983ceaf6ad715016fe784b001544de0dec1876831d986eb870072`,不是专家文件。
|
||||
- small日志 SHA256 `6e395fedf0796440ffc2f2fefac5661d97cb63ce6d015ebad0f8399e8e20f0f4`;
|
||||
HDF5日志 SHA256 `10d255845d828e664d76f7d0119e5c2b5530cf696a1333afd41f39b95dd7c5f3`。
|
||||
|
||||
两次测试前后均未发现Kit残留;显存分别736→723MiB、734→775MiB,不承诺所有资源位级恢复。
|
||||
仍有TGS外力迭代、16/21 actuator(5个被动关节)、protobuf重复注册、visualizer/MaterialX与usdrt警告;
|
||||
未关闭日志或放宽检查。更大范围、负向/多轴腕部运动、接触、真实专家文件、多环境、训练与硬件均未验证。
|
||||
完整 pre-commit 仍因未安装工具而 NOT_RUN;本次独立审查仍由父会话执行。
|
||||
@@ -0,0 +1,870 @@
|
||||
{
|
||||
"manifest_version": "l20_asset_manifest_v1",
|
||||
"asset_sha256": "6c8f35358f481cf604ee802588017f30c1661c0f38e1187c49e5340db3830538",
|
||||
"entry_file": "linkerhand_g20_left.usda",
|
||||
"dependencies": [
|
||||
{
|
||||
"path": "fixed_base_setup.usda",
|
||||
"sha256": "1d4e01f8c0cb4eb6187dae4f7ba01660b69256d77a0d54f908dcb43fbdde55b7"
|
||||
},
|
||||
{
|
||||
"path": "linkerhand_g20_left.usda",
|
||||
"sha256": "357d3b24d5a572592dafc4b4c16ea5a0b7cb95f15cc78b7a205ee1724ebe7fc5"
|
||||
},
|
||||
{
|
||||
"path": "payloads/Physics/mujoco.usda",
|
||||
"sha256": "3234a6907b8259f72eb3d9f74b38c4d53473b42d67e2a797fce75a4a96c1a49f"
|
||||
},
|
||||
{
|
||||
"path": "payloads/Physics/physics.usda",
|
||||
"sha256": "52c3799c0e494b7dc9795d0f6058901c6b0232702553c924cb42c866fdff7909"
|
||||
},
|
||||
{
|
||||
"path": "payloads/Physics/physx.usda",
|
||||
"sha256": "5bd5628593ac5fb00b8a658d670b8d41a5205c930eb1f9edb15b7f3c3bd43ab2"
|
||||
},
|
||||
{
|
||||
"path": "payloads/base.usda",
|
||||
"sha256": "084df26517fe2cfcfea6fb5bc2c2fe3382182bf07b04dc4547f34e1575658146"
|
||||
},
|
||||
{
|
||||
"path": "payloads/geometries.usd",
|
||||
"sha256": "6ba619abc15fd30b10c05cd494ec8b4839647004f43a17376e105f4654f1bfcd"
|
||||
},
|
||||
{
|
||||
"path": "payloads/instances.usda",
|
||||
"sha256": "6846beddde3bbdba8a5a39a329655314e40cebb6e29e323856e15dc50384d6a0"
|
||||
},
|
||||
{
|
||||
"path": "payloads/materials.usda",
|
||||
"sha256": "eccbe067b9585993fe025e5ca0a533c048325d00023d5ec7c6cb3025cc7245e0"
|
||||
},
|
||||
{
|
||||
"path": "payloads/robot.usda",
|
||||
"sha256": "8a1e950de525744a4ee32782a2cf67695175770c0c098b36cefd744df28f833b"
|
||||
}
|
||||
],
|
||||
"composition_errors": [],
|
||||
"unresolved_dependencies": [],
|
||||
"default_prim": "/tn__linkerhand_g20_lefturdf_cZ0",
|
||||
"root_link": "hand_base_link",
|
||||
"root_body_path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"units": {
|
||||
"length": "m",
|
||||
"mass": "kg",
|
||||
"up_axis": "Z"
|
||||
},
|
||||
"physics_variant": "physx",
|
||||
"articulation_roots": [
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/fixed_base_joint"
|
||||
],
|
||||
"world_fixed_joints": [
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/fixed_base_joint",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link"
|
||||
}
|
||||
],
|
||||
"bodies": [
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.00098641705699265,
|
||||
0.00026804901426658034,
|
||||
0.07737984508275986
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
0.00011751915008062497,
|
||||
0.0001602228730916977,
|
||||
0.00026441647787578404
|
||||
],
|
||||
"physics:mass": 0.11037349700927734,
|
||||
"physics:principalAxes": "(0.70273006, 0.038762398, 0.7092034, -0.04121172)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
0.011515513062477112,
|
||||
0.003980688285082579,
|
||||
0.00027288825367577374
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.0297929950174876e-06,
|
||||
2.4729044980631443e-06,
|
||||
4.054635155625874e-06
|
||||
],
|
||||
"physics:mass": 0.016035180538892746,
|
||||
"physics:principalAxes": "(0.69315267, 0.09873541, 0.69662505, -0.15653834)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
0.002867146162316203,
|
||||
-0.00615726551041007,
|
||||
0.00472605274990201
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
6.547052322503077e-08,
|
||||
1.788231145383179e-07,
|
||||
2.448114173603244e-07
|
||||
],
|
||||
"physics:mass": 0.001832645502872765,
|
||||
"physics:principalAxes": "(-0.4856348, 0.6328352, 0.57746726, 0.17381014)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
0.006706578657031059,
|
||||
8.60758955241181e-05,
|
||||
0.03314129263162613
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
3.3777166663639946e-06,
|
||||
1.1335857379890513e-05,
|
||||
1.352877097815508e-05
|
||||
],
|
||||
"physics:mass": 0.028716035187244415,
|
||||
"physics:principalAxes": "(0.5003233, 0.49646425, 0.50234294, 0.50085074)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals/thumb_proximal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0026873440947383642,
|
||||
0.0005682445480488241,
|
||||
0.011850172653794289
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.2337233929192735e-07,
|
||||
4.898631118521735e-07,
|
||||
6.232804707906325e-07
|
||||
],
|
||||
"physics:mass": 0.005490154959261417,
|
||||
"physics:principalAxes": "(0.4466405, 0.44347262, 0.5443941, 0.55450827)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals/thumb_proximal/thumb_distal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0007100218208506703,
|
||||
-0.0026757591404020786,
|
||||
0.012476266361773014
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
8.26387420715946e-08,
|
||||
2.520345105949673e-07,
|
||||
2.698974697068479e-07
|
||||
],
|
||||
"physics:mass": 0.0038204663433134556,
|
||||
"physics:principalAxes": "(0.7076172, 0.49610376, 0.40506423, 0.29846585)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.00794299691915512,
|
||||
-2.936739633696561e-07,
|
||||
0.0022010602988302708
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.8543546193304792e-08,
|
||||
3.1735115868514185e-08,
|
||||
4.670307163223697e-08
|
||||
],
|
||||
"physics:mass": 0.0008955164230428636,
|
||||
"physics:principalAxes": "(0.15955442, 0.6888882, 0.68885684, -0.15953577)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0011418547946959734,
|
||||
3.473157630651258e-05,
|
||||
0.022892840206623077
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
1.345547673281544e-07,
|
||||
6.678773729618115e-07,
|
||||
6.741959168721223e-07
|
||||
],
|
||||
"physics:mass": 0.004509213380515575,
|
||||
"physics:principalAxes": "(0.5117973, 0.51168644, 0.48817277, 0.48777848)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal/index_middle",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.00020699194283224642,
|
||||
2.6061707103508525e-06,
|
||||
0.01670653373003006
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
9.467015615882701e-08,
|
||||
1.767613326819628e-07,
|
||||
2.3114837688353873e-07
|
||||
],
|
||||
"physics:mass": 0.002378274919465184,
|
||||
"physics:principalAxes": "(0.7445136, -0.00005444173, 0.6676073, 0.000047438436)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal/index_middle/index_distal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.008162464946508408,
|
||||
2.270589175168425e-05,
|
||||
0.00811806507408619
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
8.608198243109655e-08,
|
||||
1.7809432506510348e-07,
|
||||
2.076314586929584e-07
|
||||
],
|
||||
"physics:mass": 0.003341702511534095,
|
||||
"physics:principalAxes": "(0.90243715, -0.0029940915, 0.43080872, -0.0014403827)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.007942995987832546,
|
||||
-2.9300665005393967e-07,
|
||||
0.0022010633256286383
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.8543560404159507e-08,
|
||||
3.173513363208258e-08,
|
||||
4.6703082290378006e-08
|
||||
],
|
||||
"physics:mass": 0.0008955169469118118,
|
||||
"physics:principalAxes": "(0.1595545, 0.6888883, 0.6888567, -0.15953599)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0011418547946959734,
|
||||
3.473157630651258e-05,
|
||||
0.022892840206623077
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
1.345547673281544e-07,
|
||||
6.678773729618115e-07,
|
||||
6.741959168721223e-07
|
||||
],
|
||||
"physics:mass": 0.004509213380515575,
|
||||
"physics:principalAxes": "(0.5117973, 0.51168644, 0.48817277, 0.48777848)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal/middle_middle",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.00020699189917650074,
|
||||
2.6062498363899067e-06,
|
||||
0.01670653373003006
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
9.467015615882701e-08,
|
||||
1.767613326819628e-07,
|
||||
2.3114837688353873e-07
|
||||
],
|
||||
"physics:mass": 0.002378274919465184,
|
||||
"physics:principalAxes": "(0.7445136, -0.000054456927, 0.6676073, 0.00004744315)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal/middle_middle/middle_distal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.008162465877830982,
|
||||
2.2705855371896178e-05,
|
||||
0.008118066005408764
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
8.60819895365239e-08,
|
||||
1.780943534868129e-07,
|
||||
2.0763148711466783e-07
|
||||
],
|
||||
"physics:mass": 0.003341702977195382,
|
||||
"physics:principalAxes": "(0.90243715, -0.0029941155, 0.4308087, -0.0014403895)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.007942995987832546,
|
||||
-2.9451308591887937e-07,
|
||||
0.002201061462983489
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.854355152237531e-08,
|
||||
3.173512297394154e-08,
|
||||
4.670307518495065e-08
|
||||
],
|
||||
"physics:mass": 0.0008955165976658463,
|
||||
"physics:principalAxes": "(0.15955459, 0.6888881, 0.68885696, -0.1595357)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0011418547946959734,
|
||||
3.4731579944491386e-05,
|
||||
0.022892840206623077
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
1.345547673281544e-07,
|
||||
6.678773729618115e-07,
|
||||
6.741959168721223e-07
|
||||
],
|
||||
"physics:mass": 0.004509213380515575,
|
||||
"physics:principalAxes": "(0.5117973, 0.51168644, 0.48817277, 0.48777848)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal/ring_middle",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0002069932088488713,
|
||||
2.6074692414113088e-06,
|
||||
0.01670653186738491
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
9.467012063169022e-08,
|
||||
1.767613184711081e-07,
|
||||
2.3114836267268402e-07
|
||||
],
|
||||
"physics:mass": 0.0023782742209732533,
|
||||
"physics:principalAxes": "(0.74451363, -0.000054586842, 0.6676073, 0.000047385933)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal/ring_middle/ring_distal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.008162465877830982,
|
||||
2.2705731680616736e-05,
|
||||
0.008118066005408764
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
8.60819895365239e-08,
|
||||
1.780943392759582e-07,
|
||||
2.0763147290381312e-07
|
||||
],
|
||||
"physics:mass": 0.0033417027443647385,
|
||||
"physics:principalAxes": "(0.90243715, -0.0029941578, 0.43080872, -0.0014404262)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.007942995987832546,
|
||||
-2.930068490059057e-07,
|
||||
0.0022010633256286383
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
2.8543560404159507e-08,
|
||||
3.173513363208258e-08,
|
||||
4.6703082290378006e-08
|
||||
],
|
||||
"physics:mass": 0.0008955169469118118,
|
||||
"physics:principalAxes": "(0.1595545, 0.6888883, 0.6888567, -0.15953599)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.0011418547946959734,
|
||||
3.4731579944491386e-05,
|
||||
0.022892840206623077
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
1.345547673281544e-07,
|
||||
6.678773729618115e-07,
|
||||
6.741959168721223e-07
|
||||
],
|
||||
"physics:mass": 0.004509213380515575,
|
||||
"physics:principalAxes": "(0.5117973, 0.51168644, 0.48817277, 0.48777848)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal/pinky_middle",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.00020699325250461698,
|
||||
2.607489932415774e-06,
|
||||
0.01670653186738491
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
9.467012063169022e-08,
|
||||
1.767613184711081e-07,
|
||||
2.3114836267268402e-07
|
||||
],
|
||||
"physics:mass": 0.0023782742209732533,
|
||||
"physics:principalAxes": "(0.74451363, -0.0000545887, 0.6676073, 0.000047383306)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal/pinky_middle/pinky_distal",
|
||||
"reset_xform_stack": false,
|
||||
"mass_properties": {
|
||||
"physics:centerOfMass": [
|
||||
-0.008162465877830982,
|
||||
2.2705418814439327e-05,
|
||||
0.008118066005408764
|
||||
],
|
||||
"physics:diagonalInertia": [
|
||||
8.608198243109655e-08,
|
||||
1.780943534868129e-07,
|
||||
2.0763147290381312e-07
|
||||
],
|
||||
"physics:mass": 0.0033417027443647385,
|
||||
"physics:principalAxes": "(0.90243715, -0.002994122, 0.43080872, -0.001440418)"
|
||||
}
|
||||
}
|
||||
],
|
||||
"joints": [
|
||||
{
|
||||
"name": "index_dip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_dip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal/index_middle",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal/index_middle/index_distal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.5499999681585768,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "index_mcp_pitch",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_mcp_pitch",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.219999954319697,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "index_mcp_roll",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_mcp_roll",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals",
|
||||
"axis": "X",
|
||||
"lower_rad": -0.22999999602684235,
|
||||
"upper_rad": 0.22999999602684235,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "index_pip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_pip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/index_metacarpals/index_proximal/index_middle",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.740000000336972,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "middle_dip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_dip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal/middle_middle",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal/middle_middle/middle_distal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.5499999681585768,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "middle_mcp_pitch",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_mcp_pitch",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.219999954319697,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "middle_mcp_roll",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_mcp_roll",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals",
|
||||
"axis": "X",
|
||||
"lower_rad": -0.22999999602684235,
|
||||
"upper_rad": 0.22999999602684235,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "middle_pip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_pip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/middle_metacarpals/middle_proximal/middle_middle",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.740000000336972,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pinky_dip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_dip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal/pinky_middle",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal/pinky_middle/pinky_distal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.5499999681585768,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pinky_mcp_pitch",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_mcp_pitch",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.219999954319697,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pinky_mcp_roll",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_mcp_roll",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals",
|
||||
"axis": "X",
|
||||
"lower_rad": -0.22999999602684235,
|
||||
"upper_rad": 0.22999999602684235,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pinky_pip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_pip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/pinky_metacarpals/pinky_proximal/pinky_middle",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.740000000336972,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ring_dip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_dip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal/ring_middle",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal/ring_middle/ring_distal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.5499999681585768,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ring_mcp_pitch",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_mcp_pitch",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.219999954319697,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ring_mcp_roll",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_mcp_roll",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals",
|
||||
"axis": "X",
|
||||
"lower_rad": -0.22999999602684235,
|
||||
"upper_rad": 0.22999999602684235,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ring_pip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_pip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/ring_metacarpals/ring_proximal/ring_middle",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.740000000336972,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.01745329238474369,
|
||||
"drive:angular:physics:maxForce": 100.0,
|
||||
"drive:angular:physics:stiffness": 1.7453292608261108,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thumb_cmc_pitch",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_cmc_pitch",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 0.8400000231209613,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.0003490658418741077,
|
||||
"drive:angular:physics:maxForce": 1.0,
|
||||
"drive:angular:physics:stiffness": 0.01745329238474369,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:armature": 9.999999747378752e-05,
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thumb_cmc_roll",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_cmc_roll",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1",
|
||||
"axis": "X",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.3999999497628992,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.0003490658418741077,
|
||||
"drive:angular:physics:maxForce": 1.0,
|
||||
"drive:angular:physics:stiffness": 0.01745329238474369,
|
||||
"drive:angular:physics:targetPosition": 0.0,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:armature": 9.999999747378752e-05,
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thumb_cmc_yaw",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_cmc_yaw",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2",
|
||||
"axis": "Z",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.570000041628963,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.0003490658418741077,
|
||||
"drive:angular:physics:maxForce": 1.0,
|
||||
"drive:angular:physics:stiffness": 0.01745329238474369,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:armature": 9.999999747378752e-05,
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thumb_ip",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_ip",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals/thumb_proximal",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals/thumb_proximal/thumb_distal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.2899999451499395,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.0003490658418741077,
|
||||
"drive:angular:physics:maxForce": 1.0,
|
||||
"drive:angular:physics:stiffness": 0.01745329238474369,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:armature": 9.999999747378752e-05,
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "thumb_mcp",
|
||||
"path": "/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_mcp",
|
||||
"body0": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals",
|
||||
"body1": "/tn__linkerhand_g20_lefturdf_cZ0/Geometry/hand_base_link/thumb_metacarpals_base1/thumb_metacarpals_base2/thumb_metacarpals/thumb_proximal",
|
||||
"axis": "Y",
|
||||
"lower_rad": 0.0,
|
||||
"upper_rad": 1.2599999681024148,
|
||||
"authored_drive_properties_usd_units": {
|
||||
"drive:angular:physics:damping": 0.0003490658418741077,
|
||||
"drive:angular:physics:maxForce": 1.0,
|
||||
"drive:angular:physics:stiffness": 0.01745329238474369,
|
||||
"drive:angular:physics:type": "force",
|
||||
"physxJoint:armature": 9.999999747378752e-05,
|
||||
"physxJoint:maxJointVelocity": 57.295780181884766
|
||||
}
|
||||
}
|
||||
],
|
||||
"source_urdf": {
|
||||
"status": "STRUCTURAL_MATCH_ONLY",
|
||||
"file_name": "linkerhand_g20_left.urdf",
|
||||
"sha256": "b8ef22e436ab311fb61f87091b72ae717821092471d628d0ea9475b5088daa5d",
|
||||
"mimic": [
|
||||
{
|
||||
"joint": "index_dip",
|
||||
"reference": "index_pip",
|
||||
"multiplier": 0.89,
|
||||
"offset_rad": 0.0
|
||||
},
|
||||
{
|
||||
"joint": "middle_dip",
|
||||
"reference": "middle_pip",
|
||||
"multiplier": 0.89,
|
||||
"offset_rad": 0.0
|
||||
},
|
||||
{
|
||||
"joint": "pinky_dip",
|
||||
"reference": "pinky_pip",
|
||||
"multiplier": 0.89,
|
||||
"offset_rad": 0.0
|
||||
},
|
||||
{
|
||||
"joint": "ring_dip",
|
||||
"reference": "ring_pip",
|
||||
"multiplier": 0.89,
|
||||
"offset_rad": 0.0
|
||||
},
|
||||
{
|
||||
"joint": "thumb_ip",
|
||||
"reference": "thumb_mcp",
|
||||
"multiplier": 1.02,
|
||||
"offset_rad": 0.0
|
||||
}
|
||||
]
|
||||
},
|
||||
"coupling_evidence": [
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_ip.newton:mimicCoef1",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_ip.newton:mimicJoint",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/thumb_ip: SdfTokenListOp(Explicit Items: [PhysxJointAPI, NewtonMimicAPI, PhysicsDriveAPI:angular, PhysicsJointStateAPI:angular, IsaacJointAPI])",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_dip.newton:mimicCoef1",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_dip.newton:mimicJoint",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/index_dip: SdfTokenListOp(Explicit Items: [PhysxJointAPI, NewtonMimicAPI, PhysicsDriveAPI:angular, PhysicsJointStateAPI:angular, IsaacJointAPI])",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_dip.newton:mimicCoef1",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_dip.newton:mimicJoint",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/middle_dip: SdfTokenListOp(Explicit Items: [PhysxJointAPI, NewtonMimicAPI, PhysicsDriveAPI:angular, PhysicsJointStateAPI:angular, IsaacJointAPI])",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_dip.newton:mimicCoef1",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_dip.newton:mimicJoint",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/ring_dip: SdfTokenListOp(Explicit Items: [PhysxJointAPI, NewtonMimicAPI, PhysicsDriveAPI:angular, PhysicsJointStateAPI:angular, IsaacJointAPI])",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_dip.newton:mimicCoef1",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_dip.newton:mimicJoint",
|
||||
"/tn__linkerhand_g20_lefturdf_cZ0/Physics/pinky_dip: SdfTokenListOp(Explicit Items: [PhysxJointAPI, NewtonMimicAPI, PhysicsDriveAPI:angular, PhysicsJointStateAPI:angular, IsaacJointAPI])"
|
||||
],
|
||||
"physx_coupling_evidence": [],
|
||||
"coupling_status": "MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED",
|
||||
"dynamic_replay_ready": false,
|
||||
"blockers": [
|
||||
"Runtime mimic response is unverified; original follower drives need suppression for diagnostics.",
|
||||
"No calibrated wrist support controller or whole-hand dynamic replay validation."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Isaac Lab scene-loading diagnostic ONLY: no physics steps, actuators, or replay.
|
||||
|
||||
Run headless with an external timeout. This legacy topology diagnostic deliberately
|
||||
performs no dynamic experiment (see track_l20.py for the experimental path). Create a disposable
|
||||
floating overlay first; this script never edits/saves USD layers or existing scenes.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
from isaaclab.app import AppLauncher
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("asset", type=Path, help="Diagnostic floating overlay, not the fixed preview")
|
||||
AppLauncher.add_app_launcher_args(parser)
|
||||
parser.set_defaults(headless=True)
|
||||
args = parser.parse_args()
|
||||
asset = args.asset.resolve(strict=True)
|
||||
|
||||
# USD inspection imports pxr: defer it until Kit has initialized its libraries.
|
||||
launcher = AppLauncher(args)
|
||||
app = launcher.app
|
||||
exit_code = 0
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
from dex_workbench_tracking.asset import inspect
|
||||
|
||||
manifest = inspect(asset)
|
||||
if manifest["world_fixed_joints"] or manifest["articulation_roots"] != [manifest["root_body_path"]]:
|
||||
parser.error("Expected a floating diagnostic overlay; use dex_workbench_tracking.asset first")
|
||||
|
||||
from pxr import UsdGeom, UsdPhysics
|
||||
|
||||
import isaaclab.sim as sim_utils
|
||||
|
||||
random.seed(42)
|
||||
np.random.seed(42)
|
||||
torch.manual_seed(42)
|
||||
sim = sim_utils.SimulationContext(sim_utils.SimulationCfg(dt=1 / 240, device=args.device))
|
||||
spawn = sim_utils.UsdFileCfg(usd_path=str(asset))
|
||||
spawn.func("/World/Hand", spawn)
|
||||
stage = sim.stage
|
||||
roots, joint_names = [], []
|
||||
for prim in stage.Traverse():
|
||||
if not str(prim.GetPath()).startswith("/World/Hand/"):
|
||||
continue
|
||||
if prim.HasAPI(UsdPhysics.ArticulationRootAPI):
|
||||
roots.append(str(prim.GetPath()))
|
||||
if prim.IsA(UsdPhysics.RevoluteJoint):
|
||||
joint_names.append(prim.GetName())
|
||||
if prim.HasAPI(UsdPhysics.RigidBodyAPI):
|
||||
matrix = UsdGeom.XformCache().GetLocalToWorldTransform(prim)
|
||||
if not np.isfinite(np.asarray(matrix)).all():
|
||||
raise AssertionError(f"Nonfinite initial body transform: {prim.GetPath()}")
|
||||
expected = "/World/Hand" + manifest["root_body_path"][len(manifest["default_prim"]) :]
|
||||
assert roots == [expected], (roots, expected)
|
||||
assert sorted(joint_names) == [joint["name"] for joint in manifest["joints"]]
|
||||
assert not stage.GetCompositionErrors(), stage.GetCompositionErrors()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "PASS",
|
||||
"check": "Isaac_Lab_scene_loading_only",
|
||||
"seed": 42,
|
||||
"num_envs": 1,
|
||||
"physics_steps": 0,
|
||||
"root": expected,
|
||||
"joints": len(joint_names),
|
||||
"dynamic_replay": "BLOCKED",
|
||||
"reset_and_tracking_metrics": "NOT_RUN",
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
except BaseException:
|
||||
exit_code = 1
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
# No SimulationContext.reset()/step(): even passive dynamics are unvalidated.
|
||||
app.close(exit_code=exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Bounded experimental L20 dynamic diagnostic, NOT a training or hardware entry.
|
||||
|
||||
Requires inspected PhysX 110.1.13 + current Isaac Lab ProxyArray/xyzw API. Runtime
|
||||
coupling is tested from passive follower response, not inferred from schema names.
|
||||
Use an external timeout <=300s. Do not launch alongside another Kit/GPU job.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
from isaaclab.app import AppLauncher
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("asset", type=Path, help="Experimental prepared overlay, never original fixed preview")
|
||||
parser.add_argument("--manifest", type=Path, required=True, help="Original data/source manifest")
|
||||
parser.add_argument("--hdf5", type=Path, help="Omit for explicitly synthetic diagnostic reference")
|
||||
parser.add_argument("--episode", default="demo_000000")
|
||||
parser.add_argument("--steps", type=int, default=480, help="Per repetition at 240Hz; two repetitions")
|
||||
parser.add_argument("--execute-experimental", action="store_true", help="Acknowledge uncalibrated controller")
|
||||
parser.add_argument("--limits", type=Path, help="JSON fields of control.Limits, SI units; UNCALIBRATED")
|
||||
AppLauncher.add_app_launcher_args(parser)
|
||||
parser.set_defaults(headless=True, visualizer="none")
|
||||
args = parser.parse_args()
|
||||
if not args.execute_experimental or not 100 <= args.steps <= 1200:
|
||||
parser.error("Require --execute-experimental and 100 <= steps <= 1200 per repetition")
|
||||
|
||||
# Kit must choose/register its USD libraries before any pxr-dependent imports.
|
||||
launcher = AppLauncher(args)
|
||||
app = launcher.app
|
||||
exit_code = 0
|
||||
try:
|
||||
import numpy as np
|
||||
import torch
|
||||
from dex_workbench_tracking.cli import synthetic
|
||||
from dex_workbench_tracking.control import (
|
||||
Limits,
|
||||
reference_pose_to_xyzw,
|
||||
rotation_error,
|
||||
validate_reference,
|
||||
wrench,
|
||||
xyzw_pose_to_reference,
|
||||
)
|
||||
from dex_workbench_tracking.prepared import inspect_prepared, require_backend, validate_mimic
|
||||
from dex_workbench_tracking.trajectory import load, require, sample
|
||||
|
||||
manifest = json.loads(args.manifest.read_text())
|
||||
prepared = inspect_prepared(args.asset, manifest)
|
||||
limits = Limits(**json.loads(args.limits.read_text())) if args.limits else Limits()
|
||||
data = load(args.hdf5, manifest) if args.hdf5 else synthetic(manifest)
|
||||
episode = data.episodes[args.episode]
|
||||
validate_reference(episode, limits)
|
||||
dt = 1 / 240
|
||||
require(args.steps * dt <= episode.time[-1], "Reference shorter than requested steps")
|
||||
reference = sample(episode, np.arange(args.steps + 1) * dt)
|
||||
# Hold-start reset uses zero velocities; require a genuinely moving diagnostic
|
||||
# for every mimic pair, so an ignored/disabled constraint cannot pass at zero.
|
||||
names = list(data.joint_names)
|
||||
for eq in prepared["mimic"]:
|
||||
require(
|
||||
np.ptp(reference.joint_position[:, names.index(eq["reference"])]) > 0.002,
|
||||
"Each mimic leader must move >0.002rad",
|
||||
)
|
||||
require(
|
||||
np.max(np.linalg.norm(reference.wrist_position - reference.wrist_position[0], axis=1)) > 0.001,
|
||||
"Wrist reference must translate >1mm",
|
||||
)
|
||||
require(
|
||||
max(np.linalg.norm(rotation_error(q, reference.wrist_quaternion[0])) for q in reference.wrist_quaternion)
|
||||
> 0.005,
|
||||
"Wrist reference must rotate >0.005rad",
|
||||
)
|
||||
from isaaclab_physx.physics import PhysxCfg
|
||||
|
||||
import omni.kit.app
|
||||
from pxr import Usd, UsdPhysics
|
||||
|
||||
import isaaclab.sim as sim_utils
|
||||
from isaaclab.actuators import ImplicitActuatorCfg
|
||||
from isaaclab.assets import Articulation, ArticulationCfg
|
||||
|
||||
manager = omni.kit.app.get_app().get_extension_manager()
|
||||
extension = manager.get_enabled_extension_id("omni.physx")
|
||||
require(extension is not None, "PhysX extension not enabled")
|
||||
version = manager.get_extension_dict(extension)["package"]["version"]
|
||||
require_backend(version, bool(Usd.SchemaRegistry().FindAppliedAPIPrimDefinition("NewtonMimicAPI")))
|
||||
random.seed(42)
|
||||
np.random.seed(42)
|
||||
torch.manual_seed(42)
|
||||
sim = sim_utils.SimulationContext(
|
||||
sim_utils.SimulationCfg(dt=dt, gravity=(0, 0, -9.81), device=args.device, physics=PhysxCfg())
|
||||
)
|
||||
cfg = ArticulationCfg(
|
||||
prim_path="/World/Hand",
|
||||
spawn=sim_utils.UsdFileCfg(usd_path=str(args.asset.resolve())),
|
||||
init_state=ArticulationCfg.InitialStateCfg(pos=(0, 0, 0.4)),
|
||||
actuators={
|
||||
"model_independent": ImplicitActuatorCfg(
|
||||
joint_names_expr=prepared["independent_joint_names"],
|
||||
stiffness=limits.finger_stiffness,
|
||||
damping=limits.finger_damping,
|
||||
effort_limit_sim=limits.finger_effort,
|
||||
velocity_limit_sim=limits.finger_velocity,
|
||||
)
|
||||
},
|
||||
)
|
||||
hand = Articulation(cfg)
|
||||
# Validate remapped USD relationships after reference spawning, before physics.
|
||||
remapped = dict(manifest)
|
||||
remapped["joints"] = [
|
||||
dict(j, path="/World/Hand" + j["path"][len(manifest["default_prim"]) :]) for j in manifest["joints"]
|
||||
]
|
||||
validate_mimic(sim.stage, remapped, passive=True)
|
||||
anchor_path = "/World/Hand" + manifest["world_fixed_joints"][0]["path"][len(manifest["default_prim"]) :]
|
||||
anchor = sim.stage.GetPrimAtPath(anchor_path)
|
||||
require(anchor.IsValid() and not anchor.IsActive(), "Obsolete world anchor must be inactive")
|
||||
active_joints = [p for p in sim.stage.Traverse() if p.IsA(UsdPhysics.Joint)]
|
||||
require(len(active_joints) == len(names), "Unexpected active joint count")
|
||||
require(all(p.IsA(UsdPhysics.RevoluteJoint) for p in active_joints), "Unexpected active constraint")
|
||||
sim.reset()
|
||||
require(not hand.is_fixed_base and hand.num_instances == 1, "Expected one floating PhysX articulation")
|
||||
require(
|
||||
set(hand.joint_names) == set(names) and len(hand.joint_names) == len(names), "Runtime DOF mapping mismatch"
|
||||
)
|
||||
require(hand.num_bodies == len(manifest["bodies"]), "Runtime body count mismatch")
|
||||
require(hand.body_names[0] == manifest["root_link"], "Unexpected root body ordering")
|
||||
runtime_ids = [hand.joint_names.index(n) for n in names]
|
||||
master_ids = [hand.joint_names.index(n) for n in prepared["independent_joint_names"]]
|
||||
master_columns = [names.index(n) for n in prepared["independent_joint_names"]]
|
||||
follower_ids = [hand.joint_names.index(eq["joint"]) for eq in prepared["mimic"]]
|
||||
|
||||
def array(proxy):
|
||||
return proxy.torch.detach().cpu().numpy().copy()
|
||||
|
||||
def tensor(values):
|
||||
return torch.as_tensor(values, dtype=torch.float32, device=hand.device)
|
||||
|
||||
def state():
|
||||
pose = array(hand.data.root_link_pose_w)[0]
|
||||
# Installed Lab uses xyzw; HDF5/controller use wxyz. Explicit boundary.
|
||||
pose = xyzw_pose_to_reference(pose)
|
||||
velocity = array(hand.data.root_link_vel_w)[0]
|
||||
q = array(hand.data.joint_pos)[0, runtime_ids]
|
||||
return pose, velocity, q
|
||||
|
||||
for gains in (hand.data.joint_stiffness, hand.data.joint_damping):
|
||||
require((array(gains)[0, follower_ids] == 0).all(), "Runtime follower gains are nonzero")
|
||||
masses = array(hand.data.body_mass)[0]
|
||||
require(np.isfinite(masses).all() and (masses > 0).all(), "Invalid runtime mass")
|
||||
require(masses.sum() * 9.81 < limits.force * 0.8, "Insufficient bounded gravity support headroom")
|
||||
lower = np.array([j["lower_rad"] for j in manifest["joints"]])
|
||||
upper = np.array([j["upper_rad"] for j in manifest["joints"]])
|
||||
traces, reset_states, summaries = [], [], []
|
||||
for repetition in range(2):
|
||||
hand.reset()
|
||||
hand.permanent_wrench_composer.reset()
|
||||
hand.instantaneous_wrench_composer.reset()
|
||||
pose0 = reference_pose_to_xyzw(reference.wrist_position[0], reference.wrist_quaternion[0])
|
||||
hand.write_root_link_pose_to_sim_index(root_pose=tensor(pose0[None]))
|
||||
hand.write_root_link_velocity_to_sim_index(root_velocity=tensor(np.zeros((1, 6))))
|
||||
hand.write_joint_position_to_sim_index(
|
||||
position=tensor(reference.joint_position[0:1]), joint_ids=runtime_ids
|
||||
)
|
||||
hand.write_joint_velocity_to_sim_index(velocity=tensor(np.zeros((1, len(names)))), joint_ids=runtime_ids)
|
||||
hand.set_joint_position_target_index(
|
||||
target=tensor(reference.joint_position[0:1, master_columns]), joint_ids=master_ids
|
||||
)
|
||||
hand.set_joint_velocity_target_index(target=tensor(np.zeros((1, len(master_ids)))), joint_ids=master_ids)
|
||||
hand.update(dt)
|
||||
reset_pose, reset_vel, reset_q = state()
|
||||
np.testing.assert_allclose(reset_pose[:3], reference.wrist_position[0], atol=1e-6)
|
||||
require(
|
||||
np.linalg.norm(rotation_error(reference.wrist_quaternion[0], reset_pose[3:])) < 1e-6,
|
||||
"Reset orientation mismatch",
|
||||
)
|
||||
np.testing.assert_allclose(reset_q, reference.joint_position[0], atol=1e-6)
|
||||
np.testing.assert_allclose(reset_vel, 0, atol=1e-6)
|
||||
np.testing.assert_allclose(array(hand.data.joint_vel), 0, atol=1e-6)
|
||||
reset_states.append(np.r_[reset_pose, reset_vel, reset_q])
|
||||
trace, errors = [], []
|
||||
for step in range(args.steps):
|
||||
require(app.is_running(), "Application stopped before finite test completed")
|
||||
pose, velocity, q = state()
|
||||
force, torque = wrench(
|
||||
reference.wrist_position[step],
|
||||
reference.wrist_quaternion[step],
|
||||
pose,
|
||||
velocity,
|
||||
array(hand.data.root_com_pose_w)[0, :3],
|
||||
array(hand.data.body_com_pose_w)[0, :, :3],
|
||||
masses,
|
||||
limits,
|
||||
)
|
||||
hand.permanent_wrench_composer.set_forces_and_torques_index(
|
||||
forces=tensor(force[None, None]),
|
||||
torques=tensor(torque[None, None]),
|
||||
body_ids=torch.tensor([0], dtype=torch.int32, device=hand.device),
|
||||
is_global=True,
|
||||
)
|
||||
hand.set_joint_position_target_index(
|
||||
target=tensor(reference.joint_position[step : step + 1, master_columns]), joint_ids=master_ids
|
||||
)
|
||||
hand.write_data_to_sim()
|
||||
sim.step(render=False)
|
||||
hand.update(dt)
|
||||
pose, velocity, q = state()
|
||||
qdot = array(hand.data.joint_vel)[0]
|
||||
require(all(np.isfinite(v).all() for v in (pose, velocity, q, qdot)), "Nonfinite dynamic state")
|
||||
require(
|
||||
np.linalg.norm(velocity[:3]) < 0.5
|
||||
and np.linalg.norm(velocity[3:]) < 3
|
||||
and np.max(np.abs(qdot)) < 2,
|
||||
"Measured velocity safety bound exceeded",
|
||||
)
|
||||
require((q >= lower - 0.01).all() and (q <= upper + 0.01).all(), "Joint limit violation >0.01rad")
|
||||
residual = max(
|
||||
abs(
|
||||
q[names.index(eq["joint"])]
|
||||
- eq["multiplier"] * q[names.index(eq["reference"])]
|
||||
- eq["offset_rad"]
|
||||
)
|
||||
for eq in prepared["mimic"]
|
||||
)
|
||||
position_error = np.linalg.norm(pose[:3] - reference.wrist_position[step + 1])
|
||||
angle_error = np.linalg.norm(rotation_error(reference.wrist_quaternion[step + 1], pose[3:]))
|
||||
joint_error = np.max(np.abs(q - reference.joint_position[step + 1]))
|
||||
require(residual < 0.002, "Mimic runtime residual >0.002rad; parser/constraint not verified")
|
||||
require(
|
||||
position_error < 0.05 and angle_error < 0.5 and joint_error < 0.2,
|
||||
"Tracking safety envelope exceeded",
|
||||
)
|
||||
trace.append(np.r_[pose, q, velocity, qdot])
|
||||
errors.append([position_error, angle_error, joint_error, residual])
|
||||
trace, errors = np.array(trace), np.array(errors)
|
||||
for eq in prepared["mimic"]:
|
||||
for name in (eq["joint"], eq["reference"]):
|
||||
require(np.ptp(trace[:, 7 + names.index(name)]) > 0.002, f"No nontrivial runtime motion: {name}")
|
||||
require(
|
||||
np.max(np.linalg.norm(trace[:, :3] - reset_pose[:3], axis=1)) > 0.0005, "No nontrivial root translation"
|
||||
)
|
||||
require(
|
||||
max(np.linalg.norm(rotation_error(p[3:7], reset_pose[3:])) for p in trace) > 0.002,
|
||||
"No nontrivial root rotation",
|
||||
)
|
||||
traces.append(trace)
|
||||
summaries.append(
|
||||
{
|
||||
"repetition": repetition,
|
||||
"max_errors_m_rad_rad_rad": errors.max(axis=0).tolist(),
|
||||
"rms_errors_m_rad_rad_rad": np.sqrt((errors**2).mean(axis=0)).tolist(),
|
||||
}
|
||||
)
|
||||
np.testing.assert_allclose(reset_states[0], reset_states[1], atol=1e-6, rtol=0)
|
||||
position_end = 7 + len(names)
|
||||
np.testing.assert_allclose(traces[0][:, :position_end], traces[1][:, :position_end], atol=1e-3, rtol=0)
|
||||
# Root linear/angular and joint velocities: 1e-3 m/s or rad/s absolute.
|
||||
np.testing.assert_allclose(traces[0][:, position_end:], traces[1][:, position_end:], atol=1e-3, rtol=0)
|
||||
hand.permanent_wrench_composer.reset()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "PASS",
|
||||
"check": "bounded_experimental_dynamic_tracking",
|
||||
"runtime_verified_for_this_run_only": True,
|
||||
"provenance": data.metadata["provenance"],
|
||||
"reference_source": "hdf5" if args.hdf5 else "analytic_in_memory",
|
||||
"reference_hdf5_sha256": hashlib.sha256(args.hdf5.read_bytes()).hexdigest() if args.hdf5 else None,
|
||||
"reference_description": data.metadata["source_description"],
|
||||
"world_anchor_inactive": not anchor.IsActive(),
|
||||
"active_state_joint_count": len(active_joints),
|
||||
"runtime_is_fixed_base": hand.is_fixed_base,
|
||||
"seed": 42,
|
||||
"num_envs": 1,
|
||||
"steps_per_repetition": args.steps,
|
||||
"repetitions": 2,
|
||||
"physx_version": version,
|
||||
"prepared": prepared,
|
||||
"limits_uncalibrated": vars(limits),
|
||||
"metrics": summaries,
|
||||
"hardware_and_training_validated": False,
|
||||
}
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
except BaseException:
|
||||
# Kit fast shutdown may not return; emit the failure before closing.
|
||||
exit_code = 1
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
app.close(exit_code=exit_code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
|
||||
# Semantic Versioning is used: https://semver.org/
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
|
||||
# Description
|
||||
category = "isaaclab"
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Composed USD inspection and non-destructive, topology-only floating overlay.
|
||||
|
||||
Requires pxr, not a running Kit application. No physical coupling is synthesized.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
|
||||
from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdUtils
|
||||
|
||||
|
||||
def _require(condition, message):
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def _sha(path):
|
||||
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _plain(value):
|
||||
if value is None or isinstance(value, (str, bool, int)):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
return value if math.isfinite(value) else str(value)
|
||||
try:
|
||||
return [_plain(item) for item in value]
|
||||
except TypeError:
|
||||
return str(value)
|
||||
|
||||
|
||||
def dependencies(source):
|
||||
"""Hash the local complete dependency bundle, including names relative to the entry.
|
||||
|
||||
Sorted UTF-8 records are ``relative_posix_path + NUL + sha256 + LF``.
|
||||
Renaming dependency files changes identity. Moving the entire bundle does not.
|
||||
"""
|
||||
source = Path(source).resolve(strict=True)
|
||||
layers, assets, unresolved = UsdUtils.ComputeAllDependencies(str(source))
|
||||
_require(not unresolved, f"Unresolved dependencies: {unresolved}")
|
||||
paths = {Path(layer.realPath).resolve(strict=True) for layer in layers}
|
||||
paths.update(Path(asset).resolve(strict=True) for asset in assets)
|
||||
paths.add(source)
|
||||
entries = sorted((os.path.relpath(path, source.parent).replace(os.sep, "/"), _sha(path)) for path in paths)
|
||||
digest = hashlib.sha256()
|
||||
for name, sha in entries:
|
||||
digest.update(f"{name}\0{sha}\n".encode())
|
||||
return [{"path": name, "sha256": sha} for name, sha in entries], digest.hexdigest()
|
||||
|
||||
|
||||
def inspect(source, source_urdf=None):
|
||||
source = Path(source).resolve(strict=True)
|
||||
deps, bundle_sha = dependencies(source)
|
||||
stage = Usd.Stage.Open(str(source))
|
||||
_require(stage and stage.GetDefaultPrim(), "USD must have a default prim")
|
||||
_require(not stage.GetCompositionErrors(), f"Composition errors: {stage.GetCompositionErrors()}")
|
||||
_require(
|
||||
UsdGeom.GetStageUpAxis(stage) == "Z"
|
||||
and UsdGeom.GetStageMetersPerUnit(stage) == 1
|
||||
and UsdPhysics.GetStageKilogramsPerUnit(stage) == 1,
|
||||
"Expected Z-up, meter, kilogram asset",
|
||||
)
|
||||
default = stage.GetDefaultPrim()
|
||||
prims = list(Usd.PrimRange(default))
|
||||
bodies = [prim for prim in prims if prim.HasAPI(UsdPhysics.RigidBodyAPI)]
|
||||
body_paths = {str(prim.GetPath()) for prim in bodies}
|
||||
joints, world_joints, child_paths = [], [], set()
|
||||
coupling_evidence = []
|
||||
for prim in prims:
|
||||
for prop in prim.GetProperties():
|
||||
if any(term in prop.GetName().lower() for term in ("mimic", "tendon", "gearing", "coupling")):
|
||||
coupling_evidence.append(str(prop.GetPath()))
|
||||
# Raw schemas retain evidence even if a PhysX schema plugin is not loaded.
|
||||
schemas = str(prim.GetMetadata("apiSchemas"))
|
||||
if any(term in schemas.lower() for term in ("mimic", "tendon", "gearing", "coupling")):
|
||||
coupling_evidence.append(f"{prim.GetPath()}: {schemas}")
|
||||
if not prim.IsA(UsdPhysics.Joint):
|
||||
continue
|
||||
joint = UsdPhysics.Joint(prim)
|
||||
if joint.GetJointEnabledAttr().Get() is False:
|
||||
continue
|
||||
b0 = [str(p) for p in joint.GetBody0Rel().GetTargets()]
|
||||
b1 = [str(p) for p in joint.GetBody1Rel().GetTargets()]
|
||||
_require(len(b1) == 1 and b1[0] in body_paths, f"Unresolved/non-body body1: {prim.GetPath()}")
|
||||
if not b0:
|
||||
_require(prim.IsA(UsdPhysics.FixedJoint), "Unsupported non-fixed world joint")
|
||||
world_joints.append({"path": str(prim.GetPath()), "body1": b1[0]})
|
||||
continue
|
||||
_require(len(b0) == 1 and b0[0] in body_paths, f"Unresolved/non-body body0: {prim.GetPath()}")
|
||||
_require(prim.IsA(UsdPhysics.RevoluteJoint), f"Unsupported joint type {prim.GetTypeName()}")
|
||||
child_paths.add(b1[0])
|
||||
revolute = UsdPhysics.RevoluteJoint(prim)
|
||||
lower, upper = revolute.GetLowerLimitAttr().Get(), revolute.GetUpperLimitAttr().Get()
|
||||
_require(
|
||||
lower is not None
|
||||
and upper is not None
|
||||
and math.isfinite(lower)
|
||||
and math.isfinite(upper)
|
||||
and lower <= upper,
|
||||
f"Missing/nonfinite limits: {prim.GetPath()}",
|
||||
)
|
||||
joints.append(
|
||||
{
|
||||
"name": prim.GetName(),
|
||||
"path": str(prim.GetPath()),
|
||||
"body0": b0[0],
|
||||
"body1": b1[0],
|
||||
"axis": revolute.GetAxisAttr().Get(),
|
||||
"lower_rad": math.radians(lower),
|
||||
"upper_rad": math.radians(upper),
|
||||
"authored_drive_properties_usd_units": {
|
||||
attr.GetName(): _plain(attr.Get())
|
||||
for attr in prim.GetAttributes()
|
||||
if attr.GetName().startswith(("drive:", "physxJoint:")) and attr.HasAuthoredValue()
|
||||
},
|
||||
}
|
||||
)
|
||||
_require(bodies and joints, "No articulated hand found")
|
||||
roots = sorted(body_paths - child_paths)
|
||||
_require(len(roots) == 1, f"Expected one body-tree root, got {roots}")
|
||||
_require(len(joints) == len(bodies) - 1 and len(child_paths) == len(joints), "Not a simple articulated tree")
|
||||
reachable = {roots[0]}
|
||||
while True:
|
||||
expanded = reachable | {joint["body1"] for joint in joints if joint["body0"] in reachable}
|
||||
if expanded == reachable:
|
||||
break
|
||||
reachable = expanded
|
||||
_require(reachable == body_paths, "Disconnected/cyclic body graph")
|
||||
joints.sort(key=lambda joint: joint["name"])
|
||||
names = [joint["name"] for joint in joints]
|
||||
_require(len(set(names)) == len(names), "Duplicate joint names")
|
||||
articulation_roots = [str(p.GetPath()) for p in prims if p.HasAPI(UsdPhysics.ArticulationRootAPI)]
|
||||
_require(len(articulation_roots) == 1, f"Expected one articulation root, got {articulation_roots}")
|
||||
_require(len(world_joints) <= 1 and all(j["body1"] == roots[0] for j in world_joints), "Ambiguous world anchor")
|
||||
source_info = {"status": "NOT_PROVIDED", "mimic": []}
|
||||
if source_urdf is not None:
|
||||
path = Path(source_urdf).resolve(strict=True)
|
||||
xml = ET.parse(path).getroot()
|
||||
urdf_joints = {j.attrib["name"]: j for j in xml.findall("joint") if j.attrib["type"] != "fixed"}
|
||||
_require(set(urdf_joints) == set(names), "Source URDF/USD joint-name mismatch")
|
||||
mimic = []
|
||||
for joint in joints:
|
||||
node = urdf_joints[joint["name"]]
|
||||
limit = node.find("limit")
|
||||
_require(node.attrib["type"] == "revolute" and limit is not None, "Unsupported source URDF joint")
|
||||
for key in ("lower", "upper"):
|
||||
_require(
|
||||
abs(float(limit.attrib[key]) - joint[f"{key}_rad"]) < 1e-5,
|
||||
f"Source URDF/USD limit mismatch: {joint['name']}",
|
||||
)
|
||||
parent, child = node.find("parent"), node.find("child")
|
||||
_require(
|
||||
parent is not None
|
||||
and child is not None
|
||||
and parent.attrib["link"] == joint["body0"].split("/")[-1]
|
||||
and child.attrib["link"] == joint["body1"].split("/")[-1],
|
||||
"Source URDF/USD link mismatch",
|
||||
)
|
||||
equation = node.find("mimic")
|
||||
if equation is not None:
|
||||
_require(equation.attrib["joint"] in names, "Unresolved source mimic reference")
|
||||
multiplier = float(equation.attrib.get("multiplier", "1"))
|
||||
offset = float(equation.attrib.get("offset", "0"))
|
||||
_require(math.isfinite(multiplier) and math.isfinite(offset), "Nonfinite mimic equation")
|
||||
mimic.append(
|
||||
{
|
||||
"joint": joint["name"],
|
||||
"reference": equation.attrib["joint"],
|
||||
"multiplier": multiplier,
|
||||
"offset_rad": offset,
|
||||
}
|
||||
)
|
||||
source_info = {"status": "STRUCTURAL_MATCH_ONLY", "file_name": path.name, "sha256": _sha(path), "mimic": mimic}
|
||||
coupling_status = "UNVERIFIED"
|
||||
physx_coupling = [
|
||||
item
|
||||
for item in coupling_evidence
|
||||
if "physxmimic" in item.lower() or "physxtendon" in item.lower() or ".physxmimic" in item.lower()
|
||||
]
|
||||
newton_coupling = [item for item in coupling_evidence if "newtonmimic" in item.lower()]
|
||||
if newton_coupling or physx_coupling:
|
||||
# PhysX 110.1.13 parses NewtonMimicAPI natively. Prefix is not a backend gate.
|
||||
coupling_status = "MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED"
|
||||
elif source_info["mimic"]:
|
||||
coupling_status = "MISSING_MIMIC_SCHEMA"
|
||||
return {
|
||||
"manifest_version": "l20_asset_manifest_v1",
|
||||
"asset_sha256": bundle_sha,
|
||||
"entry_file": source.name,
|
||||
"dependencies": deps,
|
||||
"composition_errors": [],
|
||||
"unresolved_dependencies": [],
|
||||
"default_prim": str(default.GetPath()),
|
||||
"root_link": roots[0].split("/")[-1],
|
||||
"root_body_path": roots[0],
|
||||
"units": {"length": "m", "mass": "kg", "up_axis": "Z"},
|
||||
"physics_variant": default.GetVariantSet("Physics").GetVariantSelection(),
|
||||
"articulation_roots": articulation_roots,
|
||||
"world_fixed_joints": world_joints,
|
||||
"bodies": [
|
||||
{
|
||||
"path": str(p.GetPath()),
|
||||
"reset_xform_stack": UsdGeom.Xformable(p).GetResetXformStack(),
|
||||
"mass_properties": {
|
||||
a.GetName(): _plain(a.Get())
|
||||
for a in p.GetAttributes()
|
||||
if a.GetName()
|
||||
in ("physics:mass", "physics:centerOfMass", "physics:diagonalInertia", "physics:principalAxes")
|
||||
},
|
||||
}
|
||||
for p in bodies
|
||||
],
|
||||
"joints": joints,
|
||||
"source_urdf": source_info,
|
||||
"coupling_evidence": coupling_evidence,
|
||||
"physx_coupling_evidence": physx_coupling,
|
||||
"coupling_status": coupling_status,
|
||||
"dynamic_replay_ready": False,
|
||||
"blockers": [
|
||||
"Runtime mimic response is unverified; original follower drives need suppression for diagnostics.",
|
||||
"No calibrated wrist support controller or whole-hand dynamic replay validation.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def prepare_floating_overlay(source, output):
|
||||
"""Release only the world anchor; preserve transforms and source layer bytes.
|
||||
|
||||
This is NOT a usable control asset: missing coupling remains missing. Output
|
||||
is explicitly marked diagnostic and must never overwrite an existing file.
|
||||
"""
|
||||
source, output = Path(source).resolve(strict=True), Path(output).resolve()
|
||||
_require(not output.exists(), "Refusing to overwrite output")
|
||||
before = inspect(source)
|
||||
_require(before["physics_variant"] == "physx", "Only the current PhysX variant is supported")
|
||||
anchors = before["world_fixed_joints"]
|
||||
_require(
|
||||
len(anchors) == 1 and before["articulation_roots"] == [anchors[0]["path"]],
|
||||
"Expected a fixed-world articulation root; will not guess topology",
|
||||
)
|
||||
original = Usd.Stage.Open(str(source))
|
||||
stage = Usd.Stage.CreateInMemory()
|
||||
stage.GetRootLayer().subLayerPaths = [str(source)]
|
||||
stage.SetDefaultPrim(stage.GetPrimAtPath(before["default_prim"]))
|
||||
UsdGeom.SetStageUpAxis(stage, "Z")
|
||||
UsdGeom.SetStageMetersPerUnit(stage, 1)
|
||||
UsdPhysics.SetStageKilogramsPerUnit(stage, 1)
|
||||
anchor = stage.GetPrimAtPath(anchors[0]["path"])
|
||||
# Snapshot resolved schema fallbacks before removing the API. Preserve them
|
||||
# explicitly on the inert anchor and the new root, not just authored values.
|
||||
articulation_values = []
|
||||
for attr in anchor.GetAttributes():
|
||||
if attr.GetName().startswith("physxArticulation:") and attr.Get() is not None:
|
||||
_require(not attr.GetTimeSamples(), f"Time samples prohibited: {attr.GetPath()}")
|
||||
articulation_values.append((attr.GetName(), attr.GetTypeName(), attr.Get()))
|
||||
anchor.RemoveAPI(UsdPhysics.ArticulationRootAPI)
|
||||
anchor.RemoveAppliedSchema("PhysxArticulationAPI")
|
||||
UsdPhysics.Joint(anchor).GetJointEnabledAttr().Set(False)
|
||||
root = stage.GetPrimAtPath(before["root_body_path"])
|
||||
UsdPhysics.ArticulationRootAPI.Apply(root)
|
||||
root.AddAppliedSchema("PhysxArticulationAPI")
|
||||
for name, dtype, value in articulation_values:
|
||||
anchor.CreateAttribute(name, dtype).Set(value)
|
||||
root.CreateAttribute(name, dtype).Set(value)
|
||||
stage.GetRootLayer().customLayerData = {"purpose": "L20 topology-only diagnostic; dynamic replay BLOCKED"}
|
||||
cache0, cache1 = UsdGeom.XformCache(), UsdGeom.XformCache()
|
||||
for prim in original.Traverse():
|
||||
after = stage.GetPrimAtPath(prim.GetPath())
|
||||
for attr in prim.GetAttributes():
|
||||
if str(prim.GetPath()) == anchors[0]["path"] and attr.GetName() == "physics:jointEnabled":
|
||||
continue
|
||||
_require(
|
||||
attr.Get() == after.GetAttribute(attr.GetName()).Get(), f"Unexpected attribute edit: {attr.GetPath()}"
|
||||
)
|
||||
for rel in prim.GetRelationships():
|
||||
_require(
|
||||
rel.GetTargets() == after.GetRelationship(rel.GetName()).GetTargets(), "Unexpected relationship edit"
|
||||
)
|
||||
if prim.IsA(UsdGeom.Xformable):
|
||||
_require(
|
||||
Gf.IsClose(cache0.GetLocalToWorldTransform(prim), cache1.GetLocalToWorldTransform(after), 1e-12),
|
||||
f"Unexpected transform edit: {prim.GetPath()}",
|
||||
)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
layer = Sdf.Layer.CreateAnonymous()
|
||||
layer.TransferContent(stage.GetRootLayer())
|
||||
layer.subLayerPaths = [os.path.relpath(source, output.parent).replace(os.sep, "/")]
|
||||
_require(layer.Export(str(output)), "USD export failed")
|
||||
after = inspect(output)
|
||||
_require(
|
||||
not after["world_fixed_joints"] and after["articulation_roots"] == [before["root_body_path"]],
|
||||
"Floating root topology check failed",
|
||||
)
|
||||
_require(dependencies(source)[1] == before["asset_sha256"], "Source bundle unexpectedly changed")
|
||||
return after
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("asset", type=Path)
|
||||
parser.add_argument("--source-urdf", type=Path, help="Optional local original; inspected, never modified")
|
||||
parser.add_argument("--output", type=Path, required=True, help="New JSON manifest; no overwrite")
|
||||
parser.add_argument("--floating-overlay", type=Path, help="New topology-only USD layer; no overwrite")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
_require(not args.output.exists(), "Refusing to overwrite manifest")
|
||||
manifest = inspect(args.asset, args.source_urdf)
|
||||
if args.floating_overlay:
|
||||
prepare_floating_overlay(args.asset, args.floating_overlay)
|
||||
manifest = inspect(args.floating_overlay, args.source_urdf)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("x", encoding="utf-8") as stream:
|
||||
json.dump(manifest, stream, indent=2, allow_nan=False)
|
||||
stream.write("\n")
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "PASS",
|
||||
"check": "static_asset_inspection_only",
|
||||
"manifest": str(args.output),
|
||||
"joints": len(manifest["joints"]),
|
||||
"coupling_status": manifest["coupling_status"],
|
||||
"dynamic_replay": "BLOCKED",
|
||||
}
|
||||
)
|
||||
)
|
||||
except (ValueError, OSError, RuntimeError, ET.ParseError) as error:
|
||||
parser.exit(1, f"FAIL: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,181 @@
|
||||
"""CPU preparation CLI: validate, synthesize test references, or resample HDF5."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
from .trajectory import (
|
||||
ContractError,
|
||||
Demonstrations,
|
||||
Episode,
|
||||
load,
|
||||
require,
|
||||
require_dynamic_replay_ready,
|
||||
sample,
|
||||
validate_against_manifest,
|
||||
)
|
||||
|
||||
|
||||
def write(path, data):
|
||||
"""Never overwrite inputs, original demonstrations, or an earlier generated file."""
|
||||
path = Path(path)
|
||||
with h5py.File(path, "x") as file:
|
||||
for name, value in data.metadata.items():
|
||||
file.attrs[name] = value
|
||||
meta = file.create_group("metadata")
|
||||
meta.create_dataset("joint_names", data=data.joint_names, dtype=h5py.string_dtype("utf-8"))
|
||||
meta.create_dataset("world_from_source", data=data.world_from_source, dtype="float64")
|
||||
episodes = file.create_group("episodes")
|
||||
for name, episode in data.episodes.items():
|
||||
group = episodes.create_group(name)
|
||||
for field in ("time", "wrist_position", "wrist_quaternion", "joint_position", "valid"):
|
||||
group.create_dataset(field, data=getattr(episode, field))
|
||||
|
||||
|
||||
def publish_validated(path, data, manifest):
|
||||
"""Validate privately, then publish without replacing any existing destination."""
|
||||
path = Path(path)
|
||||
with tempfile.TemporaryDirectory(prefix=".l20-tracking-", dir=path.parent) as directory:
|
||||
temporary = Path(directory) / "validated.hdf5"
|
||||
write(temporary, data)
|
||||
load(temporary, manifest)
|
||||
# Same-filesystem hard link publishes atomically and refuses overwrites.
|
||||
os.link(temporary, path)
|
||||
|
||||
|
||||
def synthetic(manifest, profile="small"):
|
||||
"""Named analytic fixtures, not experts. ``range_4s`` has smooth out-and-back endpoints."""
|
||||
require(profile in ("small", "range_4s"), "Unknown synthetic profile")
|
||||
larger = profile == "range_4s"
|
||||
duration = 4 if larger else 2
|
||||
translation, yaw = (0.01, 0.1) if larger else (0.002, 0.01)
|
||||
joints = manifest["joints"]
|
||||
time = np.linspace(0, duration, duration * 60 + 1, dtype=np.float64)
|
||||
phase = np.sin(np.pi * time / duration) ** 2
|
||||
position = np.zeros((len(time), 3), dtype=np.float32)
|
||||
position[:, 0] = translation * phase
|
||||
position[:, 2] = 0.4
|
||||
quaternion = np.zeros((len(time), 4), dtype=np.float32)
|
||||
quaternion[:, 0] = np.cos(yaw / 2 * phase)
|
||||
quaternion[:, 3] = np.sin(yaw / 2 * phase)
|
||||
names = tuple(joint["name"] for joint in joints)
|
||||
q = np.zeros((len(time), len(names)), dtype=np.float32)
|
||||
for index, joint in enumerate(joints):
|
||||
require(joint["lower_rad"] <= 0 <= joint["upper_rad"], "Synthetic fixture requires zero within limits")
|
||||
amplitude = min(0.1, joint["upper_rad"] * 0.25) if larger else min(0.01, joint["upper_rad"] * 0.01)
|
||||
q[:, index] = phase * amplitude
|
||||
equations = {entry["joint"]: entry for entry in manifest.get("source_urdf", {}).get("mimic", [])}
|
||||
resolved = set(names) - equations.keys()
|
||||
while equations:
|
||||
ready = [name for name, eq in equations.items() if eq["reference"] in resolved]
|
||||
require(ready, "Cyclic/unresolved mimic graph")
|
||||
for name in ready:
|
||||
eq = equations.pop(name)
|
||||
q[:, names.index(name)] = eq["multiplier"] * q[:, names.index(eq["reference"])] + eq["offset_rad"]
|
||||
resolved.add(name)
|
||||
metadata = {
|
||||
"schema_version": "l20_tracking_v1",
|
||||
"embodiment": "L20",
|
||||
"hand_side": "left",
|
||||
"asset_sha256": manifest["asset_sha256"],
|
||||
"root_link": manifest["root_link"],
|
||||
"metric_scale_provenance": "Analytic SI fixture, not video reconstruction or measurement",
|
||||
"scale_to_meters": 1.0,
|
||||
"provenance": "synthetic",
|
||||
"source_description": (
|
||||
"Synthetic range_4s: 4 s sin^2 out-and-back, 10 mm translation, 0.1 rad yaw; "
|
||||
"independent positive joint amplitude min(0.1 rad, 25% upper limit), followers obey source mimic; "
|
||||
"60 Hz, analytic zero endpoint velocities; NOT expert data"
|
||||
if larger
|
||||
else "Deterministic analytic reference: 2 mm translation, 0.01 rad yaw, small joint motion; NOT expert data"
|
||||
),
|
||||
}
|
||||
data = Demonstrations(
|
||||
metadata,
|
||||
names,
|
||||
np.eye(4, dtype=np.float64),
|
||||
{
|
||||
"demo_000000": Episode(time, position, quaternion, q, np.ones(len(time), dtype=bool)),
|
||||
},
|
||||
)
|
||||
validate_against_manifest(data, manifest)
|
||||
return data
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
validate = commands.add_parser("validate")
|
||||
validate.add_argument("input", type=Path)
|
||||
validate.add_argument(
|
||||
"--manifest", type=Path, help="Required to check asset identity/order/limits; otherwise schema only"
|
||||
)
|
||||
generate = commands.add_parser("synthetic")
|
||||
generate.add_argument("--manifest", required=True, type=Path)
|
||||
generate.add_argument("--output", required=True, type=Path)
|
||||
generate.add_argument("--profile", choices=("small", "range_4s"), default="small")
|
||||
resample = commands.add_parser("resample")
|
||||
resample.add_argument("input", type=Path)
|
||||
resample.add_argument("--manifest", required=True, type=Path)
|
||||
resample.add_argument("--hz", required=True, type=float)
|
||||
resample.add_argument("--output", required=True, type=Path)
|
||||
gate = commands.add_parser("replay-check", help="Fails closed until a physical tracking Adapter is implemented")
|
||||
gate.add_argument("--manifest", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8")) if args.manifest else None
|
||||
if args.command == "replay-check":
|
||||
try:
|
||||
require_dynamic_replay_ready(manifest)
|
||||
except ContractError as error:
|
||||
parser.exit(2, f"{error}\n")
|
||||
if args.command == "synthetic":
|
||||
data = synthetic(manifest, args.profile)
|
||||
publish_validated(args.output, data, manifest)
|
||||
else:
|
||||
data = load(args.input, manifest)
|
||||
if args.command == "resample":
|
||||
require(np.isfinite(args.hz) and 0 < args.hz <= 1000, "hz must be finite and in (0, 1000]")
|
||||
episodes = {}
|
||||
for name, episode in data.episodes.items():
|
||||
require(
|
||||
episode.valid.all(),
|
||||
f"{name}: CLI resampling requires a fully valid episode; segment explicitly first",
|
||||
)
|
||||
count = int(np.floor(episode.time[-1] * args.hz)) + 1
|
||||
require(2 <= count <= 10_000_000, "Resampled frame count must be between 2 and 10 million")
|
||||
query = np.arange(count, dtype=np.float64) / args.hz
|
||||
# Endpoint is included only when on-grid; never append a shortened final interval.
|
||||
query = query[query <= episode.time[-1]]
|
||||
episodes[name] = sample(episode, query)
|
||||
data = Demonstrations(dict(data.metadata), data.joint_names, data.world_from_source, episodes)
|
||||
data.metadata["source_description"] += f"; resampled at {args.hz:g} Hz (off-grid end omitted)"
|
||||
publish_validated(args.output, data, manifest)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "PASS",
|
||||
"command": args.command,
|
||||
"schema": data.metadata["schema_version"],
|
||||
"provenance": data.metadata["provenance"],
|
||||
"asset_compatibility": "PASS" if manifest else "NOT_CHECKED",
|
||||
"episodes": {
|
||||
name: {"frames": len(ep.time), "valid_frames": int(ep.valid.sum())}
|
||||
for name, ep in data.episodes.items()
|
||||
},
|
||||
"joint_count": len(data.joint_names),
|
||||
"dynamic_replay": "BLOCKED",
|
||||
}
|
||||
)
|
||||
)
|
||||
except (ContractError, OSError, KeyError, TypeError, ValueError) as error:
|
||||
parser.exit(1, f"FAIL: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Single-hand experimental world-frame wrench PD. SI units; parameters UNCALIBRATED.
|
||||
|
||||
The controller tracks a root *link* pose, applies one bounded wrench at root COM,
|
||||
and compensates gravity for all links. No state writing, dynamics or hardware claims.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .trajectory import require
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Limits:
|
||||
position_gain: float = 100.0 # N/m
|
||||
velocity_gain: float = 10.0 # N s/m
|
||||
rotation_gain: float = 0.2 # Nm/rad
|
||||
angular_velocity_gain: float = 0.02 # Nm s/rad
|
||||
force: float = 20.0 # norm N, including gravity
|
||||
torque: float = 1.0 # norm Nm, including gravity moment
|
||||
reference_speed: float = 0.05 # m/s
|
||||
reference_angular_speed: float = 0.5 # rad/s
|
||||
reference_joint_speed: float = 0.5 # rad/s
|
||||
workspace_radius: float = 0.1 # m from initial root link position
|
||||
finger_stiffness: float = 3.0 # Nm/rad; model-independent joints only
|
||||
finger_damping: float = 0.1 # Nm s/rad
|
||||
finger_effort: float = 0.2 # Nm
|
||||
finger_velocity: float = 0.5 # rad/s
|
||||
|
||||
def __post_init__(self):
|
||||
require(all(np.isfinite(v) and v > 0 for v in vars(self).values()), "Controller limits must be finite positive")
|
||||
|
||||
|
||||
def reference_pose_to_xyzw(position, quaternion):
|
||||
"""HDF5 world link position + wxyz -> installed Isaac Lab world link pose."""
|
||||
position, quaternion = np.asarray(position), np.asarray(quaternion)
|
||||
require(position.shape == (3,) and quaternion.shape == (4,), "Expected position[3], quaternion[4]")
|
||||
return np.r_[position, quaternion[1:], quaternion[0]]
|
||||
|
||||
|
||||
def xyzw_pose_to_reference(pose):
|
||||
"""Installed Isaac Lab world link pose -> controller position + wxyz."""
|
||||
pose = np.asarray(pose)
|
||||
require(pose.shape == (7,), "Expected runtime pose[7]")
|
||||
return np.r_[pose[:3], pose[6], pose[3:6]]
|
||||
|
||||
|
||||
def rotation_error(target, current):
|
||||
"""Shortest world-frame rotation vector taking current to target, both wxyz."""
|
||||
target, current = np.asarray(target, dtype=float), np.asarray(current, dtype=float)
|
||||
require(target.shape == current.shape == (4,), "quaternion[4] required")
|
||||
for q in (target, current):
|
||||
require(np.isfinite(q).all() and abs(np.linalg.norm(q) - 1) < 1e-4, "unit quaternion required")
|
||||
w0, v0 = target[0], target[1:]
|
||||
w1, v1 = current[0], -current[1:]
|
||||
error = np.r_[w0 * w1 - np.dot(v0, v1), w0 * v1 + w1 * v0 + np.cross(v0, v1)]
|
||||
if error[0] < 0:
|
||||
error = -error
|
||||
norm = np.linalg.norm(error[1:])
|
||||
return error[1:] * (2 * np.arctan2(norm, max(0, error[0])) / norm if norm > 1e-12 else 2)
|
||||
|
||||
|
||||
def bounded(vector, maximum):
|
||||
return vector * min(1.0, maximum / max(np.linalg.norm(vector), 1e-12))
|
||||
|
||||
|
||||
def wrench(target_position, target_quaternion, link_pose, link_velocity, root_com, body_com, masses, limits):
|
||||
"""All vectors are world-frame; poses use wxyz. Output acts at root COM.
|
||||
|
||||
A link-origin PD force F needs (p_link-p_rootCOM) x F when applied at COM.
|
||||
Gravity compensation is sum(-m*g) and sum((p_bodyCOM-p_rootCOM) x (-m*g)).
|
||||
Both complete wrench vectors are norm-clamped; no unclamped gravity feedforward.
|
||||
"""
|
||||
inputs = (target_position, target_quaternion, link_pose, link_velocity, root_com, body_com, masses)
|
||||
require(all(np.isfinite(v).all() for v in inputs), "Nonfinite controller state")
|
||||
require(np.asarray(link_pose).shape == (7,) and np.asarray(link_velocity).shape == (6,), "Invalid root state shape")
|
||||
require(np.asarray(body_com).shape == (len(masses), 3) and np.asarray(root_com).shape == (3,), "Invalid COM shape")
|
||||
require(np.asarray(target_position).shape == (3,) and (np.asarray(masses) > 0).all(), "Invalid target/mass")
|
||||
pd_force = limits.position_gain * (target_position - link_pose[:3]) - limits.velocity_gain * link_velocity[:3]
|
||||
pd_torque = limits.rotation_gain * rotation_error(target_quaternion, link_pose[3:])
|
||||
pd_torque -= limits.angular_velocity_gain * link_velocity[3:]
|
||||
compensation = np.asarray(masses)[:, None] * np.array([0.0, 0.0, 9.81])
|
||||
force = pd_force + compensation.sum(axis=0)
|
||||
torque = pd_torque + np.cross(link_pose[:3] - root_com, pd_force)
|
||||
torque += np.cross(body_com - root_com, compensation).sum(axis=0)
|
||||
return bounded(force, limits.force), bounded(torque, limits.torque)
|
||||
|
||||
|
||||
def validate_reference(episode, limits):
|
||||
"""Fail rather than silently clip reference speed/workspace or bridge invalid frames."""
|
||||
require(episode.valid.all(), "Dynamic diagnostic needs one fully valid episode")
|
||||
dt = np.diff(episode.time)
|
||||
require(len(dt) and (dt > 0).all(), "Increasing reference timestamps required")
|
||||
speed = np.linalg.norm(np.diff(episode.wrist_position, axis=0), axis=1) / dt
|
||||
omega = (
|
||||
np.array(
|
||||
[
|
||||
np.linalg.norm(rotation_error(b, a))
|
||||
for a, b in zip(episode.wrist_quaternion[:-1], episode.wrist_quaternion[1:], strict=True)
|
||||
]
|
||||
)
|
||||
/ dt
|
||||
)
|
||||
joint_speed = np.abs(np.diff(episode.joint_position, axis=0)) / dt[:, None]
|
||||
require((speed <= limits.reference_speed).all(), "Reference translation too fast")
|
||||
require((omega <= limits.reference_angular_speed).all(), "Reference rotation too fast")
|
||||
require((joint_speed <= limits.reference_joint_speed).all(), "Reference joints too fast")
|
||||
require(
|
||||
(np.linalg.norm(episode.wrist_position - episode.wrist_position[0], axis=1) <= limits.workspace_radius).all(),
|
||||
"Reference outside workspace",
|
||||
)
|
||||
@@ -0,0 +1,299 @@
|
||||
"""L20 model-derived floating control overlay for the inspected PhysX 110.1.13 backend.
|
||||
|
||||
NewtonMimicAPI is parsed by this PhysX version. Preserve it; never add a second
|
||||
legacy PhysxMimicJointAPI. Static eligibility is not runtime coupling verification.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from pxr import Gf, Plug, Sdf, Usd, UsdGeom, UsdPhysics
|
||||
|
||||
from .asset import _require, dependencies, inspect, prepare_floating_overlay
|
||||
|
||||
SOURCE_URDF_SHA = "b8ef22e436ab311fb61f87091b72ae717821092471d628d0ea9475b5088daa5d"
|
||||
BACKEND_VERSION = "110.1.13"
|
||||
MIMIC = [
|
||||
{"joint": f"{finger}_dip", "reference": f"{finger}_pip", "multiplier": 0.89, "offset_rad": 0.0}
|
||||
for finger in ("index", "middle", "pinky", "ring")
|
||||
] + [{"joint": "thumb_ip", "reference": "thumb_mcp", "multiplier": 1.02, "offset_rad": 0.0}]
|
||||
DRIVE_FIELDS = ("stiffness", "damping", "maxForce")
|
||||
|
||||
|
||||
def require_backend(version, newton_schema_registered):
|
||||
"""Conservative capability binding; this does not certify runtime constraint response."""
|
||||
_require(version == BACKEND_VERSION, f"Unsupported PhysX version {version}; revalidate backend first")
|
||||
_require(newton_schema_registered, "Newton mimic schema not registered")
|
||||
|
||||
|
||||
def schema_context():
|
||||
"""Resolved schema fallbacks are part of the authoring/inspection contract."""
|
||||
registry = Usd.SchemaRegistry()
|
||||
return {
|
||||
name: bool(registry.FindAppliedAPIPrimDefinition(name)) for name in ("PhysxArticulationAPI", "NewtonMimicAPI")
|
||||
}
|
||||
|
||||
|
||||
def register_schema_plugins(paths):
|
||||
"""Register caller-selected USD schema plugins; never discover vendor paths implicitly."""
|
||||
for path in paths:
|
||||
Plug.Registry().RegisterPlugins(str(Path(path).resolve(strict=True)))
|
||||
_require(all(schema_context().values()), "Schema paths must resolve PhysxArticulationAPI and NewtonMimicAPI")
|
||||
|
||||
|
||||
def schemas(prim):
|
||||
"""Composed schema list, including codeless schemas not registered in CPU Python."""
|
||||
value = prim.GetMetadata("apiSchemas")
|
||||
return list(value.GetAppliedItems()) if value else []
|
||||
|
||||
|
||||
def newton_mimic(prim):
|
||||
"""Read single-DOF Newton equation; fallbacks are from installed generatedSchema.
|
||||
|
||||
Coef0 is degrees for revolute joints (not NewtonActuator's radians).
|
||||
Coef1 is dimensionless; the USD joint axis implicitly selects the DOF.
|
||||
"""
|
||||
_require("NewtonMimicAPI" in schemas(prim), f"Missing NewtonMimicAPI: {prim.GetPath()}")
|
||||
|
||||
def value(name, fallback):
|
||||
attr = prim.GetAttribute("newton:" + name)
|
||||
result = attr.Get() if attr else None
|
||||
return fallback if result is None else result
|
||||
|
||||
targets = prim.GetRelationship("newton:mimicJoint").GetTargets()
|
||||
_require(len(targets) == 1, "Exactly one mimic leader required")
|
||||
_require(value("mimicEnabled", True) is True, "Disabled mimic constraint")
|
||||
return str(targets[0]), float(value("mimicCoef1", 1)), math.radians(float(value("mimicCoef0", 0)))
|
||||
|
||||
|
||||
def bind_source(source, manifest):
|
||||
"""Reject stale identity/structural metadata before authoring or accepting an overlay."""
|
||||
actual = inspect(source)
|
||||
for key in ("asset_sha256", "joints", "root_body_path", "root_link", "dependencies", "bodies"):
|
||||
_require(actual[key] == manifest[key], f"Stale/inconsistent source manifest: {key}")
|
||||
_require(manifest["source_urdf"]["sha256"] == SOURCE_URDF_SHA, "Unapproved URDF provenance")
|
||||
_require(manifest["source_urdf"]["mimic"] == MIMIC, "Unexpected L20 mimic equations")
|
||||
_require(actual["root_link"] == "hand_base_link" and len(actual["joints"]) == 21, "Wrong L20 model")
|
||||
_require(actual["physics_variant"] == "physx", "PhysX variant required")
|
||||
validate_mimic(Usd.Stage.Open(str(source)), actual)
|
||||
return actual
|
||||
|
||||
|
||||
def validate_mimic(stage, manifest, passive=False):
|
||||
joints = {j["name"]: j for j in manifest["joints"]}
|
||||
followers = {eq["joint"] for eq in MIMIC}
|
||||
for name, joint in joints.items():
|
||||
prim = stage.GetPrimAtPath(joint["path"])
|
||||
_require(not any("PhysxMimic" in s or "Tendon" in s for s in schemas(prim)), "Duplicate/unsupported coupling")
|
||||
_require(("NewtonMimicAPI" in schemas(prim)) == (name in followers), "Unexpected mimic set")
|
||||
_require(not UsdPhysics.Joint(prim).GetExcludeFromArticulationAttr().Get(), "Excluded joint")
|
||||
_require(joint["axis"] in ("X", "Y", "Z"), "Invalid revolute axis")
|
||||
if name not in followers:
|
||||
continue
|
||||
equation = next(eq for eq in MIMIC if eq["joint"] == name)
|
||||
leader, coefficient, offset = newton_mimic(prim)
|
||||
_require(leader == joints[equation["reference"]]["path"], "Wrong mimic leader")
|
||||
_require(abs(coefficient - equation["multiplier"]) < 1e-6, "Wrong mimic multiplier/sign")
|
||||
_require(abs(offset - equation["offset_rad"]) < 1e-8, "Wrong mimic offset/units")
|
||||
if passive:
|
||||
_require("PhysicsDriveAPI:angular" not in schemas(prim), "Follower drive API still enabled")
|
||||
for field in DRIVE_FIELDS:
|
||||
_require(prim.GetAttribute(f"drive:angular:physics:{field}").Get() == 0, "Follower drive not zero")
|
||||
return [name for name in joints if name not in followers]
|
||||
|
||||
|
||||
def _preserved(original, stage, before):
|
||||
"""Only leaf world-anchor deactivation, root migration and follower drive suppression are allowed."""
|
||||
followers = {j["path"] for j in before["joints"] if j["name"] in {eq["joint"] for eq in MIMIC}}
|
||||
anchor = before["world_fixed_joints"][0]["path"]
|
||||
root = before["root_body_path"]
|
||||
source_anchor = original.GetPrimAtPath(anchor)
|
||||
_require(
|
||||
source_anchor.IsA(UsdPhysics.FixedJoint) and not source_anchor.GetAllChildren(), "Expected leaf world anchor"
|
||||
)
|
||||
joint = UsdPhysics.Joint(source_anchor)
|
||||
_require(not joint.GetBody0Rel().GetTargets(), "Anchor must attach directly to world")
|
||||
_require(joint.GetBody1Rel().GetTargets() == [Sdf.Path(root)], "Anchor must attach only to root")
|
||||
c0, c1 = UsdGeom.XformCache(), UsdGeom.XformCache()
|
||||
# TraverseAll retains the deliberately inactive leaf: no other deletion or
|
||||
# deactivation may hide geometry, state joints or time-sampled attributes.
|
||||
_require(
|
||||
[str(p.GetPath()) for p in original.TraverseAll()] == [str(p.GetPath()) for p in stage.TraverseAll()],
|
||||
"Prim tree changed",
|
||||
)
|
||||
for prim in original.TraverseAll():
|
||||
path = str(prim.GetPath())
|
||||
after = stage.GetPrimAtPath(path)
|
||||
_require(after.IsActive() == (False if path == anchor else prim.IsActive()), f"Unexpected active state: {path}")
|
||||
allowed = set()
|
||||
expected = set(schemas(prim))
|
||||
if path == anchor:
|
||||
_require(after.GetAttribute("physics:jointEnabled").Get() is False, "Inactive anchor must remain disabled")
|
||||
allowed.add("physics:jointEnabled")
|
||||
expected -= {"PhysicsArticulationRootAPI", "PhysxArticulationAPI"}
|
||||
if path == root:
|
||||
expected |= {"PhysicsArticulationRootAPI", "PhysxArticulationAPI"}
|
||||
allowed |= {
|
||||
a.GetName()
|
||||
for a in stage.GetPrimAtPath(anchor).GetAttributes()
|
||||
if a.GetName().startswith("physxArticulation:")
|
||||
}
|
||||
for name in allowed:
|
||||
_require(
|
||||
after.GetAttribute(name).Get() == stage.GetPrimAtPath(anchor).GetAttribute(name).Get(),
|
||||
"Root solver property changed",
|
||||
)
|
||||
if path in followers:
|
||||
expected.discard("PhysicsDriveAPI:angular")
|
||||
allowed |= {f"drive:angular:physics:{field}" for field in DRIVE_FIELDS}
|
||||
_require(set(schemas(after)) == expected, f"Unexpected schema edit: {path}")
|
||||
attrs = {a.GetName() for a in prim.GetAttributes()} | {a.GetName() for a in after.GetAttributes()}
|
||||
# This diagnostic accepts static assets only, including fields whose
|
||||
# default values may be changed by the preparation allowlist.
|
||||
for name in attrs:
|
||||
for candidate in (prim.GetAttribute(name), after.GetAttribute(name)):
|
||||
if candidate:
|
||||
_require(not candidate.GetTimeSamples(), f"Time samples prohibited: {path}.{name}")
|
||||
for name in attrs - allowed:
|
||||
a, b = prim.GetAttribute(name), after.GetAttribute(name)
|
||||
_require((a.Get() if a else None) == (b.Get() if b else None), f"Unexpected attribute: {path}.{name}")
|
||||
rels = {r.GetName() for r in prim.GetRelationships()} | {r.GetName() for r in after.GetRelationships()}
|
||||
for name in rels:
|
||||
_require(
|
||||
prim.GetRelationship(name).GetTargets() == after.GetRelationship(name).GetTargets(),
|
||||
"Relationship changed",
|
||||
)
|
||||
if prim.IsA(UsdGeom.Xformable):
|
||||
_require(
|
||||
Gf.IsClose(c0.GetLocalToWorldTransform(prim), c1.GetLocalToWorldTransform(after), 1e-12),
|
||||
"Transform changed",
|
||||
)
|
||||
|
||||
|
||||
def inspect_prepared(output, source_manifest):
|
||||
"""Inspect actual composed USD + original dependency hashes, never a JSON ready flag."""
|
||||
output = Path(output).resolve(strict=True)
|
||||
layer = Sdf.Layer.FindOrOpen(str(output))
|
||||
_require(
|
||||
len(layer.subLayerPaths) == 1 and not os.path.isabs(layer.subLayerPaths[0]),
|
||||
"One relative source layer required",
|
||||
)
|
||||
source = (output.parent / layer.subLayerPaths[0]).resolve(strict=True)
|
||||
before = bind_source(source, source_manifest)
|
||||
_require(layer.customLayerData.get("source_bundle_sha256") == before["asset_sha256"], "Overlay provenance mismatch")
|
||||
_require(layer.customLayerData.get("source_urdf_sha256") == SOURCE_URDF_SHA, "Overlay URDF mismatch")
|
||||
_require(
|
||||
dict(layer.customLayerData.get("schema_context", {})) == schema_context(),
|
||||
"Authoring schema context mismatch; regenerate from source with --schema-plugin-path for both "
|
||||
"installed PhysX and Newton plugins, and use the same plugins when inspecting",
|
||||
)
|
||||
after = inspect(output)
|
||||
_require(
|
||||
not after["world_fixed_joints"] and after["articulation_roots"] == [before["root_body_path"]], "Not floating"
|
||||
)
|
||||
stage = Usd.Stage.Open(str(output))
|
||||
masters = validate_mimic(stage, after, passive=True)
|
||||
_preserved(Usd.Stage.Open(str(source)), stage, before)
|
||||
return {
|
||||
"status": (
|
||||
"STATIC_ELIGIBLE_RUNTIME_UNVERIFIED"
|
||||
if all(schema_context().values())
|
||||
else "STATIC_ONLY_SCHEMA_UNREGISTERED"
|
||||
),
|
||||
"schema_context": schema_context(),
|
||||
"preparation_note": "Runtime use requires matching registered PhysX/Newton schemas; regenerate if different",
|
||||
"prepared_asset_sha256": after["asset_sha256"],
|
||||
"source_asset_sha256": before["asset_sha256"],
|
||||
"independent_joint_names": masters,
|
||||
"mimic": MIMIC,
|
||||
"required_physx_version": BACKEND_VERSION,
|
||||
"root_body_path": before["root_body_path"],
|
||||
"runtime_verified": False,
|
||||
}
|
||||
|
||||
|
||||
def prepare(source, output, manifest):
|
||||
source, output = Path(source).resolve(strict=True), Path(output).resolve()
|
||||
_require(not output.exists(), "Refusing to overwrite output")
|
||||
before = bind_source(source, manifest)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix=".l20-prepared-", dir=output.parent) as directory:
|
||||
temporary = Path(directory) / "floating.usda"
|
||||
prepare_floating_overlay(source, temporary)
|
||||
stage = Usd.Stage.Open(str(temporary))
|
||||
for joint in before["joints"]:
|
||||
if joint["name"] not in {eq["joint"] for eq in MIMIC}:
|
||||
continue
|
||||
prim = stage.GetPrimAtPath(joint["path"])
|
||||
# Preserve resolved drive defaults as inert attributes when removing API.
|
||||
drive_values = [
|
||||
(a.GetName(), a.GetTypeName(), a.Get())
|
||||
for a in prim.GetAttributes()
|
||||
if a.GetName().startswith("drive:angular:") and a.Get() is not None
|
||||
]
|
||||
prim.RemoveAPI(UsdPhysics.DriveAPI, "angular")
|
||||
for name, dtype, value in drive_values:
|
||||
prim.CreateAttribute(name, dtype).Set(value)
|
||||
for field in DRIVE_FIELDS:
|
||||
prim.CreateAttribute(f"drive:angular:physics:{field}", Sdf.ValueTypeNames.Float).Set(0)
|
||||
# jointEnabled=false leaves a parsed joint with world/local frames that
|
||||
# diverge when the floating hand is spawned. Remove that obsolete joint
|
||||
# from active composition, not its data or any robot body/state joint.
|
||||
stage.GetPrimAtPath(before["world_fixed_joints"][0]["path"]).SetActive(False)
|
||||
stage.GetRootLayer().customLayerData = {
|
||||
"purpose": "L20 experimental floating tracking; runtime UNVERIFIED",
|
||||
"source_bundle_sha256": before["asset_sha256"],
|
||||
"source_urdf_sha256": SOURCE_URDF_SHA,
|
||||
"physx_evidence_version": BACKEND_VERSION,
|
||||
"schema_context": schema_context(),
|
||||
}
|
||||
stage.GetRootLayer().Save()
|
||||
inspect_prepared(temporary, manifest)
|
||||
# Rebase the relative source path for the final location before publication.
|
||||
final_layer = Sdf.Layer.CreateAnonymous()
|
||||
final_layer.TransferContent(stage.GetRootLayer())
|
||||
final_layer.subLayerPaths = [os.path.relpath(source, output.parent).replace(os.sep, "/")]
|
||||
# Validate with the final relative-path context before publishing.
|
||||
descriptor, name = tempfile.mkstemp(prefix=".l20-publication-", suffix=".usda", dir=output.parent)
|
||||
os.close(descriptor)
|
||||
publication = Path(name)
|
||||
try:
|
||||
_require(final_layer.Export(str(publication)), "Export failed")
|
||||
inspect_prepared(publication, manifest)
|
||||
os.link(publication, output)
|
||||
finally:
|
||||
publication.unlink()
|
||||
_require(dependencies(source)[1] == before["asset_sha256"], "Original changed")
|
||||
return inspect_prepared(output, manifest)
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("asset", type=Path)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, help="Create new experimental floating overlay")
|
||||
parser.add_argument(
|
||||
"--schema-plugin-path",
|
||||
type=Path,
|
||||
action="append",
|
||||
default=[],
|
||||
help="Repeat for caller-selected PhysX and Newton USD plugInfo.json paths; required for runtime preparation",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
if args.schema_plugin_path:
|
||||
register_schema_plugins(args.schema_plugin_path)
|
||||
manifest = json.loads(args.manifest.read_text())
|
||||
result = prepare(args.asset, args.output, manifest) if args.output else inspect_prepared(args.asset, manifest)
|
||||
print(json.dumps(result, indent=2))
|
||||
except (ValueError, OSError, RuntimeError, KeyError) as error:
|
||||
parser.exit(1, f"FAIL: {error}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Strict l20_tracking_v1 reference-state ingestion, without simulator imports."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
|
||||
|
||||
class ContractError(ValueError):
|
||||
"""An input cannot be safely interpreted as a reference trajectory."""
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise ContractError(message)
|
||||
|
||||
|
||||
def text(value, label):
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode("utf-8", errors="strict")
|
||||
require(isinstance(value, str) and bool(value.strip()), f"{label}: nonempty UTF-8 text required")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Episode:
|
||||
time: np.ndarray
|
||||
wrist_position: np.ndarray
|
||||
wrist_quaternion: np.ndarray
|
||||
joint_position: np.ndarray
|
||||
valid: np.ndarray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Demonstrations:
|
||||
metadata: dict
|
||||
joint_names: tuple[str, ...]
|
||||
world_from_source: np.ndarray
|
||||
episodes: dict[str, Episode]
|
||||
|
||||
|
||||
def _dataset(group, name, dtype, shape):
|
||||
require(name in group and isinstance(group[name], h5py.Dataset), f"{group.name}/{name}: dataset required")
|
||||
data = group[name]
|
||||
require(
|
||||
data.dtype.kind == np.dtype(dtype).kind and data.dtype.itemsize == np.dtype(dtype).itemsize,
|
||||
f"{data.name}: dtype must be {dtype}",
|
||||
)
|
||||
require(data.shape == shape, f"{data.name}: expected shape {shape}, got {data.shape}")
|
||||
value = data[...]
|
||||
require(np.isfinite(value).all(), f"{data.name}: NaN/Inf prohibited, including invalid frames")
|
||||
return value
|
||||
|
||||
|
||||
def load(path: str | Path, manifest: dict | None = None) -> Demonstrations:
|
||||
"""Read and close an HDF5 file. Manifest checking never approves physical actuation."""
|
||||
with h5py.File(path, "r") as file:
|
||||
attributes = {}
|
||||
for name in (
|
||||
"schema_version",
|
||||
"embodiment",
|
||||
"hand_side",
|
||||
"asset_sha256",
|
||||
"root_link",
|
||||
"metric_scale_provenance",
|
||||
"provenance",
|
||||
"source_description",
|
||||
):
|
||||
require(name in file.attrs, f"missing root attribute {name}")
|
||||
attributes[name] = text(file.attrs[name], name)
|
||||
for name, expected in (("schema_version", "l20_tracking_v1"), ("embodiment", "L20"), ("hand_side", "left")):
|
||||
require(attributes[name] == expected, f"{name}: expected {expected}")
|
||||
require(
|
||||
re.fullmatch(r"[0-9a-f]{64}", attributes["asset_sha256"]) is not None,
|
||||
"asset_sha256: lowercase SHA-256 required",
|
||||
)
|
||||
require(attributes["provenance"] in ("expert_retargeted", "synthetic"), "unknown provenance")
|
||||
require("scale_to_meters" in file.attrs, "missing root attribute scale_to_meters")
|
||||
scale = np.asarray(file.attrs["scale_to_meters"])
|
||||
require(
|
||||
scale.shape == () and scale.dtype.kind == "f" and np.isfinite(scale) and scale > 0,
|
||||
"scale_to_meters: positive finite floating scalar required",
|
||||
)
|
||||
attributes["scale_to_meters"] = float(scale)
|
||||
require("metadata" in file and isinstance(file["metadata"], h5py.Group), "metadata group required")
|
||||
meta = file["metadata"]
|
||||
require(
|
||||
"joint_names" in meta and isinstance(meta["joint_names"], h5py.Dataset),
|
||||
"metadata/joint_names dataset required",
|
||||
)
|
||||
names = meta["joint_names"]
|
||||
info = h5py.check_string_dtype(names.dtype)
|
||||
require(
|
||||
info is not None and info.encoding == "utf-8" and names.ndim == 1 and len(names) > 0,
|
||||
"joint_names must be UTF-8[J], J > 0",
|
||||
)
|
||||
joint_names = tuple(text(n, "joint_names") for n in names.asstr()[...])
|
||||
require(len(set(joint_names)) == len(joint_names), "duplicate joint names")
|
||||
transform = _dataset(meta, "world_from_source", "float64", (4, 4))
|
||||
require(
|
||||
np.allclose(transform[3], [0, 0, 0, 1], rtol=0, atol=1e-8), "world_from_source: invalid homogeneous row"
|
||||
)
|
||||
rotation = transform[:3, :3]
|
||||
require(
|
||||
np.allclose(rotation.T @ rotation, np.eye(3), rtol=0, atol=1e-6)
|
||||
and np.isclose(np.linalg.det(rotation), 1, rtol=0, atol=1e-6),
|
||||
"world_from_source must be a proper rigid transform, not a scale/reflection",
|
||||
)
|
||||
require(
|
||||
"episodes" in file and isinstance(file["episodes"], h5py.Group) and len(file["episodes"]) > 0,
|
||||
"nonempty episodes group required",
|
||||
)
|
||||
episodes = {}
|
||||
for name, group in file["episodes"].items():
|
||||
require(
|
||||
re.fullmatch(r"demo_[0-9]{6}", name) is not None and isinstance(group, h5py.Group),
|
||||
f"invalid episode group {name}",
|
||||
)
|
||||
require(
|
||||
"time" in group and isinstance(group["time"], h5py.Dataset) and group["time"].ndim == 1,
|
||||
f"{name}: time[T] required",
|
||||
)
|
||||
count = len(group["time"])
|
||||
require(count >= 2, f"{name}: at least two frames required")
|
||||
time = _dataset(group, "time", "float64", (count,))
|
||||
require(time[0] == 0 and (np.diff(time) > 0).all(), f"{name}: time must start at 0 and increase strictly")
|
||||
position = _dataset(group, "wrist_position", "float32", (count, 3))
|
||||
quaternion = _dataset(group, "wrist_quaternion", "float32", (count, 4))
|
||||
joints = _dataset(group, "joint_position", "float32", (count, len(joint_names)))
|
||||
valid = _dataset(group, "valid", "bool", (count,))
|
||||
require(valid.any(), f"{name}: no valid frames")
|
||||
require(
|
||||
np.allclose(np.linalg.norm(quaternion[valid], axis=1), 1, rtol=0, atol=1e-4),
|
||||
f"{name}: valid quaternions must be unit length",
|
||||
)
|
||||
adjacent = valid[:-1] & valid[1:]
|
||||
require(
|
||||
(np.sum(quaternion[:-1] * quaternion[1:], axis=1)[adjacent] >= 0).all(),
|
||||
f"{name}: adjacent valid quaternion signs must be continuous",
|
||||
)
|
||||
episodes[name] = Episode(time, position, quaternion, joints, valid)
|
||||
result = Demonstrations(attributes, joint_names, transform, episodes)
|
||||
if manifest is not None:
|
||||
validate_against_manifest(result, manifest)
|
||||
return result
|
||||
|
||||
|
||||
def validate_against_manifest(data: Demonstrations, manifest: dict):
|
||||
"""Check identity/order/limits and known URDF mimic equations, not actuator validity."""
|
||||
require(manifest.get("manifest_version") == "l20_asset_manifest_v1", "unsupported manifest")
|
||||
require(data.metadata["asset_sha256"] == manifest["asset_sha256"], "asset bundle hash mismatch")
|
||||
require(data.metadata["root_link"] == manifest["root_link"], "root link mismatch")
|
||||
joints = manifest["joints"]
|
||||
expected = tuple(joint["name"] for joint in joints)
|
||||
require(data.joint_names == expected, "joint order/names must exactly match manifest (no implicit reorder)")
|
||||
lower = np.array([joint["lower_rad"] for joint in joints])
|
||||
upper = np.array([joint["upper_rad"] for joint in joints])
|
||||
require(
|
||||
np.isfinite(lower).all() and np.isfinite(upper).all() and (lower <= upper).all(),
|
||||
"manifest requires finite ordered revolute limits",
|
||||
)
|
||||
for name, episode in data.episodes.items():
|
||||
q = episode.joint_position[episode.valid]
|
||||
require(((q >= lower - 1e-6) & (q <= upper + 1e-6)).all(), f"{name}: reference joint limit violation")
|
||||
for mimic in manifest.get("source_urdf", {}).get("mimic", []):
|
||||
child = expected.index(mimic["joint"])
|
||||
parent = expected.index(mimic["reference"])
|
||||
error = q[:, child] - (mimic["multiplier"] * q[:, parent] + mimic["offset_rad"])
|
||||
require((np.abs(error) <= 1e-3).all(), f"{name}: inconsistent mimic reference {mimic['joint']}")
|
||||
|
||||
|
||||
def sample(episode: Episode, query_time) -> Episode:
|
||||
"""Linear position/joint interpolation and shortest-arc SLERP; never cross invalid gaps.
|
||||
|
||||
Inputs must come from ``load``. Query a separate valid segment at a time; this
|
||||
rejects even sparse queries spanning an invalid frame, and never extrapolates.
|
||||
"""
|
||||
query = np.asarray(query_time, dtype=np.float64)
|
||||
require(query.ndim == 1 and len(query) > 0 and np.isfinite(query).all(), "finite query_time[N] required")
|
||||
require((np.diff(query) > 0).all(), "query times must increase strictly")
|
||||
time = episode.time
|
||||
require(query[0] >= time[0] and query[-1] <= time[-1], "extrapolation prohibited")
|
||||
left = np.searchsorted(time, query, side="right") - 1
|
||||
right = np.searchsorted(time, query, side="left")
|
||||
require(episode.valid[left.min() : right.max() + 1].all(), "query crosses or touches an invalid frame")
|
||||
denominator = time[right] - time[left]
|
||||
alpha = np.divide(query - time[left], denominator, out=np.zeros_like(query), where=denominator > 0)[:, None]
|
||||
position = (1 - alpha) * episode.wrist_position[left] + alpha * episode.wrist_position[right]
|
||||
joints = (1 - alpha) * episode.joint_position[left] + alpha * episode.joint_position[right]
|
||||
q0 = episode.wrist_quaternion[left].astype(np.float64)
|
||||
q1 = episode.wrist_quaternion[right].astype(np.float64)
|
||||
q0 /= np.linalg.norm(q0, axis=1, keepdims=True)
|
||||
q1 /= np.linalg.norm(q1, axis=1, keepdims=True)
|
||||
dot = np.sum(q0 * q1, axis=1, keepdims=True)
|
||||
q1 = np.where(dot < 0, -q1, q1)
|
||||
dot = np.clip(np.abs(dot), 0, 1)
|
||||
theta = np.arccos(dot)
|
||||
denominator = np.sin(theta)
|
||||
curved = dot < 0.9995
|
||||
weight0 = np.divide(np.sin((1 - alpha) * theta), denominator, out=1 - alpha.copy(), where=curved)
|
||||
weight1 = np.divide(np.sin(alpha * theta), denominator, out=alpha.copy(), where=curved)
|
||||
quaternion = weight0 * q0 + weight1 * q1
|
||||
quaternion /= np.linalg.norm(quaternion, axis=1, keepdims=True)
|
||||
# Sparse output samples may span many source arcs. Preserve rotations while
|
||||
# choosing a continuous quaternion hemisphere on the output timeline too.
|
||||
flips = np.where(np.sum(quaternion[:-1] * quaternion[1:], axis=1) < 0, -1, 1)
|
||||
quaternion[1:] *= np.cumprod(flips)[:, None]
|
||||
return Episode(
|
||||
query,
|
||||
position.astype(np.float32),
|
||||
quaternion.astype(np.float32),
|
||||
joints.astype(np.float32),
|
||||
np.ones(len(query), dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def require_dynamic_replay_ready(manifest: dict):
|
||||
"""A data manifest alone never authorizes dynamic control."""
|
||||
raise ContractError(
|
||||
"BLOCKED: a data manifest cannot authorize actuation. Use the separate experimental track_l20.py "
|
||||
"path with actual prepared-USD inspection, backend checks and runtime assertions. "
|
||||
"Production replay and hardware control remain unvalidated."
|
||||
)
|
||||
@@ -4,9 +4,56 @@ Changelog
|
||||
Unreleased
|
||||
~~~~~~~~~~
|
||||
|
||||
0.1.1 (2026-09-11)
|
||||
~~~~~~~~~~~~~~~~~~
|
||||
|
||||
USD initial validation milestone (USD 文件初步校验). Synthetic single-hand
|
||||
replay only; not a production training or hardware release. Cartpole and its task
|
||||
ID are unchanged. Old prepared overlays require regeneration. Full pre-commit
|
||||
remains unavailable in the validation environment.
|
||||
|
||||
Added
|
||||
^^^^^
|
||||
|
||||
* Add the explicitly synthetic ``range_4s`` HDF5 fixture profile (4 seconds,
|
||||
10 mm translation, 0.1 rad yaw, bounded master joints). Real-file replay passes
|
||||
two 960-step repetitions with unchanged gains/thresholds; no training claim.
|
||||
|
||||
* Add CPU-only ``l20_tracking_v1`` HDF5 validation, reference resampling, synthetic
|
||||
fixtures, and regression tests, documented in ``L20_TRACKING.md``. Provide the
|
||||
standalone colleague-facing contract in ``HDF5_REQUIREMENTS.md``.
|
||||
* Add reproducible L20 composed-asset inspection and a non-destructive floating-root
|
||||
topology overlay plus an Isaac Lab scene-loading diagnostic. Dynamic wrist/finger
|
||||
production replay remains blocked pending runtime validation and hardware actuator mapping.
|
||||
Cartpole is unchanged.
|
||||
* Add an experimental floating tracking overlay that preserves native Newton mimic
|
||||
constraints, suppresses follower drives, and validates source identity/allowed edits.
|
||||
Add a bounded single-hand wrench-PD/master-position replay diagnostic with explicit
|
||||
COM/world-frame gravity accounting, reset and passive-coupling assertions. Runtime
|
||||
smoke passes on the corrected registered overlay: seed 42, one hand, two 480-step
|
||||
synthetic repetitions on PhysX 110.1.13. Real demonstrations, broad motion/contact,
|
||||
vectorized training and hardware remain unvalidated. Gains are uncalibrated.
|
||||
|
||||
Fixed
|
||||
^^^^^
|
||||
|
||||
* Deactivate only the obsolete disabled leaf world anchor in prepared control
|
||||
overlays while retaining its data and strict all-prim/time-sample checks.
|
||||
Both bounded runtime tests report a floating root and 21 active revolute joints,
|
||||
without the prior disjointed-frame warning; small-profile metrics match baseline.
|
||||
|
||||
* Launch Kit before USD-dependent diagnostic imports and preserve exceptions/nonzero
|
||||
exit status across fast shutdown; an exit without PASS metrics is not acceptance.
|
||||
* Preserve resolved articulation schema defaults on both the released anchor and
|
||||
floating root. Add explicit preparation CLI schema-plugin paths and authoring-context
|
||||
checks; old overlays require regeneration. CPU regressions and the subsequent
|
||||
authorized synthetic dynamic smoke pass; metrics and remaining runtime warnings
|
||||
are recorded in ``L20_TRACKING.md``.
|
||||
|
||||
* Correct the prior missing-PhysX-coupling inference: installed PhysX 110.1.13 parses
|
||||
NewtonMimicAPI natively. Schema presence is now reported as runtime-unverified,
|
||||
not missing physics; it does not establish actual asset dynamics.
|
||||
|
||||
* Configure the L20 PhysX preview with a world-fixed articulation root, ground,
|
||||
lighting, and 240 Hz physics to prevent the observed 60 Hz instability.
|
||||
Add a bounded editor smoke script; Isaac Lab task integration is not included.
|
||||
|
||||
@@ -24,7 +24,7 @@ INSTALL_REQUIRES = [
|
||||
# Installation operation
|
||||
setup(
|
||||
name="dex_workbench",
|
||||
packages=["dex_workbench"],
|
||||
packages=["dex_workbench", "dex_workbench_tracking"],
|
||||
author=EXTENSION_TOML_DATA["package"]["author"],
|
||||
maintainer=EXTENSION_TOML_DATA["package"]["maintainer"],
|
||||
url=EXTENSION_TOML_DATA["package"]["repository"],
|
||||
@@ -32,6 +32,7 @@ setup(
|
||||
description=EXTENSION_TOML_DATA["package"]["description"],
|
||||
keywords=EXTENSION_TOML_DATA["package"]["keywords"],
|
||||
install_requires=INSTALL_REQUIRES,
|
||||
extras_require={"tracking": ["numpy>=1.26", "h5py>=3.10"]},
|
||||
license="Apache-2.0",
|
||||
include_package_data=True,
|
||||
python_requires=">=3.12",
|
||||
@@ -41,4 +42,4 @@ setup(
|
||||
"Isaac Sim :: 6.0.0",
|
||||
],
|
||||
zip_safe=False,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Static USD regression tests; run with Isaac Sim's pxr-capable Python, no GPU/Kit."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dex_workbench_tracking.asset import dependencies, inspect, prepare_floating_overlay
|
||||
|
||||
from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics
|
||||
|
||||
|
||||
class TrackingAssetTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / "robot.usda"
|
||||
stage = Usd.Stage.CreateNew(str(self.path))
|
||||
root = UsdGeom.Xform.Define(stage, "/Robot").GetPrim()
|
||||
stage.SetDefaultPrim(root)
|
||||
variant = root.GetVariantSets().AddVariantSet("Physics")
|
||||
variant.AddVariant("physx")
|
||||
variant.SetVariantSelection("physx")
|
||||
UsdGeom.SetStageMetersPerUnit(stage, 1)
|
||||
UsdGeom.SetStageUpAxis(stage, "Z")
|
||||
UsdPhysics.SetStageKilogramsPerUnit(stage, 1)
|
||||
for name in ("base", "tip"):
|
||||
prim = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim()
|
||||
UsdPhysics.RigidBodyAPI.Apply(prim)
|
||||
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr(0.1)
|
||||
hinge = UsdPhysics.RevoluteJoint.Define(stage, "/Robot/hinge")
|
||||
hinge.CreateBody0Rel().SetTargets(["/Robot/base"])
|
||||
hinge.CreateBody1Rel().SetTargets(["/Robot/tip"])
|
||||
hinge.CreateLowerLimitAttr(0)
|
||||
hinge.CreateUpperLimitAttr(90)
|
||||
fixed = UsdPhysics.FixedJoint.Define(stage, "/Robot/anchor")
|
||||
fixed.CreateBody1Rel().SetTargets(["/Robot/base"])
|
||||
UsdPhysics.ArticulationRootAPI.Apply(fixed.GetPrim())
|
||||
stage.GetRootLayer().Save()
|
||||
|
||||
def test_manifest_is_explicitly_not_actuator_map(self):
|
||||
result = inspect(self.path)
|
||||
self.assertEqual(result["root_link"], "base")
|
||||
self.assertEqual([j["name"] for j in result["joints"]], ["hinge"])
|
||||
self.assertAlmostEqual(result["joints"][0]["upper_rad"], 1.5707963267948966)
|
||||
self.assertFalse(result["dynamic_replay_ready"])
|
||||
self.assertEqual(result["coupling_status"], "UNVERIFIED")
|
||||
json.dumps(result, allow_nan=False)
|
||||
|
||||
def test_bundle_hash_is_portable_and_sensitive(self):
|
||||
target = Path(self.directory.name) / "moved"
|
||||
target.mkdir()
|
||||
copy = target / self.path.name
|
||||
shutil.copyfile(self.path, copy)
|
||||
self.assertEqual(dependencies(self.path)[1], dependencies(copy)[1])
|
||||
with copy.open("a") as stream:
|
||||
stream.write("\n# changed\n")
|
||||
self.assertNotEqual(dependencies(self.path)[1], dependencies(copy)[1])
|
||||
|
||||
def test_missing_dependency_rejected(self):
|
||||
stage = Usd.Stage.Open(str(self.path))
|
||||
stage.GetRootLayer().subLayerPaths = ["missing.usda"]
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Unresolved"):
|
||||
inspect(self.path)
|
||||
|
||||
def test_floating_overlay_preserves_source_and_transforms(self):
|
||||
before = self.path.read_bytes()
|
||||
output = Path(self.directory.name) / "floating.usda"
|
||||
result = prepare_floating_overlay(self.path, output)
|
||||
self.assertEqual(result["world_fixed_joints"], [])
|
||||
self.assertEqual(result["articulation_roots"], ["/Robot/base"])
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertFalse(result["dynamic_replay_ready"])
|
||||
stage = Usd.Stage.Open(str(output))
|
||||
self.assertFalse(UsdPhysics.Joint(stage.GetPrimAtPath("/Robot/anchor")).GetJointEnabledAttr().Get())
|
||||
self.assertFalse(Sdf.Layer.FindOrOpen(str(output)).subLayerPaths[0].startswith("/"))
|
||||
self.assertTrue(
|
||||
Gf.IsClose(
|
||||
UsdGeom.XformCache().GetLocalToWorldTransform(stage.GetPrimAtPath("/Robot/base")), Gf.Matrix4d(1), 1e-12
|
||||
)
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "overwrite"):
|
||||
prepare_floating_overlay(self.path, output)
|
||||
with self.assertRaisesRegex(ValueError, "fixed-world"):
|
||||
prepare_floating_overlay(output, Path(self.directory.name) / "again.usda")
|
||||
|
||||
def test_ambiguous_root_rejected(self):
|
||||
stage = Usd.Stage.Open(str(self.path))
|
||||
UsdPhysics.ArticulationRootAPI.Apply(stage.GetPrimAtPath("/Robot/base"))
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "one articulation"):
|
||||
inspect(self.path)
|
||||
|
||||
def test_urdf_matching_and_wrong_limits(self):
|
||||
urdf = Path(self.directory.name) / "robot.urdf"
|
||||
urdf.write_text(
|
||||
'<robot name="test"><joint name="hinge" type="revolute">'
|
||||
'<parent link="base"/><child link="tip"/>'
|
||||
'<limit lower="0" upper="1.5707963267948966"/></joint></robot>'
|
||||
)
|
||||
result = inspect(self.path, urdf)
|
||||
self.assertEqual(result["source_urdf"]["status"], "STRUCTURAL_MATCH_ONLY")
|
||||
urdf.write_text(urdf.read_text().replace('upper="1.5707963267948966"', 'upper="1"'))
|
||||
with self.assertRaisesRegex(ValueError, "limit mismatch"):
|
||||
inspect(self.path, urdf)
|
||||
|
||||
def test_newton_mimic_presence_is_not_runtime_verification(self):
|
||||
stage = Usd.Stage.Open(str(self.path))
|
||||
hinge = stage.GetPrimAtPath("/Robot/hinge")
|
||||
hinge.AddAppliedSchema("NewtonMimicAPI")
|
||||
hinge.CreateAttribute("newton:mimicCoef1", Sdf.ValueTypeNames.Float).Set(1)
|
||||
stage.GetRootLayer().Save()
|
||||
urdf = Path(self.directory.name) / "robot.urdf"
|
||||
# Deliberately self-referencing fixture only tests backend evidence, not valid transmission.
|
||||
urdf.write_text(
|
||||
'<robot name="test"><joint name="hinge" type="revolute">'
|
||||
'<parent link="base"/><child link="tip"/>'
|
||||
'<limit lower="0" upper="1.5707963267948966"/>'
|
||||
'<mimic joint="hinge" multiplier="1"/></joint></robot>'
|
||||
)
|
||||
result = inspect(self.path, urdf)
|
||||
self.assertTrue(result["coupling_evidence"])
|
||||
self.assertFalse(result["physx_coupling_evidence"])
|
||||
self.assertEqual(result["coupling_status"], "MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Analytic CPU regressions for control.py; not simulated dynamics."""
|
||||
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from dex_workbench_tracking.cli import synthetic
|
||||
from dex_workbench_tracking.control import (
|
||||
Limits,
|
||||
reference_pose_to_xyzw,
|
||||
rotation_error,
|
||||
validate_reference,
|
||||
wrench,
|
||||
xyzw_pose_to_reference,
|
||||
)
|
||||
from dex_workbench_tracking.trajectory import ContractError
|
||||
|
||||
|
||||
class ControlTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.limits = Limits()
|
||||
self.pose = np.array([0, 0, 0, 1, 0, 0, 0], dtype=float)
|
||||
self.velocity = np.zeros(6)
|
||||
|
||||
def compute(self, target=None, root_com=None, body_com=None, masses=None):
|
||||
return wrench(
|
||||
np.zeros(3) if target is None else np.array(target),
|
||||
self.pose[3:],
|
||||
self.pose,
|
||||
self.velocity,
|
||||
np.zeros(3) if root_com is None else np.array(root_com),
|
||||
np.zeros((1, 3)) if body_com is None else np.array(body_com),
|
||||
np.array([0.1]) if masses is None else np.array(masses),
|
||||
self.limits,
|
||||
)
|
||||
|
||||
def test_hdf5_wxyz_runtime_xyzw_boundary(self):
|
||||
position = [0.1, 0.2, 0.3]
|
||||
quaternion = [0.8, 0.6, 0, 0]
|
||||
runtime = reference_pose_to_xyzw(position, quaternion)
|
||||
np.testing.assert_allclose(runtime, [0.1, 0.2, 0.3, 0.6, 0, 0, 0.8])
|
||||
np.testing.assert_allclose(xyzw_pose_to_reference(runtime), position + quaternion)
|
||||
|
||||
def test_gravity_all_links_and_moment_arm(self):
|
||||
force, torque = self.compute(body_com=[[0, 0, 0], [0.1, 0, 0]], masses=[0.1, 0.2])
|
||||
np.testing.assert_allclose(force, [0, 0, 2.943])
|
||||
np.testing.assert_allclose(torque, [0, -0.1962, 0])
|
||||
|
||||
def test_link_pd_force_shift_to_com(self):
|
||||
force, torque = self.compute(target=[0, 0.01, 0], root_com=[0.1, 0, 0], body_com=[[0.1, 0, 0]])
|
||||
np.testing.assert_allclose(force, [0, 1, 0.981])
|
||||
np.testing.assert_allclose(torque, [0, 0, -0.1])
|
||||
|
||||
def test_total_wrench_including_gravity_is_bounded(self):
|
||||
force, torque = self.compute(target=[100, 100, 100], body_com=[[1, 0, 0]], masses=[100])
|
||||
self.assertLessEqual(np.linalg.norm(force), self.limits.force + 1e-10)
|
||||
self.assertLessEqual(np.linalg.norm(torque), self.limits.torque + 1e-10)
|
||||
|
||||
def test_damping_opposes_world_velocity(self):
|
||||
self.velocity[:3] = [0.01, 0, 0]
|
||||
self.velocity[3:] = [0, 0, 0.1]
|
||||
force, torque = self.compute()
|
||||
self.assertAlmostEqual(force[0], -0.1)
|
||||
self.assertAlmostEqual(torque[2], -0.002)
|
||||
|
||||
def test_orientation_world_rotation_and_antipodes(self):
|
||||
angle = 0.2
|
||||
target = np.array([np.cos(angle / 2), 0, 0, np.sin(angle / 2)])
|
||||
np.testing.assert_allclose(rotation_error(target, self.pose[3:]), [0, 0, angle])
|
||||
np.testing.assert_allclose(rotation_error(-target, self.pose[3:]), [0, 0, angle])
|
||||
np.testing.assert_allclose(rotation_error(self.pose[3:], target), [0, 0, -angle])
|
||||
# Current local-X quarter-turn followed by world-Z quarter-turn.
|
||||
np.testing.assert_allclose(
|
||||
rotation_error([0.5, 0.5, 0.5, 0.5], [2**-0.5, 2**-0.5, 0, 0]), [0, 0, np.pi / 2], atol=1e-12
|
||||
)
|
||||
|
||||
def test_nonfinite_state_bad_mass_and_limits_rejected(self):
|
||||
for mass in (0, -1, float("nan")):
|
||||
with self.assertRaises(ContractError):
|
||||
self.compute(masses=[mass])
|
||||
for value in (0, -1, float("nan"), float("inf")):
|
||||
with self.assertRaises(ContractError):
|
||||
Limits(force=value)
|
||||
|
||||
def test_reference_envelope_and_invalid_gap(self):
|
||||
manifest = {
|
||||
"manifest_version": "l20_asset_manifest_v1",
|
||||
"asset_sha256": "a" * 64,
|
||||
"root_link": "test",
|
||||
"joints": [{"name": "a", "lower_rad": 0, "upper_rad": 1}],
|
||||
}
|
||||
episode = synthetic(manifest).episodes["demo_000000"]
|
||||
validate_reference(episode, self.limits)
|
||||
episode.valid[60] = False
|
||||
with self.assertRaisesRegex(ContractError, "fully valid"):
|
||||
validate_reference(episode, self.limits)
|
||||
episode.valid[:] = True
|
||||
episode.wrist_position[60, 0] = 1
|
||||
with self.assertRaisesRegex(ContractError, "too fast"):
|
||||
validate_reference(episode, self.limits)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Source-named CPU/USD tests of prepared.py; actual L20 dependency copies, no Kit."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dex_workbench_tracking.asset import dependencies
|
||||
from dex_workbench_tracking.prepared import (
|
||||
BACKEND_VERSION,
|
||||
MIMIC,
|
||||
bind_source,
|
||||
inspect_prepared,
|
||||
newton_mimic,
|
||||
prepare,
|
||||
require_backend,
|
||||
schemas,
|
||||
)
|
||||
|
||||
from pxr import Sdf, Usd, UsdPhysics
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
ASSET_DIR = REPO / "assets/robots/dex_hand/linkerhand_g20_left"
|
||||
|
||||
|
||||
class PreparedTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.bundle = Path(self.temp.name) / "bundle"
|
||||
shutil.copytree(ASSET_DIR / "linkerhand_g20_left", self.bundle / "original")
|
||||
self.source = self.bundle / "original/linkerhand_g20_left.usda"
|
||||
self.output = self.bundle / "tracking.usda"
|
||||
self.manifest = json.loads((ASSET_DIR / "tracking_manifest.json").read_text())
|
||||
|
||||
def build(self):
|
||||
return prepare(self.source, self.output, self.manifest)
|
||||
|
||||
def test_unsupported_backend_or_unregistered_schema_rejected(self):
|
||||
require_backend(BACKEND_VERSION, True)
|
||||
for version, registered in (("110.1.12", True), ("future", True), (BACKEND_VERSION, False)):
|
||||
with self.assertRaises(ValueError):
|
||||
require_backend(version, registered)
|
||||
|
||||
def test_overlay_suppresses_only_followers_and_preserves_original(self):
|
||||
original = dependencies(self.source)
|
||||
result = self.build()
|
||||
self.assertEqual(dependencies(self.source), original)
|
||||
self.assertFalse(result["runtime_verified"])
|
||||
self.assertEqual(len(result["independent_joint_names"]), 16)
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
for eq in MIMIC:
|
||||
joint = next(j for j in self.manifest["joints"] if j["name"] == eq["joint"])
|
||||
prim = stage.GetPrimAtPath(joint["path"])
|
||||
self.assertIn("NewtonMimicAPI", schemas(prim))
|
||||
self.assertNotIn("PhysicsDriveAPI:angular", schemas(prim))
|
||||
leader, multiplier, offset = newton_mimic(prim)
|
||||
self.assertTrue(leader.endswith("/" + eq["reference"]))
|
||||
self.assertAlmostEqual(multiplier, eq["multiplier"], places=6)
|
||||
self.assertEqual(offset, 0)
|
||||
self.assertEqual(result, inspect_prepared(self.output, self.manifest))
|
||||
|
||||
def test_only_obsolete_leaf_world_anchor_is_inactive(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
|
||||
self.assertFalse(anchor.IsActive())
|
||||
self.assertFalse(anchor.GetAttribute("physics:jointEnabled").Get())
|
||||
self.assertFalse(anchor.GetAllChildren())
|
||||
self.assertIn(anchor, list(stage.TraverseAll()))
|
||||
self.assertNotIn(anchor, list(stage.Traverse()))
|
||||
joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.Joint)]
|
||||
self.assertEqual(len(joints), 21)
|
||||
self.assertTrue(all(p.IsA(UsdPhysics.RevoluteJoint) for p in joints))
|
||||
anchor.SetActive(True)
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "active state"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_unrelated_deactivation_rejected(self):
|
||||
self.edit_follower(lambda p: p.SetActive(False))
|
||||
|
||||
def test_unrelated_prim_addition_rejected(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
stage.DefinePrim(self.manifest["default_prim"] + "/Extra", "Scope")
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Prim tree changed"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_inactive_anchor_attributes_still_checked(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
|
||||
anchor.GetAttribute("physics:jointEnabled").Set(True, Usd.TimeCode(1))
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_inactive_anchor_must_remain_disabled(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
|
||||
anchor.GetAttribute("physics:jointEnabled").Set(True)
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "remain disabled"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_inactive_anchor_relationships_still_checked(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
|
||||
anchor.GetRelationship("physics:body1").SetTargets([])
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Relationship changed"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_complete_bundle_relocation_preserves_identity(self):
|
||||
before = self.build()
|
||||
moved = Path(self.temp.name) / "moved"
|
||||
shutil.copytree(self.bundle, moved)
|
||||
self.assertEqual(before, inspect_prepared(moved / "tracking.usda", self.manifest))
|
||||
layer = Sdf.Layer.FindOrOpen(str(self.output))
|
||||
self.assertEqual(layer.subLayerPaths, ["original/linkerhand_g20_left.usda"])
|
||||
|
||||
def test_stale_manifest_or_original_bytes_rejected(self):
|
||||
wrong = copy.deepcopy(self.manifest)
|
||||
wrong["asset_sha256"] = "0" * 64
|
||||
with self.assertRaisesRegex(ValueError, "Stale"):
|
||||
prepare(self.source, self.output, wrong)
|
||||
self.assertFalse(self.output.exists())
|
||||
self.build()
|
||||
with self.source.open("a") as stream:
|
||||
stream.write("\n# change identity\n")
|
||||
with self.assertRaisesRegex(ValueError, "Stale"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_altered_model_mapping_rejected(self):
|
||||
wrong = copy.deepcopy(self.manifest)
|
||||
wrong["source_urdf"]["mimic"][0]["multiplier"] = -0.89
|
||||
with self.assertRaisesRegex(ValueError, "Unexpected L20 mimic"):
|
||||
bind_source(self.source, wrong)
|
||||
|
||||
def test_fixed_preview_and_json_flag_cannot_enable(self):
|
||||
self.manifest["dynamic_replay_ready"] = True
|
||||
with self.assertRaises(ValueError):
|
||||
inspect_prepared(self.source, self.manifest)
|
||||
|
||||
def test_no_overwrite(self):
|
||||
self.build()
|
||||
before = self.output.read_bytes()
|
||||
with self.assertRaisesRegex(ValueError, "overwrite"):
|
||||
self.build()
|
||||
self.assertEqual(self.output.read_bytes(), before)
|
||||
|
||||
def edit_follower(self, callback):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
path = next(j["path"] for j in self.manifest["joints"] if j["name"] == "thumb_ip")
|
||||
callback(stage.GetPrimAtPath(path))
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaises(ValueError):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_sampled_mimic_rejected(self):
|
||||
self.edit_follower(lambda p: p.GetAttribute("newton:mimicCoef1").Set(-1.02, Usd.TimeCode(1)))
|
||||
|
||||
def test_sampled_allowlisted_follower_gain_rejected(self):
|
||||
self.edit_follower(lambda p: p.GetAttribute("drive:angular:physics:stiffness").Set(99, Usd.TimeCode(1)))
|
||||
|
||||
def test_sampled_mass_rejected(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
root = stage.GetPrimAtPath(self.manifest["root_body_path"])
|
||||
root.GetAttribute("physics:mass").Set(99, Usd.TimeCode(1))
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_sampled_transform_rejected(self):
|
||||
self.build()
|
||||
stage = Usd.Stage.Open(str(self.output))
|
||||
attr = next(
|
||||
a
|
||||
for p in stage.Traverse()
|
||||
for a in p.GetAttributes()
|
||||
if a.GetName().startswith("xformOp:") and a.Get() is not None
|
||||
)
|
||||
# Even a sample equal to the default is prohibited for this static asset.
|
||||
attr.Set(attr.Get(), Usd.TimeCode(1))
|
||||
stage.GetRootLayer().Save()
|
||||
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
|
||||
inspect_prepared(self.output, self.manifest)
|
||||
|
||||
def test_wrong_sign_rejected(self):
|
||||
self.edit_follower(lambda p: p.GetAttribute("newton:mimicCoef1").Set(-1.02))
|
||||
|
||||
def test_disabled_native_constraint_rejected(self):
|
||||
self.edit_follower(lambda p: p.CreateAttribute("newton:mimicEnabled", Sdf.ValueTypeNames.Bool).Set(False))
|
||||
|
||||
def test_duplicate_legacy_constraint_rejected(self):
|
||||
self.edit_follower(lambda p: p.AddAppliedSchema("PhysxMimicJointAPI:rotX"))
|
||||
|
||||
def test_follower_motor_rejected(self):
|
||||
self.edit_follower(lambda p: UsdPhysics.DriveAPI.Apply(p, "angular"))
|
||||
|
||||
def test_changed_mass_or_limits_rejected(self):
|
||||
self.edit_follower(lambda p: p.GetAttribute("physics:upperLimit").Set(180.0))
|
||||
|
||||
def test_newton_offset_degree_conversion(self):
|
||||
stage = Usd.Stage.CreateInMemory()
|
||||
prim = UsdPhysics.RevoluteJoint.Define(stage, "/follower").GetPrim()
|
||||
prim.AddAppliedSchema("NewtonMimicAPI")
|
||||
prim.CreateRelationship("newton:mimicJoint").SetTargets(["/leader"])
|
||||
prim.CreateAttribute("newton:mimicCoef0", Sdf.ValueTypeNames.Float).Set(90)
|
||||
prim.CreateAttribute("newton:mimicCoef1", Sdf.ValueTypeNames.Float).Set(-2)
|
||||
_, coefficient, offset = newton_mimic(prim)
|
||||
self.assertEqual(coefficient, -2)
|
||||
self.assertAlmostEqual(offset, 1.5707963267948966)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Regression of CLI's named range_4s HDF5 reference against the actual L20 manifest."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from dex_workbench_tracking.cli import synthetic
|
||||
from dex_workbench_tracking.control import Limits, validate_reference
|
||||
from dex_workbench_tracking.trajectory import ContractError, load, sample
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
MANIFEST = REPO / "assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json"
|
||||
|
||||
|
||||
class TrackingRangeProfileTests(unittest.TestCase):
|
||||
def test_default_small_profile_is_unchanged(self):
|
||||
manifest = json.loads(MANIFEST.read_text())
|
||||
implicit, explicit = synthetic(manifest), synthetic(manifest, "small")
|
||||
self.assertEqual(implicit.metadata, explicit.metadata)
|
||||
a, b = implicit.episodes["demo_000000"], explicit.episodes["demo_000000"]
|
||||
for field in ("time", "wrist_position", "wrist_quaternion", "joint_position", "valid"):
|
||||
np.testing.assert_array_equal(getattr(a, field), getattr(b, field))
|
||||
self.assertEqual(len(a.time), 121)
|
||||
self.assertEqual(a.time[-1], 2)
|
||||
self.assertAlmostEqual(a.wrist_position[:, 0].max(), 0.002)
|
||||
with self.assertRaises(ContractError):
|
||||
synthetic(manifest, "unknown")
|
||||
|
||||
def test_range_cli_publication_mapping_and_reference_limits(self):
|
||||
manifest = json.loads(MANIFEST.read_text())
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
output = Path(directory) / "range.hdf5"
|
||||
command = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"dex_workbench_tracking.cli",
|
||||
"synthetic",
|
||||
"--manifest",
|
||||
str(MANIFEST),
|
||||
"--profile",
|
||||
"range_4s",
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)["provenance"], "synthetic")
|
||||
data = load(output, manifest)
|
||||
self.assertEqual(data.metadata["asset_sha256"], manifest["asset_sha256"])
|
||||
self.assertEqual(data.metadata["provenance"], "synthetic")
|
||||
self.assertIn("range_4s", data.metadata["source_description"])
|
||||
self.assertIn("NOT expert", data.metadata["source_description"])
|
||||
names = list(data.joint_names)
|
||||
self.assertEqual(names, [j["name"] for j in manifest["joints"]])
|
||||
episode = data.episodes["demo_000000"]
|
||||
self.assertEqual(len(episode.time), 241)
|
||||
self.assertEqual(episode.time[-1], 4)
|
||||
self.assertAlmostEqual(episode.wrist_position[:, 0].max(), 0.01)
|
||||
self.assertAlmostEqual(2 * np.arccos(episode.wrist_quaternion[:, 0].min()), 0.1, places=5)
|
||||
followers = {eq["joint"] for eq in manifest["source_urdf"]["mimic"]}
|
||||
for j in manifest["joints"]:
|
||||
if j["name"] not in followers:
|
||||
self.assertAlmostEqual(
|
||||
episode.joint_position[:, names.index(j["name"])].max(), min(0.1, 0.25 * j["upper_rad"])
|
||||
)
|
||||
for eq in manifest["source_urdf"]["mimic"]:
|
||||
np.testing.assert_allclose(
|
||||
episode.joint_position[:, names.index(eq["joint"])],
|
||||
eq["multiplier"] * episode.joint_position[:, names.index(eq["reference"])],
|
||||
atol=1e-7,
|
||||
)
|
||||
for field in ("wrist_position", "wrist_quaternion", "joint_position"):
|
||||
np.testing.assert_allclose(getattr(episode, field)[0], getattr(episode, field)[-1], atol=1e-7)
|
||||
validate_reference(episode, Limits())
|
||||
resampled = sample(episode, np.arange(961) / 240)
|
||||
validate_reference(resampled, Limits())
|
||||
self.assertEqual(len(resampled.time), 961)
|
||||
before = output.read_bytes()
|
||||
refused = subprocess.run(command, capture_output=True, text=True, timeout=30)
|
||||
self.assertNotEqual(refused.returncode, 0)
|
||||
self.assertEqual(output.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Isolated CPU plugin-context regression; requires the installed Isaac USD schemas, not Kit."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from dex_workbench_tracking.prepared import inspect_prepared, prepare
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
ASSET = REPO / "assets/robots/dex_hand/linkerhand_g20_left"
|
||||
|
||||
|
||||
class TrackingSchemaContextTests(unittest.TestCase):
|
||||
def test_registered_schema_preparation_preserves_defaults_and_rejects_context_drift(self):
|
||||
installation = os.environ.get("ISAAC_PATH")
|
||||
if not installation:
|
||||
self.skipTest("ISAAC_PATH required for installed PhysX/Newton CPU schema regression")
|
||||
installation = Path(installation)
|
||||
physx = list(installation.glob("extscache/omni.usd.schema.physx-*/plugins/PhysxSchema/resources/plugInfo.json"))
|
||||
self.assertEqual(len(physx), 1, "Select an unambiguous installed PhysX schema for this test")
|
||||
newton = installation / "exts/omni.usd.schema.newton/usd/schema/newton/newton_usd_schemas/plugInfo.json"
|
||||
self.assertTrue(newton.is_file())
|
||||
manifest = json.loads((ASSET / "tracking_manifest.json").read_text())
|
||||
source = ASSET / "linkerhand_g20_left/linkerhand_g20_left.usda"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
directory = Path(directory)
|
||||
unregistered = directory / "unregistered.usda"
|
||||
registered = directory / "registered.usda"
|
||||
result = prepare(source, unregistered, manifest)
|
||||
self.assertEqual(result["status"], "STATIC_ONLY_SCHEMA_UNREGISTERED")
|
||||
self.assertEqual(result, inspect_prepared(unregistered, manifest))
|
||||
# Registering plugins in a child prevents pollution of other CPU tests.
|
||||
code = """
|
||||
import json, sys
|
||||
from pathlib import Path
|
||||
from pxr import Usd
|
||||
from dex_workbench_tracking.asset import dependencies
|
||||
from dex_workbench_tracking.prepared import inspect_prepared, register_schema_plugins
|
||||
source, manifest_path, unregistered, registered, *plugins = sys.argv[1:]
|
||||
manifest = json.loads(Path(manifest_path).read_text())
|
||||
register_schema_plugins(plugins)
|
||||
try:
|
||||
inspect_prepared(unregistered, manifest)
|
||||
except ValueError as error:
|
||||
assert "regenerate" in str(error), error
|
||||
else:
|
||||
raise AssertionError("Unregistered authoring accepted with registered schemas")
|
||||
before = dependencies(source)
|
||||
# Exercise the actual CLI registration option in a fresh CPU interpreter too.
|
||||
import subprocess
|
||||
command = [sys.executable, "-m", "dex_workbench_tracking.prepared", source,
|
||||
"--manifest", manifest_path, "--output", registered]
|
||||
for plugin in plugins:
|
||||
command += ["--schema-plugin-path", plugin]
|
||||
run = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
assert run.returncode == 0, run.stderr
|
||||
result = inspect_prepared(registered, manifest)
|
||||
assert result["status"] == "STATIC_ELIGIBLE_RUNTIME_UNVERIFIED"
|
||||
assert all(result["schema_context"].values())
|
||||
assert dependencies(source) == before
|
||||
original = Usd.Stage.Open(source)
|
||||
stage = Usd.Stage.Open(registered)
|
||||
anchor = manifest["world_fixed_joints"][0]["path"]
|
||||
root = stage.GetPrimAtPath(manifest["root_body_path"])
|
||||
for attr in original.GetPrimAtPath(anchor).GetAttributes():
|
||||
if attr.GetName().startswith("physxArticulation:"):
|
||||
for prim in (stage.GetPrimAtPath(anchor), root):
|
||||
after = prim.GetAttribute(attr.GetName())
|
||||
assert after.Get() == attr.Get(), attr.GetPath()
|
||||
assert after.HasAuthoredValue(), attr.GetPath()
|
||||
assert not after.GetTimeSamples(), attr.GetPath()
|
||||
# Registered schema fallbacks do not relax the static/time-sample gate.
|
||||
root.GetAttribute("physxArticulation:sleepThreshold").Set(99, Usd.TimeCode(1))
|
||||
stage.GetRootLayer().Save()
|
||||
try:
|
||||
inspect_prepared(registered, manifest)
|
||||
except ValueError as error:
|
||||
assert "Time samples prohibited" in str(error), error
|
||||
else:
|
||||
raise AssertionError("Sampled solver property accepted")
|
||||
root.GetAttribute("physxArticulation:sleepThreshold").ClearAtTime(Usd.TimeCode(1))
|
||||
stage.GetRootLayer().Save()
|
||||
inspect_prepared(registered, manifest)
|
||||
print("PASS: isolated registered schema author/inspect, exact default preservation, strict time samples")
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
code,
|
||||
str(source),
|
||||
str(ASSET / "tracking_manifest.json"),
|
||||
str(unregistered),
|
||||
str(registered),
|
||||
str(physx[0]),
|
||||
str(newton),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=90,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
# The inverse schema mismatch is actionable too; never pretend unknown
|
||||
# vendor fallbacks are equivalent in an unregistered process.
|
||||
with self.assertRaisesRegex(ValueError, "regenerate"):
|
||||
inspect_prepared(registered, manifest)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""CPU startup/fast-shutdown regressions for the two source-named Kit entries."""
|
||||
|
||||
import builtins
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import sys
|
||||
import types
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
|
||||
REPO = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
class TrackingStartupTests(unittest.TestCase):
|
||||
def check_entry(self, filename, inspection_module, inspection_name):
|
||||
events = []
|
||||
|
||||
class FakeApp:
|
||||
def close(self, *, exit_code):
|
||||
events.append(("close", exit_code))
|
||||
# Model Kit fast shutdown, which never returns to a pending raise.
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
class FakeLauncher:
|
||||
@staticmethod
|
||||
def add_app_launcher_args(parser):
|
||||
pass
|
||||
|
||||
def __init__(self, args):
|
||||
events.append("launch")
|
||||
self.app = FakeApp()
|
||||
|
||||
def reject_asset(*args):
|
||||
events.append("inspect")
|
||||
raise ValueError("deliberate asset rejection")
|
||||
|
||||
app_module = types.ModuleType("isaaclab.app")
|
||||
app_module.AppLauncher = FakeLauncher
|
||||
inspector = types.ModuleType(inspection_module)
|
||||
setattr(inspector, inspection_name, reject_asset)
|
||||
inspector.require_backend = inspector.validate_mimic = None
|
||||
original_import = builtins.__import__
|
||||
|
||||
def checked_import(name, *args, **kwargs):
|
||||
if name.startswith(("dex_workbench_tracking", "pxr")):
|
||||
self.assertIn("launch", events, f"USD-dependent import before AppLauncher: {name}")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
entry = REPO / "scripts/tracking" / filename
|
||||
spec = importlib.util.spec_from_file_location("tracking_entry_test", entry)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
asset = REPO / "assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json"
|
||||
argv = [str(entry), str(asset)]
|
||||
if filename == "track_l20.py":
|
||||
argv += ["--manifest", str(asset), "--execute-experimental"]
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
patch.dict(
|
||||
sys.modules,
|
||||
{
|
||||
"isaaclab.app": app_module,
|
||||
inspection_module: inspector,
|
||||
"numpy": np,
|
||||
"torch": types.ModuleType("torch"),
|
||||
},
|
||||
),
|
||||
patch.object(sys, "argv", argv),
|
||||
patch.object(builtins, "__import__", checked_import),
|
||||
contextlib.redirect_stderr(stderr),
|
||||
):
|
||||
with self.assertRaises(SystemExit) as error:
|
||||
module.main()
|
||||
self.assertEqual(error.exception.code, 1)
|
||||
self.assertEqual(events, ["launch", "inspect", ("close", 1)], stderr.getvalue())
|
||||
self.assertIn("ValueError: deliberate asset rejection", stderr.getvalue())
|
||||
|
||||
def test_track_l20_launches_before_inspection_and_preserves_failure(self):
|
||||
self.check_entry("track_l20.py", "dex_workbench_tracking.prepared", "inspect_prepared")
|
||||
|
||||
def test_inspect_l20_scene_launches_before_inspection_and_preserves_failure(self):
|
||||
self.check_entry("inspect_l20_scene.py", "dex_workbench_tracking.asset", "inspect")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,303 @@
|
||||
"""CPU regression tests for dex_workbench_tracking.trajectory and CLI; no Isaac imports."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import h5py
|
||||
import numpy as np
|
||||
from dex_workbench_tracking.cli import publish_validated, synthetic, write
|
||||
from dex_workbench_tracking.trajectory import ContractError, load, require_dynamic_replay_ready, sample
|
||||
|
||||
|
||||
class TrackingTrajectoryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.directory = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.directory.cleanup)
|
||||
self.path = Path(self.directory.name) / "fixture.hdf5"
|
||||
self.manifest = {
|
||||
"manifest_version": "l20_asset_manifest_v1",
|
||||
"asset_sha256": "a" * 64,
|
||||
"root_link": "test_root",
|
||||
"joints": [
|
||||
{"name": "finger_a", "lower_rad": -1, "upper_rad": 1},
|
||||
{"name": "finger_b", "lower_rad": -1, "upper_rad": 1},
|
||||
],
|
||||
"source_urdf": {
|
||||
"mimic": [{"joint": "finger_b", "reference": "finger_a", "multiplier": 0.5, "offset_rad": 0.0}]
|
||||
},
|
||||
}
|
||||
write(self.path, synthetic(self.manifest))
|
||||
|
||||
def edit(self, function):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
function(file)
|
||||
|
||||
def test_load_roundtrip(self):
|
||||
data = load(self.path, self.manifest)
|
||||
self.assertEqual(data.joint_names, ("finger_a", "finger_b"))
|
||||
self.assertEqual(data.metadata["provenance"], "synthetic")
|
||||
self.assertEqual(data.episodes["demo_000000"].joint_position.shape, (121, 2))
|
||||
|
||||
def test_identity_and_order_mismatch(self):
|
||||
for key, value in (("asset_sha256", "b" * 64), ("root_link", "other")):
|
||||
manifest = copy.deepcopy(self.manifest)
|
||||
manifest[key] = value
|
||||
with self.subTest(key=key), self.assertRaises(ContractError):
|
||||
load(self.path, manifest)
|
||||
manifest = copy.deepcopy(self.manifest)
|
||||
manifest["joints"].reverse()
|
||||
with self.assertRaisesRegex(ContractError, "order"):
|
||||
load(self.path, manifest)
|
||||
|
||||
def test_missing_attributes(self):
|
||||
for name in ("root_link", "provenance", "source_description", "scale_to_meters", "metric_scale_provenance"):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
value = file.attrs[name]
|
||||
del file.attrs[name]
|
||||
with self.subTest(name=name), self.assertRaises(ContractError):
|
||||
load(self.path)
|
||||
self.edit(lambda file: file.attrs.__setitem__(name, value))
|
||||
|
||||
def test_wrong_attributes(self):
|
||||
for name, bad in (
|
||||
("schema_version", "v2"),
|
||||
("embodiment", "L30"),
|
||||
("hand_side", "right"),
|
||||
("provenance", "unknown"),
|
||||
("asset_sha256", "xyz"),
|
||||
("root_link", ""),
|
||||
("scale_to_meters", 0.0),
|
||||
("scale_to_meters", float("nan")),
|
||||
("scale_to_meters", "1"),
|
||||
("scale_to_meters", [1.0]),
|
||||
):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
original = file.attrs[name]
|
||||
file.attrs[name] = bad
|
||||
with self.subTest(name=name, bad=bad), self.assertRaises(ContractError):
|
||||
load(self.path)
|
||||
self.edit(lambda file: file.attrs.__setitem__(name, original))
|
||||
|
||||
def test_duplicate_or_wrong_joint_strings(self):
|
||||
self.edit(lambda file: file["metadata/joint_names"].__setitem__(1, "finger_a"))
|
||||
with self.assertRaisesRegex(ContractError, "duplicate"):
|
||||
load(self.path)
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
del file["metadata/joint_names"]
|
||||
file["metadata"].create_dataset("joint_names", data=[b"a", b"b"])
|
||||
with self.assertRaisesRegex(ContractError, "UTF-8"):
|
||||
load(self.path)
|
||||
|
||||
def test_invalid_transform(self):
|
||||
for bad in (np.diag([2, 1, 1, 1]), np.diag([-1, 1, 1, 1]), np.zeros((4, 4))):
|
||||
self.edit(lambda file: file["metadata/world_from_source"].__setitem__(slice(None), bad))
|
||||
with self.assertRaisesRegex(ContractError, "world_from_source"):
|
||||
load(self.path)
|
||||
|
||||
def test_transform_is_not_applied_again(self):
|
||||
self.edit(lambda file: file["metadata/world_from_source"].__setitem__((0, 3), 10))
|
||||
data = load(self.path)
|
||||
self.assertEqual(data.world_from_source[0, 3], 10)
|
||||
self.assertEqual(data.episodes["demo_000000"].wrist_position[0, 0], 0)
|
||||
|
||||
def test_episode_name_and_empty_group(self):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
file.move("episodes/demo_000000", "episodes/demo_bad")
|
||||
with self.assertRaisesRegex(ContractError, "invalid episode"):
|
||||
load(self.path)
|
||||
self.edit(lambda file: file.__delitem__("episodes/demo_bad"))
|
||||
with self.assertRaisesRegex(ContractError, "nonempty"):
|
||||
load(self.path)
|
||||
|
||||
def test_shapes_and_dtypes(self):
|
||||
for name, value in (
|
||||
("time", np.arange(121, dtype=np.float32)),
|
||||
("wrist_position", np.zeros((121, 2), dtype=np.float32)),
|
||||
("joint_position", np.zeros((121, 2), dtype=np.float64)),
|
||||
("valid", np.ones(121, dtype=np.uint8)),
|
||||
):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
group = file["episodes/demo_000000"]
|
||||
original = group[name][...]
|
||||
del group[name]
|
||||
group.create_dataset(name, data=value)
|
||||
with self.subTest(name=name), self.assertRaises(ContractError):
|
||||
load(self.path)
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
group = file["episodes/demo_000000"]
|
||||
del group[name]
|
||||
group.create_dataset(name, data=original)
|
||||
|
||||
def test_bad_time(self):
|
||||
for index, value in ((0, -1), (1, 0), (2, 0.001), (1, np.nan)):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
time = file["episodes/demo_000000/time"]
|
||||
original = time[index]
|
||||
time[index] = value
|
||||
with self.subTest(index=index, value=value), self.assertRaises(ContractError):
|
||||
load(self.path)
|
||||
self.edit(lambda file: file["episodes/demo_000000/time"].__setitem__(index, original))
|
||||
|
||||
def test_nan_even_in_invalid_frame(self):
|
||||
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(2, False))
|
||||
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), np.nan))
|
||||
with self.assertRaisesRegex(ContractError, "NaN/Inf"):
|
||||
load(self.path)
|
||||
|
||||
def test_quaternion_normalization_and_sign(self):
|
||||
self.edit(lambda file: file["episodes/demo_000000/wrist_quaternion"].__setitem__(2, [2, 0, 0, 0]))
|
||||
with self.assertRaisesRegex(ContractError, "unit"):
|
||||
load(self.path)
|
||||
self.edit(lambda file: file["episodes/demo_000000/wrist_quaternion"].__setitem__(2, [-1, 0, 0, 0]))
|
||||
with self.assertRaisesRegex(ContractError, "sign"):
|
||||
load(self.path)
|
||||
|
||||
def test_all_invalid_rejected(self):
|
||||
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(slice(None), False))
|
||||
with self.assertRaisesRegex(ContractError, "no valid"):
|
||||
load(self.path)
|
||||
|
||||
def test_limits_and_mimic_inconsistency(self):
|
||||
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), 2))
|
||||
with self.assertRaisesRegex(ContractError, "limit violation"):
|
||||
load(self.path, self.manifest)
|
||||
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), 0.2))
|
||||
with self.assertRaisesRegex(ContractError, "mimic"):
|
||||
load(self.path, self.manifest)
|
||||
|
||||
def test_slerp_and_linear_interpolation(self):
|
||||
episode = load(self.path).episodes["demo_000000"]
|
||||
# Isolated two-frame analytic rotation with a 180-degree endpoint, wxyz.
|
||||
from dex_workbench_tracking.trajectory import Episode
|
||||
|
||||
simple = Episode(
|
||||
np.array([0.0, 1.0]),
|
||||
np.array([[0, 0, 0], [2, 4, 6]], dtype=np.float32),
|
||||
np.array([[1, 0, 0, 0], [0, 0, 0, 1]], dtype=np.float32),
|
||||
np.array([[0, 0], [1, 2]], dtype=np.float32),
|
||||
np.array([True, True]),
|
||||
)
|
||||
sampled = sample(simple, [0, 0.5, 1])
|
||||
np.testing.assert_allclose(sampled.wrist_quaternion[1], [np.sqrt(0.5), 0, 0, np.sqrt(0.5)], atol=1e-7)
|
||||
np.testing.assert_allclose(sampled.wrist_position[1], [1, 2, 3])
|
||||
np.testing.assert_allclose(sampled.joint_position[1], [0.5, 1])
|
||||
np.testing.assert_allclose(sample(episode, episode.time).joint_position, episode.joint_position)
|
||||
np.testing.assert_allclose(np.linalg.norm(sample(episode, [0, 0.001]).wrist_quaternion, axis=1), 1, atol=1e-7)
|
||||
|
||||
def test_no_extrapolation_or_bad_queries(self):
|
||||
episode = load(self.path).episodes["demo_000000"]
|
||||
for query in ([], [-0.1], [2.1], [0, 0], [1, 0], [np.nan], [[0.1]]):
|
||||
with self.subTest(query=query), self.assertRaises(ContractError):
|
||||
sample(episode, query)
|
||||
|
||||
def test_invalid_gap_never_bridged(self):
|
||||
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(60, False))
|
||||
episode = load(self.path).episodes["demo_000000"]
|
||||
for query in ([1], [0.99], [1.01], [0, 2]):
|
||||
with self.subTest(query=query), self.assertRaisesRegex(ContractError, "invalid"):
|
||||
sample(episode, query)
|
||||
self.assertEqual(len(sample(episode, [0, 0.5]).time), 2)
|
||||
self.assertEqual(len(sample(episode, [1.5, 2]).time), 2)
|
||||
|
||||
def test_no_overwrite(self):
|
||||
before = self.path.read_bytes()
|
||||
with self.assertRaises(OSError):
|
||||
write(self.path, synthetic(self.manifest))
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
|
||||
def test_validated_publication_failure_leaves_no_output(self):
|
||||
output = Path(self.directory.name) / "invalid.hdf5"
|
||||
data = synthetic(self.manifest)
|
||||
data.episodes["demo_000000"].wrist_quaternion[:] = 0
|
||||
with self.assertRaises(ContractError):
|
||||
publish_validated(output, data, self.manifest)
|
||||
self.assertFalse(output.exists())
|
||||
self.assertFalse(list(output.parent.glob(".l20-tracking-*")))
|
||||
|
||||
def test_validated_publication_preserves_existing_file(self):
|
||||
before = self.path.read_bytes()
|
||||
with self.assertRaises(FileExistsError):
|
||||
publish_validated(self.path, synthetic(self.manifest), self.manifest)
|
||||
self.assertEqual(self.path.read_bytes(), before)
|
||||
self.assertFalse(list(self.path.parent.glob(".l20-tracking-*")))
|
||||
|
||||
def test_cli_full_turn_downsampling_roundtrip(self):
|
||||
with h5py.File(self.path, "r+") as file:
|
||||
group = file["episodes/demo_000000"]
|
||||
time = group["time"][:]
|
||||
quaternion = np.zeros((len(time), 4), dtype=np.float32)
|
||||
quaternion[:, 0] = np.cos(np.pi * time / 2)
|
||||
quaternion[:, 3] = np.sin(np.pi * time / 2)
|
||||
group["wrist_quaternion"][:] = quaternion
|
||||
load(self.path, self.manifest)
|
||||
manifest = Path(self.directory.name) / "manifest.json"
|
||||
manifest.write_text(json.dumps(self.manifest))
|
||||
output = Path(self.directory.name) / "sparse.hdf5"
|
||||
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "0.5", "--output", output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
quaternion = load(output, self.manifest).episodes["demo_000000"].wrist_quaternion
|
||||
self.assertEqual(quaternion.shape, (2, 4))
|
||||
self.assertGreaterEqual(np.dot(quaternion[0], quaternion[1]), 0)
|
||||
np.testing.assert_allclose(quaternion[:, 0], 1, atol=1e-6)
|
||||
# Hemisphere repair cannot recover motion lost through undersampling.
|
||||
np.testing.assert_allclose(quaternion[:, 1:], 0, atol=1e-6)
|
||||
|
||||
def test_dynamic_gate_cannot_be_enabled_by_flag(self):
|
||||
for manifest in (self.manifest, {"dynamic_replay_ready": True, "coupling_status": "VERIFIED"}):
|
||||
with self.assertRaisesRegex(ContractError, "BLOCKED"):
|
||||
require_dynamic_replay_ready(manifest)
|
||||
|
||||
def run_cli(self, *args):
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "dex_workbench_tracking.cli", *map(str, args)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
|
||||
def test_cli_validate_and_resample(self):
|
||||
manifest = Path(self.directory.name) / "manifest.json"
|
||||
manifest.write_text(json.dumps(self.manifest))
|
||||
output = Path(self.directory.name) / "resampled.hdf5"
|
||||
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "100", "--output", output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
data = load(output, self.manifest)
|
||||
self.assertEqual(len(data.episodes["demo_000000"].time), 201)
|
||||
result = self.run_cli("validate", output)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(json.loads(result.stdout)["asset_compatibility"], "NOT_CHECKED")
|
||||
self.edit(lambda file: file.attrs.__setitem__("hand_side", "right"))
|
||||
result = self.run_cli("validate", self.path)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("FAIL", result.stderr)
|
||||
|
||||
def test_cli_gate_and_invalid_gap_fail_closed(self):
|
||||
manifest = Path(self.directory.name) / "manifest.json"
|
||||
manifest.write_text(json.dumps(self.manifest))
|
||||
result = self.run_cli("replay-check", "--manifest", manifest)
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("BLOCKED", result.stderr)
|
||||
output = Path(self.directory.name) / "resampled.hdf5"
|
||||
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(60, False))
|
||||
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "60", "--output", output)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertFalse(output.exists())
|
||||
|
||||
def test_cpu_import_does_not_register_isaac(self):
|
||||
code = (
|
||||
"import dex_workbench_tracking.trajectory, sys; "
|
||||
"assert not any(k.startswith(('isaac', 'omni', 'dex_workbench.')) for k in sys.modules)"
|
||||
)
|
||||
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user