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.
Files changed (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,363 @@
1
+ """Safe structure-only normalization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from mdprep.config.models import ManifestConfig
9
+ from mdprep.structure.classify import (
10
+ is_likely_ligand_or_cofactor,
11
+ is_nonstandard_nonwater_residue,
12
+ is_standard_protein_residue,
13
+ is_water_residue,
14
+ )
15
+ from mdprep.structure.inspect import InspectionSummary, inspect_pdb_structure
16
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
17
+ from mdprep.structure.pdb import read_pdb
18
+ from mdprep.structure.repair import (
19
+ StructureRepairError,
20
+ StructureRepairResult,
21
+ repair_structure,
22
+ )
23
+ from mdprep.structure.selectors import SelectorError, resolve_atom_selector, resolve_residue_selector
24
+
25
+
26
+ class StructureNormalizationError(ValueError):
27
+ """Raised when structure normalization would require an unsafe decision."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class NormalizedLigand:
32
+ ligand_id: str
33
+ residue: dict[str, object]
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class NormalizedMetalIon:
38
+ site_id: str
39
+ element: str
40
+ charge: int
41
+ atom: dict[str, object]
42
+
43
+
44
+ @dataclass
45
+ class StructureNormalizationResult:
46
+ input_path: Path
47
+ output_path: Path | None
48
+ input_summary: InspectionSummary
49
+ normalized_structure: PdbStructure
50
+ waters_kept: list[dict[str, object]] = field(default_factory=list)
51
+ waters_removed: list[dict[str, object]] = field(default_factory=list)
52
+ configured_ligands_kept: list[NormalizedLigand] = field(default_factory=list)
53
+ configured_metal_ions_kept: list[NormalizedMetalIon] = field(default_factory=list)
54
+ unknown_heterogens_removed: list[dict[str, object]] = field(default_factory=list)
55
+ unknown_heterogens_causing_failure: list[dict[str, object]] = field(default_factory=list)
56
+ warnings: list[str] = field(default_factory=list)
57
+ repair_result: StructureRepairResult | None = None
58
+
59
+ def to_report_dict(self) -> dict[str, object]:
60
+ normalized_summary = inspect_pdb_structure_from_structure(self.normalized_structure)
61
+ return {
62
+ "input_path": str(self.input_path),
63
+ "output_normalized_pdb_path": str(self.output_path) if self.output_path else None,
64
+ "atom_count_before": len(self.input_summary.structure.atoms),
65
+ "atom_count_after": len(self.normalized_structure.atoms),
66
+ "residue_count_before": len(self.input_summary.structure.residues),
67
+ "residue_count_after": len(self.normalized_structure.residues),
68
+ "chains": [{"chain_id": chain, "display": chain or "<blank>"} for chain in normalized_summary.chain_ids],
69
+ "waters_kept": self.waters_kept,
70
+ "waters_removed": self.waters_removed,
71
+ "configured_ligands_kept": [
72
+ {"id": ligand.ligand_id, **ligand.residue} for ligand in self.configured_ligands_kept
73
+ ],
74
+ "configured_metal_ions_kept": [
75
+ {
76
+ "site_id": metal.site_id,
77
+ "element": metal.element,
78
+ "charge": metal.charge,
79
+ **metal.atom,
80
+ }
81
+ for metal in self.configured_metal_ions_kept
82
+ ],
83
+ "unknown_heterogens_removed": self.unknown_heterogens_removed,
84
+ "unknown_heterogens_causing_failure": self.unknown_heterogens_causing_failure,
85
+ "histidines": _residue_dicts(self.input_summary.histidines),
86
+ "titratable_residues": _residue_dicts(self.input_summary.titratable_residues),
87
+ "possible_disulfides": [
88
+ candidate.to_dict() for candidate in self.input_summary.possible_disulfides
89
+ ],
90
+ "structure_repair": (
91
+ self.repair_result.to_dict()
92
+ if self.repair_result is not None
93
+ else None
94
+ ),
95
+ "warnings": self.warnings,
96
+ }
97
+
98
+
99
+ def normalize_structure_stage(
100
+ manifest: ManifestConfig,
101
+ *,
102
+ output_path: str | Path | None = None,
103
+ ) -> StructureNormalizationResult:
104
+ input_path = Path(manifest.project.input_structure)
105
+ input_summary = inspect_pdb_structure(
106
+ input_path,
107
+ altloc_policy=manifest.structure.altloc_policy,
108
+ disulfide_cutoff_angstrom=manifest.disulfides.detection_cutoff_angstrom,
109
+ )
110
+ repair_result: StructureRepairResult | None = None
111
+ if manifest.structure.repair.backend != "none":
112
+ if output_path is None:
113
+ raise StructureNormalizationError(
114
+ "Structure repair requires an explicit normalization output path."
115
+ )
116
+ normalized_target = Path(output_path)
117
+ repair_path = normalized_target.with_name("00_structure_repaired.pdb")
118
+ try:
119
+ repair_result = repair_structure(manifest, output_path=repair_path)
120
+ except StructureRepairError as exc:
121
+ raise StructureNormalizationError(str(exc)) from exc
122
+ assert repair_result is not None
123
+ structure = repair_result.structure
124
+ else:
125
+ structure = read_pdb(
126
+ input_path,
127
+ altloc_policy=manifest.structure.altloc_policy,
128
+ )
129
+
130
+ configured_ligands = _resolve_configured_ligands(structure, manifest)
131
+ configured_metals = _resolve_configured_metals(structure, manifest)
132
+ _validate_disulfide_selectors(structure, manifest)
133
+ configured_ligand_keys = {id(residue) for _, residue in configured_ligands}
134
+ configured_metal_residue_keys = {id(residue) for _, _, residue in configured_metals}
135
+ overlap = configured_ligand_keys & configured_metal_residue_keys
136
+ if overlap:
137
+ residues = [residue for residue in structure.residues if id(residue) in overlap]
138
+ formatted = ", ".join(_format_residue_for_error(residue) for residue in residues)
139
+ raise StructureNormalizationError(
140
+ "A residue cannot be configured as both a ligand and a metal ion: " + formatted
141
+ )
142
+ likely_heterogens = [residue for residue in structure.residues if is_likely_ligand_or_cofactor(residue)]
143
+ unknown_heterogens = [
144
+ residue
145
+ for residue in likely_heterogens
146
+ if id(residue) not in configured_ligand_keys
147
+ and id(residue) not in configured_metal_residue_keys
148
+ ]
149
+
150
+ unknown_heterogen_dicts = _residue_dicts(unknown_heterogens)
151
+ if unknown_heterogens and not manifest.structure.remove_unknown_heterogens:
152
+ formatted = ", ".join(_format_residue_for_error(residue) for residue in unknown_heterogens)
153
+ raise StructureNormalizationError(
154
+ "Unknown heterogens are present but structure.remove_unknown_heterogens is false: "
155
+ f"{formatted}. Add them to ligands: for molecular heterogens or metals: for metal ions, "
156
+ "or set structure.remove_unknown_heterogens: true."
157
+ )
158
+
159
+ keep_residue_ids: set[int] = set()
160
+ waters_kept: list[ResidueRecord] = []
161
+ waters_removed: list[ResidueRecord] = []
162
+ unknown_removed: list[ResidueRecord] = []
163
+
164
+ for residue in structure.residues:
165
+ if is_standard_protein_residue(residue):
166
+ keep_residue_ids.add(id(residue))
167
+ elif is_water_residue(residue):
168
+ if manifest.structure.keep_crystal_waters:
169
+ keep_residue_ids.add(id(residue))
170
+ waters_kept.append(residue)
171
+ else:
172
+ waters_removed.append(residue)
173
+ elif id(residue) in configured_ligand_keys:
174
+ keep_residue_ids.add(id(residue))
175
+ elif id(residue) in configured_metal_residue_keys:
176
+ keep_residue_ids.add(id(residue))
177
+ elif residue in unknown_heterogens:
178
+ unknown_removed.append(residue)
179
+ else:
180
+ keep_residue_ids.add(id(residue))
181
+
182
+ filtered_atoms: list[AtomRecord] = []
183
+ for residue in structure.residues:
184
+ if id(residue) in keep_residue_ids:
185
+ filtered_atoms.extend(residue.atoms)
186
+
187
+ normalized_structure = PdbStructure(
188
+ path=Path(output_path) if output_path is not None else structure.path,
189
+ atoms=filtered_atoms,
190
+ residues=_filter_residues_by_ids(structure.residues, keep_residue_ids),
191
+ model_count=structure.model_count,
192
+ used_model=structure.used_model,
193
+ warnings=list(structure.warnings),
194
+ conect_bonds={
195
+ bond
196
+ for bond in structure.conect_bonds
197
+ if bond[0] in {atom.serial for atom in filtered_atoms}
198
+ and bond[1] in {atom.serial for atom in filtered_atoms}
199
+ },
200
+ ter_after_serials={
201
+ serial
202
+ for serial in structure.ter_after_serials
203
+ if serial in {atom.serial for atom in filtered_atoms}
204
+ },
205
+ )
206
+
207
+ warnings = list(structure.warnings)
208
+ if repair_result is not None:
209
+ warnings.extend(repair_result.warnings)
210
+ if manifest.structure.remove_input_hydrogens:
211
+ warnings.append(
212
+ "structure.remove_input_hydrogens is not applied during the structure-only stage."
213
+ )
214
+
215
+ return StructureNormalizationResult(
216
+ input_path=input_path,
217
+ output_path=Path(output_path) if output_path is not None else None,
218
+ input_summary=input_summary,
219
+ normalized_structure=normalized_structure,
220
+ waters_kept=_residue_dicts(waters_kept),
221
+ waters_removed=_residue_dicts(waters_removed),
222
+ configured_ligands_kept=[
223
+ NormalizedLigand(ligand_id=ligand_id, residue=_residue_dict(residue))
224
+ for ligand_id, residue in configured_ligands
225
+ ],
226
+ configured_metal_ions_kept=[
227
+ NormalizedMetalIon(
228
+ site_id=site_id,
229
+ element=element,
230
+ charge=charge,
231
+ atom={
232
+ **residue.id.to_dict(),
233
+ "atom_name": atom.name,
234
+ "atom_serial": atom.serial,
235
+ },
236
+ )
237
+ for site_id, (element, charge, atom), residue in configured_metals
238
+ ],
239
+ unknown_heterogens_removed=_residue_dicts(unknown_removed),
240
+ unknown_heterogens_causing_failure=unknown_heterogen_dicts
241
+ if unknown_heterogens and not manifest.structure.remove_unknown_heterogens
242
+ else [],
243
+ warnings=warnings,
244
+ repair_result=repair_result,
245
+ )
246
+
247
+
248
+ def inspect_pdb_structure_from_structure(structure: PdbStructure) -> InspectionSummary:
249
+ from mdprep.structure.classify import is_histidine, is_titratable_residue
250
+ from mdprep.structure.disulfides import detect_possible_disulfides
251
+
252
+ residues = structure.residues
253
+ protein_residues = [residue for residue in residues if is_standard_protein_residue(residue)]
254
+ water_residues = [residue for residue in residues if is_water_residue(residue)]
255
+ heterogen_residues = [
256
+ residue for residue in residues if is_nonstandard_nonwater_residue(residue)
257
+ ]
258
+ likely_ligands = [residue for residue in residues if is_likely_ligand_or_cofactor(residue)]
259
+ histidines = [residue for residue in residues if is_histidine(residue)]
260
+ titratable = [residue for residue in residues if is_titratable_residue(residue)]
261
+ disulfides = detect_possible_disulfides(residues)
262
+ return InspectionSummary(
263
+ structure=structure,
264
+ protein_residues=protein_residues,
265
+ water_residues=water_residues,
266
+ heterogen_residues=heterogen_residues,
267
+ likely_ligands=likely_ligands,
268
+ histidines=histidines,
269
+ titratable_residues=titratable,
270
+ possible_disulfides=disulfides,
271
+ )
272
+
273
+
274
+ def _resolve_configured_ligands(
275
+ structure: PdbStructure,
276
+ manifest: ManifestConfig,
277
+ ) -> list[tuple[str, ResidueRecord]]:
278
+ resolved: list[tuple[str, ResidueRecord]] = []
279
+ for ligand in manifest.ligands:
280
+ try:
281
+ residue = resolve_residue_selector(structure, ligand.selector.model_dump())
282
+ except SelectorError as exc:
283
+ raise StructureNormalizationError(
284
+ f"Ligand {ligand.id!r} selector did not resolve exactly one residue: {exc}"
285
+ ) from exc
286
+ resolved.append((ligand.id, residue))
287
+ return resolved
288
+
289
+
290
+ def _resolve_configured_metals(
291
+ structure: PdbStructure,
292
+ manifest: ManifestConfig,
293
+ ) -> list[tuple[str, tuple[str, int, AtomRecord], ResidueRecord]]:
294
+ resolved: list[tuple[str, tuple[str, int, AtomRecord], ResidueRecord]] = []
295
+ seen_atoms: set[int] = set()
296
+ for site in manifest.metals:
297
+ for ion in site.ions:
298
+ try:
299
+ atom = resolve_atom_selector(structure, ion.selector.model_dump())
300
+ residue = resolve_residue_selector(structure, ion.selector.model_dump())
301
+ except SelectorError as exc:
302
+ raise StructureNormalizationError(
303
+ f"Metal site {site.id!r} ion selector did not resolve exactly one atom: {exc}"
304
+ ) from exc
305
+ if id(atom) in seen_atoms:
306
+ raise StructureNormalizationError(
307
+ f"Metal atom {atom.atom_identity} is configured more than once."
308
+ )
309
+ seen_atoms.add(id(atom))
310
+ observed_element = (atom.element or "").capitalize()
311
+ if observed_element and observed_element != ion.element:
312
+ raise StructureNormalizationError(
313
+ f"Metal site {site.id!r} declares element {ion.element} for "
314
+ f"{atom.atom_identity}, but the PDB element is {observed_element}."
315
+ )
316
+ if len(residue.atoms) != 1:
317
+ raise StructureNormalizationError(
318
+ f"Metal ion residue {residue.id.display()} contains {len(residue.atoms)} atoms; "
319
+ "each configured metal ion must occupy a one-atom residue."
320
+ )
321
+ resolved.append((site.id, (ion.element, ion.charge, atom), residue))
322
+ return resolved
323
+
324
+
325
+ def _validate_disulfide_selectors(structure: PdbStructure, manifest: ManifestConfig) -> None:
326
+ for group_name, pairs in (
327
+ ("force", manifest.disulfides.force),
328
+ ("forbid", manifest.disulfides.forbid),
329
+ ):
330
+ for index, pair in enumerate(pairs, start=1):
331
+ for side_name, selector in (("a", pair.a), ("b", pair.b)):
332
+ try:
333
+ resolve_residue_selector(structure, selector.model_dump())
334
+ except SelectorError as exc:
335
+ raise StructureNormalizationError(
336
+ f"Disulfide {group_name}[{index}].{side_name} selector did not resolve exactly one residue: {exc}"
337
+ ) from exc
338
+
339
+
340
+ def _filter_residues_by_ids(
341
+ residues: list[ResidueRecord],
342
+ keep_residue_ids: set[int],
343
+ ) -> list[ResidueRecord]:
344
+ return [residue for residue in residues if id(residue) in keep_residue_ids]
345
+
346
+
347
+ def _residue_dicts(residues: list[ResidueRecord]) -> list[dict[str, object]]:
348
+ return [_residue_dict(residue) for residue in residues]
349
+
350
+
351
+ def _residue_dict(residue: ResidueRecord) -> dict[str, object]:
352
+ return {
353
+ **residue.id.to_dict(),
354
+ "atom_count": len(residue.atoms),
355
+ "record_names": sorted(residue.record_names),
356
+ "original_index": residue.original_index,
357
+ }
358
+
359
+
360
+ def _format_residue_for_error(residue: ResidueRecord) -> str:
361
+ chain = residue.id.chain_id if residue.id.chain_id else "<blank>"
362
+ icode = residue.id.icode or ""
363
+ return f"{chain}:{residue.id.resname}{residue.id.resid}{icode}"