config.f90 Source File


Source Code

!> @file config.f90
!> @brief Runtime configuration module for the 1D Euler solver.
!!
!! Reads simulation parameters from a Fortran namelist file (default:
!! `input.nml`).  A missing or unreadable file is always a fatal error
!! (`config: cannot open "..."`); there is no built-in Sod-defaults fallback.
!! Callers that want fallback-to-defaults must supply a name that exists.
!!
!! Namelist groups (all optional; missing groups keep their defaults):
!!
!!   - `&grid`              — spatial discretisation
!!   - `&time_ctrl`         — time-stepping parameters
!!   - `&physics`           — gas model (gamma)
!!   - `&schemes`           — algorithm selection strings (incl. MUSCL limiter)
!!   - `&initial_condition` — Riemann problem left/right primitive states and BC types
!!   - `&output`            — result filename, diagnostic frequency, live snapshots
!!   - `&checkpoint`        — checkpoint write interval and restart path
!!
!! Example usage in the driver:
!! @code
!!   type(config_t) :: cfg
!!   call read_config('input.nml', cfg)
!! @endcode

module config
  use precision, only: wp
  use iso_fortran_env, only: iostat_end
  use mesh_1d, only: read_node_coords
  use option_registry, only: recon_muscl, problem_from_file, problem_udf, &
                             bc_periodic, bc_subsonic_inlet, bc_subsonic_outlet, &
                             is_valid_limiter, &
                             is_fds_flux_scheme, method_fvm, &
                             join_token_list, &
                             flux_scheme_names, recon_scheme_names, &
                             time_scheme_names, limiter_names, problem_type_names, &
                             boundary_condition_names, char_proj_mode_names, &
                             nrbc_mode_names, hybrid_sensor_names, method_names
  use output_format_list, only: parse_format_list, max_formats
  use string_utils, only: lowercase_token
  use path_util, only: resolve_case_path, case_base_dir
  use config_engine, only: field_desc_t, field_meta, normalize_fields, validate_fields, &
                           cfg_kind_int, cfg_kind_real, cfg_kind_logical, &
                           cfg_kind_choice, cfg_kind_string, cfg_kind_real3, &
                           choice_msg_one_of
  implicit none
  private
  public :: config_t, read_config, validate_config
  public :: build_field_table

  !> Frozen ABI schema field count (1..62, identical order to the old
  !! config_schema.ensure_schema indices) — append only.
  integer, parameter, public :: n_config_fields = 62

  !> All runtime-configurable simulation parameters with Sod shock tube
  !! defaults.
  type, public :: config_t

    ! -- &grid --
    integer :: n_cell = 500       !< Number of grid cells
    real(wp) :: x_left = 0.0_wp   !< Left  boundary coordinate  [m]
    real(wp) :: x_right = 1.0_wp   !< Right boundary coordinate  [m]
    character(len=32) :: grid_type = 'uniform'
    !< Grid type: 'uniform' (default) or 'file' (read node coords from grid_file).
    character(len=256) :: grid_file = ''
    !< Path to a node-coordinate file (one strictly-increasing coordinate per
    !! line; '#' comments and blank lines ignored). Required when grid_type='file'.
    !! n_cell, x_left, x_right are derived from the file when set.

    ! -- &time_ctrl --
    real(wp) :: dt = 1.0e-4_wp  !< Time step size [s] (used when cfl = 0)
    real(wp) :: time_start = 0.0_wp     !< Simulation start time [s]
    real(wp) :: time_stop = 0.15_wp    !< Simulation stop time  [s]
    real(wp) :: cfl = 0.0_wp     !< CFL number (> 0 enables adaptive dt; 0 = fixed dt)
    integer :: max_iter = 0
    !< Optional iteration cap. When > 0, the time loop exits once
    !! `ctx % iter` reaches `max_iter`, overriding `time_stop`.
    !! Default 0 means "ignore" — `time_stop` keeps its usual role.
    logical :: lapack_solver = .true.
    !< Use LAPACK dgbsv for the banded solve in backward Euler (.true., default).
    !! Set to .false. to use the built-in no-pivoting solver instead.
    !! Only relevant when time_scheme = 'beuler'.

    ! -- &physics --
    real(wp) :: gam = 1.4_wp        !< Ratio of specific heats, c_p / c_v

    ! -- &schemes --
    character(len=64) :: method = 'fdm'
    !< Spatial discretisation method.  Parsed from the &schemes namelist.
    !! 'fdm' (default) — finite-difference method (existing 1D solver path).
    !! 'fvm' — finite-volume method; requires an FDS/Riemann flux scheme.
    character(len=64) :: flux_scheme = 'lax_friedrichs'
    !< Flux scheme. Valid values:
    !! 'lax_friedrichs' (default), 'steger_warming', 'van_leer', 'ausm_plus',
    !! 'hll', 'hllc', 'roe'
    character(len=64) :: recon_scheme = 'weno5'           !< Spatial reconstruction variant
    character(len=64) :: time_scheme = 'rk3'             !< Time integration scheme
    character(len=8) :: char_proj = 'auto'
    !< Characteristic projection mode.
    !! 'auto' (default) — enable per-scheme: on for weno5/weno5z/weno_cu6/weno7/
    !! weno9/weno11/eno3/mp5/teno5, off otherwise.
    !! 'yes'  — always apply eigensystem decomposition (more accurate for shocks, higher cost).
    !! 'no'   — always skip eigensystem decomposition (faster; safe for scalar/smooth problems).
    character(len=32) :: limiter = 'minmod'
    !< TVD limiter for MUSCL reconstruction.
    !! Valid values: 'minmod' (default), 'superbee', 'mc', 'van_leer', 'koren'.
    !! Ignored for all non-MUSCL reconstruction schemes.
    logical :: use_positivity_limiter = .false.
    !< Enable Zhang-Shu positivity-preserving limiter (default .false.).
    !! When .true., reconstructed face states on the FDS path (AUSM+,
    !! HLL, HLLC, Roe) are scaled toward the adjacent cell average to ensure
    !! density and pressure remain non-negative.
    !! Ignored when a FVS flux scheme is active.
    logical :: use_hybrid_recon = .false.
    !< Enable shock-sensor hybrid reconstruction (default .false.).
    !! When .true., each face is classified smooth or non-smooth using the
    !! selected sensor (hybrid_sensor).  Smooth faces use the linear
    !! (optimal-weight) WENO variant — same order as the primary scheme but
    !! without nonlinear dissipation.  Non-smooth faces fall back to the full
    !! nonlinear primary reconstruction scheme.
    character(len=32) :: hybrid_sensor = 'jameson'
    !< Shock sensor used to classify faces when use_hybrid_recon = .true..
    !! Valid values:
    !!   'jameson'          — Jameson-Schmidt-Turkel pressure second-derivative
    !!                        sensor (JST 1981); physically motivated for
    !!                        compressible flows.  Threshold ~ 0.1.
    !!   'density_gradient' — Normalised density jump across the face; cheap
    !!                        and robust.  Threshold ~ 0.1.
    !!   'weno_beta'        — Ratio max(β)/min(β) of WENO5 smoothness
    !!                        indicators; only meaningful for 5-point WENO-family
    !!                        schemes; falls back to 'density_gradient' otherwise.
    !!                        Threshold ~ 50.
    real(wp) :: hybrid_sensor_threshold = 0.1_wp
    !< Sensor value above which the nonlinear WENO scheme is activated.
    !! Default 0.1 suits the 'jameson' and 'density_gradient' sensors.
    !! Use ~50 for 'weno_beta'.

    ! -- &initial_condition --
    character(len=64) :: problem_type = 'sod'
    !< IC problem type.
    !! Valid values: 'sod', 'shu_osher', 'smooth_wave', 'linear_advection',
    !! 'woodward_colella', 'lax', 'acoustic_pulse', 'from_file', 'udf'.
    character(len=256) :: ic_file = ''
    !< Path to IC data file used when problem_type = 'from_file'.
    !! File must contain whitespace-separated columns: x  rho  u  p
    !! (one line per grid point; same format as the solver output file).
    logical :: ic_interp = .true.
    !< Allow linear interpolation when the IC file grid differs from the solver grid (default .true.).
    !! When .false., a grid mismatch causes an error stop.
    character(len=256) :: ic_udf_src = ''
    !< Path to a Fortran source file (.f90) containing the user-defined IC subroutine.
    !! Used when problem_type = 'udf'.  The solver compiles this file to a shared
    !! library at runtime (requires gfortran on PATH) and calls the subroutine to
    !! fill the initial state.  See example/udf_sod.f90 for the required interface.
    character(len=32) :: bc_left = 'dirichlet'
    !< Left  boundary condition type.
    !! Valid values: 'dirichlet' (default), 'inflow', 'outflow', 'reflecting',
    !! 'periodic', 'nonreflecting', 'supersonic_inlet', 'subsonic_inlet',
    !! 'supersonic_outlet', 'subsonic_outlet', 'neumann', 'neumann_gradient'.
    character(len=32) :: bc_right = 'dirichlet'
    !< Right boundary condition type.
    !! Valid values: 'dirichlet' (default), 'inflow', 'outflow', 'reflecting',
    !! 'periodic', 'nonreflecting', 'supersonic_inlet', 'subsonic_inlet',
    !! 'supersonic_outlet', 'subsonic_outlet', 'neumann', 'neumann_gradient'.
    real(wp) :: p_ref_left = 1.0_wp
    !< Target far-field pressure for the non-reflecting BC at the left boundary [Pa].
    !! Used only when bc_left = 'nonreflecting'.
    real(wp) :: p_ref_right = 1.0_wp
    !< Target far-field pressure for the non-reflecting BC at the right boundary [Pa].
    !! Used only when bc_right = 'nonreflecting'.
    real(wp) :: sigma_nrbc = 0.0_wp
    !< Non-reflecting BC relaxation factor in [0, 1].
    !! 0 = fully non-reflecting; 1 = Dirichlet target pressure p_ref.
    !! See Thompson (1987), Poinsot & Lele (1992).
    character(len=32) :: nrbc_mode = 'pressure'
    !< NRBC algorithm variant.
    !! 'pressure' (default) = isentropic pressure-relaxation (Thompson 1987).
    !! 'characteristic' = full LODI characteristic targeting (Poinsot & Lele 1992,
    !!   Sec. 3); activates u_ref_*, rho_ref_*, sigma_nrbc_entropy.
    real(wp) :: u_ref_left = 0.0_wp
    !< Target far-field velocity for NRBC at the left boundary [m/s].
    !! Used only when bc_left = 'nonreflecting' and nrbc_mode = 'characteristic'.
    real(wp) :: u_ref_right = 0.0_wp
    !< Target far-field velocity for NRBC at the right boundary [m/s].
    !! Used only when bc_right = 'nonreflecting' and nrbc_mode = 'characteristic'.
    real(wp) :: rho_ref_left = 1.0_wp
    !< Target far-field density for NRBC at the left boundary [kg/m^3].
    !! Used only when bc_left = 'nonreflecting' and nrbc_mode = 'characteristic'.
    real(wp) :: rho_ref_right = 1.0_wp
    !< Target far-field density for NRBC at the right boundary [kg/m^3].
    !! Used only when bc_right = 'nonreflecting' and nrbc_mode = 'characteristic'.
    real(wp) :: sigma_nrbc_entropy = 0.0_wp
    !< Relaxation factor for the entropy (density) characteristic wave, in [0, 1].
    !! 0 = fully non-reflecting entropy wave (default);
    !! 1 = entropy wave driven to the rho_ref target.
    !! Active only when nrbc_mode = 'characteristic'.
    !! See Poinsot & Lele (1992), J. Comput. Phys. 101, Sec. 3.

    ! --- subsonic_inlet ---
    real(wp) :: p_stag_left = 1.0_wp
    !< Stagnation (total) pressure for the left subsonic inlet [Pa].
    !! Used only when bc_left = 'subsonic_inlet'.
    !! Derived from static conditions via p0 = p*(1 + (gam-1)/2*Ma^2)^(gam/(gam-1)).
    real(wp) :: rho_stag_left = 1.0_wp
    !< Stagnation (total) density for the left subsonic inlet [kg/m^3].
    !! Used only when bc_left = 'subsonic_inlet'.
    !! Derived from static conditions via rho0 = rho*(1 + (gam-1)/2*Ma^2)^(1/(gam-1)).
    real(wp) :: p_stag_right = 1.0_wp
    !< Stagnation (total) pressure for the right subsonic inlet [Pa].
    !! Used only when bc_right = 'subsonic_inlet'.
    real(wp) :: rho_stag_right = 1.0_wp
    !< Stagnation (total) density for the right subsonic inlet [kg/m^3].
    !! Used only when bc_right = 'subsonic_inlet'.

    ! --- subsonic_outlet ---
    real(wp) :: p_back_left = 1.0_wp
    !< Back pressure for the left subsonic outlet [Pa].
    !! Used only when bc_left = 'subsonic_outlet'.
    real(wp) :: p_back_right = 1.0_wp
    !< Back pressure for the right subsonic outlet [Pa].
    !! Used only when bc_right = 'subsonic_outlet'.

    ! --- neumann_gradient ---
    real(wp) :: neumann_grad_left(3) = 0.0_wp
    !< Prescribed outward normal gradient dq/dn at the left boundary (conserved vars).
    !! Used only when bc_left = 'neumann_gradient'.
    !! Ghost state: q_ghost = q_wall + neumann_grad_left * dx.
    real(wp) :: neumann_grad_right(3) = 0.0_wp
    !< Prescribed outward normal gradient dq/dn at the right boundary (conserved vars).
    !! Used only when bc_right = 'neumann_gradient'.
    !! Ghost state: q_ghost = q_wall + neumann_grad_right * dx.

    real(wp) :: rho_left = 1.0_wp    !< Left  density       [kg/m^3]
    real(wp) :: u_left = 0.0_wp    !< Left  velocity      [m/s]
    real(wp) :: p_left = 1.0_wp    !< Left  pressure      [Pa]
    real(wp) :: rho_right = 0.125_wp  !< Right density       [kg/m^3]
    real(wp) :: u_right = 0.0_wp    !< Right velocity      [m/s]
    real(wp) :: p_right = 0.1_wp    !< Right pressure      [Pa]
    real(wp) :: x_diaphragm = 0.5_wp    !< Diaphragm location  [m]

    ! -- &output --
    character(len=256) :: output_file = 'result.dat' !< Output filename
    character(len=64) :: output_format = 'dat'
    !< Space- or comma-separated output format list. Valid tokens: 'dat' (default
    !! columnar text), 'tec' (Tecplot ASCII), 'plt' (Tecplot binary). Multiple
    !! formats may be requested, e.g. 'dat plt'; each is written to a filename
    !! derived from output_file.
    integer :: print_freq = 50 !< Print residual every N iterations
    !> Enable detailed wall-clock timing summary output (default .false.).
    !! When .true., compute_resid() and the driver accumulate fine-grained
    !! timing and print a performance table after the run.  Lightweight
    !! per-iteration `iter_s` / `elapsed_s` logging is always available.
    !! Has no effect on results.
    logical :: do_timing = .false.
    !> Logger verbosity threshold (default 3 = LOGLVL_INFO).
    !! 0 = SILENT, 1 = ERROR, 2 = WARN, 3 = INFO, 4 = DEBUG.
    !! See module `logger` for the LOGLVL_* constants.
    integer :: verbosity = 3
    !> Log file path (default 'run.log').  Empty string disables file logging.
    character(len=256) :: log_file = 'run.log'
    !> Write a live solution snapshot every N iterations (0 = disabled).
    !! The snapshot file is overwritten each time, so a GUI app can watch
    !! it with inotify/kqueue.  Format matches result.dat with a leading
    !! comment line: "# iter=NNN t=T".
    integer :: snapshot_freq = 0
    !> Path for the live snapshot file (default 'snapshot.dat').
    character(len=256) :: snapshot_file = 'snapshot.dat'

    ! -- &checkpoint --
    !> Write a checkpoint every N iterations (0 = disabled).
    !! Checkpoint files are named '<checkpoint_file>_NNNNNN.bin' and contain
    !! enough state to resume the run exactly: iter, t, ub, and (if BDF2)
    !! bdf2_ub_prev.  The file 'latest_checkpoint' is also updated to point
    !! to the most recent checkpoint so restarts need not know the iteration.
    integer :: checkpoint_freq = 0
    !> Base name for checkpoint files (default 'checkpoint').
    character(len=256) :: checkpoint_file = 'checkpoint'
    !> Path to a checkpoint file to resume from (empty = fresh start).
    !! When set, the IC is skipped and iter/t are loaded from the checkpoint.
    character(len=256) :: restart_file = ''

  end type config_t

