Simulations worth watching, each running MuJoCo 3.14.0 in this page. Every number quoted comes from the Python script printed under its demo, which uses the same model and the same algorithm as the lab; the labs measure live, and their numbers match the scripts where both report the same quantity.

1. A planner in the page

A cart-pole hangs straight down. Nothing in its controller was learned and nothing describes a swing-up: every 40 ms the planner imagines a set of futures by simulating the cart-pole forward for one second under perturbed copies of its current plan, scores each with a running cost (pole upright, cart near the centre, little effort), and keeps the best. This is predictive sampling, a sampling-based model-predictive controller (Howell et al., arXiv:2212.00541). The faint lines are the pole tip in every imagined future; the orange line is the plan being executed. Shove the pole and watch the futures fan out.

```lab mpc {“title”: “A planner in the page”, “height”: 300}


The script runs the same planner on 20 starts per setting, each perturbed by 0.05 rad, and calls a run a success when the pole stays within 0.2 rad of upright for the last 2 of 10 seconds with the cart inside its rails.

| Samples per replan | 4 | 8 | 16 | 32 | 64 | 128 |
|---|---|---|---|---|---|---|
| success | 0/20 | 2/20 | 3/20 | 9/20 | 18/20 | 20/20 |
| planning time per replan | 0.90 ms | 0.87 ms | 1.17 ms | 1.70 ms | 2.76 ms | 5.11 ms |

| Horizon (64 samples) | 0.25 s | 0.5 s | 1 s | 2 s |
|---|---|---|---|---|
| success | 0/20 | 9/20 | 18/20 | 4/20 |

More samples help without exception, at a cost linear in their number. The horizon has a narrow sweet spot: a quarter second cannot see past the effort of a swing-up, and with the plan's four knots a 2 s horizon spaces them 0.67 s apart, too coarse to balance (inference). The planner also copes with a model it gets wrong. It always assumes a 0.2 kg pole, and balances a 0.3 kg pole in 20 of 20 starts and a 0.4 kg pole in 19 of 20, because it replans from the measured state every 40 ms. A shove of 3 rad/s to the balanced pole at t = 6 s is recovered by the end of the run in 13 of 20. Lesson 21.4 builds on this planner.

```python file=examples/showcase_predictive_sampling.py
"""Showcase: swinging up and balancing a cart-pole with predictive sampling, a sampling-based MPC
(Howell et al., arXiv:2212.00541).

INPUT   cartpole.xml from its hanging keyframe, the pole perturbed by 0.05 rad (normal); a force on the cart
        within +-20 N; the planner simulates the same model at a 10 ms timestep
PROCESS every 40 ms: a control spline with 4 knots over the horizon is perturbed by Gaussian noise at three
        scales (0.5, 2 and 8 N); every candidate, and the unperturbed one, is rolled out from the current state
        with mujoco.rollout; the cheapest is applied until the next replan. Running cost
        (1 - cos theta) + 0.1 x^2 + 0.01 xdot^2 + 0.001 thetadot^2 + 1e-4 u^2.
        Success: |theta| < 0.2 rad for the last 2 s of 10 s, with the cart inside the rails.
        (1) number of samples, 20 starts each; (2) horizon, 64 samples; (3) a pole heavier than the planner
        believes, and a shove of 3 rad/s to the balanced pole at t = 6 s
OUTPUT  printed tables

Run:  python examples/showcase_predictive_sampling.py          (about 3 minutes)
"""

import os
import time

import mujoco
import mujoco.rollout as rollout
import numpy as np

from mjcourse import model_path

FAST = os.environ.get("MJC_FAST") == "1"
FULL = mujoco.mjtState.mjSTATE_FULLPHYSICS
SCALES = np.array([0.5, 2.0, 8.0])
KNOTS, REPLAN, PLAN_DT, LIMIT = 4, 0.04, 0.01, 20.0


def cartpole(pole_mass: float = 0.2) -> mujoco.MjModel:
    spec = mujoco.MjSpec.from_file(str(model_path("cartpole")))
    spec.geom("pole").mass = pole_mass
    return spec.compile()


def cost(states: np.ndarray, ctrl: np.ndarray) -> np.ndarray:
    """Summed running cost per candidate; states are (N, T, 1 + nq + nv) with time first."""
    x, th, xd, thd = states[..., 1], states[..., 2], states[..., 3], states[..., 4]
    c = (1 - np.cos(th)) + 0.1 * x**2 + 0.01 * xd**2 + 0.001 * thd**2 + 1e-4 * ctrl[..., 0] ** 2
    return c.sum(axis=1)


def episode(seed: int, samples: int = 64, horizon: float = 1.0, real_pole: float = 0.2, shove: bool = False,
            seconds: float = 10.0) -> tuple[bool, float, float]:
    """(success, time when the pole first comes within 0.2 rad of upright, planning seconds per replan)."""
    rng = np.random.default_rng(seed)
    model = cartpole(real_pole)
    data = mujoco.MjData(model)
    mujoco.mj_resetDataKeyframe(model, data, model.key("hanging").id)
    data.qpos[1] += rng.normal(0.0, 0.05)
    mujoco.mj_forward(model, data)
    plan = cartpole()                                    # the planner's model: the nominal 0.2 kg pole
    plan.opt.timestep = PLAN_DT
    plan_datas = [mujoco.MjData(plan) for _ in range(8)]
    steps = round(horizon / PLAN_DT)
    knot_t, step_t = np.linspace(0.0, horizon, KNOTS), np.arange(steps) * PLAN_DT
    nominal, t_nominal = np.zeros(KNOTS), 0.0
    state = np.empty(mujoco.mj_stateSize(model, FULL))
    upright, planning, angles = None, 0.0, []
    replans = round(seconds / REPLAN)
    for k in range(replans):
        nominal = np.interp(knot_t + (data.time - t_nominal), knot_t, nominal)    # shift the plan forward
        t_nominal = data.time
        noise = SCALES[np.arange(samples - 1) % len(SCALES)][:, None] * rng.standard_normal((samples - 1, KNOTS))
        candidates = np.vstack([nominal, np.clip(nominal + noise, -LIMIT, LIMIT)])
        ctrl = np.stack([np.interp(step_t, knot_t, c) for c in candidates])[..., None]
        mujoco.mj_getState(model, data, state, FULL)
        t0 = time.perf_counter()
        states, _ = rollout.rollout(plan, plan_datas, np.tile(state, (samples, 1)), ctrl)
        planning += time.perf_counter() - t0
        nominal = candidates[int(np.argmin(cost(states, ctrl)))]
        if shove and k == round(6.0 / REPLAN):
            data.qvel[1] += 3.0
        for _ in range(round(REPLAN / model.opt.timestep)):
            data.ctrl[0] = np.interp(data.time - t_nominal, knot_t, nominal)
            mujoco.mj_step(model, data)
        angle = abs((data.qpos[1] + np.pi) % (2 * np.pi) - np.pi)
        angles.append(angle)
        if upright is None and angle < 0.2:
            upright = data.time
    last = np.array(angles[-round(2.0 / REPLAN):])
    success = bool((last < 0.2).all() and abs(data.qpos[0]) < 1.45)
    return success, upright if upright is not None else float("nan"), planning / replans


