Skip to content

Runtime Behavior

Abstract

Mojo provides many tools to run MuJoCo models, in addition to building the XML files. In this section we will cover implementing the runtime behavior of the model: adding the shock-absorber springs and stepping the simulation.


Adding Forces

In the LandingGear class, we are going to add a new instance method called add_spring. This method will add a force that acts between the two sites incorporated in the previous section using the runtime toolkit (mujoco_mojo.runtime, imported as rt).

This rt.PointToPointForce is created with the ideal_spring() helper. We provide:

  1. A unique name for the spring (using the side_id)
  2. Between what two sites the spring acts
  3. The spring stiffness and damping coefficient
  4. The rest length, computed from the leg's geometry (this is just the distance between our two sites)

Finally, we register this force to our RuntimeManager (rm). You will notice we do not provide one directly since we will use the RuntimeManager as a context manager.

Python
def add_spring(self, side_id: Side) -> None:
    """Attach a linear spring-damper between this leg and the rocket body, acting as its shock absorber."""
    rt.PointToPointForce.ideal_spring(
        name=f"{side_id}_spring",
        action_site=self.leg_spring_site,
        xtion_site=self.tube_spring_site,
        stiffness=1e1 * US["lbf/inch"],
        damping=1e-1 * US["lbf*s/in"],
        rest_length=INPUTS.gear.SPRING_INIT_LENGTH,
    ).register_to_rm()

Defining the Runtime Method

Like with our generator, Mojo uses another function to define the runtime behavior of the model. Before calling this, Mojo will initialize a mojo.MjState which contains a mujoco.MjModel, mujoco.MjData, and your UnitSystem (if declared).

It also provides a RuntimeManager to use. Most often we will immediately use with runtime_manager as rm: since it makes building models easier, and we will only ever really need the one manager.

Once in the context block, we do a few things:

  1. Extract our VLRModel from the user data
  2. Loop over the landing gear to add the springs using our add_spring method
  3. Use the camera we defined to record video and take screenshots during the simulation
    • We only do this for the nominal trial, not every randomized Monte Carlo trial, since video files can increase runtime, memory/storage space, and are generally not needed.
  4. Run the solver by looping using rm.step(state) (this is where rt.PointToPointForce will automatically be computed and applied for each timestep)
  5. Take one last screenshot when the rocket has landed
Python
def runtime(
    mojo_model: mojo.MojoModel,
    runtime_manager: rt.RuntimeManager,
    state: mojo.MjState,
    *args,
    **kwargs,
) -> mojo.MojoModel:
    """
    Executes the physics simulation.

    The other entry point mujoco_mojo calls for every trial, after
    `generate()` has built and compiled the MJCF model. `state` wraps the
    live `mjModel`/`mjData`; `runtime_manager` (opened as `rm` below) is
    what forces and recorders get registered against and what actually
    advances the simulation each `rm.step()`.
    """
    with runtime_manager as rm:
        vlr_model = mojo_model.get_user_data(VLRModel)

        # Attach each leg's shock-absorber spring now that the model is compiled
        for side_id, gear in vlr_model.rocket.landing_gear.items():
            gear.add_spring(side_id)

        # Only record a video for the single baseline trial, not every
        # randomized Monte Carlo variation. `rm.recording` also lets this skip
        # cleanly when a `reloaded` session has recording toggled off.
        recorder = None
        if mojo_model.is_nominal:
            recorder = (
                rt.VideoRecorder(
                    path=Path(__file__).parent / "video.gif",
                    camera_name=vlr_model.camera,
                    fps=10,
                )
                .setup(state)
                .register_to_rm()
            )
            recorder.snapshot(state, Path(__file__).parent / "ground.jpg")

        while state.data.time < 4.0:
            rm.step(state)

        if recorder:
            recorder.snapshot(state, Path(__file__).parent / "landed.jpg")

    return mojo_model

Running One Trial

Running the model can be done using mojo.utils.MojoRunner, providing the generator, runtime, a workdir, and configuration for how many Monte Carlo trials should be run. It then tells it to run with a clean working directory.

Python
if __name__ == "__main__":
    mojo.utils.setup_logger()
    workdir = Path(__file__).parent / "monte_carlo_study"

    runner = mojo.utils.MojoRunner(
        generator=generate,
        runtime=runtime,
        workdir=workdir,
        config=mojo.utils.MonteCarloConfig(n_trial=1, n_proc=1, resume=False),
    ).run(clean_workdir=True)