contains

  !> Read simulation parameters from a namelist file.
  !!
  !! Each namelist group in the file is optional.  Missing groups keep the
  !! default values defined in `config_t`.  If the file itself cannot be
  !! opened (e.g. not found), a warning is printed and all defaults are kept.
  !!
  !! @param[in]  filename  Path to the namelist input file.
  !! @param[out] cfg       Populated configuration object.
  subroutine read_config(filename, cfg, is_ok, message, case_dir)
    character(len=*), intent(in) :: filename
    type(config_t), intent(out) :: cfg
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    !> Optional case directory to anchor relative file paths against; when
    !! absent/empty the namelist's own directory is used (see path_util).
    character(len=*), intent(in), optional :: case_dir

    ! Fortran namelists require plain scalar/array variable names — derived-type
    ! component selectors (cfg%n_cell) are not supported in NAMELIST declarations
    ! by gfortran and would also change the .nml file format.  We therefore keep
    ! one set of local mirror variables: they are initialised from the config_t
    ! defaults (which cfg already holds via intent(out) default initialisation),
    ! read from the file, then transferred back into cfg in one block below.
    integer :: n_cell
    real(wp) :: x_left, x_right
    character(len=32) :: grid_type
    character(len=256) :: grid_file
    real(wp) :: dt, time_start, time_stop, cfl
    integer :: max_iter
    logical :: lapack_solver
    real(wp) :: gam
    character(len=64) :: method
    character(len=64) :: flux_scheme, recon_scheme, time_scheme
    character(len=8) :: char_proj
    character(len=32) :: limiter
    logical :: use_positivity_limiter
    logical :: use_hybrid_recon
    character(len=32) :: hybrid_sensor
    real(wp) :: hybrid_sensor_threshold
    character(len=64) :: problem_type
    character(len=256) :: ic_file, ic_udf_src
    logical :: ic_interp
    character(len=32) :: bc_left, bc_right
    real(wp) :: p_ref_left, p_ref_right, sigma_nrbc
    character(len=32) :: nrbc_mode
    real(wp) :: u_ref_left, u_ref_right, rho_ref_left, rho_ref_right
    real(wp) :: sigma_nrbc_entropy
    real(wp) :: p_stag_left, rho_stag_left, p_stag_right, rho_stag_right
    real(wp) :: p_back_left, p_back_right
    real(wp) :: neumann_grad_left(3), neumann_grad_right(3)
    real(wp) :: rho_left, u_left, p_left
    real(wp) :: rho_right, u_right, p_right, x_diaphragm
    character(len=256) :: output_file
    character(len=64) :: output_format
    integer :: print_freq
    logical :: do_timing
    integer :: verbosity
    character(len=256) :: log_file
    integer :: snapshot_freq
    character(len=256) :: snapshot_file
    integer :: checkpoint_freq
    character(len=256) :: checkpoint_file, restart_file

    namelist /grid/ n_cell, x_left, x_right, grid_type, grid_file
    namelist /time_ctrl/ dt, time_start, time_stop, cfl, max_iter, lapack_solver
    namelist /physics/ gam
    namelist /schemes/ method, flux_scheme, recon_scheme, time_scheme, char_proj, limiter, &
      use_positivity_limiter, use_hybrid_recon, hybrid_sensor, hybrid_sensor_threshold
    namelist /initial_condition/ problem_type, ic_file, ic_interp, ic_udf_src, &
      bc_left, bc_right, p_ref_left, p_ref_right, sigma_nrbc, &
      nrbc_mode, u_ref_left, u_ref_right, rho_ref_left, rho_ref_right, &
      sigma_nrbc_entropy, &
      p_stag_left, rho_stag_left, p_stag_right, rho_stag_right, &
      p_back_left, p_back_right, &
      neumann_grad_left, neumann_grad_right, &
      rho_left, u_left, p_left, rho_right, u_right, p_right, x_diaphragm
    namelist /output/ output_file, output_format, print_freq, do_timing, verbosity, log_file, &
      snapshot_freq, snapshot_file
    namelist /checkpoint/ checkpoint_freq, checkpoint_file, restart_file

    integer :: u, info
    logical :: ok
    character(len=256) :: err

    if (present(is_ok)) is_ok = .true.
    if (present(message)) message = ''

    ! Initialise locals from the config_t defaults so that any namelist group
    ! absent from the file keeps its documented default value.
    n_cell = cfg % n_cell
    x_left = cfg % x_left
    x_right = cfg % x_right
    grid_type = cfg % grid_type
    grid_file = cfg % grid_file
    dt = cfg % dt
    time_start = cfg % time_start
    time_stop = cfg % time_stop
    cfl = cfg % cfl
    max_iter = cfg % max_iter
    lapack_solver = cfg % lapack_solver
    gam = cfg % gam
    method = cfg % method
    flux_scheme = cfg % flux_scheme
    recon_scheme = cfg % recon_scheme
    time_scheme = cfg % time_scheme
    char_proj = cfg % char_proj
    limiter = cfg % limiter
    use_positivity_limiter = cfg % use_positivity_limiter
    use_hybrid_recon = cfg % use_hybrid_recon
    hybrid_sensor = cfg % hybrid_sensor
    hybrid_sensor_threshold = cfg % hybrid_sensor_threshold
    problem_type = cfg % problem_type
    ic_file = cfg % ic_file
    ic_udf_src = cfg % ic_udf_src
    ic_interp = cfg % ic_interp
    bc_left = cfg % bc_left
    bc_right = cfg % bc_right
    p_ref_left = cfg % p_ref_left
    p_ref_right = cfg % p_ref_right
    sigma_nrbc = cfg % sigma_nrbc
    nrbc_mode = cfg % nrbc_mode
    u_ref_left = cfg % u_ref_left
    u_ref_right = cfg % u_ref_right
    rho_ref_left = cfg % rho_ref_left
    rho_ref_right = cfg % rho_ref_right
    sigma_nrbc_entropy = cfg % sigma_nrbc_entropy
    p_stag_left = cfg % p_stag_left
    rho_stag_left = cfg % rho_stag_left
    p_stag_right = cfg % p_stag_right
    rho_stag_right = cfg % rho_stag_right
    p_back_left = cfg % p_back_left
    p_back_right = cfg % p_back_right
    neumann_grad_left = cfg % neumann_grad_left
    neumann_grad_right = cfg % neumann_grad_right
    rho_left = cfg % rho_left
    u_left = cfg % u_left
    p_left = cfg % p_left
    rho_right = cfg % rho_right
    u_right = cfg % u_right
    p_right = cfg % p_right
    x_diaphragm = cfg % x_diaphragm
    output_file = cfg % output_file
    output_format = cfg % output_format
    print_freq = cfg % print_freq
    do_timing = cfg % do_timing
    verbosity = cfg % verbosity
    log_file = cfg % log_file
    snapshot_freq = cfg % snapshot_freq
    snapshot_file = cfg % snapshot_file
    checkpoint_freq = cfg % checkpoint_freq
    checkpoint_file = cfg % checkpoint_file
    restart_file = cfg % restart_file

    ! Try to open the input file.  A missing or unreadable file is always an
    ! error: 1D and 2D readers should agree on this contract.  Callers that
    ! want fallback-to-defaults must supply a name that exists.
    open (newunit=u, file=filename, status='old', action='read', iostat=info)
    if (info /= 0) then
      if (present(is_ok)) is_ok = .false.
      if (present(message)) message = 'config: cannot open "'//trim(filename)//'"'
      if (.not. present(is_ok) .and. .not. present(message)) &
        error stop 'config: cannot open "'//trim(filename)//'"'
      return
    end if

    ! Read each group independently (rewind so groups may appear in any order).
    rewind (u)
    read (u, nml=grid, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &grid namelist')
      return
    end if

    rewind (u)
    read (u, nml=time_ctrl, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &time_ctrl namelist')
      return
    end if

    rewind (u)
    read (u, nml=physics, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &physics namelist')
      return
    end if

    rewind (u)
    read (u, nml=schemes, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &schemes namelist')
      return
    end if

    rewind (u)
    read (u, nml=initial_condition, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &initial_condition namelist')
      return
    end if

    rewind (u)
    read (u, nml=output, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &output namelist')
      return
    end if

    rewind (u)
    read (u, nml=checkpoint, iostat=info)
    if (info /= 0 .and. info /= iostat_end) then
      call fail_read('config: parse error in &checkpoint namelist')
      return
    end if

    close (u, iostat=info)
    if (info /= 0) then
      call fail_read('config: file close failed', already_closed=.true.)
      return
    end if

    ! Transfer locals into cfg (single assignment block — no separate populate()).
    cfg % n_cell = n_cell
    cfg % x_left = x_left
    cfg % x_right = x_right
    cfg % grid_type = grid_type
    ! Resolve a relative grid_file against the case directory (explicit case_dir
    ! override when given, else the namelist's own directory) so a case folder is
    ! movable and the node-coordinate file loads regardless of the process cwd.
    ! Absolute paths kept as-is; empty stays empty.
    cfg % grid_file = resolve_case_path(case_base_dir(filename, case_dir), grid_file)
    cfg % dt = dt
    cfg % time_start = time_start
    cfg % time_stop = time_stop
    cfg % cfl = cfl
    cfg % max_iter = max_iter
    cfg % lapack_solver = lapack_solver
    cfg % gam = gam
    cfg % method = method
    cfg % flux_scheme = flux_scheme
    cfg % recon_scheme = recon_scheme
    cfg % time_scheme = time_scheme
    cfg % char_proj = char_proj
    cfg % limiter = limiter
    cfg % use_positivity_limiter = use_positivity_limiter
    cfg % use_hybrid_recon = use_hybrid_recon
    cfg % hybrid_sensor = hybrid_sensor
    cfg % hybrid_sensor_threshold = hybrid_sensor_threshold
    cfg % problem_type = problem_type
    cfg % ic_file = ic_file
    cfg % ic_udf_src = ic_udf_src
    cfg % ic_interp = ic_interp
    cfg % bc_left = bc_left
    cfg % bc_right = bc_right
    cfg % p_ref_left = p_ref_left
    cfg % p_ref_right = p_ref_right
    cfg % sigma_nrbc = sigma_nrbc
    cfg % nrbc_mode = nrbc_mode
    cfg % u_ref_left = u_ref_left
    cfg % u_ref_right = u_ref_right
    cfg % rho_ref_left = rho_ref_left
    cfg % rho_ref_right = rho_ref_right
    cfg % sigma_nrbc_entropy = sigma_nrbc_entropy
    cfg % p_stag_left = p_stag_left
    cfg % rho_stag_left = rho_stag_left
    cfg % p_stag_right = p_stag_right
    cfg % rho_stag_right = rho_stag_right
    cfg % p_back_left = p_back_left
    cfg % p_back_right = p_back_right
    cfg % neumann_grad_left = neumann_grad_left
    cfg % neumann_grad_right = neumann_grad_right
    cfg % rho_left = rho_left
    cfg % u_left = u_left
    cfg % p_left = p_left
    cfg % rho_right = rho_right
    cfg % u_right = u_right
    cfg % p_right = p_right
    cfg % x_diaphragm = x_diaphragm
    cfg % output_file = output_file
    cfg % output_format = lowercase_token(output_format)
    cfg % print_freq = print_freq
    cfg % do_timing = do_timing
    cfg % verbosity = verbosity
    cfg % log_file = log_file
    cfg % snapshot_freq = snapshot_freq
    cfg % snapshot_file = snapshot_file
    cfg % checkpoint_freq = checkpoint_freq
    cfg % checkpoint_file = checkpoint_file
    cfg % restart_file = restart_file

    call validate_config(cfg, ok, err)
    if (present(is_ok)) is_ok = ok
    if (present(message)) message = trim(err)
    if (.not. ok .and. .not. present(is_ok) .and. .not. present(message)) error stop trim(err)

  contains

    subroutine fail_read(err_msg, already_closed)
      character(len=*), intent(in) :: err_msg
      logical, intent(in), optional :: already_closed
      integer :: close_info
      logical :: skip_close

      skip_close = .false.
      if (present(already_closed)) skip_close = already_closed
      if (.not. skip_close) close (u, iostat=close_info)

      if (present(is_ok)) is_ok = .false.
      if (present(message)) message = trim(err_msg)
      if (.not. present(is_ok) .and. .not. present(message)) error stop trim(err_msg)
    end subroutine fail_read

  end subroutine read_config

  !> Bind the 62-entry field-descriptor table (ABI schema order) to `cfg`.
  !! Entry order IS the externally visible schema index — append only.
  function build_field_table(cfg) result(fields)
    type(config_t), intent(inout), target :: cfg
    type(field_desc_t) :: fields(n_config_fields)

    ! -- grid (checks conditional on grid_type: kept in validate_config) --
    fields(1) = field_meta('n_cell', 'grid', cfg_kind_int, 'Number of grid cells', &
                           has_min=.true., min_value=1.0_wp, has_max=.true., max_value=100000.0_wp, &
                           check_in_validate=.false.)
    fields(1) % iptr => cfg % n_cell
    fields(2) = field_meta('x_left', 'grid', cfg_kind_real, 'Left boundary coordinate')
    fields(2) % rptr => cfg % x_left
    fields(3) = field_meta('x_right', 'grid', cfg_kind_real, 'Right boundary coordinate')
    fields(3) % rptr => cfg % x_right

    fields(4) = field_meta('dt', 'time_ctrl', cfg_kind_real, 'Fixed time step', &
                           has_min=.true., min_exclusive=.true., min_value=0.0_wp, &
                           msg_min='config: dt must be positive')
    fields(4) % rptr => cfg % dt
    fields(5) = field_meta('time_start', 'time_ctrl', cfg_kind_real, 'Simulation start time')
    fields(5) % rptr => cfg % time_start
    fields(6) = field_meta('time_stop', 'time_ctrl', cfg_kind_real, 'Simulation stop time')
    fields(6) % rptr => cfg % time_stop
    fields(7) = field_meta('cfl', 'time_ctrl', cfg_kind_real, 'CFL number', &
                           has_min=.true., min_value=0.0_wp, msg_min='config: cfl must be >= 0')
    fields(7) % rptr => cfg % cfl
    fields(8) = field_meta('lapack_solver', 'time_ctrl', cfg_kind_logical, &
                           'Use LAPACK banded solver for backward Euler')
    fields(8) % lptr => cfg % lapack_solver

    fields(9) = field_meta('gam', 'physics', cfg_kind_real, 'Ratio of specific heats', &
                           has_min=.true., min_value=1.001_wp, msg_min='config: gam must be >= 1.001')
    fields(9) % rptr => cfg % gam

    fields(10) = field_meta('flux_scheme', 'schemes', cfg_kind_choice, 'Numerical flux scheme', &
                            choices=flux_scheme_names, casefold=.true.)
    fields(10) % sptr => cfg % flux_scheme
    fields(11) = field_meta('recon_scheme', 'schemes', cfg_kind_choice, 'Spatial reconstruction scheme', &
                            choices=recon_scheme_names, casefold=.true.)
    fields(11) % sptr => cfg % recon_scheme
    fields(12) = field_meta('time_scheme', 'schemes', cfg_kind_choice, 'Time integration scheme', &
                            choices=time_scheme_names, casefold=.true.)
    fields(12) % sptr => cfg % time_scheme
    fields(13) = field_meta('char_proj', 'schemes', cfg_kind_choice, 'Characteristic projection mode', &
                            choices=char_proj_mode_names, choice_msg_style=choice_msg_one_of, casefold=.true.)
    fields(13) % sptr => cfg % char_proj
    fields(14) = field_meta('limiter', 'schemes', cfg_kind_choice, 'MUSCL limiter', &
                            choices=limiter_names, casefold=.true., check_in_validate=.false.)
    fields(14) % sptr => cfg % limiter
    fields(15) = field_meta('use_positivity_limiter', 'schemes', cfg_kind_logical, 'Enable positivity limiter')
    fields(15) % lptr => cfg % use_positivity_limiter
    fields(16) = field_meta('use_hybrid_recon', 'schemes', cfg_kind_logical, 'Enable hybrid reconstruction')
    fields(16) % lptr => cfg % use_hybrid_recon
    fields(17) = field_meta('hybrid_sensor', 'schemes', cfg_kind_choice, 'Hybrid shock sensor', &
                            choices=hybrid_sensor_names, choice_msg_style=choice_msg_one_of, casefold=.true.)
    fields(17) % sptr => cfg % hybrid_sensor
    fields(18) = field_meta('hybrid_sensor_threshold', 'schemes', cfg_kind_real, 'Hybrid sensor threshold')
    fields(18) % rptr => cfg % hybrid_sensor_threshold

    fields(19) = field_meta('problem_type', 'initial_condition', cfg_kind_choice, &
                            'Initial condition preset', choices=problem_type_names, casefold=.true.)
    fields(19) % sptr => cfg % problem_type
    fields(20) = field_meta('ic_file', 'initial_condition', cfg_kind_string, 'Path to IC data file')
    fields(20) % sptr => cfg % ic_file
    fields(21) = field_meta('ic_interp', 'initial_condition', cfg_kind_logical, &
                            'Interpolate IC file onto solver grid')
    fields(21) % lptr => cfg % ic_interp
    fields(22) = field_meta('ic_udf_src', 'initial_condition', cfg_kind_string, 'Path to IC UDF source')
    fields(22) % sptr => cfg % ic_udf_src
    fields(23) = field_meta('bc_left', 'initial_condition', cfg_kind_choice, 'Left boundary condition', &
                            choices=boundary_condition_names, casefold=.true.)
    fields(23) % sptr => cfg % bc_left
    fields(24) = field_meta('bc_right', 'initial_condition', cfg_kind_choice, 'Right boundary condition', &
                            choices=boundary_condition_names, casefold=.true.)
    fields(24) % sptr => cfg % bc_right
    fields(25) = field_meta('p_ref_left', 'initial_condition', cfg_kind_real, 'Left NRBC reference pressure')
    fields(25) % rptr => cfg % p_ref_left
    fields(26) = field_meta('p_ref_right', 'initial_condition', cfg_kind_real, 'Right NRBC reference pressure')
    fields(26) % rptr => cfg % p_ref_right
    fields(27) = field_meta('sigma_nrbc', 'initial_condition', cfg_kind_real, 'NRBC relaxation factor', &
                            has_min=.true., min_value=0.0_wp, has_max=.true., max_value=1.0_wp, &
                            msg_min='config: sigma_nrbc must be in [0, 1]', &
                            msg_max='config: sigma_nrbc must be in [0, 1]')
    fields(27) % rptr => cfg % sigma_nrbc
    fields(28) = field_meta('nrbc_mode', 'initial_condition', cfg_kind_choice, 'NRBC algorithm mode', &
                            choices=nrbc_mode_names, choice_msg_style=choice_msg_one_of, casefold=.true.)
    fields(28) % sptr => cfg % nrbc_mode
    fields(29) = field_meta('u_ref_left', 'initial_condition', cfg_kind_real, &
                            'Left characteristic NRBC reference velocity')
    fields(29) % rptr => cfg % u_ref_left
    fields(30) = field_meta('u_ref_right', 'initial_condition', cfg_kind_real, &
                            'Right characteristic NRBC reference velocity')
    fields(30) % rptr => cfg % u_ref_right
    fields(31) = field_meta('rho_ref_left', 'initial_condition', cfg_kind_real, &
                            'Left characteristic NRBC reference density')
    fields(31) % rptr => cfg % rho_ref_left
    fields(32) = field_meta('rho_ref_right', 'initial_condition', cfg_kind_real, &
                            'Right characteristic NRBC reference density')
    fields(32) % rptr => cfg % rho_ref_right
    fields(33) = field_meta('sigma_nrbc_entropy', 'initial_condition', cfg_kind_real, &
                            'Entropy-wave relaxation factor', &
                            has_min=.true., min_value=0.0_wp, has_max=.true., max_value=1.0_wp, &
                            msg_min='config: sigma_nrbc_entropy must be in [0, 1]', &
                            msg_max='config: sigma_nrbc_entropy must be in [0, 1]')
    fields(33) % rptr => cfg % sigma_nrbc_entropy
    ! Stagnation / back-pressure bounds are conditional on the active BC:
    ! published in the schema, enforced in validate_config's conditional block.
    fields(34) = field_meta('p_stag_left', 'initial_condition', cfg_kind_real, &
                            'Left subsonic inlet stagnation pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(34) % rptr => cfg % p_stag_left
    fields(35) = field_meta('rho_stag_left', 'initial_condition', cfg_kind_real, &
                            'Left subsonic inlet stagnation density', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(35) % rptr => cfg % rho_stag_left
    fields(36) = field_meta('p_stag_right', 'initial_condition', cfg_kind_real, &
                            'Right subsonic inlet stagnation pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(36) % rptr => cfg % p_stag_right
    fields(37) = field_meta('rho_stag_right', 'initial_condition', cfg_kind_real, &
                            'Right subsonic inlet stagnation density', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(37) % rptr => cfg % rho_stag_right
    fields(38) = field_meta('p_back_left', 'initial_condition', cfg_kind_real, &
                            'Left subsonic outlet back pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(38) % rptr => cfg % p_back_left
    fields(39) = field_meta('p_back_right', 'initial_condition', cfg_kind_real, &
                            'Right subsonic outlet back pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(39) % rptr => cfg % p_back_right
    fields(40) = field_meta('neumann_grad_left', 'initial_condition', cfg_kind_real3, &
                            'Left Neumann-gradient conserved-variable vector')
    fields(40) % r3ptr => cfg % neumann_grad_left
    fields(41) = field_meta('neumann_grad_right', 'initial_condition', cfg_kind_real3, &
                            'Right Neumann-gradient conserved-variable vector')
    fields(41) % r3ptr => cfg % neumann_grad_right
    ! rho/u/p left-right bounds are conditional on problem_type (skipped for
    ! from_file/udf): published, enforced in validate_config.
    fields(42) = field_meta('rho_left', 'initial_condition', cfg_kind_real, 'Left density', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(42) % rptr => cfg % rho_left
    fields(43) = field_meta('u_left', 'initial_condition', cfg_kind_real, 'Left velocity')
    fields(43) % rptr => cfg % u_left
    fields(44) = field_meta('p_left', 'initial_condition', cfg_kind_real, 'Left pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(44) % rptr => cfg % p_left
    fields(45) = field_meta('rho_right', 'initial_condition', cfg_kind_real, 'Right density', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(45) % rptr => cfg % rho_right
    fields(46) = field_meta('u_right', 'initial_condition', cfg_kind_real, 'Right velocity')
    fields(46) % rptr => cfg % u_right
    fields(47) = field_meta('p_right', 'initial_condition', cfg_kind_real, 'Right pressure', &
                            has_min=.true., min_value=0.0_wp, check_in_validate=.false.)
    fields(47) % rptr => cfg % p_right
    fields(48) = field_meta('x_diaphragm', 'initial_condition', cfg_kind_real, &
                            'Riemann problem diaphragm location')
    fields(48) % rptr => cfg % x_diaphragm

    fields(49) = field_meta('output_file', 'output', cfg_kind_string, 'Final result file path')
    fields(49) % sptr => cfg % output_file
    fields(50) = field_meta('print_freq', 'output', cfg_kind_int, 'Residual print interval', &
                            has_min=.true., min_value=1.0_wp, msg_min='config: print_freq must be positive')
    fields(50) % iptr => cfg % print_freq
    fields(51) = field_meta('do_timing', 'output', cfg_kind_logical, 'Enable detailed timing summary output')
    fields(51) % lptr => cfg % do_timing
    fields(52) = field_meta('verbosity', 'output', cfg_kind_int, 'Logger verbosity', &
                            has_min=.true., min_value=0.0_wp, has_max=.true., max_value=4.0_wp, &
                            msg_min='config: verbosity must be in [0, 4]', &
                            msg_max='config: verbosity must be in [0, 4]')
    fields(52) % iptr => cfg % verbosity
    fields(53) = field_meta('log_file', 'output', cfg_kind_string, 'Log file path')
    fields(53) % sptr => cfg % log_file
    fields(54) = field_meta('snapshot_freq', 'output', cfg_kind_int, 'Live snapshot interval', &
                            has_min=.true., min_value=0.0_wp, msg_min='config: snapshot_freq must be >= 0')
    fields(54) % iptr => cfg % snapshot_freq
    fields(55) = field_meta('snapshot_file', 'output', cfg_kind_string, 'Live snapshot file path')
    fields(55) % sptr => cfg % snapshot_file

    fields(56) = field_meta('checkpoint_freq', 'checkpoint', cfg_kind_int, 'Checkpoint interval', &
                            has_min=.true., min_value=0.0_wp, msg_min='config: checkpoint_freq must be >= 0')
    fields(56) % iptr => cfg % checkpoint_freq
    fields(57) = field_meta('checkpoint_file', 'checkpoint', cfg_kind_string, 'Checkpoint base filename')
    fields(57) % sptr => cfg % checkpoint_file
    fields(58) = field_meta('restart_file', 'checkpoint', cfg_kind_string, 'Checkpoint file to resume from')
    fields(58) % sptr => cfg % restart_file
    fields(59) = field_meta('max_iter', 'time_ctrl', cfg_kind_int, &
                            'Optional iteration cap; >0 exits the time loop early', &
                            has_min=.true., min_value=0.0_wp, msg_min='config: max_iter must be >= 0')
    fields(59) % iptr => cfg % max_iter

    ! grid_type is kind STRING in the published schema (not choice); its
    ! two-token check keeps its bespoke message in validate_config.
    fields(60) = field_meta('grid_type', 'grid', cfg_kind_string, &
                            "Grid type: 'uniform' or 'file'", casefold=.true.)
    fields(60) % sptr => cfg % grid_type
    fields(61) = field_meta('grid_file', 'grid', cfg_kind_string, &
                            "Path to node-coordinate file when grid_type='file'")
    fields(61) % sptr => cfg % grid_file

    fields(62) = field_meta('method', 'schemes', cfg_kind_choice, &
                            'Spatial discretization method (fdm or fvm)', &
                            choices=method_names, casefold=.true.)
    fields(62) % sptr => cfg % method
  end function build_field_table

  ! ---------------------------------------------------------------------------
  !> Validate that all namelist parameters are physically and numerically sensible.
  !!
  !! `normalize_fields` is called first so that scheme names are compared
  !! case-insensitively.  Three outcomes are possible:
  !!
  !! - **Error stop** (default): if validation fails and neither optional
  !!   argument is present, `error stop` is called with a descriptive message.
  !! - **Return flag + message**: if `is_valid` and/or `message` are present,
  !!   the result is returned in those arguments and execution continues.
  !! - **Silent pass**: if validation succeeds, `is_valid = .true.` (when
  !!   present) and `message` is set to an empty string (when present).
  !!
  !! @param cfg      Configuration to validate (normalised in-place).
  !! @param is_valid `.true.` on success, `.false.` on the first violation found.
  !! @param message  Human-readable description of the first violation, or `''`.
  subroutine validate_config(cfg, is_valid, message)
    type(config_t), intent(inout), target :: cfg
    logical, intent(out), optional :: is_valid
    character(len=*), intent(out), optional :: message

    type(field_desc_t) :: fields(n_config_fields)
    logical :: ok
    character(len=256) :: err

    fields = build_field_table(cfg)
    ! Table-driven replacement for the old normalize_config: lowercases every
    ! casefold-flagged selector field in place.
    call normalize_fields(fields)
    ! Unconditional per-field constraints (ranges + choice lists), table order.
    call validate_fields(fields, 'config', ok, err)

    if (ok) then
      ! --- Cross-field and conditional checks: KEPT VERBATIM from the old
      ! validate_config, in their original relative order. ---
      if (ok .and. trim(cfg % problem_type) == problem_from_file .and. len_trim(cfg % ic_file) == 0) then
        ok = .false.
        err = 'config: ic_file must be set when problem_type = "from_file"'
      end if
      if (ok .and. trim(cfg % problem_type) == problem_udf .and. len_trim(cfg % ic_udf_src) == 0) then
        ok = .false.
        err = 'config: ic_udf_src must be set when problem_type = "udf"'
      end if

      if (ok .and. ((trim(cfg % bc_left) == bc_periodic) .neqv. &
                    (trim(cfg % bc_right) == bc_periodic))) then
        ok = .false.
        err = 'config: bc_left and bc_right must both be periodic or neither'
      end if

      if (ok .and. trim(cfg % bc_left) == bc_subsonic_inlet .and. cfg % p_stag_left <= 0.0_wp) then
        ok = .false.
        err = 'config: p_stag_left must be positive for subsonic_inlet'
      end if
      if (ok .and. trim(cfg % bc_left) == bc_subsonic_inlet .and. cfg % rho_stag_left <= 0.0_wp) then
        ok = .false.
        err = 'config: rho_stag_left must be positive for subsonic_inlet'
      end if
      if (ok .and. trim(cfg % bc_right) == bc_subsonic_inlet .and. cfg % p_stag_right <= 0.0_wp) then
        ok = .false.
        err = 'config: p_stag_right must be positive for subsonic_inlet'
      end if
      if (ok .and. trim(cfg % bc_right) == bc_subsonic_inlet .and. cfg % rho_stag_right <= 0.0_wp) then
        ok = .false.
        err = 'config: rho_stag_right must be positive for subsonic_inlet'
      end if
      if (ok .and. trim(cfg % bc_left) == bc_subsonic_outlet .and. cfg % p_back_left <= 0.0_wp) then
        ok = .false.
        err = 'config: p_back_left must be positive for subsonic_outlet'
      end if
      if (ok .and. trim(cfg % bc_right) == bc_subsonic_outlet .and. cfg % p_back_right <= 0.0_wp) then
        ok = .false.
        err = 'config: p_back_right must be positive for subsonic_outlet'
      end if
      if (ok .and. trim(cfg % grid_type) /= 'uniform' .and. trim(cfg % grid_type) /= 'file') then
        ok = .false.
        err = 'config: grid_type must be ''uniform'' or ''file'''
      end if

      ! FVM now supports non-uniform grids: a grid_type='file' run interprets the
      ! grid_file nodes as cell FACES (n faces -> n-1 cells) and builds a
      ! cell-centered non-uniform mesh (see build_mesh_cellcentered_global).  Only
      ! the IC source, not the grid spacing, restricts FVM — see the guard below.
      ! The file/UDF IC paths sample the nodal mesh (x_node) directly, which the FVM
      ! path never allocates (it builds only the cell-centered mesh).  Reject the
      ! combination so a valid-looking namelist cannot dereference an unallocated
      ! array; these problem types stay nodal-only this increment.
      if (ok .and. trim(cfg % method) == method_fvm .and. &
          (trim(cfg % problem_type) == problem_from_file .or. &
           trim(cfg % problem_type) == problem_udf)) then
        ok = .false.
        err = 'config: FVM does not support problem_type='//trim(cfg % problem_type)// &
              ' (from_file/udf are nodal-only this increment)'
      end if
      if (ok .and. trim(cfg % grid_type) == 'file') then
        block
          real(wp), allocatable :: coords(:)
          integer :: n
          logical :: rok
          character(len=256) :: rmsg
          if (len_trim(cfg % grid_file) == 0) then
            ok = .false.
            err = 'config: grid_type=file requires a non-empty grid_file'
          else
            call read_node_coords(cfg % grid_file, coords, n, rok, rmsg)
            if (.not. rok) then
              ok = .false.
              err = rmsg
            else
              ! Derive grid dimensions from the file so the config is self-consistent
              ! immediately after validate_config returns (not only after init_from_config).
              cfg % n_cell = n - 1
              cfg % x_left = coords(1)
              cfg % x_right = coords(n)
            end if
          end if
        end block
      else if (ok) then
        ! Existing uniform-grid checks (only meaningful when not file-driven).
        if (cfg % n_cell <= 0) then
          ok = .false.
          err = 'config: n_cell must be positive'
        end if
        ! Schema-published upper bound (config_schema.f90 ensure_schema, entry 1).
        if (ok .and. cfg % n_cell > 100000) then
          ok = .false.
          err = 'config: n_cell must be <= 100000'
        end if
        if (ok .and. cfg % x_right <= cfg % x_left) then
          ok = .false.
          err = 'config: x_right must be greater than x_left'
        end if
      end if
      if (ok .and. cfg % time_stop <= cfg % time_start) then
        ok = .false.
        err = 'config: time_stop must be greater than time_start'
      end if
      if (ok .and. (cfg % x_diaphragm < cfg % x_left .or. cfg % x_diaphragm > cfg % x_right)) then
        ok = .false.
        err = 'config: x_diaphragm must lie within [x_left, x_right]'
      end if
      if (ok .and. trim(cfg % problem_type) /= problem_from_file .and. trim(cfg % problem_type) /= problem_udf) then
        if (cfg % rho_left <= 0.0_wp .or. cfg % rho_right <= 0.0_wp) then
          ok = .false.
          err = 'config: density must be positive'
        else if (cfg % p_left <= 0.0_wp .or. cfg % p_right <= 0.0_wp) then
          ok = .false.
          err = 'config: pressure must be positive'
        end if
      end if

      if (ok) then
        block
          character(len=8) :: fmt_tokens(max_formats)
          integer :: n_fmt
          logical :: fmt_ok
          character(len=256) :: fmt_msg
          call parse_format_list(lowercase_token(cfg % output_format), &
                                 [character(len=8) :: 'dat', 'tec', 'plt'], &
                                 fmt_tokens, n_fmt, fmt_ok, fmt_msg)
          if (.not. fmt_ok) then
            ok = .false.
            err = 'config: '//trim(fmt_msg)
          end if
        end block
      end if

      if (ok .and. trim(cfg % method) == method_fvm .and. &
          .not. is_fds_flux_scheme(cfg % flux_scheme)) then
        ok = .false.
        err = 'config: FVM supports FDS/Riemann fluxes only (got '// &
              trim(cfg % flux_scheme)//')'
      end if

      if (ok .and. trim(cfg % recon_scheme) == recon_muscl .and. .not. is_valid_limiter(cfg % limiter)) then
        ok = .false.
        err = 'config: unknown limiter "'//trim(cfg % limiter)// &
              '" for MUSCL; valid: '//trim(join_token_list(limiter_names))
      end if
    end if

    if (present(is_valid)) is_valid = ok
    if (present(message)) message = trim(err)
    if (.not. ok .and. .not. present(is_valid) .and. .not. present(message)) then
      error stop trim(err)
    end if
  end subroutine validate_config

end module config