From b1bea828d8df8c5fdbc13da30ecf71150edd9459 Mon Sep 17 00:00:00 2001 From: PranjalManhgaye Date: Sat, 18 Jul 2026 01:35:53 +0530 Subject: [PATCH] Render fieldcompare diff visualizations --- changelog-entries/441.md | 2 +- tools/tests/README.md | 2 +- tools/tests/requirements.txt | 1 + tools/tests/systemtests/Systemtest.py | 36 ++++ tools/tests/visualize_fieldcompare_diffs.py | 204 ++++++++++++++++++++ 5 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tools/tests/visualize_fieldcompare_diffs.py diff --git a/changelog-entries/441.md b/changelog-entries/441.md index 3e3fa3638..fec981fb4 100644 --- a/changelog-entries/441.md +++ b/changelog-entries/441.md @@ -1 +1 @@ -- Archive fieldcompare diff VTK files into a `diff-results/` folder in each systemtest run directory on failure so they are easy to find in CI artifacts when investigating comparison failures (fixes [#441](https://github.com/precice/tutorials/issues/441)). Nested paths under `precice-exports/` are preserved under `diff-results/`. +- Archive fieldcompare diff VTK files into a `diff-results/` folder in each systemtest run directory on failure so they are easy to find in CI artifacts when investigating comparison failures (fixes [#441](https://github.com/precice/tutorials/issues/441)). Nested paths under `precice-exports/` are preserved under `diff-results/`, and each numeric point field is rendered headlessly as a sphere-glyph PNG in `diff-results/visualizations/`. diff --git a/tools/tests/README.md b/tools/tests/README.md index 3321ed7d5..b4867d531 100644 --- a/tools/tests/README.md +++ b/tools/tests/README.md @@ -99,7 +99,7 @@ When the tests fail at the results comparison step, this typically means that th - `precice-exports/`: The coupling meshes of the test run. - `reference-results/`: The coupling meshes of the reference run, as stored on Git LFS, expanded into `reference-results-unpacked`. For test cases using implicit coupling, the reference `.tar.gz` also contains the reference `precice-*-iterations.log` files. -- `diff-results/`: Numerical difference of the results in the two directories (computed with `fieldcompare dir --diff precice-exports/ reference/`). These are only present on failed comparisons. +- `diff-results/`: Numerical difference of the results in the two directories (computed with `fieldcompare dir --diff precice-exports/ reference/`). These are only present on failed comparisons. The `visualizations/` subdirectory contains one PNG per numeric point field, rendered as sphere glyphs and colored by the difference values. - `iterations-logs/`: The `precice-*-iterations.log` files of the test run. Only present in test cases using implicit coupling. The comparisons to references only take into account the file SHA-256 checksums. To reproduce the comparison locally, use the [same fieldcompare command](https://github.com/precice/tutorials/blob/develop/tools/tests/docker-compose.field_compare.template.yaml): diff --git a/tools/tests/requirements.txt b/tools/tests/requirements.txt index df67e0dd1..999d2102a 100644 --- a/tools/tests/requirements.txt +++ b/tools/tests/requirements.txt @@ -1,2 +1,3 @@ jinja2 +pyvista pyyaml \ No newline at end of file diff --git a/tools/tests/systemtests/Systemtest.py b/tools/tests/systemtests/Systemtest.py index ff21b6796..4a515c71d 100644 --- a/tools/tests/systemtests/Systemtest.py +++ b/tools/tests/systemtests/Systemtest.py @@ -20,6 +20,7 @@ import re import logging import os +import sys GLOBAL_TIMEOUT = int(os.environ.get("PRECICE_SYSTEMTESTS_TIMEOUT", 180)) @@ -790,6 +791,40 @@ def __archive_fieldcompare_diffs(self) -> None: self, ) + def __visualize_fieldcompare_diffs(self) -> None: + """Best-effort rendering of archived fieldcompare diff VTK files.""" + diff_results_dir = self.system_test_dir / DIFF_RESULTS_DIR + if not diff_results_dir.is_dir(): + return + + visualizer = PRECICE_TESTS_DIR / "visualize_fieldcompare_diffs.py" + try: + result = subprocess.run( + [sys.executable, str(visualizer), str(diff_results_dir)], + capture_output=True, + text=True, + timeout=300, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + logging.warning( + "Could not render fieldcompare diff visualizations for %s: %s", + self, + error, + ) + return + + if result.returncode != 0: + details = result.stderr.strip() or result.stdout.strip() + logging.warning( + "Rendering fieldcompare diff visualizations failed for %s: %s", + self, + details, + ) + return + if result.stdout.strip(): + logging.info(result.stdout.strip()) + def __copy_rerun_system_test_script(self) -> None: """Copy tools/tests/rerun-system-test.sh into the run directory for artifact replay.""" rerun_src = PRECICE_TESTS_DIR / "rerun-system-test.sh" @@ -1124,6 +1159,7 @@ def run(self, run_directory: Path): std_err.extend(fieldcompare_result.stderr_data) if fieldcompare_result.exit_code != 0: self.__archive_fieldcompare_diffs() + self.__visualize_fieldcompare_diffs() logging.critical(f"Fieldcompare returned non zero exit code, therefore {self} failed") return SystemtestResult( False, diff --git a/tools/tests/visualize_fieldcompare_diffs.py b/tools/tests/visualize_fieldcompare_diffs.py new file mode 100644 index 000000000..d85970bd1 --- /dev/null +++ b/tools/tests/visualize_fieldcompare_diffs.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""Render fieldcompare VTK diff fields as PNG images.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections.abc import Iterator +from pathlib import Path + +import numpy as np +import pyvista as pv + + +SUPPORTED_SUFFIXES = {".vtk", ".vtp", ".vtu"} +WINDOW_SIZE = (1024, 768) + + +def discover_diff_files(diff_results_dir: Path) -> list[Path]: + """Return supported fieldcompare diff files below a results directory.""" + return sorted( + path + for path in diff_results_dir.rglob("*") + if path.is_file() + and path.suffix.lower() in SUPPORTED_SUFFIXES + and "diff" in path.name.lower() + ) + + +def _safe_name(value: str) -> str: + """Return a filesystem-safe part of an output filename.""" + cleaned = re.sub(r"[^\w.-]+", "_", value, flags=re.UNICODE) + return cleaned.strip("_.") or "unnamed" + + +def _scalar_values(values: np.ndarray) -> np.ndarray | None: + """Return scalar values, using the magnitude for vectors and tensors.""" + array = np.asarray(values) + if not np.issubdtype(array.dtype, np.number): + return None + if array.ndim == 1: + return array + if array.ndim == 2: + return np.linalg.norm(array, axis=1) + return None + + +def _fields( + dataset: pv.DataSet, +) -> Iterator[tuple[str, np.ndarray, np.ndarray]]: + """Yield field names, point locations, and scalar values.""" + locations = np.asarray(dataset.points) + for field_name in dataset.point_data.keys(): + values = _scalar_values(np.asarray(dataset.point_data[field_name])) + if values is None or len(values) != len(locations): + continue + finite = np.isfinite(values) + if finite.any(): + yield field_name, locations[finite], values[finite] + + +def _glyph_radius(points: np.ndarray) -> float: + """Return a radius based on representative nearest-neighbor distances.""" + if len(points) < 2: + return 1.0 + + extent = float(np.max(np.ptp(points, axis=0))) + if extent <= 0: + return 1.0 + + sample = points[np.linspace(0, len(points) - 1, min(len(points), 64), dtype=int)] + nearest_distances = [] + for point in sample: + distances = np.linalg.norm(points - point, axis=1) + distances = distances[distances > extent * 1e-12] + if len(distances): + nearest_distances.append(np.min(distances)) + return 0.2 * float(np.median(nearest_distances)) if nearest_distances else 1.0 + + +def _set_camera(plotter: pv.Plotter, points: np.ndarray) -> None: + """Use a face-on view for planar data and an isometric view otherwise.""" + extents = np.ptp(points, axis=0) + max_extent = float(np.max(extents)) + flat_axis = int(np.argmin(extents)) + if max_extent > 0 and extents[flat_axis] <= max_extent * 1e-6: + (plotter.view_yz, plotter.view_xz, plotter.view_xy)[flat_axis]() + else: + plotter.view_isometric() + plotter.reset_camera() + + +def render_field( + source_file: Path, + output_file: Path, + field_name: str, + points: np.ndarray, + values: np.ndarray, +) -> None: + """Render one field using sphere glyphs colored by its diff values.""" + point_cloud = pv.PolyData(points) + scalar_name = "difference" + point_cloud.point_data[scalar_name] = values + sphere = pv.Sphere( + radius=_glyph_radius(points), + theta_resolution=8, + phi_resolution=8, + ) + glyphs = point_cloud.glyph(orient=False, scale=False, geom=sphere) + + output_file.parent.mkdir(parents=True, exist_ok=True) + plotter = pv.Plotter(off_screen=True, window_size=WINDOW_SIZE) + try: + plotter.set_background("white") + max_abs_value = float(np.max(np.abs(values))) + color_limit = max_abs_value if max_abs_value > 0 else 1.0 + plotter.add_mesh( + glyphs, + scalars=scalar_name, + cmap="coolwarm", + clim=(-color_limit, color_limit), + scalar_bar_args={"title": field_name}, + ) + plotter.add_text( + f"{source_file.name}\npoint field: {field_name}", + font_size=10, + color="black", + ) + _set_camera(plotter, points) + plotter.show(screenshot=str(output_file)) + finally: + plotter.close() + + +def visualize_diff_file( + diff_file: Path, + diff_results_dir: Path, + output_dir: Path, +) -> list[Path]: + """Render every numeric point field in one diff VTK file.""" + dataset = pv.read(diff_file) + if not isinstance(dataset, pv.DataSet): + raise TypeError(f"Unsupported VTK dataset in {diff_file}") + + relative = diff_file.relative_to(diff_results_dir) + file_output_dir = output_dir / relative.parent / _safe_name(relative.stem) + generated: list[Path] = [] + for field_name, points, values in _fields(dataset): + output_file = file_output_dir / f"point_{_safe_name(field_name)}.png" + render_field(diff_file, output_file, field_name, points, values) + generated.append(output_file) + if not generated: + raise ValueError(f"No numeric point fields found in {diff_file}") + return generated + + +def visualize_diff_results( + diff_results_dir: Path, +) -> tuple[list[Path], list[str]]: + """Render all supported fieldcompare diff files below a directory.""" + diff_results_dir = diff_results_dir.resolve() + output_dir = diff_results_dir / "visualizations" + generated: list[Path] = [] + errors: list[str] = [] + for diff_file in discover_diff_files(diff_results_dir): + try: + generated.extend( + visualize_diff_file(diff_file, diff_results_dir, output_dir) + ) + except Exception as error: + errors.append(f"Could not visualize {diff_file}: {error}") + return generated, errors + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Render fieldcompare VTK diff fields as PNG images" + ) + parser.add_argument( + "diff_results_dir", + type=Path, + help="Directory containing archived fieldcompare diff VTK files", + ) + args = parser.parse_args() + + if not args.diff_results_dir.is_dir(): + parser.error(f"Not a directory: {args.diff_results_dir}") + + generated, errors = visualize_diff_results(args.diff_results_dir) + for output_file in generated: + print(f"Wrote {output_file}") + for error in errors: + print(f"WARNING: {error}", file=sys.stderr) + + if generated: + print(f"Wrote {len(generated)} diff visualization(s)") + elif not errors: + print("No fieldcompare diff VTK files found") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())