def summary(results) -> str:
    ok = np.array([r[0] for r in results])
    up = np.array([r[1] for r in results if r[0]])
    up_text = f"median {np.median(up):.2f} s" if len(up) else "-"
    return (f"success {ok.mean():.2f} ({ok.sum()}/{len(ok)}), swing-up {up_text}, "
            f"planning {1000 * np.mean([r[2] for r in results]):.2f} ms per replan")


if __name__ == "__main__":
    starts = range(4) if FAST else range(20)
    print(f"MuJoCo {mujoco.__version__}; replan every {1000 * REPLAN:.0f} ms, {KNOTS} knots, noise scales "
          f"{SCALES.tolist()} N, planner timestep {1000 * PLAN_DT:.0f} ms; {len(starts)} starts per row")
    print("(1) number of samples, horizon 1 s")
    for n in ((4, 64) if FAST else (4, 8, 16, 32, 64, 128)):
        print(f"    {n:>4} samples: " + summary([episode(s, samples=n) for s in starts]))
    print("(2) horizon, 64 samples")
    for horizon in ((0.5, 1.0) if FAST else (0.25, 0.5, 1.0, 2.0)):
        print(f"    {horizon:>4} s: " + summary([episode(s, horizon=horizon) for s in starts]))
    print("(3) 64 samples, horizon 1 s, the planner always assumes a 0.2 kg pole")
    for pole in (0.3, 0.4):
        print(f"    real pole {pole} kg: " + summary([episode(s, real_pole=pole) for s in starts]))
    print("    nominal pole, shoved by 3 rad/s at t = 6 s: " + summary([episode(s, shove=True) for s in starts]))

Output:

MuJoCo 3.14.0; replan every 40 ms, 4 knots, noise scales [0.5, 2.0, 8.0] N, planner timestep 10 ms; 20 starts per row
(1) number of samples, horizon 1 s
       4 samples: success 0.00 (0/20), swing-up -, planning 0.90 ms per replan
       8 samples: success 0.10 (2/20), swing-up median 1.34 s, planning 0.87 ms per replan
      16 samples: success 0.15 (3/20), swing-up median 0.88 s, planning 1.17 ms per replan
      32 samples: success 0.45 (9/20), swing-up median 0.84 s, planning 1.70 ms per replan
      64 samples: success 0.90 (18/20), swing-up median 0.88 s, planning 2.76 ms per replan
     128 samples: success 1.00 (20/20), swing-up median 0.94 s, planning 5.11 ms per replan
(2) horizon, 64 samples
    0.25 s: success 0.00 (0/20), swing-up -, planning 1.16 ms per replan
     0.5 s: success 0.45 (9/20), swing-up median 0.64 s, planning 1.71 ms per replan
     1.0 s: success 0.90 (18/20), swing-up median 0.88 s, planning 2.76 ms per replan
     2.0 s: success 0.20 (4/20), swing-up median 0.84 s, planning 5.33 ms per replan
(3) 64 samples, horizon 1 s, the planner always assumes a 0.2 kg pole
    real pole 0.3 kg: success 1.00 (20/20), swing-up median 0.98 s, planning 2.70 ms per replan
    real pole 0.4 kg: success 0.95 (19/20), swing-up median 1.04 s, planning 2.76 ms per replan
    nominal pole, shoved by 3 rad/s at t = 6 s: success 0.65 (13/20), swing-up median 0.92 s, planning 2.77 ms per replan

2. Twins

Two double pendulums hang from one pivot, released from the same angles except for 10⁻⁹ rad in the blue one’s first joint. For about six seconds they move as one; then they part, and nothing afterwards resembles the other. The plot shows the gap on a log scale and a dashed line for the growth predicted by the largest Lyapunov exponent. The gap follows that line until it saturates near 1 rad, about eight decades later.

