Skip to content

fitting

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

_EPS = 1e-30 module-attribute

DataSet1D

Bases: SerializerComponent

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

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

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

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

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

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

        self.x_label = x_label
        self.y_label = y_label

        self._color = None

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

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

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

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

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

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

model property writable

Model function.

is_experiment property

Is experiment.

is_simulation property

Is simulation.

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

Init function.

Parameters:

Name Type Description Default
resolution_function Optional[ResolutionFunction]

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

None
Source code in src/easyreflectometry/data/data_store.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
def __init__(
    self,
    name: str = 'Series',
    x: Optional[Union[np.ndarray, list]] = None,
    y: Optional[Union[np.ndarray, list]] = None,
    ye: Optional[Union[np.ndarray, list]] = None,
    xe: Optional[Union[np.ndarray, list]] = None,
    model: Optional['Model'] = None,  # delay type checking until runtime (quotes)
    x_label: str = 'x',
    y_label: str = 'y',
    auto_background: bool = True,
    resolution_function: Optional[ResolutionFunction] = None,
):
    """Init function.

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

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

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

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

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

    self.x_label = x_label
    self.y_label = y_label

    self._color = None

data_points()

Data points.

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

__repr__()

Repr function.

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

PolarizedDataSet

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

Parameters:

Name Type Description Default
name str

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

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

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

None
model Model

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

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

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

    def __init__(
        self,
        name: str = 'PolarizedSeries',
        channels: Optional[dict[Union[PolarizationChannel, str], DataSet1D]] = None,
        model=None,
    ):
        if not channels:
            raise ValueError('A PolarizedDataSet requires at least one channel dataset.')
        normalized: dict[PolarizationChannel, DataSet1D] = {}
        for channel, dataset in channels.items():
            channel = PolarizationChannel(channel)
            if channel in normalized:
                raise ValueError(f"Duplicate channel '{channel.value}'.")
            normalized[channel] = self._validated_dataset(channel, dataset)
        self._channels = self._in_canonical_order(normalized)
        self.name = name
        self.model = model
        #: Whether :meth:`Project.prepare_fit` fits this experiment (all its channels) by default.
        self.include_in_fit = True

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

model property writable

The model shared by all channels.

channels property

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

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

available_channels property

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

is_experiment property

Is experiment.

is_simulation property

Is simulation.

set_channel(channel, dataset)

Add or replace one channel dataset.

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

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

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

required
dataset DataSet1D

The channel data.

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

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

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

remove_channel(channel)

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

Parameters:

Name Type Description Default
channel Union[PolarizationChannel, str]

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

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

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

FitSettings dataclass

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

Attributes:

Name Type Description
minimizer AvailableMinimizers

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

mode str

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

tolerance float | None

Generic convergence tolerance; None leaves it to the engine.

max_evaluations int | None

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

objective str

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

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

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

Source code in src/easyreflectometry/fit_settings.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
@dataclass
class FitSettings:
    """Minimizer choice and its settings, as saved in a project.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

active_options()

Options of the selected minimizer.

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

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

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

engine_kwargs()

The per-call keyword arguments for the selected minimizer.

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

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

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

set_option(name, value)

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

Raises:

Type Description
ValueError

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

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

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

validate()

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

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

configure(fitter)

Write the minimizer and generic settings onto a core fitter.

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

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

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

to_dict()

Serializable form, as stored in a project file.

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

from_dict(data) classmethod

Settings from a project file, never raising on content.

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

Returns:

Type Description
tuple[FitSettings, list[str]]

The settings and the warnings to show the user.

Source code in src/easyreflectometry/fit_settings.py
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
@classmethod
def from_dict(cls, data: dict) -> tuple['FitSettings', list[str]]:
    """Settings from a project file, never raising on content.

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

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

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

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

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

Model

Bases: BaseCore

Model is the class that represents the experiment.

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

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

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

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

        Parameters
        ----------
        unique_name : Optional[str], optional
            By default, None.
        color : str, optional
            By default, COLORS[0].
        sample : Union[Sample, None], optional
            The sample being modelled. By default, None.
        scale : Union[Parameter, Number, None], optional
            Scaling factor of profile. By default, None.
        background : Union[Parameter, Number, None], optional
            Linear background magnitude. By default, None.
        name : str, optional
            Name of the model. By default, 'Model'.
        resolution_function : Union[ResolutionFunction, None], optional
            Resolution function. By default, None.
        guide_field_angle : Union[Parameter, Number, None], optional
            Field angle about the beam axis in degrees (refl1d ``Aguide``). By default, 270 (in-plane).
        applied_field : Union[Parameter, Number, None], optional
            Applied field in tesla (refl1d ``H``). By default, 0.
        interface :
            Calculator interface. By default, None.
        """
        if unique_name is None:
            unique_name = global_object.generate_unique_name(self.__class__.__name__)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    @applied_field.setter
    def applied_field(self, value: float) -> None:
        self._applied_field.value = value

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    @resolution_function.setter
    def resolution_function(self, resolution_function: ResolutionFunction) -> None:
        """Set the resolution function for the model."""
        self._resolution_function = resolution_function
        if self.interface is not None:
            # Registered per model: one calculator serves every model of a
            # project, each possibly with a different resolution.
            self.interface().set_resolution_function(self._resolution_function, self.unique_name)

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

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

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

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

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

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

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

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

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

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

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

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

        from easyreflectometry.orso_utils import sample_to_orso_model

        if self.guide_field_angle.value != 270.0 or self.applied_field.value != 0.0:
            warnings.warn(
                'The guide field angle and applied field are not written: the ORSO model language has no field.',
                UserWarning,
                stacklevel=2,
            )
        return Header.asdict(sample_to_orso_model(self.sample))

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

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

        model = super().from_dict(this_dict)

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

guide_field_angle property writable

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

applied_field property writable

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

total_thickness property

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

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

has_magnetism property

Whether any layer in the sample carries magnetic properties.

is_default property writable

Whether this model was created as a default placeholder.

resolution_function property writable

Return the resolution function.

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

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
color str

By default, COLORS[0].

COLORS[0]
sample Union[Sample, None]

The sample being modelled. By default, None.

None
scale Union[Parameter, Number, None]

Scaling factor of profile. By default, None.

None
background Union[Parameter, Number, None]

Linear background magnitude. By default, None.

None
name str

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

