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

        data_and_model = self.data_and_model_to_datagroup(
            energy=energy,
            add_background=add_background,
            include_components=plot_components,
        )

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {},
            'marker': {},
            'color': {},
            'markerfacecolor': {},
            'keep': 'energy',
        }

        for key in data_and_model:
            if key == 'Data':
                plot_kwargs_defaults['linestyle'][key] = 'none'
                plot_kwargs_defaults['marker'][key] = 'o'
                plot_kwargs_defaults['color'][key] = 'black'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            elif key == 'Model':
                plot_kwargs_defaults['linestyle'][key] = '-'
                plot_kwargs_defaults['marker'][key] = None
                plot_kwargs_defaults['color'][key] = 'red'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            else:
                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-'
        fig.autoscale()
        return fig

    def data_and_model_to_datagroup(
        self,
        energy: sc.Variable | None = None,
        add_background: bool = True,
        include_components: bool = True,
    ) -> sc.DataGroup:
        """
        Create a scipp DataGroup containing the experimental data, model calculation and optionally
        the individual components of 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.
        add_background : bool, default=True
            Whether to add background components to the sample model components when creating the
            DataGroup.
        include_components : bool, default=True
            Whether to include the individual components of the model in the DataGroup. If False,
            only the total model will be included.

        Raises
        ------
        ValueError
            If there is no data to include in the DataGroup, or if there are no Q values available
            for creating the DataGroup.

        TypeError
            If add_background is not True or False. If include_components is not True or False.

        Returns
        -------
        sc.DataGroup
            A DataGroup containing the experimental data, model calculation, and optionally the
            individual components of the model.
        """
        if self.experiment.binned_data is None:
            raise ValueError('No data to include in DataGroup. Please load data first.')

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

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

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

        energy = self._verify_energy(energy)

        if energy is None:
            energy = self.energy

        data_and_model = {
            'Data': self.experiment.binned_data,
            'Model': self._create_model_array(energy=energy),
        }

        if include_components:
            components = self._create_components_dataset(
                add_background=add_background, energy=energy
            )
            for key in components:
                data_and_model[key] = components[key]

        return sc.DataGroup(data_and_model)

    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)

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.

__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

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 sc.Variable | None

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

None

Returns:

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

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'.")

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 sc.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
321
322
323
324
325
326
327
328
329
330
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

    data_and_model = self.data_and_model_to_datagroup(
        energy=energy,
        add_background=add_background,
        include_components=plot_components,
    )

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {},
        'marker': {},
        'color': {},
        'markerfacecolor': {},
        'keep': 'energy',
    }

    for key in data_and_model:
        if key == 'Data':
            plot_kwargs_defaults['linestyle'][key] = 'none'
            plot_kwargs_defaults['marker'][key] = 'o'
            plot_kwargs_defaults['color'][key] = 'black'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        elif key == 'Model':
            plot_kwargs_defaults['linestyle'][key] = '-'
            plot_kwargs_defaults['marker'][key] = None
            plot_kwargs_defaults['color'][key] = 'red'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        else:
            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-'
    fig.autoscale()
    return fig

data_and_model_to_datagroup(energy=None, add_background=True, include_components=True)

Create a scipp DataGroup containing the experimental data, model calculation and optionally the individual components of the model.

Parameters:

Name Type Description Default
energy sc.Variable | None

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

None
add_background bool

Whether to add background components to the sample model components when creating the DataGroup.

True
include_components bool

Whether to include the individual components of the model in the DataGroup. If False, only the total model will be included.

True

Raises:

Type Description
ValueError

If there is no data to include in the DataGroup, or if there are no Q values available for creating the DataGroup.

TypeError

If add_background is not True or False. If include_components is not True or False.

Returns:

Type Description
sc.DataGroup

A DataGroup containing the experimental data, model calculation, and optionally the individual components of the model.

Source code in src/easydynamics/analysis/analysis.py
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
def data_and_model_to_datagroup(
    self,
    energy: sc.Variable | None = None,
    add_background: bool = True,
    include_components: bool = True,
) -> sc.DataGroup:
    """
    Create a scipp DataGroup containing the experimental data, model calculation and optionally
    the individual components of 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.
    add_background : bool, default=True
        Whether to add background components to the sample model components when creating the
        DataGroup.
    include_components : bool, default=True
        Whether to include the individual components of the model in the DataGroup. If False,
        only the total model will be included.

    Raises
    ------
    ValueError
        If there is no data to include in the DataGroup, or if there are no Q values available
        for creating the DataGroup.

    TypeError
        If add_background is not True or False. If include_components is not True or False.

    Returns
    -------
    sc.DataGroup
        A DataGroup containing the experimental data, model calculation, and optionally the
        individual components of the model.
    """
    if self.experiment.binned_data is None:
        raise ValueError('No data to include in DataGroup. Please load data first.')

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

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

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

    energy = self._verify_energy(energy)

    if energy is None:
        energy = self.energy

    data_and_model = {
        'Data': self.experiment.binned_data,
        'Model': self._create_model_array(energy=energy),
    }

    if include_components:
        components = self._create_components_dataset(
            add_background=add_background, energy=energy
        )
        for key in components:
            data_and_model[key] = components[key]

    return sc.DataGroup(data_and_model)

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
sc.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
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
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_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
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
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,
    )

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
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
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
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
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
705
706
707
708
709
710
711
712
713
714
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]

ParameterAnalysis

Bases: EasyDynamicsModelBase

For analysing fitted parameters.

Can be used to fit parameters to ModelComponents, ComponentCollections, or DiffusionModelBase objects, and to plot the parameters and fit results. The parameters to be analyzed can be provided as a sc.Dataset or directly as an Analysis object. Multiple parameters can be fitted simultaneously, and the fit functions can be customized for each parameter. For diffusion models, the area and width can be fitted separately (or not at all) by specifying fit settings.

