Skip to content

Commit 6368d19

Browse files
committed
Add ROS publish transcripts and H1 metal hotpath
1 parent 91c65b5 commit 6368d19

8 files changed

Lines changed: 224 additions & 3 deletions

File tree

PORT_TO_MLX_TODO.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ without pausing for replanning after every small success.
9595
- `DONE` Env/runtime diagnostics now prove the articulated `mac-sim` contract on a real locomotion backend instead of only reporting the high-level backend seam
9696
- `DONE` ROS/planner software smokes now exercise the real `mac-planners` backend and verify typed round-trip reconstruction of planner world-state and joint trajectories
9797
- `DONE` Planner/ROS batch helpers now restore batches by `batch_index` and report actual batch envelope counts instead of inferring from message order or `max(index) + 1`
98+
- `DONE` Planner/ROS batch publish now also emits replayable plain-data transcripts with ordered input envelopes, generated CLI commands, and publish results so process-level failures can be audited after the fact without importing ROS Python bindings
9899
- `DONE` Stereo/depth smoke now validates raw capture artifacts before processing and writes a machine-checkable JSON summary artifact
99100
- `DONE` `uv run scripts/bootstrap_uv_mlx.py` now bootstraps the public MLX/mac editable environment in one command
100101
- `DONE` Upstream-compatible Franka reach/stack/open-drawer controller variants now resolve to the canonical mac-native manipulation slices through the lazy task registry, public MLX wrapper, and installed CLI without inflating the canonical public task surface

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -710,7 +710,7 @@ The current ROS 2 bridge is also intentionally plain. It focuses on message/proc
710710
- [`source/isaaclab/isaaclab/backends/ros2_compat.py`](source/isaaclab/isaaclab/backends/ros2_compat.py)
711711
- [`scripts/tools/ros2_bridge_smoke.py`](scripts/tools/ros2_bridge_smoke.py)
712712

713-
Batch publish helpers now reject mixed topic-root batches across command building, typed manifest generation, and CLI publish execution, so planner world-state and joint-trajectory envelopes cannot be silently interleaved and replayed out of contract.
713+
Batch publish helpers now reject mixed topic-root batches across command building, typed manifest generation, and CLI publish execution, and the replayable batch transcript captures ordered input envelopes, commands, and publish results for deterministic audit, so planner world-state and joint-trajectory envelopes cannot be silently interleaved and replayed out of contract.
714714

715715
Example planner smoke:
716716

@@ -733,6 +733,7 @@ This is the current compatibility contract:
733733
- planner compatibility on macOS means serializable box/sphere/capsule/mesh world updates, attachment metadata, and deterministic timed joint-space plans
734734
- ROS compatibility on macOS means plain message/process interoperability first, including ROS-friendly world-state and joint-trajectory envelopes plus typed round-trip reconstruction without importing ROS Python bindings
735735
- batched ROS planner envelopes are reconstructed by `batch_index`, not by input order, so JSONL message reordering cannot silently corrupt planner/world batch recovery
736+
- batch publish transcripts preserve planned envelopes, generated commands, and publish results as plain data so process-level failures can be audited and replayed without ROS Python bindings
736737
- CUDA stream transport, NITROS, and GXF remain future follow-on work
737738

738739
## Kernel Inventory

scripts/tools/mac_planner_smoke.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def main() -> int:
100100
trajectory_batch_envelopes = joint_motion_plan_batch_to_ros_envelopes(batch)
101101
trajectory_batch_roundtrip = joint_motion_plan_batch_from_ros_envelopes(tuple(reversed(trajectory_batch_envelopes)))
102102
ros_bridge = Ros2ProcessBridge()
103+
planner_batch_publish_transcript = ros_bridge.build_batch_publish_transcript(planner_world_batch_envelopes)
104+
trajectory_batch_publish_transcript = ros_bridge.build_batch_publish_transcript(trajectory_batch_envelopes)
103105

104106
payload = {
105107
"planner": planner.state_dict(),
@@ -115,6 +117,8 @@ def main() -> int:
115117
== [item.state_dict() for item in batch],
116118
"planner_ros_batch_pub_commands": ros_bridge.build_topic_pub_batch_commands(planner_world_batch_envelopes),
117119
"trajectory_ros_batch_pub_commands": ros_bridge.build_topic_pub_batch_commands(trajectory_batch_envelopes),
120+
"planner_ros_batch_publish_transcript": planner_batch_publish_transcript,
121+
"trajectory_ros_batch_publish_transcript": trajectory_batch_publish_transcript,
118122
}
119123
args.output.parent.mkdir(parents=True, exist_ok=True)
120124
args.output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")

