The AY-Robots SO-100 hub page, the starting point for setup, data collection and imitation learning on the arm
MuJoCoSO-100SimulationSim2RealMJCFLeRobot

MuJoCo for SO-100 Simulation: Building the Model, Reading the Gap

AY-Robots ResearchAugust 23, 202621 min read

Which SO-100 MJCF to start from, why the two published models disagree by 20x on gain, how their torque limits came from the 12 V datasheet, and where a sim policy stops.

An SO-100 costs roughly 110 to 150 EUR in parts, and every one of those parts is a 3D print held together by M2 screws. That is the first reason to build a MuJoCo model of it: a simulator does not snap a shoulder bracket when your controller overshoots. The second is that some questions are faster to ask in a simulator. Can the wrist reach that corner of the table with the gripper pointing down? What happens if the teleoperation stream drops for 200 ms? Which joint sags first under a 150 g payload?

This page is about the specific case: putting an SO-100 or SO-101 into MuJoCo. Which published MJCF to start from, what those files actually claim about the Feetech STS3215 servos, where those numbers came from and where they are wrong, and the point at which a simulated policy stops helping with the kind of LeRobot workflow this platform runs. Every file quoted below was opened on 24 August 2026, and the sag numbers further down are measurements taken from the published model, not estimates.

What you need to know

  • The vendor repo ships no SO-100 MJCF, only a URDF. The SO-100 MJCF lives in MuJoCo Menagerie as trs_so_arm100. TheRobotStudio ships MJCF only for the SO-101.
  • The published models disagree hard. Joint armature is 0.1 against 0.028, position gain 50 against 998.22. A third value, kp 17.8, sits in a sidecar file in the same repo as the 998.22.
  • Both are too strong. Their forcerange values (2.94, 3.35 and 3.5 N.m) match the 12 V STS3215, rated 30 kg.cm stall. These arms use the 7.4 V part: 19.5 kg.cm stall and 5 kg.cm rated, or 1.91 and 0.49 N.m.
  • Backlash is declared and never wired up. so101_new_calib.xml defines a backlash class of plus or minus 0.5 degrees and no joint references it.
  • The Menagerie joint limits follow the SO-101 old calibration (zero at full horizontal extension), not the new one (zero at mid range). That decides which recorded angles replay correctly.
  • Simulation does not produce training data for the policies here. All five train on recorded episodes with real camera frames.

Two published MJCF models, and they do not agree

There is no single canonical file. TheRobotStudio's SO-ARM100 repository has a Simulation folder with two subdirectories. Simulation/SO100 contains so100.urdf, a rerun file and 13 STL meshes, and no MJCF at all. Simulation/SO101 has the real thing: so101_new_calib.xml, so101_old_calib.xml, matching URDFs, a scene.xml and a small joints_properties.xml. If you own an SO-100 rather than an SO-101, the MJCF you want is in MuJoCo Menagerie under trs_so_arm100, contributed separately and derived from an older SO-100 URDF.

The two calibrations in the vendor repo matter before you load anything. so101_new_calib.xml puts each joint's virtual zero at the middle of its range. so101_old_calib.xml puts zero where the arm is fully extended horizontally. Same arm, same degrees of freedom, offset joint coordinates. Replay recorded angles into the wrong one and the arm moves smoothly and is wrong everywhere, which is much harder to notice than a crash.

Which file, and which zero it assumes

SO-100 owners: mujoco_menagerie/trs_so_arm100/scene.xml. SO-101 owners: SO-ARM100/Simulation/SO101/scene.xml, which includes so101_new_calib.xml. Worth knowing before you replay anything: the Menagerie joint limits match the SO-101 old calibration, not the new one. Its Pitch range is -3.32 to 0.174 rad against old-calib shoulder_lift at -3.316 to 0.1745, while new-calib shoulder_lift is a symmetric -1.745 to 1.745. The joint names differ too, so a recorded LeRobot dataset does not map onto the Menagerie model without a rename. Keep that mapping in one dict next to your calibration file, not scattered through your code.