Source code in src/easydynamics/analysis/parameter_analysis.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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
class ParameterAnalysis(EasyDynamicsModelBase):
    """
    For analysing fitted parameters.

    Can be used to fit parameters to ModelComponents, ComponentCollections, or DiffusionModelBase
    objects, and to plot the parameters and fit results. The parameters to be analyzed can be
    provided as a sc.Dataset or directly as an Analysis object. Multiple parameters can be fitted
    simultaneously, and the fit functions can be customized for each parameter. For diffusion
    models, the area and width can be fitted separately (or not at all) by specifying fit settings.
    """

    def __init__(
        self,
        parameters: sc.Dataset | Analysis | None = None,
        bindings: FitBinding | list[FitBinding] | None = None,
        display_name: str | None = 'ParameterAnalysis',
        unique_name: str | None = None,
    ) -> None:
        """
        Initialize the ParameterAnalysis.

        Parameters
        ----------
        parameters : sc.Dataset | Analysis | None, default=None
            The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which
            case the parameters will be extracted from the Analysis).
        bindings : FitBinding | list[FitBinding] | None, default=None
            The fit bindings to use for fitting the parameters. Can be a single FitBinding or a
            list of FitBindings. If None, no fit bindings are provided.
        display_name : str | None, default='ParameterAnalysis'
            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.
        """

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

        self._parameters = self._verify_parameters(parameters)

        self._bindings = self._verify_bindings(bindings)

    #############
    # Properties
    #############
    @property
    def parameters(self) -> sc.Dataset | None:
        """
        Get the parameters for the parameter analysis.

        Returns
        -------
        sc.Dataset | None
            The parameters for the parameter analysis.
        """
        return self._parameters

    @parameters.setter
    def parameters(self, value: sc.Dataset | Analysis | None) -> None:
        """
        Set the parameter dataset for the parameter analysis.

        Parameters
        ----------
        value : sc.Dataset | Analysis | None
            The new parameter dataset for the parameter analysis.
        """
        self._parameters = self._verify_parameters(value)

    @property
    def bindings(self) -> list[FitBinding]:
        """
        Get the fit bindings for the parameter analysis.

        Returns
        -------
        list[FitBinding]
            The fit bindings for the parameter analysis.
        """
        return self._bindings

    @bindings.setter
    def bindings(self, value: FitBinding | list[FitBinding] | None) -> None:
        """
        Set the fit bindings for the parameter analysis.

        Parameters
        ----------
        value : FitBinding | list[FitBinding] | None
            The new fit bindings for the parameter analysis.
        """
        self._bindings = self._verify_bindings(value)

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

    def fit(self) -> FitResults:
        """
        Fit the parameters using the specified fit functions and settings.

        Returns
        -------
        FitResults
            The results of the fit

        Raises
        ------
        ValueError
            If no parameters Dataset is provided. If no fit functions are provided. If no parameter
            names are found for the fit functions.
        """

        if self.parameters is None:
            raise ValueError('No parameters Dataset provided.')

        if not self.bindings:
            raise ValueError('No fit bindings provided.')

        xs = []
        ys = []
        ws = []
        funcs, models = [], []

        for binding in self.bindings:
            param_names = binding.get_parameter_names()
            callables = binding.build_callables()

            for pname, func in zip(param_names, callables, strict=True):
                if pname not in self.parameters:
                    raise ValueError(
                        f"Parameter '{pname}' from binding '{binding.unique_name}' "
                        f'not found in parameters Dataset.'
                    )

                x, y, weight = self._get_xyweight_from_dataset(pname)

                xs.append(x)
                ys.append(y)
                ws.append(weight)

                funcs.append(func)
                models.append(binding.model)

        mf = MultiFitter(
            fit_objects=models,
            fit_functions=funcs,
        )

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

    def plot(
        self, names: str | list[str] | None = None, **kwargs: dict[str, Any]
    ) -> InteractiveFigure:
        """
        Plot the parameters and fit results.

        Parameters
        ----------
        names : str | list[str] | None, default=None
            The names of the parameters to plot. If None, all parameters with bindings are plotted.
        **kwargs : dict[str, Any]
            Additional keyword arguments to pass to the plotting function.

        Returns
        -------
        InteractiveFigure
            An interactive figure containing the plots of the parameters and fit results.

        Raises
        ------
        ValueError
            If the units of the specified parameters are not consistent.
        RuntimeError
            If plot() is called outside of a Jupyter notebook environment.
        """

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

        if self.parameters is None:
            raise ValueError('No parameters available to plot.')

        full_model_dataset = None
        if self.bindings:
            full_model_dataset = self.calculate_model_dataset(self.bindings)

        # If no names are provided, default to plot all parameters that have bindings.
        # If no bindings are provided, plot all parameters.
        if names is None:
            names = []

            if not self.bindings:
                names = list(self.parameters.keys())
            else:
                for b in self.bindings:
                    names.extend(b.get_parameter_names())

        names = self._normalize_names(names)

        # Check that the units of the specified parameters are consistent.
        units = [self.parameters[name].unit for name in names]
        first_unit = units[0]
        if any(unit != first_unit for unit in units):
            raise ValueError(f'Units are not compatible, and cannot be plotted together: {units}')

        color_cycle = itertools.cycle(rcParams['axes.prop_cycle'].by_key()['color'])
        markers = itertools.cycle(['o', 's', 'D', '^', 'v', '<', '>'])

        plot_kwargs = {
            'title': self.display_name,
            'linestyle': {},
            'marker': {},
            'color': {},
            'markerfacecolor': {},
        }

        data_arrays = {}
        model_arrays = {}

        # map parameter names to model names
        param_to_model = {}
        if self.bindings is not None:
            for b in self.bindings:
                param_names = b.get_parameter_names()
                model_names = b.get_model_names()

                param_to_model.update(dict(zip(param_names, model_names, strict=True)))

        for pname in names:
            data_arrays[pname] = self.parameters[pname]
            color = next(color_cycle)
            marker = next(markers)

            # Data styling
            plot_kwargs['linestyle'][pname] = 'none'
            plot_kwargs['marker'][pname] = marker
            plot_kwargs['color'][pname] = color
            plot_kwargs['markerfacecolor'][pname] = 'none'

            if full_model_dataset is not None and pname in param_to_model:
                mname = param_to_model[pname]
                model_arrays[mname] = full_model_dataset[mname]

                # Model styling
                plot_kwargs['linestyle'][mname] = '--'
                plot_kwargs['marker'][mname] = None
                plot_kwargs['color'][mname] = color

        # Update kwargs with user provided kwargs.
        plot_kwargs.update(kwargs)

        data_and_model = sc.Dataset(data_arrays)
        data_and_model.update(model_arrays)

        return pp.plot(data_and_model, **plot_kwargs)

    def calculate_model_dataset(self, bindings: list[FitBinding]) -> sc.Dataset:
        """
        Evaluate all bindings into a sc.Dataset of model predictions.

        Parameters
        ----------
        bindings : list[FitBinding]
            The bindings to evaluate.

        Returns
        -------
        sc.Dataset
            A sc.Dataset containing the model predictions for all bindings.

        Raises
        ------
        ValueError
            If any parameter name from the bindings is not found in the parameters Dataset.

        TypeError
            If bindings is not a list of FitBinding objects.
        """

        if self.parameters is None:
            raise ValueError('No parameters Dataset provided.')

        if not bindings:
            raise ValueError('No fit bindings provided.')

        if not isinstance(bindings, list) or not all(isinstance(b, FitBinding) for b in bindings):
            raise TypeError('bindings must be a list of FitBinding objects.')

        arrays = {}

        for b in bindings:
            param_names = b.get_parameter_names()
            model_names = b.get_model_names()
            callables = b.build_callables()

            for pname, mname, func in zip(param_names, model_names, callables, strict=True):
                if pname not in self.parameters:
                    raise ValueError(
                        f"Parameter '{pname}' from binding '{b.unique_name}' "
                        f'not found in parameters Dataset.'
                    )
                da = self.parameters[pname]
                x = da.coords['Q']

                y_model = func(x.values)

                arrays[mname] = sc.DataArray(
                    data=sc.array(dims=['Q'], values=y_model, unit=da.unit),
                    coords={'Q': x},
                )
        return sc.Dataset(arrays)

    def append_binding(self, binding: FitBinding) -> None:
        """
        Append a FitBinding to the list of bindings for the parameter analysis.

        Parameters
        ----------
        binding : FitBinding
            The FitBinding to append.

        Raises
        ------
        TypeError
            If binding is not a FitBinding object.
        """
        if not isinstance(binding, FitBinding):
            raise TypeError('binding must be a FitBinding object.')
        self._bindings.append(binding)

    def clear_bindings(self) -> None:
        """
        Clear all FitBindings from the list of bindings for the parameter analysis.
        """
        self._bindings.clear()

    def get_all_variables(self) -> list:
        """
        Get all variables from the fit functions.

        Returns
        -------
        list
            A list of all variables from the fit functions.
        """
        variables = set()
        for b in self._bindings:
            variables.update(b.model.get_all_variables())
        return list(variables)

    #############
    # Private methods: verification and preparation
    #############

    def _verify_bindings(self, bindings: FitBinding | list[FitBinding] | None) -> list[FitBinding]:
        """
        Verify the bindings input.

        Parameters
        ----------
        bindings : FitBinding | list[FitBinding] | None
            The bindings to verify.

        Returns
        -------
        list[FitBinding]
            A list of verified FitBindings.

        Raises
        ------
        TypeError
            If bindings is not a FitBinding, a list of FitBindings, or None.
        """
        if bindings is None:
            return []
        if isinstance(bindings, FitBinding):
            return [bindings]
        if isinstance(bindings, list) and all(isinstance(b, FitBinding) for b in bindings):
            return bindings
        raise TypeError('bindings must be a FitBinding, a list of FitBindings, or None.')

    def _verify_parameters(self, parameters: sc.Dataset | Analysis | None) -> sc.Dataset | None:
        """
        Verify the parameters input and convert it to a sc.Dataset if it's an Analysis.

        Parameters
        ----------
        parameters : sc.Dataset | Analysis | None
            The parameters to verify.

        Returns
        -------
        sc.Dataset | None
            The verified parameters as a sc.Dataset, or None if no parameters were provided.

        Raises
        ------
        TypeError
            If parameters is not a sc.Dataset, an Analysis, or None.
        ValueError
            If parameters is a sc.Dataset but does not have a 'Q' coordinate.
        """
        if parameters is None:
            return None

        if not isinstance(parameters, (sc.Dataset, Analysis)):
            raise TypeError(r'parameters must be a sc.Dataset, an Analysis, or None.')

        if isinstance(parameters, Analysis):
            verified_parameters = parameters.parameters_to_dataset()
        else:
            verified_parameters = parameters

        if 'Q' not in verified_parameters.coords:
            raise ValueError(r"parameters must have a 'Q' coordinate.")
        return verified_parameters

    def _normalize_names(self, names: str | list[str] | None) -> list[str] | None:
        """
        Normalize the names input to a list of strings and verify that they exist in the parameters
        Dataset.

        Parameters
        ----------
        names : str | list[str] | None
            The names to normalize and verify.

        Returns
        -------
        list[str] | None
            The normalized list of names, or None if names was None.

        Raises
        ------
        ValueError
            If any of the specified names are not found in the parameters Dataset, or if names is a
            list that contains non-string elements.
        """
        if names is None:
            return None
        if not isinstance(names, (str, list)):
            raise ValueError('names must be a string, a list of strings, or None.')
        if isinstance(names, list):
            if not all(isinstance(name, str) for name in names):
                raise ValueError('All names in the list must be strings.')
            for name in names:
                if name not in self.parameters:
                    raise ValueError(f"Parameter name '{name}' not found in parameters Dataset.")
        if isinstance(names, str):
            if names not in self.parameters:
                raise ValueError(f"Parameter name '{names}' not found in parameters Dataset.")
            names = [names]
        return names

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

    def _get_xyweight_from_dataset(
        self, parameter_name: str
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """
        Get the x, y, and weight values for a given parameter name from the parameters Dataset.

        Parameters
        ----------
        parameter_name : str
            The name of the parameter to get x, y, and weight values for.

        Returns
        -------
        tuple[np.ndarray, np.ndarray, np.ndarray]
            A tuple containing the x, y, and weight values for the given parameter name.

        Raises
        ------
        ValueError
            If the parameter name is not found in the parameters Dataset. If non-finite weights are
            found for the parameter.
        """
        if self._parameters is None:
            raise ValueError('No parameters Dataset provided.')
        if parameter_name not in self._parameters:
            raise ValueError(f"Parameter name '{parameter_name}' not found in parameters Dataset.")

        variances = self._parameters[parameter_name].variances
        if variances is None:
            weight = np.ones_like(self._parameters[parameter_name].values)
        elif np.any(~np.isfinite(variances)) or np.any(variances <= 0):
            raise ValueError(
                f"Non-finite variances found for parameter '{parameter_name}', "
                f'cannot compute weights.'
            )
        else:
            weight = 1 / np.sqrt(variances)

        return (
            self._parameters[parameter_name].coords['Q'].values,
            self._parameters[parameter_name].values,
            weight,
        )

    #############
    # Dunder methods
    #############
    def __repr__(self) -> str:
        cls = self.__class__.__name__

        n_params = len(self._parameters) if isinstance(self._parameters, sc.Dataset) else 0

        param_names = (
            list(self._parameters.keys()) if isinstance(self._parameters, sc.Dataset) else None
        )

        binding_info = [
            {
                'parameter': b.parameter_name,
                'model': b.model.display_name,
                'modes': b.modes,
            }
            for b in self._bindings
        ]

        return (
            f'{cls}(\n'
            f'display_name={self.display_name},\n'
            f'unique_name={self.unique_name},\n'
            f'n_parameters={n_params},\n'
            f'parameter_names={param_names},\n'
            f'bindings={binding_info}\n'
            f')'
        )

parameters property writable

Get the parameters for the parameter analysis.

Returns:

Type Description
sc.Dataset | None

The parameters for the parameter analysis.

bindings property writable

Get the fit bindings for the parameter analysis.

Returns:

Type Description
list[FitBinding]

The fit bindings for the parameter analysis.

__init__(parameters=None, bindings=None, display_name='ParameterAnalysis', unique_name=None)

Initialize the ParameterAnalysis.

Parameters:

Name Type Description Default
parameters sc.Dataset | Analysis | None

The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which case the parameters will be extracted from the Analysis).

None
bindings FitBinding | list[FitBinding] | None

The fit bindings to use for fitting the parameters. Can be a single FitBinding or a list of FitBindings. If None, no fit bindings are provided.

None
display_name str | None

Display name of the analysis.

'ParameterAnalysis'
unique_name str | None

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

None
Source code in src/easydynamics/analysis/parameter_analysis.py
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
def __init__(
    self,
    parameters: sc.Dataset | Analysis | None = None,
    bindings: FitBinding | list[FitBinding] | None = None,
    display_name: str | None = 'ParameterAnalysis',
    unique_name: str | None = None,
) -> None:
    """
    Initialize the ParameterAnalysis.

    Parameters
    ----------
    parameters : sc.Dataset | Analysis | None, default=None
        The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which
        case the parameters will be extracted from the Analysis).
    bindings : FitBinding | list[FitBinding] | None, default=None
        The fit bindings to use for fitting the parameters. Can be a single FitBinding or a
        list of FitBindings. If None, no fit bindings are provided.
    display_name : str | None, default='ParameterAnalysis'
        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.
    """

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

    self._parameters = self._verify_parameters(parameters)

    self._bindings = self._verify_bindings(bindings)

fit()

Fit the parameters using the specified fit functions and settings.

Returns:

Type Description
FitResults

The results of the fit

Raises:

Type Description
ValueError

If no parameters Dataset is provided. If no fit functions are provided. If no parameter names are found for the fit functions.

