Skip to content
Merged
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
32 changes: 32 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Tests

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]

steps:
- uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- name: Install package and test dependencies
run: python -m pip install -e ".[test]"

- name: Run tests
run: python -m pytest tests/ -v
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ authors = [
]
description = "Python port of the MUST toolbox for ultrasound signal processing and generation of simulated images"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
Expand All @@ -24,6 +24,9 @@ dependencies = [
]
dynamic = ["license", "version"]

[project.optional-dependencies]
test = ["pytest"]

[project.urls]
Homepage = "https://www.biomecardio.com/MUST"
Repository = "https://github.com/creatis-ULTIM/PyMUST"
Expand Down
1 change: 1 addition & 0 deletions src/pymust/txdelay3.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
from . import utils
import numpy as np
import scipy.optimize
Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import numpy as np
import pytest


@pytest.fixture(params=["L11-5v", "L12-3v", "C5-2v", "P4-2v"])
def probe_name(request):
return request.param


@pytest.fixture
def rng():
return np.random.default_rng(0)
Binary file added tests/data/pfield.npz
Binary file not shown.
Binary file added tests/data/simus.npz
Binary file not shown.
23 changes: 23 additions & 0 deletions tests/generate_golden_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Regenerate the golden reference .npz files used by test_regression.py.

Run this deliberately after a change that intentionally alters simus/pfield
numerics, then review the diff of the resulting .npz files:

python tests/generate_golden_data.py
"""
import numpy as np

from golden_scenarios import DATA_DIR, SCENARIOS


def main():
DATA_DIR.mkdir(exist_ok=True)
for name, build in SCENARIOS.items():
outputs = build()
path = DATA_DIR / f"{name}.npz"
np.savez(path, **outputs)
print(f"wrote {path}")


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions tests/golden_scenarios.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Fixed input scenarios shared by the golden-data generator and the regression
tests, so both sides always compute from the exact same inputs.
"""
import numpy as np
import pymust

DATA_DIR = __import__("pathlib").Path(__file__).parent / "data"


PLANE_WAVE_TILTS = [-0.3, -0.1, 0.0, 0.1, 0.3] # radians, several steering directions


def pfield_scenario():
param = pymust.getparam("L11-5v")
param.Nelements = 16

x, z = pymust.impolgrid(10, 0.04, np.pi / 4, param)
y = np.zeros_like(x)

outputs = {}
for tilt in PLANE_WAVE_TILTS:
delays = pymust.txdelayPlane(param, tilt)
rp, _, _ = pymust.pfield(x, y, z, delays, param)
outputs[f"rms_pressure_plane_tilt_{tilt:+.1f}"] = rp

focused_delays = pymust.txdelayFocused(param, 0.005, 0.03)
rp_focused, _, _ = pymust.pfield(x, y, z, focused_delays, param)
outputs["rms_pressure_focused"] = rp_focused

return outputs


def simus_scenario():
param = pymust.getparam("L11-5v")
param.Nelements = 8

x = np.array([0.0, 0.3e-2, -0.3e-2])
y = np.array([0.0, 0.0, 0.0])
z = np.array([0.2e-2, 0.4e-2, 0.6e-2])
rc = np.array([1.0, 0.7, 1.3])
delays = pymust.txdelayPlane(param, 0.05)

rf, _ = pymust.simus(x, y, z, rc, delays, param)
iq = pymust.rf2iq(rf, param)
img = pymust.bmode(iq)
return {"rf": rf, "iq": iq, "bmode": img}


SCENARIOS = {
"pfield": pfield_scenario,
"simus": simus_scenario,
}
18 changes: 18 additions & 0 deletions tests/test_getparam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import numpy as np
import pymust


def test_getparam_returns_sane_probe_parameters(probe_name):
param = pymust.getparam(probe_name)

assert param.Nelements > 0
assert param.fc > 0
assert param.pitch > 0
assert param.bandwidth > 0


def test_getparam_unknown_probe_raises():
import pytest