```lab chaos {“title”: “Twins, 1e-9 rad apart”, “height”: 300}


The exponent, measured by renormalizing the gap between two copies every 0.1 s for 100 s, is 2.65 per second: a gap grows tenfold every 0.87 s. So each factor of 1000 in the initial gap buys only about 2.6 s of agreement:

| Initial gap | 10⁻³ rad | 10⁻⁶ rad | 10⁻⁹ rad | 10⁻¹² rad |
|---|---|---|---|---|
| gap passes 0.1 rad, measured | 1.63 s | 3.87 s | 6.29 s | 9.90 s |
| predicted, ln(0.1/ε)/λ | 1.74 s | 4.35 s | 6.96 s | 9.57 s |

The integrator is an initial-condition error of its own. Against RK4 at 0.25 ms, RK4 at 2 ms departs by 0.1 rad after 5.14 s, Euler at 2 ms after 1.01 s. Over 60 s, Euler removes 34% of the energy, and implicitfast gives exactly the same numbers, because it treats only velocity-dependent forces implicitly and this pendulum has none. RK4 conserves energy to the printed precision. No single trajectory of a chaotic system can be reproduced for long, but statistics can be. Over 300 s, three RK4 runs, at two timesteps and with a 10⁻⁹ rad offset, agree on the mean tip height (−0.088 to −0.098 m) and on how often the second link flips over (59 to 63 times a minute). The energy-losing integrators give −0.3965 m and 5 flips a minute: a different motion, not a noisier one. A simulation of a chaotic system is validated by its statistics, and only after its energy is right. The integrators are Lesson 1.3's subject.

```python file=examples/showcase_chaos.py
"""Showcase: chaos in a double pendulum, and what a simulation of a chaotic system can promise.

INPUT   double_pendulum.xml released from (2.0, 0.5) rad, no damping
PROCESS (1) the largest Lyapunov exponent by Benettin's method: two copies 1e-9 rad apart, the separation in
            (q, qdot) renormalized every 0.1 s for 100 s, RK4 at 1 ms;
        (2) twins: the second copy offset by eps = 1e-3 ... 1e-12 rad in the first joint; the time at which the
            joint angles first differ by 0.1 rad, against ln(0.1 / eps) / lambda;
        (3) the integrator: Euler, implicitfast and RK4 at 2 ms and RK4 at 1 ms, each against RK4 at 0.25 ms:
            when it departs by 0.1 rad, and its energy drift over 60 s;
        (4) statistics: the mean height of the tip and how often the second link flips over, over 300 s,
            for the same integrators and offsets
OUTPUT  printed tables

Run:  python examples/showcase_chaos.py          (about 2 minutes)
"""

import math
import os

import mujoco
import numpy as np

from mjcourse import model_path

FAST = os.environ.get("MJC_FAST") == "1"
INTEGRATORS = {"Euler": mujoco.mjtIntegrator.mjINT_EULER, "implicitfast": mujoco.mjtIntegrator.mjINT_IMPLICITFAST,
               "RK4": mujoco.mjtIntegrator.mjINT_RK4}


def pendulum(integrator: str = "RK4", dt: float = 0.001, offset: float = 0.0) -> tuple[mujoco.MjModel, mujoco.MjData]:
    model = mujoco.MjModel.from_xml_path(str(model_path("double_pendulum")))
    model.opt.integrator, model.opt.timestep = INTEGRATORS[integrator], dt
    model.opt.enableflags |= mujoco.mjtEnableBit.mjENBL_ENERGY
    data = mujoco.MjData(model)
    mujoco.mj_resetDataKeyframe(model, data, model.key("release").id)
    data.qpos[0] += offset
    mujoco.mj_forward(model, data)
    return model, data


def energy(data) -> float:
    return float(data.energy[0] + data.energy[1])


def angles_at(model, data, times: np.ndarray) -> np.ndarray:
    """Joint angles at the given times (s, increasing), stepping the simulation."""
    out = np.empty((len(times), 2))
    for i, t in enumerate(times):
        while data.time < t - 1e-12:
            mujoco.mj_step(model, data)
        out[i] = data.qpos
    return out


def lyapunov(seconds: float, d0: float = 1e-9, every: float = 0.1) -> float:
    m1, a = pendulum()
    m2, b = pendulum(offset=d0)
    total, n = 0.0, round(seconds / every)
    for _ in range(n):
        for _ in range(round(every / m1.opt.timestep)):
            mujoco.mj_step(m1, a)
            mujoco.mj_step(m2, b)
        delta = np.r_[b.qpos - a.qpos, b.qvel - a.qvel]
        d = float(np.linalg.norm(delta))
        total += math.log(d / d0)
        b.qpos[:] = a.qpos + delta[:2] * d0 / d                  # renormalize along the grown direction
        b.qvel[:] = a.qvel + delta[2:] * d0 / d
    return total / (n * every)


def departure(reference: np.ndarray, other: np.ndarray, times: np.ndarray, threshold: float = 0.1) -> float:
    gap = np.abs(np.angle(np.exp(1j * (other - reference)))).max(axis=1)
    hit = np.flatnonzero(gap > threshold)
    return float(times[hit[0]]) if len(hit) else float("nan")


def statistics(integrator: str, dt: float, offset: float, seconds: float) -> tuple[float, float, float]:
    """(mean tip height above the pivot in m, flips of link 2 per minute, relative energy change)."""
    model, data = pendulum(integrator, dt, offset)
    e0, tip, heights, flips = energy(data), model.site("tip").id, [], 0
    rel = (data.qpos[1] + math.pi) % (2 * math.pi)
    for _ in range(round(seconds / dt)):
        mujoco.mj_step(model, data)
        heights.append(data.site_xpos[tip][2] - 1.0)
        now = (data.qpos[1] + math.pi) % (2 * math.pi)
        if abs(now - rel) > math.pi:                            # the relative angle wrapped past upside-down
            flips += 1
        rel = now
    return float(np.mean(heights)), flips / (seconds / 60), (energy(data) - e0) / abs(e0)


