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 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))