All Code
Python
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
"""
Example: a vertically landed rocket (VLR) with spring-damped landing gear.

The rocket body free-falls from `Inputs.STARTING_HEIGHT`, then its four legs
absorb the landing through a spring at each leg plus contact with the ground
through a footpad.

This file mixes two things that are worth telling apart while reading it:
the physics/model definition itself (the `TubeInput`, `GearInput`, `Side`,
`LandingGear`, `Rocket`, `Ground`, and `VLRModel` classes describe what is
being built), and the mujoco_mojo authoring pattern (`generate()` and
`runtime()` are the two entry points mujoco_mojo calls to build and then
simulate a model). Comments starting with "Mojo:" call out that second,
library-usage kind of detail rather than rocket physics.
"""

from __future__ import annotations

from enum import StrEnum
from pathlib import Path
from typing import Self

import numpy as np
from pydantic import Field

import mujoco_mojo as mojo
import mujoco_mojo.runtime as rt

logger = mojo.utils.get_logger(__name__)
US = mojo.UnitSystem.si()  # Define a unit system used throughout the model



class TubeInput(mojo.UserData):
    """Physical dimensions of the rocket's cylindrical body tube."""

    DIAMETER: float = 3 * US.inch
    """Outer diameter of the body tube."""

    LENGTH: float = 1 * US.foot
    """Length of the body tube."""

    @property
    def RADIUS(self) -> float:
        """Outer radius of the body tube, derived from `DIAMETER`."""
        return self.DIAMETER / 2


class GearInput(mojo.UserData):
    """Physical dimensions and layout of a single landing gear leg."""

    ANGLE: float = 30
    """Angle, in degrees, between the leg and the rocket's body tube at rest. 0 would fold the leg flat against the tube; 90 would point it straight out, perpendicular to the tube."""

    LENGTH: float = 6 * US.inch
    """Length of the leg, from its hinge to the footpad."""

    WIDTH: float = 1 * US.inch
    """Width of the leg's square cross-section."""

    SPRING_LEG_OFFSET: float = 3 * US.inch
    """Distance along the leg, measured from its hinge, where the spring attaches."""

    SPRING_VERT_OFFSET: float = 4 * US.inch
    """Height along the body tube, measured from the leg's hinge, where the spring's other end attaches."""

    PAD_DIAMETER: float = 2 * US.inch
    """Diameter of the footpad sphere at the tip of the leg."""

    @property
    def PAD_RADIUS(self) -> float:
        """Radius of the footpad, derived from `PAD_DIAMETER`."""
        return self.PAD_DIAMETER / 2

    @property
    def SPRING_INIT_LENGTH(self) -> float:
        """
        Distance between the spring's two attachment sites when the leg is
        at its resting `ANGLE` — i.e. the spring's natural (rest) length.

        The leg's hinge and the spring's two attachment points form a
        side-angle-side triangle: we know both side lengths (`SPRING_LEG_OFFSET`
        and `SPRING_VERT_OFFSET`) and the angle between them (`ANGLE + 90`,
        since at `ANGLE = 0` the leg sticks out horizontally, perpendicular
        to the vertical tube). The law of cosines then gives the third side.
        """
        b = self.SPRING_LEG_OFFSET
        c = self.SPRING_VERT_OFFSET
        angle = np.deg2rad(self.ANGLE + 90)
        return np.sqrt(b**2 + c**2 - 2 * b * c * np.cos(angle))


class Inputs(mojo.UserData):
    """Top-level physical inputs for the vertically landed rocket model."""

    tube: TubeInput = Field(default_factory=TubeInput)
    """Dimensions of the rocket's body tube."""

    gear: GearInput = Field(default_factory=GearInput)
    """Dimensions and layout of each landing gear leg."""

    STARTING_HEIGHT: float = 10 * US.foot
    """Height above the ground the rocket starts the simulation at, in free fall."""


INPUTS = Inputs()


class Side(StrEnum):
    """
    One of the four positions, spaced evenly around the rocket's body tube,
    where a landing gear leg is mounted. Named after the world-frame
    direction each side faces: `PX`/`MX` face +X/-X, `PY`/`MY` face +Y/-Y.
    """

    PX = "px"
    MX = "mx"
    PY = "py"
    MY = "my"

    @property
    def clocking_angle(self) -> float:
        """Angle, in radians, of this side around the tube's Z axis."""
        match self:
            case Side.PX:
                return np.deg2rad(0)
            case Side.MX:
                return np.deg2rad(90)
            case Side.PY:
                return np.deg2rad(180)
            case Side.MY:
                return np.deg2rad(270)

    @property
    def pos(self) -> mojo.Vec3:
        """Unit vector, in the XY plane, pointing outward from the tube toward this side."""
        return np.array(
            (
                np.cos(self.clocking_angle),
                np.sin(self.clocking_angle),
                0,
            )
        )




