Skip to content

project

project

Project facade to orchestrate models, experiments, and analysis.

Project

Bases: GuardedBase

Central API for managing a diffraction data analysis project.

Provides access to sample models, experiments, analysis, and summary.

Source code in src/easydiffraction/project/project.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
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
class Project(GuardedBase):
    """Central API for managing a diffraction data analysis project.

    Provides access to sample models, experiments, analysis, and
    summary.
    """

    # ------------------------------------------------------------------
    # Initialization
    # ------------------------------------------------------------------
    def __init__(
        self,
        name: str = 'untitled_project',
        title: str = 'Untitled Project',
        description: str = '',
    ) -> None:
        super().__init__()

        self._info: ProjectInfo = ProjectInfo(name, title, description)
        self._sample_models = SampleModels()
        self._experiments = Experiments()
        self._tabler = TableRenderer.get()
        self._plotter = Plotter()
        self._analysis = Analysis(self)
        self._summary = Summary(self)
        self._saved = False
        self._varname = varname()

    # ------------------------------------------------------------------
    # Dunder methods
    # ------------------------------------------------------------------
    def __str__(self) -> str:
        """Human-readable representation."""
        class_name = self.__class__.__name__
        project_name = self.name
        sample_models_count = len(self.sample_models)
        experiments_count = len(self.experiments)
        return (
            f"{class_name} '{project_name}' "
            f'({sample_models_count} sample models, '
            f'{experiments_count} experiments)'
        )

    # ------------------------------------------------------------------
    # Public read-only properties
    # ------------------------------------------------------------------

    @property
    def info(self) -> ProjectInfo:
        """Project metadata container."""
        return self._info

    @property
    def name(self) -> str:
        """Convenience property to access the project's name
        directly.
        """
        return self._info.name

    @property
    def full_name(self) -> str:
        return self.name

    @property
    def sample_models(self) -> SampleModels:
        """Collection of sample models in the project."""
        return self._sample_models

    @sample_models.setter
    @typechecked
    def sample_models(self, sample_models: SampleModels) -> None:
        self._sample_models = sample_models

    @property
    def experiments(self):
        """Collection of experiments in the project."""
        return self._experiments

    @experiments.setter
    @typechecked
    def experiments(self, experiments: Experiments):
        self._experiments = experiments

    @property
    def plotter(self):
        """Plotting facade bound to the project."""
        return self._plotter

    @property
    def tabler(self):
        """Tables rendering facade bound to the project."""
        return self._tabler

    @property
    def analysis(self):
        """Analysis entry-point bound to the project."""
        return self._analysis

    @property
    def summary(self):
        """Summary report builder bound to the project."""
        return self._summary

    @property
    def parameters(self):
        """Return parameters from all components (TBD)."""
        # To be implemented: return all parameters in the project
        return []

    @property
    def as_cif(self):
        """Export whole project as CIF text."""
        # Concatenate sections using centralized CIF serializers
        return project_to_cif(self)

    # ------------------------------------------
    #  Project File I/O
    # ------------------------------------------

    def load(self, dir_path: str) -> None:
        """Load a project from a given directory.

        Loads project info, sample models, experiments, etc.
        """
        console.paragraph('Loading project 📦 from')
        console.print(dir_path)
        self._info.path = dir_path
        # TODO: load project components from files inside dir_path
        console.print('Loading project is not implemented yet.')
        self._saved = True

    def save(self) -> None:
        """Save the project into the existing project directory."""
        if not self._info.path:
            log.error('Project path not specified. Use save_as() to define the path first.')
            return

        console.paragraph(f"Saving project 📦 '{self.name}' to")
        console.print(self.info.path.resolve())

        # Ensure project directory exists
        self._info.path.mkdir(parents=True, exist_ok=True)

        # Save project info
        with (self._info.path / 'project.cif').open('w') as f:
            f.write(self._info.as_cif())
            console.print('├── 📄 project.cif')

        # Save sample models
        sm_dir = self._info.path / 'sample_models'
        sm_dir.mkdir(parents=True, exist_ok=True)
        # Iterate over sample model objects (MutableMapping iter gives
        # keys)
        for model in self.sample_models.values():
            file_name: str = f'{model.name}.cif'
            file_path = sm_dir / file_name
            console.print('├── 📁 sample_models')
            with file_path.open('w') as f:
                f.write(model.as_cif)
                console.print(f'│   └── 📄 {file_name}')

        # Save experiments
        expt_dir = self._info.path / 'experiments'
        expt_dir.mkdir(parents=True, exist_ok=True)
        for experiment in self.experiments.values():
            file_name: str = f'{experiment.name}.cif'
            file_path = expt_dir / file_name
            console.print('├── 📁 experiments')
            with file_path.open('w') as f:
                f.write(experiment.as_cif)
                console.print(f'│   └── 📄 {file_name}')

        # Save analysis
        with (self._info.path / 'analysis.cif').open('w') as f:
            f.write(self.analysis.as_cif())
            console.print('├── 📄 analysis.cif')

        # Save summary
        with (self._info.path / 'summary.cif').open('w') as f:
            f.write(self.summary.as_cif())
            console.print('└── 📄 summary.cif')

        self._info.update_last_modified()
        self._saved = True

    def save_as(
        self,
        dir_path: str,
        temporary: bool = False,
    ) -> None:
        """Save the project into a new directory."""
        if temporary:
            tmp: str = tempfile.gettempdir()
            dir_path = pathlib.Path(tmp) / dir_path
        self._info.path = dir_path
        self.save()

    # ------------------------------------------
    # Plotting
    # ------------------------------------------

    def plot_meas(
        self,
        expt_name,
        x_min=None,
        x_max=None,
        d_spacing=False,
    ):
        experiment = self.experiments[expt_name]
        datastore = experiment.datastore
        expt_type = experiment.type

        # Update d-spacing if necessary
        # TODO: This is done before every plot, and not when parameters
        #  needed for d-spacing conversion are changed. The reason is
        #  to minimize the performance impact during the fitting
        #  process. Need to find a better way to handle this.
        if d_spacing:
            self.update_pattern_d_spacing(expt_name)

        # Plot measured pattern
        self.plotter.plot_meas(
            datastore,
            expt_name,
            expt_type,
            x_min=x_min,
            x_max=x_max,
            d_spacing=d_spacing,
        )

    def plot_calc(
        self,
        expt_name,
        x_min=None,
        x_max=None,
        d_spacing=False,
    ):
        self.analysis.calculate_pattern(expt_name)  # Recalculate pattern
        experiment = self.experiments[expt_name]
        datastore = experiment.datastore
        expt_type = experiment.type

        # Update d-spacing if necessary
        # TODO: This is done before every plot, and not when parameters
        #  needed for d-spacing conversion are changed. The reason is
        #  to minimize the performance impact during the fitting
        #  process. Need to find a better way to handle this.
        if d_spacing:
            self.update_pattern_d_spacing(expt_name)

        # Plot calculated pattern
        self.plotter.plot_calc(
            datastore,
            expt_name,
            expt_type,
            x_min=x_min,
            x_max=x_max,
            d_spacing=d_spacing,
        )

    def plot_meas_vs_calc(
        self,
        expt_name,
        x_min=None,
        x_max=None,
        show_residual=False,
        d_spacing=False,
    ):
        self.analysis.calculate_pattern(expt_name)  # Recalculate pattern
        experiment = self.experiments[expt_name]
        datastore = experiment.datastore
        expt_type = experiment.type

        # Update d-spacing if necessary
        # TODO: This is done before every plot, and not when parameters
        #  needed for d-spacing conversion are changed. The reason is
        #  to minimize the performance impact during the fitting
        #  process. Need to find a better way to handle this.
        if d_spacing:
            self.update_pattern_d_spacing(expt_name)

        # Plot measured vs calculated
        self.plotter.plot_meas_vs_calc(
            datastore,
            expt_name,
            expt_type,
            x_min=x_min,
            x_max=x_max,
            show_residual=show_residual,
            d_spacing=d_spacing,
        )

    def update_pattern_d_spacing(self, expt_name: str) -> None:
        """Update the pattern's d-spacing based on the experiment's beam
        mode.
        """
        experiment = self.experiments[expt_name]
        datastore = experiment.datastore
        expt_type = experiment.type
        beam_mode = expt_type.beam_mode.value

        if beam_mode == BeamModeEnum.TIME_OF_FLIGHT:
            datastore.d = tof_to_d(
                datastore.x,
                experiment.instrument.calib_d_to_tof_offset.value,
                experiment.instrument.calib_d_to_tof_linear.value,
                experiment.instrument.calib_d_to_tof_quad.value,
            )
        elif beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH:
            datastore.d = twotheta_to_d(
                datastore.x,
                experiment.instrument.setup_wavelength.value,
            )
        else:
            log.error(f'Unsupported beam mode: {beam_mode} for d-spacing update.')

