OpenFOAM simulations of the CPB device. Meshes are built with gmsh and cases are set up with PyFoam.
Each simulation run is a case, generated from a case template and a set of parameters. The template is an ordinary OpenFOAM case in which a few values are placeholders. The mesh, fields, physics and numerics are separate files that you can edit directly or control through parameters.
CFD/
pyproject.toml Python dependencies; installs src/cfdtools
src/cfdtools/ Python code shared by all base cases
mesh.py gmsh cell-count and sizing helpers
units.py unit conversion to SI (rpm, mm, mPa.s, nu = mu/rho)
monitor.py live plots of a running case
post.py read results and plot fields and profiles (matplotlib)
pv.py open a case in ParaView with a publication view (python -m cfdtools.pv)
paraview_view.py the ParaView view used by pv.py (runs inside ParaView)
geometry/ gmsh mesh builders, shared by all base cases
common.py device dimensions used by the builders
annulus.py 2D r-theta annulus (annulus_stream-plane)
wedge.py axisymmetric r-z wedge (annulus_span-plane)
annulus_stream-plane/ base case: annular gap in the r-theta plane
base/annulus-2D/ case template
studies/ parameter files for named runs and sweeps
post/ figure scripts and example notebook for this base case
cases/ generated cases (not tracked by git)
annulus_span-plane/ base case: annular gap in the r-z plane
base/wedge-2D/
studies/
cases/
Each annulus_* folder is a base case: one flow setup and its case
template. Usually base/ holds a single template. Mesh builders are kept in
the top-level geometry/ folder so that base cases modelling the same device
share one set of dimensions.
| Folder | Contents | Edit it to change |
|---|---|---|
src/cfdtools/ |
Code that does not depend on a specific geometry | Shared tools |
geometry/ |
Mesh builders and device dimensions | The geometry or mesh |
<base case>/base/<template>/ |
Boundary conditions, models, schemes, solver settings, outputs | The physics or numerics |
<base case>/studies/ |
Parameter files | The values to run |
<base case>/post/ |
Figure scripts | The figures |
<base case>/cases/ |
Generated cases | Nothing: regenerate instead |
Anything you want to keep goes in base/ or studies/. Cases can be
deleted and regenerated at any time.
-
OpenFOAM v2606 (openfoam.com release), installed separately:
- macOS: openfoam-app, which
provides the
openfoamcommand used below. - Linux / WSL: openfoam.com/download.
Other recent openfoam.com versions will probably work but are untested. The openfoam.org releases use different dictionary names and will not run these templates unchanged.
- macOS: openfoam-app, which
provides the
-
Python 3.10+, in any environment (conda, venv, ...).
From the repository root, in a new environment:
conda create -n cfd python=3.12 && conda activate cfd # or: python -m venv .venv && source .venv/bin/activate
pip install -e .This installs gmsh, PyFoam, matplotlib and cfdtools. cfdtools is
installed in editable mode, so changes under src/ apply without
reinstalling. Run pip install -e . again if pyproject.toml changes.
PyFoam runs OpenFOAM programs, so OpenFOAM must be available in the same shell. Start an OpenFOAM shell with the environment active:
conda activate cfd
openfoam # macOS; on Linux, source OpenFOAM's etc/bashrc insteadRun all commands below in that shell, from inside a base case folder
(e.g. annulus_stream-plane/).
pyFoamPrepareCase.py cases/<name> --clone-case=base/annulus-2DThis copies the template to cases/<name> and then:
- Reads
default.parametersand applies any overrides (see step 2). - Runs
derivedParameters.py, which computes SI values (Ri,Ro,ecc,omega,nu). - Fills in every
*.templatefile:foo.templateis written asfoo. - Runs
meshCreate.sh: gmsh builds the mesh,gmshToFoamconverts it, patch types are set, andcheckMeshruns. - Copies
0.org/to0/. - Runs
caseSetup.sh, if the template has one.
The parameter values used are saved in cases/<name>/PyFoamPrepareCaseParameters
(readable version: .rst). Mesh output is in meshCreate.sh.log.
For a single run:
pyFoamPrepareCase.py cases/ecc02 --clone-case=base/annulus-2D \
--values-string="{'eccentricity_mm':0.2, 'speed_rpm':200}"For runs you want to keep a record of, write a parameter file in
studies/ (OpenFOAM dictionary syntax, no header):
// studies/ecc02.parameters
eccentricity_mm 0.2;
speed_rpm 200;
pyFoamPrepareCase.py cases/ecc02 --clone-case=base/annulus-2D \
--parameter-file=studies/ecc02.parameters--parameter-file can be given more than once; later files override earlier
ones. --automatic-casename names the case after the parameter files.
Run the solver named by application in the case's system/controlDict (see
Base cases):
cd cases/<name>
pimpleFoam > log.pimpleFoam # transient, e.g. annulus-2D
simpleFoam > log.simpleFoam # steady, e.g. wedge-2DResults:
- Time folders: full fields, saved every
writeInterval. A transient solver names them by time (0.01/,0.02/, ...). A steady solver names them by iteration (500/,1000/, ...) and writes the last iteration when it stops. postProcessing/: time histories, e.g.bobTorque/0/moment.dat(torque on the bob) andresiduals/0/solverInfo.dat.log.<solver>: the solver log.
Open <name>.foam in ParaView to view the fields.
In a second terminal, with the same environment active:
python -m cfdtools.monitor cases/<name> # refresh every 1 s
python -m cfdtools.monitor cases/<name> -r 5 # refresh every 5 s
python -m cfdtools.monitor cases/<name> -c 2 # two plots per row
python -m cfdtools.monitor cases/<name> -n 2000 # only the last 2000 iterations (or seconds)
python -m cfdtools.monitor cases/<name> --save m.png # save one image and exit
python -m cfdtools.monitor cases/rpm_sweep # a folder of cases: follow the running oneThe path can be relative to wherever you run the monitor. Given a folder of
cases (a sweep or mesh study), the monitor shows the case whose
postProcessing files changed most recently, and switches when the next case
starts. The case shown is in the title.
The monitor plots every postProcessing/*/*.dat file. Residuals are shown
on a log scale. Columns that are negligible compared with the largest one in
the same file (e.g. x and y torque in a 2D case) are left out.
For transient cases (pimpleFoam), the flow is fully developed when the
torque stops changing; residuals only show that each time step converged.
For steady cases (simpleFoam), check that the residuals have fallen and the
torque has stopped changing.
PyFoam's pyFoamPlotRunner.py and OpenFOAM's foamMonitor are not used.
On macOS, PyFoam's plotting does not work and foamMonitor needs X11,
gnuplot and GNU coreutils.
Running the solver again in the same case does not replace
postProcessing/. OpenFOAM writes new files next to the old ones
(moment_0.dat, ...). The monitor plots only the newest run.
To delete results but keep the mesh and setup:
pyFoamClearCase.py cases/<name> # removes time folders (except 0) and postProcessing/To regenerate a case with the case's own parameters and templates:
pyFoamPrepareCase.py cases/<name>
pyFoamPrepareCase.py cases/<name> --no-mesh-create # keep the existing mesh
pyFoamPrepareCase.py cases/<name> --values-string="{'speed_rpm':200}"This does not pick up changes made to base/ after the case was
created. To use the current template, delete the case and generate it again:
mv cases/<name> ~/.Trash/
pyFoamPrepareCase.py cases/<name> --clone-case=base/annulus-2DTo rebuild only the mesh, run ./meshCreate.sh inside the case.
Loop over values in the shell:
for e in 0.0 0.1 0.2 0.3 0.4; do
pyFoamPrepareCase.py cases/ecc_$e --clone-case=base/annulus-2D \
--values-string="{'eccentricity_mm':$e}"
(cd cases/ecc_$e && pimpleFoam > log.pimpleFoam)
doneEach speed starts from the converged solution of the previous speed, the way
the flow develops when the speed is increased slowly. This matters above
Taylor-vortex onset, where more than one steady solution can exist. The
sweep is defined by three files in studies/:
| File | Contents |
|---|---|
rpm_sweep.speeds |
Speeds in rpm, one per line, run in the order listed |
rpm_sweep.parameters |
Settings shared by every case, e.g. maxIterations and the mesh |
rpm_sweep.sh |
The script that builds and runs the cases |
Run from annulus_span-plane/ in the OpenFOAM shell:
bash studies/rpm_sweep.shFor each speed, the script:
- builds
cases/rpm_sweep/rpm_<speed>frombase/wedge-2Dwith the.parametersfile and that speed; - copies the previous case's solution into the new case's
0/withmapFields(the first speed starts from rest). The new case keeps its own boundary conditions, so the rotating wall uses the new speed; - runs
simpleFoam, and stops the sweep if it fails or does not converge withinmaxIterations.
- A case that already exists and converged is skipped and used as the
starting point for the next speed. Running the script again continues the
sweep, e.g. after adding speeds to the end of
rpm_sweep.speeds. - To redo a case, move it to the Trash and run the script again. Every case after it started from the old result, so move those to the Trash as well.
- Logs in each case:
log.prepareCase,log.mapFields(names the source case),log.simpleFoam.
PyFoam's pyFoamRunParameterVariation.py can also build and run a sweep from
a .variations file, but every case then starts from rest.
Run before a sweep to choose its mesh. The study runs one speed (the top speed of the sweep, the hardest case to resolve) on three meshes, each with 1.5x the cells of the previous one in every direction.
| File | Contents |
|---|---|
mesh_study.meshes |
One mesh per line: name, nr_gap, nr_core, nz_ann, nz_bot, run coarse to fine |
mesh_study.parameters |
Settings shared by every mesh: speed_rpm, maxIterations |
mesh_study.sh |
The script that builds and runs the cases |
bash studies/mesh_study.shIt works like rpm_sweep.sh, with cases in cases/mesh_study/<mesh name>.
Each finer mesh starts from the coarser mesh's solution (mapFields
interpolates between the meshes), so all meshes stay on the same vortex
solution.
Compare on each mesh:
- the torque on the bob side and bottom;
- the number of sign changes of the radial velocity up the middle of the gap, which must be the same on every mesh (the same vortex pattern);
- the peak radial velocity (vortex strength);
- the peak wall shear stress on the bob (from the
wallShearStressfield).
from pathlib import Path
import numpy as np
from cfdtools import post
from cfdtools.monitor import find_series, read_dat
from cfdtools.convergence import gci
names = ["coarse", "medium", "fine"]
cells, torque = [], []
for name in names:
case = Path("cases/mesh_study") / name
p = post.case_parameters(case)
k, rho = float(p["wedge_to_full"]), float(p["rho_kg_m3"])
mesh, _ = post.read_case(case)
s = find_series(case)
T = read_dat(s["rotorSideTorque/moment"])["total_z"][-1] * k
r_mid = 0.5 * (float(p["Ri"]) + float(p["Ro"]))
z0, z1 = mesh.bounds[4], mesh.bounds[5]
_, ur = post.sample_line(mesh, (r_mid, 0, z0), (r_mid, 0, z1), "U", which="x", n=2000)
sign_changes = np.count_nonzero(np.diff(np.sign(ur)))
side, _ = post.read_patch(case, "rotorSide")
tau_max = np.linalg.norm(side.cell_data["wallShearStress"], axis=1).max() * rho
print(f"{name}: {mesh.n_cells} cells, T = {T:.5e} N m, sign changes = {sign_changes}, "
f"max |u_r| = {abs(ur).max():.4e} m/s, max tau = {tau_max:.4g} Pa")
cells.append(mesh.n_cells)
torque.append(T)
print(gci(torque, cells)) # dim=2 for the wedgegci() (in cfdtools.convergence) applies the Grid Convergence Index
procedure of Celik et al. (2008), J. Fluids Eng. 130, 078001. It returns
the observed order p, the extrapolated zero-cell-size value, and
gci_fine, the relative uncertainty of the fine-mesh value. Use the coarsest
mesh whose value is within your tolerance of the extrapolated value, and put
its counts in rpm_sweep.parameters. If oscillatory is True, the values
go up and down with refinement, and the GCI is unreliable: add a finer mesh.
pyFoamDecompose.py . 4 # write system/decomposeParDict, split the mesh into 4
mpirun -np 4 pimpleFoam -parallel > log.pimpleFoam
reconstructPar # merge processor*/ back into time foldersParallel runs are only faster for large meshes: at least about 20,000 cells per process. On Apple silicon, start with one process per performance core.
ParaView (open <name>.foam) is for looking at results. For figures in a
paper, use the scripts in post/, which use cfdtools.post:
python post/couette_figure.py cases/<name> # writes cases/<name>/figures/couette.pdf and .png
python post/couette_figure.py cases/<name> --time 0.1 -o fig.pdfcouette_figure.py (annulus-2D) draws:
- (a) velocity magnitude on the mesh cells, with the annulus unrolled (angle against radius) so the gap is visible;
- (b) tangential velocity across the gap. For a concentric case: cell values against the analytical Couette solution. For an eccentric case: profiles at the narrowest and widest gap.
To open a case in ParaView with a publication view already set up (white background, parallel projection, no orientation axes, flat colours, the Cool to Warm colour map, a colour bar, camera along the mesh's thin direction):
python -m cfdtools.pv cases/<name> # ParaView window
python -m cfdtools.pv # from inside a case folder
python -m cfdtools.pv cases/<name> --save # image only, no window: cases/<name>/figures/paraview_U.png
python -m cfdtools.pv cases/<name> --save --field p --time 0.1 --range 0 0.1 --size 3000 2000 -o fig.pngIt looks for ParaView in $PARAVIEW_BIN (a folder containing paraview and
pvbatch), then on the PATH, then in /Applications/ParaView-*.app on
macOS. The view is defined in src/cfdtools/paraview_view.py, which runs in
ParaView's own Python.
annulus_stream-plane/post/post_examples.ipynb shows each cfdtools.post
function on a case, including time histories from postProcessing/. To run
notebooks, install the kernel once with pip install -e ".[notebook]", then
select the cfd environment's Python as the kernel.
cfdtools.post functions for writing other figure scripts:
| Function | Does |
|---|---|
read_case(case, time="latest") |
Reads the mesh and cell fields at one saved time (via pyvista) |
case_parameters(case) |
Returns the parameter values the case was built with |
use_style() |
Sets matplotlib defaults for print figures |
plot_field(ax, mesh, "U", which="mag") |
Draws a field on the cells of a 2D mesh; transform= maps coordinates, e.g. to unroll an annulus |
sample_line(mesh, start, end, "U") |
Samples a field along a line, linearly interpolated between cells |
cell_centres(mesh), cell_vertices(mesh) |
Cell geometry |
sample_line interpolates between cell centres, so values within half a
cell of a wall are not exact (e.g. the velocity at the bob surface reads
slightly below the wall speed). Use cell values when that matters.
- Every file in the template is a normal OpenFOAM file and can be edited directly.
- Only files that contain parameters are templates (
*.template). A placeholder is written|-name-|, wherenameis defined indefault.parametersorderivedParameters.py. Python expressions work, e.g.|-2*omega-|. A line starting with$$is a Python assignment. - To turn a fixed value into a parameter:
- Add it to
default.parametersin lab units. - Convert it in
derivedParameters.py, if needed. - Replace the value in the file with
|-name-|. - Rename the file to
<file>.template.
- Add it to
- In
derivedParameters.py,delany temporary names (e.g. imported modules) at the end. Otherwise they are saved as parameters. - Name the initial-conditions folder
0.org.pyFoamPrepareCase.py --clone-casedoes not copy0.orig. meshCreate.sh.templatemust be executable (chmod +x). The generatedmeshCreate.shgets the same permissions.meshCreate.shlooks for ageometry/folder in the case folder and then in each parent folder, and uses the first one it finds. For the current base cases that is the top-levelCFD/geometry/.
-
Create the folder:
<new base case>/ base/<template>/ studies/ cases/.gitkeep -
Add the mesh builder to the top-level
geometry/folder. It must write MSH 2.2 ASCII (the only formatgmshToFoamreads). Physical group names become the OpenFOAM patch names. Put dimensions shared with other builders ingeometry/common.py. -
Copy the OpenFOAM tutorial closest to the new flow into
base/<template>/:foamSearch $FOAM_TUTORIALS system/controlDict applicationlists the solver each tutorial uses.- Rename
0or0.origto0.org. - Delete the tutorial's
Allrun,AllcleanandblockMeshDict. - Rename the boundary conditions in
0.org/*to match your patch names.
-
Copy
meshCreate.sh.templatefromannulus_stream-plane/base/annulus-2D/. Change the mesh builder command and the patch types (empty,wedge,wall, ...). -
Get the case running with fixed values first, without parameters or templates.
-
Replace values that should vary with parameters (see Editing a case template).
-
Add a
solverInfofunction object (for the monitor) and function objects for the quantities you need (forces,probes,fieldAverage, ...). -
Test it:
pyFoamPrepareCase.py cases/test --clone-case=base/<template> --values-string="{'endTime':0.01}" cd cases/test && pimpleFoam > log.pimpleFoam
| Base case | Template | Mesh | Solver | Patches | Status |
|---|---|---|---|---|---|
annulus_stream-plane |
annulus-2D |
geometry/annulus.py: 2D r-theta annulus, optional eccentricity |
pimpleFoam, laminar, Newtonian | innerWall (rotating), outerWall, frontAndBack (empty) |
Working |
annulus_span-plane |
wedge-2D |
geometry/wedge.py: axisymmetric r-z wedge (5°), optional bottom gap |
simpleFoam (SIMPLEC), laminar, Newtonian | rotorSide, rotorBottom (rotating), cupWall, cupBottom, top (slip), front, back (wedge) |
Working. No eccentricity (axisymmetric). Torques are for the wedge; multiply by wedge_to_full |
Mesh files (*.msh) are not tracked by git. Each case builds its own mesh.
To build one directly, from the repository root, run e.g.
python geometry/annulus.py --help.
MIT. See LICENSE.