Prepared by: Prof. Alexander Dowling, Myia Dickens (mdicken2@nd.edu,2023)
This notebook is the computational companion to Lectures 5--7. It connects error and stability experiments for ODE integrators to index-1 DAE integration, then prepares for direct collocation in Pyomo.dae.
import sys
if "google.colab" in sys.modules:
!wget "https://raw.githubusercontent.com/ndcbe/optimization/main/notebooks/helper.py"
# Do not need casadi for this notebook
#!pip install casadi
import helper
helper.easy_install()
else:
sys.path.insert(0, "../")
import helper
helper.set_plotting_style()
import numpy as np
import scipy.optimize as opt
import matplotlib.pyplot as pltSingle-Step Runge-Kutta Methods¶
Chapter 9 in Biegler (2010)
Chapter 17 in McClarren (2018)
General Form: Index 0 DAE¶
Consider the ODE system:
where are the differential variables and is a (nonlinear) continuous function.
The general Runge-Kutta formula is:
where
are the differential variables at the start of step (time )
are the differential variables at the end of step (time )
are differential variables for intermediate stage
is the size for step such that
is the number of stages
are coefficients, together known as the Runge-Kutta matrix
are coefficients, known as the weights
are coefficients, known as the nodes
is selected using local error estimates and stability requirements. Zeros in express which stage evaluations are absent.
The choice for , and selects the specific method in the Runge-Kutta family. These coefficients are often specified in a Butcher block (or Butcher tableau).
The first-order condition is . For the internally consistent stage times used here, as well:
Explicit (Forward) Euler¶
Consider one of the simplest Runge-Kutta methods:
What are , and in the general formula?
. This is only a single stage. Thus we only need to determine , , and
Moreover, because is only evaluated at and . This implies:
; this is a one-by-one zero matrix, not an empty matrix.
The implementation is very straightforward (see below). We can calculate with a single line!
def create_steps(tstart, tend, dt):
"""Positive steps covering [tstart, tend], with a shortened final step."""
if not np.isfinite([tstart, tend, dt]).all() or dt <= 0 or tend <= tstart:
raise ValueError("Require finite tstart < tend and dt > 0")
width = tend - tstart
n = int(np.ceil(width / dt))
h = np.full(n, float(dt))
h[-1] = width - float(dt) * (n - 1)
return h
def explicit_euler(f, h, z0, t0=0.0):
"""
Arguments:
f: function that returns rhs of ODE
h: list of step sizes
z0: initial conditions
Returns:
t: list of time steps. t[0] is 0.0 by default
z: list of differential variable values
"""
# Number of timesteps
nT = len(h) + 1
t = np.zeros(nT)
t[0] = t0
# Number of states
nZ = len(z0)
Z = np.zeros((nT, nZ))
# Copy initial states
Z[0, :] = z0
for i in range(1, nT):
i_ = i - 1
# Advance time
t[i] = t[i_] + h[i_]
# Explicit Euler formula
Z[i, :] = Z[i_, :] + h[i_] * f(t[i_], Z[i_, :])
return t, ZImplicit (Backward) Euler¶
Consider another simple Runge-Kutta method:
What are , and to express using the general formula?
. This is only a single stage. Moreover, because is evaluated at and . This implies:
Moreover, implies . Thus:
Notice that the formula for is implicit. We need to solve a (nonlinear) system of equations to calculate the step.
def implicit_euler(f, h, z0, t0=0.0):
"""
Arguments:
f: function that returns rhs of ODE
h: list of step sizes
z0: initial conditions
Returns:
t: list of time steps. t[0] is 0.0 by default
z: list of differential variable values
"""
# Number of timesteps
nT = len(h) + 1
t = np.zeros(nT)
t[0] = t0
# Number of states
nZ = len(z0)
Z = np.zeros((nT, nZ))
# Copy initial states
Z[0, :] = z0
for i in range(1, nT):
i_ = i - 1
# Advance time
t[i] = t[i_] + h[i_]
## Implicit Runge-Kutta formula.
## Need to solve nonlinear system of equations.
# Use Explicit Euler to calculate initial guess
Z[i, :] = Z[i_, :] + h[i_] * f(t[i_], Z[i_, :])
# Solve nonlinear equation
implicit = lambda z: Z[i_, :] + h[i_] * f(t[i], z) - z
root, info, status, message = opt.fsolve(
implicit, Z[i, :], full_output=True, xtol=1e-11
)
# A linear residual can be solved to rounding error even when fsolve
# reports stagnation. Check the equation itself before accepting a step.
residual = np.linalg.norm(implicit(root), ord=np.inf)
tolerance = 1e-10 * (1 + np.linalg.norm(root, ord=np.inf))
if not np.isfinite(root).all() or not np.isfinite(residual) or residual > tolerance:
raise RuntimeError(f"Backward Euler step {i}: {message}; residual={residual}")
Z[i, :] = root
return t, ZKey Differences¶
| Explicit Methods | Implicit Methods |
|---|---|
| + Easy to Program | - Requires converging system of nonlinear equations |
| - Stability regions are often bounded, so stability can restrict | + Stability regions are often larger or unbounded, but still depend on the method and problem |
Comparison¶
Let’s test this on a simple problem:
The solution to this problem is
For simplicity, let’s numerically analyze .

The cells below compute both panels. Their common plotting cell is also the source of the handout image. Change the step sizes and rerun to test another mesh.
rhs = lambda t, z: -z
sln = lambda t: np.exp(-t)
dt = 1.0
h = create_steps(0.0, 5.0, dt)
z0 = [1]
te, Ze = explicit_euler(rhs, h, z0)
ti, Zi = implicit_euler(rhs, h, z0)
euler_panels = []
euler_panels.append({"h": float(dt), "time": te.tolist(),
"forward": Ze[:, 0].tolist(), "backward": Zi[:, 0].tolist()})Stability¶
Keeping , are there any limits on step size?
dt = 2.5
h = create_steps(0.0, 10.0, dt)
z0 = [1]
te, Ze = explicit_euler(rhs, h, z0)
ti, Zi = implicit_euler(rhs, h, z0)
euler_panels.append({"h": float(dt), "time": te.tolist(),
"forward": Ze[:, 0].tolist(), "backward": Zi[:, 0].tolist()})results = {"alpha": 1.0, "z0": 1.0, "panels": euler_panels}
helper.save_results(
"euler-stability", results,
notebook="notebooks/3-dev/DAE_numeric_integration.ipynb",
source_tag="numeric:forward-euler",
source_cells=[("notebooks/3-dev/DAE_numeric_integration.ipynb", tag)
for tag in ("numeric:setup", "numeric:backward-euler",
"numeric:euler-fine", "numeric:euler-coarse",
"numeric:euler-stability-extract")],
description="Computed forward/backward Euler trajectories for z'=-z at two step sizes.",
solver="scipy.optimize.fsolve; every implicit step checked by its residual",
)[helper] wrote figures/results/euler-stability.json
def plot_euler_stability(results):
fig, axes = plt.subplots(1, 2, figsize=(9.0, 3.6))
for ax, panel, ylim in zip(axes, results["panels"], [(-0.15, 1.15), (-4.5, 6.5)]):
t = np.asarray(panel["time"])
t_fine = np.linspace(t[0], t[-1], 201)
ax.plot(t_fine, results["z0"] * np.exp(-results["alpha"] * t_fine), label="exact")
ax.plot(t, panel["forward"], marker="s", label="forward Euler")
ax.plot(t, panel["backward"], marker="o", label="backward Euler")
ax.axhline(0, color="0.65", linewidth=0.8, linestyle="-", zorder=0)
ax.set(xlabel="$t$", xlim=(0, t[-1]), ylim=ylim)
ax.set_title(f"$h={panel['h']}$", fontsize=15)
axes[0].set_ylabel("$z(t)$")
axes[0].legend(loc="upper right", fontsize=12)
fig.tight_layout()
return fig
fig = plot_euler_stability(results)
helper.save_figure(fig, "euler-stability")[helper] wrote media/figures/euler-stability.png and .pdf

