Skip to content

analysis

Analysis

Bases: AnalysisBase

For analysing two-dimensional data, i.e. intensity as function of energy and Q.

Supports independent fits of each Q value and simultaneous fits of all Q.

Source code in src/easydynamics/analysis/analysis.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
class Analysis(AnalysisBase):
    """
    For analysing two-dimensional data, i.e. intensity as function of energy and Q.

    Supports independent fits of each Q value and simultaneous fits of all Q.
    """

    def __init__(
        self,
        display_name: str | None = 'MyAnalysis',
        unique_name: str | None = None,
        experiment: Experiment | None = None,
        sample_model: SampleModel | None = None,
        instrument_model: InstrumentModel | None = None,
        convolution_settings: ConvolutionSettings | None = None,
        detailed_balance_settings: DetailedBalanceSettings | None = None,
        extra_parameters: Parameter | list[Parameter] | None = None,
    ) -> None:
        """
        Initialize an Analysis object.

        Parameters
        ----------
        display_name : str | None, default='MyAnalysis'
            Display name of the analysis.
        unique_name : str | None, default=None
            Unique name of the analysis. If None, a unique name is automatically generated.
        experiment : Experiment | None, default=None
            The Experiment associated with this Analysis. If None, a default Experiment is created.
        sample_model : SampleModel | None, default=None
            The SampleModel associated with this Analysis. If None, a default SampleModel is
            created.
        instrument_model : InstrumentModel | None, default=None
            The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
            is created.
        convolution_settings : ConvolutionSettings | None, default=None
             The settings for the convolution. If None, default settings will be used.
        detailed_balance_settings : DetailedBalanceSettings | None, default=None
            The settings for detailed balance. If None, default settings will be used.
        extra_parameters : Parameter | list[Parameter] | None, default=None
            Extra parameters to be included in the analysis for advanced users. If None, no extra
            parameters are added.
        """

        # Avoid triggering updates before the object is fully
        # initialized
        self._call_updaters = False
        super().__init__(
            display_name=display_name,
            unique_name=unique_name,
            experiment=experiment,
            sample_model=sample_model,
            instrument_model=instrument_model,
            convolution_settings=convolution_settings,
            detailed_balance_settings=detailed_balance_settings,
            extra_parameters=extra_parameters,
        )

        self._analysis_list = []
        if self.Q is not None:
            self._create_analysis_list()

        # Now we can allow updates to trigger recalculations
        self._call_updaters = True

    #############
    # Properties
    #############

    @property
    def analysis_list(self) -> list[Analysis1d]:
        """
        Get the Analysis1d objects associated with this Analysis.

        Returns
        -------
        list[Analysis1d]
            A list of Analysis1d objects, one for each Q index.
        """
        return self._analysis_list

    @analysis_list.setter
    def analysis_list(self, _value: list[Analysis1d]) -> None:
        """
        Analysis_list is read-only.

        To change the analysis list, modify the experiment, sample model, or instrument model.

        Parameters
        ----------
        _value : list[Analysis1d]
            The new list of Analysis1d objects. This argument is ignored, as analysis_list is
            read-only.

        Raises
        ------
        AttributeError
            Always raised, since analysis_list is read-only.
        """

        raise AttributeError(
            'analysis_list is read-only. '
            'To change the analysis list, modify the experiment, sample model, '
            'or instrument model.'
        )

    #############
    # Other methods
    #############
    def calculate(
        self,
        Q_index: int | None = None,
        energy: sc.Variable | None = None,
    ) -> list[np.ndarray] | np.ndarray:
        """
        Calculate model data for a specific Q index.

        If Q_index is None, calculate for all Q indices and return a list of arrays.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to calculate for. If None, calculate for all Q values.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.

        Returns
        -------
        list[np.ndarray] | np.ndarray
            If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is
            an integer, returns a single numpy array for that Q index.
        """
        if energy is None:
            energy = self.energy

        if Q_index is None:
            return [analysis.calculate(energy=energy) for analysis in self.analysis_list]

        Q_index = self._verify_Q_index(Q_index)
        return self.analysis_list[Q_index].calculate(energy=energy)

    def fit(
        self,
        fit_method: str = 'independent',
        Q_index: int | None = None,
    ) -> FitResults | list[FitResults]:
        """
        Fit the model to the experimental data.

        Parameters
        ----------
        fit_method : str, default='independent'
            Method to use for fitting. Options are "independent" (fit each Q index independently,
            one after the other) or "simultaneous" (fit all Q indices simultaneously).
        Q_index : int | None, default=None
            If fit_method is "independent", specify which Q index to fit. If None, fit all Q
            indices independently. Ignored if fit_method is "simultaneous".

        Raises
        ------
        ValueError
            If fit_method is not "independent" or "simultaneous" or if there are no Q values
            available for fitting.

        Returns
        -------
        FitResults | list[FitResults]
            A list of FitResults if fitting independently, or a single FitResults object if fitting
            simultaneously.
        """

        if self.Q is None:
            raise ValueError(
                'No Q values available for fitting. Please check the experiment data.'
            )

        Q_index = self._verify_Q_index(Q_index)

        if fit_method == 'independent':
            if Q_index is not None:
                return self._fit_single_Q(Q_index)
            return self._fit_all_Q_independently()
        if fit_method == 'simultaneous':
            return self._fit_all_Q_simultaneously()
        raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.")

    def plot_data_and_model(
        self,
        Q_index: int | None = None,
        plot_components: bool = True,
        add_background: bool = True,
        energy: sc.Variable | None = None,
        **kwargs: dict[str, Any],
    ) -> InteractiveFigure:
        """
        Plot the experimental data and the model prediction.

        Optionally also plot the individual components of the model.

        Uses Plopp for plotting: https://scipp.github.io/plopp/

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to plot. If None, plot all Q values.
        plot_components : bool, default=True
            Whether to plot the individual components.
        add_background : bool, default=True
            Whether to add background components to the sample model components when plotting.
            Default is True.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.
        **kwargs : dict[str, Any]
            Additional keyword arguments passed to plopp for customizing the plot.

        Raises
        ------
        ValueError
            If Q_index is out of bounds, or if there is no data to plot, or if there are no Q
            values available for plotting.
        RuntimeError
            If not in a Jupyter notebook environment.
        TypeError
            If plot_components or add_background is not True or False.

        Returns
        -------
        InteractiveFigure
            A Plopp InteractiveFigure containing the plot of the data and model.
        """

        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            return self.analysis_list[Q_index].plot_data_and_model(
                plot_components=plot_components,
                add_background=add_background,
                energy=energy,
                **kwargs,
            )

        if self.experiment.binned_data is None:
            raise ValueError('No data to plot. Please load data first.')

        if not _in_notebook():
            raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.')

        if self.Q is None:
            raise ValueError(
                'No Q values available for plotting. Please check the experiment data.'
            )

        if not isinstance(plot_components, bool):
            raise TypeError('plot_components must be True or False.')

        if not isinstance(add_background, bool):
            raise TypeError('add_background must be True or False.')

        if energy is None:
            energy = self.energy

        import plopp as pp

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {'Data': 'none', 'Model': '-'},
            'marker': {'Data': 'o', 'Model': None},
            'color': {'Data': 'black', 'Model': 'red'},
            'markerfacecolor': {'Data': 'none', 'Model': 'none'},
            'keep': 'energy',
        }
        data_and_model = {
            'Data': self.experiment.binned_data,
            'Model': self._create_model_array(energy=energy),
        }

        if plot_components:
            components = self._create_components_dataset(
                add_background=add_background, energy=energy
            )
            for key in components:
                data_and_model[key] = components[key]
                plot_kwargs_defaults['linestyle'][key] = '--'
                plot_kwargs_defaults['marker'][key] = None

        # Overwrite defaults with any user-provided kwargs
        plot_kwargs_defaults.update(kwargs)

        fig = pp.slicer(
            data_and_model,
            **plot_kwargs_defaults,
        )
        for widget in fig.bottom_bar[0].controls.values():
            widget.slider_toggler.value = '-o-'

        return fig

    def parameters_to_dataset(self) -> sc.Dataset:
        """
        Creates a scipp dataset with copies of the Parameters in the model.

        Ensures unit consistency across Q.

        Raises
        ------
        UnitError
            If there are inconsistent units for the same parameter across different Q values.

        Returns
        -------
        sc.Dataset
            A dataset where each entry is a parameter, with dimensions "Q" and values corresponding
            to the parameter values.
        """

        ds = sc.Dataset(coords={'Q': self.Q})

        # Collect all parameter names
        all_names = {
            param.name
            for analysis in self.analysis_list
            for param in analysis.get_all_parameters()
        }

        # Storage
        values = {name: [] for name in all_names}
        variances = {name: [] for name in all_names}
        units = {}

        for analysis in self.analysis_list:
            pars = {p.name: p for p in analysis.get_all_parameters()}

            for name in all_names:
                if name in pars:
                    p = pars[name]

                    # Unit consistency check
                    if name not in units:
                        units[name] = p.unit
                    elif units[name] != p.unit:
                        try:
                            p.convert_unit(units[name])
                        except Exception as e:
                            raise UnitError(
                                f"Inconsistent units for parameter '{name}': "
                                f'{units[name]} vs {p.unit}'
                            ) from e

                    values[name].append(p.value)
                    variances[name].append(p.variance)
                else:
                    values[name].append(np.nan)
                    variances[name].append(np.nan)

        # Build dataset variables
        for name in all_names:
            ds[name] = sc.Variable(
                dims=['Q'],
                values=np.asarray(values[name], dtype=float),
                variances=np.asarray(variances[name], dtype=float),
                unit=units.get(name),
            )

        return ds

    def plot_parameters(
        self,
        names: str | list[str] | None = None,
        **kwargs: dict[str, Any],
    ) -> InteractiveFigure:
        """
        Plot fitted parameters as a function of Q.

        Parameters
        ----------
        names : str | list[str] | None, default=None
            Name(s) of the parameter(s) to plot. If None, plots all parameters.
        **kwargs : dict[str, Any]
            Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g.,
            title, linestyle, marker, color).

        Raises
        ------
        TypeError
            If names is not a string, list of strings, or None.
        ValueError
            If any of the specified parameter names are not found in the dataset.

        Returns
        -------
        InteractiveFigure
            A Plopp InteractiveFigure containing the plot of the parameters.
        """

        ds = self.parameters_to_dataset()

        if not names:
            names = list(ds.keys())

        if isinstance(names, str):
            names = [names]

        if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
            raise TypeError('names must be a string or a list of strings.')

        for name in names:
            if name not in ds:
                raise ValueError(f"Parameter '{name}' not found in dataset.")

        data_to_plot = {name: ds[name] for name in names}
        plot_kwargs_defaults = {
            'linestyle': dict.fromkeys(names, 'none'),
            'marker': dict.fromkeys(names, 'o'),
            'markerfacecolor': dict.fromkeys(names, 'none'),
        }

        plot_kwargs_defaults.update(kwargs)

        import plopp as pp

        return pp.plot(
            data_to_plot,
            **plot_kwargs_defaults,
        )

    def fix_energy_offset(self, Q_index: int | None = None) -> None:
        """
        Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
        is None.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to fix the energy offset for. If None, fixes the energy offset for
            all Q values.
        """
        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            self.analysis_list[Q_index].fix_energy_offset()
        else:
            for analysis in self.analysis_list:
                analysis.fix_energy_offset()

    def free_energy_offset(self, Q_index: int | None = None) -> None:
        """
        Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
        is None.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to free the energy offset for. If None, frees the energy offset
            for all Q values.
        """
        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            self.analysis_list[Q_index].free_energy_offset()
        else:
            for analysis in self.analysis_list:
                analysis.free_energy_offset()

    #############
    # Private methods - updating models when things change
    #############

    def _on_experiment_changed(self) -> None:
        """
        Update the Q values in the sample and instrument models when the experiment changes.

        Also update all the Analysis1d objects with the new experiment.
        """
        if self._call_updaters:
            super()._on_experiment_changed()
            for analysis in self.analysis_list:
                analysis.experiment = self.experiment

    def _on_sample_model_changed(self) -> None:
        """
        Update the Q values in the sample model when the sample model changes.

        Also update all the Analysis1d objects with the new sample model.
        """
        if self._call_updaters:
            super()._on_sample_model_changed()
            for analysis in self.analysis_list:
                analysis.sample_model = self.sample_model

    def _on_instrument_model_changed(self) -> None:
        """
        Update the Q values in the instrument model when the instrument model changes.

        Also update all the Analysis1d objects with the new instrument model.
        """
        if self._call_updaters:
            super()._on_instrument_model_changed()
            for analysis in self.analysis_list:
                analysis.instrument_model = self.instrument_model

    def _on_convolution_settings_changed(self) -> None:
        """
        Update the convolution settings in all Analysis1d objects when the convolution settings
        change.
        """
        if self._call_updaters:
            super()._on_convolution_settings_changed()
            for analysis1d in self.analysis_list:
                analysis1d.convolution_settings = self.convolution_settings

    def _create_analysis_list(self) -> None:
        """
        Create the list of Analysis1d objects, one for each Q index, based on the current
        experiment, sample model, and instrument model.
        """
        self._analysis_list = []
        for Q_index in range(len(self.Q)):
            analysis = Analysis1d(
                display_name=f'{self.display_name}_Q{Q_index}',
                unique_name=(f'{self.unique_name}_Q{Q_index}'),
                experiment=self.experiment,
                sample_model=self.sample_model,
                instrument_model=self.instrument_model,
                convolution_settings=self.convolution_settings,
                detailed_balance_settings=self.detailed_balance_settings,
                extra_parameters=self._extra_parameters,
                Q_index=Q_index,
            )
            self._analysis_list.append(analysis)

    #############
    # Private methods
    #############

    def _fit_single_Q(self, Q_index: int) -> FitResults:
        """
        Fit data for a single Q index.

        Parameters
        ----------
        Q_index : int
            Index of the Q value to fit.

        Returns
        -------
        FitResults
            The results of the fit for the specified Q index.
        """

        Q_index = self._verify_Q_index(Q_index)

        return self.analysis_list[Q_index].fit()

    def _fit_all_Q_independently(self) -> list[FitResults]:
        """
        Fit data for all Q indices independently.

        Returns
        -------
        list[FitResults]
            A list of FitResults, one for each Q index.
        """
        return [analysis.fit() for analysis in self.analysis_list]

    def _fit_all_Q_simultaneously(self) -> FitResults:
        """
        Fit data for all Q indices simultaneously.

        Returns
        -------
        FitResults
            The results of the simultaneous fit across all Q indices.
        """

        xs = []
        ys = []
        ws = []

        for analysis1d in self.analysis_list:
            x, y, weight, _ = self.experiment._extract_x_y_weights_only_finite(  # noqa: SLF001
                analysis1d.Q_index
            )
            xs.append(x)
            ys.append(y)
            ws.append(weight)

            # Make sure the convolver is up to date for this Q index
            analysis1d._convolver = analysis1d._create_convolver(  # noqa: SLF001
                energy=x
            )

        mf = MultiFitter(
            fit_objects=self.analysis_list,
            fit_functions=self.get_fit_functions(),
        )

        return mf.fit(
            x=xs,
            y=ys,
            weights=ws,
        )

    def get_fit_functions(self) -> list[callable]:
        """
        Get fit functions for all Q indices, which can be used for simultaneous fitting.

        Returns
        -------
        list[callable]
            A list of fit functions, one for each Q index.
        """
        return [analysis.as_fit_function() for analysis in self.analysis_list]

    def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray:
        """
        Create a scipp array for the model.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.

        Returns
        -------
        sc.DataArray
            A DataArray containing the model values, with dimensions "Q" and "energy".
        """
        if energy is None:
            energy = self.energy
        model = sc.array(dims=['Q', 'energy'], values=self.calculate(energy=energy))
        return sc.DataArray(
            data=model,
            coords={'Q': self.Q, 'energy': energy},
        )

    def _create_components_dataset(
        self,
        add_background: bool = True,
        energy: sc.Variable | None = None,
    ) -> sc.Dataset:
        """
        Create a scipp dataset containing the individual components of the model for plotting.

        Parameters
        ----------
        add_background : bool, default=True
            Whether to add background components to the sample model components when creating the
            dataset.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the components. If None, uses the energy from
            the experiment.

        Raises
        ------
        TypeError
            If add_background is not True or False.

        Returns
        -------
        sc.Dataset
            A scipp Dataset where each entry is a component of the model, with dimensions "Q".
        """
        if not isinstance(add_background, bool):
            raise TypeError('add_background must be True or False.')

        if energy is None:
            energy = self.energy

        datasets = [
            analysis1d._create_components_dataset_single_Q(  # noqa: SLF001
                add_background=add_background, energy=energy
            )
            for analysis1d in self.analysis_list
        ]

        ds = sc.concat(datasets, dim='Q')
        return ds.assign_coords(Q=self.Q)

