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/__init__.py +226 -0
- xrdkit/broadening.py +1155 -0
- xrdkit/config.py +660 -0
- xrdkit/density.py +144 -0
- xrdkit/gsas2.py +915 -0
- xrdkit/gsas2_driver.py +2551 -0
- xrdkit/indexing.py +750 -0
- xrdkit/io.py +115 -0
- xrdkit/lattice.py +233 -0
- xrdkit/peaks.py +340 -0
- xrdkit/phases.py +494 -0
- xrdkit/plotting.py +1072 -0
- xrdkit/py.typed +0 -0
- xrdkit/sizestrain.py +539 -0
- xrdkit/structure.py +470 -0
- xrdkit-0.1.0.dist-info/METADATA +229 -0
- xrdkit-0.1.0.dist-info/RECORD +19 -0
- xrdkit-0.1.0.dist-info/WHEEL +4 -0
- xrdkit-0.1.0.dist-info/licenses/LICENSE +21 -0
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"
|