Scalar stability and oscillation¶
For , , , set . The exact solution is positive and decreasing. Each numerical method gives . Substitute the test equation into each update and solve for the new state:
For this scalar problem, means no amplification. The stronger condition also preserves nonnegative monotone decay. This sign test is a direct observation about the scalar recurrence, not a general nonlinear stability theorem.
| Method | Bounded for | Nonnegative monotone decay | Limit as |
|---|---|---|---|
| FE | Unbounded magnitude | ||
| BE | All | All | 0 |
| CN | All | -1 |
At FE’s endpoint , values alternate with unchanged magnitude. They are bounded, but do not approach zero. CN can also alternate when even though its magnitude decreases. Thus stability, non-oscillation, and accurate transient resolution are separate questions. The prepared comparison uses FE and BE at and with .
Complex stability regions and stiff decay¶
Apply a method to with complex and let . If , its absolute-stability region is the set where is defined and . For FE, BE, and CN:
FE’s region is the disk . BE’s is the exterior . For CN, squaring gives . A method is A-stable if its region contains the entire closed left half-plane, with no poles there. BE and CN satisfy this definition; FE does not.
No explicit RK method can be A-stable: its nonconstant polynomial is unbounded as . For a linear vector system, eigenvalues provide scalar mode tests when such a decomposition is applicable; nonnormal coupling can cause transient growth that scalar eigenvalues alone do not describe. If and , the exact scalar solution oscillates at constant magnitude. When , it is constant.
For a bounded smooth moving equilibrium , consider
The stiff-decay requirement used by Biegler is that the computed endpoint follows the moving equilibrium, as , at fixed positive step times after the initial layer. In the homogeneous special case , this requires . BE passes that test and CN fails: CN’s limit is -1, so a very fast decaying mode can remain as alternating numerical values.
The homogeneous limit alone is not a complete test for an arbitrary forced RK formula. A sufficient structural condition for the implicit RK setting discussed by Ascher & Petzold is nonsingular together with stiff accuracy ( equals the final row of , with endpoint stage ). It makes the last stage the new state, tying the endpoint to the equilibrium. Standard Radau IIA methods have this property. Standard Gauss methods and the Lobatto IIIA family used for trapezoidal collocation do not have stiff decay. Ascher & Petzold, pp. 42-43, 56-57, 102-104; Biegler, pp. 255-256.
Time-varying contraction and algebraic stability¶
A-stability tests a constant . AN-stability tests with . For an -stage RK method, let , where . The scalar amplification is
AN-stability requires this function to be defined and to satisfy for every admissible stage tuple with . Repeated nodes must use equal , because they sample the same time. When all , reduces to .
The real-coefficient method is algebraically stable if
Equivalently, . Algebraic stability implies AN-stability. The converse holds when the nodes are distinct. Burrage & Butcher (1979), Theorem 3.3, printed p. 50, states that qualification explicitly; the unqualified textbook synonym is too broad for repeated-node RK formulas.
For BE, , so . For FE, , , so , which fails. For trapezoidal RK, , , , giving : A-stability does not imply algebraic stability. Gauss and Radau IIA collocation have algebraic stability; this does not give Gauss stiff decay. These properties answer different questions.
For a direct time-varying counterexample, trapezoidal RK has
Choose and , both in the left half-plane. Then , so the stage-varying problem can amplify even though the constant-coefficient function is A-stable. This separates the two tests without relying on an unexplained alias.
Key observation: forward (explicit) Euler becomes unstable with large steps whereas backward (implicit) Euler is stable.
The Lecture 6 handout derives the growth factors and then generalizes them to absolute-stability regions.
Key results (for this specific test problem):
Forward Euler is stable for this problem when and non-oscillatory when .
Backward Euler is stable and non-oscillatory for every when .
Crank--Nicolson is stable for every , but oscillates when .
Similar analysis and concepts extend to Runge-Kutta methods.

Why the step-size limit exists, in one picture. Rendered from
figures/plots/stability-regions.py; the same image printed in the course pack.
The shaded set is where the method does not amplify. A method is A-stable when that set covers the entire left half-plane (Ascher & Petzold, p. 56). Forward Euler’s region is bounded, so it never can be -- hence (boundedness, including the neutral endpoint). Backward Euler and Crank-Nicolson both are.
Error Analysis¶
How does our choice in step size impact the error of these numerical techniques?

