Skip to content

fitting

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

_EPS = 1e-30 module-attribute

DataSet1D

Bases: SerializerComponent

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

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

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

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

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

        self.x_label = x_label
        self.y_label = y_label

        self._color = None

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

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

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

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

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

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

model property writable

Model function.

is_experiment property

Is experiment.

is_simulation property

Is simulation.

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

Init function.

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

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

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

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

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

    self.x_label = x_label
    self.y_label = y_label

    self._color = None

data_points()

Data points.

Source code in src/easyreflectometry/data/data_store.py
155
156
157
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
159
160
161
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

Bases: BaseCore

Model is the class that represents the experiment.

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

Source code in src/easyreflectometry/model/model.py
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
class Model(BaseCore):
    """Model is the class that represents the experiment.

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

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

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

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

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

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

        # 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def as_orso(self) -> dict:
        """Convert the model to a dictionary suitable for ORSO."""
        return self.as_dict()

    @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

is_default property writable

Whether this model was created as a default placeholder.

resolution_function property writable

Return the resolution function.

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

Constructor.

Parameters:

Name Type Description Default
unique_name Optional[str]

By default, None.

None
color str

By default, COLORS[0].

COLORS[0]
sample Union[Sample, None]

The sample being modelled. By default, None.

None
scale Union[Parameter, Number, None]

Scaling factor of profile. By default, None.

None
background Union[Parameter, Number, None]

Linear background magnitude. By default, None.

None
name str

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

'Model'
resolution_function Union[ResolutionFunction, None]

Resolution function. By default, None.

None
interface

Calculator interface. By default, None.

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

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

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

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

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

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

interface(new_interface)

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

Source code in src/easyreflectometry/model/model.py
225
226
227
228
229
230
231
232
233
@BaseCore.interface.setter
def interface(self, new_interface) -> None:
    """Set the interface; runs `generate_bindings` and then refreshes the
    calculator's resolution function.
    """
    # Call BaseCore.interface.setter for the binding propagation.
    BaseCore.interface.fset(self, new_interface)
    if new_interface is not None:
        new_interface().set_resolution_function(self._resolution_function)

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
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def add_assemblies(self, *assemblies: list[BaseAssembly]) -> None:
    """Add assemblies to the model sample.

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

duplicate_assembly(index)

Duplicate a given item or layer in a sample.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item or layer to duplicate.

required
Source code in src/easyreflectometry/model/model.py
172
173
174
175
176
177
178
179
180
181
182
183
def duplicate_assembly(self, index: int) -> None:
    """Duplicate a given item or layer in a sample.

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

remove_assembly(index)

Remove an assembly from the model.

Parameters:

Name Type Description Default
index int
required
idx

Index of the item to remove.

required
Source code in src/easyreflectometry/model/model.py
185
186
187
188
189
190
191
192
193
194
195
196
197
def remove_assembly(self, index: int) -> None:
    """Remove an assembly from the model.

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

to_dict(skip=None)

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

Source code in src/easyreflectometry/model/model.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
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
273
274
275
def as_dict(self, skip: Optional[list[str]] = None) -> dict:
    """Compatibility alias for :meth:`to_dict`."""
    return self.to_dict(skip=skip)

as_orso()

Convert the model to a dictionary suitable for ORSO.

Source code in src/easyreflectometry/model/model.py
277
278
279
def as_orso(self) -> dict:
    """Convert the model to a dictionary suitable for ORSO."""
    return self.as_dict()

from_dict(passed_dict) classmethod

Create a Model from a dictionary.

Source code in src/easyreflectometry/model/model.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
@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

MultiFitter

