Skip to content

experiments

collections

background

ChebyshevPolynomialBackground

Bases: BackgroundBase

Source code in src/easydiffraction/experiments/collections/background.py
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
class ChebyshevPolynomialBackground(BackgroundBase):
    _description: str = 'Chebyshev polynomial background'

    @property
    def _child_class(self) -> Type[PolynomialTerm]:
        return PolynomialTerm

    def calculate(self, x_data: np.ndarray) -> np.ndarray:
        """Evaluate polynomial background over x_data."""
        if not self._items:
            print(warning('No background points found. Setting background to zero.'))
            return np.zeros_like(x_data)

        u = (x_data - x_data.min()) / (x_data.max() - x_data.min()) * 2 - 1  # scale to [-1, 1]
        coefs = [term.coef.value for term in self._items.values()]
        y_data = chebval(u, coefs)
        return y_data

    def show(self) -> None:
        columns_headers: List[str] = ['Order', 'Coefficient']
        columns_alignment = ['left', 'left']
        columns_data: List[List[Union[int, float]]] = []
        for term in self._items.values():
            order = term.order.value
            coef = term.coef.value
            columns_data.append([order, coef])

        print(paragraph('Chebyshev polynomial background terms'))
        render_table(
            columns_headers=columns_headers,
            columns_alignment=columns_alignment,
            columns_data=columns_data,
        )
calculate(x_data)

Evaluate polynomial background over x_data.

Source code in src/easydiffraction/experiments/collections/background.py
165
166
167
168
169
170
171
172
173
174
def calculate(self, x_data: np.ndarray) -> np.ndarray:
    """Evaluate polynomial background over x_data."""
    if not self._items:
        print(warning('No background points found. Setting background to zero.'))
        return np.zeros_like(x_data)

    u = (x_data - x_data.min()) / (x_data.max() - x_data.min()) * 2 - 1  # scale to [-1, 1]
    coefs = [term.coef.value for term in self._items.values()]
    y_data = chebval(u, coefs)
    return y_data

LineSegmentBackground

Bases: BackgroundBase

Source code in src/easydiffraction/experiments/collections/background.py
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
class LineSegmentBackground(BackgroundBase):
    _description: str = 'Linear interpolation between points'

    @property
    def _child_class(self) -> Type[Point]:
        return Point

    def calculate(self, x_data: np.ndarray) -> np.ndarray:
        """Interpolate background points over x_data."""
        if not self._items:
            print(warning('No background points found. Setting background to zero.'))
            return np.zeros_like(x_data)

        background_x = np.array([point.x.value for point in self._items.values()])
        background_y = np.array([point.y.value for point in self._items.values()])
        interp_func = interp1d(
            background_x,
            background_y,
            kind='linear',
            bounds_error=False,
            fill_value=(
                background_y[0],
                background_y[-1],
            ),
        )
        y_data = interp_func(x_data)
        return y_data

    def show(self) -> None:
        columns_headers: List[str] = ['X', 'Intensity']
        columns_alignment = ['left', 'left']
        columns_data: List[List[float]] = []
        for point in self._items.values():
            x = point.x.value
            y = point.y.value
            columns_data.append([x, y])

        print(paragraph('Line-segment background points'))
        render_table(
            columns_headers=columns_headers,
            columns_alignment=columns_alignment,
            columns_data=columns_data,
        )
calculate(x_data)

Interpolate background points over x_data.

Source code in src/easydiffraction/experiments/collections/background.py
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def calculate(self, x_data: np.ndarray) -> np.ndarray:
    """Interpolate background points over x_data."""
    if not self._items:
        print(warning('No background points found. Setting background to zero.'))
        return np.zeros_like(x_data)

    background_x = np.array([point.x.value for point in self._items.values()])
    background_y = np.array([point.y.value for point in self._items.values()])
    interp_func = interp1d(
        background_x,
        background_y,
        kind='linear',
        bounds_error=False,
        fill_value=(
            background_y[0],
            background_y[-1],
        ),
    )
    y_data = interp_func(x_data)
    return y_data

datastore

Datastore

Stores pattern data (measured and calculated) for an experiment.

Source code in src/easydiffraction/experiments/collections/datastore.py
 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