The next calculation measures the RMS error and prints the slope over the two finest meshes. Its plotting cell produces the same image used in the handout. The triangle is a supplied scale reference; estimate each data curve’s slope independently.
Delta_t = np.array([1.0, 0.5, 0.25, 0.125, 0.0625, 0.0625 / 2])
t_final = 2
error_forward = np.zeros(Delta_t.size)
error_backward = np.zeros(Delta_t.size)
for i in range(0, len(Delta_t)):
# create steps
h = create_steps(0.0, t_final, Delta_t[i])
# solve
t, ze = explicit_euler(rhs, h, z0)
t, zi = implicit_euler(rhs, h, z0)
zsln = np.exp(-t)
n = len(t) - 1
# Calculate error
error_forward[i] = np.linalg.norm(ze[:, 0] - zsln) / np.sqrt(n)
error_backward[i] = np.linalg.norm(zi[:, 0] - zsln) / np.sqrt(n)
# Calculate slope
calc_slope = lambda error: (np.log(error[-1]) - np.log(error[-2])) / (
np.log(Delta_t[-1]) - np.log(Delta_t[-2])
)
print("Slope for Forward Euler: " + str(calc_slope(error_forward)))
print("Slope for Backward Euler: " + str(calc_slope(error_backward)))Slope for Forward Euler: 1.0220608473216777
Slope for Backward Euler: 0.9874086317220856
results = {"steps": Delta_t.tolist(), "error_forward": error_forward.tolist(),
"error_backward": error_backward.tolist(), "t_final": float(t_final),
"slopes": {"forward": float(calc_slope(error_forward)),
"backward": float(calc_slope(error_backward))}}
helper.save_results(
"euler-error-order", results,
notebook="notebooks/3-dev/DAE_numeric_integration.ipynb",
source_tag="numeric:forward-euler",
source_cells=[("notebooks/3-dev/DAE_numeric_integration.ipynb", tag)
for tag in ("numeric:setup", "numeric:backward-euler",
"numeric:euler-fine", "numeric:euler-coarse",
"numeric:euler-errors", "numeric:euler-order-extract")],
description="Measured Euler global RMS errors and finest-pair orders on six meshes.",
solver="scipy.optimize.fsolve; every implicit step checked by its residual",
)[helper] wrote figures/results/euler-error-order.json
def plot_euler_error_order(results):
fig, ax = plt.subplots(figsize=(5.4, 4.4))
steps = np.asarray(results["steps"])
ax.loglog(steps, results["error_forward"], marker="s", label="forward Euler")
ax.loglog(steps, results["error_backward"], marker="o", label="backward Euler")
ax.set(xlim=(0.022, 2.6), ylim=(3e-3, 1.4))
x0, x1, y0 = 0.037, 0.085, 0.004
y1 = y0 * x1 / x0
ax.plot([x0,x1,x1,x0], [y0,y0,y1,y0], color="0.45", linestyle=":", linewidth=1.6)
ax.annotate("slope 1", xy=(x1*1.12, np.sqrt(y0*y1)), fontsize=12, color="0.35", va="center")
# Curve identifiers carry a line sample and marker; fitted slopes are not
# printed because estimating them is the handout activity.
for fy, line, text in [(0.62, ax.lines[0], "forward Euler"),
(0.30, ax.lines[1], "backward Euler")]:
ax.plot([0.030,0.057], [fy,fy], color=line.get_color(),
linestyle=line.get_linestyle(), marker=line.get_marker(), zorder=5)
ax.text(0.066, fy, text, fontsize=12, va="center")
ax.set_xlabel(r"step size $h$")
ax.set_ylabel(r"global RMS error")
ax.set_title(r"$\dot z=-z$, $z_0=1$, integrated to $t=2$", fontsize=14)
ax.grid(True, which="both", linestyle=":", linewidth=0.6, color="0.85")
ax.set_axisbelow(True)
fig.tight_layout()
return fig
fig = plot_euler_error_order(results)
helper.save_figure(fig, "euler-error-order")[helper] wrote media/figures/euler-error-order.png and .pdf

Both Euler methods are first order in : halving approximately halves the global error. Here we measure the error with the root-mean-square norm
where is the number of steps the ODE is solved over.
For both Euler methods, the unnormalized one-step defect is , the local truncation error divided by is , and the global error is under the smoothness and stability assumptions below. Naming the normalization prevents the same calculation from being called both first and second order.
Index-1 DAE stages and coupled residuals¶
A small genuine index-1 example separates the coupled integration solve from the ODE-constrained optimization example below:
Here , so the algebraic variable is locally determined by . The exact solution is and . Backward Euler solves both unknown endpoint values together:
The upper-right sign is positive here because and the residual subtracts . Elimination gives . The root continuous from the positive initial state is
This rearrangement avoids subtracting nearly equal numbers for small . With and , , . The exact differential state at is ; satisfying the algebraic equation exactly does not remove time-discretization error.
For a general regular index-1 DAE, the RK stage equations below enforce at the stages. They do not automatically enforce an endpoint constraint for a weighted differential endpoint that is not itself a stage. A stiffly accurate method uses its last stage as the endpoint; another method may need an explicit endpoint recovery of the algebraic variables. Consistent initial data and a successful coupled nonlinear solve remain necessary.
Extending Numeric Integration to Index-1 DAEs¶
Consider semi-explicit DAEs:
For a regular semi-explicit index-1 system, is nonsingular along the solution. Enforce the algebraic equations at every stage:

