Case Study 2: From a Translated Kernel to a Designed Library
"Translating the code was the assignment. Designing what it should have been all along was the opportunity."
Executive Summary
The Project Checkpoint modernized the FORTRAN 77 PLATE kernel line by line: COMMON became a module, the
statement function became a pure function, the GOTO loop became do/exit. That is a faithful
translation, and it is where a cautious modernization stops. This case study goes one tier further and asks the
question a good engineer asks once the translation is safe: now that the numerics are locked down and tested,
what should this code have been? We package the modernized PLATE as a small library built around a
field_t derived type — a designed interface, not just a de-GOTO-ed one — and we design it deliberately so
that the two big changes the rest of the book will make (the real finite-difference stencil in
Chapter 24, and OpenMP in
Chapter 33) drop in without touching the
interface. Where Case Study 1 ported existing code, this one designs the target the port should aim at.
Skills applied: COMMON → a derived type rather than loose module variables (§19.1, and
Chapter 9); assumed-shape and
intent throughout (§19.3); the GOTO loop as do/exit (§19.2); designing a stable interface around a
replaceable body (Chapter 6); and the
numerical-equivalence discipline of Chapter 18.
Background
PLATE shared its state through one COMMON block — /GRID/, holding the temperature array T and the
working size N. The literal §19.1 translation makes that into module variables, which is a real improvement
(one authoritative declaration) but keeps the shape of the old design: a pile of global state that
procedures reach into. The moment you have more than one plate — a unit test on a $4\times4$ beside a
production run on a $200\times200$, or two regions of a decomposed domain — global state is exactly the wrong
model, because there is only one of it.
The design target is instead a field_t: a single object that carries a plate's dimensions and its
temperature array together, so a procedure receives a plate rather than reaching for the plate. This is the
same move the running heat-solver project makes at Chapter 9;
here we arrive at it from the legacy side, as the natural destination of the COMMON → module translation once
you let yourself design rather than merely translate.
Phase 1 — Design the Type and the Interface First
Following the interface-first discipline of Chapter 6,
decide the shape of the library before writing a solver body. The type bundles what /GRID/ held as loose
members:
module plate_types
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
private
public :: field_t
type :: field_t
integer :: nx = 0, ny = 0
real(dp), allocatable :: t(:,:) ! sized at run time (was T(NMAX,NMAX))
end type field_t
end module plate_types
The public procedures form a minimal, honest API — create a field, set its edges, relax it, probe an interior
cell — each taking a field_t and declaring its intent:
| Procedure | Signature | Intent of the field | Role |
|---|---|---|---|
init_field |
(fld, nx, ny) |
intent(out) |
allocate and zero a plate of the given size |
set_edges |
(fld, t_hot, t_cold) |
intent(inout) |
impose the Dirichlet boundary |
relax |
(fld, tol, max_iter) |
intent(inout) |
Jacobi-relax the interior to steady state |
hot_cell |
(fld) result(c) |
intent(in) (pure) |
report the interior cell next to the hot edge |
Committing to these four signatures now is the whole game: the body of relax will be rewritten twice more
in this book, but if the signature holds, no caller ever changes.
Phase 2 — Build the Library
The bodies are the modernized PLATE, re-housed against the type. Note that relax's inner update is exactly
the four-neighbour average we translated from the statement function — isolated on its own line so a better
stencil can replace it later without disturbing the loop around it:
module plate_solver
use, intrinsic :: iso_fortran_env, only: dp => real64
use plate_types, only: field_t
implicit none
private
public :: init_field, set_edges, relax, hot_cell
contains
subroutine init_field(fld, nx, ny)
type(field_t), intent(out) :: fld ! out: allocatable component starts deallocated
integer, intent(in) :: nx, ny
fld%nx = nx
fld%ny = ny
allocate(fld%t(nx, ny))
fld%t = 0.0_dp
end subroutine init_field
subroutine set_edges(fld, t_hot, t_cold)
type(field_t), intent(inout) :: fld
real(dp), intent(in) :: t_hot, t_cold
fld%t = t_cold ! interior + three cold edges
fld%t(1, :) = t_hot ! top edge (row 1) hot
end subroutine set_edges
subroutine relax(fld, tol, max_iter)
type(field_t), intent(inout) :: fld
real(dp), intent(in) :: tol
integer, intent(in) :: max_iter
real(dp), allocatable :: told(:,:)
real(dp) :: diff
integer :: i, j, iter
allocate(told(fld%nx, fld%ny))
iter = 0
do
iter = iter + 1
told = fld%t
diff = 0.0_dp
do j = 2, fld%ny - 1
do i = 2, fld%nx - 1
fld%t(i,j) = 0.25_dp * (told(i-1,j) + told(i+1,j) & ! <- the replaceable line
+ told(i,j-1) + told(i,j+1))
diff = max(diff, abs(fld%t(i,j) - told(i,j)))
end do
end do
if (diff <= tol .or. iter >= max_iter) exit
end do
end subroutine relax
pure function hot_cell(fld) result(c)
type(field_t), intent(in) :: fld
real(dp) :: c
c = fld%t(2, 2) ! an interior cell adjacent to the hot edge (row 1)
end function hot_cell
end module plate_solver
Phase 3 — Drive It and Check the Number
The driver reads like a description of the physics, which is the payoff of a designed interface:
program plate_lib_demo
use plate_types, only: field_t
use plate_solver, only: init_field, set_edges, relax, hot_cell
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
type(field_t) :: plate
call init_field(plate, 4, 4)
call set_edges(plate, 100.0_dp, 0.0_dp)
call relax(plate, 1.0e-6_dp, 1000)
print '(a, f8.2)', 'cell next to hot edge = ', hot_cell(plate)
end program plate_lib_demo
$ gfortran -std=f2018 -Wall -O2 plate_types.f90 plate_solver.f90 plate_lib_demo.f90 -o plate && ./plate
cell next to hot edge = 37.50
The probe reads 37.50, and — as in the Project Checkpoint — that is a theorem, not a measurement. On the
$4\times4$ plate the two interior cells next to the hot edge equal $a$ and the two next to the cold edge equal
$b$; steady state makes each the average of its four neighbours, giving $3a - b = 100$ and $a = 3b$, hence
$8b = 100 \Rightarrow b = 12.5$, $a = 37.5$. So hot_cell returns 37.50, the value
Chapter 18 established as the reference and proved by
hand. The design changed; the science did not — which is exactly the invariant Chapter 18's regression
tests are meant to protect.
Phase 4 — Confirm the Interface Survives the Future
The reason to design rather than merely translate is that a designed interface absorbs change. Walk the two big future edits and confirm each touches only a body:
- Chapter 24 (the real stencil). The genuine finite-difference update — a proper Laplacian with $dx$, $dy$,
a diffusivity, and a CFL-limited timestep — replaces the single marked line inside
relax. Thefield_t, the four public signatures, and every caller are untouched. The four-neighbour average was always a placeholder for this exact substitution. - Chapter 33 (OpenMP). The interior double loop gets a
!$omp parallel dodirective (or becomes ado concurrent). Because each cell's update reads only thetoldsnapshot and writes only its ownfld%t(i,j), the iterations are independent and the parallelization is safe by construction — a property we secured by keeping the update a pure, snapshot-based computation, not by anything OpenMP-specific.
Neither edit changes a line the caller can see. That is what "design the interface first" buys, and it is why the extra effort over the literal translation pays for itself the first time the code has to grow.
The design lesson, stated generally: the literal dictionary translation (§19.1's
COMMON→ module) makes legacy code safe; promoting the result to a designed type-plus-interface makes it extensible. Do the translation first, under the protection of a regression test, and only then — with the numerics pinned — reshape it into what it should have been. Reversing the order (redesigning while you still are not sure the numbers match) is how modernizations quietly break validated science.
Phase 5 — Cost, and When to Stop
Be honest about the trade. The field_t library is more code than the loose-module translation: a type
definition, an init_field you did not need when the arrays were module variables, and a little fld%
ceremony at every use. For a thirty-line one-shot script that ceremony is not worth it, and the loose-module
translation is the right stopping point. The library earns its keep when the code will live — when there will
be more than one plate, when the stencil and the parallel back-end are coming, when other people will call it.
PLATE is destined for all of those (it is the book's legacy anchor), so the design is warranted. Recognizing
which of your legacy kernels deserve this second phase, and which should stop at the safe literal
translation, is itself a senior skill: not every old routine needs to become a library, but the load-bearing
ones do.
Discussion Questions
init_fielddeclares the fieldintent(out), which deallocates thetcomponent on entry. What bug wouldintent(inout)risk here, and what wouldintent(out)cost if you calledinit_fieldon an already-populated field you meant to keep?- We kept the four public procedures as free procedures in a module. An alternative is type-bound
procedures, so callers write
call plate%relax(...)(Chapter 9). What does the type-bound style add, and does it change how well the interface survives the Chapter 24 and 33 edits? - Phase 5 argues some legacy kernels should stop at the literal translation. Give two concrete signals, in a codebase you have to modernize, that a routine does deserve promotion to a designed library — and two that it does not.
Your Turn: Extensions
- Option A. Add a
total_energy(fld) result(e)pure functionthat returnssum(fld%t), and use it as a second regression check: energy should be identical between the legacy and library versions on the same grid. - Option B. Give
relaxanoptional, intent(out) :: itersargument reporting the iteration count, so the caller can see convergence without the routine printing anything. Guard it withpresent(Chapter 6). - Option C. Replace the interior double loop with a single whole-array update of the interior section
(
fld%t(2:nx-1, 2:ny-1) = 0.25_dp*( ... shifted sections ... )) and confirmhot_cellis still37.50. Which form will Chapter 33 parallelize more obviously — the explicit loop or the array section?
Key Takeaways
- Translate first, design second. The literal
COMMON→ module move makes the code safe under a regression test; only then reshape it into afield_t-plus-interface that makes it extensible. - A derived type is where
COMMONwants to go. Bundling the grid into one object turns "reach for the global plate" into "receive a plate," which is what lets you have more than one. - Design the interface, treat the body as replaceable. Four stable signatures let the real stencil (Ch. 24) and OpenMP (Ch. 33) drop in without changing a single caller.
- Not every kernel earns a library. Stop at the safe literal translation for throwaway code; invest the extra design in the load-bearing routines that will grow — and know how to tell them apart.