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):
    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):
    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
world.set_gravity(g) model.gravity = g then solver.notify_model_changed(SolverNotifyFlags.ModelProperties)
world.apply_force(i, f) state.apply_force(i, f)
world.contacts the Contacts you passed in (after step)
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

config = novaphy.solvers.SolverSPH.Config()
solver = novaphy.solvers.SolverSPH(model, config)                  # reads/writes state.particle_*

config = novaphy.solvers.SolverIPC.Config()
solver = novaphy.solvers.SolverIPC(model, config)                  # CUDA, 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)

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

mpm_options = novaphy.solvers.SolverMPM.Config()
mpm = novaphy.solvers.SolverMPM(model, mpm_options)

Legacy top-level config names remain compatibility aliases: PBFConfig is SolverPBF.Config, SPHConfig is SolverSPH.Config, and IPCConfig is SolverIPC.Config when IPC is built.

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 the solver runs without contact constraints. Pass CollisionPipeline.contacts() and call collision_pipeline.collide(state, contacts) before solver.step(...) when you want rigid contacts.
  4. Gravity changes — mutate model.gravity (or per-world via model.gravity_per_world[i] = g) and 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):
    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.

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 is a zero-cost no-op.