netsunhinged 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.
Files changed (35) hide show
  1. netsunhinged/__init__.py +18 -0
  2. netsunhinged/__main__.py +83 -0
  3. netsunhinged/cif.py +530 -0
  4. netsunhinged/deform.py +367 -0
  5. netsunhinged/elements.py +47 -0
  6. netsunhinged/examples/DMOF-1_COD-7038645.cif +353 -0
  7. netsunhinged/examples/HKUST-1_COD-4128729.cif +458 -0
  8. netsunhinged/examples/MIL-53-Ga_COD-7000776.cif +102 -0
  9. netsunhinged/examples/MOF-5_COD-7240544.cif +429 -0
  10. netsunhinged/examples/Natrolite_COD-9013163.cif +99 -0
  11. netsunhinged/examples/README.md +34 -0
  12. netsunhinged/examples/ZIF-7_COD-4121899.cif +465 -0
  13. netsunhinged/examples/ZIF-8_COD-7111971.cif +416 -0
  14. netsunhinged/examples/Zeolite-A_COD-1532589.cif +166 -0
  15. netsunhinged/examples/Zeolite-Rho_COD-1530545.cif +113 -0
  16. netsunhinged/examples/catalog.json +90 -0
  17. netsunhinged/examples/rho-ZnIm2_COD-4132678.cif +390 -0
  18. netsunhinged/geometry.py +151 -0
  19. netsunhinged/model.py +454 -0
  20. netsunhinged/rigidity.py +234 -0
  21. netsunhinged/server.py +661 -0
  22. netsunhinged/static/app.js +1257 -0
  23. netsunhinged/static/index.html +168 -0
  24. netsunhinged/static/style.css +162 -0
  25. netsunhinged/static/vendor/three/LICENSE +21 -0
  26. netsunhinged/static/vendor/three/addons/controls/OrbitControls.js +1417 -0
  27. netsunhinged/static/vendor/three/three.module.min.js +6 -0
  28. netsunhinged/structure.py +532 -0
  29. netsunhinged-0.1.0.dist-info/METADATA +152 -0
  30. netsunhinged-0.1.0.dist-info/RECORD +35 -0
  31. netsunhinged-0.1.0.dist-info/WHEEL +5 -0
  32. netsunhinged-0.1.0.dist-info/entry_points.txt +2 -0
  33. netsunhinged-0.1.0.dist-info/licenses/LICENSE +30 -0
  34. netsunhinged-0.1.0.dist-info/licenses/netsunhinged/static/vendor/three/LICENSE +21 -0
  35. netsunhinged-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,18 @@
