C++ Implementation & Code Architecture

This page documents the solver architecture, memory model, parallelization strategy, build system, and test suite. Every design decision is explained with its performance and engineering rationale.

1. Module Reference

lbm_types.hpp D2Q9 lattice constants, index helpers, MRT relaxation parameters, equilibrium function, LES globals src/lbm_types.hpp
lbm.hpp Core solver: MRT collide + Smagorinsky LES, stream + Bouzidi bounce-back, BCs, momentum-exchange force extraction, JSON/VTK output src/lbm.hpp
geometry.hpp NACA 4-digit coordinates, polygon operations, point-in-polygon tests src/geometry.hpp
amr.hpp Block-structured adaptive mesh refinement: AMRBlock / AMRGrid, prolongation, restriction, regridding src/amr.hpp
main.cpp Cylinder wake entry point (LBM_Engine), auto-LES activation src/main.cpp
cavity.cpp Lid-driven cavity benchmark (LBM_Cavity) src/cavity.cpp
step.cpp Backward-facing step, separation and reattachment (LBM_Step) src/step.cpp
flat_plate.cpp Flat plate boundary layer, primary validation, NACA geometry (LBM_FlatPlate) src/flat_plate.cpp
orifice_plate.cpp Single and multi-stage orifice plate, staggered paths (LBM_OrificePlate) src/orifice_plate.cpp
urban_canyon.cpp Urban canyon, side and top-down modes (LBM_UrbanCanyon) src/urban_canyon.cpp
downwash.cpp Building downwash, scaled urban blocks (LBM_Downwash) src/downwash.cpp
cylinder_near_wall.cpp Cylinder near wall, ground-effect study (LBM_CylinderNearWall) src/cylinder_near_wall.cpp
side_by_side_cylinders.cpp Side-by-side cylinders, transverse interference (LBM_SideBySide) src/side_by_side_cylinders.cpp
rotating_cylinder.cpp Rotating cylinder, Magnus effect, Ladd moving boundary (LBM_RotatingCylinder) src/rotating_cylinder.cpp
amr_test.cpp AMR demonstration driver on the cylinder case (LBM_AMR) src/amr_test.cpp
lbm_test.cpp 12 Google Test cases: equilibrium, macros, indexing, bounce-back, obstacle, force, mass conservation, params src/lbm_test.cpp
postprocess.py VTK/JSON to PNG with --split, --cmap, --strouhal, --vorticity; JSON frame reader for the web gallery scripts/postprocess.py
CMakeLists.txt Build system: C++20, OpenMP, Google Test via FetchContent, per-case build targets CMakeLists.txt

2. Memory Model

The 2D fluid grid is stored in a single std::vector<double> with 1D indexing. This is the single most important performance decision in the code. Compare the two approaches:

// BAD: Nested vector, non-contiguous memory, pointer indirection per row
std::vector<std::vector<std::vector<double>>> f_bad(NY, std::vector<std::vector<double>>(NX, std::vector<double>(9)));

// GOOD: Flat 1D array, contiguous memory, single allocation, cache-friendly
std::vector<double> f(NX * NY * 9);
// Access at (x, y, i):  f[(y * NX + x) * 9 + i]

The flat layout ensures that the 9 distributions at a single node are stored in consecutive memory addresses. During the collision step, the CPU prefetcher can load all 9 values into cache in a single cache line operation. With nested vectors, each row access requires a separate pointer dereference and heap allocation lookup.

Performance Impact. For a 400 × 150 grid with 540,000 distribution values, the flat layout eliminates 150 separate heap allocations and their associated cache misses. On Apple M-series hardware, this translates to approximately 2-3× improvement in collision throughput over naively nested vectors.

3. The Core Solver Loop

The execution flow for each timestep is:

1. enforce_inflow()    Zou/He velocity inlet at x = 0
2. enforce_outflow()   Convective outlet at x = NX-1
3. collide()           BGK relaxation (OpenMP parallel)
4. stream()            Propagate + bounce-back (OpenMP parallel)
5. extract_forces()    Momentum exchange on cylinder surface

The collision step, shown below, is the computational heart of the solver. It applies the BGK operator at every fluid node:

// Collision step: relax each node toward equilibrium
#pragma omp parallel for collapse(2)
for (int y = 0; y < NY; ++y) {
  for (int x = 0; x < NX; ++x) {
    int idx = y * NX + x;
    if (sys.obstacle[idx]) continue;

    double rho = 0.0, mx = 0.0, my = 0.0;
    double* f_node = &sys.f[idx * 9];
    for (int i = 0; i < 9; ++i) {
      rho += f_node[i];
      mx  += f_node[i] * cx[i];
      my  += f_node[i] * cy[i];
    }
    double u = mx / rho, v = my / rho;

    for (int i = 0; i < 9; ++i) {
      double feq = compute_equilibrium(i, rho, u, v);
      f_node[i] -= (1.0 / tau) * (f_node[i] - feq);
    }
  }
}

Key optimizations in this loop:

4. OpenMP Parallelization

