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/phases.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Reference structures for phase identification.
|
|
2
|
+
|
|
3
|
+
The Crystallography Open Database is searched through its REST interface, which
|
|
4
|
+
returns one JSON object per entry, and each entry's CIF is downloaded by its
|
|
5
|
+
seven digit COD id. The CIFs gathered for a project are described by a CSV
|
|
6
|
+
index, one row per file, written by :func:`write_cif_index`. All of this uses
|
|
7
|
+
only the standard library.
|
|
8
|
+
|
|
9
|
+
A CIF's powder pattern is simulated with pymatgen by :func:`simulate_pattern`,
|
|
10
|
+
and :func:`match_candidate` weighs a simulated pattern against observed peak
|
|
11
|
+
positions. pymatgen is the optional ``phases`` extra, ``xrdkit[phases]``, and
|
|
12
|
+
is imported only when a pattern is simulated.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import csv
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import NamedTuple
|
|
24
|
+
from urllib.parse import urlencode
|
|
25
|
+
from urllib.request import Request, urlopen
|
|
26
|
+
|
|
27
|
+
from xrdkit.peaks import KALPHA1_WAVELENGTH
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"CIF_INDEX_COLUMNS",
|
|
31
|
+
"COD_URL",
|
|
32
|
+
"CandidateMatch",
|
|
33
|
+
"CodRecord",
|
|
34
|
+
"ExplainedPeak",
|
|
35
|
+
"SimulatedReflection",
|
|
36
|
+
"cod_fetch",
|
|
37
|
+
"cod_search",
|
|
38
|
+
"match_candidate",
|
|
39
|
+
"simulate_pattern",
|
|
40
|
+
"write_cif_index",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
COD_URL = "https://www.crystallography.net/cod"
|
|
44
|
+
|
|
45
|
+
# Columns of the CIF index, in order.
|
|
46
|
+
CIF_INDEX_COLUMNS = (
|
|
47
|
+
"file",
|
|
48
|
+
"source",
|
|
49
|
+
"identifier",
|
|
50
|
+
"formula",
|
|
51
|
+
"space_group",
|
|
52
|
+
"a",
|
|
53
|
+
"b",
|
|
54
|
+
"c",
|
|
55
|
+
"alpha",
|
|
56
|
+
"beta",
|
|
57
|
+
"gamma",
|
|
58
|
+
"reference",
|
|
59
|
+
"notes",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# The COD search form takes at most eight elements, el1 to el8.
|
|
63
|
+
MAX_ELEMENTS = 8
|
|
64
|
+
|
|
65
|
+
# Seconds to wait for the COD before giving up on a request.
|
|
66
|
+
TIMEOUT_S = 60.0
|
|
67
|
+
|
|
68
|
+
USER_AGENT = "xrdkit (https://github.com/amirkhesro/xrdkit)"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class CodRecord:
|
|
73
|
+
"""One COD entry as returned by a search.
|
|
74
|
+
|
|
75
|
+
Cell lengths are in angstroms, angles in degrees and the volume in cubic
|
|
76
|
+
angstroms. ``temperature`` is the temperature of the cell measurement in
|
|
77
|
+
kelvin, and ``pressure`` its pressure in kilopascals. Any value the COD
|
|
78
|
+
leaves blank is ``None``; old entries often give no temperature at all.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
cod_id: str
|
|
82
|
+
formula: str
|
|
83
|
+
space_group: str | None
|
|
84
|
+
space_group_number: int | None
|
|
85
|
+
a: float | None
|
|
86
|
+
b: float | None
|
|
87
|
+
c: float | None
|
|
88
|
+
alpha: float | None
|
|
89
|
+
beta: float | None
|
|
90
|
+
gamma: float | None
|
|
91
|
+
volume: float | None
|
|
92
|
+
authors: str
|
|
93
|
+
journal: str
|
|
94
|
+
year: int | None
|
|
95
|
+
doi: str | None = None
|
|
96
|
+
temperature: float | None = None
|
|
97
|
+
pressure: float | None = None
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def filename(self) -> str:
|
|
101
|
+
"""Name :func:`cod_fetch` gives this entry's CIF."""
|
|
102
|
+
return f"{self.cod_id}.cif"
|
|
103
|
+
|
|
104
|
+
def reference(self) -> str:
|
|
105
|
+
"""Return a one line citation, such as 'Smith, J. (2006) Acta Cryst. B'."""
|
|
106
|
+
text = self.authors
|
|
107
|
+
if self.year is not None:
|
|
108
|
+
text += f" ({self.year})"
|
|
109
|
+
if self.journal:
|
|
110
|
+
text += f" {self.journal}"
|
|
111
|
+
if self.doi:
|
|
112
|
+
text += f", doi:{self.doi}"
|
|
113
|
+
return text.strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cod_search(
|
|
117
|
+
elements: Iterable[str],
|
|
118
|
+
exact: bool = True,
|
|
119
|
+
space_group: str | int | None = None,
|
|
120
|
+
extra: Mapping[str, str | int | float] | None = None,
|
|
121
|
+
) -> list[CodRecord]:
|
|
122
|
+
"""Search the COD for entries containing ``elements``.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
elements
|
|
127
|
+
Element symbols, one to eight of them, such as ``["Sr", "Ba", "Nb", "O"]``.
|
|
128
|
+
exact
|
|
129
|
+
If true, only entries made of exactly these elements are returned;
|
|
130
|
+
otherwise entries containing these elements and any others.
|
|
131
|
+
space_group
|
|
132
|
+
Keep only entries in this space group. An integer is the International
|
|
133
|
+
Tables number and is passed to the COD's ``space_group_number`` search.
|
|
134
|
+
A string is a Hermann-Mauguin symbol such as ``"P4bm"``, compared with
|
|
135
|
+
each entry's symbol after spaces and any trailing setting, such as
|
|
136
|
+
``" (a,b,2*c)"`` or ``":H"``, are removed. The COD ignores a symbol in
|
|
137
|
+
the query itself, so the symbol is matched here and must be written as
|
|
138
|
+
the COD writes it (``"P121/c1"`` rather than ``"P21/c"``); entries
|
|
139
|
+
whose symbol the COD gives in a nonstandard centring such as ``X4bm``
|
|
140
|
+
are not matched by either form.
|
|
141
|
+
extra
|
|
142
|
+
Further COD search parameters, such as ``{"year": 2006}``. They are
|
|
143
|
+
added to the query as given and override those set from the arguments.
|
|
144
|
+
|
|
145
|
+
Raises
|
|
146
|
+
------
|
|
147
|
+
ValueError
|
|
148
|
+
If no element or more than eight are given, or one is repeated.
|
|
149
|
+
urllib.error.URLError
|
|
150
|
+
If the COD cannot be reached or answers with an error.
|
|
151
|
+
"""
|
|
152
|
+
symbols = [element.strip() for element in elements]
|
|
153
|
+
if not symbols:
|
|
154
|
+
raise ValueError("no elements given")
|
|
155
|
+
if len(symbols) > MAX_ELEMENTS:
|
|
156
|
+
raise ValueError(
|
|
157
|
+
f"the COD searches at most {MAX_ELEMENTS} elements, got {len(symbols)}"
|
|
158
|
+
)
|
|
159
|
+
if len(set(symbols)) != len(symbols):
|
|
160
|
+
raise ValueError(f"repeated element in {symbols}")
|
|
161
|
+
|
|
162
|
+
params: dict[str, str | int | float] = {"format": "json"}
|
|
163
|
+
for number, element in enumerate(symbols, start=1):
|
|
164
|
+
params[f"el{number}"] = element
|
|
165
|
+
if exact:
|
|
166
|
+
params["strictmin"] = len(symbols)
|
|
167
|
+
params["strictmax"] = len(symbols)
|
|
168
|
+
if isinstance(space_group, int):
|
|
169
|
+
params["space_group_number"] = space_group
|
|
170
|
+
if extra:
|
|
171
|
+
params.update(extra)
|
|
172
|
+
|
|
173
|
+
body = _get(f"{COD_URL}/result?{urlencode(params)}")
|
|
174
|
+
records = [_record(entry) for entry in json.loads(body.decode("utf-8"))]
|
|
175
|
+
if isinstance(space_group, str):
|
|
176
|
+
wanted = _normalise_symbol(space_group)
|
|
177
|
+
records = [
|
|
178
|
+
record
|
|
179
|
+
for record in records
|
|
180
|
+
if record.space_group is not None
|
|
181
|
+
and _normalise_symbol(record.space_group) == wanted
|
|
182
|
+
]
|
|
183
|
+
return records
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cod_fetch(cod_id: str | int, folder: str | Path) -> Path:
|
|
187
|
+
"""Download the CIF of COD entry ``cod_id`` into ``folder`` as ``<id>.cif``.
|
|
188
|
+
|
|
189
|
+
The folder is created if needed and an existing file of the same name is
|
|
190
|
+
replaced. The file is written exactly as the COD serves it.
|
|
191
|
+
|
|
192
|
+
Raises
|
|
193
|
+
------
|
|
194
|
+
ValueError
|
|
195
|
+
If ``cod_id`` is not a seven digit COD id, or what comes back is not a CIF.
|
|
196
|
+
urllib.error.URLError
|
|
197
|
+
If the COD cannot be reached or has no such entry.
|
|
198
|
+
"""
|
|
199
|
+
identifier = str(cod_id).strip()
|
|
200
|
+
if not re.fullmatch(r"\d{7}", identifier):
|
|
201
|
+
raise ValueError(f"not a COD id: {cod_id!r}")
|
|
202
|
+
body = _get(f"{COD_URL}/{identifier}.cif")
|
|
203
|
+
if b"data_" not in body:
|
|
204
|
+
raise ValueError(f"COD {identifier} did not return a CIF")
|
|
205
|
+
path = Path(folder) / f"{identifier}.cif"
|
|
206
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
path.write_bytes(body)
|
|
208
|
+
return path
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def write_cif_index(
|
|
212
|
+
records: Iterable[CodRecord | Mapping[str, object]],
|
|
213
|
+
path: str | Path,
|
|
214
|
+
notes: str = "",
|
|
215
|
+
) -> Path:
|
|
216
|
+
"""Write or update the CSV index of reference CIFs at ``path``.
|
|
217
|
+
|
|
218
|
+
Each record is either a :class:`CodRecord`, entered with source ``COD``,
|
|
219
|
+
its COD id as the identifier, ``<id>.cif`` as the file and ``notes`` as its
|
|
220
|
+
notes, or a mapping keyed by :data:`CIF_INDEX_COLUMNS` for a CIF from
|
|
221
|
+
elsewhere, which brings its own notes. A row is
|
|
222
|
+
identified by its source and identifier: a record matching a row already in
|
|
223
|
+
the index replaces it in place, and one that matches none is added at the
|
|
224
|
+
end. Rows not named by any record are kept, and so are the notes of a
|
|
225
|
+
replaced row when the new record brings none, so notes written by hand
|
|
226
|
+
survive a fresh download.
|
|
227
|
+
|
|
228
|
+
Raises
|
|
229
|
+
------
|
|
230
|
+
ValueError
|
|
231
|
+
If a mapping has a column not in the index, or no source or identifier.
|
|
232
|
+
"""
|
|
233
|
+
path = Path(path)
|
|
234
|
+
rows: dict[tuple[str, str], dict[str, str]] = {}
|
|
235
|
+
if path.is_file():
|
|
236
|
+
with path.open(newline="", encoding="utf-8") as handle:
|
|
237
|
+
for row in csv.DictReader(handle):
|
|
238
|
+
entry = {column: row.get(column) or "" for column in CIF_INDEX_COLUMNS}
|
|
239
|
+
rows[entry["source"], entry["identifier"]] = entry
|
|
240
|
+
|
|
241
|
+
for record in records:
|
|
242
|
+
entry = _index_row(record, notes)
|
|
243
|
+
key = (entry["source"], entry["identifier"])
|
|
244
|
+
previous = rows.get(key)
|
|
245
|
+
if previous is not None and not entry["notes"]:
|
|
246
|
+
entry["notes"] = previous["notes"]
|
|
247
|
+
rows[key] = entry
|
|
248
|
+
|
|
249
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
with path.open("w", newline="", encoding="utf-8") as handle:
|
|
251
|
+
writer = csv.DictWriter(handle, fieldnames=CIF_INDEX_COLUMNS)
|
|
252
|
+
writer.writeheader()
|
|
253
|
+
writer.writerows(rows.values())
|
|
254
|
+
return path
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class SimulatedReflection(NamedTuple):
|
|
258
|
+
"""One line of a simulated powder pattern.
|
|
259
|
+
|
|
260
|
+
``intensity`` is relative to the strongest line in the range simulated,
|
|
261
|
+
taken as 100. ``hkl`` is one representative of the family, with four
|
|
262
|
+
indices for a hexagonal cell; lines of several families at the same angle
|
|
263
|
+
are given by the first.
|
|
264
|
+
"""
|
|
265
|
+
|
|
266
|
+
two_theta: float
|
|
267
|
+
intensity: float
|
|
268
|
+
hkl: tuple[int, ...]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def simulate_pattern(
|
|
272
|
+
cif_path: str | Path,
|
|
273
|
+
wavelength: float = KALPHA1_WAVELENGTH,
|
|
274
|
+
two_theta_range: tuple[float, float] = (10, 100),
|
|
275
|
+
) -> list[SimulatedReflection]:
|
|
276
|
+
"""Simulate the powder pattern of the structure in a CIF.
|
|
277
|
+
|
|
278
|
+
The pattern is pymatgen's ``XRDCalculator`` for the first structure in the
|
|
279
|
+
file, in its conventional cell, for a single wavelength in angstroms (K
|
|
280
|
+
alpha 1 of copper by default), so it has no K alpha 2 lines. Partial
|
|
281
|
+
occupancies are kept, so a disordered site scatters as its average.
|
|
282
|
+
|
|
283
|
+
Raises
|
|
284
|
+
------
|
|
285
|
+
ImportError
|
|
286
|
+
If pymatgen is not installed; it comes with ``xrdkit[phases]``.
|
|
287
|
+
ValueError
|
|
288
|
+
If no structure can be read from the file.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
from pymatgen.analysis.diffraction.xrd import XRDCalculator
|
|
292
|
+
from pymatgen.io.cif import CifParser
|
|
293
|
+
except ImportError as error:
|
|
294
|
+
raise ImportError(
|
|
295
|
+
"simulate_pattern needs pymatgen: install xrdkit[phases]"
|
|
296
|
+
) from error
|
|
297
|
+
|
|
298
|
+
structures = CifParser(cif_path).parse_structures(primitive=False)
|
|
299
|
+
if not structures:
|
|
300
|
+
raise ValueError(f"no structure read from {cif_path}")
|
|
301
|
+
pattern = XRDCalculator(wavelength=wavelength).get_pattern(
|
|
302
|
+
structures[0], scaled=True, two_theta_range=two_theta_range
|
|
303
|
+
)
|
|
304
|
+
return [
|
|
305
|
+
SimulatedReflection(
|
|
306
|
+
two_theta=float(two_theta),
|
|
307
|
+
intensity=float(intensity),
|
|
308
|
+
hkl=tuple(int(index) for index in families[0]["hkl"]),
|
|
309
|
+
)
|
|
310
|
+
for two_theta, intensity, families in zip(
|
|
311
|
+
pattern.x, pattern.y, pattern.hkls, strict=True
|
|
312
|
+
)
|
|
313
|
+
]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
class ExplainedPeak(NamedTuple):
|
|
317
|
+
"""An observed peak and the simulated reflection that accounts for it."""
|
|
318
|
+
|
|
319
|
+
observed: float
|
|
320
|
+
reflection: SimulatedReflection
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def offset(self) -> float:
|
|
324
|
+
"""Observed minus simulated 2theta, in degrees."""
|
|
325
|
+
return self.observed - self.reflection.two_theta
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@dataclass
|
|
329
|
+
class CandidateMatch:
|
|
330
|
+
"""How well a candidate phase's simulated pattern fits observed peaks.
|
|
331
|
+
|
|
332
|
+
``explained`` pairs each observed position a reflection accounts for with
|
|
333
|
+
that reflection, and ``missing`` holds the strong reflections expected
|
|
334
|
+
where nothing was observed. The score is the count explained less the
|
|
335
|
+
count missing, so a phase that explains everything and predicts nothing
|
|
336
|
+
unseen scores highest.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
explained: list[ExplainedPeak] = field(default_factory=list)
|
|
340
|
+
missing: list[SimulatedReflection] = field(default_factory=list)
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def score(self) -> int:
|
|
344
|
+
return len(self.explained) - len(self.missing)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def match_candidate(
|
|
348
|
+
observed_two_theta: Iterable[float],
|
|
349
|
+
simulated: Sequence[SimulatedReflection],
|
|
350
|
+
tolerance: float = 0.05,
|
|
351
|
+
min_intensity: float = 10,
|
|
352
|
+
exclude_two_theta: Iterable[float] | None = None,
|
|
353
|
+
) -> CandidateMatch:
|
|
354
|
+
"""Weigh a candidate phase's simulated pattern against observed peaks.
|
|
355
|
+
|
|
356
|
+
An observed position is explained when a simulated reflection of any
|
|
357
|
+
intensity lies within ``tolerance`` degrees of it; of several, the
|
|
358
|
+
strongest is taken as its cause. A simulated reflection is missing when it
|
|
359
|
+
is stronger than ``min_intensity`` yet lies more than ``tolerance`` from
|
|
360
|
+
every observed position and from every position in ``exclude_two_theta``,
|
|
361
|
+
which is meant for the reflections of the phases already known to be
|
|
362
|
+
present: a line hidden under one of those is not evidence against the
|
|
363
|
+
candidate.
|
|
364
|
+
|
|
365
|
+
Only compare over the range that was measured: a reflection simulated
|
|
366
|
+
outside the observed scan would count as missing.
|
|
367
|
+
|
|
368
|
+
Raises
|
|
369
|
+
------
|
|
370
|
+
ValueError
|
|
371
|
+
If ``tolerance`` is negative.
|
|
372
|
+
"""
|
|
373
|
+
if tolerance < 0:
|
|
374
|
+
raise ValueError(f"tolerance must not be negative, got {tolerance}")
|
|
375
|
+
observed = sorted(float(position) for position in observed_two_theta)
|
|
376
|
+
excluded = [float(position) for position in exclude_two_theta or ()]
|
|
377
|
+
|
|
378
|
+
def near(two_theta: float, positions: Iterable[float]) -> bool:
|
|
379
|
+
return any(abs(two_theta - position) <= tolerance for position in positions)
|
|
380
|
+
|
|
381
|
+
match = CandidateMatch()
|
|
382
|
+
for position in observed:
|
|
383
|
+
within = [r for r in simulated if abs(r.two_theta - position) <= tolerance]
|
|
384
|
+
if within:
|
|
385
|
+
cause = max(
|
|
386
|
+
within, key=lambda r: (r.intensity, -abs(r.two_theta - position))
|
|
387
|
+
)
|
|
388
|
+
match.explained.append(ExplainedPeak(position, cause))
|
|
389
|
+
match.missing = [
|
|
390
|
+
reflection
|
|
391
|
+
for reflection in simulated
|
|
392
|
+
if reflection.intensity > min_intensity
|
|
393
|
+
and not near(reflection.two_theta, observed)
|
|
394
|
+
and not near(reflection.two_theta, excluded)
|
|
395
|
+
]
|
|
396
|
+
return match
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _get(url: str) -> bytes:
|
|
400
|
+
"""Return the body of ``url``, raising on an HTTP error."""
|
|
401
|
+
request = Request(url, headers={"User-Agent": USER_AGENT})
|
|
402
|
+
with urlopen(request, timeout=TIMEOUT_S) as response:
|
|
403
|
+
return response.read()
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _record(entry: Mapping[str, object]) -> CodRecord:
|
|
407
|
+
"""Build a :class:`CodRecord` from one object of the COD's JSON result."""
|
|
408
|
+
return CodRecord(
|
|
409
|
+
cod_id=str(entry["file"]),
|
|
410
|
+
formula=_formula(entry.get("formula")),
|
|
411
|
+
space_group=_text(entry.get("sg")),
|
|
412
|
+
space_group_number=_integer(entry.get("sgNumber")),
|
|
413
|
+
a=_number(entry.get("a")),
|
|
414
|
+
b=_number(entry.get("b")),
|
|
415
|
+
c=_number(entry.get("c")),
|
|
416
|
+
alpha=_number(entry.get("alpha")),
|
|
417
|
+
beta=_number(entry.get("beta")),
|
|
418
|
+
gamma=_number(entry.get("gamma")),
|
|
419
|
+
# "volume" in the COD result is the journal volume; the cell's is "vol".
|
|
420
|
+
volume=_number(entry.get("vol")),
|
|
421
|
+
authors=_text(entry.get("authors")) or "",
|
|
422
|
+
journal=_text(entry.get("journal")) or "",
|
|
423
|
+
year=_integer(entry.get("year")),
|
|
424
|
+
doi=_text(entry.get("doi")),
|
|
425
|
+
temperature=_number(entry.get("celltemp")),
|
|
426
|
+
pressure=_number(entry.get("cellpressure")),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _index_row(
|
|
431
|
+
record: CodRecord | Mapping[str, object], notes: str = ""
|
|
432
|
+
) -> dict[str, str]:
|
|
433
|
+
"""Return the index row of one record, every column as text.
|
|
434
|
+
|
|
435
|
+
``notes`` is used for a :class:`CodRecord` only; a mapping brings its own.
|
|
436
|
+
"""
|
|
437
|
+
if isinstance(record, CodRecord):
|
|
438
|
+
values: Mapping[str, object] = {
|
|
439
|
+
"file": record.filename,
|
|
440
|
+
"source": "COD",
|
|
441
|
+
"identifier": record.cod_id,
|
|
442
|
+
"formula": record.formula,
|
|
443
|
+
"space_group": record.space_group,
|
|
444
|
+
"a": record.a,
|
|
445
|
+
"b": record.b,
|
|
446
|
+
"c": record.c,
|
|
447
|
+
"alpha": record.alpha,
|
|
448
|
+
"beta": record.beta,
|
|
449
|
+
"gamma": record.gamma,
|
|
450
|
+
"reference": record.reference(),
|
|
451
|
+
"notes": notes,
|
|
452
|
+
}
|
|
453
|
+
else:
|
|
454
|
+
unknown = sorted(set(record) - set(CIF_INDEX_COLUMNS))
|
|
455
|
+
if unknown:
|
|
456
|
+
raise ValueError(f"not index columns: {', '.join(unknown)}")
|
|
457
|
+
values = record
|
|
458
|
+
row = {
|
|
459
|
+
column: "" if values.get(column) is None else str(values[column])
|
|
460
|
+
for column in CIF_INDEX_COLUMNS
|
|
461
|
+
}
|
|
462
|
+
if not row["source"] or not row["identifier"]:
|
|
463
|
+
raise ValueError(f"index row needs a source and an identifier: {row}")
|
|
464
|
+
return row
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _normalise_symbol(symbol: str) -> str:
|
|
468
|
+
"""Strip spaces and a trailing setting from a Hermann-Mauguin symbol."""
|
|
469
|
+
symbol = re.sub(r"\(.*\)\s*$", "", symbol)
|
|
470
|
+
symbol = symbol.split(":", 1)[0]
|
|
471
|
+
return re.sub(r"\s+", "", symbol)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _formula(value: object) -> str:
|
|
475
|
+
"""Drop the dashes the COD puts round a formula: '- Nb O3 Sr -' to 'Nb O3 Sr'."""
|
|
476
|
+
return (_text(value) or "").strip("- ").strip()
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _text(value: object) -> str | None:
|
|
480
|
+
if value is None:
|
|
481
|
+
return None
|
|
482
|
+
text = str(value).strip()
|
|
483
|
+
return text or None
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _number(value: object) -> float | None:
|
|
487
|
+
# The COD gives esds in separate fields, but drop a '(3)' in case one slips in.
|
|
488
|
+
text = _text(value)
|
|
489
|
+
return None if text is None else float(re.sub(r"\(\d+\)$", "", text))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _integer(value: object) -> int | None:
|
|
493
|
+
text = _text(value)
|
|
494
|
+
return None if text is None else int(text)
|