OpenFOAM: parameter sweeps, automation and parallel computing

by giux CFD and OpenFOAM, Featured 33 min read

Fifth operating guide on OpenFOAM installed on a Linux server and used from Windows, after those on server and transfers, monitoring and convergence, quantitative analysis with ParaView and verification and validation. This one takes you from a single case to an automated parameter campaign, in parallel with MPI as well. The deep-dive boxes open with a click and include, among other things, the full text of the package scripts.

OpenFOAM Foundation 14 · Linux server / Windows client · Practical handbook

Purpose. Move from a single CFD run to a documented, reproducible campaign. This guide provides downloadable Bash and Python scripts, an exercise using the official incompressibleFluid/cylinder tutorial, safeguards against overwriting existing work and analysis of results from Windows. All illustrative curves are synthetic and do not represent CFD simulations actually run for this guide.

Prerequisites. SSH/PowerShell, FileZilla and Notepad++ (Guide 1); logs and residuals (Guide 2); ParaView quantitative analysis (Guide 3); verification and mesh independence (Guide 4). No other editor is needed.

A typical parameter-sweep workflow
A typical parameter-sweep workflow

1. What is a CFD parameter sweep?

A sweep runs multiple separate CFD cases, ideally changing one independent variable at a time. In this exercise only the inlet velocity of flow around a cylinder varies; the geometry, mesh, viscosity, numerical schemes and boundary-condition types stay fixed. We measure the difference between the area-averaged kinematic pressures at the inlet and outlet patches. This is neither the drag force on the cylinder nor the isolated loss of the cylinder alone.

CaseInlet velocity [m/s]Geometry and meshExecution
U0050.005unchangedsequential, serial or MPI
U0100.010unchangedsequential, serial or MPI
U0150.015unchangedsequential, serial or MPI
U0200.020unchangedsequential, serial or MPI

The scientific aim is not simply four output values, but to capture the inputs, solver completion status, errors, flux imbalance, sampling time, units and the quality checks used to interpret each number.

Optional refresher — experimental control. Changing both viscosity and inlet velocity prevents attributing a pressure variation exclusively to velocity. Multifactor campaigns are possible, but require a documented experimental design and a separate column for each factor.

Deep dive · Why the inlet–outlet Δp is not the cylinder drag

The difference between the mean pressures on the inlet and outlet patches collects everything that happens in the domain: the loss caused by the cylinder, any friction on the side boundaries, the momentum-flux difference between two non-uniform velocity profiles and the wake not yet recovered at the outlet section. That is why it changes when the outlet is moved downstream, even with the same cylinder.

The force on the cylinder alone is obtained by integrating pressure and viscous stresses over its surface, with the forces function object (or forceCoeffs for dimensionless coefficients) applied to the cylinder patch. The exact patch name is in constant/polyMesh/boundary after blockMesh and mirrorMesh. In incompressible cases p is kinematic and the force must be converted to newtons with a declared reference density, exactly like the Δp in this guide: the version 14 keywords are in the forces API documentation.

# patch names and types after mesh generation
less constant/polyMesh/boundary
Deep dive · The Reynolds number of the campaign

For a cylinder of diameter D in a stream of velocity U and kinematic viscosity ν, the parameter governing the regime is Re = U·D/ν. The case viscosity is in constant/physicalProperties, the diameter follows from the geometry in system/blockMeshDict. In the campaign the velocity changes by a factor of 4 (from 0.005 to 0.020 m/s), so Re changes by a factor of 4 as well.

For a circular cylinder in an unbounded stream the wake is steady up to Re ≈ 47; above it periodic vortex shedding appears. If the campaign range crosses this threshold, some cases will be steady and others periodic: for the latter the last recorded sample depends on the phase of the oscillation and the comparison must use time averages over a whole number of periods. Confinement shifts the threshold, so it must be checked in the logs and samples, not assumed.

# case viscosity and geometry
foamDictionary constant/physicalProperties -entry nu -value
less system/blockMeshDict

2. Why use the official cylinder tutorial?

The worked example uses $FOAM_TUTORIALS/incompressibleFluid/cylinder, supplied with OpenFOAM Foundation 14. Its official Allrun calls blockMesh, mirrorMesh and foamRun in that order. Its 0/U field declares a top-level Uinlet scalar, reused by the inlet boundary condition. We can therefore alter the inlet speed through the documented foamDictionary utility without building a custom solver or recreating the case.

Version matters. Check foamVersion after sourcing the installed OpenFOAM 14 environment. The preparation script refuses to proceed when the reported version is not 14. OpenFOAM.com releases and older Foundation releases can have different dictionaries, function-object syntax and tutorial layouts.

# SSH session from a Windows workstation
ssh user@server
source /opt/OpenFOAM-14/etc/bashrc       # adjust for your installation
foamVersion
printf '%s\n' "$FOAM_TUTORIALS"
ls "$FOAM_TUTORIALS/incompressibleFluid/cylinder"

The original tutorial is read and copied, never edited in place. The download does not bundle third-party copies of the tutorial files: it uses the already-installed official case.

Deep dive · foamDictionary: reading and writing entries without an editor

foamDictionary parses the dictionary the way OpenFOAM does, so it is more reliable than sed or grep on files with macros, comments and sub-dictionaries. Nested entries are addressed with a slash.

