xrdkit 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.
xrdkit/gsas2.py ADDED
@@ -0,0 +1,915 @@
1
+ """GSAS-II jobs, run under GSAS-II's own Python.
2
+
3
+ GSAS-II brings its own Python and its own compiled extensions, and cannot be
4
+ imported here. Instead a job, a dict naming an action and its inputs, is
5
+ written as JSON, and ``gsas2_driver.py`` runs it under the GSAS-II Python
6
+ through GSASIIscriptable and writes its result as JSON for :func:`run_job` to
7
+ read back. The driver imports nothing from xrdkit.
8
+
9
+ :func:`find_gsas2` locates the installation, and :func:`write_instprm` writes
10
+ the instrument parameter file a powder histogram is read with.
11
+ :func:`build_refine_job` assembles a refinement job from a list of stages,
12
+ such as the usual sequence :func:`standard_stages` returns, and
13
+ :func:`gsas2_fwhm` gives the line width refined instrument parameters imply.
14
+ :func:`structure_edits` turns a refined structure into the atom edits that
15
+ set it up again in a new project, so that one refinement can start where
16
+ another ended.
17
+
18
+ This module imports only the standard library; the exceptions, the xrdkit
19
+ reader used to write a two column copy of an ``.xrdml`` scan, numpy for
20
+ widths at many angles at once and the bond lengths a job may ask for, are
21
+ imported when needed.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import copy
27
+ import json
28
+ import math
29
+ import os
30
+ import subprocess
31
+ import sys
32
+ from collections.abc import Mapping, Sequence
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING
36
+
37
+ from xrdkit import gsas2_driver
38
+
39
+ if TYPE_CHECKING:
40
+ import numpy as np
41
+
42
+ from xrdkit.broadening import Caglioti
43
+
44
+ __all__ = [
45
+ "GSAS2_HOME_VARIABLE",
46
+ "GSAS2_PYTHON_VARIABLE",
47
+ "Gsas2Error",
48
+ "Gsas2Install",
49
+ "build_refine_job",
50
+ "find_gsas2",
51
+ "gsas2_fwhm",
52
+ "run_job",
53
+ "standard_stages",
54
+ "structure_edits",
55
+ "write_instprm",
56
+ ]
57
+
58
+ GSAS2_PYTHON_VARIABLE = "XRDKIT_GSAS2_PYTHON"
59
+ GSAS2_HOME_VARIABLE = "XRDKIT_GSAS2_HOME"
60
+
61
+ # The default installation, under the home folder: the GSAS-II Python sits at
62
+ # its top and the folder holding the GSASII package below it.
63
+ DEFAULT_FOLDER = "gsas2main"
64
+ DEFAULT_HOME = "GSAS-II"
65
+
66
+ DRIVER = Path(__file__).with_name("gsas2_driver.py")
67
+
68
+ # First line of a GSAS-II instrument parameter file, which its reader checks.
69
+ INSTPRM_HEADER = "#GSAS-II instrument parameter file; do not add/delete items!"
70
+
71
+ # Degrees squared to centidegrees squared.
72
+ CENTIDEGREES_SQUARED = 1.0e4
73
+
74
+ # FWHM^2 over the variance of a Gaussian.
75
+ EIGHT_LN2 = 8.0 * math.log(2.0)
76
+
77
+ # The Gaussian FWHM over sigma, the smallest Gaussian variance in
78
+ # centidegrees squared, and the Thompson, Cox and Hastings quintic of
79
+ # coefficients of G^5, G^4 L, ... L^5, as GSASIIpwd.getFWHM and getgamFW
80
+ # write them.
81
+ GSAS2_SQRT_8LN2 = 2.35482
82
+ GSAS2_MIN_VARIANCE = 0.001
83
+ GSAS2_TCH = (1.0, 2.69269, 2.42843, 4.47163, 0.07842, 1.0)
84
+
85
+ # Least squares cycles a refinement stage may take; GSAS-II's own default is 3.
86
+ DEFAULT_CYCLES = 10
87
+
88
+ # What a job's bonds may set: see xrdkit.structure.bond_lengths.
89
+ BOND_KEYS = frozenset({"anions", "dmax", "dmin"})
90
+
91
+ # Atoms closer than this in every fractional coordinate share a site.
92
+ SAME_SITE = 1.0e-4
93
+
94
+
95
+ class Gsas2Error(RuntimeError):
96
+ """A GSAS-II job that exited with an error; ``stderr`` holds its report."""
97
+
98
+ def __init__(self, message: str, stdout: str = "", stderr: str = "") -> None:
99
+ super().__init__(message)
100
+ self.stdout = stdout
101
+ self.stderr = stderr
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Gsas2Install:
106
+ """A GSAS-II installation.
107
+
108
+ ``python`` is the interpreter GSAS-II runs under and ``home`` the folder
109
+ that contains the ``GSASII`` package, which the driver puts on
110
+ ``sys.path``.
111
+ """
112
+
113
+ python: Path
114
+ home: Path
115
+
116
+
117
+ def _default_python(folder: Path) -> Path:
118
+ if sys.platform == "win32":
119
+ return folder / "python.exe"
120
+ return folder / "bin" / "python"
121
+
122
+
123
+ def find_gsas2() -> Gsas2Install:
124
+ """Locate GSAS-II.
125
+
126
+ ``XRDKIT_GSAS2_PYTHON`` names the GSAS-II Python and ``XRDKIT_GSAS2_HOME``
127
+ the folder containing the ``GSASII`` package. Either left unset falls back
128
+ to the default installation, ``~/gsas2main``, whose Python is
129
+ ``python.exe`` on Windows and ``bin/python`` elsewhere, and whose package
130
+ folder is ``GSAS-II``.
131
+
132
+ Raises
133
+ ------
134
+ FileNotFoundError
135
+ If the Python is not a file or the home folder holds no ``GSASII``
136
+ package, naming both environment variables.
137
+ """
138
+ folder = Path.home() / DEFAULT_FOLDER
139
+ python = Path(os.environ.get(GSAS2_PYTHON_VARIABLE) or _default_python(folder))
140
+ home = Path(os.environ.get(GSAS2_HOME_VARIABLE) or folder / DEFAULT_HOME)
141
+
142
+ problems = []
143
+ if not python.is_file():
144
+ problems.append(f"no GSAS-II Python at {python}")
145
+ if not (home / "GSASII").is_dir():
146
+ problems.append(f"no GSASII package in {home}")
147
+ if problems:
148
+ raise FileNotFoundError(
149
+ "GSAS-II not found: "
150
+ + "; ".join(problems)
151
+ + f". Set {GSAS2_PYTHON_VARIABLE} to the GSAS-II Python and "
152
+ f"{GSAS2_HOME_VARIABLE} to the folder that contains the GSASII package."
153
+ )
154
+ return Gsas2Install(python=python, home=home)
155
+
156
+
157
+ def write_instprm(
158
+ path: str | Path,
159
+ caglioti: Caglioti,
160
+ zero: float = 0.0,
161
+ x: float = 0.0,
162
+ y: float = 0.0,
163
+ shl: float = 0.002,
164
+ lam1: float = 1.54056,
165
+ lam2: float = 1.54439,
166
+ ratio: float = 0.5,
167
+ polariz: float = 0.7,
168
+ ) -> Path:
169
+ """Write a GSAS-II instrument parameter file for a Cu K alpha lab pattern.
170
+
171
+ The file is the ``key:value`` form GSAS-II reads, one bank of constant
172
+ wavelength X-ray data (``Type:PXC``) with the K alpha 1 and K alpha 2
173
+ wavelengths, which GSAS-II takes to mean Bragg-Brentano geometry.
174
+
175
+ The profile widths come from ``caglioti``. xrdkit's U, V and W give the
176
+ squared FWHM in degrees squared, whereas GSAS-II's give the variance of
177
+ the Gaussian component in centidegrees squared, sigma^2 =
178
+ U tan^2 + V tan + W, its FWHM being sqrt(8 ln 2) sigma. Each is therefore
179
+ multiplied by 1e4 / (8 ln 2), about 1803: 1e4 for degrees squared to
180
+ centidegrees squared and 1 / (8 ln 2) for FWHM^2 to sigma^2. This treats
181
+ the whole of the fitted width as Gaussian, which is exact while ``x`` and
182
+ ``y`` are zero and a starting point otherwise.
183
+
184
+ Parameters
185
+ ----------
186
+ path
187
+ File to write, conventionally with the ``.instprm`` extension.
188
+ caglioti
189
+ Instrumental resolution, as from :func:`~xrdkit.broadening.fit_caglioti`.
190
+ zero
191
+ Zero shift, in degrees.
192
+ x, y
193
+ Lorentzian widths X / cos(theta) + Y tan(theta), in centidegrees.
194
+ shl
195
+ Axial divergence, (S + H) / L.
196
+ lam1, lam2
197
+ K alpha 1 and K alpha 2 wavelengths, in angstroms.
198
+ ratio
199
+ K alpha 2 over K alpha 1 intensity.
200
+ polariz
201
+ Polarisation fraction.
202
+
203
+ Returns
204
+ -------
205
+ Path
206
+ The file written.
207
+ """
208
+ scale = CENTIDEGREES_SQUARED / EIGHT_LN2
209
+ # In the order GSAS-II itself lists the parameters of a dual wavelength
210
+ # pattern.
211
+ values = {
212
+ "Type": "PXC",
213
+ "Bank": 1,
214
+ "Lam1": lam1,
215
+ "Lam2": lam2,
216
+ "Zero": zero,
217
+ "I(L2)/I(L1)": ratio,
218
+ "Polariz.": polariz,
219
+ "U": caglioti.u * scale,
220
+ "V": caglioti.v * scale,
221
+ "W": caglioti.w * scale,
222
+ "X": x,
223
+ "Y": y,
224
+ "Z": 0.0,
225
+ "SH/L": shl,
226
+ "Azimuth": 0.0,
227
+ }
228
+ lines = [INSTPRM_HEADER] + [f"{key}:{value}" for key, value in values.items()]
229
+ path = Path(path)
230
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
231
+ return path
232
+
233
+
234
+ def gsas2_fwhm(
235
+ two_theta: float | np.ndarray,
236
+ u: float,
237
+ v: float,
238
+ w: float,
239
+ x: float,
240
+ y: float,
241
+ shl: float = 0.0,
242
+ z: float = 0.0,
243
+ ) -> float | np.ndarray:
244
+ """The FWHM GSAS-II gives a line at ``two_theta``, in degrees.
245
+
246
+ The profile parameters are in GSAS-II's units, as refined: U, V and W
247
+ give the Gaussian variance sigma^2 = U tan^2 + V tan + W in centidegrees
248
+ squared, held at no less than 0.001, and X, Y and Z the Lorentzian FWHM
249
+ gamma = X / cos + Y tan + Z in centidegrees, theta being half of
250
+ ``two_theta``. The two are combined into the pseudo-Voigt FWHM by the
251
+ Thompson, Cox and Hastings quintic, with the Gaussian FWHM taken as
252
+ 2.35482 sigma, exactly as GSASIIpwd.getFWHM and getgamFW do.
253
+
254
+ ``shl`` is accepted so that the refined parameters can be passed whole,
255
+ but GSAS-II's FWHM does not depend on it: the axial divergence it sets
256
+ makes the line asymmetric without entering the width.
257
+ """
258
+ del shl
259
+ import numpy as np
260
+
261
+ theta = np.radians(np.asarray(two_theta, dtype=float) / 2.0)
262
+ tan_theta = np.tan(theta)
263
+ variance = np.maximum(GSAS2_MIN_VARIANCE, u * tan_theta**2 + v * tan_theta + w)
264
+ gaussian = GSAS2_SQRT_8LN2 * np.sqrt(variance)
265
+ lorentzian = z + x / np.cos(theta) + y * tan_theta
266
+ quintic = sum(
267
+ coefficient * gaussian ** (5 - order) * lorentzian**order
268
+ for order, coefficient in enumerate(GSAS2_TCH)
269
+ )
270
+ fwhm = quintic**0.2 / 100.0
271
+ return float(fwhm) if fwhm.ndim == 0 else fwhm
272
+
273
+
274
+ def standard_stages(
275
+ background_type: str = gsas2_driver.DEFAULT_BACKGROUND["type"],
276
+ background_terms: int = gsas2_driver.DEFAULT_BACKGROUND["terms"],
277
+ ) -> list[dict]:
278
+ """The usual sequence of refinement stages, for a script to edit.
279
+
280
+ Background and scale; zero; cell; U, V and W; X and Y; SH/L. Each stage
281
+ adds to those before it. The list is new on every call, so it can be
282
+ changed freely, a stage dropped or a flag added.
283
+ """
284
+ return [
285
+ {
286
+ "name": "background and scale",
287
+ "background": {"type": background_type, "terms": background_terms},
288
+ "scale": True,
289
+ },
290
+ {"name": "zero", "zero": True},
291
+ {"name": "cell", "cell": True},
292
+ {"name": "U V W", "instrument": ["U", "V", "W"]},
293
+ {"name": "X Y", "instrument": ["X", "Y"]},
294
+ {"name": "SH/L", "instrument": ["SH/L"]},
295
+ ]
296
+
297
+
298
+ def build_refine_job(
299
+ gpx: str | Path,
300
+ stages: Sequence[Mapping],
301
+ data_file: str | Path | None = None,
302
+ instprm: str | Path | None = None,
303
+ phases: Sequence[Mapping] | None = None,
304
+ limits: tuple[float, float] | None = None,
305
+ cycles: int = DEFAULT_CYCLES,
306
+ broadening: Mapping[str, Mapping[str, float]] | None = None,
307
+ export_prefix: str | Path | None = None,
308
+ le_bail_cycles: int | None = None,
309
+ max_passes: int | None = None,
310
+ pass_tolerance: float | None = None,
311
+ background_start: Mapping | None = None,
312
+ overall_uiso_start: Mapping[str, float] | None = None,
313
+ scale_start: float | None = None,
314
+ sanity: Mapping | None = None,
315
+ bonds: bool | Mapping | None = None,
316
+ on_flagged: str | None = None,
317
+ on_unsettled: str | None = None,
318
+ ) -> dict:
319
+ """A job for :func:`run_job` that refines a project stage by stage.
320
+
321
+ With ``data_file``, ``instprm`` and ``phases`` the project is created at
322
+ ``gpx`` first, as the create action does; without them ``gpx`` must
323
+ already hold one with a single histogram. The stages are checked here,
324
+ with the driver's own rules, so that a mistake is reported before
325
+ GSAS-II starts.
326
+
327
+ Parameters
328
+ ----------
329
+ gpx
330
+ The project file.
331
+ stages
332
+ Refinement stages, as described in ``gsas2_driver``;
333
+ :func:`standard_stages` gives the usual sequence. Copied.
334
+ data_file, instprm
335
+ The pattern and its instrument parameter file.
336
+ phases
337
+ Each a mapping with ``cif`` and ``name``, and optionally ``cell``,
338
+ six lattice parameters that replace those of the CIF, and ``atoms``,
339
+ edits to its atoms made in the project, as
340
+ ``gsas2_driver.check_atom_edits`` describes: ``{"label": ...,
341
+ "occupancy": ...}``, or ``{"label": new, "type": element, "copy":
342
+ existing, "occupancy": ...}`` for another element on an existing
343
+ site, either with ``xyz`` and ``uiso`` if need be;
344
+ :func:`structure_edits` gives those that set up a refined structure.
345
+ limits
346
+ Two theta range to refine over, in degrees; the whole pattern by
347
+ default.
348
+ cycles
349
+ The most least squares cycles a stage may take.
350
+ broadening
351
+ Isotropic sample broadening to start from, by phase name or ``"*"``
352
+ for every phase: ``{"size": microns, "mustrain": microstrain,
353
+ "lgmix": Lorentzian fraction}``, any of them, held fixed until a
354
+ stage refines the size or microstrain. GSAS-II's defaults,
355
+ 1 micron and 1000, both Lorentzian, are far from nothing, and the
356
+ size cannot exceed 10 microns; refining the instrument on a standard
357
+ needs ``{"*": {"size": 10.0, "mustrain": 0.0, "lgmix": 0.0}}``, or the
358
+ instrument terms take up the difference.
359
+ export_prefix
360
+ Path the exported files are named from: ``<prefix>_histogram.csv``,
361
+ ``<prefix>_reflections_<phase>.csv`` and ``<prefix>.instprm``. By
362
+ default ``gpx`` without its extension.
363
+ le_bail_cycles
364
+ Le Bail-only cycles to run whenever a stage switches Le Bail
365
+ extraction on, before its least squares; the driver's own default,
366
+ ``gsas2_driver.DEFAULT_LE_BAIL_CYCLES``, if not given.
367
+ max_passes, pass_tolerance
368
+ Refine each stage up to ``max_passes`` times, until no parameter
369
+ moves by more than ``pass_tolerance`` esds (the driver's
370
+ ``DEFAULT_PASS_TOLERANCE`` if not given) from one pass to the next.
371
+ A Le Bail refinement needs this, since GSAS-II stops each one short;
372
+ by default a stage is refined once.
373
+ background_start
374
+ ``{"type": ..., "coefficients": [...]}``, the background to start
375
+ from, such as an earlier refinement's; GSAS-II's own otherwise.
376
+ overall_uiso_start
377
+ The Uiso, by phase name or ``"*"``, that every atom of a phase is
378
+ given when a stage first refines its ``overall_uiso``, or that an
379
+ anisotropic atom is given when a Uiso group takes it in.
380
+ scale_start
381
+ The histogram scale to start from, such as an earlier refinement's;
382
+ GSAS-II's 1 otherwise.
383
+ sanity
384
+ ``{"max_shift": ..., "reference": {phase: [{"label": ..., "xyz":
385
+ [...]}, ...]}}``, either, for the sanity check after every stage: a
386
+ site is flagged when a coordinate has moved more than ``max_shift``
387
+ (fractional) from its reference position. By default
388
+ ``gsas2_driver.DEFAULT_MAX_SHIFT``, from the atoms as the job finds
389
+ them before its first stage.
390
+ bonds
391
+ True, or ``{"anions": [...], "dmax": ..., "dmin": ...}``, any of them,
392
+ for :func:`run_job` to add to the result, as ``bonds``, the cation to
393
+ anion distances of each phase's final structure, as
394
+ :func:`xrdkit.structure.bond_lengths` gives them: to oxygen, from 0.5
395
+ to 3 angstroms, by default.
396
+ on_flagged, on_unsettled
397
+ "accept" or "reject": what becomes of a stage whose sanity check
398
+ raises a flag the last stage kept did not have, and of one not
399
+ settled after ``max_passes``. A rejected stage is recorded and the
400
+ project taken back to the last stage kept, with what the stage
401
+ alone refined held from then on (see the ``gsas2_driver`` notes). By
402
+ default a new flag rejects a stage and an unsettled one is kept.
403
+
404
+ Raises
405
+ ------
406
+ ValueError
407
+ If only some of ``data_file``, ``instprm`` and ``phases`` are given, a
408
+ phase lacks ``cif`` or ``name``, the limits are not increasing,
409
+ ``cycles``, ``le_bail_cycles``, ``max_passes``, ``pass_tolerance`` or
410
+ ``scale_start`` is not positive, or a stage, the broadening, an atom
411
+ edit, the starting background, a starting Uiso, the sanity settings or
412
+ the bonds are malformed.
413
+ TypeError
414
+ If a stage or one of its lists is of the wrong type.
415
+ """
416
+ creation = (data_file, instprm, phases)
417
+ if any(item is not None for item in creation) and any(
418
+ item is None for item in creation
419
+ ):
420
+ raise ValueError("give all of data_file, instprm and phases, or none")
421
+ if not isinstance(cycles, int) or cycles < 1:
422
+ raise ValueError(f"cycles must be a positive int, got {cycles!r}")
423
+ if le_bail_cycles is not None and (
424
+ not isinstance(le_bail_cycles, int) or le_bail_cycles < 1
425
+ ):
426
+ raise ValueError(
427
+ f"le_bail_cycles must be a positive int, got {le_bail_cycles!r}"
428
+ )
429
+ if max_passes is not None and (not isinstance(max_passes, int) or max_passes < 1):
430
+ raise ValueError(f"max_passes must be a positive int, got {max_passes!r}")
431
+ if pass_tolerance is not None and not pass_tolerance > 0.0:
432
+ raise ValueError(f"pass_tolerance must be positive, got {pass_tolerance!r}")
433
+ stages = copy.deepcopy([dict(stage) for stage in stages])
434
+ gsas2_driver.accumulate_stages(stages)
435
+
436
+ gpx = Path(gpx)
437
+ job = {
438
+ "action": "refine",
439
+ "gpx": str(gpx),
440
+ "stages": stages,
441
+ "cycles": cycles,
442
+ "export_prefix": str(export_prefix or gpx.with_suffix("")),
443
+ }
444
+ if limits is not None:
445
+ lower, upper = (float(value) for value in limits)
446
+ if not lower < upper:
447
+ raise ValueError(f"limits must increase, got {limits}")
448
+ job["limits"] = [lower, upper]
449
+ if le_bail_cycles is not None:
450
+ job["le_bail_cycles"] = le_bail_cycles
451
+ if max_passes is not None:
452
+ job["max_passes"] = max_passes
453
+ if pass_tolerance is not None:
454
+ job["pass_tolerance"] = float(pass_tolerance)
455
+ if broadening is not None:
456
+ job["broadening"] = gsas2_driver.check_broadening(
457
+ {str(phase): dict(values) for phase, values in broadening.items()}
458
+ )
459
+ if background_start is not None:
460
+ job["background_start"] = gsas2_driver.check_background_start(
461
+ dict(background_start)
462
+ )
463
+ if overall_uiso_start is not None:
464
+ job["overall_uiso_start"] = gsas2_driver.check_uiso_start(
465
+ dict(overall_uiso_start)
466
+ )
467
+ if scale_start is not None:
468
+ job["scale_start"] = gsas2_driver.check_scale_start(scale_start)
469
+ if sanity is not None:
470
+ checked = gsas2_driver.check_sanity_settings(dict(sanity))
471
+ job["sanity"] = {"max_shift": checked["max_shift"]}
472
+ if checked["reference"] is not None:
473
+ job["sanity"]["reference"] = checked["reference"]
474
+ if bonds is not None and bonds is not False:
475
+ job["bonds"] = _check_bonds(bonds)
476
+ policies = {"on_flagged": on_flagged, "on_unsettled": on_unsettled}
477
+ gsas2_driver.check_policies(policies)
478
+ job.update({key: value for key, value in policies.items() if value is not None})
479
+ if data_file is not None:
480
+ entries = []
481
+ for phase in phases:
482
+ if "cif" not in phase or "name" not in phase:
483
+ raise ValueError(f"each phase needs a cif and a name, got {phase}")
484
+ entry = {"cif": str(phase["cif"]), "name": str(phase["name"])}
485
+ if phase.get("cell") is not None:
486
+ entry["cell"] = [float(value) for value in phase["cell"]]
487
+ if phase.get("atoms"):
488
+ entry["atoms"] = gsas2_driver.check_atom_edits(
489
+ [dict(edit) for edit in phase["atoms"]], entry["name"]
490
+ )
491
+ entries.append(entry)
492
+ job.update(data_file=str(data_file), instprm=str(instprm), phases=entries)
493
+ return job
494
+
495
+
496
+ def _check_bonds(bonds: bool | Mapping) -> dict:
497
+ """Check a job's ``bonds``: True for the defaults, or some of
498
+ ``anions``, ``dmax`` and ``dmin``."""
499
+ if bonds is True:
500
+ return {}
501
+ if not isinstance(bonds, Mapping) or set(bonds) - BOND_KEYS:
502
+ raise ValueError(
503
+ f"bonds must be True or give anions, dmax and/or dmin, got {bonds!r}"
504
+ )
505
+ checked: dict = {}
506
+ if "anions" in bonds:
507
+ checked["anions"] = [str(anion) for anion in bonds["anions"]]
508
+ if not checked["anions"]:
509
+ raise ValueError("bonds needs at least one anion")
510
+ for key in ("dmax", "dmin"):
511
+ if key in bonds:
512
+ checked[key] = float(bonds[key])
513
+ if not checked.get("dmax", 3.0) > checked.get("dmin", 0.5):
514
+ raise ValueError(f"bonds dmax must exceed dmin, got {bonds!r}")
515
+ return checked
516
+
517
+
518
+ def _same_site(first: Sequence[float], second: Sequence[float]) -> bool:
519
+ return all(abs((a - b) - round(a - b)) < SAME_SITE for a, b in zip(first, second))
520
+
521
+
522
+ def structure_edits(refined: Sequence[Mapping], base: Sequence[Mapping]) -> list[dict]:
523
+ """Atom edits that set up a refined structure again in a new project
524
+ read from the same CIF, for a phase entry's ``atoms``.
525
+
526
+ ``refined`` is a phase's atoms as a refine result reports them, in its
527
+ ``final`` phases or a stage's ``atoms``, and ``base`` the atoms the CIF
528
+ gives, as the create action reports them. Each refined atom is given its
529
+ occupancy, its position and, if it is isotropic, its Uiso. An atom the
530
+ CIF lacks, such as one added on a site by an earlier edit, is added
531
+ beside the CIF atom that shares its site in the refinement, with its
532
+ element, after every edit of the CIF's atoms. An anisotropic atom keeps
533
+ the Uij of the CIF, or of the atom it is added beside.
534
+
535
+ Raises
536
+ ------
537
+ ValueError
538
+ If an atom the CIF lacks shares no site with one it has.
539
+ """
540
+ labels = {str(atom["label"]) for atom in base}
541
+ edits, added = [], []
542
+ for atom in refined:
543
+ edit = {
544
+ "label": str(atom["label"]),
545
+ "occupancy": float(atom["occupancy"]),
546
+ "xyz": [float(value) for value in atom["xyz"]],
547
+ }
548
+ if atom.get("adp", "I") == "I" and atom.get("uiso") is not None:
549
+ edit["uiso"] = float(atom["uiso"])
550
+ if edit["label"] in labels:
551
+ edits.append(edit)
552
+ continue
553
+ host = next(
554
+ (
555
+ other
556
+ for other in refined
557
+ if str(other["label"]) in labels
558
+ and _same_site(other["xyz"], atom["xyz"])
559
+ ),
560
+ None,
561
+ )
562
+ if host is None:
563
+ raise ValueError(
564
+ f"atom {edit['label']!r} shares no site with an atom of the CIF"
565
+ )
566
+ added.append({**edit, "type": str(atom["type"]), "copy": str(host["label"])})
567
+ return edits + added
568
+
569
+
570
+ def _add_bonds(result: dict, options: Mapping) -> None:
571
+ """Add the cation to anion distances of each phase's final structure to
572
+ ``result``, as ``bonds`` by phase name, or the error as ``bonds_error``."""
573
+ from xrdkit.structure import bond_lengths
574
+
575
+ try:
576
+ result["bonds"] = {
577
+ phase["name"]: bond_lengths(phase, **options)
578
+ for phase in result["final"]["phases"]
579
+ if phase.get("operators")
580
+ }
581
+ except (KeyError, TypeError, ValueError) as error:
582
+ result["bonds_error"] = f"{type(error).__name__}: {error}"
583
+
584
+
585
+ def _environment(install: Gsas2Install) -> dict[str, str]:
586
+ """The environment the GSAS-II Python runs in.
587
+
588
+ GSAS-II opens its data files as text in the locale encoding, which on
589
+ Windows turns the byte order mark that PANalytical writes at the head of
590
+ an ``.xrdml`` file into characters the XML parser rejects. Python's UTF-8
591
+ mode, the default from Python 3.15, reads it correctly, and is turned on
592
+ unless ``PYTHONUTF8`` is already set.
593
+
594
+ The GSAS-II Python is a conda environment. On Windows its numpy finds the
595
+ BLAS and LAPACK DLLs only through the folders ``conda activate`` puts on
596
+ PATH, and without them the first matrix inversion kills the process, so
597
+ those of them that exist are put at the front of PATH here.
598
+ """
599
+ environment = {**os.environ, "PYTHONIOENCODING": "utf-8"}
600
+ environment.setdefault("PYTHONUTF8", "1")
601
+ if sys.platform == "win32":
602
+ prefix = install.python.parent
603
+ folders = [
604
+ prefix,
605
+ prefix / "Library" / "mingw-w64" / "bin",
606
+ prefix / "Library" / "usr" / "bin",
607
+ prefix / "Library" / "bin",
608
+ prefix / "Scripts",
609
+ ]
610
+ path = [str(folder) for folder in folders if folder.is_dir()]
611
+ environment["PATH"] = os.pathsep.join([*path, environment.get("PATH", "")])
612
+ return environment
613
+
614
+
615
+ def _write_xy(data_file: Path, workdir: Path) -> Path:
616
+ """A two column copy of an ``.xrdml`` scan in ``workdir``, read by xrdkit."""
617
+ from xrdkit.io import read_xrdml
618
+
619
+ scan = read_xrdml(data_file)
620
+ path = workdir / f"{data_file.stem}.xy"
621
+ rows = (f"{t:.6f} {i:.10g}" for t, i in zip(scan.two_theta, scan.intensity))
622
+ path.write_text("\n".join(rows) + "\n", encoding="utf-8")
623
+ return path
624
+
625
+
626
+ def run_job(
627
+ job: dict, workdir: str | Path, install: Gsas2Install | None = None
628
+ ) -> dict:
629
+ """Run a job under the GSAS-II Python and return its result.
630
+
631
+ The job, with ``gsas2_home`` added, is written to
632
+ ``workdir/<action>_job.json`` and ``gsas2_driver.py`` run on it as
633
+ ``python -B driver job.json`` in ``workdir``, so that nothing, bytecode
634
+ included, lands in the GSAS-II installation, with the conda environment's
635
+ DLL folders on PATH on Windows. Its output is kept in
636
+ ``workdir/<action>.log``. The driver writes its result to ``job["result"]``,
637
+ by default ``workdir/<action>_result.json``.
638
+
639
+ When ``job["data_file"]`` is an ``.xrdml`` scan, a two column ``.xy`` copy
640
+ is written to ``workdir`` first and passed as ``job["data_fallback"]``,
641
+ for the driver to read should GSAS-II's own xrdml importer fail. When the
642
+ job has ``bonds`` (see :func:`build_refine_job`) and the result a final
643
+ structure, its bond lengths are added to the result, and the result file
644
+ written again with them.
645
+
646
+ Parameters
647
+ ----------
648
+ job
649
+ The job; ``job["action"]`` selects what the driver does. Not modified.
650
+ workdir
651
+ Folder for the job, its result and its log; created if need be.
652
+ install
653
+ The installation to use, :func:`find_gsas2` by default.
654
+
655
+ Raises
656
+ ------
657
+ Gsas2Error
658
+ If the driver exits with an error, carrying its stderr.
659
+ """
660
+ install = install or find_gsas2()
661
+ workdir = Path(workdir).resolve()
662
+ workdir.mkdir(parents=True, exist_ok=True)
663
+ action = job["action"]
664
+
665
+ job = dict(job)
666
+ job["gsas2_home"] = str(install.home)
667
+ job.setdefault("result", str(workdir / f"{action}_result.json"))
668
+ data_file = job.get("data_file")
669
+ if (
670
+ data_file
671
+ and Path(data_file).suffix.lower() == ".xrdml"
672
+ and "data_fallback" not in job
673
+ ):
674
+ job["data_fallback"] = str(_write_xy(Path(data_file), workdir))
675
+
676
+ job_path = workdir / f"{action}_job.json"
677
+ job_path.write_text(json.dumps(job, indent=2), encoding="utf-8")
678
+ result_path = Path(job["result"])
679
+ result_path.unlink(missing_ok=True)
680
+
681
+ completed = subprocess.run(
682
+ [str(install.python), "-B", str(DRIVER), str(job_path)],
683
+ check=False,
684
+ cwd=workdir,
685
+ capture_output=True,
686
+ text=True,
687
+ encoding="utf-8",
688
+ errors="replace",
689
+ env=_environment(install),
690
+ )
691
+ (workdir / f"{action}.log").write_text(
692
+ completed.stdout + completed.stderr, encoding="utf-8"
693
+ )
694
+ if completed.returncode != 0:
695
+ raise Gsas2Error(
696
+ f"GSAS-II job {action!r} failed with exit code "
697
+ f"{completed.returncode}:\n{completed.stderr.strip()}",
698
+ stdout=completed.stdout,
699
+ stderr=completed.stderr,
700
+ )
701
+ if not result_path.is_file():
702
+ raise Gsas2Error(
703
+ f"GSAS-II job {action!r} wrote no result at {result_path}",
704
+ stdout=completed.stdout,
705
+ stderr=completed.stderr,
706
+ )
707
+ result = json.loads(result_path.read_text(encoding="utf-8"))
708
+ if "bonds" in job and result.get("final"):
709
+ _add_bonds(result, job["bonds"])
710
+ result_path.write_text(json.dumps(result, indent=2), encoding="utf-8")
711
+ return result
712
+
713
+
714
+ # Stage statuses and write ups of a sequence of refinements
715
+
716
+ # The statuses of stages refined and kept; the others are rejected and
717
+ # failed.
718
+ ACCEPTED_STATUSES = frozenset({"clean", "flagged", "unsettled"})
719
+ LOG_TAIL_LINES = 40
720
+
721
+
722
+ def stage_status(stage: Mapping) -> str:
723
+ """A refine result stage's status, as the driver records it: clean,
724
+ flagged, unsettled, rejected or failed (see the ``gsas2_driver``
725
+ notes); worked out from ``error`` for a result written before the driver
726
+ recorded one."""
727
+ if stage.get("status"):
728
+ return str(stage["status"])
729
+ return "failed" if "error" in stage else "clean"
730
+
731
+
732
+ def accepted_stages(result: Mapping) -> list[dict]:
733
+ """The stages of a refine result that were refined and kept."""
734
+ return [
735
+ stage
736
+ for stage in result.get("stages", [])
737
+ if stage_status(stage) in ACCEPTED_STATUSES
738
+ ]
739
+
740
+
741
+ def stage_statuses(result: Mapping) -> list[dict]:
742
+ """Each stage of a refine result as ``{"name", "status", "reason",
743
+ "passes", "rwp", "gof", "undetermined"}``.
744
+
745
+ ``reason`` is why a stage was rejected or failed, the new flags of one
746
+ flagged, and for one unsettled the passes it took and its largest
747
+ remaining move in esds; empty for a clean stage. ``undetermined`` lists
748
+ the messages of the parameters the driver found undetermined in it.
749
+ """
750
+ rows = []
751
+ for stage in result.get("stages", []):
752
+ status = stage_status(stage)
753
+ passes = len(stage.get("passes") or []) or None
754
+ if status == "rejected":
755
+ reason = "; ".join(stage.get("rejected_because", []))
756
+ elif status == "failed":
757
+ reason = str(stage.get("error", ""))
758
+ elif status == "unsettled":
759
+ move = stage.get("largest_remaining_move") or {}
760
+ reason = f"not settled in {passes} passes"
761
+ if move.get("shift_over_esd") is not None:
762
+ reason += (
763
+ f"; largest remaining move {move['shift_over_esd']:.2f} esd "
764
+ f"({move['parameter']})"
765
+ )
766
+ elif status == "flagged":
767
+ reason = "; ".join(flag["message"] for flag in stage.get("sanity", []))
768
+ else:
769
+ reason = ""
770
+ rows.append(
771
+ {
772
+ "name": stage.get("name"),
773
+ "status": status,
774
+ "reason": reason,
775
+ "passes": passes,
776
+ "rwp": stage.get("rwp"),
777
+ "gof": stage.get("gof"),
778
+ "undetermined": [
779
+ entry["message"] for entry in stage.get("undetermined", [])
780
+ ],
781
+ }
782
+ )
783
+ return rows
784
+
785
+
786
+ def log_tail(path: str | Path, lines: int = LOG_TAIL_LINES) -> str:
787
+ """The last ``lines`` lines of a log file; empty if there is none."""
788
+ path = Path(path)
789
+ if not path.is_file():
790
+ return ""
791
+ text = path.read_text(encoding="utf-8", errors="replace").splitlines()
792
+ return "\n".join(text[-lines:])
793
+
794
+
795
+ def _figure(value: float | None, digits: int) -> str:
796
+ return "—" if value is None else f"{value:.{digits}f}"
797
+
798
+
799
+ def _cell(text: object) -> str:
800
+ return str(text).replace("|", "\\|").replace("\n", " ")
801
+
802
+
803
+ def stage_status_table(result: Mapping) -> list[str]:
804
+ """The stages of a refine result as markdown table lines: status,
805
+ passes, Rwp, GOF and why."""
806
+ lines = [
807
+ "| Stage | Status | Passes | Rwp (%) | GOF | Why |",
808
+ "| --- | --- | --- | --- | --- | --- |",
809
+ ]
810
+ for row in stage_statuses(result):
811
+ lines.append(
812
+ f"| {_cell(row['name'])} | {row['status']} | {row['passes'] or '—'} | "
813
+ f"{_figure(row['rwp'], 3)} | {_figure(row['gof'], 3)} | "
814
+ f"{_cell(row['reason']) or '—'} |"
815
+ )
816
+ return lines
817
+
818
+
819
+ def failure_markdown(
820
+ title: str,
821
+ result: Mapping | None,
822
+ error: str,
823
+ log: str,
824
+ intro: Sequence[str] = (),
825
+ ) -> str:
826
+ """A write up of a refinement that failed: ``intro``, the error, the
827
+ stages it got through with their statuses, and ``log``, the tail of the
828
+ GSAS-II log."""
829
+ lines = [f"# {title}", ""]
830
+ if intro:
831
+ lines += [*intro, ""]
832
+ lines += ["## Error", "", "```", error.strip(), "```", "", "## Stages", ""]
833
+ if result and result.get("stages"):
834
+ lines += stage_status_table(result)
835
+ else:
836
+ lines.append("No stage was refined.")
837
+ lines += [
838
+ "",
839
+ f"## GSAS-II log, last {LOG_TAIL_LINES} lines",
840
+ "",
841
+ "```",
842
+ log.strip() or "(no log)",
843
+ "```",
844
+ ]
845
+ return "\n".join(lines) + "\n"
846
+
847
+
848
+ def summary_markdown(
849
+ title: str,
850
+ entries: Sequence[Mapping],
851
+ columns: Sequence[str] = (),
852
+ intro: Sequence[str] = (),
853
+ ) -> str:
854
+ """A summary of a sequence of refinements as markdown: one table with a
855
+ row per refinement, its final Rwp and GOF, ``columns`` of key values and
856
+ the status of each stage, then every stage not clean with why, and the
857
+ parameters left undetermined.
858
+
859
+ Each entry is ``{"name", "run", "result", "values"}``: its name, when it
860
+ was run, its refine result (None if it has none) and its key values by
861
+ column, as text. The final Rwp and GOF are those of the last stage kept;
862
+ a refinement that kept no stage is noted as leaving the model it started
863
+ from.
864
+ """
865
+ header = ["Refinement", "Run", "Outcome", "Rwp (%)", "GOF", *columns, "Stages"]
866
+ lines = [f"# {title}", ""]
867
+ if intro:
868
+ lines += [*intro, ""]
869
+ lines += ["| " + " | ".join(header) + " |", "|" + " --- |" * len(header)]
870
+ details = []
871
+ for entry in entries:
872
+ result = entry.get("result")
873
+ if result is None:
874
+ cells = [entry["name"], entry.get("run") or "—", "not run", "—", "—"]
875
+ cells += ["—"] * len(columns) + ["—"]
876
+ lines.append("| " + " | ".join(_cell(cell) for cell in cells) + " |")
877
+ continue
878
+ kept = accepted_stages(result)
879
+ final = kept[-1] if kept else {}
880
+ rows = stage_statuses(result)
881
+ values = entry.get("values") or {}
882
+ cells = [
883
+ entry["name"],
884
+ entry.get("run") or "—",
885
+ "completed" if result.get("completed") else "failed",
886
+ _figure(final.get("rwp"), 3),
887
+ _figure(final.get("gof"), 3),
888
+ *(values.get(column, "—") for column in columns),
889
+ "; ".join(f"{row['name']}: {row['status']}" for row in rows) or "none",
890
+ ]
891
+ lines.append("| " + " | ".join(_cell(cell) for cell in cells) + " |")
892
+ if result.get("error"):
893
+ details.append(f"- {entry['name']}: {result['error']}")
894
+ if not kept and result.get("stages"):
895
+ details.append(
896
+ f"- {entry['name']}: no stage was kept, so the model left behind "
897
+ "is the one the refinement started from"
898
+ )
899
+ for row in rows:
900
+ if row["status"] != "clean":
901
+ details.append(
902
+ f"- {entry['name']}, {row['name']}: {row['status']}"
903
+ + (f", {row['reason']}" if row["reason"] else "")
904
+ )
905
+ if row["undetermined"] and (
906
+ row["status"] == "rejected" or row["name"] == final.get("name")
907
+ ):
908
+ where = "rejected" if row["status"] == "rejected" else "final model"
909
+ details.append(
910
+ f"- {entry['name']}, {row['name']} ({where}): undetermined "
911
+ + "; ".join(row["undetermined"])
912
+ )
913
+ lines += ["", "## Stages not clean, and undetermined parameters", ""]
914
+ lines += details or ["Every stage is clean, and no parameter is left undetermined."]
915
+ return "\n".join(lines) + "\n"