Source code for isaaclab_contrib.coupling.coupler

# 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

"""Newton coupler for named solver configurations."""

from __future__ import annotations

from dataclasses import dataclass
from functools import partial

from isaaclab_newton.physics import (
    KaminoSolverCfg,
    MJWarpSolverCfg,
    MPMSolverCfg,
    NewtonCollisionPipelineCfg,
    NewtonSolverCfg,
)
from isaaclab_newton.physics.mpm_manager import NewtonMPMManager
from isaaclab_newton.physics.newton_manager import NewtonManager
from newton import CollisionPipeline, Model, ModelBuilder, ShapeFlags
from newton.solvers.experimental.coupled import SolverCoupled, SolverCoupledADMM, SolverCoupledProxy

from isaaclab.physics import PhysicsManager
from isaaclab.utils.string import resolve_matching_names

from ..deformable.vbd_manager import NewtonVBDManager
from .coupler_cfg import (
    CouplerAdmmCfg,
    CouplerCfg,
    CouplerEntryCfg,
    CouplerProxyCfg,
    CouplerProxyMappingCfg,
)


[docs] class NewtonCouplerManager(NewtonVBDManager): """Couple named Newton solver entries through proxy or ADMM interfaces.""" @dataclass class _ResolvedEntry: """Entry configuration with model selectors resolved to indices.""" config: CouplerEntryCfg bodies: list[int] particles: list[int] joints: list[int] shapes: list[int] @classmethod def _create_solver(cls, model: Model, solver_cfg: CouplerCfg): """Reject recursive use as a nested coupled-solver entry.""" del model, solver_cfg raise NotImplementedError("Nested Newton couplers are not supported.") @staticmethod def _requires_external_contacts(solver_cfg: NewtonSolverCfg) -> bool: """Return whether a sub-solver expects contacts from Newton's collision pipeline. Unknown solver configs conservatively opt in to external contacts. """ if isinstance(solver_cfg, MJWarpSolverCfg): return not solver_cfg.use_mujoco_contacts if isinstance(solver_cfg, KaminoSolverCfg): return not solver_cfg.use_collision_detector if isinstance(solver_cfg, MPMSolverCfg): return False return True @classmethod def _build_solver(cls, model: Model, solver_cfg: CouplerCfg) -> None: """Resolve ownership and construct the selected coupled solver.""" if NewtonManager._report_contacts: raise NotImplementedError( "Newton contact sensors are not yet supported by coupled solvers because contact forces live " "in per-entry buffers. Remove the contact sensor." ) cls._validate_config(solver_cfg) resolved_entries = [cls._resolve_entry(model, entry) for entry in solver_cfg.entries] proxies: list[CouplerProxyMappingCfg] = [] active_proxy_destinations: set[str] = set() if isinstance(solver_cfg, CouplerProxyCfg): proxies = [cls._resolve_proxy(model, proxy) for proxy in solver_cfg.proxies] active_proxy_destinations = {proxy.destination for proxy in proxies if proxy.bodies or proxy.particles} cls._validate_resolved_entries(model, resolved_entries, solver_cfg, active_proxy_destinations) entries = [cls._build_entry(entry) for entry in resolved_entries] if isinstance(solver_cfg, CouplerProxyCfg): solver = cls._build_proxy_coupled_solver(model, entries, proxies, solver_cfg) directions = {(proxy.source, proxy.destination) for proxy in proxies} proxy_destinations = {destination for _, destination in directions} outer_contact_entries = { entry.config.name for entry in resolved_entries if entry.config.name not in proxy_destinations } outer_contact_entries.update(source for source, _ in directions) outer_contact_entries.update( destination for source, destination in directions if solver.get_proxy_contacts(source, destination) is None ) NewtonManager._solver = solver needs_collision_pipeline = any( entry.config.name in outer_contact_entries and cls._requires_external_contacts(entry.config.solver_cfg) for entry in resolved_entries ) else: NewtonManager._solver = cls._build_admm_coupled_solver(model, entries, solver_cfg) needs_collision_pipeline = True NewtonManager._use_single_state = False NewtonManager._supports_contact_sensors = False NewtonManager._needs_collision_pipeline = needs_collision_pipeline @classmethod def _validate_config(cls, solver_cfg: CouplerCfg) -> None: """Validate adapter-specific nested-manager constraints before construction.""" if not isinstance(solver_cfg, (CouplerProxyCfg, CouplerAdmmCfg)): raise TypeError( f"CouplerCfg subclass {type(solver_cfg).__name__!r} is not supported; " "use CouplerProxyCfg or CouplerAdmmCfg." ) if not solver_cfg.entries: raise ValueError("CouplerCfg.entries must contain at least one solver entry.") if any(not isinstance(entry.name, str) or not entry.name for entry in solver_cfg.entries): raise ValueError("CouplerCfg entry names must be non-empty strings.") for entry in solver_cfg.entries: nested_cfg = entry.solver_cfg if not isinstance(nested_cfg, NewtonSolverCfg): raise TypeError( f"CouplerEntryCfg {entry.name!r} solver_cfg must be a NewtonSolverCfg, " f"got {type(nested_cfg).__name__}." ) if isinstance(nested_cfg, CouplerCfg): raise ValueError( f"CouplerEntryCfg {entry.name!r} contains a nested CouplerCfg; nested couplers are not supported." ) if getattr(nested_cfg, "model_cfg", None) is not None: raise ValueError( f"CouplerEntryCfg {entry.name!r} sets solver_cfg.model_cfg, but model parameters are global. " "Set model_cfg on the outer CouplerCfg instead." ) manager = nested_cfg.class_type factory = getattr(manager, "_create_solver", None) if not callable(factory) or getattr(factory, "__func__", factory) is NewtonManager._create_solver.__func__: raise TypeError( f"CouplerEntryCfg {entry.name!r} uses {type(nested_cfg).__name__}, whose manager " "does not implement nested solver construction." ) if isinstance(nested_cfg, KaminoSolverCfg): raise NotImplementedError( f"CouplerEntryCfg {entry.name!r} uses KaminoSolverCfg, whose manager-specific FK/reset " "lifecycle cannot yet be preserved inside Newton's coupled-solver entry API." ) if isinstance(nested_cfg, MPMSolverCfg) and nested_cfg.project_outside_colliders: raise NotImplementedError( f"CouplerEntryCfg {entry.name!r} enables MPMSolverCfg.project_outside_colliders, whose " "manager-level post-step projection cannot yet run inside a coupled-solver entry." ) if isinstance(nested_cfg, MPMSolverCfg) and not entry.in_place: raise ValueError(f"CouplerEntryCfg {entry.name!r} uses MPMSolverCfg and must set in_place=True.") if isinstance(nested_cfg, MJWarpSolverCfg) and nested_cfg.use_mujoco_cpu: raise NotImplementedError( f"CouplerEntryCfg {entry.name!r} enables MJWarpSolverCfg.use_mujoco_cpu, whose global reset " "state cannot yet preserve the manager's per-world reset-mask lifecycle inside a coupled entry." ) @classmethod def _validate_resolved_entries( cls, model: Model, entries: list[_ResolvedEntry], solver_cfg: CouplerCfg, active_proxy_destinations: set[str] | None = None, ) -> None: """Reject entries that neither own nor receive model elements.""" active_proxy_destinations = active_proxy_destinations or set() for entry in entries: owns_any = any((entry.bodies, entry.particles, entry.joints, entry.shapes)) if not owns_any and entry.config.name not in active_proxy_destinations: raise ValueError(f"CouplerEntryCfg {entry.config.name!r} neither owns nor receives any model elements.") if isinstance(solver_cfg, CouplerProxyCfg): cls._validate_no_cross_entry_proxy_joints(model, {entry.config.name: entry for entry in entries}) @classmethod def _register_builder_attributes(cls, builder: ModelBuilder) -> None: """Register custom attributes required by nested coupled entries.""" super()._register_builder_attributes(builder) solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) if any(isinstance(entry.solver_cfg, MPMSolverCfg) for entry in getattr(solver_cfg, "entries", ())): NewtonMPMManager._register_builder_attributes(builder) @classmethod def _prepare_builder_for_finalize(cls, builder: ModelBuilder) -> None: """Normalize kinematic colliders when a coupled entry uses implicit MPM.""" super()._prepare_builder_for_finalize(builder) solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) if any(isinstance(entry.solver_cfg, MPMSolverCfg) for entry in getattr(solver_cfg, "entries", ())): NewtonMPMManager._prepare_builder_for_finalize(builder) @classmethod def _initialize_contacts(cls) -> None: """Initialize contacts and entry-local buffers before CUDA graph capture.""" super()._initialize_contacts() if cls._contacts is not None and hasattr(NewtonManager._solver, "prepare_contacts"): NewtonManager._solver.prepare_contacts(cls._contacts) @classmethod def _supports_cuda_graph_capture(cls) -> bool: """Reject graph capture when a nested MPM entry uses a dynamic grid.""" solver_cfg = getattr(PhysicsManager._cfg, "solver_cfg", None) return all( not isinstance(entry.solver_cfg, MPMSolverCfg) or entry.solver_cfg.grid_type == "fixed" for entry in getattr(solver_cfg, "entries", ()) ) @classmethod def _resolve_entry( cls, model: Model, entry_cfg: CouplerEntryCfg, ) -> _ResolvedEntry: """Resolve one entry's selectors and derived ownership.""" bodies = cls._resolve_entities_to_body_ids(model, entry_cfg.bodies, f"entry {entry_cfg.name!r}") particles = list(dict.fromkeys(map(int, entry_cfg.particles))) if entry_cfg.all_particles: particles = list(dict.fromkeys([*particles, *range(int(model.particle_count))])) joints: list[int] = [] if entry_cfg.include_child_joints and int(model.joint_count): body_set = set(bodies) parents = model.joint_parent.numpy() joints = [ joint for joint, child in enumerate(model.joint_child.numpy()) if int(child) in body_set and (int(parents[joint]) < 0 or int(parents[joint]) in body_set) ] shapes: list[int] = [] if entry_cfg.include_body_shapes or entry_cfg.include_static_shapes: body_set = set(bodies) for shape, body_raw in enumerate(model.shape_body.numpy()): body = int(body_raw) if (entry_cfg.include_body_shapes and body in body_set) or ( entry_cfg.include_static_shapes and body < 0 ): shapes.append(shape) if entry_cfg.shape_label_patterns: labels = list(getattr(model, "shape_label", ()) or ()) labeled_shapes = [(index, label) for index, label in enumerate(labels) if label is not None] try: matched_shapes, _ = resolve_matching_names( entry_cfg.shape_label_patterns, [label for _, label in labeled_shapes] ) except ValueError as error: raise ValueError( f"CouplerEntryCfg {entry_cfg.name!r}: failed to resolve shape-label patterns." ) from error shapes.extend(labeled_shapes[index][0] for index in matched_shapes) return cls._ResolvedEntry( config=entry_cfg, bodies=bodies, particles=particles, joints=list(dict.fromkeys(joints)), shapes=list(dict.fromkeys(shapes)), ) @classmethod def _resolve_proxy( cls, model: Model, proxy_cfg: CouplerProxyMappingCfg, ) -> CouplerProxyMappingCfg: """Resolve a proxy mapping's selectors to collidable body ids, writing them into the config in place.""" selected = cls._resolve_entities_to_body_ids( model, proxy_cfg.bodies, f"proxy {proxy_cfg.source!r}->{proxy_cfg.destination!r}" ) collide_flag = int(ShapeFlags.COLLIDE_SHAPES) collide_bodies = { int(body) for body, flags in zip(model.shape_body.numpy(), model.shape_flags.numpy()) if int(body) >= 0 and int(flags) & collide_flag } bodies = [body for body in selected if body in collide_bodies] if proxy_cfg.bodies and not bodies: raise ValueError( f"CouplerProxyMappingCfg {proxy_cfg.source!r}->{proxy_cfg.destination!r} selected no bodies " "with ShapeFlags.COLLIDE_SHAPES." ) proxy_cfg.bodies = bodies proxy_cfg.particles = list(dict.fromkeys(map(int, proxy_cfg.particles))) return proxy_cfg @classmethod def _resolve_entities_to_body_ids( cls, model: Model, specs: list[str | int], field: str, ) -> list[int]: """Resolve body-label regexes or raw body ids to unique, order-preserving body ids.""" labels = list(model.body_label) body_ids: list[int] = [] for spec in specs: if isinstance(spec, int): if not 0 <= spec < len(labels): raise ValueError(f"CouplerCfg {field}: body id {spec} is out of range [0, {len(labels)}).") body_ids.append(spec) continue if isinstance(spec, str): matched, _ = resolve_matching_names(f"(?:{spec})(?:/.*)?", labels, raise_when_no_match=False) if not matched: raise ValueError(f"CouplerCfg {field}: body-label regex {spec!r} matched no Newton bodies.") body_ids.extend(matched) continue raise TypeError( f"CouplerCfg {field}: expected a full-label regex string or raw body id; got {type(spec).__name__}." ) return list(dict.fromkeys(body_ids)) @classmethod def _build_entry(cls, entry: _ResolvedEntry) -> SolverCoupled.Entry: entry_cfg = entry.config def solver_factory(model_view): return entry_cfg.solver_cfg.class_type._create_solver(model_view, entry_cfg.solver_cfg) return SolverCoupled.Entry( name=entry_cfg.name, solver=solver_factory, bodies=entry.bodies, particles=entry.particles, joints=entry.joints, shapes=entry.shapes, substeps=entry_cfg.substeps, in_place=entry_cfg.in_place, ) @classmethod def _build_proxy_coupled_solver( cls, model: Model, entries: list[SolverCoupled.Entry], proxy_cfgs: list[CouplerProxyMappingCfg], solver_cfg: CouplerProxyCfg, ) -> SolverCoupledProxy: proxies = [] for proxy_cfg in proxy_cfgs: values = vars(proxy_cfg).copy() if isinstance(values["collision_pipeline"], NewtonCollisionPipelineCfg): collision_cfg = values["collision_pipeline"] values["collision_pipeline"] = partial(CollisionPipeline, **collision_cfg.to_pipeline_args()) proxies.append(SolverCoupledProxy.Proxy(**values)) coupling_values = cls._filter_solver_kwargs(SolverCoupledProxy.Config, solver_cfg) coupling_values["proxies"] = proxies coupling = SolverCoupledProxy.Config(**coupling_values) return SolverCoupledProxy(model=model, entries=entries, coupling=coupling) @classmethod def _build_admm_coupled_solver( cls, model: Model, entries: list[SolverCoupled.Entry], solver_cfg: CouplerAdmmCfg, ) -> SolverCoupledADMM: values = cls._filter_solver_kwargs(SolverCoupledADMM.Config, solver_cfg) if solver_cfg.contact_pairs is None: values["contact_pairs"] = SolverCoupledADMM.auto_detect_contact_pairs(entries) else: values["contact_pairs"] = [ SolverCoupledADMM.ContactPair(source=source, destination=destination) for source, destination in solver_cfg.contact_pairs ] coupling = SolverCoupledADMM.Config(**values) return SolverCoupledADMM(model=model, entries=entries, coupling=coupling) @staticmethod def _validate_no_cross_entry_proxy_joints(model: Model, entries: dict[str, _ResolvedEntry]) -> None: body_owner = {int(body): name for name, entry in entries.items() for body in entry.bodies} for joint, (parent_raw, child_raw) in enumerate(zip(model.joint_parent.numpy(), model.joint_child.numpy())): parent = int(parent_raw) child = int(child_raw) parent_owner = body_owner.get(parent) child_owner = body_owner.get(child) if parent >= 0 and parent_owner is not None and child_owner is not None and parent_owner != child_owner: raise ValueError( f"CouplerProxyCfg does not support cross-entry joint {joint} between " f"{parent_owner!r} and {child_owner!r}; keep the articulation in one entry " "or use ADMM coupling." )