scripts/tools/ros2_bridge_smoke.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,8 @@ def main() -> int:
129129
trajectory_batch_publish_manifest = [
130130
item.state_dict() for item in bridge.build_batch_publish_manifest(tuple(reversed(planner_plan_batch_envelopes)))
131131
]
132+
planner_batch_publish_transcript = bridge.build_batch_publish_transcript(tuple(reversed(planner_world_batch_envelopes)))
133+
trajectory_batch_publish_transcript = bridge.build_batch_publish_transcript(tuple(reversed(planner_plan_batch_envelopes)))
132134

133135
summary = {
134136
"cli_available": bridge.cli_available(),
@@ -153,6 +155,8 @@ def main() -> int:
153155
"trajectory_batch_pub_commands": trajectory_batch_commands,
154156
"planner_batch_publish_manifest": planner_batch_publish_manifest,
155157
"trajectory_batch_publish_manifest": trajectory_batch_publish_manifest,
158+
"planner_batch_publish_transcript": planner_batch_publish_transcript,
159+
"trajectory_batch_publish_transcript": trajectory_batch_publish_transcript,
156160
"message_summary": message_summary,
157161
}
158162
if args.summary_out is not None:

source/isaaclab/isaaclab/backends/mac_sim/hotpath.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -614,6 +614,77 @@ def _h1_leg_extension_impl(joint_pos: mx.array) -> mx.array:
614614
return mx.clip(extension, 0.58, 0.98)
615615

616616

