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.
- netsunhinged/__init__.py +18 -0
- netsunhinged/__main__.py +83 -0
- netsunhinged/cif.py +530 -0
- netsunhinged/deform.py +367 -0
- netsunhinged/elements.py +47 -0
- netsunhinged/examples/DMOF-1_COD-7038645.cif +353 -0
- netsunhinged/examples/HKUST-1_COD-4128729.cif +458 -0
- netsunhinged/examples/MIL-53-Ga_COD-7000776.cif +102 -0
- netsunhinged/examples/MOF-5_COD-7240544.cif +429 -0
- netsunhinged/examples/Natrolite_COD-9013163.cif +99 -0
- netsunhinged/examples/README.md +34 -0
- netsunhinged/examples/ZIF-7_COD-4121899.cif +465 -0
- netsunhinged/examples/ZIF-8_COD-7111971.cif +416 -0
- netsunhinged/examples/Zeolite-A_COD-1532589.cif +166 -0
- netsunhinged/examples/Zeolite-Rho_COD-1530545.cif +113 -0
- netsunhinged/examples/catalog.json +90 -0
- netsunhinged/examples/rho-ZnIm2_COD-4132678.cif +390 -0
- netsunhinged/geometry.py +151 -0
- netsunhinged/model.py +454 -0
- netsunhinged/rigidity.py +234 -0
- netsunhinged/server.py +661 -0
- netsunhinged/static/app.js +1257 -0
- netsunhinged/static/index.html +168 -0
- netsunhinged/static/style.css +162 -0
- netsunhinged/static/vendor/three/LICENSE +21 -0
- netsunhinged/static/vendor/three/addons/controls/OrbitControls.js +1417 -0
- netsunhinged/static/vendor/three/three.module.min.js +6 -0
- netsunhinged/structure.py +532 -0
- netsunhinged-0.1.0.dist-info/METADATA +152 -0
- netsunhinged-0.1.0.dist-info/RECORD +35 -0
- netsunhinged-0.1.0.dist-info/WHEEL +5 -0
- netsunhinged-0.1.0.dist-info/entry_points.txt +2 -0
- netsunhinged-0.1.0.dist-info/licenses/LICENSE +30 -0
- netsunhinged-0.1.0.dist-info/licenses/netsunhinged/static/vendor/three/LICENSE +21 -0
- netsunhinged-0.1.0.dist-info/top_level.txt +1 -0
netsunhinged/__init__.py
ADDED
|
@@ -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()))
|
netsunhinged/__main__.py
ADDED
|
@@ -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)
|