AttributeMenagerie trs_so_arm100 (SO-100)SO-ARM100 Simulation/SO101 (SO-101)
Originhand-edited from the SO-ARM100 URDF, Apache-2.0onshape-to-robot export from Onshape CAD
Version statedVersion 1.3, requires MuJoCo 3.1.6 or laterno version string in the file
Last model change2025-06-09, joint limits matched to the real robotnot dated in the repo
joint armature0.10.028
joint frictionloss0.10.052
joint dampingnot set, so MuJoCo's default 00.60
Actuatorposition, kp=50, dampratio=1position, kp=998.22, kv=2.731
forcerange-3.5 to 3.5 N.m-2.94 to 2.94 in the class, overridden to -3.35 to 3.35 per actuator
ctrlrangeinheritrange="1", so it copies the joint rangeexplicit per actuator
Friction coneelliptic, impratio=10not set, so pyramidal, impratio=1
Integratornot set, so Eulernot set, so Euler
Keyframeshome and restnone
Joint namesRotation, Pitch, Elbow, Wrist_Pitch, Wrist_Roll, Jawshoulder_pan, shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper
Moving-link mass0.609 kg0.485 kg plus a 0.147 kg base

Get the model on screen in about five minutes

  1. 1
    Install the bindings

    MuJoCo ships as a self-contained Python wheel. The current release is 3.12.0, published 20 August 2026. The Menagerie SO-100 model declares a floor of 3.1.6.

    bash
    python -m venv .venv && source .venv/bin/activate
    pip install mujoco
    python -c "import mujoco; print(mujoco.__version__)"
  2. 2
    Fetch the model

    Menagerie is large and you need one directory out of it. A sparse clone keeps it to a few megabytes.

    bash
    git clone --depth 1 --filter=blob:none --sparse \
        https://github.com/google-deepmind/mujoco_menagerie.git
    cd mujoco_menagerie && git sparse-checkout set trs_so_arm100
    ls trs_so_arm100
    # assets  CHANGELOG.md  LICENSE  README.md  scene.xml  so_arm100.png  so_arm100.xml
  3. 3
    Look at it

    Load scene.xml, not so_arm100.xml. The scene adds the ground plane, skybox and light; without a floor geom the arm has nothing to push against.

    bash
    python -m mujoco.viewer --mjcf=trs_so_arm100/scene.xml
  4. 4
    Step it from Python

    The managed viewer blocks. For anything scripted you want mj_step in your own loop, and on macOS viewer.launch_passive must run under the mjpython launcher that the wheel installs.

    bash
    python drive_arm.py            # Linux / Windows
    mjpython drive_arm.py          # macOS, only for launch_passive
python
import mujoco
import numpy as np

m = mujoco.MjModel.from_xml_path("trs_so_arm100/scene.xml")
d = mujoco.MjData(m)

print("timestep", m.opt.timestep)   # 0.002 s -> 500 Hz, MuJoCo's default
print("nq", m.nq, "nu", m.nu)       # 6 joint coordinates, 6 position actuators
print("integrator", m.opt.integrator)   # 0 = Euler, the default

# keyframe 0 is "home", keyframe 1 is "rest"
mujoco.mj_resetDataKeyframe(m, d, 0)
d.ctrl[:] = d.qpos[:m.nu]           # command the pose we are already in

for _ in range(500):                # 500 * 0.002 s = 1.0 s of simulated time
    mujoco.mj_step(m, d)

print(np.degrees(d.qpos[:m.nu]).round(2))
# [  0.   -89.53  90.27  89.95 -89.95   0.  ]
# commanded -90 at the shoulder, settled at -89.53: half a degree of droop
print("actuator torque", d.actuator_force.round(3))
# [-0.  -0.37  -0.277  0.  0.  -0.]
drive_arm.py - load the Menagerie SO-100 and hold the home keyframe for one simulated second. Output comments are the real values from a run on MuJoCo 3.3.4.
The AY-Robots tutorial page for recording a first LeRobot dataset from a teleoperation session
The recording tutorial. Everything the simulator cannot answer - real calibration, real episodes, real camera frames - comes from here.

What MuJoCo gets right about this arm