class Datastore:
    """
    Stores pattern data (measured and calculated) for an experiment.
    """

    def __init__(self, sample_form: str, experiment: Experiment) -> None:
        self.sample_form: str = sample_form

        if sample_form == 'powder':
            self.pattern: Pattern = PowderPattern(experiment)
        elif sample_form == 'single_crystal':
            self.pattern: Pattern = Pattern(experiment)  # TODO: Find better name for single crystal pattern
        else:
            raise ValueError(f"Unknown sample form '{sample_form}'")

    def load_measured_data(self, file_path: str) -> None:
        """Load measured data from an ASCII file."""
        # TODO: Check if this method is used...
        #  Looks like _load_ascii_data_to_experiment from experiments.py is used instead
        print(f'Loading measured data for {self.sample_form} diffraction from {file_path}')

        try:
            data: np.ndarray = np.loadtxt(file_path)
        except Exception as e:
            print(f'Failed to load data: {e}')
            return

        if data.shape[1] < 2:
            raise ValueError('Data file must have at least two columns (x and y).')

        x: np.ndarray = data[:, 0]
        y: np.ndarray = data[:, 1]
        sy: np.ndarray = data[:, 2] if data.shape[1] > 2 else np.sqrt(np.abs(y))

        self.pattern.x = x
        self.pattern.meas = y
        self.pattern.meas_su = sy
        self.pattern.excluded = np.full(x.shape, fill_value=False, dtype=bool)  # No excluded points by default

        print(f"Loaded {len(x)} points for experiment '{self.pattern.experiment.name}'.")

    def show_measured_data(self) -> None:
        """Display measured data in console."""
        print(f'\nMeasured data ({self.sample_form}):')
        print(f'x: {self.pattern.x}')
        print(f'meas: {self.pattern.meas}')
        print(f'meas_su: {self.pattern.meas_su}')

    def show_calculated_data(self) -> None:
        """Display calculated data in console."""
        print(f'\nCalculated data ({self.sample_form}):')
        print(f'calc: {self.pattern.calc}')
load_measured_data(file_path)

Load measured data from an ASCII file.

Source code in src/easydiffraction/experiments/collections/datastore.py
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
def load_measured_data(self, file_path: str) -> None:
    """Load measured data from an ASCII file."""
    # TODO: Check if this method is used...
    #  Looks like _load_ascii_data_to_experiment from experiments.py is used instead
    print(f'Loading measured data for {self.sample_form} diffraction from {file_path}')

    try:
        data: np.ndarray = np.loadtxt(file_path)
    except Exception as e:
        print(f'Failed to load data: {e}')
        return

    if data.shape[1] < 2:
        raise ValueError('Data file must have at least two columns (x and y).')

    x: np.ndarray = data[:, 0]
    y: np.ndarray = data[:, 1]
    sy: np.ndarray = data[:, 2] if data.shape[1] > 2 else np.sqrt(np.abs(y))

    self.pattern.x = x
    self.pattern.meas = y
    self.pattern.meas_su = sy
    self.pattern.excluded = np.full(x.shape, fill_value=False, dtype=bool)  # No excluded points by default

    print(f"Loaded {len(x)} points for experiment '{self.pattern.experiment.name}'.")
show_calculated_data()

Display calculated data in console.

Source code in src/easydiffraction/experiments/collections/datastore.py
103
104
105
106
def show_calculated_data(self) -> None:
    """Display calculated data in console."""
    print(f'\nCalculated data ({self.sample_form}):')
    print(f'calc: {self.pattern.calc}')
show_measured_data()

Display measured data in console.

Source code in src/easydiffraction/experiments/collections/datastore.py
 96
 97
 98
 99
100
101
def show_measured_data(self) -> None:
    """Display measured data in console."""
    print(f'\nMeasured data ({self.sample_form}):')
    print(f'x: {self.pattern.x}')
    print(f'meas: {self.pattern.meas}')
    print(f'meas_su: {self.pattern.meas_su}')

DatastoreFactory

Factory to dynamically create appropriate datastore instances (SC/Powder).

Source code in src/easydiffraction/experiments/collections/datastore.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
class DatastoreFactory:
    """
    Factory to dynamically create appropriate datastore instances (SC/Powder).
    """

    @staticmethod
    def create(sample_form: str, experiment: Experiment) -> Datastore:
        """
        Create a datastore object depending on the sample form.

        Args:
            sample_form: The form of the sample ("powder" or "single_crystal").
            experiment: The experiment object.

        Returns:
            A new Datastore instance appropriate for the sample form.
        """
        return Datastore(sample_form, experiment)
