Skip to content

Parts

A Part is an atomic unit of behavior on a craft. The declaration sentinels (Parameter, State, Input, Output, Noise) are documented on the base classes; the stock parts below are what you add to a craft.

Declaration model

manta.parts.Part

Part(name, **overrides)

Bases: DeclarationHost

Base class for all parts.

Subclasses declare their interface via class-attribute Parameter (and later Input/State) entries, then implement update(ctx) to contribute a Wrench per tick.

Every Part has a transform parameter — a static (x, y, z) position offset from its parent's output frame. Static orientation between part and parent is currently fixed at identity; non-identity static rotations would be a future extension. The framework uses this transform to roll the part's wrench up into its parent's frame (force-at-offset → torque contribution at parent origin).

Every Part also has a parent attribute — either another Part (typically a CompositePart like the craft's RootPart or a joint) or None for the unattached state. Parents are set by CompositePart.add(child) when a child is attached. The craft's part tree is rooted at Craft.root.

Construction signature::

class Mass(Part):
    mass: Scalar = Parameter(1.0)

Mass("body")                            # at origin of parent
Mass("battery", mass=2.0,
     transform=(0.0, 0.0, -0.5))        # 0.5 m below parent origin
Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

noise_R

noise_R(name)

Measurement-noise covariance for a declared Noise slot.

Reads the per-instance <name>_sigma attribute (set at construction time, default from the declaration). Returns: * σ² (float) for scalar noise. * σ²·I_d (np.ndarray, d×d) for d-vector noise (sized off signal_manifold.ambient_dim).

Used by the EKF to size measurement updates without the user having to specify R separately: ekf.update(h, z, R=imu.noise_R("gyro_noise")).

Source code in manta/parts/base.py
def noise_R(self, name: str) -> Any:
    """Measurement-noise covariance for a declared Noise slot.

    Reads the per-instance `<name>_sigma` attribute (set at
    construction time, default from the declaration). Returns:
        * `σ²` (float) for scalar noise.
        * `σ²·I_d` (np.ndarray, d×d) for d-vector noise (sized
          off `signal_manifold.ambient_dim`).

    Used by the EKF to size measurement updates without the user
    having to specify R separately:
        `ekf.update(h, z, R=imu.noise_R("gyro_noise"))`.
    """
    import numpy as np
    decls = self.noise_declarations()
    if name not in decls:
        raise KeyError(
            f"{type(self).__name__}('{self.name}'): no Noise slot "
            f"named {name!r}. Declared: {sorted(decls)}")
    decl = decls[name]
    sigma = float(getattr(self, f"{name}_sigma"))
    var = sigma ** 2
    d = decl.signal_manifold.ambient_dim
    if d == 1:
        return var
    return var * np.eye(d)

update

update(ctx)

Compute this part's wrench contribution for the current tick. ctx is the manta.craft.TickContext. Subclasses must override and return a Wrench or PartUpdate.

Source code in manta/parts/base.py
def update(self, ctx):
    """Compute this part's wrench contribution for the current tick.
    `ctx` is the `manta.craft.TickContext`. Subclasses must override
    and return a `Wrench` or `PartUpdate`."""
    raise NotImplementedError(
        f"{type(self).__name__}: must override update(self, ctx)")

manta.parts.CompositePart

CompositePart(name, **overrides)

Bases: Part

A Part that hosts other Parts as children.

Children mount on this part's output frame. For a non-joint CompositePart the output frame is identical to the part's own frame (translation only, via transform). An ArticulatedJoint overrides this — a RevoluteJoint's output frame additionally rotates by the joint angle (a PrismaticJoint's translates by its displacement).

add(child) appends a child Part, sets its parent to self, and returns the child (so chained construction reads naturally):

gimbal = pan.add(RevoluteJoint("tilt", axis=(0, 1, 0)))
gimbal.add(Mass("camera", mass=0.05, transform=(0.1, 0, 0)))
Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    super().__init__(name, **overrides)
    self._children: list[Part] = []

walk

walk()

DFS over this part's subtree, yielding self then each descendant.

Source code in manta/parts/base.py
def walk(self):
    """DFS over this part's subtree, yielding self then each descendant."""
    yield self
    for child in self._children:
        if isinstance(child, CompositePart):
            yield from child.walk()
        else:
            yield child

update

update(ctx)

CompositePart has no intrinsic wrench contribution by default — subclasses (RootPart, joints, etc.) override if they need to.

Source code in manta/parts/base.py
def update(self, ctx):
    """CompositePart has no intrinsic wrench contribution by default —
    subclasses (RootPart, joints, etc.) override if they need to."""
    from ..ir.wrench import Wrench
    from ..ir.frames import CraftFrame
    return Wrench.zero(CraftFrame)

manta.parts.Parameter

Parameter(default, *, manifold=None, frame=None)

Bases: _Declaration

Frozen-at-config-time value. Set when the user constructs a Part, used as a constant during graph tracing.

Concrete attribute types are deduced from the default value at init time — a Parameter(1.0) becomes a Python float; a Parameter((1.0, 0.0, 0.0)) stays a tuple until the part's update() promotes it to an IR vector (Vec3[F].constant / Vec3[F].coerce).

