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

  1. Phase 4 claimed adding constants recompiles nothing that does not use it. Explain, in terms of .mod files (Chapter 8), exactly why heat_io is not recompiled when you add a brand-new module it does not use.
  2. The Makefile records FCFLAGS explicitly; fpm has a default and a --profile release switch. 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?
  3. 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.md for the packaged solver: one paragraph on what it solves, the exact fpm build && fpm run commands, 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_solver into its own diffusion physics module (as in Exercise 36.19), update the source tree and the Makefile dependency section to match, and confirm the Phase 5 test still prints PASS. 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/28 and 4/4/4). Then deliberately introduce a bug (drop the +1 in 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 in src/, tests in test/.
  • 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.