988 lines
37 KiB
Python
Executable file
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())
|