Skip to content

Fields

Each Field is a typed superposition of Disturbance objects, combined per each disturbance's combining flag. See Fields and disturbances for the concepts.

Base classes

manta.fields.Field

Field()

Base for a typed physical field — a host that CARRIES disturbances.

A Field fixes value_shape (the CasADi-MX type its disturbances produce) and registers compatible disturbances via add(). Two kinds of field extend it:

  • SuperposedField — the disturbances SUM into one value_at_sym(point, t) (gravity, B-field, fluid, collision).
  • OpticalField (an enumerated field) — the disturbances are kept SEPARATE and enumerated, never summed (a camera draws one box per ellipsoid). It has no value_at_sym.

Field itself is the shared host (the registry + the value-shape contract); it is not meant to be instantiated directly.

Source code in manta/fields/base.py
def __init__(self) -> None:
    self._disturbances: list[Disturbance] = []

add

add(disturbance)

Register a disturbance with this field. Returns self for chaining.

A disturbance constructed without an explicit name is named HERE — <ClassName>_<index among same-class disturbances of this field> — so default names depend only on registration order within the field, never on how many disturbances the process happened to construct earlier (they become IR state-vector keys; two identical scripts must produce identical keys).

Source code in manta/fields/base.py
def add(self, disturbance: Disturbance) -> Field:
    """Register a disturbance with this field. Returns self for chaining.

    A disturbance constructed without an explicit `name` is named
    HERE — `<ClassName>_<index among same-class disturbances of this
    field>` — so default names depend only on registration order
    within the field, never on how many disturbances the process
    happened to construct earlier (they become IR state-vector keys;
    two identical scripts must produce identical keys)."""
    from ..ir.module import check_name
    if not isinstance(disturbance, Disturbance):
        raise TypeError(
            f"{type(self).__name__}.add: expected Disturbance, got "
            f"{type(disturbance).__name__}")
    if disturbance.field_value_shape is not self.value_shape:
        raise TypeError(
            f"{type(self).__name__}.add: disturbance "
            f"{type(disturbance).__name__} produces "
            f"{disturbance.field_value_shape!r}, not "
            f"{self.value_shape!r}")
    # A membership on a field whose fold never evaluates it would be
    # a silent no-op — the disturbance contributes at FULL strength
    # everywhere while the user believes it is spatially bounded.
    # That is physics loss, so it fails here at registration, not at
    # some downstream sample. Both channels count as "non-default":
    # a `membership=` callable stashed by the constructor and a
    # subclass overriding the `membership` method.
    if not self.honors_membership:
        has_custom_membership = (
            getattr(disturbance, "_membership", None) is not None
            or type(disturbance).membership is not Disturbance.membership)
        if has_custom_membership:
            raise ValueError(
                f"{type(self).__name__}.add: disturbance "
                f"{type(disturbance).__name__} carries a non-default "
                f"membership, but {type(self).__name__} composes its "
                f"disturbances by a plain additive fold and never "
                f"evaluates membership — it would be silently ignored. "
                f"Membership is only honored by combining-mode fluid "
                f"fields (FluidField). Drop the membership, or bake the "
                f"spatial support into contribute_at_sym.")
    if disturbance.name is None:
        n = sum(1 for d in self._disturbances
                if type(d) is type(disturbance))
        disturbance.name = check_name(
            f"{type(disturbance).__name__}_{n}",
            who=type(disturbance).__name__)
    if any(d.name == disturbance.name for d in self._disturbances):
        raise ValueError(
            f"{type(self).__name__}.add: disturbance name "
            f"{disturbance.name!r} already exists")
    owner = getattr(disturbance, "_field", None)
    if owner is not None and owner is not self:
        raise ValueError(
            f"{type(self).__name__}.add: disturbance "
            f"{disturbance.name!r} already belongs to "
            f"{type(owner).__name__}")
    disturbance._field = self
    self._disturbances.append(disturbance)
    return self

manta.fields.Disturbance

Disturbance(name=None, *, combining=None, membership=None, **overrides)

Bases: DeclarationHost, ABC

Base for one contribution to a Field.

Subclass and implement contribute_at_sym(point, t). The returned MX must have the Field's value shape (e.g. Vec3[WorldFrame] for GravityField). Multiple disturbances on the same field combine according to their combining flag (see Field.value_at_sym).

Disturbances may declare State / Noise channels at class scope. The framework picks them up at compile time exactly like it does for Parts: * Each State slot becomes a graph input + output named <disturbance.name>.<slot>; the disturbance's attribute is rebound to the symbolic input inside contribute_at_sym. * Each white Noise channel becomes a per-tick graph input. * Each RW Noise channel (sigma > 0) synthesizes a bias state + driver, evolving via bias_next = bias + sqrt(dt)·driver.

Args: name — identifier used as the IR-slot prefix. Must be unique across the world's disturbances. Defaults to <ClassName>_<counter>. combining — how this disturbance's contribution composes with others on the same field. One of: "additive" (default) — straight linear sum. "baseline" — a regime medium (e.g. ocean / air); baselines layer by membership rather than summing. "averaged" — a membership-weighted self-mean among the averaged disturbances (an estimation overlay, e.g. overlapping wind bubbles agreeing on a mean). Only FluidField interprets "baseline"/"averaged" (see FluidField.value_at_sym); for other superposed fields (gravity, B-field) every disturbance is summed additively regardless of this flag. membership — optional callable (point, t) -> MX in [0, 1] giving this disturbance's spatial support. Defaults to 1 everywhere. Used by FluidField to bound regimes / perturbations; ignored by purely-additive fields.

Source code in manta/fields/base.py
def __init__(self, name: str | None = None, *,
             combining: str | None = None,
             membership=None,
             **overrides: Any) -> None:
    from ..ir.module import check_name
    # `None` defers naming to `Field.add()`, which assigns a
    # deterministic per-field default (`<ClassName>_<index>`).
    self.name = (check_name(name, who=type(self).__name__)
                 if name is not None else None)
    if combining is not None:
        self.combining = combining
    # Validate the EFFECTIVE value — a subclass setting the class
    # attribute (the documented way to fix a mode) must be checked
    # too: a typo'd class-level `combining` would otherwise drop the
    # disturbance from every composition bucket silently (a fluid
    # regime vanishing → density 0, no error).
    if self.combining not in ("additive", "averaged", "baseline"):
        raise ValueError(
            f"{type(self).__name__}: combining must be 'additive', "
            f"'averaged', or 'baseline'; got {self.combining!r} "
            f"(set via the class attribute or the combining= kwarg)")
    if membership is not None:
        self._membership = membership
    self._apply_declarations(overrides)

