Skip to content

project

SUM_PARTNER_MAX_BACKUP = '_sum_partner_previous_max' module-attribute

USER_CONSTRAINT_FLAG = '_easyreflectometry_user_constraint' module-attribute

DEFAULT_MINIMIZER = AvailableMinimizers.LMFit_leastsq 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

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
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
class 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, resolution_function=None) -> dict:
        """Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm')."""
        return self().polarized_reflectivity_profiles(x_array, model_id, resolution_function=resolution_function)

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

    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, resolution_function=None) -> 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).
        resolution_function : ResolutionFunction | None, optional
            Resolution of the channel's dataset; by default the model's.

        Returns
        -------
        Callable
            Function of (x_array, model_id) returning the channel reflectivity.
        """
        return self.fit_func_for(channel=channel, resolution_function=resolution_function)

    def fit_func_for(self, channel: PolarizationChannel | str | None = None, resolution_function=None) -> Callable:
        """A fit function for one dataset: optional spin channel, optional own resolution.

        A dataset measured with its own q-resolution (a ``Pointwise`` built
        from its sQz column) is smeared with that resolution rather than with
        whatever is registered on the model, so several datasets of one model
        -- different contrasts, angles or spin channels -- are each fitted with
        the resolution they were measured with.

        Parameters
        ----------
        channel : PolarizationChannel | str | None, optional
            One of 'pp', 'pm', 'mp', 'mm'; ``None`` (the default) gives the
            ordinary, channel-agnostic reflectivity.
        resolution_function : ResolutionFunction | None, optional
            Resolution to smear with; ``None`` (the default) uses the one
            registered for the model.

        Returns
        -------
        Callable
            Function of (x_array, model_id) returning the reflectivity.
        """
        if channel is None:

            def __fit_func(x_array, model_id):
                """Fit func with a bound resolution."""
                return self().reflectity_profile(x_array, model_id, resolution_function=resolution_function)

            return __fit_func

        channel = PolarizationChannel(channel)

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

        return __channel_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, resolution_function=None)

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, resolution_function=None) -> dict:
    """Reflectivity profiles of all four spin channels ('pp', 'pm', 'mp', 'mm')."""
    return self().polarized_reflectivity_profiles(x_array, model_id, resolution_function=resolution_function)

reflectivity_profile_channel(x_array, model_id, channel, resolution_function=None)

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

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

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
37
38
39
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, resolution_function=None)

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
resolution_function ResolutionFunction | None

Resolution of the channel's dataset; by default the model's.

None

Returns:

Type Description
Callable

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

Source code in src/easyreflectometry/calculators/factory.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def fit_func_for_channel(self, channel: PolarizationChannel | str, resolution_function=None) -> 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).
    resolution_function : ResolutionFunction | None, optional
        Resolution of the channel's dataset; by default the model's.

    Returns
    -------
    Callable
        Function of (x_array, model_id) returning the channel reflectivity.
    """
    return self.fit_func_for(channel=channel, resolution_function=resolution_function)

fit_func_for(channel=None, resolution_function=None)

A fit function for one dataset: optional spin channel, optional own resolution.

A dataset measured with its own q-resolution (a Pointwise built from its sQz column) is smeared with that resolution rather than with whatever is registered on the model, so several datasets of one model -- different contrasts, angles or spin channels -- are each fitted with the resolution they were measured with.

Parameters:

Name Type Description Default
channel PolarizationChannel | str | None

One of 'pp', 'pm', 'mp', 'mm'; None (the default) gives the ordinary, channel-agnostic reflectivity.

None
resolution_function ResolutionFunction | None

Resolution to smear with; None (the default) uses the one registered for the model.

None

Returns:

Type Description
Callable

Function of (x_array, model_id) returning the reflectivity.

Source code in src/easyreflectometry/calculators/factory.py
 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
def fit_func_for(self, channel: PolarizationChannel | str | None = None, resolution_function=None) -> Callable:
    """A fit function for one dataset: optional spin channel, optional own resolution.

    A dataset measured with its own q-resolution (a ``Pointwise`` built
    from its sQz column) is smeared with that resolution rather than with
    whatever is registered on the model, so several datasets of one model
    -- different contrasts, angles or spin channels -- are each fitted with
    the resolution they were measured with.

    Parameters
    ----------
    channel : PolarizationChannel | str | None, optional
        One of 'pp', 'pm', 'mp', 'mm'; ``None`` (the default) gives the
        ordinary, channel-agnostic reflectivity.
    resolution_function : ResolutionFunction | None, optional
        Resolution to smear with; ``None`` (the default) uses the one
        registered for the model.

    Returns
    -------
    Callable
        Function of (x_array, model_id) returning the reflectivity.
    """
    if channel is None:

        def __fit_func(x_array, model_id):
            """Fit func with a bound resolution."""
            return self().reflectity_profile(x_array, model_id, resolution_function=resolution_function)

        return __fit_func

    channel = PolarizationChannel(channel)

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

    return __channel_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
 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
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
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
        # Which easyscience object each backend entry was created for, keyed
        # like the wrapper storage (``(kind, unique_name)``). Weak so the
        # calculator keeps nothing alive. See `_claim`.
        self._owners: dict[tuple[str, str], weakref.ReferenceType] = {}

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

    def _claim(self, kind: str, model: Material | Layer | Multilayer | Model) -> None:
        """Make *model* the owner of its backend entry, discarding a stale one first.

        Backend entries are keyed by ``unique_name``, and easyscience hands the
        name of a dead object out again (the next ``Prefix_N`` above the live
        ones), while nothing tells the calculator that the object died. A new
        object can thus be bound to the entry its dead namesake left behind:
        ``create`` must not recreate the entry for an object that is merely
        re-bound (the entry is referenced from its item's stack), so without
        this it would keep the old one. Parameter values are pushed again on
        binding, but state that is only pushed when present is not -- a
        non-magnetic layer landing on a dead magnetic layer's name became
        magnetic (GitHub issue #426). Anything not owned by *model* itself is
        removed from the backend here, so ``create`` starts from a blank entry.
        """
        key = model.unique_name
        owner = self._owners.get((kind, key))
        if owner is not None and owner() is model:
            return
        if owner is not None or key in self._wrapper.storage[kind]:
            getattr(self._wrapper, f'remove_{kind}')(key)
        self._owners[(kind, key)] = weakref.ref(model)

    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
            self._claim('material', model)
            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
            self._claim('material', model)
            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
            self._claim('layer', model)
            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
            # `create_model` starts blank anyway; claiming drops what else is
            # kept under the name (resolution function, cached cross-sections).
            self._claim('model', model)
            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,
        resolution_function: ResolutionFunction | None = None,
    ) -> 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.
        resolution_function : ResolutionFunction | None, optional
            Resolution to smear with; by default the one registered for the
            model through :meth:`set_resolution_function`.
        """
        return self._wrapper.calculate(x_array, model_id, resolution_function=resolution_function)

    def reflectivity_profile_channel(
        self,
        x_array: np.ndarray,
        model_id: str,
        channel,
        resolution_function: ResolutionFunction | None = None,
    ) -> 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).
        resolution_function : ResolutionFunction | None, optional
            Resolution to smear with; by default the one registered for the model.

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

    def polarized_reflectivity_profiles(
        self,
        x_array: np.ndarray,
        model_id: str,
        resolution_function: ResolutionFunction | None = None,
    ) -> 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.
        resolution_function : ResolutionFunction | None, optional
            Resolution to smear with; by default the one registered for the model.

        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, resolution_function=resolution_function)

    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: ResolutionFunction, model_id: str | None = None) -> None:
        """Set the resolution function of one model, or the default for all others.

        Parameters
        ----------
        resolution_function : ResolutionFunction
            The resolution function.
        model_id : str | None, optional
            The model id. ``None`` sets the default used by every model that
            has not registered a resolution of its own.
        """
        return self._wrapper.set_resolution_function(resolution_function, model_id)

    def resolution_function_for(self, model_id: str | None = None) -> ResolutionFunction:
        """The resolution function used when calculating the model with *model_id*."""
        return self._wrapper.resolution_function_for(model_id)

    @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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
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
49
50
51
52
53
54
55
56
def __init__(self):
    """Init function."""
    self._namespace = {}
    self._wrapper: WrapperBase
    # Which easyscience object each backend entry was created for, keyed
    # like the wrapper storage (``(kind, unique_name)``). Weak so the
    # calculator keeps nothing alive. See `_claim`.
    self._owners: dict[tuple[str, str], weakref.ReferenceType] = {}

reset_storage()

Reset the storage area of the calculator.

Source code in src/easyreflectometry/calculators/calculator_base.py
58
59
60
61
def reset_storage(self) -> None:
    r"""Reset the storage area of the calculator."""
    self._wrapper.reset_storage()
    self._owners = {}

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
 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
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
        self._claim('material', model)
        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
        self._claim('material', model)
        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
        self._claim('layer', model)
        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
        # `create_model` starts blank anyway; claiming drops what else is
        # kept under the name (resolution function, cached cross-sections).
        self._claim('model', model)
        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
167
168
169
170
171
172
173
174
175
176
177
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
179
180
181
182
183
184
185
186
187
188
189
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
191
192
193
194
195
196
197
198
199
200
201
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
203
204
205
206
207
208
209
210
211
212
213
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
215
216
217
218
219
220
221
222
223
224
225
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, resolution_function=None)

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
resolution_function ResolutionFunction | None

Resolution to smear with; by default the one registered for the model through :meth:set_resolution_function.

None
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
245
def reflectity_profile(
    self,
    x_array: np.ndarray,
    model_id: str,
    resolution_function: ResolutionFunction | None = None,
) -> 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.
    resolution_function : ResolutionFunction | None, optional
        Resolution to smear with; by default the one registered for the
        model through :meth:`set_resolution_function`.
    """
    return self._wrapper.calculate(x_array, model_id, resolution_function=resolution_function)

reflectivity_profile_channel(x_array, model_id, channel, resolution_function=None)

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
resolution_function ResolutionFunction | None

Resolution to smear with; by default the one registered for the model.

None

Returns:

Type Description
np.ndarray

Reflectivity of the requested channel at q.

Source code in src/easyreflectometry/calculators/calculator_base.py
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
def reflectivity_profile_channel(
    self,
    x_array: np.ndarray,
    model_id: str,
    channel,
    resolution_function: ResolutionFunction | None = None,
) -> 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).
    resolution_function : ResolutionFunction | None, optional
        Resolution to smear with; by default the one registered for the model.

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

polarized_reflectivity_profiles(x_array, model_id, resolution_function=None)

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
resolution_function ResolutionFunction | None

Resolution to smear with; by default the one registered for the model.

None

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
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
def polarized_reflectivity_profiles(
    self,
    x_array: np.ndarray,
    model_id: str,
    resolution_function: ResolutionFunction | None = None,
) -> 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.
    resolution_function : ResolutionFunction | None, optional
        Resolution to smear with; by default the one registered for the model.

    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, resolution_function=resolution_function)

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
304
305
306
307
308
309
310
311
312
313
314
315
316
317
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
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
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, model_id=None)

Set the resolution function of one model, or the default for all others.

Parameters:

Name Type Description Default
resolution_function ResolutionFunction

The resolution function.

required
model_id str | None

The model id. None sets the default used by every model that has not registered a resolution of its own.

None
Source code in src/easyreflectometry/calculators/calculator_base.py
336
337
338
339
340
341
342
343
344
345
346
347
def set_resolution_function(self, resolution_function: ResolutionFunction, model_id: str | None = None) -> None:
    """Set the resolution function of one model, or the default for all others.

    Parameters
    ----------
    resolution_function : ResolutionFunction
        The resolution function.
    model_id : str | None, optional
        The model id. ``None`` sets the default used by every model that
        has not registered a resolution of its own.
    """
    return self._wrapper.set_resolution_function(resolution_function, model_id)

resolution_function_for(model_id=None)

The resolution function used when calculating the model with model_id.

Source code in src/easyreflectometry/calculators/calculator_base.py
349
350
351
def resolution_function_for(self, model_id: str | None = None) -> ResolutionFunction:
    """The resolution function used when calculating the model with *model_id*."""
    return self._wrapper.resolution_function_for(model_id)

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
358
359
360
361
362
363
364
365
366
367
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)

LinkPlan dataclass

Source code in src/easyreflectometry/contrasts.py
228
229
230
231
232
233
234
235
236
237
238
@dataclass
class LinkPlan:
    follower: Model
    reference: Model
    rows: list[LinkRow]

    def outdated(self, current: 'LinkPlan') -> list[str]:
        """Paths of the rows to tie that `current` (the same link, planned now) no longer allows:
        the parameters changed, or the row is already tied, shared with another model, ..."""
        rows = {row.path: row for row in current.rows}
        return [row.path for row in self.rows if row.action == 'tie' and not _tieable_now(row, rows.get(row.path))]

outdated(current)

Paths of the rows to tie that current (the same link, planned now) no longer allows: the parameters changed, or the row is already tied, shared with another model, ...

Source code in src/easyreflectometry/contrasts.py
234
235
236
237
238
def outdated(self, current: 'LinkPlan') -> list[str]:
    """Paths of the rows to tie that `current` (the same link, planned now) no longer allows:
    the parameters changed, or the row is already tied, shared with another model, ..."""
    rows = {row.path: row for row in current.rows}
    return [row.path for row in self.rows if row.action == 'tie' and not _tieable_now(row, rows.get(row.path))]

LinkRecord dataclass

The equalities one :meth:Project.apply_link made, so they can be undone together.

Source code in src/easyreflectometry/contrasts.py
250
251
252
253
254
255
256
257
@dataclass
class LinkRecord:
    """The equalities one :meth:`Project.apply_link` made, so they can be undone together."""

    follower: Model
    reference: Model
    #: (follower parameter, reference parameter, its :func:`snapshot` from before the tie)
    pairs: list[tuple[Parameter, Parameter, dict]]

ReplaceFormula dataclass

Give the contrast's copy of target another chemical formula (isotopic substitution).

Source code in src/easyreflectometry/contrasts.py
70
71
72
73
74
75
@dataclass(frozen=True)
class ReplaceFormula:
    """Give the contrast's copy of `target` another chemical formula (isotopic substitution)."""

    target: Union[LayerAreaPerMolecule, MaterialDensity]
    formula: str

ReplaceMaterial dataclass

Use new wherever the reference uses old (a material, or a mixture's component).

Source code in src/easyreflectometry/contrasts.py
62
63
64
65
66
67
@dataclass(frozen=True)
class ReplaceMaterial:
    """Use `new` wherever the reference uses `old` (a material, or a mixture's component)."""

    old: Union[Material, MaterialMixture]
    new: Union[Material, MaterialMixture]

DataSet1D

Bases: SerializerComponent

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
 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
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,
        resolution_function: Optional[ResolutionFunction] = None,
    ):
        """Init function.

        Parameters
        ----------
        resolution_function : Optional[ResolutionFunction], optional
            The q-resolution this dataset was measured with (typically a
            ``Pointwise`` built from its sQz column). Fitting and model
            curves evaluated against this dataset smear with it instead of
            the model's resolution. ``None`` (the default) means the model's
            resolution applies; it is not derived from ``xe`` here, see
            :func:`easyreflectometry.data.resolution_from_dataset`.
        """
        self._model = model
        self.resolution_function = resolution_function
        #: Whether :meth:`Project.prepare_fit` fits this experiment by default.
        self.include_in_fit = True
        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, resolution_function=None)

Init function.

Parameters:

Name Type Description Default
resolution_function Optional[ResolutionFunction]

The q-resolution this dataset was measured with (typically a Pointwise built from its sQz column). Fitting and model curves evaluated against this dataset smear with it instead of the model's resolution. None (the default) means the model's resolution applies; it is not derived from xe here, see :func:easyreflectometry.data.resolution_from_dataset.

None
Source code in src/easyreflectometry/data/data_store.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
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,
    resolution_function: Optional[ResolutionFunction] = None,
):
    """Init function.

    Parameters
    ----------
    resolution_function : Optional[ResolutionFunction], optional
        The q-resolution this dataset was measured with (typically a
        ``Pointwise`` built from its sQz column). Fitting and model
        curves evaluated against this dataset smear with it instead of
        the model's resolution. ``None`` (the default) means the model's
        resolution applies; it is not derived from ``xe`` here, see
        :func:`easyreflectometry.data.resolution_from_dataset`.
    """
    self._model = model
    self.resolution_function = resolution_function
    #: Whether :meth:`Project.prepare_fit` fits this experiment by default.
    self.include_in_fit = True
    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
101
102
103
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
105
106
107
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
217
218
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
        #: Whether :meth:`Project.prepare_fit` fits this experiment (all its channels) by default.
        self.include_in_fit = True

    @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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
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
179
180
181
182
183
184
185
186
187
188
189
190
191
192
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]

FitSettings dataclass

Minimizer choice and its settings, as saved in a project.

Attributes:

Name Type Description
minimizer AvailableMinimizers

The engine and method. An alias member (LMFit, Bumps, DFO) is stored as the member it stands for, so its options keep one key.

mode str

'minimize' or 'sample'. Persists an application's choice of Bayesian sampling; the library does not execute on it (classical fits and mcmc_sample stay separate entry points).

tolerance float | None

Generic convergence tolerance; None leaves it to the engine.

max_evaluations int | None

Generic budget (BUMPS: optimizer steps); None leaves it to the engine.

objective str

Zero-variance handling: 'hybrid', 'mighell' or 'legacy_mask'.

engine_options dict[str, dict[str, Any]]

Method-specific options keyed by minimizer name, then option name, so values of one method are neither lost nor applied when switching.

Source code in src/easyreflectometry/fit_settings.py
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
@dataclass
class FitSettings:
    """Minimizer choice and its settings, as saved in a project.

    Attributes
    ----------
    minimizer : AvailableMinimizers
        The engine and method. An alias member (``LMFit``, ``Bumps``, ``DFO``)
        is stored as the member it stands for, so its options keep one key.
    mode : str
        ``'minimize'`` or ``'sample'``. Persists an application's choice of
        Bayesian sampling; the library does not execute on it (classical fits
        and ``mcmc_sample`` stay separate entry points).
    tolerance : float | None
        Generic convergence tolerance; None leaves it to the engine.
    max_evaluations : int | None
        Generic budget (BUMPS: optimizer steps); None leaves it to the engine.
    objective : str
        Zero-variance handling: ``'hybrid'``, ``'mighell'`` or ``'legacy_mask'``.
    engine_options : dict[str, dict[str, Any]]
        Method-specific options keyed by minimizer name, then option name, so
        values of one method are neither lost nor applied when switching.
    """

    minimizer: AvailableMinimizers = field(default_factory=lambda: DEFAULT_MINIMIZER)
    mode: str = 'minimize'
    tolerance: float | None = None
    max_evaluations: int | None = None
    objective: str = 'hybrid'
    engine_options: dict[str, dict[str, Any]] = field(default_factory=dict)

    def __setattr__(self, name: str, value: Any) -> None:
        if name == 'minimizer':
            value = canonical_minimizer(value)
        super().__setattr__(name, value)

    def active_options(self) -> dict[str, Any]:
        """Options of the selected minimizer.

        Only :meth:`validate` and :meth:`engine_kwargs` read these, so every
        option value reaching an engine has been validated.
        """
        return dict(self.engine_options.get(self.minimizer.name, {}))

    def engine_kwargs(self) -> dict:
        """The per-call keyword arguments for the selected minimizer.

        LMFit: ``minimizer_kwargs`` with the options and the tolerance under its
        method's keyword. DFO-LS: the options as plain keyword arguments.
        BUMPS: none (its tolerance and budget are core fitter attributes).
        """
        options = validate_options(self.minimizer, self.active_options())
        if self.minimizer.package == 'lm':
            keyword = _LMFIT_TOLERANCE_KEYWORD.get(self.minimizer.method)
            if self.tolerance is not None and keyword is not None:
                options[keyword] = self.tolerance
            return {'minimizer_kwargs': options} if options else {}
        return options

    def set_option(self, name: str, value: Any) -> None:
        """Set (or, with None, clear) an option of the selected minimizer.

        Raises
        ------
        ValueError
            If the option is unknown or the value invalid; nothing is changed.
        """
        options = self.active_options()
        if value is None:
            options.pop(name, None)
        else:
            validate_options(self.minimizer, {name: value})
            options[name] = value
        if options:
            self.engine_options[self.minimizer.name] = options
        else:
            self.engine_options.pop(self.minimizer.name, None)

    def validate(self) -> None:
        """Check every field; raises ValueError naming the first offending one."""
        if not isinstance(self.minimizer, AvailableMinimizers):
            raise ValueError(f'minimizer must be an AvailableMinimizers member, got {self.minimizer!r}.')
        _check_mode(self.mode, self.minimizer)
        _check_tolerance(self.tolerance, self.minimizer)
        _check_budget(self.max_evaluations)
        normalize_objective(self.objective)
        _check_trust_region(self.minimizer, self.tolerance, validate_options(self.minimizer, self.active_options()))

    def configure(self, fitter) -> None:
        """Write the minimizer and generic settings onto a core fitter.

        The minimizer is switched only when it differs (core rebuilds it on
        every switch). Tolerance and budget are written unconditionally, so a
        field reset to None clears a previous override; LMFit's tolerance goes
        per call instead (:meth:`engine_kwargs`), so the core fitter's stays None.
        """
        if fitter.minimizer.enum is not self.minimizer:
            fitter.switch_minimizer(self.minimizer)
        fitter.tolerance = None if self.minimizer.package == 'lm' else self.tolerance
        fitter.max_evaluations = self.max_evaluations

    def to_dict(self) -> dict:
        """Serializable form, as stored in a project file."""
        return {
            'minimizer': self.minimizer.name,
            'mode': self.mode,
            'tolerance': self.tolerance,
            'max_evaluations': self.max_evaluations,
            'objective': self.objective,
            'engine_options': copy.deepcopy(self.engine_options),
        }

    @classmethod
    def from_dict(cls, data: dict) -> tuple['FitSettings', list[str]]:
        """Settings from a project file, never raising on content.

        Aliases are normalised, option keys included (the member's own key wins
        over its alias's). Content that is not a mapping, an unknown or
        unavailable minimizer, an invalid value or a sampling mode without
        BUMPS falls back to the default, and unknown or invalid options are
        dropped; each is reported.

        Returns
        -------
        tuple[FitSettings, list[str]]
            The settings and the warnings to show the user.
        """
        if not isinstance(data, dict):
            return cls(), [f'Fit settings must be a mapping, got {type(data).__name__}; using the defaults.']
        report: list[str] = []
        settings = cls(minimizer=_minimizer_from_name(data.get('minimizer'), report))

        for name, check, fallback in (
            ('mode', lambda v: _check_mode(v, settings.minimizer), 'Using classical minimization.'),
            ('objective', normalize_objective, 'Using hybrid.'),
            ('tolerance', lambda v: _check_tolerance(v, settings.minimizer), 'Using the engine default.'),
            ('max_evaluations', _check_budget, 'Using the engine default.'),
        ):
            if name not in data:
                continue
            try:
                setattr(settings, name, check(data[name]))
            except ValueError as error:
                report.append(f'{error} {fallback}')

        engine_options = data.get('engine_options')
        if engine_options is None:
            engine_options = {}
        elif not isinstance(engine_options, dict):
            report.append(f'Minimizer options must be a mapping, got {type(engine_options).__name__}; dropped.')
            engine_options = {}
        # Aliases first, so a member's own options win over its alias's.
        for name, options in sorted(engine_options.items(), key=lambda item: item[0] not in _MINIMIZER_ALIASES):
            key = _MINIMIZER_ALIASES.get(name, name) if isinstance(name, str) else name
            minimizer = AvailableMinimizers.__members__.get(key) if isinstance(key, str) else None
            try:
                if minimizer is None:
                    raise ValueError(f'{name!r} is not an available minimizer.')
                if not isinstance(options, dict):
                    raise ValueError(f'the options of {name} must be a mapping, got {type(options).__name__}.')
                valid = validate_options(minimizer, dict(options))
                if valid:
                    settings.engine_options.setdefault(key, {}).update(valid)
            except ValueError as error:
                report.append(f'Minimizer options dropped: {error}')

        try:
            _check_trust_region(settings.minimizer, settings.tolerance, settings.active_options())
        except ValueError as error:
            report.append(f'{error} Using the engine default.')
            settings.set_option('rhobeg', None)
        return settings, report

active_options()

Options of the selected minimizer.

Only :meth:validate and :meth:engine_kwargs read these, so every option value reaching an engine has been validated.

Source code in src/easyreflectometry/fit_settings.py
220
221
222
223
224
225
226
def active_options(self) -> dict[str, Any]:
    """Options of the selected minimizer.

    Only :meth:`validate` and :meth:`engine_kwargs` read these, so every
    option value reaching an engine has been validated.
    """
    return dict(self.engine_options.get(self.minimizer.name, {}))

engine_kwargs()

The per-call keyword arguments for the selected minimizer.

LMFit: minimizer_kwargs with the options and the tolerance under its method's keyword. DFO-LS: the options as plain keyword arguments. BUMPS: none (its tolerance and budget are core fitter attributes).

Source code in src/easyreflectometry/fit_settings.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
def engine_kwargs(self) -> dict:
    """The per-call keyword arguments for the selected minimizer.

    LMFit: ``minimizer_kwargs`` with the options and the tolerance under its
    method's keyword. DFO-LS: the options as plain keyword arguments.
    BUMPS: none (its tolerance and budget are core fitter attributes).
    """
    options = validate_options(self.minimizer, self.active_options())
    if self.minimizer.package == 'lm':
        keyword = _LMFIT_TOLERANCE_KEYWORD.get(self.minimizer.method)
        if self.tolerance is not None and keyword is not None:
            options[keyword] = self.tolerance
        return {'minimizer_kwargs': options} if options else {}
    return options

set_option(name, value)

Set (or, with None, clear) an option of the selected minimizer.

Raises:

Type Description
ValueError

If the option is unknown or the value invalid; nothing is changed.

Source code in src/easyreflectometry/fit_settings.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def set_option(self, name: str, value: Any) -> None:
    """Set (or, with None, clear) an option of the selected minimizer.

    Raises
    ------
    ValueError
        If the option is unknown or the value invalid; nothing is changed.
    """
    options = self.active_options()
    if value is None:
        options.pop(name, None)
    else:
        validate_options(self.minimizer, {name: value})
        options[name] = value
    if options:
        self.engine_options[self.minimizer.name] = options
    else:
        self.engine_options.pop(self.minimizer.name, None)

validate()

Check every field; raises ValueError naming the first offending one.

Source code in src/easyreflectometry/fit_settings.py
262
263
264
265
266
267
268
269
270
def validate(self) -> None:
    """Check every field; raises ValueError naming the first offending one."""
    if not isinstance(self.minimizer, AvailableMinimizers):
        raise ValueError(f'minimizer must be an AvailableMinimizers member, got {self.minimizer!r}.')
    _check_mode(self.mode, self.minimizer)
    _check_tolerance(self.tolerance, self.minimizer)
    _check_budget(self.max_evaluations)
    normalize_objective(self.objective)
    _check_trust_region(self.minimizer, self.tolerance, validate_options(self.minimizer, self.active_options()))

configure(fitter)

Write the minimizer and generic settings onto a core fitter.

The minimizer is switched only when it differs (core rebuilds it on every switch). Tolerance and budget are written unconditionally, so a field reset to None clears a previous override; LMFit's tolerance goes per call instead (:meth:engine_kwargs), so the core fitter's stays None.

Source code in src/easyreflectometry/fit_settings.py
272
273
274
275
276
277
278
279
280
281
282
283
def configure(self, fitter) -> None:
    """Write the minimizer and generic settings onto a core fitter.

    The minimizer is switched only when it differs (core rebuilds it on
    every switch). Tolerance and budget are written unconditionally, so a
    field reset to None clears a previous override; LMFit's tolerance goes
    per call instead (:meth:`engine_kwargs`), so the core fitter's stays None.
    """
    if fitter.minimizer.enum is not self.minimizer:
        fitter.switch_minimizer(self.minimizer)
    fitter.tolerance = None if self.minimizer.package == 'lm' else self.tolerance
    fitter.max_evaluations = self.max_evaluations

