test: add pinned LES baseline and temporary candidate diagnostics

This commit is contained in:
2026-10-17 23:47:56 -07:00
parent 69e9b78de9
commit 20ae677092
9 changed files with 742 additions and 0 deletions
+1
View File
@@ -666,6 +666,7 @@ streak.render("streakline.png")
## Validation campaigns
- [DFG 2D-2 Re100](docs/validation/dfg_re100.md): frozen periodicity and official-reference comparisons, with a declared noncanonical outlet.
- [Smagorinsky helper comparison](docs/validation/les_helpers.md): pinned baseline versus an output-local two-coefficient candidate, without production edits.
- [Jet-circle control volume](docs/validation/jet_circle_cv.md): bounded AMR versus uniform-grid conservation diagnostic.
The runners are in `tests/validation`, their CPU tests in `tests/unit`, and compact reference/source guards in `tests/validation/data`. These campaigns preserve pinned production code; successful CPU checks alone do not establish GPU or physical validation. Keep generated results outside tracked files.
+40
View File
@@ -0,0 +1,40 @@
# Smagorinsky baseline/candidate helper diagnostic
This validation-only diagnostic compares exact d79 production headers with a temporary candidate. It does not modify production source. The candidate changes only the D2Q9 and D3Q19 closed-form factors from 18 to 18 sqrt(2), plus comments defining |S| = sqrt(2 S:S) and self-consistent effective tau. The small reviewed patch and expected header hashes are in `tests/validation/les/data`; no copied headers, solver trees, raw results, or binary fixtures are versioned.
## Prepare and verify on the CPU
From the repository root, choose a new output directory:
```sh
python tests/validation/les/prepare.py --output-dir output/les-host/candidate
python tests/validation/les/test_candidate.py --candidate-dir output/les-host/candidate --output output/les-host/host_results.json
```
Preparation checks both actual production header hashes against d79, copies only `descriptors.cuh` and `turbulence_smag.cuh` into output-local baseline/patched include trees, applies the reviewed patch only to the latter, and verifies all four resulting hashes. It refuses an existing candidate directory. The host test verifies candidate hashes again and refuses an existing evidence file. The Git branch HEAD may include validation commits; header content, rather than HEAD identity, is the pin.
The independent host oracle constructs second-order Hermite populations and solves the independently derived quadratic by bisection:
tau * (tau - tau0) = 9 Cs^2 ||Pi_neq|| / (sqrt(2) rho)
There are **736 parameter cases**, covering D2Q9/D3Q19; Cs=0,.08,.16,.24; tau0=.6,.8,1.2; rho=.7,1,1.3; equilibrium, isotropic stress, pure shear, and trace-free diagonal extension; all coordinate planes and both signs; density-scaled Pi and separate fixed-Pi/nonunit-rho probes. The test checks quadratic residuals, closed/default-three/converged-200 iterations, bit-identical unchanged iterative output, and an additional explicit cap-active manufactured probe. Existing g++ and Python stdlib are sufficient; no installation is attempted. Compilation products are temporary. Previously reviewed host maxima were omega error 1.394302e-7 and quadratic residual 1.201202e-7; this is not CUDA fast-math evidence.
## Authorized GPU diagnostic
Use the existing working PyCUDA/NumPy environment and an explicitly selected device, through the same serialized GPU launch owner as the other campaigns:
```sh
CUDA_VISIBLE_DEVICES="$VERIFIED_IDLE_GPU" python tests/validation/les/run_gpu.py --run-gpu --device 0 --output-dir output/les-gpu
```
Device 0 is the logical device after CUDA visibility filtering. The output directory must not exist. The runner materializes and verifies the isolated include trees under `output/les-gpu/candidate`, then runs baseline and patched processes sequentially. Each process compiles one SourceModule at a time synchronously and synchronizes each probe. There are no background jobs, worker pools, or parallel compiler calls. No machine contact, device selection, reservation, or environment setup is performed by the runner.
Each version has exactly **48 scalar GPU cases**, 96 total: D2Q9/D3Q19, Cs=.16/0, equilibrium/shear/isotropic/cap-active manufactured stress, and closed/default-three/converged-200 branches. This is the frozen GPU matrix, not the expanded 736-case host matrix. Outputs `baseline.json` and `patched.json` contain raw scalar omega values and device name. Completion means only the diagnostic executed; an independent comparison against the declared host oracle is still required. A failed run leaves partial evidence for inspection and must not be overwritten.
## Interpretation and limits
Full-stress versus deviatoric-stress selection, iterative viscosity cap, iteration count/relaxation, omega guards, collision dispatch, and AMR guards remain unchanged. Closed and iterative paths intentionally remain different for isotropic or cap-active stresses. No settings or Cs defaults change. High stress is a helper diagnostic, not an admissible flow-state claim.
The corrected scalar formula is justified only under the stated stress-strain estimate. MRT full-trace stress does not generally share one shear relaxation rate, so its bulk coupling remains uncertified. Force-corrected non-equilibrium stress and Guo-force interaction also remain uncertified. These manufactured tests are unforced and do not cover full TRT/MRT collision, streaming modes, shifted storage, walls/Jets, flow solutions, or 3D turbulence. Existing guard statements are preserved, not newly comprehensively tested; the main matrix is guard-inactive. No AMR LES support is added or enabled.
Production integration and removal of this temporary candidate fixture are separate decisions after GPU and applicable flow evidence. CPU success alone does not approve a production change.
+247
View File
@@ -0,0 +1,247 @@
"""Bounded host-only audit of exact production Smagorinsky helper; stdlib only."""
import argparse, ctypes, hashlib, json, math, pathlib, subprocess, tempfile
HERE = pathlib.Path(__file__).resolve().parent
DEFAULT_ROOT = HERE.parents[2]
HEADERS = ["core/descriptors.cuh", "operators/turbulence_smag.cuh"]
def directions(dim):
result = [(0,) * dim]
for axis in range(dim):
v = [0] * dim
v[axis] = 1
result += [tuple(v), tuple(-x for x in v)]
for sign in (1, -1):
for a in range(dim):
for b in range(a + 1, dim):
v = [0] * dim
v[a] = 1
v[b] = sign
result += [tuple(v), tuple(-x for x in v)]
return result
def populations(dim, kind, p=0.02):
c = directions(dim)
eq = [
(
(4 / 9 if dim == 2 else 1 / 3)
if not any(v)
else (
(1 / 9 if dim == 2 else 1 / 18)
if sum(x * x for x in v) == 1
else 1 / 36
)
)
for v in c
]
neq = [0.0] * len(c)
if kind == "shear":
for i, v in enumerate(c):
if v[0] and v[1]:
neq[i] = p * v[0] * v[1] / 4
elif kind == "isotropic":
for i, v in enumerate(c):
if sum(x * x for x in v) == 1:
neq[i] = p / 2
neq[0] = -dim * p
elif kind != "equilibrium":
raise ValueError(kind)
return [a + b for a, b in zip(eq, neq)], eq
def stress(f, eq, dim):
c = directions(dim)
return [
[sum((a - b) * v[i] * v[j] for a, b, v in zip(f, eq, c)) for j in range(dim)]
for i in range(dim)
]
def norm(matrix, dev=False):
n = len(matrix)
tr = sum(matrix[i][i] for i in range(n)) / n if dev else 0
return math.sqrt(
sum(
(matrix[i][j] - (tr if i == j else 0)) ** 2
for i in range(n)
for j in range(n)
)
)
def tau_closed(tau0, cs, pnorm, rho=1.0, standard=True):
factor = math.sqrt(2) if standard else 1.0
return 0.5 * (tau0 + math.sqrt(tau0 * tau0 + 18 * factor * cs * cs * pnorm / rho))
def iterative(tau0, cs, pnorm_dev, rho=1.0, iters=3, cap=20.0, relax=0.7):
tau = tau0
nu0 = (tau0 - 0.5) / 3
for _ in range(iters):
s = 3 * pnorm_dev / (math.sqrt(2) * rho * tau)
nut = min(cs * cs * s, cap * nu0)
new = min(100.0, max(0.500001, 0.5 + 3 * (nu0 + nut)))
tau = relax * new + (1 - relax) * tau
return min(1.99, max(0.01, 1 / tau))
def wrapper(dim, closed, cs, iters=3, gpu=False):
prefix = (
""
if gpu
else "#define __device__\n#define __constant__\n#define __forceinline__ inline\n"
)
declarations = f"""#include <math.h>
{prefix}
#define LATTICE_D2Q9 0
#define LATTICE_D3Q19 1
#define LATTICE_MODEL {0 if dim==2 else 1}
#define DIM {dim}
#define NQ {9 if dim==2 else 19}
#define OMEGA_COLLISION_MIN .01f
#define OMEGA_COLLISION_MAX 1.99f
#define LES_CS {cs:.9f}f
#define LES_CLOSED_FORM {int(closed)}
#define LES_FP_ITERS {iters}
#include "core/descriptors.cuh"
#include "operators/turbulence_smag.cuh"
"""
if gpu:
return (
declarations
+ """extern "C" __global__ void probe(const float* f,const float* eq,float rho,float omega,float* out) { if(threadIdx.x==0 && blockIdx.x==0) out[0]=compute_omega_smag(f,eq,rho,omega); }\n"""
)
return (
declarations
+ """extern "C" float probe(const float* f,const float* eq,float rho,float omega) {return compute_omega_smag(f,eq,rho,omega);}\n"""
)
def compile_host(root, directory, dim, closed, cs, iters):
stem = f"d{dim}_closed{closed}_cs{cs}_i{iters}"
src = directory / (stem + ".cpp")
lib = directory / (stem + ".so")
src.write_text(wrapper(dim, closed, cs, iters))
subprocess.run(
[
"g++",
"-std=c++11",
"-O2",
"-shared",
"-fPIC",
"-I",
str(root / "src/CelerisLab/lbm/kernels"),
str(src),
"-o",
str(lib),
],
check=True,
capture_output=True,
)
dll = ctypes.CDLL(str(lib))
fn = dll.probe
fn.argtypes = [ctypes.POINTER(ctypes.c_float)] * 2 + [ctypes.c_float] * 2
fn.restype = ctypes.c_float
def call(f, eq, tau0):
arr = ctypes.c_float * len(f)
return float(fn(arr(*f), arr(*eq), 1.0, 1 / tau0))
return call
def run(root):
rows = []
checks = 0
with tempfile.TemporaryDirectory(prefix="les-host-") as tmp:
for dim in (2, 3):
for cs in (0.16, 0.0):
kernels = {
(closed, iters): compile_host(
root, pathlib.Path(tmp), dim, closed, cs, iters
)
for closed, iters in ((1, 3), (0, 3), (0, 200))
}
for case, kind, p, tau0 in [
("equilibrium", "equilibrium", 0.0, 0.8),
("pure_shear", "shear", 0.02, 0.8),
("isotropic", "isotropic", 0.02, 0.8),
("cap_active", "shear", 0.2, 0.503),
]:
f, eq = populations(dim, kind, p)
tensor = stress(f, eq, dim)
pf, pd = norm(tensor), norm(tensor, True)
actual = {
f"closed{c}_iterations{i}": fn(f, eq, tau0)
for (c, i), fn in kernels.items()
}
expected = {
"closed1_iterations3": min(
1.99,
max(0.01, 1 / tau_closed(tau0, cs, pf, standard=False)),
),
"closed0_iterations3": iterative(tau0, cs, pd),
"closed0_iterations200": iterative(tau0, cs, pd, iters=200),
}
for key in expected:
assert abs(actual[key] - expected[key]) < 3e-6, (
dim,
cs,
case,
key,
actual,
expected,
)
checks += 1
standard = 1 / tau_closed(tau0, cs, pd)
if case == "pure_shear" and cs:
assert abs(actual["closed0_iterations200"] - standard) < 3e-6
assert abs(actual["closed1_iterations3"] - standard) > 1e-3
checks += 2
if case == "isotropic" and cs:
assert abs(actual["closed0_iterations3"] - 1 / tau0) < 3e-6
assert actual["closed1_iterations3"] < 1 / tau0 - 1e-3
checks += 2
rows.append(
dict(
dim=dim,
cs=cs,
case=case,
tau0=tau0,
stress=tensor,
actual_omega=actual,
expected_omega=expected,
standard_uncapped_deviatoric_omega=standard,
)
)
hashes = {
name: hashlib.sha256(
(root / "src/CelerisLab/lbm/kernels" / name).read_bytes()
).hexdigest()
for name in HEADERS
}
return dict(
source_root=str(root),
source_headers=hashes,
compiler=subprocess.check_output(["g++", "--version"], text=True).splitlines()[
0
],
host_checks=checks,
gpu_executed=False,
results=rows,
)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=pathlib.Path, default=DEFAULT_ROOT)
ap.add_argument("--output", type=pathlib.Path, required=True)
args = ap.parse_args()
report = run(args.root.resolve())
args.output.write_text(json.dumps(report, indent=2) + "\n")
print(
f"PASS: {report['host_checks']} host checks; exact production header, no GPU; {args.output}"
)
@@ -0,0 +1,37 @@
--- a/src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh
+++ b/src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh
@@ -5,7 +5,9 @@
// nu_t = (C_s * Delta)^2 * |S|, Delta = 1 (lattice unit)
// omega_eff = 1 / (3*(nu + nu_t) + 0.5)
//
-// |S| is estimated from non-equilibrium second-order moments using tau0.
+// |S| = sqrt(2 S_ij S_ij), with Delta = 1 in lattice units.
+// The stress estimate uses self-consistent tau_eff; tau0 is molecular.
+// Closed form uses full stress. Iteration uses deviatoric stress and a cap.
// ============================================================================
#ifndef CELERIS_OPERATORS_TURBULENCE_SMAG_CUH
@@ -65,12 +67,12 @@
#if LES_CLOSED_FORM
// Closed-form Smagorinsky (no iteration):
- // ω = 2 / (τ₀ + √(τ₀² + 18·Cs²·√Q / ρ))
+ // ω = 2 / (τ₀ + √(τ₀² + 18·√2·Cs²·√Q / ρ))
// where Q = Πneq_ij · Πneq_ij (Frobenius norm squared)
const float Q = pixx*pixx + piyy*piyy + 2.0f*pixy*pixy;
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
const float smag_const_sq = LES_CS * LES_CS;
- const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
+ const float discriminant = tau0*tau0 + 18.0f * sqrtf(2.0f) * smag_const_sq * sqrtQ / rho_safe;
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
#else
@@ -140,7 +142,7 @@
+ 2.0f*(pixy*pixy + pixz*pixz + piyz*piyz);
const float sqrtQ = sqrtf(fmaxf(Q, 0.0f));
const float smag_const_sq = LES_CS * LES_CS;
- const float discriminant = tau0*tau0 + 18.0f * smag_const_sq * sqrtQ / rho_safe;
+ const float discriminant = tau0*tau0 + 18.0f * sqrtf(2.0f) * smag_const_sq * sqrtQ / rho_safe;
const float tau_eff = 0.5f * (tau0 + sqrtf(fmaxf(discriminant, tau0*tau0)));
return clamp_omega(1.0f / fmaxf(tau_eff, 0.500001f));
#else
@@ -0,0 +1,10 @@
{
"baseline_commit": "d79e3f6f0a424f5bcc3a1a6d610313e49daf97a2",
"files": {
"baseline/src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "ff7f000297ee0817e255ad9fcb6a13b647e3ddd5b206518d35f38d2895a9f501",
"baseline/src/CelerisLab/lbm/kernels/core/descriptors.cuh": "8440bc14857da9007982650f828a23608eec9f7cc4bf772e8c8dc1239cb45356",
"patched/src/CelerisLab/lbm/kernels/operators/turbulence_smag.cuh": "a3db46c2e7d27530b2d1436e65cdbed0b72cc7e047bfbeef2a640877271b51aa",
"patched/src/CelerisLab/lbm/kernels/core/descriptors.cuh": "8440bc14857da9007982650f828a23608eec9f7cc4bf772e8c8dc1239cb45356"
},
"gpu_run": false
}
+84
View File
@@ -0,0 +1,84 @@
"""Optional exact-header GPU helper probe. Not run in this audit.
Run ONLY through the authorized Horsea executor. Uses existing PyCUDA/NumPy.
This is helper parity, not full collide_dispatch or turbulence validation.
"""
import argparse, json, pathlib
from audit import DEFAULT_ROOT, populations, wrapper
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=pathlib.Path, default=DEFAULT_ROOT)
ap.add_argument("--device", type=int, default=0)
ap.add_argument("--run-gpu", action="store_true")
ap.add_argument("--output", type=pathlib.Path, required=True)
args = ap.parse_args()
if not args.run_gpu:
ap.error("Explicit --run-gpu required; use authorized Horsea executor only")
import numpy as np
import pycuda.driver as cuda
from pycuda.compiler import SourceModule
cuda.init()
ctx = cuda.Device(args.device).make_context()
rows = []
try:
for dim in (2, 3):
for cs in (0.16, 0.0):
for closed, iters in ((1, 3), (0, 3), (0, 200)):
module = SourceModule(
wrapper(dim, closed, cs, iters, gpu=True),
include_dirs=[
str(args.root.resolve() / "src/CelerisLab/lbm/kernels")
],
options=["-std=c++11", "--use_fast_math"],
no_extern_c=True,
)
fn = module.get_function("probe")
for case, kind, p, tau in [
("equilibrium", "equilibrium", 0.0, 0.8),
("pure_shear", "shear", 0.02, 0.8),
("isotropic", "isotropic", 0.02, 0.8),
("cap_active", "shear", 0.2, 0.503),
]:
f, eq = populations(dim, kind, p)
out = np.zeros(1, np.float32)
fn(
cuda.In(np.asarray(f, np.float32)),
cuda.In(np.asarray(eq, np.float32)),
np.float32(1),
np.float32(1 / tau),
cuda.Out(out),
block=(1, 1, 1),
grid=(1, 1),
)
ctx.synchronize()
rows.append(
dict(
dim=dim,
cs=cs,
closed=closed,
iters=iters,
case=case,
omega=float(out[0]),
)
)
args.output.write_text(
json.dumps(
dict(
device=cuda.Device(args.device).name(),
diagnostic_only=True,
results=rows,
),
indent=2,
)
+ "\n"
)
finally:
ctx.pop()
ctx.detach()
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
"""Materialize pinned baseline/candidate headers under a new explicit output path."""
import argparse
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[2]
DATA = HERE / "data"
PIN = "d79e3f6f0a424f5bcc3a1a6d610313e49daf97a2"
def manifest():
result = json.loads((DATA / "source_manifest.json").read_text())
if result["baseline_commit"] != PIN:
raise RuntimeError("Wrong baseline pin")
return result
def verify_candidate(root):
for name, expected in manifest()["files"].items():
if hashlib.sha256((root / name).read_bytes()).hexdigest() != expected:
raise RuntimeError("Candidate header hash mismatch: " + name)
def materialize(repo, output):
"""Copy only the two exact source headers, then patch the isolated copy."""
expected = manifest()
headers = [
Path(name).relative_to("baseline")
for name in expected["files"]
if name.startswith("baseline/")
]
for relative in headers:
wanted = expected["files"][str(Path("baseline") / relative)]
if hashlib.sha256((repo / relative).read_bytes()).hexdigest() != wanted:
raise RuntimeError("Production header does not match d79: " + str(relative))
output.mkdir(parents=True, exist_ok=False)
for mode in ("baseline", "patched"):
for relative in headers:
target = output / mode / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(repo / relative, target)
patch = DATA / "smagorinsky_normalization.patch"
subprocess.run(
["git", "apply", "--check", str(patch)], cwd=output / "patched", check=True
)
subprocess.run(["git", "apply", str(patch)], cwd=output / "patched", check=True)
verify_candidate(output)
(output / "source_manifest.json").write_text(json.dumps(expected, indent=2) + "\n")
return output
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, default=REPO)
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
materialize(args.repo.resolve(), args.output_dir.resolve())
print("Pinned baseline/candidate headers verified; production files unchanged.")
+44
View File
@@ -0,0 +1,44 @@
"""Serial pinned baseline/candidate helper diagnostic; existing CUDA environment only."""
import argparse
from pathlib import Path
import subprocess
import sys
from prepare import HERE, REPO, materialize, verify_candidate
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo", type=Path, default=REPO)
parser.add_argument("--device", type=int, default=0)
parser.add_argument("--run-gpu", action="store_true")
parser.add_argument("--output-dir", type=Path, required=True)
args = parser.parse_args()
if not args.run_gpu:
parser.error("Explicit --run-gpu required; authorized GPU executor only")
output = args.output_dir.resolve()
output.mkdir(parents=True, exist_ok=False)
candidate = materialize(args.repo.resolve(), output / "candidate")
verify_candidate(candidate)
for mode in ("baseline", "patched"):
subprocess.run(
[
sys.executable,
str(HERE / "gpu_probe.py"),
"--root",
str(candidate / mode),
"--device",
str(args.device),
"--run-gpu",
"--output",
str(output / (mode + ".json")),
],
check=True,
)
print(
"Both raw helper probes completed; this is not solver or turbulence acceptance."
)
if __name__ == "__main__":
main()
+216
View File
@@ -0,0 +1,216 @@
"""Independent tensor/oracle tests against host-compiled exact copied headers."""
import argparse, ctypes, hashlib, itertools, json, math, pathlib, subprocess, sys, tempfile
HERE = pathlib.Path(__file__).resolve().parent
from audit import directions, wrapper
def tensor(dim, kind, p, a=0, b=1):
t = [[0.0] * dim for _ in range(dim)]
if kind == "shear":
t[a][b] = t[b][a] = p
elif kind == "extension":
t[a][a] = p
t[b][b] = -p
elif kind == "isotropic":
for i in range(dim):
t[i][i] = p
elif kind != "equilibrium":
raise ValueError(kind)
return t
def state(dim, rho, t):
# Second-order Hermite projection: sum c_i c_i fneq_i = prescribed tensor.
# D2Q9/D3Q19 fourth-order isotropy makes this a distinct construction from audit.py.
c = directions(dim)
w = [
(
(4 / 9 if dim == 2 else 1 / 3)
if not any(v)
else (
(1 / 9 if dim == 2 else 1 / 18)
if sum(x * x for x in v) == 1
else 1 / 36
)
)
for v in c
]
eq = [rho * wi for wi in w]
neq = [
4.5
* wi
* sum(
(v[a] * v[b] - (1 / 3 if a == b else 0)) * t[a][b]
for a in range(dim)
for b in range(dim)
)
for wi, v in zip(w, c)
]
assert abs(sum(neq)) < 1e-12
for a in range(dim):
assert abs(sum(v[a] * f for v, f in zip(c, neq))) < 1e-12
for b in range(dim):
assert abs(sum(v[a] * v[b] * f for v, f in zip(c, neq)) - t[a][b]) < 1e-12
return [x + y for x, y in zip(eq, neq)], eq
def norm(t):
return math.sqrt(sum(x * x for row in t for x in row))
def standard_tau(tau0, cs, p, rho):
# Solve tau*(tau-tau0)=9 Cs^2 P/(sqrt(2) rho), independently via bisection.
k = 9 * cs * cs * p / (math.sqrt(2) * rho)
lo = tau0
hi = tau0 + max(1.0, k / tau0)
for _ in range(90):
mid = (lo + hi) / 2
if mid * (mid - tau0) > k:
hi = mid
else:
lo = mid
return (lo + hi) / 2
def build(root, mode, dim, cs, closed, iters, tmp):
name = f"{mode}_d{dim}_cs{cs}_c{closed}_i{iters}"
src = tmp / (name + ".cpp")
lib = tmp / (name + ".so")
src.write_text(wrapper(dim, closed, cs, iters))
subprocess.run(
[
"g++",
"-std=c++11",
"-O2",
"-shared",
"-fPIC",
"-I",
str(root / mode / "src/CelerisLab/lbm/kernels"),
str(src),
"-o",
str(lib),
],
check=True,
capture_output=True,
)
dll = ctypes.CDLL(str(lib))
fn = dll.probe
fn.argtypes = [ctypes.POINTER(ctypes.c_float)] * 2 + [ctypes.c_float] * 2
fn.restype = ctypes.c_float
def call(f, eq, rho, tau0):
ar = ctypes.c_float * len(f)
return float(fn(ar(*f), ar(*eq), rho, 1 / tau0))
return call
def run(root):
n = 0
maxerr = 0.0
maxres = 0.0
examples = []
with tempfile.TemporaryDirectory(prefix="les-candidate-") as tmp:
for dim, cs in itertools.product((2, 3), (0.0, 0.08, 0.16, 0.24)):
kernels = {
(mode, c, i): build(root, mode, dim, cs, c, i, pathlib.Path(tmp))
for mode in ("baseline", "patched")
for c, i in ((1, 3), (0, 3), (0, 200))
}
for rho, tau0 in itertools.product((0.7, 1.0, 1.3), (0.6, 0.8, 1.2)):
# Pure shear/extension all coordinate planes and both signs; Pi/rho fixed under density scaling.
cases = [("equilibrium", 0.0, 0, 1), ("isotropic", 0.01 * rho, 0, 1)]
cases += [
(kind, sign * 0.02 * rho, a, b)
for kind in ("shear", "extension")
for sign in (-1, 1)
for a, b in itertools.combinations(range(dim), 2)
]
for kind, p, a, b in cases:
t = tensor(dim, kind, p, a, b)
f, eq = state(dim, rho, t)
P = norm(t)
out = {key: fn(f, eq, rho, tau0) for key, fn in kernels.items()}
want = 1 / standard_tau(tau0, cs, P, rho)
got = out["patched", 1, 3]
err = abs(got - want)
maxerr = max(maxerr, err)
assert err < 3e-6, (dim, cs, rho, tau0, kind, p, a, b, got, want)
tau = 1 / got
res = abs(
tau * (tau - tau0) - 9 * cs * cs * P / (math.sqrt(2) * rho)
)
maxres = max(maxres, res)
assert res < 2e-6, (res, dim, kind)
# No edits to iterative path: exact float results must remain identical.
for it in (3, 200):
assert out["baseline", 0, it] == out["patched", 0, it]
if kind in ("shear", "extension", "equilibrium") or cs == 0:
assert abs(out["patched", 0, 200] - want) < 3e-6
# Three iterations need not equal closed form; they approach the same root.
assert (
abs(out["patched", 0, 200] - want)
<= abs(out["patched", 0, 3] - want) + 3e-7
)
if kind == "equilibrium" or cs == 0:
assert abs(out["baseline", 1, 3] - got) < 3e-6
elif kind in ("shear", "extension"):
assert out["baseline", 1, 3] > got
if (
rho == 1.0
and tau0 == 0.8
and cs == 0.16
and p >= 0
and a == 0
and b == 1
):
examples.append(
dict(
dim=dim,
kind=kind,
baseline_closed=out["baseline", 1, 3],
patched_closed=got,
iterative3=out["patched", 0, 3],
iterative200=out["patched", 0, 200],
standard=want,
)
)
n += 1
# Explicit fixed-P/nonunit-rho test, distinct from scaling Pi and rho together.
for rho in (0.7, 1.3):
t = tensor(dim, "shear", 0.02)
f, eq = state(dim, rho, t)
got = kernels["patched", 1, 3](f, eq, rho, 0.8)
assert abs(got - 1 / standard_tau(0.8, cs, norm(t), rho)) < 3e-6
n += 1
# Cap path and trace behavior unchanged; arbitrary stress is a helper-only diagnostic.
t = tensor(dim, "shear", 0.2)
f, eq = state(dim, 1.0, t)
for it in (3, 200):
assert kernels["baseline", 0, it](f, eq, 1.0, 0.503) == kernels[
"patched", 0, it
](f, eq, 1.0, 0.503)
return dict(
parameter_cases=n,
max_closed_omega_error=maxerr,
max_quadratic_residual=maxres,
examples=examples,
gpu_run=False,
)
if __name__ == "__main__":
from prepare import verify_candidate
ap = argparse.ArgumentParser()
ap.add_argument("--candidate-dir", type=pathlib.Path, required=True)
ap.add_argument("--output", type=pathlib.Path, required=True)
args = ap.parse_args()
if args.output.exists():
ap.error("Refusing existing evidence file")
verify_candidate(args.candidate_dir)
result = run(args.candidate_dir.resolve())
args.output.write_text(json.dumps(result, indent=2) + "\n")
print(json.dumps(result, indent=2))