contribute_at_sym abstractmethod

contribute_at_sym(point, t)

Return this disturbance's contribution at the given world-frame point at world-clock time t (Scalar MX). Output type matches the host Field's value type. Static disturbances accept t and ignore it.

Source code in manta/fields/base.py
@abstractmethod
def contribute_at_sym(self, point: Vec3, t):
    """Return this disturbance's contribution at the given world-frame
    point at world-clock time `t` (Scalar MX). Output type matches
    the host Field's value type. Static disturbances accept `t` and
    ignore it."""
    raise NotImplementedError

membership

membership(point, t)

Spatial support of this disturbance at point/t, an MX in [0, 1]. Defaults to 1 everywhere (global); a membership= callable passed at construction, or a subclass override, narrows it (a sea half-space, a bubble). FluidField weights each contribution by this; additive vector fields ignore it.

Source code in manta/fields/base.py
def membership(self, point: Vec3, t) -> ca.MX:
    """Spatial support of this disturbance at `point`/`t`, an MX in
    [0, 1]. Defaults to 1 everywhere (global); a `membership=`
    callable passed at construction, or a subclass override, narrows
    it (a sea half-space, a bubble). `FluidField` weights each
    contribution by this; additive vector fields ignore it."""
    if getattr(self, "_membership", None) is not None:
        return self._membership(point, t)
    return ca.MX(1.0)

Gravity

manta.fields.GravityField

GravityField(g=None)

Bases: SuperposedField

Gravitational acceleration g(point) in the WorldFrame.

value_at_sym(point) returns Vec3[WorldFrame] giving the acceleration a free-falling test mass would experience at point.

The add_uniform builder returns self for chaining: GravityField().add_uniform((0,0,-9.81)). As a convenience, the constructor accepts g=(gx,gy,gz) for the common single-uniform case — equivalent to one add_uniform call.

Every World must declare its gravity before a transform resolves it: a planet or GravityField(g=...) for a real environment, or GravityField.none() for a deliberate zero-g model (a free-floating rigid body, an orbital test). Forgetting the field is a configuration error, not an implicit weightless world.

Source code in manta/fields/gravity.py
def __init__(self, g: tuple[float, float, float] | None = None) -> None:
    super().__init__()
    if g is not None:
        self.add_uniform(g)

none classmethod

none()

An explicit zero-gravity declaration.

Registers the gravity field with no sources, so g(point) = 0 everywhere and the World's gravity contract is satisfied on purpose rather than by omission.

Source code in manta/fields/gravity.py
@classmethod
def none(cls) -> GravityField:
    """An explicit zero-gravity declaration.

    Registers the gravity field with no sources, so `g(point) = 0`
    everywhere and the World's gravity contract is satisfied on
    purpose rather than by omission.
    """
    return cls()

add_uniform

add_uniform(g_vec)

Attach a position-independent gravity vector. Returns self.

Other disturbances (point-mass, J2, …) attach via the generic field.add(PointMassGravity(...)).

Source code in manta/fields/gravity.py
def add_uniform(self, g_vec: tuple[float, float, float]) -> GravityField:
    """Attach a position-independent gravity vector. Returns self.

    Other disturbances (point-mass, J2, …) attach via the generic
    `field.add(PointMassGravity(...))`."""
    return self.add(UniformGravity(g_vec))

manta.fields.UniformGravity

UniformGravity(g_vec, *, name=None)

Bases: Disturbance

Position-independent gravity vector. The standard default for sims that don't care about altitude variation.

Args: g_vec — (x, y, z) gravity acceleration in WorldFrame, m/s². Conventionally (0, 0, -9.81) for Earth-near-surface with z pointing up.

