Import upstream snapshot d424a0c899f6b33cbd3daeb279913134349c0b63
Upstream: https://github.com/pollen-robotics/microduck_rl Upstream-Commit: d424a0c899f6b33cbd3daeb279913134349c0b63 Upstream-Branch: develop
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
"""Interactive crouch pose editor (roller robot).
|
||||
|
||||
Ouvre le viewer MuJoCo avec le robot rollers debout. Dans le panneau "Control"
|
||||
du viewer, bouge les sliders (genoux/hanches/chevilles…) pour composer la pose
|
||||
ACCROUPIE voulue. La gravité est coupée et la base est maintenue droite +
|
||||
abaissée pour que le point le plus bas reste au sol (tu vois donc le tronc
|
||||
descendre quand tu plies les genoux). À la fermeture de la fenêtre, la pose est
|
||||
imprimée en dict CROUCH_POSE {nom_articulation: angle_rad} prêt à coller.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/crouch_pose_editor.py
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import mujoco
|
||||
import mujoco.viewer
|
||||
|
||||
from mjlab_microduck.robot.microduck_constants import (
|
||||
get_walk_rollers_spec,
|
||||
HOME_FRAME,
|
||||
)
|
||||
|
||||
|
||||
def home_value(joint_name: str):
|
||||
for pattern, val in HOME_FRAME.joint_pos.items():
|
||||
if re.search(pattern, joint_name):
|
||||
return float(val)
|
||||
return 0.0
|
||||
|
||||
|
||||
# Modèle direct depuis le spec du robot (14 actionneurs <position> dans le XML).
|
||||
model = get_walk_rollers_spec().compile()
|
||||
data = mujoco.MjData(model)
|
||||
mujoco.mj_resetData(model, data)
|
||||
model.opt.gravity[:] = [0, 0, 0] # rien ne s'effondre : seuls les sliders bougent
|
||||
|
||||
has_free = model.jnt_type[0] == mujoco.mjtJoint.mjJNT_FREE
|
||||
|
||||
# Articulations actionnées (hors roues passives), avec adresse qpos.
|
||||
joints = []
|
||||
for i in range(model.njnt):
|
||||
name = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i)
|
||||
if not name or "freejoint" in name or "passive_" in name:
|
||||
continue
|
||||
joints.append((name, model.jnt_qposadr[i]))
|
||||
|
||||
# ctrl initial = pose HOME (les actionneurs position tiennent cette cible).
|
||||
for a in range(model.nu):
|
||||
aname = mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_ACTUATOR, a)
|
||||
data.ctrl[a] = home_value(aname or "")
|
||||
|
||||
if has_free:
|
||||
data.qpos[0:3] = [0.0, 0.0, 0.14]
|
||||
data.qpos[3:7] = [1.0, 0.0, 0.0, 0.0]
|
||||
base_xy = data.qpos[0:2].copy()
|
||||
base_quat = data.qpos[3:7].copy()
|
||||
|
||||
robot_geoms = [g for g in range(model.ngeom)
|
||||
if model.geom_type[g] != mujoco.mjtGeom.mjGEOM_PLANE]
|
||||
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
print("=== Crouch Pose Editor (rollers) ===")
|
||||
print(f"actionneurs: {model.nu} | base flottante: {has_free}")
|
||||
print("Ouvre le panneau 'Control' du viewer et bouge les sliders pour composer")
|
||||
print("la pose ACCROUPIE. Ferme la fenêtre quand c'est bon.\n")
|
||||
|
||||
with mujoco.viewer.launch_passive(model, data) as viewer:
|
||||
while viewer.is_running():
|
||||
if has_free:
|
||||
data.qpos[0:2] = base_xy
|
||||
data.qpos[3:7] = base_quat
|
||||
data.qvel[0:6] = 0.0
|
||||
mujoco.mj_step(model, data) # actionneurs position -> les joints suivent ctrl
|
||||
if has_free:
|
||||
data.qpos[0:2] = base_xy
|
||||
data.qpos[3:7] = base_quat
|
||||
data.qvel[0:6] = 0.0
|
||||
mujoco.mj_forward(model, data)
|
||||
try:
|
||||
zmin = min(float(data.geom_xpos[g, 2] - model.geom_rbound[g])
|
||||
for g in robot_geoms)
|
||||
data.qpos[2] -= zmin
|
||||
mujoco.mj_forward(model, data)
|
||||
except Exception:
|
||||
pass
|
||||
viewer.sync()
|
||||
time.sleep(1.0 / 60.0)
|
||||
|
||||
print("\n=== Pose accroupie capturée ===\n")
|
||||
print("CROUCH_POSE = {")
|
||||
for name, adr in joints:
|
||||
print(f' "{name}": {float(data.qpos[adr]):.4f},')
|
||||
print("}")
|
||||
if has_free:
|
||||
print(f"\n# hauteur de base finale (info) : z = {float(data.qpos[2]):.4f}")
|
||||
print("# Colle CROUCH_POSE ici et donne-le a Claude pour cabler la reward.")
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Script to play RL agent with RSL-RL."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import torch
|
||||
import tyro
|
||||
from rsl_rl.runners import OnPolicyRunner
|
||||
|
||||
from mjlab.envs import ManagerBasedRlEnv
|
||||
from mjlab.rl import RslRlVecEnvWrapper
|
||||
from mjlab.tasks.registry import list_tasks, load_env_cfg, load_rl_cfg, load_runner_cls
|
||||
from mjlab.tasks.tracking.mdp import MotionCommandCfg
|
||||
from mjlab.utils.os import get_checkpoint_path, get_wandb_checkpoint_path
|
||||
from mjlab.utils.torch import configure_torch_backends
|
||||
from mjlab.utils.wrappers import VideoRecorder
|
||||
from mjlab.viewer import NativeMujocoViewer, ViserPlayViewer
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExportConfig:
|
||||
onnx_file: str = "output.onnx"
|
||||
agent: Literal["zero", "random", "trained"] = "trained"
|
||||
registry_name: str | None = None
|
||||
wandb_run_path: str | None = None
|
||||
checkpoint: int | None = None # Select checkpoint by iteration number (e.g. 3000)
|
||||
checkpoint_file: str | None = None
|
||||
motion_file: str | None = None
|
||||
num_envs: int | None = None
|
||||
device: str | None = None
|
||||
video: bool = False
|
||||
video_length: int = 200
|
||||
video_height: int | None = None
|
||||
video_width: int | None = None
|
||||
camera: int | str | None = None
|
||||
viewer: Literal["auto", "native", "viser"] = "auto"
|
||||
|
||||
# Internal flag used by demo script.
|
||||
_demo_mode: tyro.conf.Suppress[bool] = False
|
||||
|
||||
|
||||
def run_export(task_id: str, cfg: ExportConfig):
|
||||
configure_torch_backends()
|
||||
|
||||
device = cfg.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
|
||||
|
||||
env_cfg = load_env_cfg(task_id, play=True)
|
||||
agent_cfg = load_rl_cfg(task_id)
|
||||
|
||||
DUMMY_MODE = cfg.agent in {"zero", "random"}
|
||||
TRAINED_MODE = not DUMMY_MODE
|
||||
|
||||
# Check if this is a motion tracking task.
|
||||
is_motion_tracking = (
|
||||
env_cfg.commands is not None
|
||||
and "motion" in env_cfg.commands
|
||||
and isinstance(env_cfg.commands["motion"], MotionCommandCfg)
|
||||
)
|
||||
is_tracking_task = is_motion_tracking
|
||||
|
||||
if is_tracking_task and cfg._demo_mode:
|
||||
# Demo mode: use uniform sampling to see more diversity with num_envs > 1.
|
||||
assert env_cfg.commands is not None
|
||||
motion_cmd = env_cfg.commands["motion"]
|
||||
assert isinstance(motion_cmd, MotionCommandCfg)
|
||||
motion_cmd.sampling_mode = "uniform"
|
||||
|
||||
if is_tracking_task:
|
||||
assert env_cfg.commands is not None
|
||||
motion_cmd = env_cfg.commands["motion"]
|
||||
assert isinstance(motion_cmd, MotionCommandCfg)
|
||||
|
||||
# Check if motion file is already set and exists
|
||||
motion_file_already_set = (
|
||||
hasattr(motion_cmd, 'motion_file')
|
||||
and motion_cmd.motion_file is not None
|
||||
and Path(motion_cmd.motion_file).exists()
|
||||
)
|
||||
|
||||
if DUMMY_MODE:
|
||||
if not cfg.registry_name:
|
||||
raise ValueError(
|
||||
"Tracking tasks require `registry_name` when using dummy agents."
|
||||
)
|
||||
# Check if the registry name includes alias, if not, append ":latest".
|
||||
registry_name = cfg.registry_name
|
||||
if ":" not in registry_name:
|
||||
registry_name = registry_name + ":latest"
|
||||
import wandb
|
||||
|
||||
api = wandb.Api()
|
||||
artifact = api.artifact(registry_name)
|
||||
motion_cmd.motion_file = str(Path(artifact.download()) / "motion.npz")
|
||||
else:
|
||||
if cfg.motion_file is not None:
|
||||
print(f"[INFO]: Using motion file from CLI: {cfg.motion_file}")
|
||||
motion_cmd.motion_file = cfg.motion_file
|
||||
elif motion_file_already_set:
|
||||
print(f"[INFO]: Using motion file from env config: {motion_cmd.motion_file}")
|
||||
else:
|
||||
# Try to download from wandb artifacts
|
||||
import wandb
|
||||
|
||||
api = wandb.Api()
|
||||
if cfg.wandb_run_path is None and cfg.checkpoint_file is not None:
|
||||
raise ValueError(
|
||||
"Tracking tasks require `motion_file` when using `checkpoint_file`, "
|
||||
"or provide `wandb_run_path` so the motion artifact can be resolved."
|
||||
)
|
||||
if cfg.wandb_run_path is not None:
|
||||
wandb_run = api.run(str(cfg.wandb_run_path))
|
||||
art = next(
|
||||
(a for a in wandb_run.used_artifacts() if a.type == "motions"),
|
||||
None,
|
||||
)
|
||||
if art is None:
|
||||
raise RuntimeError("No motion artifact found in the run.")
|
||||
motion_cmd.motion_file = str(Path(art.download()) / "motion.npz")
|
||||
|
||||
log_dir: Path | None = None
|
||||
resume_path: Path | None = None
|
||||
if TRAINED_MODE:
|
||||
log_root_path = (Path("logs") / "rsl_rl" / agent_cfg.experiment_name).resolve()
|
||||
if cfg.checkpoint_file is not None:
|
||||
resume_path = Path(cfg.checkpoint_file)
|
||||
if not resume_path.exists():
|
||||
raise FileNotFoundError(f"Checkpoint file not found: {resume_path}")
|
||||
print(f"[INFO]: Loading checkpoint: {resume_path.name}")
|
||||
elif cfg.checkpoint is not None:
|
||||
# Select a specific checkpoint iteration, from wandb or local.
|
||||
checkpoint_filename = f"model_{cfg.checkpoint}.pt"
|
||||
if cfg.wandb_run_path is not None:
|
||||
import wandb
|
||||
api = wandb.Api()
|
||||
wandb_run = api.run(str(cfg.wandb_run_path))
|
||||
run_id = cfg.wandb_run_path.split("/")[-1]
|
||||
download_dir = log_root_path / "wandb_checkpoints" / run_id
|
||||
resume_path = download_dir / checkpoint_filename
|
||||
if resume_path.exists():
|
||||
print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, cached)")
|
||||
else:
|
||||
available = [f.name for f in wandb_run.files() if "model" in f.name]
|
||||
if checkpoint_filename not in available:
|
||||
raise FileNotFoundError(
|
||||
f"Checkpoint '{checkpoint_filename}' not found in wandb run. "
|
||||
f"Available: {sorted(available)}"
|
||||
)
|
||||
wandb_run.file(checkpoint_filename).download(str(download_dir), replace=True)
|
||||
print(f"[INFO]: Loading checkpoint: {checkpoint_filename} (run: {run_id}, downloaded)")
|
||||
else:
|
||||
resume_path = get_checkpoint_path(
|
||||
log_root_path, checkpoint=re.escape(checkpoint_filename)
|
||||
)
|
||||
print(f"[INFO]: Loading checkpoint: {resume_path.name}")
|
||||
else:
|
||||
if cfg.wandb_run_path is None:
|
||||
raise ValueError(
|
||||
"`wandb_run_path` is required when `checkpoint_file` is not provided."
|
||||
)
|
||||
resume_path, was_cached = get_wandb_checkpoint_path(
|
||||
log_root_path, Path(cfg.wandb_run_path)
|
||||
)
|
||||
# Extract run_id and checkpoint name from path for display.
|
||||
run_id = resume_path.parent.name
|
||||
checkpoint_name = resume_path.name
|
||||
cached_str = "cached" if was_cached else "downloaded"
|
||||
print(
|
||||
f"[INFO]: Loading checkpoint: {checkpoint_name} (run: {run_id}, {cached_str})"
|
||||
)
|
||||
log_dir = resume_path.parent
|
||||
|
||||
if cfg.num_envs is not None:
|
||||
env_cfg.scene.num_envs = cfg.num_envs
|
||||
if cfg.video_height is not None:
|
||||
env_cfg.viewer.height = cfg.video_height
|
||||
if cfg.video_width is not None:
|
||||
env_cfg.viewer.width = cfg.video_width
|
||||
|
||||
render_mode = "rgb_array" if (TRAINED_MODE and cfg.video) else None
|
||||
if cfg.video and DUMMY_MODE:
|
||||
print(
|
||||
"[WARN] Video recording with dummy agents is disabled (no checkpoint/log_dir)."
|
||||
)
|
||||
env = ManagerBasedRlEnv(cfg=env_cfg, device=device, render_mode=render_mode)
|
||||
|
||||
if TRAINED_MODE and cfg.video:
|
||||
print("[INFO] Recording videos during play")
|
||||
assert log_dir is not None # log_dir is set in TRAINED_MODE block
|
||||
env = VideoRecorder(
|
||||
env,
|
||||
video_folder=log_dir / "videos" / "play",
|
||||
step_trigger=lambda step: step == 0,
|
||||
video_length=cfg.video_length,
|
||||
disable_logger=True,
|
||||
)
|
||||
|
||||
env = RslRlVecEnvWrapper(env, clip_actions=agent_cfg.clip_actions)
|
||||
if DUMMY_MODE:
|
||||
action_shape: tuple[int, ...] = env.unwrapped.action_space.shape # type: ignore
|
||||
if cfg.agent == "zero":
|
||||
|
||||
class PolicyZero:
|
||||
def __call__(self, obs) -> torch.Tensor:
|
||||
del obs
|
||||
return torch.zeros(action_shape, device=env.unwrapped.device)
|
||||
|
||||
policy = PolicyZero()
|
||||
else:
|
||||
|
||||
class PolicyRandom:
|
||||
def __call__(self, obs) -> torch.Tensor:
|
||||
del obs
|
||||
return 2 * torch.rand(action_shape, device=env.unwrapped.device) - 1
|
||||
|
||||
policy = PolicyRandom()
|
||||
else:
|
||||
runner_cls = load_runner_cls(task_id) or OnPolicyRunner
|
||||
runner = runner_cls(env, asdict(agent_cfg), device=device)
|
||||
runner.load(str(resume_path), map_location=device)
|
||||
policy = runner.get_inference_policy(device=device)
|
||||
|
||||
# mjlab 1.3.0: ONNX export + metadata moved to mjlab.rl.exporter_utils and
|
||||
# the runner's built-in export_policy_to_onnx. Observation normalization is
|
||||
# baked into the exported graph automatically — EmpiricalNormalization is a
|
||||
# submodule of the policy's MLPModel (obs_normalization=True in RslRlModelCfg),
|
||||
# so export_policy_to_onnx emits actor(normalizer(obs)). No manual normalizer
|
||||
# handling needed (the old export_velocity_policy_as_onnx path is gone).
|
||||
from mjlab.rl.exporter_utils import get_base_metadata, attach_metadata_to_onnx
|
||||
|
||||
onnx_path = os.path.abspath(cfg.onnx_file)
|
||||
path = os.path.dirname(onnx_path)
|
||||
filename = os.path.basename(onnx_path)
|
||||
|
||||
runner.export_policy_to_onnx(path, filename)
|
||||
|
||||
metadata = get_base_metadata(runner.env.unwrapped, run_path=cfg.checkpoint_file)
|
||||
attach_metadata_to_onnx(onnx_path, metadata)
|
||||
|
||||
print(f"Written {onnx_path}")
|
||||
|
||||
env.close()
|
||||
|
||||
|
||||
def main():
|
||||
# Parse first argument to choose the task.
|
||||
# Import tasks to populate the registry.
|
||||
import mjlab.tasks # noqa: F401
|
||||
|
||||
all_tasks = list_tasks()
|
||||
chosen_task, remaining_args = tyro.cli(
|
||||
tyro.extras.literal_type_from_choices(all_tasks),
|
||||
add_help=False,
|
||||
return_unknown_args=True,
|
||||
)
|
||||
|
||||
# Parse the rest of the arguments + allow overriding env_cfg and agent_cfg.
|
||||
agent_cfg = load_rl_cfg(chosen_task)
|
||||
|
||||
args = tyro.cli(
|
||||
ExportConfig,
|
||||
args=remaining_args,
|
||||
default=ExportConfig(),
|
||||
prog=sys.argv[0] + f" {chosen_task}",
|
||||
config=(
|
||||
tyro.conf.AvoidSubcommands,
|
||||
tyro.conf.FlagConversionOff,
|
||||
),
|
||||
)
|
||||
del remaining_args, agent_cfg
|
||||
|
||||
run_export(chosen_task, args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,79 @@
|
||||
# HF Jobs training
|
||||
|
||||
Train mjlab-microduck on Hugging Face's managed GPUs. Auth is the cached HF
|
||||
token (`hf auth login` or `HF_TOKEN`); everything goes through the
|
||||
`huggingface_hub` Python API — the standalone `hf` CLI is not required.
|
||||
|
||||
## One-time setup
|
||||
|
||||
```fish
|
||||
hf auth login # or export HF_TOKEN (any tool that caches the token works)
|
||||
wandb login # auto-detected from ~/.netrc and forwarded
|
||||
```
|
||||
|
||||
## Submit a run
|
||||
|
||||
Your normal train command, plus `--hf-jobs`:
|
||||
|
||||
```fish
|
||||
uv run train Mjlab-Kick-Flat-MicroDuck \
|
||||
--env.scene.num-envs 4096 --agent.max_iterations 4000 --hf-jobs
|
||||
```
|
||||
|
||||
You'll be asked which namespace to run under — your personal account or one
|
||||
of your orgs. Repos, uv-cache bucket, billing and the job itself all live in
|
||||
the chosen namespace. Pass `--namespace <name>` to skip the prompt
|
||||
(non-interactive runs default to personal).
|
||||
|
||||
Without `--hf-jobs` the command behaves exactly as before (local training).
|
||||
Submission flags are consumed locally; everything else is forwarded to
|
||||
`uv run train` inside the job.
|
||||
|
||||
Useful flags:
|
||||
- `--namespace <name>` — account/org to run under; skips the prompt
|
||||
- `--flavor l4x1` (default) / `a10g-large` / `a100-large`
|
||||
- `--timeout 12h` (default) — job is killed past this
|
||||
- `--detach` — submit and return immediately (default streams logs; Ctrl-C detaches without killing the job)
|
||||
- `--dry-run` — build tarball, print the job spec, do not submit
|
||||
- `--run-name <tag>` — overrides the auto-generated `<task>-<timestamp>` name
|
||||
- `--no-uv-cache` — disable the persistent `uv` cache bucket (first-run cost on every run)
|
||||
- `--no-wandb` — don't forward a wandb key
|
||||
|
||||
(`uv run scripts/hf/train_hf.py <task> ...` still works — it's a shim to the
|
||||
same code, which lives in `src/mjlab_microduck/hf_jobs.py`.)
|
||||
|
||||
## What happens under the hood
|
||||
|
||||
1. `git ls-files` snapshots tracked + uncommitted files of the repo you run
|
||||
from (worktree-aware) → `src-<stamp>.tar.gz`.
|
||||
2. Tarball is uploaded to private dataset `<namespace>/mjlab-microduck-src`.
|
||||
3. Private model repo `<namespace>/<run-name>` is created for checkpoints.
|
||||
4. A private HF bucket `<namespace>/mjlab-uv-cache` is mounted at `/uv-cache`
|
||||
and used as `UV_CACHE_DIR` so wheel downloads persist across runs (first
|
||||
run cold, subsequent runs fast).
|
||||
5. `HfApi.run_job` launches a container that:
|
||||
- installs `uv`, extracts the tarball, runs `uv sync` (warm-cached),
|
||||
- starts `scripts/hf/uploader.py` in background (watches `logs/rsl_rl/**/model_*.pt`, pushes every 60s),
|
||||
- runs `uv run train <task> <args>`,
|
||||
- does a final one-shot upload on exit.
|
||||
6. wandb credentials are forwarded as a secret — runs show up live in your
|
||||
wandb project.
|
||||
|
||||
## Browsing checkpoints
|
||||
|
||||
The submitter prints `https://huggingface.co/<namespace>/<run-name>` at
|
||||
start; new `.pt` files appear there during training.
|
||||
|
||||
## Managing jobs
|
||||
|
||||
The job id and URL are printed at submission. From Python:
|
||||
|
||||
```python
|
||||
from huggingface_hub import HfApi
|
||||
api = HfApi()
|
||||
api.list_jobs() # or namespace="pollen-robotics"
|
||||
for l in api.fetch_job_logs(job_id="...", follow=True): print(l)
|
||||
api.cancel_job(job_id="...")
|
||||
```
|
||||
|
||||
(or the `hf jobs ps/logs/cancel` CLI if you have it installed.)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Back-compat shim: the submission logic moved to mjlab_microduck.hf_jobs.
|
||||
|
||||
Prefer the integrated flag:
|
||||
uv run train <task> <train args...> --hf-jobs [--namespace <ns>] [...]
|
||||
|
||||
This script keeps the old invocation working:
|
||||
uv run scripts/hf/train_hf.py <task> [submission flags] <train args...>
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from mjlab_microduck.hf_jobs import submit
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(submit(sys.argv[1:]))
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Checkpoint uploader run inside an HF Job.
|
||||
|
||||
Watches `logs/rsl_rl/**/model_*.pt` and uploads new/updated files to the
|
||||
target HF Model repo. Designed to be `nohup uv run`-launched from the job
|
||||
bootstrap, with auth coming from the HF_TOKEN secret injected by `hf jobs run`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from huggingface_hub import HfApi, CommitOperationAdd
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo_id = os.environ.get("CKPT_REPO")
|
||||
if not repo_id:
|
||||
print("[uploader] CKPT_REPO not set, exiting", flush=True)
|
||||
return 1
|
||||
|
||||
poll_interval = float(os.environ.get("CKPT_POLL_INTERVAL", "60"))
|
||||
root = Path(os.environ.get("CKPT_ROOT", "logs/rsl_rl"))
|
||||
|
||||
one_shot = os.environ.get("CKPT_ONE_SHOT") == "1"
|
||||
|
||||
api = HfApi()
|
||||
api.create_repo(repo_id, repo_type="model", private=True, exist_ok=True)
|
||||
mode = "one-shot" if one_shot else f"every {poll_interval}s"
|
||||
print(f"[uploader] watching {root} -> {repo_id} ({mode})", flush=True)
|
||||
|
||||
sent: dict[Path, float] = {}
|
||||
while True:
|
||||
try:
|
||||
files = list(root.glob("**/model_*.pt"))
|
||||
# also pick up the dumped configs once
|
||||
files += [p for p in root.glob("**/params/*.yaml")]
|
||||
files += [p for p in root.glob("**/params/*.json")]
|
||||
|
||||
to_upload: list[CommitOperationAdd] = []
|
||||
for f in files:
|
||||
try:
|
||||
mtime = f.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if sent.get(f) == mtime:
|
||||
continue
|
||||
# use path-in-repo relative to logs/rsl_rl so the repo mirrors run dirs
|
||||
rel = f.relative_to(root)
|
||||
to_upload.append(
|
||||
CommitOperationAdd(path_in_repo=str(rel), path_or_fileobj=str(f))
|
||||
)
|
||||
sent[f] = mtime
|
||||
|
||||
if to_upload:
|
||||
msg = f"upload {len(to_upload)} file(s)"
|
||||
api.create_commit(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
operations=to_upload,
|
||||
commit_message=msg,
|
||||
)
|
||||
print(f"[uploader] pushed {len(to_upload)} file(s)", flush=True)
|
||||
except Exception as e:
|
||||
print(f"[uploader] error: {e}", flush=True)
|
||||
|
||||
if one_shot:
|
||||
return 0
|
||||
time.sleep(poll_interval)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
"""Find the latest wandb run for a given user and launch `uv run play`.
|
||||
|
||||
Par défaut : le tout dernier run (toutes tâches confondues).
|
||||
Avec un flag de type, le dernier run de CE type seulement :
|
||||
md-play --crouch # dernier Mjlab-RollerCrouch-...
|
||||
md-play --roller # dernier Mjlab-...-Rollers
|
||||
md-play --swizzle # dernier Mjlab-...-Swizzle-...
|
||||
md-play --slope # dernier Mjlab-RollerSlope-...
|
||||
Les arguments inconnus sont transmis tels quels à `uv run play`
|
||||
(ex: md-play --crouch --action-scale 0.8).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
from wandb_utils import resolve_run, run_command
|
||||
|
||||
# flag -> sous-chaîne recherchée dans le task_id (metadata args[0])
|
||||
TYPE_SUBSTR = {
|
||||
"crouch": "Crouch", # Mjlab-RollerCrouch-Flat-MicroDuck
|
||||
"roller": "MicroDuck-Rollers", # Mjlab-Velocity-Flat-MicroDuck-Rollers (≠ RollerSlope/RollerCrouch)
|
||||
"swizzle": "Swizzle", # Mjlab-Velocity-Swizzle-MicroDuck
|
||||
"slope": "Slope", # Mjlab-RollerSlope-Flat-MicroDuck
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Play latest wandb run for a user")
|
||||
parser.add_argument(
|
||||
"--user", default="coralie",
|
||||
help="Filter runs by user (matched against email, default: coralie)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true",
|
||||
help="Print the play command without executing it",
|
||||
)
|
||||
for t in TYPE_SUBSTR:
|
||||
parser.add_argument(
|
||||
f"--{t}", dest="type", action="store_const", const=t,
|
||||
help=f"latest '{t}' run only",
|
||||
)
|
||||
# unknown args (e.g. --action-scale 0.8) are forwarded to `uv run play`
|
||||
args, extra = parser.parse_known_args()
|
||||
|
||||
task_substr = TYPE_SUBSTR[args.type] if getattr(args, "type", None) else None
|
||||
_, info = resolve_run(args.user, task_substr)
|
||||
|
||||
cmd = [
|
||||
"uv", "run", "play",
|
||||
info["env_name"],
|
||||
"--wandb-run-path", info["run_path"],
|
||||
*extra,
|
||||
]
|
||||
run_command(cmd, args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Plot comparison between real and simulated observations using Plotly.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import pickle
|
||||
import numpy as np
|
||||
import plotly.graph_objects as go
|
||||
from plotly.subplots import make_subplots
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_observations(pkl_path: str):
|
||||
"""Load observations from pickle file."""
|
||||
with open(pkl_path, 'rb') as f:
|
||||
data = pickle.load(f)
|
||||
|
||||
if isinstance(data, dict):
|
||||
if 'observations' in data and 'timestamps' in data:
|
||||
observations = data['observations']
|
||||
timestamps = data['timestamps']
|
||||
else:
|
||||
raise ValueError("Dictionary must contain 'observations' and 'timestamps' keys")
|
||||
elif isinstance(data, list):
|
||||
if len(data) == 0:
|
||||
raise ValueError("Empty data list")
|
||||
|
||||
if isinstance(data[0], dict) and 'timestamp' in data[0] and 'observation' in data[0]:
|
||||
timestamps = [item['timestamp'] for item in data]
|
||||
observations = [item['observation'] for item in data]
|
||||
elif isinstance(data[0], tuple):
|
||||
timestamps = [item[0] for item in data]
|
||||
observations = [item[1] for item in data]
|
||||
else:
|
||||
observations = data
|
||||
timestamps = [i * 0.02 for i in range(len(observations))]
|
||||
else:
|
||||
raise ValueError(f"Unsupported data format: {type(data)}")
|
||||
|
||||
return np.array(observations), np.array(timestamps)
|
||||
|
||||
|
||||
def plot_comparison(real_obs, real_ts, sim_obs=None, sim_ts=None):
|
||||
"""
|
||||
Plot comparison between real and simulated observations using Plotly.
|
||||
If sim_obs is None, only plots real data.
|
||||
"""
|
||||
|
||||
# Joint names
|
||||
joint_names = [
|
||||
'L_hip_yaw', 'L_hip_roll', 'L_hip_pitch', 'L_knee', 'L_ankle',
|
||||
'neck_pitch', 'head_pitch', 'head_yaw', 'head_roll',
|
||||
'R_hip_yaw', 'R_hip_roll', 'R_hip_pitch', 'R_knee', 'R_ankle'
|
||||
]
|
||||
|
||||
obs_dim = real_obs.shape[1] if sim_obs is None else min(real_obs.shape[1], sim_obs.shape[1])
|
||||
|
||||
# Velocity (51D): ang_vel (3) + proj_grav (3) + joint_pos (14) + joint_vel (14) + actions (14) + command (3)
|
||||
base_ang_vel_start = 0
|
||||
gravity_start = 3
|
||||
joint_pos_start = 6
|
||||
joint_vel_start = 20
|
||||
action_start = 34
|
||||
|
||||
# Create subplot titles with sections
|
||||
subplot_titles = []
|
||||
|
||||
# Base angular velocity (3)
|
||||
subplot_titles.extend(['<b>BASE ANG VEL</b><br>ω_x', 'ω_y', 'ω_z', ''])
|
||||
|
||||
# Raw accelero (3)
|
||||
subplot_titles.extend(['<b>Raw Accelero</b><br>g_x', 'g_y', 'g_z', ''])
|
||||
|
||||
# Joint positions (14 + 2 empty)
|
||||
subplot_titles.append(f'<b>JOINT POSITIONS</b><br>{joint_names[0]}')
|
||||
subplot_titles.extend(joint_names[1:14])
|
||||
subplot_titles.extend(['', ''])
|
||||
|
||||
# Joint velocities (14 + 2 empty)
|
||||
subplot_titles.append(f'<b>JOINT VELOCITIES</b><br>{joint_names[0]}')
|
||||
subplot_titles.extend(joint_names[1:14])
|
||||
subplot_titles.extend(['', ''])
|
||||
|
||||
# Actions (14 + 2 empty)
|
||||
subplot_titles.append(f'<b>ACTIONS</b><br>{joint_names[0]}')
|
||||
subplot_titles.extend(joint_names[1:14])
|
||||
subplot_titles.extend(['', ''])
|
||||
|
||||
num_rows = 14
|
||||
fig = make_subplots(
|
||||
rows=num_rows, cols=4,
|
||||
subplot_titles=subplot_titles,
|
||||
vertical_spacing=0.02,
|
||||
horizontal_spacing=0.05,
|
||||
row_heights=[1]*num_rows,
|
||||
)
|
||||
|
||||
plot_idx = 0
|
||||
|
||||
# Track data for common scaling
|
||||
command_data = []
|
||||
|
||||
def add_traces(row, col, real_data, sim_data=None, y_range=None):
|
||||
"""Helper to add real and sim traces to a subplot."""
|
||||
fig.add_trace(
|
||||
go.Scatter(x=real_ts, y=real_data, name='Real',
|
||||
line=dict(color='blue', width=1.5),
|
||||
showlegend=(plot_idx == 0)),
|
||||
row=row, col=col
|
||||
)
|
||||
if sim_data is not None:
|
||||
fig.add_trace(
|
||||
go.Scatter(x=sim_ts, y=sim_data, name='Sim',
|
||||
line=dict(color='red', width=1.5, dash='dash'),
|
||||
showlegend=(plot_idx == 0)),
|
||||
row=row, col=col
|
||||
)
|
||||
if y_range:
|
||||
fig.update_yaxes(range=y_range, row=row, col=col)
|
||||
|
||||
base_ang_vel_data = []
|
||||
gravity_data = []
|
||||
joint_pos_data = []
|
||||
joint_vel_data = []
|
||||
action_data = []
|
||||
|
||||
# 1. Base angular velocity (3 subplots)
|
||||
for i in range(3):
|
||||
row, col = divmod(plot_idx, 4)
|
||||
row += 1
|
||||
col += 1
|
||||
base_ang_vel_data.append(real_obs[:, base_ang_vel_start+i])
|
||||
if sim_obs is not None:
|
||||
base_ang_vel_data.append(sim_obs[:, base_ang_vel_start+i])
|
||||
add_traces(row, col, real_obs[:, base_ang_vel_start+i], None if sim_obs is None else sim_obs[:, base_ang_vel_start+i])
|
||||
fig.update_yaxes(title_text='rad/s', row=row, col=col)
|
||||
plot_idx += 1
|
||||
|
||||
# Empty slot
|
||||
plot_idx += 1
|
||||
|
||||
# 2. Raw accelero (3 subplots)
|
||||
for i in range(3):
|
||||
row, col = divmod(plot_idx, 4)
|
||||
row += 1
|
||||
col += 1
|
||||
gravity_data.append(real_obs[:, gravity_start+i])
|
||||
if sim_obs is not None:
|
||||
gravity_data.append(sim_obs[:, gravity_start+i])
|
||||
add_traces(row, col, real_obs[:, gravity_start+i], None if sim_obs is None else sim_obs[:, gravity_start+i])
|
||||
fig.update_yaxes(title_text='g', row=row, col=col)
|
||||
plot_idx += 1
|
||||
|
||||
# Empty slot
|
||||
plot_idx += 1
|
||||
|
||||
# 3. Joint positions (14 subplots)
|
||||
for i in range(14):
|
||||
row, col = divmod(plot_idx, 4)
|
||||
row += 1
|
||||
col += 1
|
||||
if joint_pos_start + i < obs_dim:
|
||||
joint_pos_data.append(real_obs[:, joint_pos_start+i])
|
||||
if sim_obs is not None:
|
||||
joint_pos_data.append(sim_obs[:, joint_pos_start+i])
|
||||
add_traces(row, col, real_obs[:, joint_pos_start+i], None if sim_obs is None else sim_obs[:, joint_pos_start+i])
|
||||
fig.update_yaxes(title_text='rad', row=row, col=col)
|
||||
plot_idx += 1
|
||||
|
||||
# Skip 2 empty slots
|
||||
plot_idx += 2
|
||||
|
||||
# 4. Joint velocities (14 subplots)
|
||||
for i in range(14):
|
||||
row, col = divmod(plot_idx, 4)
|
||||
row += 1
|
||||
col += 1
|
||||
if joint_vel_start + i < obs_dim:
|
||||
joint_vel_data.append(real_obs[:, joint_vel_start+i])
|
||||
if sim_obs is not None:
|
||||
joint_vel_data.append(sim_obs[:, joint_vel_start+i])
|
||||
add_traces(row, col, real_obs[:, joint_vel_start+i], None if sim_obs is None else sim_obs[:, joint_vel_start+i])
|
||||
fig.update_yaxes(title_text='rad/s', row=row, col=col)
|
||||
plot_idx += 1
|
||||
|
||||
# Skip 2 empty slots
|
||||
plot_idx += 2
|
||||
|
||||
# 5. Actions (14 subplots)
|
||||
for i in range(14):
|
||||
row, col = divmod(plot_idx, 4)
|
||||
row += 1
|
||||
col += 1
|
||||
if action_start + i < obs_dim:
|
||||
action_data.append(real_obs[:, action_start+i])
|
||||
if sim_obs is not None:
|
||||
action_data.append(sim_obs[:, action_start+i])
|
||||
add_traces(row, col, real_obs[:, action_start+i], None if sim_obs is None else sim_obs[:, action_start+i])
|
||||
fig.update_yaxes(title_text='action', row=row, col=col)
|
||||
fig.update_xaxes(title_text='Time (s)', row=row, col=col)
|
||||
plot_idx += 1
|
||||
|
||||
# Set common y-ranges for each group
|
||||
def compute_range(data_list):
|
||||
if not data_list:
|
||||
return None
|
||||
all_data = np.concatenate([d.flatten() for d in data_list])
|
||||
y_min, y_max = np.min(all_data), np.max(all_data)
|
||||
margin = (y_max - y_min) * 0.1
|
||||
return [y_min - margin, y_max + margin]
|
||||
|
||||
base_ang_vel_range = compute_range(base_ang_vel_data)
|
||||
gravity_range = compute_range(gravity_data)
|
||||
joint_pos_range = compute_range(joint_pos_data)
|
||||
joint_vel_range = compute_range(joint_vel_data)
|
||||
action_range = compute_range(action_data)
|
||||
|
||||
# Apply common ranges
|
||||
plot_idx = 0
|
||||
|
||||
for i in range(3): # Base ang vel
|
||||
row, col = divmod(plot_idx, 4)
|
||||
fig.update_yaxes(range=base_ang_vel_range, row=row+1, col=col+1)
|
||||
plot_idx += 1
|
||||
plot_idx += 1
|
||||
|
||||
for i in range(3): # Gravity
|
||||
row, col = divmod(plot_idx, 4)
|
||||
fig.update_yaxes(range=gravity_range, row=row+1, col=col+1)
|
||||
plot_idx += 1
|
||||
plot_idx += 1
|
||||
|
||||
for i in range(14): # Joint pos
|
||||
row, col = divmod(plot_idx, 4)
|
||||
fig.update_yaxes(range=joint_pos_range, row=row+1, col=col+1)
|
||||
plot_idx += 1
|
||||
plot_idx += 2
|
||||
|
||||
for i in range(14): # Joint vel
|
||||
row, col = divmod(plot_idx, 4)
|
||||
fig.update_yaxes(range=joint_vel_range, row=row+1, col=col+1)
|
||||
plot_idx += 1
|
||||
plot_idx += 2
|
||||
|
||||
for i in range(14): # Actions
|
||||
row, col = divmod(plot_idx, 4)
|
||||
fig.update_yaxes(range=action_range, row=row+1, col=col+1)
|
||||
plot_idx += 1
|
||||
|
||||
# Update layout
|
||||
title = 'Real vs Simulated Observations Comparison' if sim_obs is not None else 'Real Robot Observations'
|
||||
|
||||
fig.update_layout(
|
||||
title_text=title,
|
||||
title_font_size=24,
|
||||
height=4600,
|
||||
width=1600,
|
||||
showlegend=True,
|
||||
legend=dict(x=0.85, y=0.99, bgcolor='rgba(255,255,255,0.8)'),
|
||||
hovermode='x unified'
|
||||
)
|
||||
|
||||
fig.show()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compare real and simulated observations (Plotly version)"
|
||||
)
|
||||
parser.add_argument("real_pkl", type=str,
|
||||
help="Path to .pkl file with real robot observations")
|
||||
parser.add_argument("sim_pkl", type=str, nargs='?', default=None,
|
||||
help="Path to .pkl file with simulated observations (optional)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Check if files exist
|
||||
if not Path(args.real_pkl).exists():
|
||||
print(f"Error: {args.real_pkl} not found")
|
||||
return 1
|
||||
|
||||
# Load observations
|
||||
print(f"Loading real observations from {args.real_pkl}...")
|
||||
real_obs, real_ts = load_observations(args.real_pkl)
|
||||
print(f"Loaded {len(real_obs)} real observations (shape: {real_obs.shape})")
|
||||
|
||||
if args.sim_pkl:
|
||||
if not Path(args.sim_pkl).exists():
|
||||
print(f"Error: {args.sim_pkl} not found")
|
||||
return 1
|
||||
print(f"Loading simulated observations from {args.sim_pkl}...")
|
||||
sim_obs, sim_ts = load_observations(args.sim_pkl)
|
||||
print(f"Loaded {len(sim_obs)} simulated observations (shape: {sim_obs.shape})")
|
||||
else:
|
||||
print("No sim data provided, plotting real data only")
|
||||
sim_obs, sim_ts = None, None
|
||||
|
||||
# Plot comparison
|
||||
print(f"\nGenerating interactive comparison plots...")
|
||||
plot_comparison(real_obs, real_ts, sim_obs, sim_ts)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
@@ -0,0 +1,611 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sim2real validation for the XL330 test bench.
|
||||
|
||||
Runs the same ONNX policy on a fixed sequence of target angles, either in
|
||||
MuJoCo (with the BAM M6 actuator model) or on the real XL330 (via rustypot),
|
||||
logs the joint trajectory, and plots sim vs real for comparison.
|
||||
|
||||
Example workflow
|
||||
----------------
|
||||
# 1) Record in sim:
|
||||
uv run python scripts/testbench_sim2real.py --mode sim --onnx policy.onnx --out sim.npz
|
||||
# 2) Plug the real bench via USB, then record on hardware:
|
||||
uv run python scripts/testbench_sim2real.py --mode real --onnx policy.onnx --out real.npz \
|
||||
--port /dev/ttyUSB0 --motor-id 1
|
||||
# 3) Compare the two traces:
|
||||
uv run python scripts/testbench_sim2real.py --compare sim.npz real.npz --out-plot comparison.png
|
||||
|
||||
Observation layout (must match the training env): [joint_pos, joint_vel, last_action, command]
|
||||
Action: 1-D position offset in radians, scaled by 1.0, added to default pose (0.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from mjlab_microduck.robot.testbench_constants import (
|
||||
TESTBENCH_ARM_MASS,
|
||||
TESTBENCH_XML,
|
||||
_set_arm_mass,
|
||||
)
|
||||
|
||||
|
||||
# --- Match training env ---
|
||||
CONTROL_DT = 0.02 # decimation=4 × timestep=0.005 (policy rate = 50 Hz)
|
||||
SIM_DT = 0.005 # (logging rate = 200 Hz — one sample per inner sim step)
|
||||
LOG_DT = SIM_DT
|
||||
DEFAULT_POS = 0.0
|
||||
MAX_ANGLE = math.radians(80.0)
|
||||
|
||||
# XL330 present_velocity is returned by rustypot as raw ticks (i32, NOT converted).
|
||||
# Each tick = 0.229 RPM (per Dynamixel XL330 spec). rad/s = ticks * 0.229 * 2π/60.
|
||||
DXL_VEL_TICK_TO_RAD_S = 0.229 * 2.0 * math.pi / 60.0 # ≈ 0.02398 rad/s per tick
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared: target schedule + policy wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_target_schedule(
|
||||
total_time: float,
|
||||
hold_time: float = 4.0,
|
||||
seed: int = 0,
|
||||
) -> np.ndarray:
|
||||
"""Return one target angle per control step."""
|
||||
rng = np.random.default_rng(seed)
|
||||
n_steps = int(round(total_time / CONTROL_DT))
|
||||
steps_per_hold = int(round(hold_time / CONTROL_DT))
|
||||
targets = np.zeros(n_steps, dtype=np.float32)
|
||||
i = 0
|
||||
while i < n_steps:
|
||||
angle = float(rng.uniform(-MAX_ANGLE, MAX_ANGLE))
|
||||
end = min(i + steps_per_hold, n_steps)
|
||||
targets[i:end] = angle
|
||||
i = end
|
||||
return targets
|
||||
|
||||
|
||||
class PolicyRunner:
|
||||
def __init__(self, onnx_path: str, action_scale: float = 1.0):
|
||||
print(f"Loading policy: {onnx_path} (action_scale={action_scale})")
|
||||
self.session = ort.InferenceSession(onnx_path)
|
||||
self.in_name = self.session.get_inputs()[0].name
|
||||
in_shape = self.session.get_inputs()[0].shape
|
||||
print(f" input {self.in_name} shape={in_shape}")
|
||||
self.action_scale = action_scale
|
||||
self.last_action = np.zeros(1, dtype=np.float32)
|
||||
|
||||
def reset(self):
|
||||
self.last_action[:] = 0.0
|
||||
|
||||
def step(self, q: float, qd: float, target: float) -> float:
|
||||
# Matches the testbench env's policy obs layout:
|
||||
# [joint_pos_rel, joint_vel_rel, last_action, command] (4-d).
|
||||
obs = np.array(
|
||||
[q - DEFAULT_POS, qd, self.last_action[0], target],
|
||||
dtype=np.float32,
|
||||
)[None, :]
|
||||
action = self.session.run(None, {self.in_name: obs})[0].reshape(-1)
|
||||
self.last_action = action.astype(np.float32)
|
||||
return DEFAULT_POS + float(action[0]) * self.action_scale
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sim rollout (mujoco, same XL330 testbench XML as training)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rollout_sim_bam(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict:
|
||||
"""Sim rollout using bam's MujocoController on a vanilla MuJoCo step loop.
|
||||
|
||||
Pros: 200 Hz inner-step logging, no torch/mjwarp. Cons: not the exact
|
||||
actuator that was trained against (uses bam upstream, not mjlab's M6).
|
||||
"""
|
||||
import mujoco # local import so --mode real works without mujoco
|
||||
|
||||
from bam.actuators import actuators as bam_actuators
|
||||
from bam.model import models as bam_models
|
||||
from bam.mujoco import MujocoController
|
||||
|
||||
# Load the fitted XL330 m6 params from the canonical bam bundle (identical to
|
||||
# the values that used to live in mjlab_microduck.actuator.bam_params).
|
||||
import json as _json
|
||||
from bam.model import _resolve_json_path
|
||||
with open(_resolve_json_path(None, "xl330", "m6")) as _f:
|
||||
DEFAULT_XL330_M6 = _json.load(_f)
|
||||
|
||||
VIN = 7.4
|
||||
KP_FW = 200.0
|
||||
ACTUATOR_NAME = "1"
|
||||
|
||||
# Build BAM's M6 model + XL330 voltage-controlled actuator. The
|
||||
# MujocoController below drives the joint via this model on every step,
|
||||
# writing torque to data.ctrl and updating dof_frictionloss/dof_damping
|
||||
# so MuJoCo's solver applies BAM's Stribeck+load+quadratic friction.
|
||||
bam_model = bam_models["m6"]()
|
||||
bam_model.set_actuator(bam_actuators["xl330"]())
|
||||
bam_model.actuator.kp = KP_FW
|
||||
bam_model.actuator.vin = VIN
|
||||
bam_model.load_parameters_from_dict(DEFAULT_XL330_M6)
|
||||
|
||||
kt = bam_model.kt.value
|
||||
R = bam_model.R.value
|
||||
|
||||
spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML))
|
||||
_set_arm_mass(spec, TESTBENCH_ARM_MASS)
|
||||
|
||||
# MujocoController needs a torque-controlled motor; the XL330 entry in the
|
||||
# XML is a position actuator, so convert it and set the voltage-bounded
|
||||
# force range. Armature is set on the dof by MujocoController.__init__.
|
||||
for act in spec.actuators:
|
||||
act.set_to_motor()
|
||||
act.forcelimited = False
|
||||
fl = VIN * kt / R
|
||||
act.forcerange = (-fl, fl)
|
||||
act.gear = [1.0, 0, 0, 0, 0, 0]
|
||||
for joint in spec.joints:
|
||||
if joint.type == mujoco.mjtJoint.mjJNT_HINGE:
|
||||
joint.damping = 0.0
|
||||
joint.frictionloss = 0.0
|
||||
|
||||
model = spec.compile()
|
||||
data = mujoco.MjData(model)
|
||||
model.opt.timestep = SIM_DT
|
||||
|
||||
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1")
|
||||
dof_id = int(model.jnt_dofadr[joint_id])
|
||||
qpos_id = int(model.jnt_qposadr[joint_id])
|
||||
|
||||
data.qpos[qpos_id] = 0.0
|
||||
data.qvel[dof_id] = 0.0
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
bam_ctrl = MujocoController(bam_model, ACTUATOR_NAME, model, data)
|
||||
bam_ctrl.reset(data.qpos)
|
||||
|
||||
runner = PolicyRunner(onnx_path, action_scale=action_scale)
|
||||
policy_targets = make_target_schedule(total_time, seed=seed)
|
||||
decim = int(round(CONTROL_DT / SIM_DT))
|
||||
|
||||
# Logging at SIM_DT (200 Hz): decim samples per policy step.
|
||||
N_log = len(policy_targets) * decim
|
||||
rec = {k: np.zeros(N_log, dtype=np.float32)
|
||||
for k in ("t", "target", "q", "qd", "action", "ctrl")}
|
||||
|
||||
t = 0.0
|
||||
log_i = 0
|
||||
for policy_i, target in enumerate(policy_targets):
|
||||
q = float(data.qpos[qpos_id])
|
||||
qd = float(data.qvel[dof_id])
|
||||
goal = runner.step(q, qd, float(target))
|
||||
action_raw = float(runner.last_action[0])
|
||||
|
||||
for _ in range(decim):
|
||||
q = float(data.qpos[qpos_id])
|
||||
dq = float(data.qvel[dof_id])
|
||||
|
||||
# ---- log at 200 Hz ----
|
||||
rec["t"][log_i] = t
|
||||
rec["target"][log_i] = target
|
||||
rec["q"][log_i] = q
|
||||
rec["qd"][log_i] = dq
|
||||
rec["action"][log_i] = action_raw
|
||||
rec["ctrl"][log_i] = goal
|
||||
log_i += 1
|
||||
|
||||
# BAM owns control/torque/friction: set the target, then update()
|
||||
# writes torque to data.ctrl and pushes friction/damping onto the
|
||||
# dof so MuJoCo's solver applies them on the next step.
|
||||
bam_ctrl.set_q_target(ACTUATOR_NAME, goal)
|
||||
bam_ctrl.update()
|
||||
mujoco.mj_step(model, data)
|
||||
t += SIM_DT
|
||||
|
||||
return rec
|
||||
|
||||
|
||||
def rollout_sim_mjlab(onnx_path: str, total_time: float, seed: int, action_scale: float) -> dict:
|
||||
"""Sim rollout via the actual mjlab testbench env (same BAM M6 the policy was trained against).
|
||||
|
||||
Boots make_testbench_env_cfg() with num_envs=1, overrides the target_angle
|
||||
command with our deterministic schedule each policy tick, and steps the env
|
||||
with the policy action. We replicate ManagerBasedRlEnv.step's inner
|
||||
decimation loop manually so we can log q/qd at SIM_DT (200 Hz) between
|
||||
sub-steps, matching the bam backend's logging rate.
|
||||
"""
|
||||
import torch
|
||||
|
||||
from mjlab.envs import ManagerBasedRlEnv
|
||||
|
||||
from mjlab_microduck.tasks.testbench_env_cfg import make_testbench_env_cfg
|
||||
|
||||
env_cfg = make_testbench_env_cfg(play=True)
|
||||
env_cfg.scene.num_envs = 1
|
||||
# Disable auto-resampling and auto-reset so our deterministic schedule and
|
||||
# initial pose hold for the entire rollout.
|
||||
env_cfg.commands["target_angle"].resampling_time_range = (1e6, 1e6)
|
||||
env_cfg.episode_length_s = max(total_time + 10.0, env_cfg.episode_length_s)
|
||||
# Drop observation noise so the mjlab path is a fair sim2real reference
|
||||
# (matches the bam path which doesn't inject noise either).
|
||||
env_cfg.observations["policy"].enable_corruption = False
|
||||
|
||||
device = "cuda:0" if torch.cuda.is_available() else "cpu"
|
||||
env = ManagerBasedRlEnv(cfg=env_cfg, device=device)
|
||||
env.reset(seed=seed)
|
||||
|
||||
cmd_term = env.command_manager.get_term("target_angle")
|
||||
robot = env.scene["robot"]
|
||||
|
||||
runner = PolicyRunner(onnx_path, action_scale=action_scale)
|
||||
policy_targets = make_target_schedule(total_time, seed=seed)
|
||||
decim = env.cfg.decimation
|
||||
physics_dt = env.physics_dt
|
||||
N_log = len(policy_targets) * decim
|
||||
rec = {k: np.zeros(N_log, dtype=np.float32)
|
||||
for k in ("t", "target", "q", "qd", "action", "ctrl")}
|
||||
|
||||
t = 0.0
|
||||
log_i = 0
|
||||
for target in policy_targets:
|
||||
# Inject deterministic target and recompute obs so the policy sees it
|
||||
# this tick (the env's TargetAngleCommand otherwise samples randomly).
|
||||
cmd_term._target[0, 0] = float(target)
|
||||
# update_history=True is critical: the testbench env's joint_vel obs
|
||||
# has a 1-tick delay, so the history buffer must advance each policy
|
||||
# tick or the policy sees stale velocity.
|
||||
obs_buf = env.observation_manager.compute(update_history=True)
|
||||
policy_obs = obs_buf["policy"][0].detach().cpu().numpy().astype(np.float32)
|
||||
ort_out = runner.session.run(None, {runner.in_name: policy_obs[None, :]})[0].reshape(-1)
|
||||
runner.last_action = ort_out.astype(np.float32)
|
||||
action_raw = float(ort_out[0])
|
||||
goal = DEFAULT_POS + action_raw * action_scale
|
||||
|
||||
# Manually run the decimation loop ManagerBasedRlEnv.step uses, so we
|
||||
# can sample joint state at the physics rate (200 Hz).
|
||||
action = torch.as_tensor(ort_out, device=device).reshape(1, -1)
|
||||
env.action_manager.process_action(action)
|
||||
for _ in range(decim):
|
||||
# Log the pre-step state to mirror the bam backend (which records
|
||||
# q/qd right before each mj_step).
|
||||
rec["t"][log_i] = t
|
||||
rec["target"][log_i] = float(target)
|
||||
rec["q"][log_i] = float(robot.data.joint_pos[0, 0].item())
|
||||
rec["qd"][log_i] = float(robot.data.joint_vel[0, 0].item())
|
||||
rec["action"][log_i] = action_raw
|
||||
rec["ctrl"][log_i] = goal
|
||||
log_i += 1
|
||||
|
||||
env.action_manager.apply_action()
|
||||
env.scene.write_data_to_sim()
|
||||
env.sim.step()
|
||||
env.scene.update(dt=physics_dt)
|
||||
t += physics_dt
|
||||
|
||||
env.close()
|
||||
return rec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real rollout (rustypot XL330)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rollout_real(
|
||||
onnx_path: str,
|
||||
total_time: float,
|
||||
seed: int,
|
||||
port: str,
|
||||
motor_id: int,
|
||||
baudrate: int,
|
||||
kp: int,
|
||||
action_scale: float,
|
||||
) -> dict:
|
||||
from rustypot import Xl330PyController
|
||||
|
||||
ctrl = Xl330PyController(port, baudrate, 0.05)
|
||||
assert ctrl.ping(motor_id), f"motor id={motor_id} not responding on {port}"
|
||||
|
||||
# Match the firmware gain used in sim (BAM kp_fw=200).
|
||||
ctrl.write_torque_enable(motor_id, False)
|
||||
ctrl.write_operating_mode(motor_id, 3) # position control
|
||||
ctrl.write_position_p_gain(motor_id, kp)
|
||||
ctrl.write_position_i_gain(motor_id, 0)
|
||||
ctrl.write_position_d_gain(motor_id, 0)
|
||||
# Read back to confirm the gain actually landed (firmware silently clamps
|
||||
# out-of-range values, so verifying catches mismatches early).
|
||||
readback = ctrl.read_position_p_gain(motor_id)
|
||||
if isinstance(readback, (list, tuple)):
|
||||
readback = readback[0]
|
||||
print(f" XL330 position P-gain: requested={kp}, readback={readback}")
|
||||
ctrl.write_goal_position(motor_id, 0.0)
|
||||
ctrl.write_torque_enable(motor_id, True)
|
||||
time.sleep(1.0) # let it settle at zero
|
||||
|
||||
runner = PolicyRunner(onnx_path, action_scale=action_scale)
|
||||
policy_targets = make_target_schedule(total_time, seed=seed)
|
||||
|
||||
decim = int(round(CONTROL_DT / LOG_DT)) # samples per policy tick (4 at 200 Hz / 50 Hz)
|
||||
N_log = len(policy_targets) * decim
|
||||
rec = {k: np.zeros(N_log, dtype=np.float32)
|
||||
for k in ("t", "target", "q", "qd", "action", "ctrl")}
|
||||
|
||||
def _scalar(x) -> float:
|
||||
if isinstance(x, (list, tuple)):
|
||||
return float(x[0])
|
||||
return float(x)
|
||||
|
||||
t_start = time.perf_counter()
|
||||
prev_q = 0.0
|
||||
log_i = 0
|
||||
goal = 0.0
|
||||
action_raw = 0.0
|
||||
|
||||
for policy_i, target in enumerate(policy_targets):
|
||||
tick_start = time.perf_counter()
|
||||
target_f = float(target)
|
||||
|
||||
# Read once, run policy, write goal — all at the start of the 20 ms window.
|
||||
q = _scalar(ctrl.read_present_position(motor_id))
|
||||
try:
|
||||
qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S
|
||||
except Exception:
|
||||
qd = (q - prev_q) / CONTROL_DT
|
||||
|
||||
goal = runner.step(q, qd, target_f)
|
||||
action_raw = float(runner.last_action[0])
|
||||
# ctrl.write_goal_position(motor_id, float(np.clip(goal, -MAX_ANGLE, MAX_ANGLE)))
|
||||
ctrl.write_goal_position(motor_id, float(goal))
|
||||
|
||||
# First 200 Hz sample uses the values we just read (no extra USB round-trip).
|
||||
rec["t"][log_i] = time.perf_counter() - t_start
|
||||
rec["target"][log_i] = target_f
|
||||
rec["q"][log_i] = q
|
||||
rec["qd"][log_i] = qd
|
||||
rec["action"][log_i] = action_raw
|
||||
rec["ctrl"][log_i] = goal
|
||||
prev_q = q
|
||||
log_i += 1
|
||||
|
||||
# Remaining (decim-1) samples inside the policy window: read only.
|
||||
for k in range(1, decim):
|
||||
sample_deadline = tick_start + (k + 1) * LOG_DT
|
||||
while time.perf_counter() < sample_deadline - 0.001:
|
||||
time.sleep(0.0005)
|
||||
q = _scalar(ctrl.read_present_position(motor_id))
|
||||
try:
|
||||
qd = _scalar(ctrl.read_present_velocity(motor_id)) * DXL_VEL_TICK_TO_RAD_S
|
||||
except Exception:
|
||||
qd = (q - prev_q) / LOG_DT
|
||||
prev_q = q
|
||||
|
||||
rec["t"][log_i] = time.perf_counter() - t_start
|
||||
rec["target"][log_i] = target_f
|
||||
rec["q"][log_i] = q
|
||||
rec["qd"][log_i] = qd
|
||||
rec["action"][log_i] = action_raw
|
||||
rec["ctrl"][log_i] = goal
|
||||
log_i += 1
|
||||
|
||||
# Live status on every new segment plus a heartbeat.
|
||||
new_segment = policy_i == 0 or policy_targets[policy_i] != policy_targets[policy_i - 1]
|
||||
if new_segment or policy_i % 25 == 0:
|
||||
print(
|
||||
f"\r t={rec['t'][log_i-1]:6.2f}s target={math.degrees(target_f):+6.1f}° "
|
||||
f"q={math.degrees(q):+6.1f}° err={math.degrees(q - target_f):+6.1f}° "
|
||||
f"goal={math.degrees(goal):+6.1f}°",
|
||||
end="" if not new_segment else "\n",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Hold the remaining time of the policy window if we got here early.
|
||||
dt_left = CONTROL_DT - (time.perf_counter() - tick_start)
|
||||
if dt_left > 0:
|
||||
time.sleep(dt_left)
|
||||
print()
|
||||
|
||||
ctrl.write_torque_enable(motor_id, False)
|
||||
return rec
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plotting / analytics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mae(a: np.ndarray, b: np.ndarray) -> float:
|
||||
n = min(len(a), len(b))
|
||||
return float(np.mean(np.abs(a[:n] - b[:n])))
|
||||
|
||||
|
||||
def npz_to_bam_log(npz_path: str, json_path: str, *, mass: float, length: float,
|
||||
kp: int, vin: float) -> None:
|
||||
"""Convert a rollout .npz (written by rollout_sim/rollout_real) to a BAM log json.
|
||||
|
||||
BAM log format (see ~/Rhoban/bam/bam/logs.py):
|
||||
top-level: mass, length, kp, vin, motor, trajectory, dt
|
||||
entries: position, speed, load, input_volts, temp, goal_position, torque_enable, timestamp
|
||||
Can be fed to `python -m bam.plot --logdir <dir> --actuator xl330`.
|
||||
"""
|
||||
import json
|
||||
|
||||
d = dict(np.load(npz_path))
|
||||
t = d["t"]
|
||||
# Prefer the actual recorded timestamps for dt to handle small jitter;
|
||||
# fall back to the fixed control period if there are fewer than 2 samples.
|
||||
dt = float(np.mean(np.diff(t))) if len(t) > 1 else CONTROL_DT
|
||||
|
||||
entries = []
|
||||
for i in range(len(t)):
|
||||
entries.append({
|
||||
"position": float(d["q"][i]),
|
||||
"speed": float(d["qd"][i]),
|
||||
"load": 0.0,
|
||||
"input_volts": vin,
|
||||
"temp": 25.0,
|
||||
"goal_position": float(d["ctrl"][i]),
|
||||
"torque_enable": True,
|
||||
"timestamp": float(t[i]),
|
||||
})
|
||||
|
||||
log = {
|
||||
"mass": mass,
|
||||
"length": length,
|
||||
"kp": kp,
|
||||
"vin": vin,
|
||||
"motor": "xl330",
|
||||
"trajectory": "rl_policy",
|
||||
"dt": dt,
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
out = Path(json_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(out, "w") as f:
|
||||
json.dump(log, f)
|
||||
print(f"Wrote BAM log: {out} ({len(entries)} entries, dt={dt:.4f}s, mass={mass}kg, kp={kp})")
|
||||
print(f" Replay with: (cd ~/Rhoban/bam && python -m bam.plot --logdir {out.parent} --actuator xl330)")
|
||||
|
||||
|
||||
def compare_and_plot(sim_file: str, real_file: str, out_path: str) -> None:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
sim = dict(np.load(sim_file))
|
||||
real = dict(np.load(real_file))
|
||||
n = min(len(sim["t"]), len(real["t"]))
|
||||
t = sim["t"][:n]
|
||||
|
||||
err_sim = sim["q"][:n] - sim["target"][:n]
|
||||
err_real = real["q"][:n] - real["target"][:n]
|
||||
|
||||
print("\n=== Analytics ===")
|
||||
print(f" steps compared : {n}")
|
||||
print(f" MAE q (sim vs real) : {_mae(sim['q'], real['q']):.4f} rad "
|
||||
f"({math.degrees(_mae(sim['q'], real['q'])):.2f}°)")
|
||||
print(f" sim tracking MAE : {float(np.mean(np.abs(err_sim))):.4f} rad "
|
||||
f"({math.degrees(float(np.mean(np.abs(err_sim)))):.2f}°)")
|
||||
print(f" real tracking MAE : {float(np.mean(np.abs(err_real))):.4f} rad "
|
||||
f"({math.degrees(float(np.mean(np.abs(err_real)))):.2f}°)")
|
||||
print(f" sim qd RMS : {float(np.sqrt(np.mean(sim['qd'][:n]**2))):.3f} rad/s")
|
||||
print(f" real qd RMS : {float(np.sqrt(np.mean(real['qd'][:n]**2))):.3f} rad/s")
|
||||
print(f" action MAE : {_mae(sim['action'], real['action']):.4f} rad")
|
||||
|
||||
fig, axes = plt.subplots(4, 1, figsize=(11, 10), sharex=True)
|
||||
|
||||
axes[0].plot(t, sim["target"][:n], "k-", lw=1, label="target", alpha=0.4)
|
||||
axes[0].plot(t, sim["q"][:n], "b-", lw=1.2, label="sim q")
|
||||
axes[0].plot(t, real["q"][:n], "r-", lw=1.2, label="real q")
|
||||
axes[0].plot(t, sim["ctrl"][:n], "b:", lw=0.8, alpha=0.6, label="sim goal (policy)")
|
||||
axes[0].plot(t, real["ctrl"][:n], "r:", lw=0.8, alpha=0.6, label="real goal (policy)")
|
||||
axes[0].set_ylabel("position [rad]")
|
||||
axes[0].legend(loc="upper right", fontsize=8)
|
||||
axes[0].grid(alpha=0.3)
|
||||
axes[0].set_title(f"Testbench sim2real — MAE(sim, real) = {_mae(sim['q'], real['q']):.4f} rad")
|
||||
|
||||
axes[1].plot(t, np.degrees(err_sim), "b-", lw=1, label="sim")
|
||||
axes[1].plot(t, np.degrees(err_real), "r-", lw=1, label="real")
|
||||
axes[1].axhline(0, color="k", lw=0.5)
|
||||
axes[1].set_ylabel("tracking error [deg]")
|
||||
axes[1].legend(fontsize=8)
|
||||
axes[1].grid(alpha=0.3)
|
||||
|
||||
axes[2].plot(t, sim["qd"][:n], "b-", lw=1, label="sim")
|
||||
axes[2].plot(t, real["qd"][:n], "r-", lw=1, label="real")
|
||||
axes[2].set_ylabel("velocity [rad/s]")
|
||||
axes[2].legend(fontsize=8)
|
||||
axes[2].grid(alpha=0.3)
|
||||
|
||||
axes[3].plot(t, sim["action"][:n], "b-", lw=1, label="sim action")
|
||||
axes[3].plot(t, real["action"][:n], "r-", lw=1, label="real action")
|
||||
axes[3].set_ylabel("policy action [rad]")
|
||||
axes[3].set_xlabel("time [s]")
|
||||
axes[3].legend(fontsize=8)
|
||||
axes[3].grid(alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(out_path, dpi=140)
|
||||
print(f"\nSaved plot: {out_path}")
|
||||
plt.show()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--mode", choices=["sim", "real"], help="Rollout mode")
|
||||
ap.add_argument("--sim-backend", choices=["bam", "mjlab"], default="bam",
|
||||
help="Sim backend: 'bam' uses bam.MujocoController on a vanilla "
|
||||
"mujoco loop (200 Hz log, lightweight); 'mjlab' boots the actual "
|
||||
"make_testbench_env_cfg() mjlab env with its BamM6Actuator (50 Hz log).")
|
||||
ap.add_argument("--onnx", type=str, help="Path to trained ONNX policy")
|
||||
ap.add_argument("--out", type=str, help="Output .npz log file")
|
||||
ap.add_argument("--duration", type=float, default=30.0, help="Total time [s]")
|
||||
ap.add_argument("--seed", type=int, default=0, help="Target schedule seed")
|
||||
# real-only
|
||||
ap.add_argument("--port", type=str, default="/dev/ttyUSB0")
|
||||
ap.add_argument("--motor-id", type=int, default=1)
|
||||
ap.add_argument("--baudrate", type=int, default=1_000_000)
|
||||
ap.add_argument("--kp", type=int, default=200, help="XL330 position P gain")
|
||||
ap.add_argument("--action-scale", type=float, default=1.0,
|
||||
help="Multiplier applied to the policy action before offsetting "
|
||||
"by the default pose (must match training env action scale)")
|
||||
# compare mode
|
||||
ap.add_argument("--compare", nargs=2, metavar=("SIM_NPZ", "REAL_NPZ"),
|
||||
help="Plot two logged runs side by side")
|
||||
ap.add_argument("--out-plot", type=str, default="testbench_sim2real.png")
|
||||
# BAM log export
|
||||
ap.add_argument("--to-bam", nargs=2, metavar=("NPZ", "JSON"),
|
||||
help="Convert a rollout NPZ to BAM log format "
|
||||
"(run with: python -m bam.plot --logdir <dir> --actuator xl330)")
|
||||
ap.add_argument("--bam-mass", type=float, default=TESTBENCH_ARM_MASS, help="Payload mass [kg]")
|
||||
ap.add_argument("--bam-length", type=float, default=0.1, help="Arm length [m]")
|
||||
ap.add_argument("--bam-vin", type=float, default=7.4, help="Supply voltage [V]")
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.compare:
|
||||
compare_and_plot(args.compare[0], args.compare[1], args.out_plot)
|
||||
return
|
||||
|
||||
if args.to_bam:
|
||||
npz_to_bam_log(
|
||||
args.to_bam[0],
|
||||
args.to_bam[1],
|
||||
mass=args.bam_mass,
|
||||
length=args.bam_length,
|
||||
kp=args.kp,
|
||||
vin=args.bam_vin,
|
||||
)
|
||||
return
|
||||
|
||||
if not (args.mode and args.onnx and args.out):
|
||||
ap.error("--mode, --onnx and --out are required for a rollout")
|
||||
|
||||
if args.mode == "sim":
|
||||
sim_fn = rollout_sim_mjlab if args.sim_backend == "mjlab" else rollout_sim_bam
|
||||
rec = sim_fn(args.onnx, args.duration, args.seed, args.action_scale)
|
||||
else:
|
||||
rec = rollout_real(
|
||||
args.onnx, args.duration, args.seed,
|
||||
args.port, args.motor_id, args.baudrate, args.kp,
|
||||
args.action_scale,
|
||||
)
|
||||
|
||||
out = Path(args.out)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.savez(out, **rec)
|
||||
err = rec["q"] - rec["target"]
|
||||
print(f"\nSaved {len(rec['t'])} samples to {out}")
|
||||
print(f" tracking MAE: {float(np.mean(np.abs(err))):.4f} rad ({math.degrees(float(np.mean(np.abs(err)))):.2f}°)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Validate the BAM M6 actuator kernel against real testbench data.
|
||||
|
||||
Loads real testbench recordings, replays them in MuJoCo with the BAM M6 actuator,
|
||||
and compares simulated vs real position traces. Also runs BAM's own Python simulator
|
||||
as a reference.
|
||||
|
||||
Usage:
|
||||
uv run python3 scripts/validate_bam_testbench.py [--plot] [--max-files N]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from copy import copy
|
||||
from pathlib import Path
|
||||
|
||||
import mujoco
|
||||
import numpy as np
|
||||
|
||||
# ── Paths ──
|
||||
BAM_DIR = Path(os.path.expanduser("~/Rhoban/bam"))
|
||||
DATA_DIR = BAM_DIR / "bam" / "data" / "processed"
|
||||
PARAMS_FILE = BAM_DIR / "params" / "xl330" / "m6_new.json"
|
||||
TESTBENCH_XML = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "src"
|
||||
/ "mjlab_microduck"
|
||||
/ "robot"
|
||||
/ "xl330_test_bench"
|
||||
/ "scene.xml"
|
||||
)
|
||||
|
||||
# ── Load M6 params ──
|
||||
with open(PARAMS_FILE) as f:
|
||||
M6 = json.load(f)
|
||||
|
||||
# XL330 firmware constants
|
||||
ERROR_GAIN = (4096 / (2 * np.pi)) / (256 * 885)
|
||||
VIN = 7.4
|
||||
MAX_PWM = 1.0
|
||||
|
||||
|
||||
def bam_python_rollout(log: dict) -> list[float]:
|
||||
"""Reference: BAM's own Python simulator."""
|
||||
sys.path.insert(0, str(BAM_DIR))
|
||||
from bam.model import load_model
|
||||
from bam.simulate import Simulator
|
||||
|
||||
# BAM expects arm_mass in the log (mass of the arm itself, not the payload)
|
||||
if "arm_mass" not in log:
|
||||
log = dict(log)
|
||||
log["arm_mass"] = 0.0
|
||||
|
||||
model = load_model(str(PARAMS_FILE))
|
||||
sim = Simulator(model)
|
||||
result = sim.rollout_log(log, simulate_control=True)
|
||||
return result[0] # positions
|
||||
|
||||
|
||||
def compute_m6_friction(motor_torque, external_torque, dq):
|
||||
"""M6 friction computation matching our kernel (and BAM's model.py)."""
|
||||
p = M6
|
||||
stribeck_coeff = np.exp(-(np.abs(dq / p["dtheta_stribeck"]) ** p["alpha"]))
|
||||
|
||||
gearbox_torque = np.abs(
|
||||
external_torque * p["load_friction_external"]
|
||||
- motor_torque * p["load_friction_motor"]
|
||||
)
|
||||
gearbox_torque_stribeck = np.abs(
|
||||
external_torque * p["load_friction_external_stribeck"]
|
||||
- motor_torque * p["load_friction_motor_stribeck"]
|
||||
)
|
||||
|
||||
frictionloss = p["friction_base"]
|
||||
frictionloss += gearbox_torque
|
||||
frictionloss += stribeck_coeff * p["friction_stribeck"]
|
||||
frictionloss += gearbox_torque_stribeck * stribeck_coeff
|
||||
# quadratic (tiny, skip for clarity)
|
||||
|
||||
damping = p["friction_viscous"]
|
||||
friction_budget = frictionloss + damping * np.abs(dq)
|
||||
return friction_budget
|
||||
|
||||
|
||||
def mujoco_rollout(log: dict) -> list[float]:
|
||||
"""Run the testbench in MuJoCo with our BAM M6 actuator logic."""
|
||||
mass = log["mass"]
|
||||
kp_fw = log["kp"]
|
||||
dt = log["dt"]
|
||||
entries = log["entries"]
|
||||
|
||||
# Load and modify the testbench model
|
||||
spec = mujoco.MjSpec.from_file(str(TESTBENCH_XML))
|
||||
|
||||
# Convert actuator to motor (same as our kernel's edit_spec)
|
||||
for act in spec.actuators:
|
||||
act.set_to_motor()
|
||||
act.forcelimited = True
|
||||
force_limit = VIN * M6["kt"] / M6["R"]
|
||||
act.forcerange = (-force_limit, force_limit)
|
||||
act.gear = [1.0, 0, 0, 0, 0, 0]
|
||||
|
||||
# Zero out MuJoCo joint friction (we handle it)
|
||||
for joint in spec.joints:
|
||||
if joint.type == mujoco.mjtJoint.mjJNT_HINGE:
|
||||
joint.damping = 0.0
|
||||
joint.frictionloss = 0.0
|
||||
joint.armature = M6["armature"]
|
||||
|
||||
# Set the arm mass to match the BAM recording
|
||||
for body in spec.bodies:
|
||||
if body.name == "arm":
|
||||
# Scale mass and inertia proportionally
|
||||
original_mass = body.mass
|
||||
scale = mass / original_mass if original_mass > 0 else 1.0
|
||||
body.mass = mass
|
||||
# Scale inertia proportionally to mass
|
||||
body.fullinertia = [x * scale for x in body.fullinertia]
|
||||
break
|
||||
|
||||
model = spec.compile()
|
||||
data = mujoco.MjData(model)
|
||||
model.opt.timestep = dt
|
||||
|
||||
# Find joint and actuator IDs
|
||||
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, "1")
|
||||
dof_id = model.jnt_dofadr[joint_id]
|
||||
|
||||
# Initialize state
|
||||
data.qpos[dof_id] = entries[0]["position"]
|
||||
data.qvel[dof_id] = entries[0].get("speed", 0.0)
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
positions = []
|
||||
for entry in entries:
|
||||
positions.append(float(data.qpos[dof_id]))
|
||||
|
||||
if not entry["torque_enable"]:
|
||||
data.ctrl[0] = 0.0
|
||||
mujoco.mj_step(model, data)
|
||||
continue
|
||||
|
||||
goal = entry["goal_position"]
|
||||
q = data.qpos[dof_id]
|
||||
dq = data.qvel[dof_id]
|
||||
|
||||
# ── BAM M6 actuator logic (same as our kernel) ──
|
||||
|
||||
# 1. Firmware control law
|
||||
duty = (goal - q) * kp_fw * ERROR_GAIN
|
||||
duty = np.clip(duty, -MAX_PWM, MAX_PWM)
|
||||
voltage = VIN * duty
|
||||
|
||||
# 2. DC motor torque
|
||||
motor_torque = M6["kt"] * voltage / M6["R"] - M6["kt"] ** 2 * dq / M6["R"]
|
||||
|
||||
# 3. External torque (from MuJoCo bias forces)
|
||||
# BAM convention: bias_torque = m*g*l*sin(q) with g=-9.81 (gravity negative)
|
||||
# MuJoCo convention: qfrc_bias has opposite sign
|
||||
external_torque = -data.qfrc_bias[dof_id]
|
||||
|
||||
# 4. M6 friction
|
||||
friction_budget = compute_m6_friction(motor_torque, external_torque, dq)
|
||||
|
||||
# 5. Static friction clipping
|
||||
eff_inertia = 1.0 / model.dof_invweight0[dof_id] if model.dof_invweight0[dof_id] > 0 else 1e6
|
||||
net_no_friction = motor_torque + external_torque
|
||||
tau_stop = (eff_inertia / dt) * dq + net_no_friction
|
||||
friction_mag = min(abs(tau_stop), friction_budget)
|
||||
friction_torque = -np.sign(tau_stop) * friction_mag
|
||||
|
||||
# 6. Set ctrl = motor + friction (MuJoCo adds qfrc_bias)
|
||||
data.ctrl[0] = motor_torque + friction_torque
|
||||
|
||||
mujoco.mj_step(model, data)
|
||||
|
||||
return positions
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--plot", action="store_true", help="Show plots")
|
||||
parser.add_argument("--max-files", type=int, default=5)
|
||||
args = parser.parse_args()
|
||||
|
||||
data_files = sorted(DATA_DIR.glob("*.json"))
|
||||
if args.max_files:
|
||||
data_files = data_files[: args.max_files]
|
||||
|
||||
print(f"Validating BAM M6 kernel against {len(data_files)} testbench recordings")
|
||||
print(f"M6 params: kt={M6['kt']:.4f} R={M6['R']:.4f}")
|
||||
print(f"Testbench XML: {TESTBENCH_XML}")
|
||||
print()
|
||||
|
||||
results = []
|
||||
for fpath in data_files:
|
||||
log = json.load(open(fpath))
|
||||
name = f"{log['trajectory']}_m{log['mass']}_kp{log['kp']}"
|
||||
print(f" {name}...", end=" ", flush=True)
|
||||
|
||||
real_pos = [e["position"] for e in log["entries"]]
|
||||
|
||||
# BAM Python reference
|
||||
bam_pos = bam_python_rollout(log)
|
||||
|
||||
# Our MuJoCo M6 kernel
|
||||
mj_pos = mujoco_rollout(log)
|
||||
|
||||
# Compute MAE
|
||||
real_np = np.array(real_pos)
|
||||
bam_np = np.array(bam_pos)
|
||||
mj_np = np.array(mj_pos[: len(real_np)])
|
||||
|
||||
mae_bam = np.mean(np.abs(bam_np - real_np))
|
||||
mae_mj = np.mean(np.abs(mj_np - real_np))
|
||||
mae_bam_vs_mj = np.mean(np.abs(bam_np - mj_np))
|
||||
|
||||
print(
|
||||
f"MAE bam_vs_real={mae_bam:.5f} mj_vs_real={mae_mj:.5f} bam_vs_mj={mae_bam_vs_mj:.5f}"
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"name": name,
|
||||
"real": real_np,
|
||||
"bam": bam_np,
|
||||
"mj": mj_np,
|
||||
"mae_bam": mae_bam,
|
||||
"mae_mj": mae_mj,
|
||||
"mae_bam_vs_mj": mae_bam_vs_mj,
|
||||
}
|
||||
)
|
||||
|
||||
print()
|
||||
avg_bam = np.mean([r["mae_bam"] for r in results])
|
||||
avg_mj = np.mean([r["mae_mj"] for r in results])
|
||||
avg_diff = np.mean([r["mae_bam_vs_mj"] for r in results])
|
||||
print(f"Average MAE bam_vs_real={avg_bam:.5f} mj_vs_real={avg_mj:.5f} bam_vs_mj={avg_diff:.5f}")
|
||||
|
||||
if avg_diff > 0.01:
|
||||
print("\n⚠ BAM and MuJoCo diverge significantly — likely a kernel bug!")
|
||||
elif avg_mj > avg_bam * 1.5:
|
||||
print("\n⚠ MuJoCo worse than BAM — MuJoCo dynamics differ from BAM's simple integrator")
|
||||
else:
|
||||
print("\n✓ BAM and MuJoCo agree — kernel is correct")
|
||||
|
||||
if args.plot:
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
n = len(results)
|
||||
fig, axes = plt.subplots(n, 1, figsize=(12, 3 * n), sharex=False)
|
||||
if n == 1:
|
||||
axes = [axes]
|
||||
|
||||
for ax, r in zip(axes, results):
|
||||
t = np.arange(len(r["real"])) * 0.005
|
||||
ax.plot(t, r["real"], "k-", lw=1.5, label="Real")
|
||||
ax.plot(t, r["bam"], "b--", lw=1.2, label=f'BAM (MAE={r["mae_bam"]:.4f})')
|
||||
ax.plot(t, r["mj"], "r:", lw=1.2, label=f'MuJoCo M6 (MAE={r["mae_mj"]:.4f})')
|
||||
ax.set_title(r["name"])
|
||||
ax.set_ylabel("Position (rad)")
|
||||
ax.legend(fontsize=8)
|
||||
ax.grid(alpha=0.3)
|
||||
|
||||
axes[-1].set_xlabel("Time (s)")
|
||||
plt.tight_layout()
|
||||
plt.savefig("bam_validation.png", dpi=150)
|
||||
print("Saved bam_validation.png")
|
||||
plt.show()
|
||||
except ImportError:
|
||||
print("matplotlib not available, skipping plots")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Observer la rampe du mode pente (roller_slope) dans le viewer MuJoCo.
|
||||
|
||||
Construit UNIQUEMENT le terrain « plat + rampe » (FlatRampTerrainCfg), sur
|
||||
plusieurs rangées de difficulté croissante (raideur 2° -> 20°), et ouvre le
|
||||
viewer natif MuJoCo. Aucune politique entraînée n'est nécessaire — c'est fait
|
||||
pour valider à l'œil la géométrie (jointure plat/rampe, sens de descente).
|
||||
|
||||
Usage :
|
||||
uv run python scripts/view_slope_terrain.py
|
||||
uv run python scripts/view_slope_terrain.py --rows 6 --ramp-max 8 --runout 4
|
||||
uv run python scripts/view_slope_terrain.py --build-only # test sans GUI
|
||||
|
||||
Dans le viewer : molette pour zoomer, clic-gauche glisser pour orbiter,
|
||||
clic-droit glisser pour translater. Chaque rangée est une rampe de plus en plus
|
||||
raide (difficulté 0 -> 1), de longueur tirée au hasard dans [ramp-min, ramp-max],
|
||||
et se termine par un plat de sortie. « Devant » (+x) doit descendre.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
import mujoco
|
||||
import mujoco.viewer
|
||||
|
||||
from mjlab.terrains.terrain_generator import TerrainGenerator, TerrainGeneratorCfg
|
||||
from mjlab_microduck.tasks.slope_terrain import (
|
||||
FlatRampTerrainCfg,
|
||||
RAMP_DEG_MIN,
|
||||
RAMP_DEG_MAX,
|
||||
)
|
||||
|
||||
|
||||
def build_model(rows, size, flat_length, ramp_range, runout, deg_min, deg_max):
|
||||
"""Construit le modèle MuJoCo du terrain seul (rows rampes de raideur croissante)."""
|
||||
cfg = TerrainGeneratorCfg(
|
||||
seed=0,
|
||||
size=size,
|
||||
num_rows=rows,
|
||||
num_cols=1,
|
||||
curriculum=True, # difficulté croissante le long des rangées
|
||||
difficulty_range=(0.0, 1.0),
|
||||
add_lights=True,
|
||||
sub_terrains={
|
||||
"flat_ramp": FlatRampTerrainCfg(
|
||||
flat_length=flat_length,
|
||||
ramp_length_range=ramp_range,
|
||||
runout_length=runout,
|
||||
deg_min=deg_min,
|
||||
deg_max=deg_max,
|
||||
)
|
||||
},
|
||||
)
|
||||
generator = TerrainGenerator(cfg)
|
||||
spec = mujoco.MjSpec()
|
||||
generator.compile(spec)
|
||||
model = spec.compile()
|
||||
return model
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--rows", type=int, default=5, help="Nb de rangées = nb de raideurs affichées (défaut 5)")
|
||||
p.add_argument("--size", type=float, nargs=2, default=(15.0, 4.0), help="Taille d'une tuile (x y) en m")
|
||||
p.add_argument("--flat-length", type=float, default=2.0, help="Longueur du plat de départ (m)")
|
||||
p.add_argument("--ramp-min", type=float, default=3.0, help="Longueur horizontale mini de la rampe (m)")
|
||||
p.add_argument("--ramp-max", type=float, default=8.0, help="Longueur horizontale maxi de la rampe (m)")
|
||||
p.add_argument("--runout", type=float, default=4.0, help="Longueur du plat de sortie (m)")
|
||||
p.add_argument("--deg-min", type=float, default=RAMP_DEG_MIN, help=f"Raideur min en degrés (défaut {RAMP_DEG_MIN})")
|
||||
p.add_argument("--deg-max", type=float, default=RAMP_DEG_MAX, help=f"Raideur max en degrés (défaut {RAMP_DEG_MAX})")
|
||||
p.add_argument("--build-only", action="store_true", help="Construit le modèle et quitte (test sans GUI)")
|
||||
args = p.parse_args()
|
||||
|
||||
model = build_model(
|
||||
rows=args.rows,
|
||||
size=tuple(args.size),
|
||||
flat_length=args.flat_length,
|
||||
ramp_range=(args.ramp_min, args.ramp_max),
|
||||
runout=args.runout,
|
||||
deg_min=args.deg_min,
|
||||
deg_max=args.deg_max,
|
||||
)
|
||||
data = mujoco.MjData(model)
|
||||
mujoco.mj_forward(model, data)
|
||||
|
||||
print(
|
||||
f"Terrain construit : {args.rows} rampes, raideur {args.deg_min}°->{args.deg_max}°, "
|
||||
f"longueur rampe {args.ramp_min}-{args.ramp_max}m + sortie {args.runout}m, "
|
||||
f"{model.ngeom} géométries."
|
||||
)
|
||||
if args.build_only:
|
||||
print("--build-only : OK, pas de GUI.")
|
||||
return
|
||||
|
||||
print("Ouverture du viewer MuJoCo (Ctrl+C pour quitter)…")
|
||||
with mujoco.viewer.launch_passive(model, data, show_left_ui=False, show_right_ui=False) as viewer:
|
||||
while viewer.is_running():
|
||||
mujoco.mj_forward(model, data)
|
||||
viewer.sync()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""Shared wandb helpers for play_latest and export_latest scripts."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
import wandb
|
||||
|
||||
WANDB_PROJECT = "pollen-robotics/mjlab_microduck"
|
||||
|
||||
|
||||
def find_latest_run(user_filter: str) -> wandb.apis.public.Run | None:
|
||||
"""Return the most recent run whose metadata email contains *user_filter*."""
|
||||
api = wandb.Api()
|
||||
runs = api.runs(WANDB_PROJECT, per_page=100, order="-created_at")
|
||||
for run in runs:
|
||||
email = (run.metadata or {}).get("email", "")
|
||||
if user_filter.lower() in email.lower():
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def find_latest_runs(user_filter, task_match, n):
|
||||
"""Return up to *n* most recent runs whose metadata email contains
|
||||
*user_filter* and whose task_id (metadata args[0]) satisfies *task_match*.
|
||||
|
||||
task_match: Callable[[str], bool] applied to the wandb task_id string.
|
||||
Ordered most-recent first.
|
||||
"""
|
||||
api = wandb.Api()
|
||||
runs = api.runs(WANDB_PROJECT, per_page=100, order="-created_at")
|
||||
matched = []
|
||||
for run in runs:
|
||||
email = (run.metadata or {}).get("email", "")
|
||||
if user_filter.lower() not in email.lower():
|
||||
continue
|
||||
args = (run.metadata or {}).get("args", [])
|
||||
task_id = args[0] if args else ""
|
||||
if not task_match(task_id):
|
||||
continue
|
||||
matched.append(run)
|
||||
if len(matched) >= n:
|
||||
break
|
||||
return matched
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
h = int(seconds // 3600)
|
||||
m = int((seconds % 3600) // 60)
|
||||
return f"{h}h{m:02d}m"
|
||||
|
||||
|
||||
def print_run_info(run: wandb.apis.public.Run) -> dict:
|
||||
"""Print run details and return dict with env_name, run_path, checkpoints."""
|
||||
meta = run.metadata
|
||||
summary = run.summary
|
||||
train_cfg = run.config.get("train_cfg", {})
|
||||
env_cfg = run.config.get("env_cfg", {})
|
||||
|
||||
env_name = meta.get("args", ["?"])[0]
|
||||
run_path = f"{WANDB_PROJECT}/{run.id}"
|
||||
duration = summary.get("_runtime", 0)
|
||||
total_steps = summary.get("_step", 0)
|
||||
max_iter = train_cfg.get("max_iterations", "?")
|
||||
num_envs = env_cfg.get("scene", {}).get("terrain", {}).get("num_envs", "?")
|
||||
mean_reward = summary.get("Train/mean_reward")
|
||||
lr = summary.get("Loss/learning_rate")
|
||||
|
||||
reward_items = [
|
||||
(k.removeprefix("Episode_Reward/"), v)
|
||||
for k, v in summary.items()
|
||||
if k.startswith("Episode_Reward/") and isinstance(v, (int, float))
|
||||
]
|
||||
reward_items.sort(key=lambda x: abs(x[1]), reverse=True)
|
||||
|
||||
checkpoints = sorted(
|
||||
[f.name for f in run.files() if f.name.startswith("model_") and f.name.endswith(".pt")]
|
||||
)
|
||||
last_ckpt = checkpoints[-1] if checkpoints else "none"
|
||||
|
||||
created = datetime.fromisoformat(run.created_at.replace("Z", "+00:00"))
|
||||
date_str = created.astimezone().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
print("=" * 60)
|
||||
print(f" Run: {run.name}")
|
||||
print(f" ID: {run.id}")
|
||||
print(f" Date: {date_str}")
|
||||
print(f" Status: {run.state}")
|
||||
print(f" Environment: {env_name}")
|
||||
print(f" Duration: {_format_duration(duration)}")
|
||||
print(f" Progress: {total_steps} / {max_iter} iterations")
|
||||
print(f" Num envs: {num_envs}")
|
||||
print(f" Mean reward: {mean_reward:.2f}" if mean_reward else " Mean reward: ?")
|
||||
print(f" Learning rate:{lr:.2e}" if lr else " Learning rate:?")
|
||||
print(f" Last ckpt: {last_ckpt}")
|
||||
print(f" Host: {meta.get('host', '?')}")
|
||||
print(f" GPU: {meta.get('gpu', '?')}")
|
||||
print(f" User: {meta.get('email', '?')}")
|
||||
print()
|
||||
print(" Top rewards (by magnitude):")
|
||||
for name, val in reward_items[:8]:
|
||||
sign = "+" if val >= 0 else ""
|
||||
print(f" {name:<35s} {sign}{val:.4f}")
|
||||
print("=" * 60)
|
||||
|
||||
return {"env_name": env_name, "run_path": run_path, "checkpoints": checkpoints}
|
||||
|
||||
|
||||
def resolve_run(user: str, task_substr: str | None = None) -> tuple[wandb.apis.public.Run, dict]:
|
||||
"""Find latest run for user (optionally filtered to a task-id substring),
|
||||
print info, return (run, info). Exits on failure."""
|
||||
if task_substr:
|
||||
print(f"Searching latest '{task_substr}' run for user '{user}'...")
|
||||
runs = find_latest_runs(user, lambda t: task_substr.lower() in t.lower(), 1)
|
||||
run = runs[0] if runs else None
|
||||
else:
|
||||
print(f"Searching latest run for user '{user}'...")
|
||||
run = find_latest_run(user)
|
||||
if run is None:
|
||||
suffix = f" matching '{task_substr}'" if task_substr else ""
|
||||
print(f"No run found for user '{user}'{suffix}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
info = print_run_info(run)
|
||||
return run, info
|
||||
|
||||
|
||||
def run_command(cmd: list[str], dry_run: bool) -> None:
|
||||
"""Print and optionally execute a command from the project root."""
|
||||
print()
|
||||
print("Command:")
|
||||
print(f" {' '.join(cmd)}")
|
||||
print()
|
||||
if dry_run:
|
||||
print("(dry-run, not executing)")
|
||||
return
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
subprocess.run(cmd, cwd=project_root)
|
||||
Reference in New Issue
Block a user