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/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
+ )