Skip to content

project

SUM_PARTNER_MAX_BACKUP = '_sum_partner_previous_max' module-attribute

USER_CONSTRAINT_FLAG = '_easyreflectometry_user_constraint' module-attribute

logger = logging.getLogger(__name__) module-attribute

Q_MIN = 0.001 module-attribute

Q_MAX = 0.3 module-attribute

Q_RESOLUTION = 500 module-attribute

GUIDE_FIELD_ANGLE = 270.0 module-attribute

SPIN_ASYMMETRY_SIGNIFICANCE = 3.0 module-attribute

SPIN_ASYMMETRY_CANCELLATION_FRACTION = 0.001 module-attribute

MAGNETIC_MOMENT_FLOOR_FRACTION = 0.01 module-attribute

DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq module-attribute

_PATH_SKIPPED_PROPERTIES = frozenset({'interface', 'parent', 'front_layer', 'back_layer', 'head_layer', 'tail_layer'}) module-attribute

CalculatorFactory

Bases: InterfaceFactoryTemplate

Source code in src/easyreflectometry/calculators/factory.py
14
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
class CalculatorFactory(InterfaceFactoryTemplate):
    def __init__(self):
        """Init function."""
        super().__init__(interface_list=CalculatorBase._calculators)

    def reset_storage(self) -> None:
        """Reset storage."""
        return self().reset_storage()

    def sld_profile(self, model_id: str) -> tuple:
        """Sld profile."""
        return self().sld_profile(model_id)

    def polarized_reflectivity_profiles(self, x_array, model_id: str) -> dict:
        """Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm')."""
        return self().polarized_reflectivity_profiles(x_array, model_id)

    def reflectivity_profile_channel(self, x_array, model_id: str, channel: PolarizationChannel | str):
        """Reflectivity profile of one explicit spin channel ('pp', 'pm', 'mp' or 'mm')."""
        return self().reflectivity_profile_channel(x_array, model_id, channel)

    def magnetic_sld_profile(self, model_id: str) -> tuple:
        """Nuclear and magnetic sld profiles: z, sld(z), rhoM(z) and thetaM(z)."""
        return self().magnetic_sld_profile(model_id)

    @property
    def fit_func(self) -> Callable:
        """Fit func."""
        """
        Pass through to the underlying interfaces fitting function.

        :param x_array: points to be calculated at
        :type x_array: np.ndarray
        :param args: positional arguments for the fitting function
        :type args: Any
        :param kwargs: key/value pair arguments for the fitting function.
        :type kwargs: Any
        :return: points calculated at positional values `x`
        :rtype: np.ndarray
        #"""

        def __fit_func(*args, **kwargs):
            """Fit func."""
            return self().reflectity_profile(*args, **kwargs)

        return __fit_func

    def fit_func_for_channel(self, channel: PolarizationChannel | str) -> Callable:
        """A fit function evaluating one explicit spin channel.

        Used for simultaneous multi-channel fitting: each channel dataset gets its
        own fit function while all of them share the same model (and hence the
        same parameters).

        Parameters
        ----------
        channel : PolarizationChannel | str
            One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

        Returns
        -------
        Callable
            Function of (x_array, model_id) returning the channel reflectivity.
        """
        channel = PolarizationChannel(channel)

        def __fit_func(x_array, model_id):
            """Fit func for one spin channel."""
            return self().reflectivity_profile_channel(x_array, model_id, channel)

        return __fit_func

fit_func property

Fit func.

__init__()

Init function.

Source code in src/easyreflectometry/calculators/factory.py
15
16
17
def __init__(self):
    """Init function."""
    super().__init__(interface_list=CalculatorBase._calculators)

reset_storage()

Reset storage.

Source code in src/easyreflectometry/calculators/factory.py
19
20
21
def reset_storage(self) -> None:
    """Reset storage."""
    return self().reset_storage()

sld_profile(model_id)

Sld profile.

Source code in src/easyreflectometry/calculators/factory.py
23
24
25
def sld_profile(self, model_id: str) -> tuple:
    """Sld profile."""
    return self().sld_profile(model_id)

polarized_reflectivity_profiles(x_array, model_id)

Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm').

Source code in src/easyreflectometry/calculators/factory.py
27
28
29
def polarized_reflectivity_profiles(self, x_array, model_id: str) -> dict:
    """Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm')."""
    return self().polarized_reflectivity_profiles(x_array, model_id)

reflectivity_profile_channel(x_array, model_id, channel)

Reflectivity profile of one explicit spin channel ('pp', 'pm', 'mp' or 'mm').

Source code in src/easyreflectometry/calculators/factory.py
31
32
33
def reflectivity_profile_channel(self, x_array, model_id: str, channel: PolarizationChannel | str):
    """Reflectivity profile of one explicit spin channel ('pp', 'pm', 'mp' or 'mm')."""
    return self().reflectivity_profile_channel(x_array, model_id, channel)

magnetic_sld_profile(model_id)

Nuclear and magnetic sld profiles: z, sld(z), rhoM(z) and thetaM(z).

Source code in src/easyreflectometry/calculators/factory.py
35
36
37
def magnetic_sld_profile(self, model_id: str) -> tuple:
    """Nuclear and magnetic sld profiles: z, sld(z), rhoM(z) and thetaM(z)."""
    return self().magnetic_sld_profile(model_id)

fit_func_for_channel(channel)

A fit function evaluating one explicit spin channel.

Used for simultaneous multi-channel fitting: each channel dataset gets its own fit function while all of them share the same model (and hence the same parameters).

Parameters:

Name Type Description Default
channel PolarizationChannel | str

One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

required

Returns:

Type Description
Callable

Function of (x_array, model_id) returning the channel reflectivity.

Source code in src/easyreflectometry/calculators/factory.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def fit_func_for_channel(self, channel: PolarizationChannel | str) -> Callable:
    """A fit function evaluating one explicit spin channel.

    Used for simultaneous multi-channel fitting: each channel dataset gets its
    own fit function while all of them share the same model (and hence the
    same parameters).

    Parameters
    ----------
    channel : PolarizationChannel | str
        One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

    Returns
    -------
    Callable
        Function of (x_array, model_id) returning the channel reflectivity.
    """
    channel = PolarizationChannel(channel)

    def __fit_func(x_array, model_id):
        """Fit func for one spin channel."""
        return self().reflectivity_profile_channel(x_array, model_id, channel)

    return __fit_func

PolarizationChannel

Bases: str, Enum

Spin cross-section channels for polarized neutron reflectometry.

The accepted spellings are exactly the enum values ('pp', 'pm', 'mp', 'mm'); uppercase strings are rejected.

Source code in src/easyreflectometry/calculators/polarization.py
 7
 8
 9
10
11
12
13
14
15
16
17
class PolarizationChannel(str, Enum):
    """Spin cross-section channels for polarized neutron reflectometry.

    The accepted spellings are exactly the enum values ('pp', 'pm', 'mp', 'mm');
    uppercase strings are rejected.
    """

    PP = 'pp'  # non-spin-flip, up-up
    PM = 'pm'  # spin-flip, up-down
    MP = 'mp'  # spin-flip, down-up
    MM = 'mm'  # non-spin-flip, down-down

CalculatorBase

Bases: SerializerComponent

This class is a template and defines all properties that a calculator should have.

Source code in src/easyreflectometry/calculators/calculator_base.py
 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
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
286
287
288
289
290
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
323
324
325
326
327
328
329
330
331
332
class CalculatorBase(SerializerComponent, metaclass=ABCMeta):
    """This class is a template and defines all properties that a calculator should have."""

    _calculators: list[CalculatorBase] = []  # class variable to store all calculators
    _material_link: dict[str, str]
    _layer_link: dict[str, str]
    _item_link: dict[str, str]
    _model_link: dict[str, str]

    def __init_subclass__(cls, is_abstract: bool = False, **kwargs) -> None:
        r"""Initialise all subclasses so that they can be created in the factory.

        Parameters
        ----------
        cls :
        is_abstract : bool, optional
            Is this a subclass which shouldn't be dded. By default, False.
        **kwargs :
            Key word arguments.
        """
        super().__init_subclass__(**kwargs)
        if not is_abstract:
            cls._calculators.append(cls)

    def __init__(self):
        """Init function."""
        self._namespace = {}
        self._wrapper: WrapperBase

    def reset_storage(self) -> None:
        r"""Reset the storage area of the calculator."""
        self._wrapper.reset_storage()

    def create(self, model: Material | Layer | Multilayer | Model) -> list[ItemContainer]:
        """Creation function.

        Parameters
        ----------
        model : Material | Layer | Multilayer | Model
            Object to be created.
        """
        r_list = []
        t_ = type(model)
        if issubclass(t_, Material):
            key = model.unique_name
            if key not in self._wrapper.storage['material'].keys():
                self._wrapper.create_material(key)
            r_list.append(
                ItemContainer(
                    key,
                    self._material_link,
                    self._wrapper.get_material_value,
                    self._wrapper.update_material,
                )
            )
        elif issubclass(t_, MaterialMixture):
            key = model.unique_name
            if key not in self._wrapper.storage['material'].keys():
                self._wrapper.create_material(key)
            r_list.append(
                ItemContainer(
                    key,
                    self._material_link,
                    self._wrapper.get_material_value,
                    self._wrapper.update_material,
                )
            )
        elif issubclass(t_, Layer):
            key = model.unique_name
            if key not in self._wrapper.storage['layer'].keys():
                self._wrapper.create_layer(key)
            r_list.append(
                ItemContainer(
                    key,
                    self._layer_link,
                    self._wrapper.get_layer_value,
                    self._wrapper.update_layer,
                )
            )
            self.assign_material_to_layer(model.material.unique_name, key)
        elif issubclass(t_, BaseAssembly):
            key = model.unique_name
            self._wrapper.create_item(key)
            r_list.append(
                ItemContainer(
                    key,
                    self._item_link,
                    self._wrapper.get_item_value,
                    self._wrapper.update_item,
                )
            )
            for i in model.layers:
                self.add_layer_to_item(i.unique_name, model.unique_name)
        elif issubclass(t_, Model):
            key = model.unique_name
            self._wrapper.create_model(key)
            r_list.append(
                ItemContainer(
                    key,
                    self._model_link,
                    self._wrapper.get_model_value,
                    self._wrapper.update_model,
                )
            )
            for i in model.sample:
                self.add_item_to_model(i.unique_name, key)
        return r_list

    def assign_material_to_layer(self, material_id: str, layer_id: str) -> None:
        """Assign a material to a layer.

        Parameters
        ----------
        material_id : str
            The material name.
        layer_id : str
            The layer name.
        """
        self._wrapper.assign_material_to_layer(material_id, layer_id)

    def add_layer_to_item(self, layer_id: str, item_id: str) -> None:
        """Add a layer to the item stack.

        Parameters
        ----------
        item_id : str
            The item id.
        layer_id : str
            The layer id.
        """
        self._wrapper.add_layer_to_item(layer_id, item_id)

    def remove_layer_from_item(self, layer_id: str, item_id: str) -> None:
        """Remove a layer from an item stack.

        Parameters
        ----------
        item_id : str
            The item id.
        layer_id : str
            The layer id.
        """
        self._wrapper.remove_layer_from_item(layer_id, item_id)

    def add_item_to_model(self, item_id: str, model_id: str) -> None:
        """Add a layer to the item stack.

        Parameters
        ----------
        item_id : str
            The item id.
        model_id : str
            The model id.
        """
        self._wrapper.add_item(item_id, model_id)

    def remove_item_from_model(self, item_id: str, model_id: str) -> None:
        """Remove an item from the model.

        Parameters
        ----------
        item_id : str
            The item id.
        model_id : str
            The model id.
        """
        self._wrapper.remove_item(item_id, model_id)

    def reflectity_profile(self, x_array: np.ndarray, model_id: str) -> np.ndarray:
        """Determines the reflectivity profile for the given range and model.

        Parameters
        ----------
        x_array : np.ndarray
            Points to be calculated at.
        model_id : str
            The model id.
        """
        return self._wrapper.calculate(x_array, model_id)

    def reflectivity_profile_channel(self, x_array: np.ndarray, model_id: str, channel) -> np.ndarray:
        """Determine the reflectivity profile of one explicit spin channel.

        Unlike `polarization_channel` (global calculator state), the channel is an
        argument, so several channels can be evaluated against the same model —
        one per dataset in a simultaneous multi-channel fit.

        Parameters
        ----------
        x_array : np.ndarray
            Points to be calculated at.
        model_id : str
            The model id.
        channel : PolarizationChannel | str
            One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

        Returns
        -------
        np.ndarray
            Reflectivity of the requested channel at q.
        """
        return self._wrapper.calculate_channel(x_array, model_id, channel)

    def polarized_reflectivity_profiles(self, x_array: np.ndarray, model_id: str) -> dict[str, np.ndarray]:
        """Determines the reflectivity profiles of all four spin channels for the given range and model.

        Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d).

        Parameters
        ----------
        x_array : np.ndarray
            Points to be calculated at.
        model_id : str
            The model id.

        Returns
        -------
        dict[str, np.ndarray]
            Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order).
        """
        return self._wrapper.calculate_polarized(x_array, model_id)

    def sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray]:
        """Return the scattering length density profile.

        Parameters
        ----------
        model_id : str
            The model id.

        Returns
        -------
        tuple[np.ndarray, np.ndarray]
            z and sld(z).
        """
        return self._wrapper.sld_profile(model_id)

    def magnetic_sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
        """Return the nuclear and magnetic scattering length density profiles.

        Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d).

        Parameters
        ----------
        model_id : str
            The model id.

        Returns
        -------
        tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
            z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z).
        """
        return self._wrapper.magnetic_sld_profile(model_id)

    def set_resolution_function(self, resolution_function: Callable[[np.array], np.array]) -> None:
        """Set resolution function."""
        return self._wrapper.set_resolution_function(resolution_function)

    @property
    def supports_magnetism(self) -> bool:
        """Whether this calculator backend can model magnetic samples."""
        return self._wrapper.supports_magnetism

    def remove_layer_magnetism(self, layer_id: str) -> None:
        """Remove the magnetic state of one layer; disables `include_magnetism`
        when no magnetic layer is left.

        Parameters
        ----------
        layer_id : str
            The layer id.
        """
        self._wrapper.remove_layer_magnetism(layer_id)

    @property
    def include_magnetism(self):
        """Include magnetism."""
        return self._wrapper.magnetism

    @include_magnetism.setter
    def include_magnetism(self, magnetism: bool):
        """Set the magnetism flag for the calculator.

        Parameters
        ----------
        magnetism : bool
            True if the calculator should include magnetism.
        """
        self._wrapper.magnetism = magnetism

    @property
    def polarization_channel(self):
        """The spin channel ('pp', 'pm', 'mp' or 'mm') used by `reflectity_profile` when magnetism is enabled.

        Note: this state belongs to the currently-active calculator instance; switching
        calculators via the factory constructs a fresh instance and resets it.
        """
        return self._wrapper.polarization_channel

    @polarization_channel.setter
    def polarization_channel(self, channel) -> None:
        """Set the spin channel for reflectivity calculations.

        Parameters
        ----------
        channel : PolarizationChannel | str
            One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).
        """
        self._wrapper.polarization_channel = channel

supports_magnetism property

Whether this calculator backend can model magnetic samples.

include_magnetism property writable

Include magnetism.

polarization_channel property writable

The spin channel ('pp', 'pm', 'mp' or 'mm') used by reflectity_profile when magnetism is enabled.

Note: this state belongs to the currently-active calculator instance; switching calculators via the factory constructs a fresh instance and resets it.

__init_subclass__(is_abstract=False, **kwargs)

Initialise all subclasses so that they can be created in the factory.

Parameters:

Name Type Description Default
cls
required
is_abstract bool

Is this a subclass which shouldn't be dded. By default, False.

False
**kwargs

Key word arguments.

{}
Source code in src/easyreflectometry/calculators/calculator_base.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init_subclass__(cls, is_abstract: bool = False, **kwargs) -> None:
    r"""Initialise all subclasses so that they can be created in the factory.

    Parameters
    ----------
    cls :
    is_abstract : bool, optional
        Is this a subclass which shouldn't be dded. By default, False.
    **kwargs :
        Key word arguments.
    """
    super().__init_subclass__(**kwargs)
    if not is_abstract:
        cls._calculators.append(cls)

__init__()

Init function.

Source code in src/easyreflectometry/calculators/calculator_base.py
48
49
50
51
def __init__(self):
    """Init function."""
    self._namespace = {}
    self._wrapper: WrapperBase

reset_storage()

Reset the storage area of the calculator.

Source code in src/easyreflectometry/calculators/calculator_base.py
53
54
55
def reset_storage(self) -> None:
    r"""Reset the storage area of the calculator."""
    self._wrapper.reset_storage()

create(model)

Creation function.

Parameters:

Name Type Description Default
model Material | Layer | Multilayer | Model

Object to be created.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
 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
def create(self, model: Material | Layer | Multilayer | Model) -> list[ItemContainer]:
    """Creation function.

    Parameters
    ----------
    model : Material | Layer | Multilayer | Model
        Object to be created.
    """
    r_list = []
    t_ = type(model)
    if issubclass(t_, Material):
        key = model.unique_name
        if key not in self._wrapper.storage['material'].keys():
            self._wrapper.create_material(key)
        r_list.append(
            ItemContainer(
                key,
                self._material_link,
                self._wrapper.get_material_value,
                self._wrapper.update_material,
            )
        )
    elif issubclass(t_, MaterialMixture):
        key = model.unique_name
        if key not in self._wrapper.storage['material'].keys():
            self._wrapper.create_material(key)
        r_list.append(
            ItemContainer(
                key,
                self._material_link,
                self._wrapper.get_material_value,
                self._wrapper.update_material,
            )
        )
    elif issubclass(t_, Layer):
        key = model.unique_name
        if key not in self._wrapper.storage['layer'].keys():
            self._wrapper.create_layer(key)
        r_list.append(
            ItemContainer(
                key,
                self._layer_link,
                self._wrapper.get_layer_value,
                self._wrapper.update_layer,
            )
        )
        self.assign_material_to_layer(model.material.unique_name, key)
    elif issubclass(t_, BaseAssembly):
        key = model.unique_name
        self._wrapper.create_item(key)
        r_list.append(
            ItemContainer(
                key,
                self._item_link,
                self._wrapper.get_item_value,
                self._wrapper.update_item,
            )
        )
        for i in model.layers:
            self.add_layer_to_item(i.unique_name, model.unique_name)
    elif issubclass(t_, Model):
        key = model.unique_name
        self._wrapper.create_model(key)
        r_list.append(
            ItemContainer(
                key,
                self._model_link,
                self._wrapper.get_model_value,
                self._wrapper.update_model,
            )
        )
        for i in model.sample:
            self.add_item_to_model(i.unique_name, key)
    return r_list

assign_material_to_layer(material_id, layer_id)

Assign a material to a layer.

Parameters:

Name Type Description Default
material_id str

The material name.

required
layer_id str

The layer name.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
132
133
134
135
136
137
138
139
140
141
142
def assign_material_to_layer(self, material_id: str, layer_id: str) -> None:
    """Assign a material to a layer.

    Parameters
    ----------
    material_id : str
        The material name.
    layer_id : str
        The layer name.
    """
    self._wrapper.assign_material_to_layer(material_id, layer_id)

add_layer_to_item(layer_id, item_id)

Add a layer to the item stack.

Parameters:

Name Type Description Default
item_id str

The item id.

required
layer_id str

The layer id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
144
145
146
147
148
149
150
151
152
153
154
def add_layer_to_item(self, layer_id: str, item_id: str) -> None:
    """Add a layer to the item stack.

    Parameters
    ----------
    item_id : str
        The item id.
    layer_id : str
        The layer id.
    """
    self._wrapper.add_layer_to_item(layer_id, item_id)

remove_layer_from_item(layer_id, item_id)

Remove a layer from an item stack.

Parameters:

Name Type Description Default
item_id str

The item id.

required
layer_id str

The layer id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
156
157
158
159
160
161
162
163
164
165
166
def remove_layer_from_item(self, layer_id: str, item_id: str) -> None:
    """Remove a layer from an item stack.

    Parameters
    ----------
    item_id : str
        The item id.
    layer_id : str
        The layer id.
    """
    self._wrapper.remove_layer_from_item(layer_id, item_id)

add_item_to_model(item_id, model_id)

Add a layer to the item stack.

Parameters:

Name Type Description Default
item_id str

The item id.

required
model_id str

The model id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
168
169
170
171
172
173
174
175
176
177
178
def add_item_to_model(self, item_id: str, model_id: str) -> None:
    """Add a layer to the item stack.

    Parameters
    ----------
    item_id : str
        The item id.
    model_id : str
        The model id.
    """
    self._wrapper.add_item(item_id, model_id)

remove_item_from_model(item_id, model_id)

Remove an item from the model.

Parameters:

Name Type Description Default
item_id str

The item id.

required
model_id str

The model id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
180
181
182
183
184
185
186
187
188
189
190
def remove_item_from_model(self, item_id: str, model_id: str) -> None:
    """Remove an item from the model.

    Parameters
    ----------
    item_id : str
        The item id.
    model_id : str
        The model id.
    """
    self._wrapper.remove_item(item_id, model_id)

reflectity_profile(x_array, model_id)

Determines the reflectivity profile for the given range and model.

Parameters:

Name Type Description Default
x_array np.ndarray

Points to be calculated at.

required
model_id str

The model id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
192
193
194
195
196
197
198
199
200
201
202
def reflectity_profile(self, x_array: np.ndarray, model_id: str) -> np.ndarray:
    """Determines the reflectivity profile for the given range and model.

    Parameters
    ----------
    x_array : np.ndarray
        Points to be calculated at.
    model_id : str
        The model id.
    """
    return self._wrapper.calculate(x_array, model_id)

reflectivity_profile_channel(x_array, model_id, channel)

Determine the reflectivity profile of one explicit spin channel.

Unlike polarization_channel (global calculator state), the channel is an argument, so several channels can be evaluated against the same model — one per dataset in a simultaneous multi-channel fit.

Parameters:

Name Type Description Default
x_array np.ndarray

Points to be calculated at.

required
model_id str

The model id.

required
channel PolarizationChannel | str

One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

required

Returns:

Type Description
np.ndarray

Reflectivity of the requested channel at q.

Source code in src/easyreflectometry/calculators/calculator_base.py
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def reflectivity_profile_channel(self, x_array: np.ndarray, model_id: str, channel) -> np.ndarray:
    """Determine the reflectivity profile of one explicit spin channel.

    Unlike `polarization_channel` (global calculator state), the channel is an
    argument, so several channels can be evaluated against the same model —
    one per dataset in a simultaneous multi-channel fit.

    Parameters
    ----------
    x_array : np.ndarray
        Points to be calculated at.
    model_id : str
        The model id.
    channel : PolarizationChannel | str
        One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

    Returns
    -------
    np.ndarray
        Reflectivity of the requested channel at q.
    """
    return self._wrapper.calculate_channel(x_array, model_id, channel)

polarized_reflectivity_profiles(x_array, model_id)

Determines the reflectivity profiles of all four spin channels for the given range and model.

Requires include_magnetism to be enabled and a calculator that supports it (refl1d).

Parameters:

Name Type Description Default
x_array np.ndarray

Points to be calculated at.

required
model_id str

The model id.

required

Returns:

Type Description
dict[str, np.ndarray]

Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order).

Source code in src/easyreflectometry/calculators/calculator_base.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
def polarized_reflectivity_profiles(self, x_array: np.ndarray, model_id: str) -> dict[str, np.ndarray]:
    """Determines the reflectivity profiles of all four spin channels for the given range and model.

    Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d).

    Parameters
    ----------
    x_array : np.ndarray
        Points to be calculated at.
    model_id : str
        The model id.

    Returns
    -------
    dict[str, np.ndarray]
        Reflectivity per spin channel, keyed 'pp', 'pm', 'mp', 'mm' (in that order).
    """
    return self._wrapper.calculate_polarized(x_array, model_id)

sld_profile(model_id)

Return the scattering length density profile.

Parameters:

Name Type Description Default
model_id str

The model id.

required

Returns:

Type Description
tuple[np.ndarray, np.ndarray]

z and sld(z).

Source code in src/easyreflectometry/calculators/calculator_base.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
def sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray]:
    """Return the scattering length density profile.

    Parameters
    ----------
    model_id : str
        The model id.

    Returns
    -------
    tuple[np.ndarray, np.ndarray]
        z and sld(z).
    """
    return self._wrapper.sld_profile(model_id)

magnetic_sld_profile(model_id)

Return the nuclear and magnetic scattering length density profiles.

Requires include_magnetism to be enabled and a calculator that supports it (refl1d).

Parameters:

Name Type Description Default
model_id str

The model id.

required

Returns:

Type Description
tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]

z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z).

Source code in src/easyreflectometry/calculators/calculator_base.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
def magnetic_sld_profile(self, model_id: str) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Return the nuclear and magnetic scattering length density profiles.

    Requires `include_magnetism` to be enabled and a calculator that supports it (refl1d).

    Parameters
    ----------
    model_id : str
        The model id.

    Returns
    -------
    tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]
        z, sld(z), magnetic sld rhoM(z) and magnetic angle thetaM(z).
    """
    return self._wrapper.magnetic_sld_profile(model_id)

set_resolution_function(resolution_function)

Set resolution function.

Source code in src/easyreflectometry/calculators/calculator_base.py
278
279
280
def set_resolution_function(self, resolution_function: Callable[[np.array], np.array]) -> None:
    """Set resolution function."""
    return self._wrapper.set_resolution_function(resolution_function)

remove_layer_magnetism(layer_id)

Remove the magnetic state of one layer; disables include_magnetism when no magnetic layer is left.

Parameters:

Name Type Description Default
layer_id str

The layer id.

required
Source code in src/easyreflectometry/calculators/calculator_base.py
287
288
289
290
291
292
293
294
295
296
def remove_layer_magnetism(self, layer_id: str) -> None:
    """Remove the magnetic state of one layer; disables `include_magnetism`
    when no magnetic layer is left.

    Parameters
    ----------
    layer_id : str
        The layer id.
    """
    self._wrapper.remove_layer_magnetism(layer_id)

DataSet1D

Bases: SerializerComponent

Source code in src/easyreflectometry/data/data_store.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
class DataSet1D(SerializerComponent):
    def __init__(
        self,
        name: str = 'Series',
        x: Optional[Union[np.ndarray, list]] = None,
        y: Optional[Union[np.ndarray, list]] = None,
        ye: Optional[Union[np.ndarray, list]] = None,
        xe: Optional[Union[np.ndarray, list]] = None,
        model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
        x_label: str = 'x',
        y_label: str = 'y',
        auto_background: bool = True,
    ):
        """Init function."""
        self._model = model
        if y is not None and model is not None and auto_background:
            self._model.background = max(np.min(y), 1e-10)

        if x is None:
            x = np.array([])
        if y is None:
            y = np.array([])
        if ye is None:
            ye = np.zeros_like(x)
        if xe is None:
            xe = np.zeros_like(x)

        if len(x) != len(y):
            raise ValueError('x and y must be the same length')

        self.name = name
        if not isinstance(x, np.ndarray):
            x = np.array(x)
        if not isinstance(y, np.ndarray):
            y = np.array(y)
        if not isinstance(ye, np.ndarray):
            ye = np.array(ye)
        if not isinstance(xe, np.ndarray):
            xe = np.array(xe)

        self.x = x
        self.y = y
        self.ye = ye
        self.xe = xe

        self.x_label = x_label
        self.y_label = y_label

        self._color = None

    @property
    def model(self) -> 'Model':  # delay type checking until runtime (quotes)
        """Model function."""
        return self._model

    @model.setter
    def model(self, new_model: 'Model') -> None:
        """Model function."""
        self._model = new_model

    @property
    def is_experiment(self) -> bool:
        """Is experiment."""
        return self._model is not None

    @property
    def is_simulation(self) -> bool:
        """Is simulation."""
        return self._model is None

    def data_points(self) -> tuple[float, float, float, float]:
        """Data points."""
        return zip(self.x, self.y, self.ye, self.xe)

    def __repr__(self) -> str:
        """Repr function."""
        return "1D DataStore of '{:s}' Vs '{:s}' with {} data points".format(self.x_label, self.y_label, len(self.x))

model property writable

Model function.

is_experiment property

Is experiment.

is_simulation property

Is simulation.

__init__(name='Series', x=None, y=None, ye=None, xe=None, model=None, x_label='x', y_label='y', auto_background=True)

Init function.

Source code in src/easyreflectometry/data/data_store.py
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
def __init__(
    self,
    name: str = 'Series',
    x: Optional[Union[np.ndarray, list]] = None,
    y: Optional[Union[np.ndarray, list]] = None,
    ye: Optional[Union[np.ndarray, list]] = None,
    xe: Optional[Union[np.ndarray, list]] = None,
    model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
    x_label: str = 'x',
    y_label: str = 'y',
    auto_background: bool = True,
):
    """Init function."""
    self._model = model
    if y is not None and model is not None and auto_background:
        self._model.background = max(np.min(y), 1e-10)

    if x is None:
        x = np.array([])
    if y is None:
        y = np.array([])
    if ye is None:
        ye = np.zeros_like(x)
    if xe is None:
        xe = np.zeros_like(x)

    if len(x) != len(y):
        raise ValueError('x and y must be the same length')

    self.name = name
    if not isinstance(x, np.ndarray):
        x = np.array(x)
    if not isinstance(y, np.ndarray):
        y = np.array(y)
    if not isinstance(ye, np.ndarray):
        ye = np.array(ye)
    if not isinstance(xe, np.ndarray):
        xe = np.array(xe)

    self.x = x
    self.y = y
    self.ye = ye
    self.xe = xe

    self.x_label = x_label
    self.y_label = y_label

    self._color = None

data_points()

Data points.

Source code in src/easyreflectometry/data/data_store.py
85
86
87
def data_points(self) -> tuple[float, float, float, float]:
    """Data points."""
    return zip(self.x, self.y, self.ye, self.xe)

__repr__()

Repr function.

Source code in src/easyreflectometry/data/data_store.py
89
90
91
def __repr__(self) -> str:
    """Repr function."""
    return "1D DataStore of '{:s}' Vs '{:s}' with {} data points".format(self.x_label, self.y_label, len(self.x))

PolarizedDataSet

A polarized experiment: one dataset per measured spin channel, one shared model.

Parameters:

Name Type Description Default
name str

Name of the experiment. By default, 'PolarizedSeries'.

'PolarizedSeries'
channels dict[PolarizationChannel | str, DataSet1D]

The measured channels; at least one. Keys are 'pp', 'pm', 'mp', 'mm' (or the corresponding enum members). Two-channel NSF-only experiments simply provide 'pp' and 'mm'.

None
model Model

The model shared by all channels. By default, None.

None
Source code in src/easyreflectometry/data/polarized.py
 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
class PolarizedDataSet:
    """A polarized experiment: one dataset per measured spin channel, one shared model.

    Parameters
    ----------
    name : str, optional
        Name of the experiment. By default, 'PolarizedSeries'.
    channels : dict[PolarizationChannel | str, DataSet1D]
        The measured channels; at least one. Keys are 'pp', 'pm', 'mp', 'mm'
        (or the corresponding enum members). Two-channel NSF-only experiments
        simply provide 'pp' and 'mm'.
    model : Model, optional
        The model shared by all channels. By default, None.
    """

    def __init__(
        self,
        name: str = 'PolarizedSeries',
        channels: Optional[dict[Union[PolarizationChannel, str], DataSet1D]] = None,
        model=None,
    ):
        if not channels:
            raise ValueError('A PolarizedDataSet requires at least one channel dataset.')
        normalized: dict[PolarizationChannel, DataSet1D] = {}
        for channel, dataset in channels.items():
            channel = PolarizationChannel(channel)
            if channel in normalized:
                raise ValueError(f"Duplicate channel '{channel.value}'.")
            normalized[channel] = self._validated_dataset(channel, dataset)
        self._channels = self._in_canonical_order(normalized)
        self.name = name
        self.model = model

    @staticmethod
    def _validated_dataset(channel: PolarizationChannel, dataset: DataSet1D) -> DataSet1D:
        if not isinstance(dataset, DataSet1D):
            raise ValueError(f"Channel '{channel.value}' must be a DataSet1D, got {type(dataset).__name__}.")
        return dataset

    @staticmethod
    def _in_canonical_order(channels: dict[PolarizationChannel, DataSet1D]) -> dict[PolarizationChannel, DataSet1D]:
        # Canonical channel order: pp, pm, mp, mm.
        return {channel: channels[channel] for channel in POLARIZATION_CHANNEL_TO_INDEX if channel in channels}

    @property
    def model(self):
        """The model shared by all channels."""
        return self._model

    @model.setter
    def model(self, new_model) -> None:
        self._model = new_model
        for dataset in self._channels.values():
            dataset.model = new_model

    @property
    def channels(self) -> Mapping[PolarizationChannel, DataSet1D]:
        """The measured channels, in canonical order (pp, pm, mp, mm).

        Read-only view: use :meth:`set_channel` / :meth:`remove_channel` to
        modify the channel set, so validation and model propagation apply.
        """
        return MappingProxyType(self._channels)

    @property
    def available_channels(self) -> list[PolarizationChannel]:
        """The measured channels, in canonical order (pp, pm, mp, mm)."""
        return list(self._channels.keys())

    def set_channel(self, channel: Union[PolarizationChannel, str], dataset: DataSet1D) -> None:
        """Add or replace one channel dataset.

        The dataset is validated, adopts the shared model, and the canonical
        channel order is preserved.

        Parameters
        ----------
        channel : Union[PolarizationChannel, str]
            One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).
        dataset : DataSet1D
            The channel data.
        """
        channel = PolarizationChannel(channel)
        dataset = self._validated_dataset(channel, dataset)
        dataset.model = self._model
        merged = dict(self._channels)
        merged[channel] = dataset
        self._channels = self._in_canonical_order(merged)

    def remove_channel(self, channel: Union[PolarizationChannel, str]) -> None:
        """Remove one channel dataset; the last remaining channel cannot be removed.

        Parameters
        ----------
        channel : Union[PolarizationChannel, str]
            One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).
        """
        channel = PolarizationChannel(channel)
        if channel not in self._channels:
            raise ValueError(f"No '{channel.value}' channel in this dataset.")
        if len(self._channels) == 1:
            raise ValueError('A PolarizedDataSet requires at least one channel dataset.')
        del self._channels[channel]

    def __getitem__(self, channel: Union[PolarizationChannel, str]) -> DataSet1D:
        return self._channels[PolarizationChannel(channel)]

    def __contains__(self, channel: Union[PolarizationChannel, str]) -> bool:
        try:
            return PolarizationChannel(channel) in self._channels
        except ValueError:
            return False

    def __len__(self) -> int:
        return len(self._channels)

    @property
    def is_experiment(self) -> bool:
        """Is experiment."""
        return self._model is not None

    @property
    def is_simulation(self) -> bool:
        """Is simulation."""
        return self._model is None

    def __repr__(self) -> str:
        channel_names = ', '.join(channel.value for channel in self._channels)
        return f"Polarized dataset '{self.name}' with channels: {channel_names}"

model property writable

The model shared by all channels.

channels property

The measured channels, in canonical order (pp, pm, mp, mm).

Read-only view: use :meth:set_channel / :meth:remove_channel to modify the channel set, so validation and model propagation apply.

available_channels property

The measured channels, in canonical order (pp, pm, mp, mm).

is_experiment property

Is experiment.

is_simulation property

Is simulation.

set_channel(channel, dataset)

Add or replace one channel dataset.

The dataset is validated, adopts the shared model, and the canonical channel order is preserved.

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

required
dataset DataSet1D

The channel data.

required
Source code in src/easyreflectometry/data/polarized.py
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
def set_channel(self, channel: Union[PolarizationChannel, str], dataset: DataSet1D) -> None:
    """Add or replace one channel dataset.

    The dataset is validated, adopts the shared model, and the canonical
    channel order is preserved.

    Parameters
    ----------
    channel : Union[PolarizationChannel, str]
        One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).
    dataset : DataSet1D
        The channel data.
    """
    channel = PolarizationChannel(channel)
    dataset = self._validated_dataset(channel, dataset)
    dataset.model = self._model
    merged = dict(self._channels)
    merged[channel] = dataset
    self._channels = self._in_canonical_order(merged)

remove_channel(channel)

Remove one channel dataset; the last remaining channel cannot be removed.

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).

required
Source code in src/easyreflectometry/data/polarized.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
def remove_channel(self, channel: Union[PolarizationChannel, str]) -> None:
    """Remove one channel dataset; the last remaining channel cannot be removed.

    Parameters
    ----------
    channel : Union[PolarizationChannel, str]
        One of 'pp', 'pm', 'mp', 'mm' (or the corresponding enum member).
    """
    channel = PolarizationChannel(channel)
    if channel not in self._channels:
        raise ValueError(f"No '{channel.value}' channel in this dataset.")
    if len(self._channels) == 1:
        raise ValueError('A PolarizedDataSet requires at least one channel dataset.')
    del self._channels[channel]

MultiFitter

Source code in src/easyreflectometry/fitting.py
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
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
374
375
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
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
class MultiFitter:
    def __init__(self, *args: Model, objective: str = 'hybrid'):
        r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting`
        which will populate the :py:class:`sc.DataGroup` appropriately
        after the fitting is performed.

        Parameters
        ----------
        *args : Model
            Reflectometry model(s).
        objective : str, optional
            Zero-variance handling strategy. One of
            ``'hybrid'`` (default, Mighell for zero-variance, WLS otherwise),
            ``'mighell'`` (Mighell transform for all points),
            ``'legacy_mask'`` (drop zero-variance points),
            ``'auto'`` (alias for ``'hybrid'``). By default, 'hybrid'.
        """

        self._fit_func = [_bind_fit_func(m.interface.fit_func, m.unique_name) for m in args]
        self._models = args
        self.easy_science_multi_fitter = self._build_easy_science_fitter(args, self._fit_func)
        self._fit_results: list[FitResults] | None = None
        self._classical_fit_metrics: list[dict] | None = None
        self._objective = _validate_objective(objective)
        self._sampler: Sampler | None = None
        # Set by `for_experiments`: the datasets the fit functions correspond
        # to, and the spin channel each one is evaluated on (None = unpolarized).
        self.fit_datasets: list[DataSet1D] = []
        self.fit_channels: list[Any] = []
        # Optional zero-argument callable returning the ``constraints_factory``
        # for the next fit (or ``None``). ``Project.fitter`` binds it to
        # ``Project.build_constraints_factory`` so inequality constraints
        # registered on the project are applied without passing them
        # explicitly; an explicit ``constraints_factory=`` argument wins.
        self.constraints_factory_provider: Callable[[], Callable | None] | None = None

    def _build_easy_science_fitter(self, models, fit_funcs) -> EasyScienceMultiFitter:
        """Build the EasyScience fitter, with its raw ``fit`` honouring constraints.

        :meth:`for_experiments` documents that the caller drives
        ``easy_science_multi_fitter.fit(...)`` directly (e.g. a GUI worker
        thread), which would bypass :meth:`_constraints` and silently fit an
        unconstrained problem. The returned fitter routes that path through
        the constraints machinery: :attr:`constraints_factory_provider` is
        resolved at call time (and in the calling thread — the shim's context
        variable is thread-local), and non-BUMPS engines are rejected rather
        than silently dropping the constraints.
        """
        fitter = _ConstrainedEasyScienceMultiFitter(models, fit_funcs)
        fitter._constraints_owner = weakref.ref(self)
        return fitter

    def _resolve_constraints_factory(self, explicit: Callable | None) -> Callable | None:
        if explicit is not None:
            return explicit
        if self.constraints_factory_provider is not None:
            return self.constraints_factory_provider()
        return None

    @contextlib.contextmanager
    def _constraints(self, explicit: Callable | None, fitter: EasyScienceMultiFitter | None = None):
        """Make any inequality constraints active for the duration of the block.

        The penalties are attached to the BUMPS problem as it is built (see
        :mod:`easyreflectometry._bumps_constraints`); nothing is handed to the
        core as a keyword. Engines that cannot enforce the constraints are
        rejected rather than left to fit an unconstrained problem.

        Parameters
        ----------
        explicit : Callable | None
            Factory passed to the fit call, or None to use
            :attr:`constraints_factory_provider`.
        fitter : EasyScienceMultiFitter | None, optional
            The fitter whose minimizer is about to run, when it is not this
            one's — ``fit_polarized`` builds its own. By default, None.
        """
        factory = self._resolve_constraints_factory(explicit)
        if factory is not None:
            minimizer = (fitter or self.easy_science_multi_fitter).minimizer
            package = getattr(minimizer, 'package', None)
            if package != 'bumps':
                raise ValueError(NON_BUMPS_ERROR.format(package=package))
        with _constraints_applied(factory):
            yield

    @staticmethod
    def _keep_constraints_on_extend(sampler: Sampler, factory: Callable | None) -> None:
        """Re-enter the constraints context around ``sampler.extend()``.

        The factory is current only for the duration of the sampling call that
        built the problem; without this a continued chain would silently sample
        an unpenalised posterior.
        """
        if factory is None:
            return
        original = sampler.extend

        @functools.wraps(original)
        def extend(*args, **kwargs):
            with _constraints_applied(factory):
                return original(*args, **kwargs)

        sampler.extend = extend

    @classmethod
    def for_experiments(
        cls,
        experiments: list[DataSet1D | PolarizedDataSet],
        objective: str = 'hybrid',
        constraints_factory_provider: Callable[[], Callable | None] | None = None,
    ) -> 'MultiFitter':
        """Build a fitter for a mixed list of unpolarized and polarized experiments.

        Every experiment contributes one fit function per dataset: an ordinary
        experiment one, a polarized experiment one per measured spin channel,
        each evaluating that channel's spin cross-section against the single
        model the channels share. Structural parameters are therefore common to
        all channels and the magnetic ones are constrained by all of them at
        once, exactly as in :meth:`fit_polarized` — but here several experiments
        (and several models) can be fitted together, which is what an
        application's "fit everything that is loaded" action needs.

        The resulting fitter is *not* run: the caller supplies the data arrays
        to ``easy_science_multi_fitter.fit(...)`` in the order given by
        :attr:`fit_datasets`, which lets a GUI drive it from a worker thread.
        That call resolves :attr:`constraints_factory_provider` at call time,
        so inequality constraints are applied on this path too — pass
        ``constraints_factory_provider`` (e.g.
        ``project.build_constraints_factory``) or set the attribute before
        fitting; with none set, no inequality constraints are enforced.

        Note
        ----
        Built via ``cls(*models, objective=objective)`` and then overwrites
        ``_fit_func`` / ``easy_science_multi_fitter`` with the per-channel
        versions — the ``__init__``-built pair is briefly constructed and
        discarded. Unlike :meth:`fit_polarized`, the minimizer selection and
        its ``tolerance`` / ``max_evaluations`` are *not* carried over: the
        returned fitter starts from ``easy_science_multi_fitter``'s defaults,
        so a caller that needs a specific minimizer must set it explicitly
        before calling ``.fit(...)``.

        Parameters
        ----------
        experiments : list[DataSet1D | PolarizedDataSet]
            The loaded experiments, in the order they should be fitted.
        objective : str, optional
            Zero-variance handling strategy, see :meth:`__init__`. By default, 'hybrid'.
        constraints_factory_provider : Callable[[], Callable | None] | None, optional
            Zero-argument callable returning the ``constraints_factory`` for
            the next fit, typically ``project.build_constraints_factory``;
            stored as :attr:`constraints_factory_provider`. By default, None
            (no inequality constraints are applied).

        Returns
        -------
        MultiFitter
            Fitter whose ``easy_science_multi_fitter`` has one fit function per
            entry of ``fit_datasets``, with ``fit_channels`` holding the
            matching :class:`PolarizationChannel` (``None`` when unpolarized).
        """
        if not experiments:
            raise ValueError('At least one experiment is required to build a fitter.')

        models: list[Model] = []
        datasets: list[DataSet1D] = []
        channels: list[Any] = []
        for experiment in experiments:
            model = experiment.model
            if model is None:
                raise ValueError(f"Experiment '{getattr(experiment, 'name', experiment)}' has no model to fit.")
            # `in` would compare models by value; identity is what matters here.
            if not any(model is known for known in models):
                models.append(model)
            experiment_channels = getattr(experiment, 'available_channels', None)
            if experiment_channels is None:
                datasets.append(experiment)
                channels.append(None)
                continue
            for channel in experiment_channels:
                datasets.append(experiment[channel])
                channels.append(channel)

        fitter = cls(*models, objective=objective)

        fit_funcs = []
        for dataset, channel in zip(datasets, channels):
            model = dataset.model
            interface = model.interface
            func = interface.fit_func if channel is None else interface.fit_func_for_channel(channel)
            fit_funcs.append(_bind_fit_func(func, model.unique_name))

        fitter._fit_func = fit_funcs
        fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fit_funcs)
        fitter.fit_datasets = datasets
        fitter.fit_channels = channels
        fitter.constraints_factory_provider = constraints_factory_provider
        return fitter

    def fit(
        self,
        data: sc.DataGroup,
        id: int = 0,
        objective: str | None = None,
        constraints_factory: Callable | None = None,
    ) -> sc.DataGroup:
        """Perform the fitting and populate the DataGroups with the result.

        Parameters
        ----------
        data : sc.DataGroup
            DataGroup to be fitted to and populated.
        id : int, optional
            Unused parameter kept for backward compatibility. By default, 0.
        objective : str | None, optional
            Per-call override for the zero-variance objective.
            If ``None``, uses the instance default set at construction. By default, None.
        constraints_factory : Callable | None, optional
            Inequality constraints to enforce (BUMPS engines only); see
            :mod:`easyreflectometry.inequality_constraints`. Defaults to what
            :attr:`constraints_factory_provider` returns. By default, None.

        Returns
        -------
        sc.DataGroup
            A new DataGroup with fitted model curves, SLD profiles, and fit statistics.
        """
        obj = _validate_objective(objective) if objective is not None else self._objective

        refl_nums = [k[3:] for k in data['coords'].keys() if 'Qz' == k[:2]]
        x = []
        y = []
        dy = []
        original_arrays = []

        # Process each reflectivity dataset
        for i in refl_nums:
            x_vals = data['coords'][f'Qz_{i}'].values
            y_vals = data['data'][f'R_{i}'].values
            variances = data['data'][f'R_{i}'].variances

            x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
            _emit_array_prep_warnings(stats, y_vals, f'reflectivity {i}')

            x.append(x_out)
            y.append(y_eff)
            dy.append(weights)
            original_arrays.append({'x': x_vals, 'y': y_vals, 'variances': variances})

        with self._constraints(constraints_factory):
            result = self.easy_science_multi_fitter.fit(x, y, weights=dy)
        self._fit_results = result
        self._classical_fit_metrics = []
        new_data = data.copy()
        for i, _ in enumerate(result):
            id = refl_nums[i]
            model_curve = self._fit_func[i](data['coords'][f'Qz_{id}'].values)
            new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
            sld_profile = self.easy_science_multi_fitter._fit_objects[i].interface.sld_profile(self._models[i].unique_name)
            new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
            if 'attrs' in new_data:
                new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(self._models[i].as_dict())}
            new_data['coords'][f'z_{id}'] = sc.array(
                dims=[f'z_{id}'],
                values=sld_profile[0],
                unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
            )
            metrics = _classical_metrics_for(original_arrays[i], model_curve, result[i])
            self._classical_fit_metrics.append(metrics)

            new_data['objective_chi2'] = metrics['objective_chi2']
            new_data['objective_reduced_chi'] = metrics['objective_reduced_chi']
            new_data['classical_chi2'] = metrics['classical_chi2']
            new_data['classical_reduced_chi'] = metrics['classical_reduced_chi']
            new_data['reduced_chi'] = metrics['objective_reduced_chi']
            new_data['success'] = result[i].success
        return new_data

    def fit_single_data_set_1d(
        self,
        data: DataSet1D,
        objective: str | None = None,
        constraints_factory: Callable | None = None,
    ) -> FitResults:
        """Perform fitting on a single 1D dataset.

        Parameters
        ----------
        data : DataSet1D
            The 1D dataset to fit. Note that ``data.ye`` stores
            variances (σ²), not standard deviations.
        objective : str | None, optional
            Per-call override for the zero-variance objective.
            If ``None``, uses the instance default set at construction. By default, None.
        constraints_factory : Callable | None, optional
            Inequality constraints to enforce (BUMPS engines only). Defaults
            to what :attr:`constraints_factory_provider` returns. By default, None.

        Returns
        -------
        FitResults
            Fit results from the minimizer.
        """
        obj = _validate_objective(objective) if objective is not None else self._objective

        x_vals = np.asarray(data.x)
        y_vals = np.asarray(data.y)
        variances = np.asarray(data.ye)

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
        _emit_array_prep_warnings(stats, y_vals, 'single-dataset fit')

        if obj == 'legacy_mask' and len(x_out) == 0:
            raise ValueError('Cannot fit single dataset: all points have zero variance.')

        with self._constraints(constraints_factory):
            result = self.easy_science_multi_fitter.fit(x=[x_out], y=[y_eff], weights=[weights])[0]
        self._fit_results = [result]
        model_curve = self._fit_func[0](x_vals)
        self._classical_fit_metrics = [
            _classical_metrics_for({'y': y_vals, 'variances': variances}, model_curve, result, n_points=len(x_out))
        ]
        return result

    def fit_polarized(
        self,
        data: PolarizedDataSet,
        objective: str | None = None,
        constraints_factory: Callable | None = None,
    ) -> dict[str, FitResults]:
        """Fit all measured spin channels of a polarized experiment simultaneously.

        Each channel dataset gets its own fit function evaluating the
        corresponding spin cross-section, while every channel shares the single
        model — so structural parameters (thickness, roughness, nuclear SLD,
        scale, background) are common, and the magnetic parameters
        (`rho_m`/`theta_m`) are constrained by all channels at once. The refl1d
        backend computes all four cross-sections in one kernel evaluation and
        caches them per iteration, so fitting N channels costs about as much as
        fitting one.

        Parameters
        ----------
        data : PolarizedDataSet
            The polarized experiment (its model must be the model this fitter
            was constructed with). Note that per-channel ``ye`` stores
            variances (σ²), not standard deviations.
        objective : str | None, optional
            Per-call override for the zero-variance objective.
            If ``None``, uses the instance default set at construction. By default, None.
        constraints_factory : Callable | None, optional
            Inequality constraints to enforce (BUMPS engines only); see
            :mod:`easyreflectometry.inequality_constraints`. Defaults to what
            :attr:`constraints_factory_provider` returns. By default, None.

        Returns
        -------
        dict[str, FitResults]
            Fit results per channel, keyed 'pp', 'pm', 'mp', 'mm' (measured
            channels only, in that order).

        Note
        ----
        Unlike :meth:`for_experiments`, this method does not populate
        :attr:`fit_datasets` / :attr:`fit_channels` — those are set only by the
        caller-driven, `for_experiments`-built flow.
        """
        obj = _validate_objective(objective) if objective is not None else self._objective
        if len(self._models) != 1:
            raise ValueError('Polarized fitting requires a MultiFitter constructed with exactly one model.')
        model = self._models[0]
        if data.model is not model:
            raise ValueError('PolarizedDataSet.model must be the model this fitter was constructed with.')
        channels = data.available_channels
        for channel in channels:
            if data[channel].model is not model:
                raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")

        channel_fit_funcs = [
            _bind_fit_func(model.interface.fit_func_for_channel(channel), model.unique_name) for channel in channels
        ]
        # One fit function per channel, all bound to the single model. Constructed
        # per call because the channel set comes from the data; the minimizer
        # selection and its generic settings (tolerance, max_evaluations) are
        # carried over. Engine-specific settings applied directly to the minimizer
        # instance of this fitter would not be.
        polarized_fitter = EasyScienceMultiFitter([model], channel_fit_funcs)
        polarized_fitter.switch_minimizer(self.easy_science_multi_fitter.minimizer.enum)
        polarized_fitter.tolerance = self.easy_science_multi_fitter.tolerance
        polarized_fitter.max_evaluations = self.easy_science_multi_fitter.max_evaluations

        x = []
        y = []
        dy = []
        original_arrays = []
        for channel in channels:
            dataset = data[channel]
            x_vals = np.asarray(dataset.x)
            y_vals = np.asarray(dataset.y)
            variances = np.asarray(dataset.ye)

            x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
            _emit_array_prep_warnings(stats, y_vals, f'channel {channel.value}')
            if obj == 'legacy_mask' and len(x_out) == 0:
                raise ValueError(f'Cannot fit channel {channel.value}: all points have zero variance.')

            x.append(x_out)
            y.append(y_eff)
            dy.append(weights)
            original_arrays.append({'x': x_vals, 'y': y_vals, 'variances': variances})

        with self._constraints(constraints_factory, fitter=polarized_fitter):
            results = polarized_fitter.fit(x, y, weights=dy)
        # All channels are fitted against one parameter vector (the shared model),
        # so `result.n_pars` is identical across `results`; `reduced_chi` and
        # `classical_reduced_chi` below rely on that invariant.
        self._fit_results = list(results)

        self._classical_fit_metrics = []
        for index, (channel, result) in enumerate(zip(channels, results)):
            original = original_arrays[index]
            model_curve = channel_fit_funcs[index](original['x'])
            self._classical_fit_metrics.append(_classical_metrics_for(original, model_curve, result))

        return {channel.value: result for channel, result in zip(channels, results)}

    def mcmc_sample(
        self,
        data: sc.DataGroup,
        samples: int = 10000,
        burn: int = 2000,
        thin: int = 10,
        population: int | None = None,
        objective: str | None = None,
        initializer: str | None = None,
        progress_callback: Callable[..., Any] | None = None,
        abort_test: Callable[[], bool] | None = None,
        constraints_factory: Callable | None = None,
    ) -> dict:
        """Run Bayesian MCMC sampling on reflectometry data using the DREAM sampler.

        Requires that the minimizer is a BUMPS instance (i.e. the minimizer was
        switched to ``AvailableMinimizers.Bumps``).

        :param data: DataGroup with reflectivity data.
        :param samples: Number of retained DREAM samples requested from BUMPS.
        :param burn: Burn-in steps.
        :param thin: Thinning interval.
        :param population: BUMPS DREAM population count for advanced users.
        :param objective: Zero-variance handling strategy.
        :param initializer: DREAM population initializer. One of ``'eps'``,
            ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
            uses ``'eps'``).
        :param progress_callback: Optional callback for progress updates during
            sampling.  Forwarded to the core MultiFitter.
        :param abort_test: Optional callable returning ``True`` to abort sampling.
        :param constraints_factory: Inequality constraints to enforce (see
            :mod:`easyreflectometry.inequality_constraints`); the posterior is
            penalised in the infeasible region. Defaults to what
            :attr:`constraints_factory_provider` returns.
        :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
            and ``'logp'``.
        :raises RuntimeError: If the current minimizer is not a BUMPS instance.

        The underlying :class:`~easyscience.fitting.Sampler` is retained on
        :attr:`sampler`, so the chain can be continued without re-running the
        burn-in::

            fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
            extended = fitter.sampler.extend(additional_samples=8000, thin=10)
        """
        minimizer = self.easy_science_multi_fitter.minimizer
        if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'):
            raise RuntimeError(
                'Bayesian sampling requires a BUMPS minimizer. '
                'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.'
            )

        obj = _validate_objective(objective) if objective is not None else self._objective

        refl_nums = [k[3:] for k in data['coords'].keys() if k.startswith('Qz_')]
        x = []
        y = []
        dy = []

        # Process each reflectivity dataset
        for i in refl_nums:
            x_vals = data['coords'][f'Qz_{i}'].values
            y_vals = data['data'][f'R_{i}'].values
            variances = data['data'][f'R_{i}'].variances

            if obj != 'mighell' and np.all(np.asarray(variances) <= 0.0):
                raise ValueError(
                    f'Cannot run Bayesian sampling on reflectivity {i}: all points have zero variance. '
                    'The likelihood is undefined without measurement uncertainties. Supply uncertainties, '
                    "or explicitly opt in to the Mighell transform with objective='mighell' "
                    '(a chi-square bias correction, not a true likelihood).'
                )

            x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
            _emit_array_prep_warnings(
                stats,
                y_vals,
                f'reflectivity {i}',
                action='sampling',
                extra=(
                    ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
                    'posterior widths may be unreliable.'
                ),
            )
            x.append(x_out)
            y.append(y_eff)
            dy.append(weights)

        # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``.
        # The core API moved from ``MultiFitter.mcmc_sample()`` to a dedicated
        # ``Sampler`` class: construct it with the configured fitter and the
        # bound data, then call ``sample()``. ``Sampler`` handles the
        # multi-dataset reshaping internally.
        sampler_kwargs = {}
        if initializer is not None:
            sampler_kwargs['init'] = initializer

        # Resolved once and passed on as the explicit factory, so building it
        # (which resolves every constraint's parameter paths) happens once.
        factory = self._resolve_constraints_factory(constraints_factory)
        with self._constraints(factory):
            # The factory is current only while the problem is being built, so
            # `_keep_constraints_on_extend` covers a later continuation.
            sampler = Sampler(self.easy_science_multi_fitter, x=x, y=y, weights=dy)
            self._keep_constraints_on_extend(sampler, factory)
            # Retained so the chain can be continued afterwards via ``self.sampler.extend()``.
            self._sampler = sampler
            results = sampler.sample(
                samples=samples,
                burn=burn,
                thin=thin,
                population=population,
                sampler_kwargs=sampler_kwargs or None,
                progress_callback=progress_callback,
                abort_test=abort_test,
            )
        return {
            'draws': results.draws,
            'param_names': results.param_names,
            'state': results.state,
            'logp': results.logp,
        }

    @property
    def sampler(self) -> Sampler | None:
        """The ``Sampler`` behind the most recent :meth:`mcmc_sample` call, or None.

        Holds the live BUMPS chain state, so the sampling run can be continued
        with ``fitter.sampler.extend(additional_samples=...)`` instead of
        starting a fresh chain.
        """
        return self._sampler

    @property
    def chi2(self) -> float | None:
        """Total chi-squared across all fitted datasets, or None if no fit has been performed."""
        if self._fit_results is None:
            return None
        return sum(r.chi2 for r in self._fit_results)

    @property
    def reduced_chi(self) -> float | None:
        """Reduced chi-squared from the most recent fit, or None if no fit has been performed."""
        if self._fit_results is None:
            return None
        total_chi2 = sum(r.chi2 for r in self._fit_results)
        total_points = sum(np.size(r.x) for r in self._fit_results)
        n_params = self._fit_results[0].n_pars
        total_dof = total_points - n_params

        if total_dof <= 0:
            return None

        return total_chi2 / total_dof

    @property
    def classical_chi2(self) -> float | None:
        """Classical chi-squared using only points with positive variances."""
        if self._classical_fit_metrics is None:
            return None
        return float(sum(metric['classical_chi2'] for metric in self._classical_fit_metrics))

    @property
    def classical_reduced_chi(self) -> float | None:
        """Reduced classical chi-squared using only points with positive variances."""
        if self._classical_fit_metrics is None or self._fit_results is None:
            return None
        total_chi2 = self.classical_chi2
        total_points = sum(metric['n_classical_points'] for metric in self._classical_fit_metrics)
        n_params = self._fit_results[0].n_pars
        return _compute_reduced_chi2(total_chi2, total_points, n_params)

    @property
    def objective_chi2(self) -> float | None:
        """Objective-space chi-squared returned by the minimizer."""
        return self.chi2

    @property
    def objective_reduced_chi(self) -> float | None:
        """Objective-space reduced chi-squared returned by the minimizer."""
        return self.reduced_chi

    def record_fit_results(self, results: list[FitResults] | None) -> None:
        """Adopt fit results produced elsewhere, so this fitter reports on them.

        An application that drives ``easy_science_multi_fitter.fit(...)`` itself
        — to run it in a worker thread, for instance — leaves the ``MultiFitter``
        that owns the goodness-of-fit properties none the wiser. Handing the
        results back here makes :attr:`chi2` / :attr:`reduced_chi` describe that
        fit instead of reporting "no fit performed".

        Only the minimizer-reported metrics are restored: the classical ones
        need the original data arrays, which are not part of ``FitResults``, so
        :attr:`classical_chi2` and :attr:`classical_reduced_chi` stay None.

        Parameters
        ----------
        results : list[FitResults] | None
            Results of the fit, one per fitted dataset. None clears them.
        """
        self._fit_results = list(results) if results else None
        self._classical_fit_metrics = None

    def switch_minimizer(self, minimizer: AvailableMinimizers) -> None:
        """Switch the minimizer for the fitting.

        Parameters
        ----------
        minimizer : AvailableMinimizers
            Minimizer to be switched to.
        """
        self.easy_science_multi_fitter.switch_minimizer(minimizer)

sampler property

The Sampler behind the most recent :meth:mcmc_sample call, or None.

Holds the live BUMPS chain state, so the sampling run can be continued with fitter.sampler.extend(additional_samples=...) instead of starting a fresh chain.

chi2 property

Total chi-squared across all fitted datasets, or None if no fit has been performed.

reduced_chi property

Reduced chi-squared from the most recent fit, or None if no fit has been performed.

classical_chi2 property

Classical chi-squared using only points with positive variances.

classical_reduced_chi property

Reduced classical chi-squared using only points with positive variances.

objective_chi2 property

Objective-space chi-squared returned by the minimizer.

objective_reduced_chi property

Objective-space reduced chi-squared returned by the minimizer.

__init__(*args, objective='hybrid')

A convenience class for the 🇵🇾class:easyscience.Fitting.Fitting which will populate the 🇵🇾class:sc.DataGroup appropriately after the fitting is performed.

Parameters:

Name Type Description Default
*args Model

Reflectometry model(s).

()
objective str

Zero-variance handling strategy. One of 'hybrid' (default, Mighell for zero-variance, WLS otherwise), 'mighell' (Mighell transform for all points), 'legacy_mask' (drop zero-variance points), 'auto' (alias for 'hybrid'). By default, 'hybrid'.

'hybrid'
Source code in src/easyreflectometry/fitting.py
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
323
324
325
326
327
328
def __init__(self, *args: Model, objective: str = 'hybrid'):
    r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting`
    which will populate the :py:class:`sc.DataGroup` appropriately
    after the fitting is performed.

    Parameters
    ----------
    *args : Model
        Reflectometry model(s).
    objective : str, optional
        Zero-variance handling strategy. One of
        ``'hybrid'`` (default, Mighell for zero-variance, WLS otherwise),
        ``'mighell'`` (Mighell transform for all points),
        ``'legacy_mask'`` (drop zero-variance points),
        ``'auto'`` (alias for ``'hybrid'``). By default, 'hybrid'.
    """

    self._fit_func = [_bind_fit_func(m.interface.fit_func, m.unique_name) for m in args]
    self._models = args
    self.easy_science_multi_fitter = self._build_easy_science_fitter(args, self._fit_func)
    self._fit_results: list[FitResults] | None = None
    self._classical_fit_metrics: list[dict] | None = None
    self._objective = _validate_objective(objective)
    self._sampler: Sampler | None = None
    # Set by `for_experiments`: the datasets the fit functions correspond
    # to, and the spin channel each one is evaluated on (None = unpolarized).
    self.fit_datasets: list[DataSet1D] = []
    self.fit_channels: list[Any] = []
    # Optional zero-argument callable returning the ``constraints_factory``
    # for the next fit (or ``None``). ``Project.fitter`` binds it to
    # ``Project.build_constraints_factory`` so inequality constraints
    # registered on the project are applied without passing them
    # explicitly; an explicit ``constraints_factory=`` argument wins.
    self.constraints_factory_provider: Callable[[], Callable | None] | None = None

for_experiments(experiments, objective='hybrid', constraints_factory_provider=None) classmethod

Build a fitter for a mixed list of unpolarized and polarized experiments.

Every experiment contributes one fit function per dataset: an ordinary experiment one, a polarized experiment one per measured spin channel, each evaluating that channel's spin cross-section against the single model the channels share. Structural parameters are therefore common to all channels and the magnetic ones are constrained by all of them at once, exactly as in :meth:fit_polarized — but here several experiments (and several models) can be fitted together, which is what an application's "fit everything that is loaded" action needs.

The resulting fitter is not run: the caller supplies the data arrays to easy_science_multi_fitter.fit(...) in the order given by :attr:fit_datasets, which lets a GUI drive it from a worker thread. That call resolves :attr:constraints_factory_provider at call time, so inequality constraints are applied on this path too — pass constraints_factory_provider (e.g. project.build_constraints_factory) or set the attribute before fitting; with none set, no inequality constraints are enforced.

Note

Built via cls(*models, objective=objective) and then overwrites _fit_func / easy_science_multi_fitter with the per-channel versions — the __init__-built pair is briefly constructed and discarded. Unlike :meth:fit_polarized, the minimizer selection and its tolerance / max_evaluations are not carried over: the returned fitter starts from easy_science_multi_fitter's defaults, so a caller that needs a specific minimizer must set it explicitly before calling .fit(...).

Parameters:

Name Type Description Default
experiments list[DataSet1D | PolarizedDataSet]

The loaded experiments, in the order they should be fitted.

required
objective str

Zero-variance handling strategy, see :meth:__init__. By default, 'hybrid'.

'hybrid'
constraints_factory_provider Callable[[], Callable | None] | None

Zero-argument callable returning the constraints_factory for the next fit, typically project.build_constraints_factory; stored as :attr:constraints_factory_provider. By default, None (no inequality constraints are applied).

None

Returns:

Type Description
MultiFitter

Fitter whose easy_science_multi_fitter has one fit function per entry of fit_datasets, with fit_channels holding the matching :class:PolarizationChannel (None when unpolarized).

Source code in src/easyreflectometry/fitting.py
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
@classmethod
def for_experiments(
    cls,
    experiments: list[DataSet1D | PolarizedDataSet],
    objective: str = 'hybrid',
    constraints_factory_provider: Callable[[], Callable | None] | None = None,
) -> 'MultiFitter':
    """Build a fitter for a mixed list of unpolarized and polarized experiments.

    Every experiment contributes one fit function per dataset: an ordinary
    experiment one, a polarized experiment one per measured spin channel,
    each evaluating that channel's spin cross-section against the single
    model the channels share. Structural parameters are therefore common to
    all channels and the magnetic ones are constrained by all of them at
    once, exactly as in :meth:`fit_polarized` — but here several experiments
    (and several models) can be fitted together, which is what an
    application's "fit everything that is loaded" action needs.

    The resulting fitter is *not* run: the caller supplies the data arrays
    to ``easy_science_multi_fitter.fit(...)`` in the order given by
    :attr:`fit_datasets`, which lets a GUI drive it from a worker thread.
    That call resolves :attr:`constraints_factory_provider` at call time,
    so inequality constraints are applied on this path too — pass
    ``constraints_factory_provider`` (e.g.
    ``project.build_constraints_factory``) or set the attribute before
    fitting; with none set, no inequality constraints are enforced.

    Note
    ----
    Built via ``cls(*models, objective=objective)`` and then overwrites
    ``_fit_func`` / ``easy_science_multi_fitter`` with the per-channel
    versions — the ``__init__``-built pair is briefly constructed and
    discarded. Unlike :meth:`fit_polarized`, the minimizer selection and
    its ``tolerance`` / ``max_evaluations`` are *not* carried over: the
    returned fitter starts from ``easy_science_multi_fitter``'s defaults,
    so a caller that needs a specific minimizer must set it explicitly
    before calling ``.fit(...)``.

    Parameters
    ----------
    experiments : list[DataSet1D | PolarizedDataSet]
        The loaded experiments, in the order they should be fitted.
    objective : str, optional
        Zero-variance handling strategy, see :meth:`__init__`. By default, 'hybrid'.
    constraints_factory_provider : Callable[[], Callable | None] | None, optional
        Zero-argument callable returning the ``constraints_factory`` for
        the next fit, typically ``project.build_constraints_factory``;
        stored as :attr:`constraints_factory_provider`. By default, None
        (no inequality constraints are applied).

    Returns
    -------
    MultiFitter
        Fitter whose ``easy_science_multi_fitter`` has one fit function per
        entry of ``fit_datasets``, with ``fit_channels`` holding the
        matching :class:`PolarizationChannel` (``None`` when unpolarized).
    """
    if not experiments:
        raise ValueError('At least one experiment is required to build a fitter.')

    models: list[Model] = []
    datasets: list[DataSet1D] = []
    channels: list[Any] = []
    for experiment in experiments:
        model = experiment.model
        if model is None:
            raise ValueError(f"Experiment '{getattr(experiment, 'name', experiment)}' has no model to fit.")
        # `in` would compare models by value; identity is what matters here.
        if not any(model is known for known in models):
            models.append(model)
        experiment_channels = getattr(experiment, 'available_channels', None)
        if experiment_channels is None:
            datasets.append(experiment)
            channels.append(None)
            continue
        for channel in experiment_channels:
            datasets.append(experiment[channel])
            channels.append(channel)

    fitter = cls(*models, objective=objective)

    fit_funcs = []
    for dataset, channel in zip(datasets, channels):
        model = dataset.model
        interface = model.interface
        func = interface.fit_func if channel is None else interface.fit_func_for_channel(channel)
        fit_funcs.append(_bind_fit_func(func, model.unique_name))

    fitter._fit_func = fit_funcs
    fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fit_funcs)
    fitter.fit_datasets = datasets
    fitter.fit_channels = channels
    fitter.constraints_factory_provider = constraints_factory_provider
    return fitter

fit(data, id=0, objective=None, constraints_factory=None)

Perform the fitting and populate the DataGroups with the result.

Parameters:

Name Type Description Default
data sc.DataGroup

DataGroup to be fitted to and populated.

required
id int

Unused parameter kept for backward compatibility. By default, 0.

0
objective str | None

Per-call override for the zero-variance objective. If None, uses the instance default set at construction. By default, None.

None
constraints_factory Callable | None

Inequality constraints to enforce (BUMPS engines only); see :mod:easyreflectometry.inequality_constraints. Defaults to what :attr:constraints_factory_provider returns. By default, None.

None

Returns:

Type Description
sc.DataGroup

A new DataGroup with fitted model curves, SLD profiles, and fit statistics.

Source code in src/easyreflectometry/fitting.py
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
def fit(
    self,
    data: sc.DataGroup,
    id: int = 0,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> sc.DataGroup:
    """Perform the fitting and populate the DataGroups with the result.

    Parameters
    ----------
    data : sc.DataGroup
        DataGroup to be fitted to and populated.
    id : int, optional
        Unused parameter kept for backward compatibility. By default, 0.
    objective : str | None, optional
        Per-call override for the zero-variance objective.
        If ``None``, uses the instance default set at construction. By default, None.
    constraints_factory : Callable | None, optional
        Inequality constraints to enforce (BUMPS engines only); see
        :mod:`easyreflectometry.inequality_constraints`. Defaults to what
        :attr:`constraints_factory_provider` returns. By default, None.

    Returns
    -------
    sc.DataGroup
        A new DataGroup with fitted model curves, SLD profiles, and fit statistics.
    """
    obj = _validate_objective(objective) if objective is not None else self._objective

    refl_nums = [k[3:] for k in data['coords'].keys() if 'Qz' == k[:2]]
    x = []
    y = []
    dy = []
    original_arrays = []

    # Process each reflectivity dataset
    for i in refl_nums:
        x_vals = data['coords'][f'Qz_{i}'].values
        y_vals = data['data'][f'R_{i}'].values
        variances = data['data'][f'R_{i}'].variances

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
        _emit_array_prep_warnings(stats, y_vals, f'reflectivity {i}')

        x.append(x_out)
        y.append(y_eff)
        dy.append(weights)
        original_arrays.append({'x': x_vals, 'y': y_vals, 'variances': variances})

    with self._constraints(constraints_factory):
        result = self.easy_science_multi_fitter.fit(x, y, weights=dy)
    self._fit_results = result
    self._classical_fit_metrics = []
    new_data = data.copy()
    for i, _ in enumerate(result):
        id = refl_nums[i]
        model_curve = self._fit_func[i](data['coords'][f'Qz_{id}'].values)
        new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
        sld_profile = self.easy_science_multi_fitter._fit_objects[i].interface.sld_profile(self._models[i].unique_name)
        new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
        if 'attrs' in new_data:
            new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(self._models[i].as_dict())}
        new_data['coords'][f'z_{id}'] = sc.array(
            dims=[f'z_{id}'],
            values=sld_profile[0],
            unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
        )
        metrics = _classical_metrics_for(original_arrays[i], model_curve, result[i])
        self._classical_fit_metrics.append(metrics)

        new_data['objective_chi2'] = metrics['objective_chi2']
        new_data['objective_reduced_chi'] = metrics['objective_reduced_chi']
        new_data['classical_chi2'] = metrics['classical_chi2']
        new_data['classical_reduced_chi'] = metrics['classical_reduced_chi']
        new_data['reduced_chi'] = metrics['objective_reduced_chi']
        new_data['success'] = result[i].success
    return new_data

fit_single_data_set_1d(data, objective=None, constraints_factory=None)

Perform fitting on a single 1D dataset.

Parameters:

Name Type Description Default
data DataSet1D

The 1D dataset to fit. Note that data.ye stores variances (σ²), not standard deviations.

required
objective str | None

Per-call override for the zero-variance objective. If None, uses the instance default set at construction. By default, None.

None
constraints_factory Callable | None

Inequality constraints to enforce (BUMPS engines only). Defaults to what :attr:constraints_factory_provider returns. By default, None.

None

Returns:

Type Description
FitResults

Fit results from the minimizer.

Source code in src/easyreflectometry/fitting.py
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
def fit_single_data_set_1d(
    self,
    data: DataSet1D,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> FitResults:
    """Perform fitting on a single 1D dataset.

    Parameters
    ----------
    data : DataSet1D
        The 1D dataset to fit. Note that ``data.ye`` stores
        variances (σ²), not standard deviations.
    objective : str | None, optional
        Per-call override for the zero-variance objective.
        If ``None``, uses the instance default set at construction. By default, None.
    constraints_factory : Callable | None, optional
        Inequality constraints to enforce (BUMPS engines only). Defaults
        to what :attr:`constraints_factory_provider` returns. By default, None.

    Returns
    -------
    FitResults
        Fit results from the minimizer.
    """
    obj = _validate_objective(objective) if objective is not None else self._objective

    x_vals = np.asarray(data.x)
    y_vals = np.asarray(data.y)
    variances = np.asarray(data.ye)

    x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
    _emit_array_prep_warnings(stats, y_vals, 'single-dataset fit')

    if obj == 'legacy_mask' and len(x_out) == 0:
        raise ValueError('Cannot fit single dataset: all points have zero variance.')

    with self._constraints(constraints_factory):
        result = self.easy_science_multi_fitter.fit(x=[x_out], y=[y_eff], weights=[weights])[0]
    self._fit_results = [result]
    model_curve = self._fit_func[0](x_vals)
    self._classical_fit_metrics = [
        _classical_metrics_for({'y': y_vals, 'variances': variances}, model_curve, result, n_points=len(x_out))
    ]
    return result

fit_polarized(data, objective=None, constraints_factory=None)

Fit all measured spin channels of a polarized experiment simultaneously.

Each channel dataset gets its own fit function evaluating the corresponding spin cross-section, while every channel shares the single model — so structural parameters (thickness, roughness, nuclear SLD, scale, background) are common, and the magnetic parameters (rho_m/theta_m) are constrained by all channels at once. The refl1d backend computes all four cross-sections in one kernel evaluation and caches them per iteration, so fitting N channels costs about as much as fitting one.

Parameters:

Name Type Description Default
data PolarizedDataSet

The polarized experiment (its model must be the model this fitter was constructed with). Note that per-channel ye stores variances (σ²), not standard deviations.

required
objective str | None

Per-call override for the zero-variance objective. If None, uses the instance default set at construction. By default, None.

None
constraints_factory Callable | None

Inequality constraints to enforce (BUMPS engines only); see :mod:easyreflectometry.inequality_constraints. Defaults to what :attr:constraints_factory_provider returns. By default, None.

None

Returns:

Type Description
dict[str, FitResults]

Fit results per channel, keyed 'pp', 'pm', 'mp', 'mm' (measured channels only, in that order).

Note

Unlike :meth:for_experiments, this method does not populate :attr:fit_datasets / :attr:fit_channels — those are set only by the caller-driven, for_experiments-built flow.

Source code in src/easyreflectometry/fitting.py
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
def fit_polarized(
    self,
    data: PolarizedDataSet,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> dict[str, FitResults]:
    """Fit all measured spin channels of a polarized experiment simultaneously.

    Each channel dataset gets its own fit function evaluating the
    corresponding spin cross-section, while every channel shares the single
    model — so structural parameters (thickness, roughness, nuclear SLD,
    scale, background) are common, and the magnetic parameters
    (`rho_m`/`theta_m`) are constrained by all channels at once. The refl1d
    backend computes all four cross-sections in one kernel evaluation and
    caches them per iteration, so fitting N channels costs about as much as
    fitting one.

    Parameters
    ----------
    data : PolarizedDataSet
        The polarized experiment (its model must be the model this fitter
        was constructed with). Note that per-channel ``ye`` stores
        variances (σ²), not standard deviations.
    objective : str | None, optional
        Per-call override for the zero-variance objective.
        If ``None``, uses the instance default set at construction. By default, None.
    constraints_factory : Callable | None, optional
        Inequality constraints to enforce (BUMPS engines only); see
        :mod:`easyreflectometry.inequality_constraints`. Defaults to what
        :attr:`constraints_factory_provider` returns. By default, None.

    Returns
    -------
    dict[str, FitResults]
        Fit results per channel, keyed 'pp', 'pm', 'mp', 'mm' (measured
        channels only, in that order).

    Note
    ----
    Unlike :meth:`for_experiments`, this method does not populate
    :attr:`fit_datasets` / :attr:`fit_channels` — those are set only by the
    caller-driven, `for_experiments`-built flow.
    """
    obj = _validate_objective(objective) if objective is not None else self._objective
    if len(self._models) != 1:
        raise ValueError('Polarized fitting requires a MultiFitter constructed with exactly one model.')
    model = self._models[0]
    if data.model is not model:
        raise ValueError('PolarizedDataSet.model must be the model this fitter was constructed with.')
    channels = data.available_channels
    for channel in channels:
        if data[channel].model is not model:
            raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")

    channel_fit_funcs = [
        _bind_fit_func(model.interface.fit_func_for_channel(channel), model.unique_name) for channel in channels
    ]
    # One fit function per channel, all bound to the single model. Constructed
    # per call because the channel set comes from the data; the minimizer
    # selection and its generic settings (tolerance, max_evaluations) are
    # carried over. Engine-specific settings applied directly to the minimizer
    # instance of this fitter would not be.
    polarized_fitter = EasyScienceMultiFitter([model], channel_fit_funcs)
    polarized_fitter.switch_minimizer(self.easy_science_multi_fitter.minimizer.enum)
    polarized_fitter.tolerance = self.easy_science_multi_fitter.tolerance
    polarized_fitter.max_evaluations = self.easy_science_multi_fitter.max_evaluations

    x = []
    y = []
    dy = []
    original_arrays = []
    for channel in channels:
        dataset = data[channel]
        x_vals = np.asarray(dataset.x)
        y_vals = np.asarray(dataset.y)
        variances = np.asarray(dataset.ye)

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
        _emit_array_prep_warnings(stats, y_vals, f'channel {channel.value}')
        if obj == 'legacy_mask' and len(x_out) == 0:
            raise ValueError(f'Cannot fit channel {channel.value}: all points have zero variance.')

        x.append(x_out)
        y.append(y_eff)
        dy.append(weights)
        original_arrays.append({'x': x_vals, 'y': y_vals, 'variances': variances})

    with self._constraints(constraints_factory, fitter=polarized_fitter):
        results = polarized_fitter.fit(x, y, weights=dy)
    # All channels are fitted against one parameter vector (the shared model),
    # so `result.n_pars` is identical across `results`; `reduced_chi` and
    # `classical_reduced_chi` below rely on that invariant.
    self._fit_results = list(results)

    self._classical_fit_metrics = []
    for index, (channel, result) in enumerate(zip(channels, results)):
        original = original_arrays[index]
        model_curve = channel_fit_funcs[index](original['x'])
        self._classical_fit_metrics.append(_classical_metrics_for(original, model_curve, result))

    return {channel.value: result for channel, result in zip(channels, results)}

mcmc_sample(data, samples=10000, burn=2000, thin=10, population=None, objective=None, initializer=None, progress_callback=None, abort_test=None, constraints_factory=None)

Run Bayesian MCMC sampling on reflectometry data using the DREAM sampler.

Requires that the minimizer is a BUMPS instance (i.e. the minimizer was switched to AvailableMinimizers.Bumps).

:param data: DataGroup with reflectivity data. :param samples: Number of retained DREAM samples requested from BUMPS. :param burn: Burn-in steps. :param thin: Thinning interval. :param population: BUMPS DREAM population count for advanced users. :param objective: Zero-variance handling strategy. :param initializer: DREAM population initializer. One of 'eps', 'cov', 'lhs', or 'random'. By default, None (BUMPS uses 'eps'). :param progress_callback: Optional callback for progress updates during sampling. Forwarded to the core MultiFitter. :param abort_test: Optional callable returning True to abort sampling. :param constraints_factory: Inequality constraints to enforce (see :mod:easyreflectometry.inequality_constraints); the posterior is penalised in the infeasible region. Defaults to what :attr:constraints_factory_provider returns. :return: Dictionary with keys 'draws', 'param_names', 'state', and 'logp'. :raises RuntimeError: If the current minimizer is not a BUMPS instance.

The underlying :class:~easyscience.fitting.Sampler is retained on :attr:sampler, so the chain can be continued without re-running the burn-in::

fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
extended = fitter.sampler.extend(additional_samples=8000, thin=10)
Source code in src/easyreflectometry/fitting.py
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
def mcmc_sample(
    self,
    data: sc.DataGroup,
    samples: int = 10000,
    burn: int = 2000,
    thin: int = 10,
    population: int | None = None,
    objective: str | None = None,
    initializer: str | None = None,
    progress_callback: Callable[..., Any] | None = None,
    abort_test: Callable[[], bool] | None = None,
    constraints_factory: Callable | None = None,
) -> dict:
    """Run Bayesian MCMC sampling on reflectometry data using the DREAM sampler.

    Requires that the minimizer is a BUMPS instance (i.e. the minimizer was
    switched to ``AvailableMinimizers.Bumps``).

    :param data: DataGroup with reflectivity data.
    :param samples: Number of retained DREAM samples requested from BUMPS.
    :param burn: Burn-in steps.
    :param thin: Thinning interval.
    :param population: BUMPS DREAM population count for advanced users.
    :param objective: Zero-variance handling strategy.
    :param initializer: DREAM population initializer. One of ``'eps'``,
        ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
        uses ``'eps'``).
    :param progress_callback: Optional callback for progress updates during
        sampling.  Forwarded to the core MultiFitter.
    :param abort_test: Optional callable returning ``True`` to abort sampling.
    :param constraints_factory: Inequality constraints to enforce (see
        :mod:`easyreflectometry.inequality_constraints`); the posterior is
        penalised in the infeasible region. Defaults to what
        :attr:`constraints_factory_provider` returns.
    :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
        and ``'logp'``.
    :raises RuntimeError: If the current minimizer is not a BUMPS instance.

    The underlying :class:`~easyscience.fitting.Sampler` is retained on
    :attr:`sampler`, so the chain can be continued without re-running the
    burn-in::

        fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
        extended = fitter.sampler.extend(additional_samples=8000, thin=10)
    """
    minimizer = self.easy_science_multi_fitter.minimizer
    if not (hasattr(minimizer, 'package') and minimizer.package == 'bumps'):
        raise RuntimeError(
            'Bayesian sampling requires a BUMPS minimizer. '
            'Use ``fitter.switch_minimizer(AvailableMinimizers.Bumps)`` first.'
        )

    obj = _validate_objective(objective) if objective is not None else self._objective

    refl_nums = [k[3:] for k in data['coords'].keys() if k.startswith('Qz_')]
    x = []
    y = []
    dy = []

    # Process each reflectivity dataset
    for i in refl_nums:
        x_vals = data['coords'][f'Qz_{i}'].values
        y_vals = data['data'][f'R_{i}'].values
        variances = data['data'][f'R_{i}'].variances

        if obj != 'mighell' and np.all(np.asarray(variances) <= 0.0):
            raise ValueError(
                f'Cannot run Bayesian sampling on reflectivity {i}: all points have zero variance. '
                'The likelihood is undefined without measurement uncertainties. Supply uncertainties, '
                "or explicitly opt in to the Mighell transform with objective='mighell' "
                '(a chi-square bias correction, not a true likelihood).'
            )

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
        _emit_array_prep_warnings(
            stats,
            y_vals,
            f'reflectivity {i}',
            action='sampling',
            extra=(
                ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
                'posterior widths may be unreliable.'
            ),
        )
        x.append(x_out)
        y.append(y_eff)
        dy.append(weights)

    # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``.
    # The core API moved from ``MultiFitter.mcmc_sample()`` to a dedicated
    # ``Sampler`` class: construct it with the configured fitter and the
    # bound data, then call ``sample()``. ``Sampler`` handles the
    # multi-dataset reshaping internally.
    sampler_kwargs = {}
    if initializer is not None:
        sampler_kwargs['init'] = initializer

    # Resolved once and passed on as the explicit factory, so building it
    # (which resolves every constraint's parameter paths) happens once.
    factory = self._resolve_constraints_factory(constraints_factory)
    with self._constraints(factory):
        # The factory is current only while the problem is being built, so
        # `_keep_constraints_on_extend` covers a later continuation.
        sampler = Sampler(self.easy_science_multi_fitter, x=x, y=y, weights=dy)
        self._keep_constraints_on_extend(sampler, factory)
        # Retained so the chain can be continued afterwards via ``self.sampler.extend()``.
        self._sampler = sampler
        results = sampler.sample(
            samples=samples,
            burn=burn,
            thin=thin,
            population=population,
            sampler_kwargs=sampler_kwargs or None,
            progress_callback=progress_callback,
            abort_test=abort_test,
        )
    return {
        'draws': results.draws,
        'param_names': results.param_names,
        'state': results.state,
        'logp': results.logp,
    }

record_fit_results(results)

Adopt fit results produced elsewhere, so this fitter reports on them.

An application that drives easy_science_multi_fitter.fit(...) itself — to run it in a worker thread, for instance — leaves the MultiFitter that owns the goodness-of-fit properties none the wiser. Handing the results back here makes :attr:chi2 / :attr:reduced_chi describe that fit instead of reporting "no fit performed".

Only the minimizer-reported metrics are restored: the classical ones need the original data arrays, which are not part of FitResults, so :attr:classical_chi2 and :attr:classical_reduced_chi stay None.

Parameters:

Name Type Description Default
results list[FitResults] | None

Results of the fit, one per fitted dataset. None clears them.

required
Source code in src/easyreflectometry/fitting.py
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
def record_fit_results(self, results: list[FitResults] | None) -> None:
    """Adopt fit results produced elsewhere, so this fitter reports on them.

    An application that drives ``easy_science_multi_fitter.fit(...)`` itself
    — to run it in a worker thread, for instance — leaves the ``MultiFitter``
    that owns the goodness-of-fit properties none the wiser. Handing the
    results back here makes :attr:`chi2` / :attr:`reduced_chi` describe that
    fit instead of reporting "no fit performed".

    Only the minimizer-reported metrics are restored: the classical ones
    need the original data arrays, which are not part of ``FitResults``, so
    :attr:`classical_chi2` and :attr:`classical_reduced_chi` stay None.

    Parameters
    ----------
    results : list[FitResults] | None
        Results of the fit, one per fitted dataset. None clears them.
    """
    self._fit_results = list(results) if results else None
    self._classical_fit_metrics = None

switch_minimizer(minimizer)

Switch the minimizer for the fitting.

Parameters:

Name Type Description Default
minimizer AvailableMinimizers

Minimizer to be switched to.

required
Source code in src/easyreflectometry/fitting.py
924
925
926
927
928
929
930
931
932
def switch_minimizer(self, minimizer: AvailableMinimizers) -> None:
    """Switch the minimizer for the fitting.

    Parameters
    ----------
    minimizer : AvailableMinimizers
        Minimizer to be switched to.
    """
    self.easy_science_multi_fitter.switch_minimizer(minimizer)

InequalityEvaluation dataclass

Result of evaluating a spec against the current parameter values.

Source code in src/easyreflectometry/inequality_constraints.py
242
243
244
245
246
247
248
249
250
251
252
253
@dataclass
class InequalityEvaluation:
    """Result of evaluating a spec against the current parameter values."""

    lhs: float
    rhs: float
    satisfied: bool
    violation: float

    @property
    def feasible(self) -> bool:
        return self.satisfied

InequalitySpec dataclass

Declarative cross-parameter inequality, e.g. t_head < t_tail.

Attributes:

Name Type Description
lhs_expression str

Expression over the aliases in lhs_paths, e.g. 'a + b'.

op str

One of '<', '<=', '>', '>='.

rhs_expression str

Expression over the aliases in rhs_paths; may also be a plain number (dimensionless or understood in the unit of the left side).

lhs_paths, rhs_paths dict[str, str]

Alias to structural parameter path (models/0/...); no live objects are held so the spec is trivially serializable.

name str

Optional user label.

enabled bool

Disabled specs are kept but not applied to fits.

Source code in src/easyreflectometry/inequality_constraints.py
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
@dataclass
class InequalitySpec:
    """Declarative cross-parameter inequality, e.g. ``t_head < t_tail``.

    Attributes
    ----------
    lhs_expression : str
        Expression over the aliases in `lhs_paths`, e.g. ``'a + b'``.
    op : str
        One of ``'<'``, ``'<='``, ``'>'``, ``'>='``.
    rhs_expression : str
        Expression over the aliases in `rhs_paths`; may also be a plain
        number (dimensionless or understood in the unit of the left side).
    lhs_paths, rhs_paths : dict[str, str]
        Alias to structural parameter path (``models/0/...``); no live objects
        are held so the spec is trivially serializable.
    name : str
        Optional user label.
    enabled : bool
        Disabled specs are kept but not applied to fits.
    """

    lhs_expression: str
    op: str
    rhs_expression: str
    lhs_paths: Dict[str, str] = field(default_factory=dict)
    rhs_paths: Dict[str, str] = field(default_factory=dict)
    name: str = ''
    enabled: bool = True

    def __post_init__(self) -> None:
        self.op = _normalize_relation(self.op)
        self.lhs_expression = str(self.lhs_expression).strip()
        self.rhs_expression = str(self.rhs_expression).strip()
        self.lhs_paths = dict(self.lhs_paths)
        self.rhs_paths = dict(self.rhs_paths)
        self.validate_syntax()

    # ----- validation -----

    def validate_syntax(self) -> None:
        """Check that both sides parse and every identifier has an alias mapping."""
        for side, expression, paths in (
            ('left', self.lhs_expression, self.lhs_paths),
            ('right', self.rhs_expression, self.rhs_paths),
        ):
            if not expression:
                raise ValueError(f'The {side}-hand side of an inequality cannot be empty.')
            for alias in paths:
                if not alias.isidentifier() or keyword.iskeyword(alias):
                    raise ValueError(f"Alias '{alias}' is not a valid identifier.")
            missing = [n for n in _identifiers(expression) if n not in paths]
            if missing:
                raise ValueError(f"The {side}-hand side '{expression}' references unmapped names: {', '.join(missing)}.")
            interpreter = _new_interpreter()
            try:
                _evaluate(interpreter, expression, {alias: 1.0 for alias in paths})
            except Exception as error:  # asteval raises its own hierarchy
                raise SyntaxError(f"Cannot evaluate the {side}-hand side '{expression}': {error}") from None
        # `paths` merges both sides, so one alias cannot mean two different
        # parameters — the right side would silently win.
        conflicting = sorted(
            alias for alias, path in self.lhs_paths.items() if alias in self.rhs_paths and self.rhs_paths[alias] != path
        )
        if conflicting:
            raise ValueError(
                f'Alias(es) {", ".join(repr(a) for a in conflicting)} map to different parameters on the '
                'two sides of the inequality. Use distinct alias names per side.'
            )

    @property
    def paths(self) -> Dict[str, str]:
        """All alias → path pairs of both sides."""
        merged = dict(self.lhs_paths)
        merged.update(self.rhs_paths)
        return merged

    # ----- serialization -----

    def to_dict(self) -> dict:
        return {
            'lhs_expression': self.lhs_expression,
            'op': self.op,
            'rhs_expression': self.rhs_expression,
            'lhs_paths': dict(self.lhs_paths),
            'rhs_paths': dict(self.rhs_paths),
            'name': self.name,
            'enabled': bool(self.enabled),
        }

    @classmethod
    def from_dict(cls, d: dict) -> 'InequalitySpec':
        return cls(
            lhs_expression=d['lhs_expression'],
            op=d['op'],
            rhs_expression=d['rhs_expression'],
            lhs_paths=d.get('lhs_paths', {}),
            rhs_paths=d.get('rhs_paths', {}),
            name=d.get('name', ''),
            enabled=d.get('enabled', True),
        )

    def __str__(self) -> str:
        return f'{self.lhs_expression} {self.op} {self.rhs_expression}'

paths property

All alias → path pairs of both sides.

validate_syntax()

Check that both sides parse and every identifier has an alias mapping.

Source code in src/easyreflectometry/inequality_constraints.py
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
def validate_syntax(self) -> None:
    """Check that both sides parse and every identifier has an alias mapping."""
    for side, expression, paths in (
        ('left', self.lhs_expression, self.lhs_paths),
        ('right', self.rhs_expression, self.rhs_paths),
    ):
        if not expression:
            raise ValueError(f'The {side}-hand side of an inequality cannot be empty.')
        for alias in paths:
            if not alias.isidentifier() or keyword.iskeyword(alias):
                raise ValueError(f"Alias '{alias}' is not a valid identifier.")
        missing = [n for n in _identifiers(expression) if n not in paths]
        if missing:
            raise ValueError(f"The {side}-hand side '{expression}' references unmapped names: {', '.join(missing)}.")
        interpreter = _new_interpreter()
        try:
            _evaluate(interpreter, expression, {alias: 1.0 for alias in paths})
        except Exception as error:  # asteval raises its own hierarchy
            raise SyntaxError(f"Cannot evaluate the {side}-hand side '{expression}': {error}") from None
    # `paths` merges both sides, so one alias cannot mean two different
    # parameters — the right side would silently win.
    conflicting = sorted(
        alias for alias, path in self.lhs_paths.items() if alias in self.rhs_paths and self.rhs_paths[alias] != path
    )
    if conflicting:
        raise ValueError(
            f'Alias(es) {", ".join(repr(a) for a in conflicting)} map to different parameters on the '
            'two sides of the inequality. Use distinct alias names per side.'
        )

Model

Bases: BaseCore

Model is the class that represents the experiment.

It is used to store the information about the experiment and to perform the calculations.

Source code in src/easyreflectometry/model/model.py
 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
286
287
288
289
290
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
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
374
375
376
377
378
379
380
381
382
383
384
class Model(BaseCore):
    """Model is the class that represents the experiment.

    It is used to store the information about the experiment and to perform the calculations.
    """

    def __init__(
        self,
        sample: Union[Sample, None] = None,
        scale: Union[Parameter, Number, None] = None,
        background: Union[Parameter, Number, None] = None,
        resolution_function: Union[ResolutionFunction, None] = None,
        name: str = 'Model',
        color: str = COLORS[0],
        unique_name: Optional[str] = None,
        interface=None,
    ):
        """Constructor.

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        color : str, optional
            By default, COLORS[0].
        sample : Union[Sample, None], optional
            The sample being modelled. By default, None.
        scale : Union[Parameter, Number, None], optional
            Scaling factor of profile. By default, None.
        background : Union[Parameter, Number, None], optional
            Linear background magnitude. By default, None.
        name : str, optional
            Name of the model. By default, 'Model'.
        resolution_function : Union[ResolutionFunction, None], optional
            Resolution function. 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 sample is None:
            sample = Sample(interface=interface)
        if resolution_function is None:
            resolution_function = PercentageFwhm(DEFAULTS['resolution']['value'])

        scale = get_as_parameter('scale', scale, DEFAULTS)
        apply_default_limits(scale, 'scale')
        background = get_as_parameter('background', background, DEFAULTS)
        self.color = color
        self._is_default = False
        self._resolution_function = resolution_function

        super().__init__(name=name, unique_name=unique_name)
        self._sample = sample
        self._scale = scale
        self._background = background

        # Derived, read-only "calculation" parameter (see `total_thickness`).
        # Rebuilt by every constructor call — including `from_dict` — and
        # deliberately *not* serialized: it is a function of the layers.
        self._total_thickness = Parameter(
            name='total_thickness',
            value=0.0,
            unit='angstrom',
            fixed=True,
            description='Total thickness of the film: the sum of all layer thicknesses '
            'between the superphase and the subphase (derived, read-only).',
        )
        self._total_thickness_sources: list[Parameter] = []
        self.refresh_derived()

        # Set interface last — propagates to children via BaseCore.generate_bindings
        # and then sets the resolution function on the calculator (see setter).
        if interface is not None:
            self.interface = interface

    # ----- @property accessors for serialization round-trip -----

    @property
    def sample(self) -> Sample:
        return self._sample

    @sample.setter
    def sample(self, value: Sample) -> None:
        self._sample = value

    @property
    def scale(self) -> Parameter:
        return self._scale

    @scale.setter
    def scale(self, value: float) -> None:
        self._scale.value = value

    @property
    def background(self) -> Parameter:
        return self._background

    @background.setter
    def background(self, value: float) -> None:
        self._background.value = value

    # ----- derived parameters -----

    def _film_layers(self) -> list:
        """Layers between the superphase and the subphase (first and last layer of the sample)."""
        layers = [layer for assembly in self.sample for layer in assembly.layers]
        return layers[1:-1] if len(layers) >= 3 else []

    def refresh_derived(self) -> None:
        """Re-derive the model's computed parameters from the current layer structure.

        Cheap (a few string operations) and idempotent: the dependency is
        only rebuilt when the set of contributing layers changed. Called on
        every access to :attr:`total_thickness`, so layers added or removed
        through *any* path (`Model.add_assemblies`, `assembly.layers.append`,
        ...) are picked up without explicit notification.
        """
        sources = [layer.thickness for layer in self._film_layers()]
        current = self._total_thickness_sources
        if len(sources) == len(current) and all(a is b for a, b in zip(sources, current)):
            return
        self._total_thickness_sources = sources
        total = self._total_thickness
        if not sources:
            if not total.independent:
                total.make_independent()
            # `make_dependent_on` clears `fixed`, and `make_independent` does not
            # put it back; without this the parameter would resurface as a free
            # fit parameter once the film is emptied.
            total.fixed = True
            total.value = 0.0
            return
        dependency_map = {f't{index}': source for index, source in enumerate(sources)}
        total.make_dependent_on(
            dependency_expression=' + '.join(dependency_map.keys()),
            dependency_map=dependency_map,
        )

    @property
    def total_thickness(self) -> Parameter:
        """Read-only parameter: the summed thickness of the film layers.

        The superphase (first layer of the sample) and the subphase (last
        layer) are semi-infinite and excluded. The parameter is *dependent*:
        it tracks the layer thicknesses, never enters a fit and cannot be
        set; it can be referenced from constraint expressions and
        inequality constraints like any other parameter.
        """
        self.refresh_derived()
        return self._total_thickness

    # ----- assembly management -----

    def add_assemblies(self, *assemblies: list[BaseAssembly]) -> None:
        """Add assemblies to the model sample.

        Parameters
        ----------
        *assemblies : list[BaseAssembly]
            Assemblies to add to model sample.
        """
        if not assemblies:
            self.sample.add_assembly()
            if self.interface is not None:
                self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)
        else:
            for assembly in assemblies:
                if issubclass(assembly.__class__, BaseAssembly):
                    self.sample.add_assembly(assembly)
                    if self.interface is not None:
                        self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)
                else:
                    raise ValueError(f'Object {assembly} is not a valid type, must be a child of BaseAssembly.')

    def duplicate_assembly(self, index: int) -> None:
        """Duplicate a given item or layer in a sample.

        Parameters
        ----------
        index : int
        idx :
            Index of the item or layer to duplicate.
        """
        self.sample.duplicate_assembly(index)
        if self.interface is not None:
            self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)

    def remove_assembly(self, index: int) -> None:
        """Remove an assembly from the model.

        Parameters
        ----------
        index : int
        idx :
            Index of the item to remove.
        """
        assembly_unique_name = self.sample[index].unique_name
        self.sample.remove_assembly(index)
        if self.interface is not None:
            self.interface().remove_item_from_model(assembly_unique_name, self.unique_name)

    @property
    def has_magnetism(self) -> bool:
        """Whether any layer in the sample carries magnetic properties."""
        return any(getattr(layer, 'magnetism', None) is not None for assembly in self.sample for layer in assembly.layers)

    @property
    def is_default(self) -> bool:
        """Whether this model was created as a default placeholder."""
        return self._is_default

    @is_default.setter
    def is_default(self, value: bool) -> None:
        """Set whether this model is a default placeholder."""
        self._is_default = value

    # ----- resolution function -----

    @property
    def resolution_function(self) -> ResolutionFunction:
        """Return the resolution function."""
        return self._resolution_function

    @resolution_function.setter
    def resolution_function(self, resolution_function: ResolutionFunction) -> None:
        """Set the resolution function for the model."""
        self._resolution_function = resolution_function
        if self.interface is not None:
            self.interface().set_resolution_function(self._resolution_function)

    # ----- interface (override BaseCore's to add resolution-function side effect) -----

    @BaseCore.interface.setter
    def interface(self, new_interface) -> None:
        """Set the interface; runs `generate_bindings` and then refreshes the
        calculator's resolution function.
        """
        # Call BaseCore.interface.setter for the binding propagation.
        BaseCore.interface.fset(self, new_interface)
        if new_interface is not None:
            new_interface().set_resolution_function(self._resolution_function)

    # ----- representation -----

    @property
    def _dict_repr(self) -> dict[str, dict[str, str]]:
        """A simplified dict representation."""
        if isinstance(self._resolution_function, PercentageFwhm):
            resolution_value = self._resolution_function.as_dict()['constant']
            resolution = f'{resolution_value} %'
        else:
            resolution = 'function of Q'

        return {
            self.name: {
                'scale': float(self.scale.value),
                'background': float(self.background.value),
                'resolution': resolution,
                'color': self.color,
                'sample': self.sample._dict_repr,
            }
        }

    # ----- serialization (custom because resolution_function + interface need special handling) -----

    def to_dict(self, skip: Optional[list[str]] = None) -> dict:
        """Serialize the model, encoding the resolution function and interface name."""
        if skip is None:
            skip = []
        # Sample/resolution_function/interface get bespoke encoding below.
        skip_for_super = list(skip) + ['sample', 'resolution_function', 'interface']
        this_dict = super().to_dict(skip=skip_for_super)
        this_dict['sample'] = self.sample.as_dict(skip=skip)
        this_dict['resolution_function'] = self.resolution_function.as_dict(skip=skip)
        if self.interface is None:
            this_dict['interface'] = None
        else:
            this_dict['interface'] = self.interface().name
        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)

    def as_orso(self) -> dict:
        """The sample as an ORSO simple-model (``sample.model``) dictionary.

        Slab representation: lengths in angstrom, SLDs in 1/angstrom^2,
        repeating multilayers via the inline ``N ( ... )`` stack syntax.

        Returns
        -------
        dict
            The ORSO model-language dictionary (the content of an .ort file's
            ``data_source.sample.model`` section).
        """
        # Circular import if hoisted to module-top.
        from orsopy.fileio import Header

        from easyreflectometry.orso_utils import sample_to_orso_model

        return Header.asdict(sample_to_orso_model(self.sample))

    @classmethod
    def from_dict(cls, passed_dict: dict) -> Model:
        """Create a Model from a dictionary."""
        # Circular import if hoisted to module-top.
        from easyreflectometry.calculators import CalculatorFactory

        this_dict = copy.deepcopy(passed_dict)
        resolution_function = ResolutionFunction.from_dict(this_dict.pop('resolution_function'))
        interface_name = this_dict.pop('interface')
        if interface_name is not None:
            interface = CalculatorFactory()
            interface.switch(interface_name)
        else:
            interface = None

        model = super().from_dict(this_dict)

        model.resolution_function = resolution_function
        model.interface = interface
        return model

total_thickness property

Read-only parameter: the summed thickness of the film layers.

The superphase (first layer of the sample) and the subphase (last layer) are semi-infinite and excluded. The parameter is dependent: it tracks the layer thicknesses, never enters a fit and cannot be set; it can be referenced from constraint expressions and inequality constraints like any other parameter.

has_magnetism property

Whether any layer in the sample carries magnetic properties.

is_default property writable

Whether this model was created as a default placeholder.

resolution_function property writable

Return the resolution function.

__init__(sample=None, scale=None, background=None, resolution_function=None, name='Model', color=COLORS[0], unique_name=None, interface=None)

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
color str

By default, COLORS[0].

COLORS[0]
sample Union[Sample, None]

The sample being modelled. By default, None.

None
scale Union[Parameter, Number, None]

Scaling factor of profile. By default, None.

None
background Union[Parameter, Number, None]

Linear background magnitude. By default, None.

None
name str

Name of the model. By default, 'Model'.

'Model'
resolution_function Union[ResolutionFunction, None]

Resolution function. By default, None.

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/model/model.py
 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
def __init__(
    self,
    sample: Union[Sample, None] = None,
    scale: Union[Parameter, Number, None] = None,
    background: Union[Parameter, Number, None] = None,
    resolution_function: Union[ResolutionFunction, None] = None,
    name: str = 'Model',
    color: str = COLORS[0],
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    color : str, optional
        By default, COLORS[0].
    sample : Union[Sample, None], optional
        The sample being modelled. By default, None.
    scale : Union[Parameter, Number, None], optional
        Scaling factor of profile. By default, None.
    background : Union[Parameter, Number, None], optional
        Linear background magnitude. By default, None.
    name : str, optional
        Name of the model. By default, 'Model'.
    resolution_function : Union[ResolutionFunction, None], optional
        Resolution function. 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 sample is None:
        sample = Sample(interface=interface)
    if resolution_function is None:
        resolution_function = PercentageFwhm(DEFAULTS['resolution']['value'])

    scale = get_as_parameter('scale', scale, DEFAULTS)
    apply_default_limits(scale, 'scale')
    background = get_as_parameter('background', background, DEFAULTS)
    self.color = color
    self._is_default = False
    self._resolution_function = resolution_function

    super().__init__(name=name, unique_name=unique_name)
    self._sample = sample
    self._scale = scale
    self._background = background

    # Derived, read-only "calculation" parameter (see `total_thickness`).
    # Rebuilt by every constructor call — including `from_dict` — and
    # deliberately *not* serialized: it is a function of the layers.
    self._total_thickness = Parameter(
        name='total_thickness',
        value=0.0,
        unit='angstrom',
        fixed=True,
        description='Total thickness of the film: the sum of all layer thicknesses '
        'between the superphase and the subphase (derived, read-only).',
    )
    self._total_thickness_sources: list[Parameter] = []
    self.refresh_derived()

    # Set interface last — propagates to children via BaseCore.generate_bindings
    # and then sets the resolution function on the calculator (see setter).
    if interface is not None:
        self.interface = interface

interface(new_interface)

Set the interface; runs generate_bindings and then refreshes the calculator's resolution function.

Source code in src/easyreflectometry/model/model.py
294
295
296
297
298
299
300
301
302
@BaseCore.interface.setter
def interface(self, new_interface) -> None:
    """Set the interface; runs `generate_bindings` and then refreshes the
    calculator's resolution function.
    """
    # Call BaseCore.interface.setter for the binding propagation.
    BaseCore.interface.fset(self, new_interface)
    if new_interface is not None:
        new_interface().set_resolution_function(self._resolution_function)

refresh_derived()

Re-derive the model's computed parameters from the current layer structure.

Cheap (a few string operations) and idempotent: the dependency is only rebuilt when the set of contributing layers changed. Called on every access to :attr:total_thickness, so layers added or removed through any path (Model.add_assemblies, assembly.layers.append, ...) are picked up without explicit notification.

Source code in src/easyreflectometry/model/model.py
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
def refresh_derived(self) -> None:
    """Re-derive the model's computed parameters from the current layer structure.

    Cheap (a few string operations) and idempotent: the dependency is
    only rebuilt when the set of contributing layers changed. Called on
    every access to :attr:`total_thickness`, so layers added or removed
    through *any* path (`Model.add_assemblies`, `assembly.layers.append`,
    ...) are picked up without explicit notification.
    """
    sources = [layer.thickness for layer in self._film_layers()]
    current = self._total_thickness_sources
    if len(sources) == len(current) and all(a is b for a, b in zip(sources, current)):
        return
    self._total_thickness_sources = sources
    total = self._total_thickness
    if not sources:
        if not total.independent:
            total.make_independent()
        # `make_dependent_on` clears `fixed`, and `make_independent` does not
        # put it back; without this the parameter would resurface as a free
        # fit parameter once the film is emptied.
        total.fixed = True
        total.value = 0.0
        return
    dependency_map = {f't{index}': source for index, source in enumerate(sources)}
    total.make_dependent_on(
        dependency_expression=' + '.join(dependency_map.keys()),
        dependency_map=dependency_map,
    )

add_assemblies(*assemblies)

Add assemblies to the model sample.

Parameters:

Name Type Description Default
*assemblies list[BaseAssembly]

Assemblies to add to model sample.

()
Source code in src/easyreflectometry/model/model.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
def add_assemblies(self, *assemblies: list[BaseAssembly]) -> None:
    """Add assemblies to the model sample.

    Parameters
    ----------
    *assemblies : list[BaseAssembly]
        Assemblies to add to model sample.
    """
    if not assemblies:
        self.sample.add_assembly()
        if self.interface is not None:
            self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)
    else:
        for assembly in assemblies:
            if issubclass(assembly.__class__, BaseAssembly):
                self.sample.add_assembly(assembly)
                if self.interface is not None:
                    self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)
            else:
                raise ValueError(f'Object {assembly} is not a valid type, must be a child of BaseAssembly.')

duplicate_assembly(index)

Duplicate a given item or layer in a sample.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item or layer to duplicate.

required
Source code in src/easyreflectometry/model/model.py
236
237
238
239
240
241
242
243
244
245
246
247
def duplicate_assembly(self, index: int) -> None:
    """Duplicate a given item or layer in a sample.

    Parameters
    ----------
    index : int
    idx :
        Index of the item or layer to duplicate.
    """
    self.sample.duplicate_assembly(index)
    if self.interface is not None:
        self.interface().add_item_to_model(self.sample[-1].unique_name, self.unique_name)

remove_assembly(index)

Remove an assembly from the model.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item to remove.

required
Source code in src/easyreflectometry/model/model.py
249
250
251
252
253
254
255
256
257
258
259
260
261
def remove_assembly(self, index: int) -> None:
    """Remove an assembly from the model.

    Parameters
    ----------
    index : int
    idx :
        Index of the item to remove.
    """
    assembly_unique_name = self.sample[index].unique_name
    self.sample.remove_assembly(index)
    if self.interface is not None:
        self.interface().remove_item_from_model(assembly_unique_name, self.unique_name)

to_dict(skip=None)

Serialize the model, encoding the resolution function and interface name.

Source code in src/easyreflectometry/model/model.py
327
328
329
330
331
332
333
334
335
336
337
338
339
340
def to_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Serialize the model, encoding the resolution function and interface name."""
    if skip is None:
        skip = []
    # Sample/resolution_function/interface get bespoke encoding below.
    skip_for_super = list(skip) + ['sample', 'resolution_function', 'interface']
    this_dict = super().to_dict(skip=skip_for_super)
    this_dict['sample'] = self.sample.as_dict(skip=skip)
    this_dict['resolution_function'] = self.resolution_function.as_dict(skip=skip)
    if self.interface is None:
        this_dict['interface'] = None
    else:
        this_dict['interface'] = self.interface().name
    return this_dict

as_dict(skip=None)

Compatibility alias for :meth:to_dict.

Source code in src/easyreflectometry/model/model.py
342
343
344
def as_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Compatibility alias for :meth:`to_dict`."""
    return self.to_dict(skip=skip)

as_orso()

The sample as an ORSO simple-model (sample.model) dictionary.

Slab representation: lengths in angstrom, SLDs in 1/angstrom^2, repeating multilayers via the inline N ( ... ) stack syntax.

Returns:

Type Description
dict

The ORSO model-language dictionary (the content of an .ort file's data_source.sample.model section).

Source code in src/easyreflectometry/model/model.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
def as_orso(self) -> dict:
    """The sample as an ORSO simple-model (``sample.model``) dictionary.

    Slab representation: lengths in angstrom, SLDs in 1/angstrom^2,
    repeating multilayers via the inline ``N ( ... )`` stack syntax.

    Returns
    -------
    dict
        The ORSO model-language dictionary (the content of an .ort file's
        ``data_source.sample.model`` section).
    """
    # Circular import if hoisted to module-top.
    from orsopy.fileio import Header

    from easyreflectometry.orso_utils import sample_to_orso_model

    return Header.asdict(sample_to_orso_model(self.sample))

from_dict(passed_dict) classmethod

Create a Model from a dictionary.

Source code in src/easyreflectometry/model/model.py
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
@classmethod
def from_dict(cls, passed_dict: dict) -> Model:
    """Create a Model from a dictionary."""
    # Circular import if hoisted to module-top.
    from easyreflectometry.calculators import CalculatorFactory

    this_dict = copy.deepcopy(passed_dict)
    resolution_function = ResolutionFunction.from_dict(this_dict.pop('resolution_function'))
    interface_name = this_dict.pop('interface')
    if interface_name is not None:
        interface = CalculatorFactory()
        interface.switch(interface_name)
    else:
        interface = None

    model = super().from_dict(this_dict)

    model.resolution_function = resolution_function
    model.interface = interface
    return model

ModelCollection

Bases: BaseCollection

Source code in src/easyreflectometry/model/model_collection.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
 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
class ModelCollection(BaseCollection):
    def __init__(
        self,
        *models: Tuple[Model],
        name: str = 'Models',
        interface=None,
        unique_name: Optional[str] = None,
        populate_if_none: bool = True,
        next_color_index: Optional[int] = None,
        **kwargs,
    ):
        """Init function."""
        if not models:
            if populate_if_none:
                models = DEFAULT_ELEMENTS(interface)
            else:
                models = []

        # `_next_color_index` must exist before super().__init__ because each
        # `append` during construction routes through `_append_internal` →
        # `_advance_color_index`, which reads the attribute.
        self._next_color_index = next_color_index

        super().__init__(
            name,
            interface,
            *models,
            unique_name=unique_name,
            populate_if_none=False,
            **kwargs,
        )

        color_count = len(COLORS)
        if color_count == 0:
            self._next_color_index = 0
        elif next_color_index is None:
            self._next_color_index = len(self) % color_count
        else:
            self._next_color_index = next_color_index % color_count

    @property
    def next_color_index(self) -> Optional[int]:
        """Index of the next colour to assign — kept around so it round-trips."""
        return self._next_color_index

    def next_color(self) -> str:
        """Colour the next appended model should get.

        Appending advances the cycle, so callers that build a ``Model``
        themselves can keep the per-model colours distinct::

            model = Model(sample=sample, color=collection.next_color())
            collection.add_model(model)
        """
        return self._current_color()

    def add_model(self, model: Optional[Model] = None):
        """Add a model to the collection.

        Parameters
        ----------
        model : Optional[Model], optional
            Model to add. By default, None (a new model is created with
            the collection's next colour; a supplied model keeps its own
            colour — use :meth:`next_color` when building one).
        """
        if model is None:
            model = Model(name='Model', interface=self.interface, color=self._current_color())
        self.append(model)

    def duplicate_model(self, index: int):
        """Duplicate a model in the collection.

        Parameters
        ----------
        index : int
            Model to duplicate.
        """
        to_be_duplicated = self[index]
        duplicate = Model.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        duplicate.name = duplicate.name + ' duplicate'
        # A duplicate sharing its source's colour would be indistinguishable
        # in the plots; give it the collection's next colour instead.
        duplicate.color = self._current_color()
        self.append(duplicate)

    @classmethod
    def from_dict(cls, this_dict: dict) -> ModelCollection:
        """Create an instance of a collection from a dictionary."""
        collection_dict = dict(this_dict)
        dict_data = collection_dict.pop('data', [])
        next_color_index = collection_dict.pop('next_color_index', None)

        # Reconstruct empty collection via EasyList.from_dict (handles
        # protected_types and assigns name/unique_name/populate_if_none).
        collection = super().from_dict(collection_dict)

        # Append each model without advancing the colour index — the saved
        # `next_color_index` below is the source of truth.
        for model_data in dict_data:
            collection._append_internal(Model.from_dict(model_data), advance=False)

        if len(collection) != len(dict_data):
            raise ValueError(f'Expected {len(dict_data)} models, got {len(collection)}')

        color_count = len(COLORS)
        if color_count == 0:
            collection._next_color_index = 0
        elif next_color_index is None:
            collection._next_color_index = len(collection) % color_count
        else:
            collection._next_color_index = next_color_index % color_count

        return collection

    def append(self, model: Model) -> None:  # type: ignore[override]
        """Append function."""
        self._append_internal(model, advance=True)

    def _append_internal(self, model: Model, advance: bool) -> None:
        """Append internal."""
        # Bypass our own `append` override and go straight to EasyList's
        # `MutableSequence.append` → `insert` path. Calling `super().append`
        # would dispatch back to `ModelCollection.append` because Python
        # resolves `append` via MRO from MutableSequence which doesn't
        # define it on a class higher than ModelCollection.
        from collections.abc import MutableSequence

        MutableSequence.append(self, model)
        if advance:
            self._advance_color_index()

    def _advance_color_index(self) -> None:
        """Advance color index."""
        if not COLORS:
            self._next_color_index = 0
            return
        if self._next_color_index is None:
            self._next_color_index = len(self) % len(COLORS)
            return
        self._next_color_index = (self._next_color_index + 1) % len(COLORS)

    def _current_color(self) -> str:
        """Current color."""
        if not COLORS:
            raise ValueError('No colors defined for models.')
        if self._next_color_index is None:
            self._next_color_index = 0
        return COLORS[self._next_color_index]

next_color_index property

Index of the next colour to assign — kept around so it round-trips.

__init__(*models, name='Models', interface=None, unique_name=None, populate_if_none=True, next_color_index=None, **kwargs)

Init function.

Source code in src/easyreflectometry/model/model_collection.py
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
def __init__(
    self,
    *models: Tuple[Model],
    name: str = 'Models',
    interface=None,
    unique_name: Optional[str] = None,
    populate_if_none: bool = True,
    next_color_index: Optional[int] = None,
    **kwargs,
):
    """Init function."""
    if not models:
        if populate_if_none:
            models = DEFAULT_ELEMENTS(interface)
        else:
            models = []

    # `_next_color_index` must exist before super().__init__ because each
    # `append` during construction routes through `_append_internal` →
    # `_advance_color_index`, which reads the attribute.
    self._next_color_index = next_color_index

    super().__init__(
        name,
        interface,
        *models,
        unique_name=unique_name,
        populate_if_none=False,
        **kwargs,
    )

    color_count = len(COLORS)
    if color_count == 0:
        self._next_color_index = 0
    elif next_color_index is None:
        self._next_color_index = len(self) % color_count
    else:
        self._next_color_index = next_color_index % color_count

next_color()

Colour the next appended model should get.

Appending advances the cycle, so callers that build a Model themselves can keep the per-model colours distinct::

model = Model(sample=sample, color=collection.next_color())
collection.add_model(model)
Source code in src/easyreflectometry/model/model_collection.py
66
67
68
69
70
71
72
73
74
75
def next_color(self) -> str:
    """Colour the next appended model should get.

    Appending advances the cycle, so callers that build a ``Model``
    themselves can keep the per-model colours distinct::

        model = Model(sample=sample, color=collection.next_color())
        collection.add_model(model)
    """
    return self._current_color()

add_model(model=None)

Add a model to the collection.

Parameters:

Name Type Description Default
model Optional[Model]

Model to add. By default, None (a new model is created with the collection's next colour; a supplied model keeps its own colour — use :meth:next_color when building one).

None
Source code in src/easyreflectometry/model/model_collection.py
77
78
79
80
81
82
83
84
85
86
87
88
89
def add_model(self, model: Optional[Model] = None):
    """Add a model to the collection.

    Parameters
    ----------
    model : Optional[Model], optional
        Model to add. By default, None (a new model is created with
        the collection's next colour; a supplied model keeps its own
        colour — use :meth:`next_color` when building one).
    """
    if model is None:
        model = Model(name='Model', interface=self.interface, color=self._current_color())
    self.append(model)

duplicate_model(index)

Duplicate a model in the collection.

Parameters:

Name Type Description Default
index int

Model to duplicate.

required
Source code in src/easyreflectometry/model/model_collection.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def duplicate_model(self, index: int):
    """Duplicate a model in the collection.

    Parameters
    ----------
    index : int
        Model to duplicate.
    """
    to_be_duplicated = self[index]
    duplicate = Model.from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    duplicate.name = duplicate.name + ' duplicate'
    # A duplicate sharing its source's colour would be indistinguishable
    # in the plots; give it the collection's next colour instead.
    duplicate.color = self._current_color()
    self.append(duplicate)

from_dict(this_dict) classmethod

Create an instance of a collection from a dictionary.

Source code in src/easyreflectometry/model/model_collection.py
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
@classmethod
def from_dict(cls, this_dict: dict) -> ModelCollection:
    """Create an instance of a collection from a dictionary."""
    collection_dict = dict(this_dict)
    dict_data = collection_dict.pop('data', [])
    next_color_index = collection_dict.pop('next_color_index', None)

    # Reconstruct empty collection via EasyList.from_dict (handles
    # protected_types and assigns name/unique_name/populate_if_none).
    collection = super().from_dict(collection_dict)

    # Append each model without advancing the colour index — the saved
    # `next_color_index` below is the source of truth.
    for model_data in dict_data:
        collection._append_internal(Model.from_dict(model_data), advance=False)

    if len(collection) != len(dict_data):
        raise ValueError(f'Expected {len(dict_data)} models, got {len(collection)}')

    color_count = len(COLORS)
    if color_count == 0:
        collection._next_color_index = 0
    elif next_color_index is None:
        collection._next_color_index = len(collection) % color_count
    else:
        collection._next_color_index = next_color_index % color_count

    return collection

append(model)

Append function.

Source code in src/easyreflectometry/model/model_collection.py
136
137
138
def append(self, model: Model) -> None:  # type: ignore[override]
    """Append function."""
    self._append_internal(model, advance=True)

PercentageFwhm

Bases: ResolutionFunction

Source code in src/easyreflectometry/model/resolution_functions.py
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
class PercentageFwhm(ResolutionFunction):
    def __init__(self, constant: Union[None, float] = None):
        """Init function."""
        if constant is None:
            constant = DEFAULT_RESOLUTION_FWHM_PERCENTAGE
        self.constant = constant

    def smearing(self, q: Union[np.array, float]) -> np.array:
        """Return per-point sigma values from the constant FWHM percentage.

        ``constant`` is a FWHM percentage of ``q``; it is converted to an
        absolute sigma so the smearing() contract is sigma for all types.
        """
        q_array = np.asarray(q, dtype=float)
        fwhm = (self.constant / 100.0) * q_array
        return fwhm / SIGMA_TO_FWHM

    def as_dict(
        self, skip: Optional[List[str]] = None
    ) -> dict[str, str]:  # skip is kept for consistency of the as_dict signature
        """As dict."""
        return {'smearing': 'PercentageFwhm', 'constant': self.constant}

__init__(constant=None)

Init function.

Source code in src/easyreflectometry/model/resolution_functions.py
60
61
62
63
64
def __init__(self, constant: Union[None, float] = None):
    """Init function."""
    if constant is None:
        constant = DEFAULT_RESOLUTION_FWHM_PERCENTAGE
    self.constant = constant

smearing(q)

Return per-point sigma values from the constant FWHM percentage.

constant is a FWHM percentage of q; it is converted to an absolute sigma so the smearing() contract is sigma for all types.

Source code in src/easyreflectometry/model/resolution_functions.py
66
67
68
69
70
71
72
73
74
def smearing(self, q: Union[np.array, float]) -> np.array:
    """Return per-point sigma values from the constant FWHM percentage.

    ``constant`` is a FWHM percentage of ``q``; it is converted to an
    absolute sigma so the smearing() contract is sigma for all types.
    """
    q_array = np.asarray(q, dtype=float)
    fwhm = (self.constant / 100.0) * q_array
    return fwhm / SIGMA_TO_FWHM

as_dict(skip=None)

As dict.

Source code in src/easyreflectometry/model/resolution_functions.py
76
77
78
79
80
def as_dict(
    self, skip: Optional[List[str]] = None
) -> dict[str, str]:  # skip is kept for consistency of the as_dict signature
    """As dict."""
    return {'smearing': 'PercentageFwhm', 'constant': self.constant}

Pointwise

Bases: ResolutionFunction

Pointwise resolution defined by a per-point resolution provided with the data.

The resolution is supplied as the variance of the Qz values (sQz) at the measured Qz data points, which is the form produced by data reduction (e.g. Qz_0.variances). The resolution width at each point is sqrt(sQz). For a requested q the width is obtained by linearly interpolating onto q, exactly as :class:LinearSpline does for explicitly provided widths.

This is a convenience wrapper around :class:LinearSpline that derives the widths from the [Qz, R, sQz] triple loaded from a data file; the returned widths are consumed by the calculators (refnx x_err / refl1d dq), which perform the actual convolution against the model.

Serialization contract: as_dict/from_dict store sQz_data_points as variances (sigma squared). This is deliberately unchanged by the ORSO value_is: FWHM support — FWHM columns are converted to sigma at load time, so stored values are always sigma squared and saved projects round-trip without migration.

Source code in src/easyreflectometry/model/resolution_functions.py
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
class Pointwise(ResolutionFunction):
    """Pointwise resolution defined by a per-point resolution provided with the data.

    The resolution is supplied as the variance of the Qz values (``sQz``) at the
    measured Qz data points, which is the form produced by data reduction (e.g.
    ``Qz_0.variances``).  The resolution width at each point is ``sqrt(sQz)``.
    For a requested ``q`` the width is obtained by linearly interpolating onto
    ``q``, exactly as :class:`LinearSpline` does for explicitly provided widths.

    This is a convenience wrapper around :class:`LinearSpline` that derives the
    widths from the ``[Qz, R, sQz]`` triple loaded from a data file; the returned
    widths are consumed by the calculators (refnx ``x_err`` / refl1d ``dq``),
    which perform the actual convolution against the model.

    Serialization contract: ``as_dict``/``from_dict`` store ``sQz_data_points``
    as **variances** (sigma squared). This is deliberately unchanged by the
    ORSO ``value_is: FWHM`` support — FWHM columns are converted to sigma at
    load time, so stored values are always sigma squared and saved projects
    round-trip without migration.
    """

    def __init__(self, q_data_points: List[np.ndarray]):
        """Init function.

        Parameters
        ----------
        q_data_points : List[np.ndarray]
            ``[Qz, R, sQz]`` where ``Qz`` are the measured Qz values, ``R`` the
            measured reflectivity (kept only for serialization round-trips) and
            ``sQz`` the variance of ``Qz`` at each point.
        """
        self.q_data_points = q_data_points

    def smearing(self, q: Optional[Union[np.ndarray, float]] = None) -> np.ndarray:
        """Return the resolution sigma interpolated onto ``q``.

        ``sQz`` is the variance of ``Qz``, so the sigma at each data point is
        ``sqrt(sQz)``; values are linearly interpolated onto the requested
        ``q``.  This already satisfies the sigma smearing() contract, so no
        FWHM conversion is applied.  When ``q`` is ``None`` the sigma values
        are returned at the stored data points.
        """
        Qz = np.asarray(self.q_data_points[0], dtype=float)
        sQz = np.asarray(self.q_data_points[2], dtype=float)
        q_eval = Qz if q is None else np.asarray(q, dtype=float)
        widths = np.sqrt(sQz)
        return np.asarray(np.interp(q_eval, Qz, widths))

    def as_dict(
        self, skip: Optional[List[str]] = None
    ) -> dict[str, str]:  # skip is kept for consistency of the as_dict signature
        """As dict."""
        return {
            'smearing': 'Pointwise',
            'q_data_points': list(self.q_data_points[0]),
            'R_data_points': list(self.q_data_points[1]),
            'sQz_data_points': list(self.q_data_points[2]),
        }

__init__(q_data_points)

Init function.

Parameters:

Name Type Description Default
q_data_points List[np.ndarray]

[Qz, R, sQz] where Qz are the measured Qz values, R the measured reflectivity (kept only for serialization round-trips) and sQz the variance of Qz at each point.

required
Source code in src/easyreflectometry/model/resolution_functions.py
130
131
132
133
134
135
136
137
138
139
140
def __init__(self, q_data_points: List[np.ndarray]):
    """Init function.

    Parameters
    ----------
    q_data_points : List[np.ndarray]
        ``[Qz, R, sQz]`` where ``Qz`` are the measured Qz values, ``R`` the
        measured reflectivity (kept only for serialization round-trips) and
        ``sQz`` the variance of ``Qz`` at each point.
    """
    self.q_data_points = q_data_points

smearing(q=None)

Return the resolution sigma interpolated onto q.

sQz is the variance of Qz, so the sigma at each data point is sqrt(sQz); values are linearly interpolated onto the requested q. This already satisfies the sigma smearing() contract, so no FWHM conversion is applied. When q is None the sigma values are returned at the stored data points.

Source code in src/easyreflectometry/model/resolution_functions.py
142
143
144
145
146
147
148
149
150
151
152
153
154
155
def smearing(self, q: Optional[Union[np.ndarray, float]] = None) -> np.ndarray:
    """Return the resolution sigma interpolated onto ``q``.

    ``sQz`` is the variance of ``Qz``, so the sigma at each data point is
    ``sqrt(sQz)``; values are linearly interpolated onto the requested
    ``q``.  This already satisfies the sigma smearing() contract, so no
    FWHM conversion is applied.  When ``q`` is ``None`` the sigma values
    are returned at the stored data points.
    """
    Qz = np.asarray(self.q_data_points[0], dtype=float)
    sQz = np.asarray(self.q_data_points[2], dtype=float)
    q_eval = Qz if q is None else np.asarray(q, dtype=float)
    widths = np.sqrt(sQz)
    return np.asarray(np.interp(q_eval, Qz, widths))

as_dict(skip=None)

As dict.

Source code in src/easyreflectometry/model/resolution_functions.py
157
158
159
160
161
162
163
164
165
166
def as_dict(
    self, skip: Optional[List[str]] = None
) -> dict[str, str]:  # skip is kept for consistency of the as_dict signature
    """As dict."""
    return {
        'smearing': 'Pointwise',
        'q_data_points': list(self.q_data_points[0]),
        'R_data_points': list(self.q_data_points[1]),
        'sQz_data_points': list(self.q_data_points[2]),
    }

Layer

Bases: BaseCore

Source code in src/easyreflectometry/sample/elements/layers/layer.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
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
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,
    ):
        """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.
        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

        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:
        self._material = value

    @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).
        """
        if value is None and self._magnetism is not None:
            if self.interface is not None:
                self.interface().remove_layer_magnetism(self.unique_name)
            # Detach the calculator callbacks of the removed parameters so later
            # value changes on the detached object 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.
            self._magnetism.rho_m._callback = property()
            self._magnetism.theta_m._callback = property()
        self._magnetism = value
        if value is not None and self.interface is not None:
            self._enable_calculator_magnetism()
            self.generate_bindings()

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

        Parameters
        ----------
        material : Material
            The material to assign to the layer.
        """
        self._material = material
        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

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

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
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
 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
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,
):
    """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.
    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

    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
106
107
108
109
110
111
112
113
@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
175
176
177
178
179
180
181
182
183
184
185
def assign_material(self, material: Material) -> None:
    """Assign a material to the layer interface.

    Parameters
    ----------
    material : Material
        The material to assign to the layer.
    """
    self._material = material
    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

MaterialCollection

Bases: BaseCollection

Source code in src/easyreflectometry/sample/collections/material_collection.py
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
class MaterialCollection(BaseCollection):
    def __init__(
        self,
        *materials: Tuple[Material],
        name: str = 'EasyMaterials',
        interface=None,
        unique_name: Optional[str] = None,
        populate_if_none: bool = True,
        **kwargs,
    ):
        """Init function."""
        if not materials:
            if populate_if_none:
                materials = DEFAULT_ELEMENTS(interface)
            else:
                materials = []

        super().__init__(
            name,
            interface,
            *materials,
            unique_name=unique_name,
            populate_if_none=False,
            **kwargs,
        )

    def add_material(self, material: Optional[Material] = None):
        """Add a material to the collection.

        Parameters
        ----------
        material : Optional[Material], optional
            Material to add. By default, None.
        """
        if material is None:
            material = Material(sld=0.0, isld=0.0, name='Material added')
        material.interface = self.interface
        self.append(material)

    def duplicate_material(self, index: int):
        """Duplicate a material in the collection.

        Parameters
        ----------
        index : int
        material :
            Assembly to add.
        """
        to_be_duplicated = self[index]
        duplicate = type(to_be_duplicated).from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
        duplicate.name = duplicate.name + ' duplicate'
        self.add_material(duplicate)

__init__(*materials, name='EasyMaterials', interface=None, unique_name=None, populate_if_none=True, **kwargs)

Init function.

Source code in src/easyreflectometry/sample/collections/material_collection.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(
    self,
    *materials: Tuple[Material],
    name: str = 'EasyMaterials',
    interface=None,
    unique_name: Optional[str] = None,
    populate_if_none: bool = True,
    **kwargs,
):
    """Init function."""
    if not materials:
        if populate_if_none:
            materials = DEFAULT_ELEMENTS(interface)
        else:
            materials = []

    super().__init__(
        name,
        interface,
        *materials,
        unique_name=unique_name,
        populate_if_none=False,
        **kwargs,
    )

add_material(material=None)

Add a material to the collection.

Parameters:

Name Type Description Default
material Optional[Material]

Material to add. By default, None.

None
Source code in src/easyreflectometry/sample/collections/material_collection.py
48
49
50
51
52
53
54
55
56
57
58
59
def add_material(self, material: Optional[Material] = None):
    """Add a material to the collection.

    Parameters
    ----------
    material : Optional[Material], optional
        Material to add. By default, None.
    """
    if material is None:
        material = Material(sld=0.0, isld=0.0, name='Material added')
    material.interface = self.interface
    self.append(material)

duplicate_material(index)

Duplicate a material in the collection.

Parameters:

Name Type Description Default
index int
required
material

Assembly to add.

required
Source code in src/easyreflectometry/sample/collections/material_collection.py
61
62
63
64
65
66
67
68
69
70
71
72
73
def duplicate_material(self, index: int):
    """Duplicate a material in the collection.

    Parameters
    ----------
    index : int
    material :
        Assembly to add.
    """
    to_be_duplicated = self[index]
    duplicate = type(to_be_duplicated).from_dict(to_be_duplicated.as_dict(skip=['unique_name']))
    duplicate.name = duplicate.name + ' duplicate'
    self.add_material(duplicate)

Multilayer

Bases: BaseAssembly

A multi layer is build from a single or a list of Layer or LayerCollection.

The multi layer will arrange the layers as slabs, allowing the reflectometry to be determined from them. The front layer is where the neutron beam starts in, it has an index of 0.

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

.. _multilayer documentation: ../sample/assemblies_library.html#multilayer

Source code in src/easyreflectometry/sample/assemblies/multilayer.py
 14
 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
class Multilayer(BaseAssembly):
    """A multi layer is build from a single or a list of `Layer` or `LayerCollection`.

    The multi layer will arrange the layers as slabs, allowing the reflectometry to be determined from them.
    The front layer is where the neutron beam starts in, it has an index of 0.

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

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

    def __init__(
        self,
        layers: Union[Layer, list[Layer], LayerCollection, None] = None,
        name: str = 'EasyMultilayer',
        unique_name: Optional[str] = None,
        interface=None,
        type: str = 'Multi-layer',
        populate_if_none: Optional[bool] = True,
        conformal_thickness: bool = False,
        conformal_roughness: bool = False,
    ):
        """Constructor.

        Parameters
        ----------
        populate_if_none : Optional[bool], optional
            By default, True.
        unique_name : Optional[str], optional
            By default, None.
        layers : Union[Layer, list[Layer], LayerCollection, None], optional
            The layers that make up the multi-layer. By default, None.
        name : str, optional
            Name for multi layer. By default, 'EasyMultilayer'.
        interface :
            Calculator interface. By default, None.
        type : str, optional
            Type of the constructed instance. By default, 'Multi-layer'.
        conformal_thickness : bool, optional
            Tie every layer's thickness to the front layer's. Serialization
            reads the current graph state through the matching property, so
            the ties are rebuilt on ``from_dict``. By default, False.
        conformal_roughness : bool, optional
            Tie every layer's roughness to the front layer's, likewise
            persistent. By default, False.
        """
        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]))
        # Needed to ensure an empty list is created when saving and instatiating the object as_dict -> from_dict
        # Else collisions might occur in global_object.map
        self.populate_if_none = False

        super().__init__(
            name=name,
            type=type,
            interface=interface,
            layers=layers,
            unique_name=unique_name,
        )
        if conformal_thickness:
            self.conformal_thickness = True
        if conformal_roughness:
            self.conformal_roughness = True

    def add_layer(self, *layers: tuple[Layer]) -> None:
        """Add a layer to the multi layer.

        Parameters
        ----------
        *layers : tuple[Layer]
            Layers to add to the multi layer.
        """
        for arg in layers:
            if issubclass(arg.__class__, Layer):
                self.layers.append(arg)
                if self.interface is not None:
                    self.interface().add_layer_to_item(arg.unique_name, self.unique_name)

    def duplicate_layer(self, idx: int) -> None:
        """Duplicate a given layer.

        Parameters
        ----------
        idx : int
            Index of layer to duplicate.
        """
        to_duplicate = self.layers[idx]
        duplicate_layer = Layer(
            material=to_duplicate.material,
            thickness=to_duplicate.thickness.value,
            roughness=to_duplicate.roughness.value,
            name=to_duplicate.name + ' duplicate',
        )
        self.add_layer(duplicate_layer)

    def remove_layer(self, idx: int) -> None:
        """Remove a layer from the item.

        Parameters
        ----------
        idx : int
            Index of layer to remove.
        """
        if self.interface is not None:
            self.interface().remove_layer_from_item(self.layers[idx].unique_name, self.unique_name)
        del self.layers[idx]

    # Representation
    @property
    def _dict_repr(self) -> dict:
        """A simplified dict representation."""
        return {self.name: self.layers._dict_repr}

    @classmethod
    def from_dict(cls, data: dict) -> Multilayer:
        """Create a Multilayer from a dictionary."""
        multilayer = super().from_dict(data)
        return multilayer

__init__(layers=None, name='EasyMultilayer', unique_name=None, interface=None, type='Multi-layer', populate_if_none=True, conformal_thickness=False, conformal_roughness=False)

Constructor.

Parameters:

Name Type Description Default
populate_if_none Optional[bool]

By default, True.

True
unique_name Optional[str]

By default, None.

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

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

None
name str

Name for multi layer. By default, 'EasyMultilayer'.

'EasyMultilayer'
interface

Calculator interface. By default, None.

None
type str

Type of the constructed instance. By default, 'Multi-layer'.

'Multi-layer'
conformal_thickness bool

Tie every layer's thickness to the front layer's. Serialization reads the current graph state through the matching property, so the ties are rebuilt on from_dict. By default, False.

False
conformal_roughness bool

Tie every layer's roughness to the front layer's, likewise persistent. By default, False.

False
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
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
def __init__(
    self,
    layers: Union[Layer, list[Layer], LayerCollection, None] = None,
    name: str = 'EasyMultilayer',
    unique_name: Optional[str] = None,
    interface=None,
    type: str = 'Multi-layer',
    populate_if_none: Optional[bool] = True,
    conformal_thickness: bool = False,
    conformal_roughness: bool = False,
):
    """Constructor.

    Parameters
    ----------
    populate_if_none : Optional[bool], optional
        By default, True.
    unique_name : Optional[str], optional
        By default, None.
    layers : Union[Layer, list[Layer], LayerCollection, None], optional
        The layers that make up the multi-layer. By default, None.
    name : str, optional
        Name for multi layer. By default, 'EasyMultilayer'.
    interface :
        Calculator interface. By default, None.
    type : str, optional
        Type of the constructed instance. By default, 'Multi-layer'.
    conformal_thickness : bool, optional
        Tie every layer's thickness to the front layer's. Serialization
        reads the current graph state through the matching property, so
        the ties are rebuilt on ``from_dict``. By default, False.
    conformal_roughness : bool, optional
        Tie every layer's roughness to the front layer's, likewise
        persistent. By default, False.
    """
    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]))
    # Needed to ensure an empty list is created when saving and instatiating the object as_dict -> from_dict
    # Else collisions might occur in global_object.map
    self.populate_if_none = False

    super().__init__(
        name=name,
        type=type,
        interface=interface,
        layers=layers,
        unique_name=unique_name,
    )
    if conformal_thickness:
        self.conformal_thickness = True
    if conformal_roughness:
        self.conformal_roughness = True

add_layer(*layers)

Add a layer to the multi layer.

Parameters:

Name Type Description Default
*layers tuple[Layer]

Layers to add to the multi layer.

()
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
86
87
88
89
90
91
92
93
94
95
96
97
98
def add_layer(self, *layers: tuple[Layer]) -> None:
    """Add a layer to the multi layer.

    Parameters
    ----------
    *layers : tuple[Layer]
        Layers to add to the multi layer.
    """
    for arg in layers:
        if issubclass(arg.__class__, Layer):
            self.layers.append(arg)
            if self.interface is not None:
                self.interface().add_layer_to_item(arg.unique_name, self.unique_name)

duplicate_layer(idx)

Duplicate a given layer.

Parameters:

Name Type Description Default
idx int

Index of layer to duplicate.

required
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def duplicate_layer(self, idx: int) -> None:
    """Duplicate a given layer.

    Parameters
    ----------
    idx : int
        Index of layer to duplicate.
    """
    to_duplicate = self.layers[idx]
    duplicate_layer = Layer(
        material=to_duplicate.material,
        thickness=to_duplicate.thickness.value,
        roughness=to_duplicate.roughness.value,
        name=to_duplicate.name + ' duplicate',
    )
    self.add_layer(duplicate_layer)

remove_layer(idx)

Remove a layer from the item.

Parameters:

Name Type Description Default
idx int

Index of layer to remove.

required
Source code in src/easyreflectometry/sample/assemblies/multilayer.py
117
118
119
120
121
122
123
124
125
126
127
def remove_layer(self, idx: int) -> None:
    """Remove a layer from the item.

    Parameters
    ----------
    idx : int
        Index of layer to remove.
    """
    if self.interface is not None:
        self.interface().remove_layer_from_item(self.layers[idx].unique_name, self.unique_name)
    del self.layers[idx]

from_dict(data) classmethod

Create a Multilayer from a dictionary.

Source code in src/easyreflectometry/sample/assemblies/multilayer.py
135
136
137
138
139
@classmethod
def from_dict(cls, data: dict) -> Multilayer:
    """Create a Multilayer from a dictionary."""
    multilayer = super().from_dict(data)
    return multilayer

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
 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
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]]
        args :
            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):
        """Add an assembly to the sample.

        Parameters
        ----------
        index : int
        assembly :
            Assembly to add.
        """
        # 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

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]]
()
args

The assemblies in the sample.

required
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
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
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]]
    args :
        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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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)

Add an assembly to the sample.

Parameters:

Name Type Description Default
index int
required
assembly

Assembly to add.

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

    Parameters
    ----------
    index : int
    assembly :
        Assembly to add.
    """
    # 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
118
119
120
121
122
123
124
125
126
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
128
129
130
131
132
133
134
135
136
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
138
139
140
141
142
143
144
145
146
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)

BaseCollection

Bases: EasyList

Local base for sample-tree collections (Material/Layer/Assembly/Model collections).

Built on top of easyscience.base_classes.EasyList (the replacement for the deprecated CollectionBase). On top of EasyList this class adds:

  • a name property
  • an interface property whose setter propagates the calculator interface to every contained item
  • propagation of the current interface to newly inserted items
  • a populate_if_none flag preserved for serialization round-trip
  • convenience helpers names, move_up, move_down, remove_at
  • a yaml-formatted __repr__ driven by _dict_repr
  • an as_dict alias for to_dict with skip= support and interface excluded by default

Subclasses (LayerCollection, MaterialCollection, Sample, ModelCollection) keep their existing constructor shape — they pass name and interface positionally to this class, items as *args, and additional configuration as kwargs.

Source code in src/easyreflectometry/sample/collections/base_collection.py
 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
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
286
287
288
289
290
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
323
324
325
326
327
328
329
330
331
class BaseCollection(EasyList):
    """Local base for sample-tree collections (Material/Layer/Assembly/Model collections).

    Built on top of `easyscience.base_classes.EasyList` (the replacement for
    the deprecated `CollectionBase`). On top of `EasyList` this class adds:

    - a `name` property
    - an `interface` property whose setter propagates the calculator interface
      to every contained item
    - propagation of the current `interface` to newly inserted items
    - a `populate_if_none` flag preserved for serialization round-trip
    - convenience helpers `names`, `move_up`, `move_down`, `remove_at`
    - a yaml-formatted `__repr__` driven by `_dict_repr`
    - an `as_dict` alias for `to_dict` with `skip=` support and `interface`
      excluded by default

    Subclasses (`LayerCollection`, `MaterialCollection`, `Sample`,
    `ModelCollection`) keep their existing constructor shape — they pass
    `name` and `interface` positionally to this class, items as `*args`, and
    additional configuration as kwargs.
    """

    def __init__(
        self,
        name: str,
        interface: Any = None,
        *args: Any,
        unique_name: Optional[str] = None,
        populate_if_none: bool = False,
        **kwargs: Any,
    ):
        # `_interface` must exist before `super().__init__` because `EasyList`
        # calls `self.append(item)` for each positional arg, which routes
        # through our `insert` override and reads `self._interface`.
        self._interface = None
        self._name = name
        # Legacy `CollectionBase` accepted items either positionally or as a
        # list-valued keyword (e.g. ``LayerCollection(layers=[a, b])``). Pull
        # any list-valued kwarg into the positional stream so callers using
        # that older pattern keep working.
        extra_items = []
        for key in list(kwargs.keys()):
            if isinstance(kwargs[key], list) and kwargs[key] and key != 'data':
                extra_items.extend(kwargs.pop(key))
        if extra_items:
            args = tuple(args) + tuple(extra_items)
        super().__init__(*args, unique_name=unique_name, **kwargs)
        # `populate_if_none` is a control flag, not state. It is serialized so
        # `from_dict` knows whether the original construction filled in
        # defaults; the value should be `False` once the data is restored.
        self.populate_if_none = populate_if_none

        # Assign interface LAST — propagates to all contained items.
        if interface is not None:
            self.interface = interface

    # ----- name -----

    @property
    def name(self) -> str:
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        self._name = value

    # ----- interface -----

    @property
    def interface(self) -> Any:
        return self._interface

    @interface.setter
    def interface(self, new_interface: Any) -> None:
        self._interface = new_interface
        if new_interface is None:
            return
        # Propagate to existing items.
        for item in self._data:
            if self._has_interface_setter(type(item)):
                item.interface = new_interface
        # Tell the calculator to bind to self (matches the legacy CollectionBase
        # behavior which called `interface.generate_bindings(self)` once per
        # collection).
        if hasattr(new_interface, 'generate_bindings'):
            new_interface.generate_bindings(self)

    def _get_key(self, obj):
        """Use the item's `name` for string-indexed lookups.

        Matches the legacy `CollectionBase.__getitem__` behaviour which
        searched by `item.name`. `EasyList` defaults to `unique_name`; the
        existing `Project` code (and callers) look items up by their pretty
        name (e.g. `materials['Air']`).
        """
        return getattr(obj, 'name', None) or obj.unique_name

    @staticmethod
    def _has_interface_setter(obj_type: type) -> bool:
        for klass in obj_type.__mro__:
            prop = klass.__dict__.get('interface')
            if isinstance(prop, property):
                return prop.fset is not None
        return False

    # ----- mutable-sequence overrides that propagate interface -----

    def insert(self, index: int, value: Any) -> None:
        """Insert and (if an interface is set) propagate it to the new item.

        Legacy `CollectionBase.insert` did `value.interface = self.interface`
        after registering the item; we replicate the same behaviour here so
        downstream calculator state stays in sync when items are appended
        after the collection's interface was already set.

        The type check from `EasyList.insert` is bypassed: each subclass
        accepts a single item type (Layer / Material / BaseAssembly / Model)
        and enforces it elsewhere, while the EasyList check would require
        every item to be a `NewBase` subclass — which was historically not
        guaranteed and forces an extra coupling we don't need.
        """
        if not isinstance(index, int):
            raise TypeError('Index must be an integer')
        # Skip the EasyList protected-types check; mimic the rest of its insert
        # (duplicate-name warning + append-to-_data).
        import warnings as _warnings

        if value in self:
            _warnings.warn(f'Item with unique name "{self._get_key(value)}" already in collection, it will be ignored')
            return
        self._data.insert(index, value)
        if self._interface is not None and self._has_interface_setter(type(value)):
            value.interface = self._interface

    # ----- helpers -----

    @property
    def names(self) -> list:
        """List of item names."""
        return [getattr(item, 'name', None) for item in self._data]

    @property
    def data(self) -> list:
        """Read-only view of the underlying item list.

        Provided for compatibility with code (and tests) written against the
        legacy `CollectionBase` shape, which exposed items via `.data`.
        """
        return list(self._data)

    def move_up(self, index: int) -> None:
        """Move the element at the given index up in the collection."""
        if index == 0:
            return
        self.insert(index - 1, self.pop(index))

    def move_down(self, index: int) -> None:
        """Move the element at the given index down in the collection."""
        if index == len(self) - 1:
            return
        self.insert(index + 1, self.pop(index))

    def remove_at(self, index: int) -> None:
        """Remove the item at *index* from the collection.

        Renamed from the legacy `BaseCollection.remove(index)` which shadowed
        `MutableSequence.remove(value)` (remove-by-value, inherited from
        `EasyList`). Callers that meant "remove by index" should use this; the
        standard `remove(value)` is still available with its usual semantics.
        """
        self.pop(index)

    # ----- compatibility shims (kept until call sites migrate) -----

    def get_parameters(self) -> List[Parameter]:
        """Compatibility alias for legacy callers; prefer `get_all_parameters`."""
        return self.get_all_parameters()

    def get_all_variables(self) -> List:
        """Flat list of every Parameter/Descriptor across all items.

        Walks each item in the collection and unions whatever each item
        exposes via its own `get_all_variables` (for `ModelBase` /
        `BaseCore` children) or, for objects that lack that hook,
        unions any direct `DescriptorBase` attributes.
        """
        from easyscience.variable.descriptor_base import DescriptorBase

        out: list = []
        seen: set[int] = set()
        for item in self._data:
            if hasattr(item, 'get_all_variables'):
                for v in item.get_all_variables():
                    if id(v) not in seen:
                        seen.add(id(v))
                        out.append(v)
            elif isinstance(item, DescriptorBase):
                if id(item) not in seen:
                    seen.add(id(item))
                    out.append(item)
        return out

    def get_all_parameters(self) -> List[Parameter]:
        return [v for v in self.get_all_variables() if isinstance(v, Parameter)]

    def get_free_parameters(self) -> List[Parameter]:
        return [p for p in self.get_all_parameters() if p.independent and not p.fixed]

    def get_fit_parameters(self) -> List[Parameter]:
        """Alias kept for the minimizer; matches `ModelBase.get_fit_parameters`."""
        return self.get_free_parameters()

    def _get_linkable_attributes(self) -> List[Parameter]:
        """Bridge for `easyscience.fitting.calculators.interface_factory.generate_bindings`."""
        return self.get_all_variables()

    # ----- repr -----

    @property
    def _dict_repr(self) -> dict:
        """A simplified dict representation."""
        return {self.name: [getattr(i, '_dict_repr', repr(i)) for i in self._data]}

    def __repr__(self) -> str:
        try:
            return yaml_dump(self._dict_repr)
        except Exception:
            return super().__repr__()

    # ----- serialization -----

    def _convert_to_dict(self, d: dict, encoder=None, skip: Optional[List[str]] = None, **kwargs) -> dict:
        """Serializer hook used when this collection is encoded as a *child*
        attribute (e.g. `Multilayer.layers`).

        `SerializerBase._convert_to_dict` iterates `_arg_spec` to populate the
        base dict and then calls `obj._convert_to_dict(d, ...)` if defined.
        Because `data` is supplied via `*args` (VAR_POSITIONAL — not part of
        `_arg_spec`), without this hook the nested encoding would miss the
        items entirely and round-trip would reconstruct an empty collection.
        """
        if skip is None:
            skip = []
        if self._protected_types != [NewBase] and 'protected_types' not in d:
            d['protected_types'] = [{'@module': c.__module__, '@class': c.__name__} for c in self._protected_types]
        # Encode each item. Defer to the encoder's recursive walk so nested
        # ModelBase / NewBase items get properly serialized.
        item_skip = list(skip)
        items: list = []
        for item in self._data:
            if encoder is not None and hasattr(encoder, '_recursive_encoder'):
                items.append(encoder._recursive_encoder(item, skip=item_skip, encoder=encoder, full_encode=False))
            elif hasattr(item, 'to_dict'):
                try:
                    items.append(item.to_dict(skip=list(item_skip)))
                except TypeError:
                    items.append(item.to_dict())
            else:
                items.append(item)
        d['data'] = items
        return d

    def to_dict(self, skip: Optional[List[str]] = None) -> dict:
        """Serialize with `skip` support; `interface` excluded by default.

        `EasyList.to_dict` doesn't accept a `skip` argument and is hard-wired
        to dump `data` plus the parent's `_arg_spec` view. We reimplement here
        so existing callers (`Project`, `Model.as_dict`, etc.) can keep
        passing `skip=['unique_name']` or similar.

        ``NewBase.to_dict`` mutates the ``skip`` list in-place (e.g. it
        appends ``'unique_name'`` when the collection's own unique_name is
        default-generated). We therefore pass a *copy* to it, otherwise the
        mutation would leak into the per-item serialization below and force
        every item Parameter dict to drop its ``unique_name`` — breaking the
        from_dict round-trip.
        """
        skip = list(skip or [])
        if 'interface' not in skip:
            skip.append('interface')
        # Matches legacy `BasedBase.as_dict`: drop unique_name from the
        # serialized form so nested Parameters don't get explicit names that
        # would collide with the auto-generated names produced when the global
        # counter restarts during reconstruction.
        if 'unique_name' not in skip:
            skip.append('unique_name')
        dict_repr = NewBase.to_dict(self, skip=list(skip))
        if self._protected_types != [NewBase]:
            dict_repr['protected_types'] = [{'@module': c.__module__, '@class': c.__name__} for c in self._protected_types]
        dict_repr['data'] = []
        for item in self._data:
            # Items that are ModelBase / BaseCore subclasses accept `skip`;
            # other shapes use their no-arg `to_dict`/`as_dict`.
            if hasattr(item, 'to_dict'):
                try:
                    dict_repr['data'].append(item.to_dict(skip=list(skip)))
                except TypeError:
                    dict_repr['data'].append(item.to_dict())
            else:
                dict_repr['data'].append(item.as_dict(skip=list(skip)))
        return dict_repr

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

    def __deepcopy__(self, memo):
        """Round-trip via dict-skip-unique to get a fresh copy.

        `NewBase.__copy__` already does this; the override is kept (rather
        than deleted) to mirror the legacy `BaseCollection.__deepcopy__`
        semantics — callers that relied on `copy.deepcopy(collection)` still
        get a clone built from `from_dict(as_dict(skip=['unique_name']))`.
        """
        return self.from_dict(self.as_dict(skip=['unique_name']))

names property

List of item names.

data property

Read-only view of the underlying item list.

Provided for compatibility with code (and tests) written against the legacy CollectionBase shape, which exposed items via .data.

insert(index, value)

Insert and (if an interface is set) propagate it to the new item.

Legacy CollectionBase.insert did value.interface = self.interface after registering the item; we replicate the same behaviour here so downstream calculator state stays in sync when items are appended after the collection's interface was already set.

The type check from EasyList.insert is bypassed: each subclass accepts a single item type (Layer / Material / BaseAssembly / Model) and enforces it elsewhere, while the EasyList check would require every item to be a NewBase subclass — which was historically not guaranteed and forces an extra coupling we don't need.

Source code in src/easyreflectometry/sample/collections/base_collection.py
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
def insert(self, index: int, value: Any) -> None:
    """Insert and (if an interface is set) propagate it to the new item.

    Legacy `CollectionBase.insert` did `value.interface = self.interface`
    after registering the item; we replicate the same behaviour here so
    downstream calculator state stays in sync when items are appended
    after the collection's interface was already set.

    The type check from `EasyList.insert` is bypassed: each subclass
    accepts a single item type (Layer / Material / BaseAssembly / Model)
    and enforces it elsewhere, while the EasyList check would require
    every item to be a `NewBase` subclass — which was historically not
    guaranteed and forces an extra coupling we don't need.
    """
    if not isinstance(index, int):
        raise TypeError('Index must be an integer')
    # Skip the EasyList protected-types check; mimic the rest of its insert
    # (duplicate-name warning + append-to-_data).
    import warnings as _warnings

    if value in self:
        _warnings.warn(f'Item with unique name "{self._get_key(value)}" already in collection, it will be ignored')
        return
    self._data.insert(index, value)
    if self._interface is not None and self._has_interface_setter(type(value)):
        value.interface = self._interface

move_up(index)

Move the element at the given index up in the collection.

Source code in src/easyreflectometry/sample/collections/base_collection.py
167
168
169
170
171
def move_up(self, index: int) -> None:
    """Move the element at the given index up in the collection."""
    if index == 0:
        return
    self.insert(index - 1, self.pop(index))

move_down(index)

Move the element at the given index down in the collection.

Source code in src/easyreflectometry/sample/collections/base_collection.py
173
174
175
176
177
def move_down(self, index: int) -> None:
    """Move the element at the given index down in the collection."""
    if index == len(self) - 1:
        return
    self.insert(index + 1, self.pop(index))

remove_at(index)

Remove the item at index from the collection.

Renamed from the legacy BaseCollection.remove(index) which shadowed MutableSequence.remove(value) (remove-by-value, inherited from EasyList). Callers that meant "remove by index" should use this; the standard remove(value) is still available with its usual semantics.

Source code in src/easyreflectometry/sample/collections/base_collection.py
179
180
181
182
183
184
185
186
187
def remove_at(self, index: int) -> None:
    """Remove the item at *index* from the collection.

    Renamed from the legacy `BaseCollection.remove(index)` which shadowed
    `MutableSequence.remove(value)` (remove-by-value, inherited from
    `EasyList`). Callers that meant "remove by index" should use this; the
    standard `remove(value)` is still available with its usual semantics.
    """
    self.pop(index)

get_parameters()

Compatibility alias for legacy callers; prefer get_all_parameters.

Source code in src/easyreflectometry/sample/collections/base_collection.py
191
192
193
def get_parameters(self) -> List[Parameter]:
    """Compatibility alias for legacy callers; prefer `get_all_parameters`."""
    return self.get_all_parameters()

get_all_variables()

Flat list of every Parameter/Descriptor across all items.

Walks each item in the collection and unions whatever each item exposes via its own get_all_variables (for ModelBase / BaseCore children) or, for objects that lack that hook, unions any direct DescriptorBase attributes.

Source code in src/easyreflectometry/sample/collections/base_collection.py
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def get_all_variables(self) -> List:
    """Flat list of every Parameter/Descriptor across all items.

    Walks each item in the collection and unions whatever each item
    exposes via its own `get_all_variables` (for `ModelBase` /
    `BaseCore` children) or, for objects that lack that hook,
    unions any direct `DescriptorBase` attributes.
    """
    from easyscience.variable.descriptor_base import DescriptorBase

    out: list = []
    seen: set[int] = set()
    for item in self._data:
        if hasattr(item, 'get_all_variables'):
            for v in item.get_all_variables():
                if id(v) not in seen:
                    seen.add(id(v))
                    out.append(v)
        elif isinstance(item, DescriptorBase):
            if id(item) not in seen:
                seen.add(id(item))
                out.append(item)
    return out

get_fit_parameters()

Alias kept for the minimizer; matches ModelBase.get_fit_parameters.

Source code in src/easyreflectometry/sample/collections/base_collection.py
225
226
227
def get_fit_parameters(self) -> List[Parameter]:
    """Alias kept for the minimizer; matches `ModelBase.get_fit_parameters`."""
    return self.get_free_parameters()

to_dict(skip=None)

Serialize with skip support; interface excluded by default.

EasyList.to_dict doesn't accept a skip argument and is hard-wired to dump data plus the parent's _arg_spec view. We reimplement here so existing callers (Project, Model.as_dict, etc.) can keep passing skip=['unique_name'] or similar.

NewBase.to_dict mutates the skip list in-place (e.g. it appends 'unique_name' when the collection's own unique_name is default-generated). We therefore pass a copy to it, otherwise the mutation would leak into the per-item serialization below and force every item Parameter dict to drop its unique_name — breaking the from_dict round-trip.

Source code in src/easyreflectometry/sample/collections/base_collection.py
279
280
281
282
283
284
285
286
287
288
289
290
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
def to_dict(self, skip: Optional[List[str]] = None) -> dict:
    """Serialize with `skip` support; `interface` excluded by default.

    `EasyList.to_dict` doesn't accept a `skip` argument and is hard-wired
    to dump `data` plus the parent's `_arg_spec` view. We reimplement here
    so existing callers (`Project`, `Model.as_dict`, etc.) can keep
    passing `skip=['unique_name']` or similar.

    ``NewBase.to_dict`` mutates the ``skip`` list in-place (e.g. it
    appends ``'unique_name'`` when the collection's own unique_name is
    default-generated). We therefore pass a *copy* to it, otherwise the
    mutation would leak into the per-item serialization below and force
    every item Parameter dict to drop its ``unique_name`` — breaking the
    from_dict round-trip.
    """
    skip = list(skip or [])
    if 'interface' not in skip:
        skip.append('interface')
    # Matches legacy `BasedBase.as_dict`: drop unique_name from the
    # serialized form so nested Parameters don't get explicit names that
    # would collide with the auto-generated names produced when the global
    # counter restarts during reconstruction.
    if 'unique_name' not in skip:
        skip.append('unique_name')
    dict_repr = NewBase.to_dict(self, skip=list(skip))
    if self._protected_types != [NewBase]:
        dict_repr['protected_types'] = [{'@module': c.__module__, '@class': c.__name__} for c in self._protected_types]
    dict_repr['data'] = []
    for item in self._data:
        # Items that are ModelBase / BaseCore subclasses accept `skip`;
        # other shapes use their no-arg `to_dict`/`as_dict`.
        if hasattr(item, 'to_dict'):
            try:
                dict_repr['data'].append(item.to_dict(skip=list(skip)))
            except TypeError:
                dict_repr['data'].append(item.to_dict())
        else:
            dict_repr['data'].append(item.as_dict(skip=list(skip)))
    return dict_repr

as_dict(skip=None)

Compatibility alias for :meth:to_dict.

Source code in src/easyreflectometry/sample/collections/base_collection.py
319
320
321
def as_dict(self, skip: Optional[List[str]] = None) -> dict:
    """Compatibility alias for :meth:`to_dict`."""
    return self.to_dict(skip=skip)

__deepcopy__(memo)

Round-trip via dict-skip-unique to get a fresh copy.

NewBase.__copy__ already does this; the override is kept (rather than deleted) to mirror the legacy BaseCollection.__deepcopy__ semantics — callers that relied on copy.deepcopy(collection) still get a clone built from from_dict(as_dict(skip=['unique_name'])).

Source code in src/easyreflectometry/sample/collections/base_collection.py
323
324
325
326
327
328
329
330
331
def __deepcopy__(self, memo):
    """Round-trip via dict-skip-unique to get a fresh copy.

    `NewBase.__copy__` already does this; the override is kept (rather
    than deleted) to mirror the legacy `BaseCollection.__deepcopy__`
    semantics — callers that relied on `copy.deepcopy(collection)` still
    get a clone built from `from_dict(as_dict(skip=['unique_name']))`.
    """
    return self.from_dict(self.as_dict(skip=['unique_name']))

Project

Source code in src/easyreflectometry/project.py
 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
 286
 287
 288
 289
 290
 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
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 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
 374
 375
 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
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
class Project:
    def __init__(self):
        """Init function."""
        self._info = self._default_info()
        self._path_project_parent = Path(os.path.expanduser('~'))
        self._models = ModelCollection(populate_if_none=False, unique_name='project_models')
        self._materials = MaterialCollection(populate_if_none=False, unique_name='project_materials')
        self._calculator = CalculatorFactory()
        self._experiments: Dict[DataGroup] = {}
        self._fitter: MultiFitter = None
        self._minimizer_selection: AvailableMinimizers = DEFAULT_MINIMIZER
        self._colors: list[str] = None
        self._report = None
        self._q_min: float = None
        self._q_max: float = None
        self._q_resolution: int = None
        self._current_material_index = 0
        self._current_model_index = 0
        self._current_assembly_index = 0
        self._current_layer_index = 0
        self._fitter_model_index = None
        self._current_experiment_index = 0
        self._inequality_constraints: List[InequalitySpec] = []

        # Project flags
        self._created = False
        self._with_experiments = False

    def reset(self):
        """Reset function."""
        del self._models
        del self._materials
        global_object.map._clear()

        self.__init__()

    @property
    def parameters(self) -> List[Parameter]:
        """Get all unique parameters from all models in the project.

        Parameters shared across multiple models (e.g. material SLD) are
        only included once to avoid double-counting.
        """
        parameters = []
        seen_ids: set[int] = set()
        if self._models is not None:
            for model in self._models:
                for param in model.get_all_parameters():
                    pid = id(param)
                    if pid not in seen_ids:
                        seen_ids.add(pid)
                        parameters.append(param)
        return parameters

    def _sync_parameter_states(self) -> None:
        """Apply project-level parameter enablement and default limits.

        Superphase thickness/roughness and subphase thickness are physically
        meaningless and are marked as disabled. Thickness and roughness default
        ranges are then applied only to enabled parameters created from project
        defaults, leaving explicit user-provided bounds untouched.
        """
        if self._models is None:
            return

        disabled_ids: set[int] = set()
        for model in self._models:
            sample = model.sample
            if sample is None or len(sample) == 0:
                continue
            superphase = sample.superphase
            if superphase is not None:
                disabled_ids.add(id(superphase.thickness))
                disabled_ids.add(id(superphase.roughness))
            subphase = sample.subphase
            if subphase is not None:
                disabled_ids.add(id(subphase.thickness))

        for model in self._models:
            sample = model.sample
            if sample is None or len(sample) == 0:
                continue
            for assembly in sample:
                for layer in assembly.layers:
                    self._sync_layer_parameter_state(layer.thickness, 'thickness', disabled_ids)
                    self._sync_layer_parameter_state(layer.roughness, 'roughness', disabled_ids)
                    magnetism = getattr(layer, 'magnetism', None)
                    if magnetism is not None:
                        # Magnetic parameters exist only on magnetic layers, so
                        # they are never in `disabled_ids`; theta_m carries
                        # explicit 0-360 bounds and needs no default window.
                        self._sync_layer_parameter_state(magnetism.rho_m, 'rho_m', disabled_ids)

    def _sync_layer_parameter_state(self, parameter: Parameter, kind: str, disabled_ids: set[int]) -> None:
        """Update a layer parameter's enabled state and pending default limits."""
        if id(parameter) in disabled_ids:
            parameter.enabled = False
            return

        if getattr(parameter, 'default_limits_pending', False):
            delattr(parameter, 'default_limits_pending')
            if getattr(parameter, 'enabled', True):
                parameter.min = -np.inf
                parameter.max = np.inf
                apply_default_limits(parameter, kind)

    @property
    def q_min(self):
        """Q min."""
        if self._q_min is None:
            return Q_MIN
        return self._q_min

    @q_min.setter
    def q_min(self, value: float) -> None:
        """Q min."""
        self._q_min = value

    @property
    def q_max(self):
        """Q max."""
        if self._q_max is None:
            return Q_MAX
        return self._q_max

    @q_max.setter
    def q_max(self, value: float) -> None:
        """Q max."""
        self._q_max = value

    @property
    def q_resolution(self):
        """Q resolution."""
        if self._q_resolution is None:
            return Q_RESOLUTION
        return self._q_resolution

    @q_resolution.setter
    def q_resolution(self, value: int) -> None:
        """Q resolution."""
        self._q_resolution = value

    @property
    def current_material_index(self) -> Optional[int]:
        """Current material index."""
        return self._current_material_index

    @current_material_index.setter
    def current_material_index(self, value: int) -> None:
        """Current material index."""
        if value < 0 or value >= len(self._materials):
            raise ValueError(f'Index {value} out of range')
        if self._current_material_index != value:
            self._current_material_index = value

    @property
    def current_model_index(self) -> Optional[int]:
        """Current model index."""
        return self._current_model_index

    @current_model_index.setter
    def current_model_index(self, value: int) -> None:
        """Current model index."""
        if value < 0 or value >= len(self._models):
            raise ValueError(f'Index {value} out of range')
        if self._current_model_index != value:
            self._current_model_index = value
            self._current_assembly_index = 0
            self._current_layer_index = 0

    @property
    def current_assembly_index(self) -> Optional[int]:
        """Current assembly index."""
        return self._current_assembly_index

    @current_assembly_index.setter
    def current_assembly_index(self, value: int) -> None:
        """Current assembly index."""
        if value < 0 or value >= len(self._models[self._current_model_index].sample):
            raise ValueError(f'Index {value} out of range')
        if self._current_assembly_index != value:
            self._current_assembly_index = value
            self._current_layer_index = 0

    @property
    def current_layer_index(self) -> Optional[int]:
        """Current layer index."""
        return self._current_layer_index

    @current_layer_index.setter
    def current_layer_index(self, value: int) -> None:
        """Current layer index."""
        if value < 0 or value >= len(self._models[self._current_model_index].sample[self._current_assembly_index].layers):
            raise ValueError(f'Index {value} out of range')
        if self._current_layer_index != value:
            self._current_layer_index = value

    @property
    def current_experiment_index(self) -> Optional[int]:
        """Current experiment index."""
        return self._current_experiment_index

    @current_experiment_index.setter
    def current_experiment_index(self, value: int) -> None:
        """Current experiment index."""
        if value < 0 or value >= len(self._experiments):
            raise ValueError(f'Index {value} out of range')
        if self._current_experiment_index != value:
            self._current_experiment_index = value
            # Resetting the model index to 0 when changing the experiment
            # self.current_model_index = 0

    @property
    def created(self) -> bool:
        """Created function."""
        return self._created

    @property
    def path(self):
        """Path function."""
        return self._path_project_parent / self._info['name']

    def set_path_project_parent(self, path: Union[Path, str]):
        """Set path project parent."""
        self._path_project_parent = Path(path)

    @property
    def models(self) -> ModelCollection:
        """Models function."""
        return self._models

    @models.setter
    def models(self, models: ModelCollection) -> None:
        """Models function."""
        self._replace_collection(models, self._models)
        # Use setter to update indicies for current model, assembly and layer
        self.current_model_index = 0
        # Only track materials not already in the project's material collection
        # (e.g. layers built from self._materials, as in default_model(), would
        # otherwise be re-added and trigger a spurious duplicate-item warning).
        new_materials = [material for material in self._get_materials_in_models() if material not in self._materials]
        self._materials.extend(new_materials)
        for model in self._models:
            model.interface = self._calculator
        self._sync_parameter_states()

    @property
    def fitter(self) -> MultiFitter:
        """Fitter function."""
        if len(self._models):
            if (self._fitter is None) or (self._fitter_model_index != self._current_model_index):
                self._fitter = MultiFitter(self._models[self._current_model_index])
                self._fitter.easy_science_multi_fitter.switch_minimizer(self._minimizer_selection)
                self._fitter_model_index = self._current_model_index
                # Fits run through this fitter pick up the project's inequality
                # constraints automatically (resolved at fit time). A weak
                # reference avoids a project -> fitter -> project cycle that
                # would keep a discarded project (and its unique names) alive.
                self._fitter.constraints_factory_provider = _weak_constraints_provider(self)
        return self._fitter

    # ----- structural parameter paths -----

    @staticmethod
    def _child_candidates(obj) -> list:
        """``(token, child)`` pairs to descend into from `obj`."""
        from collections.abc import Sequence

        from easyreflectometry.sample.base_core import BaseCore
        from easyreflectometry.sample.collections.base_collection import BaseCollection

        if isinstance(obj, (BaseCollection, list, tuple)) or (isinstance(obj, Sequence) and not isinstance(obj, str)):
            return [(str(index), item) for index, item in enumerate(obj)]
        if isinstance(obj, BaseCore) or hasattr(obj, 'get_all_parameters'):
            candidates = []
            for attr_name in dir(type(obj)):
                if attr_name.startswith('_') or attr_name in _PATH_SKIPPED_PROPERTIES:
                    continue
                class_attr = getattr(type(obj), attr_name, None)
                if not isinstance(class_attr, property):
                    continue
                try:
                    value = getattr(obj, attr_name)
                except Exception as exception:
                    logger.debug("Skipping property '%s' on %r: %s", attr_name, obj, exception)
                    continue
                if isinstance(value, (DescriptorNumberType, BaseCore, BaseCollection, list, tuple)):
                    candidates.append((attr_name, value))
            return candidates
        return []

    def _walk_parameters(self):
        """Yield ``(structural path, parameter)`` for every parameter under the models.

        Each object is descended into once, so parent back-references cannot
        recurse forever.
        """
        visited: set[int] = set()

        def _walk(obj, tokens: List[str]):
            if isinstance(obj, DescriptorNumberType):
                yield '/'.join(tokens), obj
                return
            if id(obj) in visited:
                return
            visited.add(id(obj))
            for token, child in self._child_candidates(obj):
                yield from _walk(child, tokens + [token])

        yield from _walk(self._models, ['models'])

    def parameter_path(self, parameter) -> Optional[str]:
        """Structural path of `parameter` within this project, e.g.
        ``models/0/sample/1/layers/0/thickness``.

        Paths are stable across save/load (unlike unique names, which are
        regenerated) and are the way inequality constraints reference
        parameters. Returns ``None`` when the parameter is not reachable
        from the project's models.
        """
        return next((path for path, candidate in self._walk_parameters() if candidate is parameter), None)

    def resolve_parameter_path(self, path: str):
        """Return the parameter at a structural `path` (see :meth:`parameter_path`)."""
        tokens = [t for t in str(path).split('/') if t != '']
        if not tokens or tokens[0] != 'models':
            raise KeyError(f"Parameter path must start with 'models': '{path}'.")
        obj = self._models
        for token in tokens[1:]:
            if token.lstrip('-').isdigit():
                try:
                    obj = obj[int(token)]
                except (IndexError, KeyError, TypeError):
                    raise KeyError(f"Parameter path '{path}': index {token} is out of range.") from None
            else:
                if token.startswith('_') or not hasattr(obj, token):
                    raise KeyError(f"Parameter path '{path}': unknown attribute '{token}'.")
                obj = getattr(obj, token)
        if not isinstance(obj, DescriptorNumberType):
            raise KeyError(f"Parameter path '{path}' does not point to a parameter.")
        return obj

    # ----- inequality constraints -----

    @property
    def inequality_constraints(self) -> List[InequalitySpec]:
        """The project's inequality constraints (a copy of the list)."""
        return list(self._inequality_constraints)

    def add_inequality_constraint(self, spec: InequalitySpec, validate: bool = True) -> InequalitySpec:
        """Register an inequality constraint; returns it.

        With ``validate`` the paths are resolved and the units of both sides
        compared, raising ``KeyError``/``ValueError`` on problems.
        """
        if not isinstance(spec, InequalitySpec):
            raise TypeError('spec must be an InequalitySpec')
        if validate:
            check_units(spec, self.resolve_parameter_path)
        self._inequality_constraints.append(spec)
        return spec

    def remove_inequality_constraint(self, which: Union[int, str, InequalitySpec]) -> None:
        """Remove a constraint by index, by name or by identity.

        A name removes **every** spec carrying that name; use the index or
        the spec object to remove a single one when names are shared.
        """
        if isinstance(which, InequalitySpec):
            self._inequality_constraints = [s for s in self._inequality_constraints if s is not which]
            return
        if isinstance(which, str):
            matches = [s for s in self._inequality_constraints if s.name == which]
            if not matches:
                raise KeyError(f"No inequality constraint named '{which}'.")
            for spec in matches:
                self._inequality_constraints.remove(spec)
            return
        del self._inequality_constraints[int(which)]

    def clear_inequality_constraints(self) -> None:
        self._inequality_constraints = []

    def evaluate_inequality_constraints(self) -> List[InequalityEvaluation]:
        """Evaluate every constraint (enabled or not) at the current values."""
        return [evaluate_spec(spec, self.resolve_parameter_path) for spec in self._inequality_constraints]

    def violated_inequality_constraints(self) -> List[InequalitySpec]:
        """Enabled constraints that the *current* parameter values violate.

        A fit started from an infeasible point begins on the BUMPS penalty
        plateau; callers should refuse or warn before launching.
        """
        violated = []
        for spec in self._inequality_constraints:
            if spec.enabled and not evaluate_spec(spec, self.resolve_parameter_path).satisfied:
                violated.append(spec)
        return violated

    def build_constraints_factory(self):
        """``constraints_factory`` hook for the enabled inequality constraints, or ``None``."""
        return build_constraints_factory(self._inequality_constraints, self.resolve_parameter_path)

    # ----- equality constraints (parameter dependencies) -----

    @staticmethod
    def _is_user_constrained(parameter) -> bool:
        """Whether `parameter` carries a constraint this project should persist.

        Both halves are needed: a constraint removed with the raw
        ``make_independent()`` leaves the marker behind, and re-applying it on
        load would resurrect what the user removed.
        """
        return getattr(parameter, USER_CONSTRAINT_FLAG, False) and not parameter.independent

    def _constraint_candidates(self) -> List[Parameter]:
        """Every parameter the project owns, model-reachable or not.

        Deliberately wider than :attr:`parameters`: a constraint on a material
        that no model uses cannot be addressed by a structural path, and has to
        be seen here so that :meth:`_constraint_record` can refuse it out loud
        rather than let it disappear at save time.
        """
        candidates = list(self.parameters)
        seen = {id(parameter) for parameter in candidates}
        if self._materials is not None:
            for parameter in self._materials.get_all_parameters():
                if id(parameter) not in seen:
                    seen.add(id(parameter))
                    candidates.append(parameter)
        return candidates

    def _user_constraints(self) -> List[dict]:
        """Records describing the constraints created via :mod:`easyreflectometry.constraints`.

        Parameters are addressed by structural path rather than by EasyScience
        serializer id: an id is minted lazily when a parameter first gains an
        observer and deleted again when it loses its last one, so it is not a
        durable handle.

        A parameter is recorded only when it is both marked *and* still
        dependent. A constraint removed with the raw ``make_independent()``
        leaves the marker behind, and re-applying that on load would resurrect
        something the user removed. Internal constraints carry no marker at all
        — their owning class rebuilds them in its own ``from_dict``.

        Raises
        ------
        ValueError
            If a constrained parameter, or a live parameter it depends on, is
            not reachable from the models, so no path can address it.
        """
        # Which parameters are constrained is decided from `_constraint_candidates`,
        # which enumerates them without walking properties. The structural walk has
        # to walk properties and leaves reference cycles behind (delaying collection
        # of the project and its unique names), so it runs only when there is
        # something to record, and only to supply the paths. Driving both from
        # one list keeps a constraint from being dropped because the two
        # enumerations disagree.
        constrained = [parameter for parameter in self._constraint_candidates() if self._is_user_constrained(parameter)]
        if not constrained:
            return []
        paths = {id(parameter): path for path, parameter in self._walk_parameters()}
        return [self._constraint_record(parameter, paths) for parameter in constrained]

    @staticmethod
    def _constraint_record(parameter, paths: dict) -> dict:
        """One save record for `parameter`, addressing everything through `paths`."""

        def _addressable(target, described_as: str) -> str:
            path = paths.get(id(target))
            if path is None:
                raise ValueError(
                    f"Cannot save the constraint on '{parameter.name}': {described_as} is not "
                    "reachable from the project's models. Only parameters that belong to a "
                    'model can take part in a saved constraint.'
                )
            return path

        dependencies = {}
        for alias, dependency in parameter._dependency_map.items():
            if isinstance(dependency, Parameter):
                # Embedding a live parameter by value would silently turn a
                # dependency into a frozen constant on load.
                dependencies[alias] = {'path': _addressable(dependency, f"its dependency '{dependency.name}'")}
            else:
                # An object-less constant built for the expression (the explicit
                # total of `constrain_to_sum`); nothing else serializes it, so it
                # is embedded here.
                dependencies[alias] = {
                    'name': dependency.name,
                    'value': float(dependency.value),
                    'unit': str(dependency.unit),
                }
        return {
            'target': _addressable(parameter, 'the parameter itself'),
            'expression': parameter._clean_dependency_string,
            'dependencies': dependencies,
        }

    def _sum_partner_max_backups(self) -> Dict[str, float]:
        """``{structural path: original max}`` for every parameter whose maximum
        :func:`easyreflectometry.constraints.clamp_sum_partners` narrowed.

        The backups live as an attribute on the parameters, which no other
        serialization records; without this, removing a sum constraint after a
        save/load could never widen the bounds back.
        """
        flagged = [parameter for parameter in self.parameters if hasattr(parameter, SUM_PARTNER_MAX_BACKUP)]
        if not flagged:
            return {}
        paths = {id(parameter): path for path, parameter in self._walk_parameters()}
        backups: Dict[str, float] = {}
        for parameter in flagged:
            path = paths.get(id(parameter))
            if path is not None:
                backups[path] = float(getattr(parameter, SUM_PARTNER_MAX_BACKUP))
        return backups

    def _restore_sum_partner_max_backups(self, backups: Dict[str, float]) -> None:
        """Re-attach the backups written by :meth:`_sum_partner_max_backups`."""
        for path, previous in (backups or {}).items():
            try:
                parameter = self.resolve_parameter_path(path)
            except KeyError:
                logger.warning('Cannot restore the bound backup for %r: the parameter no longer exists.', path)
                continue
            setattr(parameter, SUM_PARTNER_MAX_BACKUP, float(previous))

    def _restore_user_constraints(self, records: List[dict]) -> None:
        """Re-apply the constraint records written by :meth:`_user_constraints`.

        Records are applied in the order they were written. A chain
        (``a`` follows ``b`` follows ``c``) resolves whichever order it is
        restored in, because re-constraining a parameter propagates the new
        value to anything already following it.
        """
        for record in records:
            dependencies = {}
            for alias, reference in record['dependencies'].items():
                if 'path' not in reference:
                    dependencies[alias] = DescriptorNumberType(**reference)
                    continue
                try:
                    dependencies[alias] = self.resolve_parameter_path(reference['path'])
                except KeyError as error:
                    raise KeyError(
                        f'Cannot restore the constraint on {record["target"]!r}: its dependency '
                        f'{reference["path"]!r} does not exist in this project.'
                    ) from error
            constrain(self.resolve_parameter_path(record['target']), record['expression'], **dependencies)

    @property
    def calculator(self) -> str:
        """Calculator function."""
        return self._calculator.current_interface_name

    @calculator.setter
    def calculator(self, calculator: str) -> None:
        """Calculator function."""
        if calculator == self._calculator.current_interface_name:
            return

        self._calculator.switch(calculator)
        self._calculator.reset_storage()

        for model in self._models:
            model.generate_bindings()

        # The cached fitter holds fit functions bound to the previous backend.
        self._fitter = None
        self._fitter_model_index = None

    @property
    def calculator_supports_magnetism(self) -> bool:
        """Whether the active calculator can model magnetic samples.

        The GUI uses this to gate magnetism-related controls (e.g. when the
        refnx or bornagain backend is selected).
        """
        return self._calculator().supports_magnetism

    @property
    def calculators_supporting_magnetism(self) -> List[str]:
        """Names of the available calculators that can model magnetic samples.

        Lets an application offer the switch a magnetic sample needs ("this
        requires refl1d — change to it?") instead of only reporting that the
        current calculator cannot do it. The active calculator is not touched.
        """
        supporting = []
        for name in self._calculator.available_interfaces:
            calculator = next(
                (candidate for candidate in CalculatorBase._calculators if candidate.name == name),
                None,
            )
            if calculator is not None and calculator().supports_magnetism:
                supporting.append(name)
        return supporting

    @property
    def models_have_magnetism(self) -> bool:
        """Whether any model in the project carries a magnetic layer.

        A calculator without magnetism cannot be selected while this holds — the
        binding it would have to build does not exist.
        """
        return any(model.has_magnetism for model in self._models)

    @property
    def minimizer(self) -> AvailableMinimizers:
        """Minimizer function."""
        if self._fitter is not None:
            return self._fitter.easy_science_multi_fitter.minimizer.enum
        return self._minimizer_selection

    @minimizer.setter
    def minimizer(self, minimizer: AvailableMinimizers) -> None:
        """Minimizer function."""
        old_name = getattr(self._minimizer_selection, 'name', str(self._minimizer_selection))
        new_name = getattr(minimizer, 'name', str(minimizer))
        logger.info(
            'Minimizer changed from %s to %s (fitter active: %s)',
            old_name,
            new_name,
            self._fitter is not None,
        )
        self._minimizer_selection = minimizer
        if self._fitter is not None:
            self._fitter.easy_science_multi_fitter.switch_minimizer(minimizer)

    @property
    def experiments(self) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]:
        """Experiments function."""
        return self._experiments

    @experiments.setter
    def experiments(self, experiments: Dict[int, Union[DataSet1D, PolarizedDataSet]]) -> None:
        """Experiments function."""
        self._experiments = experiments

    @property
    def path_json(self):
        """Path json."""
        return self.path / 'project.json'

    def get_index_air(self) -> int:
        """Get index air."""
        if 'Air' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='Air', sld=0.0, isld=0.0))
        return [material.name for material in self._materials].index('Air')

    def get_index_si(self) -> int:
        """Get index si."""
        if 'Si' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='Si', sld=2.07, isld=0.0))
        return [material.name for material in self._materials].index('Si')

    def get_index_sio2(self) -> int:
        """Get index sio2."""
        if 'SiO2' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='SiO2', sld=3.47, isld=0.0))
        return [material.name for material in self._materials].index('SiO2')

    def get_index_d2o(self) -> int:
        """Get index d2o."""
        if 'D2O' not in [material.name for material in self._materials]:
            self._materials.add_material(Material(name='D2O', sld=6.36, isld=0.0))
        return [material.name for material in self._materials].index('D2O')

    def load_orso_file(self, path: Union[Path, str]) -> None:
        """Load an ORSO file, creating a model from its ``sample.model`` (when present) and an experiment.

        .. deprecated::
            Use :meth:`load_new_experiment` (data) together with
            :meth:`set_sample_from_orso` /
            :func:`easyreflectometry.orso_utils.load_orso_model` (model)
            instead. This wrapper now routes through the same
            ``DataSet1D`` + title + resolution path as every other importer.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the ORSO file.
        """
        warnings.warn(
            'Project.load_orso_file is deprecated; use load_new_experiment (data) and '
            'set_sample_from_orso/load_orso_model (model) instead.',
            DeprecationWarning,
            stacklevel=2,
        )
        from easyreflectometry.orso_utils import _load_orso_any
        from easyreflectometry.orso_utils import load_orso_model

        orso_data = _load_orso_any(str(path))
        sample = load_orso_model(orso_data)
        if sample is not None:
            self.models = ModelCollection([Model(sample=sample, name=sample.name)])
        else:
            self.default_model()
        self.load_experiment_for_model_at_index(path, 0)

    def set_sample_from_orso(self, sample: Sample) -> None:
        """Replace the current project model collection with a single model built from an ORSO-parsed sample.

        This is a convenience helper for the ORSO import pipeline where a complete
        :class:`~easyreflectometry.sample.Sample` is constructed elsewhere.

        Parameters
        ----------
        sample : Sample
            Sample to set as the project's (single) model.

        Returns
        -------
        None
            ``None``.
        """
        model = Model(sample=sample)
        self.models = ModelCollection([model])

    def add_sample_from_orso(self, sample: Sample) -> None:
        """Add a new model with the given sample to the existing model collection.

        The created model is appended to :attr:`models`, its calculator interface is
        set to the project's current calculator, and any materials referenced in the
        sample are added to the project's material collection.

        After adding the model, :attr:`current_model_index` is updated to point to
        the newly added model.

        Parameters
        ----------
        sample : Sample
            Sample to add as a new model.

        Returns
        -------
        None
            ``None``.
        """
        if sample is None:
            raise ValueError('The ORSO file does not contain a valid sample model definition.')
        # Take the collection's next colour: a supplied model keeps its own
        # colour, and the Model default would give every loaded sample the
        # same first palette colour.
        model = Model(sample=sample, color=self._models.next_color())
        self.models.add_model(model)
        # Set interface after adding to collection
        model.interface = self._calculator
        # Extract materials from the new model and add to project materials
        self._materials.extend(self._get_materials_from_model(model))
        self._sync_parameter_states()
        # Switch to the newly added model so its data is visible in the UI
        self.current_model_index = len(self._models) - 1

    def replace_models_from_orso(self, sample: Sample) -> None:
        """Replace all models and materials with a single model from an ORSO sample.

        All existing models and their associated materials are removed. A new
        model is created from *sample*, assigned to the project's calculator,
        and the material collection is rebuilt from the new model only.

        Parameters
        ----------
        sample : Sample
            Sample to set as the project's only model.

        Returns
        -------
        None
            ``None``.
        """
        if sample is None:
            raise ValueError('The ORSO file does not contain a valid sample model definition.')
        model = Model(sample=sample)
        if sample.name:
            model.user_data['original_name'] = sample.name  # Store original name for reference
        self.models = ModelCollection([model])
        model.interface = self._calculator
        self._materials = self._get_materials_from_model(model)
        self.current_model_index = 0

    def _get_materials_from_model(self, model: Model) -> 'MaterialCollection':
        """Get all materials from a single model's sample."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        for assembly in model.sample:
            for layer in assembly.layers:
                if layer.material not in materials_in_model:
                    materials_in_model.append(layer.material)
        return materials_in_model

    def _apply_experiment_metadata(
        self,
        path: Union[Path, str],
        experiment: DataSet1D,
        fallback_name: str,
        data_group=None,
        data_key: Optional[str] = None,
    ) -> None:
        """Set experiment name from ORSO title and configure the resolution function.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the experiment data file.
        experiment : DataSet1D
            The loaded experiment dataset to configure.
        fallback_name : str
            Name to use when no ORSO title is available.
        data_group :
            Pre-loaded scipp DataGroup (avoids reloading the file). By default, None.
        data_key : Optional[str], optional
            Specific dataset key to use for title extraction (e.g. ``'R_1'``). By default, None.
        """
        # Prefer ORSO title when available (keeps UI descriptive)
        title = None
        try:
            if data_group is None:
                data_group = load_data_from_orso_file(str(path))
            if data_key is None:
                data_key = list(data_group['data'].keys())[0]
            title = extract_orso_title(data_group, data_key)
        except (KeyError, AttributeError, ValueError, IndexError):
            title = None

        if title:
            experiment.name = title
        elif not experiment.name or experiment.name == 'Series':
            experiment.name = fallback_name

    def _apply_resolution_function(
        self,
        experiment: DataSet1D,
        model: Model,
    ) -> None:
        """Set the resolution function on *model* based on variance data in *experiment*.

        Uses the measured per-point q-resolution (``Pointwise``) when the
        experiment carries q-variance data (``xe``, i.e. sQz²); otherwise
        falls back to the default 5% FWHM percentage resolution.

        Parameters
        ----------
        experiment : DataSet1D
            The experiment whose variance data drives the choice.
        model : Model
            The model whose resolution function is set.
        """
        # nan-robust gate: nan is truthy for np.any, but a nan-carrying xe must
        # not build a Pointwise (np.interp would propagate the nan everywhere).
        if experiment.xe is not None and np.any(np.nan_to_num(experiment.xe) > 0):
            model.resolution_function = Pointwise(q_data_points=[experiment.x, experiment.y, experiment.xe])
        else:
            model.resolution_function = PercentageFwhm(5.0)

    @staticmethod
    def _auto_set_background(experiment: DataSet1D) -> None:
        """Set the model background to the minimum y-value of the experiment data."""
        if experiment.model is not None and len(experiment.y) > 0:
            experiment.model.background = max(np.min(experiment.y), 1e-10)

    def load_new_experiment(self, path: Union[Path, str], data_group=None) -> None:
        """Load new experiment.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the experiment data file.
        data_group :
            Pre-loaded scipp DataGroup for *path* (avoids re-parsing the
            file). By default, None.
        """
        if data_group is None:
            data_group = load_measurement_file(str(path))
        new_experiment = load_as_dataset(str(path), data_group=data_group)
        new_index = len(self._experiments)

        model_index = 0
        if new_index < len(self.models):
            model_index = new_index

        self._apply_experiment_metadata(path, new_experiment, f'Experiment {new_index}', data_group=data_group)
        new_experiment.model = self.models[model_index]
        self._auto_set_background(new_experiment)
        self._experiments[new_index] = new_experiment
        self._with_experiments = True
        self._apply_resolution_function(new_experiment, self.models[model_index])

    def count_datasets_in_file(self, path: Union[Path, str]) -> int:
        """Return the number of datasets contained in the file at *path*.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the data file.

        Returns
        -------
        int
            Number of datasets found; 1 if a non-ORSO file cannot be
            introspected. A corrupt ORSO file (banner present but unparsable)
            raises instead of being silently miscounted.
        """
        try:
            data_group = load_measurement_file(str(path))
            return len(data_group['data'])
        except Exception:
            if is_orso_file(str(path)):
                raise
            return 1

    def load_all_experiments_from_file(self, path: Union[Path, str]) -> int:
        """Load all datasets from a file as separate experiments sharing the current model.

        For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is
        registered as an independent experiment.  All experiments share the model that is
        currently selected.  Single-dataset files go through
        :meth:`load_new_experiment`.  A corrupt ORSO file raises (no silent
        plain-text fallback).

        Parameters
        ----------
        path : Union[Path, str]
            Path to the data file.

        Returns
        -------
        int
            Number of experiments that were added.
        """
        data_group = load_measurement_file(str(path))

        data_keys = sorted(data_group['data'].keys())
        if len(data_keys) <= 1:
            self.load_new_experiment(path, data_group=data_group)
            return 1

        model_index = self._current_model_index
        for data_key in data_keys:
            new_index = len(self._experiments)

            new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
            new_experiment.name = f'Experiment {new_index}'
            self._apply_experiment_metadata(
                path,
                new_experiment,
                f'Experiment {new_index}',
                data_group=data_group,
                data_key=data_key,
            )
            new_experiment.model = self.models[model_index]
            self._auto_set_background(new_experiment)
            self._experiments[new_index] = new_experiment
            self._apply_resolution_function(new_experiment, self.models[model_index])

        self._with_experiments = True
        return len(data_keys)

    def suggest_polarized_channel_assignment(self, paths: List[Union[Path, str]]) -> Dict[str, Optional[PolarizationChannel]]:
        """Suggest a spin-channel assignment for a set of data files.

        For each file, the ORSO header polarization is used when present, falling
        back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...).
        Files that cannot be identified map to None; a GUI should present the
        result as an editable file → channel table.

        Parameters
        ----------
        paths : List[Union[Path, str]]
            Paths of the per-channel data files.

        Returns
        -------
        Dict[str, Optional[PolarizationChannel]]
            Detected channel (or None) per path.
        """
        return {str(path): detect_polarization_channel(str(path)) for path in paths}

    def load_polarized_experiment(
        self,
        paths: Dict[Union[PolarizationChannel, str], Union[Path, str]],
        model_index: Optional[int] = None,
    ) -> int:
        """Load a polarized experiment from one data file per spin channel.

        The channel datasets form a single :class:`PolarizedDataSet` experiment
        sharing one model. Two-channel NSF-only experiments simply pass 'pp' and
        'mm' entries; spin-flip channels are optional.

        Parameters
        ----------
        paths : Dict[Union[PolarizationChannel, str], Union[Path, str]]
            Explicit channel → file assignment (e.g. from the GUI import dialog,
            pre-filled via :meth:`suggest_polarized_channel_assignment`).
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.

        Returns
        -------
        int
            Index of the newly loaded experiment, so a caller can make it
            current.
        """
        paths = {PolarizationChannel(channel): path for channel, path in paths.items()}
        channels = {}
        title_data_group = None
        for channel, path in paths.items():
            # Parse each file once: dataset count, data, and title all come
            # from the same DataGroup (previously up to 3-4 parses per file).
            data_group = load_measurement_file(str(path))
            # One file per channel means one dataset per file; a multi-dataset ORSO
            # file has no defined channel-to-dataset assignment here.
            if len(data_group['data']) > 1:
                raise ValueError(
                    f"File '{path}' contains multiple datasets; use load_polarized_experiment_from_file "
                    f'for a single multi-dataset file, or export the {channel.value} channel to its own file.'
                )
            dataset = load_as_dataset(str(path), data_group=data_group)
            # Keep the source file visible per channel (file → channel provenance).
            dataset.name = f'{channel.value}: {Path(path).name}'
            channels[channel] = dataset
            if title_data_group is None:
                title_data_group = data_group

        first_path = next(iter(paths.values()))
        return self._register_polarized_experiment(channels, model_index, str(first_path), title_data_group)

    def load_polarized_experiment_from_file(
        self,
        path: Union[Path, str],
        model_index: Optional[int] = None,
    ) -> int:
        """Load a polarized experiment from a single multi-dataset ORSO file.

        Multi-dataset packing is the ORSO format's intended way to store spin
        states: each ``data_set:`` block is classified by its **own** header
        (``instrument_settings.polarization``), so per-dataset overrides are
        honoured. Only the fully-analysed cross-sections ``pp/pm/mp/mm`` are
        mapped; ``po/mo/op/om/unpolarized/vector`` are not coerced.

        Parameters
        ----------
        path : Union[Path, str]
            Path to the multi-dataset ORSO file.
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.

        Returns
        -------
        int
            Index of the newly loaded experiment.

        Raises
        ------
        ValueError :
            If any dataset lacks a mappable polarization header, or two
            datasets declare the same channel. Such files cannot be classified
            unambiguously; use :meth:`load_all_experiments_from_file` (e.g. for
            multi-angle files) or per-channel files instead.
        """
        from easyreflectometry.orso_utils import _load_orso_any
        from easyreflectometry.orso_utils import _orso_dataset_key
        from easyreflectometry.orso_utils import load_orso_data

        orso_data = _load_orso_any(str(path))
        classified = detect_polarization_channels_per_dataset(orso_data)

        channels = {}
        for i, (declared, channel) in enumerate(classified):
            label = _orso_dataset_key(orso_data[i], i)
            if channel is None:
                reason = 'declares no mappable spin channel' if declared else 'declares no polarization'
                raise ValueError(
                    f"Dataset '{label}' in '{path}' {reason} "
                    f'(only pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized are not coerced). '
                    f'Use load_all_experiments_from_file for non-polarized multi-dataset files, '
                    f'or assign channels explicitly via load_polarized_experiment.'
                )
            if channel in channels:
                raise ValueError(
                    f"Duplicate spin channel '{channel.value}' in '{path}': more than one dataset "
                    f'declares it. Assign channels explicitly via load_polarized_experiment.'
                )
            channels[channel] = label

        data_group = load_orso_data(orso_data)
        channel_datasets = {}
        for channel, label in channels.items():
            dataset = dataset_from_datagroup(data_group, data_key=f'R_{label}')
            dataset.name = f'{channel.value}: {label}'
            channel_datasets[channel] = dataset

        return self._register_polarized_experiment(channel_datasets, model_index, str(path), data_group)

    def _register_polarized_experiment(self, channels, model_index, title_path, title_data_group) -> int:
        """Shared tail of the polarized loaders: build, name, and register the experiment.

        Background and resolution follow the first (in canonical order)
        channel; per-channel resolution functions are not supported (one per
        experiment).

        Parameters
        ----------
        channels :
            Channel → DataSet1D mapping.
        model_index :
            Index of the model, or None for the current one.
        title_path :
            Path used for the fallback experiment title lookup.
        title_data_group :
            Pre-loaded DataGroup for the title lookup (avoids re-parsing).

        Returns
        -------
        int
            Index of the newly loaded experiment.
        """
        new_index = len(self._experiments)
        if model_index is None:
            model_index = self._current_model_index
        model = self.models[model_index]

        experiment = PolarizedDataSet(
            name=f'Polarized experiment {new_index}',
            channels=channels,
            model=model,
        )
        # Name from the ORSO title of the first file, when available.
        self._apply_experiment_metadata(
            title_path, experiment, f'Polarized experiment {new_index}', data_group=title_data_group
        )

        first_dataset = experiment[experiment.available_channels[0]]
        self._auto_set_background(first_dataset)
        self._apply_resolution_function(first_dataset, model)

        self._experiments[new_index] = experiment
        self._with_experiments = True
        return new_index

    def save_experiment_as_orso(self, path: Union[Path, str], index: Optional[int] = None) -> None:
        """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

        The experiment's model (when set) is exported as the ORSO
        ``sample.model`` (slab representation); data columns are written as
        sigma. A polarized experiment becomes one file with one ``data_set:``
        block per spin channel.

        Parameters
        ----------
        path : Union[Path, str]
            Destination path; a ``.orb`` extension selects the binary format.
        index : Optional[int], optional
            Index of the experiment to save. By default, the current one.
        """
        if index is None:
            index = self._current_experiment_index
        experiment = self._experiments[index]
        save_orso_experiment(experiment, str(path), model=experiment.model)

    def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None:
        """Load experiment for model at index."""
        data_group = load_measurement_file(str(path))
        experiment = load_as_dataset(str(path), data_group=data_group)

        self._apply_experiment_metadata(path, experiment, f'Experiment {index}', data_group=data_group)
        experiment.model = self.models[index]
        self._auto_set_background(experiment)
        self._experiments[index] = experiment
        self._with_experiments = True
        self._apply_resolution_function(experiment, self._models[index])

    def _bind_calculator(self, model) -> None:
        """Bind the project's calculator to a model unless it already is.

        Reassigning ``model.interface`` re-propagates the interface over the
        whole sample tree — an expensive rebuild. The plot getters run on every
        chart refresh, so an already-bound model must be left alone; engine
        switches go through the ``calculator`` setter, which regenerates the
        bindings itself.
        """
        if model.interface is not self._calculator:
            model.interface = self._calculator

    def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D:
        """Sld data for model at index."""
        self._bind_calculator(self.models[index])
        sld = self.models[index].interface().sld_profile(self._models[index].unique_name)
        return DataSet1D(
            name=f'SLD for Model {index}',
            x=sld[0],
            y=sld[1],
        )

    def model_has_magnetism_at_index(self, index: int = 0) -> bool:
        """Whether the model at index carries magnetism (False when there is no such model)."""
        try:
            return bool(self.models[index].has_magnetism)
        except (IndexError, AttributeError):
            return False

    def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, DataSet1D]:
        """Nuclear and magnetic depth profiles of a magnetic model.

        Parameters
        ----------
        index : int
            Index of the model.

        Returns
        -------
        Dict[str, DataSet1D]
            Profiles versus depth z, keyed:

            - ``'sld'``: nuclear ρ(z), the same curve as
              :meth:`sld_data_for_model_at_index`;
            - ``'rho_m'``: magnetic SLD ρM(z);
            - ``'theta_m'``: in-plane moment angle θM(z) in degrees, restricted
              to the depths that carry a moment — the angle of a zero-length
              vector is meaningless, so points below
              :data:`MAGNETIC_MOMENT_FLOOR_FRACTION` of the largest ρM are left
              out instead of drawing an arbitrary angle through vacuum;
            - ``'spin_up'`` / ``'spin_down'``: the potentials each spin state
              sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field
              angle :data:`GUIDE_FIELD_ANGLE`.

        Raises
        ------
        ValueError
            The model has no magnetic layer, so there is no magnetic profile.
        NotImplementedError
            The active calculator cannot model magnetism.
        """
        model = self.models[index]
        if not model.has_magnetism:
            raise ValueError(
                f'Model {index} has no magnetic layer; there is no magnetic SLD profile to show. '
                'Attach magnetism to a layer first.'
            )
        self._bind_calculator(model)
        z, sld, rho_m, theta_m = model.interface().magnetic_sld_profile(model.unique_name)
        z = np.asarray(z, dtype=float)
        sld = np.asarray(sld, dtype=float)
        rho_m = np.asarray(rho_m, dtype=float)
        theta_m = np.asarray(theta_m, dtype=float)
        # Component of the moment along the guide field: what the neutron spin
        # states add to / subtract from the nuclear potential.
        projection = rho_m * np.cos(np.radians(theta_m - GUIDE_FIELD_ANGLE))
        # Only report the angle where there is a moment to have an angle, and
        # make it continuous along z: 359° followed by 1° is a 2° turn, but a
        # plotted line through the wrapped values sweeps the whole circle.
        magnitude = np.abs(rho_m)
        has_moment = magnitude > MAGNETIC_MOMENT_FLOOR_FRACTION * magnitude.max(initial=0.0)
        theta_display = _unwrapped_angle(theta_m, has_moment)
        return {
            'sld': DataSet1D(name=f'SLD for Model {index}', x=z, y=sld),
            'rho_m': DataSet1D(name=f'Magnetic SLD for Model {index}', x=z, y=rho_m),
            'theta_m': DataSet1D(name=f'Moment angle for Model {index}', x=z[has_moment], y=theta_display[has_moment]),
            'spin_up': DataSet1D(name=f'Spin-up potential for Model {index}', x=z, y=sld + projection),
            'spin_down': DataSet1D(name=f'Spin-down potential for Model {index}', x=z, y=sld - projection),
        }

    def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
        """Sample data for model at index."""
        original_resolution_function = self.models[index].resolution_function
        self.models[index].resolution_function = PercentageFwhm(0)
        reflectivity_data = self.model_data_for_model_at_index(index, q_range)
        self.models[index].resolution_function = original_resolution_function

        return reflectivity_data

    def model_data_for_model_at_index(
        self,
        index: int = 0,
        q_range: Optional[np.array] = None,
        channel: Optional[Union[PolarizationChannel, str]] = None,
    ) -> DataSet1D:
        """Model data for model at index.

        Parameters
        ----------
        index : int
            Index of the model.
        q_range : Optional[np.array]
            Points to calculate at; the project q range by default.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin cross-section to calculate. `None` (the default) gives the
            ordinary, channel-agnostic reflectivity. A channel requires a
            magnetic model (except 'pp', which falls back to the unpolarized
            calculation) and a calculator supporting magnetism, otherwise the
            calculator raises.
        """
        if q_range is None:
            q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
        self._bind_calculator(self.models[index])
        if channel is None:
            reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name)
            name = f'Reflectivity for Model {index}'
        else:
            channel = PolarizationChannel(channel)
            reflectivity = (
                self.models[index].interface().reflectivity_profile_channel(q_range, self._models[index].unique_name, channel)
            )
            name = f'Reflectivity ({channel.value}) for Model {index}'
        return DataSet1D(
            name=name,
            x=q_range,
            y=reflectivity,
        )

    def experimental_data_for_model_at_index(
        self,
        index: int = 0,
        channel: Optional[Union[PolarizationChannel, str]] = None,
    ) -> Union[DataSet1D, PolarizedDataSet]:
        """Experimental data for model at index.

        Parameters
        ----------
        index : int
            Index of the experiment.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin channel of a polarized experiment. `None` (the default) keeps
            the historical behavior and returns the stored experiment as is: a
            `DataSet1D` for an unpolarized experiment, the whole
            `PolarizedDataSet` for a polarized one.

        Returns
        -------
        Union[DataSet1D, PolarizedDataSet]
            The experiment, or the `DataSet1D` of the requested channel.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        KeyError
            `channel` is a valid spin channel but was not measured.
        ValueError
            `channel` was given for an unpolarized experiment, or is not one of
            'pp', 'pm', 'mp', 'mm'.
        """
        if index not in self._experiments.keys():
            raise IndexError(f'No experiment data for model at index {index}')

        experiment = self._experiments[index]
        if channel is None:
            return experiment

        if not isinstance(experiment, PolarizedDataSet):
            raise ValueError(
                f"Experiment at index {index} is not polarized; it has no '{channel}' channel. "
                'Call without a channel argument to get its data.'
            )
        try:
            channel = PolarizationChannel(channel)
        except ValueError as exception:
            known = ', '.join(member.value for member in PolarizationChannel)
            raise ValueError(f"Unknown spin channel '{channel}'; expected one of {known}.") from exception
        if channel not in experiment:
            measured = ', '.join(member.value for member in experiment.available_channels)
            raise KeyError(f"Channel '{channel.value}' was not measured in experiment {index} (measured: {measured}).")
        return experiment[channel]

    def experiment_is_polarized_at_index(self, index: int = 0) -> bool:
        """Whether the experiment at index holds per-channel (polarized) data.

        Returns False when no experiment is loaded at `index`, so consumers can
        use it as a plain predicate.
        """
        return isinstance(self._experiments.get(index), PolarizedDataSet)

    def experiment_channels_at_index(self, index: int = 0) -> List[PolarizationChannel]:
        """Measured spin channels of the experiment at index ([] when unpolarized)."""
        experiment = self._experiments.get(index)
        if not isinstance(experiment, PolarizedDataSet):
            return []
        return experiment.available_channels

    def experiment_supports_spin_asymmetry_at_index(self, index: int = 0) -> bool:
        """Whether SA can be formed for the experiment at index.

        Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or
        spin-flip-only experiment has no SA. The two channel datasets must also
        be structurally usable (non-empty, matching lengths, a strictly ordered
        q grid) — an application asks this before offering a spin-asymmetry
        view, so a dataset that cannot be turned into one must not be advertised
        and then fail.
        """
        channels = self.experiment_channels_at_index(index)
        if not _has_both_nsf_channels(channels):
            return False
        experiment = self._experiments[index]
        try:
            for channel in (PolarizationChannel.PP, PolarizationChannel.MM):
                _ordered_channel_arrays(experiment[channel])
        except ValueError as exception:
            logger.warning('Experiment %s cannot produce a spin asymmetry: %s', index, exception)
            return False
        return True

    def spin_asymmetry_for_experiment_at_index(self, index: int = 0) -> Dict[str, object]:
        """Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

        The measured SA is formed on the q grid of the pp channel. A mm channel
        measured on a different grid is linearly interpolated onto it — values
        with the usual weights, variances with the *squared* weights, which is
        the propagation rule for independent endpoints (the covariance this
        introduces between neighbouring SA points is not representable in
        `DataSet1D` and is discarded). Interpolation happens **only inside the q
        range both channels cover**: `np.interp` would otherwise clamp to the
        edge value and turn extrapolated points into fabricated measurements.

        Points are dropped when the denominator cannot carry a meaningful
        asymmetry:

        - it is not positive, or is lost to cancellation between the two
          channels (see :data:`SPIN_ASYMMETRY_CANCELLATION_FRACTION`) — this
          guard needs no uncertainties and is what keeps a background-subtracted
          tail from throwing ±10³ values onto the axis;
        - it is not above :data:`SPIN_ASYMMETRY_SIGNIFICANCE` times its own
          uncertainty, when the channels carry usable uncertainties;
        - the point itself is not usable (non-finite reflectivity, or a negative
          or non-finite variance).

        Parameters
        ----------
        index : int
            Index of the experiment.

        Returns
        -------
        Dict[str, object]
            - ``'measured'``: `DataSet1D` of SA versus q. As everywhere in this
              library, ``ye`` holds **variances**, not standard deviations.
            - ``'calculated'``: `DataSet1D` of the model SA on the same q
              points, or None when the model is not magnetic (an unpolarized
              model has SA ≡ 0, which is not worth drawing).
            - ``'masked_points'``: how many measured points were dropped for any
              of the reasons above.
            - ``'low_significance_points'``: of those, how many had a
              denominator below the significance threshold.
            - ``'small_denominator_points'``: of those, how many had a
              denominator that was non-positive or lost to cancellation.
            - ``'invalid_points'``: of those, how many carried a non-finite
              reflectivity or an unusable variance.
            - ``'out_of_overlap_points'``: number of pp points dropped because
              the mm channel does not cover their q.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        ValueError
            The experiment does not carry both pp and mm channels, or their
            datasets are not structurally usable.
        """
        if index not in self._experiments.keys():
            raise IndexError(f'No experiment data for model at index {index}')
        channels = self.experiment_channels_at_index(index)
        if not _has_both_nsf_channels(channels):
            raise ValueError(f'Experiment {index} does not have both non-spin-flip channels; spin asymmetry needs pp and mm.')

        experiment = self._experiments[index]
        q, r_pp, var_pp = _ordered_channel_arrays(experiment[PolarizationChannel.PP])
        q_mm, r_mm_source, var_mm_source = _ordered_channel_arrays(experiment[PolarizationChannel.MM])

        out_of_overlap = 0
        if q.shape == q_mm.shape and np.allclose(q, q_mm, rtol=1e-9, atol=0.0):
            # The usual case: both channels come from one instrument scan.
            # A tolerance keeps grids that only differ by float round-trips
            # (text files) on this path.
            r_mm, var_mm = r_mm_source, var_mm_source
        else:
            # Different grids: put mm on the pp grid, but only where mm has data
            # — np.interp clamps outside its range, which would silently invent
            # measurements at the edges.
            inside = (q >= q_mm.min()) & (q <= q_mm.max())
            out_of_overlap = int(np.count_nonzero(~inside))
            if out_of_overlap:
                logger.warning(
                    'Spin asymmetry of experiment %s: %s of %s pp points lie outside the mm q range '
                    '[%.5g, %.5g] and are dropped.',
                    index,
                    out_of_overlap,
                    q.size,
                    q_mm.min(),
                    q_mm.max(),
                )
            q, r_pp, var_pp = q[inside], r_pp[inside], var_pp[inside]
            r_mm, var_mm = _interpolate_with_variance(q, q_mm, r_mm_source, var_mm_source)

        asymmetry, variance, keep, reasons = _spin_asymmetry(r_pp, r_mm, var_pp, var_mm)
        measured = DataSet1D(
            name=f'Spin asymmetry for Experiment {index}',
            x=q[keep],
            y=asymmetry[keep],
            ye=variance[keep],
            x_label='q (1/angstrom)',
            y_label='Spin asymmetry',
        )

        calculated = None
        model_index = self._model_index_for_experiment(experiment)
        if model_index is not None and self.models[model_index].has_magnetism and measured.x.size > 0:
            calculated_pp = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='pp').y
            calculated_mm = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='mm').y
            calculated_asymmetry, _, _, _ = _spin_asymmetry(calculated_pp, calculated_mm)
            calculated = DataSet1D(
                name=f'Calculated spin asymmetry for Experiment {index}',
                x=measured.x,
                y=calculated_asymmetry,
                x_label='q (1/angstrom)',
                y_label='Spin asymmetry',
            )

        return {
            'measured': measured,
            'calculated': calculated,
            'masked_points': int(np.count_nonzero(~keep)),
            'out_of_overlap_points': out_of_overlap,
            **reasons,
        }

    def _model_index_for_experiment(self, experiment) -> Optional[int]:
        """Index of the model an experiment is bound to, or None."""
        model = getattr(experiment, 'model', None)
        if model is None:
            return None
        for model_index, candidate in enumerate(self._models):
            if candidate is model:
                return model_index
        return None

    def default_model(self):
        """Default model."""
        self._replace_collection(MaterialCollection(interface=self._calculator), self._materials)

        layers = [
            Layer(
                material=self._materials[0],
                thickness=0.0,
                roughness=0.0,
                name='Vacuum Layer',
                interface=self._calculator,
            ),
            Layer(
                material=self._materials[1],
                thickness=100.0,
                roughness=3.0,
                name='D2O Layer',
                interface=self._calculator,
            ),
            Layer(
                material=self._materials[2],
                thickness=0.0,
                roughness=1.2,
                name='Si Layer',
                interface=self._calculator,
            ),
        ]
        assemblies = [
            Multilayer(layers[0], name='Superphase', interface=self._calculator),
            Multilayer(layers[1], name='D2O', interface=self._calculator),
            Multilayer(layers[2], name='Subphase', interface=self._calculator),
        ]
        sample = Sample(*assemblies, interface=self._calculator)
        model = Model(sample=sample, interface=self._calculator)
        model.is_default = True
        self.models = ModelCollection([model])

    def is_default_model(self, index: int) -> bool:
        """Check if the model at the given index is a default model.

        Parameters
        ----------
        index : int
            Index of the model to check.

        Returns
        -------
        bool
            True if the model was created as a default placeholder.
        """
        if index < 0 or index >= len(self._models):
            return False

        return self._models[index].is_default

    def remove_model_at_index(self, index: int) -> None:
        """Remove the model at the given index.

        Removes the model from the model collection, removes the experiment at the
        same index (if any), and reindexes experiments above the removed index so
        model/experiment indices stay aligned.

        Adjusts the current model index if necessary.

        Parameters
        ----------
        index : int
            Index of the model to remove.

        Raises
        ------
        IndexError :
            If the index is out of range.
        ValueError :
            If trying to remove the last remaining model.
        """
        if index < 0 or index >= len(self._models):
            raise IndexError(f'Model index {index} out of range')

        if len(self._models) <= 1:
            raise ValueError('Cannot remove the last model from the project')

        # Remove the model from the collection
        self._models.pop(index)

        # Remove experiment mapped to the removed model index.
        if index in self._experiments:
            self._experiments.pop(index)

        # Reindex experiments above the removed model index to keep mapping aligned.
        reindexed_experiments: dict[int, DataSet1D] = {}
        for exp_index, experiment in sorted(self._experiments.items()):
            if exp_index > index:
                reindexed_experiments[exp_index - 1] = experiment
            else:
                reindexed_experiments[exp_index] = experiment
        self._experiments = reindexed_experiments

        # Adjust current model index if necessary
        if self._current_model_index >= len(self._models):
            self._current_model_index = len(self._models) - 1
        elif self._current_model_index > index:
            self._current_model_index -= 1

        # Reset assembly and layer indices for the new current model
        self._current_assembly_index = 0
        self._current_layer_index = 0

    def add_material(self, material: Material) -> None:
        """Add a material to the project material collection.

        :param material: the material to add.
        :raises ValueError: if the material is already in the collection. The collection is
            left untouched in that case, so a caller cannot mistake a refused add for a
            successful one.
        """
        if material in self._materials:
            raise ValueError(f'Material {material.name} is already in the material collection.')
        self._materials.append(material)

    def remove_material(self, index: int) -> None:
        """Remove the material at *index* from the project material collection.

        :param index: position of the material in the collection.
        :raises IndexError: if there is no material at *index*.
        :raises ValueError: if the material is used by one of the models. Removing it would
            leave the model referring to a material the project no longer holds, so the
            collection is left untouched.
        """
        material = self._materials[index]
        if material in self._get_materials_in_models():
            raise ValueError(f'Material {material.name} is used in models and cannot be removed.')
        self._materials.pop(index)

    def _default_info(self):
        """Default info."""
        return dict(
            name='DefaultEasyReflectometryProject',
            short_description='Reflectometry, 1D',
            modified=datetime.datetime.now().strftime('%d.%m.%Y %H:%M'),
        )

    def create(self):
        """Create the project directory tree on disk.

        :raises FileExistsError: if the project directory already exists. Nothing is written in
            that case and `created` stays False, so a caller cannot mistake a collision for a new
            project.
        """
        if os.path.exists(self.path):
            raise FileExistsError(f'Directory {self.path} already exists. Choose a different name or location.')
        os.makedirs(self.path)
        os.makedirs(self.path / 'experiments')
        self._created = True
        self._timestamp_modification()

    def save_as_json(self, overwrite=False):
        """Save the project as json.

        The write is atomic: the serialized project is written to a temporary file in the
        destination directory and then moved into place with `os.replace`. Any failure while
        serializing or writing therefore leaves a previously saved project file untouched.

        Failures are raised rather than reported on stdout, so that callers (notably the GUI)
        can tell a successful save from a failed one.

        :param overwrite: whether an existing project file may be replaced.
        :raises FileExistsError: if the project file exists and `overwrite` is False.
        :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends
            on a parameter that is not reachable from the models.
        :raises OSError: if the file cannot be written or moved into place.
        """
        if self.path_json.exists() and not overwrite:
            raise FileExistsError(f'File already exists {self.path_json}. Pass overwrite=True to replace it.')
        # Serialize before touching the file system, so that a serialization failure
        # leaves no temporary file behind and no doubt about the existing file.
        try:
            project_json = json.dumps(self.as_dict(include_materials_not_in_model=True), indent=4)
        except TypeError as error:
            # json.dumps reports a value it cannot encode (a stray numpy scalar, say) as a
            # TypeError; to the caller that is the same "cannot be serialized" failure.
            raise ValueError(f'The project cannot be serialized: {error}') from error
        self.path_json.parent.mkdir(exist_ok=True, parents=True)
        # The temporary file must share a directory with the destination, otherwise the
        # replace below is not guaranteed to be atomic (and fails across volumes on Windows).
        # The name is not a dot-file so that a leftover is visible while debugging.
        file_descriptor, temporary_name = tempfile.mkstemp(
            dir=self.path_json.parent, prefix=f'{self.path_json.name}.', suffix='.tmp'
        )
        temporary_path = Path(temporary_name)
        try:
            with os.fdopen(file_descriptor, mode='w') as file:
                file.write(project_json)
                file.flush()
                # Rename without fsync can leave an empty file behind after a power loss.
                os.fsync(file.fileno())
            self._apply_project_file_mode(temporary_path)
            os.replace(temporary_path, self.path_json)
        except Exception:
            temporary_path.unlink(missing_ok=True)
            raise
        logger.debug(f'Saved project to {self.path_json}')

    def _apply_project_file_mode(self, temporary_path: Path) -> None:
        """Give the temporary file the permissions the project file should end up with.

        `tempfile.mkstemp` creates files owner-only (0600) and `os.replace` carries that mode
        onto the destination. Without this step every save on POSIX would strip group and other
        read access, and overwriting a shared 0644 project file would silently make it private.
        An existing file keeps its mode; a new file gets the ordinary umask-derived one.
        """
        if self.path_json.exists():
            mode = stat.S_IMODE(self.path_json.stat().st_mode)
        else:
            umask = os.umask(0)
            os.umask(umask)
            mode = 0o666 & ~umask
        try:
            os.chmod(temporary_path, mode)
        except OSError:
            # Some file systems refuse chmod (network shares, some Windows mounts); the write
            # itself is still fine, so this is not a reason to fail the save.
            logger.debug(f'Could not set the mode of {temporary_path}')

    def load_from_json(self, path: Optional[Union[Path, str]] = None):
        """Load a project file, replacing the current project state.

        :param path: the project file; defaults to this project's own `path_json`.
        :raises FileNotFoundError: if there is no file at `path`. The current project is left
            untouched.
        :raises ValueError: if the file is not valid JSON or predates the supported file format.
        """
        if path is None:
            path = self.path_json
        path = Path(path)
        if not path.exists():
            raise FileNotFoundError(f'File {path} does not exist')
        with open(path, 'r') as file:
            project_dict = json.load(file)
            self.reset()
            self.from_dict(project_dict)
        self._path_project_parent = path.parents[1]
        self._created = True

    #: Schema version embedded in every serialized project. Bumped from 1 → 2
    #: when the sample/model classes migrated from the legacy
    #: ``easyscience.ObjBase``/``CollectionBase`` pipeline to
    #: ``ModelBase``/``EasyList``. The on-disk shape of nested objects (Layer,
    #: Material, MaterialMixture, MaterialSolvated, LayerAreaPerMolecule, etc.)
    #: changed in a way that is not backward-compatible with v1 files.
    FILE_FORMAT = 2

    def as_dict(self, include_materials_not_in_model=False):
        """As dict."""
        project_dict = {}
        project_dict['file_format'] = self.FILE_FORMAT
        project_dict['info'] = self._info
        project_dict['with_experiments'] = self._with_experiments
        if self._models is not None:
            project_dict['models'] = self._models.as_dict()
            project_dict['models']['unique_name'] = self._models.unique_name + '_to_prevent_collisions_on_load'
        if include_materials_not_in_model:
            self._as_dict_add_materials_not_in_model_dict(project_dict)
        if self._with_experiments:
            self._as_dict_add_experiments(project_dict)
        if self.fitter is not None:
            project_dict['fitter_minimizer'] = self.fitter.easy_science_multi_fitter.minimizer.name
        elif self._minimizer_selection is not None:
            project_dict['fitter_minimizer'] = self._minimizer_selection.name
        if self._calculator is not None:
            project_dict['calculator'] = self._calculator.current_interface_name
        if self._colors is not None:
            project_dict['colors'] = self._colors
        if self._inequality_constraints:
            project_dict['inequality_constraints'] = [spec.to_dict() for spec in self._inequality_constraints]
        parameter_constraints = self._user_constraints()
        if parameter_constraints:
            project_dict['parameter_constraints'] = parameter_constraints
        sum_partner_max_backups = self._sum_partner_max_backups()
        if sum_partner_max_backups:
            project_dict['sum_partner_max_backups'] = sum_partner_max_backups
        return project_dict

    def _as_dict_add_materials_not_in_model_dict(self, project_dict: dict):
        """As dict add materials not in model dict."""
        materials_not_in_model = []
        for material in self._materials:
            if material not in self._get_materials_in_models():
                materials_not_in_model.append(material)
        if len(materials_not_in_model) > 0:
            project_dict['materials_not_in_model'] = MaterialCollection(materials_not_in_model).as_dict(skip=['interface'])

    def _as_dict_add_experiments(self, project_dict: dict):
        """As dict add experiments."""
        project_dict['experiments'] = {}
        project_dict['experiments_models'] = {}
        project_dict['experiments_names'] = {}

        for key, experiment in self._experiments.items():
            if isinstance(experiment, PolarizedDataSet):
                self._as_dict_add_polarized_experiment(project_dict, key, experiment)
                continue
            project_dict['experiments'][key] = [
                list(experiment.x),
                list(experiment.y),
                list(experiment.ye),
            ]
            if experiment.xe is not None:
                project_dict['experiments'][key].append(list(experiment.xe))
            project_dict['experiments_models'][key] = experiment.model.name
            project_dict['experiments_names'][key] = experiment.name

    @staticmethod
    def _as_dict_add_polarized_experiment(project_dict: dict, key: int, experiment: PolarizedDataSet) -> None:
        """Serialize a `PolarizedDataSet`: one (name, x, y, ye, xe) array set per measured channel.

        `experiments[key]` is a plain list for an ordinary `DataSet1D` (see
        `_as_dict_add_experiments`); a dict here — tagged `'polarized': True` —
        is how `_from_dict_extract_experiments` tells the two apart on load.
        """
        project_dict['experiments'][key] = {
            'polarized': True,
            'channels': {
                channel.value: [
                    experiment[channel].name,
                    list(experiment[channel].x),
                    list(experiment[channel].y),
                    list(experiment[channel].ye),
                    list(experiment[channel].xe),
                ]
                for channel in experiment.available_channels
            },
        }
        if experiment.model is not None:
            project_dict['experiments_models'][key] = experiment.model.name
            project_dict['experiments_names'][key] = experiment.name

    def from_dict(self, project_dict: dict):
        """From dict."""
        keys = list(project_dict.keys())
        # Validate file format. v1 files were written by the legacy
        # `ObjBase`/`CollectionBase` pipeline; their inner shapes (Layer,
        # Material, MaterialMixture, …) are not compatible with the v2
        # `ModelBase`/`EasyList` deserializer. Older files must be re-created.
        file_format = project_dict.get('file_format')
        if file_format is None:
            raise ValueError(
                'This project file predates file_format=2 and cannot be loaded by '
                'this version of easyreflectometry. The serialization format changed '
                'when the sample/model classes migrated from the legacy ObjBase / '
                'CollectionBase pipeline. Please re-create the project from its '
                'underlying data using the current API.'
            )
        if file_format != self.FILE_FORMAT:
            raise ValueError(
                f'Unsupported project file_format={file_format!r}; this version of '
                f'easyreflectometry only reads file_format={self.FILE_FORMAT}. Please '
                'either update easyreflectometry or re-create the project.'
            )
        self._info = project_dict['info']
        self._with_experiments = project_dict['with_experiments']
        if 'calculator' in keys:
            self._calculator.switch(project_dict['calculator'])
        if 'models' in keys:
            self.models = ModelCollection.from_dict(project_dict['models'])
        self._replace_collection(self._get_materials_in_models(), self._materials)
        if 'materials_not_in_model' in keys:
            self._materials.extend(MaterialCollection.from_dict(project_dict['materials_not_in_model']))
        if 'fitter_minimizer' in keys:
            self.minimizer = AvailableMinimizers[project_dict['fitter_minimizer']]
        else:
            self._fitter = None
        if 'experiments' in keys:
            self._experiments = self._from_dict_extract_experiments(project_dict)
        else:
            self._experiments = {}

        # Resolve any pending parameter dependencies parked by the core
        # deserializer. Only cores that serialize nested dependencies produce
        # them; on the others this is a no-op safety net and `parameter_constraints`
        # below carries the user constraints instead.
        resolve_all_parameter_dependencies(self)
        self._restore_user_constraints(project_dict.get('parameter_constraints', []))
        self._restore_sum_partner_max_backups(project_dict.get('sum_partner_max_backups', {}))
        self._warn_on_unreadable_dependencies(project_dict.get('models'))
        # Inequality constraints are declarative (paths), nothing to resolve yet:
        # they are bound to parameters when a fit starts.
        self._inequality_constraints = [InequalitySpec.from_dict(raw) for raw in project_dict.get('inequality_constraints', [])]

    @staticmethod
    def _warn_on_unreadable_dependencies(models_dict) -> None:
        """Warn about embedded dependencies this build cannot restore.

        A core that serializes dependencies inside each nested parameter writes
        ``_dependency_string`` there. Cores without that feature drop the field
        silently on load, so such a file would lose its equality constraints
        with no signal at all. Detect it and say so; the constraints have to be
        re-applied by hand.

        The field is written for internal dependencies too (material mixtures,
        conformal roughness, ``Model.total_thickness``), and those are rebuilt
        by their owning class regardless — so its presence does not prove
        anything was actually lost. The wording is hedged accordingly.
        """

        def _contains_dependency(node) -> bool:
            if isinstance(node, dict):
                return '_dependency_string' in node or any(_contains_dependency(v) for v in node.values())
            if isinstance(node, (list, tuple)):
                return any(_contains_dependency(item) for item in node)
            return False

        if _contains_dependency(models_dict):
            warnings.warn(
                'This project was saved by a build that stores parameter dependencies inside '
                'each parameter, which this build cannot restore. Internal constraints are '
                'rebuilt automatically, but any custom equality constraints have been dropped '
                'and must be re-applied.',
                stacklevel=2,
            )

    def _from_dict_extract_experiments(self, project_dict: dict) -> Dict[int, Union[DataSet1D, PolarizedDataSet]]:
        """From dict extract experiments."""
        experiments = {}
        for key, raw in project_dict['experiments'].items():
            if isinstance(raw, dict) and raw.get('polarized'):
                experiments[int(key)] = self._polarized_experiment_from_dict(key, raw, project_dict)
                continue
            experiments[int(key)] = DataSet1D(
                name=project_dict['experiments_names'][key],
                x=raw[0],
                y=raw[1],
                ye=raw[2],
                xe=raw[3] if len(raw) > 3 else None,
                model=self._models[project_dict['experiments_models'][key]],
                auto_background=False,
            )
        return experiments

    def _polarized_experiment_from_dict(self, key: str, raw: dict, project_dict: dict) -> PolarizedDataSet:
        """Reconstruct a `PolarizedDataSet` serialized by `_as_dict_add_polarized_experiment`."""
        model = self._models[project_dict['experiments_models'][key]]
        channels = {
            channel_value: DataSet1D(
                name=arrays[0],
                x=arrays[1],
                y=arrays[2],
                ye=arrays[3],
                xe=arrays[4],
                model=model,
                auto_background=False,
            )
            for channel_value, arrays in raw['channels'].items()
        }
        return PolarizedDataSet(
            name=project_dict['experiments_names'][key],
            channels=channels,
            model=model,
        )

    def _get_materials_in_models(self) -> MaterialCollection:
        """Get materials in models."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    materials_in_model.append(layer.material)
        return materials_in_model

    def _replace_collection(self, src_collection: BaseCollection, dst_collection: BaseCollection) -> None:
        """Replace collection."""
        # Clear the destination collection
        for i in range(len(dst_collection)):
            dst_collection.pop(0)

        for element in src_collection:
            dst_collection.append(element)

    def _timestamp_modification(self):
        """Timestamp modification."""
        self._info['modified'] = datetime.datetime.now().strftime('%d.%m.%Y %H:%M')

parameters property

Get all unique parameters from all models in the project.

Parameters shared across multiple models (e.g. material SLD) are only included once to avoid double-counting.

q_min property writable

Q min.

q_max property writable

Q max.

q_resolution property writable

Q resolution.

current_material_index property writable

Current material index.

current_model_index property writable

Current model index.

current_assembly_index property writable

Current assembly index.

current_layer_index property writable

Current layer index.

current_experiment_index property writable

Current experiment index.

created property

Created function.

path property

Path function.

models property writable

Models function.

fitter property

Fitter function.

inequality_constraints property

The project's inequality constraints (a copy of the list).

calculator property writable

Calculator function.

calculator_supports_magnetism property

Whether the active calculator can model magnetic samples.

The GUI uses this to gate magnetism-related controls (e.g. when the refnx or bornagain backend is selected).

calculators_supporting_magnetism property

Names of the available calculators that can model magnetic samples.

Lets an application offer the switch a magnetic sample needs ("this requires refl1d — change to it?") instead of only reporting that the current calculator cannot do it. The active calculator is not touched.

models_have_magnetism property

Whether any model in the project carries a magnetic layer.

A calculator without magnetism cannot be selected while this holds — the binding it would have to build does not exist.

minimizer property writable

Minimizer function.

experiments property writable

Experiments function.

path_json property

Path json.

__init__()

Init function.

Source code in src/easyreflectometry/project.py
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
def __init__(self):
    """Init function."""
    self._info = self._default_info()
    self._path_project_parent = Path(os.path.expanduser('~'))
    self._models = ModelCollection(populate_if_none=False, unique_name='project_models')
    self._materials = MaterialCollection(populate_if_none=False, unique_name='project_materials')
    self._calculator = CalculatorFactory()
    self._experiments: Dict[DataGroup] = {}
    self._fitter: MultiFitter = None
    self._minimizer_selection: AvailableMinimizers = DEFAULT_MINIMIZER
    self._colors: list[str] = None
    self._report = None
    self._q_min: float = None
    self._q_max: float = None
    self._q_resolution: int = None
    self._current_material_index = 0
    self._current_model_index = 0
    self._current_assembly_index = 0
    self._current_layer_index = 0
    self._fitter_model_index = None
    self._current_experiment_index = 0
    self._inequality_constraints: List[InequalitySpec] = []

    # Project flags
    self._created = False
    self._with_experiments = False

reset()

Reset function.

Source code in src/easyreflectometry/project.py
141
142
143
144
145
146
147
def reset(self):
    """Reset function."""
    del self._models
    del self._materials
    global_object.map._clear()

    self.__init__()

set_path_project_parent(path)

Set path project parent.

Source code in src/easyreflectometry/project.py
335
336
337
def set_path_project_parent(self, path: Union[Path, str]):
    """Set path project parent."""
    self._path_project_parent = Path(path)

parameter_path(parameter)

Structural path of parameter within this project, e.g. models/0/sample/1/layers/0/thickness.

Paths are stable across save/load (unlike unique names, which are regenerated) and are the way inequality constraints reference parameters. Returns None when the parameter is not reachable from the project's models.

Source code in src/easyreflectometry/project.py
424
425
426
427
428
429
430
431
432
433
def parameter_path(self, parameter) -> Optional[str]:
    """Structural path of `parameter` within this project, e.g.
    ``models/0/sample/1/layers/0/thickness``.

    Paths are stable across save/load (unlike unique names, which are
    regenerated) and are the way inequality constraints reference
    parameters. Returns ``None`` when the parameter is not reachable
    from the project's models.
    """
    return next((path for path, candidate in self._walk_parameters() if candidate is parameter), None)

resolve_parameter_path(path)

Return the parameter at a structural path (see :meth:parameter_path).

Source code in src/easyreflectometry/project.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def resolve_parameter_path(self, path: str):
    """Return the parameter at a structural `path` (see :meth:`parameter_path`)."""
    tokens = [t for t in str(path).split('/') if t != '']
    if not tokens or tokens[0] != 'models':
        raise KeyError(f"Parameter path must start with 'models': '{path}'.")
    obj = self._models
    for token in tokens[1:]:
        if token.lstrip('-').isdigit():
            try:
                obj = obj[int(token)]
            except (IndexError, KeyError, TypeError):
                raise KeyError(f"Parameter path '{path}': index {token} is out of range.") from None
        else:
            if token.startswith('_') or not hasattr(obj, token):
                raise KeyError(f"Parameter path '{path}': unknown attribute '{token}'.")
            obj = getattr(obj, token)
    if not isinstance(obj, DescriptorNumberType):
        raise KeyError(f"Parameter path '{path}' does not point to a parameter.")
    return obj

add_inequality_constraint(spec, validate=True)

Register an inequality constraint; returns it.

With validate the paths are resolved and the units of both sides compared, raising KeyError/ValueError on problems.

Source code in src/easyreflectometry/project.py
462
463
464
465
466
467
468
469
470
471
472
473
def add_inequality_constraint(self, spec: InequalitySpec, validate: bool = True) -> InequalitySpec:
    """Register an inequality constraint; returns it.

    With ``validate`` the paths are resolved and the units of both sides
    compared, raising ``KeyError``/``ValueError`` on problems.
    """
    if not isinstance(spec, InequalitySpec):
        raise TypeError('spec must be an InequalitySpec')
    if validate:
        check_units(spec, self.resolve_parameter_path)
    self._inequality_constraints.append(spec)
    return spec

remove_inequality_constraint(which)

Remove a constraint by index, by name or by identity.

A name removes every spec carrying that name; use the index or the spec object to remove a single one when names are shared.

Source code in src/easyreflectometry/project.py
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
def remove_inequality_constraint(self, which: Union[int, str, InequalitySpec]) -> None:
    """Remove a constraint by index, by name or by identity.

    A name removes **every** spec carrying that name; use the index or
    the spec object to remove a single one when names are shared.
    """
    if isinstance(which, InequalitySpec):
        self._inequality_constraints = [s for s in self._inequality_constraints if s is not which]
        return
    if isinstance(which, str):
        matches = [s for s in self._inequality_constraints if s.name == which]
        if not matches:
            raise KeyError(f"No inequality constraint named '{which}'.")
        for spec in matches:
            self._inequality_constraints.remove(spec)
        return
    del self._inequality_constraints[int(which)]

evaluate_inequality_constraints()

Evaluate every constraint (enabled or not) at the current values.

Source code in src/easyreflectometry/project.py
496
497
498
def evaluate_inequality_constraints(self) -> List[InequalityEvaluation]:
    """Evaluate every constraint (enabled or not) at the current values."""
    return [evaluate_spec(spec, self.resolve_parameter_path) for spec in self._inequality_constraints]

violated_inequality_constraints()

Enabled constraints that the current parameter values violate.

A fit started from an infeasible point begins on the BUMPS penalty plateau; callers should refuse or warn before launching.

Source code in src/easyreflectometry/project.py
500
501
502
503
504
505
506
507
508
509
510
def violated_inequality_constraints(self) -> List[InequalitySpec]:
    """Enabled constraints that the *current* parameter values violate.

    A fit started from an infeasible point begins on the BUMPS penalty
    plateau; callers should refuse or warn before launching.
    """
    violated = []
    for spec in self._inequality_constraints:
        if spec.enabled and not evaluate_spec(spec, self.resolve_parameter_path).satisfied:
            violated.append(spec)
    return violated

build_constraints_factory()

constraints_factory hook for the enabled inequality constraints, or None.

Source code in src/easyreflectometry/project.py
512
513
514
def build_constraints_factory(self):
    """``constraints_factory`` hook for the enabled inequality constraints, or ``None``."""
    return build_constraints_factory(self._inequality_constraints, self.resolve_parameter_path)

get_index_air()

Get index air.

Source code in src/easyreflectometry/project.py
759
760
761
762
763
def get_index_air(self) -> int:
    """Get index air."""
    if 'Air' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='Air', sld=0.0, isld=0.0))
    return [material.name for material in self._materials].index('Air')

get_index_si()

Get index si.

Source code in src/easyreflectometry/project.py
765
766
767
768
769
def get_index_si(self) -> int:
    """Get index si."""
    if 'Si' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='Si', sld=2.07, isld=0.0))
    return [material.name for material in self._materials].index('Si')

get_index_sio2()

Get index sio2.

Source code in src/easyreflectometry/project.py
771
772
773
774
775
def get_index_sio2(self) -> int:
    """Get index sio2."""
    if 'SiO2' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='SiO2', sld=3.47, isld=0.0))
    return [material.name for material in self._materials].index('SiO2')

get_index_d2o()

Get index d2o.

Source code in src/easyreflectometry/project.py
777
778
779
780
781
def get_index_d2o(self) -> int:
    """Get index d2o."""
    if 'D2O' not in [material.name for material in self._materials]:
        self._materials.add_material(Material(name='D2O', sld=6.36, isld=0.0))
    return [material.name for material in self._materials].index('D2O')

load_orso_file(path)

Load an ORSO file, creating a model from its sample.model (when present) and an experiment.

.. deprecated:: Use :meth:load_new_experiment (data) together with :meth:set_sample_from_orso / :func:easyreflectometry.orso_utils.load_orso_model (model) instead. This wrapper now routes through the same DataSet1D + title + resolution path as every other importer.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the ORSO file.

required
Source code in src/easyreflectometry/project.py
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
def load_orso_file(self, path: Union[Path, str]) -> None:
    """Load an ORSO file, creating a model from its ``sample.model`` (when present) and an experiment.

    .. deprecated::
        Use :meth:`load_new_experiment` (data) together with
        :meth:`set_sample_from_orso` /
        :func:`easyreflectometry.orso_utils.load_orso_model` (model)
        instead. This wrapper now routes through the same
        ``DataSet1D`` + title + resolution path as every other importer.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the ORSO file.
    """
    warnings.warn(
        'Project.load_orso_file is deprecated; use load_new_experiment (data) and '
        'set_sample_from_orso/load_orso_model (model) instead.',
        DeprecationWarning,
        stacklevel=2,
    )
    from easyreflectometry.orso_utils import _load_orso_any
    from easyreflectometry.orso_utils import load_orso_model

    orso_data = _load_orso_any(str(path))
    sample = load_orso_model(orso_data)
    if sample is not None:
        self.models = ModelCollection([Model(sample=sample, name=sample.name)])
    else:
        self.default_model()
    self.load_experiment_for_model_at_index(path, 0)

set_sample_from_orso(sample)

Replace the current project model collection with a single model built from an ORSO-parsed sample.

This is a convenience helper for the ORSO import pipeline where a complete :class:~easyreflectometry.sample.Sample is constructed elsewhere.

Parameters:

Name Type Description Default
sample Sample

Sample to set as the project's (single) model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
def set_sample_from_orso(self, sample: Sample) -> None:
    """Replace the current project model collection with a single model built from an ORSO-parsed sample.

    This is a convenience helper for the ORSO import pipeline where a complete
    :class:`~easyreflectometry.sample.Sample` is constructed elsewhere.

    Parameters
    ----------
    sample : Sample
        Sample to set as the project's (single) model.

    Returns
    -------
    None
        ``None``.
    """
    model = Model(sample=sample)
    self.models = ModelCollection([model])

add_sample_from_orso(sample)

Add a new model with the given sample to the existing model collection.

The created model is appended to :attr:models, its calculator interface is set to the project's current calculator, and any materials referenced in the sample are added to the project's material collection.

After adding the model, :attr:current_model_index is updated to point to the newly added model.

Parameters:

Name Type Description Default
sample Sample

Sample to add as a new model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
def add_sample_from_orso(self, sample: Sample) -> None:
    """Add a new model with the given sample to the existing model collection.

    The created model is appended to :attr:`models`, its calculator interface is
    set to the project's current calculator, and any materials referenced in the
    sample are added to the project's material collection.

    After adding the model, :attr:`current_model_index` is updated to point to
    the newly added model.

    Parameters
    ----------
    sample : Sample
        Sample to add as a new model.

    Returns
    -------
    None
        ``None``.
    """
    if sample is None:
        raise ValueError('The ORSO file does not contain a valid sample model definition.')
    # Take the collection's next colour: a supplied model keeps its own
    # colour, and the Model default would give every loaded sample the
    # same first palette colour.
    model = Model(sample=sample, color=self._models.next_color())
    self.models.add_model(model)
    # Set interface after adding to collection
    model.interface = self._calculator
    # Extract materials from the new model and add to project materials
    self._materials.extend(self._get_materials_from_model(model))
    self._sync_parameter_states()
    # Switch to the newly added model so its data is visible in the UI
    self.current_model_index = len(self._models) - 1

replace_models_from_orso(sample)

Replace all models and materials with a single model from an ORSO sample.

All existing models and their associated materials are removed. A new model is created from sample, assigned to the project's calculator, and the material collection is rebuilt from the new model only.

Parameters:

Name Type Description Default
sample Sample

Sample to set as the project's only model.

required

Returns:

Type Description
None

None.

Source code in src/easyreflectometry/project.py
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
def replace_models_from_orso(self, sample: Sample) -> None:
    """Replace all models and materials with a single model from an ORSO sample.

    All existing models and their associated materials are removed. A new
    model is created from *sample*, assigned to the project's calculator,
    and the material collection is rebuilt from the new model only.

    Parameters
    ----------
    sample : Sample
        Sample to set as the project's only model.

    Returns
    -------
    None
        ``None``.
    """
    if sample is None:
        raise ValueError('The ORSO file does not contain a valid sample model definition.')
    model = Model(sample=sample)
    if sample.name:
        model.user_data['original_name'] = sample.name  # Store original name for reference
    self.models = ModelCollection([model])
    model.interface = self._calculator
    self._materials = self._get_materials_from_model(model)
    self.current_model_index = 0

load_new_experiment(path, data_group=None)

Load new experiment.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the experiment data file.

required
data_group

Pre-loaded scipp DataGroup for path (avoids re-parsing the file). By default, None.

None
Source code in src/easyreflectometry/project.py
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
def load_new_experiment(self, path: Union[Path, str], data_group=None) -> None:
    """Load new experiment.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the experiment data file.
    data_group :
        Pre-loaded scipp DataGroup for *path* (avoids re-parsing the
        file). By default, None.
    """
    if data_group is None:
        data_group = load_measurement_file(str(path))
    new_experiment = load_as_dataset(str(path), data_group=data_group)
    new_index = len(self._experiments)

    model_index = 0
    if new_index < len(self.models):
        model_index = new_index

    self._apply_experiment_metadata(path, new_experiment, f'Experiment {new_index}', data_group=data_group)
    new_experiment.model = self.models[model_index]
    self._auto_set_background(new_experiment)
    self._experiments[new_index] = new_experiment
    self._with_experiments = True
    self._apply_resolution_function(new_experiment, self.models[model_index])

count_datasets_in_file(path)

Return the number of datasets contained in the file at path.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the data file.

required

Returns:

Type Description
int

Number of datasets found; 1 if a non-ORSO file cannot be introspected. A corrupt ORSO file (banner present but unparsable) raises instead of being silently miscounted.

Source code in src/easyreflectometry/project.py
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
def count_datasets_in_file(self, path: Union[Path, str]) -> int:
    """Return the number of datasets contained in the file at *path*.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the data file.

    Returns
    -------
    int
        Number of datasets found; 1 if a non-ORSO file cannot be
        introspected. A corrupt ORSO file (banner present but unparsable)
        raises instead of being silently miscounted.
    """
    try:
        data_group = load_measurement_file(str(path))
        return len(data_group['data'])
    except Exception:
        if is_orso_file(str(path)):
            raise
        return 1

load_all_experiments_from_file(path)

Load all datasets from a file as separate experiments sharing the current model.

For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is registered as an independent experiment. All experiments share the model that is currently selected. Single-dataset files go through :meth:load_new_experiment. A corrupt ORSO file raises (no silent plain-text fallback).

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the data file.

required

Returns:

Type Description
int

Number of experiments that were added.

Source code in src/easyreflectometry/project.py
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
def load_all_experiments_from_file(self, path: Union[Path, str]) -> int:
    """Load all datasets from a file as separate experiments sharing the current model.

    For a multi-dataset ORSO file (e.g. a multi-angle measurement), each dataset is
    registered as an independent experiment.  All experiments share the model that is
    currently selected.  Single-dataset files go through
    :meth:`load_new_experiment`.  A corrupt ORSO file raises (no silent
    plain-text fallback).

    Parameters
    ----------
    path : Union[Path, str]
        Path to the data file.

    Returns
    -------
    int
        Number of experiments that were added.
    """
    data_group = load_measurement_file(str(path))

    data_keys = sorted(data_group['data'].keys())
    if len(data_keys) <= 1:
        self.load_new_experiment(path, data_group=data_group)
        return 1

    model_index = self._current_model_index
    for data_key in data_keys:
        new_index = len(self._experiments)

        new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
        new_experiment.name = f'Experiment {new_index}'
        self._apply_experiment_metadata(
            path,
            new_experiment,
            f'Experiment {new_index}',
            data_group=data_group,
            data_key=data_key,
        )
        new_experiment.model = self.models[model_index]
        self._auto_set_background(new_experiment)
        self._experiments[new_index] = new_experiment
        self._apply_resolution_function(new_experiment, self.models[model_index])

    self._with_experiments = True
    return len(data_keys)

suggest_polarized_channel_assignment(paths)

Suggest a spin-channel assignment for a set of data files.

For each file, the ORSO header polarization is used when present, falling back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...). Files that cannot be identified map to None; a GUI should present the result as an editable file → channel table.

Parameters:

Name Type Description Default
paths List[Union[Path, str]]

Paths of the per-channel data files.

required

Returns:

Type Description
Dict[str, Optional[PolarizationChannel]]

Detected channel (or None) per path.

Source code in src/easyreflectometry/project.py
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
def suggest_polarized_channel_assignment(self, paths: List[Union[Path, str]]) -> Dict[str, Optional[PolarizationChannel]]:
    """Suggest a spin-channel assignment for a set of data files.

    For each file, the ORSO header polarization is used when present, falling
    back to filename heuristics ('_uu'/'_up' → pp, '_dd'/'_down' → mm, ...).
    Files that cannot be identified map to None; a GUI should present the
    result as an editable file → channel table.

    Parameters
    ----------
    paths : List[Union[Path, str]]
        Paths of the per-channel data files.

    Returns
    -------
    Dict[str, Optional[PolarizationChannel]]
        Detected channel (or None) per path.
    """
    return {str(path): detect_polarization_channel(str(path)) for path in paths}

load_polarized_experiment(paths, model_index=None)

Load a polarized experiment from one data file per spin channel.

The channel datasets form a single :class:PolarizedDataSet experiment sharing one model. Two-channel NSF-only experiments simply pass 'pp' and 'mm' entries; spin-flip channels are optional.

Parameters:

Name Type Description Default
paths Dict[Union[PolarizationChannel, str], Union[Path, str]]

Explicit channel → file assignment (e.g. from the GUI import dialog, pre-filled via :meth:suggest_polarized_channel_assignment).

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None

Returns:

Type Description
int

Index of the newly loaded experiment, so a caller can make it current.

Source code in src/easyreflectometry/project.py
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def load_polarized_experiment(
    self,
    paths: Dict[Union[PolarizationChannel, str], Union[Path, str]],
    model_index: Optional[int] = None,
) -> int:
    """Load a polarized experiment from one data file per spin channel.

    The channel datasets form a single :class:`PolarizedDataSet` experiment
    sharing one model. Two-channel NSF-only experiments simply pass 'pp' and
    'mm' entries; spin-flip channels are optional.

    Parameters
    ----------
    paths : Dict[Union[PolarizationChannel, str], Union[Path, str]]
        Explicit channel → file assignment (e.g. from the GUI import dialog,
        pre-filled via :meth:`suggest_polarized_channel_assignment`).
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.

    Returns
    -------
    int
        Index of the newly loaded experiment, so a caller can make it
        current.
    """
    paths = {PolarizationChannel(channel): path for channel, path in paths.items()}
    channels = {}
    title_data_group = None
    for channel, path in paths.items():
        # Parse each file once: dataset count, data, and title all come
        # from the same DataGroup (previously up to 3-4 parses per file).
        data_group = load_measurement_file(str(path))
        # One file per channel means one dataset per file; a multi-dataset ORSO
        # file has no defined channel-to-dataset assignment here.
        if len(data_group['data']) > 1:
            raise ValueError(
                f"File '{path}' contains multiple datasets; use load_polarized_experiment_from_file "
                f'for a single multi-dataset file, or export the {channel.value} channel to its own file.'
            )
        dataset = load_as_dataset(str(path), data_group=data_group)
        # Keep the source file visible per channel (file → channel provenance).
        dataset.name = f'{channel.value}: {Path(path).name}'
        channels[channel] = dataset
        if title_data_group is None:
            title_data_group = data_group

    first_path = next(iter(paths.values()))
    return self._register_polarized_experiment(channels, model_index, str(first_path), title_data_group)

load_polarized_experiment_from_file(path, model_index=None)

Load a polarized experiment from a single multi-dataset ORSO file.

Multi-dataset packing is the ORSO format's intended way to store spin states: each data_set: block is classified by its own header (instrument_settings.polarization), so per-dataset overrides are honoured. Only the fully-analysed cross-sections pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized/vector are not coerced.

Parameters:

Name Type Description Default
path Union[Path, str]

Path to the multi-dataset ORSO file.

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None

Returns:

Type Description
int

Index of the newly loaded experiment.

Raises:

Type Description
ValueError :

If any dataset lacks a mappable polarization header, or two datasets declare the same channel. Such files cannot be classified unambiguously; use :meth:load_all_experiments_from_file (e.g. for multi-angle files) or per-channel files instead.

Source code in src/easyreflectometry/project.py
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
def load_polarized_experiment_from_file(
    self,
    path: Union[Path, str],
    model_index: Optional[int] = None,
) -> int:
    """Load a polarized experiment from a single multi-dataset ORSO file.

    Multi-dataset packing is the ORSO format's intended way to store spin
    states: each ``data_set:`` block is classified by its **own** header
    (``instrument_settings.polarization``), so per-dataset overrides are
    honoured. Only the fully-analysed cross-sections ``pp/pm/mp/mm`` are
    mapped; ``po/mo/op/om/unpolarized/vector`` are not coerced.

    Parameters
    ----------
    path : Union[Path, str]
        Path to the multi-dataset ORSO file.
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.

    Returns
    -------
    int
        Index of the newly loaded experiment.

    Raises
    ------
    ValueError :
        If any dataset lacks a mappable polarization header, or two
        datasets declare the same channel. Such files cannot be classified
        unambiguously; use :meth:`load_all_experiments_from_file` (e.g. for
        multi-angle files) or per-channel files instead.
    """
    from easyreflectometry.orso_utils import _load_orso_any
    from easyreflectometry.orso_utils import _orso_dataset_key
    from easyreflectometry.orso_utils import load_orso_data

    orso_data = _load_orso_any(str(path))
    classified = detect_polarization_channels_per_dataset(orso_data)

    channels = {}
    for i, (declared, channel) in enumerate(classified):
        label = _orso_dataset_key(orso_data[i], i)
        if channel is None:
            reason = 'declares no mappable spin channel' if declared else 'declares no polarization'
            raise ValueError(
                f"Dataset '{label}' in '{path}' {reason} "
                f'(only pp/pm/mp/mm are mapped; po/mo/op/om/unpolarized are not coerced). '
                f'Use load_all_experiments_from_file for non-polarized multi-dataset files, '
                f'or assign channels explicitly via load_polarized_experiment.'
            )
        if channel in channels:
            raise ValueError(
                f"Duplicate spin channel '{channel.value}' in '{path}': more than one dataset "
                f'declares it. Assign channels explicitly via load_polarized_experiment.'
            )
        channels[channel] = label

    data_group = load_orso_data(orso_data)
    channel_datasets = {}
    for channel, label in channels.items():
        dataset = dataset_from_datagroup(data_group, data_key=f'R_{label}')
        dataset.name = f'{channel.value}: {label}'
        channel_datasets[channel] = dataset

    return self._register_polarized_experiment(channel_datasets, model_index, str(path), data_group)

save_experiment_as_orso(path, index=None)

Write an experiment to an ORSO file (.ort text, or .orb binary).

The experiment's model (when set) is exported as the ORSO sample.model (slab representation); data columns are written as sigma. A polarized experiment becomes one file with one data_set: block per spin channel.

Parameters:

Name Type Description Default
path Union[Path, str]

Destination path; a .orb extension selects the binary format.

required
index Optional[int]

Index of the experiment to save. By default, the current one.

None
Source code in src/easyreflectometry/project.py
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
def save_experiment_as_orso(self, path: Union[Path, str], index: Optional[int] = None) -> None:
    """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

    The experiment's model (when set) is exported as the ORSO
    ``sample.model`` (slab representation); data columns are written as
    sigma. A polarized experiment becomes one file with one ``data_set:``
    block per spin channel.

    Parameters
    ----------
    path : Union[Path, str]
        Destination path; a ``.orb`` extension selects the binary format.
    index : Optional[int], optional
        Index of the experiment to save. By default, the current one.
    """
    if index is None:
        index = self._current_experiment_index
    experiment = self._experiments[index]
    save_orso_experiment(experiment, str(path), model=experiment.model)

load_experiment_for_model_at_index(path, index=0)

Load experiment for model at index.

Source code in src/easyreflectometry/project.py
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
def load_experiment_for_model_at_index(self, path: Union[Path, str], index: Optional[int] = 0) -> None:
    """Load experiment for model at index."""
    data_group = load_measurement_file(str(path))
    experiment = load_as_dataset(str(path), data_group=data_group)

    self._apply_experiment_metadata(path, experiment, f'Experiment {index}', data_group=data_group)
    experiment.model = self.models[index]
    self._auto_set_background(experiment)
    self._experiments[index] = experiment
    self._with_experiments = True
    self._apply_resolution_function(experiment, self._models[index])

sld_data_for_model_at_index(index=0)

Sld data for model at index.

Source code in src/easyreflectometry/project.py
1300
1301
1302
1303
1304
1305
1306
1307
1308
def sld_data_for_model_at_index(self, index: int = 0) -> DataSet1D:
    """Sld data for model at index."""
    self._bind_calculator(self.models[index])
    sld = self.models[index].interface().sld_profile(self._models[index].unique_name)
    return DataSet1D(
        name=f'SLD for Model {index}',
        x=sld[0],
        y=sld[1],
    )

model_has_magnetism_at_index(index=0)

Whether the model at index carries magnetism (False when there is no such model).

Source code in src/easyreflectometry/project.py
1310
1311
1312
1313
1314
1315
def model_has_magnetism_at_index(self, index: int = 0) -> bool:
    """Whether the model at index carries magnetism (False when there is no such model)."""
    try:
        return bool(self.models[index].has_magnetism)
    except (IndexError, AttributeError):
        return False

magnetic_sld_data_for_model_at_index(index=0)

Nuclear and magnetic depth profiles of a magnetic model.

Parameters:

Name Type Description Default
index int

Index of the model.

0

Returns:

Type Description
Dict[str, DataSet1D]

Profiles versus depth z, keyed:

  • 'sld': nuclear ρ(z), the same curve as :meth:sld_data_for_model_at_index;
  • 'rho_m': magnetic SLD ρM(z);
  • 'theta_m': in-plane moment angle θM(z) in degrees, restricted to the depths that carry a moment — the angle of a zero-length vector is meaningless, so points below :data:MAGNETIC_MOMENT_FLOOR_FRACTION of the largest ρM are left out instead of drawing an arbitrary angle through vacuum;
  • 'spin_up' / 'spin_down': the potentials each spin state sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field angle :data:GUIDE_FIELD_ANGLE.

Raises:

Type Description
ValueError

The model has no magnetic layer, so there is no magnetic profile.

NotImplementedError

The active calculator cannot model magnetism.

Source code in src/easyreflectometry/project.py
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, DataSet1D]:
    """Nuclear and magnetic depth profiles of a magnetic model.

    Parameters
    ----------
    index : int
        Index of the model.

    Returns
    -------
    Dict[str, DataSet1D]
        Profiles versus depth z, keyed:

        - ``'sld'``: nuclear ρ(z), the same curve as
          :meth:`sld_data_for_model_at_index`;
        - ``'rho_m'``: magnetic SLD ρM(z);
        - ``'theta_m'``: in-plane moment angle θM(z) in degrees, restricted
          to the depths that carry a moment — the angle of a zero-length
          vector is meaningless, so points below
          :data:`MAGNETIC_MOMENT_FLOOR_FRACTION` of the largest ρM are left
          out instead of drawing an arbitrary angle through vacuum;
        - ``'spin_up'`` / ``'spin_down'``: the potentials each spin state
          sees, ρ(z) ± ρM(z)·cos(θM(z) − A), where A is the guide-field
          angle :data:`GUIDE_FIELD_ANGLE`.

    Raises
    ------
    ValueError
        The model has no magnetic layer, so there is no magnetic profile.
    NotImplementedError
        The active calculator cannot model magnetism.
    """
    model = self.models[index]
    if not model.has_magnetism:
        raise ValueError(
            f'Model {index} has no magnetic layer; there is no magnetic SLD profile to show. '
            'Attach magnetism to a layer first.'
        )
    self._bind_calculator(model)
    z, sld, rho_m, theta_m = model.interface().magnetic_sld_profile(model.unique_name)
    z = np.asarray(z, dtype=float)
    sld = np.asarray(sld, dtype=float)
    rho_m = np.asarray(rho_m, dtype=float)
    theta_m = np.asarray(theta_m, dtype=float)
    # Component of the moment along the guide field: what the neutron spin
    # states add to / subtract from the nuclear potential.
    projection = rho_m * np.cos(np.radians(theta_m - GUIDE_FIELD_ANGLE))
    # Only report the angle where there is a moment to have an angle, and
    # make it continuous along z: 359° followed by 1° is a 2° turn, but a
    # plotted line through the wrapped values sweeps the whole circle.
    magnitude = np.abs(rho_m)
    has_moment = magnitude > MAGNETIC_MOMENT_FLOOR_FRACTION * magnitude.max(initial=0.0)
    theta_display = _unwrapped_angle(theta_m, has_moment)
    return {
        'sld': DataSet1D(name=f'SLD for Model {index}', x=z, y=sld),
        'rho_m': DataSet1D(name=f'Magnetic SLD for Model {index}', x=z, y=rho_m),
        'theta_m': DataSet1D(name=f'Moment angle for Model {index}', x=z[has_moment], y=theta_display[has_moment]),
        'spin_up': DataSet1D(name=f'Spin-up potential for Model {index}', x=z, y=sld + projection),
        'spin_down': DataSet1D(name=f'Spin-down potential for Model {index}', x=z, y=sld - projection),
    }

sample_data_for_model_at_index(index=0, q_range=None)

Sample data for model at index.

Source code in src/easyreflectometry/project.py
1378
1379
1380
1381
1382
1383
1384
1385
def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
    """Sample data for model at index."""
    original_resolution_function = self.models[index].resolution_function
    self.models[index].resolution_function = PercentageFwhm(0)
    reflectivity_data = self.model_data_for_model_at_index(index, q_range)
    self.models[index].resolution_function = original_resolution_function

    return reflectivity_data

model_data_for_model_at_index(index=0, q_range=None, channel=None)

Model data for model at index.

Parameters:

Name Type Description Default
index int

Index of the model.

0
q_range Optional[np.array]

Points to calculate at; the project q range by default.

None
channel Optional[Union[PolarizationChannel, str]]

Spin cross-section to calculate. None (the default) gives the ordinary, channel-agnostic reflectivity. A channel requires a magnetic model (except 'pp', which falls back to the unpolarized calculation) and a calculator supporting magnetism, otherwise the calculator raises.

None
Source code in src/easyreflectometry/project.py
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
def model_data_for_model_at_index(
    self,
    index: int = 0,
    q_range: Optional[np.array] = None,
    channel: Optional[Union[PolarizationChannel, str]] = None,
) -> DataSet1D:
    """Model data for model at index.

    Parameters
    ----------
    index : int
        Index of the model.
    q_range : Optional[np.array]
        Points to calculate at; the project q range by default.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin cross-section to calculate. `None` (the default) gives the
        ordinary, channel-agnostic reflectivity. A channel requires a
        magnetic model (except 'pp', which falls back to the unpolarized
        calculation) and a calculator supporting magnetism, otherwise the
        calculator raises.
    """
    if q_range is None:
        q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
    self._bind_calculator(self.models[index])
    if channel is None:
        reflectivity = self.models[index].interface().reflectity_profile(q_range, self._models[index].unique_name)
        name = f'Reflectivity for Model {index}'
    else:
        channel = PolarizationChannel(channel)
        reflectivity = (
            self.models[index].interface().reflectivity_profile_channel(q_range, self._models[index].unique_name, channel)
        )
        name = f'Reflectivity ({channel.value}) for Model {index}'
    return DataSet1D(
        name=name,
        x=q_range,
        y=reflectivity,
    )

experimental_data_for_model_at_index(index=0, channel=None)

Experimental data for model at index.

Parameters:

Name Type Description Default
index int

Index of the experiment.

0
channel Optional[Union[PolarizationChannel, str]]

Spin channel of a polarized experiment. None (the default) keeps the historical behavior and returns the stored experiment as is: a DataSet1D for an unpolarized experiment, the whole PolarizedDataSet for a polarized one.

None

Returns:

Type Description
Union[DataSet1D, PolarizedDataSet]

The experiment, or the DataSet1D of the requested channel.

Raises:

Type Description
IndexError

No experiment is loaded at index.

KeyError

channel is a valid spin channel but was not measured.

ValueError

channel was given for an unpolarized experiment, or is not one of 'pp', 'pm', 'mp', 'mm'.

Source code in src/easyreflectometry/project.py
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
def experimental_data_for_model_at_index(
    self,
    index: int = 0,
    channel: Optional[Union[PolarizationChannel, str]] = None,
) -> Union[DataSet1D, PolarizedDataSet]:
    """Experimental data for model at index.

    Parameters
    ----------
    index : int
        Index of the experiment.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin channel of a polarized experiment. `None` (the default) keeps
        the historical behavior and returns the stored experiment as is: a
        `DataSet1D` for an unpolarized experiment, the whole
        `PolarizedDataSet` for a polarized one.

    Returns
    -------
    Union[DataSet1D, PolarizedDataSet]
        The experiment, or the `DataSet1D` of the requested channel.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    KeyError
        `channel` is a valid spin channel but was not measured.
    ValueError
        `channel` was given for an unpolarized experiment, or is not one of
        'pp', 'pm', 'mp', 'mm'.
    """
    if index not in self._experiments.keys():
        raise IndexError(f'No experiment data for model at index {index}')

    experiment = self._experiments[index]
    if channel is None:
        return experiment

    if not isinstance(experiment, PolarizedDataSet):
        raise ValueError(
            f"Experiment at index {index} is not polarized; it has no '{channel}' channel. "
            'Call without a channel argument to get its data.'
        )
    try:
        channel = PolarizationChannel(channel)
    except ValueError as exception:
        known = ', '.join(member.value for member in PolarizationChannel)
        raise ValueError(f"Unknown spin channel '{channel}'; expected one of {known}.") from exception
    if channel not in experiment:
        measured = ', '.join(member.value for member in experiment.available_channels)
        raise KeyError(f"Channel '{channel.value}' was not measured in experiment {index} (measured: {measured}).")
    return experiment[channel]

experiment_is_polarized_at_index(index=0)

Whether the experiment at index holds per-channel (polarized) data.

Returns False when no experiment is loaded at index, so consumers can use it as a plain predicate.

Source code in src/easyreflectometry/project.py
1480
1481
1482
1483
1484
1485
1486
def experiment_is_polarized_at_index(self, index: int = 0) -> bool:
    """Whether the experiment at index holds per-channel (polarized) data.

    Returns False when no experiment is loaded at `index`, so consumers can
    use it as a plain predicate.
    """
    return isinstance(self._experiments.get(index), PolarizedDataSet)

experiment_channels_at_index(index=0)

Measured spin channels of the experiment at index ([] when unpolarized).

Source code in src/easyreflectometry/project.py
1488
1489
1490
1491
1492
1493
def experiment_channels_at_index(self, index: int = 0) -> List[PolarizationChannel]:
    """Measured spin channels of the experiment at index ([] when unpolarized)."""
    experiment = self._experiments.get(index)
    if not isinstance(experiment, PolarizedDataSet):
        return []
    return experiment.available_channels

experiment_supports_spin_asymmetry_at_index(index=0)

Whether SA can be formed for the experiment at index.

Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or spin-flip-only experiment has no SA. The two channel datasets must also be structurally usable (non-empty, matching lengths, a strictly ordered q grid) — an application asks this before offering a spin-asymmetry view, so a dataset that cannot be turned into one must not be advertised and then fail.

Source code in src/easyreflectometry/project.py
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
def experiment_supports_spin_asymmetry_at_index(self, index: int = 0) -> bool:
    """Whether SA can be formed for the experiment at index.

    Spin asymmetry needs both non-spin-flip channels; an NSF-incomplete or
    spin-flip-only experiment has no SA. The two channel datasets must also
    be structurally usable (non-empty, matching lengths, a strictly ordered
    q grid) — an application asks this before offering a spin-asymmetry
    view, so a dataset that cannot be turned into one must not be advertised
    and then fail.
    """
    channels = self.experiment_channels_at_index(index)
    if not _has_both_nsf_channels(channels):
        return False
    experiment = self._experiments[index]
    try:
        for channel in (PolarizationChannel.PP, PolarizationChannel.MM):
            _ordered_channel_arrays(experiment[channel])
    except ValueError as exception:
        logger.warning('Experiment %s cannot produce a spin asymmetry: %s', index, exception)
        return False
    return True

spin_asymmetry_for_experiment_at_index(index=0)

Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

The measured SA is formed on the q grid of the pp channel. A mm channel measured on a different grid is linearly interpolated onto it — values with the usual weights, variances with the squared weights, which is the propagation rule for independent endpoints (the covariance this introduces between neighbouring SA points is not representable in DataSet1D and is discarded). Interpolation happens only inside the q range both channels cover: np.interp would otherwise clamp to the edge value and turn extrapolated points into fabricated measurements.

Points are dropped when the denominator cannot carry a meaningful asymmetry:

  • it is not positive, or is lost to cancellation between the two channels (see :data:SPIN_ASYMMETRY_CANCELLATION_FRACTION) — this guard needs no uncertainties and is what keeps a background-subtracted tail from throwing ±10³ values onto the axis;
  • it is not above :data:SPIN_ASYMMETRY_SIGNIFICANCE times its own uncertainty, when the channels carry usable uncertainties;
  • the point itself is not usable (non-finite reflectivity, or a negative or non-finite variance).

Parameters:

Name Type Description Default
index int

Index of the experiment.

0

Returns:

Type Description
Dict[str, object]
  • 'measured': DataSet1D of SA versus q. As everywhere in this library, ye holds variances, not standard deviations.
  • 'calculated': DataSet1D of the model SA on the same q points, or None when the model is not magnetic (an unpolarized model has SA ≡ 0, which is not worth drawing).
  • 'masked_points': how many measured points were dropped for any of the reasons above.
  • 'low_significance_points': of those, how many had a denominator below the significance threshold.
  • 'small_denominator_points': of those, how many had a denominator that was non-positive or lost to cancellation.
  • 'invalid_points': of those, how many carried a non-finite reflectivity or an unusable variance.
  • 'out_of_overlap_points': number of pp points dropped because the mm channel does not cover their q.

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment does not carry both pp and mm channels, or their datasets are not structurally usable.

Source code in src/easyreflectometry/project.py
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
def spin_asymmetry_for_experiment_at_index(self, index: int = 0) -> Dict[str, object]:
    """Spin asymmetry SA = (R⁺⁺ − R⁻⁻) / (R⁺⁺ + R⁻⁻) of a polarized experiment.

    The measured SA is formed on the q grid of the pp channel. A mm channel
    measured on a different grid is linearly interpolated onto it — values
    with the usual weights, variances with the *squared* weights, which is
    the propagation rule for independent endpoints (the covariance this
    introduces between neighbouring SA points is not representable in
    `DataSet1D` and is discarded). Interpolation happens **only inside the q
    range both channels cover**: `np.interp` would otherwise clamp to the
    edge value and turn extrapolated points into fabricated measurements.

    Points are dropped when the denominator cannot carry a meaningful
    asymmetry:

    - it is not positive, or is lost to cancellation between the two
      channels (see :data:`SPIN_ASYMMETRY_CANCELLATION_FRACTION`) — this
      guard needs no uncertainties and is what keeps a background-subtracted
      tail from throwing ±10³ values onto the axis;
    - it is not above :data:`SPIN_ASYMMETRY_SIGNIFICANCE` times its own
      uncertainty, when the channels carry usable uncertainties;
    - the point itself is not usable (non-finite reflectivity, or a negative
      or non-finite variance).

    Parameters
    ----------
    index : int
        Index of the experiment.

    Returns
    -------
    Dict[str, object]
        - ``'measured'``: `DataSet1D` of SA versus q. As everywhere in this
          library, ``ye`` holds **variances**, not standard deviations.
        - ``'calculated'``: `DataSet1D` of the model SA on the same q
          points, or None when the model is not magnetic (an unpolarized
          model has SA ≡ 0, which is not worth drawing).
        - ``'masked_points'``: how many measured points were dropped for any
          of the reasons above.
        - ``'low_significance_points'``: of those, how many had a
          denominator below the significance threshold.
        - ``'small_denominator_points'``: of those, how many had a
          denominator that was non-positive or lost to cancellation.
        - ``'invalid_points'``: of those, how many carried a non-finite
          reflectivity or an unusable variance.
        - ``'out_of_overlap_points'``: number of pp points dropped because
          the mm channel does not cover their q.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    ValueError
        The experiment does not carry both pp and mm channels, or their
        datasets are not structurally usable.
    """
    if index not in self._experiments.keys():
        raise IndexError(f'No experiment data for model at index {index}')
    channels = self.experiment_channels_at_index(index)
    if not _has_both_nsf_channels(channels):
        raise ValueError(f'Experiment {index} does not have both non-spin-flip channels; spin asymmetry needs pp and mm.')

    experiment = self._experiments[index]
    q, r_pp, var_pp = _ordered_channel_arrays(experiment[PolarizationChannel.PP])
    q_mm, r_mm_source, var_mm_source = _ordered_channel_arrays(experiment[PolarizationChannel.MM])

    out_of_overlap = 0
    if q.shape == q_mm.shape and np.allclose(q, q_mm, rtol=1e-9, atol=0.0):
        # The usual case: both channels come from one instrument scan.
        # A tolerance keeps grids that only differ by float round-trips
        # (text files) on this path.
        r_mm, var_mm = r_mm_source, var_mm_source
    else:
        # Different grids: put mm on the pp grid, but only where mm has data
        # — np.interp clamps outside its range, which would silently invent
        # measurements at the edges.
        inside = (q >= q_mm.min()) & (q <= q_mm.max())
        out_of_overlap = int(np.count_nonzero(~inside))
        if out_of_overlap:
            logger.warning(
                'Spin asymmetry of experiment %s: %s of %s pp points lie outside the mm q range '
                '[%.5g, %.5g] and are dropped.',
                index,
                out_of_overlap,
                q.size,
                q_mm.min(),
                q_mm.max(),
            )
        q, r_pp, var_pp = q[inside], r_pp[inside], var_pp[inside]
        r_mm, var_mm = _interpolate_with_variance(q, q_mm, r_mm_source, var_mm_source)

    asymmetry, variance, keep, reasons = _spin_asymmetry(r_pp, r_mm, var_pp, var_mm)
    measured = DataSet1D(
        name=f'Spin asymmetry for Experiment {index}',
        x=q[keep],
        y=asymmetry[keep],
        ye=variance[keep],
        x_label='q (1/angstrom)',
        y_label='Spin asymmetry',
    )

    calculated = None
    model_index = self._model_index_for_experiment(experiment)
    if model_index is not None and self.models[model_index].has_magnetism and measured.x.size > 0:
        calculated_pp = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='pp').y
        calculated_mm = self.model_data_for_model_at_index(model_index, q_range=measured.x, channel='mm').y
        calculated_asymmetry, _, _, _ = _spin_asymmetry(calculated_pp, calculated_mm)
        calculated = DataSet1D(
            name=f'Calculated spin asymmetry for Experiment {index}',
            x=measured.x,
            y=calculated_asymmetry,
            x_label='q (1/angstrom)',
            y_label='Spin asymmetry',
        )

    return {
        'measured': measured,
        'calculated': calculated,
        'masked_points': int(np.count_nonzero(~keep)),
        'out_of_overlap_points': out_of_overlap,
        **reasons,
    }

default_model()

Default model.

Source code in src/easyreflectometry/project.py
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
def default_model(self):
    """Default model."""
    self._replace_collection(MaterialCollection(interface=self._calculator), self._materials)

    layers = [
        Layer(
            material=self._materials[0],
            thickness=0.0,
            roughness=0.0,
            name='Vacuum Layer',
            interface=self._calculator,
        ),
        Layer(
            material=self._materials[1],
            thickness=100.0,
            roughness=3.0,
            name='D2O Layer',
            interface=self._calculator,
        ),
        Layer(
            material=self._materials[2],
            thickness=0.0,
            roughness=1.2,
            name='Si Layer',
            interface=self._calculator,
        ),
    ]
    assemblies = [
        Multilayer(layers[0], name='Superphase', interface=self._calculator),
        Multilayer(layers[1], name='D2O', interface=self._calculator),
        Multilayer(layers[2], name='Subphase', interface=self._calculator),
    ]
    sample = Sample(*assemblies, interface=self._calculator)
    model = Model(sample=sample, interface=self._calculator)
    model.is_default = True
    self.models = ModelCollection([model])

is_default_model(index)

Check if the model at the given index is a default model.

Parameters:

Name Type Description Default
index int

Index of the model to check.

required

Returns:

Type Description
bool

True if the model was created as a default placeholder.

Source code in src/easyreflectometry/project.py
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
def is_default_model(self, index: int) -> bool:
    """Check if the model at the given index is a default model.

    Parameters
    ----------
    index : int
        Index of the model to check.

    Returns
    -------
    bool
        True if the model was created as a default placeholder.
    """
    if index < 0 or index >= len(self._models):
        return False

    return self._models[index].is_default

remove_model_at_index(index)

Remove the model at the given index.

Removes the model from the model collection, removes the experiment at the same index (if any), and reindexes experiments above the removed index so model/experiment indices stay aligned.

Adjusts the current model index if necessary.

Parameters:

Name Type Description Default
index int

Index of the model to remove.

required

Raises:

Type Description
IndexError :

If the index is out of range.

ValueError :

If trying to remove the last remaining model.

Source code in src/easyreflectometry/project.py
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
def remove_model_at_index(self, index: int) -> None:
    """Remove the model at the given index.

    Removes the model from the model collection, removes the experiment at the
    same index (if any), and reindexes experiments above the removed index so
    model/experiment indices stay aligned.

    Adjusts the current model index if necessary.

    Parameters
    ----------
    index : int
        Index of the model to remove.

    Raises
    ------
    IndexError :
        If the index is out of range.
    ValueError :
        If trying to remove the last remaining model.
    """
    if index < 0 or index >= len(self._models):
        raise IndexError(f'Model index {index} out of range')

    if len(self._models) <= 1:
        raise ValueError('Cannot remove the last model from the project')

    # Remove the model from the collection
    self._models.pop(index)

    # Remove experiment mapped to the removed model index.
    if index in self._experiments:
        self._experiments.pop(index)

    # Reindex experiments above the removed model index to keep mapping aligned.
    reindexed_experiments: dict[int, DataSet1D] = {}
    for exp_index, experiment in sorted(self._experiments.items()):
        if exp_index > index:
            reindexed_experiments[exp_index - 1] = experiment
        else:
            reindexed_experiments[exp_index] = experiment
    self._experiments = reindexed_experiments

    # Adjust current model index if necessary
    if self._current_model_index >= len(self._models):
        self._current_model_index = len(self._models) - 1
    elif self._current_model_index > index:
        self._current_model_index -= 1

    # Reset assembly and layer indices for the new current model
    self._current_assembly_index = 0
    self._current_layer_index = 0

add_material(material)

Add a material to the project material collection.

:param material: the material to add. :raises ValueError: if the material is already in the collection. The collection is left untouched in that case, so a caller cannot mistake a refused add for a successful one.

Source code in src/easyreflectometry/project.py
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
def add_material(self, material: Material) -> None:
    """Add a material to the project material collection.

    :param material: the material to add.
    :raises ValueError: if the material is already in the collection. The collection is
        left untouched in that case, so a caller cannot mistake a refused add for a
        successful one.
    """
    if material in self._materials:
        raise ValueError(f'Material {material.name} is already in the material collection.')
    self._materials.append(material)

remove_material(index)

Remove the material at index from the project material collection.

:param index: position of the material in the collection. :raises IndexError: if there is no material at index. :raises ValueError: if the material is used by one of the models. Removing it would leave the model referring to a material the project no longer holds, so the collection is left untouched.

Source code in src/easyreflectometry/project.py
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
def remove_material(self, index: int) -> None:
    """Remove the material at *index* from the project material collection.

    :param index: position of the material in the collection.
    :raises IndexError: if there is no material at *index*.
    :raises ValueError: if the material is used by one of the models. Removing it would
        leave the model referring to a material the project no longer holds, so the
        collection is left untouched.
    """
    material = self._materials[index]
    if material in self._get_materials_in_models():
        raise ValueError(f'Material {material.name} is used in models and cannot be removed.')
    self._materials.pop(index)

create()

Create the project directory tree on disk.

:raises FileExistsError: if the project directory already exists. Nothing is written in that case and created stays False, so a caller cannot mistake a collision for a new project.

Source code in src/easyreflectometry/project.py
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
def create(self):
    """Create the project directory tree on disk.

    :raises FileExistsError: if the project directory already exists. Nothing is written in
        that case and `created` stays False, so a caller cannot mistake a collision for a new
        project.
    """
    if os.path.exists(self.path):
        raise FileExistsError(f'Directory {self.path} already exists. Choose a different name or location.')
    os.makedirs(self.path)
    os.makedirs(self.path / 'experiments')
    self._created = True
    self._timestamp_modification()

save_as_json(overwrite=False)

Save the project as json.

The write is atomic: the serialized project is written to a temporary file in the destination directory and then moved into place with os.replace. Any failure while serializing or writing therefore leaves a previously saved project file untouched.

Failures are raised rather than reported on stdout, so that callers (notably the GUI) can tell a successful save from a failed one.

:param overwrite: whether an existing project file may be replaced. :raises FileExistsError: if the project file exists and overwrite is False. :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends on a parameter that is not reachable from the models. :raises OSError: if the file cannot be written or moved into place.

Source code in src/easyreflectometry/project.py
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
def save_as_json(self, overwrite=False):
    """Save the project as json.

    The write is atomic: the serialized project is written to a temporary file in the
    destination directory and then moved into place with `os.replace`. Any failure while
    serializing or writing therefore leaves a previously saved project file untouched.

    Failures are raised rather than reported on stdout, so that callers (notably the GUI)
    can tell a successful save from a failed one.

    :param overwrite: whether an existing project file may be replaced.
    :raises FileExistsError: if the project file exists and `overwrite` is False.
    :raises ValueError: if the project cannot be serialized, e.g. when a constraint depends
        on a parameter that is not reachable from the models.
    :raises OSError: if the file cannot be written or moved into place.
    """
    if self.path_json.exists() and not overwrite:
        raise FileExistsError(f'File already exists {self.path_json}. Pass overwrite=True to replace it.')
    # Serialize before touching the file system, so that a serialization failure
    # leaves no temporary file behind and no doubt about the existing file.
    try:
        project_json = json.dumps(self.as_dict(include_materials_not_in_model=True), indent=4)
    except TypeError as error:
        # json.dumps reports a value it cannot encode (a stray numpy scalar, say) as a
        # TypeError; to the caller that is the same "cannot be serialized" failure.
        raise ValueError(f'The project cannot be serialized: {error}') from error
    self.path_json.parent.mkdir(exist_ok=True, parents=True)
    # The temporary file must share a directory with the destination, otherwise the
    # replace below is not guaranteed to be atomic (and fails across volumes on Windows).
    # The name is not a dot-file so that a leftover is visible while debugging.
    file_descriptor, temporary_name = tempfile.mkstemp(
        dir=self.path_json.parent, prefix=f'{self.path_json.name}.', suffix='.tmp'
    )
    temporary_path = Path(temporary_name)
    try:
        with os.fdopen(file_descriptor, mode='w') as file:
            file.write(project_json)
            file.flush()
            # Rename without fsync can leave an empty file behind after a power loss.
            os.fsync(file.fileno())
        self._apply_project_file_mode(temporary_path)
        os.replace(temporary_path, self.path_json)
    except Exception:
        temporary_path.unlink(missing_ok=True)
        raise
    logger.debug(f'Saved project to {self.path_json}')

load_from_json(path=None)

Load a project file, replacing the current project state.

:param path: the project file; defaults to this project's own path_json. :raises FileNotFoundError: if there is no file at path. The current project is left untouched. :raises ValueError: if the file is not valid JSON or predates the supported file format.

Source code in src/easyreflectometry/project.py
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
def load_from_json(self, path: Optional[Union[Path, str]] = None):
    """Load a project file, replacing the current project state.

    :param path: the project file; defaults to this project's own `path_json`.
    :raises FileNotFoundError: if there is no file at `path`. The current project is left
        untouched.
    :raises ValueError: if the file is not valid JSON or predates the supported file format.
    """
    if path is None:
        path = self.path_json
    path = Path(path)
    if not path.exists():
        raise FileNotFoundError(f'File {path} does not exist')
    with open(path, 'r') as file:
        project_dict = json.load(file)
        self.reset()
        self.from_dict(project_dict)
    self._path_project_parent = path.parents[1]
    self._created = True

as_dict(include_materials_not_in_model=False)

As dict.

Source code in src/easyreflectometry/project.py
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
def as_dict(self, include_materials_not_in_model=False):
    """As dict."""
    project_dict = {}
    project_dict['file_format'] = self.FILE_FORMAT
    project_dict['info'] = self._info
    project_dict['with_experiments'] = self._with_experiments
    if self._models is not None:
        project_dict['models'] = self._models.as_dict()
        project_dict['models']['unique_name'] = self._models.unique_name + '_to_prevent_collisions_on_load'
    if include_materials_not_in_model:
        self._as_dict_add_materials_not_in_model_dict(project_dict)
    if self._with_experiments:
        self._as_dict_add_experiments(project_dict)
    if self.fitter is not None:
        project_dict['fitter_minimizer'] = self.fitter.easy_science_multi_fitter.minimizer.name
    elif self._minimizer_selection is not None:
        project_dict['fitter_minimizer'] = self._minimizer_selection.name
    if self._calculator is not None:
        project_dict['calculator'] = self._calculator.current_interface_name
    if self._colors is not None:
        project_dict['colors'] = self._colors
    if self._inequality_constraints:
        project_dict['inequality_constraints'] = [spec.to_dict() for spec in self._inequality_constraints]
    parameter_constraints = self._user_constraints()
    if parameter_constraints:
        project_dict['parameter_constraints'] = parameter_constraints
    sum_partner_max_backups = self._sum_partner_max_backups()
    if sum_partner_max_backups:
        project_dict['sum_partner_max_backups'] = sum_partner_max_backups
    return project_dict

from_dict(project_dict)

From dict.

Source code in src/easyreflectometry/project.py
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
def from_dict(self, project_dict: dict):
    """From dict."""
    keys = list(project_dict.keys())
    # Validate file format. v1 files were written by the legacy
    # `ObjBase`/`CollectionBase` pipeline; their inner shapes (Layer,
    # Material, MaterialMixture, …) are not compatible with the v2
    # `ModelBase`/`EasyList` deserializer. Older files must be re-created.
    file_format = project_dict.get('file_format')
    if file_format is None:
        raise ValueError(
            'This project file predates file_format=2 and cannot be loaded by '
            'this version of easyreflectometry. The serialization format changed '
            'when the sample/model classes migrated from the legacy ObjBase / '
            'CollectionBase pipeline. Please re-create the project from its '
            'underlying data using the current API.'
        )
    if file_format != self.FILE_FORMAT:
        raise ValueError(
            f'Unsupported project file_format={file_format!r}; this version of '
            f'easyreflectometry only reads file_format={self.FILE_FORMAT}. Please '
            'either update easyreflectometry or re-create the project.'
        )
    self._info = project_dict['info']
    self._with_experiments = project_dict['with_experiments']
    if 'calculator' in keys:
        self._calculator.switch(project_dict['calculator'])
    if 'models' in keys:
        self.models = ModelCollection.from_dict(project_dict['models'])
    self._replace_collection(self._get_materials_in_models(), self._materials)
    if 'materials_not_in_model' in keys:
        self._materials.extend(MaterialCollection.from_dict(project_dict['materials_not_in_model']))
    if 'fitter_minimizer' in keys:
        self.minimizer = AvailableMinimizers[project_dict['fitter_minimizer']]
    else:
        self._fitter = None
    if 'experiments' in keys:
        self._experiments = self._from_dict_extract_experiments(project_dict)
    else:
        self._experiments = {}

    # Resolve any pending parameter dependencies parked by the core
    # deserializer. Only cores that serialize nested dependencies produce
    # them; on the others this is a no-op safety net and `parameter_constraints`
    # below carries the user constraints instead.
    resolve_all_parameter_dependencies(self)
    self._restore_user_constraints(project_dict.get('parameter_constraints', []))
    self._restore_sum_partner_max_backups(project_dict.get('sum_partner_max_backups', {}))
    self._warn_on_unreadable_dependencies(project_dict.get('models'))
    # Inequality constraints are declarative (paths), nothing to resolve yet:
    # they are bound to parameters when a fit starts.
    self._inequality_constraints = [InequalitySpec.from_dict(raw) for raw in project_dict.get('inequality_constraints', [])]

constrain(parameter, expression, **parameters)

Tie parameter to an arbitrary expression of other parameters.

Placeholders in expression are supplied as keyword arguments:

.. code-block:: python

constrain(layer_b.thickness, '2 * t', t=layer_a.thickness)

parameter becomes dependent; its value, unit, variance, min and max are replaced by the evaluated expression and its fixed flag is cleared. Placeholder names must be valid Python identifiers and not Python keywords. An unmapped name in the expression that matches a mathematical builtin (e, pi, sin, ...) evaluates silently instead of raising NameError, so prefer descriptive placeholder names.

Parameters:

Name Type Description Default
parameter Parameter

Parameter to make dependent (the follower).

required
expression str

Mathematical expression to evaluate, e.g. '2 * t'.

required
parameters DescriptorNumber

Placeholder-name to parameter mapping for expression.

{}
Source code in src/easyreflectometry/constraints.py
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
def constrain(parameter: Parameter, expression: str, **parameters: DescriptorNumber) -> None:
    """Tie `parameter` to an arbitrary expression of other parameters.

    Placeholders in `expression` are supplied as keyword arguments:

    .. code-block:: python

        constrain(layer_b.thickness, '2 * t', t=layer_a.thickness)

    `parameter` becomes dependent; its value, unit, variance, min and max
    are replaced by the evaluated expression and its `fixed` flag is
    cleared. Placeholder names must be valid Python identifiers and not
    Python keywords. An unmapped name in the expression that matches a
    mathematical builtin (`e`, `pi`, `sin`, ...) evaluates silently
    instead of raising `NameError`, so prefer descriptive placeholder
    names.

    Parameters
    ----------
    parameter : Parameter
        Parameter to make dependent (the follower).
    expression : str
        Mathematical expression to evaluate, e.g. ``'2 * t'``.
    parameters : DescriptorNumber
        Placeholder-name to parameter mapping for `expression`.
    """
    parameter.make_dependent_on(dependency_expression=expression, dependency_map=parameters)
    setattr(parameter, USER_CONSTRAINT_FLAG, True)

detect_polarization_channel(path)

Detect the spin channel of a data file.

Tries the ORSO header (data_source.measurement.instrument_settings.polarization) first, then filename heuristics ('_uu'/'_pp'/'_up' → pp, '_dd'/'_mm'/'_down' → mm, '_ud'/'_pm' → pm, '_du'/'_mp' → mp, ...). Returns None when neither yields a channel; a GUI should then ask the user to assign one.

Parameters:

Name Type Description Default
path str

Path to the data file.

required

Returns:

Type Description
Optional[PolarizationChannel]

The detected channel, or None.

Source code in src/easyreflectometry/data/polarized.py
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
def detect_polarization_channel(path: str) -> Optional[PolarizationChannel]:
    """Detect the spin channel of a data file.

    Tries the ORSO header (`data_source.measurement.instrument_settings.polarization`)
    first, then filename heuristics ('_uu'/'_pp'/'_up' → pp, '_dd'/'_mm'/'_down' → mm,
    '_ud'/'_pm' → pm, '_du'/'_mp' → mp, ...). Returns None when neither yields a
    channel; a GUI should then ask the user to assign one.

    Parameters
    ----------
    path : str
        Path to the data file.

    Returns
    -------
    Optional[PolarizationChannel]
        The detected channel, or None.
    """
    header_declared, channel = _channel_from_orso_header(path)
    if channel is not None:
        return channel
    if header_declared:
        # The header explicitly declares a non-channel polarization (e.g.
        # 'unpolarized'): trust it over any channel-looking filename tokens.
        return None
    return _channel_from_filename(path)

detect_polarization_channels_per_dataset(orso_data)

Classify every dataset of a parsed ORSO file by its own header.

Unlike :func:detect_polarization_channel, which reads only the first dataset, this honours per-dataset polarization: overrides in multi-dataset files.

Parameters:

Name Type Description Default
orso_data list

Parsed ORSO dataset list (as returned by orso.load_orso).

required

Returns:

Type Description
list[tuple[bool, Optional[PolarizationChannel]]]

Per dataset: (header declares a polarization, mapped channel or None).

Source code in src/easyreflectometry/data/polarized.py
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def detect_polarization_channels_per_dataset(
    orso_data,
) -> list[tuple[bool, Optional[PolarizationChannel]]]:
    """Classify every dataset of a parsed ORSO file by its own header.

    Unlike :func:`detect_polarization_channel`, which reads only the first
    dataset, this honours per-dataset ``polarization:`` overrides in
    multi-dataset files.

    Parameters
    ----------
    orso_data : list
        Parsed ORSO dataset list (as returned by ``orso.load_orso``).

    Returns
    -------
    list[tuple[bool, Optional[PolarizationChannel]]]
        Per dataset: (header declares a polarization, mapped channel or None).
    """
    result = []
    for orso_dataset in orso_data:
        polarization = _dataset_polarization(orso_dataset)
        result.append((polarization is not None, channel_from_orso_polarization(polarization)))
    return result

load_as_dataset(fname, data_group=None)

Load data from an ORSO .ort file as a DataSet1D.

Parameters:

Name Type Description Default
fname Union[TextIO, str]

The file to be read.

required
data_group Optional[sc.DataGroup]

Pre-loaded DataGroup for fname (avoids re-parsing the file). By default, None.

None

Returns:

Type Description
DataSet1D

The (first) dataset in the file.

Source code in src/easyreflectometry/data/measurement.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
def load_as_dataset(fname: Union[TextIO, str], data_group: Optional[sc.DataGroup] = None) -> DataSet1D:
    """Load data from an ORSO .ort file as a DataSet1D.

    Parameters
    ----------
    fname : Union[TextIO, str]
        The file to be read.
    data_group : Optional[sc.DataGroup], optional
        Pre-loaded DataGroup for *fname* (avoids re-parsing the file).
        By default, None.

    Returns
    -------
    DataSet1D
        The (first) dataset in the file.
    """
    if data_group is None:
        data_group = load(fname)
    basename = os.path.splitext(os.path.basename(fname))[0]
    data_name = 'R_' + basename
    data_name = list(data_group['data'].keys())[0] if data_name not in data_group['data'] else data_name
    return dataset_from_datagroup(data_group, data_key=data_name)

dataset_from_datagroup(data_group, data_key=None)

Build a DataSet1D from one dataset of a loaded DataGroup.

The ORSO header (when present) is attached to the returned dataset as the orso_header attribute (a plain dict), so exporters can reuse the original data_source/reduction provenance.

Parameters:

Name Type Description Default
data_group sc.DataGroup

A DataGroup as returned by :func:load.

required
data_key Optional[str]

The data entry to use (e.g. 'R_0'). By default, the first entry.

None

Returns:

Type Description
DataSet1D

The dataset.

Source code in src/easyreflectometry/data/measurement.py
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
def dataset_from_datagroup(data_group: sc.DataGroup, data_key: Optional[str] = None) -> DataSet1D:
    """Build a DataSet1D from one dataset of a loaded DataGroup.

    The ORSO header (when present) is attached to the returned dataset as the
    ``orso_header`` attribute (a plain dict), so exporters can reuse the
    original ``data_source``/``reduction`` provenance.

    Parameters
    ----------
    data_group : sc.DataGroup
        A DataGroup as returned by :func:`load`.
    data_key : Optional[str], optional
        The data entry to use (e.g. ``'R_0'``). By default, the first entry.

    Returns
    -------
    DataSet1D
        The dataset.
    """
    if data_key is None:
        data_key = list(data_group['data'].keys())[0]
    coords_key = 'Qz_' + data_key[len('R_') :]
    if coords_key not in data_group['coords']:
        coords_key = list(data_group['coords'].keys())[0]
    dataset = DataSet1D(
        x=data_group['coords'][coords_key].values,
        y=data_group['data'][data_key].values,
        ye=data_group['data'][data_key].variances,
        xe=data_group['coords'][coords_key].variances,
    )
    header = None
    if 'attrs' in data_group and data_key in data_group['attrs']:
        try:
            header = data_group['attrs'][data_key]['orso_header'].values
        except (KeyError, AttributeError):
            header = None
    dataset.orso_header = header
    return dataset

extract_orso_title(data_group, data_name)

Extract orso title.

Source code in src/easyreflectometry/data/measurement.py
110
111
112
113
114
115
116
117
118
119
120
def extract_orso_title(data_group: sc.DataGroup, data_name: str) -> str | None:
    """Extract orso title."""
    try:
        header = data_group['attrs'][data_name]['orso_header']
        title = header.values.get('data_source', {}).get('experiment', {}).get('title')
    except (AttributeError, KeyError, TypeError):
        return None
    if title is None:
        return None
    title_str = str(title).strip()
    return title_str or None

load_measurement_file(fname)

Load data from an ORSO file (.ort/.orb) or a plain text file.

The discriminator is the ORSO banner line (or the HDF5 magic for binary files), not the file extension: a file carrying the banner that fails to parse raises instead of being silently re-read as plain text (which would drop the entire header, including polarization).

For a plain text file the columns are read by position in the ORSO order Qz, R, sR, sQz -- further numeric columns are ignored -- and the error columns must hold standard deviations, not variances. See :func:_load_txt.

Parameters:

Name Type Description Default
fname Union[TextIO, str]

The file to be read.

required

Returns:

Type Description
sc.DataGroup

The loaded data.

Source code in src/easyreflectometry/data/measurement.py
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
def load(fname: Union[TextIO, str]) -> sc.DataGroup:
    """Load data from an ORSO file (.ort/.orb) or a plain text file.

    The discriminator is the ORSO banner line (or the HDF5 magic for binary
    files), **not** the file extension: a file carrying the banner that fails
    to parse raises instead of being silently re-read as plain text (which
    would drop the entire header, including polarization).

    For a plain text file the columns are read by position in the ORSO order
    Qz, R, sR, sQz -- further numeric columns are ignored -- and the error
    columns must hold **standard deviations**, not variances. See
    :func:`_load_txt`.

    Parameters
    ----------
    fname : Union[TextIO, str]
        The file to be read.

    Returns
    -------
    sc.DataGroup
        The loaded data.
    """
    if is_orso_file(str(fname)):
        return load_data_from_orso_file(fname)
    return _load_txt(fname)

load_data_from_orso_file(fname)

Load data from an ORSO file (.ort text or .orb binary).

Parameters:

Name Type Description Default
fname str

Path to the file.

required

Returns:

Type Description
sc.DataGroup

A scipp DataGroup with data, coords, and attrs.

Raises:

Type Description
ValueError :

If the file cannot be parsed as ORSO. Parse failures are not swallowed here; falling back to plain-text loading is the caller's decision and only valid when the file carries no ORSO banner.

Source code in src/easyreflectometry/orso_utils.py
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def load_data_from_orso_file(fname: str) -> sc.DataGroup:
    """Load data from an ORSO file (`.ort` text or `.orb` binary).

    Parameters
    ----------
    fname : str
        Path to the file.

    Returns
    -------
    sc.DataGroup
        A scipp DataGroup with data, coords, and attrs.

    Raises
    ------
    ValueError :
        If the file cannot be parsed as ORSO. Parse failures are **not**
        swallowed here; falling back to plain-text loading is the caller's
        decision and only valid when the file carries no ORSO banner.
    """
    orso_data = _load_orso_any(fname)
    return load_orso_data(orso_data)

build_constraints_factory(specs, resolve)

Build the constraints_factory hook for the given specs.

The returned callable is what easyscience's BUMPS engine invokes with the {prefixed unique name: bumps.Parameter} mapping of a freshly built problem; it returns one penalty object per enabled spec. Paths are resolved and dependent parameters expanded at that moment, so the factory always reflects the model as it is when the fit starts.

Parameters:

Name Type Description Default
specs Iterable[InequalitySpec]

Specs to apply; disabled ones are skipped.

required
resolve Callable[[str], DescriptorNumber]

Structural-path resolver, typically project.resolve_parameter_path.

required

Returns:

Type Description
Callable | None

The factory, or None when no spec is enabled (so callers can pass it straight through as constraints_factory=...).

Source code in src/easyreflectometry/inequality_constraints.py
370
371
372
373
374
375
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
401
402
403
404
405
406
407
408
409
410
411
def build_constraints_factory(specs: Iterable[InequalitySpec], resolve: PathResolver) -> Optional[Callable]:
    """Build the ``constraints_factory`` hook for the given specs.

    The returned callable is what ``easyscience``'s BUMPS engine invokes
    with the ``{prefixed unique name: bumps.Parameter}`` mapping of a
    freshly built problem; it returns one penalty object per enabled spec.
    Paths are resolved and dependent parameters expanded at that moment, so
    the factory always reflects the model as it is when the fit starts.

    Parameters
    ----------
    specs : Iterable[InequalitySpec]
        Specs to apply; disabled ones are skipped.
    resolve : Callable[[str], DescriptorNumber]
        Structural-path resolver, typically ``project.resolve_parameter_path``.

    Returns
    -------
    Callable | None
        The factory, or ``None`` when no spec is enabled (so callers can pass
        it straight through as ``constraints_factory=...``).
    """
    active = [spec for spec in specs if spec.enabled]
    if not active:
        return None

    def factory(bumps_pars: Dict[str, Any]) -> list:
        constraints = []
        for spec in active:
            parameters = _resolve_all(spec, resolve)
            lhs = _Expression(
                spec.lhs_expression,
                {alias: _source_for(parameters[alias], bumps_pars, ()) for alias in spec.lhs_paths},
            )
            rhs = _Expression(
                spec.rhs_expression,
                {alias: _source_for(parameters[alias], bumps_pars, ()) for alias in spec.rhs_paths},
            )
            constraints.append(_Violation(lhs, spec.op, rhs, spec.name or str(spec)))
        return constraints

    return factory

check_units(spec, resolve)

Raise :class:UnitError when the two sides of spec have incompatible units.

Each side is evaluated with the unit-carrying DescriptorNumber objects themselves (the same arithmetic the equality constraints use), so t1 + sld is rejected by EasyScience and t_head < t_tail passes. A plain numeric side is accepted against any unit: it is read in the unit of the other side. The same applies to a side mixing literals with parameters (90 - b): it is checked numerically and its literals are read in the unit of the other side.

Source code in src/easyreflectometry/inequality_constraints.py
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
def check_units(spec: InequalitySpec, resolve: PathResolver) -> None:
    """Raise :class:`UnitError` when the two sides of `spec` have incompatible units.

    Each side is evaluated with the unit-carrying ``DescriptorNumber``
    objects themselves (the same arithmetic the equality constraints use),
    so ``t1 + sld`` is rejected by EasyScience and ``t_head < t_tail``
    passes. A plain numeric side is accepted against any unit: it is read in
    the unit of the other side. The same applies to a side mixing literals
    with parameters (``90 - b``): it is checked numerically and its literals
    are read in the unit of the other side.
    """
    parameters = _resolve_all(spec, resolve)
    units = []
    for expression, paths in ((spec.lhs_expression, spec.lhs_paths), (spec.rhs_expression, spec.rhs_paths)):
        interpreter = _new_interpreter()
        try:
            result = _evaluate(interpreter, expression, {alias: parameters[alias] for alias in paths})
        except Exception as unit_error:
            # Mixed literal/parameter arithmetic such as ``90 - b`` cannot be
            # evaluated with unit-carrying objects (a bare number has no unit).
            # Fall back to a numeric evaluation and read the literals in the
            # unit of the other side; purely wrong mixes still fail here.
            try:
                numeric = _evaluate(_new_interpreter(), expression, {alias: float(parameters[alias].value) for alias in paths})
            except Exception:
                raise UnitError(f"Cannot evaluate '{expression}' with units: {unit_error}") from None
            if not isinstance(numeric, numbers.Number):
                raise ValueError(f"'{expression}' does not evaluate to a number.") from None
            units.append(None)
            continue
        if isinstance(result, DescriptorNumber):
            units.append(str(result.unit))
        elif isinstance(result, numbers.Number):
            units.append(None)
        else:
            raise ValueError(f"'{expression}' does not evaluate to a number.")
    lhs_unit, rhs_unit = units
    if lhs_unit is not None and rhs_unit is not None and lhs_unit != rhs_unit:
        raise UnitError(f"Incompatible units in '{spec}': left side is in '{lhs_unit}', right side in '{rhs_unit}'.")

evaluate_spec(spec, resolve)

Evaluate a spec against the current parameter values.

Used for the start-point feasibility check before a fit is launched and for displaying the constraint state; dependent parameters contribute their current (already propagated) value.

Source code in src/easyreflectometry/inequality_constraints.py
414
415
416
417
418
419
420
421
422
423
424
425
426
def evaluate_spec(spec: InequalitySpec, resolve: PathResolver) -> InequalityEvaluation:
    """Evaluate a spec against the *current* parameter values.

    Used for the start-point feasibility check before a fit is launched and
    for displaying the constraint state; dependent parameters contribute
    their current (already propagated) value.
    """
    parameters = _resolve_all(spec, resolve)
    interpreter = _new_interpreter()
    lhs = float(_evaluate(interpreter, spec.lhs_expression, {a: float(parameters[a].value) for a in spec.lhs_paths}))
    rhs = float(_evaluate(interpreter, spec.rhs_expression, {a: float(parameters[a].value) for a in spec.rhs_paths}))
    violation = _violation(lhs, spec.op, rhs)
    return InequalityEvaluation(lhs=lhs, rhs=rhs, satisfied=violation == 0.0, violation=violation)

apply_default_limits(parameter, kind)

Apply default min/max to a parameter if current bounds are infinite.

Parameters:

Name Type Description Default
parameter Parameter

The parameter to adjust.

required
kind str

One of 'thickness', 'roughness', 'sld', 'isld', 'rho_m', 'scale'.

required
Source code in src/easyreflectometry/limits.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def apply_default_limits(parameter: Parameter, kind: str) -> None:
    """Apply default min/max to a parameter if current bounds are infinite.

    Parameters
    ----------
    parameter : Parameter
        The parameter to adjust.
    kind : str
        One of 'thickness', 'roughness', 'sld', 'isld', 'rho_m', 'scale'.
    """
    if not parameter.independent:
        return

    if kind in ('thickness', 'roughness'):
        _apply_percentage_limits(parameter)
    elif kind in ('sld', 'isld', 'rho_m'):
        # A magnetic SLD is an SLD: same physical scale, same default window.
        _apply_fixed_limits(parameter, *SLD_LIMITS)
    elif kind == 'scale':
        _apply_fixed_limits(parameter, *SCALE_LIMITS)

is_orso_file(fname)

Whether fname is an ORSO file: text with the ORSO banner line, or HDF5 (.orb).

Parameters:

Name Type Description Default
fname str

Path to the file.

required

Returns:

Type Description
bool

True when the file starts with the ORSO banner or the HDF5 magic bytes.

Source code in src/easyreflectometry/orso_utils.py
 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
def is_orso_file(fname: str) -> bool:
    """Whether *fname* is an ORSO file: text with the ORSO banner line, or HDF5 (.orb).

    Parameters
    ----------
    fname : str
        Path to the file.

    Returns
    -------
    bool
        True when the file starts with the ORSO banner or the HDF5 magic bytes.
    """
    try:
        with open(fname, 'rb') as f:
            head = f.read(128)
    except OSError:
        return False
    if head.startswith(_HDF5_MAGIC):
        return True
    try:
        first_line = head.decode('utf-8', errors='replace').splitlines()[0]
    except IndexError:
        return False
    return first_line.lstrip().startswith('#') and ORSO_BANNER in first_line

save_orso_experiment(experiment, fname, model=None)

Write an experiment to an ORSO file (.ort text, or .orb binary).

Parameters:

Name Type Description Default
experiment

A DataSet1D or PolarizedDataSet (the latter is written as one file with one data_set block per spin channel).

required
fname str

Destination path; a .orb extension selects the binary (NeXus/HDF5) representation, anything else the text one.

required
model optional

The model whose sample is exported as the ORSO sample.model. Defaults to the experiment's model.

None
Source code in src/easyreflectometry/orso_utils.py
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
def save_orso_experiment(experiment, fname: str, model=None) -> None:
    """Write an experiment to an ORSO file (`.ort` text, or `.orb` binary).

    Parameters
    ----------
    experiment :
        A DataSet1D or PolarizedDataSet (the latter is written as one file with
        one ``data_set`` block per spin channel).
    fname : str
        Destination path; a ``.orb`` extension selects the binary (NeXus/HDF5)
        representation, anything else the text one.
    model : optional
        The model whose sample is exported as the ORSO ``sample.model``.
        Defaults to the experiment's model.
    """
    fname = str(fname)
    datasets = orso_datasets_from_experiment(experiment, model=model)
    if fname.lower().endswith('.orb'):
        orso.save_nexus(datasets, fname)
    else:
        orso.save_orso(datasets, fname)

_weak_constraints_provider(project)

Source code in src/easyreflectometry/project.py
103
104
105
106
107
108
109
110
def _weak_constraints_provider(project: 'Project'):
    project_ref = weakref.ref(project)

    def provider():
        target = project_ref()
        return None if target is None else target.build_constraints_factory()

    return provider

_unwrapped_angle(angle, mask)

Make an angle profile continuous along z within each magnetic region.

The moment angle is periodic, so a profile that turns smoothly from 359° to 1° comes back from atan2 as a jump of nearly 360°. Drawn as a line that is a full sweep across the chart where the moment barely moves. Each contiguous run of mask (a magnetic region) is therefore unwrapped on its own and then shifted by whole turns so it sits as close to the conventional [0, 360) range as possible — neighbouring regions stay independent, since the angle between them is not defined.

Parameters:

Name Type Description Default
angle np.ndarray

Wrapped angles in degrees.

required
mask np.ndarray

Which points carry a moment.

required

Returns:

Type Description
np.ndarray

Angles in degrees, continuous within each masked region. Points outside the mask are returned unchanged (callers drop them).

Source code in src/easyreflectometry/project.py
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
def _unwrapped_angle(angle: np.ndarray, mask: np.ndarray) -> np.ndarray:
    """Make an angle profile continuous along z within each magnetic region.

    The moment angle is periodic, so a profile that turns smoothly from 359° to
    1° comes back from `atan2` as a jump of nearly 360°. Drawn as a line that is
    a full sweep across the chart where the moment barely moves. Each contiguous
    run of `mask` (a magnetic region) is therefore unwrapped on its own and then
    shifted by whole turns so it sits as close to the conventional [0, 360)
    range as possible — neighbouring regions stay independent, since the angle
    between them is not defined.

    Parameters
    ----------
    angle : np.ndarray
        Wrapped angles in degrees.
    mask : np.ndarray
        Which points carry a moment.

    Returns
    -------
    np.ndarray
        Angles in degrees, continuous within each masked region. Points outside
        the mask are returned unchanged (callers drop them).
    """
    unwrapped = np.array(angle, dtype=float, copy=True)
    if mask.size == 0 or not np.any(mask):
        return unwrapped

    # Each contiguous run of masked points is one magnetic region.
    masked_indices = np.flatnonzero(mask)
    region_breaks = np.flatnonzero(np.diff(masked_indices) > 1) + 1
    for region in np.split(masked_indices, region_breaks):
        segment = np.unwrap(unwrapped[region], period=360.0)
        # Keep the drawn values near the usual range rather than at 720 deg.
        turns = np.round(np.median(segment) / 360.0 - 0.5)
        unwrapped[region] = segment - turns * 360.0
    return unwrapped

_has_both_nsf_channels(channels)

Whether both non-spin-flip channels (pp, mm) — the pair spin asymmetry needs — are present.

Source code in src/easyreflectometry/project.py
2172
2173
2174
def _has_both_nsf_channels(channels: List[PolarizationChannel]) -> bool:
    """Whether both non-spin-flip channels (pp, mm) — the pair spin asymmetry needs — are present."""
    return PolarizationChannel.PP in channels and PolarizationChannel.MM in channels

_ordered_channel_arrays(dataset)

A channel's (q, reflectivity, variance) arrays, ordered and checked.

Spin asymmetry pairs two channels point by point and interpolates one onto the other, both of which assume well-formed, strictly increasing q. Rather than trusting that, the arrays are checked here and sorted when needed — np.interp silently returns nonsense for a descending grid.

Raises:

Type Description
ValueError

The dataset is empty, its arrays disagree in length or shape, its q values are not finite, or it visits the same q twice.

Source code in src/easyreflectometry/project.py
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
def _ordered_channel_arrays(dataset: DataSet1D) -> tuple:
    """A channel's (q, reflectivity, variance) arrays, ordered and checked.

    Spin asymmetry pairs two channels point by point and interpolates one onto
    the other, both of which assume well-formed, strictly increasing q. Rather
    than trusting that, the arrays are checked here and sorted when needed —
    `np.interp` silently returns nonsense for a descending grid.

    Raises
    ------
    ValueError
        The dataset is empty, its arrays disagree in length or shape, its q
        values are not finite, or it visits the same q twice.
    """
    name = getattr(dataset, 'name', '?')
    q = np.asarray(getattr(dataset, 'x', np.empty(0)), dtype=float).ravel()
    y = np.asarray(getattr(dataset, 'y', np.empty(0)), dtype=float).ravel()
    if q.size == 0:
        raise ValueError(f"Channel '{name}' has no data points.")
    if q.size != y.size:
        raise ValueError(f"Channel '{name}' has {q.size} q values for {y.size} reflectivities.")
    if not np.all(np.isfinite(q)):
        raise ValueError(f"Channel '{name}' has non-finite q values.")

    variance = np.asarray(getattr(dataset, 'ye', None) if getattr(dataset, 'ye', None) is not None else [], dtype=float)
    variance = variance.ravel()
    if variance.size == 0:
        variance = np.zeros_like(y)
    elif variance.size != y.size:
        logger.warning(
            "Channel '%s' has %s uncertainties for %s points; treating it as having none.",
            name,
            variance.size,
            y.size,
        )
        variance = np.zeros_like(y)

    order = np.argsort(q, kind='stable')
    if not np.array_equal(order, np.arange(q.size)):
        logger.warning("Channel '%s' is not ordered in q; sorting it before pairing.", name)
        q, y, variance = q[order], y[order], variance[order]
    # A single-point channel has an empty `np.diff`, so the duplicate-q check
    # below is vacuously satisfied and it passes through here unrejected. That
    # is intentional: a one-point channel is a legitimate (if degenerate) SA
    # pair when it lines up exactly with the other channel's grid, and
    # `_interpolate_with_variance` handles a size-1 `q_source` correctly (its
    # clipped `searchsorted` result always resolves to that single point).
    if np.any(np.diff(q) <= 0):
        raise ValueError(f"Channel '{name}' visits the same q more than once; the pairing would be ambiguous.")
    return q, y, variance

_interpolate_with_variance(q, q_source, values, variances)

Linear interpolation of values and their variances onto q.

Values use the linear weights (1−t, t); variances use their squares, which is the propagation rule for independent endpoints — interpolating a variance linearly (as np.interp would) overestimates it, by a factor 2 at the midpoint of two equal variances.

q must lie inside q_source; the caller restricts it to the overlap.

Source code in src/easyreflectometry/project.py
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
def _interpolate_with_variance(q: np.ndarray, q_source: np.ndarray, values: np.ndarray, variances: np.ndarray) -> tuple:
    """Linear interpolation of values and their variances onto `q`.

    Values use the linear weights (1−t, t); variances use their **squares**,
    which is the propagation rule for independent endpoints — interpolating a
    variance linearly (as `np.interp` would) overestimates it, by a factor 2 at
    the midpoint of two equal variances.

    `q` must lie inside `q_source`; the caller restricts it to the overlap.
    """
    upper = np.clip(np.searchsorted(q_source, q, side='left'), 1, q_source.size - 1)
    lower = upper - 1
    span = q_source[upper] - q_source[lower]
    # span is > 0 for a strictly increasing source grid; guard anyway.
    weight = np.where(span > 0, (q - q_source[lower]) / np.where(span > 0, span, 1.0), 0.0)
    interpolated = (1.0 - weight) * values[lower] + weight * values[upper]
    interpolated_variance = (1.0 - weight) ** 2 * variances[lower] + weight**2 * variances[upper]
    return interpolated, interpolated_variance

_spin_asymmetry(r_pp, r_mm, var_pp=None, var_mm=None)

Spin asymmetry, its variance, which points to keep, and why not.

Parameters:

Name Type Description Default
r_pp np.ndarray

Non-spin-flip reflectivities on a common q grid.

required
r_mm np.ndarray

Non-spin-flip reflectivities on a common q grid.

required
var_pp Optional[np.ndarray]

Their variances (DataSet1D.ye), or None for a calculated curve.

None
var_mm Optional[np.ndarray]

Their variances (DataSet1D.ye), or None for a calculated curve.

None

Returns:

Type Description
tuple

SA, its variance (zeros without input variances), a boolean mask of the points to keep, and a dict counting the dropped ones by reason.

Source code in src/easyreflectometry/project.py
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
def _spin_asymmetry(
    r_pp: np.ndarray,
    r_mm: np.ndarray,
    var_pp: Optional[np.ndarray] = None,
    var_mm: Optional[np.ndarray] = None,
) -> tuple:
    """Spin asymmetry, its variance, which points to keep, and why not.

    Parameters
    ----------
    r_pp, r_mm : np.ndarray
        Non-spin-flip reflectivities on a common q grid.
    var_pp, var_mm : Optional[np.ndarray]
        Their variances (`DataSet1D.ye`), or None for a calculated curve.

    Returns
    -------
    tuple
        SA, its variance (zeros without input variances), a boolean mask of the
        points to keep, and a dict counting the dropped ones by reason.
    """
    r_pp = np.asarray(r_pp, dtype=float)
    r_mm = np.asarray(r_mm, dtype=float)
    denominator = r_pp + r_mm
    var_pp = np.zeros_like(r_pp) if var_pp is None else np.asarray(var_pp, dtype=float)
    var_mm = np.zeros_like(r_mm) if var_mm is None else np.asarray(var_mm, dtype=float)

    # A denominator of exactly zero would divide by zero; those points are
    # dropped by the masks below anyway.
    safe_denominator = np.where(denominator == 0, np.nan, denominator)
    with np.errstate(invalid='ignore', divide='ignore'):
        asymmetry = (r_pp - r_mm) / safe_denominator
        # sigma_SA = 2 sqrt(R--^2 sigma_++^2 + R++^2 sigma_--^2) / (R++ + R--)^2,
        # so the variance is its square. ye holds variances, hence no squaring
        # of var_pp / var_mm here.
        variance = 4.0 * (r_mm**2 * var_pp + r_pp**2 * var_mm) / safe_denominator**4

    asymmetry = np.nan_to_num(asymmetry, nan=0.0, posinf=0.0, neginf=0.0)
    variance = np.nan_to_num(variance, nan=0.0, posinf=0.0, neginf=0.0)

    # A point with a non-finite reflectivity, or an uncertainty that is not a
    # usable variance, cannot produce a meaningful SA — and must not be silently
    # demoted to "no uncertainty", which would also skip the significance test.
    invalid = ~np.isfinite(r_pp) | ~np.isfinite(r_mm)
    invalid |= ~np.isfinite(var_pp) | ~np.isfinite(var_mm) | (var_pp < 0) | (var_mm < 0)

    # Uncertainty-independent guard: the denominator must be positive and must
    # not be the small remainder of two much larger numbers. Reflectivities are
    # positive, so this only bites on background-subtracted data — which is
    # exactly where SA otherwise explodes to +/-1e3 and destroys the axis.
    magnitude = np.abs(r_pp) + np.abs(r_mm)
    with np.errstate(invalid='ignore'):
        degenerate = ~np.isfinite(denominator) | (denominator <= 0)
        degenerate |= np.abs(denominator) <= SPIN_ASYMMETRY_CANCELLATION_FRACTION * magnitude
    degenerate &= ~invalid

    # Uncertainty-based guard: is the denominator above the noise?
    denominator_sigma = np.sqrt(np.clip(var_pp + var_mm, 0.0, None))
    with_uncertainty = denominator_sigma > 0
    insignificant = with_uncertainty & (denominator <= SPIN_ASYMMETRY_SIGNIFICANCE * denominator_sigma)
    insignificant &= ~invalid & ~degenerate

    keep = ~invalid & ~degenerate & ~insignificant
    reasons = {
        'invalid_points': int(np.count_nonzero(invalid)),
        'small_denominator_points': int(np.count_nonzero(degenerate)),
        'low_significance_points': int(np.count_nonzero(insignificant)),
    }
    return asymmetry, variance, keep, reasons