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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@ jobs:
fi
echo "lanes=$lanes" | tee -a "$GITHUB_OUTPUT"
# Whether a device build could have changed; without a diff to read, it could.
# The second grep reads the whole pipe: grep -q stops at its first match, the
# first grep then dies of SIGPIPE, and under pipefail that reads as no match.
device=true
if [ -f changed.txt ] && ! grep -vE '\.md$' changed.txt | grep -qE '^native/|^\.github/workflows/ci\.yml$'; then
if [ -f changed.txt ] && ! grep -vE '\.md$' changed.txt | grep -E '^native/|^\.github/workflows/ci\.yml$' > /dev/null; then
device=false
fi
echo "device=$device" | tee -a "$GITHUB_OUTPUT"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_ci_changes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""The CI change filter that decides whether the device compilation lanes run."""

import os
import subprocess
import sys
import textwrap
from pathlib import Path

import pytest

WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/ci.yml"
LAST = ' echo "device=$device" | tee -a "$GITHUB_OUTPUT"'

pytestmark = pytest.mark.skipif(
sys.platform == "win32", reason="the CI change filter runs in Linux Bash"
)


def _device(directory: Path, changed: str | None, *, pipefail: bool) -> str:
"""Run the workflow's device filter on a change list; return what it writes."""
workflow = WORKFLOW.read_text(encoding="utf-8")
start = workflow.index(" device=true\n")
end = workflow.index(LAST, start) + len(LAST)
if changed is not None:
(directory / "changed.txt").write_text(changed, encoding="utf-8")
output = directory / "github-output"
shell = ["bash", "-e", *(("-o", "pipefail") if pipefail else ())]
subprocess.run(
[*shell, "-c", textwrap.dedent(workflow[start:end])],
cwd=directory,
env={**os.environ, "GITHUB_OUTPUT": str(output)},
check=True,
capture_output=True,
)
return output.read_text(encoding="utf-8")


@pytest.mark.parametrize("pipefail", [False, True])
def test_device_filter_selects_native_change_first_in_large_list(tmp_path, pipefail):
# The match comes first and the list is long enough that a reader stopping at it
# leaves the upstream grep writing into a closed pipe (SIGPIPE).
changed = "native/src/core.cpp\n" + "".join(
f"src/other/{index:08d}.py\n" for index in range(100_000)
)
assert _device(tmp_path, changed, pipefail=pipefail) == "device=true\n"


@pytest.mark.parametrize("pipefail", [False, True])
@pytest.mark.parametrize(
"changed,expected",
[
("native/src/core.cpp\n", "true"),
("docs/index.md\n.github/workflows/ci.yml\n", "true"),
(None, "true"),
("native/README.md\ndocs/dev/testing.md\n", "false"),
("src/solprop/grid.py\ngui/app.py\n", "false"),
],
)
def test_device_filter_classifies_change_lists(tmp_path, changed, expected, pipefail):
# None is a push whose base is unknown: no list to read, so a device build may
# have changed.
assert _device(tmp_path, changed, pipefail=pipefail) == f"device={expected}\n"
22 changes: 21 additions & 1 deletion tests/translate/test_radial.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from numpy.testing import assert_allclose
from scipy.integrate import quad_vec

from bounds import assert_within
from hankel_oracles import gamma
from solprop.constants import C
from solprop.exceptions import TranslationError
Expand Down Expand Up @@ -107,6 +108,10 @@ def test_varying_radial_medium_uses_cumulative_expansion(case, comoving):
assert isinstance(prefactor, ParameterExpansion)
assert operator.phi_expansion.parameters == ("density",)
assert prefactor.parameters == ("density",)
# Structural: one material sample serves both polarisations, so the stored
# terms are the same numbers; only their contraction below may round apart.
terms = operator.phi_expansion.terms
np.testing.assert_array_equal(terms[..., 0, :], terms[..., 1, :])
for left, right in (
(0.0, grid.length),
(0.21 * grid.length, 0.79 * grid.length),
Expand Down Expand Up @@ -135,7 +140,22 @@ def test_varying_radial_medium_uses_cumulative_expansion(case, comoving):
)
actual = delta_phi(operator, left, right)[0]
assert_allclose(actual[:, 0], expected, rtol=0, atol=bound)
np.testing.assert_array_equal(actual[:, 0], actual[:, 1])
# The polarisation columns hold identical terms, but the increment is one
# BLAS product (np.tensordot) whose kernel may round each output column
# differently (seen with OpenBLAS on AVX-512). Each column is a length-n
# sum of real weight times term: within gamma_n(u) sum |w| |t| of exact per
# component, so two columns differ by at most twice that per component and
# sqrt(2) times more in modulus; gamma(2 n) = 4 gamma_n(u) covers it.
expansion = operator.phi_expansion
weights = expansion.weights(right) - expansion.weights(left)
magnitude = np.tensordot(np.abs(weights), np.abs(expansion.terms), axes=1)
assert_within(
actual[:, 1],
actual[:, 0],
gamma(2 * weights.size) * magnitude[0][:, 0],
elementwise=True,
what="polarisation columns",
)
for z in (0.0, 0.37 * grid.length, grid.length):
expected = model.prefactor(space.k_r**2, z) * grid.crop_scale
assert_allclose(
Expand Down
Loading