Source code in src/easyreflectometry/fitting.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
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'.
        """

        # This lets the unique_name be passed with the fit_func.
        def func_wrapper(func, unique_name):
            """Func wrapper."""

            def wrapped(*args, **kwargs):
                """Wrapped function."""
                return func(*args, unique_name, **kwargs)

            return wrapped

        self._fit_func = [func_wrapper(m.interface.fit_func, m.unique_name) for m in args]
        self._models = args
        self.easy_science_multi_fitter = EasyScienceMultiFitter(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

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

        Parameters
        ----------
        data : sc.DataGroup
            DataGroup to be fitted to and populated.
        id : int, optional
            Unused parameter kept for backward compatibility. By default, 0.
        objective : str | None, optional
            Per-call override for the zero-variance objective.
            If ``None``, uses the instance default set at construction. By default, None.

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

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

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

            x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

            if stats['masked'] > 0:
                warnings.warn(
                    f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during fitting.',
                    UserWarning,
                )
            if stats.get('transformed_all_points'):
                warnings.warn(
                    f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during fitting.',
                    UserWarning,
                )
            elif stats['mighell_substituted'] > 0:
                warnings.warn(
                    f'Applied Mighell substitution to {stats["mighell_substituted"]} '
                    f'zero-variance point(s) in reflectivity {i} during fitting.',
                    UserWarning,
                )

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

        result = self.easy_science_multi_fitter.fit(x, y, weights=dy)
        self._fit_results = result
        self._classical_fit_metrics = []
        new_data = data.copy()
        for i, _ in enumerate(result):
            id = refl_nums[i]
            model_curve = self._fit_func[i](data['coords'][f'Qz_{id}'].values)
            new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
            sld_profile = self.easy_science_multi_fitter._fit_objects[i].interface.sld_profile(self._models[i].unique_name)
            new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
            if 'attrs' in new_data:
                new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(self._models[i].as_dict())}
            new_data['coords'][f'z_{id}'] = sc.array(
                dims=[f'z_{id}'],
                values=sld_profile[0],
                unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
            )
            original = original_arrays[i]
            sigma_classical = np.sqrt(np.clip(original['variances'], 0.0, None))
            n_classical_points = int(np.sum(original['variances'] > 0.0))
            classical_chi2 = _compute_weighted_chi2(original['y'], model_curve, sigma_classical)
            classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result[i].n_pars)
            objective_chi2 = float(result[i].chi2)
            objective_reduced_chi = _fit_result_reduced_chi(result[i], np.size(result[i].x))

            self._classical_fit_metrics.append({
                'classical_chi2': classical_chi2,
                'classical_reduced_chi': classical_reduced_chi,
                'objective_chi2': objective_chi2,
                'objective_reduced_chi': objective_reduced_chi,
                'n_classical_points': n_classical_points,
            })

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

    def fit_single_data_set_1d(self, data: DataSet1D, objective: str | None = None) -> 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.

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

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

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

        if stats['masked'] > 0:
            warnings.warn(
                f'Masked {stats["masked"]} data point(s) in single-dataset fit due to zero variance during fitting.',
                UserWarning,
            )
        if stats.get('transformed_all_points'):
            warnings.warn(
                f'Applied Mighell transform to all {len(y_vals)} point(s) in single-dataset fit during fitting.',
                UserWarning,
            )
        elif stats['mighell_substituted'] > 0:
            warnings.warn(
                f'Applied Mighell substitution to {stats["mighell_substituted"]} '
                'zero-variance point(s) in single-dataset fit during fitting.',
                UserWarning,
            )

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

        result = self.easy_science_multi_fitter.fit(x=[x_out], y=[y_eff], weights=[weights])[0]
        self._fit_results = [result]
        sigma_classical = np.sqrt(np.clip(variances, 0.0, None))
        model_curve = self._fit_func[0](x_vals)
        n_classical_points = int(np.sum(variances > 0.0))
        classical_chi2 = _compute_weighted_chi2(y_vals, model_curve, sigma_classical)
        classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result.n_pars)
        self._classical_fit_metrics = [
            {
                'classical_chi2': classical_chi2,
                'classical_reduced_chi': classical_reduced_chi,
                'objective_chi2': float(result.chi2),
                'objective_reduced_chi': _fit_result_reduced_chi(result, len(x_out)),
                'n_classical_points': n_classical_points,
            }
        ]
        return result

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

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

        :param data: DataGroup with reflectivity data.
        :param samples: Number of retained DREAM samples requested from BUMPS.
        :param burn: Burn-in steps.
        :param thin: Thinning interval.
        :param population: BUMPS DREAM population count for advanced users.
        :param objective: Zero-variance handling strategy.
        :param initializer: DREAM population initializer. One of ``'eps'``,
            ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
            uses ``'eps'``).
        :param progress_callback: Optional callback for progress updates during
            sampling.  Forwarded to the core MultiFitter.
        :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
            and ``'logp'``.
        :raises RuntimeError: If the current minimizer is not a BUMPS instance.

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

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

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

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

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

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

            x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

            if stats['masked'] > 0:
                warnings.warn(
                    f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during sampling.',
                    UserWarning,
                )
            if stats.get('transformed_all_points'):
                warnings.warn(
                    f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during sampling. '
                    'The Mighell transform is a chi-square bias correction, not a true likelihood; '
                    'posterior widths may be unreliable.',
                    UserWarning,
                )
            elif stats['mighell_substituted'] > 0:
                warnings.warn(
                    f'Applied Mighell substitution to {stats["mighell_substituted"]} '
                    f'zero-variance point(s) in reflectivity {i} during sampling. '
                    'The Mighell transform is a chi-square bias correction, not a true likelihood; '
                    'posterior widths may be unreliable.',
                    UserWarning,
                )
            x.append(x_out)
            y.append(y_eff)
            dy.append(weights)

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

        sampler = Sampler(
            self.easy_science_multi_fitter,
            x=x,
            y=y,
            weights=dy,
        )
        # Retained so the chain can be continued afterwards via ``self.sampler.extend()``.
        self._sampler = sampler
        results = sampler.sample(
            samples=samples,
            burn=burn,
            thin=thin,
            population=population,
            sampler_kwargs=sampler_kwargs or None,
            progress_callback=progress_callback,
            abort_test=abort_test,
        )
        return {
            'draws': results.draws,
            'param_names': results.param_names,
            'state': results.state,
            'logp': results.logp,
        }

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

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

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

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

        if total_dof <= 0:
            return None

        return total_chi2 / total_dof

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

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

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

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

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

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

sampler property

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

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

chi2 property

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

reduced_chi property

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

classical_chi2 property

Classical chi-squared using only points with positive variances.

classical_reduced_chi property

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

objective_chi2 property

Objective-space chi-squared returned by the minimizer.

objective_reduced_chi property

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

__init__(*args, objective='hybrid')

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

Parameters:

Name Type Description Default
*args Model

Reflectometry model(s).

()
objective str

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

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

    # This lets the unique_name be passed with the fit_func.
    def func_wrapper(func, unique_name):
        """Func wrapper."""

        def wrapped(*args, **kwargs):
            """Wrapped function."""
            return func(*args, unique_name, **kwargs)

        return wrapped

    self._fit_func = [func_wrapper(m.interface.fit_func, m.unique_name) for m in args]
    self._models = args
    self.easy_science_multi_fitter = EasyScienceMultiFitter(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

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

Perform the fitting and populate the DataGroups with the result.

Parameters:

Name Type Description Default
data sc.DataGroup

DataGroup to be fitted to and populated.

required
id int

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

0
objective str | None

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

None

Returns:

Type Description
sc.DataGroup

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

Source code in src/easyreflectometry/fitting.py
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
def fit(self, data: sc.DataGroup, id: int = 0, objective: str | None = None) -> sc.DataGroup:
    """Perform the fitting and populate the DataGroups with the result.

    Parameters
    ----------
    data : sc.DataGroup
        DataGroup to be fitted to and populated.
    id : int, optional
        Unused parameter kept for backward compatibility. By default, 0.
    objective : str | None, optional
        Per-call override for the zero-variance objective.
        If ``None``, uses the instance default set at construction. By default, None.

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

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

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

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

        if stats['masked'] > 0:
            warnings.warn(
                f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during fitting.',
                UserWarning,
            )
        if stats.get('transformed_all_points'):
            warnings.warn(
                f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during fitting.',
                UserWarning,
            )
        elif stats['mighell_substituted'] > 0:
            warnings.warn(
                f'Applied Mighell substitution to {stats["mighell_substituted"]} '
                f'zero-variance point(s) in reflectivity {i} during fitting.',
                UserWarning,
            )

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

    result = self.easy_science_multi_fitter.fit(x, y, weights=dy)
    self._fit_results = result
    self._classical_fit_metrics = []
    new_data = data.copy()
    for i, _ in enumerate(result):
        id = refl_nums[i]
        model_curve = self._fit_func[i](data['coords'][f'Qz_{id}'].values)
        new_data[f'R_{id}_model'] = sc.array(dims=[f'Qz_{id}'], values=model_curve)
        sld_profile = self.easy_science_multi_fitter._fit_objects[i].interface.sld_profile(self._models[i].unique_name)
        new_data[f'SLD_{id}'] = sc.array(dims=[f'z_{id}'], values=sld_profile[1] * 1e-6, unit=sc.Unit('1/angstrom') ** 2)
        if 'attrs' in new_data:
            new_data['attrs'][f'R_{id}_model'] = {'model': sc.scalar(self._models[i].as_dict())}
        new_data['coords'][f'z_{id}'] = sc.array(
            dims=[f'z_{id}'],
            values=sld_profile[0],
            unit=(1 / new_data['coords'][f'Qz_{id}'].unit).unit,
        )
        original = original_arrays[i]
        sigma_classical = np.sqrt(np.clip(original['variances'], 0.0, None))
        n_classical_points = int(np.sum(original['variances'] > 0.0))
        classical_chi2 = _compute_weighted_chi2(original['y'], model_curve, sigma_classical)
        classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result[i].n_pars)
        objective_chi2 = float(result[i].chi2)
        objective_reduced_chi = _fit_result_reduced_chi(result[i], np.size(result[i].x))

        self._classical_fit_metrics.append({
            'classical_chi2': classical_chi2,
            'classical_reduced_chi': classical_reduced_chi,
            'objective_chi2': objective_chi2,
            'objective_reduced_chi': objective_reduced_chi,
            'n_classical_points': n_classical_points,
        })

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

