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_driver.py ADDED
@@ -0,0 +1,2551 @@
1
+ """Run one xrdkit job inside GSAS-II.
2
+
3
+ This script runs under GSAS-II's own Python, started by
4
+ :func:`xrdkit.gsas2.run_job` as ``python -B gsas2_driver.py job.json``. It
5
+ must not import xrdkit, which is not installed there. The job JSON names an
6
+ ``action`` and its inputs, and ``gsas2_home``, the folder containing the
7
+ GSASII package; the result is written as JSON to ``job["result"]``.
8
+
9
+ Each action is a function of GSASIIscriptable and the job returning the result
10
+ dict, registered in ``ACTIONS``. GSASII is imported only once the job is read,
11
+ so that xrdkit can import this module to check stages without it.
12
+
13
+ Actions
14
+ -------
15
+ ``create``
16
+ A new project with one histogram and its phases linked to it.
17
+ ``refine``
18
+ A sequence of refinement stages on a project, created first if the job
19
+ carries the inputs for it, with the pattern, reflections and refined
20
+ instrument parameters exported after the last stage.
21
+
22
+ Stages
23
+ ------
24
+ A stage is a dict with a ``name`` and the refinement flags to switch on:
25
+
26
+ ``background``
27
+ ``True`` for :data:`DEFAULT_BACKGROUND`, or ``{"type": ..., "terms": n}``.
28
+ ``scale``, ``zero``, ``displacement``
29
+ Histogram scale, zero shift, and specimen displacement (``Shift`` in
30
+ Bragg-Brentano geometry, ``DisplaceX`` and ``DisplaceY`` in
31
+ Debye-Scherrer).
32
+ ``instrument``
33
+ A list drawn from :data:`INSTRUMENT_KEYS`.
34
+ ``cell``, ``le_bail``, ``phase_fractions``, ``size``, ``mustrain``, ``overall_uiso``
35
+ ``True`` for every phase or a list of phase names. ``size`` and
36
+ ``mustrain`` refine the isotropic crystallite size and microstrain,
37
+ making either isotropic if it was not. ``overall_uiso`` refines one
38
+ displacement parameter for every atom of the phase: when a stage first
39
+ switches it on, every atom is made isotropic with the Uiso the job's
40
+ ``overall_uiso_start`` gives the phase (by name, or ``"*"``), and all
41
+ the Uiso are constrained equal.
42
+ ``atoms``
43
+ A list of ``{"phase": name, "flags": "FXU"}``, any of F (occupancy),
44
+ X (position) and U (displacement), for every atom of the phase.
45
+ ``atom_flags``
46
+ A list of ``{"phase": name, "labels": [...], "flags": "FXU"}``, for the
47
+ named atoms only. Atoms that share a site and have X refined are
48
+ constrained to move together, in the coordinates the site leaves free.
49
+ ``uiso_groups``
50
+ ``{phase: [[label, ...], ...]}``: groups of sites, each named by the
51
+ label of any atom on it, the Uiso of every atom on a group's sites
52
+ constrained to one value and refined, replacing the phase's groups or
53
+ overall Uiso before (which must be switched off in the same stage,
54
+ ``"overall_uiso": false``). The atoms keep their Uiso as the start.
55
+ ``coordinates``
56
+ ``{phase: {label: "xz", ...}}``: the coordinates to refine on the site of
57
+ each named atom, any of x, y and z, or ``"all"`` for every one the site
58
+ leaves free. Every atom on the site is refined, the atoms that share it
59
+ constrained to move together; a free coordinate not named is held. A
60
+ coordinate the site ties to another by symmetry follows it, so naming
61
+ either frees both; one the site fixes cannot be named.
62
+ ``origin``
63
+ ``{phase: {"site": label, "axis": "z"}}``: the site whose coordinate
64
+ along a polar axis is held, whatever else is refined, to fix the origin
65
+ along it; ``{phase: None}`` drops it. A stage that would refine that
66
+ coordinate on every site of a phase polar along it fails, since the
67
+ origin would then float.
68
+ ``occupancies``
69
+ A list of ``{"phase": name, "sites": [label, ...], "elements": [...]}``:
70
+ the occupancies of every atom of each named element on the named sites
71
+ refined, with the element's content over those sites, the sum of
72
+ multiplicity times occupancy, held at its value when the constraint is
73
+ set up. Each element must have an atom on every site named, if need be
74
+ added at occupancy 0 by an atom edit. With every element's content held,
75
+ so is the total over the sites, and with it the vacancies they hold
76
+ between them; each site's own total may change. Other atoms on the sites
77
+ keep their occupancies.
78
+ Flags carry over from stage to stage, a list growing by the new entries, a
79
+ ``True`` or ``False`` replacing what was there, unless the stage has
80
+ ``"reset": true``, when it starts again from nothing refined. The
81
+ constraints a stage needs replace those of the stage before whenever they
82
+ differ.
83
+
84
+ Structure
85
+ ---------
86
+ A phase entry's ``atoms`` edits the structure read from its CIF within the
87
+ project, the CIF itself untouched: occupancies, positions and isotropic
88
+ displacement parameters changed, and atoms of other elements added on
89
+ existing sites with the positions and displacement parameters of the atoms
90
+ they share them with (:func:`check_atom_edits`). A refine job's
91
+ ``background_start`` gives the background coefficients to start from, such
92
+ as those of an earlier refinement, and ``scale_start`` the histogram scale.
93
+
94
+ Sanity check
95
+ ------------
96
+ After every stage each phase's atoms are checked (:func:`check_sanity`):
97
+ a negative Uiso or diagonal Uij, an occupancy outside 0 to 1 or a site whose
98
+ occupancies add up to more than 1, and a coordinate more than ``max_shift``
99
+ (fractional, :data:`DEFAULT_MAX_SHIFT` by default) from its position in the
100
+ reference, by default the atoms as the job found them before its first
101
+ stage. A job's ``sanity``, ``{"max_shift": ..., "reference": {phase:
102
+ [{"label": ..., "xyz": [...]}, ...]}}``, sets either. The flags are recorded
103
+ with each stage, and a new one rejects the stage (below).
104
+
105
+ Rejected, unsettled and failed stages
106
+ -------------------------------------
107
+ Each stage is recorded with a ``status``, one of :data:`STATUSES`.
108
+
109
+ ``"clean"``
110
+ Settled, and raising no sanity flag the stage before it lacked. Kept.
111
+ ``"unsettled"``
112
+ Reached ``max_passes`` without settling, and raised no new flag. It is
113
+ recorded with its ``largest_remaining_move``, the parameter that moved
114
+ most in its last pass and by how many esds, and is kept when the job's
115
+ ``on_unsettled`` is ``"accept"``, the default; a job that would rather
116
+ not keep one sets ``on_unsettled`` to ``"reject"``.
117
+ ``"flagged"``
118
+ Raised a sanity flag the stage before it did not have (for the first
119
+ stage, one the atoms did not have when the job found them), with the
120
+ job's ``on_flagged`` ``"accept"``. Kept.
121
+ ``"rejected"``
122
+ Raised such a flag with ``on_flagged`` ``"reject"``, the default, or did
123
+ not settle with ``on_unsettled`` ``"reject"``. Rolled back.
124
+ ``"failed"``
125
+ Raised. Rolled back, and the run ends there.
126
+
127
+ A rejected stage is recorded, result and all, with ``rejected_because``; the
128
+ project then goes back to the state the last stage kept left it in,
129
+ parameters, atoms and constraints, and is saved so, and the stage's own
130
+ entries are dropped from the stages, so that what it alone refined is held
131
+ in every stage after it. A failed stage is recorded with its ``error``.
132
+
133
+ Every run ends with a final model, whatever became of its stages: the values
134
+ and the exports are those of the last stage kept, and where no stage was
135
+ kept they are the model the job started from, computed without refining it.
136
+ The result's ``final_from`` names the stage they come from, or "job start".
137
+
138
+ Undetermined parameters
139
+ -----------------------
140
+ Each stage also records, as ``undetermined``, the refined atom parameters
141
+ the data leave undetermined (:func:`find_undetermined`): an occupancy whose
142
+ esd is more than half its allowed range, 0 to 1, and a coordinate or an
143
+ isotropic Uiso whose esd is larger than its shift from where the job found
144
+ it. The result's ``undetermined`` are those of the last stage kept.
145
+
146
+ Le Bail extraction
147
+ ------------------
148
+ ``G2Project.refine`` never resets the Le Bail intensities, so a phase whose
149
+ Le Bail flag is switched on in a new project would have no reflection list to
150
+ extract into. Whenever a stage switches it on for a phase, the driver first
151
+ does what the GSAS-II GUI does when asked to reset the structure factors:
152
+ every intensity is set afresh and ``le_bail_cycles`` Le Bail-only cycles are
153
+ run, refining nothing, before the least squares. The starting intensities
154
+ are random, and the generator is seeded so that a job gives the same result
155
+ each time it is run.
156
+
157
+ GSAS-II extracts the Le Bail intensities afresh within every least squares
158
+ cycle, so the function being minimised shifts under the refinement, which
159
+ then stops at the first cycle that raises chi squared, well short of the
160
+ minimum. A job's ``max_passes`` refines each stage again and again, up to
161
+ that many times, until no parameter moves by more than ``pass_tolerance``
162
+ esds from one pass to the next. The histogram scale is left out of that test
163
+ when every phase is extracted by Le Bail, since the extracted intensities
164
+ take up any change in it and it drifts freely. Each stage of a job that
165
+ gives ``max_passes``, one included, records its ``passes`` and whether it
166
+ settled; without one a stage is refined once and records neither.
167
+
168
+ Sample broadening
169
+ -----------------
170
+ GSAS-II gives every phase an isotropic crystallite size of 1 micron and a
171
+ microstrain of 1000, both Lorentzian, and adds the width they imply to the
172
+ instrumental width of every reflection. Refining instrument parameters on a
173
+ standard with those left in place makes the instrument terms absorb the
174
+ difference, and they then no longer describe the instrument. A refine job's
175
+ ``broadening``, ``{phase name or "*": {"size": microns, "mustrain": value,
176
+ "lgmix": fraction}}``, any of the three, sets isotropic values, held fixed,
177
+ before the first stage.
178
+
179
+ GSAS-II holds a size to 0.001 to 10 microns, and at 10 microns a Lorentzian
180
+ size term still adds 0.088 / cos(theta) centidegrees, the same form as X,
181
+ which X then gives up. ``{"size": 10, "mustrain": 0, "lgmix": 0}`` makes the
182
+ size term Gaussian instead, adding some 0.0014 / cos^2(theta) centidegrees
183
+ squared to a variance of several, so that the sample adds next to nothing.
184
+ """
185
+
186
+ import copy
187
+ import csv
188
+ import io
189
+ import json
190
+ import math
191
+ import random
192
+ import re
193
+ import sys
194
+ from pathlib import Path
195
+
196
+ # Only importers whose format name contains the hint are tried.
197
+ XRDML_HINT = "Panalytical"
198
+ XY_HINT = "comma/tab/semicolon"
199
+ CIF_HINT = "CIF"
200
+
201
+ DEFAULT_BACKGROUND = {"type": "chebyschev-1", "terms": 6}
202
+
203
+ # Profile parameters a stage may refine, besides the zero shift.
204
+ INSTRUMENT_KEYS = ("U", "V", "W", "X", "Y", "Z", "SH/L")
205
+
206
+ # Atom refinement flags: occupancy, position and displacement.
207
+ ATOM_FLAGS = "FXU"
208
+
209
+ STAGE_KEYS = frozenset(
210
+ {
211
+ "name",
212
+ "reset",
213
+ "background",
214
+ "scale",
215
+ "zero",
216
+ "displacement",
217
+ "cell",
218
+ "instrument",
219
+ "atoms",
220
+ "le_bail",
221
+ "phase_fractions",
222
+ "size",
223
+ "mustrain",
224
+ "overall_uiso",
225
+ "atom_flags",
226
+ "uiso_groups",
227
+ "coordinates",
228
+ "origin",
229
+ "occupancies",
230
+ }
231
+ )
232
+ BOOLEAN_FLAGS = ("scale", "zero", "displacement")
233
+
234
+ # Fractional coordinates, and the word that frees every one a site allows.
235
+ AXES = "xyz"
236
+ ALL_COORDINATES = "all"
237
+
238
+ # What the sanity check after each stage looks for, the fractional shift
239
+ # from the reference beyond which a coordinate is flagged, and how far an
240
+ # occupancy or a site's total may pass 0 or 1 by rounding alone.
241
+ SANITY_KINDS = ("negative_uiso", "occupancy", "coordinate_shift")
242
+ DEFAULT_MAX_SHIFT = 0.05
243
+ OCCUPANCY_TOLERANCE = 1.0e-6
244
+ # What becomes of a stage whose sanity check raises a new flag, and of one
245
+ # not settled after max_passes: kept, or rejected and the project taken back
246
+ # to the last stage kept.
247
+ POLICIES = ("accept", "reject")
248
+ DEFAULT_ON_FLAGGED = "reject"
249
+ DEFAULT_ON_UNSETTLED = "accept"
250
+ # A stage's status: refined and kept, clean, with a new flag let through,
251
+ # or not settled; refined and rejected; failed.
252
+ STATUSES = ("clean", "flagged", "unsettled", "rejected", "failed")
253
+ # The range an occupancy may take; a refined occupancy whose esd is more
254
+ # than half of it is undetermined.
255
+ OCCUPANCY_RANGE = (0.0, 1.0)
256
+
257
+ # Atoms closer than this in every fractional coordinate share a site.
258
+ SAME_SITE = 1.0e-4
259
+ PHASE_FLAGS = (
260
+ "cell",
261
+ "le_bail",
262
+ "phase_fractions",
263
+ "size",
264
+ "mustrain",
265
+ "overall_uiso",
266
+ )
267
+
268
+ # What a phase's atom edits may carry: the label of the atom to change or
269
+ # add, for a new atom its element and the label of the atom whose site and
270
+ # displacement parameters it shares, and the occupancy, position and
271
+ # isotropic displacement parameter to give it.
272
+ ATOM_EDIT_KEYS = frozenset({"label", "occupancy", "type", "copy", "xyz", "uiso"})
273
+
274
+ # Le Bail-only cycles run when a stage switches Le Bail extraction on, and
275
+ # the seed of the random starting intensities GSAS-II gives the reflections.
276
+ DEFAULT_LE_BAIL_CYCLES = 10
277
+ LE_BAIL_SEED = 0
278
+
279
+ # A stage refined in passes is settled when no parameter moves by more than
280
+ # this many esds from one pass to the next.
281
+ DEFAULT_PASS_TOLERANCE = 0.1
282
+
283
+ # In a phase flag, every phase.
284
+ ALL_PHASES = "*"
285
+
286
+ # Sample parameters the stages switch on and off, and the displacement
287
+ # parameters of each geometry.
288
+ SAMPLE_KEYS = ("Scale", "Shift", "DisplaceX", "DisplaceY")
289
+ DISPLACEMENT = {
290
+ "Bragg-Brentano": ["Shift"],
291
+ "Debye-Scherrer": ["DisplaceX", "DisplaceY"],
292
+ }
293
+
294
+ # Sample broadening a refine job may hold, and the isotropic crystallite
295
+ # size range in microns that GSAS-II clamps a size to when it saves one.
296
+ BROADENING_KEYS = frozenset({"size", "mustrain", "lgmix"})
297
+ MIN_SIZE = 0.001
298
+ MAX_SIZE = 10.0
299
+
300
+ # Instrument parameters reported after a refinement.
301
+ REPORTED_INSTRUMENT = ("Zero", *INSTRUMENT_KEYS)
302
+
303
+ # Lines of refinement output kept as the report of a failed stage.
304
+ FAILURE_LINES = 20
305
+ FAILURE_WORDS = re.compile(r"error|fail|singular|problem|abort", re.IGNORECASE)
306
+ # What GSAS-II prints when a refinement worked. A refinement of no cycles
307
+ # leaves no covariance to judge it by, so its output is read instead.
308
+ REFINED_MARK = "Refinement successful"
309
+
310
+
311
+ def _plain(value):
312
+ """``value`` with numpy scalars and arrays turned into JSON types."""
313
+ if isinstance(value, dict):
314
+ return {str(key): _plain(item) for key, item in value.items()}
315
+ if isinstance(value, (list, tuple, set, frozenset)):
316
+ return [_plain(item) for item in value]
317
+ if hasattr(value, "tolist"):
318
+ return value.tolist()
319
+ if isinstance(value, float) and not math.isfinite(value):
320
+ return None
321
+ return value
322
+
323
+
324
+ # Stages
325
+
326
+
327
+ def empty_flags():
328
+ """Flags with nothing refined."""
329
+ return {
330
+ "background": None,
331
+ "scale": False,
332
+ "zero": False,
333
+ "displacement": False,
334
+ "instrument": [],
335
+ "cell": [],
336
+ "le_bail": [],
337
+ "phase_fractions": [],
338
+ "size": [],
339
+ "mustrain": [],
340
+ "overall_uiso": [],
341
+ "atoms": {},
342
+ "atom_flags": {},
343
+ "uiso_groups": {},
344
+ "coordinates": {},
345
+ "origin": {},
346
+ "occupancies": [],
347
+ }
348
+
349
+
350
+ def _background(value, name):
351
+ if value is False or value is None:
352
+ return None
353
+ if value is True:
354
+ return dict(DEFAULT_BACKGROUND)
355
+ if isinstance(value, dict):
356
+ unknown = set(value) - {"type", "terms"}
357
+ if unknown:
358
+ raise ValueError(
359
+ f"stage {name!r}: unknown background keys {sorted(unknown)}"
360
+ )
361
+ terms = value.get("terms", DEFAULT_BACKGROUND["terms"])
362
+ if not isinstance(terms, int) or isinstance(terms, bool) or terms < 1:
363
+ raise ValueError(f"stage {name!r}: background terms must be a positive int")
364
+ return {
365
+ "type": str(value.get("type", DEFAULT_BACKGROUND["type"])),
366
+ "terms": terms,
367
+ }
368
+ raise ValueError(f"stage {name!r}: background must be a bool or a dict")
369
+
370
+
371
+ def _phases(value, current, key, name):
372
+ """A phase flag: every phase, some phases by name, or none."""
373
+ if value is True:
374
+ return [ALL_PHASES]
375
+ if value is False or value is None:
376
+ return []
377
+ if isinstance(value, str):
378
+ value = [value]
379
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
380
+ raise ValueError(
381
+ f"stage {name!r}: {key} must be a bool or a list of phase names"
382
+ )
383
+ if ALL_PHASES in current:
384
+ return current
385
+ return current + [phase for phase in value if phase not in current]
386
+
387
+
388
+ def _instrument(value, current, name):
389
+ if isinstance(value, str):
390
+ value = [value]
391
+ if not isinstance(value, list):
392
+ raise TypeError(f"stage {name!r}: instrument must be a list")
393
+ unknown = [key for key in value if key not in INSTRUMENT_KEYS]
394
+ if unknown:
395
+ raise ValueError(
396
+ f"stage {name!r}: unknown instrument parameters {unknown}; "
397
+ f"choose from {', '.join(INSTRUMENT_KEYS)}"
398
+ )
399
+ chosen = set(current) | set(value)
400
+ return [key for key in INSTRUMENT_KEYS if key in chosen]
401
+
402
+
403
+ def _atoms(value, current, name):
404
+ if not isinstance(value, list):
405
+ raise TypeError(f"stage {name!r}: atoms must be a list")
406
+ atoms = dict(current)
407
+ for entry in value:
408
+ if not isinstance(entry, dict) or set(entry) != {"phase", "flags"}:
409
+ raise ValueError(
410
+ f'stage {name!r}: each atoms entry must be {{"phase": ..., "flags": ...}}'
411
+ )
412
+ flags = str(entry["flags"]).upper()
413
+ if any(flag not in ATOM_FLAGS for flag in flags):
414
+ raise ValueError(
415
+ f"stage {name!r}: atom flags {entry['flags']!r} must be drawn from "
416
+ f"{ATOM_FLAGS}"
417
+ )
418
+ chosen = set(atoms.get(entry["phase"], "")) | set(flags)
419
+ atoms[entry["phase"]] = "".join(flag for flag in ATOM_FLAGS if flag in chosen)
420
+ return atoms
421
+
422
+
423
+ def _atom_flags(value, current, name):
424
+ """Flags for named atoms: ``[{"phase", "labels", "flags"}]``, growing
425
+ the flags each atom already has."""
426
+ if not isinstance(value, list):
427
+ raise TypeError(f"stage {name!r}: atom_flags must be a list")
428
+ chosen = copy.deepcopy(current)
429
+ for entry in value:
430
+ if not isinstance(entry, dict) or set(entry) != {"phase", "labels", "flags"}:
431
+ raise ValueError(
432
+ f"stage {name!r}: each atom_flags entry must be "
433
+ '{"phase": ..., "labels": [...], "flags": ...}'
434
+ )
435
+ labels = entry["labels"]
436
+ if (
437
+ not isinstance(labels, list)
438
+ or not labels
439
+ or not all(isinstance(label, str) for label in labels)
440
+ ):
441
+ raise ValueError(
442
+ f"stage {name!r}: atom_flags labels must be a list of labels"
443
+ )
444
+ flags = str(entry["flags"]).upper()
445
+ if not flags or any(flag not in ATOM_FLAGS for flag in flags):
446
+ raise ValueError(
447
+ f"stage {name!r}: atom flags {entry['flags']!r} must be drawn from "
448
+ f"{ATOM_FLAGS}"
449
+ )
450
+ phase = chosen.setdefault(str(entry["phase"]), {})
451
+ for label in labels:
452
+ joined = set(phase.get(label, "")) | set(flags)
453
+ phase[label] = "".join(flag for flag in ATOM_FLAGS if flag in joined)
454
+ return chosen
455
+
456
+
457
+ def _uiso_groups(value, current, name):
458
+ """Uiso groups by phase, ``{phase: [[label, ...], ...]}``, each replacing
459
+ the phase's groups before; an empty list drops them."""
460
+ if not isinstance(value, dict):
461
+ raise TypeError(f"stage {name!r}: uiso_groups must map phases to groups")
462
+ groups = copy.deepcopy(current)
463
+ for phase, phase_groups in value.items():
464
+ if not isinstance(phase_groups, list) or not all(
465
+ isinstance(group, list)
466
+ and group
467
+ and all(isinstance(label, str) for label in group)
468
+ for group in phase_groups
469
+ ):
470
+ raise ValueError(
471
+ f"stage {name!r}: the Uiso groups of {phase!r} must be lists of labels"
472
+ )
473
+ labels = [label for group in phase_groups for label in group]
474
+ if len(labels) != len(set(labels)):
475
+ raise ValueError(
476
+ f"stage {name!r}: an atom of {phase!r} is in more than one Uiso group"
477
+ )
478
+ if phase_groups:
479
+ groups[str(phase)] = [list(group) for group in phase_groups]
480
+ else:
481
+ groups.pop(str(phase), None)
482
+ return groups
483
+
484
+
485
+ def _axes(value, name, label):
486
+ """Coordinates named for a site: letters of xyz in order, or "all"."""
487
+ text = value.lower() if isinstance(value, str) else ""
488
+ if text == ALL_COORDINATES:
489
+ return text
490
+ if not text or any(axis not in AXES for axis in text):
491
+ raise ValueError(
492
+ f"stage {name!r}: the coordinates of {label!r} must be drawn from "
493
+ f'xyz, or be "all"; got {value!r}'
494
+ )
495
+ return "".join(axis for axis in AXES if axis in text)
496
+
497
+
498
+ def _coordinates(value, current, name):
499
+ """Coordinates to refine by site, ``{phase: {label: "xz" or "all"}}``,
500
+ each site's growing by the new ones."""
501
+ if not isinstance(value, dict):
502
+ raise TypeError(f"stage {name!r}: coordinates must map phases to sites")
503
+ chosen = copy.deepcopy(current)
504
+ for phase, sites in value.items():
505
+ if not isinstance(sites, dict) or not sites:
506
+ raise ValueError(
507
+ f"stage {name!r}: the coordinates of {phase!r} must map site "
508
+ "labels to coordinates"
509
+ )
510
+ phase_sites = chosen.setdefault(str(phase), {})
511
+ for label, axes in sites.items():
512
+ axes = _axes(axes, name, label)
513
+ before = phase_sites.get(str(label), "")
514
+ if ALL_COORDINATES in (axes, before):
515
+ phase_sites[str(label)] = ALL_COORDINATES
516
+ else:
517
+ phase_sites[str(label)] = "".join(
518
+ axis for axis in AXES if axis in axes or axis in before
519
+ )
520
+ return chosen
521
+
522
+
523
+ def _origin(value, current, name):
524
+ """The site that holds the origin along a polar axis, by phase,
525
+ ``{phase: {"site": label, "axis": "z"}}``, replacing any before; None
526
+ drops it."""
527
+ if not isinstance(value, dict):
528
+ raise TypeError(f"stage {name!r}: origin must map phases to a site")
529
+ origin = copy.deepcopy(current)
530
+ for phase, entry in value.items():
531
+ if entry is None:
532
+ origin.pop(str(phase), None)
533
+ continue
534
+ if (
535
+ not isinstance(entry, dict)
536
+ or set(entry) != {"site", "axis"}
537
+ or str(entry["axis"]).lower() not in tuple(AXES)
538
+ ):
539
+ raise ValueError(
540
+ f"stage {name!r}: the origin of {phase!r} must be "
541
+ '{"site": label, "axis": "x", "y" or "z"}'
542
+ )
543
+ origin[str(phase)] = {
544
+ "site": str(entry["site"]),
545
+ "axis": str(entry["axis"]).lower(),
546
+ }
547
+ return origin
548
+
549
+
550
+ def _names(value):
551
+ """Whether ``value`` is a non-empty list of distinct non-empty strings."""
552
+ return (
553
+ isinstance(value, list)
554
+ and bool(value)
555
+ and all(isinstance(item, str) and item for item in value)
556
+ and len(set(value)) == len(value)
557
+ )
558
+
559
+
560
+ def _occupancies(value, current, name):
561
+ """Occupancy constraints, ``[{"phase", "sites", "elements"}]``, growing
562
+ by the new ones."""
563
+ if not isinstance(value, list):
564
+ raise TypeError(f"stage {name!r}: occupancies must be a list")
565
+ chosen = copy.deepcopy(current)
566
+ for entry in value:
567
+ if not isinstance(entry, dict) or set(entry) != {"phase", "sites", "elements"}:
568
+ raise ValueError(
569
+ f"stage {name!r}: each occupancies entry must be "
570
+ '{"phase": ..., "sites": [...], "elements": [...]}'
571
+ )
572
+ if not _names(entry["sites"]) or len(entry["sites"]) < 2:
573
+ raise ValueError(
574
+ f"stage {name!r}: an occupancy constraint needs two or more "
575
+ f"distinct sites, got {entry['sites']!r}"
576
+ )
577
+ if not _names(entry["elements"]):
578
+ raise ValueError(
579
+ f"stage {name!r}: an occupancy constraint needs a list of "
580
+ f"distinct elements, got {entry['elements']!r}"
581
+ )
582
+ clean = {
583
+ "phase": str(entry["phase"]),
584
+ "sites": list(entry["sites"]),
585
+ "elements": list(entry["elements"]),
586
+ }
587
+ if clean not in chosen:
588
+ chosen.append(clean)
589
+ return chosen
590
+
591
+
592
+ def accumulate_stages(stages):
593
+ """The flags in force at each stage, as a list of ``{"name", "flags"}``.
594
+
595
+ Raises
596
+ ------
597
+ ValueError
598
+ If there are no stages, or a stage has an unknown key or a flag of the
599
+ wrong kind.
600
+ TypeError
601
+ If a stage is not a dict, or one of its lists or mappings is of the
602
+ wrong type.
603
+ """
604
+ if not isinstance(stages, list) or not stages:
605
+ raise ValueError("stages must be a non-empty list")
606
+ flags = empty_flags()
607
+ accumulated = []
608
+ for index, stage in enumerate(stages, start=1):
609
+ if not isinstance(stage, dict):
610
+ raise TypeError(f"stage {index} is not a dict")
611
+ name = str(stage.get("name") or f"stage {index}")
612
+ unknown = set(stage) - STAGE_KEYS
613
+ if unknown:
614
+ raise ValueError(f"stage {name!r}: unknown keys {sorted(unknown)}")
615
+ flags = empty_flags() if stage.get("reset") else copy.deepcopy(flags)
616
+ if "background" in stage:
617
+ flags["background"] = _background(stage["background"], name)
618
+ for key in BOOLEAN_FLAGS:
619
+ if key in stage:
620
+ if not isinstance(stage[key], bool):
621
+ raise ValueError(f"stage {name!r}: {key} must be true or false")
622
+ flags[key] = stage[key]
623
+ if "instrument" in stage:
624
+ flags["instrument"] = _instrument(
625
+ stage["instrument"], flags["instrument"], name
626
+ )
627
+ for key in PHASE_FLAGS:
628
+ if key in stage:
629
+ flags[key] = _phases(stage[key], flags[key], key, name)
630
+ if "atoms" in stage:
631
+ flags["atoms"] = _atoms(stage["atoms"], flags["atoms"], name)
632
+ if "atom_flags" in stage:
633
+ flags["atom_flags"] = _atom_flags(
634
+ stage["atom_flags"], flags["atom_flags"], name
635
+ )
636
+ if "uiso_groups" in stage:
637
+ flags["uiso_groups"] = _uiso_groups(
638
+ stage["uiso_groups"], flags["uiso_groups"], name
639
+ )
640
+ if "coordinates" in stage:
641
+ flags["coordinates"] = _coordinates(
642
+ stage["coordinates"], flags["coordinates"], name
643
+ )
644
+ if "origin" in stage:
645
+ flags["origin"] = _origin(stage["origin"], flags["origin"], name)
646
+ if "occupancies" in stage:
647
+ flags["occupancies"] = _occupancies(
648
+ stage["occupancies"], flags["occupancies"], name
649
+ )
650
+ both = [
651
+ phase
652
+ for phase in flags["uiso_groups"]
653
+ if _selected(flags["overall_uiso"], phase)
654
+ ]
655
+ if both:
656
+ raise ValueError(
657
+ f"stage {name!r}: {both} cannot have an overall Uiso and Uiso "
658
+ 'groups at once; switch overall_uiso off ("overall_uiso": false)'
659
+ )
660
+ accumulated.append({"name": name, "flags": flags})
661
+ return accumulated
662
+
663
+
664
+ def check_broadening(broadening):
665
+ """Check a ``broadening`` mapping, returning it with float values.
666
+
667
+ Raises
668
+ ------
669
+ ValueError
670
+ If a phase is given anything but some of ``size``, ``mustrain`` and
671
+ ``lgmix``, or a size lies outside the range GSAS-II holds it to, a
672
+ microstrain is negative or ``lgmix`` lies outside 0 to 1.
673
+ TypeError
674
+ If it is not a dict.
675
+ """
676
+ if not isinstance(broadening, dict):
677
+ raise TypeError("broadening must map phase names to size and mustrain")
678
+ checked = {}
679
+ for phase, values in broadening.items():
680
+ if not isinstance(values, dict) or not values or set(values) - BROADENING_KEYS:
681
+ raise ValueError(
682
+ f"broadening of {phase!r} must give size, mustrain and/or lgmix, "
683
+ f"got {values!r}"
684
+ )
685
+ checked[phase] = {key: float(value) for key, value in values.items()}
686
+ size = checked[phase].get("size", MIN_SIZE)
687
+ if not MIN_SIZE <= size <= MAX_SIZE:
688
+ raise ValueError(
689
+ f"broadening of {phase!r}: size must lie between {MIN_SIZE} and "
690
+ f"{MAX_SIZE} microns, the range GSAS-II clamps it to, got {size}"
691
+ )
692
+ if checked[phase].get("mustrain", 0.0) < 0.0:
693
+ raise ValueError(f"broadening of {phase!r}: mustrain cannot be negative")
694
+ if not 0.0 <= checked[phase].get("lgmix", 0.0) <= 1.0:
695
+ raise ValueError(f"broadening of {phase!r}: lgmix must lie between 0 and 1")
696
+ return checked
697
+
698
+
699
+ def check_atom_edits(edits, phase):
700
+ """Check a phase's atom edits, returning them with float occupancies.
701
+
702
+ Each edit is ``{"label": ..., "occupancy": ...}`` to change an atom's
703
+ site fraction, or ``{"label": new, "type": element, "copy": existing,
704
+ "occupancy": ...}`` to add an atom of another element on the site of an
705
+ existing one, with its displacement parameters. Either may also carry
706
+ ``xyz``, three fractional coordinates to move the atom to, and ``uiso``,
707
+ to make it isotropic with that Uiso, as when a structure is set up from
708
+ an earlier refinement's.
709
+
710
+ Raises
711
+ ------
712
+ ValueError
713
+ If an edit has unknown keys, no label, nothing to change, a new atom
714
+ without its element or occupancy, an element for an existing atom, an
715
+ occupancy outside 0 to 1, or an ``xyz`` or ``uiso`` that is not three
716
+ or one finite numbers.
717
+ TypeError
718
+ If the edits are not a list of dicts.
719
+ """
720
+ if not isinstance(edits, list):
721
+ raise TypeError(f"atom edits of {phase!r} must be a list")
722
+ checked = []
723
+ for edit in edits:
724
+ if not isinstance(edit, dict):
725
+ raise TypeError(f"atom edit of {phase!r} is not a dict: {edit!r}")
726
+ unknown = set(edit) - ATOM_EDIT_KEYS
727
+ if unknown or "label" not in edit or set(edit) == {"label"}:
728
+ raise ValueError(
729
+ f"atom edit of {phase!r} needs a label and something to change "
730
+ "(occupancy, xyz, uiso), or a label, type, copy and occupancy; "
731
+ f"got {edit!r}"
732
+ )
733
+ if "copy" in edit and not {"type", "occupancy"} <= set(edit):
734
+ raise ValueError(
735
+ f"new atom {edit['label']!r} of {phase!r} needs a type and an occupancy"
736
+ )
737
+ if "type" in edit and "copy" not in edit:
738
+ raise ValueError(
739
+ f"atom {edit['label']!r} of {phase!r}: a type is given only for a "
740
+ "new atom, with the label of the atom to copy"
741
+ )
742
+ clean = {
743
+ key: str(value)
744
+ for key, value in edit.items()
745
+ if key in ("label", "type", "copy")
746
+ }
747
+ if "occupancy" in edit:
748
+ clean["occupancy"] = float(edit["occupancy"])
749
+ if not 0.0 <= clean["occupancy"] <= 1.0:
750
+ raise ValueError(
751
+ f"atom {clean['label']!r} of {phase!r}: occupancy must lie "
752
+ f"between 0 and 1, got {edit['occupancy']}"
753
+ )
754
+ if "xyz" in edit:
755
+ xyz = edit["xyz"]
756
+ if not isinstance(xyz, (list, tuple)) or len(xyz) != 3:
757
+ raise ValueError(
758
+ f"atom {clean['label']!r} of {phase!r}: xyz must be three "
759
+ f"fractional coordinates, got {xyz!r}"
760
+ )
761
+ clean["xyz"] = [float(value) for value in xyz]
762
+ if "uiso" in edit:
763
+ clean["uiso"] = float(edit["uiso"])
764
+ if not all(
765
+ math.isfinite(value)
766
+ for value in [*clean.get("xyz", []), clean.get("uiso", 0.0)]
767
+ ):
768
+ raise ValueError(
769
+ f"atom {clean['label']!r} of {phase!r}: xyz and uiso must be finite"
770
+ )
771
+ checked.append(clean)
772
+ return checked
773
+
774
+
775
+ def edit_atoms(G2sc, phase, edits):
776
+ """Change occupancies, positions and Uiso and add atoms on existing
777
+ sites, as :func:`check_atom_edits` describes, then let GSAS-II set the
778
+ phase up again for the elements it now holds.
779
+
780
+ Raises
781
+ ------
782
+ ValueError
783
+ If an atom to change or copy is missing, a new label is taken, or a
784
+ new position would give an atom another site symmetry, which GSAS-II
785
+ does not work out again.
786
+ """
787
+ edits = check_atom_edits(edits, phase.name)
788
+ cx, ct, cs, cia = phase.data["General"]["AtomPtrs"]
789
+ atoms = phase.data["Atoms"]
790
+ by_label = {atom[ct - 1]: atom for atom in atoms}
791
+ for edit in edits:
792
+ label = edit["label"]
793
+ if "copy" in edit:
794
+ if label in by_label:
795
+ raise ValueError(f"phase {phase.name!r} already has an atom {label!r}")
796
+ if edit["copy"] not in by_label:
797
+ raise ValueError(
798
+ f"phase {phase.name!r} has no atom {edit['copy']!r} to copy"
799
+ )
800
+ atom = copy.deepcopy(by_label[edit["copy"]])
801
+ atom[ct - 1] = label
802
+ atom[ct] = edit["type"]
803
+ atom[cia + 8] = random.randint(0, sys.maxsize)
804
+ atoms.append(atom)
805
+ by_label[label] = atom
806
+ elif label not in by_label:
807
+ raise ValueError(f"phase {phase.name!r} has no atom {label!r}")
808
+ atom = by_label[label]
809
+ if "occupancy" in edit:
810
+ atom[cx + 3] = edit["occupancy"]
811
+ if "xyz" in edit:
812
+ site = G2sc.G2spc.SytSym(edit["xyz"], phase.data["General"]["SGData"])[0]
813
+ if site.strip() != str(atom[cs]).strip():
814
+ raise ValueError(
815
+ f"atom {label!r} of {phase.name!r} at {edit['xyz']} would sit on "
816
+ f"a site of symmetry {site.strip()}, not {atom[cs]}"
817
+ )
818
+ atom[cx : cx + 3] = edit["xyz"]
819
+ if "uiso" in edit:
820
+ atom[cia] = "I"
821
+ atom[cia + 1] = edit["uiso"]
822
+ G2sc.SetupGeneral(phase.data, None)
823
+
824
+
825
+ def _xyz_esd(phase, index, site_symmetry, esds, xinel):
826
+ """The esds of an atom's x, y and z: of the refined shifts dAx, dAy and
827
+ dAz, and for a coordinate its site ties to another, as GSAS-II's
828
+ ``GetCSxinel`` codes give it, that other's esd times the multiplier; None
829
+ for one that is fixed or not refined."""
830
+ values = [esds.get(f"{phase.id}::dA{axis}:{index}") for axis in "xyz"]
831
+ if xinel is None:
832
+ return values
833
+ codes, multipliers = xinel(site_symmetry)[:2]
834
+ for axis in range(3):
835
+ if values[axis] is not None or not codes[axis]:
836
+ continue
837
+ for other in range(3):
838
+ if codes[other] == codes[axis] and values[other] is not None:
839
+ ratio = abs(multipliers[axis] / multipliers[other])
840
+ values[axis] = values[other] * ratio
841
+ break
842
+ return values
843
+
844
+
845
+ def _atom_table(phase, esds=None, xinel=None):
846
+ """Every atom of the phase: label, element, position, occupancy, site
847
+ and displacement parameters, with the esds of refined coordinates and
848
+ Uiso. ``xinel`` is GSAS-II's ``GetCSxinel``, to give a coordinate its
849
+ site ties to another the same esd."""
850
+ cx, ct, cs, cia = phase.data["General"]["AtomPtrs"]
851
+ table = []
852
+ for index, atom in enumerate(phase.data["Atoms"]):
853
+ isotropic = atom[cia] == "I"
854
+ row = {
855
+ "label": atom[ct - 1],
856
+ "type": atom[ct],
857
+ "xyz": list(atom[cx : cx + 3]),
858
+ "occupancy": atom[cx + 3],
859
+ "site_symmetry": atom[cs],
860
+ "multiplicity": atom[cs + 1],
861
+ "adp": atom[cia],
862
+ "uiso": atom[cia + 1] if isotropic else None,
863
+ "uij": None if isotropic else list(atom[cia + 2 : cia + 8]),
864
+ }
865
+ if esds is not None:
866
+ row["uiso_esd"] = esds.get(f"{phase.id}::AUiso:{index}")
867
+ row["occupancy_esd"] = esds.get(f"{phase.id}::Afrac:{index}")
868
+ row["xyz_esd"] = _xyz_esd(phase, index, atom[cs], esds, xinel)
869
+ table.append(row)
870
+ return table
871
+
872
+
873
+ def _operators(phase):
874
+ """Every symmetry operator of the phase's space group, centring and
875
+ inversion included, as ``{"rotation", "translation"}``."""
876
+ import numpy as np
877
+
878
+ sg = phase.data["General"].get("SGData")
879
+ if not sg:
880
+ return None
881
+ operators = []
882
+ for centring in sg.get("SGCen", [[0, 0, 0]]):
883
+ for rotation, translation in sg["SGOps"]:
884
+ for sign in (1, -1) if sg.get("SGInv") else (1,):
885
+ operators.append(
886
+ {
887
+ "rotation": (sign * np.asarray(rotation)).tolist(),
888
+ "translation": (
889
+ (sign * np.asarray(translation) + np.asarray(centring))
890
+ % 1.0
891
+ ).tolist(),
892
+ }
893
+ )
894
+ return operators
895
+
896
+
897
+ def check_uiso_start(starts):
898
+ """Check ``overall_uiso_start``: a positive Uiso by phase name or "*"."""
899
+ if not isinstance(starts, dict):
900
+ raise TypeError("overall_uiso_start must map phase names to a Uiso")
901
+ checked = {}
902
+ for phase, value in starts.items():
903
+ checked[str(phase)] = float(value)
904
+ if not checked[str(phase)] > 0.0:
905
+ raise ValueError(f"overall_uiso_start of {phase!r} must be positive")
906
+ return checked
907
+
908
+
909
+ def check_background_start(start):
910
+ """Check ``background_start``, ``{"type": ..., "coefficients": [...]}``,
911
+ returning it with float coefficients, or None if not given.
912
+
913
+ Raises
914
+ ------
915
+ ValueError
916
+ If it has other keys or no coefficients.
917
+ """
918
+ if start is None:
919
+ return None
920
+ if not isinstance(start, dict) or set(start) - {"type", "coefficients"}:
921
+ raise ValueError(
922
+ f'background_start must be {{"type": ..., "coefficients": [...]}}, got {start!r}'
923
+ )
924
+ coefficients = [float(value) for value in start.get("coefficients") or []]
925
+ if not coefficients:
926
+ raise ValueError("background_start needs at least one coefficient")
927
+ return {
928
+ "type": str(start.get("type", DEFAULT_BACKGROUND["type"])),
929
+ "coefficients": coefficients,
930
+ }
931
+
932
+
933
+ def check_scale_start(value):
934
+ """Check ``scale_start``, the histogram scale to start from: a positive
935
+ number, or None if not given."""
936
+ if value is None:
937
+ return None
938
+ value = float(value)
939
+ if not (math.isfinite(value) and value > 0.0):
940
+ raise ValueError(f"scale_start must be positive, got {value}")
941
+ return value
942
+
943
+
944
+ def check_sanity_settings(settings):
945
+ """Check a job's ``sanity``, returning ``{"max_shift", "reference"}``
946
+ with the defaults filled in, the reference None if not given.
947
+
948
+ Raises
949
+ ------
950
+ ValueError
951
+ If it has other keys, ``max_shift`` is not positive, or a phase's
952
+ reference is not a list of ``{"label": ..., "xyz": [x, y, z]}``.
953
+ TypeError
954
+ If it, or its reference, is not a dict.
955
+ """
956
+ if settings is None:
957
+ settings = {}
958
+ if not isinstance(settings, dict):
959
+ raise TypeError("sanity must be a dict")
960
+ if set(settings) - {"max_shift", "reference"}:
961
+ raise ValueError(
962
+ f'sanity may give only "max_shift" and "reference", got {sorted(settings)}'
963
+ )
964
+ max_shift = float(settings.get("max_shift", DEFAULT_MAX_SHIFT))
965
+ if not max_shift > 0.0:
966
+ raise ValueError(f"sanity max_shift must be positive, got {max_shift}")
967
+ reference = settings.get("reference")
968
+ if reference is not None:
969
+ if not isinstance(reference, dict):
970
+ raise TypeError("the sanity reference must map phases to atoms")
971
+ checked = {}
972
+ for phase, atoms in reference.items():
973
+ if not isinstance(atoms, list) or not all(
974
+ isinstance(atom, dict)
975
+ and "label" in atom
976
+ and isinstance(atom.get("xyz"), (list, tuple))
977
+ and len(atom["xyz"]) == 3
978
+ for atom in atoms
979
+ ):
980
+ raise ValueError(
981
+ f"the sanity reference of {phase!r} must be a list of "
982
+ '{"label": ..., "xyz": [x, y, z]}'
983
+ )
984
+ checked[str(phase)] = [
985
+ {"label": str(atom["label"]), "xyz": [float(v) for v in atom["xyz"]]}
986
+ for atom in atoms
987
+ ]
988
+ reference = checked
989
+ return {"max_shift": max_shift, "reference": reference}
990
+
991
+
992
+ def set_overall_uiso(project, phase, start):
993
+ """Make every atom of ``phase`` isotropic with Uiso ``start`` and
994
+ constrain all their Uiso to be one, returning the constraints added as
995
+ lists of variable names."""
996
+ cia = phase.data["General"]["AtomPtrs"][3]
997
+ atoms = phase.data["Atoms"]
998
+ for atom in atoms:
999
+ atom[cia] = "I"
1000
+ atom[cia + 1] = float(start)
1001
+ names = [f"{phase.id}::AUiso:{index}" for index in range(len(atoms))]
1002
+ if len(names) < 2:
1003
+ return []
1004
+ project.add_EquivConstr(names)
1005
+ return [names]
1006
+
1007
+
1008
+ def set_uiso_groups(project, phase, groups, start=None):
1009
+ """Constrain the Uiso of every atom on each group's sites to be one,
1010
+ returning the constraints added as lists of variable names.
1011
+
1012
+ Each group lists sites by the label of any atom on them, and takes in
1013
+ every atom that shares one. The atoms keep their Uiso; one that is
1014
+ anisotropic is made isotropic with ``start``.
1015
+
1016
+ Raises
1017
+ ------
1018
+ ValueError
1019
+ If a label is not an atom of the phase, an atom falls in two groups,
1020
+ or an anisotropic atom is grouped with no ``start`` given.
1021
+ """
1022
+ _, ct, _, cia = phase.data["General"]["AtomPtrs"]
1023
+ atoms = phase.data["Atoms"]
1024
+ index = {atom[ct - 1]: position for position, atom in enumerate(atoms)}
1025
+ members = site_members(phase)
1026
+ added = []
1027
+ seen = set()
1028
+ for group in groups:
1029
+ missing = [label for label in group if label not in index]
1030
+ if missing:
1031
+ raise ValueError(f"phase {phase.name!r} has no atoms {missing}")
1032
+ group = list(dict.fromkeys(m for label in group for m in members[label]))
1033
+ twice = seen & set(group)
1034
+ if twice:
1035
+ raise ValueError(
1036
+ f"atoms {sorted(twice)} of {phase.name!r} are in more than one "
1037
+ "Uiso group"
1038
+ )
1039
+ seen |= set(group)
1040
+ for label in group:
1041
+ atom = atoms[index[label]]
1042
+ if atom[cia] != "I":
1043
+ if start is None:
1044
+ raise ValueError(
1045
+ f"atom {label!r} of {phase.name!r} is anisotropic; give "
1046
+ "overall_uiso_start for its group's Uiso"
1047
+ )
1048
+ atom[cia] = "I"
1049
+ atom[cia + 1] = float(start)
1050
+ if len(group) > 1:
1051
+ names = [f"{phase.id}::AUiso:{index[label]}" for label in group]
1052
+ project.add_EquivConstr(names)
1053
+ added.append(names)
1054
+ return added
1055
+
1056
+
1057
+ def _set_uiso_scheme(project, phase, flags, uiso_start, schemes):
1058
+ """Put the Uiso constraints ``flags`` ask of ``phase`` in place, replacing
1059
+ those of another scheme set up before; a stage that asks for none leaves
1060
+ them as they are. ``schemes`` holds each phase's scheme and constraints."""
1061
+ if _selected(flags["overall_uiso"], phase.name):
1062
+ wanted = "overall"
1063
+ elif phase.name in flags["uiso_groups"]:
1064
+ wanted = tuple(tuple(group) for group in flags["uiso_groups"][phase.name])
1065
+ else:
1066
+ return
1067
+ current = schemes.get(phase.name)
1068
+ if current is not None and current[0] == wanted:
1069
+ return
1070
+ start = uiso_start.get(phase.name, uiso_start.get(ALL_PHASES))
1071
+ if current is not None:
1072
+ remove_constraints(project, current[1])
1073
+ if wanted == "overall":
1074
+ if start is None:
1075
+ raise ValueError(
1076
+ f"overall_uiso_start gives no Uiso for phase {phase.name!r}"
1077
+ )
1078
+ added = set_overall_uiso(project, phase, start)
1079
+ else:
1080
+ added = set_uiso_groups(project, phase, wanted, start)
1081
+ schemes[phase.name] = (wanted, added)
1082
+
1083
+
1084
+ def remove_constraints(project, added):
1085
+ """Remove the phase constraints whose variables are those of each list
1086
+ of names in ``added``."""
1087
+ wanted = [set(names) for names in added]
1088
+ constraints = project.data["Constraints"]["data"]
1089
+ constraints["Phase"] = [
1090
+ constraint
1091
+ for constraint in constraints.get("Phase", [])
1092
+ if {str(term[1]) for term in constraint[:-3]} not in wanted
1093
+ ]
1094
+
1095
+
1096
+ def atom_flag_strings(phase, flags):
1097
+ """The refinement flags of each atom of ``phase``, by label: those given
1098
+ every atom; U for an overall Uiso or an atom on a site of a Uiso group;
1099
+ X for an atom on a site whose coordinates are refined; F for an atom in
1100
+ an occupancy constraint; and those given the atom by name.
1101
+
1102
+ Raises
1103
+ ------
1104
+ ValueError
1105
+ If flags, a Uiso group or a site name an atom the phase does not
1106
+ have, or an occupancy constraint cannot be set up.
1107
+ """
1108
+ name = phase.name
1109
+ ct = phase.data["General"]["AtomPtrs"][1]
1110
+ labels = [atom[ct - 1] for atom in phase.data["Atoms"]]
1111
+ members = site_members(phase)
1112
+ chosen = {label: set(flags["atoms"].get(name, "")) for label in labels}
1113
+ named = {
1114
+ label: set(value) for label, value in flags["atom_flags"].get(name, {}).items()
1115
+ }
1116
+ missing = set(named) - set(chosen)
1117
+ by_site = [(group, "U") for group in flags["uiso_groups"].get(name, [])]
1118
+ by_site.append((list(flags["coordinates"].get(name, {})), "X"))
1119
+ for group, flag in by_site:
1120
+ for label in group:
1121
+ if label not in members:
1122
+ missing.add(label)
1123
+ continue
1124
+ for member in members[label]:
1125
+ named.setdefault(member, set()).add(flag)
1126
+ if missing:
1127
+ raise ValueError(f"phase {name!r} has no atoms {sorted(missing)}")
1128
+ entries = [entry for entry in flags["occupancies"] if entry["phase"] == name]
1129
+ for constraint in occupancy_constraints(phase, entries):
1130
+ for label in constraint["atoms"]:
1131
+ named.setdefault(label, set()).add("F")
1132
+ for label in labels:
1133
+ if _selected(flags["overall_uiso"], name):
1134
+ chosen[label].add("U")
1135
+ chosen[label] |= named.get(label, set())
1136
+ return {
1137
+ label: "".join(flag for flag in ATOM_FLAGS if flag in value)
1138
+ for label, value in chosen.items()
1139
+ }
1140
+
1141
+
1142
+ def _wrapped(value):
1143
+ """A fractional difference brought into -1/2 to 1/2."""
1144
+ return value - round(value)
1145
+
1146
+
1147
+ def _cluster(positions):
1148
+ """The indices of ``positions`` grouped by site: each within SAME_SITE
1149
+ of the first of its group in every fractional coordinate, whole cell
1150
+ translations aside, the groups in the order of their first members."""
1151
+ groups = []
1152
+ for index, xyz in enumerate(positions):
1153
+ for group in groups:
1154
+ first = positions[group[0]]
1155
+ if all(abs(_wrapped(a - b)) < SAME_SITE for a, b in zip(xyz, first)):
1156
+ group.append(index)
1157
+ break
1158
+ else:
1159
+ groups.append([index])
1160
+ return groups
1161
+
1162
+
1163
+ def _sites(phase):
1164
+ """Every site of the phase, as a list of (index, label, site symmetry)
1165
+ of the atoms on it, sites in the order of their first atoms."""
1166
+ cx, ct, cs, _ = phase.data["General"]["AtomPtrs"]
1167
+ atoms = phase.data["Atoms"]
1168
+ groups = _cluster([atom[cx : cx + 3] for atom in atoms])
1169
+ return [[(i, atoms[i][ct - 1], atoms[i][cs]) for i in group] for group in groups]
1170
+
1171
+
1172
+ def site_members(phase):
1173
+ """The labels of the atoms on each atom's site, by label."""
1174
+ members = {}
1175
+ for site in _sites(phase):
1176
+ labels = [label for _, label, _ in site]
1177
+ for label in labels:
1178
+ members[label] = labels
1179
+ return members
1180
+
1181
+
1182
+ def _element(atom_type):
1183
+ """The element of a GSAS-II atom type, such as Sr of "Sr+2"."""
1184
+ match = re.match(r"[A-Z][a-z]?", str(atom_type))
1185
+ return match.group(0) if match else str(atom_type)
1186
+
1187
+
1188
+ def polar_axes(phase):
1189
+ """The axes, as indices, along which the phase's space group lets the
1190
+ origin float: those that every symmetry operator leaves alone, so that
1191
+ moving every atom along one changes nothing; none for a phase without
1192
+ its symmetry operators."""
1193
+ operators = _operators(phase)
1194
+ if not operators:
1195
+ return []
1196
+ return [
1197
+ axis
1198
+ for axis in range(3)
1199
+ if all(
1200
+ [row[axis] for row in op["rotation"]] == [int(j == axis) for j in range(3)]
1201
+ for op in operators
1202
+ )
1203
+ ]
1204
+
1205
+
1206
+ def coordinate_constraints(xinel, phase, flags, refined):
1207
+ """The coordinate constraints ``flags`` ask of ``phase``.
1208
+
1209
+ ``refined`` holds the labels of the atoms whose X flag is set, and
1210
+ ``xinel`` is GSAS-II's ``GetCSxinel``, whose codes give the coordinates a
1211
+ site leaves free and those it ties together. On each site holding such
1212
+ atoms every coordinate the site leaves free is refined if one of them
1213
+ has X from ``atoms`` or ``atom_flags``, and otherwise those the site's
1214
+ ``coordinates`` name; the origin's coordinate is never refined on its
1215
+ site. A coordinate the site ties to another counts as that other.
1216
+
1217
+ Returns the equivalences that make the atoms sharing a site move
1218
+ together in each refined coordinate, the holds on each free coordinate
1219
+ of their atoms that is not refined, both as lists of variable names, and
1220
+ the site's record by the label of its first atom: ``{"site": labels
1221
+ joined by "/", "refined": coordinates, "held": coordinates, "origin":
1222
+ whether it holds the origin}``, naming each coordinate once, as the
1223
+ first of those the site ties together.
1224
+
1225
+ Every atom on a site stays refined, since GSAS-II ignores an
1226
+ equivalence, and holds its parameters, unless all of them are varied.
1227
+ Where GSAS-II's own symmetry constraints make a parameter independent in
1228
+ one equivalence and dependent in another, it recasts them as general
1229
+ constraints, with a new variable of its own for the site's shift, and
1230
+ notes that it has done so.
1231
+
1232
+ Raises
1233
+ ------
1234
+ ValueError
1235
+ If only some of the atoms on a site are refined, a label is not an
1236
+ atom of the phase, a site's coordinates or the origin name one the
1237
+ site fixes, a site's coordinates name the origin's, or every site of
1238
+ a phase polar along an axis would be refined along it.
1239
+ RuntimeError
1240
+ If ``xinel`` is None.
1241
+ """
1242
+ if xinel is None:
1243
+ raise RuntimeError("coordinates need GSAS-II's GetCSxinel")
1244
+ name = phase.name
1245
+ general = set()
1246
+ whole = flags["atoms"].get(name, "")
1247
+ for label, value in flags["atom_flags"].get(name, {}).items():
1248
+ if "X" in value:
1249
+ general.add(label)
1250
+ listed = flags["coordinates"].get(name, {})
1251
+ origin = flags["origin"].get(name)
1252
+ sites = _sites(phase)
1253
+ site_of = {
1254
+ label: position for position, site in enumerate(sites) for _, label, _ in site
1255
+ }
1256
+ missing = [label for label in listed if label not in site_of]
1257
+ if origin is not None and origin["site"] not in site_of:
1258
+ missing.append(origin["site"])
1259
+ if missing:
1260
+ raise ValueError(f"phase {name!r} has no atoms {sorted(missing)}")
1261
+ requests = {}
1262
+ for label, axes in listed.items():
1263
+ request = requests.setdefault(site_of[label], {"all": False, "axes": ""})
1264
+ if axes == ALL_COORDINATES:
1265
+ request["all"] = True
1266
+ else:
1267
+ request["axes"] += axes
1268
+
1269
+ equivalences, holds, records, refined_axes = [], [], {}, {}
1270
+ for position, site in enumerate(sites):
1271
+ labels = [label for _, label, _ in site]
1272
+ chosen = [label for label in labels if label in refined]
1273
+ if not chosen:
1274
+ continue
1275
+ if len(chosen) != len(labels):
1276
+ raise ValueError(
1277
+ f"atoms {sorted(labels)} of {name!r} share a site; refine the "
1278
+ f"coordinates of all of them, not only {sorted(chosen)}"
1279
+ )
1280
+ codes = xinel(site[0][2])[0]
1281
+ independent = [
1282
+ axis for axis in range(3) if codes[axis] and codes[axis] not in codes[:axis]
1283
+ ]
1284
+
1285
+ def first_of(axis, codes=codes, labels=labels, symmetry=site[0][2]):
1286
+ if not codes[axis]:
1287
+ raise ValueError(
1288
+ f"the site of {labels[0]!r} in {name!r} ({symmetry}) fixes "
1289
+ f"{AXES[axis]}; it cannot be refined or hold the origin"
1290
+ )
1291
+ return codes.index(codes[axis])
1292
+
1293
+ request = requests.get(position, {"all": False, "axes": ""})
1294
+ if "X" in whole or general & set(labels) or request["all"]:
1295
+ wanted = set(independent)
1296
+ else:
1297
+ wanted = set()
1298
+ explicit = {first_of(AXES.index(letter)) for letter in request["axes"]}
1299
+ wanted |= explicit
1300
+ is_origin = origin is not None and origin["site"] in labels
1301
+ if is_origin:
1302
+ held = first_of(AXES.index(origin["axis"]))
1303
+ if held in explicit:
1304
+ raise ValueError(
1305
+ f"{origin['axis']} of {origin['site']!r} in {name!r} holds the "
1306
+ "origin and cannot be refined"
1307
+ )
1308
+ wanted.discard(held)
1309
+ for axis in sorted(wanted):
1310
+ if len(site) > 1:
1311
+ equivalences.append(
1312
+ [f"{phase.id}::dA{AXES[axis]}:{index}" for index, _, _ in site]
1313
+ )
1314
+ for axis in independent:
1315
+ if axis not in wanted:
1316
+ holds += [
1317
+ [f"{phase.id}::dA{AXES[axis]}:{index}"] for index, _, _ in site
1318
+ ]
1319
+ refined_axes[position] = {
1320
+ axis
1321
+ for axis in range(3)
1322
+ if codes[axis] and codes.index(codes[axis]) in wanted
1323
+ }
1324
+ records[labels[0]] = {
1325
+ "site": "/".join(labels),
1326
+ "refined": "".join(AXES[axis] for axis in sorted(wanted)),
1327
+ "held": "".join(AXES[axis] for axis in independent if axis not in wanted),
1328
+ "origin": is_origin,
1329
+ }
1330
+
1331
+ for axis in polar_axes(phase):
1332
+ if all(
1333
+ position in refined_axes and axis in refined_axes[position]
1334
+ for position in range(len(sites))
1335
+ ):
1336
+ raise ValueError(
1337
+ f"the space group of {name!r} is polar along {AXES[axis]}, and "
1338
+ f"refining {AXES[axis]} on every site leaves the origin free; "
1339
+ f"hold one site's {AXES[axis]} with a stage's origin"
1340
+ )
1341
+ return equivalences, holds, records
1342
+
1343
+
1344
+ def occupancy_constraints(phase, entries):
1345
+ """The constraint equations the occupancy constraints ``entries`` of
1346
+ ``phase`` stand for, one for each element of each, as ``{"element",
1347
+ "sites", "atoms", "names", "multipliers", "total"}``: the atoms of the
1348
+ element on the named sites, their occupancy variables and
1349
+ multiplicities, and the element's content over the sites now, the sum
1350
+ of multiplicity times occupancy, which the constraint holds.
1351
+
1352
+ Raises
1353
+ ------
1354
+ ValueError
1355
+ If a site is not an atom's label, two labels are on one site, an
1356
+ element has no atom on a site named, or an atom falls in two
1357
+ constraints.
1358
+ """
1359
+ cx, ct, cs, _ = phase.data["General"]["AtomPtrs"]
1360
+ atoms = phase.data["Atoms"]
1361
+ index = {atom[ct - 1]: i for i, atom in enumerate(atoms)}
1362
+ sites = _sites(phase)
1363
+ site_of = {i: position for position, site in enumerate(sites) for i, _, _ in site}
1364
+ constraints, used = [], set()
1365
+ for entry in entries:
1366
+ missing = [label for label in entry["sites"] if label not in index]
1367
+ if missing:
1368
+ raise ValueError(f"phase {phase.name!r} has no atoms {missing}")
1369
+ positions = [site_of[index[label]] for label in entry["sites"]]
1370
+ if len(set(positions)) != len(positions):
1371
+ raise ValueError(
1372
+ f"the occupancy sites {entry['sites']} of {phase.name!r} name "
1373
+ "one site twice"
1374
+ )
1375
+ for element in entry["elements"]:
1376
+ chosen = []
1377
+ for label, position in zip(entry["sites"], positions):
1378
+ on_site = [
1379
+ i
1380
+ for i, _, _ in sites[position]
1381
+ if _element(atoms[i][ct]) == element
1382
+ ]
1383
+ if not on_site:
1384
+ raise ValueError(
1385
+ f"phase {phase.name!r} has no {element} on the site of "
1386
+ f"{label!r}; add one with an atom edit, at occupancy 0"
1387
+ )
1388
+ chosen += on_site
1389
+ taken = used & set(chosen)
1390
+ if taken:
1391
+ raise ValueError(
1392
+ f"atoms {sorted(atoms[i][ct - 1] for i in taken)} of "
1393
+ f"{phase.name!r} are in two occupancy constraints"
1394
+ )
1395
+ used |= set(chosen)
1396
+ multipliers = [float(atoms[i][cs + 1]) for i in chosen]
1397
+ constraints.append(
1398
+ {
1399
+ "element": element,
1400
+ "sites": list(entry["sites"]),
1401
+ "atoms": [atoms[i][ct - 1] for i in chosen],
1402
+ "names": [f"{phase.id}::Afrac:{i}" for i in chosen],
1403
+ "multipliers": multipliers,
1404
+ "total": sum(
1405
+ m * float(atoms[i][cx + 3]) for m, i in zip(multipliers, chosen)
1406
+ ),
1407
+ }
1408
+ )
1409
+ return constraints
1410
+
1411
+
1412
+ def _replace_constraints(project, managed, key, wanted, add, note):
1413
+ """Put in place the constraints ``wanted`` stands for, under ``key`` in
1414
+ ``managed``, replacing those set up there before unless they are the
1415
+ same, and return the note kept with them. ``add`` adds them, returning
1416
+ the lists of names of the constraints it added; ``note`` is kept with
1417
+ them when they are new."""
1418
+ current = managed.get(key)
1419
+ if current is not None and current[0] == wanted:
1420
+ return current[2]
1421
+ if current is not None:
1422
+ remove_constraints(project, current[1])
1423
+ managed[key] = (wanted, add() if wanted else [], note)
1424
+ return note
1425
+
1426
+
1427
+ def set_structure_constraints(project, phase, flags, xinel, managed):
1428
+ """Put the coordinate and occupancy constraints ``flags`` ask of
1429
+ ``phase`` in place, replacing those of the stage before where they
1430
+ differ, and return what they do: ``{"coordinates": by site,
1431
+ "occupancy_constraints": [...]}``, each only if there are any. See
1432
+ :func:`coordinate_constraints` and :func:`occupancy_constraints`."""
1433
+ notes = {}
1434
+ refined = {
1435
+ label
1436
+ for label, value in atom_flag_strings(phase, flags).items()
1437
+ if "X" in value
1438
+ }
1439
+ equivalences, holds, records = (
1440
+ coordinate_constraints(xinel, phase, flags, refined)
1441
+ if refined
1442
+ else ([], [], {})
1443
+ )
1444
+
1445
+ def add_coordinates():
1446
+ for names in equivalences:
1447
+ project.add_EquivConstr(names)
1448
+ for names in holds:
1449
+ project.add_HoldConstr(names)
1450
+ return [*equivalences, *holds]
1451
+
1452
+ _replace_constraints(
1453
+ project,
1454
+ managed,
1455
+ (phase.name, "coordinates"),
1456
+ (tuple(map(tuple, equivalences)), tuple(map(tuple, holds))),
1457
+ add_coordinates,
1458
+ None,
1459
+ )
1460
+ if records:
1461
+ notes["coordinates"] = records
1462
+
1463
+ entries = [entry for entry in flags["occupancies"] if entry["phase"] == phase.name]
1464
+ constraints = occupancy_constraints(phase, entries)
1465
+
1466
+ def add_occupancies():
1467
+ for constraint in constraints:
1468
+ project.add_EqnConstr(
1469
+ constraint["total"], constraint["names"], constraint["multipliers"]
1470
+ )
1471
+ return [constraint["names"] for constraint in constraints]
1472
+
1473
+ # The totals are left out of what identifies the constraints: once set
1474
+ # up, they hold them, and the occupancies they are worked out from move.
1475
+ note = _replace_constraints(
1476
+ project,
1477
+ managed,
1478
+ (phase.name, "occupancies"),
1479
+ tuple(
1480
+ (c["element"], tuple(c["names"]), tuple(c["multipliers"]))
1481
+ for c in constraints
1482
+ ),
1483
+ add_occupancies,
1484
+ [{k: v for k, v in c.items() if k != "names"} for c in constraints],
1485
+ )
1486
+ if note:
1487
+ notes["occupancy_constraints"] = note
1488
+ return notes
1489
+
1490
+
1491
+ def check_sanity(atoms, reference=None, max_shift=DEFAULT_MAX_SHIFT):
1492
+ """What looks wrong with a phase's atoms, given as the driver's atom
1493
+ table gives them, as a list of ``{"kind", "atoms", "value",
1494
+ "message"}``, ``kind`` one of :data:`SANITY_KINDS`.
1495
+
1496
+ Flagged are a negative Uiso, or diagonal Uij of an anisotropic atom; an
1497
+ occupancy outside 0 to 1, and a site whose atoms' occupancies add up to
1498
+ more than 1, beyond OCCUPANCY_TOLERANCE; and, with ``reference``, a list
1499
+ of ``{"label", "xyz"}``, a site whose coordinate has moved more than
1500
+ ``max_shift`` (fractional, whole cells aside) from the position of its
1501
+ first atom there.
1502
+ """
1503
+ flags = []
1504
+
1505
+ def flag(kind, labels, value, message):
1506
+ flags.append(
1507
+ {"kind": kind, "atoms": labels, "value": value, "message": message}
1508
+ )
1509
+
1510
+ for atom in atoms:
1511
+ label = atom["label"]
1512
+ if atom.get("adp", "I") == "I":
1513
+ if atom.get("uiso") is not None and atom["uiso"] < 0.0:
1514
+ flag(
1515
+ "negative_uiso",
1516
+ [label],
1517
+ atom["uiso"],
1518
+ f"{label} Uiso {atom['uiso']:.4f}",
1519
+ )
1520
+ else:
1521
+ for key, value in zip(("U11", "U22", "U33"), atom["uij"][:3]):
1522
+ if value < 0.0:
1523
+ flag("negative_uiso", [label], value, f"{label} {key} {value:.4f}")
1524
+ occupancy = atom["occupancy"]
1525
+ if not -OCCUPANCY_TOLERANCE <= occupancy <= 1.0 + OCCUPANCY_TOLERANCE:
1526
+ flag("occupancy", [label], occupancy, f"{label} occupancy {occupancy:.4f}")
1527
+ start = {entry["label"]: entry["xyz"] for entry in reference or []}
1528
+ for group in _cluster([atom["xyz"] for atom in atoms]):
1529
+ labels = [atoms[i]["label"] for i in group]
1530
+ site = "/".join(labels)
1531
+ if len(group) > 1:
1532
+ total = sum(atoms[i]["occupancy"] for i in group)
1533
+ if total > 1.0 + OCCUPANCY_TOLERANCE:
1534
+ flag("occupancy", labels, total, f"site {site} occupancy {total:.4f}")
1535
+ first = next((i for i in group if atoms[i]["label"] in start), None)
1536
+ if first is None:
1537
+ continue
1538
+ then = start[atoms[first]["label"]]
1539
+ for axis, (now, before) in enumerate(zip(atoms[first]["xyz"], then)):
1540
+ shift = _wrapped(now - before)
1541
+ if abs(shift) > max_shift:
1542
+ flag(
1543
+ "coordinate_shift",
1544
+ labels,
1545
+ shift,
1546
+ f"{site} {AXES[axis]} moved {shift:+.4f}",
1547
+ )
1548
+ return flags
1549
+
1550
+
1551
+ def find_undetermined(atoms, start=None):
1552
+ """The refined atom parameters of a phase that the data leave
1553
+ undetermined, as a list of ``{"atom", "parameter", "value", "esd",
1554
+ "message"}``, ``parameter`` one of occupancy, x, y, z and uiso.
1555
+
1556
+ ``atoms`` is the driver's atom table with esds, a parameter with no esd
1557
+ being one not refined, and ``start`` the atoms where the job found them.
1558
+ Undetermined are an occupancy whose esd is more than half of
1559
+ :data:`OCCUPANCY_RANGE`, and a coordinate or an isotropic Uiso whose esd
1560
+ is larger than its shift from ``start`` (whole cells aside); an atom not
1561
+ in ``start`` is judged on its occupancy alone.
1562
+ """
1563
+ before = {entry["label"]: entry for entry in start or []}
1564
+ half = 0.5 * (OCCUPANCY_RANGE[1] - OCCUPANCY_RANGE[0])
1565
+ found = []
1566
+
1567
+ def add(label, parameter, value, esd, message):
1568
+ found.append(
1569
+ {
1570
+ "atom": label,
1571
+ "parameter": parameter,
1572
+ "value": value,
1573
+ "esd": esd,
1574
+ "message": message,
1575
+ }
1576
+ )
1577
+
1578
+ for atom in atoms:
1579
+ label = atom["label"]
1580
+ esd = atom.get("occupancy_esd")
1581
+ if esd is not None and esd > half:
1582
+ add(
1583
+ label,
1584
+ "occupancy",
1585
+ atom["occupancy"],
1586
+ esd,
1587
+ f"{label} occupancy {atom['occupancy']:.3f}, esd {esd:.3f} more "
1588
+ f"than half its range",
1589
+ )
1590
+ then = before.get(label)
1591
+ if then is None:
1592
+ continue
1593
+ esds = atom.get("xyz_esd") or [None, None, None]
1594
+ for axis, (value, esd, was) in enumerate(zip(atom["xyz"], esds, then["xyz"])):
1595
+ shift = abs(_wrapped(value - was))
1596
+ if esd is not None and esd > shift:
1597
+ add(
1598
+ label,
1599
+ AXES[axis],
1600
+ value,
1601
+ esd,
1602
+ f"{label} {AXES[axis]} {value:.5f}, esd {esd:.5f} more than its "
1603
+ f"shift {shift:.5f}",
1604
+ )
1605
+ esd = atom.get("uiso_esd")
1606
+ if (
1607
+ esd is not None
1608
+ and atom.get("uiso") is not None
1609
+ and then.get("uiso") is not None
1610
+ ):
1611
+ shift = abs(atom["uiso"] - then["uiso"])
1612
+ if esd > shift:
1613
+ add(
1614
+ label,
1615
+ "uiso",
1616
+ atom["uiso"],
1617
+ esd,
1618
+ f"{label} Uiso {atom['uiso']:.5f}, esd {esd:.5f} more than its "
1619
+ f"shift {shift:.5f}",
1620
+ )
1621
+ return found
1622
+
1623
+
1624
+ def check_policies(job):
1625
+ """A refine job's ``on_flagged`` and ``on_unsettled``, each "accept" or
1626
+ "reject", their defaults filled in.
1627
+
1628
+ Raises
1629
+ ------
1630
+ ValueError
1631
+ If either is anything else.
1632
+ """
1633
+ found = []
1634
+ for key, default in (
1635
+ ("on_flagged", DEFAULT_ON_FLAGGED),
1636
+ ("on_unsettled", DEFAULT_ON_UNSETTLED),
1637
+ ):
1638
+ value = job.get(key) or default
1639
+ if value not in POLICIES:
1640
+ raise ValueError(
1641
+ f"{key} must be one of {', '.join(POLICIES)}, not {value!r}"
1642
+ )
1643
+ found.append(value)
1644
+ return tuple(found)
1645
+
1646
+
1647
+ def set_broadening(project, histogram, broadening):
1648
+ """Set isotropic crystallite size and microstrain, held fixed.
1649
+
1650
+ ``broadening`` maps a phase name, or ``"*"`` for every phase, to any of
1651
+ ``size`` in microns, ``mustrain``, and ``lgmix``, the Lorentzian fraction
1652
+ of both. The values are written into the phase's data for ``histogram``
1653
+ directly, since ``set_HAP_refinements`` can set a size but not a
1654
+ microstrain.
1655
+ """
1656
+ broadening = check_broadening(broadening)
1657
+ phases = project.phases()
1658
+ missing = sorted(set(broadening) - {phase.name for phase in phases} - {ALL_PHASES})
1659
+ if missing:
1660
+ raise ValueError(f"no phases named {missing} in the project")
1661
+ for phase in phases:
1662
+ values = broadening.get(phase.name, broadening.get(ALL_PHASES))
1663
+ if not values:
1664
+ continue
1665
+ hap = phase.data["Histograms"][histogram.name]
1666
+ for key, entry in (("size", hap["Size"]), ("mustrain", hap["Mustrain"])):
1667
+ if key in values:
1668
+ entry[0] = "isotropic"
1669
+ entry[1][0] = values[key]
1670
+ entry[2][0] = False
1671
+ if "lgmix" in values:
1672
+ entry[1][2] = values["lgmix"]
1673
+ entry[2][2] = False
1674
+
1675
+
1676
+ def _broadening(phase, histogram, esds):
1677
+ """Size and microstrain, with the esd of an isotropic value refined."""
1678
+ hap = phase.data["Histograms"][histogram.name]
1679
+ pfx = f"{phase.id}:{histogram.id}:"
1680
+ broadening = {}
1681
+ for key, name in (("size", "Size"), ("mustrain", "Mustrain")):
1682
+ entry = hap[name]
1683
+ broadening[key] = {
1684
+ "type": entry[0],
1685
+ "value": entry[1][0],
1686
+ "esd": esds.get(f"{pfx}{name};i"),
1687
+ "lorentzian_fraction": entry[1][2],
1688
+ }
1689
+ return broadening
1690
+
1691
+
1692
+ def _selected(phases, name):
1693
+ return ALL_PHASES in phases or name in phases
1694
+
1695
+
1696
+ def apply_flags(project, histogram, flags):
1697
+ """Switch off every flag the stages control, then switch on ``flags``.
1698
+
1699
+ Each ``set_refinements`` and ``clear_refinements`` call carries a single
1700
+ key: GSASIIscriptable returns from either as soon as it has handled a
1701
+ ``Background`` given as a bool, dropping any keys after it.
1702
+ """
1703
+ phases = project.phases()
1704
+ names = {phase.name for phase in phases}
1705
+ named = [
1706
+ *flags["cell"],
1707
+ *flags["le_bail"],
1708
+ *flags["phase_fractions"],
1709
+ *flags["size"],
1710
+ *flags["mustrain"],
1711
+ *flags["overall_uiso"],
1712
+ *flags["atoms"],
1713
+ *flags["atom_flags"],
1714
+ *flags["uiso_groups"],
1715
+ *flags["coordinates"],
1716
+ *flags["origin"],
1717
+ *(entry["phase"] for entry in flags["occupancies"]),
1718
+ ]
1719
+ missing = sorted({phase for phase in named if phase != ALL_PHASES} - names)
1720
+ if missing:
1721
+ raise ValueError(
1722
+ f"no phases named {missing} in the project; have {sorted(names)}"
1723
+ )
1724
+
1725
+ instrument = histogram.InstrumentParameters
1726
+ sample = histogram.SampleParameters
1727
+ histogram.clear_refinements({"Background": True})
1728
+ histogram.clear_refinements(
1729
+ {"Instrument Parameters": [k for k in REPORTED_INSTRUMENT if k in instrument]}
1730
+ )
1731
+ histogram.clear_refinements(
1732
+ {"Sample Parameters": [k for k in SAMPLE_KEYS if k in sample]}
1733
+ )
1734
+ for phase in phases:
1735
+ phase.clear_refinements({"Cell": True})
1736
+ phase.clear_refinements({"LeBail": True})
1737
+ phase.clear_refinements({"Atoms": [atom.label for atom in phase.atoms()]})
1738
+ phase.clear_HAP_refinements({"Scale": True}, [histogram])
1739
+ phase.clear_HAP_refinements({"Size": True}, [histogram])
1740
+ phase.clear_HAP_refinements({"Mustrain": True}, [histogram])
1741
+
1742
+ background = flags["background"]
1743
+ if background:
1744
+ histogram.set_refinements(
1745
+ {
1746
+ "Background": {
1747
+ "type": background["type"],
1748
+ "no. coeffs": background["terms"],
1749
+ "refine": True,
1750
+ }
1751
+ }
1752
+ )
1753
+ parameters = (["Zero"] if flags["zero"] else []) + flags["instrument"]
1754
+ if parameters:
1755
+ histogram.set_refinements({"Instrument Parameters": parameters})
1756
+ sample_flags = ["Scale"] if flags["scale"] else []
1757
+ if flags["displacement"]:
1758
+ geometry = sample.get("Type", "Bragg-Brentano")
1759
+ if geometry not in DISPLACEMENT:
1760
+ raise ValueError(f"no displacement parameters for {geometry!r} geometry")
1761
+ sample_flags += DISPLACEMENT[geometry]
1762
+ if sample_flags:
1763
+ histogram.set_refinements({"Sample Parameters": sample_flags})
1764
+ for phase in phases:
1765
+ if _selected(flags["cell"], phase.name):
1766
+ phase.set_refinements({"Cell": True})
1767
+ by_label = atom_flag_strings(phase, flags)
1768
+ values = set(by_label.values())
1769
+ if len(values) == 1 and "" not in values:
1770
+ # Every atom alike, as GSAS-II's "all".
1771
+ phase.set_refinements({"Atoms": {"all": values.pop()}})
1772
+ elif any(by_label.values()):
1773
+ phase.set_refinements(
1774
+ {"Atoms": {label: value for label, value in by_label.items() if value}}
1775
+ )
1776
+ if _selected(flags["le_bail"], phase.name):
1777
+ phase.set_refinements({"LeBail": True})
1778
+ if _selected(flags["phase_fractions"], phase.name):
1779
+ phase.set_HAP_refinements({"Scale": True}, [histogram])
1780
+ for flag, key in (("size", "Size"), ("mustrain", "Mustrain")):
1781
+ if _selected(flags[flag], phase.name):
1782
+ phase.set_HAP_refinements(
1783
+ {key: {"type": "isotropic", "refine": True}}, [histogram]
1784
+ )
1785
+
1786
+
1787
+ # Refinement
1788
+
1789
+
1790
+ class RefinementError(RuntimeError):
1791
+ """A refinement that GSAS-II reported as failed without raising."""
1792
+
1793
+
1794
+ def _copy_into(source, target):
1795
+ """Make ``target`` a deep copy of ``source`` in place.
1796
+
1797
+ As GSASIIscriptable's reload does, every dict, and every list of the same
1798
+ length, that both have at the same place is kept and filled in rather
1799
+ than replaced, so that the objects GSASIIscriptable hands out, which hold
1800
+ parts of a project's data, stay valid.
1801
+ """
1802
+ if isinstance(source, dict):
1803
+ for key in [key for key in target if key not in source]:
1804
+ del target[key]
1805
+ items = source.items()
1806
+ else:
1807
+ if len(source) != len(target):
1808
+ target[:] = copy.deepcopy(source)
1809
+ return
1810
+ items = enumerate(source)
1811
+ for key, value in items:
1812
+ current = target[key] if isinstance(source, list) or key in target else None
1813
+ if type(value) is type(current) and isinstance(value, (dict, list)):
1814
+ _copy_into(value, current)
1815
+ else:
1816
+ target[key] = copy.deepcopy(value)
1817
+
1818
+
1819
+ def _snapshot(project, covariance, state):
1820
+ """The project's data and the driver's own bookkeeping, ``state``, as a
1821
+ stage kept leaves them, to go back to."""
1822
+ return {
1823
+ "data": copy.deepcopy(project.data),
1824
+ "covariance": covariance is not None,
1825
+ "state": copy.deepcopy(state),
1826
+ }
1827
+
1828
+
1829
+ def _restore(project, snapshot):
1830
+ """Take the project back to ``snapshot`` and save it so; the covariance
1831
+ of the stage kept, if there was one, and a copy of the bookkeeping."""
1832
+ _copy_into(snapshot["data"], project.data)
1833
+ project.save()
1834
+ covariance = project.data["Covariance"]["data"] if snapshot["covariance"] else None
1835
+ return covariance, copy.deepcopy(snapshot["state"])
1836
+
1837
+
1838
+ def _unsettled_reason(passes):
1839
+ last = passes[-1]
1840
+ reason = f"not settled in {len(passes)} passes"
1841
+ if last["max_shift_over_esd"] is not None:
1842
+ reason += (
1843
+ f": {last['parameter']} still moved {last['max_shift_over_esd']:.2f} "
1844
+ "esd in the last"
1845
+ )
1846
+ return reason
1847
+
1848
+
1849
+ class _Tee(io.StringIO):
1850
+ """Copies what is printed while it is in force, still printing it."""
1851
+
1852
+ def __enter__(self):
1853
+ self._stdout = sys.stdout
1854
+ sys.stdout = self
1855
+ return self
1856
+
1857
+ def __exit__(self, *exc):
1858
+ sys.stdout = self._stdout
1859
+ return False
1860
+
1861
+ def write(self, text):
1862
+ self._stdout.write(text)
1863
+ return super().write(text)
1864
+
1865
+
1866
+ def _failure_report(output):
1867
+ lines = [line.strip() for line in output.splitlines() if line.strip()]
1868
+ flagged = [line for line in lines if FAILURE_WORDS.search(line)]
1869
+ return "\n".join((flagged or lines)[-FAILURE_LINES:])
1870
+
1871
+
1872
+ def _refine(project):
1873
+ """Refine with the flags as set, and return the covariance data.
1874
+
1875
+ GSAS-II catches the errors of a refinement and returns without saving
1876
+ it, so ``G2Project.refine`` returns normally either way. The covariance
1877
+ is cleared first so that a refinement that saved nothing shows as one
1878
+ without it; the previous covariance is then put back.
1879
+
1880
+ Raises
1881
+ ------
1882
+ RefinementError
1883
+ If the refinement saved no result, with the lines of its output that
1884
+ report the problem.
1885
+ """
1886
+ covariance = project.data["Covariance"]
1887
+ previous = covariance["data"]
1888
+ covariance["data"] = {}
1889
+ with _Tee() as output:
1890
+ project.refine()
1891
+ covariance = project.data["Covariance"]
1892
+ if "Rvals" not in covariance["data"]:
1893
+ covariance["data"] = previous
1894
+ raise RefinementError(_failure_report(output.getvalue()) or "refinement failed")
1895
+ return covariance["data"]
1896
+
1897
+
1898
+ def _compute(project):
1899
+ """Compute the project's pattern without refining it, so that a run
1900
+ that kept no stage still has a model to report and export.
1901
+
1902
+ GSAS-II works the calculated pattern out at the start of a refinement,
1903
+ so a refinement of no cycles saves it having moved nothing. It refines
1904
+ no parameter, so it leaves no covariance either, and an empty one comes
1905
+ back then: the values reported are the model's own, none with an esd.
1906
+ """
1907
+ controls = project.data["Controls"]["data"]
1908
+ before = controls.get("cycles")
1909
+ previous = project.data["Covariance"]["data"]
1910
+ project.set_Controls("cycles", 0)
1911
+ try:
1912
+ with _Tee() as output:
1913
+ project.refine()
1914
+ if REFINED_MARK not in output.getvalue():
1915
+ raise RefinementError(
1916
+ _failure_report(output.getvalue()) or "the pattern was not computed"
1917
+ )
1918
+ covariance = project.data["Covariance"]["data"]
1919
+ if "Rvals" in covariance:
1920
+ return covariance
1921
+ project.data["Covariance"]["data"] = previous
1922
+ return {}
1923
+ finally:
1924
+ if before is not None:
1925
+ project.set_Controls("cycles", before)
1926
+
1927
+
1928
+ def _extract_le_bail(G2sc, project, cycles):
1929
+ """Reset the Le Bail intensities and extract them, refining nothing.
1930
+
1931
+ As the GSAS-II GUI does when told to reset the structure factors: the
1932
+ project is saved with ``newLeBail`` set, so that every reflection of a Le
1933
+ Bail phase is given a fresh random intensity, and GSASIIstrMain.DoLeBail
1934
+ runs ``cycles`` extraction cycles on it and saves the project, which is
1935
+ then read back.
1936
+
1937
+ Raises
1938
+ ------
1939
+ RefinementError
1940
+ If the extraction fails, with GSAS-II's message.
1941
+ """
1942
+ random.seed(LE_BAIL_SEED)
1943
+ project.data["Controls"]["data"]["newLeBail"] = True
1944
+ project.save()
1945
+ with _Tee() as output:
1946
+ ok, rvals = G2sc.G2strMain.DoLeBail(project.filename, cycles=cycles)
1947
+ project.reload()
1948
+ if not ok:
1949
+ raise RefinementError(
1950
+ rvals.get("msg") or _failure_report(output.getvalue()) or "Le Bail failed"
1951
+ )
1952
+ return {"cycles": cycles, "rwp": rvals.get("Rwp"), "gof": rvals.get("GOF")}
1953
+
1954
+
1955
+ def _all_le_bail(project, le_bail):
1956
+ """Whether every phase is extracted by Le Bail."""
1957
+ return ALL_PHASES in le_bail or {p.name for p in project.phases()} <= set(le_bail)
1958
+
1959
+
1960
+ def _refine_passes(project, histogram, max_passes, tolerance, ignore=()):
1961
+ """Refine again and again until no parameter moves by more than
1962
+ ``tolerance`` esds in a pass, or ``max_passes`` have been made.
1963
+
1964
+ Returns the covariance of the last pass and a record of each: Rwp, GOF
1965
+ and the largest shift over esd since the pass before, with the parameter
1966
+ that made it, leaving out those in ``ignore``.
1967
+ """
1968
+ passes = []
1969
+ before = None
1970
+ for _ in range(max_passes):
1971
+ covariance = _refine(project)
1972
+ values = dict(zip(covariance["varyList"], covariance["variables"]))
1973
+ record = {
1974
+ "rwp": histogram.residuals.get("wR"),
1975
+ "gof": float(covariance["Rvals"]["GOF"]),
1976
+ "max_shift_over_esd": None,
1977
+ "parameter": None,
1978
+ "settled": False,
1979
+ }
1980
+ if before is not None:
1981
+ esds = _esds(covariance)
1982
+ shifts = {
1983
+ name: abs(value - before[name]) / esds[name]
1984
+ for name, value in values.items()
1985
+ if name in before and name not in ignore and esds.get(name)
1986
+ }
1987
+ if shifts:
1988
+ record["parameter"] = max(shifts, key=shifts.get)
1989
+ record["max_shift_over_esd"] = shifts[record["parameter"]]
1990
+ record["settled"] = max(shifts.values(), default=0.0) < tolerance
1991
+ passes.append(record)
1992
+ if record["settled"]:
1993
+ break
1994
+ before = values
1995
+ return covariance, passes
1996
+
1997
+
1998
+ def _esds(covariance):
1999
+ """The esd of every refined parameter, and of those derived from them."""
2000
+ esds = {}
2001
+ for name, (_, esd) in covariance.get("depSigDict", {}).items():
2002
+ if esd is not None:
2003
+ esds[name] = float(esd)
2004
+ matrix = covariance.get("covMatrix")
2005
+ for index, name in enumerate(covariance.get("varyList", [])):
2006
+ esds[name] = float(math.sqrt(abs(matrix[index][index])))
2007
+ return esds
2008
+
2009
+
2010
+ def _stage_record(name, flags, histogram, covariance):
2011
+ rvals = covariance["Rvals"]
2012
+ esds = _esds(covariance)
2013
+ residuals = histogram.residuals
2014
+ gof = float(rvals["GOF"])
2015
+ return {
2016
+ "name": name,
2017
+ "flags": flags,
2018
+ "rwp": residuals.get("wR"),
2019
+ "rp": residuals.get("R"),
2020
+ "gof": gof,
2021
+ "reduced_chi_squared": gof**2,
2022
+ "chi_squared": float(rvals["chisq"]),
2023
+ "n_observations": int(rvals["Nobs"]),
2024
+ "n_variables": int(rvals["Nvars"]),
2025
+ "converged": rvals.get("converged"),
2026
+ # GSAS-II's "Max shft/sig": the largest shift over the whole stage,
2027
+ # not in its last cycle, over the esd.
2028
+ "max_total_shift_over_esd": rvals.get("Max shft/sig"),
2029
+ "messages": rvals.get("msg", ""),
2030
+ "parameters": {
2031
+ name: {"value": float(value), "esd": esds.get(name)}
2032
+ for name, value in zip(covariance["varyList"], covariance["variables"])
2033
+ },
2034
+ "residuals": residuals,
2035
+ }
2036
+
2037
+
2038
+ def _final(project, histogram, covariance, xinel=None):
2039
+ """Instrument, sample, background and phase values after the last stage,
2040
+ with each phase's atoms and symmetry operators."""
2041
+ esds = _esds(covariance)
2042
+ hfx = f":{histogram.id}:"
2043
+ instrument = histogram.InstrumentParameters
2044
+ sample = histogram.SampleParameters
2045
+ background = histogram.Background[0]
2046
+ phases = []
2047
+ for phase in project.phases():
2048
+ cell, cell_esd = phase.get_cell_and_esd()
2049
+ pfx = f"{phase.id}:{histogram.id}:"
2050
+ hap = phase.getHAPvalues(histogram.name)
2051
+ phases.append(
2052
+ {
2053
+ "name": phase.name,
2054
+ "cell": cell,
2055
+ "cell_esd": cell_esd,
2056
+ "phase_fraction": {
2057
+ "value": float(hap["Scale"][0]),
2058
+ "esd": esds.get(pfx + "Scale"),
2059
+ },
2060
+ "weight_fraction": {
2061
+ "value": covariance.get("depSigDict", {}).get(
2062
+ pfx + "WgtFrac", [None]
2063
+ )[0],
2064
+ "esd": esds.get(pfx + "WgtFrac"),
2065
+ },
2066
+ "space_group": phase.data["General"].get("SGData", {}).get("SpGrp"),
2067
+ "le_bail": bool(hap.get("LeBail", False)),
2068
+ **_broadening(phase, histogram, esds),
2069
+ "atoms": _atom_table(phase, esds, xinel),
2070
+ "operators": _operators(phase),
2071
+ }
2072
+ )
2073
+ return {
2074
+ "instrument": {
2075
+ key: {"value": instrument[key][1], "esd": esds.get(hfx + key)}
2076
+ for key in REPORTED_INSTRUMENT
2077
+ if key in instrument
2078
+ },
2079
+ "sample": {
2080
+ key: {"value": sample[key][0], "esd": esds.get(hfx + key)}
2081
+ for key in SAMPLE_KEYS
2082
+ if key in sample
2083
+ },
2084
+ "background": {"type": background[0], "coefficients": background[3:]},
2085
+ "phases": phases,
2086
+ }
2087
+
2088
+
2089
+ # Exports
2090
+
2091
+
2092
+ def _export_histogram(histogram, path):
2093
+ import numpy as np
2094
+
2095
+ x, observed, _, calculated, background = (
2096
+ np.ma.getdata(column) for column in histogram.data["data"][1][:5]
2097
+ )
2098
+ lower, upper = histogram.Limits("lower"), histogram.Limits("upper")
2099
+ inside = (x >= lower) & (x <= upper)
2100
+ with open(path, "w", newline="", encoding="utf-8") as handle:
2101
+ writer = csv.writer(handle)
2102
+ writer.writerow(
2103
+ ["two_theta", "observed", "calculated", "background", "difference"]
2104
+ )
2105
+ for row in zip(
2106
+ x[inside], observed[inside], calculated[inside], background[inside]
2107
+ ):
2108
+ t, yo, yc, yb = (float(value) for value in row)
2109
+ writer.writerow(
2110
+ [f"{t:.6f}", f"{yo:.8g}", f"{yc:.8g}", f"{yb:.8g}", f"{yo - yc:.8g}"]
2111
+ )
2112
+
2113
+
2114
+ def _export_reflections(G2sc, reflections, path):
2115
+ """One phase's reflections. ``sig`` in the list is the Gaussian variance
2116
+ and ``gam`` the Lorentzian FWHM, both in centidegrees."""
2117
+ with open(path, "w", newline="", encoding="utf-8") as handle:
2118
+ writer = csv.writer(handle)
2119
+ writer.writerow(
2120
+ [
2121
+ "h",
2122
+ "k",
2123
+ "l",
2124
+ "multiplicity",
2125
+ "d",
2126
+ "two_theta",
2127
+ "fwhm",
2128
+ "f_obs_squared",
2129
+ "f_calc_squared",
2130
+ "i_corr",
2131
+ ]
2132
+ )
2133
+ for ref in reflections["RefList"]:
2134
+ h, k, l, multiplicity, d, two_theta, sig, gam, f_obs, f_calc = (
2135
+ float(value) for value in ref[:10]
2136
+ )
2137
+ fwhm = G2sc.G2pwd.getgamFW(gam, math.sqrt(max(sig, 0.0))) / 100.0
2138
+ writer.writerow(
2139
+ [
2140
+ round(h),
2141
+ round(k),
2142
+ round(l),
2143
+ round(multiplicity),
2144
+ f"{d:.6f}",
2145
+ f"{two_theta:.6f}",
2146
+ f"{fwhm:.6f}",
2147
+ f"{f_obs:.8g}",
2148
+ f"{f_calc:.8g}",
2149
+ f"{float(ref[11]):.6g}",
2150
+ ]
2151
+ )
2152
+
2153
+
2154
+ def _safe_name(name):
2155
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "phase"
2156
+
2157
+
2158
+ def _export(G2sc, histogram, prefix):
2159
+ """The pattern, the reflections of each phase and the instrument file."""
2160
+ prefix = Path(prefix)
2161
+ exports = {"histogram": str(prefix.with_name(prefix.name + "_histogram.csv"))}
2162
+ _export_histogram(histogram, exports["histogram"])
2163
+ exports["reflections"] = {}
2164
+ for phase, reflections in histogram.reflections().items():
2165
+ path = prefix.with_name(f"{prefix.name}_reflections_{_safe_name(phase)}.csv")
2166
+ _export_reflections(G2sc, reflections, path)
2167
+ exports["reflections"][phase] = str(path)
2168
+ exports["instprm"] = str(prefix.with_name(prefix.name + ".instprm"))
2169
+ instrument, extra = histogram.data["Instrument Parameters"]
2170
+ with open(exports["instprm"], "w", encoding="utf-8") as handle:
2171
+ G2sc.G2fil.WriteInstprm(handle, instrument, extra, histogram.SampleParameters)
2172
+ return exports
2173
+
2174
+
2175
+ # Actions
2176
+
2177
+
2178
+ def _add_histogram(project, job):
2179
+ """Read the pattern, with the xrdml importer or else the .xy fallback.
2180
+
2181
+ Returns the histogram and a note of how it was read.
2182
+ """
2183
+ data_file = job["data_file"]
2184
+ hint = XRDML_HINT if data_file.lower().endswith(".xrdml") else None
2185
+ try:
2186
+ histogram = project.add_powder_histogram(
2187
+ data_file, job["instprm"], fmthint=hint
2188
+ )
2189
+ return histogram, {"importer": "file", "file": data_file}
2190
+ except Exception as error:
2191
+ fallback = job.get("data_fallback")
2192
+ if not fallback:
2193
+ raise
2194
+ print(f"Import of {data_file} failed ({error!r}); reading {fallback}")
2195
+ histogram = project.add_powder_histogram(
2196
+ fallback, job["instprm"], fmthint=XY_HINT
2197
+ )
2198
+ return histogram, {
2199
+ "importer": "xy fallback",
2200
+ "file": fallback,
2201
+ "error": repr(error),
2202
+ }
2203
+
2204
+
2205
+ def _create_project(G2sc, job):
2206
+ """A new project at ``gpx`` with the histogram and the phases linked to it.
2207
+
2208
+ A phase entry may carry ``cell``, six lattice parameters that replace
2209
+ those read from its CIF, and ``atoms``, edits to the occupancies and
2210
+ atoms read from it (:func:`check_atom_edits`), made in the project only.
2211
+ """
2212
+ project = G2sc.G2Project(newgpx=job["gpx"])
2213
+ histogram, source = _add_histogram(project, job)
2214
+ for entry in job["phases"]:
2215
+ phase = project.add_phase(
2216
+ entry["cif"],
2217
+ phasename=entry["name"],
2218
+ histograms=[histogram],
2219
+ fmthint=CIF_HINT,
2220
+ )
2221
+ if entry.get("cell") is not None:
2222
+ cell = [float(value) for value in entry["cell"]]
2223
+ if len(cell) != 6:
2224
+ raise ValueError(f"phase {entry['name']!r}: cell needs six values")
2225
+ general = phase.data["General"]
2226
+ general["Cell"][1:7] = cell
2227
+ general["Cell"][7] = G2sc.G2lat.calc_V(G2sc.G2lat.cell2A(cell))
2228
+ if entry.get("atoms"):
2229
+ edit_atoms(G2sc, phase, entry["atoms"])
2230
+ project.save()
2231
+ return project, histogram, source
2232
+
2233
+
2234
+ def _histogram_summary(histogram):
2235
+ two_theta = histogram.getdata("X")
2236
+ instrument = histogram.InstrumentParameters
2237
+ sample = histogram.SampleParameters
2238
+ return {
2239
+ "name": histogram.name,
2240
+ "n_points": int(two_theta.size),
2241
+ "two_theta_range": [float(two_theta.min()), float(two_theta.max())],
2242
+ "limits": [histogram.Limits("lower"), histogram.Limits("upper")],
2243
+ # Each parameter is stored as [initial, current, refine]; the current
2244
+ # value is the one GSAS-II computes with.
2245
+ "instrument": {key: value[1] for key, value in instrument.items()},
2246
+ "sample": {
2247
+ "type": sample.get("Type"),
2248
+ "gonio_radius": sample.get("Gonio. radius"),
2249
+ },
2250
+ }
2251
+
2252
+
2253
+ def _phase_summary(phase):
2254
+ general = phase.data["General"]
2255
+ return {
2256
+ "name": phase.name,
2257
+ "space_group": general["SGData"]["SpGrp"],
2258
+ "cell": phase.get_cell(),
2259
+ "histograms": list(phase.data["Histograms"]),
2260
+ "atoms": _atom_table(phase),
2261
+ "operators": _operators(phase),
2262
+ }
2263
+
2264
+
2265
+ def create(G2sc, job):
2266
+ """A new project at ``gpx``: one histogram and the phases linked to it.
2267
+
2268
+ ``job`` holds ``gpx``, ``data_file``, ``instprm``, optionally
2269
+ ``data_fallback``, and ``phases``, a list of ``{"cif": path, "name": name}``
2270
+ with an optional ``cell``.
2271
+ """
2272
+ project, histogram, source = _create_project(G2sc, job)
2273
+ return {
2274
+ "gpx": project.filename,
2275
+ "source": source,
2276
+ "histogram": _histogram_summary(histogram),
2277
+ "phases": [_phase_summary(phase) for phase in project.phases()],
2278
+ }
2279
+
2280
+
2281
+ def _only_histogram(project, name=None):
2282
+ if name is not None:
2283
+ return project.histogram(name)
2284
+ histograms = project.histograms()
2285
+ if len(histograms) != 1:
2286
+ raise ValueError(
2287
+ f"the project has {len(histograms)} histograms; name one with 'histogram'"
2288
+ )
2289
+ return histograms[0]
2290
+
2291
+
2292
+ def refine(G2sc, job):
2293
+ """Refine a project stage by stage and export the result.
2294
+
2295
+ ``job`` holds ``gpx`` and ``stages``, and optionally ``histogram`` (the
2296
+ name of the one to refine, needed only if there are several),
2297
+ ``limits`` (two theta minimum and maximum), ``cycles`` (the most least
2298
+ squares cycles a stage may take), ``broadening`` (sample size and
2299
+ microstrain to start from, held unless a stage refines them, see the
2300
+ module notes), ``background_start`` (``{"type": ..., "coefficients":
2301
+ [...]}``, the background to start from), ``overall_uiso_start`` (the
2302
+ Uiso an ``overall_uiso`` stage starts every atom of a phase at),
2303
+ ``le_bail_cycles`` (the Le Bail-only cycles run when a stage switches Le
2304
+ Bail extraction on), ``max_passes`` and ``pass_tolerance``
2305
+ (refinements of each stage until it settles, see the module notes; one
2306
+ by default), ``scale_start`` (the histogram scale to start from),
2307
+ ``sanity`` (the sanity check's ``max_shift`` and ``reference``, see the
2308
+ module notes) and ``export_prefix`` (the path the exported files are
2309
+ named from). With ``data_file``, ``instprm`` and ``phases`` as for
2310
+ ``create``, the project is created first. ``on_flagged`` and
2311
+ ``on_unsettled``, "accept" or "reject", say what becomes of a stage whose
2312
+ sanity check raises a new flag and of one that has not settled (see the
2313
+ module notes; reject and accept by default).
2314
+
2315
+ Each stage is recorded with its status, residuals, parameters, the atoms
2316
+ as it left them, its sanity flags, its undetermined parameters and,
2317
+ where it has any, the coordinates it refines and holds by site and its
2318
+ occupancy constraints. A stage that fails is recorded with its error and
2319
+ ends the run; a rejected stage is recorded with the reasons, named in the
2320
+ result's ``rejected``, and the run goes on from the last stage kept.
2321
+
2322
+ Every run ends with ``final`` and, with ``export_prefix``, ``exports``:
2323
+ the model of the last stage kept, or, where no stage was kept, the one
2324
+ the job started from, computed without refining it. ``final_from`` names
2325
+ the stage they come from, or "job start".
2326
+ """
2327
+ raw = copy.deepcopy(job["stages"])
2328
+ stages = accumulate_stages(raw)
2329
+ on_flagged, on_unsettled = check_policies(job)
2330
+ broadening = check_broadening(job.get("broadening") or {})
2331
+ uiso_start = check_uiso_start(job.get("overall_uiso_start") or {})
2332
+ background_start = check_background_start(job.get("background_start"))
2333
+ scale_start = check_scale_start(job.get("scale_start"))
2334
+ sanity = check_sanity_settings(job.get("sanity"))
2335
+ if job.get("data_file"):
2336
+ project, histogram, source = _create_project(G2sc, job)
2337
+ else:
2338
+ project = G2sc.G2Project(job["gpx"])
2339
+ histogram, source = _only_histogram(project, job.get("histogram")), None
2340
+ if job.get("cycles"):
2341
+ project.set_Controls("cycles", int(job["cycles"]))
2342
+ if job.get("limits"):
2343
+ lower, upper = (float(value) for value in job["limits"])
2344
+ histogram.set_refinements({"Limits": [lower, upper]})
2345
+ if broadening:
2346
+ set_broadening(project, histogram, broadening)
2347
+ if background_start:
2348
+ coefficients = background_start["coefficients"]
2349
+ histogram.set_refinements(
2350
+ {
2351
+ "Background": {
2352
+ "type": background_start["type"],
2353
+ "no. coeffs": len(coefficients),
2354
+ "coeffs": coefficients,
2355
+ "refine": False,
2356
+ }
2357
+ }
2358
+ )
2359
+ if scale_start is not None:
2360
+ histogram.SampleParameters["Scale"][0] = scale_start
2361
+
2362
+ spc = getattr(G2sc, "G2spc", None)
2363
+ xinel = spc.GetCSxinel if spc else None
2364
+ start_atoms = {phase.name: _atom_table(phase) for phase in project.phases()}
2365
+ reference = sanity["reference"] or start_atoms
2366
+ result = {
2367
+ "gpx": project.filename,
2368
+ "source": source,
2369
+ "histogram": histogram.name,
2370
+ "limits": None,
2371
+ "stages": [],
2372
+ "completed": False,
2373
+ "sanity": {
2374
+ "max_shift": sanity["max_shift"],
2375
+ "reference": "given" if sanity["reference"] else "job start",
2376
+ },
2377
+ "on_flagged": on_flagged,
2378
+ "on_unsettled": on_unsettled,
2379
+ "rejected": [],
2380
+ }
2381
+ le_bail_cycles = int(job.get("le_bail_cycles") or DEFAULT_LE_BAIL_CYCLES)
2382
+ max_passes = int(job.get("max_passes") or 1)
2383
+ tolerance = float(job.get("pass_tolerance") or DEFAULT_PASS_TOLERANCE)
2384
+
2385
+ def sanity_flags(atoms_by_phase):
2386
+ return [
2387
+ {**flag, "phase": name}
2388
+ for name, atoms in atoms_by_phase.items()
2389
+ for flag in check_sanity(atoms, reference.get(name), sanity["max_shift"])
2390
+ ]
2391
+
2392
+ def flag_keys(flags):
2393
+ return {(flag["phase"], flag["kind"], tuple(flag["atoms"])) for flag in flags}
2394
+
2395
+ covariance = None
2396
+ le_bail = []
2397
+ # The Uiso constraints in force for each phase, as the scheme that set
2398
+ # them ("overall" or the groups) and the constraints added; and the
2399
+ # coordinate and occupancy constraints, by phase and kind.
2400
+ uiso_schemes = {}
2401
+ managed = {}
2402
+ # The last stage kept, and the flags a stage's own are judged against,
2403
+ # at first those of the atoms as the job found them.
2404
+ kept = None
2405
+ kept_flags = flag_keys(sanity_flags(start_atoms))
2406
+ # The project and the bookkeeping as the last stage kept left them, to go
2407
+ # back to when a stage is rejected or fails.
2408
+ clean = _snapshot(project, covariance, (le_bail, uiso_schemes, managed))
2409
+ # Whatever a stage raises is recorded in the result, which is written
2410
+ # regardless, rather than lost with the run; the same goes for the final
2411
+ # values and the exports.
2412
+ index = 0
2413
+ while index < len(stages):
2414
+ stage = stages[index]
2415
+ index += 1
2416
+ extraction = None
2417
+ notes = {}
2418
+ try:
2419
+ apply_flags(project, histogram, stage["flags"])
2420
+ for phase in project.phases():
2421
+ _set_uiso_scheme(
2422
+ project, phase, stage["flags"], uiso_start, uiso_schemes
2423
+ )
2424
+ notes[phase.name] = set_structure_constraints(
2425
+ project, phase, stage["flags"], xinel, managed
2426
+ )
2427
+ if any(phase not in le_bail for phase in stage["flags"]["le_bail"]):
2428
+ extraction = _extract_le_bail(G2sc, project, le_bail_cycles)
2429
+ le_bail = stage["flags"]["le_bail"]
2430
+ if not job.get("max_passes"):
2431
+ covariance, passes = _refine(project), None
2432
+ else:
2433
+ ignore = set()
2434
+ if _all_le_bail(project, le_bail):
2435
+ ignore.add(f":{histogram.id}:Scale")
2436
+ covariance, passes = _refine_passes(
2437
+ project, histogram, max_passes, tolerance, ignore
2438
+ )
2439
+ except Exception as error: # noqa: BLE001
2440
+ result["stages"].append(
2441
+ {
2442
+ **stage,
2443
+ "status": "failed",
2444
+ "error": f"{type(error).__name__}: {error}",
2445
+ }
2446
+ )
2447
+ covariance, (le_bail, uiso_schemes, managed) = _restore(project, clean)
2448
+ break
2449
+ record = _stage_record(stage["name"], stage["flags"], histogram, covariance)
2450
+ # The atoms as this stage leaves them: GSAS-II refines coordinate
2451
+ # shifts, set back to zero after each refinement, so the parameters
2452
+ # alone do not give the positions.
2453
+ esds = _esds(covariance)
2454
+ record["atoms"] = {
2455
+ phase.name: _atom_table(phase, esds, xinel) for phase in project.phases()
2456
+ }
2457
+ record["sanity"] = sanity_flags(record["atoms"])
2458
+ record["undetermined"] = [
2459
+ {**entry, "phase": name}
2460
+ for name, atoms in record["atoms"].items()
2461
+ for entry in find_undetermined(atoms, start_atoms.get(name))
2462
+ ]
2463
+ for key in ("coordinates", "occupancy_constraints"):
2464
+ found = {name: note[key] for name, note in notes.items() if key in note}
2465
+ if found:
2466
+ record[key] = found
2467
+ if extraction is not None:
2468
+ record["le_bail_extraction"] = extraction
2469
+ unsettled = False
2470
+ if passes is not None:
2471
+ record["passes"] = passes
2472
+ record["passes_converged"] = passes[-1]["settled"]
2473
+ unsettled = not passes[-1]["settled"]
2474
+ if unsettled:
2475
+ record["largest_remaining_move"] = {
2476
+ "parameter": passes[-1]["parameter"],
2477
+ "shift_over_esd": passes[-1]["max_shift_over_esd"],
2478
+ }
2479
+ new = [flag for flag in record["sanity"] if flag_keys([flag]) - kept_flags]
2480
+ reasons = []
2481
+ if new and on_flagged == "reject":
2482
+ reasons += [f"sanity check: {flag['message']}" for flag in new]
2483
+ if unsettled and on_unsettled == "reject":
2484
+ reasons.append(_unsettled_reason(passes))
2485
+ if reasons:
2486
+ record["status"] = "rejected"
2487
+ record["rejected_because"] = reasons
2488
+ result["stages"].append(record)
2489
+ result["rejected"].append(stage["name"])
2490
+ covariance, (le_bail, uiso_schemes, managed) = _restore(project, clean)
2491
+ # What this stage alone refined is held from here on: its entries
2492
+ # are dropped and the flags of the stages after it made again.
2493
+ dropped = {"name": stage["name"]}
2494
+ if raw[index - 1].get("reset"):
2495
+ dropped["reset"] = True
2496
+ raw[index - 1] = dropped
2497
+ stages = accumulate_stages(raw)
2498
+ continue
2499
+ record["status"] = "unsettled" if unsettled else "flagged" if new else "clean"
2500
+ result["stages"].append(record)
2501
+ kept = record
2502
+ kept_flags = flag_keys(record["sanity"])
2503
+ clean = _snapshot(project, covariance, (le_bail, uiso_schemes, managed))
2504
+ else:
2505
+ result["completed"] = True
2506
+ result["limits"] = [histogram.Limits("lower"), histogram.Limits("upper")]
2507
+ result["undetermined"] = kept["undetermined"] if kept else []
2508
+ result["final_from"] = kept["name"] if kept else "job start"
2509
+ if covariance is None:
2510
+ # Nothing was kept, so the project is back where the job found it.
2511
+ # Its pattern is computed, without refining anything, so that the
2512
+ # model the run leaves behind can still be reported and exported.
2513
+ try:
2514
+ covariance = _compute(project)
2515
+ except Exception as error: # noqa: BLE001
2516
+ result["compute_error"] = f"{type(error).__name__}: {error}"
2517
+ try:
2518
+ result["final"] = _final(project, histogram, covariance or {}, xinel)
2519
+ except Exception as error: # noqa: BLE001
2520
+ result["final_error"] = f"{type(error).__name__}: {error}"
2521
+ if job.get("export_prefix"):
2522
+ try:
2523
+ result["exports"] = _export(G2sc, histogram, job["export_prefix"])
2524
+ except Exception as error: # noqa: BLE001
2525
+ result["export_error"] = f"{type(error).__name__}: {error}"
2526
+ return result
2527
+
2528
+
2529
+ ACTIONS = {"create": create, "refine": refine}
2530
+
2531
+
2532
+ def main(argv):
2533
+ if len(argv) != 2:
2534
+ sys.exit("usage: gsas2_driver.py job.json")
2535
+ job = json.loads(Path(argv[1]).read_text(encoding="utf-8"))
2536
+ action = ACTIONS.get(job.get("action"))
2537
+ if action is None:
2538
+ sys.exit(f"unknown action {job.get('action')!r}; known: {', '.join(ACTIONS)}")
2539
+
2540
+ sys.path.append(job["gsas2_home"])
2541
+ from GSASII import GSASIIscriptable as G2sc
2542
+
2543
+ result = action(G2sc, job)
2544
+ result["action"] = job["action"]
2545
+ Path(job["result"]).write_text(
2546
+ json.dumps(_plain(result), indent=2), encoding="utf-8"
2547
+ )
2548
+
2549
+
2550
+ if __name__ == "__main__":
2551
+ main(sys.argv)