"""Custom presets: a registered WBC task (the *base*) plus overrides edited in the Studio.
A preset changes what the base task's builder produced, without code:
observations actor (deployed) / critic terms: remove, add from the robot's term library,
noise scale, history, keybody subset; group history / corruption
rewards weight, numeric params, on / off per term
terminations numeric params, on / off per term
command reference / RSI / assist knobs of the motion command (``rsi.bin_width_s`` …)
events domain-randomization ranges, on / off per event
agent PPO and network knobs (``algorithm.learning_rate``, ``actor.hidden_dims`` …)
*Modules* are reusable partial overrides without a base (``se``: the state-estimation actor
layout, derived from the built-in ``<task>-SE`` vs ``<task>`` pair; your own in
``modules/``). A preset applies its modules in order, then its own overrides.
Everything lives outside the repo in :func:`config_dir` (``~/.config/wbc-mjlab``, or
``$WBC_MJLAB_CONFIG_DIR``): ``presets/<name>.yaml``, ``modules/<name>.yaml``.
:func:`register` turns a preset into a real mjlab task ``<base>--<name>`` logging to
``<base experiment>--<name>``, so train, play and run discovery treat it like a built-in task
(in this process; training jobs register it via ``python -m wbc_mjlab.studio.train_preset``,
which also stores the resolved preset in the run: ``params/studio_preset.yaml``, used to replay
the run whatever happens to the preset later).
"""
from __future__ import annotations
import copy
import dataclasses
import functools
import inspect
import re
import time
import typing
from pathlib import Path
from typing import Any
import yaml
SEP = "--"
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,47}$")
GROUPS = (
"observations",
"rewards",
"terminations",
"command",
"events",
"terrain",
"agent",
)
OBS_GROUPS = ("actor", "critic")
SNAPSHOT = "studio_preset.yaml"
BUILTIN_MODULES = {
"se": (
"-SE",
"State estimation: the actor drops height / gravity references, adds anchor pose "
"error and IMU base velocity",
),
}
# Motion-command fields that are wiring, not tuning knobs.
_COMMAND_SKIP = {
"motion_file",
"anchor_body_name",
"body_names",
"entity_name",
"debug_vis",
"viz",
"viz.mode",
"actuated_joint_names",
"sampling_mode", # legacy top-level field; rsi.sampling_mode is the live one
"adaptive_kernel_size",
"adaptive_lambda",
"adaptive_uniform_ratio",
"adaptive_alpha",
"rsi.similarity_terms",
"rsi.failure_levels_filename",
"rsi.tracking_reward_prefix",
"rsi.persist_failure_levels",
}
_AGENT_KEEP = (
"seed",
"num_steps_per_env",
"save_interval",
"algorithm",
"actor.hidden_dims",
"actor.activation",
"critic.hidden_dims",
)
_AGENT_SKIP = {
"algorithm.class_name",
"algorithm.optimizer",
"algorithm.share_cnn_encoders",
}
HELP = {
"rsi.sampling_mode": "where episodes start: clip start, uniform, or adaptive (failure-weighted bins)",
"rsi.strategy": "failure signal for adaptive sampling",
"rsi.bin_width_s": "length of one RSI bin",
"rsi.uniform_ratio": "share of starts drawn uniformly",
"rsi.alpha": "EMA rate of the failure levels",
"rsi.temperature_base": "softmax temperature over bin failure levels",
"assistive_wrench_enabled": "virtual wrench that helps on hard bins (curriculum)",
"assistive_beta_max": "max assist gain on the hardest bins",
"joint_position_range": "noise added to the reference joints at reset",
"algorithm.learning_rate": "Adam learning rate",
"algorithm.entropy_coef": "exploration bonus",
"actor.hidden_dims": "changes the network: checkpoints of other sizes won't load",
}
def config_dir() -> Path:
"""Per-user Studio configs (not in the repo): see :mod:`wbc_mjlab.user_config`."""
from wbc_mjlab.user_config import config_dir as user_config_dir
return user_config_dir()
def presets_dir() -> Path:
return config_dir() / "presets"
def modules_dir() -> Path:
return config_dir() / "modules"
# ---------------------------------------------------------------------- schema
def _num(v: Any) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def _field(key: str, value: Any, *, choices=None) -> dict | None:
"""One editable value ``{key, kind, value, …}`` or None when it is not a tuning knob."""
if isinstance(value, bool):
return {"key": key, "kind": "bool", "value": value}
if _num(value):
return {
"key": key,
"kind": "int" if isinstance(value, int) else "number",
"value": value,
}
if choices and isinstance(value, str):
return {"key": key, "kind": "choice", "value": value, "choices": list(choices)}
if isinstance(value, (tuple, list)) and value and all(_num(x) for x in value):
if len(value) == 2 and not all(isinstance(x, int) for x in value):
return {"key": key, "kind": "range", "value": [float(x) for x in value]}
if all(isinstance(x, int) for x in value):
return {"key": key, "kind": "ints", "value": [int(x) for x in value]}
return None
def _param_fields(params: dict | None) -> list[dict]:
out = []
for k, v in (params or {}).items():
if (
isinstance(v, dict)
and v
and all(
isinstance(x, (tuple, list)) and len(x) == 2 and all(_num(y) for y in x)
for x in v.values()
)
):
out += [
{"key": f"{k}.{sk}", "kind": "range", "value": [float(a) for a in sv]}
for sk, sv in v.items()
]
continue
f = _field(k, v)
if f is not None:
out.append(f)
return out
def _literal_choices(cls, name: str):
try:
hint = typing.get_type_hints(cls).get(name)
except Exception: # noqa: BLE001
return None
if typing.get_origin(hint) is typing.Literal:
return typing.get_args(hint)
return None
def _walk_dataclass(obj, prefix: str = "", depth: int = 0) -> list[dict]:
out = []
for f in dataclasses.fields(obj):
key = f"{prefix}{f.name}"
value = getattr(obj, f.name)
if dataclasses.is_dataclass(value) and depth < 1:
out += _walk_dataclass(value, f"{key}.", depth + 1)
continue
if (
isinstance(value, dict)
and value
and all(isinstance(x, (tuple, list)) and len(x) == 2 for x in value.values())
):
out += [
{"key": f"{key}.{sk}", "kind": "range", "value": [float(a) for a in sv]}
for sk, sv in value.items()
]
continue
item = _field(key, value, choices=_literal_choices(type(obj), f.name))
if item is not None:
out.append(item)
return out
def _func_name(term) -> str:
fn = getattr(term, "func", None)
return getattr(fn, "__name__", type(fn).__name__ if fn is not None else "")
[docs]
def describe(env_cfg, agent_cfg) -> dict:
"""Every editable knob of a task's configs, with its current (default) value."""
rewards = [
{
"name": name,
"func": _func_name(t),
"weight": float(t.weight),
"params": _param_fields(t.params),
}
for name, t in env_cfg.rewards.items()
]
terminations = [
{
"name": name,
"func": _func_name(t),
"time_out": bool(getattr(t, "time_out", False)),
"params": _param_fields(t.params),
}
for name, t in env_cfg.terminations.items()
]
events = [
{
"name": name,
"func": _func_name(t),
"mode": str(getattr(t, "mode", "")),
"params": _param_fields(t.params),
}
for name, t in env_cfg.events.items()
]
command = []
motion = env_cfg.commands.get("motion") if hasattr(env_cfg.commands, "get") else None
if motion is not None:
command = [f for f in _walk_dataclass(motion) if f["key"] not in _COMMAND_SKIP]
command.append(
{
"key": "env.episode_length_s",
"kind": "number",
"value": float(env_cfg.episode_length_s),
}
)
agent = [
f
for f in _walk_dataclass(agent_cfg)
if f["key"] not in _AGENT_SKIP
and any(f["key"] == k or f["key"].startswith(k + ".") for k in _AGENT_KEEP)
]
for group in (command, agent):
for f in group:
if f["key"] in HELP:
f["help"] = HELP[f["key"]]
return {
"terrain": _describe_terrain(env_cfg),
"observations": _describe_observations(env_cfg),
"rewards": rewards,
"terminations": terminations,
"command": command,
"events": events,
"agent": agent,
}
# ---------------------------------------------------------------------- terrain
_TERRAIN_SKIP = {
"size",
"border_width",
"border_height",
"horizontal_scale",
"vertical_scale",
"slope_threshold",
"resolution",
"base_thickness_ratio",
"flat_patch_sampling",
"merge_similar_heights",
"height_merge_threshold",
"max_merge_distance",
}
TERRAIN_HELP = {
"curriculum.start_row": "row new envs start on (0 = easiest); they move up as they track well",
"curriculum.num_rows": "difficulty levels (rows) of the curriculum",
"curriculum.difficulty_range": "difficulty of the first and the last row",
}
def _generator(env_cfg):
t = getattr(env_cfg.scene, "terrain", None)
if t is None or getattr(t, "terrain_type", "") != "generator":
return None, None
return t, t.terrain_generator
def _describe_terrain(env_cfg) -> list[dict]:
"""Knobs of a procedural terrain (``-Terrain-Scan`` tasks): curriculum and the mix and
ranges of each sub-terrain (``<sub>.proportion``, ``rough.noise_range`` …)."""
t, gen = _generator(env_cfg)
if gen is None:
return []
out = []
if t.max_init_terrain_level is not None:
out.append(
{
"key": "curriculum.start_row",
"kind": "int",
"value": int(t.max_init_terrain_level),
}
)
for key in ("num_rows", "difficulty_range"):
f = _field(f"curriculum.{key}", getattr(gen, key))
if f is not None:
out.append(f)
for name, sub in gen.sub_terrains.items():
for f in _walk_dataclass(sub):
if f["key"].split(".")[0] in _TERRAIN_SKIP:
continue
out.append(f | {"key": f"{name}.{f['key']}"})
for f in out:
if f["key"] in TERRAIN_HELP:
f["help"] = TERRAIN_HELP[f["key"]]
return out
def _apply_terrain(env_cfg, ov: dict) -> None:
t, gen = _generator(env_cfg)
if gen is None:
return
for key, value in (ov or {}).items():
if key == "curriculum.start_row":
t.max_init_terrain_level = int(value)
elif key.startswith("curriculum."):
_set_path(gen, key.removeprefix("curriculum."), value)
else:
sub, _, rest = key.partition(".")
if sub in gen.sub_terrains:
_set_path(gen.sub_terrains[sub], rest, value)
# ---------------------------------------------------------------------- observations
def _obs_kind(name: str) -> str:
"""``reference``: what the robot is asked to track (from the motion); ``state``: measured
robot state; ``privileged``: sim-only signals (contacts, assist, curriculum)."""
if name.startswith(
("assist", "keybody_contact", "motion_segment", "motion_tracking")
):
return "privileged"
if name.startswith(("ref_", "motion_")) or name == "command":
return "reference"
return "state"
def _noise_text(noise) -> str:
if noise is None:
return ""
lo, hi = getattr(noise, "n_min", None), getattr(noise, "n_max", None)
if hi is None:
return type(noise).__name__
def f(v):
if isinstance(v, (tuple, list)):
return "(" + ", ".join(f"{x:g}" for x in v) + ")"
return f"{v:g}"
neg = (tuple(-x for x in hi) if isinstance(hi, (tuple, list)) else -hi) == (
tuple(lo) if isinstance(lo, (tuple, list)) else lo
)
return f"±{f(hi)}" if neg else f"[{f(lo)}, {f(hi)}]"
def _scale_noise(noise, factor: float):
if noise is None or factor == 1:
return noise
if factor <= 0:
return None
lo, hi = getattr(noise, "n_min", None), getattr(noise, "n_max", None)
if hi is None:
return noise
def mul(v):
return tuple(x * factor for x in v) if isinstance(v, (tuple, list)) else v * factor
return dataclasses.replace(noise, n_min=mul(lo), n_max=mul(hi))
def _takes_bodies(term) -> bool:
try:
return "body_names" in inspect.signature(term.func).parameters
except (TypeError, ValueError):
return False
# critic-only terms the export table doesn't cover: (per keybody, constant)
_EXTRA_DIMS = {
"ref_body_lin_vel": (3, 0),
"ref_body_ang_vel": (3, 0),
"motion_body_lin_vel": (3, 0),
"motion_body_ang_vel": (3, 0),
"ref_base_lin_acc": (0, 3),
"ref_base_ang_acc": (0, 3),
"assistive_force": (0, 3),
"assistive_torque": (0, 3),
"assistive_gain": (0, 1),
"motion_segment_phase": (0, 1),
}
def _obs_dim(
name: str, term, joints: int, bodies: int, tracking: int = 0, env_cfg=None
) -> int | None:
"""Width of a term (None if unknown without building the env; ``tracking``: number of
``motion_*`` reward terms; ``env_cfg``: sensor layouts, for ``height_scan``)."""
from wbc_mjlab.export.tracking_params_yaml import (
_height_scan_params,
_observation_dim,
)
if name == "height_scan":
try:
d = _height_scan_params(env_cfg, term)["num_rays"]
except Exception: # noqa: BLE001 — no cfg / sensor at hand
return None
return d * max(1, int(getattr(term, "history_length", 0) or 0))
sub = (term.params or {}).get("body_names")
nb = len(sub) if sub else bodies
try:
d = _observation_dim(name, joint_count=joints, body_count=nb)
except KeyError:
if name in _EXTRA_DIMS:
per_body, const = _EXTRA_DIMS[name]
d = per_body * nb + const
elif name == "motion_tracking_step_rewards" and tracking:
d = tracking
else:
return None
return d * max(1, int(getattr(term, "history_length", 0) or 0))
[docs]
def deployable(name: str) -> bool:
"""Actor terms the exporter (``tracking_params.yaml``) knows how to rebuild on the robot."""
from wbc_mjlab.export.tracking_params_yaml import _observation_dim
if (
name == "height_scan"
): # exported with its grid layout (elevation map on the robot)
return True
try:
_observation_dim(name, joint_count=1, body_count=1)
return True
except KeyError:
return False
def _robot_sizes(env_cfg) -> tuple[int, int, list[str]]:
motion = env_cfg.commands.get("motion") if hasattr(env_cfg.commands, "get") else None
bodies = list(getattr(motion, "body_names", ()) or ())
try:
from wbc_mjlab.export.tracking_params_yaml import _joint_names_from_cfg
joints = len(_joint_names_from_cfg(env_cfg))
except Exception: # noqa: BLE001
joints = 0
return joints, len(bodies), bodies
def _describe_observations(env_cfg) -> dict:
joints, nbodies, bodies = _robot_sizes(env_cfg)
tracking = sum(n.startswith("motion_") for n in env_cfg.rewards)
groups = []
for g in OBS_GROUPS:
grp = env_cfg.observations.get(g)
if grp is None:
continue
terms = []
for name, t in grp.terms.items():
sub = (t.params or {}).get("body_names")
terms.append(
{
"name": name,
"func": _func_name(t),
"kind": _obs_kind(name),
"dim": _obs_dim(name, t, joints, nbodies, tracking, env_cfg),
"noise": _noise_text(t.noise),
"history_length": int(t.history_length or 0),
"bodies": list(sub) if sub else None,
"takes_bodies": _takes_bodies(t),
"deployable": deployable(name) if g == "actor" else True,
}
)
groups.append(
{
"name": g,
"history_length": int(grp.history_length or 0),
"enable_corruption": bool(grp.enable_corruption),
"terms": terms,
}
)
return {"groups": groups, "bodies": bodies, "joints": joints}
@functools.lru_cache(maxsize=8)
def _library(robot_id: str) -> dict[str, dict]:
"""Every observation term the robot's built-in tasks define: ``name → {group: term}``
(the actor variant carries the deploy noise, the critic one is noise-free)."""
from wbc_mjlab.robots.env import make_wbc_env_cfg
from wbc_mjlab.tasks import all_wbc_tasks, register_all_wbc_tasks
register_all_wbc_tasks()
lib: dict[str, dict] = {}
for t in all_wbc_tasks():
if t.robot_id != robot_id or SEP in t.task_id:
continue
cfg = make_wbc_env_cfg(robot_id, task_id=t.task_id)
for g in OBS_GROUPS:
grp = cfg.observations.get(g)
for name, term in (grp.terms if grp else {}).items():
lib.setdefault(name, {}).setdefault(g, term)
return lib
def _missing_sensor(term, env_cfg) -> str:
"""The scene sensor a term reads that ``env_cfg`` doesn't have ("" if none is missing;
``robot/…`` sensors come with the robot model)."""
sensor = (term.params or {}).get("sensor_name") or ""
if not sensor or sensor.startswith("robot/") or env_cfg is None:
return ""
return "" if sensor in {s.name for s in env_cfg.scene.sensors or ()} else sensor
[docs]
def library(
robot_id: str, joints: int = 0, bodies: int = 0, env_cfg=None
) -> list[dict]:
"""The term library as rows for the UI (``dim`` when known; ``bodies`` = keybody subset
of the variant a preset gets when it adds the term to the actor; ``requires``: a scene
sensor ``env_cfg`` lacks, e.g. the height scan of the ``-Terrain-Scan`` tasks)."""
out = []
for name, v in sorted(_library(robot_id).items()):
term = v.get("actor") or next(iter(v.values()))
sub = (term.params or {}).get("body_names")
out.append(
{
"name": name,
"kind": _obs_kind(name),
"dim": _obs_dim(name, term, joints, bodies, env_cfg=env_cfg),
"requires": _missing_sensor(term, env_cfg),
"takes_bodies": _takes_bodies(term),
"bodies": list(sub) if sub else None,
"func": _func_name(term),
"deployable": deployable(name),
"noise": _noise_text(term.noise),
}
)
return out
def _new_term(robot_id: str, name: str, group: str):
variants = _library(robot_id).get(name)
if not variants:
raise ValueError(f"no observation term {name!r} for robot {robot_id}")
term = copy.deepcopy(variants.get(group) or next(iter(variants.values())))
if group != "actor":
term.noise = None
return term
def _apply_observations(env_cfg, ov: dict, robot_id: str | None) -> None:
for g, o in (ov or {}).items():
grp = env_cfg.observations.get(g)
if grp is None or not isinstance(o, dict):
continue
if "history_length" in o:
grp.history_length = int(o["history_length"]) or None
if "enable_corruption" in o:
grp.enable_corruption = bool(o["enable_corruption"])
for name, t in (o.get("terms") or {}).items():
if t.get("enabled") is False:
grp.terms.pop(name, None)
continue
if name not in grp.terms:
if not t.get("add"):
continue
if robot_id is None:
raise ValueError(f"adding {name!r} needs the robot")
term = _new_term(robot_id, name, g)
missing = _missing_sensor(term, env_cfg)
if missing:
raise ValueError(
f"{name} reads the {missing!r} sensor, which this task doesn't have: start "
"from a task that has it (e.g. a -Terrain-Scan task)"
)
grp.terms[name] = term
term = grp.terms[name]
if "noise_scale" in t:
term.noise = _scale_noise(term.noise, float(t["noise_scale"]))
if "history_length" in t:
term.history_length = int(t["history_length"])
if "bodies" in t and _takes_bodies(term):
term.params = dict(term.params or {})
if t["bodies"]:
term.params["body_names"] = tuple(t["bodies"])
else:
term.params.pop("body_names", None)
[docs]
def observation_diff(a, b) -> dict:
"""Observation overrides turning env cfg ``a`` into ``b`` (terms removed / added)."""
out: dict = {}
for g in OBS_GROUPS:
ta, tb = a.observations.get(g), b.observations.get(g)
if ta is None or tb is None:
continue
terms = {n: {"enabled": False} for n in ta.terms if n not in tb.terms}
terms |= {n: {"add": True} for n in tb.terms if n not in ta.terms}
if terms:
out[g] = {"terms": terms}
return out
# ---------------------------------------------------------------------- apply
def _coerce(old: Any, new: Any) -> Any:
if isinstance(old, bool):
return bool(new)
if isinstance(old, int) and not isinstance(old, bool):
return int(round(float(new)))
if isinstance(old, float):
return float(new)
if isinstance(old, tuple):
return (
tuple(_coerce(o, n) for o, n in zip(old, new, strict=True))
if len(old) == len(new)
else tuple(new)
)
if isinstance(old, list):
return list(new)
return new
def _set_param(params: dict, key: str, value: Any) -> None:
head, _, sub = key.partition(".")
if sub and isinstance(params.get(head), dict):
d = params[head]
k = next((x for x in d if str(x) == sub), None)
if k is not None:
d[k] = _coerce(d[k], value)
return
if key in params:
params[key] = _coerce(params[key], value)
def _set_path(obj, path: str, value: Any) -> None:
parts = path.split(".")
for p in parts[:-1]:
nxt = getattr(obj, p, None)
if nxt is None:
return
if isinstance(nxt, dict): # dict of ranges (pose_range.x)
k = next((x for x in nxt if str(x) == parts[-1]), None)
if k is not None:
nxt[k] = _coerce(nxt[k], value)
return
obj = nxt
if hasattr(obj, parts[-1]):
setattr(obj, parts[-1], _coerce(getattr(obj, parts[-1]), value))
[docs]
def apply(env_cfg, agent_cfg, overrides: dict, robot_id: str | None = None) -> None:
"""Apply preset ``overrides`` to an env config and a runner config in place
(``robot_id``: where added observation terms come from)."""
ov = overrides or {}
_apply_observations(env_cfg, ov.get("observations"), robot_id)
_apply_terrain(env_cfg, ov.get("terrain"))
for group in ("rewards", "terminations", "events"):
terms = getattr(env_cfg, group)
for name, o in (ov.get(group) or {}).items():
if name not in terms:
continue
if o.get("enabled") is False:
del terms[name]
continue
term = terms[name]
if "weight" in o and hasattr(term, "weight"):
term.weight = float(o["weight"])
if o.get("params"):
term.params = dict(term.params or {})
for k, v in o["params"].items():
_set_param(term.params, k, v)
motion = env_cfg.commands.get("motion") if hasattr(env_cfg.commands, "get") else None
for key, value in (ov.get("command") or {}).items():
if key.startswith("env."):
_set_path(env_cfg, key[4:], value)
elif motion is not None:
_set_path(motion, key, value)
if agent_cfg is not None:
for key, value in (ov.get("agent") or {}).items():
_set_path(agent_cfg, key, value)
def count_changes(overrides: dict) -> int:
n = 0
for o in (overrides.get("observations") or {}).values():
n += sum(k != "terms" for k in o) + sum(
len(t) for t in (o.get("terms") or {}).values()
)
for group in ("rewards", "terminations", "events"):
for o in (overrides.get(group) or {}).values():
n += (
int(o.get("enabled") is False) + int("weight" in o) + len(o.get("params") or {})
)
return n + sum(len(overrides.get(g) or {}) for g in ("command", "terrain", "agent"))
# ---------------------------------------------------------------------- storage
def _check(name: str) -> str:
if not NAME_RE.match(name or ""):
raise ValueError("names: letters, digits, '_', '-', '.' (max 48)")
return name
def _path(name: str) -> Path:
return presets_dir() / f"{_check(name)}.yaml"
def _module_path(name: str) -> Path:
return modules_dir() / f"{_check(name)}.yaml"
def _clean(overrides: dict) -> dict:
return {g: overrides[g] for g in GROUPS if (overrides or {}).get(g)}
def task_id(preset: dict) -> str:
return f"{preset['base']}{SEP}{preset['name']}"
def list_presets(robot: str | None = None) -> list[dict]:
out = []
d = presets_dir()
for p in sorted(d.glob("*.yaml")) if d.is_dir() else []:
try:
pr = load(p.stem)
except Exception: # noqa: BLE001
continue
if robot and pr.get("robot") and pr["robot"] != robot:
continue
out.append(
pr | {"task": task_id(pr), "changes": count_changes(pr.get("overrides") or {})}
)
return out
def load(name: str) -> dict:
data = yaml.safe_load(_path(name).read_text()) or {}
data["name"] = name
data.setdefault("overrides", {})
data["modules"] = list(data.get("modules") or [])
return data
[docs]
def save(
name: str,
base: str,
overrides: dict,
description: str = "",
modules: list[str] | None = None,
) -> dict:
from wbc_mjlab.tasks import get_task_config
cfg = get_task_config(base)
modules = list(modules or [])
module_overrides(modules, cfg.robot_id) # every module must exist
data = {
"base": cfg.task_id,
"robot": cfg.robot_id,
"description": description,
"updated": time.strftime("%Y-%m-%d %H:%M"),
"modules": modules,
"overrides": _clean(overrides),
}
p = _path(name)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(yaml.safe_dump(data, sort_keys=False))
return data | {"name": name}
def delete(name: str) -> None:
_path(name).unlink(missing_ok=True)
# ---------------------------------------------------------------------- modules
@functools.lru_cache(maxsize=8)
def _builtin_modules(robot_id: str) -> dict[str, dict]:
"""Modules read off the built-in tasks: ``se`` = ``<task>-SE`` minus ``<task>``."""
from wbc_mjlab.robots.env import make_wbc_env_cfg
from wbc_mjlab.tasks import all_wbc_tasks, register_all_wbc_tasks
register_all_wbc_tasks()
ids = {t.task_id for t in all_wbc_tasks() if t.robot_id == robot_id}
out = {}
for name, (suffix, text) in BUILTIN_MODULES.items():
pair = next(
(
(t[: -len(suffix)], t)
for t in sorted(ids, key=lambda x: (len(x), x))
if t.endswith(suffix) and t[: -len(suffix)] in ids
),
None,
)
if pair is None:
continue
a, b = (make_wbc_env_cfg(robot_id, task_id=t) for t in pair)
out[name] = {
"name": name,
"builtin": True,
"description": text,
"source": f"{pair[1]} vs {pair[0]}",
"overrides": {"observations": observation_diff(a, b)},
}
return out
[docs]
def list_modules(robot: str | None = None) -> list[dict]:
out = [
m | {"changes": count_changes(m["overrides"])}
for m in (_builtin_modules(robot).values() if robot else ())
]
d = modules_dir()
for p in sorted(d.glob("*.yaml")) if d.is_dir() else []:
try:
m = load_module(p.stem)
except Exception: # noqa: BLE001
continue
if robot and m.get("robot") and m["robot"] != robot:
continue
out.append(m | {"changes": count_changes(m["overrides"])})
return out
def load_module(name: str, robot: str | None = None) -> dict:
if robot and name in _builtin_modules(robot):
return _builtin_modules(robot)[name]
data = yaml.safe_load(_module_path(name).read_text()) or {}
data["name"] = name
data["builtin"] = False
data.setdefault("overrides", {})
return data
[docs]
def save_module(
name: str, overrides: dict, description: str = "", robot: str = ""
) -> dict:
if name in BUILTIN_MODULES:
raise ValueError(f"{name!r} is a built-in module: pick another name")
data = {
"description": description,
"robot": robot, # body / term names are robot-specific
"updated": time.strftime("%Y-%m-%d %H:%M"),
"overrides": _clean(overrides),
}
p = _module_path(name)
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text(yaml.safe_dump(data, sort_keys=False))
return data | {"name": name, "builtin": False}
def delete_module(name: str) -> None:
if name in BUILTIN_MODULES:
raise ValueError("built-in modules can't be deleted")
_module_path(name).unlink(missing_ok=True)
def module_overrides(names: list[str], robot_id: str) -> list[dict]:
out = []
for n in names or ():
try:
out.append({"name": n, "overrides": load_module(n, robot_id)["overrides"]})
except OSError as exc:
raise ValueError(f"module {n!r} not found") from exc
return out
# ---------------------------------------------------------------------- resolution
[docs]
def resolve(name: str) -> dict:
"""Preset ``name`` with its modules inlined: all it takes to rebuild its configs (the run
snapshot)."""
from wbc_mjlab.tasks import get_task_config, register_all_wbc_tasks
register_all_wbc_tasks()
preset = load(name)
robot = get_task_config(preset["base"]).robot_id
return preset | {"resolved_modules": module_overrides(preset["modules"], robot)}
def snapshot_of(run_dir: Path) -> dict | None:
p = Path(run_dir) / "params" / SNAPSHOT
if not p.is_file():
return None
try:
return yaml.safe_load(p.read_text()) or None
except Exception: # noqa: BLE001
return None
def split_task_id(tid: str) -> tuple[str, str] | None:
"""``"<base>--<name>"`` → ``(base, name)`` for preset task ids (None otherwise)."""
if SEP not in tid:
return None
base, name = tid.rsplit(SEP, 1)
return base, name
def task_for_experiment(experiment: str) -> str:
"""Preset task id of a run's experiment folder ``<base experiment>--<name>`` ("" if none)."""
if SEP not in experiment:
return ""
_, name = experiment.rsplit(SEP, 1)
try:
return task_id(load(name))
except (OSError, ValueError):
return ""
# ---------------------------------------------------------------------- registration
_REGISTERED: dict[str, str] = {}
[docs]
def build(
base: str,
overrides: dict,
*,
play: bool = False,
modules: list[dict] | list[str] = (),
):
"""Env + runner configs of ``base`` with ``modules`` (names, or resolved
``{name, overrides}``) then ``overrides`` applied (fresh copies)."""
from wbc_mjlab.robots.env import make_wbc_env_cfg
from wbc_mjlab.tasks import _rl_cfg_for_task, get_task_config
cfg = get_task_config(base)
env_cfg = make_wbc_env_cfg(cfg.robot_id, play=play, task_id=cfg.task_id)
agent_cfg = copy.deepcopy(_rl_cfg_for_task(cfg))
if modules and isinstance(modules[0], str):
modules = module_overrides(list(modules), cfg.robot_id)
for m in modules:
apply(env_cfg, agent_cfg, m["overrides"], cfg.robot_id)
apply(env_cfg, agent_cfg, overrides, cfg.robot_id)
if (
play and "actor" in env_cfg.observations
): # overrides can't turn play noise back on
env_cfg.observations["actor"].enable_corruption = False
if play: # play spreads envs over every terrain row (no curriculum), as the task does
t, gen = _generator(env_cfg)
if gen is not None:
t.max_init_terrain_level = None
return env_cfg, agent_cfg
[docs]
def register(name: str, snapshot: dict | None = None) -> str:
"""Register preset ``name`` (or a run's resolved ``snapshot`` of it) as the mjlab / WBC task
``<base>--<name>``, re-registering when the content changed; returns the task id."""
import mjlab # noqa: F401
from mjlab.tasks.registry import register_mjlab_task
from wbc_mjlab import tasks as wbc_tasks
from wbc_mjlab.rl.runner import PolicyOnlyMotionTrackingRunner
from wbc_mjlab.tasks.config import WbcTaskConfig
wbc_tasks.register_all_wbc_tasks()
preset = snapshot or resolve(name)
tid = task_id(preset)
stamp = yaml.safe_dump(preset, sort_keys=True)
if _REGISTERED.get(name) == stamp:
return tid
base = wbc_tasks.get_task_config(preset["base"])
overrides = preset.get("overrides") or {}
modules = preset.get("resolved_modules") or []
env_cfg, agent_cfg = build(base.task_id, overrides, modules=modules)
play_cfg, _ = build(base.task_id, overrides, play=True, modules=modules)
experiment = f"{base.experiment_name}{SEP}{name}"
agent_cfg.experiment_name = experiment
from mjlab.tasks import registry as mj_registry
mj_registry._REGISTRY.pop(tid, None) # noqa: SLF001 — the preset changed: re-register
register_mjlab_task(
task_id=tid,
env_cfg=env_cfg,
play_env_cfg=play_cfg,
rl_cfg=agent_cfg,
runner_cls=PolicyOnlyMotionTrackingRunner,
)
mods = [m["name"] for m in modules]
wbc_tasks._TASK_BY_ID[tid] = WbcTaskConfig( # noqa: SLF001
task_id=tid,
robot_id=base.robot_id,
description=preset.get("description")
or " + ".join([base.task_id, *mods, f"{count_changes(overrides)} change(s)"]),
experiment_name=experiment,
build_env_cfg=lambda: build(base.task_id, overrides, modules=modules)[0],
)
wbc_tasks._MJLAB_REGISTERED.add(tid) # noqa: SLF001
_REGISTERED[name] = stamp
return tid
__all__ = [
"GROUPS",
"SEP",
"apply",
"build",
"config_dir",
"count_changes",
"delete",
"delete_module",
"describe",
"library",
"list_modules",
"list_presets",
"load",
"load_module",
"observation_diff",
"register",
"resolve",
"save",
"save_module",
"snapshot_of",
"split_task_id",
"task_for_experiment",
"task_id",
]