Source code in src/easydynamics/analysis/parameter_analysis.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def fit(self) -> FitResults:
    """
    Fit the parameters using the specified fit functions and settings.

    Returns
    -------
    FitResults
        The results of the fit

    Raises
    ------
    ValueError
        If no parameters Dataset is provided. If no fit functions are provided. If no parameter
        names are found for the fit functions.
    """

    if self.parameters is None:
        raise ValueError('No parameters Dataset provided.')

    if not self.bindings:
        raise ValueError('No fit bindings provided.')

    xs = []
    ys = []
    ws = []
    funcs, models = [], []

    for binding in self.bindings:
        param_names = binding.get_parameter_names()
        callables = binding.build_callables()

        for pname, func in zip(param_names, callables, strict=True):
            if pname not in self.parameters:
                raise ValueError(
                    f"Parameter '{pname}' from binding '{binding.unique_name}' "
                    f'not found in parameters Dataset.'
                )

            x, y, weight = self._get_xyweight_from_dataset(pname)

            xs.append(x)
            ys.append(y)
            ws.append(weight)

            funcs.append(func)
            models.append(binding.model)

    mf = MultiFitter(
        fit_objects=models,
        fit_functions=funcs,
    )

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

plot(names=None, **kwargs)

Plot the parameters and fit results.

Parameters:

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

The names of the parameters to plot. If None, all parameters with bindings are plotted.

None
**kwargs dict[str, Any]

Additional keyword arguments to pass to the plotting function.

{}

Returns:

Type Description
InteractiveFigure

An interactive figure containing the plots of the parameters and fit results.

Raises:

Type Description
ValueError

If the units of the specified parameters are not consistent.

RuntimeError

If plot() is called outside of a Jupyter notebook environment.

Source code in src/easydynamics/analysis/parameter_analysis.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
def plot(
    self, names: str | list[str] | None = None, **kwargs: dict[str, Any]
) -> InteractiveFigure:
    """
    Plot the parameters and fit results.

    Parameters
    ----------
    names : str | list[str] | None, default=None
        The names of the parameters to plot. If None, all parameters with bindings are plotted.
    **kwargs : dict[str, Any]
        Additional keyword arguments to pass to the plotting function.

    Returns
    -------
    InteractiveFigure
        An interactive figure containing the plots of the parameters and fit results.

    Raises
    ------
    ValueError
        If the units of the specified parameters are not consistent.
    RuntimeError
        If plot() is called outside of a Jupyter notebook environment.
    """

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

    if self.parameters is None:
        raise ValueError('No parameters available to plot.')

    full_model_dataset = None
    if self.bindings:
        full_model_dataset = self.calculate_model_dataset(self.bindings)

    # If no names are provided, default to plot all parameters that have bindings.
    # If no bindings are provided, plot all parameters.
    if names is None:
        names = []

        if not self.bindings:
            names = list(self.parameters.keys())
        else:
            for b in self.bindings:
                names.extend(b.get_parameter_names())

    names = self._normalize_names(names)

    # Check that the units of the specified parameters are consistent.
    units = [self.parameters[name].unit for name in names]
    first_unit = units[0]
    if any(unit != first_unit for unit in units):
        raise ValueError(f'Units are not compatible, and cannot be plotted together: {units}')

    color_cycle = itertools.cycle(rcParams['axes.prop_cycle'].by_key()['color'])
    markers = itertools.cycle(['o', 's', 'D', '^', 'v', '<', '>'])

    plot_kwargs = {
        'title': self.display_name,
        'linestyle': {},
        'marker': {},
        'color': {},
        'markerfacecolor': {},
    }

    data_arrays = {}
    model_arrays = {}

    # map parameter names to model names
    param_to_model = {}
    if self.bindings is not None:
        for b in self.bindings:
            param_names = b.get_parameter_names()
            model_names = b.get_model_names()

            param_to_model.update(dict(zip(param_names, model_names, strict=True)))

    for pname in names:
        data_arrays[pname] = self.parameters[pname]
        color = next(color_cycle)
        marker = next(markers)

        # Data styling
        plot_kwargs['linestyle'][pname] = 'none'
        plot_kwargs['marker'][pname] = marker
        plot_kwargs['color'][pname] = color
        plot_kwargs['markerfacecolor'][pname] = 'none'

        if full_model_dataset is not None and pname in param_to_model:
            mname = param_to_model[pname]
            model_arrays[mname] = full_model_dataset[mname]

            # Model styling
            plot_kwargs['linestyle'][mname] = '--'
            plot_kwargs['marker'][mname] = None
            plot_kwargs['color'][mname] = color

    # Update kwargs with user provided kwargs.
    plot_kwargs.update(kwargs)

    data_and_model = sc.Dataset(data_arrays)
    data_and_model.update(model_arrays)

    return pp.plot(data_and_model, **plot_kwargs)

calculate_model_dataset(bindings)

Evaluate all bindings into a sc.Dataset of model predictions.

Parameters:

Name Type Description Default
bindings list[FitBinding]

The bindings to evaluate.

required

Returns:

Type Description
sc.Dataset

A sc.Dataset containing the model predictions for all bindings.

Raises:

Type Description
ValueError

If any parameter name from the bindings is not found in the parameters Dataset.

TypeError

If bindings is not a list of FitBinding objects.

Source code in src/easydynamics/analysis/parameter_analysis.py
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
def calculate_model_dataset(self, bindings: list[FitBinding]) -> sc.Dataset:
    """
    Evaluate all bindings into a sc.Dataset of model predictions.

    Parameters
    ----------
    bindings : list[FitBinding]
        The bindings to evaluate.

    Returns
    -------
    sc.Dataset
        A sc.Dataset containing the model predictions for all bindings.

    Raises
    ------
    ValueError
        If any parameter name from the bindings is not found in the parameters Dataset.

    TypeError
        If bindings is not a list of FitBinding objects.
    """

    if self.parameters is None:
        raise ValueError('No parameters Dataset provided.')

    if not bindings:
        raise ValueError('No fit bindings provided.')

    if not isinstance(bindings, list) or not all(isinstance(b, FitBinding) for b in bindings):
        raise TypeError('bindings must be a list of FitBinding objects.')

    arrays = {}

    for b in bindings:
        param_names = b.get_parameter_names()
        model_names = b.get_model_names()
        callables = b.build_callables()

        for pname, mname, func in zip(param_names, model_names, callables, strict=True):
            if pname not in self.parameters:
                raise ValueError(
                    f"Parameter '{pname}' from binding '{b.unique_name}' "
                    f'not found in parameters Dataset.'
                )
            da = self.parameters[pname]
            x = da.coords['Q']

            y_model = func(x.values)

            arrays[mname] = sc.DataArray(
                data=sc.array(dims=['Q'], values=y_model, unit=da.unit),
                coords={'Q': x},
            )
    return sc.Dataset(arrays)

append_binding(binding)

Append a FitBinding to the list of bindings for the parameter analysis.

Parameters:

Name Type Description Default
binding FitBinding

The FitBinding to append.

required

Raises:

Type Description
TypeError

If binding is not a FitBinding object.

Source code in src/easydynamics/analysis/parameter_analysis.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def append_binding(self, binding: FitBinding) -> None:
    """
    Append a FitBinding to the list of bindings for the parameter analysis.

    Parameters
    ----------
    binding : FitBinding
        The FitBinding to append.

    Raises
    ------
    TypeError
        If binding is not a FitBinding object.
    """
    if not isinstance(binding, FitBinding):
        raise TypeError('binding must be a FitBinding object.')
    self._bindings.append(binding)

clear_bindings()

Clear all FitBindings from the list of bindings for the parameter analysis.

Source code in src/easydynamics/analysis/parameter_analysis.py
356
357
358
359
360
def clear_bindings(self) -> None:
    """
    Clear all FitBindings from the list of bindings for the parameter analysis.
    """
    self._bindings.clear()

get_all_variables()

Get all variables from the fit functions.

Returns:

Type Description
list

A list of all variables from the fit functions.

Source code in src/easydynamics/analysis/parameter_analysis.py
362
363
364
365
366
367
368
369
370
371
372
373
374
def get_all_variables(self) -> list:
    """
    Get all variables from the fit functions.

    Returns
    -------
    list
        A list of all variables from the fit functions.
    """
    variables = set()
    for b in self._bindings:
        variables.update(b.model.get_all_variables())
    return list(variables)

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

        data_and_model = self.data_and_model_to_datagroup(
            energy=energy,
            add_background=add_background,
            include_components=plot_components,
        )

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {},
            'marker': {},
            'color': {},
            'markerfacecolor': {},
            'keep': 'energy',
        }

        for key in data_and_model:
            if key == 'Data':
                plot_kwargs_defaults['linestyle'][key] = 'none'
                plot_kwargs_defaults['marker'][key] = 'o'
                plot_kwargs_defaults['color'][key] = 'black'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            elif key == 'Model':
                plot_kwargs_defaults['linestyle'][key] = '-'
                plot_kwargs_defaults['marker'][key] = None
                plot_kwargs_defaults['color'][key] = 'red'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            else:
                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-'
        fig.autoscale()
        return fig

    def data_and_model_to_datagroup(
        self,
        energy: sc.Variable | None = None,
        add_background: bool = True,
        include_components: bool = True,
    ) -> sc.DataGroup:
        """
        Create a scipp DataGroup containing the experimental data, model calculation and optionally
        the individual components of 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.
        add_background : bool, default=True
            Whether to add background components to the sample model components when creating the
            DataGroup.
        include_components : bool, default=True
            Whether to include the individual components of the model in the DataGroup. If False,
            only the total model will be included.

        Raises
        ------
        ValueError
            If there is no data to include in the DataGroup, or if there are no Q values available
            for creating the DataGroup.

        TypeError
            If add_background is not True or False. If include_components is not True or False.

        Returns
        -------
        sc.DataGroup
            A DataGroup containing the experimental data, model calculation, and optionally the
            individual components of the model.
        """
        if self.experiment.binned_data is None:
            raise ValueError('No data to include in DataGroup. Please load data first.')

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

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

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

        energy = self._verify_energy(energy)

        if energy is None:
            energy = self.energy

        data_and_model = {
            'Data': self.experiment.binned_data,
            'Model': self._create_model_array(energy=energy),
        }

        if include_components:
            components = self._create_components_dataset(
                add_background=add_background, energy=energy
            )
            for key in components:
                data_and_model[key] = components[key]

        return sc.DataGroup(data_and_model)

    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)

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.

__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

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 sc.Variable | None

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

None

Returns:

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

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'.")

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 sc.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
321
322
323
324
325
326
327
328
329
330
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

    data_and_model = self.data_and_model_to_datagroup(
        energy=energy,
        add_background=add_background,
        include_components=plot_components,
    )

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {},
        'marker': {},
        'color': {},
        'markerfacecolor': {},
        'keep': 'energy',
    }

    for key in data_and_model:
        if key == 'Data':
            plot_kwargs_defaults['linestyle'][key] = 'none'
            plot_kwargs_defaults['marker'][key] = 'o'
            plot_kwargs_defaults['color'][key] = 'black'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        elif key == 'Model':
            plot_kwargs_defaults['linestyle'][key] = '-'
            plot_kwargs_defaults['marker'][key] = None
            plot_kwargs_defaults['color'][key] = 'red'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        else:
            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-'
    fig.autoscale()
    return fig

