protein-quest 0.3.1__py3-none-any.whl → 0.4.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.
Potentially problematic release.
This version of protein-quest might be problematic. Click here for more details.
- protein_quest/__version__.py +1 -1
- protein_quest/alphafold/confidence.py +42 -15
- protein_quest/alphafold/fetch.py +2 -4
- protein_quest/cli.py +292 -14
- protein_quest/converter.py +46 -0
- protein_quest/filters.py +39 -7
- protein_quest/go.py +1 -4
- protein_quest/mcp_server.py +14 -1
- protein_quest/pdbe/io.py +122 -41
- protein_quest/ss.py +284 -0
- protein_quest/taxonomy.py +1 -3
- protein_quest/uniprot.py +157 -4
- protein_quest/utils.py +28 -1
- {protein_quest-0.3.1.dist-info → protein_quest-0.4.0.dist-info}/METADATA +48 -4
- protein_quest-0.4.0.dist-info/RECORD +26 -0
- protein_quest-0.3.1.dist-info/RECORD +0 -24
- {protein_quest-0.3.1.dist-info → protein_quest-0.4.0.dist-info}/WHEEL +0 -0
- {protein_quest-0.3.1.dist-info → protein_quest-0.4.0.dist-info}/entry_points.txt +0 -0
- {protein_quest-0.3.1.dist-info → protein_quest-0.4.0.dist-info}/licenses/LICENSE +0 -0
protein_quest/filters.py
CHANGED
|
@@ -4,7 +4,7 @@ import logging
|
|
|
4
4
|
from collections.abc import Collection, Generator
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from
|
|
7
|
+
from typing import Literal
|
|
8
8
|
|
|
9
9
|
from dask.distributed import Client
|
|
10
10
|
from distributed.deploy.cluster import Cluster
|
|
@@ -15,6 +15,7 @@ from protein_quest.pdbe.io import (
|
|
|
15
15
|
nr_residues_in_chain,
|
|
16
16
|
write_single_chain_pdb_file,
|
|
17
17
|
)
|
|
18
|
+
from protein_quest.utils import CopyMethod, copyfile
|
|
18
19
|
|
|
19
20
|
logger = logging.getLogger(__name__)
|
|
20
21
|
|
|
@@ -29,11 +30,17 @@ class ChainFilterStatistics:
|
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
def filter_file_on_chain(
|
|
32
|
-
file_and_chain: tuple[Path, str],
|
|
33
|
+
file_and_chain: tuple[Path, str],
|
|
34
|
+
output_dir: Path,
|
|
35
|
+
out_chain: str = "A",
|
|
36
|
+
copy_method: CopyMethod = "copy",
|
|
33
37
|
) -> ChainFilterStatistics:
|
|
34
38
|
input_file, chain_id = file_and_chain
|
|
39
|
+
logger.debug("Filtering %s on chain %s", input_file, chain_id)
|
|
35
40
|
try:
|
|
36
|
-
output_file = write_single_chain_pdb_file(
|
|
41
|
+
output_file = write_single_chain_pdb_file(
|
|
42
|
+
input_file, chain_id, output_dir, out_chain=out_chain, copy_method=copy_method
|
|
43
|
+
)
|
|
37
44
|
return ChainFilterStatistics(
|
|
38
45
|
input_file=input_file,
|
|
39
46
|
chain_id=chain_id,
|
|
@@ -48,7 +55,8 @@ def filter_files_on_chain(
|
|
|
48
55
|
file2chains: Collection[tuple[Path, str]],
|
|
49
56
|
output_dir: Path,
|
|
50
57
|
out_chain: str = "A",
|
|
51
|
-
scheduler_address: str | Cluster | None = None,
|
|
58
|
+
scheduler_address: str | Cluster | Literal["sequential"] | None = None,
|
|
59
|
+
copy_method: CopyMethod = "copy",
|
|
52
60
|
) -> list[ChainFilterStatistics]:
|
|
53
61
|
"""Filter mmcif/PDB files by chain.
|
|
54
62
|
|
|
@@ -58,19 +66,37 @@ def filter_files_on_chain(
|
|
|
58
66
|
output_dir: The directory where the filtered files will be written.
|
|
59
67
|
out_chain: Under what name to write the kept chain.
|
|
60
68
|
scheduler_address: The address of the Dask scheduler.
|
|
69
|
+
If not provided, will create a local cluster.
|
|
70
|
+
If set to `sequential` will run tasks sequentially.
|
|
71
|
+
copy_method: How to copy when a direct copy is possible.
|
|
61
72
|
|
|
62
73
|
Returns:
|
|
63
74
|
Result of the filtering process.
|
|
64
75
|
"""
|
|
65
76
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
if scheduler_address == "sequential":
|
|
78
|
+
|
|
79
|
+
def task(file_and_chain: tuple[Path, str]) -> ChainFilterStatistics:
|
|
80
|
+
return filter_file_on_chain(file_and_chain, output_dir, out_chain=out_chain, copy_method=copy_method)
|
|
81
|
+
|
|
82
|
+
return list(map(task, file2chains))
|
|
83
|
+
|
|
84
|
+
# TODO make logger.debug in filter_file_on_chain show to user when --log
|
|
85
|
+
# GPT-5 generated a fairly difficult setup with a WorkerPlugin, need to find a simpler approach
|
|
66
86
|
scheduler_address = configure_dask_scheduler(
|
|
67
87
|
scheduler_address,
|
|
68
88
|
name="filter-chain",
|
|
69
89
|
)
|
|
70
90
|
|
|
71
91
|
with Client(scheduler_address) as client:
|
|
92
|
+
client.forward_logging()
|
|
72
93
|
return dask_map_with_progress(
|
|
73
|
-
client,
|
|
94
|
+
client,
|
|
95
|
+
filter_file_on_chain,
|
|
96
|
+
file2chains,
|
|
97
|
+
output_dir=output_dir,
|
|
98
|
+
out_chain=out_chain,
|
|
99
|
+
copy_method=copy_method,
|
|
74
100
|
)
|
|
75
101
|
|
|
76
102
|
|
|
@@ -92,7 +118,12 @@ class ResidueFilterStatistics:
|
|
|
92
118
|
|
|
93
119
|
|
|
94
120
|
def filter_files_on_residues(
|
|
95
|
-
input_files: list[Path],
|
|
121
|
+
input_files: list[Path],
|
|
122
|
+
output_dir: Path,
|
|
123
|
+
min_residues: int,
|
|
124
|
+
max_residues: int,
|
|
125
|
+
chain: str = "A",
|
|
126
|
+
copy_method: CopyMethod = "copy",
|
|
96
127
|
) -> Generator[ResidueFilterStatistics]:
|
|
97
128
|
"""Filter PDB/mmCIF files by number of residues in given chain.
|
|
98
129
|
|
|
@@ -102,6 +133,7 @@ def filter_files_on_residues(
|
|
|
102
133
|
min_residues: The minimum number of residues in chain.
|
|
103
134
|
max_residues: The maximum number of residues in chain.
|
|
104
135
|
chain: The chain to count residues of.
|
|
136
|
+
copy_method: How to copy passed files to output directory:
|
|
105
137
|
|
|
106
138
|
Yields:
|
|
107
139
|
Objects containing information about the filtering process for each input file.
|
|
@@ -112,7 +144,7 @@ def filter_files_on_residues(
|
|
|
112
144
|
passed = min_residues <= residue_count <= max_residues
|
|
113
145
|
if passed:
|
|
114
146
|
output_file = output_dir / input_file.name
|
|
115
|
-
copyfile(input_file, output_file)
|
|
147
|
+
copyfile(input_file, output_file, copy_method)
|
|
116
148
|
yield ResidueFilterStatistics(input_file, residue_count, True, output_file)
|
|
117
149
|
else:
|
|
118
150
|
yield ResidueFilterStatistics(input_file, residue_count, False, None)
|
protein_quest/go.py
CHANGED
|
@@ -8,8 +8,8 @@ from io import TextIOWrapper
|
|
|
8
8
|
from typing import Literal, get_args
|
|
9
9
|
|
|
10
10
|
from cattrs.gen import make_dict_structure_fn, override
|
|
11
|
-
from cattrs.preconf.orjson import make_converter
|
|
12
11
|
|
|
12
|
+
from protein_quest.converter import converter
|
|
13
13
|
from protein_quest.utils import friendly_session
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
@@ -52,9 +52,6 @@ class SearchResponse:
|
|
|
52
52
|
page_info: PageInfo
|
|
53
53
|
|
|
54
54
|
|
|
55
|
-
converter = make_converter()
|
|
56
|
-
|
|
57
|
-
|
|
58
55
|
def flatten_definition(definition, _context) -> str:
|
|
59
56
|
return definition["text"]
|
|
60
57
|
|
protein_quest/mcp_server.py
CHANGED
|
@@ -46,8 +46,17 @@ from protein_quest.emdb import fetch as emdb_fetch
|
|
|
46
46
|
from protein_quest.go import search_gene_ontology_term
|
|
47
47
|
from protein_quest.pdbe.fetch import fetch as pdbe_fetch
|
|
48
48
|
from protein_quest.pdbe.io import glob_structure_files, nr_residues_in_chain, write_single_chain_pdb_file
|
|
49
|
+
from protein_quest.ss import filter_file_on_secondary_structure
|
|
49
50
|
from protein_quest.taxonomy import search_taxon
|
|
50
|
-
from protein_quest.uniprot import
|
|
51
|
+
from protein_quest.uniprot import (
|
|
52
|
+
PdbResult,
|
|
53
|
+
Query,
|
|
54
|
+
search4af,
|
|
55
|
+
search4emdb,
|
|
56
|
+
search4macromolecular_complexes,
|
|
57
|
+
search4pdb,
|
|
58
|
+
search4uniprot,
|
|
59
|
+
)
|
|
51
60
|
|
|
52
61
|
mcp = FastMCP("protein-quest")
|
|
53
62
|
|
|
@@ -136,6 +145,7 @@ def search_alphafolds(
|
|
|
136
145
|
|
|
137
146
|
|
|
138
147
|
mcp.tool(search4emdb, name="search_emdb")
|
|
148
|
+
mcp.tool(search4macromolecular_complexes, name="search_macromolecular_complexes")
|
|
139
149
|
|
|
140
150
|
|
|
141
151
|
@mcp.tool
|
|
@@ -165,6 +175,9 @@ def alphafold_confidence_filter(file: Path, query: ConfidenceFilterQuery, filter
|
|
|
165
175
|
return filter_file_on_residues(file, query, filtered_dir)
|
|
166
176
|
|
|
167
177
|
|
|
178
|
+
mcp.tool(filter_file_on_secondary_structure)
|
|
179
|
+
|
|
180
|
+
|
|
168
181
|
@mcp.prompt
|
|
169
182
|
def candidate_structures(
|
|
170
183
|
species: str = "Human",
|
protein_quest/pdbe/io.py
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import gzip
|
|
4
4
|
import logging
|
|
5
|
-
from collections.abc import Generator
|
|
5
|
+
from collections.abc import Generator, Iterable
|
|
6
|
+
from datetime import UTC, datetime
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
import gemmi
|
|
9
10
|
|
|
10
|
-
from protein_quest import __version__
|
|
11
|
+
from protein_quest.__version__ import __version__
|
|
12
|
+
from protein_quest.utils import CopyMethod, copyfile
|
|
11
13
|
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
@@ -28,14 +30,21 @@ def nr_residues_in_chain(file: Path | str, chain: str = "A") -> int:
|
|
|
28
30
|
The number of residues in the specified chain.
|
|
29
31
|
"""
|
|
30
32
|
structure = gemmi.read_structure(str(file))
|
|
31
|
-
|
|
32
|
-
gchain = find_chain_in_model(model, chain)
|
|
33
|
+
gchain = find_chain_in_structure(structure, chain)
|
|
33
34
|
if gchain is None:
|
|
34
35
|
logger.warning("Chain %s not found in %s. Returning 0.", chain, file)
|
|
35
36
|
return 0
|
|
36
37
|
return len(gchain)
|
|
37
38
|
|
|
38
39
|
|
|
40
|
+
def find_chain_in_structure(structure: gemmi.Structure, wanted_chain: str) -> gemmi.Chain | None:
|
|
41
|
+
for model in structure:
|
|
42
|
+
chain = find_chain_in_model(model, wanted_chain)
|
|
43
|
+
if chain is not None:
|
|
44
|
+
return chain
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
39
48
|
def find_chain_in_model(model: gemmi.Model, wanted_chain: str) -> gemmi.Chain | None:
|
|
40
49
|
chain = model.find_chain(wanted_chain)
|
|
41
50
|
if chain is None:
|
|
@@ -68,10 +77,12 @@ def write_structure(structure: gemmi.Structure, path: Path):
|
|
|
68
77
|
with gzip.open(path, "wt") as f:
|
|
69
78
|
f.write(body)
|
|
70
79
|
elif path.name.endswith(".cif"):
|
|
71
|
-
|
|
80
|
+
# do not write chem_comp so it is viewable by molstar
|
|
81
|
+
# see https://github.com/project-gemmi/gemmi/discussions/362
|
|
82
|
+
doc = structure.make_mmcif_document(gemmi.MmcifOutputGroups(True, chem_comp=False))
|
|
72
83
|
doc.write_file(str(path))
|
|
73
84
|
elif path.name.endswith(".cif.gz"):
|
|
74
|
-
doc = structure.make_mmcif_document()
|
|
85
|
+
doc = structure.make_mmcif_document(gemmi.MmcifOutputGroups(True, chem_comp=False))
|
|
75
86
|
cif_str = doc.as_string()
|
|
76
87
|
with gzip.open(path, "wt") as f:
|
|
77
88
|
f.write(cif_str)
|
|
@@ -111,14 +122,17 @@ def locate_structure_file(root: Path, pdb_id: str) -> Path:
|
|
|
111
122
|
Raises:
|
|
112
123
|
FileNotFoundError: If no structure file is found for the given PDB ID.
|
|
113
124
|
"""
|
|
114
|
-
exts = [".cif.gz", ".cif", ".pdb.gz", ".pdb"]
|
|
115
|
-
# files downloaded from https://www.ebi.ac.uk/pdbe/ website
|
|
116
|
-
# have file names like pdb6t5y.ent or pdb6t5y.ent.gz for a PDB formatted file.
|
|
117
|
-
# TODO support pdb6t5y.ent or pdb6t5y.ent.gz file names
|
|
125
|
+
exts = [".cif.gz", ".cif", ".pdb.gz", ".pdb", ".ent", ".ent.gz"]
|
|
118
126
|
for ext in exts:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
candidates = (
|
|
128
|
+
root / f"{pdb_id}{ext}",
|
|
129
|
+
root / f"{pdb_id.lower()}{ext}",
|
|
130
|
+
root / f"{pdb_id.upper()}{ext}",
|
|
131
|
+
root / f"pdb{pdb_id.lower()}{ext}",
|
|
132
|
+
)
|
|
133
|
+
for candidate in candidates:
|
|
134
|
+
if candidate.exists():
|
|
135
|
+
return candidate
|
|
122
136
|
msg = f"No structure file found for {pdb_id} in {root}"
|
|
123
137
|
raise FileNotFoundError(msg)
|
|
124
138
|
|
|
@@ -139,20 +153,84 @@ def glob_structure_files(input_dir: Path) -> Generator[Path]:
|
|
|
139
153
|
class ChainNotFoundError(IndexError):
|
|
140
154
|
"""Exception raised when a chain is not found in a structure."""
|
|
141
155
|
|
|
142
|
-
def __init__(self, chain: str, file: Path | str):
|
|
143
|
-
super().__init__(f"Chain {chain} not found in {file}")
|
|
156
|
+
def __init__(self, chain: str, file: Path | str, available_chains: Iterable[str]):
|
|
157
|
+
super().__init__(f"Chain {chain} not found in {file}. Available chains are: {available_chains}")
|
|
144
158
|
self.chain_id = chain
|
|
145
159
|
self.file = file
|
|
146
160
|
|
|
147
161
|
|
|
148
|
-
def
|
|
162
|
+
def _dedup_helices(structure: gemmi.Structure):
|
|
163
|
+
helix_starts: set[str] = set()
|
|
164
|
+
duplicate_helix_indexes: list[int] = []
|
|
165
|
+
for hindex, helix in enumerate(structure.helices):
|
|
166
|
+
if str(helix.start) in helix_starts:
|
|
167
|
+
logger.debug(f"Duplicate start helix found: {hindex} {helix.start}, removing")
|
|
168
|
+
duplicate_helix_indexes.append(hindex)
|
|
169
|
+
else:
|
|
170
|
+
helix_starts.add(str(helix.start))
|
|
171
|
+
for helix_index in reversed(duplicate_helix_indexes):
|
|
172
|
+
structure.helices.pop(helix_index)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _dedup_sheets(structure: gemmi.Structure, chain2keep: str):
|
|
176
|
+
duplicate_sheet_indexes: list[int] = []
|
|
177
|
+
for sindex, sheet in enumerate(structure.sheets):
|
|
178
|
+
if sheet.name != chain2keep:
|
|
179
|
+
duplicate_sheet_indexes.append(sindex)
|
|
180
|
+
for sheet_index in reversed(duplicate_sheet_indexes):
|
|
181
|
+
structure.sheets.pop(sheet_index)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _add_provenance_info(structure: gemmi.Structure, chain2keep: str, out_chain: str):
|
|
185
|
+
old_id = structure.name
|
|
186
|
+
new_id = structure.name + f"{chain2keep}2{out_chain}"
|
|
187
|
+
structure.name = new_id
|
|
188
|
+
structure.info["_entry.id"] = new_id
|
|
189
|
+
new_title = f"From {old_id} chain {chain2keep} to {out_chain}"
|
|
190
|
+
structure.info["_struct.title"] = new_title
|
|
191
|
+
structure.info["_struct_keywords.pdbx_keywords"] = new_title.upper()
|
|
192
|
+
new_si = gemmi.SoftwareItem()
|
|
193
|
+
new_si.classification = gemmi.SoftwareItem.Classification.DataExtraction
|
|
194
|
+
new_si.name = "protein-quest.pdbe.io.write_single_chain_pdb_file"
|
|
195
|
+
new_si.version = str(__version__)
|
|
196
|
+
new_si.date = str(datetime.now(tz=UTC).date())
|
|
197
|
+
structure.meta.software = [*structure.meta.software, new_si]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def chains_in_structure(structure: gemmi.Structure) -> set[gemmi.Chain]:
|
|
201
|
+
"""Get a list of chains in a structure."""
|
|
202
|
+
return {c for model in structure for c in model}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def write_single_chain_pdb_file(
|
|
206
|
+
input_file: Path,
|
|
207
|
+
chain2keep: str,
|
|
208
|
+
output_dir: Path,
|
|
209
|
+
out_chain: str = "A",
|
|
210
|
+
copy_method: CopyMethod = "copy",
|
|
211
|
+
) -> Path:
|
|
149
212
|
"""Write a single chain from a mmCIF/pdb file to a new mmCIF/pdb file.
|
|
150
213
|
|
|
214
|
+
Also
|
|
215
|
+
|
|
216
|
+
- removes ligands and waters
|
|
217
|
+
- renumbers atoms ids
|
|
218
|
+
- removes chem_comp section from cif files
|
|
219
|
+
- adds provenance information to the header like software and input file+chain
|
|
220
|
+
|
|
221
|
+
This function is equivalent to the following gemmi commands:
|
|
222
|
+
|
|
223
|
+
```shell
|
|
224
|
+
gemmi convert --remove-lig-wat --select=B --to=cif chain-in/3JRS.cif - | \\
|
|
225
|
+
gemmi convert --from=cif --rename-chain=B:A - chain-out/3JRS_B2A.gemmi.cif
|
|
226
|
+
```
|
|
227
|
+
|
|
151
228
|
Args:
|
|
152
229
|
input_file: Path to the input mmCIF/pdb file.
|
|
153
230
|
chain2keep: The chain to keep.
|
|
154
231
|
output_dir: Directory to save the output file.
|
|
155
232
|
out_chain: The chain identifier for the output file.
|
|
233
|
+
copy_method: How to copy when no changes are needed to output file.
|
|
156
234
|
|
|
157
235
|
Returns:
|
|
158
236
|
Path to the output mmCIF/pdb file
|
|
@@ -162,39 +240,42 @@ def write_single_chain_pdb_file(input_file: Path, chain2keep: str, output_dir: P
|
|
|
162
240
|
ChainNotFoundError: If the specified chain is not found in the input file.
|
|
163
241
|
"""
|
|
164
242
|
|
|
243
|
+
logger.debug(f"chain2keep: {chain2keep}, out_chain: {out_chain}")
|
|
165
244
|
structure = gemmi.read_structure(str(input_file))
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# Only count residues of polymer
|
|
169
|
-
model.remove_ligands_and_waters()
|
|
245
|
+
structure.setup_entities()
|
|
170
246
|
|
|
171
|
-
chain =
|
|
247
|
+
chain = find_chain_in_structure(structure, chain2keep)
|
|
248
|
+
chainnames_in_structure = {c.name for c in chains_in_structure(structure)}
|
|
172
249
|
if chain is None:
|
|
173
|
-
raise ChainNotFoundError(chain2keep, input_file)
|
|
250
|
+
raise ChainNotFoundError(chain2keep, input_file, chainnames_in_structure)
|
|
251
|
+
chain_name = chain.name
|
|
174
252
|
name, extension = _split_name_and_extension(input_file.name)
|
|
175
|
-
output_file = output_dir / f"{name}_{
|
|
253
|
+
output_file = output_dir / f"{name}_{chain_name}2{out_chain}{extension}"
|
|
176
254
|
|
|
177
255
|
if output_file.exists():
|
|
178
256
|
logger.info("Output file %s already exists for input file %s. Skipping.", output_file, input_file)
|
|
179
257
|
return output_file
|
|
180
258
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
259
|
+
if chain_name == out_chain and len(chainnames_in_structure) == 1:
|
|
260
|
+
logger.info(
|
|
261
|
+
"%s only has chain %s and out_chain is also %s. Copying file to %s.",
|
|
262
|
+
input_file,
|
|
263
|
+
chain_name,
|
|
264
|
+
out_chain,
|
|
265
|
+
output_file,
|
|
266
|
+
)
|
|
267
|
+
copyfile(input_file, output_file, copy_method)
|
|
268
|
+
return output_file
|
|
269
|
+
|
|
270
|
+
gemmi.Selection(chain_name).remove_not_selected(structure)
|
|
271
|
+
for m in structure:
|
|
272
|
+
m.remove_ligands_and_waters()
|
|
273
|
+
structure.setup_entities()
|
|
274
|
+
structure.rename_chain(chain_name, out_chain)
|
|
275
|
+
_dedup_helices(structure)
|
|
276
|
+
_dedup_sheets(structure, out_chain)
|
|
277
|
+
_add_provenance_info(structure, chain_name, out_chain)
|
|
278
|
+
|
|
279
|
+
write_structure(structure, output_file)
|
|
199
280
|
|
|
200
281
|
return output_file
|
protein_quest/ss.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Module for dealing with secondary structure."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Generator, Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from gemmi import Structure, read_structure, set_leak_warnings
|
|
9
|
+
|
|
10
|
+
from protein_quest.converter import PositiveInt, Ratio, converter
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# TODO remove once v0.7.4 of gemmi is released,
|
|
15
|
+
# as uv pip install git+https://github.com/project-gemmi/gemmi.git installs 0.7.4.dev0 which does not print leaks
|
|
16
|
+
# Swallow gemmi leaked function warnings
|
|
17
|
+
set_leak_warnings(False)
|
|
18
|
+
|
|
19
|
+
# TODO if a structure has no secondary structure information, calculate it with `gemmi ss`.
|
|
20
|
+
# https://github.com/MonomerLibrary/monomers/wiki/Installation as --monomers dir
|
|
21
|
+
# gemmi executable is in https://pypi.org/project/gemmi-program/
|
|
22
|
+
# `gemmi ss` only prints secondary structure to stdout with `-v` flag.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def nr_of_residues_in_total(structure: Structure) -> int:
|
|
26
|
+
"""Count the total number of residues in the structure.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
structure: The gemmi Structure object to analyze.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The total number of residues in the structure.
|
|
33
|
+
"""
|
|
34
|
+
count = 0
|
|
35
|
+
for model in structure:
|
|
36
|
+
for chain in model:
|
|
37
|
+
count += len(chain)
|
|
38
|
+
return count
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def nr_of_residues_in_helix(structure: Structure) -> int:
|
|
42
|
+
"""Count the number of residues in alpha helices.
|
|
43
|
+
|
|
44
|
+
Requires structure to have secondary structure information.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
structure: The gemmi Structure object to analyze.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
The number of residues in alpha helices.
|
|
51
|
+
"""
|
|
52
|
+
# For cif files from AlphaFold the helix.length is set to -1
|
|
53
|
+
# so use resid instead
|
|
54
|
+
count = 0
|
|
55
|
+
for helix in structure.helices:
|
|
56
|
+
end = helix.end.res_id.seqid.num
|
|
57
|
+
start = helix.start.res_id.seqid.num
|
|
58
|
+
if end is None or start is None:
|
|
59
|
+
logger.warning(f"Invalid helix coordinates: {helix.end} or {helix.start}")
|
|
60
|
+
continue
|
|
61
|
+
length = end - start + 1
|
|
62
|
+
count += length
|
|
63
|
+
return count
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def nr_of_residues_in_sheet(structure: Structure) -> int:
|
|
67
|
+
"""Count the number of residues in beta sheets.
|
|
68
|
+
|
|
69
|
+
Requires structure to have secondary structure information.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
structure: The gemmi Structure object to analyze.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The number of residues in beta sheets.
|
|
76
|
+
"""
|
|
77
|
+
count = 0
|
|
78
|
+
for sheet in structure.sheets:
|
|
79
|
+
for strand in sheet.strands:
|
|
80
|
+
end = strand.end.res_id.seqid.num
|
|
81
|
+
start = strand.start.res_id.seqid.num
|
|
82
|
+
if end is None or start is None:
|
|
83
|
+
logger.warning(f"Invalid strand coordinates: {strand.end} or {strand.start}")
|
|
84
|
+
continue
|
|
85
|
+
length = end - start + 1
|
|
86
|
+
count += length
|
|
87
|
+
return count
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class SecondaryStructureFilterQuery:
|
|
92
|
+
"""Query object to filter on secondary structure.
|
|
93
|
+
|
|
94
|
+
Parameters:
|
|
95
|
+
abs_min_helix_residues: Minimum number of residues in helices (absolute).
|
|
96
|
+
abs_max_helix_residues: Maximum number of residues in helices (absolute).
|
|
97
|
+
abs_min_sheet_residues: Minimum number of residues in sheets (absolute).
|
|
98
|
+
abs_max_sheet_residues: Maximum number of residues in sheets (absolute).
|
|
99
|
+
ratio_min_helix_residues: Minimum number of residues in helices (relative).
|
|
100
|
+
ratio_max_helix_residues: Maximum number of residues in helices (relative).
|
|
101
|
+
ratio_min_sheet_residues: Minimum number of residues in sheets (relative).
|
|
102
|
+
ratio_max_sheet_residues: Maximum number of residues in sheets (relative).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
abs_min_helix_residues: PositiveInt | None = None
|
|
106
|
+
abs_max_helix_residues: PositiveInt | None = None
|
|
107
|
+
abs_min_sheet_residues: PositiveInt | None = None
|
|
108
|
+
abs_max_sheet_residues: PositiveInt | None = None
|
|
109
|
+
ratio_min_helix_residues: Ratio | None = None
|
|
110
|
+
ratio_max_helix_residues: Ratio | None = None
|
|
111
|
+
ratio_min_sheet_residues: Ratio | None = None
|
|
112
|
+
ratio_max_sheet_residues: Ratio | None = None
|
|
113
|
+
|
|
114
|
+
def is_actionable(self) -> bool:
|
|
115
|
+
"""Check if the secondary structure query has any actionable filters.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
True if any of the filters are set, False otherwise.
|
|
119
|
+
"""
|
|
120
|
+
return any(
|
|
121
|
+
field is not None
|
|
122
|
+
for field in [
|
|
123
|
+
self.abs_min_helix_residues,
|
|
124
|
+
self.abs_max_helix_residues,
|
|
125
|
+
self.abs_min_sheet_residues,
|
|
126
|
+
self.abs_max_sheet_residues,
|
|
127
|
+
self.ratio_min_helix_residues,
|
|
128
|
+
self.ratio_max_helix_residues,
|
|
129
|
+
self.ratio_min_sheet_residues,
|
|
130
|
+
self.ratio_max_sheet_residues,
|
|
131
|
+
]
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_range(min_val, max_val, label):
|
|
136
|
+
if min_val is not None and max_val is not None and min_val >= max_val:
|
|
137
|
+
msg = f"Invalid {label} range: min {min_val} must be smaller than max {max_val}"
|
|
138
|
+
raise ValueError(msg)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
base_query_hook = converter.get_structure_hook(SecondaryStructureFilterQuery)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@converter.register_structure_hook
|
|
145
|
+
def secondary_structure_filter_query_hook(value, _type) -> SecondaryStructureFilterQuery:
|
|
146
|
+
result: SecondaryStructureFilterQuery = base_query_hook(value, _type)
|
|
147
|
+
_check_range(result.abs_min_helix_residues, result.abs_max_helix_residues, "absolute helix residue")
|
|
148
|
+
_check_range(result.abs_min_sheet_residues, result.abs_max_sheet_residues, "absolute sheet residue")
|
|
149
|
+
_check_range(result.ratio_min_helix_residues, result.ratio_max_helix_residues, "ratio helix residue")
|
|
150
|
+
_check_range(result.ratio_min_sheet_residues, result.ratio_max_sheet_residues, "ratio sheet residue")
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass
|
|
155
|
+
class SecondaryStructureStats:
|
|
156
|
+
"""Statistics about the secondary structure of a protein.
|
|
157
|
+
|
|
158
|
+
Parameters:
|
|
159
|
+
nr_residues: Total number of residues in the structure.
|
|
160
|
+
nr_helix_residues: Number of residues in helices.
|
|
161
|
+
nr_sheet_residues: Number of residues in sheets.
|
|
162
|
+
helix_ratio: Ratio of residues in helices.
|
|
163
|
+
sheet_ratio: Ratio of residues in sheets.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
nr_residues: PositiveInt
|
|
167
|
+
nr_helix_residues: PositiveInt
|
|
168
|
+
nr_sheet_residues: PositiveInt
|
|
169
|
+
helix_ratio: Ratio
|
|
170
|
+
sheet_ratio: Ratio
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class SecondaryStructureFilterResult:
|
|
175
|
+
"""Result of filtering on secondary structure.
|
|
176
|
+
|
|
177
|
+
Parameters:
|
|
178
|
+
stats: The secondary structure statistics.
|
|
179
|
+
passed: Whether the structure passed the filtering criteria.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
stats: SecondaryStructureStats
|
|
183
|
+
passed: bool = False
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _gather_stats(structure: Structure) -> SecondaryStructureStats:
|
|
187
|
+
nr_total_residues = nr_of_residues_in_total(structure)
|
|
188
|
+
nr_helix_residues = nr_of_residues_in_helix(structure)
|
|
189
|
+
nr_sheet_residues = nr_of_residues_in_sheet(structure)
|
|
190
|
+
if nr_total_residues == 0:
|
|
191
|
+
msg = "Structure has zero residues; cannot compute secondary structure ratios."
|
|
192
|
+
raise ValueError(msg)
|
|
193
|
+
helix_ratio = nr_helix_residues / nr_total_residues
|
|
194
|
+
sheet_ratio = nr_sheet_residues / nr_total_residues
|
|
195
|
+
return SecondaryStructureStats(
|
|
196
|
+
nr_residues=nr_total_residues,
|
|
197
|
+
nr_helix_residues=nr_helix_residues,
|
|
198
|
+
nr_sheet_residues=nr_sheet_residues,
|
|
199
|
+
helix_ratio=helix_ratio,
|
|
200
|
+
sheet_ratio=sheet_ratio,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def filter_on_secondary_structure(
|
|
205
|
+
structure: Structure,
|
|
206
|
+
query: SecondaryStructureFilterQuery,
|
|
207
|
+
) -> SecondaryStructureFilterResult:
|
|
208
|
+
"""Filter a structure based on secondary structure criteria.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
structure: The gemmi Structure object to analyze.
|
|
212
|
+
query: The filtering criteria to apply.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Filtering statistics and whether structure passed.
|
|
216
|
+
"""
|
|
217
|
+
stats = _gather_stats(structure)
|
|
218
|
+
conditions: list[bool] = []
|
|
219
|
+
|
|
220
|
+
# Helix absolute thresholds
|
|
221
|
+
if query.abs_min_helix_residues is not None:
|
|
222
|
+
conditions.append(stats.nr_helix_residues >= query.abs_min_helix_residues)
|
|
223
|
+
if query.abs_max_helix_residues is not None:
|
|
224
|
+
conditions.append(stats.nr_helix_residues <= query.abs_max_helix_residues)
|
|
225
|
+
|
|
226
|
+
# Helix ratio thresholds
|
|
227
|
+
if query.ratio_min_helix_residues is not None:
|
|
228
|
+
conditions.append(stats.helix_ratio >= query.ratio_min_helix_residues)
|
|
229
|
+
if query.ratio_max_helix_residues is not None:
|
|
230
|
+
conditions.append(stats.helix_ratio <= query.ratio_max_helix_residues)
|
|
231
|
+
|
|
232
|
+
# Sheet absolute thresholds
|
|
233
|
+
if query.abs_min_sheet_residues is not None:
|
|
234
|
+
conditions.append(stats.nr_sheet_residues >= query.abs_min_sheet_residues)
|
|
235
|
+
if query.abs_max_sheet_residues is not None:
|
|
236
|
+
conditions.append(stats.nr_sheet_residues <= query.abs_max_sheet_residues)
|
|
237
|
+
|
|
238
|
+
# Sheet ratio thresholds
|
|
239
|
+
if query.ratio_min_sheet_residues is not None:
|
|
240
|
+
conditions.append(stats.sheet_ratio >= query.ratio_min_sheet_residues)
|
|
241
|
+
if query.ratio_max_sheet_residues is not None:
|
|
242
|
+
conditions.append(stats.sheet_ratio <= query.ratio_max_sheet_residues)
|
|
243
|
+
|
|
244
|
+
if not conditions:
|
|
245
|
+
msg = "No filtering conditions provided. Please specify at least one condition."
|
|
246
|
+
raise ValueError(msg)
|
|
247
|
+
passed = all(conditions)
|
|
248
|
+
return SecondaryStructureFilterResult(stats=stats, passed=passed)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def filter_file_on_secondary_structure(
|
|
252
|
+
file_path: Path,
|
|
253
|
+
query: SecondaryStructureFilterQuery,
|
|
254
|
+
) -> SecondaryStructureFilterResult:
|
|
255
|
+
"""Filter a structure file based on secondary structure criteria.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
file_path: The path to the structure file to analyze.
|
|
259
|
+
query: The filtering criteria to apply.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
Filtering statistics and whether file passed.
|
|
263
|
+
"""
|
|
264
|
+
structure = read_structure(str(file_path))
|
|
265
|
+
return filter_on_secondary_structure(structure, query)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def filter_files_on_secondary_structure(
|
|
269
|
+
file_paths: Iterable[Path],
|
|
270
|
+
query: SecondaryStructureFilterQuery,
|
|
271
|
+
) -> Generator[tuple[Path, SecondaryStructureFilterResult]]:
|
|
272
|
+
"""Filter multiple structure files based on secondary structure criteria.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
file_paths: A list of paths to the structure files to analyze.
|
|
276
|
+
query: The filtering criteria to apply.
|
|
277
|
+
|
|
278
|
+
Yields:
|
|
279
|
+
For each file returns the filtering statistics and whether structure passed.
|
|
280
|
+
"""
|
|
281
|
+
# TODO check if quick enough in serial mode, if not switch to dask map
|
|
282
|
+
for file_path in file_paths:
|
|
283
|
+
result = filter_file_on_secondary_structure(file_path, query)
|
|
284
|
+
yield file_path, result
|