MuJoCo stands for Multi-Joint dynamics with Contact. It simulates in generalized coordinates with a convex, optimization-based contact model, which is a good match for a six-joint serial arm. The parts of the SO-100 that really are rigid bodies on hinges simulate well. The parts that are a plastic gearbox with a P controller inside do not. Sorting your questions into those two buckets is most of the skill.

  • Reachability and workspace: where the end effector can go and in which orientations.
  • Joint limits. The 2025-06-09 Menagerie changelog entry reads "adjusted joint limits to match real robot's physical constraints". A trajectory that clips a limit in sim clips it on the bench too, the same class of problem as a joint that stops early.
  • Static gravity load: which pose sags, and roughly what torque each joint holds. Link masses in both models come from CAD.
  • Controller stability: whether your gains ring, whether 30 Hz is enough, whether a setpoint step saturates the actuator. You can watch actuator_force clip against forcerange directly.
  • Timing arithmetic. At the default 0.002 s timestep, one second is 500 physics steps, so a 30 Hz command loop is one setpoint every 16.7 steps. Getting that ratio wrong is how "it works in sim" turns out to mean "it works at 500 Hz".
  • Contact debugging. The bundled simulate utility draws contact points and force vectors, the fastest way to see why a grasp slips.

What it gets wrong about these servos

The torque limits are the 12 V number

This one will quietly invalidate an afternoon. The sts3215 default class in so101_new_calib.xml sets forcerange="-2.94 2.94". A kilogram-force centimetre is 0.0980665 N.m, so 30 kg.cm is 2.942 N.m. Seeed sells exactly such a part: the ST3215-C047, a 12 V servo with a 1:345 gearbox and 30 kg.cm stall torque. The arms here use the 7.4 V ST3215-C001, same 1:345 gearbox, rated 19.5 kg.cm stall and 5 kg.cm continuous. The match to 2.94 is exact, so the inference is hard to avoid, though no one in either repo states the provenance outright.

FigureAs quotedIn N.mSource
ST3215-C001 stall torque, 7.4 V, 1:34519.5 kg.cm1.91Seeed product page for the part these arms use
ST3215-C001 rated (continuous) torque5 kg.cm0.49same page
ST3215-C047 stall torque, 12 V, 1:34530 kg.cm2.94Seeed product page for the 12 V variant
sts3215 class forcerange, SO-101 MJCF2.94 N.m2.94SO-ARM100 repo
Per-actuator forcerange override, SO-1013.35 N.m3.35 (34.2 kg.cm)SO-ARM100 repo
Menagerie so_arm100 forcerange3.5 N.m3.5 (35.7 kg.cm)mujoco_menagerie

So the simulated arm is allowed 1.5 to 1.8 times the peak torque the real 7.4 V arm has, and six to seven times the torque it can hold without cooking. Stall torque is not an operating point; it is where the rotor stops and the winding heats. A policy that lifts by leaning on peak shoulder torque will lift once on the bench, then sag, then trip overload protection. That looks exactly like an arm that twitches and then sags, and people blame the power supply first.

What the inflated limit actually costs you

The gap is easy to overstate, so it is worth measuring. Loading the Menagerie model, commanding a reaching pose (shoulder at -68.8 degrees, elbow and wrist at 34.4) and letting it settle for four simulated seconds gives the numbers below. Rerunning with forcerange clamped to each candidate limit isolates what the wrong datasheet changes.

forcerangePayload in the jawShoulder steady-state errorPeak shoulder torque
3.5 N.m, as shippednone0.77 deg0.67 N.m
3.5 N.m, as shipped150 g1.27 deg1.11 N.m
1.91 N.m, real 7.4 V stallnone0.78 deg0.68 N.m
1.91 N.m, real 7.4 V stall150 g1.30 deg1.13 N.m
0.49 N.m, real 7.4 V ratednone44.5 deg, the joint collapses0.49 N.m, clipped
Read that table carefully