class LandingGear(mojo.UserData):
    """
    A single landing gear leg: a rigid leg with a footpad at the tip, hinged
    to the rocket body, with a spring connecting the leg back to the body to
    absorb the landing.
    """

    leg_spring_site: mojo.AnySite
    """Attachment point for the spring, on the leg."""

    tube_spring_site: mojo.AnySite
    """Attachment point for the spring, on the rocket body."""

    @classmethod
    def new(cls, frame: mojo.Frame, side_id: Side) -> Self:
        # Make a leg with a footpad angled at the ground
        body = mojo.Body(
            name=mojo.BodyName(f"{side_id}_leg"),
            pose=mojo.PoseEuler(
                # Rotate about Y so the leg points down and out at ANGLE
                # degrees from horizontal
                euler=np.array((0, INPUTS.gear.ANGLE, 0)),
                angle=mojo.Angle.DEGREE,
            ),
            geoms=[
                # Main leg geometry
                mojo.GeomBox(
                    size=np.array((INPUTS.gear.WIDTH / 2, 0, 0)),
                    fromto=np.array((0, 0, 0, INPUTS.gear.LENGTH, 0, 0)),
                    rgba=mojo.utils.Color.ROSE_500.with_alpha(0.5),
                    # A geom without an explicit mass/density defaults to the
                    # density of water (1000 kg/m^3), which would make this
                    # leg unrealistically heavy. 1.24 g/cm^3 is roughly a
                    # rigid engineering plastic (e.g. ABS/nylon).
                    density=1.24 * US["gram/cm^3"],
                ),
                # Footpad: the part of the leg that actually touches the ground
                mojo.GeomSphere(
                    size=INPUTS.gear.PAD_RADIUS,
                    pose=mojo.PoseQuat(pos=np.array((INPUTS.gear.LENGTH, 0, 0))),
                    rgba=mojo.utils.Color.PURPLE_500.rgba,
                    # Modeled as massless (density=0): its job is only to
                    # define the leg's contact surface, not to add inertia.
                    # Left at the default water density, it adds a lot of
                    # mass right at the tip of the leg (its maximum lever
                    # arm from the hinge), which makes landings look far
                    # more damped than they should.
                    density=0,
                ),
            ],
            joints=[
                # This joint allows the leg to pivot
                mojo.Joint(
                    name=mojo.JointName(f"{side_id}_leg_main_hinge"),
                    type=mojo.JointType.HINGE,
                    axis=np.array((0, 1, 0)),
                )
            ],
        )
        frame.bodies.append(body)

        # Make a site on the leg where one end of the spring will attach
        leg_site = mojo.SiteSphere(
            name=mojo.SiteName(f"{side_id}_leg_spring"),
            pose=mojo.PoseQuat(pos=np.array((INPUTS.gear.SPRING_LEG_OFFSET, 0, 0))),
            size=INPUTS.gear.WIDTH * 0.7,
            rgba=mojo.utils.Color.AMBER_500.rgba,
        )
        body.sites.append(leg_site)

        # Make another site on the rocket body for the other end of the spring
        tube_site = mojo.SiteSphere(
            name=mojo.SiteName(f"{side_id}_tube_spring"),
            pose=mojo.PoseQuat(pos=np.array((0, 0, INPUTS.gear.SPRING_VERT_OFFSET))),
            size=leg_site.size,
            rgba=leg_site.rgba,
        )
        frame.sites.append(tube_site)

        return cls(
            leg_spring_site=leg_site,
            tube_spring_site=tube_site,
        )


    def add_spring(self, side_id: Side) -> None:
        """Attach a linear spring-damper between this leg and the rocket body, acting as its shock absorber."""
        rt.PointToPointForce.ideal_spring(
            name=f"{side_id}_spring",
            action_site=self.leg_spring_site,
            xtion_site=self.tube_spring_site,
            stiffness=1e1 * US["lbf/inch"],
            damping=1e-1 * US["lbf*s/in"],
            rest_length=INPUTS.gear.SPRING_INIT_LENGTH,
        ).register_to_rm()