'Model'
resolution_function Union[ResolutionFunction, None]

Resolution function. By default, None.

None
guide_field_angle Union[Parameter, Number, None]

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

None
applied_field Union[Parameter, Number, None]

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

None
interface

Calculator interface. By default, None.

None
Source code in src/easyreflectometry/model/model.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
def __init__(
    self,
    sample: Union[Sample, None] = None,
    scale: Union[Parameter, Number, None] = None,
    background: Union[Parameter, Number, None] = None,
    resolution_function: Union[ResolutionFunction, None] = None,
    name: str = 'Model',
    color: str = COLORS[0],
    unique_name: Optional[str] = None,
    interface=None,
    guide_field_angle: Union[Parameter, Number, None] = None,
    applied_field: Union[Parameter, Number, None] = None,
):
    """Constructor.

    Parameters
    ----------
    unique_name : Optional[str], optional
        By default, None.
    color : str, optional
        By default, COLORS[0].
    sample : Union[Sample, None], optional
        The sample being modelled. By default, None.
    scale : Union[Parameter, Number, None], optional
        Scaling factor of profile. By default, None.
    background : Union[Parameter, Number, None], optional
        Linear background magnitude. By default, None.
    name : str, optional
        Name of the model. By default, 'Model'.
    resolution_function : Union[ResolutionFunction, None], optional
        Resolution function. By default, None.
    guide_field_angle : Union[Parameter, Number, None], optional
        Field angle about the beam axis in degrees (refl1d ``Aguide``). By default, 270 (in-plane).
    applied_field : Union[Parameter, Number, None], optional
        Applied field in tesla (refl1d ``H``). By default, 0.
    interface :
        Calculator interface. By default, None.
    """
    if unique_name is None:
        unique_name = global_object.generate_unique_name(self.__class__.__name__)

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

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

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

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

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

refresh_derived()

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

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

Source code in src/easyreflectometry/model/model.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def refresh_derived(self) -> None:
    """Re-derive the model's computed parameters from the current layer structure.

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

add_assemblies(*assemblies)

Add assemblies to the model sample.

Parameters:

Name Type Description Default
*assemblies list[BaseAssembly]

Assemblies to add to model sample.

()
Source code in src/easyreflectometry/model/model.py
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def add_assemblies(self, *assemblies: list[BaseAssembly]) -> None:
    """Add assemblies to the model sample.

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

duplicate_assembly(index)

Duplicate a given item or layer in a sample.

Parameters:

Name Type Description Default
index int

Index of the item or layer to duplicate.

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

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

remove_assembly(index)

Remove an assembly from the model.

Parameters:

Name Type Description Default
index int

Index of the item to remove.

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

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

generate_bindings()

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

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

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

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

to_dict(skip=None)

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

Source code in src/easyreflectometry/model/model.py
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def to_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Serialize the model, encoding the resolution function and interface name."""
    if skip is None:
        skip = []
    # Sample/resolution_function/interface get bespoke encoding below.
    skip_for_super = list(skip) + ['sample', 'resolution_function', 'interface']
    this_dict = super().to_dict(skip=skip_for_super)
    this_dict['sample'] = self.sample.as_dict(skip=skip)
    this_dict['resolution_function'] = self.resolution_function.as_dict(skip=skip)
    if self.interface is None:
        this_dict['interface'] = None
    else:
        this_dict['interface'] = self.interface().name
    return this_dict

as_dict(skip=None)

Compatibility alias for :meth:to_dict.

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

as_orso()

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

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

Returns:

Type Description
dict

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

Source code in src/easyreflectometry/model/model.py
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
def as_orso(self) -> dict:
    """The sample as an ORSO simple-model (``sample.model``) dictionary.

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

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

    from easyreflectometry.orso_utils import sample_to_orso_model

    if self.guide_field_angle.value != 270.0 or self.applied_field.value != 0.0:
        warnings.warn(
            'The guide field angle and applied field are not written: the ORSO model language has no field.',
            UserWarning,
            stacklevel=2,
        )
    return Header.asdict(sample_to_orso_model(self.sample))

from_dict(passed_dict) classmethod

Create a Model from a dictionary.

Source code in src/easyreflectometry/model/model.py
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
@classmethod
def from_dict(cls, passed_dict: dict) -> Model:
    """Create a Model from a dictionary."""
    # Circular import if hoisted to module-top.
    from easyreflectometry.calculators import CalculatorFactory

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

    model = super().from_dict(this_dict)

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

Pointwise

Bases: ResolutionFunction

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

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

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

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

Source code in src/easyreflectometry/model/resolution_functions.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
class Pointwise(ResolutionFunction):
    """Pointwise resolution defined by a per-point resolution provided with the data.

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

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

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

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

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

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

        ``sQz`` is the variance of ``Qz``, so the sigma at each data point is
        ``sqrt(sQz)``; values are linearly interpolated onto the requested
        ``q``.  This already satisfies the sigma smearing() contract, so no
        FWHM conversion is applied.  When ``q`` is ``None``, or ``q`` is
        exactly the stored data points, the per-point sigma values are
        returned as they are: a dataset merged from several measurements can
        hold the same ``Qz`` twice with different widths (overlapping angles),
        which no function of ``q`` can represent but a per-point lookup can.
        """
        Qz = np.asarray(self.q_data_points[0], dtype=float)
        sQz = np.asarray(self.q_data_points[2], dtype=float)
        widths = np.sqrt(sQz)
        if q is None:
            return widths.copy()
        q_eval = np.asarray(q, dtype=float)
        if q_eval.shape == Qz.shape and np.array_equal(q_eval, Qz):
            return widths.copy()
        # np.interp needs increasing abscissae; merged datasets are sorted, but
        # a Pointwise built directly from arrays need not be.
        order = np.argsort(Qz, kind='stable')
        return np.asarray(np.interp(q_eval, Qz[order], widths[order]))

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

__init__(q_data_points)

Init function.

Parameters:

Name Type Description Default
q_data_points List[np.ndarray]

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

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

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

smearing(q=None)

Return the resolution sigma interpolated onto q.

