test: add pinned LES baseline and temporary candidate diagnostics
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user