Skip to content

Model — World, Craft, Coupling

The declarative layer: build a World, populate it with Crafts and Couplings, then hand it to a transform.

World

manta.World

World(name='world')

Top-level simulation container.

Source code in manta/world.py
def __init__(self, name: str = "world") -> None:
    from .ir.module import check_name
    self.name = check_name(name, who="World")
    # _crafts: list of dicts with craft, initial_state_overrides.
    self._crafts: list[dict[str, Any]] = []
    self._couplings: list[Coupling] = []
    # Fields keyed by exact subclass (one GravityField per world, one
    # FluidField, …). Concrete subclasses query by class.
    self._fields: dict[type, Field] = {}
    # Planets registered with this world. Each one contributes its
    # standing field disturbances at snapshot-resolution time via
    # `planet.register_disturbances(world)`. Multi-planet supported;
    # disturbances superpose into the shared field instances.
    self._planets: list = []

snapshot

snapshot()

Return an independently resolved snapshot of this authoring model.

Deferred registrations and hooks run on a deep copy. If resolution fails, the authoring World remains untouched. Transforms call this automatically and retain the resulting fixed model revision.

Source code in manta/world.py
def snapshot(self) -> World:
    """Return an independently resolved snapshot of this authoring model.

    Deferred registrations and hooks run on a deep copy. If resolution
    fails, the authoring World remains untouched. Transforms call this
    automatically and retain the resulting fixed model revision.
    """
    snapshot = copy.deepcopy(self)
    snapshot._resolve()
    return snapshot

add_field

add_field(field)

Register a Field with this world. One instance per Field subclass is allowed — call field.add(disturbance) on the registered instance to attach more sources.

Returns self for chaining.

Source code in manta/world.py
def add_field(self, field: Field) -> World:
    """Register a Field with this world. One instance per Field
    subclass is allowed — call `field.add(disturbance)` on the
    registered instance to attach more sources.

    Returns self for chaining.
    """
    if not isinstance(field, Field):
        raise TypeError(
            f"World.add_field: expected a Field, got "
            f"{type(field).__name__}")
    from .fields import PlanetBindingField
    if isinstance(field, PlanetBindingField):
        raise ValueError(  # noqa: TRY004 - valid Field, invalid scope
            "PlanetBindingField is craft-scoped; pass its planet as "
            "World.add_craft(..., planet=...) instead of add_field()"
        )
    cls = type(field)
    owner = getattr(field, "_world", None)
    if owner is not None and owner is not self:
        raise ValueError(
            f"World '{self.name}': field {cls.__name__} already belongs "
            f"to World {owner.name!r}")
    if cls in self._fields:
        raise ValueError(
            f"World '{self.name}': field of type {cls.__name__} already "
            f"registered. Use `world.get_field({cls.__name__}).add(...)` "
            f"to attach additional disturbances to it.")
    field._world = self
    self._fields[cls] = field
    return self

get_field

get_field(cls)

Return the registered field of type cls, or None.

Source code in manta/world.py
def get_field(self, cls: type) -> Field | None:
    """Return the registered field of type `cls`, or None."""
    return self._fields.get(cls)

get_or_create_field

get_or_create_field(cls)

Return the registered field of type cls, creating + adding a fresh empty instance if none is registered. Used by Planet.register_disturbances so a planet's contributions land on the same field instance whether or not the user pre-added one.

Source code in manta/world.py
def get_or_create_field(self, cls: type) -> Field:
    """Return the registered field of type `cls`, creating + adding
    a fresh empty instance if none is registered. Used by
    `Planet.register_disturbances` so a planet's contributions land
    on the same field instance whether or not the user pre-added
    one."""
    if cls in self._fields:
        return self._fields[cls]
    instance = cls()
    self.add_field(instance)
    return instance

add_planet

add_planet(planet)

Register a planet with this world. The planet's register_disturbances(world) is called at Sim(world) time, attaching its standing contributions to the world's shared fields. Multi-planet worlds superpose contributions from every registered planet.

