ADR-0028d: panda_mobile HAL JOINT_VELOCITY + COMPOSITE_MODE handlers (mobile base + mux flag)
- Status: Proposed
- Date: 2026-05-28
- Related: ADR-0028 (parent); ADR-0028b (slot dispatch); ADR-0028c (sibling — added CARTESIAN_DELTA + GRIPPER_POSITION); CLAUDE.md §3 (HAL is layer 0).
Context
ADR-0028c gave the panda_mobile HAL handlers for the arm
(CARTESIAN_DELTA) and gripper (GRIPPER_POSITION) slots that the
RoboCasa pi0.5 / rldx1 manifests emit. The remaining four dims of the
12-D PandaOmron + HybridMobileBase action vector — slots [7, 10]
plus the trailing composite-mode flag at slot 11 — were declared
discard: true in the rldx-rc365 / rldx-robocasa / pi05-human300
manifests with the explanation:
base_motion is 4-D and cannot be expressed as a single 6-D
BODY_TWISTslot (schema requires width=6 for[vx,vy,vz,wx,wy,wz]). Pending a non-standard-base ControlMode extension, indices 7-10 are declared discard. —rskills/rldx1-ft-rc365-nf4/rskill.yaml:189-194
Three things are wrong with that framing:
-
It's not a 4-D base. Runtime introspection of robosuite's
HybridMobileBase._action_split_indexesforPandaOmronshowsThe contiguous'right' (0, 6) arm OSC_POSE 'right_gripper' (6, 7) gripper 'base' (7, 10) 3-D JOINT_VELOCITY (forward, side, yaw) 'torso' (10, 11) 1-D JOINT_POSITION (torso slider)[7, 10]discard range was conflating the 3-D planar-base channel with the 1-D torso slider. They are two separate composite parts. -
The torso channel was never used during training. The RC365
general_embodiment.action.base_motionnormalizer inRLWRLD/RLDX-1-FT-RC365/statistics.jsonreports the 4th dim withmean=0, std=0, min=0, max=0; the same shape holds in pi05'saction.std[10] = 0. Live deploy_sim captures confirmraw_policy_action[10] = +0.000at every step. So slot 10 is padding, not torso control — discarding it leaves robosuite's torso controller integrating zero (the JOINT_POSITION controller defaults to delta semantics: input=0 ⇒ "hold current position"), which is exactly what the policy emitted during training. No torso declaration is required. -
No new ControlMode is needed. The robosuite training-time controller for the active parts maps directly onto an existing
openral_core.ControlMode:robosuite controller ←→ openral ControlMode ──────────────────────────────────────────────── OSC_POSE (delta) ↔ CARTESIAN_DELTA ✓ ADR-0028c GRIP ↔ GRIPPER_POSITION ✓ ADR-0028c JOINT_VELOCITY (base) ↔ JOINT_VELOCITY (this ADR) JOINT_POSITION (torso) ↔ (discard — zero in training)JOINT_VELOCITYis already in the enum (schemas.py:84); the HAL just lacks the routing rule mapping a 3-D chunk onto_part_slot('base').
The cost of the misframing is concrete: the rldx-rc365 policy was
trained on PickPlaceCounterToCabinet, a task that requires
mobile manipulation — the cabinet and the counter are at
different physical locations, the robot must drive its base + raise
its torso to reach both. In the diagnostic run captured 2026-05-28
the policy emitted non-trivial base channels at multiple ticks (e.g.
step 200 [7:9] = [+0.468, +0.132, -0.014]) which deploy_sim
silently dropped. The arm reached down toward the counter (50 cm
descent in 600 steps — a real consequence of the obs-side fix in
commit f52eb7c) but never closed the geometry to the bread because
the base + torso never moved.
The training controller's slot 11 — a continuous-valued sign flag
that HybridMobileBase.set_goal reads as all_action[-1] and uses
to multiplex between "manipulate" (arm OSC tracks the achieved pose;
base velocity gets directly applied) and "navigate" (arm freezes,
base drives) — is not a per-joint actuator command. The HAL
already hardcodes out[-1] = -1.0 ("manipulate") at
sim_attached.py:623-624. That is a deliberate limitation of the
slot-dispatch path: per-slot ControlMode chunks can't carry a
sim-internal multiplexer flag without leaking robosuite specifics
into the wire ABI. We accept this limitation for now (this ADR);
the rare case where the policy wants to disable arm tracking to
drive the base is left on the table and revisited if the closed-loop
benchmark numbers demand it.
Decision
Add two handlers to the panda_mobile HAL's _pack_with_composite_split
helper so the 'base' composite part AND the trailing multiplexer flag
can be written from slot-dispatched ActionChunks:
-
JOINT_VELOCITY(3-D base) — looks up_part_slot('base')at runtime via the same_action_split_indexesintrospection ADR-0028c already uses for'right'/'right_gripper'. Writes the chunk'sjoint_velocities[0]directly into those slots. The handler accepts the manifest-declaredjoint_names(["base_x", "base_y", "base_yaw"]) and validates that they resolve to a single composite part (so a future skill that names only["base_yaw"]would route just that one channel into the correct sub-slot via the same_action_split_indexes). -
Update the three RoboCasa rSkill manifests'
action_contract.slotsto split the[7, 10]discard into one routed slot + one (still-discarded) torso slot:Applies to:- {range: [7, 9], control_mode: "joint_velocity", joint_names: ["base_x", "base_y", "base_yaw"]} - {range: [10, 10], discard: true} # torso — std=0 in training - {range: [11, 11], discard: true} # composite mode flagrskills/rldx1-ft-rc365-nf4/rskill.yaml. The pi05 manifest (rskills/pi05-robocasa365-human300-nf4/rskill.yaml) also needs this exact layout — its current[7] discard, [8,10] body_twist, [11] discardis misaligned by one dim against the actual_action_split_indexesordering, so today pi05 silently routesbase_y_velinto BODY_TWIST'svxslot,base_yaw_velintovy, and the always-zero torso intowz. The 1-dim shift plus the velocity-vs-delta semantic mismatch explains the "base wanders sideways and never rotates" symptom. -
supported_control_modesinrobots/panda_mobile/robot.yamladds"joint_velocity"so the palette filter doesn't reject manifests that declare aJOINT_VELOCITYslot. The safety supervisor already enforces per-jointvelocity_limitdeclarations via ADR-0028b §5 — no new global envelope keys. -
COMPOSITE_MODE(slot 11) — the trailing dim of the rldx / pi05 12-D action is robosuite'sHybridMobileBase.set_goalmultiplexer flag (action[-1] > 0⇒ arm OSC tracks the commanded delta;≤ 0⇒ arm OSC tracks the achieved pose and is effectively frozen, while base velocity passes through). The policy emits this dynamically; hardcoding it in the HAL (as the pre-ADR-0028d code did without[-1] = -1.0) freezes the arm and gives the visual symptom "only the base moves." We promote this to a first-classCOMPOSITE_MODEControlMode(1-D, value ∈ [-1, +1], sim-only) carried end-to-end through the typed-Action ABI: openral_core.ControlMode.COMPOSITE_MODE, wire uint8 = 12, mirrored incpp/openral_safety_kernel/include/.../validator.hpp.Action.composite_mode: list[float] | Nonefield +ActionSlotvalidator branch (width=1, no ee/frame/joints).- C++ kernel routes
kCompositeModethrough the same per-mode passthrough as cartesian/twist/gripper (structural validation only — no per-joint or workspace bounds apply). SimAttachedHAL._pack_with_composite_splitwrites the value toout[-1]and early-returns (skipping the legacyout[-1] = -1.0override below).- Real-HW HAL adapters (Franka FCI + Omron Nav2 + Franka gripper) accept the mode but treat it as a no-op: their independent arm + base controllers don't need the mux.
-
rldx-rc365 / rldx-robocasa / pi05-human300 manifests' slot 11 changes from
discard: truetocontrol_mode: composite_mode. -
Torso joint declaration is deferred. Both rldx-rc365 and pi05 emit slot 10 = 0 by training-distribution construction; the existing
discardsemantics (zero in the env_action vector ⇒ JOINT_POSITION delta=0 ⇒ "hold current torso position") replays training fidelity for free. A future skill that actually exercises the torso would lift this — add atorso_zjoint torobot.yamlwithsim_joint_name: "mobilebase0_joint_torso_height"and a targetedJOINT_POSITIONHAL handler (mirror of theJOINT_VELOCITYpattern). Out of scope here.
Why "leverage robocasa's controller" doesn't mean call it directly
Tempting alternative: in SimAttachedHAL, pass the raw 12-D action
vector straight through to env.step() — let robosuite's
HybridMobileBase do its own dispatch. This is essentially what
sim_run does today (_assemble_rc365_chunk → env.step(action)).
Rejected because:
- HAL is layer 0 (CLAUDE.md §3) — the typed-Action boundary is the contract that survives the move to real hardware. A "passthrough" mode hides the action semantics behind a sim-specific black box and re-introduces the dim-12-vs-11 skew the ADR-0028 family was written to eliminate.
- The safety supervisor needs typed chunks to envelope-check per-mode (ADR-0028b §5). A passthrough bypasses that.
- The mapping is trivial. Each composite part is already a
one-line lookup against
_part_slot(name)and writes a 1-D or 3-D slice. ADR-0028c established this pattern for'right'and'right_gripper'; extending to'base'/'torso'is the same shape.
We do leverage robosuite's controller — but at the level of "use
its _action_split_indexes to know where to write," not "hand it
raw bytes." The actual MJCF actuators are still driven by
robosuite's JOINT_VELOCITY / JOINT_POSITION part controllers
once we've written into the right slots.
Consequences
Positive
rldx1-ft-rc365-nf4can now actuate its full 12-D output indeploy_sim. Closed-loop reach-to-grasp tasks onPickPlaceCounterToCabinet(and other mobile-manipulation RoboCasa scenes) become viable.- pi05's "base wanders" symptom — observed in the same deploy_sim
failure that motivated
f52eb7c— should resolve, because the semantic mismatch (BODY_TWIST = velocity expecting m/s vs the policy emitting normalized [-1, +1]) is replaced with the correct JOINT_VELOCITY routing whose normalization robosuite's controller already handles internally. - The slot-dispatch contract stays clean: every manifest dim is either explicitly routed or explicitly discarded. No passthrough escape hatch.
- The torso joint becomes a first-class entry in
RobotDescription.joints, addressable by name from any future state assembler that needs it (e.g. a sit-to-stand skill on a different scene).
Negative / accepted limitations
- Composite mode flag (slot 11) remains hardcoded to -1 ("manipulate") at the HAL — the policy can't switch modes. Acceptable because (a) the trained head emits negative slot-11 values during arm-active phases (which is the only phase where the rest of the chunk's arm channels are meaningful), and (b) the alternative is a sim-only ControlMode that pollutes the wire ABI.
- Real-hardware port for the OmronMobileBase + torso slider is
out of scope for this ADR. The HAL handler is gated on the
presence of a robosuite composite controller (i.e.
SimAttachedHAL); a futurePandaMobileHAL(real-HW) would need its own JOINT_VELOCITY / JOINT_POSITION implementations, driven from the appropriate ros2_control hardware interface. - pi05 may still have a residual closed-loop gap if its mode-flag expectations differ from the hardcoded -1; we benchmark and revisit if so.
Implementation note — kernel n_dof gate
The C++ safety kernel (cpp/openral_safety_kernel/src/validator.cpp:51-61)
enforces chunk.n_dof == envelope.n_dof for any JOINT_* mode (the
per-joint validator at line 111-128 indexes envelope.joint_velocity_max[j]
for j ∈ [0, envelope.n_dof)). A slot-dispatched 3-D JOINT_VELOCITY
chunk for the base alone trips kNdofMismatch and e-stops the robot
before the HAL ever sees it.
Two responses considered:
- (a) Extend the kernel to treat JOINT_VELOCITY as a per-mode width
when accompanied by joint_names (mirroring the existing per-mode
short-circuit for CARTESIAN / GRIPPER / BODY_TWIST). Requires IDL
change to carry joint_names on the wire.
- (b) Pad the chunk to full-dof at dispatch time, with zeros at
non-target joints. Preserves the kernel's per-joint validation and
doesn't touch the IDL.
We picked (b): the dispatcher (_dispatch_slots in
packages/openral_rskill_ros/openral_rskill_ros/rskill_runner_node.py)
uses the slot's joint_names + the robot's joint ordering to
construct a length-n_dof_total payload. The HAL's
_pack_with_composite_split then extracts the base values via
description.base_joints and writes them to _part_slot('base').
Side effect on the kernel's per-joint velocity check: the policy
emits NORMALIZED values in [-1, +1] (robosuite's HybridMobileBase
controller does the physical scaling internally), so the base joints'
velocity_limit in robot.yaml must be ≥ 1.0 for the kernel to
accept the full normalized range. We bump the three base joints
(base_x, base_y, base_yaw) to velocity_limit: 1.0 and add a
comment explaining the normalized-vs-physical distinction. Real-HW
deployment would need a scaling step at the publisher (or restore
physical limits and re-scale at the HAL boundary).
Implementation sequence
robots/panda_mobile/robot.yaml: add"joint_velocity"tocapabilities.supported_control_modes; bump base joints'velocity_limitto 1.0 (normalized contract).packages/openral_rskill_ros/openral_rskill_ros/rskill_runner_node.py: add_pad_joint_payloadhelper + extend_dispatch_slotsto acceptdescriptionand pad JOINT_* slices.python/hal/src/openral_hal/sim_attached.py:_pack_with_composite_split: add aJOINT_VELOCITYbranch that extracts base values from the full-dof payload viadescription.base_jointsand writes to_part_slot('base').- Update the three RoboCasa rSkill manifests'
action_contractto replace thediscard: truefor slots 7-10 with the 3-routed-to-joint_velocity+ 1-discarded-torso pattern. - Tests:
- Unit (
python/hal/tests/test_sim_attached_pack.py— extend or create): assert a 3-DJOINT_VELOCITYchunk lands in slots[7, 10)of the packed env vector; assert misaligned widths raiseROSConfigError. - Integration: confirm the env-side action vector carries
non-zero base velocity channels when the policy emits them
(e.g. by logging the pre-
env.stepaction in deploy_sim). - End-to-end (
tests/sim/):obj_eef_dist_mmust drop below 0.10 m by step 220 (yamlmax_steps) on at least 1 of 5 seeds — RC365's documented success rate is 31.5%. - Docs: update the manifest comment blocks to replace the "4-D non-standard-base ControlMode pending" lament with a pointer to this ADR.
Rejected alternatives
- Add a
MOBILE_BASE_DELTAControlMode (4-D). Considered first because the manifest comments anticipated it. Rejected once the live_action_split_indexesplus the normalizer stats showed the trailing dim is unused padding — the active surface is just 3-D base velocity, already covered. - Declare
torso_zand route slot 10 through JOINT_POSITION. Considered for "schema completeness." Rejected because (a) the trained policies never exercise it (std=0) so we'd be wiring a dead channel, and (b)discard ⇒ out[10]=0 ⇒ delta=0 ⇒ holdis semantically identical to training behaviour. Trivially added later if a torso-active skill ships. - Passthrough mode. Violates the typed-Action boundary, sidesteps the safety supervisor.
- Treat slot 7-9 as
BODY_TWIST(3-D planar). Rejected because BODY_TWIST is semantically m/s velocity that the HAL integrates asvelocity * dt(sim_attached.py:290-295), whereas robosuite's'base'part already exposes aJOINT_VELOCITYcontroller that handles the per-joint normalization internally. Routing through BODY_TWIST introduces a ~20× scaling pitfall (the policy emits normalized [-1, +1], BODY_TWIST expects m/s) — exactly the symptom pi05 exhibits today with its misaligned manifest.