__str__()

Human-readable representation.

Source code in src/easydiffraction/project/project.py
58
59
60
61
62
63
64
65
66
67
68
def __str__(self) -> str:
    """Human-readable representation."""
    class_name = self.__class__.__name__
    project_name = self.name
    sample_models_count = len(self.sample_models)
    experiments_count = len(self.experiments)
    return (
        f"{class_name} '{project_name}' "
        f'({sample_models_count} sample models, '
        f'{experiments_count} experiments)'
    )

analysis property

Analysis entry-point bound to the project.

as_cif property

Export whole project as CIF text.

experiments property writable

Collection of experiments in the project.

info property

Project metadata container.

load(dir_path)

Load a project from a given directory.

Loads project info, sample models, experiments, etc.

Source code in src/easydiffraction/project/project.py
146
147
148
149
150
151
152
153
154
155
156
def load(self, dir_path: str) -> None:
    """Load a project from a given directory.

    Loads project info, sample models, experiments, etc.
    """
    console.paragraph('Loading project 📦 from')
    console.print(dir_path)
    self._info.path = dir_path
    # TODO: load project components from files inside dir_path
    console.print('Loading project is not implemented yet.')
    self._saved = True

name property

Convenience property to access the project's name directly.

parameters property

Return parameters from all components (TBD).

