Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Numeric Integration for DAEs

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 plt

Reference guide

The lecture develops the main derivations through partial fill-ins. This guide retains the detailed assumptions, calculations, and implementation choices for later use.

Quadrature, precision, and accumulated error

For h=b−a>0h=b-a>0, approximate an integral by function samples:

I(f)=∫abf(t) dt,Q(f)=h∑j=1Lwjf(a+hcj).I(f)=\int_a^b f(t)\,dt,\qquad Q(f)=h\sum_{j=1}^{L}w_j f(a+hc_j).

The nodes cjc_j lie in [0,1][0,1] for the rules considered here. The weights are dimensionless. A rule has precision pp if it integrates every polynomial of degree at most p−1p-1 exactly, and fails for at least one degree-pp polynomial. This convention makes precision one larger than the maximum degree of exactness.

RuleNodes cjc_jWeights wjw_jPrecisionOne-interval errorComposite error on a fixed interval
Midpoint1/21/212O(h3)O(h^3)O(h2)O(h^2)
Trapezoidal0,10,11/2,1/21/2,1/22O(h3)O(h^3)O(h2)O(h^2)
Simpson0,1/2,10,1/2,11/6,4/6,1/61/6,4/6,1/64O(h5)O(h^5)O(h4)O(h^4)
Midpoint, trapezoidal and Simpson rules integrate interpolants through their nodes.

The bounds require the relevant derivatives to remain bounded as the mesh is refined. For example, expand about the midpoint m=(a+b)/2m=(a+b)/2:

∫abf(t) dt=hf(m)+h324f′′(m)+O(h5).\int_a^b f(t)\,dt=h f(m)+\frac{h^3}{24}f''(m)+O(h^5).

Odd powers cancel by symmetry. Midpoint quadrature omits the second term. Trapezoidal quadrature instead gives

h2[f(a)+f(b)]=hf(m)+h38f′′(m)+O(h5),\frac h2[f(a)+f(b)]=h f(m)+\frac{h^3}{8}f''(m)+O(h^5),

so I−Qtrap=−h3f′′(m)/12+O(h5)I-Q_{\rm trap}=-h^3 f''(m)/12+O(h^5). These are single-interval statements. On a fixed interval of length TT, a uniform composite rule sums N=T/hN=T/h interval errors. If each error is bounded by Chp+1C h^{p+1} with the same CC, then

∣I−Qcomposite∣≤NChp+1=CThp.|I-Q_{\rm composite}|\le NC h^{p+1}=CT h^p.
Local quadrature errors sum over a fixed interval as the mesh is refined.

This is why the composite rule loses one order. For nonuniform widths hi≤hmax⁡h_i\le h_{\max}, the same argument gives ∑iChip+1≤CThmax⁡p\sum_i C h_i^{p+1}\le CT h_{\max}^{p}. Cancellation can improve a particular example; the bound does not assume it.

The forward finite difference is [f(b)−f(a)]/(b−a)[f(b)-f(a)]/(b-a). Reversing the numerator changes its sign. Quadrature accuracy and finite-difference accuracy concern different approximations and should not be interchanged. Ascher & Petzold, p. 76, supplies the one-interval precision convention; the composite bound above follows by summation.

Why Gauss nodes give precision 2L2L

One node on the symmetric interval

Require Q(f)=wf(c)Q(f)=w f(c) to integrate every linear function on [−1,1][-1,1] exactly. Matching the constant and linear moments gives

w=∫−111 dx=2,wc=∫−11x dx=0.w=\int_{-1}^{1}1\,dx=2,\qquad wc=\int_{-1}^{1}x\,dx=0.

Thus c=0c=0 and Q(ax+b)=2b=∫−11(ax+b) dxQ(ax+b)=2b=\int_{-1}^{1}(ax+b)\,dx. The rectangle at the sampled height has equal excess and deficit areas: the odd contribution axax cancels. The sketch uses a=1/2,b=1a=1/2,b=1; each triangle has area 1/41/4. Reversing the slope swaps their roles. The rule fails for f(x)=x2f(x)=x^2, since Q(f)=0Q(f)=0 while the integral is 2/32/3, so its precision is two.

For a linear function, the excess and deficit of the rectangle at the central sample have equal areas.

Orthogonal polynomial construction on the unit interval

Let PLP_L be a degree-LL polynomial orthogonal to every lower-degree polynomial on [0,1][0,1] with unit weight:

∫01PL(τ)q(τ) dτ=0for every q∈PL−1.\int_0^1 P_L(\tau)q(\tau)\,d\tau=0\quad\text{for every }q\in\mathcal P_{L-1}.

Choose its LL distinct roots c1,…,cLc_1,\ldots,c_L as nodes. With the Lagrange basis

ℓj(τ)=∏r≠jτ−crcj−cr,wj=∫01ℓj(τ) dτ,\ell_j(\tau)=\prod_{r\ne j}\frac{\tau-c_r}{c_j-c_r},\qquad w_j=\int_0^1\ell_j(\tau)\,d\tau,