foamDictionary 0/U -keywords                         # top-level keywords
foamDictionary 0/U -entry Uinlet -value             # velocity value
foamDictionary 0/U -entry boundaryField/inlet       # condition on the inlet patch
foamDictionary -expand 0/U                          # file with $Uinlet macros substituted
foamDictionary system/controlDict -entry endTime -value
foamDictionary system/controlDict -entry functions -keywords

The last line is the quickest way to check, after preparation, that the controlDict of each case contains pIn, pOut, qIn and qOut. Always read the tutorial Allrun as well (cat "$FOAM_TUTORIALS/incompressibleFluid/cylinder/Allrun"): it is the official description of the sequence the scripts reproduce.

3. Contents and layout of the example package

The download includes examples/cylinder_campaign:

cylinder_campaign/
  config/velocities.csv
  config/measurement_functions.foam
  scripts/prepare_campaign.sh
  scripts/run_campaign.sh
  scripts/collect_results.py
  scripts/plot_results.py
  README_FIRST_IT.txt / README_FIRST_EN.txt
  LICENSE.txt
  work/cases/          (created later, not in the download)
  results.csv          (generated after measurements are collected)
Each case is independent
One isolated directory per run
FileRole
velocities.csvsingle explicit table of input parameters
prepare_campaign.shcreates four clean cases without replacing existing ones
measurement_functions.foamrecords pressure averages and volumetric fluxes
run_campaign.shbuilds and checks the mesh; runs serially or with MPI
collect_results.pyextracts final recorded samples and flags missing values
plot_results.pyplots only rows marked as solver-finished

Each created case preserves parameters.csv, input.sha256, status.txt, its own logs/ and the solver outputs. The work/ directory is created during preparation, not pre-populated with fictional CFD outputs.

Deep dive · The complete configuration files

config/velocities.csv: one row per case, mandatory header, decimal point.

case_id,U_m_s
U005,0.005
U010,0.010
U015,0.015
U020,0.020

config/measurement_functions.foam: the functions block the script appends to the controlDict of each case.

// Tutorial package: quantities measured during the solver run.
// This block is appended to the clean OpenFOAM-14 cylinder controlDict.
functions
{
    pIn
    {
        type surfaceFieldValue;
        libs ("libfieldFunctionObjects.so");
        patch inlet;
        operation areaAverage;
        fields (p);
        writeControl writeTime;
        writeFields false;
        log true;
    }
    pOut
    {
        type surfaceFieldValue;
        libs ("libfieldFunctionObjects.so");
        patch outlet;
        operation areaAverage;
        fields (p);
        writeControl writeTime;
        writeFields false;
        log true;
    }
    qIn
    {
        type surfaceFieldValue;
        libs ("libfieldFunctionObjects.so");
        patch inlet;
        operation sum;
        fields (phi);
        writeControl writeTime;
        writeFields false;
        log true;
    }
    qOut
    {
        type surfaceFieldValue;
        libs ("libfieldFunctionObjects.so");
        patch outlet;
        operation sum;
        fields (phi);
        writeControl writeTime;
        writeFields false;
        log true;
    }
}

The scripts are distributed under the GNU GPL version 3 or later; the OpenFOAM tutorial files are not included in the package and remain under their own licence. The full text of the four scripts is given further on, in the sections where they are used.

4. Prepare on Windows using familiar tools

Download and extract the ZIP locally. Open config/velocities.csv with Notepad++. Retain the header case_id,U_m_s; use decimal periods such as 0.015 and unique case identifiers. Bash .sh scripts must use UNIX LF line endings. In Notepad++, use Edit → EOL Conversion → Unix (LF) if required.

Upload the whole cylinder_campaign directory with FileZilla to a personal server location such as /home/user/cases/cylinder_campaign, using SFTP. Then connect using PowerShell/SSH and check the transferred files:

cd ~/cases/cylinder_campaign
ls config scripts
head -5 config/velocities.csv
bash -n scripts/prepare_campaign.sh
bash -n scripts/run_campaign.sh

No chmod step is necessary when launching with bash scripts/name.sh. If the file was saved as CRLF, Bash may report $'\r': command not found. Correct line endings before investigating anything else.