if __name__ == "__main__":
    print(f"MuJoCo {mujoco.__version__}; double pendulum released from (2.0, 0.5) rad")
    lam = lyapunov(20.0 if FAST else 100.0)
    print(f"(1) largest Lyapunov exponent (RK4, 1 ms): {lam:.2f} per second; a gap grows tenfold every {math.log(10) / lam:.2f} s")
    print("(2) twins offset by eps in the first joint: time until the joint angles differ by 0.1 rad")
    times = np.arange(1, round(30.0 / 0.001) + 1) * 0.001
    ref = angles_at(*pendulum(), times)
    for eps in (1e-3, 1e-6, 1e-9, 1e-12):
        t = departure(ref, angles_at(*pendulum(offset=eps), times), times)
        print(f"    eps {eps:.0e}: measured {t:5.2f} s, predicted ln(0.1/eps)/lambda {math.log(0.1 / eps) / lam:5.2f} s")
    print("(3) integrators against RK4 at 0.25 ms: departure by 0.1 rad, and energy change over 60 s")
    fine = angles_at(*pendulum("RK4", 0.00025), times)
    for name, dt in (("Euler", 0.002), ("implicitfast", 0.002), ("RK4", 0.002), ("RK4", 0.001)):
        t = departure(fine, angles_at(*pendulum(name, dt), times), times)
        model, data = pendulum(name, dt)
        e0 = energy(data)
        for _ in range(round(60.0 / dt)):
            mujoco.mj_step(model, data)
        print(f"    {name:<13} {1000 * dt:.0f} ms: departs at {t:5.2f} s; energy change after 60 s {100 * (energy(data) - e0) / abs(e0):+8.3f} %")
    seconds = 60.0 if FAST else 300.0
    print(f"(4) statistics over {seconds:.0f} s: mean tip height below the pivot, flips of the second link per minute")
    for name, dt, eps in (("RK4", 0.001, 0.0), ("RK4", 0.001, 1e-9), ("RK4", 0.002, 0.0), ("implicitfast", 0.002, 0.0), ("Euler", 0.002, 0.0)):
        mean_h, flips, de = statistics(name, dt, eps, seconds)
        print(f"    {name:<13} {1000 * dt:.0f} ms, eps {eps:.0e}: mean tip height {mean_h:+.4f} m, {flips:5.1f} flips per minute, "
              f"energy change {100 * de:+.3f} %")

Output:

MuJoCo 3.14.0; double pendulum released from (2.0, 0.5) rad
(1) largest Lyapunov exponent (RK4, 1 ms): 2.65 per second; a gap grows tenfold every 0.87 s
(2) twins offset by eps in the first joint: time until the joint angles differ by 0.1 rad
    eps 1e-03: measured  1.63 s, predicted ln(0.1/eps)/lambda  1.74 s
    eps 1e-06: measured  3.87 s, predicted ln(0.1/eps)/lambda  4.35 s
    eps 1e-09: measured  6.29 s, predicted ln(0.1/eps)/lambda  6.96 s
    eps 1e-12: measured  9.90 s, predicted ln(0.1/eps)/lambda  9.57 s
(3) integrators against RK4 at 0.25 ms: departure by 0.1 rad, and energy change over 60 s
    Euler         2 ms: departs at  1.01 s; energy change after 60 s  -34.004 %
    implicitfast  2 ms: departs at  1.01 s; energy change after 60 s  -34.004 %
    RK4           2 ms: departs at  5.14 s; energy change after 60 s   -0.000 %
    RK4           1 ms: departs at  6.27 s; energy change after 60 s   -0.000 %
(4) statistics over 300 s: mean tip height below the pivot, flips of the second link per minute
    RK4           1 ms, eps 0e+00: mean tip height -0.0875 m,  59.0 flips per minute, energy change -0.000 %
    RK4           1 ms, eps 1e-09: mean tip height -0.0977 m,  63.4 flips per minute, energy change -0.000 %
    RK4           2 ms, eps 0e+00: mean tip height -0.0900 m,  59.4 flips per minute, energy change -0.001 %
    implicitfast  2 ms, eps 0e+00: mean tip height -0.3965 m,   5.0 flips per minute, energy change -40.588 %
    Euler         2 ms, eps 0e+00: mean tip height -0.3965 m,   5.0 flips per minute, energy change -40.588 %

3. Newton’s cradle

Lift one ball, let it go, and one ball leaves the other end; lift two, and two leave. The toy is a hard test for a contact engine, because a soft contact that spreads an impact over time and over several balls at once produces something else entirely. The lab offers three contact models and three gaps between the balls; the velocities are read 30 ms after the first impact, as fractions of the incoming speed, starting from the lifted end.

```lab cradle {“title”: “Newton’s cradle”, “height”: 280}


A head-on collision of two balls at 1 m/s shows what each contact model does to a single impact: MuJoCo's default soft contact gives a restitution of 0.136, a lightly damped one 0.750, and a stiff spring set directly in `solref` (negative values are stiffness and damping) 1.006, an exchange of velocities. In the cradle, 30 ms after the first impact:

| Contact | Gap | Velocities, lifted end first | Energy left |
|---|---|---|---|
| default soft, solref (0.02, 1) | touching | 0.107, 0.219, 0.218, 0.216, 0.216 | 0.212 |
| less damping, solref (0.02, 0.1) | touching | −0.387, 0.291, 0.334, 0.362, 0.376 | 0.642 |
| stiff spring, solref (−1e7, −10) | touching | −0.588, 0.317, 0.381, 0.423, 0.443 | 1.013 |
| stiff spring | 0.1 mm | −0.260, −0.050, −0.018, 0.306, 0.998 | 1.211 |
| stiff spring | 1 mm | 0.001, 0.000, 0.000, 0.000, 0.972 | 0.977 |

The default contact turns the cradle into one lump: every ball moves off at about a fifth of the incoming speed, which is what a perfectly inelastic collision of five equal balls gives, and four fifths of the energy are gone. Stiff, elastic contacts keep the energy, but with the balls touching the impact still spreads along the chain and the first ball bounces back. Only with gaps, so that every collision is between two balls at a time, does the toy appear: one ball leaves at 0.972 of the incoming speed, and with two lifted, two leave at 0.973 and 0.972. The 0.1 mm gap is a warning: impacts that nearly overlap added 21% to the energy. For an analysis of the real ball chain, see Herrmann and Seitz (Am. J. Phys. 50, 977, 1982; doi:10.1119/1.12936). Contact parameters are Lesson 9.2's subject; 18.2 tried to identify them.

```python file=examples/showcase_newtons_cradle.py
"""Showcase: a Newton's cradle, and what it takes from a soft-contact engine to reproduce the toy.

INPUT   five 0.26 kg balls of radius 2 cm, each on a 0.2 m pendulum, in a row along x, frictionless contacts
        (condim 1); the first ball lifted by 30 degrees and released
PROCESS (1) two balls colliding head-on at 1 m/s: the restitution each contact setting gives;
        (2) the cradle with three contact settings and three gaps between neighbouring balls, timestep 0.1 ms:
            each ball's velocity 30 ms after the first impact, as a fraction of the incoming speed, and the
            mechanical energy (kinetic + gravitational) left, as a fraction of the release energy;
        (3) two balls lifted together, with the setting that works
OUTPUT  printed tables

Run:  python examples/showcase_newtons_cradle.py          (about 30 seconds)
"""

