This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-07-25 11:09:19 +04:00
commit 1bdbaa9572
21 changed files with 13473 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
.venv/
.loop/
**/__pycache__
python/src/foam_stepper.egg-info/
# foreign
OpenFOAM-14/
ThirdParty-14/
airfrans/
# no idea if this is some `make` residue or not but has some object files so we exclude it
pythonStepper/

View file

@ -0,0 +1,262 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "f61d8fbc",
"metadata": {},
"source": [
"# AirfRANS finite-volume hand simulation\n",
"\n",
"Purpose: learn the computation, not the Python stepper or OpenFOAM object graph.\n",
"\n",
"This notebook reduces the AirfRANS/OpenFOAM momentum loop to the smallest useful stencil:\n",
"\n",
"1. one internal face shared by two cells;\n",
"2. one boundary face on one of those cells.\n",
"\n",
"Repo grounding:\n",
"\n",
"- AirfRANS case scale: $U_\\infty=(93.009,6.161,0)$ m/s, $\\nu=1.56\\times10^{-5}$ m²/s, fields `U`, `p`, `phi`, `nut`, `k`, `omega`.\n",
"- OpenFOAM v14 momentum predictor source: `OpenFOAM-14/applications/modules/incompressibleFluid/momentumPredictor.C`.\n",
"- The real predictor assembles\n",
"\n",
"$$\n",
"\\texttt{fvm::ddt(U)} + \\texttt{fvm::div(phi,U)} + \\texttt{momentumTransport->divDevSigma(U)}\n",
"= \\texttt{fvModels().source(U)}\n",
"$$\n",
"\n",
"then solves\n",
"\n",
"$$\n",
"\\texttt{UEqn == -fvc::grad(p)}.\n",
"$$\n",
"\n",
"For the hand calculation, keep one velocity component, ignore time/MRF/source/non-orthogonal details, and replace the viscous/turbulent stress term by scalar diffusion with\n",
"\n",
"$$\n",
"\\Gamma = \\nu + \\nu_t.\n",
"$$\n",
"\n",
"For a cell $P$, use the residual convention\n",
"\n",
"$$\n",
"R_P =\n",
"\\sum_f \\underbrace{\\phi_f U_f}_{\\text{convection leaving cell}}\n",
"-\n",
"\\sum_f \\underbrace{\\Gamma_f \\nabla U_f\\cdot S_f}_{\\text{diffusion leaving cell}}\n",
"+\n",
"\\sum_f \\underbrace{p_f S_{f,x}}_{\\text{pressure-gradient contribution}}.\n",
"$$\n",
"\n",
"The equation wants $R_P=0$. A face loop is bookkeeping for these three numbers.\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "176670ce",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:22:08.071527Z",
"iopub.status.busy": "2026-07-24T06:22:08.071431Z",
"iopub.status.idle": "2026-07-24T06:22:08.076755Z",
"shell.execute_reply": "2026-07-24T06:22:08.076272Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"internal face: P -> N\n",
"AirfRANS anchor: U_inf=(93.009, 6.161, 0.0) m/s, nu=1.560e-05 m^2/s\n",
"face flux phi_f = 91\n",
"upwind U_f = 92\n",
"\n",
"convection owner P 8372 | neighbour N -8372\n",
"diffusion owner P 0.0400312 | neighbour N -0.0400312\n",
"pressure owner P 0.15 | neighbour N -0.15\n",
"residual owner P 8372.19 | neighbour N -8372.19\n",
"\n",
"conservation check: R_P + R_N = 0\n"
]
}
],
"source": [
"import math\n",
"\n",
"# Real AirfRANS scale, kept only to anchor the toy numbers.\n",
"U_INF = (93.00914503999995, 6.161356013756317, 0.0)\n",
"NU = 1.56e-5\n",
"\n",
"# Toy two-cell state. P is the OpenFOAM owner, N is the neighbour.\n",
"# Face area vector S points from P to N.\n",
"U_P = 92.0 # x-velocity in owner cell P [m/s]\n",
"U_N = 90.0 # x-velocity in neighbour cell N [m/s]\n",
"p_P = 0.20 # incompressible pressure p/rho in P [m^2/s^2]\n",
"p_N = 0.10 # incompressible pressure p/rho in N [m^2/s^2]\n",
"S = 1.0 # face area vector x-component, owner-outward [m^2]\n",
"d = 1.0 # owner-to-neighbour center distance [m]\n",
"Gamma = NU + 2e-2 # nu + toy turbulent viscosity [m^2/s]\n",
"\n",
"# 1) Face flux: surfaceScalarField phi = U_f · S_f.\n",
"U_for_phi = 0.5 * (U_P + U_N)\n",
"phi_f = U_for_phi * S\n",
"\n",
"# 2) Convection: upwind momentum carried through the face.\n",
"U_f = U_P if phi_f >= 0 else U_N\n",
"convection_P = phi_f * U_f\n",
"convection_N = -convection_P\n",
"\n",
"# 3) Diffusion: - Gamma * grad(U) · S.\n",
"grad_U_dot_S_P = (U_N - U_P) / d * S\n",
"diffusion_P = -Gamma * grad_U_dot_S_P\n",
"diffusion_N = -diffusion_P\n",
"\n",
"# 4) Pressure-gradient contribution: p_f * S.\n",
"p_f = 0.5 * (p_P + p_N)\n",
"pressure_P = p_f * S\n",
"pressure_N = -pressure_P\n",
"\n",
"# 5) Residual contribution from this single internal face.\n",
"R_P_internal = convection_P + diffusion_P + pressure_P\n",
"R_N_internal = convection_N + diffusion_N + pressure_N\n",
"\n",
"assert math.isclose(R_P_internal + R_N_internal, 0.0, abs_tol=1e-12)\n",
"\n",
"def row(label, owner, neighbour):\n",
" print(f\"{label:<13} owner P {owner:>12.6g} | neighbour N {neighbour:>12.6g}\")\n",
"\n",
"print(\"internal face: P -> N\")\n",
"print(f\"AirfRANS anchor: U_inf=({U_INF[0]:.3f}, {U_INF[1]:.3f}, {U_INF[2]:.1f}) m/s, nu={NU:.3e} m^2/s\")\n",
"print(f\"face flux phi_f = {phi_f:.6g}\")\n",
"print(f\"upwind U_f = {U_f:.6g}\")\n",
"print()\n",
"row(\"convection\", convection_P, convection_N)\n",
"row(\"diffusion\", diffusion_P, diffusion_N)\n",
"row(\"pressure\", pressure_P, pressure_N)\n",
"row(\"residual\", R_P_internal, R_N_internal)\n",
"print()\n",
"print(f\"conservation check: R_P + R_N = {R_P_internal + R_N_internal:.6g}\")\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "7325d390",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:22:08.077905Z",
"iopub.status.busy": "2026-07-24T06:22:08.077807Z",
"iopub.status.idle": "2026-07-24T06:22:08.081575Z",
"shell.execute_reply": "2026-07-24T06:22:08.080994Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"boundary face on P: noSlip wall\n",
"patch algebra: U_b=0, p_b=p_P, phi_b=0\n",
"\n",
"convection owner P 0\n",
"diffusion owner P 3.68287\n",
"pressure owner P -0.2\n",
"wall total owner P 3.48287\n",
"\n",
"R_P before boundary = 8372.19\n",
"R_P after boundary = 8375.67\n",
"R_N unchanged = -8372.19\n"
]
}
],
"source": [
"# Add one boundary face on the left side of owner cell P.\n",
"# This mimics the airfoil wall idea: noSlip velocity, zeroGradient pressure, no normal flux.\n",
"\n",
"U_wall = 0.0 # noSlip\n",
"p_wall = p_P # zeroGradient pressure -> boundary value equals owner value\n",
"S_wall = -1.0 # owner-outward area vector points left\n",
"area_wall = 1.0\n",
"d_wall = 0.5 # distance from cell center to wall face\n",
"phi_wall = 0.0 # impermeable wall\n",
"\n",
"# Convection through an impermeable wall is zero.\n",
"convection_wall_P = phi_wall * U_wall\n",
"\n",
"# Boundary diffusion uses the patch value instead of a neighbour value.\n",
"grad_U_dot_S_wall = (U_wall - U_P) / d_wall * area_wall\n",
"diffusion_wall_P = -Gamma * grad_U_dot_S_wall\n",
"\n",
"# Pressure term uses the boundary pressure and the boundary area vector.\n",
"pressure_wall_P = p_wall * S_wall\n",
"\n",
"R_wall_P = convection_wall_P + diffusion_wall_P + pressure_wall_P\n",
"R_P_after_wall = R_P_internal + R_wall_P\n",
"R_N_after_wall = R_N_internal\n",
"\n",
"assert R_N_after_wall == R_N_internal\n",
"assert diffusion_wall_P > 0\n",
"\n",
"def one(label, value):\n",
" print(f\"{label:<13} owner P {value:>12.6g}\")\n",
"\n",
"print(\"boundary face on P: noSlip wall\")\n",
"print(\"patch algebra: U_b=0, p_b=p_P, phi_b=0\")\n",
"print()\n",
"one(\"convection\", convection_wall_P)\n",
"one(\"diffusion\", diffusion_wall_P)\n",
"one(\"pressure\", pressure_wall_P)\n",
"one(\"wall total\", R_wall_P)\n",
"print()\n",
"print(f\"R_P before boundary = {R_P_internal:.6g}\")\n",
"print(f\"R_P after boundary = {R_P_after_wall:.6g}\")\n",
"print(f\"R_N unchanged = {R_N_after_wall:.6g}\")\n"
]
},
{
"cell_type": "markdown",
"id": "ec2cb6d6",
"metadata": {},
"source": [
"## What this teaches\n",
"\n",
"The OpenFOAM loop shape is now visible:\n",
"\n",
"| notebook arithmetic | OpenFOAM idea |\n",
"|---|---|\n",
"| `convection_*` | `fvm::div(phi,U)` moves momentum with face flux |\n",
"| `diffusion_*` | `momentumTransport->divDevSigma(U)` / diffusion uses face area and cell spacing |\n",
"| `pressure_*` | `-fvc::grad(p)` appears as face pressure times area vector |\n",
"| internal equal/opposite signs | owner/neighbour addressing conserves internal face transfers |\n",
"| wall-only contribution | a boundary patch replaces the missing neighbour with boundary-condition algebra |\n",
"\n",
"This is not yet SIMPLE, turbulence, pressure correction, or a real airfoil mesh. It is the smallest executable picture of what every finite-volume face loop contributes to the residual.\n",
"\n",
"Good use: change one value at a time — `U_N`, `p_N`, `Gamma`, `U_wall`, or `phi_wall` — predict the sign of the residual change, then rerun.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View file

@ -0,0 +1,814 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "232f64cf",
"metadata": {},
"source": [
"# AirfRANS in OpenFOAM: algorithm-first, array-visible\n",
"\n",
"Purpose: understand what OpenFOAM is doing in the AirfRANS steady RANS solve, without drowning in C++ framework structure.\n",
"\n",
"Pattern used throughout:\n",
"\n",
"```text\n",
"OpenFOAM phase → algorithmic meaning → equations used → arrays touched → parallelization shape → small numeric probe\n",
"```\n",
"\n",
"This is not a generic CFD derivation. It follows the repo's real OpenFOAM v14 `incompressibleFluid` path used to run a migrated AirfRANS `kOmegaSST` case.\n"
]
},
{
"cell_type": "markdown",
"id": "7a6f30ae",
"metadata": {},
"source": [
"## 0. Mental model\n",
"\n",
"OpenFOAM is doing a finite-volume SIMPLE solve.\n",
"\n",
"```text\n",
"cells store unknowns\n",
"faces move flux\n",
"matrices encode neighbour coupling\n",
"pressure repairs continuity\n",
"turbulence updates effective viscosity\n",
"```\n",
"\n",
"Core arrays:\n",
"\n",
"| array | meaning |\n",
"|---|---|\n",
"| `V[c]` | cell volume |\n",
"| `C[c,3]` | cell centre |\n",
"| `Sf[f,3]` | oriented face area vector |\n",
"| `owner[f]`, `neighbour[f]` | face-to-cell topology |\n",
"| `phi[f]` | active solve face volume flux; empty front/back faces are bookkeeping for 2D |\n",
"| `U[c,3]` | mean velocity |\n",
"| `p[c]` | kinematic pressure |\n",
"| `k[c]`, `omega[c]`, `nut[c]` | SST turbulence state |\n",
"| `diag`, `upper`, `lower`, `source` | sparse finite-volume matrix storage |\n"
]
},
{
"cell_type": "code",
"execution_count": 1,
"id": "e6138eb2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:07.329068Z",
"iopub.status.busy": "2026-07-24T06:27:07.328767Z",
"iopub.status.idle": "2026-07-24T06:27:09.064496Z",
"shell.execute_reply": "2026-07-24T06:27:09.063766Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'simulation': 'airFoil2D_SST_93.213_3.79_0.418_0.0_9.665', 'cells': 283400, 'internal_face_flux_entries': 565419, 'sparse_offdiag_entries': 565419, 'boundary_patches': ['aerofoil', 'freestream', 'frontAndBack'], 'U_shape': (283400, 3), 'p_shape': (283400,), 'phi_internal_shape': (565419,)}\n"
]
}
],
"source": [
"# Fresh-kernel setup. Keep framework setup quarantined here.\n",
"from pathlib import Path\n",
"from contextlib import contextmanager\n",
"import os, sys, shutil, json, re\n",
"\n",
"# Automation can inject an incompatible PYTHONPATH. Drop it before compiled imports.\n",
"pythonpath = os.environ.pop(\"PYTHONPATH\", \"\")\n",
"for entry in pythonpath.split(os.pathsep):\n",
" if entry:\n",
" while entry in sys.path:\n",
" sys.path.remove(entry)\n",
"\n",
"import numpy as np\n",
"\n",
"\n",
"def find_repo_root(start):\n",
" start = Path(start).resolve()\n",
" for p in [start, *start.parents]:\n",
" if (p / \"pyproject.toml\").exists() and (p / \"OpenFOAM-14\").exists():\n",
" return p\n",
" raise RuntimeError(\"repo root not found\")\n",
"\n",
"\n",
"@contextmanager\n",
"def quiet_native_output():\n",
" # Suppress OpenFOAM banner/solver logs; this notebook prints selected facts instead.\n",
" sys.stdout.flush(); sys.stderr.flush()\n",
" devnull = os.open(os.devnull, os.O_WRONLY)\n",
" saved_out = os.dup(1)\n",
" saved_err = os.dup(2)\n",
" try:\n",
" os.dup2(devnull, 1)\n",
" os.dup2(devnull, 2)\n",
" yield\n",
" finally:\n",
" os.dup2(saved_out, 1)\n",
" os.dup2(saved_err, 2)\n",
" os.close(saved_out)\n",
" os.close(saved_err)\n",
" os.close(devnull)\n",
"\n",
"\n",
"def q(call, *args, **kwargs):\n",
" with quiet_native_output():\n",
" return call(*args, **kwargs)\n",
"\n",
"\n",
"ROOT = find_repo_root(Path.cwd())\n",
"os.chdir(ROOT)\n",
"sys.path.insert(0, str(ROOT / \"scripts\"))\n",
"\n",
"from openfoam_env import apply_openfoam_env\n",
"from prepare_airfrans_stepper_case import DEFAULT_SOURCE, prepare_case\n",
"\n",
"apply_openfoam_env()\n",
"import foam_stepper as foam\n",
"\n",
"WORK = ROOT / \"tmp\" / \"airfrans_openfoam_algorithm_first_notebook\"\n",
"CASE = WORK / \"airfrans_v14\"\n",
"SOURCE = DEFAULT_SOURCE\n",
"\n",
"# Rebuild the migrated case for every full notebook execution.\n",
"if CASE.exists():\n",
" shutil.rmtree(CASE)\n",
"WORK.mkdir(parents=True, exist_ok=True)\n",
"meta = prepare_case(SOURCE, CASE, end_time=1)\n",
"\n",
"with quiet_native_output():\n",
" stepper = foam.Case(CASE).make_stepper()\n",
" mesh = stepper.mesh()\n",
" fields = stepper.fields()\n",
"\n",
"# Plain arrays used by the rest of the notebook.\n",
"V = np.asarray(mesh.V)\n",
"C = np.asarray(mesh.C)\n",
"Cf = np.asarray(mesh.Cf)\n",
"Sf = np.asarray(mesh.Sf)\n",
"magSf = np.asarray(mesh.magSf)\n",
"owner = np.asarray(mesh.owner, dtype=np.int64)\n",
"neighbour = np.asarray(mesh.neighbour, dtype=np.int64)\n",
"patches = list(mesh.boundary)\n",
"\n",
"U0 = np.asarray(fields[\"U\"].internal)\n",
"p0 = np.asarray(fields[\"p\"].internal)\n",
"phi0 = np.asarray(fields[\"phi\"].internal)\n",
"k0 = np.asarray(fields[\"k\"].internal)\n",
"omega0 = np.asarray(fields[\"omega\"].internal)\n",
"nut0 = np.asarray(fields[\"nut\"].internal)\n",
"phi_field0 = fields[\"phi\"]\n",
"\n",
"print({\n",
" \"simulation\": meta.simulation,\n",
" \"cells\": int(V.size),\n",
" \"internal_face_flux_entries\": int(phi0.size),\n",
" \"sparse_offdiag_entries\": int(neighbour.size),\n",
" \"boundary_patches\": [patch.name for patch in patches],\n",
" \"U_shape\": U0.shape,\n",
" \"p_shape\": p0.shape,\n",
" \"phi_internal_shape\": phi0.shape,\n",
"})\n"
]
},
{
"cell_type": "markdown",
"id": "763c6a18",
"metadata": {},
"source": [
"## 1. Case contract: what algorithm OpenFOAM has been asked to run\n",
"\n",
"Before thinking about kernels, read the dictionaries as the numerical contract:\n",
"\n",
"- `controlDict`: steady run controls and selected solver;\n",
"- `fvSchemes`: discrete operators, interpolation, gradients, divergence, laplacians;\n",
"- `fvSolution`: linear solvers, SIMPLE controls, relaxation;\n",
"- initial fields: boundary conditions and starting values;\n",
"- `momentumTransport`: RAS model choice, here `kOmegaSST`.\n",
"\n",
"`frontAndBack` is an OpenFOAM `empty` patch. It matters for declaring the case 2D, but it does not contribute active face-flux work in the solve view used below.\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "eadd8926",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.065740Z",
"iopub.status.busy": "2026-07-24T06:27:09.065631Z",
"iopub.status.idle": "2026-07-24T06:27:09.069646Z",
"shell.execute_reply": "2026-07-24T06:27:09.069229Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"case\n",
"{'solver': 'incompressibleFluid', 'endTime': '1', 'deltaT': '1', 'RAS_model': 'kOmegaSST', 'freestream_speed': 93.213, 'alpha_deg': 3.79, 'Re': 5975192.3}\n",
"\n",
"patches\n",
"{'name': 'aerofoil', 'type': 'wall', 'active_faces_in_stepper_view': 1026}\n",
"{'name': 'freestream', 'type': 'patch', 'active_faces_in_stepper_view': 1736}\n",
"{'name': 'frontAndBack', 'type': 'empty', 'active_faces_in_stepper_view': 0}\n",
"frontAndBack is an empty 2D patch; it has no active solve-flux faces here.\n",
"\n",
"numerics\n",
"{'has_SIMPLE_block': True, 'has_div_phi_U_scheme': True, 'has_laplacian_schemes': True, 'has_relaxationFactors': True}\n"
]
}
],
"source": [
"def assignment(text, key, default=None):\n",
" m = re.search(rf\"^\\s*{re.escape(key)}\\s+([^;]+);\", text, flags=re.MULTILINE)\n",
" return default if m is None else m.group(1).strip()\n",
"\n",
"\n",
"def contains_line(text, needle):\n",
" return any(needle in line for line in text.splitlines())\n",
"\n",
"control = (CASE / \"system/controlDict\").read_text(errors=\"replace\")\n",
"schemes = (CASE / \"system/fvSchemes\").read_text(errors=\"replace\")\n",
"solution = (CASE / \"system/fvSolution\").read_text(errors=\"replace\")\n",
"transport = (CASE / \"constant/momentumTransport\").read_text(errors=\"replace\")\n",
"\n",
"patch_rows = []\n",
"for patch in patches:\n",
" patch_rows.append((patch.name, patch.type, int(patch.size), int(patch.start)))\n",
"\n",
"print(\"case\")\n",
"print({\n",
" \"solver\": assignment(control, \"solver\"),\n",
" \"endTime\": assignment(control, \"endTime\"),\n",
" \"deltaT\": assignment(control, \"deltaT\"),\n",
" \"RAS_model\": assignment(transport, \"model\"),\n",
" \"freestream_speed\": meta.u_inf,\n",
" \"alpha_deg\": round(meta.alpha_deg, 6),\n",
" \"Re\": round(meta.reynolds, 1),\n",
"})\n",
"\n",
"print(\"\\npatches\")\n",
"for row in patch_rows:\n",
" print({\"name\": row[0], \"type\": row[1], \"active_faces_in_stepper_view\": row[2]})\n",
"print(\"frontAndBack is an empty 2D patch; it has no active solve-flux faces here.\")\n",
"\n",
"print(\"\\nnumerics\")\n",
"print({\n",
" \"has_SIMPLE_block\": \"SIMPLE\" in solution,\n",
" \"has_div_phi_U_scheme\": contains_line(schemes, \"div(phi,U)\"),\n",
" \"has_laplacian_schemes\": \"laplacianSchemes\" in schemes,\n",
" \"has_relaxationFactors\": \"relaxationFactors\" in solution,\n",
"})\n"
]
},
{
"cell_type": "markdown",
"id": "943203bb",
"metadata": {},
"source": [
"## 2. The actual OpenFOAM solve order\n",
"\n",
"For this repo's OpenFOAM v14 path, `simpleFoam` maps to `foamRun -solver incompressibleFluid`.\n",
"The relevant algorithmic order is:\n",
"\n",
"```text\n",
"pre_solve\n",
"advance_time\n",
"begin SIMPLE/PIMPLE iteration\n",
" fv_models_correct\n",
" pre_predictor\n",
" momentum_transport_predictor\n",
" assemble momentum terms\n",
" assemble UEqn\n",
" relax UEqn\n",
" constrain UEqn\n",
" solve momentum predictor\n",
" compute pressure inputs: rAU, HbyA, phiHbyA\n",
" assemble pEqn\n",
" solve pEqn\n",
" correct phi, p, U\n",
" momentum_transport_corrector # k-omega SST: omega, k, nut\n",
"end iteration\n",
"post_solve\n",
"```\n",
"\n",
"Parallel intuition:\n",
"\n",
"```text\n",
"assembly is mostly face/cell parallel\n",
"linear solves are global iterative kernels\n",
"boundary conditions are patch kernels\n",
"residuals/convergence are reductions\n",
"phase boundaries are synchronization points\n",
"```\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "d13855ca",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.070871Z",
"iopub.status.busy": "2026-07-24T06:27:09.070736Z",
"iopub.status.idle": "2026-07-24T06:27:09.080021Z",
"shell.execute_reply": "2026-07-24T06:27:09.079575Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'initial_continuity_residual': {'Linf': 0.061028931972296335, 'L1': 20.391531263432356}, 'note': 'Signed internal owner/neighbour flux plus boundary patch flux into each cell.'}\n"
]
}
],
"source": [
"def continuity_residual_from_phi(phi_field, owner, neighbour, patches, n_cells):\n",
" \"\"\"Signed flux sum per cell, including boundary patch fluxes.\"\"\"\n",
" internal_phi = np.asarray(phi_field.internal)\n",
" r = np.zeros(n_cells, dtype=internal_phi.dtype)\n",
"\n",
" np.add.at(r, owner, internal_phi[:len(owner)])\n",
" np.add.at(r, neighbour, -internal_phi[:len(neighbour)])\n",
"\n",
" for patch in patches:\n",
" values = np.asarray(phi_field.boundary[patch.name].values).reshape(-1)\n",
" if values.size == 0:\n",
" continue\n",
" face_cells = np.asarray(patch.face_cells, dtype=np.int64)[:values.size]\n",
" np.add.at(r, face_cells, values)\n",
" return r\n",
"\n",
"\n",
"def norm_report(x):\n",
" x = np.asarray(x)\n",
" return {\n",
" \"Linf\": float(np.max(np.abs(x))) if x.size else 0.0,\n",
" \"L1\": float(np.sum(np.abs(x))) if x.size else 0.0,\n",
" }\n",
"\n",
"mass0 = continuity_residual_from_phi(phi_field0, owner, neighbour, patches, len(V))\n",
"print({\n",
" \"initial_continuity_residual\": norm_report(mass0),\n",
" \"note\": \"Signed internal owner/neighbour flux plus boundary patch flux into each cell.\",\n",
"})\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "3f5ddd40",
"metadata": {},
"source": [
"## 3. Momentum predictor\n",
"\n",
"OpenFOAM operation, stripped to computation:\n",
"\n",
"```text\n",
"UEqn = ddt(U) + div(phi, U) + turbulent_stress_divergence(U, nut) == sources\n",
"relax UEqn\n",
"apply matrix/boundary constraints\n",
"solve UEqn == -grad(p)\n",
"```\n",
"\n",
"Sparse-row view:\n",
"\n",
"$$\n",
"A_c U_c + \\sum_{n\\in N(c)} A_{cn}U_n = b_c - V_c(\\nabla p)_c\n",
"$$\n",
"\n",
"Arrays touched:\n",
"\n",
"```text\n",
"read: U, p, phi, nut, V, Sf, owner, neighbour, boundary fields, fvSchemes, fvSolution\n",
"write: UEqn.diag, UEqn.upper/lower, UEqn.source, possibly U\n",
"```\n",
"\n",
"Parallel shape:\n",
"\n",
"```text\n",
"per-face work: convection/diffusion coupling across owner-neighbour\n",
"per-cell work: diagonal/source accumulation and explicit terms\n",
"solver work: sparse matrix-vector iterations plus reductions\n",
"hazard: face contributions scatter into two cells unless accumulation is organized carefully\n",
"```\n"
]
},
{
"cell_type": "code",
"execution_count": 4,
"id": "57ce4919",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.081243Z",
"iopub.status.busy": "2026-07-24T06:27:09.081153Z",
"iopub.status.idle": "2026-07-24T06:27:09.322109Z",
"shell.execute_reply": "2026-07-24T06:27:09.321878Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'phases_reached': ['pre_solve', 'advance_time', 'begin_pimple_iteration', 'fv_models_correct', 'pre_predictor', 'momentum_transport_predict', 'assemble_momentum_terms', 'assemble_UEqn'], 'momentum_terms': ['assemble_momentum_ddt', 'assemble_momentum_div_phi_U', 'assemble_momentum_divDevSigma', 'assemble_momentum_sources', 'assemble_momentum_MRF_DDt', 'assemble_momentum_pressure_rhs_grad_p'], 'diag': (283400,), 'upper': (565419,), 'lower': (565419,), 'source': (283400, 3), 'H': (283400, 3), 'diag_first_5': [4.5674015979225775, 4.426112149473814, 4.2788585852295125, 4.134855363049338, 3.9941324145628894]}\n"
]
}
],
"source": [
"phase_names = []\n",
"for method in [\n",
" stepper.pre_solve,\n",
" stepper.advance_time,\n",
" stepper.begin_pimple_iteration,\n",
" stepper.fv_models_correct,\n",
" stepper.pre_predictor,\n",
" stepper.momentum_transport_predictor,\n",
"]:\n",
" result = q(method)\n",
" phase_names.append(result.name)\n",
"\n",
"terms = q(stepper.assemble_momentum_terms)\n",
"UEqn_result = q(stepper.assemble_momentum_matrix)\n",
"UEqn = UEqn_result.outputs[\"UEqn\"]\n",
"phase_names.extend([terms.name, UEqn_result.name])\n",
"\n",
"A_U = np.asarray(UEqn.diag)\n",
"upper_U = None if UEqn.upper is None else np.asarray(UEqn.upper)\n",
"lower_U = None if UEqn.lower is None else np.asarray(UEqn.lower)\n",
"b_U = np.asarray(UEqn.source)\n",
"H_U = None if UEqn.H is None else np.asarray(UEqn.H.internal)\n",
"\n",
"print({\n",
" \"phases_reached\": phase_names,\n",
" \"momentum_terms\": [t[\"name\"] for t in terms.outputs[\"terms\"]],\n",
" \"diag\": A_U.shape,\n",
" \"upper\": None if upper_U is None else upper_U.shape,\n",
" \"lower\": None if lower_U is None else lower_U.shape,\n",
" \"source\": b_U.shape,\n",
" \"H\": None if H_U is None else H_U.shape,\n",
" \"diag_first_5\": A_U[:5].tolist(),\n",
"})\n"
]
},
{
"cell_type": "markdown",
"id": "63757c15",
"metadata": {},
"source": [
"## 4. One face as kernel anatomy, not serial control flow\n",
"\n",
"The one-face view is useful only as a stencil exemplar.\n",
"It should read as:\n",
"\n",
"```text\n",
"this is one work item shape; many faces run in parallel\n",
"```\n",
"\n",
"For an internal face `f`, a face kernel typically reads both adjacent cells and writes contributions to both matrix rows:\n",
"\n",
"```text\n",
"o = owner[f]\n",
"n = neighbour[f]\n",
"read: U[o], U[n], C[o], C[n], Sf[f], phi[f], nut[o], nut[n]\n",
"write: row contribution for o and row contribution for n\n",
"```\n",
"\n",
"Race risk appears if many faces scatter-add into the same cell row at once.\n"
]
},
{
"cell_type": "code",
"execution_count": 5,
"id": "39d467a5",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.323799Z",
"iopub.status.busy": "2026-07-24T06:27:09.323707Z",
"iopub.status.idle": "2026-07-24T06:27:09.325983Z",
"shell.execute_reply": "2026-07-24T06:27:09.325697Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'face': 282709, 'owner_cell': 141545, 'neighbour_cell': 141546, 'Sf': [-0.009744034299999982, -0.0027874465999999654, 0.0], 'magSf': 0.010134893338729693, 'phi_before': -0.9234587503553671, 'U_owner_before': [93.00914503999995, 6.161356013756317, 0.0], 'U_neighbour_before': [93.00914503999995, 6.161356013756317, 0.0], 'center_distance': 0.001573663286642463}\n"
]
}
],
"source": [
"f = int(len(neighbour) // 2)\n",
"o = int(owner[f])\n",
"n = int(neighbour[f])\n",
"\n",
"face_probe = {\n",
" \"face\": f,\n",
" \"owner_cell\": o,\n",
" \"neighbour_cell\": n,\n",
" \"Sf\": Sf[f].tolist(),\n",
" \"magSf\": float(magSf[f]),\n",
" \"phi_before\": float(phi0[f]),\n",
" \"U_owner_before\": U0[o].tolist(),\n",
" \"U_neighbour_before\": U0[n].tolist(),\n",
" \"center_distance\": float(np.linalg.norm(C[n] - C[o])),\n",
"}\n",
"print(face_probe)\n"
]
},
{
"cell_type": "markdown",
"id": "906e541d",
"metadata": {},
"source": [
"## 5. Pressure correction\n",
"\n",
"OpenFOAM operation, stripped to computation:\n",
"\n",
"```text\n",
"rAU = 1 / UEqn.A\n",
"HbyA = constrained(rAU * UEqn.H)\n",
"phiHbyA = flux(HbyA) + time/mesh correction\n",
"apply pressure boundary consistency\n",
"pEqn = laplacian(rAU, p) == div(phiHbyA)\n",
"solve pEqn\n",
"phi = phiHbyA - pEqn.flux()\n",
"p.relax()\n",
"U = HbyA - rAU * grad(p)\n",
"correct U boundary conditions\n",
"```\n",
"\n",
"Why this exists:\n",
"\n",
"```text\n",
"momentum predicts U\n",
"predicted U may violate continuity\n",
"pressure solve computes a flux correction\n",
"corrected phi is the flux field OpenFOAM uses for continuity-error accounting\n",
"U is then corrected to be consistent with that pressure/flux repair\n",
"```\n",
"\n",
"Parallel shape:\n",
"\n",
"```text\n",
"per-cell: rAU, HbyA\n",
"per-face: interpolate HbyA/rAU and compute phiHbyA\n",
"solver: pressure Laplacian iterations are global\n",
"per-face: corrected phi\n",
"per-cell: corrected U\n",
"reduction: continuity errors\n",
"```\n"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "958d0625",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.327451Z",
"iopub.status.busy": "2026-07-24T06:27:09.327360Z",
"iopub.status.idle": "2026-07-24T06:27:09.655600Z",
"shell.execute_reply": "2026-07-24T06:27:09.655213Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'matrix_steps': ['relax_UEqn', 'constrain_UEqn', 'solve_UEqn'], 'pressure_steps': ['compute_pressure_inputs', 'assemble_pEqn'], 'rAU': (283400,), 'HbyA': (283400, 3), 'phiHbyA': (565419,), 'p_diag': (283400,), 'p_upper': (565419,), 'p_source': (283400,), 'rAU_first_5': [0.00026755862048551105, 0.00024708843305873933, 0.00022836470465003848, 0.00021125319224392247, 0.00019560221178537135]}\n"
]
}
],
"source": [
"relax_result = q(stepper.relax_matrix)\n",
"constrain_result = q(stepper.constrain_matrix)\n",
"momentum_solve = q(stepper.solve_momentum)\n",
"pressure_inputs = q(stepper.compute_pressure_inputs)\n",
"pEqn_result = q(stepper.assemble_pressure_matrix)\n",
"pEqn = pEqn_result.outputs[\"pEqn\"]\n",
"\n",
"rAU = np.asarray(pressure_inputs.outputs[\"rAU\"].internal)\n",
"HbyA = np.asarray(pressure_inputs.outputs[\"HbyA\"].internal)\n",
"phiHbyA = np.asarray(pressure_inputs.outputs[\"phiHbyA\"].internal)\n",
"A_p = np.asarray(pEqn.diag)\n",
"upper_p = None if pEqn.upper is None else np.asarray(pEqn.upper)\n",
"b_p = np.asarray(pEqn.source)\n",
"\n",
"print({\n",
" \"matrix_steps\": [relax_result.name, constrain_result.name, momentum_solve.name],\n",
" \"pressure_steps\": [pressure_inputs.name, pEqn_result.name],\n",
" \"rAU\": rAU.shape,\n",
" \"HbyA\": HbyA.shape,\n",
" \"phiHbyA\": phiHbyA.shape,\n",
" \"p_diag\": A_p.shape,\n",
" \"p_upper\": None if upper_p is None else upper_p.shape,\n",
" \"p_source\": b_p.shape,\n",
" \"rAU_first_5\": rAU[:5].tolist(),\n",
"})\n"
]
},
{
"cell_type": "code",
"execution_count": 7,
"id": "ab30847e",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:09.656945Z",
"iopub.status.busy": "2026-07-24T06:27:09.656826Z",
"iopub.status.idle": "2026-07-24T06:27:12.376771Z",
"shell.execute_reply": "2026-07-24T06:27:12.376091Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'pressure_steps': ['solve_pEqn', 'correct_velocity_pressure_flux'], 'changed_fields': ['p', 'U', 'phi'], 'delta_U_Linf': 142.80037348998195, 'delta_p_Linf': 139306.85715654117, 'delta_phi_Linf': 0.12004663628376555, 'continuity_after_pressure': {'Linf': 0.026893021480056893, 'L1': 1.0664099985737665}}\n"
]
}
],
"source": [
"pressure_solve = q(stepper.solve_pressure)\n",
"correct_result = q(stepper.correct_velocity_pressure_flux)\n",
"\n",
"fields_after_pressure = stepper.fields()\n",
"U_after_pressure = np.asarray(fields_after_pressure[\"U\"].internal)\n",
"p_after_pressure = np.asarray(fields_after_pressure[\"p\"].internal)\n",
"phi_after_pressure_field = fields_after_pressure[\"phi\"]\n",
"phi_after_pressure = np.asarray(phi_after_pressure_field.internal)\n",
"\n",
"mass_after_pressure = continuity_residual_from_phi(phi_after_pressure_field, owner, neighbour, patches, len(V))\n",
"\n",
"print({\n",
" \"pressure_steps\": [pressure_solve.name, correct_result.name],\n",
" \"changed_fields\": correct_result.changed_fields,\n",
" \"delta_U_Linf\": float(np.max(np.abs(U_after_pressure - U0))),\n",
" \"delta_p_Linf\": float(np.max(np.abs(p_after_pressure - p0))),\n",
" \"delta_phi_Linf\": float(np.max(np.abs(phi_after_pressure - phi0))),\n",
" \"continuity_after_pressure\": norm_report(mass_after_pressure),\n",
"})\n"
]
},
{
"cell_type": "markdown",
"id": "4c384cfc",
"metadata": {},
"source": [
"## 6. Turbulence corrector: k-omega SST\n",
"\n",
"OpenFOAM's `kOmegaSST` corrector does this computationally:\n",
"\n",
"```text\n",
"compute grad(U)\n",
"compute strain magnitude S2\n",
"compute production G from nut and grad(U)\n",
"update omega wall coefficients\n",
"compute SST blending functions F1, F2/F23\n",
"assemble and solve omega equation\n",
"bound omega\n",
"assemble and solve k equation\n",
"bound k and omega\n",
"update nut = a1*k / max(a1*omega, b1*F2*sqrt(S2))\n",
"apply nut boundary conditions/constraints\n",
"```\n",
"\n",
"For parallelization:\n",
"\n",
"```text\n",
"grad(U): face/cell stencil\n",
"production/S2: per-cell\n",
"omega matrix: face/cell assembly + sparse solve\n",
"k matrix: face/cell assembly + sparse solve\n",
"nut update: embarrassingly per-cell, plus patch kernels for wall functions\n",
"```\n"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "d8e88122",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-24T06:27:12.378174Z",
"iopub.status.busy": "2026-07-24T06:27:12.378067Z",
"iopub.status.idle": "2026-07-24T06:27:12.634993Z",
"shell.execute_reply": "2026-07-24T06:27:12.634592Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"{'final_steps': ['momentum_transport_correct', 'end_pimple_iteration', 'post_solve'], 'turbulence_changed_fields': ['viscosity', 'momentumTransport'], 'delta_k_Linf': 2.9020790511255652e-08, 'delta_omega_Linf': 1718225865.8661606, 'delta_nut_Linf': 3.1199999716434326e-09, 'final_continuity_residual': {'Linf': 0.026893021480056893, 'L1': 1.0664099985737665}, 'omega_delta_note': 'Linf is wall-dominated because omega wall coefficients update strongly.'}\n"
]
}
],
"source": [
"turbulence_result = q(stepper.momentum_transport_corrector)\n",
"end_result = q(stepper.end_pimple_iteration)\n",
"post_result = q(stepper.post_solve, write=False)\n",
"\n",
"fields1 = stepper.fields()\n",
"U1 = np.asarray(fields1[\"U\"].internal)\n",
"p1 = np.asarray(fields1[\"p\"].internal)\n",
"phi_field1 = fields1[\"phi\"]\n",
"phi1 = np.asarray(phi_field1.internal)\n",
"k1 = np.asarray(fields1[\"k\"].internal)\n",
"omega1 = np.asarray(fields1[\"omega\"].internal)\n",
"nut1 = np.asarray(fields1[\"nut\"].internal)\n",
"\n",
"mass1 = continuity_residual_from_phi(phi_field1, owner, neighbour, patches, len(V))\n",
"\n",
"print({\n",
" \"final_steps\": [turbulence_result.name, end_result.name, post_result.name],\n",
" \"turbulence_changed_fields\": turbulence_result.changed_fields,\n",
" \"delta_k_Linf\": float(np.max(np.abs(k1 - k0))),\n",
" \"delta_omega_Linf\": float(np.max(np.abs(omega1 - omega0))),\n",
" \"delta_nut_Linf\": float(np.max(np.abs(nut1 - nut0))),\n",
" \"final_continuity_residual\": norm_report(mass1),\n",
" \"omega_delta_note\": \"Linf is wall-dominated because omega wall coefficients update strongly.\",\n",
"})\n"
]
},
{
"cell_type": "markdown",
"id": "a78169fd",
"metadata": {},
"source": [
"## 7. Parallelization map\n",
"\n",
"| OpenFOAM algorithm phase | natural work items | writes | synchronization / hazard |\n",
"|---|---|---|---|\n",
"| face flux/interpolation | faces | `phi[f]`, face temporaries | reads owner/neighbour cells |\n",
"| continuity residual | faces or cells | residual per cell | face scatter needs atomics or two-pass accumulation |\n",
"| momentum assembly | faces + cells | sparse matrix rows | owner/neighbour scatter or row-wise gather |\n",
"| matrix relaxation/constraints | cells + patches | matrix rows, boundary coeffs | patch-specific logic |\n",
"| momentum solve | sparse rows | `U` iterations | global reductions each Krylov iteration |\n",
"| pressure-input build | cells + faces | `rAU`, `HbyA`, `phiHbyA` | interpolation crosses faces |\n",
"| pressure assembly/solve | faces + sparse rows | pressure matrix, `p` iterations | global solve/reductions |\n",
"| flux/velocity correction | faces + cells | `phi`, `U` | pressure field must be solved first |\n",
"| SST corrector | cells + faces + patches | `omega`, `k`, `nut` | two scalar solves, wall-function patches |\n",
"\n",
"Main lesson for GPU work:\n",
"\n",
"```text\n",
"OpenFOAM's loop is phase-serial.\n",
"Inside each phase, most assembly/update work is data-parallel.\n",
"The sparse solvers and reductions are the major global synchronization points.\n",
"```\n"
]
},
{
"cell_type": "markdown",
"id": "3737d955",
"metadata": {},
"source": [
"## 8. What this notebook should let you explain\n",
"\n",
"After working through it, you should be able to say:\n",
"\n",
"1. which OpenFOAM phase builds the momentum matrix;\n",
"2. why pressure correction exists;\n",
"3. why `phi` is central to continuity;\n",
"4. where boundary conditions alter matrices/fields;\n",
"5. which parts are face-parallel, cell-parallel, patch-parallel, or globally synchronized;\n",
"6. why a one-face/one-cell probe is a kernel stencil example, not the serial algorithm.\n",
"\n",
"If a future section cannot be described as `phase → arrays → kernel shape`, it is probably framework spandrel and should be cut.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.12.12"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because one or more lines are too long

25
pyproject.toml Normal file
View file

@ -0,0 +1,25 @@
[project]
name = "openfoam-rans-to-gpu"
version = "0.1.0"
description = "OpenFOAM RANS-to-GPU experiments and Python stepper tooling"
requires-python = ">=3.12"
dependencies = [
"foam-stepper",
"quadrants>=1.1.3",
"numpy>=2.0",
"pybind11>=2.13",
]
[dependency-groups]
dev = [
"ipykernel>=6.29",
"jupyterlab>=4.2",
"nbclient>=0.10",
"nbformat>=5.10",
]
[tool.uv]
package = false
[tool.uv.sources]
foam-stepper = { path = "python", editable = true }

15
python/pyproject.toml Normal file
View file

@ -0,0 +1,15 @@
[project]
name = "foam-stepper"
version = "0.2.0"
description = "Python-driven OpenFOAM observability stepper"
requires-python = ">=3.12"
dependencies = [
"numpy>=2.0",
]
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -0,0 +1,205 @@
"""Python-driven OpenFOAM observability stepper."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ._runtime import configure_openfoam_environment
configure_openfoam_environment()
from . import _foam_stepper as _native
from .types import (
PatchFieldView,
PatchView,
RaggedIntArray,
FieldRegistryView,
FieldView,
MatrixView,
MeshView,
SolveResult,
SourceLocation,
TransformResult,
_wrap_value,
)
OpenFoamError = _native.OpenFoamError
def version() -> str:
return _native.version()
class Case:
"""OpenFOAM case handle that creates Python-controlled steppers."""
def __init__(self, path: str | Path, solver: str = "incompressibleFluid", time: str | None = None, libs: list[str] | None = None):
if time is not None:
raise NotImplementedError("Explicit time selection is not implemented yet; OpenFOAM selects startTime")
if libs:
raise NotImplementedError("Additional library loading is not implemented yet")
self._path = str(path)
self._solver = solver
self._native = _native.Case(self._path, solver)
@property
def path(self) -> str:
return self._path
@property
def solver_name(self) -> str:
return self._solver
def times(self) -> list[str]:
root = Path(self._path)
times: list[tuple[float, str]] = []
for child in root.iterdir():
if child.is_dir() and _looks_like_time(child.name):
times.append((float(child.name), child.name))
return [name for _, name in sorted(times)]
def control_dict(self) -> str:
return self.make_stepper().control_dict()
def fv_schemes(self) -> str:
return self.make_stepper().fv_schemes()
def fv_solution(self) -> str:
return self.make_stepper().fv_solution()
def make_stepper(self) -> "SimpleStepper":
return SimpleStepper(self._native.make_stepper())
class SimpleStepper:
"""Python facade over the C++ OpenFOAM observability stepper."""
def __init__(self, native: Any):
self._native = native
@property
def case_path(self) -> str:
return self._native.case_path
@property
def solver_name(self) -> str:
return self._native.solver_name
def state(self) -> dict[str, Any]:
return dict(self._native.state())
def control_dict(self) -> str:
return self._native.control_dict()
def fv_schemes(self) -> str:
return self._native.fv_schemes()
def fv_solution(self) -> str:
return self._native.fv_solution()
def mesh(self) -> MeshView:
return MeshView.from_dict(self._native.mesh())
def fields(self) -> FieldRegistryView:
return FieldRegistryView.from_dict(self._native.fields())
def pre_solve(self) -> TransformResult:
return _transform(self._native.pre_solve())
def advance_time(self) -> TransformResult:
return _transform(self._native.advance_time())
def begin_pimple_iteration(self) -> TransformResult:
return _transform(self._native.begin_pimple_iteration())
def end_pimple_iteration(self) -> TransformResult:
return _transform(self._native.end_pimple_iteration())
def post_solve(self, write: bool = False) -> TransformResult:
return _transform(self._native.post_solve(write))
def fv_models_correct(self) -> TransformResult:
return _transform(self._native.fv_models_correct())
def pre_predictor(self) -> TransformResult:
return _transform(self._native.pre_predictor())
def momentum_transport_predictor(self) -> TransformResult:
return _transform(self._native.momentum_transport_predictor())
def momentum_transport_corrector(self) -> TransformResult:
return _transform(self._native.momentum_transport_corrector())
def assemble_momentum_terms(self) -> TransformResult:
return _transform(self._native.assemble_momentum_terms())
def assemble_momentum_matrix(self, terms: Any | None = None) -> TransformResult:
if terms is not None:
# C++ recomputes the OpenFOAM expression to preserve native invariants.
pass
return _transform(self._native.assemble_momentum_matrix())
def relax_matrix(self, matrix: Any | None = None) -> TransformResult:
return _transform(self._native.relax_matrix())
def constrain_matrix(self, matrix: Any | None = None) -> TransformResult:
return _transform(self._native.constrain_matrix())
def solve_momentum(self, matrix: Any | None = None) -> TransformResult:
return _transform(self._native.solve_momentum())
def compute_pressure_inputs(self, UEqn: Any | None = None) -> TransformResult:
return _transform(self._native.compute_pressure_inputs())
def assemble_pressure_matrix(self, inputs: Any | None = None) -> TransformResult:
return _transform(self._native.assemble_pressure_matrix())
def solve_pressure(self, matrix: Any | None = None) -> TransformResult:
return _transform(self._native.solve_pressure())
def correct_velocity_pressure_flux(self, inputs: Any | None = None, pEqn: Any | None = None) -> TransformResult:
return _transform(self._native.correct_velocity_pressure_flux())
def run_one_pimple_iteration(self) -> TransformResult:
return _transform(self._native.run_one_pimple_iteration())
def run_until(self, max_steps: int | None = None, max_iterations: int | None = None) -> list[TransformResult]:
limit = max_steps if max_steps is not None else max_iterations
results: list[TransformResult] = []
while limit is None or len(results) < limit:
result = self.run_one_pimple_iteration()
results.append(result)
if not result.outputs.get("time_loop_active", False):
break
return results
def _transform(raw: Any) -> TransformResult:
return TransformResult.from_dict(raw)
def _looks_like_time(name: str) -> bool:
try:
float(name)
except ValueError:
return False
return True
__all__ = [
"Case",
"SimpleStepper",
"OpenFoamError",
"PatchFieldView",
"PatchView",
"RaggedIntArray",
"FieldRegistryView",
"FieldView",
"MatrixView",
"MeshView",
"SolveResult",
"SourceLocation",
"TransformResult",
"version",
]

Binary file not shown.

View file

@ -0,0 +1,70 @@
"""Runtime OpenFOAM environment setup for the Python facade."""
from __future__ import annotations
import os
import shlex
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
OPENFOAM_ROOT = ROOT / "OpenFOAM-14"
_ENV_SENTINEL = "_FOAM_STEPPER_OPENFOAM_ENV"
def _capture_openfoam_env() -> dict[str, str] | None:
bashrc = OPENFOAM_ROOT / "etc/bashrc"
if not bashrc.exists():
return None
script = " ".join(
[
"set +u;",
"source",
shlex.quote(str(bashrc)),
"WM_MPLIB=Dummy",
"ParaView_TYPE=none",
"SCOTCH_TYPE=none",
"ZOLTAN_TYPE=none;",
"set -u;",
"unset FOAM_SIGFPE;",
"unset PYTHONPATH;",
"env -0",
]
)
completed = subprocess.run(
["bash", "-c", script],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={key: value for key, value in os.environ.items() if key not in {"PYTHONPATH", "FOAM_SIGFPE"}},
)
env: dict[str, str] = {}
for item in completed.stdout.decode().split("\0"):
if not item or "=" not in item:
continue
key, value = item.split("=", 1)
env[key] = value
env.pop("PYTHONPATH", None)
env.pop("FOAM_SIGFPE", None)
env[_ENV_SENTINEL] = str(OPENFOAM_ROOT)
return env
def configure_openfoam_environment() -> dict[str, str]:
"""Apply the repository OpenFOAM runtime environment if available."""
if os.environ.get(_ENV_SENTINEL) == str(OPENFOAM_ROOT):
os.environ.pop("PYTHONPATH", None)
os.environ.pop("FOAM_SIGFPE", None)
return dict(os.environ)
env = _capture_openfoam_env()
if env is None:
return dict(os.environ)
os.environ.update(env)
os.environ.pop("PYTHONPATH", None)
os.environ.pop("FOAM_SIGFPE", None)
return env

View file

@ -0,0 +1,399 @@
"""Typed Python facade objects for OpenFOAM observability snapshots."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Iterator, Mapping
import numpy as np
@dataclass(frozen=True)
class RaggedIntArray:
offsets: np.ndarray
values: np.ndarray
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "RaggedIntArray":
return cls(offsets=data["offsets"], values=data["values"])
def row(self, index: int) -> np.ndarray:
return self.values[self.offsets[index] : self.offsets[index + 1]]
def to_dict(self) -> dict[str, Any]:
return {"offsets": self.offsets, "values": self.values}
@dataclass(frozen=True)
class SourceLocation:
file: str
function: str
lines: tuple[int, int] | None = None
@classmethod
def from_dict(cls, data: Mapping[str, Any] | None) -> "SourceLocation":
if not data:
return cls(file="", function="", lines=None)
lines = data.get("lines")
return cls(
file=str(data.get("file", "")),
function=str(data.get("function", "")),
lines=tuple(lines) if lines is not None else None,
)
def to_dict(self) -> dict[str, Any]:
return {"file": self.file, "function": self.function, "lines": self.lines}
@dataclass(frozen=True)
class PatchView:
name: str
type: str
index: int
start: int
size: int
coupled: bool
constraint: bool
face_cells: np.ndarray
face_indices: np.ndarray
Cf: np.ndarray
Sf: np.ndarray
magSf: np.ndarray
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "PatchView":
return cls(
name=data["name"],
type=data["type"],
index=int(data["index"]),
start=int(data["start"]),
size=int(data["size"]),
coupled=bool(data["coupled"]),
constraint=bool(data["constraint"]),
face_cells=data["face_cells"],
face_indices=data["face_indices"],
Cf=data["Cf"],
Sf=data["Sf"],
magSf=data["magSf"],
raw=data,
)
def to_dict(self) -> Mapping[str, Any]:
return self.raw
@dataclass(frozen=True)
class PatchFieldView:
name: str
type: str
values: np.ndarray
fixes_value: bool
assignable: bool
coupled: bool
updated: bool
patch_internal: np.ndarray | None
value_internal_coeffs: np.ndarray | None
value_boundary_coeffs: np.ndarray | None
gradient_internal_coeffs: np.ndarray | None
gradient_boundary_coeffs: np.ndarray | None
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, name: str, data: Mapping[str, Any]) -> "PatchFieldView":
return cls(
name=name,
type=data["type"],
values=data["values"],
fixes_value=bool(data["fixes_value"]),
assignable=bool(data["assignable"]),
coupled=bool(data["coupled"]),
updated=bool(data["updated"]),
patch_internal=data.get("patch_internal"),
value_internal_coeffs=data.get("value_internal_coeffs"),
value_boundary_coeffs=data.get("value_boundary_coeffs"),
gradient_internal_coeffs=data.get("gradient_internal_coeffs"),
gradient_boundary_coeffs=data.get("gradient_boundary_coeffs"),
raw=data,
)
def to_dict(self) -> Mapping[str, Any]:
return self.raw
@dataclass(frozen=True)
class FieldView:
name: str
kind: str
dimensions: str
entity_kind: str
entity_count: int
internal: np.ndarray
boundary: Mapping[str, PatchFieldView]
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "FieldView":
boundary = {
name: PatchFieldView.from_dict(name, patch)
for name, patch in data.get("boundary", {}).items()
}
return cls(
name=data["name"],
kind=data["kind"],
dimensions=data["dimensions"],
entity_kind=data["entity_kind"],
entity_count=int(data["entity_count"]),
internal=data["internal"],
boundary=boundary,
raw=data,
)
def to_numpy(self, copy: bool = True) -> np.ndarray:
return np.array(self.internal, copy=copy)
def to_dict(self, copy: bool = False) -> Mapping[str, Any]:
if not copy:
return self.raw
out = dict(self.raw)
out["internal"] = np.array(self.internal, copy=True)
return out
@dataclass(frozen=True)
class FieldRegistryView(Mapping[str, FieldView]):
fields: Mapping[str, FieldView]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "FieldRegistryView":
return cls({name: FieldView.from_dict(value) for name, value in data.items()})
def __getitem__(self, key: str) -> FieldView:
return self.fields[key]
def __iter__(self) -> Iterator[str]:
return iter(self.fields)
def __len__(self) -> int:
return len(self.fields)
def __getattr__(self, key: str) -> FieldView:
try:
return self.fields[key]
except KeyError as exc:
raise AttributeError(key) from exc
def to_dict(self) -> dict[str, Mapping[str, Any]]:
return {name: field.to_dict() for name, field in self.fields.items()}
@dataclass(frozen=True)
class MeshView:
n_points: int
n_faces: int
n_internal_faces: int
n_cells: int
points: np.ndarray
faces: RaggedIntArray
cells: RaggedIntArray
owner: np.ndarray
neighbour: np.ndarray
boundary: list[PatchView]
V: np.ndarray
C: np.ndarray
Cf: np.ndarray
Sf: np.ndarray
magSf: np.ndarray
ldu: Mapping[str, Any]
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "MeshView":
return cls(
n_points=int(data["n_points"]),
n_faces=int(data["n_faces"]),
n_internal_faces=int(data["n_internal_faces"]),
n_cells=int(data["n_cells"]),
points=data["points"],
faces=RaggedIntArray.from_dict(data["faces"]),
cells=RaggedIntArray.from_dict(data["cells"]),
owner=data["owner"],
neighbour=data["neighbour"],
boundary=[PatchView.from_dict(patch) for patch in data["boundary"]],
V=data["V"],
C=data["C"],
Cf=data["Cf"],
Sf=data["Sf"],
magSf=data["magSf"],
ldu=data["ldu"],
raw=data,
)
def patch(self, name: str) -> PatchView:
for patch in self.boundary:
if patch.name == name:
return patch
raise KeyError(name)
def to_dict(self) -> Mapping[str, Any]:
return self.raw
@dataclass(frozen=True)
class MatrixView:
name: str
field_name: str
psi: FieldView
value_rank: str
dimensions: str
has_diag: bool
has_upper: bool
has_lower: bool
diagonal: bool
symmetric: bool
asymmetric: bool
diag: np.ndarray
upper: np.ndarray | None
lower: np.ndarray | None
source: np.ndarray
internal_coeffs: list[np.ndarray]
boundary_coeffs: list[np.ndarray]
face_flux_correction: FieldView | None
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "MatrixView":
face_flux = data.get("face_flux_correction")
return cls(
name=data["name"],
field_name=data["field_name"],
psi=FieldView.from_dict(data["psi"]),
value_rank=data["value_rank"],
dimensions=data["dimensions"],
has_diag=bool(data["has_diag"]),
has_upper=bool(data["has_upper"]),
has_lower=bool(data["has_lower"]),
diagonal=bool(data["diagonal"]),
symmetric=bool(data["symmetric"]),
asymmetric=bool(data["asymmetric"]),
diag=data["diag"],
upper=data.get("upper"),
lower=data.get("lower"),
source=data["source"],
internal_coeffs=list(data.get("internal_coeffs", [])),
boundary_coeffs=list(data.get("boundary_coeffs", [])),
face_flux_correction=(
FieldView.from_dict(face_flux)
if isinstance(face_flux, Mapping) and "kind" in face_flux
else None
),
raw=data,
)
@property
def A(self) -> FieldView | None:
value = self.raw.get("A")
return FieldView.from_dict(value) if isinstance(value, Mapping) else None
@property
def H(self) -> FieldView | None:
value = self.raw.get("H")
return FieldView.from_dict(value) if isinstance(value, Mapping) else None
@property
def H1(self) -> FieldView | None:
value = self.raw.get("H1")
return FieldView.from_dict(value) if isinstance(value, Mapping) else None
@property
def flux(self) -> FieldView | None:
value = self.raw.get("flux")
return FieldView.from_dict(value) if isinstance(value, Mapping) else None
def residual(self) -> np.ndarray | None:
return self.raw.get("residual")
def D(self) -> np.ndarray | None:
return self.raw.get("D")
def DD(self) -> np.ndarray | None:
return self.raw.get("DD")
def to_dict(self) -> Mapping[str, Any]:
return self.raw
@dataclass(frozen=True)
class SolveResult:
solver_name: str
field_name: str
initial_residual: Any
final_residual: Any
n_iterations: Any
converged: bool
singular: bool
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "SolveResult":
return cls(
solver_name=data["solver_name"],
field_name=data["field_name"],
initial_residual=data["initial_residual"],
final_residual=data["final_residual"],
n_iterations=data["n_iterations"],
converged=bool(data["converged"]),
singular=bool(data["singular"]),
raw=data,
)
@dataclass(frozen=True)
class TransformResult:
name: str
phase: str
source: SourceLocation
inputs: Mapping[str, Any]
outputs: Mapping[str, Any]
changed_fields: list[str]
metadata: Mapping[str, Any]
raw: Mapping[str, Any]
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> "TransformResult":
return cls(
name=data["name"],
phase=data["phase"],
source=SourceLocation.from_dict(data.get("source")),
inputs=_wrap_mapping(data.get("inputs", {})),
outputs=_wrap_mapping(data.get("outputs", {})),
changed_fields=list(data.get("changed_fields", [])),
metadata=dict(data.get("metadata", {})),
raw=data,
)
def to_dict(self) -> Mapping[str, Any]:
return self.raw
def _wrap_mapping(data: Mapping[str, Any]) -> dict[str, Any]:
return {key: _wrap_value(value) for key, value in data.items()}
def _wrap_value(value: Any) -> Any:
if isinstance(value, Mapping):
if {"n_cells", "owner", "neighbour"}.issubset(value):
return MeshView.from_dict(value)
if "kind" in value and "internal" in value:
return FieldView.from_dict(value)
if "value_rank" in value and "diag" in value:
return MatrixView.from_dict(value)
if {"solver_name", "field_name", "initial_residual"}.issubset(value):
return SolveResult.from_dict(value)
if {"name", "phase", "outputs"}.issubset(value):
return TransformResult.from_dict(value)
return _wrap_mapping(value)
if isinstance(value, list):
return [_wrap_value(item) for item in value]
return value

View file

@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
# Build the OpenFOAM Foundation v14 serial subset needed for AirfRANS-style data generation.
# Tools built: foamRun/incompressibleFluid (simpleFoam replacement), simpleFoam wrapper,
# gmshToFoam, checkMesh, and foamToVTK.
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
OPENFOAM_REPO_URL="${OPENFOAM_REPO_URL:-https://github.com/OpenFOAM/OpenFOAM-14.git}"
THIRDPARTY_REPO_URL="${THIRDPARTY_REPO_URL:-https://github.com/OpenFOAM/ThirdParty-14.git}"
AIRFRANS_REPO_URL="${AIRFRANS_REPO_URL:-https://github.com/Extrality/AirfRANS.git}"
JOBS="${JOBS:-$(nproc)}"
clone_if_missing() {
local url="$1"
local dir="$2"
if [[ -d "${ROOT_DIR}/${dir}/.git" ]]; then
printf 'using existing %s\n' "${dir}"
else
git clone --depth 1 "${url}" "${ROOT_DIR}/${dir}"
fi
}
clone_if_missing "${OPENFOAM_REPO_URL}" OpenFOAM-14
clone_if_missing "${THIRDPARTY_REPO_URL}" ThirdParty-14
clone_if_missing "${AIRFRANS_REPO_URL}" airfrans
# Dummy MPI keeps this subset serial and avoids requiring mpicc/OpenMPI for p1.
# Use SYSTEMOPENMPI instead only after installing a system MPI development package.
# OpenFOAM's bashrc probes optional shell variables that may be unset.
# shellcheck disable=SC1091
set +u
source "${ROOT_DIR}/OpenFOAM-14/etc/bashrc" \
WM_MPLIB=Dummy \
ParaView_TYPE=none \
SCOTCH_TYPE=none \
ZOLTAN_TYPE=none
set -u
cd "${ROOT_DIR}/OpenFOAM-14"
(cd wmake/src && make)
src/Pstream/Allwmake -j "${JOBS}"
src/OSspecific/${WM_OSTYPE:-POSIX}/Allwmake -j "${JOBS}"
wmake -j "${JOBS}" libso src/OpenFOAM
for target in \
src/fileFormats \
src/surfMesh \
src/triSurface \
src/meshTools \
src/finiteVolume \
src/ODE \
src/physicalProperties \
src/tracking \
src/lagrangian/basic \
src/generic/genericPatches \
src/generic/genericFields \
src/generic/genericFvPatches \
src/generic/genericFvFields \
src/sampling \
src/meshCheck \
src/mesh/extrudeModel \
src/polyTopoChange \
src/conversion \
src/thermophysicalModels/specie \
src/thermophysicalModels/thermophysicalProperties \
src/thermophysicalModels/basic \
src/thermophysicalModels/multicomponentThermo \
src/thermophysicalModels/solidThermo \
src/MomentumTransportModels/momentumTransportModels \
src/MomentumTransportModels/incompressible \
src/MomentumTransportModels/compressible \
src/ThermophysicalTransportModels/thermophysicalTransportModel \
src/ThermophysicalTransportModels/fluid \
src/ThermophysicalTransportModels/fluidThermo \
src/fvConstraints \
src/fvModels/general \
applications/modules/basicFluidSolver \
applications/modules/incompressibleFluid
do
wmake -j "${JOBS}" libso "${target}"
done
for app in \
applications/solvers/foamRun \
applications/utilities/mesh/conversion/gmshToFoam \
applications/utilities/mesh/manipulation/checkMesh
do
wmake -j "${JOBS}" "${app}"
done
applications/utilities/postProcessing/dataConversion/foamToVTK/Allwmake -j "${JOBS}"
foamRun -help >/dev/null
foamRun -solver incompressibleFluid -help >/dev/null
gmshToFoam -help >/dev/null
checkMesh -help >/dev/null
foamToVTK -help >/dev/null
simpleFoam -help >/dev/null
printf 'OpenFOAM v14 AirfRANS subset built and smoke-checked.\n'

46
scripts/build_python_stepper.sh Executable file
View file

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
JOBS="${JOBS:-$(nproc)}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
echo "Python interpreter not found: ${PYTHON_BIN}" >&2
echo "Run: uv sync --dev" >&2
exit 1
fi
# shellcheck disable=SC1091
set +u
source "${ROOT_DIR}/OpenFOAM-14/etc/bashrc" \
WM_MPLIB=Dummy \
ParaView_TYPE=none \
SCOTCH_TYPE=none \
ZOLTAN_TYPE=none
set -u
export FOAM_USER_LIBBIN="${ROOT_DIR}/python/src/foam_stepper"
export FOAM_USER_APPBIN="${ROOT_DIR}/pythonStepper/bin"
mkdir -p "${FOAM_USER_LIBBIN}" "${FOAM_USER_APPBIN}"
export PYBIND11_INCLUDES="$("${PYTHON_BIN}" -m pybind11 --includes)"
PYTHON_LIBDIR="$("${PYTHON_BIN}" -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR") or "")')"
PYTHON_LINK_LIBS="$("${PYTHON_BIN}" -c 'import sysconfig; print(" ".join(v for v in [sysconfig.get_config_var("LIBDIR") and "-L" + sysconfig.get_config_var("LIBDIR"), sysconfig.get_config_var("LDLIBRARY") and "-l" + sysconfig.get_config_var("LDLIBRARY").removeprefix("lib").removesuffix(".so"), sysconfig.get_config_var("LIBS"), sysconfig.get_config_var("SYSLIBS")] if v))')"
RPATH_DIRS=("${FOAM_LIBBIN}" "${FOAM_LIBBIN}/${FOAM_MPI}" "${FOAM_EXT_LIBBIN}")
if [[ -n "${PYTHON_LIBDIR}" ]]; then
RPATH_DIRS+=("${PYTHON_LIBDIR}")
fi
RPATH_LIBS="-Wl,--disable-new-dtags"
for lib_dir in "${RPATH_DIRS[@]}"; do
if [[ -d "${lib_dir}" ]]; then
RPATH_LIBS="${RPATH_LIBS} -Wl,-rpath,${lib_dir}"
fi
done
export PYTHON_LIBS="${PYTHON_LINK_LIBS} ${RPATH_LIBS}"
rm -f "${FOAM_USER_LIBBIN}/_foam_stepper.so"
wmake -j "${JOBS}" libso "${ROOT_DIR}/pythonStepper/pyfoam_stepper"
printf 'Built %s/_foam_stepper.so\n' "${FOAM_USER_LIBBIN}"

