A Minimal ROS2 Node for Multi-Joint Deviation Detection¶
Third standard building block toward real robotics adoption -- the single most "standard" ecosystem integration point: practically every real robotics stack (Gazebo, TurtleBot, IsaacLab, real hardware alike) runs on ROS/ROS2, a general, well-known fact about the ecosystem, not something this repo's own prior experiments checked -- an earlier draft of this sentence cited "Experiments 46-47" for that claim, which was wrong: those two experiments are cross-channel correlation and deadband gating, unrelated to simulators; fixed here rather than left silently wrong.
Honest disclosure, up front: this was never run against a live ROS2 system¶
This environment has no ROS2 installation -- checked directly before writing anything:
no rclpy module, no ros2 CLI, no Docker available either to run an official ROS2
image. Installing ROS2 natively on Windows is heavy (Chocolatey + Visual Studio Build
Tools, several GB) and wasn't authorized for this experiment. What follows is real
engineering grounded in verified, current sources -- not a claim that it has been
integration-tested live.
What was actually verified, and how¶
The rclpy API pattern. Fetched directly from ros2/examples (humble branch, the
current, real repository, not from memory) before writing anything:
# ros2/examples, rclpy/topics/minimal_publisher/.../publisher_member_function.py
class MinimalPublisher(Node):
def __init__(self):
super().__init__('minimal_publisher')
self.publisher_ = self.create_publisher(String, 'topic', 10)
...
The Node.__init__/create_subscription/create_publisher/callback pattern used in
ros2_deviation_node.py matches this exactly.
The message types. Fetched directly from ros2/common_interfaces (humble branch)
before choosing them:
# sensor_msgs/msg/JointState.msg
std_msgs/Header header
string[] name
float64[] position
float64[] velocity
float64[] effort
JointState.position is the standard field for a robot arm's joint positions -- the
subscriber reads it directly, matching the same signal type Experiments 43/46/47/49 have
been analyzing all along (LeRobot's action/observation.state).
For output, checked std_msgs' own message list directly: every *MultiArray type
(Float64MultiArray, UInt8MultiArray, etc.) carries the same note -- "deprecated as of
Foxy, recommended to create your own semantically meaningful message". Used
UInt8MultiArray anyway, disclosed rather than silently treated as current best practice:
a custom .msg needs a full package with message generation (a much heavier addition than
this single-file node), and *MultiArray remains what real ROS2 packages commonly publish
despite the recommendation. A future iteration could add a proper
dense_armor_msgs/DeviationFlags message.
What was actually run, and what wasn't¶
import rclpy fails outright in this environment, so joint_deviation_node.py itself
cannot even be imported here, let alone run. Split the callback logic into
joint_deviation_logic.py -- zero rclpy dependency, fully testable on its own -- so at
least the part that CAN be verified here, was:
def process_joint_positions(detector, position):
if len(position) != detector.n_channels:
return None
flags = detector.update(position)
return [1 if f else 0 for f in flags]
test_wrong_length_returns_none: PASSED
test_correct_length_returns_list_of_0_1: PASSED
test_matches_direct_detector_call_on_real_lerobot_data: n=303 frames, all match
test_matches_direct_detector_call_on_real_lerobot_data: PASSED
Real, executable tests (not mocked, not skipped) -- including feeding all 303 real frames
of LeRobot episode 0 through process_joint_positions and checking every single output
matches calling MultiChannelStreamingDeviationDetector directly. joint_deviation_node.py
itself only compiles syntactically (python -m py_compile, confirmed) -- the thin rclpy
wiring layer on top, genuinely untested here.
Update: a real installable ament_python package, ROS2 parameters, importing from the real library¶
The first version was a loose script -- not something a real ROS2 user could ros2 run.
Rebuilt as a proper ament_python package (package.xml/setup.py/setup.cfg/
resource/), matching ros2/examples' own minimal_publisher package layout, fetched
directly before writing anything. Two concrete gaps closed:
- ROS2 parameters, not hardcoded constructor args.
declare_parameter/get_parameter's real signatures were fetched directly fromros2/rclpy'snode.pyandparameter.pysource (humble branch) before using them --n_joints/radius/ref_mult/n_sigmasare now configurable via a launch file/YAML, the idiomatic ROS2 way, instead of requiring a code change to retarget a different robot. - Imports the real, promoted
MultiChannelStreamingDeviationDetectorfromdense_armor.utility.streaming(Dense-Armor's own library, promoted after Experiments 48-49), not a local copy --dense-armoris declared as a real pip dependency insetup.py'sinstall_requires. Re-verified this actually works: reinstalled Dense-Armor in editable mode from its local repo (reversible, restored to the prior PyPI install afterward) and re-ran the real tests against the promoted library code directly -- same result, all real LeRobot frames match.
Still not colcon-built or run against a live ROS2 system -- that remains the one gap that genuinely cannot be closed without installing ROS2 (or Docker), which needs the maintainer's explicit go-ahead given the weight of that install.
Update: actually tested live, for real, inside a real ROS2 Humble container¶
The one remaining gap is closed. With explicit authorization, this environment's real
blockers were resolved directly -- virtualization was disabled in firmware (enabled via
BIOS, verified after with Get-ComputerInfo), then WSL2's Windows components needed two
separate reboots to take effect (each verified with wsl --status before moving on, not
assumed). Docker Desktop installed in per-user mode (--user flag, no admin rights
needed), the official ros:humble image pulled, and the real package tested inside it.
A real, distinct bug found and fixed along the way: installing Dense-Armor's own source
inside the container from the standard pip install <path> flow kept silently producing a
package named UNKNOWN-0.0.0 instead of dense-armor, with no visible error at first.
Traced directly (not guessed) to the actual failure once verbose output was captured:
ros:humble's base image ships a packaging version too old for license = "BUSL-1.1"'s
SPDX expression validation, which setuptools>=77 requires packaging>=24.2 to parse --
raising ImportError: Cannot import packaging.licenses, which setuptools' own error
handling was swallowing into a silent UNKNOWN fallback rather than surfacing. Sidestepped
the whole legacy pip/setuptools/packaging compatibility chain entirely: built the wheel
where it's already proven to work (python -m build on this machine, twine check
already passing), mounted the pre-built .whl into the container, and pip installed that
directly -- no in-container build step at all.
--- TEST IMPORT REALE ---
TUTTI GLI IMPORT DI BASE FUNZIONANO
detector funzionante, flags=[False False False False False False]
--- COLCON BUILD ---
Starting >>> dense_armor_ros
Finished <<< dense_armor_ros [1.43s]
Summary: 1 package finished [2.52s]
--- TEST IMPORT NODO VIA WORKSPACE ---
NODO IMPORTABILE E COSTRUITO CORRETTAMENTE
Real, live, inside ros:humble: rclpy, sensor_msgs, std_msgs, and the real
dense_armor.utility.streaming.MultiChannelStreamingDeviationDetector all import
successfully; the detector actually runs (update() on 6 channels returns a real flag
array); colcon build succeeds against the real package structure; and after source
install/setup.bash, dense_armor_ros.joint_deviation_node.JointDeviationNode -- the
actual rclpy Node subclass -- imports and constructs from the built workspace. This is no
longer "engineering against verified sources" -- it is now genuinely live-tested.
Update: a real live rclpy.spin() run, fake publisher included¶
The one remaining honest gap -- an actual running event loop, not just an import and a
build -- is closed too. test/spin_live_test.py runs three real rclpy nodes in one
process, wired through a real SingleThreadedExecutor (the same machinery
rclpy.spin() wraps): a fake JointState publisher (90 messages: 60 quiet baseline,
30 with a synthetic +5.0 offset injected on joint 2), the real JointDeviationNode
under test (unmodified, same class colcon build produced), and a flag-collector
subscriber recording every joint_deviation_flags message that comes back.
published=90 flag_messages_received=90
baseline_any_true=False
deviation_joint_2_flagged=True
deviation_other_joints_flagged=False
LIVE SPIN TEST: PASS
All 90 published messages round-tripped through the real subscription callback -> detector -> publish chain; the baseline period produced zero flags, the injected deviation was caught on exactly the joint it was injected on, and no other joint false-positived. This is the real ROS2 callback graph actually executing, not a mock.
Honest scope¶
Live-tested inside ros:humble via Docker: colcon build, real imports, node
construction, and now a real rclpy.spin()-equivalent run with an actual message flow
end to end. Not yet tested: a genuine multi-process ROS2 graph (separate ros2 run
processes talking over real DDS across process boundaries, rather than multiple nodes
in one executor) or a real physical/simulated robot as the publisher. Consider
replacing UInt8MultiArray with a proper custom message once the package is real
enough to warrant one.
Reproducing this¶
scripts/dense_armor_streaming/ros2_package/ -- a full ament_python package:
package.xml, setup.py, setup.cfg, resource/dense_armor_ros,
dense_armor_ros/joint_deviation_logic.py (tested, rclpy-free),
dense_armor_ros/joint_deviation_node.py (rclpy wiring, ROS2 parameters),
test/test_joint_deviation_logic.py (the real logic tests -- reuses already-cached LeRobot
data, no new download).
To reproduce the live container test: build a Dense-Armor wheel locally
(python -m build), then with Docker + WSL2 available on Windows,
docker run --rm \
-v "<path-to-ros2_package>:/ws/src/dense_armor_ros:ro" \
-v "<path-to-dense-armor-dist>:/dense-armor-wheel:ro" \
ros:humble bash /ws/src/dense_armor_ros/test/full_test.sh
test/full_test.sh installs the pre-built wheel, runs test/live_container_check.py (a
plain import/detector smoke test), then colcon build --symlink-install, then imports
JointDeviationNode via the built workspace, then runs test/spin_live_test.py (the real
rclpy.spin()-equivalent fake-publisher run) -- the exact sequence that produced both
sets of output above.