Solver Observables#

Experimental

The solver observable API may change while additional solvers and observable categories are migrated to it.

Quantities produced by a solver but not required to advance simulation belong in newton.solvers.SolverBase.Observables, separately from State and Contacts. Request only the arrays an application needs by composing entries from solver.ObservableKind in a set:

from newton.solvers import SolverMuJoCo

solver = SolverMuJoCo(model)
observables = solver.observables(
    {
        solver.ObservableKind.BODY_QDD,
        solver.ObservableKind.BODY_PARENT_F,
    }
)

solver.step(state_in, state_out, control, contacts, dt, observables=observables)
acceleration = observables.body_qdd
parent_wrench = observables.body_parent_f

The required kinds argument accepts a collection, passed positionally or by keyword. Use solver.observables(set()) to allocate an empty container, or solver.observables(solver.supported_observables) to explicitly request every observable supported by the configured solver. contacts and requires_grad are keyword-only. Prefer selecting only the outputs you need: the supported set varies by backend and can grow as solvers gain capabilities. Supply contacts=contacts whenever the requested set includes contact-indexed fields.

Allocate an observable container once and reuse it across steps. The container is owned by the solver instance that allocated it. Contact-indexed observables are sized from the rigid and soft capacities of the Contacts passed to the factory, typically created by a CollisionPipeline:

pipeline = newton.CollisionPipeline(model)
solver = newton.solvers.SolverXPBD(model)
contacts = pipeline.contacts()
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)

pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)
contact_force = observables.contact_f

All requested arrays are allocated when observables() returns. An unrequested field is None; a requested field with zero capacity is an empty array. Contact-indexed requests require contacts even when their capacity is zero. Body- and joint-only observables need neither a pipeline nor contacts; if contacts are supplied for such requests or an empty request, they are ignored and observables.contacts is None.

The live contact counts do not determine allocation sizes. contact_f has contacts.rigid_contact_max + contacts.soft_contact_max entries, with rigid slots followed by soft slots. Kernels use the contact counts to process valid entries. If the solver owns a collision pipeline, contacts defaults to solver.contacts.

A step may use any buffer accepted by validate_observables(), so allocate from the largest buffer the container will be used with. Rows follow the step’s buffer: its contact counts give the valid rows, and the soft segment begins at its rigid_contact_max. observables.contacts_for_kind(kind) reports the buffer each contact-indexed array was last produced with (observables.contacts for contact_f). SensorContact and the viewer require that buffer, and custom consumers should check it together with observables.model is model. Newly allocated forces are zero and can be read by a sensor or viewer before the first step. Allocate observables and contacts before graph capture; neither ordinary nor conditional graph execution needs deferred observable allocation. Other solver scratch buffers may still need their usual warmup. Graph replays do not update the buffer record; call validate_observables() with a replayed graph’s buffer before consuming its outputs.

Native collision backends#

With MuJoCo’s internal collision detection, construct the solver first and size the pipeline for the backend’s export capacity:

solver = newton.solvers.SolverMuJoCo(model)
pipeline = newton.CollisionPipeline(
    model, rigid_contact_max=solver.get_max_contact_count(), soft_contact_max=0
)
contacts = pipeline.contacts()
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)
solver.step(state_in, state_out, control, contacts, dt, observables=observables)

There is no pipeline.collide() call in this mode: the solver fills contact geometry and forces. Native Kamino similarly seeds the model’s rigid capacity when constructed before the pipeline. Both MuJoCo backends and Kamino reject observable storage smaller than their configured export capacity. With use_mujoco_cpu=True, nconmax bounds contact export, not simulation; excess contacts are omitted with a warning.

A solver advertises available entries through supported_observables and rejects an unsupported request during allocation. Passing an observable container to a different solver, or contacts that exceed its contact arrays, is also rejected.

Standard observables#

Kind and field

Description

Solvers

BODY_QDD / observables.body_qdd

Rigid-body center-of-mass spatial accelerations in the world frame

SolverMuJoCo and SolverKamino

BODY_PARENT_F / observables.body_parent_f

Incoming parent-joint wrenches on rigid bodies

SolverMuJoCo, SolverFeatherstone, SolverSteel, and SolverXPBD

CONTACT_F / observables.contact_f

Contact spatial forces in the row layout of the step’s contacts

SolverMuJoCo, SolverXPBD, SolverVBD, SolverKamino, and SolverSteel