import math

import mujoco
import numpy as np

R, LENGTH, N, MASS = 0.02, 0.2, 5, 0.26
SETTINGS = {"default soft contact, solref (0.02, 1)": "0.02 1",
            "less damping, solref (0.02, 0.1)": "0.02 0.1",
            "stiff spring, solref (-1e7, -10)": "-1e7 -10"}


def cradle(solref: str, gap: float, dt: float = 1e-4) -> mujoco.MjModel:
    balls = "".join(f"""
    <body name="b{i}" pos="{i * (2 * R + gap):.6f} 0 0.3">
      <joint type="hinge" axis="0 1 0"/>
      <geom type="capsule" fromto="0 0 0 0 0 {-LENGTH + R}" size="0.0015" mass="0" contype="0" conaffinity="0"/>
      <geom name="g{i}" type="sphere" pos="0 0 {-LENGTH}" size="{R}" mass="{MASS}" condim="1" solref="{solref}"/>
    </body>""" for i in range(N))
    return mujoco.MjModel.from_xml_string(f"""<mujoco><option timestep="{dt}"><flag energy="enable"/></option>
  <worldbody>{balls}</worldbody></mujoco>""")


def energy(data) -> float:
    return float(data.energy[0] + data.energy[1])


def release(model, lifted: int = 1, angle: float = 30.0, after: float = 0.03) -> tuple[np.ndarray, float]:
    """Velocities along +x (fraction of the incoming speed) `after` s past the first impact, and energy left."""
    data = mujoco.MjData(model)
    data.qpos[:lifted] = math.radians(angle)           # a positive hinge angle swings the bob toward -x
    mujoco.mj_forward(model, data)
    rest = mujoco.MjData(model)
    mujoco.mj_forward(model, rest)
    e_rest = energy(rest)
    e0 = energy(data) - e_rest
    incoming = math.sqrt(2 * 9.81 * LENGTH * (1 - math.cos(math.radians(angle))))
    first, second = model.geom(f"g{lifted - 1}").id, model.geom(f"g{lifted}").id
    hit = None
    while hit is None or data.time < hit + after:
        mujoco.mj_step(model, data)
        if hit is None and any({data.contact[i].geom1, data.contact[i].geom2} == {first, second} for i in range(data.ncon)):
            hit = data.time
        if not np.isfinite(data.qvel).all() or data.time > 2.0:
            return np.full(N, np.nan), float("nan")
    return -data.qvel * LENGTH / incoming, (energy(data) - e_rest) / e0


def head_on(solref: str, dt: float = 1e-4) -> tuple[float, float]:
    model = mujoco.MjModel.from_xml_string(f"""<mujoco><option timestep="{dt}" gravity="0 0 0"/><worldbody>
    <body><joint type="slide" axis="1 0 0"/><geom type="sphere" size="{R}" mass="{MASS}" condim="1" solref="{solref}"/></body>
    <body pos="0.05 0 0"><joint type="slide" axis="1 0 0"/><geom type="sphere" size="{R}" mass="{MASS}" condim="1" solref="{solref}"/></body>
    </worldbody></mujoco>""")
    data = mujoco.MjData(model)
    data.qvel[0] = 1.0
    for _ in range(round(0.05 / dt)):
        mujoco.mj_step(model, data)
    return float(data.qvel[0]), float(data.qvel[1])


if __name__ == "__main__":
    print(f"MuJoCo {mujoco.__version__}; balls of {MASS} kg, radius {100 * R:.0f} cm, on {LENGTH} m pendulums; timestep 0.1 ms")
    print("(1) two balls head-on at 1 m/s: velocities after the collision, and the restitution")
    for label, solref in SETTINGS.items():
        v0, v1 = head_on(solref)
        print(f"    {label:<40} {v0:+.3f} and {v1:+.3f} m/s, restitution {v1 - v0:.3f}")
    print("(2) the cradle, first ball lifted 30 deg: velocities 30 ms after the first impact (fraction of the incoming"
          " speed, along +x) and mechanical energy left")
    for label, solref in SETTINGS.items():
        for gap in (0.0, 0.0001, 0.001):
            v, e = release(cradle(solref, gap))
            print(f"    {label:<40} gap {1000 * gap:3.1f} mm: " + " ".join(f"{x:+.3f}" for x in v) + f"   energy {e:.3f}")
    print("(3) two balls lifted together, stiff spring, gap 1.0 mm")
    v, e = release(cradle(SETTINGS["stiff spring, solref (-1e7, -10)"], 0.001), lifted=2)
    print("    " + " ".join(f"{x:+.3f}" for x in v) + f"   energy {e:.3f}")

Output:

MuJoCo 3.14.0; balls of 0.26 kg, radius 2 cm, on 0.2 m pendulums; timestep 0.1 ms
(1) two balls head-on at 1 m/s: velocities after the collision, and the restitution
    default soft contact, solref (0.02, 1)   +0.432 and +0.568 m/s, restitution 0.136
    less damping, solref (0.02, 0.1)         +0.125 and +0.875 m/s, restitution 0.750
    stiff spring, solref (-1e7, -10)         -0.003 and +1.003 m/s, restitution 1.006
