kenpeter123 commited on
Commit
1851ea6
·
verified ·
1 Parent(s): 232d7f4

Microduck RL: exact DuckEMW recipe, cap 2.0, model_58000 (1.65 m/s @ cmd 2.0)

Browse files
README.md ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ tags:
3
+ - reinforcement-learning
4
+ - locomotion
5
+ - bipedal
6
+ - microduck
7
+ - pollen-robotics
8
+ ---
9
+
10
+ # Microduck RL — 2.0 m/s running push
11
+
12
+ Fork of [pollen-robotics/microduck_rl](https://github.com/pollen-robotics/microduck_rl) training
13
+ Pollin Robotics **Microduck** (25 cm open-source biped, XL330 servos) to run at a
14
+ **measured mean 2.0 m/s** forward speed via PPO (rsl_rl + MuJoCo Warp / BAM).
15
+
16
+ ## Honest status (2026-09-12)
17
+
18
+ - Recipe: exact DuckEMW running config, **only deviation = speed cap raised to 2.0**
19
+ (`MICRODUCK_RUNNING_SPEED_CAP=2.0`, `MICRODUCK_RUNNING_TARGET_MAX_SPEED=2.0`).
20
+ No flight reward, no Su-style rewards, no achievement gate — reverted to the proven base.
21
+ - Measured mean forward speed (world-displacement eval, `scripts/eval_sprint_speed.py`):
22
+ - cmd 0.8 → 0.90 m/s
23
+ - cmd 1.2 → 1.32 m/s
24
+ - cmd 1.6 → 1.54 m/s
25
+ - **cmd 2.0 → 1.65 m/s** (p10 1.45, p90 1.78, max 1.89, ~0 resets)
26
+ - Goal (mean 2.0 @ cmd 2.0) **NOT yet reached** — training still climbing (~+0.3 m/s per 40k iters).
27
+
28
+ ## How speed is measured (no lies)
29
+
30
+ `scripts/eval_sprint_speed.py` measures true world-frame displacement
31
+ (`root_link_pos_w` delta / time) across 64 envs, reporting mean / p10 / p90 / max / resets
32
+ per command. Reward/curriculum metrics are NEVER reported as speed.
33
+
34
+ ## Run it
35
+
36
+ ```bash
37
+ export MICRODUCK_RUNNING_SPEED_CAP=2.0
38
+ export MICRODUCK_RUNNING_TARGET_MAX_SPEED=2.0
39
+ .venv/bin/train Mjlab-Running-Flat-MicroDuck \
40
+ --env.scene.num-envs 4096 --agent.max-iterations 80000
41
+ ```
42
+
43
+ ## Files
44
+
45
+ - `src/mjlab_microduck/tasks/microduck_running_env_cfg.py` — running task cfg + knobs
46
+ - `src/mjlab_microduck/tasks/mdp.py` — rewards + speed curriculum
47
+ - `scripts/eval_sprint_speed.py` — lie-proof world-displacement eval
48
+ - `model_58000.pt` — latest checkpoint (measured 1.65 m/s @ cmd 2.0)
checkpoints/model_58000.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b06b59ca7c1beaa7c004555cb81124a9640baef30e23890acc49860913ef4bbd
3
+ size 4843527
microduck_running_env_cfg.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forward-running task for Microduck.
2
+
3
+ This deliberately starts from the proven velocity environment so the 61D
4
+ observation contract, BAM actuator model, delays, noise, and sim2real domain
5
+ randomization remain identical to the walking policy family. The task is
6
+ forward-only and rewards measured progress beyond ordinary walking speed.
7
+
8
+ Two registered recipes use this file:
9
+
10
+ * ``Running`` optimizes forward progress without prescribing a gait.
11
+ * ``RunningFlight`` adds a small reward only when a controlled flight phase
12
+ begins. It does not pay for airtime, so a long ballistic fall is not useful.
13
+ """
14
+
15
+ import math
16
+ import os
17
+ from copy import deepcopy
18
+
19
+ from mjlab.managers import (
20
+ CurriculumTermCfg,
21
+ EventTermCfg,
22
+ RewardTermCfg,
23
+ SceneEntityCfg,
24
+ )
25
+ from mjlab.tasks.velocity import mdp as velocity_mdp
26
+
27
+ from mjlab_microduck.tasks import mdp as microduck_mdp
28
+ from mjlab_microduck.tasks.microduck_velocity_env_cfg import (
29
+ NUM_STEPS_PER_ENV,
30
+ VELOCITY_PUSH_INTERVAL_S,
31
+ MicroduckRlCfg,
32
+ make_microduck_velocity_env_cfg,
33
+ )
34
+ from mjlab_microduck.tasks.symmetry import SYMMETRY_CFG
35
+
36
+ RUNNING_PLAY_SPEED = 1.0
37
+ RUNNING_SPEED_CAP = float(os.environ.get("MICRODUCK_RUNNING_SPEED_CAP", "1.4"))
38
+ RUNNING_TARGET_MAX_SPEED = float(
39
+ os.environ.get("MICRODUCK_RUNNING_TARGET_MAX_SPEED", "1.2")
40
+ )
41
+ RUNNING_FINAL_ACTION_RATE_WEIGHT = float(
42
+ os.environ.get("MICRODUCK_RUNNING_ACTION_RATE_WEIGHT", "-0.10")
43
+ )
44
+ RUNNING_FORWARD_PROGRESS_WEIGHT = float(
45
+ os.environ.get("MICRODUCK_RUNNING_FORWARD_PROGRESS_WEIGHT", "5.0")
46
+ )
47
+ RUNNING_HIGH_SPEED_STAGE_INTERVAL = int(
48
+ os.environ.get("MICRODUCK_RUNNING_HIGH_SPEED_STAGE_INTERVAL", "500")
49
+ )
50
+ RUNNING_HIGH_SPEED_STEP = float(
51
+ os.environ.get("MICRODUCK_RUNNING_HIGH_SPEED_STEP", "0.15")
52
+ )
53
+ RUNNING_FLIGHT_REWARD_WEIGHT = float(
54
+ os.environ.get("MICRODUCK_RUNNING_FLIGHT_WEIGHT", "0.0")
55
+ )
56
+ RUNNING_GATE_PERFORMANCE = os.environ.get("MICRODUCK_RUNNING_GATE_PERFORMANCE") == "1"
57
+ RUNNING_ENABLE_SYMMETRY = os.environ.get("MICRODUCK_RUNNING_ENABLE_SYMMETRY") == "1"
58
+ RUNNING_ENABLE_HEADING_FEEDBACK = (
59
+ os.environ.get("MICRODUCK_RUNNING_ENABLE_HEADING_FEEDBACK") == "1"
60
+ )
61
+ RUNNING_CADENCE_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_CADENCE_WEIGHT", "0.0"))
62
+ RUNNING_FAST_TOUCH_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_FAST_TOUCH_WEIGHT", "0.0"))
63
+ RUNNING_EXPLOSIVE_ACCEL_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_EXPLOSIVE_ACCEL_WEIGHT", "0.0"))
64
+ RUNNING_STANDING_FRACTION = 0.03
65
+ RUNNING_CURRICULUM_START_ITERATION = int(
66
+ os.environ.get("MICRODUCK_RUNNING_CURRICULUM_START_ITERATION", "0")
67
+ )
68
+ RUNNING_ROBUST_PUSH_MPS = float(
69
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_MPS", "0")
70
+ )
71
+ RUNNING_ROBUST_TRUNK_COM_M = float(
72
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_TRUNK_COM_M", "0")
73
+ )
74
+ RUNNING_ROBUST_HEAD_COM_M = float(
75
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_HEAD_COM_M", "0")
76
+ )
77
+ RUNNING_ROBUST_INITIAL_TILT_DEG = float(
78
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_INITIAL_TILT_DEG", "0")
79
+ )
80
+ RUNNING_ROBUST_FRICTION_MIN = float(
81
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_FRICTION_MIN", "0.7")
82
+ )
83
+ RUNNING_ROBUST_FRICTION_MAX = float(
84
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_FRICTION_MAX", "1.3")
85
+ )
86
+ RUNNING_ROBUST_PUSH_INTERVAL_MIN_S = float(
87
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_INTERVAL_MIN_S", "3.0")
88
+ )
89
+ RUNNING_ROBUST_PUSH_INTERVAL_MAX_S = float(
90
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_INTERVAL_MAX_S", "6.0")
91
+ )
92
+
93
+ # The lower edge rises too: zero-command behavior comes from the explicit
94
+ # standing bucket, rather than consuming most running samples near zero.
95
+ _RUNNING_BASE_SPEED_STAGES = (
96
+ {"step": 0 * NUM_STEPS_PER_ENV, "min_speed": 0.20, "max_speed": 0.45},
97
+ {"step": 1000 * NUM_STEPS_PER_ENV, "min_speed": 0.30, "max_speed": 0.55},
98
+ {"step": 2000 * NUM_STEPS_PER_ENV, "min_speed": 0.40, "max_speed": 0.65},
99
+ {"step": 3000 * NUM_STEPS_PER_ENV, "min_speed": 0.50, "max_speed": 0.75},
100
+ {"step": 4000 * NUM_STEPS_PER_ENV, "min_speed": 0.60, "max_speed": 0.85},
101
+ {"step": 5000 * NUM_STEPS_PER_ENV, "min_speed": 0.70, "max_speed": 0.95},
102
+ {"step": 6000 * NUM_STEPS_PER_ENV, "min_speed": 0.90, "max_speed": 1.20},
103
+ )
104
+
105
+
106
+ def _running_speed_stages(
107
+ target_max_speed: float, high_speed_stage_interval: int = 500, high_speed_step: float = 0.15
108
+ ) -> tuple[dict, ...]:
109
+ """Return the proven base curriculum plus an optional continuation ramp."""
110
+ if target_max_speed < 1.2:
111
+ raise ValueError("running target max speed must be at least 1.2 m/s")
112
+ if high_speed_stage_interval <= 0:
113
+ raise ValueError("running high-speed stage interval must be positive")
114
+ if target_max_speed == 1.2:
115
+ return _RUNNING_BASE_SPEED_STAGES
116
+ intermediate_1 = min(1.35, target_max_speed)
117
+ intermediate_2 = min(1.50, target_max_speed)
118
+ intermediate_3 = min(1.65, target_max_speed)
119
+ stages = _RUNNING_BASE_SPEED_STAGES + (
120
+ {
121
+ "step": 6750 * NUM_STEPS_PER_ENV,
122
+ "min_speed": 0.95,
123
+ "max_speed": intermediate_1,
124
+ },
125
+ {
126
+ "step": 7250 * NUM_STEPS_PER_ENV,
127
+ "min_speed": 1.05,
128
+ "max_speed": intermediate_2,
129
+ },
130
+ {
131
+ "step": 7750 * NUM_STEPS_PER_ENV,
132
+ "min_speed": 1.15,
133
+ "max_speed": intermediate_3,
134
+ },
135
+ )
136
+
137
+ # Checkpoint 8,749 has consolidated the 1.65 m/s slice. Targets above
138
+ # that must arrive as new post-checkpoint stages; replacing the 7,750
139
+ # target would make a resumed run jump immediately to its final command.
140
+ if target_max_speed <= 1.65:
141
+ return stages
142
+
143
+ high_speed_targets: list[float] = []
144
+ next_target = 1.80
145
+ while next_target < target_max_speed - 1e-9:
146
+ high_speed_targets.append(round(next_target, 2))
147
+ next_target += high_speed_step
148
+ high_speed_targets.append(target_max_speed)
149
+
150
+ return stages + tuple(
151
+ {
152
+ "step": (8750 + index * high_speed_stage_interval)
153
+ * NUM_STEPS_PER_ENV,
154
+ "min_speed": round(max(1.25, max_speed - 0.55), 2),
155
+ "max_speed": max_speed,
156
+ }
157
+ for index, max_speed in enumerate(high_speed_targets)
158
+ )
159
+
160
+
161
+ RUNNING_SPEED_STAGES = _running_speed_stages(
162
+ RUNNING_TARGET_MAX_SPEED, RUNNING_HIGH_SPEED_STAGE_INTERVAL, RUNNING_HIGH_SPEED_STEP
163
+ )
164
+
165
+
166
+ def _apply_running_robustness(
167
+ cfg,
168
+ *,
169
+ push_mps: float,
170
+ trunk_com_m: float,
171
+ head_com_m: float,
172
+ initial_tilt_deg: float,
173
+ friction_range: tuple[float, float],
174
+ push_interval_s: tuple[float, float] = VELOCITY_PUSH_INTERVAL_S,
175
+ ) -> None:
176
+ """Apply one fixed continuation stage without resume-step ambiguity."""
177
+ values = (push_mps, trunk_com_m, head_com_m, initial_tilt_deg)
178
+ if any(value < 0.0 for value in values):
179
+ raise ValueError("running robustness magnitudes must be non-negative")
180
+ if not 0.0 < friction_range[0] <= friction_range[1]:
181
+ raise ValueError("running robustness friction range must be positive and ordered")
182
+ if not 0.0 < push_interval_s[0] <= push_interval_s[1]:
183
+ raise ValueError("running robustness push interval must be positive and ordered")
184
+
185
+ cfg.events["foot_friction"].params["ranges"] = friction_range
186
+ if push_mps > 0.0:
187
+ velocity_range = {
188
+ "x": (-push_mps, push_mps),
189
+ "y": (-push_mps, push_mps),
190
+ }
191
+ if "push_robot" in cfg.events:
192
+ cfg.events["push_robot"].params["velocity_range"] = velocity_range
193
+ cfg.events["push_robot"].interval_range_s = push_interval_s
194
+ else:
195
+ cfg.events["push_robot"] = EventTermCfg(
196
+ func=velocity_mdp.push_by_setting_velocity,
197
+ mode="interval",
198
+ interval_range_s=push_interval_s,
199
+ params={
200
+ "velocity_range": velocity_range,
201
+ "asset_cfg": SceneEntityCfg("robot"),
202
+ },
203
+ )
204
+ else:
205
+ cfg.events.pop("push_robot", None)
206
+
207
+ if trunk_com_m > 0.0:
208
+ cfg.events["randomize_com"].params["ranges"] = (
209
+ -trunk_com_m,
210
+ trunk_com_m,
211
+ )
212
+ if head_com_m > 0.0:
213
+ cfg.events["randomize_head_com"].params["ranges"] = (
214
+ -head_com_m,
215
+ head_com_m,
216
+ )
217
+ if initial_tilt_deg > 0.0:
218
+ cfg.events["randomize_base_orientation"] = EventTermCfg(
219
+ func=microduck_mdp.randomize_base_orientation,
220
+ mode="reset",
221
+ params={
222
+ "asset_cfg": SceneEntityCfg("robot"),
223
+ "max_pitch_deg": initial_tilt_deg,
224
+ "max_roll_deg": initial_tilt_deg,
225
+ },
226
+ )
227
+
228
+
229
+ def make_microduck_running_env_cfg(
230
+ play: bool = False, flight_reward_weight: float = RUNNING_FLIGHT_REWARD_WEIGHT
231
+ ):
232
+ """Build the flat-ground, forward-only running environment."""
233
+ cfg = make_microduck_velocity_env_cfg(play=play, rough=False)
234
+ cfg.episode_length_s = 12.0
235
+ # Microduck is only ~25 cm tall; the velocity recipe's 3 m follow camera
236
+ # hides gait and contact details in rollout videos.
237
+ cfg.viewer.distance = 0.55
238
+ cfg.viewer.max_extra_envs = 0
239
+
240
+ command = cfg.commands["twist"]
241
+ command.rel_standing_envs = 0.0 if play else RUNNING_STANDING_FRACTION
242
+ command.rel_heading_envs = 0.0
243
+ command.rel_turn_in_place_envs = 0.0
244
+ command.resampling_time_range = (12.0, 12.0)
245
+ if play:
246
+ command.ranges.lin_vel_x = (RUNNING_PLAY_SPEED, RUNNING_PLAY_SPEED)
247
+ command.ranges.lin_vel_y = (0.0, 0.0)
248
+ command.ranges.ang_vel_z = (0.0, 0.0)
249
+ else:
250
+ command.ranges.lin_vel_x = (
251
+ RUNNING_SPEED_STAGES[0]["min_speed"],
252
+ RUNNING_SPEED_STAGES[0]["max_speed"],
253
+ )
254
+ # Tiny non-zero lateral/yaw ranges keep those command neurons alive,
255
+ # while still making essentially every active sample forward-running.
256
+ command.ranges.lin_vel_y = (-0.02, 0.02)
257
+ command.ranges.ang_vel_z = (-0.05, 0.05)
258
+
259
+ if RUNNING_ENABLE_HEADING_FEEDBACK:
260
+ # Slot 2 becomes signed spawn-heading error, recomputed every step. It
261
+ # remains in the same position and mirrors with the same sign rule as a
262
+ # yaw-rate command, so the deployment observation contract is unchanged.
263
+ command.heading_command = False
264
+ command.rel_heading_envs = 0.0
265
+ command.ranges.heading = None
266
+ command.ranges.ang_vel_z = (0.0, 0.0)
267
+ command = microduck_mdp.SpawnHeadingVelocityCommandCfg(
268
+ **vars(command), heading_error_clip=1.0
269
+ )
270
+ cfg.commands["twist"] = command
271
+
272
+ # Max-speed objective. Velocity tracking remains useful as a curriculum
273
+ # guide, but forward progress is strong enough that exceeding the command is
274
+ # profitable. This is what makes the optimum "as fast as possible".
275
+ cfg.rewards["forward_progress"] = RewardTermCfg(
276
+ func=microduck_mdp.running_forward_progress,
277
+ weight=RUNNING_FORWARD_PROGRESS_WEIGHT,
278
+ params={"speed_cap": RUNNING_SPEED_CAP},
279
+ )
280
+ cfg.rewards["track_linear_velocity"].weight = 2.0
281
+ cfg.rewards["track_linear_velocity"].params["std"] = math.sqrt(0.15)
282
+ cfg.rewards["track_angular_velocity"].weight = 0.5
283
+ cfg.rewards["track_angular_velocity"].params["std"] = math.sqrt(0.5)
284
+ cfg.rewards["planar_drift"] = RewardTermCfg(
285
+ func=microduck_mdp.running_planar_drift_cost,
286
+ weight=-0.05,
287
+ params={"command_name": "twist", "lateral_weight": 4.0},
288
+ )
289
+ # Rate tracking alone can only say "stop turning"; once a heading error
290
+ # exists it provides no signal for which way to steer back. Anchor yaw to
291
+ # each randomized spawn heading so straight running is the actual optimum.
292
+ cfg.rewards["heading_hold"] = RewardTermCfg(
293
+ func=microduck_mdp.heading_hold_reward,
294
+ weight=1.5,
295
+ params={"std": 0.4, "asset_cfg": SceneEntityCfg("robot")},
296
+ )
297
+
298
+ # Permit the forward lean and fast leg cycling a sprint needs. These stay
299
+ # non-zero only to rule out tumbling and unbounded thrash as cheap optima.
300
+ cfg.rewards["pose"].weight = 0.15
301
+ cfg.rewards["upright"].weight = 0.75
302
+ cfg.rewards["upright"].params["std"] = math.sqrt(0.15)
303
+ cfg.rewards["body_ang_vel"].weight = -0.01
304
+ cfg.rewards["angular_momentum"].weight = -0.005
305
+ cfg.rewards["action_rate_l2"].weight = -0.02
306
+ cfg.rewards["foot_slip"].weight = -0.05
307
+ cfg.rewards["air_time"].weight = 0.0
308
+ cfg.rewards["foot_clearance"].params["target_height"] = 0.015
309
+ cfg.rewards["foot_swing_height"].params["target_height"] = 0.015
310
+
311
+ # Head/body slots remain present and sample tiny non-zero ranges, preserving
312
+ # the 61D hot-swap contract, but posture precision must not block discovery.
313
+ cfg.rewards["head_pose_tracking"].weight = 0.0
314
+ cfg.rewards["head_pose_bias"].weight = 0.0
315
+ cfg.rewards["body_pose_tracking"].weight = 0.0
316
+
317
+ if flight_reward_weight > 0.0:
318
+ cfg.rewards["flight_event"] = RewardTermCfg(
319
+ func=microduck_mdp.running_flight_event,
320
+ weight=flight_reward_weight,
321
+ params={
322
+ "sensor_name": "feet_ground_contact",
323
+ "min_forward_speed": 0.30,
324
+ "max_tilt_deg": 50.0,
325
+ "min_airborne_steps": 3,
326
+ },
327
+ )
328
+
329
+ # Su-Bingtian-style sprint levers for a small biped (cadence + stiff/fast
330
+ # ground touch + explosive low-speed acceleration). All off by default;
331
+ # enable via env knobs so they can be tuned without code edits.
332
+ if RUNNING_CADENCE_WEIGHT != 0.0:
333
+ cfg.rewards["running_cadence"] = RewardTermCfg(
334
+ func=microduck_mdp.running_cadence,
335
+ weight=RUNNING_CADENCE_WEIGHT,
336
+ params={"sensor_name": "feet_ground_contact"},
337
+ )
338
+ if RUNNING_FAST_TOUCH_WEIGHT != 0.0:
339
+ cfg.rewards["running_fast_ground_touch"] = RewardTermCfg(
340
+ func=microduck_mdp.running_fast_ground_touch,
341
+ weight=RUNNING_FAST_TOUCH_WEIGHT,
342
+ params={"sensor_name": "feet_ground_contact"},
343
+ )
344
+ if RUNNING_EXPLOSIVE_ACCEL_WEIGHT != 0.0:
345
+ cfg.rewards["running_explosive_accel"] = RewardTermCfg(
346
+ func=microduck_mdp.running_explosive_accel,
347
+ weight=RUNNING_EXPLOSIVE_ACCEL_WEIGHT,
348
+ params={"command_name": "twist", "speed_cap": RUNNING_SPEED_CAP},
349
+ )
350
+
351
+ # A clean speed-discovery phase: no random velocity kicks and no scheduled
352
+ # widening of CoM/head commands or precision taxes. Fixed initial DR stays
353
+ # active, so the result is not a deterministic-sim-only policy.
354
+ _apply_running_robustness(
355
+ cfg,
356
+ push_mps=RUNNING_ROBUST_PUSH_MPS,
357
+ trunk_com_m=RUNNING_ROBUST_TRUNK_COM_M,
358
+ head_com_m=RUNNING_ROBUST_HEAD_COM_M,
359
+ initial_tilt_deg=RUNNING_ROBUST_INITIAL_TILT_DEG,
360
+ friction_range=(
361
+ RUNNING_ROBUST_FRICTION_MIN,
362
+ RUNNING_ROBUST_FRICTION_MAX,
363
+ ),
364
+ push_interval_s=(
365
+ RUNNING_ROBUST_PUSH_INTERVAL_MIN_S,
366
+ RUNNING_ROBUST_PUSH_INTERVAL_MAX_S,
367
+ ),
368
+ )
369
+ for name in (
370
+ "standing_envs",
371
+ "head_pose_range",
372
+ "body_pose_range",
373
+ "com_range",
374
+ "head_com_range",
375
+ "head_pose_bias_weight",
376
+ ):
377
+ cfg.curriculum.pop(name, None)
378
+
379
+ if not play:
380
+ cfg.curriculum["running_speed_range"] = CurriculumTermCfg(
381
+ func=microduck_mdp.running_command_ranges_curriculum,
382
+ params={
383
+ "command_name": "twist",
384
+ "gate_on_performance": RUNNING_GATE_PERFORMANCE,
385
+ "gate_margin": 0.02,
386
+ "gate_min_steps": 500,
387
+ "speed_stages": [
388
+ {
389
+ **stage,
390
+ "step": stage["step"]
391
+ - RUNNING_CURRICULUM_START_ITERATION * NUM_STEPS_PER_ENV,
392
+ }
393
+ for stage in RUNNING_SPEED_STAGES
394
+ ],
395
+ },
396
+ )
397
+ # Smoothness is introduced only after a fast gait exists.
398
+ cfg.curriculum["action_rate_weight"] = CurriculumTermCfg(
399
+ func=microduck_mdp.reward_weight,
400
+ params={
401
+ "reward_name": "action_rate_l2",
402
+ "weight_stages": [
403
+ {
404
+ "step": (0 - RUNNING_CURRICULUM_START_ITERATION)
405
+ * NUM_STEPS_PER_ENV,
406
+ "weight": -0.02,
407
+ },
408
+ {
409
+ "step": (2500 - RUNNING_CURRICULUM_START_ITERATION)
410
+ * NUM_STEPS_PER_ENV,
411
+ "weight": -0.05,
412
+ },
413
+ {
414
+ "step": (4000 - RUNNING_CURRICULUM_START_ITERATION)
415
+ * NUM_STEPS_PER_ENV,
416
+ "weight": RUNNING_FINAL_ACTION_RATE_WEIGHT,
417
+ },
418
+ {
419
+ "step": (5500 - RUNNING_CURRICULUM_START_ITERATION)
420
+ * NUM_STEPS_PER_ENV,
421
+ "weight": RUNNING_FINAL_ACTION_RATE_WEIGHT,
422
+ },
423
+ ],
424
+ },
425
+ )
426
+ else:
427
+ cfg.curriculum.pop("action_rate_weight", None)
428
+
429
+ return cfg
430
+
431
+
432
+ MicroduckRunningRlCfg = deepcopy(MicroduckRlCfg)
433
+ MicroduckRunningRlCfg.experiment_name = "running"
434
+ MicroduckRunningRlCfg.run_name = "running-max-speed"
435
+ MicroduckRunningRlCfg.algorithm.entropy_coef = 0.02
436
+ MicroduckRunningRlCfg.algorithm.symmetry_cfg = (
437
+ deepcopy(SYMMETRY_CFG) if RUNNING_ENABLE_SYMMETRY else None
438
+ )
439
+ MicroduckRunningRlCfg.max_iterations = 7_500
440
+
441
+ MicroduckRunningFlightRlCfg = deepcopy(MicroduckRunningRlCfg)
442
+ MicroduckRunningFlightRlCfg.experiment_name = "running_flight"
443
+ MicroduckRunningFlightRlCfg.run_name = "running-flight-event"
scripts/eval_sprint_speed.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """eval_sprint_speed.py — 在 warp 训练环境内直接测 checkpoint 的真实速度。
2
+
3
+ 绕开本地 CPU harness 的所有差异项(BAM/延迟/噪声/归一化烘焙),用训练同款
4
+ 环境 + rsl_rl 推理策略(含 obs normalizer)回答:策略在原生环境里跑多快?
5
+
6
+ 用法(实例上,工作目录 = repo 根):
7
+ uv run --no-sync python scripts/eval_sprint_speed.py \
8
+ --checkpoint logs/rsl_rl/sprint/<run>/model_1999.pt --vx 0.4 1.2 2.0
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ from dataclasses import asdict
15
+
16
+ import numpy as np
17
+ import torch
18
+
19
+
20
+ def main():
21
+ ap = argparse.ArgumentParser()
22
+ ap.add_argument("--task", default="Mjlab-Sprint-Flat-MicroDuck")
23
+ ap.add_argument("--checkpoint", default=None)
24
+ ap.add_argument("--onnx", default=None, help="测 ONNX(归一化已烘焙)而非 .pt checkpoint")
25
+ ap.add_argument("--vx", type=float, nargs="+", default=[0.4, 0.8, 1.2, 1.6, 2.0])
26
+ ap.add_argument("--num-envs", type=int, default=64)
27
+ ap.add_argument("--seconds", type=float, default=8.0)
28
+ ap.add_argument("--device", default="cuda:0")
29
+ args = ap.parse_args()
30
+
31
+ import mjlab.tasks # noqa: F401 — populate the registry
32
+ import mjlab_microduck.tasks # noqa: F401 — microduck registrations
33
+ from mjlab.tasks.registry import load_env_cfg, load_rl_cfg, load_runner_cls
34
+ from mjlab.envs import ManagerBasedRlEnv
35
+ from mjlab.rl import RslRlVecEnvWrapper, MjlabOnPolicyRunner
36
+
37
+ env_cfg = load_env_cfg(args.task)
38
+ agent_cfg = load_rl_cfg(args.task)
39
+ env_cfg.scene.num_envs = args.num_envs
40
+
41
+ env = ManagerBasedRlEnv(cfg=env_cfg, device=args.device)
42
+ env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
43
+
44
+ if args.onnx:
45
+ import onnxruntime as ort
46
+ session = ort.InferenceSession(args.onnx)
47
+
48
+ def policy(obs):
49
+ vec = obs["actor"].detach().cpu().numpy().astype(np.float32)
50
+ # 导出的 ONNX batch 维固定为 1,逐 env 推理
51
+ acts = [session.run(None, {session.get_inputs()[0].name: vec[i : i + 1]})[0]
52
+ for i in range(vec.shape[0])]
53
+ return torch.tensor(np.concatenate(acts, axis=0),
54
+ device=args.device, dtype=torch.float32)
55
+ else:
56
+ assert args.checkpoint, "--checkpoint or --onnx required"
57
+ runner_cls = load_runner_cls(args.task) or MjlabOnPolicyRunner
58
+ runner = runner_cls(env, asdict(agent_cfg), device=args.device)
59
+ runner.load(args.checkpoint, load_cfg={"actor": True}, strict=True,
60
+ map_location=args.device)
61
+ policy = runner.get_inference_policy(device=args.device)
62
+
63
+ uw = env.unwrapped
64
+ robot = uw.scene["robot"]
65
+ cmd_term = uw.command_manager.get_term("twist")
66
+ dt = uw.step_dt
67
+
68
+ print(f"policy: {args.onnx or args.checkpoint}")
69
+ print(f"control dt: {dt:.4f}s envs: {args.num_envs} task: {args.task}")
70
+
71
+ def force_cmd(vx):
72
+ cmd_term.vel_command_b[:, 0] = vx
73
+ cmd_term.vel_command_b[:, 1] = 0.0
74
+ cmd_term.vel_command_b[:, 2] = 0.0
75
+ cmd_term.is_standing_env[:] = False
76
+
77
+ obs, _ = env.reset()
78
+ for vx in args.vx:
79
+ obs, _ = env.reset()
80
+ force_cmd(vx)
81
+ # 2s 加速/稳定段
82
+ for _ in range(int(2.0 / dt)):
83
+ force_cmd(vx)
84
+ actions = policy(obs)
85
+ obs, _, _, _ = env.step(actions)
86
+ x0 = robot.data.root_link_pos_w[:, 0].clone()
87
+ y0 = robot.data.root_link_pos_w[:, 1].clone()
88
+ n_meas = int(args.seconds / dt)
89
+ dones_total = 0
90
+ for _ in range(n_meas):
91
+ force_cmd(vx)
92
+ actions = policy(obs)
93
+ obs, _, dones, _ = env.step(actions)
94
+ dones_total += int(dones.sum())
95
+ x1 = robot.data.root_link_pos_w[:, 0]
96
+ y1 = robot.data.root_link_pos_w[:, 1]
97
+ dist = torch.hypot(x1 - x0, y1 - y0)
98
+ speed = dist / args.seconds
99
+ alive = (dones_total == 0)
100
+ print(f"cmd={vx:4.1f} 实测 speed mean={speed.mean():.3f} "
101
+ f"p10={speed.quantile(0.1):.3f} p90={speed.quantile(0.9):.3f} "
102
+ f"max={speed.max():.3f} m/s resets={dones_total}")
103
+
104
+ env.close()
105
+
106
+
107
+ if __name__ == "__main__":
108
+ main()
src/mjlab_microduck/tasks/mdp.py ADDED
The diff for this file is too large to render. See raw diff
 
src/mjlab_microduck/tasks/microduck_running_env_cfg.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Forward-running task for Microduck.
2
+
3
+ This deliberately starts from the proven velocity environment so the 61D
4
+ observation contract, BAM actuator model, delays, noise, and sim2real domain
5
+ randomization remain identical to the walking policy family. The task is
6
+ forward-only and rewards measured progress beyond ordinary walking speed.
7
+
8
+ Two registered recipes use this file:
9
+
10
+ * ``Running`` optimizes forward progress without prescribing a gait.
11
+ * ``RunningFlight`` adds a small reward only when a controlled flight phase
12
+ begins. It does not pay for airtime, so a long ballistic fall is not useful.
13
+ """
14
+
15
+ import math
16
+ import os
17
+ from copy import deepcopy
18
+
19
+ from mjlab.managers import (
20
+ CurriculumTermCfg,
21
+ EventTermCfg,
22
+ RewardTermCfg,
23
+ SceneEntityCfg,
24
+ )
25
+ from mjlab.tasks.velocity import mdp as velocity_mdp
26
+
27
+ from mjlab_microduck.tasks import mdp as microduck_mdp
28
+ from mjlab_microduck.tasks.microduck_velocity_env_cfg import (
29
+ NUM_STEPS_PER_ENV,
30
+ VELOCITY_PUSH_INTERVAL_S,
31
+ MicroduckRlCfg,
32
+ make_microduck_velocity_env_cfg,
33
+ )
34
+ from mjlab_microduck.tasks.symmetry import SYMMETRY_CFG
35
+
36
+ RUNNING_PLAY_SPEED = 1.0
37
+ RUNNING_SPEED_CAP = float(os.environ.get("MICRODUCK_RUNNING_SPEED_CAP", "1.4"))
38
+ RUNNING_TARGET_MAX_SPEED = float(
39
+ os.environ.get("MICRODUCK_RUNNING_TARGET_MAX_SPEED", "1.2")
40
+ )
41
+ RUNNING_FINAL_ACTION_RATE_WEIGHT = float(
42
+ os.environ.get("MICRODUCK_RUNNING_ACTION_RATE_WEIGHT", "-0.10")
43
+ )
44
+ RUNNING_FORWARD_PROGRESS_WEIGHT = float(
45
+ os.environ.get("MICRODUCK_RUNNING_FORWARD_PROGRESS_WEIGHT", "5.0")
46
+ )
47
+ RUNNING_HIGH_SPEED_STAGE_INTERVAL = int(
48
+ os.environ.get("MICRODUCK_RUNNING_HIGH_SPEED_STAGE_INTERVAL", "500")
49
+ )
50
+ RUNNING_HIGH_SPEED_STEP = float(
51
+ os.environ.get("MICRODUCK_RUNNING_HIGH_SPEED_STEP", "0.15")
52
+ )
53
+ RUNNING_FLIGHT_REWARD_WEIGHT = float(
54
+ os.environ.get("MICRODUCK_RUNNING_FLIGHT_WEIGHT", "0.0")
55
+ )
56
+ RUNNING_GATE_PERFORMANCE = os.environ.get("MICRODUCK_RUNNING_GATE_PERFORMANCE") == "1"
57
+ RUNNING_ENABLE_SYMMETRY = os.environ.get("MICRODUCK_RUNNING_ENABLE_SYMMETRY") == "1"
58
+ RUNNING_ENABLE_HEADING_FEEDBACK = (
59
+ os.environ.get("MICRODUCK_RUNNING_ENABLE_HEADING_FEEDBACK") == "1"
60
+ )
61
+ RUNNING_CADENCE_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_CADENCE_WEIGHT", "0.0"))
62
+ RUNNING_FAST_TOUCH_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_FAST_TOUCH_WEIGHT", "0.0"))
63
+ RUNNING_EXPLOSIVE_ACCEL_WEIGHT = float(os.environ.get("MICRODUCK_RUNNING_EXPLOSIVE_ACCEL_WEIGHT", "0.0"))
64
+ RUNNING_STANDING_FRACTION = 0.03
65
+ RUNNING_CURRICULUM_START_ITERATION = int(
66
+ os.environ.get("MICRODUCK_RUNNING_CURRICULUM_START_ITERATION", "0")
67
+ )
68
+ RUNNING_ROBUST_PUSH_MPS = float(
69
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_MPS", "0")
70
+ )
71
+ RUNNING_ROBUST_TRUNK_COM_M = float(
72
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_TRUNK_COM_M", "0")
73
+ )
74
+ RUNNING_ROBUST_HEAD_COM_M = float(
75
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_HEAD_COM_M", "0")
76
+ )
77
+ RUNNING_ROBUST_INITIAL_TILT_DEG = float(
78
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_INITIAL_TILT_DEG", "0")
79
+ )
80
+ RUNNING_ROBUST_FRICTION_MIN = float(
81
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_FRICTION_MIN", "0.7")
82
+ )
83
+ RUNNING_ROBUST_FRICTION_MAX = float(
84
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_FRICTION_MAX", "1.3")
85
+ )
86
+ RUNNING_ROBUST_PUSH_INTERVAL_MIN_S = float(
87
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_INTERVAL_MIN_S", "3.0")
88
+ )
89
+ RUNNING_ROBUST_PUSH_INTERVAL_MAX_S = float(
90
+ os.environ.get("MICRODUCK_RUNNING_ROBUST_PUSH_INTERVAL_MAX_S", "6.0")
91
+ )
92
+
93
+ # The lower edge rises too: zero-command behavior comes from the explicit
94
+ # standing bucket, rather than consuming most running samples near zero.
95
+ _RUNNING_BASE_SPEED_STAGES = (
96
+ {"step": 0 * NUM_STEPS_PER_ENV, "min_speed": 0.20, "max_speed": 0.45},
97
+ {"step": 1000 * NUM_STEPS_PER_ENV, "min_speed": 0.30, "max_speed": 0.55},
98
+ {"step": 2000 * NUM_STEPS_PER_ENV, "min_speed": 0.40, "max_speed": 0.65},
99
+ {"step": 3000 * NUM_STEPS_PER_ENV, "min_speed": 0.50, "max_speed": 0.75},
100
+ {"step": 4000 * NUM_STEPS_PER_ENV, "min_speed": 0.60, "max_speed": 0.85},
101
+ {"step": 5000 * NUM_STEPS_PER_ENV, "min_speed": 0.70, "max_speed": 0.95},
102
+ {"step": 6000 * NUM_STEPS_PER_ENV, "min_speed": 0.90, "max_speed": 1.20},
103
+ )
104
+
105
+
106
+ def _running_speed_stages(
107
+ target_max_speed: float, high_speed_stage_interval: int = 500, high_speed_step: float = 0.15
108
+ ) -> tuple[dict, ...]:
109
+ """Return the proven base curriculum plus an optional continuation ramp."""
110
+ if target_max_speed < 1.2:
111
+ raise ValueError("running target max speed must be at least 1.2 m/s")
112
+ if high_speed_stage_interval <= 0:
113
+ raise ValueError("running high-speed stage interval must be positive")
114
+ if target_max_speed == 1.2:
115
+ return _RUNNING_BASE_SPEED_STAGES
116
+ intermediate_1 = min(1.35, target_max_speed)
117
+ intermediate_2 = min(1.50, target_max_speed)
118
+ intermediate_3 = min(1.65, target_max_speed)
119
+ stages = _RUNNING_BASE_SPEED_STAGES + (
120
+ {
121
+ "step": 6750 * NUM_STEPS_PER_ENV,
122
+ "min_speed": 0.95,
123
+ "max_speed": intermediate_1,
124
+ },
125
+ {
126
+ "step": 7250 * NUM_STEPS_PER_ENV,
127
+ "min_speed": 1.05,
128
+ "max_speed": intermediate_2,
129
+ },
130
+ {
131
+ "step": 7750 * NUM_STEPS_PER_ENV,
132
+ "min_speed": 1.15,
133
+ "max_speed": intermediate_3,
134
+ },
135
+ )
136
+
137
+ # Checkpoint 8,749 has consolidated the 1.65 m/s slice. Targets above
138
+ # that must arrive as new post-checkpoint stages; replacing the 7,750
139
+ # target would make a resumed run jump immediately to its final command.
140
+ if target_max_speed <= 1.65:
141
+ return stages
142
+
143
+ high_speed_targets: list[float] = []
144
+ next_target = 1.80
145
+ while next_target < target_max_speed - 1e-9:
146
+ high_speed_targets.append(round(next_target, 2))
147
+ next_target += high_speed_step
148
+ high_speed_targets.append(target_max_speed)
149
+
150
+ return stages + tuple(
151
+ {
152
+ "step": (8750 + index * high_speed_stage_interval)
153
+ * NUM_STEPS_PER_ENV,
154
+ "min_speed": round(max(1.25, max_speed - 0.55), 2),
155
+ "max_speed": max_speed,
156
+ }
157
+ for index, max_speed in enumerate(high_speed_targets)
158
+ )
159
+
160
+
161
+ RUNNING_SPEED_STAGES = _running_speed_stages(
162
+ RUNNING_TARGET_MAX_SPEED, RUNNING_HIGH_SPEED_STAGE_INTERVAL, RUNNING_HIGH_SPEED_STEP
163
+ )
164
+
165
+
166
+ def _apply_running_robustness(
167
+ cfg,
168
+ *,
169
+ push_mps: float,
170
+ trunk_com_m: float,
171
+ head_com_m: float,
172
+ initial_tilt_deg: float,
173
+ friction_range: tuple[float, float],
174
+ push_interval_s: tuple[float, float] = VELOCITY_PUSH_INTERVAL_S,
175
+ ) -> None:
176
+ """Apply one fixed continuation stage without resume-step ambiguity."""
177
+ values = (push_mps, trunk_com_m, head_com_m, initial_tilt_deg)
178
+ if any(value < 0.0 for value in values):
179
+ raise ValueError("running robustness magnitudes must be non-negative")
180
+ if not 0.0 < friction_range[0] <= friction_range[1]:
181
+ raise ValueError("running robustness friction range must be positive and ordered")
182
+ if not 0.0 < push_interval_s[0] <= push_interval_s[1]:
183
+ raise ValueError("running robustness push interval must be positive and ordered")
184
+
185
+ cfg.events["foot_friction"].params["ranges"] = friction_range
186
+ if push_mps > 0.0:
187
+ velocity_range = {
188
+ "x": (-push_mps, push_mps),
189
+ "y": (-push_mps, push_mps),
190
+ }
191
+ if "push_robot" in cfg.events:
192
+ cfg.events["push_robot"].params["velocity_range"] = velocity_range
193
+ cfg.events["push_robot"].interval_range_s = push_interval_s
194
+ else:
195
+ cfg.events["push_robot"] = EventTermCfg(
196
+ func=velocity_mdp.push_by_setting_velocity,
197
+ mode="interval",
198
+ interval_range_s=push_interval_s,
199
+ params={
200
+ "velocity_range": velocity_range,
201
+ "asset_cfg": SceneEntityCfg("robot"),
202
+ },
203
+ )
204
+ else:
205
+ cfg.events.pop("push_robot", None)
206
+
207
+ if trunk_com_m > 0.0:
208
+ cfg.events["randomize_com"].params["ranges"] = (
209
+ -trunk_com_m,
210
+ trunk_com_m,
211
+ )
212
+ if head_com_m > 0.0:
213
+ cfg.events["randomize_head_com"].params["ranges"] = (
214
+ -head_com_m,
215
+ head_com_m,
216
+ )
217
+ if initial_tilt_deg > 0.0:
218
+ cfg.events["randomize_base_orientation"] = EventTermCfg(
219
+ func=microduck_mdp.randomize_base_orientation,
220
+ mode="reset",
221
+ params={
222
+ "asset_cfg": SceneEntityCfg("robot"),
223
+ "max_pitch_deg": initial_tilt_deg,
224
+ "max_roll_deg": initial_tilt_deg,
225
+ },
226
+ )
227
+
228
+
229
+ def make_microduck_running_env_cfg(
230
+ play: bool = False, flight_reward_weight: float = RUNNING_FLIGHT_REWARD_WEIGHT
231
+ ):
232
+ """Build the flat-ground, forward-only running environment."""
233
+ cfg = make_microduck_velocity_env_cfg(play=play, rough=False)
234
+ cfg.episode_length_s = 12.0
235
+ # Microduck is only ~25 cm tall; the velocity recipe's 3 m follow camera
236
+ # hides gait and contact details in rollout videos.
237
+ cfg.viewer.distance = 0.55
238
+ cfg.viewer.max_extra_envs = 0
239
+
240
+ command = cfg.commands["twist"]
241
+ command.rel_standing_envs = 0.0 if play else RUNNING_STANDING_FRACTION
242
+ command.rel_heading_envs = 0.0
243
+ command.rel_turn_in_place_envs = 0.0
244
+ command.resampling_time_range = (12.0, 12.0)
245
+ if play:
246
+ command.ranges.lin_vel_x = (RUNNING_PLAY_SPEED, RUNNING_PLAY_SPEED)
247
+ command.ranges.lin_vel_y = (0.0, 0.0)
248
+ command.ranges.ang_vel_z = (0.0, 0.0)
249
+ else:
250
+ command.ranges.lin_vel_x = (
251
+ RUNNING_SPEED_STAGES[0]["min_speed"],
252
+ RUNNING_SPEED_STAGES[0]["max_speed"],
253
+ )
254
+ # Tiny non-zero lateral/yaw ranges keep those command neurons alive,
255
+ # while still making essentially every active sample forward-running.
256
+ command.ranges.lin_vel_y = (-0.02, 0.02)
257
+ command.ranges.ang_vel_z = (-0.05, 0.05)
258
+
259
+ if RUNNING_ENABLE_HEADING_FEEDBACK:
260
+ # Slot 2 becomes signed spawn-heading error, recomputed every step. It
261
+ # remains in the same position and mirrors with the same sign rule as a
262
+ # yaw-rate command, so the deployment observation contract is unchanged.
263
+ command.heading_command = False
264
+ command.rel_heading_envs = 0.0
265
+ command.ranges.heading = None
266
+ command.ranges.ang_vel_z = (0.0, 0.0)
267
+ command = microduck_mdp.SpawnHeadingVelocityCommandCfg(
268
+ **vars(command), heading_error_clip=1.0
269
+ )
270
+ cfg.commands["twist"] = command
271
+
272
+ # Max-speed objective. Velocity tracking remains useful as a curriculum
273
+ # guide, but forward progress is strong enough that exceeding the command is
274
+ # profitable. This is what makes the optimum "as fast as possible".
275
+ cfg.rewards["forward_progress"] = RewardTermCfg(
276
+ func=microduck_mdp.running_forward_progress,
277
+ weight=RUNNING_FORWARD_PROGRESS_WEIGHT,
278
+ params={"speed_cap": RUNNING_SPEED_CAP},
279
+ )
280
+ cfg.rewards["track_linear_velocity"].weight = 2.0
281
+ cfg.rewards["track_linear_velocity"].params["std"] = math.sqrt(0.15)
282
+ cfg.rewards["track_angular_velocity"].weight = 0.5
283
+ cfg.rewards["track_angular_velocity"].params["std"] = math.sqrt(0.5)
284
+ cfg.rewards["planar_drift"] = RewardTermCfg(
285
+ func=microduck_mdp.running_planar_drift_cost,
286
+ weight=-0.05,
287
+ params={"command_name": "twist", "lateral_weight": 4.0},
288
+ )
289
+ # Rate tracking alone can only say "stop turning"; once a heading error
290
+ # exists it provides no signal for which way to steer back. Anchor yaw to
291
+ # each randomized spawn heading so straight running is the actual optimum.
292
+ cfg.rewards["heading_hold"] = RewardTermCfg(
293
+ func=microduck_mdp.heading_hold_reward,
294
+ weight=1.5,
295
+ params={"std": 0.4, "asset_cfg": SceneEntityCfg("robot")},
296
+ )
297
+
298
+ # Permit the forward lean and fast leg cycling a sprint needs. These stay
299
+ # non-zero only to rule out tumbling and unbounded thrash as cheap optima.
300
+ cfg.rewards["pose"].weight = 0.15
301
+ cfg.rewards["upright"].weight = 0.75
302
+ cfg.rewards["upright"].params["std"] = math.sqrt(0.15)
303
+ cfg.rewards["body_ang_vel"].weight = -0.01
304
+ cfg.rewards["angular_momentum"].weight = -0.005
305
+ cfg.rewards["action_rate_l2"].weight = -0.02
306
+ cfg.rewards["foot_slip"].weight = -0.05
307
+ cfg.rewards["air_time"].weight = 0.0
308
+ cfg.rewards["foot_clearance"].params["target_height"] = 0.015
309
+ cfg.rewards["foot_swing_height"].params["target_height"] = 0.015
310
+
311
+ # Head/body slots remain present and sample tiny non-zero ranges, preserving
312
+ # the 61D hot-swap contract, but posture precision must not block discovery.
313
+ cfg.rewards["head_pose_tracking"].weight = 0.0
314
+ cfg.rewards["head_pose_bias"].weight = 0.0
315
+ cfg.rewards["body_pose_tracking"].weight = 0.0
316
+
317
+ if flight_reward_weight > 0.0:
318
+ cfg.rewards["flight_event"] = RewardTermCfg(
319
+ func=microduck_mdp.running_flight_event,
320
+ weight=flight_reward_weight,
321
+ params={
322
+ "sensor_name": "feet_ground_contact",
323
+ "min_forward_speed": 0.30,
324
+ "max_tilt_deg": 50.0,
325
+ "min_airborne_steps": 3,
326
+ },
327
+ )
328
+
329
+ # Su-Bingtian-style sprint levers for a small biped (cadence + stiff/fast
330
+ # ground touch + explosive low-speed acceleration). All off by default;
331
+ # enable via env knobs so they can be tuned without code edits.
332
+ if RUNNING_CADENCE_WEIGHT != 0.0:
333
+ cfg.rewards["running_cadence"] = RewardTermCfg(
334
+ func=microduck_mdp.running_cadence,
335
+ weight=RUNNING_CADENCE_WEIGHT,
336
+ params={"sensor_name": "feet_ground_contact"},
337
+ )
338
+ if RUNNING_FAST_TOUCH_WEIGHT != 0.0:
339
+ cfg.rewards["running_fast_ground_touch"] = RewardTermCfg(
340
+ func=microduck_mdp.running_fast_ground_touch,
341
+ weight=RUNNING_FAST_TOUCH_WEIGHT,
342
+ params={"sensor_name": "feet_ground_contact"},
343
+ )
344
+ if RUNNING_EXPLOSIVE_ACCEL_WEIGHT != 0.0:
345
+ cfg.rewards["running_explosive_accel"] = RewardTermCfg(
346
+ func=microduck_mdp.running_explosive_accel,
347
+ weight=RUNNING_EXPLOSIVE_ACCEL_WEIGHT,
348
+ params={"command_name": "twist", "speed_cap": RUNNING_SPEED_CAP},
349
+ )
350
+
351
+ # A clean speed-discovery phase: no random velocity kicks and no scheduled
352
+ # widening of CoM/head commands or precision taxes. Fixed initial DR stays
353
+ # active, so the result is not a deterministic-sim-only policy.
354
+ _apply_running_robustness(
355
+ cfg,
356
+ push_mps=RUNNING_ROBUST_PUSH_MPS,
357
+ trunk_com_m=RUNNING_ROBUST_TRUNK_COM_M,
358
+ head_com_m=RUNNING_ROBUST_HEAD_COM_M,
359
+ initial_tilt_deg=RUNNING_ROBUST_INITIAL_TILT_DEG,
360
+ friction_range=(
361
+ RUNNING_ROBUST_FRICTION_MIN,
362
+ RUNNING_ROBUST_FRICTION_MAX,
363
+ ),
364
+ push_interval_s=(
365
+ RUNNING_ROBUST_PUSH_INTERVAL_MIN_S,
366
+ RUNNING_ROBUST_PUSH_INTERVAL_MAX_S,
367
+ ),
368
+ )
369
+ for name in (
370
+ "standing_envs",
371
+ "head_pose_range",
372
+ "body_pose_range",
373
+ "com_range",
374
+ "head_com_range",
375
+ "head_pose_bias_weight",
376
+ ):
377
+ cfg.curriculum.pop(name, None)
378
+
379
+ if not play:
380
+ cfg.curriculum["running_speed_range"] = CurriculumTermCfg(
381
+ func=microduck_mdp.running_command_ranges_curriculum,
382
+ params={
383
+ "command_name": "twist",
384
+ "gate_on_performance": RUNNING_GATE_PERFORMANCE,
385
+ "gate_margin": 0.02,
386
+ "gate_min_steps": 500,
387
+ "speed_stages": [
388
+ {
389
+ **stage,
390
+ "step": stage["step"]
391
+ - RUNNING_CURRICULUM_START_ITERATION * NUM_STEPS_PER_ENV,
392
+ }
393
+ for stage in RUNNING_SPEED_STAGES
394
+ ],
395
+ },
396
+ )
397
+ # Smoothness is introduced only after a fast gait exists.
398
+ cfg.curriculum["action_rate_weight"] = CurriculumTermCfg(
399
+ func=microduck_mdp.reward_weight,
400
+ params={
401
+ "reward_name": "action_rate_l2",
402
+ "weight_stages": [
403
+ {
404
+ "step": (0 - RUNNING_CURRICULUM_START_ITERATION)
405
+ * NUM_STEPS_PER_ENV,
406
+ "weight": -0.02,
407
+ },
408
+ {
409
+ "step": (2500 - RUNNING_CURRICULUM_START_ITERATION)
410
+ * NUM_STEPS_PER_ENV,
411
+ "weight": -0.05,
412
+ },
413
+ {
414
+ "step": (4000 - RUNNING_CURRICULUM_START_ITERATION)
415
+ * NUM_STEPS_PER_ENV,
416
+ "weight": RUNNING_FINAL_ACTION_RATE_WEIGHT,
417
+ },
418
+ {
419
+ "step": (5500 - RUNNING_CURRICULUM_START_ITERATION)
420
+ * NUM_STEPS_PER_ENV,
421
+ "weight": RUNNING_FINAL_ACTION_RATE_WEIGHT,
422
+ },
423
+ ],
424
+ },
425
+ )
426
+ else:
427
+ cfg.curriculum.pop("action_rate_weight", None)
428
+
429
+ return cfg
430
+
431
+
432
+ MicroduckRunningRlCfg = deepcopy(MicroduckRlCfg)
433
+ MicroduckRunningRlCfg.experiment_name = "running"
434
+ MicroduckRunningRlCfg.run_name = "running-max-speed"
435
+ MicroduckRunningRlCfg.algorithm.entropy_coef = 0.02
436
+ MicroduckRunningRlCfg.algorithm.symmetry_cfg = (
437
+ deepcopy(SYMMETRY_CFG) if RUNNING_ENABLE_SYMMETRY else None
438
+ )
439
+ MicroduckRunningRlCfg.max_iterations = 7_500
440
+
441
+ MicroduckRunningFlightRlCfg = deepcopy(MicroduckRunningRlCfg)
442
+ MicroduckRunningFlightRlCfg.experiment_name = "running_flight"
443
+ MicroduckRunningFlightRlCfg.run_name = "running-flight-event"