Case Study 2: Build the Config Reader
"A simulation is only as reproducible as the file that configured it."
Executive Summary
Where the first case study fixed an inherited parser, this one builds a small, reusable piece your
heat solver will actually use: a configuration reader that turns lines like alpha = 0.25 into typed
values in a config_t derived type. It must tolerate the things real config files contain — comments,
blank lines, and irregular spacing around the = — and it must reject malformed lines rather than
silently misread them. You will design the type, write a one-line parser that splits on =, dispatch on
the key with select case, convert each value with an internal read, and expose a frame_name that builds
the solver's output filenames from the configured prefix. The result is the front door of the solver: the
component that reads a run's parameters, and the natural place, in Chapter 13,
to start validating them.
Skills applied: deferred-length strings and assumed-length dummies (§12.1); index, trim, adjustl
to split and clean a line (§12.2, §12.3); internal reads for text→number (§12.4); select case dispatch
(Ch. 4) on a parsed key; a derived type with an allocatable component (Ch. 9); a module interface (Ch. 8).
Background
Scientific codes are configured by files, not recompilation — you change dt and rerun, you do not edit
source. Chapter 7 gave you namelist for this,
and namelist is excellent; but hand-rolling a key = value reader is worth doing once, both because you
will meet the pattern everywhere and because it exercises every tool in this chapter. Our config for the
heat solver has six settings — grid size nx, ny; physics alpha, dt; run length nsteps; and the
output prefix — and its file looks like this, comments and all:
# heat solver configuration
nx = 100
ny = 100
alpha = 0.25 # thermal diffusivity
dt = 0.001
nsteps = 500
prefix = heat
Phase 1 — Design the Type
Bundle the settings into a derived type (Ch. 9), giving each a sensible default so a missing line is not a
crash. The output prefix is a deferred-length allocatable component, because its length is not known
until the file is read — exactly the field_t-style use of allocatable components from
Chapter 11:
type :: config_t
integer :: nx = 0, ny = 0, nsteps = 0
real(dp) :: alpha = 0.0_dp, dt = 0.0_dp
character(:), allocatable :: prefix
end type config_t
Phase 2 — Parse One Line: Strip, Split, Clean
A single routine, apply_line, handles one line and updates the config. The work has three steps, each a
one-liner from this chapter. First, strip a trailing comment: everything from a # onward is
discarded, using index to find it. Second, skip blank or comment-only lines (nothing left after the
strip). Third, split on = with index, and clean each side with trim(adjustl(...)) so spacing
around the = does not matter:
hash = index(line, '#')
if (hash > 0) then
work = line(:hash-1) ! keep only the part before the comment
else
work = line
end if
if (len_trim(work) == 0) return ! blank or comment-only: nothing to do
eq = index(work, '=')
if (eq == 0) then
ok = .false. ! no '=' : this line is malformed
return
end if
key = trim(adjustl(work(:eq-1))) ! left of '=', both ends stripped
val = trim(adjustl(work(eq+1:))) ! right of '=', both ends stripped
By the time we reach the last two lines, key and val are clean deferred-length strings — 'alpha' and
'0.25' for the line alpha = 0.25 # thermal diffusivity, the comment and every stray blank already
gone.
Phase 3 — Dispatch on the Key, Convert the Value
Now route each key to its field with select case (the construct from Chapter 4, here on a string), and
convert numeric values with an internal read. The prefix is already text, so it is a direct assignment;
an unrecognized key sets the error flag:
select case (key)
case ('nx'); read(val, *) cfg%nx
case ('ny'); read(val, *) cfg%ny
case ('nsteps'); read(val, *) cfg%nsteps
case ('alpha'); read(val, *) cfg%alpha
case ('dt'); read(val, *) cfg%dt
case ('prefix'); cfg%prefix = val
case default; ok = .false. ! unknown key
end select
Each read(val, *) is an internal read: it parses the text of val into the typed component, using the
same list-directed conversion you would use on a file. read(val, *) cfg%alpha turns '0.25' into the
real(dp) value 0.25; read(val, *) cfg%nx turns '100' into the integer 100.
Phase 4 — Wire It Up and Build a Filename
Package the type and the routines in a module (Ch. 8), add the frame_name that builds an output name
from the configured prefix (the Project Checkpoint pattern, now reading the prefix from the config), and
drive it over the file's lines:
module heat_config
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
private
public :: config_t, apply_line, frame_name
type :: config_t
integer :: nx = 0, ny = 0, nsteps = 0
real(dp) :: alpha = 0.0_dp, dt = 0.0_dp
character(:), allocatable :: prefix
end type config_t
contains
subroutine apply_line(line, cfg, ok)
character(len=*), intent(in) :: line
type(config_t), intent(inout) :: cfg
logical, intent(out) :: ok
character(:), allocatable :: work, key, val
integer :: hash, eq
ok = .true.
hash = index(line, '#')
if (hash > 0) then
work = line(:hash-1)
else
work = line
end if
if (len_trim(work) == 0) return
eq = index(work, '=')
if (eq == 0) then
ok = .false.
return
end if
key = trim(adjustl(work(:eq-1)))
val = trim(adjustl(work(eq+1:)))
select case (key)
case ('nx'); read(val, *) cfg%nx
case ('ny'); read(val, *) cfg%ny
case ('nsteps'); read(val, *) cfg%nsteps
case ('alpha'); read(val, *) cfg%alpha
case ('dt'); read(val, *) cfg%dt
case ('prefix'); cfg%prefix = val
case default; ok = .false.
end select
end subroutine apply_line
function frame_name(cfg, step) result(name)
type(config_t), intent(in) :: cfg
integer, intent(in) :: step
character(:), allocatable :: name
character(len=64) :: buf
write(buf, '(a, a, i6.6, a)') trim(cfg%prefix), '_', step, '.vtk'
name = trim(buf)
end function frame_name
end module heat_config
program config_demo
use heat_config
implicit none
type(config_t) :: cfg
character(len=40) :: lines(8)
logical :: ok
integer :: i
lines(1) = '# heat solver configuration'
lines(2) = 'nx = 100'
lines(3) = 'ny = 100'
lines(4) = 'alpha = 0.25 # thermal diffusivity'
lines(5) = 'dt = 0.001'
lines(6) = 'nsteps = 500'
lines(7) = 'prefix = heat'
lines(8) = 'gibberish line' ! no '=' : should be rejected
do i = 1, size(lines)
call apply_line(trim(lines(i)), cfg, ok)
if (.not. ok) print '(a)', 'warning: could not parse: ' // trim(lines(i))
end do
print '(a, i0)', 'nx = ', cfg%nx
print '(a, i0)', 'ny = ', cfg%ny
print '(a, f6.3)', 'alpha =', cfg%alpha
print '(a, f6.3)', 'dt =', cfg%dt
print '(a, i0)', 'nsteps = ', cfg%nsteps
print '(a)', 'prefix = ' // cfg%prefix
print '(a)', 'frame = ' // frame_name(cfg, 42)
end program config_demo
$ gfortran -std=f2018 -Wall case-study-02.f90 -o cfg && ./cfg
warning: could not parse: gibberish line
nx = 100
ny = 100
alpha = 0.250
dt = 0.001
nsteps = 500
prefix = heat
frame = heat_000042.vtk
Sanity check. Line 1 is a pure comment — stripped to nothing and skipped, no warning. Line 4 carries a
trailing comment that is removed before the split, so alpha reads as 0.250 and the words "thermal
diffusivity" never reach the parser. The nx, ny, nsteps integers and the dt real convert exactly.
Line 8 has no =, so apply_line sets ok = .false. and the driver reports it rather than guessing.
Finally frame_name(cfg, 42) reads the configured prefix heat and builds heat_000042.vtk — the output
name the solver will hand to the VTK writer in
Chapter 26. The
config reader is the solver's front door, and it is built entirely from this chapter's tools.
Phase 5 — Harden It (a Preview of Chapter 13)
The parser rejects a line with no =, but it still trusts that val is convertible: hand it dt = fast
and the internal read(val, *) cfg%dt will fail. The professional version adds iostat= to every internal
read — the same mechanism you used for file I/O in
Chapter 7 — and turns a nonzero status into a
clear rejection:
integer :: ios
read(val, *, iostat=ios) cfg%alpha
if (ios /= 0) ok = .false. ! 'alpha = fast' is caught, not crashed
That single iostat= converts a run-ending crash into a reported error, and it is the seam where
Chapter 13 takes over: validating that nx > 0, that
dt is CFL-safe, and error stop-ping on a configuration that cannot produce a valid run. Your solver now
reads its configuration; next it will learn to distrust it.
Discussion Questions
- The design gives every field a default and treats a missing line as "use the default." When is that the right choice, and when should a missing setting be a hard error instead?
select case (key)dispatches on an exact string match. What are the consequences of case sensitivity (NXvsnx), and how might you normalize the key first — and at what cost?- Comments are stripped with
index(line, '#'). What breaks if a value legitimately contains a#(say, a color code or a path), and how would namelist or a quoted-string rule avoid it?
Your Turn: Extensions
- Option A. Add a
read_config_file(filename, cfg)that opens the file and loopsapply_lineover its lines with a realread, combining Chapter 7's file I/O with this chapter's parsing. Count and report the line number of any rejected line. - Option B. Add
iostat=to every internal read (Phase 5) and makeapply_linereport which value failed to convert, not just that the line was bad. - Option C. Add a
write_config(cfg)that prints the config back out in the samekey = valueformat using internal writes — a round-trip that proves your reader and writer agree, previewing the reproducibility theme of Chapter 37.
Key Takeaways
- A
key = valuereader is four tools from this chapter:index('#')to strip a comment,index('=')to split,trim(adjustl(...))to clean each side, and an internalreadto convert the value. - A derived type with a deferred-length
prefixcomponent holds the parsed config cleanly, with defaults that make missing lines harmless and anokflag that makes malformed lines visible. select caseon the parsed key is a readable dispatch, andframe_nameshows the payoff: the solver's output filenames are built from the configured prefix, feeding Chapter 26's visualization.- The
iostat=seam (Phase 5) is where parsing ends and validation begins — the subject of Chapter 13, and the difference between a code that reads its inputs and one that can be trusted with them.