Tutorial: Positive-projection method

This tutorial is about solving an ODE using the projection method introduced by Adrian Sandu in Positive Numerical Integration Methods for Chemical Kinetic Systems. It guarantees positivity by solving an optimization problem while preserving all linear invariants.

The Sandu projection is a post-processing technique that can be used in combination with any ODE solver. If the ODE solver computes a negative approximation at any time step, the projection method calculates a positive approximation, also taking into account the linear invariants.

Solution of the ODE system

As an example we want to the solve the NPZD problem prob_pds_npzd, which is an ODE system in which negative approximations quickly lead to unacceptable solutions. First, we solve the problem without Sandu projection and select ROS2 form OrdinaryDiffEq.jl as ODE solver.

using PositiveIntegrators
using OrdinaryDiffEqRosenbrock
using Plots

prob = prob_pds_npzd

ref_sol = solve(prob, ROS2(); abstol = 1e-8, reltol = 1e-6); # reference solution for plotting

sol = solve(prob, ROS2(); abstol = 5e-2, reltol = 1e-1)

plot(ref_sol, linestyle = :dash, label = "", color = palette(:default)[1:4]')
plot!(sol, ylims = (-2.5, 12.5), denseplot = false,  markers = :circle, linewidth = 2, color = palette(:default)[1:4]', label = ["N" "P" "Z" "D"], legend = :right)
Example block output

The plot shows the numerical solution obtained with ROS2 compared to a reference solution (dashed lines). We see that the ROS2 method produces negative approximations, which can occur because Rosenbrock methods are not positivity-preserving. For the NPZD problem, however, this is fatal and leads to a completely unacceptable numerical solution. It is therefore particularly important to use techniques that guarantee positivity of the numerical approximations for this problem. We achieve this below with the SanduProjection.

To apply the SanduProjection we need to choose an optimization solver which is supported by JuMP.jl and can handle quadratic optimization problems (QP). In this tutorial we select Clarabel.jl as optimization solver.

In addition, we need to specify the linear invariants of the problem. The only linear invariant of the NPZD problem is $N(t)+P(t)+Z(t)+D(t)=N(0)+P(0)+Z(0)+D(0)=15$ for all times $t≥0$. This can be written in the form

\[\mathbf{A}^T \begin{pmatrix} N(t)\\ P(t)\\ Z(t)\\ D(t) \end{pmatrix} = \mathbf{b}\]

with $\mathbf{A}^T = [1.0,\ 1.0,\ 1.0,\ 1.0]$ and $\mathbf{b} = [15]$.

The projection method SanduProjection is implemented as a callback and hence, must be passed as an argument to the keyword callback. In addition, we must also use save_everystep = false.

using JuMP, Clarabel

AT = [1.0 1.0 1.0 1.0]
b = [15.0]
proj = SanduProjection(Model(Clarabel.Optimizer), AT, b)

sol_proj = solve(prob, ROS2(); abstol = 5e-2, reltol = 1e-1,
                 save_everystep = false, callback = proj);

plot(ref_sol, linestyle = :dash, label = "", color = palette(:default)[1:4]')
plot!(sol_proj, ylims = (-2.5, 12.5), denseplot = false,  markers = :circle, linewidth = 2, color = palette(:default)[1:4]', label = ["N" "P" "Z" "D"], legend = :right)
Example block output

As intended, negative approximations no longer occur and we obtain an acceptable approximation.

The SanduProjection is implemented as a DiscreteCallback and we can display the number of projection steps in the following way.

@show get_numsteps_SanduProjection(proj)
1

We can see that in this example, a single projection step was already sufficient.

Package versions

These results were obtained using the following versions.

using InteractiveUtils
versioninfo()
println()

using Pkg
Pkg.status(["PositiveIntegrators", "JuMP", "Clarabel", "OrdinaryDiffEqRosenbrock", "Plots"],
           mode=PKGMODE_MANIFEST)
Julia Version 1.11.7
Commit f2b3dbda30a (2025-09-08 12:10 UTC)
Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 4 × AMD EPYC 7763 64-Core Processor
  WORD_SIZE: 64
  LLVM: libLLVM-16.0.6 (ORCJIT, znver3)
Threads: 1 default, 0 interactive, 1 GC (on 4 virtual cores)
Environment:
  JULIA_PKG_SERVER_REGISTRY_PREFERENCE = eager

Status `~/work/PositiveIntegrators.jl/PositiveIntegrators.jl/docs/Manifest.toml`
  [61c947e1] Clarabel v0.11.0
  [4076af6c] JuMP v1.29.1
  [43230ef6] OrdinaryDiffEqRosenbrock v1.18.1
  [91a5bcdd] Plots v1.41.1
  [d1b20bf0] PositiveIntegrators v0.2.14-DEV `~/work/PositiveIntegrators.jl/PositiveIntegrators.jl`