"""Plot channels for the Studio's Plots window.
Two sources, one layout (``{channels: [{id, name, group, unit, lower, upper}], data, ref}``
with ``data`` / ``ref`` (rows, channels) float32 values):
- **Clip** (Data step) — :func:`clip_channels`: the open clip's root position, root roll /
pitch / yaw, every joint (with its limits) and the feet's sole heights. A converted clip
previewed against its raw source gets the source as ``ref`` (resampled to the clip's
frames), drawn dashed. Sent once per clip as JSON (values little-endian, base64).
- **Play** — :class:`PlayRecorder`: a rolling record of the session's envs while the
policy runs: the robot's root, joints, PD targets, torques and actions, the reference it
tracks as ``ref``, per-keybody tracking errors and every reward term (weighted, as summed
into the reward, and raw: before the weight). Row ``k`` pairs the robot after step ``k``
with the reference that step's rewards compared it to (the command advances after the
rewards, so that is the frame set at the end of step ``k - 1``). Recording is one
vectorized write per step for every recorded env, on the sim device; the client pulls new
rows as binary (:func:`encode_rows`) with every stage frame.
"""
from __future__ import annotations
import base64
import json
import re
import struct
import threading
import time
import numpy as np
import torch
HISTORY_S = 60.0 # seconds of Play kept per recording
_SIDE = (
("left", re.compile(r"(^|[_\W])l([_\W]|$)|left", re.I)),
("right", re.compile(r"(^|[_\W])r([_\W]|$)|right", re.I)),
)
_PART = (
("leg", re.compile(r"hip|thigh|knee|shin|calf|ankle|foot|toe|leg", re.I)),
("arm", re.compile(r"shoulder|elbow|wrist|hand|arm|finger|thumb|palm", re.I)),
(
"torso",
re.compile(r"waist|torso|spine|pelvis|trunk|chest|abdomen|lumbar|back", re.I),
),
("head", re.compile(r"head|neck", re.I)),
)
[docs]
def joint_group(name: str) -> str:
""" "Left leg", "Right arm", "Torso", "Head" or "Other" from a joint's name."""
part = next((label for label, p in _PART if p.search(name)), None)
side = next((label for label, p in _SIDE if p.search(name)), None)
if part in ("leg", "arm"):
return f"{side.capitalize()} {part}" if side else f"{part.capitalize()}s"
return {"torso": "Torso", "head": "Head"}.get(part or "", "Other")
def _b64(rows: np.ndarray) -> str:
return base64.b64encode(np.ascontiguousarray(rows, dtype="<f4").tobytes()).decode()
def _rpy(quat_wxyz: np.ndarray) -> np.ndarray:
"""Roll, pitch, yaw (rad, unwrapped over time) of quaternions (T, 4)."""
w, x, y, z = (quat_wxyz[:, i] for i in range(4))
roll = np.arctan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y))
pitch = np.arcsin(np.clip(2 * (w * y - z * x), -1.0, 1.0))
yaw = np.arctan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z))
return np.unwrap(np.stack([roll, pitch, yaw], axis=1), axis=0)
def _rpy_t(q: torch.Tensor) -> torch.Tensor:
"""Roll, pitch, yaw of quaternions (..., 4) wxyz on the sim device (wrapped: the client
unwraps channels marked ``wrap``)."""
w, x, y, z = q.unbind(-1)
return torch.stack(
(
torch.atan2(2 * (w * x + y * z), 1 - 2 * (x * x + y * y)),
torch.asin(torch.clamp(2 * (w * y - z * x), -1.0, 1.0)),
torch.atan2(2 * (w * z + x * y), 1 - 2 * (y * y + z * z)),
),
dim=-1,
)
def _ch(cid: str, name: str, group: str, unit: str, **extra) -> dict:
return {"id": cid, "name": name, "group": group, "unit": unit, **extra}
def _root_channels(wrap: bool = False) -> list[dict]:
out = [_ch(f"root_{a}", f"root {a}", "Root position", "m") for a in "xyz"]
out += [
_ch(
f"root_{a}",
f"root {a}",
"Root rotation",
"rad",
**({"wrap": True} if wrap else {}),
)
for a in ("roll", "pitch", "yaw")
]
return out
def _joint_channel(
name: str, limits: dict, group: str | None = None, prefix: str = ""
) -> dict:
lo, hi = limits.get(name, (None, None))
return _ch(
f"{prefix}{name}", name, group or joint_group(name), "rad", lower=lo, upper=hi
)
def hinge_limits(model) -> dict[str, tuple[float, float]]:
"""Joint name → (lower, upper) for the model's limited hinge / slide joints."""
from wbc_mjlab.studio.clips import _hinges
return {n: (lo, hi) for n, _, _, limited, lo, hi in _hinges(model) if limited}
[docs]
def effort_limits(session) -> dict[str, tuple[float, float, str]]:
"""Joint name → (lower, upper, source) torque limits of a Play session's robot: the
tightest of the compiled model's joint ``actuatorfrcrange`` / joint-actuator ``forcerange``
(× gear) — from the asset's MJCF —, the actuators' constant ``effort_limit`` and the peak of
a torque-speed envelope (Unitree actuators: the real cap is lower at speed). Joints with
none of them are left out."""
import mujoco
m = session.env.sim.mj_model
out: dict[str, tuple[float, float, str]] = {}
def offer(name: str, lo: float, hi: float, source: str) -> None:
name = name.rsplit("/", 1)[-1] # the entity prefix ("robot/")
lo, hi = sorted((float(lo), float(hi)))
if not (np.isfinite(lo) and np.isfinite(hi)) or hi - lo <= 0:
return
old = out.get(name)
if old is None or hi - lo < old[1] - old[0]:
out[name] = (lo, hi, source)
for j in range(m.njnt):
if m.jnt_actfrclimited[j]:
name = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_JOINT, j) or ""
offer(name, *m.jnt_actfrcrange[j], "model")
for a in range(m.nu):
if (
m.actuator_trntype[a] == mujoco.mjtTrn.mjTRN_JOINT and m.actuator_forcelimited[a]
):
j = int(m.actuator_trnid[a, 0])
name = mujoco.mj_id2name(m, mujoco.mjtObj.mjOBJ_JOINT, j) or ""
gear = float(m.actuator_gear[a, 0])
offer(name, *(m.actuator_forcerange[a] * gear), "model")
for act in session.robot.actuators:
cfg = act.cfg
cap = getattr(cfg, "effort_limit", None)
y1, y2 = getattr(cfg, "y1", None), getattr(cfg, "y2", None)
for name in act.target_names:
if cap is not None and np.isfinite(cap) and cap > 0:
offer(name, -cap, cap, "actuator")
if y1 is not None and y2 is not None:
peak = max(float(y1), float(y2))
offer(name, -peak, peak, "actuator peak (lower at speed)")
return out
# ---------------------------------------------------------------------- clip (Data)
def _ground_speed(q: np.ndarray, fps: float) -> np.ndarray:
"""Horizontal speed of the root (central differences of x, y)."""
if len(q) < 2:
return np.zeros(len(q), np.float32)
return np.linalg.norm(np.gradient(q[:, 0:2], 1.0 / fps, axis=0), axis=1)
def _clip_columns(clip, model, feet: list[str], sole_z: float) -> list[np.ndarray]:
from wbc_mjlab.studio.clips import _hinges, foot_heights
q = clip.qpos
cols = [q[:, i] for i in range(3)]
cols += list(_rpy(q[:, 3:7]).T)
cols.append(_ground_speed(q, clip.fps))
cols += [q[:, qadr] for _, qadr, *_ in _hinges(model)]
cols += list(foot_heights(clip, model, feet, sole_z).values())
return cols
[docs]
def clip_channels(clip, model, *, feet: list[str], sole_z: float, compare=None) -> dict:
"""Channels of a clip (Data step); ``compare`` (the raw source of a converted clip) is
resampled to the clip's frames and sent as ``ref``."""
from wbc_mjlab.studio.clips import _hinges, foot_heights
limits = hinge_limits(model)
channels = _root_channels()
channels.append(_ch("root_speed", "root speed (xy)", "Root speed", "m/s"))
channels += [_joint_channel(n, limits) for n, *_ in _hinges(model)]
channels += [
_ch(f"foot_{n}", n.removesuffix("_link"), "Feet (sole height)", "m")
for n in foot_heights(clip, model, feet, sole_z)
]
data = np.stack(_clip_columns(clip, model, feet, sole_z), axis=1)
out = {
"source": "clip",
"title": clip.name,
"fps": float(clip.fps),
"frames": int(clip.frames),
"start": 0,
"channels": channels,
"data": _b64(data),
"ref": None,
"ref_label": "",
}
if compare is not None:
ref = np.stack(_clip_columns(compare, model, feet, sole_z), axis=1)
if ref.shape[1] == data.shape[1]:
t, tc = np.arange(clip.frames) / clip.fps, np.arange(compare.frames) / compare.fps
ref = np.stack([np.interp(t, tc, ref[:, j]) for j in range(ref.shape[1])], 1)
ref[t > tc[-1] + 1e-6] = np.nan # beyond the source: no comparison
out["ref"], out["ref_label"] = _b64(ref), "raw source"
return out
# ---------------------------------------------------------------------- Play
MAX_TRACKS = (
32 # envs recorded at once (all of a small session; else the recently focused)
)
TRACK_BUDGET = 128 * 2**20 # bytes of GPU memory the tracks may take
[docs]
class PlayRecorder:
"""Rolling record of a Play session, several envs at once (each a *track*): the robot
(``data``) next to the reference it tracks (``ref``, the ``ref_cols`` channels only), plus
per-row meta columns (clip, frame in the clip, episode step) the client uses to mark episode
and clip changes.
A session of up to :data:`MAX_TRACKS` envs (memory permitting) records every env from the
start, so switching the focused env shows its history at once; a larger one records the
most recently focused envs (an env starts recording when first focused or fetched). One
vectorized write per step for all tracks; nothing leaves the GPU until fetched."""
META = ("clip", "clip_frame", "episode_step")
def __init__(self, session, model, *, fps: float, horizon_s: float = HISTORY_S):
self.s = session
self.fps = float(fps)
self.horizon = max(2, int(round(horizon_s * fps)))
self.count = (
0 # rows recorded (all tracks share the clock; row k sits at k % horizon)
)
self._lock = threading.Lock()
rm = session.env.reward_manager
self.reward_names = list(rm.active_terms)
self._weights = torch.tensor(
[rm.get_term_cfg(n).weight for n in self.reward_names],
device=session.device,
dtype=torch.float,
)
self._build_channels(model)
c, r, m = len(self.channels), len(self.ref_cols), len(self.META)
per_env = self.horizon * (c + r + m) * 4
k = max(1, min(session.num_envs, MAX_TRACKS, TRACK_BUDGET // per_env))
dev = session.device
self._data = torch.zeros(self.horizon, k, c, device=dev)
self._ref = torch.full((self.horizon, k, r), float("nan"), device=dev)
self._meta = torch.zeros(self.horizon, k, m, device=dev)
self._envs = torch.arange(k, device=dev) # env of each track
self._track_env = list(range(k))
self._begin = [0] * k # first row of each track's record
self._epoch = list(range(1, k + 1)) # changes when a track starts over
self._next_epoch = k + 1
self._used = [0.0] * k # last focus / fetch (which track a new env takes over)
self._has_pending = torch.zeros(k, dtype=torch.bool, device=dev)
self._pending = ( # the reference each track's next row is compared to
torch.zeros(k, r, device=dev),
torch.zeros(k, len(self.slices_body), 3, device=dev),
)
@property
def num_tracks(self) -> int:
return len(self._track_env)
# -------------------------------------------------------------- layout
def _build_channels(self, model) -> None:
s = self.s
joints = list(s.robot.joint_names)
limits = hinge_limits(model)
am = s.env.action_manager
action_names: list[str] = []
for name in am.active_terms:
term = am.get_term(name)
names = list(getattr(term, "target_names", None) or [])
action_names += (
names
if len(names) == term.action_dim
else [f"{name}[{i}]" for i in range(term.action_dim)]
)
bodies = [b.removesuffix("_link") for b in s.cmd.cfg.body_names]
chans: list[dict] = []
slices: dict[str, slice] = {}
def add(key: str, items: list[dict]) -> None:
slices[key] = slice(len(chans), len(chans) + len(items))
chans.extend(items)
add("root", _root_channels(wrap=True))
add("joint_pos", [_joint_channel(n, limits) for n in joints])
add(
"joint_target",
[_joint_channel(n, limits, "PD targets", "target:") for n in joints],
)
efforts = effort_limits(s)
def torque(n: str) -> dict:
lo, hi, src = efforts.get(n, (None, None, ""))
return _ch(
f"tau:{n}", n, "Joint torques", "Nm", lower=lo, upper=hi, limit_note=src
)
# the motor torque (what the effort limits cap); actuators with a friction model (Unitree)
# take friction off it: the joint gets motor - friction (qfrc_actuator)
self._friction = [
(act.target_ids, act)
for act in s.robot.actuators
if getattr(act, "friction", False) is not False
]
add("torque", [torque(n) for n in joints])
if self._friction:
add(
"friction",
[_ch(f"fric:{n}", n, "Actuator friction", "Nm") for n in joints],
)
add(
"action",
[_ch(f"act:{n}", n, "Actions (policy output)", "") for n in action_names],
)
add(
"body_err",
[_ch(f"err:{b}", b, "Tracking error (keybody position)", "m") for b in bodies],
)
add(
"reward",
[_ch(f"rew:{n}", n, "Rewards (weighted)", "reward") for n in self.reward_names]
+ [_ch("rew:total", "total", "Rewards (weighted)", "reward")],
)
add(
"reward_raw",
[
_ch(f"raw:{n}", n, "Rewards (raw, before weight)", "raw")
for n in self.reward_names
],
)
self.channels = chans
self.slices = slices
self.slices_body = range(len(bodies))
# channels with a reference: root and joints, the first columns (the ref is stored for
# those only)
self.ref_cols = list(range(slices["joint_pos"].stop))
self._nan_raw = self._weights == 0.0
# -------------------------------------------------------------- tracks
def _track(self, env: int) -> int:
"""The track recording ``env``; a new env takes over the least recently used track
(under the lock)."""
env = int(env)
if env in self._track_env:
k = self._track_env.index(env)
else:
k = min(range(self.num_tracks), key=self._used.__getitem__)
self._track_env[k] = env
self._envs[k] = env
self._restart(k)
self._used[k] = time.monotonic()
return k
def _restart(self, k: int) -> None:
self._begin[k] = self.count
self._epoch[k] = self._next_epoch
self._next_epoch += 1
self._has_pending[k] = False
[docs]
def follow(self, env: int) -> None:
"""Keep ``env`` (the focused one) recorded."""
if 0 <= int(env) < self.s.num_envs:
with self._lock:
self._track(env)
[docs]
def clear(self) -> None:
"""Drop every record (restart): the client starts over."""
with self._lock:
for k in range(self.num_tracks):
self._restart(k)
# -------------------------------------------------------------- recording
[docs]
@torch.no_grad()
def record(self) -> None:
"""One row for every track (call after each env step)."""
with self._lock:
self._record()
def _record(self) -> None:
s, e = self.s, self._envs
d, cmd = s.robot.data, s.cmd
origin = s.env.scene.env_origins[e]
sl = self.slices
row = self._data[self.count % self.horizon]
ref = self._ref[self.count % self.horizon]
row[:, sl["root"]] = torch.cat(
(d.root_link_pos_w[e] - origin, _rpy_t(d.root_link_quat_w[e])), dim=-1
)
row[:, sl["joint_pos"]] = d.joint_pos[e]
row[:, sl["joint_target"]] = d.joint_pos_target[e]
if self._friction:
fric = torch.zeros_like(d.qfrc_actuator[e])
for ids, act in self._friction:
if act.friction is not None:
fric[:, ids] = act.friction[e]
row[:, sl["torque"]] = d.qfrc_actuator[e] + fric
row[:, sl["friction"]] = fric
else:
row[:, sl["torque"]] = d.qfrc_actuator[e]
row[:, sl["action"]] = s.env.action_manager.action[e]
# the reference this step's rewards compared to: set at the end of the previous step
# (none on a track's first row)
has = self._has_pending[:, None]
ref_prev, bodies_prev = self._pending
ref.copy_(torch.where(has, ref_prev, float("nan")))
err = torch.norm(bodies_prev - cmd.robot_body_pos_w[e], dim=-1)
row[:, sl["body_err"]] = torch.where(has, err, float("nan"))
# the frame the next step's rewards compare against (the command just advanced)
self._pending = (
torch.cat(
(
cmd.body_pos_w[e, 0] - origin,
_rpy_t(cmd.body_quat_w[e, 0]),
cmd.joint_pos[e],
),
dim=-1,
),
cmd.body_pos_relative_w[e],
)
self._has_pending.fill_(True)
step = s.env.reward_manager._step_reward[e] # noqa: SLF001 — weighted, per step
row[:, sl["reward"]] = torch.cat((step, step.sum(-1, keepdim=True)), dim=-1)
raw = step / torch.where(self._nan_raw, 1.0, self._weights)
row[:, sl["reward_raw"]] = torch.where(self._nan_raw, float("nan"), raw)
tid = cmd.trajectory_ids[e]
self._meta[self.count % self.horizon] = torch.stack(
(
tid.float(),
(cmd.time_steps[e] - cmd.motion.segment_start_idx[tid]).float(),
s.env.episode_length_buf[e].float(),
),
dim=-1,
)
self.count += 1
# -------------------------------------------------------------- fetch
[docs]
def fetch(self, env: int = 0, since: int = 0, epoch: int | None = None) -> dict:
"""Rows ``since``… of ``env``'s record as (rows, columns) float32 arrays (``data``,
``ref`` for the ``ref_cols`` channels, ``meta``); all of it, with the channel list, when
``epoch`` is not the record's (another env, a restart: the client starts over). An env
not recorded yet starts recording now."""
env = min(max(0, int(env)), self.s.num_envs - 1)
with self._lock:
k = self._track(env)
fresh = epoch != self._epoch[k]
hi = self.count
lo = max(self._begin[k], hi - self.horizon, 0 if fresh else min(int(since), hi))
idx = torch.arange(lo, hi, device=self.s.device) % self.horizon
parts = (self._data[idx, k], self._ref[idx, k], self._meta[idx, k])
out = {
"source": "play",
"epoch": self._epoch[k],
"env": env,
"fps": self.fps,
"start": lo,
"end": hi,
"fresh": fresh,
}
out["data"], out["ref"], out["meta"] = (p.cpu().numpy() for p in parts)
if fresh:
out["channels"] = self.channels
out["ref_cols"] = self.ref_cols
out["clips"] = self.s.clip_names()
out["meta_columns"] = list(self.META)
out["ref_label"] = "reference"
out["history_s"] = self.horizon / self.fps
return out
[docs]
def encode_rows(out: dict) -> bytes:
"""A :meth:`PlayRecorder.fetch` result as ``[u32 header length][header JSON][pad to 4]``
then the float32 ``data``, ``ref`` and ``meta`` rows (little-endian); the header lists
their column counts (``cols``)."""
arrays = [np.ascontiguousarray(out[k], dtype="<f4") for k in ("data", "ref", "meta")]
head = {k: v for k, v in out.items() if k not in ("data", "ref", "meta")}
head["cols"] = [int(a.shape[1]) for a in arrays]
text = json.dumps(head).encode()
pad = (-(4 + len(text))) % 4
return b"".join(
[
struct.pack("<I", len(text) + pad),
text,
b" " * pad,
*(a.tobytes() for a in arrays),
]
)
__all__ = [
"PlayRecorder",
"clip_channels",
"effort_limits",
"encode_rows",
"hinge_limits",
"joint_group",
]