sQz is the variance of Qz, so the sigma at each data point is sqrt(sQz); values are linearly interpolated onto the requested q. This already satisfies the sigma smearing() contract, so no FWHM conversion is applied. When q is None, or q is exactly the stored data points, the per-point sigma values are returned as they are: a dataset merged from several measurements can hold the same Qz twice with different widths (overlapping angles), which no function of q can represent but a per-point lookup can.

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

    ``sQz`` is the variance of ``Qz``, so the sigma at each data point is
    ``sqrt(sQz)``; values are linearly interpolated onto the requested
    ``q``.  This already satisfies the sigma smearing() contract, so no
    FWHM conversion is applied.  When ``q`` is ``None``, or ``q`` is
    exactly the stored data points, the per-point sigma values are
    returned as they are: a dataset merged from several measurements can
    hold the same ``Qz`` twice with different widths (overlapping angles),
    which no function of ``q`` can represent but a per-point lookup can.
    """
    Qz = np.asarray(self.q_data_points[0], dtype=float)
    sQz = np.asarray(self.q_data_points[2], dtype=float)
    widths = np.sqrt(sQz)
    if q is None:
        return widths.copy()
    q_eval = np.asarray(q, dtype=float)
    if q_eval.shape == Qz.shape and np.array_equal(q_eval, Qz):
        return widths.copy()
    # np.interp needs increasing abscissae; merged datasets are sorted, but
    # a Pointwise built directly from arrays need not be.
    order = np.argsort(Qz, kind='stable')
    return np.asarray(np.interp(q_eval, Qz[order], widths[order]))

as_dict(skip=None)

As dict.

Source code in src/easyreflectometry/model/resolution_functions.py
167
168
169
170
171
172
173
174
175
176
def as_dict(
    self, skip: Optional[List[str]] = None
) -> dict[str, str]:  # skip is kept for consistency of the as_dict signature
    """As dict."""
    return {
        'smearing': 'Pointwise',
        'q_data_points': list(self.q_data_points[0]),
        'R_data_points': list(self.q_data_points[1]),
        'sQz_data_points': list(self.q_data_points[2]),
    }

ResolutionFunction

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

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

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

smearing(q) abstractmethod

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

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

from_dict(data) classmethod

Smearing function.

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

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

FitScopeError

Bases: ValueError

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

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

FitPreconditionError

Bases: ValueError

A fit that cannot start: the minimizer needs finite bounds some free parameters lack.

Attributes:

Name Type Description
parameters list

The offending parameters, each once.

Source code in src/easyreflectometry/fitting.py
329
330
331
332
333
334
335
336
337
338
339
340
class FitPreconditionError(ValueError):
    """A fit that cannot start: the minimizer needs finite bounds some free parameters lack.

    Attributes
    ----------
    parameters : list
        The offending parameters, each once.
    """

    def __init__(self, message: str, parameters: list):
        super().__init__(message)
        self.parameters = parameters

_FitInput dataclass

One dataset (or spin channel) to fit, before zero-variance handling.

Source code in src/easyreflectometry/fitting.py
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
@dataclass
class _FitInput:
    """One dataset (or spin channel) to fit, before zero-variance handling."""

    x: np.ndarray
    y: np.ndarray
    variances: np.ndarray
    model: Model
    label: str
    channel: Any = None
    #: The dataset's own resolution; None smears with the model's.
    resolution_function: ResolutionFunction | None = None
    #: A stored fit function to use when neither a channel nor a resolution
    #: of its own applies (the plain ``DataGroup`` and single-dataset fits).
    fit_func: Callable | None = None

PreparedFit dataclass

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

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

Attributes:

Name Type Description
settings FitSettings | None

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

objective str

The resolved zero-variance objective.

channels list

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

original list[dict]

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

fitted list[dict]

x, y, weights handed to the engine.

stats list[dict]

Zero-variance handling counts per dataset.

curve_funcs list[Callable]

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

fit_funcs list[Callable]

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

core_fitter EasyScienceMultiFitter

The fitter that is executed.

fit_kwargs dict

Method-specific option keyword arguments for the engine.

added_roots list[Parameter]

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

experiments list

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

skipped list[str]

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

Source code in src/easyreflectometry/fitting.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
@dataclass
class PreparedFit:
    """Everything one fit run needs, fixed when it is prepared.

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

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

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

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

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

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

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

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

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

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

call_kwargs(**kwargs)

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

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

execute(**kwargs)

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

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

finalize(results)

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

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

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

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

FitRun dataclass

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

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

Statistics (only for a completed run):

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

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

