Upgrade guide: world.step → solver.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 fromModel.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.
Recommended pattern¶
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¶
- Aliased state. Passing the same
SimStatefor bothstate_inandstate_outis the in-place case; pass distinct buffers if you need the input preserved. control=Noneuses the defaults baked into theModel(matching Newton). Pass an explicitmodel.control()if you want to feed injoint_f,joint_target_pos, orjoint_target_vel. Body forces and torques live onSimState.body_f/state.apply_force(...)/state.apply_torque(...).contacts=Nonemeans most solvers run without contact constraints. PassCollisionPipeline.contacts()and callcollision_pipeline.collide(state, contacts)beforesolver.step(...)when you want rigid contacts.SolverIPCis the exception: libuipc handles collision internally and ignores the NovaPhyContactsargument.- Gravity changes — call
model.set_gravity(g)for all worlds ormodel.set_gravity(g, world=i)for one world, then callsolver.notify_model_changed(novaphy.solvers.SolverNotifyFlags.ModelProperties). - No top-level shortcut. Mirroring Newton 1:1, NovaPhy does not
expose
novaphy.SolverXXXat the top level — every solver, configuration object and notify-flag enum lives undernovaphy.solvers.*only. Comparenewton/__init__.py: it exportsModel/ModelBuilder/State/Control/Contacts/eval_fketc. but noSolverXXX;newton.solvers.SolverXXX(model)is the single entry.
Runtimeshims removed. Earlier preview builds shippednovaphy.SolverRuntime/novaphy.FeatherstoneRuntime/XPBDSolverRuntimeas demo wrappers that bundled the quintet behind a singleruntime.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 drivessolver.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_arearigid_contact_patch_depthrigid_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.