Skip to content

fitting

NON_BUMPS_ERROR = "Inequality constraints (constraints_factory) require the BUMPS engine; the selected minimizer uses '{package}'." module-attribute

_VALID_OBJECTIVES = ('legacy_mask', 'mighell', 'hybrid', 'auto') module-attribute

_EPS = 1e-30 module-attribute

DataSet1D

Bases: SerializerComponent

Source code in src/easyreflectometry/data/data_store.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
class DataSet1D(SerializerComponent):
    def __init__(
        self,
        name: str = 'Series',
        x: Optional[Union[np.ndarray, list]] = None,
        y: Optional[Union[np.ndarray, list]] = None,
        ye: Optional[Union[np.ndarray, list]] = None,
        xe: Optional[Union[np.ndarray, list]] = None,
        model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
        x_label: str = 'x',
        y_label: str = 'y',
        auto_background: bool = True,
    ):
        """Init function."""
        self._model = model
        if y is not None and model is not None and auto_background:
            self._model.background = max(np.min(y), 1e-10)

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

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

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

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

        self.x_label = x_label
        self.y_label = y_label

        self._color = None

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

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

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

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

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

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

model property writable

Model function.

is_experiment property

Is experiment.

is_simulation property

Is simulation.

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

Init function.

Source code in src/easyreflectometry/data/data_store.py
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def __init__(
    self,
    name: str = 'Series',
    x: Optional[Union[np.ndarray, list]] = None,
    y: Optional[Union[np.ndarray, list]] = None,
    ye: Optional[Union[np.ndarray, list]] = None,
    xe: Optional[Union[np.ndarray, list]] = None,
    model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
    x_label: str = 'x',
    y_label: str = 'y',
    auto_background: bool = True,
):
    """Init function."""
    self._model = model
    if y is not None and model is not None and auto_background:
        self._model.background = max(np.min(y), 1e-10)

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

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

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

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

    self.x_label = x_label
    self.y_label = y_label

    self._color = None

data_points()

Data points.

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

__repr__()

Repr function.

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

PolarizedDataSet

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

Parameters:

Name Type Description Default
name str

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

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

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

None
model Model

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

None
Source code in src/easyreflectometry/data/polarized.py
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
class PolarizedDataSet:
    """A polarized experiment: one dataset per measured spin channel, one shared model.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

model property writable

The model shared by all channels.

channels property

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

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

available_channels property

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

is_experiment property

Is experiment.

is_simulation property

Is simulation.

set_channel(channel, dataset)

Add or replace one channel dataset.

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

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

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

required
dataset DataSet1D

The channel data.

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

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

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

remove_channel(channel)

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

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

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

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

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

Model

Bases: BaseCore

Model is the class that represents the experiment.

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

Source code in src/easyreflectometry/model/model.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
class Model(BaseCore):
    """Model is the class that represents the experiment.

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

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

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        color : str, optional
            By default, COLORS[0].
        sample : Union[Sample, None], optional
            The sample being modelled. By default, None.
        scale : Union[Parameter, Number, None], optional
            Scaling factor of profile. By default, None.
        background : Union[Parameter, Number, None], optional
            Linear background magnitude. By default, None.
        name : str, optional
            Name of the model. By default, 'Model'.
        resolution_function : Union[ResolutionFunction, None], optional
            Resolution function. By default, None.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

        if sample is None:
            sample = Sample(interface=interface)
        if resolution_function is None:
            resolution_function = PercentageFwhm(DEFAULTS['resolution']['value'])

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        from easyreflectometry.orso_utils import sample_to_orso_model

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

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

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

        model = super().from_dict(this_dict)

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

total_thickness property

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

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

has_magnetism property

Whether any layer in the sample carries magnetic properties.

is_default property writable

Whether this model was created as a default placeholder.

resolution_function property writable

Return the resolution function.

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

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
color str

By default, COLORS[0].

COLORS[0]
sample Union[Sample, None]

The sample being modelled. By default, None.

None
scale Union[Parameter, Number, None]

Scaling factor of profile. By default, None.

None
background Union[Parameter, Number, None]

Linear background magnitude. By default, None.

None
name str

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

