Skip to content
Merged
4 changes: 4 additions & 0 deletions kwave/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 5 additions & 1 deletion kwave/kspaceFirstOrder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 ---

Expand Down
50 changes: 41 additions & 9 deletions kwave/solvers/cpp_simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import stat
import subprocess
import tempfile
from pathlib import Path

import numpy as np

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions tests/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
82 changes: 82 additions & 0 deletions tests/test_cpp_simulation.py
Original file line number Diff line number Diff line change
@@ -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
Loading