Prepared by: Prof. Alexander Dowling (2026)
This notebook is the PDE companion to Lecture 7. We take one transient conduction problem,
write it for three geometries at once, discretize space with the method of lines, and solve
the resulting differential-algebraic system with Pyomo.dae.
The physical problem and the idea of indexing the three geometries with a single shape parameter are adapted from J. C. Kantor, ND Pyomo Cookbook, notebook 5.03 “Heat Conduction in Various Geometries”. The derivation, the code and the numerical experiments below are our own.
# Install Pyomo and solvers for Google Colab
import sys
if "google.colab" in sys.modules:
!wget "https://raw.githubusercontent.com/ndcbe/optimization/main/notebooks/helper.py"
import helper
helper.easy_install()
else:
sys.path.insert(0, "../")
import helper
helper.set_plotting_style()
import numpy as np
import matplotlib.pyplot as plt
import pyomo.environ as pyo
import pyomo.dae as dae
from pyomo.util.model_size import build_model_size_report
from scipy.special import j0, j1, jn_zerosLearning Objectives¶
Nondimensionalize a transient conduction PDE and recognize the Fourier number as its only time scale
Write one model that covers a slab, a cylinder and a sphere through a shape index
Apply the method of lines: discretize space first, then hand a DAE in time to
Pyomo.daeCompare finite difference, finite volume and finite element spatial discretizations, and say where each belongs
Handle the coordinate singularity correctly
Run the IDAES Diagnostics Toolbox on a
Pyomo.daemodel and read its report -- including the difference between a warning and a cautionRecognize a dangling variable: a derivative that appears in no discretization equation, which IPOPT will happily declare
optimal
Use the boundary-condition guide for Dirichlet, Neumann and Robin equations, the center limit for regularization, and the boundary-control extension for stiffness and active path constraints. The executable FD and FV builders below are the correct models; the alternative-discretization tables retain historical failure evidence, without adding runnable broken variants.
The Physical Problem¶
A solid body, initially at uniform temperature , is dropped at into a bath held at . The surface reaches the bath temperature immediately, and we want the temperature everywhere inside as a function of time.
With constant positive thermal conductivity , density and heat capacity , no source term, and no axial or angular temperature variation, the energy balance is
Divide by and define the thermal diffusivity , which has units of length/time:
One Equation, Three Geometries¶
The three classical bodies -- an infinite slab of half-thickness , an infinite cylinder of radius , and a sphere of radius -- are all one-dimensional: temperature depends on a single space coordinate measured from the center. In each case the Laplacian of a radially symmetric field is
where the shape index is
| Geometry | measured from | Surface at | |
|---|---|---|---|
| Slab (infinite plate, half-thickness ) | 0 | the midplane | |
| Cylinder (infinite, radius ) | 1 | the axis | |
| Sphere (radius ) | 2 | the center |
For the slab, and the extra term vanishes -- Cartesian coordinates. For and the geometry contributes the term, which is singular at the center. That singularity is the interesting part of this notebook, so hold onto it.
Nondimensionalization¶
Assume and . If the two temperatures agree, this normalization is undefined and the constant-temperature solution should be treated directly. Three scales are available: the temperature difference the body must cross, the body’s own size, and the time the heat needs to cross it. Define
Substituting, with , and :
Every term carries the same factor , which therefore cancels:
with
Two things are worth noticing.
No parameters survive. The dimensionless problem depends only on . Solve it once per geometry and rescale it for other constant positive material properties, sizes and distinct temperature pairs under these assumptions. Variable properties, sources or a convective boundary introduce additional parameters.
is the Fourier number, : the ratio of the time elapsed to the time heat needs to diffuse across the body. of order one means diffusion has acted over the body scale; a specified closeness to equilibrium still depends on the geometry and the tolerance.
The last condition is the symmetry (or no-flux) condition. It is not an extra physical assumption -- it follows from the body being symmetric about , so no heat can cross the center. It is, however, the boundary condition that will cause all the trouble.
From here on we drop the primes and write , , for the dimensionless quantities.
Dimensionally, , so is dimensionless, whereas and are not. A diffusive penetration length scales as ; the Fourier number is its squared ratio to . This is a scale estimate, not a sharp front location.
The Method of Lines¶
We have one equation in two independent variables. Pyomo.dae is comfortable with either, but
the standard strategy for a parabolic PDE is to break the symmetry between them:
Method of lines. Discretize the spatial derivatives on a fixed mesh . What is left is a system of ODEs (here, a DAE, because the boundary rows are algebraic) in time alone, one equation per mesh point -- the “lines” of the name.
Writing , the semi-discrete system is
with the discrete approximation to at node , plus algebraic rows at (symmetry) and (the bath temperature).
Why this order?
The time direction is an initial-value problem, and Lecture 7’s machinery -- orthogonal collocation on finite elements -- is built exactly for that. We can hand the time direction to
dae.collocationwith right Radau. Its ODE stability properties are useful for diffusion, but temporal accuracy, algebraic consistency, nonlinear convergence and spatial error still need checking.The space direction is a boundary-value problem in disguise. All of its structure -- the boundary conditions, the coordinate singularity, the conservation properties -- is decided by how we build . Doing it deliberately, rather than letting a transformation do it for us, is the whole game.
In Pyomo.dae both directions are ContinuousSets and both get a TransformationFactory, so the
method of lines is a way of thinking about the model rather than a different API. But it tells you
which transformation to worry about.
The reduction does not guarantee a regular index-1 DAE. Algebraic boundary equations or active constraints can raise the index. The smooth-ODE endpoint orders apply only under the relevant regularity and consistency assumptions. Ascher & Petzold, Example 1.7, pp. 12–13, explains why a spatially fixed method of lines is especially useful when time and space play different roles; rapidly moving sharp fronts may require another mesh strategy.
Three Ways to Discretize Space¶
For this linear conduction model, all three families give a sparse spatial operator on discrete unknowns (nodal values or cell values). They differ in what they ask the approximation to reproduce exactly, and that choice is what makes one appropriate and another awkward for a given problem.
Finite difference (FD): match the Taylor series¶
Pick a stencil and choose coefficients so the truncation error is as high order as possible. On a uniform mesh of spacing ,
Cheap and transparent. Every row is three numbers. This is what
dae.finite_differencebuilds.Appropriate when the geometry is simple, the mesh is structured, and the coefficients (, ) are smooth.
Awkward when you need a guarantee. FD approximates the differential equation pointwise; pointwise consistency alone does not establish a discrete conservation law. Conservative FD formulations do exist, and simple FD/FV stencils can coincide. At a coordinate singularity use a regularized equation or an integral balance, rather than evaluating at zero.
Finite volume (FV): match the integral balance¶
Partition the domain into control volumes (here, shells) and integrate the conservation law over each one. Using the self-adjoint form and integrating over the cell ,
Approximating each face gradient by a two-point difference gives a scheme in which the flux leaving one cell is identically the flux entering the next, so the sum over cells telescopes: the semi-discrete balance conserves energy algebraically on any mesh. Numerical time integration and solver residuals determine how closely a computed trajectory satisfies that balance.
Appropriate when conservation matters (reactors, flow, shocks), the mesh is unstructured, or the coefficients jump at an interface -- FV puts the interface on a face, where the flux is single valued.
Note what it does at : the inner face of the first cell has area for . The symmetry condition is structural: no special case, no boundary condition to write. We will use this below.
Finite element (FE): match a weighted residual¶
Expand in basis functions and require the residual to be orthogonal to a set of test functions. Galerkin FE integrates the weak form by parts, which lowers the smoothness required of the trial functions. For self-adjoint diffusion with appropriate boundary conditions and quadrature, the resulting stiffness matrix is symmetric and an energy argument establishes stability. These properties are not universal for every FE model. Orthogonal collocation instead sets the residual to zero at selected points inside each element; it is not the same discretization as Galerkin FE.
Appropriate when the geometry is complicated, the solution is smooth enough to reward high order, or you want error estimates and adaptivity on a rigorous footing.
In
Pyomo.dae,dae.collocationsupplies the collocation construction, not a general Galerkin FE assembler. We use collocation in time below. It can also be applied in space -- and, as we will see, it inherits exactly the same trap as the finite difference scheme.
A shared residual viewpoint¶
For a trial field , let be the differential-equation residual. A weighted-residual method asks for for each test weight. Galerkin uses basis/test functions and typically integrates a diffusion term by parts. FV uses one subdomain indicator at a time and evaluates face fluxes, giving the control-volume balance. Collocation samples the strong residual at points, formally corresponding to point-evaluation (Dirac) weights. FD instead derives local derivative approximations from Taylor expansions. These viewpoints can yield the same stencil in a special case, but they impose different conditions in general. See Patankar, Section 3.2, pp. 27–31.
The Coordinate Singularity at ¶
For the term is undefined at . The solution is perfectly well behaved there -- by symmetry at least as fast as , so L’Hopital’s rule gives -- but the equation as written cannot be evaluated at . Two consequences for the discrete model:
Do not evaluate the unregularized expression at zero. For a smooth symmetric solution, and . Hence the finite center equation is
One valid discretization uses that regularized evolution equation with a symmetry-aware stencil. The builder below instead skips the center PDE and supplies a second-order Neumann row tying the center temperature to nearby temperatures. These are different closures; do not add both indiscriminately. For the skipped-row formulation, the derivative must have a defining equation as well as the prescribed value.
Skip the corner . The initial condition asks for there and the boundary condition asks for . Both cannot hold. We keep the boundary condition (the bath is imposed from onward) and skip the initial condition at . Physically this is the discontinuity that makes the surface flux infinite at ; numerically it is simply one row we must not write twice.