Deep dive · Checking line endings, BOM and invisible characters on the server
file scripts/*.sh config/velocities.csv      # reports "with CRLF line terminators" and "with BOM"
grep -c $'\r' scripts/run_campaign.sh          # lines with CR: must be 0
sed -i 's/\r$//' scripts/*.sh                 # fix if dos2unix is not installed

The CSV is more forgiving than the scripts: prepare_campaign.sh strips any trailing \r from each field itself, so a CSV with Windows line endings is read correctly. It does not tolerate the BOM that some programs (Excel above all) add at the start of UTF-8 files: the header becomes “\ufeffcase_id”, is not recognised as the header, fails the name check and the script stops with ERROR: invalid row and exit code 3. In Notepad++ choose Encoding → UTF-8 (without BOM) before saving.

bash -n only checks syntax: it executes nothing, so it does not catch missing environment variables or wrong paths. The scripts perform those checks themselves at start-up.

5. Create isolated cases without changing the source

Run preparation only, not the solver:

bash scripts/prepare_campaign.sh
find work/cases -maxdepth 2 -name parameters.csv -print

For each CSV row, the script copies only the case input directories 0, constant and system from the installed tutorial. At its core, it applies the equivalent of:

foamDictionary 0/U -entry Uinlet -set 0.015
foamDictionary 0/U -entry Uinlet -value

It appends measurement function objects to controlDict, calculates SHA-256 hashes for key input files and writes status PREPARED. Before running, the batch checks these hashes; any subsequent input change causes the case to stop. An existing case directory is always skipped. This prevents accidental loss but also means editing velocities.csv will not modify previously prepared cases. Use new identifiers or a new campaign directory for revised experiments.

Practical note — revisions. After any calculation has started, make a new campaign or name the revised case U015_rev2. Avoid automated rm -rf, decomposePar -force or cleanup procedures that destroy results in an attempt to “start fresh”.

Deep dive · The prepare_campaign.sh script, line by line
#!/usr/bin/env bash
# GPL-3.0-or-later. Prepare fresh, isolated cases from the official
# OpenFOAM Foundation 14 incompressibleFluid/cylinder tutorial.
set -Eeuo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
source_case="${FOAM_TUTORIALS:-}/incompressibleFluid/cylinder"
case_root="$root/work/cases"

if ! command -v foamVersion >/dev/null 2>&1 || ! command -v foamDictionary >/dev/null 2>&1; then
  echo "ERROR: source the OpenFOAM Foundation 14 environment first." >&2; exit 2
fi
if [[ "$(foamVersion)" != *14* ]]; then
  echo "ERROR: this package targets OpenFOAM Foundation 14, got: $(foamVersion)" >&2; exit 2
fi
if [[ ! -f "$source_case/0/U" || ! -f "$source_case/system/blockMeshDict" ]]; then
  echo "ERROR: missing official tutorial at $source_case" >&2; exit 2
fi
if [[ ! -d "$case_root" ]]; then mkdir -p -- "$case_root"; fi

while IFS=, read -r name speed || [[ -n "${name:-}" ]]; do
  name=${name%$'\r'}; speed=${speed%$'\r'}
  [[ "$name" == case_id || -z "$name" ]] && continue
  if [[ ! "$name" =~ ^[A-Za-z0-9_-]+$ || ! "$speed" =~ ^(0|[0-9]+)(\.[0-9]+)?([eE][-+]?[0-9]+)?$ ]] ||
     ! awk -v v="$speed" 'BEGIN{exit !(v>0 && v<=1)}'; then
    echo "ERROR: invalid row: $name,$speed" >&2; exit 3
  fi
  case_dir="$case_root/$name"
  if [[ -e "$case_dir" ]]; then
    echo "SKIP existing case $case_dir (no overwrite)"; continue
  fi
  mkdir -- "$case_dir"
  # Copy only case INPUTS, never any pre-existing results or solver logs.
  cp -a -- "$source_case/0" "$source_case/constant" "$source_case/system" "$case_dir/"
  if [[ -d "$case_dir/constant/polyMesh" ]]; then
    echo "ERROR: source unexpectedly contains a mesh; review case $case_dir" >&2; exit 4
  fi
  foamDictionary "$case_dir/0/U" -entry Uinlet -set "$speed" >/dev/null
  cat "$root/config/measurement_functions.foam" >> "$case_dir/system/controlDict"
  printf 'case_id,U_m_s\n%s,%s\n' "$name" "$speed" > "$case_dir/parameters.csv"
  (cd "$case_dir" && sha256sum 0/U system/controlDict system/fvSolution system/fvSchemes constant/physicalProperties constant/momentumTransport > input.sha256)
  printf 'PREPARED\n' > "$case_dir/status.txt"
  echo "CREATED $name U=$speed m/s"
done < "$root/config/velocities.csv"
  • set -Eeuo pipefail: the first failing command, an undefined variable or an error inside a pipe stops the script.
  • Three preliminary checks, all with exit code 2: OpenFOAM environment loaded (foamVersion and foamDictionary available), version containing “14”, tutorial present with 0/U and system/blockMeshDict.
  • Each CSV row is validated: name made only of letters, digits, hyphen and underscore; numeric velocity between 0 (excluded) and 1 m/s. An invalid row stops everything with exit code 3. For velocities above 1 m/s, change the v>0 && v<=1 condition in the awk line.
  • If the case directory exists, SKIP is printed and the next row is processed: this is the mechanism that prevents overwriting.
  • Only 0, constant and system are copied; if the copy already contained a constant/polyMesh the script stops with exit code 4, because the mesh must be generated in the case, not inherited.
  • foamDictionary … -set writes the velocity, the measurement block is appended to the controlDict, then parameters.csv, the input.sha256 fingerprint and the PREPARED status are written.

The fingerprint covers six files: 0/U, system/controlDict, system/fvSolution, system/fvSchemes, constant/physicalProperties and constant/momentumTransport. Changes to these files after preparation block the case; changes to other files (for example system/blockMeshDict or system/decomposeParDict) are not detected and must be documented by hand.

# manual check of a prepared case
cd work/cases/U015
cat parameters.csv status.txt
sha256sum -c input.sha256
foamDictionary 0/U -entry Uinlet -value

6. Execute the campaign with a Bash batch

For serial cases, run:

bash scripts/run_campaign.sh
# Inspect from another SSH session:
find work/cases -name status.txt -exec sh -c 'echo "$1: $(cat "$1")"' _ {} \;
tail -n 25 work/cases/U015/logs/*.foamRun.log

For each case, the script performs blockMesh, mirrorMesh, checkMesh and foamRun. Logs are saved one file per stage as logs/<timestamp>.<stage>.log. Non-zero exit codes mark the case as failed and prevent setting its completed flag. Other cases may continue, but the campaign returns an error if any case fails.

Hidden marker files .block_mesh_done, .mirror_mesh_done, .mesh_checked, .run_started and .completed prevent unplanned repeated meshing or re-running. Do not change them without first inspecting the actual solver files and logs.

Crucial distinction: SOLVER_FINISHED means the solver exited successfully and measurement files were located. It does not guarantee physically valid, time-independent or mesh-independent results. Use the checks covered in Guides 2 and 4.

Deep dive · The run_campaign.sh script and its exit codes
#!/usr/bin/env bash
# GPL-3.0-or-later. Sequential campaign, optional MPI per case.
# Never deletes, force-recreates or overwrites existing case results.
set -Eeuo pipefail
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
case_root="$root/work/cases"
np=1
resume=0
while (($#)); do
  case "$1" in
    --np) (($#>=2)) || { echo '--np requires an argument' >&2; exit 2; }; np=$2; shift 2 ;;
    --resume) resume=1; shift ;;
    *) echo "Usage: bash scripts/run_campaign.sh [--np N] [--resume]" >&2; exit 2 ;;
  esac
done
[[ "$np" =~ ^[1-9][0-9]*$ ]] || { echo 'ERROR: --np must be positive integer' >&2; exit 2; }
command -v foamRun >/dev/null 2>&1 || { echo 'ERROR: source OpenFOAM first' >&2; exit 2; }
[[ -d "$case_root" ]] || { echo 'ERROR: run prepare_campaign.sh first' >&2; exit 2; }

execute_case() (
  set -Eeuo pipefail
  case_dir="$1"
  cd "$case_dir"
  name=$(basename "$case_dir")
  if [[ -f .completed ]]; then echo "$name: SKIP completed"; return 0; fi
  if [[ -f .run_started && "$resume" -eq 0 ]]; then
    echo "$name: SKIP unfinished previous run; inspect logs and use --resume explicitly" >&2
    return 8
  fi
  if [[ -f .nproc && "$(cat .nproc)" != "$np" ]]; then
    echo "$name: ERROR, processor count changed; use the original --np" >&2
    return 5
  fi
  if [[ ! -f .nproc ]]; then printf '%s\n' "$np" > .nproc; fi
  stamp=$(date -u +%Y%m%dT%H%M%SZ)_$$
  mkdir -p logs
  printf 'RUNNING\n' > status.txt
  : > .run_started
  fail() { printf 'FAILED\n' > status.txt; echo "$name: FAILED; see logs/$stamp.*" >&2; }
  # Catch accidental edits after preparation: use a fresh identifier for revisions.
  if ! sha256sum -c input.sha256 > "logs/$stamp.input-check.log" 2>&1; then
    echo "$name: ERROR: inputs differ from recorded hashes" >&2
    printf 'FAILED\n' > status.txt
    return 7
  fi
  step() {
    tag=$1; shift
    echo "$name: $tag"
    # Explicit check: bash errexit is disabled inside functions called by `if`.
    if "$@" > "logs/$stamp.$tag.log" 2>&1; then
      return 0
    else
      rc=$?; fail; return "$rc"
    fi
  }
  # Stage stamps prevent repeated mirrorMesh or skipped partial preparations.
  # Before retrying a failed *blockMesh* inspect mesh state: never force-clean.
  if [[ ! -f .block_mesh_done ]]; then
    if [[ -f constant/polyMesh/points ]]; then
      echo 'ERROR: mesh exists without stage marker; inspect manually' >&2; return 4
    fi
    step blockMesh blockMesh || return $?
    : > .block_mesh_done
  fi
  if [[ ! -f .mirror_mesh_done ]]; then
    step mirrorMesh mirrorMesh || return $?
    : > .mirror_mesh_done
  fi
  if [[ ! -f .mesh_checked ]]; then
    step checkMesh checkMesh || return $?
    : > .mesh_checked
  fi
  if ((np>1)); then
    command -v mpirun >/dev/null 2>&1 || { echo 'ERROR: mpirun missing' >&2; return 3; }
    if [[ ! -d processor0 ]]; then step decomposePar decomposePar || return $?; fi
    step foamRun mpirun -np "$np" foamRun -parallel || return $?
    step reconstructPar reconstructPar -latestTime || return $?
  else
    [[ ! -d processor0 ]] || { echo 'ERROR: parallel case cannot be resumed in serial' >&2; return 4; }
    step foamRun foamRun || return $?
  fi
  # Completion here means exit status 0 + data presence, NOT physical convergence.
  for measurement in pIn pOut qIn qOut; do
    find "postProcessing/$measurement" -type f -name '*.dat' -print -quit 2>/dev/null | grep -q . || {
      echo "ERROR: missing measurement $measurement" >&2; fail; return 6;
    }
  done
  printf 'SOLVER_FINISHED\n' > status.txt
  : > .completed
  echo "$name: SOLVER_FINISHED (check physical quality independently)"
)
failures=0
for case_dir in "$case_root"/*; do
  [[ -d "$case_dir" ]] || continue
  if ! execute_case "$case_dir"; then failures=$((failures + 1)); fi
done
if ((failures)); then echo "Campaign has $failures failed case(s)" >&2; exit 1; fi

The execute_case function has its body in round brackets: it runs in a subshell, so the cd into the case and the error settings do not leak into the main loop. A failing case returns a non-zero code, the loop counts the failure and moves to the next case; at the end the script exits with 1 if at least one case failed.

The step function checks the exit code of each command explicitly because, as the comment in the script says, Bash errexit does not apply inside functions called from an if. Each stage writes to logs/<date>_<pid>.<stage>.log, with the date in UTC.

CodeMeaningWhat to do
0case completed or already completed—
2wrong usage, OpenFOAM environment not loaded, preparation missingload the environment, run prepare_campaign.sh
3mpirun not available with --np > 1load the server MPI environment
4mesh present without marker, or parallel case resumed in serialinspect the case by hand
5--np different from the one recorded in .nprocuse the original value
6one of the four measurement files is missingcheck the functions block and the foamRun log
7inputs differ from the recorded fingerprintcreate a case with a new name
8previous run not finished, without --resumeread the logs, then decide whether to use --resume
othercode returned by the failed OpenFOAM commandread the log of the stage reported

If the connection drops without tmux, status.txt stays at RUNNING and the .run_started marker is present: on the next launch the case is skipped with code 8 until --resume is chosen explicitly.

7. MPI parallel runs and practical resource limits

The same cases can run sequentially, each using four MPI processes:

bash scripts/run_campaign.sh --np 4

The MPI branch performs decomposePar, mpirun -np 4 foamRun -parallel and reconstructPar -latestTime. Four is only an example. Check the available CPU and memory (nproc, free -h), account for other server users and distinguish processes per case from concurrently running cases.

ApproachPotential compute demandBenefitLimitation
Sequential, --np 1~1 process at oncepredictableno MPI speed-up
Sequential, --np 4up to 4 processes per casemay speed up larger meshesMPI overhead and memory
Four cases simultaneously × 4 MPI processesup to 16 processespossible with a schedulernot implemented here

Parallel execution can be slower on small meshes due to communication overhead. Preserve the same --np when resuming an already decomposed case; the script explicitly blocks automatic changes.

Optional refresher — domain decomposition. decomposePar partitions the mesh into processor subdomains, commonly kept in processor0, processor1 and so on. These are not interchangeable with campaign case directories, and should never be removed while a solver is active.

Deep dive · decomposeParDict: the file the script does not write

decomposePar reads system/decomposeParDict and creates as many subdomains as numberOfSubdomains says. The script passes --np only to mpirun: the dictionary is neither created nor changed. Before a campaign with --np 4, check in every case that the file exists and that the number matches; otherwise decomposePar fails or creates a number of processor* directories different from the MPI processes and foamRun -parallel stops.

for d in work/cases/*/; do
  printf '%s: ' "$d"
  foamDictionary "$d/system/decomposeParDict" -entry numberOfSubdomains -value 2>/dev/null || echo 'MISSING'
