From fa953fe395f1a1ba9c97f2bc95f3ad3c456c6033 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 01:09:23 +0800 Subject: [PATCH 1/8] feat(sim): Add a simple Newton functional solver example --- .../physics-configuration.md | 15 +- docs/source/api_reference/public_api.rst | 15 + embodichain/lab/sim/cfg/physics.py | 38 +- embodichain/lab/sim/objects/articulation.py | 7 +- .../lab/sim/physics/solvers/__init__.py | 20 ++ embodichain/lab/sim/physics/solvers/simple.py | 324 ++++++++++++++++++ examples/simple_newton_solver/README.md | 79 +++++ examples/simple_newton_solver/demo.py | 130 +++++++ examples/simple_newton_solver/slider.urdf | 16 + pyproject.toml | 3 + tests/sim/physics/test_simple_solver.py | 200 +++++++++++ 11 files changed, 833 insertions(+), 14 deletions(-) create mode 100644 embodichain/lab/sim/physics/solvers/__init__.py create mode 100644 embodichain/lab/sim/physics/solvers/simple.py create mode 100644 examples/simple_newton_solver/README.md create mode 100644 examples/simple_newton_solver/demo.py create mode 100644 examples/simple_newton_solver/slider.urdf create mode 100644 tests/sim/physics/test_simple_solver.py diff --git a/agent_context/topics/simulation-system/physics-configuration.md b/agent_context/topics/simulation-system/physics-configuration.md index 2d243afe7..8ff2b5c88 100644 --- a/agent_context/topics/simulation-system/physics-configuration.md +++ b/agent_context/topics/simulation-system/physics-configuration.md @@ -25,10 +25,23 @@ Newton `solver_cfg=None` preserves DexSim's scene-aware `AutoSolverCfg`. The concrete solver is resolved from the complete Spawn scene, then exposed through `NewtonPhysicsBackend.solver_type`. Do not guess a solver-specific robot preset while it is unresolved. Gradient mode requires an explicit -`semi_implicit` solver. Read exact solver options/defaults in +`semi_implicit` solver or a named provider supporting the requested native +gradient mode. Functional Scene calls additionally require its functional +dynamics service. Installed `dexsim.newton_solvers` names pass through to DexSim's +`RegisteredNewtonSolverCfg`; missing SDK capabilities fail at selection or +provider loading rather than breaking ordinary backend imports. Read exact solver options/defaults in `cfg/simulation.py` and the installed DexSim config types; do not substitute a hardcoded fallback when an expected DexSim API is absent. +`physics/solvers/simple.py` owns the optional spring-slider provider; +`examples/simple_newton_solver/` demonstrates it through the public +`SimulationManager.spawn_result` Scene. The installed SDK must include the +service API. DexSim owns physical state, clocks and publication; the provider +owns its model-scoped parameters and functional transition. Independent +rollout uses `publish=False`; publishing advances the live clock once and must +not be paired with a manager update for the same transition. Scene functional +reset selects arena names, including the separate global `default` arena. + `collision_cfg` owns the external Newton contact pipeline. Its `update_interval=None` updates at the first local solver substep; positive `k` updates at `0, k, 2k, ...`, reusing contacts between updates. `collision_cfg=None` diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index ea1f17cc6..45664ed22 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -1473,6 +1473,21 @@ embodichain.lab.sim.physics.newton NewtonPhysicsBackend +embodichain.lab.sim.physics.solvers.simple +---------------------------------------- + +.. currentmodule:: embodichain.lab.sim.physics.solvers.simple + +The optional ``simple`` entry point provides a spring-slider example of native +and functional Newton stepping. It requires a DexSim build exporting +``FunctionalDynamics``; ordinary backend imports do not load this provider. +See ``examples/simple_newton_solver/README.md`` for installation, supported +models, gradients and Scene publication/reset semantics. + +.. autosummary:: + + SimpleSolverProvider + embodichain.lab.sim.motion.planners.base_planner ------------------------------------------------ diff --git a/embodichain/lab/sim/cfg/physics.py b/embodichain/lab/sim/cfg/physics.py index 314eca508..67f58ecbc 100644 --- a/embodichain/lab/sim/cfg/physics.py +++ b/embodichain/lab/sim/cfg/physics.py @@ -311,8 +311,10 @@ class NewtonPhysicsCfg(PhysicsBackendCfg): requires_grad: bool = False """Whether to finalize the Newton model with differentiable state enabled. - EmbodiChain currently requires the Semi-implicit solver for this mode and - disables CUDA graph capture when gradients are enabled. + Requires Semi-implicit or an installed named solver supporting the requested + gradient mode. The provider validates native support during model creation; + Scene functional calls additionally require its functional dynamics service. + CUDA graph capture is disabled when gradients are enabled. """ use_cuda_graph: bool = True @@ -349,6 +351,11 @@ class NewtonPhysicsCfg(PhysicsBackendCfg): parameters accepted by that DexSim solver config. If omitted, EmbodiChain preserves DexSim's scene-aware ``AutoSolverCfg`` default. A DexSim build exporting ``AutoSolverCfg`` is required; no concrete-solver fallback is used. + + Installed ``dexsim.newton_solvers`` entry-point names are also accepted by + DexSim builds with the named-extension API. For example, + ``{"solver_type": "simple", "stiffness": 2.0}`` selects the spring-slider + demonstration. Extension names are exact; options pass to their provider. """ collision_cfg: NewtonCollisionPipelineCfg | Mapping[str, Any] | None = field( @@ -407,13 +414,16 @@ def to_dexsim_cfg( solver_cfg_map=solver_cfg_map, ) - if self.requires_grad and ( - solver_cfg is None or solver_cfg.solver_type != "semi_implicit" + solver_name = "auto" if solver_cfg is None else str(solver_cfg.solver_type) + if ( + self.requires_grad + and solver_name in solver_cfg_map + and solver_name != "semi_implicit" ): logger.log_error( "Newton gradient mode requires an explicit " - "solver_type='semi_implicit'; AutoSolver does not select a " - "differentiable solver." + "solver_type='semi_implicit' or a named functional extension; " + "AutoSolver does not select a differentiable solver." ) collision_pipeline_cfg = None @@ -494,11 +504,13 @@ def _normalize_newton_solver_type(solver_type: str) -> str: "vbdsolvercfg": "vbd", } if key not in aliases: - logger.log_error( - f"Unsupported Newton solver type '{solver_type}'. " - "Expected one of 'auto', 'dexuni', 'mjwarp', 'xpbd', 'semi_implicit', " - "'featherstone', or 'vbd'." - ) + try: + from dexsim.engine.newton_physics import normalize_newton_solver_name + except ImportError as exc: + raise ValueError( + f"Solver {solver_type!r} requires a DexSim build with named solver extensions" + ) from exc + return normalize_newton_solver_name(solver_type) return aliases[key] @@ -525,6 +537,10 @@ def _newton_solver_cfg_to_dexsim( or "auto" ) normalized_solver_type = _normalize_newton_solver_type(str(configured_solver_type)) + if normalized_solver_type not in solver_cfg_map: + from dexsim.engine.newton_physics import RegisteredNewtonSolverCfg + + return RegisteredNewtonSolverCfg(normalized_solver_type, solver_cfg_data) solver_cfg_type = solver_cfg_map[normalized_solver_type] if normalized_solver_type == "dexuni": try: diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py index a17c39fd6..086685154 100644 --- a/embodichain/lab/sim/objects/articulation.py +++ b/embodichain/lab/sim/objects/articulation.py @@ -2845,8 +2845,11 @@ def reset( # impulse when initialized away from their equality manifold. qpos = self._project_mimic_qpos(qpos) self.set_qpos(qpos, target=False, env_ids=local_env_ids) - # Set drive target to hold position. - self.set_qpos(qpos, target=True, env_ids=local_env_ids) + # Newton clear_dynamics synthesizes reset hold targets as bookkeeping, + # including the manager's coordinated clear below. Do not send a drive + # command here: effort-only providers correctly reject position targets. + if not self.body_data.is_newton_backend: + self.set_qpos(qpos, target=True, env_ids=local_env_ids) if clear_dynamics: self.clear_dynamics(env_ids=local_env_ids) diff --git a/embodichain/lab/sim/physics/solvers/__init__.py b/embodichain/lab/sim/physics/solvers/__init__.py new file mode 100644 index 000000000..920744cf8 --- /dev/null +++ b/embodichain/lab/sim/physics/solvers/__init__.py @@ -0,0 +1,20 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- +"""Optional Newton solver providers, loaded through package entry points.""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/embodichain/lab/sim/physics/solvers/simple.py b/embodichain/lab/sim/physics/solvers/simple.py new file mode 100644 index 000000000..2ab2a86d2 --- /dev/null +++ b/embodichain/lab/sim/physics/solvers/simple.py @@ -0,0 +1,324 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- +"""Small spring-slider solver demonstrating the DexSim extension contract.""" + +from __future__ import annotations + +import math +from collections.abc import Mapping +from contextlib import nullcontext +from typing import Any + +import newton +import numpy as np +import torch +import warp as wp +from newton.solvers import SolverBase + +try: + from dexsim.engine.newton_physics import ( + FunctionalDynamics, + FunctionalSolverState, + NewtonSolverCapabilities, + NewtonSolverContext, + NewtonSolverExtension, + ) +except ImportError as exc: + raise ImportError( + "The simple solver requires DexSim's FunctionalDynamics extension API " + "(MR !1483 service revision); install a compatible DexSim wheel." + ) from exc + +from embodichain.utils.math import quat_apply + +__all__ = ["SimpleSolverProvider"] + + +def _torch_scope(device: wp.context.Device) -> Any: + return ( + torch.cuda.stream(wp.stream_to_torch(wp.get_stream(device))) + if device.is_cuda + else nullcontext() + ) + + +def _tensor(array: wp.array) -> torch.Tensor: + # Warp cannot expose a null pointer as a Torch tensor for empty arrays. + if array.size == 0: + return torch.as_tensor(array.numpy(), device=str(array.device)) + return wp.to_torch(array) + + +class _SliderDynamics(FunctionalDynamics): + def __init__(self, model: newton.Model, options: Mapping[str, Any]) -> None: + unknown = set(options) - {"stiffness", "damping", "force_scale"} + if unknown: + raise ValueError(f"Unknown simple solver options: {sorted(unknown)}") + values = {"stiffness": 1.0, "damping": 0.1, "force_scale": 1.0} + values.update(options) + for name, value in values.items(): + if not math.isfinite(float(value)) or ( + name != "force_scale" and float(value) < 0 + ): + raise ValueError(f"Invalid simple solver option {name}={value!r}") + self.model = model + self.stiffness = float(values["stiffness"]) + self.damping = float(values["damping"]) + self.force_scale = torch.tensor( + float(values["force_scale"]), device=str(model.device), requires_grad=True + ) + self.refresh() + + def refresh(self) -> None: + model = self.model + types = model.joint_type.numpy() + parents, children = model.joint_parent.numpy(), model.joint_child.numpy() + moving = np.flatnonzero(types == int(newton.JointType.PRISMATIC)) + if ( + not len(moving) + or not np.isin( + types, [int(newton.JointType.FIXED), int(newton.JointType.PRISMATIC)] + ).all() + or len(set(children.tolist())) != model.body_count + or any(int(children[j]) in parents for j in moving) + or not model.joint_enabled.numpy().all() + ): + raise ValueError( + "simple supports independent prismatic leaf joints with fixed ancestors only" + ) + if model.particle_count or getattr(model, "constraint_mimic_count", 0): + raise ValueError("simple does not support particles or mimic constraints") + moving_shapes = np.isin(model.shape_body.numpy(), children[moving]) + if np.any( + model.shape_flags.numpy()[moving_shapes] + & int(newton.ShapeFlags.COLLIDE_SHAPES) + ): + raise ValueError( + "simple does not support collision shapes on moving bodies" + ) + if np.any(model.gravity.numpy()): + raise ValueError("simple requires zero gravity") + for name in ("joint_target_ke", "joint_target_kd", "joint_friction"): + if np.any(getattr(model, name).numpy()): + raise ValueError(f"simple does not support nonzero {name}") + self.children = torch.as_tensor( + children[moving], dtype=torch.long, device=str(model.device) + ) + if np.any( + model.body_flags.numpy()[children[moving]] & int(newton.BodyFlags.KINEMATIC) + ): + raise ValueError("simple requires dynamic slider bodies") + mass = _tensor(model.body_mass)[self.children].detach().clone() + if not bool(torch.isfinite(mass).all() and (mass > 0).all()): + raise ValueError("simple requires finite positive slider mass") + self.mass = mass + _tensor(model.joint_armature).detach().clone() + if not bool(torch.isfinite(self.mass).all() and (self.mass > 0).all()): + raise ValueError("simple requires finite positive effective mass") + self.drag = _tensor(model.joint_damping).detach().clone() + self.damping + self.lower = _tensor(model.joint_limit_lower).detach().clone() + self.upper = _tensor(model.joint_limit_upper).detach().clone() + self.effort_limit = _tensor(model.joint_effort_limit).detach().clone() + self.velocity_limit = _tensor(model.joint_velocity_limit).detach().clone() + child_frames = _tensor(model.joint_X_c)[ + torch.as_tensor(moving, device=str(model.device)) + ] + self.local_axes = quat_apply( + child_frames[:, 3:], _tensor(model.joint_axis) + ).detach() + + def create_state(self, native_state: newton.State) -> FunctionalSolverState: + return FunctionalSolverState( + { + name: _tensor(getattr(native_state, name)).detach().clone() + for name in ("joint_q", "joint_qd", "body_q", "body_qd", "body_qdd") + if getattr(native_state, name, None) is not None + } + ) + + def parameters(self) -> Mapping[str, torch.Tensor]: + return {"force_scale": self.force_scale} + + def step( + self, + state: FunctionalSolverState, + control: torch.Tensor, + parameters: Mapping[str, torch.Tensor], + dt: float, + ) -> FunctionalSolverState: + q, qd = state.physical["joint_q"], state.physical["joint_qd"] + if not isinstance(control, torch.Tensor) or control.shape != q.shape: + raise ValueError( + "simple control must be a tensor shaped (joint_dof_count,)" + ) + if control.device != q.device or control.dtype != q.dtype: + raise ValueError("simple control must match the state device and dtype") + if not math.isfinite(dt) or dt <= 0: + raise ValueError("simple dt must be positive and finite") + force = (parameters["force_scale"] * control).clamp( + -self.effort_limit, self.effort_limit + ) + acceleration = (force - self.stiffness * q - self.drag * qd) / self.mass + velocity = (qd + dt * acceleration).clamp( + -self.velocity_limit, self.velocity_limit + ) + proposed = q + dt * velocity + position = proposed.clamp(self.lower, self.upper) + velocity = torch.where( + proposed != position, torch.zeros_like(velocity), velocity + ) + acceleration = (velocity - qd) / dt + # Advance the existing pose by the coordinate delta so explicit root + # placement and randomized states are preserved across transitions. + previous_pose = state.physical["body_q"] + axes = quat_apply(previous_pose[self.children, 3:], self.local_axes) + pose = previous_pose.index_copy( + 0, + self.children, + torch.cat( + ( + previous_pose[self.children, :3] + axes * (position - q)[:, None], + previous_pose[self.children, 3:], + ), + dim=1, + ), + ) + spatial = torch.zeros( + (self.model.body_count, 6), device=q.device, dtype=q.dtype + ) + zeros = torch.zeros_like(axes) + body_qd = spatial.index_copy( + 0, self.children, torch.cat((axes * velocity[:, None], zeros), dim=1) + ) + body_qdd = spatial.index_copy( + 0, + self.children, + torch.cat((axes * acceleration[:, None], zeros), dim=1), + ) + return FunctionalSolverState( + { + "joint_q": position, + "joint_qd": velocity, + "body_q": pose, + "body_qd": body_qd, + "body_qdd": body_qdd, + } + ) + + def commit(self, state: FunctionalSolverState) -> None: + # There is no recurrent history. DexSim owns physical publication. + pass + + def reset_history( + self, + state: FunctionalSolverState, + reset_state: FunctionalSolverState, + world_mask: wp.array | None, + ) -> tuple[Mapping[str, Any], None]: + return {}, None + + +class _NativeSliderSolver(SolverBase): + def __init__(self, model: newton.Model, dynamics: _SliderDynamics) -> None: + super().__init__(model) + self.dynamics = dynamics + + def step( + self, + state_in: newton.State, + state_out: newton.State, + control: newton.Control | None, + contacts: newton.Contacts | None, + dt: float, + *, + observables: SolverBase.Observables | None = None, + ) -> None: + self.validate_observables(observables, contacts) + with _torch_scope(self.model.device), torch.no_grad(): + source = self.dynamics.create_state(state_in) + force = _tensor(self.model.joint_f if control is None else control.joint_f) + result = self.dynamics.step(source, force, self.dynamics.parameters(), dt) + for name, value in result.physical.items(): + destination = getattr(state_out, name, None) + if destination is not None: + _tensor(destination).copy_(value) + + def notify_model_changed(self, flags: newton.ModelFlags | int) -> None: + with _torch_scope(self.model.device): + self.dynamics.refresh() + + +class SimpleSolverProvider: + """Create a minimal, differentiable spring-slider Newton extension. + + Each moving body must be a dynamic prismatic leaf attached to the world or + fixed ancestors. Visual shapes and static ground are allowed; moving-body + collisions, particles, mimic + constraints, gravity, target drives and friction are rejected. Joint mass, + armature, damping and effort/velocity/position limits are respected. Limits + use hard clamping; gradients at their boundaries are piecewise-defined. + + Options are ``stiffness`` (N/m, default 1), ``damping`` (N s/m, default 0.1) + and trainable ``force_scale`` (default 1). Native and functional execution + share semi-implicit Euler: first update velocity, then position. This is an + eager educational solver, not a general robot/contact solver. Parameters + belong to one finalized model and must be checkpointed across rebuilds. + """ + + capabilities = NewtonSolverCapabilities( + reset=True, + cuda_graph=False, + external_wrenches=False, + supported_control_fields=("joint_f",), + runtime_controls=False, + native_body_accelerations=True, + ) + + def register_custom_attributes( + self, + builder: newton.ModelBuilder, + options: Mapping[str, Any], + ) -> None: + """Use standard Newton attributes; no custom registration is needed. + + Args: + builder: Builder owned by DexSim before model finalization. + options: Solver options validated during creation. + """ + + def create( + self, + context: NewtonSolverContext, + options: Mapping[str, Any], + ) -> NewtonSolverExtension: + """Validate the model and create native and functional views of one solver. + + Args: + context: Finalized model and resources borrowed from DexSim. + options: Spring stiffness, damping and initial force scale. + + Returns: + Model-scoped extension with an independent functional trajectory API. + + Raises: + ValueError: The model or options exceed the documented supported scope. + """ + with _torch_scope(context.device): + dynamics = _SliderDynamics(context.model, options) + return NewtonSolverExtension( + native_solver=_NativeSliderSolver(context.model, dynamics), + capabilities=self.capabilities, + functional=dynamics, + ) diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md new file mode 100644 index 000000000..f8a170979 --- /dev/null +++ b/examples/simple_newton_solver/README.md @@ -0,0 +1,79 @@ +# Simple Newton solver + +This example installs a real `dexsim.newton_solvers` provider named `simple` +from EmbodiChain. It demonstrates an ordinary `SimulationManager.update()`, +independent differentiable rollout, parameter/input gradients, publication into +the live Scene, and partial functional/native reset in two environments. + +## Prerequisite + +Use Python 3.10 or 3.11 with Torch and a DexSim build containing the +`FunctionalDynamics` service revision of +[DexSim MR !1483](http://192.168.3.16/Engine/dexverse/-/merge_requests/1483). +The released DexSim pin in EmbodiChain does not yet provide that API. Normal +EmbodiChain installations remain supported; this optional example requires the +integration SDK. The older callback revision of !1483 is also insufficient. + +In a prepared EmbodiChain development environment, install this checkout and +then the matching SDK wheel. `--no-deps` intentionally preserves that prepared +environment rather than reinstalling the released SDK pin: + +```bash +python -m pip install --no-deps --no-build-isolation -e . +python -m pip install --no-deps /path/to/compatible/dexsim_engine.whl +python examples/simple_newton_solver/demo.py --device cpu +# Optional, on a supported CUDA installation: +python examples/simple_newton_solver/demo.py --device cuda:0 +``` + +Reinstall editable metadata after changing entry points. The command runs +headlessly and prints a JSON result with positions and nonzero gradients. It +checks numerical equality and state isolation before reporting success. + +## Dynamics and supported model + +For each independent prismatic leaf, with coordinate `q`, velocity `v`, input +force `u`, trainable scale `g`, and effective mass `m + armature`: + +```text +f = clamp(g * u, -effort_limit, effort_limit) +a = (f - stiffness * q - (damping + joint_damping) * v) / effective_mass +v_next = clamp(v + dt * a, -velocity_limit, velocity_limit) +q_next = clamp(q + dt * v_next, lower_limit, upper_limit) +``` + +A position-limit clamp stops the slider. Body acceleration is the realized +velocity change divided by `dt`. Gradients through the clamps are piecewise; +the example stays away from these boundaries. Native and functional stepping +call the same transition implementation. Body poses use the fixed joint frames +and world-space slider axis, preserving arena placement and body orientation. + +The model must have fixed ancestors and independent prismatic leaf bodies, +zero gravity, no particles, mimic constraints, friction or position/velocity +drives. Moving-body collisions are rejected. Static ground is allowed because +the moving bodies have collision disabled. The demo uses an explicit collision +overlay: URDF visual geometry can otherwise become collision geometry during +import. Unsupported explicit control targets and external body forces remain +errors. This eager solver does not support CUDA Graph capture. + +## API boundaries + +`NewtonPhysicsCfg(solver_cfg={"solver_type": "simple", "stiffness": 2.0})` +uses installed metadata discovery; no global registration call is needed. +`requires_grad=True` enables the functional path. After `sim.prepare()`, use +the public `sim.spawn_result` Scene: + +- `create_functional_state()` clones physical state. +- `functional_parameters()` returns the model-scoped trainable `force_scale`. +- `functional_step(..., publish=False)` computes an isolated trajectory. +- `functional_step(..., publish=True)` also publishes detached physical state + and advances the live clock once. Do not also call `sim.update()` for that + transition. The returned tensors retain their gradient graph. +- `reset_functional_state(..., arenas=["arena_0"])` resets only the selected + environment; the global `default` arena is a separate selection. + +The service is stateless: recurrent history is empty, history commit is a no-op, +and DexSim owns native buffers, clocks and lifecycle. A topology rebuild creates +a new provider; save and restore learned parameters explicitly across rebuilds. +Training loops and optimizers remain downstream. The example adds no manager or +registry of its own. diff --git a/examples/simple_newton_solver/demo.py b/examples/simple_newton_solver/demo.py new file mode 100644 index 000000000..b983449ba --- /dev/null +++ b/examples/simple_newton_solver/demo.py @@ -0,0 +1,130 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- +"""Run ordinary and functional stepping through public EmbodiChain/Scene APIs.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import torch + +from embodichain.lab.sim import SimulationManager, SimulationManagerCfg +from embodichain.lab.sim.cfg import ( + ArticulationCfg, + CollisionPropertiesCfg, + NewtonPhysicsCfg, + RigidBodyPhysicsCfg, +) + + +def run(device: str) -> dict[str, object]: + """Demonstrate discovery, parity, gradients, publication and partial reset.""" + config = SimulationManagerCfg( + headless=True, + num_envs=2, + enable_entity_gizmo=False, + startup_summary="off", + physics_cfg=NewtonPhysicsCfg( + device=device, + gravity=(0.0, 0.0, 0.0), + physics_dt=0.02, + num_substeps=2, + requires_grad=True, + use_cuda_graph=False, + collision_cfg=None, + solver_cfg={"solver_type": "simple", "stiffness": 2.0, "damping": 0.2}, + ), + ) + sim = SimulationManager(config, requires_native_renderer=False) + try: + slider = sim.add_articulation( + ArticulationCfg( + uid="slider", + fpath=str(Path(__file__).with_name("slider.urdf")), + asset_physics_mode="overlay", + attrs=RigidBodyPhysicsCfg( + collision_props=CollisionPropertiesCfg(collision_enabled=False), + ), + ) + ) + sim.prepare() + scene = sim.spawn_result + initial = scene.create_functional_state() + params = scene.functional_parameters() + force = torch.tensor([4.0, 6.0], device=device, requires_grad=True) + + predicted = scene.functional_step(initial, force, publish=False) + torch.testing.assert_close( + slider.get_qpos().flatten(), initial.physical["joint_q"] + ) + slider.set_qf(force.detach().reshape(2, 1)) + sim.update(step=1) + torch.testing.assert_close( + slider.get_qpos().flatten(), predicted.physical["joint_q"] + ) + + trajectory = initial + for _ in range(6): + trajectory = scene.functional_step(trajectory, force, publish=False) + saved = trajectory.physical["joint_q"].clone() + published = scene.functional_step(trajectory, force, publish=True) + torch.testing.assert_close( + slider.get_qpos().flatten(), published.physical["joint_q"] + ) + torch.testing.assert_close(trajectory.physical["joint_q"], saved) + + reset = scene.reset_functional_state(published, arenas=["arena_0"]) + torch.testing.assert_close( + reset.physical["joint_q"][0], initial.physical["joint_q"][0] + ) + torch.testing.assert_close( + reset.physical["joint_q"][1], published.physical["joint_q"][1] + ) + loss = ( + trajectory.physical["joint_q"].square().sum() + + reset.physical["joint_q"].square().sum() + ) + grad_force, grad_scale = torch.autograd.grad( + loss, (force, params["force_scale"]) + ) + assert torch.isfinite(grad_force).all() and (grad_force.abs() > 0).all() + assert torch.isfinite(grad_scale) and grad_scale.abs() > 0 + slider.reset(env_ids=[0]) + torch.testing.assert_close( + slider.get_qpos()[0].flatten(), initial.physical["joint_q"][:1] + ) + return { + "device": device, + "native_functional_parity": True, + "published_position": published.physical["joint_q"].detach().tolist(), + "partial_reset_position": reset.physical["joint_q"].detach().tolist(), + "force_gradient": grad_force.tolist(), + "force_scale_gradient": grad_scale.item(), + } + finally: + sim.destroy(exit_process=False) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--device", default="cpu", help="cpu or cuda:0") + args = parser.parse_args() + try: + print(json.dumps(run(args.device), indent=2)) + finally: + SimulationManager.flush_cleanup_queue() diff --git a/examples/simple_newton_solver/slider.urdf b/examples/simple_newton_solver/slider.urdf new file mode 100644 index 000000000..0d6880fd5 --- /dev/null +++ b/examples/simple_newton_solver/slider.urdf @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 20930f944..5559be303 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,9 @@ embodichain_tasks = "embodichain_tasks" [project.entry-points."embodichain.robots"] official = "embodichain_robots._catalog:ROBOT_CONFIGS" +[project.entry-points."dexsim.newton_solvers"] +simple = "embodichain.lab.sim.physics.solvers.simple:SimpleSolverProvider" + [tool.uv.sources] bpy = { index = "blender" } diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py new file mode 100644 index 000000000..afcd2f578 --- /dev/null +++ b/tests/sim/physics/test_simple_solver.py @@ -0,0 +1,200 @@ +# ---------------------------------------------------------------------------- +# Copyright (c) 2021-2026 DexForce Technology Co., Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ---------------------------------------------------------------------------- +"""Independent numerical expectations for the packaged spring-slider provider.""" + +from __future__ import annotations + +import math + +import newton +import numpy as np +import pytest +import torch +import warp as wp + +from dexsim.engine.newton_physics import NewtonSolverContext, resolve_newton_solver +from embodichain.lab.sim.cfg import NewtonPhysicsCfg + + +def _model(device="cpu", *, joint="prismatic", collision=False, gravity=(0, 0, 0)): + builder = newton.ModelBuilder(gravity=gravity) + body = builder.add_link(mass=2.0, inertia=wp.mat33(np.eye(3))) + rotation = wp.quat_from_axis_angle(wp.vec3(0, 0, 1), math.pi / 2) + add_joint = getattr(builder, "add_joint_" + joint) + index = add_joint( + -1, body, axis=(1, 0, 0), parent_xform=wp.transform((1, 2, 3), rotation) + ) + builder.add_articulation([index]) + if collision: + builder.add_shape_sphere(body=body, radius=0.1) + return builder.finalize(device) + + +def _extension(model, **options): + provider = resolve_newton_solver("simple") + return provider.create( + NewtonSolverContext(model, model.control(), None, model.device, 0.1, {}), + options, + ) + + +@pytest.mark.parametrize( + "device", ["cpu", pytest.param("cuda:0", marks=pytest.mark.gpu)] +) +def test_discovered_solver_native_parity_and_analytical_gradients(device): + model = _model(device) + extension = _extension(model, stiffness=0, damping=0) + dynamics = extension.functional + native = model.state() + newton.eval_fk(model, native.joint_q, native.joint_qd, native) + placed = wp.to_torch(native.body_q).clone() + placed[:, 0] += 5.0 + native.body_q.assign(placed.detach().cpu().numpy()) + initial = dynamics.create_state(native) + force = torch.tensor([4.0], device=device, requires_grad=True) + parameters = dynamics.parameters() + first = dynamics.step(initial, force, parameters, 0.1) + second = dynamics.step(first, force, parameters, 0.1) + # m=2, F=4: v1=.2, q1=.02; v2=.4, q2=.06. + torch.testing.assert_close( + second.physical["joint_q"], torch.tensor([0.06], device=device) + ) + torch.testing.assert_close( + second.physical["body_q"][0, :3], torch.tensor([6.0, 2.06, 3.0], device=device) + ) + grad_force, grad_scale = torch.autograd.grad( + second.physical["joint_q"].sum(), (force, parameters["force_scale"]) + ) + torch.testing.assert_close(grad_force, torch.tensor([0.015], device=device)) + torch.testing.assert_close(grad_scale, torch.tensor(0.06, device=device)) + control, output = model.control(), model.state() + control.joint_f.assign([4.0]) + extension.native_solver.step(native, output, control, None, 0.1) + extension.native_solver.step(output, native, control, None, 0.1) + wp.synchronize_device(model.device) + torch.testing.assert_close(wp.to_torch(native.joint_q), second.physical["joint_q"]) + torch.testing.assert_close(wp.to_torch(native.body_qd), second.physical["body_qd"]) + torch.testing.assert_close( + initial.physical["joint_q"], torch.zeros(1, device=device) + ) + torch.testing.assert_close( + first.physical["joint_q"], torch.tensor([0.02], device=device) + ) + + +def test_spring_damping_limits_and_mass_refresh(): + model = _model() + extension = _extension(model, stiffness=2, damping=1) + native = model.state() + native.joint_q.assign([0.2]) + native.joint_qd.assign([0.3]) + service = extension.functional + state = service.create_state(native) + force = torch.tensor([4.0]) + out = service.step(state, force, service.parameters(), 0.1) + # a=(4-2*.2-1*.3)/2=1.65; v=.465, q=.2465. + torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.2465])) + model.body_mass.assign([4.0]) + model.joint_limit_upper.assign([0.23]) + extension.native_solver.notify_model_changed( + newton.ModelFlags.BODY_INERTIAL_PROPERTIES + ) + out = service.step(state, force, service.parameters(), 0.1) + torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.23])) + torch.testing.assert_close(out.physical["joint_qd"], torch.tensor([0.0])) + # Move the limit out again to observe the changed mass rather than only clamp. + model.joint_limit_upper.assign([1.0]) + extension.native_solver.notify_model_changed( + newton.ModelFlags.JOINT_DOF_FORCE_PROPERTIES + ) + out = service.step(state, force, service.parameters(), 0.1) + torch.testing.assert_close(out.physical["joint_q"], torch.tensor([0.23825])) + + +@pytest.mark.parametrize( + "kwargs, message", + [ + ({"joint": "revolute"}, "prismatic leaf"), + ({"collision": True}, "collision shapes"), + ({"gravity": (0, 0, -9.81)}, "zero gravity"), + ], +) +def test_unsupported_model_is_rejected(kwargs, message): + with pytest.raises(ValueError, match=message): + _extension(_model(**kwargs)) + + +def test_named_config_and_independent_provider_parameters(): + cfg = NewtonPhysicsCfg( + device="cpu", + requires_grad=True, + solver_cfg={"solver_type": "simple", "stiffness": 3.0}, + ).to_dexsim_cfg(gpu_id=0) + assert cfg.solver_cfg.solver_type == "simple" + assert cfg.solver_cfg.options == {"stiffness": 3.0} + first, second = _extension(_model()), _extension(_model()) + with torch.no_grad(): + first.functional.parameters()["force_scale"].fill_(2) + assert second.functional.parameters()["force_scale"].item() == 1 + + +@pytest.mark.requires_sim +@pytest.mark.parametrize("owner", ["articulation", "manager"]) +def test_effort_only_reset_restores_selected_environment(tmp_path, owner): + from embodichain.lab.sim import SimulationManager, SimulationManagerCfg + from embodichain.lab.sim.cfg import ArticulationCfg + + asset = tmp_path / "slider.urdf" + asset.write_text(""" + + + + """) + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + num_envs=2, + enable_entity_gizmo=False, + startup_summary="off", + physics_cfg=NewtonPhysicsCfg( + device="cpu", + gravity=(0, 0, 0), + requires_grad=True, + collision_cfg=None, + solver_cfg={"solver_type": "simple"}, + ), + ), + requires_native_renderer=False, + ) + art = None + try: + art = sim.add_articulation(ArticulationCfg(uid="slider", fpath=str(asset))) + sim.prepare() + art.set_qpos(torch.tensor([[0.2], [0.4]]), target=False) + art.set_qvel(torch.tensor([[0.3], [0.5]]), target=False) + with pytest.raises(NotImplementedError, match="joint_target_q"): + art.set_qpos(torch.zeros(2, 1), target=True) + if owner == "articulation": + art.reset(env_ids=[0]) + else: + sim.reset_objects_state(env_ids=[0]) + torch.testing.assert_close(art.get_qpos(), torch.tensor([[0.0], [0.4]])) + torch.testing.assert_close(art.get_qvel(), torch.tensor([[0.0], [0.5]])) + torch.testing.assert_close(art.get_qpos(target=True)[0], torch.zeros(1)) + finally: + art = None + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() From e3d087cab81844d545e4aded96786a3edff3303d Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 01:21:27 +0800 Subject: [PATCH 2/8] fix(sim): Reject closed loops in the simple slider solver --- embodichain/lab/sim/physics/solvers/simple.py | 1 + examples/simple_newton_solver/README.md | 7 +++++++ tests/sim/physics/test_simple_solver.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/embodichain/lab/sim/physics/solvers/simple.py b/embodichain/lab/sim/physics/solvers/simple.py index 2ab2a86d2..05b24b04b 100644 --- a/embodichain/lab/sim/physics/solvers/simple.py +++ b/embodichain/lab/sim/physics/solvers/simple.py @@ -92,6 +92,7 @@ def refresh(self) -> None: or not np.isin( types, [int(newton.JointType.FIXED), int(newton.JointType.PRISMATIC)] ).all() + or len(children) != model.body_count or len(set(children.tolist())) != model.body_count or any(int(children[j]) in parents for j in moving) or not model.joint_enabled.numpy().all() diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md index f8a170979..7b20dfce7 100644 --- a/examples/simple_newton_solver/README.md +++ b/examples/simple_newton_solver/README.md @@ -77,3 +77,10 @@ and DexSim owns native buffers, clocks and lifecycle. A topology rebuild creates a new provider; save and restore learned parameters explicitly across rebuilds. Training loops and optimizers remain downstream. The example adds no manager or registry of its own. + +The demonstrated gradients are with respect to effort and `force_scale`. +Functional state includes both joint and body coordinates. To differentiate a +Cartesian loss with respect to initial joint positions, initialize body poses +through a differentiable FK graph consistent with those positions. Promoting +only `joint_q` to a trainable tensor while keeping the initial `body_q` detached +instead differentiates a redundant state with its initial body pose held fixed. diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py index afcd2f578..faf2c6575 100644 --- a/tests/sim/physics/test_simple_solver.py +++ b/tests/sim/physics/test_simple_solver.py @@ -151,6 +151,23 @@ def test_named_config_and_independent_provider_parameters(): assert second.functional.parameters()["force_scale"].item() == 1 +def test_fixed_loop_cannot_be_treated_as_an_independent_slider(): + builder = newton.ModelBuilder(gravity=(0, 0, 0)) + inertia = wp.mat33(np.eye(3)) + base = builder.add_link(mass=1.0, inertia=inertia) + slider = builder.add_link(mass=2.0, inertia=inertia) + other = builder.add_link(mass=1.0, inertia=inertia) + root = builder.add_joint_fixed(-1, base) + slide = builder.add_joint_prismatic(base, slider, axis=(1, 0, 0)) + builder.add_articulation([root, slide]) + other_root = builder.add_joint_fixed(-1, other) + builder.add_articulation([other_root]) + builder.add_joint_fixed(other, slider, label="closed_loop_weld") + model = builder.finalize("cpu") + with pytest.raises(ValueError, match="independent prismatic leaf"): + _extension(model) + + @pytest.mark.requires_sim @pytest.mark.parametrize("owner", ["articulation", "manager"]) def test_effort_only_reset_restores_selected_environment(tmp_path, owner): From a6b206beb210e5bb46eebf9ba1e4827c1fcc1ee7 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 01:28:09 +0800 Subject: [PATCH 3/8] test(sim): Distinguish optional SDK coverage from integration acceptance --- examples/simple_newton_solver/README.md | 9 +++++++++ tests/sim/physics/test_simple_solver.py | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md index 7b20dfce7..f3c3617e5 100644 --- a/examples/simple_newton_solver/README.md +++ b/examples/simple_newton_solver/README.md @@ -30,6 +30,15 @@ Reinstall editable metadata after changing entry points. The command runs headlessly and prints a JSON result with positions and nonzero gradients. It checks numerical equality and state isolation before reporting success. +For feature acceptance, first require the SDK capability, then run the provider +tests. Ordinary CI using the released SDK skips this optional test module; that +skip is not validation of the solver: + +```bash +python -c 'from dexsim.engine.newton_physics import FunctionalDynamics' +python -m pytest tests/sim/physics/test_simple_solver.py -q --run-gpu +``` + ## Dynamics and supported model For each independent prismatic leaf, with coordinate `q`, velocity `v`, input diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py index faf2c6575..6472f7ddc 100644 --- a/tests/sim/physics/test_simple_solver.py +++ b/tests/sim/physics/test_simple_solver.py @@ -25,6 +25,15 @@ import torch import warp as wp +from dexsim.engine import newton_physics + +if not hasattr(newton_physics, "FunctionalDynamics"): + pytest.skip( + "Optional simple solver requires the DexSim FunctionalDynamics SDK; " + "run its integration acceptance with the matching SDK wheel", + allow_module_level=True, + ) + from dexsim.engine.newton_physics import NewtonSolverContext, resolve_newton_solver from embodichain.lab.sim.cfg import NewtonPhysicsCfg From a6f863f66796db9cbed0f776a2ca7ef88249225b Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 01:43:20 +0800 Subject: [PATCH 4/8] docs(sim): Resolve the integration SDK runtime dependencies --- examples/simple_newton_solver/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md index f3c3617e5..e37ee11c0 100644 --- a/examples/simple_newton_solver/README.md +++ b/examples/simple_newton_solver/README.md @@ -14,13 +14,16 @@ The released DexSim pin in EmbodiChain does not yet provide that API. Normal EmbodiChain installations remain supported; this optional example requires the integration SDK. The older callback revision of !1483 is also insufficient. -In a prepared EmbodiChain development environment, install this checkout and -then the matching SDK wheel. `--no-deps` intentionally preserves that prepared -environment rather than reinstalling the released SDK pin: +In a dedicated EmbodiChain development environment, install this checkout and +then the matching SDK wheel. Use `--no-deps` only for the EmbodiChain checkout, +so it does not reinstall the released SDK pin. Let the SDK wheel resolve its +own dependencies; the qualified build requires Newton 1.7.0 and Warp 1.17.0. +This integration environment intentionally overrides EmbodiChain's released +SDK pin: ```bash python -m pip install --no-deps --no-build-isolation -e . -python -m pip install --no-deps /path/to/compatible/dexsim_engine.whl +python -m pip install /path/to/compatible/dexsim_engine.whl python examples/simple_newton_solver/demo.py --device cpu # Optional, on a supported CUDA installation: python examples/simple_newton_solver/demo.py --device cuda:0 From 3a75a462ebd2f808fec537f69457c92e1a1d9f94 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 03:37:35 +0800 Subject: [PATCH 5/8] fix(sim): Keep optional solver inspectable with released SDK --- embodichain/lab/sim/physics/solvers/simple.py | 57 ++++++++++++------- examples/simple_newton_solver/demo.py | 2 + tests/sim/test_sim_manager_cfg.py | 19 +++++++ 3 files changed, 57 insertions(+), 21 deletions(-) diff --git a/embodichain/lab/sim/physics/solvers/simple.py b/embodichain/lab/sim/physics/solvers/simple.py index 05b24b04b..3f172f5dd 100644 --- a/embodichain/lab/sim/physics/solvers/simple.py +++ b/embodichain/lab/sim/physics/solvers/simple.py @@ -20,31 +20,33 @@ import math from collections.abc import Mapping from contextlib import nullcontext -from typing import Any +from typing import Any, TYPE_CHECKING import newton import numpy as np import torch import warp as wp from newton.solvers import SolverBase +from dexsim.engine import newton_physics as _sdk -try: +from embodichain.utils.math import quat_apply + +__all__ = ["SimpleSolverProvider"] + +# Catalogues and autodoc may inspect the provider with the released SDK. +# Resolve annotations when available; instance creation enforces the runtime API. +if TYPE_CHECKING: from dexsim.engine.newton_physics import ( - FunctionalDynamics, FunctionalSolverState, NewtonSolverCapabilities, NewtonSolverContext, NewtonSolverExtension, ) -except ImportError as exc: - raise ImportError( - "The simple solver requires DexSim's FunctionalDynamics extension API " - "(MR !1483 service revision); install a compatible DexSim wheel." - ) from exc - -from embodichain.utils.math import quat_apply - -__all__ = ["SimpleSolverProvider"] +else: + FunctionalSolverState = getattr(_sdk, "FunctionalSolverState", Any) + NewtonSolverCapabilities = getattr(_sdk, "NewtonSolverCapabilities", Any) + NewtonSolverContext = getattr(_sdk, "NewtonSolverContext", Any) + NewtonSolverExtension = getattr(_sdk, "NewtonSolverExtension", Any) def _torch_scope(device: wp.context.Device) -> Any: @@ -62,7 +64,7 @@ def _tensor(array: wp.array) -> torch.Tensor: return wp.to_torch(array) -class _SliderDynamics(FunctionalDynamics): +class _SliderDynamics: def __init__(self, model: newton.Model, options: Mapping[str, Any]) -> None: unknown = set(options) - {"stiffness", "damping", "force_scale"} if unknown: @@ -278,14 +280,27 @@ class SimpleSolverProvider: belong to one finalized model and must be checkpointed across rebuilds. """ - capabilities = NewtonSolverCapabilities( - reset=True, - cuda_graph=False, - external_wrenches=False, - supported_control_fields=("joint_f",), - runtime_controls=False, - native_body_accelerations=True, - ) + def __init__(self) -> None: + """Require the extension SDK when the provider is selected. + + Raises: + ImportError: The installed SDK lacks the functional service contract. + """ + if not hasattr(_sdk, "FunctionalDynamics") or not hasattr( + NewtonSolverCapabilities, "native_body_accelerations" + ): + raise ImportError( + "The simple solver requires DexSim's FunctionalDynamics extension API " + "(MR !1483 service revision); install a compatible DexSim wheel." + ) + self.capabilities = NewtonSolverCapabilities( + reset=True, + cuda_graph=False, + external_wrenches=False, + supported_control_fields=("joint_f",), + runtime_controls=False, + native_body_accelerations=True, + ) def register_custom_attributes( self, diff --git a/examples/simple_newton_solver/demo.py b/examples/simple_newton_solver/demo.py index b983449ba..c6ecb6bc4 100644 --- a/examples/simple_newton_solver/demo.py +++ b/examples/simple_newton_solver/demo.py @@ -30,12 +30,14 @@ NewtonPhysicsCfg, RigidBodyPhysicsCfg, ) +from embodichain.lab.visualization import VisualizationCfg def run(device: str) -> dict[str, object]: """Demonstrate discovery, parity, gradients, publication and partial reset.""" config = SimulationManagerCfg( headless=True, + visualization=VisualizationCfg(backend="none"), num_envs=2, enable_entity_gizmo=False, startup_summary="off", diff --git a/tests/sim/test_sim_manager_cfg.py b/tests/sim/test_sim_manager_cfg.py index 6adf8c294..2174a33cb 100644 --- a/tests/sim/test_sim_manager_cfg.py +++ b/tests/sim/test_sim_manager_cfg.py @@ -41,6 +41,25 @@ def test_simulation_manager_cfg_uses_default_physics_cfg() -> None: assert type(cfg.physics_cfg) is DefaultPhysicsCfg +@pytest.mark.no_sim +def test_optional_solver_definition_is_inspectable_without_extension_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import importlib.util + import inspect + + from dexsim.engine import newton_physics as sdk + + source = importlib.util.find_spec("embodichain.lab.sim.physics.solvers.simple") + monkeypatch.delattr(sdk, "FunctionalDynamics", raising=False) + spec = importlib.util.spec_from_file_location("_simple_sdk_probe", source.origin) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + assert inspect.isclass(module.SimpleSolverProvider) + with pytest.raises(ImportError, match="FunctionalDynamics"): + module.SimpleSolverProvider() + + @pytest.mark.no_sim def test_newton_physics_cfg_owns_backend_device_default() -> None: """The concrete Newton default shadows the generic backend default.""" From 9a7bbaa80d77746cf1dd6fd163e866a3053cb75a Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 03:40:54 +0800 Subject: [PATCH 6/8] fix(docs): Resolve optional solver annotations on released SDK --- docs/source/api_reference/public_api.rst | 2 +- embodichain/lab/sim/physics/solvers/simple.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/source/api_reference/public_api.rst b/docs/source/api_reference/public_api.rst index 45664ed22..0e1a641d3 100644 --- a/docs/source/api_reference/public_api.rst +++ b/docs/source/api_reference/public_api.rst @@ -1474,7 +1474,7 @@ embodichain.lab.sim.physics.newton NewtonPhysicsBackend embodichain.lab.sim.physics.solvers.simple ----------------------------------------- +------------------------------------------ .. currentmodule:: embodichain.lab.sim.physics.solvers.simple diff --git a/embodichain/lab/sim/physics/solvers/simple.py b/embodichain/lab/sim/physics/solvers/simple.py index 3f172f5dd..64c656e38 100644 --- a/embodichain/lab/sim/physics/solvers/simple.py +++ b/embodichain/lab/sim/physics/solvers/simple.py @@ -20,7 +20,7 @@ import math from collections.abc import Mapping from contextlib import nullcontext -from typing import Any, TYPE_CHECKING +from typing import Any import newton import numpy as np @@ -35,18 +35,18 @@ # Catalogues and autodoc may inspect the provider with the released SDK. # Resolve annotations when available; instance creation enforces the runtime API. -if TYPE_CHECKING: +try: from dexsim.engine.newton_physics import ( FunctionalSolverState, NewtonSolverCapabilities, NewtonSolverContext, NewtonSolverExtension, ) -else: - FunctionalSolverState = getattr(_sdk, "FunctionalSolverState", Any) - NewtonSolverCapabilities = getattr(_sdk, "NewtonSolverCapabilities", Any) - NewtonSolverContext = getattr(_sdk, "NewtonSolverContext", Any) - NewtonSolverExtension = getattr(_sdk, "NewtonSolverExtension", Any) +except ImportError: + FunctionalSolverState = Any + NewtonSolverCapabilities = Any + NewtonSolverContext = Any + NewtonSolverExtension = Any def _torch_scope(device: wp.context.Device) -> Any: From 3faf27d44adc4a910703249d34e9d8a681a141e7 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Fri, 23 Oct 2026 05:24:29 +0800 Subject: [PATCH 7/8] fix(sim): Preserve supported Newton initialization targets --- .../physics-configuration.md | 8 ++++ embodichain/lab/sim/objects/articulation.py | 14 ++++-- .../lab/sim/objects/backends/newton.py | 17 +++++++ examples/simple_newton_solver/README.md | 11 +++-- tests/sim/objects/test_articulation.py | 47 +++++++++++++++++++ .../objects/test_backend_physics_helpers.py | 24 ++++++++++ tests/sim/physics/test_simple_solver.py | 19 ++++++-- 7 files changed, 128 insertions(+), 12 deletions(-) diff --git a/agent_context/topics/simulation-system/physics-configuration.md b/agent_context/topics/simulation-system/physics-configuration.md index 8ff2b5c88..ef779171f 100644 --- a/agent_context/topics/simulation-system/physics-configuration.md +++ b/agent_context/topics/simulation-system/physics-configuration.md @@ -42,6 +42,14 @@ rollout uses `publish=False`; publishing advances the live clock once and must not be paired with a manager update for the same transition. Scene functional reset selects arena names, including the separate global `default` arena. +Articulation initialization also calls `reset(clear_dynamics=False)` without a +later coordinated clear. Preserve its position hold targets and mimic target +propagation when the solver accepts them. Query the finalized public +`Scene.dynamics_capabilities().supported_control_fields`; only an explicit +exclusion of `joint_target_q` suppresses that command. Released SDKs predating +extensions retain their unrestricted target behavior. Extension SDKs missing +the query require an upgrade instead of guessing support from solver names. + `collision_cfg` owns the external Newton contact pipeline. Its `update_interval=None` updates at the first local solver substep; positive `k` updates at `0, k, 2k, ...`, reusing contacts between updates. `collision_cfg=None` diff --git a/embodichain/lab/sim/objects/articulation.py b/embodichain/lab/sim/objects/articulation.py index 086685154..d1354d4e3 100644 --- a/embodichain/lab/sim/objects/articulation.py +++ b/embodichain/lab/sim/objects/articulation.py @@ -96,6 +96,7 @@ from embodichain.lab.sim.objects.backends.newton import ( _configure_newton_mimic_compliance, _stabilize_newton_mimic_target_write, + _supports_newton_position_targets, ) from embodichain.utils.math import ( matrix_from_euler, @@ -2797,6 +2798,11 @@ def reset( num_instances = len(local_env_ids) self.cfg: ArticulationCfg + write_hold_targets = ( + not self.body_data.is_newton_backend + or _supports_newton_position_targets(self._spawn_result) + ) + self.restore_visual_material(env_ids=local_env_ids) self._restore_default_physical_properties(local_env_ids) @@ -2845,10 +2851,10 @@ def reset( # impulse when initialized away from their equality manifold. qpos = self._project_mimic_qpos(qpos) self.set_qpos(qpos, target=False, env_ids=local_env_ids) - # Newton clear_dynamics synthesizes reset hold targets as bookkeeping, - # including the manager's coordinated clear below. Do not send a drive - # command here: effort-only providers correctly reject position targets. - if not self.body_data.is_newton_backend: + # Initialization uses clear_dynamics=False with no subsequent clear. + # Keep hold targets and mimic propagation for position-capable solvers; + # effort-only solvers use native reset bookkeeping without a command. + if write_hold_targets: self.set_qpos(qpos, target=True, env_ids=local_env_ids) if clear_dynamics: diff --git a/embodichain/lab/sim/objects/backends/newton.py b/embodichain/lab/sim/objects/backends/newton.py index 33b164404..322557cf5 100644 --- a/embodichain/lab/sim/objects/backends/newton.py +++ b/embodichain/lab/sim/objects/backends/newton.py @@ -34,6 +34,23 @@ def is_newton_scene(scene: object) -> bool: return isinstance(scene, Scene) and scene.backend == "newton" +def _supports_newton_position_targets(scene: Scene) -> bool: + """Query the finalized solver contract while preserving legacy SDK support.""" + query = getattr(scene, "dynamics_capabilities", None) + if query is None: + from dexsim.engine import newton_physics + + if hasattr(newton_physics, "NewtonSolverExtension"): + raise RuntimeError( + "Newton solver extensions require a compatible DexSim SDK with " + "Scene.dynamics_capabilities() for articulation initialization." + ) + # Released SDKs predating extensions accept the existing target channels. + return True + fields = query().supported_control_fields + return fields is None or "joint_target_q" in fields + + def _create_newton_standalone_state_sync( model: Any, body_ids: Sequence[int], diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md index e37ee11c0..42d517edc 100644 --- a/examples/simple_newton_solver/README.md +++ b/examples/simple_newton_solver/README.md @@ -8,11 +8,12 @@ the live Scene, and partial functional/native reset in two environments. ## Prerequisite Use Python 3.10 or 3.11 with Torch and a DexSim build containing the -`FunctionalDynamics` service revision of +`FunctionalDynamics` service and `Scene.dynamics_capabilities()` revision of [DexSim MR !1483](http://192.168.3.16/Engine/dexverse/-/merge_requests/1483). The released DexSim pin in EmbodiChain does not yet provide that API. Normal EmbodiChain installations remain supported; this optional example requires the -integration SDK. The older callback revision of !1483 is also insufficient. +integration SDK. The older callback and early service revisions without the +capability query are also insufficient. In a dedicated EmbodiChain development environment, install this checkout and then the matching SDK wheel. Use `--no-deps` only for the EmbodiChain checkout, @@ -38,7 +39,11 @@ tests. Ordinary CI using the released SDK skips this optional test module; that skip is not validation of the solver: ```bash -python -c 'from dexsim.engine.newton_physics import FunctionalDynamics' +python - <<'PY' +from dexsim.engine.newton_physics import FunctionalDynamics +from dexsim.scene import Scene +assert callable(getattr(Scene, "dynamics_capabilities", None)) +PY python -m pytest tests/sim/physics/test_simple_solver.py -q --run-gpu ``` diff --git a/tests/sim/objects/test_articulation.py b/tests/sim/objects/test_articulation.py index 8b5ff0b33..eeede72a9 100644 --- a/tests/sim/objects/test_articulation.py +++ b/tests/sim/objects/test_articulation.py @@ -61,6 +61,53 @@ def _inertia_tensor(inertia: torch.Tensor, com_pose: torch.Tensor) -> torch.Tens return rotation @ torch.diag_embed(inertia) @ rotation.transpose(-1, -2) +@pytest.mark.requires_sim +def test_newton_initial_hold_targets_for_multiple_articulations(tmp_path: Path) -> None: + from embodichain.lab.sim.cfg import NewtonPhysicsCfg + + asset = tmp_path / "initial_slider.urdf" + asset.write_text(""" + + + + """) + sim = SimulationManager( + SimulationManagerCfg( + headless=True, + enable_entity_gizmo=False, + physics_cfg=NewtonPhysicsCfg( + device="cpu", + gravity=(0, 0, 0), + collision_cfg=None, + solver_cfg={"solver_type": "semi_implicit"}, + ), + ), + requires_native_renderer=False, + ) + articulations = [] + art = None + try: + initial_positions = (0.2, -0.3) + for index, position in enumerate(initial_positions): + articulations.append( + sim.add_articulation( + ArticulationCfg( + uid=f"slider_{index}", fpath=str(asset), init_qpos=[position] + ) + ) + ) + sim.prepare() + for art, position in zip(articulations, initial_positions, strict=True): + expected = torch.tensor([[position]]) + torch.testing.assert_close(art.get_qpos(), expected) + torch.testing.assert_close(art.get_qpos(target=True), expected) + finally: + art = None + articulations.clear() + sim.destroy(exit_process=False) + SimulationManager.flush_cleanup_queue() + + @pytest.mark.parametrize("device", ["cpu", "cuda:0"]) @pytest.mark.parametrize("env_ids", [None, [0]], ids=["all", "selected"]) @pytest.mark.parametrize("operation", ["reset", "root_pose", "qpos"]) diff --git a/tests/sim/objects/test_backend_physics_helpers.py b/tests/sim/objects/test_backend_physics_helpers.py index b844547ad..3d86209ca 100644 --- a/tests/sim/objects/test_backend_physics_helpers.py +++ b/tests/sim/objects/test_backend_physics_helpers.py @@ -49,6 +49,7 @@ ) from embodichain.lab.sim.objects.backends.newton import ( _stabilize_newton_mimic_target_write, + _supports_newton_position_targets, ) from embodichain.lab.sim.objects.backends.lifecycle import ( apply_rigid_initial_state, @@ -66,6 +67,29 @@ ) +@pytest.mark.parametrize( + "fields, expected", + [(None, True), (("joint_f",), False), (("joint_target_q",), True)], +) +def test_newton_hold_targets_follow_declared_channels(fields, expected) -> None: + scene = SimpleNamespace( + dynamics_capabilities=lambda: SimpleNamespace(supported_control_fields=fields) + ) + assert _supports_newton_position_targets(scene) is expected + + +def test_newton_hold_targets_distinguish_legacy_and_incomplete_sdks( + monkeypatch, +) -> None: + from dexsim.engine import newton_physics + + monkeypatch.delattr(newton_physics, "NewtonSolverExtension", raising=False) + assert _supports_newton_position_targets(SimpleNamespace()) + monkeypatch.setattr(newton_physics, "NewtonSolverExtension", object, raising=False) + with pytest.raises(RuntimeError, match="Scene.dynamics_capabilities"): + _supports_newton_position_targets(SimpleNamespace()) + + class _RigidEntity: def __init__(self, handle: int = 7) -> None: self.handle = handle diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py index 6472f7ddc..26a44b22b 100644 --- a/tests/sim/physics/test_simple_solver.py +++ b/tests/sim/physics/test_simple_solver.py @@ -179,7 +179,10 @@ def test_fixed_loop_cannot_be_treated_as_an_independent_slider(): @pytest.mark.requires_sim @pytest.mark.parametrize("owner", ["articulation", "manager"]) -def test_effort_only_reset_restores_selected_environment(tmp_path, owner): +@pytest.mark.parametrize("requires_grad", [False, True]) +def test_effort_only_reset_restores_selected_environment( + tmp_path, owner, requires_grad +): from embodichain.lab.sim import SimulationManager, SimulationManagerCfg from embodichain.lab.sim.cfg import ArticulationCfg @@ -198,7 +201,7 @@ def test_effort_only_reset_restores_selected_environment(tmp_path, owner): physics_cfg=NewtonPhysicsCfg( device="cpu", gravity=(0, 0, 0), - requires_grad=True, + requires_grad=requires_grad, collision_cfg=None, solver_cfg={"solver_type": "simple"}, ), @@ -207,8 +210,12 @@ def test_effort_only_reset_restores_selected_environment(tmp_path, owner): ) art = None try: - art = sim.add_articulation(ArticulationCfg(uid="slider", fpath=str(asset))) + initial = 0.0 if requires_grad else 0.1 + art = sim.add_articulation( + ArticulationCfg(uid="slider", fpath=str(asset), init_qpos=[initial]) + ) sim.prepare() + torch.testing.assert_close(art.get_qpos(), torch.full((2, 1), initial)) art.set_qpos(torch.tensor([[0.2], [0.4]]), target=False) art.set_qvel(torch.tensor([[0.3], [0.5]]), target=False) with pytest.raises(NotImplementedError, match="joint_target_q"): @@ -217,9 +224,11 @@ def test_effort_only_reset_restores_selected_environment(tmp_path, owner): art.reset(env_ids=[0]) else: sim.reset_objects_state(env_ids=[0]) - torch.testing.assert_close(art.get_qpos(), torch.tensor([[0.0], [0.4]])) + torch.testing.assert_close(art.get_qpos(), torch.tensor([[initial], [0.4]])) torch.testing.assert_close(art.get_qvel(), torch.tensor([[0.0], [0.5]])) - torch.testing.assert_close(art.get_qpos(target=True)[0], torch.zeros(1)) + torch.testing.assert_close( + art.get_qpos(target=True)[0], torch.tensor([initial]) + ) finally: art = None sim.destroy(exit_process=False) From 3fcc0fbdfbd8c6fa315042dbad3df72bd7fdd0e9 Mon Sep 17 00:00:00 2001 From: yuecideng Date: Sat, 24 Oct 2026 08:35:32 +0000 Subject: [PATCH 8/8] feat(sim): Configure custom Newton solvers with direct factories --- .../physics-configuration.md | 11 ++++-- embodichain/lab/sim/cfg/physics.py | 8 +++- .../lab/sim/physics/solvers/__init__.py | 2 +- examples/simple_newton_solver/README.md | 39 +++++++++++++++---- examples/simple_newton_solver/demo.py | 9 ++++- tests/sim/physics/test_simple_solver.py | 33 +++++++++++----- 6 files changed, 78 insertions(+), 24 deletions(-) diff --git a/agent_context/topics/simulation-system/physics-configuration.md b/agent_context/topics/simulation-system/physics-configuration.md index ef779171f..48991a4c0 100644 --- a/agent_context/topics/simulation-system/physics-configuration.md +++ b/agent_context/topics/simulation-system/physics-configuration.md @@ -25,9 +25,12 @@ Newton `solver_cfg=None` preserves DexSim's scene-aware `AutoSolverCfg`. The concrete solver is resolved from the complete Spawn scene, then exposed through `NewtonPhysicsBackend.solver_type`. Do not guess a solver-specific robot preset while it is unresolved. Gradient mode requires an explicit -`semi_implicit` solver or a named provider supporting the requested native +`semi_implicit` solver or a direct/named provider supporting the requested native gradient mode. Functional Scene calls additionally require its functional -dynamics service. Installed `dexsim.newton_solvers` names pass through to DexSim's +dynamics service. Python callers pass DexSim's `ProviderNewtonSolverCfg` with a +provider class/zero-argument factory directly through `solver_cfg`; this path +does not inspect installed metadata. DexSim creates fresh instances per model +build. Installed `dexsim.newton_solvers` names optionally pass through to DexSim's `RegisteredNewtonSolverCfg`; missing SDK capabilities fail at selection or provider loading rather than breaking ordinary backend imports. Read exact solver options/defaults in `cfg/simulation.py` and the installed DexSim config types; do not substitute a @@ -36,7 +39,9 @@ hardcoded fallback when an expected DexSim API is absent. `physics/solvers/simple.py` owns the optional spring-slider provider; `examples/simple_newton_solver/` demonstrates it through the public `SimulationManager.spawn_result` Scene. The installed SDK must include the -service API. DexSim owns physical state, clocks and publication; the provider +direct-factory and service APIs. The example uses direct factory configuration; +the `simple` entry point remains optional for name-based configurations. +DexSim owns physical state, clocks and publication; the provider owns its model-scoped parameters and functional transition. Independent rollout uses `publish=False`; publishing advances the live clock once and must not be paired with a manager update for the same transition. Scene functional diff --git a/embodichain/lab/sim/cfg/physics.py b/embodichain/lab/sim/cfg/physics.py index 67f58ecbc..eb71ea28e 100644 --- a/embodichain/lab/sim/cfg/physics.py +++ b/embodichain/lab/sim/cfg/physics.py @@ -311,7 +311,7 @@ class NewtonPhysicsCfg(PhysicsBackendCfg): requires_grad: bool = False """Whether to finalize the Newton model with differentiable state enabled. - Requires Semi-implicit or an installed named solver supporting the requested + Requires Semi-implicit or a direct/named provider supporting the requested gradient mode. The provider validates native support during model creation; Scene functional calls additionally require its functional dynamics service. CUDA graph capture is disabled when gradients are enabled. @@ -356,6 +356,12 @@ class NewtonPhysicsCfg(PhysicsBackendCfg): DexSim builds with the named-extension API. For example, ``{"solver_type": "simple", "stiffness": 2.0}`` selects the spring-slider demonstration. Extension names are exact; options pass to their provider. + + Python callers can pass DexSim's ``ProviderNewtonSolverCfg`` with a + ``provider_factory`` class or zero-argument factory and provider ``options``. + That configuration passes through directly without package discovery. The + SDK creates a fresh provider for each model build; pass a factory rather + than a live provider or solver instance. """ collision_cfg: NewtonCollisionPipelineCfg | Mapping[str, Any] | None = field( diff --git a/embodichain/lab/sim/physics/solvers/__init__.py b/embodichain/lab/sim/physics/solvers/__init__.py index 920744cf8..328ca6e0d 100644 --- a/embodichain/lab/sim/physics/solvers/__init__.py +++ b/embodichain/lab/sim/physics/solvers/__init__.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # ---------------------------------------------------------------------------- -"""Optional Newton solver providers, loaded through package entry points.""" +"""Optional Newton providers for direct factories or named package discovery.""" from __future__ import annotations diff --git a/examples/simple_newton_solver/README.md b/examples/simple_newton_solver/README.md index 42d517edc..a6cd101cb 100644 --- a/examples/simple_newton_solver/README.md +++ b/examples/simple_newton_solver/README.md @@ -1,19 +1,19 @@ # Simple Newton solver -This example installs a real `dexsim.newton_solvers` provider named `simple` -from EmbodiChain. It demonstrates an ordinary `SimulationManager.update()`, +This example passes `SimpleSolverProvider` directly to DexSim, without requiring +entry-point registration. It demonstrates an ordinary `SimulationManager.update()`, independent differentiable rollout, parameter/input gradients, publication into the live Scene, and partial functional/native reset in two environments. ## Prerequisite Use Python 3.10 or 3.11 with Torch and a DexSim build containing the -`FunctionalDynamics` service and `Scene.dynamics_capabilities()` revision of +`ProviderNewtonSolverCfg`, `FunctionalDynamics` service and `Scene.dynamics_capabilities()` revision of [DexSim MR !1483](http://192.168.3.16/Engine/dexverse/-/merge_requests/1483). The released DexSim pin in EmbodiChain does not yet provide that API. Normal EmbodiChain installations remain supported; this optional example requires the -integration SDK. The older callback and early service revisions without the -capability query are also insufficient. +integration SDK. Older callback/service revisions without the direct factory +configuration are also insufficient. In a dedicated EmbodiChain development environment, install this checkout and then the matching SDK wheel. Use `--no-deps` only for the EmbodiChain checkout, @@ -30,7 +30,7 @@ python examples/simple_newton_solver/demo.py --device cpu python examples/simple_newton_solver/demo.py --device cuda:0 ``` -Reinstall editable metadata after changing entry points. The command runs +The command runs headlessly and prints a JSON result with positions and nonzero gradients. It checks numerical equality and state isolation before reporting success. @@ -41,6 +41,7 @@ skip is not validation of the solver: ```bash python - <<'PY' from dexsim.engine.newton_physics import FunctionalDynamics +from dexsim.engine.newton_physics import ProviderNewtonSolverCfg from dexsim.scene import Scene assert callable(getattr(Scene, "dynamics_capabilities", None)) PY @@ -75,8 +76,30 @@ errors. This eager solver does not support CUDA Graph capture. ## API boundaries -`NewtonPhysicsCfg(solver_cfg={"solver_type": "simple", "stiffness": 2.0})` -uses installed metadata discovery; no global registration call is needed. +Python configuration passes the provider factory directly: + +```python +from dexsim.engine.newton_physics import ProviderNewtonSolverCfg +from embodichain.lab.sim.cfg import NewtonPhysicsCfg +from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider + +physics = NewtonPhysicsCfg( + solver_cfg=ProviderNewtonSolverCfg( + provider_factory=SimpleSolverProvider, + options={"stiffness": 2.0}, + ), +) +``` + +Pass a class or zero-argument factory, not an already-created provider/solver. +DexSim creates a fresh provider for every model build and applies the same +validation and lifecycle as named providers. This path does not scan installed +entry points. + +The package also keeps an optional `simple` entry point for data-based configs: +`NewtonPhysicsCfg(solver_cfg={"solver_type": "simple", "stiffness": 2.0})`. +Only that name-based path requires installed metadata; reinstall editable +metadata after changing entry points. No global registration call is needed. `requires_grad=True` enables the functional path. After `sim.prepare()`, use the public `sim.spawn_result` Scene: diff --git a/examples/simple_newton_solver/demo.py b/examples/simple_newton_solver/demo.py index c6ecb6bc4..04bb6b285 100644 --- a/examples/simple_newton_solver/demo.py +++ b/examples/simple_newton_solver/demo.py @@ -22,6 +22,7 @@ from pathlib import Path import torch +from dexsim.engine.newton_physics import ProviderNewtonSolverCfg from embodichain.lab.sim import SimulationManager, SimulationManagerCfg from embodichain.lab.sim.cfg import ( @@ -31,10 +32,11 @@ RigidBodyPhysicsCfg, ) from embodichain.lab.visualization import VisualizationCfg +from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider def run(device: str) -> dict[str, object]: - """Demonstrate discovery, parity, gradients, publication and partial reset.""" + """Demonstrate direct provider creation, parity, gradients and partial reset.""" config = SimulationManagerCfg( headless=True, visualization=VisualizationCfg(backend="none"), @@ -49,7 +51,10 @@ def run(device: str) -> dict[str, object]: requires_grad=True, use_cuda_graph=False, collision_cfg=None, - solver_cfg={"solver_type": "simple", "stiffness": 2.0, "damping": 0.2}, + solver_cfg=ProviderNewtonSolverCfg( + provider_factory=SimpleSolverProvider, + options={"stiffness": 2.0, "damping": 0.2}, + ), ), ) sim = SimulationManager(config, requires_native_renderer=False) diff --git a/tests/sim/physics/test_simple_solver.py b/tests/sim/physics/test_simple_solver.py index 26a44b22b..147832b60 100644 --- a/tests/sim/physics/test_simple_solver.py +++ b/tests/sim/physics/test_simple_solver.py @@ -27,15 +27,20 @@ from dexsim.engine import newton_physics -if not hasattr(newton_physics, "FunctionalDynamics"): +if not hasattr(newton_physics, "ProviderNewtonSolverCfg"): pytest.skip( - "Optional simple solver requires the DexSim FunctionalDynamics SDK; " + "Optional simple solver requires the DexSim direct-provider SDK; " "run its integration acceptance with the matching SDK wheel", allow_module_level=True, ) -from dexsim.engine.newton_physics import NewtonSolverContext, resolve_newton_solver +from dexsim.engine.newton_physics import ( + NewtonSolverContext, + ProviderNewtonSolverCfg, + resolve_newton_solver, +) from embodichain.lab.sim.cfg import NewtonPhysicsCfg +from embodichain.lab.sim.physics.solvers.simple import SimpleSolverProvider def _model(device="cpu", *, joint="prismatic", collision=False, gravity=(0, 0, 0)): @@ -52,8 +57,8 @@ def _model(device="cpu", *, joint="prismatic", collision=False, gravity=(0, 0, 0 return builder.finalize(device) -def _extension(model, **options): - provider = resolve_newton_solver("simple") +def _extension(model, *, named=False, **options): + provider = resolve_newton_solver("simple" if named else SimpleSolverProvider) return provider.create( NewtonSolverContext(model, model.control(), None, model.device, 0.1, {}), options, @@ -63,9 +68,10 @@ def _extension(model, **options): @pytest.mark.parametrize( "device", ["cpu", pytest.param("cuda:0", marks=pytest.mark.gpu)] ) -def test_discovered_solver_native_parity_and_analytical_gradients(device): +@pytest.mark.parametrize("named", [False, True]) +def test_solver_native_parity_and_analytical_gradients(device, named): model = _model(device) - extension = _extension(model, stiffness=0, damping=0) + extension = _extension(model, named=named, stiffness=0, damping=0) dynamics = extension.functional native = model.state() newton.eval_fk(model, native.joint_q, native.joint_qd, native) @@ -181,11 +187,18 @@ def test_fixed_loop_cannot_be_treated_as_an_independent_slider(): @pytest.mark.parametrize("owner", ["articulation", "manager"]) @pytest.mark.parametrize("requires_grad", [False, True]) def test_effort_only_reset_restores_selected_environment( - tmp_path, owner, requires_grad + tmp_path, monkeypatch, owner, requires_grad ): from embodichain.lab.sim import SimulationManager, SimulationManagerCfg from embodichain.lab.sim.cfg import ArticulationCfg + def no_discovery(): + pytest.fail("Direct solver configuration must not discover entry points") + + monkeypatch.setattr( + "dexsim.engine.newton_physics.solver_extensions._entry_points", no_discovery + ) + asset = tmp_path / "slider.urdf" asset.write_text(""" @@ -203,7 +216,9 @@ def test_effort_only_reset_restores_selected_environment( gravity=(0, 0, 0), requires_grad=requires_grad, collision_cfg=None, - solver_cfg={"solver_type": "simple"}, + solver_cfg=ProviderNewtonSolverCfg( + provider_factory=SimpleSolverProvider + ), ), ), requires_native_renderer=False,