init
This commit is contained in:
commit
1bdbaa9572
21 changed files with 13473 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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/
|
||||
262
notebooks/airfrans_equation_first_hand_simulation.ipynb
Normal file
262
notebooks/airfrans_equation_first_hand_simulation.ipynb
Normal 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
|
||||
}
|
||||
814
notebooks/airfrans_openfoam_algorithm_first.ipynb
Normal file
814
notebooks/airfrans_openfoam_algorithm_first.ipynb
Normal 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
|
||||
}
|
||||
7597
notebooks/airfrans_stepper_full_detail_learning_tool.ipynb
Normal file
7597
notebooks/airfrans_stepper_full_detail_learning_tool.ipynb
Normal file
File diff suppressed because one or more lines are too long
25
pyproject.toml
Normal file
25
pyproject.toml
Normal 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
15
python/pyproject.toml
Normal 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"]
|
||||
205
python/src/foam_stepper/__init__.py
Normal file
205
python/src/foam_stepper/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
python/src/foam_stepper/_foam_stepper.so
Executable file
BIN
python/src/foam_stepper/_foam_stepper.so
Executable file
Binary file not shown.
70
python/src/foam_stepper/_runtime.py
Normal file
70
python/src/foam_stepper/_runtime.py
Normal 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
|
||||
399
python/src/foam_stepper/types.py
Normal file
399
python/src/foam_stepper/types.py
Normal 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
|
||||
104
scripts/build_openfoam_airfrans_subset.sh
Executable file
104
scripts/build_openfoam_airfrans_subset.sh
Executable 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
46
scripts/build_python_stepper.sh
Executable 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
475
scripts/fuzz_python_stepper.py
Executable 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
36
scripts/fuzz_python_stepper.sh
Executable 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
73
scripts/openfoam_env.py
Normal 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
|
||||
274
scripts/prepare_airfrans_stepper_case.py
Executable file
274
scripts/prepare_airfrans_stepper_case.py
Executable 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()
|
||||
43
scripts/quadrants_hello_world.py
Executable file
43
scripts/quadrants_hello_world.py
Executable 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()
|
||||
988
scripts/verify_airfrans_stepper.py
Executable file
988
scripts/verify_airfrans_stepper.py
Executable 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
194
scripts/verify_python_stepper.py
Executable 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()
|
||||
40
scripts/verify_python_stepper.sh
Executable file
40
scripts/verify_python_stepper.sh
Executable 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"
|
||||
Loading…
Reference in a new issue