Agentic CFD workflow
You turn a problem statement into a trustworthy simulation report. The reader is an engineer who will mostly read the summary and must be able to check every claim. Four rules:
- Code computes every number. You write the code; you never type a result, reference value or formula output into the report by hand. Theory is solved symbolically (SymPy) and self-checked.
- Every choice is documented. Each OpenFOAM setting belongs to a decision with why / effect / alternatives. A checker refuses to render the report otherwise.
- Nothing from memory. Reference data, correlations and constants come from the problem's
reference/files or a cited source. If you must use an approximate value, say so in the report. - Log insights live, like a lab notebook. Part of your job is to improve this workflow for the
agents after you. The moment something fails, surprises you, needs a second attempt, or is unclear
in these guidelines, open an insight (before you start fixing it):
insights.log(case, stage, problem); fill in diagnosis, fix, lesson and guideline withinsights.resolve(...)once solved. Do not save them up for the end: memories of a long session get lost and distorted. Expect to log 5-15 entries; a run with none was not looked at closely.
Folder layout
cfdlib/ shared tools (do NOT modify; log needed changes as insights, stage "tooling")
foam.py render_case, run (timeout, PID file), stop, running; read fields and postProcessing .dat files
monitor.py parse_log, assess (health checks), preflight (short test run + run-time projection)
verification.py error norms, observed order, Richardson extrapolation, GCI, level_sequence
(convergence of any sequence of levels, including a reference's own)
signals.py periodic signals: cycles, refined peaks, periodicity check, FFT frequency
plots.py figure style and sizes (figure, wide, sketch); to_svg checks for overlapping text
report.py m(), tex_num(), tex_fix() (fixed decimals for table columns), tex_pct(), LETTERS, load_yaml,
environment_info(), render()
perf.py performance numbers per run (summarize, machine) for the performance appendix
contract.py the report contract checker (also --decisions mode)
insights.py the live insight log (checks typesetting when you log; refuses to overwrite a filled entry)
foamdict.py OpenFOAM dictionary parser (used by the checker)
cases/<name>/ one folder per problem
problem.yaml GIVEN by the user: description (short overview), setup (list of facts: geometry,
boundary conditions, definitions), given data, requested outputs [+ reference/].
Do not change the physics. Pass description and setup to the report unchanged.
reference/ user-supplied reference data with citations
numerics.yaml your numerical plan (meshes, run lengths, tolerances)
decisions.yaml every choice, written WHILE you set up the case
insights.yaml the live log (cfdlib.insights)
template/ the OpenFOAM case (0/, constant/, system/) with {{ placeholders }}
theory.py / analytical.py, sketch.py, run.py, analyze.py
results/<name>/ raw data, results.json, report.html
Worked examples (read all their files before starting; copy the structure, not the numerical settings):
cases/plane_poiseuille/: linear, steady, exact solution (verification). Report:results/plane_poiseuille/report.html.cases/lid_driven_cavity_re100/: nonlinear, steady, no exact solution, published benchmark (benchmark comparison, Richardson extrapolation, residual-stopped SIMPLEC, separate pre-flight and production commands). Report:results/lid_driven_cavity_re100/report.html. Its insights.yaml holds 15 lessons from the agent that built it.cases/stokes_first_problem/: linear, TRANSIENT, exact solution (combined and separate mesh and time-step studies, per-step momentum balance, concurrent runs). Report:results/stokes_first_problem/report.html.cases/dfg_cylinder_re100/: nonlinear, TRANSIENT and periodic (vortex shedding), benchmark with several reference levels, curved-body O-grid mesh generated in Python, MPI runs and a performance appendix. Report:results/dfg_cylinder_re100/report.html. Its insights.yaml holds 16 lessons (the most important: PISO was first order in time).
Stages
0. Intake
- Read
problem.yamland anyreference/files. List the requested outputs (they get letters a, b, c...). - Compute the governing dimensionless numbers (Re, Ma, ...) in code. Decide which physics apply.
- If data is missing or contradictory, choose a documented assumption and flag it in the report's limitations. Never silently invent inputs.
- If a reference gives no uncertainty, estimate one (its grid, its printed digits, a second source) and record how; you will need it in stage 5.
1. Theory: what can be known without the simulation
- Exact solution available: derive it with SymPy, self-check it (satisfies equations and boundary
conditions), evaluate the requested quantities. See
cases/plane_poiseuille/analytical.py. - No exact solution (usual case): say so in
theory.intro, and give what theory CAN say: limiting cases, scaling, conservation laws, symmetry, sign of quantities, expected flow structure. Seecases/lid_driven_cavity_re100/theory.py. - Every derived quantity gets: why it matters, the meaning and value of each symbol in this problem, and the formula with the numbers substituted (hand-check).
2. Setup and decisions
- Find the closest OpenFOAM tutorial (
$FOAM_TUTORIALS) and start from it; make minimal changes. - Read
references/openfoam-primer.mdfor what each file controls and the known pitfalls. - Templates may contain Jinja loops: generating a whole
blockMeshDictfrom Python is fine and is the way to mesh curved bodies (example:mesh_spec()incases/dfg_cylinder_re100/run.py). - Edit files that contain LaTeX only with the Edit/Write tools, never through Bash heredocs or
inline Python: the Bash tool eats backslashes (
\timesbecomes a TAB). Cases 3 and 4 both lost time to this. - Write
decisions.yamlas you go: one entry per choice withkind(physics / accuracy / solver / output) andsettingspatterns covering every setting in the case files. Check coverage as soon as one case is rendered:python -m cfdlib.contract --decisions cases/<name>/decisions.yaml <case dir>. - Plan a mesh study: at least 3 meshes, refinement ratio 2 (needed for observed order and GCI). Transient problems also need a time-step study (or an argument, with numbers, that time error is negligible).
- Define a physical convergence/steadiness check for your problem (a balance, a conserved quantity, a monitored value that must stop changing) and its tolerance BEFORE seeing results. Normalised residuals alone are not enough. Transient problems: a per-step balance (change of a conserved quantity = boundary fluxes) is a strong check; it must use the solver's own time scheme.
- Pass/fail tolerances must discriminate: size them from the coarse pre-flight error and the expected order (what the finest mesh should reach), not from round numbers. A tolerance 100x looser than what is achieved proves nothing (case 3). Make the observed order of accuracy a rule of its own.
3. Pre-flight test (mandatory, before any long run)
cfdlib.monitor.preflight()on the coarsest AND finest meshes with a short run (a few % of the full length). It works in a separate<case>_preflightcopy; render pre-flight and production cases into separate directories too, so re-running one never deletes the other.- It reports crashes, NaN, Courant number, residual/Courant/continuity trends and projects run time.
Transient runs: the projection is reliable. Steady runs: pass
residual_targets; the result is a lower bound (SIMPLE slows down later and on finer meshes). - Print the header and first rows of every
postProcessingfile the pre-flight wrote and check each is non-trivial (case 4: a pressure integral wrote 0 at every step, silently). - Transient nonlinear problems: a short time-step study (3 time steps, ratio 2, on a coarse mesh) belongs in the pre-flight, before the mesh study. It shows the observed order in time: PISO (1 outer corrector) turned out first order in case 4 and cost a full re-run.
- A pre-flight that starts from rest underestimates the cost of a developed unsteady flow (case 4:
twice). Start it from a developed field (
mapFieldsfrom a coarse run) or budget twice the projection. - If it fails or warns, fix the setup and log an insight. No production runs on a failing pre-flight. If the projected time is excessive (> ~15 min per run here), coarsen the plan and say so.
4. Production runs
- Run meshes coarse to fine. For steady runs, extrapolate the iteration count of the finest mesh from the coarser ones (it often grows 3-4x per refinement) before launching it.
- Stopping must be physical: make the physical check of stage 2 the stopping rule (e.g. a
runTimeControlfunction object on probed values, see the primer), or check per mesh that the residual targets actually satisfy it, on the coarse meshes first. - Independent runs (different meshes) may run at the same time, up to 6 at once (8 cores). Say so in the reproducibility notes, since it inflates per-run wall times.
- Long runs go in the background;
python -m cfdlib.foam pslists solver processes, andpython -m cfdlib.foam stop <case dir>kills one (stopping the Python wrapper alone leaves an orphan solver). Record per run: health (monitor.assess), the physical check, wall time. - Run a sequence of long runs as ONE background script that writes a completion marker and a record per run. After any interruption (an API pause too), read those files before re-running anything.
- While all hardware threads run a solver, your own work slows it: leave 1-2 threads free or budget 1.5 times the projection.
5. Analysis
- All comparisons in code. With an exact solution: errors and observed order per mesh.
Without: observed order and Richardson extrapolation from 3 meshes, GCI on the finest mesh
(
verification.py), then compare the extrapolated value with the reference. - A benchmark is a simulation too: judge agreement against (your GCI band + the reference's own uncertainty), not against zero. If the difference does not shrink with refinement, it is not your discretisation error; test robustness (another scheme, tighter iteration) before blaming either side, and recommend a second independent reference.
- When the reference gives several mesh levels, it is still converging: compute ITS observed order
and Richardson value (
verification.level_sequence) and compare your result with both its finest level and its extrapolated value. Decide the pass rule before looking (case 4 kept the finest-level rule and reported the extrapolation as context). - Periodic outputs (shedding, oscillations): use
cfdlib.signals(cycles between upward crossings, parabola-refined peaks, periodicity check, FFT cross-check) instead of writing your own. - A coarse-mesh match can be luck (error cancellation). Report differences honestly, where and why.
- An explanation is a claim: check it against the data (sign, size, scaling) before writing it. In case 3 the first explanation of an error had the wrong sign and a third of the size.
6. Report
- Build
results.jsoninanalyze.pyand callreport.render(results, svgs, out, case_dir=<finest case>). The contract is inreferences/report-contract.md;renderrefuses to write a report that breaks it. - Math: every formula, symbol and scientific number in text as LaTeX inside
\( \)(m(),tex_num()): write\(0.009\,U\), never0.009 U,6.3e-06oru*H/nu. The checker rejects e-notation and ASCII math, and warns on number-times-symbol in plain text. - Captions and findings are results too: generate them from the data (a caption saying "shrinks"
must be computed). Refer to figures as
{fig:<key>}; render() inserts the number. - Figures (
cfdlib.plots):plots.figure()for one panel,plots.wide(n)for 2-3 panels,plots.sketch()for the problem sketch (required).to_svg()refuses figures whose text overlaps text, lines, arrows or shapes, or whose legend covers data, or that are wider than the column. Fix the layout (labels away from lines,annotatewith a leader line, legend outside);allow=only for overlaps that are truly intended, and log an insight if you need it. - Verdict: PASS only if every check you defined passes; WARN if results are usable with caveats; FAIL otherwise. Never soften a failed check.
7. Visual review (mandatory)
The contract checks structure; only looking catches what a reader will see. Open the report in the
built-in browser through the local server (file:// does not work):
http://localhost:8765/<name>/report.html (if it does not load, start the reports server from
.claude/launch.json with the preview tool). Add ?v=<n> after re-rendering to avoid a cached copy.
- Screenshot every section, top to bottom. Check: figures readable (text not tiny, nothing
overlapping, leader lines not crossing curves), tables not cut off, math rendered (no raw
\(), table columns aligned, the summary answers each request. - Run in the page (waits for MathJax, opens every ⓘ panel, then lists every element whose content
is cut off; should be empty; run right after loading, before MathJax finishes, it reports un-typeset
TeX as overflow):
await MathJax.startup.promise; document.querySelectorAll('details').forEach(d => d.open = true); [...document.querySelectorAll('section *')].filter(x => !x.tagName.startsWith('MJX') && x.scrollWidth > x.clientWidth + 2 && getComputedStyle(x).overflowX !== 'visible').map(x => x.closest('section').id + ': ' + x.className)then screenshot the opened ⓘ panels too. - After any change of plan mid-course (scheme, rank count, run length, mesh levels), search decisions.yaml, analyze.py and the rendered text for the superseded names and numbers. Case 4's final review found a decision, reproducibility commands and a limitation still describing the old plan; no automatic check catches this.
- Render a draft report from partial results while the finest run is still going; layout problems show up early.
- Fix what you find, re-render, look again. Log what you found as insights (stage "report").
- Without a browser: run analyze with
CFD_FIG_PNG_DIR=<dir>to get PNGs of every figure and look at them.
Side mission: performance
Cases 1-3 used one CPU core; case 4 measured MPI scaling and linear solvers (numbers in the primer). Larger problems need speed, so every report should leave data for optimising it (CPU cores and MPI now; GPU linear solvers next).
foam.runwrites<case>/perf.<name>.json(wall time, CPU time, peak memory) for every command.perf.summarize(case_dir, solver, cells, cores)adds steps, wall microseconds per cell per step, CPU efficiency, mean linear-solver iterations per field (pressure usually dominates: that is what a GPU linear solver would accelerate) and the time outside the solver loop.- Measure before you optimise. Typical levers, in order of effort: run independent cases at the same
time; MPI decomposition of the large runs (
decomposePar,mpirun -np N <solver> -parallel,reconstructPar; 8 physical cores / 16 threads, Open MPI 4.1.6,--use-hwthread-cpusabove 8 ranks) with a scaling test (1, 2, 4, 8, 16 ranks on the finest mesh, recording pressure iterations per decomposition); linear-solver choice and tolerances forp(GAMG was 4 times faster than PCG in case 4); write interval and function objects.perf.summarizegiveslin_iters_by_solve(iterations of the 1st, 2nd, ... and last pressure solve of a step). - A GPU test must compare against the BEST CPU setup (GAMG on all ranks), not PCG on one core, and report time and iterations per pressure solve separately from the total. An optimisation that can change the answer is a decision (document it) and must be shown to give the same result as the baseline (to round-off or within the stated tolerance).
- Report it in the optional
performanceappendix (see report-contract.md) and log what you learn as insights with stageperformance: what limited speed, what helped by how much, what you would try next, and what data a GPU test would need.
Insight entries
Fields: stage, problem (when logged), then diagnosis, fix, lesson, guideline (the change
to THESE guidelines or tools that would have prevented it). Also log things that worked first time
only because of a specific guideline or example, and anything in these guidelines that was unclear,
wrong or missing. Unresolved entries are shown as unresolved in the report; that is allowed and honest.
Machine and environment
See references/environment.md (WSL commands from PowerShell AND from Git Bash, paths, quoting,
the report server). Short version: run Python inside WSL with
/home/tomyud/venvs/cfd/bin/python -m cases.<name>.<module> from /mnt/d/Projects/Agentic CFD;
put shell work in .sh files; never pass complex inline bash through PowerShell.