to_dict()

Serializable form, as stored in a project file.

Source code in src/easyreflectometry/fit_settings.py
285
286
287
288
289
290
291
292
293
294
def to_dict(self) -> dict:
    """Serializable form, as stored in a project file."""
    return {
        'minimizer': self.minimizer.name,
        'mode': self.mode,
        'tolerance': self.tolerance,
        'max_evaluations': self.max_evaluations,
        'objective': self.objective,
        'engine_options': copy.deepcopy(self.engine_options),
    }

from_dict(data) classmethod

Settings from a project file, never raising on content.

Aliases are normalised, option keys included (the member's own key wins over its alias's). Content that is not a mapping, an unknown or unavailable minimizer, an invalid value or a sampling mode without BUMPS falls back to the default, and unknown or invalid options are dropped; each is reported.

Returns:

Type Description
tuple[FitSettings, list[str]]

The settings and the warnings to show the user.

Source code in src/easyreflectometry/fit_settings.py
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
@classmethod
def from_dict(cls, data: dict) -> tuple['FitSettings', list[str]]:
    """Settings from a project file, never raising on content.

    Aliases are normalised, option keys included (the member's own key wins
    over its alias's). Content that is not a mapping, an unknown or
    unavailable minimizer, an invalid value or a sampling mode without
    BUMPS falls back to the default, and unknown or invalid options are
    dropped; each is reported.

    Returns
    -------
    tuple[FitSettings, list[str]]
        The settings and the warnings to show the user.
    """
    if not isinstance(data, dict):
        return cls(), [f'Fit settings must be a mapping, got {type(data).__name__}; using the defaults.']
    report: list[str] = []
    settings = cls(minimizer=_minimizer_from_name(data.get('minimizer'), report))

    for name, check, fallback in (
        ('mode', lambda v: _check_mode(v, settings.minimizer), 'Using classical minimization.'),
        ('objective', normalize_objective, 'Using hybrid.'),
        ('tolerance', lambda v: _check_tolerance(v, settings.minimizer), 'Using the engine default.'),
        ('max_evaluations', _check_budget, 'Using the engine default.'),
    ):
        if name not in data:
            continue
        try:
            setattr(settings, name, check(data[name]))
        except ValueError as error:
            report.append(f'{error} {fallback}')

    engine_options = data.get('engine_options')
    if engine_options is None:
        engine_options = {}
    elif not isinstance(engine_options, dict):
        report.append(f'Minimizer options must be a mapping, got {type(engine_options).__name__}; dropped.')
        engine_options = {}
    # Aliases first, so a member's own options win over its alias's.
    for name, options in sorted(engine_options.items(), key=lambda item: item[0] not in _MINIMIZER_ALIASES):
        key = _MINIMIZER_ALIASES.get(name, name) if isinstance(name, str) else name
        minimizer = AvailableMinimizers.__members__.get(key) if isinstance(key, str) else None
        try:
            if minimizer is None:
                raise ValueError(f'{name!r} is not an available minimizer.')
            if not isinstance(options, dict):
                raise ValueError(f'the options of {name} must be a mapping, got {type(options).__name__}.')
            valid = validate_options(minimizer, dict(options))
            if valid:
                settings.engine_options.setdefault(key, {}).update(valid)
        except ValueError as error:
            report.append(f'Minimizer options dropped: {error}')

    try:
        _check_trust_region(settings.minimizer, settings.tolerance, settings.active_options())
    except ValueError as error:
        report.append(f'{error} Using the engine default.')
        settings.set_option('rhobeg', None)
    return settings, report

FitRun dataclass

The record of one completed (or failed, or cancelled) fit, kept by the project.

It belongs to the run, not to a fitter, so it outlives a change of the current model; and it names its datasets, so it can be read after the experiments were rearranged.