475
scripts/fuzz_python_stepper.py Executable file
View file

@ -0,0 +1,475 @@
#!/usr/bin/env python3
"""Seeded equivalence fuzzer for the Python-driven OpenFOAM stepper."""
from __future__ import annotations
import argparse
import importlib.util
import json
import os
import random
import shutil
import subprocess
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
def _drop_ambient_pythonpath() -> None:
pythonpath = os.environ.pop("PYTHONPATH", "")
for entry in pythonpath.split(os.pathsep):
if not entry:
continue
while entry in sys.path:
sys.path.remove(entry)
_drop_ambient_pythonpath()
import numpy as np
def _load_openfoam_env_helpers():
try:
from openfoam_env import apply_openfoam_env, openfoam_env
except ModuleNotFoundError:
spec = importlib.util.spec_from_file_location("openfoam_env", Path(__file__).with_name("openfoam_env.py"))
if spec is None or spec.loader is None:
raise
module = importlib.util.module_from_spec(spec)
sys.modules["openfoam_env"] = module
spec.loader.exec_module(module)
return module.apply_openfoam_env, module.openfoam_env
return apply_openfoam_env, openfoam_env
apply_openfoam_env, openfoam_env = _load_openfoam_env_helpers()
ROOT = Path(__file__).resolve().parents[1]
TUTORIAL = ROOT / "OpenFOAM-14/tutorials/incompressibleFluid/venturiTube"
DEFAULT_WORK = ROOT / "tmp/python_stepper_fuzz"
DEFAULT_SEEDS = 10
DEFAULT_ATOL = 1e-10
DEFAULT_RTOL = 1e-10
FIELDS = ("U", "p", "phi")
@dataclass(frozen=True)
class CaseVariant:
seed: int
diameter: float
dia_cells: int
ven_cells: int
in_cells: int
out_cells: int
box_cells: int
rad_cells: int
out_grading: float
u_inlet: float
nu: float
p_relax: float
u_relax: float
p_tolerance: float
u_tolerance: float
n_non_orthogonal_correctors: int
def scalar(value: float) -> str:
return f"{value:.17g}"
def variant_for_seed(seed: int) -> CaseVariant:
rng = random.Random(seed)
return CaseVariant(
seed=seed,
diameter=rng.choice([0.05, 0.075, 0.1, 0.15, 0.2]),
dia_cells=rng.choice([4, 6, 8]),
ven_cells=rng.choice([2, 4]),
in_cells=rng.choice([6, 8, 10]),
out_cells=rng.choice([8, 10, 12]),
box_cells=rng.choice([2, 3, 4]),
rad_cells=rng.choice([4, 6, 8]),
out_grading=rng.choice([0.5, 0.75, 1.0, 1.25]),
u_inlet=rng.choice([0.05, 0.1, 0.2, 0.4]),
nu=rng.choice([2e-5, 4e-5, 8e-5, 1.6e-4]),
p_relax=rng.choice([0.2, 0.3, 0.5, 0.7]),
u_relax=rng.choice([0.5, 0.7, 0.9]),
p_tolerance=rng.choice([1e-6, 1e-7]),
u_tolerance=rng.choice([1e-7, 1e-8]),
n_non_orthogonal_correctors=rng.choice([0, 1]),
)
def replace_required(path: Path, text: str, old: str, new: str) -> str:
count = text.count(old)
if count == 0:
raise AssertionError(f"{path}: missing literal {old!r}")
if count != 1:
raise AssertionError(f"{path}: expected one literal {old!r}, found {count}")
return text.replace(old, new, 1)
def patch_file(path: Path, replacements: list[tuple[str, str]]) -> None:
text = path.read_text()
for old, new in replacements:
text = replace_required(path, text, old, new)
path.write_text(text)
def patch_block_mesh(case: Path, variant: CaseVariant) -> None:
patch_file(
case / "system/blockMeshDict",
[
("diameter 0.1;", f"diameter {scalar(variant.diameter)};"),
("diaCells 16;", f"diaCells {variant.dia_cells};"),
("venCells 8;", f"venCells {variant.ven_cells};"),
("inCells 20;", f"inCells {variant.in_cells};"),
("outCells 40;", f"outCells {variant.out_cells};"),
("boxCells 8;", f"boxCells {variant.box_cells};"),
("radCells 16;", f"radCells {variant.rad_cells};"),
("outGrading 0.5;", f"outGrading {scalar(variant.out_grading)};"),
],
)
def patch_initial_fields(case: Path, variant: CaseVariant) -> None:
patch_file(case / "0/U", [("Uinlet 0.2;", f"Uinlet {scalar(variant.u_inlet)};")])
def patch_physical_properties(case: Path, variant: CaseVariant) -> None:
patch_file(case / "constant/physicalProperties", [("nu 4e-05;", f"nu {scalar(variant.nu)};")])
def patch_fv_solution(case: Path, variant: CaseVariant) -> None:
patch_file(
case / "system/fvSolution",
[
(" tolerance 1e-6;", f" tolerance {scalar(variant.p_tolerance)};"),
(" tolerance 1e-7;", f" tolerance {scalar(variant.u_tolerance)};"),
(
" nNonOrthogonalCorrectors 0;",
f" nNonOrthogonalCorrectors {variant.n_non_orthogonal_correctors};",
),
(" p 0.3;", f" p {scalar(variant.p_relax)};"),
(" U 0.7;", f" U {scalar(variant.u_relax)};"),
],
)
def patch_control_dict_for_seed(case: Path) -> None:
patch_file(
case / "system/controlDict",
[
("startFrom latestTime;", "startFrom startTime;"),
("endTime 1000;", "endTime 1;"),
("writeInterval 50;", "writeInterval 1;"),
("writePrecision 8;", "writePrecision 17;"),
],
)
def patch_control_dict_to_latest(case: Path) -> None:
patch_file(case / "system/controlDict", [("startFrom startTime;", "startFrom latestTime;")])
def run(cmd: list[str], *, log_path: Path | None = None) -> None:
env = openfoam_env()
if log_path is None:
subprocess.run(cmd, cwd=ROOT, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, env=env)
return
with log_path.open("w") as log:
subprocess.run(cmd, cwd=ROOT, check=True, stdout=log, stderr=subprocess.STDOUT, env=env)
def prepare_case(dst: Path, variant: CaseVariant) -> None:
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(TUTORIAL, dst, ignore=shutil.ignore_patterns("processor*", "postProcessing", "*.log"))
for orig in (dst / "0").glob("*.orig"):
shutil.copyfile(orig, orig.with_suffix(""))
patch_block_mesh(dst, variant)
patch_initial_fields(dst, variant)
patch_physical_properties(dst, variant)
patch_fv_solution(dst, variant)
patch_control_dict_for_seed(dst)
run(["blockMesh", "-case", str(dst)])
run(["createZones", "-case", str(dst)])
def prepare_seed(seed_dir: Path, variant: CaseVariant) -> dict[str, Path]:
if seed_dir.exists():
shutil.rmtree(seed_dir)
outputs = seed_dir / "outputs"
outputs.mkdir(parents=True)
(seed_dir / "variant.json").write_text(json.dumps(asdict(variant), indent=2, sort_keys=True) + "\n")
cases = {
"foam": seed_dir / "foam_case",
"run_one": seed_dir / "python_run_one_case",
"run_split": seed_dir / "python_split_case",
}
for case in cases.values():
prepare_case(case, variant)
return cases
def save_npz(out: Path, *, U: np.ndarray, p: np.ndarray, phi: np.ndarray) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
np.savez_compressed(out, U=np.asarray(U), p=np.asarray(p), phi=np.asarray(phi))
def child_load_fields(case: Path, out: Path) -> None:
import foam_stepper as foam
fields = foam.Case(case).make_stepper().fields()
save_npz(out, U=fields.U.internal, p=fields.p.internal, phi=fields.phi.internal)
def child_run_one(case: Path, out: Path) -> None:
import foam_stepper as foam
result = foam.Case(case).make_stepper().run_one_pimple_iteration()
fields = result.outputs["fields"]
save_npz(out, U=fields["U"].internal, p=fields["p"].internal, phi=fields["phi"].internal)
def child_run_split(case: Path, out: Path) -> None:
import foam_stepper as foam
stepper = foam.Case(case).make_stepper()
stepper.pre_solve()
stepper.advance_time()
begin = stepper.begin_pimple_iteration()
assert begin.outputs["active"] is True
stepper.fv_models_correct()
stepper.pre_predictor()
stepper.momentum_transport_predictor()
stepper.assemble_momentum_matrix()
stepper.relax_matrix()
stepper.constrain_matrix()
stepper.solve_momentum()
stepper.compute_pressure_inputs()
stepper.assemble_pressure_matrix()
stepper.solve_pressure()
stepper.correct_velocity_pressure_flux()
stepper.momentum_transport_corrector()
stepper.end_pimple_iteration()
stepper.post_solve(write=False)
fields = stepper.fields()
save_npz(out, U=fields.U.internal, p=fields.p.internal, phi=fields.phi.internal)
def load_npz(path: Path) -> dict[str, np.ndarray]:
with np.load(path) as data:
return {field: data[field] for field in FIELDS}
def failure_message(
*,
seed: int,
variant: CaseVariant,
seed_dir: Path,
path_label: str,
field: str,
baseline_path: Path,
actual_path: Path,
expected_shape: tuple[int, ...],
actual_shape: tuple[int, ...],
max_abs: float,
max_rel: float,
) -> str:
return (
f"fuzz equivalence failed seed={seed} path={path_label} field={field}\n"
f"variant={json.dumps(asdict(variant), sort_keys=True)}\n"
f"variant_json={seed_dir / 'variant.json'}\n"
f"foamRun_log={seed_dir / 'foamRun.log'}\n"
f"baseline_output={baseline_path}\n"
f"actual_output={actual_path}\n"
f"shape_pair=actual{actual_shape} expected{expected_shape}\n"
f"max_abs={max_abs:.17g}\n"
f"max_rel={max_rel:.17g}"
)
def compare_field(
*,
seed: int,
variant: CaseVariant,
seed_dir: Path,
path_label: str,
field: str,
expected: np.ndarray,
actual: np.ndarray,
baseline_path: Path,
actual_path: Path,
atol: float,
rtol: float,
) -> float:
if actual.shape != expected.shape:
raise AssertionError(
failure_message(
seed=seed,
variant=variant,
seed_dir=seed_dir,
path_label=path_label,
field=field,
baseline_path=baseline_path,
actual_path=actual_path,
expected_shape=expected.shape,
actual_shape=actual.shape,
max_abs=float("nan"),
max_rel=float("nan"),
)
)
abs_diff = np.abs(actual - expected)
max_abs = float(np.max(abs_diff)) if abs_diff.size else 0.0
max_rel = float(np.max(abs_diff / np.maximum(np.abs(expected), atol))) if abs_diff.size else 0.0
if not np.allclose(actual, expected, rtol=rtol, atol=atol):
raise AssertionError(
failure_message(
seed=seed,
variant=variant,
seed_dir=seed_dir,
path_label=path_label,
field=field,
baseline_path=baseline_path,
actual_path=actual_path,
expected_shape=expected.shape,
actual_shape=actual.shape,
max_abs=max_abs,
max_rel=max_rel,
)
)
return max_abs
def compare_outputs(
*,
seed: int,
variant: CaseVariant,
seed_dir: Path,
baseline_path: Path,
actual_paths: dict[str, Path],
atol: float,
rtol: float,
) -> dict[str, dict[str, float]]:
baseline = load_npz(baseline_path)
summary: dict[str, dict[str, float]] = {}
for path_label, actual_path in actual_paths.items():
actual = load_npz(actual_path)
summary[path_label] = {}
for field in FIELDS:
summary[path_label][field] = compare_field(
seed=seed,
variant=variant,
seed_dir=seed_dir,
path_label=path_label,
field=field,
expected=baseline[field],
actual=actual[field],
baseline_path=baseline_path,
actual_path=actual_path,
atol=atol,
rtol=rtol,
)
return summary
def run_child(child: str, case: Path, out: Path) -> None:
subprocess.run(
[sys.executable, str(Path(__file__).resolve()), "--child", child, "--case", str(case), "--out", str(out)],
cwd=ROOT,
check=True,
env=openfoam_env(),
)
def run_seed(seed: int, variant: CaseVariant, work_dir: Path, *, atol: float, rtol: float) -> Path:
seed_dir = work_dir / f"seed_{seed}"
cases = prepare_seed(seed_dir, variant)
outputs = seed_dir / "outputs"
baseline_path = outputs / "baseline.npz"
run_one_path = outputs / "run_one.npz"
run_split_path = outputs / "run_split.npz"
run(
["foamRun", "-case", str(cases["foam"]), "-solver", "incompressibleFluid", "-noFunctionObjects"],
log_path=seed_dir / "foamRun.log",
)
patch_control_dict_to_latest(cases["foam"])
run_child("load-fields", cases["foam"], baseline_path)
run_child("run-one", cases["run_one"], run_one_path)
run_child("run-split", cases["run_split"], run_split_path)
summary = compare_outputs(
seed=seed,
variant=variant,
seed_dir=seed_dir,
baseline_path=baseline_path,
actual_paths={"run_one": run_one_path, "run_split": run_split_path},
atol=atol,
rtol=rtol,
)
cells = int(load_npz(baseline_path)["U"].shape[0])
print(
f"seed={seed} cells={cells} "
f"run_one: U={summary['run_one']['U']:.3e} p={summary['run_one']['p']:.3e} phi={summary['run_one']['phi']:.3e} "
f"split: U={summary['run_split']['U']:.3e} p={summary['run_split']['p']:.3e} phi={summary['run_split']['phi']:.3e}",
flush=True,
)
return seed_dir
def run_parent(args: argparse.Namespace) -> None:
if args.seeds < 1:
raise SystemExit("--seeds must be at least 1")
args.work_dir.mkdir(parents=True, exist_ok=True)
for seed in range(args.seed_start, args.seed_start + args.seeds):
variant = variant_for_seed(seed)
seed_dir = run_seed(seed, variant, args.work_dir, atol=args.atol, rtol=args.rtol)
if not args.keep_passing:
shutil.rmtree(seed_dir)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--seeds", type=int, default=DEFAULT_SEEDS)
parser.add_argument("--seed-start", type=int, default=0)
parser.add_argument("--work-dir", type=Path, default=DEFAULT_WORK)
parser.add_argument("--atol", type=float, default=DEFAULT_ATOL)
parser.add_argument("--rtol", type=float, default=DEFAULT_RTOL)
parser.add_argument("--keep-passing", action="store_true")
parser.add_argument("--child", choices=("load-fields", "run-one", "run-split"))
parser.add_argument("--case", type=Path)
parser.add_argument("--out", type=Path)
args = parser.parse_args()
if args.child is not None and (args.case is None or args.out is None):
parser.error("--child requires --case and --out")
if args.child is None and (args.case is not None or args.out is not None):
parser.error("--case and --out are only valid with --child")
return args
def main() -> None:
args = parse_args()
if args.child == "load-fields":
apply_openfoam_env()
child_load_fields(args.case, args.out)
elif args.child == "run-one":
apply_openfoam_env()
child_run_one(args.case, args.out)
elif args.child == "run-split":
apply_openfoam_env()
child_run_split(args.case, args.out)
else:
run_parent(args)
if __name__ == "__main__":
main()