(2) the cradle, first ball lifted 30 deg: velocities 30 ms after the first impact (fraction of the incoming speed, along +x) and mechanical energy left
    default soft contact, solref (0.02, 1)   gap 0.0 mm: +0.107 +0.219 +0.218 +0.216 +0.216   energy 0.212
    default soft contact, solref (0.02, 1)   gap 0.1 mm: +0.098 +0.211 +0.220 +0.223 +0.223   energy 0.215
    default soft contact, solref (0.02, 1)   gap 1.0 mm: +0.160 +0.271 +0.279 +0.228 +0.037   energy 0.250
    less damping, solref (0.02, 0.1)         gap 0.0 mm: -0.387 +0.291 +0.334 +0.362 +0.376   energy 0.642
    less damping, solref (0.02, 0.1)         gap 0.1 mm: -0.415 +0.116 +0.256 +0.424 +0.594   energy 0.810
    less damping, solref (0.02, 0.1)         gap 1.0 mm: -0.049 +0.051 +0.075 +0.208 +0.689   energy 0.542
    stiff spring, solref (-1e7, -10)         gap 0.0 mm: -0.588 +0.317 +0.381 +0.423 +0.443   energy 1.013
    stiff spring, solref (-1e7, -10)         gap 0.1 mm: -0.260 -0.050 -0.018 +0.306 +0.998   energy 1.211
    stiff spring, solref (-1e7, -10)         gap 1.0 mm: +0.001 +0.000 +0.000 +0.000 +0.972   energy 0.977
(3) two balls lifted together, stiff spring, gap 1.0 mm
    +0.001 +0.001 +0.001 +0.973 +0.972   energy 0.983

4. A robot that writes

The 7-joint arm of Level 5 writes “MuJoCo” in 6 cm letters on a board in front of it, lifting the pen 3 cm between strokes. Each stroke segment is a minimum-jerk move; the ink is the tool’s real path while the pen is down. Write the word with operational-space control, then with the Jacobian-transpose controller, on the same reference.

```lab write {“title”: “A robot that writes”, “height”: 320}


| Average speed | Operational space: RMS, largest | Jacobian transpose: RMS, largest | Time to write |
|---|---|---|---|
| 5 cm/s | 0.03 mm, 0.08 mm | 3.09 mm, 10.52 mm | 25.3 s |
| 10 cm/s | 0.07 mm, 0.17 mm | 8.71 mm, 34.53 mm | 13.3 s |
| 20 cm/s | 0.13 mm, 0.43 mm | 12.93 mm, 33.70 mm | 8.6 s |
| 40 cm/s | 0.39 mm, 1.98 mm | 14.42 mm, 42.43 mm | 7.2 s |

Operational-space control uses the arm's mass matrix to turn the reference acceleration into joint torques, so at every speed the letters come out within a fraction of a millimetre. The Jacobian-transpose controller has the same gravity compensation and a stiff task-space spring, but no model of the arm's inertia: at 10 cm/s its pen strays up to 3.5 cm, more than half a letter. Both finish in the same time, because both follow the same reference; the faster rows are limited by the minimum durations given to short strokes and pen lifts. Lesson 8.3 derives both controllers; Lesson 6.2 the Jacobian they share.

```python file=examples/showcase_robot_writes.py
"""Showcase: arm7 writes "MuJoCo" on a vertical board under operational-space control (Lessons 6 and 8.3).

INPUT   arm7.xml from its home keyframe; the tool site "ee" writes on a virtual board, the plane 3 cm beyond
        the home position along x; letters 6 cm tall from a stroke font, read facing the robot
PROCESS each stroke segment is a minimum-jerk move at a set average speed; the pen lifts 3 cm off the board
        between strokes. Two controllers track the reference: operational space (bandwidth 30 rad/s,
        null-space posture, mjcourse.control.operational_space) and Jacobian transpose (Kp 400 N/m, Kd 40 N s/m,
        gravity compensated, as in Lesson 8.3). For average writing speeds of 5, 10, 20 and 40 cm/s:
        the RMS and largest distance between the tool and its reference while the pen is down, and the time
        to write the word
OUTPUT  printed table

Run:  python examples/showcase_robot_writes.py          (about 1 minute)
"""

import math
import os

import mujoco
import numpy as np

from mjcourse import control, model_path

FAST = os.environ.get("MJC_FAST") == "1"
HEIGHT, LIFT, WN, K_NULL = 0.06, 0.03, 30.0, 20.0


def circle(cx, cy, r, a0, a1, n):
    return [(cx + r * math.cos(a), cy + r * math.sin(a)) for a in np.linspace(math.radians(a0), math.radians(a1), n)]


# Strokes in a letter box of width 1 and height 1.5 (cap height), baseline at 0.
FONT = {
    "M": [[(0, 0), (0, 1.5), (0.5, 0.6), (1, 1.5), (1, 0)]],
    "u": [[(0, 1), (0, 0.3), (0.3, 0), (0.7, 0), (1, 0.3)], [(1, 1), (1, 0)]],
    "J": [[(0.3, 1.5), (1, 1.5)], [(0.7, 1.5), (0.7, 0.35), (0.45, 0), (0.15, 0), (0, 0.3)]],
    "o": [circle(0.5, 0.5, 0.5, 90, 450, 17)],
    "C": [circle(0.75, 0.75, 0.75, 45, 315, 17)],
}
ADVANCE = {"M": 1.4, "u": 1.35, "J": 1.3, "o": 1.3, "C": 1.35}


def strokes(word: str, origin: np.ndarray) -> list[np.ndarray]:
    """3D strokes on the board: letter x -> world +y (readable facing the robot), letter y -> world z."""
    scale, out, cursor = HEIGHT / 1.5, [], 0.0
    width = sum(ADVANCE[c] for c in word) * scale
    for c in word:
        for stroke in FONT[c]:
            pts = [origin + [0.0, (cursor + u) * scale - width / 2, (v - 0.75) * scale] for u, v in stroke]
            out.append(np.array(pts))
        cursor += ADVANCE[c]
    return out