The second point is routine bookkeeping. The first one hides a trap.
The builder uses a central interior stencil and a one-sided second-order center row. Read the defining equation after the transformations as well as the physical boundary condition; both are needed for this choice of closure.
A Pyomo.dae Model¶
Here is the model, written once for all three geometries. Read the three constraint rules first -- they are a direct transcription of the boxed PDE and its three side conditions. Then read the block after the transformations, which supplies the one equation the spatial discretization does not write for us. The next section explains why it is needed and what happens without it.
def build_fd(s, nfe_r, tf=0.5, nfe_t=30, ncp=3):
"""Transient conduction in a slab (s=0), cylinder (s=1) or sphere (s=2).
Space: finite difference, CENTRAL, nfe_r elements on r in [0, 1], plus a
second-order one-sided row at r = 0 for the symmetry condition.
Time: orthogonal collocation on finite elements (Radau), nfe_t elements, ncp points.
Arguments:
s: shape index. 0 = slab, 1 = cylinder, 2 = sphere.
nfe_r: number of spatial finite elements.
tf: final dimensionless time (Fourier number).
nfe_t, ncp: time discretization.
Returns:
A ConcreteModel, already discretized, ready to solve. Zero degrees of freedom.
"""
m = pyo.ConcreteModel()
# Two continuous sets: dimensionless time and dimensionless radius
m.t = dae.ContinuousSet(bounds=(0, tf))
m.r = dae.ContinuousSet(bounds=(0, 1))
# Dimensionless temperature and its derivatives.
# Everything here is dimensionless by construction, so there are no Pyomo
# units to carry -- see [](./units_and_pyomo_dae.md) for the case where there are.
m.T = pyo.Var(m.t, m.r, initialize=0.0)
m.dTdt = dae.DerivativeVar(m.T, wrt=m.t)
m.dTdr = dae.DerivativeVar(m.T, wrt=m.r)
m.d2Tdr2 = dae.DerivativeVar(m.T, wrt=(m.r, m.r))
@m.Constraint(m.t, m.r)
def pde(m, t, r):
# Skip t=0: the initial condition owns that row.
# Skip r=0: the s/r term is singular there. The symmetry condition takes its place.
# Skip r=1: the bath temperature owns that row.
if t == 0 or r == 0 or r == 1:
return pyo.Constraint.Skip
return m.dTdt[t, r] == m.d2Tdr2[t, r] + (s / r) * m.dTdr[t, r]
@m.Constraint(m.r)
def ic(m, r):
# Skip the corner (t, r) = (0, 1): the boundary condition wins there.
if r == 1:
return pyo.Constraint.Skip
return m.T[0, r] == 0
@m.Constraint(m.t)
def bc_surface(m, t):
return m.T[t, 1] == 1
# --- discretize -------------------------------------------------------
pyo.TransformationFactory("dae.finite_difference").apply_to(
m, wrt=m.r, nfe=nfe_r, scheme="CENTRAL"
)
pyo.TransformationFactory("dae.collocation").apply_to(
m, wrt=m.t, nfe=nfe_t, ncp=ncp, scheme="LAGRANGE-RADAU"
)
# --- symmetry at the center, AFTER discretization ---------------------
# The mesh only exists once the transformation has run, and the stencil below
# names specific mesh points.
#
# The symmetry condition is TWO statements, and CENTRAL gives us neither:
# (1) dTdr[t, 0] is zero -- the physics, imposed by fixing
# (2) dTdr[t, 0] is tied to the temperatures -- the equation CENTRAL never wrote
# Omitting (2) leaves dTdr[t, 0] dangling and the center temperature unconstrained.
rs = sorted(m.r)
h = rs[1] - rs[0]
for t in m.t:
m.dTdr[t, 0].fix(0) # (1) symmetry; fixing removes a variable instead of adding a row
@m.Constraint(m.t)
def bc_center_def(m, t):
# (2) second-order three-point one-sided stencil:
# dT/dr|_0 = (-3 T_0 + 4 T_1 - T_2) / (2h) + O(h^2)
if t == 0:
return pyo.Constraint.Skip # the initial condition already pins this row
return m.dTdr[t, 0] * (2 * h) == (
-3 * m.T[t, rs[0]] + 4 * m.T[t, rs[1]] - m.T[t, rs[2]]
)
# A simulation, not an optimization: a constant objective keeps IPOPT happy.
m.obj = pyo.Objective(expr=0.0)
return m
def solve_model(m, tee=False):
"""Require a successful NLP solve before extracting a production trajectory.
Successful termination is necessary; the independent discretization checks
below are still required. Historical failure tables are not run here.
"""
results = pyo.SolverFactory("ipopt").solve(m, tee=tee)
assert pyo.check_optimal_termination(results), str(results.solver)
return results.solver.termination_conditionThe Trap: A Derivative That Appears in No Equation¶
Everything in build_fd up to the transformations is a transcription of the mathematics. The block
after them is not, and it is the part that is easy to leave out. Here is the natural way to write the
symmetry condition:
@m.Constraint(m.t)
def bc_center(m, t):
return m.dTdr[t, 0] == 0 # DO NOT DO THIS AND NOTHING ELSEIt reads exactly like . It is also wrong, for a reason that has nothing
to do with the mathematics and everything to do with what scheme="CENTRAL" actually generates.
A central difference needs a neighbour on both sides. At an interior node it has one:
At there is no , so Pyomo.dae writes no equation at all for .
It is not set to a one-sided difference; it is not set to anything. It is a variable that appears in
no discretization equation -- a dangling variable. The constraint above would then not impose
symmetry on the temperature field; it would assign a value to a number that touches nothing else in
the model, and the center temperature would be determined by nothing at all.
We can see the hole directly, without building a model at all. Discretize a bare DerivativeVar and
ask which mesh points the generated defining equations cover.
def stencil_coverage(scheme, nfe=4):
"""Which mesh points does dae.finite_difference give a defining equation to?
A probe of the transformation, not a model of anything: one variable, its first
and second derivatives, no PDE and no boundary conditions.
"""
p = pyo.ConcreteModel()
p.r = dae.ContinuousSet(bounds=(0, 1))
p.T = pyo.Var(p.r)
p.dTdr = dae.DerivativeVar(p.T, wrt=p.r)
p.d2Tdr2 = dae.DerivativeVar(p.T, wrt=(p.r, p.r))
pyo.TransformationFactory("dae.finite_difference").apply_to(p, wrt=p.r, nfe=nfe, scheme=scheme)
mesh = set(p.r)
return (sorted(mesh - set(p.dTdr_disc_eq.keys())),
sorted(mesh - set(p.d2Tdr2_disc_eq.keys())))
print("mesh points on r in [0, 1] with nfe=4:", [f"{v:g}" for v in np.linspace(0, 1, 5)], "\n")
print(f"{'scheme':10s} {'r with NO dT/dr equation':>28s} {'r with NO d2T/dr2 equation':>30s}")
for scheme in ["CENTRAL", "FORWARD", "BACKWARD"]:
miss1, miss2 = stencil_coverage(scheme)
print(f"{scheme:10s} {str(miss1):>28s} {str(miss2):>30s}")mesh points on r in [0, 1] with nfe=4: ['0', '0.25', '0.5', '0.75', '1']
scheme r with NO dT/dr equation r with NO d2T/dr2 equation
CENTRAL [0, 1] [0, 1]
FORWARD [1] [0.75, 1]
BACKWARD [0] [0, 0.25]
Read the CENTRAL row: neither endpoint gets a defining equation for the first derivative, and neither
gets one for the second. At that is harmless -- the PDE row is skipped there and the bath
temperature is imposed directly. At it is fatal, because the PDE row is skipped there too.
The other two rows are the reason “just use a one-sided scheme everywhere” is not a fix: FORWARD and
BACKWARD each close one hole and open another somewhere else. They move the problem rather than solve
it. See the first table below.
So build_fd writes both halves of the symmetry condition itself: it fixes to
zero, which is the physics, and it adds bc_center_def, which is the equation the scheme never wrote.
Together with the three-point stencil collapses to , the
second-order statement that the profile is flat at the center.
The Reference Solution¶
Each of the three geometries has a classical separation-of-variables solution, so we can measure error rather than eyeball it. For the dimensionless problem above:
def analytic(s, t, r, nterms=400):
"""Series solution of the dimensionless problem. Returns an array over r."""
r = np.atleast_1d(np.asarray(r, dtype=float))
out = np.zeros_like(r)
if s == 0:
n = np.arange(1, nterms)
lam = (2 * n - 1) * np.pi / 2
for k, rr in enumerate(r):
out[k] = 1 - np.sum(2 * (-1) ** (n + 1) / lam * np.cos(lam * rr) * np.exp(-(lam**2) * t))
elif s == 1:
b = jn_zeros(0, nterms)
for k, rr in enumerate(r):
out[k] = 1 - np.sum(2 * j0(b * rr) * np.exp(-(b**2) * t) / (b * j1(b)))
elif s == 2:
n = np.arange(1, nterms)
for k, rr in enumerate(r):
if rr < 1e-12: # the r -> 0 limit of sin(n pi r)/r
out[k] = 1 + np.sum(2 * (-1) ** n * np.exp(-((n * np.pi) ** 2) * t))
else:
out[k] = 1 + (2 / (np.pi * rr)) * np.sum(
((-1) ** n / n) * np.sin(n * np.pi * rr) * np.exp(-((n * np.pi) ** 2) * t)
)
else:
raise ValueError("s must be 0, 1 or 2")
return out
GEOMETRY = {0: "Slab", 1: "Cylinder", 2: "Sphere"}Checking the Model Before You Trust It¶
This is a simulation: the temperature field is completely determined by the data, so a correctly posed model must have zero degrees of freedom. That is the cheapest check available, and it happens before the solver is ever called.
print(f"{'geometry':10s} {'variables':>10s} {'constraints':>12s} {'DOF':>6s}")
for s in [0, 1, 2]:
report = build_model_size_report(build_fd(s=s, nfe_r=40)).activated
dof = report["variables"] - report["constraints"]
print(f"{GEOMETRY[s]:10s} {report['variables']:10d} {report['constraints']:12d} {dof:6d}")geometry variables constraints DOF
Slab 14519 14519 0
Cylinder 14519 14519 0
Sphere 14519 14519 0
The IDAES Diagnostics Toolbox¶
Counting degrees of freedom tells you how many variables are unaccounted for. It does not tell you which. On a model with tens of thousands of rows that difference is the whole game, so run the structural analysis rather than the arithmetic.
The IDAES Diagnostics Toolbox
does exactly this. report_structural_issues() needs no solution and no initialization -- it works on
the incidence pattern of the model, so you can run it the moment the model is built. Make it the first
thing you do to any Pyomo.dae model, before you ever look at a number.
from idaes.core.util.diagnostics_tools.diagnostics_toolbox import DiagnosticsToolbox
# A small model so the report is readable; the structural conclusions do not depend on mesh size.
DiagnosticsToolbox(build_fd(s=0, nfe_r=8, nfe_t=4)).report_structural_issues()====================================================================================
Model Statistics
Activated Blocks: 1 (Deactivated: 0)
Free Variables in Activated Constraints: 407 (External: 0)
Free Variables with only lower bounds: 0
Free Variables with only upper bounds: 0
Free Variables with upper and lower bounds: 0
Fixed Variables in Activated Constraints: 12 (External: 0)
Activated Equality Constraints: 407 (Deactivated: 0)
Activated Inequality Constraints: 0 (Deactivated: 0)
Activated Objectives: 1 (Deactivated: 0)
------------------------------------------------------------------------------------
0 WARNINGS
No warnings found!
------------------------------------------------------------------------------------
2 Cautions
Caution: 13 variables fixed to 0
Caution: 49 unused variables (1 fixed)
------------------------------------------------------------------------------------
Suggested next steps:
Try to initialize/solve your model and then call report_numerical_issues()
====================================================================================
Zero warnings. No structural singularity, no under-constrained set, no over-constrained set,
degrees of freedom consistent with the constraint count. That is what a well-posed Pyomo.dae model
looks like.
The two cautions are worth understanding, because they are not defects and you will see them on every model of this shape.
13 variables fixed to 0-- these are at the 13 time points, fixed bybuild_fdas statement (1) of the symmetry condition. The toolbox flags variables fixed to zero because a strayfix(0)is a common way to accidentally delete physics. Here it is the physics.49 unused variables-- variables that appear in no activated constraint. All 49 are inert: 9 are at , which Radau collocation never references; 26 are on the two boundary rows and , where the PDE is skipped; and 14 are at the surface and at , likewise unreferenced.Pyomo.daecreates a full grid of derivative variables and only some of them are used. They influence no result.
A caution is an invitation to look, not a finding. Knowing which cautions your model should produce is what lets you notice the day it produces a different one.
Alternative Discretizations at , and What They Do¶
The table below documents the discretizations we did not ship, so that the failure modes are on record without a broken model being a copy-paste away.
All numbers were measured on the slab () unless stated, at , with
nfe_t=30, ncp=3 Radau collocation in time, and IPOPT 3.13.2 with ma27. “Center error” is
with ; the five values run over
and “order” is of the ratio of the last two.
| Alternative discretization at | Modeling issue | Failure evidence (measured) |
|---|---|---|
scheme="CENTRAL" with dTdr[t,0] == 0 as the only center row (no defining equation) | Dangling variable. CENTRAL emits no defining equation for a derivative at an endpoint, so dTdr[t,0] appears in no discretization row. The constraint assigns a value to a variable that touches nothing; the center temperature is constrained by nothing. | DOF = 90 at nfe_r=40 -- one spare degree of freedom per time point at which the PDE is enforced. report_structural_issues() reports “Structural singularity found” with an under-constrained set of 360 variables and 348 constraints (at nfe_r=8, nfe_t=4). Center error plateaus: 0.739, 0.731, 0.726, 0.724, 0.722, observed order 0.00 -- refinement does nothing, because there is no equation to converge. The answer is arbitrary: initial guesses of 0.0, 0.5 and -2.0 give , -0.0703 and -0.2667 against an exact 0.6292. All three solves returned optimal, and all three are dimensionless temperature undershoots below the initial value (not negative Kelvin temperatures). 🔴 Silent on the cylinder and the sphere -- see the note below. |
scheme="FORWARD" for the whole interior, as a way to get a definition at | Moves the hole. FORWARD does define both derivatives at , but its second-derivative stencil runs off the right end of the domain instead: the last interior node loses its defining equation, and that node carries a real PDE row. | The coverage probe above: CENTRAL leaves undefined at and ; FORWARD at the last interior node and ; BACKWARD at and the first interior node. No single one-sided scheme covers every node that needs one. |
First-order one-sided center row, dTdr[t,0]*h == T[t,r1] - T[t,r0] (with dTdr[t,0] fixed, i.e. ) | Correct but low order. No dangling variable, DOF = 0, converges. But the center row is while the interior is , and the boundary row caps the accuracy of the whole scheme. | Center error 0.047, 0.023, 0.0115, 0.0057, 0.0029 -- observed order 1.00. The shipped three-point row on the same meshes: 6.6e-5, 3.1e-5, 9.4e-6, 2.6e-6, 6.7e-7 -- observed order 1.94, four orders of magnitude better on the finest mesh. |
✅ Shipped: scheme="CENTRAL" interior + fixed dTdr[t,0] + second-order three-point center row | The scheme’s missing equation is supplied explicitly, at the same order as the interior. | DOF = 0, 0 warnings from report_structural_issues(), observed order 1.94, and initialization-independent (next cell). Solves at scale: nfe_r=160, nfe_t=30 is 58,604 variables, DOF 0, optimal in 4.0 s, . |
The Answer Does Not Depend on the Initial Guess¶
A model with unconstrained degrees of freedom and no objective has no unique answer, and the quickest way to see that is to change the initial guess and re-solve. Run the same check on the correct model: if the answer moves, something is not pinned.
print(f"{'geometry':10s} {'nfe_r':>6s}" + "".join(f"{'guess ' + str(g):>14s}" for g in [0.0, 0.5, -2.0])
+ f"{'exact':>12s}")
for s in [0, 1, 2]:
exact = analytic(s, 0.5, 0.0)[0]
for n in [10, 20, 40]:
row = []
for guess in [0.0, 0.5, -2.0]:
m = build_fd(s=s, nfe_r=n)
for v in m.T.values():
v.set_value(guess)
solve_model(m)
row.append(pyo.value(m.T[0.5, 0]))
print(f"{GEOMETRY[s]:10s} {n:6d}" + "".join(f"{v:14.6f}" for v in row) + f"{exact:12.6f}")geometry nfe_r guess 0.0 guess 0.5 guess -2.0 exact
Slab 10 0.629157 0.629157 0.629157 0.629223
Slab 20 0.629192 0.629192 0.629192 0.629223
Slab 40 0.629213 0.629213 0.629213 0.629223
Cylinder 10 0.910245 0.910245 0.910245 0.911110
Cylinder 20 0.910885 0.910885 0.910885 0.911110
Cylinder 40 0.911053 0.911053 0.911053 0.911110
Sphere 10 0.985151 0.985151 0.985151 0.985616
Sphere 20 0.985500 0.985500 0.985500 0.985616
Sphere 40 0.985587 0.985587 0.985587 0.985616
Identical to every digit printed across a range of guesses that includes a physically impossible one, in every
geometry and on every mesh. The remaining discrepancy against exact is truncation error, and unlike
a structural defect it goes away under refinement -- which is what the next section measures.
Mesh refinement checks a different error¶
Consistency alone does not prove convergence: stability, well-posed boundary equations, a converged temporal discretization and sufficiently small nonlinear-solver error are also needed. Refine space while holding the temporal error small, then refine time on a fixed spatial mesh. Compare against an independent reference, not only the previous mesh.
A flat error curve can indicate a structural defect, but it can also be a temporal- or solver-error floor. Inspect the equations and vary those tolerances before deciding which error dominates.
NFE_LIST = [10, 20, 40, 80, 160]
TF = 0.5
conv = {}
convergence_rows = []
for s in [0, 1, 2]:
exact = analytic(s, TF, 0.0)[0]
errs = []
for n in NFE_LIST:
m = build_fd(s=s, nfe_r=n)
solve_model(m)
errs.append(abs(pyo.value(m.T[TF, 0]) - exact))
conv[s] = np.array(errs)
convergence_rows.append({
"shape_index": s,
"geometry": GEOMETRY[s],
"exact_center_temperature": exact,
"errors": errs,
"observed_order": float(np.log2(errs[-2] / errs[-1])),
})
initial_guess_rows = []
for s in [0, 1, 2]:
exact = analytic(s, TF, 0.0)[0]
for n in [10, 20, 40]:
values = []
for guess in [0.0, 0.5, -2.0]:
m = build_fd(s=s, nfe_r=n)
for v in m.T.values():
v.set_value(guess)
solve_model(m)
values.append(pyo.value(m.T[TF, 0]))
initial_guess_rows.append({
"shape_index": s,
"geometry": GEOMETRY[s],
"nfe_r": n,
"initial_guesses": [0.0, 0.5, -2.0],
"center_temperatures": values,
"exact_center_temperature": exact,
})
convergence_results = {
"nfe_r": NFE_LIST,
"final_fourier_number": TF,
"geometries": convergence_rows,
"initial_guess_experiment": initial_guess_rows,
"diagnostics": {
"model": "build_fd(s=0, nfe_r=8, nfe_t=4)",
"warnings": 0,
"expected_cautions": [
"13 variables fixed to 0",
"49 unused variables (1 fixed)",
],
},
}
helper.save_results(
"heat-convergence",
convergence_results,
notebook="notebooks/3-dev/heat_conduction_pde.ipynb",
description=(
"Center-temperature spatial-refinement errors for the shipped finite-"
"difference heat-conduction model in slab, cylinder and sphere geometries; "
"also records the initial-guess experiment and the expected structural "
"diagnostics for the model."
),
solver="Ipopt 3.13.2 (IDAES build, linear solver ma27); Pyomo 6.10.1",
source_tag="numeric:heat-fd-model",
source_cells=[("notebooks/3-dev/heat_conduction_pde.ipynb", tag) for tag in
("numeric:heat-series", "numeric:heat-convergence-data")],
)
results = convergence_results
print(f"|T(t_f, 0) - exact| at t_f = {TF}")
print(f" {'':9s}" + "".join(f"{'h=1/'+str(n):>12s}" for n in NFE_LIST) + f"{'order':>9s}")
for row in convergence_rows:
print(f" {row['geometry']:9s}" + "".join(f"{v:12.3e}" for v in row["errors"])
+ f"{row['observed_order']:9.2f}")[helper] wrote figures/results/heat-convergence.json
|T(t_f, 0) - exact| at t_f = 0.5
h=1/10 h=1/20 h=1/40 h=1/80 h=1/160 order
Slab 6.573e-05 3.075e-05 9.391e-06 2.557e-06 6.653e-07 1.94
Cylinder 8.651e-04 2.256e-04 5.707e-05 1.432e-05 3.582e-06 2.00
Sphere 4.656e-04 1.164e-04 2.910e-05 7.275e-06 1.819e-06 2.00
Read the order column: it is of the ratio of the last two errors, so 2.0 means second order
in , 1.0 means first order, and would mean the error is not going anywhere. All three
geometries land at 1.9--2.0, which is what the interior stencil and the center row were each built to
deliver -- and it is only true because they deliver it at the same order. The first-order center row
in the table above produces the same picture with slope 1, and the dangling model produces a flat line.
h = 1.0 / np.asarray(results["nfe_r"], dtype=float)
fig, ax = plt.subplots(figsize=(6.4, 4.8))
MARKERS = {0: "o", 1: "s", 2: "^"}
for row in results["geometries"]:
s = row["shape_index"]
errors = np.asarray(row["errors"], dtype=float)
(line,) = ax.loglog(h, errors, marker=MARKERS[s], markersize=7, lw=2.0)
# direct labelling: a legend keyed only by colour dies in black and white
ax.annotate(row["geometry"], xy=(h[-1], errors[-1]), xytext=(8, -4),
textcoords="offset points", fontsize=11,
color=line.get_color(), fontweight="bold")
# reference slope, drawn in grey so it never competes with the data
slab_errors = np.asarray(results["geometries"][0]["errors"], dtype=float)
ax.loglog(h, slab_errors[0] * (h / h[0]) ** 2, color="0.6", lw=1.5,
ls=(0, (2, 2)), zorder=0)
ax.annotate("slope 2", xy=(h[2], slab_errors[0] * (h[2] / h[0]) ** 2),
xytext=(-52, -6), textcoords="offset points", fontsize=11, color="0.4")
ax.set_xlabel("mesh spacing $h$")
ax.set_ylabel("center error")
ax.set_xlim(h[-1] / 2.2, h[0] * 1.5)
ax.set_title("Center-temperature error under spatial mesh refinement")
# Write media/figures/heat-convergence.{png,pdf} -- what the lecture handout
# \includegraphics. A no-op on Colab, where there is no repo to write to.
helper.save_figure(fig, "heat-convergence")
plt.show()
[helper] wrote media/figures/heat-convergence.png and .pdf