Clamping to the real stall torque changes almost nothing here, because holding this pose only needs 0.67 N.m. The shipped 3.5 N.m is not distorting this particular answer. What the table does show is that the pose needs more than the 0.49 N.m the servo is rated to hold continuously, and sits well under the 1.91 N.m it can produce for a moment. That is the regime the real arm lives in: fine for a few seconds, warm after a minute. The inflated forcerange bites only when a trajectory leans on peak torque, and then it bites hard, because sim hands out 3.5 N.m forever.

7.4 V servos, 7.4 V supply

The SO-100 and SO-101 use Feetech STS3215 bus servos on a 7.4 V rail. Feeding those servos 12 V destroys them. The confusion is easy precisely because a 12 V part carrying the same STS3215 name and the same 1:345 gearbox exists, and it is the source of the 30 kg.cm figure that ended up in the MJCF. Check the label on the servo, not the number in the XML. If a joint has already stopped answering on the bus, start at servo not responding.

Backlash is declared and never wired up

so101_new_calib.xml contains a default class named backlash, commented as plus or minus 0.5 degrees, with a joint range of plus or minus 0.008726646 rad. Grep the file for uses and there are none: the string class="backlash" appears once, in the definition itself. Not one joint, not one geom references it. Somebody started following MuJoCo's documented backlash recipe and stopped. The recipe itself is short.

xml
<body>
  <!-- driven joint: the actuator targets this one -->
  <joint name="elbow_flex" type="hinge" pos="0 0 0" axis="0 0 1" armature="0.028"/>
  <!-- backlash joint: free within a small range, no actuator -->
  <joint name="elbow_flex_lash" type="hinge" pos="0 0 0" axis="0 0 1"
         class="backlash"/>
  ...
</body>

<actuator>
  <position class="sts3215" name="elbow_flex" joint="elbow_flex"
            forcerange="-3.35 3.35" ctrlrange="-1.69 1.69"/>
</actuator>
MuJoCo's documented backlash pattern: two coincident hinges, actuator on the driven one

The body's rotation relative to its parent is the sum of the two joints. The armature on the driven joint is not optional: without it the joint-space inertia matrix is singular, because two coincident joints could accelerate in opposite directions without meeting any inertia. For how much lash to model, one published bench test measured 1.3 mm of play at an 86 mm lever, about 0.87 degrees, against a datasheet limit of 0.5. The same test put repeatability near 0.17 degrees, roughly two counts on the 4096-step 12-bit encoder. It ran on a 12 V STS3215, so its torque numbers do not apply here, but it shares the 1:345 metal gearbox with the 7.4 V part, and backlash is a gearbox property. The mechanical figures travel; the torque figures do not.

Three gain values, two of them in the same repo

The kp=50 against kp=998.22 split is the headline, but the vendor repo does not even agree with itself. so101_new_calib.xml carries a comment reading "Additional joints_properties.xml" and then inlines a copy of that file. The actual joints_properties.xml sitting next to it specifies kp=17.8, kv=0.0, forcerange=-3.35 3.35, while the inlined copy specifies kp=998.22, kv=2.731, forcerange=-2.94 2.94. Same directory, same servo, gains three orders of magnitude apart. Whichever you inherit depends on which file you load, and only the MJCF is actually loaded by scene.xml. Treat all three numbers as somebody's starting guess.

The gripper is a hinge here and a percentage in LeRobot

The SO-101 simulation README states it plainly: in LeRobot the gripper is a linear joint where 0 is fully closed and 100 is fully open, and that mapping is not yet reflected in the current URDF and MuJoCo files. In the MJCF it is a hinge, ctrlrange -0.17453 to 1.74533 rad on the SO-101 and -0.174 to 1.75 on the Menagerie SO-100. Pipe recorded gripper commands straight into d.ctrl and the jaw drives to a limit and stays there. Two lines to fix once you know, a long afternoon if you do not, and the same family as a gripper that does not close on real hardware.

One default class for six different loads

