Skip to content

volume_fraction

Volume fraction (occupancy) profiles of the components of a sample.

A volume fraction profile shows how much of the sample volume each component (each distinct material) occupies as a function of depth. It is the natural companion of the scattering length density (SLD) profile: where the SLD profile shows one blended curve, the volume fraction profile separates it into the lipid heads, the tails, the solvent, the substrate and so on, in the style of refnx.reflect.create_occupancy.

The profile is built directly from the sample tree, without a calculator:

  • every layer is decomposed into its constituent materials (:class:~easyreflectometry.sample.MaterialSolvated and :class:~easyreflectometry.sample.MaterialMixture are split with their fraction, repeating multilayers are expanded, gradient layers are read as a linear mixing gradient between their two end materials), and
  • the per-slab fractions are smeared across each interface with the same error-function kernel the plotted SLD profile uses, so the fractions sum to one at every depth and sum_c rho_c * phi_c(z) reproduces the SLD profile whenever each component has the same SLD in every slab it occupies.

Conventions and caveats:

  • z = 0 is the interface between the superphase and the first film layer and z increases into the sample, the same convention as the refnx calculator's SLD profile.
  • The error-function kernel is the plotting convention shared with the SLD profile, not the Névot-Croce factor the reflectivity calculation uses; the curves are a model-derived in-plane average, not a measured concentration profile.
  • Smearing is a sum of independent steps, so with very different roughness on the two sides of a thin layer a fraction can dip below zero. The values are returned unchanged (clipping or renormalising would change the model) and a :class:VolumeFractionWarning reports the excursion.
  • A solvent_fraction may describe lateral patchiness rather than solvation; the in-plane average is drawn either way.
  • For a :class:~easyreflectometry.sample.LayerAreaPerMolecule the fractions are those of the current material model, in which the solvent fraction acts as coverage of a layer whose molecule SLD is b / (thickness * area).

__all__ = ['DEFAULT_POINTS', 'NEGATIVE_FRACTION_TOLERANCE', 'VolumeFractionProfile', 'VolumeFractionWarning', 'volume_fraction_profile'] module-attribute

DEFAULT_POINTS = 500 module-attribute

NEGATIVE_FRACTION_TOLERANCE = 1e-06 module-attribute

_GRADIENT_RELATIVE_TOLERANCE = 1e-09 module-attribute

_GRADIENT_ABSOLUTE_TOLERANCE = 1e-12 module-attribute

_EQUIVALENCE_DECIMALS = 10 module-attribute

GradientLayer

Bases: 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.

Source code in src/easyreflectometry/sample/assemblies/gradient_layer.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
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.
    """

    def __init__(
        self,
        front_material: Optional[Material] = None,
        back_material: Optional[Material] = None,
        thickness: Optional[float] = 2.0,
        roughness: Optional[float] = 0.2,
        discretisation_elements: int = 10,
        name: str = 'EasyGradienLayer',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        front_material : Optional[Material], optional
            Material of front of the layer. By default, None.
        back_material : Optional[Material], optional
            Material of back of the layer. By default, None.
        thickness : Optional[float], optional
            Thicknkess of the layer. By default, 2.0.
        roughness : Optional[float], optional
            Roughness of the layer. By default, 0.2.
        discretisation_elements : int, optional
            Number of discrete layers. By default, 10.
        name : str, optional
            Name for gradient layer. By default, 'EasyGradienLayer'.
        interface :
            Calculator interface. By default, None.
        """

        if front_material is None:
            front_material = Material(0.0, 0.0, 'Air')

        if back_material is None:
            back_material = Material(6.36, 0.0, 'D2O')

        if discretisation_elements < 2:
            raise ValueError('Discretisation elements must be greater than 2.')

        gradient_layers = _prepare_gradient_layers(
            front_material=front_material,
            back_material=back_material,
            discretisation_elements=discretisation_elements,
            interface=interface,
        )

        super().__init__(
            layers=gradient_layers,
            name=name,
            unique_name=unique_name,
            interface=None,
            type='Gradient-layer',
        )
        self._front_material = front_material
        self._back_material = back_material
        self._discretisation_elements = discretisation_elements

        self._setup_thickness_constraints()
        self._enable_thickness_constraints()
        self._setup_roughness_constraints()
        self._enable_roughness_constraints()

        # Set the thickness and roughness properties
        self.thickness = thickness
        self.roughness = roughness

        if interface is not None:
            self.interface = interface

    @property
    def front_material(self) -> Material:
        return self._front_material

    @property
    def back_material(self) -> Material:
        return self._back_material

    @property
    def discretisation_elements(self) -> int:
        return self._discretisation_elements

    @property
    def thickness(self) -> float:
        """Get the thickness of the gradient layer in Angstrom."""
        return self.front_layer.thickness.value * self._discretisation_elements

    @thickness.setter
    def thickness(self, thickness: float) -> None:
        """Set the thickness of the gradient layer.

        Parameters
        ----------
        thickness : float
            Thickness of the gradient layer in Angstroms.
        """
        self.front_layer.thickness.value = thickness / self._discretisation_elements

    @property
    def roughness(self) -> float:
        """Get the Roughness of the gradient layer in Angstrom."""
        return self.front_layer.roughness.value

    @roughness.setter
    def roughness(self, roughness: float) -> None:
        """Set the roughness of the gradient layer.

        Parameters
        ----------
        roughness : float
            Roughness of the gradient layer in Angstroms.
        """
        self.front_layer.roughness.value = roughness

    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        return {
            'thickness': float(self.thickness),  # Conversion to float is necessary to prevent property reference in dict
            'discretisation_elements': int(self._discretisation_elements),  # Same as above
            'back_layer': self.back_layer._dict_repr,
            'front_layer': self.front_layer._dict_repr,
        }

    def to_dict(self, skip: Optional[list[str]] = None) -> dict:
        """Produces a cleaned dict using a custom to_dict method to skip necessary things.

        The resulting dict matches the parameters in __init__: layers are derived
        in ``__init__`` from ``front_material``/``back_material``/``discretisation_elements``
        so they are excluded from the serialized representation.

        Parameters
        ----------
        skip : Optional[list[str]], optional
            List of keys to skip. By default, None.
        """
        this_dict = super().to_dict(skip=skip)
        # Determined in __init__
        this_dict.pop('layers', None)
        # `thickness` / `roughness` are read-only float views; the serialized
        # constructor args are the floats themselves.
        this_dict['thickness'] = float(self.thickness)
        this_dict['roughness'] = float(self.roughness)
        return this_dict

    def as_dict(self, skip: Optional[list[str]] = None) -> dict:
        """Compatibility alias for :meth:`to_dict`."""
        return self.to_dict(skip=skip)

thickness property writable

Get the thickness of the gradient layer in Angstrom.

roughness property writable

Get the Roughness of the gradient layer in Angstrom.