create(sample_form, experiment) staticmethod

Create a datastore object depending on the sample form.

Parameters:

Name Type Description Default
sample_form str

The form of the sample ("powder" or "single_crystal").

required
experiment Experiment

The experiment object.

required

Returns:

Type Description
Datastore

A new Datastore instance appropriate for the sample form.

Source code in src/easydiffraction/experiments/collections/datastore.py
114
115
116
117
118
119
120
121
122
123
124
125
126
@staticmethod
def create(sample_form: str, experiment: Experiment) -> Datastore:
    """
    Create a datastore object depending on the sample form.

    Args:
        sample_form: The form of the sample ("powder" or "single_crystal").
        experiment: The experiment object.

    Returns:
        A new Datastore instance appropriate for the sample form.
    """
    return Datastore(sample_form, experiment)

Pattern

Base pattern class for both powder and single crystal experiments. Stores x, measured intensities, uncertainties, background, and calculated intensities.

Source code in src/easydiffraction/experiments/collections/datastore.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Pattern:
    """
    Base pattern class for both powder and single crystal experiments.
    Stores x, measured intensities, uncertainties, background, and calculated intensities.
    """

    def __init__(self, experiment: Experiment) -> None:
        self.experiment = experiment

        # Data arrays
        self.x: Optional[np.ndarray] = None
        self.d: Optional[np.ndarray] = None
        self.meas: Optional[np.ndarray] = None
        self.meas_su: Optional[np.ndarray] = None
        self.bkg: Optional[np.ndarray] = None
        self.excluded: Optional[np.ndarray] = None  # Flags for excluded points
        self._calc: Optional[np.ndarray] = None  # Cached calculated intensities

    @property
    def calc(self) -> Optional[np.ndarray]:
        """Access calculated intensities. Should be updated via external calculation."""
        return self._calc

    @calc.setter
    def calc(self, values: np.ndarray) -> None:
        """Set calculated intensities (from Analysis.calculate_pattern())."""
        self._calc = values
calc property writable

Access calculated intensities. Should be updated via external calculation.

PowderPattern

Bases: Pattern

Specialized pattern for powder diffraction (can be extended in the future).

Source code in src/easydiffraction/experiments/collections/datastore.py
44
45
46
47
48
49
50
51
class PowderPattern(Pattern):
    """
    Specialized pattern for powder diffraction (can be extended in the future).
    """

    # TODO: Check if this class is needed or if it can be merged with Pattern
    def __init__(self, experiment: Experiment) -> None:
        super().__init__(experiment)

excluded_regions

ExcludedRegions

Bases: Collection

Collection of ExcludedRegion instances.

Source code in src/easydiffraction/experiments/collections/excluded_regions.py
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
class ExcludedRegions(Collection):
    """
    Collection of ExcludedRegion instances.
    """

    @property
    def _type(self) -> str:
        return 'category'  # datablock or category

    @property
    def _child_class(self) -> Type[ExcludedRegion]:
        return ExcludedRegion

    def on_item_added(self, item: ExcludedRegion) -> None:
        """
        Mark excluded points in the experiment pattern when a new region is added.
        """
        experiment = self._parent
        pattern = experiment.datastore.pattern

        # Boolean mask for points within the new excluded region
        in_region = (pattern.full_x >= item.minimum.value) & (pattern.full_x <= item.maximum.value)

        # Update the exclusion mask
        pattern.excluded[in_region] = True

        # Update the excluded points in the datastore
        pattern.x = pattern.full_x[~pattern.excluded]
        pattern.meas = pattern.full_meas[~pattern.excluded]
        pattern.meas_su = pattern.full_meas_su[~pattern.excluded]

    def show(self) -> None:
        # TODO: Consider moving this to the base class
        #  to avoid code duplication with implementations in Background, etc.
        #  Consider using parameter names as column headers
        columns_headers: List[str] = ['minimum', 'maximum']
        columns_alignment = ['left', 'left']
        columns_data: List[List[float]] = []
        for region in self._items.values():
            minimum = region.minimum.value
            maximum = region.maximum.value
            columns_data.append([minimum, maximum])

        print(paragraph('Excluded regions'))
        render_table(
            columns_headers=columns_headers,
            columns_alignment=columns_alignment,
            columns_data=columns_data,
        )
on_item_added(item)

Mark excluded points in the experiment pattern when a new region is added.