Args: manifold — optional Manifold instance or shortcut string ("R1", "R3"; same vocabulary as Noise). Declaring it makes the parameter promotable: a transform constructed with parameters=[...] (system identification — see manta.fit) can promote it from a baked graph constant to a live graph input named <craft>.<part>.<param>. Inside update() a promoted parameter reads as an IR value (the trace binds it), so parts consume promotable parameters through the .coerce factory, which accepts both forms. None (default) — a plain Python config value. frame — Frame tag, consumed when manifold is a shortcut resolving to a vector manifold. The promoted input's frame; must match what update() composes it with.

Source code in manta/parts/_declarations.py
def __init__(self, default: Any, *, manifold=None, frame=None) -> None:
    super().__init__(default)
    if manifold is None:
        self.manifold = None
    else:
        from ..ir.manifold import Manifold, manifold_from_shortcut
        self.manifold = (manifold if isinstance(manifold, Manifold)
                         else manifold_from_shortcut(manifold,
                                                     frame=frame))

manta.parts.State

State(init, manifold='R1', frame=None)

Bases: _Declaration

Per-tick state slot.

Declared at class scope. The framework: * Creates a graph input named "." each compile. * Rebinds the part attribute to that input node before calling update(), so self.<state_name> reads the symbolic current value. * Reads the new value from PartUpdate.new_state["<state_name>"] and emits it as a graph output of the same name. Omitted states pass through unchanged.

