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):
- 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.
- 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. - 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
Worldlater 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.