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

  1. 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?
  2. select case (key) dispatches on an exact string match. What are the consequences of case sensitivity (NX vs nx), and how might you normalize the key first — and at what cost?
  3. 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 loops apply_line over its lines with a real read, 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 make apply_line report 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 same key = value format using internal writes — a round-trip that proves your reader and writer agree, previewing the reproducibility theme of Chapter 37.

Key Takeaways

  • A key = value reader is four tools from this chapter: index('#') to strip a comment, index('=') to split, trim(adjustl(...)) to clean each side, and an internal read to convert the value.
  • A derived type with a deferred-length prefix component holds the parsed config cleanly, with defaults that make missing lines harmless and an ok flag that makes malformed lines visible.
  • select case on the parsed key is a readable dispatch, and frame_name shows 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.