openFOAM-RANS-to-GPU/scripts/fuzz_python_stepper.py

475 lines
16 KiB
Python
Executable file

#!/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()