Source code in src/easydiffraction/experiments/collections/excluded_regions.py
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def on_item_added(self, item: ExcludedRegion) -> None:
    """
    Mark excluded points in the experiment pattern when a new region is added.
    """
    experiment = self._parent
    pattern = experiment.datastore.pattern

    # Boolean mask for points within the new excluded region
    in_region = (pattern.full_x >= item.minimum.value) & (pattern.full_x <= item.maximum.value)

    # Update the exclusion mask
    pattern.excluded[in_region] = True

    # Update the excluded points in the datastore
    pattern.x = pattern.full_x[~pattern.excluded]
    pattern.meas = pattern.full_meas[~pattern.excluded]
    pattern.meas_su = pattern.full_meas_su[~pattern.excluded]

linked_phases

LinkedPhases

Bases: Collection

Collection of LinkedPhase instances.

Source code in src/easydiffraction/experiments/collections/linked_phases.py
44
45
46
47
48
49
50
51
52
53
54
55
class LinkedPhases(Collection):
    """
    Collection of LinkedPhase instances.
    """

    @property
    def _type(self) -> str:
        return 'category'  # datablock or category

    @property
    def _child_class(self) -> Type[LinkedPhase]:
        return LinkedPhase

experiment

BaseExperiment

Bases: Datablock

Base class for all experiments with only core attributes. Wraps experiment type, instrument and datastore.

Source code in src/easydiffraction/experiments/experiment.py
 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
class BaseExperiment(Datablock):
    """
    Base class for all experiments with only core attributes.
    Wraps experiment type, instrument and datastore.
    """

    # TODO: Find better name for the attribute 'type'.
    #  1. It shadows the built-in type() function.
    #  2. It is not very clear what it refers to.
    def __init__(self, name: str, type: ExperimentType):
        self.name = name
        self.type = type
        self.datastore = DatastoreFactory.create(
            sample_form=self.type.sample_form.value,
            experiment=self,
        )

    # ---------------------------
    # Name (ID) of the experiment
    # ---------------------------

    @property
    def name(self):
        return self._name

    @name.setter
    @enforce_type
    def name(self, new_name: str):
        self._name = new_name

    # ---------------
    # Experiment type
    # ---------------

    @property
    def type(self):
        return self._type

    @type.setter
    @enforce_type
    def type(self, new_experiment_type: ExperimentType):
        self._type = new_experiment_type

    # ----------------
    # Misc. Need to be sorted
    # ----------------

    def as_cif(
        self,
        max_points: Optional[int] = None,
    ) -> str:
        """
        Export the sample model to CIF format.
        Returns:
            str: CIF string representation of the experiment.
        """
        # Data block header
        cif_lines: List[str] = [f'data_{self.name}']

        # Experiment type
        cif_lines += ['', self.type.as_cif()]

        # Instrument setup and calibration
        if hasattr(self, 'instrument'):
            cif_lines += ['', self.instrument.as_cif()]

        # Peak profile, broadening and asymmetry
        if hasattr(self, 'peak'):
            cif_lines += ['', self.peak.as_cif()]

        # Phase scale factors for powder experiments
        if hasattr(self, 'linked_phases') and self.linked_phases._items:
            cif_lines += ['', self.linked_phases.as_cif()]

        # Crystal scale factor for single crystal experiments
        if hasattr(self, 'linked_crystal'):
            cif_lines += ['', self.linked_crystal.as_cif()]

        # Background points
        if hasattr(self, 'background') and self.background._items:
            cif_lines += ['', self.background.as_cif()]

        # Excluded regions
        if hasattr(self, 'excluded_regions') and self.excluded_regions._items:
            cif_lines += ['', self.excluded_regions.as_cif()]

        # Measured data
        if hasattr(self, 'datastore') and hasattr(self.datastore, 'pattern'):
            cif_lines.append('')
            cif_lines.append('loop_')
            category = '_pd_meas'  # TODO: Add category to pattern component
            attributes = ('2theta_scan', 'intensity_total', 'intensity_total_su')
            for attribute in attributes:
                cif_lines.append(f'{category}.{attribute}')
            pattern = self.datastore.pattern
            if max_points is not None and len(pattern.x) > 2 * max_points:
                for i in range(max_points):
                    x = pattern.x[i]
                    meas = pattern.meas[i]
                    meas_su = pattern.meas_su[i]
                    cif_lines.append(f'{x} {meas} {meas_su}')
                cif_lines.append('...')
                for i in range(-max_points, 0):
                    x = pattern.x[i]
                    meas = pattern.meas[i]
                    meas_su = pattern.meas_su[i]
                    cif_lines.append(f'{x} {meas} {meas_su}')
            else:
                for x, meas, meas_su in zip(pattern.x, pattern.meas, pattern.meas_su):
                    cif_lines.append(f'{x} {meas} {meas_su}')

        return '\n'.join(cif_lines)

    def show_as_cif(self) -> None:
        cif_text: str = self.as_cif(max_points=5)
        paragraph_title: str = paragraph(f"Experiment 🔬 '{self.name}' as cif")
        render_cif(cif_text, paragraph_title)

    @abstractmethod
    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
        pass

