httk-io 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.
- httk/handlers/io/__init__.py +7 -0
- httk/handlers/io/py.typed +0 -0
- httk/io/__init__.py +3 -0
- httk/io/cif/__init__.py +30 -0
- httk/io/cif/cif_parser.py +562 -0
- httk/io/cif/cif_reader.py +318 -0
- httk/io/cif/cif_writer.py +284 -0
- httk/io/cif/expand_asu.py +558 -0
- httk/io/cif/mcif_parser.py +682 -0
- httk/io/cif/symops_utils.py +36 -0
- httk/io/py.typed +0 -0
- httk_io-0.1.0.dist-info/METADATA +82 -0
- httk_io-0.1.0.dist-info/RECORD +16 -0
- httk_io-0.1.0.dist-info/WHEEL +5 -0
- httk_io-0.1.0.dist-info/licenses/LICENSE +661 -0
- httk_io-0.1.0.dist-info/top_level.txt +1 -0
|
File without changes
|
httk/io/__init__.py
ADDED
httk/io/cif/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#
|
|
2
|
+
# The high-throughput toolkit (httk)
|
|
3
|
+
# Copyright (C) 2012-2025 The httk AUTHORS
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU Affero General Public License as
|
|
7
|
+
# published by the Free Software Foundation, either version 3 of the
|
|
8
|
+
# License, or (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU Affero General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
from .cif_parser import asus_from_cif_file, single_asu_from_cif_file
|
|
19
|
+
from .cif_reader import read_cif
|
|
20
|
+
from .expand_asu import cif_to_struct
|
|
21
|
+
from .mcif_parser import mag_asus_from_mcif_file, single_mag_asu_from_mcif_file
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"asus_from_cif_file",
|
|
25
|
+
"cif_to_struct",
|
|
26
|
+
"mag_asus_from_mcif_file",
|
|
27
|
+
"read_cif",
|
|
28
|
+
"single_asu_from_cif_file",
|
|
29
|
+
"single_mag_asu_from_mcif_file",
|
|
30
|
+
]
|
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
#
|
|
2
|
+
# The high-throughput toolkit (httk)
|
|
3
|
+
# Copyright (C) 2012-2025 The httk AUTHORS
|
|
4
|
+
#
|
|
5
|
+
# This program is free software: you can redistribute it and/or modify
|
|
6
|
+
# it under the terms of the GNU Affero General Public License as
|
|
7
|
+
# published by the Free Software Foundation, either version 3 of the
|
|
8
|
+
# License, or (at your option) any later version.
|
|
9
|
+
#
|
|
10
|
+
# This program is distributed in the hope that it will be useful,
|
|
11
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
12
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
13
|
+
# GNU Affero General Public License for more details.
|
|
14
|
+
#
|
|
15
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
16
|
+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
17
|
+
|
|
18
|
+
import itertools
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from decimal import Decimal
|
|
22
|
+
from fractions import Fraction
|
|
23
|
+
from typing import Any, Literal, TypedDict, overload
|
|
24
|
+
|
|
25
|
+
from .cif_reader import read_cif
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CifMeta(TypedDict):
|
|
29
|
+
"""Metadata returned by :func:`parse_cif_float` when ``meta=True``."""
|
|
30
|
+
|
|
31
|
+
esd: float | None
|
|
32
|
+
resolution: float
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# Regexp close to https://www.iucr.org/__data/iucr/cifdic_html/2/cif_mm.dic/Dtypecodes.html
|
|
36
|
+
# matches: 1.234(5), -12.3(12), 3(1)E2, 1.0e-3, +4.2, etc.
|
|
37
|
+
_CIF_NUM_RE = re.compile(
|
|
38
|
+
r'^(?P<sign>-)?' # optional leading minus
|
|
39
|
+
r'(?P<mant>(?:\d+\.?|\d*\.\d+))(\((?P<esd>\d+)\))?' # mantissa + optional (uncertainty)
|
|
40
|
+
r'(?:[eE](?P<exp>[+-]?\d+))?$' # optional exponent
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _literal_resolution(txt: str) -> float:
|
|
45
|
+
"""
|
|
46
|
+
Resolution implied by the literal format of txt (no ESD logic).
|
|
47
|
+
|
|
48
|
+
- '1/3' -> 0.0 (treated as exact)
|
|
49
|
+
- '0.123' -> 1e-3
|
|
50
|
+
- '5.' -> 1.0
|
|
51
|
+
- '.25' -> 1e-2
|
|
52
|
+
- '10' -> 1.0
|
|
53
|
+
"""
|
|
54
|
+
if txt is None:
|
|
55
|
+
return 0.0
|
|
56
|
+
s = txt.strip()
|
|
57
|
+
if not s:
|
|
58
|
+
return 0.0
|
|
59
|
+
|
|
60
|
+
# Strip leading sign
|
|
61
|
+
if s[0] in "+-":
|
|
62
|
+
s = s[1:]
|
|
63
|
+
|
|
64
|
+
# Fractions -> exact
|
|
65
|
+
if "/" in s:
|
|
66
|
+
try:
|
|
67
|
+
Fraction(s) # Only to validate it
|
|
68
|
+
return 0.0
|
|
69
|
+
except Exception:
|
|
70
|
+
return 0.0
|
|
71
|
+
|
|
72
|
+
# No exponent here if we use mant_str; but be robust
|
|
73
|
+
s = s.lower()
|
|
74
|
+
if "e" in s:
|
|
75
|
+
s, _ = s.split("e", 1)
|
|
76
|
+
|
|
77
|
+
if "." in s:
|
|
78
|
+
before, after = s.split(".", 1)
|
|
79
|
+
# malformed: just ignore non-digits
|
|
80
|
+
digits = "".join(ch for ch in after if ch.isdigit())
|
|
81
|
+
if not digits: # '5.' or '.' or '.e3'
|
|
82
|
+
return 1.0
|
|
83
|
+
return 10.0 ** (-len(digits))
|
|
84
|
+
else:
|
|
85
|
+
# Integer literal
|
|
86
|
+
return 1.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@overload
|
|
90
|
+
def parse_cif_float(token: str, *, meta: Literal[False] = ..., pragmatic: bool = ...) -> float | None: ...
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@overload
|
|
94
|
+
def parse_cif_float(token: str, *, meta: Literal[True], pragmatic: bool = ...) -> tuple[float | None, CifMeta]: ...
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_cif_float(
|
|
98
|
+
token: str, *, meta: bool = False, pragmatic: bool = False
|
|
99
|
+
) -> float | None | tuple[float | None, CifMeta]:
|
|
100
|
+
"""
|
|
101
|
+
Parse a CIF numeric field.
|
|
102
|
+
|
|
103
|
+
If meta=False:
|
|
104
|
+
return float_value or None.
|
|
105
|
+
|
|
106
|
+
If meta=True:
|
|
107
|
+
return (float_value_or_None, {'esd': esd_or_None, 'resolution': float_resolution}).
|
|
108
|
+
"""
|
|
109
|
+
if token is None:
|
|
110
|
+
raise Exception("parse_cif_float parsing None")
|
|
111
|
+
|
|
112
|
+
t = token.strip()
|
|
113
|
+
if t == '?':
|
|
114
|
+
if meta:
|
|
115
|
+
return None, {'esd': None, 'resolution': 0.0}
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
if t in ('.', ''):
|
|
119
|
+
raise Exception("Missing cif value cannot be conveted to float")
|
|
120
|
+
|
|
121
|
+
# Replace unicode minus
|
|
122
|
+
if any(ch in t for ch in ("\u2212", "\u2013", "\u2014")):
|
|
123
|
+
if pragmatic:
|
|
124
|
+
t = t.replace("\u2212", "-").replace("\u2013", "-").replace("\u2014", "-")
|
|
125
|
+
else:
|
|
126
|
+
raise Exception("Cif contains non-ascii minus sign: " + str(t))
|
|
127
|
+
|
|
128
|
+
m = _CIF_NUM_RE.match(t)
|
|
129
|
+
|
|
130
|
+
if not m:
|
|
131
|
+
# fractions are allowed here too
|
|
132
|
+
try:
|
|
133
|
+
if "/" in t:
|
|
134
|
+
val = float(Fraction(t))
|
|
135
|
+
else:
|
|
136
|
+
val = float(t)
|
|
137
|
+
except Exception:
|
|
138
|
+
# last resort: grab first float-looking chunk
|
|
139
|
+
val = float(re.split(r'([0-9]*(\.[0-9]+)?)', t)[1])
|
|
140
|
+
|
|
141
|
+
res = _literal_resolution(t)
|
|
142
|
+
if meta:
|
|
143
|
+
return val, {'esd': None, 'resolution': res}
|
|
144
|
+
return val
|
|
145
|
+
|
|
146
|
+
# ---- Normal CIF number ----
|
|
147
|
+
sign = -1 if m.group('sign') == '-' else 1
|
|
148
|
+
mant_str = m.group('mant')
|
|
149
|
+
mant = Decimal(mant_str)
|
|
150
|
+
exp = int(m.group('exp') or '0')
|
|
151
|
+
val = float(sign * mant * (Decimal(10) ** exp))
|
|
152
|
+
|
|
153
|
+
# resolution from mantissa literal
|
|
154
|
+
res = _literal_resolution(mant_str) * (10.0**exp)
|
|
155
|
+
|
|
156
|
+
esd_str = m.group('esd')
|
|
157
|
+
if not meta:
|
|
158
|
+
# Ignore esd; user didn't ask for meta info
|
|
159
|
+
return val
|
|
160
|
+
|
|
161
|
+
if esd_str is not None:
|
|
162
|
+
# Classic CIF esd logic
|
|
163
|
+
if '.' in mant_str:
|
|
164
|
+
dec_places = len(mant_str.split('.', 1)[1])
|
|
165
|
+
else:
|
|
166
|
+
dec_places = 0
|
|
167
|
+
esd_abs = Decimal(int(esd_str)) * (Decimal(10) ** (exp - dec_places))
|
|
168
|
+
esd_val = float(esd_abs)
|
|
169
|
+
else:
|
|
170
|
+
esd_val = None
|
|
171
|
+
|
|
172
|
+
return val, {'esd': esd_val, 'resolution': res}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def parse_cif_int(token: str, *, strict: bool = True, allow_round: bool = False) -> int:
|
|
176
|
+
"""
|
|
177
|
+
Convert a CIF numeric token (e.g., '123(4)', '3E2', '1.0E3') to an int using the central value.
|
|
178
|
+
- strict=True: require the value to be exactly integral; otherwise raise ValueError.
|
|
179
|
+
- allow_round=True (only if strict=False): round half-even to the nearest int.
|
|
180
|
+
"""
|
|
181
|
+
t = token.strip()
|
|
182
|
+
if t in ('.', '?', ''):
|
|
183
|
+
raise ValueError("Missing CIF value cannot be converted to int")
|
|
184
|
+
|
|
185
|
+
m = _CIF_NUM_RE.match(t)
|
|
186
|
+
if not m:
|
|
187
|
+
# Fall back for plain integers without (esd)/exponent; will raise if not int-like
|
|
188
|
+
val = Decimal(t)
|
|
189
|
+
else:
|
|
190
|
+
sign = -1 if m.group('sign') == '-' else 1
|
|
191
|
+
mant = Decimal(m.group('mant'))
|
|
192
|
+
exp = int(m.group('exp') or '0')
|
|
193
|
+
val = sign * mant * (Decimal(10) ** exp)
|
|
194
|
+
|
|
195
|
+
# Decide how to coerce
|
|
196
|
+
if strict:
|
|
197
|
+
# exactly integral?
|
|
198
|
+
if val == val.to_integral_value(): # no fractional part
|
|
199
|
+
return int(val)
|
|
200
|
+
raise ValueError(f"Non-integer numeric cannot be coerced strictly: {token!r}")
|
|
201
|
+
else:
|
|
202
|
+
# Non-strict: either require integral or allow rounding
|
|
203
|
+
if val == val.to_integral_value():
|
|
204
|
+
return int(val)
|
|
205
|
+
if allow_round:
|
|
206
|
+
return int(val.to_integral_value()) # banker's rounding (half-even)
|
|
207
|
+
raise ValueError(f"Non-integer numeric (set allow_round=True to round): {token!r}")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def parse_linear_expr(expr, use_fractions=False):
|
|
211
|
+
"""
|
|
212
|
+
expr: e.g. 'x-y', '-z+1/2', 'x', 'y', 'z-1', 'x-2y', '3x+1/2'
|
|
213
|
+
Returns (row, const) where row is [ax, ay, az] (integers or Fractions),
|
|
214
|
+
and const is a float or Fraction depending on use_fractions.
|
|
215
|
+
"""
|
|
216
|
+
s = expr.replace(" ", "")
|
|
217
|
+
if not s:
|
|
218
|
+
raise ValueError("Empty expression")
|
|
219
|
+
if s[0] not in "+-":
|
|
220
|
+
s = "+" + s
|
|
221
|
+
|
|
222
|
+
coeffs: dict[str, Fraction | float | int] = {'x': 0, 'y': 0, 'z': 0}
|
|
223
|
+
const = Fraction(0) if use_fractions else 0.0
|
|
224
|
+
|
|
225
|
+
# ([sign]) ( [optional number] [var] | number )
|
|
226
|
+
token_re = r'([+-])(?:(?:(?:(\d+(?:/\d+)?|\d*\.\d+)?)' r'(x|y|z))|((?:\d+/\d+)|(?:\d+(?:\.\d+)?)))'
|
|
227
|
+
|
|
228
|
+
pos = 0
|
|
229
|
+
for m in re.finditer(token_re, s):
|
|
230
|
+
if m.start() != pos:
|
|
231
|
+
# There are leftover characters -> invalid tokenization
|
|
232
|
+
raise ValueError(f"Unparsed tail in '{expr}' near '{s[pos:]}'")
|
|
233
|
+
pos = m.end()
|
|
234
|
+
|
|
235
|
+
sign, coef_str, var, num = m.groups()
|
|
236
|
+
sgn = 1 if sign == '+' else -1
|
|
237
|
+
|
|
238
|
+
if var is not None:
|
|
239
|
+
# variable term ± (coef or 1) * var
|
|
240
|
+
if coef_str in (None, ""):
|
|
241
|
+
coef_val = 1
|
|
242
|
+
else:
|
|
243
|
+
coef_val = Fraction(coef_str) if '/' in coef_str else float(coef_str)
|
|
244
|
+
if use_fractions and not isinstance(coef_val, Fraction):
|
|
245
|
+
coef_val = Fraction(coef_val)
|
|
246
|
+
coeffs[var] += sgn * coef_val
|
|
247
|
+
else:
|
|
248
|
+
# standalone numeric translation
|
|
249
|
+
if use_fractions:
|
|
250
|
+
val = Fraction(num) if '/' in num else Fraction(num)
|
|
251
|
+
else:
|
|
252
|
+
val = float(Fraction(num)) if '/' in num else float(num)
|
|
253
|
+
const += sgn * val
|
|
254
|
+
|
|
255
|
+
if pos != len(s):
|
|
256
|
+
raise ValueError(f"Unparsed tail in '{expr}' near '{s[pos:]}'")
|
|
257
|
+
|
|
258
|
+
# Ensure output constant has requested type
|
|
259
|
+
const_out = const if use_fractions else float(const)
|
|
260
|
+
|
|
261
|
+
# Order as (x,y,z)
|
|
262
|
+
return (coeffs['x'], coeffs['y'], coeffs['z']), const_out
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def parse_xyz_op(op, use_fractions=False):
|
|
266
|
+
"""
|
|
267
|
+
op: e.g. 'x-y,x,-z+1/2'
|
|
268
|
+
Returns (R, t) where R is 3x3 (list of rows), t is length-3 float list
|
|
269
|
+
"""
|
|
270
|
+
parts = [p.strip() for p in op.split(",")]
|
|
271
|
+
if len(parts) != 3:
|
|
272
|
+
raise ValueError(f"Unexpected op format: {op}")
|
|
273
|
+
px, py, pz = parts
|
|
274
|
+
rx, tx = parse_linear_expr(px, use_fractions=use_fractions)
|
|
275
|
+
ry, ty = parse_linear_expr(py, use_fractions=use_fractions)
|
|
276
|
+
rz, tz = parse_linear_expr(pz, use_fractions=use_fractions)
|
|
277
|
+
return (rx, ry, rz), (tx, ty, tz)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def xyz_symops_to_matrix(symops_xyz, use_fractions=False):
|
|
281
|
+
return [parse_xyz_op(s, use_fractions) for s in symops_xyz]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_atoms(block, resolution=True) -> tuple[Any, ...]:
|
|
285
|
+
"""
|
|
286
|
+
Returns:
|
|
287
|
+
if resolution == False:
|
|
288
|
+
(symbols, labels, positions, occupancies)
|
|
289
|
+
if resolution == True:
|
|
290
|
+
(symbols, labels, positions, occupancies, res)
|
|
291
|
+
|
|
292
|
+
positions: list of (x, y, z) floats
|
|
293
|
+
occupancies:
|
|
294
|
+
- list of floats (same length as symbols) if '_atom_site_occupancy' exists
|
|
295
|
+
- None otherwise
|
|
296
|
+
"""
|
|
297
|
+
syms = block.get('atom_site_type_symbol')
|
|
298
|
+
lbs = block.get('atom_site_label')
|
|
299
|
+
xs = block.get('atom_site_fract_x')
|
|
300
|
+
ys = block.get('atom_site_fract_y')
|
|
301
|
+
zs = block.get('atom_site_fract_z')
|
|
302
|
+
|
|
303
|
+
n = len(xs)
|
|
304
|
+
assert len(ys) == len(zs) == len(lbs) == len(syms) == n
|
|
305
|
+
|
|
306
|
+
# Optional occupancy column
|
|
307
|
+
occ_col = block.get('atom_site_occupancy')
|
|
308
|
+
if occ_col is not None:
|
|
309
|
+
occs = []
|
|
310
|
+
for t in occ_col:
|
|
311
|
+
v = parse_cif_float(t, meta=False)
|
|
312
|
+
occs.append(v)
|
|
313
|
+
else:
|
|
314
|
+
occs = None
|
|
315
|
+
|
|
316
|
+
symbols = [s.strip() for s in syms]
|
|
317
|
+
labels = [lab.strip() for lab in lbs]
|
|
318
|
+
|
|
319
|
+
# Fast path: no resolution / grid requested
|
|
320
|
+
if not resolution:
|
|
321
|
+
positions = [
|
|
322
|
+
(
|
|
323
|
+
parse_cif_float(xi, meta=False),
|
|
324
|
+
parse_cif_float(yi, meta=False),
|
|
325
|
+
parse_cif_float(zi, meta=False),
|
|
326
|
+
)
|
|
327
|
+
for xi, yi, zi in zip(xs, ys, zs)
|
|
328
|
+
]
|
|
329
|
+
return symbols, labels, positions, occs
|
|
330
|
+
|
|
331
|
+
# Full path with resolution
|
|
332
|
+
positions = []
|
|
333
|
+
coord_resolutions = []
|
|
334
|
+
|
|
335
|
+
for xi, yi, zi in zip(xs, ys, zs):
|
|
336
|
+
vx, mx = parse_cif_float(xi, meta=True)
|
|
337
|
+
vy, my = parse_cif_float(yi, meta=True)
|
|
338
|
+
vz, mz = parse_cif_float(zi, meta=True)
|
|
339
|
+
|
|
340
|
+
positions.append((vx, vy, vz))
|
|
341
|
+
coord_resolutions.extend(
|
|
342
|
+
[
|
|
343
|
+
mx.get('resolution', 0.0),
|
|
344
|
+
my.get('resolution', 0.0),
|
|
345
|
+
mz.get('resolution', 0.0),
|
|
346
|
+
]
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
# 1) Data-implied resolution: take the COARSEST (largest) non-zero resolution.
|
|
350
|
+
finite_res = [r for r in coord_resolutions if r is not None]
|
|
351
|
+
if finite_res:
|
|
352
|
+
data_resolution = max(finite_res)
|
|
353
|
+
else:
|
|
354
|
+
data_resolution = 0.0 # no constraint from data formatting
|
|
355
|
+
|
|
356
|
+
# 2) Separation resolution from coordinates (fractional, so use periodic deltas)
|
|
357
|
+
if n > 1:
|
|
358
|
+
|
|
359
|
+
def periodic_delta(a, b):
|
|
360
|
+
d = abs(a - b)
|
|
361
|
+
return min(d, 1.0 - d) # fractional periodicity
|
|
362
|
+
|
|
363
|
+
eps = data_resolution / 10 if data_resolution > 0.0 else 1e-12
|
|
364
|
+
|
|
365
|
+
# compute deltas for each axis
|
|
366
|
+
deltas_x = [periodic_delta(positions[i][0], positions[j][0]) for i, j in itertools.combinations(range(n), 2)]
|
|
367
|
+
deltas_y = [periodic_delta(positions[i][1], positions[j][1]) for i, j in itertools.combinations(range(n), 2)]
|
|
368
|
+
deltas_z = [periodic_delta(positions[i][2], positions[j][2]) for i, j in itertools.combinations(range(n), 2)]
|
|
369
|
+
|
|
370
|
+
# filter out zero-ish separations using adaptive epsilon
|
|
371
|
+
pos_x = [d for d in deltas_x if d > eps]
|
|
372
|
+
pos_y = [d for d in deltas_y if d > eps]
|
|
373
|
+
pos_z = [d for d in deltas_z if d > eps]
|
|
374
|
+
|
|
375
|
+
sep_x = min(pos_x) if pos_x else float('inf')
|
|
376
|
+
sep_y = min(pos_y) if pos_y else float('inf')
|
|
377
|
+
sep_z = min(pos_z) if pos_z else float('inf')
|
|
378
|
+
|
|
379
|
+
min_sep = min(sep_x, sep_y, sep_z)
|
|
380
|
+
|
|
381
|
+
if math.isinf(min_sep):
|
|
382
|
+
separation_resolution = float('inf')
|
|
383
|
+
else:
|
|
384
|
+
separation_resolution = min_sep / 2.0
|
|
385
|
+
|
|
386
|
+
else:
|
|
387
|
+
separation_resolution = float('inf')
|
|
388
|
+
|
|
389
|
+
# 3) Final res
|
|
390
|
+
if separation_resolution == float('inf'):
|
|
391
|
+
res = data_resolution
|
|
392
|
+
elif data_resolution == 0.0:
|
|
393
|
+
res = separation_resolution
|
|
394
|
+
else:
|
|
395
|
+
res = min(data_resolution, separation_resolution)
|
|
396
|
+
|
|
397
|
+
# grid should sit in [0, 1], but fractional coords guarantee that anyway.
|
|
398
|
+
return symbols, labels, positions, occs, res
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _parse_uc(block):
|
|
402
|
+
a = parse_cif_float(block.get('cell_length_a'))
|
|
403
|
+
b = parse_cif_float(block.get('cell_length_b'))
|
|
404
|
+
c = parse_cif_float(block.get('cell_length_c'))
|
|
405
|
+
alpha = parse_cif_float(block.get('cell_angle_alpha'))
|
|
406
|
+
beta = parse_cif_float(block.get('cell_angle_beta'))
|
|
407
|
+
gamma = parse_cif_float(block.get('cell_angle_gamma'))
|
|
408
|
+
return a, b, c, alpha, beta, gamma
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _basis_from_lengths_angles(a, b, c, alpha, beta, gamma):
|
|
412
|
+
"""
|
|
413
|
+
Conventional 3x3 lattice (rows are a,b,c in Cartesian Å) from a,b,c (Å) and angles (deg).
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def _deg2rad(d):
|
|
417
|
+
return d * math.pi / 180.0
|
|
418
|
+
|
|
419
|
+
alpha, beta, gamma = map(_deg2rad, (alpha, beta, gamma))
|
|
420
|
+
ca, cb, cg = math.cos(alpha), math.cos(beta), math.cos(gamma)
|
|
421
|
+
sg = math.sin(gamma)
|
|
422
|
+
|
|
423
|
+
ax, ay, az = a, 0.0, 0.0
|
|
424
|
+
bx, by, bz = b * cg, b * sg, 0.0
|
|
425
|
+
# cz via the standard formula for triclinic cells
|
|
426
|
+
cx = c * cb
|
|
427
|
+
cy = c * (ca - cb * cg) / (sg if abs(sg) > 1e-12 else 1.0)
|
|
428
|
+
cz_sq = c**2 - cx**2 - cy**2
|
|
429
|
+
cz = math.sqrt(max(cz_sq, 0.0))
|
|
430
|
+
return [[ax, ay, az], [bx, by, bz], [cx, cy, cz]]
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def parse_asu_cell(cifblock):
|
|
434
|
+
a, b, c, alpha, beta, gamma = _parse_uc(cifblock)
|
|
435
|
+
basis = _basis_from_lengths_angles(a, b, c, alpha, beta, gamma)
|
|
436
|
+
symbols, labels, positions, occs, res = _parse_atoms(cifblock, resolution=True)
|
|
437
|
+
|
|
438
|
+
# figure out equivalent atoms based on labels
|
|
439
|
+
labels_map = {}
|
|
440
|
+
equivalent_atoms = []
|
|
441
|
+
next_id = 1
|
|
442
|
+
for lab in labels:
|
|
443
|
+
if lab not in labels_map:
|
|
444
|
+
labels_map[lab] = next_id
|
|
445
|
+
next_id += 1
|
|
446
|
+
equivalent_atoms.append(labels_map[lab])
|
|
447
|
+
|
|
448
|
+
return basis, positions, res, symbols, labels, equivalent_atoms
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def parse_structural_modulation(cifblock):
|
|
452
|
+
"""
|
|
453
|
+
Extract structural superspace modulation information from a standard CIF.
|
|
454
|
+
|
|
455
|
+
Returns a tuple ``(structural_q, mod_dim, has_struct_mod, struct_mod_atoms)`` where
|
|
456
|
+
``structural_q`` is a list of q-vectors or ``None``, ``mod_dim`` is the modulation
|
|
457
|
+
dimension (0 if absent), ``has_struct_mod`` is a bool, and ``struct_mod_atoms`` is a
|
|
458
|
+
sorted list of atom-site labels.
|
|
459
|
+
"""
|
|
460
|
+
# modulation dimension (0 if absent)
|
|
461
|
+
mod_dim = int(cifblock.get('cell_modulation_dimension', 0))
|
|
462
|
+
|
|
463
|
+
# structural_q from cell_wave_vector (only if mod_dim > 0)
|
|
464
|
+
structural_q = None
|
|
465
|
+
qx = cifblock.get('_cell_wave_vector_x')
|
|
466
|
+
qy = cifblock.get('_cell_wave_vector_y')
|
|
467
|
+
qz = cifblock.get('_cell_wave_vector_z')
|
|
468
|
+
if qx and qy and qz:
|
|
469
|
+
structural_q = [[float(qx[i]), float(qy[i]), float(qz[i])] for i in range(len(qx))]
|
|
470
|
+
|
|
471
|
+
# detect structural Fourier modulations
|
|
472
|
+
has_struct_mod = False
|
|
473
|
+
struct_mod_atoms = set()
|
|
474
|
+
|
|
475
|
+
labels = cifblock.get('_atom_site_displace_Fourier.atom_site_label')
|
|
476
|
+
if labels:
|
|
477
|
+
has_struct_mod = True
|
|
478
|
+
struct_mod_atoms.update(labels)
|
|
479
|
+
|
|
480
|
+
labels = cifblock.get('_atom_site_occupancy_Fourier.atom_site_label')
|
|
481
|
+
if labels:
|
|
482
|
+
has_struct_mod = True
|
|
483
|
+
struct_mod_atoms.update(labels)
|
|
484
|
+
|
|
485
|
+
return structural_q, mod_dim, has_struct_mod, sorted(struct_mod_atoms)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cifblock_to_asu(cifblock, *, return_single=False):
|
|
489
|
+
|
|
490
|
+
# basic atom-site parsing
|
|
491
|
+
basis, positions, resolution, symbols, labels, equivalent_atoms = parse_asu_cell(cifblock)
|
|
492
|
+
|
|
493
|
+
# standard space group symmetry
|
|
494
|
+
symops_xyz = cifblock.get('space_group_symop.operation_xyz')
|
|
495
|
+
if symops_xyz is None:
|
|
496
|
+
# Some readers normalize loop tags without dots, so accept that too.
|
|
497
|
+
symops_xyz = cifblock.get('space_group_symop_operation_xyz')
|
|
498
|
+
if symops_xyz is None:
|
|
499
|
+
# some CIFs use older spelling
|
|
500
|
+
symops_xyz = cifblock.get('symmetry_equiv_pos_as_xyz')
|
|
501
|
+
|
|
502
|
+
if symops_xyz is None:
|
|
503
|
+
raise Exception("No symmetry operations in CIF.")
|
|
504
|
+
|
|
505
|
+
symops = xyz_symops_to_matrix(symops_xyz, use_fractions=True)
|
|
506
|
+
|
|
507
|
+
# structural modulation
|
|
508
|
+
structural_q, mod_dim, has_struct_mod, struct_atoms = parse_structural_modulation(cifblock)
|
|
509
|
+
|
|
510
|
+
# Build the incommensurate structure descriptor, or None
|
|
511
|
+
incomm = None
|
|
512
|
+
if mod_dim > 0 or structural_q or has_struct_mod:
|
|
513
|
+
incomm = {
|
|
514
|
+
'structural_q': structural_q,
|
|
515
|
+
'mod_dim': mod_dim,
|
|
516
|
+
'has_structural_modulation': has_struct_mod,
|
|
517
|
+
'structural_modulated_atoms': struct_atoms,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
space_group_name_hm = cifblock.get('_space_group_name_H-M_alt') or cifblock.get('_symmetry_space_group_name_H-M')
|
|
521
|
+
space_group_name_hall = cifblock.get('_space_group_name_Hall') or cifblock.get('_symmetry_space_group_name_Hall')
|
|
522
|
+
space_group_nbr = cifblock.get('_space_group_IT_number') or cifblock.get('symmetry_space_group_IT_number')
|
|
523
|
+
icsd = cifblock.get('database_code_ICSD')
|
|
524
|
+
doi = cifblock.get('citation_doi')
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
'basis': basis,
|
|
528
|
+
'positions': positions,
|
|
529
|
+
'symbols': symbols,
|
|
530
|
+
'symops': symops,
|
|
531
|
+
'incomm': incomm,
|
|
532
|
+
'space_group_nbr': space_group_nbr,
|
|
533
|
+
'space_group_name_hm': space_group_name_hm,
|
|
534
|
+
'space_group_name_hall': space_group_name_hall,
|
|
535
|
+
'icsd': icsd,
|
|
536
|
+
'doi': doi,
|
|
537
|
+
'resolution': resolution,
|
|
538
|
+
'equivalent_atoms': equivalent_atoms,
|
|
539
|
+
'labels': labels,
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def asus_from_cif_file(fs):
|
|
544
|
+
cifblocks, header = read_cif(fs, allow_cif2=False)
|
|
545
|
+
|
|
546
|
+
outputs = []
|
|
547
|
+
for name, cifblock in cifblocks:
|
|
548
|
+
outputs += [cifblock_to_asu(cifblock)]
|
|
549
|
+
return outputs
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def single_asu_from_cif_file(fs):
|
|
553
|
+
cifblocks, header = read_cif(fs, allow_cif2=False)
|
|
554
|
+
|
|
555
|
+
# Get the first cifblock with atomic sites
|
|
556
|
+
for name, cifblock in cifblocks:
|
|
557
|
+
if 'atom_site_label' in cifblock:
|
|
558
|
+
break
|
|
559
|
+
else:
|
|
560
|
+
raise Exception("No structural block found in CIF.")
|
|
561
|
+
|
|
562
|
+
return cifblock_to_asu(cifblock)
|