__init__(front_material=None, back_material=None, thickness=2.0, roughness=0.2, discretisation_elements=10, name='EasyGradienLayer', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
front_material Optional[Material]

Material of front of the layer. By default, None.

None
back_material Optional[Material]

Material of back of the layer. By default, None.

None
thickness Optional[float]

Thicknkess of the layer. By default, 2.0.

2.0
roughness Optional[float]

Roughness of the layer. By default, 0.2.

0.2
discretisation_elements int

Number of discrete layers. By default, 10.

10
name str

Name for gradient layer. By default, 'EasyGradienLayer'.

'EasyGradienLayer'
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/assemblies/gradient_layer.py
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    front_material: Optional[Material] = None,
    back_material: Optional[Material] = None,
    thickness: Optional[float] = 2.0,
    roughness: Optional[float] = 0.2,
    discretisation_elements: int = 10,
    name: str = 'EasyGradienLayer',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    front_material : Optional[Material], optional
        Material of front of the layer. By default, None.
    back_material : Optional[Material], optional
        Material of back of the layer. By default, None.
    thickness : Optional[float], optional
        Thicknkess of the layer. By default, 2.0.
    roughness : Optional[float], optional
        Roughness of the layer. By default, 0.2.
    discretisation_elements : int, optional
        Number of discrete layers. By default, 10.
    name : str, optional
        Name for gradient layer. By default, 'EasyGradienLayer'.
    interface :
        Calculator interface. By default, None.
    """

    if front_material is None:
        front_material = Material(0.0, 0.0, 'Air')

    if back_material is None:
        back_material = Material(6.36, 0.0, 'D2O')

    if discretisation_elements < 2:
        raise ValueError('Discretisation elements must be greater than 2.')

    gradient_layers = _prepare_gradient_layers(
        front_material=front_material,
        back_material=back_material,
        discretisation_elements=discretisation_elements,
        interface=interface,
    )

    super().__init__(
        layers=gradient_layers,
        name=name,
        unique_name=unique_name,
        interface=None,
        type='Gradient-layer',
    )
    self._front_material = front_material
    self._back_material = back_material
    self._discretisation_elements = discretisation_elements

    self._setup_thickness_constraints()
    self._enable_thickness_constraints()
    self._setup_roughness_constraints()
    self._enable_roughness_constraints()

    # Set the thickness and roughness properties
    self.thickness = thickness
    self.roughness = roughness

    if interface is not None:
        self.interface = interface

to_dict(skip=None)

Produces a cleaned dict using a custom to_dict method to skip necessary things.

The resulting dict matches the parameters in init: layers are derived in __init__ from front_material/back_material/discretisation_elements so they are excluded from the serialized representation.

Parameters:

Name Type Description Default
skip Optional[list[str]]

List of keys to skip. By default, None.

None
Source code in src/easyreflectometry/sample/assemblies/gradient_layer.py
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def to_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Produces a cleaned dict using a custom to_dict method to skip necessary things.

    The resulting dict matches the parameters in __init__: layers are derived
    in ``__init__`` from ``front_material``/``back_material``/``discretisation_elements``
    so they are excluded from the serialized representation.

    Parameters
    ----------
    skip : Optional[list[str]], optional
        List of keys to skip. By default, None.
    """
    this_dict = super().to_dict(skip=skip)
    # Determined in __init__
    this_dict.pop('layers', None)
    # `thickness` / `roughness` are read-only float views; the serialized
    # constructor args are the floats themselves.
    this_dict['thickness'] = float(self.thickness)
    this_dict['roughness'] = float(self.roughness)
    return this_dict

as_dict(skip=None)

Compatibility alias for :meth:to_dict.

Source code in src/easyreflectometry/sample/assemblies/gradient_layer.py
168
169
170
def as_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Compatibility alias for :meth:`to_dict`."""
    return self.to_dict(skip=skip)

RepeatingMultilayer

Bases: Multilayer

A repeating multi layer is build from a Multilayer and which it repeats for a given number of times. This enables a computational efficiency in many reflectometry engines as the operation can be performed for a single Multilayer and cheaply combined for the appropriate number of repetitions.

More information about the usage of this assembly is available in the repeating multilayer documentation_

.. _repeating multilayer documentation: ../sample/assemblies_library.html#repeatingmultilayer

Source code in src/easyreflectometry/sample/assemblies/repeating_multilayer.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class RepeatingMultilayer(Multilayer):
    """A repeating multi layer is build from a `Multilayer` and which it repeats
    for a given number of times. This enables a computational efficiency in many
    reflectometry engines as the operation can be performed for a single
    `Multilayer` and cheaply combined for the appropriate number of
    `repetitions`.

    More information about the usage of this assembly is available in the
    `repeating multilayer documentation`_

    .. _`repeating multilayer documentation`: ../sample/assemblies_library.html#repeatingmultilayer
    """

    def __init__(
        self,
        layers: Union[LayerCollection, Layer, list[Layer], None] = None,
        repetitions: Union[Parameter, int, None] = None,
        name: str = 'EasyRepeatingMultilayer',
        unique_name: Optional[str] = None,
        interface=None,
        populate_if_none: bool = True,
        conformal_thickness: bool = False,
        conformal_roughness: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        populate_if_none : bool, optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        layers : Union[LayerCollection, Layer, list[Layer], None], optional
            The layers that make up the multi-layer that will be repeated. By default, None.
        repetitions : Union[Parameter, int, None], optional
            Number of repetitions of the given series of layers. By default, None.
        name : str, optional
            Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.
        interface :
            Calculator interface. By default, None.
        conformal_thickness : bool, optional
            Tie every layer's thickness to the front layer's (persisted). By default, False.
        conformal_roughness : bool, optional
            Tie every layer's roughness to the front layer's (persisted). By default, False.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        if layers is None:
            if populate_if_none:
                layers = LayerCollection([Layer(interface=interface)])
            else:
                layers = LayerCollection()
        elif isinstance(layers, Layer):
            layers = LayerCollection(layers, name=layers.name)
        elif isinstance(layers, list):
            layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))

        repetitions = get_as_parameter(
            name='repetitions',
            value=repetitions,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Repetitions',
        )

        super().__init__(
            layers=layers,
            name=name,
            unique_name=unique_name,
            interface=None,
            type='Repeating Multi-layer',
            populate_if_none=False,
            conformal_thickness=conformal_thickness,
            conformal_roughness=conformal_roughness,
        )
        self._repetitions = repetitions

        if interface is not None:
            self.interface = interface

    @property
    def repetitions(self) -> Parameter:
        return self._repetitions

    @repetitions.setter
    def repetitions(self, value) -> None:
        self._repetitions.value = value

    # Representation
    @property
    def _dict_repr(self) -> dict:
        """A simplified dict representation."""
        d_dict = {self.name: self.layers._dict_repr}
        d_dict[self.name]['repetitions'] = float(self.repetitions.value)
        return d_dict

__init__(layers=None, repetitions=None, name='EasyRepeatingMultilayer', unique_name=None, interface=None, populate_if_none=True, conformal_thickness=False, conformal_roughness=False)

Constructor.

Parameters:

Name Type Description Default
populate_if_none bool

By default, True.

True
unique_name Optional[str]

By default, None.

None
layers Union[LayerCollection, Layer, list[Layer], None]

The layers that make up the multi-layer that will be repeated. By default, None.

None
repetitions Union[Parameter, int, None]

Number of repetitions of the given series of layers. By default, None.

None
name str

Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.

'EasyRepeatingMultilayer'
interface

Calculator interface. By default, None.

None
conformal_thickness bool

Tie every layer's thickness to the front layer's (persisted). By default, False.

False
conformal_roughness bool

Tie every layer's roughness to the front layer's (persisted). By default, False.

False
Source code in src/easyreflectometry/sample/assemblies/repeating_multilayer.py
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def __init__(
    self,
    layers: Union[LayerCollection, Layer, list[Layer], None] = None,
    repetitions: Union[Parameter, int, None] = None,
    name: str = 'EasyRepeatingMultilayer',
    unique_name: Optional[str] = None,
    interface=None,
    populate_if_none: bool = True,
    conformal_thickness: bool = False,
    conformal_roughness: bool = False,
):
    """Constructor.

    Parameters
    ----------
    populate_if_none : bool, optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    layers : Union[LayerCollection, Layer, list[Layer], None], optional
        The layers that make up the multi-layer that will be repeated. By default, None.
    repetitions : Union[Parameter, int, None], optional
        Number of repetitions of the given series of layers. By default, None.
    name : str, optional
        Name for the repeating multi layer. By default, 'EasyRepeatingMultilayer'.
    interface :
        Calculator interface. By default, None.
    conformal_thickness : bool, optional
        Tie every layer's thickness to the front layer's (persisted). By default, False.
    conformal_roughness : bool, optional
        Tie every layer's roughness to the front layer's (persisted). By default, False.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    if layers is None:
        if populate_if_none:
            layers = LayerCollection([Layer(interface=interface)])
        else:
            layers = LayerCollection()
    elif isinstance(layers, Layer):
        layers = LayerCollection(layers, name=layers.name)
    elif isinstance(layers, list):
        layers = LayerCollection(*layers, name='/'.join([layer.name for layer in layers]))

    repetitions = get_as_parameter(
        name='repetitions',
        value=repetitions,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Repetitions',
    )

    super().__init__(
        layers=layers,
        name=name,
        unique_name=unique_name,
        interface=None,
        type='Repeating Multi-layer',
        populate_if_none=False,
        conformal_thickness=conformal_thickness,
        conformal_roughness=conformal_roughness,
    )
    self._repetitions = repetitions

    if interface is not None:
        self.interface = interface

MaterialMixture

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/materials/material_mixture.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
class MaterialMixture(BaseCore):
    def __init__(
        self,
        material_a: Union[Material, None] = None,
        material_b: Union[Material, None] = None,
        fraction: Union[Parameter, float, None] = None,
        name: Union[str, None] = None,
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        material_a : Union[Material, None], optional
            The first material. By default, None.
        material_b : Union[Material, None], optional
            The second material. By default, None.
        fraction : Union[Parameter, float, None], optional
            The fraction of material_b in material_a. By default, None.
        name : Union[str, None], optional
            Name of the material. By default, None.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        if material_a is None:
            material_a = Material(interface=interface)
        if material_b is None:
            material_b = Material(interface=interface)

        fraction = get_as_parameter(
            name='fraction',
            value=fraction,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Fraction',
        )

        sld_value = weighted_average(
            a=material_a.sld.value,
            b=material_b.sld.value,
            p=fraction.value,
        )
        isld_value = weighted_average(
            a=material_a.isld.value,
            b=material_b.isld.value,
            p=fraction.value,
        )

        sld = get_as_parameter(
            name='sld',
            value=sld_value,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Sld',
        )
        isld = get_as_parameter(
            name='isld',
            value=isld_value,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Isld',
        )

        # `name` may be None to signal "derive from material names"; resolve
        # before super().__init__ since BaseCore stores `_name` directly.
        if name is None:
            resolved_name = material_a.name + '/' + material_b.name
        else:
            resolved_name = name

        super().__init__(name=resolved_name, unique_name=unique_name)
        self._material_a = material_a
        self._material_b = material_b
        self._fraction = fraction
        self._sld = sld
        self._isld = isld

        self._materials_constraints()

        if interface is not None:
            self.interface = interface

    # ----- constructor-arg accessors -----

    @property
    def material_a(self) -> Material:
        return self._material_a

    @material_a.setter
    def material_a(self, new_material_a: Material) -> None:
        self._material_a = new_material_a
        self._materials_constraints()
        if self.interface is not None:
            self.interface.generate_bindings(self)
        self._update_name()

    @property
    def material_b(self) -> Material:
        return self._material_b

    @material_b.setter
    def material_b(self, new_material_b: Material) -> None:
        self._material_b = new_material_b
        self._materials_constraints()
        if self.interface is not None:
            self.interface.generate_bindings(self)
        self._update_name()

    @property
    def fraction(self) -> Parameter:
        """The Parameter that controls the mixing fraction of material_b in material_a."""
        return self._fraction

    @fraction.setter
    def fraction(self, value: float) -> None:
        if not isinstance(value, (int, float)):
            raise ValueError('fraction must be a float')
        self._fraction.value = value

    # ----- derived sld / isld parameters (shared shape with Material) -----
    #
    # These are *derived* via the constraints set up in `_materials_constraints`
    # (not constructor arguments) so we expose them as floats to match the
    # legacy MaterialMixture API. The underlying Parameter objects remain
    # available as `self._sld` / `self._isld`.

    @property
    def sld(self) -> float:
        return self._sld.value

    @property
    def isld(self) -> float:
        return self._isld.value

    # ----- calculator binding -----

    def _get_linkable_attributes(self):
        """Return the *mixed* sld / isld parameters for calculator binding.

        Override of the inherited `BaseCore._get_linkable_attributes`, which
        walks `get_all_variables()` and would otherwise expose the **child**
        materials' sld/isld (because our own `sld` / `isld` are floats, not
        Parameters). The calculator's `InterfaceFactoryTemplate.generate_bindings`
        matches by parameter `name`; without this override it binds to
        `material_a.sld` and reflectivity is computed off the wrong SLD.
        """
        return [self._sld, self._isld]

    # ----- internal helpers -----

    def _materials_constraints(self):
        """Wire the mixed `_sld` / `_isld` to depend on the current child
        material parameters and the current `_fraction`. Idempotent: callers
        invoke this once from ``__init__`` and again from ``from_dict`` after
        the saved Parameters have been reattached (so the dependency graph
        points at the right objects, not the temporary constructor params)."""
        # Detach any existing dependency before rebuilding so make_dependent_on
        # doesn't chain on top of stale references.
        for derived in (self._sld, self._isld):
            if not derived.independent:
                derived.make_independent()

        dependency_expression = 'a * (1 - p) + b * p'
        dependency_map = {
            'a': self._material_a.sld,
            'b': self._material_b.sld,
            'p': self._fraction,
        }
        self._sld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

        dependency_map = {
            'a': self._material_a.isld,
            'b': self._material_b.isld,
            'p': self._fraction,
        }
        self._isld.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map)

    def _update_name(self) -> None:
        """Update name."""
        self.name = self._material_a.name + '/' + self._material_b.name

    # ----- deserialization -----

    @classmethod
    def from_dict(cls, obj_dict: dict) -> 'MaterialMixture':
        """Re-attach mixed-sld dependencies after :class:`ModelBase` swaps in
        the saved ``_fraction`` Parameter.

        :class:`ModelBase.from_dict` runs ``__init__`` (which builds the
        ``_sld`` / ``_isld`` constraints against the *temporary* ``_fraction``
        created from the float kwargs) and then re-points ``self._fraction``
        at the persisted Parameter. The constraint graph still references the
        temporary object, so subsequent ``mm.fraction = X`` mutations don't
        propagate to ``_sld`` / ``_isld``. Re-running ``_materials_constraints``
        here points the graph at the live objects.
        """
        instance = super().from_dict(obj_dict)
        instance._materials_constraints()
        return instance

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        return {
            self.name: {
                'fraction': f'{self._fraction.value:.3f} {self._fraction.unit}',
                'sld': f'{self._sld.value:.3f}e-6 {self._sld.unit}',
                'isld': f'{self._isld.value:.3f}e-6 {self._isld.unit}',
                'material_a': self._material_a._dict_repr,
                'material_b': self._material_b._dict_repr,
            }
        }

fraction property writable

The Parameter that controls the mixing fraction of material_b in material_a.

__init__(material_a=None, material_b=None, fraction=None, name=None, unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
material_a Union[Material, None]

The first material. By default, None.

None
material_b Union[Material, None]

The second material. By default, None.

None
fraction Union[Parameter, float, None]

The fraction of material_b in material_a. By default, None.

None
name Union[str, None]

Name of the material. By default, None.

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/elements/materials/material_mixture.py
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def __init__(
    self,
    material_a: Union[Material, None] = None,
    material_b: Union[Material, None] = None,
    fraction: Union[Parameter, float, None] = None,
    name: Union[str, None] = None,
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    material_a : Union[Material, None], optional
        The first material. By default, None.
    material_b : Union[Material, None], optional
        The second material. By default, None.
    fraction : Union[Parameter, float, None], optional
        The fraction of material_b in material_a. By default, None.
    name : Union[str, None], optional
        Name of the material. By default, None.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    if material_a is None:
        material_a = Material(interface=interface)
    if material_b is None:
        material_b = Material(interface=interface)

    fraction = get_as_parameter(
        name='fraction',
        value=fraction,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Fraction',
    )

    sld_value = weighted_average(
        a=material_a.sld.value,
        b=material_b.sld.value,
        p=fraction.value,
    )
    isld_value = weighted_average(
        a=material_a.isld.value,
        b=material_b.isld.value,
        p=fraction.value,
    )

    sld = get_as_parameter(
        name='sld',
        value=sld_value,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Sld',
    )
    isld = get_as_parameter(
        name='isld',
        value=isld_value,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Isld',
    )

    # `name` may be None to signal "derive from material names"; resolve
    # before super().__init__ since BaseCore stores `_name` directly.
    if name is None:
        resolved_name = material_a.name + '/' + material_b.name
    else:
        resolved_name = name

    super().__init__(name=resolved_name, unique_name=unique_name)
    self._material_a = material_a
    self._material_b = material_b
    self._fraction = fraction
    self._sld = sld
    self._isld = isld

    self._materials_constraints()

    if interface is not None:
        self.interface = interface

from_dict(obj_dict) classmethod

Re-attach mixed-sld dependencies after :class:ModelBase swaps in the saved _fraction Parameter.

:class:ModelBase.from_dict runs __init__ (which builds the _sld / _isld constraints against the temporary _fraction created from the float kwargs) and then re-points self._fraction at the persisted Parameter. The constraint graph still references the temporary object, so subsequent mm.fraction = X mutations don't propagate to _sld / _isld. Re-running _materials_constraints here points the graph at the live objects.

Source code in src/easyreflectometry/sample/elements/materials/material_mixture.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
@classmethod
def from_dict(cls, obj_dict: dict) -> 'MaterialMixture':
    """Re-attach mixed-sld dependencies after :class:`ModelBase` swaps in
    the saved ``_fraction`` Parameter.

    :class:`ModelBase.from_dict` runs ``__init__`` (which builds the
    ``_sld`` / ``_isld`` constraints against the *temporary* ``_fraction``
    created from the float kwargs) and then re-points ``self._fraction``
    at the persisted Parameter. The constraint graph still references the
    temporary object, so subsequent ``mm.fraction = X`` mutations don't
    propagate to ``_sld`` / ``_isld``. Re-running ``_materials_constraints``
    here points the graph at the live objects.
    """
    instance = super().from_dict(obj_dict)
    instance._materials_constraints()
    return instance

Sample

Bases: BaseCollection

A sample is a collection of assemblies that represent the structure for which experimental measurements exist.

Source code in src/easyreflectometry/sample/collections/sample.py
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
class Sample(BaseCollection):
    """A sample is a collection of assemblies that represent the structure for which experimental measurements exist."""

    def __init__(
        self,
        *assemblies: Optional[List[BaseAssembly]],
        name: str = 'EasySample',
        interface=None,
        unique_name: Optional[str] = None,
        populate_if_none: bool = True,
        **kwargs,
    ):
        """Constructor.

        Parameters
        ----------
        **kwargs :
        populate_if_none : bool, optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        *assemblies : Optional[List[BaseAssembly]]
            The assemblies in the sample.
        name : str, optional
            Name of the sample. By default, 'EasySample'.
        interface :
            Calculator interface. By default, None.
        """
        # `from_dict` (via `EasyList.from_dict`) passes the items as a single
        # list-positional arg; unpack that so validation and super() agree.
        if len(assemblies) == 1 and isinstance(assemblies[0], list):
            assemblies = tuple(assemblies[0])

        if not assemblies:
            if populate_if_none:
                assemblies = DEFAULT_ELEMENTS(interface)
            else:
                assemblies = []

        for assembly in assemblies:
            if not issubclass(type(assembly), BaseAssembly):
                raise ValueError('The elements must be an Assembly.')
        super().__init__(
            name,
            interface,
            *assemblies,
            unique_name=unique_name,
            populate_if_none=populate_if_none,
            **kwargs,
        )

    def add_assembly(self, assembly: Optional[BaseAssembly] = None):
        """Add an assembly to the sample.

        Parameters
        ----------
        assembly : Optional[BaseAssembly], optional
            Assembly to add. By default, None.
        """
        if assembly is None:
            assembly = Multilayer(
                name='EasyMultilayer added',
                interface=self.interface,
            )
        self.append(assembly)

    def duplicate_assembly(self, index: int):
        """Duplicate an assembly of the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to duplicate.
        """
        # Order matters: RepeatingMultilayer and SurfactantLayer are subclasses of
        # BaseAssembly but not Multilayer; however a RepeatingMultilayer IS a
        # Multilayer, so the most-specific check must come first to avoid
        # serialising it through the wrong `from_dict`.
        to_be_duplicated = self[index]
        if isinstance(to_be_duplicated, RepeatingMultilayer):
            duplicate = RepeatingMultilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        elif isinstance(to_be_duplicated, SurfactantLayer):
            duplicate = SurfactantLayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        elif isinstance(to_be_duplicated, Multilayer):
            duplicate = Multilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        else:
            raise TypeError(f'Cannot duplicate assembly of type {type(to_be_duplicated).__name__}')
        duplicate.name = duplicate.name + ' duplicate'
        self.append(duplicate)

    def move_up(self, index: int):
        """Move the assembly at the given index up in the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to move up.
        """
        super().move_up(index)

    def move_down(self, index: int):
        """Move the assembly at the given index down in the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to move down.
        """
        super().move_down(index)

    def remove_assembly(self, index: int):
        """Remove the assembly at the given index from the sample.

        Parameters
        ----------
        index : int
            Index of the assembly to remove.
        """
        self.pop(index)

    @property
    def superphase(self) -> Layer:
        """The superphase of the sample."""
        return self[0].front_layer

    @property
    def subphase(self) -> Layer:
        """The subphase of the sample."""
        # This assembly only got one layer
        if self[-1].back_layer is None:
            return self[-1].front_layer
        else:
            return self[-1].back_layer

    def volume_fraction_profile(
        self,
        z: Optional[np.ndarray] = None,
        *,
        max_delta_z: Optional[float] = None,
        merge_equivalent: bool = True,
    ) -> VolumeFractionProfile:
        """Volume fraction (occupancy) of every component of the sample versus depth.

        A thin wrapper around :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`;
        see there for the parameters, the depth convention and the caveats.

        Parameters
        ----------
        z : np.ndarray, optional
            Depths (Å) to evaluate at; ``z = 0`` is the superphase / first layer interface.
            By default a 500-point grid spanning the sample.
        max_delta_z : float, optional
            Maximum spacing of the default grid. Cannot be combined with ``z``.
        merge_equivalent : bool, optional
            Merge materials with equal name and SLD into one component. By default, True.

        Returns
        -------
        VolumeFractionProfile
            The per-component profiles, summing to one at every depth.
        """
        return volume_fraction_profile(self, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)

superphase property

The superphase of the sample.

subphase property

The subphase of the sample.

__init__(*assemblies, name='EasySample', interface=None, unique_name=None, populate_if_none=True, **kwargs)

Constructor.

Parameters:

Name Type Description Default
**kwargs
{}
populate_if_none bool

By default, True.

True
unique_name Optional[str]

By default, None.

None
*assemblies Optional[List[BaseAssembly]]

The assemblies in the sample.

()
name str

Name of the sample. By default, 'EasySample'.

'EasySample'
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/collections/sample.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def __init__(
    self,
    *assemblies: Optional[List[BaseAssembly]],
    name: str = 'EasySample',
    interface=None,
    unique_name: Optional[str] = None,
    populate_if_none: bool = True,
    **kwargs,
):
    """Constructor.

    Parameters
    ----------
    **kwargs :
    populate_if_none : bool, optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    *assemblies : Optional[List[BaseAssembly]]
        The assemblies in the sample.
    name : str, optional
        Name of the sample. By default, 'EasySample'.
    interface :
        Calculator interface. By default, None.
    """
    # `from_dict` (via `EasyList.from_dict`) passes the items as a single
    # list-positional arg; unpack that so validation and super() agree.
    if len(assemblies) == 1 and isinstance(assemblies[0], list):
        assemblies = tuple(assemblies[0])

    if not assemblies:
        if populate_if_none:
            assemblies = DEFAULT_ELEMENTS(interface)
        else:
            assemblies = []

    for assembly in assemblies:
        if not issubclass(type(assembly), BaseAssembly):
            raise ValueError('The elements must be an Assembly.')
    super().__init__(
        name,
        interface,
        *assemblies,
        unique_name=unique_name,
        populate_if_none=populate_if_none,
        **kwargs,
    )

add_assembly(assembly=None)

Add an assembly to the sample.

Parameters:

Name Type Description Default
assembly Optional[BaseAssembly]

Assembly to add. By default, None.

None
Source code in src/easyreflectometry/sample/collections/sample.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def add_assembly(self, assembly: Optional[BaseAssembly] = None):
    """Add an assembly to the sample.

    Parameters
    ----------
    assembly : Optional[BaseAssembly], optional
        Assembly to add. By default, None.
    """
    if assembly is None:
        assembly = Multilayer(
            name='EasyMultilayer added',
            interface=self.interface,
        )
    self.append(assembly)

duplicate_assembly(index)

Duplicate an assembly of the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to duplicate.

required
Source code in src/easyreflectometry/sample/collections/sample.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def duplicate_assembly(self, index: int):
    """Duplicate an assembly of the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to duplicate.
    """
    # Order matters: RepeatingMultilayer and SurfactantLayer are subclasses of
    # BaseAssembly but not Multilayer; however a RepeatingMultilayer IS a
    # Multilayer, so the most-specific check must come first to avoid
    # serialising it through the wrong `from_dict`.
    to_be_duplicated = self[index]
    if isinstance(to_be_duplicated, RepeatingMultilayer):
        duplicate = RepeatingMultilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    elif isinstance(to_be_duplicated, SurfactantLayer):
        duplicate = SurfactantLayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    elif isinstance(to_be_duplicated, Multilayer):
        duplicate = Multilayer.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    else:
        raise TypeError(f'Cannot duplicate assembly of type {type(to_be_duplicated).__name__}')
    duplicate.name = duplicate.name + ' duplicate'
    self.append(duplicate)

move_up(index)

Move the assembly at the given index up in the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to move up.

required
Source code in src/easyreflectometry/sample/collections/sample.py
120
121
122
123
124
125
126
127
128
def move_up(self, index: int):
    """Move the assembly at the given index up in the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to move up.
    """
    super().move_up(index)

move_down(index)

Move the assembly at the given index down in the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to move down.

required
Source code in src/easyreflectometry/sample/collections/sample.py
130
131
132
133
134
135
136
137
138
def move_down(self, index: int):
    """Move the assembly at the given index down in the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to move down.
    """
    super().move_down(index)

remove_assembly(index)

Remove the assembly at the given index from the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to remove.

required
Source code in src/easyreflectometry/sample/collections/sample.py
140
141
142
143
144
145
146
147
148
def remove_assembly(self, index: int):
    """Remove the assembly at the given index from the sample.

    Parameters
    ----------
    index : int
        Index of the assembly to remove.
    """
    self.pop(index)

volume_fraction_profile(z=None, *, max_delta_z=None, merge_equivalent=True)

Volume fraction (occupancy) of every component of the sample versus depth.

A thin wrapper around :func:easyreflectometry.sample.volume_fraction.volume_fraction_profile; see there for the parameters, the depth convention and the caveats.

Parameters:

Name Type Description Default
z np.ndarray

Depths (Å) to evaluate at; z = 0 is the superphase / first layer interface. By default a 500-point grid spanning the sample.

None
max_delta_z float

Maximum spacing of the default grid. Cannot be combined with z.

None
merge_equivalent bool

Merge materials with equal name and SLD into one component. By default, True.

True

Returns:

Type Description
VolumeFractionProfile

The per-component profiles, summing to one at every depth.

Source code in src/easyreflectometry/sample/collections/sample.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def volume_fraction_profile(
    self,
    z: Optional[np.ndarray] = None,
    *,
    max_delta_z: Optional[float] = None,
    merge_equivalent: bool = True,
) -> VolumeFractionProfile:
    """Volume fraction (occupancy) of every component of the sample versus depth.

    A thin wrapper around :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`;
    see there for the parameters, the depth convention and the caveats.

    Parameters
    ----------
    z : np.ndarray, optional
        Depths (Å) to evaluate at; ``z = 0`` is the superphase / first layer interface.
        By default a 500-point grid spanning the sample.
    max_delta_z : float, optional
        Maximum spacing of the default grid. Cannot be combined with ``z``.
    merge_equivalent : bool, optional
        Merge materials with equal name and SLD into one component. By default, True.

    Returns
    -------
    VolumeFractionProfile
        The per-component profiles, summing to one at every depth.
    """
    return volume_fraction_profile(self, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)

Layer

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/layers/layer.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
class Layer(BaseCore):
    def __init__(
        self,
        material: Union[Material, None] = None,
        thickness: Union[Parameter, float, None] = None,
        roughness: Union[Parameter, float, None] = None,
        name: str = 'EasyLayer',
        unique_name: Optional[str] = None,
        interface=None,
        magnetism: Union[LayerMagnetism, None] = None,
        rho_m_coupled: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        material : Union[Material, None], optional
            The material for the layer. By default, None.
        thickness : Union[Parameter, float, None], optional
            Layer thickness in Angstrom. By default, None.
        roughness : Union[Parameter, float, None], optional
            Upper roughness on the layer in Angstrom. By default, None.
        magnetism : Union[LayerMagnetism, None], optional
            Magnetic properties of the layer; None for a non-magnetic layer.
            By default, None.
        rho_m_coupled : bool, optional
            Take the magnetic SLD from the material (see :attr:`rho_m_coupled`).
            By default, False.
        name : str, optional
            Name of the layer. By default, 'EasyLayer'.
        interface :
            Interface object. By default, None.
        """
        if material is None:
            material = Material(interface=interface)

        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        thickness_value = thickness
        thickness = get_as_parameter(
            name='thickness',
            value=thickness,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Thickness',
        )
        thickness.default_limits_pending = not isinstance(thickness_value, Parameter)

        roughness_value = roughness
        roughness = get_as_parameter(
            name='roughness',
            value=roughness,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Roughness',
        )
        roughness.default_limits_pending = not isinstance(roughness_value, Parameter)

        super().__init__(name=name, unique_name=unique_name)
        self._material = material
        self._thickness = thickness
        self._roughness = roughness
        self._magnetism = magnetism
        self._rho_m_coupled = False
        if rho_m_coupled:
            self.rho_m_coupled = True

        if interface is not None:
            self.interface = interface

    # ----- interface (override BaseCore's to switch on calculator magnetism) -----

    @BaseCore.interface.setter
    def interface(self, new_interface) -> None:
        """Set the interface; runs `generate_bindings` and, for a magnetic layer,
        enables magnetism on the calculator (raising if it does not support it).
        """
        BaseCore.interface.fset(self, new_interface)
        if new_interface is not None and self._magnetism is not None:
            self._enable_calculator_magnetism()

    def _enable_calculator_magnetism(self) -> None:
        """Turn on magnetism on the attached calculator.

        Raises `NotImplementedError` when the active calculator cannot model
        magnetic samples (e.g. refnx or bornagain).
        """
        self.interface().include_magnetism = True

    @property
    def material(self) -> Material:
        return self._material

    @material.setter
    def material(self, value: Material) -> None:
        coupled = self.rho_m_coupled
        self._material = value
        self._reconcile_rho_m_coupling(coupled)

    @property
    def thickness(self) -> Parameter:
        return self._thickness

    @thickness.setter
    def thickness(self, value: float) -> None:
        self._thickness.value = value

    @property
    def roughness(self) -> Parameter:
        return self._roughness

    @roughness.setter
    def roughness(self, value: float) -> None:
        self._roughness.value = value

    @property
    def magnetism(self) -> Optional[LayerMagnetism]:
        return self._magnetism

    @magnetism.setter
    def magnetism(self, value: Optional[LayerMagnetism]) -> None:
        """Attach or remove the magnetic properties of this layer.

        Attaching regenerates the calculator bindings so `rho_m`/`theta_m` become
        live on the backend; removing drops the layer's magnetic state from the
        backend (and switches calculator magnetism off entirely when this was the
        last magnetic layer).
        """
        previous = self._magnetism
        coupled = self.rho_m_coupled
        if previous is not None and previous is not value:
            if value is None and self.interface is not None:
                self.interface().remove_layer_magnetism(self.unique_name)
            # The detached object must not follow the material any more...
            if coupled:
                _release(previous.rho_m)
            # ...and its value changes must no longer reach the backend.
            # `property()` (fget/fset/fdel all None) is easyscience's own "no
            # callback" sentinel -- `Parameter.__copy__` sets it the same way --
            # so a later `fset` guard in `Parameter` cleanly no-ops.
            previous.rho_m._callback = property()
            previous.theta_m._callback = property()
        self._magnetism = value
        self._reconcile_rho_m_coupling(coupled)
        if value is not None and self.interface is not None:
            self._enable_calculator_magnetism()
            self.generate_bindings()

    @property
    def rho_m_coupled(self) -> bool:
        """Whether the layer's magnetic SLD is the material's ``rho_m``
        (a :class:`~easyreflectometry.sample.MaterialMagnetic`).

        When True, ``magnetism.rho_m`` is a dependent parameter following the
        current material; the layer keeps its own ``theta_m``. Opt-in: layers of
        one material may carry different net moments. Setting it attaches a
        default :class:`LayerMagnetism` when the layer has none, and requires a
        material with ``rho_m``. Uncoupling keeps the current value. Removing
        the magnetism, or a user ``unconstrain`` of ``rho_m``, uncouples.

        Only the layer's own wiring counts: a user constraint placed on
        ``rho_m`` (:func:`~easyreflectometry.constraints.constrain`) reads as
        uncoupled, is saved as the user constraint it is, and is left alone
        when the material is later replaced. Setting this to True explicitly
        replaces such a constraint with the coupling.
        """
        if not self._rho_m_coupled or self._magnetism is None:
            return False
        rho_m = self._magnetism.rho_m
        if rho_m.independent or getattr(rho_m, USER_CONSTRAINT_FLAG, False):
            return False
        source = getattr(self._material, 'rho_m', None)
        dependency_map = rho_m.dependency_map
        return source is not None and len(dependency_map) == 1 and dependency_map.get('rho_m') is source

    @rho_m_coupled.setter
    def rho_m_coupled(self, couple: bool) -> None:
        couple = bool(couple)
        if couple and getattr(self._material, 'rho_m', None) is None:
            raise ValueError(f'The material of layer {self.name!r} has no magnetic SLD to couple to.')
        if couple and self._magnetism is None:
            self.magnetism = LayerMagnetism()
        if not couple and self.rho_m_coupled:
            _release(self._magnetism.rho_m)
        self._reconcile_rho_m_coupling(couple)

    def _reconcile_rho_m_coupling(self, couple: bool) -> None:
        """Re-establish the invariant of :attr:`rho_m_coupled` after a change of
        material, magnetism or coupling: coupled means the current magnetism's
        ``rho_m`` follows the current material's ``rho_m``.

        `couple` is the state to keep, read *before* the change (afterwards the
        dependency may point at a replaced object).
        """
        self._rho_m_coupled = False
        if not couple or self._magnetism is None:
            return
        rho_m = self._magnetism.rho_m
        source = getattr(self._material, 'rho_m', None)
        if source is None:
            warnings.warn(
                f'The material of layer {self.name!r} has no magnetic SLD; '
                'the layer keeps its current rho_m and is no longer coupled.',
                UserWarning,
                stacklevel=3,
            )
            if not rho_m.independent:
                _release(rho_m)
            return
        # Drops any previous dependency, including a stale user-constraint marker
        # that would make the project persist this internal wiring.
        unconstrain(rho_m)
        rho_m.make_dependent_on(dependency_expression='rho_m', dependency_map={'rho_m': source})
        self._rho_m_coupled = True

    def assign_material(self, material: Material) -> None:
        """Assign a material to the layer interface.

        Parameters
        ----------
        material : Material
            The material to assign to the layer.
        """
        coupled = self.rho_m_coupled
        self._material = material
        self._reconcile_rho_m_coupling(coupled)
        if self.interface is not None:
            self.interface().assign_material_to_layer(self.material.unique_name, self.unique_name)

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        this_dict = {
            self.name: {
                'material': self.material._dict_repr,
                'thickness': f'{self.thickness.value:.3f} {self.thickness.unit}',
                'roughness': f'{self.roughness.value:.3f} {self.roughness.unit}',
            }
        }
        if self._magnetism is not None:
            this_dict[self.name]['magnetism'] = self._magnetism._dict_repr
        return this_dict

rho_m_coupled property writable

Whether the layer's magnetic SLD is the material's rho_m (a :class:~easyreflectometry.sample.MaterialMagnetic).

When True, magnetism.rho_m is a dependent parameter following the current material; the layer keeps its own theta_m. Opt-in: layers of one material may carry different net moments. Setting it attaches a default :class:LayerMagnetism when the layer has none, and requires a material with rho_m. Uncoupling keeps the current value. Removing the magnetism, or a user unconstrain of rho_m, uncouples.

Only the layer's own wiring counts: a user constraint placed on rho_m (:func:~easyreflectometry.constraints.constrain) reads as uncoupled, is saved as the user constraint it is, and is left alone when the material is later replaced. Setting this to True explicitly replaces such a constraint with the coupling.

__init__(material=None, thickness=None, roughness=None, name='EasyLayer', unique_name=None, interface=None, magnetism=None, rho_m_coupled=False)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
material Union[Material, None]

The material for the layer. By default, None.

None
thickness Union[Parameter, float, None]

Layer thickness in Angstrom. By default, None.

None
roughness Union[Parameter, float, None]

Upper roughness on the layer in Angstrom. By default, None.

None
magnetism Union[LayerMagnetism, None]

Magnetic properties of the layer; None for a non-magnetic layer. By default, None.

None
rho_m_coupled bool

Take the magnetic SLD from the material (see :attr:rho_m_coupled). By default, False.

False
name str

Name of the layer. By default, 'EasyLayer'.

'EasyLayer'
interface

Interface object. By default, None.

None
Source code in src/easyreflectometry/sample/elements/layers/layer.py
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def __init__(
    self,
    material: Union[Material, None] = None,
    thickness: Union[Parameter, float, None] = None,
    roughness: Union[Parameter, float, None] = None,
    name: str = 'EasyLayer',
    unique_name: Optional[str] = None,
    interface=None,
    magnetism: Union[LayerMagnetism, None] = None,
    rho_m_coupled: bool = False,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    material : Union[Material, None], optional
        The material for the layer. By default, None.
    thickness : Union[Parameter, float, None], optional
        Layer thickness in Angstrom. By default, None.
    roughness : Union[Parameter, float, None], optional
        Upper roughness on the layer in Angstrom. By default, None.
    magnetism : Union[LayerMagnetism, None], optional
        Magnetic properties of the layer; None for a non-magnetic layer.
        By default, None.
    rho_m_coupled : bool, optional
        Take the magnetic SLD from the material (see :attr:`rho_m_coupled`).
        By default, False.
    name : str, optional
        Name of the layer. By default, 'EasyLayer'.
    interface :
        Interface object. By default, None.
    """
    if material is None:
        material = Material(interface=interface)

    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    thickness_value = thickness
    thickness = get_as_parameter(
        name='thickness',
        value=thickness,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Thickness',
    )
    thickness.default_limits_pending = not isinstance(thickness_value, Parameter)

    roughness_value = roughness
    roughness = get_as_parameter(
        name='roughness',
        value=roughness,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Roughness',
    )
    roughness.default_limits_pending = not isinstance(roughness_value, Parameter)

    super().__init__(name=name, unique_name=unique_name)
    self._material = material
    self._thickness = thickness
    self._roughness = roughness
    self._magnetism = magnetism
    self._rho_m_coupled = False
    if rho_m_coupled:
        self.rho_m_coupled = True

    if interface is not None:
        self.interface = interface

interface(new_interface)

Set the interface; runs generate_bindings and, for a magnetic layer, enables magnetism on the calculator (raising if it does not support it).

Source code in src/easyreflectometry/sample/elements/layers/layer.py
115
116
117
118
119
120
121
122
@BaseCore.interface.setter
def interface(self, new_interface) -> None:
    """Set the interface; runs `generate_bindings` and, for a magnetic layer,
    enables magnetism on the calculator (raising if it does not support it).
    """
    BaseCore.interface.fset(self, new_interface)
    if new_interface is not None and self._magnetism is not None:
        self._enable_calculator_magnetism()

assign_material(material)

Assign a material to the layer interface.

Parameters:

Name Type Description Default
material Material

The material to assign to the layer.

required
Source code in src/easyreflectometry/sample/elements/layers/layer.py
258
259
260
261
262
263
264
265
266
267
268
269
270
def assign_material(self, material: Material) -> None:
    """Assign a material to the layer interface.

    Parameters
    ----------
    material : Material
        The material to assign to the layer.
    """
    coupled = self.rho_m_coupled
    self._material = material
    self._reconcile_rho_m_coupling(coupled)
    if self.interface is not None:
        self.interface().assign_material_to_layer(self.material.unique_name, self.unique_name)

Material

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/materials/material.py
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
class Material(BaseCore):
    def __init__(
        self,
        sld: Union[Parameter, float, None] = None,
        isld: Union[Parameter, float, None] = None,
        name: str = 'EasyMaterial',
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        sld : Union[Parameter, float, None], optional
            Real scattering length density. By default, None.
        isld : Union[Parameter, float, None], optional
            Imaginary scattering length density. By default, None.
        name : str, optional
            Name of the material. By default, 'EasyMaterial'.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        sld = get_as_parameter(
            name='sld',
            value=sld,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Sld',
        )
        apply_default_limits(sld, 'sld')

        isld = get_as_parameter(
            name='isld',
            value=isld,
            default_dict=DEFAULTS,
            unique_name_prefix=f'{unique_name}_Isld',
        )
        apply_default_limits(isld, 'isld')

        super().__init__(name=name, unique_name=unique_name)
        self._sld = sld
        self._isld = isld

        if interface is not None:
            self.interface = interface

    @property
    def sld(self) -> Parameter:
        return self._sld

    @sld.setter
    def sld(self, value: float) -> None:
        self._sld.value = value

    @property
    def isld(self) -> Parameter:
        return self._isld

    @isld.setter
    def isld(self, value: float) -> None:
        self._isld.value = value

    # Representation
    @property
    def _dict_repr(self) -> dict[str, str]:
        """A simplified dict representation."""
        return {
            self.name: {
                'sld': f'{self._sld.value:.3f}e-6 {self._sld.unit}',
                'isld': f'{self._isld.value:.3f}e-6 {self._isld.unit}',
            }
        }

__init__(sld=None, isld=None, name='EasyMaterial', unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
sld Union[Parameter, float, None]

Real scattering length density. By default, None.

None
isld Union[Parameter, float, None]

Imaginary scattering length density. By default, None.

None
name str

Name of the material. By default, 'EasyMaterial'.

'EasyMaterial'
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/sample/elements/materials/material.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
def __init__(
    self,
    sld: Union[Parameter, float, None] = None,
    isld: Union[Parameter, float, None] = None,
    name: str = 'EasyMaterial',
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    sld : Union[Parameter, float, None], optional
        Real scattering length density. By default, None.
    isld : Union[Parameter, float, None], optional
        Imaginary scattering length density. By default, None.
    name : str, optional
        Name of the material. By default, 'EasyMaterial'.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    sld = get_as_parameter(
        name='sld',
        value=sld,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Sld',
    )
    apply_default_limits(sld, 'sld')

    isld = get_as_parameter(
        name='isld',
        value=isld,
        default_dict=DEFAULTS,
        unique_name_prefix=f'{unique_name}_Isld',
    )
    apply_default_limits(isld, 'isld')

    super().__init__(name=name, unique_name=unique_name)
    self._sld = sld
    self._isld = isld

    if interface is not None:
        self.interface = interface

VolumeFractionWarning

Bases: UserWarning

A volume fraction profile left the physically admissible range [0, 1].

Emitted by :func:volume_fraction_profile when the smeared fraction of a component dips below zero, which happens when the roughness on the two sides of a thin layer is very different. The values are reported unchanged: the slab model, not the plot, is inadmissible there.

Source code in src/easyreflectometry/sample/volume_fraction.py
87
88
89
90
91
92
93
94
class VolumeFractionWarning(UserWarning):
    """A volume fraction profile left the physically admissible range ``[0, 1]``.

    Emitted by :func:`volume_fraction_profile` when the smeared fraction of a
    component dips below zero, which happens when the roughness on the two
    sides of a thin layer is very different. The values are reported
    unchanged: the slab model, not the plot, is inadmissible there.
    """

_Slab dataclass

One flat layer as the occupancy calculation sees it.

roughness is the roughness of the interface above the slab (between it and the previous slab), which is what Layer.roughness stores and what refnx's slab column 3 means.

Source code in src/easyreflectometry/sample/volume_fraction.py
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@dataclass(frozen=True)
class _Slab:
    """One flat layer as the occupancy calculation sees it.

    ``roughness`` is the roughness of the interface *above* the slab (between it
    and the previous slab), which is what ``Layer.roughness`` stores and what
    refnx's slab column 3 means.
    """

    thickness: float
    roughness: float
    composition: dict
    name: str

VolumeFractionProfile dataclass

Volume fraction of every component of a sample versus depth.

Attributes:

Name Type Description
z np.ndarray

Depth in Å; z = 0 at the superphase / first layer interface.

fractions dict

Component key -> phi_c(z). Keys are stable identifiers (the unique_name of the material, or of the first member of a merged group), in order of first appearance from the front of the sample.

labels dict

Component key -> display label, unique within the profile.

Source code in src/easyreflectometry/sample/volume_fraction.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
@dataclass
class VolumeFractionProfile:
    """Volume fraction of every component of a sample versus depth.

    Attributes
    ----------
    z : np.ndarray
        Depth in Å; ``z = 0`` at the superphase / first layer interface.
    fractions : dict
        Component key -> ``phi_c(z)``. Keys are stable identifiers (the
        ``unique_name`` of the material, or of the first member of a merged
        group), in order of first appearance from the front of the sample.
    labels : dict
        Component key -> display label, unique within the profile.
    """

    z: np.ndarray
    fractions: dict
    labels: dict

    @property
    def components(self) -> list:
        """The component keys, front to back."""
        return list(self.fractions)

    @property
    def total(self) -> np.ndarray:
        """``sum_c phi_c(z)``; ones up to floating point error."""
        if not self.fractions:
            return np.zeros_like(self.z)
        return np.sum(np.array(list(self.fractions.values())), axis=0)

    def grouped(self, groups: Mapping[str, Sequence[str]]) -> 'VolumeFractionProfile':
        """Merge components into named groups for presentation.

        Parameters
        ----------
        groups : Mapping[str, Sequence[str]]
            Group label -> members, given as component keys or labels, e.g.
            ``{'Heads': ['C10H18NO8P'], 'Water': ['D2O', 'H2O']}``. Each
            component may appear at most once over all groups. Components not
            listed keep their own trace. The result lists the groups first, in
            the given order, then the remaining components in their original
            order.

        Returns
        -------
        VolumeFractionProfile
            A new profile; this one is left unchanged.

        Raises
        ------
        ValueError
            A member is unknown, a component is listed twice, a group is
            empty, or a group label collides with a retained component.
        """
        label_to_key = {label: key for key, label in self.labels.items()}
        used: dict = {}
        fractions: dict = {}
        labels: dict = {}
        for group_label, members in groups.items():
            keys = []
            for member in members:
                key = member if member in self.fractions else label_to_key.get(member)
                if key is None:
                    raise ValueError(
                        f'Unknown component {member!r} in group {group_label!r}. '
                        f'Available keys: {list(self.fractions)}; labels: {list(self.labels.values())}.'
                    )
                if key in used:
                    raise ValueError(
                        f'Component {self.labels[key]!r} is listed in group {group_label!r} '
                        f'and already in group {used[key]!r}; groups must not overlap.'
                    )
                used[key] = group_label
                keys.append(key)
            if not keys:
                raise ValueError(f'Group {group_label!r} has no members.')
            fractions[group_label] = np.sum(np.array([self.fractions[key] for key in keys]), axis=0)
            labels[group_label] = group_label
        for key, values in self.fractions.items():
            if key in used:
                continue
            label = self.labels[key]
            if key in fractions or label in labels.values():
                raise ValueError(
                    f'Group label {label!r} collides with the retained component {label!r}; '
                    'include that component in the group or rename the group.'
                )
            fractions[key] = values.copy()
            labels[key] = label
        return VolumeFractionProfile(z=self.z.copy(), fractions=fractions, labels=labels)

components property

The component keys, front to back.

total property

sum_c phi_c(z); ones up to floating point error.

grouped(groups)

Merge components into named groups for presentation.

Parameters:

Name Type Description Default
groups Mapping[str, Sequence[str]]

Group label -> members, given as component keys or labels, e.g. {'Heads': ['C10H18NO8P'], 'Water': ['D2O', 'H2O']}. Each component may appear at most once over all groups. Components not listed keep their own trace. The result lists the groups first, in the given order, then the remaining components in their original order.

required

Returns:

Type Description
VolumeFractionProfile

A new profile; this one is left unchanged.

Raises:

Type Description
ValueError

A member is unknown, a component is listed twice, a group is empty, or a group label collides with a retained component.

Source code in src/easyreflectometry/sample/volume_fraction.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def grouped(self, groups: Mapping[str, Sequence[str]]) -> 'VolumeFractionProfile':
    """Merge components into named groups for presentation.

    Parameters
    ----------
    groups : Mapping[str, Sequence[str]]
        Group label -> members, given as component keys or labels, e.g.
        ``{'Heads': ['C10H18NO8P'], 'Water': ['D2O', 'H2O']}``. Each
        component may appear at most once over all groups. Components not
        listed keep their own trace. The result lists the groups first, in
        the given order, then the remaining components in their original
        order.

    Returns
    -------
    VolumeFractionProfile
        A new profile; this one is left unchanged.

    Raises
    ------
    ValueError
        A member is unknown, a component is listed twice, a group is
        empty, or a group label collides with a retained component.
    """
    label_to_key = {label: key for key, label in self.labels.items()}
    used: dict = {}
    fractions: dict = {}
    labels: dict = {}
    for group_label, members in groups.items():
        keys = []
        for member in members:
            key = member if member in self.fractions else label_to_key.get(member)
            if key is None:
                raise ValueError(
                    f'Unknown component {member!r} in group {group_label!r}. '
                    f'Available keys: {list(self.fractions)}; labels: {list(self.labels.values())}.'
                )
            if key in used:
                raise ValueError(
                    f'Component {self.labels[key]!r} is listed in group {group_label!r} '
                    f'and already in group {used[key]!r}; groups must not overlap.'
                )
            used[key] = group_label
            keys.append(key)
        if not keys:
            raise ValueError(f'Group {group_label!r} has no members.')
        fractions[group_label] = np.sum(np.array([self.fractions[key] for key in keys]), axis=0)
        labels[group_label] = group_label
    for key, values in self.fractions.items():
        if key in used:
            continue
        label = self.labels[key]
        if key in fractions or label in labels.values():
            raise ValueError(
                f'Group label {label!r} collides with the retained component {label!r}; '
                'include that component in the group or rename the group.'
            )
        fractions[key] = values.copy()
        labels[key] = label
    return VolumeFractionProfile(z=self.z.copy(), fractions=fractions, labels=labels)

_ComponentRegistry

Assigns stable keys and unique labels to the leaf materials of a sample.

Source code in src/easyreflectometry/sample/volume_fraction.py
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
class _ComponentRegistry:
    """Assigns stable keys and unique labels to the leaf materials of a sample."""

    def __init__(self, merge_equivalent: bool):
        self._merge_equivalent = merge_equivalent
        self._key_by_identity: dict = {}
        self._key_by_equivalence: dict = {}
        self._label_counts: dict = {}
        self.labels: dict = {}

    def key_for(self, material: 'Material') -> str:
        identity = material.unique_name
        key = self._key_by_identity.get(identity)
        if key is not None:
            return key
        key = identity
        if self._merge_equivalent:
            equivalence = (
                material.name,
                round(_scalar(material.sld), _EQUIVALENCE_DECIMALS),
                round(_scalar(material.isld), _EQUIVALENCE_DECIMALS),
            )
            key = self._key_by_equivalence.setdefault(equivalence, identity)
        self._key_by_identity[identity] = key
        if key == identity:
            self.labels[key] = self._unique_label(material.name)
        return key

    def _unique_label(self, name: str) -> str:
        count = self._label_counts.get(name, 0) + 1
        self._label_counts[name] = count
        return name if count == 1 else f'{name} ({count})'

volume_fraction_profile(sample, z=None, *, max_delta_z=None, merge_equivalent=True)

Erf-smeared volume fraction of every component of sample versus depth.

Parameters:

Name Type Description Default
sample Sample

The sample. It does not need to be bound to a calculator.

required
z np.ndarray

Depths (Å) at which to evaluate the profile, z = 0 being the superphase / first layer interface. By default a grid of :data:DEFAULT_POINTS points spanning the sample plus four times the outer roughnesses on each side (the refnx convention; the extent only looks at the two outer interfaces, so pass a wider z when a broad internal interface matters).

None
max_delta_z float

Maximum spacing of the default grid; raises the point count above :data:DEFAULT_POINTS when needed. Cannot be combined with z.

None
merge_equivalent bool

Merge materials the calculator cannot tell apart (equal name, SLD and imaginary SLD) into one component, so that the several default solvent objects of a bilayer come out as one "D2O" trace and its two head groups as one trace. False keeps every material object separate. Materials that share a name but differ in SLD are never merged; use :meth:VolumeFractionProfile.grouped for that. By default, True.

True

Returns:

Type Description
VolumeFractionProfile

The profile. sum_c phi_c(z) == 1 at every depth.

Raises:

Type Description
ValueError

The sample has fewer than two layers, both z and max_delta_z are given, a grid argument is invalid, a parameter value is not finite, or a :class:~easyreflectometry.sample.GradientLayer was edited after construction so that its sub-layers no longer carry the linear blend of its end materials.

Warns:

Type Description
VolumeFractionWarning

A fraction dips below -NEGATIVE_FRACTION_TOLERANCE somewhere on the grid (strongly unequal roughness around a thin layer).

Source code in src/easyreflectometry/sample/volume_fraction.py
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
def volume_fraction_profile(
    sample: 'Sample',
    z: Optional[np.ndarray] = None,
    *,
    max_delta_z: Optional[float] = None,
    merge_equivalent: bool = True,
) -> VolumeFractionProfile:
    """Erf-smeared volume fraction of every component of ``sample`` versus depth.

    Parameters
    ----------
    sample : Sample
        The sample. It does not need to be bound to a calculator.
    z : np.ndarray, optional
        Depths (Å) at which to evaluate the profile, ``z = 0`` being the
        superphase / first layer interface. By default a grid of
        :data:`DEFAULT_POINTS` points spanning the sample plus four times the
        outer roughnesses on each side (the refnx convention; the extent only
        looks at the two outer interfaces, so pass a wider ``z`` when a broad
        internal interface matters).
    max_delta_z : float, optional
        Maximum spacing of the default grid; raises the point count above
        :data:`DEFAULT_POINTS` when needed. Cannot be combined with ``z``.
    merge_equivalent : bool, optional
        Merge materials the calculator cannot tell apart (equal name, SLD and
        imaginary SLD) into one component, so that the several default solvent
        objects of a bilayer come out as one "D2O" trace and its two head
        groups as one trace. ``False`` keeps every material object separate.
        Materials that share a name but differ in SLD are never merged; use
        :meth:`VolumeFractionProfile.grouped` for that. By default, True.

    Returns
    -------
    VolumeFractionProfile
        The profile. ``sum_c phi_c(z) == 1`` at every depth.

    Raises
    ------
    ValueError
        The sample has fewer than two layers, both ``z`` and ``max_delta_z``
        are given, a grid argument is invalid, a parameter value is not
        finite, or a :class:`~easyreflectometry.sample.GradientLayer` was
        edited after construction so that its sub-layers no longer carry the
        linear blend of its end materials.

    Warns
    -----
    VolumeFractionWarning
        A fraction dips below ``-NEGATIVE_FRACTION_TOLERANCE`` somewhere on the
        grid (strongly unequal roughness around a thin layer).
    """
    if z is not None and max_delta_z is not None:
        raise ValueError('Give either z or max_delta_z, not both.')
    slabs, labels = _resolve_slabs(sample, merge_equivalent=merge_equivalent)
    grid = _default_grid(slabs, max_delta_z) if z is None else _validated_grid(z)

    thickness = np.array([slab.thickness for slab in slabs], dtype=float)
    interfaces = np.cumsum(thickness[:-1])  # z of the interface above slab 1, 2, ..., M
    sigmas = np.array([slab.roughness for slab in slabs[1:]], dtype=float)
    fractions = {}
    for key in labels:
        values = np.array([slab.composition.get(key, 0.0) for slab in slabs], dtype=float)
        fractions[key] = _smear(grid, interfaces, sigmas, values)
    _warn_if_inadmissible(grid, fractions, labels)
    return VolumeFractionProfile(z=grid, fractions=fractions, labels=labels)

_composition(material)

Leaf materials of material with their volume fractions, which sum to one.

A MaterialMixture (and so a MaterialSolvated) is split into its two parts weighted by its fraction; the recursion covers nested mixtures should they become constructible. Anything else is a leaf.

Source code in src/easyreflectometry/sample/volume_fraction.py
276
277
278
279
280
281
282
283
284
285
286
287
288
def _composition(material) -> list:
    """Leaf materials of ``material`` with their volume fractions, which sum to one.

    A ``MaterialMixture`` (and so a ``MaterialSolvated``) is split into its two
    parts weighted by its fraction; the recursion covers nested mixtures should
    they become constructible. Anything else is a leaf.
    """
    if isinstance(material, MaterialMixture):
        p = float(material.fraction.value)
        parts = [(leaf, weight * (1.0 - p)) for leaf, weight in _composition(material.material_a)]
        parts += [(leaf, weight * p) for leaf, weight in _composition(material.material_b)]
        return parts
    return [(material, 1.0)]

_scalar(value)

Float value of a Parameter or a plain number.

Source code in src/easyreflectometry/sample/volume_fraction.py
325
326
327
def _scalar(value) -> float:
    """Float value of a Parameter or a plain number."""
    return float(getattr(value, 'value', value))

_layer_entries(sample)

Yield (layer, composition) front to back, with repetitions expanded.

Source code in src/easyreflectometry/sample/volume_fraction.py
330
331
332
333
334
335
336
337
338
339
340
341
342
def _layer_entries(sample: 'Sample') -> Iterator:
    """Yield ``(layer, composition)`` front to back, with repetitions expanded."""
    for assembly in sample:
        if isinstance(assembly, GradientLayer):
            yield from _gradient_entries(assembly)
        elif isinstance(assembly, RepeatingMultilayer):
            repetitions = int(round(_scalar(assembly.repetitions)))
            for _ in range(repetitions):
                for layer in assembly.layers:
                    yield layer, _composition(layer.material)
        else:
            for layer in assembly.layers:
                yield layer, _composition(layer.material)

_gradient_entries(assembly)

Sub-layer i of N is the i/N blend of the two end materials.

That is how GradientLayer builds its sub-layers, but it freezes them at construction, so the blend is checked against each sub-layer's actual SLD and a stale gradient is refused rather than silently reinterpreted.

Source code in src/easyreflectometry/sample/volume_fraction.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
def _gradient_entries(assembly: GradientLayer) -> Iterator:
    """Sub-layer ``i`` of ``N`` is the ``i/N`` blend of the two end materials.

    That is how ``GradientLayer`` builds its sub-layers, but it freezes them at
    construction, so the blend is checked against each sub-layer's actual SLD
    and a stale gradient is refused rather than silently reinterpreted.
    """
    front, back = assembly.front_material, assembly.back_material
    layers = list(assembly.layers)
    elements = int(assembly.discretisation_elements)
    if len(layers) != elements:
        raise ValueError(
            f'GradientLayer {assembly.name!r} has {len(layers)} sub-layers but {elements} discretisation '
            'elements; its composition is undefined. Rebuild the gradient layer.'
        )
    for index, layer in enumerate(layers):
        fraction_back = index / elements
        for attribute in ('sld', 'isld'):
            expected = (1.0 - fraction_back) * _scalar(getattr(front, attribute)) + fraction_back * _scalar(
                getattr(back, attribute)
            )
            actual = _scalar(getattr(layer.material, attribute))
            if not math.isclose(actual, expected, rel_tol=_GRADIENT_RELATIVE_TOLERANCE, abs_tol=_GRADIENT_ABSOLUTE_TOLERANCE):
                raise ValueError(
                    f'GradientLayer {assembly.name!r}: sub-layer {index} has {attribute} {actual:g}, but the linear '
                    f'blend of its end materials gives {expected:g}. Its end materials or sub-layers were edited '
                    'after construction, so its composition is undefined. Rebuild the gradient layer.'
                )
        yield layer, [(front, 1.0 - fraction_back), (back, fraction_back)]

_resolve_slabs(sample, merge_equivalent=True)

Flatten sample into (slabs, labels).

The first and last slabs are the superphase and subphase of the expanded list; their thickness is forced to zero as the calculators do.

Source code in src/easyreflectometry/sample/volume_fraction.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
def _resolve_slabs(sample: 'Sample', merge_equivalent: bool = True) -> tuple:
    """Flatten ``sample`` into ``(slabs, labels)``.

    The first and last slabs are the superphase and subphase of the *expanded*
    list; their thickness is forced to zero as the calculators do.
    """
    registry = _ComponentRegistry(merge_equivalent)
    slabs = []
    for layer, parts in _layer_entries(sample):
        composition: dict = {}
        for material, weight in parts:
            key = registry.key_for(material)
            composition[key] = composition.get(key, 0.0) + float(weight)
        thickness = abs(_scalar(layer.thickness))
        roughness = abs(_scalar(layer.roughness))
        _check_finite(layer, thickness, roughness, composition)
        slabs.append(_Slab(thickness=thickness, roughness=roughness, composition=composition, name=layer.name))
    if len(slabs) < 2:
        raise ValueError(
            'A volume fraction profile needs at least a superphase and a subphase; the sample has fewer than two layers.'
        )
    for index in (0, -1):
        bounding = slabs[index]
        slabs[index] = _Slab(thickness=0.0, roughness=bounding.roughness, composition=bounding.composition, name=bounding.name)
    return slabs, registry.labels

_check_finite(layer, thickness, roughness, composition)

Source code in src/easyreflectometry/sample/volume_fraction.py
403
404
405
406
407
def _check_finite(layer: 'Layer', thickness: float, roughness: float, composition: dict) -> None:
    if not (math.isfinite(thickness) and math.isfinite(roughness)):
        raise ValueError(f'Layer {layer.name!r} has a non-finite thickness or roughness.')
    if not all(math.isfinite(value) for value in composition.values()):
        raise ValueError(f'Layer {layer.name!r} has a non-finite material fraction.')

_default_grid(slabs, max_delta_z)

refnx's grid: the film plus four outer roughnesses and 5 Å on each side.

Source code in src/easyreflectometry/sample/volume_fraction.py
413
414
415
416
417
418
419
420
421
422
423
424
def _default_grid(slabs: list, max_delta_z: Optional[float]) -> np.ndarray:
    """refnx's grid: the film plus four outer roughnesses and 5 Å on each side."""
    total = sum(slab.thickness for slab in slabs)
    start = -5.0 - 4.0 * slabs[1].roughness
    end = 5.0 + total + 4.0 * slabs[-1].roughness
    points = DEFAULT_POINTS
    if max_delta_z is not None:
        max_delta_z = float(max_delta_z)
        if not (math.isfinite(max_delta_z) and max_delta_z > 0):
            raise ValueError('max_delta_z must be a finite, positive spacing in Å.')
        points = max(DEFAULT_POINTS, int(math.ceil((end - start) / max_delta_z)) + 1)
    return np.linspace(start, end, num=points)

_validated_grid(z)

Source code in src/easyreflectometry/sample/volume_fraction.py
427
428
429
430
431
432
433
def _validated_grid(z) -> np.ndarray:
    grid = np.asarray(z, dtype=float)
    if grid.ndim != 1 or grid.size == 0:
        raise ValueError('z must be a one-dimensional, non-empty array of depths in Å.')
    if not np.all(np.isfinite(grid)):
        raise ValueError('z must contain only finite depths.')
    return grid

_smear(z, interfaces, sigmas, values)

Sum of erf steps: values[0] + sum_i (values[i+1] - values[i]) * H_sigma_i(z - interfaces[i]).

This is refnx's sld_profile loop applied to an arbitrary per-slab quantity. It is O(interfaces x points); plenty for hundreds of interfaces. Should a many-thousand-repetition stack ever need it, vectorise as steps @ H(z[None, :], interfaces[:, None], sigmas[:, None]).

Source code in src/easyreflectometry/sample/volume_fraction.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
def _smear(z: np.ndarray, interfaces: np.ndarray, sigmas: np.ndarray, values: np.ndarray) -> np.ndarray:
    """Sum of erf steps: ``values[0] + sum_i (values[i+1] - values[i]) * H_sigma_i(z - interfaces[i])``.

    This is refnx's ``sld_profile`` loop applied to an arbitrary per-slab
    quantity. It is O(interfaces x points); plenty for hundreds of interfaces.
    Should a many-thousand-repetition stack ever need it, vectorise as
    ``steps @ H(z[None, :], interfaces[:, None], sigmas[:, None])``.
    """
    profile = np.full_like(z, values[0], dtype=float)
    for location, sigma, step in zip(interfaces, sigmas, np.diff(values)):
        if sigma == 0:
            # '>=' on purpose: refnx's Step gives H(0) = 1, and a grid point can land on an interface.
            kernel = np.where(z >= location, 1.0, 0.0)
        else:
            kernel = 0.5 * (1.0 + erf((z - location) / (sigma * math.sqrt(2.0))))
        profile += step * kernel
    return profile

_warn_if_inadmissible(z, fractions, labels)

Source code in src/easyreflectometry/sample/volume_fraction.py
455
456
457
458
459
460
461
462
463
464
465
466
467
468
def _warn_if_inadmissible(z: np.ndarray, fractions: dict, labels: dict) -> None:
    offenders = []
    for key, values in fractions.items():
        index = int(np.argmin(values))
        if values[index] < -NEGATIVE_FRACTION_TOLERANCE:
            offenders.append(f'{labels[key]!r} reaches {values[index]:.3g} at z = {z[index]:.2f} Å')
    if offenders:
        warnings.warn(
            'Volume fraction below zero: ' + '; '.join(offenders) + '. This happens when the roughness on the '
            'two sides of a thin layer is very different, so the slab model is not physically admissible there. '
            'The values are reported unchanged; a grid check cannot certify every depth in between.',
            VolumeFractionWarning,
            stacklevel=3,
        )