ciffer 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.
- ciffer/__init__.py +21 -0
- ciffer/cif/__init__.py +333 -0
- ciffer/cif/altloc.py +79 -0
- ciffer/cif/assembly.py +121 -0
- ciffer/cif/bonds.py +275 -0
- ciffer/cif/category.py +151 -0
- ciffer/cif/hierarchy.py +415 -0
- ciffer/cif/metadata.py +166 -0
- ciffer/cif/pdb.py +77 -0
- ciffer/cif/rcsb.py +28 -0
- ciffer/cif/writable.py +315 -0
- ciffer/py.typed +0 -0
- ciffer/serialize.py +100 -0
- ciffer/structure.py +340 -0
- ciffer/table.py +337 -0
- ciffer/view.py +374 -0
- ciffer-0.1.0.dist-info/METADATA +180 -0
- ciffer-0.1.0.dist-info/RECORD +20 -0
- ciffer-0.1.0.dist-info/WHEEL +4 -0
- ciffer-0.1.0.dist-info/licenses/LICENSE +21 -0
ciffer/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from .cif import fetch, read, write
|
|
2
|
+
from .structure import Structure, concat
|
|
3
|
+
from .table import EdgeList, Table
|
|
4
|
+
from .view import AtomView, Bond, ChainView, Level, ResidueView, StructConn, View
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AtomView",
|
|
8
|
+
"Bond",
|
|
9
|
+
"ChainView",
|
|
10
|
+
"EdgeList",
|
|
11
|
+
"Level",
|
|
12
|
+
"ResidueView",
|
|
13
|
+
"StructConn",
|
|
14
|
+
"Structure",
|
|
15
|
+
"Table",
|
|
16
|
+
"View",
|
|
17
|
+
"concat",
|
|
18
|
+
"fetch",
|
|
19
|
+
"read",
|
|
20
|
+
"write",
|
|
21
|
+
]
|
ciffer/cif/__init__.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, overload
|
|
9
|
+
|
|
10
|
+
import gemmi
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from ciffer.structure import Structure
|
|
14
|
+
|
|
15
|
+
from .altloc import conformer_rows
|
|
16
|
+
from .assembly import assemble, chosen_assembly
|
|
17
|
+
from .category import Category
|
|
18
|
+
from .hierarchy import from_categories, to_categories
|
|
19
|
+
from .metadata import entry_metadata
|
|
20
|
+
from .pdb import pdb_block, pdb_text
|
|
21
|
+
from .rcsb import download_mmcif
|
|
22
|
+
from .writable import unwritten, writable_columns
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@overload
|
|
29
|
+
def read(
|
|
30
|
+
path: str | os.PathLike[str],
|
|
31
|
+
*,
|
|
32
|
+
model: int | None = None,
|
|
33
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
34
|
+
assembly: bool | int | str = False,
|
|
35
|
+
) -> Structure: ...
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@overload
|
|
39
|
+
def read(
|
|
40
|
+
path: str | os.PathLike[str],
|
|
41
|
+
*,
|
|
42
|
+
model: list[int] | Literal["all"],
|
|
43
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
44
|
+
assembly: bool | int | str = False,
|
|
45
|
+
) -> list[Structure]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read(
|
|
49
|
+
path: str | os.PathLike[str],
|
|
50
|
+
*,
|
|
51
|
+
model: int | list[int] | Literal["all"] | None = None,
|
|
52
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
53
|
+
assembly: bool | int | str = False,
|
|
54
|
+
) -> Structure | list[Structure]:
|
|
55
|
+
"""Read an mmCIF or PDB file.
|
|
56
|
+
|
|
57
|
+
A PDB file is read as gemmi converts it to mmCIF, so it has no residues without
|
|
58
|
+
atoms and no bonds within residues. Its chains get label ids A, B, ..., Z, AA, BA,
|
|
59
|
+
and so on, in the order of the file. Without SEQRES records, its polymer residues
|
|
60
|
+
are numbered from 1 in the order of the file.
|
|
61
|
+
|
|
62
|
+
Parameters
|
|
63
|
+
----------
|
|
64
|
+
path: str | os.PathLike[str]
|
|
65
|
+
A ``.cif``, ``.mmcif``, ``.pdb``, or ``.ent`` file, optionally gzipped.
|
|
66
|
+
model: int | list[int] | Literal["all"] | None, optional
|
|
67
|
+
Model number to read, or a list of them. None reads the first model, and
|
|
68
|
+
``"all"`` reads every model.
|
|
69
|
+
altloc: Literal["occupancy", "all"] | None, optional
|
|
70
|
+
None keeps the first conformer as gemmi does. ``"occupancy"`` keeps, at each
|
|
71
|
+
residue position, the altloc with the highest total occupancy as biotite does.
|
|
72
|
+
``"all"`` keeps every alternative location.
|
|
73
|
+
assembly: bool | int | str, optional
|
|
74
|
+
Assembly id to build, such as ``"1"`` or ``1``. True builds the first assembly
|
|
75
|
+
of the file, or keeps the deposited chains if the file has none;
|
|
76
|
+
``metadata["assembly"]`` tells which.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
Structure | list[Structure]
|
|
81
|
+
A list, in the given order, when ``model`` is a list or ``"all"``.
|
|
82
|
+
|
|
83
|
+
Examples
|
|
84
|
+
--------
|
|
85
|
+
.. code-block:: python
|
|
86
|
+
|
|
87
|
+
st = ciffer.read("1abc.cif.gz")
|
|
88
|
+
st = ciffer.read("design.pdb")
|
|
89
|
+
pair = ciffer.read("2k9q.cif", model=[1, 3])
|
|
90
|
+
ensemble = ciffer.read("2k9q.cif", model="all")
|
|
91
|
+
likely = ciffer.read("1ejg.cif", altloc="occupancy")
|
|
92
|
+
every = ciffer.read("1ejg.cif", altloc="all")
|
|
93
|
+
tetramer = ciffer.read("1stp.cif", assembly="1")
|
|
94
|
+
biological = ciffer.read("1stp.cif", assembly=True)
|
|
95
|
+
|
|
96
|
+
"""
|
|
97
|
+
path = Path(path)
|
|
98
|
+
if _file_format(path) == "pdb":
|
|
99
|
+
block = pdb_block(path)
|
|
100
|
+
else:
|
|
101
|
+
block = gemmi.cif.read(str(path)).sole_block()
|
|
102
|
+
return _from_block(block, model=model, altloc=altloc, assembly=assembly)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@overload
|
|
106
|
+
def fetch(
|
|
107
|
+
pdb_id: str,
|
|
108
|
+
directory: str | os.PathLike[str] | None = None,
|
|
109
|
+
*,
|
|
110
|
+
model: int | None = None,
|
|
111
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
112
|
+
assembly: bool | int | str = False,
|
|
113
|
+
) -> Structure: ...
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@overload
|
|
117
|
+
def fetch(
|
|
118
|
+
pdb_id: str,
|
|
119
|
+
directory: str | os.PathLike[str] | None = None,
|
|
120
|
+
*,
|
|
121
|
+
model: list[int] | Literal["all"],
|
|
122
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
123
|
+
assembly: bool | int | str = False,
|
|
124
|
+
) -> list[Structure]: ...
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch(
|
|
128
|
+
pdb_id: str,
|
|
129
|
+
directory: str | os.PathLike[str] | None = None,
|
|
130
|
+
*,
|
|
131
|
+
model: int | list[int] | Literal["all"] | None = None,
|
|
132
|
+
altloc: Literal["occupancy", "all"] | None = None,
|
|
133
|
+
assembly: bool | int | str = False,
|
|
134
|
+
) -> Structure | list[Structure]:
|
|
135
|
+
"""Read an entry of the PDB, downloaded from RCSB as mmCIF.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
pdb_id: str
|
|
140
|
+
A 4-character or extended id, such as ``"1stp"`` or ``"pdb_00001stp"``.
|
|
141
|
+
directory: str | os.PathLike[str] | None, optional
|
|
142
|
+
Directory to save the file in, as ``1stp.cif`` with the id in lowercase. A file
|
|
143
|
+
saved there before is read instead of downloading; delete it to download again.
|
|
144
|
+
None keeps the file in memory only.
|
|
145
|
+
model, altloc, assembly
|
|
146
|
+
As in ``read``.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
Structure | list[Structure]
|
|
151
|
+
A list, in the given order, when ``model`` is a list or ``"all"``.
|
|
152
|
+
|
|
153
|
+
Raises
|
|
154
|
+
------
|
|
155
|
+
ValueError
|
|
156
|
+
If the id has characters other than letters, digits, and underscores.
|
|
157
|
+
FileNotFoundError
|
|
158
|
+
If RCSB has no entry of the id.
|
|
159
|
+
|
|
160
|
+
Examples
|
|
161
|
+
--------
|
|
162
|
+
.. code-block:: python
|
|
163
|
+
|
|
164
|
+
st = ciffer.fetch("1stp")
|
|
165
|
+
st = ciffer.fetch("pdb_00001stp", assembly="1")
|
|
166
|
+
st = ciffer.fetch("1stp", "data")
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
if not re.fullmatch(r"[A-Za-z0-9_]+", pdb_id):
|
|
170
|
+
msg = f"{pdb_id!r} is not a PDB id."
|
|
171
|
+
raise ValueError(msg)
|
|
172
|
+
|
|
173
|
+
path = None if directory is None else Path(directory) / f"{pdb_id.lower()}.cif"
|
|
174
|
+
if path is not None and path.exists():
|
|
175
|
+
return read(path, model=model, altloc=altloc, assembly=assembly)
|
|
176
|
+
|
|
177
|
+
data = download_mmcif(pdb_id)
|
|
178
|
+
# Read before saving, so that a file gemmi cannot read is not kept.
|
|
179
|
+
block = gemmi.cif.read_string(data).sole_block()
|
|
180
|
+
if path is not None:
|
|
181
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
# A name of its own keeps other processes saving the same entry apart.
|
|
183
|
+
temporary = path.with_name(f"{path.name}.{uuid.uuid4().hex}.tmp")
|
|
184
|
+
try:
|
|
185
|
+
temporary.write_bytes(data)
|
|
186
|
+
temporary.replace(path)
|
|
187
|
+
finally:
|
|
188
|
+
temporary.unlink(missing_ok=True)
|
|
189
|
+
return _from_block(block, model=model, altloc=altloc, assembly=assembly)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _from_block(
|
|
193
|
+
block: gemmi.cif.Block,
|
|
194
|
+
*,
|
|
195
|
+
model: int | list[int] | Literal["all"] | None,
|
|
196
|
+
altloc: Literal["occupancy", "all"] | None,
|
|
197
|
+
assembly: bool | int | str,
|
|
198
|
+
) -> Structure | list[Structure]:
|
|
199
|
+
"""Build the structures that ``read`` returns from an mmCIF block."""
|
|
200
|
+
if altloc not in (None, "occupancy", "all"):
|
|
201
|
+
msg = f"altloc must be None, 'occupancy', or 'all', got {altloc!r}."
|
|
202
|
+
raise ValueError(msg)
|
|
203
|
+
|
|
204
|
+
site = Category(block, "_atom_site.")
|
|
205
|
+
entity = Category(block, "_entity.")
|
|
206
|
+
entity_poly = Category(block, "_entity_poly.")
|
|
207
|
+
chem_comp = Category(block, "_chem_comp.")
|
|
208
|
+
struct_asym = Category(block, "_struct_asym.")
|
|
209
|
+
scheme = Category(block, "_pdbx_poly_seq_scheme.")
|
|
210
|
+
comp_bond = Category(block, "_chem_comp_bond.")
|
|
211
|
+
conn = Category(block, "_struct_conn.")
|
|
212
|
+
gen = Category(block, "_pdbx_struct_assembly_gen.")
|
|
213
|
+
oper = Category(block, "_pdbx_struct_oper_list.")
|
|
214
|
+
|
|
215
|
+
# A file without model numbers holds one model, numbered 1.
|
|
216
|
+
numbers = site.get("pdbx_PDB_model_num")
|
|
217
|
+
numbers = np.where(numbers == "", "1", numbers).astype(int)
|
|
218
|
+
models = list(dict.fromkeys(numbers.tolist()))
|
|
219
|
+
|
|
220
|
+
if model is None:
|
|
221
|
+
chosen = models[:1]
|
|
222
|
+
elif model == "all":
|
|
223
|
+
chosen = models
|
|
224
|
+
elif isinstance(model, int):
|
|
225
|
+
chosen = [model]
|
|
226
|
+
else:
|
|
227
|
+
chosen = model
|
|
228
|
+
missing = [number for number in chosen if number not in models]
|
|
229
|
+
if missing:
|
|
230
|
+
msg = f"Models {missing} are not in the file; models are {models}."
|
|
231
|
+
raise ValueError(msg)
|
|
232
|
+
|
|
233
|
+
assemblies = list(dict.fromkeys(gen.get("assembly_id").tolist()))
|
|
234
|
+
assembly_id = chosen_assembly(assemblies, assembly=assembly)
|
|
235
|
+
|
|
236
|
+
structures = []
|
|
237
|
+
for number in chosen:
|
|
238
|
+
model_site = site.take(np.flatnonzero(numbers == number))
|
|
239
|
+
model_site = model_site.take(conformer_rows(model_site, altloc))
|
|
240
|
+
st = from_categories(
|
|
241
|
+
model_site,
|
|
242
|
+
entity=entity,
|
|
243
|
+
entity_poly=entity_poly,
|
|
244
|
+
chem_comp=chem_comp,
|
|
245
|
+
struct_asym=struct_asym,
|
|
246
|
+
scheme=scheme,
|
|
247
|
+
comp_bond=comp_bond,
|
|
248
|
+
conn=conn,
|
|
249
|
+
)
|
|
250
|
+
metadata = {**entry_metadata(block), "model": number, "models": models.copy()}
|
|
251
|
+
if assembly_id is not None:
|
|
252
|
+
st = assemble(st, assembly_id, gen=gen, oper=oper)
|
|
253
|
+
metadata["assembly"] = assembly_id
|
|
254
|
+
if assemblies:
|
|
255
|
+
metadata["assemblies"] = assemblies.copy()
|
|
256
|
+
st.metadata = metadata
|
|
257
|
+
structures.append(st)
|
|
258
|
+
|
|
259
|
+
return structures if isinstance(model, list) or model == "all" else structures[0]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def write(st: Structure, path: str | os.PathLike[str]) -> None:
|
|
263
|
+
"""Write a structure to an mmCIF or PDB file, gzipped if the name ends in .gz.
|
|
264
|
+
|
|
265
|
+
Parameters
|
|
266
|
+
----------
|
|
267
|
+
st: Structure
|
|
268
|
+
Needs atom ``name``, ``element``, and ``xyz``, and residue ``name``. Missing
|
|
269
|
+
standard columns are filled, and other columns and metadata keys are skipped
|
|
270
|
+
with a warning.
|
|
271
|
+
path: str | os.PathLike[str]
|
|
272
|
+
A ``.cif``, ``.mmcif``, ``.pdb``, or ``.ent`` file, optionally gzipped.
|
|
273
|
+
|
|
274
|
+
Notes
|
|
275
|
+
-----
|
|
276
|
+
Missing chain types are judged from residues, so give them for branched and
|
|
277
|
+
macrolide chains. Missing ``label_seq_id`` numbers each polymer from 1, and an
|
|
278
|
+
entity whose chains do not each run from 1 to the same end without gaps, as after
|
|
279
|
+
cropping, is written without its sequence. Readers may give no bonds to residue
|
|
280
|
+
types that ``bond`` leaves out, so give bonds for every residue or for none. Chains
|
|
281
|
+
sharing an ``entity_id`` but holding other molecules, as after ``concat``, are
|
|
282
|
+
written as separate entities.
|
|
283
|
+
|
|
284
|
+
A PDB file loses, without a warning, bonds, chain descriptions, hydrogen bonds,
|
|
285
|
+
connection orders, and the metadata ``authors``, ``r_free``, ``release_date``, and
|
|
286
|
+
``doi``.
|
|
287
|
+
|
|
288
|
+
Examples
|
|
289
|
+
--------
|
|
290
|
+
.. code-block:: python
|
|
291
|
+
|
|
292
|
+
st = ciffer.read("input.cif")
|
|
293
|
+
st.atoms.xyz = predicted
|
|
294
|
+
st.atoms.b_factor = plddt
|
|
295
|
+
ciffer.write(st, "predicted.cif.gz")
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
if not isinstance(st, Structure):
|
|
299
|
+
msg = (
|
|
300
|
+
f"write takes a Structure, not {type(st).__name__}; extract() a view "
|
|
301
|
+
"first, or write each model of a list to its own file."
|
|
302
|
+
)
|
|
303
|
+
raise TypeError(msg)
|
|
304
|
+
path = Path(path)
|
|
305
|
+
as_pdb = _file_format(path) == "pdb"
|
|
306
|
+
|
|
307
|
+
atoms, residues, chains = writable_columns(st)
|
|
308
|
+
doc = gemmi.cif.Document()
|
|
309
|
+
block = doc.add_new_block(str(st.metadata.get("id", "ciffer")))
|
|
310
|
+
for category, items in to_categories(st, atoms, residues, chains).items():
|
|
311
|
+
block.set_mmcif_category(category, items)
|
|
312
|
+
|
|
313
|
+
text = pdb_text(block) if as_pdb else doc.as_string()
|
|
314
|
+
if path.name.lower().endswith(".gz"):
|
|
315
|
+
with gzip.open(path, "wt") as file:
|
|
316
|
+
file.write(text)
|
|
317
|
+
else:
|
|
318
|
+
path.write_text(text)
|
|
319
|
+
|
|
320
|
+
skipped = unwritten(st, residues, chains)
|
|
321
|
+
if skipped:
|
|
322
|
+
warnings.warn(f"Not written: {', '.join(skipped)}.", stacklevel=2)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _file_format(path: Path) -> Literal["mmcif", "pdb"]:
|
|
326
|
+
"""Return the format the file name tells, or raise ValueError."""
|
|
327
|
+
stem = path.name.lower().removesuffix(".gz")
|
|
328
|
+
if stem.endswith((".cif", ".mmcif")):
|
|
329
|
+
return "mmcif"
|
|
330
|
+
if stem.endswith((".pdb", ".ent")):
|
|
331
|
+
return "pdb"
|
|
332
|
+
msg = f"{path} is not a .cif, .mmcif, .pdb, or .ent file, optionally gzipped."
|
|
333
|
+
raise ValueError(msg)
|
ciffer/cif/altloc.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .category import Category, group_rows
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def conformer_rows(site: Category, altloc: str | None) -> NDArray[np.intp]:
|
|
14
|
+
"""Return the ``_atom_site`` rows to keep for an altloc choice.
|
|
15
|
+
|
|
16
|
+
None keeps the first conformer as gemmi does, ``"occupancy"`` keeps the altloc
|
|
17
|
+
with the highest total occupancy at each residue position, and ``"all"`` keeps
|
|
18
|
+
every row.
|
|
19
|
+
|
|
20
|
+
Examples
|
|
21
|
+
--------
|
|
22
|
+
.. code-block:: python
|
|
23
|
+
|
|
24
|
+
site = site.take(conformer_rows(site, "occupancy"))
|
|
25
|
+
|
|
26
|
+
"""
|
|
27
|
+
if altloc == "all":
|
|
28
|
+
return np.arange(len(site))
|
|
29
|
+
if altloc == "occupancy":
|
|
30
|
+
return _highest_occupancy(site)
|
|
31
|
+
return _first(site)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _first(site: Category) -> NDArray[np.intp]:
|
|
35
|
+
"""Return the rows gemmi keeps when it removes alternative conformations.
|
|
36
|
+
|
|
37
|
+
Each residue position keeps the residue type of its first row, and that residue
|
|
38
|
+
keeps the first row of each atom name.
|
|
39
|
+
"""
|
|
40
|
+
name, atom = site["label_comp_id"], site["label_atom_id"]
|
|
41
|
+
first, position = _positions(site)
|
|
42
|
+
candidates = np.flatnonzero(name == name[first][position])
|
|
43
|
+
first_atoms, _ = group_rows(position[candidates], atom[candidates])
|
|
44
|
+
return candidates[first_atoms]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _highest_occupancy(site: Category) -> NDArray[np.intp]:
|
|
48
|
+
"""Return the rows of the altloc with the highest total occupancy at each position.
|
|
49
|
+
|
|
50
|
+
Rows without an altloc are kept, and ties go to the earlier letter. A missing
|
|
51
|
+
occupancy counts as 0.
|
|
52
|
+
"""
|
|
53
|
+
altloc = site["label_alt_id"]
|
|
54
|
+
occupancy = site.get("occupancy")
|
|
55
|
+
occupancy = np.where(occupancy == "", "0", occupancy).astype(float)
|
|
56
|
+
_, position = _positions(site)
|
|
57
|
+
|
|
58
|
+
rows = np.flatnonzero(altloc != "")
|
|
59
|
+
first, pair = group_rows(position[rows], altloc[rows])
|
|
60
|
+
total = np.bincount(pair, weights=occupancy[rows])
|
|
61
|
+
|
|
62
|
+
# Sort the (position, altloc) pairs so that the best one leads each position.
|
|
63
|
+
pair_position, pair_altloc = position[rows][first], altloc[rows][first]
|
|
64
|
+
order = np.lexsort((pair_altloc, -total, pair_position))
|
|
65
|
+
best = order[np.unique(pair_position[order], return_index=True)[1]]
|
|
66
|
+
|
|
67
|
+
keep = altloc == ""
|
|
68
|
+
keep[rows[np.isin(pair, best)]] = True
|
|
69
|
+
return np.flatnonzero(keep)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _positions(site: Category) -> tuple[NDArray[np.intp], NDArray[np.intp]]:
|
|
73
|
+
"""Return the first row of each residue position, and the position of each row."""
|
|
74
|
+
return group_rows(
|
|
75
|
+
site["label_asym_id"],
|
|
76
|
+
site["label_seq_id"],
|
|
77
|
+
site.get("auth_seq_id"),
|
|
78
|
+
site.get("pdbx_PDB_ins_code"),
|
|
79
|
+
)
|
ciffer/cif/assembly.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
from ciffer.structure import Structure, concat
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from .category import Category
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def chosen_assembly(assemblies: list[str], *, assembly: bool | int | str) -> str | None:
|
|
15
|
+
"""Return the assembly id to build, or None to keep the deposited chains.
|
|
16
|
+
|
|
17
|
+
>>> chosen_assembly(["1", "2"], assembly=True)
|
|
18
|
+
'1'
|
|
19
|
+
>>> chosen_assembly(["1", "2"], assembly=2)
|
|
20
|
+
'2'
|
|
21
|
+
>>> chosen_assembly([], assembly=True) is None
|
|
22
|
+
True
|
|
23
|
+
|
|
24
|
+
"""
|
|
25
|
+
if assembly is False:
|
|
26
|
+
return None
|
|
27
|
+
if assembly is True:
|
|
28
|
+
return assemblies[0] if assemblies else None
|
|
29
|
+
return str(assembly) # ids are text, but users write 1 for "1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def assemble(
|
|
33
|
+
st: Structure, assembly_id: str, *, gen: Category, oper: Category
|
|
34
|
+
) -> Structure:
|
|
35
|
+
"""Copy chains by the operators of an assembly.
|
|
36
|
+
|
|
37
|
+
Copies are named as gemmi names them: the copy of author chain A made by the n-th
|
|
38
|
+
operator is A{n}, or the next free number, and its label_asym_id gets the same
|
|
39
|
+
number. Connections between chains of different copies are not made.
|
|
40
|
+
|
|
41
|
+
Examples
|
|
42
|
+
--------
|
|
43
|
+
.. code-block:: python
|
|
44
|
+
|
|
45
|
+
gen = Category(block, "_pdbx_struct_assembly_gen.")
|
|
46
|
+
oper = Category(block, "_pdbx_struct_oper_list.")
|
|
47
|
+
assemble(st, "1", gen=gen, oper=oper)
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
rows = np.flatnonzero(gen.get("assembly_id") == assembly_id)
|
|
51
|
+
if not rows.size:
|
|
52
|
+
ids = list(dict.fromkeys(gen.get("assembly_id").tolist()))
|
|
53
|
+
msg = f"Assembly {assembly_id!r} is not in the file; assemblies are {ids}."
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
rotations = np.array(
|
|
57
|
+
[[oper[f"matrix[{i}][{j}]"] for j in "123"] for i in "123"], dtype=float
|
|
58
|
+
).transpose(2, 0, 1)
|
|
59
|
+
shifts = np.array([oper[f"vector[{i}]"] for i in "123"], dtype=float).T
|
|
60
|
+
operators = dict(
|
|
61
|
+
zip(oper["id"].tolist(), zip(rotations, shifts, strict=True), strict=True)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
copies, used, counter = [], set(), 0
|
|
65
|
+
for row in rows:
|
|
66
|
+
chains = gen["asym_id_list"][row].split(",")
|
|
67
|
+
selected = np.isin(st.chains.label_asym_id, chains)
|
|
68
|
+
|
|
69
|
+
for steps in _operator_steps(gen["oper_expression"][row]):
|
|
70
|
+
counter += 1
|
|
71
|
+
if not selected.any():
|
|
72
|
+
continue
|
|
73
|
+
part = st.chains[selected].extract()
|
|
74
|
+
xyz = part.atoms.xyz
|
|
75
|
+
for step in steps:
|
|
76
|
+
rotation, shift = operators[step]
|
|
77
|
+
xyz = xyz @ rotation.T + shift
|
|
78
|
+
part.atoms.xyz = xyz
|
|
79
|
+
|
|
80
|
+
# gemmi numbers copies by operator, skipping names already taken.
|
|
81
|
+
auth = part.chains.auth_asym_id.tolist()
|
|
82
|
+
number = {}
|
|
83
|
+
for name in dict.fromkeys(auth):
|
|
84
|
+
n = counter
|
|
85
|
+
while f"{name}{n}" in used:
|
|
86
|
+
n += 1
|
|
87
|
+
used.add(f"{name}{n}")
|
|
88
|
+
number[name] = str(n)
|
|
89
|
+
suffix = [number[name] for name in auth]
|
|
90
|
+
part.chains.auth_asym_id = np.char.add(auth, suffix)
|
|
91
|
+
part.chains.label_asym_id = np.char.add(part.chains.label_asym_id, suffix)
|
|
92
|
+
copies.append(part)
|
|
93
|
+
|
|
94
|
+
if not copies:
|
|
95
|
+
return st.chains[np.zeros(len(st.chains), bool)].extract()
|
|
96
|
+
return concat(copies)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _operator_steps(expression: str) -> list[tuple[str, ...]]:
|
|
100
|
+
"""Return the operator ids of each copy, in the order they are applied.
|
|
101
|
+
|
|
102
|
+
Operators of a product apply from right to left, as biotite applies them.
|
|
103
|
+
|
|
104
|
+
>>> _operator_steps("1,3-4")
|
|
105
|
+
[('1',), ('3',), ('4',)]
|
|
106
|
+
>>> _operator_steps("(X0)(1-2)")
|
|
107
|
+
[('1', 'X0'), ('2', 'X0')]
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
groups = [group for group in expression.replace(")", "").split("(") if group]
|
|
111
|
+
steps = []
|
|
112
|
+
for group in reversed(groups):
|
|
113
|
+
ids = []
|
|
114
|
+
for part in group.split(","):
|
|
115
|
+
first, _, last = part.partition("-")
|
|
116
|
+
if last:
|
|
117
|
+
ids += [str(n) for n in range(int(first), int(last) + 1)]
|
|
118
|
+
else:
|
|
119
|
+
ids.append(part)
|
|
120
|
+
steps.append(ids)
|
|
121
|
+
return list(itertools.product(*steps))
|