1
+ """NetsUnhinged - large-amplitude flexibility of framework materials (MOFs, ZIFs, zeolites)
2
+ from rigid-body / hinge models.
3
+
4
+ Implements and generalises the approach of Sarkisov, Martin, Haranczyk & Smit,
5
+ "On the Flexibility of Metal-Organic Frameworks", J. Am. Chem. Soc. 2014, 136, 2228.
6
+ """
7
+
8
+ from .cif import read_cif
9
+ from .structure import Structure
10
+ from .model import MechanicalModel, ModelOptions, preset
11
+
12
+ __all__ = ["read_cif", "Structure", "MechanicalModel", "ModelOptions", "preset", "load"]
13
+ __version__ = "0.1.0"
14
+
15
+
16
+ def load(path):
17
+ with open(path, encoding="utf-8", errors="replace") as fh:
18
+ return Structure(read_cif(fh.read()))
@@ -0,0 +1,83 @@
1
+ """Command line: ``python -m netsunhinged [serve|analyze] ...``"""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ import numpy as np
8
+
9
+
10
+ def cmd_analyze(args):
11
+ from . import deform, load, rigidity
12
+ from .model import MechanicalModel, ModelOptions, preset
13
+
14
+ rows = []
15
+ for path in args.cif:
16
+ st = load(path)
17
+ if args.options:
18
+ with open(args.options, encoding="utf-8") as fh:
19
+ opts = ModelOptions.from_dict(json.load(fh))
20
+ else:
21
+ opts = preset(st, args.preset)
22
+ m = MechanicalModel(st, opts)
23
+ rig = rigidity.analyze(m)
24
+ print(f"\n=== {path} ({st.formula(st.framework)}, {st.N} atoms, {st.n_guest} guest atoms)")
25
+ for w in st.warnings:
26
+ print(" warning:", w)
27
+ s = m.summary()
28
+ print(f" model: {s['n_bodies']} rigid bodies, {s['n_hinges']} hinges, "
29
+ f"{s['n_ball_joints']} ball joints, {s['n_flex_angle_atoms']} pivot atoms")
30
+ print(f" infinitesimal: {rig['n_macroscopic']} cell-changing mechanism(s), "
31
+ f"{rig['n_internal']} internal mechanism(s)")
32
+ for k, md in enumerate(rig["macroscopic_modes"]):
33
+ print(f" mode {k + 1}: {md['kind']}: {md['description']}")
34
+ verdict = "-"
35
+ if not args.no_finite:
36
+ regimes = list(deform.REGIMES) + (list(deform.EXTENDED) if args.extended else [])
37
+ _, res, verdict, dt = deform.run_all(
38
+ m, regimes=regimes, step=args.step, metric=args.metric,
39
+ thr=args.thr, thr2=args.thr2, modes=rig["macroscopic_modes"])
40
+ print(f" finite deformation ({dt:.1f} s):")
41
+ key = "F" if args.metric == "relative" else "E"
42
+ for name, r in res.items():
43
+ mx = max(p[key] for p in r["points"])
44
+ print(f" {name:8s} {r['classification']:14s} max {key} = {mx:9.2e} {r['detail']}")
45
+ print(f" VERDICT: {verdict.upper()}")
46
+ rows.append((path, rig["n_macroscopic"], verdict))
47
+ if len(rows) > 1:
48
+ print("\nsummary:")
49
+ for path, nm, v in rows:
50
+ print(f" {v:14s} {nm} mode(s) {path}")
51
+
52
+
53
+ def main(argv=None):
54
+ p = argparse.ArgumentParser(prog="netsunhinged", description=__doc__)
55
+ sub = p.add_subparsers(dest="cmd")
56
+ s = sub.add_parser("serve", help="start the interactive web app")
57
+ s.add_argument("--port", type=int, default=8765)
58
+ s.add_argument("--host", default="127.0.0.1")
59
+ s.add_argument("--no-browser", action="store_true")
60
+ a = sub.add_parser("analyze", help="analyse CIF files from the command line")
61
+ a.add_argument("cif", nargs="+")
62
+ a.add_argument("--preset", default="auto",
63
+ choices=["auto", "paper", "zeolite", "rigid", "bondbend", "donor_pivots"])
64
+ a.add_argument("--options", help="JSON file with model options (as exported by the app)")
65
+ a.add_argument("--step", type=float, default=0.02)
66
+ a.add_argument("--metric", default="relative", choices=["relative", "absolute"])
67
+ a.add_argument("--thr", type=float, default=0.01)
68
+ a.add_argument("--thr2", type=float, default=0.05)
69
+ a.add_argument("--extended", action="store_true", help="also run fully relaxed regimes")
70
+ a.add_argument("--no-finite", action="store_true", help="only infinitesimal analysis")
71
+ args = p.parse_args(argv)
72
+ if args.cmd == "analyze":
73
+ cmd_analyze(args)
74
+ else:
75
+ from .server import serve
76
+ if args.cmd is None:
77
+ serve()
78
+ else:
79
+ serve(args.host, args.port, not args.no_browser)
80
+
81
+
82
+ if __name__ == "__main__":
83
+ sys.exit(main())
netsunhinged/cif.py ADDED
@@ -0,0 +1,530 @@
1
+ """Minimal, dependency-free CIF reader.
2
+
3
+ Reads cell parameters, symmetry operations and the atom_site loop of the first
4
+ data block that contains atoms, and expands the asymmetric unit to the full
5
+ unit cell (P1).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ import numpy as np
14
+
15
+
16
+ @dataclass
17
+ class CifAtom:
18
+ label: str # label of the asymmetric-unit atom it was generated from
19
+ element: str
20
+ frac: np.ndarray # fractional coordinates wrapped to [0, 1)
21
+ occupancy: float = 1.0
22
+
23
+
24
+ @dataclass
25
+ class CifData:
26
+ name: str
27
+ cell: tuple # a, b, c, alpha, beta, gamma (Angstrom, degrees)
28
+ atoms: list
29
+ symops: list
30
+ warnings: list = field(default_factory=list)
31
+
32
+
33
+ # ---------------------------------------------------------------- tokenizer
34
+
35
+ def _tokenize(text: str):
36
+ """Yield CIF tokens; text fields (;...;) and quoted strings become one token."""
37
+ lines = text.splitlines()
38
+ i = 0
39
+ while i < len(lines):
40
+ line = lines[i]
41
+ if line.startswith(";"):
42
+ buf = [line[1:]]
43
+ i += 1
44
+ while i < len(lines) and not lines[i].startswith(";"):
45
+ buf.append(lines[i])
46
+ i += 1
47
+ i += 1
48
+ yield ("\n".join(buf)).strip()
49
+ continue
50
+ j, n = 0, len(line)
51
+ while j < n:
52
+ ch = line[j]
53
+ if ch.isspace():
54
+ j += 1
55
+ elif ch == "#":
56
+ break
57
+ elif ch in "'\"":
58
+ k = j + 1
59
+ # a closing quote must be followed by whitespace or EOL
60
+ while k < n and not (line[k] == ch and (k + 1 == n or line[k + 1].isspace())):
61
+ k += 1
62
+ yield line[j + 1:k]
63
+ j = k + 1
64
+ else:
65
+ k = j
66
+ while k < n and not line[k].isspace():
67
+ k += 1
68
+ yield line[j:k]
69
+ j = k
70
+ i += 1
71
+
72
+
73
+ def _parse_blocks(text: str):
74
+ """Return list of (block_name, items, loops). items: tag->value; loops: list of (tags, rows)."""
75
+ blocks = []
76
+ tokens = list(_tokenize(text))
77
+ i = 0
78
+ cur = None
79
+ while i < len(tokens):
80
+ t = tokens[i]
81
+ tl = t.lower()
82
+ if tl.startswith("data_"):
83
+ cur = (t[5:], {}, [])
84
+ blocks.append(cur)
85
+ i += 1
86
+ elif cur is None:
87
+ i += 1
88
+ elif tl == "loop_":
89
+ i += 1
90
+ tags = []
91
+ while i < len(tokens) and tokens[i].startswith("_"):
92
+ tags.append(tokens[i].lower())
93
+ i += 1
94
+ values = []
95
+ while i < len(tokens):
96
+ v = tokens[i]
97
+ vl = v.lower()
98
+ if v.startswith("_") or vl == "loop_" or vl.startswith("data_"):
99
+ break
100
+ values.append(v)
101
+ i += 1
102
+ if tags:
103
+ nrow = len(values) // len(tags)
104
+ rows = [values[r * len(tags):(r + 1) * len(tags)] for r in range(nrow)]
105
+ cur[2].append((tags, rows))
106
+ elif t.startswith("_"):
107
+ if i + 1 < len(tokens):
108
+ cur[1][tl] = tokens[i + 1]
109
+ i += 2
110
+ else:
111
+ i += 1
112
+ return blocks
113
+
114
+
115
+ def _num(s: str) -> float:
116
+ """Parse a CIF number, dropping the standard uncertainty '(..)'."""
117
+ s = s.strip()
118
+ if s in (".", "?", ""):
119
+ raise ValueError("missing number")
120
+ return float(re.sub(r"\(.*?\)", "", s))
121
+
122
+
123
+ # ------------------------------------------------------------ symmetry ops
124
+
125
+ _UNSAFE_TEXT = re.compile(r"[<>&\"`\x00-\x1f]")
126
+
127
+
128
+ def safe_text(text: str, limit: int = 80) -> str:
129
+ """Strip characters that could break out of HTML/attributes; CIF text is untrusted."""
130
+ return _UNSAFE_TEXT.sub("_", str(text))[:limit]
131
+
132
+
133
+ _SYMOP_TOKEN = re.compile(r"\s*(?:(\d+\.\d*|\.\d+|\d+)|([xyz])|([-+*/()]))", re.I)
134
+
135
+
136
+ def _parse_affine(expr: str):
137
+ """Parse one component of a symmetry operation ('-x+1/2', 'x-y', '2x', '1/2+z' ...)
138
+ into (coefficients of x, y, z; constant) with a small recursive-descent parser.
139
+
140
+ No eval, no powers, no products of variables, bounded length and nesting: a crafted
141
+ CIF can neither execute code nor make the parser hang."""
142
+ if len(expr) > 40:
143
+ raise ValueError("expression too long")
144
+ toks, pos = [], 0
145
+ while pos < len(expr):
146
+ m = _SYMOP_TOKEN.match(expr, pos)
147
+ if not m or m.end() == pos:
148
+ raise ValueError("unexpected character")
149
+ num, var, op = m.groups()
150
+ if num is not None:
151
+ toks.append(("n", float(num)))
152
+ elif var is not None:
153
+ toks.append(("v", "xyz".index(var.lower())))
154
+ else:
155
+ toks.append(("o", op))
156
+ pos = m.end()
157
+ if expr[pos:].strip() == "":
158
+ break
159
+ k = [0]
160
+
161
+ def peek():
162
+ return toks[k[0]] if k[0] < len(toks) else (None, None)
163
+
164
+ def take():
165
+ tok = peek()
166
+ k[0] += 1
167
+ return tok
168
+
169
+ def factor(depth):
170
+ kind, val = take()
171
+ if kind == "n":
172
+ out = (np.zeros(3), val)
173
+ if peek()[0] == "v": # implicit product such as '2x'
174
+ _, idx = take()
175
+ vec = np.zeros(3)
176
+ vec[idx] = val
177
+ out = (vec, 0.0)
178
+ return out
179
+ if kind == "v":
180
+ vec = np.zeros(3)
181
+ vec[val] = 1.0
182
+ return vec, 0.0
183
+ if kind == "o" and val in "+-":
184
+ vec, c = factor(depth)
185
+ return (-vec, -c) if val == "-" else (vec, c)
186
+ if kind == "o" and val == "(" and depth < 3:
187
+ out = expr_(depth + 1)
188
+ if take() != ("o", ")"):
189
+ raise ValueError("unbalanced parentheses")
190
+ return out
191
+ raise ValueError("syntax error")
192
+
193
+ def term(depth):
194
+ vec, c = factor(depth)
195
+ while peek() in (("o", "*"), ("o", "/")):
196
+ _, op = take()
197
+ v2, c2 = factor(depth)
198
+ if op == "*":
199
+ if vec.any() and v2.any():
200
+ raise ValueError("product of variables")
201
+ # (a.X + c)(b.X + c2) with a or b zero
202
+ vec, c = vec * c2 + v2 * c, c * c2
203
+ else:
204
+ if v2.any() or c2 == 0:
205
+ raise ValueError("division by a variable or zero")
206
+ vec, c = vec / c2, c / c2
207
+ return vec, c
208
+
209
+ def expr_(depth):
210
+ vec, c = term(depth)
211
+ while peek() in (("o", "+"), ("o", "-")):
212
+ _, op = take()
213
+ v2, c2 = term(depth)
214
+ vec, c = (vec + v2, c + c2) if op == "+" else (vec - v2, c - c2)
215
+ return vec, c
216
+
217
+ vec, c = expr_(0)
218
+ if k[0] != len(toks):
219
+ raise ValueError("trailing characters")
220
+ if np.abs(vec).max() > 3 or abs(c) > 3:
221
+ raise ValueError("coefficient out of range")
222
+ return vec, c
223
+
224
+
225
+ def parse_symop(op: str):
226
+ """'-x+1/2, y, z+1/2' -> (R 3x3, t 3)."""
227
+ parts = [p.strip() for p in op.split(",")]
228
+ if len(parts) != 3:
229
+ raise ValueError(f"bad symmetry operation '{op[:60]}'")
230
+ R = np.zeros((3, 3))
231
+ t = np.zeros(3)
232
+ for row, expr in enumerate(parts):
233
+ try:
234
+ R[row], t[row] = _parse_affine(expr)
235
+ except ValueError as exc:
236
+ raise ValueError(f"bad symmetry operation '{op[:60]}' ({exc})") from None
237
+ return R, t
238
+
239
+
240
+ _CENTERING = {
241
+ "P": [(0, 0, 0)],
242
+ "I": [(0, 0, 0), (.5, .5, .5)],
243
+ "F": [(0, 0, 0), (0, .5, .5), (.5, 0, .5), (.5, .5, 0)],
244
+ "C": [(0, 0, 0), (.5, .5, 0)],
245
+ "A": [(0, 0, 0), (0, .5, .5)],
246
+ "B": [(0, 0, 0), (.5, 0, .5)],
247
+ }
248
+
249
+
250
+ def _rot(axis, n):
251
+ """n-fold rotation about an axis of an orthogonal lattice (integer matrix)."""
252
+ a = np.array(axis, float)
253
+ a /= np.linalg.norm(a)
254
+ th = 2 * np.pi / n
255
+ K = np.array([[0, -a[2], a[1]], [a[2], 0, -a[0]], [-a[1], a[0], 0]])
256
+ return np.round(np.eye(3) + np.sin(th) * K + (1 - np.cos(th)) * K @ K).astype(int)
257
+
258
+
259
+ def symops_from_hm(symbol: str):
260
+ """Symmetry operations from a Hermann-Mauguin symbol for *symmorphic* space groups of
261
+ orthogonal lattices (cubic, tetragonal, orthorhombic), e.g. 'Fm-3m', 'P 4/m m m'.
262
+
263
+ Groups with screw axes or glide planes need origin information that the symbol alone
264
+ does not give unambiguously; for those a ValueError is raised."""
265
+ s = symbol.replace(" ", "").replace("_", "")
266
+ if not s or s[0] not in _CENTERING:
267
+ raise ValueError(f"unsupported space-group symbol '{safe_text(symbol, 20)}'")
268
+ lat, rest = s[0], s[1:]
269
+ tokens = re.findall(r"-?[1-6](?:/[a-z])?|[a-z]", rest)
270
+ if "".join(tokens) != rest or any(re.search(r"[abcdegn]", t) for t in tokens) \
271
+ or re.search(r"[1-6][1-6]", rest):
272
+ raise ValueError(f"space group '{safe_text(symbol, 20)}' has screw axes or glide planes")
273
+ if len(tokens) == 3 and tokens[1].lstrip("-").startswith("3"):
274
+ dirs = [(1, 0, 0), (1, 1, 1), (1, 1, 0)] # cubic
275
+ elif tokens and tokens[0].lstrip("-")[0] == "4":
276
+ dirs = [(0, 0, 1), (1, 0, 0), (1, 1, 0)] # tetragonal
277
+ elif len(tokens) == 3:
278
+ dirs = [(1, 0, 0), (0, 1, 0), (0, 0, 1)] # orthorhombic
279
+ else:
280
+ raise ValueError(f"unsupported space-group symbol '{safe_text(symbol, 20)}'")
281
+ # 3- and 6-fold axes exist only along the cubic body diagonal of an orthogonal lattice;
282
+ # trigonal/hexagonal symbols (P6/mmm, P-3m1 ...) would give wrong integer matrices
283
+ for k, tok in enumerate(tokens):
284
+ if re.search(r"[36]", tok) and not (dirs[1] == (1, 1, 1) and k == 1):
285
+ raise ValueError(f"space group '{safe_text(symbol, 20)}' is trigonal or hexagonal")
286
+ gens = []
287
+ for tok, ax in zip(tokens, dirs):
288
+ for part in tok.split("/"):
289
+ if part == "m":
290
+ gens.append(-_rot(ax, 2)) # mirror normal to ax
291
+ elif part.startswith("-"):
292
+ gens.append(-_rot(ax, int(part[1:]))) # rotoinversion
293
+ elif part != "1":
294
+ gens.append(_rot(ax, int(part)))
295
+ group ={tuple(np.eye(3, dtype=int).ravel())}
296
+ frontier = [np.eye(3, dtype=int)]
297
+ while frontier:
298
+ new = []
299
+ for g in frontier:
300
+ for h in gens:
301
+ p = g @ h
302
+ key = tuple(p.ravel())
303
+ if key not in group:
304
+ group.add(key)
305
+ new.append(p)
306
+ frontier = new
307
+ if len(group) > 48: # largest point group: m-3m
308
+ raise ValueError(f"unsupported space-group symbol '{safe_text(symbol, 20)}'")
309
+ ops = []
310
+ for key in sorted(group):
311
+ R = np.array(key, float).reshape(3, 3)
312
+ for t in _CENTERING[lat]:
313
+ ops.append((R, np.array(t, float)))
314
+ return ops
315
+
316
+
317
+ # ---------------------------------------------------------------- elements
318
+
319
+ _ELEMENT_RE = re.compile(r"^([A-Z][a-z]?)")
320
+
321
+
322
+ def _element_from(type_symbol: str | None, label: str) -> str:
323
+ for src in (type_symbol, label):
324
+ if not src or src in (".", "?"):
325
+ continue
326
+ s = src.strip()
327
+ s = s[0].upper() + s[1:].lower() if len(s) > 1 else s.upper()
328
+ m = _ELEMENT_RE.match(s)
329
+ if m:
330
+ sym = m.group(1)
331
+ from .elements import COVALENT_RADII
332
+ if sym in COVALENT_RADII:
333
+ return sym
334
+ if sym[0] in COVALENT_RADII:
335
+ return sym[0]
336
+ raise ValueError(f"cannot determine element for atom '{safe_text(label, 32)}'")
337
+
338
+
339
+ # ------------------------------------------------------------------ reader
340
+
341
+ def cell_matrix(a, b, c, alpha, beta, gamma) -> np.ndarray:
342
+ """Lattice vectors as rows (a along x, b in the xy plane)."""
343
+ al, be, ga = np.radians([alpha, beta, gamma])
344
+ ax = np.array([a, 0.0, 0.0])
345
+ bx = np.array([b * np.cos(ga), b * np.sin(ga), 0.0])
346
+ cx = c * np.cos(be)
347
+ cy = c * (np.cos(al) - np.cos(be) * np.cos(ga)) / np.sin(ga)
348
+ cz = np.sqrt(max(c * c - cx * cx - cy * cy, 1e-12))
349
+ return np.array([ax, bx, [cx, cy, cz]])
350
+
351
+
352
+ def read_cif(text: str, merge_tol: float = 0.25, disorder_merge: float = 0.7,
353
+ max_atoms: int | None = None) -> CifData:
354
+ blocks = _parse_blocks(text)
355
+ block = None
356
+ for b in blocks:
357
+ if any("_atom_site_fract_x" in tags for tags, _ in b[2]):
358
+ block = b
359
+ break
360
+ if block is None:
361
+ raise ValueError("no data block with _atom_site_fract_x found")
362
+ name, items, loops = block
363
+ name = safe_text(name)
364
+ warnings = []
365
+
366
+ try:
367
+ cell = tuple(_num(items[k]) for k in (
368
+ "_cell_length_a", "_cell_length_b", "_cell_length_c",
369
+ "_cell_angle_alpha", "_cell_angle_beta", "_cell_angle_gamma"))
370
+ except KeyError as exc:
371
+ raise ValueError(f"missing cell parameter {exc}") from None
372
+
373
+ # symmetry operations
374
+ symops = None
375
+ for tags, rows in loops:
376
+ for key in ("_space_group_symop_operation_xyz", "_symmetry_equiv_pos_as_xyz",
377
+ "_space_group_symop.operation_xyz"):
378
+ if key in tags:
379
+ col = tags.index(key)
380
+ symops = [r[col] for r in rows]
381
+ break
382
+ if symops:
383
+ break
384
+ if not symops:
385
+ for key in ("_space_group_symop_operation_xyz", "_symmetry_equiv_pos_as_xyz"):
386
+ if key in items:
387
+ symops = [items[key]]
388
+ # placeholders such as '?' or '.' carry no information
389
+ symops = [s for s in (symops or []) if s.strip() not in ("?", ".", "")]
390
+ ops = [parse_symop(s) for s in symops]
391
+ if not ops:
392
+ hm = next((items[k] for k in ("_symmetry_space_group_name_h-m", "_space_group_name_h-m_alt")
393
+ if items.get(k, "?") not in ("?", ".")), None)
394
+ if hm is None or hm.replace(" ", "") == "P1":
395
+ ops = [parse_symop("x,y,z")]
396
+ warnings.append("No symmetry operations found - assuming P1.")
397
+ else:
398
+ try:
399
+ if any(abs(ang - 90.0) > 1e-3 for ang in cell[3:]):
400
+ raise ValueError("the cell is not orthogonal")
401
+ ops = symops_from_hm(hm)
402
+ except ValueError as exc:
403
+ raise ValueError(f"The CIF lists no symmetry operations and they cannot be derived "
404
+ f"safely from the space-group symbol ({exc}). Please re-export the "
405
+ f"CIF with symmetry operations (e.g. from Mercury or VESTA).") from None
406
+ warnings.append(f"No symmetry operations in the CIF - generated {len(ops)} operations "
407
+ f"from the space-group symbol {safe_text(hm, 20)}.")
408
+
409
+ # atom sites
410
+ tags, rows = next((tg, r) for tg, r in loops if "_atom_site_fract_x" in tg)
411
+
412
+ def col(name):
413
+ return tags.index(name) if name in tags else None
414
+
415
+ c_label, c_type = col("_atom_site_label"), col("_atom_site_type_symbol")
416
+ c_x, c_y, c_z = col("_atom_site_fract_x"), col("_atom_site_fract_y"), col("_atom_site_fract_z")
417
+ c_occ, c_dis = col("_atom_site_occupancy"), col("_atom_site_disorder_group")
418
+ c_calc = col("_atom_site_calc_flag")
419
+ c_asm = col("_atom_site_disorder_assembly")
420
+
421
+ # disorder: within each disorder assembly keep only the lowest-numbered group (SHELX PART);
422
+ # assemblies often use their own numbers (e.g. A: 1/2, B: 3/4)
423
+ def _group(r):
424
+ if c_dis is None or r[c_dis] in (".", "?"):
425
+ return None
426
+ try:
427
+ return abs(int(r[c_dis])) # negative parts: special-position disorder
428
+ except ValueError:
429
+ return None
430
+
431
+ def _assembly(r):
432
+ return r[c_asm] if c_asm is not None and r[c_asm] not in (".", "?") else ""
433
+ first_group = {}
434
+ for r in rows:
435
+ g = _group(r)
436
+ if g:
437
+ first_group[_assembly(r)] = min(g, first_group.get(_assembly(r), g))
438
+
439
+ asym = []
440
+ skipped_disorder = 0
441
+ for r in rows:
442
+ label = r[c_label] if c_label is not None else (r[c_type] if c_type is not None else "X")
443
+ label = safe_text(label, 32)
444
+ if c_calc is not None and r[c_calc].lower() == "dum":
445
+ continue
446
+ try:
447
+ frac = np.array([_num(r[c_x]), _num(r[c_y]), _num(r[c_z])])
448
+ except ValueError:
449
+ continue
450
+ occ = 1.0
451
+ if c_occ is not None:
452
+ try:
453
+ occ = _num(r[c_occ])
454
+ except ValueError:
455
+ occ = 1.0
456
+ g = _group(r)
457
+ if g and g != first_group.get(_assembly(r), g):
458
+ skipped_disorder += 1
459
+ continue
460
+ el = _element_from(r[c_type] if c_type is not None else None, label)
461
+ asym.append((label, el, frac, occ))
462
+ if skipped_disorder:
463
+ warnings.append(f"Skipped {skipped_disorder} atoms of minor disorder components "
464
+ "(the first component of each disorder assembly is kept).")
465
+ if any(o < 0.99 for *_, o in asym):
466
+ warnings.append("Structure contains partially occupied sites; they are kept as full atoms.")
467
+
468
+ # expand by symmetry, merge duplicates. Partially occupied symmetry copies of the same
469
+ # site that lie closer than a bond (disorder across a mirror or axis) are one disordered
470
+ # atom: they are merged at their average position. A grid of buckets keeps the search
471
+ # local (linear in the number of atoms).
472
+ L = cell_matrix(*cell)
473
+ reach = max(merge_tol, disorder_merge)
474
+ widths = abs(np.linalg.det(L)) / np.array([np.linalg.norm(np.cross(L[(k + 1) % 3], L[(k + 2) % 3]))
475
+ for k in range(3)])
476
+ ngrid = np.maximum(1, np.floor(widths / reach)).astype(int)
477
+ grid: dict = {}
478
+ atoms: list[CifAtom] = []
479
+ n_merged = n_mixed = 0
480
+ for label, el, frac, occ in asym:
481
+ for R, t in ops:
482
+ f = (R @ frac + t) % 1.0
483
+ f[f > 1 - 1e-6] = 0.0
484
+ cellidx = np.floor(f * ngrid).astype(int) % ngrid
485
+ dup = False
486
+ for dx in (-1, 0, 1):
487
+ for dy in (-1, 0, 1):
488
+ for dz in (-1, 0, 1):
489
+ key = tuple((cellidx + (dx, dy, dz)) % ngrid)
490
+ for a in grid.get(key, ()):
491
+ df = f - a.frac
492
+ df -= np.round(df)
493
+ d = np.linalg.norm(df @ L)
494
+ if a.element != el:
495
+ # substitutional disorder (e.g. Si/Al on one zeolite T site):
496
+ # the site is represented by its majority element
497
+ if d < merge_tol and min(occ, a.occupancy) < 0.99:
498
+ if occ > a.occupancy:
499
+ a.element, a.label, a.occupancy = el, label, occ
500
+ n_mixed += 1
501
+ dup = True
502
+ break
503
+ continue
504
+ if d < merge_tol:
505
+ dup = True
506
+ elif occ < 0.99 and a.label == label and d < disorder_merge:
507
+ a.frac = (a.frac + df / 2) % 1.0
508
+ n_merged += 1
509
+ dup = True
510
+ if dup:
511
+ break
512
+ if dup:
513
+ break
514
+ if dup:
515
+ break
516
+ if dup:
517
+ break
518
+ if not dup:
519
+ a = CifAtom(label, el, f, occ)
520
+ atoms.append(a)
521
+ grid.setdefault(tuple(cellidx), []).append(a)
522
+ if max_atoms and len(atoms) > max_atoms:
523
+ raise ValueError(f"The structure has more than {max_atoms} atoms per unit cell, "
524
+ "which exceeds the limit of this server.")
525
+ if n_mixed:
526
+ warnings.append(f"{n_mixed} mixed-occupancy sites (e.g. Si/Al) are represented by their majority element.")
527
+ if n_merged:
528
+ warnings.append(f"Merged {n_merged} partially occupied symmetry copies of disordered sites "
529
+ f"(closer than {disorder_merge} Å) at their average positions.")
530
+ return CifData(name=name, cell=cell, atoms=atoms, symops=symops, warnings=warnings)