SolverKamino computes acceleration as the discrete step-average (body_qd_out - body_qd_in) / dt. Across an impact, this includes the velocity impulse divided by dt. MuJoCo computes BODY_QDD and BODY_PARENT_F even with disable_sensors=True by enabling the post-constraint RNE stage independently of sensors. With use_mujoco_cpu=True, body and contact observables are exported from the step’s input dynamics, including with RK4; use the input state for contact positions. Body observables require Euler, RK4, implicit, or implicitfast integration. CONTACT_F, like the SolverMuJoCo.ObservableKind.QFRC_* kinds, supports any integrator; overflow observable requests are rejected.

SolverSteel accounts BODY_PARENT_F from the step’s solved motion, including its contact, friction, spin and loop-closure impulses, so loads carried by a subtree reach the joints that support it; see the solver’s documentation for the cases it leaves out. Like SolverMuJoCo, it reconstructs the wrench from the bodies’ rigid inertia, without joint armature, so a dynamic joint’s projected wrench is its generalized load (joint_f, explicit passive spring and damping forces, and joint-space rows) less armature * qdd. SolverFeatherstone reports the start-of-step inverse dynamics at zero joint acceleration instead.

SolverSteel reports only the linear part of each rigid contact force; the torque part is zero. Contacts without solved rows, such as rows dropped for capacity or skipped for sleeping islands, report zero. A sleeping body’s contacts therefore read as unloaded even though they still support it; use sleep_skip_constraints=False or disable sleeping when consumers rely on these forces.

Kamino exports contact points in the step’s input body frames and world-frame wrenches about the input centers of mass. Use the input state when consuming those contacts, including for native contact detection. In-place callers must preserve the input poses separately if a consumer needs to transform the contact points back to world coordinates.

SolverCoupled exposes a body observable when every entry that owns bodies supports that kind. It allocates an entry-local container for each sub-solver and gathers owned rows into parent model order. Contact observables are not exposed by the coupled wrapper because filtered entry contacts require an explicit contact-index remapping contract.

Solver-specific observables#

A solver may expose additional kinds through solver.ObservableKind. For example, MuJoCo adds QFRC_ACTUATOR and returns a SolverMuJoCo.Observables container with a qfrc_actuator array:

solver = newton.solvers.SolverMuJoCo(model)
observables = solver.observables(
    kinds={
        solver.ObservableKind.BODY_QDD,
        solver.ObservableKind.QFRC_ACTUATOR,
    }
)

MuJoCo also exports QFRC_BIAS, QFRC_CONSTRAINT, and QFRC_FRICTIONLOSS on both backends. qfrc_frictionloss is the part of qfrc_constraint that comes from each DOF’s own friction loss, so qfrc_constraint - qfrc_frictionloss is the constraint load on a DOF without it. All three come from MuJoCo’s last force evaluation of the step: the start of the step, or the last stage with RK4. The solver writes observables only during step() and reset() leaves them unchanged, so zero them for reset worlds if a consumer reads them before the next step.

The constants are strings, so literal names work too:

observables = solver.observables(kinds={"body_qdd", "qfrc_actuator"})

Pass a collection even for one kind, such as {"body_qdd"}. Bare strings and unknown names are rejected. ObservableKind is not iterable; query solver.supported_observables for the names accepted by the configured backend. An inherited name does not imply support for that observable.

Steel sleep flags#

With enable_sleeping=True, SolverSteel exposes BODY_AWAKE through observables.body_awake, a bool array with shape (model.body_count,). A step that receives the container overwrites it with True for awake bodies and False for sleeping ones as of the end of that step, also during graph replay; other steps leave it unchanged. A new container holds the solver’s current flags. reset() and notify_model_changed() wake islands according to the sleeping rules but do not write existing containers; the next step that receives one reports the result. Bodies of articulations with no responding degrees of freedom, such as a body welded to the world, always read True. Without sleeping the kind is unsupported and requesting it raises.

MuJoCo overflow diagnostics#

SolverMuJoCo exposes OVERFLOW through observables.overflow, an int32 array with shape (model.world_count,). It reports capacity overflow after a step without synchronizing the device in the solver. Requesting it requires use_mujoco_cpu=False.

Use OverflowBits to interpret Newton’s flags, and mujoco_warp.OverflowType for MuJoCo Warp’s flags. For example, this scene deliberately allocates fewer solver contacts than Newton generates:

import mujoco_warp
import newton
import warp as wp
from newton.solvers import SolverMuJoCo

