Quick Start: Modular Robots¶

Overview¶

This guide walks through the essentials of setting up a modular robot in ARIEL and running it in simulation. By the end you will know how to:

  1. Choose or build a robot body — use a prebuilt robot, assemble one from modules, or generate one randomly from a genotype.

  2. Create a simulation environment — pick a world and spawn your robot in it.

  3. Write a controller — use the NA-CPG and the Controller class to drive the robot’s joints.

  4. Run and visualise — launch an interactive viewer or record a headless run.

  5. Track and plot results — record position history and produce a trajectory plot.


1. Imports¶

[ ]:
# Standard library
from pathlib import Path

# Third-party libraries
import matplotlib.pyplot as plt
import mujoco
import numpy as np
from mujoco import viewer

# ARIEL — module primitives (for manual assembly)
from ariel.body_phenotypes.robogen_lite.config import ModuleFaces
from ariel.body_phenotypes.robogen_lite.constructor import (
    construct_mjspec_from_graph,
)
from ariel.body_phenotypes.robogen_lite.decoders.hi_prob_decoding import (
    HighProbabilityDecoder,
)
from ariel.body_phenotypes.robogen_lite.modules.brick import BrickModule
from ariel.body_phenotypes.robogen_lite.modules.core import CoreModule
from ariel.body_phenotypes.robogen_lite.modules.hinge import HingeModule

# ARIEL — prebuilt robot bodies
from ariel.body_phenotypes.robogen_lite.prebuilt_robots.gecko import gecko

# ARIEL — genotype-to-phenotype pipeline
from ariel.ec.genotypes.nde import NeuralDevelopmentalEncoding

# ARIEL — controller
from ariel.simulation.controllers.controller import Controller
from ariel.simulation.controllers.na_cpg import (
    NaCPG,
    create_fully_connected_adjacency,
)

# ARIEL — simulation environments
from ariel.simulation.environments import SimpleFlatWorld

# ARIEL — utilities
from ariel.utils.runners import simple_runner
from ariel.utils.tracker import Tracker

2. The Robot Body¶

ARIEL provides three ways to get a robot body:

Approach

When to use

Prebuilt robot

Quickest way to get started — body is already wired up.

Manual assembly

Full control over morphology using CoreModule, HingeModule, and BrickModule.

Genotype decoding

Generate a random (or evolved) morphology from a numeric genotype via the NDE pipeline.

For a deeper introduction to the module types (core, brick, hinge) see the Modular Robots reference page.

2a. Using a prebuilt robot¶

The gecko is a prebuilt robot that ships with ARIEL. It has a core, a spine, four legs, and several active hinges.

[ ]:
# gecko() returns a CoreModule — the root of the robot tree.
gecko_body = gecko()

print(type(gecko_body))

2b. Assembling a robot manually¶

You can build any morphology by attaching modules to the core’s sites. Every module exposes attachment sites on its FRONT, LEFT, RIGHT, and BACK faces (where physically available).

[ ]:
# Start with a core (the robot's brain)
core = CoreModule(index=0)

# Add active hinges on left and right
left_arm = HingeModule(index=1)
right_arm = HingeModule(index=2)
right_arm.rotate(180)  # Flip so it points the other way

# Add passive brick modules as feet
left_foot = BrickModule(index=3)
right_foot = BrickModule(index=4)

# Wire them together
core.sites[ModuleFaces.LEFT].attach_body(body=left_arm.body, prefix="left_arm")
core.sites[ModuleFaces.RIGHT].attach_body(body=right_arm.body, prefix="right_arm")
left_arm.sites[ModuleFaces.FRONT].attach_body(body=left_foot.body, prefix="left_foot")
right_arm.sites[ModuleFaces.FRONT].attach_body(body=right_foot.body, prefix="right_foot")

custom_body = core
print("Custom robot assembled.")

2c. Generating a random robot from a genotype¶

The Neural Developmental Encoding (NDE) pipeline turns a numeric genotype into a robot morphology. This is the foundation of body evolution in ARIEL.

The pipeline has three stages:

Genotype  ──►  NDE network  ──►  Probability matrices  ──►  HighProbabilityDecoder  ──►  Robot graph  ──►  MjSpec

The genotype is a list of three float arrays (chromosomes) — one each for:

  • module types (core / brick / hinge)

  • connection topology (which module attaches to which face)

  • rotations (orientation of each attachment)

The NDE network maps each chromosome to a probability matrix. The HighProbabilityDecoder then greedily picks the most probable configuration to produce a concrete robot graph.

For full details on the NDE representation and how to evolve it, see the NDE Genotype documentation.

[ ]:
RNG = np.random.default_rng(seed=42)

