Skip to content

Architecture

ssl_simulator's core is a data-oriented, entity-component-system (ECS) design: all mutable simulation data lives in a World as struct-of-arrays component arrays; behaviour is System callables that mutate those arrays in place, vectorized over the N agents; a scheduler orders systems by their declared reads/writes; and a thin Engine ticks them and logs. There are no Controller/RobotModel/Context objects - data and behaviour are fully separated and meet only through named component arrays.

┌────────────────────────────────────────────────────────────┐
│  User code   │  systems (controllers / dynamics / graphs)  │
├────────────────────────────────────────────────────────────┤
│  Engine      │  build schedule · tick systems · log        │
│    └─ World  │  components (SoA) · manifolds · params      │
├────────────────────────────────────────────────────────────┤
│  Support     │  math · loggers · save_sim/load_sim · vista │
└────────────────────────────────────────────────────────────┘

Why this design

Three forces shaped it (see the survey that informed it: Brax/MJX, Bevy/Flecs/EnTT, Warp/Genesis/ JAX-MD):

  1. Shared C++/Paparazzi math. The low-level math is intended to come from a C++/pybind library that bit-matches the Paparazzi autopilot. That rules out the JAX functional-immutable model (XLA owning execution can't drop into hand-written C++ mid-step) and points to in-place mutation over numpy SoA arrays, swappable to C++ kernels - hand a contiguous buffer to a kernel, write results back in place.
  2. Vectorized end-user code. A system body is one numpy (later C++) call over all N agents, not a per-entity Python loop. Because systems are already batched, vmap/autodiff buy nothing.
  3. Heterogeneity (future). ECS archetypes (a component set = a SoA table, systems query the tables that carry their components) are the clean answer, and compose with per-archetype C++ kernels. The current core is single-archetype; the archetype seam is documented below.

The model

World
  ├─ components : dict[str, np.ndarray]   # SoA (N, *shape): state, signals, derivatives, scalars
  ├─ manifolds  : dict[str, Manifold]     # retraction for each integrated state (default: Flat)
  ├─ params     : dict[str, Any]          # static run metadata (Brax's "Model"); logged as settings
  ├─ systems / monitors / tracked         # behaviour + observability
  └─ time

Engine.run(world, duration):
    schedule = build_schedule(world.systems, integrated-state-names)
    <probe pass>                          # seed control signals / RNG before the initial log
    for tick:
        for system in schedule: system.run(world, dt)   # each mutates world[...] in place
        log(snapshot(world))              # component arrays + tracked scalars + monitor values

Component

A component is just a named array world["p"] of shape (N, *shape). Register with world.add (plain) or world.add_state (integrated, with a manifold). Systems read/write it in place.

Manifold

The retraction primitive for integrated state - the data-oriented (JAX-MD's shift_fn): Flat.retract(x, τ) = x + τ (vectorized); SO3.retract(R, ξ) = R · exp(ξ) on (N,3,3) rotation-matrix arrays via lieplusplus - the same C++/pybind Lie library the Paparazzi autopilot uses, so the simulator and autopilot share identical Lie math. Flat and Lie-group states run the same integration path; this is the boundary the shared C++ math plugs into.

Non-vectorized operations

SO3.retract is a single call into lieplusplus' so3_retract for the whole (N, 3, 3) array, writing back in place via out= - one crossing into C++ per tick, not N.

map_entities (ssl_simulator.core.batch) remains the fallback for an op that isn't (yet) vectorized: a scalar C/pybind kernel, or an older lieplusplus wheel. The rule is vectorize when the op supports it, loop when it doesn't. Because a system's (N, …) interface is unchanged either way, swapping a scalar op for a batched wheel or a GPU kernel touches only the op behind the loop - not the system or the world.

System

A callable with declared dependencies - reads/writes (component names) and run(world, dt). Controllers/policies (params in __init__), dynamics, and comm-graph builders are all systems; a plain function with .reads/.writes attributes works too. Built-ins: IntegrationSystem(pairs) retracts each state by its derivative (state ⊞ dt·deriv); MonitorSystem ticks diagnostics.

Scheduler

A stable topological sort by within-tick producer→consumer edges. Integrated state is loop-carried - available at tick start from the previous tick's integration - so reading a state component creates no ordering edge, and the IntegrationSystem (sole state writer) lands last. That's what turns the control→dynamics→integrate→state loop into a valid order instead of a cycle. Ties keep registration order; after/before hints resolve ambiguities; a genuine non-state cycle raises.

Observability

Monitor (running reduction + one-shot warning) declared in a system's monitors and ticked by MonitorSystem; a tracked {name: source} for global scalars sampled at log time; and params for static metadata → the settings header. The engine logs component arrays + tracked scalars + monitor values via the existing DataLogger.

User-facing API

from ssl_simulator import World, System, IntegrationSystem, Engine, Flat

class Consensus(System):
    reads, writes = ("p",), ("u",)
    def __init__(self, laplacian, gain): self.L, self.gain = laplacian, gain
    def run(self, world, dt):
        world["u"][:] = -self.gain * (self.L @ world["p"])

world = World(n=8)
world.add_state("p", dim=2, init=p0, manifold=Flat)   # integrated state
world.add("u", dim=2)                                  # command signal
world.add_system(Consensus(L, 1.0))
world.add_system(IntegrationSystem([("p", "u")]))      # ẋ = u
Engine(time_step=0.05, log_filename="run.csv").run(world, duration=5.0)

Robot models and controllers are just systems / world-builder helpers - see ssl_simulator/robot_models (single_integrator, unicycle_2d/UnicycleDynamics) and ssl_simulator/controllers (Oscillator, ConstantSignal).

Reused, unchanged

The persistence/viz/math stack is already data-oriented and was reused verbatim: core/loggers.py (DataLogger), utils save_sim/load_sim, the math/ package (Lie exp/log, graphs), the Monitor primitive, and all of ssl-vista (it consumes the logged {name: array} dicts). Logged component/setting names are flat (p, p_est, objective, edges) - no robot./ctrl. prefixes.

Efficiency

Pure in-place mutation with no OOP dispatch, no facade, and no per-step object wrapping: a single-integrator consensus step costs ~2.7 µs (N=10), versus ~4.2 µs for the last OOP-hybrid iteration - a ~36% reduction. The Manifold/System boundary is the drop-in point for C++ kernels.

Non-goals / seams (deferred)

  • Heterogeneity: single archetype today. Seam: a World later holds multiple SoA archetype blocks; a system runs its vectorized body over each block that carries its components.
  • C++/pybind kernel backend and any Paparazzi SITL bridge - the boundary accepts it; numpy now.
  • JAX/GPU/autodiff - deliberately declined for the C++/Paparazzi-parity requirement.