builder = newton.ModelBuilder()
builder.add_ground_plane()
for i in range(4):
    body = builder.add_body(xform=wp.transform((i * 0.2, 0.0, 0.04), wp.quat_identity()))
    builder.add_shape_sphere(body, radius=0.05)
model = builder.finalize()

solver = SolverMuJoCo(model, use_mujoco_contacts=False, nconmax=1)
pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
state_in, state_out = model.state(), model.state()
newton.eval_fk(model, model.joint_q, model.joint_qd, state_in)
observables = solver.observables(kinds={solver.ObservableKind.OVERFLOW})

pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, model.control(), contacts, 1.0 / 60.0, observables=observables)
overflow = observables.overflow.numpy()

if (overflow & SolverMuJoCo.OverflowBits.CONTACT_PIPELINE).any():
    print("Newton contact pipeline capacity exceeded")
if (overflow & SolverMuJoCo.OverflowBits.SOLVER_NCONMAX).any():
    print("MuJoCo Warp solver contact capacity exceeded")
if (overflow & mujoco_warp.OverflowType.NEFC).any():
    print("MuJoCo Warp constraint capacity exceeded")

The bit layout is:

Bits

Public flags

Meaning

0-15

mujoco_warp.OverflowType

MuJoCo Warp diagnostics, including NEFC, NJMAX_NNZ, collision overflow, and solver iteration limits.

16

CONTACT_PIPELINE

Newton generated more rigid contacts than contacts.rigid_contact_max.

17

SOLVER_NCONMAX

Newton generated more rigid contacts than the solver’s shared naconmax buffer can consume.

The Newton flags describe shared contact buffers and are repeated in every world’s mask when the global count exceeds capacity. They do not identify which world lost contacts. MuJoCo Warp’s flags retain their backend-provided world assignment. When MuJoCo Warp performs its own collision detection (use_mujoco_contacts=True), the Newton flags are suppressed and contacts may be None.

Allocate the container before CUDA graph capture and pass it to each step that should update the diagnostic. The mask reflects that step only, including after graph replay; omitted or unselected observables remain unchanged. Reading .numpy() synchronizes with the device, so inspect the result outside capture.

Newton checks every declared mujoco_warp.OverflowType member when importing the backend and raises RuntimeError if any flag uses bits above 15. This prevents future upstream flags from silently colliding with Newton’s flags, even if the upstream ALL mask omits a member.

The observable reports runtime diagnostics; strict_capacity=True instead compares the two buffer capacities before conversion. A capacity mismatch can occur without actual overflow, and no Python warning or exception is automatically raised for a nonzero runtime mask.

Selection#

ArticulationView reads frequencies from a SolverBase.Observables source rather than requiring field registration on the model. Standard and custom fields using supported static layouts, such as BODY and JOINT_DOF, can therefore be selected directly:

view = newton.selection.ArticulationView(model, "robot_*")
accelerations = view.get_attribute("body_qdd", observables)

The container must belong to the same model and the field must be allocated. Custom string frequencies still need the model’s articulation-ownership metadata. Raw contact rows do not have stable, unique articulation ownership: their order changes during collision detection and their endpoints can belong to different articulations. The view rejects contact frequencies. Filter using contact endpoints or reduce forces to a BODY-frequency field before using an articulation view; automatic contact filtering and reduction are not provided.

Sensors#

Solver-dependent sensors expose a composable solver_observable_kinds set. A caller can union the requirements of multiple consumers, allocate one container, and pass it through the step:

imu = newton.sensors.SensorIMU(model, sites="imu_*", request_state_attributes=False)
contact_sensor = newton.sensors.SensorContact(
    model, sensing_shapes="foot_*", request_contact_attributes=False
)

kinds = imu.solver_observable_kinds | contact_sensor.solver_observable_kinds
# Use contacts from a pipeline configured with a compatible capacity.
observables = solver.observables(kinds=kinds, contacts=contacts)

solver.step(state_in, state_out, control, contacts, dt, observables=observables)
imu.update(state_out, observables=observables)
contact_sensor.update(state_in, contacts, observables=observables)

The viewer follows the same pattern:

viewer.log_contacts(contacts, state_in, observables=observables)

Substep scheduling#

Allocate the union of needed observables once, then use select() to create reusable subsets. Each subset has the same concrete container type and shares the selected array objects, including gradients, with the original container. Omitted fields are None in the subset, while the original container and its arrays are unchanged. The subset’s read-only kinds and is_requested(kind) describe only its selected fields. No observable arrays are allocated or copied by select().

