pdfbl.sequential 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pdfbl/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env python
2
+ ##############################################################################
3
+ #
4
+ # (c) 2025 The Trustees of Columbia University in the City of New York.
5
+ # All rights reserved.
6
+ #
7
+ # File coded by: Billinge Group members and community contributors.
8
+ #
9
+ # See GitHub contributions for a more detailed list of contributors.
10
+ # https://github.com/pdf-bl/pdfbl.sequential/graphs/contributors
11
+ #
12
+ # See LICENSE.rst for license information.
13
+ #
14
+ ##############################################################################
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env python
2
+ ##############################################################################
3
+ #
4
+ # (c) 2025 Simon Billinge.
5
+ # All rights reserved.
6
+ #
7
+ # File coded by: members of the Billinge Group and PDF beamline at NSLS-II.
8
+ #
9
+ # See GitHub contributions for a more detailed list of contributors.
10
+ # https://github.com/pdf-bl/pdfbl.sequential/graphs/contributors
11
+ #
12
+ # See LICENSE.rst for license information.
13
+ #
14
+ ##############################################################################
15
+ """Automated sequential refinements of PDF data."""
16
+
17
+ # package version
18
+ from pdfbl.sequential.version import __version__ # noqa
19
+
20
+ # silence the pyflakes syntax checker
21
+ assert __version__ or True
22
+
23
+ # End of file
@@ -0,0 +1,470 @@
1
+ import json
2
+ import tempfile
3
+ import warnings
4
+ from pathlib import Path
5
+ from queue import Queue
6
+ from typing import Literal
7
+
8
+ import numpy
9
+ from diffpy.srfit.fitbase import (
10
+ FitContribution,
11
+ FitRecipe,
12
+ FitResults,
13
+ Profile,
14
+ )
15
+ from diffpy.srfit.pdf import PDFGenerator, PDFParser
16
+ from diffpy.srfit.structure import constrainAsSpaceGroup
17
+ from diffpy.structure.parsers import getParser
18
+ from scipy.optimize import least_squares
19
+
20
+
21
+ class PDFAdapter:
22
+ """Adapter to expose PDF fitting interface. Designed to provide a
23
+ simplified PDF fitting interface for human users and AI agents.
24
+
25
+ Attributes
26
+ ----------
27
+ recipe : FitRecipe
28
+ The FitRecipe object managing the fitting process.
29
+
30
+ Methods
31
+ -------
32
+ initialize_profile(profile_path, qmin=None, qmax=None, xmin=None, xmax=None, dx=None)
33
+ Load and initialize the PDF profile from the given file path with
34
+ some optional parameters.
35
+ initialize_structures(structure_paths : list[str], run_parallel=True)
36
+ Load and initialize the structures from the given file paths, and
37
+ generate corresponding PDFGenerator objects.
38
+ initialize_contribution(equation_string=None)
39
+ Initialize the FitContribution object combining the PDF generators and
40
+ the profile.
41
+ initialize_recipe()
42
+ Initialize the FitRecipe object for the fitting process.
43
+ set_initial_variable_values(variable_name_to_value : dict)
44
+ Update parameter values from the provided dictionary.
45
+ refine_variables(variable_names: list[str])
46
+ Refine the parameters specified in the list and in that order.
47
+ get_variable_names()
48
+ Get the names of all variables in the recipe.
49
+ save_results(mode: str, filename: str=None)
50
+ Save the fitting results.
51
+ """ # noqa: E501
52
+
53
+ def __init__(self):
54
+ self.intermediate_results = {}
55
+ self.iter_count = 0
56
+
57
+ def monitor_intermediate_results(
58
+ self, key: str, step: int = 10, queue: Queue = None
59
+ ):
60
+ """Store an intermediate result during the fitting process.
61
+
62
+ Parameters
63
+ ----------
64
+ key : str
65
+ The key to identify the intermediate result.
66
+ step : int
67
+ The step interval to store the intermediate result.
68
+ queue : Queue
69
+ The queue to store the intermediate results.
70
+ """
71
+ if queue is None:
72
+ queue = Queue()
73
+ self.intermediate_results[(key, step)] = queue
74
+
75
+ def initialize_profile(
76
+ self,
77
+ profile_path: str,
78
+ qmin=None,
79
+ qmax=None,
80
+ xmin=None,
81
+ xmax=None,
82
+ dx=None,
83
+ ):
84
+ """Load and initialize the PDF profile from the given file path
85
+ with some optional parameters.
86
+
87
+ The target output, FitRecipe, requires a profile object, multiple
88
+ PDFGenerator objects, and a FitContribution object combining them. This
89
+ method initializes the profile object.
90
+
91
+ Parameters
92
+ ----------
93
+ profile_path : str
94
+ The path to the experimental PDF profile file.
95
+ qmin : float
96
+ The minimum Q value for PDF calculation. The default value is
97
+ the one parsed from the profile file.
98
+ qmax : float
99
+ The maximum Q value for PDF calculation. The default value is the
100
+ one parsed from the profile file.
101
+ xmin : float
102
+ The minimum r value for PDF calculation. The default value is the
103
+ one parsed from the profile file.
104
+ xmax : float
105
+ The maximum r value for PDF calculation. The default value is the
106
+ one parsed from the profile file.
107
+ dx : float
108
+ The r step size for PDF calculation. The default value is the
109
+ one parsed from the profile file.
110
+ """
111
+ profile = Profile()
112
+ parser = PDFParser()
113
+ parser.parseString(Path(profile_path).read_text())
114
+ profile.loadParsedData(parser)
115
+ if qmin:
116
+ profile.meta["qmin"] = qmin
117
+ if qmax:
118
+ profile.meta["qmax"] = qmax
119
+ profile.setCalculationRange(xmin=xmin, xmax=xmax, dx=dx)
120
+ self.profile = profile
121
+
122
+ def initialize_structures(
123
+ self, structure_paths: list[str], run_parallel=True
124
+ ):
125
+ """Load and initialize the structures from the given file paths,
126
+ and generate corresponding PDFGenerator objects.
127
+
128
+ The target output, FitRecipe, requires a profile object, multiple
129
+ PDFGenerator objects, and a FitContribution object combining them. This
130
+ method creates the PDFGenerator objects from the structure files.
131
+
132
+ Must be called after initialize_profile.
133
+
134
+ Parameters
135
+ ----------
136
+ structure_paths : list of str
137
+ The list of paths to the structure files (CIF format).
138
+
139
+ Notes
140
+ -----
141
+ Planned features:
142
+ - Support cif file manipulation.
143
+ - Add/Remove atoms.
144
+ - symmetry operations?
145
+ """
146
+ if isinstance(structure_paths, str):
147
+ structure_paths = [structure_paths]
148
+ structures = []
149
+ spacegroups = []
150
+ pdfgenerators = []
151
+ if run_parallel:
152
+ try:
153
+ import multiprocessing
154
+ from multiprocessing import Pool
155
+
156
+ import psutil
157
+
158
+ syst_cores = multiprocessing.cpu_count()
159
+ cpu_percent = psutil.cpu_percent()
160
+ avail_cores = numpy.floor(
161
+ (100 - cpu_percent) / (100.0 / syst_cores)
162
+ )
163
+ ncpu = int(numpy.max([1, avail_cores]))
164
+ pool = Pool(processes=ncpu)
165
+ self.pool = pool
166
+ except ImportError:
167
+ warnings.warn(
168
+ "\nYou don't appear to have the necessary packages for "
169
+ "parallelization. Proceeding without parallelization."
170
+ )
171
+ run_parallel = False
172
+ for i, structure_path in enumerate(structure_paths):
173
+ stru_parser = getParser("cif")
174
+ structure = stru_parser.parse(Path(structure_path).read_text())
175
+ sg = getattr(stru_parser, "spacegroup", None)
176
+ spacegroup = sg.short_name if sg is not None else "P1"
177
+ structures.append(structure)
178
+ spacegroups.append(spacegroup)
179
+ pdfgenerator = PDFGenerator(f"G{i+1}")
180
+ pdfgenerator.setStructure(structure)
181
+ if run_parallel:
182
+ pdfgenerator.parallel(ncpu=ncpu, mapfunc=self.pool.map)
183
+ pdfgenerators.append(pdfgenerator)
184
+ self.spacegroups = spacegroups
185
+ self.pdfgenerators = pdfgenerators
186
+
187
+ def initialize_contribution(self, equation_string=None):
188
+ """Initialize the FitContribution object combining the PDF
189
+ generators and the profile.
190
+
191
+ The target output, FitRecipe, requires a profile object, multiple
192
+ PDFGenerator objects, and a FitContribution object combining them. This
193
+ method creates the FitContribution object combining the profile and PDF
194
+ generators.
195
+
196
+ Must be called after initialize_profile and initialize_structures.
197
+
198
+ Parameters
199
+ ----------
200
+ equation_string : str
201
+ The equation string defining the contribution. The default
202
+ equation will be generated based on the number of phases.
203
+ e.g.
204
+ for one phase: "s0*G1",
205
+ for two phases: "s0*(s1*G1+(1-s1)*G2)",
206
+ for three phases: "s0*(s1*G1+s2*G2+(1-(s1+s2))*G3)",
207
+ ...
208
+
209
+ Notes
210
+ -----
211
+ Planned features:
212
+ - Support registerFunction for custom equations.
213
+ """
214
+ contribution = FitContribution("pdfcontribution")
215
+ contribution.setProfile(self.profile)
216
+ for pdfgenerator in self.pdfgenerators:
217
+ contribution.addProfileGenerator(pdfgenerator)
218
+ number_of_phase = len(self.pdfgenerators)
219
+ if equation_string is None:
220
+ if number_of_phase == 1:
221
+ equation_string = "s0*G1"
222
+ else:
223
+ equation_string = (
224
+ "s0*("
225
+ + "+".join(
226
+ [f"s{i+1}*G{i+1}" for i in range(number_of_phase - 1)]
227
+ )
228
+ + f"+(1-({'+'.join([f's{i+1}' for i in range(1, number_of_phase)])}))*G{number_of_phase}" # noqa: E501
229
+ + ")"
230
+ )
231
+ contribution.setEquation(equation_string)
232
+ self.contribution = contribution
233
+ return self.contribution
234
+
235
+ def initialize_recipe(
236
+ self,
237
+ ):
238
+ """Initialize the FitRecipe object for the fitting process.
239
+
240
+ The target output, FitRecipe, requires a profile object, multiple
241
+ PDFGenerator objects, and a FitContribution object combining them. This
242
+ method creates the FitRecipe object combining the profile, PDF
243
+ generators, and contribution.
244
+
245
+ Must be called after initialize_contribution.
246
+
247
+ Notes
248
+ -----
249
+ Planned features:
250
+ - support instructions to
251
+ - add variables
252
+ - constrain variables of the scatters
253
+ - change symmetry constraints
254
+ """
255
+
256
+ def modify_xyz_adp_name(parname, nth_phase):
257
+ parname, nth_atom = parname.split("_")
258
+ return f"{parname}_phase_{nth_phase+1}_atom_{int(nth_atom)+1}"
259
+
260
+ def modify_lat_delta_name(parname, nth_phase):
261
+ return f"{parname}_phase_{nth_phase+1}"
262
+
263
+ recipe = FitRecipe()
264
+ recipe.addContribution(self.contribution)
265
+ qdamp = recipe.newVar("qdamp", fixed=False, value=0.04)
266
+ qbroad = recipe.newVar("qbroad", fixed=False, value=0.02)
267
+ for i, (pdfgenerator, spacegroup) in enumerate(
268
+ zip(self.pdfgenerators, self.spacegroups)
269
+ ):
270
+ for pname in [
271
+ "delta1",
272
+ "delta2",
273
+ ]:
274
+ par = getattr(pdfgenerator, pname)
275
+ recipe.addVar(
276
+ par, name=modify_lat_delta_name(pname, i), fixed=False
277
+ )
278
+ if len(self.pdfgenerators) > 1:
279
+ recipe.addVar(
280
+ getattr(self.contribution, f"s{i+1}"),
281
+ name=f"s{i+1}",
282
+ fixed=False,
283
+ )
284
+ recipe.restrain(f"s{i+1}", lb=0.0, ub=1.0)
285
+ recipe.constrain(pdfgenerator.qdamp, qdamp)
286
+ recipe.constrain(pdfgenerator.qbroad, qbroad)
287
+ stru_parset = pdfgenerator.phase
288
+ spacegroupparams = constrainAsSpaceGroup(stru_parset, spacegroup)
289
+ for par in spacegroupparams.xyzpars:
290
+ recipe.addVar(
291
+ par, name=modify_xyz_adp_name(par.name, i), fixed=False
292
+ )
293
+ for par in spacegroupparams.latpars:
294
+ recipe.addVar(
295
+ par, name=modify_lat_delta_name(par.name, i), fixed=False
296
+ )
297
+ for par in spacegroupparams.adppars:
298
+ recipe.addVar(
299
+ par, name=modify_xyz_adp_name(par.name, i), fixed=False
300
+ )
301
+ recipe.addVar(self.contribution.s0, name="s0", fixed=False)
302
+ recipe.fix("all")
303
+ recipe.fithooks[0].verbose = 0
304
+ self.recipe = recipe
305
+
306
+ def set_initial_variable_values(self, variable_name_to_value: dict):
307
+ """Update parameter values from the provided dictionary.
308
+
309
+ Parameters
310
+ ----------
311
+ variable_name_to_value : dict
312
+ A dictionary mapping variable names to their new values.
313
+ """
314
+ for vname, vvalue in variable_name_to_value.items():
315
+ self.recipe._parameters[vname].setValue(vvalue)
316
+
317
+ def residual(self, p=[]):
318
+ """Wrapper for the recipe residual function to store
319
+ intermediate results if needed.
320
+
321
+ Parameters
322
+ ----------
323
+ p : list
324
+ List of parameter values.
325
+
326
+ Returns
327
+ -------
328
+ numpy.ndarray
329
+ The residual array.
330
+ """
331
+ residual = self.recipe.residual(p)
332
+ fitresults_dict = None
333
+ for (key, step), values in self.intermediate_results.items():
334
+ if (self.iter_count % step) == 0:
335
+ if fitresults_dict is None:
336
+ fitresults_dict = self.save_results(mode="dict")
337
+ value = fitresults_dict.get(key, None)
338
+ if value is None:
339
+ raise KeyError(
340
+ f"{key} is not found in the fit results. "
341
+ f"Available keys are: {list(fitresults_dict.keys())}"
342
+ )
343
+ values.put(value)
344
+ self.iter_count += 1
345
+ return residual
346
+
347
+ def refine_variables(self, variable_names: list[str]):
348
+ """Refine the parameters specified in the list and in that
349
+ order. Must be called after initialize_recipe.
350
+
351
+ Parameters
352
+ ----------
353
+ variable_names : list of str
354
+ The names of the variables to refine.
355
+ """
356
+ for vname in variable_names:
357
+ if vname not in self.recipe._parameters:
358
+ raise ValueError(
359
+ f"Variable {vname} not found in the recipe. "
360
+ "Please choose from the existing variables: "
361
+ f"{list(self.recipe._parameters.keys())}"
362
+ )
363
+ for vname in variable_names:
364
+ self.recipe.free(vname)
365
+ least_squares(
366
+ self.residual,
367
+ self.recipe.values,
368
+ x_scale="jac",
369
+ )
370
+
371
+ def get_variable_names(self) -> list[str]:
372
+ """Get the names of all variables in the recipe.
373
+
374
+ Returns
375
+ -------
376
+ list of str
377
+ A list of variable names.
378
+ """
379
+ return list(self.recipe._parameters.keys())
380
+
381
+ def save_results(
382
+ self, mode: Literal["str", "dict"] = "str", filename=None
383
+ ):
384
+ """Save the fitting results. Must be called after
385
+ refine_variables.
386
+
387
+ Parameters
388
+ ----------
389
+ mode : str
390
+ The format to save the results. Options are:
391
+ "str" - Save results as a formatted text string.
392
+ "dict" - Save results as a JSON-compatible dictionary.
393
+ filename : str
394
+ The path to the output file. If None, results will not be saved to
395
+ a file.
396
+
397
+ Returns
398
+ -------
399
+ str or dict
400
+ The fitting results in the specified format.
401
+ """
402
+ fit_results = FitResults(self.recipe)
403
+ if mode == "str":
404
+ if filename is None:
405
+ tmp_directory = tempfile.TemporaryDirectory()
406
+ temp_file = Path(tmp_directory.name) / "data.txt"
407
+ filename = str(temp_file)
408
+ fit_results.saveResults(filename)
409
+ with open(filename, "r") as f:
410
+ results_str = f.read()
411
+ if filename is None:
412
+ tmp_directory.cleanup()
413
+ return results_str
414
+
415
+ elif mode == "dict":
416
+ results_dict = {}
417
+ results_dict["residual"] = fit_results.residual
418
+ results_dict["contributions"] = (
419
+ fit_results.residual - fit_results.penalty
420
+ )
421
+ results_dict["restraints"] = fit_results.penalty
422
+ results_dict["chi2"] = fit_results.chi2
423
+ results_dict["reduced_chi2"] = fit_results.rchi2
424
+ results_dict["rw"] = fit_results.rw
425
+ # variables
426
+ results_dict["variables"] = {}
427
+ for name, val, unc in zip(
428
+ fit_results.varnames, fit_results.varvals, fit_results.varunc
429
+ ):
430
+ results_dict["variables"][name] = {
431
+ "value": val,
432
+ "uncertainty": unc,
433
+ }
434
+ # fixed variables
435
+ results_dict["fixed_variables"] = {}
436
+ if fit_results.fixednames is not None:
437
+ for name, val in zip(
438
+ fit_results.fixednames, fit_results.fixedvals
439
+ ):
440
+ results_dict["fixed_variables"][name] = {"value": val}
441
+ # constraints
442
+ results_dict["constraints"] = {}
443
+ if fit_results.connames and fit_results.showcon:
444
+ for con in fit_results.conresults.values():
445
+ for i, loc in enumerate(con.conlocs):
446
+ names = [obj.name for obj in loc]
447
+ name = ".".join(names)
448
+ val = con.convals[i]
449
+ unc = con.conuncs[i]
450
+ results_dict["constraints"][name] = {
451
+ "value": val,
452
+ "uncertainty": unc,
453
+ }
454
+ # covariance matrix
455
+ results_dict["covariance_matrix"] = fit_results.cov.tolist()
456
+ # certainty
457
+ certain = True
458
+ for con in fit_results.conresults.values():
459
+ if (con.dy == 1).all():
460
+ certain = False
461
+ results_dict["certain"] = certain
462
+ if filename is not None:
463
+ with open(filename, "w") as f:
464
+ json.dump(results_dict, f, indent=2)
465
+ return results_dict
466
+
467
+ else:
468
+ raise ValueError(
469
+ f"Unsupported mode: {mode}. Please use 'json' or 'txt'."
470
+ )
@@ -0,0 +1,25 @@
1
+ import argparse
2
+
3
+ from pdfbl.sequential import __version__
4
+
5
+
6
+ def main():
7
+ """Entry point for the pdfbl-cli.
8
+
9
+ Examples
10
+ --------
11
+ >>> pdfbl-cli --version
12
+ """
13
+ parser = argparse.ArgumentParser(
14
+ description=(
15
+ "Scripts for running sequential PDF refinements "
16
+ "using diffpy.cmi automatically"
17
+ )
18
+ )
19
+ parser.add_argument(
20
+ "--version",
21
+ action="version",
22
+ version=f"pdfbl.sequential {__version__}",
23
+ help="Show the version of pdfbl.sequential and exit.",
24
+ )
25
+ parser.parse_args()