MtnMCG commited on
Commit
a1d2967
·
verified ·
1 Parent(s): 00e9adb

Upload ninja_env.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. ninja_env.py +1801 -0
ninja_env.py ADDED
@@ -0,0 +1,1801 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ embodied_pcei/src/ninja_env.py
3
+
4
+ High-performance physical simulation environment for the American Ninja Warrior course.
5
+ Built on MuJoCo with native Apple Silicon performance.
6
+
7
+ Features:
8
+ - Articulated bipedal humanoid with 21 joint torque actuators.
9
+ - Staged American Ninja Warrior course:
10
+ * Stage 0: Flat sprint runway (0 to 8m)
11
+ * Stage 1: Quad steps / angled stepping stones (8 to 18m)
12
+ * Stage 2: Elevated balance beam (18 to 28m)
13
+ * Stage 3: Hurdles & stepping barriers (28 to 38m)
14
+ * Stage 4: Warped wall incline ramp & summit buzzer (38 to 48m)
15
+ - Raycast Pseudo-Vision: forward height probes measuring terrain/obstacle topology.
16
+ - Metabolic / Homeostatic Telemetry: lungs/stamina, stomach/hunger, heart/health.
17
+ - Built-in offscreen renderer for live web dashboard streaming.
18
+ """
19
+
20
+ import numpy as np
21
+ import mujoco
22
+ from pathlib import Path
23
+ from typing import Dict, Tuple, Optional
24
+
25
+ XML_PATH = Path(__file__).resolve().parent.parent / "models" / "ninja_course.xml"
26
+ FLAT_ARENA_XML = Path(__file__).resolve().parent.parent / "models" / "flat_arena.xml"
27
+
28
+ # Obstacle Stage Milestones along +X
29
+ STAGE_MILESTONES = [
30
+ (8.0, "Stage 1: Quad Steps"),
31
+ (18.0, "Stage 2: Balance Beam"),
32
+ (28.0, "Stage 3: Hurdles"),
33
+ (38.0, "Stage 4: Warped Wall"),
34
+ (46.0, "Course Finish: Summit Buzzer"),
35
+ ]
36
+
37
+ # 18 Progressive Navigation Waypoints across the 48m Ninja Warrior Course
38
+ # Format: (Target 3D Coordinate [X, Y, Z], Waypoint Label, Arrival Radius in meters)
39
+ COURSE_WAYPOINTS = [
40
+ (np.array([3.0, 0.00, 1.33], dtype=np.float32), "Runway Accelerate 1", 1.2),
41
+ (np.array([6.0, 0.00, 1.33], dtype=np.float32), "Runway Accelerate 2", 1.2),
42
+ (np.array([8.0, 0.00, 1.33], dtype=np.float32), "Runway Exit Threshold", 1.0),
43
+ (np.array([10.0, -0.60, 0.20], dtype=np.float32), "Stage 1: Quad Step 1 (Right)", 0.8),
44
+ (np.array([12.2, 0.60, 0.30], dtype=np.float32), "Stage 1: Quad Step 2 (Left)", 0.8),
45
+ (np.array([14.4, -0.50, 0.35], dtype=np.float32), "Stage 1: Quad Step 3 (Right)", 0.8),
46
+ (np.array([16.6, 0.50, 0.40], dtype=np.float32), "Stage 1: Quad Step 4 (Left)", 0.8),
47
+ (np.array([18.5, 0.00, 0.45], dtype=np.float32), "Platform Mid 1", 1.0),
48
+ (np.array([20.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Entry", 0.6),
49
+ (np.array([23.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Mid", 0.6),
50
+ (np.array([26.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Exit", 0.6),
51
+ (np.array([28.5, 0.00, 0.50], dtype=np.float32), "Platform Mid 2", 1.0),
52
+ (np.array([31.5, 0.00, 0.20], dtype=np.float32), "Stage 3: Hurdle 1", 0.9),
53
+ (np.array([34.5, 0.00, 0.28], dtype=np.float32), "Stage 3: Hurdle 2", 0.9),
54
+ (np.array([37.5, 0.00, 0.22], dtype=np.float32), "Stage 3: Hurdle 3", 0.9),
55
+ (np.array([40.0, 0.00, 0.30], dtype=np.float32), "Stage 4: Warped Wall Approach",1.2),
56
+ (np.array([42.5, 0.00, 0.70], dtype=np.float32), "Stage 4: Warped Wall Incline", 1.2),
57
+ (np.array([47.5, 0.00, 2.45], dtype=np.float32), "Course Finish: Summit Buzzer", 0.6),
58
+ ]
59
+
60
+ # Translucent Neon Color Palette for Multi-Agent Ghost Swarm Rendering
61
+ GHOST_PALETTE = [
62
+ np.array([0.2, 0.7, 1.0, 0.16], dtype=np.float32), # Neon Cyan
63
+ np.array([0.7, 0.3, 1.0, 0.16], dtype=np.float32), # Electric Purple
64
+ np.array([0.1, 0.9, 0.6, 0.16], dtype=np.float32), # Neon Emerald
65
+ np.array([1.0, 0.55, 0.1, 0.16], dtype=np.float32), # Sunset Orange
66
+ np.array([1.0, 0.2, 0.6, 0.16], dtype=np.float32), # Neon Pink
67
+ np.array([0.35, 0.8, 1.0, 0.16], dtype=np.float32), # Sky Blue
68
+ np.array([0.85, 0.95, 0.2, 0.16], dtype=np.float32),# Lime Volt
69
+ ]
70
+
71
+ # Canonical Reference Quiet Standing Equilibrium Pose (21 Actuated Joints)
72
+ # Torso at Z=1.168m, spine vertical, knees naturally flexed at 0.05 rad (~3 deg), arms down at sides
73
+ STAND_REF_QPOS = np.array([
74
+ 0.0, 0.0, 0.0, # abdomen_z, abdomen_y, abdomen_x
75
+ 0.0, 0.0, -0.05, 0.05, -0.025, 0.0, # right_hip_x, z, y, knee, ankle_y, ankle_x
76
+ 0.0, 0.0, -0.05, 0.05, -0.025, 0.0, # left_hip_x, z, y, knee, ankle_y, ankle_x
77
+ 0.65, 0.0, 0.0, # right_shoulder_x, y, elbow (down at side)
78
+ -0.65, 0.0, 0.0, # left_shoulder_x, y, elbow (down at side)
79
+ ], dtype=np.float32)
80
+
81
+ STAND_JOINT_WEIGHTS = np.array([
82
+ 1.5, 1.5, 1.5, # abdomen_z, abdomen_y, abdomen_x
83
+ 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, # right leg (hip_x, hip_z, hip_y, knee, ankle_y, ankle_x)
84
+ 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, # left leg (hip_x, hip_z, hip_y, knee, ankle_y, ankle_x)
85
+ 0.5, 0.5, 0.5, # right arm (shoulder_x, shoulder_y, elbow)
86
+ 0.5, 0.5, 0.5 # left arm (shoulder_x, shoulder_y, elbow)
87
+ ], dtype=np.float32)
88
+
89
+
90
+ class NinjaEnv:
91
+ def __init__(self, xml_path: Optional[str] = None, frame_skip: int = 5, arena_mode: bool = True, stream_mode: str = "cruise"):
92
+ self.arena_mode = arena_mode
93
+ self.stream_mode = stream_mode # "cruise", "stand", "getup", "reach"
94
+ if xml_path is None:
95
+ xml_path = str(FLAT_ARENA_XML if arena_mode else XML_PATH)
96
+ self.model = mujoco.MjModel.from_xml_path(xml_path)
97
+ self.data = mujoco.MjData(self.model)
98
+ self.frame_skip = frame_skip
99
+
100
+ self.dt = self.model.opt.timestep * frame_skip # 0.005 * 5 = 0.025s (40 Hz)
101
+
102
+ self.n_actuators = self.model.nu # 21
103
+ self.n_qpos = self.model.nq # 28 (7 root + 21 joints)
104
+ self.n_qvel = self.model.nv # 27 (6 root + 21 joints)
105
+
106
+ # Solid-State Angular LiDAR Sensor Configuration
107
+ # 16 vertical beams (pitch: -35 deg to +15 deg) x 32 horizontal beams (yaw: -45 deg to +45 deg) = 512 rays
108
+ self.num_lidar_v = 16
109
+ self.num_lidar_h = 32
110
+ self.num_lidar_rays = self.num_lidar_v * self.num_lidar_h # 512
111
+ self.num_rays = self.num_lidar_rays # backwards-compatibility alias
112
+ self.lidar_max_range = 10.0 # meters
113
+ self.lidar_v_angles = np.radians(np.linspace(-35, 15, self.num_lidar_v))
114
+ self.lidar_h_angles = np.radians(np.linspace(-45, 45, self.num_lidar_h))
115
+
116
+ # Precompute unit direction vectors in head local coordinate frame
117
+ # Forward = +X, Left = +Y, Up = +Z
118
+ local_vectors = []
119
+ for pitch in self.lidar_v_angles:
120
+ for yaw in self.lidar_h_angles:
121
+ vx = np.cos(pitch) * np.cos(yaw)
122
+ vy = np.cos(pitch) * np.sin(yaw)
123
+ vz = np.sin(pitch)
124
+ v = np.array([vx, vy, vz], dtype=np.float64)
125
+ v /= np.linalg.norm(v)
126
+ local_vectors.append(v)
127
+ self.lidar_local_vectors = np.array(local_vectors, dtype=np.float64) # (128, 3)
128
+ self.current_lidar_grid = np.zeros((self.num_lidar_v, self.num_lidar_h), dtype=np.float32)
129
+ self._geomid_buf = np.zeros(1, dtype=np.int32)
130
+ self._visor_offset = np.array([0.15, 0.0, 0.0], dtype=np.float64)
131
+
132
+ # Metabolic internal states
133
+ self.stamina = 1.0 # 0.0 to 1.0
134
+ self.health = 1.0 # 0.0 to 1.0
135
+ self.hunger = 0.0 # 0.0 to 1.0
136
+
137
+ # Milestone tracking & Course Waypoints
138
+ self.milestones_reached = set()
139
+ self.waypoints_reached = set()
140
+ self.current_wp_idx = 0
141
+ self.furthest_x = 0.0
142
+ self.max_steps = 1000
143
+
144
+ # Flat Arena & Dynamic Random Goal Navigation State (Stream 4: Cruise)
145
+ self.goal_pos = np.array([5.0, 0.0, 1.25], dtype=np.float32)
146
+ self.goals_completed = 0
147
+ self.goal_arrival_radius = 2.0 # Wide capture radius (2.0m): easily clears by walking near target
148
+ self.min_dist_to_goal = 5.0
149
+ self.prev_dist_to_goal = 5.0
150
+ self.steps_not_approaching = 0
151
+ self.transition_leeway_window = 90 # ~2.25s leeway (90 steps at 40Hz) for turning & realignment
152
+ self.goal_transition_steps = 0 # No leeway on initial spawn (must advance immediately)
153
+
154
+ # Stream 1: Quiet Standing Stillness State
155
+ self.stand_still_steps = 0
156
+ self.stand_drift_steps = 0
157
+ self.stand_one_leg_steps = 0
158
+
159
+ # Stream 2: Fall Recovery State (Ragdoll & Curated Library)
160
+ self.fall_state_type = "random_ragdoll"
161
+ self.consecutive_upright_steps = 0
162
+ self.prev_z = 0.20
163
+ self.prev_head_z = 0.20
164
+ self.getup_stagnant_steps = 0
165
+ self.max_elevation_achieved = 0.20
166
+ self.max_head_elevation = 0.20
167
+ self.getup_goal_pos = np.array([0.0, 0.0, 1.168], dtype=np.float32)
168
+ self.getup_success_count = 0
169
+
170
+ # Stream 3: Bilateral Hand Reaching State (Bolted Torso)
171
+ self.active_reach_hand = "right"
172
+ self.reach_target_pos = np.array([0.35, -0.25, 1.35], dtype=np.float32)
173
+ self.reach_touches = 0
174
+ self.prev_reach_dist = 0.40
175
+ self.min_reach_dist = 0.40
176
+ self.steps_on_reach_target = 0
177
+ self.steps_receding_reach = 0
178
+
179
+ # Bilateral gait stance duty-cycle counters
180
+ self.r_foot_contact_steps = 0
181
+ self.l_foot_contact_steps = 0
182
+
183
+ # Offscreen renderer instance (lazy-loaded)
184
+ self._renderer = None
185
+
186
+ # Cache body and geom IDs for fast lookups
187
+ self.torso_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "torso")
188
+ self.head_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "head")
189
+ self.r_foot_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "right_foot")
190
+ self.l_foot_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "left_foot")
191
+ self.r_foot_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_foot_geom")
192
+ self.l_foot_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_foot_geom")
193
+ self.r_hand_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_hand")
194
+ self.l_hand_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_hand")
195
+ self.r_arm_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "right_lower_arm")
196
+ self.l_arm_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "left_lower_arm")
197
+
198
+ # Cache leg joint qpos indices for athletic stance and gait monitoring
199
+ self.r_knee_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_knee")]
200
+ self.l_knee_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_knee")]
201
+ self.r_hip_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_hip_y")]
202
+ self.l_hip_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_hip_y")]
203
+ self.r_ankle_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_ankle_y")]
204
+ self.l_ankle_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_ankle_y")]
205
+ # Cache abdomen joint qpos indices for core posture alignment
206
+ self.abdomen_x_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_x")]
207
+ self.abdomen_y_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_y")]
208
+ self.abdomen_z_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_z")]
209
+
210
+ # Cache arm joint qpos and dof velocity indices for bilateral reach
211
+ self.arm_qpos_indices = [
212
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_x")],
213
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_y")],
214
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_elbow")],
215
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_x")],
216
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_y")],
217
+ self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_elbow")],
218
+ ]
219
+ self.arm_dof_indices = [
220
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_x")],
221
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_y")],
222
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_elbow")],
223
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_x")],
224
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_y")],
225
+ self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_elbow")],
226
+ ]
227
+
228
+ # Cache collision geoms for physical impact detection (head, visor, chest, torso)
229
+ # All geoms belonging to world body (body 0) are environmental surfaces: floor + all obstacles
230
+ self.world_obstacle_geoms = {i for i in range(self.model.ngeom) if self.model.geom_bodyid[i] == 0}
231
+ self.body_impact_geoms = {
232
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "torso_geom"),
233
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "chest_geom"),
234
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "head_geom"),
235
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "visor"),
236
+ }
237
+
238
+ # Cache humanoid geom indices (all geoms belonging to non-world bodies) for ghost rendering
239
+ self.humanoid_geoms = [i for i in range(self.model.ngeom) if self.model.geom_bodyid[i] != 0]
240
+
241
+ # Cache limb geoms for Get-Up multi-phase floor contact righting
242
+ self.r_shin_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_shin_geom")
243
+ self.l_shin_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_shin_geom")
244
+ self.support_hand_geoms = {
245
+ self.r_hand_geom_id,
246
+ self.l_hand_geom_id,
247
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_larm_geom"),
248
+ mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_larm_geom"),
249
+ }
250
+ self.support_knee_geoms = {self.r_shin_geom_id, self.l_shin_geom_id}
251
+
252
+ def _lock_torso_for_reach(self):
253
+ """Bolts root torso, core spine, and legs in neutral athletic stance (Stream 3: Isolated Arm Motor Cortex)."""
254
+ self.data.qpos[0] = 0.0
255
+ self.data.qpos[1] = 0.0
256
+ self.data.qpos[2] = 1.35
257
+ self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0]) # Upright quaternion
258
+ self.data.qvel[0:6] = 0.0 # Zero base linear & angular velocity
259
+ # Lock core spine neutral
260
+ self.data.qpos[7:10] = 0.0 # abdomen_z, abdomen_y, abdomen_x
261
+ # Lock legs in neutral supportive stance
262
+ self.data.qpos[10:22] = 0.0 # zero all hip, knee, ankle
263
+ self.data.qpos[self.r_knee_qpos] = 0.30
264
+ self.data.qpos[self.l_knee_qpos] = 0.30
265
+ self.data.qpos[self.r_ankle_qpos] = -0.10
266
+ self.data.qpos[self.l_ankle_qpos] = -0.10
267
+ self.data.qvel[6:21] = 0.0 # Zero core & lower body velocities (DOFs 6 to 20, preserving arm DOFs 21:27)
268
+
269
+ def _sample_reach_target(self):
270
+ """
271
+ Samples a randomized 3D reach goal via Forward Kinematics (FK) from the active arm's
272
+ reachable joint space, mathematically guaranteeing 100.0% of targets are physically reachable (Stream 3).
273
+ """
274
+ self.active_reach_hand = np.random.choice(["right", "left"])
275
+
276
+ # Save current posture
277
+ saved_qpos = self.data.qpos.copy()
278
+ target_pos = None
279
+
280
+ # Sample joint angles from the arm's valid forward/lateral reachable envelope
281
+ for _ in range(25):
282
+ if self.active_reach_hand == "right":
283
+ # Right arm envelope: shoulder roll/pitch and elbow flexion
284
+ target_sx = float(np.random.uniform(-0.75, 0.40))
285
+ target_sy = float(np.random.uniform(-0.60, 0.85))
286
+ target_el = float(np.random.uniform(-1.10, 0.45))
287
+ self.data.qpos[self.arm_qpos_indices[0]] = target_sx # right_shoulder_x
288
+ self.data.qpos[self.arm_qpos_indices[1]] = target_sy # right_shoulder_y
289
+ self.data.qpos[self.arm_qpos_indices[2]] = target_el # right_elbow
290
+ mujoco.mj_forward(self.model, self.data)
291
+ cand_pos = self.data.geom_xpos[self.r_hand_geom_id].copy()
292
+ else:
293
+ # Left arm envelope: shoulder roll/pitch and elbow flexion
294
+ target_sx = float(np.random.uniform(-0.40, 0.75))
295
+ target_sy = float(np.random.uniform(-0.60, 0.85))
296
+ target_el = float(np.random.uniform(-0.45, 1.10))
297
+ self.data.qpos[self.arm_qpos_indices[3]] = target_sx # left_shoulder_x
298
+ self.data.qpos[self.arm_qpos_indices[4]] = target_sy # left_shoulder_y
299
+ self.data.qpos[self.arm_qpos_indices[5]] = target_el # left_elbow
300
+ mujoco.mj_forward(self.model, self.data)
301
+ cand_pos = self.data.geom_xpos[self.l_hand_geom_id].copy()
302
+
303
+ # Restore original pose to measure distance
304
+ self.data.qpos[:] = saved_qpos
305
+ mujoco.mj_forward(self.model, self.data)
306
+ curr_h = self.data.geom_xpos[self.r_hand_geom_id] if self.active_reach_hand == "right" else self.data.geom_xpos[self.l_hand_geom_id]
307
+ dist_to_cand = float(np.linalg.norm(curr_h - cand_pos))
308
+ target_pos = cand_pos
309
+ # Ensure target is at least 0.16m away so the agent must actively articulate its arm
310
+ if dist_to_cand >= 0.16:
311
+ break
312
+
313
+ # Always restore state cleanly
314
+ self.data.qpos[:] = saved_qpos
315
+ mujoco.mj_forward(self.model, self.data)
316
+
317
+ self.reach_target_pos = target_pos.astype(np.float32)
318
+ self.goal_pos = self.reach_target_pos.copy()
319
+
320
+ def _apply_fall_state(self, pose_name: Optional[str] = None):
321
+ """
322
+ Initializes agent in a randomized fall state with 360° randomized yaw,
323
+ perturbed joints, physics contact settling, and a randomized standing destination goal (Stream 2).
324
+ """
325
+ if pose_name is None:
326
+ poses = ["prone", "supine", "lateral_left", "lateral_right", "kneeling_crumpled", "seated_slumped", "random_ragdoll"]
327
+ pose_name = np.random.choice(poses)
328
+ self.fall_state_type = pose_name
329
+
330
+ self.data.qvel[:] = 0.0
331
+ # Random initial floor translation offset
332
+ spawn_x = float(np.random.uniform(-0.4, 0.4))
333
+ spawn_y = float(np.random.uniform(-0.4, 0.4))
334
+
335
+ # Base orientation quaternion [w, x, y, z] per canonical pose
336
+ if pose_name == "prone":
337
+ # Lying on stomach: pitch 90 deg forward
338
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22])
339
+ q_base = np.array([0.7071, 0.0, 0.7071, 0.0])
340
+ self.data.qpos[self.r_knee_qpos] = 0.2
341
+ self.data.qpos[self.l_knee_qpos] = 0.2
342
+ elif pose_name == "supine":
343
+ # Lying on back: pitch 90 deg backward
344
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22])
345
+ q_base = np.array([0.7071, 0.0, -0.7071, 0.0])
346
+ self.data.qpos[self.r_knee_qpos] = 0.3
347
+ self.data.qpos[self.l_knee_qpos] = 0.3
348
+ elif pose_name == "lateral_left":
349
+ # Lying on left flank: roll 90 deg left
350
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22])
351
+ q_base = np.array([0.7071, -0.7071, 0.0, 0.0])
352
+ elif pose_name == "lateral_right":
353
+ # Lying on right flank: roll 90 deg right
354
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22])
355
+ q_base = np.array([0.7071, 0.7071, 0.0, 0.0])
356
+ elif pose_name == "kneeling_crumpled":
357
+ # Crumpled on knees, torso pitched forward
358
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.48])
359
+ q_base = np.array([0.9238, 0.0, 0.3826, 0.0])
360
+ self.data.qpos[self.r_knee_qpos] = 1.7
361
+ self.data.qpos[self.l_knee_qpos] = 1.7
362
+ self.data.qpos[self.r_hip_qpos] = -1.0
363
+ self.data.qpos[self.l_hip_qpos] = -1.0
364
+ elif pose_name == "seated_slumped":
365
+ # Slumped seated on buttocks, legs forward
366
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.35])
367
+ q_base = np.array([0.9238, 0.0, -0.3826, 0.0])
368
+ self.data.qpos[self.r_hip_qpos] = -1.4
369
+ self.data.qpos[self.l_hip_qpos] = -1.4
370
+ self.data.qpos[self.r_knee_qpos] = 0.2
371
+ self.data.qpos[self.l_knee_qpos] = 0.2
372
+ else:
373
+ # Random ragdoll drop: drop from 0.45m with completely randomized 3D orientation
374
+ self.data.qpos[0:3] = np.array([spawn_x, spawn_y, float(np.random.uniform(0.40, 0.60))])
375
+ rand_quat = np.random.uniform(-1.0, 1.0, size=4)
376
+ q_base = rand_quat / np.linalg.norm(rand_quat)
377
+
378
+ # Apply randomized 360° yaw rotation to base pose quaternion
379
+ yaw = float(np.random.uniform(-np.pi, np.pi))
380
+ q_yaw = np.array([np.cos(yaw / 2.0), 0.0, 0.0, np.sin(yaw / 2.0)], dtype=np.float64)
381
+ # Quaternion multiplication: q_yaw * q_base
382
+ w1, x1, y1, z1 = q_yaw
383
+ w2, x2, y2, z2 = q_base
384
+ q_rot = np.array([
385
+ w1*w2 - x1*x2 - y1*y2 - z1*z2,
386
+ w1*x2 + x1*w2 + y1*z2 - z1*y2,
387
+ w1*y2 - x1*z2 + y1*w2 + z1*x2,
388
+ w1*z2 + x1*y2 - y1*x2 + z1*w2,
389
+ ], dtype=np.float64)
390
+ self.data.qpos[3:7] = q_rot / np.linalg.norm(q_rot)
391
+
392
+ # Stochastic joint perturbation across all 21 joint degrees of freedom
393
+ self.data.qpos[7:] += np.random.uniform(-0.20, 0.20, size=self.n_actuators)
394
+
395
+ # Settle contact forces smoothly (12 passive sub-steps with zero actuation)
396
+ mujoco.mj_forward(self.model, self.data)
397
+ for _ in range(12):
398
+ self.data.ctrl[:] = 0.0
399
+ mujoco.mj_step(self.model, self.data)
400
+ self.data.qvel[:] = 0.0
401
+ mujoco.mj_forward(self.model, self.data)
402
+
403
+ # Sample a randomized standing destination goal beacon (0.6m - 1.8m away at random heading)
404
+ stand_dist = float(np.random.uniform(0.6, 1.8))
405
+ stand_heading = float(np.random.uniform(-np.pi, np.pi))
406
+ cur_torso = self.data.xpos[self.torso_body_id]
407
+ gx = float(cur_torso[0] + stand_dist * np.cos(stand_heading))
408
+ gy = float(cur_torso[1] + stand_dist * np.sin(stand_heading))
409
+ gz = 1.168 # Grounded standing torso height equilibrium
410
+ self.getup_goal_pos = np.array([gx, gy, gz], dtype=np.float32)
411
+ self.goal_pos = self.getup_goal_pos.copy()
412
+
413
+ def _sample_new_goal(self, is_initial: bool = False):
414
+ """Samples a new random navigation target within the 50m x 50m flat arena."""
415
+ torso_pos = self.data.xpos[self.torso_body_id]
416
+ if is_initial:
417
+ # Initial target: distance 5.0m - 7.5m in forward cone (-60 deg to +60 deg)
418
+ dist = float(np.random.uniform(5.0, 7.5))
419
+ angle = float(np.random.uniform(-np.pi / 3, np.pi / 3))
420
+ self.goal_transition_steps = 0 # Spawn directly facing target: immediate approach required
421
+ else:
422
+ # Subsequent target: distance 5.5m - 9.0m in full 360 deg omnidirectional bearing
423
+ dist = float(np.random.uniform(5.5, 9.0))
424
+ angle = float(np.random.uniform(-np.pi, np.pi))
425
+ self.goal_transition_steps = self.transition_leeway_window # ~1.85s direction change leeway
426
+
427
+ gx = float(np.clip(torso_pos[0] + dist * np.cos(angle), -22.0, 22.0))
428
+ gy = float(np.clip(torso_pos[1] + dist * np.sin(angle), -22.0, 22.0))
429
+
430
+ # Ensure newly sampled target is at least (arrival_radius + 1.5m = 3.5m) away from current torso
431
+ for _ in range(20):
432
+ if np.linalg.norm(np.array([gx - torso_pos[0], gy - torso_pos[1]])) >= (self.goal_arrival_radius + 1.5):
433
+ break
434
+ center_angle = float(np.arctan2(-torso_pos[1], -torso_pos[0]))
435
+ angle = float(center_angle + np.random.uniform(-np.pi / 4, np.pi / 4))
436
+ dist = float(np.random.uniform(5.5, 8.5))
437
+ gx = float(np.clip(torso_pos[0] + dist * np.cos(angle), -22.0, 22.0))
438
+ gy = float(np.clip(torso_pos[1] + dist * np.sin(angle), -22.0, 22.0))
439
+
440
+ gz = 1.25 # Torso/chest level
441
+ self.goal_pos = np.array([gx, gy, gz], dtype=np.float32)
442
+ self.min_dist_to_goal = float(np.linalg.norm(self.goal_pos - torso_pos))
443
+ self.prev_dist_to_goal = float(self.min_dist_to_goal)
444
+ self.steps_since_progress = 0
445
+ self.steps_not_approaching = 0
446
+ self.negative_vx_steps = 0
447
+
448
+ @property
449
+ def observation_dim(self) -> int:
450
+ """
451
+ Total observation vector length:
452
+ - Torso height z: 1
453
+ - Torso orientation (forward & up vectors): 6
454
+ - Torso linear velocity (local/global): 3
455
+ - Torso angular velocity: 3
456
+ - 21 joint angles: 21
457
+ - 21 joint velocities: 21
458
+ - 2 foot contact forces: 2
459
+ - 512 solid-state LiDAR range rays (16x32 grid): 512
460
+ - 3 ego-centric relative waypoint vector (Δx, Δy, Δz local): 3
461
+ - 3 metabolic stats (stamina, health, hunger): 3
462
+ Total: 1 + 6 + 3 + 3 + 21 + 21 + 2 + 512 + 3 + 3 = 575
463
+ """
464
+ return 575
465
+
466
+ @property
467
+ def action_dim(self) -> int:
468
+ return self.n_actuators # 21
469
+
470
+ def reset(self, seed: Optional[int] = None, spawn_x: float = 0.0, initial_vx: float = 0.0) -> np.ndarray:
471
+ if seed is not None:
472
+ np.random.seed(seed)
473
+
474
+ mujoco.mj_resetData(self.model, self.data)
475
+
476
+ # Initial root pose: at X=spawn_x, Y=0, Z=1.33 (athletic bent-knee stance)
477
+ self.data.qpos[0] = spawn_x # X
478
+ self.data.qpos[1] = 0.0 # Y
479
+ self.data.qpos[2] = 1.33 # Z
480
+ self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0]) # Quaternion (unit)
481
+
482
+ # Staggered lead leg initialization: alternates 50% left lead, 50% right lead
483
+ # This breaks single-leg asymmetric gait traps and forces both thighs to cycle
484
+ lead_leg = np.random.choice(["right", "left"])
485
+ if lead_leg == "right":
486
+ self.data.qpos[self.r_hip_qpos] = -0.35 # Right thigh poised forward
487
+ self.data.qpos[self.r_knee_qpos] = 0.45
488
+ self.data.qpos[self.r_ankle_qpos] = -0.15
489
+ self.data.qpos[self.l_hip_qpos] = 0.05 # Left leg supporting in stance
490
+ self.data.qpos[self.l_knee_qpos] = 0.25
491
+ self.data.qpos[self.l_ankle_qpos] = -0.05
492
+ else:
493
+ self.data.qpos[self.l_hip_qpos] = -0.35 # Left thigh poised forward
494
+ self.data.qpos[self.l_knee_qpos] = 0.45
495
+ self.data.qpos[self.l_ankle_qpos] = -0.15
496
+ self.data.qpos[self.r_hip_qpos] = 0.05 # Right leg supporting in stance
497
+ self.data.qpos[self.r_knee_qpos] = 0.25
498
+ self.data.qpos[self.r_ankle_qpos] = -0.05
499
+
500
+ # Add slight stochastic noise to joint angles for exploration diversity
501
+ joint_noise = np.random.uniform(-0.03, 0.03, size=self.n_actuators)
502
+ self.data.qpos[7:] += joint_noise
503
+
504
+ # Zero velocities with tiny noise (or inject forward running momentum if pioneer spawn)
505
+ self.data.qvel[:] = np.random.uniform(-0.02, 0.02, size=self.n_qvel)
506
+ if initial_vx > 0.0:
507
+ self.data.qvel[0] = initial_vx + np.random.uniform(-0.1, 0.1)
508
+
509
+ # Reset internal stats
510
+ self.stamina = 1.0
511
+ self.health = 1.0
512
+ self.hunger = 0.0
513
+
514
+ self.milestones_reached = set()
515
+ self.waypoints_reached = set()
516
+ self.current_wp_idx = 0
517
+
518
+ if self.stream_mode == "stand":
519
+ # Stream 1: Quiet Standing Balance (Canonical Reference Equilibrium Stance)
520
+ self.data.qpos[0] = 0.0
521
+ self.data.qpos[1] = 0.0
522
+ self.data.qpos[2] = 1.168
523
+ self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0])
524
+ self.data.qvel[:] = 0.0
525
+ self.data.qpos[7:7 + self.n_actuators] = STAND_REF_QPOS.copy()
526
+ # Add slight stochastic exploration noise (+/- 0.015 rad ~ 0.8 deg) so agent learns to balance
527
+ self.data.qpos[7:7 + self.n_actuators] += np.random.uniform(-0.015, 0.015, size=self.n_actuators)
528
+ # Settle foot ground contacts smoothly (10 passive sub-steps with zero actuation)
529
+ mujoco.mj_forward(self.model, self.data)
530
+ for _ in range(10):
531
+ self.data.ctrl[:] = 0.0
532
+ mujoco.mj_step(self.model, self.data)
533
+ self.data.qvel[:] = 0.0
534
+ mujoco.mj_forward(self.model, self.data)
535
+ self.goal_pos = np.array([0.0, 0.0, 1.168], dtype=np.float32)
536
+ self.stand_still_steps = 0
537
+ self.stand_drift_steps = 0
538
+ self.stand_one_leg_steps = 0
539
+ self.goals_completed = 0
540
+ elif self.stream_mode == "getup":
541
+ # Stream 2: Fall Recovery (Randomized 360° poses & standing destination goal)
542
+ self._apply_fall_state()
543
+ self.prev_z = float(self.data.xpos[self.torso_body_id][2])
544
+ self.prev_head_z = float(self.data.xpos[self.head_body_id][2])
545
+ self.consecutive_upright_steps = 0
546
+ self.getup_stagnant_steps = 0
547
+ self.max_elevation_achieved = self.prev_z
548
+ self.max_head_elevation = self.prev_head_z
549
+ self.getup_success_count = 0
550
+ self.goals_completed = 0
551
+ elif self.stream_mode == "reach":
552
+ # Stream 2: Bilateral Hand Reaching (Bolted Torso with neutral resting arm configuration)
553
+ self._lock_torso_for_reach()
554
+ # Initialize both arms down at sides (natural resting posture at hip)
555
+ self.data.qpos[self.arm_qpos_indices[0]] = 0.65 + float(np.random.uniform(-0.03, 0.03)) # right_shoulder_x (down at side)
556
+ self.data.qpos[self.arm_qpos_indices[1]] = float(np.random.uniform(-0.03, 0.03)) # right_shoulder_y
557
+ self.data.qpos[self.arm_qpos_indices[2]] = float(np.random.uniform(-0.03, 0.03)) # right_elbow
558
+ self.data.qpos[self.arm_qpos_indices[3]] = -0.65 + float(np.random.uniform(-0.03, 0.03)) # left_shoulder_x (down at side)
559
+ self.data.qpos[self.arm_qpos_indices[4]] = float(np.random.uniform(-0.03, 0.03)) # left_shoulder_y
560
+ self.data.qpos[self.arm_qpos_indices[5]] = float(np.random.uniform(-0.03, 0.03)) # left_elbow
561
+ self.data.qvel[self.arm_dof_indices] = 0.0
562
+ mujoco.mj_forward(self.model, self.data)
563
+ self._sample_reach_target()
564
+ is_right_target = (self.active_reach_hand == "right")
565
+ active_h = self.data.geom_xpos[self.r_hand_geom_id] if is_right_target else self.data.geom_xpos[self.l_hand_geom_id]
566
+ self.prev_reach_dist = float(np.linalg.norm(active_h - self.reach_target_pos))
567
+ self.min_reach_dist = self.prev_reach_dist
568
+ self.reach_touches = 0
569
+ self.steps_on_reach_target = 0
570
+ self.steps_receding_reach = 0
571
+ self.goals_completed = 0
572
+ elif self.arena_mode:
573
+ self.goals_completed = 0
574
+ self._sample_new_goal(is_initial=True)
575
+ else:
576
+ # Fast-forward waypoint index if spawning downfield
577
+ for idx, (wp_pos, _, _) in enumerate(COURSE_WAYPOINTS):
578
+ if wp_pos[0] > spawn_x + 0.3:
579
+ self.current_wp_idx = idx
580
+ break
581
+
582
+ self.spawn_x = float(spawn_x)
583
+ self.furthest_x = float(spawn_x)
584
+ self.current_step = 0
585
+ self.steps_since_progress = 0
586
+ self.last_progress_x = float(spawn_x)
587
+ self.negative_vx_steps = 0
588
+ self.steps_not_approaching = 0
589
+ self.r_foot_contact_steps = 0
590
+ self.l_foot_contact_steps = 0
591
+
592
+ mujoco.mj_forward(self.model, self.data)
593
+ return self._get_obs()
594
+
595
+ def _get_obs(self) -> np.ndarray:
596
+ # 1. Torso state
597
+ torso_xpos = self.data.xpos[self.torso_body_id] # (3,)
598
+ torso_xmat = self.data.xmat[self.torso_body_id].reshape(3, 3) # (3, 3)
599
+ forward_vec = torso_xmat[:, 0] # Local X axis in world frame
600
+ up_vec = torso_xmat[:, 2] # Local Z axis in world frame
601
+
602
+ torso_cvel = self.data.cvel[self.torso_body_id] # 6D: [ang_vel(3), lin_vel(3)]
603
+ ang_vel = torso_cvel[:3]
604
+ lin_vel = torso_cvel[3:]
605
+
606
+ # 2. Joint states (21 angles, 21 velocities)
607
+ joint_qpos = self.data.qpos[7:7 + self.n_actuators]
608
+ joint_qvel = self.data.qvel[6:6 + self.n_actuators]
609
+
610
+ # 3. Touch sensors & Task-Specific Contact Indicators
611
+ touch_forces = np.zeros(2, dtype=np.float32)
612
+ if self.stream_mode == "reach":
613
+ # For isolated Reach, legs are bolted stationary. Repurpose touch_forces as explicit Active Hand Indicator:
614
+ # [1.0, 0.0] -> Right Hand Active | [0.0, 1.0] -> Left Hand Active
615
+ if self.active_reach_hand == "right":
616
+ touch_forces[0] = 1.0
617
+ touch_forces[1] = 0.0
618
+ else:
619
+ touch_forces[0] = 0.0
620
+ touch_forces[1] = 1.0
621
+ else:
622
+ if self.model.nsensor >= 2:
623
+ touch_forces[0] = np.clip(self.data.sensordata[0] / 100.0, 0.0, 5.0) # Right foot
624
+ touch_forces[1] = np.clip(self.data.sensordata[1] / 100.0, 0.0, 5.0) # Left foot
625
+
626
+ # 4. True Solid-State Angular LiDAR (512 rays from visor/eyes)
627
+ lidar_obs = self._compute_lidar_vision()
628
+
629
+ # 5. Dynamic Goal / Progressive Waypoint (Ego-centric local relative vector)
630
+ if self.stream_mode == "stand":
631
+ rel_world = self.goal_pos - torso_xpos
632
+ rel_local = torso_xmat.T @ rel_world
633
+ waypoint_obs = np.array([
634
+ np.clip(rel_local[0] / 10.0, -1.0, 1.0),
635
+ np.clip(rel_local[1] / 5.0, -1.0, 1.0),
636
+ np.clip(rel_local[2] / 2.0, -1.0, 1.0),
637
+ ], dtype=np.float32)
638
+ elif self.stream_mode == "getup":
639
+ rel_world = self.getup_goal_pos - torso_xpos
640
+ rel_local = torso_xmat.T @ rel_world
641
+ waypoint_obs = np.array([
642
+ np.clip(rel_local[0] / 5.0, -1.0, 1.0),
643
+ np.clip(rel_local[1] / 5.0, -1.0, 1.0),
644
+ np.clip(rel_local[2] / 2.0, -1.0, 1.0),
645
+ ], dtype=np.float32)
646
+ elif self.stream_mode == "reach":
647
+ # Direct closed-loop motor servoing: vector from ACTIVE HAND directly to the reach target orb!
648
+ active_h = self.data.geom_xpos[self.r_hand_geom_id] if (self.active_reach_hand == "right") else self.data.geom_xpos[self.l_hand_geom_id]
649
+ rel_hand_world = self.reach_target_pos - active_h
650
+ rel_hand_local = torso_xmat.T @ rel_hand_world
651
+ waypoint_obs = np.array([
652
+ np.clip(rel_hand_local[0] / 0.6, -1.0, 1.0),
653
+ np.clip(rel_hand_local[1] / 0.6, -1.0, 1.0),
654
+ np.clip(rel_hand_local[2] / 0.6, -1.0, 1.0),
655
+ ], dtype=np.float32)
656
+ elif self.arena_mode:
657
+ target_pos = self.goal_pos
658
+ rel_world = target_pos - torso_xpos
659
+ rel_local = torso_xmat.T @ rel_world
660
+ waypoint_obs = np.array([
661
+ np.clip(rel_local[0] / 10.0, -1.0, 1.0),
662
+ np.clip(rel_local[1] / 5.0, -1.0, 1.0),
663
+ np.clip(rel_local[2] / 2.0, -1.0, 1.0),
664
+ ], dtype=np.float32)
665
+ else:
666
+ target_pos = COURSE_WAYPOINTS[self.current_wp_idx][0]
667
+ rel_world = target_pos - torso_xpos
668
+ rel_local = torso_xmat.T @ rel_world
669
+ waypoint_obs = np.array([
670
+ np.clip(rel_local[0] / 10.0, -1.0, 1.0),
671
+ np.clip(rel_local[1] / 5.0, -1.0, 1.0),
672
+ np.clip(rel_local[2] / 2.0, -1.0, 1.0),
673
+ ], dtype=np.float32)
674
+
675
+ # 6. Metabolic state (stamina, health, hunger/task-channel)
676
+ if self.stream_mode == "reach":
677
+ hand_indicator = 1.0 if (self.active_reach_hand == "right") else -1.0
678
+ metabolic_obs = np.array([self.stamina, self.health, hand_indicator], dtype=np.float32)
679
+ else:
680
+ metabolic_obs = np.array([self.stamina, self.health, self.hunger], dtype=np.float32)
681
+
682
+ obs = np.concatenate([
683
+ np.array([torso_xpos[2]], dtype=np.float32), # Torso Z (1)
684
+ forward_vec.astype(np.float32), # 3
685
+ up_vec.astype(np.float32), # 3
686
+ lin_vel.astype(np.float32), # 3
687
+ ang_vel.astype(np.float32), # 3
688
+ joint_qpos.astype(np.float32), # 21
689
+ joint_qvel.astype(np.float32), # 21
690
+ touch_forces, # 2
691
+ lidar_obs, # 512
692
+ waypoint_obs, # 3
693
+ metabolic_obs, # 3
694
+ ])
695
+
696
+ return np.nan_to_num(obs, nan=0.0, posinf=50.0, neginf=-50.0)
697
+
698
+ def _compute_lidar_vision(self) -> np.ndarray:
699
+ """
700
+ True Solid-State Angular LiDAR scan from the robot's visor/eyes.
701
+ Casts 128 rays (8 vertical elevation x 16 horizontal azimuth) using MuJoCo's native C raycaster.
702
+ Returns normalized range vector (128,) in [0.0, 1.0].
703
+ """
704
+ head_pos = self.data.xpos[self.head_body_id]
705
+ head_mat = self.data.xmat[self.head_body_id].reshape(3, 3)
706
+
707
+ # Ray origin: 0.15m forward from head center through visor
708
+ ray_origin = np.ascontiguousarray(head_pos + head_mat @ self._visor_offset, dtype=np.float64)
709
+
710
+ # Transform all 128 precomputed local vectors to world coordinates
711
+ # (128, 3) @ (3, 3).T -> (128, 3)
712
+ world_vectors = self.lidar_local_vectors @ head_mat.T
713
+
714
+ normalized_ranges = np.zeros(self.num_lidar_rays, dtype=np.float32)
715
+ raw_grid = np.zeros((self.num_lidar_v, self.num_lidar_h), dtype=np.float32)
716
+
717
+ ray_idx = 0
718
+ for row in range(self.num_lidar_v):
719
+ for col in range(self.num_lidar_h):
720
+ vec = np.ascontiguousarray(world_vectors[ray_idx], dtype=np.float64)
721
+ dist = mujoco.mj_ray(self.model, self.data, ray_origin, vec, None, 1, -1, self._geomid_buf)
722
+
723
+ if dist >= 0:
724
+ gid = int(self._geomid_buf[0])
725
+ if gid in self.humanoid_geoms:
726
+ actual_dist = self.lidar_max_range
727
+ else:
728
+ actual_dist = min(dist, self.lidar_max_range)
729
+ else:
730
+ actual_dist = self.lidar_max_range
731
+
732
+ raw_grid[row, col] = actual_dist
733
+ # Zero-centered range: -0.5 (impact / 0m) to +0.5 (clear / 10m), eliminates DC bias
734
+ normalized_ranges[ray_idx] = (actual_dist / self.lidar_max_range) - 0.5
735
+ ray_idx += 1
736
+
737
+ self.current_lidar_grid = raw_grid
738
+ return normalized_ranges
739
+
740
+ def _compute_pseudo_vision(self, agent_x: float, agent_y: float, agent_z: float) -> np.ndarray:
741
+ """Backwards compatibility fallback for 1D pseudo-vision."""
742
+ return self._compute_lidar_vision()[:16]
743
+
744
+ def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, Dict]:
745
+ self.current_step += 1
746
+
747
+ # Clip action to [-1.0, 1.0]
748
+ action = np.clip(action, -1.0, 1.0)
749
+ self.data.ctrl[:] = action
750
+ if self.stream_mode == "reach":
751
+ # For isolated arm reaching, freeze motor commands to core and legs
752
+ self.data.ctrl[0:15] = 0.0
753
+
754
+ # Substep physics
755
+ for _ in range(self.frame_skip):
756
+ mujoco.mj_step(self.model, self.data)
757
+ if self.stream_mode == "reach":
758
+ self._lock_torso_for_reach()
759
+
760
+ # Extract state measurements
761
+ torso_xpos = self.data.xpos[self.torso_body_id]
762
+ x, y, z = torso_xpos[0], torso_xpos[1], torso_xpos[2]
763
+
764
+ torso_cvel = self.data.cvel[self.torso_body_id]
765
+ vx = torso_cvel[3] # Forward linear velocity
766
+ vy = torso_cvel[4]
767
+
768
+ torso_xmat = self.data.xmat[self.torso_body_id].reshape(3, 3)
769
+ up_z = torso_xmat[2, 2] # Cosine of torso vertical tilt
770
+
771
+ # Update metabolic stats
772
+ torque_cost = np.sum(np.square(action))
773
+ self.stamina = np.clip(self.stamina - (torque_cost * 0.0003) + 0.0005, 0.0, 1.0)
774
+ self.hunger = np.clip(self.hunger + 0.0001, 0.0, 1.0)
775
+
776
+ # Multi-limb ground contact detection for floor righting and locomotion
777
+ contact_r = False
778
+ contact_l = False
779
+ contact_hand = False
780
+ contact_knee = False
781
+ for c_idx in range(self.data.ncon):
782
+ c = self.data.contact[c_idx]
783
+ g1, g2 = c.geom1, c.geom2
784
+ if (g1 == self.r_foot_geom_id and g2 in self.world_obstacle_geoms) or (g2 == self.r_foot_geom_id and g1 in self.world_obstacle_geoms):
785
+ contact_r = True
786
+ if (g1 == self.l_foot_geom_id and g2 in self.world_obstacle_geoms) or (g2 == self.l_foot_geom_id and g1 in self.world_obstacle_geoms):
787
+ contact_l = True
788
+ if (g1 in self.support_hand_geoms and g2 in self.world_obstacle_geoms) or (g2 in self.support_hand_geoms and g1 in self.world_obstacle_geoms):
789
+ contact_hand = True
790
+ if (g1 in self.support_knee_geoms and g2 in self.world_obstacle_geoms) or (g2 in self.support_knee_geoms and g1 in self.world_obstacle_geoms):
791
+ contact_knee = True
792
+
793
+ touch_r = contact_r or (bool(self.data.sensordata[0] > 5.0) if self.model.nsensor >= 1 else False)
794
+ touch_l = contact_l or (bool(self.data.sensordata[1] > 5.0) if self.model.nsensor >= 2 else False)
795
+ if touch_r:
796
+ self.r_foot_contact_steps += 1
797
+ if touch_l:
798
+ self.l_foot_contact_steps += 1
799
+
800
+ upright_scale = float(np.clip((z - 0.45) / 0.40, 0.0, 1.0)) # 0 at z<=0.45m, 1.0 at z>=0.85m
801
+ in_leeway = False
802
+ torso_speed = float(np.linalg.norm(torso_cvel[3:]))
803
+
804
+ # ── STREAM 1: QUIET STANDING STILLNESS (REFERENCE POSE SIMILARITY) ────────
805
+ if self.stream_mode == "stand":
806
+ head_z = float(self.data.xpos[self.head_body_id][2])
807
+ dist_from_origin = float(np.linalg.norm([x, y]))
808
+
809
+ # 1. Joint Configuration Similarity:
810
+ # Gaussian kernel comparing 21 actuated joints against canonical STAND_REF_QPOS
811
+ current_qpos = self.data.qpos[7:7 + self.n_actuators]
812
+ diff_q = current_qpos - STAND_REF_QPOS
813
+ weighted_q_mse = float(np.sum(STAND_JOINT_WEIGHTS * (diff_q ** 2)) / np.sum(STAND_JOINT_WEIGHTS))
814
+ r_pose = float(np.exp(-3.0 * weighted_q_mse))
815
+
816
+ # 2. Torso Center-of-Mass Height Similarity (centered at z* = 1.168m):
817
+ r_height = float(np.exp(-30.0 * ((z - 1.168) ** 2)))
818
+
819
+ # 3. Torso Upright Orientation Similarity:
820
+ r_upright = float(np.exp(-12.0 * (max(0.0, 1.0 - up_z) ** 2)))
821
+
822
+ # 4. Multi-Rigid-Body Stillness Similarity (root linear, root angular, joint vel):
823
+ lin_vel_sq = float(np.sum(torso_cvel[3:] ** 2))
824
+ ang_vel_sq = float(np.sum(torso_cvel[:3] ** 2))
825
+ joint_vel_sq = float(np.mean(self.data.qvel[6:6 + self.n_actuators] ** 2))
826
+ r_stillness = float(np.exp(-4.0 * (lin_vel_sq + 0.5 * ang_vel_sq + 0.1 * joint_vel_sq)))
827
+
828
+ # 5. Foot Ground Contact Multiplier:
829
+ both_feet_down = bool(touch_r and touch_l)
830
+ one_foot_down = bool(touch_r or touch_l)
831
+ if both_feet_down:
832
+ m_feet = 1.0
833
+ elif one_foot_down:
834
+ m_feet = 0.45
835
+ else:
836
+ m_feet = 0.05
837
+
838
+ # 6. Action Control / Effort Penalty:
839
+ r_ctrl = -0.002 * float(np.sum(action ** 2))
840
+
841
+ # Bounded Gaussian Similarity Reward (Peak = 14.0/step at quiet standing equilibrium)
842
+ total_reward = m_feet * (4.0 * r_pose + 3.0 * r_height + 3.0 * r_upright + 4.0 * r_stillness) + r_ctrl
843
+
844
+ # Track stillness success criteria:
845
+ is_stand_still = bool(
846
+ r_pose > 0.85 and
847
+ r_height > 0.88 and
848
+ r_upright > 0.90 and
849
+ r_stillness > 0.80 and
850
+ both_feet_down
851
+ )
852
+ if is_stand_still:
853
+ self.stand_still_steps += 1
854
+ else:
855
+ self.stand_still_steps = max(0, self.stand_still_steps - 1)
856
+
857
+ # Check collision with environmental obstacles/ground using body geoms (head, chest, torso, visor)
858
+ is_obstacle_or_ground_impact = False
859
+ for c_idx in range(self.data.ncon):
860
+ c = self.data.contact[c_idx]
861
+ if (c.geom1 in self.body_impact_geoms and c.geom2 in self.world_obstacle_geoms) or \
862
+ (c.geom2 in self.body_impact_geoms and c.geom1 in self.world_obstacle_geoms):
863
+ is_obstacle_or_ground_impact = True
864
+ break
865
+
866
+ terminated = False
867
+ # Clean early termination only when genuinely fallen or severely displaced:
868
+ if z < 0.85 or up_z < 0.55 or is_obstacle_or_ground_impact:
869
+ terminated = True
870
+ total_reward -= 15.0
871
+ elif dist_from_origin > 1.20:
872
+ terminated = True
873
+ total_reward -= 10.0
874
+
875
+ # Mastery time limit: 384 steps (~9.6s)
876
+ truncated = (self.current_step >= 384)
877
+ if truncated and (not terminated) and z > 1.10 and both_feet_down:
878
+ total_reward += 50.0 # Mastery bonus for surviving 384 steps upright on both feet
879
+
880
+ # Detailed metrics for logging & dashboard
881
+ torso_roll = abs(float(torso_xmat[2, 1]))
882
+ torso_pitch = abs(float(torso_xmat[2, 0]))
883
+ spine_lateral = abs(float(self.data.qpos[self.abdomen_x_qpos]))
884
+ spine_sagittal = abs(float(self.data.qpos[self.abdomen_y_qpos]))
885
+
886
+ info = {
887
+ "x_pos": float(x), "y_pos": float(y), "z_pos": float(z),
888
+ "vx": float(vx), "torso_speed": float(torso_speed),
889
+ "furthest_x": float(self.furthest_x), "milestones": 0,
890
+ "waypoint_idx": 0, "waypoint_name": "Quiet Stand Stillness",
891
+ "dist_to_wp": float(dist_from_origin), "waypoints_completed": int(self.stand_still_steps > 150),
892
+ "goals_completed": int(self.stand_still_steps > 150),
893
+ "dist_to_goal": float(dist_from_origin),
894
+ "goal_arrival_radius": 1.20,
895
+ "goal_pos": [0.0, 0.0, 1.168],
896
+ "target_local_pos": [0.0, 0.0, 0.0],
897
+ "gaze_alignment": 1.0,
898
+ "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger),
899
+ "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(),
900
+ "in_leeway": False, "leeway_remaining": 0,
901
+ "stream_mode": "stand", "active_reach_hand": "none", "reach_touches": 0,
902
+ "getup_success": False,
903
+ "both_feet_down": bool(both_feet_down),
904
+ "one_foot_down": bool(one_foot_down),
905
+ "stand_still_score": float(is_stand_still),
906
+ "torso_roll": float(torso_roll),
907
+ "torso_pitch": float(torso_pitch),
908
+ "spine_lateral": float(spine_lateral),
909
+ "spine_sagittal": float(spine_sagittal),
910
+ "r_pose": float(r_pose),
911
+ "r_height": float(r_height),
912
+ "r_upright": float(r_upright),
913
+ "r_stillness": float(r_stillness),
914
+ }
915
+ return self._get_obs(), float(total_reward), terminated, truncated, info
916
+
917
+ # ── STREAM 2: FALL RECOVERY (GET-UP) ────────────────────────────────
918
+ elif self.stream_mode == "getup":
919
+ head_z = float(self.data.xpos[self.head_body_id][2])
920
+ delta_head_z = head_z - self.prev_head_z
921
+ self.prev_head_z = head_z
922
+
923
+ r_foot_z = float(self.data.xpos[self.r_foot_id][2])
924
+ l_foot_z = float(self.data.xpos[self.l_foot_id][2])
925
+ max_foot_z = max(r_foot_z, l_foot_z)
926
+
927
+ # 1. REWARD FOR HEAD & TORSO ELEVATION (Rises vertically, head leads above torso):
928
+ reward_head_elevation = 16.0 * delta_head_z
929
+ reward_torso_elevation = 12.0 * max(0.0, z - self.prev_z)
930
+ reward_head_height = 5.0 * max(0.0, head_z - 0.40)
931
+ reward_head_above_torso = 3.5 * max(0.0, head_z - z)
932
+ reward_upright = 3.0 * max(0.0, up_z - 0.30)
933
+
934
+ # 2. MULTI-PHASE GROUND CONTACT SUPPORT (Hands, Knees, Feet):
935
+ both_feet_down = touch_r and touch_l
936
+ one_foot_down = touch_r or touch_l
937
+
938
+ # Floor bracing support (head_z < 1.15m): reward hands pushing floor & knees tucked
939
+ reward_hand_brace = 4.0 if (contact_hand and head_z < 1.15) else 0.0
940
+ reward_knee_brace = 3.0 if (contact_knee and head_z < 1.15) else 0.0
941
+
942
+ # Standing stage (head_z >= 1.05m): reward planting feet
943
+ if both_feet_down:
944
+ reward_feet_ground = 6.0
945
+ elif one_foot_down:
946
+ reward_feet_ground = 2.0
947
+ elif head_z >= 1.20:
948
+ reward_feet_ground = -1.0 # Only penalize missing foot contact once upright
949
+ else:
950
+ reward_feet_ground = 0.0 # Zero penalty while floor-bound rolling or pushing up!
951
+
952
+ # Floor proximity for feet during ground phase
953
+ reward_feet_down = 2.0 * max(0.0, 0.30 - max_foot_z) if head_z < 1.15 else 0.0
954
+
955
+ # 3. MITIGATE LEG AIR-FLAILING:
956
+ # Only penalize feet higher than head IF head is actually on the ground and feet are violently kicked up
957
+ feet_above_head = max(0.0, max_foot_z - max(0.40, head_z))
958
+ penalty_shooting_legs = -4.0 * feet_above_head
959
+
960
+ # Air-flail penalty only when feet are high in air with zero contact
961
+ penalty_air_flail = -2.0 * max(0.0, max_foot_z - 0.50) if (head_z < 1.0 and not (contact_hand or contact_knee)) else 0.0
962
+
963
+ # 4. Energy cost & Arm Windmilling Suppression
964
+ arm_flail_penalty = -0.001 * float(np.sum(np.square(action[15:21])))
965
+ reward_ctrl = -0.001 * torque_cost + arm_flail_penalty
966
+ dist_to_stand_goal = float(np.linalg.norm(self.getup_goal_pos[:2] - np.array([x, y])))
967
+ reward_goal_heading = 2.0 * max(0.0, 1.0 - dist_to_stand_goal / 2.0) if head_z > 1.30 else 0.0
968
+
969
+ total_reward = (
970
+ reward_head_elevation +
971
+ reward_torso_elevation +
972
+ reward_head_height +
973
+ reward_head_above_torso +
974
+ reward_upright +
975
+ reward_hand_brace +
976
+ reward_knee_brace +
977
+ reward_feet_ground +
978
+ reward_feet_down +
979
+ penalty_shooting_legs +
980
+ penalty_air_flail +
981
+ reward_ctrl +
982
+ reward_goal_heading
983
+ )
984
+ self.prev_z = z
985
+
986
+ # Track peak head elevation for stagnation monitoring
987
+ if head_z > self.max_head_elevation:
988
+ self.max_head_elevation = head_z
989
+ self.getup_stagnant_steps = 0
990
+ else:
991
+ self.getup_stagnant_steps += 1
992
+
993
+ # Upright standing with feet planted verification:
994
+ # Requires head up (> 1.35m), torso upright (z > 1.05m, up_z > 0.70), and foot contact
995
+ is_upright = (head_z > 1.35) and (z > 1.05) and (up_z > 0.70) and (one_foot_down or (head_z > 1.45 and z > 1.10))
996
+ if is_upright:
997
+ self.consecutive_upright_steps += 1
998
+ total_reward += 3.0
999
+ if both_feet_down:
1000
+ total_reward += 3.0 # Extra jackpot bonus when BOTH feet are planted!
1001
+ else:
1002
+ self.consecutive_upright_steps = 0
1003
+
1004
+ # Lenient Ground Collapse Stagnation & Rise Stall Deaths:
1005
+ # Tolerates 140 steps (~3.5s) struggling on ground without head elevation
1006
+ # Tolerates 180 steps (~4.5s) coordinating knee-tuck and feet planting to reach upright stance
1007
+ is_ground_stuck = (head_z < 0.50 and self.getup_stagnant_steps > 140)
1008
+ is_stall_stuck = (head_z >= 0.50 and self.consecutive_upright_steps == 0 and self.getup_stagnant_steps > 180)
1009
+
1010
+ terminated = False
1011
+ if self.consecutive_upright_steps >= 12:
1012
+ total_reward += 100.0 # Upright Recovery Jackpot!
1013
+ self.getup_success_count += 1
1014
+ terminated = True
1015
+ elif is_ground_stuck or is_stall_stuck:
1016
+ terminated = True
1017
+ total_reward -= 10.0 # Timeout stagnation death
1018
+
1019
+ truncated = (self.current_step >= 280)
1020
+ target_pos = self.getup_goal_pos
1021
+
1022
+ info = {
1023
+ "x_pos": float(x), "y_pos": float(y), "z_pos": float(z),
1024
+ "vx": float(vx), "torso_speed": float(torso_speed),
1025
+ "furthest_x": float(self.furthest_x), "milestones": 0,
1026
+ "waypoint_idx": 0, "waypoint_name": f"Get-Up ({self.fall_state_type})",
1027
+ "dist_to_wp": float(max(0.0, 1.55 - head_z)),
1028
+ "waypoints_completed": int(self.consecutive_upright_steps >= 12),
1029
+ "goals_completed": int(self.consecutive_upright_steps >= 12),
1030
+ "dist_to_goal": float(dist_to_stand_goal),
1031
+ "goal_arrival_radius": 0.50,
1032
+ "goal_pos": self.getup_goal_pos.tolist(),
1033
+ "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(),
1034
+ "gaze_alignment": float(max(0.0, up_z)),
1035
+ "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger),
1036
+ "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(),
1037
+ "in_leeway": False, "leeway_remaining": 0,
1038
+ "stream_mode": "getup", "active_reach_hand": "none", "reach_touches": 0,
1039
+ "getup_success": bool(self.consecutive_upright_steps >= 12),
1040
+ "head_z": float(head_z),
1041
+ "both_feet_down": bool(both_feet_down),
1042
+ }
1043
+ return self._get_obs(), float(total_reward), terminated, truncated, info
1044
+
1045
+ # ── STREAM 2: BILATERAL HAND REACHING (BOLTED BODY) ────────────────
1046
+ elif self.stream_mode == "reach":
1047
+ # Torso, core, and legs are bolted in neutral athletic upright stance.
1048
+ # Control is isolated strictly to the 6 arm actuators reaching for 3D orbs.
1049
+ r_h = self.data.geom_xpos[self.r_hand_geom_id]
1050
+ l_h = self.data.geom_xpos[self.l_hand_geom_id]
1051
+ dist_r = float(np.linalg.norm(r_h - self.reach_target_pos))
1052
+ dist_l = float(np.linalg.norm(l_h - self.reach_target_pos))
1053
+
1054
+ # The designated active hand is the hand closer to the target ball
1055
+ is_right_target = (self.active_reach_hand == "right")
1056
+
1057
+ if is_right_target:
1058
+ active_h = r_h
1059
+ wrong_h = l_h
1060
+ dist_to_target = dist_r
1061
+ dist_inactive = dist_l
1062
+ active_body_id = self.r_arm_body_id
1063
+ wrong_body_id = self.l_arm_body_id
1064
+ active_act = action[15:18]
1065
+ wrong_act = action[18:21]
1066
+ active_qvel = self.data.qvel[self.arm_dof_indices[0:3]]
1067
+ wrong_qvel = self.data.qvel[self.arm_dof_indices[3:6]]
1068
+ wrong_qpos = self.data.qpos[self.arm_qpos_indices[3:6]]
1069
+ wrong_rest_qpos = np.array([-0.65, 0.0, 0.0], dtype=np.float32)
1070
+ else:
1071
+ active_h = l_h
1072
+ wrong_h = r_h
1073
+ dist_to_target = dist_l
1074
+ dist_inactive = dist_r
1075
+ active_body_id = self.l_arm_body_id
1076
+ wrong_body_id = self.r_arm_body_id
1077
+ active_act = action[18:21]
1078
+ wrong_act = action[15:18]
1079
+ active_qvel = self.data.qvel[self.arm_dof_indices[3:6]]
1080
+ wrong_qvel = self.data.qvel[self.arm_dof_indices[0:3]]
1081
+ wrong_qpos = self.data.qpos[self.arm_qpos_indices[0:3]]
1082
+ wrong_rest_qpos = np.array([0.65, 0.0, 0.0], dtype=np.float32)
1083
+
1084
+ # 1. Main reach distance progress and proximity reward
1085
+ dist_prog = self.prev_reach_dist - dist_to_target
1086
+ reward_reach_prog = 25.0 * dist_prog
1087
+ reward_proximity = 6.0 * np.exp(-4.0 * dist_to_target)
1088
+
1089
+ # 2. MASSIVE SPEED EFFICIENCY REWARDS:
1090
+ # a) Direct closing velocity vector along the unit ray connecting active hand to target orb
1091
+ delta_active_target = self.reach_target_pos - active_h
1092
+ dist_hand_target = max(1e-4, float(np.linalg.norm(delta_active_target)))
1093
+ u_reach_target = delta_active_target / dist_hand_target
1094
+ active_hand_vel = self.data.cvel[active_body_id][3:6]
1095
+ v_closing = float(np.dot(active_hand_vel, u_reach_target))
1096
+ # Speed efficiency bonus for thrusting active hand toward target
1097
+ reward_closing_speed = 4.0 * max(0.0, v_closing)
1098
+
1099
+ # b) Mild latency penalty (encourages prompt reaching)
1100
+ penalty_latency = -0.02 * (float(self.steps_on_reach_target) / 40.0)
1101
+
1102
+ # 3. Slight reward for moving the correct hand
1103
+ active_hand_speed = float(np.linalg.norm(active_hand_vel))
1104
+ active_qvel_mag = float(np.sum(np.abs(active_qvel)))
1105
+ reward_correct_motion = 0.15 * min(1.0, active_hand_speed / 0.25) + 0.10 * np.tanh(active_qvel_mag)
1106
+
1107
+ # 4. REGULARIZATION ON INACTIVE HAND (stay neutral and relaxed at side)
1108
+ wrong_hand_speed = float(np.linalg.norm(self.data.cvel[wrong_body_id][3:6]))
1109
+ penalty_wrong_act = -0.05 * float(np.sum(np.square(wrong_act)))
1110
+ penalty_wrong_qvel = -0.05 * float(np.sum(np.abs(wrong_qvel)))
1111
+ penalty_wrong_speed = -0.20 * min(1.0, wrong_hand_speed)
1112
+ penalty_wrong_raised = -0.50 * max(0.0, float(wrong_h[2]) - 1.28)
1113
+ penalty_wrong_posture = -0.20 * min(1.0, float(np.linalg.norm(wrong_qpos - wrong_rest_qpos)))
1114
+ penalty_wrong_lead = -0.50 * max(0.0, dist_to_target - dist_inactive)
1115
+ penalty_wrong_encroach = -0.50 * max(0.0, 0.35 - dist_inactive)
1116
+ penalty_wrong_touch = -2.0 if dist_inactive < 0.14 else 0.0
1117
+
1118
+ # General control penalty on active arm to encourage efficient articulation
1119
+ reward_ctrl = -0.001 * float(np.sum(np.square(active_act)))
1120
+
1121
+ self.steps_on_reach_target += 1
1122
+ if dist_to_target < self.min_reach_dist:
1123
+ self.min_reach_dist = dist_to_target
1124
+ self.steps_receding_reach = 0
1125
+ elif dist_to_target > self.min_reach_dist + 0.25:
1126
+ self.steps_receding_reach += 1
1127
+
1128
+ reward_touch = 0.0
1129
+ speed_efficiency_jackpot = 0.0
1130
+ if dist_to_target < 0.14:
1131
+ # 1. Base touch award
1132
+ base_touch = 40.0
1133
+ # 2. Speed efficiency jackpot: maximum when acquired in few steps
1134
+ speed_efficiency_jackpot = max(0.0, 110.0 - float(self.steps_on_reach_target)) * 0.40
1135
+ # 3. Consecutive hit streak multiplier
1136
+ touch_streak_bonus = min(30.0, float(self.reach_touches) * 6.0)
1137
+ reward_touch = base_touch + speed_efficiency_jackpot + touch_streak_bonus
1138
+
1139
+ self.reach_touches += 1
1140
+ self._sample_reach_target()
1141
+ is_right_target = (self.active_reach_hand == "right")
1142
+ active_h = self.data.geom_xpos[self.r_hand_geom_id] if is_right_target else self.data.geom_xpos[self.l_hand_geom_id]
1143
+ dist_to_target = float(np.linalg.norm(active_h - self.reach_target_pos))
1144
+ # Reset timers for next target!
1145
+ self.steps_on_reach_target = 0
1146
+ self.steps_receding_reach = 0
1147
+ self.min_reach_dist = dist_to_target
1148
+
1149
+ self.prev_reach_dist = dist_to_target
1150
+ total_reward = (
1151
+ reward_reach_prog +
1152
+ reward_proximity +
1153
+ reward_closing_speed +
1154
+ reward_correct_motion +
1155
+ reward_touch +
1156
+ penalty_latency +
1157
+ penalty_wrong_act +
1158
+ penalty_wrong_qvel +
1159
+ penalty_wrong_speed +
1160
+ penalty_wrong_raised +
1161
+ penalty_wrong_posture +
1162
+ penalty_wrong_lead +
1163
+ penalty_wrong_encroach +
1164
+ penalty_wrong_touch +
1165
+ reward_ctrl
1166
+ )
1167
+
1168
+ # Lenient Reaching Time Limits:
1169
+ # Tolerates 110 steps (~2.75s) per target orb, or 45 steps receding away
1170
+ is_reach_timeout = (self.steps_on_reach_target > 110)
1171
+ is_reach_receding = (self.steps_receding_reach > 45)
1172
+
1173
+ terminated = False
1174
+ if is_reach_timeout or is_reach_receding:
1175
+ terminated = True
1176
+ total_reward -= 2.0 # Mild timeout penalty
1177
+
1178
+ truncated = (self.current_step >= 384)
1179
+ target_pos = self.reach_target_pos
1180
+
1181
+ head_xmat = self.data.xmat[self.head_body_id].reshape(3, 3)
1182
+ head_fwd = head_xmat[:, 0]
1183
+ delta_head_target = target_pos - self.data.xpos[self.head_body_id]
1184
+ cos_gaze = float(np.dot(head_fwd, delta_head_target / max(1e-3, np.linalg.norm(delta_head_target))))
1185
+
1186
+ info = {
1187
+ "x_pos": float(x), "y_pos": float(y), "z_pos": float(z),
1188
+ "vx": float(vx), "torso_speed": float(torso_speed),
1189
+ "furthest_x": float(self.furthest_x), "milestones": 0,
1190
+ "waypoint_idx": 0, "waypoint_name": f"Reach ({self.active_reach_hand.upper()})",
1191
+ "dist_to_wp": float(dist_to_target), "waypoints_completed": int(self.reach_touches),
1192
+ "goals_completed": int(self.reach_touches),
1193
+ "dist_to_goal": float(dist_to_target),
1194
+ "goal_arrival_radius": 0.14,
1195
+ "goal_pos": self.reach_target_pos.tolist(),
1196
+ "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(),
1197
+ "gaze_alignment": float(cos_gaze),
1198
+ "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger),
1199
+ "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(),
1200
+ "in_leeway": False, "leeway_remaining": 0,
1201
+ "stream_mode": "reach", "active_reach_hand": str(self.active_reach_hand),
1202
+ "reach_touches": int(self.reach_touches), "getup_success": False, "stand_still_score": 0.0,
1203
+ "wrong_hand_dist": float(dist_inactive), "wrong_hand_touch": bool(dist_inactive < 0.14),
1204
+ "wrong_hand_speed": float(wrong_hand_speed),
1205
+ "wrong_arm_action_penalty": float(penalty_wrong_act),
1206
+ "wrong_arm_raised_penalty": float(penalty_wrong_raised),
1207
+ "v_closing": float(v_closing),
1208
+ "reward_closing_speed": float(reward_closing_speed),
1209
+ "speed_efficiency_jackpot": float(speed_efficiency_jackpot),
1210
+ }
1211
+ return self._get_obs(), float(total_reward), terminated, truncated, info
1212
+ # Target Navigation Vector & Forward Propulsion
1213
+ if self.arena_mode:
1214
+ target_pos = self.goal_pos
1215
+ delta_target = target_pos - torso_xpos
1216
+ dist_to_target = float(np.linalg.norm(delta_target))
1217
+ dist_xy = float(np.linalg.norm(delta_target[:2]))
1218
+
1219
+ # Check dynamic goal arrival (entering wide arrival radius)
1220
+ reward_arrival = 0.0
1221
+ if dist_xy < self.goal_arrival_radius:
1222
+ self.goals_completed += 1
1223
+ # Escalating jackpot for chained consecutive goals: 50 for 1st, 85 for 2nd, 120 for 3rd, 155 for 4th...
1224
+ reward_arrival = 50.0 + 35.0 * (self.goals_completed - 1)
1225
+ self._sample_new_goal(is_initial=False)
1226
+ target_pos = self.goal_pos
1227
+ delta_target = target_pos - torso_xpos
1228
+ dist_to_target = float(np.linalg.norm(delta_target))
1229
+ dist_xy = float(np.linalg.norm(delta_target[:2]))
1230
+ self.prev_dist_to_goal = dist_to_target
1231
+ dist_delta = 0.0
1232
+ else:
1233
+ dist_delta = self.prev_dist_to_goal - dist_to_target
1234
+ self.prev_dist_to_goal = dist_to_target
1235
+
1236
+ # Leeway period active after goal completion to allow decelerating & turning
1237
+ in_leeway = (self.goal_transition_steps > 0)
1238
+ if in_leeway:
1239
+ self.goal_transition_steps -= 1
1240
+ # While turning during leeway, continuously update closest distance baseline to current position
1241
+ self.min_dist_to_goal = dist_to_target
1242
+
1243
+ # 1. Distance progress potential (scaled with goal streak) & sharp penalty for moving away
1244
+ streak_mult = 1.0 + 0.35 * min(4, self.goals_completed)
1245
+ if dist_delta >= 0.0:
1246
+ reward_dist_progress = 3.0 * streak_mult * dist_delta
1247
+ else:
1248
+ # Double penalty for moving further away from target outside of turn leeway
1249
+ reward_dist_progress = 0.0 if in_leeway else (6.0 * dist_delta)
1250
+
1251
+ # 2. Penalty for drifting/retreating beyond the closest distance achieved so far (muted in leeway)
1252
+ retreat_distance = max(0.0, dist_to_target - self.min_dist_to_goal)
1253
+ penalty_retreat = 0.0 if in_leeway else (-3.0 * retreat_distance if retreat_distance > 0.10 else 0.0)
1254
+
1255
+ unit_target_dir = delta_target / max(0.1, dist_to_target)
1256
+ vel_towards_target = float(np.dot(torso_cvel[3:], unit_target_dir))
1257
+ torso_speed = float(np.linalg.norm(torso_cvel[3:]))
1258
+
1259
+ # 3. Controlled Forward Movement vs Severe Retrograde Penalty
1260
+ # Cap forward reward at 1.8 m/s so Olympic sprinting gives no extra reward
1261
+ v_eff = min(max(0.0, vel_towards_target), 1.8)
1262
+ reward_forward = 3.0 * v_eff * upright_scale
1263
+ penalty_retrograde = 0.0 if (vel_towards_target >= 0.0 or in_leeway) else (5.0 * vel_towards_target)
1264
+
1265
+ # 4. Heading guidance velocity (steep negative slope when heading backwards outside leeway)
1266
+ if vel_towards_target >= 0.0:
1267
+ reward_heading = 1.5 * v_eff * upright_scale
1268
+ else:
1269
+ reward_heading = 0.0 if in_leeway else (3.0 * vel_towards_target)
1270
+
1271
+ # 5. Speed Regulation Penalty: Penalize reckless overspeeding (> 2.0 m/s) to promote stable locomotion
1272
+ penalty_overspeed = 0.0
1273
+ if torso_speed > 2.0:
1274
+ penalty_overspeed = -3.0 * ((torso_speed - 2.0) ** 2)
1275
+
1276
+ # 6. Turn alignment & braking reward during transition leeway: encourage slowing down & rapid yaw alignment to new goal
1277
+ reward_turn = 0.0
1278
+ reward_brake = 0.0
1279
+ if in_leeway:
1280
+ torso_fwd = torso_xmat[:, 0]
1281
+ cos_torso_goal = float(np.dot(torso_fwd[:2], unit_target_dir[:2]))
1282
+ # Reward rotating body to face new target (+0.0 to +1.0)
1283
+ reward_turn = 1.0 * max(0.0, cos_torso_goal)
1284
+ # Reward decelerating below 1.6 m/s so the agent can pivot cleanly
1285
+ if torso_speed < 1.6:
1286
+ reward_brake = 0.6 * (1.6 - torso_speed)
1287
+
1288
+ # 7. Subsequent Goal Urgency: Time-decay pressure once Goal 1 is cleared
1289
+ penalty_urgency = 0.0
1290
+ if self.goals_completed >= 1 and not in_leeway:
1291
+ penalty_urgency = -0.08 * min(3, self.goals_completed)
1292
+
1293
+ reward_wp = (
1294
+ reward_arrival +
1295
+ reward_dist_progress +
1296
+ penalty_retreat +
1297
+ penalty_retrograde +
1298
+ penalty_overspeed +
1299
+ reward_turn +
1300
+ reward_brake +
1301
+ penalty_urgency
1302
+ )
1303
+ reward_drift = 0.0
1304
+ reward_milestone = 0.0
1305
+ dist_to_wp = dist_to_target
1306
+ else:
1307
+ # Obstacle course mode
1308
+ wp_pos, wp_name, wp_radius = COURSE_WAYPOINTS[self.current_wp_idx]
1309
+ target_pos = wp_pos
1310
+ delta_to_wp = wp_pos - torso_xpos
1311
+ dist_to_wp = float(np.linalg.norm(delta_to_wp))
1312
+ dist_xy = float(np.linalg.norm(delta_to_wp[:2]))
1313
+
1314
+ reward_forward = 3.5 * vx * upright_scale
1315
+ if vx < -0.05:
1316
+ reward_forward += 1.5 * vx
1317
+
1318
+ wp_reached = False
1319
+ if dist_xy < wp_radius:
1320
+ wp_reached = True
1321
+ elif torso_xpos[0] >= wp_pos[0] and abs(torso_xpos[1] - wp_pos[1]) < (wp_radius + 0.3):
1322
+ wp_reached = True
1323
+
1324
+ reward_wp = 0.0
1325
+ if wp_reached and self.current_wp_idx not in self.waypoints_reached:
1326
+ self.waypoints_reached.add(self.current_wp_idx)
1327
+ if self.current_wp_idx == len(COURSE_WAYPOINTS) - 1:
1328
+ reward_wp = 100.0
1329
+ elif 3 <= self.current_wp_idx <= 14:
1330
+ reward_wp = 25.0
1331
+ else:
1332
+ reward_wp = 15.0
1333
+ if self.current_wp_idx < len(COURSE_WAYPOINTS) - 1:
1334
+ self.current_wp_idx += 1
1335
+ wp_pos, wp_name, wp_radius = COURSE_WAYPOINTS[self.current_wp_idx]
1336
+ target_pos = wp_pos
1337
+ delta_to_wp = wp_pos - torso_xpos
1338
+ dist_to_wp = float(np.linalg.norm(delta_to_wp))
1339
+
1340
+ wp_unit_dir = delta_to_wp / max(0.1, dist_to_wp)
1341
+ vel_towards_wp = float(np.dot(torso_cvel[3:], wp_unit_dir))
1342
+ reward_heading = 2.0 * max(-0.5, min(vel_towards_wp, 3.5)) * upright_scale
1343
+
1344
+ target_y = float(wp_pos[1])
1345
+ reward_drift = -0.5 * abs(y - target_y)
1346
+
1347
+ reward_milestone = 0.0
1348
+ for threshold, name in STAGE_MILESTONES:
1349
+ if x >= threshold and threshold not in self.milestones_reached:
1350
+ self.milestones_reached.add(threshold)
1351
+ reward_milestone += 50.0
1352
+
1353
+ # Head Gaze Alignment Reward: mild reward for facing objective with head/visor
1354
+ head_xmat = self.data.xmat[self.head_body_id].reshape(3, 3)
1355
+ head_fwd = head_xmat[:, 0] # Local X axis of head (forward gaze direction)
1356
+ head_pos = self.data.xpos[self.head_body_id]
1357
+ delta_head_target = target_pos - head_pos
1358
+ dist_head_target = float(np.linalg.norm(delta_head_target))
1359
+ u_head_target = delta_head_target / max(1e-3, dist_head_target)
1360
+ cos_gaze = float(np.dot(head_fwd, u_head_target))
1361
+ reward_gaze = 0.15 * max(0.0, cos_gaze)
1362
+
1363
+ # 2. Upright posture reward (staying balanced above 0.70m)
1364
+ reward_upright = 1.0 if z > 0.70 else 0.0
1365
+
1366
+ # 3. Upright orientation bonus & active lateral roll stabilization
1367
+ roll_y = torso_xmat[1, 2]
1368
+ reward_roll = -2.0 * abs(roll_y)
1369
+ reward_orient = 0.8 * max(0.0, up_z) + reward_roll
1370
+
1371
+ # Advance speed for biomechanical stepping gating
1372
+ forward_vec = torso_xmat[:, 0]
1373
+ v_advance = float(np.dot(torso_cvel[3:6], forward_vec)) if self.arena_mode else vx
1374
+
1375
+ # 4. Alternating Bipedal Stepping Cadence
1376
+ reward_gait = 0.0
1377
+ if z > 0.55 and v_advance > 0.10:
1378
+ if touch_r ^ touch_l:
1379
+ reward_gait = 2.0 * min(v_advance, 1.5)
1380
+ elif touch_r and touch_l:
1381
+ reward_gait = -0.4 * min(v_advance, 1.5)
1382
+
1383
+ # 5. Knee Articulation & Stance Stability
1384
+ r_knee_q = self.data.qpos[self.r_knee_qpos]
1385
+ l_knee_q = self.data.qpos[self.l_knee_qpos]
1386
+ reward_knee = 0.0
1387
+ if z > 0.55 and v_advance > 0.10:
1388
+ if not touch_r:
1389
+ if 0.25 <= r_knee_q <= 1.1:
1390
+ reward_knee += 0.3
1391
+ elif r_knee_q > 1.6:
1392
+ reward_knee -= 0.5 * (r_knee_q - 1.6)
1393
+ if not touch_l:
1394
+ if 0.25 <= l_knee_q <= 1.1:
1395
+ reward_knee += 0.3
1396
+ elif l_knee_q > 1.6:
1397
+ reward_knee -= 0.5 * (l_knee_q - 1.6)
1398
+ if touch_r and r_knee_q < 0.50:
1399
+ reward_knee += 0.2
1400
+ if touch_l and l_knee_q < 0.50:
1401
+ reward_knee += 0.2
1402
+
1403
+ # 6. Swing foot forward velocity bonus
1404
+ r_foot_vfwd = float(np.dot(self.data.cvel[self.r_foot_id][3:6], forward_vec)) if self.arena_mode else self.data.cvel[self.r_foot_id][3]
1405
+ l_foot_vfwd = float(np.dot(self.data.cvel[self.l_foot_id][3:6], forward_vec)) if self.arena_mode else self.data.cvel[self.l_foot_id][3]
1406
+ reward_swing = 0.0
1407
+ if z > 0.55 and v_advance > 0.10:
1408
+ if not touch_r and r_foot_vfwd > 0.15:
1409
+ reward_swing += 0.3 * min(r_foot_vfwd, 2.0)
1410
+ if not touch_l and l_foot_vfwd > 0.15:
1411
+ reward_swing += 0.3 * min(l_foot_vfwd, 2.0)
1412
+
1413
+ # 7. Thigh Forward Stride Separation
1414
+ r_hip_q = self.data.qpos[self.r_hip_qpos]
1415
+ l_hip_q = self.data.qpos[self.l_hip_qpos]
1416
+ reward_stride = 0.0
1417
+ if z > 0.55 and v_advance > 0.10:
1418
+ if not touch_r:
1419
+ stride_diff = l_hip_q - r_hip_q
1420
+ if 0.1 < stride_diff < 1.3:
1421
+ reward_stride = 1.0 * stride_diff
1422
+ elif not touch_l:
1423
+ stride_diff = r_hip_q - l_hip_q
1424
+ if 0.1 < stride_diff < 1.3:
1425
+ reward_stride = 1.0 * stride_diff
1426
+
1427
+ # 8. Bilateral Stance Symmetry (Duty-Cycle Equalizer)
1428
+ reward_symmetry = 0.0
1429
+ total_contacts = self.r_foot_contact_steps + self.l_foot_contact_steps
1430
+ if self.current_step > 10 and total_contacts > 6 and z > 0.55:
1431
+ asym = abs(self.r_foot_contact_steps - self.l_foot_contact_steps) / total_contacts
1432
+ if asym > 0.20:
1433
+ reward_symmetry = -2.0 * (asym - 0.20)
1434
+
1435
+ # 9. Arm Windmilling Suppression (Penalize flailing arms to force balance from core and legs)
1436
+ arm_flail_penalty = -0.008 * float(np.sum(np.square(action[15:21])))
1437
+
1438
+ # 10. Energy expenditure penalty
1439
+ reward_ctrl = -0.0015 * torque_cost + arm_flail_penalty
1440
+
1441
+ total_reward = (
1442
+ reward_forward +
1443
+ reward_heading +
1444
+ reward_wp +
1445
+ reward_gaze +
1446
+ reward_upright +
1447
+ reward_orient +
1448
+ reward_gait +
1449
+ reward_knee +
1450
+ reward_swing +
1451
+ reward_stride +
1452
+ reward_symmetry +
1453
+ reward_ctrl +
1454
+ reward_drift +
1455
+ reward_milestone
1456
+ )
1457
+
1458
+ if self.arena_mode:
1459
+ rad_dist = float(np.linalg.norm([x, y]))
1460
+ if rad_dist > self.furthest_x:
1461
+ self.furthest_x = rad_dist
1462
+
1463
+ if in_leeway:
1464
+ # During turning leeway window: reset & do not accumulate moving-away or stagnation counters
1465
+ self.steps_not_approaching = 0
1466
+ self.negative_vx_steps = 0
1467
+ self.steps_since_progress = 0
1468
+ self.min_dist_to_goal = dist_to_target
1469
+ else:
1470
+ # Active approach check: requires advancing toward target (> 0.15 m/s and closing distance)
1471
+ is_actively_approaching = (vel_towards_target >= 0.15) and (dist_delta > 0.001)
1472
+ if is_actively_approaching:
1473
+ self.steps_not_approaching = 0
1474
+ else:
1475
+ self.steps_not_approaching += 1
1476
+
1477
+ # Receding tracker: moving away from target
1478
+ is_receding = (vel_towards_target < -0.05) or (dist_delta < -0.003)
1479
+ if is_receding:
1480
+ self.negative_vx_steps += 1
1481
+ else:
1482
+ self.negative_vx_steps = max(0, self.negative_vx_steps - 1)
1483
+
1484
+ # Record best approach distance
1485
+ if dist_to_target < self.min_dist_to_goal:
1486
+ self.min_dist_to_goal = dist_to_target
1487
+ self.steps_since_progress = 0
1488
+ else:
1489
+ self.steps_since_progress += 1
1490
+ else:
1491
+ if x > self.furthest_x:
1492
+ self.furthest_x = x
1493
+
1494
+ if x > self.last_progress_x + 0.06:
1495
+ self.last_progress_x = x
1496
+ self.steps_since_progress = 0
1497
+ else:
1498
+ self.steps_since_progress += 1
1499
+
1500
+ if vx < -0.15:
1501
+ self.negative_vx_steps += 1
1502
+ else:
1503
+ self.negative_vx_steps = 0
1504
+
1505
+ # ── TERMINATION & TRUNCATION ──────────────────────────────────────────
1506
+ # Exact preserved death conditions:
1507
+ # 1. Physical collision: head, visor, chest, or upper torso directly impacts the floor/obstacles
1508
+ # 2. True collapse: torso center of mass drops below 0.35m or head drops below 0.45m
1509
+ # 3. Complete inversion: torso tilted > 81 deg from vertical (up_z < 0.15)
1510
+ # 4. Arena boundary departure (|x| > 24m or |y| > 24m) or course lateral departure (|y| > 3.0m)
1511
+ # 5. Fast elimination when moving away or not actively approaching target (outside leeway)
1512
+ terminated = False
1513
+
1514
+ is_obstacle_or_ground_impact = False
1515
+ for c_idx in range(self.data.ncon):
1516
+ c = self.data.contact[c_idx]
1517
+ g1, g2 = c.geom1, c.geom2
1518
+ if (g1 in self.body_impact_geoms and g2 in self.world_obstacle_geoms) or \
1519
+ (g2 in self.body_impact_geoms and g1 in self.world_obstacle_geoms):
1520
+ is_obstacle_or_ground_impact = True
1521
+ break
1522
+
1523
+ head_z = self.data.xpos[self.head_body_id][2]
1524
+ is_altitude_collapse = (z < 0.35) or (head_z < 0.45)
1525
+ is_tilt_collapse = (up_z < 0.15)
1526
+ is_physical_fall = is_obstacle_or_ground_impact or is_altitude_collapse or is_tilt_collapse
1527
+
1528
+ if self.arena_mode:
1529
+ is_boundary_departure = (abs(x) > 24.0 or abs(y) > 24.0)
1530
+
1531
+ # FAST KILL TRIGGERS (active outside turning leeway period):
1532
+ # 1. Backing away kill: moved away from target for > 25 steps (~0.6s)
1533
+ is_moving_away_kill = (not in_leeway) and (self.negative_vx_steps > 25)
1534
+
1535
+ # 2. Retreat distance kill: backed up > 0.40m further than closest approach achieved
1536
+ is_retreat_kill = (not in_leeway) and (dist_to_target > self.min_dist_to_goal + 0.40) and (self.current_step > 15)
1537
+
1538
+ # 3. Not actively approaching kill: dawdling, circling, or stationary for > 80 steps (~2.0s)
1539
+ is_not_approaching_kill = (not in_leeway) and (self.current_step > 25 and self.steps_not_approaching > 80)
1540
+
1541
+ # 4. Stagnation kill: no new record distance achieved for > 85 steps (~2.125s)
1542
+ is_stagnant_kill = (not in_leeway) and (self.current_step > 25 and self.steps_since_progress > 85)
1543
+
1544
+ if is_physical_fall:
1545
+ terminated = True
1546
+ total_reward -= 20.0
1547
+ self.health = max(0.0, self.health - 0.2)
1548
+ elif is_boundary_departure:
1549
+ terminated = True
1550
+ total_reward -= 20.0
1551
+ elif is_moving_away_kill or is_retreat_kill:
1552
+ terminated = True
1553
+ total_reward -= 25.0 # Severe penalty for retreating/moving away from target
1554
+ elif is_not_approaching_kill or is_stagnant_kill:
1555
+ terminated = True
1556
+ total_reward -= 25.0 # Severe penalty for failing to actively approach target
1557
+
1558
+ # Dropped Goal Streak Penalty:
1559
+ # If the agent achieved at least 1 goal, but died before reaching the next goal,
1560
+ # apply a moderate penalty to encourage multi-goal chaining without negating arrival returns
1561
+ if terminated and self.goals_completed >= 1:
1562
+ dropped_goal_penalty = -15.0 * (1.0 + 0.2 * min(3, self.goals_completed - 1))
1563
+ total_reward += dropped_goal_penalty
1564
+ else:
1565
+ is_moving_backwards = (x < self.spawn_x - 0.50) or (self.negative_vx_steps > 60)
1566
+ is_stagnant = (self.current_step > 50 and self.steps_since_progress > 90)
1567
+ is_lateral_departure = (abs(y) > 3.0)
1568
+
1569
+ if is_physical_fall:
1570
+ terminated = True
1571
+ total_reward -= 20.0
1572
+ self.health = max(0.0, self.health - 0.2)
1573
+ elif is_moving_backwards:
1574
+ terminated = True
1575
+ total_reward -= 15.0
1576
+ elif is_stagnant:
1577
+ terminated = True
1578
+ total_reward -= 10.0
1579
+ elif is_lateral_departure:
1580
+ terminated = True
1581
+ total_reward -= 20.0
1582
+
1583
+ if x >= 47.0:
1584
+ terminated = True
1585
+ total_reward += 200.0
1586
+
1587
+ truncated = (self.current_step >= self.max_steps)
1588
+
1589
+ info = {
1590
+ "x_pos": float(x),
1591
+ "y_pos": float(y),
1592
+ "z_pos": float(z),
1593
+ "vx": float(vx),
1594
+ "torso_speed": float(torso_speed),
1595
+ "furthest_x": float(self.furthest_x),
1596
+ "milestones": len(self.milestones_reached),
1597
+ "waypoint_idx": int(self.current_wp_idx),
1598
+ "waypoint_name": str(COURSE_WAYPOINTS[self.current_wp_idx][1]) if not self.arena_mode else f"Arena Target #{self.goals_completed + 1}",
1599
+ "dist_to_wp": float(dist_to_wp),
1600
+ "waypoints_completed": len(self.waypoints_reached) if not self.arena_mode else int(self.goals_completed),
1601
+ "goals_completed": int(self.goals_completed),
1602
+ "dist_to_goal": float(dist_to_wp),
1603
+ "goal_arrival_radius": float(self.goal_arrival_radius) if self.arena_mode else 1.0,
1604
+ "goal_pos": self.goal_pos.tolist() if self.arena_mode else list(COURSE_WAYPOINTS[self.current_wp_idx][0]),
1605
+ "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(),
1606
+ "gaze_alignment": float(cos_gaze),
1607
+ "stamina": float(self.stamina),
1608
+ "health": float(self.health),
1609
+ "hunger": float(self.hunger),
1610
+ "step": self.current_step,
1611
+ "lidar_grid": self.current_lidar_grid.tolist(),
1612
+ "in_leeway": bool(in_leeway) if self.arena_mode else False,
1613
+ "leeway_remaining": int(self.goal_transition_steps) if self.arena_mode else 0,
1614
+ "stream_mode": "cruise",
1615
+ "active_reach_hand": "none",
1616
+ "reach_touches": 0,
1617
+ "getup_success": False,
1618
+ "stand_still_score": 0.0,
1619
+ }
1620
+
1621
+ return self._get_obs(), float(total_reward), terminated, truncated, info
1622
+
1623
+ def render_frame(
1624
+ self,
1625
+ width: int = 480,
1626
+ height: int = 320,
1627
+ camera_name: str = "behind_cam",
1628
+ ghost_datas: Optional[List[Any]] = None,
1629
+ ghost_alpha: Optional[float] = None,
1630
+ ) -> np.ndarray:
1631
+ """
1632
+ Renders an RGB numpy image of the current simulation frame.
1633
+ Optionally injects translucent ghost avatars into mjvScene for parallel multi-agent swarm visualization.
1634
+ """
1635
+ if self._renderer is None or self._renderer.width != width or self._renderer.height != height:
1636
+ self._renderer = mujoco.Renderer(self.model, height=height, width=width)
1637
+
1638
+ self._renderer.update_scene(self.data, camera=camera_name)
1639
+
1640
+ # Inject dynamic course waypoint goal markers into mjvScene
1641
+ scene = self._renderer.scene
1642
+ identity_mat = np.eye(3).flatten()
1643
+ if self.stream_mode == "reach":
1644
+ # Stream 3 Reach: Glowing magenta hand target orb + vertical guide line
1645
+ rx, ry, rz = float(self.reach_target_pos[0]), float(self.reach_target_pos[1]), float(self.reach_target_pos[2])
1646
+ if scene.ngeom < scene.maxgeom:
1647
+ dst_orb = scene.geoms[scene.ngeom]
1648
+ orb_pos = np.array([rx, ry, rz], dtype=np.float64)
1649
+ orb_size = np.array([0.09, 0.09, 0.09], dtype=np.float64)
1650
+ orb_rgba = np.array([0.88, 0.20, 0.95, 0.95], dtype=np.float32) # Radiant Magenta
1651
+ mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba)
1652
+ dst_orb.transparent = 1
1653
+ scene.ngeom += 1
1654
+
1655
+ if scene.ngeom < scene.maxgeom:
1656
+ dst_guide = scene.geoms[scene.ngeom]
1657
+ guide_pos = np.array([rx, ry, max(0.01, rz / 2.0)], dtype=np.float64)
1658
+ guide_size = np.array([0.012, max(0.01, rz / 2.0), 0.0], dtype=np.float64)
1659
+ guide_rgba = np.array([0.88, 0.20, 0.95, 0.35], dtype=np.float32)
1660
+ mujoco.mjv_initGeom(dst_guide, mujoco.mjtGeom.mjGEOM_CYLINDER, guide_size, guide_pos, identity_mat, guide_rgba)
1661
+ dst_guide.transparent = 1
1662
+ scene.ngeom += 1
1663
+
1664
+ elif self.stream_mode == "getup":
1665
+ # Stream 2 Get-Up: Golden upright elevation target disc
1666
+ torso_xpos = self.data.xpos[self.torso_body_id]
1667
+ tx, ty = float(torso_xpos[0]), float(torso_xpos[1])
1668
+ if scene.ngeom < scene.maxgeom:
1669
+ dst_target = scene.geoms[scene.ngeom]
1670
+ target_pos = np.array([tx, ty, 1.33], dtype=np.float64)
1671
+ target_size = np.array([0.25, 0.02, 0.0], dtype=np.float64)
1672
+ target_rgba = np.array([0.98, 0.75, 0.15, 0.65], dtype=np.float32) # Glowing Amber
1673
+ mujoco.mjv_initGeom(dst_target, mujoco.mjtGeom.mjGEOM_CYLINDER, target_size, target_pos, identity_mat, target_rgba)
1674
+ dst_target.transparent = 1
1675
+ scene.ngeom += 1
1676
+
1677
+ elif self.stream_mode == "stand":
1678
+ # Stream 1 Stand: Cyan stillness equilibrium ground pad
1679
+ if scene.ngeom < scene.maxgeom:
1680
+ dst_ring = scene.geoms[scene.ngeom]
1681
+ ring_pos = np.array([0.0, 0.0, 0.015], dtype=np.float64)
1682
+ ring_size = np.array([0.35, 0.015, 0.0], dtype=np.float64)
1683
+ ring_rgba = np.array([0.22, 0.74, 0.97, 0.50], dtype=np.float32) # Cyan Pad
1684
+ mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba)
1685
+ dst_ring.transparent = 1
1686
+ scene.ngeom += 1
1687
+
1688
+ elif self.arena_mode:
1689
+ # ACTIVE ARENA GOAL: Glowing floating target orb + vertical laser beacon column + ground target ring
1690
+ gx, gy, gz = float(self.goal_pos[0]), float(self.goal_pos[1]), float(self.goal_pos[2])
1691
+
1692
+ # 1. Floating target orb (sphere)
1693
+ if scene.ngeom < scene.maxgeom:
1694
+ dst_orb = scene.geoms[scene.ngeom]
1695
+ orb_pos = np.array([gx, gy, gz + 0.35], dtype=np.float64)
1696
+ orb_size = np.array([0.22, 0.22, 0.22], dtype=np.float64)
1697
+ orb_rgba = np.array([0.98, 0.75, 0.15, 0.90], dtype=np.float32) # Vibrant Gold
1698
+ mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba)
1699
+ dst_orb.transparent = 1
1700
+ scene.ngeom += 1
1701
+
1702
+ # 2. Vertical laser beacon column (cylinder)
1703
+ if scene.ngeom < scene.maxgeom:
1704
+ dst_beam = scene.geoms[scene.ngeom]
1705
+ beam_pos = np.array([gx, gy, 1.25], dtype=np.float64)
1706
+ beam_size = np.array([0.05, 1.25, 0.0], dtype=np.float64)
1707
+ beam_rgba = np.array([0.22, 0.74, 0.97, 0.55], dtype=np.float32) # Glowing Cyan
1708
+ mujoco.mjv_initGeom(dst_beam, mujoco.mjtGeom.mjGEOM_CYLINDER, beam_size, beam_pos, identity_mat, beam_rgba)
1709
+ dst_beam.transparent = 1
1710
+ scene.ngeom += 1
1711
+
1712
+ # 3. Ground target arrival ring
1713
+ if scene.ngeom < scene.maxgeom:
1714
+ dst_ring = scene.geoms[scene.ngeom]
1715
+ ring_pos = np.array([gx, gy, 0.02], dtype=np.float64)
1716
+ ring_size = np.array([self.goal_arrival_radius, 0.02, 0.0], dtype=np.float64)
1717
+ ring_rgba = np.array([0.22, 0.74, 0.97, 0.45], dtype=np.float32)
1718
+ mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba)
1719
+ dst_ring.transparent = 1
1720
+ scene.ngeom += 1
1721
+ else:
1722
+ for wp_idx, (wp_coord, wp_name, wp_radius) in enumerate(COURSE_WAYPOINTS):
1723
+ if scene.ngeom + 3 >= scene.maxgeom:
1724
+ break
1725
+ wx = float(wp_coord[0])
1726
+ wy = float(wp_coord[1])
1727
+ wz = float(wp_coord[2])
1728
+
1729
+ if wp_idx == self.current_wp_idx:
1730
+ # ACTIVE GOAL: Glowing floating target orb + vertical laser beacon column + ground target ring
1731
+ # 1. Floating target orb (sphere)
1732
+ dst_orb = scene.geoms[scene.ngeom]
1733
+ orb_pos = np.array([wx, wy, wz + 0.35], dtype=np.float64)
1734
+ orb_size = np.array([0.18, 0.18, 0.18], dtype=np.float64)
1735
+ orb_rgba = np.array([0.98, 0.75, 0.15, 0.90], dtype=np.float32) # Vibrant Gold
1736
+ mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba)
1737
+ dst_orb.transparent = 1
1738
+ scene.ngeom += 1
1739
+
1740
+ # 2. Vertical laser beacon column (cylinder)
1741
+ if scene.ngeom < scene.maxgeom:
1742
+ dst_beam = scene.geoms[scene.ngeom]
1743
+ beam_pos = np.array([wx, wy, wz + 0.60], dtype=np.float64)
1744
+ beam_size = np.array([0.05, 0.60, 0.0], dtype=np.float64)
1745
+ beam_rgba = np.array([0.22, 0.74, 0.97, 0.55], dtype=np.float32) # Glowing Cyan
1746
+ mujoco.mjv_initGeom(dst_beam, mujoco.mjtGeom.mjGEOM_CYLINDER, beam_size, beam_pos, identity_mat, beam_rgba)
1747
+ dst_beam.transparent = 1
1748
+ scene.ngeom += 1
1749
+
1750
+ # 3. Ground target landing pad ring
1751
+ if scene.ngeom < scene.maxgeom:
1752
+ dst_ring = scene.geoms[scene.ngeom]
1753
+ ring_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64)
1754
+ ring_size = np.array([float(wp_radius) * 0.6, 0.02, 0.0], dtype=np.float64)
1755
+ ring_rgba = np.array([0.22, 0.74, 0.97, 0.45], dtype=np.float32)
1756
+ mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba)
1757
+ dst_ring.transparent = 1
1758
+ scene.ngeom += 1
1759
+
1760
+ elif wp_idx < self.current_wp_idx:
1761
+ # COMPLETED GOAL: Subtle emerald disc on surface
1762
+ dst_comp = scene.geoms[scene.ngeom]
1763
+ comp_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64)
1764
+ comp_size = np.array([0.25, 0.015, 0.0], dtype=np.float64)
1765
+ comp_rgba = np.array([0.10, 0.85, 0.35, 0.40], dtype=np.float32) # Emerald Green
1766
+ mujoco.mjv_initGeom(dst_comp, mujoco.mjtGeom.mjGEOM_CYLINDER, comp_size, comp_pos, identity_mat, comp_rgba)
1767
+ dst_comp.transparent = 1
1768
+ scene.ngeom += 1
1769
+
1770
+ else:
1771
+ # UPCOMING GOAL: Translucent waypoint marker disc
1772
+ dst_up = scene.geoms[scene.ngeom]
1773
+ up_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64)
1774
+ up_size = np.array([0.16, 0.015, 0.0], dtype=np.float64)
1775
+ up_rgba = np.array([0.60, 0.65, 0.80, 0.25], dtype=np.float32) # Translucent Silver
1776
+ mujoco.mjv_initGeom(dst_up, mujoco.mjtGeom.mjGEOM_CYLINDER, up_size, up_pos, identity_mat, up_rgba)
1777
+ dst_up.transparent = 1
1778
+ scene.ngeom += 1
1779
+
1780
+ # Inject translucent ghost avatars if provided
1781
+ if ghost_datas:
1782
+ scene = self._renderer.scene
1783
+ for g_idx, g_data in enumerate(ghost_datas):
1784
+ color = GHOST_PALETTE[g_idx % len(GHOST_PALETTE)]
1785
+ if ghost_alpha is not None:
1786
+ color = color.copy()
1787
+ color[3] = float(ghost_alpha)
1788
+ for gid in self.humanoid_geoms:
1789
+ if scene.ngeom >= scene.maxgeom:
1790
+ break
1791
+ dst = scene.geoms[scene.ngeom]
1792
+ pos = g_data.geom_xpos[gid].astype(np.float64)
1793
+ size = self.model.geom_size[gid].astype(np.float64)
1794
+ mat = g_data.geom_xmat[gid].astype(np.float64)
1795
+ geom_type = int(self.model.geom_type[gid])
1796
+
1797
+ mujoco.mjv_initGeom(dst, geom_type, size, pos, mat, color)
1798
+ dst.transparent = 1
1799
+ scene.ngeom += 1
1800
+
1801
+ return self._renderer.render()