For example, an IMU may need acceleration every substep, while a contact sensor only needs the final substep’s forces. After configuring the solver, collision pipeline, and sensors as above:

kinds = imu.solver_observable_kinds | contact_sensor.solver_observable_kinds
observables = solver.observables(kinds=kinds, contacts=contacts)
every_substep = observables.select(imu.solver_observable_kinds)
substep_dt = frame_dt / num_substeps

for substep in range(num_substeps):
    is_last = substep == num_substeps - 1
    requested = observables if is_last else every_substep
    # Run the usual collision update here if using external collision detection.
    solver.step(state_in, state_out, control, contacts, substep_dt, observables=requested)
    state_in, state_out = state_out, state_in
    imu.update(state_in, observables=every_substep)

contact_sensor.update(state_in, contacts, observables=observables)

If all fields are needed only at the end, pass observables=None on earlier substeps. observables.select(set()) also requests no diagnostics. Selecting a field absent from the source raises ValueError; selecting an existing subset can only narrow it. Select from the original container to create a different combination. Do not temporarily replace fields with None.

Contact-indexed subsets share their contact-buffer record with their source and siblings. A body-only or empty subset has contacts=None even when the original container includes contact fields.

Create selections before graph capture and reuse them. A fixed Python substep schedule is captured with the graph; changing a Python selection afterward does not change an existing graph. Device-driven choices require graph control flow with the desired selections captured in its branches.

Skipped arrays retain their previous values. is_requested() does not mean those values were updated, and mixed-rate containers do not represent a single time snapshot. Consume sensors only after their required fields have been produced, before reusing the corresponding state or contact geometry. Last-substep forces are not frame-averaged forces; averaging or integration requires sampling the relevant substeps. Contact rows may change between collision updates.

Selection controls observable writes and optional diagnostic work, not calculations needed by the dynamics or other requested fields. Legacy extended attributes can still request their own work. MuJoCo currently keeps its post-constraint RNE stage enabled after its first request, so omitting those observables does not necessarily avoid that internal computation on subsequent steps.

Contact forces from SolverVBD#

SolverVBD exports one wrench per contact record it solves – body-body contacts, and rigid-soft particle, edge, and face records against rigid shapes – through contact_f:

  1. Create the CollisionPipeline and its contacts, then request SolverBase.ObservableKind.CONTACT_F with solver.observables(contacts=contacts, kinds=...). This allocates the entire force array before stepping or graph capture; no contact attributes need to be requested.

  2. Each frame, run collision detection and pass the container to solver.step(..., observables=observables). The step evaluates the wrenches directly into the array only when CONTACT_F is selected, without changing the simulation result. Omit observables or pass a selection without CONTACT_F to skip reporting on earlier substeps.

  3. Rows [0, rigid_contact_max) hold the body-body contacts (i < rigid_contact_count) when VBD integrates the rigid bodies: the force on body 0 by body 1 with its torque about body 0’s center of mass, the convention shared by every solver writing contact_f. With integrate_with_external_rigid_solver=True, VBD’s rigid rows are zero: request and consume the external solver’s forces through its own observable container. Rows rigid_contact_max + i for i < soft_contact_count hold the rigid-soft records. Rows past either active count are zero.

Each soft row is expressed in world frame. Its first three entries are the force [N] exerted on the contacted shape’s body (model.shape_body[contacts.soft_contact_shape[i]], -1 for a static shape) by the soft feature; its last three entries are the torque [N·m] of that force about the body’s center of mass, or about the world origin for a static shape. The force acts at the shape-side contact point (soft_contact_body_pos mapped to world space). Negate the force to get the force on the soft contact point, and distribute it to the record’s particles with soft_contact_barycentric. All values are the solver’s own contact law evaluated once at the final configuration of the step – the forces the last iteration balanced – not time-step averages; records without penetration are zero.

For soft contacts this is the penalty law at the final per-contact stiffness, damping while the contact point approaches the surface, and regularized Coulomb friction on the step’s slip, bounded by the elastic normal load. Body-body contacts use the compliant ALM or legacy AVBD contact law with its multipliers. Both evaluations retain the step-start pose history used by the iterations.

Soft self-contact forces are not exported. SensorContact reads the rigid-contact rows, so it reports VBD’s body-body contacts, but it does not read the soft rows. Construct it with request_contact_attributes=False and pass the container as sensor.update(state_out, contacts, observables=observables).

import numpy as np
import warp as wp
import newton
from newton.solvers import SolverBase

