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