Source code in manta/fields/gravity.py
def __init__(self, g_vec: tuple[float, float, float], *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.g_vec = tuple(float(x) for x in g_vec)
    if len(self.g_vec) != 3:
        raise ValueError(
            f"UniformGravity: g_vec must be length-3, got {g_vec!r}")

manta.fields.PointMassGravity

PointMassGravity(position, GM, eps=1.0, *, name=None)

Bases: Disturbance

Newtonian gravity from a point mass at a fixed anchor position.

g(p) = -GM · (p - r_src) / |p - r_src|³

Args: position — (x, y, z) source position in WorldFrame, meters. GM — gravitational parameter (G·M), m³/s². For Earth GM ≈ 3.986e14; for the Moon ≈ 4.903e12. eps — softening length to avoid singularity at r→0 (m). Defaults to 1.0 — far below any realistic orbital scale, well above numerical noise.

Source code in manta/fields/gravity.py
def __init__(self,
             position: tuple[float, float, float],
             GM: float,
             eps: float = 1.0, *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.position = tuple(float(x) for x in position)
    self.GM = float(GM)
    self.eps = float(eps)
    if self.GM <= 0.0:
        raise ValueError(
            f"PointMassGravity: GM must be > 0, got {GM!r}")
    # eps == 0 is allowed: exact Newtonian, singular only at the source.
    if self.eps < 0.0:
        raise ValueError(
            f"PointMassGravity: eps must be >= 0, got {eps!r}")

manta.fields.J2Gravity

J2Gravity(position, GM, J2, eq_radius, polar_axis=(0.0, 0.0, 1.0), eps=1.0, *, name=None)

Bases: Disturbance

J2 oblateness perturbation around a point mass.

For an oblate spheroid with equatorial bulge, the gravitational potential acquires a J2 term beyond the point-mass; the corresponding acceleration is

g_J2(r) = -(3/2) · J2 · GM · R_eq² / r⁵ · [
    (1 - 5(ẑ·r̂)²) · r + 2(ẑ·r̂)·|r|·ẑ
]

where ẑ is the polar (spin) axis. This is the perturbation only — add it alongside a PointMassGravity to model the full field.

Args: position — (x, y, z) planet center in WorldFrame, m. GM — gravitational parameter (G·M), m³/s². J2 — dimensionless J2 coefficient. Earth: 1.0826e-3. eq_radius — equatorial radius, m. Earth: 6.378e6. polar_axis — unit polar/spin axis in WorldFrame. Default (0, 0, 1). eps — softening length, m.

Source code in manta/fields/gravity.py
def __init__(self,
             position: tuple[float, float, float],
             GM: float,
             J2: float,
             eq_radius: float,
             polar_axis: tuple[float, float, float] = (0.0, 0.0, 1.0),
             eps: float = 1.0, *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.position = tuple(float(x) for x in position)
    self.GM = float(GM)
    self.J2 = float(J2)
    self.eq_radius = float(eq_radius)
    axis = ca.DM([float(polar_axis[0]),
                  float(polar_axis[1]),
                  float(polar_axis[2])])
    n = float(ca.norm_2(axis))
    if n == 0.0:
        raise ValueError("J2Gravity: polar_axis must be nonzero.")
    self._axis = axis / n
    self.eps = float(eps)
    if self.GM <= 0.0:
        raise ValueError(f"J2Gravity: GM must be > 0, got {GM!r}")
    if self.eq_radius <= 0.0:
        raise ValueError(
            f"J2Gravity: eq_radius must be > 0, got {eq_radius!r}")
    if self.eps < 0.0:
        raise ValueError(f"J2Gravity: eps must be >= 0, got {eps!r}")

manta.fields.BodyPointMassGravity

BodyPointMassGravity(craft, offset_body, GM, eps=1.0, *, name=None)

Bases: Disturbance

Inverse-square gravity from a point mass that RIDES a craft — the field a GravitySource part emits to simulate a massive body (planet, asteroid, station) moving through the sim. Same law as PointMassGravity, but the source position tracks the carrying craft's pose (read from the active trace), not a fixed world point.

Args: craft — the craft carrying the source. offset_body — the source position in the craft's body frame, m. GM — gravitational parameter (G·M), m³/s². eps — softening length, m. Default 1.0.

Source code in manta/fields/gravity.py
def __init__(self, craft, offset_body, GM: float,
             eps: float = 1.0, *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.craft = craft
    self.offset_body = tuple(float(x) for x in offset_body)
    self.GM = float(GM)
    self.eps = float(eps)
    if self.GM <= 0.0:
        raise ValueError(f"BodyPointMassGravity: GM must be > 0, got {GM!r}")
    if self.eps < 0.0:
        raise ValueError(f"BodyPointMassGravity: eps must be >= 0, got {eps!r}")

Fluid

manta.fields.FluidField

FluidField(density=None, velocity=(0.0, 0.0, 0.0), *, pressure=0.0, temperature=0.0, viscosity=None)

Bases: SuperposedField

Fluid density + pressure + temperature + bulk velocity over the world frame.

The field value at a point is a FluidState. Concrete sources are added as Disturbance subclasses with a combining role (see the module docstring): baseline regime media, averaged estimation overlays, and additive perturbations.

The add_uniform builder returns self for chaining; other disturbances attach via field.add(CurrentFlow(...)). The constructor accepts density=/pressure=/temperature= for the common single-uniform case — equivalent to one add_uniform baseline.

Source code in manta/fields/fluid.py
def __init__(self,
             density: float | None = None,
             velocity: tuple[float, float, float] = (0.0, 0.0, 0.0),
             *,
             pressure: float = 0.0,
             temperature: float = 0.0,
             viscosity: float | None = None,
             ) -> None:
    super().__init__()
    if density is not None:
        self.add_uniform(density, velocity,
                         pressure=pressure, temperature=temperature,
                         viscosity=viscosity)

value_at_sym

value_at_sym(point, t)

Combine every registered disturbance at point/t into one FluidState, per the baseline / averaged / additive rule set (module docstring). Returns the zero value if none are registered.

Source code in manta/fields/fluid.py
def value_at_sym(self, point: Vec3, t) -> FluidState:
    """Combine every registered disturbance at `point`/`t` into one
    `FluidState`, per the baseline / averaged / additive rule set
    (module docstring). Returns the zero value if none are
    registered.
    """
    if not self._disturbances:
        return self._zero_value()

    buckets: dict[str, list] = {
        "baseline": [], "averaged": [], "additive": []}
    for d in self._disturbances:
        if d.combining not in buckets:
            # Construction validates `combining`, but an instance
            # mutated afterwards would otherwise just vanish from
            # every bucket — silent physics loss.
            raise ValueError(
                f"{type(self).__name__}: disturbance {d.name!r} has "
                f"unknown combining mode {d.combining!r} (must be "
                f"'additive', 'averaged', or 'baseline').")
        buckets[d.combining].append(d)
    baselines = buckets["baseline"]
    averaged = buckets["averaged"]
    additive = buckets["additive"]

    # Baseline regimes: layered alpha-composite override in insertion
    # order. base ← (1 − w)·base + w·contribution.
    base = self._zero_value()
    for d in baselines:
        w = d.membership(point, t)
        c = d.contribute_at_sym(point, t)
        base = base.scaled(1.0 - w) + c.scaled(w)

    # Averaged overlays: membership-weighted self-mean with a
    # smoothly SATURATED denominator. The naive `num / (den + ε)`
    # defeats the membership ramp: for a single bubble the weight
    # cancels out of its own mean (w·v / (w + ε) ≈ v anywhere
    # w ≳ ε — full-strength wind right up to the fringe), and the
    # derivative of w/(w+ε) at w → 0 is w′/ε — the C¹ boundary a
    # CraftWindBubble promises turns into a ~1/ε-amplified
    # near-step in every Jacobian the EKF/LQR consumes. Instead,
    # divide by `smooth_max0(den − 1) + 1`: at coverage den ≥ 1 the
    # denominator is den (exact overlap mean — two full bubbles
    # agree on their average), while at den < 1 it saturates at 1,
    # so the value decays as Σ wᵢ·vᵢ (≈ v·w at a lone fringe — the
    # true C¹ ramp) and the derivative stays bounded by the
    # membership ramp's own slope w′, ε-free. The transition is C^∞
    # (see `smooth_max0`); _COVERAGE_EPS_SQ only rounds the kink at
    # den = 1.
    avg = self._zero_value()
    if averaged:
        num = self._zero_value()
        den = ca.MX(0.0)
        for d in averaged:
            w = d.membership(point, t)
            num = num + d.contribute_at_sym(point, t).scaled(w)
            den = den + w
        den_sat = smooth_max0(den - 1.0, _COVERAGE_EPS_SQ) + 1.0
        avg = num.scaled(1.0 / den_sat)

    # Additive perturbations: membership-weighted sum on top.
    pert = self._zero_value()
    for d in additive:
        w = d.membership(point, t)
        pert = pert + d.contribute_at_sym(point, t).scaled(w)

    return base + avg + pert

add_flat_ocean

add_flat_ocean(*, density=1025.0, surface_z=0.0, surface_pressure=101325.0, gravity=9.80665, temperature=288.15, viscosity=0.00135, velocity=(0.0, 0.0, 0.0), surface_blend=0.05)

Attach a flat hydrostatic ocean below surface_z. Returns self. See FlatOcean for what it is and is not.

Source code in manta/fields/fluid.py
def add_flat_ocean(self,
                   *,
                   density: float = 1025.0,
                   surface_z: float = 0.0,
                   surface_pressure: float = 101325.0,
                   gravity: float = 9.80665,
                   temperature: float = 288.15,
                   viscosity: float = 1.35e-3,
                   velocity: tuple[float, float, float] = (0.0, 0.0, 0.0),
                   surface_blend: float = 0.05,
                   ) -> FluidField:
    """Attach a flat hydrostatic ocean below `surface_z`. Returns
    self. See `FlatOcean` for what it is and is not."""
    return self.add(FlatOcean(
        density=density, surface_z=surface_z,
        surface_pressure=surface_pressure, gravity=gravity,
        temperature=temperature, viscosity=viscosity,
        velocity=velocity, surface_blend=surface_blend))

add_uniform

add_uniform(density, velocity=(0.0, 0.0, 0.0), *, pressure=0.0, temperature=0.0, viscosity=None)

Attach a uniform baseline medium (density + optional pressure, temperature, viscosity, flow). Viscosity defaults to Sutherland's law for air; pass it explicitly for a liquid. Returns self.

Source code in manta/fields/fluid.py
def add_uniform(self,
                density: float,
                velocity: tuple[float, float, float] = (0.0, 0.0, 0.0),
                *,
                pressure: float = 0.0,
                temperature: float = 0.0,
                viscosity: float | None = None,
                ) -> FluidField:
    """Attach a uniform baseline medium (density + optional pressure,
    temperature, viscosity, flow). Viscosity defaults to Sutherland's
    law for air; pass it explicitly for a liquid. Returns self."""
    return self.add(UniformFluid(density, velocity,
                                 pressure=pressure,
                                 temperature=temperature,
                                 viscosity=viscosity))

manta.fields.FluidState dataclass

FluidState(density, pressure, temperature, viscosity, velocity, material_acceleration=None, angular_velocity=None)

Local fluid properties at a world-frame point.

The required fields retain their historical order: density, pressure, temperature, viscosity, velocity. Optional fluid-kinematics vectors follow them so existing user-authored disturbances remain source compatible.

density — kg/m³. CasADi-MX scalar (composes with symbolic state). pressure — Pa. MX scalar. temperature — K. MX scalar. viscosity — dynamic viscosity μ, Pa·s. MX scalar. An independent property (like density): a gas baseline fills it from temperature via Sutherland's law, while water sets it directly. Drives the Reynolds number a foil sees. Perturbation/overlay disturbances that carry no viscosity pass ca.MX(0.0). velocity — bulk fluid velocity at the point, Vec3[WorldFrame]. material_acceleration — optional inertial material acceleration of the bulk fluid, Vec3[WorldFrame]. Static world-frame fluids leave it unset (equivalent to zero); rotating-planet fluids provide it for pressure-force equilibrium. angular_velocity — optional local bulk-fluid angular velocity, equal to half the velocity-field curl. Rotating-planet fluids use it to make added rotational inertia relative to the water rather than to an arbitrary inertial frame.

Disturbances and FluidField.value_at_sym return / consume this type. __add__ (per-component sum) backs the additive pool; scaled (per-component scalar multiply) backs the membership weighting in the baseline / averaged blends.

scaled

scaled(s)

This state with every component multiplied by the MX (or float) scalar s — the membership weight in the blends.

Source code in manta/fields/fluid.py
def scaled(self, s) -> FluidState:
    """This state with every component multiplied by the MX (or
    float) scalar `s` — the membership weight in the blends."""
    return FluidState(
        density     = s * self.density,
        pressure    = s * self.pressure,
        temperature = s * self.temperature,
        viscosity   = s * self.viscosity,
        velocity    = _VEC3_W.from_mx(s * self.velocity._mx),
        material_acceleration=(
            None
            if self.material_acceleration is None
            else _VEC3_W.from_mx(s * self.material_acceleration._mx)
        ),
        angular_velocity=(
            None
            if self.angular_velocity is None
            else _VEC3_W.from_mx(s * self.angular_velocity._mx)
        ),
    )

manta.fields.UniformFluid

UniformFluid(density, velocity=(0.0, 0.0, 0.0), *, pressure=0.0, temperature=0.0, viscosity=None, name=None, combining=None, membership=None)

Bases: Disturbance

Position-independent baseline medium: constant density (+ optional pressure, temperature, flow).

A baseline regime — where it is active (its membership, global by default) it defines the ambient fluid rather than adding to it.

Args: density — kg/m³. Common: ~1.225 (air), ~1025 (seawater), ~1000 (fresh water). velocity — bulk flow vector in WorldFrame, m/s. Default zero. pressure — Pa. Default 0 (unset). temperature — K. Default 0 (unset). viscosity — dynamic viscosity μ, Pa·s. Default None → filled from Sutherland's law for air at temperature (or ISA sea level if temperature is unset), giving ~1.79e-5 for a bare air medium. Liquids and exotic gases pass μ explicitly (seawater ≈ 1.35e-3).

Source code in manta/fields/fluid.py
def __init__(self,
             density: float,
             velocity: tuple[float, float, float] = (0.0, 0.0, 0.0),
             *,
             pressure: float = 0.0,
             temperature: float = 0.0,
             viscosity: float | None = None,
             name: str | None = None,
             combining: str | None = None,
             membership=None) -> None:
    super().__init__(name=name, combining=combining, membership=membership)
    self.density     = float(density)
    self.pressure    = float(pressure)
    self.temperature = float(temperature)
    self.velocity    = tuple(float(x) for x in velocity)
    if self.density < 0.0:
        raise ValueError(
            f"UniformFluid: density must be >= 0, got {density!r}")
    if len(self.velocity) != 3:
        raise ValueError(
            f"UniformFluid: velocity must be length-3, got {velocity!r}")
    # Default-fill viscosity from Sutherland(T) for air (the fluid-
    # column physics lives beside us in `fluid_props`).
    if viscosity is None:
        T = self.temperature if self.temperature > 0.0 else T0_ISA
        viscosity = float(sutherland_viscosity(T))
    self.viscosity = float(viscosity)
    if self.viscosity < 0.0:
        raise ValueError(
            f"UniformFluid: viscosity must be >= 0, got {viscosity!r}")

manta.fields.CurrentFlow

CurrentFlow(velocity, *, name=None, combining=None, membership=None)

Bases: Disturbance

Localized current — an additive velocity perturbation that leaves density / pressure / temperature untouched.

v1 ships the simplest non-spatial model: a constant velocity contribution everywhere (bound it with a membership= for a pocket). Future versions will accept a Gaussian envelope or a tabulated map.

Args: velocity — world-frame velocity contribution, m/s.

Source code in manta/fields/fluid.py
def __init__(self,
             velocity: tuple[float, float, float],
             *,
             name: str | None = None,
             combining: str | None = None,
             membership=None) -> None:
    super().__init__(name=name, combining=combining, membership=membership)
    self.velocity = tuple(float(x) for x in velocity)
    if len(self.velocity) != 3:
        raise ValueError(
            f"CurrentFlow: velocity must be length-3, got {velocity!r}")

manta.fields.WeatherPatch

WeatherPatch(*, temperature=0.0, pressure=0.0, density=0.0, name=None, combining=None, membership=None)

Bases: Disturbance

Local thermodynamic perturbation — additive temperature / pressure (and optional density) deltas layered on top of the ambient regime.

The planet's Atmosphere / Ocean baseline already gives a sane average (T, P, ρ) everywhere; a WeatherPatch is how a user paints a custom LOCAL curve over it — a warm thermal, a low-pressure cell, a surface inversion — without touching the baseline. Bind it to a region with membership= (e.g. within_sphere(...)); the default is global.

Each of temperature (K), pressure (Pa) and density (kg/m³) is either a constant or a callable (point: Vec3[WorldFrame], t) -> ca.MX for a position/time-varying field — so a curve is just a Python function of the query point. They are deltas: they add to whatever the baseline (and any other patches) already report.

Note: this perturbs the (T, P, ρ) components independently — it does NOT re-impose the ideal-gas tie between them. That is deliberate (the user is authoring the curve they want); pass whichever components you care about and leave the rest at 0.

Args: temperature — K delta. Constant or (point, t) -> MX. Default 0. pressure — Pa delta. Constant or (point, t) -> MX. Default 0. density — kg/m³ delta. Constant or (point, t) -> MX. Default 0.

Source code in manta/fields/fluid.py
def __init__(self,
             *,
             temperature=0.0,
             pressure=0.0,
             density=0.0,
             name: str | None = None,
             combining: str | None = None,
             membership=None) -> None:
    super().__init__(name=name, combining=combining, membership=membership)
    self.temperature = temperature
    self.pressure    = pressure
    self.density     = density

manta.fields.CraftWindBubble

CraftWindBubble(craft, radius=20.0, sigma=0.001, *, boundary=0.0, name=None, combining=None)

Bases: Disturbance

A localized wind contribution anchored to a craft.

Density / pressure / temperature contributions are zero (wind only moves the air). The wind vector is the disturbance's value everywhere; its spatial confinement to the bubble (where |point - craft.position| < radius) is the membership — a smooth sphere indicator (1 inside, 0 outside, blended over boundary).

combining="averaged" keeps the estimation design intent: the field folds all averaged disturbances into a membership-weighted mean, so where two crafts' bubbles overlap their wind estimates agree on the weighted average (rather than summing).

The wind itself is a RandomWalkNoise channel — the framework synthesizes a state slot named <bubble.name>.wind and an RW driver, evolving the wind via wind_next = wind + sqrt(dt)·driver.

Args: craft — owning Craft. The bubble follows the craft's WorldFrame position symbolically (read from the active trace's bindings). radius — bubble radius in meters; membership is 1 well inside. sigma — RW drift density σ/√Hz for the wind. Larger ⇒ EKF expects more drift in the wind estimate. boundary — width (m) of the smooth membership shell at the radius. 0 (default) is a hard cutoff; >0 gives a C¹ ramp (healthier Jacobians for a craft crossing it).

Source code in manta/fields/wind_bubble.py
def __init__(self,
             craft,
             radius: float = 20.0,
             sigma: float = 1e-3, *,
             boundary: float = 0.0,
             name: str | None = None,
             combining: str | None = None) -> None:
    super().__init__(name=name or f"{craft.name}_wind",
                     combining=combining,
                     wind_sigma=sigma)
    self.craft    = craft
    self.radius   = float(radius)
    self.boundary = float(boundary)

Magnetic

manta.fields.MagField

MagField(B=None)

Bases: SuperposedField

Magnetic flux density field, Vec3[WorldFrame] in Tesla.

The add_uniform builder returns self for chaining; other disturbances attach via field.add(DipoleMag(...)). The constructor accepts B=(bx,by,bz) for the common single-uniform case — equivalent to one add_uniform.

Source code in manta/fields/mag.py
def __init__(self, B: tuple[float, float, float] | None = None) -> None:
    super().__init__()
    if B is not None:
        self.add_uniform(B)

add_uniform

add_uniform(B_vec)

Attach a position-independent magnetic field. Returns self.

Source code in manta/fields/mag.py
def add_uniform(self, B_vec: tuple[float, float, float]) -> MagField:
    """Attach a position-independent magnetic field. Returns self."""
    return self.add(UniformMag(B_vec))

manta.fields.UniformMag

UniformMag(B_vec, *, name=None)

Bases: Disturbance

Position-independent magnetic field.

For a quick first-order Earth-field model, common values (in Tesla): * Equator ≈ 30 µT — (3e-5, 0, 0) horizontal * Mid-lat ≈ 50 µT — (2e-5, 0, -4.5e-5) inclined * Pole ≈ 60 µT — (0, 0, -6e-5)

Source code in manta/fields/mag.py
def __init__(self, B_vec: tuple[float, float, float], *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.B_vec = tuple(float(x) for x in B_vec)
    if len(self.B_vec) != 3:
        raise ValueError(
            f"UniformMag: B_vec must be length-3, got {B_vec!r}")

manta.fields.DipoleMag

DipoleMag(position, moment, eps=0.001, *, name=None)

Bases: Disturbance

Point magnetic dipole.

B(p) = μ₀/(4π) · [3(m·r̂)·r̂ − m] / r³

where r = p − r_src, r̂ = r/|r|.

Args: position — (x, y, z) dipole position in WorldFrame, m. moment — (mx, my, mz) magnetic dipole moment in WorldFrame, A·m². For Earth this is ~8e22 (purely fictitious point-dipole approximation). eps — softening length (m) to avoid singularity at the dipole position. Default 1e-3.

Source code in manta/fields/mag.py
def __init__(self,
             position: tuple[float, float, float],
             moment: tuple[float, float, float],
             eps: float = 1e-3, *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.position = tuple(float(x) for x in position)
    self.moment   = tuple(float(x) for x in moment)
    self.eps      = float(eps)

manta.fields.BodyDipoleMag

BodyDipoleMag(craft, offset_body, moment_body, eps=0.001, *, name=None)

Bases: Disturbance

A magnetic dipole that RIDES a craft — the field a MagneticSource part emits to model the magnetic signature of motors, magnets, or magnetized structure on a moving vehicle. Same law as DipoleMag, but BOTH the dipole position and its moment vector are body-fixed: the position tracks the craft and the moment rotates with it (read from the active trace), so a magnetometer on another craft sees the field swing as the source turns.

Args: craft — the craft carrying the source. offset_body — dipole position in the craft body frame, m. moment_body — dipole moment in the craft body frame, A·m². eps — softening length, m. Default 1e-3.

Source code in manta/fields/mag.py
def __init__(self, craft, offset_body,
             moment_body: tuple[float, float, float],
             eps: float = 1e-3, *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.craft = craft
    self.offset_body = tuple(float(x) for x in offset_body)
    self.moment_body = tuple(float(x) for x in moment_body)
    if len(self.moment_body) != 3:
        raise ValueError(
            f"BodyDipoleMag: moment_body must be length-3, "
            f"got {moment_body!r}")
    self.eps = float(eps)

Collision

manta.fields.CollisionField

CollisionField()

Bases: SuperposedField

Outward-penetration vector field for contact detection.

Per the Field-base pattern, every registered Disturbance is an obstacle shape that contributes its own penetration vector when the query point is inside it. Multi-obstacle overlap composes additively.

Source code in manta/fields/base.py
def __init__(self) -> None:
    self._disturbances: list[Disturbance] = []

add_half_space

add_half_space(origin=(0.0, 0.0, 0.0), normal=(0.0, 0.0, 1.0))

Attach a half-space obstacle (infinite ground plane / wall). Returns self.

Source code in manta/fields/collision.py
def add_half_space(self,
                   origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
                   normal: tuple[float, float, float] = (0.0, 0.0, 1.0)
                   ) -> CollisionField:
    """Attach a half-space obstacle (infinite ground plane / wall).
    Returns self."""
    return self.add(HalfSpace(origin=origin, normal=normal))

add_sphere

add_sphere(center, radius)

Attach a solid-sphere obstacle (e.g. a planet surface). Returns self.

Source code in manta/fields/collision.py
def add_sphere(self,
               center: tuple[float, float, float],
               radius: float) -> CollisionField:
    """Attach a solid-sphere obstacle (e.g. a planet surface).
    Returns self."""
    return self.add(Sphere(center=center, radius=radius))

add_ellipsoid

add_ellipsoid(center, equatorial_radius, flattening, polar_axis=(0.0, 0.0, 1.0), *, height=0.0)

Attach a solid oblate-spheroid obstacle (a WGS-84 Earth). Returns the Ellipsoid (not self) so the caller can reuse its signed height for other surface-relative queries.

Source code in manta/fields/collision.py
def add_ellipsoid(self,
                  center: tuple[float, float, float],
                  equatorial_radius: float,
                  flattening: float,
                  polar_axis: tuple[float, float, float] = (0.0, 0.0, 1.0),
                  *, height: float = 0.0) -> Ellipsoid:
    """Attach a solid oblate-spheroid obstacle (a WGS-84 Earth).
    Returns the `Ellipsoid` (not self) so the caller can reuse its
    signed height for other surface-relative queries."""
    el = Ellipsoid(center=center, equatorial_radius=equatorial_radius,
                   flattening=flattening, polar_axis=polar_axis,
                   height=height)
    self.add(el)
    return el

add_heightfield

add_heightfield(heights, *, x0=0.0, y0=0.0, dx=1.0, dy=1.0)

Attach gridded solid terrain z = h(x, y) (bathymetry, a ground DEM). Returns the Heightfield (not self) so the caller can keep it for height_at queries.

Source code in manta/fields/collision.py
def add_heightfield(self, heights, *,
                    x0: float = 0.0, y0: float = 0.0,
                    dx: float = 1.0, dy: float = 1.0
                    ) -> Heightfield:
    """Attach gridded solid terrain `z = h(x, y)` (bathymetry, a
    ground DEM). Returns the `Heightfield` (not self) so the caller
    can keep it for `height_at` queries."""
    hf = Heightfield(heights, x0=x0, y0=y0, dx=dx, dy=dy)
    self.add(hf)
    return hf

manta.fields.HalfSpace

HalfSpace(origin=(0.0, 0.0, 0.0), normal=(0.0, 0.0, 1.0), *, name=None)

Bases: Disturbance

Infinite half-space below a plane.

The plane is defined by an origin point on it and an outward normal. Points where (p − origin) · normal < 0 are inside the obstacle (below the plane); the outward direction is +normal.

Args: origin — point on the plane (world frame), m. normal — outward unit normal (world frame). For a ground plane at z=0 with air above and solid below: origin=(0,0,0), normal=(0,0,1).

Source code in manta/fields/collision.py
def __init__(self,
             origin: tuple[float, float, float] = (0.0, 0.0, 0.0),
             normal: tuple[float, float, float] = (0.0, 0.0, 1.0),
             *, name: str | None = None) -> None:
    from ..parts._declarations import unit_axis
    super().__init__(name=name)
    self.origin = tuple(float(x) for x in origin)
    if len(self.origin) != 3:
        raise ValueError(
            f"HalfSpace: origin must be length-3; got {origin!r}")
    # The penetration math assumes a unit normal (a non-unit one
    # would scale the response by |normal|²) — normalize at
    # construction, same convention as part axes.
    self.normal = unit_axis(normal, who="HalfSpace", what="normal")

manta.fields.Sphere

Sphere(center, radius, *, name=None)

Bases: Disturbance

Solid sphere obstacle — e.g. a whole planet's surface.

Points with |p − center| < radius are inside; the outward direction is the local radial, so a craft standing anywhere on the sphere gets an up-is-outward contact normal — no per-site ground plane needed.

Args: center — sphere centre (world frame), m. radius — sphere radius, m.

Source code in manta/fields/collision.py
def __init__(self,
             center: tuple[float, float, float],
             radius: float,
             *, name: str | None = None) -> None:
    super().__init__(name=name)
    self.center = tuple(float(x) for x in center)
    self.radius = float(radius)
    if len(self.center) != 3:
        raise ValueError(
            f"Sphere: center must be length-3; got {center!r}")
    if self.radius <= 0.0:
        raise ValueError(f"Sphere: radius must be > 0; got {radius!r}")

manta.fields.Ellipsoid

Ellipsoid(center, equatorial_radius, flattening, polar_axis=(0.0, 0.0, 1.0), *, height=0.0, name=None)

Bases: Disturbance

Solid oblate spheroid — a planet with an equatorial bulge, e.g. the WGS-84 Earth (flattening = 1/298.257…).

Points at negative signed height are inside; the outward direction is the ellipsoid normal (the direction gravity + centrifugal force hangs a plumb line along on a planet in hydrostatic balance), so a craft standing anywhere on the surface gets the same "up" the planet's local_tangent_basis uses. The spheroid is symmetric about polar_axis, so it is the same shape whether the planet spins beneath it or not — a world-fixed obstacle serves a rotating planet.

signed_height_sym is the reusable symbolic core: Cartesian signed height and outward normal. signed_height is its numeric counterpart. Earth uses the same geometry for the sea surface (fluid membership, hydrostatic column, wave orbital direction) so the water line and the solid surface are one geometry. The numeric and symbolic forms are cross-checked in the tests.

Args: center — spheroid centre (world frame), m. equatorial_radius — semi-major axis a, m. flattening — (a − b)/a; 0 is a sphere. polar_axis — symmetry (spin) axis, world frame. height — m; the solid surface sits this far above the reference spheroid along its outward normal (a mean-sea-level offset). Default 0.

Source code in manta/fields/collision.py
def __init__(self,
             center: tuple[float, float, float],
             equatorial_radius: float,
             flattening: float,
             polar_axis: tuple[float, float, float] = (0.0, 0.0, 1.0),
             *, height: float = 0.0, name: str | None = None) -> None:
    from ..parts._declarations import unit_axis
    super().__init__(name=name)
    self.center = tuple(float(x) for x in center)
    if len(self.center) != 3:
        raise ValueError(
            f"Ellipsoid: center must be length-3; got {center!r}")
    self.equatorial_radius = float(equatorial_radius)
    if self.equatorial_radius <= 0.0:
        raise ValueError(
            f"Ellipsoid: equatorial_radius must be > 0; got "
            f"{equatorial_radius!r}")
    self.flattening = float(flattening)
    if not (0.0 <= self.flattening < 1.0):
        raise ValueError(
            f"Ellipsoid: flattening must be in [0, 1); got {flattening!r}")
    self.polar_axis = unit_axis(polar_axis, who="Ellipsoid",
                                what="polar_axis")
    self.height = float(height)
    self._axis_dm = ca.DM(list(self.polar_axis))

signed_height

signed_height(r)

Numeric Cartesian (signed height, outward unit normal).

r is an offset from the ellipsoid centre in the same axes as polar_axis. This is surface geometry, not an LLA conversion: no longitude, datum, or coordinate-system convention is introduced.

Source code in manta/fields/collision.py
def signed_height(self, r: np.ndarray) -> tuple[float, np.ndarray]:
    """Numeric Cartesian ``(signed height, outward unit normal)``.

    ``r`` is an offset from the ellipsoid centre in the same axes as
    ``polar_axis``. This is surface geometry, not an LLA conversion: no
    longitude, datum, or coordinate-system convention is introduced.
    """
    offset = np.asarray(r, dtype=float)
    if offset.shape != (3,) or not np.isfinite(offset).all():
        raise ValueError("Ellipsoid.signed_height needs a finite 3-vector")
    if float(np.linalg.norm(offset)) == 0.0:
        raise ValueError("Ellipsoid.signed_height is undefined at its centre")
    a = self.equatorial_radius
    f = self.flattening
    e2 = f * (2.0 - f)
    b = a * (1.0 - f)
    ep2 = e2 / (1.0 - e2)
    axis = np.asarray(self.polar_axis, dtype=float)
    z = float(offset @ axis)
    rho_vector = offset - z * axis
    rho = float(np.linalg.norm(rho_vector))
    beta = np.arctan2(a * z, b * rho)
    latitude = np.arctan2(
        z + ep2 * b * np.sin(beta) ** 3,
        rho - e2 * a * np.cos(beta) ** 3,
    )
    beta = np.arctan2(
        (1.0 - f) * np.sin(latitude), np.cos(latitude)
    )
    latitude = np.arctan2(
        z + ep2 * b * np.sin(beta) ** 3,
        rho - e2 * a * np.cos(beta) ** 3,
    )
    sin_latitude = np.sin(latitude)
    cos_latitude = np.cos(latitude)
    signed = (
        rho * cos_latitude
        + z * sin_latitude
        - a * np.sqrt(1.0 - e2 * sin_latitude * sin_latitude)
        - self.height
    )
    radial = rho_vector / rho if rho > 0.0 else np.zeros(3)
    normal = cos_latitude * radial + sin_latitude * axis
    normal_norm = float(np.linalg.norm(normal))
    if normal_norm == 0.0:
        raise ValueError("Ellipsoid.signed_height is undefined at its centre")
    normal /= normal_norm
    return float(signed), normal

signed_height_sym

signed_height_sym(r_mx)

(height, up) for an offset r_mx (3×1 MX) from the centre — signed height above the surface (negative inside; the height offset already subtracted) and the outward unit normal, both in the frame r_mx is expressed in. That frame must share the polar_axis coordinates (true for the world frame and for any frame rotated about the axis, e.g. a planet's body frame).

Source code in manta/fields/collision.py
def signed_height_sym(self, r_mx: ca.MX) -> tuple[ca.MX, ca.MX]:
    """`(height, up)` for an offset `r_mx` (3×1 MX) from the centre —
    signed height above the surface (negative inside; the `height`
    offset already subtracted) and the outward unit normal, both
    in the frame `r_mx` is expressed in. That frame must share the
    `polar_axis` coordinates (true for the world frame and for any
    frame rotated about the axis, e.g. a planet's body frame)."""
    a = self.equatorial_radius
    f = self.flattening
    e2 = f * (2.0 - f)
    b = a * (1.0 - f)
    ep2 = e2 / (1.0 - e2)
    axis = self._axis_dm
    z = ca.dot(r_mx, axis)
    rho_vec = r_mx - z * axis
    rho = soft_norm(rho_vec)
    beta = ca.atan2(a * z, b * rho)
    lat = ca.atan2(z + ep2 * b * ca.sin(beta) ** 3,
                   rho - e2 * a * ca.cos(beta) ** 3)
    beta = ca.atan2((1.0 - f) * ca.sin(lat), ca.cos(lat))
    lat = ca.atan2(z + ep2 * b * ca.sin(beta) ** 3,
                   rho - e2 * a * ca.cos(beta) ** 3)
    sin_lat, cos_lat = ca.sin(lat), ca.cos(lat)
    h = (rho * cos_lat + z * sin_lat
         - a * ca.sqrt(1.0 - e2 * sin_lat * sin_lat)) - self.height
    # e_rho = rho_vec / rho vanishes smoothly on the axis, where
    # cos(lat) → 0 anyway, so `up` stays a unit vector there.
    up = cos_lat * (rho_vec / rho) + sin_lat * axis
    return h, up

Optical

manta.fields.OpticalField

OpticalField()

Bases: Field

A scene of semantic ellipsoids. Carries (does not sum) its disturbances; the camera part enumerates ellipsoids and projects each quadric to an image-frame bounding box.

Source code in manta/fields/base.py
def __init__(self) -> None:
    self._disturbances: list[Disturbance] = []

ellipsoids property

ellipsoids

Every registered ellipsoid (scenery + body-anchored).

add_ellipsoid

add_ellipsoid(center, semi_axes, *, orientation=(1.0, 0.0, 0.0, 0.0), label=0)

Attach a fixed scenery ellipsoid. Returns self for chaining.

Source code in manta/fields/optical.py
def add_ellipsoid(self, center, semi_axes, *,
                  orientation=(1.0, 0.0, 0.0, 0.0),
                  label: int = 0) -> OpticalField:
    """Attach a fixed scenery ellipsoid. Returns self for chaining."""
    return self.add(SemanticEllipsoid(
        center, semi_axes, orientation=orientation, label=label))

manta.fields.SemanticEllipsoid

SemanticEllipsoid(center, semi_axes, *, orientation=(1.0, 0.0, 0.0, 0.0), label=0, name=None)

Bases: _EllipsoidBase

A fixed semantic ellipsoid in the world (a landmark / scenery).

Args: center — (x, y, z) world position of the ellipsoid center, m. semi_axes — (a, b, c) half-extents along the ellipsoid's own axes, m. orientation— wxyz quaternion (world ← ellipsoid). Default upright. label — integer class id the camera reports with the box.

Source code in manta/fields/optical.py
def __init__(self, center, semi_axes, *,
             orientation=(1.0, 0.0, 0.0, 0.0),
             label: int = 0, name: str | None = None) -> None:
    super().__init__(name=name)
    self.center = tuple(float(x) for x in center)
    self.semi_axes = tuple(float(x) for x in semi_axes)
    self._Lambda = _diag_shape_mx(self.semi_axes)
    q = ca.DM([float(x) for x in orientation])
    self._R = quat_to_rotmat(q / ca.norm_2(q))       # world ← body
    self.label = int(label)

manta.fields.BodySemanticEllipsoid

BodySemanticEllipsoid(craft, offset_body, semi_axes, *, label=0, name=None)

Bases: _EllipsoidBase

A semantic ellipsoid that rides a craft (emitted by an OpticalSource). Center and orientation track the carrying craft's pose, read from the active trace; the semi-axes are the body extent.

Source code in manta/fields/optical.py
def __init__(self, craft, offset_body, semi_axes, *,
             label: int = 0, name: str | None = None) -> None:
    super().__init__(name=name)
    self.source_craft = craft
    self.offset_body = tuple(float(x) for x in offset_body)
    self.semi_axes = tuple(float(x) for x in semi_axes)
    self._Lambda = _diag_shape_mx(self.semi_axes)
    self.label = int(label)