Kitchen sinkΒΆ

Here a full example of the configuration file is provided, with its fields and capabilities.

Note

In each models.BC.BC_map (and models.scalar_transports.<name>.BC.BC_map) entry, the structural keys (pos, system, BC, wall_normal, order) stay at the top level and the BC-specific parameters live under a nested params: field, validated at parse time against the selected BC. The entry is extra="forbid", so a misspelled or misplaced key is rejected on load; BCs that take no parameters omit params: entirely. Fluid and scalar entries share the same placement grammar: pos takes a cardinal alias, a boolean predicate over x, y, z or a typed shape, system names the domain.systems frame it is written in, and wall_normal is the explicit outward cardinal direction required by every scheme that reconstructs along the wall normal.

Note

A boundary condition, a models.volumetric_regions entry and a scalar source_regions entry all name their region of space with the same pos, which takes a cardinal alias, a boolean predicate or a typed shape (kind: point, points, line, plane, box, predicate). See the boundary-condition guide for the per-kind fields.

# ============================================================
# Nassu kitchensink configuration
# This file demonstrates every available configuration field.
# Use it as a reference when building your own .nassu.yaml.
# ============================================================

# Load parent configurations from other files before this one is parsed.
# Simulations defined in those files can be used as parents here.
# The path must be relative to your shell's current working directory,
# not to this file's location.
dependencies:
  # sim_cfg_files: ["path/to/dependent/simulation_config.yaml"]
  sim_cfg_files: [] # Empty list - no external dependencies

# Named values used throughout this file.
# Reference them with ${var_name} inside !sub and !math tags.
# Nested keys are accessed with dot notation: ${cfg_time.cst_step}
variables:
  # Special built-in variables injected by Nassu at load time:
  version: ${NASSU_VERSION} # x.y.z - running Nassu version string
  filepath: ${NASSU_FILE_PATH} # docs/source/user_guide/02_config/file/kitchensink.nassu.yaml
  foldername: ${NASSU_FILE_FOLDER} # docs/source/user_guide/02_config/file - directory containing this file

  base_folder: "examples/study_cases/high_rise_building" # Root output path

  domain_size: 96 # Base domain dimension in lattice nodes
  height_ref: 8 # Reference building height in lattice units

  # Nested variable maps - keys are accessed as ${cfg_time.develop_steps} etc.
  cfg_time:
    develop_steps: 5000 # Steps for flow to develop before statistics collection
    # !math with variable references: computes one convective time unit (H/U)
    cst_step: !math ${height_ref}/${u_ref[1]}
    cst_run: 100 # Number of convective time units to run after development
    # Multi-line !math expression - references other nested variables
    total_steps: !math ${cfg_time.develop_steps} + ${cfg_time.cst_step} * ${cfg_time.cst_run}

  # !math applied to a list - each element is evaluated independently
  u_ref: !math [0.05, 0.05*1.1] # Two reference velocities in lattice units
  angle_use: !math cos(pi*45/180) # 45-degree wind angle in radians
  nested_angle: !math cos(acos(pi/4)) # Nested trigonometric expression
  min_max: !math min(cos(acos(pi/4)), acos(pi/4)) # min() across two expressions

  # YAML anchor for a macroscopic rescale block - define once and reuse with *MACRS_RESCALE.
  # Converts lattice values to physical units for output: result = value * mul + cte
  macrs_rescale: &MACRS_RESCALE
    rho: {mul: 1e3, cte: 100}   # Multiply density by 1000 (lattice rho to kg/m3)
    ux: {mul: 100, cte: 0}   # Scale x-velocity to physical units
    uy: {mul: 100, cte: 0}   # Scale y-velocity to physical units
    uz: {mul: 100, cte: 0}   # Scale z-velocity to physical units