36
scripts/fuzz_python_stepper.sh Executable file
View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
JOBS="${JOBS:-$(nproc)}"
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
uv sync --dev
fi
if ! PYTHONPATH= "${PYTHON_BIN}" -c 'import pybind11, numpy' >/dev/null 2>&1; then
uv sync --dev
fi
"${ROOT_DIR}/scripts/build_openfoam_airfrans_subset.sh" >/dev/null
set +u
source "${ROOT_DIR}/OpenFOAM-14/etc/bashrc" \
WM_MPLIB=Dummy \
ParaView_TYPE=none \
SCOTCH_TYPE=none \
ZOLTAN_TYPE=none
set -u
unset FOAM_SIGFPE
(
cd "${ROOT_DIR}/OpenFOAM-14"
wmake -j "${JOBS}" libso src/mesh/blockMesh >/dev/null
wmake -j "${JOBS}" applications/utilities/mesh/generation/blockMesh >/dev/null
wmake -j "${JOBS}" applications/utilities/mesh/manipulation/createZones >/dev/null
)
"${ROOT_DIR}/scripts/build_python_stepper.sh" >/dev/null
"${PYTHON_BIN}" "${ROOT_DIR}/scripts/fuzz_python_stepper.py" "$@"

73
scripts/openfoam_env.py Normal file
View file