the quadrature is exact on PL−1\mathcal P_{L-1}. Now take any p∈P2L−1p\in\mathcal P_{2L-1}. Polynomial division gives

p=qPL+r,deg⁡q≤L−1,deg⁡r≤L−1.p=qP_L+r,\qquad \deg q\le L-1,\quad\deg r\le L-1.
Polynomial division splits the Gauss proof into sampling at roots and integration by orthogonality.

At every node, p(cj)=r(cj)p(c_j)=r(c_j) because PL(cj)=0P_L(c_j)=0. Orthogonality also gives ∫01qPL=0\int_0^1qP_L=0. Therefore

∑jwjp(cj)=∑jwjr(cj)=∫01r=∫01p.\sum_jw_jp(c_j)=\sum_jw_jr(c_j)=\int_0^1r=\int_0^1p.

Both zero terms matter: roots remove qPLqP_L from the discrete sum, while orthogonality removes it from the integral. Counting LL node locations and LL weights suggests 2L2L matching moments but does not prove that suitable real nodes exist or that all moment equations can be satisfied. The orthogonal-polynomial construction does.

No real LL-node formula can be exact through degree 2L2L: the nonnegative polynomial ∏j(τ−cj)2\prod_j(\tau-c_j)^2 vanishes at all nodes but has a positive integral. Thus the maximum precision is 2L2L. With L=1L=1, P1(τ)=τ−1/2P_1(\tau)=\tau-1/2, so c1=1/2c_1=1/2 and w1=1w_1=1. With L=2L=2, the nodes are 1/2±1/(23)1/2\pm1/(2\sqrt3) and the weights are 1/2,1/21/2,1/2.

Biegler, Theorem 10.1, p. 291, uses this construction. Here “degree at most 2L−12L-1” states the convention without relying on the book’s ambiguous use of polynomial “order.”

From an integral to a time step

For z˙=f(t,z)\dot{\mathbf z}=\mathbf f(t,\mathbf z), the exact identity is

z(ti+1)=z(ti)+∫titi+1f(t,z(t)) dt.\mathbf z(t_{i+1})=\mathbf z(t_i)+\int_{t_i}^{t_{i+1}}\mathbf f(t,\mathbf z(t))\,dt.

The integrand contains the unknown trajectory. Using the left endpoint gives forward Euler (FE), the right endpoint gives backward Euler (BE), and averaging both gives the trapezoidal method, also called Crank-Nicolson (CN):

zi+1FE=zi+hif(ti,zi),zi+1BE=zi+hif(ti+1,zi+1BE),zi+1CN=zi+hi2[f(ti,zi)+f(ti+1,zi+1CN)].\begin{aligned} \mathbf z_{i+1}^{\rm FE}&=\mathbf z_i+h_i\mathbf f(t_i,\mathbf z_i),\\ \mathbf z_{i+1}^{\rm BE}&=\mathbf z_i+h_i\mathbf f(t_{i+1},\mathbf z_{i+1}^{\rm BE}),\\ \mathbf z_{i+1}^{\rm CN}&=\mathbf z_i+\tfrac{h_i}{2}[\mathbf f(t_i,\mathbf z_i)+\mathbf f(t_{i+1},\mathbf z_{i+1}^{\rm CN})]. \end{aligned}
Known and unknown endpoint derivative samples distinguish Euler and trapezoidal updates.

FE evaluates a known state. BE and CN require a solve for the new state. A smaller quadrature error does not remove the cost or possible failure of that nonlinear solve. Ascher & Petzold, pp. 77-78, derives these formulas.

Local error, global error, and zero-stability

Write a difference operator Nh\mathcal N_h with the derivative normalization included. For FE,

(Nhv)i=vi+1−vihi−f(ti,vi).(\mathcal N_h\mathbf v)_i=\frac{\mathbf v_{i+1}-\mathbf v_i}{h_i}-\mathbf f(t_i,\mathbf v_i).

The normalized local truncation error is di=(Nhz)i\mathbf d_i=(\mathcal N_h\mathbf z)_i, obtained by inserting the exact trajectory. Taylor expansion gives di=12hiz′′(ti)+O(hi2)\mathbf d_i=\tfrac12h_i\mathbf z''(t_i)+O(h_i^2). Thus FE is consistent of order one. The corresponding unnormalized one-step defect is hidi=O(hi2)h_i\mathbf d_i=O(h_i^2). Some references call that defect the local error; state the normalization whenever quoting an order.

The global error at a mesh point is ei=z(ti)−zi\mathbf e_i=\mathbf z(t_i)-\mathbf z_i, using a fixed initial-value problem and a fixed final time. Convergence of order qq means max⁡i∥ei∥≤Chmax⁡q\max_i\|\mathbf e_i\|\le C h_{\max}^{q} for sufficiently small hmax⁡h_{\max}, with CC independent of the mesh.

Local evaluation restarts at the exact state, while global error compares the repeated computed trajectory.

Zero-stability controls the propagation of perturbations as the mesh is refined. One operator form, on the admissible nearby grid functions, is