__init__(display_name='MyAnalysis', unique_name=None, experiment=None, sample_model=None, instrument_model=None, convolution_settings=None, detailed_balance_settings=None, extra_parameters=None)

Initialize an Analysis object.

Parameters:

Name Type Description Default
display_name str | None

Display name of the analysis.

'MyAnalysis'
unique_name str | None

Unique name of the analysis. If None, a unique name is automatically generated.

None
experiment Experiment | None

The Experiment associated with this Analysis. If None, a default Experiment is created.

None
sample_model SampleModel | None

The SampleModel associated with this Analysis. If None, a default SampleModel is created.

None
instrument_model InstrumentModel | None

The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created.

None
convolution_settings ConvolutionSettings | None

The settings for the convolution. If None, default settings will be used.

None
detailed_balance_settings DetailedBalanceSettings | None

The settings for detailed balance. If None, default settings will be used.

None
extra_parameters Parameter | list[Parameter] | None

Extra parameters to be included in the analysis for advanced users. If None, no extra parameters are added.

None
Source code in src/easydynamics/analysis/analysis.py
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
def __init__(
    self,
    display_name: str | None = 'MyAnalysis',
    unique_name: str | None = None,
    experiment: Experiment | None = None,
    sample_model: SampleModel | None = None,
    instrument_model: InstrumentModel | None = None,
    convolution_settings: ConvolutionSettings | None = None,
    detailed_balance_settings: DetailedBalanceSettings | None = None,
    extra_parameters: Parameter | list[Parameter] | None = None,
) -> None:
    """
    Initialize an Analysis object.

    Parameters
    ----------
    display_name : str | None, default='MyAnalysis'
        Display name of the analysis.
    unique_name : str | None, default=None
        Unique name of the analysis. If None, a unique name is automatically generated.
    experiment : Experiment | None, default=None
        The Experiment associated with this Analysis. If None, a default Experiment is created.
    sample_model : SampleModel | None, default=None
        The SampleModel associated with this Analysis. If None, a default SampleModel is
        created.
    instrument_model : InstrumentModel | None, default=None
        The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
        is created.
    convolution_settings : ConvolutionSettings | None, default=None
         The settings for the convolution. If None, default settings will be used.
    detailed_balance_settings : DetailedBalanceSettings | None, default=None
        The settings for detailed balance. If None, default settings will be used.
    extra_parameters : Parameter | list[Parameter] | None, default=None
        Extra parameters to be included in the analysis for advanced users. If None, no extra
        parameters are added.
    """

    # Avoid triggering updates before the object is fully
    # initialized
    self._call_updaters = False
    super().__init__(
        display_name=display_name,
        unique_name=unique_name,
        experiment=experiment,
        sample_model=sample_model,
        instrument_model=instrument_model,
        convolution_settings=convolution_settings,
        detailed_balance_settings=detailed_balance_settings,
        extra_parameters=extra_parameters,
    )

    self._analysis_list = []
    if self.Q is not None:
        self._create_analysis_list()

    # Now we can allow updates to trigger recalculations
    self._call_updaters = True

analysis_list property writable

Get the Analysis1d objects associated with this Analysis.

Returns:

Type Description
list[Analysis1d]

A list of Analysis1d objects, one for each Q index.

calculate(Q_index=None, energy=None)

Calculate model data for a specific Q index.

If Q_index is None, calculate for all Q indices and return a list of arrays.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to calculate for. If None, calculate for all Q values.

None
energy Variable | None

The energy values to use for calculating the model. If None, uses the energy from the experiment.

None

Returns:

Type Description
list[ndarray] | ndarray

If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is an integer, returns a single numpy array for that Q index.

Source code in src/easydynamics/analysis/analysis.py
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
def calculate(
    self,
    Q_index: int | None = None,
    energy: sc.Variable | None = None,
) -> list[np.ndarray] | np.ndarray:
    """
    Calculate model data for a specific Q index.

    If Q_index is None, calculate for all Q indices and return a list of arrays.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to calculate for. If None, calculate for all Q values.
    energy : sc.Variable | None, default=None
        The energy values to use for calculating the model. If None, uses the energy from the
        experiment.

    Returns
    -------
    list[np.ndarray] | np.ndarray
        If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is
        an integer, returns a single numpy array for that Q index.
    """
    if energy is None:
        energy = self.energy

    if Q_index is None:
        return [analysis.calculate(energy=energy) for analysis in self.analysis_list]

    Q_index = self._verify_Q_index(Q_index)
    return self.analysis_list[Q_index].calculate(energy=energy)

fit(fit_method='independent', Q_index=None)

Fit the model to the experimental data.

Parameters:

Name Type Description Default
fit_method str

Method to use for fitting. Options are "independent" (fit each Q index independently, one after the other) or "simultaneous" (fit all Q indices simultaneously).

'independent'
Q_index int | None

If fit_method is "independent", specify which Q index to fit. If None, fit all Q indices independently. Ignored if fit_method is "simultaneous".

None

Raises:

Type Description
ValueError

If fit_method is not "independent" or "simultaneous" or if there are no Q values available for fitting.

Returns:

Type Description
FitResults | list[FitResults]

A list of FitResults if fitting independently, or a single FitResults object if fitting simultaneously.

Source code in src/easydynamics/analysis/analysis.py
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
def fit(
    self,
    fit_method: str = 'independent',
    Q_index: int | None = None,
) -> FitResults | list[FitResults]:
    """
    Fit the model to the experimental data.

    Parameters
    ----------
    fit_method : str, default='independent'
        Method to use for fitting. Options are "independent" (fit each Q index independently,
        one after the other) or "simultaneous" (fit all Q indices simultaneously).
    Q_index : int | None, default=None
        If fit_method is "independent", specify which Q index to fit. If None, fit all Q
        indices independently. Ignored if fit_method is "simultaneous".

    Raises
    ------
    ValueError
        If fit_method is not "independent" or "simultaneous" or if there are no Q values
        available for fitting.

    Returns
    -------
    FitResults | list[FitResults]
        A list of FitResults if fitting independently, or a single FitResults object if fitting
        simultaneously.
    """

    if self.Q is None:
        raise ValueError(
            'No Q values available for fitting. Please check the experiment data.'
        )

    Q_index = self._verify_Q_index(Q_index)

    if fit_method == 'independent':
        if Q_index is not None:
            return self._fit_single_Q(Q_index)
        return self._fit_all_Q_independently()
    if fit_method == 'simultaneous':
        return self._fit_all_Q_simultaneously()
    raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.")

fix_energy_offset(Q_index=None)

Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index is None.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to fix the energy offset for. If None, fixes the energy offset for all Q values.

None
Source code in src/easydynamics/analysis/analysis.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def fix_energy_offset(self, Q_index: int | None = None) -> None:
    """
    Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
    is None.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to fix the energy offset for. If None, fixes the energy offset for
        all Q values.
    """
    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        self.analysis_list[Q_index].fix_energy_offset()
    else:
        for analysis in self.analysis_list:
            analysis.fix_energy_offset()

free_energy_offset(Q_index=None)

Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index is None.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to free the energy offset for. If None, frees the energy offset for all Q values.

None
Source code in src/easydynamics/analysis/analysis.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def free_energy_offset(self, Q_index: int | None = None) -> None:
    """
    Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
    is None.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to free the energy offset for. If None, frees the energy offset
        for all Q values.
    """
    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        self.analysis_list[Q_index].free_energy_offset()
    else:
        for analysis in self.analysis_list:
            analysis.free_energy_offset()

get_fit_functions()

Get fit functions for all Q indices, which can be used for simultaneous fitting.

Returns:

Type Description
list[callable]

A list of fit functions, one for each Q index.

Source code in src/easydynamics/analysis/analysis.py
625
626
627
628
629
630
631
632
633
634
def get_fit_functions(self) -> list[callable]:
    """
    Get fit functions for all Q indices, which can be used for simultaneous fitting.

    Returns
    -------
    list[callable]
        A list of fit functions, one for each Q index.
    """
    return [analysis.as_fit_function() for analysis in self.analysis_list]

parameters_to_dataset()

Creates a scipp dataset with copies of the Parameters in the model.

Ensures unit consistency across Q.

Raises:

Type Description
UnitError

If there are inconsistent units for the same parameter across different Q values.

Returns:

Type Description
Dataset

A dataset where each entry is a parameter, with dimensions "Q" and values corresponding to the parameter values.

Source code in src/easydynamics/analysis/analysis.py
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
def parameters_to_dataset(self) -> sc.Dataset:
    """
    Creates a scipp dataset with copies of the Parameters in the model.

    Ensures unit consistency across Q.

    Raises
    ------
    UnitError
        If there are inconsistent units for the same parameter across different Q values.

    Returns
    -------
    sc.Dataset
        A dataset where each entry is a parameter, with dimensions "Q" and values corresponding
        to the parameter values.
    """

    ds = sc.Dataset(coords={'Q': self.Q})

    # Collect all parameter names
    all_names = {
        param.name
        for analysis in self.analysis_list
        for param in analysis.get_all_parameters()
    }

    # Storage
    values = {name: [] for name in all_names}
    variances = {name: [] for name in all_names}
    units = {}

    for analysis in self.analysis_list:
        pars = {p.name: p for p in analysis.get_all_parameters()}

        for name in all_names:
            if name in pars:
                p = pars[name]

                # Unit consistency check
                if name not in units:
                    units[name] = p.unit
                elif units[name] != p.unit:
                    try:
                        p.convert_unit(units[name])
                    except Exception as e:
                        raise UnitError(
                            f"Inconsistent units for parameter '{name}': "
                            f'{units[name]} vs {p.unit}'
                        ) from e

                values[name].append(p.value)
                variances[name].append(p.variance)
            else:
                values[name].append(np.nan)
                variances[name].append(np.nan)

    # Build dataset variables
    for name in all_names:
        ds[name] = sc.Variable(
            dims=['Q'],
            values=np.asarray(values[name], dtype=float),
            variances=np.asarray(variances[name], dtype=float),
            unit=units.get(name),
        )

    return ds

plot_data_and_model(Q_index=None, plot_components=True, add_background=True, energy=None, **kwargs)

Plot the experimental data and the model prediction.

Optionally also plot the individual components of the model.

Uses Plopp for plotting: https://scipp.github.io/plopp/

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to plot. If None, plot all Q values.

None
plot_components bool

Whether to plot the individual components.

True
add_background bool

Whether to add background components to the sample model components when plotting. Default is True.

True
energy Variable | None

The energy values to use for calculating the model. If None, uses the energy from the experiment.

None
**kwargs dict[str, Any]

Additional keyword arguments passed to plopp for customizing the plot.

{}

Raises:

Type Description
ValueError

If Q_index is out of bounds, or if there is no data to plot, or if there are no Q values available for plotting.

RuntimeError

If not in a Jupyter notebook environment.

TypeError

If plot_components or add_background is not True or False.

Returns:

Type Description
InteractiveFigure

A Plopp InteractiveFigure containing the plot of the data and model.

Source code in src/easydynamics/analysis/analysis.py
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
def plot_data_and_model(
    self,
    Q_index: int | None = None,
    plot_components: bool = True,
    add_background: bool = True,
    energy: sc.Variable | None = None,
    **kwargs: dict[str, Any],
) -> InteractiveFigure:
    """
    Plot the experimental data and the model prediction.

    Optionally also plot the individual components of the model.

    Uses Plopp for plotting: https://scipp.github.io/plopp/

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to plot. If None, plot all Q values.
    plot_components : bool, default=True
        Whether to plot the individual components.
    add_background : bool, default=True
        Whether to add background components to the sample model components when plotting.
        Default is True.
    energy : sc.Variable | None, default=None
        The energy values to use for calculating the model. If None, uses the energy from the
        experiment.
    **kwargs : dict[str, Any]
        Additional keyword arguments passed to plopp for customizing the plot.

    Raises
    ------
    ValueError
        If Q_index is out of bounds, or if there is no data to plot, or if there are no Q
        values available for plotting.
    RuntimeError
        If not in a Jupyter notebook environment.
    TypeError
        If plot_components or add_background is not True or False.

    Returns
    -------
    InteractiveFigure
        A Plopp InteractiveFigure containing the plot of the data and model.
    """

    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        return self.analysis_list[Q_index].plot_data_and_model(
            plot_components=plot_components,
            add_background=add_background,
            energy=energy,
            **kwargs,
        )

    if self.experiment.binned_data is None:
        raise ValueError('No data to plot. Please load data first.')

    if not _in_notebook():
        raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.')

    if self.Q is None:
        raise ValueError(
            'No Q values available for plotting. Please check the experiment data.'
        )

    if not isinstance(plot_components, bool):
        raise TypeError('plot_components must be True or False.')

    if not isinstance(add_background, bool):
        raise TypeError('add_background must be True or False.')

    if energy is None:
        energy = self.energy

    import plopp as pp

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {'Data': 'none', 'Model': '-'},
        'marker': {'Data': 'o', 'Model': None},
        'color': {'Data': 'black', 'Model': 'red'},
        'markerfacecolor': {'Data': 'none', 'Model': 'none'},
        'keep': 'energy',
    }
    data_and_model = {
        'Data': self.experiment.binned_data,
        'Model': self._create_model_array(energy=energy),
    }

    if plot_components:
        components = self._create_components_dataset(
            add_background=add_background, energy=energy
        )
        for key in components:
            data_and_model[key] = components[key]
            plot_kwargs_defaults['linestyle'][key] = '--'
            plot_kwargs_defaults['marker'][key] = None

    # Overwrite defaults with any user-provided kwargs
    plot_kwargs_defaults.update(kwargs)

    fig = pp.slicer(
        data_and_model,
        **plot_kwargs_defaults,
    )
    for widget in fig.bottom_bar[0].controls.values():
        widget.slider_toggler.value = '-o-'

    return fig