as_cif(max_points=None)

Export the sample model to CIF format. Returns: str: CIF string representation of the experiment.

Source code in src/easydiffraction/experiments/experiment.py
 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
def as_cif(
    self,
    max_points: Optional[int] = None,
) -> str:
    """
    Export the sample model to CIF format.
    Returns:
        str: CIF string representation of the experiment.
    """
    # Data block header
    cif_lines: List[str] = [f'data_{self.name}']

    # Experiment type
    cif_lines += ['', self.type.as_cif()]

    # Instrument setup and calibration
    if hasattr(self, 'instrument'):
        cif_lines += ['', self.instrument.as_cif()]

    # Peak profile, broadening and asymmetry
    if hasattr(self, 'peak'):
        cif_lines += ['', self.peak.as_cif()]

    # Phase scale factors for powder experiments
    if hasattr(self, 'linked_phases') and self.linked_phases._items:
        cif_lines += ['', self.linked_phases.as_cif()]

    # Crystal scale factor for single crystal experiments
    if hasattr(self, 'linked_crystal'):
        cif_lines += ['', self.linked_crystal.as_cif()]

    # Background points
    if hasattr(self, 'background') and self.background._items:
        cif_lines += ['', self.background.as_cif()]

    # Excluded regions
    if hasattr(self, 'excluded_regions') and self.excluded_regions._items:
        cif_lines += ['', self.excluded_regions.as_cif()]

    # Measured data
    if hasattr(self, 'datastore') and hasattr(self.datastore, 'pattern'):
        cif_lines.append('')
        cif_lines.append('loop_')
        category = '_pd_meas'  # TODO: Add category to pattern component
        attributes = ('2theta_scan', 'intensity_total', 'intensity_total_su')
        for attribute in attributes:
            cif_lines.append(f'{category}.{attribute}')
        pattern = self.datastore.pattern
        if max_points is not None and len(pattern.x) > 2 * max_points:
            for i in range(max_points):
                x = pattern.x[i]
                meas = pattern.meas[i]
                meas_su = pattern.meas_su[i]
                cif_lines.append(f'{x} {meas} {meas_su}')
            cif_lines.append('...')
            for i in range(-max_points, 0):
                x = pattern.x[i]
                meas = pattern.meas[i]
                meas_su = pattern.meas_su[i]
                cif_lines.append(f'{x} {meas} {meas_su}')
        else:
            for x, meas, meas_su in zip(pattern.x, pattern.meas, pattern.meas_su):
                cif_lines.append(f'{x} {meas} {meas_su}')

    return '\n'.join(cif_lines)

BasePowderExperiment

Bases: BaseExperiment

Base class for all powder experiments.