max⁡i∥xi−vi∥≤K(∥x0−v0∥+max⁡i∥(Nhx)i−(Nhv)i∥),\max_i\|\mathbf x_i-\mathbf v_i\|\le K\left(\|\mathbf x_0-\mathbf v_0\|+\max_i\|(\mathcal N_h\mathbf x)_i-(\mathcal N_h\mathbf v)_i\|\right),

where KK and the allowed maximum step are independent of the mesh on a fixed time interval. This is a statement about a family of shrinking meshes, rather than a large-step absolute-stability condition.

A mesh-independent inverse bound converts residual and initial-data differences into trajectory error.

To see consistency plus zero-stability imply convergence, substitute the exact grid values for x\mathbf x and the computed solution for v\mathbf v. The computed residual is zero; the exact residual is d\mathbf d. Hence

max⁡i∥ei∥≤K(∥e0∥+max⁡i∥di∥).\max_i\|\mathbf e_i\|\le K\left(\|\mathbf e_0\|+\max_i\|\mathbf d_i\|\right).

If initial error and normalized truncation error are O(hmax⁡q)O(h_{\max}^{q}), so is the global error. This proof requires the norm estimate, regularity, and fixed time interval; “add NN errors” alone does not establish stability for an arbitrary method. Ascher & Petzold, Definition 3.2 and Theorem 3.1, pp. 38-39; Biegler, equations (9.5)-(9.6), p. 254.

Method orders and error normalization

Under the smoothness and zero-stability assumptions on a fixed interval:

MethodUnnormalized one-step defectNormalized local truncation errorGlobal error
Forward EulerO(h2)O(h^2)O(h)O(h)O(h)O(h)
Backward EulerO(h2)O(h^2)O(h)O(h)O(h)O(h)
Trapezoidal / Crank-NicolsonO(h3)O(h^3)O(h2)O(h^2)O(h2)O(h^2)
Explicit midpointO(h3)O(h^3)O(h2)O(h^2)O(h2)O(h^2)
Classical RK4O(h5)O(h^5)O(h4)O(h^4)O(h4)O(h^4)

The order concerns refinement toward zero step size. It does not imply that a coarse step is stable or resolves a fast transient. Ascher & Petzold, pp. 77-80, gives the formulas; the preceding operator estimate connects their normalized local orders to the global orders.

Higher-order explicit methods

The explicit midpoint rule estimates the state halfway through a step, then uses its derivative for the full update:

k1=f(ti,zi),k2=f(ti+hi/2,zi+hik1/2),zi+1=zi+hik2.\mathbf k_1=\mathbf f(t_i,\mathbf z_i),\quad \mathbf k_2=\mathbf f(t_i+h_i/2,\mathbf z_i+h_i\mathbf k_1/2),\quad \mathbf z_{i+1}=\mathbf z_i+h_i\mathbf k_2.

It has normalized local order two and one-step defect O(hi3)O(h_i^3). The classical four-stage RK4 formula is

k1=f(ti,zi),k2=f(ti+hi/2,zi+hik1/2),k3=f(ti+hi/2,zi+hik2/2),k4=f(ti+hi,zi+hik3),zi+1=zi+hi6(k1+2k2+2k3+k4).\begin{aligned} \mathbf k_1&=\mathbf f(t_i,\mathbf z_i),\\ \mathbf k_2&=\mathbf f(t_i+h_i/2,\mathbf z_i+h_i\mathbf k_1/2),\\ \mathbf k_3&=\mathbf f(t_i+h_i/2,\mathbf z_i+h_i\mathbf k_2/2),\\ \mathbf k_4&=\mathbf f(t_i+h_i,\mathbf z_i+h_i\mathbf k_3),\\ \mathbf z_{i+1}&=\mathbf z_i+\tfrac{h_i}{6}(\mathbf k_1+2\mathbf k_2+2\mathbf k_3+\mathbf k_4). \end{aligned}

Its normalized local order is four and its one-step defect is O(hi5)O(h_i^5). These global orders require sufficient smoothness and stability. Higher order reduces the small-step error but does not imply A-stability. On z′=λzz'=\lambda z, explicit midpoint has R(ζ)=1+ζ+ζ2/2R(\zeta)=1+\zeta+\zeta^2/2, while RK4 has R(ζ)=1+ζ+ζ2/2+ζ3/6+ζ4/24R(\zeta)=1+\zeta+\zeta^2/2+\zeta^3/6+\zeta^4/24; both have bounded absolute-stability regions. Ascher & Petzold, equations (4.3) and (4.7), pp. 78-79.

Single-Step Runge-Kutta Methods

General Form: Index 0 DAE

Consider the ODE system:

z˙=f(t,z),z(t0)=z0\dot{\mathbf{z}} = \mathbf{f}(t,\mathbf{z}), \quad \mathbf{z}(t_0) = \mathbf{z}_0

where z(t)\mathbf{z}(t) are the differential variables and f(t,z)\mathbf{f}(t,\mathbf{z}) is a (nonlinear) continuous function.

The general Runge-Kutta formula is:

zi+1=zi+hi∑k=1nsbkf(ti+ckhi,z^k)z^k=zi+hi∑j=1nsak,jf(ti+cjhi,z^j),k=1,...,ns\begin{align} \mathbf{z}_{i+1} &= \mathbf{z}_{i} + h_i \sum_{k=1}^{n_s} b_k \mathbf{f}(t_i + c_k h_i, \hat{\mathbf{z}}_k) \\ \hat{\mathbf{z}}_k &= \mathbf{z}_i + h_i \sum_{j=1}^{n_s} a_{k,j} \mathbf{f}(t_i + c_j h_i, \hat{\mathbf{z}}_j), \quad k=1,...,n_s \end{align}

where

  • zi\mathbf{z}_i are the differential variables at the start of step ii (time tit_i)

  • zi+1\mathbf{z}_{i+1} are the differential variables at the end of step ii (time ti+1t_{i+1})

  • z^k\hat{\mathbf{z}}_k are differential variables for intermediate stage kk

  • hih_i is the size for step ii such that ti+1=ti+hit_{i+1} = t_i + h_i

  • nsn_s is the number of stages

  • ak,ja_{k,j} are coefficients, together known as the Runge-Kutta matrix

  • bkb_k are coefficients, known as the weights

  • ckc_k are coefficients, known as the nodes

hih_i is selected using local error estimates and stability requirements. Zeros in A\mathbf A express which stage evaluations are absent.

The choice for A\mathbf{A}, b\mathbf{b} and c\mathbf{c} 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 bT1=1\mathbf b^T\mathbf 1=1. For the internally consistent stage times used here, A1=c\mathbf A\mathbf1=\mathbf c as well:

∑k=1nsbk=1and∑j=1nsak,j=ck\sum_{k=1}^{n_s} b_k = 1 \quad \mathrm{and} \quad \sum_{j=1}^{n_s} a_{k,j} = c_k

Explicit (Forward) Euler

Consider one of the simplest Runge-Kutta methods:

zi+1=zi+hi f(ti,zi)\mathbf{z}_{i+1} = \mathbf{z}_{i} + h_i~\mathbf{f}(t_i, \mathbf{z}_i)

What are A\mathbf{A}, b\mathbf{b} and c\mathbf{c} in the general formula?

ns=1n_s = 1. This is only a single stage. Thus we only need to determine c1c_1, b1b_1, and a11a_{11}

Moreover, z^1=zi\hat{\mathbf{z}}_1 = \mathbf{z}_i because f(⋅)\mathbf{f}(\cdot) is only evaluated at tit_i and zi\mathbf{z}_i. This implies:

  • a11=0a_{11} = 0

  • c1=0c_1 = 0

  • b1=1b_1 = 1

  • A=[0]\mathbf{A}=[0]; this is a one-by-one zero matrix, not an empty matrix.

The implementation is very straightforward (see below). We can calculate zi+1\mathbf{z}_{i+1} 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, Z

Implicit (Backward) Euler

Consider another simple Runge-Kutta method:

zi+1=zi+hi f(ti+1,zi+1)\mathbf{z}_{i+1} = \mathbf{z}_{i} + h_i~\mathbf{f}(t_{i+1}, \mathbf{z}_{i+1})

What are A\mathbf{A}, b\mathbf{b} and c\mathbf{c} to express using the general formula?

ns=1n_s = 1. This is only a single stage. Moreover, z^1=zi+1\hat{\mathbf{z}}_1 = \mathbf{z}_{i+1} because f(⋅)\mathbf{f}(\cdot) is evaluated at ti+1t_{i+1} and zi+1\mathbf{z}_{i+1}. This implies:

  • b1=1b_1 = 1

  • c1=1c_1 = 1

Moreover, zi+1=zi+hi f(ti+1,zi+1)\mathbf{z}_{i+1} = \mathbf{z}_{i} + h_i~\mathbf{f}(t_{i+1}, \mathbf{z}_{i+1}) implies z^1=zi+hif(ti+1,z^1)\hat{\mathbf{z}}_1 = \mathbf{z}_i + h_i \mathbf{f}(t_{i+1}, \hat{\mathbf{z}}_1). Thus:

  • a1,1=1a_{1,1} = 1

Notice that the formula for zi+1\mathbf{z}_{i+1} 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, Z

Key Differences

Explicit MethodsImplicit Methods
+ Easy to Program- Requires converging system of nonlinear equations
- Stability regions are often bounded, so stability can restrict hih_i+ Stability regions are often larger or unbounded, but still depend on the method and problem

Comparison

Let’s test this on a simple problem:

z˙(t)=−λz(t),z0=1.\dot{z}(t) = -\lambda z(t), \qquad z_0 = 1.

The solution to this problem is

z(t)=e−λt.z(t) = e^{-\lambda t}.

For simplicity, let’s numerically analyze λ=1\lambda = 1.

Forward and backward Euler compared with the exact solution at two step sizes.

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 λ=1\lambda = 1, 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
<Figure size 900x360 with 2 Axes>

Scalar stability and oscillation