Both models apply one set of joint parameters to all six joints. armature is reflected rotor inertia and scales with the square of the gear ratio, so it differs per joint whenever the gearing does. On the SO-101 follower all six servos are 1:345, so a shared value is defensible. The leader is not uniform: Hugging Face's assembly guide lists 1:191 for shoulder pan and elbow flex, 1:345 for shoulder lift, and 1:147 for wrist flex, wrist roll and gripper. Friction is worse, since stiction in a plastic gearbox depends on load, temperature and runtime. A single frictionloss of 0.052 or 0.1 is a placeholder, and no policy trained against a placeholder inherits anything about the real thing.

dampratio does not mean what it looks like

The Menagerie model uses dampratio="1", which reads as "critically damped". MuJoCo's reference is careful: dampratio is computed from the mass at the reference configuration qpos0 including armature, but it explicitly does not account for passive joint damping or frictionloss, and it recommends values below 1 when those are non-negligible. Both are non-zero in these models. The same docs recommend the implicitfast or implicit integrator whenever kv or dampratio is used, and neither file sets an integrator at all, so both run the default Euler. Check those two before you go looking at a policy that freezes mid motion.

Fitting the model to your own arm

None of this makes the model useless. It means the published parameters are a starting point and the fitting is your job. MuJoCo's own overview lists system identification as one of the things the engine is for. On a six-joint arm it is not exotic: drive a known input, record the real response, replay the same input in simulation, search a handful of parameters until the curves overlap. You already have the tooling if you are doing dataset recording, because a step response is a very boring episode.

  1. 1
    Record a step response per joint

    Hold five joints, step the sixth by 20 to 30 degrees, log commanded and measured positions at your control rate. Two directions, two payloads: empty and a known mass in the jaw. The desktop client used for recording already logs joint states alongside the camera streams.

    bash
    # the point is a clean single-joint step and a log with t, ctrl[j], qpos[j]
    python record_step.py --joint shoulder_lift --amplitude-deg 25 --repeats 5
  2. 2
    Replay the identical command sequence in MuJoCo

    Freeze the other joints at the pose you held on the bench and feed the recorded ctrl array in at the same rate, holding each command for the right number of physics steps.

    python
    steps_per_cmd = round((1.0 / cmd_hz) / m.opt.timestep)   # 30 Hz -> 17 steps
    sim = []
    for u in ctrl_log:
        d.ctrl[j] = u
        for _ in range(steps_per_cmd):
            mujoco.mj_step(m, d)
        sim.append(d.qpos[j])
  3. 3
    Search the four parameters that matter

    kp, kv or dampratio, joint damping, frictionloss. A coarse grid usually gets inside a degree. Fit rise time and steady-state offset separately: frictionloss shows up as a dead band around the setpoint, damping shows up in the overshoot.

    python
    import itertools
    grid = itertools.product(
        [50, 100, 200, 400, 800],      # kp
        [0.0, 0.5, 1.0, 2.0, 3.0],     # kv
        [0.0, 0.3, 0.6, 1.0],          # joint damping
        [0.0, 0.02, 0.05, 0.1],        # frictionloss
    )
    best = min(grid, key=lambda p: rmse(replay(*p), bench_trace))
  4. 4
    Fix forcerange, then sanity check against gravity

    Set forcerange to something you can defend (1.91 N.m for the physical ceiling, 0.49 for what the arm holds all day), park it horizontally with a known mass in the jaw, and compare steady-state sag. This is the test that catches a torque limit from the wrong datasheet; a low-load step response will not. Use the same pose as your data collection setup so the numbers compare.

    python
    m.actuator_forcerange[:] = [[-1.91, 1.91]] * m.nu
    m.actuator_forcelimited[:] = 1
    
    mujoco.mj_resetDataKeyframe(m, d, 0)
    d.ctrl[:] = target_pose
    for _ in range(4000):        # 8 s, well past settling
        mujoco.mj_step(m, d)
    print(np.degrees(d.qpos[:m.nu] - target_pose).round(2),
          "| peak torque", d.actuator_force.round(3))

Where a sim policy stops helping

This is the part that matters most if you arrived at MuJoCo from the VLA side. The five policies you can train here are all imitation learning policies. GR00T N1.7 and N1.5 are roughly 3 B parameter foundation models with a diffusion action head; on N1.7 about 40 M of those parameters train during fine-tuning. Pi0.5 is a flow-matching VLA on a PaliGemma backbone. SmolVLA is about 450 M. ACT is about 80 M and trains from scratch, with no base model at all. Every one of them consumes recorded episodes: joint states, camera frames, a task string. The numbers sit side by side on the policies page.