plot_parameters(names=None, **kwargs)

Plot fitted parameters as a function of Q.

Parameters:

Name Type Description Default
names str | list[str] | None

Name(s) of the parameter(s) to plot. If None, plots all parameters.

None
**kwargs dict[str, Any]

Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g., title, linestyle, marker, color).

{}

Raises:

Type Description
TypeError

If names is not a string, list of strings, or None.

ValueError

If any of the specified parameter names are not found in the dataset.

Returns:

Type Description
InteractiveFigure

A Plopp InteractiveFigure containing the plot of the parameters.

Source code in src/easydynamics/analysis/analysis.py
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
def plot_parameters(
    self,
    names: str | list[str] | None = None,
    **kwargs: dict[str, Any],
) -> InteractiveFigure:
    """
    Plot fitted parameters as a function of Q.

    Parameters
    ----------
    names : str | list[str] | None, default=None
        Name(s) of the parameter(s) to plot. If None, plots all parameters.
    **kwargs : dict[str, Any]
        Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g.,
        title, linestyle, marker, color).

    Raises
    ------
    TypeError
        If names is not a string, list of strings, or None.
    ValueError
        If any of the specified parameter names are not found in the dataset.

    Returns
    -------
    InteractiveFigure
        A Plopp InteractiveFigure containing the plot of the parameters.
    """

    ds = self.parameters_to_dataset()

    if not names:
        names = list(ds.keys())

    if isinstance(names, str):
        names = [names]

    if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
        raise TypeError('names must be a string or a list of strings.')

    for name in names:
        if name not in ds:
            raise ValueError(f"Parameter '{name}' not found in dataset.")

    data_to_plot = {name: ds[name] for name in names}
    plot_kwargs_defaults = {
        'linestyle': dict.fromkeys(names, 'none'),
        'marker': dict.fromkeys(names, 'o'),
        'markerfacecolor': dict.fromkeys(names, 'none'),
    }

    plot_kwargs_defaults.update(kwargs)

    import plopp as pp

    return pp.plot(
        data_to_plot,
        **plot_kwargs_defaults,
    )

analysis

Analysis

Bases: AnalysisBase

For analysing two-dimensional data, i.e. intensity as function of energy and Q.

Supports independent fits of each Q value and simultaneous fits of all Q.

Source code in src/easydynamics/analysis/analysis.py
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
class Analysis(AnalysisBase):
    """
    For analysing two-dimensional data, i.e. intensity as function of energy and Q.

    Supports independent fits of each Q value and simultaneous fits of all Q.
    """

    def __init__(
        self,
        display_name: str | None = 'MyAnalysis',
        unique_name: str | None = None,
        experiment: Experiment | None = None,
        sample_model: SampleModel | None = None,
        instrument_model: InstrumentModel | None = None,
        convolution_settings: ConvolutionSettings | None = None,
        detailed_balance_settings: DetailedBalanceSettings | None = None,
        extra_parameters: Parameter | list[Parameter] | None = None,
    ) -> None:
        """
        Initialize an Analysis object.

        Parameters
        ----------
        display_name : str | None, default='MyAnalysis'
            Display name of the analysis.
        unique_name : str | None, default=None
            Unique name of the analysis. If None, a unique name is automatically generated.
        experiment : Experiment | None, default=None
            The Experiment associated with this Analysis. If None, a default Experiment is created.
        sample_model : SampleModel | None, default=None
            The SampleModel associated with this Analysis. If None, a default SampleModel is
            created.
        instrument_model : InstrumentModel | None, default=None
            The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
            is created.
        convolution_settings : ConvolutionSettings | None, default=None
             The settings for the convolution. If None, default settings will be used.
        detailed_balance_settings : DetailedBalanceSettings | None, default=None
            The settings for detailed balance. If None, default settings will be used.
        extra_parameters : Parameter | list[Parameter] | None, default=None
            Extra parameters to be included in the analysis for advanced users. If None, no extra
            parameters are added.
        """

        # Avoid triggering updates before the object is fully
        # initialized
        self._call_updaters = False
        super().__init__(
            display_name=display_name,
            unique_name=unique_name,
            experiment=experiment,
            sample_model=sample_model,
            instrument_model=instrument_model,
            convolution_settings=convolution_settings,
            detailed_balance_settings=detailed_balance_settings,
            extra_parameters=extra_parameters,
        )

        self._analysis_list = []
        if self.Q is not None:
            self._create_analysis_list()

        # Now we can allow updates to trigger recalculations
        self._call_updaters = True

    #############
    # Properties
    #############

    @property
    def analysis_list(self) -> list[Analysis1d]:
        """
        Get the Analysis1d objects associated with this Analysis.

        Returns
        -------
        list[Analysis1d]
            A list of Analysis1d objects, one for each Q index.
        """
        return self._analysis_list

    @analysis_list.setter
    def analysis_list(self, _value: list[Analysis1d]) -> None:
        """
        Analysis_list is read-only.

        To change the analysis list, modify the experiment, sample model, or instrument model.

        Parameters
        ----------
        _value : list[Analysis1d]
            The new list of Analysis1d objects. This argument is ignored, as analysis_list is
            read-only.

        Raises
        ------
        AttributeError
            Always raised, since analysis_list is read-only.
        """

        raise AttributeError(
            'analysis_list is read-only. '
            'To change the analysis list, modify the experiment, sample model, '
            'or instrument model.'
        )

    #############
    # Other methods
    #############
    def calculate(
        self,
        Q_index: int | None = None,
        energy: sc.Variable | None = None,
    ) -> list[np.ndarray] | np.ndarray:
        """
        Calculate model data for a specific Q index.

        If Q_index is None, calculate for all Q indices and return a list of arrays.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to calculate for. If None, calculate for all Q values.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.

        Returns
        -------
        list[np.ndarray] | np.ndarray
            If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is
            an integer, returns a single numpy array for that Q index.
        """
        if energy is None:
            energy = self.energy

        if Q_index is None:
            return [analysis.calculate(energy=energy) for analysis in self.analysis_list]

        Q_index = self._verify_Q_index(Q_index)
        return self.analysis_list[Q_index].calculate(energy=energy)

    def fit(
        self,
        fit_method: str = 'independent',
        Q_index: int | None = None,
    ) -> FitResults | list[FitResults]:
        """
        Fit the model to the experimental data.

        Parameters
        ----------
        fit_method : str, default='independent'
            Method to use for fitting. Options are "independent" (fit each Q index independently,
            one after the other) or "simultaneous" (fit all Q indices simultaneously).
        Q_index : int | None, default=None
            If fit_method is "independent", specify which Q index to fit. If None, fit all Q
            indices independently. Ignored if fit_method is "simultaneous".

        Raises
        ------
        ValueError
            If fit_method is not "independent" or "simultaneous" or if there are no Q values
            available for fitting.

        Returns
        -------
        FitResults | list[FitResults]
            A list of FitResults if fitting independently, or a single FitResults object if fitting
            simultaneously.
        """

        if self.Q is None:
            raise ValueError(
                'No Q values available for fitting. Please check the experiment data.'
            )

        Q_index = self._verify_Q_index(Q_index)

        if fit_method == 'independent':
            if Q_index is not None:
                return self._fit_single_Q(Q_index)
            return self._fit_all_Q_independently()
        if fit_method == 'simultaneous':
            return self._fit_all_Q_simultaneously()
        raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.")

    def plot_data_and_model(
        self,
        Q_index: int | None = None,
        plot_components: bool = True,
        add_background: bool = True,
        energy: sc.Variable | None = None,
        **kwargs: dict[str, Any],
    ) -> InteractiveFigure:
        """
        Plot the experimental data and the model prediction.

        Optionally also plot the individual components of the model.

        Uses Plopp for plotting: https://scipp.github.io/plopp/

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to plot. If None, plot all Q values.
        plot_components : bool, default=True
            Whether to plot the individual components.
        add_background : bool, default=True
            Whether to add background components to the sample model components when plotting.
            Default is True.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.
        **kwargs : dict[str, Any]
            Additional keyword arguments passed to plopp for customizing the plot.

        Raises
        ------
        ValueError
            If Q_index is out of bounds, or if there is no data to plot, or if there are no Q
            values available for plotting.
        RuntimeError
            If not in a Jupyter notebook environment.
        TypeError
            If plot_components or add_background is not True or False.

        Returns
        -------
        InteractiveFigure
            A Plopp InteractiveFigure containing the plot of the data and model.
        """

        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            return self.analysis_list[Q_index].plot_data_and_model(
                plot_components=plot_components,
                add_background=add_background,
                energy=energy,
                **kwargs,
            )

        if self.experiment.binned_data is None:
            raise ValueError('No data to plot. Please load data first.')

        if not _in_notebook():
            raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.')

        if self.Q is None:
            raise ValueError(
                'No Q values available for plotting. Please check the experiment data.'
            )

        if not isinstance(plot_components, bool):
            raise TypeError('plot_components must be True or False.')

        if not isinstance(add_background, bool):
            raise TypeError('add_background must be True or False.')

        if energy is None:
            energy = self.energy

        import plopp as pp

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {'Data': 'none', 'Model': '-'},
            'marker': {'Data': 'o', 'Model': None},
            'color': {'Data': 'black', 'Model': 'red'},
            'markerfacecolor': {'Data': 'none', 'Model': 'none'},
            'keep': 'energy',
        }
        data_and_model = {
            'Data': self.experiment.binned_data,
            'Model': self._create_model_array(energy=energy),
        }

        if plot_components:
            components = self._create_components_dataset(
                add_background=add_background, energy=energy
            )
            for key in components:
                data_and_model[key] = components[key]
                plot_kwargs_defaults['linestyle'][key] = '--'
                plot_kwargs_defaults['marker'][key] = None

        # Overwrite defaults with any user-provided kwargs
        plot_kwargs_defaults.update(kwargs)

        fig = pp.slicer(
            data_and_model,
            **plot_kwargs_defaults,
        )
        for widget in fig.bottom_bar[0].controls.values():
            widget.slider_toggler.value = '-o-'

        return fig

    def parameters_to_dataset(self) -> sc.Dataset:
        """
        Creates a scipp dataset with copies of the Parameters in the model.

        Ensures unit consistency across Q.

        Raises
        ------
        UnitError
            If there are inconsistent units for the same parameter across different Q values.

        Returns
        -------
        sc.Dataset
            A dataset where each entry is a parameter, with dimensions "Q" and values corresponding
            to the parameter values.
        """

        ds = sc.Dataset(coords={'Q': self.Q})

        # Collect all parameter names
        all_names = {
            param.name
            for analysis in self.analysis_list
            for param in analysis.get_all_parameters()
        }

        # Storage
        values = {name: [] for name in all_names}
        variances = {name: [] for name in all_names}
        units = {}

        for analysis in self.analysis_list:
            pars = {p.name: p for p in analysis.get_all_parameters()}

            for name in all_names:
                if name in pars:
                    p = pars[name]

                    # Unit consistency check
                    if name not in units:
                        units[name] = p.unit
                    elif units[name] != p.unit:
                        try:
                            p.convert_unit(units[name])
                        except Exception as e:
                            raise UnitError(
                                f"Inconsistent units for parameter '{name}': "
                                f'{units[name]} vs {p.unit}'
                            ) from e

                    values[name].append(p.value)
                    variances[name].append(p.variance)
                else:
                    values[name].append(np.nan)
                    variances[name].append(np.nan)

        # Build dataset variables
        for name in all_names:
            ds[name] = sc.Variable(
                dims=['Q'],
                values=np.asarray(values[name], dtype=float),
                variances=np.asarray(variances[name], dtype=float),
                unit=units.get(name),
            )

        return ds

    def plot_parameters(
        self,
        names: str | list[str] | None = None,
        **kwargs: dict[str, Any],
    ) -> InteractiveFigure:
        """
        Plot fitted parameters as a function of Q.

        Parameters
        ----------
        names : str | list[str] | None, default=None
            Name(s) of the parameter(s) to plot. If None, plots all parameters.
        **kwargs : dict[str, Any]
            Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g.,
            title, linestyle, marker, color).

        Raises
        ------
        TypeError
            If names is not a string, list of strings, or None.
        ValueError
            If any of the specified parameter names are not found in the dataset.

        Returns
        -------
        InteractiveFigure
            A Plopp InteractiveFigure containing the plot of the parameters.
        """

        ds = self.parameters_to_dataset()

        if not names:
            names = list(ds.keys())

        if isinstance(names, str):
            names = [names]

        if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
            raise TypeError('names must be a string or a list of strings.')

        for name in names:
            if name not in ds:
                raise ValueError(f"Parameter '{name}' not found in dataset.")

        data_to_plot = {name: ds[name] for name in names}
        plot_kwargs_defaults = {
            'linestyle': dict.fromkeys(names, 'none'),
            'marker': dict.fromkeys(names, 'o'),
            'markerfacecolor': dict.fromkeys(names, 'none'),
        }

        plot_kwargs_defaults.update(kwargs)

        import plopp as pp

        return pp.plot(
            data_to_plot,
            **plot_kwargs_defaults,
        )

    def fix_energy_offset(self, Q_index: int | None = None) -> None:
        """
        Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
        is None.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to fix the energy offset for. If None, fixes the energy offset for
            all Q values.
        """
        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            self.analysis_list[Q_index].fix_energy_offset()
        else:
            for analysis in self.analysis_list:
                analysis.fix_energy_offset()

    def free_energy_offset(self, Q_index: int | None = None) -> None:
        """
        Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
        is None.

        Parameters
        ----------
        Q_index : int | None, default=None
            Index of the Q value to free the energy offset for. If None, frees the energy offset
            for all Q values.
        """
        if Q_index is not None:
            Q_index = self._verify_Q_index(Q_index)
            self.analysis_list[Q_index].free_energy_offset()
        else:
            for analysis in self.analysis_list:
                analysis.free_energy_offset()

    #############
    # Private methods - updating models when things change
    #############

    def _on_experiment_changed(self) -> None:
        """
        Update the Q values in the sample and instrument models when the experiment changes.

        Also update all the Analysis1d objects with the new experiment.
        """
        if self._call_updaters:
            super()._on_experiment_changed()
            for analysis in self.analysis_list:
                analysis.experiment = self.experiment

    def _on_sample_model_changed(self) -> None:
        """
        Update the Q values in the sample model when the sample model changes.

        Also update all the Analysis1d objects with the new sample model.
        """
        if self._call_updaters:
            super()._on_sample_model_changed()
            for analysis in self.analysis_list:
                analysis.sample_model = self.sample_model

    def _on_instrument_model_changed(self) -> None:
        """
        Update the Q values in the instrument model when the instrument model changes.

        Also update all the Analysis1d objects with the new instrument model.
        """
        if self._call_updaters:
            super()._on_instrument_model_changed()
            for analysis in self.analysis_list:
                analysis.instrument_model = self.instrument_model

    def _on_convolution_settings_changed(self) -> None:
        """
        Update the convolution settings in all Analysis1d objects when the convolution settings
        change.
        """
        if self._call_updaters:
            super()._on_convolution_settings_changed()
            for analysis1d in self.analysis_list:
                analysis1d.convolution_settings = self.convolution_settings

    def _create_analysis_list(self) -> None:
        """
        Create the list of Analysis1d objects, one for each Q index, based on the current
        experiment, sample model, and instrument model.
        """
        self._analysis_list = []
        for Q_index in range(len(self.Q)):
            analysis = Analysis1d(
                display_name=f'{self.display_name}_Q{Q_index}',
                unique_name=(f'{self.unique_name}_Q{Q_index}'),
                experiment=self.experiment,
                sample_model=self.sample_model,
                instrument_model=self.instrument_model,
                convolution_settings=self.convolution_settings,
                detailed_balance_settings=self.detailed_balance_settings,
                extra_parameters=self._extra_parameters,
                Q_index=Q_index,
            )
            self._analysis_list.append(analysis)

    #############
    # Private methods
    #############

    def _fit_single_Q(self, Q_index: int) -> FitResults:
        """
        Fit data for a single Q index.

        Parameters
        ----------
        Q_index : int
            Index of the Q value to fit.

        Returns
        -------
        FitResults
            The results of the fit for the specified Q index.
        """

        Q_index = self._verify_Q_index(Q_index)

        return self.analysis_list[Q_index].fit()

    def _fit_all_Q_independently(self) -> list[FitResults]:
        """
        Fit data for all Q indices independently.

        Returns
        -------
        list[FitResults]
            A list of FitResults, one for each Q index.
        """
        return [analysis.fit() for analysis in self.analysis_list]

    def _fit_all_Q_simultaneously(self) -> FitResults:
        """
        Fit data for all Q indices simultaneously.

        Returns
        -------
        FitResults
            The results of the simultaneous fit across all Q indices.
        """

        xs = []
        ys = []
        ws = []

        for analysis1d in self.analysis_list:
            x, y, weight, _ = self.experiment._extract_x_y_weights_only_finite(  # noqa: SLF001
                analysis1d.Q_index
            )
            xs.append(x)
            ys.append(y)
            ws.append(weight)

            # Make sure the convolver is up to date for this Q index
            analysis1d._convolver = analysis1d._create_convolver(  # noqa: SLF001
                energy=x
            )

        mf = MultiFitter(
            fit_objects=self.analysis_list,
            fit_functions=self.get_fit_functions(),
        )

        return mf.fit(
            x=xs,
            y=ys,
            weights=ws,
        )

    def get_fit_functions(self) -> list[callable]:
        """
        Get fit functions for all Q indices, which can be used for simultaneous fitting.

        Returns
        -------
        list[callable]
            A list of fit functions, one for each Q index.
        """
        return [analysis.as_fit_function() for analysis in self.analysis_list]

    def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray:
        """
        Create a scipp array for the model.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the model. If None, uses the energy from the
            experiment.

        Returns
        -------
        sc.DataArray
            A DataArray containing the model values, with dimensions "Q" and "energy".
        """
        if energy is None:
            energy = self.energy
        model = sc.array(dims=['Q', 'energy'], values=self.calculate(energy=energy))
        return sc.DataArray(
            data=model,
            coords={'Q': self.Q, 'energy': energy},
        )

    def _create_components_dataset(
        self,
        add_background: bool = True,
        energy: sc.Variable | None = None,
    ) -> sc.Dataset:
        """
        Create a scipp dataset containing the individual components of the model for plotting.

        Parameters
        ----------
        add_background : bool, default=True
            Whether to add background components to the sample model components when creating the
            dataset.
        energy : sc.Variable | None, default=None
            The energy values to use for calculating the components. If None, uses the energy from
            the experiment.

        Raises
        ------
        TypeError
            If add_background is not True or False.

        Returns
        -------
        sc.Dataset
            A scipp Dataset where each entry is a component of the model, with dimensions "Q".
        """
        if not isinstance(add_background, bool):
            raise TypeError('add_background must be True or False.')

        if energy is None:
            energy = self.energy

        datasets = [
            analysis1d._create_components_dataset_single_Q(  # noqa: SLF001
                add_background=add_background, energy=energy
            )
            for analysis1d in self.analysis_list
        ]

        ds = sc.concat(datasets, dim='Q')
        return ds.assign_coords(Q=self.Q)

