Numerical Solvers¶
Pulsim's transient kernel supports two fixed-step integrators
(Tustin / BDF1) baked into pulsim.simulate(...), plus two
adaptive Runge-Kutta integrators (DormandPrince5 and
RadauIIA3) usable standalone against any user-supplied
f(t, x) → dx/dt callback.
Quick decision table¶
| Need | Use |
|---|---|
| Default SMPS / converter sim | pulsim.simulate(...) (Tustin fixed-step) |
| Stiff circuit (RC ≪ dt) | pulsim.simulate(..., integrator="bdf1") (L-stable, fixed) |
| Non-stiff ODE outside pulsim | DormandPrince5(f=...).solve(...) |
| Stiff ODE outside pulsim | RadauIIA3(f=...).solve(...) |
| Variable step inside pulsim | v1.6.0 — in-kernel coupling planned |
Fixed-step integrators (in-kernel)¶
Tustin (trapezoidal companion) — default¶
- 2nd-order, A-stable, NOT L-stable.
- Cheap: 1 sparse solve per step.
- Used for the bread-and-butter PWM-switched-mode workflow.
BDF1 (implicit Euler)¶
- 1st-order, L-stable.
- Used for stiff systems where Tustin would oscillate at large
dt. - Enabled by
opts.advanced.timestep.integrator = Bdf1.
Adaptive Runge-Kutta integrators (Phase 2.4, standalone)¶
Both integrators ship as pure-Python classes in
python/pulsim/integrators.py. They run against any
f(t, x) → dx/dt callback (numpy array in, numpy array out),
not yet coupled to the in-kernel pulsim.simulate solve —
that requires reformulating the cache to expose continuous
matrices, queued for v1.6.0.
DormandPrince5 — RK 5(4)¶
7-stage FSAL embedded pair (the "RK45" of MATLAB / SciPy fame).
- 5th-order solution + 4th-order embedded error estimate.
- FSAL → 6 effective
fevals per step (vs 7). - PI step controller with safety factor 0.9, growth ≤ 5×.
- Default
rtol=1e-5,atol=1e-8.
import pulsim as p
import numpy as np
def lorenz(t, x, sigma=10, rho=28, beta=8/3):
return np.array([sigma*(x[1]-x[0]),
x[0]*(rho-x[2]) - x[1],
x[0]*x[1] - beta*x[2]])
solver = p.DormandPrince5(f=lorenz, rtol=1e-8, atol=1e-10)
res = solver.solve(t_span=(0.0, 10.0), x0=np.array([1.0, 1.0, 1.0]))
print(f"{res.n_accepted} accepted, {res.n_rejected} rejected, "
f"{res.n_f_evals} f evals")
RadauIIA3 — implicit, L-stable¶
2-stage Radau IIA, order 3.
- L-stable — handles stiff ODEs that crash RK45.
- Newton inner solve on the 2n × 2n block system.
- Step-doubling error estimate (Richardson) — robust + simple.
- Optional analytical Jacobian via
jac=callable; defaults to central finite differences.
Speedup demo¶
examples/scripts/run_adaptive_rk_speedup.py — van der Pol stiff
oscillator (μ=100):
| Integrator | Steps | Wall-clock | vs Euler |
|---|---|---|---|
| Fixed-step Euler (dt=10 µs) | 49,999 | 86.3 ms | baseline |
| DormandPrince5 (rtol=1e-4) | 56 | 3.7 ms | 23× faster |
| RadauIIA3 (rtol=1e-4) | 18 | 7.0 ms | 12× faster |
DOPRI5 vs Radau final states agree to 1.49e-7. DOPRI5 wins wall-clock because each step is cheap (explicit). Radau wins step count because L-stability lets dt grow several orders of magnitude through the stiff transitions, at the cost of a Newton solve per step.
Tuning guide¶
| Knob | DormandPrince5 | RadauIIA3 | Effect |
|---|---|---|---|
rtol |
1e-5 | 1e-5 | Larger → fewer steps, lower accuracy. |
atol |
1e-8 | 1e-8 | Floor on the error scale (for states near zero). |
dt_min |
1e-12 | 1e-12 | Raises if controller wants smaller (typically means tolerances too tight). |
dt_max |
∞ | ∞ | Cap for non-stiff regions to avoid skipping events. |
safety |
0.9 | 0.9 | Multiplier on optimal step estimate. |
growth_max |
5.0 | 5.0 | Max single-step growth factor. |
newton_max_iter |
n/a | 8 | Radau: max Newton iterations per step. |
newton_tol |
n/a | 1e-8 | Radau: Newton convergence threshold. |
jac |
n/a | None | Radau: analytical Jacobian callable, else finite diff. |
What's NOT here (v1.5)¶
- In-kernel coupling — replace Tustin / BDF1 inside
pulsim.simulatewithDormandPrince5/RadauIIA3. NeedsPwlStateSpaceCacheto expose continuousA, bmatrices (currently Tustin-discretized). Scope: 3-4 weeks kernel refactor. Queued for v1.6.0. - Dense-output interpolants — needed for precise switching-event localisation. Both algorithms support them (Hermite for DOPRI5, Lagrange for Radau); not exposed in this release.
- DOPRI8(7) higher-order — only added on demand.
- Rosenbrock / Rodas semi-implicit (no Newton, one linear solve) — DOPRI5 + Radau cover the common cases.
See also¶
python/pulsim/integrators.py— source.python/pulsim/adaptive.py— coarse-grain Python-level adaptive driver wrapping fixed-stepsimulate()segments. Useful when you need adaptive stepping today without waiting for v1.6.0.openspec/changes/add-adaptive-runge-kutta-solvers/— proposal + design + delta spec.