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
3 changes: 3 additions & 0 deletions kwave/kWaveSimulation_helper/save_to_disk_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ def grab_medium_props(integer_variables, float_variables, medium, is_elastic_cod
integer_variables.absorbing_flag = 0

if medium.absorbing:
from kwave.solvers.validation import warn_cpp_alpha_mode_unsupported

warn_cpp_alpha_mode_unsupported(medium.alpha_mode, stacklevel=4)
if is_elastic_code: # pragma: no cover
# add to the variable list
float_variables["chi"] = None
Expand Down
5 changes: 5 additions & 0 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
import warnings

import numpy as np

Expand Down Expand Up @@ -36,6 +37,10 @@ def __init__(self, kgrid, medium, source, sensor, *, pml_size, pml_alpha, use_sg
self.use_sg = use_sg
self.ndim = kgrid.dim

from kwave.solvers.validation import warn_cpp_alpha_mode_unsupported

warn_cpp_alpha_mode_unsupported(medium.alpha_mode, stacklevel=3)

def prepare(self, data_path=None):
"""Write HDF5 input file. Returns (input_file, output_file)."""
if data_path is None:
Expand Down
19 changes: 13 additions & 6 deletions kwave/solvers/kspace_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,7 @@ def _setup_physics_operators(self):

# Absorption/dispersion
alpha_coeff_raw = getattr(self.medium, "alpha_coeff", 0)
alpha_mode = self.medium.alpha_mode
if not _is_enabled(alpha_coeff_raw):
self._absorption = lambda div_u: 0
self._dispersion = lambda rho: 0
Expand All @@ -419,17 +420,23 @@ def _setup_physics_operators(self):
alpha_power = float(xp.array(getattr(self.medium, "alpha_power", 1.5)).flatten()[0])
alpha_np = 100 * alpha_coeff * (1e-6 / (2 * np.pi)) ** alpha_power / (20 * np.log10(np.e))

no_absorption = alpha_mode == "no_absorption"
no_dispersion = alpha_mode == "no_dispersion"

if abs(alpha_power - 2.0) < 1e-10: # Stokes
self._absorption = lambda div_u: -2 * alpha_np * self.c0 * self.rho0 * div_u
self._absorption = (lambda div_u: 0) if no_absorption else (lambda div_u: -2 * alpha_np * self.c0 * self.rho0 * div_u)
self._dispersion = lambda rho: 0
else: # Power-law with fractional Laplacian
tau = -2 * alpha_np * self.c0 ** (alpha_power - 1)
eta = 2 * alpha_np * self.c0**alpha_power * xp.tan(np.pi * alpha_power / 2)
nabla1 = self._fractional_laplacian(alpha_power - 2)
nabla2 = self._fractional_laplacian(alpha_power - 1)
# Fractional Laplacian already includes full k-space structure; no kappa needed
self._absorption = lambda div_u: tau * self._diff(self.rho0 * div_u, nabla1)
self._dispersion = lambda rho: eta * self._diff(rho, nabla2)
self._absorption = (lambda div_u: 0) if no_absorption else (lambda div_u: tau * self._diff(self.rho0 * div_u, nabla1))

if no_dispersion:
self._dispersion = lambda rho: 0
else:
eta = 2 * alpha_np * self.c0**alpha_power * xp.tan(np.pi * alpha_power / 2)
nabla2 = self._fractional_laplacian(alpha_power - 1)
self._dispersion = lambda rho: eta * self._diff(rho, nabla2)

# Nonlinearity
BonA_raw = getattr(self.medium, "BonA", 0)
Expand Down
21 changes: 21 additions & 0 deletions kwave/solvers/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ def validate_medium(medium, kgrid):
power = float(np.asarray(medium.alpha_power).flat[0])
if power < 0 or power > 3:
warnings.warn(f"medium.alpha_power={power} is outside typical range [0, 3].", stacklevel=3)
alpha_mode = medium.alpha_mode
if abs(power - 1.0) < 0.05 and alpha_mode != "no_dispersion":
raise ValueError(
f"medium.alpha_power={power} is too close to 1.0. The dispersion term "
f"contains tan(pi*alpha_power/2), which diverges at alpha_power=1. "
f"For backend='python': set medium.alpha_mode='no_dispersion' to disable "
f"the dispersion term. For backend='cpp' or to use both absorption and "
f"dispersion, choose an alpha_power value further from 1.0."
)
Comment on lines +51 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Near-unity validation blocks C++ backend with no valid escape hatch

The guard at line 51 raises a ValueError for abs(power - 1.0) < 0.05 unless alpha_mode == 'no_dispersion'. Because validate_simulation is called before backend dispatch in kspaceFirstOrder.py (line 178), this check fires for the C++ backend too.

The problem is the suggested escape hatch — alpha_mode='no_dispersion' — is explicitly unsupported by the C++ backend. Setting it only triggers a separate warning that it will be "silently ignored." So a C++ user with alpha_power=0.97 must:

  1. Set alpha_mode='no_dispersion' to pass validation.
  2. Receive a warning that no_dispersion is silently ignored by the C++ backend.
  3. The C++ binary then runs with the full dispersion formula anyway.

The error message even recommends alpha_mode='no_dispersion' as the solution, but that recommendation is only valid for the Python backend. For C++ users this creates a misleading Catch-22.

Consider either (a) skipping this check when the C++ backend is active (threading the backend parameter through to validate_medium), or (b) updating the error message to clarify it only applies to backend='python' and suggesting alpha_power further from 1.0 as the actionable fix for C++ users:

if abs(power - 1.0) < 0.05 and alpha_mode != "no_dispersion":
    raise ValueError(
        f"medium.alpha_power={power} is too close to 1.0. The dispersion term "
        f"contains tan(pi*alpha_power/2), which diverges at alpha_power=1. "
        f"For backend='python': set medium.alpha_mode='no_dispersion' to disable "
        f"the dispersion term. For backend='cpp' or to use both absorption and "
        f"dispersion, choose an alpha_power value further from 1.0."
    )



def validate_pml(pml_size, kgrid):
Expand Down Expand Up @@ -97,6 +106,18 @@ def validate_source(source, kgrid):
raise ValueError("All velocity source components must either be single (1D) or many (2D) time series.")


def warn_cpp_alpha_mode_unsupported(alpha_mode, stacklevel=3):
"""Warn that the C++ backend cannot honor medium.alpha_mode."""
if alpha_mode in ("no_absorption", "no_dispersion"):
warnings.warn(
f"medium.alpha_mode='{alpha_mode}' is not supported by the C++ backend "
f"and will be silently ignored. The C++ binary always computes both "
f"absorption and dispersion when absorbing_flag > 0. Use backend='python' "
f"to honor alpha_mode.",
stacklevel=stacklevel,
)


def validate_sensor(sensor, kgrid):
if sensor is None or not hasattr(sensor, "mask") or sensor.mask is None:
return
Expand Down
132 changes: 132 additions & 0 deletions tests/test_alpha_power_near_unity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Tests for alpha_power near 1.0 (tan(pi*y/2) singularity)."""
import numpy as np
import pytest

from kwave.kgrid import kWaveGrid
from kwave.kmedium import kWaveMedium
from kwave.ksensor import kSensor
from kwave.ksource import kSource
from kwave.kspaceFirstOrder import kspaceFirstOrder


def _make_sim():
"""Create a minimal 2D simulation with a point source."""
N = (64, 64)
dx = (1e-4, 1e-4)
kgrid = kWaveGrid(N, dx)
kgrid.makeTime(1500)

source = kSource()
source.p0 = np.zeros(N)
source.p0[32, 32] = 1.0

sensor = kSensor(mask=np.ones(N))
return kgrid, source, sensor


def test_alpha_power_near_unity_raises_without_no_dispersion():
"""alpha_power close to 1.0 must raise when dispersion is enabled."""
kgrid, source, sensor = _make_sim()
medium = kWaveMedium(sound_speed=1500, density=1000, alpha_coeff=0.5, alpha_power=1.01)

with pytest.raises(ValueError, match="too close to 1.0"):
kspaceFirstOrder(kgrid, medium, source, sensor, pml_inside=True, quiet=True)


def test_alpha_power_near_unity_works_with_no_dispersion():
"""alpha_power close to 1.0 must produce valid output when dispersion is disabled."""
kgrid, source, sensor = _make_sim()
medium = kWaveMedium(
sound_speed=1500,
density=1000,
alpha_coeff=0.5,
alpha_power=1.01,
alpha_mode="no_dispersion",
)

result = kspaceFirstOrder(kgrid, medium, source, sensor, pml_inside=True, quiet=True)
p = np.asarray(result["p"])
assert not np.any(np.isnan(p)), "Output contains NaN with alpha_power=1.01 and no_dispersion"
assert not np.all(p == 0), "Output is all zeros — absorption had no effect"


def test_alpha_mode_no_absorption():
"""alpha_mode='no_absorption' should disable absorption but keep dispersion."""
kgrid, source, sensor = _make_sim()
medium_lossy = kWaveMedium(sound_speed=1500, density=1000, alpha_coeff=0.5, alpha_power=1.5)
medium_no_abs = kWaveMedium(
sound_speed=1500,
density=1000,
alpha_coeff=0.5,
alpha_power=1.5,
alpha_mode="no_absorption",
)

result_lossy = kspaceFirstOrder(kgrid, medium_lossy, source, sensor, pml_inside=True, quiet=True)
result_no_abs = kspaceFirstOrder(kgrid, medium_no_abs, source, sensor, pml_inside=True, quiet=True)

p_lossy = np.asarray(result_lossy["p"])
p_no_abs = np.asarray(result_no_abs["p"])

assert not np.any(np.isnan(p_no_abs))
# Disabling absorption should change the output
assert not np.allclose(p_lossy, p_no_abs), "Disabling absorption should change the output"


def test_alpha_mode_no_dispersion():
"""alpha_mode='no_dispersion' should disable dispersion but keep absorption."""
kgrid, source, sensor = _make_sim()
medium_lossy = kWaveMedium(sound_speed=1500, density=1000, alpha_coeff=0.5, alpha_power=1.5)
medium_no_disp = kWaveMedium(
sound_speed=1500,
density=1000,
alpha_coeff=0.5,
alpha_power=1.5,
alpha_mode="no_dispersion",
)

result_lossy = kspaceFirstOrder(kgrid, medium_lossy, source, sensor, pml_inside=True, quiet=True)
result_no_disp = kspaceFirstOrder(kgrid, medium_no_disp, source, sensor, pml_inside=True, quiet=True)

p_lossy = np.asarray(result_lossy["p"])
p_no_disp = np.asarray(result_no_disp["p"])

assert not np.any(np.isnan(p_no_disp))
# Both should have absorption so similar amplitude, but waveforms differ
assert not np.allclose(p_lossy, p_no_disp), "Disabling dispersion should change the output"


def test_alpha_power_normal_range_unaffected():
"""alpha_power=1.5 (well away from singularity) should work as before."""
kgrid, source, sensor = _make_sim()
medium = kWaveMedium(sound_speed=1500, density=1000, alpha_coeff=0.5, alpha_power=1.5)

result = kspaceFirstOrder(kgrid, medium, source, sensor, pml_inside=True, quiet=True)
p = np.asarray(result["p"])
assert not np.any(np.isnan(p))
assert p.shape[0] > 0


def test_cpp_backend_warns_on_alpha_mode(tmp_path):
"""C++ backend should warn when alpha_mode is set (it cannot honor it)."""
kgrid, source, sensor = _make_sim()
medium = kWaveMedium(
sound_speed=1500,
density=1000,
alpha_coeff=0.5,
alpha_power=1.5,
alpha_mode="no_dispersion",
)

with pytest.warns(UserWarning, match="not supported by the C\\+\\+ backend"):
kspaceFirstOrder(
kgrid,
medium,
source,
sensor,
pml_inside=True,
quiet=True,
backend="cpp",
save_only=True,
data_path=str(tmp_path),
)
Loading