For z˙=−αz\dot z=-\alpha z, α>0\alpha>0, z0>0z_0>0, set μ=αh\mu=\alpha h. The exact solution is positive and decreasing. Each numerical method gives zi+1=g(μ)ziz_{i+1}=g(\mu)z_i. Substitute the test equation into each update and solve for the new state:

gFE=1−μ,gBE=11+μ,gCN=2−μ2+μ.g_{\rm FE}=1-\mu,\qquad g_{\rm BE}=\frac1{1+\mu},\qquad g_{\rm CN}=\frac{2-\mu}{2+\mu}.

For this scalar problem, ∣g∣≤1|g|\le1 means no amplification. The stronger condition 0≤g≤10\le g\le1 also preserves nonnegative monotone decay. This sign test is a direct observation about the scalar recurrence, not a general nonlinear stability theorem.

MethodBounded for μ≥0\mu\ge0Nonnegative monotone decayLimit as μ→∞\mu\to\infty
FE0≤μ≤20\le\mu\le20≤μ≤10\le\mu\le1Unbounded magnitude
BEAll μ≥0\mu\ge0All μ≥0\mu\ge00
CNAll μ≥0\mu\ge00≤μ≤20\le\mu\le2-1

At FE’s endpoint μ=2\mu=2, values alternate with unchanged magnitude. They are bounded, but do not approach zero. CN can also alternate when μ>2\mu>2 even though its magnitude decreases. Thus stability, non-oscillation, and accurate transient resolution are separate questions. The prepared comparison uses FE and BE at h=1h=1 and h=2.5h=2.5 with α=1\alpha=1.

Complex stability regions and stiff decay

Apply a method to z˙=λz\dot z=\lambda z with complex λ\lambda and let ζ=hλ\zeta=h\lambda. If zi+1=R(ζ)ziz_{i+1}=R(\zeta)z_i, its absolute-stability region is the set where RR is defined and ∣R(ζ)∣≤1|R(\zeta)|\le1. For FE, BE, and CN:

RFE=1+ζ,RBE=(1−ζ)−1,RCN=1+ζ/21−ζ/2.R_{\rm FE}=1+\zeta,\quad R_{\rm BE}=(1-\zeta)^{-1},\quad R_{\rm CN}=\frac{1+\zeta/2}{1-\zeta/2}.

FE’s region is the disk ∣1+ζ∣≤1|1+\zeta|\le1. BE’s is the exterior ∣1−ζ∣≥1|1-\zeta|\ge1. For CN, squaring ∣1+ζ/2∣≤∣1−ζ/2∣|1+\zeta/2|\le|1-\zeta/2| gives Re⁡ζ≤0\operatorname{Re}\zeta\le0. 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 R(ζ)R(\zeta) is unbounded as ζ→−∞\zeta\to-\infty. 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 Re⁡λ=0\operatorname{Re}\lambda=0 and Im⁡λ≠0\operatorname{Im}\lambda\ne0, the exact scalar solution oscillates at constant magnitude. When λ=0\lambda=0, it is constant.

For a bounded smooth moving equilibrium γ(t)\gamma(t), consider

z˙=λ[z−γ(t)].\dot z=\lambda[z-\gamma(t)].

The stiff-decay requirement used by Biegler is that the computed endpoint follows the moving equilibrium, ∣zi−γ(ti)∣→0|z_i-\gamma(t_i)|\to0 as Re⁡(hλ)→−∞\operatorname{Re}(h\lambda)\to-\infty, at fixed positive step times after the initial layer. In the homogeneous special case γ=0\gamma=0, this requires R(ζ)→0R(\zeta)\to0. 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 A\mathbf A together with stiff accuracy (bT\mathbf b^T equals the final row of A\mathbf A, with endpoint stage cs=1c_s=1). 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 λ\lambda. AN-stability tests z′=λ(t)zz'=\lambda(t)z with Re⁡λ(t)≤0\operatorname{Re}\lambda(t)\le0. For an ss-stage RK method, let Z=diag⁡(ζ1,…,ζs)\mathbf Z=\operatorname{diag}(\zeta_1,\ldots,\zeta_s), where ζj=hλ(ti+cjh)\zeta_j=h\lambda(t_i+c_jh). The scalar amplification is

K(Z)=1+bTZ(I−AZ)−11.K(\mathbf Z)=1+\mathbf b^T\mathbf Z(\mathbf I-\mathbf A\mathbf Z)^{-1}\mathbf1.

AN-stability requires this function to be defined and to satisfy ∣K(Z)∣≤1|K(\mathbf Z)|\le1 for every admissible stage tuple with Re⁡ζj≤0\operatorname{Re}\zeta_j\le0. Repeated nodes must use equal ζj\zeta_j, because they sample the same time. When all ζj=ζ\zeta_j=\zeta, KK reduces to R(ζ)R(\zeta).

The real-coefficient method is algebraically stable if

bj≥0for all j,M=BA+ATB−bbT⪰0,B=diag⁡(b).b_j\ge0\quad\text{for all }j,\qquad \mathbf M=\mathbf B\mathbf A+\mathbf A^T\mathbf B-\mathbf b\mathbf b^T\succeq0, \quad \mathbf B=\operatorname{diag}(\mathbf b).

