Download runtime/sim_mujoco_bridge.py from BAAI/PingPongRealWorldRL: direct link, hf CLI and curl.
- Browser
- Download file 76.9 kB
-
https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/sim_mujoco_bridge.py
- Command line
-
hf download hf://BAAI/PingPongRealWorldRL/runtime/sim_mujoco_bridge.py
-
curl -L -o sim_mujoco_bridge.py https://huggingface.co/BAAI/PingPongRealWorldRL/resolve/main/runtime/sim_mujoco_bridge.py
76.9 kB
| """Isolated 1000 Hz MuJoCo owner for simulation of the H2 deployment transport. | |
| This module is the simulated plant/bridge side only. It never runs the policy | |
| and never imports the hardware middleware. The unchanged 50 Hz | |
| ``RealShmRunner`` communicates with it through a random H2BR segment using the | |
| same current ABI version as the hardware bridge. | |
| """ | |
| from __future__ import annotations | |
| from contextlib import contextmanager | |
| from dataclasses import asdict, dataclass | |
| import fcntl | |
| import json | |
| import math | |
| from multiprocessing import shared_memory | |
| import os | |
| from pathlib import Path | |
| import stat | |
| import time | |
| import traceback | |
| from typing import Iterator | |
| import numpy as np | |
| from . import ball as ball_model | |
| from .embodiments import load_embodiment | |
| from .h2_contract import ( | |
| HARDWARE_JOINT_NAMES, | |
| H2_DOF, | |
| LOWCMD_DOF, | |
| MODE_PR, | |
| command_position_bounds_hw, | |
| ) | |
| from .h2_shm_bridge import ( | |
| BRIDGE_SIZE, | |
| H2BridgeMemory, | |
| MAX_KD, | |
| MAX_KP, | |
| MOTION_SWITCHER_ATTESTED_FLAG, | |
| PYTHON_FAULT_REQUEST_FLAG, | |
| SHM_MAGIC, | |
| SHM_NAME, | |
| SHM_VERSION, | |
| SIMULATION_RUNNER_ATTESTED_FLAG, | |
| SIMULATOR_IDENTITY, | |
| SIMULATOR_OWNER_FLAG, | |
| SIM_SHM_PREFIX, | |
| TT_DATA_LEN, | |
| ) | |
| from .motion_attestation import interface_hash | |
| from .mathutil import yaw_of | |
| from .planner_protocol import ( | |
| BALL_SOURCE_MONOTONIC_INDEX, | |
| H2_PLANNER_PACKET_LEN, | |
| PROTOCOL_VERSION_INDEX, | |
| ROBOT_SOURCE_MONOTONIC_INDEX, | |
| build_h2_planner_packet, | |
| ) | |
| BRIDGE_HZ = 1000.0 | |
| BRIDGE_DT = 1.0 / BRIDGE_HZ | |
| # Training/reference dynamics use 200 Hz. The transport still publishes state | |
| # and consumes commands at 1000 Hz, so these clocks are intentionally separate. | |
| DEFAULT_PHYSICS_DT = 0.005 | |
| COMMAND_WATCHDOG_S = 0.10 | |
| HOLD_TORQUE_FRACTION = 0.90 | |
| MOCAP_HZ = 120.0 | |
| TRACE_HZ = 50.0 | |
| # Mirror the fixed production C++ LowStateFinite/CommandValid envelope. | |
| STATE_JOINT_LIMIT_TOLERANCE_RAD = 0.05 | |
| STATE_MAX_JOINT_VELOCITY_RAD_S = 40.0 | |
| LOWSTATE_MIN_QUATERNION_NORM = 0.5 | |
| LOWSTATE_MAX_QUATERNION_NORM = 1.5 | |
| HIT_REACH_WINDOW_HALF_WIDTH_S = 0.15 | |
| FAULT_KINDS = ( | |
| "none", | |
| "state_dropout", | |
| "invalid_lowstate", | |
| "non_pr_mode", | |
| "motor_fault", | |
| "planner_receipt_dropout", | |
| "robot_source_dropout", | |
| "planner_origin_mismatch", | |
| "planner_protocol_mismatch", | |
| "command_id_rollback", | |
| "root_jump", | |
| "heading_jump", | |
| "corrupt_shm_nan", | |
| ) | |
| def validate_sim_shm_name(name: str) -> str: | |
| normalized = str(name).lstrip("/") | |
| if normalized == SHM_NAME or not normalized.startswith(SIM_SHM_PREFIX): | |
| raise ValueError(f"simulator requires a random {SIM_SHM_PREFIX}<run-id> SHM name") | |
| if not normalized or "/" in normalized or normalized in {".", ".."}: | |
| raise ValueError("invalid simulation SHM name") | |
| return normalized | |
| def _h2_armature(joint_name: str) -> float: | |
| """Isaac Lab H2 actuator armature, copied into this isolated new project.""" | |
| joint = joint_name.removesuffix("_joint") | |
| armature_5020 = 0.003609725 | |
| armature_7520_14 = 0.010177520 | |
| armature_7520_22 = 0.025101925 | |
| armature_4010 = 0.00425 | |
| if joint.endswith(("_hip_pitch", "_hip_roll", "_knee")): | |
| return armature_7520_22 | |
| if joint.endswith("_hip_yaw") or joint == "waist_yaw": | |
| return armature_7520_14 | |
| if joint.endswith(("_ankle_pitch", "_ankle_roll")): | |
| return 2.0 * armature_5020 | |
| if joint in ("waist_roll", "waist_pitch", "head_pitch", "head_yaw"): | |
| return 2.0 * armature_5020 | |
| if joint.endswith( | |
| ("_shoulder_pitch", "_shoulder_roll", "_shoulder_yaw", "_elbow", "_wrist_roll") | |
| ): | |
| return armature_5020 | |
| if joint.endswith(("_wrist_pitch", "_wrist_yaw")): | |
| return armature_4010 | |
| raise ValueError(f"no H2 armature mapping for {joint_name!r}") | |
| class SimScenario: | |
| shm_name: str | |
| mjcf_path: str | |
| ready_pose_path: str | |
| trace_path: str | |
| physics_dt: float = DEFAULT_PHYSICS_DT | |
| # Canonical simulated L1 occurs only after the 5.0 s interpolation and | |
| # 0.5 s frozen-pose settle gate have completed. | |
| arm_at_s: float = 5.55 | |
| command_at_s: float = 6.30 | |
| time_to_hit_s: float = 0.80 | |
| fault_kind: str = "none" | |
| fault_at_s: float = 6.80 | |
| fault_duration_s: float | None = None | |
| viewer: bool = False | |
| keep_mjcf_joint_dynamics: bool = False | |
| # Match commissioning: the frozen pose may be suspended, but the feet must | |
| # carry the robot when L1 hands control to the balance policy. | |
| startup_support_until_s: float = 5.55 | |
| robot_base_origin_world: tuple[float, float, float] = (-1.87, 0.0, -0.76) | |
| hit_position: tuple[float, float, float] = (0.40, 0.0, 1.10) | |
| racket_velocity: tuple[float, float, float] = (2.0, 0.0, 0.45) | |
| incoming_velocity: tuple[float, float, float] = (-3.0, 0.0, 0.0) | |
| landing_xy: tuple[float, float] = (2.87, 0.0) | |
| # Simulation-only perturbation applied atomically on the synthetic L1 | |
| # frame. It exercises G1-style direct policy entry from measured suspended | |
| # gravity offsets without adding a production OOD threshold. | |
| handover_joint_offsets_hw: tuple[float, ...] = (0.0,) * H2_DOF | |
| handover_offset_preload_s: float = 0.50 | |
| def validate(self) -> None: | |
| validate_sim_shm_name(self.shm_name) | |
| if self.fault_kind not in FAULT_KINDS: | |
| raise ValueError(f"unknown fault {self.fault_kind!r}; expected one of {FAULT_KINDS}") | |
| values = np.asarray( | |
| [self.physics_dt, self.arm_at_s, self.command_at_s, self.time_to_hit_s, | |
| self.startup_support_until_s, | |
| self.fault_at_s, self.handover_offset_preload_s, | |
| *self.robot_base_origin_world, *self.hit_position, | |
| *self.racket_velocity, *self.incoming_velocity, *self.landing_xy], | |
| dtype=np.float64, | |
| ) | |
| if not np.isfinite(values).all(): | |
| raise ValueError("simulation scenario contains NaN/Inf") | |
| offsets = np.asarray(self.handover_joint_offsets_hw, dtype=np.float64) | |
| if offsets.shape != (H2_DOF,) or not np.isfinite(offsets).all(): | |
| raise ValueError(f"handover_joint_offsets_hw must contain {H2_DOF} finite values") | |
| if not 0.10 <= self.handover_offset_preload_s <= 1.0: | |
| raise ValueError("handover_offset_preload_s must be in [0.10,1.0]s") | |
| if not 0.001 <= self.physics_dt <= 0.005: | |
| raise ValueError("physics_dt must be in [0.001,0.005]s") | |
| if self.arm_at_s < 0.0 or self.command_at_s <= self.arm_at_s: | |
| raise ValueError("command_at_s must be later than arm_at_s") | |
| if self.startup_support_until_s < 0.0: | |
| raise ValueError("startup support release time must be non-negative") | |
| if not 0.0 < self.time_to_hit_s <= 2.0: | |
| raise ValueError("time_to_hit_s must be in (0,2]s") | |
| if self.fault_kind != "none" and self.fault_at_s <= 0.0: | |
| raise ValueError("fault_at_s must be positive") | |
| if self.fault_duration_s is not None: | |
| if self.fault_kind not in ("robot_source_dropout", "planner_receipt_dropout"): | |
| raise ValueError("fault_duration_s is only supported for mocap/planner dropouts") | |
| if not math.isfinite(self.fault_duration_s) or self.fault_duration_s <= 0.0: | |
| raise ValueError("fault_duration_s must be finite and positive") | |
| if not Path(self.mjcf_path).is_file() or not Path(self.ready_pose_path).is_file(): | |
| raise FileNotFoundError("simulation MJCF or ready-pose artifact is missing") | |
| def fault_active(self, elapsed_s: float) -> bool: | |
| return ( | |
| self.fault_kind != "none" and elapsed_s >= self.fault_at_s | |
| and (self.fault_duration_s is None | |
| or elapsed_s < self.fault_at_s + self.fault_duration_s) | |
| ) | |
| class SimCommand: | |
| stamp_ns: int | |
| enabled: bool | |
| flags: int | |
| mode_pr: int | |
| q: np.ndarray | |
| dq: np.ndarray | |
| tau: np.ndarray | |
| kp: np.ndarray | |
| kd: np.ndarray | |
| mode: np.ndarray | |
| reserve: np.ndarray | |
| class SimH2BridgeOwner: | |
| """Exclusive creator and state writer for one random simulation H2BR.""" | |
| def __init__(self, name: str): | |
| self.name = validate_sim_shm_name(name) | |
| self._shm = shared_memory.SharedMemory(name=self.name, create=True, size=BRIDGE_SIZE) | |
| path = Path("/dev/shm") / self.name | |
| if path.exists(): | |
| os.chmod(path, 0o600) | |
| metadata = path.stat() | |
| if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid(): | |
| self._shm.unlink() | |
| self._shm.close() | |
| raise RuntimeError("simulation H2BR is not an owner-controlled regular file") | |
| self._shm.buf[:] = b"\x00" * BRIDGE_SIZE | |
| self.mem = H2BridgeMemory.from_buffer(self._shm.buf) | |
| self.mem.magic = SHM_MAGIC | |
| self.mem.version = SHM_VERSION | |
| self.mem.reserved_flags = SIMULATOR_OWNER_FLAG | |
| self.mem.bridge_interface_hash = interface_hash(SIMULATOR_IDENTITY) | |
| self.mem.state_mode_pr = MODE_PR | |
| self.mem.imu_quat_wxyz[0] = 1.0 | |
| def locked(self, operation: int) -> Iterator[None]: | |
| while True: | |
| try: | |
| fcntl.flock(self._shm._fd, operation) | |
| break | |
| except InterruptedError: | |
| continue | |
| try: | |
| yield | |
| finally: | |
| fcntl.flock(self._shm._fd, fcntl.LOCK_UN) | |
| def read_command(self) -> SimCommand: | |
| with self.locked(fcntl.LOCK_SH): | |
| return SimCommand( | |
| stamp_ns=int(self.mem.command_stamp_ns), | |
| enabled=bool(self.mem.command_enabled), | |
| flags=int(self.mem.reserved_flags), | |
| mode_pr=int(self.mem.cmd_mode_pr), | |
| q=np.ctypeslib.as_array(self.mem.cmd_q).copy(), | |
| dq=np.ctypeslib.as_array(self.mem.cmd_dq).copy(), | |
| tau=np.ctypeslib.as_array(self.mem.cmd_tau).copy(), | |
| kp=np.ctypeslib.as_array(self.mem.cmd_kp).copy(), | |
| kd=np.ctypeslib.as_array(self.mem.cmd_kd).copy(), | |
| mode=np.ctypeslib.as_array(self.mem.cmd_mode).copy(), | |
| reserve=np.ctypeslib.as_array(self.mem.cmd_reserve).copy(), | |
| ) | |
| def write_state( | |
| self, | |
| *, | |
| q: np.ndarray, | |
| dq: np.ndarray, | |
| quat_wxyz: np.ndarray, | |
| gyro: np.ndarray, | |
| root_xy: np.ndarray, | |
| quat_xyzw: np.ndarray, | |
| planner_packet: np.ndarray, | |
| start_requested: bool, | |
| stop_requested: bool, | |
| emergency_stop: bool, | |
| mode_pr: int, | |
| update_state: bool = True, | |
| update_planner: bool = True, | |
| planner_ready: bool = True, | |
| planner_stamp_ns: int | None = None, | |
| ) -> None: | |
| now_ns = time.monotonic_ns() | |
| with self.locked(fcntl.LOCK_EX): | |
| seq = int(self.mem.state_seq) | |
| if seq & 1: | |
| seq += 1 | |
| if update_state: | |
| np.ctypeslib.as_array(self.mem.joint_pos)[:] = np.asarray(q, dtype=np.float32) | |
| np.ctypeslib.as_array(self.mem.joint_vel)[:] = np.asarray(dq, dtype=np.float32) | |
| np.ctypeslib.as_array(self.mem.imu_quat_wxyz)[:] = np.asarray( | |
| quat_wxyz, dtype=np.float32 | |
| ) | |
| np.ctypeslib.as_array(self.mem.gyro)[:] = np.asarray(gyro, dtype=np.float32) | |
| np.ctypeslib.as_array(self.mem.root_current_xy)[:] = np.asarray( | |
| root_xy, dtype=np.float32 | |
| ) | |
| np.ctypeslib.as_array(self.mem.mocap_pelvis_quat_xyzw)[:] = np.asarray( | |
| quat_xyzw, dtype=np.float32 | |
| ) | |
| self.mem.state_stamp_ns = now_ns | |
| self.mem.state_ready = 1 | |
| self.mem.robot_connected = 1 | |
| self.mem.state_mode_pr = int(mode_pr) | |
| if update_planner: | |
| destination = np.ctypeslib.as_array(self.mem.tt_data) | |
| destination.fill(0.0) | |
| packet = np.asarray(planner_packet, dtype=np.float64).reshape(-1) | |
| if packet.size > TT_DATA_LEN: | |
| raise ValueError( | |
| f"planner packet has {packet.size} values; H2BR holds {TT_DATA_LEN}" | |
| ) | |
| destination[: packet.size] = packet | |
| stamp_ns = now_ns if planner_stamp_ns is None else int(planner_stamp_ns) | |
| if stamp_ns <= 0: | |
| raise ValueError("planner receipt timestamp must be positive") | |
| self.mem.tt_stamp_ns = stamp_ns | |
| self.mem.tt_ready = int(bool(planner_ready)) | |
| self.mem.start_requested = int(bool(start_requested)) | |
| self.mem.stop_requested = int(bool(stop_requested)) | |
| self.mem.emergency_stop = int(bool(emergency_stop)) | |
| self.mem.mode_machine = 0 | |
| self.mem.state_seq = seq + 2 | |
| def mark_emergency(self) -> None: | |
| with self.locked(fcntl.LOCK_EX): | |
| self.mem.emergency_stop = 1 | |
| def close(self) -> None: | |
| if getattr(self, "mem", None) is not None: | |
| self.mem = None | |
| self._shm.close() | |
| try: | |
| self._shm.unlink() | |
| except FileNotFoundError: | |
| pass | |
| lock_path = Path("/dev/shm") / f"{self.name}.python_writer.lock" | |
| try: | |
| lock_path.unlink() | |
| except FileNotFoundError: | |
| pass | |
| class H2MujocoPlant: | |
| """Floating-base H2 plant with all indices resolved from joint names.""" | |
| def __init__( | |
| self, | |
| mjcf_path: str, | |
| ready_pose_path: str, | |
| *, | |
| physics_dt: float, | |
| keep_mjcf_joint_dynamics: bool, | |
| ): | |
| import mujoco | |
| self.mujoco = mujoco | |
| self.model = mujoco.MjModel.from_xml_path(str(mjcf_path)) | |
| self.data = mujoco.MjData(self.model) | |
| if self.model.nu != H2_DOF: | |
| raise RuntimeError(f"H2 MJCF must have {H2_DOF} actuators, got {self.model.nu}") | |
| self.model.opt.timestep = float(physics_dt) | |
| qpos_addr: list[int] = [] | |
| qvel_addr: list[int] = [] | |
| actuator_id: list[int] = [] | |
| joint_lo: list[float] = [] | |
| joint_hi: list[float] = [] | |
| for name in HARDWARE_JOINT_NAMES: | |
| jid = int(mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, name)) | |
| if jid < 0: | |
| raise RuntimeError(f"H2 MJCF is missing joint {name!r}") | |
| matches = np.flatnonzero(self.model.actuator_trnid[:, 0] == jid) | |
| if matches.size != 1: | |
| raise RuntimeError(f"joint {name!r} must have exactly one actuator, got {matches.tolist()}") | |
| qpos_addr.append(int(self.model.jnt_qposadr[jid])) | |
| qvel_addr.append(int(self.model.jnt_dofadr[jid])) | |
| actuator_id.append(int(matches[0])) | |
| joint_lo.append(float(self.model.jnt_range[jid, 0])) | |
| joint_hi.append(float(self.model.jnt_range[jid, 1])) | |
| self.qpos_addr_hw = np.asarray(qpos_addr, dtype=np.int32) | |
| self.qvel_addr_hw = np.asarray(qvel_addr, dtype=np.int32) | |
| self.actuator_id_hw = np.asarray(actuator_id, dtype=np.int32) | |
| if len(set(self.actuator_id_hw.tolist())) != H2_DOF: | |
| raise RuntimeError("H2 HW-to-MuJoCo actuator mapping is not bijective") | |
| self.joint_lo_hw = np.asarray(joint_lo, dtype=np.float64) | |
| self.joint_hi_hw = np.asarray(joint_hi, dtype=np.float64) | |
| self.torque_lo_hw = self.model.actuator_ctrlrange[self.actuator_id_hw, 0].astype(np.float64) | |
| self.torque_hi_hw = self.model.actuator_ctrlrange[self.actuator_id_hw, 1].astype(np.float64) | |
| if not np.all(self.torque_lo_hw < 0.0) or not np.all(self.torque_hi_hw > 0.0): | |
| raise RuntimeError("H2 actuator torque ranges must straddle zero") | |
| if not keep_mjcf_joint_dynamics: | |
| self.model.dof_armature[self.qvel_addr_hw] = np.asarray( | |
| [_h2_armature(name) for name in HARDWARE_JOINT_NAMES], dtype=np.float64 | |
| ) | |
| self.model.dof_damping[self.qvel_addr_hw] = 0.0 | |
| self.model.dof_frictionloss[self.qvel_addr_hw] = 0.0 | |
| with np.load(str(ready_pose_path), allow_pickle=True) as ready: | |
| ready_names = [str(value) for value in np.asarray(ready["joint_names"]).ravel()] | |
| ready_values = np.asarray(ready["joint_pos"], dtype=np.float64)[0] | |
| ready_by_name = dict(zip(ready_names, ready_values)) | |
| missing = [name for name in HARDWARE_JOINT_NAMES if name not in ready_by_name] | |
| if missing: | |
| raise RuntimeError(f"ready pose is missing H2 joints: {missing}") | |
| self.ready_q_hw = np.asarray( | |
| [ready_by_name[name] for name in HARDWARE_JOINT_NAMES], dtype=np.float64 | |
| ) | |
| body_names = [str(value) for value in np.asarray(ready["body_names"]).ravel()] | |
| pelvis_index = body_names.index("pelvis") | |
| root_pos = np.asarray(ready["body_pos_w"], dtype=np.float64)[0, pelvis_index] | |
| root_quat = np.asarray(ready["body_quat_w"], dtype=np.float64)[0, pelvis_index] | |
| self.data.qpos[:] = 0.0 | |
| self.data.qvel[:] = 0.0 | |
| self.data.qpos[:3] = root_pos | |
| self.data.qpos[3:7] = root_quat | |
| self.data.qpos[self.qpos_addr_hw] = self.ready_q_hw | |
| self.initial_root_pos = root_pos.copy() | |
| self.initial_root_quat = root_quat.copy() | |
| mujoco.mj_forward(self.model, self.data) | |
| self.pelvis_id = int(mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "pelvis")) | |
| if self.pelvis_id < 0: | |
| raise RuntimeError("H2 MJCF has no pelvis body") | |
| self.paddle_center_site_id = int( | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_SITE, "paddle_center") | |
| ) | |
| if self.paddle_center_site_id < 0: | |
| raise RuntimeError("H2 MJCF has no paddle_center site") | |
| h2_spec = load_embodiment("h2") | |
| self.virtual_paddle_body_id = int( | |
| mujoco.mj_name2id( | |
| self.model, | |
| mujoco.mjtObj.mjOBJ_BODY, | |
| h2_spec.paddle_body_name, | |
| ) | |
| ) | |
| if self.virtual_paddle_body_id < 0: | |
| raise RuntimeError( | |
| f"H2 MJCF has no virtual-paddle body {h2_spec.paddle_body_name!r}" | |
| ) | |
| self.virtual_paddle_body_name = h2_spec.paddle_body_name | |
| self.virtual_paddle_offset_xyz = np.asarray( | |
| h2_spec.paddle_offset_xyz, dtype=np.float64 | |
| ) | |
| self.virtual_paddle_offset_rpy = np.asarray( | |
| h2_spec.paddle_offset_rpy, dtype=np.float64 | |
| ) | |
| self.virtual_paddle_half_width = float(h2_spec.paddle_half_width) | |
| self.virtual_paddle_half_height = float(h2_spec.paddle_half_height) | |
| self.stroke_split_relative_y = float(h2_spec.stroke_split_relative_y) | |
| self.mocap_ids = {} | |
| for name in ("tt_ball", "tt_target_marker", "tt_landing_marker"): | |
| bid = int(mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, name)) | |
| self.mocap_ids[name] = int(self.model.body_mocapid[bid]) if bid >= 0 else -1 | |
| self.last_tau_hw = np.zeros(H2_DOF, dtype=np.float64) | |
| self.last_target_hw = self.ready_q_hw.copy() | |
| self.last_marker_positions: dict[str, np.ndarray] = {} | |
| def state(self) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: | |
| q = self.data.qpos[self.qpos_addr_hw].astype(np.float64).copy() | |
| dq = self.data.qvel[self.qvel_addr_hw].astype(np.float64).copy() | |
| quat = self.data.qpos[3:7].astype(np.float64).copy() | |
| norm = float(np.linalg.norm(quat)) | |
| if norm > 0.0 and np.isfinite(norm): | |
| quat /= norm | |
| # MuJoCo free-joint angular qvel is the body-frame angular velocity used | |
| # by the H2 training/sim2sim observation. mjOBJ_BODY local velocity is | |
| # expressed through the inertial-body frame and can also lag after the | |
| # explicit support reset, which changes the policy input materially. | |
| gyro_body = self.data.qvel[3:6].astype(np.float64).copy() | |
| return q, dq, quat, gyro_body | |
| def apply( | |
| self, | |
| command: SimCommand | None, | |
| *, | |
| hold_q: np.ndarray | None = None, | |
| root_supported: bool = False, | |
| ) -> np.ndarray: | |
| q, dq, _, _ = self.state() | |
| if command is None: | |
| tau = np.zeros(H2_DOF, dtype=np.float64) | |
| else: | |
| target = command.q[:H2_DOF].astype(np.float64) | |
| if hold_q is not None: | |
| target = np.asarray(hold_q, dtype=np.float64).reshape(H2_DOF) | |
| tau = ( | |
| command.tau[:H2_DOF] | |
| + command.kp[:H2_DOF] * (target - q) | |
| + command.kd[:H2_DOF] * (command.dq[:H2_DOF] - dq) | |
| ) | |
| self.last_target_hw = target.copy() | |
| tau = np.clip(tau, self.torque_lo_hw, self.torque_hi_hw) | |
| self.data.ctrl[:] = 0.0 | |
| self.data.ctrl[self.actuator_id_hw] = tau | |
| self.last_tau_hw = tau.copy() | |
| self.mujoco.mj_step(self.model, self.data) | |
| if root_supported: | |
| # Explicit startup harness: the real commissioning procedure also | |
| # requires suspension for the first map/control validation. The | |
| # support is removed before the canonical strike. | |
| self.data.qpos[:3] = self.initial_root_pos | |
| self.data.qpos[3:7] = self.initial_root_quat | |
| self.data.qvel[:6] = 0.0 | |
| self.mujoco.mj_forward(self.model, self.data) | |
| return tau | |
| def update_markers(self, *, elapsed_s: float, scenario: SimScenario) -> None: | |
| hidden = np.array([0.0, 0.0, -1.0], dtype=np.float64) | |
| deadline = scenario.command_at_s + scenario.time_to_hit_s | |
| if elapsed_s < scenario.command_at_s: | |
| ball = hidden | |
| elif elapsed_s <= deadline: | |
| remaining = deadline - elapsed_s | |
| ball = np.asarray(scenario.hit_position) - np.asarray(scenario.incoming_velocity) * remaining | |
| else: | |
| flight = min(1.0, (elapsed_s - deadline) / 0.65) | |
| landing = np.array([scenario.landing_xy[0], scenario.landing_xy[1], 0.78]) | |
| ball = (1.0 - flight) * np.asarray(scenario.hit_position) + flight * landing | |
| ball[2] += 0.45 * 4.0 * flight * (1.0 - flight) | |
| marker_positions = { | |
| "tt_ball": ball, | |
| "tt_target_marker": np.asarray(scenario.hit_position), | |
| "tt_landing_marker": np.array([scenario.landing_xy[0], scenario.landing_xy[1], 0.765]), | |
| } | |
| for name, position in marker_positions.items(): | |
| mocap_id = self.mocap_ids.get(name, -1) | |
| if mocap_id >= 0: | |
| self.data.mocap_pos[mocap_id] = position | |
| self.last_marker_positions[name] = np.asarray(position, dtype=np.float64).copy() | |
| def update_markers_from_planner( | |
| self, | |
| packet: np.ndarray, | |
| *, | |
| ready: bool, | |
| ball_position_robot_m: np.ndarray | None, | |
| ) -> None: | |
| """Render planner targets plus the independent hybrid-ball position. | |
| Planner-v3 intentionally has no instantaneous ball position. The ball | |
| marker therefore comes only from the adapter's real ZMQ observation or | |
| the canonical post-contact kinematic simulation; missing ball data is | |
| rendered as hidden rather than reconstructed from TTH. | |
| """ | |
| hidden = np.array([0.0, 0.0, -1.0], dtype=np.float64) | |
| values = np.asarray(packet, dtype=np.float64).reshape(-1) | |
| active = bool( | |
| ready | |
| and values.size >= H2_PLANNER_PACKET_LEN | |
| and np.isfinite(values[:H2_PLANNER_PACKET_LEN]).all() | |
| and values[14] > 0.0 | |
| and values[7] > 0.0 | |
| ) | |
| if active: | |
| hit = values[8:11].copy() | |
| landing = np.array([values[18], values[19], 0.765], dtype=np.float64) | |
| else: | |
| hit = hidden | |
| landing = hidden | |
| if ball_position_robot_m is None: | |
| ball = hidden | |
| else: | |
| ball = np.asarray(ball_position_robot_m, dtype=np.float64) | |
| if ball.shape != (3,) or not np.isfinite(ball).all(): | |
| raise ValueError("hybrid ball marker must contain three finite values") | |
| marker_positions = { | |
| "tt_ball": ball, | |
| "tt_target_marker": hit, | |
| "tt_landing_marker": landing, | |
| } | |
| for name, position in marker_positions.items(): | |
| mocap_id = self.mocap_ids.get(name, -1) | |
| if mocap_id >= 0: | |
| self.data.mocap_pos[mocap_id] = position | |
| self.last_marker_positions[name] = np.asarray(position, dtype=np.float64).copy() | |
| def paddle_center_world(self) -> np.ndarray: | |
| return self.data.site_xpos[self.paddle_center_site_id].astype(np.float64).copy() | |
| def virtual_paddle_state(self) -> ball_model.PaddleState: | |
| """Return the exact training virtual paddle, not the MJCF display site.""" | |
| # ``mj_step`` integrates qpos/qvel after computing derived body | |
| # velocities. Refresh them before sampling a new 120 Hz ball frame so | |
| # contact uses the current wrist state rather than the preceding step. | |
| self.mujoco.mj_forward(self.model, self.data) | |
| velocity = np.zeros(6, dtype=np.float64) | |
| self.mujoco.mj_objectVelocity( | |
| self.model, | |
| self.data, | |
| self.mujoco.mjtObj.mjOBJ_BODY, | |
| self.virtual_paddle_body_id, | |
| velocity, | |
| 0, | |
| ) | |
| return ball_model.virtual_paddle( | |
| self.data.xpos[self.virtual_paddle_body_id].astype(np.float64), | |
| self.data.xquat[self.virtual_paddle_body_id].astype(np.float64), | |
| velocity[3:6], | |
| velocity[:3], | |
| self.virtual_paddle_offset_xyz, | |
| self.virtual_paddle_offset_rpy, | |
| ) | |
| class SyntheticPlanner: | |
| """Deterministic v3 producer with independent receipt and source clocks.""" | |
| def __init__(self, scenario: SimScenario): | |
| self.scenario = scenario | |
| now = time.monotonic() | |
| self.robot_source_stamp = now | |
| self.ball_source_stamp = now | |
| self.next_robot_sample = now | |
| self.next_ball_sample = now | |
| self.cached_root = np.zeros(3, dtype=np.float64) | |
| self.cached_quat_xyzw = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float64) | |
| def packet(self, *, now: float, elapsed_s: float, root_local: np.ndarray, quat_wxyz: np.ndarray) -> np.ndarray: | |
| fault_active = self.scenario.fault_active(elapsed_s) | |
| robot_stale = fault_active and self.scenario.fault_kind == "robot_source_dropout" | |
| if now >= self.next_robot_sample and not robot_stale: | |
| self.cached_root = np.asarray(root_local, dtype=np.float64).copy() | |
| self.cached_quat_xyzw = np.asarray(quat_wxyz, dtype=np.float64)[[1, 2, 3, 0]] | |
| self.robot_source_stamp = now | |
| self.next_robot_sample = now + 1.0 / MOCAP_HZ | |
| if now >= self.next_ball_sample: | |
| self.ball_source_stamp = now | |
| self.next_ball_sample = now + 1.0 / MOCAP_HZ | |
| origin = np.asarray(self.scenario.robot_base_origin_world, dtype=np.float64).copy() | |
| root = self.cached_root.copy() | |
| quat = self.cached_quat_xyzw.copy() | |
| if fault_active and self.scenario.fault_kind == "planner_origin_mismatch": | |
| origin[0] += 0.50 | |
| if fault_active and self.scenario.fault_kind == "root_jump": | |
| root[1] += 1.0 | |
| if fault_active and self.scenario.fault_kind == "heading_jump": | |
| yaw = 1.2 | |
| quat = np.array([0.0, 0.0, math.sin(yaw / 2.0), math.cos(yaw / 2.0)]) | |
| command_id = 1 if elapsed_s >= self.scenario.command_at_s else 0 | |
| if fault_active and self.scenario.fault_kind == "command_id_rollback": | |
| command_id = 0 | |
| deadline = self.scenario.command_at_s + self.scenario.time_to_hit_s | |
| tth = max(-0.59, deadline - elapsed_s) if command_id > 0 else -0.59 | |
| packet = build_h2_planner_packet( | |
| robot_pos_world=origin + root, | |
| robot_quat_xyzw=quat, | |
| time_to_hit_s=tth, | |
| hit_pos_robot=self.scenario.hit_position, | |
| racket_velocity_robot=self.scenario.racket_velocity, | |
| command_id=command_id, | |
| incoming_velocity_robot=self.scenario.incoming_velocity, | |
| landing_xy_robot=self.scenario.landing_xy, | |
| robot_source_monotonic_s=self.robot_source_stamp, | |
| ball_source_monotonic_s=self.ball_source_stamp, | |
| robot_base_origin_world=origin, | |
| ) | |
| if fault_active and self.scenario.fault_kind == "planner_protocol_mismatch": | |
| packet[PROTOCOL_VERSION_INDEX] = 99.0 | |
| return packet | |
| class SimMujocoBridge: | |
| def __init__( | |
| self, | |
| scenario: SimScenario, | |
| *, | |
| planner=None, | |
| live_mode: bool = False, | |
| trace_hz: float = TRACE_HZ, | |
| trace_max_bytes: int | None = None, | |
| live_status_path: str = "", | |
| live_ball_source=None, | |
| live_ball_controller=None, | |
| live_ball_endpoint: str = "", | |
| ): | |
| scenario.validate() | |
| self.scenario = scenario | |
| self.owner = SimH2BridgeOwner(scenario.shm_name) | |
| try: | |
| self.plant = H2MujocoPlant( | |
| scenario.mjcf_path, | |
| scenario.ready_pose_path, | |
| physics_dt=scenario.physics_dt, | |
| keep_mjcf_joint_dynamics=scenario.keep_mjcf_joint_dynamics, | |
| ) | |
| except BaseException: | |
| self.owner.close() | |
| raise | |
| self.planner = SyntheticPlanner(scenario) if planner is None else planner | |
| self.live_mode = bool(live_mode) | |
| if self.live_mode and (live_ball_source is None or live_ball_controller is None): | |
| self.owner.close() | |
| raise ValueError("live MuJoCo requires both ZMQ ball source and hybrid controller") | |
| if not self.live_mode and (live_ball_source is not None or live_ball_controller is not None): | |
| self.owner.close() | |
| raise ValueError("hybrid ball input is available only in live MuJoCo mode") | |
| self.live_ball_source = live_ball_source | |
| self.live_ball_controller = live_ball_controller | |
| self.live_ball_endpoint = str(live_ball_endpoint) | |
| self.live_ball_snapshot = ( | |
| live_ball_controller.snapshot() if self.live_mode else None | |
| ) | |
| self._live_forehand = True | |
| self._live_ball_last_wall_s: float | None = None | |
| self.trace_hz = float(trace_hz) | |
| self.trace_max_bytes = ( | |
| None if trace_max_bytes is None else int(trace_max_bytes) | |
| ) | |
| self.live_status_path = str(live_status_path) | |
| if not np.isfinite(self.trace_hz) or self.trace_hz < 0.0 or self.trace_hz > TRACE_HZ: | |
| raise ValueError(f"trace_hz must be finite and in [0,{TRACE_HZ:.0f}]") | |
| if self.trace_max_bytes is not None and self.trace_max_bytes < 4096: | |
| raise ValueError("trace_max_bytes must be at least 4096 when enabled") | |
| self.fault_latched = False | |
| self.fault_reason = "" | |
| self.fault_wall_s: float | None = None | |
| self.output_inhibited = False | |
| self.hold_q: np.ndarray | None = None | |
| self.last_valid_command: SimCommand | None = None | |
| self.first_command_s: float | None = None | |
| self.state_updates = 0 | |
| self.command_updates = 0 | |
| self.actuation_steps = 0 | |
| self.max_abs_tau = 0.0 | |
| self.max_torque_utilization = 0.0 | |
| self.max_abs_joint_velocity = 0.0 | |
| self.min_root_z = float(self.plant.data.qpos[2]) | |
| self.max_tilt_rad = 0.0 | |
| self.max_loop_lag_s = 0.0 | |
| self._last_command_stamp = 0 | |
| self._last_state_publish_wall = time.monotonic() | |
| self._fault_injected = False | |
| self._handover_offset_injected = False | |
| self.startup_support_released_s: float | None = None | |
| self.command_target_clamps = 0 | |
| self.max_unconstrained_effort_utilization = 0.0 | |
| self.max_target_projection_rad = 0.0 | |
| self.target_projection_ticks = 0 | |
| self.plant_metric_samples = 0 | |
| self.hit_window_samples = 0 | |
| self.min_paddle_to_ball_distance_m = float("inf") | |
| self.min_paddle_to_ball_elapsed_s: float | None = None | |
| self.min_paddle_to_hit_point_distance_m = float("inf") | |
| self.min_paddle_to_hit_point_elapsed_s: float | None = None | |
| self.last_paddle_center = self.plant.paddle_center_world() | |
| self.last_ball_position = np.array([0.0, 0.0, -1.0], dtype=np.float64) | |
| def _live_planner_context( | |
| self, packet: np.ndarray, *, planner_ready: bool | |
| ) -> tuple[bool, int]: | |
| """Resolve stroke side and command id using the deployed planner packet.""" | |
| values = np.asarray(packet, dtype=np.float64).reshape(-1) | |
| command_id = 0 | |
| if ( | |
| planner_ready | |
| and values.size >= H2_PLANNER_PACKET_LEN | |
| and np.isfinite(values[:H2_PLANNER_PACKET_LEN]).all() | |
| ): | |
| raw_command_id = float(values[14]) | |
| rounded_command_id = int(round(raw_command_id)) | |
| if rounded_command_id >= 0 and abs(raw_command_id - rounded_command_id) <= 1.0e-6: | |
| command_id = rounded_command_id | |
| if command_id > 0: | |
| pelvis = self.plant.data.qpos[:3].astype(np.float64) | |
| relative_hit = values[8:11] - pelvis | |
| yaw = yaw_of(self.plant.data.qpos[3:7]) | |
| lateral = ( | |
| -math.sin(yaw) * float(relative_hit[0]) | |
| + math.cos(yaw) * float(relative_hit[1]) | |
| ) | |
| self._live_forehand = bool( | |
| lateral <= self.plant.stroke_split_relative_y | |
| ) | |
| return self._live_forehand, command_id | |
| def _update_live_hybrid_ball( | |
| self, | |
| packet: np.ndarray, | |
| *, | |
| planner_ready: bool, | |
| wall_now_s: float, | |
| ) -> None: | |
| """Poll one latest real frame, then render the current hybrid state.""" | |
| if not self.live_mode: | |
| raise RuntimeError("hybrid ball update is live-mode only") | |
| previous_wall_s = self._live_ball_last_wall_s | |
| elapsed_s = 0.0 if previous_wall_s is None else max(0.0, wall_now_s - previous_wall_s) | |
| self._live_ball_last_wall_s = float(wall_now_s) | |
| snapshot = self.live_ball_controller.advance_simulation(elapsed_s) | |
| if snapshot.landed: | |
| landing_xy = self.live_ball_controller.ball.landing_xy | |
| print( | |
| "[HYBRID BALL LANDING] " | |
| f"xy_m={None if landing_xy is None else landing_xy.tolist()}", | |
| flush=True, | |
| ) | |
| sample = self.live_ball_source.poll_latest() | |
| if sample is not None: | |
| forehand, command_id = self._live_planner_context( | |
| packet, planner_ready=planner_ready | |
| ) | |
| snapshot = self.live_ball_controller.observe_real_sample( | |
| sample, | |
| paddle=self.plant.virtual_paddle_state(), | |
| forehand=forehand, | |
| command_id=command_id, | |
| ) | |
| if snapshot.contact: | |
| velocity = np.asarray(snapshot.marker_velocity_mps, dtype=np.float64) | |
| print( | |
| "[HYBRID BALL CONTACT] real->sim " | |
| f"command_id={command_id} outgoing_mps={velocity.tolist()}", | |
| flush=True, | |
| ) | |
| elif snapshot.new_rally: | |
| print( | |
| f"[HYBRID BALL NEW RALLY] source_stamp={sample.source_stamp_s:.6f}", | |
| flush=True, | |
| ) | |
| self.live_ball_snapshot = snapshot | |
| self.plant.update_markers_from_planner( | |
| packet, | |
| ready=planner_ready, | |
| ball_position_robot_m=snapshot.marker_position_robot_m, | |
| ) | |
| def _hybrid_ball_telemetry(self) -> dict[str, object]: | |
| if not self.live_mode or self.live_ball_snapshot is None: | |
| return {} | |
| snapshot = self.live_ball_snapshot | |
| receipt_age_s = ( | |
| None | |
| if snapshot.source_receipt_monotonic_ns <= 0 | |
| else max( | |
| 0.0, | |
| (time.monotonic_ns() - snapshot.source_receipt_monotonic_ns) * 1.0e-9, | |
| ) | |
| ) | |
| landing_xy = self.live_ball_controller.ball.landing_xy | |
| outgoing_velocity = self.live_ball_controller.ball.outgoing_vel | |
| return { | |
| "hybrid_ball_mode": snapshot.mode, | |
| "hybrid_ball_phase": snapshot.phase, | |
| "hybrid_ball_velocity_source": snapshot.velocity_source, | |
| "hybrid_ball_source_stamp_s": snapshot.source_stamp_s, | |
| "hybrid_ball_source_age_s": receipt_age_s, | |
| "hybrid_ball_contact_count": snapshot.contact_count, | |
| "hybrid_ball_simulated_steps": snapshot.simulated_steps, | |
| "hybrid_ball_marker_velocity_mps": ( | |
| None | |
| if snapshot.marker_velocity_mps is None | |
| else snapshot.marker_velocity_mps.tolist() | |
| ), | |
| "hybrid_ball_outgoing_velocity_mps": ( | |
| outgoing_velocity.tolist() | |
| if snapshot.contact_count > 0 | |
| else None | |
| ), | |
| "hybrid_ball_landing_xy": ( | |
| None if landing_xy is None else landing_xy.tolist() | |
| ), | |
| } | |
| def _latch_fault(self, reason: str, elapsed_s: float, *, inhibit: bool = False) -> None: | |
| if not self.fault_latched: | |
| self.fault_latched = True | |
| self.fault_reason = str(reason) | |
| self.fault_wall_s = float(elapsed_s) | |
| q, _, _, _ = self.plant.state() | |
| self.hold_q = q | |
| # Production LatchFault synchronizes the emergency flag even when | |
| # the triggering LowState is rejected and never copied into SHM. | |
| self.owner.mark_emergency() | |
| self.output_inhibited = self.output_inhibited or bool(inhibit) | |
| def _latched_hold_command(self, basis: SimCommand) -> SimCommand: | |
| """Recreate the production C++ captured-pose fault hold. | |
| A non-inhibiting fault keeps publishing a hold captured at the first | |
| fault edge. The hold target is a mechanical position (unlike a learned | |
| virtual PD target), and its gains are only reduced as needed to reserve | |
| the same 90 percent effort envelope as ``LatchedHold`` in the C++ | |
| bridge. The returned command is still projected from the latest state | |
| immediately before each simulated actuator tick. | |
| """ | |
| q, dq, _, _ = self.plant.state() | |
| captured = q if self.hold_q is None else np.asarray(self.hold_q, dtype=np.float64) | |
| hold_lo, hold_hi = command_position_bounds_hw( | |
| self.plant.joint_lo_hw, | |
| self.plant.joint_hi_hw, | |
| ) | |
| target = np.zeros(LOWCMD_DOF, dtype=np.float32) | |
| target[:H2_DOF] = np.clip(captured, hold_lo, hold_hi).astype(np.float32) | |
| desired_velocity = np.zeros(LOWCMD_DOF, dtype=np.float32) | |
| feedforward = np.zeros(LOWCMD_DOF, dtype=np.float32) | |
| kp = np.asarray(basis.kp, dtype=np.float32).copy() | |
| kd = np.asarray(basis.kd, dtype=np.float32).copy() | |
| torque_budget = HOLD_TORQUE_FRACTION * np.minimum( | |
| np.abs(self.plant.torque_lo_hw), | |
| np.abs(self.plant.torque_hi_hw), | |
| ) | |
| for index in range(H2_DOF): | |
| position_error = float(target[index]) - float(q[index]) | |
| abs_error = abs(position_error) | |
| if abs_error > 1.0e-6 and float(kp[index]) * abs_error > torque_budget[index]: | |
| kp[index] = np.float32(torque_budget[index] / abs_error) | |
| position_effort = abs(float(kp[index]) * position_error) | |
| damping_budget = max(0.0, float(torque_budget[index]) - position_effort) | |
| abs_velocity = abs(float(dq[index])) | |
| if ( | |
| abs_velocity > 1.0e-6 | |
| and float(kd[index]) * abs_velocity > damping_budget | |
| ): | |
| kd[index] = np.float32(damping_budget / abs_velocity) | |
| kp[H2_DOF:] = 0.0 | |
| kd[H2_DOF:] = 0.0 | |
| mode = np.zeros(LOWCMD_DOF, dtype=np.uint8) | |
| mode[:H2_DOF] = 1 | |
| return SimCommand( | |
| stamp_ns=basis.stamp_ns, | |
| enabled=basis.enabled, | |
| flags=basis.flags, | |
| mode_pr=MODE_PR, | |
| q=target, | |
| dq=desired_velocity, | |
| tau=feedforward, | |
| kp=kp, | |
| kd=kd, | |
| mode=mode, | |
| reserve=np.zeros(LOWCMD_DOF, dtype=np.uint32), | |
| ) | |
| def _validate_command(self, command: SimCommand, now_ns: int) -> str: | |
| required_flags = SIMULATOR_OWNER_FLAG | SIMULATION_RUNNER_ATTESTED_FLAG | |
| if command.flags & required_flags != required_flags: | |
| return "simulation runner attestation is missing" | |
| if command.flags & MOTION_SWITCHER_ATTESTED_FLAG: | |
| return "simulator received the real MotionSwitcher-attestation bit" | |
| if command.flags & PYTHON_FAULT_REQUEST_FLAG: | |
| return "Python runner requested the permanent fault latch" | |
| if not command.enabled: | |
| return "" | |
| age_s = (now_ns - command.stamp_ns) * 1.0e-9 | |
| if command.stamp_ns <= 0 or age_s < -0.01: | |
| return "command timestamp is invalid" | |
| if age_s > COMMAND_WATCHDOG_S: | |
| return f"command watchdog expired ({age_s:.3f}s)" | |
| arrays = np.concatenate([command.q, command.dq, command.tau, command.kp, command.kd]) | |
| if not np.isfinite(arrays).all(): | |
| return "command contains NaN/Inf" | |
| if command.mode_pr != MODE_PR: | |
| return f"command mode_pr={command.mode_pr}, expected PR(0)" | |
| if np.any(command.kp < 0.0) or np.any(command.kp > MAX_KP): | |
| return "command Kp is outside the ABI bounds" | |
| if np.any(command.kd < 0.0) or np.any(command.kd > MAX_KD): | |
| return "command Kd is outside the ABI bounds" | |
| if np.any(command.mode[:H2_DOF] != 1) or np.any(command.mode[H2_DOF:] != 0): | |
| return "command motor-mode slots are invalid" | |
| if np.any(command.reserve != 0): | |
| return "command reserve slots are non-zero" | |
| if any( | |
| np.any(arr[H2_DOF:] != 0.0) | |
| for arr in (command.q, command.dq, command.tau, command.kp, command.kd) | |
| ): | |
| return "inactive H2 slots 31..34 are non-zero" | |
| if np.any(command.q[29:31] != 0.0): | |
| return "raw learned head targets must be exactly zero" | |
| return "" | |
| def _constrain_for_latest_state(self, command: SimCommand) -> tuple[SimCommand | None, str]: | |
| """Project only actuator effort from the immutable raw virtual target. | |
| This is the H2 equivalent of the working G1/sim2sim actuator path: | |
| compute PD effort from the newest state, saturate that effort at the | |
| actuator ``ctrlrange``, then encode the saturated result as a local | |
| command for this tick. A learned ``q_des`` is therefore deliberately | |
| not intersected with a mechanical joint-target interval. In | |
| particular, an exact-zero raw head target may project away from zero | |
| when compensating velocity would otherwise exceed the torque limit. | |
| """ | |
| q, dq, _, _ = self.plant.state() | |
| # Keep the v7 raw learned target immutable. The next 1000 Hz tick must | |
| # start from it again and use that tick's newest measured q/dq. | |
| target = command.q.copy().astype(np.float64) | |
| velocity_offset = command.kd[:H2_DOF] * (command.dq[:H2_DOF] - dq) | |
| raw_effort = ( | |
| command.tau[:H2_DOF] | |
| + command.kp[:H2_DOF] * (target[:H2_DOF] - q) | |
| + velocity_offset | |
| ) | |
| torque_scale = np.maximum( | |
| np.abs(self.plant.torque_lo_hw), np.abs(self.plant.torque_hi_hw) | |
| ) | |
| self.max_unconstrained_effort_utilization = max( | |
| self.max_unconstrained_effort_utilization, | |
| float(np.max(np.abs(raw_effort) / torque_scale)), | |
| ) | |
| safe_effort = np.clip( | |
| raw_effort, | |
| self.plant.torque_lo_hw, | |
| self.plant.torque_hi_hw, | |
| ) | |
| saturated = np.abs(safe_effort - raw_effort) > 1.0e-7 | |
| zero_kp = command.kp[:H2_DOF] <= 1.0e-8 | |
| offset = command.tau[:H2_DOF] + velocity_offset | |
| zero_kp_infeasible = zero_kp & ( | |
| (offset < self.plant.torque_lo_hw - 1.0e-3) | |
| | (offset > self.plant.torque_hi_hw + 1.0e-3) | |
| ) | |
| if np.any(zero_kp_infeasible): | |
| index = int(np.flatnonzero(zero_kp_infeasible)[0]) | |
| return None, f"zero-Kp effort is infeasible for {HARDWARE_JOINT_NAMES[index]}" | |
| projected = saturated & ~zero_kp | |
| for index in np.flatnonzero(projected): | |
| kp = float(command.kp[index]) | |
| target[index] = q[index] + ( | |
| safe_effort[index] | |
| - float(command.tau[index]) | |
| - float(velocity_offset[index]) | |
| ) / kp | |
| self.command_target_clamps += int(np.count_nonzero(projected)) | |
| projection = float( | |
| np.max(np.abs(target[:H2_DOF] - np.asarray(command.q[:H2_DOF], dtype=np.float64))) | |
| ) | |
| self.max_target_projection_rad = max(self.max_target_projection_rad, projection) | |
| if np.any(projected): | |
| self.target_projection_ticks += 1 | |
| return SimCommand( | |
| stamp_ns=command.stamp_ns, | |
| enabled=command.enabled, | |
| flags=command.flags, | |
| mode_pr=command.mode_pr, | |
| q=target.astype(np.float32), | |
| dq=command.dq, | |
| tau=command.tau, | |
| kp=command.kp, | |
| kd=command.kd, | |
| mode=command.mode, | |
| reserve=command.reserve, | |
| ), "" | |
| def _observe_and_validate_plant_state( | |
| self, | |
| q: np.ndarray, | |
| dq: np.ndarray, | |
| quat_wxyz: np.ndarray, | |
| gyro: np.ndarray, | |
| ) -> str: | |
| """Update physics-rate peaks and mirror production LowStateFinite gates.""" | |
| self.plant_metric_samples += 1 | |
| root_z = float(self.plant.data.qpos[2]) | |
| if math.isfinite(root_z): | |
| self.min_root_z = min(self.min_root_z, root_z) | |
| if np.isfinite(dq).all(): | |
| self.max_abs_joint_velocity = max( | |
| self.max_abs_joint_velocity, float(np.max(np.abs(dq))) | |
| ) | |
| raw_quat = self.plant.data.qpos[3:7].astype(np.float64).copy() | |
| values = np.concatenate([q, dq, raw_quat, gyro, [root_z]]) | |
| if not np.isfinite(values).all(): | |
| return "simulated LowState contains NaN/Inf" | |
| quat_norm = float(np.linalg.norm(raw_quat)) | |
| if not LOWSTATE_MIN_QUATERNION_NORM <= quat_norm <= LOWSTATE_MAX_QUATERNION_NORM: | |
| return f"simulated LowState quaternion norm {quat_norm:.3f} is invalid" | |
| up_z = 1.0 - 2.0 * ( | |
| float(quat_wxyz[1]) * float(quat_wxyz[1]) | |
| + float(quat_wxyz[2]) * float(quat_wxyz[2]) | |
| ) | |
| tilt = float(math.acos(float(np.clip(up_z, -1.0, 1.0)))) | |
| self.max_tilt_rad = max(self.max_tilt_rad, tilt) | |
| bad_position = np.flatnonzero( | |
| (q < self.plant.joint_lo_hw - STATE_JOINT_LIMIT_TOLERANCE_RAD) | |
| | (q > self.plant.joint_hi_hw + STATE_JOINT_LIMIT_TOLERANCE_RAD) | |
| ) | |
| if bad_position.size: | |
| index = int(bad_position[0]) | |
| return ( | |
| "simulated LowState joint position left production domain: " | |
| f"{HARDWARE_JOINT_NAMES[index]}={q[index]:.4f}rad" | |
| ) | |
| bad_velocity = np.flatnonzero(np.abs(dq) > STATE_MAX_JOINT_VELOCITY_RAD_S) | |
| if bad_velocity.size: | |
| index = int(bad_velocity[0]) | |
| return ( | |
| "simulated LowState joint velocity left production domain: " | |
| f"{HARDWARE_JOINT_NAMES[index]}={dq[index]:.3f}rad/s" | |
| ) | |
| return "" | |
| def _observe_hit_reach(self, elapsed_s: float) -> None: | |
| deadline = self.scenario.command_at_s + self.scenario.time_to_hit_s | |
| if abs(float(elapsed_s) - deadline) > HIT_REACH_WINDOW_HALF_WIDTH_S: | |
| return | |
| paddle = self.plant.paddle_center_world() | |
| ball = self.plant.last_marker_positions.get("tt_ball") | |
| hit_point = np.asarray(self.scenario.hit_position, dtype=np.float64) | |
| if ball is None or not np.isfinite(np.concatenate([paddle, ball, hit_point])).all(): | |
| return | |
| self.hit_window_samples += 1 | |
| self.last_paddle_center = paddle.copy() | |
| self.last_ball_position = ball.copy() | |
| ball_distance = float(np.linalg.norm(paddle - ball)) | |
| hit_distance = float(np.linalg.norm(paddle - hit_point)) | |
| if ball_distance < self.min_paddle_to_ball_distance_m: | |
| self.min_paddle_to_ball_distance_m = ball_distance | |
| self.min_paddle_to_ball_elapsed_s = float(elapsed_s) | |
| if hit_distance < self.min_paddle_to_hit_point_distance_m: | |
| self.min_paddle_to_hit_point_distance_m = hit_distance | |
| self.min_paddle_to_hit_point_elapsed_s = float(elapsed_s) | |
| def _telemetry(self, elapsed_s: float, command: SimCommand | None) -> dict[str, object]: | |
| q, dq, quat, _ = self.plant.state() | |
| up_z = 1.0 - 2.0 * (quat[1] * quat[1] + quat[2] * quat[2]) | |
| tilt = float(math.acos(float(np.clip(up_z, -1.0, 1.0)))) | |
| paddle = self.plant.paddle_center_world() | |
| ball = self.plant.last_marker_positions.get("tt_ball", self.last_ball_position) | |
| telemetry = { | |
| "elapsed_s": round(float(elapsed_s), 6), | |
| "q_hw": q.tolist(), | |
| "dq_hw": dq.tolist(), | |
| "q_target_hw": self.plant.last_target_hw.tolist(), | |
| "q_desired_hw": ( | |
| np.asarray(command.q[:H2_DOF], dtype=np.float64).tolist() | |
| if command is not None and command.enabled | |
| else self.plant.last_target_hw.tolist() | |
| ), | |
| "tau_hw": self.plant.last_tau_hw.tolist(), | |
| "pelvis_pos": self.plant.data.qpos[:3].astype(float).tolist(), | |
| "pelvis_quat_wxyz": quat.tolist(), | |
| "tilt_rad": tilt, | |
| "paddle_center_world": paddle.tolist(), | |
| "ball_mocap_world": np.asarray(ball, dtype=np.float64).tolist(), | |
| "paddle_to_ball_distance_m": float(np.linalg.norm(paddle - ball)), | |
| "paddle_to_hit_point_distance_m": float( | |
| np.linalg.norm(paddle - np.asarray(self.scenario.hit_position, dtype=np.float64)) | |
| ), | |
| "command_stamp_ns": int(command.stamp_ns) if command is not None else 0, | |
| "fault_latched": self.fault_latched, | |
| "fault_reason": self.fault_reason, | |
| } | |
| telemetry.update(self._hybrid_ball_telemetry()) | |
| return telemetry | |
| def _planner_packet( | |
| self, | |
| *, | |
| now: float, | |
| elapsed_s: float, | |
| quat_wxyz: np.ndarray, | |
| ) -> tuple[np.ndarray, bool, bool, int | None]: | |
| packet = self.planner.packet( | |
| now=now, | |
| elapsed_s=elapsed_s, | |
| root_local=self.plant.data.qpos[:3], | |
| quat_wxyz=quat_wxyz, | |
| ) | |
| if not self.live_mode: | |
| return np.asarray(packet, dtype=np.float64), True, True, None | |
| return ( | |
| np.asarray(packet, dtype=np.float64), | |
| bool(getattr(self.planner, "last_packet_was_updated", False)), | |
| bool(getattr(self.planner, "last_packet_ready", False)), | |
| int(getattr(self.planner, "last_receipt_stamp_ns", 0)) or None, | |
| ) | |
| def _rotate_trace(path: Path, trace_file, max_bytes: int | None): | |
| if max_bytes is None or trace_file.tell() < max_bytes: | |
| return trace_file | |
| trace_file.close() | |
| oldest = path.with_name(path.name + ".3") | |
| try: | |
| oldest.unlink() | |
| except FileNotFoundError: | |
| pass | |
| for source_suffix, destination_suffix in ((".2", ".3"), (".1", ".2"), ("", ".1")): | |
| source = path.with_name(path.name + source_suffix) | |
| destination = path.with_name(path.name + destination_suffix) | |
| try: | |
| source.replace(destination) | |
| except FileNotFoundError: | |
| pass | |
| return path.open("w", encoding="utf-8") | |
| def _write_live_status( | |
| self, | |
| *, | |
| elapsed_s: float, | |
| command: SimCommand | None, | |
| planner_ready: bool, | |
| start_requested: bool, | |
| stop_requested: bool, | |
| ) -> None: | |
| if not self.live_status_path: | |
| return | |
| status = self._telemetry(elapsed_s, command) | |
| status.update( | |
| { | |
| "schema": "h2-live-mujoco-status-v1", | |
| "planner_ready": bool(planner_ready), | |
| "start_requested": bool(start_requested), | |
| "stop_requested": bool(stop_requested), | |
| "state_updates": self.state_updates, | |
| "command_updates": self.command_updates, | |
| "actuation_steps": self.actuation_steps, | |
| "max_torque_utilization": self.max_torque_utilization, | |
| "max_abs_joint_velocity_rad_s": self.max_abs_joint_velocity, | |
| "min_root_z_m": self.min_root_z, | |
| "max_tilt_rad": self.max_tilt_rad, | |
| "max_loop_lag_s": self.max_loop_lag_s, | |
| } | |
| ) | |
| path = Path(self.live_status_path) | |
| temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") | |
| try: | |
| temporary.write_text( | |
| json.dumps(status, sort_keys=True) + "\n", encoding="utf-8" | |
| ) | |
| os.replace(temporary, path) | |
| except OSError: | |
| try: | |
| temporary.unlink() | |
| except OSError: | |
| pass | |
| def run( | |
| self, | |
| stop_event, | |
| scenario_start_event, | |
| status_queue, | |
| *, | |
| start_request_event=None, | |
| stop_request_event=None, | |
| policy_active_event=None, | |
| ) -> dict[str, object]: | |
| view = None | |
| trace_file = None | |
| scenario_wall_start: float | None = None | |
| scenario_elapsed_s = 0.0 | |
| tick = 0 | |
| physics_accumulator = 0.0 | |
| next_tick = time.perf_counter() | |
| try: | |
| trace_path = Path(self.scenario.trace_path) if self.scenario.trace_path else None | |
| if trace_path is not None and self.trace_hz > 0.0: | |
| trace_path.parent.mkdir(parents=True, exist_ok=True) | |
| trace_file = trace_path.open("w", encoding="utf-8") | |
| if self.scenario.viewer: | |
| import mujoco.viewer | |
| view = mujoco.viewer.launch_passive(self.plant.model, self.plant.data) | |
| # A close rear three-quarter view keeps both feet, ankles and | |
| # the upper body visible without letting the table hide a | |
| # balance failure. Users can still orbit/zoom normally. | |
| view.cam.lookat[:] = self.plant.data.qpos[:3] | |
| view.cam.distance = 2.8 | |
| view.cam.azimuth = 55.0 | |
| view.cam.elevation = -10.0 | |
| q, dq, quat, gyro = self.plant.state() | |
| initial_state_reason = self._observe_and_validate_plant_state(q, dq, quat, gyro) | |
| if initial_state_reason: | |
| raise RuntimeError(initial_state_reason) | |
| packet, update_planner, planner_ready, planner_stamp_ns = self._planner_packet( | |
| now=time.monotonic(), elapsed_s=0.0, quat_wxyz=quat, | |
| ) | |
| if self.live_mode: | |
| self._update_live_hybrid_ball( | |
| packet, | |
| planner_ready=planner_ready, | |
| wall_now_s=time.monotonic(), | |
| ) | |
| else: | |
| self.plant.update_markers(elapsed_s=0.0, scenario=self.scenario) | |
| self.owner.write_state( | |
| q=q, dq=dq, quat_wxyz=quat, gyro=gyro, | |
| root_xy=self.plant.data.qpos[:2], quat_xyzw=quat[[1, 2, 3, 0]], | |
| planner_packet=packet, start_requested=False, stop_requested=False, | |
| emergency_stop=False, mode_pr=MODE_PR, | |
| update_planner=update_planner, | |
| planner_ready=planner_ready, | |
| planner_stamp_ns=planner_stamp_ns, | |
| ) | |
| self.state_updates += 1 | |
| ready_status = { | |
| "event": "ready", | |
| "shm_name": self.scenario.shm_name, | |
| "actuator_id_by_hw": self.plant.actuator_id_hw.tolist(), | |
| "qpos_addr_by_hw": self.plant.qpos_addr_hw.tolist(), | |
| "qvel_addr_by_hw": self.plant.qvel_addr_hw.tolist(), | |
| } | |
| if self.live_mode: | |
| ready_status["mocap_zmq_endpoint"] = self.live_ball_endpoint | |
| status_queue.put(ready_status) | |
| while not stop_event.is_set(): | |
| wall_now = time.monotonic() | |
| if scenario_start_event.is_set() and scenario_wall_start is None: | |
| scenario_wall_start = wall_now | |
| # Artifact/model loading can keep the transport owner alive | |
| # for seconds before the runner starts. Do not inherit that | |
| # arbitrary pre-scenario remainder: align the 200 Hz plant to | |
| # the fifth 1000 Hz transport tick for reproducible real-loop | |
| # simulation without changing either deployed clock. | |
| physics_accumulator = 0.0 | |
| elapsed_s = 0.0 if scenario_wall_start is None else wall_now - scenario_wall_start | |
| scenario_elapsed_s = float(elapsed_s) | |
| start_requested = bool( | |
| self.live_mode | |
| and start_request_event is not None | |
| and start_request_event.is_set() | |
| ) | |
| stop_requested = bool( | |
| self.live_mode | |
| and stop_request_event is not None | |
| and stop_request_event.is_set() | |
| ) | |
| if stop_requested and not self.fault_latched: | |
| self._latch_fault("simulated R1 stop request", elapsed_s) | |
| fault_active = ( | |
| not self.live_mode | |
| and | |
| scenario_wall_start is not None | |
| and self.scenario.fault_active(elapsed_s) | |
| ) | |
| command = self.owner.read_command() | |
| if command.stamp_ns != self._last_command_stamp: | |
| self.command_updates += 1 | |
| self._last_command_stamp = command.stamp_ns | |
| reason = self._validate_command(command, time.monotonic_ns()) | |
| if reason: | |
| if reason == "Python runner requested the permanent fault latch": | |
| self._latch_fault(reason, elapsed_s) | |
| elif command.enabled or self.last_valid_command is not None: | |
| self._latch_fault(reason, elapsed_s) | |
| elif command.enabled: | |
| first_valid_command = self.last_valid_command is None | |
| self.last_valid_command = command | |
| if first_valid_command: | |
| # The plant is intentionally stationary during model | |
| # loading. Start its 200 Hz phase from the first valid | |
| # 50 Hz command, not from an arbitrary transport tick | |
| # that happened while the runner was still loading. | |
| physics_accumulator = 0.0 | |
| if self.first_command_s is None and scenario_wall_start is not None: | |
| self.first_command_s = elapsed_s | |
| if fault_active and not self._fault_injected: | |
| self._fault_injected = True | |
| if self.scenario.fault_kind == "invalid_lowstate": | |
| self._latch_fault("injected invalid LowState", elapsed_s, inhibit=True) | |
| elif self.scenario.fault_kind == "non_pr_mode": | |
| self._latch_fault( | |
| "LowState left PR(0) mode (mode_pr=1)", elapsed_s, inhibit=True | |
| ) | |
| elif self.scenario.fault_kind == "motor_fault": | |
| self._latch_fault("injected motor fault", elapsed_s, inhibit=True) | |
| state_age = wall_now - self._last_state_publish_wall | |
| if self.scenario.fault_kind == "state_dropout" and fault_active and state_age > COMMAND_WATCHDOG_S: | |
| self._latch_fault("simulated LowState watchdog expired", elapsed_s, inhibit=True) | |
| root_supported = bool( | |
| scenario_wall_start is not None | |
| and not self.output_inhibited | |
| and ( | |
| ( | |
| self.live_mode | |
| and not ( | |
| policy_active_event is not None | |
| and policy_active_event.is_set() | |
| ) | |
| ) | |
| or ( | |
| not self.live_mode | |
| and elapsed_s < self.scenario.startup_support_until_s | |
| ) | |
| ) | |
| ) | |
| if ( | |
| scenario_wall_start is not None | |
| and not root_supported | |
| and self.startup_support_released_s is None | |
| ): | |
| self.startup_support_released_s = elapsed_s | |
| physics_accumulator += BRIDGE_DT | |
| step_physics = physics_accumulator + 1.0e-12 >= self.scenario.physics_dt | |
| if step_physics: | |
| physics_accumulator -= self.scenario.physics_dt | |
| if self.last_valid_command is None: | |
| # Model an already-powered stationary robot while policy and | |
| # CLoSD artifacts load. Physics begins on the first valid command. | |
| self.plant.mujoco.mj_forward(self.plant.model, self.plant.data) | |
| elif not step_physics: | |
| pass | |
| elif self.output_inhibited: | |
| self.plant.apply(None, root_supported=False) | |
| else: | |
| basis = self.last_valid_command | |
| if self.fault_latched: | |
| basis = self._latched_hold_command(basis) | |
| constrained, constrain_reason = self._constrain_for_latest_state(basis) | |
| if constrained is None: | |
| # Match production: a projection failure latches and | |
| # continues with the captured hold. It must never turn | |
| # a finite PR command into a zero-effort/free-fall tick. | |
| self._latch_fault(constrain_reason, elapsed_s) | |
| hold = self._latched_hold_command(self.last_valid_command) | |
| self.plant.apply(hold, root_supported=root_supported) | |
| self.actuation_steps += 1 | |
| else: | |
| self.plant.apply(constrained, root_supported=root_supported) | |
| self.actuation_steps += 1 | |
| offsets = np.asarray(self.scenario.handover_joint_offsets_hw, dtype=np.float64) | |
| preload_start_s = ( | |
| self.scenario.arm_at_s - self.scenario.handover_offset_preload_s | |
| ) | |
| if ( | |
| not self.live_mode | |
| and scenario_wall_start is not None | |
| and np.any(offsets != 0.0) | |
| and preload_start_s <= elapsed_s < self.scenario.arm_at_s | |
| ): | |
| # Model the tether/external load as a stationary constraint | |
| # that establishes the measured gravity offsets before L1. | |
| # Releasing this constraint at L1 avoids an instantaneous | |
| # joint/contact teleport on the policy's first frame. | |
| self.plant.data.qpos[self.plant.qpos_addr_hw] = ( | |
| self.plant.ready_q_hw + offsets | |
| ) | |
| self.plant.data.qvel[self.plant.qvel_addr_hw] = 0.0 | |
| self.plant.mujoco.mj_forward(self.plant.model, self.plant.data) | |
| self._handover_offset_injected = True | |
| q, dq, quat, gyro = self.plant.state() | |
| state_reason = self._observe_and_validate_plant_state(q, dq, quat, gyro) | |
| if state_reason: | |
| self._latch_fault(state_reason, elapsed_s, inhibit=True) | |
| self.max_abs_tau = max(self.max_abs_tau, float(np.max(np.abs(self.plant.last_tau_hw)))) | |
| limits = np.maximum(np.abs(self.plant.torque_lo_hw), np.abs(self.plant.torque_hi_hw)) | |
| self.max_torque_utilization = max( | |
| self.max_torque_utilization, | |
| float(np.max(np.abs(self.plant.last_tau_hw) / limits)), | |
| ) | |
| packet, planner_was_updated, planner_ready, planner_stamp_ns = self._planner_packet( | |
| now=wall_now, | |
| elapsed_s=elapsed_s, | |
| quat_wxyz=quat, | |
| ) | |
| if self.live_mode: | |
| self._update_live_hybrid_ball( | |
| packet, | |
| planner_ready=planner_ready, | |
| wall_now_s=wall_now, | |
| ) | |
| else: | |
| self.plant.update_markers(elapsed_s=elapsed_s, scenario=self.scenario) | |
| if step_physics and not self.live_mode: | |
| self._observe_hit_reach(elapsed_s) | |
| update_planner = planner_was_updated and not ( | |
| fault_active and self.scenario.fault_kind == "planner_receipt_dropout" | |
| ) | |
| if not self.live_mode: | |
| planner_ready = not ( | |
| fault_active and self.scenario.fault_kind == "planner_protocol_mismatch" | |
| ) | |
| emergency = self.fault_latched | |
| write_q = q.copy() | |
| if fault_active and self.scenario.fault_kind == "corrupt_shm_nan": | |
| write_q[0] = np.nan | |
| reject_lowstate = bool(state_reason) or ( | |
| fault_active | |
| and self.scenario.fault_kind in ( | |
| "state_dropout", "invalid_lowstate", "non_pr_mode", | |
| ) | |
| ) | |
| if not reject_lowstate: | |
| self.owner.write_state( | |
| q=write_q, | |
| dq=dq, | |
| quat_wxyz=quat, | |
| gyro=gyro, | |
| root_xy=(packet[:2] if self.live_mode and planner_ready else self.plant.data.qpos[:2]), | |
| quat_xyzw=(packet[3:7] if self.live_mode and planner_ready else quat[[1, 2, 3, 0]]), | |
| planner_packet=packet, | |
| start_requested=( | |
| start_requested | |
| if self.live_mode | |
| else scenario_wall_start is not None and elapsed_s >= self.scenario.arm_at_s | |
| ), | |
| stop_requested=stop_requested, | |
| emergency_stop=emergency, | |
| mode_pr=MODE_PR, | |
| update_state=True, | |
| update_planner=update_planner, | |
| planner_ready=planner_ready, | |
| planner_stamp_ns=planner_stamp_ns, | |
| ) | |
| self._last_state_publish_wall = wall_now | |
| self.state_updates += 1 | |
| trace_interval = ( | |
| None | |
| if self.trace_hz <= 0.0 | |
| else max(1, int(round(BRIDGE_HZ / self.trace_hz))) | |
| ) | |
| if trace_file is not None and trace_interval is not None and tick % trace_interval == 0: | |
| trace_file.write( | |
| json.dumps(self._telemetry(elapsed_s, command), separators=(",", ":")) | |
| + "\n" | |
| ) | |
| trace_file.flush() | |
| trace_file = self._rotate_trace( | |
| trace_path, trace_file, self.trace_max_bytes | |
| ) | |
| if self.live_mode and tick % max(1, int(BRIDGE_HZ // 2)) == 0: | |
| self._write_live_status( | |
| elapsed_s=elapsed_s, | |
| command=command, | |
| planner_ready=planner_ready, | |
| start_requested=start_requested, | |
| stop_requested=stop_requested, | |
| ) | |
| if view is not None: | |
| if not view.is_running(): | |
| stop_event.set() | |
| elif tick % 33 == 0: | |
| view.sync() | |
| tick += 1 | |
| next_tick += BRIDGE_DT | |
| delay = next_tick - time.perf_counter() | |
| if delay > 0.0: | |
| time.sleep(delay) | |
| else: | |
| self.max_loop_lag_s = max(self.max_loop_lag_s, -delay) | |
| next_tick = time.perf_counter() | |
| finally: | |
| if view is not None: | |
| view.close() | |
| if trace_file is not None: | |
| trace_file.close() | |
| self.owner.close() | |
| report = { | |
| "schema": "h2-mujoco-h2br-summary-v1", | |
| "live_mode": self.live_mode, | |
| "scenario": asdict(self.scenario), | |
| "physics_hz": 1.0 / self.scenario.physics_dt, | |
| "bridge_hz": BRIDGE_HZ, | |
| "scenario_elapsed_s": scenario_elapsed_s, | |
| "state_updates": self.state_updates, | |
| "command_updates": self.command_updates, | |
| "actuation_steps": self.actuation_steps, | |
| "first_command_s": self.first_command_s, | |
| "fault_injected": self._fault_injected, | |
| "fault_latched": self.fault_latched, | |
| "fault_reason": self.fault_reason, | |
| "fault_wall_s": self.fault_wall_s, | |
| "output_inhibited": self.output_inhibited, | |
| "max_abs_tau_nm": self.max_abs_tau, | |
| "max_torque_utilization": self.max_torque_utilization, | |
| "max_unconstrained_effort_utilization": self.max_unconstrained_effort_utilization, | |
| "command_target_clamps": self.command_target_clamps, | |
| "max_target_projection_rad": self.max_target_projection_rad, | |
| "target_projection_ticks": self.target_projection_ticks, | |
| "plant_metric_samples": self.plant_metric_samples, | |
| "max_abs_joint_velocity_rad_s": self.max_abs_joint_velocity, | |
| "min_root_z_m": self.min_root_z, | |
| "initial_root_z_m": float(self.plant.initial_root_pos[2]), | |
| "max_tilt_rad": self.max_tilt_rad, | |
| "hit_deadline_s": ( | |
| None | |
| if self.live_mode | |
| else self.scenario.command_at_s + self.scenario.time_to_hit_s | |
| ), | |
| "hit_reach_window_half_width_s": HIT_REACH_WINDOW_HALF_WIDTH_S, | |
| "hit_window_samples": self.hit_window_samples, | |
| "min_paddle_to_ball_distance_m": ( | |
| self.min_paddle_to_ball_distance_m | |
| if math.isfinite(self.min_paddle_to_ball_distance_m) | |
| else None | |
| ), | |
| "min_paddle_to_ball_elapsed_s": self.min_paddle_to_ball_elapsed_s, | |
| "min_paddle_to_hit_point_distance_m": ( | |
| self.min_paddle_to_hit_point_distance_m | |
| if math.isfinite(self.min_paddle_to_hit_point_distance_m) | |
| else None | |
| ), | |
| "min_paddle_to_hit_point_elapsed_s": self.min_paddle_to_hit_point_elapsed_s, | |
| "max_loop_lag_s": self.max_loop_lag_s, | |
| "startup_support_until_s": self.scenario.startup_support_until_s, | |
| "startup_support_released_s": self.startup_support_released_s, | |
| "handover_offset_injected": self._handover_offset_injected, | |
| "handover_joint_offsets_hw": list(self.scenario.handover_joint_offsets_hw), | |
| "handover_offset_preload_s": self.scenario.handover_offset_preload_s, | |
| "actuator_id_by_hardware_id": self.plant.actuator_id_hw.tolist(), | |
| "qpos_addr_by_hardware_id": self.plant.qpos_addr_hw.tolist(), | |
| "qvel_addr_by_hardware_id": self.plant.qvel_addr_hw.tolist(), | |
| } | |
| if self.live_mode: | |
| report["hybrid_ball"] = { | |
| "endpoint": self.live_ball_endpoint, | |
| **self._hybrid_ball_telemetry(), | |
| } | |
| return report | |
| def simulation_bridge_process(scenario_dict, stop_event, scenario_start_event, status_queue) -> None: | |
| """Spawn-safe bridge target; all errors are reported before process exit.""" | |
| try: | |
| scenario = SimScenario(**scenario_dict) | |
| bridge = SimMujocoBridge(scenario) | |
| report = bridge.run(stop_event, scenario_start_event, status_queue) | |
| status_queue.put({"event": "finished", "report": report}) | |
| except BaseException as error: | |
| status_queue.put({ | |
| "event": "error", | |
| "error": f"{type(error).__name__}: {error}", | |
| "traceback": traceback.format_exc(), | |
| }) | |
| raise | |
| def live_simulation_bridge_process( | |
| scenario_dict, | |
| stop_event, | |
| scenario_start_event, | |
| status_queue, | |
| planner_handles, | |
| start_request_event, | |
| stop_request_event, | |
| policy_active_event, | |
| trace_hz: float, | |
| trace_max_bytes: int, | |
| live_status_path: str, | |
| mocap_zmq_endpoint: str = "tcp://127.0.0.1:5556", | |
| ) -> None: | |
| """Spawn-safe live HIL target with no ROS or hardware imports.""" | |
| ball_source = None | |
| try: | |
| from .embodiments import load_embodiment | |
| from .live_hybrid_ball import ( | |
| DEFAULT_BALL_CONTROL_DT, | |
| HybridBallController, | |
| LatestZmqBallSource, | |
| ) | |
| from .ros_planner_ingress import FusedMujocoPlannerSource, SharedPlannerMailbox | |
| scenario = SimScenario(**scenario_dict) | |
| h2_spec = load_embodiment("h2") | |
| ball_source = LatestZmqBallSource( | |
| scenario.robot_base_origin_world, | |
| endpoint=mocap_zmq_endpoint, | |
| ) | |
| ball_controller = HybridBallController( | |
| paddle_half_width=h2_spec.paddle_half_width, | |
| paddle_half_height=h2_spec.paddle_half_height, | |
| hit_plane_x=h2_spec.hit_plane_x, | |
| ball_control_dt=DEFAULT_BALL_CONTROL_DT, | |
| ) | |
| planner = FusedMujocoPlannerSource( | |
| SharedPlannerMailbox(planner_handles), | |
| scenario.robot_base_origin_world, | |
| ) | |
| bridge = SimMujocoBridge( | |
| scenario, | |
| planner=planner, | |
| live_mode=True, | |
| trace_hz=trace_hz, | |
| trace_max_bytes=trace_max_bytes, | |
| live_status_path=live_status_path, | |
| live_ball_source=ball_source, | |
| live_ball_controller=ball_controller, | |
| live_ball_endpoint=mocap_zmq_endpoint, | |
| ) | |
| report = bridge.run( | |
| stop_event, | |
| scenario_start_event, | |
| status_queue, | |
| start_request_event=start_request_event, | |
| stop_request_event=stop_request_event, | |
| policy_active_event=policy_active_event, | |
| ) | |
| status_queue.put({"event": "finished", "report": report}) | |
| except BaseException as error: | |
| status_queue.put( | |
| { | |
| "event": "error", | |
| "error": f"{type(error).__name__}: {error}", | |
| "traceback": traceback.format_exc(), | |
| } | |
| ) | |
| raise | |
| finally: | |
| if ball_source is not None: | |
| ball_source.close() | |
| __all__ = [ | |
| "BRIDGE_HZ", | |
| "BRIDGE_DT", | |
| "COMMAND_WATCHDOG_S", | |
| "DEFAULT_PHYSICS_DT", | |
| "FAULT_KINDS", | |
| "H2MujocoPlant", | |
| "SimH2BridgeOwner", | |
| "SimMujocoBridge", | |
| "SimScenario", | |
| "SyntheticPlanner", | |
| "live_simulation_bridge_process", | |
| "simulation_bridge_process", | |
| "validate_sim_shm_name", | |
| ] | |