done

A minimal dictionary, to be copied into system/ of each case before the launch (the file is not covered by input.sha256, so the addition must be recorded):

FoamFile
{
    format      ascii;
    class       dictionary;
    object      decomposeParDict;
}

numberOfSubdomains 4;

method          scotch;
Deep dive · Measuring whether parallel pays off: speed-up and efficiency

With T1 the serial run time and Tn the time with n processes, the speed-up is S = T1/Tn and the efficiency E = S/n. An efficiency close to 1 means the cores are well used; low values mean communication and synchronisation outweigh computation. As a practical order of magnitude, parallel runs start to pay off when each process handles at least tens of thousands of cells: the cell count is in the checkMesh log.

# mesh cells and times recorded by foamRun
grep -m1 "cells:" work/cases/U015/logs/*.checkMesh.log
grep ExecutionTime work/cases/U015/logs/*.foamRun.log | tail -n 1
# server load before choosing --np
nproc; free -h; uptime

Do not start more processes than there are free physical cores: on an overloaded server the MPI processes wait for each other and the run becomes slower than in serial.

8. Measure physical quantities during the run

The attached measurement_functions.foam provides four Foundation-style surfaceFieldValue function objects: pIn, pOut, qIn and qOut. The patch names inlet and outlet are grounded in the original cylinder case.

pIn
{
    type surfaceFieldValue;
    libs ("libfieldFunctionObjects.so");
    patch inlet;
    operation areaAverage;
    fields (p);
    writeControl writeTime;
    writeFields false;
    log true;
}

Pressure uses areaAverage(p); flux uses sum(phi). Data appear under directories such as postProcessing/pIn/.... The write cadence follows controlDict. Our collector selects the last available sample, not a time average, and checks that the four measurements refer to a consistent time.

In this incompressible-fluid example, p is a kinematic pressure [m²/s²] and phi is an oriented volumetric flux [m³/s]. Therefore:

ΔPPa = ρ ( ⟨p⟩A,in − ⟨p⟩A,out )

with explicitly specified density rho in kg/m³. The Python command defaults to 1000 kg/m³ as an exercise value only. It is not inferred from the tutorial; replace it with the correct fluid density. Never apply this conversion blindly to a compressible case or a pressure field already expressed in Pa.

Deep dive · Where the data end up and how the collector reads them

Each function object writes into postProcessing/<name>/<start time>/ a text file (typically surfaceFieldValue.dat): header lines beginning with #, then one row per write with the time in the first column and the value in the second. If the run restarts from a later time, a new subdirectory with the new start time is created.

collect_results.py reads all the *.dat files of each measurement, discards headers and non-numeric rows and takes the sample with the largest time: that is why it also copes with restarts. It then checks that the four times coincide and, if they do not, marks the case INCOMPLETE_DATA.

With writeControl writeTime a sample is written only when the fields are written. For a denser history, useful to see whether Δp has settled, use writeControl timeStep; with writeInterval 1; (or another interval). The change must be made in config/measurement_functions.foam before preparation: afterwards it would change the controlDict recorded in the fingerprint and the case would be blocked.

ls work/cases/U015/postProcessing/*/*/
tail -n 3 work/cases/U015/postProcessing/pIn/*/*.dat

9. Flux balance and outward-normal conventions

For a constant-density incompressible fluid without sources, the oriented patch fluxes should approximately obey:

Qin + Qout ≃ 0

The signs come from outward-facing patch normals. The collector computes:

εQ = 100 · |Qin + Qout| / max( |Qin|, |Qout| )

in percent. A small result is consistent with continuity, but does not validate the numerical model by itself. This exercise deliberately does not impose a universal mass-balance tolerance; tolerances must be justified for the intended application.

Deep dive · A numerical example of the balance

With outward normals, the inflow is negative. With example values Qin = −1.000·10⁻³ m³/s and Qout = +0.998·10⁻³ m³/s the sum is −2·10⁻⁶ m³/s and the imbalance is εQ = 100 · 2·10⁻⁶ / 1.000·10⁻³ = 0.2 %.

In an incompressible constant-density flow, continuity holds at every step, transients included: the imbalance therefore mostly reflects the pressure-solver tolerance. A large value is usually not physical: it points to an open patch left out of the sum, a wrong sign or two samples taken at different times. If the domain has other patches with flow, they must be added to the balance.

10. Retrieve and process data on Windows

Using FileZilla, download only what is needed: each case’s parameters.csv, status.txt, logs/, postProcessing/, and optionally the input dictionaries under 0, constant and system. Do not transfer all time directories or every processor* mesh just to inspect logs or collect the final measurements.

You may run the collector directly on the Linux server, or locally on Windows after downloading the campaign with its work/cases/.../postProcessing directories:

# Windows PowerShell, working from the downloaded package
cd C:\CFD\cylinder_campaign
py -3 scripts\collect_results.py --rho 1000
py -3 scripts\plot_results.py results.csv

The collector uses Python’s standard library only. The optional plotter requires Matplotlib (py -3 -m pip install matplotlib). Missing numerical data are marked INCOMPLETE_DATA rather than guessed.

Deep dive · The collect_results.py script
#!/usr/bin/env python3
# GPL-3.0-or-later. No third-party dependencies. Run on Windows or Linux.
"""Read the last numerical records of four OpenFOAM surfaceFieldValue outputs.

Values are NOT convergence/validation certificates. CFD runs are required
before real measurements exist. Assumes OpenFOAM Foundation 14 cylinder case:
p is kinematic pressure [m2/s2]; phi is volumetric flux [m3/s].
"""
import argparse
import csv
import math
from pathlib import Path


def read_measurement(case: Path, name: str):
    files = sorted((case / 'postProcessing' / name).glob('**/*.dat'))
    samples = []
    for file in files:
        for raw in file.read_text(encoding='utf-8', errors='replace').splitlines():
            line = raw.strip()
            if not line or line.startswith('#'):
                continue
            fields = line.split()
            if len(fields) < 2:
                continue
            try:
                t, value = float(fields[0]), float(fields[1])
            except ValueError:
                continue
            if math.isfinite(t) and math.isfinite(value):
                samples.append((t, value))
    if not samples:
        raise ValueError(f'No numerical rows found for {name}')
    return max(samples, key=lambda r: r[0])