Equivalently, Mij=biaij+bjaji−bibjM_{ij}=b_i a_{ij}+b_j a_{ji}-b_i b_j. Algebraic stability implies AN-stability. The converse holds when the nodes cjc_j 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, A=b=c=1A=b=c=1, so M=1≥0M=1\ge0. For FE, A=c=0A=c=0, b=1b=1, so M=−1M=-1, which fails. For trapezoidal RK, A=[[0,0],[1/2,1/2]]\mathbf A=[[0,0],[1/2,1/2]], b=(1/2,1/2)T\mathbf b=(1/2,1/2)^T, c=(0,1)T\mathbf c=(0,1)^T, giving M=diag⁡(−1/4,1/4)\mathbf M=\operatorname{diag}(-1/4,1/4): 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

K(ζ1,ζ2)=1+ζ1/21−ζ2/2.K(\zeta_1,\zeta_2)=\frac{1+\zeta_1/2}{1-\zeta_2/2}.

Choose ζ1=−10\zeta_1=-10 and ζ2=0\zeta_2=0, both in the left half-plane. Then K=−4K=-4, so the stage-varying problem can amplify even though the constant-coefficient function R(ζ)=K(ζ,ζ)R(\zeta)=K(\zeta,\zeta) 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 hλ≤2h\lambda \leq 2 and non-oscillatory when hλ≤1h\lambda \leq 1.

  • Backward Euler is stable and non-oscillatory for every h>0h > 0 when λ>0\lambda > 0.

  • Crank--Nicolson is stable for every h>0h > 0, but oscillates when hλ>2h\lambda > 2.

  • Similar analysis and concepts extend to Runge-Kutta methods.

Regions of absolute stability in the complex h-lambda plane: forward Euler is a bounded disc, backward Euler is the exterior of a disc, Crank-Nicolson is exactly the left half-plane.

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 hλ≤2h\lambda\le 2 (boundedness, including the neutral endpoint). Backward Euler and Crank-Nicolson both are.

Error Analysis

How does our choice in step size hh impact the error of these numerical techniques?

Measured global error at six successively halved step sizes, with a unit-slope reference triangle.

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
<Figure size 540x440 with 1 Axes>

Both Euler methods are first order in Δt\Delta t: halving Δt\Delta t approximately halves the global error. Here we measure the error with the root-mean-square norm

Error=1N∑n=1N(yapproxn−yexactn)2,\mathrm{Error} = \frac{1}{\sqrt{N}}\sqrt{\sum_{n=1}^{N} \left(y^n_\mathrm{approx} - y^n_\mathrm{exact}\right)^2},

where NN is the number of steps the ODE is solved over.

For both Euler methods, the unnormalized one-step defect is O(h2)O(h^2), the local truncation error divided by hh is O(h)O(h), and the global error is O(h)O(h) 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:

z˙=−y,0=y−z2,z(0)=y(0)=1.\dot z=-y,\qquad 0=y-z^2,\qquad z(0)=y(0)=1.

Here gy=1g_y=1, so the algebraic variable is locally determined by zz. The exact solution is z(t)=1/(1+t)z(t)=1/(1+t) and y(t)=1/(1+t)2y(t)=1/(1+t)^2. Backward Euler solves both unknown endpoint values together:

F(zi+1,yi+1)=[zi+1−zi+hiyi+1yi+1−zi+12]=0,J=[1hi−2zi+11].\mathbf F(z_{i+1},y_{i+1})= \begin{bmatrix}z_{i+1}-z_i+h_i y_{i+1}\\y_{i+1}-z_{i+1}^2\end{bmatrix}=\mathbf0, \qquad \mathbf J=\begin{bmatrix}1&h_i\\-2z_{i+1}&1\end{bmatrix}.

The upper-right sign is positive here because f=−yf=-y and the residual subtracts hfhf. Elimination gives hizi+12+zi+1−zi=0h_i z_{i+1}^2+z_{i+1}-z_i=0. The root continuous from the positive initial state is

zi+1=2zi1+1+4hizi,yi+1=zi+12.z_{i+1}=\frac{2z_i}{1+\sqrt{1+4h_i z_i}},\qquad y_{i+1}=z_{i+1}^2.

This rearrangement avoids subtracting nearly equal numbers for small hih_i. With zi=1z_i=1 and hi=1/2h_i=1/2, zi+1=3−1≈0.73205z_{i+1}=\sqrt3-1\approx0.73205, yi+1≈0.53590y_{i+1}\approx0.53590. The exact differential state at t=1/2t=1/2 is 2/32/3; satisfying the algebraic equation exactly does not remove time-discretization error.

For a general regular index-1 DAE, the RK stage equations below enforce g=0\mathbf g=\mathbf0 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:

z˙=f(t,z,y),g(z,y)=0,z(t0)=z0\begin{aligned} \dot{\mathbf{z}} = \mathbf{f}(t,\mathbf{z},\mathbf{y}), \quad \mathbf{g}(\mathbf{z},\mathbf{y}) = \mathbf{0}, \quad \mathbf{z}(t_0) = \mathbf{z}_0 \end{aligned}