Source code in src/easydiffraction/experiments/experiment.py
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
class BasePowderExperiment(BaseExperiment):
    """
    Base class for all powder experiments.
    """

    def __init__(
        self,
        name: str,
        type: ExperimentType,
    ) -> None:
        super().__init__(name=name, type=type)

        self._peak_profile_type: str = DEFAULT_PEAK_PROFILE_TYPE[self.type.scattering_type.value][self.type.beam_mode.value]
        self.peak = PeakFactory.create(
            scattering_type=self.type.scattering_type.value,
            beam_mode=self.type.beam_mode.value,
            profile_type=self._peak_profile_type,
        )

        self.linked_phases: LinkedPhases = LinkedPhases()
        self.excluded_regions: ExcludedRegions = ExcludedRegions(parent=self)

    @abstractmethod
    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
        pass

    @property
    def peak_profile_type(self):
        return self._peak_profile_type

    @peak_profile_type.setter
    def peak_profile_type(self, new_type: str):
        if new_type not in PeakFactory._supported[self.type.scattering_type.value][self.type.beam_mode.value]:
            supported_types = list(PeakFactory._supported[self.type.scattering_type.value][self.type.beam_mode.value].keys())
            print(warning(f"Unsupported peak profile '{new_type}'"))
            print(f'Supported peak profiles: {supported_types}')
            print("For more information, use 'show_supported_peak_profile_types()'")
            return
        self.peak = PeakFactory.create(
            scattering_type=self.type.scattering_type.value, beam_mode=self.type.beam_mode.value, profile_type=new_type
        )
        self._peak_profile_type = new_type
        print(paragraph(f"Peak profile type for experiment '{self.name}' changed to"))
        print(new_type)

    def show_supported_peak_profile_types(self):
        columns_headers = ['Peak profile type', 'Description']
        columns_alignment = ['left', 'left']
        columns_data = []
        for name, config in PeakFactory._supported[self.type.scattering_type.value][self.type.beam_mode.value].items():
            description = getattr(config, '_description', 'No description provided.')
            columns_data.append([name, description])

        print(paragraph('Supported peak profile types'))
        render_table(columns_headers=columns_headers, columns_alignment=columns_alignment, columns_data=columns_data)

    def show_current_peak_profile_type(self):
        print(paragraph('Current peak profile type'))
        print(self.peak_profile_type)

ExperimentFactory

Creates Experiment instances with only relevant attributes.

Source code in src/easydiffraction/experiments/experiment.py
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
class ExperimentFactory:
    """Creates Experiment instances with only relevant attributes."""

    _supported = {
        'bragg': {
            'powder': PowderExperiment,
            'single crystal': SingleCrystalExperiment,
        },
        'total': {
            'powder': PairDistributionFunctionExperiment,
        },
    }

    @classmethod
    def create(
        cls,
        name: str,
        sample_form: DEFAULT_SAMPLE_FORM,
        beam_mode: DEFAULT_BEAM_MODE,
        radiation_probe: DEFAULT_RADIATION_PROBE,
        scattering_type: DEFAULT_SCATTERING_TYPE,
    ) -> BaseExperiment:
        # TODO: Add checks for expt_type and expt_class
        expt_type = ExperimentType(
            sample_form=sample_form,
            beam_mode=beam_mode,
            radiation_probe=radiation_probe,
            scattering_type=scattering_type,
        )

        expt_class = cls._supported[scattering_type][sample_form]
        expt_obj = expt_class(
            name=name,
            type=expt_type,
        )

        return expt_obj

PairDistributionFunctionExperiment

Bases: BasePowderExperiment

PDF experiment class with specific attributes.

Source code in src/easydiffraction/experiments/experiment.py
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
class PairDistributionFunctionExperiment(BasePowderExperiment):
    """PDF experiment class with specific attributes."""

    def __init__(
        self,
        name: str,
        type: ExperimentType,
    ):
        super().__init__(name=name, type=type)

    def _load_ascii_data_to_experiment(self, data_path):
        """
        Loads x, y, sy values from an ASCII data file into the experiment.

        The file must be structured as:
            x  y  sy
        """
        try:
            from diffpy.utils.parsers.loaddata import loadData
        except ImportError:
            raise ImportError('diffpy module not found.')
        try:
            data = loadData(data_path)
        except Exception as e:
            raise IOError(f'Failed to read data from {data_path}: {e}')

        if data.shape[1] < 2:
            raise ValueError('Data file must have at least two columns: x and y.')

        default_sy = 0.03
        if data.shape[1] < 3:
            print(f'Warning: No uncertainty (sy) column provided. Defaulting to {default_sy}.')

        # Extract x, y, and sy data
        x = data[:, 0]
        # We should also add sx = data[:, 2] to capture the e.s.d. of x. It
        # might be useful in future.
        y = data[:, 1]
        # Using sqrt isn’t appropriate here, as the y-scale isn’t raw counts
        # and includes both positive and negative values. For now, set the
        # e.s.d. to a fixed value of 0.03 if it’s not included in the measured
        # data file. We should improve this later.
        # sy = data[:, 3] if data.shape[1] > 2 else np.sqrt(y)
        sy = data[:, 2] if data.shape[1] > 2 else np.full_like(y, fill_value=default_sy)

        # Attach the data to the experiment's datastore
        self.datastore.pattern.x = x
        self.datastore.pattern.meas = y
        self.datastore.pattern.meas_su = sy

        print(paragraph('Data loaded successfully'))
        print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}")