Source code in src/easyreflectometry/fitting.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
@dataclass(frozen=True)
class FitRun:
    """The record of one completed (or failed, or cancelled) fit, kept by the project.

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

    Statistics (only for a completed run):

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

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

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

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

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

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

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

Source code in src/easyreflectometry/fitting.py
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
@classmethod
def from_results(
    cls,
    prepared: PreparedFit,
    results: list[FitResults] | None,
    status: str,
    minimizer: str,
    metrics: list[dict] | None = None,
    inputs: Sequence[tuple] | None = None,
) -> 'FitRun':
    """The record of a run; `metrics` is ``prepared.finalize(results)`` when already
    computed, `inputs` the ``(key, name, channel)`` of each dataset (unnamed otherwise)."""
    inputs = tuple(inputs) if inputs else tuple((None, f'dataset {index}', None) for index in range(len(prepared.fitted)))
    completed_at = datetime.datetime.now().isoformat(timespec='seconds')
    if status != 'completed' or not results:
        return cls(status, completed_at, minimizer, prepared.objective, inputs)
    n_free = int(results[0].n_pars)
    per_dataset = [
        {
            'objective_chi2': metric['objective_chi2'],
            'objective_n_points': metric['n_points'],
            'objective_chi2_per_point': _ratio(metric['objective_chi2'], metric['n_points']),
            'classical_chi2': metric['classical_chi2'],
            'classical_n_points': metric['n_classical_points'],
            'classical_chi2_per_point': _ratio(metric['classical_chi2'], metric['n_classical_points']),
        }
        for metric in (metrics if metrics is not None else prepared.finalize(results))
    ]
    pooled = {}
    for kind, points in (('objective', 'objective_n_points'), ('classical', 'classical_n_points')):
        chi2 = float(sum(entry[f'{kind}_chi2'] for entry in per_dataset))
        n_points = int(sum(entry[points] for entry in per_dataset))
        pooled[f'{kind}_chi2'] = chi2
        pooled[f'{kind}_n_points'] = n_points
        pooled[f'{kind}_dof'] = n_points - n_free
        pooled[f'{kind}_reduced_chi2'] = _ratio(chi2, n_points - n_free)
    for entry in per_dataset:
        entry['share_of_objective'] = _ratio(entry['objective_chi2'], pooled['objective_chi2'])
    return cls(status, completed_at, minimizer, prepared.objective, inputs, n_free, pooled, tuple(per_dataset))

remapped(keys)

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

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

MultiFitter

Source code in src/easyreflectometry/fitting.py
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
class MultiFitter:
    def __init__(self, *args: Model, objective: str = 'hybrid'):
        r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting`
        which will populate the :py:class:`sc.DataGroup` appropriately
        after the fitting is performed.

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

        self._fit_func = [_bind_fit_func(m.interface.fit_func, m.unique_name) for m in args]
        self._models = args
        self.easy_science_multi_fitter = self._build_easy_science_fitter(args, self._fit_func)
        self._fit_results: list[FitResults] | None = None
        self._classical_fit_metrics: list[dict] | None = None
        self._objective = _validate_objective(objective)
        self._sampler: Sampler | None = None
        # Set by `for_experiments`: the datasets the fit functions correspond
        # to, and the spin channel each one is evaluated on (None = unpolarized).
        self.fit_datasets: list[DataSet1D] = []
        self.fit_channels: list[Any] = []
        # Names of the experiments `Project.prepare_sample(skip_invalid=True)`
        # left out (no model in the project); empty otherwise.
        self.skipped: list[str] = []
        # Optional zero-argument callable returning the ``constraints_factory``
        # for the next fit (or ``None``). ``Project.fitter`` binds it to
        # ``Project.build_constraints_factory`` so inequality constraints
        # registered on the project are applied without passing them
        # explicitly; an explicit ``constraints_factory=`` argument wins.
        self.constraints_factory_provider: Callable[[], Callable | None] | None = None
        # The settings runs are prepared from (a snapshot is taken per run).
        # None: the stored core fitter's own minimizer, tolerance and budget.
        self.settings: FitSettings | None = None

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

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

        The stored fitter refers to its owner weakly (the owner holds it). A
        prepared run's fitter (``keep_owner``) holds it strongly: the caller may
        keep only the run, and the constraints need the owner at execution.
        """
        fitter = _ConstrainedEasyScienceMultiFitter(models, fit_funcs)
        fitter._constraints_owner = (lambda: self) if keep_owner else weakref.ref(self)
        return fitter

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

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

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

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

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

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

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

        sampler.extend = extend

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

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

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

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

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

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

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

        fitter = cls(*models, objective=objective)
        fitter.fit_datasets = datasets
        fitter.fit_channels = channels
        # Bound with every point's resolution; `prepare` rebinds for a subset.
        fitter._fit_func = [fitter._bind(item, item.resolution_function) for item in fitter._experiment_inputs()]
        fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fitter._fit_func)
        fitter.constraints_factory_provider = constraints_factory_provider
        return fitter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        Parameters
        ----------
        data : sc.DataGroup
            DataGroup to be fitted to and populated. For a
            :meth:`for_experiments` fitter it holds one curve per entry of
            :attr:`fit_datasets` (one per spin channel of a polarized
            experiment), in the same order.
        id : int, optional
            Unused parameter kept for backward compatibility. By default, 0.
        objective : str | None, optional
            Per-call override for the zero-variance objective.
            If ``None``, uses the instance default set at construction. By default, None.
        constraints_factory : Callable | None, optional
            Inequality constraints to enforce (BUMPS engines only); see
            :mod:`easyreflectometry.inequality_constraints`. Defaults to what
            :attr:`constraints_factory_provider` returns. By default, None.

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

        Raises
        ------
        ValueError
            On a :meth:`for_experiments` fitter, when the number of curves
            differs from the number of entries of :attr:`fit_datasets`.
        """
        refl_nums, inputs = self._datagroup_inputs(data)
        prepared = self.prepare(inputs, objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            result = prepared.execute()
        self.record_fit_results(result, prepared.finalize(result))
        new_data = data.copy()
        for i, _ in enumerate(result):
            id = refl_nums[i]
            model = inputs[i].model
            model_curve = prepared.curve_funcs[i](data['coords'][f'Qz_{id}'].values)
            new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
            sld_profile = model.interface.sld_profile(model.unique_name)
            new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
            if 'attrs' in new_data:
                new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(model.as_dict())}
            new_data['coords'][f'z_{id}'] = sc.array(
                dims=[f'z_{id}'],
                values=sld_profile[0],
                unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
            )
        new_data['objective_chi2'] = self.objective_chi2
        new_data['objective_reduced_chi'] = self.objective_reduced_chi
        new_data['classical_chi2'] = self.classical_chi2
        new_data['classical_reduced_chi'] = self.classical_reduced_chi
        new_data['reduced_chi'] = self.objective_reduced_chi
        new_data['success'] = all(item.success for item in result)
        return new_data

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

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

        Returns
        -------
        FitResults
            Fit results from the minimizer.

        Note
        ----
        When ``data`` carries its own ``resolution_function`` the fit is
        smeared with it rather than with the model's.
        """
        item = _FitInput(
            x=np.asarray(data.x),
            y=np.asarray(data.y),
            variances=np.asarray(data.ye),
            model=self._models[0],
            label='single-dataset fit',
            resolution_function=getattr(data, 'resolution_function', None),
            fit_func=self._fit_func[0],
        )
        prepared = self.prepare([item], objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            result = prepared.execute()[0]
        self.record_fit_results([result], prepared.finalize([result]))
        return result

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

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

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

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

        Note
        ----
        Unlike :meth:`for_experiments`, this method does not populate
        :attr:`fit_datasets` / :attr:`fit_channels` — those are set only by the
        caller-driven, `for_experiments`-built flow.
        """
        if len(self._models) != 1:
            raise ValueError('Polarized fitting requires a MultiFitter constructed with exactly one model.')
        model = self._models[0]
        if data.model is not model:
            raise ValueError('PolarizedDataSet.model must be the model this fitter was constructed with.')
        inputs = []
        for channel in data.available_channels:
            dataset = data[channel]
            if dataset.model is not model:
                raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")
            inputs.append(
                _FitInput(
                    x=np.asarray(dataset.x),
                    y=np.asarray(dataset.y),
                    variances=np.asarray(dataset.ye),
                    model=model,
                    label=f'channel {channel.value}',
                    channel=channel,
                    resolution_function=getattr(dataset, 'resolution_function', None),
                )
            )
        prepared = self.prepare(inputs, objective=objective)
        with self._constraints(constraints_factory, fitter=prepared.core_fitter):
            results = prepared.execute()
        # All channels are fitted against one parameter vector (the shared model),
        # so `result.n_pars` is identical across `results`; `reduced_chi` and
        # `classical_reduced_chi` rely on that invariant.
        self.record_fit_results(results, prepared.finalize(results))
        return {channel.value: result for channel, result in zip(prepared.channels, results)}

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

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

        :param data: DataGroup with reflectivity data. May be left out for a
            fitter built with :meth:`for_experiments`: the sampler then runs
            on :attr:`fit_datasets`, each dataset (each spin channel of a
            polarized experiment) smeared with its own resolution, exactly as
            :meth:`prepare` fits them. With a ``DataGroup`` and such a fitter,
            curve *k* must be dataset *k*; its arrays are sampled with that
            dataset's model, channel and resolution.
        :param samples: Number of retained DREAM samples requested from BUMPS.
        :param burn: Burn-in steps.
        :param thin: Thinning interval.
        :param population: BUMPS DREAM population count for advanced users.
        :param objective: Zero-variance handling strategy.
        :param initializer: DREAM population initializer. One of ``'eps'``,
            ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
            uses ``'eps'``).
        :param progress_callback: Optional callback for progress updates during
            sampling.  Forwarded to the core MultiFitter.
        :param abort_test: Optional callable returning ``True`` to abort sampling.
        :param constraints_factory: Inequality constraints to enforce (see
            :mod:`easyreflectometry.inequality_constraints`); the posterior is
            penalised in the infeasible region. Defaults to what
            :attr:`constraints_factory_provider` returns.
        :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
            and ``'logp'``.
        :raises RuntimeError: If the current minimizer is not a BUMPS instance.
        :raises ValueError: Without ``data`` on a fitter not built with
            :meth:`for_experiments`, or with a ``DataGroup`` whose curve count
            differs from the fitter's datasets.

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

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

            fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
            extended = fitter.sampler.extend(additional_samples=8000, thin=10)
        """
        # Checked before preparing, so a wrong engine is reported before any data warnings.
        minimizer = self.settings.minimizer if self.settings is not None else self.easy_science_multi_fitter.minimizer
        if getattr(minimizer, 'package', None) != 'bumps':
            raise RuntimeError(
                'Bayesian sampling requires a BUMPS minimizer. Select one first, e.g. '
                '``project.minimizer = AvailableMinimizers.Bumps_simplex`` or '
                '``fitter.switch_minimizer(AvailableMinimizers.Bumps_simplex)``.'
            )
        obj = self._resolve_objective(objective, self.settings)
        if data is None:
            if not self.fit_datasets:
                raise ValueError('mcmc_sample needs a DataGroup unless the fitter was built with MultiFitter.for_experiments.')
            inputs = self._experiment_inputs()
        else:
            _, inputs = self._datagroup_inputs(data)
        for item in inputs:
            if obj != 'mighell' and np.all(np.asarray(item.variances) <= 0.0):
                raise ValueError(
                    f'Cannot run Bayesian sampling on {item.label}: all points have zero variance. '
                    'The likelihood is undefined without measurement uncertainties. Supply uncertainties, '
                    "or explicitly opt in to the Mighell transform with objective='mighell' "
                    '(a chi-square bias correction, not a true likelihood).'
                )
        prepared = self.prepare(
            inputs,
            objective=obj,
            action='sampling',
            extra_warning=(
                ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
                'posterior widths may be unreliable.'
            ),
        )
        core_fitter = prepared.core_fitter

        # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``,
        # which handles the multi-dataset reshaping internally.
        sampler_kwargs = {}
        if initializer is not None:
            sampler_kwargs['init'] = initializer

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

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

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

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

        These properties describe this fitter's latest fit; a run prepared through
        :meth:`Project.prepare_fit` is recorded in :attr:`Project.last_fit`, which
        outlives a change of the current model.
        """
        if self._fit_results is None:
            return None
        return sum(r.chi2 for r in self._fit_results)

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

        if total_dof <= 0:
            return None

        return total_chi2 / total_dof

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

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

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

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

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

        An application that executes a :class:`PreparedFit` itself — in a
        worker thread, for instance — leaves the ``MultiFitter`` that owns the
        goodness-of-fit properties none the wiser. Handing the results back
        here makes :attr:`chi2` / :attr:`reduced_chi` describe that fit.

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

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

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

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

sampler property

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

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

chi2 property

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

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

reduced_chi property

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

classical_chi2 property

Classical chi-squared using only points with positive variances.

classical_reduced_chi property

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

objective_chi2 property

Objective-space chi-squared returned by the minimizer.

objective_reduced_chi property

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

__init__(*args, objective='hybrid')

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

Parameters:

Name Type Description Default
*args Model

Reflectometry model(s).

()
objective str

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

'hybrid'
Source code in src/easyreflectometry/fitting.py
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def __init__(self, *args: Model, objective: str = 'hybrid'):
    r"""A convenience class for the :py:class:`easyscience.Fitting.Fitting`
    which will populate the :py:class:`sc.DataGroup` appropriately
    after the fitting is performed.

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

    self._fit_func = [_bind_fit_func(m.interface.fit_func, m.unique_name) for m in args]
    self._models = args
    self.easy_science_multi_fitter = self._build_easy_science_fitter(args, self._fit_func)
    self._fit_results: list[FitResults] | None = None
    self._classical_fit_metrics: list[dict] | None = None
    self._objective = _validate_objective(objective)
    self._sampler: Sampler | None = None
    # Set by `for_experiments`: the datasets the fit functions correspond
    # to, and the spin channel each one is evaluated on (None = unpolarized).
    self.fit_datasets: list[DataSet1D] = []
    self.fit_channels: list[Any] = []
    # Names of the experiments `Project.prepare_sample(skip_invalid=True)`
    # left out (no model in the project); empty otherwise.
    self.skipped: list[str] = []
    # Optional zero-argument callable returning the ``constraints_factory``
    # for the next fit (or ``None``). ``Project.fitter`` binds it to
    # ``Project.build_constraints_factory`` so inequality constraints
    # registered on the project are applied without passing them
    # explicitly; an explicit ``constraints_factory=`` argument wins.
    self.constraints_factory_provider: Callable[[], Callable | None] | None = None
    # The settings runs are prepared from (a snapshot is taken per run).
    # None: the stored core fitter's own minimizer, tolerance and budget.
    self.settings: FitSettings | None = None

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

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

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

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

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

Parameters:

Name Type Description Default
experiments list[DataSet1D | PolarizedDataSet]

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

required
objective str

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

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

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

None

Returns:

Type Description
MultiFitter

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

Source code in src/easyreflectometry/fitting.py
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
@classmethod
def for_experiments(
    cls,
    experiments: list[DataSet1D | PolarizedDataSet],
    objective: str = 'hybrid',
    constraints_factory_provider: Callable[[], Callable | None] | None = None,
) -> 'MultiFitter':
    """Build a fitter for a mixed list of unpolarized and polarized experiments.

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

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

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

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

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

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

    fitter = cls(*models, objective=objective)
    fitter.fit_datasets = datasets
    fitter.fit_channels = channels
    # Bound with every point's resolution; `prepare` rebinds for a subset.
    fitter._fit_func = [fitter._bind(item, item.resolution_function) for item in fitter._experiment_inputs()]
    fitter.easy_science_multi_fitter = fitter._build_easy_science_fitter(models, fitter._fit_func)
    fitter.constraints_factory_provider = constraints_factory_provider
    return fitter

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

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

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

Parameters:

Name Type Description Default
inputs list[_FitInput] | None

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

None
objective str | None

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

None
action str

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

'fitting'
extra_warning str

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

''

Returns:

Type Description
PreparedFit

The run, ready to execute.

Raises:

Type Description
ValueError

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

FitPreconditionError

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

Note

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

Source code in src/easyreflectometry/fitting.py
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
def prepare(
    self,
    inputs: list[_FitInput] | None = None,
    *,
    objective: str | None = None,
    action: str = 'fitting',
    extra_warning: str = '',
) -> PreparedFit:
    """Prepare one fit run: zero-variance handling, bindings, configured core fitter.

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

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

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

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

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

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

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

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

Perform the fitting and populate the DataGroups with the result.

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

Parameters:

Name Type Description Default
data sc.DataGroup

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

required
id int

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

0
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
sc.DataGroup

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

Raises:

Type Description
ValueError

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

Source code in src/easyreflectometry/fitting.py
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
def fit(
    self,
    data: sc.DataGroup,
    id: int = 0,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> sc.DataGroup:
    """Perform the fitting and populate the DataGroups with the result.

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

    Parameters
    ----------
    data : sc.DataGroup
        DataGroup to be fitted to and populated. For a
        :meth:`for_experiments` fitter it holds one curve per entry of
        :attr:`fit_datasets` (one per spin channel of a polarized
        experiment), in the same order.
    id : int, optional
        Unused parameter kept for backward compatibility. By default, 0.
    objective : str | None, optional
        Per-call override for the zero-variance objective.
        If ``None``, uses the instance default set at construction. By default, None.
    constraints_factory : Callable | None, optional
        Inequality constraints to enforce (BUMPS engines only); see
        :mod:`easyreflectometry.inequality_constraints`. Defaults to what
        :attr:`constraints_factory_provider` returns. By default, None.

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

    Raises
    ------
    ValueError
        On a :meth:`for_experiments` fitter, when the number of curves
        differs from the number of entries of :attr:`fit_datasets`.
    """
    refl_nums, inputs = self._datagroup_inputs(data)
    prepared = self.prepare(inputs, objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        result = prepared.execute()
    self.record_fit_results(result, prepared.finalize(result))
    new_data = data.copy()
    for i, _ in enumerate(result):
        id = refl_nums[i]
        model = inputs[i].model
        model_curve = prepared.curve_funcs[i](data['coords'][f'Qz_{id}'].values)
        new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
        sld_profile = model.interface.sld_profile(model.unique_name)
        new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
        if 'attrs' in new_data:
            new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(model.as_dict())}
        new_data['coords'][f'z_{id}'] = sc.array(
            dims=[f'z_{id}'],
            values=sld_profile[0],
            unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
        )
    new_data['objective_chi2'] = self.objective_chi2
    new_data['objective_reduced_chi'] = self.objective_reduced_chi
    new_data['classical_chi2'] = self.classical_chi2
    new_data['classical_reduced_chi'] = self.classical_reduced_chi
    new_data['reduced_chi'] = self.objective_reduced_chi
    new_data['success'] = all(item.success for item in result)
    return new_data

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

Perform fitting on a single 1D dataset.

Parameters:

Name Type Description Default
data DataSet1D

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

required
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
FitResults

Fit results from the minimizer.

Note

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

Source code in src/easyreflectometry/fitting.py
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
def fit_single_data_set_1d(
    self,
    data: DataSet1D,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> FitResults:
    """Perform fitting on a single 1D dataset.

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

    Returns
    -------
    FitResults
        Fit results from the minimizer.

    Note
    ----
    When ``data`` carries its own ``resolution_function`` the fit is
    smeared with it rather than with the model's.
    """
    item = _FitInput(
        x=np.asarray(data.x),
        y=np.asarray(data.y),
        variances=np.asarray(data.ye),
        model=self._models[0],
        label='single-dataset fit',
        resolution_function=getattr(data, 'resolution_function', None),
        fit_func=self._fit_func[0],
    )
    prepared = self.prepare([item], objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        result = prepared.execute()[0]
    self.record_fit_results([result], prepared.finalize([result]))
    return result

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

Fit all measured spin channels of a polarized experiment simultaneously.

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

Parameters:

Name Type Description Default
data PolarizedDataSet

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

required
objective str | None

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

None
constraints_factory Callable | None

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

None

Returns:

Type Description
dict[str, FitResults]

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

Note

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

Source code in src/easyreflectometry/fitting.py
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
def fit_polarized(
    self,
    data: PolarizedDataSet,
    objective: str | None = None,
    constraints_factory: Callable | None = None,
) -> dict[str, FitResults]:
    """Fit all measured spin channels of a polarized experiment simultaneously.

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

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

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

    Note
    ----
    Unlike :meth:`for_experiments`, this method does not populate
    :attr:`fit_datasets` / :attr:`fit_channels` — those are set only by the
    caller-driven, `for_experiments`-built flow.
    """
    if len(self._models) != 1:
        raise ValueError('Polarized fitting requires a MultiFitter constructed with exactly one model.')
    model = self._models[0]
    if data.model is not model:
        raise ValueError('PolarizedDataSet.model must be the model this fitter was constructed with.')
    inputs = []
    for channel in data.available_channels:
        dataset = data[channel]
        if dataset.model is not model:
            raise ValueError(f"The '{channel.value}' channel dataset is bound to a different model than the fitter's.")
        inputs.append(
            _FitInput(
                x=np.asarray(dataset.x),
                y=np.asarray(dataset.y),
                variances=np.asarray(dataset.ye),
                model=model,
                label=f'channel {channel.value}',
                channel=channel,
                resolution_function=getattr(dataset, 'resolution_function', None),
            )
        )
    prepared = self.prepare(inputs, objective=objective)
    with self._constraints(constraints_factory, fitter=prepared.core_fitter):
        results = prepared.execute()
    # All channels are fitted against one parameter vector (the shared model),
    # so `result.n_pars` is identical across `results`; `reduced_chi` and
    # `classical_reduced_chi` rely on that invariant.
    self.record_fit_results(results, prepared.finalize(results))
    return {channel.value: result for channel, result in zip(prepared.channels, results)}

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

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

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

:param data: DataGroup with reflectivity data. May be left out for a fitter built with :meth:for_experiments: the sampler then runs on :attr:fit_datasets, each dataset (each spin channel of a polarized experiment) smeared with its own resolution, exactly as :meth:prepare fits them. With a DataGroup and such a fitter, curve k must be dataset k; its arrays are sampled with that dataset's model, channel and resolution. :param samples: Number of retained DREAM samples requested from BUMPS. :param burn: Burn-in steps. :param thin: Thinning interval. :param population: BUMPS DREAM population count for advanced users. :param objective: Zero-variance handling strategy. :param initializer: DREAM population initializer. One of 'eps', 'cov', 'lhs', or 'random'. By default, None (BUMPS uses 'eps'). :param progress_callback: Optional callback for progress updates during sampling. Forwarded to the core MultiFitter. :param abort_test: Optional callable returning True to abort sampling. :param constraints_factory: Inequality constraints to enforce (see :mod:easyreflectometry.inequality_constraints); the posterior is penalised in the infeasible region. Defaults to what :attr:constraints_factory_provider returns. :return: Dictionary with keys 'draws', 'param_names', 'state', and 'logp'. :raises RuntimeError: If the current minimizer is not a BUMPS instance. :raises ValueError: Without data on a fitter not built with :meth:for_experiments, or with a DataGroup whose curve count differs from the fitter's datasets.

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

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

fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
extended = fitter.sampler.extend(additional_samples=8000, thin=10)
Source code in src/easyreflectometry/fitting.py
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
def mcmc_sample(
    self,
    data: sc.DataGroup | None = None,
    samples: int = 10000,
    burn: int = 2000,
    thin: int = 10,
    population: int | None = None,
    objective: str | None = None,
    initializer: str | None = None,
    progress_callback: Callable[..., Any] | None = None,
    abort_test: Callable[[], bool] | None = None,
    constraints_factory: Callable | None = None,
) -> dict:
    """Run Bayesian MCMC sampling on reflectometry data using the DREAM sampler.

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

    :param data: DataGroup with reflectivity data. May be left out for a
        fitter built with :meth:`for_experiments`: the sampler then runs
        on :attr:`fit_datasets`, each dataset (each spin channel of a
        polarized experiment) smeared with its own resolution, exactly as
        :meth:`prepare` fits them. With a ``DataGroup`` and such a fitter,
        curve *k* must be dataset *k*; its arrays are sampled with that
        dataset's model, channel and resolution.
    :param samples: Number of retained DREAM samples requested from BUMPS.
    :param burn: Burn-in steps.
    :param thin: Thinning interval.
    :param population: BUMPS DREAM population count for advanced users.
    :param objective: Zero-variance handling strategy.
    :param initializer: DREAM population initializer. One of ``'eps'``,
        ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
        uses ``'eps'``).
    :param progress_callback: Optional callback for progress updates during
        sampling.  Forwarded to the core MultiFitter.
    :param abort_test: Optional callable returning ``True`` to abort sampling.
    :param constraints_factory: Inequality constraints to enforce (see
        :mod:`easyreflectometry.inequality_constraints`); the posterior is
        penalised in the infeasible region. Defaults to what
        :attr:`constraints_factory_provider` returns.
    :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
        and ``'logp'``.
    :raises RuntimeError: If the current minimizer is not a BUMPS instance.
    :raises ValueError: Without ``data`` on a fitter not built with
        :meth:`for_experiments`, or with a ``DataGroup`` whose curve count
        differs from the fitter's datasets.

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

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

        fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
        extended = fitter.sampler.extend(additional_samples=8000, thin=10)
    """
    # Checked before preparing, so a wrong engine is reported before any data warnings.
    minimizer = self.settings.minimizer if self.settings is not None else self.easy_science_multi_fitter.minimizer
    if getattr(minimizer, 'package', None) != 'bumps':
        raise RuntimeError(
            'Bayesian sampling requires a BUMPS minimizer. Select one first, e.g. '
            '``project.minimizer = AvailableMinimizers.Bumps_simplex`` or '
            '``fitter.switch_minimizer(AvailableMinimizers.Bumps_simplex)``.'
        )
    obj = self._resolve_objective(objective, self.settings)
    if data is None:
        if not self.fit_datasets:
            raise ValueError('mcmc_sample needs a DataGroup unless the fitter was built with MultiFitter.for_experiments.')
        inputs = self._experiment_inputs()
    else:
        _, inputs = self._datagroup_inputs(data)
    for item in inputs:
        if obj != 'mighell' and np.all(np.asarray(item.variances) <= 0.0):
            raise ValueError(
                f'Cannot run Bayesian sampling on {item.label}: all points have zero variance. '
                'The likelihood is undefined without measurement uncertainties. Supply uncertainties, '
                "or explicitly opt in to the Mighell transform with objective='mighell' "
                '(a chi-square bias correction, not a true likelihood).'
            )
    prepared = self.prepare(
        inputs,
        objective=obj,
        action='sampling',
        extra_warning=(
            ' The Mighell transform is a chi-square bias correction, not a true likelihood; '
            'posterior widths may be unreliable.'
        ),
    )
    core_fitter = prepared.core_fitter

    # Delegate the actual BUMPS/DREAM sampling to the core ``Sampler``,
    # which handles the multi-dataset reshaping internally.
    sampler_kwargs = {}
    if initializer is not None:
        sampler_kwargs['init'] = initializer

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

record_fit_results(results, metrics=None)

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

An application that executes a :class:PreparedFit itself — in a worker thread, for instance — leaves the MultiFitter that owns the goodness-of-fit properties none the wiser. Handing the results back here makes :attr:chi2 / :attr:reduced_chi describe that fit.

Parameters:

Name Type Description Default
results list[FitResults] | None

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

required
metrics list[dict] | None

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

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

    An application that executes a :class:`PreparedFit` itself — in a
    worker thread, for instance — leaves the ``MultiFitter`` that owns the
    goodness-of-fit properties none the wiser. Handing the results back
    here makes :attr:`chi2` / :attr:`reduced_chi` describe that fit.

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

switch_minimizer(minimizer)

Switch the minimizer for the fitting.

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

Parameters:

Name Type Description Default
minimizer AvailableMinimizers

Minimizer to be switched to.

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

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

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

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

The zero-variance objective objective names ('auto' is 'hybrid').

Raises:

Type Description
ValueError

If it names none.

Source code in src/easyreflectometry/fit_settings.py
164
165
166
167
168
169
170
171
172
173
174
175
176
def normalize_objective(objective: Any) -> str:
    """The zero-variance objective ``objective`` names (``'auto'`` is ``'hybrid'``).

    Raises
    ------
    ValueError
        If it names none.
    """
    if isinstance(objective, str):
        objective = _OBJECTIVE_ALIASES.get(objective, objective)
    if objective not in OBJECTIVES:
        raise ValueError(f'Unknown objective {objective!r}. Valid options: {OBJECTIVES + tuple(_OBJECTIVE_ALIASES)}.')
    return objective

requires_finite_bounds(minimizer)

Whether minimizer needs a finite min and max on every free parameter.

Source code in src/easyreflectometry/fit_settings.py
179
180
181
def requires_finite_bounds(minimizer: AvailableMinimizers) -> bool:
    """Whether ``minimizer`` needs a finite ``min`` and ``max`` on every free parameter."""
    return minimizer.package == 'lm' and minimizer.method == 'differential_evolution'

_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
 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
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
163
164
165
166
167
168
169
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
172
173
174
175
176
177
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
180
181
182
183
184
185
186
187
188
189
190
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
193
194
195
196
197
198
199
200
201
202
203
204
205
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

_resolution_for_fitted_points(resolution_function, x_vals, y_vals, variances, objective)

The dataset resolution restricted to the points :func:_prepare_fit_arrays keeps.

legacy_mask drops zero-variance points, so the fit evaluates the model on a subset of x. A merged dataset can hold the same q twice with different widths; looked up by q alone, the subset would lose which width belongs to which point (:meth:Pointwise.smearing only keeps per-point widths when evaluated on exactly its stored q). The widths are therefore evaluated on the full grid and selected together with the points.

Source code in src/easyreflectometry/fitting.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def _resolution_for_fitted_points(
    resolution_function: ResolutionFunction | None,
    x_vals: np.ndarray,
    y_vals: np.ndarray,
    variances: np.ndarray,
    objective: str,
) -> ResolutionFunction | None:
    """The dataset resolution restricted to the points :func:`_prepare_fit_arrays` keeps.

    ``legacy_mask`` drops zero-variance points, so the fit evaluates the model
    on a subset of ``x``. A merged dataset can hold the same q twice with
    different widths; looked up by q alone, the subset would lose which width
    belongs to which point (:meth:`Pointwise.smearing` only keeps per-point
    widths when evaluated on exactly its stored q). The widths are therefore
    evaluated on the full grid and selected together with the points.
    """
    if resolution_function is None or objective != 'legacy_mask':
        return resolution_function
    keep = ~(variances <= 0.0)
    if np.all(keep):
        return resolution_function
    sigma = np.asarray(resolution_function.smearing(x_vals), dtype=float)
    return Pointwise([x_vals[keep], y_vals[keep], np.square(sigma[keep])])

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

The classical (positive-variance-only) and objective-space chi-square of one dataset.

Reduced values are not given per dataset: their degrees of freedom belong to the pooled fit (see :class:FitRun and the MultiFitter properties).

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. If None, derived from result.x.

None

Returns:

Type Description
dict

Keys 'classical_chi2', 'n_classical_points', 'objective_chi2' and 'n_points' (the points fitted).

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

    Reduced values are not given per dataset: their degrees of freedom belong
    to the pooled fit (see :class:`FitRun` and the ``MultiFitter`` properties).

    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. If ``None``, derived from ``result.x``.

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

_free_roots(models)

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

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

_curve_ids(data)

The <n> of every Qz_<n> coordinate of a DataGroup, in numeric order.

Curve k is paired with model k (or dataset k) by this order, so it follows the key numbers rather than the order the keys were inserted in, and Qz_10 comes after Qz_2. Suffixes that are not numbers keep their insertion order, after the numbered ones.

Source code in src/easyreflectometry/fitting.py
360
361
362
363
364
365
366
367
368
369
def _curve_ids(data: sc.DataGroup) -> list[str]:
    """The ``<n>`` of every ``Qz_<n>`` coordinate of a ``DataGroup``, in numeric order.

    Curve *k* is paired with model *k* (or dataset *k*) by this order, so it
    follows the key numbers rather than the order the keys were inserted in,
    and ``Qz_10`` comes after ``Qz_2``. Suffixes that are not numbers keep
    their insertion order, after the numbered ones.
    """
    ids = [key[3:] for key in data['coords'].keys() if key.startswith('Qz_')]
    return sorted(ids, key=lambda suffix: (0, int(suffix)) if suffix.isdigit() else (1, 0))

_ratio(numerator, denominator)

Source code in src/easyreflectometry/fitting.py
464
465
def _ratio(numerator: float, denominator: float) -> float | None:
    return numerator / denominator if denominator > 0 else None

_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
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
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])