chore(release): v0.1.1 USD文件初步校验

原因:记录 L20 USD 初步校验、严格浮动覆盖层、受限动力学回放及 HDF5 交付契约,包版本更新为 0.1.1。

验证:68 项 CPU/USD 回归测试通过,Ruff/format 与暂存 diff 检查通过;已核验单环境 small 2x480、合成 HDF5 2x960 步明确 PASS。独立暂存审查未发现问题。完整 pre-commit 因模块缺失未执行,干净环境安装未验证。

兼容性:Cartpole 及任务 ID 不变,原始 USD/URDF 未改;旧 prepared 覆盖层需重新生成。仅合成轨迹初步校验,不代表真实专家回放、训练或硬件验收。
This commit is contained in:
2026-09-11 15:12:39 +08:00
parent 071a1bdff5
commit 057f4c2cf6
20 changed files with 4086 additions and 3 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
# Semantic Versioning is used: https://semver.org/
version = "0.1.0"
version = "0.1.1"
# Description
category = "isaaclab"
@@ -0,0 +1,338 @@
"""Composed USD inspection and non-destructive, topology-only floating overlay.
Requires pxr, not a running Kit application. No physical coupling is synthesized.
"""
import hashlib
import json
import math
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics, UsdUtils
def _require(condition, message):
if not condition:
raise ValueError(message)
def _sha(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def _plain(value):
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
return value if math.isfinite(value) else str(value)
try:
return [_plain(item) for item in value]
except TypeError:
return str(value)
def dependencies(source):
"""Hash the local complete dependency bundle, including names relative to the entry.
Sorted UTF-8 records are ``relative_posix_path + NUL + sha256 + LF``.
Renaming dependency files changes identity. Moving the entire bundle does not.
"""
source = Path(source).resolve(strict=True)
layers, assets, unresolved = UsdUtils.ComputeAllDependencies(str(source))
_require(not unresolved, f"Unresolved dependencies: {unresolved}")
paths = {Path(layer.realPath).resolve(strict=True) for layer in layers}
paths.update(Path(asset).resolve(strict=True) for asset in assets)
paths.add(source)
entries = sorted((os.path.relpath(path, source.parent).replace(os.sep, "/"), _sha(path)) for path in paths)
digest = hashlib.sha256()
for name, sha in entries:
digest.update(f"{name}\0{sha}\n".encode())
return [{"path": name, "sha256": sha} for name, sha in entries], digest.hexdigest()
def inspect(source, source_urdf=None):
source = Path(source).resolve(strict=True)
deps, bundle_sha = dependencies(source)
stage = Usd.Stage.Open(str(source))
_require(stage and stage.GetDefaultPrim(), "USD must have a default prim")
_require(not stage.GetCompositionErrors(), f"Composition errors: {stage.GetCompositionErrors()}")
_require(
UsdGeom.GetStageUpAxis(stage) == "Z"
and UsdGeom.GetStageMetersPerUnit(stage) == 1
and UsdPhysics.GetStageKilogramsPerUnit(stage) == 1,
"Expected Z-up, meter, kilogram asset",
)
default = stage.GetDefaultPrim()
prims = list(Usd.PrimRange(default))
bodies = [prim for prim in prims if prim.HasAPI(UsdPhysics.RigidBodyAPI)]
body_paths = {str(prim.GetPath()) for prim in bodies}
joints, world_joints, child_paths = [], [], set()
coupling_evidence = []
for prim in prims:
for prop in prim.GetProperties():
if any(term in prop.GetName().lower() for term in ("mimic", "tendon", "gearing", "coupling")):
coupling_evidence.append(str(prop.GetPath()))
# Raw schemas retain evidence even if a PhysX schema plugin is not loaded.
schemas = str(prim.GetMetadata("apiSchemas"))
if any(term in schemas.lower() for term in ("mimic", "tendon", "gearing", "coupling")):
coupling_evidence.append(f"{prim.GetPath()}: {schemas}")
if not prim.IsA(UsdPhysics.Joint):
continue
joint = UsdPhysics.Joint(prim)
if joint.GetJointEnabledAttr().Get() is False:
continue
b0 = [str(p) for p in joint.GetBody0Rel().GetTargets()]
b1 = [str(p) for p in joint.GetBody1Rel().GetTargets()]
_require(len(b1) == 1 and b1[0] in body_paths, f"Unresolved/non-body body1: {prim.GetPath()}")
if not b0:
_require(prim.IsA(UsdPhysics.FixedJoint), "Unsupported non-fixed world joint")
world_joints.append({"path": str(prim.GetPath()), "body1": b1[0]})
continue
_require(len(b0) == 1 and b0[0] in body_paths, f"Unresolved/non-body body0: {prim.GetPath()}")
_require(prim.IsA(UsdPhysics.RevoluteJoint), f"Unsupported joint type {prim.GetTypeName()}")
child_paths.add(b1[0])
revolute = UsdPhysics.RevoluteJoint(prim)
lower, upper = revolute.GetLowerLimitAttr().Get(), revolute.GetUpperLimitAttr().Get()
_require(
lower is not None
and upper is not None
and math.isfinite(lower)
and math.isfinite(upper)
and lower <= upper,
f"Missing/nonfinite limits: {prim.GetPath()}",
)
joints.append(
{
"name": prim.GetName(),
"path": str(prim.GetPath()),
"body0": b0[0],
"body1": b1[0],
"axis": revolute.GetAxisAttr().Get(),
"lower_rad": math.radians(lower),
"upper_rad": math.radians(upper),
"authored_drive_properties_usd_units": {
attr.GetName(): _plain(attr.Get())
for attr in prim.GetAttributes()
if attr.GetName().startswith(("drive:", "physxJoint:")) and attr.HasAuthoredValue()
},
}
)
_require(bodies and joints, "No articulated hand found")
roots = sorted(body_paths - child_paths)
_require(len(roots) == 1, f"Expected one body-tree root, got {roots}")
_require(len(joints) == len(bodies) - 1 and len(child_paths) == len(joints), "Not a simple articulated tree")
reachable = {roots[0]}
while True:
expanded = reachable | {joint["body1"] for joint in joints if joint["body0"] in reachable}
if expanded == reachable:
break
reachable = expanded
_require(reachable == body_paths, "Disconnected/cyclic body graph")
joints.sort(key=lambda joint: joint["name"])
names = [joint["name"] for joint in joints]
_require(len(set(names)) == len(names), "Duplicate joint names")
articulation_roots = [str(p.GetPath()) for p in prims if p.HasAPI(UsdPhysics.ArticulationRootAPI)]
_require(len(articulation_roots) == 1, f"Expected one articulation root, got {articulation_roots}")
_require(len(world_joints) <= 1 and all(j["body1"] == roots[0] for j in world_joints), "Ambiguous world anchor")
source_info = {"status": "NOT_PROVIDED", "mimic": []}
if source_urdf is not None:
path = Path(source_urdf).resolve(strict=True)
xml = ET.parse(path).getroot()
urdf_joints = {j.attrib["name"]: j for j in xml.findall("joint") if j.attrib["type"] != "fixed"}
_require(set(urdf_joints) == set(names), "Source URDF/USD joint-name mismatch")
mimic = []
for joint in joints:
node = urdf_joints[joint["name"]]
limit = node.find("limit")
_require(node.attrib["type"] == "revolute" and limit is not None, "Unsupported source URDF joint")
for key in ("lower", "upper"):
_require(
abs(float(limit.attrib[key]) - joint[f"{key}_rad"]) < 1e-5,
f"Source URDF/USD limit mismatch: {joint['name']}",
)
parent, child = node.find("parent"), node.find("child")
_require(
parent is not None
and child is not None
and parent.attrib["link"] == joint["body0"].split("/")[-1]
and child.attrib["link"] == joint["body1"].split("/")[-1],
"Source URDF/USD link mismatch",
)
equation = node.find("mimic")
if equation is not None:
_require(equation.attrib["joint"] in names, "Unresolved source mimic reference")
multiplier = float(equation.attrib.get("multiplier", "1"))
offset = float(equation.attrib.get("offset", "0"))
_require(math.isfinite(multiplier) and math.isfinite(offset), "Nonfinite mimic equation")
mimic.append(
{
"joint": joint["name"],
"reference": equation.attrib["joint"],
"multiplier": multiplier,
"offset_rad": offset,
}
)
source_info = {"status": "STRUCTURAL_MATCH_ONLY", "file_name": path.name, "sha256": _sha(path), "mimic": mimic}
coupling_status = "UNVERIFIED"
physx_coupling = [
item
for item in coupling_evidence
if "physxmimic" in item.lower() or "physxtendon" in item.lower() or ".physxmimic" in item.lower()
]
newton_coupling = [item for item in coupling_evidence if "newtonmimic" in item.lower()]
if newton_coupling or physx_coupling:
# PhysX 110.1.13 parses NewtonMimicAPI natively. Prefix is not a backend gate.
coupling_status = "MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED"
elif source_info["mimic"]:
coupling_status = "MISSING_MIMIC_SCHEMA"
return {
"manifest_version": "l20_asset_manifest_v1",
"asset_sha256": bundle_sha,
"entry_file": source.name,
"dependencies": deps,
"composition_errors": [],
"unresolved_dependencies": [],
"default_prim": str(default.GetPath()),
"root_link": roots[0].split("/")[-1],
"root_body_path": roots[0],
"units": {"length": "m", "mass": "kg", "up_axis": "Z"},
"physics_variant": default.GetVariantSet("Physics").GetVariantSelection(),
"articulation_roots": articulation_roots,
"world_fixed_joints": world_joints,
"bodies": [
{
"path": str(p.GetPath()),
"reset_xform_stack": UsdGeom.Xformable(p).GetResetXformStack(),
"mass_properties": {
a.GetName(): _plain(a.Get())
for a in p.GetAttributes()
if a.GetName()
in ("physics:mass", "physics:centerOfMass", "physics:diagonalInertia", "physics:principalAxes")
},
}
for p in bodies
],
"joints": joints,
"source_urdf": source_info,
"coupling_evidence": coupling_evidence,
"physx_coupling_evidence": physx_coupling,
"coupling_status": coupling_status,
"dynamic_replay_ready": False,
"blockers": [
"Runtime mimic response is unverified; original follower drives need suppression for diagnostics.",
"No calibrated wrist support controller or whole-hand dynamic replay validation.",
],
}
def prepare_floating_overlay(source, output):
"""Release only the world anchor; preserve transforms and source layer bytes.
This is NOT a usable control asset: missing coupling remains missing. Output
is explicitly marked diagnostic and must never overwrite an existing file.
"""
source, output = Path(source).resolve(strict=True), Path(output).resolve()
_require(not output.exists(), "Refusing to overwrite output")
before = inspect(source)
_require(before["physics_variant"] == "physx", "Only the current PhysX variant is supported")
anchors = before["world_fixed_joints"]
_require(
len(anchors) == 1 and before["articulation_roots"] == [anchors[0]["path"]],
"Expected a fixed-world articulation root; will not guess topology",
)
original = Usd.Stage.Open(str(source))
stage = Usd.Stage.CreateInMemory()
stage.GetRootLayer().subLayerPaths = [str(source)]
stage.SetDefaultPrim(stage.GetPrimAtPath(before["default_prim"]))
UsdGeom.SetStageUpAxis(stage, "Z")
UsdGeom.SetStageMetersPerUnit(stage, 1)
UsdPhysics.SetStageKilogramsPerUnit(stage, 1)
anchor = stage.GetPrimAtPath(anchors[0]["path"])
# Snapshot resolved schema fallbacks before removing the API. Preserve them
# explicitly on the inert anchor and the new root, not just authored values.
articulation_values = []
for attr in anchor.GetAttributes():
if attr.GetName().startswith("physxArticulation:") and attr.Get() is not None:
_require(not attr.GetTimeSamples(), f"Time samples prohibited: {attr.GetPath()}")
articulation_values.append((attr.GetName(), attr.GetTypeName(), attr.Get()))
anchor.RemoveAPI(UsdPhysics.ArticulationRootAPI)
anchor.RemoveAppliedSchema("PhysxArticulationAPI")
UsdPhysics.Joint(anchor).GetJointEnabledAttr().Set(False)
root = stage.GetPrimAtPath(before["root_body_path"])
UsdPhysics.ArticulationRootAPI.Apply(root)
root.AddAppliedSchema("PhysxArticulationAPI")
for name, dtype, value in articulation_values:
anchor.CreateAttribute(name, dtype).Set(value)
root.CreateAttribute(name, dtype).Set(value)
stage.GetRootLayer().customLayerData = {"purpose": "L20 topology-only diagnostic; dynamic replay BLOCKED"}
cache0, cache1 = UsdGeom.XformCache(), UsdGeom.XformCache()
for prim in original.Traverse():
after = stage.GetPrimAtPath(prim.GetPath())
for attr in prim.GetAttributes():
if str(prim.GetPath()) == anchors[0]["path"] and attr.GetName() == "physics:jointEnabled":
continue
_require(
attr.Get() == after.GetAttribute(attr.GetName()).Get(), f"Unexpected attribute edit: {attr.GetPath()}"
)
for rel in prim.GetRelationships():
_require(
rel.GetTargets() == after.GetRelationship(rel.GetName()).GetTargets(), "Unexpected relationship edit"
)
if prim.IsA(UsdGeom.Xformable):
_require(
Gf.IsClose(cache0.GetLocalToWorldTransform(prim), cache1.GetLocalToWorldTransform(after), 1e-12),
f"Unexpected transform edit: {prim.GetPath()}",
)
output.parent.mkdir(parents=True, exist_ok=True)
layer = Sdf.Layer.CreateAnonymous()
layer.TransferContent(stage.GetRootLayer())
layer.subLayerPaths = [os.path.relpath(source, output.parent).replace(os.sep, "/")]
_require(layer.Export(str(output)), "USD export failed")
after = inspect(output)
_require(
not after["world_fixed_joints"] and after["articulation_roots"] == [before["root_body_path"]],
"Floating root topology check failed",
)
_require(dependencies(source)[1] == before["asset_sha256"], "Source bundle unexpectedly changed")
return after
def main():
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("asset", type=Path)
parser.add_argument("--source-urdf", type=Path, help="Optional local original; inspected, never modified")
parser.add_argument("--output", type=Path, required=True, help="New JSON manifest; no overwrite")
parser.add_argument("--floating-overlay", type=Path, help="New topology-only USD layer; no overwrite")
args = parser.parse_args()
try:
_require(not args.output.exists(), "Refusing to overwrite manifest")
manifest = inspect(args.asset, args.source_urdf)
if args.floating_overlay:
prepare_floating_overlay(args.asset, args.floating_overlay)
manifest = inspect(args.floating_overlay, args.source_urdf)
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("x", encoding="utf-8") as stream:
json.dump(manifest, stream, indent=2, allow_nan=False)
stream.write("\n")
print(
json.dumps(
{
"status": "PASS",
"check": "static_asset_inspection_only",
"manifest": str(args.output),
"joints": len(manifest["joints"]),
"coupling_status": manifest["coupling_status"],
"dynamic_replay": "BLOCKED",
}
)
)
except (ValueError, OSError, RuntimeError, ET.ParseError) as error:
parser.exit(1, f"FAIL: {error}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,181 @@
"""CPU preparation CLI: validate, synthesize test references, or resample HDF5."""
import argparse
import json
import os
import tempfile
from pathlib import Path
import h5py
import numpy as np
from .trajectory import (
ContractError,
Demonstrations,
Episode,
load,
require,
require_dynamic_replay_ready,
sample,
validate_against_manifest,
)
def write(path, data):
"""Never overwrite inputs, original demonstrations, or an earlier generated file."""
path = Path(path)
with h5py.File(path, "x") as file:
for name, value in data.metadata.items():
file.attrs[name] = value
meta = file.create_group("metadata")
meta.create_dataset("joint_names", data=data.joint_names, dtype=h5py.string_dtype("utf-8"))
meta.create_dataset("world_from_source", data=data.world_from_source, dtype="float64")
episodes = file.create_group("episodes")
for name, episode in data.episodes.items():
group = episodes.create_group(name)
for field in ("time", "wrist_position", "wrist_quaternion", "joint_position", "valid"):
group.create_dataset(field, data=getattr(episode, field))
def publish_validated(path, data, manifest):
"""Validate privately, then publish without replacing any existing destination."""
path = Path(path)
with tempfile.TemporaryDirectory(prefix=".l20-tracking-", dir=path.parent) as directory:
temporary = Path(directory) / "validated.hdf5"
write(temporary, data)
load(temporary, manifest)
# Same-filesystem hard link publishes atomically and refuses overwrites.
os.link(temporary, path)
def synthetic(manifest, profile="small"):
"""Named analytic fixtures, not experts. ``range_4s`` has smooth out-and-back endpoints."""
require(profile in ("small", "range_4s"), "Unknown synthetic profile")
larger = profile == "range_4s"
duration = 4 if larger else 2
translation, yaw = (0.01, 0.1) if larger else (0.002, 0.01)
joints = manifest["joints"]
time = np.linspace(0, duration, duration * 60 + 1, dtype=np.float64)
phase = np.sin(np.pi * time / duration) ** 2
position = np.zeros((len(time), 3), dtype=np.float32)
position[:, 0] = translation * phase
position[:, 2] = 0.4
quaternion = np.zeros((len(time), 4), dtype=np.float32)
quaternion[:, 0] = np.cos(yaw / 2 * phase)
quaternion[:, 3] = np.sin(yaw / 2 * phase)
names = tuple(joint["name"] for joint in joints)
q = np.zeros((len(time), len(names)), dtype=np.float32)
for index, joint in enumerate(joints):
require(joint["lower_rad"] <= 0 <= joint["upper_rad"], "Synthetic fixture requires zero within limits")
amplitude = min(0.1, joint["upper_rad"] * 0.25) if larger else min(0.01, joint["upper_rad"] * 0.01)
q[:, index] = phase * amplitude
equations = {entry["joint"]: entry for entry in manifest.get("source_urdf", {}).get("mimic", [])}
resolved = set(names) - equations.keys()
while equations:
ready = [name for name, eq in equations.items() if eq["reference"] in resolved]
require(ready, "Cyclic/unresolved mimic graph")
for name in ready:
eq = equations.pop(name)
q[:, names.index(name)] = eq["multiplier"] * q[:, names.index(eq["reference"])] + eq["offset_rad"]
resolved.add(name)
metadata = {
"schema_version": "l20_tracking_v1",
"embodiment": "L20",
"hand_side": "left",
"asset_sha256": manifest["asset_sha256"],
"root_link": manifest["root_link"],
"metric_scale_provenance": "Analytic SI fixture, not video reconstruction or measurement",
"scale_to_meters": 1.0,
"provenance": "synthetic",
"source_description": (
"Synthetic range_4s: 4 s sin^2 out-and-back, 10 mm translation, 0.1 rad yaw; "
"independent positive joint amplitude min(0.1 rad, 25% upper limit), followers obey source mimic; "
"60 Hz, analytic zero endpoint velocities; NOT expert data"
if larger
else "Deterministic analytic reference: 2 mm translation, 0.01 rad yaw, small joint motion; NOT expert data"
),
}
data = Demonstrations(
metadata,
names,
np.eye(4, dtype=np.float64),
{
"demo_000000": Episode(time, position, quaternion, q, np.ones(len(time), dtype=bool)),
},
)
validate_against_manifest(data, manifest)
return data
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
validate = commands.add_parser("validate")
validate.add_argument("input", type=Path)
validate.add_argument(
"--manifest", type=Path, help="Required to check asset identity/order/limits; otherwise schema only"
)
generate = commands.add_parser("synthetic")
generate.add_argument("--manifest", required=True, type=Path)
generate.add_argument("--output", required=True, type=Path)
generate.add_argument("--profile", choices=("small", "range_4s"), default="small")
resample = commands.add_parser("resample")
resample.add_argument("input", type=Path)
resample.add_argument("--manifest", required=True, type=Path)
resample.add_argument("--hz", required=True, type=float)
resample.add_argument("--output", required=True, type=Path)
gate = commands.add_parser("replay-check", help="Fails closed until a physical tracking Adapter is implemented")
gate.add_argument("--manifest", required=True, type=Path)
args = parser.parse_args()
try:
manifest = json.loads(args.manifest.read_text(encoding="utf-8")) if args.manifest else None
if args.command == "replay-check":
try:
require_dynamic_replay_ready(manifest)
except ContractError as error:
parser.exit(2, f"{error}\n")
if args.command == "synthetic":
data = synthetic(manifest, args.profile)
publish_validated(args.output, data, manifest)
else:
data = load(args.input, manifest)
if args.command == "resample":
require(np.isfinite(args.hz) and 0 < args.hz <= 1000, "hz must be finite and in (0, 1000]")
episodes = {}
for name, episode in data.episodes.items():
require(
episode.valid.all(),
f"{name}: CLI resampling requires a fully valid episode; segment explicitly first",
)
count = int(np.floor(episode.time[-1] * args.hz)) + 1
require(2 <= count <= 10_000_000, "Resampled frame count must be between 2 and 10 million")
query = np.arange(count, dtype=np.float64) / args.hz
# Endpoint is included only when on-grid; never append a shortened final interval.
query = query[query <= episode.time[-1]]
episodes[name] = sample(episode, query)
data = Demonstrations(dict(data.metadata), data.joint_names, data.world_from_source, episodes)
data.metadata["source_description"] += f"; resampled at {args.hz:g} Hz (off-grid end omitted)"
publish_validated(args.output, data, manifest)
print(
json.dumps(
{
"status": "PASS",
"command": args.command,
"schema": data.metadata["schema_version"],
"provenance": data.metadata["provenance"],
"asset_compatibility": "PASS" if manifest else "NOT_CHECKED",
"episodes": {
name: {"frames": len(ep.time), "valid_frames": int(ep.valid.sum())}
for name, ep in data.episodes.items()
},
"joint_count": len(data.joint_names),
"dynamic_replay": "BLOCKED",
}
)
)
except (ContractError, OSError, KeyError, TypeError, ValueError) as error:
parser.exit(1, f"FAIL: {error}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,112 @@
"""Single-hand experimental world-frame wrench PD. SI units; parameters UNCALIBRATED.
The controller tracks a root *link* pose, applies one bounded wrench at root COM,
and compensates gravity for all links. No state writing, dynamics or hardware claims.
"""
from dataclasses import dataclass
import numpy as np
from .trajectory import require
@dataclass(frozen=True)
class Limits:
position_gain: float = 100.0 # N/m
velocity_gain: float = 10.0 # N s/m
rotation_gain: float = 0.2 # Nm/rad
angular_velocity_gain: float = 0.02 # Nm s/rad
force: float = 20.0 # norm N, including gravity
torque: float = 1.0 # norm Nm, including gravity moment
reference_speed: float = 0.05 # m/s
reference_angular_speed: float = 0.5 # rad/s
reference_joint_speed: float = 0.5 # rad/s
workspace_radius: float = 0.1 # m from initial root link position
finger_stiffness: float = 3.0 # Nm/rad; model-independent joints only
finger_damping: float = 0.1 # Nm s/rad
finger_effort: float = 0.2 # Nm
finger_velocity: float = 0.5 # rad/s
def __post_init__(self):
require(all(np.isfinite(v) and v > 0 for v in vars(self).values()), "Controller limits must be finite positive")
def reference_pose_to_xyzw(position, quaternion):
"""HDF5 world link position + wxyz -> installed Isaac Lab world link pose."""
position, quaternion = np.asarray(position), np.asarray(quaternion)
require(position.shape == (3,) and quaternion.shape == (4,), "Expected position[3], quaternion[4]")
return np.r_[position, quaternion[1:], quaternion[0]]
def xyzw_pose_to_reference(pose):
"""Installed Isaac Lab world link pose -> controller position + wxyz."""
pose = np.asarray(pose)
require(pose.shape == (7,), "Expected runtime pose[7]")
return np.r_[pose[:3], pose[6], pose[3:6]]
def rotation_error(target, current):
"""Shortest world-frame rotation vector taking current to target, both wxyz."""
target, current = np.asarray(target, dtype=float), np.asarray(current, dtype=float)
require(target.shape == current.shape == (4,), "quaternion[4] required")
for q in (target, current):
require(np.isfinite(q).all() and abs(np.linalg.norm(q) - 1) < 1e-4, "unit quaternion required")
w0, v0 = target[0], target[1:]
w1, v1 = current[0], -current[1:]
error = np.r_[w0 * w1 - np.dot(v0, v1), w0 * v1 + w1 * v0 + np.cross(v0, v1)]
if error[0] < 0:
error = -error
norm = np.linalg.norm(error[1:])
return error[1:] * (2 * np.arctan2(norm, max(0, error[0])) / norm if norm > 1e-12 else 2)
def bounded(vector, maximum):
return vector * min(1.0, maximum / max(np.linalg.norm(vector), 1e-12))
def wrench(target_position, target_quaternion, link_pose, link_velocity, root_com, body_com, masses, limits):
"""All vectors are world-frame; poses use wxyz. Output acts at root COM.
A link-origin PD force F needs (p_link-p_rootCOM) x F when applied at COM.
Gravity compensation is sum(-m*g) and sum((p_bodyCOM-p_rootCOM) x (-m*g)).
Both complete wrench vectors are norm-clamped; no unclamped gravity feedforward.
"""
inputs = (target_position, target_quaternion, link_pose, link_velocity, root_com, body_com, masses)
require(all(np.isfinite(v).all() for v in inputs), "Nonfinite controller state")
require(np.asarray(link_pose).shape == (7,) and np.asarray(link_velocity).shape == (6,), "Invalid root state shape")
require(np.asarray(body_com).shape == (len(masses), 3) and np.asarray(root_com).shape == (3,), "Invalid COM shape")
require(np.asarray(target_position).shape == (3,) and (np.asarray(masses) > 0).all(), "Invalid target/mass")
pd_force = limits.position_gain * (target_position - link_pose[:3]) - limits.velocity_gain * link_velocity[:3]
pd_torque = limits.rotation_gain * rotation_error(target_quaternion, link_pose[3:])
pd_torque -= limits.angular_velocity_gain * link_velocity[3:]
compensation = np.asarray(masses)[:, None] * np.array([0.0, 0.0, 9.81])
force = pd_force + compensation.sum(axis=0)
torque = pd_torque + np.cross(link_pose[:3] - root_com, pd_force)
torque += np.cross(body_com - root_com, compensation).sum(axis=0)
return bounded(force, limits.force), bounded(torque, limits.torque)
def validate_reference(episode, limits):
"""Fail rather than silently clip reference speed/workspace or bridge invalid frames."""
require(episode.valid.all(), "Dynamic diagnostic needs one fully valid episode")
dt = np.diff(episode.time)
require(len(dt) and (dt > 0).all(), "Increasing reference timestamps required")
speed = np.linalg.norm(np.diff(episode.wrist_position, axis=0), axis=1) / dt
omega = (
np.array(
[
np.linalg.norm(rotation_error(b, a))
for a, b in zip(episode.wrist_quaternion[:-1], episode.wrist_quaternion[1:], strict=True)
]
)
/ dt
)
joint_speed = np.abs(np.diff(episode.joint_position, axis=0)) / dt[:, None]
require((speed <= limits.reference_speed).all(), "Reference translation too fast")
require((omega <= limits.reference_angular_speed).all(), "Reference rotation too fast")
require((joint_speed <= limits.reference_joint_speed).all(), "Reference joints too fast")
require(
(np.linalg.norm(episode.wrist_position - episode.wrist_position[0], axis=1) <= limits.workspace_radius).all(),
"Reference outside workspace",
)
@@ -0,0 +1,299 @@
"""L20 model-derived floating control overlay for the inspected PhysX 110.1.13 backend.
NewtonMimicAPI is parsed by this PhysX version. Preserve it; never add a second
legacy PhysxMimicJointAPI. Static eligibility is not runtime coupling verification.
"""
import json
import math
import os
import tempfile
from pathlib import Path
from pxr import Gf, Plug, Sdf, Usd, UsdGeom, UsdPhysics
from .asset import _require, dependencies, inspect, prepare_floating_overlay
SOURCE_URDF_SHA = "b8ef22e436ab311fb61f87091b72ae717821092471d628d0ea9475b5088daa5d"
BACKEND_VERSION = "110.1.13"
MIMIC = [
{"joint": f"{finger}_dip", "reference": f"{finger}_pip", "multiplier": 0.89, "offset_rad": 0.0}
for finger in ("index", "middle", "pinky", "ring")
] + [{"joint": "thumb_ip", "reference": "thumb_mcp", "multiplier": 1.02, "offset_rad": 0.0}]
DRIVE_FIELDS = ("stiffness", "damping", "maxForce")
def require_backend(version, newton_schema_registered):
"""Conservative capability binding; this does not certify runtime constraint response."""
_require(version == BACKEND_VERSION, f"Unsupported PhysX version {version}; revalidate backend first")
_require(newton_schema_registered, "Newton mimic schema not registered")
def schema_context():
"""Resolved schema fallbacks are part of the authoring/inspection contract."""
registry = Usd.SchemaRegistry()
return {
name: bool(registry.FindAppliedAPIPrimDefinition(name)) for name in ("PhysxArticulationAPI", "NewtonMimicAPI")
}
def register_schema_plugins(paths):
"""Register caller-selected USD schema plugins; never discover vendor paths implicitly."""
for path in paths:
Plug.Registry().RegisterPlugins(str(Path(path).resolve(strict=True)))
_require(all(schema_context().values()), "Schema paths must resolve PhysxArticulationAPI and NewtonMimicAPI")
def schemas(prim):
"""Composed schema list, including codeless schemas not registered in CPU Python."""
value = prim.GetMetadata("apiSchemas")
return list(value.GetAppliedItems()) if value else []
def newton_mimic(prim):
"""Read single-DOF Newton equation; fallbacks are from installed generatedSchema.
Coef0 is degrees for revolute joints (not NewtonActuator's radians).
Coef1 is dimensionless; the USD joint axis implicitly selects the DOF.
"""
_require("NewtonMimicAPI" in schemas(prim), f"Missing NewtonMimicAPI: {prim.GetPath()}")
def value(name, fallback):
attr = prim.GetAttribute("newton:" + name)
result = attr.Get() if attr else None
return fallback if result is None else result
targets = prim.GetRelationship("newton:mimicJoint").GetTargets()
_require(len(targets) == 1, "Exactly one mimic leader required")
_require(value("mimicEnabled", True) is True, "Disabled mimic constraint")
return str(targets[0]), float(value("mimicCoef1", 1)), math.radians(float(value("mimicCoef0", 0)))
def bind_source(source, manifest):
"""Reject stale identity/structural metadata before authoring or accepting an overlay."""
actual = inspect(source)
for key in ("asset_sha256", "joints", "root_body_path", "root_link", "dependencies", "bodies"):
_require(actual[key] == manifest[key], f"Stale/inconsistent source manifest: {key}")
_require(manifest["source_urdf"]["sha256"] == SOURCE_URDF_SHA, "Unapproved URDF provenance")
_require(manifest["source_urdf"]["mimic"] == MIMIC, "Unexpected L20 mimic equations")
_require(actual["root_link"] == "hand_base_link" and len(actual["joints"]) == 21, "Wrong L20 model")
_require(actual["physics_variant"] == "physx", "PhysX variant required")
validate_mimic(Usd.Stage.Open(str(source)), actual)
return actual
def validate_mimic(stage, manifest, passive=False):
joints = {j["name"]: j for j in manifest["joints"]}
followers = {eq["joint"] for eq in MIMIC}
for name, joint in joints.items():
prim = stage.GetPrimAtPath(joint["path"])
_require(not any("PhysxMimic" in s or "Tendon" in s for s in schemas(prim)), "Duplicate/unsupported coupling")
_require(("NewtonMimicAPI" in schemas(prim)) == (name in followers), "Unexpected mimic set")
_require(not UsdPhysics.Joint(prim).GetExcludeFromArticulationAttr().Get(), "Excluded joint")
_require(joint["axis"] in ("X", "Y", "Z"), "Invalid revolute axis")
if name not in followers:
continue
equation = next(eq for eq in MIMIC if eq["joint"] == name)
leader, coefficient, offset = newton_mimic(prim)
_require(leader == joints[equation["reference"]]["path"], "Wrong mimic leader")
_require(abs(coefficient - equation["multiplier"]) < 1e-6, "Wrong mimic multiplier/sign")
_require(abs(offset - equation["offset_rad"]) < 1e-8, "Wrong mimic offset/units")
if passive:
_require("PhysicsDriveAPI:angular" not in schemas(prim), "Follower drive API still enabled")
for field in DRIVE_FIELDS:
_require(prim.GetAttribute(f"drive:angular:physics:{field}").Get() == 0, "Follower drive not zero")
return [name for name in joints if name not in followers]
def _preserved(original, stage, before):
"""Only leaf world-anchor deactivation, root migration and follower drive suppression are allowed."""
followers = {j["path"] for j in before["joints"] if j["name"] in {eq["joint"] for eq in MIMIC}}
anchor = before["world_fixed_joints"][0]["path"]
root = before["root_body_path"]
source_anchor = original.GetPrimAtPath(anchor)
_require(
source_anchor.IsA(UsdPhysics.FixedJoint) and not source_anchor.GetAllChildren(), "Expected leaf world anchor"
)
joint = UsdPhysics.Joint(source_anchor)
_require(not joint.GetBody0Rel().GetTargets(), "Anchor must attach directly to world")
_require(joint.GetBody1Rel().GetTargets() == [Sdf.Path(root)], "Anchor must attach only to root")
c0, c1 = UsdGeom.XformCache(), UsdGeom.XformCache()
# TraverseAll retains the deliberately inactive leaf: no other deletion or
# deactivation may hide geometry, state joints or time-sampled attributes.
_require(
[str(p.GetPath()) for p in original.TraverseAll()] == [str(p.GetPath()) for p in stage.TraverseAll()],
"Prim tree changed",
)
for prim in original.TraverseAll():
path = str(prim.GetPath())
after = stage.GetPrimAtPath(path)
_require(after.IsActive() == (False if path == anchor else prim.IsActive()), f"Unexpected active state: {path}")
allowed = set()
expected = set(schemas(prim))
if path == anchor:
_require(after.GetAttribute("physics:jointEnabled").Get() is False, "Inactive anchor must remain disabled")
allowed.add("physics:jointEnabled")
expected -= {"PhysicsArticulationRootAPI", "PhysxArticulationAPI"}
if path == root:
expected |= {"PhysicsArticulationRootAPI", "PhysxArticulationAPI"}
allowed |= {
a.GetName()
for a in stage.GetPrimAtPath(anchor).GetAttributes()
if a.GetName().startswith("physxArticulation:")
}
for name in allowed:
_require(
after.GetAttribute(name).Get() == stage.GetPrimAtPath(anchor).GetAttribute(name).Get(),
"Root solver property changed",
)
if path in followers:
expected.discard("PhysicsDriveAPI:angular")
allowed |= {f"drive:angular:physics:{field}" for field in DRIVE_FIELDS}
_require(set(schemas(after)) == expected, f"Unexpected schema edit: {path}")
attrs = {a.GetName() for a in prim.GetAttributes()} | {a.GetName() for a in after.GetAttributes()}
# This diagnostic accepts static assets only, including fields whose
# default values may be changed by the preparation allowlist.
for name in attrs:
for candidate in (prim.GetAttribute(name), after.GetAttribute(name)):
if candidate:
_require(not candidate.GetTimeSamples(), f"Time samples prohibited: {path}.{name}")
for name in attrs - allowed:
a, b = prim.GetAttribute(name), after.GetAttribute(name)
_require((a.Get() if a else None) == (b.Get() if b else None), f"Unexpected attribute: {path}.{name}")
rels = {r.GetName() for r in prim.GetRelationships()} | {r.GetName() for r in after.GetRelationships()}
for name in rels:
_require(
prim.GetRelationship(name).GetTargets() == after.GetRelationship(name).GetTargets(),
"Relationship changed",
)
if prim.IsA(UsdGeom.Xformable):
_require(
Gf.IsClose(c0.GetLocalToWorldTransform(prim), c1.GetLocalToWorldTransform(after), 1e-12),
"Transform changed",
)
def inspect_prepared(output, source_manifest):
"""Inspect actual composed USD + original dependency hashes, never a JSON ready flag."""
output = Path(output).resolve(strict=True)
layer = Sdf.Layer.FindOrOpen(str(output))
_require(
len(layer.subLayerPaths) == 1 and not os.path.isabs(layer.subLayerPaths[0]),
"One relative source layer required",
)
source = (output.parent / layer.subLayerPaths[0]).resolve(strict=True)
before = bind_source(source, source_manifest)
_require(layer.customLayerData.get("source_bundle_sha256") == before["asset_sha256"], "Overlay provenance mismatch")
_require(layer.customLayerData.get("source_urdf_sha256") == SOURCE_URDF_SHA, "Overlay URDF mismatch")
_require(
dict(layer.customLayerData.get("schema_context", {})) == schema_context(),
"Authoring schema context mismatch; regenerate from source with --schema-plugin-path for both "
"installed PhysX and Newton plugins, and use the same plugins when inspecting",
)
after = inspect(output)
_require(
not after["world_fixed_joints"] and after["articulation_roots"] == [before["root_body_path"]], "Not floating"
)
stage = Usd.Stage.Open(str(output))
masters = validate_mimic(stage, after, passive=True)
_preserved(Usd.Stage.Open(str(source)), stage, before)
return {
"status": (
"STATIC_ELIGIBLE_RUNTIME_UNVERIFIED"
if all(schema_context().values())
else "STATIC_ONLY_SCHEMA_UNREGISTERED"
),
"schema_context": schema_context(),
"preparation_note": "Runtime use requires matching registered PhysX/Newton schemas; regenerate if different",
"prepared_asset_sha256": after["asset_sha256"],
"source_asset_sha256": before["asset_sha256"],
"independent_joint_names": masters,
"mimic": MIMIC,
"required_physx_version": BACKEND_VERSION,
"root_body_path": before["root_body_path"],
"runtime_verified": False,
}
def prepare(source, output, manifest):
source, output = Path(source).resolve(strict=True), Path(output).resolve()
_require(not output.exists(), "Refusing to overwrite output")
before = bind_source(source, manifest)
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".l20-prepared-", dir=output.parent) as directory:
temporary = Path(directory) / "floating.usda"
prepare_floating_overlay(source, temporary)
stage = Usd.Stage.Open(str(temporary))
for joint in before["joints"]:
if joint["name"] not in {eq["joint"] for eq in MIMIC}:
continue
prim = stage.GetPrimAtPath(joint["path"])
# Preserve resolved drive defaults as inert attributes when removing API.
drive_values = [
(a.GetName(), a.GetTypeName(), a.Get())
for a in prim.GetAttributes()
if a.GetName().startswith("drive:angular:") and a.Get() is not None
]
prim.RemoveAPI(UsdPhysics.DriveAPI, "angular")
for name, dtype, value in drive_values:
prim.CreateAttribute(name, dtype).Set(value)
for field in DRIVE_FIELDS:
prim.CreateAttribute(f"drive:angular:physics:{field}", Sdf.ValueTypeNames.Float).Set(0)
# jointEnabled=false leaves a parsed joint with world/local frames that
# diverge when the floating hand is spawned. Remove that obsolete joint
# from active composition, not its data or any robot body/state joint.
stage.GetPrimAtPath(before["world_fixed_joints"][0]["path"]).SetActive(False)
stage.GetRootLayer().customLayerData = {
"purpose": "L20 experimental floating tracking; runtime UNVERIFIED",
"source_bundle_sha256": before["asset_sha256"],
"source_urdf_sha256": SOURCE_URDF_SHA,
"physx_evidence_version": BACKEND_VERSION,
"schema_context": schema_context(),
}
stage.GetRootLayer().Save()
inspect_prepared(temporary, manifest)
# Rebase the relative source path for the final location before publication.
final_layer = Sdf.Layer.CreateAnonymous()
final_layer.TransferContent(stage.GetRootLayer())
final_layer.subLayerPaths = [os.path.relpath(source, output.parent).replace(os.sep, "/")]
# Validate with the final relative-path context before publishing.
descriptor, name = tempfile.mkstemp(prefix=".l20-publication-", suffix=".usda", dir=output.parent)
os.close(descriptor)
publication = Path(name)
try:
_require(final_layer.Export(str(publication)), "Export failed")
inspect_prepared(publication, manifest)
os.link(publication, output)
finally:
publication.unlink()
_require(dependencies(source)[1] == before["asset_sha256"], "Original changed")
return inspect_prepared(output, manifest)
def main():
import argparse
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("asset", type=Path)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--output", type=Path, help="Create new experimental floating overlay")
parser.add_argument(
"--schema-plugin-path",
type=Path,
action="append",
default=[],
help="Repeat for caller-selected PhysX and Newton USD plugInfo.json paths; required for runtime preparation",
)
args = parser.parse_args()
try:
if args.schema_plugin_path:
register_schema_plugins(args.schema_plugin_path)
manifest = json.loads(args.manifest.read_text())
result = prepare(args.asset, args.output, manifest) if args.output else inspect_prepared(args.asset, manifest)
print(json.dumps(result, indent=2))
except (ValueError, OSError, RuntimeError, KeyError) as error:
parser.exit(1, f"FAIL: {error}\n")
if __name__ == "__main__":
main()
@@ -0,0 +1,225 @@
"""Strict l20_tracking_v1 reference-state ingestion, without simulator imports."""
import re
from dataclasses import dataclass
from pathlib import Path
import h5py
import numpy as np
class ContractError(ValueError):
"""An input cannot be safely interpreted as a reference trajectory."""
def require(condition, message):
if not condition:
raise ContractError(message)
def text(value, label):
if isinstance(value, bytes):
value = value.decode("utf-8", errors="strict")
require(isinstance(value, str) and bool(value.strip()), f"{label}: nonempty UTF-8 text required")
return value
@dataclass(frozen=True)
class Episode:
time: np.ndarray
wrist_position: np.ndarray
wrist_quaternion: np.ndarray
joint_position: np.ndarray
valid: np.ndarray
@dataclass(frozen=True)
class Demonstrations:
metadata: dict
joint_names: tuple[str, ...]
world_from_source: np.ndarray
episodes: dict[str, Episode]
def _dataset(group, name, dtype, shape):
require(name in group and isinstance(group[name], h5py.Dataset), f"{group.name}/{name}: dataset required")
data = group[name]
require(
data.dtype.kind == np.dtype(dtype).kind and data.dtype.itemsize == np.dtype(dtype).itemsize,
f"{data.name}: dtype must be {dtype}",
)
require(data.shape == shape, f"{data.name}: expected shape {shape}, got {data.shape}")
value = data[...]
require(np.isfinite(value).all(), f"{data.name}: NaN/Inf prohibited, including invalid frames")
return value
def load(path: str | Path, manifest: dict | None = None) -> Demonstrations:
"""Read and close an HDF5 file. Manifest checking never approves physical actuation."""
with h5py.File(path, "r") as file:
attributes = {}
for name in (
"schema_version",
"embodiment",
"hand_side",
"asset_sha256",
"root_link",
"metric_scale_provenance",
"provenance",
"source_description",
):
require(name in file.attrs, f"missing root attribute {name}")
attributes[name] = text(file.attrs[name], name)
for name, expected in (("schema_version", "l20_tracking_v1"), ("embodiment", "L20"), ("hand_side", "left")):
require(attributes[name] == expected, f"{name}: expected {expected}")
require(
re.fullmatch(r"[0-9a-f]{64}", attributes["asset_sha256"]) is not None,
"asset_sha256: lowercase SHA-256 required",
)
require(attributes["provenance"] in ("expert_retargeted", "synthetic"), "unknown provenance")
require("scale_to_meters" in file.attrs, "missing root attribute scale_to_meters")
scale = np.asarray(file.attrs["scale_to_meters"])
require(
scale.shape == () and scale.dtype.kind == "f" and np.isfinite(scale) and scale > 0,
"scale_to_meters: positive finite floating scalar required",
)
attributes["scale_to_meters"] = float(scale)
require("metadata" in file and isinstance(file["metadata"], h5py.Group), "metadata group required")
meta = file["metadata"]
require(
"joint_names" in meta and isinstance(meta["joint_names"], h5py.Dataset),
"metadata/joint_names dataset required",
)
names = meta["joint_names"]
info = h5py.check_string_dtype(names.dtype)
require(
info is not None and info.encoding == "utf-8" and names.ndim == 1 and len(names) > 0,
"joint_names must be UTF-8[J], J > 0",
)
joint_names = tuple(text(n, "joint_names") for n in names.asstr()[...])
require(len(set(joint_names)) == len(joint_names), "duplicate joint names")
transform = _dataset(meta, "world_from_source", "float64", (4, 4))
require(
np.allclose(transform[3], [0, 0, 0, 1], rtol=0, atol=1e-8), "world_from_source: invalid homogeneous row"
)
rotation = transform[:3, :3]
require(
np.allclose(rotation.T @ rotation, np.eye(3), rtol=0, atol=1e-6)
and np.isclose(np.linalg.det(rotation), 1, rtol=0, atol=1e-6),
"world_from_source must be a proper rigid transform, not a scale/reflection",
)
require(
"episodes" in file and isinstance(file["episodes"], h5py.Group) and len(file["episodes"]) > 0,
"nonempty episodes group required",
)
episodes = {}
for name, group in file["episodes"].items():
require(
re.fullmatch(r"demo_[0-9]{6}", name) is not None and isinstance(group, h5py.Group),
f"invalid episode group {name}",
)
require(
"time" in group and isinstance(group["time"], h5py.Dataset) and group["time"].ndim == 1,
f"{name}: time[T] required",
)
count = len(group["time"])
require(count >= 2, f"{name}: at least two frames required")
time = _dataset(group, "time", "float64", (count,))
require(time[0] == 0 and (np.diff(time) > 0).all(), f"{name}: time must start at 0 and increase strictly")
position = _dataset(group, "wrist_position", "float32", (count, 3))
quaternion = _dataset(group, "wrist_quaternion", "float32", (count, 4))
joints = _dataset(group, "joint_position", "float32", (count, len(joint_names)))
valid = _dataset(group, "valid", "bool", (count,))
require(valid.any(), f"{name}: no valid frames")
require(
np.allclose(np.linalg.norm(quaternion[valid], axis=1), 1, rtol=0, atol=1e-4),
f"{name}: valid quaternions must be unit length",
)
adjacent = valid[:-1] & valid[1:]
require(
(np.sum(quaternion[:-1] * quaternion[1:], axis=1)[adjacent] >= 0).all(),
f"{name}: adjacent valid quaternion signs must be continuous",
)
episodes[name] = Episode(time, position, quaternion, joints, valid)
result = Demonstrations(attributes, joint_names, transform, episodes)
if manifest is not None:
validate_against_manifest(result, manifest)
return result
def validate_against_manifest(data: Demonstrations, manifest: dict):
"""Check identity/order/limits and known URDF mimic equations, not actuator validity."""
require(manifest.get("manifest_version") == "l20_asset_manifest_v1", "unsupported manifest")
require(data.metadata["asset_sha256"] == manifest["asset_sha256"], "asset bundle hash mismatch")
require(data.metadata["root_link"] == manifest["root_link"], "root link mismatch")
joints = manifest["joints"]
expected = tuple(joint["name"] for joint in joints)
require(data.joint_names == expected, "joint order/names must exactly match manifest (no implicit reorder)")
lower = np.array([joint["lower_rad"] for joint in joints])
upper = np.array([joint["upper_rad"] for joint in joints])
require(
np.isfinite(lower).all() and np.isfinite(upper).all() and (lower <= upper).all(),
"manifest requires finite ordered revolute limits",
)
for name, episode in data.episodes.items():
q = episode.joint_position[episode.valid]
require(((q >= lower - 1e-6) & (q <= upper + 1e-6)).all(), f"{name}: reference joint limit violation")
for mimic in manifest.get("source_urdf", {}).get("mimic", []):
child = expected.index(mimic["joint"])
parent = expected.index(mimic["reference"])
error = q[:, child] - (mimic["multiplier"] * q[:, parent] + mimic["offset_rad"])
require((np.abs(error) <= 1e-3).all(), f"{name}: inconsistent mimic reference {mimic['joint']}")
def sample(episode: Episode, query_time) -> Episode:
"""Linear position/joint interpolation and shortest-arc SLERP; never cross invalid gaps.
Inputs must come from ``load``. Query a separate valid segment at a time; this
rejects even sparse queries spanning an invalid frame, and never extrapolates.
"""
query = np.asarray(query_time, dtype=np.float64)
require(query.ndim == 1 and len(query) > 0 and np.isfinite(query).all(), "finite query_time[N] required")
require((np.diff(query) > 0).all(), "query times must increase strictly")
time = episode.time
require(query[0] >= time[0] and query[-1] <= time[-1], "extrapolation prohibited")
left = np.searchsorted(time, query, side="right") - 1
right = np.searchsorted(time, query, side="left")
require(episode.valid[left.min() : right.max() + 1].all(), "query crosses or touches an invalid frame")
denominator = time[right] - time[left]
alpha = np.divide(query - time[left], denominator, out=np.zeros_like(query), where=denominator > 0)[:, None]
position = (1 - alpha) * episode.wrist_position[left] + alpha * episode.wrist_position[right]
joints = (1 - alpha) * episode.joint_position[left] + alpha * episode.joint_position[right]
q0 = episode.wrist_quaternion[left].astype(np.float64)
q1 = episode.wrist_quaternion[right].astype(np.float64)
q0 /= np.linalg.norm(q0, axis=1, keepdims=True)
q1 /= np.linalg.norm(q1, axis=1, keepdims=True)
dot = np.sum(q0 * q1, axis=1, keepdims=True)
q1 = np.where(dot < 0, -q1, q1)
dot = np.clip(np.abs(dot), 0, 1)
theta = np.arccos(dot)
denominator = np.sin(theta)
curved = dot < 0.9995
weight0 = np.divide(np.sin((1 - alpha) * theta), denominator, out=1 - alpha.copy(), where=curved)
weight1 = np.divide(np.sin(alpha * theta), denominator, out=alpha.copy(), where=curved)
quaternion = weight0 * q0 + weight1 * q1
quaternion /= np.linalg.norm(quaternion, axis=1, keepdims=True)
# Sparse output samples may span many source arcs. Preserve rotations while
# choosing a continuous quaternion hemisphere on the output timeline too.
flips = np.where(np.sum(quaternion[:-1] * quaternion[1:], axis=1) < 0, -1, 1)
quaternion[1:] *= np.cumprod(flips)[:, None]
return Episode(
query,
position.astype(np.float32),
quaternion.astype(np.float32),
joints.astype(np.float32),
np.ones(len(query), dtype=bool),
)
def require_dynamic_replay_ready(manifest: dict):
"""A data manifest alone never authorizes dynamic control."""
raise ContractError(
"BLOCKED: a data manifest cannot authorize actuation. Use the separate experimental track_l20.py "
"path with actual prepared-USD inspection, backend checks and runtime assertions. "
"Production replay and hardware control remain unvalidated."
)
+47
View File
@@ -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.
+3 -2
View File
@@ -24,7 +24,7 @@ INSTALL_REQUIRES = [
# Installation operation
setup(
name="dex_workbench",
packages=["dex_workbench"],
packages=["dex_workbench", "dex_workbench_tracking"],
author=EXTENSION_TOML_DATA["package"]["author"],
maintainer=EXTENSION_TOML_DATA["package"]["maintainer"],
url=EXTENSION_TOML_DATA["package"]["repository"],
@@ -32,6 +32,7 @@ setup(
description=EXTENSION_TOML_DATA["package"]["description"],
keywords=EXTENSION_TOML_DATA["package"]["keywords"],
install_requires=INSTALL_REQUIRES,
extras_require={"tracking": ["numpy>=1.26", "h5py>=3.10"]},
license="Apache-2.0",
include_package_data=True,
python_requires=">=3.12",
@@ -41,4 +42,4 @@ setup(
"Isaac Sim :: 6.0.0",
],
zip_safe=False,
)
)
@@ -0,0 +1,130 @@
"""Static USD regression tests; run with Isaac Sim's pxr-capable Python, no GPU/Kit."""
import json
import shutil
import tempfile
import unittest
from pathlib import Path
from dex_workbench_tracking.asset import dependencies, inspect, prepare_floating_overlay
from pxr import Gf, Sdf, Usd, UsdGeom, UsdPhysics
class TrackingAssetTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name) / "robot.usda"
stage = Usd.Stage.CreateNew(str(self.path))
root = UsdGeom.Xform.Define(stage, "/Robot").GetPrim()
stage.SetDefaultPrim(root)
variant = root.GetVariantSets().AddVariantSet("Physics")
variant.AddVariant("physx")
variant.SetVariantSelection("physx")
UsdGeom.SetStageMetersPerUnit(stage, 1)
UsdGeom.SetStageUpAxis(stage, "Z")
UsdPhysics.SetStageKilogramsPerUnit(stage, 1)
for name in ("base", "tip"):
prim = UsdGeom.Xform.Define(stage, f"/Robot/{name}").GetPrim()
UsdPhysics.RigidBodyAPI.Apply(prim)
UsdPhysics.MassAPI.Apply(prim).CreateMassAttr(0.1)
hinge = UsdPhysics.RevoluteJoint.Define(stage, "/Robot/hinge")
hinge.CreateBody0Rel().SetTargets(["/Robot/base"])
hinge.CreateBody1Rel().SetTargets(["/Robot/tip"])
hinge.CreateLowerLimitAttr(0)
hinge.CreateUpperLimitAttr(90)
fixed = UsdPhysics.FixedJoint.Define(stage, "/Robot/anchor")
fixed.CreateBody1Rel().SetTargets(["/Robot/base"])
UsdPhysics.ArticulationRootAPI.Apply(fixed.GetPrim())
stage.GetRootLayer().Save()
def test_manifest_is_explicitly_not_actuator_map(self):
result = inspect(self.path)
self.assertEqual(result["root_link"], "base")
self.assertEqual([j["name"] for j in result["joints"]], ["hinge"])
self.assertAlmostEqual(result["joints"][0]["upper_rad"], 1.5707963267948966)
self.assertFalse(result["dynamic_replay_ready"])
self.assertEqual(result["coupling_status"], "UNVERIFIED")
json.dumps(result, allow_nan=False)
def test_bundle_hash_is_portable_and_sensitive(self):
target = Path(self.directory.name) / "moved"
target.mkdir()
copy = target / self.path.name
shutil.copyfile(self.path, copy)
self.assertEqual(dependencies(self.path)[1], dependencies(copy)[1])
with copy.open("a") as stream:
stream.write("\n# changed\n")
self.assertNotEqual(dependencies(self.path)[1], dependencies(copy)[1])
def test_missing_dependency_rejected(self):
stage = Usd.Stage.Open(str(self.path))
stage.GetRootLayer().subLayerPaths = ["missing.usda"]
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Unresolved"):
inspect(self.path)
def test_floating_overlay_preserves_source_and_transforms(self):
before = self.path.read_bytes()
output = Path(self.directory.name) / "floating.usda"
result = prepare_floating_overlay(self.path, output)
self.assertEqual(result["world_fixed_joints"], [])
self.assertEqual(result["articulation_roots"], ["/Robot/base"])
self.assertEqual(self.path.read_bytes(), before)
self.assertFalse(result["dynamic_replay_ready"])
stage = Usd.Stage.Open(str(output))
self.assertFalse(UsdPhysics.Joint(stage.GetPrimAtPath("/Robot/anchor")).GetJointEnabledAttr().Get())
self.assertFalse(Sdf.Layer.FindOrOpen(str(output)).subLayerPaths[0].startswith("/"))
self.assertTrue(
Gf.IsClose(
UsdGeom.XformCache().GetLocalToWorldTransform(stage.GetPrimAtPath("/Robot/base")), Gf.Matrix4d(1), 1e-12
)
)
with self.assertRaisesRegex(ValueError, "overwrite"):
prepare_floating_overlay(self.path, output)
with self.assertRaisesRegex(ValueError, "fixed-world"):
prepare_floating_overlay(output, Path(self.directory.name) / "again.usda")
def test_ambiguous_root_rejected(self):
stage = Usd.Stage.Open(str(self.path))
UsdPhysics.ArticulationRootAPI.Apply(stage.GetPrimAtPath("/Robot/base"))
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "one articulation"):
inspect(self.path)
def test_urdf_matching_and_wrong_limits(self):
urdf = Path(self.directory.name) / "robot.urdf"
urdf.write_text(
'<robot name="test"><joint name="hinge" type="revolute">'
'<parent link="base"/><child link="tip"/>'
'<limit lower="0" upper="1.5707963267948966"/></joint></robot>'
)
result = inspect(self.path, urdf)
self.assertEqual(result["source_urdf"]["status"], "STRUCTURAL_MATCH_ONLY")
urdf.write_text(urdf.read_text().replace('upper="1.5707963267948966"', 'upper="1"'))
with self.assertRaisesRegex(ValueError, "limit mismatch"):
inspect(self.path, urdf)
def test_newton_mimic_presence_is_not_runtime_verification(self):
stage = Usd.Stage.Open(str(self.path))
hinge = stage.GetPrimAtPath("/Robot/hinge")
hinge.AddAppliedSchema("NewtonMimicAPI")
hinge.CreateAttribute("newton:mimicCoef1", Sdf.ValueTypeNames.Float).Set(1)
stage.GetRootLayer().Save()
urdf = Path(self.directory.name) / "robot.urdf"
# Deliberately self-referencing fixture only tests backend evidence, not valid transmission.
urdf.write_text(
'<robot name="test"><joint name="hinge" type="revolute">'
'<parent link="base"/><child link="tip"/>'
'<limit lower="0" upper="1.5707963267948966"/>'
'<mimic joint="hinge" multiplier="1"/></joint></robot>'
)
result = inspect(self.path, urdf)
self.assertTrue(result["coupling_evidence"])
self.assertFalse(result["physx_coupling_evidence"])
self.assertEqual(result["coupling_status"], "MIMIC_SCHEMA_PRESENT_RUNTIME_UNVERIFIED")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,103 @@
"""Analytic CPU regressions for control.py; not simulated dynamics."""
import unittest
import numpy as np
from dex_workbench_tracking.cli import synthetic
from dex_workbench_tracking.control import (
Limits,
reference_pose_to_xyzw,
rotation_error,
validate_reference,
wrench,
xyzw_pose_to_reference,
)
from dex_workbench_tracking.trajectory import ContractError
class ControlTests(unittest.TestCase):
def setUp(self):
self.limits = Limits()
self.pose = np.array([0, 0, 0, 1, 0, 0, 0], dtype=float)
self.velocity = np.zeros(6)
def compute(self, target=None, root_com=None, body_com=None, masses=None):
return wrench(
np.zeros(3) if target is None else np.array(target),
self.pose[3:],
self.pose,
self.velocity,
np.zeros(3) if root_com is None else np.array(root_com),
np.zeros((1, 3)) if body_com is None else np.array(body_com),
np.array([0.1]) if masses is None else np.array(masses),
self.limits,
)
def test_hdf5_wxyz_runtime_xyzw_boundary(self):
position = [0.1, 0.2, 0.3]
quaternion = [0.8, 0.6, 0, 0]
runtime = reference_pose_to_xyzw(position, quaternion)
np.testing.assert_allclose(runtime, [0.1, 0.2, 0.3, 0.6, 0, 0, 0.8])
np.testing.assert_allclose(xyzw_pose_to_reference(runtime), position + quaternion)
def test_gravity_all_links_and_moment_arm(self):
force, torque = self.compute(body_com=[[0, 0, 0], [0.1, 0, 0]], masses=[0.1, 0.2])
np.testing.assert_allclose(force, [0, 0, 2.943])
np.testing.assert_allclose(torque, [0, -0.1962, 0])
def test_link_pd_force_shift_to_com(self):
force, torque = self.compute(target=[0, 0.01, 0], root_com=[0.1, 0, 0], body_com=[[0.1, 0, 0]])
np.testing.assert_allclose(force, [0, 1, 0.981])
np.testing.assert_allclose(torque, [0, 0, -0.1])
def test_total_wrench_including_gravity_is_bounded(self):
force, torque = self.compute(target=[100, 100, 100], body_com=[[1, 0, 0]], masses=[100])
self.assertLessEqual(np.linalg.norm(force), self.limits.force + 1e-10)
self.assertLessEqual(np.linalg.norm(torque), self.limits.torque + 1e-10)
def test_damping_opposes_world_velocity(self):
self.velocity[:3] = [0.01, 0, 0]
self.velocity[3:] = [0, 0, 0.1]
force, torque = self.compute()
self.assertAlmostEqual(force[0], -0.1)
self.assertAlmostEqual(torque[2], -0.002)
def test_orientation_world_rotation_and_antipodes(self):
angle = 0.2
target = np.array([np.cos(angle / 2), 0, 0, np.sin(angle / 2)])
np.testing.assert_allclose(rotation_error(target, self.pose[3:]), [0, 0, angle])
np.testing.assert_allclose(rotation_error(-target, self.pose[3:]), [0, 0, angle])
np.testing.assert_allclose(rotation_error(self.pose[3:], target), [0, 0, -angle])
# Current local-X quarter-turn followed by world-Z quarter-turn.
np.testing.assert_allclose(
rotation_error([0.5, 0.5, 0.5, 0.5], [2**-0.5, 2**-0.5, 0, 0]), [0, 0, np.pi / 2], atol=1e-12
)
def test_nonfinite_state_bad_mass_and_limits_rejected(self):
for mass in (0, -1, float("nan")):
with self.assertRaises(ContractError):
self.compute(masses=[mass])
for value in (0, -1, float("nan"), float("inf")):
with self.assertRaises(ContractError):
Limits(force=value)
def test_reference_envelope_and_invalid_gap(self):
manifest = {
"manifest_version": "l20_asset_manifest_v1",
"asset_sha256": "a" * 64,
"root_link": "test",
"joints": [{"name": "a", "lower_rad": 0, "upper_rad": 1}],
}
episode = synthetic(manifest).episodes["demo_000000"]
validate_reference(episode, self.limits)
episode.valid[60] = False
with self.assertRaisesRegex(ContractError, "fully valid"):
validate_reference(episode, self.limits)
episode.valid[:] = True
episode.wrist_position[60, 0] = 1
with self.assertRaisesRegex(ContractError, "too fast"):
validate_reference(episode, self.limits)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,225 @@
"""Source-named CPU/USD tests of prepared.py; actual L20 dependency copies, no Kit."""
import copy
import json
import shutil
import tempfile
import unittest
from pathlib import Path
from dex_workbench_tracking.asset import dependencies
from dex_workbench_tracking.prepared import (
BACKEND_VERSION,
MIMIC,
bind_source,
inspect_prepared,
newton_mimic,
prepare,
require_backend,
schemas,
)
from pxr import Sdf, Usd, UsdPhysics
REPO = Path(__file__).resolve().parents[3]
ASSET_DIR = REPO / "assets/robots/dex_hand/linkerhand_g20_left"
class PreparedTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.bundle = Path(self.temp.name) / "bundle"
shutil.copytree(ASSET_DIR / "linkerhand_g20_left", self.bundle / "original")
self.source = self.bundle / "original/linkerhand_g20_left.usda"
self.output = self.bundle / "tracking.usda"
self.manifest = json.loads((ASSET_DIR / "tracking_manifest.json").read_text())
def build(self):
return prepare(self.source, self.output, self.manifest)
def test_unsupported_backend_or_unregistered_schema_rejected(self):
require_backend(BACKEND_VERSION, True)
for version, registered in (("110.1.12", True), ("future", True), (BACKEND_VERSION, False)):
with self.assertRaises(ValueError):
require_backend(version, registered)
def test_overlay_suppresses_only_followers_and_preserves_original(self):
original = dependencies(self.source)
result = self.build()
self.assertEqual(dependencies(self.source), original)
self.assertFalse(result["runtime_verified"])
self.assertEqual(len(result["independent_joint_names"]), 16)
stage = Usd.Stage.Open(str(self.output))
for eq in MIMIC:
joint = next(j for j in self.manifest["joints"] if j["name"] == eq["joint"])
prim = stage.GetPrimAtPath(joint["path"])
self.assertIn("NewtonMimicAPI", schemas(prim))
self.assertNotIn("PhysicsDriveAPI:angular", schemas(prim))
leader, multiplier, offset = newton_mimic(prim)
self.assertTrue(leader.endswith("/" + eq["reference"]))
self.assertAlmostEqual(multiplier, eq["multiplier"], places=6)
self.assertEqual(offset, 0)
self.assertEqual(result, inspect_prepared(self.output, self.manifest))
def test_only_obsolete_leaf_world_anchor_is_inactive(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
self.assertFalse(anchor.IsActive())
self.assertFalse(anchor.GetAttribute("physics:jointEnabled").Get())
self.assertFalse(anchor.GetAllChildren())
self.assertIn(anchor, list(stage.TraverseAll()))
self.assertNotIn(anchor, list(stage.Traverse()))
joints = [p for p in stage.Traverse() if p.IsA(UsdPhysics.Joint)]
self.assertEqual(len(joints), 21)
self.assertTrue(all(p.IsA(UsdPhysics.RevoluteJoint) for p in joints))
anchor.SetActive(True)
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "active state"):
inspect_prepared(self.output, self.manifest)
def test_unrelated_deactivation_rejected(self):
self.edit_follower(lambda p: p.SetActive(False))
def test_unrelated_prim_addition_rejected(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
stage.DefinePrim(self.manifest["default_prim"] + "/Extra", "Scope")
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Prim tree changed"):
inspect_prepared(self.output, self.manifest)
def test_inactive_anchor_attributes_still_checked(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
anchor.GetAttribute("physics:jointEnabled").Set(True, Usd.TimeCode(1))
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
inspect_prepared(self.output, self.manifest)
def test_inactive_anchor_must_remain_disabled(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
anchor.GetAttribute("physics:jointEnabled").Set(True)
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "remain disabled"):
inspect_prepared(self.output, self.manifest)
def test_inactive_anchor_relationships_still_checked(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
anchor = stage.GetPrimAtPath(self.manifest["world_fixed_joints"][0]["path"])
anchor.GetRelationship("physics:body1").SetTargets([])
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Relationship changed"):
inspect_prepared(self.output, self.manifest)
def test_complete_bundle_relocation_preserves_identity(self):
before = self.build()
moved = Path(self.temp.name) / "moved"
shutil.copytree(self.bundle, moved)
self.assertEqual(before, inspect_prepared(moved / "tracking.usda", self.manifest))
layer = Sdf.Layer.FindOrOpen(str(self.output))
self.assertEqual(layer.subLayerPaths, ["original/linkerhand_g20_left.usda"])
def test_stale_manifest_or_original_bytes_rejected(self):
wrong = copy.deepcopy(self.manifest)
wrong["asset_sha256"] = "0" * 64
with self.assertRaisesRegex(ValueError, "Stale"):
prepare(self.source, self.output, wrong)
self.assertFalse(self.output.exists())
self.build()
with self.source.open("a") as stream:
stream.write("\n# change identity\n")
with self.assertRaisesRegex(ValueError, "Stale"):
inspect_prepared(self.output, self.manifest)
def test_altered_model_mapping_rejected(self):
wrong = copy.deepcopy(self.manifest)
wrong["source_urdf"]["mimic"][0]["multiplier"] = -0.89
with self.assertRaisesRegex(ValueError, "Unexpected L20 mimic"):
bind_source(self.source, wrong)
def test_fixed_preview_and_json_flag_cannot_enable(self):
self.manifest["dynamic_replay_ready"] = True
with self.assertRaises(ValueError):
inspect_prepared(self.source, self.manifest)
def test_no_overwrite(self):
self.build()
before = self.output.read_bytes()
with self.assertRaisesRegex(ValueError, "overwrite"):
self.build()
self.assertEqual(self.output.read_bytes(), before)
def edit_follower(self, callback):
self.build()
stage = Usd.Stage.Open(str(self.output))
path = next(j["path"] for j in self.manifest["joints"] if j["name"] == "thumb_ip")
callback(stage.GetPrimAtPath(path))
stage.GetRootLayer().Save()
with self.assertRaises(ValueError):
inspect_prepared(self.output, self.manifest)
def test_sampled_mimic_rejected(self):
self.edit_follower(lambda p: p.GetAttribute("newton:mimicCoef1").Set(-1.02, Usd.TimeCode(1)))
def test_sampled_allowlisted_follower_gain_rejected(self):
self.edit_follower(lambda p: p.GetAttribute("drive:angular:physics:stiffness").Set(99, Usd.TimeCode(1)))
def test_sampled_mass_rejected(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
root = stage.GetPrimAtPath(self.manifest["root_body_path"])
root.GetAttribute("physics:mass").Set(99, Usd.TimeCode(1))
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
inspect_prepared(self.output, self.manifest)
def test_sampled_transform_rejected(self):
self.build()
stage = Usd.Stage.Open(str(self.output))
attr = next(
a
for p in stage.Traverse()
for a in p.GetAttributes()
if a.GetName().startswith("xformOp:") and a.Get() is not None
)
# Even a sample equal to the default is prohibited for this static asset.
attr.Set(attr.Get(), Usd.TimeCode(1))
stage.GetRootLayer().Save()
with self.assertRaisesRegex(ValueError, "Time samples prohibited"):
inspect_prepared(self.output, self.manifest)
def test_wrong_sign_rejected(self):
self.edit_follower(lambda p: p.GetAttribute("newton:mimicCoef1").Set(-1.02))
def test_disabled_native_constraint_rejected(self):
self.edit_follower(lambda p: p.CreateAttribute("newton:mimicEnabled", Sdf.ValueTypeNames.Bool).Set(False))
def test_duplicate_legacy_constraint_rejected(self):
self.edit_follower(lambda p: p.AddAppliedSchema("PhysxMimicJointAPI:rotX"))
def test_follower_motor_rejected(self):
self.edit_follower(lambda p: UsdPhysics.DriveAPI.Apply(p, "angular"))
def test_changed_mass_or_limits_rejected(self):
self.edit_follower(lambda p: p.GetAttribute("physics:upperLimit").Set(180.0))
def test_newton_offset_degree_conversion(self):
stage = Usd.Stage.CreateInMemory()
prim = UsdPhysics.RevoluteJoint.Define(stage, "/follower").GetPrim()
prim.AddAppliedSchema("NewtonMimicAPI")
prim.CreateRelationship("newton:mimicJoint").SetTargets(["/leader"])
prim.CreateAttribute("newton:mimicCoef0", Sdf.ValueTypeNames.Float).Set(90)
prim.CreateAttribute("newton:mimicCoef1", Sdf.ValueTypeNames.Float).Set(-2)
_, coefficient, offset = newton_mimic(prim)
self.assertEqual(coefficient, -2)
self.assertAlmostEqual(offset, 1.5707963267948966)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,89 @@
"""Regression of CLI's named range_4s HDF5 reference against the actual L20 manifest."""
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import numpy as np
from dex_workbench_tracking.cli import synthetic
from dex_workbench_tracking.control import Limits, validate_reference
from dex_workbench_tracking.trajectory import ContractError, load, sample
REPO = Path(__file__).resolve().parents[3]
MANIFEST = REPO / "assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json"
class TrackingRangeProfileTests(unittest.TestCase):
def test_default_small_profile_is_unchanged(self):
manifest = json.loads(MANIFEST.read_text())
implicit, explicit = synthetic(manifest), synthetic(manifest, "small")
self.assertEqual(implicit.metadata, explicit.metadata)
a, b = implicit.episodes["demo_000000"], explicit.episodes["demo_000000"]
for field in ("time", "wrist_position", "wrist_quaternion", "joint_position", "valid"):
np.testing.assert_array_equal(getattr(a, field), getattr(b, field))
self.assertEqual(len(a.time), 121)
self.assertEqual(a.time[-1], 2)
self.assertAlmostEqual(a.wrist_position[:, 0].max(), 0.002)
with self.assertRaises(ContractError):
synthetic(manifest, "unknown")
def test_range_cli_publication_mapping_and_reference_limits(self):
manifest = json.loads(MANIFEST.read_text())
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "range.hdf5"
command = [
sys.executable,
"-m",
"dex_workbench_tracking.cli",
"synthetic",
"--manifest",
str(MANIFEST),
"--profile",
"range_4s",
"--output",
str(output),
]
result = subprocess.run(command, capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["provenance"], "synthetic")
data = load(output, manifest)
self.assertEqual(data.metadata["asset_sha256"], manifest["asset_sha256"])
self.assertEqual(data.metadata["provenance"], "synthetic")
self.assertIn("range_4s", data.metadata["source_description"])
self.assertIn("NOT expert", data.metadata["source_description"])
names = list(data.joint_names)
self.assertEqual(names, [j["name"] for j in manifest["joints"]])
episode = data.episodes["demo_000000"]
self.assertEqual(len(episode.time), 241)
self.assertEqual(episode.time[-1], 4)
self.assertAlmostEqual(episode.wrist_position[:, 0].max(), 0.01)
self.assertAlmostEqual(2 * np.arccos(episode.wrist_quaternion[:, 0].min()), 0.1, places=5)
followers = {eq["joint"] for eq in manifest["source_urdf"]["mimic"]}
for j in manifest["joints"]:
if j["name"] not in followers:
self.assertAlmostEqual(
episode.joint_position[:, names.index(j["name"])].max(), min(0.1, 0.25 * j["upper_rad"])
)
for eq in manifest["source_urdf"]["mimic"]:
np.testing.assert_allclose(
episode.joint_position[:, names.index(eq["joint"])],
eq["multiplier"] * episode.joint_position[:, names.index(eq["reference"])],
atol=1e-7,
)
for field in ("wrist_position", "wrist_quaternion", "joint_position"):
np.testing.assert_allclose(getattr(episode, field)[0], getattr(episode, field)[-1], atol=1e-7)
validate_reference(episode, Limits())
resampled = sample(episode, np.arange(961) / 240)
validate_reference(resampled, Limits())
self.assertEqual(len(resampled.time), 961)
before = output.read_bytes()
refused = subprocess.run(command, capture_output=True, text=True, timeout=30)
self.assertNotEqual(refused.returncode, 0)
self.assertEqual(output.read_bytes(), before)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,114 @@
"""Isolated CPU plugin-context regression; requires the installed Isaac USD schemas, not Kit."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from dex_workbench_tracking.prepared import inspect_prepared, prepare
REPO = Path(__file__).resolve().parents[3]
ASSET = REPO / "assets/robots/dex_hand/linkerhand_g20_left"
class TrackingSchemaContextTests(unittest.TestCase):
def test_registered_schema_preparation_preserves_defaults_and_rejects_context_drift(self):
installation = os.environ.get("ISAAC_PATH")
if not installation:
self.skipTest("ISAAC_PATH required for installed PhysX/Newton CPU schema regression")
installation = Path(installation)
physx = list(installation.glob("extscache/omni.usd.schema.physx-*/plugins/PhysxSchema/resources/plugInfo.json"))
self.assertEqual(len(physx), 1, "Select an unambiguous installed PhysX schema for this test")
newton = installation / "exts/omni.usd.schema.newton/usd/schema/newton/newton_usd_schemas/plugInfo.json"
self.assertTrue(newton.is_file())
manifest = json.loads((ASSET / "tracking_manifest.json").read_text())
source = ASSET / "linkerhand_g20_left/linkerhand_g20_left.usda"
with tempfile.TemporaryDirectory() as directory:
directory = Path(directory)
unregistered = directory / "unregistered.usda"
registered = directory / "registered.usda"
result = prepare(source, unregistered, manifest)
self.assertEqual(result["status"], "STATIC_ONLY_SCHEMA_UNREGISTERED")
self.assertEqual(result, inspect_prepared(unregistered, manifest))
# Registering plugins in a child prevents pollution of other CPU tests.
code = """
import json, sys
from pathlib import Path
from pxr import Usd
from dex_workbench_tracking.asset import dependencies
from dex_workbench_tracking.prepared import inspect_prepared, register_schema_plugins
source, manifest_path, unregistered, registered, *plugins = sys.argv[1:]
manifest = json.loads(Path(manifest_path).read_text())
register_schema_plugins(plugins)
try:
inspect_prepared(unregistered, manifest)
except ValueError as error:
assert "regenerate" in str(error), error
else:
raise AssertionError("Unregistered authoring accepted with registered schemas")
before = dependencies(source)
# Exercise the actual CLI registration option in a fresh CPU interpreter too.
import subprocess
command = [sys.executable, "-m", "dex_workbench_tracking.prepared", source,
"--manifest", manifest_path, "--output", registered]
for plugin in plugins:
command += ["--schema-plugin-path", plugin]
run = subprocess.run(command, capture_output=True, text=True, timeout=60)
assert run.returncode == 0, run.stderr
result = inspect_prepared(registered, manifest)
assert result["status"] == "STATIC_ELIGIBLE_RUNTIME_UNVERIFIED"
assert all(result["schema_context"].values())
assert dependencies(source) == before
original = Usd.Stage.Open(source)
stage = Usd.Stage.Open(registered)
anchor = manifest["world_fixed_joints"][0]["path"]
root = stage.GetPrimAtPath(manifest["root_body_path"])
for attr in original.GetPrimAtPath(anchor).GetAttributes():
if attr.GetName().startswith("physxArticulation:"):
for prim in (stage.GetPrimAtPath(anchor), root):
after = prim.GetAttribute(attr.GetName())
assert after.Get() == attr.Get(), attr.GetPath()
assert after.HasAuthoredValue(), attr.GetPath()
assert not after.GetTimeSamples(), attr.GetPath()
# Registered schema fallbacks do not relax the static/time-sample gate.
root.GetAttribute("physxArticulation:sleepThreshold").Set(99, Usd.TimeCode(1))
stage.GetRootLayer().Save()
try:
inspect_prepared(registered, manifest)
except ValueError as error:
assert "Time samples prohibited" in str(error), error
else:
raise AssertionError("Sampled solver property accepted")
root.GetAttribute("physxArticulation:sleepThreshold").ClearAtTime(Usd.TimeCode(1))
stage.GetRootLayer().Save()
inspect_prepared(registered, manifest)
print("PASS: isolated registered schema author/inspect, exact default preservation, strict time samples")
"""
result = subprocess.run(
[
sys.executable,
"-c",
code,
str(source),
str(ASSET / "tracking_manifest.json"),
str(unregistered),
str(registered),
str(physx[0]),
str(newton),
],
capture_output=True,
text=True,
timeout=90,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
# The inverse schema mismatch is actionable too; never pretend unknown
# vendor fallbacks are equivalent in an unregistered process.
with self.assertRaisesRegex(ValueError, "regenerate"):
inspect_prepared(registered, manifest)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,90 @@
"""CPU startup/fast-shutdown regressions for the two source-named Kit entries."""
import builtins
import contextlib
import importlib.util
import io
import sys
import types
import unittest
from pathlib import Path
from unittest.mock import patch
import numpy as np
REPO = Path(__file__).resolve().parents[3]
class TrackingStartupTests(unittest.TestCase):
def check_entry(self, filename, inspection_module, inspection_name):
events = []
class FakeApp:
def close(self, *, exit_code):
events.append(("close", exit_code))
# Model Kit fast shutdown, which never returns to a pending raise.
raise SystemExit(exit_code)
class FakeLauncher:
@staticmethod
def add_app_launcher_args(parser):
pass
def __init__(self, args):
events.append("launch")
self.app = FakeApp()
def reject_asset(*args):
events.append("inspect")
raise ValueError("deliberate asset rejection")
app_module = types.ModuleType("isaaclab.app")
app_module.AppLauncher = FakeLauncher
inspector = types.ModuleType(inspection_module)
setattr(inspector, inspection_name, reject_asset)
inspector.require_backend = inspector.validate_mimic = None
original_import = builtins.__import__
def checked_import(name, *args, **kwargs):
if name.startswith(("dex_workbench_tracking", "pxr")):
self.assertIn("launch", events, f"USD-dependent import before AppLauncher: {name}")
return original_import(name, *args, **kwargs)
entry = REPO / "scripts/tracking" / filename
spec = importlib.util.spec_from_file_location("tracking_entry_test", entry)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
asset = REPO / "assets/robots/dex_hand/linkerhand_g20_left/tracking_manifest.json"
argv = [str(entry), str(asset)]
if filename == "track_l20.py":
argv += ["--manifest", str(asset), "--execute-experimental"]
stderr = io.StringIO()
with (
patch.dict(
sys.modules,
{
"isaaclab.app": app_module,
inspection_module: inspector,
"numpy": np,
"torch": types.ModuleType("torch"),
},
),
patch.object(sys, "argv", argv),
patch.object(builtins, "__import__", checked_import),
contextlib.redirect_stderr(stderr),
):
with self.assertRaises(SystemExit) as error:
module.main()
self.assertEqual(error.exception.code, 1)
self.assertEqual(events, ["launch", "inspect", ("close", 1)], stderr.getvalue())
self.assertIn("ValueError: deliberate asset rejection", stderr.getvalue())
def test_track_l20_launches_before_inspection_and_preserves_failure(self):
self.check_entry("track_l20.py", "dex_workbench_tracking.prepared", "inspect_prepared")
def test_inspect_l20_scene_launches_before_inspection_and_preserves_failure(self):
self.check_entry("inspect_l20_scene.py", "dex_workbench_tracking.asset", "inspect")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
"""CPU regression tests for dex_workbench_tracking.trajectory and CLI; no Isaac imports."""
import copy
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
import h5py
import numpy as np
from dex_workbench_tracking.cli import publish_validated, synthetic, write
from dex_workbench_tracking.trajectory import ContractError, load, require_dynamic_replay_ready, sample
class TrackingTrajectoryTests(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
self.path = Path(self.directory.name) / "fixture.hdf5"
self.manifest = {
"manifest_version": "l20_asset_manifest_v1",
"asset_sha256": "a" * 64,
"root_link": "test_root",
"joints": [
{"name": "finger_a", "lower_rad": -1, "upper_rad": 1},
{"name": "finger_b", "lower_rad": -1, "upper_rad": 1},
],
"source_urdf": {
"mimic": [{"joint": "finger_b", "reference": "finger_a", "multiplier": 0.5, "offset_rad": 0.0}]
},
}
write(self.path, synthetic(self.manifest))
def edit(self, function):
with h5py.File(self.path, "r+") as file:
function(file)
def test_load_roundtrip(self):
data = load(self.path, self.manifest)
self.assertEqual(data.joint_names, ("finger_a", "finger_b"))
self.assertEqual(data.metadata["provenance"], "synthetic")
self.assertEqual(data.episodes["demo_000000"].joint_position.shape, (121, 2))
def test_identity_and_order_mismatch(self):
for key, value in (("asset_sha256", "b" * 64), ("root_link", "other")):
manifest = copy.deepcopy(self.manifest)
manifest[key] = value
with self.subTest(key=key), self.assertRaises(ContractError):
load(self.path, manifest)
manifest = copy.deepcopy(self.manifest)
manifest["joints"].reverse()
with self.assertRaisesRegex(ContractError, "order"):
load(self.path, manifest)
def test_missing_attributes(self):
for name in ("root_link", "provenance", "source_description", "scale_to_meters", "metric_scale_provenance"):
with h5py.File(self.path, "r+") as file:
value = file.attrs[name]
del file.attrs[name]
with self.subTest(name=name), self.assertRaises(ContractError):
load(self.path)
self.edit(lambda file: file.attrs.__setitem__(name, value))
def test_wrong_attributes(self):
for name, bad in (
("schema_version", "v2"),
("embodiment", "L30"),
("hand_side", "right"),
("provenance", "unknown"),
("asset_sha256", "xyz"),
("root_link", ""),
("scale_to_meters", 0.0),
("scale_to_meters", float("nan")),
("scale_to_meters", "1"),
("scale_to_meters", [1.0]),
):
with h5py.File(self.path, "r+") as file:
original = file.attrs[name]
file.attrs[name] = bad
with self.subTest(name=name, bad=bad), self.assertRaises(ContractError):
load(self.path)
self.edit(lambda file: file.attrs.__setitem__(name, original))
def test_duplicate_or_wrong_joint_strings(self):
self.edit(lambda file: file["metadata/joint_names"].__setitem__(1, "finger_a"))
with self.assertRaisesRegex(ContractError, "duplicate"):
load(self.path)
with h5py.File(self.path, "r+") as file:
del file["metadata/joint_names"]
file["metadata"].create_dataset("joint_names", data=[b"a", b"b"])
with self.assertRaisesRegex(ContractError, "UTF-8"):
load(self.path)
def test_invalid_transform(self):
for bad in (np.diag([2, 1, 1, 1]), np.diag([-1, 1, 1, 1]), np.zeros((4, 4))):
self.edit(lambda file: file["metadata/world_from_source"].__setitem__(slice(None), bad))
with self.assertRaisesRegex(ContractError, "world_from_source"):
load(self.path)
def test_transform_is_not_applied_again(self):
self.edit(lambda file: file["metadata/world_from_source"].__setitem__((0, 3), 10))
data = load(self.path)
self.assertEqual(data.world_from_source[0, 3], 10)
self.assertEqual(data.episodes["demo_000000"].wrist_position[0, 0], 0)
def test_episode_name_and_empty_group(self):
with h5py.File(self.path, "r+") as file:
file.move("episodes/demo_000000", "episodes/demo_bad")
with self.assertRaisesRegex(ContractError, "invalid episode"):
load(self.path)
self.edit(lambda file: file.__delitem__("episodes/demo_bad"))
with self.assertRaisesRegex(ContractError, "nonempty"):
load(self.path)
def test_shapes_and_dtypes(self):
for name, value in (
("time", np.arange(121, dtype=np.float32)),
("wrist_position", np.zeros((121, 2), dtype=np.float32)),
("joint_position", np.zeros((121, 2), dtype=np.float64)),
("valid", np.ones(121, dtype=np.uint8)),
):
with h5py.File(self.path, "r+") as file:
group = file["episodes/demo_000000"]
original = group[name][...]
del group[name]
group.create_dataset(name, data=value)
with self.subTest(name=name), self.assertRaises(ContractError):
load(self.path)
with h5py.File(self.path, "r+") as file:
group = file["episodes/demo_000000"]
del group[name]
group.create_dataset(name, data=original)
def test_bad_time(self):
for index, value in ((0, -1), (1, 0), (2, 0.001), (1, np.nan)):
with h5py.File(self.path, "r+") as file:
time = file["episodes/demo_000000/time"]
original = time[index]
time[index] = value
with self.subTest(index=index, value=value), self.assertRaises(ContractError):
load(self.path)
self.edit(lambda file: file["episodes/demo_000000/time"].__setitem__(index, original))
def test_nan_even_in_invalid_frame(self):
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(2, False))
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), np.nan))
with self.assertRaisesRegex(ContractError, "NaN/Inf"):
load(self.path)
def test_quaternion_normalization_and_sign(self):
self.edit(lambda file: file["episodes/demo_000000/wrist_quaternion"].__setitem__(2, [2, 0, 0, 0]))
with self.assertRaisesRegex(ContractError, "unit"):
load(self.path)
self.edit(lambda file: file["episodes/demo_000000/wrist_quaternion"].__setitem__(2, [-1, 0, 0, 0]))
with self.assertRaisesRegex(ContractError, "sign"):
load(self.path)
def test_all_invalid_rejected(self):
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(slice(None), False))
with self.assertRaisesRegex(ContractError, "no valid"):
load(self.path)
def test_limits_and_mimic_inconsistency(self):
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), 2))
with self.assertRaisesRegex(ContractError, "limit violation"):
load(self.path, self.manifest)
self.edit(lambda file: file["episodes/demo_000000/joint_position"].__setitem__((2, 0), 0.2))
with self.assertRaisesRegex(ContractError, "mimic"):
load(self.path, self.manifest)
def test_slerp_and_linear_interpolation(self):
episode = load(self.path).episodes["demo_000000"]
# Isolated two-frame analytic rotation with a 180-degree endpoint, wxyz.
from dex_workbench_tracking.trajectory import Episode
simple = Episode(
np.array([0.0, 1.0]),
np.array([[0, 0, 0], [2, 4, 6]], dtype=np.float32),
np.array([[1, 0, 0, 0], [0, 0, 0, 1]], dtype=np.float32),
np.array([[0, 0], [1, 2]], dtype=np.float32),
np.array([True, True]),
)
sampled = sample(simple, [0, 0.5, 1])
np.testing.assert_allclose(sampled.wrist_quaternion[1], [np.sqrt(0.5), 0, 0, np.sqrt(0.5)], atol=1e-7)
np.testing.assert_allclose(sampled.wrist_position[1], [1, 2, 3])
np.testing.assert_allclose(sampled.joint_position[1], [0.5, 1])
np.testing.assert_allclose(sample(episode, episode.time).joint_position, episode.joint_position)
np.testing.assert_allclose(np.linalg.norm(sample(episode, [0, 0.001]).wrist_quaternion, axis=1), 1, atol=1e-7)
def test_no_extrapolation_or_bad_queries(self):
episode = load(self.path).episodes["demo_000000"]
for query in ([], [-0.1], [2.1], [0, 0], [1, 0], [np.nan], [[0.1]]):
with self.subTest(query=query), self.assertRaises(ContractError):
sample(episode, query)
def test_invalid_gap_never_bridged(self):
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(60, False))
episode = load(self.path).episodes["demo_000000"]
for query in ([1], [0.99], [1.01], [0, 2]):
with self.subTest(query=query), self.assertRaisesRegex(ContractError, "invalid"):
sample(episode, query)
self.assertEqual(len(sample(episode, [0, 0.5]).time), 2)
self.assertEqual(len(sample(episode, [1.5, 2]).time), 2)
def test_no_overwrite(self):
before = self.path.read_bytes()
with self.assertRaises(OSError):
write(self.path, synthetic(self.manifest))
self.assertEqual(self.path.read_bytes(), before)
def test_validated_publication_failure_leaves_no_output(self):
output = Path(self.directory.name) / "invalid.hdf5"
data = synthetic(self.manifest)
data.episodes["demo_000000"].wrist_quaternion[:] = 0
with self.assertRaises(ContractError):
publish_validated(output, data, self.manifest)
self.assertFalse(output.exists())
self.assertFalse(list(output.parent.glob(".l20-tracking-*")))
def test_validated_publication_preserves_existing_file(self):
before = self.path.read_bytes()
with self.assertRaises(FileExistsError):
publish_validated(self.path, synthetic(self.manifest), self.manifest)
self.assertEqual(self.path.read_bytes(), before)
self.assertFalse(list(self.path.parent.glob(".l20-tracking-*")))
def test_cli_full_turn_downsampling_roundtrip(self):
with h5py.File(self.path, "r+") as file:
group = file["episodes/demo_000000"]
time = group["time"][:]
quaternion = np.zeros((len(time), 4), dtype=np.float32)
quaternion[:, 0] = np.cos(np.pi * time / 2)
quaternion[:, 3] = np.sin(np.pi * time / 2)
group["wrist_quaternion"][:] = quaternion
load(self.path, self.manifest)
manifest = Path(self.directory.name) / "manifest.json"
manifest.write_text(json.dumps(self.manifest))
output = Path(self.directory.name) / "sparse.hdf5"
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "0.5", "--output", output)
self.assertEqual(result.returncode, 0, result.stderr)
quaternion = load(output, self.manifest).episodes["demo_000000"].wrist_quaternion
self.assertEqual(quaternion.shape, (2, 4))
self.assertGreaterEqual(np.dot(quaternion[0], quaternion[1]), 0)
np.testing.assert_allclose(quaternion[:, 0], 1, atol=1e-6)
# Hemisphere repair cannot recover motion lost through undersampling.
np.testing.assert_allclose(quaternion[:, 1:], 0, atol=1e-6)
def test_dynamic_gate_cannot_be_enabled_by_flag(self):
for manifest in (self.manifest, {"dynamic_replay_ready": True, "coupling_status": "VERIFIED"}):
with self.assertRaisesRegex(ContractError, "BLOCKED"):
require_dynamic_replay_ready(manifest)
def run_cli(self, *args):
return subprocess.run(
[sys.executable, "-m", "dex_workbench_tracking.cli", *map(str, args)],
capture_output=True,
text=True,
timeout=30,
env=os.environ.copy(),
)
def test_cli_validate_and_resample(self):
manifest = Path(self.directory.name) / "manifest.json"
manifest.write_text(json.dumps(self.manifest))
output = Path(self.directory.name) / "resampled.hdf5"
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "100", "--output", output)
self.assertEqual(result.returncode, 0, result.stderr)
data = load(output, self.manifest)
self.assertEqual(len(data.episodes["demo_000000"].time), 201)
result = self.run_cli("validate", output)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout)["asset_compatibility"], "NOT_CHECKED")
self.edit(lambda file: file.attrs.__setitem__("hand_side", "right"))
result = self.run_cli("validate", self.path)
self.assertNotEqual(result.returncode, 0)
self.assertIn("FAIL", result.stderr)
def test_cli_gate_and_invalid_gap_fail_closed(self):
manifest = Path(self.directory.name) / "manifest.json"
manifest.write_text(json.dumps(self.manifest))
result = self.run_cli("replay-check", "--manifest", manifest)
self.assertEqual(result.returncode, 2)
self.assertIn("BLOCKED", result.stderr)
output = Path(self.directory.name) / "resampled.hdf5"
self.edit(lambda file: file["episodes/demo_000000/valid"].__setitem__(60, False))
result = self.run_cli("resample", self.path, "--manifest", manifest, "--hz", "60", "--output", output)
self.assertEqual(result.returncode, 1)
self.assertFalse(output.exists())
def test_cpu_import_does_not_register_isaac(self):
code = (
"import dex_workbench_tracking.trajectory, sys; "
"assert not any(k.startswith(('isaac', 'omni', 'dex_workbench.')) for k in sys.modules)"
)
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
if __name__ == "__main__":
unittest.main()