Skip to content

Upgrade guide: world.stepsolver.step

NovaPhy 0.4.0 realigned the stepping API with Newton's solver.step(state_in, state_out, control, contacts, dt) contract — see newton/_src/solvers/solver.py.

If you are coming from a 0.3.x codebase that constructed novaphy.World, this page lists the call-site changes you need to make.

Why

Newton's primary forward-dynamics entry is:

class SolverBase:
    def __init__(self, model: Model):
        self.model = model

    def step(self, state_in, state_out, control, contacts, dt) -> None:
        ...

There is no World concept — solvers consume Model + State + Control + Contacts directly. NovaPhy historically funneled stepping through World.step(dt), which:

  • hid the canonical Newton entry behind a wrapper;
  • held its own gravity_ field that could drift from Model.gravity;
  • rolled multi-solver fluid + rigid orchestration into one opaque call.

The 0.4.0 refactor removed World and made solver.step the canonical forward-dynamics entry.

import novaphy

builder = novaphy.ModelBuilder()
# ... add bodies, shapes, joints, fluid blocks ...
model = builder.finalize()

config   = novaphy.solvers.SolverSemiImplicit.Config()
solver   = novaphy.solvers.SolverSemiImplicit(model, config)
state    = model.state()
control  = model.control()
collision_pipeline = novaphy.CollisionPipeline(model)
contacts = collision_pipeline.contacts()

dt = 1.0 / 240.0
for _ in range(steps):
    state.clear_forces()
    collision_pipeline.collide(state, contacts)
    solver.step(state, state, control, contacts, dt)

state_in and state_out may alias (in-place stepping). Pass two distinct SimState objects when the input buffer must be preserved:

state_a = model.state()
state_b = model.state()
for _ in range(steps):
    state_a.clear_forces()
    collision_pipeline.collide(state_a, contacts)
    solver.step(state_a, state_b, control, contacts, dt)
    state_a, state_b = state_b, state_a   # swap

Call-site mapping

Before After
world = novaphy.World(model) config = novaphy.solvers.SolverSemiImplicit.Config()
solver = novaphy.solvers.SolverSemiImplicit(model, config)
world = novaphy.World(model, multibody_settings=mbs) config = novaphy.solvers.SolverFeatherstone.Config()
copy mbs fields onto config, then solver = novaphy.solvers.SolverFeatherstone(model, config)
world = novaphy.World.make_xpbd(model) config = novaphy.solvers.SolverXPBD.Config()
solver = novaphy.solvers.SolverXPBD(model, config)
world.step(dt) solver.step(state, state, control, contacts, dt)
world.step(world.state, world.state, None, None, dt) solver.step(state, state, None, contacts, dt)
world.state the SimState you passed in
world.gravity model.gravity_for_world(0) (or inspect the model.gravity device array)
world.set_gravity(g) model.set_gravity(g) then solver.notify_model_changed(SolverNotifyFlags.ModelProperties)
world.apply_force(i, f) state.apply_force(i, f)
world.contacts the Contacts populated by collision_pipeline.collide(...)
fluid_world.step(dt) explicit pbf_solver.step(...) + solver.step(...) chain (see below)
world.performance_monitor monitor = novaphy.PerformanceMonitor() then with monitor.scoped(): solver.step(...)

Solver constructors

NovaPhy solver constructors use a C++-backed nested Config class. The shape is the same for every solver; each solver's config carries its own attribute set.

config = novaphy.solvers.SolverSemiImplicit.Config()
solver = novaphy.solvers.SolverSemiImplicit(model, config)         # semi-implicit Euler + PGS

config = novaphy.solvers.SolverXPBD.Config()
config.iterations = 20
solver = novaphy.solvers.SolverXPBD(model, config)                 # XPBD maximal-coordinate

config = novaphy.solvers.SolverFeatherstone.Config()
config.angular_damping = 0.05
solver = novaphy.solvers.SolverFeatherstone(model, config)         # ABA + PGS articulated

config = novaphy.solvers.SolverPBF.Config()
solver = novaphy.solvers.SolverPBF(model, config)                  # then initialize_state

if novaphy.has_sph_cuda():
    config = novaphy.solvers.SolverSPH.Config()
    solver = novaphy.solvers.SolverSPH(model, config)              # CUDA-only runnable backend

if novaphy.has_ipc():
    config = novaphy.solvers.SolverIPC.Config()
    solver = novaphy.solvers.SolverIPC(model, config)              # libuipc-backed

The config-object construction path is preferred for solver-specific option bundles. The config classes are pybind-exposed C++ structs, so each solver can carry its own attribute set without introducing Python dataclasses:

pbf_options = novaphy.solvers.SolverPBF.Config()
pbf_options.solver_iterations = 8
pbf = novaphy.solvers.SolverPBF(model, pbf_options)

if novaphy.has_sph_cuda():
    sph_options = novaphy.solvers.SolverSPH.Config()
    sph_options.substeps = 4
    sph = novaphy.solvers.SolverSPH(model, sph_options)

SolverSPH has no runnable CPU backend. SolverLBM is also CUDA-only at runtime: its class and configuration are always available, but numerical operations require NOVAPHY_WITH_LBM_CUDA=ON. Construct it with a model and check solver.has_cuda_backend before seeding or stepping. SolverMPM remains the API-parity scaffold whose step() raises.

