File size: 4,948 Bytes
da839c2
5dbd0bf
 
 
 
 
 
 
 
da839c2
 
5dbd0bf
da839c2
 
 
5dbd0bf
 
 
 
 
 
 
da839c2
5dbd0bf
 
 
fab4b5b
 
225dc91
5dbd0bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da839c2
5dbd0bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225dc91
5dbd0bf
 
225dc91
5dbd0bf
 
 
 
 
 
04ad412
5dbd0bf
6772038
5dbd0bf
 
 
 
 
da839c2
 
 
 
 
 
5dbd0bf
 
 
 
 
225dc91
5dbd0bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225dc91
 
5dbd0bf
 
 
 
 
 
 
1a36c6b
a7dddb6
 
 
1a36c6b
 
5dbd0bf
1a36c6b
5dbd0bf
1a36c6b
 
 
5dbd0bf
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
"""Simulation job: run one validated scene and upload the results to R2 (Modal or RunPod).

Job input (sent by POST /simulate/run)::

    {"run_id": "...", "mjcf": "<mujoco>...</mujoco>", "robot": "unitree_g1" | null,
     "duration_s": 10, "controller": "hold" | "passive"}

Output::

    {"run_id": ..., "summary": {...}, "files": {"mcap": "runs/<id>/run.mcap", "video": ..., ...},
     "worker_seconds": 41.2}

Entry points: ``handler`` for RunPod (``python -m worker.handler`` in worker/Dockerfile) and
``run_simulation`` in worker/modal_app.py for Modal. Environment on the worker: R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, MENAGERIE_DIR (baked into the image), MUJOCO_GL=egl.
"""

from __future__ import annotations

import os
import shutil
import tempfile
import time
import traceback
from pathlib import Path

from api.mjcf_check import SCENE_FILE, prepare_scene_dir, static_check
from api.storage import LocalStorage, R2Config, R2Storage, Storage, run_prefix
from worker.sim_runner import RunConfig, config_for, simulate

MAX_DURATION_S = 60.0


def _storage() -> Storage:
    config = R2Config.from_env()
    if config:
        return R2Storage(config)
    local = os.environ.get("ROSDIFF_LOCAL_STORAGE")
    if local:
        return LocalStorage(Path(local))
    raise RuntimeError("R2 is not configured (R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET)")


def run_job(
    job_input: dict, storage: Storage | None = None, menagerie_dir: Path | None = None, render: bool = True
) -> dict:
    started = time.monotonic()
    run_id = str(job_input["run_id"])
    if not run_id.isalnum():
        raise ValueError("run_id must be alphanumeric")
    mjcf = job_input["mjcf"]
    robot = job_input.get("robot")
    duration = min(float(job_input.get("duration_s", 10.0)), MAX_DURATION_S)
    controller = job_input.get("controller", "hold")

    # Defence in depth: the API validated this scene already.
    failed = static_check(mjcf, robot)
    if failed:
        raise ValueError(f"scene rejected: {failed.error}")

    storage = storage or _storage()
    menagerie = Path(menagerie_dir or os.environ.get("MENAGERIE_DIR", "/opt/mujoco_menagerie"))
    scene_dir = prepare_scene_dir(mjcf, robot, menagerie)
    out = Path(tempfile.mkdtemp(prefix=f"run_{run_id}_"))
    try:
        config = config_for(robot, duration, controller, render=render)
        summary = simulate(scene_dir / SCENE_FILE, out, config)
        if render and summary.get("render_error") and os.environ.get("MUJOCO_GL") == "egl":
            summary = _rerun_with_osmesa(scene_dir / SCENE_FILE, out, config, robot, summary["render_error"])
        prefix = run_prefix(run_id)
        (out / "scene.xml").write_text(mjcf)
        files = {}
        for kind, name in (
            ("mcap", "run.mcap"),
            ("video", "video.mp4"),
            ("video_webm", "video.webm"),
            ("summary", "summary.json"),
            ("trajectory", "trajectory.npz"),
            ("scene", "scene.xml"),
        ):
            if (out / name).is_file():
                storage.upload(out / name, f"{prefix}/{name}")
                files[kind] = f"{prefix}/{name}"
        return {
            "run_id": run_id,
            "summary": summary,
            "files": files,
            "worker_seconds": round(time.monotonic() - started, 1),
        }
    finally:
        shutil.rmtree(scene_dir, ignore_errors=True)
        shutil.rmtree(out, ignore_errors=True)


def _rerun_with_osmesa(scene: Path, out: Path, config: RunConfig, robot: str | None, egl_error: str) -> dict:
    """GPU (EGL) rendering failed on this machine: redo the run with CPU rendering in a fresh process."""
    import json
    import subprocess
    import sys

    env = {**os.environ, "MUJOCO_GL": "osmesa", "PYOPENGL_PLATFORM": "osmesa"}
    cmd = [
        sys.executable,
        "-m",
        "worker.sim_runner",
        str(scene),
        str(out),
        "--duration",
        str(config.duration_s),
        "--controller",
        config.controller,
        "--robot",
        robot or "",
    ]
    subprocess.run(cmd, env=env, check=False, capture_output=True, timeout=3600)
    summary = json.loads((out / "summary.json").read_text())
    summary["egl_error"] = egl_error
    return summary


def _functions() -> dict:
    from worker.sweep import run_sweep_job

    return {"run_simulation": run_job, "sweep": run_sweep_job}


def handler(job: dict) -> dict:
    """RunPod entry point. Errors are returned (not raised) so they are recorded as the job's output."""
    try:
        job_input = dict(job["input"])
        fn = _functions()[job_input.pop("function", "run_simulation")]
        return fn(job_input)
    except Exception as e:
        return {"error": f"{type(e).__name__}: {e}", "traceback": traceback.format_exc()[-2000:]}


if __name__ == "__main__":
    import runpod

    runpod.serverless.start({"handler": handler})