The curves approach second-order behavior on the finer meshes; the reported order uses only the last pair. Coarser pairs, particularly for the slab, can be pre-asymptotic. Read the actual pairwise ratios rather than interpreting every segment as a line of slope 2. An observed convergence rate is evidence for this experiment, not a general proof of the scheme.
Reproducibility and archived figure data¶
The canonical convergence and profile figures are generated from committed numerical data using the plotting cells below. The handout can reuse either figure without introducing another implementation of the PDE. figures/results/heat-convergence.json stores the raw mesh-
refinement errors for all three geometries, the initial-guess experiment, and the expected
structural-diagnostics summary. figures/results/heat-slab-profile.json stores every profile and
analytical-series comparison for the slab, cylinder, and sphere; the dedicated profile figure uses the slab subset.
A maintainer re-renders either handout figure without Pyomo or IPOPT with:
python figures/render_from_notebook.py heat-convergence heat-slab-profileThe archived runs used Pyomo 6.10.1, IPOPT 3.13.2 (IDAES build with ma27), nfe_t=30, and
three Radau collocation points for the refinement experiment; the profile data use nfe_r=80,
nfe_t=50, and three Radau points. Exact iteration counts and timings are intentionally not used
as scientific evidence: they depend on the operating system, solver build, linear solver, and CPU.
The verification targets are: the degree-of-freedom count, expected diagnostics cautions, initial-
guess invariance, and second-order convergence are the checks to reproduce.
The Correct Model, Geometry by Geometry¶
The builder is the same in all three cases. What changes is why each piece is correct.
Slab, ¶
The PDE is without a coordinate singularity, but the half-slab still requires symmetry at its midplane. A center evolution row is possible only with a stencil that encodes that symmetry, for example by an even ghost value. Merely dropping the Neumann information and writing the PDE does not close the problem. The shipped model instead uses one center Neumann row with its defining stencil, and skips the center PDE, for all three geometries.
Cylinder, ¶
The unregularized cannot be evaluated at zero. Skip it as this builder does, or replace it by the regularized center equation derived above. The symmetry condition is doing real work -- it is the only equation that determines the axis temperature, and without a defining equation for the axis is unconstrained. Every interior row has , so the coefficient is finite everywhere it is used; it is large () at the first interior node, which is why the discrete operator is stiff near the axis and why an symmetry stencil is worth having.
Sphere, ¶
Identical structure, with . The weighting is stronger, so the near-center rows dominate the Jacobian even more -- which is also why the dangling model of the first table is least visibly wrong here. Nothing about the correct model changes.
In all three cases the count works out the same way, per time point: unknowns (the surface value is imposed), PDE rows at the interior nodes, and one center row. equations, unknowns, zero degrees of freedom -- which is what the DOF table above confirms.
# Production models: second-order symmetry stencil, fine mesh.
TIMES = [0.01, 0.05, 0.15, 0.5]
profile_rows = []
for s in [0, 1, 2]:
m = build_fd(s=s, nfe_r=80, tf=max(TIMES), nfe_t=50, ncp=3)
status = solve_model(m)
rgrid = np.array(sorted(m.r))
numeric_profiles = {}
analytic_profiles = {}
for tt in TIMES:
# the collocation mesh contains every element boundary, and TIMES were
# chosen to land on one; assert rather than silently interpolate
assert tt in m.t, f"t = {tt} is not a mesh point"
numeric_profiles[str(tt)] = [pyo.value(m.T[tt, r]) for r in rgrid]
analytic_profiles[str(tt)] = analytic(s, tt, rgrid).tolist()
err = max(
np.max(np.abs(np.asarray(numeric_profiles[str(tt)]) - np.asarray(analytic_profiles[str(tt)])))
for tt in TIMES if tt >= 0.05
)
profile_rows.append({
"shape_index": s,
"geometry": GEOMETRY[s],
"status": str(status),
"r": rgrid.tolist(),
"numeric_profiles": numeric_profiles,
"analytic_profiles": analytic_profiles,
"max_abs_error_for_fo_ge_0_05": float(err),
})
print(f"{GEOMETRY[s]:9s} status = {status}, max |numeric - series| over r for Fo >= 0.05: {err:.2e}")
profile_results = {
"times": TIMES,
"nfe_r": 80,
"nfe_t": 50,
"ncp": 3,
"geometries": profile_rows,
}
helper.save_results(
"heat-slab-profile",
profile_results,
notebook="notebooks/3-dev/heat_conduction_pde.ipynb",
description=(
"Raw temperature profiles for the slab, cylinder and sphere, calculated "
"with the shipped finite-difference spatial discretization and Radau "
"collocation in time. The handout shows the slab subset; this archive retains "
"all three geometries and the analytical-series comparison."
),
solver="Ipopt 3.13.2 (IDAES build, linear solver ma27); Pyomo 6.10.1",
source_tag="numeric:heat-fd-model",
source_cells=[("notebooks/3-dev/heat_conduction_pde.ipynb", tag) for tag in
("numeric:heat-series", "numeric:heat-profile-data")],
)
results = profile_resultsSlab status = optimal, max |numeric - series| over r for Fo >= 0.05: 4.26e-05
Cylinder status = optimal, max |numeric - series| over r for Fo >= 0.05: 8.26e-05
Sphere status = optimal, max |numeric - series| over r for Fo >= 0.05: 1.34e-04
[helper] wrote figures/results/heat-slab-profile.json
fig, axes = plt.subplots(1, 3, figsize=(13, 4.6), sharey=True)
for ax, row in zip(axes, results["geometries"]):
rgrid = np.asarray(row["r"], dtype=float)
for tt in results["times"]:
key = str(tt)
(line,) = ax.plot(rgrid, row["numeric_profiles"][key], lw=2.5)
# direct labelling: a legend keyed only by colour dies in black and white
j = int(0.42 * len(rgrid))
ax.annotate(f"Fo = {tt:g}", xy=(rgrid[j], row["numeric_profiles"][key][j]),
xytext=(0, 7), textcoords="offset points",
fontsize=11, color=line.get_color(), fontweight="bold")
# series solution, as sparse open markers, for the two well-resolved times
for tt in results["times"][1:]:
key = str(tt)
ax.plot(rgrid[::8], np.asarray(row["analytic_profiles"][key])[::8], ls="none", marker="o",
mfc="none", mec="0.35", ms=5, mew=1.0)
ax.set_xlabel("$r$")
ax.set_title(f"{row['geometry']} (s = {row['shape_index']})")
ax.set_xlim(0, 1)
ax.set_ylim(-0.03, 1.05)
axes[0].set_ylabel("$T$")
fig.suptitle("Temperature profiles: open circles are the analytical series solution", y=1.02)
plt.show()