Module-level config names under novaphy.solvers remain compatibility aliases: PBFConfig is SolverPBF.Config, SPHConfig is SolverSPH.Config, and IPCConfig is SolverIPC.Config when IPC is built. They are not exported from the top-level novaphy namespace.

Power-user post-construction tuning remains available for rigid solvers::

config = novaphy.solvers.SolverSemiImplicit.Config()
config.velocity_iterations = 30
config.warm_starting = True
config.sleep_enabled = True
solver = novaphy.solvers.SolverSemiImplicit(model, config)

config = novaphy.solvers.SolverXPBD.Config()
config.iterations = 20
config.velocity_damping = 0.99
solver = novaphy.solvers.SolverXPBD(model, config)

config = novaphy.solvers.SolverFeatherstone.Config()
config.pgs_iterations = 50
config.pgs_slop = 0.001
solver = novaphy.solvers.SolverFeatherstone(model, config)

solver.model returns the bound model (Newton-aligned attribute).

Common pitfalls

  1. Aliased state. Passing the same SimState for both state_in and state_out is the in-place case; pass distinct buffers if you need the input preserved.
  2. control=None uses the defaults baked into the Model (matching Newton). Pass an explicit model.control() if you want to feed in joint_f, joint_target_pos, or joint_target_vel. Body forces and torques live on SimState.body_f / state.apply_force(...) / state.apply_torque(...).
  3. contacts=None means most solvers run without contact constraints. Pass CollisionPipeline.contacts() and call collision_pipeline.collide(state, contacts) before solver.step(...) when you want rigid contacts. SolverIPC is the exception: libuipc handles collision internally and ignores the NovaPhy Contacts argument.
  4. Gravity changes — call model.set_gravity(g) for all worlds or model.set_gravity(g, world=i) for one world, then call solver.notify_model_changed(novaphy.solvers.SolverNotifyFlags.ModelProperties).
  5. No top-level shortcut. Mirroring Newton 1:1, NovaPhy does not expose novaphy.SolverXXX at the top level — every solver, configuration object and notify-flag enum lives under novaphy.solvers.* only. Compare newton/__init__.py: it exports Model / ModelBuilder / State / Control / Contacts / eval_fk etc. but no SolverXXX; newton.solvers.SolverXXX(model) is the single entry.

Runtime shims removed. Earlier preview builds shipped novaphy.SolverRuntime / novaphy.FeatherstoneRuntime / XPBDSolverRuntime as demo wrappers that bundled the quintet behind a single runtime.step(dt=...) call. They have been deleted to match Newton's surface 1:1 — every demo, test, and production caller now constructs (model, solver, state, control, contacts) directly and drives solver.step(state_in, state_out, control, contacts, dt).

Fluid + rigid coupling

The unified PBF + Akinci coupling + rigid orchestration that previously lived inside World::step is now an explicit user-level chain:

pbf_config = novaphy.solvers.SolverPBF.Config()
rigid_config = novaphy.solvers.SolverSemiImplicit.Config()
pbf      = novaphy.solvers.SolverPBF(model, pbf_config)
solver   = novaphy.solvers.SolverSemiImplicit(model, rigid_config)
state    = model.state()
collision_pipeline = novaphy.CollisionPipeline(model)
contacts = collision_pipeline.contacts()
pbf.initialize_state(state)

for _ in range(steps):
    state.clear_forces()
    collision_pipeline.collide(state, contacts)
    pbf.step(state, state, None, contacts, dt)
    collision_pipeline.collide(state, contacts)
    solver.step(state, state, None, contacts, dt)

collision_pipeline.collide writes both rigid_contact_* and soft_contact_* channels. SolverPBF consumes the soft-point channel, and the rigid solver consumes the rigid contact rows, so the two passes compose through the same aggregate.

Contact metadata changes

Hydroelastic collision metadata is now exposed through public Contacts SoA columns instead of being an internal collision-pipeline detail:

  • rigid_contact_patch_area
  • rigid_contact_patch_depth
  • rigid_contact_pressure

The arrays are allocated with rigid_contact_max, alongside the other rigid_contact_* columns. Non-hydro rigid contacts store zero patch metadata. Hydroelastic rows store positive patch area, signed patch depth, and pressure; the public penetration still follows max(0, -2 * rigid_contact_patch_depth).

If downstream code previously probed for hydro fields with hasattr, update it to read these arrays directly and filter rows by rigid_contact_patch_area > 0. This matches the Newton-style public Contacts contract used by CollisionPipeline -> Contacts -> Solver.step(...).

Profiling

World.performance_monitor is replaced by a standalone monitor driven externally with a thread-local context manager (Newton's event_scope pattern):

monitor = novaphy.PerformanceMonitor()
monitor.enabled = True
monitor.trace_enabled = True

for _ in range(steps):
    with monitor.scoped():
        solver.step(state, state, control, contacts, dt)

for stat in monitor.phase_stats():
    print(stat.name, stat.avg_ms)
monitor.write_trace_json("trace.json")

Outside the with block every C++ phase scope returns without recording a timestamp.