class Rocket(mojo.UserData):
    """The rocket: a body tube with four landing gear legs mounted around it."""

    body: mojo.Body
    """The rocket's MJCF body: the body tube geometry, with a free joint so it can fall and land."""

    landing_gear: dict[Side, LandingGear]
    """The four landing gear legs, keyed by which side they're mounted on."""

    @classmethod
    def new(cls, mojo_model: mojo.MojoModel) -> Self:
        assert mojo_model.mjcf.worldbody  # For type hinting

        # Make the body with the geometry
        # It is defined at STARTING_HEIGHT so that it has distance to the ground
        # and we use a FreeJoint so that it is free to move
        rocket_body = mojo.Body(
            name=mojo.BodyName("rocket"),
            geoms=[
                # Make a cylinder to represent the body tube
                # We use a Geom instead of a Site since it will have mass
                mojo.GeomCylinder(
                    size=INPUTS.tube.RADIUS,
                    fromto=np.array((0, 0, 0, 0, 0, INPUTS.tube.LENGTH)),
                    rgba=mojo.utils.Color.CYAN_500.with_alpha(0.2),
                    mass=1 * US.pound,
                )
            ],
            pose=mojo.PoseEuler(
                euler=np.array((5, 0, 0)),
                angle=mojo.Angle.DEGREE,
                pos=np.array((0, 0, INPUTS.STARTING_HEIGHT)),
            ),
            freejoints=[mojo.FreeJoint()],
        )
        mojo_model.mjcf.worldbody.bodies.append(rocket_body)

        # Add 4 symmetric sides around the rocket, each mounting one landing gear leg
        landing_gear: dict[Side, LandingGear] = {}
        for side_id in Side:
            # Make a new side for clocking around
            frame = mojo.Frame(
                name=mojo.FrameName(f"{side_id}_side"),
                pose=mojo.PoseEuler(
                    euler=np.array((0, 0, side_id.clocking_angle)),
                    angle=mojo.Angle.RADIAN,
                    pos=INPUTS.tube.RADIUS * side_id.pos,
                ),
            )
            rocket_body.frames.append(frame)

            # We no longer need to pass mojo_model around since we will be using
            # the newly made `frame` as our base object
            landing_gear[side_id] = LandingGear.new(frame, side_id)

        return cls(body=rocket_body, landing_gear=landing_gear)




class Ground(mojo.UserData):
    """Definition of the ground plane."""

    @classmethod
    def new(cls, mojo_model: mojo.MojoModel) -> Self:
        assert mojo_model.mjcf.worldbody  # For type hinting

        # Configure ground grid and plane
        mojo_model.mjcf.assets.append(
            mojo.Asset(
                textures=[
                    grid_tex := mojo.TextureBuiltIn(
                        name=mojo.TextureName("grid_tex"),
                        type=mojo.TextureType.D2,
                        builtin=mojo.TextureBuiltInType.CHECKER,
                        width=1024,
                        height=1024,
                        rgb1=mojo.utils.Color.SLATE_400.rgb,
                        rgb2=mojo.utils.Color.SLATE_600.rgb,
                    )
                ],
                materials=[
                    grid_mat := mojo.Material(
                        name=mojo.MaterialName("grid_mat"),
                        texture=grid_tex.name,
                        texrepeat=np.array((1, 1)),
                    )
                ],
            )
        )
        mojo_model.mjcf.worldbody.geoms.append(
            mojo.GeomPlane(
                name=mojo.GeomName("floor"),
                size=np.array([0, 0, 0.1]),
                pose=mojo.PoseQuat(pos=np.array((0.5 * US.m, 0.5 * US.m, 0))),
                material=grid_mat.name,
            ),
        )

        return cls()




