Skip to content

UrdfManager: two of the same arm, iterative mounts, and per-mount frame names - #5105

Open
jeff-hykin wants to merge 108 commits into
jeff/feat/usd_managerfrom
jeff/feat/usd_manager_namespace
Open

jeff-hykin wants to merge 108 commits into
jeff/feat/usd_managerfrom
jeff/feat/usd_manager_namespace

Conversation

@jeff-hykin

@jeff-hykin jeff-hykin commented Nov 6, 2026 •

Copy link
Copy Markdown
Member

Lets the same module be mounted twice by resolving mounts iteratively (with cycle detection) against post-namespace frame names, applying per-subtree frame prefixes and tf_renames, and republishing every mesh asset under a conflict-free /dimos/assets/ path.

ModuleBase.frame_id already puts a module's own frame behind frame_id_prefix, but a
module that stamps or looks up frames by other names of its own (a URDF link, a
sensor mount) had nowhere to go. ConnectionModule had grown exactly that helper
privately; lift it to ModuleBase so the next module needing it does not grow a third
copy.

Blueprint.namespace() sets frame_id_prefix, so this is what keeps two of the same
robot, or two of the same arm, from colliding on tf: they share a description and so
share link names.

Behaviour is unchanged for ConnectionModule beyond an empty name now staying empty
rather than becoming "prefix/".
…ink frames

Two of the same arm, or two of the same robot, were not expressible. Both halves of
the problem were that a URDF-derived name is shared by every copy of that URDF:

- physical_connections addressed a module by its class, and RPCClient.remote turns a
  class into an RPC topic by class name, so two instances of one class resolved to
  the same topic. Values are now normalized to a PhysicalConnection, which carries
  the deployed instance name alongside the class. A class still works and still means
  "the only instance of itself"; the useful new form is a namespaced blueprint,
  XArm.blueprint(...).namespace("left"), which names the exact instance it deploys.
  An instance name on its own is accepted too, for a module whose class is not
  importable here, at the cost of the fail-fast get_usd check.

  The subtree client cache is keyed by remote name for the same reason.

- the joint tf went out under the raw URDF link names. It now goes through frame(),
  which Blueprint.namespace() has already pointed at the right prefix, and
  UrdfManagerPointClearing looks its root link and multi-DOF joint edges up the same
  way so it still finds what the manager published.

Both are no-ops without a namespace: frame_id_prefix is None by default, and nothing
in the tree sets physical_connections yet.

A blueprint value is resolved to the one atom answering get_usd, so passing an arm's
whole blueprint (arm plus its driver) works; a blueprint with none of them, or more
than one, is rejected with the names it found.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

go2 replay realtime (arm64)

Details
Benchmark suite Current: 6c3076d Previous: 5bc57a0 Ratio
peak memory 1742.41 MB 1776.91 MB 0.98
peak threads 369 threads 376 threads 0.98
network (transport) 2733.227 MB 2730.972 MB 1.00
disk write 1.824 MB 1.59 MB 1.15
instructions 138.679 G 138.52 G 1.00

This comment was automatically generated by workflow using github-action-benchmark.

@codecov

codecov Bot commented Nov 6, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 61.07784% with 455 lines in your changes missing coverage. Please review.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
dimos/robot/test_urdf_manager.py 48.86% 315 Missing ⚠️
dimos/robot/urdf_manager.py 54.64% 121 Missing and 6 partials ⚠️
dimos/robot/joint_tf.py 92.85% 5 Missing and 4 partials ⚠️
dimos/robot/test_joint_tf.py 98.50% 1 Missing and 1 partial ⚠️
dimos/robot/urdf_manager_point_clearing.py 83.33% 1 Missing and 1 partial ⚠️
@@                    Coverage Diff                    @@
##           jeff/feat/usd_manager    #5105      +/-   ##
=========================================================
- Coverage                  82.60%   82.46%   -0.15%     
=========================================================
  Files                       1870     1872       +2     
  Lines                     183606   184719    +1113     
  Branches                   15445    15505      +60     
