Skip to content

Commit fa9becc

Browse files
committed
Add sim2real startup ramp and safety guards
1 parent 6cab61a commit fa9becc

5 files changed

Lines changed: 222 additions & 19 deletions

File tree

teleopit/configs/onboard_sim2real.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ reference_anchor_velocity_smoothing_alpha: 0.25
1818
reference_steps: [0]
1919
reference_debug_log: false
2020

21+
# Startup ramp duration (seconds) -- smoothly blend from locked to policy positions
22+
startup_ramp_duration: 2.0
23+
# Joint velocity safety limit (rad/s) -- trigger emergency damping if exceeded
24+
joint_vel_limit: 10.0
25+
2126
# Physical robot SDK configuration
2227
real_robot:
2328
network_interface: "eth0"
29+
mode_machine: 5 # Fixed MODE_MACHINE for G1 HG protocol (matching GR00T)
2430
joint_map: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28]
2531
# Real-robot PD gains (from mjlab g1_constants.py stiffness/damping, passed directly to SDK — no scaling)
2632
kp_real: [40.2, 99.1, 40.2, 99.1, 28.5, 28.5,
@@ -36,6 +42,21 @@ real_robot:
3642
kd_damping: 8.0 # Damping mode kd
3743
control_mode: "PR" # PR=Pitch-Roll ankle mode
3844
msg_type: "HG" # HG protocol for G1
45+
# Joint position limits (from GR00T g1_29dof_gear_wbc.yaml)
46+
joint_pos_lower: [-2.5307, -0.5236, -2.7576, -0.087267, -0.87267, -0.2618,
47+
-2.5307, -2.9671, -2.7576, -0.087267, -0.87267, -0.2618,
48+
-2.618, -0.52, -0.52,
49+
-3.0892, -1.5882, -2.618, -1.0472,
50+
-1.972222054, -1.61443, -1.61443,
51+
-3.0892, -2.2515, -2.618, -1.0472,
52+
-1.972222054, -1.61443, -1.61443]
53+
joint_pos_upper: [2.8798, 2.9671, 2.7576, 2.8798, 0.5236, 0.2618,
54+
2.8798, 0.5236, 2.7576, 2.8798, 0.5236, 0.2618,
55+
2.618, 0.52, 0.52,
56+
2.6704, 2.2515, 2.618, 2.0944,
57+
1.972222054, 1.61443, 1.61443,
58+
2.6704, 1.5882, 2.618, 2.0944,
59+
1.972222054, 1.61443, 1.61443]
3960

4061
# Mocap mode switching safety checks
4162
mocap_switch:

teleopit/configs/pico4_sim2real.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ reference_anchor_velocity_smoothing_alpha: 0.25
1818
reference_steps: [0]
1919
reference_debug_log: false
2020

21+
# Startup ramp duration (seconds) -- smoothly blend from locked to policy positions
22+
startup_ramp_duration: 2.0
23+
# Joint velocity safety limit (rad/s) -- trigger emergency damping if exceeded
24+
joint_vel_limit: 10.0
25+
2126
# Physical robot SDK configuration
2227
real_robot:
2328
network_interface: "eth0"
29+
mode_machine: 5 # Fixed MODE_MACHINE for G1 HG protocol (matching GR00T)
2430
joint_map: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28]
2531
# Real-robot PD gains (from mjlab g1_constants.py stiffness/damping, passed directly to SDK — no scaling)
2632
kp_real: [40.2, 99.1, 40.2, 99.1, 28.5, 28.5,
@@ -36,6 +42,21 @@ real_robot:
3642
kd_damping: 8.0 # Damping mode kd
3743
control_mode: "PR" # PR=Pitch-Roll ankle mode
3844
msg_type: "HG" # HG protocol for G1
45+
# Joint position limits (from GR00T g1_29dof_gear_wbc.yaml)
46+
joint_pos_lower: [-2.5307, -0.5236, -2.7576, -0.087267, -0.87267, -0.2618,
47+
-2.5307, -2.9671, -2.7576, -0.087267, -0.87267, -0.2618,
48+
-2.618, -0.52, -0.52,
49+
-3.0892, -1.5882, -2.618, -1.0472,
50+
-1.972222054, -1.61443, -1.61443,
51+
-3.0892, -2.2515, -2.618, -1.0472,
52+
-1.972222054, -1.61443, -1.61443]
53+
joint_pos_upper: [2.8798, 2.9671, 2.7576, 2.8798, 0.5236, 0.2618,
54+
2.8798, 0.5236, 2.7576, 2.8798, 0.5236, 0.2618,
55+
2.618, 0.52, 0.52,
56+
2.6704, 2.2515, 2.618, 2.0944,
57+
1.972222054, 1.61443, 1.61443,
58+
2.6704, 1.5882, 2.618, 2.0944,
59+
1.972222054, 1.61443, 1.61443]
3960

4061
# Mocap mode switching safety checks
4162
mocap_switch:

teleopit/configs/sim2real.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,15 @@ reference_anchor_velocity_smoothing_alpha: 0.25
1818
reference_steps: [0]
1919
reference_debug_log: false
2020

21+
# Startup ramp duration (seconds) -- smoothly blend from locked to policy positions
22+
startup_ramp_duration: 2.0
23+
# Joint velocity safety limit (rad/s) -- trigger emergency damping if exceeded
24+
joint_vel_limit: 10.0
25+
2126
# Physical robot SDK configuration
2227
real_robot:
2328
network_interface: "eth0"
29+
mode_machine: 5 # Fixed MODE_MACHINE for G1 HG protocol (matching GR00T)
2430
joint_map: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28]
2531
# Real-robot PD gains (from mjlab g1_constants.py stiffness/damping, passed directly to SDK — no scaling)
2632
kp_real: [40.2, 99.1, 40.2, 99.1, 28.5, 28.5,
@@ -36,6 +42,21 @@ real_robot:
3642
kd_damping: 8.0 # Damping mode kd
3743
control_mode: "PR" # PR=Pitch-Roll ankle mode
3844
msg_type: "HG" # HG protocol for G1
45+
# Joint position limits (from GR00T g1_29dof_gear_wbc.yaml)
46+
joint_pos_lower: [-2.5307, -0.5236, -2.7576, -0.087267, -0.87267, -0.2618,
47+
-2.5307, -2.9671, -2.7576, -0.087267, -0.87267, -0.2618,
48+
-2.618, -0.52, -0.52,
49+
-3.0892, -1.5882, -2.618, -1.0472,
50+
-1.972222054, -1.61443, -1.61443,
51+
-3.0892, -2.2515, -2.618, -1.0472,
52+
-1.972222054, -1.61443, -1.61443]
53+
joint_pos_upper: [2.8798, 2.9671, 2.7576, 2.8798, 0.5236, 0.2618,
54+
2.8798, 0.5236, 2.7576, 2.8798, 0.5236, 0.2618,
55+
2.618, 0.52, 0.52,
56+
2.6704, 2.2515, 2.618, 2.0944,
57+
1.972222054, 1.61443, 1.61443,
58+
2.6704, 1.5882, 2.618, 2.0944,
59+
1.972222054, 1.61443, 1.61443]
3960

4061
# Mocap mode switching safety checks
4162
mocap_switch:

teleopit/sim2real/controller.py

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,31 @@ def __init__(self, cfg: Any) -> None:
187187
self._fixed_reference_yaw_quat: Float32Array | None = None
188188
self._fixed_reference_pivot_pos_w: Float32Array | None = None
189189

190+
# ---- Startup ramp (matching GR00T JointSafetyMonitor pattern) ----
191+
ramp_dur = float(cfg_get(cfg, "startup_ramp_duration", cfg_get(real_cfg, "startup_ramp_duration", 2.0)))
192+
self._ramp_duration_steps: int = max(1, int(ramp_dur * self.policy_hz))
193+
self._ramp_step: int = 0
194+
self._ramp_start_positions: Float32Array | None = None
195+
self._ramp_active: bool = False
196+
197+
# ---- Joint safety (inspired by GR00T JointSafetyMonitor) ----
198+
self._joint_vel_limit: float = float(
199+
cfg_get(cfg, "joint_vel_limit", cfg_get(real_cfg, "joint_vel_limit", 10.0))
200+
)
201+
joint_pos_lower = cfg_get(real_cfg, "joint_pos_lower", None)
202+
joint_pos_upper = cfg_get(real_cfg, "joint_pos_upper", None)
203+
if joint_pos_lower is not None and joint_pos_upper is not None:
204+
self._joint_pos_lower = np.asarray(joint_pos_lower, dtype=np.float32)
205+
self._joint_pos_upper = np.asarray(joint_pos_upper, dtype=np.float32)
206+
else:
207+
self._joint_pos_lower = None
208+
self._joint_pos_upper = None
209+
210+
# ---- Control loop timing diagnostics ----
211+
self._loop_count: int = 0
212+
self._loop_timer_start: float = 0.0
213+
self._timing_log_interval: int = int(5.0 * self.policy_hz) # every 5 seconds
214+
190215
# ---- Mocap switch safety ----
191216
mocap_sw = cfg_get(cfg, "mocap_switch", {})
192217
self._check_frames: int = int(cfg_get(mocap_sw, "check_frames", 10))
@@ -207,6 +232,8 @@ def run(self) -> None:
207232
"Control loop started | mode=IDLE | press Start to enter STANDING"
208233
)
209234
dt = 1.0 / self.policy_hz
235+
self._loop_count = 0
236+
self._loop_timer_start = time.monotonic()
210237