def main():
    p = argparse.ArgumentParser()
    p.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1])
    p.add_argument('--rho', type=float, default=1000.0,
                   help='Fluid density kg/m3, default 1000; NOT inferred from tutorial')
    p.add_argument('--output', type=Path, help='CSV destination; default ROOT/results.csv')
    args = p.parse_args()
    if args.rho <= 0 or not math.isfinite(args.rho):
        p.error('rho must be a positive finite number')
    case_root = args.root / 'work' / 'cases'
    if not case_root.is_dir():
        p.error(f'No cases found: {case_root}')
    fields = ['case_id', 'U_m_s', 'status', 'time', 'pIn_m2_s2', 'pOut_m2_s2',
              'DeltaP_Pa', 'qIn_m3_s', 'qOut_m3_s', 'imbalance_percent', 'notes']
    records = []
    for case in sorted(p for p in case_root.iterdir() if p.is_dir()):
        parameters = case / 'parameters.csv'
        if not parameters.is_file():
            continue
        with parameters.open(encoding='utf-8', newline='') as f:
            cfg = next(csv.DictReader(f))
        row = {key: '' for key in fields}
        row.update(case_id=case.name, U_m_s=cfg['U_m_s'])
        status_file = case / 'status.txt'
        row['status'] = status_file.read_text(encoding='utf-8').strip() if status_file.exists() else 'UNKNOWN'
        if not (case / '.completed').exists():
            row['notes'] = 'No .completed marker; do not treat as finished'
        else:
            try:
                out = {n: read_measurement(case, n) for n in ('pIn', 'pOut', 'qIn', 'qOut')}
                times = [out[n][0] for n in out]
                if max(times) - min(times) > 1e-7 * max(1.0, max(abs(t) for t in times)):
                    raise ValueError('Measurement times differ; inspect files')
                pin, pout, qin, qout = (out[n][1] for n in ('pIn', 'pOut', 'qIn', 'qOut'))
                row['time'] = format(max(times), '.10g')
                row['pIn_m2_s2'] = format(pin, '.10g')
                row['pOut_m2_s2'] = format(pout, '.10g')
                row['DeltaP_Pa'] = format((pin-pout)*args.rho, '.10g')
                row['qIn_m3_s'] = format(qin, '.10g')
                row['qOut_m3_s'] = format(qout, '.10g')
                denom = max(abs(qin), abs(qout))
                row['imbalance_percent'] = format(100*abs(qin+qout)/denom, '.6g') if denom else ''
                row['notes'] = 'Last recorded sample, NOT a convergence test'
            except (ValueError, OSError) as exc:
                row['status'] = 'INCOMPLETE_DATA'
                row['notes'] = str(exc)
        records.append(row)
    output = args.output or args.root / 'results.csv'
    output.parent.mkdir(parents=True, exist_ok=True)
    with output.open('w', encoding='utf-8', newline='') as f:
        writer = csv.DictWriter(f, fieldnames=fields)
        writer.writeheader()
        writer.writerows(records)
    print(f'Wrote {len(records)} row(s): {output}')
    for row in records:
        print(row['case_id'], row['status'], row['DeltaP_Pa'], row['notes'])