Backward differentiation formulas (BDFs) also extend to index-1 DAEs. Backward Euler is the one-step, first-order member of this family.
At each BDF step, solve the coupled equations for with Newton’s method. Using the conventional row-by-variable Jacobian and the residual , the Jacobian is:
This follows Biegler (2010), equations (9.18)--(9.19), pp. 259--260, with two corrections made explicit: derivatives here use the course’s row-by-variable convention, and the upper-right block has a minus sign when the first residual is written in the form above. The printed equation (9.19) combines the opposite sign in that block with ; direct differentiation shows those signs cannot belong to the same residual, and the published errata do not list the issue.
Order requires a theorem for the method and variable. Regular index-1 structure permits local elimination of , but it does not prove unconditional retention of every ODE order or stability property. Gear & Petzold (1984), Theorem 2.1, p. 719, proves fixed-step BDF order with correct initial values and its stated regularity assumptions. This is an order result; it is not a general stability theorem. RK algebraic variables can have a different order from differential variables.
Discussion: Why are implicit methods usually preferred for index-1 DAE systems?
Integration within dynamic optimization¶
The DAE background reference gives the full integration-versus-transcription architecture. In an embedded integration approach, a candidate control or parameter vector is supplied to an IVP solver; the NLP sees a reduced objective and constraints evaluated from the resulting trajectory. In simultaneous transcription, time-indexed states and discretized dynamic residuals are NLP variables and constraints. A large sparse NLP can then exploit local time coupling.
The tradeoff is specific. Embedded integration provides adaptive step control and a dynamically feasible trajectory when integration succeeds, but a poor parameter trial can make integration fail. Repeated sensitivities and path-constraint checks can be costly. Simultaneous transcription exposes sparse derivatives, state bounds, and path constraints at its chosen points; it requires a mesh, suitable initialization, and a check between points. Satisfying collocation constraints alone does not certify the continuous-time path.
The complete ODE-constrained optimization builder below remains useful: it minimizes an accumulated cost by choosing , uses three ODEs and a path inequality, and exposes the backward-Euler discretization to Ipopt. It is not an index-1 DAE example. Compare the independent coupled residual example above before attributing algebraic-constraint behavior to this model.
Backward Euler transcription of an ODE-constrained optimization problem¶
Reference: Bynum, Hackebeil, Hart, Laird, Nicholson, Siirola, Watson & Woodruff, Pyomo — Optimization Modeling in Python, 3rd ed., Springer (2021), §12.1, p. 181, equations (12.1)–(12.6). (Earlier versions of this notebook cited “Hart, 2010”, which is the 1st edition — a different book with different pagination.)
The example below is an ODE model with a path inequality, not a DAE. It shows how backward Euler exposes time-indexed states, controls, and constraints to an optimization solver.
Consider the following optimal control problem
Discussion: What variable is the problem attempting to minimize? What variable is being optimized? What types of equations are there?
Click here to expand
Manipulated variables:
Objective:
3 differential equations in the constraints and a path constraint, which is an inequality constraint restricting a variable.
The path constraint directly impacts .
import pyomo.environ as pyo
import pyomo.dae as daeThat t is a bare Python float -- the raw ContinuousSet index that Pyomo
passed into the rule. It is not a Pyomo object and it carries no units, so there is
nothing to attach units to. This is the cleanest illustration in the course of
Pyomo issue #1790 (add units support
to ContinuousSet/Pyomo.dae, still open): a ContinuousSet cannot hold units,
which is also why a units-correct Pyomo.dae model passes
assert_units_consistent before discretization and fails after it.
When a model is physical and needs a time-dependent reference trajectory, the workaround is to have the helper return a bare number and multiply by the intended units at the call site. Hot Air Balloon Dynamic Control does exactly that.
def create_dynamic_optimization_model():
# Create model
m = pyo.ConcreteModel()
# Declare time set
m.tf = pyo.Param(initialize=1) # final time
m.t = dae.ContinuousSet(bounds=(0, m.tf))
# Declare constraint and input variables
m.u = pyo.Var(m.t, initialize=0)
m.x1 = pyo.Var(m.t)
m.x2 = pyo.Var(m.t)
m.x3 = pyo.Var(m.t)
# Declare differential variables
m.dx1 = dae.DerivativeVar(m.x1, wrt=m.t)
m.dx2 = dae.DerivativeVar(m.x2, wrt=m.t)
m.dx3 = dae.DerivativeVar(m.x3)
# Declare differential equations
@m.Constraint(m.t)
def x1dotcon(m, t):
if t == m.t.first():
return pyo.Constraint.Skip
return m.dx1[t] == m.x2[t]
@m.Constraint(m.t)
def x2dotcon(m, t):
if t == m.t.first():
return pyo.Constraint.Skip
return m.dx2[t] == -m.x2[t] + m.u[t]
@m.Constraint(m.t)
def x3dotcon(m, t):
if t == m.t.first():
return pyo.Constraint.Skip
return m.dx3[t] == m.x1[t] ** 2 + m.x2[t] ** 2 + 0.005 * m.u[t] ** 2
# Declare inequality constraints
@m.Constraint(m.t)
def con(m, t):
return m.x2[t] - 8 * (t - 0.5) ** 2 + 0.5 <= 0
# Declare the initial conditions
@m.Constraint()
def init_x1(m):
return m.x1[0] == 0
@m.Constraint()
def init_x2(m):
return m.x2[0] == -1
@m.Constraint()
def init_x3(m):
return m.x3[0] == 0
# Declare Objective function
m.obj = pyo.Objective(expr=m.x3[m.tf])
return m
# Discretize and solve using backward Euler
def solve_with_backward_euler(m):
"""
Arguments:
m: Pyomo.DAE dynamic optimization model
New Elements:
nfe = number for finite elements - specifies the number of discretization points to be used
Purpose:
Discretizes the ODE constraints with backward Euler and solves the NLP
"""
discretizer = pyo.TransformationFactory("dae.finite_difference")
discretizer.apply_to(m, nfe=20, wrt=m.t, scheme="BACKWARD")
solver = pyo.SolverFactory("ipopt")
results = solver.solve(m, tee=True)
assert pyo.check_optimal_termination(results), (
f"Solve failed: status={results.solver.status}, "
f"termination={results.solver.termination_condition}"
)
# Plot the results
def plotter(subplot, x, *y, **kwds):
plt.subplot(subplot)
# Color AND linestyle, so the figure survives a black-and-white printout.
linestyles = ["-", "--", "-.", ":"]
for i, _y in enumerate(y):
plt.plot(
list(x),
[pyo.value(_y[t]) for t in x],
color="brgcmk"[i % 6],
linestyle=linestyles[i % 4],
)
if kwds.get("points", False):
plt.plot(list(x), [pyo.value(_y[t]) for t in x], "o")
plt.title(kwds.get("title", ""), fontsize=16, fontweight="bold")
plt.tick_params(direction="in", labelsize=15)
plt.legend(tuple(_y.name for _y in y))
plt.xlabel(x.name, fontsize=16, fontweight="bold")
def plot_results(m):
plotter(121, m.t, m.x1, m.x2, title="Differential Variables")
plotter(122, m.t, m.u, title="Control Variable", points=True)
plt.show()
# Create model
model = create_dynamic_optimization_model()# Solve the discretized dynamic optimization problem
results = solve_with_backward_euler(model)Ipopt 3.13.2:
******************************************************************************
This program contains Ipopt, a library for large-scale nonlinear optimization.
Ipopt is released as open source code under the Eclipse Public License (EPL).
For more information visit http://projects.coin-or.org/Ipopt
This version of Ipopt was compiled from source code available at
https://github.com/IDAES/Ipopt as part of the Institute for the Design of
Advanced Energy Systems Process Systems Engineering Framework (IDAES PSE
Framework) Copyright (c) 2018-2019. See https://github.com/IDAES/idaes-pse.
This version of Ipopt was compiled using HSL, a collection of Fortran codes
for large-scale scientific computation. All technical papers, sales and
publicity material resulting from use of the HSL codes within IPOPT must
contain the following acknowledgement:
HSL, a collection of Fortran codes for large-scale scientific
computation. See http://www.hsl.rl.ac.uk.
******************************************************************************
This is Ipopt version 3.13.2, running with linear solver ma27.
Number of nonzeros in equality constraint Jacobian...: 363
Number of nonzeros in inequality constraint Jacobian.: 21
Number of nonzeros in Lagrangian Hessian.............: 60
Total number of variables............................: 143
variables with only lower bounds: 0
variables with lower and upper bounds: 0
variables with only upper bounds: 0
Total number of equality constraints.................: 123
Total number of inequality constraints...............: 21
inequality constraints with only lower bounds: 0
inequality constraints with lower and upper bounds: 0
inequality constraints with only upper bounds: 21
iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls
0 0.0000000e+00 1.00e+00 2.82e-01 -1.0 0.00e+00 - 0.00e+00 0.00e+00 0
1 0.0000000e+00 1.24e+00 6.10e-01 -1.0 1.57e+01 - 6.19e-01 1.00e+00f 1
2 3.3913637e-01 2.47e-01 1.00e-06 -1.0 5.83e+00 - 1.00e+00 1.00e+00f 1
3 3.3096072e-01 1.37e-02 2.00e-07 -1.7 1.05e+00 - 1.00e+00 1.00e+00h 1
4 1.5256107e-01 5.24e-02 1.50e-09 -3.8 9.83e-01 - 1.00e+00 1.00e+00h 1
5 1.4973078e-01 9.43e-03 1.50e-09 -3.8 6.08e-01 - 1.00e+00 1.00e+00h 1
6 1.4923815e-01 1.41e-03 1.50e-09 -3.8 3.46e-01 - 1.00e+00 1.00e+00h 1
7 1.4795692e-01 3.28e-04 4.99e-05 -5.7 1.84e-01 - 1.00e+00 9.88e-01h 1
8 1.4798373e-01 1.22e-05 1.84e-11 -5.7 3.85e-02 - 1.00e+00 1.00e+00h 1
9 1.4796950e-01 7.16e-08 2.51e-14 -8.6 2.69e-03 - 1.00e+00 1.00e+00h 1
iter objective inf_pr inf_du lg(mu) ||d|| lg(rg) alpha_du alpha_pr ls
10 1.4796952e-01 6.90e-13 2.51e-14 -8.6 1.15e-05 - 1.00e+00 1.00e+00h 1
Number of Iterations....: 10
(scaled) (unscaled)
Objective...............: 1.4796951825372692e-01 1.4796951825372692e-01
Dual infeasibility......: 2.5063284780912909e-14 2.5063284780912909e-14
Constraint violation....: 6.9010075431918949e-13 6.9010075431918949e-13
Complementarity.........: 2.5060342008003969e-09 2.5060342008003969e-09
Overall NLP error.......: 2.5060342008003969e-09 2.5060342008003969e-09
Number of objective function evaluations = 11
Number of objective gradient evaluations = 11
Number of equality constraint evaluations = 11
Number of inequality constraint evaluations = 11
Number of equality constraint Jacobian evaluations = 11
Number of inequality constraint Jacobian evaluations = 11
Number of Lagrangian Hessian evaluations = 10
Total CPU secs in IPOPT (w/o function evaluations) = 0.002
Total CPU secs in NLP function evaluations = 0.000
EXIT: Optimal Solution Found.
# Plot solution
plot_results(model)
Stiffness and solver families¶
A practical symptom of stiffness is that a stable computation needs steps much smaller than the visible solution’s slow time scale would suggest for accuracy. The handout’s coupled example is
Its distinct real eigenvalues are , approximately -1.0101 and -99.9899. A diagonalizable modal representation gives slow and fast factors and . The corresponding time constants are about 0.9900 and 0.0100. Requiring both FE modal factors to satisfy gives , hence at least 500 steps to reach . Scalar eigenvalue tests do not exclude nonnormal transient growth in the original coordinates.
A fast component becoming visually small does not remove its eigenvalue from the equations: roundoff or other perturbations can re-excite it. The reaction example below makes the same restriction observable through accepted steps and work counts.

