Skip to content

Integro-Differential Equations

Integro-differential equations (IDEs) combine differential and integral operators, modeling systems where the rate of change depends not only on the current state but on a weighted accumulation of the entire past. They appear in viscoelasticity (hereditary materials), population dynamics with age structure, heat conduction with memory, and radiative transfer. Numra provides the numra-ide crate with quadrature-based Volterra solvers and an efficient Prony series solver for sum-of-exponentials kernels.

A Volterra integro-differential equation has the form:

y(t)=f(t,y)+0tK(t,s,y(s))ds,y(0)=y0y'(t) = f(t, y) + \int_0^t K(t, s, y(s)) \, ds, \quad y(0) = y_0

where:

  • f(t,y)f(t, y) is the local (instantaneous) right-hand side,
  • K(t,s,y(s))K(t, s, y(s)) is the memory kernel encoding history dependence,
  • the integral accumulates contributions from the entire past s[0,t]s \in [0, t].

A kernel is a convolution kernel if it depends only on the time difference:

K(t,s,y(s))=K^(ts)h(y(s))K(t, s, y(s)) = \hat{K}(t-s) \cdot h(y(s))

Convolution kernels can be computed more efficiently and are the most common type in physical applications.

Comparison with Other Memory-Dependent DEs

Section titled “Comparison with Other Memory-Dependent DEs”
Equation TypeMemory StructureComputational Cost
ODENo memoryO(N)O(N) total
DDEDiscrete delay pointsO(N)O(N) total
FDEPower-law kernel (Caputo)O(N2)O(N^2) total
IDEGeneral kernelO(N2)O(N^2) total
IDE (Prony)Sum of exponentialsO(N)O(N) total

Every IDE in Numra implements the IdeSystem trait:

pub trait IdeSystem<S: Scalar> {
/// Dimension of the state space.
fn dim(&self) -> usize;
/// Evaluate the local right-hand side f(t, y).
fn rhs(&self, t: S, y: &[S], f: &mut [S]);
/// Evaluate the memory kernel K(t, s, y(s)).
fn kernel(&self, t: S, s: S, y_s: &[S], k: &mut [S]);
/// Whether K depends only on (t-s), not t and s separately.
fn is_convolution_kernel(&self) -> bool { false }
}

The rhs method evaluates the non-integral part, and kernel evaluates the integrand at a specific past time ss given the solution y(s)y(s) at that time.

Numra provides several built-in kernel types via the Kernel trait:

K(τ)=aebτK(\tau) = a \, e^{-b\tau}

Models the Maxwell element in viscoelasticity. Memory decays exponentially.

use numra_ide::kernels::ExponentialKernel;
let kernel = ExponentialKernel::new(2.0, 0.5);
// K(0) = 2.0, K(2) = 2 * exp(-1) ~ 0.736
K(τ)=aταK(\tau) = a \, \tau^{-\alpha}

Models fractional memory effects. Singular at τ=0\tau = 0 (regularized to zero in Numra). For 0<α<10 < \alpha < 1, this is a weakly singular kernel.

use numra_ide::kernels::PowerLawKernel;
let kernel = PowerLawKernel::new(1.0, 0.5);
// K(1) = 1.0, K(4) = 0.5
K(τ)=i=1maiebiτK(\tau) = \sum_{i=1}^{m} a_i \, e^{-b_i \tau}

The most computationally efficient kernel type because the integral can be updated recursively without storing the full history.

use numra_ide::kernels::PronyKernel;
// Two-term generalized Maxwell model
let kernel = PronyKernel::two_term(1.0, 0.5, 2.0, 1.0);
// K(0) = 1 + 2 = 3, K(2) = exp(-1) + 2*exp(-2) ~ 0.639
K(τ)=τβ1Eα,β(λτα)K(\tau) = \tau^{\beta-1} \, E_{\alpha,\beta}(-\lambda \tau^\alpha)

Generalizes both exponential (α=β=1\alpha = \beta = 1) and power-law kernels.

