diff --git a/src/easyreflectometry/sample/assemblies/gradient_layer.py b/src/easyreflectometry/sample/assemblies/gradient_layer.py index fa38879c..e662b13a 100644 --- a/src/easyreflectometry/sample/assemblies/gradient_layer.py +++ b/src/easyreflectometry/sample/assemblies/gradient_layer.py @@ -4,7 +4,7 @@ from typing import Optional from easyscience import global_object -from numpy import arange +from numpy import linspace from ..collections.layer_collection import LayerCollection from ..elements.layers.layer import Layer @@ -16,6 +16,27 @@ class GradientLayer(BaseAssembly): """A set of discrete gradient layers changing from the front to the back material. The front layer is where the neutron beam starts in, it has an index of 0. + + .. note:: + + **The front and back materials are frozen at construction.** Their SLD / + iSLD values are sampled once (see :func:`_prepare_gradient_layers`) to + seed the discrete sublayers, which span the two endpoints inclusively. + The end materials are *not* wired to the sublayers, so mutating or + fitting ``front_material`` / ``back_material`` after construction has no + effect on the computed reflectivity. To keep this from being a silent + no-op, the end materials are excluded from the parameter tree + (:meth:`get_all_variables`) and are therefore not offered as fittable + parameters. Fit the discrete sublayers (``layers``) directly, or rebuild + the :class:`GradientLayer` with new end materials, to change the profile. + + This is a wiring gap, not a calculator limitation: the sublayer SLDs + are plain snapshots and no dependency expressions connect them to the + end materials. Since easyscience now propagates bound dependent + parameters to the calculator (see :class:`MaterialMixture`, whose + ``fraction`` drives the mixed SLD through exactly such dependencies), + end-material fitting can be implemented by making each sublayer SLD + dependent on the end-material SLDs (issue #373). """ def __init__( @@ -58,7 +79,7 @@ def __init__( back_material = Material(6.36, 0.0, 'D2O') if discretisation_elements < 2: - raise ValueError('Discretisation elements must be greater than 2.') + raise ValueError('Discretisation elements must be at least 2.') gradient_layers = _prepare_gradient_layers( front_material=front_material, @@ -134,6 +155,22 @@ def roughness(self, roughness: float) -> None: """ self.front_layer.roughness.value = roughness + def get_all_variables(self): + """Return the fittable variables, excluding the frozen end materials. + + The ``front_material`` / ``back_material`` SLD & iSLD only seed the + discrete sublayers at construction and are not wired to them (see the + class docstring). Exposing them here would advertise them as fittable + while mutating them silently has no effect on the reflectivity, so they + are filtered out. The discrete sublayers — which *do* drive the + reflectivity — remain in the tree and can be fitted directly. + """ + excluded = set() + for material in (self._front_material, self._back_material): + for variable in material.get_all_variables(): + excluded.add(id(variable)) + return [variable for variable in super().get_all_variables() if id(variable) not in excluded] + @property def _dict_repr(self) -> dict[str, str]: """A simplified dict representation.""" @@ -175,14 +212,14 @@ def _linear_gradient( back_value: float, discretisation_elements: int, ) -> list[float]: - """Linear gradient.""" - discrete_step = (back_value - front_value) / discretisation_elements - if discrete_step != 0: - # Both front and back values are included - gradient = arange(front_value, back_value + discrete_step, discrete_step) - else: - gradient = [front_value] * discretisation_elements - return gradient + """Linear gradient of ``discretisation_elements`` evenly spaced values. + + Both endpoints are included: the first value equals ``front_value`` and the + last equals ``back_value``. ``linspace`` guarantees exactly + ``discretisation_elements`` values, so the back material is represented by + the final sublayer instead of being dropped one step short. + """ + return [float(value) for value in linspace(front_value, back_value, discretisation_elements)] def _prepare_gradient_layers( diff --git a/tests/integration/test_dependent_parameter_binding.py b/tests/integration/test_dependent_parameter_binding.py new file mode 100644 index 00000000..1cd7cb07 --- /dev/null +++ b/tests/integration/test_dependent_parameter_binding.py @@ -0,0 +1,220 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause +"""Regression tests: bound *dependent* parameters must reach the calculator. + +Historically `easyscience.Parameter._update()` recomputed a dependent +parameter's value but never pushed it through `_callback.fset`, so the +calculator (refnx / refl1d) kept the value from binding time. Every +constraint expressed through a bound dependent parameter was then a silent +no-op during simulation and fitting: + +- `MaterialMixture`: `fraction` had zero effect on reflectivity and never + moved in a fit (`_sld` / `_isld` are dependent parameters bound to the + calculator), +- `GradientLayer` (and other assemblies): the thickness / roughness + constraints updated only the front layer on the calculator side. + +These tests pin the fixed behavior end-to-end against real calculators. +""" + +import numpy as np +import pytest +from easyscience import global_object + +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.data import DataSet1D +from easyreflectometry.fitting import MultiFitter +from easyreflectometry.model import Model +from easyreflectometry.model import PercentageFwhm +from easyreflectometry.sample import GradientLayer +from easyreflectometry.sample import Layer +from easyreflectometry.sample import Material +from easyreflectometry.sample import MaterialMixture +from easyreflectometry.sample import Multilayer +from easyreflectometry.sample import Sample + +CALCULATORS = ['refnx', 'refl1d'] + + +def _mixture_model(fraction: float) -> tuple[Model, MaterialMixture]: + """Air | mixture(100 A) | Si substrate, only the mixture is interesting.""" + air = Material(0.0, 0.0, 'Air') + si = Material(2.07, 0.0, 'Si') + mixture = MaterialMixture( + material_a=Material(2.0, 0.0, 'A'), + material_b=Material(6.0, 0.0, 'B'), + fraction=fraction, + name='Mix', + ) + superphase = Layer(air, 0, 0, 'Air layer') + mix_layer = Layer(mixture, 100, 3, 'Mix layer') + subphase = Layer(si, 0, 3, 'Si substrate') + sample = Sample( + Multilayer(superphase), + Multilayer(mix_layer), + Multilayer(subphase), + name='Mix sample', + ) + model = Model(sample, 1, 1e-9, PercentageFwhm(2.0), 'Mix model') + return model, mixture + + +class TestMaterialMixtureReachesCalculator: + @pytest.fixture(autouse=True) + def _clean_map(self): + global_object.map._clear() + yield + global_object.map._clear() + + @pytest.mark.parametrize('calculator', CALCULATORS) + def test_fraction_change_updates_calculator_storage(self, calculator): + # When + model, mixture = _mixture_model(fraction=0.2) + interface = CalculatorFactory() + interface.switch(calculator) + model.interface = interface + + # Then: binding pushed the initial dependent value to the calculator + assert mixture.sld._callback.fget() == pytest.approx(mixture.sld.value_no_call_back) + + # When: the independent driver changes + mixture.fraction = 0.8 + + # Expect: python-side dependency math ran ... + expected_sld = 2.0 * (1 - 0.8) + 6.0 * 0.8 + assert mixture.sld.value_no_call_back == pytest.approx(expected_sld) + # ... and, crucially, the calculator-side storage was updated too + # (this was the silent no-op: fset was never called from _update). + assert mixture.sld._callback.fget() == pytest.approx(expected_sld) + # ... and reading .value does not clobber the result with a stale one + assert mixture.sld.value == pytest.approx(expected_sld) + + @pytest.mark.parametrize('calculator', CALCULATORS) + def test_fraction_change_updates_reflectivity(self, calculator): + # When + model, mixture = _mixture_model(fraction=0.2) + interface = CalculatorFactory() + interface.switch(calculator) + model.interface = interface + q = np.linspace(0.01, 0.3, 50) + before = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Then + mixture.fraction = 0.8 + after = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Expect: the fraction drives the computed reflectivity + assert not np.allclose(before, after) + + +class TestAssemblyConstraintsReachCalculator: + @pytest.fixture(autouse=True) + def _clean_map(self): + global_object.map._clear() + yield + global_object.map._clear() + + def _gradient_model(self) -> tuple[Model, GradientLayer]: + gradient = GradientLayer( + front_material=Material(2.0, 0.0, 'Front'), + back_material=Material(6.0, 0.0, 'Back'), + thickness=60.0, + roughness=1.0, + discretisation_elements=6, + name='Gradient', + ) + model = Model(interface=None) + model.add_assemblies(gradient) + return model, gradient + + @pytest.mark.parametrize('calculator', CALCULATORS) + def test_thickness_constraint_updates_all_sublayers(self, calculator): + # When + model, gradient = self._gradient_model() + interface = CalculatorFactory() + interface.switch(calculator) + model.interface = interface + + # Then: the assembly setter drives the front layer; the dependent + # sublayers must follow it into the calculator storage. + gradient.thickness = 120.0 + per_sublayer = 120.0 / gradient.discretisation_elements + + # Expect + for sublayer in gradient.layers: + assert sublayer.thickness.value_no_call_back == pytest.approx(per_sublayer) + assert sublayer.thickness._callback.fget() == pytest.approx(per_sublayer) + + @pytest.mark.parametrize('calculator', CALCULATORS) + def test_roughness_constraint_updates_all_sublayers(self, calculator): + # When + model, gradient = self._gradient_model() + interface = CalculatorFactory() + interface.switch(calculator) + model.interface = interface + + # Then + gradient.roughness = 5.0 + + # Expect + for sublayer in gradient.layers: + assert sublayer.roughness.value_no_call_back == pytest.approx(5.0) + assert sublayer.roughness._callback.fget() == pytest.approx(5.0) + + @pytest.mark.parametrize('calculator', CALCULATORS) + def test_thickness_change_updates_reflectivity(self, calculator): + # When + model, gradient = self._gradient_model() + interface = CalculatorFactory() + interface.switch(calculator) + model.interface = interface + q = np.linspace(0.01, 0.3, 50) + before = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Then + gradient.thickness = 120.0 + after = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Expect + assert not np.allclose(before, after) + + +class TestMaterialMixtureFractionFits: + @pytest.fixture(autouse=True) + def _clean_map(self): + global_object.map._clear() + yield + global_object.map._clear() + + def test_fit_moves_fraction_to_truth(self): + """End-to-end: a fit recovers the fraction that generated the data. + + Before the easyscience fix the minimizer saw zero gradient in + `fraction` (the calculator never received the updated mixed SLD), + so the fit left the start value untouched. + """ + # When: synthesize data at the true fraction + truth = 0.7 + start = 0.3 + model, mixture = _mixture_model(fraction=truth) + model.interface = CalculatorFactory() + q = np.linspace(0.01, 0.3, 100) + y_true = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Then: start the fit elsewhere with fraction as the only free parameter + mixture.fraction = start + mixture.fraction.fixed = False + data = DataSet1D( + name='synthetic', + x=q, + y=y_true, + ye=(0.01 * y_true) ** 2, # ye stores variances + ) + fitter = MultiFitter(model) + result = fitter.fit_single_data_set_1d(data) + + # Expect: the fraction moved and recovered the truth + fitted = mixture.fraction.value + assert result.success + assert fitted != pytest.approx(start), 'fraction did not move during fit' + assert fitted == pytest.approx(truth, abs=0.05) diff --git a/tests/sample/assemblies/test_gradient_layer.py b/tests/sample/assemblies/test_gradient_layer.py index 7efda4a9..b67baf6e 100644 --- a/tests/sample/assemblies/test_gradient_layer.py +++ b/tests/sample/assemblies/test_gradient_layer.py @@ -7,11 +7,14 @@ from unittest.mock import MagicMock +import numpy as np import pytest from easyscience import global_object from numpy.testing import assert_almost_equal import easyreflectometry.sample.assemblies.gradient_layer +from easyreflectometry.calculators import CalculatorFactory +from easyreflectometry.model import Model from easyreflectometry.sample.assemblies.gradient_layer import GradientLayer from easyreflectometry.sample.assemblies.gradient_layer import _linear_gradient from easyreflectometry.sample.assemblies.gradient_layer import _prepare_gradient_layers @@ -45,12 +48,14 @@ def test_init(self, gradient_layer: GradientLayer) -> None: assert gradient_layer.thickness == 1.0 assert gradient_layer.back_layer.thickness.value == 0.1 + # The discretisation spans both endpoints inclusively: the front layer + # equals the front material and the back layer equals the back material. assert gradient_layer.front_layer.material.sld.value == 10.0 - assert gradient_layer.layers[5].material.sld.value == 5.0 - assert gradient_layer.back_layer.material.sld.value == 1.0 + assert gradient_layer.back_layer.material.sld.value == 0.0 + assert_almost_equal(gradient_layer.layers[5].material.sld.value, 10.0 + (5 / 9) * (0.0 - 10.0)) assert gradient_layer.front_layer.material.isld.value == -10.0 - assert gradient_layer.layers[5].material.isld.value == -5.0 - assert gradient_layer.back_layer.material.isld.value == -1.0 + assert gradient_layer.back_layer.material.isld.value == 0.0 + assert_almost_equal(gradient_layer.layers[5].material.isld.value, -10.0 + (5 / 9) * (0.0 - -10.0)) def test_default(self) -> None: # When Then @@ -85,7 +90,7 @@ def test_from_pars(self) -> None: def test_repr(self, gradient_layer: GradientLayer) -> None: # When Then Expect - expected_str = "thickness: 1.0\ndiscretisation_elements: 10\nback_layer:\n '9':\n material:\n EasyMaterial:\n sld: 1.000e-6 1/Å^2\n isld: -1.000e-6 1/Å^2\n thickness: 0.100 Å\n roughness: 2.000 Å\nfront_layer:\n '0':\n material:\n EasyMaterial:\n sld: 10.000e-6 1/Å^2\n isld: -10.000e-6 1/Å^2\n thickness: 0.100 Å\n roughness: 2.000 Å\n" # noqa: E501 + expected_str = "thickness: 1.0\ndiscretisation_elements: 10\nback_layer:\n '9':\n material:\n EasyMaterial:\n sld: 0.000e-6 1/Å^2\n isld: 0.000e-6 1/Å^2\n thickness: 0.100 Å\n roughness: 2.000 Å\nfront_layer:\n '0':\n material:\n EasyMaterial:\n sld: 10.000e-6 1/Å^2\n isld: -10.000e-6 1/Å^2\n thickness: 0.100 Å\n roughness: 2.000 Å\n" # noqa: E501 assert gradient_layer.__repr__() == expected_str def test_dict_round_trip(self) -> None: @@ -138,29 +143,121 @@ def test_roughness_getter(self, gradient_layer: GradientLayer) -> None: # Then assert gradient_layer.roughness == 10.0 + # ----- issue #373 regression coverage ----- + + @pytest.mark.parametrize('elements', [0, 1]) + def test_too_few_discretisation_elements_raises(self, elements: int) -> None: + # When Then Expect: the message matches the `< 2` guard. + with pytest.raises(ValueError, match='at least 2'): + GradientLayer(discretisation_elements=elements) + + def test_minimum_discretisation_elements_allowed(self) -> None: + # When: exactly two elements is the minimum (front + back). + front = Material(2.0, 0.0, 'Front') + back = Material(6.0, 0.0, 'Back') + result = GradientLayer(front_material=front, back_material=back, discretisation_elements=2, name='minimal') + + # Then Expect: both endpoints present, nothing dropped. + assert len(result.layers) == 2 + assert result.front_layer.material.sld.value == 2.0 + assert result.back_layer.material.sld.value == 6.0 + + def test_back_endpoint_included(self, gradient_layer: GradientLayer) -> None: + # When Then Expect: the final sublayer equals the back material (no off-by-one). + assert gradient_layer.back_layer.material.sld.value == self.back.sld.value + assert gradient_layer.back_layer.material.isld.value == self.back.isld.value + + def test_end_materials_excluded_from_parameter_tree(self, gradient_layer: GradientLayer) -> None: + # When + parameters = gradient_layer.get_all_parameters() + + # Expect: the frozen end materials are not offered as fittable parameters ... + assert not any(p is self.front.sld for p in parameters) + assert not any(p is self.front.isld for p in parameters) + assert not any(p is self.back.sld for p in parameters) + assert not any(p is self.back.isld for p in parameters) + # ... but the discrete sublayers (which do drive reflectivity) remain. + for layer in gradient_layer.layers: + assert any(p is layer.material.sld for p in parameters) + + def test_end_materials_excluded_even_when_unfixed(self, gradient_layer: GradientLayer) -> None: + # When: a user unfixes the endpoint hoping to fit it. + self.front.sld.fixed = False + self.back.sld.fixed = False + + # Then Expect: it still never reaches the fitter's free-parameter list, + # so the silent no-op cannot happen. + free = gradient_layer.get_free_parameters() + assert not any(p is self.front.sld for p in free) + assert not any(p is self.back.sld for p in free) + + def test_end_materials_are_frozen(self, gradient_layer: GradientLayer) -> None: + # When: the end material is mutated after construction. + before = [layer.material.sld.value for layer in gradient_layer.layers] + self.front.sld.value = 4.0 + after = [layer.material.sld.value for layer in gradient_layer.layers] + + # Then Expect: the sublayers keep their construction-time snapshot. + assert before == after + + def test_sublayers_drive_reflectivity(self) -> None: + # When + global_object.map._clear() + front = Material(2.0, 0.0, 'Front') + back = Material(6.0, 0.0, 'Back') + gradient = GradientLayer(front_material=front, back_material=back, thickness=60.0, discretisation_elements=6, name='G') + model = Model(interface=CalculatorFactory()) + model.add_assemblies(gradient) + q = [0.05, 0.1, 0.2] + before = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Then: mutating a sublayer SLD (a real, in-tree parameter) changes reflectivity ... + gradient.layers[2].material.sld.value = 3.9 + after_sublayer = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + # ... while mutating the frozen end material does not. + front.sld.value = 5.0 + after_endpoint = np.asarray(model.interface().reflectivity_profile(q, model.unique_name)) + + # Expect + assert not np.allclose(before, after_sublayer) + assert np.allclose(after_sublayer, after_endpoint) + def test_linear_gradient_increasing(): # When Then - result = _linear_gradient(front_value=1.5, back_value=2.5, discretisation_elements=10) + result = _linear_gradient(front_value=1.5, back_value=2.5, discretisation_elements=11) - # Expect + # Expect: exactly `discretisation_elements` values, both endpoints included. + assert len(result) == 11 assert_almost_equal([1.5, 1.6, 1.7, 1.8, 1.9, 2.0, 2.1, 2.2, 2.3, 2.4, 2.5], result) def test_linear_gradient_decreasing(): # When Then - result = _linear_gradient(front_value=2.5, back_value=1.5, discretisation_elements=10) + result = _linear_gradient(front_value=2.5, back_value=1.5, discretisation_elements=11) # Expect + assert len(result) == 11 assert_almost_equal([2.5, 2.4, 2.3, 2.2, 2.1, 2.0, 1.9, 1.8, 1.7, 1.6, 1.5], result) +def test_linear_gradient_includes_both_endpoints(): + # When Then: with N values the last element is the back value (no off-by-one). + result = _linear_gradient(front_value=10.0, back_value=0.0, discretisation_elements=10) + + # Expect + assert len(result) == 10 + assert result[0] == 10.0 + assert result[-1] == 0.0 + + def test_linear_gradient_same(): # When Then result = _linear_gradient(front_value=2.5, back_value=2.5, discretisation_elements=10) # Expect - assert_almost_equal([2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5, 2.5], result) + assert len(result) == 10 + assert_almost_equal([2.5] * 10, result) def test_prepare_gradient_layers(monkeypatch):