builder = newton.ModelBuilder()
builder.add_ground_plane()
builder.add_particle(pos=wp.vec3(0.0, 0.0, 0.045), vel=wp.vec3(0.0), mass=1.0, radius=0.05)
ball = builder.add_body(xform=wp.transform(wp.vec3(1.0, 0.0, 0.099), wp.quat_identity()))
ball_shape = builder.add_shape_sphere(ball, radius=0.1)
builder.color()
model = builder.finalize()

pipeline = newton.CollisionPipeline(model)
contacts = pipeline.contacts()
solver = newton.solvers.SolverVBD(model)
observables = solver.observables(kinds={solver.ObservableKind.CONTACT_F}, contacts=contacts)
state_in, state_out = model.state(), model.state()

pipeline.collide(state_in, contacts)
solver.step(state_in, state_out, None, contacts, dt=1.0 / 60.0, observables=observables)

# Body-body rows: force on body 0 by body 1; flip the sign of rows where the ball is shape 1.
n_rigid = int(contacts.rigid_contact_count.numpy()[0])
rigid = observables.contact_f.numpy()[:n_rigid]
shape0 = contacts.rigid_contact_shape0.numpy()[:n_rigid]
sign = np.where(shape0 == ball_shape, 1.0, -1.0)
force_on_ball = (sign[:, None] * rigid[:, :3]).sum(axis=0)  # [N], world frame

# Soft rows follow the rigid rows.
n_soft = int(contacts.soft_contact_count.numpy()[0])
start = contacts.rigid_contact_max
wrenches = observables.contact_f.numpy()[start : start + n_soft]
force_on_shape = wrenches[:, :3]  # [N], on the ground (static shape), world frame
torque = wrenches[:, 3:]  # [N·m], about the world origin for the static ground

# Reaction on the soft side: negate and distribute by barycentric weights.
corners = contacts.soft_contact_indices.numpy()[:n_soft]
weights = contacts.soft_contact_barycentric.numpy()[:n_soft]
particle_force = np.zeros((model.particle_count, 3))
for row in range(n_soft):
    for corner, weight in zip(corners[row], weights[row]):
        if corner >= 0:
            particle_force[corner] -= weight * force_on_shape[row]

print(n_rigid > 0, force_on_ball[2] > 0.0, n_soft, force_on_shape[0, 2] < 0.0, particle_force[0, 2] > 0.0)
True True 1 True True

Deprecated extended attributes#

Deprecated since version 1.7: Request solver-produced arrays from the solver instead of extending State or Contacts.

The following compatibility paths remain available for a deprecation period:

Deprecated destination

Replacement

State.body_qdd

SolverBase.ObservableKind.BODY_QDD and observables.body_qdd

State.body_parent_f

SolverBase.ObservableKind.BODY_PARENT_F and observables.body_parent_f

State.mujoco.qfrc_actuator

SolverMuJoCo.ObservableKind.QFRC_ACTUATOR and observables.qfrc_actuator

Contacts.force

SolverBase.ObservableKind.CONTACT_F and observables.contact_f

Model.request_state_attributes() and ModelBuilder.request_state_attributes()

solver.observables(kinds={...})

Model.request_contact_attributes() and ModelBuilder.request_contact_attributes()

solver.observables(kinds={...}, contacts=contacts)

solver.update_contacts()

Pass contact observables to solver.step(..., observables=observables)

The request methods and legacy force export via update_contacts() emit DeprecationWarning. MuJoCo and Kamino keep geometry-only update_contacts() supported without a warning when contacts.force is None. SensorIMU and SensorContact retain their legacy request_state_attributes=True and request_contact_attributes=True defaults during the deprecation period; explicitly pass False when using solver observables. Setting either option to True is deprecated in Newton 1.7 and emits one sensor-specific warning at the caller’s location. Passing False does not emit a warning or request legacy fields.

Contacts.EXTENDED_ATTRIBUTES and direct requested_attributes={"force"} remain compatibility APIs. New integrations should not allocate Contacts.force.

State.EXTENDED_ATTRIBUTES remains a compatibility registry for the three deprecated state destinations listed above.

This deprecation does not affect custom attributes registered with ModelBuilder.add_custom_attribute. Existing custom model, state, and control data remain supported; the migration only covers built-in solver-produced diagnostics. The new CONTACT* frequencies are reserved for solver observables: ModelBuilder.add_custom_attribute() rejects them because builder finalization does not allocate dynamic contact rows.