data_and_model_to_datagroup(energy=None, add_background=True, include_components=True)

Create a scipp DataGroup containing the experimental data, model calculation and optionally the individual components of the model.

Parameters:

Name Type Description Default
energy sc.Variable | None

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

None
add_background bool

Whether to add background components to the sample model components when creating the DataGroup.

True
include_components bool

Whether to include the individual components of the model in the DataGroup. If False, only the total model will be included.

True

Raises:

Type Description
ValueError

If there is no data to include in the DataGroup, or if there are no Q values available for creating the DataGroup.

TypeError

If add_background is not True or False. If include_components is not True or False.

Returns:

Type Description
sc.DataGroup

A DataGroup containing the experimental data, model calculation, and optionally the individual components of the model.

Source code in src/easydynamics/analysis/analysis.py
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
def data_and_model_to_datagroup(
    self,
    energy: sc.Variable | None = None,
    add_background: bool = True,
    include_components: bool = True,
) -> sc.DataGroup:
    """
    Create a scipp DataGroup containing the experimental data, model calculation and optionally
    the individual components of 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.
    add_background : bool, default=True
        Whether to add background components to the sample model components when creating the
        DataGroup.
    include_components : bool, default=True
        Whether to include the individual components of the model in the DataGroup. If False,
        only the total model will be included.

    Raises
    ------
    ValueError
        If there is no data to include in the DataGroup, or if there are no Q values available
        for creating the DataGroup.

    TypeError
        If add_background is not True or False. If include_components is not True or False.

    Returns
    -------
    sc.DataGroup
        A DataGroup containing the experimental data, model calculation, and optionally the
        individual components of the model.
    """
    if self.experiment.binned_data is None:
        raise ValueError('No data to include in DataGroup. Please load data first.')

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

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

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

    energy = self._verify_energy(energy)

    if energy is None:
        energy = self.energy

    data_and_model = {
        'Data': self.experiment.binned_data,
        'Model': self._create_model_array(energy=energy),
    }

    if include_components:
        components = self._create_components_dataset(
            add_background=add_background, energy=energy
        )
        for key in components:
            data_and_model[key] = components[key]

    return sc.DataGroup(data_and_model)

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
sc.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
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
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_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
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
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,
    )

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
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
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
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
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
705
706
707
708
709
710
711
712
713
714
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]

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
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
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.

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

        data_and_model = self.data_and_model_to_datagroup(
            energy=energy,
            add_background=add_background,
            include_components=plot_components,
        )

        plot_kwargs_defaults = {
            'title': self.display_name,
            'linestyle': {},
            'marker': {},
            'color': {},
            'markerfacecolor': {},
        }

        for key in data_and_model:
            if key == 'Data':
                plot_kwargs_defaults['linestyle'][key] = 'none'
                plot_kwargs_defaults['marker'][key] = 'o'
                plot_kwargs_defaults['color'][key] = 'black'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            elif key == 'Model':
                plot_kwargs_defaults['linestyle'][key] = '-'
                plot_kwargs_defaults['marker'][key] = None
                plot_kwargs_defaults['color'][key] = 'red'
                plot_kwargs_defaults['markerfacecolor'][key] = 'none'

            else:
                plot_kwargs_defaults['linestyle'][key] = '--'
                plot_kwargs_defaults['marker'][key] = None

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

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

    def data_and_model_to_datagroup(
        self,
        energy: sc.Variable | None = None,
        add_background: bool = True,
        include_components: bool = True,
    ) -> sc.DataGroup:
        """
        Create a scipp DataGroup containing the experimental data, model calculation, and
        optionally the individual components.

        Parameters
        ----------
        energy : sc.Variable | None, default=None
            Optional energy grid to use for the model calculation. If None, the energy grid from
            the experiment is used.
        add_background : bool, default=True
            Whether to add the background to the model prediction when plotting individual
            components.
        include_components : bool, default=True
            Whether to include the individual components of the model in the DataGroup. If True,
            the DataGroup will include a DataArray for each component with the component's display
            name as the key

        Raises
        ------
        ValueError
            If no data is available in the experiment to include in the DataGroup. If no Q values
            are available in the experiment to create the DataGroup. If Q_index is not set to
            create the DataGroup.
        TypeError
            If add_background is not a boolean. If include_components is not a boolean.

        Returns
        -------
        sc.DataGroup
            A DataGroup containing the experimental data, model calculation, and optionally the
            individual components.
        """

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

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

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

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

        if self.Q_index is None:
            raise ValueError('Q_index must be set to create DataGroup.')

        energy = self._verify_energy(energy)

        if energy is None:
            energy = self._masked_energy

        data_and_model = {
            'Data': self.experiment.binned_data['Q', self.Q_index],
            'Model': self._create_model_array(energy=energy),
        }

        if include_components:
            components = self._create_components_dataset_single_Q(
                add_background=add_background,
                energy=energy,
            )

            for key in components:
                data_and_model[key] = components[key]

        return sc.DataGroup(data_and_model)

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

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

        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

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 sc.Variable | None

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

None

Returns:

Type Description
np.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

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 np.ndarray | sc.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

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

{}

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

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

    data_and_model = self.data_and_model_to_datagroup(
        energy=energy,
        add_background=add_background,
        include_components=plot_components,
    )

    plot_kwargs_defaults = {
        'title': self.display_name,
        'linestyle': {},
        'marker': {},
        'color': {},
        'markerfacecolor': {},
    }

    for key in data_and_model:
        if key == 'Data':
            plot_kwargs_defaults['linestyle'][key] = 'none'
            plot_kwargs_defaults['marker'][key] = 'o'
            plot_kwargs_defaults['color'][key] = 'black'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        elif key == 'Model':
            plot_kwargs_defaults['linestyle'][key] = '-'
            plot_kwargs_defaults['marker'][key] = None
            plot_kwargs_defaults['color'][key] = 'red'
            plot_kwargs_defaults['markerfacecolor'][key] = 'none'

        else:
            plot_kwargs_defaults['linestyle'][key] = '--'
            plot_kwargs_defaults['marker'][key] = None

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

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

data_and_model_to_datagroup(energy=None, add_background=True, include_components=True)

Create a scipp DataGroup containing the experimental data, model calculation, and optionally the individual components.

Parameters:

Name Type Description Default
energy sc.Variable | None

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

None
add_background bool

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

True
include_components bool

Whether to include the individual components of the model in the DataGroup. If True, the DataGroup will include a DataArray for each component with the component's display name as the key

True

Raises:

Type Description
ValueError

If no data is available in the experiment to include in the DataGroup. If no Q values are available in the experiment to create the DataGroup. If Q_index is not set to create the DataGroup.

TypeError

If add_background is not a boolean. If include_components is not a boolean.

Returns:

Type Description
sc.DataGroup

A DataGroup containing the experimental data, model calculation, and optionally the individual components.

Source code in src/easydynamics/analysis/analysis1d.py
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
def data_and_model_to_datagroup(
    self,
    energy: sc.Variable | None = None,
    add_background: bool = True,
    include_components: bool = True,
) -> sc.DataGroup:
    """
    Create a scipp DataGroup containing the experimental data, model calculation, and
    optionally the individual components.

    Parameters
    ----------
    energy : sc.Variable | None, default=None
        Optional energy grid to use for the model calculation. If None, the energy grid from
        the experiment is used.
    add_background : bool, default=True
        Whether to add the background to the model prediction when plotting individual
        components.
    include_components : bool, default=True
        Whether to include the individual components of the model in the DataGroup. If True,
        the DataGroup will include a DataArray for each component with the component's display
        name as the key

    Raises
    ------
    ValueError
        If no data is available in the experiment to include in the DataGroup. If no Q values
        are available in the experiment to create the DataGroup. If Q_index is not set to
        create the DataGroup.
    TypeError
        If add_background is not a boolean. If include_components is not a boolean.

    Returns
    -------
    sc.DataGroup
        A DataGroup containing the experimental data, model calculation, and optionally the
        individual components.
    """

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

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

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

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

    if self.Q_index is None:
        raise ValueError('Q_index must be set to create DataGroup.')

    energy = self._verify_energy(energy)

    if energy is None:
        energy = self._masked_energy

    data_and_model = {
        'Data': self.experiment.binned_data['Q', self.Q_index],
        'Model': self._create_model_array(energy=energy),
    }

    if include_components:
        components = self._create_components_dataset_single_Q(
            add_background=add_background,
            energy=energy,
        )

        for key in components:
            data_and_model[key] = components[key]

    return sc.DataGroup(data_and_model)

fix_energy_offset()

Fix the energy offset parameter for the current Q index.

Source code in src/easydynamics/analysis/analysis1d.py
417
418
419
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
421
422
423
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())

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

    def get_parameters_near_bounds(
        self,
        rtol: float = 1e-5,
        atol: float = 1e-8,
    ) -> list[Parameter]:
        """
        Get a list of parameters that are near their bounds.

        Parameters
        ----------
        rtol : float, default=1e-5
            Relative tolerance for determining if a parameter is near its bound.
        atol : float, default=1e-8
            Absolute tolerance for determining if a parameter is near its bound.

        Returns
        -------
        list[Parameter]
            A list of parameters that are near their bounds.

        Raises
        ------
        TypeError
            If rtol or atol is not a float.
        ValueError
            If rtol or atol is negative.
        """

        if not isinstance(rtol, (int, float)):
            raise TypeError(f'rtol must be a float. Got {type(rtol)}.')

        if rtol < 0:
            raise ValueError(f'rtol must be non-negative. Got {rtol}.')

        if not isinstance(atol, (int, float)):
            raise TypeError(f'atol must be a float. Got {type(atol)}.')

        if atol < 0:
            raise ValueError(f'atol must be non-negative. Got {atol}.')

        parameters = self.get_all_parameters()
        at_bounds = []

        for p in parameters:
            value = p.value
            if not np.isfinite(value):
                at_bounds.append(p)
                continue

            at_min = not np.isneginf(p.min) and np.isclose(value, p.min, rtol=rtol, atol=atol)

            at_max = not np.isposinf(p.max) and np.isclose(value, p.max, rtol=rtol, atol=atol)

            if at_min or at_max:
                at_bounds.append(p)

        return at_bounds

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

    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

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

experiment property writable

Get the Experiment associated with this Analysis.

Returns:

Type Description
Experiment

The Experiment associated with this Analysis.

sample_model property writable

Get the SampleModel associated with this Analysis.

Returns:

Type Description
SampleModel

The SampleModel associated with this Analysis.

instrument_model property writable

Get the InstrumentModel associated with this Analysis.

Returns:

Type Description
InstrumentModel

The InstrumentModel associated with this Analysis.

Q property writable

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

Returns:

Type Description
sc.Variable | None

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

energy property writable

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