MuJoCo renders with OpenGL. It hands you an image with clean edges, no motion blur, no rolling shutter, no auto-exposure hunting when the gripper shadow crosses the table, and none of the sensor noise a cheap webcam produces. Domain randomization exists as a technique precisely because of this gap: Tobin and colleagues showed in 2017 that randomizing textures, lighting and camera pose can carry a detector across to real images. It works, and it is a training-time cost paid in addition to the fine-tune, not instead of it. If you are collecting demonstrations for a fine-tune, the honest advice is in the data collection guide: record real episodes.

Advantages
  • No hardware risk. Command a trajectory that would hit the table and watch, repeatedly, for free.
  • Deterministic replay: the same ctrl sequence gives the same qpos every time, which makes controller bugs findable in a way a jittery serial bus does not.
  • Fast iteration on reachability, joint limits and grasp geometry, the parts MuJoCo models well.
  • Gain tuning without heat. Sweep kp over three orders of magnitude instead of listening for the servo to whine.
  • A route to massively parallel RL through MJX and MuJoCo Playground, if reinforcement learning is what you are actually doing.
Trade-offs
  • The models disagree by a factor of 20 on actuator gain, and the vendor repo carries a third value in a sidecar file, so there is no default you can trust unfitted.
  • Torque limits come from the 12 V rating: about 1.8x the real 7.4 V stall figure and 7x the continuous one.
  • Backlash, stiction and gearbox temperature drift are unmodelled, and the declared backlash class is attached to nothing.
  • Rendered frames do not match webcam frames, so the vision half of a VLA does not transfer without deliberate randomization work.
  • None of the five trainable policies accepts simulated episodes as a substitute. Minimums are 30 episodes for SmolVLA and 50 for the rest, and they mean real ones.
  • Remote inference latency is a constraint a physics engine will never show you. Per-model step times are on the policies page, but a step time is not a round-trip time.
The AY-Robots training matrix: five policy models as rows, four supported arms as columns, each cell linking to a specific guide
The training matrix on /train. Every cell expects recorded episodes from a physical arm, not simulated rollouts.

If parallel simulation is what you need, look past plain MuJoCo. MuJoCo Playground, published February 2025, builds on MJX and reports training policies in minutes on a single GPU across quadrupeds, humanoids, dexterous hands and arms. The 10x single-scene penalty above is specific to the JAX implementation; the docs say the newer Warp implementation resolves several of those bottlenecks on NVIDIA hardware. The LeRobot sim2real project trains RL policies in ManiSkill and SAPIEN instead, with a zero-shot RGB tutorial that picks up cubes with an SO-100. For the NVIDIA stack, compare Isaac Lab and Isaac Gym. MuJoCo's strength is a fast, accurate, editable single-scene model you can read in a text editor: the right tool for understanding one arm, the wrong one for generating a million rollouts.

Two routes to a working policy

Full local control, no account, every parameter in a file you own. The right path if you want understanding rather than a shipped policy. The simulation half and the imitation learning half stay separate: MuJoCo tunes your controller and validates your workspace, then you record real episodes and train on those.

bash
# 1. simulation side
pip install mujoco
git clone --depth 1 --filter=blob:none --sparse \
    https://github.com/google-deepmind/mujoco_menagerie.git
cd mujoco_menagerie && git sparse-checkout set trs_so_arm100
python -m mujoco.viewer --mjcf=trs_so_arm100/scene.xml

# SO-101 owners want the vendor files instead
git clone --depth 1 https://github.com/TheRobotStudio/SO-ARM100.git
python -m mujoco.viewer --mjcf=SO-ARM100/Simulation/SO101/scene.xml

# 2. hardware side, entirely separate
pip install -e ".[feetech]"        # LeRobot with the Feetech SDK
lerobot-find-port
lerobot-setup-motors --robot.type=so101_follower --robot.port=/dev/ttyACM0
lerobot-calibrate   --robot.type=so101_follower \
                    --robot.port=/dev/ttyACM0 \
                    --robot.id=my_follower