Source code in manta/world.py
def add_planet(self, planet) -> World:
    """Register a planet with this world. The planet's
    `register_disturbances(world)` is called at `Sim(world)`
    time, attaching its standing contributions to the world's
    shared fields. Multi-planet worlds superpose contributions
    from every registered planet.
    """
    from .planets.base import Planet
    if not isinstance(planet, Planet):
        raise TypeError(
            f"World.add_planet: expected a Planet, got "
            f"{type(planet).__name__}")
    owner = getattr(planet, "_world", None)
    if owner is not None and owner is not self:
        raise ValueError(
            f"World '{self.name}': planet {planet.name!r} already belongs "
            f"to World {owner.name!r}")
    for existing in self._planets:
        if existing is planet:
            raise ValueError(
                f"World '{self.name}': planet {planet.name!r} already added")
        if existing.name == planet.name:
            raise ValueError(
                f"World '{self.name}': planet name {planet.name!r} "
                f"collides with an existing planet")
    planet._world = self
    self._planets.append(planet)
    return self

add_craft

add_craft(craft, *, position=(0.0, 0.0, 0.0), orientation=(1.0, 0.0, 0.0, 0.0), velocity=(0.0, 0.0, 0.0), angular_velocity=(0.0, 0.0, 0.0), planet=None, **extra_state)

Add a craft to the world.

Args: craft — the Craft instance. position — craft-origin position, WorldFrame (m). orientation — wxyz quaternion, world-from-craft. velocity — craft-origin velocity, WorldFrame (m/s). angular_velocity — body rates in CraftFrame (rad/s) — the same convention as the integrated state (what a strapped-down gyro reads). For a non-identity orientation, world-frame rates must be rotated into the body first. planet — optional exact Planet whose body-fixed Cartesian frame this craft uses. The binding is compile-time context for planet-dependent parts; it does not select physical field contributions. **extra_state — per-part state overrides (e.g., **{"wheel.angle": 0.5}).

Source code in manta/world.py
def add_craft(self,
              craft: Craft,
              *,
              position=(0.0, 0.0, 0.0),
              orientation=(1.0, 0.0, 0.0, 0.0),
              velocity=(0.0, 0.0, 0.0),
              angular_velocity=(0.0, 0.0, 0.0),
              planet=None,
              **extra_state: Any) -> Craft:
    """Add a craft to the world.

    Args:
        craft            — the Craft instance.
        position         — craft-origin position, WorldFrame (m).
        orientation      — wxyz quaternion, world-from-craft.
        velocity         — craft-origin velocity, WorldFrame (m/s).
        angular_velocity — body rates in **CraftFrame** (rad/s) — the
                           same convention as the integrated state
                           (what a strapped-down gyro reads). For a
                           non-identity `orientation`, world-frame
                           rates must be rotated into the body first.
        planet           — optional exact Planet whose body-fixed
                           Cartesian frame this craft uses. The binding is
                           compile-time context for planet-dependent parts;
                           it does not select physical field contributions.
        **extra_state    — per-part state overrides
                           (e.g., `**{"wheel.angle": 0.5}`).
    """
    if not isinstance(craft, Craft):
        raise TypeError(
            f"World.add_craft: expected a Craft, got "
            f"{type(craft).__name__}")
    from .fields import PlanetBindingField
    binding = None if planet is None else PlanetBindingField(planet)
    from .planets.state import PlanetState
    for argument, value, expected_kind in (
            ("position", position, "position"),
            ("velocity", velocity, "velocity")):
        if isinstance(value, PlanetState) and value.kind != expected_kind:
            raise ValueError(
                f"World.add_craft: {argument} received a PlanetState of "
                f"kind {value.kind!r}; use planet.{expected_kind}(...)")
    owner = getattr(craft, "_world", None)
    if owner is not None and owner is not self:
        raise ValueError(
            f"World '{self.name}': craft {craft.name!r} already belongs "
            f"to World {owner.name!r}")
    # Validate uniqueness.
    for entry in self._crafts:
        if entry["craft"] is craft:
            raise ValueError(
                f"World '{self.name}': craft '{craft.name}' already added")
        if entry["craft"].name == craft.name:
            raise ValueError(
                f"World '{self.name}': craft name '{craft.name}' collides "
                f"with an existing craft")

    initial_state_overrides: dict[str, Any] = {
        "position":         position,
        "orientation":      orientation,
        "velocity":         velocity,
        "angular_velocity": angular_velocity,
        **extra_state,
    }
    craft._world = self
    self._crafts.append({
        "craft":  craft,
        "initial_state_overrides": initial_state_overrides,
        "fields": () if binding is None else (binding,),
    })
    return craft

fields_for_craft

fields_for_craft(craft)

Compile-time fields scoped to exactly craft.