if __name__ == '__main__':
    main()
  • --root: package folder (by default the one containing scripts/); --rho: density in kg/m³, must be positive and finite; --output: CSV destination, by default results.csv in the package folder.
  • A case without .completed appears in the CSV with the status read from status.txt and the note “No .completed marker”, without numbers.
  • If the times of the four measurements differ beyond a relative tolerance of 10⁻⁷, the case becomes INCOMPLETE_DATA.
  • Numbers are written with 10 significant digits and a decimal point.
Deep dive · Opening results.csv in Excel with non-English regional settings

The file uses a comma as separator and a point as decimal separator. Excel configured for a locale with decimal commas, opened by double-click, expects semicolons and decimal commas, and puts everything in one column or turns numbers into dates. Use Data → From Text/CSV instead, choose the “Comma” delimiter and, in Transform Data, set the locale of the numeric columns to “English (United States)”. Alternatively, LibreOffice Calc asks for separator and language when opening the file.

11. Interpret the results table cautiously

The generated CSV contains:

ColumnMeaning
statusexecution status, not scientific quality
timelatest common measurement time
pIn_m2_s2, pOut_m2_s2area-averaged kinematic pressures
DeltaP_Padifference converted to Pa using supplied rho
qIn_m3_s, qOut_m3_ssigned volumetric flow rates
imbalance_percentnormalized continuity mismatch, not total uncertainty
Example parametric curve, with invented data
Synthetic teaching curve: not a computed CFD result