Two tracks that meet only at the joint-angle convention
  • Budget an evening of system identification per joint if you want sag predicted correctly. The bench recording is the slow part.
  • Keep one joint-name mapping in one place: Menagerie uses Rotation/Pitch/Elbow, LeRobot uses shoulder_pan/shoulder_lift/elbow_flex.
  • Fix the gripper mapping before anything else touches it: LeRobot 0 to 100, MJCF a hinge in radians.
  • For training, follow the hardware route in ACT on SO-100, or pick a model on the ACT page. ACT is cheapest to start with: about 80 M parameters, 20 ms per action step, a 24 GB card.
  • You supply your own GPU, or your own spot-market plumbing.
The AY-Robots try page showing three ways to start without owning a robot: drive a real arm, compare models, rent a GPU
Three entry points that need no hardware on your desk. Driving the real arm answers most of what people build a simulator to answer.

No arm on your desk yet?

Drive a real SO-100 in the browser, no signup, queue-based. It answers reachability and gripper questions in ten minutes, which beats fitting a MuJoCo model to a servo you have never held.

Try it now

Frequently asked questions

Is there an official MuJoCo model for the SO-100?

Not in TheRobotStudio's own repository. As of 24 August 2026, Simulation/SO100 contains so100.urdf, a rerun file and STL assets, but no MJCF. The SO-100 MJCF lives in MuJoCo Menagerie as trs_so_arm100: initial release 25 November 2024, last changed 9 June 2025, Apache-2.0, requiring MuJoCo 3.1.6 or later. The vendor repo does ship MJCF for the SO-101, in two calibration variants.

Why do the two models use such different actuator gains?

Different authors, different starting points. Menagerie uses kp=50 with dampratio=1, hand-tuned to look stable in the viewer. The SO-101 file uses kp=998.22 with kv=2.731, and an inline comment says those were calculated assuming the servo's internal proportional gain is set to 16, with motor properties adapted from the Open Duck Mini project. The joints_properties.xml file in that same directory then specifies kp=17.8 and kv=0.0 instead. None of the three is wrong so much as unfitted.

Can I train a VLA policy in MuJoCo and run it on a real SO-100?

Not usefully with the models on this platform. GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT all train on recorded episodes containing real camera frames, and the minimum episode counts (30 for SmolVLA, 50 for the rest) mean real recordings. RL in simulation with heavy domain randomization is a separate path, and the LeRobot sim2real project does exactly that using ManiSkill rather than MuJoCo.

Why is my simulated arm stronger than the real one?

Because the forcerange values in both models match the 12 V STS3215 rating. The sts3215 class in the SO-101 file uses 2.94 N.m, exactly the 30 kg.cm Seeed lists for the 12 V ST3215-C047. The 7.4 V ST3215-C001 actually fitted here is rated 19.5 kg.cm stall (1.91 N.m) and 5 kg.cm continuous (0.49 N.m). Set forcerange to match your hardware before trusting any lifting result, and remember stall torque is momentary, not something the servo holds.

Should I use MJX to speed things up?

Only if you run many environments at once. The docs state that for a single scene the JAX implementation can be 10x slower than CPU MuJoCo, and that it works best at thousands or tens of thousands of parallel scenes. It also carries mesh budgets: roughly 200 vertices or fewer for convex mesh against primitive collisions, fewer than 32 for convex-convex, tunable with the compiler's maxhullvert attribute. The newer Warp implementation is documented as resolving several of those bottlenecks on NVIDIA GPUs.

Does the simulator help with calibration?

Indirectly. It will not calibrate your servos: that means driving each joint through its range with lerobot-calibrate and writing an offset file. What the model gives you is a reference for what the angles should look like, which makes an off-by-a-calibration bug visible. If a recorded pose looks physically impossible when replayed into the MJCF, the recording is wrong, not the model. Check which convention you are replaying into first, because the Menagerie limits follow the SO-101 old calibration.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started