Args: init Python value (default initial value across compiles). For R1 a float; for R3 a length-3 tuple / ndarray; for SO(3) a length-4 quaternion (w, x, y, z). manifold String shortcut ('R1', 'R3') or a Manifold instance. SO(3) state is fully supported — pass an explicit SO3Manifold(from_frame=..., to_frame=...) instance (the string shortcut is intentionally disallowed because SO(3) needs the dual-frame parametrization). The slot then evolves on the manifold: the part integrates it with manifold.boxplus(q, ω·dt), the framework keeps it unit-normalized, and the EKF/LQR linearization gives it a 3-dim tangent automatically. See tests/test_so3_state. frame Frame tag for R3 state. Default CraftFrame. Ignored for R1 and SO(3) (the latter's frames live on the manifold). Folded into the Manifold instance.

state.manifold always reads back as a Manifold instance; the string form is normalized at construction.

Source code in manta/parts/_declarations.py
def __init__(self, init, manifold="R1", frame=None) -> None:
    from ..ir.manifold import (
        Manifold, R3Manifold, SO3Manifold, manifold_from_shortcut,
    )
    if isinstance(manifold, Manifold):
        mfd = manifold
    else:
        # String shortcut: SO(3) is intentionally not in the shortcut
        # table because it needs explicit from_frame/to_frame. Pass
        # `manifold=SO3Manifold(from_frame=..., to_frame=...)` instead.
        if manifold not in ("R1", "R3"):
            raise NotImplementedError(
                f"State.manifold={manifold!r}: string shortcuts are "
                f"only defined for 'R1' / 'R3'. For SO(3), pass an "
                f"explicit SO3Manifold(from_frame=..., to_frame=...) "
                f"instance to capture the dual-frame parametrization.")
        mfd = manifold_from_shortcut(manifold, frame=frame)
    if isinstance(mfd, R3Manifold):
        try:
            t = tuple(float(x) for x in init)
        except (TypeError, ValueError):
            raise ValueError(
                f"State(manifold='R3'): init must be a 3-element "
                f"sequence, got {init!r}")
        if len(t) != 3:
            raise ValueError(
                f"State(manifold='R3'): init must be length-3, got "
                f"{init!r}")
        init = t
    elif isinstance(mfd, SO3Manifold):
        if mfd.from_frame is None or mfd.to_frame is None:
            raise ValueError(
                f"State(SO3Manifold): from_frame and to_frame must "
                f"both be specified — got from_frame={mfd.from_frame!r}, "
                f"to_frame={mfd.to_frame!r}.")
        try:
            t = tuple(float(x) for x in init)
        except (TypeError, ValueError):
            raise ValueError(
                f"State(SO3Manifold): init must be a length-4 "
                f"quaternion (w, x, y, z), got {init!r}")
        if len(t) != 4:
            raise ValueError(
                f"State(SO3Manifold): init must be length-4, got "
                f"{init!r}")
        init = t
    super().__init__(default=init)
    self.init     = init
    self.manifold = mfd
    # Keep the explicit `frame` attribute for read sites that
    # consult it directly; for R3 it mirrors the manifold's frame.
    # For SO3 the dual frames live on the manifold itself.
    self.frame = (mfd.frame if isinstance(mfd, R3Manifold) else frame)

manta.parts.Input

Input(default)

Bases: _Declaration

Per-tick external value.

Declared at class scope on a Part. The framework: * Creates a graph input named "." each compile. * Rebinds the part attribute to the symbolic node before calling update(), so self.<input_name> reads the current value. * Initial state from Craft.initial_state() includes the input slot seeded with the declaration's default (or the construction-time override if the user passed one). * Inputs pass through Sim.step's merge — they persist between steps until the user overrides. This makes per-tick commands ergonomic: set once, tick repeatedly, change when you want.

Args: default — Python value used to seed the initial state. May be overridden at construction (Motor("m", torque_cmd=0.5)) in which case the override becomes the seed.

The semantic distinction from Parameter: Parameter values are frozen into the compiled graph as constants; Input values are re-evaluated each tick from the state dict.

Source code in manta/parts/_declarations.py
def __init__(self, default: Any) -> None:
    self.default = default

manta.parts.Output

Output()

Bases: _Declaration

Per-tick value produced by a part (sensor reading, derived quantity, telemetry signal).

Declared at class scope. The part writes its computed value via PartUpdate.outputs["<name>"] = <Vec3 | Scalar | …>. The framework emits the value as a graph output named "."; tick callers read it from the result dict (read-only, doesn't round-trip back as next-tick state). The output's shape is whatever the part writes — nothing downstream needs it declared.

Source code in manta/parts/_declarations.py
def __init__(self) -> None:
    super().__init__(default=None)

manta.parts.Noise

Noise(signal_manifold='R3', *, frame=None, sigma=0.0)

Bases: _Declaration

Abstract base for noise-channel declarations.

Subclasses set class-level metadata (kind, contributes_state) and implement synthesize() (the per-tick IR plumbing). Backends key on signal_manifold.kind via their own registry — no isinstance(WhiteNoise) dispatch anywhere in the codebase.

Concrete subclasses:

  • WhiteNoise — per-tick i.i.d. Gaussian. The framework creates a graph input named <part>.<noise_name>, rebinds the part attribute to that input, and the part adds it directly into its sensor reading (or process expression). σ is the per-tick measurement stddev. kind = "white".

  • RandomWalkNoise — random-walk bias. The framework synthesizes:

    • A state slot <part>.<noise_name> holding the bias.
    • A driver noise input <part>.<noise_name>_driver.
    • A state update each tick: bias_next = bias + sqrt(dt) · driver, driver ~ N(0, σ²). Inside update(), self.<noise_name> reads the bias state (the slowly-drifting current value). σ has continuous σ/√Hz semantics; per-tick bias variance is dt·σ². kind = "random_walk".

Args: signal_manifold — Manifold instance OR shortcut string. The manifold of the symbol user code reads as self.<name>. Shortcuts: "R1" (scalar), "R3" (combine with frame=). Default "R3". Same vocabulary as State(manifold=). frame — Frame class, only consumed when signal_manifold is a shortcut and resolves to a vector-typed manifold. Ignored otherwise. sigma — 1-σ standard deviation, scalar (isotropic across axes). See subclass docstrings for unit conventions.

Source code in manta/parts/_declarations.py
def __init__(self, signal_manifold="R3", *, frame=None,
             sigma: float = 0.0) -> None:
    super().__init__(default=None)
    from ..ir.manifold import manifold_from_shortcut
    self.signal_manifold = manifold_from_shortcut(
        signal_manifold, frame=frame)
    self.sigma = float(sigma)

resolved_signal_manifold

resolved_signal_manifold(*, default_frame=None)

Return self.signal_manifold with any unresolved frame substituted from default_frame. Used at IR synthesis time; the unresolved form keeps R3Manifold(frame=None) legal so a part can declare a noise without committing to a frame until the compiler knows which one it's in (CraftFrame for parts, WorldFrame for disturbances).

Source code in manta/parts/_declarations.py
def resolved_signal_manifold(self, *, default_frame=None):
    """Return `self.signal_manifold` with any unresolved frame
    substituted from `default_frame`. Used at IR synthesis time;
    the unresolved form keeps `R3Manifold(frame=None)` legal so
    a part can declare a noise without committing to a frame
    until the compiler knows which one it's in (CraftFrame for
    parts, WorldFrame for disturbances)."""
    from ..ir.manifold import R3Manifold
    if isinstance(self.signal_manifold, R3Manifold) \
            and self.signal_manifold.frame is None:
        return R3Manifold(frame=default_frame)
    return self.signal_manifold

state_manifold

state_manifold(*, default_frame=None)

Manifold of the synthesized state slot, or None. For RW the state lives in the same space as the per-tick signal.

Source code in manta/parts/_declarations.py
def state_manifold(self, *, default_frame=None):
    """Manifold of the synthesized state slot, or None. For RW
    the state lives in the same space as the per-tick signal."""
    if not self.contributes_state:
        return None
    return self.resolved_signal_manifold(default_frame=default_frame)

is_active

is_active(owner, name)

Is this channel currently producing nonzero output? Reads the runtime <name>_sigma attribute on the owner.

Source code in manta/parts/_declarations.py
def is_active(self, owner, name: str) -> bool:
    """Is this channel currently producing nonzero output? Reads
    the runtime `<name>_sigma` attribute on the owner."""
    return float(getattr(owner, f"{name}_sigma")) > 0.0

driver_input_name

driver_input_name(name)

The name of this channel's per-tick stochastic input. For White noise the signal IS the driver (same name); for RW the driver is a separate <name>_driver input distinct from the bias state name.

Source code in manta/parts/_declarations.py
def driver_input_name(self, name: str) -> str:
    """The name of this channel's per-tick stochastic input. For
    White noise the signal IS the driver (same name); for RW the
    driver is a separate `<name>_driver` input distinct from the
    bias state name."""
    return name

initial_state_entries

initial_state_entries(name, owner)

Names → zero values this channel contributes to the seed state dict (state_spec.unpack-compatible). Inert RW channels return an empty dict; everyone else seeds at least the signal slot.

Source code in manta/parts/_declarations.py
def initial_state_entries(self, name: str, owner) -> dict[str, object]:
    """Names → zero values this channel contributes to the seed
    state dict (state_spec.unpack-compatible). Inert RW channels
    return an empty dict; everyone else seeds at least the signal
    slot."""
    return {name: self._zero_value()}

synthesize

synthesize(*, base_name, name, dt, default_frame, owner)

Build one tick's worth of IR plumbing for this channel. Subclasses implement; the world-tick compiler calls this once per noise declaration per owner.

Source code in manta/parts/_declarations.py
def synthesize(self, *, base_name: str, name: str, dt, default_frame,
               owner) -> SynthesizedNoise:
    """Build one tick's worth of IR plumbing for this channel.
    Subclasses implement; the world-tick compiler calls this once
    per noise declaration per owner."""
    raise NotImplementedError(
        f"{type(self).__name__}.synthesize must be implemented.")

manta.parts.PartUpdate

PartUpdate(wrench=None, new_state=None, outputs=None, rates=None)

Bundle returned by Part.update(ctx) describing this tick's contributions: a wrench (force + torque on parent in CraftFrame), new values for any declared State slots, any declared Output values the part produces, and the rates its I/O runs at.

Construction::

return PartUpdate(wrench, {"angle": a})
return PartUpdate(wrench=w, new_state={"angle": a, "rate": r})
return PartUpdate(wrench=w, outputs={"gyro": gyro_vec},
                  rates={"gyro": self.rate})

rates maps this part's Output slots and/or Input attribute names to a rate in Hz (None ⇒ every tick). It is metadata only — the compiled tick stays a pure function (no sample-and-hold state enters the kernel, so it never complicates autodiff or the EKF/LQR linearization). The runtimes gate the matching port: an Output is published once per 1/rate window and held in between; an Input command is latched (ZOH) once per window, so truth and the estimator's predict see the same held command.

Stateless parts can return a bare Wrench instead — the framework wraps it as PartUpdate(wrench=w) automatically.

Source code in manta/parts/_declarations.py
def __init__(self,
             wrench=None,
             new_state: dict | None = None,
             outputs: dict | None = None,
             rates: dict | None = None) -> None:
    if wrench is None:
        raise TypeError("PartUpdate: wrench is required")
    self.wrench = wrench
    self.new_state = dict(new_state) if new_state else {}
    self.outputs   = dict(outputs)   if outputs   else {}
    self.rates     = dict(rates)     if rates     else {}

Structure

manta.parts.Mass

Mass(name, **overrides)

Bases: Part

A lump of mass with diagonal inertia tensor.

Parameters: mass — kilograms. Promotable (system-ID target). moi — 3-tuple, diagonal MOI tensor (Ixx, Iyy, Izz) about the part's own COM, in part frame. Defaults to zero (point mass). Promotable, like mass: a tunable transform (Sim(world, parameters=[...]) / Fit) promotes it to a live R3 input and the inertia rollup keeps it symbolic.

Gravity contribution is applied automatically whenever a GravityField is registered on the world: F = m · g(p_world), sampled at the part's anchor position. With no GravityField registered, ctx.field(GravityField) returns an empty default and the contribution is identically zero — no special-case opt-out needed.

The part's spatial location is set via its transform parameter (inherited from Part). Aggregation at the Craft level rolls these individual contributions into total mass, COM, and MOI about craft origin via parallel-axis lifts.

Source code in manta/parts/structure/mass.py
def __init__(self, name: str, **overrides) -> None:
    super().__init__(name, **overrides)
    # Zero is allowed per-part (the craft-level total-mass guard
    # catches an all-massless craft); negative mass/MOI is nonsense.
    if float(self.mass) < 0.0:
        raise ValueError(
            f"{type(self).__name__} {name!r}: mass must be >= 0, "
            f"got {self.mass!r}")
    moi = tuple(float(x) for x in self.moi)
    if len(moi) != 3 or any(x < 0.0 for x in moi):
        raise ValueError(
            f"{type(self).__name__} {name!r}: moi must be three "
            f"non-negative diagonal entries, got {self.moi!r}")

manta.parts.PointBuoy

PointBuoy(name, **overrides)

Bases: Part

Single-point buoyancy displacing a fixed volume.

Parameters: volume — m³ displaced by the buoyancy element. Default 1e-3.

Force = -ρ(p_world) · V · g(p_world) at the part's mount point, rotated from anchor to craft frame, applied at the offset (so the framework lifts force-at-offset → body-frame torque for tilt response).

Source code in manta/parts/structure/point_buoy.py
def __init__(self, name: str, **overrides) -> None:
    super().__init__(name, **overrides)
    if float(self.volume) < 0.0:
        raise ValueError(
            f"{type(self).__name__} {name!r}: volume must be >= 0, "
            f"got {self.volume!r}")

manta.parts.Collider

Collider(name, **overrides)

Bases: Part

Point contact element backed by the registered CollisionField.

Parameters: stiffness — N/m. Spring constant of the contact normal-force. Bigger = stiffer contact. Default 5e3. damping — N·s/m. Damper coefficient for the relative velocity along the outward normal direction. Bigger = more energy dissipation per bounce. Default 50.0. friction — N·s/m. Viscous TANGENTIAL friction: opposes the contact point's velocity perpendicular to the outward normal, gated smoothly by penetration (a smooth, EKF-friendly stand-in for Coulomb friction — grips a resting contact against sliding). Default 0 (frictionless contact, the prior behaviour).

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

Actuation

manta.parts.Thruster

Thruster(name, **overrides)

Bases: Part

Polynomial-in-throttle thruster (linear + quadratic).

Coefficients are 3-vectors in the thruster's own frame. For a thruster mounted directly on the craft root that frame is CraftFrame, so Thruster("t", force=(0,0,1)) is a pure +z thrust in body coords. Mounted on a joint's rotor, the thruster's frame spins with the rotor and the framework rotates the emitted wrench into body coords — a gimballed thruster's thrust direction tracks the joint angle automatically, with no frame handling here. Any unset coefficient defaults to zero.

Input: throttle — scalar control input. Units depend on the scaling of the coefficients.

Process-noise channels (the actuator analogue of a sensor's noise — set σ to engage, default 0 = a perfectly clean actuator): force_noise : per-tick white force (N) added to the thrust, in the thruster frame. Because it enters the wrench (not an Output) it propagates through the dynamics into the next state, so the EKF auto-builds Q from it (just as σ on a sensor auto-builds R), and a NoiseDriver jitters the truth thrust by it. torque_noise : per-tick white torque (N·m) added to the reaction torque, same frame and same role for attitude.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

Articulation

manta.parts.RevoluteJoint

RevoluteJoint(name, **overrides)

Bases: ArticulatedJoint

1-DOF revolute joint with an axial rotor (set of Mass children).

Parameters: axis — input-frame unit vector along the rotation axis. Default (0, 0, 1). mode — "passive" or "saturating". Default "passive". stall_torque — saturating-mode torque clamp magnitude (N·m). Ignored in passive mode. Default 1.0. damping — viscous joint friction (N·m·s/rad). Default 0.

Inputs: torque_cmd — commanded torque about axis. Clamped to ±stall_torque in saturating mode; ignored entirely in passive mode.

State: angle — joint angle, rad. rate — joint angular rate (rotor spin relative to body), rad/s.

Source code in manta/parts/articulation/joint.py
def __init__(self, name: str, **overrides) -> None:
    mode = overrides.get("mode", _PASSIVE)
    if mode not in _MODES:
        raise ValueError(
            f"{type(self).__name__} {name!r}: mode must be one of "
            f"{_MODES}, got {mode!r}")
    super().__init__(name, **overrides)
    # The kinematic pass and the joint-space rows both assume a unit
    # axis — normalize once here (a zero axis is a config error).
    self.axis = unit_axis(self.axis,
                          who=f"{type(self).__name__} {name!r}",
                          what="axis")

manta.parts.PrismaticJoint

PrismaticJoint(name, **overrides)

Bases: ArticulatedJoint

1-DOF prismatic (sliding) joint carrying a subtree of Mass children.

Parameters: axis — input-frame unit vector along the slide axis. Default (0, 0, 1). mode — "passive" or "saturating". Default "passive". stall_force — saturating-mode force clamp magnitude (N). Ignored in passive mode. Default 1.0. damping — viscous slide friction (N·s/m). Default 0.

Inputs: force_cmd — commanded force along axis. Clamped to ±stall_force in saturating mode; ignored entirely in passive mode.

State: displacement — slide displacement along axis, m. rate — slide rate (relative to the mount), m/s.

Source code in manta/parts/articulation/joint.py
def __init__(self, name: str, **overrides) -> None:
    mode = overrides.get("mode", _PASSIVE)
    if mode not in _MODES:
        raise ValueError(
            f"{type(self).__name__} {name!r}: mode must be one of "
            f"{_MODES}, got {mode!r}")
    super().__init__(name, **overrides)
    # The kinematic pass and the joint-space rows both assume a unit
    # axis — normalize once here (a zero axis is a config error).
    self.axis = unit_axis(self.axis,
                          who=f"{type(self).__name__} {name!r}",
                          what="axis")

Aerodynamics

manta.parts.DragSurface

DragSurface(name, *, force=None, force_tensors=None, torque=None, torque_tensors=None, **overrides)

Bases: Part

Polynomial drag/lift surface (legacy Surface1..4).

Parameters: force_tensors — list of 3×3 matrices [A_1, A_2, …, A_N], CraftFrame. Default is a single zero matrix (no drag). torque_tensors — same shape, for the surface's contribution to body torque about the mount point.

Convenience args (mutually exclusive with the *_tensors form): force=(x,y,z) — sets A_1 = diag(x, y, z) (per-axis linear drag). torque=(x,y,z) — sets B_1 = diag(x, y, z) (per-axis linear torque).

Source code in manta/parts/aero/drag_surface.py
def __init__(self,
             name: str,
             *,
             force: tuple | None = None,
             force_tensors: list | tuple | None = None,
             torque: tuple | None = None,
             torque_tensors: list | tuple | None = None,
             **overrides) -> None:
    overrides["force_tensors"] = _as_tensor_polynomial(
        force=force, tensors=force_tensors, name=name, kind="force")
    overrides["torque_tensors"] = _as_tensor_polynomial(
        force=torque, tensors=torque_tensors, name=name, kind="torque")
    super().__init__(name, **overrides)

isotropic_quadratic classmethod

isotropic_quadratic(name, *, area, drag_coefficient, **kwargs)

Single-Cd quadratic hull/sphere drag: F = -½·ρ·A·Cd · v_rel^(2) (element-wise square per body axis) Identical to the v1 isotropic model, just expressed in tensor form so the user can mix it with other polynomial orders.

Source code in manta/parts/aero/drag_surface.py
@classmethod
def isotropic_quadratic(cls,
                        name: str,
                        *,
                        area: float,
                        drag_coefficient: float,
                        **kwargs) -> "DragSurface":
    """Single-Cd quadratic hull/sphere drag:
        F = -½·ρ·A·Cd · v_rel^(2)  (element-wise square per body axis)
    Identical to the v1 isotropic model, just expressed in tensor form
    so the user can mix it with other polynomial orders."""
    A_1 = np.zeros((3, 3))
    A_2 = -0.5 * area * drag_coefficient * np.eye(3)
    return cls(name, force_tensors=[A_1, A_2], **kwargs)

directional_quadratic classmethod

directional_quadratic(name, *, areas, drag_coefficient, **kwargs)

Anisotropic quadratic drag — a per-body-axis reference area: F_i = -½·ρ·areas_i·Cd · v_i·|v_i| (diagonal A_2) Use it for a slender body: a cylindrical fuselage along, say, body +z is areas=(side, side, frontal) with frontal ≪ side — low drag nose-on, high drag broadside (and an off-axis flow gets a restoring body torque through the standard force-at-offset lift).

Source code in manta/parts/aero/drag_surface.py
@classmethod
def directional_quadratic(cls,
                          name: str,
                          *,
                          areas: tuple,
                          drag_coefficient: float,
                          **kwargs) -> "DragSurface":
    """Anisotropic quadratic drag — a per-body-axis reference area:
        F_i = -½·ρ·areas_i·Cd · v_i·|v_i|   (diagonal A_2)
    Use it for a slender body: a cylindrical fuselage along, say, body
    +z is ``areas=(side, side, frontal)`` with ``frontal ≪ side`` —
    low drag nose-on, high drag broadside (and an off-axis flow gets a
    restoring body torque through the standard force-at-offset lift)."""
    ax, ay, az = (float(a) for a in areas)
    A_1 = np.zeros((3, 3))
    A_2 = -0.5 * drag_coefficient * np.diag([ax, ay, az])
    return cls(name, force_tensors=[A_1, A_2], **kwargs)

manta.parts.Naca00xx

Naca00xx(name, **overrides)

Bases: Part

Symmetric airfoil (NACA 00xx family).

Parameters: area — m². Planform area of the airfoil. chord_axis — body-frame unit vector along the chord (leading → trailing edge). Default (1, 0, 0). normal_axis — body-frame unit vector normal to the chord, in the airfoil's plane. Positive α (wind from below) → positive lift along this axis. Default (0, 0, 1). CL_max — dimensionless. Peak lift coefficient (achieved at α=45° in this model). For a NACA 0012 a realistic stall-limited value is ~1.0–1.3. CD_0 — dimensionless. Zero-lift drag coefficient (skin friction + pressure drag at α=0). Typical: 0.005–0.015 for clean airfoils. induced_k — dimensionless. Induced/lift-dependent drag coefficient. CD = CD_0 + induced_k·sin²(α). Higher for lower aspect-ratio wings.

Source code in manta/parts/aero/naca_airfoil.py
def __init__(self, name: str, **overrides) -> None:
    super().__init__(name, **overrides)
    who = f"{type(self).__name__} {name!r}"
    if float(self.area) <= 0.0:
        raise ValueError(f"{who}: area must be > 0, got {self.area!r}")
    if float(self.CD_0) < 0.0 or float(self.induced_k) < 0.0:
        raise ValueError(
            f"{who}: CD_0 and induced_k must be >= 0, got "
            f"CD_0={self.CD_0!r}, induced_k={self.induced_k!r}")
    # The AoA decomposition assumes orthonormal chord/normal axes —
    # normalize each and refuse a non-perpendicular pair.
    self.chord_axis  = unit_axis(self.chord_axis,
                                 who=who, what="chord_axis")
    self.normal_axis = unit_axis(self.normal_axis,
                                 who=who, what="normal_axis")
    dot = sum(c * n for c, n in zip(self.chord_axis, self.normal_axis))
    if abs(dot) > 1e-6:
        raise ValueError(
            f"{who}: chord_axis and normal_axis must be perpendicular "
            f"(unit-vector dot product is {dot:.3e}).")

Sensors

manta.parts.IMU

IMU(name, **overrides)

Bases: Part

Inertial-measurement unit with Kalibr-style 4-parameter noise.

Channels (override sigmas via construction): gyro_noise — vec3 white, per-tick rad/s. accel_noise — vec3 white, per-tick m/s². gyro_bias — vec3 RW, rad/s²/√Hz drift density. accel_bias — vec3 RW, m/s³/√Hz drift density.

The two RW channels add bias state slots that the EKF can estimate; skip them by leaving sigma at 0.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.DVL

DVL(name, **overrides)

Bases: Part

Body-frame linear-velocity sensor.

Outputs: velocity : Vec3[CraftFrame] — body-frame velocity (R^T·v_anchor). What a DVL reads when locked to a reference (seafloor / ground).

Noise channel (set σ to engage): velocity_noise — vec3 white, per-tick m/s. Becomes the EKF's measurement R, exactly as PositionSensor's position_noise. Defaults to 0 (an ideal read).

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.Magnetometer

Magnetometer(name, **overrides)

Bases: Part

3-axis magnetometer.

Outputs: B : Vec3[CraftFrame] — magnetic flux density at the sensor position, in the sensor's own frame. SI units (Tesla). For a sensor mounted directly on the craft root that frame is CraftFrame; on a joint rotor it spins with the rotor.

Noise channel (set σ to engage): B_noise — vec3 white, per-tick Tesla. Becomes the EKF's measurement R for a heading/attitude fix, exactly as PositionSensor's position_noise does. Defaults to 0 (a clean reading).

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.PositionSensor

PositionSensor(name, **overrides)

Bases: Part

Outputs the sensor's world-frame position each tick.

Outputs: position : Vec3[WorldFrame] — sensor mount-point position in world frame; exactly what a GPS or mocap marker reads.

Noise channel (set σ to engage — leave at 0 for a noiseless oracle): position_noise : world-frame white noise on the reading. Engage it (PositionSensor("gps", position_noise_sigma=0.5)) to give the EKF an auto-built R for this sensor, e.g. when driving the filter through step().

Rate (Hz): rate : measurement rate. None (default) ⇒ a fresh fix every tick. Set it (PositionSensor("gps", rate=1.0)) to model a slow sensor: the Sim publishes a new reading once per 1/rate window and holds it in between, and the EKF folds each fix in exactly once. Pure metadata — the tick stays a smooth function (the estimator sees the continuous model).

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.ProjectiveCamera

ProjectiveCamera(name, *, width=640.0, height=480.0, hfov_deg=70.0, fx=None, fy=None, cx=None, cy=None, rate=None, noise_sigma=0.0, transform=(0.0, 0.0, 0.0))

Bases: Part

Base for a pinhole camera that measures OpticalField ellipsoids.

Construct with the image size and horizontal field of view (the focal length and a centred principal point are derived), or override any intrinsic explicitly. Subclasses set _COMPONENTS (the per-target scalar measurement names, excluding vis) and implement _project.

Parameters: width, height — image size in pixels. fx, fy, cx, cy — intrinsics (focal lengths + principal point, px). rate — optional capture rate (Hz); None ⇒ every tick. noise_sigma — per-component pixel measurement σ (subclasses expose it under a friendlier name). 0 ⇒ noiseless oracle, no noise channels (byte-identical to a camera with none), and not EKF-usable.

Source code in manta/parts/sensor/camera.py
def __init__(self, name: str, *, width: float = 640.0, height: float = 480.0,
             hfov_deg: float = 70.0, fx=None, fy=None, cx=None, cy=None,
             rate=None, noise_sigma: float = 0.0,
             transform=(0.0, 0.0, 0.0)) -> None:
    import math
    w, h = float(width), float(height)
    f = (w / 2.0) / math.tan(math.radians(hfov_deg) / 2.0)
    super().__init__(
        name, width=w, height=h,
        fx=float(fx) if fx is not None else f,
        fy=float(fy) if fy is not None else (float(fx) if fx is not None else f),
        cx=float(cx) if cx is not None else w / 2.0,
        cy=float(cy) if cy is not None else h / 2.0,
        rate=rate, noise_sigma=float(noise_sigma), transform=transform)
    # Set via set_targets() by World.finalize() (every
    # ellipsoid not on this camera's craft). Drives output/noise
    # declarations and update.
    self._targets: tuple = ()

set_targets

set_targets(targets)

Point the camera: fix the compile-time set of ellipsoids it measures. Called once by World.finalize() before the tick is traced; the output/noise declarations follow it.

Also materializes the per-channel <name>_sigma attributes the framework reads off the instance (Noise.is_active, the tick- signature walk, Craft.sample_noise) — this is the one mutation point; noise_declarations() stays a pure read.

Source code in manta/parts/sensor/camera.py
def set_targets(self, targets) -> None:
    """Point the camera: fix the compile-time set of ellipsoids it
    measures. Called once by `World.finalize()` before
    the tick is traced; the output/noise declarations follow it.

    Also materializes the per-channel `<name>_sigma` attributes the
    framework reads off the instance (`Noise.is_active`, the tick-
    signature walk, `Craft.sample_noise`) — this is the one mutation
    point; `noise_declarations()` stays a pure read."""
    self._targets = tuple(targets)
    sigma = float(self.noise_sigma)
    if sigma > 0.0:
        for e in self._targets:
            for suffix in self._COMPONENTS:
                setattr(self, f"{e.name}_{suffix}_noise_sigma", sigma)

manta.parts.BBoxCamera

BBoxCamera(name, *, bbox_sigma=0.0, **kw)

Bases: ProjectiveCamera

Pinhole camera emitting per-object image-frame bounding boxes.

Outputs per visible source S: <S>_xmin/_ymin/_xmax/_ymax (pixel box, clamped to the image) and <S>_vis (1 when in front and a real ellipse, else 0). The box size encodes range given the target's semi-axes.

BBoxCamera("cam", width=640, height=480, hfov_deg=70)
BBoxCamera("cam", width=1280, height=720, bbox_sigma=2.0)   # EKF-usable
Source code in manta/parts/sensor/camera.py
def __init__(self, name: str, *, bbox_sigma: float = 0.0, **kw) -> None:
    super().__init__(name, noise_sigma=bbox_sigma, **kw)

manta.parts.CentroidCamera

CentroidCamera(name, *, pixel_sigma=0.0, **kw)

Bases: ProjectiveCamera

Pinhole camera emitting per-object image-frame CENTROIDS (u, v).

A centroid is the projection of the target's centre — a pure bearing, independent of the target's size. Outputs per visible source S: <S>_u, <S>_v (pixels) and <S>_vis. One camera fixes a ray; space several apart and select their _u/_v as EKF sensors and the filter triangulates the target's 3-D position (the wider the baseline, the better the range — size never enters).

CentroidCamera("c0", width=1280, height=720, hfov_deg=40,
               pixel_sigma=1.0)
Source code in manta/parts/sensor/camera.py
def __init__(self, name: str, *, pixel_sigma: float = 0.0, **kw) -> None:
    super().__init__(name, noise_sigma=pixel_sigma, **kw)

Attachment and disturbance

manta.parts.TetherEndpoint

TetherEndpoint(name, **overrides)

Bases: Part

Marker Part for one end of a tether. No wrench contribution; the Tether coupling applies the actual force using this part's transform as the attachment offset.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.TrajectoryEndpoint

TrajectoryEndpoint(name, **overrides)

Bases: Part

Spring-damper that slews its craft along a reference pose path.

Parameters: trajectory — callable f(t) -> TrajectorySample taking the symbolic clock (a Scalar) and returning the reference at that time. Required. kp_pos, kd_pos — position spring / damping gains (N per m, N per m/s). With mass set, critical damping is kd_pos = 2·sqrt(kp_pos·mass). kp_att, kd_att — attitude spring / damping gains (N·m per rad, N·m per rad/s). mass — craft mass (kg). When > 0, enables gravity + linear- acceleration feedforward for tight tracking. 0 (the default) is a pure spring — fine for light craft and a zero-gravity world, but it will droop under gravity.

Mount it on the craft root at the origin (the default transform). An off-origin mount would inject a force×lever-arm torque that corrupts the attitude channel.

Source code in manta/parts/attachment/trajectory_endpoint.py
def __init__(self, name: str, **overrides) -> None:
    super().__init__(name, **overrides)
    if self.trajectory is None or not callable(self.trajectory):
        raise ValueError(
            f"{type(self).__name__}({name!r}): `trajectory` must be a "
            f"callable f(t) -> TrajectorySample.")
    if float(self.mass) < 0.0:
        raise ValueError(
            f"{type(self).__name__}({name!r}): mass must be >= 0.")

manta.parts.ProcessNoise

ProcessNoise(name, **overrides)

Bases: Part

White force/torque wrench — model uncertainty as Langevin forcing.

Set σ to engage a channel (force_noise_sigma= / torque_noise_sigma= on construction); both default to 0 (a perfectly modeled craft). The EKF assembles Q from the engaged channels and NoiseDriver excites the truth identically.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

Field sources

manta.parts.GravitySource

GravitySource(name, **overrides)

Bases: FieldSource

Adds a point-mass gravity disturbance that rides the carrying craft.

Parameters: GM — gravitational parameter G·M (m³/s²). Earth ≈ 3.986e14, Moon ≈ 4.903e12, a 1000-ton asteroid ≈ 6.7e-5. eps — softening length (m) capping the singularity at the source.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.MagneticSource

MagneticSource(name, **overrides)

Bases: FieldSource

Adds a magnetic dipole disturbance that rides the carrying craft.

Parameters: moment — (mx, my, mz) dipole moment in the craft BODY frame, A·m². A small hobby motor magnet is ~1e-2–1e-1. eps — softening length (m) at the dipole position.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)

manta.parts.OpticalSource

OpticalSource(name, **overrides)

Bases: FieldSource

Adds a semantic ellipsoid disturbance that rides the carrying craft.

Parameters: semi_axes — (a, b, c) half-extents of the bounding ellipsoid along the craft's body axes, m. Roughly half the vehicle's length/width/height. label — integer class id the camera reports with each box.

Source code in manta/parts/base.py
def __init__(self, name: str, **overrides: Any) -> None:
    from ..ir.module import check_name
    self.name = check_name(name, who=type(self).__name__)
    self.parent: "Part | None" = None
    self._apply_declarations(overrides)