__init__(display_name='MyAnalysis', unique_name=None, experiment=None, sample_model=None, instrument_model=None, convolution_settings=None, detailed_balance_settings=None, extra_parameters=None)

Initialize an Analysis object.

Parameters:

Name Type Description Default
display_name str | None

Display name of the analysis.

'MyAnalysis'
unique_name str | None

Unique name of the analysis. If None, a unique name is automatically generated.

None
experiment Experiment | None

The Experiment associated with this Analysis. If None, a default Experiment is created.

None
sample_model SampleModel | None

The SampleModel associated with this Analysis. If None, a default SampleModel is created.

None
instrument_model InstrumentModel | None

The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created.

None
convolution_settings ConvolutionSettings | None

The settings for the convolution. If None, default settings will be used.

None
detailed_balance_settings DetailedBalanceSettings | None

The settings for detailed balance. If None, default settings will be used.

None
extra_parameters Parameter | list[Parameter] | None

Extra parameters to be included in the analysis for advanced users. If None, no extra parameters are added.

None
Source code in src/easydynamics/analysis/analysis.py
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
def __init__(
    self,
    display_name: str | None = 'MyAnalysis',
    unique_name: str | None = None,
    experiment: Experiment | None = None,
    sample_model: SampleModel | None = None,
    instrument_model: InstrumentModel | None = None,
    convolution_settings: ConvolutionSettings | None = None,
    detailed_balance_settings: DetailedBalanceSettings | None = None,
    extra_parameters: Parameter | list[Parameter] | None = None,
) -> None:
    """
    Initialize an Analysis object.

    Parameters
    ----------
    display_name : str | None, default='MyAnalysis'
        Display name of the analysis.
    unique_name : str | None, default=None
        Unique name of the analysis. If None, a unique name is automatically generated.
    experiment : Experiment | None, default=None
        The Experiment associated with this Analysis. If None, a default Experiment is created.
    sample_model : SampleModel | None, default=None
        The SampleModel associated with this Analysis. If None, a default SampleModel is
        created.
    instrument_model : InstrumentModel | None, default=None
        The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
        is created.
    convolution_settings : ConvolutionSettings | None, default=None
         The settings for the convolution. If None, default settings will be used.
    detailed_balance_settings : DetailedBalanceSettings | None, default=None
        The settings for detailed balance. If None, default settings will be used.
    extra_parameters : Parameter | list[Parameter] | None, default=None
        Extra parameters to be included in the analysis for advanced users. If None, no extra
        parameters are added.
    """

    # Avoid triggering updates before the object is fully
    # initialized
    self._call_updaters = False
    super().__init__(
        display_name=display_name,
        unique_name=unique_name,
        experiment=experiment,
        sample_model=sample_model,
        instrument_model=instrument_model,
        convolution_settings=convolution_settings,
        detailed_balance_settings=detailed_balance_settings,
        extra_parameters=extra_parameters,
    )

    self._analysis_list = []
    if self.Q is not None:
        self._create_analysis_list()

    # Now we can allow updates to trigger recalculations
    self._call_updaters = True

analysis_list property writable

Get the Analysis1d objects associated with this Analysis.

Returns:

Type Description
list[Analysis1d]

A list of Analysis1d objects, one for each Q index.

calculate(Q_index=None, energy=None)

Calculate model data for a specific Q index.

If Q_index is None, calculate for all Q indices and return a list of arrays.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to calculate for. If None, calculate for all Q values.

None
energy Variable | None

The energy values to use for calculating the model. If None, uses the energy from the experiment.

None

Returns:

Type Description
list[ndarray] | ndarray

If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is an integer, returns a single numpy array for that Q index.

Source code in src/easydynamics/analysis/analysis.py
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
def calculate(
    self,
    Q_index: int | None = None,
    energy: sc.Variable | None = None,
) -> list[np.ndarray] | np.ndarray:
    """
    Calculate model data for a specific Q index.

    If Q_index is None, calculate for all Q indices and return a list of arrays.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to calculate for. If None, calculate for all Q values.
    energy : sc.Variable | None, default=None
        The energy values to use for calculating the model. If None, uses the energy from the
        experiment.

    Returns
    -------
    list[np.ndarray] | np.ndarray
        If Q_index is None, returns a list of numpy arrays, one for each Q index. If Q_index is
        an integer, returns a single numpy array for that Q index.
    """
    if energy is None:
        energy = self.energy

    if Q_index is None:
        return [analysis.calculate(energy=energy) for analysis in self.analysis_list]

    Q_index = self._verify_Q_index(Q_index)
    return self.analysis_list[Q_index].calculate(energy=energy)

fit(fit_method='independent', Q_index=None)

Fit the model to the experimental data.

Parameters:

Name Type Description Default
fit_method str

Method to use for fitting. Options are "independent" (fit each Q index independently, one after the other) or "simultaneous" (fit all Q indices simultaneously).

'independent'
Q_index int | None

If fit_method is "independent", specify which Q index to fit. If None, fit all Q indices independently. Ignored if fit_method is "simultaneous".

None

Raises:

Type Description
ValueError

If fit_method is not "independent" or "simultaneous" or if there are no Q values available for fitting.

Returns:

Type Description
FitResults | list[FitResults]

A list of FitResults if fitting independently, or a single FitResults object if fitting simultaneously.

Source code in src/easydynamics/analysis/analysis.py
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
def fit(
    self,
    fit_method: str = 'independent',
    Q_index: int | None = None,
) -> FitResults | list[FitResults]:
    """
    Fit the model to the experimental data.

    Parameters
    ----------
    fit_method : str, default='independent'
        Method to use for fitting. Options are "independent" (fit each Q index independently,
        one after the other) or "simultaneous" (fit all Q indices simultaneously).
    Q_index : int | None, default=None
        If fit_method is "independent", specify which Q index to fit. If None, fit all Q
        indices independently. Ignored if fit_method is "simultaneous".

    Raises
    ------
    ValueError
        If fit_method is not "independent" or "simultaneous" or if there are no Q values
        available for fitting.

    Returns
    -------
    FitResults | list[FitResults]
        A list of FitResults if fitting independently, or a single FitResults object if fitting
        simultaneously.
    """

    if self.Q is None:
        raise ValueError(
            'No Q values available for fitting. Please check the experiment data.'
        )

    Q_index = self._verify_Q_index(Q_index)

    if fit_method == 'independent':
        if Q_index is not None:
            return self._fit_single_Q(Q_index)
        return self._fit_all_Q_independently()
    if fit_method == 'simultaneous':
        return self._fit_all_Q_simultaneously()
    raise ValueError("Invalid fit method. Choose 'independent' or 'simultaneous'.")

fix_energy_offset(Q_index=None)

Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index is None.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to fix the energy offset for. If None, fixes the energy offset for all Q values.

None
Source code in src/easydynamics/analysis/analysis.py
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def fix_energy_offset(self, Q_index: int | None = None) -> None:
    """
    Fix the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
    is None.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to fix the energy offset for. If None, fixes the energy offset for
        all Q values.
    """
    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        self.analysis_list[Q_index].fix_energy_offset()
    else:
        for analysis in self.analysis_list:
            analysis.fix_energy_offset()

free_energy_offset(Q_index=None)

Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index is None.

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to free the energy offset for. If None, frees the energy offset for all Q values.

None
Source code in src/easydynamics/analysis/analysis.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def free_energy_offset(self, Q_index: int | None = None) -> None:
    """
    Free the energy offset parameter(s) for a specific Q index, or for all Q indices if Q_index
    is None.

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to free the energy offset for. If None, frees the energy offset
        for all Q values.
    """
    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        self.analysis_list[Q_index].free_energy_offset()
    else:
        for analysis in self.analysis_list:
            analysis.free_energy_offset()

get_fit_functions()

Get fit functions for all Q indices, which can be used for simultaneous fitting.

Returns:

Type Description
list[callable]

A list of fit functions, one for each Q index.

Source code in src/easydynamics/analysis/analysis.py
625
626
627
628
629
630
631
632
633
634
def get_fit_functions(self) -> list[callable]:
    """
    Get fit functions for all Q indices, which can be used for simultaneous fitting.

    Returns
    -------
    list[callable]
        A list of fit functions, one for each Q index.
    """
    return [analysis.as_fit_function() for analysis in self.analysis_list]

parameters_to_dataset()

Creates a scipp dataset with copies of the Parameters in the model.

Ensures unit consistency across Q.

Raises:

Type Description
UnitError

If there are inconsistent units for the same parameter across different Q values.

Returns:

Type Description
Dataset

A dataset where each entry is a parameter, with dimensions "Q" and values corresponding to the parameter values.

Source code in src/easydynamics/analysis/analysis.py
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
def parameters_to_dataset(self) -> sc.Dataset:
    """
    Creates a scipp dataset with copies of the Parameters in the model.

    Ensures unit consistency across Q.

    Raises
    ------
    UnitError
        If there are inconsistent units for the same parameter across different Q values.

    Returns
    -------
    sc.Dataset
        A dataset where each entry is a parameter, with dimensions "Q" and values corresponding
        to the parameter values.
    """

    ds = sc.Dataset(coords={'Q': self.Q})

    # Collect all parameter names
    all_names = {
        param.name
        for analysis in self.analysis_list
        for param in analysis.get_all_parameters()
    }

    # Storage
    values = {name: [] for name in all_names}
    variances = {name: [] for name in all_names}
    units = {}

    for analysis in self.analysis_list:
        pars = {p.name: p for p in analysis.get_all_parameters()}

        for name in all_names:
            if name in pars:
                p = pars[name]

                # Unit consistency check
                if name not in units:
                    units[name] = p.unit
                elif units[name] != p.unit:
                    try:
                        p.convert_unit(units[name])
                    except Exception as e:
                        raise UnitError(
                            f"Inconsistent units for parameter '{name}': "
                            f'{units[name]} vs {p.unit}'
                        ) from e

                values[name].append(p.value)
                variances[name].append(p.variance)
            else:
                values[name].append(np.nan)
                variances[name].append(np.nan)

    # Build dataset variables
    for name in all_names:
        ds[name] = sc.Variable(
            dims=['Q'],
            values=np.asarray(values[name], dtype=float),
            variances=np.asarray(variances[name], dtype=float),
            unit=units.get(name),
        )

    return ds