Returns:

Type Description
sc.Variable | None

The energy values from the associated.

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.

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.

extra_parameters property writable

Get the extra parameters included in this Analysis.

Returns:

Type Description
list[Parameter]

The extra parameters included in this Analysis.

__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()

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

get_parameters_near_bounds(rtol=1e-05, atol=1e-08)

Get a list of parameters that are near their bounds.

Parameters:

Name Type Description Default
rtol float

Relative tolerance for determining if a parameter is near its bound.

1e-5
atol float

Absolute tolerance for determining if a parameter is near its bound.

1e-8

Returns:

Type Description
list[Parameter]

A list of parameters that are near their bounds.

Raises:

Type Description
TypeError

If rtol or atol is not a float.

ValueError

If rtol or atol is negative.

Source code in src/easydynamics/analysis/analysis_base.py
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
def get_parameters_near_bounds(
    self,
    rtol: float = 1e-5,
    atol: float = 1e-8,
) -> list[Parameter]:
    """
    Get a list of parameters that are near their bounds.

    Parameters
    ----------
    rtol : float, default=1e-5
        Relative tolerance for determining if a parameter is near its bound.
    atol : float, default=1e-8
        Absolute tolerance for determining if a parameter is near its bound.

    Returns
    -------
    list[Parameter]
        A list of parameters that are near their bounds.

    Raises
    ------
    TypeError
        If rtol or atol is not a float.
    ValueError
        If rtol or atol is negative.
    """

    if not isinstance(rtol, (int, float)):
        raise TypeError(f'rtol must be a float. Got {type(rtol)}.')

    if rtol < 0:
        raise ValueError(f'rtol must be non-negative. Got {rtol}.')

    if not isinstance(atol, (int, float)):
        raise TypeError(f'atol must be a float. Got {type(atol)}.')

    if atol < 0:
        raise ValueError(f'atol must be non-negative. Got {atol}.')

    parameters = self.get_all_parameters()
    at_bounds = []

    for p in parameters:
        value = p.value
        if not np.isfinite(value):
            at_bounds.append(p)
            continue

        at_min = not np.isneginf(p.min) and np.isclose(value, p.min, rtol=rtol, atol=atol)

        at_max = not np.isposinf(p.max) and np.isclose(value, p.max, rtol=rtol, atol=atol)

        if at_min or at_max:
            at_bounds.append(p)

    return at_bounds

__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
580
581
582
583
584
585
586
587
588
589
590
591
592
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})'
    )

fit_binding

FitBinding

Bases: EasyDynamicsBase

Contract between dataset, model, and fit function for ParameterAnalysis. This class encapsulates the necessary information to bind a dataset key to a model and convert it into a fit function callable.