PowderExperiment

Bases: InstrumentMixin, BasePowderExperiment

Powder experiment class with specific attributes. Wraps background, peak profile, and linked phases.

Source code in src/easydiffraction/experiments/experiment.py
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
class PowderExperiment(
    InstrumentMixin,
    BasePowderExperiment,
):
    """
    Powder experiment class with specific attributes.
    Wraps background, peak profile, and linked phases.
    """

    def __init__(
        self,
        name: str,
        type: ExperimentType,
    ) -> None:
        super().__init__(name=name, type=type)

        self._background_type: str = DEFAULT_BACKGROUND_TYPE
        self.background = BackgroundFactory.create(background_type=self.background_type)

    # -------------
    # Measured data
    # -------------

    def _load_ascii_data_to_experiment(self, data_path: str) -> None:
        """
        Loads x, y, sy values from an ASCII data file into the experiment.

        The file must be structured as:
            x  y  sy
        """
        try:
            data = np.loadtxt(data_path)
        except Exception as e:
            raise IOError(f'Failed to read data from {data_path}: {e}')

        if data.shape[1] < 2:
            raise ValueError('Data file must have at least two columns: x and y.')

        if data.shape[1] < 3:
            print('Warning: No uncertainty (sy) column provided. Defaulting to sqrt(y).')

        # Extract x, y data
        x: np.ndarray = data[:, 0]
        y: np.ndarray = data[:, 1]

        # Round x to 4 decimal places
        # TODO: This is needed for CrysPy, as otherwise it fails to match
        #  the size of the data arrays.
        x = np.round(x, 4)

        # Determine sy from column 3 if available, otherwise use sqrt(y)
        sy: np.ndarray = data[:, 2] if data.shape[1] > 2 else np.sqrt(y)

        # Replace values smaller than 0.0001 with 1.0
        # TODO: This is needed for minimization algorithms that fail with
        #  very small or zero uncertainties.
        sy = np.where(sy < 0.0001, 1.0, sy)

        # Attach the data to the experiment's datastore

        # The full pattern data
        self.datastore.pattern.full_x = x
        self.datastore.pattern.full_meas = y
        self.datastore.pattern.full_meas_su = sy

        # The pattern data used for fitting (without excluded points)
        # This is the same as full_x, full_meas, full_meas_su by default
        self.datastore.pattern.x = x
        self.datastore.pattern.meas = y
        self.datastore.pattern.meas_su = sy

        # Excluded mask
        # No excluded points by default
        self.datastore.pattern.excluded = np.full(x.shape, fill_value=False, dtype=bool)

        print(paragraph('Data loaded successfully'))
        print(f"Experiment 🔬 '{self.name}'. Number of data points: {len(x)}")

    @property
    def background_type(self):
        return self._background_type

    @background_type.setter
    def background_type(self, new_type):
        if new_type not in BackgroundFactory._supported:
            supported_types = list(BackgroundFactory._supported.keys())
            print(warning(f"Unknown background type '{new_type}'"))
            print(f'Supported background types: {supported_types}')
            print("For more information, use 'show_supported_background_types()'")
            return
        self.background = BackgroundFactory.create(new_type)
        self._background_type = new_type
        print(paragraph(f"Background type for experiment '{self.name}' changed to"))
        print(new_type)

    def show_supported_background_types(self):
        columns_headers = ['Background type', 'Description']
        columns_alignment = ['left', 'left']
        columns_data = []
        for name, config in BackgroundFactory._supported.items():
            description = getattr(config, '_description', 'No description provided.')
            columns_data.append([name, description])

        print(paragraph('Supported background types'))
        render_table(columns_headers=columns_headers, columns_alignment=columns_alignment, columns_data=columns_data)

    def show_current_background_type(self):
        print(paragraph('Current background type'))
        print(self.background_type)

SingleCrystalExperiment

Bases: BaseExperiment

Single crystal experiment class with specific attributes.

Source code in src/easydiffraction/experiments/experiment.py
403
404
405
406
407
408
409
410
411
412
413
414
415
class SingleCrystalExperiment(BaseExperiment):
    """Single crystal experiment class with specific attributes."""

    def __init__(
        self,
        name: str,
        type: ExperimentType,
    ) -> None:
        super().__init__(name=name, type=type)
        self.linked_crystal = None

    def show_meas_chart(self) -> None:
        print('Showing measured data chart is not implemented yet.')