plot_data_and_model(Q_index=None, plot_components=True, add_background=True, energy=None, **kwargs)

Plot the experimental data and the model prediction.

Optionally also plot the individual components of the model.

Uses Plopp for plotting: https://scipp.github.io/plopp/

Parameters:

Name Type Description Default
Q_index int | None

Index of the Q value to plot. If None, plot all Q values.

None
plot_components bool

Whether to plot the individual components.

True
add_background bool

Whether to add background components to the sample model components when plotting. Default is True.

True
energy Variable | None

The energy values to use for calculating the model. If None, uses the energy from the experiment.

None
**kwargs dict[str, Any]

Additional keyword arguments passed to plopp for customizing the plot.

{}

Raises:

Type Description
ValueError

If Q_index is out of bounds, or if there is no data to plot, or if there are no Q values available for plotting.

RuntimeError

If not in a Jupyter notebook environment.

TypeError

If plot_components or add_background is not True or False.

Returns:

Type Description
InteractiveFigure

A Plopp InteractiveFigure containing the plot of the data and model.

Source code in src/easydynamics/analysis/analysis.py
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
def plot_data_and_model(
    self,
    Q_index: int | None = None,
    plot_components: bool = True,
    add_background: bool = True,
    energy: sc.Variable | None = None,
    **kwargs: dict[str, Any],
) -> InteractiveFigure:
    """
    Plot the experimental data and the model prediction.

    Optionally also plot the individual components of the model.

    Uses Plopp for plotting: https://scipp.github.io/plopp/

    Parameters
    ----------
    Q_index : int | None, default=None
        Index of the Q value to plot. If None, plot all Q values.
    plot_components : bool, default=True
        Whether to plot the individual components.
    add_background : bool, default=True
        Whether to add background components to the sample model components when plotting.
        Default is True.
    energy : sc.Variable | None, default=None
        The energy values to use for calculating the model. If None, uses the energy from the
        experiment.
    **kwargs : dict[str, Any]
        Additional keyword arguments passed to plopp for customizing the plot.

    Raises
    ------
    ValueError
        If Q_index is out of bounds, or if there is no data to plot, or if there are no Q
        values available for plotting.
    RuntimeError
        If not in a Jupyter notebook environment.
    TypeError
        If plot_components or add_background is not True or False.

    Returns
    -------
    InteractiveFigure
        A Plopp InteractiveFigure containing the plot of the data and model.
    """

    if Q_index is not None:
        Q_index = self._verify_Q_index(Q_index)
        return self.analysis_list[Q_index].plot_data_and_model(
            plot_components=plot_components,
            add_background=add_background,
            energy=energy,
            **kwargs,
        )

    if self.experiment.binned_data is None:
        raise ValueError('No data to plot. Please load data first.')

    if not _in_notebook():
        raise RuntimeError('plot_data() can only be used in a Jupyter notebook environment.')

    if self.Q is None:
        raise ValueError(
            'No Q values available for plotting. Please check the experiment data.'
        )

    if not isinstance(plot_components, bool):
        raise TypeError('plot_components must be True or False.')

    if not isinstance(add_background, bool):
        raise TypeError('add_background must be True or False.')

    if energy is None:
        energy = self.energy

    import plopp as pp

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {'Data': 'none', 'Model': '-'},
        'marker': {'Data': 'o', 'Model': None},
        'color': {'Data': 'black', 'Model': 'red'},
        'markerfacecolor': {'Data': 'none', 'Model': 'none'},
        'keep': 'energy',
    }
    data_and_model = {
        'Data': self.experiment.binned_data,
        'Model': self._create_model_array(energy=energy),
    }

    if plot_components:
        components = self._create_components_dataset(
            add_background=add_background, energy=energy
        )
        for key in components:
            data_and_model[key] = components[key]
            plot_kwargs_defaults['linestyle'][key] = '--'
            plot_kwargs_defaults['marker'][key] = None

    # Overwrite defaults with any user-provided kwargs
    plot_kwargs_defaults.update(kwargs)

    fig = pp.slicer(
        data_and_model,
        **plot_kwargs_defaults,
    )
    for widget in fig.bottom_bar[0].controls.values():
        widget.slider_toggler.value = '-o-'

    return fig

plot_parameters(names=None, **kwargs)

Plot fitted parameters as a function of Q.

Parameters:

Name Type Description Default
names str | list[str] | None

Name(s) of the parameter(s) to plot. If None, plots all parameters.

None
**kwargs dict[str, Any]

Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g., title, linestyle, marker, color).

{}

Raises:

Type Description
TypeError

If names is not a string, list of strings, or None.

ValueError

If any of the specified parameter names are not found in the dataset.

Returns:

Type Description
InteractiveFigure

A Plopp InteractiveFigure containing the plot of the parameters.

Source code in src/easydynamics/analysis/analysis.py
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
def plot_parameters(
    self,
    names: str | list[str] | None = None,
    **kwargs: dict[str, Any],
) -> InteractiveFigure:
    """
    Plot fitted parameters as a function of Q.

    Parameters
    ----------
    names : str | list[str] | None, default=None
        Name(s) of the parameter(s) to plot. If None, plots all parameters.
    **kwargs : dict[str, Any]
        Additional keyword arguments passed to plopp.slicer for customizing the plot (e.g.,
        title, linestyle, marker, color).

    Raises
    ------
    TypeError
        If names is not a string, list of strings, or None.
    ValueError
        If any of the specified parameter names are not found in the dataset.

    Returns
    -------
    InteractiveFigure
        A Plopp InteractiveFigure containing the plot of the parameters.
    """

    ds = self.parameters_to_dataset()

    if not names:
        names = list(ds.keys())

    if isinstance(names, str):
        names = [names]

    if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
        raise TypeError('names must be a string or a list of strings.')

    for name in names:
        if name not in ds:
            raise ValueError(f"Parameter '{name}' not found in dataset.")

    data_to_plot = {name: ds[name] for name in names}
    plot_kwargs_defaults = {
        'linestyle': dict.fromkeys(names, 'none'),
        'marker': dict.fromkeys(names, 'o'),
        'markerfacecolor': dict.fromkeys(names, 'none'),
    }

    plot_kwargs_defaults.update(kwargs)

    import plopp as pp

    return pp.plot(
        data_to_plot,
        **plot_kwargs_defaults,
    )

analysis1d

Analysis1d

Bases: AnalysisBase

For analysing one-dimensional data, i.e. intensity as function of energy for a single Q index.

Is used primarily in the Analysis class, but can also be used on its own for simpler analyses.

