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:

  1. Boundary condition → select case (bc_type). A discrete choice among fixed alternatives, with a case default catching invalid codes. Announced once, before the loop.
  2. Grid sweep → named nested do with cycle. Loop rows and columns, and cycle past boundary points rather than wrapping the interior work in an if (interior) then … end if. Naming the loops (rows:, cols:) documents the nest and lets us target cycle cols unambiguously.
  3. Periodic reporting → if (mod(step, report_every) == 0). The mod trick from Chapter 3 turns "every $k$-th step" into a one-line condition — no separate counter to maintain.
  4. Stopping → a logical :: converged and a compound exit. Two independent reasons to stop (converged, or reached the step cap) combine with .or. into one exit time_loop. The named exit makes 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 + 1 line inside cols (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 case in the select case, with no other line touched.
  • Parallelism (Part VIII) parallelizes the rows/ cols sweep inside a step, while the time_loop stays 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

  1. The time_loop is a bare do with a compound exit, not a do while. Could it be written as do 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?
  2. We used cycle cols to skip boundary points. Rewrite the sweep to instead wrap the interior work in if (interior) then … end if. Which version stays readable when the interior work grows to twenty lines, and why?
  3. 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 :: scheme selected over 1 (explicit Euler), 2 (implicit), 3 (Runge–Kutta), printed once via a select 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_dp and print t = 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 :: unstable set true if interior_updates == 0 (a degenerate grid), and exit time_loop with 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 nested do/cycle, if (mod(...)), and a logical with a compound exit.
  • Choosing a construct by intent (dispatch → select case, not an if-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.