Physical fields remain world-scoped. This narrow overlay currently carries only PlanetBindingField and exists so two craft in one world can resolve different planets without a context-wide PlanetFrame.

Source code in manta/world.py
def fields_for_craft(self, craft: Craft) -> tuple[Field, ...]:
    """Compile-time fields scoped to exactly ``craft``.

    Physical fields remain world-scoped.  This narrow overlay currently
    carries only ``PlanetBindingField`` and exists so two craft in one
    world can resolve different planets without a context-wide
    ``PlanetFrame``.
    """
    for entry in self._crafts:
        if entry["craft"] is craft:
            return tuple(entry.get("fields", ()))
    raise KeyError(
        f"World {self.name!r}: craft {getattr(craft, 'name', craft)!r} "
        "is not registered"
    )

add_coupling

add_coupling(coupling)

Add an inter-craft coupling. Both endpoint crafts must already be registered via add_craft. The coupling forces them into the same connected component at compile time → one shared compiled tick over both.

Source code in manta/world.py
def add_coupling(self, coupling: Coupling) -> Coupling:
    """Add an inter-craft coupling. Both endpoint crafts must already
    be registered via `add_craft`. The coupling forces them into the
    same connected component at compile time → one shared compiled
    tick over both."""
    if not isinstance(coupling, Coupling):
        raise TypeError(
            f"World.add_coupling: expected Coupling, got "
            f"{type(coupling).__name__}")
    registered = {id(e["craft"]) for e in self._crafts}
    for c, label in ((coupling.craft_a, "craft_a"),
                      (coupling.craft_b, "craft_b")):
        if id(c) not in registered:
            raise ValueError(
                f"World.add_coupling: coupling.{label} '{c.name}' is "
                f"not registered with this World. Call add_craft first.")
    if coupling.craft_a is coupling.craft_b:
        raise ValueError(
            "World.add_coupling: an inter-craft coupling must reference "
            "two distinct crafts")
    if not hasattr(coupling, "name"):
        raise TypeError(
            f"World.add_coupling: {type(coupling).__name__} did not "
            "initialize Coupling; call super().__init__(name)")
    owner = getattr(coupling, "_world", None)
    if owner is not None and owner is not self:
        raise ValueError(
            f"World '{self.name}': coupling {coupling.name!r} already "
            f"belongs to World {owner.name!r}")
    for existing in self._couplings:
        if existing is coupling:
            raise ValueError(
                f"World '{self.name}': coupling {coupling.name!r} "
                "already added")
        if existing.name == coupling.name:
            raise ValueError(
                f"World '{self.name}': coupling name {coupling.name!r} "
                "collides with an existing coupling")
    coupling._world = self
    self._couplings.append(coupling)
    return coupling

manta.ModelArtifact dataclass

ModelArtifact(name, model_id, artifact_id, state_spec, input_names, sensor_names, parameter_names, validation, _world, _authoring_world, derivation=(lambda: MappingProxyType({}))())

One resolved World revision shared by all artifacts of a transform.

The authoring World is never frozen. Constructing another transform from it captures a new revision, so users may add or remove parts between a Sim, EKF, UKF, or controller build. _world is the transform-owned snapshot and is intentionally not an authoring surface.

validation_id property

validation_id

Identity of the structural validation receipt for this model.

with_derivation

with_derivation(name, report)

Return this physical model with immutable provenance attached.

Source code in manta/model.py
def with_derivation(self, name: str, report: Any) -> ModelArtifact:
    """Return this physical model with immutable provenance attached."""
    if not isinstance(name, str) or not name:
        raise ValueError("derivation name must be a non-empty string")
    base = name
    index = 2
    while name in self.derivation:
        name = f"{base}_{index}"
        index += 1
    derivation = MappingProxyType({**self.derivation, name: report})
    return replace(
        self,
        artifact_id=self._artifact_identity(self.model_id, derivation),
        derivation=derivation,
    )

transform_metadata

transform_metadata(profile)

Canonical provenance every model-derived Module must carry.

Source code in manta/model.py
def transform_metadata(self, profile: Mapping[str, Any]) -> dict[str, Any]:
    """Canonical provenance every model-derived Module must carry."""
    return {
        "source_model_id": self.model_id,
        "source_artifact_id": self.artifact_id,
        "validation_id": self.validation_id,
        "transform_profile": dict(profile),
    }

with_derivations