class VLRModel(mojo.UserData):
    """
    Root user-data container for the whole model.

    `generate()` builds this and stores it on `mojo_model.user_data`;
    `runtime()` later reads it back out via `mojo_model.get_user_data()` to
    get at the MJCF pieces (sites, bodies) it needs to attach forces to and
    step the simulation.
    """

    ground: Ground
    """The ground plane and world setup."""

    rocket: Rocket
    """The rocket body and its four landing gear legs."""

    camera: mojo.CameraName
    """Name of the camera `runtime()` points the video recorder at."""

    @classmethod
    def new(cls, mojo_model: mojo.MojoModel) -> Self:
        mojo_model.mjcf.model = mojo.ModelName("Vertically Landing Rocket")
        mojo_model.mjcf.worldbody = mojo.WorldBody()

        ground = Ground.new(mojo_model)
        rocket = Rocket.new(mojo_model)

        # A fixed viewing camera, used later by the VideoRecorder in runtime()
        camera_name = mojo.CameraName("iso_view")
        mojo_model.mjcf.worldbody.cameras.append(
            mojo.Camera(
                name=camera_name,
                pose=mojo.PoseQuat.look_at(
                    target=np.array((0, 0, 6 * US.inch)),
                    eye=np.array((1, 1, 1)) * 2 * US.foot,
                    roll=-90,
                ),
            )
        )

        mojo_model.mjcf.worldbody.lights.append(
            mojo.Light(
                pos=mojo.Pos(np.array((0, 0, 10 * US.foot + INPUTS.STARTING_HEIGHT)))
            )
        )

        return cls(ground=ground, rocket=rocket, camera=camera_name)




def generate(mojo_model: mojo.MojoModel, *args, **kwargs) -> mojo.MojoModel:
    """
    Generates the MJCF model and samples distributions.

    This is one of the two entry points mujoco_mojo calls for every
    trial (the other is `runtime()`, below) — it builds and returns the
    MJCF model, but doesn't step the simulation.
    """
    mojo_model.us = US
    mojo_model.user_data = VLRModel.new(mojo_model)

    mojo_model.mjcf.options.append(
        mojo.Option(
            timestep=0.001 * US.s,
            gravity=np.array((0, 0, -9.81 * US["m/s**2"])),
            integrator=mojo.Integrator.EULER,
            cone=mojo.Cone.ELLIPTIC,
            # These `o_*` fields override every contact's solver and friction
            # parameters -- but MuJoCo silently ignores them unless
            # `flag.override` is also enabled, as it is below. Without the
            # flag, each contact would keep combining its two geoms'
            # individual `solref`/`friction` instead (their element-wise
            # maximum), which is easy to miss.
            o_solref=np.array((0.005, 1)),
            o_friction=np.array((0, 0, 0, 0, 0)),
            flag=mojo.Flag(override=mojo.typing.EnableDisable.ENABLE),
        )
    )
    mojo_model.mjcf.visuals.append(
        mojo.Visual(rgba=mojo.VisualRGBA(haze=np.array((0, 0, 0, 0))))
    )
    return mojo_model




def runtime(
    mojo_model: mojo.MojoModel,
    runtime_manager: rt.RuntimeManager,
    state: mojo.MjState,
    *args,
    **kwargs,
) -> mojo.MojoModel:
    """
    Executes the physics simulation.

    The other entry point mujoco_mojo calls for every trial, after
    `generate()` has built and compiled the MJCF model. `state` wraps the
    live `mjModel`/`mjData`; `runtime_manager` (opened as `rm` below) is
    what forces and recorders get registered against and what actually
    advances the simulation each `rm.step()`.
    """
    with runtime_manager as rm:
        vlr_model = mojo_model.get_user_data(VLRModel)

        # Attach each leg's shock-absorber spring now that the model is compiled
        for side_id, gear in vlr_model.rocket.landing_gear.items():
            gear.add_spring(side_id)

        # Only record a video for the single baseline trial, not every
        # randomized Monte Carlo variation. `rm.recording` also lets this skip
        # cleanly when a `reloaded` session has recording toggled off.
        recorder = None
        if mojo_model.is_nominal:
            recorder = (
                rt.VideoRecorder(
                    path=Path(__file__).parent / "video.gif",
                    camera_name=vlr_model.camera,
                    fps=10,
                )
                .setup(state)
                .register_to_rm()
            )
            recorder.snapshot(state, Path(__file__).parent / "ground.jpg")

        while state.data.time < 4.0:
            rm.step(state)

        if recorder:
            recorder.snapshot(state, Path(__file__).parent / "landed.jpg")

    return mojo_model



if __name__ == "__main__":
    mojo.utils.setup_logger()
    workdir = Path(__file__).parent / "monte_carlo_study"

    runner = mojo.utils.MojoRunner(
        generator=generate,
        runtime=runtime,
        workdir=workdir,
        config=mojo.utils.MonteCarloConfig(n_trial=1, n_proc=1, resume=False),
    ).run(clean_workdir=True)

Success

That is pretty much it. MuJoCo Mojo provides many more tools than just the ones used in this walkthrough (such as automating data output, requirements verification, running optimization studies, and data interaction through Dojo, the list could go on).