@ -0,0 +1,73 @@
"""Internal OpenFOAM environment resolution for Python scripts.
This keeps user-facing commands rooted in the project .venv while still giving
OpenFOAM subprocesses the environment normally produced by etc/bashrc.
"""
from __future__ import annotations
import os
import shlex
import subprocess
from functools import lru_cache
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
@lru_cache(maxsize=1)
def openfoam_env() -> dict[str, str]:
"""Return a sanitized environment with OpenFOAM v14 sourced.
The returned environment intentionally removes ambient PYTHONPATH. The
project package is installed into .venv by `uv sync --dev`, so PYTHONPATH is
unnecessary and can accidentally shadow the venv's binary wheels.
"""
bashrc = ROOT / "OpenFOAM-14/etc/bashrc"
if not bashrc.exists():
raise RuntimeError(f"OpenFOAM bashrc not found: {bashrc}")
command = " ".join(
[
"set +u;",
"source",
shlex.quote(str(bashrc)),
"WM_MPLIB=Dummy",
"ParaView_TYPE=none",
"SCOTCH_TYPE=none",
"ZOLTAN_TYPE=none",
">/dev/null;",
"set -u;",
"unset FOAM_SIGFPE;",
"unset PYTHONPATH;",
"env -0",
]
)
completed = subprocess.run(
["bash", "-lc", command],
cwd=ROOT,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={key: value for key, value in os.environ.items() if key not in {"PYTHONPATH", "FOAM_SIGFPE"}},
)
env: dict[str, str] = {}
for item in completed.stdout.decode().split("\0"):
if not item:
continue
key, _, value = item.partition("=")
env[key] = value
env.pop("PYTHONPATH", None)
env.pop("FOAM_SIGFPE", None)
return env
def apply_openfoam_env() -> dict[str, str]:
"""Apply and return the sanitized OpenFOAM environment to this process."""
env = openfoam_env()
os.environ.update(env)
os.environ.pop("PYTHONPATH", None)
os.environ.pop("FOAM_SIGFPE", None)
return env

