diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 42983d7c3..1ef1f3e87 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -83,6 +83,7 @@ jobs: cd ../../ - name: Archive system test logs if: ${{ always() }} + id: archive-logs uses: actions/upload-artifact@v7 with: name: system_tests_run_${{ github.run_id }}_${{ github.run_attempt }}_logs @@ -90,7 +91,28 @@ jobs: runs/*/system-tests-build.log runs/*/system-tests-run.log runs/*/system-tests-compare.log + runs/*/system-tests-compare-diff.log runs/*/*/system-tests_*.log + if-no-files-found: warn + - name: Archive fieldcompare diff visualizations + if: ${{ always() }} + id: archive-diffs + uses: actions/upload-artifact@v7 + with: + name: system_tests_run_${{ github.run_id }}_${{ github.run_attempt }}_diffs + path: | + runs/*/diff-results/visualizations/**/*.png + if-no-files-found: warn + - name: Link diff visualizations in the job summary + if: ${{ always() && steps.archive-diffs.outputs.artifact-url != '' }} + run: | + { + echo "" + echo "## Diff visualizations" + echo "" + echo "When fieldcompare fails, PNG renders of the archived diff VTK fields are included in the [\`_diffs\` artifact](${{ steps.archive-diffs.outputs.artifact-url }})." + echo "Look under \`runs/*/diff-results/visualizations/\`." + } >> "$GITHUB_STEP_SUMMARY" - name: Archive run files if: ${{ failure() || inputs.upload_artifacts == 'TRUE' }} uses: actions/upload-artifact@v7 diff --git a/changelog-entries/441.md b/changelog-entries/441.md index 3e3fa3638..0cd006948 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), [#740](https://github.com/precice/tutorials/pull/740), [#883](https://github.com/precice/tutorials/pull/883)). diff --git a/tests/README.md b/tests/README.md index d570d235b..4f64abb39 100644 --- a/tests/README.md +++ b/tests/README.md @@ -90,6 +90,7 @@ Each of these directories includes the usual tutorial case files and logs, as we 1. `system-tests-build.log`: The logs of building the respective components. 2. `system-tests-run.log`: The logs of running the simulation (intermixed, from all participants). 3. `system-tests-compare.log`: The logs for the comparison to the reference results. +4. `system-tests-compare-diff.log`: Progress and errors from rendering fieldcompare diff visualizations. Only present when comparison fails and visualization ran. In addition, in the directories of the cases executed, you can find `system-tests-.log` files. @@ -99,7 +100,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 and accompanied by visualization in `diff-results/visualizations/`. - `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/tests/docker-compose.field_compare.template.yaml): @@ -120,13 +121,25 @@ The differences are only shown per file, and there is no global metric or other Alternatively, [visualize the `precice-exports/diff_*.vtu` in ParaView](https://precice.org/configuration-export.html#visualization-with-paraview). +To regenerate the PNG visualizations locally from an archived `diff-results/` folder (for example after downloading a CI artifact): + +```bash +python3 visualize_fieldcompare_diffs.py /path/to/diff-results +``` + +The script prints progress per file (`[3/40] Rendering ...`) and uses several worker processes. Sparse meshes keep sphere glyphs; denser clouds use point sprites so large cases finish in minutes rather than tens of minutes. + +The default image size is set by `WINDOW_SIZE` in `visualize_fieldcompare_diffs.py` (currently `1024 x 768`). Increase it for higher-resolution PNGs, e.g. `WINDOW_SIZE = (1920, 1080)`. + +For PDF output instead of PNG, replace the `plotter.show(screenshot=...)` call in `render_field()` with `plotter.render()` followed by `plotter.save_graphic(str(output_file.with_suffix(".pdf")))`. + ### Re-running from CI artifacts When a system test fails in CI, download the **full** artifact: `system_tests_run___full` -(a smaller `_logs` archive contains only log files). The archive contains a shared `runs/` directory: +(a smaller `_logs` archive contains the stage log files; on comparison failures, difference visualizations are in a separate `_diffs` archive. The archives contain a shared `runs/` directory: ```text runs/ @@ -138,6 +151,7 @@ runs/ ├── system-tests-build.log ├── system-tests-run.log ├── system-tests-compare.log + ├── system-tests-compare-diff.log # on comparison failures, when visualization ran └── … ``` @@ -290,6 +304,7 @@ Metadata and workflow/script files: - Multi-stage build Dockerfiles that define how to build each component, in a layered approach - `docker-compose.template.yaml`: Describes how to prepare each test (Docker Compose service template) - `docker-compose.field_compare.template.yaml`: Describes how to compare results with fieldcompare (Docker Compose service template) + - `docker-compose.diff_visualizer.template.yaml`: Describes how to render fieldcompare diff VTK files to PNG images on failure - `components.yaml`: Declares the available components and their parameters/options - `reference-results-metadata.md.template`: Template for reporting the versions and machine used to generate each reference results archive - `reference_versions.yaml`: List of arguments to use for generating the reference results @@ -310,7 +325,8 @@ Implementation scripts: - `tests/` - `systemtests.py`: Main entry point - - `requirements.txt`: Dependencies (jinja2, pyyaml) + - `requirements.txt`: Dependencies (jinja2, pyyaml, pyvista for optional local use of the visualizer script) + - `visualize_fieldcompare_diffs.py`: Renders archived fieldcompare diff VTK files to PNG images (normally run via the `diff_visualizer` Docker stage) - `metadata_parser/`: Reads the YAML files into Python objects (defines the schema) - `systemtests/`: Main implementation classes - `Systemtest.py` diff --git a/tests/docker-compose.diff_visualizer.template.yaml b/tests/docker-compose.diff_visualizer.template.yaml new file mode 100644 index 000000000..9a9e07f7e --- /dev/null +++ b/tests/docker-compose.diff_visualizer.template.yaml @@ -0,0 +1,21 @@ +services: + diff-visualizer: + build: + context: {{ dockerfile_context }} + dockerfile: Dockerfile + target: diff_visualizer + args: + {% for key, value in build_arguments.items() %} + - {{ key }}={{ value }} + {% endfor %} + volumes: + - ./{{ diff_results_folder }}:/diff-results + - ../tests/visualize_fieldcompare_diffs.py:/home/precice/visualize_fieldcompare_diffs.py:ro + environment: + VTK_DEFAULT_OPENGL_WINDOW: vtkOSOpenGLRenderWindow + PYVISTA_OFF_SCREEN: "true" + PYTHONUNBUFFERED: "1" + command: + - /home/precice/venv/bin/python + - /home/precice/visualize_fieldcompare_diffs.py + - /diff-results diff --git a/tests/dockerfiles/ubuntu_2404/Dockerfile b/tests/dockerfiles/ubuntu_2404/Dockerfile index 7a772a2f5..49a871dcd 100644 --- a/tests/dockerfiles/ubuntu_2404/Dockerfile +++ b/tests/dockerfiles/ubuntu_2404/Dockerfile @@ -60,7 +60,8 @@ RUN apt-get -qq update && \ python3-venv \ pkg-config \ wget \ - inotify-tools + inotify-tools \ + libosmesa6 # The following are dependencies of gmsh, needed by some tutorials RUN apt-get -qq update && \ apt-get -qq install \ @@ -73,6 +74,18 @@ USER precice ### end of precice_dependencies stage ### +FROM precice_dependencies AS diff_visualizer +# Headless PNG rendering of fieldcompare diff VTK files (CPU/OSMesa, no display server). +USER precice +WORKDIR /home/precice +ENV VTK_DEFAULT_OPENGL_WINDOW=vtkOSOpenGLRenderWindow +ENV PYVISTA_OFF_SCREEN=true +RUN python3 -m venv /home/precice/venv && \ + . /home/precice/venv/bin/activate && \ + pip3 install --no-cache-dir pyvista +### end of diff_visualizer stage ### + + FROM precice_dependencies AS precice # Build & install precice into /home/precice/precice ARG PRECICE_PR diff --git a/tests/requirements-reference.txt b/tests/requirements-reference.txt index 900fdeacc..47593eb36 100644 --- a/tests/requirements-reference.txt +++ b/tests/requirements-reference.txt @@ -2,7 +2,8 @@ # Reference only: run scripts keep using loose requirements.txt constraints. # Generated by tools/releasing/update-requirements-reference.py — do not edit manually. # Source: tests/requirements.txt -# Generated: 2026-07-13T16:48:40Z +# Generated: 2026-07-19T05:18:03Z jinja2==3.1.6 +pyvista==0.48.4 pyyaml==6.0.3 diff --git a/tests/requirements.txt b/tests/requirements.txt index df67e0dd1..999d2102a 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,2 +1,3 @@ jinja2 +pyvista pyyaml \ No newline at end of file diff --git a/tests/systemtests/Systemtest.py b/tests/systemtests/Systemtest.py index 5e497d9bc..88eca074c 100644 --- a/tests/systemtests/Systemtest.py +++ b/tests/systemtests/Systemtest.py @@ -30,6 +30,10 @@ DIFF_RESULTS_DIR = "diff-results" ITERATIONS_LOGS_DIR = "iterations-logs" +DIFF_VISUALIZER_LOG = "system-tests-compare-diff.log" +DIFF_VISUALIZER_TIMEOUT = int( + os.environ.get("PRECICE_SYSTEMTESTS_DIFF_VISUALIZER_TIMEOUT", 900) +) STAGE_LOG_FILES = { "build": "system-tests-build.log", @@ -214,7 +218,7 @@ def _get_length_of_name(results: List[SystemtestResult]) -> int: with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as f: print("\n\n", file=f) print( - "In case a test fails, download the archive from the bottom of this page and inspect the per-stage logs (`system-tests-build.log`, `system-tests-run.log`, `system-tests-compare.log`). The stage runtimes might already give useful hints.", + "In case a test fails, download the archive from the bottom of this page and inspect the per-stage logs (`system-tests-build.log`, `system-tests-run.log`, `system-tests-compare.log`, and `system-tests-compare-diff.log` when visualizations ran). The stage runtimes might already give useful hints.", file=f) print( "See the [documentation](https://precice.org/dev-docs-system-tests.html#understanding-what-went-wrong).", @@ -805,6 +809,153 @@ def __archive_fieldcompare_diffs(self) -> None: self, ) + def __get_diff_visualizer_compose_file(self) -> str: + platform = self.params_to_use.get("PLATFORM") + render_dict = { + 'dockerfile_context': ( + Path("..") / "tests" / "dockerfiles" / Path(platform) + ), + 'build_arguments': self.params_to_use, + 'diff_results_folder': DIFF_RESULTS_DIR, + } + jinja_env = Environment(loader=FileSystemLoader(PRECICE_TESTS_DIR)) + template = jinja_env.get_template( + "docker-compose.diff_visualizer.template.yaml") + return template.render(render_dict) + + def __append_diff_visualizer_status(self, status: str, elapsed_s: float) -> None: + log_path = self.system_test_dir / DIFF_VISUALIZER_LOG + with log_path.open("a", encoding="utf-8") as log_file: + log_file.write(f"\nstatus: {status}\nelapsed_s: {elapsed_s:.1f}\n") + + def __visualize_fieldcompare_diffs(self) -> None: + """Best-effort rendering of archived fieldcompare diff VTK files via Docker.""" + diff_results_dir = self.system_test_dir / DIFF_RESULTS_DIR + if not diff_results_dir.is_dir(): + return + + compose_path = self.system_test_dir / "docker-compose.diff_visualizer.yaml" + log_path = self.system_test_dir / DIFF_VISUALIZER_LOG + log_path.write_text("=== compare-diff ===\n", encoding="utf-8") + log_lock = threading.Lock() + time_start = time.perf_counter() + + try: + compose_path.write_text( + self.__get_diff_visualizer_compose_file(), encoding="utf-8") + except OSError as error: + elapsed_s = time.perf_counter() - time_start + self.__append_diff_visualizer_status(f"error: {error}", elapsed_s) + logging.warning( + "Could not render fieldcompare diff visualizations for %s: %s", + self, + error, + ) + return + + logging.info( + "Rendering fieldcompare diff visualizations for %s " + "(timeout %ss)", + self, + DIFF_VISUALIZER_TIMEOUT, + ) + try: + process = subprocess.Popen( + [ + "docker", + "compose", + "--file", + compose_path.name, + "up", + "--exit-code-from", + "diff-visualizer", + "--abort-on-container-exit", + ], + cwd=self.system_test_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + except OSError as error: + elapsed_s = time.perf_counter() - time_start + self.__append_diff_visualizer_status(f"error: {error}", elapsed_s) + logging.warning( + "Could not render fieldcompare diff visualizations for %s: %s", + self, + error, + ) + return + + def read_stream(stream, prefix: str) -> None: + if stream is None: + return + for line in stream: + line = line.rstrip("\n\r") + with log_lock: + with log_path.open("a", encoding="utf-8") as log_file: + log_file.write(f"{prefix}{line}\n") + stream.close() + + stdout_thread = threading.Thread( + target=read_stream, args=(process.stdout, ""), daemon=True) + stderr_thread = threading.Thread( + target=read_stream, args=(process.stderr, "[stderr] "), daemon=True) + stdout_thread.start() + stderr_thread.start() + + timed_out = False + try: + exit_code = process.wait(timeout=DIFF_VISUALIZER_TIMEOUT) + except subprocess.TimeoutExpired: + timed_out = True + process.kill() + try: + process.wait(timeout=SHORT_TIMEOUT) + except subprocess.TimeoutExpired: + pass + exit_code = process.returncode if process.returncode is not None else 1 + + stdout_thread.join(timeout=SHORT_TIMEOUT) + stderr_thread.join(timeout=SHORT_TIMEOUT) + elapsed_s = time.perf_counter() - time_start + + if timed_out: + self.__append_diff_visualizer_status( + f"timed out after {DIFF_VISUALIZER_TIMEOUT}s", elapsed_s + ) + logging.warning( + "Could not render fieldcompare diff visualizations for %s: " + "timed out after %ss (visualizer ran %.1fs). " + "See %s", + self, + DIFF_VISUALIZER_TIMEOUT, + elapsed_s, + DIFF_VISUALIZER_LOG, + ) + return + + if exit_code != 0: + self.__append_diff_visualizer_status( + f"failed (exit {exit_code})", elapsed_s + ) + logging.warning( + "Rendering fieldcompare diff visualizations failed for %s " + "after %.1fs (exit %s). See %s", + self, + elapsed_s, + exit_code, + DIFF_VISUALIZER_LOG, + ) + return + + self.__append_diff_visualizer_status("ok", elapsed_s) + logging.info( + "Diff visualizations for %s took %.1fs", + self, + elapsed_s, + ) + def __copy_rerun_system_test_script(self) -> None: """Copy tests/rerun-system-test.sh into the run directory for artifact replay.""" rerun_src = PRECICE_TESTS_DIR / "rerun-system-test.sh" @@ -1139,6 +1290,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/tests/visualize_fieldcompare_diffs.py b/tests/visualize_fieldcompare_diffs.py new file mode 100644 index 000000000..0e1977bbf --- /dev/null +++ b/tests/visualize_fieldcompare_diffs.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Render fieldcompare VTK diff fields as PNG images.""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from collections.abc import Iterator +from concurrent.futures import ProcessPoolExecutor, as_completed +from multiprocessing import get_context +from pathlib import Path + +import numpy as np +import pyvista as pv + + +SUPPORTED_SUFFIXES = {".vtk", ".vtp", ".vtu"} +WINDOW_SIZE = (1024, 768) +# Sphere glyphs stay readable on sparse 1D meshes; denser clouds use point sprites. +# Keep this low: glyphed dense meshes were what timed out CI at 300s. +GLYPH_MAX_POINTS = 128 + + +def _log(message: str) -> None: + """Print immediately so CI and redirected local runs show progress.""" + print(message, flush=True) + + +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 _point_size(n_points: int) -> float: + """Screen-space point size that stays visible without filling the window.""" + return float(max(4.0, min(18.0, 350.0 / max(np.sqrt(n_points), 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 as colored points or, for small clouds, sphere glyphs.""" + point_cloud = pv.PolyData(points) + scalar_name = "difference" + point_cloud.point_data[scalar_name] = values + use_glyphs = len(points) <= GLYPH_MAX_POINTS + if use_glyphs: + sphere = pv.Sphere( + radius=_glyph_radius(points), + theta_resolution=8, + phi_resolution=8, + ) + mesh = point_cloud.glyph(orient=False, scale=False, geom=sphere) + mesh_kwargs: dict = {} + else: + mesh = point_cloud + mesh_kwargs = { + "render_points_as_spheres": True, + "point_size": _point_size(len(points)), + } + + output_file.parent.mkdir(parents=True, exist_ok=True) + vmin = float(np.min(values)) + vmax = float(np.max(values)) + plotter = pv.Plotter(off_screen=True, window_size=WINDOW_SIZE) + try: + plotter.set_background("white") + plotter.add_mesh( + mesh, + scalars=scalar_name, + cmap="coolwarm", + clim=(vmin, vmax), + scalar_bar_args={"title": field_name}, + **mesh_kwargs, + ) + plotter.add_text( + ( + f"{source_file.name}\n" + f"point field: {field_name}\n" + f"Difference: computed - reference\n" + f"range: {vmin:.6e} to {vmax:.6e}" + ), + 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) + safe_stem = ( + re.sub(r"[^\w.-]+", "_", relative.stem, flags=re.UNICODE).strip("_.") + or "unnamed" + ) + file_output_dir = output_dir / relative.parent / safe_stem + generated: list[Path] = [] + for field_name, points, values in _fields(dataset): + safe_field = ( + re.sub(r"[^\w.-]+", "_", field_name, flags=re.UNICODE).strip("_.") + or "unnamed" + ) + output_file = file_output_dir / f"point_{safe_field}.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 _worker_count() -> int: + cpu_count = os.cpu_count() or 1 + return max(1, min(cpu_count, 8)) + + +def _visualize_one_file( + diff_file: str, + diff_results_dir: str, + output_dir: str, + index: int, + total: int, +) -> tuple[list[str], str | None]: + """Render one file in a worker process. Returns (output paths, error).""" + path = Path(diff_file) + _log(f"[{index}/{total}] Rendering {path.name}") + try: + generated = visualize_diff_file( + path, Path(diff_results_dir), Path(output_dir) + ) + names = ", ".join(p.name for p in generated) + _log(f"[{index}/{total}] Wrote {len(generated)} image(s) from {path.name}: {names}") + return [str(p) for p in generated], None + except Exception as error: + message = f"Could not visualize {path}: {error}" + _log(f"[{index}/{total}] WARNING: {message}") + return [], message + + +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] = [] + diff_files = 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() + ) + total = len(diff_files) + workers = min(_worker_count(), total) if total else 1 + _log(f"Found {total} fieldcompare diff VTK file(s), using {workers} worker(s)") + if not diff_files: + return generated, errors + + if workers == 1: + for index, diff_file in enumerate(diff_files, start=1): + paths, error = _visualize_one_file( + str(diff_file), + str(diff_results_dir), + str(output_dir), + index, + total, + ) + generated.extend(Path(p) for p in paths) + if error: + errors.append(error) + return generated, errors + + with ProcessPoolExecutor( + max_workers=workers, + mp_context=get_context("spawn"), + ) as executor: + futures = { + executor.submit( + _visualize_one_file, + str(diff_file), + str(diff_results_dir), + str(output_dir), + index, + total, + ): diff_file + for index, diff_file in enumerate(diff_files, start=1) + } + for future in as_completed(futures): + diff_file = futures[future] + try: + paths, error = future.result() + except Exception as error: + errors.append(f"Could not visualize {diff_file}: {error}") + continue + generated.extend(Path(p) for p in paths) + if error: + errors.append(error) + generated.sort() + 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 error in errors: + print(f"WARNING: {error}", file=sys.stderr, flush=True) + + if generated: + _log(f"Wrote {len(generated)} diff visualization(s)") + elif not errors: + _log("No fieldcompare diff VTK files found") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main())