Statistics (only for a completed run):

  • pooled: objective_chi2 (the engine's, on the transformed fitted points), objective_n_points, objective_dof (points minus :attr:n_free_parameters), objective_reduced_chi2; and the same four classical_* values over the measured points with a positive variance. A reduced value is None when its dof is not positive.
  • per_dataset, in :attr:inputs order: objective_chi2, objective_n_points, objective_chi2_per_point, classical_chi2, classical_n_points, classical_chi2_per_point (None without points) and share_of_objective (None when the pooled chi-square is zero). The per-dataset objective values add up to the pooled one: the engine's per-dataset results are disjoint slices of the pooled residuals.

A record is history: read it, do not write to its dictionaries.

Source code in src/easyreflectometry/fitting.py
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
@dataclass(frozen=True)
class FitRun:
    """The record of one completed (or failed, or cancelled) fit, kept by the project.

    It belongs to the run, not to a fitter, so it outlives a change of the
    current model; and it names its datasets, so it can be read after the
    experiments were rearranged.

    Statistics (only for a completed run):

    - ``pooled``: ``objective_chi2`` (the engine's, on the transformed fitted
      points), ``objective_n_points``, ``objective_dof`` (points minus
      :attr:`n_free_parameters`), ``objective_reduced_chi2``; and the same
      four ``classical_*`` values over the measured points with a positive
      variance. A reduced value is None when its dof is not positive.
    - ``per_dataset``, in :attr:`inputs` order: ``objective_chi2``,
      ``objective_n_points``, ``objective_chi2_per_point``, ``classical_chi2``,
      ``classical_n_points``, ``classical_chi2_per_point`` (None without
      points) and ``share_of_objective`` (None when the pooled chi-square is
      zero). The per-dataset objective values add up to the pooled one: the
      engine's per-dataset results are disjoint slices of the pooled residuals.

    A record is history: read it, do not write to its dictionaries.
    """

    status: str
    completed_at: str
    minimizer: str
    objective: str
    #: ``(experiment key, experiment name, channel)`` per fitted dataset; the key
    #: is None for data that is not (or no longer) a project experiment.
    inputs: tuple
    n_free_parameters: int = 0
    pooled: dict = field(default_factory=dict)
    per_dataset: tuple = ()

    @classmethod
    def from_results(
        cls,
        prepared: PreparedFit,
        results: list[FitResults] | None,
        status: str,
        minimizer: str,
        metrics: list[dict] | None = None,
        inputs: Sequence[tuple] | None = None,
    ) -> 'FitRun':
        """The record of a run; `metrics` is ``prepared.finalize(results)`` when already
        computed, `inputs` the ``(key, name, channel)`` of each dataset (unnamed otherwise)."""
        inputs = tuple(inputs) if inputs else tuple((None, f'dataset {index}', None) for index in range(len(prepared.fitted)))
        completed_at = datetime.datetime.now().isoformat(timespec='seconds')
        if status != 'completed' or not results:
            return cls(status, completed_at, minimizer, prepared.objective, inputs)
        n_free = int(results[0].n_pars)
        per_dataset = [
            {
                'objective_chi2': metric['objective_chi2'],
                'objective_n_points': metric['n_points'],
                'objective_chi2_per_point': _ratio(metric['objective_chi2'], metric['n_points']),
                'classical_chi2': metric['classical_chi2'],
                'classical_n_points': metric['n_classical_points'],
                'classical_chi2_per_point': _ratio(metric['classical_chi2'], metric['n_classical_points']),
            }
            for metric in (metrics if metrics is not None else prepared.finalize(results))
        ]
        pooled = {}
        for kind, points in (('objective', 'objective_n_points'), ('classical', 'classical_n_points')):
            chi2 = float(sum(entry[f'{kind}_chi2'] for entry in per_dataset))
            n_points = int(sum(entry[points] for entry in per_dataset))
            pooled[f'{kind}_chi2'] = chi2
            pooled[f'{kind}_n_points'] = n_points
            pooled[f'{kind}_dof'] = n_points - n_free
            pooled[f'{kind}_reduced_chi2'] = _ratio(chi2, n_points - n_free)
        for entry in per_dataset:
            entry['share_of_objective'] = _ratio(entry['objective_chi2'], pooled['objective_chi2'])
        return cls(status, completed_at, minimizer, prepared.objective, inputs, n_free, pooled, tuple(per_dataset))

    def remapped(self, keys: dict[int, int]) -> 'FitRun':
        """This record after experiments were re-keyed (old key -> new key; a removed one -> None)."""
        inputs = tuple((None if key is None else keys.get(key), name, channel) for key, name, channel in self.inputs)
        return dataclasses.replace(self, inputs=inputs)

from_results(prepared, results, status, minimizer, metrics=None, inputs=None) classmethod

The record of a run; metrics is prepared.finalize(results) when already computed, inputs the (key, name, channel) of each dataset (unnamed otherwise).

Source code in src/easyreflectometry/fitting.py
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
@classmethod
def from_results(
    cls,
    prepared: PreparedFit,
    results: list[FitResults] | None,
    status: str,
    minimizer: str,
    metrics: list[dict] | None = None,
    inputs: Sequence[tuple] | None = None,
) -> 'FitRun':
    """The record of a run; `metrics` is ``prepared.finalize(results)`` when already
    computed, `inputs` the ``(key, name, channel)`` of each dataset (unnamed otherwise)."""
    inputs = tuple(inputs) if inputs else tuple((None, f'dataset {index}', None) for index in range(len(prepared.fitted)))
    completed_at = datetime.datetime.now().isoformat(timespec='seconds')
    if status != 'completed' or not results:
        return cls(status, completed_at, minimizer, prepared.objective, inputs)
    n_free = int(results[0].n_pars)
    per_dataset = [
        {
            'objective_chi2': metric['objective_chi2'],
            'objective_n_points': metric['n_points'],
            'objective_chi2_per_point': _ratio(metric['objective_chi2'], metric['n_points']),
            'classical_chi2': metric['classical_chi2'],
            'classical_n_points': metric['n_classical_points'],
            'classical_chi2_per_point': _ratio(metric['classical_chi2'], metric['n_classical_points']),
        }
        for metric in (metrics if metrics is not None else prepared.finalize(results))
    ]
    pooled = {}
    for kind, points in (('objective', 'objective_n_points'), ('classical', 'classical_n_points')):
        chi2 = float(sum(entry[f'{kind}_chi2'] for entry in per_dataset))
        n_points = int(sum(entry[points] for entry in per_dataset))
        pooled[f'{kind}_chi2'] = chi2
        pooled[f'{kind}_n_points'] = n_points
        pooled[f'{kind}_dof'] = n_points - n_free
        pooled[f'{kind}_reduced_chi2'] = _ratio(chi2, n_points - n_free)
    for entry in per_dataset:
        entry['share_of_objective'] = _ratio(entry['objective_chi2'], pooled['objective_chi2'])
    return cls(status, completed_at, minimizer, prepared.objective, inputs, n_free, pooled, tuple(per_dataset))

remapped(keys)

This record after experiments were re-keyed (old key -> new key; a removed one -> None).

Source code in src/easyreflectometry/fitting.py
544
545
546
547
def remapped(self, keys: dict[int, int]) -> 'FitRun':
    """This record after experiments were re-keyed (old key -> new key; a removed one -> None)."""
    inputs = tuple((None if key is None else keys.get(key), name, channel) for key, name, channel in self.inputs)
    return dataclasses.replace(self, inputs=inputs)

FitScopeError

Bases: ValueError

The experiments asked for cannot be fitted together (none selected, or one has no model).

Source code in src/easyreflectometry/fitting.py
325
326
class FitScopeError(ValueError):
    """The experiments asked for cannot be fitted together (none selected, or one has no model)."""

MultiFitter

Source code in src/easyreflectometry/fitting.py
 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
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] = []
        # Names of the experiments `Project.prepare_sample(skip_invalid=True)`
        # left out (no model in the project); empty otherwise.
        self.skipped: list[str] = []
        # 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
        # The settings runs are prepared from (a snapshot is taken per run).
        # None: the stored core fitter's own minimizer, tolerance and budget.
        self.settings: FitSettings | None = None

    def _build_easy_science_fitter(self, models, fit_funcs, keep_owner: bool = False) -> 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.

        The stored fitter refers to its owner weakly (the owner holds it). A
        prepared run's fitter (``keep_owner``) holds it strongly: the caller may
        keep only the run, and the constraints need the owner at execution.
        """
        fitter = _ConstrainedEasyScienceMultiFitter(models, fit_funcs)
        fitter._constraints_owner = (lambda: self) if keep_owner else 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. :meth:`prepare` (with no inputs)
        prepares a run over :attr:`fit_datasets`, which a GUI can then execute
        from a worker thread. Each fit function is bound to its dataset's own
        resolution. Inequality constraints are resolved at execution through
        :attr:`constraints_factory_provider` — pass ``constraints_factory_provider``
        (e.g. ``project.build_constraints_factory``) or set the attribute
        before fitting; with none set, no inequality constraints are enforced.

        The fitter starts from the core defaults (minimizer, tolerance, budget)
        unless :attr:`settings` is set, as :meth:`Project.prepare_fit` does.

        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)
        fitter.fit_datasets = datasets
        fitter.fit_channels = channels
        # Bound with every point's resolution; `prepare` rebinds for a subset.
        fitter._fit_func = [fitter._bind(item, item.resolution_function) for item in fitter._experiment_inputs()]
        fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fitter._fit_func)
        fitter.constraints_factory_provider = constraints_factory_provider
        return fitter

    def _experiment_inputs(self) -> list[_FitInput]:
        """One input per entry of :attr:`fit_datasets`."""
        inputs = []
        for dataset, channel in zip(self.fit_datasets, self.fit_channels):
            name = getattr(dataset, 'name', None) or 'dataset'
            inputs.append(
                _FitInput(
                    x=np.asarray(dataset.x),
                    y=np.asarray(dataset.y),
                    variances=np.asarray(dataset.ye),
                    model=dataset.model,
                    label=name if channel is None else f'{name} channel {channel.value}',
                    channel=channel,
                    resolution_function=getattr(dataset, 'resolution_function', None),
                )
            )
        return inputs

    def _datagroup_inputs(self, data: sc.DataGroup) -> tuple[list[str], list[_FitInput]]:
        """Inputs for the reflectivity curves of a ``DataGroup``.

        For a fitter built with the models, curve *k* belongs to model *k* and
        is smeared with that model's resolution. For a fitter built with
        :meth:`for_experiments`, curve *k* belongs to entry *k* of
        :attr:`fit_datasets` (one per dataset, one per spin channel of a
        polarized experiment) and keeps that dataset's model, channel and own
        resolution; only the arrays come from the ``DataGroup``.

        Curves are taken in the order of their ``Qz_<n>`` numbers, not of the
        ``DataGroup``'s insertion order (see :func:`_curve_ids`).
        """
        refl_nums = _curve_ids(data)
        if self.fit_datasets:
            experiment_inputs = self._experiment_inputs()
            if len(refl_nums) != len(experiment_inputs):
                raise ValueError(
                    f'The DataGroup holds {len(refl_nums)} reflectivity curve(s) but the fitter was built for '
                    f'{len(experiment_inputs)} dataset(s); pass one curve per dataset (per spin channel of a '
                    'polarized experiment), in the same order, or call without a DataGroup.'
                )
            inputs = [
                dataclasses.replace(
                    item,
                    x=data['coords'][f'Qz_{i}'].values,
                    y=data['data'][f'R_{i}'].values,
                    variances=data['data'][f'R_{i}'].variances,
                    label=f'reflectivity {i}',
                )
                for item, i in zip(experiment_inputs, refl_nums)
            ]
            return refl_nums, inputs
        inputs = [
            _FitInput(
                x=data['coords'][f'Qz_{i}'].values,
                y=data['data'][f'R_{i}'].values,
                variances=data['data'][f'R_{i}'].variances,
                model=self._models[index],
                label=f'reflectivity {i}',
                fit_func=self._fit_func[index],
            )
            for index, i in enumerate(refl_nums)
        ]
        return refl_nums, inputs

    @staticmethod
    def _bind(item: _FitInput, resolution_function: ResolutionFunction | None = None) -> Callable:
        """The fit function of one input, bound to ``resolution_function``.

        With neither a channel nor a resolution of its own, an input carrying a
        stored fit function uses it, so a plain fit reuses the stored core fitter.
        """
        if item.fit_func is not None and item.channel is None and resolution_function is None:
            return item.fit_func
        func = item.model.interface.fit_func_for(channel=item.channel, resolution_function=resolution_function)
        return _bind_fit_func(func, item.model.unique_name)

    def _resolve_objective(self, objective: str | None, settings: FitSettings | None) -> str:
        if objective is not None:
            return _validate_objective(objective)
        if settings is not None:
            return _validate_objective(settings.objective)
        return self._objective

    def prepare(
        self,
        inputs: list[_FitInput] | None = None,
        *,
        objective: str | None = None,
        action: str = 'fitting',
        extra_warning: str = '',
    ) -> PreparedFit:
        """Prepare one fit run: zero-variance handling, bindings, configured core fitter.

        This is the only place those rules are applied; :meth:`fit`,
        :meth:`fit_single_data_set_1d`, :meth:`fit_polarized`,
        :meth:`mcmc_sample` and an application's worker all run what it returns.
        Preparing never changes this fitter, so preparing again (with another
        objective, say) starts from the measured data each time.

        Parameters
        ----------
        inputs : list[_FitInput] | None, optional
            Datasets to fit. By default the :attr:`fit_datasets` set by
            :meth:`for_experiments`.
        objective : str | None, optional
            Zero-variance objective; by default the settings' or this fitter's.
        action : str, optional
            Verb for the zero-variance warnings. By default, 'fitting'.
        extra_warning : str, optional
            Sentence appended to the Mighell warnings. By default, ''.

        Returns
        -------
        PreparedFit
            The run, ready to execute.

        Raises
        ------
        ValueError
            If the settings are invalid (however they were edited), or
            ``legacy_mask`` leaves a dataset without points.
        FitPreconditionError
            If a free parameter's bounds are unusable for the minimizer.

        Note
        ----
        The run owns copies of the measured arrays, so editing a dataset
        afterwards changes neither what it fits nor its metrics. Model
        parameters and constraints stay live: they are what the fit changes.
        """
        if inputs is None:
            inputs = self._experiment_inputs()
        if not inputs:
            raise ValueError('Nothing to fit: no datasets were given.')
        settings = copy.deepcopy(self.settings)
        if settings is not None:
            settings.validate()
        obj = self._resolve_objective(objective, settings)

        original, fitted, stats, fit_funcs, curve_funcs, models = [], [], [], [], [], []
        for item in inputs:
            x_vals, y_vals, variances = np.array(item.x), np.array(item.y), np.array(item.variances)
            x_out, y_eff, weights, item_stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
            _emit_array_prep_warnings(item_stats, y_vals, item.label, action=action, extra=extra_warning)
            if obj == 'legacy_mask' and len(x_out) == 0:
                raise ValueError(f'Cannot fit {item.label}: all points have zero variance.')
            original.append({'x': x_vals, 'y': y_vals, 'variances': variances})
            fitted.append({'x': x_out, 'y': y_eff, 'weights': weights})
            stats.append(item_stats)
            # `legacy_mask` drops points: a per-point resolution must drop the
            # same ones, so the fitted function is bound to the subset while
            # the curve function keeps every point.
            fitted_resolution = _resolution_for_fitted_points(item.resolution_function, x_vals, y_vals, variances, obj)
            fit_funcs.append(self._bind(item, fitted_resolution))
            curve_funcs.append(
                fit_funcs[-1] if fitted_resolution is item.resolution_function else self._bind(item, item.resolution_function)
            )
            if not any(item.model is known for known in models):
                models.append(item.model)

        roots = _free_roots(models)
        core_fitter = self._core_fitter_for(models, fit_funcs, settings, roots)
        self._check_fit_preconditions(core_fitter)
        self._warn_on_bound_starts(core_fitter)
        return PreparedFit(
            settings=settings,
            objective=obj,
            channels=[item.channel for item in inputs],
            original=original,
            fitted=fitted,
            stats=stats,
            curve_funcs=curve_funcs,
            fit_funcs=fit_funcs,
            core_fitter=core_fitter,
            fit_kwargs=settings.engine_kwargs() if settings is not None else {},
            added_roots=roots,
        )

    def _core_fitter_for(self, models: list, fit_funcs: list[Callable], settings: FitSettings | None, roots: list = ()):
        """The core fitter a prepared run executes.

        Without settings, a run over exactly the stored fit functions uses the
        stored core fitter, as direct configuration of it (notebooks, tests)
        expects; any other run gets a fresh one carrying the stored minimizer,
        tolerance and budget. With settings, a fresh one configured from them.
        `roots` (see :func:`_free_roots`) join the fit objects, so the core
        varies them like any free parameter of the models.
        """
        if (
            settings is None
            and not roots
            and len(fit_funcs) == len(self._fit_func)
            and all(a is b for a, b in zip(fit_funcs, self._fit_func))
        ):
            return self.easy_science_multi_fitter
        core_fitter = self._build_easy_science_fitter([*models, *roots], fit_funcs, keep_owner=True)
        if settings is not None:
            settings.configure(core_fitter)
        else:
            stored = self.easy_science_multi_fitter
            core_fitter.switch_minimizer(stored.minimizer.enum)
            core_fitter.tolerance = stored.tolerance
            core_fitter.max_evaluations = stored.max_evaluations
        return core_fitter

    @staticmethod
    def _check_fit_preconditions(core_fitter) -> None:
        """Refuse a run whose minimizer needs finite bounds that some free parameter lacks.

        Checked before any engine call, over the unique free independent
        parameters. Ordered bounds and a value inside them need no check here:
        ``Parameter`` refuses anything else.
        """
        minimizer = getattr(core_fitter, 'minimizer', None)
        enum = getattr(minimizer, 'enum', None)
        if not isinstance(enum, AvailableMinimizers) or not requires_finite_bounds(enum):
            return  # also: a stand-in fitter (tests) has nothing to check
        unbounded, seen = [], set()
        for parameter in core_fitter.fit_object.get_fit_parameters():
            if id(parameter) not in seen and not (np.isfinite(parameter.min) and np.isfinite(parameter.max)):
                unbounded.append(parameter)
            seen.add(id(parameter))
        if unbounded:
            names = ', '.join(f"'{parameter.name}'" for parameter in unbounded)
            raise FitPreconditionError(
                f'{enum.name} requires finite bounds on all free parameters; {names} have none.', unbounded
            )

    @staticmethod
    def _warn_on_bound_starts(core_fitter) -> None:
        """Warn when lmfit's ``leastsq`` would start a free parameter on one of its bounds.

        ``leastsq`` maps bounded parameters through a sine transform whose
        derivative vanishes at the bound, so such a parameter can stay where it
        started and the fit stall. A previous fit that ended on a bound leaves
        exactly this state behind.
        """
        enum = getattr(getattr(core_fitter, 'minimizer', None), 'enum', None)
        if not isinstance(enum, AvailableMinimizers) or (enum.package, enum.method) != ('lm', 'leastsq'):
            return
        on_bound = []
        for parameter in {id(p): p for p in core_fitter.fit_object.get_fit_parameters()}.values():
            span = parameter.max - parameter.min
            tolerance = 1e-6 * span if np.isfinite(span) else 0.0
            if any(
                np.isfinite(bound) and abs(parameter.value - bound) <= tolerance for bound in (parameter.min, parameter.max)
            ):
                on_bound.append(f"'{parameter.name}'")
        if on_bound:
            warnings.warn(
                f'{", ".join(on_bound)} start on a bound. LMFit leastsq may not move such a parameter; '
                'move it inside its range or use LMFit_scipy_least_squares or a BUMPS minimizer.',
                UserWarning,
            )

    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.

        On a fitter built with the models, curve *k* (the *k*-th ``Qz_<n>``
        by number) is fitted with model *k* and its resolution. On a fitter
        built with :meth:`for_experiments`, curve *k* is fitted as entry *k*
        of :attr:`fit_datasets`: that dataset's model, spin channel and own
        resolution, with the arrays taken from the ``DataGroup``. Each
        curve's model curve, SLD profile and model are written back for that
        curve's own model.

        Parameters
        ----------
        data : sc.DataGroup
            DataGroup to be fitted to and populated. For a
            :meth:`for_experiments` fitter it holds one curve per entry of
            :attr:`fit_datasets` (one per spin channel of a polarized
            experiment), in the same order.
        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.

        Raises
        ------
        ValueError
            On a :meth:`for_experiments` fitter, when the number of curves
            differs from the number of entries of :attr:`fit_datasets`.
        """
        refl_nums, inputs = self._datagroup_inputs(data)
        prepared = self.prepare(inputs, objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            result = prepared.execute()
        self.record_fit_results(result, prepared.finalize(result))
        new_data = data.copy()
        for i, _ in enumerate(result):
            id = refl_nums[i]
            model = inputs[i].model
            model_curve = prepared.curve_funcs[i](data['coords'][f'Qz_{id}'].values)
            new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
            sld_profile = model.interface.sld_profile(model.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(model.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,
            )
        new_data['objective_chi2'] = self.objective_chi2
        new_data['objective_reduced_chi'] = self.objective_reduced_chi
        new_data['classical_chi2'] = self.classical_chi2
        new_data['classical_reduced_chi'] = self.classical_reduced_chi
        new_data['reduced_chi'] = self.objective_reduced_chi
        new_data['success'] = all(item.success for item in result)
        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.

        Note
        ----
        When ``data`` carries its own ``resolution_function`` the fit is
        smeared with it rather than with the model's.
        """
        item = _FitInput(
            x=np.asarray(data.x),
            y=np.asarray(data.y),
            variances=np.asarray(data.ye),
            model=self._models[0],
            label='single-dataset fit',
            resolution_function=getattr(data, 'resolution_function', None),
            fit_func=self._fit_func[0],
        )
        prepared = self.prepare([item], objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            result = prepared.execute()[0]
        self.record_fit_results([result], prepared.finalize([result]))
        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.
        """
        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.')
        inputs = []
        for channel in data.available_channels:
            dataset = data[channel]
            if dataset.model is not model:
                raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")
            inputs.append(
                _FitInput(
                    x=np.asarray(dataset.x),
                    y=np.asarray(dataset.y),
                    variances=np.asarray(dataset.ye),
                    model=model,
                    label=f'channel {channel.value}',
                    channel=channel,
                    resolution_function=getattr(dataset, 'resolution_function', None),
                )
            )
        prepared = self.prepare(inputs, objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            results = prepared.execute()
        # 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` rely on that invariant.
        self.record_fit_results(results, prepared.finalize(results))
        return {channel.value: result for channel, result in zip(prepared.channels, results)}

    def mcmc_sample(
        self,
        data: sc.DataGroup | None = None,
        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. May be left out for a
            fitter built with :meth:`for_experiments`: the sampler then runs
            on :attr:`fit_datasets`, each dataset (each spin channel of a
            polarized experiment) smeared with its own resolution, exactly as
            :meth:`prepare` fits them. With a ``DataGroup`` and such a fitter,
            curve *k* must be dataset *k*; its arrays are sampled with that
            dataset's model, channel and resolution.
        :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.
        :raises ValueError: Without ``data`` on a fitter not built with
            :meth:`for_experiments`, or with a ``DataGroup`` whose curve count
            differs from the fitter's datasets.

        The sampler reads none of the generic settings (tolerance, budget):
        only the minimizer, which must be a BUMPS one.

        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)
        """
        # Checked before preparing, so a wrong engine is reported before any data warnings.
        minimizer = self.settings.minimizer if self.settings is not None else self.easy_science_multi_fitter.minimizer
        if getattr(minimizer, 'package', None) != 'bumps':
            raise RuntimeError(
                'Bayesian sampling requires a BUMPS minimizer. Select one first, e.g. '
                '``project.minimizer = AvailableMinimizers.Bumps_simplex`` or '
                '``fitter.switch_minimizer(AvailableMinimizers.Bumps_simplex)``.'
            )
        obj = self._resolve_objective(objective, self.settings)
        if data is None:
            if not self.fit_datasets:
                raise ValueError('mcmc_sample needs a DataGroup unless the fitter was built with MultiFitter.for_experiments.')
            inputs = self._experiment_inputs()
        else:
            _, inputs = self._datagroup_inputs(data)
        for item in inputs:
            if obj != 'mighell' and np.all(np.asarray(item.variances) <= 0.0):
                raise ValueError(
                    f'Cannot run Bayesian sampling on {item.label}: 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).'
                )
        prepared = self.prepare(
            inputs,
            objective=obj,
            action='sampling',
            extra_warning=(
                ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
                'posterior widths may be unreliable.'
            ),
        )
        core_fitter = prepared.core_fitter

        # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``,
        # which 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, fitter=core_fitter):
            # The factory is current only while the problem is being built, so
            # `_keep_constraints_on_extend` covers a later continuation.
            sampler = Sampler(core_fitter, x=prepared.x, y=prepared.y, weights=prepared.weights)
            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.

        These properties describe this fitter's latest fit; a run prepared through
        :meth:`Project.prepare_fit` is recorded in :attr:`Project.last_fit`, which
        outlives a change of the current model.
        """
        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, metrics: list[dict] | None = None) -> None:
        """Adopt fit results produced elsewhere, so this fitter reports on them.

        An application that executes a :class:`PreparedFit` itself — 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.

        Parameters
        ----------
        results : list[FitResults] | None
            Results of the fit, one per fitted dataset. None clears them.
        metrics : list[dict] | None, optional
            :meth:`PreparedFit.finalize` of the same run; without it the
            classical metrics stay None, as they need the measured arrays.
        """
        self._fit_results = list(results) if results else None
        self._classical_fit_metrics = list(metrics) if (results and metrics) else None

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

        A fitter with :attr:`settings` (one a project hands out) switches the
        settings' minimizer, as its runs are configured from them; for a
        project's fitter that is ``project.minimizer``.

        Parameters
        ----------
        minimizer : AvailableMinimizers
            Minimizer to be switched to.
        """
        if self.settings is None:
            self.easy_science_multi_fitter.switch_minimizer(minimizer)
            return
        self.settings.minimizer = minimizer
        self.settings.configure(self.easy_science_multi_fitter)

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.

These properties describe this fitter's latest fit; a run prepared through :meth:Project.prepare_fit is recorded in :attr:Project.last_fit, which outlives a change of the current model.

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
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
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] = []
    # Names of the experiments `Project.prepare_sample(skip_invalid=True)`
    # left out (no model in the project); empty otherwise.
    self.skipped: list[str] = []
    # 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
    # The settings runs are prepared from (a snapshot is taken per run).
    # None: the stored core fitter's own minimizer, tolerance and budget.
    self.settings: FitSettings | 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. :meth:prepare (with no inputs) prepares a run over :attr:fit_datasets, which a GUI can then execute from a worker thread. Each fit function is bound to its dataset's own resolution. Inequality constraints are resolved at execution through :attr:constraints_factory_provider — pass constraints_factory_provider (e.g. project.build_constraints_factory) or set the attribute before fitting; with none set, no inequality constraints are enforced.

The fitter starts from the core defaults (minimizer, tolerance, budget) unless :attr:settings is set, as :meth:Project.prepare_fit does.

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
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
@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. :meth:`prepare` (with no inputs)
    prepares a run over :attr:`fit_datasets`, which a GUI can then execute
    from a worker thread. Each fit function is bound to its dataset's own
    resolution. Inequality constraints are resolved at execution through
    :attr:`constraints_factory_provider` — pass ``constraints_factory_provider``
    (e.g. ``project.build_constraints_factory``) or set the attribute
    before fitting; with none set, no inequality constraints are enforced.

    The fitter starts from the core defaults (minimizer, tolerance, budget)
    unless :attr:`settings` is set, as :meth:`Project.prepare_fit` does.

    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)
    fitter.fit_datasets = datasets
    fitter.fit_channels = channels
    # Bound with every point's resolution; `prepare` rebinds for a subset.
    fitter._fit_func = [fitter._bind(item, item.resolution_function) for item in fitter._experiment_inputs()]
    fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fitter._fit_func)
    fitter.constraints_factory_provider = constraints_factory_provider
    return fitter

prepare(inputs=None, *, objective=None, action='fitting', extra_warning='')

Prepare one fit run: zero-variance handling, bindings, configured core fitter.

This is the only place those rules are applied; :meth:fit, :meth:fit_single_data_set_1d, :meth:fit_polarized, :meth:mcmc_sample and an application's worker all run what it returns. Preparing never changes this fitter, so preparing again (with another objective, say) starts from the measured data each time.

Parameters:

Name Type Description Default
inputs list[_FitInput] | None

Datasets to fit. By default the :attr:fit_datasets set by :meth:for_experiments.

None
objective str | None

Zero-variance objective; by default the settings' or this fitter's.

None
action str

Verb for the zero-variance warnings. By default, 'fitting'.

'fitting'
extra_warning str

Sentence appended to the Mighell warnings. By default, ''.

''

Returns:

Type Description
PreparedFit

The run, ready to execute.

Raises:

Type Description
ValueError

If the settings are invalid (however they were edited), or legacy_mask leaves a dataset without points.

FitPreconditionError

If a free parameter's bounds are unusable for the minimizer.

Note

The run owns copies of the measured arrays, so editing a dataset afterwards changes neither what it fits nor its metrics. Model parameters and constraints stay live: they are what the fit changes.

Source code in src/easyreflectometry/fitting.py
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
def prepare(
    self,
    inputs: list[_FitInput] | None = None,
    *,
    objective: str | None = None,
    action: str = 'fitting',
    extra_warning: str = '',
) -> PreparedFit:
    """Prepare one fit run: zero-variance handling, bindings, configured core fitter.

    This is the only place those rules are applied; :meth:`fit`,
    :meth:`fit_single_data_set_1d`, :meth:`fit_polarized`,
    :meth:`mcmc_sample` and an application's worker all run what it returns.
    Preparing never changes this fitter, so preparing again (with another
    objective, say) starts from the measured data each time.

    Parameters
    ----------
    inputs : list[_FitInput] | None, optional
        Datasets to fit. By default the :attr:`fit_datasets` set by
        :meth:`for_experiments`.
    objective : str | None, optional
        Zero-variance objective; by default the settings' or this fitter's.
    action : str, optional
        Verb for the zero-variance warnings. By default, 'fitting'.
    extra_warning : str, optional
        Sentence appended to the Mighell warnings. By default, ''.

    Returns
    -------
    PreparedFit
        The run, ready to execute.

    Raises
    ------
    ValueError
        If the settings are invalid (however they were edited), or
        ``legacy_mask`` leaves a dataset without points.
    FitPreconditionError
        If a free parameter's bounds are unusable for the minimizer.

    Note
    ----
    The run owns copies of the measured arrays, so editing a dataset
    afterwards changes neither what it fits nor its metrics. Model
    parameters and constraints stay live: they are what the fit changes.
    """
    if inputs is None:
        inputs = self._experiment_inputs()
    if not inputs:
        raise ValueError('Nothing to fit: no datasets were given.')
    settings = copy.deepcopy(self.settings)
    if settings is not None:
        settings.validate()
    obj = self._resolve_objective(objective, settings)

    original, fitted, stats, fit_funcs, curve_funcs, models = [], [], [], [], [], []
    for item in inputs:
        x_vals, y_vals, variances = np.array(item.x), np.array(item.y), np.array(item.variances)
        x_out, y_eff, weights, item_stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)
        _emit_array_prep_warnings(item_stats, y_vals, item.label, action=action, extra=extra_warning)
        if obj == 'legacy_mask' and len(x_out) == 0:
            raise ValueError(f'Cannot fit {item.label}: all points have zero variance.')
        original.append({'x': x_vals, 'y': y_vals, 'variances': variances})
        fitted.append({'x': x_out, 'y': y_eff, 'weights': weights})
        stats.append(item_stats)
        # `legacy_mask` drops points: a per-point resolution must drop the
        # same ones, so the fitted function is bound to the subset while
        # the curve function keeps every point.
        fitted_resolution = _resolution_for_fitted_points(item.resolution_function, x_vals, y_vals, variances, obj)
        fit_funcs.append(self._bind(item, fitted_resolution))
        curve_funcs.append(
            fit_funcs[-1] if fitted_resolution is item.resolution_function else self._bind(item, item.resolution_function)
        )
        if not any(item.model is known for known in models):
            models.append(item.model)

    roots = _free_roots(models)
    core_fitter = self._core_fitter_for(models, fit_funcs, settings, roots)
    self._check_fit_preconditions(core_fitter)
    self._warn_on_bound_starts(core_fitter)
    return PreparedFit(
        settings=settings,
        objective=obj,
        channels=[item.channel for item in inputs],
        original=original,
        fitted=fitted,
        stats=stats,
        curve_funcs=curve_funcs,
        fit_funcs=fit_funcs,
        core_fitter=core_fitter,
        fit_kwargs=settings.engine_kwargs() if settings is not None else {},
        added_roots=roots,
    )

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

Perform the fitting and populate the DataGroups with the result.

On a fitter built with the models, curve k (the k-th Qz_<n> by number) is fitted with model k and its resolution. On a fitter built with :meth:for_experiments, curve k is fitted as entry k of :attr:fit_datasets: that dataset's model, spin channel and own resolution, with the arrays taken from the DataGroup. Each curve's model curve, SLD profile and model are written back for that curve's own model.

Parameters:

Name Type Description Default
data sc.DataGroup

DataGroup to be fitted to and populated. For a :meth:for_experiments fitter it holds one curve per entry of :attr:fit_datasets (one per spin channel of a polarized experiment), in the same order.

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.

Raises:

Type Description
ValueError

On a :meth:for_experiments fitter, when the number of curves differs from the number of entries of :attr:fit_datasets.

Source code in src/easyreflectometry/fitting.py
 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
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.

    On a fitter built with the models, curve *k* (the *k*-th ``Qz_<n>``
    by number) is fitted with model *k* and its resolution. On a fitter
    built with :meth:`for_experiments`, curve *k* is fitted as entry *k*
    of :attr:`fit_datasets`: that dataset's model, spin channel and own
    resolution, with the arrays taken from the ``DataGroup``. Each
    curve's model curve, SLD profile and model are written back for that
    curve's own model.

    Parameters
    ----------
    data : sc.DataGroup
        DataGroup to be fitted to and populated. For a
        :meth:`for_experiments` fitter it holds one curve per entry of
        :attr:`fit_datasets` (one per spin channel of a polarized
        experiment), in the same order.
    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.

    Raises
    ------
    ValueError
        On a :meth:`for_experiments` fitter, when the number of curves
        differs from the number of entries of :attr:`fit_datasets`.
    """
    refl_nums, inputs = self._datagroup_inputs(data)
    prepared = self.prepare(inputs, objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        result = prepared.execute()
    self.record_fit_results(result, prepared.finalize(result))
    new_data = data.copy()
    for i, _ in enumerate(result):
        id = refl_nums[i]
        model = inputs[i].model
        model_curve = prepared.curve_funcs[i](data['coords'][f'Qz_{id}'].values)
        new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
        sld_profile = model.interface.sld_profile(model.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(model.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,
        )
    new_data['objective_chi2'] = self.objective_chi2
    new_data['objective_reduced_chi'] = self.objective_reduced_chi
    new_data['classical_chi2'] = self.classical_chi2
    new_data['classical_reduced_chi'] = self.classical_reduced_chi
    new_data['reduced_chi'] = self.objective_reduced_chi
    new_data['success'] = all(item.success for item in result)
    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.

Note

When data carries its own resolution_function the fit is smeared with it rather than with the model's.

Source code in src/easyreflectometry/fitting.py
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
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.

    Note
    ----
    When ``data`` carries its own ``resolution_function`` the fit is
    smeared with it rather than with the model's.
    """
    item = _FitInput(
        x=np.asarray(data.x),
        y=np.asarray(data.y),
        variances=np.asarray(data.ye),
        model=self._models[0],
        label='single-dataset fit',
        resolution_function=getattr(data, 'resolution_function', None),
        fit_func=self._fit_func[0],
    )
    prepared = self.prepare([item], objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        result = prepared.execute()[0]
    self.record_fit_results([result], prepared.finalize([result]))
    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
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
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.
    """
    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.')
    inputs = []
    for channel in data.available_channels:
        dataset = data[channel]
        if dataset.model is not model:
            raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")
        inputs.append(
            _FitInput(
                x=np.asarray(dataset.x),
                y=np.asarray(dataset.y),
                variances=np.asarray(dataset.ye),
                model=model,
                label=f'channel {channel.value}',
                channel=channel,
                resolution_function=getattr(dataset, 'resolution_function', None),
            )
        )
    prepared = self.prepare(inputs, objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        results = prepared.execute()
    # 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` rely on that invariant.
    self.record_fit_results(results, prepared.finalize(results))
    return {channel.value: result for channel, result in zip(prepared.channels, results)}

mcmc_sample(data=None, 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. May be left out for a fitter built with :meth:for_experiments: the sampler then runs on :attr:fit_datasets, each dataset (each spin channel of a polarized experiment) smeared with its own resolution, exactly as :meth:prepare fits them. With a DataGroup and such a fitter, curve k must be dataset k; its arrays are sampled with that dataset's model, channel and resolution. :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. :raises ValueError: Without data on a fitter not built with :meth:for_experiments, or with a DataGroup whose curve count differs from the fitter's datasets.

The sampler reads none of the generic settings (tolerance, budget): only the minimizer, which must be a BUMPS one.

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
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
def mcmc_sample(
    self,
    data: sc.DataGroup | None = None,
    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. May be left out for a
        fitter built with :meth:`for_experiments`: the sampler then runs
        on :attr:`fit_datasets`, each dataset (each spin channel of a
        polarized experiment) smeared with its own resolution, exactly as
        :meth:`prepare` fits them. With a ``DataGroup`` and such a fitter,
        curve *k* must be dataset *k*; its arrays are sampled with that
        dataset's model, channel and resolution.
    :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.
    :raises ValueError: Without ``data`` on a fitter not built with
        :meth:`for_experiments`, or with a ``DataGroup`` whose curve count
        differs from the fitter's datasets.

    The sampler reads none of the generic settings (tolerance, budget):
    only the minimizer, which must be a BUMPS one.

    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)
    """
    # Checked before preparing, so a wrong engine is reported before any data warnings.
    minimizer = self.settings.minimizer if self.settings is not None else self.easy_science_multi_fitter.minimizer
    if getattr(minimizer, 'package', None) != 'bumps':
        raise RuntimeError(
            'Bayesian sampling requires a BUMPS minimizer. Select one first, e.g. '
            '``project.minimizer = AvailableMinimizers.Bumps_simplex`` or '
            '``fitter.switch_minimizer(AvailableMinimizers.Bumps_simplex)``.'
        )
    obj = self._resolve_objective(objective, self.settings)
    if data is None:
        if not self.fit_datasets:
            raise ValueError('mcmc_sample needs a DataGroup unless the fitter was built with MultiFitter.for_experiments.')
        inputs = self._experiment_inputs()
    else:
        _, inputs = self._datagroup_inputs(data)
    for item in inputs:
        if obj != 'mighell' and np.all(np.asarray(item.variances) <= 0.0):
            raise ValueError(
                f'Cannot run Bayesian sampling on {item.label}: 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).'
            )
    prepared = self.prepare(
        inputs,
        objective=obj,
        action='sampling',
        extra_warning=(
            ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
            'posterior widths may be unreliable.'
        ),
    )
    core_fitter = prepared.core_fitter

    # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``,
    # which 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, fitter=core_fitter):
        # The factory is current only while the problem is being built, so
        # `_keep_constraints_on_extend` covers a later continuation.
        sampler = Sampler(core_fitter, x=prepared.x, y=prepared.y, weights=prepared.weights)
        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, metrics=None)

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

An application that executes a :class:PreparedFit itself — 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.

Parameters:

Name Type Description Default
results list[FitResults] | None

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

required
metrics list[dict] | None

:meth:PreparedFit.finalize of the same run; without it the classical metrics stay None, as they need the measured arrays.

None
Source code in src/easyreflectometry/fitting.py
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
def record_fit_results(self, results: list[FitResults] | None, metrics: list[dict] | None = None) -> None:
    """Adopt fit results produced elsewhere, so this fitter reports on them.

    An application that executes a :class:`PreparedFit` itself — 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.

    Parameters
    ----------
    results : list[FitResults] | None
        Results of the fit, one per fitted dataset. None clears them.
    metrics : list[dict] | None, optional
        :meth:`PreparedFit.finalize` of the same run; without it the
        classical metrics stay None, as they need the measured arrays.
    """
    self._fit_results = list(results) if results else None
    self._classical_fit_metrics = list(metrics) if (results and metrics) else None

switch_minimizer(minimizer)

Switch the minimizer for the fitting.

A fitter with :attr:settings (one a project hands out) switches the settings' minimizer, as its runs are configured from them; for a project's fitter that is project.minimizer.

Parameters:

Name Type Description Default
minimizer AvailableMinimizers

Minimizer to be switched to.

required
Source code in src/easyreflectometry/fitting.py
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
def switch_minimizer(self, minimizer: AvailableMinimizers) -> None:
    """Switch the minimizer for the fitting.

    A fitter with :attr:`settings` (one a project hands out) switches the
    settings' minimizer, as its runs are configured from them; for a
    project's fitter that is ``project.minimizer``.

    Parameters
    ----------
    minimizer : AvailableMinimizers
        Minimizer to be switched to.
    """
    if self.settings is None:
        self.easy_science_multi_fitter.switch_minimizer(minimizer)
        return
    self.settings.minimizer = minimizer
    self.settings.configure(self.easy_science_multi_fitter)

PreparedFit dataclass

Everything one fit run needs, fixed when it is prepared.

Built by :meth:MultiFitter.prepare, the one place that applies the zero-variance objective, decides which resolution each fit function is bound to, and configures the core fitter. Settings edited afterwards do not reach it.

Attributes:

Name Type Description
settings FitSettings | None

Snapshot of the settings; None for a fitter without settings.

objective str

The resolved zero-variance objective.

channels list

Spin channel per dataset (None when unpolarized); keys polarized results.

original list[dict]

x, y, variances as measured, all points; for the classical metrics.

fitted list[dict]

x, y, weights handed to the engine.

stats list[dict]

Zero-variance handling counts per dataset.

curve_funcs list[Callable]

Full-resolution evaluators, for model curves and classical metrics.

fit_funcs list[Callable]

Evaluators bound to the fitted points' resolution, as executed.

core_fitter EasyScienceMultiFitter

The fitter that is executed.

fit_kwargs dict

Method-specific option keyword arguments for the engine.

added_roots list[Parameter]

Free parameters the fitted models follow but do not own (a tie to a model left out of the fit), varied so the tie does not hold them fixed.

experiments list

The project experiment behind each dataset (one entry per spin channel of a polarized one), set by :meth:Project.prepare_fit; empty otherwise.

skipped list[str]

Experiments left out because they have no model (skip_invalid).

Source code in src/easyreflectometry/fitting.py
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
@dataclass
class PreparedFit:
    """Everything one fit run needs, fixed when it is prepared.

    Built by :meth:`MultiFitter.prepare`, the one place that applies the
    zero-variance objective, decides which resolution each fit function is
    bound to, and configures the core fitter. Settings edited afterwards do not
    reach it.

    Attributes
    ----------
    settings : FitSettings | None
        Snapshot of the settings; None for a fitter without settings.
    objective : str
        The resolved zero-variance objective.
    channels : list
        Spin channel per dataset (None when unpolarized); keys polarized results.
    original : list[dict]
        ``x``, ``y``, ``variances`` as measured, all points; for the classical metrics.
    fitted : list[dict]
        ``x``, ``y``, ``weights`` handed to the engine.
    stats : list[dict]
        Zero-variance handling counts per dataset.
    curve_funcs : list[Callable]
        Full-resolution evaluators, for model curves and classical metrics.
    fit_funcs : list[Callable]
        Evaluators bound to the fitted points' resolution, as executed.
    core_fitter : EasyScienceMultiFitter
        The fitter that is executed.
    fit_kwargs : dict
        Method-specific option keyword arguments for the engine.
    added_roots : list[Parameter]
        Free parameters the fitted models follow but do not own (a tie to a
        model left out of the fit), varied so the tie does not hold them fixed.
    experiments : list
        The project experiment behind each dataset (one entry per spin channel
        of a polarized one), set by :meth:`Project.prepare_fit`; empty otherwise.
    skipped : list[str]
        Experiments left out because they have no model (``skip_invalid``).
    """

    settings: FitSettings | None
    objective: str
    channels: list
    original: list[dict]
    fitted: list[dict]
    stats: list[dict]
    curve_funcs: list[Callable]
    fit_funcs: list[Callable]
    core_fitter: EasyScienceMultiFitter
    fit_kwargs: dict
    added_roots: list = field(default_factory=list)
    experiments: list = field(default_factory=list)
    skipped: list = field(default_factory=list)

    @property
    def x(self) -> list[np.ndarray]:
        return [arrays['x'] for arrays in self.fitted]

    @property
    def y(self) -> list[np.ndarray]:
        return [arrays['y'] for arrays in self.fitted]

    @property
    def weights(self) -> list[np.ndarray]:
        return [arrays['weights'] for arrays in self.fitted]

    def call_kwargs(self, **kwargs) -> dict:
        """The engine keyword arguments of this run merged with per-call ones (which win)."""
        merged = copy.deepcopy(self.fit_kwargs)
        explicit = kwargs.pop('minimizer_kwargs', None)
        if explicit:
            merged['minimizer_kwargs'] = {**merged.get('minimizer_kwargs', {}), **explicit}
        merged.update(kwargs)
        return merged

    def execute(self, **kwargs) -> list[FitResults]:
        """Run the fit synchronously; ``kwargs`` go to the core fitter's ``fit``."""
        return self.core_fitter.fit(x=self.x, y=self.y, weights=self.weights, **self.call_kwargs(**kwargs))

    def finalize(self, results: list[FitResults]) -> list[dict]:
        """Objective and classical metrics of a finished run, one dict per dataset.

        The classical metrics are computed from the measured arrays and the
        full-resolution curves, not from the transformed fitted arrays.
        """
        return [
            _classical_metrics_for(original, curve(original['x']), result, n_points=len(fitted['x']))
            for original, fitted, curve, result in zip(self.original, self.fitted, self.curve_funcs, results)
        ]

call_kwargs(**kwargs)

The engine keyword arguments of this run merged with per-call ones (which win).

Source code in src/easyreflectometry/fitting.py
439
440
441
442
443
444
445
446
def call_kwargs(self, **kwargs) -> dict:
    """The engine keyword arguments of this run merged with per-call ones (which win)."""
    merged = copy.deepcopy(self.fit_kwargs)
    explicit = kwargs.pop('minimizer_kwargs', None)
    if explicit:
        merged['minimizer_kwargs'] = {**merged.get('minimizer_kwargs', {}), **explicit}
    merged.update(kwargs)
    return merged

execute(**kwargs)

Run the fit synchronously; kwargs go to the core fitter's fit.

Source code in src/easyreflectometry/fitting.py
448
449
450
def execute(self, **kwargs) -> list[FitResults]:
    """Run the fit synchronously; ``kwargs`` go to the core fitter's ``fit``."""
    return self.core_fitter.fit(x=self.x, y=self.y, weights=self.weights, **self.call_kwargs(**kwargs))

finalize(results)

Objective and classical metrics of a finished run, one dict per dataset.

The classical metrics are computed from the measured arrays and the full-resolution curves, not from the transformed fitted arrays.

Source code in src/easyreflectometry/fitting.py
452
453
454
455
456
457
458
459
460
461
def finalize(self, results: list[FitResults]) -> list[dict]:
    """Objective and classical metrics of a finished run, one dict per dataset.

    The classical metrics are computed from the measured arrays and the
    full-resolution curves, not from the transformed fitted arrays.
    """
    return [
        _classical_metrics_for(original, curve(original['x']), result, n_points=len(fitted['x']))
        for original, fitted, curve, result in zip(self.original, self.fitted, self.curve_funcs, results)
    ]

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
 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
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
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,
        guide_field_angle: Union[Parameter, Number, None] = None,
        applied_field: Union[Parameter, Number, None] = 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.
        guide_field_angle : Union[Parameter, Number, None], optional
            Field angle about the beam axis in degrees (refl1d ``Aguide``). By default, 270 (in-plane).
        applied_field : Union[Parameter, Number, None], optional
            Applied field in tesla (refl1d ``H``). By default, 0.
        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)
        guide_field_angle = get_as_parameter('guide_field_angle', guide_field_angle, DEFAULTS)
        applied_field = get_as_parameter('applied_field', applied_field, 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
        self._guide_field_angle = guide_field_angle
        self._applied_field = applied_field

        # 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

    @property
    def guide_field_angle(self) -> Parameter:
        """Field angle about the beam axis in degrees (refl1d ``Aguide``); only magnetic calculators use it."""
        return self._guide_field_angle

    @guide_field_angle.setter
    def guide_field_angle(self, value: float) -> None:
        self._guide_field_angle.value = value

    @property
    def applied_field(self) -> Parameter:
        """Applied field in tesla (refl1d ``H``); only magnetic calculators use it."""
        return self._applied_field

    @applied_field.setter
    def applied_field(self, value: float) -> None:
        self._applied_field.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
            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
            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:
            # Registered per model: one calculator serves every model of a
            # project, each possibly with a different resolution.
            self.interface().set_resolution_function(self._resolution_function, self.unique_name)

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

    def generate_bindings(self) -> None:
        """Propagate the interface over the sample tree, then register this
        model's resolution function on the calculator.

        Runs on every interface assignment and on every engine switch (the
        project regenerates bindings on a fresh calculator then), so the new
        calculator always knows this model's resolution.
        """
        super().generate_bindings()
        self._interface().set_resolution_function(self._resolution_function, self.unique_name)

    # ----- 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

        if self.guide_field_angle.value != 270.0 or self.applied_field.value != 0.0:
            warnings.warn(
                'The guide field angle and applied field are not written: the ORSO model language has no field.',
                UserWarning,
                stacklevel=2,
            )
        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

guide_field_angle property writable

Field angle about the beam axis in degrees (refl1d Aguide); only magnetic calculators use it.

applied_field property writable

Applied field in tesla (refl1d H); only magnetic calculators use it.

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, guide_field_angle=None, applied_field=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
guide_field_angle Union[Parameter, Number, None]

Field angle about the beam axis in degrees (refl1d Aguide). By default, 270 (in-plane).

None
applied_field Union[Parameter, Number, None]

Applied field in tesla (refl1d H). By default, 0.

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/model/model.py
 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
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,
    guide_field_angle: Union[Parameter, Number, None] = None,
    applied_field: Union[Parameter, Number, None] = 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.
    guide_field_angle : Union[Parameter, Number, None], optional
        Field angle about the beam axis in degrees (refl1d ``Aguide``). By default, 270 (in-plane).
    applied_field : Union[Parameter, Number, None], optional
        Applied field in tesla (refl1d ``H``). By default, 0.
    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)
    guide_field_angle = get_as_parameter('guide_field_angle', guide_field_angle, DEFAULTS)
    applied_field = get_as_parameter('applied_field', applied_field, 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
    self._guide_field_angle = guide_field_angle
    self._applied_field = applied_field

    # 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

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
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
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
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
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

Index of the item or layer to duplicate.

required
Source code in src/easyreflectometry/model/model.py
285
286
287
288
289
290
291
292
293
294
295
def duplicate_assembly(self, index: int) -> None:
    """Duplicate a given item or layer in a sample.

    Parameters
    ----------
    index : int
        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

Index of the item to remove.

required
Source code in src/easyreflectometry/model/model.py
297
298
299
300
301
302
303
304
305
306
307
308
def remove_assembly(self, index: int) -> None:
    """Remove an assembly from the model.

    Parameters
    ----------
    index : int
        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)

generate_bindings()

Propagate the interface over the sample tree, then register this model's resolution function on the calculator.

Runs on every interface assignment and on every engine switch (the project regenerates bindings on a fresh calculator then), so the new calculator always knows this model's resolution.

Source code in src/easyreflectometry/model/model.py
343
344
345
346
347
348
349
350
351
352
def generate_bindings(self) -> None:
    """Propagate the interface over the sample tree, then register this
    model's resolution function on the calculator.

    Runs on every interface assignment and on every engine switch (the
    project regenerates bindings on a fresh calculator then), so the new
    calculator always knows this model's resolution.
    """
    super().generate_bindings()
    self._interface().set_resolution_function(self._resolution_function, 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
377
378
379
380
381
382
383
384
385
386
387
388
389
390
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
392
393
394
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
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
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

    if self.guide_field_angle.value != 270.0 or self.applied_field.value != 0.0:
        warnings.warn(
            'The guide field angle and applied field are not written: the ORSO model language has no field.',
            UserWarning,
            stacklevel=2,
        )
    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
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
@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}

ResolutionFunction

Source code in src/easyreflectometry/model/resolution_functions.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class ResolutionFunction:
    @abstractmethod
    def smearing(self, q: Union[np.array, float]) -> np.array:
        """Return the resolution as sigma (standard deviation) at each ``q``."""
        ...

    @abstractmethod
    def as_dict(self, skip: Optional[List[str]] = None) -> dict: ...

    @classmethod
    def from_dict(cls, data: dict) -> ResolutionFunction:
        """Smearing function."""
        if data['smearing'] == 'PercentageFwhm':
            return PercentageFwhm(data['constant'])
        if data['smearing'] == 'LinearSpline':
            return LinearSpline(data['q_data_points'], data['fwhm_values'])
        if data['smearing'] == 'Pointwise':
            return Pointwise([
                data['q_data_points'],
                data['R_data_points'],
                data['sQz_data_points'],
            ])
        raise ValueError('Unknown resolution function type')

smearing(q) abstractmethod

Return the resolution as sigma (standard deviation) at each q.

Source code in src/easyreflectometry/model/resolution_functions.py
35
36
37
38
@abstractmethod
def smearing(self, q: Union[np.array, float]) -> np.array:
    """Return the resolution as sigma (standard deviation) at each ``q``."""
    ...

from_dict(data) classmethod

Smearing function.

Source code in src/easyreflectometry/model/resolution_functions.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
@classmethod
def from_dict(cls, data: dict) -> ResolutionFunction:
    """Smearing function."""
    if data['smearing'] == 'PercentageFwhm':
        return PercentageFwhm(data['constant'])
    if data['smearing'] == 'LinearSpline':
        return LinearSpline(data['q_data_points'], data['fwhm_values'])
    if data['smearing'] == 'Pointwise':
        return Pointwise([
            data['q_data_points'],
            data['R_data_points'],
            data['sQz_data_points'],
        ])
    raise ValueError('Unknown resolution function type')

Layer

Bases: BaseCore

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

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

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

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

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

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

        if interface is not None:
            self.interface = interface

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

rho_m_coupled property writable

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

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

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

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

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
material Union[Material, None]

The material for the layer. By default, None.

None
thickness Union[Parameter, float, None]

Layer thickness in Angstrom. By default, None.

None
roughness Union[Parameter, float, None]

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

None
magnetism Union[LayerMagnetism, None]

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

None
rho_m_coupled bool

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

False
name str

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

'EasyLayer'
interface

Interface object. By default, None.

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

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

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

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

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

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

    if interface is not None:
        self.interface = interface

interface(new_interface)

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

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

assign_material(material)

Assign a material to the layer interface.

Parameters:

Name Type Description Default
material Material

The material to assign to the layer.

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

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

Material

Bases: BaseCore

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

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

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

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

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

        if interface is not None:
            self.interface = interface

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

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

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

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

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

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

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
sld Union[Parameter, float, None]

Real scattering length density. By default, None.

None
isld Union[Parameter, float, None]

Imaginary scattering length density. By default, None.

None
name str

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

'EasyMaterial'
interface

Calculator interface. By default, None.

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

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

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

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

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

    if interface is not None:
        self.interface = interface

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
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
            Index of the material to duplicate.
        """
        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

Index of the material to duplicate.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

superphase property

The superphase of the sample.

subphase property

The subphase of the sample.

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

Constructor.

Parameters:

Name Type Description Default
**kwargs
{}
populate_if_none bool

By default, True.

True
unique_name Optional[str]

By default, None.

None
*assemblies Optional[List[BaseAssembly]]

The assemblies in the sample.

()
name str

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

'EasySample'
interface

Calculator interface. By default, None.

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

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

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

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

add_assembly(assembly=None)

Add an assembly to the sample.

Parameters:

Name Type Description Default
assembly Optional[BaseAssembly]

Assembly to add. By default, None.

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

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

duplicate_assembly(index)

Duplicate an assembly of the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to duplicate.

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

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

move_up(index)

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

Parameters:

Name Type Description Default
index int

Index of the assembly to move up.

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

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

move_down(index)

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

Parameters:

Name Type Description Default
index int

Index of the assembly to move down.

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

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

remove_assembly(index)

Remove the assembly at the given index from the sample.

Parameters:

Name Type Description Default
index int

Index of the assembly to remove.

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

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

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

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

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

Parameters:

Name Type Description Default
z np.ndarray

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

None
max_delta_z float

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

None
merge_equivalent bool

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

True

Returns:

Type Description
VolumeFractionProfile

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

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

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

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

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

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
 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
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
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. `data` and `protected_types` are
        # `EasyList`'s own list-valued arguments (`from_dict` passes the
        # latter as a list of classes) and must reach it untouched.
        extra_items = []
        for key in list(kwargs.keys()):
            if isinstance(kwargs[key], list) and kwargs[key] and key not in ('data', 'protected_types'):
                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')
        # The items this encodes (through `_convert_to_dict`) are replaced
        # below, so they must not be registered as shared-object occurrences.
        with references.suspended():
            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)

    @classmethod
    def from_dict(cls, obj_dict: dict) -> BaseCollection:
        """Rebuild the collection, each item through its own class's ``from_dict``.

        ``EasyList.from_dict`` deserializes the ``data`` list with the
        serializer's generic path, which calls every item's constructor with
        the stored keys. An item whose class restores state in ``from_dict``
        (e.g. ``MaterialDensity`` and its ``sld_coupled`` key, or any nested
        item that does) then fails or loses that state. Apart from the items,
        this mirrors ``EasyList.from_dict``.
        """
        if not isinstance(obj_dict, dict) or '@module' not in obj_dict or '@class' not in obj_dict:
            raise ValueError('Input must be a dictionary representing an EasyScience collection.')
        if obj_dict['@class'] != cls.__name__:
            raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.')
        temp_dict = copy.deepcopy(obj_dict)
        protected_types = None
        if 'protected_types' in temp_dict:
            protected_types = []
            for type_dict in temp_dict.pop('protected_types'):
                if '@module' not in type_dict or '@class' not in type_dict:
                    raise ValueError('Each protected type must be a serialized EasyScience class with @module and @class keys')
                module = __import__(type_dict['@module'], globals(), locals(), [type_dict['@class']], 0)
                if not hasattr(module, type_dict['@class']):
                    raise ImportError(f'Could not import class {type_dict["@class"]} from module {type_dict["@module"]}')
                protected_types.append(getattr(module, type_dict['@class']))
        raw_items = temp_dict.pop('data', [])
        kwargs = SerializerBase.deserialize_dict(temp_dict)
        data = [references.deserialize(item) for item in raw_items]
        return cls(data, protected_types=protected_types, **kwargs)

    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
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
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
172
173
174
175
176
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
178
179
180
181
182
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
184
185
186
187
188
189
190
191
192
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
196
197
198
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
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
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
230
231
232
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
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
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')
    # The items this encodes (through `_convert_to_dict`) are replaced
    # below, so they must not be registered as shared-object occurrences.
    with references.suspended():
        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
327
328
329
def as_dict(self, skip: Optional[List[str]] = None) -> dict:
    """Compatibility alias for :meth:`to_dict`."""
    return self.to_dict(skip=skip)

from_dict(obj_dict) classmethod

Rebuild the collection, each item through its own class's from_dict.

EasyList.from_dict deserializes the data list with the serializer's generic path, which calls every item's constructor with the stored keys. An item whose class restores state in from_dict (e.g. MaterialDensity and its sld_coupled key, or any nested item that does) then fails or loses that state. Apart from the items, this mirrors EasyList.from_dict.

Source code in src/easyreflectometry/sample/collections/base_collection.py
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
@classmethod
def from_dict(cls, obj_dict: dict) -> BaseCollection:
    """Rebuild the collection, each item through its own class's ``from_dict``.

    ``EasyList.from_dict`` deserializes the ``data`` list with the
    serializer's generic path, which calls every item's constructor with
    the stored keys. An item whose class restores state in ``from_dict``
    (e.g. ``MaterialDensity`` and its ``sld_coupled`` key, or any nested
    item that does) then fails or loses that state. Apart from the items,
    this mirrors ``EasyList.from_dict``.
    """
    if not isinstance(obj_dict, dict) or '@module' not in obj_dict or '@class' not in obj_dict:
        raise ValueError('Input must be a dictionary representing an EasyScience collection.')
    if obj_dict['@class'] != cls.__name__:
        raise ValueError(f'Class name in dictionary does not match the expected class: {cls.__name__}.')
    temp_dict = copy.deepcopy(obj_dict)
    protected_types = None
    if 'protected_types' in temp_dict:
        protected_types = []
        for type_dict in temp_dict.pop('protected_types'):
            if '@module' not in type_dict or '@class' not in type_dict:
                raise ValueError('Each protected type must be a serialized EasyScience class with @module and @class keys')
            module = __import__(type_dict['@module'], globals(), locals(), [type_dict['@class']], 0)
            if not hasattr(module, type_dict['@class']):
                raise ImportError(f'Could not import class {type_dict["@class"]} from module {type_dict["@module"]}')
            protected_types.append(getattr(module, type_dict['@class']))
    raw_items = temp_dict.pop('data', [])
    kwargs = SerializerBase.deserialize_dict(temp_dict)
    data = [references.deserialize(item) for item in raw_items]
    return cls(data, protected_types=protected_types, **kwargs)

__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
362
363
364
365
366
367
368
369
370
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']))

ModelRemoval dataclass

What removing a model affects (see :meth:Project.plan_model_removal).

Source code in src/easyreflectometry/project.py
122
123
124
125
126
127
128
129
130
131
132
133
134
@dataclass(frozen=True)
class ModelRemoval:
    """What removing a model affects (see :meth:`Project.plan_model_removal`)."""

    #: Keys of the experiments bound to the model.
    experiments: List[int]
    #: Parameters of other models that follow a parameter only this model owns.
    dependents: List[Parameter]
    #: Inequality constraints on parameters only this model owns.
    inequality_constraints: List[InequalitySpec]
    #: Old key to new key of every remaining experiment, once :meth:`Project.remove_model_at_index`
    #: removed the experiments (as :meth:`Project.remove_experiment` returns); empty otherwise.
    remap: Dict[int, int] = field(default_factory=dict)

Project

Source code in src/easyreflectometry/project.py
 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
2131
2132
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
2170
2171
2172
2173
2174
2175
2176
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
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
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
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
2916
2917
2918
2919
2920
2921
2922
2923
2924
2925
2926
2927
2928
2929
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
2993
2994
2995
2996
2997
2998
2999
3000
3001
3002
3003
3004
3005
3006
3007
3008
3009
3010
3011
3012
3013
3014
3015
3016
3017
3018
3019
3020
3021
3022
3023
3024
3025
3026
3027
3028
3029
3030
3031
3032
3033
3034
3035
3036
3037
3038
3039
3040
3041
3042
3043
3044
3045
3046
3047
3048
3049
3050
3051
3052
3053
3054
3055
3056
3057
3058
3059
3060
3061
3062
3063
3064
3065
3066
3067
3068
3069
3070
3071
3072
3073
3074
3075
3076
3077
3078
3079
3080
3081
3082
3083
3084
3085
3086
3087
3088
3089
3090
3091
3092
3093
3094
3095
3096
3097
3098
3099
3100
3101
3102
3103
3104
3105
3106
3107
3108
3109
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._fit_settings = FitSettings()
        #: Warnings from the last :meth:`from_dict`, for the user; cleared by each load.
        self.load_report: list[str] = []
        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] = []
        self._last_fit = None
        #: (derived model, the model it was derived from), see :meth:`add_contrast`.
        self._contrasts: List[tuple] = []
        self._links: List[LinkRecord] = []

        # 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)

        # The field only acts on a magnetic model under a magnetic calculator;
        # elsewhere it is inert and must not show up as a fit control. The
        # fitter reads `fixed`, not `enabled`, so an inert field is fixed too.
        supports_magnetism = self.calculator_supports_magnetism
        for model in self._models:
            active = supports_magnetism and model.has_magnetism
            for parameter in (model.guide_field_angle, model.applied_field):
                parameter.enabled = active
                if not active:
                    _fix_if_free(parameter)
        self._fix_unused_magnetic_material_inputs()

    def _fix_unused_magnetic_material_inputs(self) -> None:
        """Keep the inputs of a magnetic material that acts on nothing out of the fit.

        A :class:`MaterialMagnetic`'s moment only reaches the reflectivity
        through a layer coupled to it (``Layer.rho_m_coupled``); with its nuclear
        SLD decoupled, its density is in the same position. Either one left
        free after the last coupled layer goes would be a zero-sensitivity fit
        parameter, so it is fixed here, on every path that re-syncs the
        project (layer and coupling edits, engine switches, loading).
        """
        coupled: set[int] = set()
        magnetic: dict[int, object] = {}
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    material = getattr(layer, 'material', None)
                    if getattr(material, 'magnetic_moment', None) is not None:
                        magnetic[id(material)] = material
                    if getattr(layer, 'rho_m_coupled', False):
                        coupled.add(id(material))
        for material in self._materials or []:
            if getattr(material, 'magnetic_moment', None) is not None:
                magnetic.setdefault(id(material), material)
        if not magnetic:
            return
        # A moment or density another parameter depends on (a user constraint)
        # still acts; the material's own derived SLDs do not count.
        own = {id(parameter) for material in magnetic.values() for parameter in (material.rho_m, material.sld, material.isld)}
        referenced = {
            id(source)
            for parameter in self.parameters
            if not parameter.independent and id(parameter) not in own
            for source in parameter.dependency_map.values()
        }
        for material_id, material in magnetic.items():
            if material_id in coupled:
                continue
            if id(material.magnetic_moment) not in referenced:
                _fix_if_free(material.magnetic_moment)
            if not material.sld_coupled and id(material.density) not in referenced:
                _fix_if_free(material.density)

    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')
            # A dependent parameter (a conformal follower) takes its bounds from its leader.
            if getattr(parameter, 'enabled', True) and parameter.independent:
                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)
        self._invalidate_fitter()
        # 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).
        self._extend_palette_from_models()
        for model in self._models:
            model.interface = self._calculator
        self._sync_parameter_states()

    @property
    def fitter(self) -> MultiFitter:
        """The fitter of the current model, configured from :attr:`fit_settings`.

        Every hand-out writes the project's settings onto it, so configure the
        fit through :attr:`fit_settings` (or :attr:`minimizer`), not through
        the returned fitter's core fitter.
        """
        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.settings = self._fit_settings
                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)
            self._fit_settings.configure(self._fitter.easy_science_multi_fitter)
        return self._fitter

    def _invalidate_fitter(self) -> None:
        """Drop the cached fitter; the next :attr:`fitter` access builds a new one."""
        self._fitter = None
        self._fitter_model_index = None

    @property
    def fit_settings(self) -> FitSettings:
        """The project's fit configuration; edits apply to the next fit prepared."""
        return self._fit_settings

    @fit_settings.setter
    def fit_settings(self, settings: FitSettings) -> None:
        settings.validate()
        self._fit_settings = settings
        if self._fitter is not None:
            self._fitter.settings = settings

    def prepare_fit(
        self,
        experiments: Optional[list] = None,
        *,
        objective: Optional[str] = None,
        skip_invalid: bool = False,
    ) -> PreparedFit:
        """Prepare a fit of the experiments with a snapshot of :attr:`fit_settings`.

        The run is not executed: call :meth:`PreparedFit.execute`, or run its
        ``core_fitter`` in a worker thread, then hand the results to
        :meth:`record_fit`. Inequality constraints are resolved when it executes.

        A parameter of a fitted model that follows a free parameter of a model
        left out of the fit keeps following it, and that parameter is varied
        too (``prepared.added_roots``); the left-out data does not enter the fit.

        Parameters
        ----------
        experiments : list, optional
            The experiments to fit, in order. By default, the loaded ones
            whose ``include_in_fit`` is set.
        objective : str, optional
            Zero-variance objective overriding the settings' one.
        skip_invalid : bool, optional
            Leave out experiments that have no model in this project (listed
            in ``prepared.skipped``) instead of raising. By default, False.

        Returns
        -------
        PreparedFit
            The prepared run.

        Raises
        ------
        FitScopeError
            Nothing to fit, or an experiment has no model in this project.
        FitPreconditionError
            If a free parameter's bounds are unusable for the minimizer.
        """
        experiments, invalid = self._fit_scope(experiments, skip_invalid, action='fit')
        fitter = self._fitter_for(experiments)
        prepared = fitter.prepare(objective=objective)
        prepared.skipped = [experiment.name for experiment in invalid]
        prepared.experiments = [
            experiment for experiment in experiments for _ in (getattr(experiment, 'available_channels', None) or [None])
        ]
        return prepared

    def prepare_sample(
        self,
        experiments: Optional[list] = None,
        *,
        skip_invalid: bool = False,
    ) -> MultiFitter:
        """A fitter ready for Bayesian sampling of the experiments, with :attr:`fit_settings`.

        The sampling is not run: call ``mcmc_sample()`` on the result (with
        no ``DataGroup``), from a worker thread if need be. Each dataset --
        each spin channel of a polarized experiment -- is sampled with the
        resolution it was measured with, exactly as :meth:`prepare_fit` fits
        it, so the posterior and the classical fit agree on what the model
        is compared to. Inequality constraints are resolved when it runs.

        Parameters
        ----------
        experiments : list, optional
            The experiments to sample, in order. By default, the loaded ones
            whose ``include_in_fit`` is set.
        skip_invalid : bool, optional
            Leave out experiments that have no model in this project instead
            of raising. By default, False.

        Returns
        -------
        MultiFitter
            The fitter, built with :meth:`MultiFitter.for_experiments`. Its
            ``settings`` are a snapshot of :attr:`fit_settings` taken here, as
            :meth:`prepare_fit` takes one: an edit made before ``mcmc_sample()``
            runs applies to the next sampling, not to this one. Its
            ``skipped`` lists the names of the experiments left out by
            ``skip_invalid``, like ``PreparedFit.skipped``.

        Raises
        ------
        FitScopeError
            Nothing to sample, or an experiment has no model in this project.
        """
        experiments, invalid = self._fit_scope(experiments, skip_invalid, action='sample')
        fitter = self._fitter_for(experiments)
        fitter.skipped = [experiment.name for experiment in invalid]
        return fitter

    def _fit_scope(self, experiments: Optional[list], skip_invalid: bool, action: str) -> tuple[list, list]:
        """The experiments a fit or sampling covers, and the ones left out for lack of a model.

        `action` is the verb for the messages: ``'fit'`` or ``'sample'``.
        """
        if experiments is None:
            experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
        invalid = [experiment for experiment in experiments if self._model_index(experiment.model) is None]
        if invalid and not skip_invalid:
            names = ', '.join(f"'{experiment.name}'" for experiment in invalid)
            done = {'fit': 'fitted', 'sample': 'sampled'}[action]
            raise FitScopeError(f'{names} cannot be {done}: no model of this project is assigned.')
        experiments = [experiment for experiment in experiments if experiment not in invalid]
        if not experiments:
            raise FitScopeError(f'No experiment to {action}: include at least one experiment that has a model.')
        return experiments, invalid

    def _fitter_for(self, experiments: list) -> MultiFitter:
        """A `for_experiments` fitter over `experiments`, with a snapshot of this project's settings and its constraints.

        The snapshot is taken now, so a fit or a sampling prepared here is not
        changed by a settings edit made before it runs (sampling prepares its
        run only when ``mcmc_sample()`` is called, possibly in another thread).
        """
        fitter = MultiFitter.for_experiments(experiments, constraints_factory_provider=_weak_constraints_provider(self))
        fitter.settings = copy.deepcopy(self._fit_settings)
        return fitter

    def fit_parameters(self, experiments: Optional[list] = None) -> List[Parameter]:
        """The parameters a fit of `experiments` varies, each once; nothing is prepared.

        The free parameters of their models, and the free parameters of other
        models those follow (see :meth:`prepare_fit`). Experiments without a
        model of this project contribute nothing.

        Parameters
        ----------
        experiments : list, optional
            By default, the loaded ones whose ``include_in_fit`` is set.
        """
        if experiments is None:
            experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
        models = []
        for experiment in experiments:
            index = self._model_index(experiment.model)
            if index is not None and not any(model is self._models[index] for model in models):
                models.append(self._models[index])
        parameters = {id(parameter): parameter for model in models for parameter in model.get_free_parameters()}
        for root in _free_roots(models):
            parameters.setdefault(id(root), root)
        return list(parameters.values())

    def record_fit(self, prepared: PreparedFit, results: Optional[list] = None, status: str = 'completed') -> FitRun:
        """Record the outcome of a run prepared by :meth:`prepare_fit`; see :attr:`last_fit`.

        A completed run's results also reach :attr:`fitter`'s goodness-of-fit
        properties. A failed or cancelled one is recorded without statistics.
        """
        completed = status == 'completed' and bool(results)
        metrics = prepared.finalize(results) if completed else None
        # The run's own settings; the project's may have changed since it was prepared.
        settings = prepared.settings or self._fit_settings
        # Experiments may have been removed since too: the keys are those of now.
        keys = {id(experiment): key for key, experiment in self._experiments.items()}
        inputs = [
            (keys.get(id(experiment)), experiment.name, None if channel is None else channel.value)
            for experiment, channel in zip(prepared.experiments, prepared.channels)
        ]
        self._last_fit = FitRun.from_results(prepared, results, status, settings.minimizer.name, metrics, inputs)
        if completed:
            self.fitter.record_fit_results(results, metrics)
        return self._last_fit

    @property
    def last_fit(self) -> Optional[FitRun]:
        """The record of the latest fit recorded with :meth:`record_fit`, or None."""
        return self._last_fit

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

    def _walk_parameters(self):
        """Yield ``(structural path, parameter)`` for every parameter under the models;
        a parameter of an object shared by several models at its first path only."""
        yield from paths.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()
        self._sync_parameter_states()

        # The cached fitter holds fit functions bound to the previous backend.
        self._invalidate_fitter()

    @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:
        """The selected minimizer; an alias for ``fit_settings.minimizer``."""
        return self._fit_settings.minimizer

    @minimizer.setter
    def minimizer(self, minimizer: AvailableMinimizers) -> None:
        """Select the minimizer; it applies to the next fit."""
        if not isinstance(minimizer, AvailableMinimizers):
            raise ValueError(f'minimizer must be an AvailableMinimizers member, got {minimizer!r}.')
        previous = self._fit_settings.minimizer
        self._fit_settings.minimizer = minimizer
        logger.info('Minimizer changed from %s to %s', previous.name, self._fit_settings.minimizer.name)

    @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

    def remove_experiment(self, index: int) -> Dict[int, int]:
        """Remove the experiment at `index`; the later ones move down a key.

        Keys stay ``0..n-1`` and equal to display positions, so a caller that
        keeps per-experiment state (a selection, visibility) remaps it with
        the returned table.

        Returns
        -------
        Dict[int, int]
            Old key to new key of every remaining experiment.

        Raises
        ------
        IndexError
            No experiment at `index`.
        """
        if index not in self._experiments:
            raise IndexError(f'No experiment at index {index}')
        remaining = [key for key in sorted(self._experiments) if key != index]
        remap = {old: new for new, old in enumerate(remaining)}
        self._experiments = {remap[key]: self._experiments[key] for key in remaining}
        current = self._current_experiment_index
        self._current_experiment_index = remap.get(current, min(current, len(remaining) - 1)) if remaining else 0
        self._with_experiments = bool(self._experiments)
        if self._last_fit is not None:
            self._last_fit = self._last_fit.remapped(remap)
        return remap

    def model_index_for_experiment(self, index: int) -> Optional[int]:
        """Index of the model the experiment at `index` is bound to, or None when it has none."""
        return self._model_index_for_experiment(self._experiments[index])

    def set_model_for_experiment(self, index: int, model_index: int) -> None:
        """Bind the experiment at `index` to the model at `model_index`."""
        self._experiments[index].model = self._models[model_index]

    def experiments_for_model(self, model_index: int) -> List[int]:
        """Keys of the experiments bound to the model at `model_index`."""
        model = self._models[model_index]
        return [key for key, experiment in sorted(self._experiments.items()) if experiment.model is model]

    # ----- contrasts -----

    def add_contrast(
        self,
        reference_index: int,
        name: str,
        substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
    ) -> int:
        """Add a contrast of the model at `reference_index`; see :func:`easyreflectometry.contrasts.derive_contrast`.

        Replacement materials join the materials palette. Returns the index of the new model.
        """
        reference = self._models[reference_index]
        model = derive_contrast(reference, name=name, substitutions=substitutions)
        model.color = self._models.next_color()
        self._models.append(model)
        model.interface = self._calculator
        self._extend_palette_from_models()
        self._sync_parameter_states()
        self._contrasts.append((model, reference))
        self._invalidate_fitter()
        return len(self._models) - 1

    def contrast_reference(self, model_index: int) -> Optional[int]:
        """Index of the model the model at `model_index` was derived from, if it was and still exists."""
        model = self._models[model_index]
        return self._model_index(next((reference for derived, reference in self._contrasts if derived is model), None))

    def parameter_models(self, parameter: Parameter) -> List[int]:
        """Indices of the models `parameter` belongs to (several when an object is shared)."""
        return self._parameter_owners().get(id(parameter), [])

    def _parameter_owners(self) -> Dict[int, List[int]]:
        owners: Dict[int, List[int]] = {}
        for index, model in enumerate(self._models):
            for parameter in {id(p): p for p in model.get_all_parameters()}.values():
                owners.setdefault(id(parameter), []).append(index)
        return owners

    @property
    def links(self) -> List[LinkRecord]:
        """The links made by :meth:`apply_link` (a copy of the list)."""
        return list(self._links)

    def plan_link(self, follower_index: int, reference_index: int, materials: Optional[str] = None) -> LinkPlan:
        """What tying the model at `follower_index` to the one at `reference_index` would do; see
        :func:`easyreflectometry.contrasts.plan_link`. Nothing is changed."""
        owners = self._parameter_owners()
        return plan_link(
            self._models[follower_index],
            self._models[reference_index],
            owners=lambda parameter: owners.get(id(parameter), []),
            follower_index=follower_index,
            materials=materials,
        )

    def apply_link(self, plan: LinkPlan) -> LinkRecord:
        """Tie the rows of `plan` marked ``'tie'``, all or none.

        Raises
        ------
        ValueError
            A row is still ``'undecided'``, or the project changed since the plan
            was made so that a row can no longer be tied as planned (it was applied
            already, a model now shares the parameter, ...). Nothing is tied then.
        """
        undecided = [row.path for row in plan.rows if row.action == 'undecided']
        if undecided:
            raise ValueError(f'Decide whether to tie {", ".join(undecided)} before linking.')
        # The project may have changed since the plan was made (a model now shares a
        # parameter, the plan was applied already): plan again and compare.
        follower_index, reference_index = self._model_index(plan.follower), self._model_index(plan.reference)
        if follower_index is None or reference_index is None:
            raise ValueError('The plan refers to a model that is no longer in the project; plan the link again.')
        outdated = plan.outdated(self.plan_link(follower_index, reference_index))
        if outdated:
            raise ValueError(f'The plan is out of date at {", ".join(outdated)}; plan the link again.')
        pairs = []
        try:
            for row in plan.rows:
                if row.action == 'tie':
                    state = snapshot(row.follower)
                    constrain_equal(row.follower, row.reference)
                    pairs.append((row.follower, row.reference, state))
        except Exception:
            for follower, _, state in pairs:
                unconstrain(follower)
                restore(follower, state)
            raise
        record = LinkRecord(plan.follower, plan.reference, pairs)
        self._links.append(record)
        return record

    def unlink(self, record: LinkRecord, restore_state: bool = True) -> List[int]:
        """Remove the ties `record` made. Each follower gets back its value, bounds and
        fixed state from before the link (`restore_state`), or keeps its current ones.
        Returns the indices of the models whose parameters were freed."""
        freed = []
        for follower, reference, state in record.pairs:
            if follows(follower, reference):
                unconstrain(follower)
                if restore_state:
                    restore(follower, state)
                freed.append(follower)
        self._links = [link for link in self._links if link is not record]
        owners = self._parameter_owners()
        return sorted({index for parameter in freed for index in owners.get(id(parameter), [])})

    def detach(self, parameter: Parameter, model_index: Optional[int] = None) -> None:
        """Make `parameter` independent (in the model at `model_index` only, when it is shared).

        A tie is removed (with the state from before it, when a link made it).
        A parameter of an assembly shared with other models gets a copy of that
        assembly in the model at `model_index`, with every other parameter tied
        to the shared one, so only `parameter`'s counterpart is free.

        A tie on a shared parameter is one tie: removing it frees the parameter
        in every model sharing it, so that is done without a `model_index`.

        Raises
        ------
        ValueError
            The parameter is derived; `model_index` is not one of its models; it
            is shared and `model_index` is not given; it is shared and tied and
            `model_index` is given; or it belongs to a material the model shares
            (assign the layer another material instead).
        """
        owners = self.parameter_models(parameter)
        if model_index is not None and model_index not in owners:
            name = self._models[model_index].name if 0 <= model_index < len(self._models) else model_index
            raise ValueError(f"'{parameter.name}' is not part of '{name}'.")
        if not parameter.independent:
            if not getattr(parameter, USER_CONSTRAINT_FLAG, False):
                raise ValueError(f"'{parameter.name}' is derived from other parameters; it cannot be detached.")
            if len(owners) > 1 and model_index is not None:
                raise ValueError(
                    f"'{parameter.name}' is one parameter of {len(owners)} models and follows another one; "
                    'removing the tie frees it in all of them, so detach it without a model.'
                )
            self._untie(parameter)
            return
        if len(owners) < 2:
            return
        if model_index is None:
            raise ValueError(f"'{parameter.name}' is shared by several models; say which one to detach it in.")
        model = self._models[model_index]
        positions = [
            position
            for position, assembly in enumerate(model.sample)
            if any(p is parameter for p in assembly.get_all_parameters())
        ]
        if len(positions) != 1:
            raise ValueError(
                f"'{parameter.name}' is part of '{model.name}' at several positions ({positions}); "
                'give them their own assemblies first.'
            )
        position = positions[0]
        assembly = model.sample[position]
        kept = kept_children(assembly, {}, set())
        if any(parameter is p for material in kept.values() for p in material.get_all_parameters()):
            raise ValueError(f"'{parameter.name}' belongs to a shared material; give the layer its own material instead.")
        copy = references.rebuild(assembly, kept)
        twin = dict(paths.walk(copy, []))[next(path for path, p in paths.walk(assembly, []) if p is parameter)]
        tie_corresponding(copy, assembly, excluded={id(twin)})
        model.sample[position] = copy
        model.interface = self._calculator
        self._sync_parameter_states()
        self._invalidate_fitter()

    def _untie(self, parameter: Parameter) -> None:
        """Make `parameter` independent; when a link tied it, with its state from before the link."""
        unconstrain(parameter)
        for record in self._links:
            for pair in record.pairs:
                if pair[0] is parameter:
                    record.pairs.remove(pair)
                    restore(parameter, pair[2])
                    self._links = [link for link in self._links if link.pairs]
                    return

    def _extend_palette_from_models(self) -> None:
        """Add the models' materials the palette does not hold yet."""
        new_materials = [material for material in self._get_materials_in_models() if material not in self._materials]
        self._materials.extend(new_materials)

    @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.')
        self._check_sample_calculable(sample)
        # 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.')
        self._check_sample_calculable(sample)
        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 _check_sample_calculable(self, sample: Sample) -> None:
        """Refuse a sample the current calculator cannot model, before the
        project is changed: binding a magnetic sample to a calculator without
        magnetism fails only after the model was already added or the old
        models replaced.

        Raises
        ------
        NotImplementedError
            The sample has magnetic layers and the calculator cannot model them.
        """
        magnetic = any(getattr(layer, 'magnetism', None) is not None for assembly in sample for layer in assembly.layers)
        if magnetic and not self.calculator_supports_magnetism:
            engines = ', '.join(self.calculators_supporting_magnetism) or 'none of the available engines'
            raise NotImplementedError(
                f'The sample has magnetic layers, which the {self.calculator} calculation engine cannot model; '
                f'switch to {engines} and load it again.'
            )

    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 of *experiment*, and of *model*, from the experiment's variance data.

        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.

        The measured resolution is stored on the experiment itself, so every
        dataset is fitted and plotted with the resolution it was measured
        with even when several datasets share one model. The model's
        resolution is set as well: it is what simulations, the GUI's
        resolution field and datasets without a measured resolution use.

        Parameters
        ----------
        experiment : DataSet1D
            The experiment whose variance data drives the choice.
        model : Model
            The model whose resolution function is set.
        """
        measured = resolution_from_dataset(experiment)
        experiment.resolution_function = measured
        model.resolution_function = PercentageFwhm(5.0) if measured is None else measured

    @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 _model_in_use(self, model: Model, ignoring=None) -> bool:
        """Whether a loaded experiment other than `ignoring` is bound to `model`."""
        return any(experiment.model is model for experiment in self._experiments.values() if experiment is not ignoring)

    def _bind_loaded_dataset(self, experiment: DataSet1D, model: Model, replacing=None) -> None:
        """Bind a freshly loaded dataset to `model`.

        The dataset keeps the resolution it was measured with. The model takes
        its background and resolution from the dataset only while no other
        experiment uses it, so loading another contrast or angle onto a model
        leaves what its first dataset (or the user) set. A background set by
        hand before the first dataset is loaded is replaced by that dataset's.
        """
        configure_model = not self._model_in_use(model, ignoring=replacing)
        experiment.model = model
        if configure_model:
            self._auto_set_background(experiment)
            self._apply_resolution_function(experiment, model)
        else:
            experiment.resolution_function = resolution_from_dataset(experiment)

    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)
        self._bind_loaded_dataset(new_experiment, self.models[model_index])
        self._experiments[new_index] = new_experiment
        self._with_experiments = True

    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

        # Every dataset is built before any is added, so a failure adds none.
        new_experiments = []
        for offset, data_key in enumerate(data_keys):
            fallback_name = f'Experiment {len(self._experiments) + offset}'
            new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
            new_experiment.name = fallback_name
            self._apply_experiment_metadata(path, new_experiment, fallback_name, data_group=data_group, data_key=data_key)
            new_experiments.append(new_experiment)

        model = self.models[self._current_model_index]
        for new_experiment in new_experiments:
            self._bind_loaded_dataset(new_experiment, model)
            self._experiments[len(self._experiments)] = new_experiment
        self._with_experiments = True
        return len(data_keys)

    def _load_single_dataset(self, path: Union[Path, str]) -> DataSet1D:
        """Load a file that holds exactly one dataset, keeping its ORSO title as name."""
        data_group = load_measurement_file(str(path))
        if len(data_group['data']) > 1:
            raise ValueError(
                f"File '{path}' contains multiple datasets; a contrast is combined from single-dataset files "
                '(use load_all_experiments_from_file to load each dataset of this file as its own experiment).'
            )
        dataset = load_as_dataset(str(path), data_group=data_group)
        self._apply_experiment_metadata(path, dataset, Path(path).name, data_group=data_group)
        return dataset

    def load_experiment_from_files(
        self,
        paths: List[Union[Path, str]],
        model_index: Optional[int] = None,
        name: Optional[str] = None,
    ) -> int:
        """Load several files measured on one contrast as a single experiment.

        A contrast is often measured as several curves -- one per incident
        angle or wavelength band -- each with its own q-resolution. The files
        are concatenated (sorted by q) into one experiment whose every point
        keeps the resolution it was measured with, so the fit smears each
        point correctly. See :func:`easyreflectometry.data.merge_datasets`.

        Parameters
        ----------
        paths : List[Union[Path, str]]
            The files, one dataset each.
        model_index : Optional[int], optional
            Index of the model the experiment belongs to. By default, the
            current model.
        name : Optional[str], optional
            Name of the experiment. By default, the ORSO title of the first
            file, or ``'Experiment N'``.

        Returns
        -------
        int
            Index of the newly loaded experiment.
        """
        if not paths:
            raise ValueError('At least one file is required.')
        if model_index is None:
            model_index = self._current_model_index
        model = self.models[model_index]
        new_index = len(self._experiments)

        datasets = [self._load_single_dataset(path) for path in paths]
        # 'Series' is the DataSet1D default that _apply_experiment_metadata
        # replaces by the ORSO title of the first file, or else the fallback.
        experiment = merge_datasets(
            datasets, name='Series' if name is None else name, fill_resolution=self._fill_resolution_for(model)
        )
        if name is None:
            self._apply_experiment_metadata(paths[0], experiment, f'Experiment {new_index}')

        # The merged resolution is kept as merge_datasets built it from the
        # inputs' own resolutions; only a model no other experiment uses follows it.
        configure_model = not self._model_in_use(model)
        experiment.model = model
        if configure_model:
            self._auto_set_background(experiment)
            model.resolution_function = (
                PercentageFwhm(5.0) if experiment.resolution_function is None else experiment.resolution_function
            )
        self._experiments[new_index] = experiment
        self._with_experiments = True
        return new_index

    def append_to_experiment_at_index(self, index: int, path: Union[Path, str]) -> None:
        """Add another measured curve of the same contrast to an existing experiment.

        The file's points are merged into the experiment at *index* (sorted by
        q), each keeping its own resolution: the file's measured one, and the
        experiment's ``resolution_function`` as currently assigned. The
        experiment's name and model are kept.

        Parameters
        ----------
        index : int
            Index of the experiment to extend.
        path : Union[Path, str]
            The file to add, one dataset.

        Raises
        ------
        IndexError
            No experiment is loaded at *index*.
        ValueError
            The experiment is polarized (extend its channels one file at a
            time instead), or the file holds several datasets.
        """
        if index not in self._experiments:
            raise IndexError(f'No experiment at index {index}')
        experiment = self._experiments[index]
        if isinstance(experiment, PolarizedDataSet):
            raise ValueError('Cannot append a file to a polarized experiment; its channels are separate datasets.')
        model = experiment.model
        addition = self._load_single_dataset(path)
        merged = merge_datasets([experiment, addition], name=experiment.name, fill_resolution=self._fill_resolution_for(model))
        merged.model = model
        merged.include_in_fit = experiment.include_in_fit
        # merge_datasets kept the experiment's own resolution (measured or
        # explicitly assigned) for its points; the model follows the merged
        # one unless another experiment uses it. Without any, the experiment
        # keeps using the model's as it is.
        if model is not None and merged.resolution_function is not None and not self._model_in_use(model, ignoring=experiment):
            model.resolution_function = merged.resolution_function
        self._experiments[index] = merged

    @staticmethod
    def _fill_resolution_for(model: Optional[Model]) -> Optional[ResolutionFunction]:
        """The resolution to assume for merged points that carry none: the model's, unless that is itself measured."""
        if model is None:
            return None
        resolution_function = model.resolution_function
        if isinstance(resolution_function, PercentageFwhm):
            return resolution_function
        return None

    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.

        The background follows the first (in canonical order) channel. Every
        channel keeps the resolution it was measured with (its own
        ``Pointwise`` when the file carries sQz), which the fit and the
        per-channel model curves use; the model's resolution follows the
        first channel for simulations and the GUI's resolution field.

        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
        )

        configure_model = not self._model_in_use(model)
        for channel in experiment.available_channels:
            experiment[channel].resolution_function = resolution_from_dataset(experiment[channel])
        if configure_model:
            # The model follows the first channel, see `_bind_loaded_dataset`.
            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)
        self._bind_loaded_dataset(experiment, self.models[index], replacing=self._experiments.get(index))
        self._experiments[index] = experiment
        self._with_experiments = True

    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, Union[DataSet1D, float]]:
        """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;
            - ``'rho_m_perp'``: magnitude of the magnetic SLD transverse to the
              field, |ρM⊥|(z);
            - ``'spin_up'`` / ``'spin_down'``: nuclear plus / minus the
              longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component
              along the field (see
              :func:`~easyreflectometry.special.calculations.field_frame_components`).
              These are the potentials of the two spin states only when the
              moment is collinear with the field and the applied field is zero;
              with a transverse moment the eigenmodes split by ±|ρM| instead.
              The applied field is not included.

            plus the floats ``'guide_field_angle'`` and ``'applied_field'`` of the model.

        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)
        guide_field_angle = float(model.guide_field_angle.value)
        parallel, perpendicular = field_frame_components(rho_m, 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]),
            'rho_m_perp': DataSet1D(name=f'Transverse magnetic SLD for Model {index}', x=z, y=perpendicular),
            'spin_up': DataSet1D(name=f'Nuclear + longitudinal magnetic SLD for Model {index}', x=z, y=sld + parallel),
            'spin_down': DataSet1D(name=f'Nuclear - longitudinal magnetic SLD for Model {index}', x=z, y=sld - parallel),
            'guide_field_angle': guide_field_angle,
            'applied_field': float(model.applied_field.value),
        }

    def volume_fraction_data_for_model_at_index(
        self,
        index: int = 0,
        *,
        z: Optional[np.ndarray] = None,
        max_delta_z: Optional[float] = None,
        merge_equivalent: bool = True,
        groups: Optional[Mapping[str, Sequence[str]]] = None,
    ) -> Dict[str, DataSet1D]:
        """Volume fraction (occupancy) depth profiles of the components of a model.

        Built from the sample alone, so no calculator binding is needed. See
        :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`
        for the depth convention and the caveats.

        Parameters
        ----------
        index : int
            Index of the model.
        z : np.ndarray, optional
            Depths (Å) to evaluate at; ``z = 0`` is the superphase / first
            layer interface. By default a 500-point grid spanning the sample,
            the same grid the refnx calculator uses for its SLD profile.
        max_delta_z : float, optional
            Maximum spacing of the default grid. Cannot be combined with ``z``.
        merge_equivalent : bool, optional
            Merge materials with equal name and SLD into one component. By default, True.
        groups : Mapping[str, Sequence[str]], optional
            Presentation groups forwarded to
            :meth:`~easyreflectometry.sample.VolumeFractionProfile.grouped`,
            e.g. ``{'Water': ['D2O', 'H2O']}``.

        Returns
        -------
        Dict[str, DataSet1D]
            One profile per component, keyed by its stable component key (or
            group label) in front-to-back order; the display label is in the
            dataset's ``name``. The ``y`` values sum to one at every ``x``.
        """
        model = self.models[index]
        profile = volume_fraction_profile(model.sample, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)
        if groups:
            profile = profile.grouped(groups)
        return {
            key: DataSet1D(
                name=f'{profile.labels[key]} volume fraction for Model {index}',
                x=profile.z,
                y=values,
                x_label='z (Å)',
                y_label='Volume fraction',
            )
            for key, values in profile.fractions.items()
        }

    def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
        """The unsmeared reflectivity of the model at `index`: the sample alone, no resolution.

        The zero-width resolution is bound to this one calculation; the
        model's own resolution, which is calculator state shared with a fit
        possibly running in another thread, is not touched.
        """
        return self.model_data_for_model_at_index(index, q_range, resolution_function=PercentageFwhm(0))

    def model_data_for_model_at_index(
        self,
        index: int = 0,
        q_range: Optional[np.array] = None,
        channel: Optional[Union[PolarizationChannel, str]] = None,
        resolution_function: Optional[ResolutionFunction] = 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.
        resolution_function : Optional[ResolutionFunction]
            Resolution to smear with; the model's by default. Pass a dataset's
            own resolution to get the curve the fit compares that dataset to
            (see :meth:`model_data_for_experiment_at_index`).
        """
        if q_range is None:
            q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
        self._bind_calculator(self.models[index])
        interface = self.models[index].interface()
        unique_name = self._models[index].unique_name
        if channel is None:
            reflectivity = interface.reflectity_profile(q_range, unique_name, resolution_function=resolution_function)
            name = f'Reflectivity for Model {index}'
        else:
            channel = PolarizationChannel(channel)
            reflectivity = interface.reflectivity_profile_channel(
                q_range, unique_name, channel, resolution_function=resolution_function
            )
            name = f'Reflectivity ({channel.value}) for Model {index}'
        return DataSet1D(
            name=name,
            x=q_range,
            y=reflectivity,
        )

    def model_data_for_experiment_at_index(
        self,
        index: int = 0,
        channel: Optional[Union[PolarizationChannel, str]] = None,
        q_range: Optional[np.array] = None,
    ) -> DataSet1D:
        """The model curve an experiment is compared to: its model, at its q, with its resolution.

        Parameters
        ----------
        index : int
            Index of the experiment.
        channel : Optional[Union[PolarizationChannel, str]]
            Spin channel of a polarized experiment; required for one, not
            allowed for an unpolarized one.
        q_range : Optional[np.array]
            Points to calculate at; the experiment's own q by default.

        Raises
        ------
        IndexError
            No experiment is loaded at `index`.
        ValueError
            The experiment has no model, or `channel` does not match the
            experiment's kind.
        """
        experiment = self.experimental_data_for_model_at_index(index)
        if isinstance(experiment, PolarizedDataSet):
            if channel is None:
                raise ValueError(f'Experiment at index {index} is polarized; a channel is required.')
            dataset = experiment[channel]
        else:
            if channel is not None:
                raise ValueError(f'Experiment at index {index} is not polarized; it has no channel.')
            dataset = experiment
        model = experiment.model
        if model is None:
            raise ValueError(f'Experiment at index {index} has no model.')
        model_index = next(i for i, candidate in enumerate(self.models) if candidate is model)
        return self.model_data_for_model_at_index(
            model_index,
            q_range=np.asarray(dataset.x) if q_range is None else q_range,
            channel=channel,
            resolution_function=getattr(dataset, 'resolution_function', None),
        )

    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."""
        return self._model_index(getattr(experiment, 'model', 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 plan_model_removal(self, index: int) -> 'ModelRemoval':
        """What removing the model at `index` would affect; nothing is changed.

        Raises
        ------
        IndexError :
            If the index is out of range.
        """
        if index < 0 or index >= len(self._models):
            raise IndexError(f'Model index {index} out of range')
        model = self._models[index]
        survivors = [other for other in self._models if other is not model]
        surviving = {id(parameter) for other in survivors for parameter in other.get_all_parameters()}
        # Parameters only this model owns; one shared with a surviving model stays.
        removed = {id(parameter) for parameter in model.get_all_parameters() if id(parameter) not in surviving}
        dependents = [
            parameter
            for other in survivors
            for parameter in other.get_all_parameters()
            if not parameter.independent and any(id(leader) in removed for leader in parameter.dependency_map.values())
        ]
        inequality_constraints = [
            spec
            for spec in self._inequality_constraints
            if any(id(self._parameter_or_none(path)) in removed for path in spec.paths.values())
        ]
        return ModelRemoval(
            experiments=self.experiments_for_model(index),
            dependents=list({id(parameter): parameter for parameter in dependents}.values()),
            inequality_constraints=inequality_constraints,
        )

    def remove_model_at_index(self, index: int, experiments: Union[str, int, None] = None) -> 'ModelRemoval':
        """Remove the model at `index`, resolving everything that refers to it.

        Parameters of other models that follow a parameter only this model
        owns become independent, keeping their current value (or getting back
        the one from before the link, when :meth:`apply_link` made the tie). Inequality
        constraints on such parameters are removed; the others are re-pathed.
        Objects the model shares with other models stay with them. See
        :meth:`plan_model_removal`.

        Parameters
        ----------
        index : int
            Index of the model to remove.
        experiments : Union[str, int, None], optional
            What happens to the experiments bound to the model: ``'remove'``
            removes them, an int rebinds them to the model at that index
            (counted before the removal). Required when there are any.

        Returns
        -------
        ModelRemoval
            What was affected; its ``remap`` keys the remaining experiments.

        Raises
        ------
        IndexError :
            If the index is out of range.
        ValueError :
            If trying to remove the last remaining model, or experiments are
            bound to it and `experiments` does not say what to do with them.
            Nothing is changed then.
        """
        plan = self.plan_model_removal(index)
        if len(self._models) <= 1:
            raise ValueError('Cannot remove the last model from the project')
        if plan.experiments and experiments != 'remove':
            if not isinstance(experiments, int) or experiments == index or not 0 <= experiments < len(self._models):
                raise ValueError(
                    f'Experiments {plan.experiments} use the model; pass experiments="remove" '
                    'or the index of another model to bind them to.'
                )

        for parameter in plan.dependents:
            self._untie(parameter)
        removed = self._models[index]
        self._links = [link for link in self._links if removed not in (link.follower, link.reference)]
        self._contrasts = [pair for pair in self._contrasts if removed not in pair]
        remap = {key: key for key in self._experiments}
        if experiments == 'remove':
            for key in reversed(plan.experiments):
                step = self.remove_experiment(key)
                remap = {old: step[new] for old, new in remap.items() if new in step}
        elif plan.experiments:
            for key in plan.experiments:
                self._experiments[key].model = self._models[experiments]
        self._keeping_inequality_paths(lambda: self._models.pop(index))
        self._invalidate_fitter()

        # 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
        return ModelRemoval(plan.experiments, plan.dependents, plan.inequality_constraints, remap)

    def move_model(self, index: int, new_index: int) -> None:
        """Move the model at `index` to `new_index`, keeping inequality constraints on their parameters."""
        if index == new_index:
            return
        current = self._models[self._current_model_index]
        self._keeping_inequality_paths(lambda: self._models.insert(new_index, self._models.pop(index)))
        self._current_model_index = self._model_index(current)
        self._invalidate_fitter()

    def _parameter_or_none(self, path: str):
        try:
            return self.resolve_parameter_path(path)
        except KeyError:
            return None

    def _keeping_inequality_paths(self, edit) -> List[InequalitySpec]:
        """Run a structural `edit`, then re-path every inequality constraint to the
        parameters it referred to before. Constraints whose parameters are gone are
        removed and returned."""
        targets = [
            (spec, {alias: self._parameter_or_none(path) for alias, path in spec.paths.items()})
            for spec in self._inequality_constraints
        ]
        edit()
        locations = {id(parameter): path for path, parameter in self._walk_parameters()}
        kept, removed = [], []
        for spec, parameters in targets:
            new_paths = {alias: locations.get(id(parameter)) for alias, parameter in parameters.items()}
            if None in new_paths.values():
                removed.append(spec)
                continue
            spec.lhs_paths = {alias: new_paths[alias] for alias in spec.lhs_paths}
            spec.rhs_paths = {alias: new_paths[alias] for alias in spec.rhs_paths}
            kept.append(spec)
        self._inequality_constraints = kept
        return removed

    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
    #: Written instead of :attr:`FILE_FORMAT` when an older reader would load another
    #: project: the models share objects (reference nodes, see
    #: :mod:`easyreflectometry.sample.references`), an experiment is left out of the fit,
    #: or an experiment's model is found by a name several models have. Readers that
    #: predate it refuse the file rather than misread it.
    FILE_FORMAT_SHARED = 3

    def as_dict(self, include_materials_not_in_model=False):
        """As dict.

        Objects used in several places (a material in two layers, an assembly
        in two models) are written once and referred to elsewhere. The
        materials palette is written in order under ``materials``.
        ``include_materials_not_in_model`` adds the unused materials once more
        under their older key, for readers that predate ``materials``.
        """
        project_dict = {}
        project_dict['file_format'] = self.FILE_FORMAT
        project_dict['info'] = self._info
        project_dict['with_experiments'] = self._with_experiments
        with references.writing():
            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'
            project_dict['materials'] = [material.as_dict() for material in self._materials]
        references.prune(project_dict)
        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._needs_new_format(project_dict):
            project_dict['file_format'] = self.FILE_FORMAT_SHARED
        project_dict['fit_settings'] = self._fit_settings.to_dict()
        # Kept for readers that predate `fit_settings`.
        project_dict['fitter_minimizer'] = project_dict['fit_settings']['minimizer']
        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
        self._as_dict_add_contrasts(project_dict)
        return project_dict

    def _model_index(self, model: Model) -> Optional[int]:
        return next((index for index, candidate in enumerate(self._models) if candidate is model), None)

    def _as_dict_add_contrasts(self, project_dict: dict) -> None:
        """Record contrast provenance and links by model index and parameter path.

        Entries whose models, or tied parameters, are no longer in the project
        (models replaced wholesale) are left out.
        """
        contrasts = [[self._model_index(derived), self._model_index(reference)] for derived, reference in self._contrasts]
        contrasts = [pair for pair in contrasts if None not in pair]
        if contrasts:
            project_dict['contrasts'] = contrasts
        parameter_paths = {id(parameter): path for path, parameter in self._walk_parameters()} if self._links else {}
        links = []
        for link in self._links:
            models = [self._model_index(link.follower), self._model_index(link.reference)]
            pairs = [
                [parameter_paths[id(follower)], parameter_paths[id(reference)], state]
                for follower, reference, state in link.pairs
                if id(follower) in parameter_paths and id(reference) in parameter_paths
            ]
            if None not in models and pairs:
                links.append({'follower': models[0], 'reference': models[1], 'pairs': pairs})
        if links:
            project_dict['links'] = links

    def _from_dict_restore_contrasts(self, project_dict: dict, report: list[str]) -> None:
        """Restore contrast provenance and links; an entry that does not fit the models is reported and dropped."""
        self._contrasts, self._links = [], []
        for derived, reference in project_dict.get('contrasts', []):
            try:
                self._contrasts.append((self._models[derived], self._models[reference]))
            except (IndexError, TypeError):
                report.append(f'Contrast provenance {derived} from {reference} refers to a missing model; dropped.')
        for raw in project_dict.get('links', []):
            try:
                pairs = [
                    (self.resolve_parameter_path(follower), self.resolve_parameter_path(reference), state)
                    for follower, reference, state in raw['pairs']
                ]
                self._links.append(LinkRecord(self._models[raw['follower']], self._models[raw['reference']], pairs))
            except (KeyError, IndexError, TypeError) as error:
                report.append(f'A link between models could not be restored: {error}')

    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 _needs_new_format(self, project_dict: dict) -> bool:
        """Whether a reader of :attr:`FILE_FORMAT` would load another project from `project_dict`.

        Such a reader keeps no shared objects (older readers ignore ``materials``, so
        only references inside the models count), fits every experiment, and finds an
        experiment's model by name.
        """
        if references.contains_references(project_dict.get('models')):
            return True
        if any(included is False for included in project_dict.get('experiments_fit_scope', {}).values()):
            return True
        names = [model.name for model in self._models] if self._models is not None else []
        return any(names.count(name) > 1 for name in project_dict.get('experiments_models', {}).values())

    def _as_dict_add_experiments(self, project_dict: dict):
        """As dict add experiments."""
        project_dict['experiments'] = {}
        project_dict['experiments_models'] = {}
        project_dict['experiments_model_indices'] = {}
        project_dict['experiments_names'] = {}
        project_dict['experiments_fit_scope'] = {}

        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),
            ]
            resolution = self._dataset_resolution_as_dict(experiment)
            if experiment.xe is not None or resolution is not None:
                # Fourth entry xe (None when cleared), fifth the dataset's own
                # resolution. The fifth is written even when None, an explicit
                # "use the model's resolution"; only its absence (files written
                # before it existed) means "derive it from xe".
                project_dict['experiments'][key].append(None if experiment.xe is None else list(experiment.xe))
                project_dict['experiments'][key].append(resolution)
            self._as_dict_add_experiment_model(project_dict, key, experiment)

    @staticmethod
    def _dataset_resolution_as_dict(dataset: DataSet1D) -> Optional[dict]:
        """The dataset's own resolution, serialized, or None when it uses the model's."""
        resolution_function = getattr(dataset, 'resolution_function', None)
        if resolution_function is None:
            return None
        return resolution_function.as_dict()

    @staticmethod
    def _dataset_resolution_from_dict(dataset: DataSet1D, arrays: list, position: int) -> None:
        """Restore a dataset's own resolution from ``arrays[position]``.

        A present entry is used as it is, ``None`` included (the dataset uses
        the model's resolution). Older files, written before the entry
        existed, get the measured resolution derived from ``xe``.
        """
        if len(arrays) <= position:
            dataset.resolution_function = resolution_from_dataset(dataset)
        elif arrays[position] is None:
            dataset.resolution_function = None
        else:
            dataset.resolution_function = ResolutionFunction.from_dict(arrays[position])

    def _as_dict_add_experiment_model(self, project_dict: dict, key: int, experiment) -> None:
        """Record the experiment's name, fit inclusion and model: by index (authoritative) and by name (older readers)."""
        project_dict['experiments_names'][key] = experiment.name
        project_dict['experiments_fit_scope'][key] = experiment.include_in_fit
        model_index = self._model_index_for_experiment(experiment)
        if model_index is not None:
            project_dict['experiments_model_indices'][key] = model_index
            project_dict['experiments_models'][key] = experiment.model.name

    def _as_dict_add_polarized_experiment(self, project_dict: dict, key: int, experiment: PolarizedDataSet) -> None:
        """Serialize a `PolarizedDataSet`: one (name, x, y, ye, xe, resolution) 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),
                    None if experiment[channel].xe is None else list(experiment[channel].xe),
                    # Written even when None; see `_dataset_resolution_from_dict`.
                    self._dataset_resolution_as_dict(experiment[channel]),
                ]
                for channel in experiment.available_channels
            },
        }
        self._as_dict_add_experiment_model(project_dict, key, experiment)

    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 not in (self.FILE_FORMAT, self.FILE_FORMAT_SHARED):
            raise ValueError(
                f'Unsupported project file_format={file_format!r}; this version of '
                f'easyreflectometry only reads file_format={self.FILE_FORMAT} and {self.FILE_FORMAT_SHARED}. '
                'Please either update easyreflectometry or re-create the project.'
            )
        report: list[str] = []
        self._info = project_dict['info']
        self._with_experiments = project_dict['with_experiments']
        if 'calculator' in keys:
            self._calculator.switch(project_dict['calculator'])
        with references.reading(report):
            if 'models' in keys:
                self.models = ModelCollection.from_dict(project_dict['models'])
            palette = [references.deserialize(entry) for entry in project_dict.get('materials', [])]
        if 'materials' in keys:
            self._replace_collection(palette, self._materials)
        else:
            # A file predating `materials`: the models' materials, then the unused ones.
            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']))
        # Settings are replaced wholesale, never merged with the previous project's.
        # A file predating `fit_settings` only stored `fitter_minimizer`.
        legacy = {'minimizer': project_dict.get('fitter_minimizer')}
        self._fit_settings, settings_report = FitSettings.from_dict(project_dict.get('fit_settings', legacy))
        report.extend(settings_report)
        self._invalidate_fitter()
        self._last_fit = None  # the previous contents' run, not this project's
        if 'experiments' in keys:
            self._experiments = self._from_dict_extract_experiments(project_dict, report)
        else:
            self._experiments = {}
        self.load_report = report

        # 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', [])]
        self._from_dict_restore_contrasts(project_dict, self.load_report)

    @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, report: list[str]) -> Dict[int, DataSet1D | PolarizedDataSet]:
        """From dict extract experiments."""
        experiments = {}
        for key, raw in project_dict['experiments'].items():
            name = project_dict['experiments_names'].get(key, f'Experiment {key}')
            model = self._saved_experiment_model(project_dict, key, name, report)
            if isinstance(raw, dict) and raw.get('polarized'):
                experiment = self._polarized_experiment_from_dict(name, raw, model)
            else:
                experiment = self._dataset_from_dict(name, raw, model)
            experiment.include_in_fit = project_dict.get('experiments_fit_scope', {}).get(key, True) is not False
            experiments[int(key)] = experiment
        return experiments

    def _dataset_from_dict(self, name: str, raw: list, model: Optional[Model]) -> DataSet1D:
        """Reconstruct an unpolarized experiment serialized by `_as_dict_add_experiments`."""
        dataset = DataSet1D(
            name=name,
            x=raw[0],
            y=raw[1],
            ye=raw[2],
            xe=raw[3] if len(raw) > 3 else None,
            model=model,
            auto_background=False,
        )
        self._dataset_resolution_from_dict(dataset, raw, 4)
        return dataset

    def _saved_experiment_model(self, project_dict: dict, key, name: str, report: list[str]) -> Optional[Model]:
        """The model a saved experiment is bound to: by index, else by name (files that
        predate the index); an ambiguous or missing one is reported."""
        index = project_dict.get('experiments_model_indices', {}).get(key)
        if isinstance(index, int) and 0 <= index < len(self._models):
            return self._models[index]
        model_name = project_dict.get('experiments_models', {}).get(key)
        matches = [model for model in self._models if model.name == model_name]
        if not matches:
            report.append(f"Experiment '{name}' has no model; assign one before fitting it.")
            return None
        if len(matches) > 1:
            report.append(f"Experiment '{name}': several models are named '{model_name}'; the first is used.")
        return matches[0]

    def _polarized_experiment_from_dict(self, name: str, raw: dict, model: Optional[Model]) -> PolarizedDataSet:
        """Reconstruct a `PolarizedDataSet` serialized by `_as_dict_add_polarized_experiment`."""
        channels = {}
        for channel_value, arrays in raw['channels'].items():
            dataset = DataSet1D(
                name=arrays[0],
                x=arrays[1],
                y=arrays[2],
                ye=arrays[3],
                xe=arrays[4],
                model=model,
                auto_background=False,
            )
            self._dataset_resolution_from_dict(dataset, arrays, 5)
            channels[channel_value] = dataset
        return PolarizedDataSet(name=name, channels=channels, model=model)

    def _get_materials_in_models(self) -> MaterialCollection:
        """Get materials in models."""
        materials_in_model = MaterialCollection(populate_if_none=False)
        seen: set[int] = set()
        for model in self._models:
            for assembly in model.sample:
                for layer in assembly.layers:
                    if id(layer.material) not in seen:  # one material in two layers, once
                        seen.add(id(layer.material))
                        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

The fitter of the current model, configured from :attr:fit_settings.

Every hand-out writes the project's settings onto it, so configure the fit through :attr:fit_settings (or :attr:minimizer), not through the returned fitter's core fitter.

fit_settings property writable

The project's fit configuration; edits apply to the next fit prepared.

last_fit property

The record of the latest fit recorded with :meth:record_fit, or None.

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

The selected minimizer; an alias for fit_settings.minimizer.

experiments property writable

Experiments function.

The links made by :meth:apply_link (a copy of the list).

path_json property

Path json.

__init__()

Init function.

Source code in src/easyreflectometry/project.py
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
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._fit_settings = FitSettings()
    #: Warnings from the last :meth:`from_dict`, for the user; cleared by each load.
    self.load_report: list[str] = []
    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] = []
    self._last_fit = None
    #: (derived model, the model it was derived from), see :meth:`add_contrast`.
    self._contrasts: List[tuple] = []
    self._links: List[LinkRecord] = []

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

reset()

Reset function.

Source code in src/easyreflectometry/project.py
181
182
183
184
185
186
187
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
430
431
432
def set_path_project_parent(self, path: Union[Path, str]):
    """Set path project parent."""
    self._path_project_parent = Path(path)

prepare_fit(experiments=None, *, objective=None, skip_invalid=False)

Prepare a fit of the experiments with a snapshot of :attr:fit_settings.

The run is not executed: call :meth:PreparedFit.execute, or run its core_fitter in a worker thread, then hand the results to :meth:record_fit. Inequality constraints are resolved when it executes.

A parameter of a fitted model that follows a free parameter of a model left out of the fit keeps following it, and that parameter is varied too (prepared.added_roots); the left-out data does not enter the fit.

Parameters:

Name Type Description Default
experiments list

The experiments to fit, in order. By default, the loaded ones whose include_in_fit is set.

None
objective str

Zero-variance objective overriding the settings' one.

None
skip_invalid bool

Leave out experiments that have no model in this project (listed in prepared.skipped) instead of raising. By default, False.

False

Returns:

Type Description
PreparedFit

The prepared run.

Raises:

Type Description
FitScopeError

Nothing to fit, or an experiment has no model in this project.

FitPreconditionError

If a free parameter's bounds are unusable for the minimizer.

Source code in src/easyreflectometry/project.py
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
def prepare_fit(
    self,
    experiments: Optional[list] = None,
    *,
    objective: Optional[str] = None,
    skip_invalid: bool = False,
) -> PreparedFit:
    """Prepare a fit of the experiments with a snapshot of :attr:`fit_settings`.

    The run is not executed: call :meth:`PreparedFit.execute`, or run its
    ``core_fitter`` in a worker thread, then hand the results to
    :meth:`record_fit`. Inequality constraints are resolved when it executes.

    A parameter of a fitted model that follows a free parameter of a model
    left out of the fit keeps following it, and that parameter is varied
    too (``prepared.added_roots``); the left-out data does not enter the fit.

    Parameters
    ----------
    experiments : list, optional
        The experiments to fit, in order. By default, the loaded ones
        whose ``include_in_fit`` is set.
    objective : str, optional
        Zero-variance objective overriding the settings' one.
    skip_invalid : bool, optional
        Leave out experiments that have no model in this project (listed
        in ``prepared.skipped``) instead of raising. By default, False.

    Returns
    -------
    PreparedFit
        The prepared run.

    Raises
    ------
    FitScopeError
        Nothing to fit, or an experiment has no model in this project.
    FitPreconditionError
        If a free parameter's bounds are unusable for the minimizer.
    """
    experiments, invalid = self._fit_scope(experiments, skip_invalid, action='fit')
    fitter = self._fitter_for(experiments)
    prepared = fitter.prepare(objective=objective)
    prepared.skipped = [experiment.name for experiment in invalid]
    prepared.experiments = [
        experiment for experiment in experiments for _ in (getattr(experiment, 'available_channels', None) or [None])
    ]
    return prepared

prepare_sample(experiments=None, *, skip_invalid=False)

A fitter ready for Bayesian sampling of the experiments, with :attr:fit_settings.

The sampling is not run: call mcmc_sample() on the result (with no DataGroup), from a worker thread if need be. Each dataset -- each spin channel of a polarized experiment -- is sampled with the resolution it was measured with, exactly as :meth:prepare_fit fits it, so the posterior and the classical fit agree on what the model is compared to. Inequality constraints are resolved when it runs.

Parameters:

Name Type Description Default
experiments list

The experiments to sample, in order. By default, the loaded ones whose include_in_fit is set.

None
skip_invalid bool

Leave out experiments that have no model in this project instead of raising. By default, False.

False

Returns:

Type Description
MultiFitter

The fitter, built with :meth:MultiFitter.for_experiments. Its settings are a snapshot of :attr:fit_settings taken here, as :meth:prepare_fit takes one: an edit made before mcmc_sample() runs applies to the next sampling, not to this one. Its skipped lists the names of the experiments left out by skip_invalid, like PreparedFit.skipped.

Raises:

Type Description
FitScopeError

Nothing to sample, or an experiment has no model in this project.

Source code in src/easyreflectometry/project.py
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
def prepare_sample(
    self,
    experiments: Optional[list] = None,
    *,
    skip_invalid: bool = False,
) -> MultiFitter:
    """A fitter ready for Bayesian sampling of the experiments, with :attr:`fit_settings`.

    The sampling is not run: call ``mcmc_sample()`` on the result (with
    no ``DataGroup``), from a worker thread if need be. Each dataset --
    each spin channel of a polarized experiment -- is sampled with the
    resolution it was measured with, exactly as :meth:`prepare_fit` fits
    it, so the posterior and the classical fit agree on what the model
    is compared to. Inequality constraints are resolved when it runs.

    Parameters
    ----------
    experiments : list, optional
        The experiments to sample, in order. By default, the loaded ones
        whose ``include_in_fit`` is set.
    skip_invalid : bool, optional
        Leave out experiments that have no model in this project instead
        of raising. By default, False.

    Returns
    -------
    MultiFitter
        The fitter, built with :meth:`MultiFitter.for_experiments`. Its
        ``settings`` are a snapshot of :attr:`fit_settings` taken here, as
        :meth:`prepare_fit` takes one: an edit made before ``mcmc_sample()``
        runs applies to the next sampling, not to this one. Its
        ``skipped`` lists the names of the experiments left out by
        ``skip_invalid``, like ``PreparedFit.skipped``.

    Raises
    ------
    FitScopeError
        Nothing to sample, or an experiment has no model in this project.
    """
    experiments, invalid = self._fit_scope(experiments, skip_invalid, action='sample')
    fitter = self._fitter_for(experiments)
    fitter.skipped = [experiment.name for experiment in invalid]
    return fitter

fit_parameters(experiments=None)

The parameters a fit of experiments varies, each once; nothing is prepared.

The free parameters of their models, and the free parameters of other models those follow (see :meth:prepare_fit). Experiments without a model of this project contribute nothing.

Parameters:

Name Type Description Default
experiments list

By default, the loaded ones whose include_in_fit is set.

None
Source code in src/easyreflectometry/project.py
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
def fit_parameters(self, experiments: Optional[list] = None) -> List[Parameter]:
    """The parameters a fit of `experiments` varies, each once; nothing is prepared.

    The free parameters of their models, and the free parameters of other
    models those follow (see :meth:`prepare_fit`). Experiments without a
    model of this project contribute nothing.

    Parameters
    ----------
    experiments : list, optional
        By default, the loaded ones whose ``include_in_fit`` is set.
    """
    if experiments is None:
        experiments = [self._experiments[key] for key in sorted(self._experiments) if self._experiments[key].include_in_fit]
    models = []
    for experiment in experiments:
        index = self._model_index(experiment.model)
        if index is not None and not any(model is self._models[index] for model in models):
            models.append(self._models[index])
    parameters = {id(parameter): parameter for model in models for parameter in model.get_free_parameters()}
    for root in _free_roots(models):
        parameters.setdefault(id(root), root)
    return list(parameters.values())

record_fit(prepared, results=None, status='completed')

Record the outcome of a run prepared by :meth:prepare_fit; see :attr:last_fit.

A completed run's results also reach :attr:fitter's goodness-of-fit properties. A failed or cancelled one is recorded without statistics.

Source code in src/easyreflectometry/project.py
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
def record_fit(self, prepared: PreparedFit, results: Optional[list] = None, status: str = 'completed') -> FitRun:
    """Record the outcome of a run prepared by :meth:`prepare_fit`; see :attr:`last_fit`.

    A completed run's results also reach :attr:`fitter`'s goodness-of-fit
    properties. A failed or cancelled one is recorded without statistics.
    """
    completed = status == 'completed' and bool(results)
    metrics = prepared.finalize(results) if completed else None
    # The run's own settings; the project's may have changed since it was prepared.
    settings = prepared.settings or self._fit_settings
    # Experiments may have been removed since too: the keys are those of now.
    keys = {id(experiment): key for key, experiment in self._experiments.items()}
    inputs = [
        (keys.get(id(experiment)), experiment.name, None if channel is None else channel.value)
        for experiment, channel in zip(prepared.experiments, prepared.channels)
    ]
    self._last_fit = FitRun.from_results(prepared, results, status, settings.minimizer.name, metrics, inputs)
    if completed:
        self.fitter.record_fit_results(results, metrics)
    return self._last_fit

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
670
671
672
673
674
675
676
677
678
679
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
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
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
708
709
710
711
712
713
714
715
716
717
718
719
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
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
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
742
743
744
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
746
747
748
749
750
751
752
753
754
755
756
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
758
759
760
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)

remove_experiment(index)

Remove the experiment at index; the later ones move down a key.

Keys stay 0..n-1 and equal to display positions, so a caller that keeps per-experiment state (a selection, visibility) remaps it with the returned table.

Returns:

Type Description
Dict[int, int]

Old key to new key of every remaining experiment.

Raises:

Type Description
IndexError

No experiment at index.

Source code in src/easyreflectometry/project.py
 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
def remove_experiment(self, index: int) -> Dict[int, int]:
    """Remove the experiment at `index`; the later ones move down a key.

    Keys stay ``0..n-1`` and equal to display positions, so a caller that
    keeps per-experiment state (a selection, visibility) remaps it with
    the returned table.

    Returns
    -------
    Dict[int, int]
        Old key to new key of every remaining experiment.

    Raises
    ------
    IndexError
        No experiment at `index`.
    """
    if index not in self._experiments:
        raise IndexError(f'No experiment at index {index}')
    remaining = [key for key in sorted(self._experiments) if key != index]
    remap = {old: new for new, old in enumerate(remaining)}
    self._experiments = {remap[key]: self._experiments[key] for key in remaining}
    current = self._current_experiment_index
    self._current_experiment_index = remap.get(current, min(current, len(remaining) - 1)) if remaining else 0
    self._with_experiments = bool(self._experiments)
    if self._last_fit is not None:
        self._last_fit = self._last_fit.remapped(remap)
    return remap

model_index_for_experiment(index)

Index of the model the experiment at index is bound to, or None when it has none.

Source code in src/easyreflectometry/project.py
1021
1022
1023
def model_index_for_experiment(self, index: int) -> Optional[int]:
    """Index of the model the experiment at `index` is bound to, or None when it has none."""
    return self._model_index_for_experiment(self._experiments[index])

set_model_for_experiment(index, model_index)

Bind the experiment at index to the model at model_index.

Source code in src/easyreflectometry/project.py
1025
1026
1027
def set_model_for_experiment(self, index: int, model_index: int) -> None:
    """Bind the experiment at `index` to the model at `model_index`."""
    self._experiments[index].model = self._models[model_index]

experiments_for_model(model_index)

Keys of the experiments bound to the model at model_index.

Source code in src/easyreflectometry/project.py
1029
1030
1031
1032
def experiments_for_model(self, model_index: int) -> List[int]:
    """Keys of the experiments bound to the model at `model_index`."""
    model = self._models[model_index]
    return [key for key, experiment in sorted(self._experiments.items()) if experiment.model is model]

add_contrast(reference_index, name, substitutions=None)

Add a contrast of the model at reference_index; see :func:easyreflectometry.contrasts.derive_contrast.

Replacement materials join the materials palette. Returns the index of the new model.

Source code in src/easyreflectometry/project.py
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
def add_contrast(
    self,
    reference_index: int,
    name: str,
    substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
) -> int:
    """Add a contrast of the model at `reference_index`; see :func:`easyreflectometry.contrasts.derive_contrast`.

    Replacement materials join the materials palette. Returns the index of the new model.
    """
    reference = self._models[reference_index]
    model = derive_contrast(reference, name=name, substitutions=substitutions)
    model.color = self._models.next_color()
    self._models.append(model)
    model.interface = self._calculator
    self._extend_palette_from_models()
    self._sync_parameter_states()
    self._contrasts.append((model, reference))
    self._invalidate_fitter()
    return len(self._models) - 1

contrast_reference(model_index)

Index of the model the model at model_index was derived from, if it was and still exists.

Source code in src/easyreflectometry/project.py
1057
1058
1059
1060
def contrast_reference(self, model_index: int) -> Optional[int]:
    """Index of the model the model at `model_index` was derived from, if it was and still exists."""
    model = self._models[model_index]
    return self._model_index(next((reference for derived, reference in self._contrasts if derived is model), None))

parameter_models(parameter)

Indices of the models parameter belongs to (several when an object is shared).

Source code in src/easyreflectometry/project.py
1062
1063
1064
def parameter_models(self, parameter: Parameter) -> List[int]:
    """Indices of the models `parameter` belongs to (several when an object is shared)."""
    return self._parameter_owners().get(id(parameter), [])

What tying the model at follower_index to the one at reference_index would do; see :func:easyreflectometry.contrasts.plan_link. Nothing is changed.

Source code in src/easyreflectometry/project.py
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
def plan_link(self, follower_index: int, reference_index: int, materials: Optional[str] = None) -> LinkPlan:
    """What tying the model at `follower_index` to the one at `reference_index` would do; see
    :func:`easyreflectometry.contrasts.plan_link`. Nothing is changed."""
    owners = self._parameter_owners()
    return plan_link(
        self._models[follower_index],
        self._models[reference_index],
        owners=lambda parameter: owners.get(id(parameter), []),
        follower_index=follower_index,
        materials=materials,
    )

Tie the rows of plan marked 'tie', all or none.

Raises:

Type Description
ValueError

A row is still 'undecided', or the project changed since the plan was made so that a row can no longer be tied as planned (it was applied already, a model now shares the parameter, ...). Nothing is tied then.

Source code in src/easyreflectometry/project.py
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
def apply_link(self, plan: LinkPlan) -> LinkRecord:
    """Tie the rows of `plan` marked ``'tie'``, all or none.

    Raises
    ------
    ValueError
        A row is still ``'undecided'``, or the project changed since the plan
        was made so that a row can no longer be tied as planned (it was applied
        already, a model now shares the parameter, ...). Nothing is tied then.
    """
    undecided = [row.path for row in plan.rows if row.action == 'undecided']
    if undecided:
        raise ValueError(f'Decide whether to tie {", ".join(undecided)} before linking.')
    # The project may have changed since the plan was made (a model now shares a
    # parameter, the plan was applied already): plan again and compare.
    follower_index, reference_index = self._model_index(plan.follower), self._model_index(plan.reference)
    if follower_index is None or reference_index is None:
        raise ValueError('The plan refers to a model that is no longer in the project; plan the link again.')
    outdated = plan.outdated(self.plan_link(follower_index, reference_index))
    if outdated:
        raise ValueError(f'The plan is out of date at {", ".join(outdated)}; plan the link again.')
    pairs = []
    try:
        for row in plan.rows:
            if row.action == 'tie':
                state = snapshot(row.follower)
                constrain_equal(row.follower, row.reference)
                pairs.append((row.follower, row.reference, state))
    except Exception:
        for follower, _, state in pairs:
            unconstrain(follower)
            restore(follower, state)
        raise
    record = LinkRecord(plan.follower, plan.reference, pairs)
    self._links.append(record)
    return record

Remove the ties record made. Each follower gets back its value, bounds and fixed state from before the link (restore_state), or keeps its current ones. Returns the indices of the models whose parameters were freed.

Source code in src/easyreflectometry/project.py
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
def unlink(self, record: LinkRecord, restore_state: bool = True) -> List[int]:
    """Remove the ties `record` made. Each follower gets back its value, bounds and
    fixed state from before the link (`restore_state`), or keeps its current ones.
    Returns the indices of the models whose parameters were freed."""
    freed = []
    for follower, reference, state in record.pairs:
        if follows(follower, reference):
            unconstrain(follower)
            if restore_state:
                restore(follower, state)
            freed.append(follower)
    self._links = [link for link in self._links if link is not record]
    owners = self._parameter_owners()
    return sorted({index for parameter in freed for index in owners.get(id(parameter), [])})

detach(parameter, model_index=None)

Make parameter independent (in the model at model_index only, when it is shared).

A tie is removed (with the state from before it, when a link made it). A parameter of an assembly shared with other models gets a copy of that assembly in the model at model_index, with every other parameter tied to the shared one, so only parameter's counterpart is free.

A tie on a shared parameter is one tie: removing it frees the parameter in every model sharing it, so that is done without a model_index.

Raises:

Type Description
ValueError

The parameter is derived; model_index is not one of its models; it is shared and model_index is not given; it is shared and tied and model_index is given; or it belongs to a material the model shares (assign the layer another material 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
def detach(self, parameter: Parameter, model_index: Optional[int] = None) -> None:
    """Make `parameter` independent (in the model at `model_index` only, when it is shared).

    A tie is removed (with the state from before it, when a link made it).
    A parameter of an assembly shared with other models gets a copy of that
    assembly in the model at `model_index`, with every other parameter tied
    to the shared one, so only `parameter`'s counterpart is free.

    A tie on a shared parameter is one tie: removing it frees the parameter
    in every model sharing it, so that is done without a `model_index`.

    Raises
    ------
    ValueError
        The parameter is derived; `model_index` is not one of its models; it
        is shared and `model_index` is not given; it is shared and tied and
        `model_index` is given; or it belongs to a material the model shares
        (assign the layer another material instead).
    """
    owners = self.parameter_models(parameter)
    if model_index is not None and model_index not in owners:
        name = self._models[model_index].name if 0 <= model_index < len(self._models) else model_index
        raise ValueError(f"'{parameter.name}' is not part of '{name}'.")
    if not parameter.independent:
        if not getattr(parameter, USER_CONSTRAINT_FLAG, False):
            raise ValueError(f"'{parameter.name}' is derived from other parameters; it cannot be detached.")
        if len(owners) > 1 and model_index is not None:
            raise ValueError(
                f"'{parameter.name}' is one parameter of {len(owners)} models and follows another one; "
                'removing the tie frees it in all of them, so detach it without a model.'
            )
        self._untie(parameter)
        return
    if len(owners) < 2:
        return
    if model_index is None:
        raise ValueError(f"'{parameter.name}' is shared by several models; say which one to detach it in.")
    model = self._models[model_index]
    positions = [
        position
        for position, assembly in enumerate(model.sample)
        if any(p is parameter for p in assembly.get_all_parameters())
    ]
    if len(positions) != 1:
        raise ValueError(
            f"'{parameter.name}' is part of '{model.name}' at several positions ({positions}); "
            'give them their own assemblies first.'
        )
    position = positions[0]
    assembly = model.sample[position]
    kept = kept_children(assembly, {}, set())
    if any(parameter is p for material in kept.values() for p in material.get_all_parameters()):
        raise ValueError(f"'{parameter.name}' belongs to a shared material; give the layer its own material instead.")
    copy = references.rebuild(assembly, kept)
    twin = dict(paths.walk(copy, []))[next(path for path, p in paths.walk(assembly, []) if p is parameter)]
    tie_corresponding(copy, assembly, excluded={id(twin)})
    model.sample[position] = copy
    model.interface = self._calculator
    self._sync_parameter_states()
    self._invalidate_fitter()

get_index_air()

Get index air.

Source code in src/easyreflectometry/project.py
1224
1225
1226
1227
1228
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
1230
1231
1232
1233
1234
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
1236
1237
1238
1239
1240
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
1242
1243
1244
1245
1246
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
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
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
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
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
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
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.')
    self._check_sample_calculable(sample)
    # 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
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
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.')
    self._check_sample_calculable(sample)
    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
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
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)
    self._bind_loaded_dataset(new_experiment, self.models[model_index])
    self._experiments[new_index] = new_experiment
    self._with_experiments = True

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
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
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
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
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

    # Every dataset is built before any is added, so a failure adds none.
    new_experiments = []
    for offset, data_key in enumerate(data_keys):
        fallback_name = f'Experiment {len(self._experiments) + offset}'
        new_experiment = dataset_from_datagroup(data_group, data_key=data_key)
        new_experiment.name = fallback_name
        self._apply_experiment_metadata(path, new_experiment, fallback_name, data_group=data_group, data_key=data_key)
        new_experiments.append(new_experiment)

    model = self.models[self._current_model_index]
    for new_experiment in new_experiments:
        self._bind_loaded_dataset(new_experiment, model)
        self._experiments[len(self._experiments)] = new_experiment
    self._with_experiments = True
    return len(data_keys)

load_experiment_from_files(paths, model_index=None, name=None)

Load several files measured on one contrast as a single experiment.

A contrast is often measured as several curves -- one per incident angle or wavelength band -- each with its own q-resolution. The files are concatenated (sorted by q) into one experiment whose every point keeps the resolution it was measured with, so the fit smears each point correctly. See :func:easyreflectometry.data.merge_datasets.

Parameters:

Name Type Description Default
paths List[Union[Path, str]]

The files, one dataset each.

required
model_index Optional[int]

Index of the model the experiment belongs to. By default, the current model.

None
name Optional[str]

Name of the experiment. By default, the ORSO title of the first file, or 'Experiment N'.

None

Returns:

Type Description
int

Index of the newly loaded experiment.

Source code in src/easyreflectometry/project.py
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
def load_experiment_from_files(
    self,
    paths: List[Union[Path, str]],
    model_index: Optional[int] = None,
    name: Optional[str] = None,
) -> int:
    """Load several files measured on one contrast as a single experiment.

    A contrast is often measured as several curves -- one per incident
    angle or wavelength band -- each with its own q-resolution. The files
    are concatenated (sorted by q) into one experiment whose every point
    keeps the resolution it was measured with, so the fit smears each
    point correctly. See :func:`easyreflectometry.data.merge_datasets`.

    Parameters
    ----------
    paths : List[Union[Path, str]]
        The files, one dataset each.
    model_index : Optional[int], optional
        Index of the model the experiment belongs to. By default, the
        current model.
    name : Optional[str], optional
        Name of the experiment. By default, the ORSO title of the first
        file, or ``'Experiment N'``.

    Returns
    -------
    int
        Index of the newly loaded experiment.
    """
    if not paths:
        raise ValueError('At least one file is required.')
    if model_index is None:
        model_index = self._current_model_index
    model = self.models[model_index]
    new_index = len(self._experiments)

    datasets = [self._load_single_dataset(path) for path in paths]
    # 'Series' is the DataSet1D default that _apply_experiment_metadata
    # replaces by the ORSO title of the first file, or else the fallback.
    experiment = merge_datasets(
        datasets, name='Series' if name is None else name, fill_resolution=self._fill_resolution_for(model)
    )
    if name is None:
        self._apply_experiment_metadata(paths[0], experiment, f'Experiment {new_index}')

    # The merged resolution is kept as merge_datasets built it from the
    # inputs' own resolutions; only a model no other experiment uses follows it.
    configure_model = not self._model_in_use(model)
    experiment.model = model
    if configure_model:
        self._auto_set_background(experiment)
        model.resolution_function = (
            PercentageFwhm(5.0) if experiment.resolution_function is None else experiment.resolution_function
        )
    self._experiments[new_index] = experiment
    self._with_experiments = True
    return new_index

append_to_experiment_at_index(index, path)

Add another measured curve of the same contrast to an existing experiment.

The file's points are merged into the experiment at index (sorted by q), each keeping its own resolution: the file's measured one, and the experiment's resolution_function as currently assigned. The experiment's name and model are kept.

Parameters:

Name Type Description Default
index int

Index of the experiment to extend.

required
path Union[Path, str]

The file to add, one dataset.

required

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment is polarized (extend its channels one file at a time instead), or the file holds several datasets.

Source code in src/easyreflectometry/project.py
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
def append_to_experiment_at_index(self, index: int, path: Union[Path, str]) -> None:
    """Add another measured curve of the same contrast to an existing experiment.

    The file's points are merged into the experiment at *index* (sorted by
    q), each keeping its own resolution: the file's measured one, and the
    experiment's ``resolution_function`` as currently assigned. The
    experiment's name and model are kept.

    Parameters
    ----------
    index : int
        Index of the experiment to extend.
    path : Union[Path, str]
        The file to add, one dataset.

    Raises
    ------
    IndexError
        No experiment is loaded at *index*.
    ValueError
        The experiment is polarized (extend its channels one file at a
        time instead), or the file holds several datasets.
    """
    if index not in self._experiments:
        raise IndexError(f'No experiment at index {index}')
    experiment = self._experiments[index]
    if isinstance(experiment, PolarizedDataSet):
        raise ValueError('Cannot append a file to a polarized experiment; its channels are separate datasets.')
    model = experiment.model
    addition = self._load_single_dataset(path)
    merged = merge_datasets([experiment, addition], name=experiment.name, fill_resolution=self._fill_resolution_for(model))
    merged.model = model
    merged.include_in_fit = experiment.include_in_fit
    # merge_datasets kept the experiment's own resolution (measured or
    # explicitly assigned) for its points; the model follows the merged
    # one unless another experiment uses it. Without any, the experiment
    # keeps using the model's as it is.
    if model is not None and merged.resolution_function is not None and not self._model_in_use(model, ignoring=experiment):
        model.resolution_function = merged.resolution_function
    self._experiments[index] = merged

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
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
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
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
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
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
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
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
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
1908
1909
1910
1911
1912
1913
1914
1915
1916
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)
    self._bind_loaded_dataset(experiment, self.models[index], replacing=self._experiments.get(index))
    self._experiments[index] = experiment
    self._with_experiments = True

sld_data_for_model_at_index(index=0)

Sld data for model at index.

Source code in src/easyreflectometry/project.py
1930
1931
1932
1933
1934
1935
1936
1937
1938
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
1940
1941
1942
1943
1944
1945
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;
  • 'rho_m_perp': magnitude of the magnetic SLD transverse to the field, |ρM⊥|(z);
  • 'spin_up' / 'spin_down': nuclear plus / minus the longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component along the field (see :func:~easyreflectometry.special.calculations.field_frame_components). These are the potentials of the two spin states only when the moment is collinear with the field and the applied field is zero; with a transverse moment the eigenmodes split by ±|ρM| instead. The applied field is not included.

plus the floats 'guide_field_angle' and 'applied_field' of the model.

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
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
def magnetic_sld_data_for_model_at_index(self, index: int = 0) -> Dict[str, Union[DataSet1D, float]]:
    """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;
        - ``'rho_m_perp'``: magnitude of the magnetic SLD transverse to the
          field, |ρM⊥|(z);
        - ``'spin_up'`` / ``'spin_down'``: nuclear plus / minus the
          longitudinal magnetic SLD, ρ(z) ± ρM∥(z), with ρM∥ the component
          along the field (see
          :func:`~easyreflectometry.special.calculations.field_frame_components`).
          These are the potentials of the two spin states only when the
          moment is collinear with the field and the applied field is zero;
          with a transverse moment the eigenmodes split by ±|ρM| instead.
          The applied field is not included.

        plus the floats ``'guide_field_angle'`` and ``'applied_field'`` of the model.

    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)
    guide_field_angle = float(model.guide_field_angle.value)
    parallel, perpendicular = field_frame_components(rho_m, 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]),
        'rho_m_perp': DataSet1D(name=f'Transverse magnetic SLD for Model {index}', x=z, y=perpendicular),
        'spin_up': DataSet1D(name=f'Nuclear + longitudinal magnetic SLD for Model {index}', x=z, y=sld + parallel),
        'spin_down': DataSet1D(name=f'Nuclear - longitudinal magnetic SLD for Model {index}', x=z, y=sld - parallel),
        'guide_field_angle': guide_field_angle,
        'applied_field': float(model.applied_field.value),
    }

volume_fraction_data_for_model_at_index(index=0, *, z=None, max_delta_z=None, merge_equivalent=True, groups=None)

Volume fraction (occupancy) depth profiles of the components of a model.

Built from the sample alone, so no calculator binding is needed. See :func:easyreflectometry.sample.volume_fraction.volume_fraction_profile for the depth convention and the caveats.

Parameters:

Name Type Description Default
index int

Index of the model.

0
z np.ndarray

Depths (Å) to evaluate at; z = 0 is the superphase / first layer interface. By default a 500-point grid spanning the sample, the same grid the refnx calculator uses for its SLD profile.

None
max_delta_z float

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

None
merge_equivalent bool

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

True
groups Mapping[str, Sequence[str]]

Presentation groups forwarded to :meth:~easyreflectometry.sample.VolumeFractionProfile.grouped, e.g. {'Water': ['D2O', 'H2O']}.

None

Returns:

Type Description
Dict[str, DataSet1D]

One profile per component, keyed by its stable component key (or group label) in front-to-back order; the display label is in the dataset's name. The y values sum to one at every x.

Source code in src/easyreflectometry/project.py
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
def volume_fraction_data_for_model_at_index(
    self,
    index: int = 0,
    *,
    z: Optional[np.ndarray] = None,
    max_delta_z: Optional[float] = None,
    merge_equivalent: bool = True,
    groups: Optional[Mapping[str, Sequence[str]]] = None,
) -> Dict[str, DataSet1D]:
    """Volume fraction (occupancy) depth profiles of the components of a model.

    Built from the sample alone, so no calculator binding is needed. See
    :func:`easyreflectometry.sample.volume_fraction.volume_fraction_profile`
    for the depth convention and the caveats.

    Parameters
    ----------
    index : int
        Index of the model.
    z : np.ndarray, optional
        Depths (Å) to evaluate at; ``z = 0`` is the superphase / first
        layer interface. By default a 500-point grid spanning the sample,
        the same grid the refnx calculator uses for its SLD profile.
    max_delta_z : float, optional
        Maximum spacing of the default grid. Cannot be combined with ``z``.
    merge_equivalent : bool, optional
        Merge materials with equal name and SLD into one component. By default, True.
    groups : Mapping[str, Sequence[str]], optional
        Presentation groups forwarded to
        :meth:`~easyreflectometry.sample.VolumeFractionProfile.grouped`,
        e.g. ``{'Water': ['D2O', 'H2O']}``.

    Returns
    -------
    Dict[str, DataSet1D]
        One profile per component, keyed by its stable component key (or
        group label) in front-to-back order; the display label is in the
        dataset's ``name``. The ``y`` values sum to one at every ``x``.
    """
    model = self.models[index]
    profile = volume_fraction_profile(model.sample, z, max_delta_z=max_delta_z, merge_equivalent=merge_equivalent)
    if groups:
        profile = profile.grouped(groups)
    return {
        key: DataSet1D(
            name=f'{profile.labels[key]} volume fraction for Model {index}',
            x=profile.z,
            y=values,
            x_label='z (Å)',
            y_label='Volume fraction',
        )
        for key, values in profile.fractions.items()
    }

sample_data_for_model_at_index(index=0, q_range=None)

The unsmeared reflectivity of the model at index: the sample alone, no resolution.

The zero-width resolution is bound to this one calculation; the model's own resolution, which is calculator state shared with a fit possibly running in another thread, is not touched.

Source code in src/easyreflectometry/project.py
2073
2074
2075
2076
2077
2078
2079
2080
def sample_data_for_model_at_index(self, index: int = 0, q_range: Optional[np.array] = None) -> DataSet1D:
    """The unsmeared reflectivity of the model at `index`: the sample alone, no resolution.

    The zero-width resolution is bound to this one calculation; the
    model's own resolution, which is calculator state shared with a fit
    possibly running in another thread, is not touched.
    """
    return self.model_data_for_model_at_index(index, q_range, resolution_function=PercentageFwhm(0))

model_data_for_model_at_index(index=0, q_range=None, channel=None, resolution_function=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
resolution_function Optional[ResolutionFunction]

Resolution to smear with; the model's by default. Pass a dataset's own resolution to get the curve the fit compares that dataset to (see :meth:model_data_for_experiment_at_index).

None
Source code in src/easyreflectometry/project.py
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
def model_data_for_model_at_index(
    self,
    index: int = 0,
    q_range: Optional[np.array] = None,
    channel: Optional[Union[PolarizationChannel, str]] = None,
    resolution_function: Optional[ResolutionFunction] = 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.
    resolution_function : Optional[ResolutionFunction]
        Resolution to smear with; the model's by default. Pass a dataset's
        own resolution to get the curve the fit compares that dataset to
        (see :meth:`model_data_for_experiment_at_index`).
    """
    if q_range is None:
        q_range = np.linspace(self.q_min, self.q_max, self.q_resolution)
    self._bind_calculator(self.models[index])
    interface = self.models[index].interface()
    unique_name = self._models[index].unique_name
    if channel is None:
        reflectivity = interface.reflectity_profile(q_range, unique_name, resolution_function=resolution_function)
        name = f'Reflectivity for Model {index}'
    else:
        channel = PolarizationChannel(channel)
        reflectivity = interface.reflectivity_profile_channel(
            q_range, unique_name, channel, resolution_function=resolution_function
        )
        name = f'Reflectivity ({channel.value}) for Model {index}'
    return DataSet1D(
        name=name,
        x=q_range,
        y=reflectivity,
    )

model_data_for_experiment_at_index(index=0, channel=None, q_range=None)

The model curve an experiment is compared to: its model, at its q, with its resolution.

Parameters:

Name Type Description Default
index int

Index of the experiment.

0
channel Optional[Union[PolarizationChannel, str]]

Spin channel of a polarized experiment; required for one, not allowed for an unpolarized one.

None
q_range Optional[np.array]

Points to calculate at; the experiment's own q by default.

None

Raises:

Type Description
IndexError

No experiment is loaded at index.

ValueError

The experiment has no model, or channel does not match the experiment's kind.

Source code in src/easyreflectometry/project.py
2128
2129
2130
2131
2132
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
2170
2171
2172
def model_data_for_experiment_at_index(
    self,
    index: int = 0,
    channel: Optional[Union[PolarizationChannel, str]] = None,
    q_range: Optional[np.array] = None,
) -> DataSet1D:
    """The model curve an experiment is compared to: its model, at its q, with its resolution.

    Parameters
    ----------
    index : int
        Index of the experiment.
    channel : Optional[Union[PolarizationChannel, str]]
        Spin channel of a polarized experiment; required for one, not
        allowed for an unpolarized one.
    q_range : Optional[np.array]
        Points to calculate at; the experiment's own q by default.

    Raises
    ------
    IndexError
        No experiment is loaded at `index`.
    ValueError
        The experiment has no model, or `channel` does not match the
        experiment's kind.
    """
    experiment = self.experimental_data_for_model_at_index(index)
    if isinstance(experiment, PolarizedDataSet):
        if channel is None:
            raise ValueError(f'Experiment at index {index} is polarized; a channel is required.')
        dataset = experiment[channel]
    else:
        if channel is not None:
            raise ValueError(f'Experiment at index {index} is not polarized; it has no channel.')
        dataset = experiment
    model = experiment.model
    if model is None:
        raise ValueError(f'Experiment at index {index} has no model.')
    model_index = next(i for i, candidate in enumerate(self.models) if candidate is model)
    return self.model_data_for_model_at_index(
        model_index,
        q_range=np.asarray(dataset.x) if q_range is None else q_range,
        channel=channel,
        resolution_function=getattr(dataset, 'resolution_function', None),
    )

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
2174
2175
2176
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 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
2228
2229
2230
2231
2232
2233
2234
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
2236
2237
2238
2239
2240
2241
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
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
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
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
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
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
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
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
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
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

plan_model_removal(index)

What removing the model at index would affect; nothing is changed.

Raises:

Type Description
IndexError :

If the index is out of range.

Source code in src/easyreflectometry/project.py
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
def plan_model_removal(self, index: int) -> 'ModelRemoval':
    """What removing the model at `index` would affect; nothing is changed.

    Raises
    ------
    IndexError :
        If the index is out of range.
    """
    if index < 0 or index >= len(self._models):
        raise IndexError(f'Model index {index} out of range')
    model = self._models[index]
    survivors = [other for other in self._models if other is not model]
    surviving = {id(parameter) for other in survivors for parameter in other.get_all_parameters()}
    # Parameters only this model owns; one shared with a surviving model stays.
    removed = {id(parameter) for parameter in model.get_all_parameters() if id(parameter) not in surviving}
    dependents = [
        parameter
        for other in survivors
        for parameter in other.get_all_parameters()
        if not parameter.independent and any(id(leader) in removed for leader in parameter.dependency_map.values())
    ]
    inequality_constraints = [
        spec
        for spec in self._inequality_constraints
        if any(id(self._parameter_or_none(path)) in removed for path in spec.paths.values())
    ]
    return ModelRemoval(
        experiments=self.experiments_for_model(index),
        dependents=list({id(parameter): parameter for parameter in dependents}.values()),
        inequality_constraints=inequality_constraints,
    )

remove_model_at_index(index, experiments=None)

Remove the model at index, resolving everything that refers to it.

Parameters of other models that follow a parameter only this model owns become independent, keeping their current value (or getting back the one from before the link, when :meth:apply_link made the tie). Inequality constraints on such parameters are removed; the others are re-pathed. Objects the model shares with other models stay with them. See :meth:plan_model_removal.

Parameters:

Name Type Description Default
index int

Index of the model to remove.

required
experiments Union[str, int, None]

What happens to the experiments bound to the model: 'remove' removes them, an int rebinds them to the model at that index (counted before the removal). Required when there are any.

None

Returns:

Type Description
ModelRemoval

What was affected; its remap keys the remaining experiments.

Raises:

Type Description
IndexError :

If the index is out of range.

ValueError :

If trying to remove the last remaining model, or experiments are bound to it and experiments does not say what to do with them. Nothing is changed then.

Source code in src/easyreflectometry/project.py
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
def remove_model_at_index(self, index: int, experiments: Union[str, int, None] = None) -> 'ModelRemoval':
    """Remove the model at `index`, resolving everything that refers to it.

    Parameters of other models that follow a parameter only this model
    owns become independent, keeping their current value (or getting back
    the one from before the link, when :meth:`apply_link` made the tie). Inequality
    constraints on such parameters are removed; the others are re-pathed.
    Objects the model shares with other models stay with them. See
    :meth:`plan_model_removal`.

    Parameters
    ----------
    index : int
        Index of the model to remove.
    experiments : Union[str, int, None], optional
        What happens to the experiments bound to the model: ``'remove'``
        removes them, an int rebinds them to the model at that index
        (counted before the removal). Required when there are any.

    Returns
    -------
    ModelRemoval
        What was affected; its ``remap`` keys the remaining experiments.

    Raises
    ------
    IndexError :
        If the index is out of range.
    ValueError :
        If trying to remove the last remaining model, or experiments are
        bound to it and `experiments` does not say what to do with them.
        Nothing is changed then.
    """
    plan = self.plan_model_removal(index)
    if len(self._models) <= 1:
        raise ValueError('Cannot remove the last model from the project')
    if plan.experiments and experiments != 'remove':
        if not isinstance(experiments, int) or experiments == index or not 0 <= experiments < len(self._models):
            raise ValueError(
                f'Experiments {plan.experiments} use the model; pass experiments="remove" '
                'or the index of another model to bind them to.'
            )

    for parameter in plan.dependents:
        self._untie(parameter)
    removed = self._models[index]
    self._links = [link for link in self._links if removed not in (link.follower, link.reference)]
    self._contrasts = [pair for pair in self._contrasts if removed not in pair]
    remap = {key: key for key in self._experiments}
    if experiments == 'remove':
        for key in reversed(plan.experiments):
            step = self.remove_experiment(key)
            remap = {old: step[new] for old, new in remap.items() if new in step}
    elif plan.experiments:
        for key in plan.experiments:
            self._experiments[key].model = self._models[experiments]
    self._keeping_inequality_paths(lambda: self._models.pop(index))
    self._invalidate_fitter()

    # 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
    return ModelRemoval(plan.experiments, plan.dependents, plan.inequality_constraints, remap)

move_model(index, new_index)

Move the model at index to new_index, keeping inequality constraints on their parameters.

Source code in src/easyreflectometry/project.py
2549
2550
2551
2552
2553
2554
2555
2556
def move_model(self, index: int, new_index: int) -> None:
    """Move the model at `index` to `new_index`, keeping inequality constraints on their parameters."""
    if index == new_index:
        return
    current = self._models[self._current_model_index]
    self._keeping_inequality_paths(lambda: self._models.insert(new_index, self._models.pop(index)))
    self._current_model_index = self._model_index(current)
    self._invalidate_fitter()

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
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
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
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
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
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
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
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
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
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
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.

Objects used in several places (a material in two layers, an assembly in two models) are written once and referred to elsewhere. The materials palette is written in order under materials. include_materials_not_in_model adds the unused materials once more under their older key, for readers that predate materials.

Source code in src/easyreflectometry/project.py
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
def as_dict(self, include_materials_not_in_model=False):
    """As dict.

    Objects used in several places (a material in two layers, an assembly
    in two models) are written once and referred to elsewhere. The
    materials palette is written in order under ``materials``.
    ``include_materials_not_in_model`` adds the unused materials once more
    under their older key, for readers that predate ``materials``.
    """
    project_dict = {}
    project_dict['file_format'] = self.FILE_FORMAT
    project_dict['info'] = self._info
    project_dict['with_experiments'] = self._with_experiments
    with references.writing():
        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'
        project_dict['materials'] = [material.as_dict() for material in self._materials]
    references.prune(project_dict)
    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._needs_new_format(project_dict):
        project_dict['file_format'] = self.FILE_FORMAT_SHARED
    project_dict['fit_settings'] = self._fit_settings.to_dict()
    # Kept for readers that predate `fit_settings`.
    project_dict['fitter_minimizer'] = project_dict['fit_settings']['minimizer']
    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
    self._as_dict_add_contrasts(project_dict)
    return project_dict

from_dict(project_dict)

From dict.

Source code in src/easyreflectometry/project.py
2930
2931
2932
2933
2934
2935
2936
2937
2938
2939
2940
2941
2942
2943
2944
2945
2946
2947
2948
2949
2950
2951
2952
2953
2954
2955
2956
2957
2958
2959
2960
2961
2962
2963
2964
2965
2966
2967
2968
2969
2970
2971
2972
2973
2974
2975
2976
2977
2978
2979
2980
2981
2982
2983
2984
2985
2986
2987
2988
2989
2990
2991
2992
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 not in (self.FILE_FORMAT, self.FILE_FORMAT_SHARED):
        raise ValueError(
            f'Unsupported project file_format={file_format!r}; this version of '
            f'easyreflectometry only reads file_format={self.FILE_FORMAT} and {self.FILE_FORMAT_SHARED}. '
            'Please either update easyreflectometry or re-create the project.'
        )
    report: list[str] = []
    self._info = project_dict['info']
    self._with_experiments = project_dict['with_experiments']
    if 'calculator' in keys:
        self._calculator.switch(project_dict['calculator'])
    with references.reading(report):
        if 'models' in keys:
            self.models = ModelCollection.from_dict(project_dict['models'])
        palette = [references.deserialize(entry) for entry in project_dict.get('materials', [])]
    if 'materials' in keys:
        self._replace_collection(palette, self._materials)
    else:
        # A file predating `materials`: the models' materials, then the unused ones.
        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']))
    # Settings are replaced wholesale, never merged with the previous project's.
    # A file predating `fit_settings` only stored `fitter_minimizer`.
    legacy = {'minimizer': project_dict.get('fitter_minimizer')}
    self._fit_settings, settings_report = FitSettings.from_dict(project_dict.get('fit_settings', legacy))
    report.extend(settings_report)
    self._invalidate_fitter()
    self._last_fit = None  # the previous contents' run, not this project's
    if 'experiments' in keys:
        self._experiments = self._from_dict_extract_experiments(project_dict, report)
    else:
        self._experiments = {}
    self.load_report = report

    # 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', [])]
    self._from_dict_restore_contrasts(project_dict, self.load_report)

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)

constrain_equal(parameter, to)

Tie parameter to always equal to.

parameter becomes dependent: it is removed from the free fit parameters, immediately takes the value of to, and follows it from then on. Its value, unit, variance, min and max are replaced by to's, and its fixed flag is cleared. While constrained, the parameter's value and bounds cannot be set directly.

Parameters:

Name Type Description Default
parameter Parameter

Parameter to make dependent (the follower).

required
to DescriptorNumber

Parameter (or descriptor) to follow.

required
Source code in src/easyreflectometry/constraints.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
def constrain_equal(parameter: Parameter, to: DescriptorNumber) -> None:
    """Tie `parameter` to always equal `to`.

    `parameter` becomes dependent: it is removed from the free fit
    parameters, immediately takes the value of `to`, and follows it from
    then on. Its value, unit, variance, min and max are replaced by
    `to`'s, and its `fixed` flag is cleared. While constrained, the
    parameter's value and bounds cannot be set directly.

    Parameters
    ----------
    parameter : Parameter
        Parameter to make dependent (the follower).
    to : DescriptorNumber
        Parameter (or descriptor) to follow.
    """
    constrain(parameter, 'a', a=to)

unconstrain(parameter)

Remove any constraint from parameter.

Idempotent: calling it on an already-independent parameter is a no-op. The parameter keeps its current (last evaluated) value and becomes fittable again. Its bounds, unit, variance and fixed state are not restored to their pre-constraint values. Review and reset the bounds before fitting.

Parameters:

Name Type Description Default
parameter Parameter

Parameter to make independent again.

required
Source code in src/easyreflectometry/constraints.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def unconstrain(parameter: Parameter) -> None:
    """Remove any constraint from `parameter`.

    Idempotent: calling it on an already-independent parameter is a
    no-op. The parameter keeps its current (last evaluated) value and
    becomes fittable again. Its bounds, unit, variance and `fixed` state
    are not restored to their pre-constraint values. Review and reset
    the bounds before fitting.

    Parameters
    ----------
    parameter : Parameter
        Parameter to make independent again.
    """
    if not parameter.independent:
        parameter.make_independent()
    if hasattr(parameter, USER_CONSTRAINT_FLAG):
        delattr(parameter, USER_CONSTRAINT_FLAG)

derive_contrast(reference, *, name, substitutions=None)

A new contrast of reference's structure; reference is not changed.

Assemblies the substitutions do not touch are shared with reference. An assembly they touch is copied: its materials are shared except the replaced ones, its layers are new, and every independent parameter of the copy is tied to the reference's except those of the replacements (and the scattering lengths of a re-formulated material). So a hydrated layer in another solvent keeps the reference's thickness, roughness and solvent fraction, and its effective SLD follows the dry material it still shares. Scale, background and resolution are copied by value and stay independent.

Raises:

Type Description
UnsupportedSubstitution

A target is not part of reference, or a formula target is neither a :class:LayerAreaPerMolecule nor a :class:MaterialDensity.

Source code in src/easyreflectometry/contrasts.py
 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
def derive_contrast(
    reference: Model,
    *,
    name: str,
    substitutions: Optional[Sequence[Union[ReplaceMaterial, ReplaceFormula]]] = None,
) -> Model:
    """A new contrast of `reference`'s structure; `reference` is not changed.

    Assemblies the substitutions do not touch are shared with `reference`.
    An assembly they touch is copied: its materials are shared except the
    replaced ones, its layers are new, and every independent parameter of the
    copy is tied to the reference's except those of the replacements (and the
    scattering lengths of a re-formulated material). So a hydrated layer in
    another solvent keeps the reference's thickness, roughness and solvent
    fraction, and its effective SLD follows the dry material it still shares.
    Scale, background and resolution are copied by value and stay independent.

    Raises
    ------
    UnsupportedSubstitution
        A target is not part of `reference`, or a formula target is neither a
        :class:`LayerAreaPerMolecule` nor a :class:`MaterialDensity`.
    """
    substitutions = list(substitutions or [])
    swaps = {id(item.old): item.new for item in substitutions if isinstance(item, ReplaceMaterial)}
    formulas = {id(item.target): item for item in substitutions if isinstance(item, ReplaceFormula)}
    targets = set(swaps) | set(formulas)
    # What may be substituted is decided once, in substitution_candidates: a material a
    # LayerAreaPerMolecule builds for itself is rebuilt from the formula, so replacing it
    # would do nothing.
    materials, formula_targets = substitution_candidates(reference)
    replaceable = {id(material) for material in materials}
    reformulable = {id(target) for target in formula_targets}
    in_reference = {id(obj) for obj in _objects(reference.sample)}
    for item in substitutions:
        target = item.old if isinstance(item, ReplaceMaterial) else item.target
        if id(target) not in in_reference:
            raise UnsupportedSubstitution(f"'{getattr(target, 'name', target)}' is not part of '{reference.name}'.")
        if isinstance(item, ReplaceMaterial):
            if id(target) not in replaceable:
                raise UnsupportedSubstitution(
                    f"'{target.name}' is built by a layer from its chemical formula; use ReplaceFormula on that layer."
                )
        else:
            if id(target) not in reformulable:
                raise UnsupportedSubstitution(f"'{target.name}' has no chemical formula to replace.")
            _check_formula(item.formula)

    assemblies = []
    for assembly in reference.sample:
        if not any(id(obj) in targets for obj in _objects(assembly)):
            assemblies.append(assembly)
            continue
        copy = references.rebuild(assembly, kept_children(assembly, swaps, targets))
        corresponding = _corresponding_objects(assembly, copy)
        excluded = {id(parameter) for new in swaps.values() for parameter in new.get_all_parameters()}
        for target_id, item in formulas.items():
            if target_id in corresponding:
                target_copy = corresponding[target_id]
                if isinstance(target_copy, LayerAreaPerMolecule):
                    target_copy.molecular_formula = item.formula
                else:
                    target_copy.chemical_structure = item.formula
                    excluded |= {id(target_copy.scattering_length_real), id(target_copy.scattering_length_imag)}
        tie_corresponding(copy, assembly, excluded)
        assemblies.append(copy)

    model = Model(
        sample=Sample(*assemblies),
        name=name,
        resolution_function=ResolutionFunction.from_dict(reference.resolution_function.as_dict()),
    )
    for attribute in ('scale', 'background', 'guide_field_angle', 'applied_field'):
        restore(getattr(model, attribute), snapshot(getattr(reference, attribute)))
    return model

follows(parameter, leader)

Whether parameter is a plain equality tie to leader.

Source code in src/easyreflectometry/contrasts.py
355
356
357
358
359
360
361
362
363
def follows(parameter: Parameter, leader: Parameter) -> bool:
    """Whether `parameter` is a plain equality tie to `leader`."""
    dependency_map = parameter.dependency_map or {}
    return (
        not parameter.independent
        and len(dependency_map) == 1
        and next(iter(dependency_map.values())) is leader
        and parameter._clean_dependency_string.strip() == next(iter(dependency_map))
    )

kept_children(assembly, swaps, targets)

What a copy of assembly keeps (see :func:references.rebuild).

Each replaced material maps to its replacement; every other material that holds no target is shared. Layers are copied, and so are the materials a :class:LayerAreaPerMolecule builds for itself (all but its solvent), because their constraints belong to that layer.

Source code in src/easyreflectometry/contrasts.py
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
def kept_children(assembly: Any, swaps: dict[int, Any], targets: set[int]) -> dict[int, Any]:
    """What a copy of `assembly` keeps (see :func:`references.rebuild`).

    Each replaced material maps to its replacement; every other material that
    holds no target is shared. Layers are copied, and so are the materials a
    :class:`LayerAreaPerMolecule` builds for itself (all but its solvent),
    because their constraints belong to that layer.
    """
    keep: dict[int, Any] = {}
    seen: set[int] = set()

    def visit(node: Any, owned: bool) -> None:
        if id(node) in seen or isinstance(node, DescriptorNumber):
            return
        seen.add(id(node))
        if id(node) in swaps:
            keep[id(node)] = swaps[id(node)]
            return
        if (
            isinstance(node, (Material, MaterialMixture))
            and not owned
            and not any(id(obj) in targets for obj in _objects(node))
        ):
            keep[id(node)] = node
            return
        for _, child in paths.children(node):
            internal = isinstance(node, LayerAreaPerMolecule) and child is node.material
            visit(child, internal or (owned and child is not getattr(node, 'solvent', None)))

    visit(assembly, False)
    return keep

The equalities that would tie follower's parameters to reference's; nothing is changed.

Parameters correspond by their path within the model, so the two layouts must match. Scale and background stay per contrast; parameters that are one object, derived, disabled or followed by something else are skipped with a reason; a parameter the follower shares with a third model is skipped as well (tying it would move that model too). Chemistry parameters (:data:CHEMISTRY) are 'undecided' unless materials is 'tie' or 'skip'.

Raises:

Type Description
LayoutMismatch

The models' parameters do not correspond one to one.

Source code in src/easyreflectometry/contrasts.py
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
def plan_link(
    follower: Model,
    reference: Model,
    *,
    owners: Callable[[Parameter], list[int]],
    follower_index: int,
    materials: Optional[str] = None,
) -> LinkPlan:
    """The equalities that would tie `follower`'s parameters to `reference`'s; nothing is changed.

    Parameters correspond by their path within the model, so the two layouts
    must match. Scale and background stay per contrast; parameters that are
    one object, derived, disabled or followed by something else are skipped
    with a reason; a parameter the follower shares with a third model is
    skipped as well (tying it would move that model too). Chemistry
    parameters (:data:`CHEMISTRY`) are ``'undecided'`` unless `materials` is
    ``'tie'`` or ``'skip'``.

    Raises
    ------
    LayoutMismatch
        The models' parameters do not correspond one to one.
    """
    # Every path: a material used by two layers of one model and by two distinct
    # objects in the other still corresponds layer by layer.
    follower_parameters = _parameters(follower.sample, ['sample'], every_path=True)
    reference_parameters = _parameters(reference.sample, ['sample'], every_path=True)
    if follower_parameters.keys() != reference_parameters.keys():
        mismatch = sorted(follower_parameters.keys() ^ reference_parameters.keys())[0]
        raise LayoutMismatch(f"'{follower.name}' and '{reference.name}' differ in layout at {mismatch}.")
    rows = [
        LinkRow(name, getattr(follower, name), getattr(reference, name), 'skip', 'per contrast')
        for name in ('scale', 'background')
    ]
    planned: dict[int, LinkRow] = {}
    for path, parameter in follower_parameters.items():
        row = LinkRow(path, parameter, reference_parameters[path], 'tie')
        first = planned.get(id(parameter))
        if first is None:
            planned[id(parameter)] = row
            _classify(row, follower_index, owners, materials)
        elif first.reference is row.reference or first.action not in ('tie', 'undecided'):
            row.action, row.reason = 'skip', f'the same parameter as {first.path}'
        else:
            # The follower uses one parameter where the reference uses two: no silent choice.
            row.action = first.action = 'conflict'
            row.reason = first.reason = f'one parameter at {first.path} and {path}, two different reference parameters'
        rows.append(row)
    return LinkPlan(follower, reference, rows)

restore(parameter, state)

Source code in src/easyreflectometry/contrasts.py
271
272
273
274
275
276
277
def restore(parameter: Parameter, state: dict) -> None:
    parameter.min, parameter.max = -float('inf'), float('inf')
    parameter.value = state['value']
    parameter.min, parameter.max = state['min'], state['max']
    parameter.fixed = state['fixed']
    if 'variance' in state:  # links saved before the uncertainty was kept have none
        parameter.variance = state['variance']

snapshot(parameter)

The state :func:restore gives back to parameter (a tie replaces its value, bounds and uncertainty).

Source code in src/easyreflectometry/contrasts.py
260
261
262
263
264
265
266
267
268
def snapshot(parameter: Parameter) -> dict:
    """The state :func:`restore` gives back to `parameter` (a tie replaces its value, bounds and uncertainty)."""
    return {
        'value': parameter.value,
        'min': parameter.min,
        'max': parameter.max,
        'fixed': parameter.fixed,
        'variance': parameter.variance,
    }

tie_corresponding(copy, reference, excluded=frozenset())

Tie every independent parameter of copy to the reference parameter at the same path.

Parameters that are the same object, have no counterpart or are listed in excluded (by id) are left alone. Returns the parameters tied.

Source code in src/easyreflectometry/contrasts.py
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
def tie_corresponding(copy: Any, reference: Any, excluded: set[int] = frozenset()) -> list[Parameter]:
    """Tie every independent parameter of `copy` to the reference parameter at the same path.

    Parameters that are the same object, have no counterpart or are listed in
    `excluded` (by id) are left alone. Returns the parameters tied.
    """
    leaders = _parameters(reference, [])
    tied = []
    for path, parameter in _parameters(copy, []).items():
        leader = leaders.get(path)
        if leader is None or leader is parameter or id(parameter) in excluded or not parameter.independent:
            continue
        constrain_equal(parameter, leader)
        tied.append(parameter)
    return tied

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
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
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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
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
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
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)

merge_datasets(datasets, name=None, fill_resolution=None)

Combine several measurements of one contrast into a single dataset.

A contrast is often measured as several curves -- one per incident angle or wavelength band -- each with its own q-resolution. This concatenates them, sorted by Qz, into one dataset that keeps every point's own resolution, so the fit smears each point with the width it was measured with. Overlapping q ranges are kept as they are: the merged dataset may hold the same Qz twice with different widths, which :meth:Pointwise.smearing honours when evaluated at the data points.

Each input contributes the widths of its own resolution_function, evaluated at its points -- the measured :class:Pointwise set by the file loaders, or whatever was assigned explicitly -- so merging does not change the resolution a measurement is fitted with. An input whose resolution_function is None (the model's resolution applies) has no widths of its own; its xe column is not consulted.

Parameters:

Name Type Description Default
datasets Sequence[DataSet1D]

The measurements to combine; at least one. The model, labels and ORSO header of the first one are kept.

required
name Optional[str]

Name of the merged dataset. By default the input names joined by ' + '.

None
fill_resolution Optional[ResolutionFunction]

Resolution assumed for the points of a dataset that carries no resolution when others do, so the merged resolution covers every point. By default the 5% FWHM default resolution. A warning is emitted in that case. When no dataset carries a resolution, the merged dataset has none either and xe is the inputs' columns concatenated.

None

Returns:

Type Description
DataSet1D

The merged dataset. When any input carried a resolution, its resolution_function is a :class:Pointwise over all points and xe holds the matching variances.

Raises:

Type Description
ValueError

No datasets were given.

Source code in src/easyreflectometry/data/measurement.py
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
def merge_datasets(
    datasets: Sequence[DataSet1D],
    name: Optional[str] = None,
    fill_resolution: Optional[ResolutionFunction] = None,
) -> DataSet1D:
    """Combine several measurements of one contrast into a single dataset.

    A contrast is often measured as several curves -- one per incident angle
    or wavelength band -- each with its own q-resolution. This concatenates
    them, sorted by ``Qz``, into one dataset that keeps every point's own
    resolution, so the fit smears each point with the width it was measured
    with. Overlapping q ranges are kept as they are: the merged dataset may
    hold the same ``Qz`` twice with different widths, which
    :meth:`Pointwise.smearing` honours when evaluated at the data points.

    Each input contributes the widths of its own ``resolution_function``,
    evaluated at its points -- the measured :class:`Pointwise` set by the
    file loaders, or whatever was assigned explicitly -- so merging does not
    change the resolution a measurement is fitted with. An input whose
    ``resolution_function`` is ``None`` (the model's resolution applies) has
    no widths of its own; its ``xe`` column is not consulted.

    Parameters
    ----------
    datasets : Sequence[DataSet1D]
        The measurements to combine; at least one. The model, labels and ORSO
        header of the first one are kept.
    name : Optional[str], optional
        Name of the merged dataset. By default the input names joined by ' + '.
    fill_resolution : Optional[ResolutionFunction], optional
        Resolution assumed for the points of a dataset that carries no
        resolution when others do, so the merged resolution covers every
        point. By default the 5% FWHM default resolution. A warning is
        emitted in that case. When no dataset carries a resolution, the
        merged dataset has none either and ``xe`` is the inputs' columns
        concatenated.

    Returns
    -------
    DataSet1D
        The merged dataset. When any input carried a resolution, its
        ``resolution_function`` is a :class:`Pointwise` over all points and
        ``xe`` holds the matching variances.

    Raises
    ------
    ValueError
        No datasets were given.
    """
    datasets = list(datasets)
    if not datasets:
        raise ValueError('At least one dataset is required to merge.')

    resolutions = [getattr(dataset, 'resolution_function', None) for dataset in datasets]
    any_resolution = any(resolution is not None for resolution in resolutions)
    if any_resolution and not all(resolution is not None for resolution in resolutions):
        missing = [dataset.name for dataset, resolution in zip(datasets, resolutions) if resolution is None]
        if fill_resolution is None:
            fill_resolution = PercentageFwhm(DEFAULT_RESOLUTION_FWHM_PERCENTAGE)
        warnings.warn(
            f'Dataset(s) {missing} carry no q-resolution while the others do; their points are '
            f'given the {type(fill_resolution).__name__} resolution so the merged dataset has one.',
            UserWarning,
            stacklevel=2,
        )

    xs, ys, yes, xes = [], [], [], []
    for dataset, resolution in zip(datasets, resolutions):
        x = np.asarray(dataset.x, dtype=float)
        if any_resolution:
            # Evaluated at the dataset's own points, so a Pointwise returns
            # its per-point widths rather than interpolating by q.
            xe = np.square(np.asarray((resolution or fill_resolution).smearing(x), dtype=float))
        else:
            xe = np.zeros_like(x) if dataset.xe is None else np.asarray(dataset.xe, dtype=float)
        xs.append(x)
        ys.append(np.asarray(dataset.y, dtype=float))
        yes.append(np.zeros_like(x) if dataset.ye is None else np.asarray(dataset.ye, dtype=float))
        xes.append(xe)

    x = np.concatenate(xs)
    order = np.argsort(x, kind='stable')
    first = datasets[0]
    merged = DataSet1D(
        name=' + '.join(dataset.name for dataset in datasets) if name is None else name,
        x=x[order],
        y=np.concatenate(ys)[order],
        ye=np.concatenate(yes)[order],
        xe=np.concatenate(xes)[order],
        model=first.model,
        x_label=first.x_label,
        y_label=first.y_label,
        auto_background=False,
    )
    merged.orso_header = getattr(first, 'orso_header', None)
    if any_resolution:
        merged.resolution_function = Pointwise(q_data_points=[merged.x, merged.y, merged.xe])
    return merged

resolution_from_dataset(dataset)

The measured q-resolution of a dataset, or None when it carries none.

A dataset carries its resolution as the xe column, the variance of Qz (sQz²) at every point. When any of it is positive a :class:Pointwise resolution is built from it; an absent, empty, all-zero or all-NaN column gives None, meaning the model's resolution applies.

Parameters:

Name Type Description Default
dataset DataSet1D

The dataset.

required

Returns:

Type Description
Optional[Pointwise]

The per-point resolution, or None.

Source code in src/easyreflectometry/data/measurement.py
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
def resolution_from_dataset(dataset: DataSet1D) -> Optional[Pointwise]:
    """The measured q-resolution of a dataset, or ``None`` when it carries none.

    A dataset carries its resolution as the ``xe`` column, the variance of
    ``Qz`` (sQz²) at every point. When any of it is positive a
    :class:`Pointwise` resolution is built from it; an absent, empty, all-zero
    or all-NaN column gives ``None``, meaning the model's resolution applies.

    Parameters
    ----------
    dataset : DataSet1D
        The dataset.

    Returns
    -------
    Optional[Pointwise]
        The per-point resolution, or ``None``.
    """
    xe = getattr(dataset, 'xe', None)
    if xe is None or len(xe) == 0:
        return None
    xe = np.asarray(xe, dtype=float)
    # 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 not np.any(np.nan_to_num(xe) > 0):
        return None
    return Pointwise(q_data_points=[np.asarray(dataset.x, dtype=float), np.asarray(dataset.y, dtype=float), xe])

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.

The measured q-resolution, when the data carry one (positive sQz), is set as the dataset's resolution_function (see :func:resolution_from_dataset), so fitting smears the dataset with the widths it was measured with. Otherwise it stays None and the model's resolution applies.

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

    The measured q-resolution, when the data carry one (positive sQz), is set
    as the dataset's ``resolution_function`` (see
    :func:`resolution_from_dataset`), so fitting smears the dataset with the
    widths it was measured with. Otherwise it stays ``None`` and the model's
    resolution applies.

    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
    dataset.resolution_function = resolution_from_dataset(dataset)
    return dataset

extract_orso_title(data_group, data_name)

Extract orso title.

Source code in src/easyreflectometry/data/measurement.py
123
124
125
126
127
128
129
130
131
132
133
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
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
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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
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)

_free_roots(models)

Free parameters that parameters of models follow, directly or through other dependencies, but that no model in models owns: a tie to a model left out of the fit. Leaving them out of the fit would hold them fixed.

Source code in src/easyreflectometry/fitting.py
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
def _free_roots(models: list) -> list[Parameter]:
    """Free parameters that parameters of `models` follow, directly or through other
    dependencies, but that no model in `models` owns: a tie to a model left out of
    the fit. Leaving them out of the fit would hold them fixed."""
    owned = {id(parameter) for model in models for parameter in model.get_all_parameters()}
    pending = [parameter for model in models for parameter in model.get_all_parameters() if not parameter.independent]
    roots, seen = {}, set()
    while pending:
        parameter = pending.pop()
        if id(parameter) in seen:
            continue
        seen.add(id(parameter))
        for leader in (parameter.dependency_map or {}).values():
            if not isinstance(leader, Parameter):
                continue
            if not leader.independent:
                pending.append(leader)
            elif not leader.fixed and id(leader) not in owned:
                # A fixed root is held fixed on purpose; only a free one would be held by the tie.
                roots[id(leader)] = leader
    return list(roots.values())

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
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
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
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
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)

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

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

Parameters:

Name Type Description Default
sample Sample

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

required
z np.ndarray

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

None
max_delta_z float

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

None
merge_equivalent bool

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

True

Returns:

Type Description
VolumeFractionProfile

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

Raises:

Type Description
ValueError

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

Warns:

Type Description
VolumeFractionWarning

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

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

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

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

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

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

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

_weak_constraints_provider(project)

Source code in src/easyreflectometry/project.py
137
138
139
140
141
142
143
144
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
3112
3113
3114
3115
3116
3117
3118
3119
3120
3121
3122
3123
3124
3125
3126
3127
3128
3129
3130
3131
3132
3133
3134
3135
3136
3137
3138
3139
3140
3141
3142
3143
3144
3145
3146
3147
3148
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
3151
3152
3153
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
3156
3157
3158
3159
3160
3161
3162
3163
3164
3165
3166
3167
3168
3169
3170
3171
3172
3173
3174
3175
3176
3177
3178
3179
3180
3181
3182
3183
3184
3185
3186
3187
3188
3189
3190
3191
3192
3193
3194
3195
3196
3197
3198
3199
3200
3201
3202
3203
3204
3205
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
3208
3209
3210
3211
3212
3213
3214
3215
3216
3217
3218
3219
3220
3221
3222
3223
3224
3225
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
3228
3229
3230
3231
3232
3233
3234
3235
3236
3237
3238
3239
3240
3241
3242
3243
3244
3245
3246
3247
3248
3249
3250
3251
3252
3253
3254
3255
3256
3257
3258
3259
3260
3261
3262
3263
3264
3265
3266
3267
3268
3269
3270
3271
3272
3273
3274
3275
3276
3277
3278
3279
3280
3281
3282
3283
3284
3285
3286
3287
3288
3289
3290
3291
3292
3293
3294
3295
3296
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

_fix_if_free(parameter)

Fix an independent parameter that is free; dependent ones never fit.

Source code in src/easyreflectometry/project.py
3299
3300
3301
3302
def _fix_if_free(parameter: Parameter) -> None:
    """Fix an independent parameter that is free; dependent ones never fit."""
    if parameter.independent and not parameter.fixed:
        parameter.fixed = True

paths

Structural paths of parameters, e.g. models/0/sample/1/layers/0/thickness.

Paths are stable across save/load (unlike unique names) and are how a project addresses parameters in saved constraints. Within one model the relative paths (sample/1/layers/0/thickness) also match the parameters of two models with the same layout.

children(obj)

(token, child) pairs to descend into from obj.

Source code in src/easyreflectometry/sample/paths.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
def children(obj: Any) -> list[tuple[str, Any]]:
    """``(token, child)`` pairs to descend into from `obj`."""
    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 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, (DescriptorNumber, BaseCore, BaseCollection, list, tuple)):
                candidates.append((attr_name, value))
        return candidates
    return []

walk(root, tokens, visited=None, every_path=False)

Yield (path, parameter) for every parameter under root, paths starting with tokens.

By default each object is descended into once (per visited, shared between calls when given), so an object reachable from several places is reported at its first path only. With every_path, it is reported at each of them; only a cycle back to an ancestor is cut.

Source code in src/easyreflectometry/sample/paths.py
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def walk(
    root: Any, tokens: list[str], visited: Optional[set[int]] = None, every_path: bool = False
) -> Iterator[tuple[str, Any]]:
    """Yield ``(path, parameter)`` for every parameter under `root`, paths starting with `tokens`.

    By default each object is descended into once (per `visited`, shared
    between calls when given), so an object reachable from several places is
    reported at its first path only. With `every_path`, it is reported at each
    of them; only a cycle back to an ancestor is cut.
    """
    visited = set() if visited is None else visited
    if isinstance(root, DescriptorNumber):
        yield '/'.join(tokens), root
        return
    if id(root) in visited:
        return
    visited.add(id(root))
    for token, child in children(root):
        yield from walk(child, tokens + [token], visited, every_path)
    if every_path:
        visited.discard(id(root))

references

Shared sample objects in a serialized tree.

A material, layer or assembly may sit in several places of a project (two layers of one model, two contrasts sharing a structure). Serialized naively, each place gets its own copy and the sharing is lost on reload. Inside :func:writing, the first occurrence of an object is written in full and tagged with @ref_id; every later occurrence is written as a reference node {'@ref': id, '@module': ..., '@class': ...}. Inside :func:reading, a reference node resolves to the object built for that id, so each shared object is constructed once and its owners refer to that one object.

The same tables let a caller build a copy that keeps chosen children: seed the writer with the objects to keep (or to swap) and the reader with what each id should resolve to (:func:rebuild).

@ref_id and @ref are part of the project file format (a tag stays on an object that is referred to; :func:prune drops the others). Anything that rewrites a file must keep them, or the sharing is lost.

writing()

Serialize shared objects once; drop unused @ref_id tags afterwards with :func:prune.

Source code in src/easyreflectometry/sample/references.py
57
58
59
60
61
62
63
64
65
@contextlib.contextmanager
def writing() -> Iterator[_Writer]:
    """Serialize shared objects once; drop unused ``@ref_id`` tags afterwards with :func:`prune`."""
    writer = _Writer()
    token = _writer.set(writer)
    try:
        yield writer
    finally:
        _writer.reset(token)

reading(report=None)

Resolve reference nodes while deserializing; problems are appended to report.

Source code in src/easyreflectometry/sample/references.py
68
69
70
71
72
73
74
75
76
@contextlib.contextmanager
def reading(report: Optional[list[str]] = None) -> Iterator[_Reader]:
    """Resolve reference nodes while deserializing; problems are appended to `report`."""
    reader = _Reader(report if report is not None else [])
    token = _reader.set(reader)
    try:
        yield reader
    finally:
        _reader.reset(token)

suspended()

No tagging inside: for a serialization pass whose output is discarded.

Source code in src/easyreflectometry/sample/references.py
79
80
81
82
83
84
85
86
@contextlib.contextmanager
def suspended() -> Iterator[None]:
    """No tagging inside: for a serialization pass whose output is discarded."""
    token = _writer.set(None)
    try:
        yield
    finally:
        _writer.reset(token)

tag(obj, d)

Serializer hook: d is obj serialized; return it tagged, or a reference node.

Source code in src/easyreflectometry/sample/references.py
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
def tag(obj: Any, d: dict) -> dict:
    """Serializer hook: `d` is `obj` serialized; return it tagged, or a reference node."""
    writer = _writer.get()
    if writer is None:
        return d
    seen = id(obj) in writer.ids
    key = writer.key_for(obj)
    if seen:
        return {REF: key, '@module': d['@module'], '@class': d['@class']}
    d[REF_ID] = key
    return d

resolve(value)

The object a reference node stands for; anything else is returned unchanged.

An unknown id is reported and the node is returned as is, so it deserializes to a default object of its class.

Source code in src/easyreflectometry/sample/references.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def resolve(value: Any) -> Any:
    """The object a reference node stands for; anything else is returned unchanged.

    An unknown id is reported and the node is returned as is, so it
    deserializes to a default object of its class.
    """
    if not is_reference(value):
        return value
    reader = _reader.get()
    if reader is None:
        raise ValueError(f'Reference {value[REF]!r} found outside a project load; it cannot be resolved.')
    obj = reader.objects.get(value[REF])
    if obj is None:
        reader.report.append(f'Shared {value["@class"]} {value[REF]!r} is missing from the file; a default one is used.')
        return value
    if type(obj).__name__ != value.get('@class', type(obj).__name__):
        reader.report.append(
            f'Shared {value["@class"]} {value[REF]!r} is a {type(obj).__name__} in the file; a default one is used.'
        )
        return value
    return obj

deserialize(value)

value deserialized: a reference node resolves to its object, anything else (including an unresolvable reference, built as a default of its class) goes through the serializer.

Source code in src/easyreflectometry/sample/references.py
129
130
131
132
133
134
135
136
137
138
def deserialize(value: Any) -> Any:
    """`value` deserialized: a reference node resolves to its object, anything
    else (including an unresolvable reference, built as a default of its class)
    goes through the serializer."""
    from easyscience.io import SerializerBase

    obj = resolve(value)
    # A single-key dict routes the value through the serializer's per-value
    # path, which uses the value's own class `from_dict`.
    return obj if obj is not value else SerializerBase.deserialize_dict({'item': value})['item']

build_children(obj_dict)

obj_dict with its sample-object children built, in document order.

Building them here, one after the other as they were written, means a reference always meets an object that is already built, also when two sibling slots hold the same object (MaterialMixture(a, a)). Other children (parameters, collections) are left to the regular deserialization: a built collection would be turned into a plain list by it.

Source code in src/easyreflectometry/sample/references.py
141
142
143
144
145
146
147
148
149
150
def build_children(obj_dict: dict) -> dict:
    """`obj_dict` with its sample-object children built, in document order.

    Building them here, one after the other as they were written, means a reference
    always meets an object that is already built, also when two sibling slots hold
    the same object (``MaterialMixture(a, a)``). Other children (parameters,
    collections) are left to the regular deserialization: a built collection would
    be turned into a plain list by it.
    """
    return {key: deserialize(value) if _is_sample_object(value) else value for key, value in obj_dict.items()}

register(obj_dict, obj)

Record obj as built from obj_dict, so later reference nodes resolve to it.

Source code in src/easyreflectometry/sample/references.py
169
170
171
172
173
def register(obj_dict: dict, obj: Any) -> None:
    """Record `obj` as built from `obj_dict`, so later reference nodes resolve to it."""
    reader = _reader.get()
    if reader is not None and REF_ID in obj_dict:
        reader.objects[obj_dict[REF_ID]] = obj

prune(node)

Drop the @ref_id tags no reference node in node points to, in place; returns node.

Source code in src/easyreflectometry/sample/references.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
def prune(node: Any) -> Any:
    """Drop the ``@ref_id`` tags no reference node in `node` points to, in place; returns `node`."""
    referenced: set[str] = set()

    def collect(item: Any) -> None:
        if isinstance(item, dict):
            if REF in item:
                referenced.add(item[REF])
            for value in item.values():
                collect(value)
        elif isinstance(item, list):
            for value in item:
                collect(value)

    def strip(item: Any) -> None:
        if isinstance(item, dict):
            if REF_ID in item and item[REF_ID] not in referenced:
                del item[REF_ID]
            for value in item.values():
                strip(value)
        elif isinstance(item, list):
            for value in item:
                strip(value)

    collect(node)
    strip(node)
    return node

rebuild(obj, keep)

A copy of obj built from its serialized form, keeping chosen children.

keep maps id(child) to the object the copy should hold in its place: the child itself to share it with obj, or another object to swap it in. Everything else under obj is copied.

Source code in src/easyreflectometry/sample/references.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
def rebuild(obj: Any, keep: dict[int, Any]) -> Any:
    """A copy of `obj` built from its serialized form, keeping chosen children.

    `keep` maps ``id(child)`` to the object the copy should hold in its place:
    the child itself to share it with `obj`, or another object to swap it in.
    Everything else under `obj` is copied.
    """
    writer = _Writer()
    for child_id in keep:
        # Mark as seen, so every occurrence is written as a reference node.
        writer.ids[child_id] = (str(len(writer.ids)), None)
    token = _writer.set(writer)
    try:
        data = obj.as_dict(skip=['unique_name'])
    finally:
        _writer.reset(token)
    with reading() as reader:
        for child_id, replacement in keep.items():
            reader.objects[writer.ids[child_id][0]] = replacement
        return type(obj).from_dict(prune(data))