View file

@ -0,0 +1,274 @@
#!/usr/bin/env python3
"""Prepare a v14-compatible AirfRANS raw OpenFOAM case for the Python stepper.
The source raw data lives in the sibling ../airfrans repo and must not be
modified. This helper copies only the files needed for one local v14
OpenFOAM/stepper iteration: mesh, initial fields, fvSchemes, and fvSolution.
"""
from __future__ import annotations
import argparse
import json
import math
import re
import shutil
from dataclasses import asdict, dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
RAW_ROOT = ROOT.parent / "airfrans/data/raw/OF_dataset"
DEFAULT_SIMULATION = "airFoil2D_SST_93.213_3.79_0.418_0.0_9.665"
DEFAULT_SOURCE = RAW_ROOT / DEFAULT_SIMULATION
DEFAULT_DEST = ROOT / "tmp/airfrans_stepper_case" / f"{DEFAULT_SIMULATION}_v14"
FLOAT_RE = re.compile(r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?")
@dataclass(frozen=True)
class AirfransCaseMetadata:
simulation: str
source: str
u_inf: float
velocity: tuple[float, float, float]
nu: float
rho_inf: float
reynolds: float
mach: float
alpha_deg: float
drag_dir: tuple[float, float, float]
lift_dir: tuple[float, float, float]
source_end_time: int
migrated_end_time: int
def read_text(path: Path) -> str:
return path.read_text(errors="replace")
def assignment(text: str, key: str) -> str:
match = re.search(rf"^\s*{re.escape(key)}\s+([^;]+);", text, flags=re.MULTILINE)
if not match:
raise ValueError(f"missing assignment {key!r}")
return match.group(1).strip()
def vector_assignment(text: str, key: str) -> tuple[float, float, float]:
values = [float(value) for value in FLOAT_RE.findall(assignment(text, key))]
if len(values) != 3:
raise ValueError(f"expected 3-vector for {key!r}, got {values!r}")
return (values[0], values[1], values[2])
def copy_required_file(src_root: Path, dst_root: Path, relative: str) -> None:
src = src_root / relative
dst = dst_root / relative
if not src.exists():
raise FileNotFoundError(src)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
def copy_required_tree(src_root: Path, dst_root: Path, relative: str) -> None:
src = src_root / relative
dst = dst_root / relative
if not src.exists():
raise FileNotFoundError(src)
if dst.exists():
shutil.rmtree(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst)
def header(class_name: str, location: str, object_name: str) -> str:
location_line = f' location "{location}";\n' if location else ""
return f"""/*--------------------------------*- C++ -*----------------------------------*\\
========= |
\\ / F ield | OpenFOAM: The Open Source CFD Toolbox
\\ / O peration | Website: https://openfoam.org
\\ / A nd | Version: 14
\\/ M anipulation |
\\*---------------------------------------------------------------------------*/
FoamFile
{{
format ascii;
class {class_name};
{location_line} object {object_name};
}}
// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //
"""
def write_control_dict(dst: Path, *, u_inf: float, end_time: int) -> None:
content = header("dictionary", "system", "controlDict") + f"""Uinf {u_inf:.17g};
solver incompressibleFluid;
startFrom startTime;
startTime 0;
stopAt endTime;
endTime {end_time};
deltaT 1;
writeControl timeStep;
writeInterval {end_time};
purgeWrite 0;
writeFormat ascii;
writePrecision 17;
writeCompression off;
timeFormat general;
timePrecision 6;
runTimeModifiable true;
// Function objects are intentionally disabled for first-step parity; run
// foamRun/foam_stepper with -noFunctionObjects and parse archived raw force
// outputs separately in the notebook.
// ************************************************************************* //
"""
(dst / "system/controlDict").write_text(content)
def write_physical_properties(dst: Path, *, nu: float, rho_inf: float) -> None:
content = header("dictionary", "constant", "physicalProperties") + f"""viscosityModel constant;
rho {rho_inf:.17g};
nu {nu:.17g};
// ************************************************************************* //
"""
(dst / "constant/physicalProperties").write_text(content)
def write_momentum_transport(dst: Path) -> None:
content = header("dictionary", "constant", "momentumTransport") + """simulationType RAS;
RAS
{
model kOmegaSST;
turbulence on;
}
// ************************************************************************* //
"""
(dst / "constant/momentumTransport").write_text(content)
def metadata_from_source(src: Path, migrated_end_time: int) -> AirfransCaseMetadata:
control = read_text(src / "system/controlDict")
transport = read_text(src / "constant/transportProperties")
u_field = read_text(src / "0/U")
u_inf = float(assignment(control, "Uinf"))
velocity = vector_assignment(u_field, "field")
nu = float(assignment(transport, "nu"))
rho_inf = float(assignment(control, "rhoInf"))
drag_dir = vector_assignment(control, "dragDir")
lift_dir = vector_assignment(control, "liftDir")
source_end_time = int(float(assignment(control, "endTime")))
alpha_deg = math.degrees(math.atan2(drag_dir[1], drag_dir[0]))
return AirfransCaseMetadata(
simulation=src.name,
source=str(src),
u_inf=u_inf,
velocity=velocity,
nu=nu,
rho_inf=rho_inf,
reynolds=u_inf / nu,
mach=u_inf / 346.1,
alpha_deg=alpha_deg,
drag_dir=drag_dir,
lift_dir=lift_dir,
source_end_time=source_end_time,
migrated_end_time=migrated_end_time,
)
def prepare_case(src: Path, dst: Path, *, end_time: int = 1) -> AirfransCaseMetadata:
src = src.resolve()
dst = dst.resolve()
if not src.exists():
raise FileNotFoundError(src)
required = [
"system/fvSchemes",
"system/fvSolution",
"system/blockMeshDict",
"0/U",
"0/p",
"0/nut",
"0/k",
"0/omega",
"constant/transportProperties",
"constant/turbulenceProperties",
"constant/polyMesh/boundary",
"constant/polyMesh/points.gz",
"constant/polyMesh/faces.gz",
"constant/polyMesh/owner.gz",
"constant/polyMesh/neighbour.gz",
]
missing = [relative for relative in required if not (src / relative).exists()]
if missing:
raise FileNotFoundError(f"missing required AirfRANS files: {missing}")
if dst.exists():
shutil.rmtree(dst)
dst.mkdir(parents=True)
copy_required_tree(src, dst, "constant/polyMesh")
for relative in [
"system/fvSchemes",
"system/fvSolution",
"system/blockMeshDict",
"0/U",
"0/p",
"0/nut",
"0/k",
"0/omega",
"constant/transportProperties",
"constant/turbulenceProperties",
]:
copy_required_file(src, dst, relative)
meta = metadata_from_source(src, end_time)
write_control_dict(dst, u_inf=meta.u_inf, end_time=end_time)
write_physical_properties(dst, nu=meta.nu, rho_inf=meta.rho_inf)
write_momentum_transport(dst)
# Keep the original dictionaries for audit without letting v14 select them.
shutil.move(dst / "constant/transportProperties", dst / "constant/transportProperties.v2112")
shutil.move(dst / "constant/turbulenceProperties", dst / "constant/turbulenceProperties.v2112")
(dst / "airfrans_case_metadata.json").write_text(json.dumps(asdict(meta), indent=2, sort_keys=True) + "\n")
return meta
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--dest", type=Path, default=DEFAULT_DEST)
parser.add_argument("--end-time", type=int, default=1)
args = parser.parse_args()
meta = prepare_case(args.source, args.dest, end_time=args.end_time)
print(json.dumps(asdict(meta), indent=2, sort_keys=True))
print(f"prepared={args.dest}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Minimal Quadrants CUDA kernel smoke run."""
from __future__ import annotations
import argparse
import numpy as np
import quadrants as qd
@qd.kernel
def axpy(n: int, x: qd.types.NDArray[qd.f32, 1], y: qd.types.NDArray[qd.f32, 1]) -> None:
for i in range(n):
y[i] = 2.0 * x[i] + 1.0
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--arch", default="cuda", choices=("cuda", "gpu", "cpu", "x64"))
args = parser.parse_args()
qd.init(arch=getattr(qd, args.arch))
x_np = np.arange(8, dtype=np.float32)
expected = 2.0 * x_np + 1.0
x = qd.ndarray(qd.f32, shape=x_np.shape)
y = qd.ndarray(qd.f32, shape=x_np.shape)
x.from_numpy(x_np)
axpy(x_np.size, x, y)
qd.sync()
actual = y.to_numpy()
if not np.array_equal(actual, expected):
raise SystemExit(f"Quadrants hello kernel mismatch: {actual} != {expected}")
print(f"quadrants hello world ok on arch={args.arch}: {actual}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,988 @@
#!/usr/bin/env python3
"""Verifier harness for the migrated AirfRANS OpenFOAM stepper case."""
from __future__ import annotations
import argparse
import dataclasses
import hashlib
import json
import math
import os
import shutil
import subprocess
import sys
import time
import traceback
from collections.abc import Mapping
from pathlib import Path
from typing import Any
def _drop_ambient_pythonpath() -> None:
pythonpath = os.environ.pop("PYTHONPATH", "")
for entry in pythonpath.split(os.pathsep):
if not entry:
continue
while entry in sys.path:
sys.path.remove(entry)
_drop_ambient_pythonpath()
import numpy as np
from openfoam_env import apply_openfoam_env, openfoam_env
from prepare_airfrans_stepper_case import DEFAULT_SOURCE, prepare_case
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_WORK = ROOT / "tmp/airfrans_stepper_verify"
PRIMARY_FIELDS = ("U", "p", "phi")
TURBULENCE_FIELDS = ("nut", "k", "omega")
REQUIRED_FIELDS = PRIMARY_FIELDS + TURBULENCE_FIELDS
LOADING_FAILURE = "loading_or_parsing_failure"
ORACLE_FAILURE = "openfoam_oracle_failure"
STEPPER_FAILURE = "stepper_execution_failure"
COMPARISON_FAILURE = "numerical_comparison_failure"
INTERNAL_FAILURE = "internal_harness_failure"
EXIT_CODES = {
LOADING_FAILURE: 2,
ORACLE_FAILURE: 3,
STEPPER_FAILURE: 4,
COMPARISON_FAILURE: 5,
INTERNAL_FAILURE: 6,
}
class HarnessError(Exception):
"""Categorized verifier failure that can be serialized into the report."""
def __init__(
self,
category: str,
step: str,
message: str,
*,
details: Mapping[str, Any] | None = None,
cause: BaseException | None = None,
) -> None:
super().__init__(message)
self.category = category
self.step = step
self.details = dict(details or {})
self.cause = cause
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
"category": self.category,
"step": self.step,
"message": str(self),
"details": self.details,
}
if self.cause is not None:
out["cause"] = {
"type": type(self.cause).__name__,
"message": str(self.cause),
}
return json_ready(out)
def json_ready(value: Any) -> Any:
"""Convert report values into strict JSON-compatible data."""
if dataclasses.is_dataclass(value) and not isinstance(value, type):
return json_ready(dataclasses.asdict(value))
if isinstance(value, Path):
return str(value)
if isinstance(value, np.ndarray):
return array_stats(value)
if isinstance(value, np.generic):
return json_ready(value.item())
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, (str, int, bool)) or value is None:
return value
if isinstance(value, Mapping):
return {str(key): json_ready(item) for key, item in value.items()}
if isinstance(value, (list, tuple, set)):
return [json_ready(item) for item in value]
return repr(value)
def array_shape(array: Any | None) -> list[int] | None:
if array is None:
return None
return [int(dim) for dim in np.asarray(array).shape]
def finite_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def array_stats(array: Any) -> dict[str, Any]:
arr = np.asarray(array)
out: dict[str, Any] = {
"shape": array_shape(arr),
"dtype": str(arr.dtype),
"size": int(arr.size),
}
if arr.size == 0 or not np.issubdtype(arr.dtype, np.number):
return out
finite = np.isfinite(arr)
out["finite_count"] = int(np.count_nonzero(finite))
out["nonfinite_count"] = int(arr.size - out["finite_count"])
if out["finite_count"]:
finite_values = arr[finite]
out.update(
{
"min": finite_float(np.min(finite_values)),
"max": finite_float(np.max(finite_values)),
"mean": finite_float(np.mean(finite_values)),
}
)
return out
def value_at(array: np.ndarray, index: tuple[int, ...]) -> Any:
value = array[index]
if isinstance(value, np.generic):
return json_ready(value.item())
if isinstance(value, np.ndarray):
return json_ready(value.tolist())
return json_ready(value)
def entity_value_at(array: np.ndarray, entity_index: int | None) -> Any:
if entity_index is None or array.ndim == 0:
return None
value = array[entity_index]
if isinstance(value, np.generic):
return json_ready(value.item())
if isinstance(value, np.ndarray):
return json_ready(value.tolist())
return json_ready(value)
def update_hash_text(digest: "hashlib._Hash", value: str) -> None:
encoded = value.encode("utf-8")
digest.update(len(encoded).to_bytes(8, "little"))
digest.update(encoded)
def update_hash_array(digest: "hashlib._Hash", label: str, array: Any) -> None:
arr = np.ascontiguousarray(np.asarray(array))
update_hash_text(digest, label)
update_hash_text(digest, str(arr.dtype))
update_hash_text(digest, repr(tuple(int(dim) for dim in arr.shape)))
digest.update(arr.tobytes())
def source_summary(source: Any) -> dict[str, Any]:
return {
"file": getattr(source, "file", ""),
"function": getattr(source, "function", ""),
"lines": json_ready(getattr(source, "lines", None)),
}
def boundary_field_summary(patch: Any) -> dict[str, Any]:
return {
"type": getattr(patch, "type", ""),
"values_shape": array_shape(getattr(patch, "values", None)),
"fixes_value": bool(getattr(patch, "fixes_value", False)),
"assignable": bool(getattr(patch, "assignable", False)),
"coupled": bool(getattr(patch, "coupled", False)),
"updated": bool(getattr(patch, "updated", False)),
"patch_internal_shape": array_shape(getattr(patch, "patch_internal", None)),
"value_internal_coeffs_shape": array_shape(getattr(patch, "value_internal_coeffs", None)),
"value_boundary_coeffs_shape": array_shape(getattr(patch, "value_boundary_coeffs", None)),
"gradient_internal_coeffs_shape": array_shape(getattr(patch, "gradient_internal_coeffs", None)),
"gradient_boundary_coeffs_shape": array_shape(getattr(patch, "gradient_boundary_coeffs", None)),
}
def field_summary(field: Any) -> dict[str, Any]:
boundary = getattr(field, "boundary", {})
return {
"name": getattr(field, "name", ""),
"kind": getattr(field, "kind", ""),
"dimensions": getattr(field, "dimensions", ""),
"entity_kind": getattr(field, "entity_kind", ""),
"entity_count": int(getattr(field, "entity_count", 0)),
"internal": array_stats(getattr(field, "internal")),
"boundary": {name: boundary_field_summary(patch) for name, patch in boundary.items()},
}
def solve_summary(performance: Any) -> dict[str, Any]:
return {
"solver_name": getattr(performance, "solver_name", ""),
"field_name": getattr(performance, "field_name", ""),
"initial_residual": json_ready(getattr(performance, "initial_residual", None)),
"final_residual": json_ready(getattr(performance, "final_residual", None)),
"n_iterations": json_ready(getattr(performance, "n_iterations", None)),
"converged": bool(getattr(performance, "converged", False)),
"singular": bool(getattr(performance, "singular", False)),
}
def matrix_summary(matrix: Any) -> dict[str, Any]:
derived: dict[str, Any] = {}
for name in ("A", "H", "H1", "flux", "face_flux_correction"):
value = getattr(matrix, name, None)
if callable(value):
value = value()
if value is not None:
derived[name] = field_summary(value)
for name in ("residual", "D", "DD"):
value = getattr(matrix, name, None)
if callable(value):
value = value()
if value is not None:
derived[name] = array_stats(value)
return {
"name": getattr(matrix, "name", ""),
"field_name": getattr(matrix, "field_name", ""),
"value_rank": getattr(matrix, "value_rank", ""),
"dimensions": getattr(matrix, "dimensions", ""),
"has_diag": bool(getattr(matrix, "has_diag", False)),
"has_upper": bool(getattr(matrix, "has_upper", False)),
"has_lower": bool(getattr(matrix, "has_lower", False)),
"diagonal": bool(getattr(matrix, "diagonal", False)),
"symmetric": bool(getattr(matrix, "symmetric", False)),
"asymmetric": bool(getattr(matrix, "asymmetric", False)),
"diag": array_stats(getattr(matrix, "diag")),
"upper": None if getattr(matrix, "upper", None) is None else array_stats(getattr(matrix, "upper")),
"lower": None if getattr(matrix, "lower", None) is None else array_stats(getattr(matrix, "lower")),
"source": array_stats(getattr(matrix, "source")),
"psi": field_summary(getattr(matrix, "psi")),
"internal_coeff_shapes": [array_shape(item) for item in getattr(matrix, "internal_coeffs", [])],
"boundary_coeff_shapes": [array_shape(item) for item in getattr(matrix, "boundary_coeffs", [])],
"derived": derived,
}
def summarize_value(value: Any) -> Any:
if hasattr(value, "diag") and hasattr(value, "field_name") and hasattr(value, "source"):
return matrix_summary(value)
if hasattr(value, "internal") and hasattr(value, "entity_kind") and hasattr(value, "boundary"):
return field_summary(value)
if hasattr(value, "solver_name") and hasattr(value, "initial_residual"):
return solve_summary(value)
if hasattr(value, "name") and hasattr(value, "phase") and hasattr(value, "outputs"):
return transform_summary(value)
if isinstance(value, Mapping):
return {str(key): summarize_value(item) for key, item in value.items()}
if isinstance(value, list):
return [summarize_value(item) for item in value]
if isinstance(value, tuple):
return [summarize_value(item) for item in value]
return json_ready(value)
def transform_summary(result: Any) -> dict[str, Any]:
return {
"name": getattr(result, "name", ""),
"phase": getattr(result, "phase", ""),
"changed_fields": json_ready(getattr(result, "changed_fields", [])),
"source": source_summary(getattr(result, "source", None)),
"metadata": json_ready(getattr(result, "metadata", {})),
"outputs": summarize_value(getattr(result, "outputs", {})),
}
def selected_field_summaries(fields: Mapping[str, Any]) -> dict[str, Any]:
return {name: field_summary(fields[name]) for name in REQUIRED_FIELDS if name in fields}
def field_dict_to_mapping(fields: Any) -> dict[str, Any]:
if isinstance(fields, Mapping):
return dict(fields)
if hasattr(fields, "fields") and isinstance(fields.fields, Mapping):
return dict(fields.fields)
return {name: getattr(fields, name) for name in REQUIRED_FIELDS if hasattr(fields, name)}
def mesh_patch_table(mesh: Any) -> list[dict[str, Any]]:
patches = []
for patch in getattr(mesh, "boundary", []):
patches.append(
{
"index": int(getattr(patch, "index", 0)),
"name": getattr(patch, "name", ""),
"type": getattr(patch, "type", ""),
"start": int(getattr(patch, "start", 0)),
"size": int(getattr(patch, "size", 0)),
"coupled": bool(getattr(patch, "coupled", False)),
"constraint": bool(getattr(patch, "constraint", False)),
}
)
return patches
def mesh_topology_digest(mesh: Any) -> str:
digest = hashlib.sha256()
for name in ("n_points", "n_faces", "n_internal_faces", "n_cells"):
update_hash_text(digest, f"{name}={int(getattr(mesh, name))}")
update_hash_array(digest, "faces.offsets", mesh.faces.offsets)
update_hash_array(digest, "faces.values", mesh.faces.values)
update_hash_array(digest, "cells.offsets", mesh.cells.offsets)
update_hash_array(digest, "cells.values", mesh.cells.values)
update_hash_array(digest, "owner", mesh.owner)
update_hash_array(digest, "neighbour", mesh.neighbour)
ldu = getattr(mesh, "ldu", {})
if isinstance(ldu, Mapping):
if "lower_addr" in ldu:
update_hash_array(digest, "ldu.lower_addr", ldu["lower_addr"])
if "upper_addr" in ldu:
update_hash_array(digest, "ldu.upper_addr", ldu["upper_addr"])
for entry in ldu.get("patch_addr", []):
update_hash_text(digest, f"ldu.patch_index={entry.get('patch_index')}")
update_hash_array(digest, "ldu.patch_addr", entry.get("addr", []))
for patch in mesh_patch_table(mesh):
update_hash_text(digest, json.dumps(patch, sort_keys=True))
return digest.hexdigest()
def mesh_geometry_digest(mesh: Any) -> str:
digest = hashlib.sha256()
for name in ("points", "V", "C", "Cf", "Sf", "magSf"):
update_hash_array(digest, name, getattr(mesh, name))
return digest.hexdigest()
def mesh_identity(mesh: Any) -> dict[str, Any]:
return {
"n_points": int(mesh.n_points),
"n_faces": int(mesh.n_faces),
"n_internal_faces": int(mesh.n_internal_faces),
"n_cells": int(mesh.n_cells),
"patches": mesh_patch_table(mesh),
"topology_sha256": mesh_topology_digest(mesh),
"geometry_sha256": mesh_geometry_digest(mesh),
}
def compare_mesh_identity(mode: str, actual: Mapping[str, Any], expected: Mapping[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
keys = ("n_points", "n_faces", "n_internal_faces", "n_cells", "topology_sha256", "geometry_sha256", "patches")
differences = {
key: {"actual": actual.get(key), "expected": expected.get(key)}
for key in keys
if actual.get(key) != expected.get(key)
}
report = {"matches_oracle": not differences, "differences": differences}
if not differences:
return report, []
return report, [{"mode": mode, "kind": "mesh_identity", "differences": differences}]
def field_compare_report(name: str, actual_field: Any, expected_field: Any, *, rtol: float, atol: float) -> tuple[dict[str, Any], dict[str, Any] | None]:
actual = np.asarray(actual_field.internal)
expected = np.asarray(expected_field.internal)
report: dict[str, Any] = {
"field": name,
"actual_shape": array_shape(actual),
"expected_shape": array_shape(expected),
"actual_entity_kind": getattr(actual_field, "entity_kind", ""),
"expected_entity_kind": getattr(expected_field, "entity_kind", ""),
"rtol": rtol,
"atol": atol,
}
if actual.shape != expected.shape:
report.update({"allclose": False, "reason": "shape_mismatch"})
return report, {"field": name, "reason": "shape_mismatch", **report}
if actual.size == 0:
report.update(
{
"allclose": True,
"max_abs": 0.0,
"mean_abs": 0.0,
"location": None,
"actual_at_max": None,
"expected_at_max": None,
}
)
return report, None
diff = np.abs(actual - expected)
finite = np.isfinite(diff)
if not np.all(finite):
flat_index = int(np.flatnonzero(~finite)[0])
max_abs: float | None = None
else:
flat_index = int(np.argmax(diff))
max_abs = finite_float(diff.reshape(-1)[flat_index])
max_index = tuple(int(item) for item in np.unravel_index(flat_index, diff.shape))
entity_index = max_index[0] if max_index else None
component_index = list(max_index[1:]) if len(max_index) > 1 else None
actual_at_max = value_at(actual, max_index)
expected_at_max = value_at(expected, max_index)
tolerance_at_max = None
if isinstance(expected_at_max, (int, float)):
tolerance_at_max = atol + rtol * abs(float(expected_at_max))
finite_diff = diff[finite]
allclose = bool(np.allclose(actual, expected, rtol=rtol, atol=atol, equal_nan=False))
report.update(
{
"allclose": allclose,
"max_abs": max_abs,
"mean_abs": finite_float(np.mean(finite_diff)) if finite_diff.size else None,
"location": {
"array_index": list(max_index),
"entity_kind": getattr(actual_field, "entity_kind", ""),
"entity_index": entity_index,
"component_index": component_index,
},
"actual_at_max": actual_at_max,
"expected_at_max": expected_at_max,
"actual_entity_at_max": entity_value_at(actual, entity_index),
"expected_entity_at_max": entity_value_at(expected, entity_index),
"tolerance_at_max": finite_float(tolerance_at_max),
"nonfinite_error_count": int(diff.size - np.count_nonzero(finite)),
}
)
if allclose:
return report, None
report["reason"] = "value_mismatch"
return report, {"field": name, "reason": "value_mismatch", **report}
def compare_fields(
mode: str,
actual: Mapping[str, Any],
expected: Mapping[str, Any],
*,
rtol: float,
atol: float,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
report: dict[str, Any] = {}
mismatches: list[dict[str, Any]] = []
for name in REQUIRED_FIELDS:
if name not in actual or name not in expected:
missing = {
"field": name,
"reason": "missing_field",
"missing_actual": name not in actual,
"missing_expected": name not in expected,
"actual_fields": sorted(actual),
"expected_fields": sorted(expected),
}
report[name] = {"field": name, "allclose": False, **missing}
mismatches.append({"mode": mode, **missing})
continue
field_report, mismatch = field_compare_report(name, actual[name], expected[name], rtol=rtol, atol=atol)
report[name] = field_report
if mismatch is not None:
mismatches.append({"mode": mode, **mismatch})
return report, mismatches
def run_openfoam_command(cmd: list[str], *, log_path: Path, timeout: int) -> dict[str, Any]:
started = time.monotonic()
env = openfoam_env()
log_path.parent.mkdir(parents=True, exist_ok=True)
try:
with log_path.open("w") as log:
subprocess.run(cmd, cwd=ROOT, env=env, check=True, stdout=log, stderr=subprocess.STDOUT, timeout=timeout)
except subprocess.TimeoutExpired as exc:
raise HarnessError(
ORACLE_FAILURE,
Path(cmd[0]).name,
f"OpenFOAM command timed out after {timeout}s: {' '.join(cmd)}",
details={"cmd": cmd, "timeout_seconds": timeout, "log_path": log_path},
cause=exc,
) from exc
except subprocess.CalledProcessError as exc:
raise HarnessError(
ORACLE_FAILURE,
Path(cmd[0]).name,
f"OpenFOAM command failed with exit code {exc.returncode}: {' '.join(cmd)}",
details={"cmd": cmd, "returncode": exc.returncode, "log_path": log_path},
cause=exc,
) from exc
return {
"cmd": cmd,
"returncode": 0,
"log_path": log_path,
"duration_seconds": round(time.monotonic() - started, 6),
"timeout_seconds": timeout,
}
def patch_start_from_latest(case: Path) -> None:
path = case / "system/controlDict"
text = path.read_text()
old = "startFrom startTime;"
new = "startFrom latestTime;"
if old not in text:
raise AssertionError(f"{path}: missing {old!r}")
path.write_text(text.replace(old, new, 1))
def import_foam() -> Any:
apply_openfoam_env()
import foam_stepper as foam
return foam
def prepare_work_cases(source: Path, work: Path, *, include_split: bool) -> dict[str, Any]:
if work.exists():
shutil.rmtree(work)
work.mkdir(parents=True)
oracle = work / "oracle_case"
run_one = work / "run_one_case"
split = work / "split_case" if include_split else None
oracle_meta = prepare_case(source, oracle, end_time=1)
run_one_meta = prepare_case(source, run_one, end_time=1)
split_meta = prepare_case(source, split, end_time=1) if split is not None else None
return {
"oracle_case": oracle,
"run_one_case": run_one,
"split_case": split,
"metadata": {
"oracle": oracle_meta,
"run_one": run_one_meta,
"split": split_meta,
},
}
def make_stepper(foam: Any, case: Path, label: str) -> Any:
try:
return foam.Case(case).make_stepper()
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
f"load_{label}_case",
f"failed to load {label} case with foam_stepper: {case}",
details={"case": case},
cause=exc,
) from exc
def read_mesh_identity(stepper: Any, label: str) -> dict[str, Any]:
try:
return mesh_identity(stepper.mesh())
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
f"read_{label}_mesh",
f"failed to read {label} mesh identity",
details={"case": getattr(stepper, "case_path", "")},
cause=exc,
) from exc
def read_fields(stepper: Any, label: str) -> dict[str, Any]:
try:
return field_dict_to_mapping(stepper.fields())
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
f"read_{label}_fields",
f"failed to read {label} fields",
details={"case": getattr(stepper, "case_path", "")},
cause=exc,
) from exc
def checked_step(stages: list[dict[str, Any]], name: str, fn: Any) -> Any:
try:
result = fn()
except Exception as exc:
raise HarnessError(
STEPPER_FAILURE,
name,
f"split-step stage failed: {name}",
cause=exc,
) from exc
stages.append(transform_summary(result))
return result
def run_split_iteration(stepper: Any) -> dict[str, Any]:
stages: list[dict[str, Any]] = []
checked_step(stages, "pre_solve", stepper.pre_solve)
checked_step(stages, "advance_time", stepper.advance_time)
begin = checked_step(stages, "begin_pimple_iteration", stepper.begin_pimple_iteration)
if begin.outputs.get("active") is not True:
raise HarnessError(
STEPPER_FAILURE,
"begin_pimple_iteration",
"split-step PIMPLE iteration was inactive",
details={"outputs": summarize_value(begin.outputs)},
)
checked_step(stages, "fv_models_correct", stepper.fv_models_correct)
checked_step(stages, "pre_predictor", stepper.pre_predictor)
checked_step(stages, "momentum_transport_predict", stepper.momentum_transport_predictor)
terms = checked_step(stages, "assemble_momentum_terms", stepper.assemble_momentum_terms)
UEqn = checked_step(stages, "assemble_UEqn", stepper.assemble_momentum_matrix)
checked_step(stages, "relax_UEqn", stepper.relax_matrix)
checked_step(stages, "constrain_UEqn", stepper.constrain_matrix)
checked_step(stages, "solve_UEqn", stepper.solve_momentum)
checked_step(stages, "compute_pressure_inputs", stepper.compute_pressure_inputs)
pEqn = checked_step(stages, "assemble_pEqn", stepper.assemble_pressure_matrix)
checked_step(stages, "solve_pEqn", stepper.solve_pressure)
checked_step(stages, "correct_velocity_pressure_flux", stepper.correct_velocity_pressure_flux)
checked_step(stages, "momentum_transport_correct", stepper.momentum_transport_corrector)
checked_step(stages, "end_pimple_iteration", stepper.end_pimple_iteration)
checked_step(stages, "post_solve", lambda: stepper.post_solve(write=False))
try:
fields = field_dict_to_mapping(stepper.fields())
except Exception as exc:
raise HarnessError(
STEPPER_FAILURE,
"split_fields",
"failed to read split-step fields after execution",
cause=exc,
) from exc
momentum_terms = [term.get("name", "") for term in terms.outputs.get("terms", [])]
UEqn_matrix = UEqn.outputs["UEqn"]
pEqn_matrix = pEqn.outputs["pEqn"]
return {
"fields": fields,
"stages": stages,
"graph": [stage["name"] for stage in stages],
"momentum_terms": momentum_terms,
"UEqn": matrix_summary(UEqn_matrix),
"pEqn": matrix_summary(pEqn_matrix),
}
def visible_turbulence_fields(fields: Mapping[str, Any]) -> list[str]:
return [name for name in TURBULENCE_FIELDS if name in fields]
def base_report(args: argparse.Namespace) -> dict[str, Any]:
report_path = args.report if args.report is not None else args.work / "verifier_report.json"
return {
"harness": {
"name": "airfrans_stepper_verifier",
"spec": "VERIFIER_HARNESS_SPEC.md",
"schema_version": 1,
},
"status": "running",
"failure": None,
"paths": {
"root": ROOT,
"source": args.source,
"work": args.work,
"report": report_path,
},
"source_policy": "raw AirfRANS source is read-only; all solver runs use prepared work-directory copies",
"tolerances": {
"rtol": args.rtol,
"atol": args.atol,
"policy": "CPU stepper parity with the repository OpenFOAM oracle must pass np.allclose for every required field.",
},
"required_fields": {
"primary": list(PRIMARY_FIELDS),
"turbulence": list(TURBULENCE_FIELDS),
"all": list(REQUIRED_FIELDS),
},
"commands": [],
"case_preparation": {},
"mesh_identity": {},
"modes": {
"run_one": {"enabled": True},
"split": {"enabled": not args.skip_split},
},
"tracked_turbulence_fields": {},
"comparison_mismatches": [],
}
def run_harness(args: argparse.Namespace, report: dict[str, Any]) -> None:
try:
prepared = prepare_work_cases(args.source, args.work, include_split=not args.skip_split)
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
"prepare_cases",
"failed to prepare reproducible AirfRANS work cases",
details={"source": args.source, "work": args.work},
cause=exc,
) from exc
oracle_case = prepared["oracle_case"]
run_one_case = prepared["run_one_case"]
split_case = prepared["split_case"]
report["case_preparation"] = json_ready(prepared)
report["commands"].append(
run_openfoam_command(["checkMesh", "-case", str(oracle_case), "-constant"], log_path=args.work / "checkMesh.log", timeout=120)
)
report["commands"].append(
run_openfoam_command(
["foamRun", "-case", str(oracle_case), "-solver", "incompressibleFluid", "-noFunctionObjects"],
log_path=args.work / "foamRun_oracle.log",
timeout=600,
)
)
try:
patch_start_from_latest(oracle_case)
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
"select_oracle_latest_time",
"failed to configure oracle case to load latestTime output",
details={"case": oracle_case},
cause=exc,
) from exc
try:
foam = import_foam()
except Exception as exc:
raise HarnessError(
LOADING_FAILURE,
"import_foam_stepper",
"failed to import foam_stepper in the repository OpenFOAM environment",
cause=exc,
) from exc
oracle_stepper = make_stepper(foam, oracle_case, "oracle")
oracle_mesh = read_mesh_identity(oracle_stepper, "oracle")
oracle_fields = read_fields(oracle_stepper, "oracle")
report["mesh_identity"]["oracle"] = oracle_mesh
report["modes"]["oracle"] = {
"case": oracle_case,
"fields": selected_field_summaries(oracle_fields),
}
report["tracked_turbulence_fields"]["oracle"] = visible_turbulence_fields(oracle_fields)
run_one_stepper = make_stepper(foam, run_one_case, "run_one")
run_one_mesh = read_mesh_identity(run_one_stepper, "run_one")
report["mesh_identity"]["run_one"] = run_one_mesh
mesh_report, mesh_mismatches = compare_mesh_identity("run_one", run_one_mesh, oracle_mesh)
report["modes"]["run_one"]["mesh_comparison"] = mesh_report
try:
run_one_result = run_one_stepper.run_one_pimple_iteration()
run_one_fields = field_dict_to_mapping(run_one_result.outputs["fields"])
except Exception as exc:
raise HarnessError(
STEPPER_FAILURE,
"run_one_pimple_iteration",
"full one-iteration stepper execution failed",
details={"case": run_one_case},
cause=exc,
) from exc
run_one_comparisons, run_one_mismatches = compare_fields(
"run_one",
run_one_fields,
oracle_fields,
rtol=args.rtol,
atol=args.atol,
)
report["modes"]["run_one"].update(
{
"case": run_one_case,
"stage": transform_summary(run_one_result),
"graph": [entry.name for entry in run_one_result.outputs.get("graph", [])],
"fields": selected_field_summaries(run_one_fields),
"comparisons": run_one_comparisons,
}
)
report["tracked_turbulence_fields"]["run_one"] = visible_turbulence_fields(run_one_fields)
mismatches = mesh_mismatches + run_one_mismatches
if split_case is not None:
split_stepper = make_stepper(foam, split_case, "split")
split_mesh = read_mesh_identity(split_stepper, "split")
report["mesh_identity"]["split"] = split_mesh
split_mesh_report, split_mesh_mismatches = compare_mesh_identity("split", split_mesh, oracle_mesh)
report["modes"]["split"]["mesh_comparison"] = split_mesh_report
split_result = run_split_iteration(split_stepper)
split_fields = split_result["fields"]
split_comparisons, split_mismatches = compare_fields(
"split",
split_fields,
oracle_fields,
rtol=args.rtol,
atol=args.atol,
)
report["modes"]["split"].update(
{
"case": split_case,
"graph": split_result["graph"],
"stages": split_result["stages"],
"momentum_terms": split_result["momentum_terms"],
"UEqn": split_result["UEqn"],
"pEqn": split_result["pEqn"],
"fields": selected_field_summaries(split_fields),
"comparisons": split_comparisons,
}
)
report["tracked_turbulence_fields"]["split"] = visible_turbulence_fields(split_fields)
mismatches.extend(split_mesh_mismatches)
mismatches.extend(split_mismatches)
report["comparison_mismatches"] = json_ready(mismatches)
if mismatches:
raise HarnessError(
COMPARISON_FAILURE,
"compare_oracle_stepper_outputs",
f"{len(mismatches)} verifier comparison mismatch(es) observed",
details={"mismatches": mismatches},
)
def write_report(report: Mapping[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(json_ready(report), indent=2, sort_keys=True, allow_nan=False) + "\n")
def fmt_sci(value: Any) -> str:
if value is None:
return "none"
try:
return f"{float(value):.3e}"
except (TypeError, ValueError):
return str(value)
def format_location(location: Mapping[str, Any] | None) -> str:
if not location:
return "none"
component = location.get("component_index")
component_text = "" if component is None else f" component={component}"
return f"{location.get('entity_kind')}[{location.get('entity_index')}]{component_text}"
def print_human_summary(report: Mapping[str, Any]) -> None:
status = report.get("status")
print(f"airfrans_stepper_verification {status}")
print(f"source={report['paths']['source']}")
print(f"work={report['paths']['work']}")
print(f"report={report['paths']['report']}")
print(f"rtol={report['tolerances']['rtol']} atol={report['tolerances']['atol']}")
if status != "passed":
failure = report.get("failure") or {}
print(f"failure_category={failure.get('category')}")
print(f"failure_step={failure.get('step')}")
print(f"failure_message={failure.get('message')}")
return
oracle_mesh = report["mesh_identity"]["oracle"]
patch_names = [patch["name"] for patch in oracle_mesh["patches"]]
print(f"oracle_case={report['case_preparation']['oracle_case']}")
print(f"run_one_case={report['modes']['run_one']['case']}")
if report["modes"].get("split", {}).get("enabled"):
print(f"split_case={report['modes']['split']['case']}")
print(f"mesh_cells={oracle_mesh['n_cells']}")
print(f"mesh_internal_faces={oracle_mesh['n_internal_faces']}")
print(f"mesh_patches={patch_names}")
print(f"mesh_topology_sha256={oracle_mesh['topology_sha256']}")
for mode in ("run_one", "split"):
mode_report = report["modes"].get(mode, {})
if not mode_report.get("enabled", False):
continue
for field_name in REQUIRED_FIELDS:
data = mode_report.get("comparisons", {}).get(field_name)
if not data:
continue
print(
f"mode={mode} field={field_name} actual_shape={tuple(data['actual_shape'])} "
f"expected_shape={tuple(data['expected_shape'])} max_abs={fmt_sci(data.get('max_abs'))} "
f"location={format_location(data.get('location'))} allclose={data['allclose']}"
)
graph = mode_report.get("graph")
if graph:
print(f"{mode}_graph={graph}")
split_report = report["modes"].get("split", {})
if split_report.get("enabled") and "UEqn" in split_report and "pEqn" in split_report:
print(f"split_momentum_terms={split_report.get('momentum_terms')}")
print(f"split_UEqn_diag_shape={tuple(split_report['UEqn']['diag']['shape'])}")
print(f"split_pEqn_diag_shape={tuple(split_report['pEqn']['diag']['shape'])}")
print(f"visible_turbulence_fields={report['tracked_turbulence_fields']}")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--work", type=Path, default=DEFAULT_WORK)
parser.add_argument("--report", type=Path, default=None, help="JSON report path; defaults to WORK/verifier_report.json")
parser.add_argument("--rtol", type=float, default=1e-8)
parser.add_argument("--atol", type=float, default=1e-8)
parser.add_argument("--skip-split", action="store_true", help="Skip the explicit split-step parity check for local debugging")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
args.source = args.source.resolve()
args.work = args.work.resolve()
if args.report is None:
args.report = args.work / "verifier_report.json"
else:
args.report = args.report.resolve()
report = base_report(args)
exit_code = 0
try:
run_harness(args, report)
report["status"] = "passed"
except HarnessError as exc:
report["status"] = "failed"
report["failure"] = exc.to_dict()
exit_code = EXIT_CODES.get(exc.category, EXIT_CODES[INTERNAL_FAILURE])
except Exception as exc: # pragma: no cover - keeps CLI failures categorized in the report.
report["status"] = "failed"
report["failure"] = json_ready(
{
"category": INTERNAL_FAILURE,
"step": "run_harness",
"message": str(exc),
"cause": {"type": type(exc).__name__, "message": str(exc)},
"traceback": traceback.format_exc(),
}
)
exit_code = EXIT_CODES[INTERNAL_FAILURE]
try:
write_report(report, args.report)
except Exception as exc:
report["status"] = "failed"
report["failure"] = json_ready(
{
"category": INTERNAL_FAILURE,
"step": "write_report",
"message": f"failed to write verifier report: {args.report}",
"cause": {"type": type(exc).__name__, "message": str(exc)},
}
)
exit_code = EXIT_CODES[INTERNAL_FAILURE]
print_human_summary(json_ready(report))
return exit_code
if __name__ == "__main__":
raise SystemExit(main())

194
scripts/verify_python_stepper.py Executable file
View file

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""Scenario checks for the Python-driven OpenFOAM observability stepper."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
def _drop_ambient_pythonpath() -> None:
pythonpath = os.environ.pop("PYTHONPATH", "")
for entry in pythonpath.split(os.pathsep):
if not entry:
continue
while entry in sys.path:
sys.path.remove(entry)
_drop_ambient_pythonpath()
import numpy as np
from openfoam_env import apply_openfoam_env, openfoam_env
ROOT = Path(__file__).resolve().parents[1]
TUTORIAL = ROOT / "OpenFOAM-14/tutorials/incompressibleFluid/venturiTube"
WORK = ROOT / "tmp/python_stepper_verify"
LOAD_CASE = WORK / "load_case"
TRANSFORM_CASE = WORK / "transform_case"
PY_EQ_CASE = WORK / "python_equivalence_case"
FOAM_EQ_CASE = WORK / "foam_equivalence_case"
def run(cmd: list[str]) -> None:
subprocess.run(cmd, cwd=ROOT, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT, env=openfoam_env())
def patch_control_dict(case: Path, *, latest: bool = False) -> None:
path = case / "system/controlDict"
text = path.read_text()
replacements = {
"startFrom startTime;": "startFrom latestTime;" if latest else "startFrom startTime;",
"endTime 1000;": "endTime 1;",
"writeInterval 50;": "writeInterval 1;",
}
for old, new in replacements.items():
text = text.replace(old, new)
path.write_text(text)
def prepare_case(dst: Path) -> None:
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(TUTORIAL, dst, ignore=shutil.ignore_patterns("processor*", "postProcessing", "*.log"))
for orig in (dst / "0").glob("*.orig"):
shutil.copyfile(orig, orig.with_suffix(""))
patch_control_dict(dst)
run(["blockMesh", "-case", str(dst)])
run(["createZones", "-case", str(dst)])
def import_foam():
apply_openfoam_env()
import foam_stepper as foam
return foam
def load_scenario() -> None:
foam = import_foam()
case = foam.Case(LOAD_CASE)
stepper = case.make_stepper()
mesh = stepper.mesh()
fields = stepper.fields()
assert mesh.n_cells == 57600
assert mesh.n_internal_faces == fields.phi.internal.shape[0]
assert fields.U.internal.shape == (mesh.n_cells, 3)
assert fields.p.internal.shape == (mesh.n_cells,)
assert fields.phi.internal.shape == (mesh.n_internal_faces,)
assert {patch.name for patch in mesh.boundary} == {"inlet", "outlet", "walls"}
try:
stepper.assemble_pressure_matrix()
except foam.OpenFoamError as exc:
assert "Momentum matrix is not assembled" in str(exc)
else:
raise AssertionError("assemble_pressure_matrix succeeded before momentum assembly")
def transformation_scenario() -> None:
foam = import_foam()
stepper = foam.Case(TRANSFORM_CASE).make_stepper()
stepper.pre_solve()
stepper.advance_time()
begin = stepper.begin_pimple_iteration()
assert begin.outputs["active"] is True
terms = stepper.assemble_momentum_terms()
names = {term["name"] for term in terms.outputs["terms"]}
assert {
"assemble_momentum_ddt",
"assemble_momentum_div_phi_U",
"assemble_momentum_MRF_DDt",
"assemble_momentum_divDevSigma",
"assemble_momentum_sources",
"assemble_momentum_pressure_rhs_grad_p",
}.issubset(names)
UEqn = stepper.assemble_momentum_matrix().outputs["UEqn"]
assert UEqn.diag.shape == (57600,)
assert UEqn.psi.internal.shape == (57600, 3)
assert UEqn.source.shape == (57600, 3)
assert UEqn.H is not None
stepper.relax_matrix()
stepper.constrain_matrix()
momentum = stepper.solve_momentum()
assert momentum.outputs["performance"].field_name == "U"
pressure_inputs = stepper.compute_pressure_inputs()
assert pressure_inputs.outputs["rAU"].internal.shape == (57600,)
assert pressure_inputs.outputs["HbyA"].internal.shape == (57600, 3)
assert pressure_inputs.outputs["phiHbyA"].internal.shape == (170624,)
pEqn = stepper.assemble_pressure_matrix().outputs["pEqn"]
assert pEqn.diag.shape == (57600,)
assert pEqn.psi.internal.shape == (57600,)
assert pEqn.flux is not None
pressure = stepper.solve_pressure()
assert pressure.name == "solve_pEqn"
assert pressure.outputs["performance"].field_name == "p"
assert pressure.outputs["p"].internal.shape == (57600,)
assert pressure.outputs["phi"].internal.shape == (170624,)
correction = stepper.correct_velocity_pressure_flux()
assert correction.outputs["U"].internal.shape == (57600, 3)
assert correction.outputs["phi"].internal.shape == (170624,)
def equivalence_scenario() -> None:
foam = import_foam()
run(["foamRun", "-case", str(FOAM_EQ_CASE), "-solver", "incompressibleFluid", "-noFunctionObjects"])
patch_control_dict(FOAM_EQ_CASE, latest=True)
baseline = foam.Case(FOAM_EQ_CASE).make_stepper().fields()
result = foam.Case(PY_EQ_CASE).make_stepper().run_one_pimple_iteration()
actual = result.outputs["fields"]
comparisons = {
"U": (actual["U"].internal, baseline.U.internal),
"p": (actual["p"].internal, baseline.p.internal),
"phi": (actual["phi"].internal, baseline.phi.internal),
}
for name, (lhs, rhs) in comparisons.items():
if lhs.shape != rhs.shape:
raise AssertionError(f"{name} shape mismatch: {lhs.shape} != {rhs.shape}")
max_abs = float(np.max(np.abs(lhs - rhs)))
if not np.allclose(lhs, rhs, rtol=1e-7, atol=1e-7):
raise AssertionError(f"{name} mismatch: max_abs={max_abs}")
print(f"equivalence {name}: max_abs={max_abs:.3e}")
graph = result.outputs["graph"]
graph_names = {entry.name for entry in graph}
assert {"pre_solve", "advance_time", "assemble_UEqn", "solve_UEqn", "assemble_pEqn", "solve_pEqn"}.issubset(graph_names)
def main() -> None:
if len(sys.argv) == 2:
scenarios = {
"load": load_scenario,
"transformation": transformation_scenario,
"equivalence": equivalence_scenario,
}
scenarios[sys.argv[1]]()
return
if WORK.exists():
shutil.rmtree(WORK)
WORK.mkdir(parents=True)
for case_dir in (LOAD_CASE, TRANSFORM_CASE, PY_EQ_CASE, FOAM_EQ_CASE):
prepare_case(case_dir)
for scenario in ("load", "transformation", "equivalence"):
subprocess.run([sys.executable, __file__, scenario], cwd=ROOT, check=True, env=openfoam_env())
print("python stepper verification passed")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
JOBS="${JOBS:-$(nproc)}"
PYTHON_BIN="${PYTHON_BIN:-${ROOT_DIR}/.venv/bin/python}"
if [[ ! -x "${PYTHON_BIN}" ]]; then
uv sync --dev
fi
if ! PYTHONPATH= "${PYTHON_BIN}" -c 'import pybind11, numpy' >/dev/null 2>&1; then
uv sync --dev
fi
"${ROOT_DIR}/scripts/build_openfoam_airfrans_subset.sh" >/dev/null
# blockMesh/createZones are verification fixtures for deterministic tutorial cases.
set +u
source "${ROOT_DIR}/OpenFOAM-14/etc/bashrc" \
WM_MPLIB=Dummy \
ParaView_TYPE=none \
SCOTCH_TYPE=none \
ZOLTAN_TYPE=none
set -u
# OpenFOAM's SIGFPE trap is useful for solver binaries but unsafe for Python's
# own floating-point formatting/comparison code inside the verification process.
unset FOAM_SIGFPE
(
cd "${ROOT_DIR}/OpenFOAM-14"
wmake -j "${JOBS}" libso src/mesh/blockMesh >/dev/null
wmake -j "${JOBS}" applications/utilities/mesh/generation/blockMesh >/dev/null
wmake -j "${JOBS}" applications/utilities/mesh/manipulation/createZones >/dev/null
)
"${ROOT_DIR}/scripts/build_python_stepper.sh" >/dev/null
"${PYTHON_BIN}" "${ROOT_DIR}/scripts/verify_python_stepper.py"

1801
uv.lock Normal file

File diff suppressed because it is too large Load diff