OpenFOAM: parameter sweeps, automation and parallel computing
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.

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.
| Case | Inlet velocity [m/s] | Geometry and mesh | Execution |
|---|---|---|---|
U005 | 0.005 | unchanged | sequential, serial or MPI |
U010 | 0.010 | unchanged | sequential, serial or MPI |
U015 | 0.015 | unchanged | sequential, serial or MPI |
U020 | 0.020 | unchanged | sequential, 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)

| File | Role |
|---|---|
velocities.csv | single explicit table of input parameters |
prepare_campaign.sh | creates four clean cases without replacing existing ones |
measurement_functions.foam | records pressure averages and volumetric fluxes |
run_campaign.sh | builds and checks the mesh; runs serially or with MPI |
collect_results.py | extracts final recorded samples and flags missing values |
plot_results.py | plots 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 (
foamVersionandfoamDictionaryavailable), version containing “14”, tutorial present with0/Uandsystem/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<=1condition in theawkline. - If the case directory exists,
SKIPis printed and the next row is processed: this is the mechanism that prevents overwriting. - Only
0,constantandsystemare copied; if the copy already contained aconstant/polyMeshthe script stops with exit code 4, because the mesh must be generated in the case, not inherited. foamDictionary … -setwrites the velocity, the measurement block is appended to thecontrolDict, thenparameters.csv, theinput.sha256fingerprint and thePREPAREDstatus 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.
| Code | Meaning | What to do |
|---|---|---|
| 0 | case completed or already completed | — |
| 2 | wrong usage, OpenFOAM environment not loaded, preparation missing | load the environment, run prepare_campaign.sh |
| 3 | mpirun not available with --np > 1 | load the server MPI environment |
| 4 | mesh present without marker, or parallel case resumed in serial | inspect the case by hand |
| 5 | --np different from the one recorded in .nproc | use the original value |
| 6 | one of the four measurement files is missing | check the functions block and the foamRun log |
| 7 | inputs differ from the recorded fingerprint | create a case with a new name |
| 8 | previous run not finished, without --resume | read the logs, then decide whether to use --resume |
| other | code returned by the failed OpenFOAM command | read 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.
| Approach | Potential compute demand | Benefit | Limitation |
|---|---|---|---|
Sequential, --np 1 | ~1 process at once | predictable | no MPI speed-up |
Sequential, --np 4 | up to 4 processes per case | may speed up larger meshes | MPI overhead and memory |
| Four cases simultaneously × 4 MPI processes | up to 16 processes | possible with a scheduler | not 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 containingscripts/);--rho: density in kg/m³, must be positive and finite;--output: CSV destination, by defaultresults.csvin the package folder.- A case without
.completedappears in the CSV with the status read fromstatus.txtand 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:
| Column | Meaning |
|---|---|
status | execution status, not scientific quality |
time | latest common measurement time |
pIn_m2_s2, pOut_m2_s2 | area-averaged kinematic pressures |
DeltaP_Pa | difference converted to Pa using supplied rho |
qIn_m3_s, qOut_m3_s | signed volumetric flow rates |
imbalance_percent | normalized continuity mismatch, not total uncertainty |

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:
- Software provenance (
OpenFOAM Foundation 14, tutorial source and date), environment andfoamVersion. - Exact
0,constantandsysteminputs,parameters.csv,input.sha256, mesh changes and MPI process count. - Original meshing, checking, solver, decomposition and reconstruction logs where applicable.
- Raw
postProcessingdata, aggregated CSV, selected density, measurement definitions and units. - 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.shthe four original rows produceSKIP existing case …and only the new one producesCREATED U025 U=0.025 m/s. Check:foamDictionary work/cases/U025/0/U -entry Uinlet -valuemust return 0.025. - B. Cases live in
work/casesinside the package folder, so two independent campaigns are obtained with two copies of the package (for examplecylinder_campaign_np1andcylinder_campaign_np4). The.nprocmarker in any case prevents mixing the two modes in the same case. For the 4-process version, prepare thedecomposeParDictfirst. - 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
- OpenFOAM Foundation 14 — official source repository.
- Official
cylindertutorial —Allrunand field0/U. - Foundation 14 API documentation —
foamDictionary. - Foundation 14 API documentation —
surfaceFieldValue. - OpenFOAM parallel-domain documentation (be aware of differences among OpenFOAM distributions).
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.