Case Study 2: Designing the Solver's Control Architecture
"Controlling complexity is the essence of computer programming." — Brian Kernighan
Executive Summary
Where the first case study ported someone else's loop, this one asks you to design one from scratch — the
control skeleton of a real time-marching simulation, before any physics exists. A production solver's driver
makes several control decisions on every run: which boundary condition to apply, how to sweep the grid, how
often to report progress, and when to stop. Get these decisions tangled into a thicket of nested ifs and the
code becomes unmaintainable exactly where it matters most. Get them expressed in the right constructs —
select case for the dispatch, named loops for the sweep, cycle for periodic actions, a clean compound
exit for stopping — and the driver reads like a specification. You will build that driver, verify it on a
tiny grid you can count by hand, and defend each construct choice as a design decision.
Skills applied: select case dispatch and why it beats nested ifs (§4.2); named nested loops with
cycle and exit (§4.4); mod for periodic actions (§4.3, and
Chapter 3); a logical flag with a compound .or. exit
condition (§4.1); architecting the heat solver's control flow (Project Checkpoint).
Background
The Project Checkpoint in this chapter builds a minimal time-loop skeleton: march some steps, and inside each, classify grid points as boundary or interior. That is enough to introduce the idea, but a real driver must do more, and the question is how to structure the "more" without descending into spaghetti. Consider what an explicit finite-difference solver's driver actually has to decide, every run and every step:
| Decision | When | Natural construct |
|---|---|---|
| Which boundary condition (Dirichlet / Neumann / periodic)? | once, at start-up | select case |
| Which grid points get updated (interior only)? | every step, every point | nested do + if/cycle |
| Report progress now, or stay quiet? | every step | if (mod(step, k) == 0) |
| Has it converged, or hit the step cap? | every step | a logical + compound exit |
Each row is a control-flow decision, and each maps cleanly onto exactly one construct from this chapter. The craft is in choosing the right construct per decision, so that the architecture is legible. We design it now, with the physics still stubbed out, precisely so the control flow can be verified in isolation — a principle you will see again in Chapter 37: test the frame before you trust the contents.
Phase 1 — Why Not Just Nest ifs?
The naive driver expresses every decision as an if. The boundary-condition choice becomes a ladder:
if (bc_type == 1) then
! Dirichlet
else if (bc_type == 2) then
! Neumann
else if (bc_type == 3) then
! periodic
else
! error
end if
This works, but it is the wrong tool, and §4.2 told you why: it is a single-value dispatch, and select
case says so to both the reader and the compiler. The compiler can verify the cases are disjoint and may
compile a jump table; the reader sees at a glance "this is a choice among fixed alternatives," not "these are
four unrelated conditions that happen to test the same variable." The distinction is not pedantry. In a driver
that will grow to dispatch on solver type, output format, and time-integration scheme, using select case
for genuine dispatch and reserving if for genuine conditions is what keeps the control flow readable at
scale. Say what you mean, and the next person to read your code — often you — recovers your intent for
free.
Phase 2 — Choosing a Construct per Decision
Walk the four decisions and commit to a construct for each, with the reasoning:
- Boundary condition →
select case (bc_type). A discrete choice among fixed alternatives, with acase defaultcatching invalid codes. Announced once, before the loop. - Grid sweep → named nested
dowithcycle. Loop rows and columns, andcyclepast boundary points rather than wrapping the interior work in anif (interior) then … end if. Naming the loops (rows:,cols:) documents the nest and lets us targetcycle colsunambiguously. - Periodic reporting →
if (mod(step, report_every) == 0). Themodtrick from Chapter 3 turns "every $k$-th step" into a one-line condition — no separate counter to maintain. - Stopping → a
logical :: convergedand a compoundexit. Two independent reasons to stop (converged, or reached the step cap) combine with.or.into oneexit time_loop. The namedexitmakes it unmistakable which loop ends.
The loop nest also honors the performance habit from §4.4: the first array index i runs in the innermost
loop, so when
Chapter 24 drops real
arithmetic in, the memory-access order is already right.
Phase 3 — Build It
Here is the driver, with the physics deliberately absent — we count interior points as a stand-in for "the work the stencil will do." Every construct from Phase 2 appears exactly once, doing exactly its job.
program heat_driver_skeleton
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer, parameter :: nx = 5, ny = 5
integer, parameter :: max_steps = 100 ! hard cap: never run forever
integer, parameter :: report_every = 20 ! diagnostics cadence
integer, parameter :: n_settle = 60 ! placeholder "steady state" step
integer, parameter :: bc_type = 1 ! 1=Dirichlet, 2=Neumann, 3=periodic
integer :: step, i, j, interior_updates
logical :: converged
! (1) Dispatch on the boundary condition, once.
select case (bc_type)
case (1)
print '(a)', 'boundary condition: Dirichlet (fixed edge temperature)'
case (2)
print '(a)', 'boundary condition: Neumann (fixed edge flux)'
case (3)
print '(a)', 'boundary condition: periodic'
case default
print '(a)', 'boundary condition: UNKNOWN'
end select
! (4) Stopping flag and the time loop.
converged = .false.
step = 0
time_loop: do
step = step + 1
! (2) Sweep the grid; cycle past boundary points, count interior updates.
interior_updates = 0
rows: do j = 1, ny
cols: do i = 1, nx
if (i == 1 .or. i == nx .or. j == 1 .or. j == ny) cycle cols
interior_updates = interior_updates + 1
end do cols
end do rows
! (3) Report only every report_every steps.
if (mod(step, report_every) == 0) then
print '(a, i3, a, i0)', 'step ', step, ': interior updates = ', interior_updates
end if
! (4) Two independent stopping reasons, combined with .or.
if (step >= n_settle) converged = .true.
if (converged .or. step >= max_steps) exit time_loop
end do time_loop
print '(a, i0, a)', 'stopped after ', step, ' steps'
end program heat_driver_skeleton
$ gfortran -std=f2018 -Wall -O2 heat_driver_skeleton.f90 -o driver && ./driver
boundary condition: Dirichlet (fixed edge temperature)
step 20: interior updates = 9
step 40: interior updates = 9
step 60: interior updates = 9
stopped after 60 steps
Phase 4 — Verify on a Grid You Can Count
Trust nothing you have not traced. The grid is $5 \times 5 = 25$ points; a point is interior only when it is
off every edge, i.e. $i \in \{2,3,4\}$ and $j \in \{2,3,4\}$ — a $3 \times 3$ block, so 9 interior
points every step. That is why each report reads interior updates = 9.
The reporting cadence is report_every = 20, so mod(step, 20) == 0 is true at steps 20, 40, 60 and nowhere
else — three report lines. The stopping logic sets converged once step reaches n_settle = 60; at step
60 the report fires first (60 is a multiple of 20), then converged becomes true, then the compound test
converged .or. step >= max_steps is true and exit time_loop runs. The step cap of 100 is never reached
because convergence stops us first — but it is there as a guarantee that the loop cannot run forever, which is
a non-negotiable property of any production driver.
| Property | Predicted by hand | Printed |
|---|---|---|
| interior points per step | $3 \times 3 = 9$ | 9 |
| report steps | 20, 40, 60 | three lines at 20/40/60 |
| final step | 60 (n_settle), before the cap of 100 |
stopped after 60 steps |
Every printed number was derived before the program was compiled — the discipline this whole book runs on.
Phase 5 — Design Review: What We Deferred, and Why It Scales
A good skeleton is honest about what it is not yet. There is no temperature update, no residual, no CFL timestep check, no file output — all deliberately stubbed. What matters is that adding each of them touches exactly one place:
- The stencil update replaces the
interior_updates = interior_updates + 1line insidecols(Chapter 24). - The real convergence test replaces the placeholder
if (step >= n_settle)with a computed residual — the exact tolerance-test discipline of Case Study 1. - A new boundary condition is one more
casein theselect case, with no other line touched. - Parallelism (Part VIII) parallelizes the
rows/colssweep inside a step, while thetime_loopstays serial because steps depend on one another — a distinction the named loops make it easy to express.
That is the payoff of choosing constructs by intent: each future change is local, because each decision lives in the one construct built for it. The control architecture you designed here, on a grid of nine interior points, is structurally the same one that will run on a grid of nine million — and that continuity is the whole reason we built the frame before the physics.
Discussion Questions
- The
time_loopis a baredowith a compoundexit, not ado while. Could it be written asdo while (.not. converged .and. step < max_steps)? What would you have to move, and which reads more clearly for a driver with two stopping reasons? - We used
cycle colsto skip boundary points. Rewrite the sweep to instead wrap the interior work inif (interior) then … end if. Which version stays readable when the interior work grows to twenty lines, and why? - The report fires at step 60 and then the loop stops at step 60. Is that the behavior you want, or should a driver suppress the routine diagnostic on the final step in favor of a summary? Argue both sides.
Your Turn: Extensions
- Option A. Add a second dispatch: an
integer, parameter :: schemeselected over1(explicit Euler),2(implicit),3(Runge–Kutta), printed once via aselect case, defaulting to an error. Show the driver now announces both its boundary condition and its scheme. - Option B. Make the report show elapsed simulated time rather than the step index: introduce
dt = 0.01_dpand printt = t0 + real(step, dp) * dt, reinforcing why the loop counter stays an integer (§4.3) while the physical time is derived from it. - Option C. Add an abort path: a
logical :: unstableset true ifinterior_updates == 0(a degenerate grid), andexit time_loopwith a distinct message. Where in the step should the check go so the driver fails fast rather than wasting work?
Key Takeaways
- A time-marching driver makes four recurring control decisions — dispatch, sweep, report, stop — and each
maps to exactly one construct:
select case, named nesteddo/cycle,if (mod(...)), and alogicalwith a compoundexit. - Choosing a construct by intent (dispatch →
select case, not anif-ladder) keeps a growing driver legible and makes every future change local to one place. - A production loop needs a hard step cap in its exit condition, so it can never run forever, even if the convergence test fails.
- Designing and verifying the control frame before adding physics means that when the numerics arrive, they drop into a structure you already trust — the same skeleton scales from nine interior points to nine million unchanged.