def min_jerk(a: np.ndarray, b: np.ndarray, duration: float, h: float):
    """Samples (position, velocity, acceleration, pen) of a minimum-jerk move from a to b."""
    n = max(round(duration / h), 1)
    for k in range(n):
        s = k / n
        p = 10 * s**3 - 15 * s**4 + 6 * s**5
        dp = (30 * s**2 - 60 * s**3 + 30 * s**4) / duration
        ddp = (60 * s - 180 * s**2 + 120 * s**3) / duration**2
        yield a + (b - a) * p, (b - a) * dp, (b - a) * ddp


def reference(word: str, start: np.ndarray, board_x: float, speed: float, h: float):
    """The full reference: pen-up travel at twice the writing speed, then each stroke on the board."""
    pos, out = start.copy(), []
    for stroke in strokes(word, np.array([board_x, start[1], start[2]])):
        lifted = stroke[0] - [LIFT, 0.0, 0.0]
        for a, b, down in ((pos, lifted, False), (lifted, stroke[0], False)):
            out += [(x, v, acc, down) for x, v, acc in min_jerk(a, b, max(np.linalg.norm(b - a) / (2 * speed), 0.15), h)]
        for a, b in zip(stroke[:-1], stroke[1:]):
            out += [(x, v, acc, True) for x, v, acc in min_jerk(a, b, max(np.linalg.norm(b - a) / speed, 0.05), h)]
        pos = stroke[-1] - [LIFT, 0.0, 0.0]
        out += [(x, v, acc, False) for x, v, acc in min_jerk(stroke[-1], pos, 0.15, h)]
    return out


def write(kind: str, speed: float) -> tuple[float, float, float]:
    """(RMS and largest pen-down error in mm, seconds to write)."""
    model = mujoco.MjModel.from_xml_path(str(model_path("arm7")))
    data = mujoco.MjData(model)
    home = model.key_qpos[model.key("home").id].copy()
    data.qpos[:] = home
    mujoco.mj_forward(model, data)
    sid, h = model.site("ee").id, model.opt.timestep
    start = data.site_xpos[sid].copy()
    errors = []
    for x_d, v_d, a_d, down in reference("MuJoCo", start, start[0] + LIFT, speed, h):
        mujoco.mj_forward(model, data)
        if kind == "operational space":
            tau = control.operational_space(model, data, "ee", x_d, v_d, WN**2, 2 * WN, xdd_des=a_d,
                                            q_rest=home, k_null=K_NULL)
        else:
            jac = np.zeros((3, model.nv))
            mujoco.mj_jacSite(model, data, jac, None, sid)
            e, edot = x_d - data.site_xpos[sid], v_d - jac @ data.qvel
            tau = jac.T @ (400.0 * e + 40.0 * edot) + data.qfrc_bias + model.dof_damping * data.qvel
        data.ctrl[:] = np.clip(tau, *model.actuator_ctrlrange.T)
        mujoco.mj_step(model, data)
        if down:
            errors.append(np.linalg.norm(data.site_xpos[sid] - x_d))
    errors = 1000 * np.array(errors)
    return float(np.sqrt(np.mean(errors**2))), float(errors.max()), data.time


if __name__ == "__main__":
    print(f"MuJoCo {mujoco.__version__}; arm7 writes \"MuJoCo\", letters {100 * HEIGHT:.0f} cm tall, pen lifted {100 * LIFT:.0f} cm "
          "between strokes")
    print(f"  {'average speed':>14}  {'controller':<20}{'pen-down RMS error':>20}{'largest':>10}{'time to write':>15}")
    for speed in ((0.1,) if FAST else (0.05, 0.1, 0.2, 0.4)):
        for kind in ("operational space", "Jacobian transpose"):
            rms, peak, seconds = write(kind, speed)
            print(f"  {100 * speed:>9.0f} cm/s  {kind:<20}{rms:>17.2f} mm{peak:>7.2f} mm{seconds:>13.1f} s")

Output:

MuJoCo 3.14.0; arm7 writes "MuJoCo", letters 6 cm tall, pen lifted 3 cm between strokes
   average speed  controller            pen-down RMS error   largest  time to write
          5 cm/s  operational space                0.03 mm   0.08 mm         25.3 s
          5 cm/s  Jacobian transpose               3.09 mm  10.52 mm         25.3 s
         10 cm/s  operational space                0.07 mm   0.17 mm         13.3 s
         10 cm/s  Jacobian transpose               8.71 mm  34.53 mm         13.3 s
         20 cm/s  operational space                0.13 mm   0.43 mm          8.6 s
         20 cm/s  Jacobian transpose              12.93 mm  33.70 mm          8.6 s
         40 cm/s  operational space                0.39 mm   1.98 mm          7.2 s
         40 cm/s  Jacobian transpose              14.42 mm  42.43 mm          7.2 s

5. Cloth over a ball

A square of cloth falls onto a ball. MuJoCo simulates it as a flex: vertices with a radius, triangles between them, and edges held at their length by equality constraints. Drop a small cloth and it settles over the ball; drop a large one and the ball shows through. The course viewer draws geoms, not flexes, so this lab draws the cloth itself from the flex’s triangles and vertex positions.

