Case Study 2: Packaging the Solver — From a Pile of Files to a Real Project
"A program that runs is a start. A program someone else can build, navigate, and trust is software."
Executive Summary
Your heat solver works, but it is a pile of files: six .f90 sources in one directory, built by an
ever-lengthening gfortran command you retype and occasionally get wrong. In this case study you promote it
to a real project — the shape a maintainer, a reviewer, or the you-of-next-year expects. You will design a
source tree by architectural role, write both an fpm.toml and the dependency section of a Makefile so you
can read either in the wild, add a constants utility module to see the layering absorb a change cleanly, and
write a first regression test in test/. The result is the package the capstone
(Chapter 38) presents as research. Where Case Study 1 read an
existing structure, this one builds one — the harder, and more lasting, skill.
Skills applied: designing a source tree from the five roles (§36.1–§36.2); choosing and writing a build system, and capturing a build configuration (§36.3); reasoning about the module map and compile order (§36.4, and Chapter 8); writing a test that guards the physics (foreshadowing Chapter 37).
Background
The solver's modules are, by now, canonical: kinds (the dp precision), timers (tic/toc), heat_types
(the field_t), heat_solver (step, stable_dt, and the Laplacian), heat_io (write_field), and the
heat driver. They already form a clean dependency graph — you designed it in
Chapter 8. What they lack is a home: a
directory structure that expresses that graph, and a build system that tracks it. We give them both.
Phase 1 — The Problem: A Flat Pile
Today the project looks like this, and builds like this:
$ ls
heat.f90 heat_io.f90 heat_solver.f90 heat_types.f90 kinds.f90 timers.f90
$ gfortran -std=f2018 -Wall kinds.f90 timers.f90 heat_types.f90 \
heat_solver.f90 heat_io.f90 heat.f90 -o heat
Two problems, both of which grow with the code. The flat directory hides the architecture — nothing tells a
newcomer that kinds is a foundation and heat is the apex. And the build command encodes the compile order
by hand: get one file out of order and you meet "Cannot open module file"
(Chapter 8). Six files is annoying; sixty
is untenable.
Phase 2 — Design the Source Tree by Role
Sort the six modules into the districts of §36.2 — utilities at the bottom, core types, then solver and I/O,
the driver on top — in the src/ + app/ + test/ shape fpm expects:
heat-solver/
├── fpm.toml the build manifest
├── README.md what it solves; how to build and run
├── src/ THE LIBRARY (everything reusable)
│ ├── kinds.f90 UTILITY: dp
│ ├── constants.f90 UTILITY: pi, physical constants (added in Phase 4)
│ ├── timers.f90 UTILITY: tic/toc
│ ├── heat_types.f90 CORE: field_t
│ ├── heat_solver.f90 SOLVER: step, stable_dt, laplacian (physics)
│ └── heat_io.f90 I/O: write_field, read_config
├── app/
│ └── main.f90 DRIVER: program heat
└── test/
└── test_step.f90 a first regression check (Phase 5)
The rule that decides app/ versus src/ is simple: app/ holds programs (each becomes an executable),
src/ holds the library (the modules). The driver is a program, so it moves to app/main.f90; everything
else is a module, so it lives under src/. The tree now is the architecture — read it top-down and you see
orchestration resting on I/O and solver resting on core resting on utilities.
Phase 3 — Write the Build File(s)
With fpm, the manifest is short because the convention supplies the rest — fpm discovers every source and
derives the compile order by reading the use statements:
name = "heat-solver"
version = "0.1.0"
license = "MIT"
author = "Your Name"
[build]
auto-executables = true
auto-tests = true
$ fpm build # compiles src/, app/, and test/ in dependency order
$ fpm run # runs app/main.f90 -> the heat program
$ fpm test # runs the programs in test/
You will also inherit codes that use make, so write the part that carries the real knowledge — the
dependency section that dictates compile order — for the same six files. Note that this is exactly the
bookkeeping fpm did for free:
FC = gfortran
FCFLAGS = -std=f2018 -Wall -O2 # the RELEASE build configuration (record it!)
# Module dependencies -> compile ORDER (Chapter 8). Maintained BY HAND:
kinds.o :
constants.o : kinds.o
timers.o : kinds.o
heat_types.o : kinds.o
heat_solver.o : kinds.o heat_types.o
heat_io.o : kinds.o heat_types.o
main.o : kinds.o constants.o timers.o heat_types.o heat_solver.o heat_io.o
Those foo.o : bar.o lines are a build configuration written by hand: they encode that heat_solver's
.mod needs heat_types's .mod first. The FCFLAGS line is the other half of the configuration — the
flags that decide whether this is a debug or a release build. Record both, or your result is not reproducible
(Chapter 37).
Phase 4 — Add a Utility, Watch the Layering Absorb It
A good architecture makes new needs cheap. Suppose the solver grows to need $\pi$ (say, for an analytical
initial condition). You add a constants utility module at the bottom layer:
module constants ! UTILITY layer, beside kinds
use kinds, only: dp
implicit none
private
public :: pi, two_pi
real(dp), parameter :: pi = 3.14159265358979_dp
real(dp), parameter :: two_pi = 2.0_dp * pi
end module constants
Now trace the blast radius. constants sits in src/ beside kinds; whichever module needs $\pi$ adds
use constants, only: pi; and — this is the payoff of the layering — nothing else changes or even
recompiles. heat_io does not use $\pi$, so heat_io is untouched. With fpm you simply add the file and
rebuild; it finds constants and slots it into the compile order automatically. With the Makefile you add
one node (constants.o : kinds.o) and one dependency to whoever uses it. A change confined to one layer stays
confined — the module boundaries of
Chapter 8 doing exactly the job they were
designed for.
Phase 5 — A First Test in test/
An empty test/ directory is a promise; fill it. Here is a first regression test: it exercises one step
of the solver and checks a value you can compute by hand, printing PASS or FAIL. This is the seed
Chapter 37 grows into a real pFUnit suite, but even this tiny
program is the difference between "it ran" and "it computed the right number."
! test/test_step.f90 (bundled self-contained here so it compiles alone).
module kinds
implicit none
integer, parameter :: dp = selected_real_kind(15, 307)
end module kinds
module heat_min ! the minimum solver slice this test needs
use kinds, only: dp
implicit none
private
public :: step
contains
subroutine step(u, alpha, dt) ! one FTCS step, interior only, Dirichlet edges held
real(dp), intent(inout) :: u(:,:)
real(dp), intent(in) :: alpha, dt
real(dp), allocatable :: lap(:,:)
integer :: nx, ny
nx = size(u,1); ny = size(u,2)
allocate(lap(nx,ny)); lap = 0.0_dp
lap(2:nx-1,2:ny-1) = u(1:nx-2,2:ny-1) - 2.0_dp*u(2:nx-1,2:ny-1) + u(3:nx,2:ny-1) &
+ u(2:nx-1,1:ny-2) - 2.0_dp*u(2:nx-1,2:ny-1) + u(2:nx-1,3:ny)
u(2:nx-1,2:ny-1) = u(2:nx-1,2:ny-1) + alpha*dt * lap(2:nx-1,2:ny-1)
end subroutine step
end module heat_min
program test_step
use kinds, only: dp
use heat_min, only: step
implicit none
real(dp) :: u(5,5)
real(dp), parameter :: expected = 20.0_dp, tol = 1.0e-10_dp
u = 0.0_dp; u(1,:) = 100.0_dp ! hot top edge
call step(u, alpha=1.0_dp, dt=0.2_dp) ! one step; dx=dy=1
if (abs(u(2,2) - expected) < tol) then
print '(a, f6.2, a)', 'PASS: u(2,2) = ', u(2,2), ' after one step (expected 20.00)'
else
print '(a, f6.2)', 'FAIL: u(2,2) = ', u(2,2)
end if
end program test_step
$ gfortran -std=f2018 -Wall -O2 test_step.f90 -o test_step && ./test_step
PASS: u(2,2) = 20.00 after one step (expected 20.00)
The check is exact and hand-verifiable: with the hot edge at $100$ and every other cell $0$, one step gives
the interior cell u(2,2) a Laplacian of $100 - 2\cdot0 + 0 + (0 - 2\cdot0 + 0) = 100$, so
u(2,2) = 0 + \alpha\,dt \cdot 100 = 0.2 \times 100 = 20.00. The test knows the physics is right, and it
will shout FAIL the day a careless refactor breaks it — which is the entire reason test/ exists in the
tree.
Discussion Questions
- Phase 4 claimed adding
constantsrecompiles nothing that does not use it. Explain, in terms of.modfiles (Chapter 8), exactly whyheat_iois not recompiled when you add a brand-new module it does notuse. - The Makefile records
FCFLAGSexplicitly; fpm has a default and a--profile releaseswitch. Why is "which flags built this?" a question a scientific result must be able to answer, and where would you record the answer for a run whose figure goes in a paper? - The Phase 5 test checks a single value,
u(2,2). Name two other things about the one-step result you could assert to make the test stronger, and one property (hint: a boundary value) that should be invariant under any number of steps.
Your Turn: Extensions
- Option A. Write the
README.mdfor the packaged solver: one paragraph on what it solves, the exactfpm build && fpm runcommands, and the expected first lines of output. A newcomer should be able to build and run from your README alone — test it by pretending you have never seen the code. - Option B. Split the Laplacian out of
heat_solverinto its owndiffusionphysics module (as in Exercise 36.19), update the source tree and the Makefile dependency section to match, and confirm the Phase 5 test still printsPASS. You have now separated the engine from the science — the move a real code makes as its physics grows. - Option C. Add a second test to
test/that runs two steps and checks the full interior against the Chapter 24 result (28/32/28and4/4/4). Then deliberately introduce a bug (drop the+1in one stencil index) and confirm the test catches it. Feeling a test fail on a real bug is the point of writing it.
Key Takeaways
- Promote a working pile of files to software by giving it a source tree that mirrors its dependency
graph (
src//app//test/, roles bottom-to-top) and a build system that tracks compile order — fpm by convention, or a Makefile you maintain by hand. - The rule that sorts files is blunt and reliable: programs go in
app/, library modules insrc/, tests intest/. - A clean layering makes change cheap: a new utility module is absorbed with no ripple to modules that do not use it — the module boundaries of Chapter 8 paying off.
- A build configuration — the flags and dependency order — is part of a reproducible result; record it.
- Even a one-assertion test in
test/converts "it ran" into "it computed the right number," and is the seed of the real test suite in Chapter 37.