Source code for isaaclab_newton.physics.mpm_manager

# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause

"""Implicit MPM Newton manager."""

from __future__ import annotations

import warnings

import warp as wp
from newton import (
    BodyFlags,
    Contacts,
    Control,
    GeoType,
    Model,
    ModelBuilder,
    State,
    StateFlags,
)
from newton.solvers import SolverImplicitMPM
from warp.fem import TemporaryStore

from .mpm_manager_cfg import MPMSolverCfg
from .newton_manager import NewtonManager


def _make_solver_config(solver_cfg: MPMSolverCfg) -> SolverImplicitMPM.Config:
    """Build Newton's implicit MPM solver config from Isaac Lab's cfg."""
    collider_velocity_mode = solver_cfg.collider_velocity_mode
    deprecated_velocity_modes = {
        "instantaneous": "forward",
        "finite_difference": "backward",
    }
    if replacement := deprecated_velocity_modes.get(collider_velocity_mode):
        warnings.warn(
            f"collider_velocity_mode={collider_velocity_mode!r} is deprecated; use {replacement!r} instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        collider_velocity_mode = replacement

    return SolverImplicitMPM.Config(
        max_iterations=solver_cfg.max_iterations,
        tolerance=solver_cfg.tolerance,
        solver=solver_cfg.solver,
        warmstart_mode=solver_cfg.warmstart_mode,
        collider_velocity_mode=collider_velocity_mode,
        voxel_size=solver_cfg.voxel_size,
        grid_type=solver_cfg.grid_type,
        grid_padding=solver_cfg.grid_padding,
        max_active_cell_count=solver_cfg.max_active_cell_count,
        max_leaf_node_count=solver_cfg.max_leaf_node_count,
        max_lower_node_count=solver_cfg.max_lower_node_count,
        max_upper_node_count=solver_cfg.max_upper_node_count,
        separate_worlds=solver_cfg.separate_worlds,
        transfer_scheme=solver_cfg.transfer_scheme,
        integration_scheme=solver_cfg.integration_scheme,
        critical_fraction=solver_cfg.critical_fraction,
        air_drag=solver_cfg.air_drag,
        collider_normal_from_sdf_gradient=solver_cfg.collider_normal_from_sdf_gradient,
        collider_basis=solver_cfg.collider_basis,
        strain_basis=solver_cfg.strain_basis,
        velocity_basis=solver_cfg.velocity_basis,
    )


[docs] class NewtonMPMManager(NewtonManager): """:class:`NewtonManager` specialization for Newton's implicit MPM solver. MPM advances particle materials in-place and treats rigid geometry as colliders, so it does not consume Newton's rigid-body collision pipeline and steps with a single :class:`State`. """ _project_outside_colliders: bool = False """Whether :meth:`_step_solver` projects particles out of colliders each substep. Set from :attr:`MPMSolverCfg.project_outside_colliders` in :meth:`_build_solver` and read in :meth:`_step_solver`. """ _implicit_mpm_solver_root: object | None = None _implicit_mpm_solver_cache: tuple[SolverImplicitMPM, ...] = () @classmethod def _register_builder_attributes(cls, builder: ModelBuilder) -> None: """Register the particle custom attributes required by :class:`SolverImplicitMPM`. Implicit MPM materials are configured per-particle through Newton custom attributes (``mpm:young_modulus``, ``mpm:viscosity``, ...). These must be present on the builder *before* particles are added so that ``add_particles(custom_attributes=...)`` succeeds and so that ``builder.finalize()`` allocates the matching model arrays. Idempotent: ``has_custom_attribute`` guards against re-registration when the hook is invoked multiple times (e.g. once via :meth:`create_builder` and again via :meth:`start_simulation`). """ if not builder.has_custom_attribute("mpm:young_modulus"): SolverImplicitMPM.register_custom_attributes(builder) @classmethod def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: """Normalize rigid colliders before MPM solver construction. Newton's implicit MPM solver treats positive-mass body colliders as finite-mass colliders. Isaac Lab kinematic assets can import with a computed mass, so clear mass and inertia for kinematic bodies to match Newton's direct-builder MPM examples. The solver consumes mesh vertices and indices but only accepts the triangle-mesh geometry type, so classify convex meshes as meshes without changing their geometry. """ kinematic_flag = int(BodyFlags.KINEMATIC) for body_id, flags in enumerate(builder.body_flags): if int(flags) & kinematic_flag: builder.body_mass[body_id] = 0.0 builder.body_inv_mass[body_id] = 0.0 builder.body_inertia[body_id] = wp.mat33() builder.body_inv_inertia[body_id] = wp.mat33() for shape_id, shape_type in enumerate(builder.shape_type): if shape_type == GeoType.CONVEX_MESH: builder.shape_type[shape_id] = GeoType.MESH @classmethod def _create_solver(cls, model: Model, solver_cfg: MPMSolverCfg) -> SolverImplicitMPM: """Construct the configured implicit MPM solver.""" return SolverImplicitMPM( model, _make_solver_config(solver_cfg), temporary_store=TemporaryStore(), ) @classmethod def _build_solver(cls, model: Model, solver_cfg: MPMSolverCfg) -> None: """Construct :class:`SolverImplicitMPM` and populate the base-class slots. MPM steps in-place on a single :class:`State` and runs collision handling internally, so it neither double-buffers state nor drives Newton's :class:`CollisionPipeline`. Args: model: Finalized Newton model the solver should run on. solver_cfg: Implicit MPM solver configuration. """ NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = True NewtonManager._needs_collision_pipeline = False NewtonManager._supports_rigid_body_force_input = False cls._project_outside_colliders = solver_cfg.project_outside_colliders @classmethod def _requires_initial_reset_before_graph_capture(cls) -> bool: """Capture MPM only after the task authors its initial particle state.""" return True @classmethod def _supports_cuda_graph_capture(cls) -> bool: """Return whether the active MPM grid has capture-stable storage.""" return cls._solver_supports_cuda_graph_capture(cls._solver) @staticmethod def _solver_supports_cuda_graph_capture(solver: SolverImplicitMPM) -> bool: """Return whether an implicit-MPM solver satisfies Newton's capture contract.""" if solver.grid_type == "fixed": return True if solver.grid_type != "sparse": return False strain_rebuild_safe = solver.strain_basis.startswith("pic") or solver.strain_basis in ( "P0", "P1d", "Q1d", "Q1", ) collider_rebuild_safe = solver.collider_basis.startswith("pic") or solver.collider_basis in ( "Q1", "S2", "S3", ) return ( solver.max_active_cell_count > 0 and solver.grid_padding == 0 and solver.velocity_basis == "Q1" and strain_rebuild_safe and collider_rebuild_safe ) @classmethod def _check_solver_status(cls) -> None: """Raise asynchronous sparse-grid rebuild failures after graph replay.""" if NewtonManager._graph is None: return for solver in cls._implicit_mpm_solvers(): solver.check_sparse_grid_rebuild_status() @classmethod def _implicit_mpm_solvers(cls) -> tuple[SolverImplicitMPM, ...]: """Return direct or coupled implicit-MPM solvers without importing the coupler.""" root_solver = NewtonManager._solver if root_solver is cls._implicit_mpm_solver_root: return cls._implicit_mpm_solver_cache if isinstance(root_solver, SolverImplicitMPM): solvers = (root_solver,) elif root_solver is None or not hasattr(root_solver, "entry_names") or not hasattr(root_solver, "solver"): solvers = () else: solvers = tuple( entry_solver for name in root_solver.entry_names() if isinstance((entry_solver := root_solver.solver(name)), SolverImplicitMPM) ) cls._implicit_mpm_solver_root = root_solver cls._implicit_mpm_solver_cache = solvers return solvers @classmethod def _step_solver( cls, state_0: State, state_1: State, control: Control, contacts: Contacts | None, substep_dt: float ) -> None: """Run one implicit MPM substep, optionally projecting particles out of colliders. The implicit solve already resolves colliders at the grid level. When :attr:`MPMSolverCfg.project_outside_colliders` is set, the manager also runs ``project_outside`` after the step (as in Newton's MPM examples) to hard-project particles out of collider interiors. The flag is evaluated when the step is first run, so the chosen branch is baked into any captured CUDA graph. """ cls._solver.step(state_0, state_1, control, contacts, substep_dt) if cls._project_outside_colliders: cls._solver.project_outside(state_1, state_1, substep_dt) @classmethod def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: """Preserve the existing implicit-MPM behavior for automatic asset resets. Shared-grid MPM configurations cannot reset solver history for only a subset of worlds. Tasks that use independent MPM worlds and require an exact history reset call :meth:`reset_solver_state` explicitly after authoring their complete state. Args: world_mask: Per-world reset mask, intentionally ignored. """
[docs] @classmethod def reset_solver_state( cls, state: State | None = None, world_mask: wp.array(dtype=wp.bool) | None = None, flags: StateFlags | int | None = None, ) -> None: """Reset MPM and coupled-solver history after task state is rewritten. When :paramref:`state` is omitted, both distinct manager state buffers are reset so a later buffer swap cannot restore stale history. A mask follows Newton's canonical ``world_count + 1`` contract, where the last entry selects global entities in world -1. A selected single local world is promoted to a full reset because a one-world MPM grid has no environment offsets. Args: state: State whose solver-owned history should be reset. If omitted, reset both manager states. world_mask: Canonical per-world mask, including the final global-world entry. flags: State components whose solver-owned history should reset. Raises: RuntimeError: If the MPM solver or a usable state is not initialized. ValueError: If :paramref:`world_mask` does not use Newton's canonical shape. """ solver = NewtonManager._solver model = NewtonManager._model if solver is None or model is None or not cls._implicit_mpm_solvers(): raise RuntimeError("An implicit MPM solver is not initialized; cannot reset solver state.") reset_mask = world_mask if world_mask is not None: expected_shape = (model.world_count + 1,) if world_mask.shape != expected_shape: raise ValueError(f"world_mask must have shape {expected_shape}; got {world_mask.shape}.") if model.world_count == 1: selected = world_mask.numpy() if not selected.any(): return if selected[0] and not selected[-1]: reset_mask = None candidates = (state,) if state is not None else (NewtonManager._state_1, NewtonManager._state_0) states: list[State] = [] seen: set[int] = set() for candidate in candidates: if candidate is None or id(candidate) in seen: continue seen.add(id(candidate)) states.append(candidate) if not states: raise RuntimeError("Newton state is not initialized; provide an explicit state to reset.") for candidate in states: solver.reset(candidate, world_mask=reset_mask, flags=flags)
@classmethod def _solver_specific_clear(cls) -> None: """Reset MPM-specific class state on teardown. :meth:`_build_solver` sets :attr:`_project_outside_colliders` from the active config. Resetting it here keeps a teardown-only :meth:`clear` (without a follow-up rebuild) from leaving a stale value on the class, mirroring how :meth:`NewtonManager.clear` resets the base-class flags. """ cls._project_outside_colliders = False cls._implicit_mpm_solver_root = None cls._implicit_mpm_solver_cache = ()