Download scripts/test_interactive_actuator_lemon_drop.py from Raghav-16/Microduck_Manipulation: direct link, hf CLI and curl.
- Browser
- Download file 31.1 kB
-
https://huggingface.co/Raghav-16/Microduck_Manipulation/resolve/main/scripts/test_interactive_actuator_lemon_drop.py
- Command line
-
hf download hf://Raghav-16/Microduck_Manipulation/scripts/test_interactive_actuator_lemon_drop.py
-
curl -L -o test_interactive_actuator_lemon_drop.py https://huggingface.co/Raghav-16/Microduck_Manipulation/resolve/main/scripts/test_interactive_actuator_lemon_drop.py
31.1 kB
| #!/usr/bin/env python3 | |
| """Interactive Actuator Control & Lemon Drop Simulation for MicroDuck. | |
| Features: | |
| 1. Robot stands stably and firmly upright in calibrated standing equilibrium. | |
| 2. Spoon is physically clamped in the lower jaw and upper beak. | |
| 3. Lemon rests in the concave spoon bowl with Coulomb friction (mu = 0.50, condim = 6). | |
| 4. Direct interactive keyboard control over robot actuators: | |
| - Pitch: W / S (or Up / Down) tilts neck and head forward / backward. | |
| - Roll: A / D (or Left / Right) tilts head roll left / right. | |
| - Yaw: Q / E turns head yaw left / right. | |
| - Beak: O / C opens / closes beak aperture. | |
| - Torque: + / - increases / decreases beak clamping torque. | |
| - 1: Neutral level head pose reset. | |
| - 2: Automatic Pitch Drop test (tilts past 26.6° static friction limit). | |
| - 3: Automatic Roll Drop test (rolls sideways over bowl rim). | |
| - 4: Automatic Beak Release test (loosens jaw until spoon falls). | |
| - R: Reset simulation to standing state with lemon in spoon. | |
| - Space: Pause / Resume physics. | |
| - Tab: Switch camera (Close-up -> Side Profile -> Isometric -> Front). | |
| """ | |
| import argparse | |
| import math | |
| import os | |
| import sys | |
| import time | |
| import numpy as np | |
| import mujoco | |
| import mujoco.viewer | |
| _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 DEFAULT_POSE | |
| from test_spoon_lemon_balance import create_spoon_lemon_scene_xml | |
| STANDING_KEY_QPOS = "0.000000 -0.000000 0.124959 1.000000 0.000001 0.000013 -0.000008 0.000065 -0.087201 -0.458087 -0.004760 0.452976 0.275303 0.276662 -0.001086 -0.000020 1.561717 -0.000190 0.087218 0.458244 0.004704 -0.452974 0.042017 -0.000045 0.228806 0.492879 0.507634 0.506983 0.492287 0.113564 0.000311 0.233415 0.548665 0.457111 0.443115 0.541909" | |
| STANDING_KEY_CTRL = "0.000000 -0.087300 -0.457900 -0.004900 0.453000 0.276000 0.276000 0.000000 0.000000 0.000000 0.087300 0.457900 0.004900 -0.453000 1.550000" | |
| def build_standing_scene_xml(): | |
| """Generates the standing scene with stiff position actuators on stance legs and calibrated Keyframe 0.""" | |
| xml_str = create_spoon_lemon_scene_xml() | |
| # Increase actuator stiffness to firm position servos (25.0 N*m/rad) so legs hold standing stance rigidly | |
| xml_str = xml_str.replace('gainprm="0.55" biasprm="0 -0.55"', 'gainprm="25.0" biasprm="0 -25.0"') | |
| xml_str = xml_str.replace('forcerange="-0.96 0.96"', 'forcerange="-5.0 5.0"') | |
| # Replace default empty INIT keyframe with exact pristine standing pose (Keyframe 0) | |
| xml_str = xml_str.replace( | |
| '<key name="INIT" qpos="0 0 0.12 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0"/>', | |
| f'<key name="INIT" qpos="{STANDING_KEY_QPOS}" ctrl="{STANDING_KEY_CTRL}"/>' | |
| ) | |
| return xml_str | |
| def setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr, | |
| target_clamp_angle=1.550, clamp_torque=0.45): | |
| """Firmly settles the robot into standing posture with spoon and lemon placed.""" | |
| 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] | |
| data.qvel[0:6] = 0.0 | |
| for i, idx in enumerate(joint_qpos_indices): | |
| data.qpos[idx] = default_pose[i] | |
| data.ctrl[i] = default_pose[i] | |
| data.qpos[beak_qpos] = target_clamp_angle | |
| data.qfrc_applied[beak_dof] = -clamp_torque | |
| if model.nu > 14: | |
| data.ctrl[14] = target_clamp_angle | |
| mujoco.mj_forward(model, data) | |
| # Place spoon in jaw cradle | |
| p_jaw = data.xpos[jaw_bid] | |
| R_jaw = data.xmat[jaw_bid].reshape(3, 3) | |
| HOVER_LOCAL = np.array([-0.0119, 0.0, -0.0465]) | |
| CYL_QUAT = np.array([0.0, 0.7071068, 0.7071068, 0.0]) | |
| data.qpos[spoon_qadr:spoon_qadr + 3] = p_jaw + R_jaw @ HOVER_LOCAL | |
| q_jaw = np.empty(4) | |
| mujoco.mju_mat2Quat(q_jaw, data.xmat[jaw_bid]) | |
| q_s = np.empty(4) | |
| mujoco.mju_mulQuat(q_s, q_jaw, CYL_QUAT) | |
| data.qpos[spoon_qadr + 3:spoon_qadr + 7] = q_s | |
| data.qvel[spoon_qadr:spoon_qadr + 6] = 0.0 | |
| mujoco.mj_forward(model, data) | |
| # Place lemon inside concave spoon bowl | |
| p_spoon = data.xpos[spoon_bid] | |
| R_spoon = data.xmat[spoon_bid].reshape(3, 3) | |
| data.qpos[lemon_qadr:lemon_qadr + 3] = p_spoon + R_spoon @ np.array([0.0, 0.0052, 0.070]) | |
| data.qpos[lemon_qadr + 3:lemon_qadr + 7] = q_s | |
| data.qvel[lemon_qadr:lemon_qadr + 6] = 0.0 | |
| mujoco.mj_forward(model, data) | |
| # Settle feet on ground | |
| for _ in range(150): | |
| data.qpos[0:3] = [0.0, 0.0, 0.125] | |
| data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0] | |
| data.qvel[0:6] = 0.0 | |
| for i in range(14): | |
| data.ctrl[i] = default_pose[i] | |
| if model.nu > 14: | |
| data.ctrl[14] = target_clamp_angle | |
| data.qfrc_applied[beak_dof] = -clamp_torque | |
| data.qvel[lemon_qadr:lemon_qadr + 6] *= 0.95 | |
| mujoco.mj_step(model, data) | |
| # Final zero velocity check | |
| data.qvel[:] = 0.0 | |
| for i in range(14): | |
| data.ctrl[i] = default_pose[i] | |
| if model.nu > 14: | |
| data.ctrl[14] = target_clamp_angle | |
| data.qfrc_applied[beak_dof] = -clamp_torque | |
| mujoco.mj_forward(model, data) | |
| def main(): | |
| parser = argparse.ArgumentParser(description="MicroDuck Standing Actuator Lemon Drop Simulation") | |
| parser.add_argument("--test", action="store_true", help="Run automated test suite verifying lemon drop physics") | |
| parser.add_argument("--proofs", action="store_true", help="Render visual proof images of standing, slipping, and dropping") | |
| args = parser.parse_args() | |
| xml_str = build_standing_scene_xml() | |
| model = mujoco.MjModel.from_xml_string(xml_str) | |
| model.opt.timestep = 0.002 | |
| data = mujoco.MjData(model) | |
| default_pose = DEFAULT_POSE[:14].copy() | |
| default_pose[5] = 0.2760 # neck pitch: level | |
| default_pose[6] = 0.2760 # head pitch: level | |
| default_pose[7] = 0.0 # head yaw | |
| default_pose[8] = 0.0 # head roll | |
| joint_qpos_indices = [int(model.jnt_qposadr[model.actuator_trnid[a, 0]]) for a in range(14)] | |
| beak_qpos = int(model.jnt_qposadr[model.actuator_trnid[14, 0]]) | |
| beak_dof = int(model.jnt_dofadr[model.actuator_trnid[14, 0]]) | |
| spoon_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "spoon") | |
| lemon_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "lemon") | |
| jaw_bid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "jaw_soft") | |
| spoon_qadr = model.jnt_qposadr[mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "spoon_free")] | |
| lemon_qadr = model.jnt_qposadr[mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "lemon_free")] | |
| _ARTIFACT_DIR = os.environ.get("ARTIFACT_DIR", str(Path(__file__).resolve().parent.parent / "docs" / "media")) | |
| if args.proofs: | |
| from PIL import Image | |
| renderer = mujoco.Renderer(model, width=1280, height=720) | |
| vopt = mujoco.MjvOption() | |
| vopt.geomgroup[3] = 0 | |
| # State 1: Stable level standing | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr) | |
| for _ in range(250): | |
| for i in range(14): data.ctrl[i] = default_pose[i] | |
| beak_err = 1.550 - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-0.45 + 10.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.0, 0.2)) | |
| mujoco.mj_step(model, data) | |
| cam = mujoco.MjvCamera() | |
| cam.type = mujoco.mjtCamera.mjCAMERA_FREE | |
| cam.lookat = [0.10, 0.0, 0.16] | |
| cam.distance = 0.52 | |
| cam.elevation = -12.0 | |
| cam.azimuth = 45.0 | |
| renderer.update_scene(data, camera=cam, scene_option=vopt) | |
| p1 = os.path.join(_ARTIFACT_DIR, "standing_lemon_balanced_proof.png") | |
| Image.fromarray(renderer.render()).save(p1) | |
| # State 2: Head tilt slip (critical angle ~27°) | |
| ctrl_test = default_pose.copy() | |
| for step in range(500): | |
| t = step * 0.002 | |
| pitch_delta = min(0.48, t * 0.50) # ~27.5 degrees | |
| ctrl_test[5] = default_pose[5] - pitch_delta | |
| ctrl_test[6] = default_pose[6] - pitch_delta | |
| for i in range(14): data.ctrl[i] = ctrl_test[i] | |
| beak_err = 1.550 - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-0.45 + 10.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.0, 0.2)) | |
| mujoco.mj_step(model, data) | |
| renderer.update_scene(data, camera=cam, scene_option=vopt) | |
| p2 = os.path.join(_ARTIFACT_DIR, "standing_lemon_tilt_slip_proof.png") | |
| Image.fromarray(renderer.render()).save(p2) | |
| # State 3: Lemon dropped to floor | |
| for step in range(1200): | |
| ctrl_test[5] = default_pose[5] - 0.85 | |
| ctrl_test[6] = default_pose[6] - 0.85 | |
| for i in range(14): data.ctrl[i] = ctrl_test[i] | |
| beak_err = 1.550 - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-0.45 + 10.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.0, 0.2)) | |
| mujoco.mj_step(model, data) | |
| renderer.update_scene(data, camera=cam, scene_option=vopt) | |
| p3 = os.path.join(_ARTIFACT_DIR, "standing_lemon_dropped_floor_proof.png") | |
| Image.fromarray(renderer.render()).save(p3) | |
| print("\nVisual proofs saved:") | |
| print(f" 1. Balanced: {p1}") | |
| print(f" 2. Slipping: {p2}") | |
| print(f" 3. Dropped: {p3}") | |
| return | |
| # Automated test suite mode | |
| if args.test: | |
| print("\n" + "=" * 70) | |
| print("RUNNING AUTOMATED LEMON DROP PHYSICS VERIFICATION") | |
| print("=" * 70) | |
| # Test 1: Baseline standing stability (tilt = 0.0°) | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr) | |
| for _ in range(1000): # 2.0s | |
| for i in range(14): data.ctrl[i] = default_pose[i] | |
| beak_err = 1.550 - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-0.45 + 10.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.0, 0.2)) | |
| mujoco.mj_step(model, data) | |
| p_s = data.xpos[spoon_bid] | |
| R_s = data.xmat[spoon_bid].reshape(3, 3) | |
| bowl = p_s + R_s @ np.array([0.0, 0.0052, 0.070]) | |
| disp1 = float(np.linalg.norm(data.xpos[lemon_bid] - bowl)) | |
| print(f"Test 1 (Standing at 0° tilt): disp = {disp1 * 1000:.2f} mm | Balanced = {disp1 < 0.005}") | |
| # Test 2: Pitch tilt forward past 26.6° friction limit | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr) | |
| ctrl_test = default_pose.copy() | |
| dropped_pitch = False | |
| drop_time = None | |
| for step in range(2500): # 5.0s | |
| t = step * 0.002 | |
| pitch_delta = min(0.90, t * 0.30) | |
| ctrl_test[5] = default_pose[5] - pitch_delta | |
| ctrl_test[6] = default_pose[6] - pitch_delta | |
| for i in range(14): data.ctrl[i] = ctrl_test[i] | |
| beak_err = 1.550 - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-0.45 + 10.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.0, 0.2)) | |
| mujoco.mj_step(model, data) | |
| p_s = data.xpos[spoon_bid] | |
| R_s = data.xmat[spoon_bid].reshape(3, 3) | |
| bowl = p_s + R_s @ np.array([0.0, 0.0052, 0.070]) | |
| disp = float(np.linalg.norm(data.xpos[lemon_bid] - bowl)) | |
| if disp > 0.045 and not dropped_pitch: | |
| dropped_pitch = True | |
| drop_time = t | |
| print(f"Test 2 (Forward Pitch Ramp): DROPPED at t={drop_time:.2f}s (pitch_delta = {pitch_delta:.2f} rad, disp = {disp * 1000:.1f} mm)") | |
| break | |
| print(f"Test 2 Result: Dropped = {dropped_pitch}") | |
| # Test 3: Beak Release (loosen beak clamp) | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr) | |
| dropped_beak = False | |
| for step in range(1500): | |
| t = step * 0.002 | |
| data.qfrc_applied[beak_dof] = 0.0 # Zero clamping torque | |
| data.qpos[beak_qpos] = 2.00 # Open beak | |
| for i in range(14): data.ctrl[i] = default_pose[i] | |
| mujoco.mj_step(model, data) | |
| if data.xpos[spoon_bid][2] < 0.05 and not dropped_beak: | |
| dropped_beak = True | |
| print(f"Test 3 (Beak Release Open): SPOON & LEMON FELL TO FLOOR at t={t:.2f}s (spoon_z = {data.xpos[spoon_bid][2]:.3f} m)") | |
| break | |
| print(f"Test 3 Result: Dropped = {dropped_beak}") | |
| print("=" * 70 + "\n") | |
| return | |
| # Interactive Native Viewer Mode | |
| # Calibrate each actuator's ctrlrange to its actual joint range so MuJoCo GUI sliders have perfect bounds | |
| for a in range(model.nu): | |
| jnt_id = model.actuator_trnid[a, 0] | |
| model.actuator_ctrlrange[a] = model.jnt_range[jnt_id] | |
| ACTUATOR_NAMES = [ | |
| "left_hip_yaw", # 0 | |
| "left_hip_roll", # 1 | |
| "left_hip_pitch", # 2 | |
| "left_knee", # 3 | |
| "left_ankle", # 4 | |
| "neck_pitch", # 5 | |
| "head_pitch", # 6 | |
| "head_yaw", # 7 | |
| "head_roll", # 8 | |
| "right_hip_yaw", # 9 | |
| "right_hip_roll", # 10 | |
| "right_hip_pitch", # 11 | |
| "right_knee", # 12 | |
| "right_ankle", # 13 | |
| "beak", # 14 | |
| ] | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr) | |
| clamp_torque = 0.45 | |
| selected_actuator = 6 # Default: head_pitch | |
| auto_mode = None # None, "pitch_drop", "roll_drop", "beak_release" | |
| auto_timer = 0.0 | |
| user_paused = False | |
| reset_requested = False | |
| camera_mode = 0 # 0: Close-Up, 1: Side Profile, 2: Isometric, 3: Front, 4: Top-Down | |
| cam_preset_changed = True | |
| def on_key(keycode): | |
| nonlocal user_paused, reset_requested, selected_actuator | |
| nonlocal clamp_torque, auto_mode, auto_timer, camera_mode, cam_preset_changed | |
| if keycode == 32: # Space | |
| user_paused = not user_paused | |
| elif keycode in (ord('R'), ord('r'), ord('1'), 8, 259): # R, 1, or Backspace: Pristine reset | |
| reset_requested = True | |
| cam_preset_changed = True | |
| elif keycode in (ord('2'),): | |
| auto_mode = "pitch_drop" | |
| auto_timer = 0.0 | |
| print("\n[DEMO] Triggered Demo 2: Automatic Forward Pitch Drop (tilts past 26.6° friction limit)") | |
| elif keycode in (ord('3'),): | |
| auto_mode = "roll_drop" | |
| auto_timer = 0.0 | |
| print("\n[DEMO] Triggered Demo 3: Automatic Side Roll Drop (tilts sideways over rim)") | |
| elif keycode in (ord('4'),): | |
| auto_mode = "beak_release" | |
| auto_timer = 0.0 | |
| print("\n[DEMO] Triggered Demo 4: Automatic Beak Opening & Release") | |
| elif keycode in (ord('['),): | |
| selected_actuator = (selected_actuator - 1) % model.nu | |
| print(f"\n[ACTUATOR] Selected: [{selected_actuator}] {ACTUATOR_NAMES[selected_actuator]} = {math.degrees(data.ctrl[selected_actuator]):+.1f}°") | |
| elif keycode in (ord(']'),): | |
| selected_actuator = (selected_actuator + 1) % model.nu | |
| print(f"\n[ACTUATOR] Selected: [{selected_actuator}] {ACTUATOR_NAMES[selected_actuator]} = {math.degrees(data.ctrl[selected_actuator]):+.1f}°") | |
| elif keycode in (ord('+'), ord('='), 265): # + or Up: Step selected actuator positive | |
| auto_mode = None | |
| mn, mx = model.actuator_ctrlrange[selected_actuator] | |
| data.ctrl[selected_actuator] = float(np.clip(data.ctrl[selected_actuator] + 0.04, mn, mx)) | |
| print(f"\n[ACTUATOR] [{selected_actuator}] {ACTUATOR_NAMES[selected_actuator]} -> {math.degrees(data.ctrl[selected_actuator]):+.1f}° ({data.ctrl[selected_actuator]:.3f} rad)") | |
| elif keycode in (ord('-'), ord('_'), 264): # - or Down: Step selected actuator negative | |
| auto_mode = None | |
| mn, mx = model.actuator_ctrlrange[selected_actuator] | |
| data.ctrl[selected_actuator] = float(np.clip(data.ctrl[selected_actuator] - 0.04, mn, mx)) | |
| print(f"\n[ACTUATOR] [{selected_actuator}] {ACTUATOR_NAMES[selected_actuator]} -> {math.degrees(data.ctrl[selected_actuator]):+.1f}° ({data.ctrl[selected_actuator]:.3f} rad)") | |
| elif keycode in (ord('W'), ord('w')): # W: Tilt head/neck pitch down (forward) | |
| auto_mode = None | |
| data.ctrl[5] = float(np.clip(data.ctrl[5] - 0.04, model.actuator_ctrlrange[5, 0], model.actuator_ctrlrange[5, 1])) | |
| data.ctrl[6] = float(np.clip(data.ctrl[6] - 0.04, model.actuator_ctrlrange[6, 0], model.actuator_ctrlrange[6, 1])) | |
| print(f"\n[ACTUATOR] Neck & Head Pitch Down -> Neck:{math.degrees(data.ctrl[5]):+.1f}°, Head:{math.degrees(data.ctrl[6]):+.1f}°") | |
| elif keycode in (ord('S'), ord('s')): # S: Tilt head/neck pitch up (backward) | |
| auto_mode = None | |
| data.ctrl[5] = float(np.clip(data.ctrl[5] + 0.04, model.actuator_ctrlrange[5, 0], model.actuator_ctrlrange[5, 1])) | |
| data.ctrl[6] = float(np.clip(data.ctrl[6] + 0.04, model.actuator_ctrlrange[6, 0], model.actuator_ctrlrange[6, 1])) | |
| print(f"\n[ACTUATOR] Neck & Head Pitch Up -> Neck:{math.degrees(data.ctrl[5]):+.1f}°, Head:{math.degrees(data.ctrl[6]):+.1f}°") | |
| elif keycode in (ord('A'), ord('a'), 263): # A: Roll head left | |
| auto_mode = None | |
| data.ctrl[8] = float(np.clip(data.ctrl[8] + 0.04, model.actuator_ctrlrange[8, 0], model.actuator_ctrlrange[8, 1])) | |
| print(f"\n[ACTUATOR] Head Roll Left -> {math.degrees(data.ctrl[8]):+.1f}°") | |
| elif keycode in (ord('D'), ord('d'), 262): # D: Roll head right | |
| auto_mode = None | |
| data.ctrl[8] = float(np.clip(data.ctrl[8] - 0.04, model.actuator_ctrlrange[8, 0], model.actuator_ctrlrange[8, 1])) | |
| print(f"\n[ACTUATOR] Head Roll Right -> {math.degrees(data.ctrl[8]):+.1f}°") | |
| elif keycode in (ord('Q'), ord('q')): # Q: Yaw head left | |
| auto_mode = None | |
| data.ctrl[7] = float(np.clip(data.ctrl[7] + 0.05, model.actuator_ctrlrange[7, 0], model.actuator_ctrlrange[7, 1])) | |
| print(f"\n[ACTUATOR] Head Yaw Left -> {math.degrees(data.ctrl[7]):+.1f}°") | |
| elif keycode in (ord('E'), ord('e')): # E: Yaw head right | |
| auto_mode = None | |
| data.ctrl[7] = float(np.clip(data.ctrl[7] - 0.05, model.actuator_ctrlrange[7, 0], model.actuator_ctrlrange[7, 1])) | |
| print(f"\n[ACTUATOR] Head Yaw Right -> {math.degrees(data.ctrl[7]):+.1f}°") | |
| elif keycode in (ord('O'), ord('o')): # O: Open beak | |
| auto_mode = None | |
| data.ctrl[14] = float(np.clip(data.ctrl[14] + 0.04, 1.47, 2.05)) | |
| print(f"\n[ACTUATOR] Beak Open Angle -> {data.ctrl[14]:.3f} rad ({math.degrees(data.ctrl[14]):.1f}°)") | |
| elif keycode in (ord('C'), ord('c')): # C: Close beak | |
| auto_mode = None | |
| data.ctrl[14] = float(np.clip(data.ctrl[14] - 0.04, 1.47, 2.05)) | |
| print(f"\n[ACTUATOR] Beak Clamp Angle -> {data.ctrl[14]:.3f} rad ({math.degrees(data.ctrl[14]):.1f}°)") | |
| elif keycode in (ord('K'), ord('k')): # K: Crouch knees | |
| auto_mode = None | |
| data.ctrl[3] = float(np.clip(data.ctrl[3] + 0.06, -1.57, 1.57)) | |
| data.ctrl[12] = float(np.clip(data.ctrl[12] - 0.06, -1.57, 1.57)) | |
| print(f"\n[ACTUATOR] Knees Flex (Crouch) -> L:{math.degrees(data.ctrl[3]):.1f}°, R:{math.degrees(data.ctrl[12]):.1f}°") | |
| elif keycode in (ord('I'), ord('i')): # I: Extend knees (Stand) | |
| auto_mode = None | |
| data.ctrl[3] = float(np.clip(data.ctrl[3] - 0.06, -1.57, 1.57)) | |
| data.ctrl[12] = float(np.clip(data.ctrl[12] + 0.06, -1.57, 1.57)) | |
| print(f"\n[ACTUATOR] Knees Extend (Stand) -> L:{math.degrees(data.ctrl[3]):.1f}°, R:{math.degrees(data.ctrl[12]):.1f}°") | |
| elif keycode in (ord('H'), ord('h')): # H: Hips pitch forward | |
| auto_mode = None | |
| data.ctrl[2] = float(np.clip(data.ctrl[2] + 0.05, -1.57, 1.57)) | |
| data.ctrl[11] = float(np.clip(data.ctrl[11] - 0.05, -1.57, 1.57)) | |
| print(f"\n[ACTUATOR] Hips Pitch Forward -> L:{math.degrees(data.ctrl[2]):.1f}°, R:{math.degrees(data.ctrl[11]):.1f}°") | |
| elif keycode in (ord('Y'), ord('y')): # Y: Hips pitch backward | |
| auto_mode = None | |
| data.ctrl[2] = float(np.clip(data.ctrl[2] - 0.05, -1.57, 1.57)) | |
| data.ctrl[11] = float(np.clip(data.ctrl[11] + 0.05, -1.57, 1.57)) | |
| print(f"\n[ACTUATOR] Hips Pitch Backward -> L:{math.degrees(data.ctrl[2]):.1f}°, R:{math.degrees(data.ctrl[11]):.1f}°") | |
| elif keycode in (ord('V'), ord('v'), 9, 258): # V or Tab: Cycle camera views | |
| camera_mode = (camera_mode + 1) % 5 | |
| cam_preset_changed = True | |
| cams = ["Spoon & Lemon Close-Up", "Side Profile", "Isometric Arena", "Front Facial", "Top-Down Bird's-Eye"] | |
| print(f"\n[CAMERA] Preset -> {cams[camera_mode]} (Mouse Orbit & Zoom Active)") | |
| elif keycode == ord('5'): | |
| camera_mode = 0 | |
| cam_preset_changed = True | |
| print("\n[CAMERA] Preset 5 -> Spoon & Lemon Close-Up (Mouse Orbit & Zoom Active)") | |
| elif keycode == ord('6'): | |
| camera_mode = 1 | |
| cam_preset_changed = True | |
| print("\n[CAMERA] Preset 6 -> Side Profile (Tilt Angle View)") | |
| elif keycode == ord('7'): | |
| camera_mode = 2 | |
| cam_preset_changed = True | |
| print("\n[CAMERA] Preset 7 -> Isometric Arena View") | |
| elif keycode == ord('8'): | |
| camera_mode = 3 | |
| cam_preset_changed = True | |
| print("\n[CAMERA] Preset 8 -> Front Facial View") | |
| elif keycode == ord('9'): | |
| camera_mode = 4 | |
| cam_preset_changed = True | |
| print("\n[CAMERA] Preset 9 -> Top-Down Bird's-Eye View") | |
| viewer = mujoco.viewer.launch_passive(model, data, key_callback=on_key) | |
| viewer.cam.type = mujoco.mjtCamera.mjCAMERA_FREE | |
| viewer.opt.geomgroup[3] = 0 | |
| print("\n" + "=" * 76) | |
| print("MICRODUCK STANDING ACTUATOR CONTROL & LEMON DROP VIEWER READY") | |
| print("=" * 76) | |
| print(" GUI SLIDERS (100% REAL-TIME CONTROLLABLE IN MUJOCO WINDOW):") | |
| print(" Look at the left sidebar (Control tab). All 15 sliders are active:") | |
| print(" • neck_pitch, head_pitch, head_yaw, head_roll") | |
| print(" • beak (aperture & clamping)") | |
| print(" • left_knee, right_knee (crouch / squat)") | |
| print(" • left_hip_pitch, right_hip_pitch (pelvis lean)") | |
| print(" • left_ankle, right_ankle, hip yaw & roll") | |
| print(" Drag ANY slider left or right with your mouse to move it in real-time!") | |
| print(" KEYBOARD CONTROLS:") | |
| print(" [ / ]: Select ANY of the 15 actuators") | |
| print(" Up / Down (or + / -): Adjust selected actuator angle (+/- 2.3°)") | |
| print(" W / S: Quick Head & Neck Pitch (Forward / Backward)") | |
| print(" A / D: Quick Head Roll (Left / Right)") | |
| print(" Q / E: Quick Head Yaw (Left / Right)") | |
| print(" O / C: Quick Beak Open / Close") | |
| print(" K / I: Quick Knees Flex / Extend (Squat / Stand)") | |
| print(" H / Y: Quick Hips Pitch (Lean Forward / Backward)") | |
| print(" PRESET DEMOS:") | |
| print(" 1: Reset to Level Neutral Standing (0.0°)") | |
| print(" 2: Auto Pitch Drop Demo (Tilts past 26.6° friction limit)") | |
| print(" 3: Auto Roll Drop Demo (Rolls lemon over side rim)") | |
| print(" 4: Auto Beak Release Demo (Loosens jaw until spoon drops)") | |
| print(" CAMERA PRESETS & MOUSE:") | |
| print(" Mouse Left-Click+Drag: Orbit / Rotate 360° around robot") | |
| print(" Mouse Right-Click+Drag: Pan / Translate view") | |
| print(" Scroll Wheel: Zoom in / out smoothly") | |
| print(" V or Tab: Cycle Views (Close-Up -> Side -> Iso -> Front -> Top)") | |
| print(" 5 / 6 / 7 / 8 / 9: Direct presets (5:Close, 6:Side, 7:Iso, 8:Front, 9:Top)") | |
| print(" SIMULATION CONTROLS:") | |
| print(" Space: Pause / Resume physics") | |
| print(" R / 1 / Backspace: Pristine Reset (also GUI toolbar 'Reset' button)") | |
| print("=" * 76 + "\n") | |
| step = 0 | |
| sim_dt = model.opt.timestep | |
| while viewer.is_running(): | |
| t_start = time.time() | |
| # Check if reset requested or if native MuJoCo GUI reset button was clicked (data.time jumped back to 0 or data.ctrl[14] zeroed) | |
| native_gui_reset = (step > 15 and data.time < 0.010) or (data.ctrl[14] < 1.30) | |
| if reset_requested or native_gui_reset: | |
| reset_requested = False | |
| auto_mode = None | |
| auto_timer = 0.0 | |
| clamp_torque = 0.45 | |
| selected_actuator = 6 # head_pitch | |
| cam_preset_changed = True | |
| camera_mode = 0 # Spoon & lemon close-up view | |
| setup_standing_robot(model, data, default_pose, joint_qpos_indices, beak_qpos, beak_dof, | |
| spoon_bid, lemon_bid, jaw_bid, spoon_qadr, lemon_qadr, | |
| target_clamp_angle=1.550, clamp_torque=0.45) | |
| for i in range(14): | |
| data.ctrl[i] = default_pose[i] | |
| data.ctrl[14] = 1.550 | |
| data.qfrc_applied[beak_dof] = -0.45 | |
| step = 16 | |
| print("\n[RESET] Simulation reset to pristine standing state with balanced lemon!") | |
| if user_paused: | |
| viewer.sync() | |
| time.sleep(0.02) | |
| continue | |
| # Handle automated demos | |
| if auto_mode == "pitch_drop": | |
| auto_timer += sim_dt | |
| p_val = default_pose[5] - min(0.95, auto_timer * 0.40) | |
| data.ctrl[5] = p_val | |
| data.ctrl[6] = p_val | |
| elif auto_mode == "roll_drop": | |
| auto_timer += sim_dt | |
| data.ctrl[8] = default_pose[8] + min(0.40, auto_timer * 0.25) | |
| elif auto_mode == "beak_release": | |
| auto_timer += sim_dt | |
| data.ctrl[14] = min(2.05, 1.550 + auto_timer * 0.50) | |
| # Active beak clamping physics tied directly to data.ctrl[14] | |
| target_clamp_angle = float(data.ctrl[14]) | |
| if target_clamp_angle > 1.62: | |
| eff_torque = max(0.0, clamp_torque * max(0.0, (1.85 - target_clamp_angle) / 0.23)) | |
| else: | |
| eff_torque = clamp_torque | |
| beak_err = target_clamp_angle - data.qpos[beak_qpos] | |
| data.qfrc_applied[beak_dof] = float(np.clip(-eff_torque + 15.0 * beak_err - 0.3 * data.qvel[beak_dof], -1.2, 0.2)) | |
| mujoco.mj_step(model, data) | |
| step += 1 | |
| # Calculate lemon and spoon telemetry | |
| p_s = data.xpos[spoon_bid] | |
| R_s = data.xmat[spoon_bid].reshape(3, 3) | |
| bowl = p_s + R_s @ np.array([0.0, 0.0052, 0.070]) | |
| lemon_pos = data.xpos[lemon_bid] | |
| disp_mm = float(np.linalg.norm(lemon_pos - bowl)) * 1000.0 | |
| # Spoon orientation relative to world horizontal | |
| # spoon forward vector in world: | |
| h_fwd = R_s @ np.array([0.0, 0.0, 1.0]) | |
| # spoon bowl normal vector in world: | |
| b_norm = R_s @ np.array([0.0, 1.0, 0.0]) | |
| spoon_pitch_deg = math.degrees(math.asin(np.clip(b_norm[0], -0.999, 0.999))) | |
| spoon_roll_deg = math.degrees(math.asin(np.clip(b_norm[1], -0.999, 0.999))) | |
| # Status text | |
| if disp_mm < 3.0: | |
| status = "BALANCED (In Bowl)" | |
| status_color = "\033[92m" # Green | |
| elif disp_mm < 45.0: | |
| status = "SLIPPING ON RIM" | |
| status_color = "\033[93m" # Yellow | |
| else: | |
| status = "DROPPED TO FLOOR" | |
| status_color = "\033[91m" # Red | |
| reset_color = "\033[0m" | |
| # Apply camera preset when requested (does NOT overwrite every frame so native mouse orbit/zoom works!) | |
| if cam_preset_changed: | |
| p_jaw = data.xpos[jaw_bid] | |
| if camera_mode == 0: # Close-up of spoon and lemon | |
| viewer.cam.lookat = [p_s[0] + 0.03, p_s[1], p_s[2] + 0.04] | |
| viewer.cam.distance = 0.38 | |
| viewer.cam.elevation = -12.0 | |
| viewer.cam.azimuth = 32.0 | |
| elif camera_mode == 1: # Side profile showing tilt angle | |
| viewer.cam.lookat = [p_jaw[0], p_jaw[1], p_jaw[2] - 0.02] | |
| viewer.cam.distance = 0.55 | |
| viewer.cam.elevation = -5.0 | |
| viewer.cam.azimuth = 90.0 | |
| elif camera_mode == 2: # Isometric standing view | |
| viewer.cam.lookat = [0.0, 0.0, 0.12] | |
| viewer.cam.distance = 0.85 | |
| viewer.cam.elevation = -18.0 | |
| viewer.cam.azimuth = 45.0 | |
| elif camera_mode == 3: # Front facial view | |
| viewer.cam.lookat = [p_s[0] + 0.05, p_s[1], p_s[2]] | |
| viewer.cam.distance = 0.45 | |
| viewer.cam.elevation = -8.0 | |
| viewer.cam.azimuth = 180.0 | |
| elif camera_mode == 4: # Top-down bird's-eye view | |
| viewer.cam.lookat = [p_s[0], p_s[1], p_s[2]] | |
| viewer.cam.distance = 0.50 | |
| viewer.cam.elevation = -88.0 | |
| viewer.cam.azimuth = 90.0 | |
| cam_preset_changed = False | |
| viewer.sync() | |
| # Telemetry terminal HUD every 50 steps (0.10s) | |
| if step % 50 == 0: | |
| friction_limit = 26.6 # arctan(0.50) | |
| tilt_warning = " [EXCEEDS 26.6° LIMIT!]" if abs(spoon_pitch_deg) > friction_limit or abs(spoon_roll_deg) > friction_limit else "" | |
| sel_name = ACTUATOR_NAMES[selected_actuator] | |
| sel_val_deg = math.degrees(data.ctrl[selected_actuator]) | |
| hud_str = ( | |
| f"\rSelected: [{selected_actuator:2d}] {sel_name:15s}={sel_val_deg:+5.1f}° | " | |
| f"Spoon Pitch:{spoon_pitch_deg:+5.1f}° | Roll:{spoon_roll_deg:+5.1f}° | " | |
| f"Beak:{math.degrees(target_clamp_angle):4.1f}° | " | |
| f"Disp:{disp_mm:4.1f}mm | {status_color}{status}{reset_color}{tilt_warning} " | |
| ) | |
| sys.stdout.write(hud_str) | |
| sys.stdout.flush() | |
| t_elapsed = time.time() - t_start | |
| dt_sleep = sim_dt - t_elapsed | |
| if dt_sleep > 0: | |
| time.sleep(dt_sleep) | |
| viewer.close() | |
| if __name__ == "__main__": | |
| main() | |