plotter property

Plotting facade bound to the project.

sample_models property writable

Collection of sample models in the project.

save()

Save the project into the existing project directory.

Source code in src/easydiffraction/project/project.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
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def save(self) -> None:
    """Save the project into the existing project directory."""
    if not self._info.path:
        log.error('Project path not specified. Use save_as() to define the path first.')
        return

    console.paragraph(f"Saving project 📦 '{self.name}' to")
    console.print(self.info.path.resolve())

    # Ensure project directory exists
    self._info.path.mkdir(parents=True, exist_ok=True)

    # Save project info
    with (self._info.path / 'project.cif').open('w') as f:
        f.write(self._info.as_cif())
        console.print('├── 📄 project.cif')

    # Save sample models
    sm_dir = self._info.path / 'sample_models'
    sm_dir.mkdir(parents=True, exist_ok=True)
    # Iterate over sample model objects (MutableMapping iter gives
    # keys)
    for model in self.sample_models.values():
        file_name: str = f'{model.name}.cif'
        file_path = sm_dir / file_name
        console.print('├── 📁 sample_models')
        with file_path.open('w') as f:
            f.write(model.as_cif)
            console.print(f'│   └── 📄 {file_name}')

    # Save experiments
    expt_dir = self._info.path / 'experiments'
    expt_dir.mkdir(parents=True, exist_ok=True)
    for experiment in self.experiments.values():
        file_name: str = f'{experiment.name}.cif'
        file_path = expt_dir / file_name
        console.print('├── 📁 experiments')
        with file_path.open('w') as f:
            f.write(experiment.as_cif)
            console.print(f'│   └── 📄 {file_name}')

    # Save analysis
    with (self._info.path / 'analysis.cif').open('w') as f:
        f.write(self.analysis.as_cif())
        console.print('├── 📄 analysis.cif')

    # Save summary
    with (self._info.path / 'summary.cif').open('w') as f:
        f.write(self.summary.as_cif())
        console.print('└── 📄 summary.cif')

    self._info.update_last_modified()
    self._saved = True

save_as(dir_path, temporary=False)

Save the project into a new directory.

Source code in src/easydiffraction/project/project.py
212
213
214
215
216
217
218
219
220
221
222
def save_as(
    self,
    dir_path: str,
    temporary: bool = False,
) -> None:
    """Save the project into a new directory."""
    if temporary:
        tmp: str = tempfile.gettempdir()
        dir_path = pathlib.Path(tmp) / dir_path
    self._info.path = dir_path
    self.save()

summary property

Summary report builder bound to the project.

tabler property

Tables rendering facade bound to the project.

update_pattern_d_spacing(expt_name)

Update the pattern's d-spacing based on the experiment's beam mode.

