Instructions to use JackDH/microduck-rolling-start with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Microduck
How to use JackDH/microduck-rolling-start with Microduck:
# Replace SLOT with the slot specified in the model card (walk, stand, sitstand, ground_pick, kick_left, kick_right, roulade). sudo robotctl policy load SLOT JackDH/microduck-rolling-start
- Notebooks
- Google Colab
- Kaggle
Download training/learn.py from JackDH/microduck-rolling-start: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/JackDH/microduck-rolling-start/resolve/main/training/learn.py
- Command line
-
hf download hf://JackDH/microduck-rolling-start/training/learn.py
-
curl -L -o learn.py https://huggingface.co/JackDH/microduck-rolling-start/resolve/main/training/learn.py
11 kB
| """Episode-return policy search for the head-to-wheel recovery on the Arena's roller rig.""" | |
| import functools | |
| import hashlib | |
| import json | |
| import multiprocessing as mp | |
| import os | |
| from pathlib import Path | |
| import numpy as np | |
| from arena import runner | |
| from arena.course import compile_stage | |
| from arena.manifest import load_event | |
| from arena.policy import load_policy | |
| from arena.timeline import Start, Timeline | |
| OUT = Path(__file__).resolve().parent | |
| RIG = "rollers" | |
| EVENT = load_event("got-a-talent") | |
| START = Start(x=0, y=-1.45, yaw_deg=90) | |
| BASE_MODELS = { | |
| "skate": ( | |
| "JackDH/microduck-roller-sprint-20261110-best", | |
| "43e9b57825fbfe08cf4b510368b643ff9ae47d93", | |
| "policy.onnx", | |
| "0b724f5cebe7f50815f6dd9631009e47c4b0a607f81b22d0f4048ffd6d5a77d6", | |
| ), | |
| "roll": ( | |
| "pollen-robotics/microduck-policies", | |
| "1b56c396825c052a4e26e95cf2b8d8298af9e9b4", | |
| "roulade.onnx", | |
| "3d60da08fc13f29c1b57f41977aa898132c0d60042100149d8e775affcbca32b", | |
| ), | |
| "rise": ( | |
| "pollen-robotics/microduck-policies", | |
| "1b56c396825c052a4e26e95cf2b8d8298af9e9b4", | |
| "velstand.onnx", | |
| "1c659be55da94bc5753b707de5c6a3e7c49931e05ca3b6991615cef1a8ba9a45", | |
| ), | |
| } | |
| def model_file(name): | |
| repo, revision, filename, digest = BASE_MODELS[name] | |
| path = None | |
| if project := os.environ.get("MICRODUCK_PROJECT"): | |
| rel = ( | |
| "challenges/publish-roller-best-20261110/policy.onnx" | |
| if name == "skate" | |
| else f"policies/official/{filename}" | |
| ) | |
| path = Path(project) / rel | |
| if path is None or not path.is_file(): | |
| from huggingface_hub import hf_hub_download | |
| path = Path(hf_hub_download(repo, filename, revision=revision)) | |
| if hashlib.sha256(path.read_bytes()).hexdigest() != digest: | |
| raise ValueError(f"Unexpected contents for {name} policy") | |
| return path | |
| # The timeline gives the coarse cues. The network reads gravity and angular velocity to adjust its leg action. | |
| TIMES = [0, 0.2, 1.37, 1.55, 1.87, 2.33, 2.85, 3.15] | |
| MODES = [0, 0, 1, 2, 3, 4, 5, 0] | |
| SPEEDS = [0, 0.32, 0, 0, 0, 0.54, 0.54, 0.65] | |
| DURATION = 5.16 | |
| def timeline_dict(): | |
| frames = [ | |
| {"t": t, "twist": [v, 0, 0], "body": [0, 0, 0, 0, 0, m]} | |
| for t, v, m in zip(TIMES, SPEEDS, MODES) | |
| ] | |
| return { | |
| "timeline_version": 2, | |
| "duration_s": DURATION, | |
| "ducks": [{"policy": "self", "start": START.to_dict(), "keyframes": frames}], | |
| } | |
| TIMELINE = Timeline( | |
| DURATION, | |
| tuple(TIMES), | |
| tuple((v,) + (0.0,) * 11 + (m,) for v, m in zip(SPEEDS, MODES)), | |
| ) | |
| def actor(name): | |
| p = load_policy(model_file(name)) | |
| return p.session, p._input_name, p._output_name | |
| def actions(name, x): | |
| sess, i, o = actor(name) | |
| return sess.run([o], {i: x.reshape(1, 61)})[0][0] | |
| # Symmetric sagittal legs, neck/head, and a lateral hip balance response. | |
| MAP = np.zeros((6, 14), np.float32) | |
| for n, (l, r) in enumerate([(2, 11), (3, 12), (4, 13)]): | |
| MAP[n, l] = 1 | |
| MAP[n, r] = -1 | |
| MAP[3, 5] = 1 | |
| MAP[4, 6] = 1 | |
| MAP[5, [1, 10]] = [1, -1] | |
| SHAPE = (4, 6) | |
| SIZE = 32 | |
| def configure(speed=0.32, entry=1.37): | |
| global TIMES, SPEEDS, DURATION, TIMELINE | |
| offset = entry - 1.37 | |
| TIMES = [0, 0.2] + [ | |
| round(t + offset, 2) for t in (1.37, 1.55, 1.87, 2.33, 2.85, 3.15) | |
| ] | |
| SPEEDS = [0, speed, 0, 0, 0, 0.54, 0.54, 0.65] | |
| DURATION = round(5.16 + offset, 2) | |
| TIMELINE = Timeline( | |
| DURATION, | |
| tuple(TIMES), | |
| tuple((v,) + (0.0,) * 11 + (m,) for v, m in zip(SPEEDS, MODES)), | |
| ) | |
| class Landing: | |
| def __init__(self, theta): | |
| self.p = np.asarray(theta, np.float32) | |
| self.steps = self.p[:24].reshape(SHAPE) | |
| def act(self, obs): | |
| mode = int(obs[-1] + 0.5) | |
| x = obs.copy() | |
| x[49:] = 0 | |
| if mode == 0: | |
| return actions("skate", x) | |
| if mode <= 3: | |
| x[34:48] /= np.float32(1.12) | |
| x[48] = 0 | |
| base = actions("roll", x) * np.float32(1.12) | |
| else: | |
| base = actions("rise", x) | |
| if mode == 1: | |
| return base | |
| residual = self.steps[mode - 2].copy() | |
| # The last eight parameters adapt the joints to pitch, roll and their velocities. | |
| pitch_vel = np.clip(obs[1] * np.float32(0.14), -1, 1) | |
| residual[:3] += self.p[24:27] * obs[3] + self.p[27:30] * pitch_vel | |
| residual[5] += self.p[30] * obs[4] + self.p[31] * np.clip( | |
| obs[0] * np.float32(0.15), -1, 1 | |
| ) | |
| return base + np.tanh(residual) @ MAP | |
| # Compile each stage once per process. Each new game resets friction/delay and carries the original physics. | |
| def stage(): | |
| return compile_stage(EVENT, [(RIG, START)]) | |
| def run(policy, seed): | |
| model = stage() | |
| sim, settle, _ = runner._start( | |
| EVENT, | |
| model, | |
| [policy], | |
| [START.yaw_deg], | |
| int(seed), | |
| [np.asarray(TIMELINE.command_at(0))], | |
| ) | |
| trace = np.empty((round(DURATION * 50), 21), np.float32) | |
| for i in range(len(trace)): | |
| sim.step([np.asarray(TIMELINE.command_at(i * 0.02))]) | |
| sim.record(trace[i : i + 1]) | |
| if runner._reset_by_mujoco(sim.data): | |
| raise RuntimeError("Diverged") | |
| return trace, settle | |
| def metrics(tr): | |
| w, x, y, z = tr[:, 3:7].T | |
| tilt = np.degrees(np.arccos(np.clip(1 - 2 * (x * x + y * y), -1, 1))) | |
| pitch = np.degrees(np.unwrap(np.arctan2(2 * (w * y - x * z), 2 * (x * y + w * z)))) | |
| start = int(np.ceil(TIMES[2] * 50 - 1e-8)) - 1 | |
| angle = pitch - np.median(pitch[start - 8 : start]) | |
| theta = float(np.median(angle[-25:])) | |
| h = tr[:, 2] | |
| pos = tr[:, 1] | |
| upright = (tilt < 35) & (h > 0.085) | |
| inv = float(tilt[start:].max()) | |
| glide = float(pos[-1] - pos[-51]) | |
| inside = bool((abs(tr[:, 0]) < 1.28).all() and (abs(pos) < 1.87).all()) | |
| success = bool( | |
| 310 < theta < 405 | |
| and inv > 150 | |
| and np.all(upright[-45:]) | |
| and glide > 0.09 | |
| and inside | |
| and pos[start] - pos[start - 25] > 0.035 | |
| ) | |
| # Reward partial catches continuously and reserve the completion bonus for a genuine roll and rollout. | |
| turn = np.exp(-(((angle - 360) / 82) ** 2)) | |
| orient = np.clip((1 + 1 - 2 * (x * x + y * y)) / 2, 0, 1) | |
| height = np.clip((h - 0.043) / 0.073, 0, 1) | |
| alignment = np.clip(2 * (x * y + w * z), 0, 1) | |
| quality = turn * orient * height * (0.45 + 0.55 * alignment) | |
| reward = ( | |
| 0.9 * np.clip((inv - 100) / 70, 0, 1) | |
| + 1.3 * quality[start + 50 : start + 100].mean() | |
| + 3.1 * quality[start + 100 :].mean() | |
| + 1.4 * np.clip(glide / 0.36, -0.5, 1) * quality[-50:].mean() | |
| + 2.5 * success | |
| ) | |
| good = np.flatnonzero(~upright[start:]) | |
| recovery = round((int(good[-1]) + 1) * 0.02, 2) if len(good) and success else None | |
| return { | |
| "return": round(float(reward), 6), | |
| "success": success, | |
| "turn_deg": round(theta, 1), | |
| "max_tilt": round(inv, 1), | |
| "entry_mps": round(float((pos[start] - pos[start - 25]) / 0.5), 3), | |
| "entry_distance_m": round(float(pos[start] - pos[0]), 3), | |
| "final_m": round(glide, 3), | |
| "recovery_s": recovery, | |
| "inside": inside, | |
| } | |
| def test_job(args): | |
| theta, seed = args | |
| tr, _ = run(Landing(theta), seed) | |
| return metrics(tr) | |
| def calc(pool, models, seeds): | |
| res = list( | |
| pool.imap(test_job, [(v, s) for v in models for s in seeds], chunksize=2) | |
| ) | |
| return [res[i : i + len(seeds)] for i in range(0, len(res), len(seeds))] | |
| def save(path, obj): | |
| tmp = path.with_suffix(".tmp") | |
| tmp.write_text(json.dumps(obj, indent=2) + "\n") | |
| tmp.replace(path) | |
| def train( | |
| steps, | |
| pop, | |
| batch, | |
| warm=None, | |
| run_dir=OUT, | |
| random_seed=11062026, | |
| validation=None, | |
| search_scale=0.62, | |
| floor=0.075, | |
| workers=16, | |
| ): | |
| rng = np.random.default_rng(random_seed) | |
| mean = np.zeros(SIZE) | |
| sig = np.full(SIZE, 0.33) | |
| sig[24:] = 0.22 | |
| best = [] | |
| hist = [] | |
| if warm is not None: | |
| mean = np.asarray(warm) | |
| sig *= search_scale | |
| with mp.Pool( | |
| workers, initializer=configure, initargs=(SPEEDS[1], TIMES[2]) | |
| ) as pool: | |
| validation = list(range(31, 43)) if validation is None else list(validation) | |
| baseline = calc(pool, [mean], validation)[0] | |
| print( | |
| "Baseline validation:", | |
| sum(r["success"] for r in baseline), | |
| "/", | |
| len(baseline), | |
| flush=True, | |
| ) | |
| for gen in range(steps): | |
| first = rng.normal(size=(pop // 2, SIZE)) | |
| eps = np.concatenate((first, -first)) | |
| candidates = np.clip(mean + sig * eps, -2.8, 2.8) | |
| candidates[0] = mean | |
| seeds = list( | |
| map(int, rng.choice(np.arange(80, 600), size=batch, replace=False)) | |
| ) | |
| results = calc(pool, candidates, seeds) | |
| returns = np.array([np.mean([a["return"] for a in rr]) for rr in results]) | |
| elite = candidates[np.argsort(returns)[-max(5, pop // 6) :]] | |
| mean = 0.27 * mean + 0.73 * np.mean(elite, axis=0) | |
| sig = np.maximum(floor, 0.62 * sig + 0.38 * np.std(elite, axis=0)) | |
| leaders = [mean, candidates[np.argmax(returns)]] | |
| if (gen + 1) % 2 == 0 or gen == 0: | |
| trials = calc(pool, leaders, [*validation, 1]) | |
| for model, rr in zip(leaders, trials): | |
| item = { | |
| "gen": gen + 1, | |
| "theta": model.tolist(), | |
| "validation_success": sum(q["success"] for q in rr[:-1]), | |
| "official_pass": rr[-1]["success"], | |
| "validation_return": float( | |
| np.mean([q["return"] for q in rr[:-1]]) | |
| ), | |
| } | |
| best.append(item) | |
| best.sort( | |
| key=lambda o: (o["validation_success"], o["validation_return"]), | |
| reverse=True, | |
| ) | |
| best = best[:5] + [o for o in best[5:] if o["official_pass"]][:3] | |
| save(run_dir / "checkpoints.json", best) | |
| hist.append( | |
| { | |
| "generation": gen + 1, | |
| "training_seeds": seeds, | |
| "mean_return": float(returns.mean()), | |
| "best_training_return": float(returns.max()), | |
| } | |
| ) | |
| save(run_dir / "learning.json", hist) | |
| print( | |
| "Generation", | |
| gen + 1, | |
| "best validation", | |
| best[0]["validation_success"], | |
| "/", | |
| len(validation), | |
| "training return", | |
| round(returns.max(), 2), | |
| flush=True, | |
| ) | |