# --- Hyperparameters ---
NUM_MODULES   = 20   # maximum number of modules in the decoded robot
GENOTYPE_SIZE = 64   # length of each chromosome

# --- Step 1: create a random genotype ---
# Each chromosome is a float32 array of length GENOTYPE_SIZE.
type_genes = RNG.random(GENOTYPE_SIZE).astype(np.float32)   # module-type chromosome
conn_genes = RNG.random(GENOTYPE_SIZE).astype(np.float32)   # connectivity chromosome
rot_genes  = RNG.random(GENOTYPE_SIZE).astype(np.float32)   # rotation chromosome

genotype = [type_genes, conn_genes, rot_genes]

# --- Step 2: run genotype through the NDE to get probability matrices ---
nde = NeuralDevelopmentalEncoding(number_of_modules=NUM_MODULES)
p_matrices = nde.forward(genotype)
# p_matrices is a list of three 2-D arrays:
#   p_matrices[0] — module-type probabilities
#   p_matrices[1] — connectivity probabilities
#   p_matrices[2] — rotation probabilities
print(f"Probability matrix shapes: {[m.shape for m in p_matrices]}")

# --- Step 3: decode into a robot graph ---
decoder = HighProbabilityDecoder(num_modules=NUM_MODULES)
robot_graph = decoder.probability_matrices_to_graph(
    p_matrices[0],
    p_matrices[1],
    p_matrices[2],
)
print(f"Robot graph: {robot_graph.number_of_nodes()} nodes, {robot_graph.number_of_edges()} edges")

# --- Step 4: build the ARIEL robot from the graph ---
random_robot = construct_mjspec_from_graph(robot_graph)
print(f"Robot body type: {type(random_robot)}")

The resulting random_robot is a CoreModule, identical in interface to the prebuilt gecko or a manually assembled body — you can spawn it in a world exactly the same way.

In an EA the genotype arrays are replaced by the genes of an individual, so evolving the body is simply a matter of evolving those three float arrays.

For the rest of this guide we use the prebuilt gecko for consistency.


3. Simulation Environment¶

An environment defines the world geometry (floor, terrain, obstacles). ARIEL ships several ready-to-use worlds:

Class

Description

SimpleFlatWorld

Flat chequerboard floor — great for locomotion experiments.

SimpleTiltedWorld

Gently sloped flat floor.

RuggedTerrainWorld

Procedurally generated rough terrain.

CraterTerrainWorld

Terrain with a central crater.

OlympicArena

Rectangular arena with walls and a target marker.

All worlds expose the same spawn() interface, so you can swap them out without changing the rest of your code.

Spawning the robot¶

world.spawn() merges the robot’s XML specification into the world’s MuJoCo spec. Always reset the MuJoCo control callback before spawning.

[ ]:
# Always reset the control callback before building a new simulation.
mujoco.set_mjcb_control(None)

# Create the world
world = SimpleFlatWorld()

# Create the robot body
robot = gecko()

# Spawn the robot at the origin
world.spawn(robot.spec, position=[0, 0, 0])

# Compile into a MuJoCo model and initialise data
model = world.spec.compile()
data = mujoco.MjData(model)

print(f"Number of actuators (joints): {model.nu}")
print(f"Number of degrees of freedom: {model.nv}")

4. Controller¶

ARIEL separates the control logic from the MuJoCo callback using two classes:

Class

Role

NaCPG

Computes joint-angle targets using the Normalized Asymmetric CPG equations.

Controller

Wraps any callable into a MuJoCo-compatible callback; handles control frequency, smoothing, joint-limit clamping, and data tracking.

4a. The NA-CPG¶

The Normalized Asymmetric CPG (NA-CPG) is a biologically inspired oscillator network where each joint is driven by one CPG node. The nodes are coupled via an adjacency graph; a fully-connected graph is a reasonable default.

Its learnable parameters — phase, w (frequency), amplitudes, ha, and b — are initialized randomly, so an untrained CPG already produces movement. In an evolutionary setting these parameters become the controller genotype to evolve.

NaCPG.forward(time) returns a torch.Tensor of joint angles, one per actuator.

[ ]:
# Build the CPG — one node per actuator, fully connected.
adj_dict = create_fully_connected_adjacency(model.nu)
cpg = NaCPG(
    adjacency_dict=adj_dict,
    hard_bounds=(-np.pi / 2, np.pi / 2),  # keep angles within hinge limits
)

print(f"CPG nodes (= actuators): {cpg.n}")
print(f"Total learnable parameters: {cpg.num_of_parameters}")

4b. Wrapping the CPG with Controller¶

The Controller class bridges any callable with MuJoCo’s control callback system. Its set_control method is the function you hand to mujoco.set_mjcb_control().