with pytest.raises(Exception):
pymust.getparam("not-a-real-probe")
38 changes: 38 additions & 0 deletions tests/test_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import pymust


EXPECTED_FUNCTIONS = [
"bmode",
"dasmtx",
"dasmtx3",
"getparam",
"impolgrid",
"iq2doppler",
"getNyquistVelocity",
"pfield",
"pfield3",
"rf2iq",
"simus",
"simus3",
"tgc",
"txdelay",
"txdelayCircular",
"txdelayPlane",
"txdelayFocused",
"txdelay3",
"txdelay3Plane",
"txdelay3Diverging",
"txdelay3Focused",
"getDopplerColorMap",
"genscat",
"mkmovie",
"getpulse",
"smoothn",
"sptrack",
]


def test_all_public_functions_are_importable():
for name in EXPECTED_FUNCTIONS:
assert hasattr(pymust, name), f"pymust.{name} is missing"
assert callable(getattr(pymust, name))
87 changes: 87 additions & 0 deletions tests/test_regression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Golden-master regression tests.

These compare simus/pfield output, computed fresh on every test run, against
reference arrays saved in tests/data/. simus and pfield are deterministic
within one environment (no randomness - see tests/golden_scenarios.py), but
NOT bit-reproducible across environments: pfield accumulates results in
single precision (complex64), and different BLAS/LAPACK backends (e.g.
OpenBLAS vs Apple's Accelerate) round matrix reductions differently. simus
then runs a sharp threshold ("zero out samples below -100dB relative to the
peak", see the RelThresh/tanh step in simus.py) on top of that, which turns
tiny backend-dependent noise into visibly different values for the handful
of samples that sit right at the threshold. This was caught in practice by
comparing a conda-env-generated reference against a plain pip install: exact
comparison failed on ~36% of RF samples even though every value was
numerically negligible (all well under 1e-4 of the peak amplitude).

So a sample's relative tolerance depends on how loud it is compared to the
signal's own peak (in dB, 20*log10(|value|/peak)):
- at or above NOISE_FLOOR_DB: tight rtol - this is "real" signal, and a
regression here (wrong scaling, wrong timing/shape, wrong physics)
should still fail loudly.
- below NOISE_FLOOR_DB: loose rtol - down in the noise floor, relative
comparison is meaningless (dividing by a near-zero reference blows up
the ratio) and this is exactly where backend rounding causes samples to
snap across simus's threshold. A small absolute tolerance (scaled to
the peak) still catches a sample that shouldn't be near-zero at all.

If you intentionally change the numerics of simus/pfield (e.g. a bug fix that
changes the output), regenerate the references and review the diff:

python tests/generate_golden_data.py
"""
import numpy as np
import pytest

from golden_scenarios import DATA_DIR, SCENARIOS

NOISE_FLOOR_DB = -30.0
TIGHT_RTOL = 1e-4
LOOSE_RTOL = 1.0
ATOL_FRACTION_OF_PEAK = 1e-5


def _load_reference(name):
path = DATA_DIR / f"{name}.npz"
if not path.exists():
pytest.skip(f"no golden reference at {path}; run generate_golden_data.py")
with np.load(path) as data:
return {key: data[key] for key in data.files}


def _assert_matches_reference(fresh_value, ref_value, label):
fresh_value = np.asarray(fresh_value, dtype=np.complex128 if np.iscomplexobj(fresh_value) else np.float64)
ref_value = np.asarray(ref_value, dtype=np.complex128 if np.iscomplexobj(ref_value) else np.float64)
assert fresh_value.shape == ref_value.shape, label

peak = np.max(np.abs(ref_value))
atol = max(peak * ATOL_FRACTION_OF_PEAK, 1e-9)

with np.errstate(divide="ignore"):
ref_db = 20 * np.log10(np.abs(ref_value) / peak)
rtol = np.where(ref_db < NOISE_FLOOR_DB, LOOSE_RTOL, TIGHT_RTOL)

diff = np.abs(fresh_value - ref_value)
allowed = atol + rtol * np.abs(ref_value)
bad = diff > allowed

if bad.any():
worst = np.unravel_index(np.argmax(diff - allowed), diff.shape)
raise AssertionError(
f"{label}: {bad.sum()}/{bad.size} elements exceed tolerance "
f"(no longer matches the golden reference); worst at {worst}: "
f"fresh={fresh_value[worst]!r} ref={ref_value[worst]!r} "
f"diff={diff[worst]:.3g} allowed={allowed[worst]:.3g} "
f"({ref_db[worst]:.1f} dB relative to peak)"
)


@pytest.mark.parametrize("scenario_name", sorted(SCENARIOS))
def test_matches_golden_reference(scenario_name):
reference = _load_reference(scenario_name)
fresh = SCENARIOS[scenario_name]()

assert set(fresh) == set(reference), "scenario outputs changed - regenerate golden data"

for key, fresh_value in fresh.items():
_assert_matches_reference(fresh_value, reference[key], f"{scenario_name}.{key}")
28 changes: 28 additions & 0 deletions tests/test_simus_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import numpy as np
import pymust


def test_simus_rf2iq_bmode_pipeline():
param = pymust.getparam("L11-5v")
param.Nelements = 8

x = np.array([0.0, 0.0])
y = np.array([0.0, 0.0])
z = np.array([0.2e-2, 0.5e-2])
rc = np.array([1.0, 1.0])
tx_delays = np.zeros(param.Nelements).reshape((1, -1))

rf, _ = pymust.simus(x, y, z, rc, tx_delays, param)
assert rf.ndim == 2
assert rf.shape[1] == param.Nelements
assert np.isfinite(rf).all()
assert np.any(rf != 0)

iq = pymust.rf2iq(rf, param)
assert iq.shape == rf.shape
assert np.iscomplexobj(iq)
assert np.isfinite(iq).all()

img = pymust.bmode(iq)
assert img.shape == rf.shape
assert img.dtype == np.uint8
23 changes: 23 additions & 0 deletions tests/test_txdelay.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import numpy as np
import pymust


def test_txdelay_plane_and_focused(probe_name):
param = pymust.getparam(probe_name)

plane = pymust.txdelayPlane(param, 0.1)
focused = pymust.txdelayFocused(param, 0, 0.03)

for delays in (plane, focused):
assert delays.shape[-1] == param.Nelements
assert np.isfinite(delays).all()


def test_txdelay_circular_wave_on_linear_array():
# txdelayCircular is only defined for linear (non-curved) arrays.
param = pymust.getparam("L11-5v")

circular = pymust.txdelayCircular(param, 0.1, np.pi / 3)

assert circular.shape[-1] == param.Nelements
assert np.isfinite(circular).all()
40 changes: 40 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import numpy as np
import pymust


def test_genscat_generates_scatterers_within_roi():
roi_width, roi_depth = 0.02, 0.02
x, y, z, rc = pymust.genscat(np.array([roi_width, roi_depth]), 0.001)

assert x.shape == y.shape == z.shape == rc.shape
assert x.size > 0
# The ROI is centered on x=0 with its top edge at z=0, per genscat's docstring.
assert np.abs(x).max() <= roi_width / 2 + 1e-9
assert (z >= -1e-9).all()
assert z.max() <= roi_depth + 1e-9


def test_smoothn_reduces_noise(rng):
t = np.linspace(0, 10, 200)
clean = np.sin(t)
noisy = clean + 0.3 * rng.standard_normal(t.size)

smoothed, _, _ = pymust.smoothn(noisy)

assert smoothed.shape == noisy.shape
assert np.isfinite(smoothed).all()
error_before = np.mean((noisy - clean) ** 2)
error_after = np.mean((smoothed - clean) ** 2)
assert error_after < error_before


def test_impolgrid_returns_grid_coordinates():
param = pymust.getparam("L11-5v")

grid = pymust.impolgrid(50, 0.05, np.pi / 3, param)

assert len(grid) == 2
x, z = grid
assert x.shape == z.shape
assert np.isfinite(x).all()
assert np.isfinite(z).all()
Loading