617+
_h1_leg_extension_compiled = mx.compile(_h1_leg_extension_impl)
618+
_h1_leg_extension_metal = None
619+
_h1_leg_extension_hotpath_initialized = False
620+
H1_LEG_EXTENSION_HOTPATH_BACKEND = HOTPATH_BACKEND
621+
622+
623+
def _build_h1_leg_extension_metal_kernel():
624+
if not hasattr(mx, "fast") or not hasattr(mx.fast, "metal_kernel"):
625+
return None
626+
source = r"""
627+
uint env_id = thread_position_in_grid.x;
628+
uint joint_dim = (uint)params[0];
629+
uint base = env_id * joint_dim;
630+
for (uint leg_id = 0; leg_id < 2; ++leg_id) {
631+
uint joint_base = base + leg_id * 5;
632+
float hip_pitch = joint_pos[joint_base + 2];
633+
float knee = joint_pos[joint_base + 3];
634+
float ankle = joint_pos[joint_base + 4];
635+
float extension = 0.40f
636+
+ 0.20f * metal::cos(hip_pitch + 0.20f)
637+
+ 0.26f * metal::cos(hip_pitch + knee - 0.10f)
638+
+ 0.08f * metal::cos(hip_pitch + knee + ankle);
639+
extension_out[env_id * 2 + leg_id] = metal::clamp(extension, 0.58f, 0.98f);
640+
}
641+
"""
642+
try:
643+
return mx.fast.metal_kernel(
644+
name="h1_leg_extension",
645+
input_names=["joint_pos", "params"],
646+
output_names=["extension_out"],
647+
source=source,
648+
)
649+
except Exception:
650+
return None
651+
652+
653+
def _ensure_h1_leg_extension_hotpath() -> None:
654+
global _h1_leg_extension_metal
655+
global _h1_leg_extension_hotpath_initialized
656+
global H1_LEG_EXTENSION_HOTPATH_BACKEND
657+
if _h1_leg_extension_hotpath_initialized:
658+
return
659+
_h1_leg_extension_hotpath_initialized = True
660+
_h1_leg_extension_metal = _build_h1_leg_extension_metal_kernel()
661+
if _h1_leg_extension_metal is not None:
662+
H1_LEG_EXTENSION_HOTPATH_BACKEND = "mlx-metal-h1-leg-extension"
663+
664+
665+
def get_h1_leg_extension_hotpath_backend() -> str:
666+
_ensure_h1_leg_extension_hotpath()
667+
return H1_LEG_EXTENSION_HOTPATH_BACKEND
668+
669+
670+
def h1_leg_extension_hotpath(joint_pos: mx.array) -> mx.array:
671+
_ensure_h1_leg_extension_hotpath()
672+
joint_pos = mx.array(joint_pos[:, :10], dtype=mx.float32)
673+
if _h1_leg_extension_metal is None:
674+
return _h1_leg_extension_compiled(joint_pos)
675+
if int(joint_pos.shape[0]) == 0:
676+
return mx.zeros((0, 2), dtype=mx.float32)
677+
params = mx.array([int(joint_pos.shape[1])], dtype=mx.float32)
678+
outputs = _h1_leg_extension_metal(
679+
inputs=[joint_pos, params],
680+
grid=(int(joint_pos.shape[0]), 1, 1),
681+
threadgroup=(64, 1, 1),
682+
output_shapes=[(int(joint_pos.shape[0]), 2)],
683+
output_dtypes=[mx.float32],
684+
)
685+
return outputs[0]
686+
687+
617688
def _h1_body_positions_impl(
618689
root_pos_w: mx.array,
619690
joint_pos: mx.array,
@@ -630,7 +701,7 @@ def _h1_body_positions_impl(
630701
hip_pitch = leg_pos[:, :, 2]
631702
knee = leg_pos[:, :, 3]
632703
ankle = leg_pos[:, :, 4]
633-
extension = _h1_leg_extension_impl(joint_pos)
704+
extension = h1_leg_extension_hotpath(joint_pos)
634705
command_speed = mx.linalg.norm(commands[:, :2], axis=1, keepdims=True)
635706
phase = gait_phase.reshape((num_envs, 1)) + gait_phase_offsets.reshape((1, 2))
636707
swing = mx.maximum(mx.sin(phase), 0.0) * (0.18 + 0.65 * command_speed)
@@ -659,7 +730,6 @@ def _h1_body_positions_impl(
659730

660731

661732
anymal_body_positions_hotpath = mx.compile(_anymal_body_positions_impl)
662-
h1_leg_extension_hotpath = mx.compile(_h1_leg_extension_impl)
663733
h1_body_positions_hotpath = mx.compile(_h1_body_positions_impl)
664734

665735

source/isaaclab/isaaclab/backends/ros2_compat.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919

2020
ROS2_MESSAGE_ENVELOPE_SCHEMA_VERSION = 1
21+
ROS2_BATCH_PUBLISH_TRANSCRIPT_SCHEMA_VERSION = 1
2122

2223

2324
def _normalize_message_value(value: Any) -> Any:
@@ -399,6 +400,73 @@ def build_batch_publish_manifest(
399400
for batch_index, envelope in indexed_envelopes
400401
]
401402

403+
def build_batch_publish_transcript(
404+
self,
405+
envelopes: list[Ros2MessageEnvelope] | tuple[Ros2MessageEnvelope, ...],
406+
*,
407+
once: bool = True,
408+
records: list[Ros2BatchPublishRecord] | tuple[Ros2BatchPublishRecord, ...] | None = None,
409+
) -> dict[str, Any]:
410+
"""Build a replayable batch publish transcript for audit and deterministic replay."""
411+
412+
if not envelopes:
413+
return {
414+
"schema_version": ROS2_BATCH_PUBLISH_TRANSCRIPT_SCHEMA_VERSION,
415+
"kind": "ros2_batch_publish_transcript",
416+
"publish_state": "planned",
417+
"topic_root": None,
418+
"message_count": 0,
419+
"batch_indices": [],
420+
"topic_sequence": [],
421+
"msg_type_sequence": [],
422+
"command_sequence": [],
423+
"input_envelope_sequence": [],
424+
"record_sequence": [],
425+
"record_count": 0,
426+
"observed_record_count": 0,
427+
"success": None,
428+
"replayable": True,
429+
}
430+
431+
indexed_envelopes = _sorted_batch_envelopes(envelopes, topic_root=_topic_root_for_batch(envelopes[0].topic))
432+
planned_records = [
433+
Ros2BatchPublishRecord(
434+
batch_index=batch_index,
435+
topic=envelope.topic,
436+
msg_type=envelope.msg_type,
437+
command=tuple(self.build_topic_pub_command(envelope, once=once)),
438+
)
439+
for batch_index, envelope in indexed_envelopes
440+
]
441+
transcript_records = planned_records if records is None else list(records)
442+
if len(transcript_records) > len(planned_records):
443+
raise ValueError("record count cannot exceed planned batch size")
444+
publish_state = "planned" if records is None else "published"
445+
if records is not None and len(transcript_records) < len(planned_records):
446+
publish_state = "failed"
447+
elif records is not None and any(record.returncode not in (None, 0) for record in transcript_records):
448+
publish_state = "failed"
449+
success = None
450+
if records is not None:
451+
success = publish_state == "published" and len(transcript_records) == len(planned_records)
452+
return {
453+
"schema_version": ROS2_BATCH_PUBLISH_TRANSCRIPT_SCHEMA_VERSION,
454+
"kind": "ros2_batch_publish_transcript",
455+
"publish_state": publish_state,
456+
"topic_root": _topic_root_for_batch(envelopes[0].topic),
457+
"message_count": len(indexed_envelopes),
458+
"batch_indices": [batch_index for batch_index, _ in indexed_envelopes],
459+
"topic_sequence": [envelope.topic for _, envelope in indexed_envelopes],
460+
"msg_type_sequence": [envelope.msg_type for _, envelope in indexed_envelopes],
461+
"command_sequence": [list(record.command) for record in planned_records],
462+
"input_envelope_sequence": [envelope.state_dict() for _, envelope in indexed_envelopes],
463+
"record_sequence": [record.state_dict() for record in transcript_records],
464+
"record_count": len(planned_records),
465+
"observed_record_count": len(transcript_records),
466+
"success": success,
467+
"replayable": True,
468+
}
469+
402470
def publish_batch_via_cli(
403471
self,
404472
envelopes: list[Ros2MessageEnvelope] | tuple[Ros2MessageEnvelope, ...],
@@ -434,6 +502,11 @@ def publish_batch_via_cli(
434502
f"returncode={exc.returncode}"
435503
)
436504
error.batch_publish_manifest = [item.state_dict() for item in results]
505+
error.batch_publish_transcript = self.build_batch_publish_transcript(
506+
envelopes,
507+
once=once,
508+
records=results,
509+
)
437510
raise error from exc
438511
results.append(
439512
replace(

source/isaaclab/test/backends/test_mac_hotpath.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
get_franka_stack_rgb_hotpath_backend,
3737
get_locomotion_hotpath_backend,
3838
h1_body_positions_hotpath,
39+
h1_leg_extension_hotpath,
40+
get_h1_leg_extension_hotpath_backend,
3941
locomotion_root_step_hotpath,
4042
prime_contact_state,
4143
quadruped_support_metrics_hotpath,
@@ -186,6 +188,31 @@ def test_h1_body_positions_hotpath_returns_expected_shape_and_base_slot():
186188
assert np.allclose(np.array(body_pos)[:, 0, :], np.array(root_pos_w))
187189

188190

191+
def test_h1_leg_extension_hotpath_matches_reference_math():
192+
joint_pos = mx.array(
193+
[
194+
[0.05, -0.25, 0.70, -0.15, 0.05, 0.12, 0.10, -0.08, 0.18, 0.22, -0.30, 0.04, 0.12, -0.07, 0.20, -0.04, 0.15, 0.08, -0.02, 0.11],
195+
[0.00, -0.30, 0.60, 0.08, -0.10, 0.18, -0.05, 0.12, -0.16, 0.06, 0.20, -0.02, 0.14, 0.03, -0.12, 0.10, -0.18, 0.05, 0.09, -0.06],
196+
],
197+
dtype=mx.float32,
198+
)
199+
200+
extension = h1_leg_extension_hotpath(joint_pos)
201+
mx.eval(extension)
202+
203+
joint_np = np.array(joint_pos)[:, :10].reshape((2, 2, 5))
204+
hip_pitch = joint_np[:, :, 2]
205+
knee = joint_np[:, :, 3]
206+
ankle = joint_np[:, :, 4]
207+
expected = 0.40 + 0.20 * np.cos(hip_pitch + 0.20) + 0.26 * np.cos(hip_pitch + knee - 0.10) + 0.08 * np.cos(
208+
hip_pitch + knee + ankle
209+
)
210+
expected = np.clip(expected, 0.58, 0.98).astype(np.float32)
211+
212+
assert extension.shape == (2, 2)
213+
assert np.allclose(np.array(extension), expected)
214+
215+
189216
def test_hotpath_backend_label_is_stable():
190217
assert HOTPATH_BACKEND == "mlx-compiled"
191218
assert get_anymal_leg_extension_hotpath_backend() in {"mlx-compiled", "mlx-metal-anymal-leg-extension"}
@@ -196,6 +223,7 @@ def test_hotpath_backend_label_is_stable():
196223
assert get_franka_stack_hotpath_backend() in {"mlx-compiled", "mlx-metal-franka-stack"}
197224
assert get_franka_stack_rgb_hotpath_backend() in {"mlx-compiled", "mlx-metal-franka-stack-rgb"}
198225
assert get_locomotion_hotpath_backend() in {"mlx-compiled", "mlx-metal-root-step"}
226+
assert get_h1_leg_extension_hotpath_backend() in {"mlx-compiled", "mlx-metal-h1-leg-extension"}
199227

200228

201229
def test_franka_end_effector_hotpath_matches_reference_math():

source/isaaclab/test/backends/test_ros2_bridge.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,39 @@ def test_ros2_process_bridge_builds_batch_publish_manifest_in_batch_index_order(
178178
assert manifest[0].command[4] == "/planner/world_state/0"
179179

180180

181+
def test_ros2_process_bridge_builds_batch_publish_transcript_in_batch_index_order():
182+
"""Batch publish transcripts should capture replayable envelope and command order."""
183+
bridge = Ros2ProcessBridge()
184+
envelopes = [
185+
Ros2MessageEnvelope(
186+
topic="/planner/world_state/1",
187+
msg_type="robotflow_msgs/msg/PlannerWorldState",
188+
payload={"frame_id": "world", "batch_index": 1},
189+
batch_index=1,
190+
),
191+
Ros2MessageEnvelope(
192+
topic="/planner/world_state/0",
193+
msg_type="robotflow_msgs/msg/PlannerWorldState",
194+
payload={"frame_id": "world", "batch_index": 0},
195+
batch_index=0,
196+
),
197+
]
198+
199+
transcript = bridge.build_batch_publish_transcript(tuple(envelopes))
200+
201+
assert transcript["schema_version"] == 1
202+
assert transcript["kind"] == "ros2_batch_publish_transcript"
203+
assert transcript["publish_state"] == "planned"
204+
assert transcript["batch_indices"] == [0, 1]
205+
assert transcript["topic_sequence"] == ["/planner/world_state/0", "/planner/world_state/1"]
206+
assert transcript["msg_type_sequence"] == ["robotflow_msgs/msg/PlannerWorldState", "robotflow_msgs/msg/PlannerWorldState"]
207+
assert transcript["record_count"] == 2
208+
assert transcript["observed_record_count"] == 2
209+
assert transcript["replayable"] is True
210+
assert transcript["input_envelope_sequence"][0]["batch_index"] == 0
211+
assert transcript["command_sequence"][0][4] == "/planner/world_state/0"
212+
213+
181214
def test_ros2_process_bridge_wraps_batch_publish_failures_with_batch_context(monkeypatch):
182215
"""Batch publish failures should report the failing batch index and topic."""
183216
bridge = Ros2ProcessBridge()
@@ -219,6 +252,10 @@ def _publish_via_cli(envelope, *, once=True, check=True):
219252
assert manifest[-1]["topic"] == "/planner/world_state/1"
220253
assert manifest[-1]["returncode"] == 1
221254
assert manifest[-1]["stderr"] == "boom"
255+
transcript = exc_info.value.batch_publish_transcript
256+
assert transcript["publish_state"] == "failed"
257+
assert transcript["observed_record_count"] == 2
258+
assert transcript["record_sequence"][-1]["stderr"] == "boom"
222259

223260

224261
def test_ros2_process_bridge_rejects_invalid_batch_envelopes():
@@ -568,3 +605,6 @@ def test_ros2_bridge_smoke_uses_planner_backend_round_trip(tmp_path: Path):
568605
assert [item["batch_index"] for item in summary["trajectory_batch_publish_manifest"]] == [0, 1]
569606
assert summary["planner_batch_publish_manifest"][0]["returncode"] is None
570607
assert summary["trajectory_batch_publish_manifest"][0]["command"][4] == "/planner/joint_trajectory/0"
608+
assert summary["planner_batch_publish_transcript"]["publish_state"] == "planned"
609+
assert summary["trajectory_batch_publish_transcript"]["publish_state"] == "planned"
610+
assert summary["planner_batch_publish_transcript"]["batch_indices"] == [0, 1]

0 commit comments

Comments
 (0)