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.
@@ -0,0 +1,7 @@
1
+ from httk.core.register import register_loader
2
+
3
+ register_loader(
4
+ name="cif",
5
+ loader="httk.io.cif:read_cif",
6
+ extensions=(".cif",),
7
+ )
File without changes
httk/io/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cif.cif_reader import read_cif # importing registers the loader
2
+
3
+ __all__ = ["read_cif"]
@@ -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)