For a regular semi-explicit index-1 system, gy\mathbf g_{\mathbf y} is nonsingular along the solution. Enforce the algebraic equations at every stage:

zi+1=zi+hi∑k=1nsbkf(ti+ckhi,z^k,y^k)z^k=zi+hi∑j=1nsak,jf(ti+cjhi,z^j,y^j),k=1,...,ns0=g(z^k,y^k),k=1,...,ns\begin{aligned} \mathbf{z}_{i+1} &= \mathbf{z}_{i} + h_i \sum_{k=1}^{n_s} b_k \mathbf{f}(t_i + c_k h_i, \hat{\mathbf{z}}_k, \hat{\mathbf{y}}_k) \\ \hat{\mathbf{z}}_k &= \mathbf{z}_i + h_i \sum_{j=1}^{n_s} a_{k,j} \mathbf{f}(t_i + c_j h_i, \hat{\mathbf{z}}_j, \hat{\mathbf{y}}_j), \quad k=1,...,n_s \\ \mathbf{0} &= \mathbf{g}(\hat{\mathbf{z}}_k, \hat{\mathbf{y}}_k), \quad k=1,...,n_s \end{aligned}
Differential and algebraic stage values solve a coupled residual system.

Backward differentiation formulas (BDFs) also extend to index-1 DAEs. Backward Euler is the one-step, first-order member of this family.

zi+1=∑j=1nsαjzi−j+1+β0hf(zi+1,yi+1)0=g(zi+1,yi+1)\begin{aligned} \mathbf{z}_{i+1} &= \sum_{j=1}^{n_s} \alpha_j \mathbf{z}_{i-j+1} + \beta_0 h\mathbf{f}(\mathbf{z}_{i+1},\mathbf{y}_{i+1})\\ \mathbf{0} &= \mathbf{g}(\mathbf{z}_{i+1},\mathbf{y}_{i+1}) \end{aligned}

At each BDF step, solve the coupled equations for (zi+1,yi+1)(\mathbf{z}_{i+1},\mathbf{y}_{i+1}) with Newton’s method. Using the conventional row-by-variable Jacobian and the residual zi+1−∑jαjzi−j+1−β0hf(zi+1,yi+1)=0\mathbf{z}_{i+1}-\sum_j \alpha_j \mathbf{z}_{i-j+1}-\beta_0 h \mathbf{f}(\mathbf{z}_{i+1},\mathbf{y}_{i+1})=0, the Jacobian is:

J(zi+1,yi+1)=[I−hβ0∂f∂z−hβ0∂f∂y∂g∂z∂g∂y]\begin{aligned} \mathbf{J}(\mathbf{z}_{i+1},\mathbf{y}_{i+1}) &= \begin{bmatrix} \mathbf{I} - h\beta_0\frac{\partial \mathbf{f}}{\partial \mathbf{z}} & -h\beta_0\frac{\partial \mathbf{f}}{\partial \mathbf{y}} \\ \frac{\partial \mathbf{g}}{\partial \mathbf{z}} & \frac{\partial \mathbf{g}}{\partial \mathbf{y}} \end{bmatrix} \end{aligned}

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 I−hβ0fz\mathbf{I}-h\beta_0 \mathbf{f}_{\mathbf{z}}; 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 y\mathbf y, 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 k<7k<7 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 x3(tf)x_3(t_f) by choosing u(t)u(t), 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

min⁡u(t)x3(tf)s.t.x˙1=x2x˙2=−x2+u(t)x˙3=x12+x22+0.005⋅u2x2−8⋅(t−0.5)2+0.5≤0x1(0)=0,x2(0)=−1,x3(0)=0,tf=1\begin{align} \min_{u(t)} \quad & x_3(t_f) \\ \mathrm{s.t.} \quad & \dot{x}_1 = x_2 \\ & \dot{x}_2 = -x_2 + u(t) \\ & \dot{x}_3 = x_1^2 + x_2^2 + 0.005 \cdot u^2 \\ & x_2 - 8 \cdot (t-0.5)^2 + 0.5 \leq 0 \\ & x_1(0) = 0, x_2(0) = -1, x_3(0) = 0, t_f = 1 \end{align}

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: u(t)u(t)

Objective: x3(tf)x_3(t_f)

3 differential equations in the constraints and a path constraint, which is an inequality constraint restricting a variable.

The path constraint directly impacts x2x_2.

import pyomo.environ as pyo
import pyomo.dae as dae

That 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)
<Figure size 640x480 with 2 Axes>

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

z˙=[−1001−1−1]z.\dot{\mathbf z}=\begin{bmatrix}-100&1\\-1&-1\end{bmatrix}\mathbf z.

Its distinct real eigenvalues are (−101±9797)/2(-101\pm\sqrt{9797})/2, approximately -1.0101 and -99.9899. A diagonalizable modal representation gives slow and fast factors eλ1te^{\lambda_1t} and eλ2te^{\lambda_2t}. The corresponding time constants are about 0.9900 and 0.0100. Requiring both FE modal factors to satisfy ∣1+hλj∣≤1|1+h\lambda_j|\le1 gives h≤2/99.9899≈0.0200h\le2/99.9899\approx0.0200, hence at least 500 steps to reach t=10t=10. 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.