Source code in src/easydynamics/analysis/fit_binding.py
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 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
class FitBinding(EasyDynamicsBase):
    """
    Contract between dataset, model, and fit function for ParameterAnalysis. This class
    encapsulates the necessary information to bind a dataset key to a model and convert it into a
    fit function callable.
    """

    def __init__(
        self,
        parameter_name: str,
        model: ModelComponent | ComponentCollection | DiffusionModelBase,
        modes: str | list[str] | None = None,
        display_name: str | None = None,
        unique_name: str | None = None,
    ) -> None:
        """
        Initialize a FitBinding.

        Parameters
        ----------
        parameter_name : str
            The name of the parameter to fit. This should correspond to a key in the dataset.
        model : ModelComponent | ComponentCollection | DiffusionModelBase
            The model to fit. This can be a single ModelComponent, a ComponentCollection, or a
            DiffusionModelBase.
        modes : str | list[str] | None, default=None
            The modes to fit for diffusion models. This can be a single string, a list of strings,
            or None (which defaults to ["area", "width"]). Only applicable if the model is a
            DiffusionModelBase. Default is None.
        display_name : str | None, default=None
            An optional display name for the FitBinding. If None, the unique_name will be used.
            Default is None.
        unique_name : str | None, default=None
            An optional unique name for the FitBinding. If None, a unique name will be generated.
            Default is None.

        Raises
        ------
        TypeError
            If parameter_name is not a string, if model is not a ModelComponent,
            ComponentCollection or DiffusionModelBase, or if modes is not a string, list of
            strings, or None.

        Examples
        --------
        1. Basic usage with a ModelComponent:
        >>> import easydynamics.sample_model as sm
        >>> import easydynamics as edyn
        >>> fit_func = sm.Polynomial(coefficients=[3.7, -0.5], display_name='Straight line')
        >>> binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)
        >>> print(binding)
        FitBinding(parameter_name='Gaussian area', model=Polynomial(unique_name = Polynomial_1,
        unit = meV, coefficients = [Straight line_c0=3.7, Straight line_c1=-0.5]), modes=None)

        2. Usage with a DiffusionModelBase and specific modes:
        >>> brownian_diffusion_model = sm.BrownianTranslationalDiffusion(
        ...     display_name='Brownian Translational Diffusion',
        ...     diffusion_coefficient=2.4e-9,
        ...     scale=0.5,
        ... )
        >>> binding = edyn.FitBinding(
        ...     parameter_name='Lorentzian',
        ...     model=brownian_diffusion_model,
        ...     modes=['area', 'width'],
        ... )
        FitBinding(parameter_name=Lorentzian, model=Brownian Translational Diffusion,
        modes=['area', 'width'], display_name=FitBinding_1, unique_name=FitBinding_1)
        """

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

        if not isinstance(parameter_name, str):
            raise TypeError('parameter_name must be a string')

        if not isinstance(model, (ModelComponent, ComponentCollection, DiffusionModelBase)):
            raise TypeError(
                'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase'
            )

        if modes is not None and not isinstance(modes, (str, list)):
            raise TypeError('modes must be a string, list of strings, or None')

        if isinstance(modes, list) and not all(isinstance(mode, str) for mode in modes):
            raise TypeError('All modes in the list must be strings')

        self._parameter_name = parameter_name
        self._model = model
        self._modes = modes

    # ------------------------------------------------------------------
    # Properties
    # ------------------------------------------------------------------

    @property
    def parameter_name(self) -> str:
        """
        The name of the parameter to fit. This should correspond to a key in the dataset.

        Returns
        -------
        str
            The name of the parameter to fit.
        """
        return self._parameter_name

    @parameter_name.setter
    def parameter_name(self, value: str) -> None:
        """
        Set the name of the parameter to fit.

        Parameters
        ----------
        value : str
            The new name of the parameter to fit.

        Raises
        ------
        TypeError
            If the value is not a string.
        """
        if not isinstance(value, str):
            raise TypeError('parameter_name must be a string')
        self._parameter_name = value

    @property
    def model(self) -> ModelComponent | ComponentCollection | DiffusionModelBase:
        """
        The model to fit. This can be a single ModelComponent, a ComponentCollection, or a
        DiffusionModelBase.

        Returns
        -------
        ModelComponent | ComponentCollection | DiffusionModelBase
            The model to fit.
        """
        return self._model

    @model.setter
    def model(self, value: ModelComponent | ComponentCollection | DiffusionModelBase) -> None:
        """
        Set the model to fit.

        Parameters
        ----------
        value : ModelComponent | ComponentCollection | DiffusionModelBase
            The new model to fit.

        Raises
        ------
        TypeError
            If the value is not a ModelComponent, ComponentCollection, or DiffusionModelBase.
        """
        if not isinstance(value, (ModelComponent, ComponentCollection, DiffusionModelBase)):
            raise TypeError(
                'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase.'
            )
        self._model = value

    @property
    def modes(self) -> str | list[str] | None:
        """
        The modes to fit for diffusion models. This can be a single string, a list of strings, or
        None (which defaults to ["area", "width"]).

        Returns
        -------
        str | list[str] | None
            The modes to fit for diffusion models.
        """
        return self._modes

    @modes.setter
    def modes(self, value: str | list[str] | None) -> None:
        """
        Set the modes to fit for diffusion models.

        Parameters
        ----------
        value : str | list[str] | None
            The new modes to fit for diffusion models.

        Raises
        ------
        TypeError
            If the value is not a string, list of strings, or None.
        """
        if value is not None and not isinstance(value, (str, list)):
            raise TypeError('modes must be a string, list of strings, or None')

        if isinstance(value, str):
            value = [value]
        if isinstance(value, list) and not all(isinstance(mode, str) for mode in value):
            raise TypeError('All modes in the list must be strings')
        self._modes = value

    # ------------------------------------------------------------------
    # Other methods
    # ------------------------------------------------------------------

    def build_callables(self) -> list[Callable]:
        """
        Build the callables for fitting based on the model and modes.

        Returns
        -------
        list[Callable]
            A list of callables for fitting.
        """
        modes = self._get_modes()

        if isinstance(self.model, DiffusionModelBase):
            return [self._build_diffusion_callable(mode) for mode in modes]

        return [lambda x, **_: self.model.evaluate(x)]

    def get_model_names(self) -> list[str]:
        """
        Get the names of the models based on the current modes.

        Returns
        -------
        list[str]
            A list of model names.
        """
        modes = self._get_modes()

        if isinstance(self.model, DiffusionModelBase):
            return [f'{self.model.display_name} {mode}' for mode in modes]

        return [self.model.display_name]

    def get_parameter_names(self) -> list[str]:
        """
        Get the names of the parameters based on the current modes.

        Returns
        -------
        list[str]
            A list of parameter names.
        """
        modes = self._get_modes()

        if isinstance(self.model, DiffusionModelBase):
            return [f'{self.parameter_name} {mode}' for mode in modes]

        return [self.parameter_name]

    # ------------------------------------------------------------------
    # Private methods
    # ------------------------------------------------------------------

    def _build_diffusion_callable(self, mode: str) -> Callable:
        """
        Build a callable for a specific diffusion mode.

        Parameters
        ----------
        mode : str
            The diffusion mode ("area" or "width").

        Returns
        -------
        Callable
            A callable for the specified diffusion mode.

        Raises
        ------
        ValueError
            If the mode is unknown.
        """
        model = self.model

        if mode == 'area':
            return lambda x, **_: model.calculate_QISF(x) * model.scale.value

        if mode == 'width':
            return lambda x, **_: model.calculate_width(x)

        raise ValueError(f'Unknown diffusion mode: {mode}')

    def _get_modes(self) -> list[str]:
        """
        Get the modes to fit for diffusion models, defaulting to ["area", "width"] if not set.

        Returns
        -------
        list[str]
            The modes to fit for diffusion models.
        """
        return ['area', 'width'] if self.modes is None else self.modes

    # ------------------------------------------------------------------
    # dunder methods
    # ------------------------------------------------------------------

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

        Returns
        -------
        str
            A string representation of the FitBinding.
        """
        return (
            f'FitBinding(parameter_name={self.parameter_name},\n '
            f'model={self.model.display_name},\n '
            f'modes={self.modes},\n '
            f'display_name={self.display_name},\n '
            f'unique_name={self.unique_name})'
        )

parameter_name property writable

The name of the parameter to fit. This should correspond to a key in the dataset.

Returns:

Type Description
str

The name of the parameter to fit.

model property writable

The model to fit. This can be a single ModelComponent, a ComponentCollection, or a DiffusionModelBase.

Returns:

Type Description
ModelComponent | ComponentCollection | DiffusionModelBase

The model to fit.

modes property writable

The modes to fit for diffusion models. This can be a single string, a list of strings, or None (which defaults to ["area", "width"]).

Returns:

Type Description
str | list[str] | None

The modes to fit for diffusion models.

__init__(parameter_name, model, modes=None, display_name=None, unique_name=None)

Initialize a FitBinding.

Parameters:

Name Type Description Default
parameter_name str

The name of the parameter to fit. This should correspond to a key in the dataset.

required
model ModelComponent | ComponentCollection | DiffusionModelBase

The model to fit. This can be a single ModelComponent, a ComponentCollection, or a DiffusionModelBase.

required
modes str | list[str] | None

The modes to fit for diffusion models. This can be a single string, a list of strings, or None (which defaults to ["area", "width"]). Only applicable if the model is a DiffusionModelBase. Default is None.

None
display_name str | None

An optional display name for the FitBinding. If None, the unique_name will be used. Default is None.

None
unique_name str | None

An optional unique name for the FitBinding. If None, a unique name will be generated. Default is None.

None

Raises:

Type Description
TypeError

If parameter_name is not a string, if model is not a ModelComponent, ComponentCollection or DiffusionModelBase, or if modes is not a string, list of strings, or None.

Examples:

  1. Basic usage with a ModelComponent:
>>> import easydynamics.sample_model as sm
>>> import easydynamics as edyn
>>> fit_func = sm.Polynomial(coefficients=[3.7, -0.5], display_name='Straight line')
>>> binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)
>>> print(binding)
FitBinding(parameter_name='Gaussian area', model=Polynomial(unique_name = Polynomial_1,
unit = meV, coefficients = [Straight line_c0=3.7, Straight line_c1=-0.5]), modes=None)
  1. Usage with a DiffusionModelBase and specific modes:
>>> brownian_diffusion_model = sm.BrownianTranslationalDiffusion(
...     display_name='Brownian Translational Diffusion',
...     diffusion_coefficient=2.4e-9,
...     scale=0.5,
... )
>>> binding = edyn.FitBinding(
...     parameter_name='Lorentzian',
...     model=brownian_diffusion_model,
...     modes=['area', 'width'],
... )
FitBinding(parameter_name=Lorentzian, model=Brownian Translational Diffusion,
modes=['area', 'width'], display_name=FitBinding_1, unique_name=FitBinding_1)
Source code in src/easydynamics/analysis/fit_binding.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
def __init__(
    self,
    parameter_name: str,
    model: ModelComponent | ComponentCollection | DiffusionModelBase,
    modes: str | list[str] | None = None,
    display_name: str | None = None,
    unique_name: str | None = None,
) -> None:
    """
    Initialize a FitBinding.

    Parameters
    ----------
    parameter_name : str
        The name of the parameter to fit. This should correspond to a key in the dataset.
    model : ModelComponent | ComponentCollection | DiffusionModelBase
        The model to fit. This can be a single ModelComponent, a ComponentCollection, or a
        DiffusionModelBase.
    modes : str | list[str] | None, default=None
        The modes to fit for diffusion models. This can be a single string, a list of strings,
        or None (which defaults to ["area", "width"]). Only applicable if the model is a
        DiffusionModelBase. Default is None.
    display_name : str | None, default=None
        An optional display name for the FitBinding. If None, the unique_name will be used.
        Default is None.
    unique_name : str | None, default=None
        An optional unique name for the FitBinding. If None, a unique name will be generated.
        Default is None.

    Raises
    ------
    TypeError
        If parameter_name is not a string, if model is not a ModelComponent,
        ComponentCollection or DiffusionModelBase, or if modes is not a string, list of
        strings, or None.

    Examples
    --------
    1. Basic usage with a ModelComponent:
    >>> import easydynamics.sample_model as sm
    >>> import easydynamics as edyn
    >>> fit_func = sm.Polynomial(coefficients=[3.7, -0.5], display_name='Straight line')
    >>> binding = edyn.FitBinding(parameter_name='Gaussian area', model=fit_func)
    >>> print(binding)
    FitBinding(parameter_name='Gaussian area', model=Polynomial(unique_name = Polynomial_1,
    unit = meV, coefficients = [Straight line_c0=3.7, Straight line_c1=-0.5]), modes=None)

    2. Usage with a DiffusionModelBase and specific modes:
    >>> brownian_diffusion_model = sm.BrownianTranslationalDiffusion(
    ...     display_name='Brownian Translational Diffusion',
    ...     diffusion_coefficient=2.4e-9,
    ...     scale=0.5,
    ... )
    >>> binding = edyn.FitBinding(
    ...     parameter_name='Lorentzian',
    ...     model=brownian_diffusion_model,
    ...     modes=['area', 'width'],
    ... )
    FitBinding(parameter_name=Lorentzian, model=Brownian Translational Diffusion,
    modes=['area', 'width'], display_name=FitBinding_1, unique_name=FitBinding_1)
    """

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

    if not isinstance(parameter_name, str):
        raise TypeError('parameter_name must be a string')

    if not isinstance(model, (ModelComponent, ComponentCollection, DiffusionModelBase)):
        raise TypeError(
            'model must be a ModelComponent, ComponentCollection, or DiffusionModelBase'
        )

    if modes is not None and not isinstance(modes, (str, list)):
        raise TypeError('modes must be a string, list of strings, or None')

    if isinstance(modes, list) and not all(isinstance(mode, str) for mode in modes):
        raise TypeError('All modes in the list must be strings')

    self._parameter_name = parameter_name
    self._model = model
    self._modes = modes

build_callables()

Build the callables for fitting based on the model and modes.

Returns:

Type Description
list[Callable]

A list of callables for fitting.

Source code in src/easydynamics/analysis/fit_binding.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
def build_callables(self) -> list[Callable]:
    """
    Build the callables for fitting based on the model and modes.

    Returns
    -------
    list[Callable]
        A list of callables for fitting.
    """
    modes = self._get_modes()

    if isinstance(self.model, DiffusionModelBase):
        return [self._build_diffusion_callable(mode) for mode in modes]

    return [lambda x, **_: self.model.evaluate(x)]

get_model_names()

Get the names of the models based on the current modes.

Returns:

Type Description
list[str]

A list of model names.

Source code in src/easydynamics/analysis/fit_binding.py
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def get_model_names(self) -> list[str]:
    """
    Get the names of the models based on the current modes.

    Returns
    -------
    list[str]
        A list of model names.
    """
    modes = self._get_modes()

    if isinstance(self.model, DiffusionModelBase):
        return [f'{self.model.display_name} {mode}' for mode in modes]

    return [self.model.display_name]

get_parameter_names()

Get the names of the parameters based on the current modes.

Returns:

Type Description
list[str]

A list of parameter names.

Source code in src/easydynamics/analysis/fit_binding.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
def get_parameter_names(self) -> list[str]:
    """
    Get the names of the parameters based on the current modes.

    Returns
    -------
    list[str]
        A list of parameter names.
    """
    modes = self._get_modes()

    if isinstance(self.model, DiffusionModelBase):
        return [f'{self.parameter_name} {mode}' for mode in modes]

    return [self.parameter_name]

__repr__()

Return a string representation of the FitBinding.

Returns:

Type Description
str

A string representation of the FitBinding.

Source code in src/easydynamics/analysis/fit_binding.py
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
def __repr__(self) -> str:
    """
    Return a string representation of the FitBinding.

    Returns
    -------
    str
        A string representation of the FitBinding.
    """
    return (
        f'FitBinding(parameter_name={self.parameter_name},\n '
        f'model={self.model.display_name},\n '
        f'modes={self.modes},\n '
        f'display_name={self.display_name},\n '
        f'unique_name={self.unique_name})'
    )

parameter_analysis

ParameterAnalysis

Bases: EasyDynamicsModelBase

For analysing fitted parameters.

Can be used to fit parameters to ModelComponents, ComponentCollections, or DiffusionModelBase objects, and to plot the parameters and fit results. The parameters to be analyzed can be provided as a sc.Dataset or directly as an Analysis object. Multiple parameters can be fitted simultaneously, and the fit functions can be customized for each parameter. For diffusion models, the area and width can be fitted separately (or not at all) by specifying fit settings.

Source code in src/easydynamics/analysis/parameter_analysis.py
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
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
class ParameterAnalysis(EasyDynamicsModelBase):
    """
    For analysing fitted parameters.

    Can be used to fit parameters to ModelComponents, ComponentCollections, or DiffusionModelBase
    objects, and to plot the parameters and fit results. The parameters to be analyzed can be
    provided as a sc.Dataset or directly as an Analysis object. Multiple parameters can be fitted
    simultaneously, and the fit functions can be customized for each parameter. For diffusion
    models, the area and width can be fitted separately (or not at all) by specifying fit settings.
    """

    def __init__(
        self,
        parameters: sc.Dataset | Analysis | None = None,
        bindings: FitBinding | list[FitBinding] | None = None,
        display_name: str | None = 'ParameterAnalysis',
        unique_name: str | None = None,
    ) -> None:
        """
        Initialize the ParameterAnalysis.

        Parameters
        ----------
        parameters : sc.Dataset | Analysis | None, default=None
            The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which
            case the parameters will be extracted from the Analysis).
        bindings : FitBinding | list[FitBinding] | None, default=None
            The fit bindings to use for fitting the parameters. Can be a single FitBinding or a
            list of FitBindings. If None, no fit bindings are provided.
        display_name : str | None, default='ParameterAnalysis'
            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.
        """

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

        self._parameters = self._verify_parameters(parameters)

        self._bindings = self._verify_bindings(bindings)

    #############
    # Properties
    #############
    @property
    def parameters(self) -> sc.Dataset | None:
        """
        Get the parameters for the parameter analysis.

        Returns
        -------
        sc.Dataset | None
            The parameters for the parameter analysis.
        """
        return self._parameters

    @parameters.setter
    def parameters(self, value: sc.Dataset | Analysis | None) -> None:
        """
        Set the parameter dataset for the parameter analysis.

        Parameters
        ----------
        value : sc.Dataset | Analysis | None
            The new parameter dataset for the parameter analysis.
        """
        self._parameters = self._verify_parameters(value)

    @property
    def bindings(self) -> list[FitBinding]:
        """
        Get the fit bindings for the parameter analysis.

        Returns
        -------
        list[FitBinding]
            The fit bindings for the parameter analysis.
        """
        return self._bindings

    @bindings.setter
    def bindings(self, value: FitBinding | list[FitBinding] | None) -> None:
        """
        Set the fit bindings for the parameter analysis.

        Parameters
        ----------
        value : FitBinding | list[FitBinding] | None
            The new fit bindings for the parameter analysis.
        """
        self._bindings = self._verify_bindings(value)

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

    def fit(self) -> FitResults:
        """
        Fit the parameters using the specified fit functions and settings.

        Returns
        -------
        FitResults
            The results of the fit

        Raises
        ------
        ValueError
            If no parameters Dataset is provided. If no fit functions are provided. If no parameter
            names are found for the fit functions.
        """

        if self.parameters is None:
            raise ValueError('No parameters Dataset provided.')

        if not self.bindings:
            raise ValueError('No fit bindings provided.')

        xs = []
        ys = []
        ws = []
        funcs, models = [], []

        for binding in self.bindings:
            param_names = binding.get_parameter_names()
            callables = binding.build_callables()

            for pname, func in zip(param_names, callables, strict=True):
                if pname not in self.parameters:
                    raise ValueError(
                        f"Parameter '{pname}' from binding '{binding.unique_name}' "
                        f'not found in parameters Dataset.'
                    )

                x, y, weight = self._get_xyweight_from_dataset(pname)

                xs.append(x)
                ys.append(y)
                ws.append(weight)

                funcs.append(func)
                models.append(binding.model)

        mf = MultiFitter(
            fit_objects=models,
            fit_functions=funcs,
        )

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

    def plot(
        self, names: str | list[str] | None = None, **kwargs: dict[str, Any]
    ) -> InteractiveFigure:
        """
        Plot the parameters and fit results.

        Parameters
        ----------
        names : str | list[str] | None, default=None
            The names of the parameters to plot. If None, all parameters with bindings are plotted.
        **kwargs : dict[str, Any]
            Additional keyword arguments to pass to the plotting function.

        Returns
        -------
        InteractiveFigure
            An interactive figure containing the plots of the parameters and fit results.

        Raises
        ------
        ValueError
            If the units of the specified parameters are not consistent.
        RuntimeError
            If plot() is called outside of a Jupyter notebook environment.
        """

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

        if self.parameters is None:
            raise ValueError('No parameters available to plot.')

        full_model_dataset = None
        if self.bindings:
            full_model_dataset = self.calculate_model_dataset(self.bindings)

        # If no names are provided, default to plot all parameters that have bindings.
        # If no bindings are provided, plot all parameters.
        if names is None:
            names = []

            if not self.bindings:
                names = list(self.parameters.keys())
            else:
                for b in self.bindings:
                    names.extend(b.get_parameter_names())

        names = self._normalize_names(names)

        # Check that the units of the specified parameters are consistent.
        units = [self.parameters[name].unit for name in names]
        first_unit = units[0]
        if any(unit != first_unit for unit in units):
            raise ValueError(f'Units are not compatible, and cannot be plotted together: {units}')

        color_cycle = itertools.cycle(rcParams['axes.prop_cycle'].by_key()['color'])
        markers = itertools.cycle(['o', 's', 'D', '^', 'v', '<', '>'])

        plot_kwargs = {
            'title': self.display_name,
            'linestyle': {},
            'marker': {},
            'color': {},
            'markerfacecolor': {},
        }

        data_arrays = {}
        model_arrays = {}

        # map parameter names to model names
        param_to_model = {}
        if self.bindings is not None:
            for b in self.bindings:
                param_names = b.get_parameter_names()
                model_names = b.get_model_names()

                param_to_model.update(dict(zip(param_names, model_names, strict=True)))

        for pname in names:
            data_arrays[pname] = self.parameters[pname]
            color = next(color_cycle)
            marker = next(markers)

            # Data styling
            plot_kwargs['linestyle'][pname] = 'none'
            plot_kwargs['marker'][pname] = marker
            plot_kwargs['color'][pname] = color
            plot_kwargs['markerfacecolor'][pname] = 'none'

            if full_model_dataset is not None and pname in param_to_model:
                mname = param_to_model[pname]
                model_arrays[mname] = full_model_dataset[mname]

                # Model styling
                plot_kwargs['linestyle'][mname] = '--'
                plot_kwargs['marker'][mname] = None
                plot_kwargs['color'][mname] = color

        # Update kwargs with user provided kwargs.
        plot_kwargs.update(kwargs)

        data_and_model = sc.Dataset(data_arrays)
        data_and_model.update(model_arrays)

        return pp.plot(data_and_model, **plot_kwargs)

    def calculate_model_dataset(self, bindings: list[FitBinding]) -> sc.Dataset:
        """
        Evaluate all bindings into a sc.Dataset of model predictions.

        Parameters
        ----------
        bindings : list[FitBinding]
            The bindings to evaluate.

        Returns
        -------
        sc.Dataset
            A sc.Dataset containing the model predictions for all bindings.

        Raises
        ------
        ValueError
            If any parameter name from the bindings is not found in the parameters Dataset.

        TypeError
            If bindings is not a list of FitBinding objects.
        """

        if self.parameters is None:
            raise ValueError('No parameters Dataset provided.')

        if not bindings:
            raise ValueError('No fit bindings provided.')

        if not isinstance(bindings, list) or not all(isinstance(b, FitBinding) for b in bindings):
            raise TypeError('bindings must be a list of FitBinding objects.')

        arrays = {}

        for b in bindings:
            param_names = b.get_parameter_names()
            model_names = b.get_model_names()
            callables = b.build_callables()

            for pname, mname, func in zip(param_names, model_names, callables, strict=True):
                if pname not in self.parameters:
                    raise ValueError(
                        f"Parameter '{pname}' from binding '{b.unique_name}' "
                        f'not found in parameters Dataset.'
                    )
                da = self.parameters[pname]
                x = da.coords['Q']

                y_model = func(x.values)

                arrays[mname] = sc.DataArray(
                    data=sc.array(dims=['Q'], values=y_model, unit=da.unit),
                    coords={'Q': x},
                )
        return sc.Dataset(arrays)

    def append_binding(self, binding: FitBinding) -> None:
        """
        Append a FitBinding to the list of bindings for the parameter analysis.

        Parameters
        ----------
        binding : FitBinding
            The FitBinding to append.

        Raises
        ------
        TypeError
            If binding is not a FitBinding object.
        """
        if not isinstance(binding, FitBinding):
            raise TypeError('binding must be a FitBinding object.')
        self._bindings.append(binding)

    def clear_bindings(self) -> None:
        """
        Clear all FitBindings from the list of bindings for the parameter analysis.
        """
        self._bindings.clear()

    def get_all_variables(self) -> list:
        """
        Get all variables from the fit functions.

        Returns
        -------
        list
            A list of all variables from the fit functions.
        """
        variables = set()
        for b in self._bindings:
            variables.update(b.model.get_all_variables())
        return list(variables)

    #############
    # Private methods: verification and preparation
    #############

    def _verify_bindings(self, bindings: FitBinding | list[FitBinding] | None) -> list[FitBinding]:
        """
        Verify the bindings input.

        Parameters
        ----------
        bindings : FitBinding | list[FitBinding] | None
            The bindings to verify.

        Returns
        -------
        list[FitBinding]
            A list of verified FitBindings.

        Raises
        ------
        TypeError
            If bindings is not a FitBinding, a list of FitBindings, or None.
        """
        if bindings is None:
            return []
        if isinstance(bindings, FitBinding):
            return [bindings]
        if isinstance(bindings, list) and all(isinstance(b, FitBinding) for b in bindings):
            return bindings
        raise TypeError('bindings must be a FitBinding, a list of FitBindings, or None.')

    def _verify_parameters(self, parameters: sc.Dataset | Analysis | None) -> sc.Dataset | None:
        """
        Verify the parameters input and convert it to a sc.Dataset if it's an Analysis.

        Parameters
        ----------
        parameters : sc.Dataset | Analysis | None
            The parameters to verify.

        Returns
        -------
        sc.Dataset | None
            The verified parameters as a sc.Dataset, or None if no parameters were provided.

        Raises
        ------
        TypeError
            If parameters is not a sc.Dataset, an Analysis, or None.
        ValueError
            If parameters is a sc.Dataset but does not have a 'Q' coordinate.
        """
        if parameters is None:
            return None

        if not isinstance(parameters, (sc.Dataset, Analysis)):
            raise TypeError(r'parameters must be a sc.Dataset, an Analysis, or None.')

        if isinstance(parameters, Analysis):
            verified_parameters = parameters.parameters_to_dataset()
        else:
            verified_parameters = parameters

        if 'Q' not in verified_parameters.coords:
            raise ValueError(r"parameters must have a 'Q' coordinate.")
        return verified_parameters

    def _normalize_names(self, names: str | list[str] | None) -> list[str] | None:
        """
        Normalize the names input to a list of strings and verify that they exist in the parameters
        Dataset.

        Parameters
        ----------
        names : str | list[str] | None
            The names to normalize and verify.

        Returns
        -------
        list[str] | None
            The normalized list of names, or None if names was None.

        Raises
        ------
        ValueError
            If any of the specified names are not found in the parameters Dataset, or if names is a
            list that contains non-string elements.
        """
        if names is None:
            return None
        if not isinstance(names, (str, list)):
            raise ValueError('names must be a string, a list of strings, or None.')
        if isinstance(names, list):
            if not all(isinstance(name, str) for name in names):
                raise ValueError('All names in the list must be strings.')
            for name in names:
                if name not in self.parameters:
                    raise ValueError(f"Parameter name '{name}' not found in parameters Dataset.")
        if isinstance(names, str):
            if names not in self.parameters:
                raise ValueError(f"Parameter name '{names}' not found in parameters Dataset.")
            names = [names]
        return names

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

    def _get_xyweight_from_dataset(
        self, parameter_name: str
    ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
        """
        Get the x, y, and weight values for a given parameter name from the parameters Dataset.

        Parameters
        ----------
        parameter_name : str
            The name of the parameter to get x, y, and weight values for.

        Returns
        -------
        tuple[np.ndarray, np.ndarray, np.ndarray]
            A tuple containing the x, y, and weight values for the given parameter name.

        Raises
        ------
        ValueError
            If the parameter name is not found in the parameters Dataset. If non-finite weights are
            found for the parameter.
        """
        if self._parameters is None:
            raise ValueError('No parameters Dataset provided.')
        if parameter_name not in self._parameters:
            raise ValueError(f"Parameter name '{parameter_name}' not found in parameters Dataset.")

        variances = self._parameters[parameter_name].variances
        if variances is None:
            weight = np.ones_like(self._parameters[parameter_name].values)
        elif np.any(~np.isfinite(variances)) or np.any(variances <= 0):
            raise ValueError(
                f"Non-finite variances found for parameter '{parameter_name}', "
                f'cannot compute weights.'
            )
        else:
            weight = 1 / np.sqrt(variances)

        return (
            self._parameters[parameter_name].coords['Q'].values,
            self._parameters[parameter_name].values,
            weight,
        )

    #############
    # Dunder methods
    #############
    def __repr__(self) -> str:
        cls = self.__class__.__name__

        n_params = len(self._parameters) if isinstance(self._parameters, sc.Dataset) else 0

        param_names = (
            list(self._parameters.keys()) if isinstance(self._parameters, sc.Dataset) else None
        )

        binding_info = [
            {
                'parameter': b.parameter_name,
                'model': b.model.display_name,
                'modes': b.modes,
            }
            for b in self._bindings
        ]

        return (
            f'{cls}(\n'
            f'display_name={self.display_name},\n'
            f'unique_name={self.unique_name},\n'
            f'n_parameters={n_params},\n'
            f'parameter_names={param_names},\n'
            f'bindings={binding_info}\n'
            f')'
        )

parameters property writable

Get the parameters for the parameter analysis.

Returns:

Type Description
sc.Dataset | None

The parameters for the parameter analysis.

bindings property writable

Get the fit bindings for the parameter analysis.

Returns:

Type Description
list[FitBinding]

The fit bindings for the parameter analysis.

__init__(parameters=None, bindings=None, display_name='ParameterAnalysis', unique_name=None)

Initialize the ParameterAnalysis.

Parameters:

Name Type Description Default
parameters sc.Dataset | Analysis | None

The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which case the parameters will be extracted from the Analysis).

None
bindings FitBinding | list[FitBinding] | None

The fit bindings to use for fitting the parameters. Can be a single FitBinding or a list of FitBindings. If None, no fit bindings are provided.

None
display_name str | None

Display name of the analysis.

'ParameterAnalysis'
unique_name str | None

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

None
Source code in src/easydynamics/analysis/parameter_analysis.py
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
def __init__(
    self,
    parameters: sc.Dataset | Analysis | None = None,
    bindings: FitBinding | list[FitBinding] | None = None,
    display_name: str | None = 'ParameterAnalysis',
    unique_name: str | None = None,
) -> None:
    """
    Initialize the ParameterAnalysis.

    Parameters
    ----------
    parameters : sc.Dataset | Analysis | None, default=None
        The parameters to analyze. Can be provided as a sc.Dataset or as an Analysis (in which
        case the parameters will be extracted from the Analysis).
    bindings : FitBinding | list[FitBinding] | None, default=None
        The fit bindings to use for fitting the parameters. Can be a single FitBinding or a
        list of FitBindings. If None, no fit bindings are provided.
    display_name : str | None, default='ParameterAnalysis'
        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.
    """

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

    self._parameters = self._verify_parameters(parameters)

    self._bindings = self._verify_bindings(bindings)

fit()

Fit the parameters using the specified fit functions and settings.

Returns:

Type Description
FitResults

The results of the fit

Raises:

Type Description
ValueError

If no parameters Dataset is provided. If no fit functions are provided. If no parameter names are found for the fit functions.

Source code in src/easydynamics/analysis/parameter_analysis.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
def fit(self) -> FitResults:
    """
    Fit the parameters using the specified fit functions and settings.

    Returns
    -------
    FitResults
        The results of the fit

    Raises
    ------
    ValueError
        If no parameters Dataset is provided. If no fit functions are provided. If no parameter
        names are found for the fit functions.
    """

    if self.parameters is None:
        raise ValueError('No parameters Dataset provided.')

    if not self.bindings:
        raise ValueError('No fit bindings provided.')

    xs = []
    ys = []
    ws = []
    funcs, models = [], []

    for binding in self.bindings:
        param_names = binding.get_parameter_names()
        callables = binding.build_callables()

        for pname, func in zip(param_names, callables, strict=True):
            if pname not in self.parameters:
                raise ValueError(
                    f"Parameter '{pname}' from binding '{binding.unique_name}' "
                    f'not found in parameters Dataset.'
                )

            x, y, weight = self._get_xyweight_from_dataset(pname)

            xs.append(x)
            ys.append(y)
            ws.append(weight)

            funcs.append(func)
            models.append(binding.model)

    mf = MultiFitter(
        fit_objects=models,
        fit_functions=funcs,
    )

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

plot(names=None, **kwargs)

Plot the parameters and fit results.

Parameters:

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

The names of the parameters to plot. If None, all parameters with bindings are plotted.

None
**kwargs dict[str, Any]

Additional keyword arguments to pass to the plotting function.

{}

Returns:

Type Description
InteractiveFigure

An interactive figure containing the plots of the parameters and fit results.

Raises:

Type Description
ValueError

If the units of the specified parameters are not consistent.

RuntimeError

If plot() is called outside of a Jupyter notebook environment.

Source code in src/easydynamics/analysis/parameter_analysis.py
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
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
def plot(
    self, names: str | list[str] | None = None, **kwargs: dict[str, Any]
) -> InteractiveFigure:
    """
    Plot the parameters and fit results.

    Parameters
    ----------
    names : str | list[str] | None, default=None
        The names of the parameters to plot. If None, all parameters with bindings are plotted.
    **kwargs : dict[str, Any]
        Additional keyword arguments to pass to the plotting function.

    Returns
    -------
    InteractiveFigure
        An interactive figure containing the plots of the parameters and fit results.

    Raises
    ------
    ValueError
        If the units of the specified parameters are not consistent.
    RuntimeError
        If plot() is called outside of a Jupyter notebook environment.
    """

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

    if self.parameters is None:
        raise ValueError('No parameters available to plot.')

    full_model_dataset = None
    if self.bindings:
        full_model_dataset = self.calculate_model_dataset(self.bindings)

    # If no names are provided, default to plot all parameters that have bindings.
    # If no bindings are provided, plot all parameters.
    if names is None:
        names = []

        if not self.bindings:
            names = list(self.parameters.keys())
        else:
            for b in self.bindings:
                names.extend(b.get_parameter_names())

    names = self._normalize_names(names)

    # Check that the units of the specified parameters are consistent.
    units = [self.parameters[name].unit for name in names]
    first_unit = units[0]
    if any(unit != first_unit for unit in units):
        raise ValueError(f'Units are not compatible, and cannot be plotted together: {units}')

    color_cycle = itertools.cycle(rcParams['axes.prop_cycle'].by_key()['color'])
    markers = itertools.cycle(['o', 's', 'D', '^', 'v', '<', '>'])

    plot_kwargs = {
        'title': self.display_name,
        'linestyle': {},
        'marker': {},
        'color': {},
        'markerfacecolor': {},
    }

    data_arrays = {}
    model_arrays = {}

    # map parameter names to model names
    param_to_model = {}
    if self.bindings is not None:
        for b in self.bindings:
            param_names = b.get_parameter_names()
            model_names = b.get_model_names()

            param_to_model.update(dict(zip(param_names, model_names, strict=True)))

    for pname in names:
        data_arrays[pname] = self.parameters[pname]
        color = next(color_cycle)
        marker = next(markers)

        # Data styling
        plot_kwargs['linestyle'][pname] = 'none'
        plot_kwargs['marker'][pname] = marker
        plot_kwargs['color'][pname] = color
        plot_kwargs['markerfacecolor'][pname] = 'none'

        if full_model_dataset is not None and pname in param_to_model:
            mname = param_to_model[pname]
            model_arrays[mname] = full_model_dataset[mname]

            # Model styling
            plot_kwargs['linestyle'][mname] = '--'
            plot_kwargs['marker'][mname] = None
            plot_kwargs['color'][mname] = color

    # Update kwargs with user provided kwargs.
    plot_kwargs.update(kwargs)

    data_and_model = sc.Dataset(data_arrays)
    data_and_model.update(model_arrays)

    return pp.plot(data_and_model, **plot_kwargs)

calculate_model_dataset(bindings)

Evaluate all bindings into a sc.Dataset of model predictions.

Parameters:

Name Type Description Default
bindings list[FitBinding]

The bindings to evaluate.

required

Returns:

Type Description
sc.Dataset

A sc.Dataset containing the model predictions for all bindings.

Raises:

Type Description
ValueError

If any parameter name from the bindings is not found in the parameters Dataset.

TypeError

If bindings is not a list of FitBinding objects.

Source code in src/easydynamics/analysis/parameter_analysis.py
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
def calculate_model_dataset(self, bindings: list[FitBinding]) -> sc.Dataset:
    """
    Evaluate all bindings into a sc.Dataset of model predictions.

    Parameters
    ----------
    bindings : list[FitBinding]
        The bindings to evaluate.

    Returns
    -------
    sc.Dataset
        A sc.Dataset containing the model predictions for all bindings.

    Raises
    ------
    ValueError
        If any parameter name from the bindings is not found in the parameters Dataset.

    TypeError
        If bindings is not a list of FitBinding objects.
    """

    if self.parameters is None:
        raise ValueError('No parameters Dataset provided.')

    if not bindings:
        raise ValueError('No fit bindings provided.')

    if not isinstance(bindings, list) or not all(isinstance(b, FitBinding) for b in bindings):
        raise TypeError('bindings must be a list of FitBinding objects.')

    arrays = {}

    for b in bindings:
        param_names = b.get_parameter_names()
        model_names = b.get_model_names()
        callables = b.build_callables()

        for pname, mname, func in zip(param_names, model_names, callables, strict=True):
            if pname not in self.parameters:
                raise ValueError(
                    f"Parameter '{pname}' from binding '{b.unique_name}' "
                    f'not found in parameters Dataset.'
                )
            da = self.parameters[pname]
            x = da.coords['Q']

            y_model = func(x.values)

            arrays[mname] = sc.DataArray(
                data=sc.array(dims=['Q'], values=y_model, unit=da.unit),
                coords={'Q': x},
            )
    return sc.Dataset(arrays)

append_binding(binding)

Append a FitBinding to the list of bindings for the parameter analysis.

Parameters:

Name Type Description Default
binding FitBinding

The FitBinding to append.

required

Raises:

Type Description
TypeError

If binding is not a FitBinding object.

Source code in src/easydynamics/analysis/parameter_analysis.py
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
def append_binding(self, binding: FitBinding) -> None:
    """
    Append a FitBinding to the list of bindings for the parameter analysis.

    Parameters
    ----------
    binding : FitBinding
        The FitBinding to append.

    Raises
    ------
    TypeError
        If binding is not a FitBinding object.
    """
    if not isinstance(binding, FitBinding):
        raise TypeError('binding must be a FitBinding object.')
    self._bindings.append(binding)

clear_bindings()

Clear all FitBindings from the list of bindings for the parameter analysis.

Source code in src/easydynamics/analysis/parameter_analysis.py
356
357
358
359
360
def clear_bindings(self) -> None:
    """
    Clear all FitBindings from the list of bindings for the parameter analysis.
    """
    self._bindings.clear()

get_all_variables()

Get all variables from the fit functions.

Returns:

Type Description
list

A list of all variables from the fit functions.

Source code in src/easydynamics/analysis/parameter_analysis.py
362
363
364
365
366
367
368
369
370
371
372
373
374
def get_all_variables(self) -> list:
    """
    Get all variables from the fit functions.

    Returns
    -------
    list
        A list of all variables from the fit functions.
    """
    variables = set()
    for b in self._bindings:
        variables.update(b.model.get_all_variables())
    return list(variables)