| Driver or family | Formula | What the name establishes |
|---|---|---|
SciPy RK45 | Explicit Dormand-Prince 5(4) RK pair | Fifth-order steps with a fourth-order-based local error estimate; bounded stability region |
SciPy Radau | Implicit fifth-order Radau IIA | Stiff-capable RK with coupled stages |
SciPy BDF | Implicit variable-order 1-5 BDF/NDF implementation | Multistep history; orders above two are not A-stable |
| Adams | Explicit Adams-Bashforth or implicit Adams-Moulton | Multistep derivative history, usually for nonstiff ODEs |
SciPy LSODA | Switches Adams/BDF | Detects stiffness and switches formulas |
SUNDIALS CVODES | Adams or BDF for ODEs, with sensitivities | An ODE solver, including use through interfaces such as CasADi |
SUNDIALS IDA/IDAS | BDF for residual-form DAEs | Solves ; IDAS adds sensitivities |
solve_ivp supplies an ODE interface, . Its BDF option is not the IDAS DAE residual interface. Neither the word “implicit” nor the family name alone establishes the stability of every implementation at every order. BDF1 and BDF2 are A-stable; higher classical BDF orders have smaller stability sectors. Sparse Jacobians, factorization reuse, nonlinear convergence, scaling, and requested accuracy affect practical cost.
Official SciPy solve_ivp documentation, CVODES mathematical description, and IDAS mathematical description describe these interfaces. The work counters below are measured values for the stated experiment, not a universal ranking.
Dense output, events, and checking a run¶
dense_output=True returns an interpolant between accepted mesh points; evaluating sol.sol(t) does not force the solver to step at every requested time. The reaction comparison uses this interpolant on a common checking grid. Interpolation error still depends on the method and tolerances.
An event function returns a scalar whose zeros identify requested occurrences, such as a concentration reaching a threshold. solve_ivp locates sign-changing event roots over accepted steps; several crossings inside one step can be missed. An event may be terminal or restricted by crossing direction. A known control discontinuity should be supplied as an interval boundary and a restart, rather than relying on a smooth-step interpolation across it.
Check success, the diagnostic message, and the reached final time before using a trajectory in optimization. If a terminal event is intended, compare against that event instead of requiring the original final time. Inspect nfev, njev, and nlu together with achieved error, conservation, and the accepted mesh. The official SciPy interface documentation defines these results.
Step changes, control jumps, and method selection¶
An embedded RK pair shares stages between two formulas with different weights. Their difference estimates a local error, which the driver compares with scales such as atol + rtol * abs(y). This does not directly bound the global error at every time. Check important outputs by tightening tolerances, comparing a second method, or using an analytic reference when available.
A one-step RK method requires the current state and its stages. A multistep BDF or Adams method also uses a history. On a nonuniform mesh the coefficients or history representation must change consistently; reusing a constant-step stencil after arbitrarily changing changes the method.

At a known piecewise-constant control jump , integrate exactly to , use the prescribed one-sided control on each interval, then restart with the continuous state and the new control. An RHS discontinuity inside a step invalidates the smooth local expansion. Multistep history built across the jump is inappropriate and needs restarting or reducing the method order. A one-step method avoids carrying such history, but still must locate the jump and apply the correct RHS. State-reset events additionally require applying the reset map; ordinary continuous-state control switches do not.
For a small nonstiff ODE with frequent known switches, an explicit RK driver is a reasonable starting point. For a stiff ODE, compare stiff-capable methods with achieved accuracy and Jacobian cost included. For a residual-form DAE, use a suitable DAE solver and consistent initial conditions. For a large PDE discretization, examine sparsity and linear algebra before assuming that fewer RHS calls implies less total work. No single driver is best for all four cases.
RK stages, tableaus, and stability functions¶
A Butcher tableau stores stage dependencies compactly:
The row tells how derivative sample contributes to stage state ; tells how that sample contributes to the endpoint. A strictly lower triangular gives explicit stages in sequence. A lower triangular matrix with nonzero diagonal entries gives implicit stage solves. A full matrix generally couples the stages. Nodes in are used by the standard formulas here, but that interval is not a universal definition of RK methods.

FE has . BE has . Explicit midpoint has
Applying the general RK formula to gives stage vector . Where is invertible,