Schematic clocks for the two decaying modes of the coupled handout model; spacings differ between rows.
Driver or familyFormulaWhat the name establishes
SciPy RK45Explicit Dormand-Prince 5(4) RK pairFifth-order steps with a fourth-order-based local error estimate; bounded stability region
SciPy RadauImplicit fifth-order Radau IIAStiff-capable RK with coupled stages
SciPy BDFImplicit variable-order 1-5 BDF/NDF implementationMultistep history; orders above two are not A-stable
AdamsExplicit Adams-Bashforth or implicit Adams-MoultonMultistep derivative history, usually for nonstiff ODEs
SciPy LSODASwitches Adams/BDFDetects stiffness and switches formulas
SUNDIALS CVODESAdams or BDF for ODEs, with sensitivitiesAn ODE solver, including use through interfaces such as CasADi
SUNDIALS IDA/IDASBDF for residual-form DAEsSolves F(t,z,z˙)=0\mathbf F(t,\mathbf z,\dot{\mathbf z})=\mathbf0; IDAS adds sensitivities

solve_ivp supplies an ODE interface, z˙=f(t,z)\dot{\mathbf z}=\mathbf f(t,\mathbf z). 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 hh changes the method.

A marked integration step crosses a known control jump while the state remains continuous.

At a known piecewise-constant control jump tst_s, integrate exactly to tst_s, 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:

cAbT.\begin{array}{c|c}\mathbf c&\mathbf A\\\hline&\mathbf b^T\end{array}.

The row akja_{kj} tells how derivative sample jj contributes to stage state kk; bjb_j tells how that sample contributes to the endpoint. A strictly lower triangular A\mathbf A 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 [0,1][0,1] are used by the standard formulas here, but that interval is not a universal definition of RK methods.

Two-stage RK dependency graph with stage coefficients and endpoint weights.

FE has A=[0],b=[1],c=[0]A=[0],b=[1],c=[0]. BE has A=[1],b=[1],c=[1]A=[1],b=[1],c=[1]. Explicit midpoint has

0001/21/2001.\begin{array}{c|cc}0&0&0\\1/2&1/2&0\\\hline&0&1\end{array}.

Applying the general RK formula to z′=λzz'=\lambda z gives stage vector z^=zi1+ζAz^\hat{\mathbf z}=z_i\mathbf1+\zeta\mathbf A\hat{\mathbf z}. Where I−ζA\mathbf I-\zeta\mathbf A is invertible,

z^=(I−ζA)−11zi,R(ζ)=1+ζbT(I−ζA)−11.\hat{\mathbf z}=(\mathbf I-\zeta\mathbf A)^{-1}\mathbf1z_i,\qquad R(\zeta)=1+\zeta\mathbf b^T(\mathbf I-\zeta\mathbf A)^{-1}\mathbf1.
Scalar RK stages are solved before the endpoint amplification is computed; Y collects scalar stage values.

This derives the stability function directly from the same coefficients that specify the method. The hih_i 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 A→B→CA \rightarrow B \rightarrow C with time in seconds, k1=1000 s−1k_1=1000\,\mathrm{s}^{-1} and k2=1 s−1k_2=1\,\mathrm{s}^{-1}:

c˙=[−k100k1−k200k20]c,c(0)=[1,0,0]T.\dot{\mathbf{c}} = \begin{bmatrix} -k_1 & 0 & 0 \\ k_1 & -k_2 & 0 \\ 0 & k_2 & 0\end{bmatrix} \mathbf{c}, \qquad \mathbf{c}(0) = [1, 0, 0]^T .

The Jacobian eigenvalues are −k1-k_1, −k2-k_2 and 0, so the ratio of the two decaying rates is k1/k2=1000k_1/k_2 = 1000: AA disappears in about 1/k1=11/k_1 = 1 ms while BB 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
<Figure size 1100x460 with 2 Axes>
# 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();
<Figure size 1000x360 with 2 Axes>

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):

methodkindright-hand-side evaluationsaccepted stepsLU factorizationstypical step after the transient
RK45explicit11,0121,58203.3×10−33.3 \times 10^{-3}
Radauimplicit1,926274665.6×10−25.6 \times 10^{-2}
BDFimplicit556277527.9×10−27.9 \times 10^{-2}
LSODAswitching48423697.3×10−27.3 \times 10^{-2}

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 −k1-k_1 — still -1000 long after the mode it belongs to has died — at the Dormand--Prince stability limit h≈3.3/k1h \approx 3.3/k_1. The step it actually takes, 3.25×10−33.25 \times 10^{-3}, 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 11,012=2+6(1,835)11{,}012=2+6(1{,}835) records 1,835 attempts. Of these, 1,582 were accepted and 253 were rejected. The cost is about 11,012/1,582=6.9611{,}012/1{,}582=6.96 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 hih_i 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.

References
  1. 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
  2. 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