use numra_ide::kernels::MittagLefflerKernel;
let kernel = MittagLefflerKernel::relaxation(1.0, 0.5);

The basic solver uses composite trapezoidal quadrature for the integral and explicit Euler for the time step:

yn+1=yn+Δt[f(tn,yn)+0tn+1K(tn+1,s,y(s))ds]y_{n+1} = y_n + \Delta t \left[ f(t_n, y_n) + \int_0^{t_{n+1}} K(t_{n+1}, s, y(s)) \, ds \right]

The integral is approximated with the trapezoidal rule over all stored history points.

  • Time stepping: Explicit Euler
  • Quadrature: Composite trapezoidal
  • Cost per step: O(n)O(n) kernel evaluations
  • Total cost: O(N2)O(N^2)

An improved solver that replaces explicit Euler with classical 4th-order Runge-Kutta for the time-stepping:

  • Time stepping: Classical RK4
  • Quadrature: Composite trapezoidal
  • Accuracy: Significantly better than Euler for the same step size
  • Cost per step: 4×O(n)4 \times O(n) kernel evaluations (four RK stages)

For kernels that are sums of exponentials, the PronySolver achieves O(1)O(1) cost per step by recursively updating auxiliary variables. The key insight is that for

I(t)=0taeb(ts)y(s)dsI(t) = \int_0^t a \, e^{-b(t-s)} y(s) \, ds

the integral satisfies I(t)=ay(t)bI(t)I'(t) = a \, y(t) - b \, I(t), converting the IDE into an augmented ODE system.

use numra_ide::IdeOptions;
let opts = IdeOptions::default()
.dt(0.01) // Time step
.max_steps(100_000) // Maximum number of steps
.tol(1e-10) // Tolerance for implicit iteration
.quad_points(4); // Quadrature points per step
OptionDefaultDescription
dt0.01Time step size
max_steps100,000Maximum number of steps
tol1e-10Convergence tolerance
max_iter100Maximum iterations per step
quad_points4Gauss-Legendre quadrature points per subinterval

A system with exponential memory kernel modeling a viscoelastic material:

y(t)=ky(t)+0te(ts)y(s)ds,y(0)=1y'(t) = -k \, y(t) + \int_0^t e^{-(t-s)} y(s) \, ds, \quad y(0) = 1
use numra_ide::{IdeSystem, VolterraSolver, IdeSolver, IdeOptions};
struct ExponentialMemory { k: f64 }
impl IdeSystem<f64> for ExponentialMemory {
fn dim(&self) -> usize { 1 }
fn rhs(&self, _t: f64, y: &[f64], f: &mut [f64]) {
f[0] = -self.k * y[0];
}
fn kernel(&self, t: f64, s: f64, y_s: &[f64], k: &mut [f64]) {
k[0] = (-(t - s)).exp() * y_s[0];
}
fn is_convolution_kernel(&self) -> bool { true }
}
let system = ExponentialMemory { k: 1.0 };
let opts = IdeOptions::default().dt(0.01);
let result = VolterraSolver::solve(&system, 0.0, 5.0, &[1.0], &opts)
.expect("Solve failed");
for (i, &t) in result.t.iter().enumerate().step_by(50) {
println!("t = {:.2}, y = {:.6}", t, result.y_at(i)[0]);
}

For the same problem with improved time-stepping accuracy:

use numra_ide::{IdeSystem, VolterraRK4Solver, IdeSolver, IdeOptions};
// Same system definition as above...
let opts = IdeOptions::default().dt(0.05); // Can use larger step with RK4
let result = VolterraRK4Solver::solve(&system, 0.0, 5.0, &[1.0], &opts)
.expect("Solve failed");

Example: Viscoelastic Material with Prony Kernel

Section titled “Example: Viscoelastic Material with Prony Kernel”

For a generalized Maxwell model with two relaxation times:

