Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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/`.
Comment thread
MakisH marked this conversation as resolved.
Outdated
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
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
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"
Comment thread
MakisH marked this conversation as resolved.
Outdated


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},
)

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.

In this run, fieldcompare complained about the force in the last time window:

field-compare-1  | Comparing '/runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-05-084014/precice-exports/Solid-Mesh-Solid.dt500.vtu'
field-compare-1  |       and '/runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-05-084014/reference-results-unpacked/fluid-openfoam_solid-calculix/Solid-Mesh-Solid.dt500.vtu'
field-compare-1  |   Reading '/runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-05-084014/precice-exports/Solid-Mesh-Solid.dt500.vtu'
field-compare-1  |   Reading '/runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-05-084014/reference-results-unpacked/fluid-openfoam_solid-calculix/Solid-Mesh-Solid.dt500.vtu'
field-compare-1  |    -- Comparing the field 'Force': FAILED
field-compare-1  |      -- Report: Deviation above tolerance detected -> [ 8.05858e-03 -5.47671e-05  0.00000e+00] vs. [ 8.05464e-03 -5.46938e-05  0.00000e+00] ([ 0.05 -0.13   inf] %)
field-compare-1  |      -- Predicate: DefaultEquality (abs_tol: 0., rel_tol: 3.e-07)
field-compare-1  |   Wrote diff into '/runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-05-084014/precice-exports/diff_Solid-Mesh-Solid.dt500.vtu.vtu'
field-compare-1  |   File comparison FAILED with 2 PASSED / 1 FAILED / 0 SKIPPED

but looking at the image, this difference is not clearly visible:

Image

The [-1.0,1.0] range is a bit arbitrary. Why is that being picked? I would generally rely on the color scale and range to understand how large a regression is.

Also, if the difference is visualized (and not the absolute value of it), why have the range symmetric? There must be a minimum value and a maximum value.

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.

Notes from our discussion:

  • We should show the range of the diff files
  • Even if the values tend to zero, if that doesn't cause any issues, let's have the real values: seeing a range -1.23e-33 to +4.56e-33 is also clear, and it is how ParaView does it
  • We noticed that the visualizations for the Solid participant are missing from the picture.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I have updated the color scale to use the real min/max from the diff field (clim=(vmin, vmax)), including near-zero ranges, and show that range in the overlay.
On the Solid side: I re-checked locally with real CI diffs and synthetic Solid/Force cases, Solid PNGs are produced. Some look almost uniform when the diff is ~0 (e.g. range 0 to 0); that is a real zero-difference field.

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.

Still, fieldcompare reports:

field-compare-1  | [ FAILED  ] runs/perpendicular-flap_fluid-openfoam-solid-calculix_2026-08-13-152820/precice-exports/Solid-Mesh-Fluid.dt500.vtu: ('Force')

but the archive does not include the respective files, so I cannot yet check if this was fixed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 8ee8a5ea. Root cause was the 300s timeout while glyphs were still on Fluid files. We now use point sprites on dense meshes, parallel workers, 900s timeout, and system-tests-compare-diff.log. On your flap diff-results locally: ~2 min, 4509 PNGs, Solid-Mesh-Solid Force including dt500.

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}")

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.

In https://github.com/precice/tutorials/actions/runs/31705035580, I still don't see the images for the Solid-Mesh-Solid files. Since there are no logs, I cannot see if these files triggered this exception.

We need a log file, probably called system-tests-compare-diff.log, next to system-tests-compare.log.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

the visualizer was killed mid-run before Solid finished. Re-ran on the same flap artifact, Solid-Mesh-Fluid and Solid-Mesh-Solid Force PNGs are produced now .

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())