The plotted points are invented solely to explain chart organization. They must never be compared numerically with the original tutorial. Once genuine outputs exist, plot_results.py uses only rows labelled SOLVER_FINISHED. Before publishing a trend, check the residuals, transient behavior, stable quantities of interest and grid sensitivity.

Deep dive · The plot_results.py script
#!/usr/bin/env python3
# GPL-3.0-or-later. Optional matplotlib package on Windows/Linux.
import argparse
import csv
import math
from pathlib import Path

p = argparse.ArgumentParser()
p.add_argument('csvfile', type=Path, nargs='?', default=Path('results.csv'))
p.add_argument('--output', type=Path, default=Path('DeltaP_vs_U.png'))
a = p.parse_args()
with a.csvfile.open(encoding='utf-8-sig', newline='') as f:
    rows = list(csv.DictReader(f))
points = []
for r in rows:
    if r['status'] != 'SOLVER_FINISHED' or not r['DeltaP_Pa']:
        continue
    try:
        u, dp = float(r['U_m_s']), float(r['DeltaP_Pa'])
    except ValueError:
        continue
    if math.isfinite(u) and math.isfinite(dp):
        points.append((u, dp))
if not points:
    raise SystemExit('No finished CFD results; refusing to create a misleading plot')
points.sort()
try:
    import matplotlib.pyplot as plt
except ImportError as e:
    raise SystemExit('Install matplotlib: python -m pip install matplotlib') from e
fig, ax = plt.subplots(figsize=(7, 4.5))
ax.plot([p[0] for p in points], [p[1] for p in points], '-o')
ax.set_xlabel('Inlet velocity [m/s]')
ax.set_ylabel('Pressure difference [Pa]')
ax.set_title('OpenFOAM campaign - final samples (not validated)')
ax.grid(True, alpha=.3)
fig.tight_layout()
fig.savefig(a.output, dpi=175)
print('Saved:', a.output)

The script refuses to produce the plot if no case is SOLVER_FINISHED with a Δp value: an empty plot, or one made of incomplete cases, would be misleading. The output file name is changed with --output.

Deep dive · Reading the slope: is Δp proportional to U or to U²?

When viscous effects dominate, the pressure drop grows roughly linearly with velocity; when inertial effects dominate it grows roughly with the square. The local exponent between two consecutive cases is n = ln(ΔP₂/ΔP₁) / ln(U₂/U₁): values near 1 indicate a viscous regime, near 2 an inertial regime, intermediate values a transition. It is a physical plausibility check that needs no external data.

# local exponents from results.csv (standard library only)
import csv, math
rows = [r for r in csv.DictReader(open("results.csv", encoding="utf-8"))
        if r["status"] == "SOLVER_FINISHED" and r["DeltaP_Pa"]]
pts = sorted((float(r["U_m_s"]), float(r["DeltaP_Pa"])) for r in rows)
for (u1, p1), (u2, p2) in zip(pts, pts[1:]):
    if p1 > 0 and p2 > 0:
        print(f"{u1:g} -> {u2:g} m/s: n = {math.log(p2/p1)/math.log(u2/u1):.3f}")

