mdprep 0.2.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.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Explicit structure repair with strict identity and geometry validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, replace
|
|
6
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
|
+
from math import dist
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from mdprep.config.models import ManifestConfig, ResidueSelector
|
|
13
|
+
from mdprep.structure.classify import is_standard_protein_residue
|
|
14
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
|
|
15
|
+
from mdprep.structure.pdb import read_pdb
|
|
16
|
+
from mdprep.structure.writer import write_pdb
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StructureRepairError(ValueError):
|
|
20
|
+
"""Raised when requested coordinate repair is unavailable or ambiguous."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class StructureRepairResult:
|
|
25
|
+
backend: str
|
|
26
|
+
backend_version: str
|
|
27
|
+
input_path: Path
|
|
28
|
+
sequence_source_path: Path | None
|
|
29
|
+
raw_output_path: Path
|
|
30
|
+
output_path: Path
|
|
31
|
+
structure: PdbStructure
|
|
32
|
+
missing_residues_detected: tuple[dict[str, object], ...]
|
|
33
|
+
missing_heavy_atoms_detected: tuple[dict[str, object], ...]
|
|
34
|
+
added_atom_count: int
|
|
35
|
+
peptide_bond_checks: tuple[dict[str, object], ...]
|
|
36
|
+
random_seed: int
|
|
37
|
+
platform: str
|
|
38
|
+
warnings: tuple[str, ...]
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict[str, object]:
|
|
41
|
+
return {
|
|
42
|
+
"backend": self.backend,
|
|
43
|
+
"backend_version": self.backend_version,
|
|
44
|
+
"input_path": str(self.input_path),
|
|
45
|
+
"sequence_source_path": (
|
|
46
|
+
str(self.sequence_source_path)
|
|
47
|
+
if self.sequence_source_path is not None
|
|
48
|
+
else None
|
|
49
|
+
),
|
|
50
|
+
"raw_output_path": str(self.raw_output_path),
|
|
51
|
+
"output_path": str(self.output_path),
|
|
52
|
+
"missing_residues_detected": list(self.missing_residues_detected),
|
|
53
|
+
"missing_heavy_atoms_detected": list(
|
|
54
|
+
self.missing_heavy_atoms_detected
|
|
55
|
+
),
|
|
56
|
+
"added_atom_count": self.added_atom_count,
|
|
57
|
+
"peptide_bond_checks": list(self.peptide_bond_checks),
|
|
58
|
+
"random_seed": self.random_seed,
|
|
59
|
+
"platform": self.platform,
|
|
60
|
+
"warnings": list(self.warnings),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def repair_structure(
|
|
65
|
+
manifest: ManifestConfig,
|
|
66
|
+
*,
|
|
67
|
+
output_path: str | Path,
|
|
68
|
+
) -> StructureRepairResult | None:
|
|
69
|
+
config = manifest.structure.repair
|
|
70
|
+
if config.backend == "none":
|
|
71
|
+
return None
|
|
72
|
+
if config.backend != "pdbfixer":
|
|
73
|
+
raise StructureRepairError(
|
|
74
|
+
f"Unsupported structure repair backend: {config.backend}"
|
|
75
|
+
)
|
|
76
|
+
try:
|
|
77
|
+
from openmm import Platform
|
|
78
|
+
from openmm.app import PDBFile
|
|
79
|
+
from pdbfixer import PDBFixer
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
raise StructureRepairError(
|
|
82
|
+
"structure.repair.backend: pdbfixer was requested, but PDBFixer "
|
|
83
|
+
"and OpenMM are unavailable. Install PDBFixer or disable repair."
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
input_path = Path(manifest.project.input_structure)
|
|
87
|
+
sequence_path = (
|
|
88
|
+
Path(config.sequence_source) if config.sequence_source is not None else None
|
|
89
|
+
)
|
|
90
|
+
if not input_path.is_file():
|
|
91
|
+
raise FileNotFoundError(f"Input structure not found: {input_path}")
|
|
92
|
+
if sequence_path is not None and not sequence_path.is_file():
|
|
93
|
+
raise FileNotFoundError(
|
|
94
|
+
f"Structure repair sequence source not found: {sequence_path}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
platform = Platform.getPlatformByName(config.platform)
|
|
99
|
+
fixer = PDBFixer(filename=str(input_path), platform=platform)
|
|
100
|
+
if sequence_path is not None:
|
|
101
|
+
sequence_fixer = PDBFixer(filename=str(sequence_path), platform=platform)
|
|
102
|
+
fixer.sequences = sequence_fixer.sequences
|
|
103
|
+
fixer.findMissingResidues()
|
|
104
|
+
detected_residues = _detected_missing_residues(fixer)
|
|
105
|
+
_validate_expected_missing_residues(
|
|
106
|
+
detected_residues,
|
|
107
|
+
expected=config.missing_residues,
|
|
108
|
+
)
|
|
109
|
+
fixer.findMissingAtoms()
|
|
110
|
+
detected_atoms = _detected_missing_atoms(fixer)
|
|
111
|
+
if detected_atoms and not config.add_missing_heavy_atoms:
|
|
112
|
+
formatted = ", ".join(
|
|
113
|
+
f"{item['chain']}:{item['resname']}{item['resid']} "
|
|
114
|
+
f"({','.join(item['atom_names'])})"
|
|
115
|
+
for item in detected_atoms
|
|
116
|
+
)
|
|
117
|
+
raise StructureRepairError(
|
|
118
|
+
"Missing protein heavy atoms were detected but "
|
|
119
|
+
"structure.repair.add_missing_heavy_atoms is false: "
|
|
120
|
+
+ formatted
|
|
121
|
+
)
|
|
122
|
+
fixer.addMissingAtoms(seed=config.random_seed)
|
|
123
|
+
except StructureRepairError:
|
|
124
|
+
raise
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
raise StructureRepairError(f"PDBFixer structure repair failed: {exc}") from exc
|
|
127
|
+
|
|
128
|
+
output = Path(output_path)
|
|
129
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
raw_output = output.with_name(output.stem + ".pdbfixer_raw.pdb")
|
|
131
|
+
with raw_output.open("w", encoding="utf-8") as handle:
|
|
132
|
+
PDBFile.writeFile(fixer.topology, fixer.positions, handle, keepIds=True)
|
|
133
|
+
|
|
134
|
+
original = read_pdb(
|
|
135
|
+
input_path,
|
|
136
|
+
altloc_policy=manifest.structure.altloc_policy,
|
|
137
|
+
)
|
|
138
|
+
repaired_raw = read_pdb(raw_output)
|
|
139
|
+
repaired = _restore_existing_atom_metadata(
|
|
140
|
+
original,
|
|
141
|
+
repaired_raw,
|
|
142
|
+
output_path=output,
|
|
143
|
+
)
|
|
144
|
+
checks = _validate_repaired_peptide_bonds(
|
|
145
|
+
repaired,
|
|
146
|
+
expected=config.missing_residues,
|
|
147
|
+
minimum=config.peptide_bond_min_angstrom,
|
|
148
|
+
maximum=config.peptide_bond_max_angstrom,
|
|
149
|
+
require_trans=config.require_trans_peptide_bonds,
|
|
150
|
+
minimum_abs_omega=config.trans_peptide_min_abs_degrees,
|
|
151
|
+
)
|
|
152
|
+
write_pdb(repaired, output)
|
|
153
|
+
warnings = (
|
|
154
|
+
"PDBFixer-generated missing coordinates are deterministic for the "
|
|
155
|
+
"recorded seed/platform but remain modeled coordinates; minimize the "
|
|
156
|
+
"final Amber system before production MD.",
|
|
157
|
+
)
|
|
158
|
+
return StructureRepairResult(
|
|
159
|
+
backend="pdbfixer",
|
|
160
|
+
backend_version=_package_version("pdbfixer"),
|
|
161
|
+
input_path=input_path,
|
|
162
|
+
sequence_source_path=sequence_path,
|
|
163
|
+
raw_output_path=raw_output,
|
|
164
|
+
output_path=output,
|
|
165
|
+
structure=repaired,
|
|
166
|
+
missing_residues_detected=tuple(detected_residues),
|
|
167
|
+
missing_heavy_atoms_detected=tuple(detected_atoms),
|
|
168
|
+
added_atom_count=len(repaired.atoms) - len(original.atoms),
|
|
169
|
+
peptide_bond_checks=tuple(checks),
|
|
170
|
+
random_seed=config.random_seed,
|
|
171
|
+
platform=config.platform,
|
|
172
|
+
warnings=warnings,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _detected_missing_residues(fixer: object) -> list[dict[str, object]]:
|
|
177
|
+
topology = fixer.topology # type: ignore[attr-defined]
|
|
178
|
+
chains = list(topology.chains())
|
|
179
|
+
result: list[dict[str, object]] = []
|
|
180
|
+
for key, names in sorted(fixer.missingResidues.items()): # type: ignore[attr-defined]
|
|
181
|
+
if not names:
|
|
182
|
+
continue
|
|
183
|
+
chain_index, insertion_index = key
|
|
184
|
+
if chain_index >= len(chains):
|
|
185
|
+
raise StructureRepairError(
|
|
186
|
+
f"PDBFixer reported invalid missing-residue chain index {chain_index}."
|
|
187
|
+
)
|
|
188
|
+
chain = chains[chain_index]
|
|
189
|
+
residues = list(chain.residues())
|
|
190
|
+
previous_id = (
|
|
191
|
+
_integer_residue_id(residues[insertion_index - 1].id)
|
|
192
|
+
if insertion_index > 0
|
|
193
|
+
else None
|
|
194
|
+
)
|
|
195
|
+
next_id = (
|
|
196
|
+
_integer_residue_id(residues[insertion_index].id)
|
|
197
|
+
if insertion_index < len(residues)
|
|
198
|
+
else None
|
|
199
|
+
)
|
|
200
|
+
inferred = _infer_missing_residue_numbers(
|
|
201
|
+
previous_id,
|
|
202
|
+
next_id,
|
|
203
|
+
count=len(names),
|
|
204
|
+
)
|
|
205
|
+
for offset, name in enumerate(names):
|
|
206
|
+
result.append(
|
|
207
|
+
{
|
|
208
|
+
"chain": chain.id,
|
|
209
|
+
"resname": str(name),
|
|
210
|
+
"resid": inferred[offset],
|
|
211
|
+
"icode": None,
|
|
212
|
+
"chain_index": chain_index,
|
|
213
|
+
"insertion_index": insertion_index + offset,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _infer_missing_residue_numbers(
|
|
220
|
+
previous_id: int | None,
|
|
221
|
+
next_id: int | None,
|
|
222
|
+
*,
|
|
223
|
+
count: int,
|
|
224
|
+
) -> list[int]:
|
|
225
|
+
if previous_id is not None and next_id is not None:
|
|
226
|
+
expected = list(range(previous_id + 1, next_id))
|
|
227
|
+
if len(expected) != count:
|
|
228
|
+
raise StructureRepairError(
|
|
229
|
+
"PDBFixer missing-residue placement is ambiguous: residue IDs "
|
|
230
|
+
f"{previous_id} and {next_id} delimit {len(expected)} position(s), "
|
|
231
|
+
f"but the sequence contains {count} missing residue(s)."
|
|
232
|
+
)
|
|
233
|
+
return expected
|
|
234
|
+
if previous_id is not None:
|
|
235
|
+
return list(range(previous_id + 1, previous_id + count + 1))
|
|
236
|
+
if next_id is not None:
|
|
237
|
+
return list(range(next_id - count, next_id))
|
|
238
|
+
raise StructureRepairError(
|
|
239
|
+
"PDBFixer reported missing residues in an empty chain; residue numbering "
|
|
240
|
+
"cannot be inferred safely."
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _integer_residue_id(value: str) -> int:
|
|
245
|
+
try:
|
|
246
|
+
return int(value)
|
|
247
|
+
except ValueError as exc:
|
|
248
|
+
raise StructureRepairError(
|
|
249
|
+
f"Structure repair requires integer PDB residue IDs; found {value!r}."
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _validate_expected_missing_residues(
|
|
254
|
+
detected: list[dict[str, object]],
|
|
255
|
+
*,
|
|
256
|
+
expected: list[ResidueSelector],
|
|
257
|
+
) -> None:
|
|
258
|
+
detected_keys = {
|
|
259
|
+
(
|
|
260
|
+
str(item["chain"]),
|
|
261
|
+
str(item["resname"]),
|
|
262
|
+
int(item["resid"]),
|
|
263
|
+
item["icode"],
|
|
264
|
+
)
|
|
265
|
+
for item in detected
|
|
266
|
+
}
|
|
267
|
+
expected_keys = {
|
|
268
|
+
(item.chain, item.resname, item.resid, item.icode) for item in expected
|
|
269
|
+
}
|
|
270
|
+
if detected_keys != expected_keys:
|
|
271
|
+
missing_from_manifest = sorted(detected_keys - expected_keys)
|
|
272
|
+
requested_but_absent = sorted(expected_keys - detected_keys)
|
|
273
|
+
raise StructureRepairError(
|
|
274
|
+
"PDBFixer missing-residue detection does not exactly match "
|
|
275
|
+
"structure.repair.missing_residues. "
|
|
276
|
+
f"Unapproved detections: {missing_from_manifest or 'none'}; "
|
|
277
|
+
f"requested but not detected: {requested_but_absent or 'none'}."
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _detected_missing_atoms(fixer: object) -> list[dict[str, object]]:
|
|
282
|
+
values: list[dict[str, object]] = []
|
|
283
|
+
for residue, atoms in fixer.missingAtoms.items(): # type: ignore[attr-defined]
|
|
284
|
+
if not atoms:
|
|
285
|
+
continue
|
|
286
|
+
values.append(
|
|
287
|
+
{
|
|
288
|
+
"chain": residue.chain.id,
|
|
289
|
+
"resname": residue.name,
|
|
290
|
+
"resid": int(residue.id),
|
|
291
|
+
"icode": residue.insertionCode or None,
|
|
292
|
+
"atom_names": sorted(atom.name for atom in atoms),
|
|
293
|
+
"source": "missing_heavy_atoms",
|
|
294
|
+
}
|
|
295
|
+
)
|
|
296
|
+
for residue, atom_names in fixer.missingTerminals.items(): # type: ignore[attr-defined]
|
|
297
|
+
if not atom_names:
|
|
298
|
+
continue
|
|
299
|
+
values.append(
|
|
300
|
+
{
|
|
301
|
+
"chain": residue.chain.id,
|
|
302
|
+
"resname": residue.name,
|
|
303
|
+
"resid": int(residue.id),
|
|
304
|
+
"icode": residue.insertionCode or None,
|
|
305
|
+
"atom_names": sorted(str(name) for name in atom_names),
|
|
306
|
+
"source": "missing_terminal_atoms",
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
return sorted(
|
|
310
|
+
values,
|
|
311
|
+
key=lambda item: (
|
|
312
|
+
str(item["chain"]),
|
|
313
|
+
int(item["resid"]),
|
|
314
|
+
str(item["icode"] or ""),
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _restore_existing_atom_metadata(
|
|
320
|
+
original: PdbStructure,
|
|
321
|
+
repaired: PdbStructure,
|
|
322
|
+
*,
|
|
323
|
+
output_path: Path,
|
|
324
|
+
) -> PdbStructure:
|
|
325
|
+
original_by_identity = {atom.atom_identity: atom for atom in original.atoms}
|
|
326
|
+
repaired_by_identity = {atom.atom_identity: atom for atom in repaired.atoms}
|
|
327
|
+
lost = sorted(set(original_by_identity) - set(repaired_by_identity))
|
|
328
|
+
if lost:
|
|
329
|
+
raise StructureRepairError(
|
|
330
|
+
"PDBFixer removed or renamed existing atoms, which mdprep forbids: "
|
|
331
|
+
+ ", ".join(str(item) for item in lost[:20])
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
restored_atoms: list[AtomRecord] = []
|
|
335
|
+
new_serial_by_identity: dict[tuple[str, str, int, str | None, str], int] = {}
|
|
336
|
+
for repaired_atom in repaired.atoms:
|
|
337
|
+
if repaired_atom.serial is None:
|
|
338
|
+
raise StructureRepairError(
|
|
339
|
+
f"PDBFixer output atom {repaired_atom.atom_identity} has no serial."
|
|
340
|
+
)
|
|
341
|
+
original_atom = original_by_identity.get(repaired_atom.atom_identity)
|
|
342
|
+
if original_atom is not None:
|
|
343
|
+
displacement = dist(
|
|
344
|
+
(original_atom.x, original_atom.y, original_atom.z),
|
|
345
|
+
(repaired_atom.x, repaired_atom.y, repaired_atom.z),
|
|
346
|
+
)
|
|
347
|
+
if displacement > 0.002:
|
|
348
|
+
raise StructureRepairError(
|
|
349
|
+
"PDBFixer moved an existing atom by more than 0.002 A: "
|
|
350
|
+
f"{original_atom.atom_identity} moved {displacement:.6f} A."
|
|
351
|
+
)
|
|
352
|
+
restored_atom = replace(
|
|
353
|
+
repaired_atom,
|
|
354
|
+
occupancy=original_atom.occupancy,
|
|
355
|
+
bfactor=original_atom.bfactor,
|
|
356
|
+
element=original_atom.element,
|
|
357
|
+
record_name=original_atom.record_name,
|
|
358
|
+
original_line=original_atom.original_line,
|
|
359
|
+
)
|
|
360
|
+
else:
|
|
361
|
+
restored_atom = repaired_atom
|
|
362
|
+
restored_atoms.append(restored_atom)
|
|
363
|
+
new_serial_by_identity[restored_atom.atom_identity] = repaired_atom.serial
|
|
364
|
+
|
|
365
|
+
old_identity_by_serial = {
|
|
366
|
+
atom.serial: atom.atom_identity
|
|
367
|
+
for atom in original.atoms
|
|
368
|
+
if atom.serial is not None
|
|
369
|
+
}
|
|
370
|
+
bonds = set(repaired.conect_bonds)
|
|
371
|
+
for left, right in original.conect_bonds:
|
|
372
|
+
left_identity = old_identity_by_serial.get(left)
|
|
373
|
+
right_identity = old_identity_by_serial.get(right)
|
|
374
|
+
if left_identity is None or right_identity is None:
|
|
375
|
+
continue
|
|
376
|
+
new_left = new_serial_by_identity.get(left_identity)
|
|
377
|
+
new_right = new_serial_by_identity.get(right_identity)
|
|
378
|
+
if new_left is not None and new_right is not None:
|
|
379
|
+
bonds.add((min(new_left, new_right), max(new_left, new_right)))
|
|
380
|
+
|
|
381
|
+
repaired_by_serial = {
|
|
382
|
+
atom.serial: atom
|
|
383
|
+
for atom in restored_atoms
|
|
384
|
+
if atom.serial is not None
|
|
385
|
+
}
|
|
386
|
+
serials_by_residue: dict[tuple[str, str, int, str | None], list[int]] = {}
|
|
387
|
+
for atom in restored_atoms:
|
|
388
|
+
if atom.serial is not None:
|
|
389
|
+
serials_by_residue.setdefault(atom.residue_key, []).append(atom.serial)
|
|
390
|
+
ter_after = set(repaired.ter_after_serials)
|
|
391
|
+
for old_serial in original.ter_after_serials:
|
|
392
|
+
identity = old_identity_by_serial.get(old_serial)
|
|
393
|
+
if identity is None:
|
|
394
|
+
continue
|
|
395
|
+
original_atom = original_by_identity.get(identity)
|
|
396
|
+
if original_atom is None:
|
|
397
|
+
continue
|
|
398
|
+
repaired_residue_serials = serials_by_residue.get(original_atom.residue_key, [])
|
|
399
|
+
if not repaired_residue_serials:
|
|
400
|
+
continue
|
|
401
|
+
# A terminal atom such as OXT may have been appended after the atom
|
|
402
|
+
# that preceded the input TER record. Move the boundary to the final
|
|
403
|
+
# atom of that repaired residue instead of splitting the residue.
|
|
404
|
+
ter_after = {
|
|
405
|
+
serial
|
|
406
|
+
for serial in ter_after
|
|
407
|
+
if repaired_by_serial.get(serial) is None
|
|
408
|
+
or repaired_by_serial[serial].residue_key != original_atom.residue_key
|
|
409
|
+
}
|
|
410
|
+
ter_after.add(repaired_residue_serials[-1])
|
|
411
|
+
|
|
412
|
+
return PdbStructure(
|
|
413
|
+
path=output_path,
|
|
414
|
+
atoms=restored_atoms,
|
|
415
|
+
residues=_build_residues_like(repaired.residues, restored_atoms),
|
|
416
|
+
model_count=1,
|
|
417
|
+
used_model=1,
|
|
418
|
+
warnings=list(original.warnings),
|
|
419
|
+
conect_bonds=bonds,
|
|
420
|
+
ter_after_serials=ter_after,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _build_residues_like(
|
|
425
|
+
repaired_residues: list[ResidueRecord],
|
|
426
|
+
atoms: list[AtomRecord],
|
|
427
|
+
) -> list[ResidueRecord]:
|
|
428
|
+
atoms_by_key: dict[tuple[str, str, int, str | None], list[AtomRecord]] = {}
|
|
429
|
+
for atom in atoms:
|
|
430
|
+
atoms_by_key.setdefault(atom.residue_key, []).append(atom)
|
|
431
|
+
result: list[ResidueRecord] = []
|
|
432
|
+
for index, residue in enumerate(repaired_residues):
|
|
433
|
+
residue_atoms = atoms_by_key.get(
|
|
434
|
+
(
|
|
435
|
+
residue.id.chain_id,
|
|
436
|
+
residue.id.resname,
|
|
437
|
+
residue.id.resid,
|
|
438
|
+
residue.id.icode,
|
|
439
|
+
),
|
|
440
|
+
[],
|
|
441
|
+
)
|
|
442
|
+
if not residue_atoms:
|
|
443
|
+
continue
|
|
444
|
+
result.append(
|
|
445
|
+
ResidueRecord(
|
|
446
|
+
id=residue.id,
|
|
447
|
+
atoms=residue_atoms,
|
|
448
|
+
record_names={atom.record_name for atom in residue_atoms},
|
|
449
|
+
original_index=index,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return result
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _validate_repaired_peptide_bonds(
|
|
456
|
+
structure: PdbStructure,
|
|
457
|
+
*,
|
|
458
|
+
expected: list[ResidueSelector],
|
|
459
|
+
minimum: float,
|
|
460
|
+
maximum: float,
|
|
461
|
+
require_trans: bool,
|
|
462
|
+
minimum_abs_omega: float,
|
|
463
|
+
) -> list[dict[str, object]]:
|
|
464
|
+
polymer = [
|
|
465
|
+
residue for residue in structure.residues if is_standard_protein_residue(residue)
|
|
466
|
+
]
|
|
467
|
+
checks: list[dict[str, object]] = []
|
|
468
|
+
for selector in expected:
|
|
469
|
+
matches = [
|
|
470
|
+
residue
|
|
471
|
+
for residue in polymer
|
|
472
|
+
if residue.id.chain_id == selector.chain
|
|
473
|
+
and residue.id.resname == selector.resname
|
|
474
|
+
and residue.id.resid == selector.resid
|
|
475
|
+
and residue.id.icode == selector.icode
|
|
476
|
+
]
|
|
477
|
+
if len(matches) != 1:
|
|
478
|
+
raise StructureRepairError(
|
|
479
|
+
f"Repaired residue {selector.chain}:{selector.resname}{selector.resid} "
|
|
480
|
+
f"resolved {len(matches)} times."
|
|
481
|
+
)
|
|
482
|
+
residue = matches[0]
|
|
483
|
+
index = polymer.index(residue)
|
|
484
|
+
neighbors: list[tuple[ResidueRecord, ResidueRecord]] = []
|
|
485
|
+
if index > 0 and polymer[index - 1].id.chain_id == residue.id.chain_id:
|
|
486
|
+
neighbors.append((polymer[index - 1], residue))
|
|
487
|
+
if (
|
|
488
|
+
index + 1 < len(polymer)
|
|
489
|
+
and polymer[index + 1].id.chain_id == residue.id.chain_id
|
|
490
|
+
):
|
|
491
|
+
neighbors.append((residue, polymer[index + 1]))
|
|
492
|
+
for left, right in neighbors:
|
|
493
|
+
c_atom = _required_atom(left, "C")
|
|
494
|
+
n_atom = _required_atom(right, "N")
|
|
495
|
+
length = dist(
|
|
496
|
+
(c_atom.x, c_atom.y, c_atom.z),
|
|
497
|
+
(n_atom.x, n_atom.y, n_atom.z),
|
|
498
|
+
)
|
|
499
|
+
if not minimum <= length <= maximum:
|
|
500
|
+
raise StructureRepairError(
|
|
501
|
+
f"Repaired peptide bond {left.id.display()} C -- "
|
|
502
|
+
f"{right.id.display()} N is {length:.4f} A; required "
|
|
503
|
+
f"{minimum:.3f}-{maximum:.3f} A."
|
|
504
|
+
)
|
|
505
|
+
omega = _dihedral_degrees(
|
|
506
|
+
_required_atom(left, "CA"),
|
|
507
|
+
c_atom,
|
|
508
|
+
n_atom,
|
|
509
|
+
_required_atom(right, "CA"),
|
|
510
|
+
)
|
|
511
|
+
if require_trans and abs(omega) < minimum_abs_omega:
|
|
512
|
+
raise StructureRepairError(
|
|
513
|
+
f"Repaired peptide bond {left.id.display()}--"
|
|
514
|
+
f"{right.id.display()} has omega {omega:.2f} degrees; "
|
|
515
|
+
"an unapproved cis/non-trans peptide was generated."
|
|
516
|
+
)
|
|
517
|
+
checks.append(
|
|
518
|
+
{
|
|
519
|
+
"left_residue": left.id.to_dict(),
|
|
520
|
+
"right_residue": right.id.to_dict(),
|
|
521
|
+
"c_n_distance_angstrom": length,
|
|
522
|
+
"omega_degrees": omega,
|
|
523
|
+
"trans_required": require_trans,
|
|
524
|
+
"ok": True,
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
return checks
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _required_atom(residue: ResidueRecord, name: str) -> AtomRecord:
|
|
531
|
+
matches = [atom for atom in residue.atoms if atom.name == name]
|
|
532
|
+
if len(matches) != 1:
|
|
533
|
+
raise StructureRepairError(
|
|
534
|
+
f"Residue {residue.id.display()} requires exactly one {name} atom "
|
|
535
|
+
f"for peptide validation; found {len(matches)}."
|
|
536
|
+
)
|
|
537
|
+
return matches[0]
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _dihedral_degrees(
|
|
541
|
+
atom0: AtomRecord,
|
|
542
|
+
atom1: AtomRecord,
|
|
543
|
+
atom2: AtomRecord,
|
|
544
|
+
atom3: AtomRecord,
|
|
545
|
+
) -> float:
|
|
546
|
+
points = [
|
|
547
|
+
np.asarray((atom.x, atom.y, atom.z), dtype=float)
|
|
548
|
+
for atom in (atom0, atom1, atom2, atom3)
|
|
549
|
+
]
|
|
550
|
+
b0 = -(points[1] - points[0])
|
|
551
|
+
b1 = points[2] - points[1]
|
|
552
|
+
b2 = points[3] - points[2]
|
|
553
|
+
norm = np.linalg.norm(b1)
|
|
554
|
+
if norm == 0:
|
|
555
|
+
raise StructureRepairError("Cannot evaluate peptide omega for zero-length bond.")
|
|
556
|
+
b1 /= norm
|
|
557
|
+
v = b0 - np.dot(b0, b1) * b1
|
|
558
|
+
w = b2 - np.dot(b2, b1) * b1
|
|
559
|
+
return float(np.degrees(np.arctan2(np.dot(np.cross(b1, v), w), np.dot(v, w))))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _package_version(name: str) -> str:
|
|
563
|
+
try:
|
|
564
|
+
return version(name)
|
|
565
|
+
except PackageNotFoundError:
|
|
566
|
+
return "unknown"
|