The dedicated slab profile below shares the archive used for the three-panel comparison. The slab is especially useful for exposing the documented missing-center-row defect; cylinder and sphere results remain available above. Both the full profile and convergence experiments belong in this reference guide even when lecture space permits only one figure.
fig, ax = plt.subplots(figsize=(6.2, 4.6))
slab = results["geometries"][0]
rgrid = np.asarray(slab["r"], dtype=float)
for tt in results["times"]:
key = str(tt)
(line,) = ax.plot(rgrid, slab["numeric_profiles"][key], lw=2.5)
j = int(0.42 * len(rgrid))
ax.annotate(f"Fo = {tt:g}", xy=(rgrid[j], slab["numeric_profiles"][key][j]),
xytext=(0, 7), textcoords="offset points",
fontsize=11, color=line.get_color(), fontweight="bold")
for tt in results["times"][1:]:
key = str(tt)
ax.plot(rgrid[::8], np.asarray(slab["analytic_profiles"][key])[::8], ls="none", marker="o",
mfc="none", mec="0.35", ms=5, mew=1.0)
ax.set_xlabel("$r$")
ax.set_ylabel("$T$")
ax.set_title("Slab (s = 0): open circles are the analytical series solution")
ax.set_xlim(0, 1)
ax.set_ylim(-0.03, 1.05)
# Write media/figures/heat-slab-profile.{png,pdf} for the lecture handout.
helper.save_figure(fig, "heat-slab-profile")
plt.show()
[helper] wrote media/figures/heat-slab-profile.png and .pdf