Internally Controller:

  • Calls your function every time_steps_per_ctrl_step physics steps (control frequency).

  • Blends the new output with the old data.ctrl via a smoothing factor alpha to avoid jitter.

  • Clips all values to [-Ï€/2, Ï€/2] automatically.

  • Calls tracker.update(data) every time_steps_per_save steps so you never have to do it manually.

The controller_callback_function must accept (model, data) and return an array-like of length model.nu.

[ ]:
# Set up the tracker — Controller will call tracker.update() automatically.
tracker = Tracker(
    mujoco_obj_to_find=mujoco.mjtObj.mjOBJ_GEOM,
    name_to_bind="core",
    observable_attributes=["xpos"],
)
tracker.setup(world.spec, data)

# Wrap the CPG: the callback receives (model, data) and returns joint angles.
def cpg_callback(model: mujoco.MjModel, data: mujoco.MjData):
    return cpg.forward(time=data.time)

# Create the Controller.
controller = Controller(
    controller_callback_function=cpg_callback,
    time_steps_per_ctrl_step=50,   # call CPG every 50 physics steps
    time_steps_per_save=500,        # record tracker data every 500 steps
    alpha=0.5,                      # smoothing: 0 = never update, 1 = immediate update
    tracker=tracker,
)

# Register with MuJoCo — controller.set_control IS the callback.
mujoco.set_mjcb_control(controller.set_control)

print("Controller registered.")

5. Running the Simulation¶

ARIEL offers three ways to run a simulation:

Method

Description

Interactive viewer (viewer.launch)

Opens the MuJoCo GUI — great for debugging and demos.

Headless runner (simple_runner)

Fastest option; no window, no rendering. Use this inside an EA evaluation loop.

Video renderer (video_renderer)

Records an MP4 — good for logging and presentations.

Because controller.set_control is registered as the MuJoCo callback, all three modes automatically drive the robot and record tracker data — no extra wiring needed.

For full details on MuJoCo’s data variables accessible during simulation see the MuJoCo Data Variables documentation.

5a. Headless run (recommended for evaluation loops)¶

[ ]:
# Run for 15 simulated seconds — controller and tracker work automatically.
simple_runner(model, data, duration=15)

print(f"Simulation finished at t = {data.time:.2f} s")
print(f"Final core position: {data.qpos[:3]}")
print(f"Recorded {len(tracker.history['xpos'][0])} position snapshots.")

5b. Manual step loop¶

If you need finer control over when things happen (e.g., logging extra diagnostics mid-run), step the simulation manually.

[ ]:
# Fresh simulation
mujoco.set_mjcb_control(None)
world = SimpleFlatWorld()
robot = gecko()
world.spawn(robot.spec, position=[0, 0, 0])
model = world.spec.compile()
data = mujoco.MjData(model)

# Re-create tracker and controller for the new model
tracker = Tracker(
    mujoco_obj_to_find=mujoco.mjtObj.mjOBJ_GEOM,
    name_to_bind="core",
    observable_attributes=["xpos"],
)
tracker.setup(world.spec, data)

adj_dict = create_fully_connected_adjacency(model.nu)
cpg = NaCPG(adjacency_dict=adj_dict, hard_bounds=(-np.pi / 2, np.pi / 2))

controller = Controller(
    controller_callback_function=lambda m, d: cpg.forward(time=d.time),
    tracker=tracker,
)
mujoco.set_mjcb_control(controller.set_control)

# Step loop — tracker updates are handled inside controller.set_control
mujoco.mj_resetData(model, data)
duration = 15
while data.time < duration:
    mujoco.mj_step(model, data)

print(f"Recorded {len(tracker.history['xpos'][0])} position snapshots.")

5c. Interactive viewer¶

The interactive viewer opens a window where you can rotate the camera and observe the robot in real time. It blocks until the window is closed.

Tip: Pass mujoco.set_mjcb_control(None) before viewer.launch to disable the controller and drag joints manually.

[ ]:
# Uncomment to open the interactive viewer
# mujoco.set_mjcb_control(controller.set_control)
# viewer.launch(model=model, data=data)

6. Visualising the Trajectory¶

After a run, tracker.history['xpos'][0] contains a list of [x, y, z] positions for the core geom. We can plot the 2-D (x, y) path to see how far the robot travelled.