with_derivations(derivation)

Carry provenance forward onto a newly derived physical model.

Source code in manta/model.py
def with_derivations(
        self, derivation: Mapping[str, Any]) -> ModelArtifact:
    """Carry provenance forward onto a newly derived physical model."""
    if self.derivation:
        raise ValueError("with_derivations requires a fresh model artifact")
    owned = MappingProxyType(dict(derivation))
    return replace(
        self,
        artifact_id=self._artifact_identity(self.model_id, owned),
        derivation=owned,
    )

world_copy

world_copy()

Return an editable authoring copy of this model revision.

Source code in manta/model.py
def world_copy(self):
    """Return an editable authoring copy of this model revision."""
    return copy.deepcopy(self._authoring_world)

manta.ModelValidationReport dataclass

ModelValidationReport(checks, craft_names, coupling_names)

Certificate of structural checks completed before using a model.

Validation failures raise before this report is constructed; valid is consequently always true. This is not a container for failed validation.

Craft

manta.Craft

Craft(name)

A collection of parts with shared rigid-body dynamics.

Internally a craft is a tree of parts rooted at Craft.root (a RootPart). craft.add(part) is sugar for craft.root.add(part); craft.parts returns a flat tuple of all parts in the tree (DFS order). Nested composition (e.g. a joint hosting another joint for a pan-tilt gimbal) is supported via the standard composite add() chain on individual parts.

State (13 DOF): position : Vec3[WorldFrame] orientation : Quat[WorldFrame, CraftFrame] velocity : Vec3[WorldFrame] angular_velocity : Vec3[CraftFrame] plus one Scalar per R1 State slot declared on any of the parts.

Source code in manta/craft.py
def __init__(self, name: str) -> None:
    from .ir.module import check_name
    from .parts.base import RootPart
    self.name = check_name(name, who="Craft")
    self.root = RootPart(f"{name}_root")

parts property

parts

Flat tuple of every part in the tree, root first (DFS order). Excludes the root itself.

total_mass property

total_mass