simulations:
  - name: highRiseBuildingZero # Unique simulation name within this file

    # False to skip this simulation without removing it from the file.
    # Defaults to true
    run_simul: true

    # Total number of LBM time steps to advance.
    n_steps: 50000

    # !sub substitutes ${var_name} references - use it for any string field
    # that needs variable interpolation.
    save_path: !sub "${base_folder}/resuts/high_rise_building"

    # Controls how often Nassu prints simulation progress to stdout.
    report:
      start_step: 0 # Step at which reporting begins (0 = from the start)
      end_step: 0 # Step at which reporting stops (0 = until the end)
      frequency: 1000 # Report simulation progress every 1000 steps
      warmup_steps: 0 # Initial steps excluded from the final full-run MLUPS
      # (performance warmup cutoff; 0 keeps the full-run average)

    # ----------------------------------------------------------------
    # Domain
    # ----------------------------------------------------------------
    domain:
      # Lattice node counts for the full domain box.
      # All dimensions must be divisible by block_size.
      domain_size:
        # !unroll expands this simulation into multiple variants, one per list
        # element. All !unroll lists in the same simulation block must have the
        # same length. Here two variants are produced: 96^3 and 192^3 domains.
        x: !unroll [!math "${domain_size}", !math "${domain_size}*2"]
        y: !unroll [!math "${domain_size}", !math "${domain_size}*2"]
        z: !unroll [!math "${domain_size}", !math "${domain_size}*2"]

      # Alternative to domain_size: size the level-0 domain by a PHYSICAL EXTENT
      # given in a coordinate system (see `systems` / `default_system` below). Each
      # axis is rounded UP to a block_size multiple (with a warning) and the realized
      # extent is reported in info.yaml. Provide either domain_size or domain_extent;
      # a matching domain_size alongside it (e.g. from a dumped config) is accepted.
      # domain_extent: [300.0, 200.0, 150.0] # in domain_extent_system coordinates
      # domain_extent_system: site            # defaults to default_system; non-rotated

      # Output coordinate frame: to write an export's coordinates in physical units,
      # declare a coordinate `system` below and select it per export via
      # `data.exports.<name>.system` (and per monitor via `system`). There is no
      # global output coordinate rescale.

      # Size of each lattice block in nodes per edge.
      # Changing this affects GPU shared-memory usage - leave as 8 unless advised.
      block_size: 8

      # Block-forest serialization. Build a refined grid once, save it,
      # and reuse it across runs to skip the (potentially expensive) re-refinement.
      #
      # block_forest_save: write the constructed forest (after the refinement program
      #   below runs) to a `.nassu-bf.json` file. Commented out here; uncomment to cache.
      # block_forest_save: !sub "${NASSU_FILE_FOLDER}/grids/cached.nassu-bf.json"
      #
      # block_forest_load: load the grid from a `.nassu-bf.json` instead of building it.
      #   The declared `domain_size`, `block_size` and periodic dimensions must match the
      #   file (mismatch is an error). Any `refinement.static` below is applied ON TOP of
      #   the loaded forest, so a cached grid can be reused and refined further.
      # block_forest_load: !sub "${NASSU_FILE_FOLDER}/grids/cached.nassu-bf.json"

      # Named coordinate systems. Each entry is a similarity
      # transform - isotropic `scale`, `rotation` (radians) and `translation`,
      # all about `fixed_point` (scale -> rotate -> translate, same convention as
      # a body `transformation`) - relative to a `parent` system (default "lbm").
      # Positioned objects (bodies, point clouds, refinement volumes) can
      # tag the frame their coordinates are given in via a `system` field; the
      # config layer composes the chain down to the base "lbm" lattice frame at
      # setup. The "lbm" name is reserved (the identity) and cannot be redefined.
      # `scale` is a single isotropic scalar because the lattice is cubic;
      # per-axis scale is rejected. A config that declares no systems keeps
      # everything in "lbm".
      systems:
        # A site frame in metres: 1 m -> 4 lattice nodes, origin shifted into
        # the domain. Parent defaults to "lbm".
        site:
          scale: 4.0
          translation: [16.0, 8.0, 0.0]
        # A building frame chained onto `site` (parent: site -> lbm): its own
        # offset and yaw are expressed in site (metre) coordinates and resolve
        # through `site` down to the lattice frame.
        building:
          parent: site
          scale: 1.0
          translation: [10.0, 6.0, 0.0]
          rotation: [0.0, 0.0, 0.7853981634] # 45 deg yaw about z
          fixed_point: [0.0, 0.0, 0.0]
      # Coordinate system used by positioned objects that do not set their own
      # `system`. Defaults to "lbm" (the base lattice frame).
      default_system: lbm

      # Static (non-adaptive) mesh refinement rules.
      # Each named entry under `static` is a user-chosen group name that
      # combines any subset of: volumes_refine, bodies, volume_refinement_limit,
      # volumes_not_refine. The names below are illustrative.
      refinement:
        static:
          # Refinement group: refine a rectangular volume and a body simultaneously
          volumes_body_refine:
            # List of axis-aligned boxes to refine to a given level.
            volumes_refine:
              - is_abs: true # true = coordinates in absolute lattice units;
                  # false = coordinates relative to domain size (0 to 1)
                lvl: 1 # Target refinement level for blocks in this box
                start: [0.0, 0.0, 0.0] # Lower corner [x, y, z]
                # !math on a list computes each element independently
                end: !math [32.0, 160.0, "${domain_size}/2"] # Upper corner [x, y, z]
                # Coordinate system the start/end corners are given in. Only
                # is_abs: true volumes may use a non-lbm system, and that system
                # must not contain a rotation (rotated/oriented refinement volumes
                # are not supported). Here the box is given in the metre `site`
                # frame and resolves to lattice coordinates at setup. Omit for "lbm".
                system: site

            # List of bodies whose bounding surfaces drive block refinement.
            bodies:
              - body_name: CAARC # Must match a key under domain.bodies
                lvl: 4 # Refinement level to apply around this body
                normal_offsets: [0] # Surface offset multipliers for refinement shell
                # Optional per-body transformation applied before refinement lookup
                transformation:
                  translation: [12.0, 0.0, 1.0] # Shift body for refinement only

          # Refinement group: limit the spatial extent that any refinement can reach
          limit_volume:
            bodies:
              - body_name: CAARC
                lvl: 6
                normal_offsets: [0]
            # Clamp all refinement (volume and body) to this bounding box.
            # No block outside this box is refined beyond level 0.
            volume_refinement_limit:
              is_abs: true
              start: [0, 10, 0]
              end: [40, 50, 150]
              # Like refinement volumes, the limit box may be given in a coordinate
              # system. Only is_abs: true, non-rotated systems are allowed (the
              # limit box carries no orientation). Omit for "lbm".
              system: site

          # Refinement group: suppress refinement inside specified volumes
          ignore_volumes:
            bodies:
              - body_name: CAARC
                lvl: 6
                # !range generates a numeric list: np.arange(start, end, step)
                # End is exclusive. Generates from -0.5 to 4.9, doesn't include 5
                normal_offsets: !range [-0.5, 5, 0.2]
              - {body_name: CAARC.surface_name, lvl: 4, normal_offsets: !math [1/4]}

            # Blocks fully inside any of these volumes are not refined.
            volumes_not_refine:
              - is_abs: true # Absolute lattice coordinates
                start: [0, 10, 0]
                end: [40, 50, 150]
                # Exclude volumes accept the same coordinate `system`;
                # is_abs: true, non-rotated only. Omit for "lbm".
                system: site
              - is_abs: false # Relative coordinates (fraction of domain size)
                start: [0, 0.15, 0.1]
                end: [0.1, 0.2, 0.3]

          # Refinement group: exhaustive example using all available sub-fields
          full_example:
            volumes_refine:
              - is_abs: true
                lvl: 1
                start: [0.0, 0.0, 0.0]
                end: [32.0, 160.0, 64.0]
              - is_abs: true
                lvl: 4
                start: [10, 20, 30]
                end: [32.0, 160.0, 64.0]
              # Oriented (rotated) volume: start/end define the box in a local frame
              # and `transformation` maps it into the domain, refining a tight rotated
              # box (e.g. aligned with an angled building). Requires is_abs: true when a
              # rotation is given.
              - is_abs: true
                lvl: 4
                start: [0, 0, 0]
                end: [40, 20, 60]
                transformation:
                  rotation: [0.0, 0.0, 0.7853981634] # 45 deg about z (radians)
                  translation: [60.0, 40.0, 0.0]
                  fixed_point: [20.0, 10.0, 30.0] # box centre
            bodies:
              - body_name: CAARC
                lvl: 6
                normal_offsets: [0]
            volume_refinement_limit:
              is_abs: true
              start: [0, 10, 0]
              end: [40, 50, 150]
            volumes_not_refine:
              - is_abs: true
                start: [0, 10, 0]
                end: [40, 50, 150]
              - is_abs: false
                start: [0, 0.15, 0.1]
                end: [0.1, 0.2, 0.3]

      # Point-cloud bodies defined by CSV files instead of .lnas geometry.
      # The CSV must contain columns: x, y, z, nx, ny, nz, area
      point_clouds:
        tree: # User-chosen name for this point-cloud body
          IBM:
            # True to activate IBM for this point cloud
            run: true
            # IBM config name from models.IBM.body_cfgs
            cfg_use: default
            # IBM force-spread order. Bodies with the same order are processed
            # together; lower order runs first.
            order: 1
            # Interval in time steps during which IBM is active.
            # end_step: 0 = run until the simulation ends; start_step: 0 = from step 0
            interval_run:
              end_step: 0
              start_step: 0
          csv_path: fixture/point_cloud/sphere_100.csv # Path to point-cloud CSV

      # STL/LNAS bodies for IBM. Each key is a user-chosen body name.
      # !not-inherit prevents this dict from merging with any parent simulation's
      # bodies field - the child's bodies list replaces the parent's entirely.
      bodies: !not-inherit
        CAARC: # User-chosen body name; referenced elsewhere by this key
          IBM:
            run: true # True to activate IBM for this body
            cfg_use: building_cfg # IBM config name from models.IBM.body_cfgs
            # Bodies with the same order are processed together;
            # lower order runs first
            order: 1
            # Interval in time steps during which IBM is active.
            # 0 means no limit is applied
            interval_run:
              end_step: 0
              start_step: 0
          # Band-only voxelization of the body surface as an alternative to IBM
          # for the wall BC. Attaches a velocity BC to the surface band nodes with
          # the wall normal snapped to the nearest cardinal.
          voxelization:
            run: false # True to voxelize this body's surface
            # Band velocity BC alias: 'RegularizedHWBB'
            BC: RegularizedHWBB
            order: 0 # Order to apply the band BC (less is first)
            # Voxel band radius. Default 1 seals every D3Q27 link crossing the
            # wall; also sets the hole tolerance (gaps narrower than the band heal)
            band_radius: 1
            # Raise on band nodes with no acceptable cardinal normal (concave
            # corners). Set False to skip such nodes instead.
            strict_normal: true
            # Scalar (advection-diffusion) BCs on the same band nodes, one entry
            # per scalar field, independent of the velocity BC.
            scalar_bcs:
              # Zero-flux (adiabatic / impermeable) wall for the scalar field
              # (regularized Neumann with zero prescribed flux).
              - scalar: temperature
                BC: ScalarRegularizedNeumann
                J_w: 0.0
                order: 0
              # Fixed-value (Dirichlet) emission patch: prescribed wall value
              # phi_w. The scalar imposes no velocity (the advection velocity
              # comes from the fluid). Also accepts 'ScalarRegularizedNeumann'
              # with 'J_w' for a fixed flux, or 'ScalarRegularizedRobin' with
              # 'h' and 'phi_inf' for a convective heat-loss wall.
              - scalar: smoke
                BC: ScalarRegularizedDirichlet
                phi_w: 1.0
          geometry_path: fixture/lnas/wind_tunnel/CAARC.lnas # Path to .lnas geometry file

          # Offsets (in lattice nodes) at which to add copies of this geometry,
          # displaced along surface normals. Produces layered IBM shells.
          # !range [-5, 1, 0.5] -> [-5.0, -4.5, -4.0, ..., 0.5]
          normal_offsets_add: !range [-5, 1, 0.5]

          # Height attributed to each surface triangle for volumetric algorithms.
          # Defaults to 1
          height_assume: 1

          # Whether the surface height scales with refinement level.
          # height_at_lvl = height_assume * 2^lvl
          # Defaults to false
          height_scales_per_lvl: false

          # How to handle triangles smaller than area.min.
          # Options are: "add" (keep them), "ignore" (discard them)
          # Defaults to "error" (raise an exception)
          small_triangles: "add" # | "ignore"

          # Triangle area bounds (at level 0). Triangles above area.max are
          # subdivided; triangles below area.min follow the small_triangles policy.
          # max should be at least 4Γ— min.
          # Defaults to { min: 0.25, max: 1 }
          area: {min: 0.25, max: 1}

          # Clip triangles outside these bounding boxes at each geometry stage.
          # Defaults to no limit (no triangles removed).
          volumes_limits:
            # Applied to raw geometry coordinates, before any transformation
            raw:
              - start: [10, 20, 0]
                end: [100, 100, 100]
            # Applied after this body's own transformation
            body_transformed:
              - start: [60, 40, 0]
                end: [100, 200, 100]
            # Applied after all transformations (including global_transformations)
            full_transformed:
              - start: [60, 40, 0]
                end: [100, 200, 100]
              - start: [100, 40, 50]
                end: [150, 140, 70]

          # Affine transformation applied to this body before it enters the domain.
          # Order: translate to fixed_point -> scale -> rotate -> translate -> revert.
          # rotation values are in radians.
          transformation:
            fixed_point: [0, 0, 0] # Pivot point for scale and rotation
            rotation: [0, 0, 0] # Rotation angles [rx, ry, rz] in radians
            scale: [1.0, 1.0, 1.0] # Per-axis scale factors
            translation: [12.0, 0.0, 1.0] # Final translation [x, y, z]

          # Coordinate system this body's coordinates are expressed in. The
          # body's own `transformation` above is applied first, then
          # the resolved system->lbm map. Here the geometry is positioned in the
          # metre `site` frame and composed down to the lattice at setup. Omit
          # (or set null) to use `domain.default_system`.
          system: site

          # Enable runtime motion for this body (basis for moving bodies / FSI).
          # When true, the kernels apply runtime_transformation
          # below to each node's reference position/normal in-kernel (the node
          # buffer is never moved), and the node->block mapping is recomputed
          # before the first interp/spread of each step. Leave false for static
          # bodies (the static path is unchanged). Single refinement level only.
          # Defaults to false
          movable: false
          # Runtime (current) body transformation, applied in-kernel to the IBM
          # Lagrangian nodes' positions and normals during the run, relative to
          # the loaded pose. Distinct from `transformation` above (baked in once
          # at load). Only takes effect when movable is true; the motion driver
          # writes this each step. Same fields as `transformation`;
          # defaults to identity.
          runtime_transformation:
            fixed_point: [0, 0, 0]
            rotation: [0, 0, 0]
            scale: [1.0, 1.0, 1.0]
            translation: [0.0, 0.0, 0.0]
          # Optional prescribed time-series motion. Keyframes of
          # (level-0 time step -> 4x4 pose), linearly interpolated in time and
          # clamped outside the range. When set, the body moves: its pose is
          # sampled at the current level-0 step each step (overriding
          # runtime_transformation) and the IBM no-slip target becomes the
          # boundary point velocity dx/dt (per-step finite difference, level-0
          # time). Setting `motion` implies `movable`. Omit for a static body.
          motion:
            keyframes:
              - time: 0 # level-0 step
                transformation:
                  translation: [0.0, 0.0, 0.0]
              - time: 5000
                transformation:
                  translation: [25.0, 0.0, 0.0] # 25 lattice nodes over 5000 steps

        cube_not_run: # Second body - IBM disabled for this one
          IBM:
            run: false # IBM is not computed for this body
            cfg_use: terrain_cfg # Config name is still declared (for reference)
          geometry_path: fixture/stl/basic/cube.stl
          transformation:
            fixed_point: [0, 0, 0]
            rotation: [0, 0, 0]
            scale: [1.0, 1.0, 1.0]
            translation: [12.0, 0.0, 1.0]

      # Clip all IBM nodes (bodies and point clouds) to this bounding box.
      # Nodes outside this volume are discarded from the IBM kernel.
      # Defaults to the full domain.
      bodies_domain_limits:
        start: [8.0, 4.0, 0.0]
        end: [448.0, 156.0, 48.0]
        is_abs: true # true = absolute lattice coordinates

      # Apply the same affine transformation to multiple bodies, point clouds,
      # and/or probe sets in one step. Transformations are applied after any
      # body-specific ones and in list order.
      global_transformations:
        - transformation:
            translation: [0, 0.5, 0] # [x, y, z] shift
            fixed_point: [608.90625, 64, 0] # Pivot for rotation and scale
            rotation: [0, 0, 3.141592654] # Rotate 180Β° around z-axis
            scale: [1, 1, 1]
          point_clouds_apply: [] # Point-cloud names to transform
          bodies_apply: ["CAARC"] # Body names to transform

          # Export paths to transform. Syntax:
          #   "volume.<export>"                    - all volume entities of an export (translate/scale only)
          #   "volume.<export>.<entity>"           - a specific volume entity's box
          #   "series.<export>"                    - all entities in a series export
          #   "series.<export>.lines"              - all lines in a series export
          #   "series.<export>.points.<name>"      - a specific point
          probes_apply: ["series.series1.lines", "series.series1.points.point1", "volume.field_volume.wake"]
          # True to also apply this transformation to body-type probes
          # (probes defined via a body_name) within the listed series.
          # Defaults to false
          apply_to_bodies_probes: false
        - transformation:
            translation: [50, -10, 0]
          point_clouds_apply: []
          bodies_apply: []
          probes_apply: ["series.spectrum_probe.points.downstream", "series.spectrum_probe.points.upstream"]
          # True to apply this transformation to bodies in probes series as well
          apply_to_bodies_probes: true

    # ----------------------------------------------------------------
    # Checkpoint
    # ----------------------------------------------------------------
    checkpoint:
      export:
        # Interval at which to write checkpoint files to disk.
        # No export when this block is absent.
        interval: {end_step: 30000, frequency: 5000, start_step: 10000}
        # True to write a final checkpoint when the simulation ends.
        # Defaults to false
        finish_save: true
        # True to delete all but the most recent checkpoint on disk,
        # saving storage at the cost of losing earlier restart points.
        # Defaults to false
        keep_only_last_checkpoint: false
      load:
        # True to resume from a saved checkpoint at startup.
        # Defaults to false
        checkpoint_start: true
        # True to reset the time counter to 0 after loading the checkpoint fields.
        # Useful for branching a new run from an existing flow state.
        # Defaults to false
        reset_time_step: false
        # Path to the folder containing the checkpoint to load.
        # When null, Nassu looks for the latest checkpoint in save_path.
        # Defaults to null
        folderpath: "path/to/checkpoint/folder"

    # ----------------------------------------------------------------
    # Data export
    # ----------------------------------------------------------------
    data:
      # Runtime guards - the single mechanism for divergence / sanity control.
      # Each guard watches a measurement (a cheap `field` peek or a `monitor`
      # reduction), trips on a `condition`, and takes an `action` on its
      # `interval`. With NO guards declared, an implicit default set applies
      # the standard divergence behaviour: the fluid `rho` is checked
      # `is_nonfinite -> stop` every 10 steps, each passive transported field
      # `is_nonfinite -> freeze` (frozen and dropped from exports), and each
      # coupled field `is_nonfinite -> stop`. A declared guard whose
      # source targets a given field replaces that field's implicit default.
      guards:
        # Cheap NaN/Inf check on the fluid density, every 20 steps. Declaring
        # this overrides the implicit fluid default (e.g. to change its cadence).
        fluid_nan:
          field: rho # peek a fluid macroscopic from one random block
          condition: is_nonfinite # is_nonfinite | gt | lt | abs_gt | outside
          action: stop # stop | freeze | warn | ignore
          interval: {frequency: 20}
        # Threshold guard backed by a monitor reduction (thresholds MUST be
        # monitor-backed): warn if the domain-max density grows too large.
        rho_cap:
          monitor: rho_max # a declared `data.monitors.fields` entry
          macr: rho # which of the monitor's macroscopics to read
          stat: max # min | max | mean
          condition: abs_gt # needs `value`; `outside` needs `lo`/`hi`
          value: 5.0
          action: warn

      # Named export rescale/rename/time-rescale PROFILES. Each export (and
      # monitor) selects one by name through its own `rescale` field; an export
      # with no `rescale` applies no rescaling. Rescale, rename and time-rescale
      # live only in these profiles, not on individual exports.
      # Per-export macro SELECTION (`macrs` lists, statistics
      # `macrs_1st_order` / `macrs_2nd_order`) stays on each export.
      export_rescales:
        physical: # profile name, referenced by an export/monitor `rescale: physical`
          # Lattice-to-physical rescale per macroscopic: result = value * mul + cte.
          # Keys are macroscopic names that exist in the config (base or derived).
          macrs_rescale: *MACRS_RESCALE
          # Rename macroscopic output names for readability. Keys are macroscopic
          # names (rho, ux, uxuy ...), values are the exported names. A renamed name
          # must not be a prefix of any derived field emitted by an export that
          # selects this profile (renaming `ux` would alias `uxuy`/`uxuz`), so only
          # `rho` is renamed here.
          macrs_rename: {rho: pressure}
          # Multiply the time-step index by this factor for the output time axis.
          # Use the lattice-to-physical time ratio to convert to seconds.
          # Defaults to 1.
          time_rescale: 1

      # Derived macroscopics: named SymPy expressions over base macroscopic
      # instance names, declared once at the data root. A derived name can then
      # be listed in ANY export's `macrs` (or a stats `macrs_1st_order` /
      # `macrs_2nd_order`) and it flows through every output that export enables
      # (instantaneous volume, statistics, series), evaluated from the
      # base macroscopics it references - e.g. `<ux*uy>` for the Reynolds-stress
      # cross components. Expressions reference only base instance names (no
      # derived-on-derived) and the whitelisted math functions; a derived name
      # must not collide with a built-in macroscopic name and need NOT appear in
      # an export's `macrs` to be used (its bases are pulled in automatically).
      derived_macrs:
        uxuy: "ux*uy"
        uxuz: "ux*uz"
        uyuz: "uy*uz"

      # Global field monitors - compute scalar statistics (min, max, mean)
      # over the domain and write them to CSV at each interval.
      monitors:
        fields:
          # monitor_name:
          #   ... specs

          # Monitor 1: track the maximum density and its position
          rho_max:
            macrs: [rho] # Macroscopics to monitor (rho, u, S, etc.)
            # Statistics to compute. Options: min, max, mean, pos
            # pos reports the grid coordinate of the max/min value
            stats: [max, pos]
            interval: {start_step: 500, end_step: 10000, frequency: 50}
            # Rescale, rename and time-rescale come from a `data.export_rescales`
            # profile selected via this monitor's `rescale` field (omit for none).
            # Monitor rows are buffered in memory and committed (CSV append +
            # plot re-render) in batches every `interval_flush` steps. Lower values
            # keep the on-disk files more up to date and reduce peak memory at the
            # cost of more frequent disk I/O. The buffer is force-flushed at end of run.
            # Defaults to 250
            interval_flush: 250

          # Monitor 2: min/max/mean of multiple macroscopics over a sub-volume
          macrs_stats:
            # u expands to ux/uy/uz; S expands to all Sij components
            macrs: [rho, u, S]
            stats: [min, max, mean]
            interval: {start_step: 0, end_step: 0, frequency: 500}
            # Restrict monitoring to blocks that overlap these boxes.
            # If omitted, the full domain is monitored.
            volumes_monitor:
              - start: [10, 20, 0]
                end: [100, 100, 100]
                is_abs: true # Absolute lattice coordinates
                # Optional coordinate system the corners are given in (default
                # domain.default_system). Absolute, non-rotated systems only.
                system:
              - start: [0.1, 0.1, 0.1]
                end: [0.9, 0.9, 0.9]
                is_abs: false # Relative coordinates (fraction of domain size)
            # Exclude blocks fully inside these boxes from the monitor.
            volumes_ignore:
              - start: [50, 60, 5]
                end: [70, 120, 10]
                is_abs: true

      # Export IBM Lagrangian-node fields (interpolated velocity, spread force, ...)
      # for each configured body, as an XDMF temporal collection.
      export_IBM_nodes:
        export_caarc:
          body_name: CAARC # Must match a key in domain.bodies or domain.point_clouds
          start_step: 0 # 0 = start from the first step
          end_step: 0 # 0 = export until the simulation ends
          frequency: 0 # 0 = export at every step (use with care - large output)
          # How the nodes are exported. `triangle` (default) writes a
          # triangle-conformed mesh: the topology is the source geometry's
          # faces and each triangle carries one representative value (forces,
          # area, normal, ...) aggregated from the runtime nodes on it.
          # `point_cloud` writes the raw Lagrangian nodes and allows
          # sub-sampling via `stride` / `target_num_nodes`. `triangle` is only
          # valid for body geometries (point clouds must use `point_cloud`).
          mode: triangle
          # `stride` / `target_num_nodes` below apply to `point_cloud` mode
          # only; setting them with `mode: triangle` is a configuration error.
          # Export every Nth Lagrangian node. 1 keeps all nodes; 2 keeps
          # nodes 0, 2, 4, ...; etc. Useful for sub-sampling bodies with
          # very large node counts. Default 1.
          stride: 1
          # Desired approximate number of exported nodes. When set, it
          # overrides `stride`: an effective stride is derived at the first
          # export as max(1, round(total_nodes / target_num_nodes)). Leave
          # unset (null) to use `stride` directly. Default null.
          target_num_nodes:
          # H5 rollover threshold in gigabytes; a new chunk is started as
          # soon as the current one exceeds this size. Defaults to 4.0 GB.
          max_h5_size_gigabytes: 4.0
          # Number of (lvl-0) simulation steps between successive HDF5 writes.
          # Per-step snapshots are accumulated in memory and committed to disk in
          # batches every `interval_flush` steps. The buffer is also force-flushed
          # at end of run and on checkpoint. Default 250.
          interval_flush: 250

      # Lean per-body skin-friction node export: a probe-style
      # CSV/HDF time series of BC-independent wall-friction primitives, separate
      # from the heavy `export_IBM_nodes` mesh export above. At its frequency a
      # read-only friction-sampling pass populates the per-node friction fields
      # (it spreads no force and never perturbs the flow), then the full
      # post-processing set is written every time (no column selection).
      body_nodes:
        CAARC_friction:
          body_name: CAARC # Must match a key in domain.bodies
          start_step: 0
          end_step: 0
          frequency: 100
          # `triangle` (default) aggregates the runtime Lagrangian nodes onto the
          # body's source-geometry triangles (~10k faces instead of ~1M nodes):
          # the intensive diagnostics become area-weighted means, `area` sums,
          # normals come from the source triangle. `point_cloud` writes the raw
          # per-node rows. `triangle` is body-only.
          mode: triangle
          # The export always writes the full post-proc set: the viscous
          # `traction_x/y/z`, the friction velocity `friction_u_tau` /
          # `friction_y_plus`, the wall surface `pressure`
          # (p = rho*(1+theta)*c_s^2), the summed `area` and (triangle mode)
          # `n_nodes`. The geometry columns (centroid `pos_*`, source-triangle
          # `normal_*`) go to the static topology sidecar. The nonlinear scalars
          # (tau_w, Cf, form/friction split) are derived later at read time.
          # Steps between successive HDF5 writes; buffered between flushes and
          # force-flushed at end of run and on checkpoint. Default 250.
          interval_flush: 250

      # Unified exports. Each named export picks one `target` (a volume OR a series
      # entity collection) and one or more `outputs` (instantaneous / stats).
      exports:

        # Volume snapshot of the instantaneous field over a sub-volume.
        field_volume:
          macrs: ["rho", "u", "S"] # Macroscopics to include in each snapshot
          rescale: physical # name of a `data.export_rescales` profile (omit for none)
          # Coordinate frame the exported node coordinates are written in: a
          # `domain.systems` name, or omit / null for lbm (lattice).
          system: site
          interval:
            start_step: 78
            end_step: 0 # 0 = run until the simulation ends
            frequency: 10 # may be fractional
            lvl: 0 # reference level, or the literal "max" for the finest level
            constant_dt: true # default: uniform interval (frequency floored to whole finest steps); false = closest-snap
          target:
            # Named entities grouped by kind (volumes / lines / csvs / points /
            # bodies / planes); kinds mix freely in one export.
            volumes:
              wake: # entity name; file stem <export>.volume.<entity>.<kind>
                volume:
                  start: [32, 0, 0]
                  end: [448, 128, 64]
                  is_abs: true # absolute lattice coordinates
                  # Coordinate `system` the corners are given in.
                  # is_abs: true, non-rotated only; mapped to lattice at load. This
                  # is the INPUT frame for the box; the OUTPUT frame is the export's
                  # own `system` above. Omit for "lbm".
                  system: site
                max_lvl: 3 # max refinement level to export; -1 exports all levels
                max_h5_size_gb: 4.0 # HDF5 rollover threshold in GB
              full: {} # a second volume entity: whole domain, all defaults
          outputs:
            instantaneous: true # write the raw field every interval step

        # Running statistics over the same volume. By default the running averages
        # are written as a single snapshot at the end of the run; set
        # `stats.flush_interval` to also snapshot the partial averages periodically.
        # `stats` macroscopics must be a subset of `macrs`; the selected profile's
        # `macrs_rescale` is applied to the instantaneous value BEFORE accumulation.
        field_stats:
          macrs: ["rho", "u"]
          rescale: physical # selects the `data.export_rescales` profile above
          interval: {start_step: 78, end_step: 0, frequency: 10, lvl: 0}
          target:
            volumes:
              field_stats:
                volume: {start: [32, 0, 0], end: [448, 128, 64], is_abs: true}
          outputs:
            instantaneous: false # stats-only export (no raw field written)
            stats:
              # The derived names declared at `data.derived_macrs` can be listed
              # directly in the order lists below to accumulate statistics of the
              # per-step value of the expression - e.g. `<ux*uy>` for the
              # Reynolds-stress cross components. They need NOT appear in this
              # export's `macrs` (their bases are pulled in automatically).
              macrs_1st_order: ["rho", "u", "uxuy", "uxuz", "uyuz"] # running mean
              macrs_2nd_order: ["u"] # running mean of squares (std basis)
              # How often (in level-0 steps) to snapshot the running averages to
              # disk. 0 (default) = write only at the end of the run; a positive
              # value (at least 100) also writes intermediate partial-average
              # snapshots and flushes them on checkpoint.
              flush_interval: 0

        # Series export: a collection of point-like entities sampled by interpolation.
        # All five entity kinds are shown; an export uses any subset.
        series1:
          macrs: [rho, u] # Macroscopics to sample
          interval:
            start_step: 78
            end_step: 0
            frequency: 10
            lvl: 4 # sample on the finest level's time-step counter
          interval_group: 1000 # steps per HDF table (series targets); default 1000
          interval_flush: 250 # steps between disk flushes (series targets); default 250
          target:
            lines:
              line1:
                start_pos: [200.46875, 79.4285, 2.905] # Line start [x, y, z]
                end_pos: [200.46875, 80.5715, 2.905] # Line end [x, y, z]
                dist: 0.28575 # Spacing between sample points along the line
                # Optional: name the coordinate system the endpoints are given in
                # (default domain.default_system). Mapped to lbm before sampling.
                # See domain.systems / domain.default_system.
                system:
            points:
              point1:
                pos: [200.46875, 79.4285, 2.905] # Point position [x, y, z]
                system:      # Coordinate system the position is given in
            bodies:
              # Sample at triangle centroids ("cell") or vertices ("vertex") of a
              # named body at a given normal offset.
              my_CAARC:
                body_name: "CAARC" # Must match a key in domain.bodies
                normal_offset: 0.03125
                element_type: "cell" # or "vertex"
              my_surface:
                # Reference a named sub-surface as "body_name.surface_name"
                body_name: "CAARC.surface_name"
                normal_offset: -0.03125 # negative offset = inside the body
                element_type: "cell"
            csvs:
              # Read positions from a CSV with a header row of columns x, y, z.
              my_csv:
                filename: "my_filename.csv"
            planes:
              # Axis-aligned plane (3D only), exported as a triangle surface so
              # ParaView renders it as a surface. For an oriented plane, apply a
              # rotation/translation via domain.global_transformations.probes_apply.
              plane1:
                axis: z # Plane normal direction (x, y or z)
                axis_pos: 2.905 # Coordinate along `axis` where the plane sits
                # In-plane bounds, ordered by global axis index excluding `axis`
                # (axis=z -> (x, y)). Omit both min and max to span the full domain.
                min: [200.0, 79.0]
                max: [201.0, 81.0]
                # Grid spacing: scalar (uniform) or a pair (one per in-plane axis).
                dist: 0.28575
          outputs:
            instantaneous: true

        # Max-rate point probe for FFT analysis: `interval: {frequency: 1, lvl: max}`
        # samples each point at every finest-level internal iteration.
        spectrum_probe:
          macrs: [rho, u]
          interval: {frequency: 1, lvl: max}
          target:
            points:
              upstream:
                pos: [200.46875, 80.0, 4.81]
              downstream:
                pos: [201.48375, 80.0, 4.81]
          outputs:
            instantaneous: true

    # ----------------------------------------------------------------
    # Models
    # ----------------------------------------------------------------
    models:
      # GPU engine configuration
      engine:
        # Specific GPU device indices to use (e.g. [0, 1]).
        # null lets Nassu pick devices automatically in ascending order.
        # Defaults to null
        devices_numbers:
        # Number of GPU devices to use. Multi-device is not supported yet.
        # Defaults to 1
        n_devices: 1
        # Compute engine.
        # Options are: CUDA
        name: CUDA

      # Floating-point precision settings
      precision:
        # Precision used inside CUDA kernels for intermediate calculations.
        # Options are: single, double, default
        # Defaults to default
        calculations: default
        # Base precision - must always be specified explicitly.
        # Options are: single, double
        default: single
        # Precision used for macroscopic field storage (rho, u, pi_neq, ...).
        # Options are: single, double, default
        # Defaults to default
        macroscopics: default
        # Precision used for LBM populations in GPU shared memory.
        # Options are: single, double, default
        # Defaults to default
        populations: default

      # Multiblock grid communication settings
      multiblock:
        # Refinement-interface communication operator.
        #   value_interp  (default) - Lagrava-2012 value-interpolation coupling
        #                             (cubic C2F prolongation, F2C point injection)
        #   conservative           - conservative moment-transfer pair: positivity-
        #                             preserving momentum-space C2F prolongation and
        #                             averaged F2C restriction (forbids negative-rho
        #                             at the interface near omega -> 2)
        interface_coupling: value_interp
        # Number of overlap nodes on the fine side of each Fine-to-Coarse interface.
        # Defaults to 1
        overlap_F2C: 2
        # Per-level override for overlap_F2C. Key is the level number, value is the
        # overlap count to use for that level's F2C communication.
        # Defaults to {} (no per-level overrides)
        custom_overlap_F2C:
          1: 3 # Level 1 -> use overlap of 3
          4: 2 # Level 4 -> use overlap of 2
        # Mark coarse-side nodes inside F2C communication regions as unused
        # to prevent undefined behaviour from uninitialised values.
        # Defaults to true
        mark_nodes_as_unused: true
        # Optional adaptive checkerboard dissipation for near-inviscid
        # (omega -> 2) refinement interfaces. A 2dx odd-even sensor gates a
        # graded extra subgrid viscosity only where the grid-scale mode is
        # present, so the resolved flow at the base Smagorinsky constant is
        # untouched. Requires models.LES. Omit to disable (byte-identical).
        interface_stabilization:
          strength: 0.2 # extra-viscosity gain (a local Smagorinsky-constant boost)
          threshold: 0.5 # sensor value (0 smooth, 1 grid-scale) above which it ramps in

      # LES turbulence model
      LES:
        # Subgrid scale model to use.
        # Options are: Smagorinsky
        model: Smagorinsky
        # Smagorinsky constant C_S. Typical CWE range: 0.10 to 0.17
        sgs_cte: 0.17

      # Generalized-Newtonian (non-Newtonian) rheology: a shear-rate-dependent
      # apparent viscosity eta(gamma_dot), gamma_dot = |S|. Absent (or
      # model: newtonian) keeps the constant-viscosity Newtonian solver
      # byte-identical. The apparent viscosity enters only through omega (where
      # the LES eddy viscosity enters); it can be combined with LES.
      rheology:
        # Model discriminator. Options are:
        #   newtonian (default, no-op), power_law, carreau_yasuda, herschel_bulkley
        model: power_law
        # Model-specific parameters (validated at parse time against `model`).
        # Values are in level-0 lattice units (consistent with the base tau).
        params:
          # power_law:        K, n, nu_min, nu_max (eta = K * gamma_dot^(n-1))
          K: 0.05
          n: 0.7
          # Stability clip on the kinematic apparent viscosity (keeps omega in
          # (0, 2)); required for power_law / herschel_bulkley, optional for
          # carreau_yasuda (self-bounded).
          nu_min: 1.0e-4
          nu_max: 5.0e-1
          # carreau_yasuda:   eta_0, eta_inf, lambda_, a, n
          #   eta = eta_inf + (eta_0 - eta_inf) * [1 + (lambda_*gamma_dot)^a]^((n-1)/a)
          # herschel_bulkley: K, n, tau_0, m_reg, nu_min, nu_max (Papanastasiou-
          #   regularized: eta = K*gamma_dot^(n-1) + tau_0*(1 - exp(-m_reg*gamma_dot))/gamma_dot)

      # Lattice Boltzmann Method settings
      LBM:
        # Global body force vector applied uniformly across the domain [x, y, z].
        # Non-zero values drive a pressure-gradient flow (e.g. channel flow).
        F: {x: 0, y: 0, z: 0}
        # Collision operator.
        # Options are: RBGK (2nd-order Hermite), RRBGK (3rd-order, default for LES),
        #              HRRBGK (hybrid regularised)
        coll_oper: RRBGK
        # Operator-specific parameters (only relevant for HRRBGK)
        coll_oper_params:
          # Blending constant sigma for HRRBGK. Valid range: 0.95 to 1.0
          # Defaults to 0.99
          sigma_hrrbgk: 0.99
          # Blending mode for HRRBGK.
          # Options are: dynamic, constant
          # Defaults to constant
          mode_hrrbgk: dynamic
        # True to activate the thermal model (variable theta β‰  0).
        # The isothermal path (theta = 0) is the production default.
        thermal_model: false
        # Relaxation time tau = 1/omega. Controls kinematic viscosity:
        # nu = cs^2 * (tau - 0.5) * dt. Must satisfy tau > 0.5 for stability.
        # With RRBGK, values up to tau β‰ˆ 1 remain stable at high Re.
        tau: 0.5000008125
        # Optional independent bulk (volume) viscosity zeta (lattice units).
        # When set, the trace of the non-equilibrium second moment relaxes at the
        # constant rate omega_bulk = 1 / (zeta / cs^2 + 1/2) instead of the shear
        # rate omega, damping the low-Mach reduced-pressure checkerboard mode.
        # Omit (the default) to leave the scheme at its near-zero intrinsic bulk
        # viscosity; the generated kernels are then byte-identical.
        bulk_viscosity: 0.1
        # Velocity set (lattice topology).
        # Options are: D2Q9 (2D), D3Q15, D3Q19, D3Q27 (3D)
        # D3Q27 is preferred for LES - complete 27-dimensional Hermite basis
        vel_set: D3Q27

      # Immersed Boundary Method settings
      IBM:
        # Discrete Dirac delta kernel width.
        # Options are: 3_points (support Β±1.5 Ξ”x), 4_points (support Β±2 Ξ”x)
        # Defaults to 3_points
        dirac_delta: 3_points
        # Minimum sum of Dirac delta weights for IBM to operate on a node.
        # Use 0.99 to skip nodes near domain boundaries or multiblock transitions
        # where the stencil is truncated. Lower values allow IBM everywhere.
        # Defaults to 0.99
        min_dirac_sum: 0.99
        # Number of steps over which the IBM force ramps linearly from 0 to 100 %.
        # Prevents a large transient impulse at startup.
        # Defaults to 0 (no ramp)
        forces_accomodate_time: 500
        # Convergence threshold on the force change between IBM sub-iterations.
        # A reasonable value for CWE is 1e-3 (β‰ˆ rho * u_ref^2).
        # Defaults to 1000 (effectively no limit)
        forces_spread_limit: 1e-3
        # True to set IBM forces to zero at the start of each time step,
        # so each step converges from scratch. False reuses the previous
        # step's force as the initial guess (can speed up convergence).
        # Defaults to true
        reset_forces: false

        # Named IBM configurations referenced by domain.bodies.IBM.cfg_use
        body_cfgs:
          # Built-in default config - empty means all sub-fields use their defaults
          default: {}

          # Configuration for a building body with an equilibrium TBL wall model
          building_cfg:
            # Number of IBM sub-iterations per time step.
            # More iterations improve convergence at the cost of runtime.
            # Defaults to 5
            n_iterations: 5
            # Multiply the computed IBM force by this factor before spreading.
            # 1.0 = standard IBM; values < 1 damp the force.
            # Defaults to 1
            forces_factor: 1
            # Allocate IBM force buffers (and run force processing) on every
            # runnable block instead of only this body's precomputed affected
            # set. Escape hatch for moving bodies (movable: true): a
            # node may move into a block that was not originally affected, which
            # would otherwise have no force buffer and silently drop the spread
            # force. Uses more memory when on. Defaults to false
            allocate_force_all_blocks: false
            # Wall model - replaces conventional no-slip IBM near walls.
            wall_model:
              # Wall model type.
              # Options are: EqLog (log-law), EqTBL (equilibrium turbulent
              # boundary layer), NonEqTBL (non-equilibrium TBL with streamwise
              # pressure-gradient term).
              name: NonEqTBL
              # Distance (in lattice nodes) from the wall at which to interpolate
              # the tangential velocity used by the wall model.
              dist_ref: 2
              # Thickness of the IBM spreading shell (in lattice nodes).
              dist_shell: 0.25
              # Step at which to switch from conventional IBM to the wall model.
              # Conventional IBM is applied for steps < start_step.
              # Defaults to 1000
              start_step: 1000
              # Parameters specific to the chosen wall model.
              params:
                z0: 0.0001 # Roughness length in lattice units
                TDMA_max_error: 5e-06 # TDMA solver convergence tolerance
                TDMA_max_iters: 50 # Maximum TDMA iterations per node
                # Number of TDMA grid divisions (must be an odd number)
                TDMA_max_div: 25 # Maximum divisions
                TDMA_min_div: 21 # Minimum divisions
                # Target y+ for the first TDMA node nearest the wall
                # Defaults to 0.2
                TDMA_yp_target: 0.2
                # Exponential wall-clustering strength (>= 0) for the TBL TDMA
                # grid. The wall-normal solve uses the divisions on a uniform
                # computational coordinate xi in [0, 1] mapped to the physical
                # wall distance by n = dist_interp*(exp(beta*xi)-1)/(exp(beta)-1),
                # clustering nodes toward the wall for better near-wall
                # resolution at the same division count. The default 2.0 puts
                # the first node well inside the viscous sublayer for a typical
                # 10-50 wall-unit near-wall cell; 0.0 gives a uniform grid. The
                # tridiagonal solver is unchanged - beta enters only through the
                # grid metric.
                TDMA_stretch_beta: 2.0
                # NonEqTBL only: floor (> 0) on the previous-step friction
                # velocity used to compute the LaRTE adaptive EMA coefficient
                # on the streamwise pressure gradient,
                # alpha_node = u_friction_lagged / (dist_ref + u_friction_lagged).
                # The floor keeps alpha_node well-defined at start-up and at
                # near-stagnation nodes. Default 1e-4 (lattice); a tighter
                # case-specific guideline is one tenth of the log-law
                # equilibrium friction velocity at the freestream scale.
                NeqWM_u_friction_floor: 1.0e-4
                # NonEqTBL only: static multiplier (>= 0) applied to the
                # LaRTE-filtered pressure gradient right before it enters the
                # TBL TDMA. The physically motivated value is the lattice
                # Mach number itself, m = Ma_LBM = U_LBM * sqrt(3), which
                # rescales the raw LBM `dp/ds` magnitude back to the
                # incompressible-equivalent expectation of the TBL ODE - see
                # `theory/wall_model/neq_pres_filter` for the derivation. In
                # this kitchensink we hard-code 1.0 for explicitness, but
                # case YAMLs typically derive it from a `Ma_LBM` variable as
                # `NeqWM_pres_grad_mult: !math ${Ma_LBM}`. Setting it to 0.0
                # zeroes the source seen by the TDMA (NonEqTBL then behaves
                # like the equilibrium TBL) while pres_grad and
                # pres_grad_filt keep updating - useful as a passive raw-
                # signal sensor for spectral diagnostics.
                NeqWM_pres_grad_mult: 1.0

          # Configuration for a terrain body with a log-law wall model
          terrain_cfg:
            n_iterations: 3
            forces_factor: 1
            wall_model:
              # Equilibrium log-law wall model
              name: EqLog
              dist_ref: 2.5
              dist_shell: 0.5
              # Parameters specific to the EqLog model
              params:
                z0: 0.0001 # Aerodynamic roughness length in lattice units
              # Optional flux-driven Monin-Obukhov stratified-surface-layer
              # stability correction. Omitting it (the default) keeps the
              # neutral log-law path unchanged. The buoyancy strength
              # B = beta*|gravity| is read from the coupling scalar's `buoyancy`
              # block, so `coupling_scalar` must name a buoyant
              # `scalar_transports` entry.
              # mo_stability:
              #   # Scalar the buoyancy strength B = beta*|gravity| is read from
              #   # (must declare `buoyancy`).
              #   coupling_scalar: temperature
              #   # Prescribed surface kinematic heat flux <w'phi'>_0 (lattice
              #   # units), the MO driver. Positive -> unstable, negative ->
              #   # stable, zero -> neutral.
              #   surface_heat_flux: 1.0e-6
              #   # Optional startup ramp: linearly ease the surface flux in from
              #   # zero (neutral) over this many coarsest-level steps, removing
              #   # the step-0 flux shock. 0 (default) applies it at full strength.
              #   ramp_steps: 0

          # Configuration using the debug-only constant-force model
          constant_body_cfg:
            n_iterations: 1
            forces_factor: 0.12
            # Debug-only: apply a fixed body force rather than iterating to enforce a
            # velocity. A tool for exercising the IBM force path, not a physical
            # model; leave unset in production runs.
            debug_cte_force:
              # Target velocity magnitude imposed on the body normal direction.
              constant_velocity: 1e-2
              # Factor applied to the tangential component of the force.
              # 0 = no tangential correction; 1 = drive tangential velocity to zero.
              tangential_force_factor: 0
              # True to use individual surface normals for force direction.
              # False = use direction_apply for all nodes.
              use_normal: false
              # Global force direction when use_normal is false [x, y, z].
              # The vector does not need to be normalised.
              # Defaults to null
              direction_apply: [0, 1, -1]

      # Initial condition for the macroscopic fields at t = 0
      initialization:
        # Path to an .xdmf file from a previous simulation to initialise from.
        # Nassu uses linear interpolation to map the field onto the current mesh.
        # The file must contain at least rho and u; S is reconstructed via finite
        # differences on u.
        # Mutually exclusive with equations. Defaults to null.
        # macrs_filename: ./fixture/macrs/multiblock_load.xdmf
        # Equation-based initialization: SymPy expressions for rho, ux, uy, uz
        # as functions of physical node coordinates x, y, z (lattice units).
        # Allowed functions: sin, cos, tan, asin, acos, atan, atan2, log, log2, log10,
        #   exp, sqrt, Abs, ceil, floor, Min/min, Max/max.
        # Defaults: rho="1", ux="0", uy="0", uz="0"
        equations:
          rho: "1"
          ux: "0.05 * (z / 100.0) ** 0.25"
          uy: "0"
          uz: "0"
        # Inlet field initialization: prefill the whole domain with the active
        # inlet method's mean velocity profile ux(z) (homogeneous in x and y),
        # instead of a constant. Requires an inlet turbulence method
        # (models.BC.SEM). Defaults to false.
        # (The name `sem_field` is accepted as an alias.)
        inlet_field: false

      # Boundary conditions applied at each domain face
      BC:
        # Which domain faces use periodic BCs [x, y, z].
        # A true entry means the face pair at x=0 and x=N_x are periodic.
        periodic_dims: [false, false, false]

        # Global TDMA parameters shared across all wall-model BCs that use TDMA.
        # Individual IBM wall models can override these with their own params block.
        WM_cfg:
          TDMA_max_error: 5e-06 # Convergence tolerance for TDMA
          TDMA_max_iters: 50 # Maximum iterations per TDMA solve
          # Number of TDMA divisions (must be an odd number)
          TDMA_max_div: 25

        # Ordered list of BCs to apply. BCs with the same order are applied
        # simultaneously; conflicts are resolved in list order (last wins).
        # Available BC types:
        #   RegularizedHWBB                  - no-slip wall
        #   RegularizedVelocityWall          - moving wall (alias RegularizedVelocityBB)
        #   UniformFlow                      - prescribed inlet velocity
        #   Neumann, RegularizedNeumannSlip  - zero-gradient / free-slip
        #   RegularizedNeumannOutlet         - zero-gradient outlet with fixed rho
        BC_map:
            # Boundary condition name (see available types above)
          - BC: RegularizedNeumannOutlet # Zero-gradient outlet
            # Order in which to apply the BC. 0 runs first, then 1, then 2.
            order: 2
            # Face position. Options: E (x=N), W (x=0), N (y=N), S (y=0),
            #   F (z=N), B (z=0). Combine for edges/corners: NF, SW, etc.
            pos: E
            # Outward normal direction of this face (points out of the domain).
            # Combine directions for edge/corner normals the same way as pos.
            wall_normal: E
            # BC-specific kwarg: target density at the outlet
            params:
              rho: 1.0
          - BC: RegularizedNeumannSlip # Free-slip top face
            order: 1
            pos: F # z = N (top face)
            wall_normal: F

          - BC: RegularizedHWBB # No-slip ground (regularised halfway bounce-back)
            order: 1
            pos: B # z = 0 (bottom face)
            wall_normal: B

          - BC: RegularizedNeumannSlip # Free-slip north lateral face
            order: 0
            pos: N # y = N
            wall_normal: N

          - BC: RegularizedNeumannSlip # Free-slip south lateral face
            order: 0
            pos: S # y = 0
            wall_normal: S

          - BC: Neumann # Zero-gradient at top-north edge
            order: 0
            # Combined position: nodes at both z=N and y=N
            pos: NF
            # Wall normal points in the N direction for this combined edge
            wall_normal: N

          - BC: Neumann # Zero-gradient at top-south edge
            order: 0
            pos: SF
            wall_normal: S

          - BC: RegularizedHWBB # No-slip interior baffle defined by a predicate
            order: 0
            # `pos` also accepts a boolean predicate over node coordinates
            # x, y, z (instead of a cardinal alias). This selects every node
            # satisfying the expression - here an axis-aligned cuboid slab.
            # Grammar: + - * / and integer powers (** or ^), comparisons
            # (< <= > >= == !=) and boolean and/or/not (&, |, ~).
            pos: "(x >= 14) & (x <= 17) & (8 <= y) & (y <= 24)"
            # Required for non-cardinal predicate regions (cardinal-snapped).
            wall_normal: E
            # Optional: name the `domain.systems` frame `pos` is written in
            # (default `domain.default_system`, itself "lbm"); each lattice node
            # is mapped into the system and the position checked there (the
            # transform - including rotation - is baked in at config load).
            # Applies to a predicate and to a shape alike; not valid on a
            # cardinal alias, which is an lbm-frame domain face by definition.
            # system: site

          - BC: RegularizedHWBB # No-slip interior wall placed by a typed shape
            order: 0
            # `pos` also accepts a typed shape instead of a string: it is lowered
            # to the equivalent node predicate at config load, so the solver owns
            # the thickness convention (a plane is one node layer thick, a line
            # one node per step). Kinds and their fields:
            #   point     -> pos
            #   points    -> points
            #   line      -> start, end
            #   plane     -> axis, axis_pos, min, max
            #   box       -> start, end, is_abs
            #   predicate -> predicate
            pos:
              kind: plane
              # Plane's normal direction; the plane spans the other two axes.
              axis: z
              # Coordinate along `axis` where the plane sits.
              axis_pos: 4.0
              # Optional in-plane extent, ordered by global axis index excluding
              # `axis` (axis=z -> (x, y)). Omit both to span the whole domain.
              min: [10, 10]
              max: [50, 50]
              # Optional: rigid transform mapping the shape's local frame into
              # the entry's coordinate system.
              # transformation:
              #   rotation: [0, 0, 0.7854]
              # A selection geometry takes no `dist`: spacing belongs to an
              # export probe, which generates sample points.
            # The frame lives on the entry, next to `pos`, in either spelling.
            # system: site
            wall_normal: B

        # Reset mean density to rho_norm on the specified face after each step.
        # Prevents slow pressure drift caused by mass conservation error at outlets.
        # Empty list by default (no normalisation).
        rho_normalization:
          - pos: W # Face to compute the mean over. Options: N, S, W, E, F, B
            rho_norm: 1.0 # Target mean density. Defaults to 1

        # Inlet turbulence generator - produces turbulent inflow at the inlet (x = 0).
        # Selected via `type`: `sem` (Synthetic Eddy Method, below) or `podfs`
        # (Proper Orthogonal Decomposition Fourier Series, commented alternative
        # further down). Requires the inlet face (W) to NOT have a UniformFlow BC.
        inlet_turbulence:
          type: sem
          eddies:
            # Integral length scale used for eddy size [x, y, z] in lattice units.
            # Larger values produce bigger, lower-frequency structures.
            lengthscale: {x: 14, y: 14, z: 14}
            # Volumetric eddy density: n_eddies = density Γ— (SEM_volume / eddy_volume).
            # Higher values improve turbulence isotropy at the cost of more compute.
            eddies_vol_density: 10
            # Seed for the random number generator that initialises eddy positions.
            # 0 = non-reproducible; any other integer gives a reproducible field.
            seed_rand: 0
            # Bounding box [y, z] for the eddy generation volume at the inlet.
            # x extent is set automatically to 2 Γ— lengthscale.x.
            domain_limits_yz:
              start: [16, 0] # [y_min, z_min] in lattice units
              end: [48, 96] # [y_max, z_max] in lattice units
          profile:
            # CSV file containing the mean velocity and Reynolds stress tensor
            # profile as a function of height z.
            csv_profile_data: "fixture/SEM/example/real_profile.csv"
            # Constant offset added to all z values when reading the profile.
            # Shifts the profile vertically relative to the domain.
            z_offset: 0
            # Turbulence intensity tuning constant K.
            # 1.0 matches the target turbulence intensity exactly.
            K: 1
            # Scale factor applied to all z (height) coordinates in the profile CSV.
            # Use to convert from metres to lattice units. The profile is a
            # function of height z only (the inlet is homogeneous in y).
            # Defaults to 1
            length_mul: 1
            # Scale factor applied to all velocity values in the profile CSV.
            # Reynolds stresses are scaled by vel_mul^2.
            # Defaults to 1
            vel_mul: 1

        # PODFS inlet alternative (data-driven). Replaces the `inlet_turbulence`
        # block above; only one inlet generator may be active at a time. PODFS
        # replays a compact temporal basis through a Fourier series. The runtime is
        # LOAD-ONLY: it consumes a single, self-describing basis file produced by a
        # separate OFFLINE step (`nassu podfs-build <build_spec.yaml>`), which runs
        # the plane-sampling -> POD transform and bakes the mean, modes, Fourier
        # coefficients, analysis-grid coordinates, and an optional high-frequency
        # supplement into the file. Theory: theory/BC/bc.inlet.
        #
        # inlet_turbulence:
        #   type: podfs
        #   # The prebuilt basis file (HDF5, written by `nassu podfs-build`). The
        #   # mean profile, POD modes, Fourier coefficients and the analysis grid all
        #   # live inside it; the solver makes no POD decisions at runtime.
        #   basis_path: "fixture/PODFS/example/basis.h5"
        #   vel_mul: 1.0          # whole-velocity rescale (intensity ratio preserved)
        #   time_rescale: 1.0     # maps basis time onto solver clock; T_eff = T/time_rescale
        #   high_freq: true       # use the stored high-frequency supplement if present
        #   # Load-time mode crop: by count (n_modes) OR by cumulative energy
        #   # fraction (energy_crop, 0-1); the two are mutually exclusive. n_harmonics
        #   # crops the Fourier harmonics. Omit to use everything stored.
        #   n_modes: 200          # keep first N energy-ranked modes ...
        #   # energy_crop: 0.95   # ... OR fewest modes reaching 95% energy
        #   n_harmonics: 64       # keep DC + first K Fourier harmonics
        #   # Affine spatial placement of the basis onto the inlet: y symmetric about
        #   # the centreline, z grounded; below z_offset the field is zero. Omit for
        #   # the native span, centred and grounded at the recorded height.
        #   spatial:
        #     y_center: 80.0      # span centreline (default: inlet mid-width)
        #     y_scale: 1.0        # spanwise stretch of the basis width about y_center
        #     z_offset: 5.01      # ground level (default: basis recorded z-bottom)
        #     z_scale: 1.0        # vertical stretch of the basis height from z_offset

      # ----------------------------------------------------------------
      # Scalar transport
      # ----------------------------------------------------------------
      # Each entry under `scalar_transports` adds one passive-scalar
      # advection-diffusion LBM coupled to the fluid. The dictionary key
      # (e.g. `temperature`) is used as the symbol-name scope for the
      # scalar's macroscopics, so two scalars produce disjoint variables
      # (`temperature_phi`, `pollutant_phi`, etc.).
      scalar_transports:
        temperature:
          # Lattice for the scalar populations `g_i`. D3Q7 is the minimal
          # production choice; D2Q5 is the 2-D debug analogue. Larger sets
          # (D3Q15 / D3Q19 / D3Q27) are accepted at higher memory cost.
          velocity_set: D3Q7
          # Only RRBGK is supported today; the field is fixed for forward
          # compatibility with future operators (e.g. TRT).
          collision_operator: RRBGK
          # When to advance the scalar. Defaults to (0, 0) which means
          # the scalar is not stepped; set start_step / end_step to enable.
          interval: {start_step: 0, end_step: 0}
          adv_diff_equation:
            # Molecular diffusivity D in lattice units. The LES path adds
            # nu_SGS / Sc_t to this when LES is active.
            D: 1.0e-5
            # Source / sink term S(x, y, z, t, phi). Use "0" for a passive
            # scalar with no source.
            S: "0"
          # Turbulent Schmidt (Prandtl) number: with LES active, the eddy
          # diffusivity nu_SGS / Sc_t adds to D node-locally (collision and
          # multiblock level transfers). Runtime tunable; ignored without LES.
          Sc_t: 0.7
          # By default a passive transported field that goes NaN/Inf is frozen
          # and dropped from exports while the fluid keeps running; a coupled
          # field aborts the run. To instead abort on THIS field's
          # divergence, declare a `data.guards` entry that overrides its default,
          # e.g. `{field: my-scalar_phi, condition: is_nonfinite, action: stop}`.
          # Optional Boussinesq coupling: this scalar exerts the body force
          # F_buoy = -rho0 * beta * (phi - phi_ref) * gravity on the fluid
          # (Guo forcing). `gravity` points along gravity in lattice units
          # (e.g. [0, 0, -g_lbm] with z up), so phi > phi_ref rises. The
          # parameters are compile-time; at most one scalar may be buoyant.
          # Costs one float per node (signed magnitude along gravity).
          buoyancy:
            beta: 2.0e-3
            phi_ref: 0.0
            gravity: [0.0, 0.0, -1.0e-5]
            rho0: 1.0
            # Optional startup ramp: linearly ease the buoyancy force in from
            # zero over this many coarsest-level steps, removing the step-0
            # buoyancy shock. 0 (default) applies it at full strength.
            ramp_steps: 0
          # Initial field expression; evaluated once at setup. May reference
          # (x, y, z) in lattice coordinates.
          initial_field: "0"
          # Per-scalar boundary conditions. Aliases live in
          # `AllScalarBCSchemesAliases` (ScalarRegularizedDirichlet,
          # ScalarRegularizedNeumann, ScalarRegularizedRobin,
          # ScalarUniformInlet). Periodicity is
          # inherited from `models.BC.periodic_dims`.
          BC:
            BC_map:
              # Zero-flux (adiabatic) wall on the south face (regularized
              # Neumann with zero prescribed flux).
              - pos: S
                BC: ScalarRegularizedNeumann
                params:
                  J_w: 0.0
                wall_normal: S
              # Prescribed flux on the north face.
              - pos: N
                BC: ScalarRegularizedNeumann
                wall_normal: N
                params:
                  J_w: 0.0
              - pos: E
                BC: ScalarRegularizedRobin
                wall_normal: E
                params:
                  h: 0.0058
                  phi_inf: 0.0
              # Uniform inlet on the west face. `phi_inlet` accepts either a
              # constant (e.g. `phi_inlet: 1.0`) or an (x, y, z) equation string
              # for a height-varying (stratified) inlet (below).
              # `ScalarRegularizedDirichlet.phi_w` accepts the same forms.
              - pos: W
                BC: ScalarUniformInlet
                params:
                  phi_inlet: 0.0
                  # z-varying (stratified) form:
                  # phi_inlet: "0.05 * (z / 100.0) ** 0.25"
              # `pos` also accepts a boolean predicate over node coordinates
              # x, y, z, exactly as the fluid `BC_map` does. This places the
              # scalar wall on an interior surface (here an emitting footprint
              # on the ground) instead of a whole cardinal domain face.
              # Grammar: + - * / and integer powers (** or ^), comparisons
              # (< <= > >= == !=) and boolean and/or/not (&, |, ~).
              - pos: "(z <= 0) & (x >= 120) & (x <= 180) & (y >= 40) & (y <= 90)"
                BC: ScalarRegularizedDirichlet
                # Required: the outward cardinal direction of the wall. It is
                # never inferred from `pos`, for a cardinal alias or a predicate
                # region alike; a missing one is rejected at config load.
                wall_normal: B
                order: 1
                params:
                  phi_w: 1.0
                # Optional: name the `domain.systems` frame `pos` is written
                # in (default `domain.default_system`, itself "lbm"); each
                # lattice node is mapped into the system and the position
                # checked there (the transform, including rotation, is baked in
                # at config load). Applies to a predicate and to a shape alike;
                # not valid on a cardinal alias.
                # system: site
          # Volumetric scalar source (region emission): add a constant rate
          # to the scalar collision on every node inside a region (a
          # pollutant-emitting region), complementing the `adv_diff_equation.S`
          # expression. The region is placed by the same `pos` a surface BC
          # takes: a cardinal alias, a boolean predicate over x, y, z, or a
          # typed shape. The `rate` is compile-time (baked into the kernel);
          # all regions of one scalar must share the same `rate`, and the
          # region geometry is shared across scalars (per-scalar rate).
          source_regions:
            - pos: "(z >= 30) & (z <= 38)"
              rate: 1.0e-3
              # Optional: a `domain.systems` frame `pos` is written in, baked
              # into the resolved predicate at config load (rotations OK).
              # system: site
        # Double-distribution-function (DDF) energy field. A
        # transported field of `kind: energy` rides the SAME advection-diffusion
        # path as a scalar: it transports the conserved energy density
        # `energy = rho h` (a Gaussian blob here), advected and diffused by the
        # fluid velocity at the thermal diffusivity `alpha` (the energy analogue
        # of the scalar `D`) with turbulent Prandtl number `Pr_t` (the analogue
        # of `Sc_t`). The energy source is the intrinsic deviation-cancelling
        # correction (no user `adv_diff_equation`/`buoyancy`/`source_regions`).
        # The energy set must be rank-4 isotropic (D2Q9/D3Q15/D3Q19/D3Q27);
        # D2Q5/D3Q7 are rejected. See the DDF energy theory page.
        energy:
          kind: energy
          velocity_set: D3Q27
          collision_operator: RRBGK
          interval: {start_step: 0, end_step: 0}
          # Thermal diffusivity mu/(rho_0 Pr) (constant), in the slot the
          # scalar uses for `D`.
          alpha: 1.0e-3
          # Turbulent Prandtl number, in the slot the scalar uses for `Sc_t`.
          # Consumed only when LES is active; runtime tunable.
          Pr_t: 0.7
          # Initial energy density field; may reference (x, y, z) in lattice
          # coordinates.
          initial_field: "exp(-((x-8)*(x-8) + (y-8)*(y-8) + (z-8)*(z-8)) / 8.0)"

      # ----------------------------------------------------------------
      # Volumetric (region) boundary conditions
      # ----------------------------------------------------------------
      # Unlike surface BCs, a volumetric region acts on every fluid node inside a
      # region of the domain. Each region is placed by the same `pos` a surface
      # BC takes: a cardinal alias, a predicate over x, y, z, or a shape. Two
      # coefficients combine on region nodes, both baked into the kernel as Guo
      # body forces:
      #   porous_alpha -> linear Darcy sink F[a] = -alpha * u[a]
      #   porous_beta  -> quadratic Forchheimer/canopy drag F[a] = -beta * |u| * u[a]
      # All regions share one alpha and one beta (the same value must be repeated
      # on every region); both coefficients are level-0 lattice values, rescaled
      # by 1/2^lvl per refinement level.
      volumetric_regions:
        # Linear Darcy region: an outlet sponge that damps pressure waves and
        # near-outlet velocity proportionally to the local speed (beta omitted -> 0).
        - pos: "(x >= 480) & (x <= 500)"
          porous_alpha: 0.2
          porous_beta: 0.05
        # Quadratic canopy region: a forest/canopy momentum sink whose drag grows
        # with |u|^2 (Forchheimer / pressure-decay). Shares the single alpha/beta.
        - pos: "(z >= 0) & (z <= 20) & (x >= 100) & (x <= 200)"
          porous_alpha: 0.2
          porous_beta: 0.05
          # Optional: a `domain.systems` frame the predicate is written in.
          # Baked into `pos` at config load; rotations supported.
          # system: site

      # ----------------------------------------------------------------
      # Variable-density low-Mach thermal closure (Taha et al. 2024)
      # ----------------------------------------------------------------
      # Opt-in Tier 3 of the thermal modelling hierarchy: the density is
      # slaved to the ideal-gas equation of state rho = P / (r T) instead of
      # the population sum, and buoyancy is the exact (rho - rho_inf) * gravity
      # term (the Boussinesq force is its linearised small-dT limit). Unset
      # this block to keep the isothermal / Boussinesq path bit-identical.
      # D3Q27 only; mutually exclusive with the legacy LBM.thermal_model flag.
      low_mach:
        # Specific gas constant in lattice units, closing rho = P / (r T).
        r: 1.0
        # Spatially uniform thermodynamic pressure (lattice units). For an
        # open domain dP/dt = 0 and it stays at this ambient value.
        P_thermo: 1.0
        # Reference / ambient temperature (lattice units), used by the EOS
        # and as the buoyancy reference state.
        T_ref: 1.0
        # Ambient density in the buoyancy term (rho - rho_inf) * gravity.
        # Omit to default to the EOS-consistent value P / (r T_ref).
        rho_inf: 1.0
        # Molecular Prandtl number: thermal conductivity lambda = cp mu(T) / Pr.
        Pr: 0.7
        # Constant specific heat at constant pressure (lattice units). The energy
        # field transports specific enthalpy h and derives T = T_ref + h/cp.
        cp: 1.0
        # Reference dynamic viscosity mu_0 of the law mu(T) = mu_0 (T/T_ref)^n.
        # Omit to default to the molecular lattice value nu_0 = (tau - 1/2)/3.
        mu_ref: 0.05
        # Power-law exponent n of mu(T) = mu_0 (T/T_ref)^n. 0 (default) is a
        # constant viscosity; the effective diffusivity alpha_eff = mu/(rho Pr)
        # still varies through the EOS density.
        mu_exponent: 0.0
        # Gravitational acceleration vector (lattice units), along gravity.
        gravity: [0.0, 0.0, -1.0e-5]
        # Thermodynamic-pressure closure. 'open' pins P to ambient
        # (dP/dt = 0); valid here because the domain vents via the W inlet and the
        # E RegularizedNeumannOutlet on the fluid BC_map above. 'closed' instead
        # evolves P(t) = M r / integral(1/T dV) for a sealed (no inlet/outlet),
        # heated enclosure - a heated wall-sealed domain must use 'closed'.
        domain_closure: open
        # Finite-difference energy field: transports specific
        # enthalpy h per rho Dh/Dt = div(lambda grad T) + Q, deriving the
        # temperature T = T_ref + h/cp. Conduction uses the per-node diffusivity
        # alpha_eff = mu(T)/(rho Pr) (variable-lambda face-flux form); advection
        # u.grad h is upwind. Omit the whole block to default to a uniform T_ref
        # start with no heat source.
        energy:
          # Initial temperature field T(x, y, z) in lattice units; the enthalpy
          # is seeded as h = cp (T - T_ref). A uniform ambient start is just the
          # constant T_ref.
          initial_field: "1.0"
          # Volumetric heat sources (a burner): a constant volumetric heat-release
          # rate Q (energy per unit volume per unit time, lattice units) on nodes
          # inside each predicate region; Q enters rho Dh/Dt (added as Q/rho). The
          # kernel bakes one rate, so every region must share the rate.
          source_regions:
            - pos: "(x >= 6) & (x <= 10) & (y >= 6) & (y <= 10)"
              rate: 1.0e-3
          # Temperature wall BCs on the domain faces of the FD energy field.
          # One entry per cardinal face, same W/E/S/N/B/F
          # convention as the fluid BCs (W=x-min, E=x-max, S=y-min, N=y-max,
          # B=z-min, F=z-max). A face with no entry keeps the adiabatic ghost
          # fallback; a periodic axis (models.BC.periodic_dims) may not carry a
          # wall BC. Aliases: TempDirichlet (fixed T_w), TempNeumann (fixed flux
          # J_w, default 0.0 = adiabatic), TempRobin (convective h, T_inf).
          wall_bcs:
            - pos: "W"             # x-min wall held at a fixed temperature
              BC: TempDirichlet
              T_w: 2.0
            - pos: "E"             # x-max convective (Robin) wall
              BC: TempRobin
              h: 0.1
              T_inf: 1.0

      # Tier 2 weakly-compressible thermal coupling. The shared
      # finite-difference energy field feeds a temperature deviation
      # theta = T / T_ref - 1 into the equilibrium so p = rho cs^2 (1 + theta);
      # the density stays rho = sum_i f_i (unlike the Tier 3 low_mach closure).
      # Shown commented because it requires LBM.thermal_model: true and is
      # MUTUALLY EXCLUSIVE with the low_mach block active above.
      # energy:
      #   # Reference temperature for the deviation rescale theta = T/T_ref - 1.
      #   T_ref: 1.0
      #   # Molecular Prandtl number: thermal conductivity lambda = cp mu(T) / Pr.
      #   Pr: 0.7
      #   # Constant specific heat (caloric relation T = T_ref + h/cp).
      #   cp: 1.0
      #   # Reference dynamic viscosity mu_0 (omit -> nu_0 = (tau - 1/2)/3) and
      #   # power-law exponent n of mu(T) = mu_0 (T/T_ref)^n.
      #   mu_ref: 0.05
      #   mu_exponent: 0.0
      #   # The temperature field itself - the SAME EnergyFieldConfig the
      #   # low_mach closure uses (initial condition, volumetric heat sources,
      #   # temperature wall BCs). Omit to default to a uniform T_ref start.
      #   field:
      #     initial_field: "1.0"
      #     wall_bcs:
      #       - pos: "W"
      #         BC: TempDirichlet
      #         T_w: 2.0
      #       - pos: "E"
      #         BC: TempDirichlet
      #         T_w: 1.0

    # ----------------------------------------------------------------
    # Debug
    # ----------------------------------------------------------------
    # Developer flags - leave all values at their defaults for production runs.
    debug:
      IBM: {no_force_spread: false, no_nodes_export: false}
      # no_force_spread: true skips IBM force spreading (diagnostics only)
      # no_nodes_export: true suppresses IBM node export even if configured
      LBM: {collision_only: false, no_macrs_export: false, streaming_only: false}
      # collision_only: true runs only the collision step (no streaming)
      # no_macrs_export: true suppresses all macroscopic field output
      # streaming_only: true runs only the streaming step (no collision)
      code_generation: {load_generated_files: false, save_generated_files: false}
      # load_generated_files: true reuses previously generated CUDA source files
      # instead of regenerating - never use in production
      # save_generated_files: true writes the generated CUDA source and compile log
      # to the run's code_generated/ folder; off by default so a normal run never
      # writes to disk (works on read-only package installs) - diagnostics only
      multiblock: {export_comm_vtk: false, run_communication: true}
      # export_comm_vtk: true writes block-communication debug VTK files
      # run_communication: false skips multiblock communication (diagnostics)
      output_IBM_nodes: false # True to print IBM node data to stdout
      output_only: false # True to run export routines without advancing the LBM
      profile: false # True to enable CUDA profiling markers
      profile_kernels: false # True to record per-kernel GPU timings (CUDA
      # events) and the host-overhead metric into info.yaml
      isolate:      # Run only one subsystem's kernels in the step loop (solo
      # mode for performance measurement): LBM, comm, IBM, BC, scalar, SEM or
      # proc. Setup and the init kernel sequence always run full.