Download scripts/test_racket_drop_and_walk.py from Raghav-16/Microduck_Manipulation: direct link, hf CLI and curl.
- Browser
- Download file 52 kB
-
https://huggingface.co/Raghav-16/Microduck_Manipulation/resolve/main/scripts/test_racket_drop_and_walk.py
- Command line
-
hf download hf://Raghav-16/Microduck_Manipulation/scripts/test_racket_drop_and_walk.py
-
curl -L -o test_racket_drop_and_walk.py https://huggingface.co/Raghav-16/Microduck_Manipulation/resolve/main/scripts/test_racket_drop_and_walk.py
52 kB
| #!/usr/bin/env python3 | |
| """Evaluate Scaled Racket Minimal Drop, Physical Beak Clamping & Walking Balance. | |
| Protocol: | |
| 1. Phase 1 (0.0s - 0.4s): Robot stands in stable biped balance with beak WIDE OPEN (aperture ~24mm, q=2.04 rad). | |
| 2. Phase 2 (0.4s - 0.5s): Scaled racket handle is minimally dropped (3mm) onto lower silicone jaw bed. | |
| 3. Phase 3 (0.5s - 0.8s): Beak closes with 0.18 N·m calibrated servo torque, firmly clamping handle without mesh penetration. | |
| 4. Phase 4 (0.8s - 5.0s): Robot walks forward at Vx = 0.20 m/s with zero welds, pure contact physics. | |
| Note for macOS: Run with `mjpython` when using `--gui`: | |
| microduck_rl/.venv/bin/mjpython microduck_manipulation/scripts/test_racket_drop_and_walk.py --racket pickleball --gui --loop | |
| """ | |
| import argparse | |
| import math | |
| import os | |
| import sys | |
| import time | |
| import numpy as np | |
| import mujoco | |
| import mujoco.viewer | |
| from PIL import Image | |
| _SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| _MANIP_DIR = os.path.dirname(_SCRIPT_DIR) | |
| sys.path.insert(0, _SCRIPT_DIR) | |
| from infer_policy import PolicyInference, DEFAULT_POSE | |
| _BASE_SCENE_XML = os.path.join(_MANIP_DIR, "models/scene_loadcarry_physical_clamp.xml") | |
| _DEFAULT_POLICY = os.path.join(_MANIP_DIR, "policies/onnx/payload_clamping_locomotion.onnx") | |
| _TT_POLICY = os.path.join(_MANIP_DIR, "policies/onnx/racket_ball_balance.onnx") | |
| _TT_CARRY_POLICY = os.path.join(_MANIP_DIR, "policies/onnx/racket_steadicam_carry.onnx") | |
| _OUTPUT_DIR = os.path.join(_MANIP_DIR, "docs/media") | |
| _ARTIFACT_DIR = _OUTPUT_DIR | |
| os.makedirs(_OUTPUT_DIR, exist_ok=True) | |
| class TableTennisPolicyInference(PolicyInference): | |
| """Extends PolicyInference with the 8 ball balancing observations (69D total).""" | |
| def __init__(self, model, data, onnx_path): | |
| super().__init__( | |
| model, data, | |
| walking_onnx_path=onnx_path, | |
| action_scale=1.0, | |
| new_cmd_obs=True, | |
| use_projected_gravity=True, | |
| ) | |
| # Calibrated nominal posture: matched to DEFAULT_POSE from loadcarry v4 | |
| self.default_pose[5] = float(DEFAULT_POSE[5]) # neck_pitch: 0.3491 rad | |
| self.default_pose[6] = float(DEFAULT_POSE[6]) # head_pitch: 0.3491 rad | |
| self.default_pose[7] = 0.0 # head_yaw | |
| self.default_pose[8] = 0.0 # head_roll | |
| self.racket_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "racket") | |
| self.ball_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "pingpong_ball") | |
| def get_ball_observations(self): | |
| p_racket = self.data.xpos[self.racket_bid] | |
| R_racket = self.data.xmat[self.racket_bid].reshape(3, 3) | |
| p_ball = self.data.xpos[self.ball_bid] | |
| p_rel = R_racket.T @ (p_ball - p_racket) | |
| dx = float(p_rel[0]) | |
| dz = float(p_rel[2] - 0.1015) | |
| v_ball = self.data.cvel[self.ball_bid][3:6] | |
| v_racket = self.data.cvel[self.racket_bid][3:6] | |
| v_rel_local = R_racket.T @ (v_ball - v_racket) | |
| vx = float(v_rel_local[0]) | |
| vz = float(v_rel_local[2]) | |
| R_base = self.data.xmat[self.trunk_base_id].reshape(3, 3) | |
| normal_world = R_racket @ np.array([0.0, 1.0, 0.0]) | |
| normal_base = R_base.T @ normal_world | |
| ball_contact = 0.0 | |
| for ci in range(self.data.ncon): | |
| con = self.data.contact[ci] | |
| b1 = self.model.geom_bodyid[con.geom1] | |
| b2 = self.model.geom_bodyid[con.geom2] | |
| if (b1 == self.ball_bid and b2 == self.racket_bid) or (b1 == self.racket_bid and b2 == self.ball_bid): | |
| ball_contact = 1.0 | |
| break | |
| pitch_err = float(math.asin(np.clip(normal_world[0], -0.999, 0.999))) | |
| roll_err = float(math.asin(np.clip(normal_world[1], -0.999, 0.999))) | |
| expected_dim = self.ort_session.get_inputs()[0].shape[1] if hasattr(self, "ort_session") else 71 | |
| if isinstance(expected_dim, int) and expected_dim == 71: | |
| return np.array( | |
| [dx, dz, vx, vz, normal_base[0], normal_base[1], normal_base[2], ball_contact, pitch_err, roll_err], | |
| dtype=np.float32, | |
| ) | |
| return np.array([dx, dz, vx, vz, normal_base[0], normal_base[1], normal_base[2], ball_contact], dtype=np.float32) | |
| def get_observations(self): | |
| base_obs = super().get_observations() | |
| ball_obs = self.get_ball_observations() | |
| return np.concatenate([base_obs, ball_obs]).astype(np.float32) | |
| class TableTennisRacketCarryPolicyInference(PolicyInference): | |
| """Extends PolicyInference with the 3D racket normal in base frame (64D total, zero ball).""" | |
| def __init__(self, model, data, onnx_path): | |
| super().__init__( | |
| model, data, | |
| walking_onnx_path=onnx_path, | |
| action_scale=1.0, | |
| new_cmd_obs=True, | |
| use_projected_gravity=True, | |
| ) | |
| self.default_pose[5] = 0.2760 # neck_pitch: calibrated horizontal zero-pitch setpoint (mean pitch = -0.87°, std = 1.54°) | |
| self.default_pose[6] = float(DEFAULT_POSE[6]) # head_pitch: 0.3491 rad (+20°) from loadcarry v4 | |
| self.default_pose[7] = 0.0 # head_yaw | |
| self.default_pose[8] = 0.0 # head_roll: bilateral zero roll for forward carry | |
| self.racket_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "racket") | |
| def get_racket_normal_base(self): | |
| R_racket = self.data.xmat[self.racket_bid].reshape(3, 3) | |
| R_base = self.data.xmat[self.trunk_base_id].reshape(3, 3) | |
| normal_world = R_racket @ np.array([0.0, 1.0, 0.0]) | |
| normal_base = R_base.T @ normal_world | |
| return normal_base.astype(np.float32) | |
| def get_observations(self): | |
| base_obs = super().get_observations() | |
| rkt_norm = self.get_racket_normal_base() | |
| return np.concatenate([base_obs, rkt_norm]).astype(np.float32) | |
| def create_scaled_scene_xml(racket_type="table_tennis", include_ball=True): | |
| """Builds an MJCF string embedding the racket and ball with zero welds.""" | |
| with open(_BASE_SCENE_XML, "r") as f: | |
| base = f.read() | |
| assets_dir = os.path.join(_MANIP_DIR, "models/assets") | |
| import re | |
| base = re.sub(r'meshdir="[^"]*"', f'meshdir="{assets_dir}/" balanceinertia="true"', base) | |
| base = base.replace('elevation="-20"/>', 'elevation="-20" offwidth="1280" offheight="960"/>') | |
| extra_assets = """ | |
| <!-- CAD Meshes for Solid Table Tennis Racket (100% Solid Full-Tang Wood, Zero Hollow Gaps) --> | |
| <mesh name="tt_blade_wood_mesh" file="tt_blade_wood.stl" scale="0.70 0.70 0.70"/> | |
| <mesh name="tt_rubber_red_mesh" file="tt_rubber_red.stl" scale="0.70 0.70 0.70"/> | |
| <mesh name="tt_rubber_black_mesh" file="tt_rubber_black.stl" scale="0.70 0.70 0.70"/> | |
| <mesh name="tt_handle_scales_mesh" file="tt_handle_scales.stl" scale="0.70 0.70 0.70"/> | |
| <mesh name="tt_handle_stripe_mesh" file="tt_handle_stripe.stl" scale="0.70 0.70 0.70"/> | |
| <mesh name="tt_handle_lens_mesh" file="tt_handle_lens.stl" scale="0.70 0.70 0.70"/> | |
| <material name="tt_wood_ply" rgba="0.88 0.77 0.60 1" specular="0.25" shininess="0.15"/> | |
| <material name="tt_rubber_red" rgba="0.86 0.10 0.12 1" specular="0.45" shininess="0.4"/> | |
| <material name="tt_rubber_black" rgba="0.12 0.12 0.14 1" specular="0.4" shininess="0.3"/> | |
| <material name="tt_handle_wood" rgba="0.62 0.44 0.28 1" specular="0.2" shininess="0.1"/> | |
| <material name="tt_handle_stripe_mat" rgba="0.20 0.30 0.55 1" specular="0.3" shininess="0.2"/> | |
| <material name="tt_lens_gold" rgba="0.92 0.80 0.24 1" specular="0.9" shininess="0.95"/> | |
| <material name="tt_ball_orange" rgba="1.00 0.58 0.08 1" specular="0.4" shininess="0.5"/> | |
| <material name="pb_edge_guard" rgba="0.12 0.12 0.14 1" specular="0.3" shininess="0.4"/> | |
| <material name="pb_face_cyan" rgba="0.08 0.65 0.84 1" specular="0.5" shininess="0.6"/> | |
| <material name="pb_face_yellow" rgba="0.98 0.82 0.15 1" specular="0.6" shininess="0.7"/> | |
| <material name="pb_grip_white" rgba="0.94 0.94 0.96 1" specular="0.1" shininess="0.1"/> | |
| <material name="pb_butt_cap" rgba="0.12 0.12 0.15 1" specular="0.4" shininess="0.5"/> | |
| <material name="bm_frame_blue" rgba="0.12 0.25 0.48 1" specular="0.8" shininess="0.9"/> | |
| <material name="bm_shaft_carbon" rgba="0.09 0.14 0.22 1" specular="0.8" shininess="0.9"/> | |
| <material name="bm_t_joint_gold" rgba="0.90 0.78 0.22 1" specular="0.95" shininess="0.95"/> | |
| <material name="bm_strings" rgba="0.95 0.98 0.98 0.42" specular="0.3" shininess="0.5"/> | |
| <material name="bm_grip_black" rgba="0.12 0.12 0.12 1" specular="0.1" shininess="0.1"/> | |
| <material name="bm_cone_grey" rgba="0.25 0.28 0.32 1" specular="0.5" shininess="0.6"/> | |
| <material name="bm_butt_green" rgba="0.05 0.52 0.28 1" specular="0.5" shininess="0.6"/> | |
| </asset> | |
| """ | |
| base = base.replace(" </asset>", extra_assets) | |
| cyl_start = base.find('<body name="cylinder"') | |
| cyl_end = base.find('</body>\n </worldbody>') + len('</body>') | |
| old_cyl = base[cyl_start:cyl_end] | |
| if racket_type == "table_tennis": | |
| # Proportionally Scaled 0.70x Table Tennis Racket & Scaled Ball: | |
| # Total Assembled Mass: 80g (60g handle + 20g blade face overhang, handle-biased counterbalance) | |
| # Dimensions: 178mm length, 105mm width, 110mm blade head (100% full visual scale) | |
| # 100% Solid Full-Tang Wood Handle: 62mm length, 8.4mm thickness | |
| body_str = """ <body name="racket" pos="0.048 0.0 0.220" quat="0.7071068 0.7071068 0 0"> | |
| <freejoint name="racket_free"/> | |
| <!-- Scaled tournament racket mass: 40 grams (30g handle / 10g blade face, COM at z=16mm) --> | |
| <inertial pos="0 0 0.0160" mass="0.040" diaginertia="0.0000425 0.0000360 0.0000140"/> | |
| <!-- High-Fidelity CAD STL Visual Geoms (0.70x Scale) --> | |
| <geom name="tt_wood_vis" type="mesh" mesh="tt_blade_wood_mesh" material="tt_wood_ply" contype="0" conaffinity="0" group="0"/> | |
| <geom name="tt_red_vis" type="mesh" mesh="tt_rubber_red_mesh" material="tt_rubber_red" contype="0" conaffinity="0" group="0"/> | |
| <geom name="tt_black_vis" type="mesh" mesh="tt_rubber_black_mesh" material="tt_rubber_black" contype="0" conaffinity="0" group="0"/> | |
| <geom name="tt_handle_vis" type="mesh" mesh="tt_handle_scales_mesh" material="tt_handle_wood" contype="0" conaffinity="0" group="0"/> | |
| <geom name="tt_stripe_vis" type="mesh" mesh="tt_handle_stripe_mesh" material="tt_handle_stripe_mat" contype="0" conaffinity="0" group="0"/> | |
| <geom name="tt_lens_vis" type="mesh" mesh="tt_handle_lens_mesh" material="tt_lens_gold" contype="0" conaffinity="0" group="0"/> | |
| <!-- Collision Geoms (group="3" purely invisible collision) --> | |
| <!-- Solid Wood Handle for Beak Clamping --> | |
| <geom name="racket_handle" type="box" pos="0 0 0.008" size="0.01155 0.00294 0.031" material="tt_handle_wood" friction="8.0 0.5 0.5" contype="0" conaffinity="0" group="3"/> | |
| <!-- Flat Rubber Surface for Ball Contact --> | |
| <geom name="racket_face" type="box" pos="0 0 0.1015" size="0.0525 0.0035 0.0540" material="tt_rubber_red" friction="0.80 0.005 0.005" contype="0" conaffinity="0" group="3"/> | |
| </body>""" | |
| if include_ball: | |
| body_str += """ | |
| <!-- Proportionally Scaled 0.70x Table Tennis Ball (Diameter 28.1mm, Mass 1.30g) --> | |
| <body name="pingpong_ball" pos="0.050 0.1015 0.220"> | |
| <freejoint name="ball_free"/> | |
| <inertial pos="0 0 0" mass="0.0013" diaginertia="0.000000170 0.000000170 0.000000170"/> | |
| <geom name="ball_geom" type="sphere" size="0.0141" material="tt_ball_orange" friction="0.80 0.005 0.005" condim="6"/> | |
| <!-- ITTF 3-Star Branding Emblem on Ball --> | |
| <geom name="ball_star1" type="sphere" pos="0 0.0142 0" size="0.0011" rgba="0.15 0.15 0.15 1" contype="0" conaffinity="0"/> | |
| <geom name="ball_star2" type="sphere" pos="-0.0021 0.0142 0" size="0.0009" rgba="0.15 0.15 0.15 1" contype="0" conaffinity="0"/> | |
| <geom name="ball_star3" type="sphere" pos="0.0021 0.0142 0" size="0.0009" rgba="0.15 0.15 0.15 1" contype="0" conaffinity="0"/> | |
| </body>""" | |
| elif racket_type == "pickleball": | |
| # Scaled Pickleball Paddle: 120mm handle (spans Y = -60mm to +60mm) | |
| # Throat starts at Y = +68mm (over 22mm outside the 45.7mm jaw cheek!) | |
| body_str = """ <body name="racket" pos="0.048 0.0 0.220" quat="0.7071068 0.7071068 0 0"> | |
| <freejoint name="racket_free"/> | |
| <!-- Mass: 75g counterbalanced so COM is at mouth center (z=0) --> | |
| <inertial pos="0 0 0" mass="0.075" diaginertia="0.00018 0.00015 0.000035"/> | |
| <geom name="racket_butt" type="cylinder" pos="0 0 -0.064" size="0.007 0.002" material="pb_butt_cap" friction="2.5 0.1 0.1"/> | |
| <!-- ONLY THIS 8.0mm HANDLE IS INSIDE THE MOUTH (length 120mm, spans Y = -60mm to +60mm) --> | |
| <geom name="racket_handle" type="cylinder" pos="0 0 0" size="0.004 0.060" material="pb_grip_white" friction="3.5 0.2 0.2" contype="0" conaffinity="0"/> | |
| <!-- Throat starts at z = 0.068 (Y = +68mm, over 22mm outside the 45.7mm jaw cheek!) --> | |
| <geom name="racket_throat" type="box" pos="0 0 0.068" size="0.012 0.003 0.008" material="pb_edge_guard"/> | |
| <!-- Paddle face: completely outside mouth and clear of jaw --> | |
| <geom name="racket_face" type="box" pos="0 0 0.128" size="0.035 0.0028 0.046" material="pb_face_cyan" friction="1.8 0.1 0.1"/> | |
| <geom name="racket_accent" type="box" pos="0 0.0030 0.128" size="0.028 0.0003 0.016" material="pb_face_yellow"/> | |
| <geom name="racket_edge_l" type="capsule" fromto="-0.039 0 0.082 -0.039 0 0.160" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_edge_r" type="capsule" fromto="0.039 0 0.082 0.039 0 0.160" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_edge_top" type="capsule" fromto="-0.026 0 0.174 0.026 0 0.174" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_cor_tl" type="capsule" fromto="-0.039 0 0.160 -0.026 0 0.174" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_cor_tr" type="capsule" fromto="0.039 0 0.160 0.026 0 0.174" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_cor_bl" type="capsule" fromto="-0.039 0 0.082 -0.012 0 0.070" size="0.0035" material="pb_edge_guard"/> | |
| <geom name="racket_cor_br" type="capsule" fromto="0.039 0 0.082 0.012 0 0.070" size="0.0035" material="pb_edge_guard"/> | |
| </body>""" | |
| else: | |
| # Scaled Badminton Racket: 110mm handle (spans Y = -55mm to +55mm), lengthened 88mm shaft, and 2x doubled oval head | |
| body_str = """ <body name="racket" pos="0.048 0.0 0.220" quat="0.7071068 0.7071068 0 0"> | |
| <freejoint name="racket_free"/> | |
| <!-- Mass: 45g counterbalanced so COM is at mouth center (z=0) --> | |
| <inertial pos="0 0 0" mass="0.045" diaginertia="0.00028 0.00028 0.000015"/> | |
| <geom name="racket_butt" type="cylinder" pos="0 0 -0.058" size="0.0055 0.0015" material="bm_butt_green" friction="2.0 0.1 0.1"/> | |
| <geom name="racket_handle" type="cylinder" pos="0 0 0" size="0.004 0.055" material="bm_grip_black" friction="3.5 0.2 0.2" contype="0" conaffinity="0"/> | |
| <geom name="racket_cone" type="cylinder" pos="0 0 0.058" size="0.0035 0.003" material="bm_cone_grey"/> | |
| <!-- Lengthened carbon shaft (88mm total span: z from 0.060 to 0.148) --> | |
| <geom name="racket_shaft" type="cylinder" pos="0 0 0.104" size="0.0022 0.044" material="bm_shaft_carbon" friction="2.5 0.1 0.1"/> | |
| <geom name="racket_t_joint" type="cylinder" pos="0 0 0.150" size="0.0032 0.003" material="bm_t_joint_gold"/> | |
| <!-- 2x Doubled Oval Head (100mm width, 124mm length: z from 0.153 to 0.277) --> | |
| <geom name="bm_fr_0" type="capsule" fromto="0.0500 0 0.2150 0.0462 0 0.2387" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_1" type="capsule" fromto="0.0462 0 0.2387 0.0354 0 0.2588" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_2" type="capsule" fromto="0.0354 0 0.2588 0.0191 0 0.2723" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_3" type="capsule" fromto="0.0191 0 0.2723 0.0000 0 0.2770" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_4" type="capsule" fromto="0.0000 0 0.2770 -0.0191 0 0.2723" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_5" type="capsule" fromto="-0.0191 0 0.2723 -0.0354 0 0.2588" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_6" type="capsule" fromto="-0.0354 0 0.2588 -0.0462 0 0.2387" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_7" type="capsule" fromto="-0.0462 0 0.2387 -0.0500 0 0.2150" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_8" type="capsule" fromto="-0.0500 0 0.2150 -0.0462 0 0.1913" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_9" type="capsule" fromto="-0.0462 0 0.1913 -0.0354 0 0.1712" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_10" type="capsule" fromto="-0.0354 0 0.1712 -0.0191 0 0.1577" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_11" type="capsule" fromto="-0.0191 0 0.1577 -0.0000 0 0.1530" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_12" type="capsule" fromto="-0.0000 0 0.1530 0.0191 0 0.1577" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_13" type="capsule" fromto="0.0191 0 0.1577 0.0354 0 0.1712" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_14" type="capsule" fromto="0.0354 0 0.1712 0.0462 0 0.1913" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="bm_fr_15" type="capsule" fromto="0.0462 0 0.1913 0.0500 0 0.2150" size="0.0018" material="bm_frame_blue"/> | |
| <geom name="racket_strings" type="ellipsoid" pos="0 0 0.2150" size="0.047 0.0002 0.059" material="bm_strings" contype="0" conaffinity="0"/> | |
| </body>""" | |
| xml = base.replace(old_cyl, body_str) | |
| xml = xml.replace('geom1="cylinder_geom"', 'geom1="racket_handle"') | |
| xml = xml.replace('body1="cylinder"', 'body1="racket"') | |
| # 1. Body Exclusions to prevent hinge mesh overlap | |
| xml = xml.replace('<exclude body1="cylinder" body2="trunk_base"/>', | |
| '<exclude body1="racket" body2="trunk_base"/>\n' | |
| ' <exclude body1="neck" body2="beak_lower"/>\n' | |
| ' <exclude body1="neck_pitch" body2="beak_lower"/>\n' | |
| ' <exclude body1="jaw_soft" body2="beak_lower"/>\n' | |
| ' <exclude body1="pingpong_ball" body2="trunk_base"/>') | |
| # 2. Hard limits on beak joint (prevents over-rotation) | |
| xml = xml.replace('<joint name="beak" pos="0 0 0" axis="0 1 0" range="1.47 2.05" ref="1.500" damping="0.05"/>', | |
| '<joint name="beak" pos="0 0 0" axis="0 1 0" range="1.47 2.05" ref="1.500" damping="0.05" limited="true"/>') | |
| # 3. Soft silicone mouth lining parameters (compliant grip, zero collision explosion) | |
| xml = xml.replace('solref="0.004 1.0"', 'solref="0.002 1.0"') | |
| xml = xml.replace('solimp="0.95 0.99 0.001 0.5 2"', 'solimp="0.98 0.995 0.0005 0.5 2"') | |
| # 4. Compliant Silicone Clamping Beds (Upper Roof Pad & Lower Jaw Silicone Trough) | |
| roof_geom_str = """ <!-- Compliant Silicone Clamping Bed lining Upper Beak Ceiling for Narrow Handles --> | |
| <geom name="mouth_roof_pad" type="box" pos="-0.0080 0 -0.0650" size="0.0010 0.025 0.0125" contype="0" conaffinity="0" group="3" material="soft_mouth_top_material"/> | |
| """ | |
| xml = xml.replace(' <geom name="soft_mouth_top_collision"', roof_geom_str + ' <geom name="soft_mouth_top_collision"') | |
| jaw_cradle_str = """ <!-- Conformal lower jaw silicone bed --> | |
| <geom name="jaw_cradle_bed" type="box" pos="-0.0130 0 -0.0465" size="0.0010 0.025 0.0125" contype="0" conaffinity="0" group="3" material="jaw_soft_material"/> | |
| """ | |
| xml = xml.replace(' <geom name="jaw_soft_lower_collision"', jaw_cradle_str + ' <geom name="jaw_soft_lower_collision"') | |
| # 5. Spatial contact pairs for silicone mouth beds | |
| contact_str = """ <!-- Pure rigid contact pairs with analytical mouth silicone beds --> | |
| <pair geom1="racket_handle" geom2="mouth_roof_pad" condim="6" friction="4.5 4.5 0.2 0.2 0.2" solref="0.002 1.0" solimp="0.99 0.999 0.0001 0.5 2"/> | |
| <pair geom1="racket_handle" geom2="jaw_cradle_bed" condim="6" friction="4.5 4.5 0.2 0.2 0.2" solref="0.002 1.0" solimp="0.99 0.999 0.0001 0.5 2"/> | |
| """ | |
| if racket_type == "table_tennis" and include_ball: | |
| contact_str += """ <pair geom1="ball_geom" geom2="racket_face" condim="6" friction="0.80 0.80 0.005 0.005 0.005" solref="0.003 1.0" solimp="0.95 0.99 0.001 0.5 2"/> | |
| """ | |
| xml = xml.replace(' </contact>', contact_str + ' </contact>') | |
| return xml | |
| def run_drop_and_walk_test( | |
| racket_type="table_tennis", | |
| policy_path=None, | |
| gui=False, | |
| num_steps=600, | |
| loop=False, | |
| drop_height=0.002, | |
| reset_on_drop=False, | |
| vx=None, | |
| standing=False, | |
| pre_clamped=None, | |
| no_ball=False, | |
| continuous=False, | |
| lock_neck=False, | |
| lock_head_pitch=False, | |
| lock_yaw=False, | |
| lock_roll=False, | |
| lock_head=False, | |
| lemon_spoon=False, | |
| steadicam=False, | |
| speed=1.0, | |
| ): | |
| if continuous or (num_steps is not None and num_steps <= 0): | |
| num_steps = 10**9 | |
| sim_speed = float(speed) if speed is not None and speed > 0 else 1.0 | |
| has_ball = (racket_type == "table_tennis" and not no_ball) | |
| xml_str = create_scaled_scene_xml(racket_type=racket_type, include_ball=has_ball) | |
| model = mujoco.MjModel.from_xml_string(xml_str) | |
| model.opt.timestep = 0.002 if steadicam else 0.005 | |
| data = mujoco.MjData(model) | |
| # Select default policy based on task | |
| if policy_path is None: | |
| if has_ball and os.path.exists(_TT_POLICY): | |
| policy_path = _TT_POLICY | |
| elif not has_ball and racket_type == "table_tennis" and os.path.exists(_TT_CARRY_POLICY): | |
| policy_path = _TT_CARRY_POLICY | |
| else: | |
| policy_path = _DEFAULT_POLICY | |
| # Check input dimension of the selected policy | |
| input_dim = 61 | |
| try: | |
| import onnxruntime as ort | |
| sess = ort.InferenceSession(policy_path, providers=["CPUExecutionProvider"]) | |
| input_dim = sess.get_inputs()[0].shape[1] | |
| except Exception: | |
| pass | |
| print(f"[Policy] Using: {policy_path} (input_dim={input_dim})") | |
| if (input_dim == 69 or input_dim == 71) and has_ball: | |
| policy = TableTennisPolicyInference(model, data, onnx_path=policy_path) | |
| elif input_dim == 64: | |
| policy = TableTennisRacketCarryPolicyInference(model, data, onnx_path=policy_path) | |
| else: | |
| policy = PolicyInference( | |
| model, data, | |
| walking_onnx_path=policy_path, | |
| action_scale=1.0, | |
| new_cmd_obs=True, | |
| use_projected_gravity=True, | |
| ) | |
| racket_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "racket") | |
| beak_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "beak") | |
| beak_dof = model.jnt_dofadr[beak_jid] | |
| beak_qpos = model.jnt_qposadr[beak_jid] | |
| mouth_sid = model.site("mouth_tip").id | |
| racket_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "racket_free") | |
| racket_qadr = model.jnt_qposadr[racket_jid] | |
| jaw_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "jaw_soft") | |
| trunk_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "trunk_base") | |
| ball_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "pingpong_ball") if has_ball else -1 | |
| ball_jid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "ball_free") if has_ball else -1 | |
| ball_qadr = model.jnt_qposadr[ball_jid] if has_ball else -1 | |
| int_pitch = 0.0 | |
| head_pos_steadicam = 0.0 | |
| if racket_type == "table_tennis": | |
| # Autonomous active control / zero artificial manual offsets | |
| policy.head_offset[:] = 0.0 | |
| policy.default_pose[5] = 0.3491 # neck_pitch: nominal cradle setpoint | |
| policy.default_pose[6] = -0.0800 # head_pitch: nominal horizontal setpoint | |
| policy.default_pose[7] = 0.0 | |
| policy.default_pose[8] = 0.0 | |
| clamp_torque = 0.50 # N*m calibrated firm physical clamping torque | |
| if standing: | |
| vel_x = 0.0 | |
| else: | |
| vel_x = vx if vx is not None else 0.18 | |
| CYL_QUAT = np.array([0.0, 0.7071068, 0.7071068, 0.0]) # forward horizontal blade facing up | |
| HOVER_LOCAL = np.array([-0.0120, 0.0, -0.0680]) # calibrated front mouth silicone cradle position (zero skull collision) | |
| else: | |
| clamp_torque = 0.50 | |
| vel_x = 0.0 if standing else (vx if vx is not None else 0.18) | |
| CYL_QUAT = np.array([0.7071068, 0.7071068, 0.0, 0.0]) | |
| HOVER_LOCAL = np.array([-0.0120, 0.0, -0.0680]) | |
| if pre_clamped is None: | |
| # Default to pre-clamped balance mode (consistent with RL training environment) | |
| pre_clamped = True | |
| beak_wide_open = 2.04 # rad (117 deg, ~24.1mm gap) | |
| target_clamp_angle = 1.654 if racket_type == "badminton" else (1.666 if racket_type == "pickleball" else 1.637) | |
| beak_init_grip = target_clamp_angle # rad tight handle clamping posture | |
| beak_clamp_ctrl = 1.000 # rad closed actuator setpoint assisting physical clamp torque | |
| ls_state = "WALKING" | |
| ls_vx = vel_x | |
| ls_settle_timer = 0.0 | |
| def reset_episode(): | |
| nonlocal ls_state, ls_vx, ls_settle_timer, head_pos_steadicam | |
| head_pos_steadicam = 0.0 | |
| ls_state = "WALKING" | |
| ls_vx = vel_x | |
| ls_settle_timer = 0.0 | |
| if lemon_spoon: | |
| policy.default_pose[5] = -0.4500 | |
| policy.default_pose[6] = -0.4500 | |
| policy.default_pose[7] = 0.0 | |
| policy.default_pose[8] = 0.0 | |
| mujoco.mj_resetData(model, data) | |
| data.qpos[0:3] = [0.0, 0.0, 0.125] | |
| data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0] | |
| for i, idx in enumerate(policy.joint_qpos_indices): | |
| data.qpos[idx] = policy.default_pose[i] | |
| if pre_clamped: | |
| data.qpos[beak_qpos] = beak_init_grip | |
| data.qfrc_applied[beak_dof] = -clamp_torque | |
| if model.nu > 14: | |
| data.ctrl[14] = beak_clamp_ctrl | |
| else: | |
| data.qpos[beak_qpos] = beak_wide_open | |
| mujoco.mj_forward(model, data) | |
| p_jaw = data.xpos[jaw_bid] | |
| R_jaw = data.xmat[jaw_bid].reshape(3, 3) | |
| data.qpos[racket_qadr:racket_qadr+3] = p_jaw + R_jaw @ HOVER_LOCAL | |
| q_jaw = np.empty(4) | |
| mujoco.mju_mat2Quat(q_jaw, data.xmat[jaw_bid]) | |
| q_r = np.empty(4) | |
| mujoco.mju_mulQuat(q_r, q_jaw, CYL_QUAT) | |
| data.qpos[racket_qadr+3:racket_qadr+7] = q_r | |
| mujoco.mj_forward(model, data) | |
| if pre_clamped: | |
| data.qfrc_applied[beak_dof] = -clamp_torque | |
| mujoco.mj_forward(model, data) | |
| if has_ball: | |
| # Place scaled 28.1mm ball above the paddle face center | |
| p_racket = data.xpos[racket_bid] | |
| R_racket = data.xmat[racket_bid].reshape(3, 3) | |
| face_center_world = p_racket + R_racket @ np.array([0.0, 0.0035 + 0.0141 + drop_height, 0.1015]) | |
| data.qpos[ball_qadr:ball_qadr+3] = face_center_world | |
| data.qpos[ball_qadr+3:ball_qadr+7] = [1.0, 0.0, 0.0, 0.0] | |
| if ball_jid >= 0: | |
| data.qvel[model.jnt_dofadr[ball_jid]:model.jnt_dofadr[ball_jid]+6] = 0.0 | |
| mujoco.mj_forward(model, data) | |
| reset_episode() | |
| print("=" * 80) | |
| print(f" Dynamic Drop & Clamping Test: {racket_type.replace('_', ' ').title()}") | |
| print(f" Mode: {'INTERACTIVE GLFW VIEWER (Live Simulation)' if gui else 'HEADLESS RENDERING'}") | |
| if gui: | |
| print(" KEYBOARD CONTROLS FOR LIVE BEAK, SPEED & TORQUE MANIPULATION:") | |
| print(" [O] or [ [ ] : Open beak position incrementally (+2.5°)") | |
| print(" [C] or [ ] ] : Close / clamp beak position incrementally (-2.5°)") | |
| print(" [+] or [=] : Increase clamping torque (+0.05 N·m)") | |
| print(" [-] or [_] : Decrease clamping torque (-0.05 N·m)") | |
| print(" [W] or [Up] : Increase commanded walking speed (+0.03 m/s)") | |
| print(" [S] or [Down]: Decrease commanded walking speed (-0.03 m/s)") | |
| print(" [.] or [>] : Speed up viewer playback (+0.25x)") | |
| print(" [,] or [<] : Slow down viewer playback (-0.25x)") | |
| print(" [A] : Return to Automatic policy clamping mode") | |
| print(" [Space] : Pause / Resume simulation") | |
| print(" [R] : Reset episode") | |
| print(f" Beak OPEN: {math.degrees(beak_wide_open):.1f}° (24.1mm aperture) | Racket Mass: {model.body_mass[racket_bid]*1000:.1f}g") | |
| if has_ball: | |
| print(f" Ball: Proportionally Scaled 0.70x Tournament Ball (Dia: 28.1mm, Mass: {model.body_mass[ball_bid]*1000:.2f}g)") | |
| print(f" Handle: 100% Solid Full-Tang Wood Core (5.8mm tang + dual 1.3mm scales = 8.4mm solid)") | |
| print("=" * 80) | |
| print(f"{'Time':>6} | {'Phase':>18} | {'Beak':>7} | {'Clamp Fn':>9} | {'Ball Status':>28}") | |
| print("-" * 80) | |
| renderer = None | |
| if not gui: | |
| renderer = mujoco.Renderer(model, height=720, width=960) | |
| cam_close = mujoco.MjvCamera() | |
| cam_close.type = mujoco.mjtCamera.mjCAMERA_FREE | |
| cam_close.distance = 0.38 if has_ball else 0.24 | |
| cam_close.elevation = -28.0 if has_ball else -6.0 | |
| cam_close.azimuth = 145.0 if has_ball else 125.0 | |
| cam_ctx = mujoco.MjvCamera() | |
| cam_ctx.type = mujoco.mjtCamera.mjCAMERA_TRACKING | |
| cam_ctx.trackbodyid = 1 | |
| cam_ctx.distance = 0.70 | |
| cam_ctx.elevation = -18.0 if has_ball else -14.0 | |
| cam_ctx.azimuth = 142.0 if has_ball else 138.0 | |
| keyframes_close = {} | |
| keyframes_ctx = {} | |
| user_beak_override = None | |
| user_paused = False | |
| reset_requested = False | |
| def on_key(keycode): | |
| nonlocal user_beak_override, user_paused, reset_requested, clamp_torque, vel_x, sim_speed | |
| # GLFW keycodes: | |
| # 'O' = 79, 'o' = 111, '[' = 91 -> Open beak | |
| if keycode in (ord('O'), ord('o'), 91): | |
| curr = user_beak_override if user_beak_override is not None else data.qpos[beak_qpos] | |
| user_beak_override = min(2.04, curr + 0.044) | |
| print(f"\n[INTERACTIVE] BEAK OPENED -> {math.degrees(user_beak_override):.1f}° (gap: {(user_beak_override - 1.47)*44.0:.1f}mm)") | |
| # 'C' = 67, 'c' = 99, ']' = 93 -> Close / clamp beak | |
| elif keycode in (ord('C'), ord('c'), 93): | |
| curr = user_beak_override if user_beak_override is not None else data.qpos[beak_qpos] | |
| user_beak_override = max(1.47, curr - 0.044) | |
| print(f"\n[INTERACTIVE] BEAK CLOSED -> {math.degrees(user_beak_override):.1f}°") | |
| # '+' / '=' = 61 or 43 -> Increase torque | |
| elif keycode in (ord('+'), ord('='), 61, 43): | |
| clamp_torque = min(1.00, clamp_torque + 0.05) | |
| print(f"\n[INTERACTIVE] CLAMP TORQUE INCREASED -> {clamp_torque:.2f} N·m") | |
| # '-' / '_' = 45 or 95 -> Decrease torque | |
| elif keycode in (ord('-'), ord('_'), 45, 95): | |
| clamp_torque = max(0.00, clamp_torque - 0.05) | |
| print(f"\n[INTERACTIVE] CLAMP TORQUE DECREASED -> {clamp_torque:.2f} N·m") | |
| # 'W' / 'w' or Up arrow = 265 -> Increase commanded forward walking speed | |
| elif keycode in (ord('W'), ord('w'), 265): | |
| vel_x = min(0.35, vel_x + 0.03) | |
| print(f"\n[INTERACTIVE] Walking Forward Speed INCREASED -> {vel_x:.2f} m/s") | |
| # 'S' / 's' or Down arrow = 264 -> Decrease commanded forward walking speed | |
| elif keycode in (ord('S'), ord('s'), 264): | |
| vel_x = max(0.00, vel_x - 0.03) | |
| print(f"\n[INTERACTIVE] Walking Forward Speed DECREASED -> {vel_x:.2f} m/s") | |
| # '.' or '>' -> Increase viewer playback speed | |
| elif keycode in (ord('.'), ord('>'), 46): | |
| sim_speed = min(5.0, sim_speed + 0.25) | |
| print(f"\n[INTERACTIVE] Viewer Playback Speed INCREASED -> {sim_speed:.2f}x") | |
| # ',' or '<' -> Decrease viewer playback speed | |
| elif keycode in (ord(','), ord('<'), 44): | |
| sim_speed = max(0.25, sim_speed - 0.25) | |
| print(f"\n[INTERACTIVE] Viewer Playback Speed DECREASED -> {sim_speed:.2f}x") | |
| # 'A' = 65, 'a' = 97 -> Auto mode | |
| elif keycode in (ord('A'), ord('a')): | |
| user_beak_override = None | |
| print(f"\n[INTERACTIVE] Restored AUTOMATIC policy clamping mode (Torque: {clamp_torque:.2f} N·m).") | |
| # Spacebar = 32 -> Pause / Resume | |
| elif keycode == 32: | |
| user_paused = not user_paused | |
| print(f"\n[INTERACTIVE] {'SIMULATION PAUSED' if user_paused else 'SIMULATION RESUMED'}") | |
| # 'R' = 82, 'r' = 114 -> Reset | |
| elif keycode in (ord('R'), ord('r')): | |
| reset_requested = True | |
| print("\n[INTERACTIVE] Episode Reset requested.") | |
| viewer = None | |
| if gui: | |
| viewer = mujoco.viewer.launch_passive(model, data, key_callback=on_key) | |
| viewer.cam.type = mujoco.mjtCamera.mjCAMERA_TRACKING | |
| viewer.cam.trackbodyid = 1 | |
| viewer.cam.distance = 0.65 | |
| viewer.cam.elevation = -18.0 | |
| # Ensure visual CAD meshes (groups 0-2) are visible while collision bounding boxes (group 3) are hidden | |
| viewer.opt.geomgroup[0] = 1 | |
| viewer.opt.geomgroup[1] = 1 | |
| viewer.opt.geomgroup[2] = 1 | |
| viewer.opt.geomgroup[3] = 0 | |
| episode = 0 | |
| running = True | |
| walk_pitches = [] | |
| walk_rolls = [] | |
| while running: | |
| reset_episode() | |
| for step in range(num_steps): | |
| if gui and not viewer.is_running(): | |
| running = False | |
| break | |
| if reset_requested: | |
| reset_requested = False | |
| break | |
| if user_paused: | |
| if gui: | |
| viewer.sync() | |
| time.sleep(0.02) | |
| continue | |
| sim_time = step * 0.005 | |
| if sim_time >= 1.0: | |
| R_r_blade = data.xmat[racket_bid].reshape(3, 3) | |
| norm_blade = R_r_blade @ np.array([0.0, 1.0, 0.0]) | |
| p_blade = math.asin(np.clip(float(norm_blade[0]), -0.999, 0.999)) | |
| r_blade = math.asin(np.clip(float(norm_blade[1]), -0.999, 0.999)) | |
| walk_pitches.append(math.degrees(p_blade)) | |
| walk_rolls.append(math.degrees(r_blade)) | |
| # Dynamic Protocol / Manual Beak Override | |
| if user_beak_override is not None: | |
| # Live User Beak Control | |
| phase_name = f"MANUAL BEAK ({math.degrees(user_beak_override):.1f}°)" | |
| beak_err = user_beak_override - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(8.0 * beak_err - 0.25 * data.qvel[beak_dof], -1.0, 1.0)) | |
| if model.nu > 14: | |
| data.ctrl[14] = float(np.clip(user_beak_override, 1.470, 2.050)) | |
| cmd_vx = vel_x | |
| elif pre_clamped or sim_time >= 0.75: | |
| phase_name = "4: BALANCING" if vel_x == 0.0 else "4: WALKING" | |
| # Active Conformal Anti-Migration Clamping (prevents slip over long walking runs) | |
| beak_err = target_clamp_angle - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-clamp_torque + 12.0 * beak_err - 0.5 * data.qvel[beak_dof], -1.2, 0.2)) | |
| if model.nu > 14: | |
| data.ctrl[14] = 1.0 | |
| cmd_vx = vel_x | |
| elif sim_time < 0.40: | |
| # Phase 1: Open beak stand (hover handle centered in open mouth with zero contact) | |
| phase_name = "1: BEAK OPEN" | |
| data.qpos[beak_qpos] = beak_wide_open | |
| data.qvel[beak_dof] = 0.0 | |
| data.qfrc_applied[beak_dof] = 0.0 | |
| p_jaw = data.xpos[jaw_bid] | |
| R_jaw = data.xmat[jaw_bid].reshape(3, 3) | |
| data.qpos[racket_qadr:racket_qadr+3] = p_jaw + R_jaw @ HOVER_LOCAL | |
| q_jaw = np.empty(4) | |
| mujoco.mju_mat2Quat(q_jaw, data.xmat[jaw_bid]) | |
| q_r = np.empty(4) | |
| mujoco.mju_mulQuat(q_r, q_jaw, CYL_QUAT) | |
| data.qpos[racket_qadr+3:racket_qadr+7] = q_r | |
| data.qvel[model.jnt_dofadr[racket_jid]:model.jnt_dofadr[racket_jid]+6] = 0.0 | |
| cmd_vx = 0.0 | |
| elif sim_time < 0.43: | |
| # Phase 2: Minimal Free Drop (release 4mm onto mandible cradle bed, beak remains open) | |
| phase_name = "2: MINIMAL DROP" | |
| data.qpos[beak_qpos] = beak_wide_open | |
| data.qvel[beak_dof] = 0.0 | |
| data.qfrc_applied[beak_dof] = 0.0 | |
| cmd_vx = 0.0 | |
| else: | |
| # Phase 3: Active Clamping (calibrated servo torque clamps beak) | |
| phase_name = "3: CLAMPING" | |
| beak_err = target_clamp_angle - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-clamp_torque + 12.0 * beak_err - 0.5 * data.qvel[beak_dof], -1.2, 0.2)) | |
| if model.nu > 14: | |
| data.ctrl[14] = 1.0 | |
| cmd_vx = 0.0 | |
| pitch_corr = 0.0 | |
| roll_corr = 0.0 | |
| if lemon_spoon and has_ball and (pre_clamped or sim_time >= 0.75): | |
| p_r = data.xpos[racket_bid] | |
| R_r = data.xmat[racket_bid].reshape(3, 3) | |
| p_b = data.xpos[ball_bid] | |
| v_b = data.cvel[ball_bid][3:6] | |
| v_r = data.cvel[racket_bid][3:6] | |
| rel_pos = R_r.T @ (p_b - p_r) | |
| dx = float(rel_pos[0]) | |
| dz = float(rel_pos[2] - 0.1015) | |
| dist = math.sqrt(dx**2 + dz**2) | |
| rel_vel = R_r.T @ (v_b - v_r) | |
| vx_rel = float(rel_vel[0]) | |
| vz_rel = float(rel_vel[2]) | |
| v_norm = math.sqrt(vx_rel**2 + vz_rel**2) | |
| R_trunk = data.xmat[trunk_id].reshape(3, 3) | |
| trunk_roll = np.arctan2(R_trunk[2, 1], R_trunk[2, 2]) | |
| trunk_pitch = - np.arcsin(np.clip(R_trunk[2, 0], -1.0, 1.0)) | |
| base_level_pitch = - trunk_pitch | |
| base_level_roll = - trunk_roll | |
| R_SAFE = 0.016 | |
| R_INNER = 0.008 | |
| SETTLE_DURATION = 0.40 | |
| if ls_state == "WALKING": | |
| # Gentle, human-like acceleration ramp (0.002 m/s per 5ms = 0.4 m/s^2 smooth take-off) | |
| ls_vx = min(vel_x, ls_vx + 0.002) | |
| pitch_corr = 0.0 | |
| roll_corr = 0.0 | |
| if dist > R_SAFE: | |
| ls_state = "DECELERATING" | |
| elif ls_state == "DECELERATING": | |
| # Gentle braking ramp (0.004 m/s per 5ms = 0.8 m/s^2 smooth stop) | |
| prev_vx = ls_vx | |
| ls_vx = max(0.0, ls_vx - 0.004) | |
| a_decel = (prev_vx - ls_vx) / 0.005 | |
| inertial_tilt = - (a_decel / 9.81) * 0.20 | |
| pitch_corr = inertial_tilt - 1.5 * dz - 0.15 * vz_rel | |
| roll_corr = - 1.5 * dx - 0.15 * vx_rel | |
| if ls_vx <= 0.002: | |
| ls_vx = 0.0 | |
| ls_state = "RECENTERING" | |
| elif ls_state == "RECENTERING": | |
| ls_vx = 0.0 | |
| pitch_corr = - 2.0 * dz - 0.25 * vz_rel | |
| roll_corr = - 2.0 * dx - 0.25 * vx_rel | |
| if dist < R_INNER and v_norm < 0.02: | |
| ls_state = "SETTLING" | |
| ls_settle_timer = sim_time | |
| elif ls_state == "SETTLING": | |
| ls_vx = 0.0 | |
| pitch_corr = - 0.5 * dz | |
| roll_corr = - 0.5 * dx | |
| if dist > R_SAFE: | |
| ls_state = "RECENTERING" | |
| elif (sim_time - ls_settle_timer) >= SETTLE_DURATION: | |
| ls_state = "WALKING" | |
| phase_name = f"LEMON:{ls_state}" | |
| cmd_vx = ls_vx | |
| # 50 Hz policy inference | |
| if step % 4 == 0: | |
| if policy.vel_cmd[0] != cmd_vx: | |
| policy.set_vel_cmd(cmd_vx, 0.0, 0.0) | |
| policy._update_command() | |
| action = policy.infer() | |
| use_steadicam_gimbal = steadicam or (racket_type == "table_tennis" and not has_ball) | |
| if use_steadicam_gimbal: | |
| R_trunk = data.xmat[trunk_id].reshape(3, 3) | |
| h_fwd = np.array([R_trunk[0, 0], R_trunk[1, 0], 0.0]) | |
| h_fwd /= (np.linalg.norm(h_fwd) + 1e-6) | |
| h_lat = np.array([-h_fwd[1], h_fwd[0], 0.0]) | |
| R_r = data.xmat[racket_bid].reshape(3, 3) | |
| b_norm = R_r @ np.array([0.0, 1.0, 0.0]) | |
| racket_pitch = math.asin(np.clip(float(np.dot(b_norm, h_fwd)), -0.999, 0.999)) | |
| # Pillar 2: First-order Slew-Rate Integrator (max 0.12 rad/s, mathematically kills jitter) | |
| rate_cmd = np.clip(0.80 * (racket_pitch + 0.004), -0.12, 0.12) | |
| dt_ctrl = 4 * model.opt.timestep | |
| head_pos_steadicam = np.clip(head_pos_steadicam + rate_cmd * dt_ctrl, -0.40, 0.40) | |
| # Pillar 1: Kinematic Differential Pantograph (equal & opposite, net head pitch change = 0) | |
| action[5] = 0.0 + head_pos_steadicam | |
| action[6] = 0.0 - head_pos_steadicam | |
| action[7] = 0.0 | |
| action[8] = -0.002 | |
| elif lemon_spoon: | |
| action[5] += -0.35 + float(np.clip(pitch_corr, -0.25, 0.25)) | |
| action[8] += -0.14 + float(np.clip(roll_corr, -0.20, 0.20)) | |
| action[7] = 0.0 | |
| elif lock_head: | |
| action[5] = 0.0 | |
| action[6] = 0.0 | |
| action[7] = 0.0 | |
| action[8] = 0.0 | |
| else: | |
| if lock_neck: | |
| action[5] = 0.0 | |
| if lock_head_pitch: | |
| action[6] = 0.0 | |
| if lock_yaw: | |
| action[7] = 0.0 | |
| if lock_roll: | |
| action[8] = 0.0 | |
| policy.apply_action(action) | |
| if model.nu > 14: | |
| if user_beak_override is not None: | |
| data.ctrl[14] = float(np.clip(user_beak_override, 1.470, 2.050)) | |
| elif pre_clamped or sim_time >= 0.43: | |
| data.ctrl[14] = beak_clamp_ctrl | |
| mujoco.mj_step(model, data) | |
| # Contact monitoring | |
| fn_tot = 0.0 | |
| ball_on_blade = False | |
| ball_force = 0.0 | |
| for ci in range(data.ncon): | |
| con = data.contact[ci] | |
| b1 = model.geom_bodyid[con.geom1] | |
| b2 = model.geom_bodyid[con.geom2] | |
| g1_name = model.geom(con.geom1).name | |
| g2_name = model.geom(con.geom2).name | |
| if b1 == racket_bid or b2 == racket_bid: | |
| f = np.zeros(6) | |
| mujoco.mj_contactForce(model, data, ci, f) | |
| if ("jaw" in g1_name or "jaw" in g2_name or "mouth" in g1_name or "mouth" in g2_name): | |
| fn_tot += f[0] | |
| if has_ball and ((b1 == ball_bid) or (b2 == ball_bid)): | |
| ball_on_blade = True | |
| ball_force += f[0] | |
| beak_deg = math.degrees(data.qpos[beak_qpos]) | |
| ball_dropped = False | |
| ball_status_str = "Pure Racket Carry" | |
| if has_ball: | |
| p_ball = data.xpos[ball_bid] | |
| p_rac = data.xpos[racket_bid] | |
| R_rac = data.xmat[racket_bid].reshape(3, 3) | |
| rel_b = R_rac.T @ (p_ball - p_rac) | |
| # Ball is considered dropped if it rolled > 58mm from sweet spot or dropped below rubber | |
| if sim_time > 0.8 and (abs(rel_b[0]) > 0.058 or rel_b[1] < -0.015 or rel_b[2] < 0.040 or rel_b[2] > 0.165): | |
| ball_dropped = True | |
| ball_status_str = f"On Blade: {ball_on_blade} ({ball_force*1000:4.1f}mN) dx={rel_b[0]*1000:+.1f}mm dy={rel_b[1]*1000:+.1f}mm dz={rel_b[2]*1000:+.1f}mm" | |
| if ball_dropped: | |
| ball_status_str += " [BALL DROPPED!]" | |
| if step % 20 == 0 or ball_dropped: | |
| print(f"{sim_time:5.2f}s | {phase_name:>18} | {beak_deg:6.1f}° | {fn_tot:7.2f} N | {ball_status_str:>28}") | |
| if ball_dropped and reset_on_drop: | |
| print(f"--> Ball fell off paddle at t={sim_time:.2f}s! Resetting episode...") | |
| break | |
| if gui: | |
| viewer.sync() | |
| dt_sleep = max(0.0, (model.opt.timestep / sim_speed)) | |
| if dt_sleep > 0: | |
| time.sleep(dt_sleep) | |
| else: | |
| # Capture keyframe verification renders | |
| if step in (10, 82, 130, 220, 1000, 3000, 5000, 5990): | |
| phase_clean = phase_name.split(':')[1].strip().lower().replace(' ', '_') if ':' in phase_name else phase_name.strip().lower().replace(' ', '_') | |
| tag = f"step_{step}_{phase_clean}" | |
| if has_ball: | |
| cam_close.lookat = data.xpos[ball_bid].copy() | |
| else: | |
| m_pos = data.site_xpos[mouth_sid] | |
| cam_close.lookat = [m_pos[0], m_pos[1], m_pos[2]] | |
| renderer.update_scene(data, camera=cam_close) | |
| img1 = Image.fromarray(renderer.render()) | |
| p1 = os.path.join(_ARTIFACT_DIR, f"{racket_type}_{tag}_closeup.png") | |
| img1.save(p1) | |
| keyframes_close[tag] = p1 | |
| renderer.update_scene(data, camera=cam_ctx) | |
| img2 = Image.fromarray(renderer.render()) | |
| p2 = os.path.join(_ARTIFACT_DIR, f"{racket_type}_{tag}_context.png") | |
| img2.save(p2) | |
| keyframes_ctx[tag] = p2 | |
| episode += 1 | |
| if not loop or not gui: | |
| break | |
| if gui and viewer is not None: | |
| viewer.close() | |
| print("-" * 80) | |
| print(f"Simulation completed! Final Normal Clamping Force: {fn_tot:.2f} N | Total Steps: {num_steps}") | |
| if walk_pitches: | |
| print("[Telemetry] Table Tennis Racket Orientation (Carry Phase):") | |
| print(f" Pitch (Fore-Aft): Mean = {np.mean(walk_pitches):+.2f}° | Range = [{np.min(walk_pitches):+.2f}°, {np.max(walk_pitches):+.2f}°] | Std = {np.std(walk_pitches):.2f}°") | |
| print(f" Roll (Lateral): Mean = {np.mean(walk_rolls):+.2f}° | Range = [{np.min(walk_rolls):+.2f}°, {np.max(walk_rolls):+.2f}°] | Std = {np.std(walk_rolls):.2f}°") | |
| if has_ball: | |
| print(f"Final Ball Status: {ball_status_str}") | |
| if not gui: | |
| print(f"Keyframe renders saved to: {_ARTIFACT_DIR}") | |
| return keyframes_close, keyframes_ctx | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Real-World Racket Drop, Clamping & Dynamic Ball Balancing Test") | |
| parser.add_argument("--racket", choices=["table_tennis", "pickleball", "badminton"], default="table_tennis", | |
| help="Racket type: authentic ITTF table tennis (180g + 40mm ball), pickleball, or badminton") | |
| parser.add_argument("--policy", type=str, default=None, | |
| help="Path to ONNX policy (defaults to racket_ball_balance.onnx)") | |
| parser.add_argument("--steps", type=int, default=1000, help="Number of 200Hz physics steps (1000 = 5.0s, 0 or negative = infinite)") | |
| parser.add_argument("--drop-height", type=float, default=0.002, | |
| help="Initial ball drop height above paddle face in meters (e.g. 0.08 for 8cm air drop)") | |
| parser.add_argument("--gui", action="store_true", help="Launch native interactive GLFW MuJoCo viewer") | |
| parser.add_argument("--loop", action="store_true", help="Continuously loop the test in GUI") | |
| parser.add_argument("--continue", dest="continuous", action="store_true", help="Run continuously for a long time / indefinitely without resetting") | |
| parser.add_argument("--infinite", action="store_true", help="Run continuously for a long time / indefinitely without resetting") | |
| parser.add_argument("--reset-on-drop", action="store_true", help="Reset episode immediately when the ball falls off") | |
| parser.add_argument("--vx", type=float, default=None, help="Commanded forward walking velocity (m/s, default: 0.18)") | |
| parser.add_argument("--stand", action="store_true", help="Command in-place standing balance instead of forward walk") | |
| parser.add_argument("--walk", action="store_true", default=True, help="Command forward walking (default: True)") | |
| parser.add_argument("--drop-test", action="store_true", help="Force open-beak drop test phase instead of pre-clamped balance") | |
| parser.add_argument("--no-ball", action="store_true", help="Omit the pingpong ball, test purely holding and walking with the racket") | |
| parser.add_argument("--lock-neck", action="store_true", help="Lock neck_pitch to nominal level setpoint (residual=0)") | |
| parser.add_argument("--lock-head-pitch", action="store_true", help="Lock head_pitch to nominal level setpoint (residual=0)") | |
| parser.add_argument("--lock-yaw", action="store_true", help="Lock head_yaw to 0.0 (residual=0)") | |
| parser.add_argument("--lock-roll", action="store_true", help="Lock head_roll to 0.0 (residual=0)") | |
| parser.add_argument("--lock-head", action="store_true", help="Lock ALL 4 neck and head joints to nominal level setpoints") | |
| parser.add_argument("--lemon-spoon", action="store_true", | |
| help="Enable 'Lemon-in-a-Spoon' governor: safe circular tolerance region, deceleration on escape, and settling pause timer") | |
| parser.add_argument("--steadicam", action="store_true", | |
| help="Enable steadicam active neck-head gimbal stabilization for ultra-steady level carry") | |
| parser.add_argument("--speed", type=float, default=1.0, | |
| help="Viewer simulation playback speed multiplier (e.g. 1.5, 2.0)") | |
| args = parser.parse_args() | |
| vel_forward = 0.0 if args.stand else (args.vx if args.vx is not None else (0.12 if args.lemon_spoon else 0.18)) | |
| if vel_forward > 0.0 and vel_forward < 0.15 and not args.lemon_spoon: | |
| print(f"\n[NOTE] Commanded velocity vx={vel_forward:.2f} m/s is below nominal walking gait threshold (0.15 - 0.22 m/s).") | |
| print(" At vx=0.10, the policy takes micro-shuffles (~1mm/step). For active forward stepping, recommended: --vx 0.18.\n") | |
| if args.gui: | |
| print("\n[Interactive Viewer Notes]") | |
| print(" - The camera TRACKS the robot trunk_base (the robot remains centered; watch feet and checkerboard floor).") | |
| print(" - Press Spacebar to pause/resume | Right-click drag to zoom | Left-click drag to rotate camera.\n") | |
| is_continuous = args.continuous or args.infinite or (args.steps is not None and args.steps <= 0) | |
| run_drop_and_walk_test( | |
| racket_type=args.racket, | |
| policy_path=args.policy, | |
| gui=args.gui, | |
| num_steps=args.steps, | |
| loop=args.loop, | |
| drop_height=args.drop_height, | |
| reset_on_drop=args.reset_on_drop, | |
| vx=vel_forward, | |
| standing=args.stand, | |
| pre_clamped=False if args.drop_test else None, | |
| no_ball=args.no_ball, | |
| continuous=is_continuous, | |
| lock_neck=args.lock_neck, | |
| lock_head_pitch=args.lock_head_pitch, | |
| lock_yaw=args.lock_yaw, | |
| lock_roll=args.lock_roll, | |
| lock_head=args.lock_head, | |
| lemon_spoon=args.lemon_spoon, | |
| steadicam=args.steadicam, | |
| speed=args.speed, | |
| ) | |
| if __name__ == "__main__": | |
| main() | |