Sum of the declared mass of every genuinely inertial part (contributes_inertia trait) — gain-like mass parameters (e.g. TrajectoryEndpoint's feedforward) don't count.

add

add(part)

Attach a part to the craft's root. Equivalent to craft.root.add(part).

Source code in manta/craft.py
def add(self, part: Part) -> Part:
    """Attach a part to the craft's root. Equivalent to
    `craft.root.add(part)`."""
    return self.root.add(part)

remove

remove(part)

Detach a part anywhere in this craft's tree.

Existing transforms retain their private model revision. A later transform captures the edited tree.

Source code in manta/craft.py
def remove(self, part: Part | str) -> Part:
    """Detach a part anywhere in this craft's tree.

    Existing transforms retain their private model revision. A later
    transform captures the edited tree.
    """
    match = next(
        (candidate for candidate in self.parts
         if candidate is part
         or (isinstance(part, str) and candidate.name == part)),
        None,
    )
    if match is None:
        label = part if isinstance(part, str) else getattr(part, "name", part)
        raise KeyError(f"Craft('{self.name}').remove: no part {label!r}")
    return match.parent.remove(match)

aggregate_inertials

aggregate_inertials()

Public-facing accessor: see _aggregate_inertials. Useful for external inspection and tests.

Source code in manta/craft.py
def aggregate_inertials(self) -> dict[str, Any]:
    """Public-facing accessor: see `_aggregate_inertials`. Useful for
    external inspection and tests."""
    return _aggregate_inertials(self.root)

sample_noise

sample_noise(rng)

Draw one tick of white-Gaussian samples for every declared Noise slot on every part. Returns a dict of "<part>.<noise>" → np.ndarray ready to merge into the state dict before calling the compiled tick.

This is the model-side draw, keyed by full state name. The running sim does not call it — NumpySim drives noise through the Module's NOISE port with a NoiseDriver. Keep it for hand-driven ticks and for tests that want the per-channel sigmas without a backend.

Slots whose sigma is 0 return zero vectors without consuming RNG state (so a deterministic-seed sim stays reproducible regardless of which noise channels are active).

Source code in manta/craft.py
def sample_noise(self, rng) -> dict:
    """Draw one tick of white-Gaussian samples for every declared
    `Noise` slot on every part. Returns a dict of
    `"<part>.<noise>" → np.ndarray` ready to merge into the state
    dict before calling the compiled tick.

    This is the *model-side* draw, keyed by full state name. The
    running sim does not call it — `NumpySim` drives noise through
    the Module's NOISE port with a `NoiseDriver`. Keep it for
    hand-driven ticks and for tests that want the per-channel
    sigmas without a backend.

    Slots whose sigma is 0 return zero vectors without consuming
    RNG state (so a deterministic-seed sim stays reproducible
    regardless of which noise channels are active).
    """
    out: dict[str, Any] = {}
    for part in self.parts:
        for nname, ndecl in part.noise_declarations().items():
            sigma = float(getattr(part, f"{nname}_sigma"))
            # Inert RW channels skip RNG entirely; everyone else
            # samples into the channel's driver-input name.
            if ndecl.contributes_state and sigma <= 0.0:
                continue
            key = f"{part.name}.{ndecl.driver_input_name(nname)}"
            d = ndecl.signal_manifold.ambient_dim
            if d == 1:
                out[key] = (rng.normal(0.0, sigma)
                            if sigma > 0.0 else 0.0)
            else:
                out[key] = (rng.normal(0.0, sigma, d)
                            if sigma > 0.0
                            else np.zeros(d, dtype=float))
    return out

initial_state

initial_state(**overrides)

Build the initial state dict for the compiled tick.

Returns a dict with the rigid-body slots (position, orientation, velocity, angular_velocity) AND a "<part_name>.<state_name>" entry for every part that declares state. Defaults come from each State declaration's init; keyword overrides replace them by name.

Source code in manta/craft.py
def initial_state(self, **overrides) -> dict:
    """Build the initial state dict for the compiled tick.

    Returns a dict with the rigid-body slots (position, orientation,
    velocity, angular_velocity) AND a `"<part_name>.<state_name>"`
    entry for every part that declares state. Defaults come from each
    State declaration's `init`; keyword overrides replace them by name.
    """
    state: dict[str, Any] = {
        "position":         np.asarray((0.0, 0.0, 0.0), dtype=float),
        "orientation":      np.asarray((1.0, 0.0, 0.0, 0.0), dtype=float),
        "velocity":         np.asarray((0.0, 0.0, 0.0), dtype=float),
        "angular_velocity": np.asarray((0.0, 0.0, 0.0), dtype=float),
    }
    for part in self.parts:
        for sname, sdecl in part.state_declarations().items():
            if sdecl.manifold.kind == "scalar":
                state[f"{part.name}.{sname}"] = float(sdecl.init)
            else:
                # vec / quat — `init` is a fixed-length tuple
                # validated at declaration time. Store as ndarray
                # for symmetry with rigid-body slots.
                state[f"{part.name}.{sname}"] = np.asarray(
                    sdecl.init, dtype=float)
        # Input slots: seed from the part's current attribute (which
        # is either the constructor-time override or the declaration
        # default). These pass through Sim.step's merge so
        # the user can update them per-tick or leave them alone.
        for iname in part.input_declarations():
            state[f"{part.name}.{iname}"] = float(getattr(part, iname))
        # Noise / RW-bias slots. Seed everything at zero.
        #   * White: one slot `<part>.<nname>` (the per-tick driver).
        #     EKF leaves it at zero; `NumpySim` overwrites it from an
        #     attached `NoiseDriver` (see `codegen/numpy/_noise.py`).
        #   * RW (sigma > 0): two slots — `<part>.<nname>` is the
        #     bias state, `<part>.<nname>_driver` is the per-tick
        #     driver. RW channels with sigma == 0 are inert.
        for nname, ndecl in part.noise_declarations().items():
            # Each channel declares which slots it contributes to
            # the seed dict (white: just the signal; active RW:
            # bias + driver; inert RW: nothing).
            for k, v in ndecl.initial_state_entries(nname, part).items():
                state[f"{part.name}.{k}"] = v
    unknown = set(overrides) - set(state)
    if unknown:
        raise KeyError(
            f"Craft.initial_state: unknown slot(s) {sorted(unknown)}. "
            f"Available: {sorted(state)}")
    for k, v in overrides.items():
        current = state[k]
        if isinstance(current, np.ndarray):
            state[k] = np.asarray(v, dtype=float)
        else:
            state[k] = float(v)
    return state

Coupling

A Coupling joins two crafts with a force exchanged between them — as opposed to an articulation (a 1-DOF joint inside one craft). For when to reach for which, see Articulation vs coupling.

manta.Coupling

Coupling(name)

Bases: ABC

Abstract base for inter-craft constraints.

A concrete subclass (e.g. Tether) declares two craft endpoints (craft_a / craft_b) and produces the extra wrench terms they exchange (compute_wrenches_sym) in the tick graph for the connected component. The presence of a Coupling forces both crafts into the same compile unit.

Source code in manta/couplings/base.py
def __init__(self, name: str) -> None:
    from ..ir.module import check_name

    self.name = check_name(name, who=type(self).__name__)
    self._world = None

craft_a abstractmethod property

craft_a

The first coupled craft.

craft_b abstractmethod property

craft_b

The second coupled craft.

compute_wrenches_sym abstractmethod

compute_wrenches_sym(ctx_a, ctx_b)

The wrench pair (wrench_on_a, wrench_on_b) this coupling applies, given each craft's TickContext. Frames: each wrench is in its own craft's CraftFrame, at that craft's origin.

Source code in manta/couplings/base.py
@abstractmethod
def compute_wrenches_sym(self, ctx_a, ctx_b):
    """The wrench pair `(wrench_on_a, wrench_on_b)` this coupling
    applies, given each craft's `TickContext`. Frames: each wrench is
    in its own craft's `CraftFrame`, at that craft's origin."""

manta.couplings.Tether

Tether(craft_a, endpoint_a, craft_b, endpoint_b, *, name=None, stiffness, damping=0.0, rest_length=0.0, slack_smoothing=0.001)

Bases: Coupling

Slack-capable spring-damper tether between two TetherEndpoint Parts.

Args: craft_a, craft_b — the two coupled crafts (Craft instances). endpoint_a, endpoint_b — names of the TetherEndpoint Parts on craft_a / craft_b (strings). stiffness — spring constant k, N/m (taut only). damping — damper constant c, N·s/m (taut only). rest_length — natural length L_rest, m. Slack below, taut above. slack_smoothing — half-width, m, of the C¹ blend band around the taut/slack boundary (and, scaled by k, of the tension-only clamp). 0 ⇒ hard switches.

Convention: tension only. When L > rest_length the tether pulls A toward B (and B toward A), the damper resisting length rate; the net force is clamped so it can never push. When L < rest_length the tether is slack and exerts exactly zero force — the crafts move freely until the rope tautens again.

Source code in manta/couplings/tether.py
def __init__(self,
             craft_a,
             endpoint_a: str,
             craft_b,
             endpoint_b: str,
             *,
             name: str | None = None,
             stiffness: float,
             damping: float = 0.0,
             rest_length: float = 0.0,
             slack_smoothing: float = 1e-3) -> None:
    if name is None:
        name = (f"{craft_a.name}_{endpoint_a}_to_"
                f"{craft_b.name}_{endpoint_b}_tether")
    super().__init__(name)
    self._craft_a = craft_a
    self._craft_b = craft_b
    self.endpoint_a_name = str(endpoint_a)
    self.endpoint_b_name = str(endpoint_b)
    self.stiffness = require_positive(
        stiffness, name=f"Tether {self.name!r}.stiffness", allow_zero=True)
    self.damping = require_positive(
        damping, name=f"Tether {self.name!r}.damping", allow_zero=True)
    self.rest_length = require_positive(
        rest_length, name=f"Tether {self.name!r}.rest_length",
        allow_zero=True)
    self.slack_smoothing = require_positive(
        slack_smoothing, name=f"Tether {self.name!r}.slack_smoothing",
        allow_zero=True)
    # Resolve endpoints now — a bad name fails here, at the line that
    # wrote it, not at compile. Add endpoint Parts before the Tether.
    self.endpoint_a = self._find_endpoint(craft_a, self.endpoint_a_name)
    self.endpoint_b = self._find_endpoint(craft_b, self.endpoint_b_name)

compute_wrenches_sym

compute_wrenches_sym(ctx_a, ctx_b)

Return (wrench_on_a_at_craft_origin, wrench_on_b_at_craft_origin).

Both wrenches are in their respective CraftFrame, lifted to the body origin (force-at-offset + lever-arm torque). The compile layer adds these directly to each craft's aggregate net wrench.

Source code in manta/couplings/tether.py
def compute_wrenches_sym(self, ctx_a, ctx_b) -> tuple[Wrench, Wrench]:
    """Return (wrench_on_a_at_craft_origin, wrench_on_b_at_craft_origin).

    Both wrenches are in their respective CraftFrame, lifted to the
    body origin (force-at-offset + lever-arm torque). The compile
    layer adds these directly to each craft's aggregate net wrench.
    """
    # Endpoint offsets in each body frame. A promoted (tunable)
    # endpoint transform reads as a trace-bound IR value — keep the
    # symbol (the bound vector's tag is the generic PartFrame; an
    # endpoint hangs directly off the root, so its coords ARE craft
    # coords, same assumption the constant path makes).
    def _off(ep):
        from ..parts._trace import is_promoted
        tr = ep.mount_offset
        if is_promoted(tr):
            return Vec3[CraftFrame].from_mx(tr._mx)
        return Vec3[CraftFrame].constant(tuple(tr))

    off_a_craft = _off(self.endpoint_a)
    off_b_craft = _off(self.endpoint_b)

    # Endpoint positions in world frame. The coupling reads each craft's
    # root ctx (root frame = CraftFrame): orientation is
    # Quat[WorldFrame, CraftFrame] and position[WorldFrame] is the craft
    # origin in world.
    off_a_anchor = ctx_a.orientation.apply(off_a_craft)
    off_b_anchor = ctx_b.orientation.apply(off_b_craft)
    p_a_anchor = ctx_a.position[WorldFrame] + off_a_anchor
    p_b_anchor = ctx_b.position[WorldFrame] + off_b_anchor

    # Vector from A to B; instantaneous length with softened sqrt.
    r = p_b_anchor - p_a_anchor
    r_mx = r._mx
    L    = soft_norm(r_mx)
    r_hat_mx = r_mx / L
    r_hat = Vec3[WorldFrame].from_mx(r_hat_mx)

    # Endpoint velocities in world frame:
    #   v_endpoint_anchor = v_origin + R · (ω_body × r_endpoint_body)
    # ω_body (craft inertial ω, in craft coords) = R^T · ω[WorldFrame].
    omega_a_craft = ctx_a.orientation.conjugate().apply(
        ctx_a.angular_velocity[WorldFrame])
    omega_b_craft = ctx_b.orientation.conjugate().apply(
        ctx_b.angular_velocity[WorldFrame])
    v_rot_a_craft = omega_a_craft.cross(off_a_craft)
    v_rot_b_craft = omega_b_craft.cross(off_b_craft)
    v_a_anchor = (ctx_a.velocity[WorldFrame]
                  + ctx_a.orientation.apply(v_rot_a_craft))
    v_b_anchor = (ctx_b.velocity[WorldFrame]
                  + ctx_b.orientation.apply(v_rot_b_craft))
    v_rel = v_b_anchor - v_a_anchor

    # Tension positive when stretched; positive v_along means A and
    # B moving apart (damper resists that).
    stretch = L - self.rest_length
    v_along_mx = ca.dot(v_rel._mx, r_hat_mx)
    T_raw = self.stiffness * stretch + self.damping * v_along_mx

    # Rope, not rod: two compact-support gates. w_taut kills the
    # whole force when slack (a slack rope neither springs nor
    # damps); w_tension kills a compressive net sum (a hard damper
    # during rapid approach must not push — the rope goes slack
    # instead). Compact support ⇒ exactly zero outside the bands.
    w_taut = hermite_blend(stretch, self.slack_smoothing)
    w_tension = hermite_blend(
        T_raw, self.stiffness * self.slack_smoothing)
    F_mag = w_taut * w_tension * T_raw

    # Force on A is along +r̂ (toward B) when taut and stretched.
    F_on_a_anchor = r_hat * F_mag
    F_on_b_anchor = F_on_a_anchor * (-1.0)

    # Rotate into each craft's frame.
    F_on_a_craft = ctx_a.orientation.conjugate().apply(F_on_a_anchor)
    F_on_b_craft = ctx_b.orientation.conjugate().apply(F_on_b_anchor)

    # Lift force-at-offset to wrench-at-craft-origin:
    #   F at offset r yields F at origin plus torque r × F about origin.
    wrench_a = Wrench(
        force=F_on_a_craft,
        torque=off_a_craft.cross(F_on_a_craft),
    )
    wrench_b = Wrench(
        force=F_on_b_craft,
        torque=off_b_craft.cross(F_on_b_craft),
    )
    return wrench_a, wrench_b