This derives the stability function directly from the same coefficients that specify the method. The multiplying every derivative sum is essential for dimensions and consistency. Biegler’s printed equations (9.8) and (9.17), pp. 255 and 259, omit it; the formulas here include it.
Standard Gauss, Radau IIA, and the Lobatto IIIA collocation methods used in the next lecture are implicit RK methods. “All collocation is implicit” would be too broad: a one-node construction at the left endpoint gives FE.
Stiff chemical-reaction example¶
Consider consecutive first-order reactions with time in seconds, and :
The Jacobian eigenvalues are , and 0, so the ratio of the two decaying rates is : disappears in about ms while is still converting a thousand times more slowly. The zero eigenvalue describes conserved total concentration; it is excluded from this ratio. We compare one explicit adaptive method (RK45) with three methods designed for, or able to detect, stiffness (Radau, BDF, LSODA).
Use the same tolerances for every method, and compare solver work through accepted steps and right-hand-side evaluations rather than wall time, which varies substantially across computers and Colab sessions.
# ---------- MODEL ----------------------------------------------------------
# Consecutive first-order reactions A --k1--> B --k2--> C with k1 = 1000 and
# k2 = 1. The Jacobian's eigenvalues are -k1, -k2 and 0, so the stiffness ratio
# is k1/k2 = 1000: A disappears on a timescale of 1/k1 = 1 ms while B is still
# converting on a timescale of 1/k2 = 1 s.
#
# The system is LINEAR, so the Jacobian is constant and exact. That is
# deliberate: Radau and BDF are handed the analytic Jacobian, which removes
# "how good is the finite-difference Jacobian" as an explanation for the work
# counts below. It also gives an analytic solution to measure error against.
from scipy.integrate import solve_ivp
k1, k2 = 1000.0, 1.0
reaction_jacobian = np.array([[-k1, 0.0, 0.0],
[k1, -k2, 0.0],
[0.0, k2, 0.0]])
def reaction_rhs(t, concentration):
"""d[A, B, C]/dt for the consecutive first-order reactions."""
return reaction_jacobian @ concentration
def reaction_exact(t):
"""Analytic solution from [A, B, C](0) = [1, 0, 0]; rows are A, B, C.
A = e^{-k1 t}, B = k1/(k1 - k2) (e^{-k2 t} - e^{-k1 t}), C = 1 - A - B.
Used as the accuracy reference, so "cheap" cannot be confused with "loose".
"""
t = np.asarray(t, dtype=float)
a = np.exp(-k1 * t)
b = k1 / (k1 - k2) * (np.exp(-k2 * t) - np.exp(-k1 * t))
return np.vstack([a, b, 1.0 - a - b])# ---------- SOLVE + EXTRACT ------------------------------------------------
# Everything below this cell reads `results` and nothing else. The archive
# figures/results/stiff-solver-comparison.json stores the DATA -- the accepted
# step sequence, the work counters and the measured error for each method -- so
# the figure can be restyled later without re-integrating. See figures/README.md
# and figures/render_from_notebook.py for the cell contract.
#
# ⚠ EVERY METHOD GETS THE SAME TOLERANCES. Comparing work at different
# tolerances measures nothing, so rtol/atol are fixed here and the achieved
# error is measured afterwards to confirm the four answers really are
# comparable.
#
# ⚠ WORK, NOT WALL TIME. Prof. Dowling's standing instruction for this
# comparison: count right-hand-side evaluations and accepted steps, which are
# properties of the method and the problem, not of the laptop or the Colab VM
# the notebook happens to run on.
t_final = 5.0
rtol, atol = 1e-7, 1e-10
transient_end = 0.5 # after this, A is gone and only the slow B -> C remains
# Dormand-Prince's real-axis absolute-stability boundary is h|lambda| ~ 3.3.
# With lambda = -k1 that caps the explicit step at ~3.3/k1 no matter how smooth
# the solution has become -- which is the whole argument of this section.
explicit_step_ceiling = 3.3 / k1
method_kind = {"RK45": "explicit", "Radau": "implicit",
"BDF": "implicit", "LSODA": "switching"}
t_check = np.linspace(0.0, t_final, 2001)
exact_check = reaction_exact(t_check)
methods_out = []
solutions = {} # kept for the concentration-profile diagnostic below
for method in ("RK45", "Radau", "BDF", "LSODA"):
options = {"jac": reaction_jacobian} if method in ("Radau", "BDF") else {}
solution = solve_ivp(
reaction_rhs, (0.0, t_final), [1.0, 0.0, 0.0], method=method,
rtol=rtol, atol=atol, dense_output=True, **options
)
solutions[method] = solution
assert solution.success, f"{method} failed: {solution.message}"
assert np.isclose(solution.t[-1], t_final), f"{method} stopped early"
profile = solution.sol(t_check)
mass_error = float(np.max(np.abs(profile.sum(axis=0) - 1.0)))
assert mass_error < 1e-8, f"{method} violated A + B + C conservation"
h = np.diff(solution.t)
slow = solution.t[:-1] > transient_end
methods_out.append({
"key": method,
"label": method,
"kind": method_kind[method],
"nfev": int(solution.nfev),
"njev": int(getattr(solution, "njev", 0)),
"nlu": int(getattr(solution, "nlu", 0)),
"n_steps": int(h.size),
"max_error": float(np.max(np.abs(profile - exact_check))),
"mass_error": mass_error,
# The step actually taken once the fast transient is over. This is the
# number the figure is about: the stiff solvers are free here and the
# explicit one is not.
"h_slow": float(np.median(h[slow])),
"h_min": float(h.min()),
"h_max": float(h.max()),
# Step START times, so t_step[i] is where step h[i] was taken from.
"t_step": [float(v) for v in solution.t[:-1]],
"h": [float(v) for v in h],
})
results = {
"t_final": t_final,
"rtol": rtol,
"atol": atol,
"k1": k1,
"k2": k2,
"transient_end": transient_end,
"explicit_step_ceiling": explicit_step_ceiling,
"methods": methods_out,
}
print(f"{'method':<8}{'nfev':>8}{'steps':>8}{'nlu':>7}{'h (slow)':>12}{'max error':>12}")
for m in results["methods"]:
print(f"{m['key']:<8}{m['nfev']:>8d}{m['n_steps']:>8d}{m['nlu']:>7d}"
f"{m['h_slow']:>12.2e}{m['max_error']:>12.2e}")
helper.save_results(
"stiff-solver-comparison",
results,
notebook="notebooks/3-dev/DAE_numeric_integration.ipynb",
source_tag="handout:stiff-reaction",
description=(
"Work and step size for SciPy's RK45, Radau, BDF and LSODA on the stiff "
"consecutive-reaction model A -> B -> C (k1 = 1000, k2 = 1) over "
"t in [0, 5] at rtol 1e-7 / atol 1e-10. The explicit method is held at "
"the absolute-stability ceiling h ~ 3.3/k1 long after the fast transient "
"has died, and pays for it in right-hand-side evaluations; the three "
"stiff-capable methods take steps roughly twenty times larger and reach "
"the same accuracy."
),
solver="scipy.integrate.solve_ivp",
source_cells=[("notebooks/3-dev/DAE_numeric_integration.ipynb", "numeric:reaction-experiment")],
)method nfev steps nlu h (slow) max error
RK45 11012 1582 0 3.25e-03 3.91e-09
Radau 1926 274 66 5.64e-02 6.67e-09
BDF 556 277 52 7.86e-02 9.33e-08
LSODA 484 236 9 7.26e-02 8.64e-08
[helper] wrote figures/results/stiff-solver-comparison.json
# The PLOTTING function: it takes the extracted step sequences and work
# counters, not a solver object.
#
# This cell is tagged `figure:stiff-solver-comparison`, which makes it the
# single source of the figure the Lecture 6 handout prints.
# figures/render_from_notebook.py re-runs exactly this cell against the archived
# JSON when the house style changes, so there is no second copy of the plotting
# code. That driver binds `np`, `plt` and `helper` for the cell, so it imports
# nothing.
def plot_stiff_solver_comparison(results):
"""Why a stiff problem needs a stiff solver, in step sizes and in work.
LEFT PANEL -- the mechanism. Accepted step size against time. Once the fast
transient is over (t > 0.005 or so) the solution is a smooth exponential
decay and accuracy alone would allow a large step. Radau, BDF and LSODA
take one. RK45 cannot: its step is pinned flat at the absolute-stability
ceiling h ~ 3.3/k1 by the *fast* eigenvalue, which is still -1000 even
though the mode it belongs to died long ago. That flat line is the figure.
RIGHT PANEL -- the price. Right-hand-side evaluations, which is what
Prof. Dowling asks students to compare instead of laptop timings: a count
is a property of the method and the problem, a timing is a property of the
machine. The accepted-step count is printed on each bar because the two say
slightly different things -- RK45 takes more steps and more
evaluations. In this SciPy implementation, RK45 uses six new RHS evaluations
per attempted step; rejected attempts increase the cost per accepted step.
Implicit steps also incur Jacobian and linear-algebra costs.
LOG X ON THE LEFT. The interesting behaviour spans t = 1e-6 to t = 5 --
nine decades of step size across six decades of time. On a linear time axis
the entire transient, where every method is forced small, collapses onto the
axis at x = 0 and the panel would show four flat lines with no explanation
of where they came from.
GREYSCALE. Four methods, each taking colour AND linestyle together from the
house prop_cycle (figures/dowling.mplstyle pairs the two element-wise), plus
a distinct marker in the left panel and a distinct hatch in the right, plus
direct labels rather than a legend. Any one of those is enough on a
photocopy.
"""
methods = results["methods"]
ceiling = results["explicit_step_ceiling"]
# Colour AND linestyle, element-wise, from the house cycle -- entry k is
# the k-th (colour, linestyle) pair, so the two identities never drift.
cycle = plt.rcParams["axes.prop_cycle"].by_key()
colors = [cycle["color"][k] for k in range(len(methods))]
styles = [cycle["linestyle"][k] for k in range(len(methods))]
markers = ["s", "o", "^", "D"]
# figures/plots/_house.py HATCH_CYCLE, copied literally: that module is not
# on disk on Colab and this cell must run there too.
hatches = ("///", "\\\\\\", "...", "xxx")
# ⚠ figsize is set HERE. helper.set_plotting_style() overrides the house
# default for on-screen readability and render_from_notebook.py does not, so
# a figure relying on the default comes out at two different aspect ratios
# depending on who generated it.
fig, (ax_h, ax_w) = plt.subplots(
1, 2, figsize=(11.0, 4.6), gridspec_kw={"width_ratios": [1.5, 1.0]}
)
# ---- left: accepted step size against time -----------------------------
# A reference rule, not a series, so it is achromatic and drawn underneath.
# The label sits at the LEFT end of the rule: that is the one stretch of the
# panel with nothing in it, because every method is taking sub-microsecond
# steps while the fast mode is still alive.
ax_h.axhline(ceiling, color="0.65", linewidth=1.0, zorder=1)
ax_h.annotate(
r"explicit stability ceiling $h \approx 3.3/k_1$",
xy=(2.5e-10, ceiling), xytext=(0, 4), textcoords="offset points",
ha="left", va="bottom", fontsize=10.5, color="0.40", zorder=2,
)
for k, m in enumerate(methods):
t = np.asarray(m["t_step"], dtype=float)
h = np.asarray(m["h"], dtype=float)
# t[0] = 0 cannot be drawn on a log axis; the first step starts there
# for every method, so dropping it loses nothing and hides nothing.
# ⚠ Two trims, for two different reasons.
# t > 0: the first step starts at t = 0, which cannot be drawn on a
# log axis. Every method starts there, so nothing is hidden.
# t + h <= 0.97 * t_final: the closing steps are SHORTENED to land
# exactly on t_final rather than overshoot it -- Radau's last two
# are both 1.3e-2 against a steady 6.8e-2 -- and they plot as a dip
# at the right edge that says something about the endpoint, not
# about the method. The last 3% of the horizon is dropped for every
# series alike; each is in its asymptotic regime long before then.
keep = (t > 0.0) & (t + h <= 0.97 * results["t_final"])
t, h = t[keep], h[keep]
# Markers spaced evenly in log t, not every Nth point: RK45 takes 1582
# near-uniform steps, so "every 100th point" puts every marker in the
# right-hand decade and none where the curves are being compared.
picks = np.searchsorted(t, np.logspace(np.log10(t[0]), np.log10(t[-1]), 11))
picks = sorted(set(int(min(i, t.size - 1)) for i in picks))
ax_h.plot(
t, h,
color=colors[k], linestyle=styles[k], marker=markers[k],
markevery=picks, markersize=6.0,
linewidth=1.6, zorder=3 + k, label=m["label"],
)
ax_h.set_xscale("log")
ax_h.set_yscale("log")
ax_h.set_ylim(1e-10, 4e-1)
ax_h.set_xlabel("Time $t$ (log scale)")
ax_h.set_ylabel("Accepted step size $h_i$")
ax_h.set_title("Accepted step sizes", fontsize=12.5)
# ⚠ A LEGEND HERE, DIRECT LABELS ON THE RIGHT-HAND PANEL. The house
# preference is direct labelling, and it was tried first -- but for
# t < 0.03 all four curves lie on top of one another, and after they
# separate the three stiff methods stay within 30% of each other, which is
# a few millimetres on a nine-decade log axis. Four labels cannot be placed
# beside four curves that are not four curves apart. The lower-right
# quadrant, on the other hand, is genuinely empty -- once t > 0.01 no
# method is still taking a step below 1e-5 -- so the legend covers nothing.
# Rendered and looked at.
ax_h.legend(loc="lower right", frameon=False, fontsize=12, handlelength=3.0)
# ---- right: work, counted in right-hand-side evaluations ---------------
y = np.arange(len(methods))[::-1]
nfev = [m["nfev"] for m in methods]
for k, m in enumerate(methods):
ax_w.barh(
y[k], m["nfev"], height=0.62,
facecolor=colors[k], alpha=0.30,
edgecolor=colors[k], linewidth=1.4, hatch=hatches[k], zorder=3,
)
ax_w.annotate(
f"{m['nfev']:,} evaluations\n{m['n_steps']:,} accepted steps",
xy=(m["nfev"], y[k]), xytext=(8, 0), textcoords="offset points",
ha="left", va="center", fontsize=10.5, zorder=4,
)
ax_w.set_yticks(y)
ax_w.set_yticklabels([m["label"] for m in methods], fontsize=12.5)
ax_w.set_xlim(0, max(nfev) * 2.1)
ax_w.set_ylim(-1.35, len(methods) - 0.35)
ax_w.set_xlabel("Right-hand-side evaluations")
ax_w.set_title("Computational effort", fontsize=12.5)
# Accuracy, so "cheaper" cannot be read as "sloppier". One line of text
# beats a second panel that would show four indistinguishable bars.
worst = max(m["max_error"] for m in methods)
exponent = int(np.floor(np.log10(worst)))
mantissa = np.ceil(worst / 10.0 ** exponent)
if mantissa >= 10:
exponent += 1
mantissa = 1.0
ax_w.annotate(
"All four match the analytic solution to\n"
rf"$\leq {mantissa:.0f} \times 10^{{{exponent}}}$, at "
rf"rtol $10^{{-7}}$ and atol $10^{{-10}}$",
xy=(0.5, -0.85), xycoords=("axes fraction", "data"),
ha="center", va="center", fontsize=10.5, color="0.35",
)
fig.tight_layout()
return fig
fig = plot_stiff_solver_comparison(results)
# Write media/figures/stiff-solver-comparison.{png,pdf} -- what the lecture
# handout \includegraphics. A no-op on Colab, where there is no repo to write to.
helper.save_figure(fig, "stiff-solver-comparison")[helper] wrote media/figures/stiff-solver-comparison.png and .pdf