fit_single_data_set_1d(data, objective=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

Returns:

Type Description
FitResults

Fit results from the minimizer.

Source code in src/easyreflectometry/fitting.py
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
def fit_single_data_set_1d(self, data: DataSet1D, objective: str | 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.

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

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

    x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

    if stats['masked'] > 0:
        warnings.warn(
            f'Masked {stats["masked"]} data point(s) in single-dataset fit due to zero variance during fitting.',
            UserWarning,
        )
    if stats.get('transformed_all_points'):
        warnings.warn(
            f'Applied Mighell transform to all {len(y_vals)} point(s) in single-dataset fit during fitting.',
            UserWarning,
        )
    elif stats['mighell_substituted'] > 0:
        warnings.warn(
            f'Applied Mighell substitution to {stats["mighell_substituted"]} '
            'zero-variance point(s) in single-dataset fit during fitting.',
            UserWarning,
        )

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

    result = self.easy_science_multi_fitter.fit(x=[x_out], y=[y_eff], weights=[weights])[0]
    self._fit_results = [result]
    sigma_classical = np.sqrt(np.clip(variances, 0.0, None))
    model_curve = self._fit_func[0](x_vals)
    n_classical_points = int(np.sum(variances > 0.0))
    classical_chi2 = _compute_weighted_chi2(y_vals, model_curve, sigma_classical)
    classical_reduced_chi = _compute_reduced_chi2(classical_chi2, n_classical_points, result.n_pars)
    self._classical_fit_metrics = [
        {
            'classical_chi2': classical_chi2,
            'classical_reduced_chi': classical_reduced_chi,
            'objective_chi2': float(result.chi2),
            'objective_reduced_chi': _fit_result_reduced_chi(result, len(x_out)),
            'n_classical_points': n_classical_points,
        }
    ]
    return result

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

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

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

:param data: DataGroup with reflectivity data. :param samples: Number of retained DREAM samples requested from BUMPS. :param burn: Burn-in steps. :param thin: Thinning interval. :param population: BUMPS DREAM population count for advanced users. :param objective: Zero-variance handling strategy. :param initializer: DREAM population initializer. One of 'eps', 'cov', 'lhs', or 'random'. By default, None (BUMPS uses 'eps'). :param progress_callback: Optional callback for progress updates during sampling. Forwarded to the core MultiFitter. :return: Dictionary with keys 'draws', 'param_names', 'state', and 'logp'. :raises RuntimeError: If the current minimizer is not a BUMPS instance.

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

fitter.mcmc_sample(data, samples=2000, burn=500, thin=10)
extended = fitter.sampler.extend(additional_samples=8000, thin=10)
Source code in src/easyreflectometry/fitting.py
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def mcmc_sample(
    self,
    data: sc.DataGroup,
    samples: int = 10000,
    burn: int = 2000,
    thin: int = 10,
    population: int | None = None,
    objective: str | None = None,
    initializer: str | None = None,
    progress_callback: Callable[..., Any] | None = None,
    abort_test: Callable[[], bool] | None = None,
) -> dict:
    """Run Bayesian MCMC sampling on reflectometry data using the DREAM sampler.

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

    :param data: DataGroup with reflectivity data.
    :param samples: Number of retained DREAM samples requested from BUMPS.
    :param burn: Burn-in steps.
    :param thin: Thinning interval.
    :param population: BUMPS DREAM population count for advanced users.
    :param objective: Zero-variance handling strategy.
    :param initializer: DREAM population initializer. One of ``'eps'``,
        ``'cov'``, ``'lhs'``, or ``'random'``. By default, None (BUMPS
        uses ``'eps'``).
    :param progress_callback: Optional callback for progress updates during
        sampling.  Forwarded to the core MultiFitter.
    :return: Dictionary with keys ``'draws'``, ``'param_names'``, ``'state'``,
        and ``'logp'``.
    :raises RuntimeError: If the current minimizer is not a BUMPS instance.

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

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

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

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

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

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

        x_out, y_eff, weights, stats = _prepare_fit_arrays(x_vals, y_vals, variances, obj)

        if stats['masked'] > 0:
            warnings.warn(
                f'Masked {stats["masked"]} data point(s) in reflectivity {i} due to zero variance during sampling.',
                UserWarning,
            )
        if stats.get('transformed_all_points'):
            warnings.warn(
                f'Applied Mighell transform to all {len(y_vals)} point(s) in reflectivity {i} during sampling. '
                'The Mighell transform is a chi-square bias correction, not a true likelihood; '
                'posterior widths may be unreliable.',
                UserWarning,
            )
        elif stats['mighell_substituted'] > 0:
            warnings.warn(
                f'Applied Mighell substitution to {stats["mighell_substituted"]} '
                f'zero-variance point(s) in reflectivity {i} during sampling. '
                'The Mighell transform is a chi-square bias correction, not a true likelihood; '
                'posterior widths may be unreliable.',
                UserWarning,
            )
        x.append(x_out)
        y.append(y_eff)
        dy.append(weights)

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

    sampler = Sampler(
        self.easy_science_multi_fitter,
        x=x,
        y=y,
        weights=dy,
    )
    # 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,
    }

switch_minimizer(minimizer)

Switch the minimizer for the fitting.

Parameters:

Name Type Description Default
minimizer AvailableMinimizers

Minimizer to be switched to.

required
Source code in src/easyreflectometry/fitting.py
545
546
547
548
549
550
551
552
553
def switch_minimizer(self, minimizer: AvailableMinimizers) -> None:
    """Switch the minimizer for the fitting.

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

_validate_objective(objective)

Validate and resolve the objective string.

Parameters:

Name Type Description Default
objective str

The objective mode string.

required

Raises:

Type Description
ValueError :

If the objective is not one of the valid options.

Returns:

Type Description
str

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

Source code in src/easyreflectometry/fitting.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def _validate_objective(objective: str) -> str:
    """Validate and resolve the objective string.

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

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

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

_prepare_fit_arrays(x_vals, y_vals, variances, objective)

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

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

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

Parameters:

Name Type Description Default
x_vals np.ndarray

Independent variable values.

required
y_vals np.ndarray

Observed dependent variable values.

required
variances np.ndarray

Variance (σ²) of each observed point.

required
objective str

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

required

Returns:

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

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

Source code in src/easyreflectometry/fitting.py
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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
134
135
136
137
138
139
140
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
143
144
145
146
147
148
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
151
152
153
154
155
156
157
158
159
160
161
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')

_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
556
557
558
559
560
561
562
563
564
565
566
567
568
569
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])