The three-layer pipeline¶
manta is organized as three layers with an explicit boundary at every
step. The model is declarative; the transforms are siblings over
it, each owning its math and emitting a typed Module IR; a Target*
lowers any Module to a backend.
Controllers and estimators remain generic transforms over any suitable
World or ModelArtifact; they do not request or perform an operational
model reduction. A deployment integration layer may fit or reduce a world
offline, validate it under its own acceptance policy, then pass that accepted
world to the same Manta APIs. Reusable fitting/reduction mathematics belongs
in Manta, while vehicle identity, release thresholds, artifact installation,
and readiness policy remain downstream concerns.
Model Transform Target Result
─────────────────────────────────────────────────────────────────
World → Sim(world) → TargetNumpy(sim) → NumpyRuntime
(linearized tick) .step() / .outputs()
World → EKF(world) → TargetNumpy(ekf) → NumpyRuntime
(Kalman recursion, .update() / .predict()
baked kernels) (you own the loop)
World → LQR(world, …) → TargetNumpy(lqr) → NumpyRuntime
(Riccati → gain K) .control(state) → {input: u}
any of these → .module() → TargetCpp(x, …) → <basename>.cpp/.hpp
+ flat-C kernels + CMake
Layer 1 — Model¶
A World holds Crafts,
Planets, Couplings, and shared
Fields. A craft is a tree of Parts, each
declaring its channels at class scope:
Parameter— frozen at construction, baked into the graph.State— mutable per-tick state, with a manifold (R1,R3,SO3).Input— per-tick user-supplied value (e.g. throttle).Output— per-tick observable (a sensor reading).Noise— white noise or a random-walk bias state.
The model is pure description: nothing executes at this layer.
Authoring models and transform snapshots¶
World, Craft, fields, and parts are editable authoring objects. Constructing
a transform takes a private snapshot and resolves deferred model behavior on
that copy: planets register their disturbances, field-source parts emit their
sources, camera targets are discovered, and planet-frame initial state is
lowered to the world frame. A failed resolution discards the private copy and
does not partially mutate the authoring model.
Existing transforms never change when the authoring model is edited. A later
Sim(world) or EKF(world) captures the later revision, which makes iterative
model comparison possible without a lock/finalize lifecycle.
Every transform exposes that resolved revision as an immutable
ModelArtifact in its .model attribute. The artifact
has stable content identity, a validated state/input/sensor layout, and an
editable world_copy(). It can be passed directly to another transform. This
keeps the common workflow deliberately open-ended:
Physical-model identity includes the behavior-relevant tick contract,
including channel cadence, defaults, and stochastic scale. Every
model-derived module carries hashed source-model, source-artifact, validation,
and transform-profile metadata. Descriptive labels may instead use
Module.annotations, the explicitly non-hashed map.
sim = Sim(world) # revision A
world.crafts[0].remove("camera")
ekf = EKF(world) # revision B
world.crafts[0].add(camera)
ukf = UKF(world) # revision C
# Rebuild from exactly A, independently of later authoring edits.
replay_filter = EKF(sim.model)
The validated artifact is therefore a boundary between revisions, not a
one-way finalize() operation on the authoring objects.
Layer 2 — Transform¶
Sim(world), EKF(world), and LQR(world, …) are pure compile-time.
Each writes its math symbolically over the shared LinearizedSystem
(manifold-aware F / B / H / L over the compiled world tick) and emits a
typed Module — a state layout plus named CasADi kernels plus typed
entry points. A Module is not directly callable: it is data
describing the computation.
The three transforms are siblings — none is privileged, none knows about
the others, and each owns exactly its own math (forward dynamics for
Sim; the Kalman recursion for EKF; the Riccati solve for LQR).
Layer 3 — Target¶
A Target* lowers a Module to a backend:
TargetNumpy→ the one native-PythonNumpyRuntime. Its surface is derived from the Module's shape — a Sim Module yields.step()/.outputs(), an EKF Module yields.update()/.predict(), an LQR Module yields.control().TargetCpp→ a typed Eigen C++ class over flat-C kernels, plus a CMake project, for embedded deployment.TargetWasm→ the same flat-C kernels behind a browser-facing WebAssembly/JavaScript bundle with the module's typed runtime contract and checkpoint identity.TargetJax→ a jitted JAX rollout.
Backends contain no per-transform code: each implements exactly one generic lowering of a Module. This is what keeps the numpy and C++ paths behaving identically — you own the same driving loop in both.
Target choice is per module, not a deployment-wide build mode. One runtime
assembly may deliberately combine a compiled physics/controller kernel with an
interpreted device or analysis kernel, and independently choose optimization
levels or build deadlines for the compilations it requests. Manta does not
upgrade, downgrade, or fill in those choices. In particular, failure to produce
a requested compiled artifact is an explicit build/readiness failure; it does
not silently turn that request into TargetNumpy execution. The integration
layer validates and runs the artifact assembly it was given.
Why error-state, why CasADi¶
The rigid-body state lives on a manifold (orientation is SO(3)), so the
EKF is an error-state filter: covariance and updates live in the
tangent space, and the model carries manifold-correct boxplus/boxminus
so attitude never leaves the unit-quaternion sheet. CasADi gives manta
the symbolic graph + autodiff it needs to assemble F/B/H/L (and the
process/measurement noise) automatically from the declared model, and to
lower the same graph to C.