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

988 lines
37 KiB
Python
Executable file

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