12. Interrupted runs, restart and tmux

An interactive solver may terminate when its SSH session closes. Start it inside a detached tmux session:

tmux new -s foam_campaign
cd ~/cases/cylinder_campaign
bash scripts/run_campaign.sh --np 4
# Ctrl+B, then D to detach
# Later:
tmux attach -t foam_campaign

On failure, inspect status.txt and logs/...foamRun.log before retrying. The flag --resume deliberately allows re-running cases without .completed, at the original MPI process count:

bash scripts/run_campaign.sh --np 4 --resume

Limitations: restart uses the tutorial’s startFrom latestTime behavior and requires consistent written field checkpoints. The flag does not fix incorrect physics, broken dictionaries or incomplete mesh decomposition. The script never force-deletes results and does not force a potentially destructive re-decomposition.

Deep dive · What –resume does exactly, and a side effect of the fingerprint

With --resume the script accepts cases that have .run_started but not .completed. Stages already finished (.block_mesh_done, .mirror_mesh_done, .mesh_checked) are not repeated; in parallel, if processor0 exists, decomposePar is skipped and foamRun -parallel restarts from the existing subdomains. The input fingerprint is checked again at every launch.

Practical consequence: if the controlDict is edited during a run (for example changing endTime or stopAt to stop it), the file no longer matches the fingerprint and on the next --resume the case stops with code 7. This is the intended behaviour: the change must be recorded and, as a rule, a new case is created. Regenerating input.sha256 is possible but must be a conscious decision written into the campaign record.

# processes still running and their state
pgrep -af foamRun
find work/cases -name status.txt -exec sh -c 'echo "$1: $(cat "$1")"' _ {} \;
ls -a work/cases/U015

13. Minimal reproducibility protocol

Preserve at least these elements before sharing a campaign chart:

  1. Software provenance (OpenFOAM Foundation 14, tutorial source and date), environment and foamVersion.
  2. Exact 0, constant and system inputs, parameters.csv, input.sha256, mesh changes and MPI process count.
  3. Original meshing, checking, solver, decomposition and reconstruction logs where applicable.
  4. Raw postProcessing data, aggregated CSV, selected density, measurement definitions and units.
  5. Plots, mass balance, solver convergence, mesh checks, quality criteria and limits of conclusions.

Solver exit status cannot justify confidence bounds or physical model validation. Consult the verification and validation procedures in Guide 4. This example runner is intentionally conservative, not a general-purpose HPC scheduler; it does not reserve compute nodes or launch cases concurrently.

Deep dive · A campaign archive in four commands

Run in the package folder once the campaign is over, they record versions and fingerprints and produce an archive without the processor* directories, which take a lot of space and can be rebuilt.

{ date -u; uname -a; foamVersion; mpirun --version 2>&1 | head -n 1; } > campaign_environment.txt
find work/cases -path '*/processor*' -prune -o -type f -print0 | sort -z | xargs -0 sha256sum > campaign_files.sha256
tar czf "cylinder_campaign_$(date -u +%Y%m%d).tar.gz" --exclude='processor*' config scripts work results.csv campaign_environment.txt campaign_files.sha256
sha256sum cylinder_campaign_*.tar.gz

The archive fingerprint, written into the record, makes it possible to show later that the data analysed are the ones produced. If results.csv does not exist yet, remove it from the tar line.

14. Practical exercises and completion checklist

Exercise A — Prepare without solving. Add U025,0.025 to the CSV, run prepare_campaign.sh, check that it creates a new directory, then run it again and confirm that existing cases are skipped. Inspect Uinlet with foamDictionary.

Exercise B — Compare serial and MPI. Create two fresh campaigns, one run with --np 1, the other with --np 4. Record compute times and compare the extracted physical quantities. Do not overload a shared server with concurrent experiments.

Exercise C — Build the evidence. Generate results.csv; inspect any missing measurements, compute an example flux imbalance and produce DeltaP_vs_U.png. Decide using the logs and the flow timescale whether final measurements are actually comparable. The values in this document are illustrative only.

Completion checklist: no overwritten cases; fixed geometry; correct parameter table; inspected logs; known sampling time; consistent pressure and flux units; independently documented solver convergence and mesh dependence.

Deep dive · Hints for the exercises
  • A. On the second run of prepare_campaign.sh the four original rows produce SKIP existing case … and only the new one produces CREATED U025 U=0.025 m/s. Check: foamDictionary work/cases/U025/0/U -entry Uinlet -value must return 0.025.
  • B. Cases live in work/cases inside the package folder, so two independent campaigns are obtained with two copies of the package (for example cylinder_campaign_np1 and cylinder_campaign_np4). The .nproc marker in any case prevents mixing the two modes in the same case. For the 4-process version, prepare the decomposeParDict first.
  • C. Besides imbalance_percent, compute the local exponent between consecutive cases and check that the measurement time is the same in all cases: comparing a settled case with one still in its transient produces a meaningless curve.

15. Official references and validation limits

Validation status for this package: Bash syntax (bash -n), Python compilation (py_compile), and collector behavior on synthetic output can be tested without an OpenFOAM executable. Run and inspect a pilot case on your server before using this material for research production.

Related guides: Linux server from Windows, monitoring and convergence, ParaView for quantitative analysis, verification, validation and mesh independence and the Learning OpenFOAM 14 path.

#automation #CFD #MPI #OpenFOAM #parameter sweep