Skip to main content

Quick start

Start with a tensor ODE if your equations fit f(t,y). Use a custom state/system when a simulation needs positions, velocities, auxiliary quantities and stage hooks to move together. Both paths use the same scheme selection and run control.

From a checkout, install with python -m pip install -e .. Contributors can use python -m pip install -e '.[test]' for the test dependencies.

Solve a known equation​

The equation y'=-y has the exact solution y(t)=y(0)*exp(-t). It is a useful first check because numerical error, time and the expected decay are all explicit.

import math
import torch
from warpSPHIntegrators import TensorState, tensor_rhs, solve
from warpSPHIntegrators.schemes import ExplicitRK

initial = TensorState(torch.tensor([1., 2.], dtype=torch.float64))
rhs = tensor_rhs(lambda t, y: -y)
run = solve(ExplicitRK.tsit5, initial, 1., rhs, dt=.1,
adaptive=True, rtol=1e-7, atol=1e-9)
assert run.successful
assert torch.allclose(run.state.y, initial.y*math.exp(-1), rtol=1e-6)
assert initial.t == 0. # solve owns the accepted/trial states

ExplicitRK.tsit5 selects Tsitouras 5(4). The main solution has order five; a fourth-order comparison estimates local error so the runner can adjust the step. An enum is an unambiguous configuration key. The catalog also accepts its exact stored lookup names; friendly display labels are not additional aliases.

Use float64 while checking a new problem against a known solution. Smaller steps eventually encounter floating-point or solver accuracy limits; they do not improve the result indefinitely.

Take a step yourself​

from warpSPHIntegrators import get_scheme, TensorState, tensor_rhs
from warpSPHIntegrators.schemes import ExplicitRK
import torch

method = get_scheme(ExplicitRK.rungeKutta4) # classical Runge–Kutta 4
state = TensorState(torch.ones(1, dtype=torch.float64))
step = method(state, .01, tensor_rhs(lambda t, y: -y))
assert step.step_status.successful
assert step.state.t == .01

A step returns IntegrationResult(state, stages, ...). Each StageResult pairs the physical update with optional auxiliary data. Some methods also return an error estimate and accepted-history candidate. In a manual loop, keep step.state and thread step.history into the next call for history methods. The run API handles acceptance, rejection and history for complete trajectories; direct stepping leaves those decisions to the caller.

Connect a complete simulation state​

The state protocol distinguishes fields by what should happen at a stage:

Field declarationBehavior
integrated(update_key, ...)Advance using a declared derivative, including every integrated quantity
constant(...)Carry a physical/configuration value without integrating it
copied(...)Refresh a value calculated at a physical stage, such as a density estimate
reference_state(...)Identify the state inside a larger system
tagged(...)Give fields physical roles used by typed component-update helpers

A system supplies new-stage construction and typed update operations. Its RHS receives (system, dt, ...) and returns an update, optionally followed by auxiliary values. Hooks can prepare stage data, process RHS results and finalize the accepted step. The integrator owns the stage clock.

This complete oscillator example shows those pieces without a particle-neighbor backend. The exact solution has position cos(2t) and velocity -2sin(2t).

"""Complete CPU example: python examples/harmonic_oscillator.py."""

from dataclasses import dataclass

import torch

from warpSPHIntegrators.state import (
BaseIntegrationSystem, BaseState, ComponentUpdateSpec, PositionUpdateSpec,
constant, get_reference_state, integrated, reference_state, tagged,
)
from warpSPHIntegrators import get_scheme
from warpSPHIntegrators.state.fields import update_component, update_position


@dataclass
class OscillatorState(BaseState):
position: torch.Tensor = integrated('dx_dt', tags=('position',))
velocity: torch.Tensor = integrated('dv_dt', tags=('velocity',))
mass: torch.Tensor = constant()
spring_constant: torch.Tensor = constant()


@dataclass
class OscillatorUpdate:
dx_dt: torch.Tensor = tagged(tags=('position_derivative',))
dv_dt: torch.Tensor = tagged(tags=('velocity_derivative',))


@dataclass
class OscillatorSystem(BaseIntegrationSystem):
state: OscillatorState = reference_state()
t: float = 0.0

def initializeNewState(self, *args, **kwargs):
return OscillatorSystem(get_reference_state(self).initializeNewState(), self.t)

def apply_position_update(self, update, spec, **kwargs):
return update_position(self, update, spec, 'position', 'position_derivative',
'velocity', 'velocity_derivative')

def apply_velocity_update(self, update, spec, **kwargs):
return update_component(self, update, spec, 'velocity', 'velocity_derivative')

def apply_state_update(self, update, spec, **kwargs):
position_spec = PositionUpdateSpec(derivative_dt=spec.derivative_dt, blend=spec.blend)
self.apply_position_update(update, position_spec, **kwargs)
self.apply_velocity_update(update, spec, **kwargs)
return self


def rhs(system, dt, **kwargs):
state = get_reference_state(system)
return OscillatorUpdate(
dx_dt=state.velocity,
dv_dt=-(state.spring_constant / state.mass) * state.position,
), None


def run_example():
system = OscillatorSystem(OscillatorState(
position=torch.tensor([1.0], dtype=torch.float64),
velocity=torch.tensor([0.0], dtype=torch.float64),
mass=torch.tensor([1.0], dtype=torch.float64),
spring_constant=torch.tensor([4.0], dtype=torch.float64),
))
scheme = get_scheme('RK4')
for _ in range(100):
result = scheme(system, dt=0.01, f=rhs)
system = result.state
return result


if __name__ == '__main__':
result = run_example()
state = get_reference_state(result.state)
print(f't={result.state.t:.2f}, x={state.position.item():.6f}, '
f'v={state.velocity.item():.6f}, stages={len(result.stages)}')

The example's update_position and update_component helpers use field tags, so additional quantities do not need one scheme-specific code path each. A custom finalizer must understand the selected family's quadrature. Ordinary RK uses scalar derivative weights; Nyström and exponential families can carry separate component or operator-weighted increments. Keep finalization compatible with the family's contract rather than treating all weights as interchangeable.

Add implicit solves when the equations need them​

Stiff equations can require tiny explicit steps for stability even when the visible solution varies slowly. ESDIRK and Radau methods solve stage equations; JFNK uses Newton corrections and matrix-free GMRES linear solves.

import torch
from warpSPHIntegrators import TensorState, tensor_rhs, solve
from warpSPHIntegrators.schemes import DIRK

run = solve(DIRK.esdirk324l2sa, TensorState(torch.ones(1, dtype=torch.float64)),
.5, tensor_rhs(lambda t, y: -10*y), dt=.05,
solver_opts={'matvec': 'jvp', 'tol': 1e-12, 'newton_tol': 1e-9})
assert run.successful
assert torch.isfinite(run.state.y).all()

Inspect convergence before interpreting cost or accuracy. A fixed Picard iteration budget can diverge on a stiff problem even when the underlying implicit method is stable. The solver guide explains the diagnostics, JVP/finite-difference choices and preconditioners.

Next, use method selection to choose the right family, runs for tolerances/events/restart, and options for the exact step controls. The API reference lists the focused imports for custom systems and backends.