Both the collision and streaming loops are parallelized using #pragma omp parallel for collapse(2). The collapse(2) clause fuses the nested y and x loops into a single iteration space, enabling better load balancing across threads:

#pragma omp parallel for collapse(2)
for (int y = 0; y < NY; ++y) {
  for (int x = 0; x < NX; ++x) {
    // ... collision work ...
  }
}

Without collapse(2), if NY = 150 and NX = 400, only 150 chunks would be distributed across threads, causing load imbalance when some rows contain obstacle nodes. With collapse(2), 60,000 individual node tasks are distributed, giving near-perfect load balance.

Collision and streaming are separate parallel regions; they cannot be fused into a single loop because streaming reads from the post-collision state of neighboring nodes, introducing a data dependency across grid cells.

5. Force Extraction: Momentum Exchange Method

The forces on the cylinder are computed by summing momentum transfers across all fluid-obstacle boundary links. Each boundary link connects a fluid node adjacent to an obstacle node:

// For each obstacle node, check all 9 neighbor directions
for (int y = 0; y < NY; ++y) {
  for (int x = 0; x < NX; ++x) {
    int node_idx = y * NX + x;
    if (!sys.obstacle[node_idx]) continue;

    for (int i = 0; i < 9; ++i) {
      int nx = x + cx[i], ny = y + cy[i];
      // ... boundary check + periodicity ...
      int fluid_idx = ny * NX + nx;
      if (!sys.obstacle[fluid_idx]) {
        double delta = sys.f[fluid_idx * 9 + i] - sys.f[node_idx * 9 + bounce_back[i]];
        fx += cx[i] * delta;
        fy += cy[i] * delta;
      }
    }
  }
}

This method is exact in the LBM framework (no modeling error for stationary boundaries) and requires no body-fitted mesh or interpolation; the forces emerge naturally from the distribution functions at the boundary.

6. Build System & Quick Start

# Configure and build all targets
cmake -B build && cmake --build build

# Run a case via its CMake target
cmake --build build --target sim        # cylinder (LBM_Engine)
cmake --build build --target cavity     # lid-driven cavity
cmake --build build --target flat_plate # flat plate boundary layer
cmake --build build --target amr        # AMR demonstration

# Or run the executable directly with arguments
./build/LBM_Engine 200          # cylinder at Re=200
./build/LBM_Engine 100 12000    # cylinder at Re=100 for 12000 steps

# Run the Google Test suite (12 cases)
./build/LBM_Tests

# Batch sweep across cases
bash scripts/run_all_cases.sh

# Post-process VTK to JSON for the web viewer
python3 scripts/postprocess.py output/re100 --json --every 5

# Preview the documentation site
python3 -m http.server -d docs 8765

7. Build Configuration

SettingValueRationale
C++ StandardC++20std::span, std::array, constexpr, designated initializers
Optimization-O2 (default), -O3 -march=native (optional)Aggressive inlining for small hot functions (compute_equilibrium)
OpenMPRequiredParallel collision and streaming loops
Google TestFetchContentAutomatic download, no system install required
CompilerClang (macOS), GCC (Linux)Homebrew GCC on macOS for OpenMP compatibility

8. Test Suite

The test suite covers 12 test cases across 8 test groups:

GroupTestsWhat It Validates
EquilibriumTest4Sum = rho, momentum matching, rest state, all directions covered
MacrosTest2Recovery of input rho/u/v from equilibrium, zero velocity
IndexTest11D index formula for nodes
BounceBackTest1Opposite-direction mapping for bounce-back
ObstacleTest1Cylinder placement, interior/exterior checks
ForceTest1Force vector reset
TimeStepTest1Mass conservation over multiple timesteps
ParamsTest1tau-from-Re calculation

9. Solver Output & the ML Data Pipeline

Every simulation run emits a structured result set that doubles as training data for the neural-network surrogate. Per frame, the solver writes frame_*.json containing the velocity components (u, v), the vorticity field (omega), and the pressure field (p) on a downsampled grid; a meta.json records the grid dimensions, lattice spacing, Reynolds number, relaxation time, and inflow velocity; and forces.jsonl appends time-resolved drag and lift coefficients. Paraview VTK is written alongside for 3D inspection and offline post-processing.

scripts/postprocess.py converts the VTK output to JSON/PNG and can slice individual frames for the web gallery (with --split, --cmap, --strouhal, and --vorticity flags). The neural-network data loader (pinn/data/loader.py) then ingests those frame*.json files into NumPy arrays, samples sensor points (importance-sampled near the geometry) and collocation points for the PDE residual, and normalizes the coordinates (x, y) to [-1, 1] and the physical parameters (e.g. Reynolds number to Re_n). This JSON-to-NumPy step is the bridge that turns a multi-hour C++ simulation into a training batch.

This result pipeline is exactly what feeds the surrogate work documented next: Physics-Informed Neural Networks.

10. Continuous Integration

GitHub Actions builds and tests on both Ubuntu and macOS on every push and pull request:

.github/workflows/ci.yml
  - matrix: [ubuntu-latest, macos-latest]
  - cmake -B build
  - cmake --build build
  - ./build/LBM_Tests