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