At a fixed Fourier number the sphere is furthest along and the slab is furthest behind. That is the surface-to-volume ratio: per unit volume, a sphere presents of surface, a cylinder , a slab . The shape index is doing exactly that bookkeeping, and it is the reason alone is not enough to say “how hot is it inside” without also saying which shape you meant.
fig, ax = plt.subplots(figsize=(6.2, 4.6))
for s in [0, 1, 2]:
m = build_fd(s=s, nfe_r=80, tf=0.5, nfe_t=50, ncp=3)
solve_model(m)
tgrid = np.array(sorted(m.t))
Tc = np.array([pyo.value(m.T[t, 0]) for t in tgrid])
(line,) = ax.plot(tgrid, Tc, lw=2.5)
frac = {0: 0.62, 1: 0.34, 2: 0.26}[s] # keep the three labels off each other
k = int(frac * len(tgrid))
ax.annotate(GEOMETRY[s], xy=(tgrid[k], Tc[k]), xytext=(6, -18),
textcoords="offset points", fontsize=12,
color=line.get_color(), fontweight="bold")
ax.set_xlabel("Fourier number $\\mathrm{Fo} = \\alpha t / R^2$")
ax.set_ylabel("center temperature $T(t, 0)$")
ax.set_xlim(0, 0.5)
ax.set_ylim(-0.03, 1.03)
ax.set_title("Heating from a step change at the surface")
plt.show()
The Finite Volume Alternative¶
Everything above was finite difference. It is worth building the finite volume version of the same problem, because it makes a structural point that no amount of care with stencils can:
In the finite volume formulation the symmetry condition at is not a boundary condition at all. It is a consequence of the geometry, and there is nothing to forget.
Place shells with faces at , , and unknowns at the cell centers. The balance on shell is the discrete form of the integral law from earlier,
For the innermost shell, and therefore for : the inner face has no area, so no heat can cross it. For the slab, makes , and we set the inner flux to zero by symmetry -- one line, and the only geometry-specific line in the model.
At the outer face of the last shell the temperature is known, and the half-cell distance from the center of that shell to the surface is , so the flux is .
Note also that there is no DerivativeVar in space at all. The method of lines is explicit here: the
only ContinuousSet is time.
def build_fv(s, ncells, tf=0.5, nfe_t=30, ncp=3):
"""Finite volume discretization of the same problem.
Shells of equal thickness h = 1/ncells, unknowns at cell centers, fluxes at faces.
Interior fluxes cancel in the volume balance. The inner boundary uses
zero area for s>0 and an explicit zero-flux condition for the slab.
"""
h = 1.0 / ncells
faces = np.linspace(0.0, 1.0, ncells + 1)
centers = 0.5 * (faces[:-1] + faces[1:])
area = faces**s # A = r^s at each face
vol = (faces[1:] ** (s + 1) - faces[:-1] ** (s + 1)) / (s + 1)
m = pyo.ConcreteModel()
m.t = dae.ContinuousSet(bounds=(0, tf))
m.c = pyo.RangeSet(0, ncells - 1) # cell index; NOT a ContinuousSet
m.T = pyo.Var(m.t, m.c, initialize=0.0)
m.dTdt = dae.DerivativeVar(m.T, wrt=m.t)
@m.Constraint(m.t, m.c)
def shell(m, t, i):
if t == 0:
return pyo.Constraint.Skip
if i == 0:
flux_in = 0.0 # zero face area (s>0) or symmetry (s=0)
else:
flux_in = area[i] * (m.T[t, i] - m.T[t, i - 1]) / h
if i == ncells - 1:
flux_out = area[i + 1] * (1.0 - m.T[t, i]) / (h / 2) # bath at r = 1
else:
flux_out = area[i + 1] * (m.T[t, i + 1] - m.T[t, i]) / h
return vol[i] * m.dTdt[t, i] == flux_out - flux_in
@m.Constraint(m.c)
def ic(m, i):
return m.T[0, i] == 0
pyo.TransformationFactory("dae.collocation").apply_to(
m, wrt=m.t, nfe=nfe_t, ncp=ncp, scheme="LAGRANGE-RADAU"
)
m.obj = pyo.Objective(expr=0.0)
m.centers = centers
return mNC_LIST = [10, 20, 40, 80]
print(f"{'geometry':10s} {'DOF':>5s} " + "".join(f"{'N='+str(n):>12s}" for n in NC_LIST) + f"{'order':>9s}")
fv_conv = {}
for s in [0, 1, 2]:
errs = []
for n in NC_LIST:
m = build_fv(s=s, ncells=n)
solve_model(m)
# compare at the first cell center, r = h/2
errs.append(abs(pyo.value(m.T[0.5, 0]) - analytic(s, 0.5, m.centers[0])[0]))
fv_conv[s] = np.array(errs)
rep = build_model_size_report(build_fv(s=s, ncells=40)).activated
order = np.log2(errs[-2] / errs[-1])
print(f"{GEOMETRY[s]:10s} {rep['variables'] - rep['constraints']:5d} "
+ "".join(f"{v:12.3e}" for v in errs) + f"{order:9.2f}")geometry DOF N=10 N=20 N=40 N=80 order
Slab 0 1.318e-03 3.299e-04 8.251e-05 2.063e-05 2.00
Cylinder 0 9.179e-04 2.294e-04 5.734e-05 1.433e-05 2.00
Sphere 0 3.672e-04 9.172e-05 2.293e-05 5.734e-06 2.00
Zero degrees of freedom in every geometry, second-order convergence, and no symmetry constraint anywhere in the model. The Diagnostics Toolbox says the same thing structurally.
DiagnosticsToolbox(build_fv(s=1, ncells=8, nfe_t=4)).report_structural_issues()====================================================================================
Model Statistics
Activated Blocks: 1 (Deactivated: 0)
Free Variables in Activated Constraints: 200 (External: 0)
Free Variables with only lower bounds: 0
Free Variables with only upper bounds: 0
Free Variables with upper and lower bounds: 0
Fixed Variables in Activated Constraints: 0 (External: 0)
Activated Equality Constraints: 200 (Deactivated: 0)
Activated Inequality Constraints: 0 (Deactivated: 0)
Activated Objectives: 1 (Deactivated: 0)
------------------------------------------------------------------------------------
0 WARNINGS
No warnings found!
------------------------------------------------------------------------------------
1 Cautions
Caution: 8 unused variables (0 fixed)
------------------------------------------------------------------------------------
Suggested next steps:
Try to initialize/solve your model and then call report_numerical_issues()
====================================================================================
The FV diagnostics again report zero structural warnings. For the cylinder and sphere, bounded center gradients multiply a zero-area inner face, making its net heat flux zero. For the slab, the face has nonzero area and flux_in = 0 explicitly imposes symmetry. Finite volume does not prevent an incorrect boundary flux or an incorrect geometry model; it makes the balance and the boundary-flux choice visible.
Cell-centered FV has no center temperature variable requiring a separate Neumann derivative row. Its unknowns are cell values rather than boundary values; surface reporting and boundary fluxes therefore need the half-cell distance. The following table retains historical measured failures of alternative formulas. Those variants are not runnable builders in this guide.
Alternative Finite Volume Discretizations, and What They Do¶
Like the first failure table, these are historical measurements under the stated configuration, not fresh executions in this revision; none ships as
a runnable option above. Conditions are , nfe_t=30, ncp=3 Radau, IPOPT
3.13.2 with ma27, and cells. The error is taken at the first cell center,
, against the series solution evaluated there; “order” is of the ratio of the last two.
| Alternative discretization | Modeling issue | Failure evidence (measured) |
|---|---|---|
| Evaluate the face areas at the cell center, , instead of at the faces | Not conservative. The flux leaving cell is no longer identically the flux entering cell , so the sum over cells stops telescoping -- the one property finite volume exists to provide. For it is not even consistent. | Slab unaffected ( either way, so the bug is invisible at ). Cylinder error plateaus at 0.2824, 0.2820, 0.2819, 0.2819 -- observed order 0.00. Sphere 0.3890, 0.3738, 0.3654, 0.3610 -- observed order 0.02. Every solve returned optimal. 🔴 The mirror image of the finite difference trap: loud on the cylinder and sphere, silent on the slab. |
| Lumped cell volume instead of the exact shell volume | First-order mass matrix. The volume multiplies ; approximating it to degrades the whole scheme even though it stays conservative, square and stable. Tempting because it is one fewer line. | Slab unaffected (the volume is exact there). Cylinder 5.22e-2, 2.53e-2, 1.24e-2, 6.14e-3 -- observed order 1.02. Sphere 2.99e-2, 1.20e-2, 5.33e-3, 2.50e-3 -- observed order 1.09. Against order 2.00 for the shipped model. |
| Outer boundary flux over the full cell width instead of the half cell | Wrong distance at the Dirichlet face. The last unknown sits at a cell center, a distance from the surface. Using halves the surface flux and makes a first-order boundary error the accuracy bottleneck -- the finite volume version of an under-resolved boundary row. | Order falls to 1.00 in all three geometries: slab 4.51e-2 5.71e-3, cylinder 2.83e-2 3.26e-3, sphere 9.04e-3 9.15e-4 over . |
| Node-centered layout with an explicit symmetry boundary condition at (i.e. put an unknown on the axis) | Reintroduces the Neumann condition that cell-centered finite volume removes -- and with it the dangling-variable failure mode in full. | See the finite difference table above; this is that table. |
| ✅ Shipped: cell-centered, face areas at the faces, exact shell volumes, half-cell surface flux | Conservative by construction; the radial zero-area flux is structural; the slab uses an explicit zero-flux boundary. | DOF = 0 in all three geometries, 0 warnings from report_structural_issues(), observed order 2.00 everywhere, and no symmetry constraint anywhere in the model. |
What each boundary condition supplies¶
Dirichlet prescribes the value, such as . At that boundary, the value is already supplied; the interior PDE stencil need not create a second evolution equation for it. For a boundary temperature control , write and include the control’s bounds, cost and parameterization.
Neumann prescribes a normal derivative or flux, for example . It does not directly set the temperature. If the PDE row is omitted, a defining stencil must tie that derivative to the temperature unknowns. The shipped center row is
so . This is why fixing a dangling DerivativeVar to zero alone fails. Skip the same row at the initial time if the initial conditions already imply it; adding dependent equations can destroy a square solve.
Robin couples temperature and outward heat flux. Write the physical convective boundary as
After the stated scaling,
Here is a heat-transfer coefficient, distinct from a time-element length. With a cold solid and hot bath, , so the outward heat flux is negative: heat enters the solid. A central stencil also leaves the derivative at the outer boundary undefined. Supply a one-sided defining row or a boundary-face flux derived from the balance; the Robin equation alone does not create that row. The large-Biot limit approaches the fixed-surface-temperature model under the usual regularity assumptions.
Row rule. Omit a PDE or derivative row only when another equation supplies the missing information. Count unknowns and independent equations after transformation, inspect the derivative coverage, then check the residual and physical behavior. A zero count alone cannot detect dependent equations. The IDAES diagnostics workflow separates structural checks from checks requiring a numerical solution.
Initial-boundary corner. The interior initial data are for , while the boundary jumps to 1 for positive time. They are incompatible at . The code retains the boundary value there and skips the conflicting initial row. It does not assert a smooth solution at that corner; early boundary layers need a finer or graded spatial mesh and temporal resolution.
Boundary controls, spatial stiffness and active path constraints¶
For Biegler Example 10.6 (pp. 318–319), let on , , with in the interior and boundary controls , . The cost penalizes field temperature and controls:
A path lower bound is . This is a distinct optimal-control extension, not a property of the fixed-bath simulation.
With , , the interior ODE is

