Skip to content

Commit c297518

Browse files
run_policy: dry-run modes, synthetic interface, ORT pin, recorder cpu pin
Robot-local work captured from fmd-rccu-1: - DebugConfig.dry_run / dryer_run: run the loop without sending commands (dry_run) or without any bridge/hardware via a fabricated-state SyntheticInterface (dryer_run). - base.py: wire the synthetic interface for dryer_run; pin ONNX Runtime to a single intra-op thread so it stays off the isolated RT cores. - SessionRecorder: pin ros2 bag record to its own core (DebugConfig.rosbag_cpu, default 2) so it stops stealing cycles from the policy core.
1 parent 4a356e6 commit c297518

4 files changed

Lines changed: 282 additions & 13 deletions

File tree

src/holosoma_inference/holosoma_inference/config/config_types/task.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,20 @@ class DebugConfig:
2525
force_zero_action: bool = False
2626
"""Zero out the scaled policy action (robot holds default pose)."""
2727

28+
dry_run: bool = False
29+
"""Run the full policy loop (obs, inference, postprocess) but do NOT send
30+
commands to the robot. Safe for bring-up / on-hardware testing: everything
31+
computes and logs as normal, but send_low_command is skipped so no torque
32+
is applied. Pair with print_observations to inspect what would be sent."""
33+
34+
dryer_run: bool = False
35+
"""Like ``dry_run``, but ALSO fabricate the robot state instead of reading
36+
it from a bridge/driver. Swaps in a synthetic interface that returns a
37+
plausible standing pose (default_dof_angles, upright IMU, zero velocities)
38+
and never opens a DDS connection, so the loop runs with no sim/hardware
39+
present at all. Implies ``dry_run`` (never sends commands). Use to smoke-test
40+
config loading, ONNX inference, and the obs pipeline off-robot."""
41+
2842
record_rosbag: bool = False
2943
"""Record a per-session rosbag for the lifetime of this run_policy process.
3044
Spawns ``ros2 bag record --all`` in the background when the policy starts and
@@ -35,6 +49,13 @@ class DebugConfig:
3549
rosbag_dir: str = "~/run_policy_sessions"
3650
"""Directory for per-session rosbags recorded via ``record_rosbag``."""
3751

52+
rosbag_cpu: int = 2
53+
"""CPU core to pin the ``ros2 bag record`` process to (via ``taskset``). The
54+
recorder otherwise inherits the policy's affinity (e.g. ``taskset -c 8``) and
55+
contends with inference on that core, adding policy-loop jitter. Pin it to a
56+
general-purpose core off both the policy core and the isolated RT cores.
57+
Set to a negative value to disable pinning (inherit the parent's affinity)."""
58+
3859

3960
@dataclass(frozen=True)
4061
class Ros2DepthConsumerConfig:

src/holosoma_inference/holosoma_inference/policies/base.py

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ def _init_communication_components(self):
149149
if hasattr(self, "_shared_hardware_source"):
150150
self.interface = self._shared_hardware_source.interface
151151
return
152+
# Dryer run: fabricate state, never open a DDS connection. Bypasses the
153+
# SDK backend entirely so the loop runs with no bridge/driver present.
154+
if self.config.task.debug.dryer_run:
155+
from holosoma_inference.sdk.synthetic_interface import SyntheticInterface
156+
157+
self.interface = SyntheticInterface(
158+
self.robot_config,
159+
self.config.task.domain_id,
160+
self.config.task.interface,
161+
False,
162+
)
163+
return
152164
# The SDK's own wireless-controller path is only needed when an
153165
# "interface" channel is selected. The "joystick" channel is read
154166
# via host-side evdev (UsbJoystickInput) and does not touch the SDK.
@@ -384,9 +396,36 @@ def _setup_keyboard_listener(self):
384396
# Policy Methods
385397
# ============================================================================
386398

399+
@staticmethod
400+
def _make_ort_session_options():
401+
"""ONNX Runtime options tuned for the real-time control loop.
402+
403+
By default ORT sizes its intra-op thread pool to the CPU count AND pins
404+
one worker to each core via sched_setaffinity. That per-thread pin
405+
bypasses the kernel's ``isolcpus`` reservation, so the workers land on
406+
the isolated RT cores (10-13 here) and contend with the 1 kHz EtherCAT
407+
actuator threads. The policy is a small MLP run at 50 Hz (~1 ms
408+
single-threaded), so a multi-core pool buys nothing and the pinning is
409+
pure harm.
410+
411+
Setting ``intra_op_num_threads = 1`` (with sequential exec) means ORT
412+
builds no worker pool at all — inference runs on the calling thread,
413+
which stays within the process's inherited (0-9) affinity mask. So
414+
there is nothing left to per-core-pin; we deliberately do NOT set
415+
``session.intra_op_thread_affinities`` (ORT rejects an empty string, and
416+
a non-empty one would re-introduce explicit pinning).
417+
"""
418+
opts = onnxruntime.SessionOptions()
419+
opts.intra_op_num_threads = 1
420+
opts.inter_op_num_threads = 1
421+
opts.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL
422+
return opts
423+
387424
def setup_policy(self, model_path):
388425
"""Setup ONNX policy model and extract metadata."""
389-
self.onnx_policy_session = onnxruntime.InferenceSession(model_path)
426+
self.onnx_policy_session = onnxruntime.InferenceSession(
427+
model_path, sess_options=self._make_ort_session_options()
428+
)
390429
input_names = [inp.name for inp in self.onnx_policy_session.get_inputs()]
391430
output_names = [out.name for out in self.onnx_policy_session.get_outputs()]
392431

@@ -700,14 +739,31 @@ def policy_action(self):
700739

701740
# Stage 5: Action Pub
702741
with self.latency_tracker.measure("action_pub"):
703-
self.interface.send_low_command(
704-
self.cmd_q,
705-
self.cmd_dq,
706-
self.cmd_tau,
707-
robot_state_data[0, 7 : 7 + self.num_dofs],
708-
kp_override=kp_override,
709-
kd_override=kd_override,
710-
)
742+
# dry_run: the interface is the REAL bridge/driver, so we must NOT
743+
# call send_low_command at all. dryer_run: the interface is the
744+
# SyntheticInterface, whose send_low_command only publishes to a
745+
# synthetic measurement topic (nothing reaches the robot), so we DO
746+
# call it — that's what produces the measurable command stream.
747+
if self.config.task.debug.dry_run and not self.config.task.debug.dryer_run:
748+
# Log once so it's unmistakable no torque is being applied.
749+
if not getattr(self, "_dry_run_logged", False):
750+
logger.warning(
751+
colored(
752+
"DRY RUN: policy loop active but send_low_command is SKIPPED "
753+
"(no commands sent to the robot).",
754+
"yellow",
755+
)
756+
)
757+
self._dry_run_logged = True
758+
else:
759+
self.interface.send_low_command(
760+
self.cmd_q,
761+
self.cmd_dq,
762+
self.cmd_tau,
763+
robot_state_data[0, 7 : 7 + self.num_dofs],
764+
kp_override=kp_override,
765+
kd_override=kd_override,
766+
)
711767

712768
# Telemetry hook: fires every control tick in every state (policy,
713769
# stiff-hold, init ramp) with the final executed command. Default
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Synthetic (offline) interface for ``--task.debug.dryer-run``.
2+
3+
Fabricates a plausible standing robot state instead of reading it from a sim
4+
bridge or hardware driver, so the policy loop runs with nothing else present.
5+
Used to smoke-test config loading, ONNX inference, and the observation pipeline
6+
off-robot.
7+
8+
For measurement, ``send_low_command`` publishes the commands the policy *would*
9+
send to a **separate synthetic topic** (``/dryer_run/cmd``, ``sensor_msgs/
10+
JointState``) that no real driver subscribes to — so you can ``ros2 topic hz /
11+
dryer_run/cmd`` / ``echo`` / ``bag record`` the loop's output while nothing is
12+
ever sent to the actual robot. If ROS2 is unavailable (pure offline run) it
13+
degrades to a no-op and stays fully offline.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import numpy as np
19+
from loguru import logger
20+
21+
from holosoma_inference.config.config_types import RobotConfig
22+
from holosoma_inference.sdk.base.base_interface import BaseInterface
23+
24+
# Deliberately NOT a real driver topic (e.g. /alpha/joints/cmd). Nothing on the
25+
# robot subscribes here, so publishing is safe — it's for measurement only.
26+
_SYNTHETIC_CMD_TOPIC = "/dryer_run/cmd"
27+
28+
29+
class SyntheticInterface(BaseInterface):
30+
"""Returns a fixed, upright standing state; publishes cmds to a fake topic.
31+
32+
State layout matches :meth:`BaseInterface.get_low_state`:
33+
``[base_pos(3), quat(4), dof_pos(N), lin_vel(3), ang_vel(3), dof_vel(N)]``
34+
followed by an appended ``projected_gravity(3)`` (upright).
35+
"""
36+
37+
def __init__(
38+
self,
39+
robot_config: RobotConfig,
40+
domain_id: int = 0,
41+
interface_str: str | None = None,
42+
use_joystick: bool = False,
43+
publish_cmd: bool = True,
44+
cmd_topic: str = _SYNTHETIC_CMD_TOPIC,
45+
):
46+
super().__init__(robot_config, domain_id, interface_str, use_joystick)
47+
48+
default_angles = getattr(robot_config, "default_dof_angles", None)
49+
if default_angles is not None and len(default_angles) > 0:
50+
self._dof_pos = np.asarray(default_angles, dtype=float)
51+
else:
52+
n = len(getattr(robot_config, "dof_names", []) or [])
53+
self._dof_pos = np.zeros(n if n > 0 else 1)
54+
self._num_dof = self._dof_pos.shape[0]
55+
56+
dof_names = list(getattr(robot_config, "dof_names", []) or [])
57+
if len(dof_names) != self._num_dof:
58+
dof_names = [f"joint_{i}" for i in range(self._num_dof)]
59+
self._dof_names = dof_names
60+
61+
# Upright: identity quaternion (w,x,y,z), gravity straight down in the
62+
# base frame, zero linear/angular velocity, zero joint velocity.
63+
self._quat = np.array([1.0, 0.0, 0.0, 0.0])
64+
self._projected_gravity = np.array([0.0, 0.0, -1.0])
65+
66+
self._kp_level = 1.0
67+
self._kd_level = 1.0
68+
69+
logger.warning(
70+
"DRYER RUN: using SyntheticInterface — state is fabricated (upright, "
71+
f"default pose, zero velocities across {self._num_dof} DOF) and NO "
72+
"connection to any bridge/driver is opened."
73+
)
74+
75+
# Optional command publishing to a synthetic topic for measurement.
76+
self._cmd_topic = cmd_topic
77+
self._cmd_pub = None
78+
self._node = None
79+
self._JointState = None
80+
if publish_cmd:
81+
self._setup_cmd_publisher()
82+
83+
def _setup_cmd_publisher(self) -> None:
84+
try:
85+
import rclpy
86+
from rclpy.node import Node
87+
from rclpy.qos import QoSProfile, ReliabilityPolicy
88+
from sensor_msgs.msg import JointState
89+
except Exception as exc: # ROS2 not sourced → stay fully offline.
90+
logger.warning(
91+
f"DRYER RUN: command publishing requested but ROS2 is unavailable "
92+
f"({exc}); running fully offline — no synthetic cmd topic."
93+
)
94+
return
95+
96+
if not rclpy.ok():
97+
rclpy.init()
98+
self._node = Node("dryer_run_synthetic")
99+
qos = QoSProfile(depth=10, reliability=ReliabilityPolicy.RELIABLE)
100+
self._cmd_pub = self._node.create_publisher(JointState, self._cmd_topic, qos)
101+
self._JointState = JointState
102+
logger.warning(
103+
f"DRYER RUN: publishing synthetic commands on {self._cmd_topic} "
104+
"(sensor_msgs/JointState: name/position/velocity/effort) for "
105+
"measurement. No real driver subscribes to this topic — nothing is "
106+
"sent to the robot."
107+
)
108+
109+
def get_low_state(self) -> np.ndarray:
110+
return np.concatenate(
111+
[
112+
np.zeros(3), # base_pos
113+
self._quat, # quat (w,x,y,z)
114+
self._dof_pos, # dof_pos
115+
np.zeros(3), # base lin_vel
116+
np.zeros(3), # base ang_vel
117+
np.zeros(self._num_dof), # dof_vel
118+
self._projected_gravity, # projected_gravity (upright)
119+
]
120+
).reshape(1, -1)
121+
122+
def send_low_command(
123+
self,
124+
cmd_q: np.ndarray,
125+
cmd_dq: np.ndarray,
126+
cmd_tau: np.ndarray,
127+
dof_pos_latest: np.ndarray = None,
128+
kp_override: np.ndarray = None,
129+
kd_override: np.ndarray = None,
130+
) -> None:
131+
# Only ever publishes to the synthetic measurement topic — never to a
132+
# real driver. If publishing is disabled/unavailable, this is a no-op.
133+
if self._cmd_pub is None:
134+
return
135+
# On shutdown (Ctrl-C / SIGTERM) the rclpy context can be torn down
136+
# while a final publish is in flight — guard so exit stays clean.
137+
try:
138+
import rclpy
139+
140+
if not rclpy.ok():
141+
return
142+
except Exception:
143+
return
144+
msg = self._JointState()
145+
msg.header.stamp = self._node.get_clock().now().to_msg()
146+
msg.name = self._dof_names
147+
msg.position = np.asarray(cmd_q, dtype=float).reshape(-1).tolist()
148+
msg.velocity = np.asarray(cmd_dq, dtype=float).reshape(-1).tolist()
149+
msg.effort = np.asarray(cmd_tau, dtype=float).reshape(-1).tolist()
150+
try:
151+
self._cmd_pub.publish(msg)
152+
except Exception:
153+
# Context invalidated mid-publish during shutdown — ignore.
154+
return
155+
156+
def get_joystick_msg(self):
157+
return None
158+
159+
def get_joystick_key(self, wc_msg=None):
160+
return None
161+
162+
@property
163+
def kp_level(self):
164+
return self._kp_level
165+
166+
@kp_level.setter
167+
def kp_level(self, value):
168+
self._kp_level = float(value)
169+
170+
@property
171+
def kd_level(self):
172+
return self._kd_level
173+
174+
@kd_level.setter
175+
def kd_level(self, value):
176+
self._kd_level = float(value)

src/holosoma_inference/holosoma_inference/utils/session_recorder.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,24 @@ class SessionRecorder:
3636
that won't start must never take down the policy run.
3737
"""
3838

39-
def __init__(self, enabled: bool, out_dir: str = "~/run_policy_sessions", storage: str = "mcap"):
39+
def __init__(
40+
self,
41+
enabled: bool,
42+
out_dir: str = "~/run_policy_sessions",
43+
storage: str = "mcap",
44+
cpu: int = 2,
45+
):
4046
self.enabled = enabled
4147
self.out_dir = Path(out_dir).expanduser()
4248
self.storage = storage
49+
self.cpu = cpu
4350
self.bag_path: Path | None = None
4451
self._proc: subprocess.Popen | None = None
4552

4653
@classmethod
4754
def from_debug_config(cls, debug: DebugConfig) -> SessionRecorder:
48-
"""Build from a task ``DebugConfig`` (reads ``record_rosbag`` / ``rosbag_dir``)."""
49-
return cls(enabled=debug.record_rosbag, out_dir=debug.rosbag_dir)
55+
"""Build from a task ``DebugConfig`` (reads ``record_rosbag`` / ``rosbag_dir`` / ``rosbag_cpu``)."""
56+
return cls(enabled=debug.record_rosbag, out_dir=debug.rosbag_dir, cpu=debug.rosbag_cpu)
5057

5158
def __enter__(self) -> Self:
5259
if not self.enabled:
@@ -62,9 +69,18 @@ def __enter__(self) -> Self:
6269
"-o", str(self.bag_path),
6370
]
6471
# fmt: on
72+
# Pin the recorder off the policy core: as a child of the (often
73+
# taskset-pinned) policy process it would otherwise inherit the
74+
# policy's CPU affinity and steal cycles from inference, adding
75+
# control-loop jitter. taskset -c <cpu> moves it to its own core.
76+
if self.cpu >= 0:
77+
cmd = ["taskset", "-c", str(self.cpu), *cmd]
6578
# setsid: own process group so we can SIGINT only the recorder on exit.
6679
self._proc = subprocess.Popen(cmd, preexec_fn=os.setsid) # noqa: PLW1509
67-
logger.info(f"💾 Recording session rosbag → {self.bag_path} (ros2 bag record --all)")
80+
logger.info(
81+
f"💾 Recording session rosbag → {self.bag_path} "
82+
f"(ros2 bag record --all{f', taskset -c {self.cpu}' if self.cpu >= 0 else ''})"
83+
)
6884
except Exception as exc: # never let recording break the run
6985
logger.warning(f"Could not start session rosbag recorder: {exc}")
7086
self._proc = None

0 commit comments

Comments
 (0)