Source code in src/easydynamics/analysis/analysis1d.py
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
class Analysis1d(AnalysisBase):
    """
    For analysing one-dimensional data, i.e. intensity as function of energy for a single Q index.

    Is used primarily in the Analysis class, but can also be used on its own for simpler analyses.
    """

    def __init__(
        self,
        display_name: str | None = 'MyAnalysis',
        unique_name: str | None = None,
        experiment: Experiment | None = None,
        sample_model: SampleModel | None = None,
        instrument_model: InstrumentModel | None = None,
        Q_index: int | None = None,
        convolution_settings: ConvolutionSettings | None = None,
        detailed_balance_settings: DetailedBalanceSettings | None = None,
        extra_parameters: Parameter | list[Parameter] | None = None,
    ) -> None:
        """
        Initialize a Analysis1d.

        Parameters
        ----------
        display_name : str | None, default='MyAnalysis'
            Display name of the analysis.
        unique_name : str | None, default=None
            Unique name of the analysis. If None, a unique name is automatically generated.
        experiment : Experiment | None, default=None
            The Experiment associated with this Analysis. If None, a default Experiment is created.
        sample_model : SampleModel | None, default=None
            The SampleModel associated with this Analysis. If None, a default SampleModel is
            created.
        instrument_model : InstrumentModel | None, default=None
            The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
            is created.
        Q_index : int | None, default=None
            The Q index to analyze. If None, the analysis will not be able to calculate or fit
            until a Q index is set.
        convolution_settings : ConvolutionSettings | None, default=None
            The settings for the convolution. If None, default settings will be used.
        detailed_balance_settings : DetailedBalanceSettings | None, default=None
            The settings for detailed balance. If None, default settings will be used.
        extra_parameters : Parameter | list[Parameter] | None, default=None
            Extra parameters to be included in the analysis for advanced users. If None, no extra
            parameters are added.
        """
        super().__init__(
            display_name=display_name,
            unique_name=unique_name,
            experiment=experiment,
            sample_model=sample_model,
            instrument_model=instrument_model,
            convolution_settings=convolution_settings,
            detailed_balance_settings=detailed_balance_settings,
            extra_parameters=extra_parameters,
        )

        self._Q_index = self._verify_Q_index(Q_index)

        if self._Q_index is not None and self.experiment is not None:
            masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index)
            self._masked_energy = masked_energy
        else:
            self._masked_energy = None

        self._fit_result = None
        if self._Q_index is not None:
            self._convolver = self._create_convolver()
        else:
            self._convolver = None

    #############
    # Properties
    #############

    @property
    def Q_index(self) -> int | None:
        """
        Get the Q index associated with this Analysis.

        Returns
        -------
        int | None
            The Q index associated with this Analysis.
        """

        return self._Q_index

    @Q_index.setter
    def Q_index(self, value: int | None) -> None:
        """
        Set the Q index for single Q analysis.

        Parameters
        ----------
        value : int | None
            The Q index.
        """

        self._Q_index = self._verify_Q_index(value)
        self._on_Q_index_changed()

    #############
    # Other methods
    #############

    def calculate(self, energy: sc.Variable | None = None) -> np.ndarray:
        """
        Calculate the model prediction for the chosen Q index.

        Makes sure the convolver is up to date before calculating.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for calculation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The calculated model prediction.
        """
        energy = self._verify_energy(energy)
        self._convolver = self._create_convolver(energy=energy)

        return self._calculate(energy=energy)

    def _calculate(self, energy: sc.Variable | None = None) -> np.ndarray:
        """
        Calculate the model prediction for the chosen Q index.

        Does not check if the convolver is up to date.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for calculation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The calculated model prediction.
        """

        sample_intensity = self._evaluate_sample(energy=energy)

        background_intensity = self._evaluate_background(energy=energy)

        return sample_intensity + background_intensity

    def fit(self) -> FitResults:
        """
        Fit the model to the experimental data for the chosen Q index.

        The energy grid is fixed for the duration of the fit. Convolution objects are created once
        and reused during parameter optimization for performance reasons.

        Raises
        ------
        ValueError
            If no experiment is associated with this Analysis.

        Returns
        -------
        FitResults
            The result of the fit.
        """
        if self._experiment is None:
            raise ValueError('No experiment is associated with this Analysis.')

        # Create convolver once to reuse during fitting
        self._convolver = self._create_convolver()

        fitter = EasyScienceFitter(
            fit_object=self,
            fit_function=self.as_fit_function(),
        )

        x, y, weights, _ = self.experiment._extract_x_y_weights_only_finite(  # noqa: SLF001
            Q_index=self._require_Q_index()
        )
        fit_result = fitter.fit(x=x, y=y, weights=weights)

        self._fit_result = fit_result

        return fit_result

    def as_fit_function(
        self,
        _x: np.ndarray | sc.Variable | None = None,
        **kwargs: dict[str, Any],  # noqa: ARG002
    ) -> callable:
        """
        Return self._calculate as a fit function.

        The EasyScience fitter requires x as input, but self._calculate() already uses the correct
        energy from the experiment. So we ignore the x input and just return the calculated model.

        Parameters
        ----------
        _x : np.ndarray | sc.Variable | None, default=None
            Ignored. The energy grid is taken from the experiment.
        **kwargs : dict[str, Any]
            Ignored. Included for compatibility with the EasyScience fitter.

        Returns
        -------
        callable
            A function that can be used as a fit function in the EasyScience fitter, which returns
            the calculated model.
        """

        def fit_function(
            _x: np.ndarray | sc.Variable | None = None,
            **kwargs: dict[str, Any],  # noqa: ARG001
        ) -> np.ndarray:
            """Fit function."""
            return self._calculate()

        return fit_function

    def get_all_variables(self) -> list[DescriptorNumber]:
        """
        Get all variables used in the analysis.

        Returns
        -------
        list[DescriptorNumber]
            A list of all variables.
        """
        variables = self.sample_model.get_all_variables(Q_index=self.Q_index)

        variables.extend(self.instrument_model.get_all_variables(Q_index=self.Q_index))

        if self._extra_parameters:
            variables.extend(self._extra_parameters)

        return variables

    def plot_data_and_model(
        self,
        plot_components: bool = True,
        add_background: bool = True,
        energy: sc.Variable | None = None,
        **kwargs: dict[str, Any],
    ) -> InteractiveFigure:
        """
        Plot the experimental data and the model prediction for the chosen Q index. Optionally also
        plot the individual components of the model.

        Uses Plopp for plotting: https://scipp.github.io/plopp/

        Parameters
        ----------
        plot_components : bool, default=True
            Whether to plot the individual components of the model.
        add_background : bool, default=True
            Whether to add the background to the model prediction when plotting individual
            components.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for plotting. If None, the energy grid from the experiment
            is used.
        **kwargs : dict[str, Any]
            Keyword arguments to pass to the plotting function.

        Raises
        ------
        ValueError
            If no data is available to plot.

        Returns
        -------
        InteractiveFigure
            A plot of the data and model.
        """
        import plopp as pp

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {'Data': 'none', 'Model': '-'},
            'marker': {'Data': 'o', 'Model': 'none'},
            'color': {'Data': 'black', 'Model': 'red'},
            'markerfacecolor': {'Data': 'none', 'Model': 'none'},
        }

        if self.experiment.binned_data is None:
            raise ValueError('No data to plot. Please load data first.')

        energy = self._verify_energy(energy)
        if energy is None:
            energy = self._masked_energy

        # Create a dataset containing the data, model, and individual
        # components for plotting.
        data_and_model = {
            'Data': self.experiment.binned_data['Q', self.Q_index],
            'Model': self._create_model_array(energy=energy),
        }

        if plot_components:
            components = self._create_components_dataset_single_Q(
                add_background=add_background, energy=energy
            )
            for comp_name in components:
                data_and_model[comp_name] = components[comp_name]
                plot_kwargs_defaults['linestyle'][comp_name] = '--'
                plot_kwargs_defaults['marker'][comp_name] = None

        # Overwrite defaults with any user-provided kwargs
        plot_kwargs_defaults.update(kwargs)

        return pp.plot(
            data_and_model,
            **plot_kwargs_defaults,
        )

    def fix_energy_offset(self) -> None:
        """Fix the energy offset parameter for the current Q index."""
        self.instrument_model.fix_energy_offset(Q_index=self._require_Q_index())

    def free_energy_offset(self) -> None:
        """Free the energy offset parameter for the current Q index."""
        self.instrument_model.free_energy_offset(Q_index=self._require_Q_index())

    #############
    # Private methods: small utilities
    #############

    def _require_Q_index(self) -> int:
        """
        Get the Q index, ensuring it is set.

        Raises a ValueError if the Q index is not set.

        Raises
        ------
        ValueError
            If the Q index is not set.

        Returns
        -------
        int
            The Q index.
        """
        if self._Q_index is None:
            raise ValueError('Q_index must be set.')
        return self._Q_index

    def _on_Q_index_changed(self) -> None:
        """
        Handle changes to the Q index.

        This method is called whenever the Q index is changed. It updates the Convolution object
        for the new Q index and the masked energy from the experiment for the new Q index.
        """
        masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index)
        self._masked_energy = masked_energy
        self._convolver = self._create_convolver()

    def _verify_energy(self, energy: sc.Variable | None) -> sc.Variable | None:
        """
        Verify that the provided energy is the correct type.

        Parameters
        ----------
        energy : sc.Variable | None
            The energy to verify.

        Raises
        ------
        TypeError
            If energy is not a sc.Variable or None.

        Returns
        -------
        sc.Variable | None
            The verified energy, or None if no energy is provided.
        """

        if energy is not None and not isinstance(energy, sc.Variable):
            raise TypeError(f'Energy must be a sc.Variable or None. Got {type(energy)}.')
        return energy

    def _calculate_energy_with_offset(
        self,
        energy: sc.Variable,
        energy_offset: Parameter,
    ) -> sc.Variable:
        """
        Calculate the energy grid with the energy offset applied.

        Parameters
        ----------
        energy : sc.Variable
            The energy grid to apply the offset to.
        energy_offset : Parameter
            The energy offset to apply.

        Raises
        ------
        sc.UnitError
            If the energy and energy offset have incompatible units.

        Returns
        -------
        sc.Variable
            The energy grid with the offset applied.
        """

        if energy.unit != energy_offset.unit:
            try:
                energy_offset.convert_unit(str(energy.unit))
            except Exception as e:
                raise sc.UnitError(
                    f'Energy and energy offset must have compatible units. '
                    f'Got {energy.unit} and {energy_offset.unit}.'
                ) from e

        energy_with_offset = energy.copy(deep=True)
        energy_with_offset.values -= energy_offset.value
        return energy_with_offset

    #############
    # Private methods: evaluation
    #############

    def _evaluate_components(
        self,
        components: ComponentCollection | ModelComponent,
        convolver: Convolution | None = None,
        convolve: bool = True,
        energy: sc.Variable | None = None,
        apply_detailed_balance: bool = False,
    ) -> np.ndarray:
        """
        Calculate the contribution of a set of components, optionally convolving with the
        resolution.

        If convolve is True and a Convolution object is provided (for full model evaluation), we
        use it to perform the convolution of the components with the resolution. If convolve is
        True but no Convolution object is provided, create a new Convolution object for the given
        components (for individual components). If convolve is False, evaluate the components
        directly without convolution (for background).

        Parameters
        ----------
        components : ComponentCollection | ModelComponent
            The components to evaluate.
        convolver : Convolution | None, default=None
            An optional Convolution object to use for convolution. If None, a new Convolution
            object will be created if convolve is True.
        convolve : bool, default=True
            Whether to perform convolution with the resolution.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.
        apply_detailed_balance : bool, default=False
            Whether to apply detailed balance correction.


        Returns
        -------
        np.ndarray
            The evaluated contribution of the components.
        """

        Q_index = self._require_Q_index()
        if energy is None:
            energy = self._masked_energy

        energy_offset = self.instrument_model.get_energy_offset(Q_index)
        energy_with_offset = self._calculate_energy_with_offset(
            energy=energy,
            energy_offset=energy_offset,
        )

        # If there are no components, return zero
        if isinstance(components, ComponentCollection) and components.is_empty:
            return np.zeros_like(energy.values)

        # If a convolver is provided, we use it. This allows reusing the
        # same convolver for multiple evaluations during fitting for
        # performance reasons.
        if convolver is not None:
            return convolver.convolution()

        # No convolution can happen for multiple reasons:
        #   Case 1: convolve=False, used for evaluating background components, where we don't want
        #       to convolve with the resolution. In this case, apply_detailed_balance is False,
        #       and we evaluate the components without DBF regardles of the settings
        #   Case 2: convolve=True but there is no resolution_model. In this case,
        #       apply_detailed_balance is True. We apply DBF if temperature is provided and
        #       the settings say to use detailed balance.

        resolution = self.instrument_model.resolution_model.get_component_collection(Q_index)
        if not convolve or resolution.is_empty:
            result_no_convolution = components.evaluate(energy_with_offset)
            if (
                apply_detailed_balance
                and self.temperature is not None
                and self.detailed_balance_settings.use_detailed_balance
            ):
                DBF = detailed_balance_factor(
                    energy=energy_with_offset,
                    temperature=self.temperature,
                    divide_by_temperature=self.detailed_balance_settings.normalize_detailed_balance,
                    energy_unit=self.unit,
                )
                result_no_convolution *= DBF
            return result_no_convolution

        # If no convolver is provided, we create a new one. This is for
        # evaluating individual components for plotting, where
        # performance is not important. We already handled the case of
        # background components above, so we know that this is for sample components,
        # where detailed balance settings should be applied.

        conv = Convolution(
            energy=energy,
            sample_components=components,
            resolution_components=resolution,
            energy_offset=energy_offset,
            convolution_settings=self.convolution_settings,
            temperature=self.temperature,
            detailed_balance_settings=self.detailed_balance_settings,
        )
        return conv.convolution()

    def _evaluate_sample(
        self,
        energy: sc.Variable | None = None,
    ) -> np.ndarray:
        """
        Evaluate the sample contribution for a given Q index.

        Assumes that self._convolver is up to date.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The evaluated sample contribution.
        """
        Q_index = self._require_Q_index()
        components = self.sample_model.get_component_collection(Q_index=Q_index)
        return self._evaluate_components(
            components=components,
            convolver=self._convolver,
            convolve=True,
            energy=energy,
            apply_detailed_balance=True,
        )

    def _evaluate_sample_component(
        self,
        component: ModelComponent,
        energy: sc.Variable | None = None,
    ) -> np.ndarray:
        """
        Evaluate a single sample component for the chosen Q index.

        Parameters
        ----------
        component : ModelComponent
            The sample component to evaluate.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The evaluated sample component contribution.
        """
        return self._evaluate_components(
            components=component,
            convolver=None,
            convolve=True,
            energy=energy,
            apply_detailed_balance=True,
        )

    def _evaluate_background(self, energy: sc.Variable | None = None) -> np.ndarray:
        """
        Evaluate the background contribution for the chosen Q index.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The evaluated background contribution.
        """
        Q_index = self._require_Q_index()
        background_components = self.instrument_model.background_model.get_component_collection(
            Q_index=Q_index
        )
        return self._evaluate_components(
            components=background_components,
            convolver=None,
            convolve=False,
            energy=energy,
            apply_detailed_balance=False,
        )

    def _evaluate_background_component(
        self,
        component: ModelComponent,
        energy: sc.Variable | None = None,
    ) -> np.ndarray:
        """
        Evaluate a single background component for the chosen Q index.

        Parameters
        ----------
        component : ModelComponent
            The background component to evaluate.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        np.ndarray
            The evaluated background component contribution.
        """

        return self._evaluate_components(
            components=component,
            convolver=None,
            convolve=False,
            energy=energy,
            apply_detailed_balance=False,
        )

    def _create_convolver(
        self,
        energy: sc.Variable | None = None,
    ) -> Convolution | None:
        """
        Initialize and return a Convolution object for the chosen Q index. If the necessary
        components for convolution are not available, return None.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for convolution. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        Convolution | None
            The initialized Convolution object or None if not available.
        """
        Q_index = self._require_Q_index()

        if energy is None:
            energy = self._masked_energy

        sample_components = self.sample_model.get_component_collection(Q_index)
        if sample_components.is_empty:
            return None

        resolution_components = self.instrument_model.resolution_model.get_component_collection(
            Q_index
        )
        if resolution_components.is_empty:
            return None

        return Convolution(
            energy=energy,
            sample_components=sample_components,
            resolution_components=resolution_components,
            energy_offset=self.instrument_model.get_energy_offset(Q_index),
            convolution_settings=self.convolution_settings,
            temperature=self.temperature,
            detailed_balance_settings=self.detailed_balance_settings,
        )

    #############
    # Private methods: create scipp arrays for plotting
    #############

    def _create_component_scipp_array(
        self,
        component: ModelComponent,
        background: np.ndarray | None = None,
        energy: sc.Variable | None = None,
    ) -> sc.DataArray:
        """
        Create a scipp DataArray for a single component.

        Adds the background if it is not None.

        Parameters
        ----------
        component : ModelComponent
            The component to evaluate.
        background : np.ndarray | None, default=None
            Optional background to add to the component.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        sc.DataArray
            The model calculation of the component.
        """

        values = self._evaluate_sample_component(component=component, energy=energy)
        if background is not None:
            values += background
        return self._to_scipp_array(values=values, energy=energy)

    def _create_background_component_scipp_array(
        self,
        component: ModelComponent,
        energy: sc.Variable | None = None,
    ) -> sc.DataArray:
        """
        Create a scipp DataArray for a single background component.

        Parameters
        ----------
        component : ModelComponent
            The component to evaluate.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        sc.DataArray
            The model calculation of the component.
        """

        values = self._evaluate_background_component(
            component=component,
            energy=energy,
        )
        return self._to_scipp_array(values=values, energy=energy)

    def _create_model_array(self, energy: sc.Variable | None = None) -> sc.DataArray:
        """
        Create a scipp DataArray for the full sample model including background.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        sc.DataArray
            The model calculation of the full sample model.
        """
        values = self.calculate(energy=energy)
        return self._to_scipp_array(values=values, energy=energy)

    def _create_components_dataset_single_Q(
        self,
        add_background: bool = True,
        energy: sc.Variable | None = None,
    ) -> dict[str, sc.DataArray]:
        """
        Create sc.DataArrays for all sample and background components.

        Parameters
        ----------
        add_background : bool, default=True
            Whether to add background components.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for evaluation. If None, the energy grid from the
            experiment is used.

        Returns
        -------
        dict[str, sc.DataArray]
            A dictionary of component names to their corresponding sc.DataArrays.
        """
        scipp_arrays = {}
        sample_components = self.sample_model.get_component_collection(
            Q_index=self.Q_index
        ).components

        background_components = self.instrument_model.background_model.get_component_collection(
            Q_index=self.Q_index
        ).components

        if energy is None:
            energy = self._masked_energy

        background = self._evaluate_background(energy=energy) if add_background else None

        for component in sample_components:
            scipp_arrays[component.display_name] = self._create_component_scipp_array(
                component=component, background=background, energy=energy
            )
        for component in background_components:
            scipp_arrays[component.display_name] = self._create_background_component_scipp_array(
                component=component, energy=energy
            )
        return sc.Dataset(scipp_arrays)

    def _to_scipp_array(
        self,
        values: np.ndarray,
        energy: sc.Variable | None = None,
    ) -> sc.DataArray:
        """
        Convert a numpy array of values to a sc.DataArray with the correct coordinates for energy
        and Q.

        Parameters
        ----------
        values : np.ndarray
            The values to convert.
        energy : sc.Variable | None, default=None
            Optional energy grid to use for the energy coordinate. If None, the energy grid from
            the experiment is used.

        Returns
        -------
        sc.DataArray
            The converted sc.DataArray.
        """

        if energy is None:
            energy = self._masked_energy
        return sc.DataArray(
            data=sc.array(dims=['energy'], values=values),
            coords={
                'energy': energy,
                'Q': self.Q[self.Q_index],
            },
        )

Q_index property writable

Get the Q index associated with this Analysis.

Returns:

Type Description
int | None

The Q index associated with this Analysis.

__init__(display_name='MyAnalysis', unique_name=None, experiment=None, sample_model=None, instrument_model=None, Q_index=None, convolution_settings=None, detailed_balance_settings=None, extra_parameters=None)

Initialize a Analysis1d.

Parameters:

Name Type Description Default
display_name str | None

Display name of the analysis.

'MyAnalysis'
unique_name str | None

Unique name of the analysis. If None, a unique name is automatically generated.

None
experiment Experiment | None

The Experiment associated with this Analysis. If None, a default Experiment is created.

None
sample_model SampleModel | None

The SampleModel associated with this Analysis. If None, a default SampleModel is created.

None
instrument_model InstrumentModel | None

The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created.

None
Q_index int | None

The Q index to analyze. If None, the analysis will not be able to calculate or fit until a Q index is set.

None
convolution_settings ConvolutionSettings | None

The settings for the convolution. If None, default settings will be used.

None
detailed_balance_settings DetailedBalanceSettings | None

The settings for detailed balance. If None, default settings will be used.

None
extra_parameters Parameter | list[Parameter] | None

Extra parameters to be included in the analysis for advanced users. If None, no extra parameters are added.