The matrix is tridiagonal; only the first and last rows directly see the boundary controls. Its homogeneous eigenvalues are
The fastest decay grows like while the slowest approaches a finite rate: spatial refinement increases stiffness. For forward Euler on this uniform diffusion system, is a sufficient absolute-stability restriction. Right-Radau time integration removes that explicit stability restriction for this linear system, but accuracy and algebraic consistency still matter.
To match Biegler’s scaled time, set ; the ODE loses its factor and the horizon becomes . Trapezoidal spatial quadrature gives the scaled cost
The bound becomes , with endpoint lower bounds . Both the time differential and the explicit time argument change; scaling only the ODE is incomplete.
If an interior path constraint is active over an interval, treating it as an equality can create a high-index constrained system. Repeated differentiation propagates information from that spatial node toward the boundary controls; for the symmetric center constraint in this example the number of differentiations grows with spatial resolution (Biegler p. 319 gives ). This is why a general promise of smooth-ODE order for every discretized PDE is too strong. The fixed-bath simulations here contain no such active path constraint.
For the method of lines and eigenvalues, see LeVeque (2007), Sections 9.2–9.4, pp. 184–186; for boundary control and index growth, Biegler (2010), Example 10.6, pp. 318–319.
Check time integration against the semi-discrete matrix exponential¶
The analytic series checks the continuous PDE. A second reference isolates the temporal error: eliminate the center temperature using , form the interior FD matrix directly, and solve this linear ODE with a matrix exponential. The equilibrium is the all-ones vector, so . This calculation uses no Pyomo derivative transformation and no collocation coefficients.
Agreement between the matrix exponential and the transcribed trajectory checks the time solution of this spatial model. Their common difference from the series is a spatial error. Neither reference should be used to relabel a solver’s successful termination as a proof of PDE correctness.
from scipy.sparse import csr_matrix
from scipy.sparse.linalg import expm_multiply
for s in [0,1,2]:
N = 40; spacing = 1.0/N; tf_check = 0.5
L = np.zeros((N-1,N-1))
for j in range(1,N):
coeff = [(j-1,1/spacing**2-s/(2*j*spacing**2)),
(j,-2/spacing**2),(j+1,1/spacing**2+s/(2*j*spacing**2))]
for node,a in coeff:
if node == 0:
L[j-1,0] += 4*a/3
L[j-1,1] -= a/3
elif node < N:
L[j-1,node-1] += a
assert max(np.linalg.eigvals(L).real) < 0
interior = 1-expm_multiply(csr_matrix(tf_check*L),np.ones(N-1))
center = (4*interior[0]-interior[1])/3
check_model = build_fd(s=s,nfe_r=N,nfe_t=30,ncp=3)
solve_model(check_model)
pyomo_interior = np.array([pyo.value(check_model.T[tf_check,r])
for r in sorted(check_model.r)[1:-1]])
time_error = float(np.max(np.abs(pyomo_interior-interior)))
space_error = abs(center-analytic(s,tf_check,0.0)[0])
assert time_error < 2e-6
assert space_error < 1e-3
print(f"{GEOMETRY[s]}: time discrepancy={time_error:.3e}; "
f"center spatial error={space_error:.3e}")Slab: time discrepancy=6.861e-11; center spatial error=9.391e-06
Cylinder: time discrepancy=2.744e-10; center spatial error=5.707e-05
Sphere: time discrepancy=1.152e-09; center spatial error=2.909e-05
Spatial-method references¶
LeVeque, Finite Difference Methods for Ordinary and Partial Differential Equations (2007), Chapter 2, pp. 20–21, states convergence in terms of consistency and stability; Chapter 9, pp. 184–186, develops the diffusion method of lines and its stiffness. Patankar, Numerical Heat Transfer and Fluid Flow (1980), Section 3.2-4, pp. 30–31, develops the control-volume balance and its conservation property. The weighted-residual viewpoint explains Galerkin FE, FV subdomain integration, and collocation as distinct residual choices; it does not make their stencils interchangeable.
The full dynamic-transcription derivation is in Dynamic Optimization with Collocation and Pyomo.DAE. Exact collocation coefficients and separate stability tests are in Collocation Points and Runge-Kutta Coefficients.
Take-Home Messages¶
Nondimensionalize first. Under the constant-property, symmetric, fixed-surface-temperature assumptions, the three geometries reduce to one equation in one parameter , with the Fourier number as the clock.
The method of lines is a decision, not a syntax. Discretizing space first is what lets you hand the time direction to Lecture 7’s collocation machinery and concentrate on the spatial operator, where the physics lives.
Finite difference, finite volume and finite element differ in what they reproduce exactly -- the Taylor series, the integral balance, or a weighted residual. Reach for FV when conservation or an interface matters, FE when the geometry or the accuracy demands it, FD when neither does.
🔴 A Neumann boundary condition written on a
DerivativeVarmay constrain nothing.CENTRALfinite differences and Radau collocation both leave the derivative at the left endpoint of the domain with no defining equation. Writingm.dTdr[t, 0] == 0and stopping there assigns a value to a dangling variable, and the center temperature is unconstrained. Write the defining equation too.optimalis notcorrect. Every failure in the two tables above terminated cleanly, several of them with plausible-looking numbers. Three things catch this class of defect: a degree-of-freedom count -- a regular determined simulation should have zero --report_structural_issues(), which says which variables are loose rather than how many, and mesh refinement, which turns a structural defect into a visible plateau. Do all three.Run the diagnostics before you run the solver.
report_structural_issues()needs no solution and no initialization; it costs seconds and it reads the incidence pattern you cannot read by eye. Learn what cautions your model should emit, so that a new one means something.Test on the geometry where the bug is loud. The finite difference defect was obvious in the slab and nearly invisible in the sphere; the finite volume defect is the other way round. A model verified only on the forgiving case is not verified.
Try It Yourself¶
Predict, before running it, which endpoint loses its defining equation under
scheme="BACKWARD"and what that breaks inbuild_fd. Check your answer withstencil_coverage("BACKWARD")-- and then say what one-sided row you would owe the model to repair it.Replace
dae.finite_differencein space withdae.collocationin a copy ofbuild_fdand confirm, with the toolbox rather than by eye, that the derivative at still needs a defining equation of your own.Add a Robin (convective) boundary condition at the surface, , where is the Biot number. Which endpoint does
CENTRALleave undefined now, and what does that imply about which one-sided stencil you owe the model? Recover the results above as .Grade the mesh toward so the short-time boundary layer is resolved, and redo the comparison.
ContinuousSet(initialize=[...])accepts an explicit list of points.Reproduce one row of either table for yourself in a scratch copy of the builder -- then delete it. Confirming a documented failure mode is worth an hour; leaving a runnable broken model in a notebook someone else will copy is not.