config_schema.f90 Source File


Source Code

!> @file config_schema.f90
!> @brief Runtime-queryable schema and typed accessors for `config_t`.
!!
!! This module exposes the solver-owned metadata needed by adapters:
!! parameter names, groups, value kinds, bounds, and choice lists.  It also
!! provides typed getters/setters so higher-level integrations do not need to
!! know the layout of `config_t`.
module config_schema
  use precision, only: wp
  use config, only: config_t
  use option_registry, only: 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
  implicit none
  private

  !> Schema kind tag for integer-valued parameters.
  integer, parameter, public :: cfg_kind_int = 1
  !> Schema kind tag for scalar real-valued parameters.
  integer, parameter, public :: cfg_kind_real = 2
  !> Schema kind tag for logical `.true.` / `.false.` parameters.
  integer, parameter, public :: cfg_kind_logical = 3
  !> Schema kind tag for string parameters constrained to a published choice list.
  integer, parameter, public :: cfg_kind_choice = 4
  !> Schema kind tag for free-form string parameters such as file paths.
  integer, parameter, public :: cfg_kind_string = 5
  !> Schema kind tag for fixed-length real vectors of length 3.
  integer, parameter, public :: cfg_kind_real3 = 6

  integer, parameter :: choice_none = 0
  integer, parameter :: choice_flux = 1
  integer, parameter :: choice_recon = 2
  integer, parameter :: choice_time = 3
  integer, parameter :: choice_limiter = 4
  integer, parameter :: choice_problem = 5
  integer, parameter :: choice_bc = 6
  integer, parameter :: choice_char_proj = 7
  integer, parameter :: choice_nrbc_mode = 8
  integer, parameter :: choice_hybrid_sensor = 9
  integer, parameter :: n_schema_entries = 58

  !> Metadata published for one runtime-queryable configuration key.
  !!
  !! This structure is the authoritative schema record surfaced through the
  !! Fortran session layer, the C ABI, and the Python binding.  The table order
  !! is stable and intentionally exposed through 1-based schema indices.
  type, public :: config_schema_entry_t
    character(len=32) :: key = ''          !< Canonical lowercase parameter name.
    character(len=32) :: group = ''        !< Namelist group such as `grid` or `output`.
    integer :: value_kind = 0              !< One of the `cfg_kind_*` tags above.
    character(len=256) :: help = ''        !< Short user-facing help text.
    logical :: has_min = .false.           !< True when `min_value` is meaningful.
    real(wp) :: min_value = 0.0_wp         !< Inclusive lower bound when present.
    logical :: has_max = .false.           !< True when `max_value` is meaningful.
    real(wp) :: max_value = 0.0_wp         !< Inclusive upper bound when present.
    integer :: choice_set = choice_none    !< Internal choice-list selector for enum-like strings.
  end type config_schema_entry_t

  type(config_schema_entry_t), save :: schema_entries(n_schema_entries) !< Lazily initialised schema table.
  logical, save :: schema_ready = .false. !< Guards one-time table population.

  public :: config_schema_count, get_config_schema_entry, find_config_schema_entry
  public :: config_schema_choice_count, get_config_schema_choice
  public :: config_get_integer, config_get_real, config_get_logical
  public :: config_get_string, config_get_real3
  public :: config_set_integer, config_set_real, config_set_logical
  public :: config_set_string, config_set_real3
  public :: config_default_integer, config_default_real, config_default_logical
  public :: config_default_string, config_default_real3

