Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changelog-entries/441.md
Original file line number Diff line number Diff line change
@@ -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/`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Links to both PRs should be mentioned here. It looks like this previously slipped.

2 changes: 1 addition & 1 deletion tools/tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tools/tests/requirements.txt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like we need more dependencies. Running on GHA, I get:

2026-07-18 08:56:07.732 (   2.224s) [    7FE8965761C0]vtkOpenGLRenderWindow.c:645   WARN| Failed to load EGL! Please install the EGL library from your distribution's package manager.
2026-07-18 08:56:07.732 (   2.225s) [    7FE8965761C0]vtkOSOpenGLRenderWindow:149   WARN| libOSMesa not found. It appears that OSMesa is not installed in your system. Please install the OSMesa library from your distribution's package manager.
2026-07-18 08:56:07.734 (   2.226s) [    7FE8965761C0]vtkOpenGLRenderWindow.c:645   WARN| Failed to load EGL! Please install the EGL library from your distribution's package manager.
2026-07-18 08:56:07.734 (   2.226s) [    7FE8965761C0]vtkOSOpenGLRenderWindow:149   WARN| libOSMesa not found. It appears that OSMesa is not installed in your system. Please install the OSMesa library from your distribution's package manager.
CRITICAL: Fieldcompare returned non zero exit code, therefore Channel transport (fluid-openfoam, transport-openfoam) failed

Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
jinja2
pyvista
pyyaml
36 changes: 36 additions & 0 deletions tools/tests/systemtests/Systemtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import re
import logging
import os
import sys


GLOBAL_TIMEOUT = int(os.environ.get("PRECICE_SYSTEMTESTS_TIMEOUT", 180))
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
204 changes: 204 additions & 0 deletions tools/tests/visualize_fieldcompare_diffs.py

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The respective images could also be added to the _logs build artifact in the GHA workflow.

Even better would be if we could add them on the job summary. I guess that images on job summaries are supported, but I am not sure where to source the image from.

Original file line number Diff line number Diff line change
@@ -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())