diff --git a/HDF5_REQUIREMENTS.md b/HDF5_REQUIREMENTS.md new file mode 100644 index 0000000..c22a412 --- /dev/null +++ b/HDF5_REQUIREMENTS.md @@ -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。 diff --git a/L20_TRACKING.md b/L20_TRACKING.md new file mode 100644 index 0000000..85c141e --- /dev/null +++ b/L20_TRACKING.md @@ -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;本次独立审查仍由父会话执行。 diff --git a/assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json b/assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json new file mode 100644 index 0000000..c2470d8 --- /dev/null +++ b/assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json @@ -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." + ] +} diff --git a/scripts/tracking/inspect_l20_scene.py b/scripts/tracking/inspect_l20_scene.py new file mode 100644 index 0000000..fcceb82 --- /dev/null +++ b/scripts/tracking/inspect_l20_scene.py @@ -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() diff --git a/scripts/tracking/track_l20.py b/scripts/tracking/track_l20.py new file mode 100644 index 0000000..363638e --- /dev/null +++ b/scripts/tracking/track_l20.py @@ -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() diff --git a/source/dex_workbench/config/extension.toml b/source/dex_workbench/config/extension.toml index c591466..b1e1f52 100644 --- a/source/dex_workbench/config/extension.toml +++ b/source/dex_workbench/config/extension.toml @@ -1,7 +1,7 @@ [package] # Semantic Versioning is used: https://semver.org/ -version = "0.1.0" +version = "0.1.1" # Description category = "isaaclab" diff --git a/source/dex_workbench/dex_workbench_tracking/asset.py b/source/dex_workbench/dex_workbench_tracking/asset.py new file mode 100644 index 0000000..bd708d4 --- /dev/null +++ b/source/dex_workbench/dex_workbench_tracking/asset.py @@ -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() diff --git a/source/dex_workbench/dex_workbench_tracking/cli.py b/source/dex_workbench/dex_workbench_tracking/cli.py new file mode 100644 index 0000000..64c3ce7 --- /dev/null +++ b/source/dex_workbench/dex_workbench_tracking/cli.py @@ -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() diff --git a/source/dex_workbench/dex_workbench_tracking/control.py b/source/dex_workbench/dex_workbench_tracking/control.py new file mode 100644 index 0000000..803a778 --- /dev/null +++ b/source/dex_workbench/dex_workbench_tracking/control.py @@ -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", + ) diff --git a/source/dex_workbench/dex_workbench_tracking/prepared.py b/source/dex_workbench/dex_workbench_tracking/prepared.py new file mode 100644 index 0000000..9256d90 --- /dev/null +++ b/source/dex_workbench/dex_workbench_tracking/prepared.py @@ -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() diff --git a/source/dex_workbench/dex_workbench_tracking/trajectory.py b/source/dex_workbench/dex_workbench_tracking/trajectory.py new file mode 100644 index 0000000..9c676df --- /dev/null +++ b/source/dex_workbench/dex_workbench_tracking/trajectory.py @@ -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." + ) diff --git a/source/dex_workbench/docs/CHANGELOG.rst b/source/dex_workbench/docs/CHANGELOG.rst index 3e3da67..463ac55 100644 --- a/source/dex_workbench/docs/CHANGELOG.rst +++ b/source/dex_workbench/docs/CHANGELOG.rst @@ -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. diff --git a/source/dex_workbench/setup.py b/source/dex_workbench/setup.py index 182332e..074eb2b 100644 --- a/source/dex_workbench/setup.py +++ b/source/dex_workbench/setup.py @@ -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, -) \ No newline at end of file +) diff --git a/source/dex_workbench/tests/test_tracking_asset.py b/source/dex_workbench/tests/test_tracking_asset.py new file mode 100644 index 0000000..7e8f2a8 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_asset.py @@ -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( + '' + '' + '' + ) + 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( + '' + '' + '' + '' + ) + 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() diff --git a/source/dex_workbench/tests/test_tracking_control.py b/source/dex_workbench/tests/test_tracking_control.py new file mode 100644 index 0000000..22f1900 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_control.py @@ -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() diff --git a/source/dex_workbench/tests/test_tracking_prepared.py b/source/dex_workbench/tests/test_tracking_prepared.py new file mode 100644 index 0000000..048aad9 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_prepared.py @@ -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() diff --git a/source/dex_workbench/tests/test_tracking_range_profile.py b/source/dex_workbench/tests/test_tracking_range_profile.py new file mode 100644 index 0000000..abc01b7 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_range_profile.py @@ -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() diff --git a/source/dex_workbench/tests/test_tracking_schema_context.py b/source/dex_workbench/tests/test_tracking_schema_context.py new file mode 100644 index 0000000..2fbd747 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_schema_context.py @@ -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() diff --git a/source/dex_workbench/tests/test_tracking_startup.py b/source/dex_workbench/tests/test_tracking_startup.py new file mode 100644 index 0000000..23c5f03 --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_startup.py @@ -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() diff --git a/source/dex_workbench/tests/test_tracking_trajectory.py b/source/dex_workbench/tests/test_tracking_trajectory.py new file mode 100644 index 0000000..e4684ed --- /dev/null +++ b/source/dex_workbench/tests/test_tracking_trajectory.py @@ -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()