```lab cloth {“title”: “Cloth over a ball”, “height”: 300}


After 3 s:

| Cloth | Side | Contacts: floor, ball | Vertices inside the floor or ball | Deepest: floor, ball |
|---|---|---|---|---|
| 6 × 6 | 0.15 m | 0, 40 | 0 | 0.0, 0.0 mm |
| 8 × 8 | 0.21 m | 0, 50 | 1 | 0.0, 0.1 mm |
| 10 × 10 | 0.27 m | 0, 50 | 3 | 0.0, 0.5 mm |
| 14 × 14 | 0.39 m | 29, 21 | 67 | 6.2, 6.3 mm |
| 20 × 20 | 0.57 m | 43, 7 | 257 | 26.5, 23.2 mm |

From 8 × 8 up, the cloth never has more than 50 contacts, however many vertices touch something: 67 of 196, then 257 of 400, are inside the floor or the ball, and the deepest is 26.5 mm into the floor. MuJoCo caps the contacts of one collision pair at `mjMAXCONPAIR`, which is 50 (`mujoco.mjMAXCONPAIR` in Python). The table shows the cap applying to the whole cloth, floor and ball together, and two separate cloths on the floor get 50 each (inference from the counts: the cap is per flex). Nothing warns; the cloth simply sinks. A cloth that must rest on things needs to stay under the cap, with fewer vertices in contact (the 6 × 6 cloth needed 40) or split into several flexes, each with its own 50. Contacts and their limits are Lesson 9.1's subject.

```python file=examples/showcase_cloth.py
"""Showcase: a cloth dropped over a ball, and a contact limit that decides whether it stays on top.

INPUT   a square flexcomp cloth (dim 2, edges held by equality constraints, vertex radius 6 mm, 0.1 kg),
        0.03 m between vertices, released 0.3 m above a floor with a ball of radius 8 cm on it
PROCESS (1) cloths of 6 x 6 to 20 x 20 vertices: after 3 s, the contacts by geom, the vertices touching or
            inside the floor or the ball (closer than their radius), the deepest penetration, and the cost
            of a step;
        (2) two separate 14 x 14 cloths on the floor: the contacts of each
OUTPUT  printed tables; mujoco.mjMAXCONPAIR

Run:  python examples/showcase_cloth.py          (about 30 seconds)
"""

import collections
import time

import mujoco
import numpy as np

R_VERT, R_BALL, SPACING = 0.006, 0.08, 0.03


def cloth(name: str, n: int, x: float = 0.0) -> str:
    return f"""
      <flexcomp name="{name}" type="grid" count="{n} {n} 1" spacing="{SPACING} {SPACING} {SPACING}" pos="{x} 0 0.3"
                radius="{R_VERT}" dim="2" mass="0.1">
        <contact condim="3" solref="0.01 1" selfcollide="none"/>
        <edge equality="true" damping="0.001"/>
      </flexcomp>"""


def scene(cloths: str, ball: bool = True) -> mujoco.MjModel:
    sphere = f'<geom name="ball" type="sphere" size="{R_BALL}" pos="0 0 {R_BALL}"/>' if ball else ""
    return mujoco.MjModel.from_xml_string(f"""<mujoco>
  <option timestep="0.002" solver="CG" tolerance="1e-6" integrator="implicitfast"/>
  <worldbody><geom name="floor" type="plane" size="2 2 0.1"/>{sphere}{cloths}</worldbody></mujoco>""")


def settle(model, seconds: float = 3.0) -> tuple[mujoco.MjData, float]:
    data = mujoco.MjData(model)
    t0 = time.perf_counter()
    for _ in range(round(seconds / model.opt.timestep)):
        mujoco.mj_step(model, data)
    return data, (time.perf_counter() - t0) / round(seconds / model.opt.timestep)


if __name__ == "__main__":
    print(f"MuJoCo {mujoco.__version__}; mujoco.mjMAXCONPAIR = {mujoco.mjMAXCONPAIR}")
    print("(1) one cloth over the ball, after 3 s")
    print(f"    {'vertices':>9} {'side':>7}   {'contacts: floor, ball':>22}   {'vertices touching or inside':>28}   "
          f"{'deepest into floor, ball':>25}   {'step':>8}")
    for n in (6, 8, 10, 14, 20):
        model = scene(cloth("cloth", n))
        data, per = settle(model)
        ball = model.geom("ball").id
        by = collections.Counter("ball" if ball in (int(data.contact[i].geom[0]), int(data.contact[i].geom[1])) else "floor"
                                 for i in range(data.ncon))
        v = data.flexvert_xpos
        into_floor = R_VERT - v[:, 2]
        into_ball = R_BALL + R_VERT - np.linalg.norm(v - [0.0, 0.0, R_BALL], axis=1)
        touching = int(np.sum((into_floor > 0) | (into_ball > 0)))
        print(f"    {n * n:>9} {SPACING * (n - 1):>6.2f}m   {by['floor']:>12}, {by['ball']:>3}   {touching:>28}   "
              f"{1000 * max(into_floor.max(), 0):>14.1f} mm, {1000 * max(into_ball.max(), 0):.1f} mm   {1000 * per:>5.2f} ms")
    print("(2) two separate 14 x 14 cloths on the floor, no ball")
    model = scene(cloth("a", 14, -0.4) + cloth("b", 14, 0.4), ball=False)
    data, _ = settle(model)
    by_flex = collections.Counter(int(data.contact[i].flex[1]) for i in range(data.ncon))
    print(f"    contacts: {data.ncon} in total; cloth a {by_flex[0]}, cloth b {by_flex[1]}")

Output:

MuJoCo 3.14.0; mujoco.mjMAXCONPAIR = 50
(1) one cloth over the ball, after 3 s
     vertices    side    contacts: floor, ball    vertices touching or inside    deepest into floor, ball       step
           36   0.15m              0,  40                              0              0.0 mm, 0.0 mm    0.13 ms
           64   0.21m              0,  50                              1              0.0 mm, 0.1 mm    0.39 ms
          100   0.27m              0,  50                              3              0.0 mm, 0.5 mm    0.36 ms
          196   0.39m             29,  21                             67              6.2 mm, 6.3 mm    0.62 ms
          400   0.57m             43,   7                            257             26.5 mm, 23.2 mm    1.24 ms
(2) two separate 14 x 14 cloths on the floor, no ball
    contacts: 100 in total; cloth a 50, cloth b 50