None
Source code in src/easydynamics/analysis/analysis1d.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def __init__(
    self,
    display_name: str | None = 'MyAnalysis',
    unique_name: str | None = None,
    experiment: Experiment | None = None,
    sample_model: SampleModel | None = None,
    instrument_model: InstrumentModel | None = None,
    Q_index: int | None = None,
    convolution_settings: ConvolutionSettings | None = None,
    detailed_balance_settings: DetailedBalanceSettings | None = None,
    extra_parameters: Parameter | list[Parameter] | None = None,
) -> None:
    """
    Initialize a Analysis1d.

    Parameters
    ----------
    display_name : str | None, default='MyAnalysis'
        Display name of the analysis.
    unique_name : str | None, default=None
        Unique name of the analysis. If None, a unique name is automatically generated.
    experiment : Experiment | None, default=None
        The Experiment associated with this Analysis. If None, a default Experiment is created.
    sample_model : SampleModel | None, default=None
        The SampleModel associated with this Analysis. If None, a default SampleModel is
        created.
    instrument_model : InstrumentModel | None, default=None
        The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
        is created.
    Q_index : int | None, default=None
        The Q index to analyze. If None, the analysis will not be able to calculate or fit
        until a Q index is set.
    convolution_settings : ConvolutionSettings | None, default=None
        The settings for the convolution. If None, default settings will be used.
    detailed_balance_settings : DetailedBalanceSettings | None, default=None
        The settings for detailed balance. If None, default settings will be used.
    extra_parameters : Parameter | list[Parameter] | None, default=None
        Extra parameters to be included in the analysis for advanced users. If None, no extra
        parameters are added.
    """
    super().__init__(
        display_name=display_name,
        unique_name=unique_name,
        experiment=experiment,
        sample_model=sample_model,
        instrument_model=instrument_model,
        convolution_settings=convolution_settings,
        detailed_balance_settings=detailed_balance_settings,
        extra_parameters=extra_parameters,
    )

    self._Q_index = self._verify_Q_index(Q_index)

    if self._Q_index is not None and self.experiment is not None:
        masked_energy = self.experiment.get_masked_energy(Q_index=self._Q_index)
        self._masked_energy = masked_energy
    else:
        self._masked_energy = None

    self._fit_result = None
    if self._Q_index is not None:
        self._convolver = self._create_convolver()
    else:
        self._convolver = None

as_fit_function(_x=None, **kwargs)

Return self._calculate as a fit function.

The EasyScience fitter requires x as input, but self._calculate() already uses the correct energy from the experiment. So we ignore the x input and just return the calculated model.

Parameters:

Name Type Description Default
_x ndarray | Variable | None

Ignored. The energy grid is taken from the experiment.

None
**kwargs dict[str, Any]

Ignored. Included for compatibility with the EasyScience fitter.

{}

Returns:

Type Description
callable

A function that can be used as a fit function in the EasyScience fitter, which returns the calculated model.

Source code in src/easydynamics/analysis/analysis1d.py
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
def as_fit_function(
    self,
    _x: np.ndarray | sc.Variable | None = None,
    **kwargs: dict[str, Any],  # noqa: ARG002
) -> callable:
    """
    Return self._calculate as a fit function.

    The EasyScience fitter requires x as input, but self._calculate() already uses the correct
    energy from the experiment. So we ignore the x input and just return the calculated model.

    Parameters
    ----------
    _x : np.ndarray | sc.Variable | None, default=None
        Ignored. The energy grid is taken from the experiment.
    **kwargs : dict[str, Any]
        Ignored. Included for compatibility with the EasyScience fitter.

    Returns
    -------
    callable
        A function that can be used as a fit function in the EasyScience fitter, which returns
        the calculated model.
    """

    def fit_function(
        _x: np.ndarray | sc.Variable | None = None,
        **kwargs: dict[str, Any],  # noqa: ARG001
    ) -> np.ndarray:
        """Fit function."""
        return self._calculate()

    return fit_function

calculate(energy=None)

Calculate the model prediction for the chosen Q index.

Makes sure the convolver is up to date before calculating.

Parameters:

Name Type Description Default
energy Variable | None

Optional energy grid to use for calculation. If None, the energy grid from the experiment is used.

None

Returns:

Type Description
ndarray

The calculated model prediction.

Source code in src/easydynamics/analysis/analysis1d.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
def calculate(self, energy: sc.Variable | None = None) -> np.ndarray:
    """
    Calculate the model prediction for the chosen Q index.

    Makes sure the convolver is up to date before calculating.

    Parameters
    ----------
    energy : sc.Variable | None, default=None
        Optional energy grid to use for calculation. If None, the energy grid from the
        experiment is used.

    Returns
    -------
    np.ndarray
        The calculated model prediction.
    """
    energy = self._verify_energy(energy)
    self._convolver = self._create_convolver(energy=energy)

    return self._calculate(energy=energy)

fit()

Fit the model to the experimental data for the chosen Q index.

The energy grid is fixed for the duration of the fit. Convolution objects are created once and reused during parameter optimization for performance reasons.

Raises:

Type Description
ValueError

If no experiment is associated with this Analysis.

Returns:

Type Description
FitResults

The result of the fit.

Source code in src/easydynamics/analysis/analysis1d.py
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
def fit(self) -> FitResults:
    """
    Fit the model to the experimental data for the chosen Q index.

    The energy grid is fixed for the duration of the fit. Convolution objects are created once
    and reused during parameter optimization for performance reasons.

    Raises
    ------
    ValueError
        If no experiment is associated with this Analysis.

    Returns
    -------
    FitResults
        The result of the fit.
    """
    if self._experiment is None:
        raise ValueError('No experiment is associated with this Analysis.')

    # Create convolver once to reuse during fitting
    self._convolver = self._create_convolver()

    fitter = EasyScienceFitter(
        fit_object=self,
        fit_function=self.as_fit_function(),
    )

    x, y, weights, _ = self.experiment._extract_x_y_weights_only_finite(  # noqa: SLF001
        Q_index=self._require_Q_index()
    )
    fit_result = fitter.fit(x=x, y=y, weights=weights)

    self._fit_result = fit_result

    return fit_result

fix_energy_offset()

Fix the energy offset parameter for the current Q index.

Source code in src/easydynamics/analysis/analysis1d.py
345
346
347
def fix_energy_offset(self) -> None:
    """Fix the energy offset parameter for the current Q index."""
    self.instrument_model.fix_energy_offset(Q_index=self._require_Q_index())

free_energy_offset()

Free the energy offset parameter for the current Q index.

Source code in src/easydynamics/analysis/analysis1d.py
349
350
351
def free_energy_offset(self) -> None:
    """Free the energy offset parameter for the current Q index."""
    self.instrument_model.free_energy_offset(Q_index=self._require_Q_index())

get_all_variables()

Get all variables used in the analysis.

Returns:

Type Description
list[DescriptorNumber]

A list of all variables.

Source code in src/easydynamics/analysis/analysis1d.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
def get_all_variables(self) -> list[DescriptorNumber]:
    """
    Get all variables used in the analysis.

    Returns
    -------
    list[DescriptorNumber]
        A list of all variables.
    """
    variables = self.sample_model.get_all_variables(Q_index=self.Q_index)

    variables.extend(self.instrument_model.get_all_variables(Q_index=self.Q_index))

    if self._extra_parameters:
        variables.extend(self._extra_parameters)

    return variables

plot_data_and_model(plot_components=True, add_background=True, energy=None, **kwargs)

Plot the experimental data and the model prediction for the chosen Q index. Optionally also plot the individual components of the model.

Uses Plopp for plotting: https://scipp.github.io/plopp/

Parameters:

Name Type Description Default
plot_components bool

Whether to plot the individual components of the model.

True
add_background bool

Whether to add the background to the model prediction when plotting individual components.

True
energy Variable | None

Optional energy grid to use for plotting. If None, the energy grid from the experiment is used.

None
**kwargs dict[str, Any]

Keyword arguments to pass to the plotting function.

{}

Raises:

Type Description
ValueError

If no data is available to plot.

Returns:

Type Description
InteractiveFigure

A plot of the data and model.

Source code in src/easydynamics/analysis/analysis1d.py
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
def plot_data_and_model(
    self,
    plot_components: bool = True,
    add_background: bool = True,
    energy: sc.Variable | None = None,
    **kwargs: dict[str, Any],
) -> InteractiveFigure:
    """
    Plot the experimental data and the model prediction for the chosen Q index. Optionally also
    plot the individual components of the model.

    Uses Plopp for plotting: https://scipp.github.io/plopp/

    Parameters
    ----------
    plot_components : bool, default=True
        Whether to plot the individual components of the model.
    add_background : bool, default=True
        Whether to add the background to the model prediction when plotting individual
        components.
    energy : sc.Variable | None, default=None
        Optional energy grid to use for plotting. If None, the energy grid from the experiment
        is used.
    **kwargs : dict[str, Any]
        Keyword arguments to pass to the plotting function.

    Raises
    ------
    ValueError
        If no data is available to plot.

    Returns
    -------
    InteractiveFigure
        A plot of the data and model.
    """
    import plopp as pp

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {'Data': 'none', 'Model': '-'},
        'marker': {'Data': 'o', 'Model': 'none'},
        'color': {'Data': 'black', 'Model': 'red'},
        'markerfacecolor': {'Data': 'none', 'Model': 'none'},
    }

    if self.experiment.binned_data is None:
        raise ValueError('No data to plot. Please load data first.')

    energy = self._verify_energy(energy)
    if energy is None:
        energy = self._masked_energy

    # Create a dataset containing the data, model, and individual
    # components for plotting.
    data_and_model = {
        'Data': self.experiment.binned_data['Q', self.Q_index],
        'Model': self._create_model_array(energy=energy),
    }

    if plot_components:
        components = self._create_components_dataset_single_Q(
            add_background=add_background, energy=energy
        )
        for comp_name in components:
            data_and_model[comp_name] = components[comp_name]
            plot_kwargs_defaults['linestyle'][comp_name] = '--'
            plot_kwargs_defaults['marker'][comp_name] = None

    # Overwrite defaults with any user-provided kwargs
    plot_kwargs_defaults.update(kwargs)

    return pp.plot(
        data_and_model,
        **plot_kwargs_defaults,
    )

analysis_base

AnalysisBase

Bases: EasyDynamicsModelBase

Base class for analysis in EasyDynamics.

This class is not meant to be used directly.

An Analysis consists of an Experiment, a SampleModel, and an InstrumentModel. The Experiment contains the data to be fitted, the SampleModel contains the model for the sample, and the InstrumentModel contains the model for the instrument, including background and resolution