[ ]:
def plot_trajectory(history: list) -> None:
    """Plot the XY trajectory of the robot's core module."""
    pos = np.array(history)  # shape: (T, 3)

    fig, ax = plt.subplots(figsize=(7, 7))
    ax.plot(pos[:, 0], pos[:, 1], "b-", linewidth=1.5, label="Path")
    ax.plot(pos[0, 0], pos[0, 1], "go", markersize=10, label="Start")
    ax.plot(pos[-1, 0], pos[-1, 1], "ro", markersize=10, label="End")

    ax.set_xlabel("X position (m)")
    ax.set_ylabel("Y position (m)")
    ax.set_title("Robot trajectory (XY plane)")
    ax.legend()
    ax.set_aspect("equal")
    ax.grid(True)

    # Centre the view
    margin = max(np.abs(pos[:, :2]).max(), 0.3)
    ax.set_xlim(-margin, margin)
    ax.set_ylim(-margin, margin)

    plt.tight_layout()
    plt.show()


plot_trajectory(tracker.history["xpos"][0])

7. Defining a Fitness Function¶

In an evolutionary context you typically summarise a trajectory as a single fitness score. A common choice is the 2-D displacement from the spawn position.

[ ]:
def displacement_fitness(history: list) -> float:
    """Return the 2-D Euclidean distance from start to end position."""
    pos = np.array(history)
    x0, y0 = pos[0, :2]
    xf, yf = pos[-1, :2]
    return float(np.sqrt((xf - x0) ** 2 + (yf - y0) ** 2))


fitness = displacement_fitness(tracker.history["xpos"][0])
print(f"Fitness (displacement): {fitness:.4f} m")

8. Putting It All Together¶

The snippet below wraps everything into a single reusable evaluate() function — the natural building block for an EA evaluation loop.

To see how this plugs into a full evolutionary algorithm that co-evolves bodies and controllers, see the Robot Evolution guide.

[ ]:
def evaluate(
    cpg: NaCPG,
    *,
    duration: float = 15.0,
) -> float:
    """Evaluate a CPG controller on the gecko robot and return displacement fitness.

    Parameters
    ----------
    cpg:
        A configured NaCPG instance whose parameters define the controller.
    duration:
        How many simulated seconds to run.

    Returns
    -------
    float
        2-D displacement from start to end (metres).
    """
    # --- Build world + robot ---
    mujoco.set_mjcb_control(None)
    world = SimpleFlatWorld()
    robot = gecko()
    world.spawn(robot.spec, position=[0, 0, 0])
    model = world.spec.compile()
    data = mujoco.MjData(model)

    # --- Tracker + Controller ---
    tracker = Tracker(
        mujoco_obj_to_find=mujoco.mjtObj.mjOBJ_GEOM,
        name_to_bind="core",
        observable_attributes=["xpos"],
    )
    tracker.setup(world.spec, data)

    controller = Controller(
        controller_callback_function=lambda m, d: cpg.forward(time=d.time),
        tracker=tracker,
    )
    mujoco.set_mjcb_control(controller.set_control)

    # --- Run ---
    simple_runner(model, data, duration=duration)

    # --- Score ---
    return displacement_fitness(tracker.history["xpos"][0])


# Create a CPG with random parameters and evaluate it
mujoco.set_mjcb_control(None)
world_tmp = SimpleFlatWorld()
robot_tmp = gecko()
world_tmp.spawn(robot_tmp.spec, position=[0, 0, 0])
model_tmp = world_tmp.spec.compile()

cpg_to_eval = NaCPG(
    adjacency_dict=create_fully_connected_adjacency(model_tmp.nu),
    hard_bounds=(-np.pi / 2, np.pi / 2),
)

score = evaluate(cpg_to_eval, duration=15)
print(f"Displacement: {score:.4f} m")

Summary¶

Step

Key call

Reset MuJoCo callback

mujoco.set_mjcb_control(None)

Create world

world = SimpleFlatWorld()

Get robot body (prebuilt)

robot = gecko()

Get robot body (manual)

assemble CoreModule / HingeModule / BrickModule

Get robot body (genotype)

nde.forward(genotype) → decoder.probability_matrices_to_graph(...) → construct_mjspec_from_graph(graph)

Spawn robot

world.spawn(robot.spec, position=[x, y, z])

Compile

model = world.spec.compile(); data = mujoco.MjData(model)

Create CPG

NaCPG(create_fully_connected_adjacency(model.nu))

Create controller

Controller(controller_callback_function=..., tracker=tracker)

Register controller

mujoco.set_mjcb_control(controller.set_control)

Run headless

simple_runner(model, data, duration=N)

Run interactive

viewer.launch(model=model, data=data)

Where to go next¶

  • Modular Robots — in-depth reference for module types and the physical robot system.

  • NDE Genotype — how to evolve robot bodies using the Neural Developmental Encoding.

  • Robot Evolution — a complete example that wires the body + controller into an evolutionary algorithm.

  • MuJoCo Data Variables — all MuJoCo state variables accessible during simulation.