experiments

Experiments

Bases: Collection

Collection manager for multiple Experiment instances.

Source code in src/easydiffraction/experiments/experiments.py
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 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
class Experiments(Collection):
    """
    Collection manager for multiple Experiment instances.
    """

    @property
    def _child_class(self):
        return BaseExperiment

    def __init__(self) -> None:
        super().__init__()
        self._experiments: Dict[str, BaseExperiment] = self._items  # Alias for legacy support

    def add(
        self,
        experiment=None,
        name=None,
        sample_form=None,
        beam_mode=None,
        radiation_probe=None,
        scattering_type=None,
        cif_path=None,
        cif_str=None,
        data_path=None,
    ):
        """
        Add a new experiment to the collection.
        """
        if scattering_type is None:
            scattering_type = 'bragg'
        if experiment:
            self._add_prebuilt_experiment(experiment)
        elif cif_path:
            self._add_from_cif_path(cif_path)
        elif cif_str:
            self._add_from_cif_string(cif_str)
        elif all(
            [
                name,
                sample_form,
                beam_mode,
                radiation_probe,
                data_path,
            ]
        ):
            self._add_from_data_path(
                name=name,
                sample_form=sample_form,
                beam_mode=beam_mode,
                radiation_probe=radiation_probe,
                scattering_type=scattering_type,
                data_path=data_path,
            )
        else:
            raise ValueError('Provide either experiment, type parameters, cif_path, cif_str, or data_path')

    @enforce_type
    def _add_prebuilt_experiment(self, experiment: BaseExperiment):
        self._experiments[experiment.name] = experiment

    def _add_from_cif_path(self, cif_path: str) -> None:
        print('Loading Experiment from CIF path...')
        raise NotImplementedError('CIF loading not implemented.')

    def _add_from_cif_string(self, cif_str: str) -> None:
        print('Loading Experiment from CIF string...')
        raise NotImplementedError('CIF loading not implemented.')

    def _add_from_data_path(
        self,
        name,
        sample_form,
        beam_mode,
        radiation_probe,
        scattering_type,
        data_path,
    ):
        """
        Load an experiment from raw data ASCII file.
        """
        print(paragraph('Loading measured data from ASCII file'))
        print(os.path.abspath(data_path))
        experiment = ExperimentFactory.create(
            name=name,
            sample_form=sample_form,
            beam_mode=beam_mode,
            radiation_probe=radiation_probe,
            scattering_type=scattering_type,
        )
        experiment._load_ascii_data_to_experiment(data_path)
        self._experiments[experiment.name] = experiment

    def remove(self, experiment_id: str) -> None:
        if experiment_id in self._experiments:
            del self._experiments[experiment_id]

    def show_names(self) -> None:
        print(paragraph('Defined experiments' + ' 🔬'))
        print(self.ids)

    @property
    def ids(self) -> List[str]:
        return list(self._experiments.keys())

    def show_params(self) -> None:
        for exp in self._experiments.values():
            print(exp)

    def as_cif(self) -> str:
        return '\n\n'.join([exp.as_cif() for exp in self._experiments.values()])

add(experiment=None, name=None, sample_form=None, beam_mode=None, radiation_probe=None, scattering_type=None, cif_path=None, cif_str=None, data_path=None)

Add a new experiment to the collection.

Source code in src/easydiffraction/experiments/experiments.py
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
def add(
    self,
    experiment=None,
    name=None,
    sample_form=None,
    beam_mode=None,
    radiation_probe=None,
    scattering_type=None,
    cif_path=None,
    cif_str=None,
    data_path=None,
):
    """
    Add a new experiment to the collection.
    """
    if scattering_type is None:
        scattering_type = 'bragg'
    if experiment:
        self._add_prebuilt_experiment(experiment)
    elif cif_path:
        self._add_from_cif_path(cif_path)
    elif cif_str:
        self._add_from_cif_string(cif_str)
    elif all(
        [
            name,
            sample_form,
            beam_mode,
            radiation_probe,
            data_path,
        ]
    ):
        self._add_from_data_path(
            name=name,
            sample_form=sample_form,
            beam_mode=beam_mode,
            radiation_probe=radiation_probe,
            scattering_type=scattering_type,
            data_path=data_path,
        )
    else:
        raise ValueError('Provide either experiment, type parameters, cif_path, cif_str, or data_path')