contains

  !> Return the number of published schema entries.
  integer function config_schema_count() result(count)
    call ensure_schema()
    count = n_schema_entries
  end function config_schema_count

  !> Find a schema entry by key and return its 1-based index.
  !!
  !! Returns `0` when the key is not part of the published schema.
  integer function find_config_schema_entry(key) result(index)
    character(len=*), intent(in) :: key
    integer :: i
    character(len=32) :: normalized

    call ensure_schema()
    normalized = normalize_key(key)
    index = 0
    do i = 1, n_schema_entries
      if (trim(schema_entries(i) % key) == trim(normalized)) then
        index = i
        return
      end if
    end do
  end function find_config_schema_entry

  !> Copy one schema entry by its 1-based index.
  !!
  !! Invalid indices leave `entry` reset to defaults and return `found = .false.`.
  subroutine get_config_schema_entry(index, entry, found)
    integer, intent(in) :: index
    type(config_schema_entry_t), intent(out) :: entry
    logical, intent(out) :: found

    call ensure_schema()
    if (index < 1 .or. index > n_schema_entries) then
      found = .false.
      entry = config_schema_entry_t()
      return
    end if

    found = .true.
    entry = schema_entries(index)
  end subroutine get_config_schema_entry

  !> Return the number of allowed choices for a choice-valued schema entry.
  !!
  !! Non-choice entries, or out-of-range indices, report `0`.
  integer function config_schema_choice_count(index) result(count)
    integer, intent(in) :: index
    type(config_schema_entry_t) :: entry
    logical :: found

    call get_config_schema_entry(index, entry, found)
    if (.not. found) then
      count = 0
      return
    end if

    select case (entry % choice_set)
    case (choice_flux)
      count = size(flux_scheme_names)
    case (choice_recon)
      count = size(recon_scheme_names)
    case (choice_time)
      count = size(time_scheme_names)
    case (choice_limiter)
      count = size(limiter_names)
    case (choice_problem)
      count = size(problem_type_names)
    case (choice_bc)
      count = size(boundary_condition_names)
    case (choice_char_proj)
      count = size(char_proj_mode_names)
    case (choice_nrbc_mode)
      count = size(nrbc_mode_names)
    case (choice_hybrid_sensor)
      count = size(hybrid_sensor_names)
    case default
      count = 0
    end select
  end function config_schema_choice_count

  !> Copy one allowed string token from a schema entry's choice list.
  !!
  !! `choice_index` is 1-based to match the ABI-facing schema table order.
  subroutine get_config_schema_choice(index, choice_index, value, found)
    integer, intent(in) :: index
    integer, intent(in) :: choice_index
    character(len=*), intent(out) :: value
    logical, intent(out) :: found
    type(config_schema_entry_t) :: entry
    logical :: entry_found

    call get_config_schema_entry(index, entry, entry_found)
    value = ''
    if (.not. entry_found) then
      found = .false.
      return
    end if

    select case (entry % choice_set)
    case (choice_flux)
      call copy_choice(flux_scheme_names, choice_index, value, found)
    case (choice_recon)
      call copy_choice(recon_scheme_names, choice_index, value, found)
    case (choice_time)
      call copy_choice(time_scheme_names, choice_index, value, found)
    case (choice_limiter)
      call copy_choice(limiter_names, choice_index, value, found)
    case (choice_problem)
      call copy_choice(problem_type_names, choice_index, value, found)
    case (choice_bc)
      call copy_choice(boundary_condition_names, choice_index, value, found)
    case (choice_char_proj)
      call copy_choice(char_proj_mode_names, choice_index, value, found)
    case (choice_nrbc_mode)
      call copy_choice(nrbc_mode_names, choice_index, value, found)
    case (choice_hybrid_sensor)
      call copy_choice(hybrid_sensor_names, choice_index, value, found)
    case default
      found = .false.
    end select
  end subroutine get_config_schema_choice

  !> Read the compiled-in default integer value for a schema key.
  subroutine config_default_integer(key, value, is_ok, message)
    character(len=*), intent(in) :: key
    integer, intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    type(config_t) :: defaults

    call config_get_integer(defaults, key, value, is_ok, message)
  end subroutine config_default_integer

  !> Read the compiled-in default scalar real value for a schema key.
  subroutine config_default_real(key, value, is_ok, message)
    character(len=*), intent(in) :: key
    real(wp), intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    type(config_t) :: defaults

    call config_get_real(defaults, key, value, is_ok, message)
  end subroutine config_default_real

  !> Read the compiled-in default logical value for a schema key.
  subroutine config_default_logical(key, value, is_ok, message)
    character(len=*), intent(in) :: key
    logical, intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    type(config_t) :: defaults

    call config_get_logical(defaults, key, value, is_ok, message)
  end subroutine config_default_logical

  !> Read the compiled-in default string or choice token for a schema key.
  subroutine config_default_string(key, value, is_ok, message)
    character(len=*), intent(in) :: key
    character(len=*), intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    type(config_t) :: defaults

    call config_get_string(defaults, key, value, is_ok, message)
  end subroutine config_default_string

  !> Read the compiled-in default length-3 real vector for a schema key.
  subroutine config_default_real3(key, value, is_ok, message)
    character(len=*), intent(in) :: key
    real(wp), intent(out) :: value(3)
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    type(config_t) :: defaults

    call config_get_real3(defaults, key, value, is_ok, message)
  end subroutine config_default_real3

  !> Read one integer-valued field from `cfg` by schema key.
  subroutine config_get_integer(cfg, key, value, is_ok, message)
    type(config_t), intent(in) :: cfg
    character(len=*), intent(in) :: key
    integer, intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    value = 0
    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('n_cell')
      value = cfg % n_cell
    case ('print_freq')
      value = cfg % print_freq
    case ('verbosity')
      value = cfg % verbosity
    case ('snapshot_freq')
      value = cfg % snapshot_freq
    case ('checkpoint_freq')
      value = cfg % checkpoint_freq
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not an integer parameter', is_ok, message)
    end select
  end subroutine config_get_integer

  !> Read one scalar real-valued field from `cfg` by schema key.
  subroutine config_get_real(cfg, key, value, is_ok, message)
    type(config_t), intent(in) :: cfg
    character(len=*), intent(in) :: key
    real(wp), intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    value = 0.0_wp
    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('x_left')
      value = cfg % x_left
    case ('x_right')
      value = cfg % x_right
    case ('dt')
      value = cfg % dt
    case ('time_start')
      value = cfg % time_start
    case ('time_stop')
      value = cfg % time_stop
    case ('cfl')
      value = cfg % cfl
    case ('gam')
      value = cfg % gam
    case ('hybrid_sensor_threshold')
      value = cfg % hybrid_sensor_threshold
    case ('p_ref_left')
      value = cfg % p_ref_left
    case ('p_ref_right')
      value = cfg % p_ref_right
    case ('sigma_nrbc')
      value = cfg % sigma_nrbc
    case ('u_ref_left')
      value = cfg % u_ref_left
    case ('u_ref_right')
      value = cfg % u_ref_right
    case ('rho_ref_left')
      value = cfg % rho_ref_left
    case ('rho_ref_right')
      value = cfg % rho_ref_right
    case ('sigma_nrbc_entropy')
      value = cfg % sigma_nrbc_entropy
    case ('p_stag_left')
      value = cfg % p_stag_left
    case ('rho_stag_left')
      value = cfg % rho_stag_left
    case ('p_stag_right')
      value = cfg % p_stag_right
    case ('rho_stag_right')
      value = cfg % rho_stag_right
    case ('p_back_left')
      value = cfg % p_back_left
    case ('p_back_right')
      value = cfg % p_back_right
    case ('rho_left')
      value = cfg % rho_left
    case ('u_left')
      value = cfg % u_left
    case ('p_left')
      value = cfg % p_left
    case ('rho_right')
      value = cfg % rho_right
    case ('u_right')
      value = cfg % u_right
    case ('p_right')
      value = cfg % p_right
    case ('x_diaphragm')
      value = cfg % x_diaphragm
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a real scalar parameter', is_ok, message)
    end select
  end subroutine config_get_real

  !> Read one logical field from `cfg` by schema key.
  subroutine config_get_logical(cfg, key, value, is_ok, message)
    type(config_t), intent(in) :: cfg
    character(len=*), intent(in) :: key
    logical, intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    value = .false.
    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('lapack_solver')
      value = cfg % lapack_solver
    case ('use_positivity_limiter')
      value = cfg % use_positivity_limiter
    case ('use_hybrid_recon')
      value = cfg % use_hybrid_recon
    case ('ic_interp')
      value = cfg % ic_interp
    case ('do_timing')
      value = cfg % do_timing
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a logical parameter', is_ok, message)
    end select
  end subroutine config_get_logical

  !> Read one string or choice token from `cfg` by schema key.
  subroutine config_get_string(cfg, key, value, is_ok, message)
    type(config_t), intent(in) :: cfg
    character(len=*), intent(in) :: key
    character(len=*), intent(out) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    value = ''
    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('flux_scheme')
      value = trim(cfg % flux_scheme)
    case ('recon_scheme')
      value = trim(cfg % recon_scheme)
    case ('time_scheme')
      value = trim(cfg % time_scheme)
    case ('char_proj')
      value = trim(cfg % char_proj)
    case ('limiter')
      value = trim(cfg % limiter)
    case ('hybrid_sensor')
      value = trim(cfg % hybrid_sensor)
    case ('problem_type')
      value = trim(cfg % problem_type)
    case ('ic_file')
      value = trim(cfg % ic_file)
    case ('ic_udf_src')
      value = trim(cfg % ic_udf_src)
    case ('bc_left')
      value = trim(cfg % bc_left)
    case ('bc_right')
      value = trim(cfg % bc_right)
    case ('nrbc_mode')
      value = trim(cfg % nrbc_mode)
    case ('output_file')
      value = trim(cfg % output_file)
    case ('log_file')
      value = trim(cfg % log_file)
    case ('snapshot_file')
      value = trim(cfg % snapshot_file)
    case ('checkpoint_file')
      value = trim(cfg % checkpoint_file)
    case ('restart_file')
      value = trim(cfg % restart_file)
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a string/choice parameter', is_ok, message)
    end select
  end subroutine config_get_string

  !> Read one fixed-length real-3 field from `cfg` by schema key.
  subroutine config_get_real3(cfg, key, value, is_ok, message)
    type(config_t), intent(in) :: cfg
    character(len=*), intent(in) :: key
    real(wp), intent(out) :: value(3)
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    value = 0.0_wp
    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('neumann_grad_left')
      value = cfg % neumann_grad_left
    case ('neumann_grad_right')
      value = cfg % neumann_grad_right
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a 3-vector parameter', is_ok, message)
    end select
  end subroutine config_get_real3

  !> Write one integer-valued field in `cfg` by schema key.
  !!
  !! This is a typed field assignment helper; cross-field validation still
  !! happens separately through `validate_config`.
  subroutine config_set_integer(cfg, key, value, is_ok, message)
    type(config_t), intent(inout) :: cfg
    character(len=*), intent(in) :: key
    integer, intent(in) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('n_cell')
      cfg % n_cell = value
    case ('print_freq')
      cfg % print_freq = value
    case ('verbosity')
      cfg % verbosity = value
    case ('snapshot_freq')
      cfg % snapshot_freq = value
    case ('checkpoint_freq')
      cfg % checkpoint_freq = value
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not an integer parameter', is_ok, message)
    end select
  end subroutine config_set_integer

  !> Write one scalar real-valued field in `cfg` by schema key.
  subroutine config_set_real(cfg, key, value, is_ok, message)
    type(config_t), intent(inout) :: cfg
    character(len=*), intent(in) :: key
    real(wp), intent(in) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('x_left')
      cfg % x_left = value
    case ('x_right')
      cfg % x_right = value
    case ('dt')
      cfg % dt = value
    case ('time_start')
      cfg % time_start = value
    case ('time_stop')
      cfg % time_stop = value
    case ('cfl')
      cfg % cfl = value
    case ('gam')
      cfg % gam = value
    case ('hybrid_sensor_threshold')
      cfg % hybrid_sensor_threshold = value
    case ('p_ref_left')
      cfg % p_ref_left = value
    case ('p_ref_right')
      cfg % p_ref_right = value
    case ('sigma_nrbc')
      cfg % sigma_nrbc = value
    case ('u_ref_left')
      cfg % u_ref_left = value
    case ('u_ref_right')
      cfg % u_ref_right = value
    case ('rho_ref_left')
      cfg % rho_ref_left = value
    case ('rho_ref_right')
      cfg % rho_ref_right = value
    case ('sigma_nrbc_entropy')
      cfg % sigma_nrbc_entropy = value
    case ('p_stag_left')
      cfg % p_stag_left = value
    case ('rho_stag_left')
      cfg % rho_stag_left = value
    case ('p_stag_right')
      cfg % p_stag_right = value
    case ('rho_stag_right')
      cfg % rho_stag_right = value
    case ('p_back_left')
      cfg % p_back_left = value
    case ('p_back_right')
      cfg % p_back_right = value
    case ('rho_left')
      cfg % rho_left = value
    case ('u_left')
      cfg % u_left = value
    case ('p_left')
      cfg % p_left = value
    case ('rho_right')
      cfg % rho_right = value
    case ('u_right')
      cfg % u_right = value
    case ('p_right')
      cfg % p_right = value
    case ('x_diaphragm')
      cfg % x_diaphragm = value
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a real scalar parameter', is_ok, message)
    end select
  end subroutine config_set_real

  !> Write one logical field in `cfg` by schema key.
  subroutine config_set_logical(cfg, key, value, is_ok, message)
    type(config_t), intent(inout) :: cfg
    character(len=*), intent(in) :: key
    logical, intent(in) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('lapack_solver')
      cfg % lapack_solver = value
    case ('use_positivity_limiter')
      cfg % use_positivity_limiter = value
    case ('use_hybrid_recon')
      cfg % use_hybrid_recon = value
    case ('ic_interp')
      cfg % ic_interp = value
    case ('do_timing')
      cfg % do_timing = value
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a logical parameter', is_ok, message)
    end select
  end subroutine config_set_logical

  !> Write one string or choice token in `cfg` by schema key.
  subroutine config_set_string(cfg, key, value, is_ok, message)
    type(config_t), intent(inout) :: cfg
    character(len=*), intent(in) :: key
    character(len=*), intent(in) :: value
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('flux_scheme')
      cfg % flux_scheme = value
    case ('recon_scheme')
      cfg % recon_scheme = value
    case ('time_scheme')
      cfg % time_scheme = value
    case ('char_proj')
      cfg % char_proj = value
    case ('limiter')
      cfg % limiter = value
    case ('hybrid_sensor')
      cfg % hybrid_sensor = value
    case ('problem_type')
      cfg % problem_type = value
    case ('ic_file')
      cfg % ic_file = value
    case ('ic_udf_src')
      cfg % ic_udf_src = value
    case ('bc_left')
      cfg % bc_left = value
    case ('bc_right')
      cfg % bc_right = value
    case ('nrbc_mode')
      cfg % nrbc_mode = value
    case ('output_file')
      cfg % output_file = value
    case ('log_file')
      cfg % log_file = value
    case ('snapshot_file')
      cfg % snapshot_file = value
    case ('checkpoint_file')
      cfg % checkpoint_file = value
    case ('restart_file')
      cfg % restart_file = value
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a string/choice parameter', is_ok, message)
    end select
  end subroutine config_set_string

  !> Write one fixed-length real-3 field in `cfg` by schema key.
  subroutine config_set_real3(cfg, key, value, is_ok, message)
    type(config_t), intent(inout) :: cfg
    character(len=*), intent(in) :: key
    real(wp), intent(in) :: value(3)
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message
    character(len=32) :: normalized

    call set_status(.true., '', is_ok, message)
    normalized = normalize_key(key)

    select case (trim(normalized))
    case ('neumann_grad_left')
      cfg % neumann_grad_left = value
    case ('neumann_grad_right')
      cfg % neumann_grad_right = value
    case default
      call set_status(.false., 'config_schema: "'//trim(normalized)//'" is not a 3-vector parameter', is_ok, message)
    end select
  end subroutine config_set_real3

  !> Populate the schema table on first use.
  !!
  !! The insertion order becomes the externally visible schema index, so new
  !! entries should be appended intentionally to avoid reshuffling adapters.
  subroutine ensure_schema()
    if (schema_ready) return

    ! Keep groups clustered for discoverability in generated docs and UI forms.
    call set_entry(1, '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)
    call set_entry(2, 'x_left', 'grid', cfg_kind_real, 'Left boundary coordinate')
    call set_entry(3, 'x_right', 'grid', cfg_kind_real, 'Right boundary coordinate')

    call set_entry(4, 'dt', 'time_ctrl', cfg_kind_real, 'Fixed time step', has_min=.true., min_value=0.0_wp)
    call set_entry(5, 'time_start', 'time_ctrl', cfg_kind_real, 'Simulation start time')
    call set_entry(6, 'time_stop', 'time_ctrl', cfg_kind_real, 'Simulation stop time')
    call set_entry(7, 'cfl', 'time_ctrl', cfg_kind_real, 'CFL number', has_min=.true., min_value=0.0_wp)
    call set_entry(8, 'lapack_solver', 'time_ctrl', cfg_kind_logical, 'Use LAPACK banded solver for backward Euler')

    call set_entry(9, 'gam', 'physics', cfg_kind_real, 'Ratio of specific heats', has_min=.true., min_value=1.001_wp)

    call set_entry(10, 'flux_scheme', 'schemes', cfg_kind_choice, 'Numerical flux scheme', choice_set=choice_flux)
    call set_entry(11, 'recon_scheme', 'schemes', cfg_kind_choice, 'Spatial reconstruction scheme', choice_set=choice_recon)
    call set_entry(12, 'time_scheme', 'schemes', cfg_kind_choice, 'Time integration scheme', choice_set=choice_time)
    call set_entry(13, 'char_proj', 'schemes', cfg_kind_choice, 'Characteristic projection mode', choice_set=choice_char_proj)
    call set_entry(14, 'limiter', 'schemes', cfg_kind_choice, 'MUSCL limiter', choice_set=choice_limiter)
    call set_entry(15, 'use_positivity_limiter', 'schemes', cfg_kind_logical, 'Enable positivity limiter')
    call set_entry(16, 'use_hybrid_recon', 'schemes', cfg_kind_logical, 'Enable hybrid reconstruction')
    call set_entry(17, 'hybrid_sensor', 'schemes', cfg_kind_choice, 'Hybrid shock sensor', choice_set=choice_hybrid_sensor)
    call set_entry(18, 'hybrid_sensor_threshold', 'schemes', cfg_kind_real, 'Hybrid sensor threshold')

    call set_entry(19, 'problem_type', 'initial_condition', cfg_kind_choice, 'Initial condition preset', choice_set=choice_problem)
    call set_entry(20, 'ic_file', 'initial_condition', cfg_kind_string, 'Path to IC data file')
    call set_entry(21, 'ic_interp', 'initial_condition', cfg_kind_logical, 'Interpolate IC file onto solver grid')
    call set_entry(22, 'ic_udf_src', 'initial_condition', cfg_kind_string, 'Path to IC UDF source')
    call set_entry(23, 'bc_left', 'initial_condition', cfg_kind_choice, 'Left boundary condition', choice_set=choice_bc)
    call set_entry(24, 'bc_right', 'initial_condition', cfg_kind_choice, 'Right boundary condition', choice_set=choice_bc)
    call set_entry(25, 'p_ref_left', 'initial_condition', cfg_kind_real, 'Left NRBC reference pressure')
    call set_entry(26, 'p_ref_right', 'initial_condition', cfg_kind_real, 'Right NRBC reference pressure')
    call set_entry(27, '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)
    call set_entry(28, 'nrbc_mode', 'initial_condition', cfg_kind_choice, 'NRBC algorithm mode', choice_set=choice_nrbc_mode)
    call set_entry(29, 'u_ref_left', 'initial_condition', cfg_kind_real, 'Left characteristic NRBC reference velocity')
    call set_entry(30, 'u_ref_right', 'initial_condition', cfg_kind_real, 'Right characteristic NRBC reference velocity')
    call set_entry(31, 'rho_ref_left', 'initial_condition', cfg_kind_real, 'Left characteristic NRBC reference density')
    call set_entry(32, 'rho_ref_right', 'initial_condition', cfg_kind_real, 'Right characteristic NRBC reference density')
    call set_entry(33, '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)
    call set_entry(34, 'p_stag_left', 'initial_condition', cfg_kind_real, 'Left subsonic inlet stagnation pressure', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(35, 'rho_stag_left', 'initial_condition', cfg_kind_real, 'Left subsonic inlet stagnation density', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(36, 'p_stag_right', 'initial_condition', cfg_kind_real, 'Right subsonic inlet stagnation pressure', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(37, 'rho_stag_right', 'initial_condition', cfg_kind_real, 'Right subsonic inlet stagnation density', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(38, 'p_back_left', 'initial_condition', cfg_kind_real, 'Left subsonic outlet back pressure', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(39, 'p_back_right', 'initial_condition', cfg_kind_real, 'Right subsonic outlet back pressure', &
                   has_min=.true., min_value=0.0_wp)
    call set_entry(40, 'neumann_grad_left', 'initial_condition', cfg_kind_real3, &
                   'Left Neumann-gradient conserved-variable vector')
    call set_entry(41, 'neumann_grad_right', 'initial_condition', cfg_kind_real3, &
                   'Right Neumann-gradient conserved-variable vector')
    call set_entry(42, 'rho_left', 'initial_condition', cfg_kind_real, 'Left density', has_min=.true., min_value=0.0_wp)
    call set_entry(43, 'u_left', 'initial_condition', cfg_kind_real, 'Left velocity')
    call set_entry(44, 'p_left', 'initial_condition', cfg_kind_real, 'Left pressure', has_min=.true., min_value=0.0_wp)
    call set_entry(45, 'rho_right', 'initial_condition', cfg_kind_real, 'Right density', has_min=.true., min_value=0.0_wp)
    call set_entry(46, 'u_right', 'initial_condition', cfg_kind_real, 'Right velocity')
    call set_entry(47, 'p_right', 'initial_condition', cfg_kind_real, 'Right pressure', has_min=.true., min_value=0.0_wp)
    call set_entry(48, 'x_diaphragm', 'initial_condition', cfg_kind_real, 'Riemann problem diaphragm location')

    call set_entry(49, 'output_file', 'output', cfg_kind_string, 'Final result file path')
    call set_entry(50, 'print_freq', 'output', cfg_kind_int, 'Residual print interval', has_min=.true., min_value=1.0_wp)
    call set_entry(51, 'do_timing', 'output', cfg_kind_logical, 'Enable detailed timing summary output')
    call set_entry(52, 'verbosity', 'output', cfg_kind_int, 'Logger verbosity', has_min=.true., min_value=0.0_wp, &
                   has_max=.true., max_value=4.0_wp)
    call set_entry(53, 'log_file', 'output', cfg_kind_string, 'Log file path')
    call set_entry(54, 'snapshot_freq', 'output', cfg_kind_int, 'Live snapshot interval', has_min=.true., min_value=0.0_wp)
    call set_entry(55, 'snapshot_file', 'output', cfg_kind_string, 'Live snapshot file path')

    call set_entry(56, 'checkpoint_freq', 'checkpoint', cfg_kind_int, 'Checkpoint interval', has_min=.true., min_value=0.0_wp)
    call set_entry(57, 'checkpoint_file', 'checkpoint', cfg_kind_string, 'Checkpoint base filename')
    call set_entry(58, 'restart_file', 'checkpoint', cfg_kind_string, 'Checkpoint file to resume from')

    schema_ready = .true.
  end subroutine ensure_schema

  !> Fill one slot in the lazily initialised schema table.
  subroutine set_entry(index, key, group, value_kind, help, has_min, min_value, has_max, max_value, choice_set)
    integer, intent(in) :: index
    character(len=*), intent(in) :: key, group, help
    integer, intent(in) :: value_kind
    logical, intent(in), optional :: has_min, has_max
    real(wp), intent(in), optional :: min_value, max_value
    integer, intent(in), optional :: choice_set

    schema_entries(index) = config_schema_entry_t()
    schema_entries(index) % key = normalize_key(key)
    schema_entries(index) % group = group
    schema_entries(index) % value_kind = value_kind
    schema_entries(index) % help = help
    if (present(has_min)) schema_entries(index) % has_min = has_min
    if (present(min_value)) schema_entries(index) % min_value = min_value
    if (present(has_max)) schema_entries(index) % has_max = has_max
    if (present(max_value)) schema_entries(index) % max_value = max_value
    if (present(choice_set)) schema_entries(index) % choice_set = choice_set
  end subroutine set_entry

  !> Normalise a user-provided parameter key to lowercase trimmed form.
  pure function normalize_key(key) result(normalized)
    character(len=*), intent(in) :: key
    character(len=32) :: normalized
    integer :: i, code

    normalized = ''
    normalized = adjustl(trim(key))
    do i = 1, len_trim(normalized)
      code = iachar(normalized(i:i))
      if (code >= iachar('A') .and. code <= iachar('Z')) normalized(i:i) = achar(code + 32)
    end do
  end function normalize_key

  !> Copy one 1-based choice-list item into `value`.
  subroutine copy_choice(values, index, value, found)
    character(len=*), intent(in) :: values(:)
    integer, intent(in) :: index
    character(len=*), intent(out) :: value
    logical, intent(out) :: found

    value = ''
    if (index < 1 .or. index > size(values)) then
      found = .false.
      return
    end if

    found = .true.
    value = trim(values(index))
  end subroutine copy_choice

  !> Fan out a success flag and optional message to caller-provided outputs.
  subroutine set_status(ok, err, is_ok, message)
    logical, intent(in) :: ok
    character(len=*), intent(in) :: err
    logical, intent(out), optional :: is_ok
    character(len=*), intent(out), optional :: message

    if (present(is_ok)) is_ok = ok
    if (present(message)) message = trim(err)
  end subroutine set_status

end module config_schema