=========================================================
+ Hits                      151674   152333     +659     
- Misses                     27956    28400     +444     
- Partials                    3976     3986      +10     
Components Coverage Δ
Tests 94.91% <57.78%> (-0.37%) ⬇️
Flag Coverage Δ
OS-macos-latest 77.21% <61.07%> (-0.12%) ⬇️
OS-ubuntu-24.04-arm 76.98% <61.07%> (-0.11%) ⬇️
OS-ubuntu-latest 77.34% <61.07%> (-0.12%) ⬇️
Py-3.10 77.34% <61.07%> (-0.12%) ⬇️
Py-3.11 77.32% <61.07%> (-0.12%) ⬇️
Py-3.12 77.33% <61.07%> (-0.12%) ⬇️
SelfHosted-Linux 40.41% <30.79%> (-0.07%) ⬇️
SelfHosted-macOS 40.14% <30.88%> (-0.07%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
dimos/robot/galaxea/r1pro/test_joint_tf.py 100.00% <100.00%> (ø)
dimos/robot/test_joint_tf.py 98.50% <98.50%> (ø)
dimos/robot/urdf_manager_point_clearing.py 83.73% <83.33%> (-0.34%) ⬇️
dimos/robot/joint_tf.py 92.85% <92.85%> (ø)
dimos/robot/urdf_manager.py 57.91% <54.64%> (-33.76%) ⬇️
dimos/robot/test_urdf_manager.py 61.69% <48.86%> (-37.43%) ⬇️

... and 6 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

This reverts commit 0375047.

The prefix is going into the description at graft time instead, so neither UrdfManager
nor UrdfManagerPointClearing reads frame_id_prefix any more, and with no new caller the
helper has nothing to earn its place with. ConnectionModule gets its private copy back.
… frame_id_prefix

Two of the same arm still need one of them renamed, but the rename belongs in the
description rather than in a runtime attribute. A module holds one description and does
not know which copy of itself it is; the name it was deployed under says exactly that.
So UrdfManager asks a namespaced module for its USD and moves every frame in the answer
under that namespace itself.

prefix_usd_frames(usd, prefix) rewrites the dimos:link and dimos:joint attributes, which
are the names the description travels under: a link name is a tf frame_id, and a joint
name is what a JointState keys on, so leaving the joints behind would be the same
collision one layer down. Prim names cannot hold a "/" and prim paths are already unique
by nesting, so neither has to move, and the joints' body relationships keep working.

The prefix comes from PhysicalConnection.namespace: everything before the last "/" of the
instance name, the same rule the coordinator's _atom_namespace uses, so a nested
namespace ("fleet/robot0/xarm") prefixes whole.

The root's own links are never renamed. It is one URDF, so its names are already unique,
and the physical_connections keys stay the spelling the user wrote them in.

This drops the frame_id_prefix reading added earlier on this branch: joint tf goes out
under the URDF's link names again, and UrdfManagerPointClearing looks them up unchanged.
An empty prefix returns the same string object, so an un-namespaced subtree is not even
re-exported.
… name

namespace() sets instance_name and frame_id_prefix together, so deriving one from the
other usually agrees -- but it keeps a frame_id_prefix the user set by hand, and that
override is precisely the answer to "what are this module's frames called". So
PhysicalConnection carries a frame_prefix read straight off the atom's kwargs.

An instance name passed on its own still falls back to the namespace in the name: there
is no object there to read anything off.
@jeff-hykin jeff-hykin changed the title UrdfManager: two of the same arm — namespaced subtree RPC and prefixed URDF link frames UrdfManager: two of the same arm — namespaced subtree RPC and USD frame renaming Nov 6, 2026
Resolved in favour of this branch's shape, with physical_connections -> mounts and
PhysicalConnection -> Mount applied across it. Mount is what a mount holds: the module,
the name it answers RPC under, and the prefix its frames take.
Same reason as the one on the base branch: test_no_sections forbids a comment wrapped in
separator dashes, and the test names already say what each group covers.
Follows the base branch off custom attributes: prefix_usd_frames now rewrites displayName,
and gives a prim one from its prim name when it has none, because a prefixed name like
"left/arm" is exactly what a prim name cannot hold.

It walks the prims that stand for a link (UsdPhysics.RigidBodyAPI) or a joint
(UsdPhysics.Joint) rather than the ones carrying an attribute of ours, so the geometry prims
and the defaultPrim are left alone as before.

The stand-in subtree in the tests now speaks the same standard dialect urdf_to_usd emits,
which is what it was always meant to stand in for.
A USD layer names its meshes relative to something, and only the module that answered for it
knows what. Without that root travelling alongside, a subtree's mesh paths are unresolvable
the moment they leave the process that produced them: the answering module's cwd is not the
asker's, and two subtrees need not share a root at all. So get_usd and get_scene now answer
with [asset_root, usd], and get_scene rebases each subtree onto the root its own module
reported before grafting, which is what lets it answer with a single root for the whole scene.

resolve_usd_assets(usd, root) does the rebasing, on the standard reference arcs at the layer
level. An absolute path is left alone, and an empty root means "nothing to resolve".

A malformed answer -- not two strings, or an empty layer -- is treated like a module that is
not up yet and asked again rather than grafted blindly.

get_scene has no deadline any more. A mount is a module the blueprint says is physically
there, so not answering is a thing to wait out, not to give up on; half a scene would hand a
renderer a robot with no arm. scene_timeout_s is gone with the TimeoutError. Since waiting
forever silently is its own failure, it now warns every 20 asks.
A vendor description names its frames whatever it names them, which is rarely what the robot
they are bolted to calls them. ``Mount.of(arm).renaming(pre=..., post=...)`` says so, on
either side of the namespace prefix:

    .renaming(pre={"base_link": "arm_base"})        -> left/arm_base
    .renaming(post={"left/base_link": "left_arm"})  -> left_arm

Both sides exist because either can be the easier one to write. ``pre`` is in the subtree's
own names, as its own description spells them, which is what you have in front of you when
reading a vendor URDF. ``post`` is in the prefixed names, for when the prefix is not the shape
you wanted at all -- it is the only way to get a name that does not start with the namespace.

So _adopt now goes rename, prefix, rename. Both maps reach joints as well as links, since a
joint name is a JointState key and needs renaming for the same reasons.

rename_usd_frames reads every name before writing any, so a -> b alongside b -> a is a swap
rather than both collapsing onto one. A key matching no link or joint is an error naming what
the subtree does have: a misspelt frame would otherwise be a silent no-op that only shows up
later as a scene whose frames are not the ones asked for.

renaming() merges rather than replaces, like the chainable edits on RobotModel, so two callers
can each add one.
@jeff-hykin jeff-hykin changed the title UrdfManager: two of the same arm — namespaced subtree RPC and USD frame renaming UsdManager: two of the same arm — namespaced subtree RPC and per-mount frame names Nov 7, 2026
@jeff-hykin jeff-hykin changed the title UsdManager: two of the same arm — namespaced subtree RPC and per-mount frame names UrdfManager: two of the same arm — namespaced subtree RPC and per-mount frame names Nov 7, 2026
Both branches had dropped it; resolved in favour of this one, which carries the asset-root
answer and the per-mount renames on top.
…pace

# Conflicts:
#	dimos/robot/urdf_manager.py
…exist before stop can run

Merging the static-tf publisher up: a namespaced manager must put its fixed
edges through frame() like its moving ones, or the office scene would publish
un-prefixed frames. The thread's fields are initialised before _check_mounts, so
the dispose() that a rejected mount triggers does not read them first -- the same
trap as the publisher that stop() touched before __init__ had made it.
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Nov 10, 2026
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Nov 10, 2026
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Nov 10, 2026
@github-actions github-actions Bot added ready-to-merge Required CI checks have passed on this PR and removed ready-to-merge Required CI checks have passed on this PR labels Nov 10, 2026
Comment thread dimos/robot/urdf_manager.py Outdated

SUBTREE_RPC = "get_usd"
SCENE_RPC = "get_scene"
ASSET_RPC = "get_asset"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: fix unused var

if mount.subtree_rpc == SCENE_RPC:
answer = getattr(client, SCENE_RPC)(chain)
else:
answer = getattr(client, SUBTREE_RPC)()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: make sure this isn't recursion on UsdManager

root = self._asset_root()
return found if found.is_absolute() or not root else Path(root) / found

def static_transforms(self) -> list[Transform]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo: this needs to happen on a per-robot basis.

Pim, on #5102: "todo: don't delete prexisting comments/docstrings". An earlier
pass in this stack stripped comments and docstrings from every file it touched,
including the ones it only edited in passing. model.py is pre-existing code, so
31 docstrings and 9 comments that main wrote went with it.

Restored from main by qualified name, so only the ones whose code is still here
came back; the ones belonging to code this PR replaced stayed out. The same
stripping hit pre-existing files on five other branches in the stack and each is
being restored on the branch that did it.
Four of Pim's todos on #5105.

"this needs to happen on a per-robot basis" (on static_transforms), which Jeff
widened to: make tooling that a module can be handed its USD, a joint-state
stream and a tf stream, and it publishes on tf by itself.

That is `dimos/robot/joint_tf.py`. `JointTf(model, joint_state, tf).start()` and
it runs: the moved-edge dedupe, the keepalive repeat a tf lookup needs, the rate
limit, and the fixed edges on their own when no joint state ever arrives (the
office scene has no controller, so nothing calls add_joint_state for it). `frame=`
is how three robots in one world get three sets of frame names. Nothing in it
knows about modules, so a robot that is not a UsdManager can use it as easily.

UsdManager now owns one instead of being one. `handle_coordinator_joint_state` is
gone because JointTf subscribes to the stream itself, which is the point;
`static_transforms`, `joint_transforms` and `add_joint_state` stay as one-liners
because other code and the point-clearing subclass call them.
`MULTI_DOF_JOINT_TYPES` moved with the code that reads it.

"fix unused var": ASSET_RPC. It is genuinely unused until `sim2/sim.py` asks for
assets, so it is deleted here and declared on the branch that first calls it.

"make sure this isn't recursion on UsdManager": it is, deliberately -- a mounted
manager is asked for its whole scene, not just its own layer, because `_tree_rpc`
picks get_scene over get_usd when the module answers both. What bounds it is the
visited chain and `_refuse_a_cycle`. Every test of that faked the chain by calling
get_scene with one already in it, so there is now a real one: two managers mounted
on each other through a client that really calls the other's get_scene, asserting
the error names `left_scene -> right_scene -> left_scene` and that each manager
was asked exactly once.

"don't delete prexisting comments/docstrings": restored in urdf_manager.py,
urdf_manager_point_clearing.py and test_urdf_manager.py from what main says.

12 new tests in test_joint_tf.py; the 107 in test_urdf_manager.py still pass.
The old static-tf thread only started `if publish_joint_tf and static_transforms()`,
and JointTf dropped the second half: a manager standing on a bare world link --
no joints, no controller wired to it -- got a thread that woke four times a second
to publish an empty list. There are ten of these in office_555.

Nothing else changes: a manager with a joint stream still starts, and so does one
with fixed edges and no stream, which is the case the thread exists for.
jeff-hykin added a commit that referenced this pull request Nov 11, 2026
Pim, on #5102: "PR1 shouldn't edit urdf manager (utils only)". This reverts
e51f020 (the static-tf thread) here; the file is byte-identical to main
again, and the first PR touches nothing but dimos/robot/assets.

The fixed-edge publishing it added is still needed -- the office scene has no
controller, so nothing ever calls add_joint_state for it -- so it moves up to
#5105, which is the PR that actually owns UrdfManager's behaviour.
@github-actions github-actions Bot removed the ready-to-merge Required CI checks have passed on this PR label Nov 11, 2026
jeff-hykin added a commit that referenced this pull request Nov 11, 2026
…t called

Pim, on #5105: "fix unused var". ASSET_RPC was declared four PRs before anything
called it, so it was deleted there and lands here instead -- `sim2/sim.py` is the
first thing that asks a manager for an asset.
jeff-hykin added a commit that referenced this pull request Nov 11, 2026
The conflict was this branch's joint_renames work against the per-robot joint->tf
tooling from #5105, which is where joint_transforms now lives. So joint_state_name
and the warn-once go into JointTf as a `joint_renames` option, and UsdManager hands
it `self.config.joint_renames` -- which is the shape the tooling wanted anyway:
everything about posing one description's joints is in one place, and a module that
is not a UsdManager gets the renaming for free.

UsdManager.joint_state_name stays as a one-liner onto it.
@github-actions github-actions Bot added the ready-to-merge Required CI checks have passed on this PR label Nov 11, 2026

This branch was successfully deployed

No deployments
cachix — 4343e33f Deployed Nov 10, 2026 by jeff-hykin via cachix-build-macos #16872
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-to-merge Required CI checks have passed on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants