xrdkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xrdkit/__init__.py +226 -0
- xrdkit/broadening.py +1155 -0
- xrdkit/config.py +660 -0
- xrdkit/density.py +144 -0
- xrdkit/gsas2.py +915 -0
- xrdkit/gsas2_driver.py +2551 -0
- xrdkit/indexing.py +750 -0
- xrdkit/io.py +115 -0
- xrdkit/lattice.py +233 -0
- xrdkit/peaks.py +340 -0
- xrdkit/phases.py +494 -0
- xrdkit/plotting.py +1072 -0
- xrdkit/py.typed +0 -0
- xrdkit/sizestrain.py +539 -0
- xrdkit/structure.py +470 -0
- xrdkit-0.1.0.dist-info/METADATA +229 -0
- xrdkit-0.1.0.dist-info/RECORD +19 -0
- xrdkit-0.1.0.dist-info/WHEEL +4 -0
- xrdkit-0.1.0.dist-info/licenses/LICENSE +21 -0
xrdkit/config.py
ADDED
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
"""Refinement settings for samples and their reference structures, from TOML.
|
|
2
|
+
|
|
3
|
+
A settings file holds two tables of tables, read and checked by
|
|
4
|
+
:func:`load_config`:
|
|
5
|
+
|
|
6
|
+
``samples``
|
|
7
|
+
One table per sample, named as the caller likes (the name of its results
|
|
8
|
+
folder, say), with the keys
|
|
9
|
+
|
|
10
|
+
- ``id``: the sample's identifier;
|
|
11
|
+
- ``scan``: its scan file, relative to the data folder;
|
|
12
|
+
- ``composition``: its nominal composition, atoms of each element per
|
|
13
|
+
formula unit, ``{Sr = 0.40, Ba = 0.50, ...}``;
|
|
14
|
+
- ``structure``: the name of its reference structure's table;
|
|
15
|
+
- ``start_cell``: where the cell starts, ``{file, model}`` for a lattice
|
|
16
|
+
refinement result or ``{a, c}`` given outright;
|
|
17
|
+
- ``two_theta``: the range refined, ``[low, high]`` in degrees;
|
|
18
|
+
- ``background``: ``{function, terms}``;
|
|
19
|
+
- ``refine_microstrain``: whether the microstrain is refined;
|
|
20
|
+
- ``notes``: free text;
|
|
21
|
+
|
|
22
|
+
and optionally ``followed_reflections``, a list of hkl labels,
|
|
23
|
+
``trials``, ``{runs = [{low, terms}, ...], followed = [hkl, ...]}``
|
|
24
|
+
with ``low`` left out for the whole scan, and ``write_up``, a table of
|
|
25
|
+
tables of text, by mode and section.
|
|
26
|
+
|
|
27
|
+
``structures``
|
|
28
|
+
One table per reference structure, with the keys
|
|
29
|
+
|
|
30
|
+
- ``cif``: its CIF, relative to the caller's root folder;
|
|
31
|
+
- ``label``, ``phase_name``: how it is named in write ups and in the
|
|
32
|
+
GSAS-II project;
|
|
33
|
+
- ``space_group``, ``formula_units``: formula units per cell;
|
|
34
|
+
- ``sites``: a list of ``{atoms = {label = element, ...}, wyckoff,
|
|
35
|
+
kind}``, one per site, named by its first atom, of kind A, B or O;
|
|
36
|
+
- ``uiso_groups``: a list of ``{name, sites}``, every site in one;
|
|
37
|
+
- ``origin``: ``{site, axis}``, the site coordinate that fixes the
|
|
38
|
+
origin along a polar axis;
|
|
39
|
+
- ``exchange``: ``{elements, sites}``, the elements whose occupancies
|
|
40
|
+
are traded between the sites;
|
|
41
|
+
- ``composition``: ``{added = {element = host element, ...}}``, how a
|
|
42
|
+
nominal composition goes on the sites: every element the sites hold
|
|
43
|
+
is scaled by one factor over them, which keeps its distribution, and
|
|
44
|
+
each added element goes on every site of its host in proportion to
|
|
45
|
+
the host's occupancy there (see
|
|
46
|
+
:func:`xrdkit.structure.composition_edits`);
|
|
47
|
+
|
|
48
|
+
and optionally ``free_coordinates``, the coordinates to refine by
|
|
49
|
+
Wyckoff position, ``{"8d" = "xyz", "2a" = "z", ...}``, every coordinate
|
|
50
|
+
for a position not named, and ``bond_limits``, ``{kind = {min, max}}``
|
|
51
|
+
in angstroms, the range outside which a cation to anion bond from a
|
|
52
|
+
site of that kind is flagged.
|
|
53
|
+
|
|
54
|
+
A top level ``unsettled`` table, optional, gives for each mode of the
|
|
55
|
+
caller's pipeline, by name, the rule for a stage that has not settled:
|
|
56
|
+
``"accept"`` (keep it and go on) or ``"reject"`` (roll it back); a sample's
|
|
57
|
+
own ``unsettled`` overrides it mode by mode, and each sample carries the
|
|
58
|
+
two merged as its ``unsettled``.
|
|
59
|
+
|
|
60
|
+
Every sample's composition is checked against its structure's sites: each
|
|
61
|
+
element must be held by a site or added by the rule, and no kind of site
|
|
62
|
+
may be given more atoms per cell than it has positions.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from __future__ import annotations
|
|
66
|
+
|
|
67
|
+
import math
|
|
68
|
+
import re
|
|
69
|
+
import tomllib
|
|
70
|
+
from collections.abc import Mapping
|
|
71
|
+
from pathlib import Path
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"SITE_KINDS",
|
|
75
|
+
"ConfigError",
|
|
76
|
+
"check_composition",
|
|
77
|
+
"load_config",
|
|
78
|
+
"sample_settings",
|
|
79
|
+
"validate_config",
|
|
80
|
+
"wyckoff_multiplicity",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
# The kinds of site a structure's sites are sorted into.
|
|
84
|
+
SITE_KINDS = ("A", "B", "O")
|
|
85
|
+
|
|
86
|
+
SAMPLE_REQUIRED = (
|
|
87
|
+
"id",
|
|
88
|
+
"scan",
|
|
89
|
+
"composition",
|
|
90
|
+
"structure",
|
|
91
|
+
"start_cell",
|
|
92
|
+
"two_theta",
|
|
93
|
+
"background",
|
|
94
|
+
"refine_microstrain",
|
|
95
|
+
"notes",
|
|
96
|
+
)
|
|
97
|
+
SAMPLE_OPTIONAL = ("followed_reflections", "trials", "write_up", "unsettled")
|
|
98
|
+
# The rules for a stage that has not settled.
|
|
99
|
+
UNSETTLED_RULES = ("accept", "reject")
|
|
100
|
+
STRUCTURE_REQUIRED = (
|
|
101
|
+
"cif",
|
|
102
|
+
"label",
|
|
103
|
+
"phase_name",
|
|
104
|
+
"space_group",
|
|
105
|
+
"formula_units",
|
|
106
|
+
"sites",
|
|
107
|
+
"uiso_groups",
|
|
108
|
+
"origin",
|
|
109
|
+
"exchange",
|
|
110
|
+
"composition",
|
|
111
|
+
)
|
|
112
|
+
STRUCTURE_OPTIONAL = ("free_coordinates", "bond_limits")
|
|
113
|
+
|
|
114
|
+
ELEMENT = re.compile(r"[A-Z][a-z]?")
|
|
115
|
+
WYCKOFF = re.compile(r"(\d+)([a-z])")
|
|
116
|
+
# A composition may put this much more on a kind of site than it has
|
|
117
|
+
# positions, for rounding.
|
|
118
|
+
CAPACITY_TOLERANCE = 1.0e-9
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ConfigError(ValueError):
|
|
122
|
+
"""A settings file with a key missing or unknown, a value of the wrong
|
|
123
|
+
kind, or values that do not fit together."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fail(where: str, message: str) -> ConfigError:
|
|
127
|
+
return ConfigError(f"{where}: {message}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _table(value: object, where: str) -> Mapping:
|
|
131
|
+
if not isinstance(value, Mapping):
|
|
132
|
+
raise _fail(where, f"must be a table, not {type(value).__name__}")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _keys(table: object, where: str, required: tuple, optional: tuple = ()) -> Mapping:
|
|
137
|
+
table = _table(table, where)
|
|
138
|
+
missing = [key for key in required if key not in table]
|
|
139
|
+
if missing:
|
|
140
|
+
raise _fail(where, "missing key " + ", ".join(repr(key) for key in missing))
|
|
141
|
+
unknown = sorted(set(table) - set(required) - set(optional))
|
|
142
|
+
if unknown:
|
|
143
|
+
raise _fail(
|
|
144
|
+
where,
|
|
145
|
+
"unknown key "
|
|
146
|
+
+ ", ".join(repr(key) for key in unknown)
|
|
147
|
+
+ "; the keys are "
|
|
148
|
+
+ ", ".join(required + optional),
|
|
149
|
+
)
|
|
150
|
+
return table
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _string(value: object, where: str, empty: bool = False) -> str:
|
|
154
|
+
if not isinstance(value, str) or not (empty or value.strip()):
|
|
155
|
+
raise _fail(
|
|
156
|
+
where, "must be a non-empty string" if not empty else "must be text"
|
|
157
|
+
)
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _number(value: object, where: str, minimum: float | None = None) -> float:
|
|
162
|
+
if (
|
|
163
|
+
isinstance(value, bool)
|
|
164
|
+
or not isinstance(value, (int, float))
|
|
165
|
+
or not math.isfinite(value)
|
|
166
|
+
):
|
|
167
|
+
raise _fail(where, f"must be a number, not {value!r}")
|
|
168
|
+
if minimum is not None and value < minimum:
|
|
169
|
+
raise _fail(where, f"must be at least {minimum:g}, not {value!r}")
|
|
170
|
+
return float(value)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _integer(value: object, where: str, minimum: int) -> int:
|
|
174
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
175
|
+
raise _fail(
|
|
176
|
+
where, f"must be a whole number of at least {minimum}, not {value!r}"
|
|
177
|
+
)
|
|
178
|
+
return value
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _strings(value: object, where: str, minimum: int = 1) -> list[str]:
|
|
182
|
+
if not isinstance(value, list) or len(value) < minimum:
|
|
183
|
+
raise _fail(where, f"must be a list of at least {minimum} strings")
|
|
184
|
+
for index, item in enumerate(value):
|
|
185
|
+
_string(item, f"{where}[{index}]")
|
|
186
|
+
if len(set(value)) != len(value):
|
|
187
|
+
raise _fail(where, f"lists an entry twice: {value}")
|
|
188
|
+
return list(value)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _element(value: object, where: str) -> str:
|
|
192
|
+
if not isinstance(value, str) or not ELEMENT.fullmatch(value):
|
|
193
|
+
raise _fail(where, f"must be an element symbol such as 'Sr', not {value!r}")
|
|
194
|
+
return value
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def wyckoff_multiplicity(symbol: str) -> int:
|
|
198
|
+
"""The multiplicity of a Wyckoff position written as ``"4c"``.
|
|
199
|
+
|
|
200
|
+
Raises
|
|
201
|
+
------
|
|
202
|
+
ValueError
|
|
203
|
+
If ``symbol`` is not a number followed by one lower case letter.
|
|
204
|
+
"""
|
|
205
|
+
match = WYCKOFF.fullmatch(str(symbol))
|
|
206
|
+
if not match:
|
|
207
|
+
raise ValueError(f"not a Wyckoff position such as '4c': {symbol!r}")
|
|
208
|
+
return int(match.group(1))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# Structures
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _sites(value: object, where: str) -> list[dict]:
|
|
215
|
+
if not isinstance(value, list) or not value:
|
|
216
|
+
raise _fail(where, "must be a list of site tables")
|
|
217
|
+
sites, labels = [], set()
|
|
218
|
+
for index, table in enumerate(value):
|
|
219
|
+
at = f"{where}[{index}]"
|
|
220
|
+
_keys(table, at, ("atoms", "wyckoff", "kind"))
|
|
221
|
+
atoms = _table(table["atoms"], f"{at}.atoms")
|
|
222
|
+
if not atoms:
|
|
223
|
+
raise _fail(f"{at}.atoms", "must name at least one atom")
|
|
224
|
+
for label, element in atoms.items():
|
|
225
|
+
_element(element, f"{at}.atoms.{label}")
|
|
226
|
+
if label in labels:
|
|
227
|
+
raise _fail(f"{at}.atoms", f"atom {label!r} is on another site too")
|
|
228
|
+
labels.add(label)
|
|
229
|
+
wyckoff = _string(table["wyckoff"], f"{at}.wyckoff")
|
|
230
|
+
try:
|
|
231
|
+
wyckoff_multiplicity(wyckoff)
|
|
232
|
+
except ValueError as error:
|
|
233
|
+
raise _fail(f"{at}.wyckoff", str(error)) from None
|
|
234
|
+
if table["kind"] not in SITE_KINDS:
|
|
235
|
+
raise _fail(
|
|
236
|
+
f"{at}.kind",
|
|
237
|
+
f"must be one of {', '.join(SITE_KINDS)}, not {table['kind']!r}",
|
|
238
|
+
)
|
|
239
|
+
sites.append(
|
|
240
|
+
{
|
|
241
|
+
"name": next(iter(atoms)),
|
|
242
|
+
"atoms": dict(atoms),
|
|
243
|
+
"wyckoff": wyckoff,
|
|
244
|
+
"kind": table["kind"],
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
empty = [kind for kind in SITE_KINDS if not any(s["kind"] == kind for s in sites)]
|
|
248
|
+
if empty:
|
|
249
|
+
raise _fail(where, f"no site of kind {', '.join(empty)}")
|
|
250
|
+
return sites
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _site_names(value: object, where: str, names: list[str]) -> list[str]:
|
|
254
|
+
listed = _strings(value, where)
|
|
255
|
+
unknown = [name for name in listed if name not in names]
|
|
256
|
+
if unknown:
|
|
257
|
+
raise _fail(
|
|
258
|
+
where,
|
|
259
|
+
f"no site named {', '.join(map(repr, unknown))}; sites are named by "
|
|
260
|
+
f"their first atom: {', '.join(names)}",
|
|
261
|
+
)
|
|
262
|
+
return listed
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _structure(table: object, where: str) -> dict:
|
|
266
|
+
_keys(table, where, STRUCTURE_REQUIRED, STRUCTURE_OPTIONAL)
|
|
267
|
+
structure = {
|
|
268
|
+
key: _string(table[key], f"{where}.{key}")
|
|
269
|
+
for key in ("cif", "label", "phase_name", "space_group")
|
|
270
|
+
}
|
|
271
|
+
structure["formula_units"] = _integer(
|
|
272
|
+
table["formula_units"], f"{where}.formula_units", 1
|
|
273
|
+
)
|
|
274
|
+
sites = _sites(table["sites"], f"{where}.sites")
|
|
275
|
+
names = [site["name"] for site in sites]
|
|
276
|
+
kind_of = {site["name"]: site["kind"] for site in sites}
|
|
277
|
+
structure["sites"] = sites
|
|
278
|
+
|
|
279
|
+
free = _table(table.get("free_coordinates", {}), f"{where}.free_coordinates")
|
|
280
|
+
for wyckoff, axes in free.items():
|
|
281
|
+
at = f"{where}.free_coordinates.{wyckoff}"
|
|
282
|
+
try:
|
|
283
|
+
wyckoff_multiplicity(wyckoff)
|
|
284
|
+
except ValueError as error:
|
|
285
|
+
raise _fail(at, str(error)) from None
|
|
286
|
+
if axes != "all" and not (
|
|
287
|
+
isinstance(axes, str)
|
|
288
|
+
and axes
|
|
289
|
+
and set(axes) <= set("xyz")
|
|
290
|
+
and len(set(axes)) == len(axes)
|
|
291
|
+
):
|
|
292
|
+
raise _fail(at, f"must be 'all' or some of 'xyz', not {axes!r}")
|
|
293
|
+
structure["free_coordinates"] = dict(free)
|
|
294
|
+
|
|
295
|
+
groups = table["uiso_groups"]
|
|
296
|
+
if not isinstance(groups, list) or not groups:
|
|
297
|
+
raise _fail(f"{where}.uiso_groups", "must be a list of {name, sites} tables")
|
|
298
|
+
placed: dict[str, str] = {}
|
|
299
|
+
structure["uiso_groups"] = []
|
|
300
|
+
for index, group in enumerate(groups):
|
|
301
|
+
at = f"{where}.uiso_groups[{index}]"
|
|
302
|
+
_keys(group, at, ("name", "sites"))
|
|
303
|
+
name = _string(group["name"], f"{at}.name")
|
|
304
|
+
members = _site_names(group["sites"], f"{at}.sites", names)
|
|
305
|
+
for member in members:
|
|
306
|
+
if member in placed:
|
|
307
|
+
raise _fail(
|
|
308
|
+
at, f"site {member} is in Uiso group {placed[member]!r} already"
|
|
309
|
+
)
|
|
310
|
+
placed[member] = name
|
|
311
|
+
structure["uiso_groups"].append({"name": name, "sites": members})
|
|
312
|
+
outside = [name for name in names if name not in placed]
|
|
313
|
+
if outside:
|
|
314
|
+
raise _fail(
|
|
315
|
+
f"{where}.uiso_groups", f"sites {', '.join(outside)} are in no Uiso group"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
origin = _keys(table["origin"], f"{where}.origin", ("site", "axis"))
|
|
319
|
+
_site_names([origin["site"]], f"{where}.origin.site", names)
|
|
320
|
+
if origin["axis"] not in ("x", "y", "z"):
|
|
321
|
+
raise _fail(
|
|
322
|
+
f"{where}.origin.axis", f"must be x, y or z, not {origin['axis']!r}"
|
|
323
|
+
)
|
|
324
|
+
structure["origin"] = dict(origin)
|
|
325
|
+
|
|
326
|
+
exchange = _keys(table["exchange"], f"{where}.exchange", ("elements", "sites"))
|
|
327
|
+
elements = _strings(exchange["elements"], f"{where}.exchange.elements", 2)
|
|
328
|
+
for index, element in enumerate(elements):
|
|
329
|
+
_element(element, f"{where}.exchange.elements[{index}]")
|
|
330
|
+
between = _site_names(exchange["sites"], f"{where}.exchange.sites", names)
|
|
331
|
+
if len(between) < 2:
|
|
332
|
+
raise _fail(f"{where}.exchange.sites", "must name at least two sites")
|
|
333
|
+
if len({kind_of[name] for name in between}) > 1:
|
|
334
|
+
raise _fail(
|
|
335
|
+
f"{where}.exchange.sites",
|
|
336
|
+
"must all be of one kind, not "
|
|
337
|
+
+ ", ".join(f"{name} ({kind_of[name]})" for name in between),
|
|
338
|
+
)
|
|
339
|
+
on_sites = {
|
|
340
|
+
element
|
|
341
|
+
for site in sites
|
|
342
|
+
if site["name"] in between
|
|
343
|
+
for element in site["atoms"].values()
|
|
344
|
+
}
|
|
345
|
+
absent = [element for element in elements if element not in on_sites]
|
|
346
|
+
if absent:
|
|
347
|
+
raise _fail(
|
|
348
|
+
f"{where}.exchange.elements",
|
|
349
|
+
f"{', '.join(absent)} on none of the sites {', '.join(between)}",
|
|
350
|
+
)
|
|
351
|
+
structure["exchange"] = {"elements": elements, "sites": between}
|
|
352
|
+
|
|
353
|
+
rule = _keys(table["composition"], f"{where}.composition", ("added",))
|
|
354
|
+
added = _table(rule["added"], f"{where}.composition.added")
|
|
355
|
+
held = {element for site in sites for element in site["atoms"].values()}
|
|
356
|
+
for element, host in added.items():
|
|
357
|
+
at = f"{where}.composition.added.{element}"
|
|
358
|
+
_element(element, at)
|
|
359
|
+
_element(host, at)
|
|
360
|
+
if element in held:
|
|
361
|
+
raise _fail(
|
|
362
|
+
at,
|
|
363
|
+
f"{element} is on the sites already; only an element "
|
|
364
|
+
"they lack is added",
|
|
365
|
+
)
|
|
366
|
+
if host not in held:
|
|
367
|
+
raise _fail(at, f"its host {host} is on none of the sites")
|
|
368
|
+
structure["composition"] = {"added": dict(added)}
|
|
369
|
+
|
|
370
|
+
limits = _table(table.get("bond_limits", {}), f"{where}.bond_limits")
|
|
371
|
+
structure["bond_limits"] = {}
|
|
372
|
+
for kind, bounds in limits.items():
|
|
373
|
+
at = f"{where}.bond_limits.{kind}"
|
|
374
|
+
if kind not in SITE_KINDS:
|
|
375
|
+
raise _fail(
|
|
376
|
+
at, f"not a kind of site; the kinds are {', '.join(SITE_KINDS)}"
|
|
377
|
+
)
|
|
378
|
+
_keys(bounds, at, (), ("min", "max"))
|
|
379
|
+
checked = {
|
|
380
|
+
key: _number(value, f"{at}.{key}", 0.0) for key, value in bounds.items()
|
|
381
|
+
}
|
|
382
|
+
if checked.get("min", 0.0) > checked.get("max", math.inf):
|
|
383
|
+
raise _fail(at, "min is above max")
|
|
384
|
+
structure["bond_limits"][kind] = checked
|
|
385
|
+
return structure
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# Samples
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def check_composition(
|
|
392
|
+
composition: Mapping[str, float], structure: Mapping, where: str = "composition"
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Check that ``composition``, atoms per formula unit, fits the sites of
|
|
395
|
+
``structure``, a structure table as :func:`load_config` returns it.
|
|
396
|
+
|
|
397
|
+
Every element the sites hold must be in the composition (at 0 if it is
|
|
398
|
+
absent), and every element of the composition must be on a site or added
|
|
399
|
+
by the structure's composition rule. An element takes the kinds of site
|
|
400
|
+
it is on, or its host's; kinds that share an element are counted
|
|
401
|
+
together, and none may be given more atoms per cell than the
|
|
402
|
+
multiplicities of their sites add up to.
|
|
403
|
+
|
|
404
|
+
Raises
|
|
405
|
+
------
|
|
406
|
+
ConfigError
|
|
407
|
+
If the composition does not fit.
|
|
408
|
+
"""
|
|
409
|
+
sites = structure["sites"]
|
|
410
|
+
added = structure["composition"]["added"]
|
|
411
|
+
kinds_of: dict[str, set[str]] = {}
|
|
412
|
+
for site in sites:
|
|
413
|
+
for element in site["atoms"].values():
|
|
414
|
+
kinds_of.setdefault(element, set()).add(site["kind"])
|
|
415
|
+
held = sorted(kinds_of)
|
|
416
|
+
for element, host in added.items():
|
|
417
|
+
kinds_of[element] = set(kinds_of[host])
|
|
418
|
+
missing = [element for element in held if element not in composition]
|
|
419
|
+
if missing:
|
|
420
|
+
raise _fail(
|
|
421
|
+
where,
|
|
422
|
+
f"lacks {', '.join(missing)}, which the sites of the structure hold; "
|
|
423
|
+
"give 0 for an element that is absent",
|
|
424
|
+
)
|
|
425
|
+
foreign = [element for element in composition if element not in kinds_of]
|
|
426
|
+
if foreign:
|
|
427
|
+
raise _fail(
|
|
428
|
+
where,
|
|
429
|
+
f"has {', '.join(foreign)}, which no site of the structure holds and "
|
|
430
|
+
"its composition rule does not add",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
# Kinds of site that share an element are one pool of positions.
|
|
434
|
+
pools: list[set[str]] = []
|
|
435
|
+
for kinds in kinds_of.values():
|
|
436
|
+
joined = set(kinds)
|
|
437
|
+
for pool in [pool for pool in pools if pool & joined]:
|
|
438
|
+
joined |= pool
|
|
439
|
+
pools.remove(pool)
|
|
440
|
+
pools.append(joined)
|
|
441
|
+
units = structure["formula_units"]
|
|
442
|
+
for pool in pools:
|
|
443
|
+
on = [element for element in composition if kinds_of[element] <= pool]
|
|
444
|
+
content = sum(units * composition[element] for element in on)
|
|
445
|
+
pool_sites = [site for site in sites if site["kind"] in pool]
|
|
446
|
+
capacity = sum(wyckoff_multiplicity(site["wyckoff"]) for site in pool_sites)
|
|
447
|
+
if content > capacity + CAPACITY_TOLERANCE:
|
|
448
|
+
raise _fail(
|
|
449
|
+
where,
|
|
450
|
+
f"puts {content:g} atoms per cell ({units} formula units of "
|
|
451
|
+
+ ", ".join(f"{element} {composition[element]:g}" for element in on)
|
|
452
|
+
+ f") on the {'/'.join(sorted(pool))} sites, which have "
|
|
453
|
+
f"{capacity} positions ("
|
|
454
|
+
+ ", ".join(f"{site['name']} {site['wyckoff']}" for site in pool_sites)
|
|
455
|
+
+ ")",
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _start_cell(value: object, where: str) -> dict:
|
|
460
|
+
table = _table(value, where)
|
|
461
|
+
if "file" in table:
|
|
462
|
+
_keys(table, where, ("file", "model"))
|
|
463
|
+
return {
|
|
464
|
+
"file": _string(table["file"], f"{where}.file"),
|
|
465
|
+
"model": _string(table["model"], f"{where}.model"),
|
|
466
|
+
}
|
|
467
|
+
if "a" in table or "c" in table:
|
|
468
|
+
_keys(table, where, ("a", "c"))
|
|
469
|
+
return {key: _number(table[key], f"{where}.{key}", 0.0) for key in ("a", "c")}
|
|
470
|
+
raise _fail(
|
|
471
|
+
where, "must give file and model (a lattice refinement result) or a and c"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _trials(value: object, where: str) -> dict:
|
|
476
|
+
table = _keys(value, where, ("runs",), ("followed",))
|
|
477
|
+
runs = table["runs"]
|
|
478
|
+
if not isinstance(runs, list) or not runs:
|
|
479
|
+
raise _fail(f"{where}.runs", "must be a list of {low, terms} tables")
|
|
480
|
+
checked = []
|
|
481
|
+
for index, run in enumerate(runs):
|
|
482
|
+
at = f"{where}.runs[{index}]"
|
|
483
|
+
_keys(run, at, ("terms",), ("low",))
|
|
484
|
+
checked.append(
|
|
485
|
+
{
|
|
486
|
+
"low": None
|
|
487
|
+
if "low" not in run
|
|
488
|
+
else _number(run["low"], f"{at}.low", 0.0),
|
|
489
|
+
"terms": _integer(run["terms"], f"{at}.terms", 1),
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
followed = table.get("followed", [])
|
|
493
|
+
return {
|
|
494
|
+
"runs": checked,
|
|
495
|
+
"followed": _strings(followed, f"{where}.followed") if followed else [],
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _unsettled(value: object, where: str) -> dict[str, str]:
|
|
500
|
+
rules = _table(value, where)
|
|
501
|
+
for mode, rule in rules.items():
|
|
502
|
+
if rule not in UNSETTLED_RULES:
|
|
503
|
+
raise _fail(
|
|
504
|
+
f"{where}.{mode}",
|
|
505
|
+
f"must be one of {', '.join(UNSETTLED_RULES)}, not {rule!r}",
|
|
506
|
+
)
|
|
507
|
+
return dict(rules)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _sample(table: object, where: str, structures: Mapping[str, dict]) -> dict:
|
|
511
|
+
_keys(table, where, SAMPLE_REQUIRED, SAMPLE_OPTIONAL)
|
|
512
|
+
sample = {
|
|
513
|
+
"id": _string(table["id"], f"{where}.id"),
|
|
514
|
+
"scan": _string(table["scan"], f"{where}.scan"),
|
|
515
|
+
"notes": _string(table["notes"], f"{where}.notes", empty=True),
|
|
516
|
+
}
|
|
517
|
+
composition = _table(table["composition"], f"{where}.composition")
|
|
518
|
+
if not composition:
|
|
519
|
+
raise _fail(f"{where}.composition", "names no element")
|
|
520
|
+
sample["composition"] = {
|
|
521
|
+
_element(element, f"{where}.composition"): _number(
|
|
522
|
+
value, f"{where}.composition.{element}", 0.0
|
|
523
|
+
)
|
|
524
|
+
for element, value in composition.items()
|
|
525
|
+
}
|
|
526
|
+
name = _string(table["structure"], f"{where}.structure")
|
|
527
|
+
if name not in structures:
|
|
528
|
+
raise _fail(
|
|
529
|
+
f"{where}.structure",
|
|
530
|
+
f"no structure {name!r} under structures; there are "
|
|
531
|
+
+ (", ".join(structures) or "none"),
|
|
532
|
+
)
|
|
533
|
+
sample["structure"] = name
|
|
534
|
+
sample["start_cell"] = _start_cell(table["start_cell"], f"{where}.start_cell")
|
|
535
|
+
|
|
536
|
+
two_theta = table["two_theta"]
|
|
537
|
+
if not isinstance(two_theta, list) or len(two_theta) != 2:
|
|
538
|
+
raise _fail(f"{where}.two_theta", "must be [low, high] in degrees")
|
|
539
|
+
low, high = (
|
|
540
|
+
_number(value, f"{where}.two_theta[{index}]", 0.0)
|
|
541
|
+
for index, value in enumerate(two_theta)
|
|
542
|
+
)
|
|
543
|
+
if not low < high <= 180.0:
|
|
544
|
+
raise _fail(
|
|
545
|
+
f"{where}.two_theta",
|
|
546
|
+
f"must rise from low to high within 180°, not {two_theta}",
|
|
547
|
+
)
|
|
548
|
+
sample["two_theta"] = (low, high)
|
|
549
|
+
|
|
550
|
+
background = _keys(
|
|
551
|
+
table["background"], f"{where}.background", ("function", "terms")
|
|
552
|
+
)
|
|
553
|
+
sample["background"] = {
|
|
554
|
+
"function": _string(background["function"], f"{where}.background.function"),
|
|
555
|
+
"terms": _integer(background["terms"], f"{where}.background.terms", 1),
|
|
556
|
+
}
|
|
557
|
+
if not isinstance(table["refine_microstrain"], bool):
|
|
558
|
+
raise _fail(f"{where}.refine_microstrain", "must be true or false")
|
|
559
|
+
sample["refine_microstrain"] = table["refine_microstrain"]
|
|
560
|
+
|
|
561
|
+
followed = table.get("followed_reflections", [])
|
|
562
|
+
sample["followed_reflections"] = (
|
|
563
|
+
_strings(followed, f"{where}.followed_reflections") if followed else []
|
|
564
|
+
)
|
|
565
|
+
sample["trials"] = (
|
|
566
|
+
_trials(table["trials"], f"{where}.trials") if "trials" in table else None
|
|
567
|
+
)
|
|
568
|
+
write_up = _table(table.get("write_up", {}), f"{where}.write_up")
|
|
569
|
+
sample["write_up"] = {}
|
|
570
|
+
for mode, sections in write_up.items():
|
|
571
|
+
sections = _table(sections, f"{where}.write_up.{mode}")
|
|
572
|
+
sample["write_up"][mode] = {
|
|
573
|
+
section: _string(text, f"{where}.write_up.{mode}.{section}", empty=True)
|
|
574
|
+
for section, text in sections.items()
|
|
575
|
+
}
|
|
576
|
+
check_composition(sample["composition"], structures[name], f"{where}.composition")
|
|
577
|
+
return sample
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# Files
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def validate_config(data: Mapping, source: str = "settings") -> dict:
|
|
584
|
+
"""Check settings read from a TOML file and return them with the
|
|
585
|
+
optional keys filled in, each sample and structure carrying its table
|
|
586
|
+
name as ``name`` and each site its first atom's label as ``name``.
|
|
587
|
+
|
|
588
|
+
Raises
|
|
589
|
+
------
|
|
590
|
+
ConfigError
|
|
591
|
+
On the first key missing or unknown, value of the wrong kind, or
|
|
592
|
+
composition that does not fit its structure, naming the file and
|
|
593
|
+
the table.
|
|
594
|
+
"""
|
|
595
|
+
try:
|
|
596
|
+
_keys(data, "top level", ("samples", "structures"), ("unsettled",))
|
|
597
|
+
unsettled = _unsettled(data.get("unsettled", {}), "unsettled")
|
|
598
|
+
structures = {}
|
|
599
|
+
for name, table in _table(data["structures"], "structures").items():
|
|
600
|
+
structures[name] = {"name": name, **_structure(table, f"structures.{name}")}
|
|
601
|
+
samples = {}
|
|
602
|
+
ids: dict[str, str] = {}
|
|
603
|
+
for name, table in _table(data["samples"], "samples").items():
|
|
604
|
+
sample = {"name": name, **_sample(table, f"samples.{name}", structures)}
|
|
605
|
+
own = _unsettled(table.get("unsettled", {}), f"samples.{name}.unsettled")
|
|
606
|
+
sample["unsettled"] = {**unsettled, **own}
|
|
607
|
+
if sample["id"] in ids:
|
|
608
|
+
raise _fail(
|
|
609
|
+
f"samples.{name}.id",
|
|
610
|
+
f"{sample['id']!r} is the id of samples.{ids[sample['id']]} too",
|
|
611
|
+
)
|
|
612
|
+
ids[sample["id"]] = name
|
|
613
|
+
samples[name] = sample
|
|
614
|
+
except ConfigError as error:
|
|
615
|
+
raise ConfigError(f"{source}: {error}") from None
|
|
616
|
+
return {
|
|
617
|
+
"source": source,
|
|
618
|
+
"unsettled": unsettled,
|
|
619
|
+
"samples": samples,
|
|
620
|
+
"structures": structures,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def load_config(path: str | Path) -> dict:
|
|
625
|
+
"""Read the settings file at ``path`` with tomllib and check it (see
|
|
626
|
+
:func:`validate_config` and the module notes for its layout).
|
|
627
|
+
|
|
628
|
+
Raises
|
|
629
|
+
------
|
|
630
|
+
ConfigError
|
|
631
|
+
If the file is not valid TOML or its settings do not check out.
|
|
632
|
+
FileNotFoundError
|
|
633
|
+
If there is no such file.
|
|
634
|
+
"""
|
|
635
|
+
path = Path(path)
|
|
636
|
+
with path.open("rb") as handle:
|
|
637
|
+
try:
|
|
638
|
+
data = tomllib.load(handle)
|
|
639
|
+
except tomllib.TOMLDecodeError as error:
|
|
640
|
+
raise ConfigError(f"{path}: not valid TOML: {error}") from None
|
|
641
|
+
return validate_config(data, str(path))
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def sample_settings(config: Mapping, identifier: str) -> tuple[dict, dict]:
|
|
645
|
+
"""The sample of ``config`` whose id, or table name, is ``identifier``,
|
|
646
|
+
and its structure.
|
|
647
|
+
|
|
648
|
+
Raises
|
|
649
|
+
------
|
|
650
|
+
ConfigError
|
|
651
|
+
If no sample has that id or name.
|
|
652
|
+
"""
|
|
653
|
+
samples = config["samples"]
|
|
654
|
+
for name, sample in samples.items():
|
|
655
|
+
if identifier in (sample["id"], name):
|
|
656
|
+
return sample, config["structures"][sample["structure"]]
|
|
657
|
+
raise ConfigError(
|
|
658
|
+
f"{config.get('source', 'settings')}: no sample {identifier!r}; the samples are "
|
|
659
|
+
+ ", ".join(f"{sample['id']} ({name})" for name, sample in samples.items())
|
|
660
|
+
)
|