# A notebook-only diagnostic: do the four methods actually agree, on BOTH
# timescales? (Activity step 3 -- "check the important fast and slow
# behavior".) The handout figure is the tagged cell above; this one is here so
# you can look at the concentrations themselves.
fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))
t_slow = np.linspace(0.0, t_final, 500)
t_fast = np.linspace(0.0, 0.01, 300)
for method, solution in solutions.items():
axes[0].plot(t_slow, solution.sol(t_slow)[1], label=method)
axes[1].plot(t_fast, solution.sol(t_fast)[0], label=method)
axes[0].set(xlabel="time", ylabel="B concentration", title="Slow evolution")
axes[1].set(xlabel="time", ylabel="A concentration", title="Fast transient")
axes[0].legend()
plt.tight_layout();
All four methods agree on the concentrations to better than 10-7, but they do not need comparable work. In the archived course run (rerun the preceding cells to obtain counts for your environment):
| method | kind | right-hand-side evaluations | accepted steps | LU factorizations | typical step after the transient |
|---|---|---|---|---|---|
RK45 | explicit | 11,012 | 1,582 | 0 | |
Radau | implicit | 1,926 | 274 | 66 | |
BDF | implicit | 556 | 277 | 52 | |
LSODA | switching | 484 | 236 | 9 |
The mechanism is in the left panel of the figure: once the fast transient is over, the solution is a smooth exponential decay and accuracy alone would permit a large step. Radau, BDF and LSODA take one. RK45 cannot, because its step is capped by the fast eigenvalue — still -1000 long after the mode it belongs to has died — at the Dormand--Prince stability limit . The step it actually takes, , is that ceiling, not an accuracy requirement.
Exact counts depend on software version and tolerances; the comparison is method-specific. RK45 uses about 5.7 times Radau’s right-hand-side evaluations, 19.8 times BDF’s, and 22.8 times LSODA’s in this run. Right-hand-side counts omit Jacobian construction and linear algebra: compare njev, nlu, achieved error, and problem size as well.
For this archived SciPy 1.18.0 run, RK45 makes two initial right-hand-side evaluations and six new evaluations per attempted step. The endpoint derivative is reused at the next attempt (the first-same-as-last, or FSAL, property). Thus records 1,835 attempts. Of these, 1,582 were accepted and 253 were rejected. The cost is about evaluations per accepted step, rather than exactly six. This accounting uses the stated implementation and automatic initial-step selection; it is not a universal cost formula for every RK driver. See the SciPy 1.18.0 RK implementation.
Discussion: Which method would you try first if this integration were repeated inside an optimization algorithm? What additional information would you check before deciding?
Sources and further reading¶
Ascher, U. M. & Petzold, L. R. (1998), Computer Methods for Ordinary Differential Equations and Differential-Algebraic Equations: pp. 38-39 (normalized local error, zero-stability, convergence), pp. 42-43 and 56-57 (scalar stability and stiffness), pp. 76-80 (quadrature and explicit RK), pp. 102-104 (collocation, stiff accuracy and decay), Exercise 4.13, p. 119 (algebraic stability).
Biegler, L. T. (2010), Nonlinear Programming: §9.2, pp. 253-260 (integration, RK, multistep methods and DAEs); Theorem 10.1, p. 291 (Gaussian quadrature). Use in each RK derivative sum and differentiate the chosen DAE residual to determine its Jacobian signs.
Burrage, K. & Butcher, J. C. (1979), “Stability criteria for implicit Runge-Kutta methods,” SIAM Journal on Numerical Analysis 16(1), 46-57, DOI: definitions on pp. 48-49 and Theorem 3.3 on p. 50. The distinct-node condition is needed for the converse equivalence.
Gear, C. W. & Petzold, L. R. (1984), “ODE methods for the solution of differential/algebraic systems,” SIAM Journal on Numerical Analysis 21(4), 716-728, DOI: Theorem 2.1, p. 719, is the fixed-step BDF convergence result with its regularity and initial-value assumptions.
Bynum et al. (2021), Pyomo: Optimization Modeling in Python, 3rd ed., §12.1, p. 181: complete ODE-constrained optimization example implemented above.
The derivations distinguish scalar test-equation behavior from nonlinear and DAE claims. The code includes endpoint, residual, conservation, and analytic-error checks; successful integration alone is insufficient evidence of the requested accuracy.
- Burrage, K., & Butcher, J. C. (1979). Stability Criteria for Implicit Runge–Kutta Methods. SIAM Journal on Numerical Analysis, 16(1), 46–57. 10.1137/0716004
- Gear, C. W., & Petzold, L. R. (1984). ODE Methods for the Solution of Differential/Algebraic Systems. SIAM Journal on Numerical Analysis, 21(4), 716–728. 10.1137/0721048






