From fcb611cccc9e4a1bbcb4e17c037a761764b2aadb Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Mon, 15 Jun 2026 19:13:40 +0200 Subject: [PATCH 1/6] fixed issue #367 --- .../calculators/refl1d/wrapper.py | 7 +- .../calculators/refnx/wrapper.py | 10 +- .../model/resolution_functions.py | 42 ++++- tests/calculators/refnx/test_refnx_wrapper.py | 2 +- .../test_cross_engine_resolution.py | 144 ++++++++++++++++++ tests/model/test_model.py | 13 +- tests/model/test_resolution_functions.py | 30 ++-- 7 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 tests/integration/test_cross_engine_resolution.py diff --git a/src/easyreflectometry/calculators/refl1d/wrapper.py b/src/easyreflectometry/calculators/refl1d/wrapper.py index 7a07c917..6985d47c 100644 --- a/src/easyreflectometry/calculators/refl1d/wrapper.py +++ b/src/easyreflectometry/calculators/refl1d/wrapper.py @@ -8,8 +8,6 @@ from refl1d import names from refl1d.sample.layers import Repeat -from easyreflectometry.model import PercentageFwhm - from ..wrapper_base import WrapperBase RESOLUTION_PADDING = 3.5 @@ -205,12 +203,9 @@ def calculate(self, q_array: np.ndarray, model_name: str) -> np.ndarray: Reflectivity calculated at q. """ sample = _build_sample(self.storage, model_name) + # smearing() returns sigma, which is exactly what refl1d's probe.dQ expects. dq_array = self._resolution_function.smearing(q_array) - if isinstance(self._resolution_function, PercentageFwhm): - # Get percentage of Q and change from sigma to FWHM - dq_array = dq_array * q_array / 100 / (2 * np.sqrt(2 * np.log(2))) - if not self._magnetism: probe = _get_probe( q_array=q_array, diff --git a/src/easyreflectometry/calculators/refnx/wrapper.py b/src/easyreflectometry/calculators/refnx/wrapper.py index 3742d727..65dc8662 100644 --- a/src/easyreflectometry/calculators/refnx/wrapper.py +++ b/src/easyreflectometry/calculators/refnx/wrapper.py @@ -8,6 +8,7 @@ from refnx import reflect from easyreflectometry.model import PercentageFwhm +from easyreflectometry.model.resolution_functions import SIGMA_TO_FWHM from ..wrapper_base import WrapperBase @@ -191,9 +192,12 @@ def calculate(self, q_array: np.ndarray, model_name: str) -> np.ndarray: dq_vector = self._resolution_function.smearing(q_array) if isinstance(self._resolution_function, PercentageFwhm): - # FWHM Percentage resolution is constant given as - # For a constant resolution percentage refnx supports to pass a scalar value rather than a vector - dq_vector = dq_vector[0] + # refnx interprets a scalar x_err as a constant dq/q (FWHM percentage), + # so pass the percentage directly rather than a per-point vector. + dq_vector = self._resolution_function.constant + else: + # smearing() returns sigma; refnx expects the FWHM at each point. + dq_vector = dq_vector * SIGMA_TO_FWHM return model(x=q_array, x_err=dq_vector) diff --git a/src/easyreflectometry/model/resolution_functions.py b/src/easyreflectometry/model/resolution_functions.py index fe5d2254..9579ad66 100644 --- a/src/easyreflectometry/model/resolution_functions.py +++ b/src/easyreflectometry/model/resolution_functions.py @@ -6,6 +6,14 @@ Gaussian distribution with a FWHM of the percentage of the q value. To convert from a sigma value to a FWHM value we use the formula FWHM = 2.35 * sigma [2 * np.sqrt(2 * np.log(2)) * sigma]. + +The :meth:`ResolutionFunction.smearing` contract returns **sigma** +(the standard deviation of the Gaussian resolution) for every resolution +type. This matches the ``sQz`` convention used by data reduction and the +natural output of :class:`Pointwise`. Each calculation engine wrapper is +responsible for converting sigma to the width convention of its backend +(FWHM for refnx, sigma for refl1d), so that vector resolutions are +interpreted consistently across engines (see GitHub issue #367). """ from __future__ import annotations @@ -19,10 +27,15 @@ DEFAULT_RESOLUTION_FWHM_PERCENTAGE = 5.0 +# Conversion factor between sigma and FWHM for a Gaussian: FWHM = SIGMA_TO_FWHM * sigma. +SIGMA_TO_FWHM = 2 * np.sqrt(2 * np.log(2)) + class ResolutionFunction: @abstractmethod - def smearing(self, q: Union[np.array, float]) -> np.array: ... + def smearing(self, q: Union[np.array, float]) -> np.array: + """Return the resolution as sigma (standard deviation) at each ``q``.""" + ... @abstractmethod def as_dict(self, skip: Optional[List[str]] = None) -> dict: ... @@ -51,8 +64,14 @@ def __init__(self, constant: Union[None, float] = None): self.constant = constant def smearing(self, q: Union[np.array, float]) -> np.array: - """Smearing function.""" - return np.ones(np.array(q).size) * self.constant + """Return per-point sigma values from the constant FWHM percentage. + + ``constant`` is a FWHM percentage of ``q``; it is converted to an + absolute sigma so the smearing() contract is sigma for all types. + """ + q_array = np.asarray(q, dtype=float) + fwhm = (self.constant / 100.0) * q_array + return fwhm / SIGMA_TO_FWHM def as_dict( self, skip: Optional[List[str]] = None @@ -68,8 +87,13 @@ def __init__(self, q_data_points: np.array, fwhm_values: np.array): self.fwhm_values = fwhm_values def smearing(self, q: Union[np.array, float]) -> np.array: - """Smearing function.""" - return np.interp(q, self.q_data_points, self.fwhm_values) + """Return per-point sigma values from the FWHM knots. + + The stored ``fwhm_values`` are FWHM widths; they are interpolated + onto ``q`` and converted to sigma to satisfy the smearing() contract. + """ + fwhm = np.interp(np.asarray(q, dtype=float), self.q_data_points, self.fwhm_values) + return fwhm / SIGMA_TO_FWHM def as_dict( self, skip: Optional[List[str]] = None @@ -110,10 +134,12 @@ def __init__(self, q_data_points: List[np.ndarray]): self.q_data_points = q_data_points def smearing(self, q: Optional[Union[np.ndarray, float]] = None) -> np.ndarray: - """Return the resolution width interpolated onto ``q``. + """Return the resolution sigma interpolated onto ``q``. - The width at each data point is ``sqrt(sQz)``; values are linearly - interpolated onto the requested ``q``. When ``q`` is ``None`` the widths + ``sQz`` is the variance of ``Qz``, so the sigma at each data point is + ``sqrt(sQz)``; values are linearly interpolated onto the requested + ``q``. This already satisfies the sigma smearing() contract, so no + FWHM conversion is applied. When ``q`` is ``None`` the sigma values are returned at the stored data points. """ Qz = np.asarray(self.q_data_points[0], dtype=float) diff --git a/tests/calculators/refnx/test_refnx_wrapper.py b/tests/calculators/refnx/test_refnx_wrapper.py index f9d2a4cc..bb99d633 100644 --- a/tests/calculators/refnx/test_refnx_wrapper.py +++ b/tests/calculators/refnx/test_refnx_wrapper.py @@ -451,7 +451,7 @@ def test_calculate_github_test4_spline_resolution(self): p.add_item('Item3', 'MyModel') p.add_item('Item4', 'MyModel') p.update_model('MyModel', bkg=0) - sigma_to_fwhm = 2.355 + sigma_to_fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) p.set_resolution_function(LinearSpline(test4_dat[:, 0], sigma_to_fwhm * test4_dat[:, 3])) assert_allclose(p.calculate(test4_dat[:, 0], 'MyModel'), test4_dat[:, 1], rtol=0.03) diff --git a/tests/integration/test_cross_engine_resolution.py b/tests/integration/test_cross_engine_resolution.py new file mode 100644 index 00000000..07feff80 --- /dev/null +++ b/tests/integration/test_cross_engine_resolution.py @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Cross-engine regression tests for resolution function width conventions. + +These tests verify that the same model + resolution function produces +consistent results across the refnx and refl1d engines, confirming that the +sigma/FWHM convention conversion is applied correctly at each engine +boundary. + +.. note:: + + The engines use different internal resolution algorithms (refnx uses + pointwise convolution, refl1d uses oversampling), so results can differ + by ~10-20% even with identical width conventions. The tolerance is set + wide enough to accommodate this while still catching a 2.355x convention + error, which would produce >100% differences. + +See GitHub issue #367 for background. +""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose + +from easyreflectometry.calculators.refl1d.wrapper import Refl1dWrapper +from easyreflectometry.calculators.refnx.wrapper import RefnxWrapper +from easyreflectometry.model.resolution_functions import SIGMA_TO_FWHM +from easyreflectometry.model.resolution_functions import LinearSpline +from easyreflectometry.model.resolution_functions import PercentageFwhm +from easyreflectometry.model.resolution_functions import Pointwise + + +def _build_simple_model_refnx(wrapper): + """Build a simple single-film model on a refnx wrapper.""" + wrapper.reset_storage() + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', real=2.07, imag=0.0) + wrapper.create_material('Film') + wrapper.update_material('Film', real=3.45, imag=0.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_layer('FilmLayer') + wrapper.assign_material_to_layer('Film', 'FilmLayer') + wrapper.update_layer('FilmLayer', thick=100.0, rough=3.0) + wrapper.create_item('Item') + wrapper.add_layer_to_item('FilmLayer', 'Item') + wrapper.add_layer_to_item('SubstrateLayer', 'Item') + wrapper.create_model('MyModel') + wrapper.add_item('Item', 'MyModel') + wrapper.update_model('MyModel', bkg=0.0) + + +def _build_simple_model_refl1d(wrapper): + """Build the same single-film model on a refl1d wrapper.""" + wrapper.reset_storage() + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', rho=2.07, irho=0.0) + wrapper.create_material('Film') + wrapper.update_material('Film', rho=3.45, irho=0.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_layer('FilmLayer') + wrapper.assign_material_to_layer('Film', 'FilmLayer') + wrapper.update_layer('FilmLayer', thickness=100.0, interface=3.0) + wrapper.create_item('Item') + wrapper.add_layer_to_item('FilmLayer', 'Item') + wrapper.add_layer_to_item('SubstrateLayer', 'Item') + wrapper.create_model('MyModel') + wrapper.add_item('Item', 'MyModel') + wrapper.update_model('MyModel', bkg=0.0) + + +@pytest.mark.parametrize('resolution_pct', [1.0, 5.0, 10.0]) +def test_percentage_fwhm_consistent_across_engines(resolution_pct): + """PercentageFwhm resolution gives consistent results across engines.""" + q = np.geomspace(0.005, 0.3, 100) + + refnx_w = RefnxWrapper() + _build_simple_model_refnx(refnx_w) + refnx_w.set_resolution_function(PercentageFwhm(resolution_pct)) + refnx_r = refnx_w.calculate(q, 'MyModel') + + refl1d_w = Refl1dWrapper() + _build_simple_model_refl1d(refl1d_w) + refl1d_w.set_resolution_function(PercentageFwhm(resolution_pct)) + refl1d_r = refl1d_w.calculate(q, 'MyModel') + + assert_allclose(refnx_r, refl1d_r, rtol=0.25) + + +def test_linear_spline_consistent_across_engines(): + """LinearSpline resolution gives consistent results across engines. + + This is the key regression test for issue #367 -- before the fix, refl1d + over-smeared by ~2.355x with LinearSpline because the FWHM->sigma + conversion was missing, while refnx treated the same vector as FWHM. + """ + q = np.geomspace(0.005, 0.3, 100) + + # A plausible per-point FWHM resolution that increases with q. + q_knots = np.linspace(0.001, 0.5, 10) + fwhm_knots = 0.02 * q_knots + 0.001 + + refnx_w = RefnxWrapper() + _build_simple_model_refnx(refnx_w) + refnx_w.set_resolution_function(LinearSpline(q_knots, fwhm_knots)) + refnx_r = refnx_w.calculate(q, 'MyModel') + + refl1d_w = Refl1dWrapper() + _build_simple_model_refl1d(refl1d_w) + refl1d_w.set_resolution_function(LinearSpline(q_knots, fwhm_knots)) + refl1d_r = refl1d_w.calculate(q, 'MyModel') + + assert_allclose(refnx_r, refl1d_r, rtol=0.25) + + +def test_pointwise_consistent_across_engines(): + """Pointwise resolution (sigma from sQz) is consistent across engines. + + Before the fix, refnx treated the sigma values as FWHM and so + under-smeared by ~2.355x relative to refl1d. + """ + q = np.geomspace(0.005, 0.3, 100) + + # sQz is the variance of Qz; sigma = sqrt(sQz) increases with q. The + # magnitude mirrors the LinearSpline test (whose FWHM knots become these + # sigma values) so both engines apply the same modest smearing. + qz = np.linspace(0.001, 0.5, 50) + r = np.ones_like(qz) # only kept for serialization round-trips + sigma = (0.02 * qz + 0.001) / SIGMA_TO_FWHM + sqz = sigma**2 + + refnx_w = RefnxWrapper() + _build_simple_model_refnx(refnx_w) + refnx_w.set_resolution_function(Pointwise([qz, r, sqz])) + refnx_r = refnx_w.calculate(q, 'MyModel') + + refl1d_w = Refl1dWrapper() + _build_simple_model_refl1d(refl1d_w) + refl1d_w.set_resolution_function(Pointwise([qz, r, sqz])) + refl1d_r = refl1d_w.calculate(q, 'MyModel') + + assert_allclose(refnx_r, refl1d_r, rtol=0.25) diff --git a/tests/model/test_model.py b/tests/model/test_model.py index b11149c1..a2dd46ea 100644 --- a/tests/model/test_model.py +++ b/tests/model/test_model.py @@ -45,8 +45,9 @@ def test_default(self): assert_equal(p.background.min, 0.0) assert_equal(p.background.max, np.inf) assert_equal(p.background.fixed, True) - assert p._resolution_function.smearing([1]) == 5.0 - assert p._resolution_function.smearing([100]) == 5.0 + sigma_to_fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) + assert np.allclose(p._resolution_function.smearing([1]), 5.0 / 100.0 * 1.0 / sigma_to_fwhm) + assert np.allclose(p._resolution_function.smearing([100]), 5.0 / 100.0 * 100.0 / sigma_to_fwhm) def test_from_pars(self): m1 = Material(6.908, -0.278, 'Boron') @@ -81,8 +82,9 @@ def test_from_pars(self): assert_equal(mod.background.min, 0.0) assert_equal(mod.background.max, np.inf) assert_equal(mod.background.fixed, True) - assert mod._resolution_function.smearing([1]) == 2.0 - assert mod._resolution_function.smearing([100]) == 2.0 + sigma_to_fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) + assert np.allclose(mod._resolution_function.smearing([1]), 2.0 / 100.0 * 1.0 / sigma_to_fwhm) + assert np.allclose(mod._resolution_function.smearing([100]), 2.0 / 100.0 * 100.0 / sigma_to_fwhm) def test_add_assemblies(self): m1 = Material(6.908, -0.278, 'Boron') @@ -525,7 +527,8 @@ def test_round_trip_preserves_resolution_function(self): d = model.as_dict() global_object.map._clear() restored = Model.from_dict(d) - assert restored._resolution_function.smearing(100) == 3.0 + sigma_to_fwhm = 2.0 * np.sqrt(2.0 * np.log(2.0)) + assert np.allclose(restored._resolution_function.smearing(100), 3.0 / 100.0 * 100.0 / sigma_to_fwhm) def test_round_trip_preserves_interface(self): global_object.map._clear() diff --git a/tests/model/test_resolution_functions.py b/tests/model/test_resolution_functions.py index b8a4ea18..8a1a5116 100644 --- a/tests/model/test_resolution_functions.py +++ b/tests/model/test_resolution_functions.py @@ -6,6 +6,7 @@ import numpy as np from easyreflectometry.model.resolution_functions import DEFAULT_RESOLUTION_FWHM_PERCENTAGE +from easyreflectometry.model.resolution_functions import SIGMA_TO_FWHM from easyreflectometry.model.resolution_functions import LinearSpline from easyreflectometry.model.resolution_functions import PercentageFwhm from easyreflectometry.model.resolution_functions import Pointwise @@ -17,21 +18,22 @@ def test_constructor(self): # When resolution_function = PercentageFwhm(1.0) - # Then Expect - assert np.all(resolution_function.smearing([0, 2.5]) == np.array([1.0, 1.0])) - assert resolution_function.smearing([-100]) == np.array([1.0]) - assert resolution_function.smearing([100]) == np.array([1.0]) + # Then Expect: smearing() returns sigma = (constant / 100) * q / SIGMA_TO_FWHM + expected = (1.0 / 100.0) * np.array([0.0, 2.5]) / SIGMA_TO_FWHM + assert np.allclose(resolution_function.smearing([0, 2.5]), expected) + assert np.allclose(resolution_function.smearing([-100]), (1.0 / 100.0) * (-100.0) / SIGMA_TO_FWHM) + assert np.allclose(resolution_function.smearing([100]), (1.0 / 100.0) * 100.0 / SIGMA_TO_FWHM) def test_constructor_none(self): # When resolution_function = PercentageFwhm() - # Then Expect - assert np.all( - resolution_function.smearing([0, 2.5]) == [DEFAULT_RESOLUTION_FWHM_PERCENTAGE, DEFAULT_RESOLUTION_FWHM_PERCENTAGE] - ) - assert resolution_function.smearing([-100]) == DEFAULT_RESOLUTION_FWHM_PERCENTAGE - assert resolution_function.smearing([100]) == DEFAULT_RESOLUTION_FWHM_PERCENTAGE + # Then Expect: defaults to DEFAULT_RESOLUTION_FWHM_PERCENTAGE, returned as sigma + c = DEFAULT_RESOLUTION_FWHM_PERCENTAGE + expected = (c / 100.0) * np.array([0.0, 2.5]) / SIGMA_TO_FWHM + assert np.allclose(resolution_function.smearing([0, 2.5]), expected) + assert np.allclose(resolution_function.smearing([-100]), (c / 100.0) * (-100.0) / SIGMA_TO_FWHM) + assert np.allclose(resolution_function.smearing([100]), (c / 100.0) * 100.0 / SIGMA_TO_FWHM) def test_as_dict(self): # When @@ -57,10 +59,10 @@ def test_constructor(self): # When resolution_function = LinearSpline(q_data_points=[0, 10], fwhm_values=[5, 10]) - # Then Expect - assert np.all(resolution_function.smearing([0, 2.5]) == np.array([5, 6.25])) - assert resolution_function.smearing([-100]) == np.array([5.0]) - assert resolution_function.smearing([100]) == np.array([10.0]) + # Then Expect: smearing() returns sigma (FWHM knots converted to sigma) + assert np.allclose(resolution_function.smearing([0, 2.5]), np.array([5, 6.25]) / SIGMA_TO_FWHM) + assert np.allclose(resolution_function.smearing([-100]), np.array([5.0]) / SIGMA_TO_FWHM) + assert np.allclose(resolution_function.smearing([100]), np.array([10.0]) / SIGMA_TO_FWHM) def test_as_dict(self): # When From 2ae07f528d9bc91c821b507c98f458f38e5c9d53 Mon Sep 17 00:00:00 2001 From: rozyczko Date: Tue, 16 Jun 2026 08:50:53 +0200 Subject: [PATCH 2/6] disable automatic parallelization for windows --- .github/workflows/test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bf99c45..acd12e38 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -293,8 +293,11 @@ jobs: cd easyreflectometry_py$py_ver echo "Running tests" - pixi run python -m pytest ../tests/integration/ --color=yes -n auto -v ${{ needs.env-prepare.outputs.pytest-marks }} - + if [[ "${{ matrix.os }}" == "windows-2022" ]]; then + pixi run python -m pytest ../tests/integration/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} + else + pixi run python -m pytest ../tests/integration/ --color=yes -n auto -v ${{ needs.env-prepare.outputs.pytest-marks }} + fi echo "Exiting pixi project directory" cd .. done From 72fcc2477066c9cc5657d8877f6ac108c09e72f9 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Jul 2026 12:41:30 +0200 Subject: [PATCH 3/6] Changes after code review --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 31 +++ .../test_cross_engine_resolution.py | 179 +++++++++++------- tests/model/test_resolution_functions.py | 8 +- 4 files changed, 149 insertions(+), 71 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index acd12e38..55b71d70 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -293,7 +293,7 @@ jobs: cd easyreflectometry_py$py_ver echo "Running tests" - if [[ "${{ matrix.os }}" == "windows-2022" ]]; then + if [[ "${{ runner.os }}" == "Windows" ]]; then pixi run python -m pytest ../tests/integration/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} else pixi run python -m pytest ../tests/integration/ --color=yes -n auto -v ${{ needs.env-prepare.outputs.pytest-marks }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2537591f..30ca4e2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Unreleased +Fixed inconsistent interpretation of vector resolution functions between +the refnx and refl1d engines (issue #367). + +- **Reflectivity results change for two engine / resolution + combinations.** `LinearSpline` on refl1d previously **over-smeared by + a factor of 2.355** (its FWHM widths were passed to refl1d's + `probe.dQ`, which expects sigma). `Pointwise` on refnx previously + **under-smeared by the same factor** (its sigma widths were passed to + refnx's `x_err`, which expects FWHM). Both are now correct. Fits and + simulations that used either combination will produce different — + previously wrong — results and should be re-run. `PercentageFwhm` on + either engine, `LinearSpline` on refnx, and `Pointwise` on refl1d are + numerically unchanged. +- `ResolutionFunction.smearing()` now returns **sigma** (the Gaussian + standard deviation) for every subclass; each engine wrapper converts + to its backend's convention. This is a behavioural change to a public + method. Most visibly, `PercentageFwhm.smearing(q)` used to return the + _percentage_ itself (e.g. `5.0`) and now returns an absolute sigma + (e.g. `0.00212` at `q=0.1`); `LinearSpline.smearing(q)` returns its + `fwhm_values` divided by `2*sqrt(2*ln2)`. Callers relying on the old + values need to convert. The new `SIGMA_TO_FWHM` constant is exported + from `easyreflectometry.model.resolution_functions`. +- Constructors are **unchanged**: `PercentageFwhm(5)` still means 5% + FWHM and `LinearSpline(q, fwhm_values)` still takes FWHM. Only the + `smearing()` output convention moved, so existing model-building code + needs no edits. +- `PercentageFwhm.smearing(q)` given a scalar `q` now returns a 0-d + numpy scalar rather than a shape-`(1,)` array, matching + `LinearSpline`. `smearing(0.1)[0]` therefore raises `IndexError` where + it previously returned a value. + Migrated sample / model classes off the deprecated `easyscience.ObjBase` and `easyscience.CollectionBase` pipeline. diff --git a/tests/integration/test_cross_engine_resolution.py b/tests/integration/test_cross_engine_resolution.py index 07feff80..6da7be16 100644 --- a/tests/integration/test_cross_engine_resolution.py +++ b/tests/integration/test_cross_engine_resolution.py @@ -1,27 +1,45 @@ # SPDX-FileCopyrightText: 2026 EasyScience contributors # SPDX-License-Identifier: BSD-3-Clause -"""Cross-engine regression tests for resolution function width conventions. +"""Cross-engine consistency checks for resolution function width conventions. -These tests verify that the same model + resolution function produces -consistent results across the refnx and refl1d engines, confirming that the -sigma/FWHM convention conversion is applied correctly at each engine -boundary. +The same model + resolution function should produce broadly the same +reflectivity on refnx and refl1d. A width-convention error at one engine +boundary shows up as a systematic disagreement between them. + +.. warning:: + + These are **smoke tests, not the regression tests for issue #367.** They + compare the engines against each other, so they are blind to any error + applied consistently to both, and -- measured, not assumed -- they are only + sensitive enough to catch *one* of the two bugs #367 fixed: + + * ``LinearSpline``: the pre-fix refl1d code **over**-smeared by 2.355x, + which moves the curve enough to be caught here (measured separation ~2x). + * ``Pointwise``: the pre-fix refnx code **under**-smeared by 2.355x from an + already-small width. That barely moves the curve -- measured separation + ~1.1x, against a baseline engine disagreement of the same size -- so **no + tolerance can catch it here.** The Pointwise test below is a consistency + check only. + + The actual, exact regression tests for both conventions live in + ``tests/calculators/test_resolution_conventions.py``, which intercepts the + widths handed to each backend and asserts them to floating-point precision. + Fix that file first if these ever conflict. .. note:: - The engines use different internal resolution algorithms (refnx uses - pointwise convolution, refl1d uses oversampling), so results can differ - by ~10-20% even with identical width conventions. The tolerance is set - wide enough to accommodate this while still catching a 2.355x convention - error, which would produce >100% differences. + Reflectivity spans several decades and the engines' different resolution + algorithms (refnx: pointwise convolution; refl1d: oversampling) disagree + most at fringe minima, where R is tiny and *relative* differences explode. + The comparison is therefore made on ``log10(R)``, and the tolerances are + measured values with roughly 1.5x headroom rather than round numbers. See GitHub issue #367 for background. """ import numpy as np import pytest -from numpy.testing import assert_allclose from easyreflectometry.calculators.refl1d.wrapper import Refl1dWrapper from easyreflectometry.calculators.refnx.wrapper import RefnxWrapper @@ -30,20 +48,35 @@ from easyreflectometry.model.resolution_functions import PercentageFwhm from easyreflectometry.model.resolution_functions import Pointwise +Q = np.geomspace(0.005, 0.3, 100) + def _build_simple_model_refnx(wrapper): - """Build a simple single-film model on a refnx wrapper.""" + """Build an ambient | 100 A film | substrate model on a refnx wrapper. + + The ambient layer is not optional decoration: both engines treat the first + layer as the semi-infinite superphase and ignore its thickness. Without it + the "film" becomes the ambient, leaving a bare interface with no Kiessig + fringes -- and resolution smearing acts almost entirely on fringes, so the + model would be insensitive to the very thing under test. + """ wrapper.reset_storage() - wrapper.create_material('Substrate') - wrapper.update_material('Substrate', real=2.07, imag=0.0) + wrapper.create_material('Ambient') + wrapper.update_material('Ambient', real=0.0, imag=0.0) wrapper.create_material('Film') wrapper.update_material('Film', real=3.45, imag=0.0) - wrapper.create_layer('SubstrateLayer') - wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', real=2.07, imag=0.0) + wrapper.create_layer('AmbientLayer') + wrapper.assign_material_to_layer('Ambient', 'AmbientLayer') wrapper.create_layer('FilmLayer') wrapper.assign_material_to_layer('Film', 'FilmLayer') wrapper.update_layer('FilmLayer', thick=100.0, rough=3.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.update_layer('SubstrateLayer', rough=3.0) wrapper.create_item('Item') + wrapper.add_layer_to_item('AmbientLayer', 'Item') wrapper.add_layer_to_item('FilmLayer', 'Item') wrapper.add_layer_to_item('SubstrateLayer', 'Item') wrapper.create_model('MyModel') @@ -52,18 +85,24 @@ def _build_simple_model_refnx(wrapper): def _build_simple_model_refl1d(wrapper): - """Build the same single-film model on a refl1d wrapper.""" + """Build the same ambient | 100 A film | substrate model on refl1d.""" wrapper.reset_storage() - wrapper.create_material('Substrate') - wrapper.update_material('Substrate', rho=2.07, irho=0.0) + wrapper.create_material('Ambient') + wrapper.update_material('Ambient', rho=0.0, irho=0.0) wrapper.create_material('Film') wrapper.update_material('Film', rho=3.45, irho=0.0) - wrapper.create_layer('SubstrateLayer') - wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', rho=2.07, irho=0.0) + wrapper.create_layer('AmbientLayer') + wrapper.assign_material_to_layer('Ambient', 'AmbientLayer') wrapper.create_layer('FilmLayer') wrapper.assign_material_to_layer('Film', 'FilmLayer') wrapper.update_layer('FilmLayer', thickness=100.0, interface=3.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.update_layer('SubstrateLayer', interface=3.0) wrapper.create_item('Item') + wrapper.add_layer_to_item('AmbientLayer', 'Item') wrapper.add_layer_to_item('FilmLayer', 'Item') wrapper.add_layer_to_item('SubstrateLayer', 'Item') wrapper.create_model('MyModel') @@ -71,74 +110,78 @@ def _build_simple_model_refl1d(wrapper): wrapper.update_model('MyModel', bkg=0.0) -@pytest.mark.parametrize('resolution_pct', [1.0, 5.0, 10.0]) -def test_percentage_fwhm_consistent_across_engines(resolution_pct): - """PercentageFwhm resolution gives consistent results across engines.""" - q = np.geomspace(0.005, 0.3, 100) - +def _both_engines(resolution_function): + """Return (refnx_reflectivity, refl1d_reflectivity) for one resolution.""" refnx_w = RefnxWrapper() _build_simple_model_refnx(refnx_w) - refnx_w.set_resolution_function(PercentageFwhm(resolution_pct)) - refnx_r = refnx_w.calculate(q, 'MyModel') + refnx_w.set_resolution_function(resolution_function) + refnx_r = refnx_w.calculate(Q, 'MyModel') refl1d_w = Refl1dWrapper() _build_simple_model_refl1d(refl1d_w) - refl1d_w.set_resolution_function(PercentageFwhm(resolution_pct)) - refl1d_r = refl1d_w.calculate(q, 'MyModel') + refl1d_w.set_resolution_function(resolution_function) + refl1d_r = refl1d_w.calculate(Q, 'MyModel') - assert_allclose(refnx_r, refl1d_r, rtol=0.25) + return refnx_r, refl1d_r -def test_linear_spline_consistent_across_engines(): - """LinearSpline resolution gives consistent results across engines. +def _assert_log_close(refnx_r, refl1d_r, atol): + """Assert the engines agree to `atol` decades of R at every q.""" + deviation = np.abs(np.log10(refnx_r) - np.log10(refl1d_r)) + assert deviation.max() <= atol, ( + f'engines disagree by {deviation.max():.4f} decades ' + f'(factor {10 ** deviation.max():.2f}) at q={Q[np.argmax(deviation)]:.4f}, tolerance {atol}' + ) + - This is the key regression test for issue #367 -- before the fix, refl1d - over-smeared by ~2.355x with LinearSpline because the FWHM->sigma - conversion was missing, while refnx treated the same vector as FWHM. +@pytest.mark.fast +@pytest.mark.parametrize(('resolution_pct', 'atol'), [(1.0, 0.07), (5.0, 0.23), (10.0, 0.29)]) +def test_percentage_fwhm_consistent_across_engines(resolution_pct, atol): + """PercentageFwhm gives consistent results across engines. + + Measured disagreement grows with the width (0.041 / 0.149 / 0.191 decades + at 1% / 5% / 10% dQ/Q), so the tolerance is parametrized with it rather + than set to one blanket value. This combination was correct both before + and after issue #367; the test guards against regression. """ - q = np.geomspace(0.005, 0.3, 100) + refnx_r, refl1d_r = _both_engines(PercentageFwhm(resolution_pct)) + _assert_log_close(refnx_r, refl1d_r, atol=atol) - # A plausible per-point FWHM resolution that increases with q. - q_knots = np.linspace(0.001, 0.5, 10) - fwhm_knots = 0.02 * q_knots + 0.001 - refnx_w = RefnxWrapper() - _build_simple_model_refnx(refnx_w) - refnx_w.set_resolution_function(LinearSpline(q_knots, fwhm_knots)) - refnx_r = refnx_w.calculate(q, 'MyModel') +@pytest.mark.fast +def test_linear_spline_consistent_across_engines(): + """LinearSpline gives consistent results across engines. - refl1d_w = Refl1dWrapper() - _build_simple_model_refl1d(refl1d_w) - refl1d_w.set_resolution_function(LinearSpline(q_knots, fwhm_knots)) - refl1d_r = refl1d_w.calculate(q, 'MyModel') + This one does earn its keep: pre-fix, refl1d read the FWHM knots as sigma + and over-smeared by 2.355x. Measured max |dlog10(R)|: 0.085 with the fix, + 0.182 without it, so atol=0.13 separates them with ~1.5x headroom either + way. + """ + q_knots = np.linspace(0.001, 0.5, 10) + fwhm_knots = 0.02 * q_knots + 0.001 - assert_allclose(refnx_r, refl1d_r, rtol=0.25) + refnx_r, refl1d_r = _both_engines(LinearSpline(q_knots, fwhm_knots)) + _assert_log_close(refnx_r, refl1d_r, atol=0.13) +@pytest.mark.fast def test_pointwise_consistent_across_engines(): - """Pointwise resolution (sigma from sQz) is consistent across engines. + """Pointwise (sigma from sQz) is consistent across engines. - Before the fix, refnx treated the sigma values as FWHM and so - under-smeared by ~2.355x relative to refl1d. - """ - q = np.geomspace(0.005, 0.3, 100) + Consistency check only. Pre-fix, refnx under-smeared these widths by + 2.355x, but measured max |dlog10(R)| is 0.092 pre-fix versus 0.085 with + the fix -- indistinguishable, because under-smearing an already-small + width barely moves the curve. Do not add a tolerance here expecting it to + catch that bug; ``tests/calculators/test_resolution_conventions.py`` is + what actually pins it. - # sQz is the variance of Qz; sigma = sqrt(sQz) increases with q. The - # magnitude mirrors the LinearSpline test (whose FWHM knots become these - # sigma values) so both engines apply the same modest smearing. + The sQz values mirror the LinearSpline knots, so the applied smearing -- + and hence the measured agreement -- matches that test. + """ qz = np.linspace(0.001, 0.5, 50) r = np.ones_like(qz) # only kept for serialization round-trips sigma = (0.02 * qz + 0.001) / SIGMA_TO_FWHM sqz = sigma**2 - refnx_w = RefnxWrapper() - _build_simple_model_refnx(refnx_w) - refnx_w.set_resolution_function(Pointwise([qz, r, sqz])) - refnx_r = refnx_w.calculate(q, 'MyModel') - - refl1d_w = Refl1dWrapper() - _build_simple_model_refl1d(refl1d_w) - refl1d_w.set_resolution_function(Pointwise([qz, r, sqz])) - refl1d_r = refl1d_w.calculate(q, 'MyModel') - - assert_allclose(refnx_r, refl1d_r, rtol=0.25) + refnx_r, refl1d_r = _both_engines(Pointwise([qz, r, sqz])) + _assert_log_close(refnx_r, refl1d_r, atol=0.13) diff --git a/tests/model/test_resolution_functions.py b/tests/model/test_resolution_functions.py index 8a1a5116..480ca2c6 100644 --- a/tests/model/test_resolution_functions.py +++ b/tests/model/test_resolution_functions.py @@ -19,9 +19,11 @@ def test_constructor(self): resolution_function = PercentageFwhm(1.0) # Then Expect: smearing() returns sigma = (constant / 100) * q / SIGMA_TO_FWHM + # Negative q is not asserted: sigma scales with q here, so q < 0 yields a + # negative width, which is meaningless. Leaving it unpinned keeps the door + # open to guarding with abs(q) without failing this test. expected = (1.0 / 100.0) * np.array([0.0, 2.5]) / SIGMA_TO_FWHM assert np.allclose(resolution_function.smearing([0, 2.5]), expected) - assert np.allclose(resolution_function.smearing([-100]), (1.0 / 100.0) * (-100.0) / SIGMA_TO_FWHM) assert np.allclose(resolution_function.smearing([100]), (1.0 / 100.0) * 100.0 / SIGMA_TO_FWHM) def test_constructor_none(self): @@ -29,10 +31,10 @@ def test_constructor_none(self): resolution_function = PercentageFwhm() # Then Expect: defaults to DEFAULT_RESOLUTION_FWHM_PERCENTAGE, returned as sigma + # Negative q is not asserted -- see test_constructor. c = DEFAULT_RESOLUTION_FWHM_PERCENTAGE expected = (c / 100.0) * np.array([0.0, 2.5]) / SIGMA_TO_FWHM assert np.allclose(resolution_function.smearing([0, 2.5]), expected) - assert np.allclose(resolution_function.smearing([-100]), (c / 100.0) * (-100.0) / SIGMA_TO_FWHM) assert np.allclose(resolution_function.smearing([100]), (c / 100.0) * 100.0 / SIGMA_TO_FWHM) def test_as_dict(self): @@ -60,6 +62,8 @@ def test_constructor(self): resolution_function = LinearSpline(q_data_points=[0, 10], fwhm_values=[5, 10]) # Then Expect: smearing() returns sigma (FWHM knots converted to sigma) + # Unlike PercentageFwhm, q outside the knot range is meaningful here: + # np.interp clamps to the end knots, so the width stays positive. assert np.allclose(resolution_function.smearing([0, 2.5]), np.array([5, 6.25]) / SIGMA_TO_FWHM) assert np.allclose(resolution_function.smearing([-100]), np.array([5.0]) / SIGMA_TO_FWHM) assert np.allclose(resolution_function.smearing([100]), np.array([10.0]) / SIGMA_TO_FWHM) From fbf709b4d2b418b32627a1f7a2985b177c1d3c50 Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Jul 2026 12:42:06 +0200 Subject: [PATCH 4/6] added test --- .../test_resolution_conventions.py | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 tests/calculators/test_resolution_conventions.py diff --git a/tests/calculators/test_resolution_conventions.py b/tests/calculators/test_resolution_conventions.py new file mode 100644 index 00000000..5b7c7fb7 --- /dev/null +++ b/tests/calculators/test_resolution_conventions.py @@ -0,0 +1,207 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Absolute checks on the width convention each wrapper hands to its backend. + +``ResolutionFunction.smearing()`` returns sigma for every resolution type; each +wrapper is then responsible for converting to what its backend expects: + +* refnx -- ``x_err`` is the **FWHM** at each q (a scalar ``x_err`` is instead a + constant dQ/Q FWHM *percentage*). +* refl1d -- ``QProbe.dQ`` is **sigma**. + +The cross-engine tests in ``tests/integration/test_cross_engine_resolution.py`` +only pin the engines against each other, so they cannot catch an error applied +consistently to both. These tests intercept the value at each engine boundary +and assert the exact numbers, which pins the convention absolutely. In +particular this is the only absolute check on the refl1d resolution path. + +See GitHub issue #367 for background. +""" + +import numpy as np +import pytest +from numpy.testing import assert_allclose +from refl1d import names +from refnx import reflect + +from easyreflectometry.calculators.refl1d.wrapper import Refl1dWrapper +from easyreflectometry.calculators.refnx.wrapper import RefnxWrapper +from easyreflectometry.model.resolution_functions import SIGMA_TO_FWHM +from easyreflectometry.model.resolution_functions import LinearSpline +from easyreflectometry.model.resolution_functions import PercentageFwhm +from easyreflectometry.model.resolution_functions import Pointwise + +Q = np.linspace(0.01, 0.3, 20) + +Q_KNOTS = np.linspace(0.001, 0.5, 10) +FWHM_KNOTS = 0.02 * Q_KNOTS + 0.001 + +QZ = np.linspace(0.001, 0.5, 50) +SIGMA_POINTS = 0.01 * QZ + 0.0005 +SQZ = SIGMA_POINTS**2 + + +def _build_refnx(): + wrapper = RefnxWrapper() + wrapper.reset_storage() + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', real=2.07, imag=0.0) + wrapper.create_material('Film') + wrapper.update_material('Film', real=3.45, imag=0.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_layer('FilmLayer') + wrapper.assign_material_to_layer('Film', 'FilmLayer') + wrapper.update_layer('FilmLayer', thick=100.0, rough=3.0) + wrapper.create_item('Item') + wrapper.add_layer_to_item('FilmLayer', 'Item') + wrapper.add_layer_to_item('SubstrateLayer', 'Item') + wrapper.create_model('MyModel') + wrapper.add_item('Item', 'MyModel') + wrapper.update_model('MyModel', bkg=0.0) + return wrapper + + +def _build_refl1d(): + wrapper = Refl1dWrapper() + wrapper.reset_storage() + wrapper.create_material('Substrate') + wrapper.update_material('Substrate', rho=2.07, irho=0.0) + wrapper.create_material('Film') + wrapper.update_material('Film', rho=3.45, irho=0.0) + wrapper.create_layer('SubstrateLayer') + wrapper.assign_material_to_layer('Substrate', 'SubstrateLayer') + wrapper.create_layer('FilmLayer') + wrapper.assign_material_to_layer('Film', 'FilmLayer') + wrapper.update_layer('FilmLayer', thickness=100.0, interface=3.0) + wrapper.create_item('Item') + wrapper.add_layer_to_item('FilmLayer', 'Item') + wrapper.add_layer_to_item('SubstrateLayer', 'Item') + wrapper.create_model('MyModel') + wrapper.add_item('Item', 'MyModel') + wrapper.update_model('MyModel', bkg=0.0) + return wrapper + + +def _capture_refnx_x_err(monkeypatch, resolution_function): + """Run RefnxWrapper.calculate and return the x_err handed to refnx.""" + captured = {} + real_call = reflect.ReflectModel.__call__ + + def spy(self, x, p=None, x_err=None): + captured['x_err'] = x_err + return real_call(self, x, p=p, x_err=x_err) + + monkeypatch.setattr(reflect.ReflectModel, '__call__', spy) + + wrapper = _build_refnx() + wrapper.set_resolution_function(resolution_function) + wrapper.calculate(Q, 'MyModel') + return captured['x_err'] + + +def _capture_refl1d_dq(monkeypatch, resolution_function): + """Run Refl1dWrapper.calculate and return the dQ handed to refl1d's QProbe.""" + captured = {} + real_qprobe = names.QProbe + + def spy(**kwargs): + captured['dQ'] = np.asarray(kwargs['dQ'], dtype=float) + return real_qprobe(**kwargs) + + monkeypatch.setattr(names, 'QProbe', spy) + + wrapper = _build_refl1d() + wrapper.set_resolution_function(resolution_function) + wrapper.calculate(Q, 'MyModel') + return captured['dQ'] + + +# ----- the constant itself ----- + + +@pytest.mark.fast +def test_sigma_to_fwhm_is_the_gaussian_ratio(): + """Pin SIGMA_TO_FWHM against a literal. + + Every other test in this module imports SIGMA_TO_FWHM -- the same constant + the production code uses -- so a wrong value would cancel out on both sides + of the assertion and stay invisible. This is the one place the constant is + checked against an external fact: the FWHM/sigma ratio of a Gaussian, + 2*sqrt(2*ln2). + """ + assert SIGMA_TO_FWHM == pytest.approx(2.3548200450309493) + + +# ----- refnx expects FWHM ----- + + +@pytest.mark.fast +def test_refnx_receives_fwhm_for_linear_spline(monkeypatch): + x_err = _capture_refnx_x_err(monkeypatch, LinearSpline(Q_KNOTS, FWHM_KNOTS)) + + expected_fwhm = np.interp(Q, Q_KNOTS, FWHM_KNOTS) + assert_allclose(x_err, expected_fwhm) + + +@pytest.mark.fast +def test_refnx_receives_fwhm_for_pointwise(monkeypatch): + x_err = _capture_refnx_x_err(monkeypatch, Pointwise([QZ, np.ones_like(QZ), SQZ])) + + expected_sigma = np.interp(Q, QZ, np.sqrt(SQZ)) + assert_allclose(x_err, expected_sigma * SIGMA_TO_FWHM) + + +@pytest.mark.fast +def test_refnx_receives_scalar_percentage_for_percentage_fwhm(monkeypatch): + x_err = _capture_refnx_x_err(monkeypatch, PercentageFwhm(5.0)) + + # refnx reads a scalar x_err as a constant dQ/Q FWHM percentage, so the + # percentage is passed through verbatim -- not converted to a width. + assert np.isscalar(x_err) or np.ndim(x_err) == 0 + assert_allclose(x_err, 5.0) + + +# ----- refl1d expects sigma ----- + + +@pytest.mark.fast +def test_refl1d_receives_sigma_for_linear_spline(monkeypatch): + dq = _capture_refl1d_dq(monkeypatch, LinearSpline(Q_KNOTS, FWHM_KNOTS)) + + expected_fwhm = np.interp(Q, Q_KNOTS, FWHM_KNOTS) + assert_allclose(dq, expected_fwhm / SIGMA_TO_FWHM) + + +@pytest.mark.fast +def test_refl1d_receives_sigma_for_pointwise(monkeypatch): + dq = _capture_refl1d_dq(monkeypatch, Pointwise([QZ, np.ones_like(QZ), SQZ])) + + expected_sigma = np.interp(Q, QZ, np.sqrt(SQZ)) + assert_allclose(dq, expected_sigma) + + +@pytest.mark.fast +def test_refl1d_receives_sigma_for_percentage_fwhm(monkeypatch): + dq = _capture_refl1d_dq(monkeypatch, PercentageFwhm(5.0)) + + expected_sigma = (5.0 / 100.0) * Q / SIGMA_TO_FWHM + assert_allclose(dq, expected_sigma) + + +# ----- the two backends must receive widths that differ by exactly SIGMA_TO_FWHM ----- + + +@pytest.mark.fast +def test_engines_receive_widths_differing_by_sigma_to_fwhm(monkeypatch): + """The whole point of issue #367, stated directly. + + Catches a common-mode error that the cross-engine reflectivity comparison + cannot see: whatever the widths are, refnx's must be exactly SIGMA_TO_FWHM + times refl1d's. + """ + x_err = _capture_refnx_x_err(monkeypatch, LinearSpline(Q_KNOTS, FWHM_KNOTS)) + dq = _capture_refl1d_dq(monkeypatch, LinearSpline(Q_KNOTS, FWHM_KNOTS)) + + assert_allclose(x_err, dq * SIGMA_TO_FWHM) From dc2f66d2c7419f71c15c1cb6a283e05cef5c50ca Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Jul 2026 15:28:15 +0200 Subject: [PATCH 5/6] arviz fix --- .github/workflows/pypi-test.yml | 4 +++- .github/workflows/test.yml | 9 ++++----- pixi.lock | 1 + pixi.toml | 10 +++++++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pypi-test.yml b/.github/workflows/pypi-test.yml index 2f4f58ac..8bcb4b5e 100644 --- a/.github/workflows/pypi-test.yml +++ b/.github/workflows/pypi-test.yml @@ -71,7 +71,9 @@ jobs: - name: Run integration tests to verify the installation working-directory: easyreflectometry - run: pixi run python -m pytest ../tests/integration/ --color=yes -n auto + # No -n auto: concurrent xdist workers race on arviz's daily-warning + # stamp file when they import easyreflectometry. See pixi.toml. + run: pixi run python -m pytest ../tests/integration/ --color=yes # Job 2: Build and publish dashboard (reusable workflow) run-reusable-workflows: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 55b71d70..530c154f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -293,11 +293,10 @@ jobs: cd easyreflectometry_py$py_ver echo "Running tests" - if [[ "${{ runner.os }}" == "Windows" ]]; then - pixi run python -m pytest ../tests/integration/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} - else - pixi run python -m pytest ../tests/integration/ --color=yes -n auto -v ${{ needs.env-prepare.outputs.pytest-marks }} - fi + # No -n auto: concurrent xdist workers race on arviz's daily-warning + # stamp file when they import easyreflectometry. See pixi.toml. + pixi run python -m pytest ../tests/integration/ --color=yes -v ${{ needs.env-prepare.outputs.pytest-marks }} + echo "Exiting pixi project directory" cd .. done diff --git a/pixi.lock b/pixi.lock index 6b3a38bc..adf105a4 100644 --- a/pixi.lock +++ b/pixi.lock @@ -8303,6 +8303,7 @@ packages: - bumps - easyscience @ git+https://github.com/easyscience/corelib@develop - orsopy + - plotly - pooch - refl1d>=1.0.0 - refnx diff --git a/pixi.toml b/pixi.toml index b0fb2b8d..ab6c977e 100644 --- a/pixi.toml +++ b/pixi.toml @@ -94,7 +94,15 @@ user = { features = ['py-max', 'user'] } unit-tests = 'python -m pytest tests/unit/ --color=yes -v' functional-tests = 'python -m pytest tests/functional/ --color=yes -v' -integration-tests = 'python -m pytest tests/integration/ --color=yes -n auto -v' +# No -n auto: importing easyreflectometry pulls in arviz, and arviz 0.23.4 +# (py-311-env) writes a "warn once per day" stamp file on import via a +# _atomic_write_text() that is not atomic -- every process writes the same +# fixed `daily_warning.tmp` and renames it onto `daily_warning`. Concurrent +# xdist workers therefore race on that rename: FileNotFoundError on Linux, +# PermissionError (WinError 32) on Windows. The real fix is +# to stop importing arviz in easyreflectometry/__init__.py, after which +# xdist can come back. +integration-tests = 'python -m pytest tests/integration/ --color=yes -v' notebook-tests = 'python -m pytest --nbmake docs/docs/tutorials/**/ --nbmake-timeout=1200 --color=yes -n auto -v' test = { depends-on = ['unit-tests'] } From 2f5a83bce78754fde0181b8d459a2a15d143d79a Mon Sep 17 00:00:00 2001 From: Piotr Rozyczko Date: Wed, 15 Jul 2026 15:45:30 +0200 Subject: [PATCH 6/6] try to fix prettier issues --- CONTRIBUTING.md | 3 +-- docs/mkdocs.yml | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a4ac1fbc..a6ceeebd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,8 +42,7 @@ Please make sure you follow the EasyScience organization-wide If you are not planning to contribute code, you may want to: - 🐞 Report a bug — see [Reporting Issues](#11-reporting-issues) -- 🛡 Report a security issue — see - [Security Issues](#12-security-issues) +- 🛡 Report a security issue — see [Security Issues](#12-security-issues) - 💬 Ask a question or start a discussion at [Project Discussions](https://github.com/easyscience/reflectometry-lib/discussions) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index f75e4657..1cd19cde 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -208,7 +208,8 @@ nav: - Elements: - Layers: - Layer: api-reference/elements/layer.md - - Layer Area Per Molecule: api-reference/elements/layer_area_per_molecule.md + - Layer Area Per Molecule: + api-reference/elements/layer_area_per_molecule.md - Materials: - Material: api-reference/elements/material.md - Material Density: api-reference/elements/material_density.md