.. _architecture: Architecture & porting playbook =============================== This is the source of truth for how roqsim is built and how to rework existing MuJoCo code into it. It documents both what exists today and designs that are **planned / not yet implemented** (so marked). A large external MuJoCo codebase is expected to be reworked into this structure — the **Porting playbook** (section 6) is the section to read for that. Status legend: **[planned]** means designed but not yet built. Everything else is implemented. .. _1-overview--goals: 1. Overview & goals ------------------- roqsim is a lightweight, plugin-driven MuJoCo simulator for mobile robots, robot arms, and mobile manipulators (plus extras like conveyor belts). A MuJoCo step loop plus **plugins** that hook into well-defined lifecycle points, all loaded and configured from a **single YAML file**. Two ways to run, one engine: - **Standalone driver** (``runner.py``) — owns the loop; windowed by default, headless for containers; real-time / factor / as-fast-as-possible pacing; can record world state over time. - **scenario-execution driver** (``scenario_adapter.py``) — a ``SimulationInterface`` subclass; scenario-execution owns the loop and calls ``dt``/``setup``/``reset``/``step``/``shutdown``. It also publishes ``context``, the seam an in-process scenario action reads (§12, *Reaching a plugin from outside*). Both wrap the same ``Engine``, so behaviour is identical across the two. Tick pipeline (one ``step()``): :: external threads ──post(cmd)──▶ [command queue] │ (drained on physics thread) physics thread: drain ─▶ pre_step(all) ─▶ mj_step ─▶ post_step(all) ─▶ snapshot (write ctrl) (physics) (read/publish) (cross-thread reads) Non-goals: roqsim is not a general game engine or a physics fork; it orchestrates MuJoCo. It does not recompile the model at runtime (see anti-patterns). .. _2-lifecycle-reference: 2. Lifecycle reference ---------------------- A plugin (``roqsim.plugin.Plugin``) implements any subset of six hooks. The engine only calls hooks a plugin actually overrides (an un-overridden hook costs nothing and is absent from the timing table). +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | Hook | When | May touch | Typical use | +======================+=====================================+=================================================+================================================================================================+ | ``build(spec, ctx)`` | once, pre-compile | ``spec`` only (``model``/``data`` are ``None``) | add bodies/geoms/sensors/assets | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | ``configure(ctx)`` | once, post-compile | ``model``, ``data``, ids | resolve body/site ids, open resources, advertise services, register ``RobotHandle``/``Entity`` | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | ``on_reset(ctx)`` | every reset, after ``mj_resetData`` | ``model``, ``data`` | re-home arm, respawn objects | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | ``pre_step(ctx)`` | each tick, before ``mj_step`` | ``data`` (write ``ctrl``, forces) | apply commands | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | ``post_step(ctx)`` | each tick, after ``mj_step`` | ``data`` (read) | publish, record | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ | ``shutdown(ctx)`` | once, teardown (reverse order) | — | close nodes, flush files | +----------------------+-------------------------------------+-------------------------------------------------+------------------------------------------------------------------------------------------------+ Full-run sequence: :: setup(): build(p0)…build(pN) → spec.compile() → MjData → configure(p0)…configure(pN) reset(): drain → mj_resetData → mj_forward → on_reset(p0)…on_reset(pN) → mj_forward → gates.reset() step(): drain → pre_step(p0…pN) → mj_step → post_step(p0…pN) → publish_snapshot shutdown(): shutdown(pN)…shutdown(p0) (best-effort; a failure is logged, others still run) Ordering rule: within a hook, plugins run in **YAML order**; ``shutdown`` runs in reverse. Cross-plugin dependencies are expressed by ordering + the blackboard, never by importing another plugin. Reference implementation: ``roqsim/src/roqsim/engine.py``. .. _3-api-contracts: 3. API contracts ---------------- .. _plugin-pluginpy-impl: ``Plugin`` (``plugin.py``) ~~~~~~~~~~~~~~~~~~~~~~~~~~ - ``__init__(self, config: dict | None, *, name: str | None)`` — receives its YAML ``config:`` section. - ``validate_config(self, config) -> list[str]`` — return error strings (empty = valid). - ``expand(cls, spec, world, base_dir) -> list[PluginSpec]`` *(classmethod, optional)* — extra specs to splice in right after this one at config load; used by spawn plugins to pull in a model's manifest (see §4). What it returns is expanded in turn. A plugin lists the config keys ``expand`` reads in ``expansion_keys``, so a too-late override of one is refused. Default: none. - Hooks as in §2. ``parallel_safe: bool`` marks a read-only ``post_step`` for the future parallel executor. ``transport_only: bool`` marks a plugin that builds no geometry and holds no state (``BridgeBase`` and its subclasses), so the scene-only consumers — ``roqsim render``, the review window, the exporters — drop it and can therefore build a ``*_ros`` world without its middleware installed; ``roqsim sim`` keeps it unless asked for ``--no-communication``, which warns that the run then publishes and receives nothing (see :doc:`plugins` › Transport plugins). .. _simcontext-contextpy-impl: ``SimContext`` (``context.py``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Passed to every hook. Key members: - ``spec`` (build phase), ``model``, ``data``, ``dt``, ``sim_time``. - ``config`` — the full parsed YAML dict. - ``blackboard: Blackboard`` — ``set/get/require/__contains__``; typed cross-plugin store. - ``entities: EntityRegistry`` — ``add/remove/get/names/all`` of ``Entity(name, kind, body, meta)``; backs ``simulation_interfaces`` discovery. - ``render`` — a ``RenderService`` (lazily set; see §8). **[planned]** - ``post(cmd)`` / ``drain_commands()`` — the thread-safe command queue (§7). - ``publish_snapshot(d)`` / ``read_snapshot()`` — immutable snapshot for cross-thread readers. - ``register_gate(name, role)`` / ``gates()`` — step-gate API for synchronous mode (§10); **inert** now. .. _robothandle-contextpy-impl: ``RobotHandle`` (``context.py``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``RobotHandle(name, drive(vx,vy,w), read_odom()->(x,y,yaw,vx,vy,w))``. A controller plugin puts one on the blackboard (key convention ``robot:``); a bridge looks it up. Both callables run on the physics thread. .. _registry-registrypy-impl: Registry (``registry.py``) ~~~~~~~~~~~~~~~~~~~~~~~~~~ ``resolve_plugin(ref, base_dir) -> type[Plugin]``. See §4. .. _config-configpy-impl: Config (``config.py``) ~~~~~~~~~~~~~~~~~~~~~~ ``load_config(path)`` / ``load_config_from_dict(raw, base_dir)`` → ``SimConfig``. ``instantiate_plugins(cfg) -> list[Plugin]`` resolves classes, expands manifests (``Plugin.expand``; see §4), constructs, and runs aggregated validation. .. _4-config--registry: 4. Config & registry -------------------- Single world YAML, two sections; ``plugins`` order = execution order: .. code:: yaml sim: timestep: 0.004 # optional; else from the model pacing: realtime # realtime | {factor: 4.0} | asap [planned: honoured by runner] world: empty_room # built-in name OR a path to an MJCF file; default empty_room (see below) integrator: implicitfast # euler | rk4 | implicit | implicitfast noslip_iterations: 10 # solver effort; see "Solver options" below sync: {enabled: false} # foreseen lockstep mode (§10); inert components: - floorplan: # (1) entry-point short name -- the ref *is* the key mesh: envs/x.stl collision: true name: ground # optional instance name (reserved sibling key) - "my_pkg.mod:MyPlugin": { ... } # (2) importable module:Class (PYTHONPATH) - "./plugins/x.py:Foo": { ... } # (3) file path:Class (relative to this YAML) Each entry is a mapping with exactly one plugin-ref key (its value is the ``config`` map) plus an optional reserved ``name:`` sibling, defaulting to the ref. ``name:`` or ``components:`` found inside the config map is refused (``parse_plugin_entry``), because no plugin reads either from its config. Three plugin-ref resolution forms (``resolve_plugin``): 1. **Short name** → ``roqsim.plugins`` entry-point group. 2. **``module.path:Class``** → ``importlib.import_module`` (any package on ``PYTHONPATH``). 3. **``path/to/file.py:Class``** → ``importlib.util.spec_from_file_location`` (relative to the config dir). Forms 2 and 3 contain a colon, and the ref is the entry's *key*, so **quote it** (``- "my_pkg.mod:MyPlugin": {...}``) — unquoted it parses only while no space follows the colon, so a stray ``key: value`` space would silently truncate the ref. Short names have no colon and need no quotes. Order: no ``:`` → must be an entry-point (else error). Has ``:`` → file if the left side ends in ``.py`` or exists on disk, else module. Every failure raises ``PluginError`` naming the attempted form. Validation is **delegated to each plugin** (``validate_config``); ``instantiate_plugins`` aggregates all errors across all plugins and raises once, namespaced ``[name (ref)] message``. World definition (``sim.world``) ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The static environment the robots/props stand in — ground + lighting — is a *world definition*, chosen with the ``sim.world`` key (``roqsim/world.py``). It is separate from robot-family scene plugins so a fixed cell (an arm, a conveyor) never has to depend on the mobile package for a floor. Semantics: - ``sim.world`` is either a built-in world name or a **path to an MJCF file**. The only built-in is ``empty_room`` (a checker ground plane named ``floor``, a ceiling light, and four perimeter walls — a bounded, lit room); unset ⇒ ``empty_room``. A value that ends in ``.xml``/``.mjcf`` or contains a path separator is loaded as the base scene (``MjSpec.from_file``, resolved relative to the world YAML) — e.g. a baked scene like ``depot/depot.xml`` (see ``roqsim_scenes``). Anything that is neither the built-in nor a resolvable file is a fail-fast error. - The engine builds/loads the world into the ``MjSpec`` **before** any plugin ``build``, so plugins attach onto it. - A scene plugin that builds its **own** ground+lighting sets the class attribute ``provides_world = True`` (the mobile ``floorplan``, which also adds lidar walls). When such a plugin is present the engine **skips** the world definition; if ``sim.world`` was *also* set explicitly the engine logs a warning and lets the plugin win. So ``floorplan`` is the mobile scene, ``sim.world`` is the fixed-cell default, and they never double up the floor. Policy specs (``roqsim.policy``) '''''''''''''''''''''''''''''''' A policy-driven robot needs its observation assembled in exactly the layout its checkpoint was trained on, and getting that wrong does not raise -- the robot twitches. Three plugins (``g1_locomotion``, ``oli_locomotion``, ``spot_locomotion``) each hand-assemble that vector, against two mutually incompatible config schemas (``g1.yaml`` flat, ``oli/walk_param.yaml`` nested), so a fourth policy would mean a third bespoke reader. ``roqsim.policy`` makes the layout data: a ``PolicySpec`` YAML beside the checkpoint lists the observation terms in order, the actuated joints, the joints that are *observed but not commanded*, the control gains, and the envelope the policy was trained for. ``PolicySpec.build_observation`` is then the only thing that assembles an observation. It sits in **core** rather than a robot-family package because every family needs it, and it costs core nothing: it *describes* checkpoints and never loads them, so it imports only ``numpy`` and ``yaml``. Checkpoint loading stays in the family plugins, which is where ``torch``/``onnxruntime`` belong. Generality is measured, not asserted. The format is tested against the two policies that already ship -- the G1's 47-dim walk observation (with its gait phase) and Spot's 48-dim Isaac observation (with base linear velocity, which the humanoids do not use) -- by rebuilding each from a spec and comparing element-wise with the plugin's own builder. Neither plugin is migrated: they work and are covered, and swapping a live observation builder would risk a silent regression for no present gain. Those tests also show that Unitree's ``get_gravity_orientation`` and Isaac Lab's ``quat_rotate_inverse(q, [0,0,-1])`` are bit-identical (max difference 0 over 500 random quaternions), so one ``projected_gravity`` term serves a humanoid and a quadruped alike. Solver options (``sim.solver`` and friends) ''''''''''''''''''''''''''''''''''''''''''' ``sim`` carries five optional passthroughs to MuJoCo's ``