211238
try:
212239
while True:
@@ -226,16 +253,38 @@ def run(self) -> None:
226253
self._sleep_until(t0, dt)
227254
continue
228255

229-
# 3. Mode transitions
256+
# 3. Joint velocity safety check
257+
if self.mode in (RobotMode.STANDING, RobotMode.MOCAP):
258+
if self._check_joint_velocity_safety():
259+
self._sleep_until(t0, dt)
260+
continue
261+
262+
# 4. Mode transitions
230263
self._handle_transitions()
231264

232-
# 4. Execute current mode
265+
# 5. Execute current mode
233266
if self.mode == RobotMode.STANDING:
234267
self._standing_step()
235268
elif self.mode == RobotMode.MOCAP:
236269
self._mocap_step()
237270

238-
# 5. Rate control
271+
# 6. Timing diagnostics
272+
self._loop_count += 1
273+
elapsed = time.monotonic() - t0
274+
if elapsed > dt * 1.5:
275+
logger.warning(
276+
"Control loop overrun: %.1fms (target %.1fms)",
277+
elapsed * 1000, dt * 1000,
278+
)
279+
if self._loop_count % self._timing_log_interval == 0:
280+
wall = time.monotonic() - self._loop_timer_start
281+
actual_hz = self._loop_count / wall if wall > 0 else 0
282+
logger.info(
283+
"Control loop: %.1f Hz (target %.0f Hz) | mode=%s",
284+
actual_hz, self.policy_hz, self.mode.value,
285+
)
286+
287+
# 7. Rate control
239288
self._sleep_until(t0, dt)
240289

