Source code for isaaclab_newton.physics.mjwarp_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

"""MuJoCo Warp Newton manager."""

from __future__ import annotations

import logging

import numpy as np
import warp as wp
from newton import Contacts, Model
from newton.solvers import SolverMuJoCo

from isaaclab.physics import PhysicsManager

from .mjwarp_manager_cfg import MJWarpSolverCfg
from .newton_manager import NewtonManager

logger = logging.getLogger(__name__)


[docs] class NewtonMJWarpManager(NewtonManager): """:class:`NewtonManager` specialization for the MuJoCo Warp solver. Owns construction of :class:`SolverMuJoCo`, contact-buffer allocation in both internal-MuJoCo and Newton-pipeline contact modes, and the debug convergence logging emitted from :meth:`_log_solver_debug` when :attr:`NewtonCfg.debug_mode` is enabled. """ @classmethod def _create_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> SolverMuJoCo: """Construct the configured MuJoCo Warp solver.""" kwargs = cls._filter_solver_kwargs(SolverMuJoCo, solver_cfg) # ls_parallel is deprecated in newton; forwarding it (even as False) emits a warning. kwargs.pop("ls_parallel", None) return SolverMuJoCo(model, **kwargs) @classmethod def _build_solver(cls, model: Model, solver_cfg: MJWarpSolverCfg) -> None: """Construct :class:`SolverMuJoCo` and populate the base-class slots. Filters cfg fields against the solver's ``__init__`` signature so non-constructor metadata (``solver_type``, ``class_type``) and the ignored deprecated ``ls_parallel`` field are not forwarded. Sets :attr:`NewtonManager._needs_collision_pipeline` to ``True`` only when ``use_mujoco_contacts=False``. """ NewtonManager._solver = cls._create_solver(model, solver_cfg) NewtonManager._use_single_state = True NewtonManager._needs_collision_pipeline = not solver_cfg.use_mujoco_contacts cfg = PhysicsManager._cfg # Cross-config validation that needs both halves. if solver_cfg.use_mujoco_contacts and cfg.collision_cfg is not None: raise ValueError( "NewtonCfg: collision_cfg cannot be set when " "solver_cfg.use_mujoco_contacts=True. Either set " "use_mujoco_contacts=False or remove collision_cfg." ) @classmethod def _initialize_contacts(cls) -> None: """Allocate contact buffers. Delegates to the base implementation when Newton's :class:`CollisionPipeline` is active. When ``use_mujoco_contacts=True`` the solver runs MuJoCo's internal collision detection, so this method instead pre-allocates a :class:`Contacts` buffer sized to the solver's maximum contact count; ``solver.update_contacts`` later populates it from MuJoCo data for contact-sensor reporting. """ if cls._needs_collision_pipeline: super()._initialize_contacts() return if cls._solver is not None: NewtonManager._contacts = Contacts( rigid_contact_max=cls._solver.get_max_contact_count(), soft_contact_max=0, device=PhysicsManager._device, requested_attributes=cls._model.get_requested_contact_attributes(), ) @classmethod def _reset_solver_internals(cls, world_mask: wp.array | None) -> None: """Clear MuJoCo Warp solver-internal state for flagged worlds. Specializes the base hook, whose :meth:`SolverBase.reset` call resolves to :meth:`SolverMuJoCo.reset` here: with ``flags=0`` it zeroes only the solver-owned buffers persisting across steps (``qacc_warmstart``, ``qfrc_applied``, ``xfrc_applied``, ``ctrl``, ``act``) for the flagged worlds, while the joint state IsaacLab authored during the env reset is left untouched. Without this, a NaN produced in one solve persists across :meth:`isaaclab.envs.ManagerBasedEnv.reset` because the next solver substep warm-starts from the NaN — the world is then permanently dead. See https://github.com/newton-physics/newton/issues/1266. With ``use_mujoco_cpu=True`` the solver owns a single global ``MjData`` and its reset path is not mask-aware — it clears the buffers for every world. Since this hook fires on every step/forward boundary (usually with an all-``False`` mask), the CPU path is gated on at least one world actually being flagged so warm-starting is not defeated on every step. Args: world_mask: Per-world bool mask of shape ``(world_count,)``; ``True`` for worlds that need their MJWarp internals cleared. ``None`` is treated as a no-op. """ if world_mask is None: return if cls._solver.use_mujoco_cpu and not world_mask.numpy().any(): return # flags=0 skips the joint-state reset to model defaults: IsaacLab owns # joint_q/joint_qd and has already written the authored reset pose. cls._solver.reset(cls._state_0, world_mask=world_mask, flags=0) @classmethod def _log_solver_debug(cls) -> None: """Optionally log MuJoCo solver convergence at the end of step.""" cfg = PhysicsManager._cfg if cfg is not None and cfg.debug_mode: # type: ignore[union-attr] data = cls._get_solver_convergence_steps() logger.info(f"Solver convergence data: {data}") if data["max"] == cls._solver.mjw_model.opt.iterations: logger.warning(f"Solver didn't converge! max_iter={data['max']}") @classmethod def _get_solver_convergence_steps(cls) -> dict[str, float | int]: """Return MuJoCo Warp solver convergence statistics. Reads ``mjw_data.solver_niter`` (only available on :class:`SolverMuJoCo`) and summarizes per-environment iteration counts. """ niter = cls._solver.mjw_data.solver_niter.numpy() return { "max": np.max(niter), "mean": np.mean(niter), "min": np.min(niter), "std": np.std(niter), }