diff --git a/kwave/compat.py b/kwave/compat.py index 7dcb074e..a077c8fc 100644 --- a/kwave/compat.py +++ b/kwave/compat.py @@ -70,5 +70,9 @@ def options_to_kwargs(simulation_options=None, execution_options=None): kwargs["num_threads"] = opts.num_threads if opts.device_num is not None: kwargs["device_num"] = opts.device_num + # Read _binary_path directly: the property auto-resolves to a default, + # so it can't distinguish a user-set path from one. + if opts._binary_path is not None: + kwargs["binary_path"] = opts._binary_path return kwargs diff --git a/kwave/kspaceFirstOrder.py b/kwave/kspaceFirstOrder.py index 3a71cccd..b10c0767 100644 --- a/kwave/kspaceFirstOrder.py +++ b/kwave/kspaceFirstOrder.py @@ -102,6 +102,7 @@ def kspaceFirstOrder( debug: bool = False, num_threads: Optional[int] = None, device_num: Optional[int] = None, + binary_path: Optional[str] = None, ) -> dict: """Run a k-Wave simulation. @@ -154,6 +155,9 @@ def kspaceFirstOrder( num_threads: Thread count for the C++ OMP binary. ``None`` uses all available cores. Default ``None``. device_num: GPU device index for CUDA execution. Default ``None``. + binary_path: Path to a custom C++ binary. When ``None`` (default), + the binary bundled with ``k-wave-data`` is used. Only applies + when ``backend="cpp"``. Returns: dict: Recorded sensor data keyed by field name (e.g. @@ -247,7 +251,7 @@ def kspaceFirstOrder( if not pml_inside: result["pml_size"] = pml_size return result - result = cpp_sim.run(device=device, num_threads=num_threads, device_num=device_num, quiet=quiet, debug=debug, data_path=data_path) + result = cpp_sim.run(device=device, num_threads=num_threads, device_num=device_num, quiet=quiet, debug=debug, data_path=data_path, binary_path=binary_path) # --- Post-processing: strip PML from full-grid fields --- diff --git a/kwave/solvers/cpp_simulation.py b/kwave/solvers/cpp_simulation.py index 545898b4..fe465c7c 100644 --- a/kwave/solvers/cpp_simulation.py +++ b/kwave/solvers/cpp_simulation.py @@ -9,6 +9,7 @@ import stat import subprocess import tempfile +from pathlib import Path import numpy as np @@ -48,14 +49,14 @@ def prepare(self, data_path=None): self._write_hdf5(input_file) return input_file, output_file - def run(self, *, device="cpu", num_threads=None, device_num=None, quiet=False, debug=False, data_path=None): + def run(self, *, device="cpu", num_threads=None, device_num=None, quiet=False, debug=False, data_path=None, binary_path=None): import warnings cleanup = data_path is None input_file, output_file = self.prepare(data_path=data_path) data_dir = os.path.dirname(input_file) try: - self._execute(input_file, output_file, device=device, num_threads=num_threads, device_num=device_num, quiet=quiet, debug=debug) + self._execute(input_file, output_file, device=device, num_threads=num_threads, device_num=device_num, quiet=quiet, debug=debug, binary_path=binary_path) result = self._parse_output(output_file) result = self._fix_output_order(result) return result @@ -311,17 +312,48 @@ def _get_absorbing_flag(self): return 2 # Stokes return 1 # Power-law - def _execute(self, input_file, output_file, *, device, num_threads, device_num, quiet, debug): - """Run the C++ k-Wave binary.""" + @staticmethod + def _resolve_binary_path(device: str, binary_path: str | None = None) -> Path: + """Resolve the path to the C++ k-Wave binary. + + When *binary_path* is provided the caller's custom binary is used and + verified to exist. Otherwise the bundled binary is selected based on + *device* (``"gpu"`` → CUDA, anything else → OMP) and verified to exist. + + Args: + device: ``"cpu"`` or ``"gpu"``. + binary_path: Optional path to a custom binary. When ``None`` the + bundled binary that ships with ``k-wave-data`` is used. + + Returns: + Resolved :class:`~pathlib.Path` pointing at the binary. + + Raises: + FileNotFoundError: When the binary does not exist at the resolved + path. + ValueError: When ``device="gpu"`` is requested on macOS where no + CUDA binary is available. + """ + if binary_path is not None: + resolved = Path(binary_path) + if not resolved.exists(): + raise FileNotFoundError(f"Custom C++ binary not found at {resolved}.") + return resolved + import kwave binary_name = "kspaceFirstOrder-CUDA" if device == "gpu" else "kspaceFirstOrder-OMP" - binary_path = kwave.BINARY_PATH / binary_name - if not binary_path.exists(): + resolved = kwave.BINARY_PATH / binary_name + if not resolved.exists(): if kwave.PLATFORM == "darwin" and device == "gpu": raise ValueError("GPU simulations are not supported on macOS. Use device='cpu'.") - raise FileNotFoundError(f"C++ binary not found at {binary_path}. Install with: pip install k-wave-data") - binary_path.chmod(binary_path.stat().st_mode | stat.S_IEXEC) + raise FileNotFoundError(f"C++ binary not found at {resolved}. Install with: pip install k-wave-data") + return resolved + + def _execute(self, input_file, output_file, *, device, num_threads, device_num, quiet, debug, binary_path=None): + """Run the C++ k-Wave binary.""" + resolved = self._resolve_binary_path(device, binary_path) + resolved.chmod(resolved.stat().st_mode | stat.S_IEXEC) # Build command-line options options = ["-i", input_file, "-o", output_file] @@ -336,7 +368,7 @@ def _execute(self, input_file, output_file, *, device, num_threads, device_num, elif debug: options.extend(["--verbose", "2"]) - command = [str(binary_path)] + options + command = [str(resolved)] + options try: subprocess.run(command, capture_output=quiet, text=True, check=True) except subprocess.CalledProcessError as e: diff --git a/tests/test_compat.py b/tests/test_compat.py index 63d6d251..498b2001 100644 --- a/tests/test_compat.py +++ b/tests/test_compat.py @@ -71,6 +71,11 @@ def test_device_num(self): kwargs = options_to_kwargs(execution_options=opts) assert kwargs["device_num"] == 1 + def test_binary_path(self): + opts = SimulationExecutionOptions(is_gpu_simulation=False, backend="OMP", binary_path="./kspaceFirstOrder-OMP") + kwargs = options_to_kwargs(execution_options=opts) + assert kwargs.get("binary_path") == "./kspaceFirstOrder-OMP" + class TestCombined: def test_both_options(self): diff --git a/tests/test_cpp_simulation.py b/tests/test_cpp_simulation.py new file mode 100644 index 00000000..c2171028 --- /dev/null +++ b/tests/test_cpp_simulation.py @@ -0,0 +1,82 @@ +"""Unit tests for CppSimulation._resolve_binary_path.""" +import stat +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from kwave.solvers.cpp_simulation import CppSimulation + + +class TestResolveBinaryPath: + """Tests for CppSimulation._resolve_binary_path(). + + chmod is exercised separately in test_execute_makes_binary_executable + because it lives in _execute(), not the resolver. + """ + + def test_custom_path_existing_file_is_returned(self, tmp_path): + binary = tmp_path / "my-kwave-binary" + binary.write_text("#!/bin/sh\n") + resolved = CppSimulation._resolve_binary_path("cpu", binary_path=str(binary)) + assert isinstance(resolved, Path) + assert resolved == binary + + def test_custom_path_missing_raises_file_not_found(self, tmp_path): + missing = tmp_path / "does-not-exist" + with pytest.raises(FileNotFoundError, match="Custom C\\+\\+ binary not found"): + CppSimulation._resolve_binary_path("cpu", binary_path=str(missing)) + + def test_custom_path_used_regardless_of_device(self, tmp_path): + binary = tmp_path / "my-cuda-binary" + binary.write_text("#!/bin/sh\n") + # device="gpu" must not trigger the macOS CUDA guard when a custom path is given. + resolved = CppSimulation._resolve_binary_path("gpu", binary_path=str(binary)) + assert resolved == binary + + def test_default_cpu_selects_omp_binary(self, tmp_path): + omp_binary = tmp_path / "kspaceFirstOrder-OMP" + omp_binary.write_text("#!/bin/sh\n") + with patch("kwave.BINARY_PATH", tmp_path), patch("kwave.PLATFORM", "linux"): + resolved = CppSimulation._resolve_binary_path("cpu") + assert resolved.name == "kspaceFirstOrder-OMP" + + def test_default_gpu_selects_cuda_binary(self, tmp_path): + cuda_binary = tmp_path / "kspaceFirstOrder-CUDA" + cuda_binary.write_text("#!/bin/sh\n") + with patch("kwave.BINARY_PATH", tmp_path), patch("kwave.PLATFORM", "linux"): + resolved = CppSimulation._resolve_binary_path("gpu") + assert resolved.name == "kspaceFirstOrder-CUDA" + + def test_default_missing_omp_raises_file_not_found(self, tmp_path): + with patch("kwave.BINARY_PATH", tmp_path), patch("kwave.PLATFORM", "linux"): + with pytest.raises(FileNotFoundError, match="pip install k-wave-data"): + CppSimulation._resolve_binary_path("cpu") + + def test_default_missing_cuda_on_linux_raises_file_not_found(self, tmp_path): + with patch("kwave.BINARY_PATH", tmp_path), patch("kwave.PLATFORM", "linux"): + with pytest.raises(FileNotFoundError, match="pip install k-wave-data"): + CppSimulation._resolve_binary_path("gpu") + + def test_default_gpu_on_macos_raises_value_error(self, tmp_path): + # macOS + gpu must raise before the binary-existence check. + with patch("kwave.BINARY_PATH", tmp_path), patch("kwave.PLATFORM", "darwin"): + with pytest.raises(ValueError, match="not supported on macOS"): + CppSimulation._resolve_binary_path("gpu") + + @pytest.mark.skipif(sys.platform == "win32", reason="Windows does not honor Unix executable bits") + def test_execute_makes_binary_executable(self, tmp_path, monkeypatch): + binary = tmp_path / "kspaceFirstOrder-OMP" + binary.write_bytes(b"") + binary.chmod(binary.stat().st_mode & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)) + assert not (binary.stat().st_mode & stat.S_IEXEC) + + monkeypatch.setattr(CppSimulation, "_resolve_binary_path", staticmethod(lambda device, binary_path=None: binary)) + monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None) + + sim = CppSimulation.__new__(CppSimulation) + sim._execute("input.h5", "output.h5", device="cpu", num_threads=None, device_num=None, quiet=False, debug=False) + + assert binary.stat().st_mode & stat.S_IEXEC