241290
except KeyboardInterrupt:
@@ -276,6 +325,13 @@ def _standing_step(self) -> None:
276325

277326
action = self.policy.compute_action(obs)
278327
target_dof_pos = self.policy.get_target_dof_pos(action)
328+
329+
# Apply startup ramp: smoothly blend from locked position to policy output
330+
target_dof_pos = self._apply_startup_ramp(target_dof_pos)
331+
332+
# Clip to joint limits if configured
333+
target_dof_pos = self._clip_to_joint_limits(target_dof_pos)
334+
279335
self.robot.send_positions(target_dof_pos)
280336

281337
self._last_action = np.asarray(action, dtype=np.float32).reshape(-1)
@@ -385,6 +441,10 @@ def _mocap_step(self) -> None:
385441
action = self.policy.compute_action(obs)
386442
target_dof_pos = self.policy.get_target_dof_pos(action)
387443

444+
# Apply startup ramp and joint limits
445+
target_dof_pos = self._apply_startup_ramp(target_dof_pos)
446+
target_dof_pos = self._clip_to_joint_limits(target_dof_pos)
447+
388448
# Update position targets (250Hz thread handles publishing)
389449
self.robot.send_positions(target_dof_pos)
390450

@@ -440,6 +500,57 @@ def _compute_anchor_velocities(
440500

441501
return anchor_lin_vel_w, anchor_ang_vel_w
442502

503+
# ------------------------------------------------------------------
504+
# Startup ramp and safety
505+
# ------------------------------------------------------------------
506+
507+
def _apply_startup_ramp(self, target_dof_pos: Float32Array) -> Float32Array:
508+
"""Smoothly ramp from locked position to policy output (matching GR00T pattern).
509+
510+
During the first ``_ramp_duration_steps`` after entering STANDING,
511+
linearly interpolate between the initial joint positions and the
512+
policy-commanded targets. This prevents the step discontinuity
513+
that causes violent shaking on the NX board.
514+
"""
515+
if not self._ramp_active:
516+
return target_dof_pos
517+
518+
if self._ramp_start_positions is None:
519+
# Should not happen, but be safe
520+
self._ramp_active = False
521+
return target_dof_pos
522+
523+
ramp_factor = min(1.0, self._ramp_step / self._ramp_duration_steps)
524+
ramped = self._ramp_start_positions + ramp_factor * (
525+
target_dof_pos - self._ramp_start_positions
526+
)
527+
528+
self._ramp_step += 1
529+
if self._ramp_step >= self._ramp_duration_steps:
530+
self._ramp_active = False
531+
logger.info("Startup ramp complete (%d steps)", self._ramp_duration_steps)
532+
533+
return np.asarray(ramped, dtype=np.float32)
534+
535+
def _clip_to_joint_limits(self, target_dof_pos: Float32Array) -> Float32Array:
536+
"""Clip target positions to configured joint limits."""
537+
if self._joint_pos_lower is not None and self._joint_pos_upper is not None:
538+
return np.clip(target_dof_pos, self._joint_pos_lower, self._joint_pos_upper)
539+
return target_dof_pos
540+
541+
def _check_joint_velocity_safety(self) -> bool:
542+
"""Check joint velocities against safety limit. Returns True if violation detected."""
543+
state = self.robot.get_state()
544+
max_vel = np.max(np.abs(state.qvel))
545+
if max_vel > self._joint_vel_limit:
546+
logger.error(
547+
"SAFETY: joint velocity %.2f rad/s exceeds limit %.2f -- entering damping",
548+
max_vel, self._joint_vel_limit,
549+
)
550+
self._enter_damping()
551+
return True
552+
return False
553+
443554
# ------------------------------------------------------------------
444555
# State machine transitions
445556
# ------------------------------------------------------------------
@@ -510,6 +621,15 @@ def _enter_standing(self) -> None:
510621
self._last_retarget_qpos = init_qpos
511622
self._last_reference_qpos = None
512623

624+
# Activate startup ramp: smoothly blend from current to policy targets
625+
self._ramp_start_positions = state.qpos.copy().astype(np.float32)
626+
self._ramp_step = 0
627+
self._ramp_active = True
628+
logger.info(
629+
"Startup ramp armed: %d steps (%.1fs)",
630+
self._ramp_duration_steps, self._ramp_duration_steps / self.policy_hz,
631+
)
632+
513633
if prev_mode == RobotMode.MOCAP:
514634
# Returning from MOCAP: soft reset to keep policy observation
515635
# continuity (history buffer, _last_action unchanged).

teleopit/sim2real/unitree_g1.py

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
Wraps DDS communication (LowCmd publisher / LowState subscriber) and
44
provides a RobotState-compatible API for the sim2real controller.
55
6-
Design based on unitreerobotics/xr_teleoperate patterns:
6+
Design based on unitreerobotics/xr_teleoperate and GR00T-WholeBodyControl patterns:
77
- LowCmd publisher is created LAZILY (only for low-level/debug mode)
88
- Uses unitree_hg_msg_dds__LowCmd_() default constructor for messages
9-
- mode_machine is read from LowState and stamped on every LowCmd
9+
- mode_machine fixed to 5 for G1 HG protocol (matching GR00T)
10+
- level_flag=0xFF, unused motor slots use PosStopF/VelStopF sentinels
1011
- CRC32 computed on every outgoing LowCmd
1112
- Continuous publishing at 250Hz via dedicated thread
1213
"""
@@ -32,6 +33,11 @@
3233
_MODE_PR = 0
3334
# Motor control rate (Hz) -- matches xr_teleoperate
3435
_PUBLISH_HZ = 250
36+
# Fixed MODE_MACHINE for G1 HG protocol (matching GR00T UNITREE_LEGGED_CONST)
37+
_MODE_MACHINE = 5
38+
# SDK sentinel values for "no command" motor slots (matching GR00T InitLowCmd)
39+
_POS_STOP_F = 2146000000.0
40+
_VEL_STOP_F = 16000.0
3541

3642

3743
def _cfg_get(cfg: Any, key: str, default: Any = None) -> Any:
@@ -102,9 +108,10 @@ def __init__(self, cfg: Any) -> None:
102108
self._publish_thread: threading.Thread | None = None
103109
self._publish_running: bool = False
104110

105-
# mode_machine tracking (read from LowState, must be set on every LowCmd)
106-
self._mode_machine: int = 0
107-
self._mode_machine_updated: bool = False
111+
# mode_machine: use fixed constant for G1 HG protocol (matching GR00T),
112+
# continuously tracked from LowState for diagnostics
113+
self._mode_machine: int = int(_cfg_get(cfg, "mode_machine", _MODE_MACHINE))
114+
self._lowstate_mode_machine: int = -1
108115

109116
# Cached MotionSwitcherClient (lazy init)
110117
self._motion_switcher: Any = None
@@ -117,8 +124,9 @@ def __init__(self, cfg: Any) -> None:
117124
logger.warning("No LowState received within 3s -- robot may not be connected")
118125
else:
119126
logger.info(
120-
"UnitreeG1Robot: mode_machine=%d from LowState",
127+
"UnitreeG1Robot: configured mode_machine=%d, LowState mode_machine=%d",
121128
self._mode_machine,
129+
getattr(self, "_lowstate_mode_machine", -1),
122130
)
123131

124132
logger.info(
@@ -132,9 +140,9 @@ def __init__(self, cfg: Any) -> None:
132140

133141
def _on_lowstate(self, msg: Any) -> None:
134142
self._lowstate = msg
135-
if not self._mode_machine_updated:
136-
self._mode_machine = msg.mode_machine
137-
self._mode_machine_updated = True
143+
# Continuously track mode_machine from robot for diagnostics;
144+
# LowCmd always uses the configured constant (_MODE_MACHINE).
145+
self._lowstate_mode_machine = msg.mode_machine
138146

139147
# ------------------------------------------------------------------
140148
# Lazy LowCmd publisher + 250Hz publish thread
@@ -159,15 +167,27 @@ def _ensure_low_level(self) -> None:
159167
self._cmd = self._LowCmd_Factory()
160168
self._cmd.mode_pr = _MODE_PR
161169
self._cmd.mode_machine = self._mode_machine
170+
self._cmd.level_flag = 0xFF # Required by G1 HG protocol (matching GR00T)
162171

163-
# Initialize all motors to damping (safe default)
172+
# Initialize all motor slots (matching GR00T InitLowCmd pattern).
173+
# Controlled joints (0..28) start in damping; unused slots (29..34)
174+
# get PosStopF/VelStopF sentinel values so the MCU ignores them.
164175
for i in range(_NUM_MOTORS):
165-
self._cmd.motor_cmd[i].mode = 1 # Enable motor (G1 hg protocol)
166-
self._cmd.motor_cmd[i].q = 0.0
167-
self._cmd.motor_cmd[i].kp = 0.0
168-
self._cmd.motor_cmd[i].dq = 0.0
169-
self._cmd.motor_cmd[i].kd = self._kd_damping
170-
self._cmd.motor_cmd[i].tau = 0.0
176+
self._cmd.motor_cmd[i].mode = 0x01 # G1 hg motor enable
177+
if i < _NUM_JOINTS:
178+
# Controlled joint: damping as safe default
179+
self._cmd.motor_cmd[i].q = 0.0
180+
self._cmd.motor_cmd[i].kp = 0.0
181+
self._cmd.motor_cmd[i].dq = 0.0
182+
self._cmd.motor_cmd[i].kd = self._kd_damping
183+
self._cmd.motor_cmd[i].tau = 0.0
184+
else:
185+
# Unused motor slot: SDK "stop" sentinels (matching GR00T)
186+
self._cmd.motor_cmd[i].q = _POS_STOP_F
187+
self._cmd.motor_cmd[i].kp = 0.0
188+
self._cmd.motor_cmd[i].dq = _VEL_STOP_F
189+
self._cmd.motor_cmd[i].kd = 0.0
190+
self._cmd.motor_cmd[i].tau = 0.0
171191

172192
self._low_level_ready = True
173193
logger.info("LowCmd publisher created (low-level control ready)")

0 commit comments

Comments
 (0)