'Model'
resolution_function Union[ResolutionFunction, None]

Resolution function. By default, None.

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/model/model.py
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def __init__(
    self,
    sample: Union[Sample, None] = None,
    scale: Union[Parameter, Number, None] = None,
    background: Union[Parameter, Number, None] = None,
    resolution_function: Union[ResolutionFunction, None] = None,
    name: str = 'Model',
    color: str = COLORS[0],
    unique_name: Optional[str] = None,
    interface=None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    color : str, optional
        By default, COLORS[0].
    sample : Union[Sample, None], optional
        The sample being modelled. By default, None.
    scale : Union[Parameter, Number, None], optional
        Scaling factor of profile. By default, None.
    background : Union[Parameter, Number, None], optional
        Linear background magnitude. By default, None.
    name : str, optional
        Name of the model. By default, 'Model'.
    resolution_function : Union[ResolutionFunction, None], optional
        Resolution function. By default, None.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

    if sample is None:
        sample = Sample(interface=interface)
    if resolution_function is None:
        resolution_function = PercentageFwhm(DEFAULTS['resolution']['value'])

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

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

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

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

interface(new_interface)

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

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

refresh_derived()

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

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

Source code in src/easyreflectometry/model/model.py
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
def refresh_derived(self) -> None:
    """Re-derive the model's computed parameters from the current layer structure.

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

add_assemblies(*assemblies)

Add assemblies to the model sample.

Parameters:

Name Type Description Default
*assemblies list[BaseAssembly]

Assemblies to add to model sample.

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

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

duplicate_assembly(index)

Duplicate a given item or layer in a sample.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item or layer to duplicate.

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

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

remove_assembly(index)

Remove an assembly from the model.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item to remove.

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

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

to_dict(skip=None)

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

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

as_dict(skip=None)

Compatibility alias for :meth:to_dict.

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

as_orso()

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

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

Returns:

Type Description
dict

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

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

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

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

    from easyreflectometry.orso_utils import sample_to_orso_model

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

from_dict(passed_dict) classmethod

Create a Model from a dictionary.

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

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

    model = super().from_dict(this_dict)

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

_ConstrainedEasyScienceMultiFitter

Bases: EasyScienceMultiFitter

EasyScience MultiFitter whose raw fit honours inequality constraints.

fit is a read-only property on the base class (it builds a fresh callable per access), so the interception lives in an override rather than a monkey-patch. An explicit constraints_factory keyword is consumed here and enforced by this library — the core takes no such argument and would swallow it through **kwargs. Without one, a call running inside an active :meth:MultiFitter._constraints block passes through untouched, leaving the outer factory in force.

Source code in src/easyreflectometry/fitting.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
class _ConstrainedEasyScienceMultiFitter(EasyScienceMultiFitter):
    """EasyScience ``MultiFitter`` whose raw ``fit`` honours inequality constraints.

    ``fit`` is a read-only property on the base class (it builds a fresh
    callable per access), so the interception lives in an override rather
    than a monkey-patch. An explicit ``constraints_factory`` keyword is
    consumed here and enforced by this library — the core takes no such
    argument and would swallow it through ``**kwargs``. Without one, a call
    running inside an active :meth:`MultiFitter._constraints` block passes
    through untouched, leaving the outer factory in force.
    """

    #: Weak reference to the owning :class:`MultiFitter` (weak to avoid a
    #: reference cycle); ``None`` disables the interception.
    _constraints_owner = None

    @property
    def fit(self) -> Callable:
        original = EasyScienceMultiFitter.fit.fget(self)
        owner = self._constraints_owner() if self._constraints_owner is not None else None
        if owner is None:
            return original

        @functools.wraps(original)
        def fit_with_constraints(*args, **kwargs):
            explicit = kwargs.pop('constraints_factory', None)
            # An explicit factory wins over an outer block; without one, an
            # outer block has already attached what it resolved.
            if explicit is None and _constraints_active():
                return original(*args, **kwargs)
            with owner._constraints(explicit, fitter=self):
                return original(*args, **kwargs)

        return fit_with_constraints

MultiFitter

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

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

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

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

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

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

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

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

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

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

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

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

        sampler.extend = extend

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

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

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

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

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

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

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

        fitter = cls(*models, objective=objective)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        if total_dof <= 0:
            return None

        return total_chi2 / total_dof

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

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

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

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

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

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

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

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

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

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

sampler property

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

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

chi2 property

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

reduced_chi property

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

classical_chi2 property

Classical chi-squared using only points with positive variances.

classical_reduced_chi property

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

objective_chi2 property

Objective-space chi-squared returned by the minimizer.

objective_reduced_chi property

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

__init__(*args, objective='hybrid')

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

Parameters:

Name Type Description Default
*args Model

Reflectometry model(s).

()
objective str

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

'hybrid'
Source code in src/easyreflectometry/fitting.py
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def __init__(self, *args: Model, objective: str = 'hybrid'):
    r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting`
    which will populate the :py:class:`sc.DataGroup` appropriately
    after the fitting is performed.

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

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

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

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

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

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

Note

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

Parameters:

Name Type Description Default
experiments list[DataSet1D | PolarizedDataSet]

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

required
objective str

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

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

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

None

Returns:

Type Description
MultiFitter

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

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

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

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

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

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

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

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

    fitter = cls(*models, objective=objective)

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

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

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

Perform the fitting and populate the DataGroups with the result.

Parameters:

Name Type Description Default
data sc.DataGroup

DataGroup to be fitted to and populated.

required
id int

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

0
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
sc.DataGroup

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

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

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

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

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

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

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

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

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

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

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

Perform fitting on a single 1D dataset.

Parameters:

Name Type Description Default
data DataSet1D

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

required
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
FitResults

Fit results from the minimizer.

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

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

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

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

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

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

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

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

Fit all measured spin channels of a polarized experiment simultaneously.

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

Parameters:

Name Type Description Default
data PolarizedDataSet

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

required
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
dict[str, FitResults]

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

Note

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

record_fit_results(results)

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

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

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

Parameters:

Name Type Description Default
results list[FitResults] | None

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

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

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

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

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

switch_minimizer(minimizer)

Switch the minimizer for the fitting.

Parameters:

Name Type Description Default
minimizer AvailableMinimizers

Minimizer to be switched to.

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

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

_constraints_applied(factory)

Attach factory's constraints to problems built inside the block.

A no-op when factory is None.

Parameters:

Name Type Description Default
factory Optional[Callable]

Receives the {prefixed name: BumpsParameter} mapping of a freshly built problem and returns the constraints to attach.

required
Source code in src/easyreflectometry/_bumps_constraints.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@contextlib.contextmanager
def applied(factory: Optional[Callable]) -> Iterator[None]:
    """Attach `factory`'s constraints to problems built inside the block.

    A no-op when `factory` is ``None``.

    Parameters
    ----------
    factory : Optional[Callable]
        Receives the ``{prefixed name: BumpsParameter}`` mapping of a freshly
        built problem and returns the constraints to attach.
    """
    if factory is None:
        yield
        return
    install()
    token = _active.set(factory)
    try:
        yield
    finally:
        _active.reset(token)

_constraints_active()

Whether an :func:applied block is active in the current context.

Lets an inner wrapper (MultiFitter routes the raw easy_science_multi_fitter.fit through the constraints machinery) detect that an outer block already attached a factory — possibly an explicit one that must not be overridden by re-resolving the provider.

Source code in src/easyreflectometry/_bumps_constraints.py
101
102
103
104
105
106
107
108
109
def is_applied() -> bool:
    """Whether an :func:`applied` block is active in the current context.

    Lets an inner wrapper (``MultiFitter`` routes the raw
    ``easy_science_multi_fitter.fit`` through the constraints machinery)
    detect that an outer block already attached a factory — possibly an
    explicit one that must not be overridden by re-resolving the provider.
    """
    return _active.get() is not None

_validate_objective(objective)

Validate and resolve the objective string.

Parameters:

Name Type Description Default
objective str

The objective mode string.

required

Raises:

Type Description
ValueError :

If the objective is not one of the valid options.

Returns:

Type Description
str

Resolved objective string ('auto' becomes 'hybrid').

Source code in src/easyreflectometry/fitting.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def _validate_objective(objective: str) -> str:
    """Validate and resolve the objective string.

    Parameters
    ----------
    objective : str
        The objective mode string.

    Raises
    ------
    ValueError :
        If the objective is not one of the valid options.

    Returns
    -------
    str
        Resolved objective string ('auto' becomes 'hybrid').
    """
    if objective not in _VALID_OBJECTIVES:
        raise ValueError(f'Unknown objective {objective!r}. Valid options: {_VALID_OBJECTIVES}')
    if objective == 'auto':
        return 'hybrid'
    return objective

_prepare_fit_arrays(x_vals, y_vals, variances, objective)

Prepare x, y_eff, and weights arrays for fitting based on the objective mode.

For legacy_mask, zero-variance points are removed from all arrays. For hybrid, valid-variance points use standard WLS while zero-variance points use Mighell-transformed y and weights. For mighell, all points use the Mighell transform.

Note: variances here means σ² (the scipp convention), not σ.

Parameters:

Name Type Description Default
x_vals np.ndarray

Independent variable values.

required
y_vals np.ndarray

Observed dependent variable values.

required
variances np.ndarray

Variance (σ²) of each observed point.

required
objective str

One of 'legacy_mask', 'hybrid', 'mighell'.

required

Returns:

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

Tuple of (x_out, y_eff, weights, stats) where stats is a dict with keys 'valid', 'mighell_substituted', 'masked'.

Source code in src/easyreflectometry/fitting.py
 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
def _prepare_fit_arrays(
    x_vals: np.ndarray,
    y_vals: np.ndarray,
    variances: np.ndarray,
    objective: str,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict]:
    """Prepare x, y_eff, and weights arrays for fitting based on the objective mode.

    For ``legacy_mask``, zero-variance points are removed from all arrays.
    For ``hybrid``, valid-variance points use standard WLS while zero-variance
    points use Mighell-transformed y and weights.
    For ``mighell``, all points use the Mighell transform.

    Note: ``variances`` here means σ² (the scipp convention), not σ.

    Parameters
    ----------
    x_vals : np.ndarray
        Independent variable values.
    y_vals : np.ndarray
        Observed dependent variable values.
    variances : np.ndarray
        Variance (σ²) of each observed point.
    objective : str
        One of 'legacy_mask', 'hybrid', 'mighell'.

    Returns
    -------
    tuple[np.ndarray, np.ndarray, np.ndarray, dict]
        Tuple of (x_out, y_eff, weights, stats) where stats is a dict
        with keys 'valid', 'mighell_substituted', 'masked'.
    """
    n = len(y_vals)
    zero_mask = variances <= 0.0
    n_zero = int(np.sum(zero_mask))
    n_valid = n - n_zero

    if objective == 'legacy_mask':
        valid = ~zero_mask
        x_out = x_vals[valid]
        y_eff = y_vals[valid]
        if n_valid > 0:
            weights = 1.0 / np.sqrt(variances[valid])
        else:
            weights = np.array([])
        stats = {
            'valid': n_valid,
            'mighell_substituted': 0,
            'masked': n_zero,
            'transformed_all_points': False,
        }
        return x_out, y_eff, weights, stats

    # hybrid or mighell
    y_eff = np.copy(y_vals)
    sigma = np.empty(n)

    if objective == 'mighell':
        apply_mighell = np.ones(n, dtype=bool)
    else:
        # hybrid: apply Mighell only to zero-variance points
        apply_mighell = zero_mask

    # Standard WLS for non-Mighell points
    standard = ~apply_mighell
    if np.any(standard):
        sigma[standard] = np.sqrt(variances[standard])

    # Mighell transform for selected points
    if np.any(apply_mighell):
        y_m = y_vals[apply_mighell]
        delta = np.minimum(y_m, 1.0)
        y_eff[apply_mighell] = y_m + delta
        sigma[apply_mighell] = np.sqrt(np.maximum(y_m + 1.0, _EPS))

    weights = 1.0 / sigma
    n_mighell = int(np.sum(apply_mighell))
    stats = {
        'valid': n - n_mighell,
        'mighell_substituted': n_mighell,
        'masked': 0,
        'transformed_all_points': bool(objective == 'mighell'),
    }
    return x_vals, y_eff, weights, stats

_compute_weighted_chi2(y_obs, y_calc, sigma)

Return weighted chi-square for finite, strictly positive uncertainties.

Source code in src/easyreflectometry/fitting.py
177
178
179
180
181
182
183
def _compute_weighted_chi2(y_obs: np.ndarray, y_calc: np.ndarray, sigma: np.ndarray) -> float:
    """Return weighted chi-square for finite, strictly positive uncertainties."""
    valid = np.isfinite(y_obs) & np.isfinite(y_calc) & np.isfinite(sigma) & (sigma > 0.0)
    if not np.any(valid):
        return 0.0
    residual = (y_obs[valid] - y_calc[valid]) / sigma[valid]
    return float(np.sum(residual**2))

_compute_reduced_chi2(chi2, n_points, n_params)

Return reduced chi-square or None when degrees of freedom are not positive.

Source code in src/easyreflectometry/fitting.py
186
187
188
189
190
191
def _compute_reduced_chi2(chi2: float, n_points: int, n_params: int) -> float | None:
    """Return reduced chi-square or None when degrees of freedom are not positive."""
    dof = int(n_points) - int(n_params)
    if dof <= 0:
        return None
    return float(chi2 / dof)

_fit_result_reduced_chi(result, n_points=None)

Return reduced chi-square from either supported FitResults attribute name.

Source code in src/easyreflectometry/fitting.py
194
195
196
197
198
199
200
201
202
203
204
def _fit_result_reduced_chi(result: FitResults, n_points: int | None = None) -> float:
    """Return reduced chi-square from either supported FitResults attribute name."""
    for attribute in ('reduced_chi', 'reduced_chi2'):
        value = getattr(result, attribute, None)
        if isinstance(value, (int, float, np.number)):
            return float(value)
    if n_points is not None:
        reduced_chi = _compute_reduced_chi2(float(result.chi2), n_points, result.n_pars)
        if reduced_chi is not None:
            return reduced_chi
    raise AttributeError('FitResults object has neither reduced_chi nor reduced_chi2')

_bind_fit_func(func, unique_name)

Bind a model's fit function to its unique_name for EasyScienceMultiFitter.

EasyScienceMultiFitter calls each fit function positionally as func(x, *extra_args); the model's interface.fit_func expects its unique_name as that extra positional argument, so it has to be closed over here rather than passed through the fitter's call signature.

Source code in src/easyreflectometry/fitting.py
207
208
209
210
211
212
213
214
215
216
217
218
219
def _bind_fit_func(func: Callable, unique_name: str) -> Callable:
    """Bind a model's fit function to its ``unique_name`` for ``EasyScienceMultiFitter``.

    ``EasyScienceMultiFitter`` calls each fit function positionally as
    ``func(x, *extra_args)``; the model's ``interface.fit_func`` expects its
    ``unique_name`` as that extra positional argument, so it has to be closed
    over here rather than passed through the fitter's call signature.
    """

    def wrapped(*args, **kwargs):
        return func(*args, unique_name, **kwargs)

    return wrapped

_emit_array_prep_warnings(stats, y_vals, label, *, action='fitting', extra='')

Warn about zero-variance handling applied by :func:_prepare_fit_arrays.

Parameters:

Name Type Description Default
stats dict

The stats dict returned by :func:_prepare_fit_arrays.

required
y_vals np.ndarray

The original (pre-transform) y values, used for the "all points" count.

required
label str

Identifies what was fitted/sampled, e.g. 'reflectivity 1' or 'channel pp'.

required
action str

Verb describing the operation, e.g. 'fitting' or 'sampling'. By default, 'fitting'.

'fitting'
extra str

Extra sentence(s) appended to the Mighell-related warnings (e.g. a likelihood-validity caveat for MCMC). By default, ''.

''
Source code in src/easyreflectometry/fitting.py
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
def _emit_array_prep_warnings(stats: dict, y_vals: np.ndarray, label: str, *, action: str = 'fitting', extra: str = '') -> None:
    """Warn about zero-variance handling applied by :func:`_prepare_fit_arrays`.

    Parameters
    ----------
    stats : dict
        The ``stats`` dict returned by :func:`_prepare_fit_arrays`.
    y_vals : np.ndarray
        The original (pre-transform) y values, used for the "all points" count.
    label : str
        Identifies what was fitted/sampled, e.g. ``'reflectivity 1'`` or
        ``'channel pp'``.
    action : str, optional
        Verb describing the operation, e.g. ``'fitting'`` or ``'sampling'``. By default, 'fitting'.
    extra : str, optional
        Extra sentence(s) appended to the Mighell-related warnings (e.g. a
        likelihood-validity caveat for MCMC). By default, ''.
    """
    if stats['masked'] > 0:
        warnings.warn(
            f'Masked {stats["masked"]} data point(s) in {label} due to zero variance during {action}.',
            UserWarning,
        )
    if stats.get('transformed_all_points'):
        warnings.warn(
            f'Applied Mighell transform to all {len(y_vals)} point(s) in {label} during {action}.{extra}',
            UserWarning,
        )
    elif stats['mighell_substituted'] > 0:
        warnings.warn(
            f'Applied Mighell substitution to {stats["mighell_substituted"]} '
            f'zero-variance point(s) in {label} during {action}.{extra}',
            UserWarning,
        )

_classical_metrics_for(original, model_curve, result, n_points=None)

Assemble the classical (positive-variance-only) and objective-space fit metrics.

Parameters:

Name Type Description Default
original dict

Dict with keys 'y' and 'variances' holding the un-transformed observed values and their variances (σ²).

required
model_curve np.ndarray

Model evaluated at the original x values.

required
result FitResults

The minimizer's result for this dataset/channel.

required
n_points int | None

Number of points actually fitted, used as the reduced_chi/reduced_chi2 fallback's point count. If None, derived from result.x. By default, None.

None

Returns:

Type Description
dict

Keys 'classical_chi2', 'classical_reduced_chi', 'objective_chi2', 'objective_reduced_chi', 'n_classical_points'.

Source code in src/easyreflectometry/fitting.py
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
def _classical_metrics_for(original: dict, model_curve: np.ndarray, result: FitResults, n_points: int | None = None) -> dict:
    """Assemble the classical (positive-variance-only) and objective-space fit metrics.

    Parameters
    ----------
    original : dict
        Dict with keys ``'y'`` and ``'variances'`` holding the un-transformed
        observed values and their variances (σ²).
    model_curve : np.ndarray
        Model evaluated at the original x values.
    result : FitResults
        The minimizer's result for this dataset/channel.
    n_points : int | None, optional
        Number of points actually fitted, used as the ``reduced_chi``/``reduced_chi2``
        fallback's point count. If ``None``, derived from ``result.x``. By default, None.

    Returns
    -------
    dict
        Keys ``'classical_chi2'``, ``'classical_reduced_chi'``,
        ``'objective_chi2'``, ``'objective_reduced_chi'``, ``'n_classical_points'``.
    """
    sigma_classical = np.sqrt(np.clip(original['variances'], 0.0, None))
    n_classical_points = int(np.sum(original['variances'] > 0.0))
    classical_chi2 = _compute_weighted_chi2(original['y'], model_curve, sigma_classical)
    if n_points is None:
        n_points = np.size(result.x)
    return {
        'classical_chi2': classical_chi2,
        'classical_reduced_chi': _compute_reduced_chi2(classical_chi2, n_classical_points, result.n_pars),
        'objective_chi2': float(result.chi2),
        'objective_reduced_chi': _fit_result_reduced_chi(result, n_points),
        'n_classical_points': n_classical_points,
    }

_flatten_list(this_list)

Flatten nested lists.

Parameters:

Name Type Description Default
this_list list

List to be flattened.

required

Returns:

Type Description
list

Flattened list.

Source code in src/easyreflectometry/fitting.py
935
936
937
938
939
940
941
942
943
944
945
946
947
948
def _flatten_list(this_list: list) -> list:
    """Flatten nested lists.

    Parameters
    ----------
    this_list : list
        List to be flattened.

    Returns
    -------
    list
        Flattened list.
    """
    return np.array([item for sublist in this_list for item in sublist])