Source code in src/easydiffraction/project/project.py
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def update_pattern_d_spacing(self, expt_name: str) -> None:
    """Update the pattern's d-spacing based on the experiment's beam
    mode.
    """
    experiment = self.experiments[expt_name]
    datastore = experiment.datastore
    expt_type = experiment.type
    beam_mode = expt_type.beam_mode.value

    if beam_mode == BeamModeEnum.TIME_OF_FLIGHT:
        datastore.d = tof_to_d(
            datastore.x,
            experiment.instrument.calib_d_to_tof_offset.value,
            experiment.instrument.calib_d_to_tof_linear.value,
            experiment.instrument.calib_d_to_tof_quad.value,
        )
    elif beam_mode == BeamModeEnum.CONSTANT_WAVELENGTH:
        datastore.d = twotheta_to_d(
            datastore.x,
            experiment.instrument.setup_wavelength.value,
        )
    else:
        log.error(f'Unsupported beam mode: {beam_mode} for d-spacing update.')

project_info

Project metadata container used by Project.

ProjectInfo

Bases: GuardedBase

Stores metadata about the project, such as name, title, description, and file paths.

Source code in src/easydiffraction/project/project_info.py
 14
 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
class ProjectInfo(GuardedBase):
    """Stores metadata about the project, such as name, title,
    description, and file paths.
    """

    def __init__(
        self,
        name: str = 'untitled_project',
        title: str = 'Untitled Project',
        description: str = '',
    ) -> None:
        super().__init__()

        self._name = name
        self._title = title
        self._description = description
        self._path: pathlib.Path = pathlib.Path.cwd()
        self._created: datetime.datetime = datetime.datetime.now()
        self._last_modified: datetime.datetime = datetime.datetime.now()

    @property
    def name(self) -> str:
        """Return the project name."""
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        self._name = value

    @property
    def unique_name(self) -> str:
        """Unique name for GuardedBase diagnostics."""
        return self.name

    @property
    def title(self) -> str:
        """Return the project title."""
        return self._title

    @title.setter
    def title(self, value: str) -> None:
        self._title = value

    @property
    def description(self) -> str:
        """Return sanitized description with single spaces."""
        return ' '.join(self._description.split())

    @description.setter
    def description(self, value: str) -> None:
        self._description = ' '.join(value.split())

    @property
    def path(self) -> pathlib.Path:
        """Return the project path as a Path object."""
        return self._path

    @path.setter
    def path(self, value) -> None:
        # Accept str or Path; normalize to Path
        self._path = pathlib.Path(value)

    @property
    def created(self) -> datetime.datetime:
        """Return the creation timestamp."""
        return self._created

    @property
    def last_modified(self) -> datetime.datetime:
        """Return the last modified timestamp."""
        return self._last_modified

    def update_last_modified(self) -> None:
        """Update the last modified timestamp."""
        self._last_modified = datetime.datetime.now()

    def parameters(self):
        """Placeholder for parameter listing."""
        pass

    # TODO: Consider moving to io.cif.serialize
    def as_cif(self) -> str:
        """Export project metadata to CIF."""
        return project_info_to_cif(self)

    # TODO: Consider moving to io.cif.serialize
    def show_as_cif(self) -> None:
        """Pretty-print CIF via shared utilities."""
        paragraph_title: str = f"Project 📦 '{self.name}' info as CIF"
        cif_text: str = self.as_cif()
        console.paragraph(paragraph_title)
        render_cif(cif_text)

as_cif()

Export project metadata to CIF.

Source code in src/easydiffraction/project/project_info.py
95
96
97
def as_cif(self) -> str:
    """Export project metadata to CIF."""
    return project_info_to_cif(self)

created property

Return the creation timestamp.

description property writable

Return sanitized description with single spaces.

last_modified property

Return the last modified timestamp.

name property writable

Return the project name.

parameters()

Placeholder for parameter listing.

Source code in src/easydiffraction/project/project_info.py
90
91
92
def parameters(self):
    """Placeholder for parameter listing."""
    pass

path property writable

Return the project path as a Path object.

show_as_cif()

Pretty-print CIF via shared utilities.

Source code in src/easydiffraction/project/project_info.py
100
101
102
103
104
105
def show_as_cif(self) -> None:
    """Pretty-print CIF via shared utilities."""
    paragraph_title: str = f"Project 📦 '{self.name}' info as CIF"
    cif_text: str = self.as_cif()
    console.paragraph(paragraph_title)
    render_cif(cif_text)

title property writable

Return the project title.

unique_name property

Unique name for GuardedBase diagnostics.

update_last_modified()

Update the last modified timestamp.

Source code in src/easydiffraction/project/project_info.py
86
87
88
def update_last_modified(self) -> None:
    """Update the last modified timestamp."""
    self._last_modified = datetime.datetime.now()