Adding solver observables#

Solver developers extend ObservableKind with string constants and Observables with array fields. Declare the kinds the solver computes in SUPPORTED_OBSERVABLES; solver.supported_observables reports the effective instance capabilities.

from dataclasses import dataclass

import warp as wp

class CustomSolver(newton.solvers.SolverBase):
    class ObservableKind(newton.solvers.SolverBase.ObservableKind):
        CONTACT_PRESSURE = "contact_pressure"

    @dataclass(eq=False)
    class Observables(newton.solvers.SolverBase.Observables):
        contact_pressure: "wp.array[float] | None" = newton.solvers.SolverBase.Observables.field(
            dtype=float,
            frequency=newton.Model.AttributeFrequency.CONTACT_RIGID,
        )
        """Rigid contact pressure [Pa], shape (rigid_contact_max,)."""

    SUPPORTED_OBSERVABLES = frozenset({ObservableKind.CONTACT_PRESSURE})

The field name supplies its kind, so the string is declared only once. Pass kind="another_name" to SolverBase.Observables.field() when the public kind differs from the field name, for example a qualified name such as "thermal:temperature". Each field also declares its Warp dtype and row frequency: the indexing domain, not how often it is updated.

ObservableKind subclasses inherit standard and custom constants without modifying their parents. Class definition rejects empty or non-string public attributes, changes to inherited values, and duplicate strings under different names, including conflicts across multiple parents. Redeclaring the same name and value is allowed; private attributes starting with _ are ignored.

The factory instantiates self.Observables(). Solvers inherit the base container unless they extend it; a solver adding fields must derive its nested class from its parent’s Observables and use @dataclass(eq=False). The dataclass defaults declared arrays to None and excludes them from constructor arguments. eq=False preserves identity and hashing for articulation-view caches.

solver.observables(kinds=...) allocates requested arrays in declaration order on the model’s device, using the requested gradient setting. Derived containers inherit fields and may redeclare a field’s dtype or frequency without changing their parents or siblings. Missing declarations, duplicate kinds, and missing dataclass decorators are rejected before allocation.

Contact dependencies follow the field’s frequency. Any requested contact frequency, including custom fields requested without CONTACT_F, is sized from contacts= or the solver-owned pipeline. Body- and joint-indexed fields do not require a collision pipeline or contact storage.

In step(), call validate_observables() before launching work or modifying outputs. It checks the container and the step’s contacts and records those contacts for the requested fields. Compute diagnostics only when requested:

self.validate_observables(observables, contacts)
# Advance the simulation, then compute the requested diagnostic.
if observables is not None and observables.is_requested(self.ObservableKind.CONTACT_PRESSURE):
    wp.launch(
        compute_contact_pressure,
        dim=contacts.rigid_contact_max,
        inputs=[contacts.rigid_contact_count],
        outputs=[observables.contact_pressure],
        device=self.model.device,
    )

Here compute_contact_pressure is a solver-specific kernel. is_requested() tests the request, including for zero-length arrays; it does not indicate freshness.

Customizing the factory and selection#

Most custom fields need only their declarations. For additional initialization, override observables(kinds, *, contacts=None, requires_grad=None) and delegate to super().observables(kinds, contacts=contacts, requires_grad=requires_grad). Preserve this signature. Perform backend preflight checks before delegating and finish auxiliary allocation before returning the container. All factory allocations must finish before graph capture; neither step() nor select() calls the factory.

Simple derived containers inherit select() unchanged. Containers with nested observable containers must override it, call super(), and select their children in the returned object. See SolverCoupled for an example. Other custom metadata is shallow-copied.

Field declarations describe runtime diagnostics and do not introduce USD-authorable model attributes.

Contact row domains#

Three experimental frequencies describe the existing contact storage layouts:

  • CONTACT_RIGID: rigid_contact_max rigid-rigid slots.

  • CONTACT_SOFT: soft_contact_max soft-rigid slots. This does not include the separate soft self-contact storage.

  • CONTACT: the packed sum of both capacities, used by contact_f for compatibility. The soft segment begins at rigid_contact_max, not at the live rigid contact count.

These frequencies currently describe solver observables, not builder custom attributes. Capacity determines allocation; live counts determine which slots can be read. Packed storage does not guarantee that a solver produces forces for both segments. SensorContact currently consumes only rigid-rigid forces, using rigid_contact_count and rigid shape endpoints. See Contact forces from SolverVBD for SolverVBD’s soft-contact output.