Skip to content

Unified policy service node (Variant A: reuse Ros2Input)#128

Closed
kingb wants to merge 2 commits into
amazon-far:dev/tomasz/tracker_servicefrom
kingb:feat/unified-policy-service-node-varA
Closed

Unified policy service node (Variant A: reuse Ros2Input)#128
kingb wants to merge 2 commits into
amazon-far:dev/tomasz/tracker_servicefrom
kingb:feat/unified-policy-service-node-varA

Conversation

@kingb

@kingb kingb commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a unified ROS2 policy service node that runs any holosoma policy (locomotion, WBT, …) and supports runtime policy swapping — the run_policy.py experience, served over ROS2. Builds on #124, reusing its holosoma_service package, unitree_mp multiprocess proxy, TargetSource, and _on_command_sent feedback hook.

This is Variant A (reuse Ros2Input): the node touches no shared input code — loco velocity/state ride the existing Ros2Input; the node only adds a WBT dense-target bridge and a policy-agnostic feedback publisher. A companion PR proposes Variant B (single node owns all I/O via an injected input source) for comparison.

Base branch is dev/tomasz/tracker_service (#124), which this depends on — not main.

What's here

  • service_node.py — builds the policy via _select_policy_class/DualModePolicy (no extension-only policy_type), routes the Unitree SDK through unitree_mp (CycloneDDS in a child process, isolated from this process's rclpy), strips ROS args before tyro so ros2 launch/run work.
  • feedback.pyPolicyFeedbackPublisher: policy-agnostic executed_cmd + heartbeat, driven by the per-tick _on_command_sent hook; each node spun on its own SingleThreadedExecutor.
  • target_bridge.pyDenseTargetBridge: WBT-only CmdDenseTargetSource, attached only to policies exposing _target_source (loco untouched).
  • teleop_with_loco_policy.launch.py — fully parameterized (velocity_input/state_input/interface/domain_id/node_name) for composition into a parent launch.
  • Two small fixes to shared holosoma_inference: ROS2 "kill" command (parity with joystick L1+R1), and a hasattr guard in dual_mode so the SWITCH_MODE injection doesn't crash on providers without _mapping (ros2/injected).

Testing

Validated walking on a real G1 (ROS2 Jazzy) via the ros2 launch path: velocity over /cmd_vel, state on the native G1 joystick, executed_cmd at 50 Hz, heartbeat status reflecting the active policy.

kingb added 2 commits June 19, 2026 18:11
…nput

- inputs/impl/ros2: map the "kill" string to StateCommand.KILL, giving an
  in-band emergency exit over ROS2 state input (parity with the joystick L1+R1
  kill). Useful for any ros2/injected state-input setup.
- policies/dual_mode: guard the SWITCH_MODE _mapping injection with hasattr.
  Only keyboard/joystick providers expose _mapping; Ros2Input (and injected
  ROS2 providers) map the "switch_mode" string to SWITCH_MODE natively and are
  intercepted via the dispatch patch. Without the guard, dual-mode crashed with
  AttributeError when the command provider was a ROS2 input.
Add a ROS2 service node that runs any holosoma policy (locomotion, WBT, ...)
and supports runtime policy swapping -- the run_policy.py experience served
over ROS2. Refactors the WBT-only holosoma_node into reusable pieces:

- feedback.py: PolicyFeedbackPublisher -- policy-agnostic executed_cmd +
  heartbeat, driven by the per-tick _on_command_sent hook. Spins each node on
  its own SingleThreadedExecutor (rclpy.spin shares the global executor and
  collides with Ros2Input under "Executor is already spinning").
- target_bridge.py: DenseTargetBridge -- WBT-only CmdDense -> TargetSource,
  attached only to policies that expose _target_source.
- service_node.py: builds the policy via _select_policy_class/DualModePolicy
  (no extension-only policy_type), attaches the dense bridge to WBT-family
  policies, wires feedback into each policy. Routes the Unitree SDK through its
  multiprocess interface (sdk_type unitree -> unitree_mp) so the SDK CycloneDDS
  runs in a child process and never shares this process rclpy (otherwise SDK
  init heap-corrupts). Strips ROS args before tyro so ros2 launch/run work.

Loco input rides the existing Ros2Input (velocity_input/state_input=ros2); no
new input code. Adds teleop_with_loco_policy.launch.py (fully parameterized for
composition into a parent launch) + the policy_service_node entry point.

Validated walking on a real G1 (ROS2 Jazzy) via the launch file.
@kingb

kingb commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Companion alternative: #129 (Variant B — single node owns all I/O). These are two designs for the same goal; pick one. Both build on #124 and were validated walking on a real G1.

default_value="policy_service",
description="ROS2 node name (override when composing multiple instances).",
),
Node(

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.

My understanding is that service_node removes the need for holsoma_node, is that right? Or at least we can use the same entry point for WBT and loco? Do we still need separate launch files?

tomasz-lewicki pushed a commit that referenced this pull request Jun 24, 2026
## Summary

Makes the Variant B service node (`policy_service_node`) the **single
entrypoint** for both locomotion and WBT, and removes the separate
`holosoma_node`. This is the unified entrypoint we deferred when picking
Variant B in #128/#129.

Builds on #124 (`dev/tomasz/tracker_service`) — **base this branch, not
main.**

## How it works

Most of the WBT path already worked through Variant B:
`WholeBodyTrackingPolicy` honors `_target_source` attribute injection
(set post-build, read only at runtime in `rl_inference()`), and
`ServiceIONode` already subscribes `CmdDense` + serves `get_target()`.
The one gap was **policy resolution** — the node resolved via core
`_select_policy_class`, which didn't honor `config.task.policy_type` /
the `holosoma.policies.by_type` entry-point group.

## Changes

- **`dual_mode._select_policy_class`** — resolve
`config.task.policy_type` against the `holosoma.policies.by_type`
entry-point group *before* the existing robot_type/`motion_command`
heuristic. Additive and generic: core reads an optional `policy_type`
via `getattr` and resolves classes **by string** through the entry-point
group, so it never imports or hardcodes any policy class.
- **`teleop_with_holosoma_policy.launch.py`** — drive
`policy_service_node` (was `holosoma_node`); keep the
`input_type:=smplh|dense` selector + conditional retargeter; expose the
`velocity_input`/`state_input`/`interface`/`domain_id`/`node_name`
knobs.
- **Delete `holosoma_node.py`** + its `console_scripts` entry. Its dense
`TargetSource` behavior already lives in `ServiceIONode`
(`_attach_target_source`).
- **Tests** — resolution-order coverage for `_select_policy_class`;
`_attach_target_source`/`_iter_policies` coverage (ROS-guarded so it
skips off-container). Docs/diagram updated.

## Testing

- `ruff` + `ruff-format`: clean.
- `pytest src/holosoma_inference/.../test_select_policy_class.py`: 6/6
pass (real package). Service-node attach test skips off-ROS (runs in the
inference CI container).
@kingb

kingb commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

We went with implementation B #129

@kingb kingb closed this Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants