Metadata-Version: 2.5
Name: isaaclab-robot-doctor
Version: 0.1.0
Summary: Sanity-check a URDF/USD robot for NVIDIA Isaac Lab and generate a starting ArticulationCfg. No GPU, no Isaac Sim.
Project-URL: Homepage, https://github.com/Lynn-hh/isaaclab-robot-doctor
Project-URL: Issues, https://github.com/Lynn-hh/isaaclab-robot-doctor/issues
Author-email: Lin He <lynnhe02@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: articulation,isaac-lab,isaac-sim,robotics,urdf,usd
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.10
Requires-Dist: numpy>=1.21
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Requires-Dist: ruff>=0.5; extra == 'dev'
Provides-Extra: usd
Requires-Dist: usd-core>=23.5; extra == 'usd'
Description-Content-Type: text/markdown

# isaaclab-robot-doctor

One command that looks at your robot description (URDF or USD) and tells you what will go wrong
when you import it into [NVIDIA Isaac Lab](https://github.com/isaac-sim/IsaacLab), then writes a
starting `ArticulationCfg` for it. It runs on a laptop: no GPU, no Isaac Sim, no mesh files opened.

```
isaaclab-robot-doctor check   robot.urdf      # findings grouped ERROR / WARN / INFO, exit 1 on any ERROR
isaaclab-robot-doctor gen-cfg robot.urdf --name robot   # Python source for an ArticulationCfg
```

It exists because the same questions keep coming up when people bring their own robot to Isaac
Lab: masses and inertias that PhysX rejects, descriptions in millimetres, joints with no effort
limit, mimic joints, kinematic loops, a base that should have been fixed, joint indices that do not
match the file order, and "what PD gains do I start with?" (Isaac Lab issues #2783, #4918, #4129).

## Install

```
pip install 'isaaclab-robot-doctor[usd]'
```

Until the first PyPI release lands, install from GitHub instead:

```
pip install 'isaaclab-robot-doctor[usd] @ git+https://github.com/Lynn-hh/isaaclab-robot-doctor'
```

The `[usd]` extra pulls in `usd-core` (Pixar's USD wheel) for `.usd` / `.usda` / `.usdc` input.
Without it the tool still reads URDF. Python 3.10 or newer; the only hard dependency is numpy.

## `check`

```
$ isaaclab-robot-doctor check fr3_franka_hand.urdf
fr3_franka_hand.urdf: robot 'fr3' (URDF): 14 links, 13 joints (9 movable; 7 revolute, 4 fixed, 2 prismatic)

ERROR (0)

WARN (1)
  joint.mimic [fr3_finger_joint2]: joint 'fr3_finger_joint2' mimics 'fr3_finger_joint1' (multiplier 1, offset 0)
      fix: the follower stays a joint of its own in Isaac Lab; the shipped Franka/Robotiq cfgs give it a zero-gain ImplicitActuatorCfg group and rely on a mimic constraint authored in the USD - check your converted asset for that constraint (URDF importer 3.0 dropped the convert_mimic_joints_to_normal_joints option)

INFO (6)
  mass.massless_fixed_link [base]: link 'base' has no mass and is a root whose joints are all fixed
      fix: fine with merge_fixed_joints=True (the UrdfConverterCfg default merges fixed-joint children into their parent); with merge_fixed_joints=False it would become a massless rigid body - check
  mass.massless_fixed_link [fr3_link8]: link 'fr3_link8' has no mass and is attached to its parent by a fixed joint
      fix: fine with merge_fixed_joints=True (the UrdfConverterCfg default merges fixed-joint children into their parent); with merge_fixed_joints=False it would become a massless rigid body - check
  mass.massless_fixed_link [fr3_hand_tcp]: link 'fr3_hand_tcp' has no mass and is attached to its parent by a fixed joint
      fix: fine with merge_fixed_joints=True (the UrdfConverterCfg default merges fixed-joint children into their parent); with merge_fixed_joints=False it would become a massless rigid body - check
  tree.no_collision: 3 link(s) have no collision geometry: base, fr3_link8, fr3_hand_tcp
      fix: they cannot make contact; fine for internal frames, otherwise add <collision> (or set UrdfConverterCfg.collision_from_visuals=True to reuse the visual meshes)
  base.floating [base]: root link 'base' is not attached to a world link (floating base)
      fix: manipulator: pass fix_base=True (UrdfFileCfg) or fix_root_link=True (UsdFileCfg); legged/mobile robot: leave the base free and spawn it above the ground with init_state.pos
  order.joints: breadth-first joint order from 'base': fr3_joint1, fr3_joint2, fr3_joint3, fr3_joint4, fr3_joint5, fr3_joint6, fr3_joint7, fr3_finger_joint1, fr3_finger_joint2 - same as the file order
      fix: Isaac Lab docs: the physics joint order is breadth-first over the kinematic tree (siblings in authored joint order), not the file order; read Articulation.joint_names at runtime and address joints with joint_names_expr regexes / find_joints, never by index

0 errors, 1 warnings, 6 infos -> OK
```

(`fr3_franka_hand.urdf` is the Franka FR3 description from
[franka_description](https://github.com/frankaemika/franka_description), as redistributed in
newton's `franka_emika_panda` asset.)

`--json` prints the same findings as JSON (`id`, `severity`, `message`, `hint`, `subject`) plus a
summary; `--no-info` hides the INFO group. Exit codes: 0 clean, 1 at least one ERROR, 2 the file
could not be read.

## `gen-cfg`

```
$ isaaclab-robot-doctor gen-cfg fr3_franka_hand.urdf --name fr3 --fixed-base
```

```python
"""ArticulationCfg for fr3, generated by isaaclab-robot-doctor 0.1.0 from fr3_franka_hand.urdf.

Field names follow Isaac Lab's develop branch (October 2026). Starting PD gains use
stiffness = 1.1 x joint_effort_limit and damping = 0.04 x stiffness, the medians
over the ImplicitActuatorCfg entries shipped in isaaclab_assets. They are starting points:
tune them with a step-response test on your robot before training anything.
"""

from isaaclab_newton.sim.schemas import NewtonArticulationCfg
from isaaclab_physx.sim.schemas import PhysxArticulationCfg, PhysxRigidBodyCfg

import isaaclab.sim as sim_utils
from isaaclab.actuators import ImplicitActuatorCfg
from isaaclab.assets.articulation import ArticulationCfg

FR3_CFG = ArticulationCfg(
    # set prim_path where the cfg is used, e.g. FR3_CFG.replace(prim_path="{ENV_REGEX_NS}/Robot")
    spawn=sim_utils.UrdfFileCfg(
        asset_path="/path/to/fr3_franka_hand.urdf",
        fix_base=True,
        merge_fixed_joints=True,
        rigid_props=PhysxRigidBodyCfg(disable_gravity=False, max_depenetration_velocity=5.0),
        articulation_props=[
            PhysxArticulationCfg(
                enabled_self_collisions=False, solver_position_iteration_count=8, solver_velocity_iteration_count=0
            ),
            NewtonArticulationCfg(self_collision_enabled=False),
        ],
    ),
    init_state=ArticulationCfg.InitialStateCfg(
        joint_pos={
            "fr3_joint1": 0.0,
            "fr3_joint2": 0.0,
            "fr3_joint3": 0.0,
            "fr3_joint4": -1.597,
            "fr3_joint5": 0.0,
            "fr3_joint6": 2.531,
            "fr3_joint7": 0.0,
            "fr3_finger_joint1": 0.0,
            "fr3_finger_joint2": 0.0,
        },
    ),
    actuators={
        "arm": ImplicitActuatorCfg(
            joint_names_expr=["fr3_joint[1-7]"],
            joint_effort_limit={"fr3_joint[1-4]": 87.0, "fr3_joint[5-7]": 12.0},
            joint_velocity_limit={"fr3_joint[1-4]": 2.62, "fr3_joint(5|7)": 5.26, "fr3_joint6": 4.18},
            stiffness={"fr3_joint[1-4]": 95.7, "fr3_joint[5-7]": 13.2},
            damping={"fr3_joint[1-4]": 3.83, "fr3_joint[5-7]": 0.528},
        ),
        "gripper": ImplicitActuatorCfg(
            joint_names_expr=["fr3_finger_joint1"],
            joint_effort_limit=100.0,
            joint_velocity_limit=0.2,
            stiffness=110.0,
            damping=4.4,
        ),
        "fr3_finger_joint2_passive": ImplicitActuatorCfg(
            joint_names_expr=["fr3_finger_joint2"],
            joint_effort_limit=100.0,
            joint_velocity_limit=0.2,
            # mimic follower of 'fr3_finger_joint1': zero gains, as in Isaac Lab's shipped Franka cfg;
            # if the converted USD has no mimic constraint, give it the leader's gains instead
            stiffness=0.0,
            damping=0.0,
        ),
    },
    soft_joint_pos_limit_factor=1.0,
)
"""Configuration of fr3 (9 actuated joints)."""
```

The output above is verbatim except for `asset_path`, which is the absolute path of the input file.

Options: `--usd-path PATH` spawns from an already converted USD (`UsdFileCfg`) instead of the input
file; `--fixed-base` / `--floating-base` override what the file says (a URDF without a `world`
link is assumed floating, and the generated code says so); `-o FILE` writes to a file.

What the generator does:

* **Actuator groups** come from joint-name families: trailing indices and `joint` suffixes are
  stripped (`panda_joint1..7` becomes one family, `wrist_1_joint..wrist_3_joint` another),
  singletons that share a last or first token are merged (`FL_hip_joint` + `FR_hip_joint` ->
  `.*_hip_joint`; `shoulder_pan_joint` + `shoulder_lift_joint` -> `shoulder_.*`), revolute and
  prismatic joints never share a group, and mimic followers get their own zero-gain group. Every
  `joint_names_expr` is verified with `re.fullmatch` (what Isaac Lab uses) to match each joint
  exactly once; when no clean pattern exists the joint names are listed explicitly.
* **Limits** are copied from the description. When they differ inside a group the field becomes a
  dict keyed by sub-patterns, the style used in Isaac Lab's own Franka config. Joints without a
  limit are left out of the dict and marked with a `TODO`.
* **`joint_pos`** is the midpoint of the position limits, or 0 when 0 lies within them.
* **Field names** are those of Isaac Lab's `develop` branch as of October 2026
  (`joint_effort_limit`, `joint_velocity_limit`, `PhysxArticulationCfg`, `fix_base` on
  `UrdfFileCfg`). Isaac Lab 2.x used `effort_limit_sim`, `velocity_limit_sim` and
  `sim_utils.ArticulationRootPropertiesCfg(fix_root_link=...)`; rename by hand if you are on 2.x.

## The gain rule, and where it comes from

```
stiffness = 1.1 x joint_effort_limit
damping   = 0.04 x stiffness
```

These are not derived from physics. They are the medians over every `ImplicitActuatorCfg` in
Isaac Lab's shipped robot configs (`source/isaaclab_assets/isaaclab_assets/robots/*.py`, develop,
October 2026), read with a small AST script:

| ratio                            | n   | median | 1st-3rd quartile |
|----------------------------------|-----|--------|------------------|
| stiffness / joint_effort_limit   | 106 | 1.125  | 0.5 - 10         |
| damping / stiffness              | 104 | 0.038  | 0.022 - 0.057    |

The quartiles tell the real story: the shipped configs span two orders of magnitude for the same
ratio (a UR10 arm uses 9.2, Unitree legs use 0.5-0.7, a gripper may use 0.01). The medians give a
PD controller that is neither floppy nor explosive on a typical arm, which is all a starting point
can promise. Run a step-response test and tune from there; the generated file says so in its
docstring.

## What `check` looks at

| id | severity | what it catches |
|----|----------|-----------------|
| `urdf.xacro` | ERROR | unexpanded xacro (`${...}` or `<xacro:...>` outside comments) |
| `names.duplicate`, `names.empty` | ERROR | duplicate or empty link/joint names |
| `names.invalid_prim_name` | WARN | names that are not valid USD prim names (spaces, dots, dashes, leading digit); the converted USD will rename them |
| `tree.unknown_link` | ERROR | joint references a link that does not exist |
| `tree.kinematic_loop`, `tree.multiple_parents`, `tree.no_root` | ERROR | the joint graph is not a tree (PhysX articulations must be trees) |
| `tree.multiple_roots` | ERROR | more than one root link |
| `mass.missing` | ERROR | a link that moves on its own has no mass or mass <= 0 |
| `mass.tiny` | WARN | mass below 1 g (large mass ratios destabilise PhysX articulations) |
| `mass.massless_fixed_link` | INFO | massless link attached by a fixed joint; merged away by `merge_fixed_joints=True` |
| `mass.from_density` | WARN / INFO | USD body without explicit mass; PhysX computes it from collider volume x density |
| `inertia.missing` | WARN | mass given but no inertia tensor |
| `inertia.not_symmetric`, `inertia.not_positive_definite` | ERROR | invalid inertia tensor |
| `inertia.triangle_inequality` | WARN | principal moments violate Ia + Ib >= Ic (not physically realisable) |
| `joint.limit_missing` | ERROR | revolute/prismatic joint without `<limit>` |
| `joint.limits_inverted` | ERROR | lower > upper |
| `joint.zero_range` | WARN | lower == upper (including URDF's default of 0 when both are omitted) |
| `joint.effort_missing`, `joint.velocity_missing` | WARN | no effort / velocity limit to feed the actuator cfg |
| `joint.prismatic_range` | WARN | prismatic travel above 5 m (usually millimetres) |
| `joint.continuous` | INFO | continuous joint: unlimited in UsdPhysics, so wrap angles in observations |
| `joint.mimic`, `joint.mimic_unknown` | WARN / ERROR | mimic joints (see the hint above) |
| `joint.unsupported_type` | WARN / INFO | floating, planar, spherical, distance or D6 joints, which this tool does not model |
| `tree.no_collision` | INFO | links without collision geometry |
| `units.primitive_size`, `units.joint_origin` | WARN | primitive shape over 5 m or under 1 mm, or a joint origin over 5 m from its parent: looks like millimetres |
| `units.mesh_scale_mm`, `units.mesh_scale` | INFO / WARN | mesh scaled by 0.001 (fine) or by an unusual factor |
| `units.stage_meters_per_unit` | WARN | USD stage not in metres |
| `base.fixed`, `base.floating` | INFO | whether the root is attached to the world, with the matching `fix_base` / `fix_root_link` hint |
| `order.joints` | INFO | the breadth-first joint order Isaac Lab will expose, and whether it differs from the file order |
| `usd.no_articulation_root`, `usd.multiple_articulation_roots`, `usd.no_rigid_bodies` | ERROR / WARN | USD files missing the schemas `ArticulationCfg` needs |
| `usd.joint_no_drive` | INFO | USD joints without `UsdPhysics.DriveAPI` |

Every hint that describes Isaac Lab behaviour was checked against the Isaac Lab source or docs
(`develop`, October 2026). Where the behaviour of the Isaac Sim URDF importer could not be verified
from the Isaac Lab code, the hint says "check" rather than asserting.

## Limitations

* Mesh files are never opened, so unit checks rely on primitive shapes, mesh `scale` and joint
  origins. A robot described purely with unscaled meshes in millimetres is only caught through
  its joint origins.
* The USD reader reads UsdPhysics schemas only. `physxJoint:maxJointVelocity` is read by name and
  assumed to be in deg/s for revolute joints (PhysX schema documentation); mimic joints are
  detected from an applied `PhysxMimicJointAPI` schema. The inertia check uses only the principal
  moments, not the principal axes.
* Nothing is simulated. The gains are a table lookup, the joint order is the breadth-first order
  the Isaac Lab docs describe (OVPhysX orders siblings by joint type then path, which can differ).
* MJCF input is not supported yet.

## Roadmap

* GPU step-response PD tuning: spawn the generated config in Isaac Sim, apply a position step to
  each actuator group and fit stiffness/damping to a target rise time and overshoot.
* MJCF input.
* A flag to emit Isaac Lab 2.x field names.

## Development

```
git clone https://github.com/Lynn-hh/isaaclab-robot-doctor
cd isaaclab-robot-doctor
pip install -e '.[usd,dev]'
ruff check . && ruff format --check . && pytest
```

The FR3 smoke test is skipped unless `FR3_URDF` points at `fr3_franka_hand.urdf` (or newton's
asset cache is present). The hand-written URDF/USDA fixtures live in `tests/assets/`.

## Not affiliated with NVIDIA

This is an independent tool. Isaac Lab, Isaac Sim and PhysX are NVIDIA products; this project is
not endorsed by or associated with NVIDIA. Field names and behaviour were checked against the
public Isaac Lab repository and may lag behind it.

## License

Apache-2.0. Copyright 2026 Lin He.