y(t)=y(t)+0t[a1eb1(ts)+a2eb2(ts)]y(s)dsy'(t) = -y(t) + \int_0^t \left[ a_1 e^{-b_1(t-s)} + a_2 e^{-b_2(t-s)} \right] y(s) \, ds
use numra_ide::{IdeSystem, VolterraSolver, IdeSolver, IdeOptions};
use numra_ide::kernels::PronyKernel;
struct ViscoelasticMaterial {
kernel: PronyKernel<f64>,
}
impl IdeSystem<f64> for ViscoelasticMaterial {
fn dim(&self) -> usize { 1 }
fn rhs(&self, _t: f64, y: &[f64], f: &mut [f64]) {
f[0] = -y[0];
}
fn kernel(&self, t: f64, s: f64, y_s: &[f64], k: &mut [f64]) {
use numra_ide::Kernel;
k[0] = self.kernel.evaluate(t - s) * y_s[0];
}
fn is_convolution_kernel(&self) -> bool { true }
}
let material = ViscoelasticMaterial {
kernel: PronyKernel::two_term(1.0, 0.5, 0.5, 2.0),
};
let opts = IdeOptions::default().dt(0.01);
let result = VolterraSolver::solve(&material, 0.0, 10.0, &[1.0], &opts)
.expect("Solve failed");

IDEs with coupled components:

use numra_ide::{IdeSystem, VolterraSolver, IdeSolver, IdeOptions};
struct CoupledIDE;
impl IdeSystem<f64> for CoupledIDE {
fn dim(&self) -> usize { 2 }
fn rhs(&self, _t: f64, y: &[f64], f: &mut [f64]) {
f[0] = -y[0] + 0.1 * y[1];
f[1] = -y[1];
}
fn kernel(&self, t: f64, s: f64, y_s: &[f64], k: &mut [f64]) {
let decay = (-(t - s)).exp();
k[0] = 0.5 * decay * y_s[0];
k[1] = 0.2 * decay * y_s[1];
}
}
let opts = IdeOptions::default().dt(0.01);
let result = VolterraSolver::solve(&CoupledIDE, 0.0, 5.0, &[1.0, 1.0], &opts)
.expect("Solve failed");
let y_final = result.y_final().unwrap();
println!("y1(5) = {:.6}, y2(5) = {:.6}", y_final[0], y_final[1]);

IdeResult provides the standard Numra result interface:

let result = VolterraSolver::solve(&system, t0, tf, &y0, &opts)?;
// Final state
let y_final = result.y_final().unwrap();
// Access at index
let y_i = result.y_at(50);
// Statistics
println!("RHS evaluations: {}", result.stats.n_rhs);
println!("Kernel evaluations: {}", result.stats.n_kernel);
println!("Steps taken: {}", result.stats.n_steps);
Kernel TypeRecommended SolverCostNotes
General K(t,s,y)K(t,s,y)VolterraRK4SolverO(N2)O(N^2)Most flexible
Simple problemsVolterraSolverO(N2)O(N^2)Euler time-stepping
Sum of exponentialsPronySolverO(N)O(N)Fastest, limited to SOE kernels
Power-lawVolterraRK4SolverO(N2)O(N^2)Consider FDE solver instead

The trapezoidal rule used for the integral is second-order accurate. For weakly singular kernels (like power-law), the accuracy degrades near s=ts = t. Strategies:

  • Use a smaller Δt\Delta t to compensate
  • For power-law kernels with α<1\alpha < 1, consider using the FDE solver instead

The O(N2)O(N^2) cost of general Volterra solvers becomes significant for long integrations. To manage computational expense:

  • Use PronySolver whenever the kernel can be approximated by a sum of exponentials
  • Many physical kernels (even power-law) can be approximated by Prony series
  • Use the coarsest Δt\Delta t that meets accuracy requirements

When the kernel has the form K(τ)=τα/Γ(1α)K(\tau) = \tau^{-\alpha} / \Gamma(1-\alpha), the Volterra integral becomes the Riemann-Liouville fractional integral, and the IDE reduces to a fractional differential equation. For such problems, the dedicated FDE solver in numra-fde is more efficient because it uses optimized Caputo weights.