Source code in src/easydynamics/analysis/analysis_base.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
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
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
class AnalysisBase(EasyDynamicsModelBase):
    """
    Base class for analysis in EasyDynamics.

    This class is not meant to be used directly.

    An Analysis consists of an Experiment, a SampleModel, and an InstrumentModel. The Experiment
    contains the data to be fitted, the SampleModel contains the model for the sample, and the
    InstrumentModel contains the model for the instrument, including background and resolution
    """

    def __init__(
        self,
        display_name: str | None = 'MyAnalysis',
        unique_name: str | None = None,
        experiment: Experiment | None = None,
        sample_model: SampleModel | None = None,
        instrument_model: InstrumentModel | None = None,
        convolution_settings: ConvolutionSettings | None = None,
        detailed_balance_settings: DetailedBalanceSettings | None = None,
        extra_parameters: Parameter | list[Parameter] | None = None,
    ) -> None:
        """
        Initialize the AnalysisBase.

        Parameters
        ----------
        display_name : str | None, default='MyAnalysis'
            Display name of the analysis.
        unique_name : str | None, default=None
            Unique name of the analysis. If None, a unique name is automatically generated. By
            default, None.
        experiment : Experiment | None, default=None
            The Experiment associated with this Analysis. If None, a default Experiment is created.
        sample_model : SampleModel | None, default=None
            The SampleModel associated with this Analysis. If None, a default SampleModel is
            created.
        instrument_model : InstrumentModel | None, default=None
            The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
            is created.
        convolution_settings : ConvolutionSettings | None, default=None
             The settings for the convolution. If None, default settings will be used.
        detailed_balance_settings : DetailedBalanceSettings | None, default=None
            The settings for detailed balance. If None, default settings will be used.
        extra_parameters : Parameter | list[Parameter] | None, default=None
            Extra parameters to be included in the analysis for advanced users. If None, no extra
            parameters are added.

        Raises
        ------
        TypeError
            If experiment is not an Experiment or None or if sample_model is not a SampleModel or
            None or if instrument_model is not an InstrumentModel or None or if
            convolution_settings is not a ConvolutionSettings or None or if
            detailed_balance_settings is not a DetailedBalanceSettings or None or if
            extra_parameters is not a Parameter, a list of Parameters, or None.
        """

        super().__init__(display_name=display_name, unique_name=unique_name)

        if experiment is None:
            self._experiment = Experiment()
        elif isinstance(experiment, Experiment):
            self._experiment = experiment
        else:
            raise TypeError('experiment must be an instance of Experiment or None.')

        if sample_model is None:
            self._sample_model = SampleModel()
        elif isinstance(sample_model, SampleModel):
            self._sample_model = sample_model
        else:
            raise TypeError('sample_model must be an instance of SampleModel or None.')

        if instrument_model is None:
            self._instrument_model = InstrumentModel()
        elif isinstance(instrument_model, InstrumentModel):
            self._instrument_model = instrument_model
        else:
            raise TypeError('instrument_model must be an instance of InstrumentModel or None.')

        if convolution_settings is None:
            self.convolution_settings = ConvolutionSettings()
        elif isinstance(convolution_settings, ConvolutionSettings):
            self.convolution_settings = convolution_settings
        else:
            raise TypeError(
                'convolution_settings must be an instance of ConvolutionSettings or None.'
            )

        if extra_parameters is not None:
            if isinstance(extra_parameters, Parameter):
                self._extra_parameters = [extra_parameters]
            elif isinstance(extra_parameters, list) and all(
                isinstance(p, Parameter) for p in extra_parameters
            ):
                self._extra_parameters = extra_parameters
            else:
                raise TypeError('extra_parameters must be a Parameter or a list of Parameters.')
        else:
            self._extra_parameters = []

        if detailed_balance_settings is None:
            self._detailed_balance_settings = DetailedBalanceSettings()
        elif isinstance(detailed_balance_settings, DetailedBalanceSettings):
            self._detailed_balance_settings = detailed_balance_settings
        else:
            raise TypeError(
                'detailed_balance_settings must be an instance of DetailedBalanceSettings or None.'
            )

        self._on_experiment_changed()

    #############
    # Properties
    #############

    @property
    def experiment(self) -> Experiment:
        """
        Get the Experiment associated with this Analysis.

        Returns
        -------
        Experiment
            The Experiment associated with this Analysis.
        """

        return self._experiment

    @experiment.setter
    def experiment(self, value: Experiment) -> None:
        """
        Set the Experiment for this Analysis.

        Parameters
        ----------
        value : Experiment
            The Experiment to set for this Analysis.

        Raises
        ------
        TypeError
            If value is not an Experiment.
        """

        if not isinstance(value, Experiment):
            raise TypeError('experiment must be an instance of Experiment')
        self._experiment = value
        self._on_experiment_changed()

    @property
    def sample_model(self) -> SampleModel:
        """
        Get the SampleModel associated with this Analysis.

        Returns
        -------
        SampleModel
            The SampleModel associated with this Analysis.
        """

        return self._sample_model

    @sample_model.setter
    def sample_model(self, value: SampleModel) -> None:
        """
        Set the SampleModel for this Analysis.

        Parameters
        ----------
        value : SampleModel
            The SampleModel to set for this Analysis.

        Raises
        ------
        TypeError
            If value is not a SampleModel.
        """
        if not isinstance(value, SampleModel):
            raise TypeError('sample_model must be an instance of SampleModel')
        self._sample_model = value
        self._on_sample_model_changed()

    @property
    def instrument_model(self) -> InstrumentModel:
        """
        Get the InstrumentModel associated with this Analysis.

        Returns
        -------
        InstrumentModel
            The InstrumentModel associated with this Analysis.
        """
        return self._instrument_model

    @instrument_model.setter
    def instrument_model(self, value: InstrumentModel) -> None:
        """
        Set the InstrumentModel for this Analysis.

        Parameters
        ----------
        value : InstrumentModel
            The InstrumentModel to set for this Analysis.

        Raises
        ------
        TypeError
            If value is not an InstrumentModel.
        """
        if not isinstance(value, InstrumentModel):
            raise TypeError('instrument_model must be an instance of InstrumentModel')
        self._instrument_model = value
        self._on_instrument_model_changed()

    @property
    def Q(self) -> sc.Variable | None:
        """
        Get the Q values from the associated Experiment, if available.

        Returns
        -------
        sc.Variable | None
            The Q values from the associated Experiment, if available, and None if not.
        """
        return self.experiment.Q

    @Q.setter
    def Q(self, _value: sc.Variable) -> None:
        """
        Q cannot be set, as it is a read-only property derived from the Experiment.

        Parameters
        ----------
        _value : sc.Variable
            The Q values to set. This argument is ignored, as Q is a read-only property.

        Raises
        ------
        AttributeError
            If trying to set Q.
        """
        raise AttributeError('Q is a read-only property derived from the Experiment.')

    @property
    def energy(self) -> sc.Variable | None:
        """
        Get the energy values from the associated Experiment, if available.

        Returns
        -------
        sc.Variable | None
            The energy values from the associated.
        """

        return self.experiment.energy

    @energy.setter
    def energy(self, _value: sc.Variable) -> None:
        """
        Energy cannot be set, as it is a read-only property derived from the Experiment.

        Parameters
        ----------
        _value : sc.Variable
            The energy values to set. This argument is ignored, as energy is a read-only property.

        Raises
        ------
        AttributeError
            If trying to set energy.
        """

        raise AttributeError('energy is a read-only property derived from the Experiment.')

    @property
    def temperature(self) -> Parameter | None:
        """
        Get the temperature from the associated SampleModel, if available.

        Returns
        -------
        Parameter | None
            The temperature from the associated SampleModel, if available, and None if not.
        """

        return self.sample_model.temperature

    @temperature.setter
    def temperature(self, _value: np.ndarray | Parameter) -> None:
        """
        Temperature cannot be set, as it is a read-only property derived from the SampleModel.

        Parameters
        ----------
        _value : np.ndarray | Parameter
            The temperature to set. This argument is ignored, as temperature is a read-only
            property.

        Raises
        ------
        AttributeError
            If trying to set temperature.
        """

        raise AttributeError('temperature is a read-only property derived from the SampleModel.')

    @property
    def convolution_settings(self) -> ConvolutionSettings:
        """
        Get the convolution settings for this Analysis.

        Returns
        -------
        ConvolutionSettings
            The convolution settings for this Analysis.
        """
        return self._convolution_settings

    @convolution_settings.setter
    def convolution_settings(self, value: ConvolutionSettings) -> None:
        """
        Set the convolution settings for this Analysis.

        Parameters
        ----------
        value : ConvolutionSettings
            The convolution settings to set for this Analysis.

        Raises
        ------
        TypeError
            If value is not an instance of ConvolutionSettings.
        """
        if not isinstance(value, ConvolutionSettings):
            raise TypeError('convolution_settings must be an instance of ConvolutionSettings.')
        self._convolution_settings = value
        self._on_convolution_settings_changed()

    @property
    def detailed_balance_settings(self) -> DetailedBalanceSettings:
        """
        Get the DetailedBalanceSettings of the SampleModel.

        Returns
        -------
        DetailedBalanceSettings
            The DetailedBalanceSettings of the SampleModel.
        """
        return self._detailed_balance_settings

    @detailed_balance_settings.setter
    def detailed_balance_settings(self, value: DetailedBalanceSettings) -> None:
        """
        Set the DetailedBalanceSettings of the SampleModel.

        Parameters
        ----------
        value : DetailedBalanceSettings
            The DetailedBalanceSettings to set.

        Raises
        ------
        TypeError
            If value is not a DetailedBalanceSettings.
        """
        if not isinstance(value, DetailedBalanceSettings):
            raise TypeError('detailed_balance_settings must be a DetailedBalanceSettings')
        self._detailed_balance_settings = value

    @property
    def extra_parameters(self) -> list[Parameter]:
        """
        Get the extra parameters included in this Analysis.

        Returns
        -------
        list[Parameter]
            The extra parameters included in this Analysis.
        """
        return self._extra_parameters

    @extra_parameters.setter
    def extra_parameters(self, value: Parameter | list[Parameter]) -> None:
        """
        Set the extra parameters for this Analysis.

        Parameters
        ----------
        value : Parameter | list[Parameter]
            The extra parameters to include in this Analysis.

        Raises
        ------
        TypeError
            If value is not a Parameter, a list of Parameters, or None.
        """
        if isinstance(value, Parameter):
            self._extra_parameters = [value]
        elif isinstance(value, list) and all(isinstance(p, Parameter) for p in value):
            self._extra_parameters = value
        elif value is None:
            self._extra_parameters = []
        else:
            raise TypeError('extra_parameters must be a Parameter, a list of Parameters, or None.')

    #############
    # Other methods
    #############

    def normalize_resolution(self) -> None:
        """
        Normalize the resolution in the InstrumentModel to ensure that it integrates to 1.

        This is important for accurate fitting and interpretation of the results.
        """
        self.instrument_model.normalize_resolution()

    #############
    # Private methods
    #############

    def _on_experiment_changed(self) -> None:
        """
        Update the Q values in the sample and instrument models when the experiment changes.
        """
        self.sample_model.Q = self.Q
        self.instrument_model.Q = self.Q

    def _on_sample_model_changed(self) -> None:
        """
        Update the Q values in the sample model when the sample model changes.
        """
        self.sample_model.Q = self.Q

    def _on_instrument_model_changed(self) -> None:
        """
        Update the Q values in the instrument model when the instrument model changes.
        """
        self.instrument_model.Q = self.Q

    def _on_convolution_settings_changed(self) -> None:
        """
        For subclasses that implement convolution, this method can be overridden
        """

    def _verify_Q_index(self, Q_index: int | None) -> int | None:
        """
        Verify that the Q index is valid.

        Parameters
        ----------
        Q_index : int | None
            The Q index to verify.

        Raises
        ------
        TypeError
            If Q_index is not an integer or None.
        IndexError
            If the Q index is not valid.

        Returns
        -------
        int | None
            The verified Q index.
        """
        if Q_index is None:
            return None

        if not isinstance(Q_index, int):
            raise TypeError('Q_index must be an integer or None.')

        if Q_index < 0 or (self.Q is not None and Q_index >= len(self.Q)):
            raise IndexError('Q_index must be a valid index for the Q values.')
        return Q_index

    #############
    # Dunder methods
    #############

    def __repr__(self) -> str:
        """
        Return a string representation of the Analysis.

        Returns
        -------
        str
            A string representation of the Analysis.
        """
        return (
            f' {self.__class__.__name__} (display_name={self.display_name}, '
            f'unique_name={self.unique_name})'
        )

Q property writable

Get the Q values from the associated Experiment, if available.

Returns:

Type Description
Variable | None

The Q values from the associated Experiment, if available, and None if not.

__init__(display_name='MyAnalysis', unique_name=None, experiment=None, sample_model=None, instrument_model=None, convolution_settings=None, detailed_balance_settings=None, extra_parameters=None)

Initialize the AnalysisBase.

Parameters:

Name Type Description Default
display_name str | None

Display name of the analysis.

'MyAnalysis'
unique_name str | None

Unique name of the analysis. If None, a unique name is automatically generated. By default, None.

None
experiment Experiment | None

The Experiment associated with this Analysis. If None, a default Experiment is created.

None
sample_model SampleModel | None

The SampleModel associated with this Analysis. If None, a default SampleModel is created.

None
instrument_model InstrumentModel | None

The InstrumentModel associated with this Analysis. If None, a default InstrumentModel is created.

None
convolution_settings ConvolutionSettings | None

The settings for the convolution. If None, default settings will be used.

None
detailed_balance_settings DetailedBalanceSettings | None

The settings for detailed balance. If None, default settings will be used.

None
extra_parameters Parameter | list[Parameter] | None

Extra parameters to be included in the analysis for advanced users. If None, no extra parameters are added.

None

Raises:

Type Description
TypeError

If experiment is not an Experiment or None or if sample_model is not a SampleModel or None or if instrument_model is not an InstrumentModel or None or if convolution_settings is not a ConvolutionSettings or None or if detailed_balance_settings is not a DetailedBalanceSettings or None or if extra_parameters is not a Parameter, a list of Parameters, or None.

Source code in src/easydynamics/analysis/analysis_base.py
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
def __init__(
    self,
    display_name: str | None = 'MyAnalysis',
    unique_name: str | None = None,
    experiment: Experiment | None = None,
    sample_model: SampleModel | None = None,
    instrument_model: InstrumentModel | None = None,
    convolution_settings: ConvolutionSettings | None = None,
    detailed_balance_settings: DetailedBalanceSettings | None = None,
    extra_parameters: Parameter | list[Parameter] | None = None,
) -> None:
    """
    Initialize the AnalysisBase.

    Parameters
    ----------
    display_name : str | None, default='MyAnalysis'
        Display name of the analysis.
    unique_name : str | None, default=None
        Unique name of the analysis. If None, a unique name is automatically generated. By
        default, None.
    experiment : Experiment | None, default=None
        The Experiment associated with this Analysis. If None, a default Experiment is created.
    sample_model : SampleModel | None, default=None
        The SampleModel associated with this Analysis. If None, a default SampleModel is
        created.
    instrument_model : InstrumentModel | None, default=None
        The InstrumentModel associated with this Analysis. If None, a default InstrumentModel
        is created.
    convolution_settings : ConvolutionSettings | None, default=None
         The settings for the convolution. If None, default settings will be used.
    detailed_balance_settings : DetailedBalanceSettings | None, default=None
        The settings for detailed balance. If None, default settings will be used.
    extra_parameters : Parameter | list[Parameter] | None, default=None
        Extra parameters to be included in the analysis for advanced users. If None, no extra
        parameters are added.

    Raises
    ------
    TypeError
        If experiment is not an Experiment or None or if sample_model is not a SampleModel or
        None or if instrument_model is not an InstrumentModel or None or if
        convolution_settings is not a ConvolutionSettings or None or if
        detailed_balance_settings is not a DetailedBalanceSettings or None or if
        extra_parameters is not a Parameter, a list of Parameters, or None.
    """

    super().__init__(display_name=display_name, unique_name=unique_name)

    if experiment is None:
        self._experiment = Experiment()
    elif isinstance(experiment, Experiment):
        self._experiment = experiment
    else:
        raise TypeError('experiment must be an instance of Experiment or None.')

    if sample_model is None:
        self._sample_model = SampleModel()
    elif isinstance(sample_model, SampleModel):
        self._sample_model = sample_model
    else:
        raise TypeError('sample_model must be an instance of SampleModel or None.')

    if instrument_model is None:
        self._instrument_model = InstrumentModel()
    elif isinstance(instrument_model, InstrumentModel):
        self._instrument_model = instrument_model
    else:
        raise TypeError('instrument_model must be an instance of InstrumentModel or None.')

    if convolution_settings is None:
        self.convolution_settings = ConvolutionSettings()
    elif isinstance(convolution_settings, ConvolutionSettings):
        self.convolution_settings = convolution_settings
    else:
        raise TypeError(
            'convolution_settings must be an instance of ConvolutionSettings or None.'
        )

    if extra_parameters is not None:
        if isinstance(extra_parameters, Parameter):
            self._extra_parameters = [extra_parameters]
        elif isinstance(extra_parameters, list) and all(
            isinstance(p, Parameter) for p in extra_parameters
        ):
            self._extra_parameters = extra_parameters
        else:
            raise TypeError('extra_parameters must be a Parameter or a list of Parameters.')
    else:
        self._extra_parameters = []

    if detailed_balance_settings is None:
        self._detailed_balance_settings = DetailedBalanceSettings()
    elif isinstance(detailed_balance_settings, DetailedBalanceSettings):
        self._detailed_balance_settings = detailed_balance_settings
    else:
        raise TypeError(
            'detailed_balance_settings must be an instance of DetailedBalanceSettings or None.'
        )

    self._on_experiment_changed()

__repr__()

Return a string representation of the Analysis.

Returns:

Type Description
str

A string representation of the Analysis.

Source code in src/easydynamics/analysis/analysis_base.py
498
499
500
501
502
503
504
505
506
507
508
509
510
def __repr__(self) -> str:
    """
    Return a string representation of the Analysis.

    Returns
    -------
    str
        A string representation of the Analysis.
    """
    return (
        f' {self.__class__.__name__} (display_name={self.display_name}, '
        f'unique_name={self.unique_name})'
    )

convolution_settings property writable

Get the convolution settings for this Analysis.

Returns:

Type Description
ConvolutionSettings

The convolution settings for this Analysis.

detailed_balance_settings property writable

Get the DetailedBalanceSettings of the SampleModel.

Returns:

Type Description
DetailedBalanceSettings

The DetailedBalanceSettings of the SampleModel.

energy property writable

Get the energy values from the associated Experiment, if available.

Returns:

Type Description
Variable | None

The energy values from the associated.

experiment property writable

Get the Experiment associated with this Analysis.

Returns:

Type Description
Experiment

The Experiment associated with this Analysis.

extra_parameters property writable

Get the extra parameters included in this Analysis.

Returns:

Type Description
list[Parameter]

The extra parameters included in this Analysis.

instrument_model property writable

Get the InstrumentModel associated with this Analysis.

Returns:

Type Description
InstrumentModel

The InstrumentModel associated with this Analysis.

normalize_resolution()

Normalize the resolution in the InstrumentModel to ensure that it integrates to 1.

This is important for accurate fitting and interpretation of the results.

Source code in src/easydynamics/analysis/analysis_base.py
427
428
429
430
431
432
433
def normalize_resolution(self) -> None:
    """
    Normalize the resolution in the InstrumentModel to ensure that it integrates to 1.

    This is important for accurate fitting and interpretation of the results.
    """
    self.instrument_model.normalize_resolution()

sample_model property writable

Get the SampleModel associated with this Analysis.

Returns:

Type Description
SampleModel

The SampleModel associated with this Analysis.

temperature property writable

Get the temperature from the associated SampleModel, if available.

Returns:

Type Description
Parameter | None

The temperature from the associated SampleModel, if available, and None if not.