Distributed under the Boost Software License, Version 1.0.
Write equations naturally in Rust, with symbolic Jacobians and adaptive stiff solvers.
Aizawa attractor computed by cauchy-ode
cauchy-ode numerically solves Cauchy's initial value problem for ordinary
differential equations and systems: find a trajectory
The Cargo package is named cauchy-ode. Rust identifiers cannot contain dashes,
so Cargo exposes the library as cauchy_ode: imports use use cauchy_ode::....
Solver integrates expressions with the adaptive England, Lawson, and
Rosenbrock methods. Build with Cargo and a Fortran compiler: Intel ifx on
Windows, or gfortran on Linux/macOS. Linking uses Intel oneMKL on Windows,
Accelerate on macOS, and BLAS/LAPACK on Linux. See Building.
See Building instructions for Windows, Linux, and macOS setup.
- MathExpr expressions: supported operators, functions, precedence, constants, conditional expressions, and symbolic derivatives.
- Examples: precision comparisons and interactive attractor demos.
- Building and CI: platform dependencies and verification commands.
use cauchy_ode::Solver;
fn main() {
// x' = t + sqrt(x), x(1) = 1, integrate to t = 2.
let solution = Solver::default()
.solve("t+sqrt(x)", 1.0, 1.0, 2.0)
.expect("ODE integration failed");
let final_x = solution.states.last().unwrap()[0]; // approximately 4
println!("x(2) = {final_x}");
}solve(rhs, start_time, initial_x, end_time) accepts t and x in the RHS.
Expressions use explicit multiplication (2*x), ^ for powers, and functions
such as sin, sqrt, and log (natural logarithm). The complete
expression reference lists all supported syntax.
For systems, use one expression per component and zero-based variable names:
use cauchy_ode::Solver;
fn main() {
// x0' = x1, x1' = -x0.
let solution = Solver::default().solve_system(
&["x1", "-x0"], 0.0, &[1.0, 0.0], std::f64::consts::TAU,
).expect("ODE integration failed");
println!("Final state: {:?}", solution.states.last().unwrap());
}Choose an algorithm with the method field.
England remains the default.
For stiff systems Rosenbrock is recommended.
The four system types below share the initial condition
Type 1 — General systems (OdeSystem::general):
Type 2 — Autonomous systems (OdeSystem::autonomous), with no explicit
dependence on time:
Type 3 — Linear systems (OdeSystem::linear):
Here
Type 4 — Systems with a constant linear part (OdeSystem::split):
Here
Methods shipped with the library:
- England: Runge–Kutta process modification developed by R. England. A fast and precise fifth-order method suitable for solving systems of Type 1. See [1].
- Lawson: Exponential method modification developed by J. D. Lawson. Recommended for linear and quasi-linear systems of Types 1, 3, and 4, including stiff ones. The method is A-stable for linear systems: stability does not impose an upper step-size limit for decaying linear test modes. Accuracy still depends on step size. See [2].
- Rosenbrock: Implicit process developed by H. H. Rosenbrock. Recommended for nonlinear systems of Types 1 and 2, including stiff ones. See [3].
| Method | Fortran routine | System constructor | Required callbacks |
|---|---|---|---|
England |
SENGL |
Any | RHS |
Lawson |
SLOUN |
Any | RHS, symbolic Jacobian |
LawsonLinear |
SLOUI |
OdeSystem::linear |
A(t), phi(t) |
LawsonSplit |
SLOUU |
OdeSystem::split |
u(t,x), constant B |
Rosenbrock |
SROSN |
Any | RHS, symbolic Jacobian, partial time derivative |
RosenbrockAutonomous |
SROSA |
OdeSystem::autonomous |
RHS, symbolic Jacobian |
For custom variable names and reusable parsed systems:
use cauchy_ode::{Method, OdeSystem, Solver};
fn main() {
let system = OdeSystem::general("time", &["position", "velocity"], &[
"velocity", "-position - 0.1*velocity + sin(time)",
]).unwrap();
let solver = Solver { method: Method::Rosenbrock, ..Solver::default() };
let solution = solver.solve_problem(&system, 0.0, &[1.0, 0.0], 10.0).unwrap();
println!("Final state: {:?}", solution.states.last().unwrap());
}OdeSystem::new and OdeSystem::new_with_parameters remain available as
compatibility aliases for general and general_with_parameters.
OdeSystem::autonomous(&["x", "y"], &["y", "-x"]) declares a time-independent
system. For the convenience solve and solve_system methods, selecting
RosenbrockAutonomous declares the expressions autonomous and rejects t.
Unknown variables and incompatible method/system combinations are errors.
The scalar convenience methods accept both x and x0; they refer to the
same component, including when calculating its derivatives.
OdeSystem itself has no implicit aliases: state and time names are exactly
those declared in its constructor. For example, a system declaring only x0
rejects undeclared x. You can name time clock or even x, or use t as a
state name when time has another name. Names must be valid, distinct identifiers.
Use the _with_parameters constructors to bind constants before name validation
and symbolic differentiation:
use cauchy_ode::{OdeSystem, Solver};
fn main() {
let system = OdeSystem::general_with_parameters(
"clock",
&["position", "velocity"],
&["velocity", "-stiffness*position-damping*velocity+sin(clock)"],
&[("stiffness", 4.0), ("damping", 0.2)],
).unwrap();
let trajectory = Solver::default()
.solve_problem(&system, 0.0, &[1.0, 0.0], 10.0)
.unwrap();
println!("Final state: {:?}", trajectory.states.last().unwrap());
}Bindings are &[(&str, f64)]. Values must be finite; parameter names must be valid
identifiers, unique, and distinct from state/time names. Missing bindings are
errors; unused bindings are allowed so a shared configuration can serve multiple
systems. Substitution operates on expression trees, not text, and does not alter
longer names or function calls. Values are copied into the system: rebuild it
with new bindings to change parameters.
The four constructors are general_with_parameters, autonomous_with_parameters,
linear_with_parameters, and split_with_parameters; the existing constructors
remain available without bindings. Linear bindings apply to both the matrix and
forcing. The parameterized split constructor takes matrix expression strings
instead of the numeric matrix accepted by split:
fn main() {
let _system = cauchy_ode::OdeSystem::split_with_parameters(
"clock", &["amount"], &["-rate"], &["-loss*amount^2"],
&[("rate", 2.0), ("loss", 1.0)],
).unwrap();
}After substitution, its matrix must evaluate to finite constants; the remainder may still depend on time and state. Parameters are numeric constants, not runtime variables or expressions depending on other parameters.
For
use cauchy_ode::{Method, OdeSystem, Solver};
fn main() {
// Column-major A(t) = [[-2, t], [0, -3]].
let system = OdeSystem::linear("t", &["x", "y"],
&["-2", "0", "t", "-3"], &["sin(t)", "0"]).unwrap();
let solver = Solver { method: Method::LawsonLinear, ..Solver::default() };
let solution = solver.solve_problem(&system, 0.0, &[1.0, 0.0], 2.0).unwrap();
println!("Final state: {:?}", solution.states.last().unwrap());
}For
use cauchy_ode::{Method, OdeSystem, Solver};
fn main() {
let system = OdeSystem::split("t", &["x", "y"],
&[-100.0, 0.0, 2.0, -3.0], &["sin(t)-x^3", "x*y"]).unwrap();
let solver = Solver { method: Method::LawsonSplit, ..Solver::default() };
let solution = solver.solve_problem(&system, 0.0, &[1.0, 0.0], 2.0).unwrap();
println!("Final state: {:?}", solution.states.last().unwrap());
}All matrices, including Jacobian callbacks, use Fortran column-major order:
index column * dimension + row. The supplied SLOUI implements linear; represent that
case with the complete RHS in OdeSystem::general and select general Lawson.
The result contains the initial point and every accepted adaptive step in times and states,
including the requested endpoint. recommended_steps
stores the signed initial step and the recommended next step after each accepted
step, matching the old product's solution table. Actual step lengths are the
differences between successive times. Earlier end times integrate backward.
Configure initial_step, min_step, max_step, tolerance,
relative_threshold, and max_steps on Solver. Tolerance is a local error
criterion, not a bound on global error. The final step may be shorter than
min_step to reach the endpoint.
Starting from the same numerical state at
time
Implementation note: the current Lawson and Rosenbrock routines explicitly use
step doubling with CRUNGE = 1/15 = 1/(2^4-1) and retain the two-half-step
result. SENGL instead computes its discrepancy from a weighted combination of
shared England stages; it does not perform the full-step/two-half-step comparison
above. The routines use the maximum absolute component of the discrepancy,
dividing by the maximum absolute solution component when that amplitude reaches
relative_threshold, before comparing against tolerance.
Expressions are parsed when the system is created. Symbolic computation trees
are prepared once per solve, and the variable map is reused by callbacks.
The existing MathExpr differentiation rules apply, including piecewise
functions; callers should choose equations whose required derivatives exist
along the integration stages.
All callbacks carry an opaque Rust context, call MathExpr::evaluate, and
return an error status. No global callback state is used. Every step routine
and callback takes PC (pointer to context) as its first argument, passing TYPE(C_PTR) by
reference. Callback signatures are declared in ftn/solver_callbacks.f90.
The new routines export cauchy_ode_sloun, cauchy_ode_sloui, cauchy_ode_slouu,
cauchy_ode_srosn, and cauchy_ode_srosa. Their callbacks and Lawson step helpers
include an IERR output argument; failures return immediately.
MEXP and INV adapters call the existing matrix exponential and inverse
implementations, and MPP adds the identity for Rosenbrock's I-h*J matrix.
Parse/evaluation errors, non-finite values, step limits and time stagnation
return SolverError. Fortran status 65 indicates a minimum-step tolerance
failure. Matrix exponential errors use 1000 + abs(INFO) (1003 also covers
non-finite matrices), and inverse errors use 2000 + abs(INFO); negative
statuses -1000 and -2000 indicate allocation and non-finite inverse data errors.
These statuses are preserved in SolverError::FortranFailure, including a
singular Rosenbrock stage matrix, rather than being overwritten with 65.
cargo test --test stiff_systems -- --nocapturetests/stiff_systems.rs exercises three multiscale
problems. Here “sub-linear” is interpreted as the project's semilinear split
form x' = Bx + u(t,x), not a sublinear-growth condition on u.
| Problem | Equations and initial state | Jacobian eigenvalues |
|---|---|---|
| Constructed coupled linear decay | x'=-1e9*x+999999999*y, y'=-y, z'=-1e-9*z; (2,1,1) |
Exactly {-1e9,-1,-1e-9} |
Kaps, semilinear (epsilon=1e-9) |
x'=-1000000002*x+1e9*y², y'=x-y-y²; (1,1) |
Approximately {-1e9,-1} along the exact trajectory |
| Robertson, nonlinear kinetics | x'=-0.04*x+1e4*y*z, y'=0.04*x-1e4*y*z-3e7*y², z'=3e7*y²; (1,0,0) |
State-dependent; nonzero-mode ratio exceeds 1e12 near the tested endpoint; mass conservation supplies a zero mode |
The linear test checks every accepted point against
(exp(-1e9*t)+exp(-t), exp(-t), exp(-1e-9*t)) on [0,1], including the fast
initial layer. The ultraslow component is almost constant on this interval.
All five Lawson/Rosenbrock variants pass; explicit England is checked for
step-budget exhaustion instead.
Kaps' benchmark and exact solution
give (exp(-2*t),exp(-t)); both Rosenbrock variants are checked against this
throughout [0,1]. Its split uses
B=[[-1000000002,0],[1,-1]] and u=[1e9*y²,-y²].
Known limitation: both split and general Lawson exhaust the deliberately
bounded step budget at this stiffness. In particular, this split leaves a large
derivative in the explicit nonlinear remainder; a stiff constant matrix alone
does not guarantee an efficient split.
For Robertson kinetics,
both Rosenbrock variants reach t=4e10 and are compared against SUNDIALS'
independently computed reference values, including the tiny intermediate
concentration. Every accepted state is checked for mass conservation and
nonnegative concentrations within numerical tolerance. General Lawson's
step-budget limitation at the requested tight tolerance is also recorded.
Tests check finite, increasing times, Jacobian eigenvalue separation, and steps well beyond the fastest time scale—not just successful return codes. Eigenvalue separation is a useful stiffness indicator, not a complete definition: trajectory, interval, and stability restrictions also matter. These tests do not change the solver algorithms or assert that every method is efficient on every stiff system.
[1] R. England. Error Estimates for Runge-Kutta Type Solutions to Systems of Ordinary Differential Equations. Research and Development Department, Pressed Steel Fisher Ltd., Cowley, Oxford, UK. October 1968.
[2] J. D. Lawson. Generalized Runge-Kutta Processes for Stable Systems with Large Lipschitz Constants. SIAM Journal on Numerical Analysis, 1967, vol. 4, no. 3.
[3] H. H. Rosenbrock. Some General Implicit Processes for the Numerical Solution of Differential Equations. The Computer Journal, vol. 5 (1963), pp. 329–330.
[4] Ordinary Differential Equations, Section 1.3.1, “Step doubling (Runge's principle),” p. 4. Aarhus University, Practical Programming and Numerical Methods course notes, 2025. PDF.
