twanghcmut's picture
download
raw
51.4 kB
#!/usr/bin/env python
"""S9's batched MuJoCo particle simulator -- runs ONLY in the isolated `mujoco` conda env.
Sibling of ``scripts/_mujoco_settle_worker.py`` (read that file's docstring first; this
one assumes the isolation rationale there and does not repeat it): same "no fpgm import,
no mujoco.viewer, no rendering, talk over files" discipline, same reason (`fpgm`'s numpy is
pinned `<2` by SAM 3.1; a plain `pip install mujoco` pulls numpy 2.x and risks a downgrade
cascade there). The one thing this worker adds on top of that precedent is **batching**:
``_mujoco_settle_worker.py`` builds and steps exactly one MJCF per subprocess invocation,
which is fine for a one-off brick drop but far too slow for S9, where a single posterior
round evaluates thousands of parameter hypotheses (particles) per episode-object. Model
*compilation* (``mujoco.MjModel.from_xml_string``, which parses the MJCF, loads the mesh,
builds the collision tree) costs milliseconds and does not depend on any of the identified
parameters in :data:`RIGID_PARAM_NAMES`/:data:`PRISMATIC_PARAM_NAMES` -- every one of them
is a plain ``MjModel`` *field* (mass, friction, solref, joint damping, ...) that can be
edited post-compile with no recompilation. So this worker compiles the MJCF **once** per
invocation (one invocation = one :class:`~fpgm.physics.types.SimSpec`, i.e. one fixed
scene *structure*) and loops every particle over the same compiled model, editing fields
and calling :func:`mujoco.mj_resetData` between particles -- amortising the one expensive
step over however many particles are in ``--particles``. This is the "reuse the compiled
MjModel where the parameter only touches model fields; recompile only when structure
changes" design the caller's docstring asks for; because a single worker invocation never
changes structure (one spec, one MJCF), that reduces here to "compile exactly once." One
field edit is NOT enough on its own, though, and this was found empirically while building
this worker, not assumed: writing ``model.body_mass``/``model.body_inertia`` alone leaves
``model.body_invweight0`` (the constraint solver's reference inverse-mass, cached at compile
time) pointing at the density-1 baseline body -- contacts still register (``ncon>0``) but are
far too soft, and a body that should rest on a support box visibly sinks through it instead.
:func:`mujoco.mj_setConst` recomputes that cache from the current field values (still no XML
reparsing, no mesh reload -- an ``O(nv)`` call, not a recompilation) and is called once per
particle, right after :func:`_apply_theta`; see that call site for the trajectory comparison
that caught this.
**What is simulated vs. what is a hypothesis.** Unlike ``_mujoco_settle_worker.py``, where
friction/restitution were fixed ASSUMPTIONS a human chose once, every entry of
:data:`RIGID_PARAM_NAMES`/:data:`PRISMATIC_PARAM_NAMES` here is a *hypothesis* carried by
one particle -- this worker's only job is to render each hypothesis into a trajectory
precisely and repeatably. Turning "does this trajectory look like what the camera saw" into
evidence for or against the hypothesis is ``fpgm.physics.likelihood``'s job (out of this
worker's scope entirely; it never sees the observed track). Anything this worker still
fixes rather than reads from a particle -- rolling friction, the contact solver's
``timeconst`` half of ``solref`` (only the damping-ratio half is identified), and a shared
mesh scale of 1.0 (i.e. the caller is trusted to have already written every hull STL, body
of interest and kinematic passengers alike, in real-world metres) -- remains a genuine
MODELLING ASSUMPTION, not a measurement, and is called out at its point of use below, in the
same spirit as ``_mujoco_settle_worker.py``'s own friction/restitution note. Bodies *beyond*
the first one in ``spec.bodies`` split into two cases (see :func:`_apply_kinematic_friction`
vs. :func:`_apply_generic_body_defaults`): a ``"kinematic"`` body (the common, expected case
-- see below) is mocap-driven off its own measured ``pose_track`` and borrows the body of
interest's *own* particle-varying ``log_mu_slide``/``log_mu_torsion`` for its friction, since
a second, per-pair friction parameter is not identifiable from one episode's worth of
evidence (see that function's docstring); any body that is neither the first one nor
``"kinematic"`` (not currently emitted by ``fpgm.physics.scene.build_sim_spec``, but the
worker must still not crash or silently mis-simulate one) falls back to fixed generic
defaults with no particle-controlled behaviour at all.
**Interface assumption: "the body of interest" is ``spec.bodies[0]``.**
:class:`~fpgm.physics.types.SimSpec` has no field naming which body a caller wants back --
:class:`~fpgm.physics.types.SimResult` carries exactly one body's ``(N, T, 7)`` trajectory
and one ``label``. The convention adopted here (and relied on by
``fpgm.physics.scene.build_sim_spec``) is: **the first entry of ``spec.bodies`` is the body
of interest**, always ``"free"`` or ``"prismatic"``; its world pose (``data.xpos``/
``data.xquat``, not raw ``qpos`` -- see :func:`_extract_pose`) is what gets recorded every
frame, and the parameter columns in ``--particles`` (``theta``/``param_names``) are
interpreted against it and it alone. Every other tracked object S6 saw in the same episode
now rides along as ``spec.bodies[1:]``, each one ``kind="kinematic"`` -- a MuJoCo mocap body
driven off its own measured ``pose_track`` (see :class:`~fpgm.physics.types.BodySpec`'s own
docstring), fully able to contact the body of interest (grasped, pushed, rested-on) but
never itself a hypothesis: nothing about *its* mass, density or inertia is read from
``theta``, because a mocap body has no dynamics for those fields to act on in the first
place. This is what turns "the brick rests on the drawer" from a fact this worker would
otherwise need to be told into one that simply falls out of both bodies being present with
their real measured poses -- see ``fpgm.physics.scene``'s module docstring for the full
motivation. Any *further* non-kinematic body beyond the first (not currently emitted by
``build_sim_spec``, kept only so the worker degrades sanely rather than crashing) gets
:data:`_GENERIC_BODY_*` physical defaults (see :func:`_apply_generic_body_defaults`) since
there is no parameter channel for it either.
**Why mocap fingers, not a real gripper joint (restated from physics/types.py, because
it drives a modelling choice below).** :class:`~fpgm.physics.types.GripperSpec` gives two
kinematically-driven mocap boxes -- ``data.mocap_pos``/``data.mocap_quat`` are set to
``finger_poses[t]`` every frame and MuJoCo treats a mocap body as having effectively
infinite mass, unaffected by contact forces but fully able to exert them. Nothing in the
model "knows" the object is grasped: whether it stays between the fingers is decided purely
by contact + ``log_mu_gripper``, which is exactly what makes one model cover grasp, slip,
push, place and release with no phase special-casing (see ``GripperSpec``'s own docstring
for the full rationale). One consequence worth being explicit about: the recorded
``finger_poses`` are **piecewise-linearly (position) / slerp (orientation) interpolated**
across the ``substeps`` physics steps between two consecutive output frames (see
:func:`_lerp`/:func:`_slerp`), not held constant at the frame-``t`` target throughout --
holding it constant would introduce a full output-frame's worth of positional lag on fast
finger motion; interpolating keeps the driven contact geometry close to the FK-measured
motion at every substep, at the cost of assuming the true motion is well-approximated by a
straight line between two consecutive 60 Hz(-ish) FK samples, which is a mild and standard
assumption for typical DROID gripper speeds.
**Priority, not friction mixing.** MuJoCo's default contact-parameter combination rule
mixes two colliding geoms' ``friction``/``solref`` (elementwise max for friction, a
weighted rule for solref) rather than letting either one win outright. That would dilute
exactly the parameters this stage exists to identify: if the heightfield/finger geoms carried
any non-trivial default friction, ``log_mu_slide``/``log_mu_gripper`` would only ever be
observed through ``max(identified, fixed_default)``, silently floored. Every geom on the
body of interest is instead given MuJoCo's ``priority="1"`` (every other geom -- the
heightfield, safety floor, fingers -- stays at the default ``priority="0"``); per the MuJoCo
docs, the higher-priority geom's own friction/solref parameters are then used *directly* for that
contact pair, unmixed. This is the mechanism, not a fixed value, that makes each particle's
``theta`` actually control what the simulation does.
Usage (always as ``-m``, never as a bare script path -- see the note below)::
PYTHONPATH=src /home/quang/miniconda3/envs/mujoco/bin/python \\
-m fpgm.physics.worker_mujoco \\
--spec sim_spec.json --particles particles.npz --out result.npz
**Why ``-m``, not a direct script path (unlike ``scripts/_mujoco_settle_worker.py``,
which IS invoked as a bare path).** This module lives at ``src/fpgm/physics/worker_mujoco.py``,
a sibling of ``physics/types.py``. Running it as a bare script path makes Python prepend
*that file's own directory* to ``sys.path``, which then shadows the standard-library
``types`` module with ``fpgm/physics/types.py`` for the rest of the process -- breaking
stdlib imports that need the real ``types`` (``enum``, ``weakref``, ...) with a confusing
circular-import error. Invoking as ``-m`` (with ``PYTHONPATH=src`` and any cwd) instead
puts the current working directory at ``sys.path[0]``, never the package's own directory,
so the collision cannot occur. ``fpgm.physics.scene.MujocoSimulator`` (the subprocess
caller, ``src/fpgm/physics/simulate.py``) always launches it this way.
"""
from __future__ import annotations
import argparse
import json
import time
from pathlib import Path
import mujoco
import numpy as np
# --------------------------------------------------------------------------- #
# Parameter-space contract, DUPLICATED (not imported) from fpgm.physics.types.
#
# The `mujoco` env has no `fpgm` installed (see module docstring) -- this mirrors
# scripts/settle_after_release.py's own precedent of duplicating a handful of
# quaternion helpers across the same env boundary rather than adding a cross-env
# dependency for them. `--particles` therefore always carries its own
# `param_names` (D,) array (see :func:`_load_particles`) rather than trusting
# this list alone; a name mismatch between the two sides fails loudly (KeyError
# on a missing name) instead of silently misinterpreting a column.
# --------------------------------------------------------------------------- #
RIGID_PARAM_NAMES: tuple[str, ...] = (
"log_density",
"com_x",
"com_y",
"log_mu_slide",
"log_mu_torsion",
"log_solref_damping",
"log_mu_gripper",
)
PRISMATIC_PARAM_NAMES: tuple[str, ...] = (
"log_joint_friction",
"log_joint_damping",
)
# -- fixed modelling assumptions (never particle-controlled) ---------------- #
#: Rolling friction is not in RIGID_PARAM_NAMES (contact identifiability for a
#: body that is dropped/pushed/grasped, never rolled, does not usefully separate
#: it from sliding friction) -- held at MuJoCo's own small default, matching
#: scripts/settle_after_release.py's `_ASSUMED_FRICTION`'s rolling term.
_DEFAULT_ROLL_FRICTION = 1.0e-4
#: Finger-vs-object torsional/rolling friction: also not identified (only the
#: sliding term, log_mu_gripper, is) -- fixed at small generic defaults since
#: gripper contact in this scene is dominated by sliding/normal force, not spin.
_DEFAULT_GRIPPER_TORSION = 5.0e-3
_DEFAULT_GRIPPER_ROLL = 1.0e-4
#: solref = (timeconst, dampratio). Only dampratio (bounce/underdamping) is
#: identified via log_solref_damping; timeconst is held at MuJoCo's own default
#: (also scripts/settle_after_release.py's `_ASSUMED_SOLREF[0]`) -- varying both
#: would make solref_damping alone not identifiable from a fixed-timeconst prior.
_SOLREF_TIMECONST = 0.02
#: Generic (non-identified) physical defaults for any body beyond spec.bodies[0]
#: -- see the module docstring's "interface assumption" note. 1000 kg/m^3 is
#: plain water density, the conventional "no better information" placeholder;
#: this path is not expected to be exercised by fpgm.physics.scene.build_sim_spec,
#: which always emits a single-body spec, but the worker must still behave
#: sanely (not crash, not silently mis-simulate) if a spec ever does carry more.
_GENERIC_BODY_DENSITY = 1000.0
_GENERIC_BODY_FRICTION = (0.5, 5.0e-3, 1.0e-4)
_GENERIC_BODY_SOLREF = (_SOLREF_TIMECONST, 1.0)
#: How far below the lowest of {the heightfield's own base, every body's initial z} the
#: numerical safety floor sits. Reaching it is a divergence signal (ok=False),
#: never a feature -- same convention as _mujoco_settle_worker.py's floor_z.
_SAFETY_FLOOR_DROP_M = 1.0
_SAFETY_FLOOR_HALF_SIZE = 3.0
#: Solid depth MuJoCo's <hfield> extends BELOW its own lowest elevation vertex (the 4th
#: `size` component -- see `_heightfield_mujoco_params`'s docstring for the empirically
#: -verified geometry). Not zero: a zero-thickness heightfield is exactly the kind of thin
#: shell DEFAULT_SUBSTEPS's docstring already found tunnels through at low substep counts;
#: this gives the field's own solid base the same order of margin as
#: the old fitted-plane support box's own fixed thickness gave it (now removed; see
#: fpgm.physics.scene's module docstring for that representation's replacement).
_HFIELD_BASE_Z_M = 0.05
#: Floor on a heightfield's own elevation dynamic range (`z_max - z_min` over the WHOLE
#: grid, including hole-sentinel cells). A perfectly flat input would make this exactly 0,
#: which MuJoCo's compiler rejects (`size` component 3 must be > 0) -- this is a numerical
#: floor only, never expected to bind on real (always slightly noisy, and always containing
#: at least one deliberately-dropped hole cell) data.
_MIN_ELEVATION_RANGE_M = 1.0e-4
class WorkerError(RuntimeError):
"""Raised for a malformed --spec/--particles input; never for a diverged rollout.
A diverged particle is not an error -- it is `ok=False` and a recorded, real
result (see the module docstring on why silently dropping it would be wrong).
This is only for "the wire contract itself does not hold" cases: a missing
parameter name, a shape mismatch, a body with no geometry.
"""
# --------------------------------------------------------------------------- #
# Small standalone quaternion/geometry helpers.
#
# Deliberately not shared with fpgm.geometry.transforms (that module lives in
# `fpgm`, unavailable here) nor re-derived ad hoc per call site -- kept as the
# minimal, tested set this worker needs, matching the precedent already set by
# scripts/settle_after_release.py's own standalone quaternion helpers (see that
# file's module-level comment on why they are duplicated, not imported).
# --------------------------------------------------------------------------- #
def _mat_to_quat(rot3x3: np.ndarray) -> np.ndarray:
"""(3, 3) proper rotation matrix -> (4,) wxyz unit quaternion, via MuJoCo's own routine."""
quat = np.zeros(4, dtype=np.float64)
mat9 = np.ascontiguousarray(rot3x3, dtype=np.float64).reshape(-1)
mujoco.mju_mat2Quat(quat, mat9)
return quat
def _lerp(a: np.ndarray, b: np.ndarray, t: float) -> np.ndarray:
return a + t * (b - a)
def _slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray:
"""Spherical linear interpolation between two wxyz unit quaternions."""
q0 = q0 / np.linalg.norm(q0)
q1 = q1 / np.linalg.norm(q1)
dot = float(np.dot(q0, q1))
if dot < 0.0:
q1 = -q1
dot = -dot
dot = min(dot, 1.0)
if dot > 0.9995:
out = q0 + t * (q1 - q0)
return out / np.linalg.norm(out)
theta0 = np.arccos(dot)
theta = theta0 * t
q2 = q1 - q0 * dot
q2 = q2 / np.linalg.norm(q2)
return q0 * np.cos(theta) + q2 * np.sin(theta)
# --------------------------------------------------------------------------- #
# Heightfield: metric grid -> MuJoCo's [0, 1]-normalised <hfield> contract
# --------------------------------------------------------------------------- #
def _heightfield_mujoco_params(
height_m: np.ndarray, *, cell_size_m: float, origin_xy: tuple[float, float],
) -> dict:
"""Convert a raw metric ``(ny, nx)`` height grid into MuJoCo's ``<hfield>`` contract.
**MuJoCo hfield geometry, EMPIRICALLY VERIFIED against this worker's own MuJoCo
version** (its docs describe the ``size`` attribute in prose but do not spell out the
row/column-to-world-axis mapping or the exact elevation formula; both were confirmed
directly with a ray-cast probe rather than assumed -- see
``tests/test_physics_simulate.py``'s heightfield settle tests for the same fact
re-verified end to end through a real compile):
* ``hfield_data`` is stored ROW-MAJOR, ``(nrow, ncol)`` flattened as ``row * ncol + col``,
values normalised to ``[0, 1]``.
* Column index increases with world ``+X``; row index increases with world ``+Y``.
* The ``(nrow, ncol)`` VERTICES are evenly spaced across the geom's own
``size = (radius_x, radius_y, elevation_z, base_z)``: vertex ``(row, col)`` sits at
local ``x = -radius_x + col * (2*radius_x)/(ncol-1)``, similarly for ``y``/row -- i.e.
the field spans exactly ``[-radius_x, +radius_x]`` x ``[-radius_y, +radius_y]`` about
the geom's own ``pos``, with (ncol-1)/(nrow-1) INTERVALS between the outermost
vertices, not (ncol)/(nrow) -- this is a vertex grid, not a pixel grid.
* World elevation at a vertex is ``geom_pos_z + hfield_data[row, col] * elevation_z``
(verified: a flat field of normalised value ``v`` settles a dropped sphere at exactly
``geom_pos_z + v * elevation_z``, to floating-point precision, for both ``v=0`` and
``v=1``). ``base_z`` (measured separately) extends a SOLID base downward from there,
giving the field real volume rather than an infinitesimally thin shell -- see
:data:`_HFIELD_BASE_Z_M`.
``fpgm.physics.scene._rasterize_top_down`` already produces a grid at uniform
``cell_size_m`` vertex spacing in both axes, so ``radius_x``/``radius_y`` fall straight
out of ``(nx - 1) * cell_size_m / 2`` -- no resampling is needed to satisfy MuJoCo's
"evenly spaced" requirement.
Args:
height_m: ``(ny, nx)`` world-frame Z, ALREADY hole-filled (see
``fpgm.physics.scene._fill_unknown_cells`` -- this function has no "what is an
unknown cell" policy of its own; every entry here is treated as a real elevation
to normalise).
cell_size_m: Uniform vertex spacing, metres, both axes.
origin_xy: World ``(x, y)`` of grid vertex ``(row=0, col=0)`` -- the grid's
minimum-XY corner.
Returns:
A dict with ``normalized01`` (``(ny, nx)`` float64 in ``[0, 1]``, already row-major
in the layout ``hfield_data`` needs), ``nrow``/``ncol``, ``radius_x``/``radius_y``,
``elevation_z``, ``base_z``, and ``pos`` (the ``<geom pos=...>`` this field must be
placed at for the metric round-trip above to hold).
"""
ny, nx = height_m.shape
z_min = float(np.min(height_m))
z_max = float(np.max(height_m))
elevation_z = max(z_max - z_min, _MIN_ELEVATION_RANGE_M)
normalized01 = np.clip((height_m - z_min) / elevation_z, 0.0, 1.0).astype(np.float64)
radius_x = (nx - 1) * cell_size_m / 2.0
radius_y = (ny - 1) * cell_size_m / 2.0
pos_x = float(origin_xy[0]) + radius_x
pos_y = float(origin_xy[1]) + radius_y
pos_z = z_min
return {
"normalized01": normalized01,
"nrow": ny,
"ncol": nx,
"radius_x": radius_x,
"radius_y": radius_y,
"elevation_z": elevation_z,
"base_z": _HFIELD_BASE_Z_M,
"pos": (pos_x, pos_y, pos_z),
}
def _load_heightfield_grid(grid_path: Path) -> tuple[np.ndarray, np.ndarray]:
"""``(height_m, valid)`` from the sidecar ``.npz`` :class:`~fpgm.physics.types.
HeightfieldSpec.grid_path` points at -- see that class's docstring for why this is a
file, not inline JSON.
Raises:
WorkerError: file missing, unreadable, or missing the expected arrays -- the
heightfield is the scene's ONLY static collision geometry (see the module
docstring); a malformed one must fail loudly, not silently simulate free fall
through empty space again.
"""
grid_path = Path(grid_path)
if not grid_path.exists():
raise WorkerError(f"heightfield grid file not found: {grid_path}")
try:
with np.load(grid_path) as npz:
if "height_m" not in npz or "valid" not in npz:
raise WorkerError(
f"{grid_path}: missing 'height_m'/'valid' array (got {list(npz.files)})"
)
height_m = np.asarray(npz["height_m"], dtype=np.float64)
valid = np.asarray(npz["valid"], dtype=bool)
except (OSError, ValueError) as exc:
raise WorkerError(
f"{grid_path}: could not be read as an .npz heightfield grid: {exc}"
) from exc
if height_m.shape != valid.shape or height_m.ndim != 2:
raise WorkerError(
f"{grid_path}: height_m/valid shape mismatch {height_m.shape}/{valid.shape}"
)
if not np.all(np.isfinite(height_m)):
raise WorkerError(f"{grid_path}: height_m has non-finite entries -- hole-filling failed")
return height_m, valid
# --------------------------------------------------------------------------- #
# MJCF assembly
# --------------------------------------------------------------------------- #
def _mesh_asset_xml(label: str, mesh_path: str) -> str:
return f' <mesh name="{label}_hull" file="{mesh_path}"/>\n'
def _body_xml(body: dict, *, priority: int) -> str:
label = body["label"]
px, py, pz = body["init_pose_pos"]
qw, qx, qy, qz = body["init_pose_quat"]
kind = body["kind"]
if kind == "kinematic":
# A mocap body, exactly like the two gripper fingers (see the module
# docstring's "why mocap fingers" note) but carrying the tracked object's
# OWN convex hull instead of a box, and driven from `pose_track` rather
# than `finger_poses` -- see `_set_body_mocap`/`_set_body_mocap_interp`.
# `priority` is intentionally unused here: this geom's own friction is set
# every particle (see `_apply_kinematic_friction`), and MuJoCo's contact
# priority rule (see the module docstring) already makes the body-of-
# interest's own priority="1" geom win any contact against this one
# regardless of what this geom's friction says.
return (
f' <body name="{label}" mocap="true" '
f'pos="{px:.8g} {py:.8g} {pz:.8g}" quat="{qw:.8g} {qx:.8g} {qy:.8g} {qz:.8g}">\n'
f' <geom name="{label}_geom" type="mesh" mesh="{label}_hull" '
f'friction="0.5 0.005 0.0001" solref="{_SOLREF_TIMECONST:.8g} 1" '
f'rgba="0.3 0.5 0.7 1"/>\n'
f" </body>\n"
)
if kind == "free":
joint_xml = f' <freejoint name="{label}_free"/>\n'
else:
ax, ay, az = body["joint_axis_local"]
ox, oy, oz = body["joint_origin_local"]
lo, hi = body["joint_range"]
joint_xml = (
f' <joint name="{label}_slide" type="slide" '
f'pos="{ox:.8g} {oy:.8g} {oz:.8g}" axis="{ax:.8g} {ay:.8g} {az:.8g}" '
f'range="{lo:.8g} {hi:.8g}"/>\n'
)
return (
f' <body name="{label}" pos="{px:.8g} {py:.8g} {pz:.8g}" '
f'quat="{qw:.8g} {qx:.8g} {qy:.8g} {qz:.8g}">\n'
f"{joint_xml}"
f' <geom name="{label}_geom" type="mesh" mesh="{label}_hull" '
f'density="1" priority="{priority}" '
f'friction="0.5 0.005 0.0001" solref="{_SOLREF_TIMECONST:.8g} 1" '
f'rgba="0.7 0.3 0.3 1"/>\n'
f" </body>\n"
)
def _finger_xml(name: str, half_extent: np.ndarray) -> str:
hx, hy, hz = half_extent
return (
f' <body name="{name}" mocap="true" pos="0 0 -10" quat="1 0 0 0">\n'
f' <geom name="{name}_geom" type="box" size="{hx:.8g} {hy:.8g} {hz:.8g}" '
f'friction="0.5 0.005 0.0001" solref="{_SOLREF_TIMECONST:.8g} 1" '
f'rgba="0.2 0.2 0.2 0.6" contype="1" conaffinity="1"/>\n'
f" </body>\n"
)
def build_mjcf(spec: dict) -> str:
"""Assemble the MJCF for one :class:`~fpgm.physics.types.SimSpec`.
One f-string, no XML library -- same rationale as
scripts/_mujoco_settle_worker.py's `_build_mjcf`: every element here is a
handful of numeric attributes, easy to eyeball in a debugger if a compile
fails, and a full XML tree would be pure overhead for that.
"""
gx, gy, gz = spec["gravity"]
sim_dt = float(spec["dt"]) / int(spec["substeps"])
bodies = spec["bodies"]
mesh_assets = "".join(_mesh_asset_xml(b["label"], b["mesh_path"]) for b in bodies)
body_defs = "".join(
_body_xml(b, priority=(1 if i == 0 else 0)) for i, b in enumerate(bodies)
)
gripper_xml = ""
if spec.get("gripper") is not None:
half_extent = np.asarray(spec["gripper"]["finger_size"], dtype=np.float64)
gripper_xml = _finger_xml("finger_L", half_extent) + _finger_xml("finger_R", half_extent)
hfield_asset_xml = ""
hfield_geom_xml = ""
if spec.get("heightfield") is not None:
# Populated by `_load_spec` (see that function) -- the ONE place this worker reads
# the sidecar grid file and converts it to MuJoCo's [0, 1] contract; see
# `_heightfield_mujoco_params`'s docstring for the exact geometry this XML relies on.
p = spec["heightfield"]["_mujoco"]
px, py, pz = p["pos"]
hfield_asset_xml = (
f' <hfield name="terrain" nrow="{p["nrow"]}" ncol="{p["ncol"]}" '
f'size="{p["radius_x"]:.8g} {p["radius_y"]:.8g} {p["elevation_z"]:.8g} '
f'{p["base_z"]:.8g}"/>\n'
)
hfield_geom_xml = (
f' <geom name="heightfield_surface" type="hfield" hfield="terrain" '
f'pos="{px:.8g} {py:.8g} {pz:.8g}" '
f'friction="0.5 0.005 0.0001" rgba="0.6 0.5 0.35 1"/>\n'
)
safety_z = pz - _SAFETY_FLOOR_DROP_M
else:
min_z = min(float(b["init_pose_pos"][2]) for b in bodies)
safety_z = min_z - _SAFETY_FLOOR_DROP_M
safety_xml = (
f' <geom name="safety_floor" type="plane" pos="0 0 {safety_z:.8g}" '
f'size="{_SAFETY_FLOOR_HALF_SIZE:.8g} {_SAFETY_FLOOR_HALF_SIZE:.8g} 0.1" '
f'friction="0.5 0.005 0.0001" rgba="0.3 0.3 0.3 0.3"/>\n'
)
return f"""
<mujoco model="s9_particle_batch">
<compiler angle="radian"/>
<option timestep="{sim_dt:.8g}" gravity="{gx:.8g} {gy:.8g} {gz:.8g}"
integrator="implicitfast" cone="elliptic"/>
<asset>
{mesh_assets}{hfield_asset_xml} </asset>
<worldbody>
{body_defs}{gripper_xml}{hfield_geom_xml}{safety_xml} </worldbody>
</mujoco>
"""
# --------------------------------------------------------------------------- #
# Spec / particle loading
# --------------------------------------------------------------------------- #
def _load_spec(path: Path) -> dict:
"""Read ``--spec`` and pre-derive the local-frame quantities MJCF needs.
Everything in :class:`~fpgm.physics.types.SimSpec.to_json_dict` is WORLD-frame
(poses, prismatic axis/origin); MJCF's own ``<joint pos= axis=>`` for a body
are expressed in that BODY's own frame. This function does that one rotation
per prismatic body, once, rather than inside the (particle-)hot loop.
Also where the heightfield's sidecar ``.npz`` (see
:class:`~fpgm.physics.types.HeightfieldSpec.grid_path`) is actually read from disk and
converted to MuJoCo's ``[0, 1]`` contract (:func:`_heightfield_mujoco_params`), stashed
at ``raw["heightfield"]["_mujoco"]`` for :func:`build_mjcf`/:func:`simulate_all` -- once
per worker invocation, structural (particle-independent), same "pre-derive, do not
repeat in the hot loop" rationale as the prismatic local-frame conversion above.
"""
raw = json.loads(Path(path).read_text())
required = ("uuid", "camera_serial", "dt", "n_frames", "bodies")
missing = [k for k in required if k not in raw]
if missing:
raise WorkerError(f"--spec missing required keys: {missing}")
if not raw["bodies"]:
raise WorkerError("--spec has an empty 'bodies' list")
for body in raw["bodies"]:
init_pose = np.asarray(body["init_pose"], dtype=np.float64)
if init_pose.shape != (4, 4):
raise WorkerError(f"{body['label']}: init_pose must be (4,4), got {init_pose.shape}")
rot = init_pose[:3, :3]
pos = init_pose[:3, 3]
body["init_pose_pos"] = pos.tolist()
body["init_pose_quat"] = _mat_to_quat(rot).tolist()
body["init_lin_vel"] = list(body.get("init_lin_vel", (0.0, 0.0, 0.0)))
body["init_ang_vel"] = list(body.get("init_ang_vel", (0.0, 0.0, 0.0)))
if body["kind"] == "prismatic":
axis_world = np.asarray(body["joint_axis_world"], dtype=np.float64)
origin_world = np.asarray(body["joint_origin_world"], dtype=np.float64)
body["joint_axis_local"] = (rot.T @ axis_world).tolist()
body["joint_origin_local"] = (rot.T @ (origin_world - pos)).tolist()
if raw.get("heightfield") is not None:
hf = raw["heightfield"]
for key in ("grid_path", "nx", "ny", "cell_size_m", "origin_xy"):
if key not in hf:
raise WorkerError(f"--spec heightfield missing required key {key!r}")
height_m, _valid = _load_heightfield_grid(Path(hf["grid_path"]))
if height_m.shape != (int(hf["ny"]), int(hf["nx"])):
raise WorkerError(
f"{hf['grid_path']}: grid shape {height_m.shape} disagrees with spec's "
f"declared (ny, nx) = ({hf['ny']}, {hf['nx']})"
)
hf["_mujoco"] = _heightfield_mujoco_params(
height_m, cell_size_m=float(hf["cell_size_m"]), origin_xy=tuple(hf["origin_xy"]),
)
return raw
def _load_particles(path: Path) -> tuple[np.ndarray, list[str]]:
with np.load(path) as npz:
if "theta" not in npz or "param_names" not in npz:
raise WorkerError(
f"--particles must contain 'theta' and 'param_names', got {list(npz.files)}"
)
theta = np.asarray(npz["theta"], dtype=np.float64)
param_names = [str(n) for n in npz["param_names"]]
if theta.ndim != 2 or theta.shape[1] != len(param_names):
raise WorkerError(
f"--particles theta shape {theta.shape} disagrees with "
f"{len(param_names)} param_names"
)
return theta, param_names
# --------------------------------------------------------------------------- #
# Per-particle model editing
# --------------------------------------------------------------------------- #
class _BodyHandles:
"""Cached ids + density=1 baseline for one body, resolved once after compile."""
def __init__(self, model: mujoco.MjModel, label: str, kind: str) -> None:
self.label = label
self.kind = kind
self.body_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, label)
self.geom_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, f"{label}_geom")
if self.body_id < 0 or self.geom_id < 0:
raise WorkerError(f"MJCF compiled without expected body/geom for {label!r}")
self.base_mass = float(model.body_mass[self.body_id])
self.base_inertia = model.body_inertia[self.body_id].copy()
self.base_ipos = model.body_ipos[self.body_id].copy()
self.bounding_radius = float(model.geom_rbound[self.geom_id])
if self.kind == "prismatic":
joint_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, f"{label}_slide")
if joint_id < 0:
raise WorkerError(f"MJCF compiled without expected slide joint for {label!r}")
self.dof_id = int(model.jnt_dofadr[joint_id])
else:
self.dof_id = None
# A "kinematic" body has no joint at all (it is mocap="true", exactly like
# the gripper fingers) -- its own id into data.mocap_pos/mocap_quat, resolved
# once here rather than by mj_name2id'ing "label" every substep the way
# _set_finger_mocap does for the (fixed, only-ever-two) fingers.
self.mocap_id = int(model.body_mocapid[self.body_id]) if self.kind == "kinematic" else None
def _apply_theta(
model: mujoco.MjModel,
handles: _BodyHandles,
theta: dict[str, float],
finger_geom_ids: list[int],
) -> None:
"""Edit exactly the MjModel fields RIGID_PARAM_NAMES/PRISMATIC_PARAM_NAMES own.
No recompilation -- every field touched here (`body_mass`, `body_inertia`,
`body_ipos`, `geom_friction`, `geom_solref`, `dof_frictionloss`,
`dof_damping`) is a plain compiled-model array, safe to overwrite between
particles. This is the amortisation the module docstring describes.
"""
density = float(np.exp(theta["log_density"]))
model.body_mass[handles.body_id] = handles.base_mass * density
model.body_inertia[handles.body_id] = handles.base_inertia * density
# com_x/com_y are in units of the object's own bounding radius (RIGID_PARAM_NAMES'
# contract, mirrored from physics/types.py's RIGID_PARAMS docstring) -- see the
# module docstring's note on this being a documented approximation (no
# parallel-axis re-derivation of the inertia tensor for the shifted CoM).
com_offset = np.array(
[theta["com_x"] * handles.bounding_radius, theta["com_y"] * handles.bounding_radius, 0.0]
)
model.body_ipos[handles.body_id] = handles.base_ipos + com_offset
model.geom_friction[handles.geom_id] = [
float(np.exp(theta["log_mu_slide"])),
float(np.exp(theta["log_mu_torsion"])),
_DEFAULT_ROLL_FRICTION,
]
damping = float(np.exp(theta["log_solref_damping"]))
model.geom_solref[handles.geom_id] = [_SOLREF_TIMECONST, damping]
mu_gripper = float(np.exp(theta["log_mu_gripper"]))
for gid in finger_geom_ids:
model.geom_friction[gid] = [mu_gripper, _DEFAULT_GRIPPER_TORSION, _DEFAULT_GRIPPER_ROLL]
if handles.kind == "prismatic":
model.dof_frictionloss[handles.dof_id] = float(np.exp(theta["log_joint_friction"]))
model.dof_damping[handles.dof_id] = float(np.exp(theta["log_joint_damping"]))
def _apply_kinematic_friction(
model: mujoco.MjModel, handles: _BodyHandles, theta: dict[str, float]
) -> None:
"""Give a kinematic (mocap-driven, "other tracked object") body a real friction.
A contact needs friction numbers from BOTH surfaces to mean anything -- see the
module docstring's "Priority, not friction mixing" note, which is exactly why this
matters: MuJoCo still reads ``geom_friction`` off *this* geom even though the body
of interest's own ``priority="1"`` geom is the one whose value ultimately wins the
contact unmixed. Leaving this at the MJCF's compiled-in placeholder friction would
silently make every brick-vs-drawer (or brick-vs-anything-else-tracked) contact
behave as if the drawer were made of that placeholder material instead of
participating in the identified physics at all.
The honest choice with ONE episode's worth of evidence is to reuse the body of
interest's OWN identified ``log_mu_slide``/``log_mu_torsion`` here, not to invent a
second, per-pair friction parameter. A genuinely separate brick-vs-drawer friction
is not identifiable from this data: the only observed trajectory in this SimSpec is
the body of interest's, so there is no second, independent channel of evidence that
could tell "the drawer's surface is slippery" apart from "the brick's own sliding
friction with everything it touches is low" -- both explanations predict exactly the
same brick trajectory. Adding a ``log_mu_slide_<other_label>`` per kinematic body
would multiply the parameter count by however many objects the episode happened to
track, purely to carry a value this stage's own likelihood could never separate from
the one it already has.
"""
model.geom_friction[handles.geom_id] = [
float(np.exp(theta["log_mu_slide"])),
float(np.exp(theta["log_mu_torsion"])),
_DEFAULT_ROLL_FRICTION,
]
def _apply_generic_body_defaults(model: mujoco.MjModel, handles: _BodyHandles) -> None:
"""Physical parameters for any body beyond spec.bodies[0] -- see module docstring."""
model.body_mass[handles.body_id] = handles.base_mass * _GENERIC_BODY_DENSITY
model.body_inertia[handles.body_id] = handles.base_inertia * _GENERIC_BODY_DENSITY
model.geom_friction[handles.geom_id] = list(_GENERIC_BODY_FRICTION)
model.geom_solref[handles.geom_id] = list(_GENERIC_BODY_SOLREF)
if handles.kind == "prismatic":
model.dof_frictionloss[handles.dof_id] = 0.0
model.dof_damping[handles.dof_id] = 0.1
def _extract_pose(data: mujoco.MjData, body_id: int) -> np.ndarray:
"""[x, y, z, qw, qx, qy, qz] -- `data.xpos`/`xquat`, not raw `qpos`.
Works uniformly whether the body's own joint is a 7-dof freejoint or a
1-dof slide joint (`qpos` for the latter is a scalar, not a pose) -- see
the module docstring's "body of interest" note.
"""
return np.concatenate([data.xpos[body_id], data.xquat[body_id]])
def _set_initial_state(
model: mujoco.MjModel, data: mujoco.MjData, body: dict, handles: _BodyHandles
) -> None:
if handles.kind == "kinematic":
# A mocap body has no qpos/qvel at all -- its own DOF-free position is set
# directly via data.mocap_pos/mocap_quat (see _set_body_mocap, called from
# simulate_all right after this function for every body, kinematic or not,
# frame 0's pose). Nothing to do here; init_lin_vel/init_ang_vel do not even
# apply to a body with no velocity state.
return
lin_vel = np.asarray(body["init_lin_vel"], dtype=np.float64)
ang_vel_world = np.asarray(body["init_ang_vel"], dtype=np.float64)
if handles.kind == "free":
jnt_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, f"{handles.label}_free")
qadr = int(model.jnt_qposadr[jnt_id])
vadr = int(model.jnt_dofadr[jnt_id])
# freejoint qpos is already init_pose_pos/quat (set at compile time via body
# pos/quat) -- qpos stores the DEVIATION from that reference for a freejoint's
# translational part too, but MuJoCo initialises qpos[0:3] to the body's
# compiled pos, qpos[3:7] to its compiled quat, i.e. exactly init_pose already.
# Only qvel needs to be set here.
# MuJoCo freejoint qvel convention (verified empirically, see
# scripts/_mujoco_settle_worker.py's docstring): linear in WORLD frame,
# angular in the BODY-LOCAL frame -- rotate the world-frame FK estimate here.
rot = np.asarray(body["init_pose"], dtype=np.float64)[:3, :3]
ang_vel_body = rot.T @ ang_vel_world
data.qvel[vadr : vadr + 3] = lin_vel
data.qvel[vadr + 3 : vadr + 6] = ang_vel_body
del qadr # unused; kept for readability of the derivation above
else:
# Slide joint: qpos is a scalar deviation from the compiled reference frame
# (= init_pose by construction, see _load_spec) -- simulation starts at the
# observed pose exactly, i.e. qpos=0. qvel is the scalar rate along the axis.
jnt_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, f"{handles.label}_slide")
qadr = int(model.jnt_qposadr[jnt_id])
vadr = int(model.jnt_dofadr[jnt_id])
axis_local = np.asarray(body["joint_axis_local"], dtype=np.float64)
data.qpos[qadr] = 0.0
data.qvel[vadr] = float(np.dot(lin_vel, axis_local))
def simulate_all(spec: dict, theta: np.ndarray, param_names: list[str]) -> dict:
"""Compile once, loop every particle, return the raw arrays that go into ``--out``.
Returns a dict with ``poses`` (N, T, 7), ``ok`` (N,), and the timing breakdown
keys documented in the module docstring / CLI usage.
"""
t_build0 = time.perf_counter()
xml = build_mjcf(spec)
model = mujoco.MjModel.from_xml_string(xml)
data = mujoco.MjData(model)
bodies = spec["bodies"]
handles = [_BodyHandles(model, b["label"], b["kind"]) for b in bodies]
finger_geom_ids = []
if spec.get("gripper") is not None:
for name in ("finger_L_geom", "finger_R_geom"):
gid = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_GEOM, name)
if gid < 0:
raise WorkerError(f"MJCF compiled without expected gripper geom {name!r}")
finger_geom_ids.append(gid)
if spec.get("heightfield") is not None:
# Structural, particle-independent data -- filled ONCE here, exactly like the mesh
# assets MJCF's own `file=` attribute already loaded at compile time. There is no
# `<hfield file=...>` equivalent for a raw float array without a lossy 8-bit PNG
# round-trip (see fpgm.physics.types.HeightfieldSpec's docstring for why that was
# rejected), so this is the one place the normalised grid actually reaches the
# compiled model.
hf_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_HFIELD, "terrain")
if hf_id < 0:
raise WorkerError("MJCF compiled without the expected hfield asset 'terrain'")
adr = int(model.hfield_adr[hf_id])
n = int(model.hfield_nrow[hf_id]) * int(model.hfield_ncol[hf_id])
normalized01 = spec["heightfield"]["_mujoco"]["normalized01"]
if normalized01.size != n:
raise WorkerError(
f"heightfield grid has {normalized01.size} cells, compiled hfield 'terrain' "
f"has {n} (nrow={model.hfield_nrow[hf_id]}, ncol={model.hfield_ncol[hf_id]})"
)
model.hfield_data[adr : adr + n] = normalized01.reshape(-1)
build_seconds = time.perf_counter() - t_build0
n_frames = int(spec["n_frames"])
substeps = int(spec["substeps"])
finger_poses = (
np.asarray(spec["gripper"]["finger_poses"], dtype=np.float64)
if spec.get("gripper") is not None
else None
)
# Every kinematic body's own (T, 4, 4) driving track, paired with its resolved
# mocap_id -- computed once here (particle-independent, exactly like finger_poses
# above), not inside the per-particle loop below.
kinematic = [
(h, np.asarray(b["pose_track"], dtype=np.float64))
for h, b in zip(handles, bodies, strict=True)
if h.kind == "kinematic"
]
safety_z = _safety_floor_z(spec)
n_particles = theta.shape[0]
poses = np.full((n_particles, n_frames, 7), np.nan, dtype=np.float64)
ok = np.zeros(n_particles, dtype=bool)
t_step0 = time.perf_counter()
for i in range(n_particles):
row = dict(zip(param_names, theta[i].tolist(), strict=True))
_apply_theta(model, handles[0], row, finger_geom_ids)
for extra in handles[1:]:
if extra.kind == "kinematic":
_apply_kinematic_friction(model, extra, row)
else:
_apply_generic_body_defaults(model, extra)
# mj_setConst refreshes the compile-time-cached constants that a raw
# `body_mass`/`body_inertia` write does NOT update on its own --
# `body_invweight0` chief among them, the reference inverse-mass the
# constraint solver uses to weight contact impedance. Measured directly
# while building this worker: without this call, a body compiled at
# `density="1"` (see _BodyHandles' base_mass/base_inertia, "compile once,
# scale per particle") then scaled up to a realistic mass produces
# contacts that are detected (ncon>0) but far too SOFT -- the object
# visibly sinks through a support box it should rest on, because the
# solver is still weighting the contact as if the body were still
# density=1 (a ~1e4x lighter, unrealistically-easy-to-push-through
# object). With the call, the same edit reproduces a direct
# `density="<value>"` compile's settle trajectory exactly. This is not
# a recompilation (no XML reparsing, no mesh reload) -- it is the one
# extra O(nv) call needed to make a mass/inertia field edit fully take
# effect, and is paid once per particle, not per step.
mujoco.mj_setConst(model, data)
mujoco.mj_resetData(model, data)
for h, b in zip(handles, bodies, strict=True):
_set_initial_state(model, data, b, h)
if finger_poses is not None:
_set_finger_mocap(model, data, finger_poses[0])
for kh, ktrack in kinematic:
_set_body_mocap(data, kh.mocap_id, ktrack[0])
mujoco.mj_forward(model, data)
diverged = not np.all(np.isfinite(data.qpos)) or not np.all(np.isfinite(data.qvel))
if not diverged:
poses[i, 0] = _extract_pose(data, handles[0].body_id)
if not diverged:
for t in range(1, n_frames):
for k in range(1, substeps + 1):
frac = k / substeps
if finger_poses is not None:
_set_finger_mocap_interp(
model, data, finger_poses[t - 1], finger_poses[t], frac
)
for kh, ktrack in kinematic:
_set_body_mocap_interp(data, kh.mocap_id, ktrack[t - 1], ktrack[t], frac)
mujoco.mj_step(model, data)
if not np.all(np.isfinite(data.qpos)) or not np.all(np.isfinite(data.qvel)):
diverged = True
break
if float(data.xpos[handles[0].body_id, 2]) < safety_z:
diverged = True
break
poses[i, t] = _extract_pose(data, handles[0].body_id)
ok[i] = not diverged
sim_seconds = time.perf_counter() - t_step0
return {
"poses": poses,
"ok": ok,
"sim_seconds": sim_seconds,
"build_seconds": build_seconds,
"n_particles": n_particles,
}
def _safety_floor_z(spec: dict) -> float:
"""See :data:`_SAFETY_FLOOR_DROP_M` -- this is ``build_mjcf``'s own ``safety_z``
computation, duplicated here (not shared) because :func:`simulate_all` needs the
number, not the XML string ``build_mjcf`` builds it into.
"""
if spec.get("heightfield") is not None:
pos_z = spec["heightfield"]["_mujoco"]["pos"][2]
return float(pos_z) - _SAFETY_FLOOR_DROP_M
return min(float(b["init_pose_pos"][2]) for b in spec["bodies"]) - _SAFETY_FLOOR_DROP_M
def _set_finger_mocap(model: mujoco.MjModel, data: mujoco.MjData, frame: np.ndarray) -> None:
left_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "finger_L")
right_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "finger_R")
for body_id, pose in ((left_id, frame[0]), (right_id, frame[1])):
mocap_id = int(model.body_mocapid[body_id])
data.mocap_pos[mocap_id] = pose[:3, 3]
data.mocap_quat[mocap_id] = _mat_to_quat(pose[:3, :3])
def _set_finger_mocap_interp(
model: mujoco.MjModel, data: mujoco.MjData, frame_a: np.ndarray, frame_b: np.ndarray, t: float
) -> None:
left_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "finger_L")
right_id = mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_BODY, "finger_R")
pairs = ((left_id, frame_a[0], frame_b[0]), (right_id, frame_a[1], frame_b[1]))
for body_id, pose_a, pose_b in pairs:
mocap_id = int(model.body_mocapid[body_id])
data.mocap_pos[mocap_id] = _lerp(pose_a[:3, 3], pose_b[:3, 3], t)
quat_a, quat_b = _mat_to_quat(pose_a[:3, :3]), _mat_to_quat(pose_b[:3, :3])
data.mocap_quat[mocap_id] = _slerp(quat_a, quat_b, t)
def _set_body_mocap(data: mujoco.MjData, mocap_id: int, pose: np.ndarray) -> None:
"""Like ``_set_finger_mocap``, but for one already-resolved ``mocap_id`` (see
``_BodyHandles.mocap_id``) rather than re-resolving a fixed finger name -- a
``"kinematic"`` body's name is data-dependent (whichever other label S6 tracked),
so there is no fixed name to look up the way the two fingers have.
"""
data.mocap_pos[mocap_id] = pose[:3, 3]
data.mocap_quat[mocap_id] = _mat_to_quat(pose[:3, :3])
def _set_body_mocap_interp(
data: mujoco.MjData, mocap_id: int, pose_a: np.ndarray, pose_b: np.ndarray, t: float
) -> None:
"""Substep interpolation for a kinematic body -- same ``_lerp``/``_slerp`` pair
``_set_finger_mocap_interp`` uses, same rationale (module docstring's "why mocap
fingers" note): holding a tracked object's pose constant for a whole output frame
would introduce a full frame's worth of positional lag on a fast-moving object
(e.g. a drawer mid-slide), exactly as it would for a fast-moving finger.
"""
data.mocap_pos[mocap_id] = _lerp(pose_a[:3, 3], pose_b[:3, 3], t)
quat_a, quat_b = _mat_to_quat(pose_a[:3, :3]), _mat_to_quat(pose_b[:3, :3])
data.mocap_quat[mocap_id] = _slerp(quat_a, quat_b, t)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--spec", type=Path, required=True)
p.add_argument("--particles", type=Path, required=True)
p.add_argument("--out", type=Path, required=True)
return p.parse_args()
def main() -> int:
args = parse_args()
t_io0 = time.perf_counter()
spec = _load_spec(args.spec)
theta, param_names = _load_particles(args.particles)
io_read_seconds = time.perf_counter() - t_io0
result = simulate_all(spec, theta, param_names)
t_io1 = time.perf_counter()
np.savez(
args.out,
poses=result["poses"],
ok=result["ok"],
sim_seconds=result["sim_seconds"],
n_substeps=int(spec["substeps"]),
label=spec["bodies"][0]["label"],
build_seconds=result["build_seconds"],
io_seconds=io_read_seconds + (time.perf_counter() - t_io1),
n_particles=result["n_particles"],
mujoco_version=mujoco.__version__,
)
print(
f"worker_mujoco: {result['n_particles']} particles x {spec['n_frames']} frames, "
f"ok={int(result['ok'].sum())}/{result['n_particles']}, "
f"build={result['build_seconds']:.3f}s step={result['sim_seconds']:.3f}s "
f"({1000.0 * result['sim_seconds'] / max(result['n_particles'], 1):.3f} ms/particle, "
f"mujoco {mujoco.__version__})"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
51.4 kB
·
Xet hash:
8a8b3c9f0fbf3d59c68a88817045e0cbe5b952a36878cac70f4e0420b32ccb35

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.