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,838 @@
1
+ """Ligand extraction and parameterization stage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from dataclasses import dataclass, field, replace
8
+ from pathlib import Path
9
+
10
+ from mdprep.ambertools.antechamber import run_antechamber
11
+ from mdprep.ambertools.commands import AmberToolRun, AmberToolsError
12
+ from mdprep.ambertools.mol2 import (
13
+ Mol2Error,
14
+ Mol2ValidationResult,
15
+ read_mol2,
16
+ validate_and_write_final_mol2,
17
+ write_mol2_with_charges,
18
+ )
19
+ from mdprep.ambertools.parmchk2 import run_parmchk2
20
+ from mdprep.config.models import ManifestConfig
21
+ from mdprep.leap.forcefields import forcefield_sources
22
+ from mdprep.leap.log_parser import LeapLogError, assert_tleap_success
23
+ from mdprep.leap.residues import (
24
+ LeapResidueError,
25
+ append_disulfide_conect_records,
26
+ disulfide_bond_commands,
27
+ prepare_leap_input_pdb,
28
+ validate_ligand_parameter_files,
29
+ )
30
+ from mdprep.leap.runner import TLeapRun, TLeapRunError, run_tleap
31
+ from mdprep.ligands.extract import ExtractedLigand, LigandExtractionError, extract_configured_ligands
32
+ from mdprep.ligands.pyscf_charges import LigandPySCFChargeError, LigandPySCFChargeResult, derive_pyscf_charges
33
+ from mdprep.protonation.apply import ProtonationResult
34
+ from mdprep.qm.point_charges import (
35
+ PointChargeError,
36
+ PointChargeSelection,
37
+ extract_point_charges_from_prmtop,
38
+ )
39
+ from mdprep.structure.models import PdbStructure
40
+
41
+
42
+ class LigandWorkflowError(ValueError):
43
+ """Raised when ligand-stage preparation cannot complete safely."""
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class LigandWorkflowItem:
48
+ ligand_id: str
49
+ selector: dict[str, object]
50
+ residue_identity: dict[str, object]
51
+ atom_count: int
52
+ charge_method: str
53
+ atom_types: str
54
+ net_charge: int
55
+ multiplicity: int
56
+ extracted_pdb_path: Path
57
+ identity_path: Path
58
+ final_mol2_path: Path | None = None
59
+ final_frcmod_path: Path | None = None
60
+ validation: Mol2ValidationResult | None = None
61
+ antechamber: AmberToolRun | None = None
62
+ parmchk2: AmberToolRun | None = None
63
+ qm: LigandPySCFChargeResult | None = None
64
+ provisional_mol2_path: Path | None = None
65
+ warnings: list[str] = field(default_factory=list)
66
+ status: str = "ok"
67
+
68
+ def to_dict(self) -> dict[str, object]:
69
+ return {
70
+ "ligand_id": self.ligand_id,
71
+ "selector": self.selector,
72
+ "residue_identity": self.residue_identity,
73
+ "atom_count": self.atom_count,
74
+ "charge_method": self.charge_method,
75
+ "atom_types": self.atom_types,
76
+ "net_charge": self.net_charge,
77
+ "multiplicity": self.multiplicity,
78
+ "extracted_pdb_path": str(self.extracted_pdb_path),
79
+ "identity_path": str(self.identity_path),
80
+ "antechamber": self.antechamber.to_dict() if self.antechamber else None,
81
+ "parmchk2": self.parmchk2.to_dict() if self.parmchk2 else None,
82
+ "final_mol2_path": str(self.final_mol2_path) if self.final_mol2_path else None,
83
+ "final_frcmod_path": str(self.final_frcmod_path) if self.final_frcmod_path else None,
84
+ "validation": self.validation.to_dict() if self.validation else None,
85
+ "qm": self.qm.to_dict() if self.qm else None,
86
+ "provisional_mol2_path": str(self.provisional_mol2_path) if self.provisional_mol2_path else None,
87
+ "warnings": self.warnings,
88
+ "errors": [],
89
+ "status": self.status,
90
+ }
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class LigandStageResult:
95
+ ligands: list[LigandWorkflowItem]
96
+ provisional_ligands: list[LigandWorkflowItem] = field(default_factory=list)
97
+ qmmesp_provisional_system: "QmmespProvisionalSystem | None" = None
98
+
99
+ def to_report_dict(self) -> dict[str, object]:
100
+ return {
101
+ "ligands": [item.to_dict() for item in self.ligands],
102
+ "qmmesp_provisional_ligands": [item.to_dict() for item in self.provisional_ligands],
103
+ "qmmesp_provisional_system": (
104
+ self.qmmesp_provisional_system.to_dict() if self.qmmesp_provisional_system else None
105
+ ),
106
+ }
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class QmmespProvisionalSystem:
111
+ leap_input_path: Path
112
+ script_path: Path
113
+ tleap_run: TLeapRun
114
+ prmtop_path: Path
115
+ inpcrd_path: Path
116
+ pdb_path: Path
117
+
118
+ def to_dict(self) -> dict[str, object]:
119
+ return {
120
+ "workflow": "all_configured_ligands_provisional_am1bcc",
121
+ "leap_input_path": str(self.leap_input_path),
122
+ "script_path": str(self.script_path),
123
+ "tleap": self.tleap_run.to_dict(),
124
+ "prmtop_path": str(self.prmtop_path),
125
+ "inpcrd_path": str(self.inpcrd_path),
126
+ "pdb_path": str(self.pdb_path),
127
+ }
128
+
129
+
130
+ def run_ligand_stage(
131
+ structure: PdbStructure,
132
+ manifest: ManifestConfig,
133
+ *,
134
+ output_dir: str | Path,
135
+ protonation_result: ProtonationResult | None = None,
136
+ ) -> LigandStageResult:
137
+ try:
138
+ extracted = extract_configured_ligands(structure, manifest, output_dir=output_dir)
139
+ qmmesp_indices = [
140
+ index for index, item in enumerate(extracted) if item.config.charge_method == "qmmesp_pyscf"
141
+ ]
142
+ if not qmmesp_indices:
143
+ return LigandStageResult(
144
+ ligands=[_process_ligand(item, output_dir=output_dir) for item in extracted]
145
+ )
146
+
147
+ if qmmesp_indices:
148
+ if protonation_result is None:
149
+ raise LigandWorkflowError("qmmesp_pyscf requires a protonation result to build the provisional Amber system.")
150
+ provisional_items = [
151
+ _prepare_provisional_am1bcc_ligand(item, output_dir=output_dir) for item in extracted
152
+ ]
153
+ provisional_result = LigandStageResult(ligands=provisional_items)
154
+ provisional_system = _build_qmmesp_provisional_system(
155
+ structure,
156
+ manifest,
157
+ provisional_result,
158
+ output_dir=output_dir,
159
+ protonation_result=protonation_result,
160
+ context="QMMESP provisional",
161
+ )
162
+ final_items: list[LigandWorkflowItem] = []
163
+ for index, extracted_ligand in enumerate(extracted):
164
+ provisional_item = provisional_items[index]
165
+ if extracted_ligand.config.charge_method != "qmmesp_pyscf":
166
+ if extracted_ligand.config.charge_method == "am1bcc":
167
+ final_items.append(
168
+ _promote_provisional_am1bcc_ligand(
169
+ extracted_ligand,
170
+ provisional_item=provisional_item,
171
+ output_dir=output_dir,
172
+ )
173
+ )
174
+ else:
175
+ final_items.append(
176
+ _process_ligand(
177
+ extracted_ligand,
178
+ output_dir=output_dir,
179
+ qmmesp_provisional_item=provisional_item,
180
+ )
181
+ )
182
+ continue
183
+ extracted_ligand = extracted[index]
184
+ point_charges = extract_point_charges_from_prmtop(
185
+ prmtop=provisional_system.prmtop_path,
186
+ inpcrd=provisional_system.inpcrd_path,
187
+ ligand=extracted_ligand.config,
188
+ manifest=manifest,
189
+ target_coordinates=_coordinates(extracted_ligand),
190
+ target_atom_names=[atom.name for atom in extracted_ligand.atoms],
191
+ )
192
+ final_items.append(
193
+ _finalize_qm_ligand(
194
+ extracted_ligand,
195
+ output_dir=output_dir,
196
+ method_name="qmmesp_pyscf",
197
+ provisional_item=provisional_item,
198
+ point_charges=point_charges,
199
+ )
200
+ )
201
+ except (
202
+ LigandExtractionError,
203
+ AmberToolsError,
204
+ Mol2Error,
205
+ FileNotFoundError,
206
+ LigandPySCFChargeError,
207
+ PointChargeError,
208
+ LeapLogError,
209
+ LeapResidueError,
210
+ TLeapRunError,
211
+ ) as exc:
212
+ _write_ligand_failure_report(output_dir, exc)
213
+ raise LigandWorkflowError(str(exc)) from exc
214
+ return LigandStageResult(
215
+ ligands=final_items,
216
+ provisional_ligands=provisional_items,
217
+ qmmesp_provisional_system=provisional_system,
218
+ )
219
+
220
+
221
+ def build_provisional_am1bcc_system(
222
+ structure: PdbStructure,
223
+ manifest: ManifestConfig,
224
+ *,
225
+ output_dir: str | Path,
226
+ protonation_result: ProtonationResult,
227
+ ) -> tuple[LigandStageResult, QmmespProvisionalSystem]:
228
+ """Build an unsolvated Amber system with AM1-BCC for every ligand.
229
+
230
+ This is shared by QMMESP charge fitting and the optional QM/MM refinement.
231
+ It deliberately ignores each ligand's requested *final* charge method: the
232
+ returned parameters are provisional and may never be promoted implicitly.
233
+ """
234
+
235
+ try:
236
+ extracted = extract_configured_ligands(
237
+ structure,
238
+ manifest,
239
+ output_dir=output_dir,
240
+ )
241
+ provisional_items = [
242
+ _prepare_provisional_am1bcc_ligand(item, output_dir=output_dir)
243
+ for item in extracted
244
+ ]
245
+ provisional_result = LigandStageResult(ligands=provisional_items)
246
+ provisional_system = _build_qmmesp_provisional_system(
247
+ structure,
248
+ manifest,
249
+ provisional_result,
250
+ output_dir=output_dir,
251
+ protonation_result=protonation_result,
252
+ context="QM/MM refinement provisional",
253
+ )
254
+ except (
255
+ LigandExtractionError,
256
+ AmberToolsError,
257
+ Mol2Error,
258
+ FileNotFoundError,
259
+ LeapLogError,
260
+ LeapResidueError,
261
+ TLeapRunError,
262
+ ) as exc:
263
+ _write_ligand_failure_report(output_dir, exc)
264
+ raise LigandWorkflowError(str(exc)) from exc
265
+ return provisional_result, provisional_system
266
+
267
+
268
+ def _write_ligand_failure_report(output_dir: str | Path, exc: Exception) -> None:
269
+ """Persist structured diagnostics even when ligand processing stops early."""
270
+
271
+ lines = [line.strip() for line in str(exc).splitlines() if line.strip()]
272
+ warnings = [line for line in lines if line.startswith("WARNING")]
273
+ errors = [line for line in lines if line.startswith("ERROR")]
274
+ if not errors:
275
+ errors = [str(exc)]
276
+ report_path = Path(output_dir) / "reports" / "ligand_failure.json"
277
+ report_path.parent.mkdir(parents=True, exist_ok=True)
278
+ report_path.write_text(
279
+ json.dumps(
280
+ {
281
+ "status": "failed",
282
+ "exception_type": type(exc).__name__,
283
+ "warnings": warnings,
284
+ "errors": errors,
285
+ },
286
+ indent=2,
287
+ sort_keys=True,
288
+ )
289
+ + "\n",
290
+ encoding="utf-8",
291
+ )
292
+
293
+
294
+ def _process_ligand(
295
+ extracted: ExtractedLigand,
296
+ *,
297
+ output_dir: str | Path,
298
+ qmmesp_provisional_item: LigandWorkflowItem | None = None,
299
+ ) -> LigandWorkflowItem:
300
+ ligand = extracted.config
301
+ parameters_dir = Path(output_dir) / "ligands" / ligand.id / "parameters"
302
+ parameters_dir.mkdir(parents=True, exist_ok=True)
303
+ antechamber_run: AmberToolRun | None = None
304
+ parmchk2_run: AmberToolRun | None = None
305
+ qm_result: LigandPySCFChargeResult | None = None
306
+ provisional_mol2_path: Path | None = None
307
+ warnings = list(extracted.warnings)
308
+
309
+ if ligand.charge_method in {"am1bcc", "gas_resp_pyscf", "mcpb_resp_pyscf"}:
310
+ if ligand.charge_method == "gas_resp_pyscf" and ligand.user_mol2:
311
+ working_mol2 = _copy_user_mol2(ligand, parameters_dir, suffix="provisional_user")
312
+ warnings.append(
313
+ "User mol2 supplied provisional atom types and bonded topology for gas_resp_pyscf; "
314
+ "its charges will be replaced by PySCF-fitted gas-phase charges."
315
+ )
316
+ elif qmmesp_provisional_item is not None:
317
+ if qmmesp_provisional_item.final_mol2_path is None:
318
+ raise LigandWorkflowError(f"Ligand {ligand.id} has no provisional AM1-BCC mol2.")
319
+ working_mol2 = qmmesp_provisional_item.final_mol2_path
320
+ antechamber_run = qmmesp_provisional_item.antechamber
321
+ warnings.append(
322
+ "Reused the all-ligand provisional AM1-BCC scaffold for final gas-phase RESP fitting."
323
+ )
324
+ else:
325
+ working_mol2 = parameters_dir / f"{ligand.id}.antechamber.mol2"
326
+ antechamber_run = run_antechamber(
327
+ ligand=ligand,
328
+ input_pdb=extracted.pdb_path,
329
+ output_mol2=working_mol2,
330
+ residue_name=extracted.residue.id.resname,
331
+ work_dir=parameters_dir,
332
+ )
333
+ if ligand.charge_method == "mcpb_resp_pyscf":
334
+ provisional_mol2_path = working_mol2
335
+ warnings.append(
336
+ "AM1-BCC supplies only the provisional GAFF topology and charges for this "
337
+ "metal-bound ligand. MCPB.py will replace the charge distribution with the "
338
+ "embedded PySCF large-model RESP fit before the final Amber build."
339
+ )
340
+ if ligand.charge_method == "gas_resp_pyscf":
341
+ provisional_mol2_path = working_mol2
342
+ pyscf_mol2 = parameters_dir / f"{ligand.id}.pyscf_charges.mol2"
343
+ qm_result = derive_pyscf_charges(
344
+ extracted=extracted,
345
+ provisional_mol2_path=working_mol2,
346
+ output_mol2_path=pyscf_mol2,
347
+ output_dir=output_dir,
348
+ method_name="gas_resp_pyscf",
349
+ point_charges=None,
350
+ )
351
+ working_mol2 = pyscf_mol2
352
+ if antechamber_run is None:
353
+ warnings.append("User mol2 charges were provisional and replaced by PySCF-fitted gas-phase charges.")
354
+ else:
355
+ warnings.append("AM1-BCC charges were provisional and replaced by PySCF-fitted gas-phase charges.")
356
+ elif ligand.charge_method == "user_mol2":
357
+ working_mol2 = _copy_user_mol2(ligand, parameters_dir, suffix="user")
358
+ elif ligand.charge_method == "qmmesp_pyscf":
359
+ raise LigandWorkflowError("qmmesp_pyscf is processed in the QMMESP two-pass ligand workflow.")
360
+ else:
361
+ raise LigandWorkflowError(f"Unsupported ligand charge method: {ligand.charge_method}")
362
+
363
+ final_mol2 = parameters_dir / f"{ligand.id}.final.mol2"
364
+ validation = validate_and_write_final_mol2(
365
+ mol2_path=working_mol2,
366
+ extracted_atoms=extracted.atoms,
367
+ ligand=ligand,
368
+ final_mol2_path=final_mol2,
369
+ charges_csv_path=parameters_dir / "charges.csv",
370
+ validation_json_path=parameters_dir / "validation.json",
371
+ )
372
+ warnings.extend(validation.warnings)
373
+ if qmmesp_provisional_item is not None and provisional_mol2_path is None:
374
+ provisional_mol2_path = qmmesp_provisional_item.final_mol2_path
375
+
376
+ final_frcmod = parameters_dir / f"{ligand.id}.frcmod"
377
+ if ligand.user_frcmod:
378
+ source_frcmod = Path(ligand.user_frcmod)
379
+ if not source_frcmod.exists():
380
+ raise FileNotFoundError(f"Ligand {ligand.id} user_frcmod does not exist: {source_frcmod}")
381
+ shutil.copyfile(source_frcmod, final_frcmod)
382
+ elif qmmesp_provisional_item is not None and qmmesp_provisional_item.final_frcmod_path is not None:
383
+ shutil.copyfile(qmmesp_provisional_item.final_frcmod_path, final_frcmod)
384
+ parmchk2_run = qmmesp_provisional_item.parmchk2
385
+ else:
386
+ parmchk2_run = run_parmchk2(
387
+ ligand=ligand,
388
+ input_mol2=final_mol2,
389
+ output_frcmod=final_frcmod,
390
+ work_dir=parameters_dir,
391
+ )
392
+
393
+ return LigandWorkflowItem(
394
+ ligand_id=ligand.id,
395
+ selector=ligand.selector.model_dump(mode="json"),
396
+ residue_identity=extracted.residue.id.to_dict(),
397
+ atom_count=len(extracted.atoms),
398
+ charge_method=ligand.charge_method,
399
+ atom_types=ligand.atom_types,
400
+ net_charge=ligand.net_charge,
401
+ multiplicity=ligand.multiplicity,
402
+ extracted_pdb_path=extracted.pdb_path,
403
+ identity_path=extracted.identity_path,
404
+ final_mol2_path=final_mol2,
405
+ final_frcmod_path=final_frcmod,
406
+ validation=validation,
407
+ antechamber=antechamber_run,
408
+ parmchk2=parmchk2_run,
409
+ qm=qm_result,
410
+ provisional_mol2_path=provisional_mol2_path,
411
+ warnings=warnings,
412
+ status=(
413
+ "provisional_for_mcpb"
414
+ if ligand.charge_method == "mcpb_resp_pyscf"
415
+ else "ok"
416
+ ),
417
+ )
418
+
419
+
420
+ def _prepare_provisional_am1bcc_ligand(
421
+ extracted: ExtractedLigand,
422
+ *,
423
+ output_dir: str | Path,
424
+ ) -> LigandWorkflowItem:
425
+ """Generate an AM1-BCC provisional model for one configured ligand.
426
+
427
+ This always runs antechamber, including for ligands whose final method is
428
+ user_mol2 or QMMESP. If a user mol2 is present, its atom typing and bonded
429
+ topology are retained while the generated AM1-BCC charges are copied onto
430
+ it for the provisional QM/MM system.
431
+ """
432
+
433
+ ligand = extracted.config
434
+ provisional_dir = Path(output_dir) / "ligands" / ligand.id / "provisional_am1bcc"
435
+ provisional_dir.mkdir(parents=True, exist_ok=True)
436
+ warnings = list(extracted.warnings)
437
+
438
+ generated_mol2 = provisional_dir / f"{ligand.id}.antechamber_am1bcc.mol2"
439
+ antechamber_run = run_antechamber(
440
+ ligand=ligand,
441
+ input_pdb=extracted.pdb_path,
442
+ output_mol2=generated_mol2,
443
+ residue_name=extracted.residue.id.resname,
444
+ work_dir=provisional_dir,
445
+ )
446
+ validated_am1bcc = provisional_dir / f"{ligand.id}.am1bcc.validated.mol2"
447
+ generated_validation = validate_and_write_final_mol2(
448
+ mol2_path=generated_mol2,
449
+ extracted_atoms=extracted.atoms,
450
+ ligand=ligand,
451
+ final_mol2_path=validated_am1bcc,
452
+ charges_csv_path=provisional_dir / "generated_am1bcc_charges.csv",
453
+ validation_json_path=provisional_dir / "generated_am1bcc_validation.json",
454
+ )
455
+ warnings.extend(generated_validation.warnings)
456
+ warnings.append(
457
+ "Generated AM1-BCC charges for this ligand for the all-ligand provisional Amber system."
458
+ )
459
+
460
+ source_mol2 = validated_am1bcc
461
+ if ligand.user_mol2:
462
+ user_scaffold = _copy_user_mol2(ligand, provisional_dir, suffix="user_scaffold")
463
+ am1bcc_charges = [atom.charge for atom in read_mol2(validated_am1bcc).atoms]
464
+ source_mol2 = provisional_dir / f"{ligand.id}.user_scaffold_am1bcc.mol2"
465
+ write_mol2_with_charges(user_scaffold, am1bcc_charges, source_mol2)
466
+ warnings.append(
467
+ "Preserved the user mol2 atom types and bonded topology in the provisional system, "
468
+ "but replaced its provisional charges with generated AM1-BCC charges."
469
+ )
470
+
471
+ provisional_mol2 = provisional_dir / f"{ligand.id}.provisional.mol2"
472
+ validation = validate_and_write_final_mol2(
473
+ mol2_path=source_mol2,
474
+ extracted_atoms=extracted.atoms,
475
+ ligand=ligand,
476
+ final_mol2_path=provisional_mol2,
477
+ charges_csv_path=provisional_dir / "provisional_charges.csv",
478
+ validation_json_path=provisional_dir / "provisional_validation.json",
479
+ )
480
+ warnings.extend(validation.warnings)
481
+
482
+ provisional_frcmod = provisional_dir / f"{ligand.id}.provisional.frcmod"
483
+ parmchk2_run: AmberToolRun | None = None
484
+ if ligand.user_frcmod:
485
+ source_frcmod = Path(ligand.user_frcmod)
486
+ if not source_frcmod.exists():
487
+ raise FileNotFoundError(f"Ligand {ligand.id} user_frcmod does not exist: {source_frcmod}")
488
+ shutil.copyfile(source_frcmod, provisional_frcmod)
489
+ warnings.append("User frcmod supplied bonded parameters for the provisional Amber system.")
490
+ else:
491
+ parmchk2_run = run_parmchk2(
492
+ ligand=ligand,
493
+ input_mol2=provisional_mol2,
494
+ output_frcmod=provisional_frcmod,
495
+ work_dir=provisional_dir,
496
+ )
497
+ return LigandWorkflowItem(
498
+ ligand_id=ligand.id,
499
+ selector=ligand.selector.model_dump(mode="json"),
500
+ residue_identity=extracted.residue.id.to_dict(),
501
+ atom_count=len(extracted.atoms),
502
+ charge_method="am1bcc",
503
+ atom_types=ligand.atom_types,
504
+ net_charge=ligand.net_charge,
505
+ multiplicity=ligand.multiplicity,
506
+ extracted_pdb_path=extracted.pdb_path,
507
+ identity_path=extracted.identity_path,
508
+ final_mol2_path=provisional_mol2,
509
+ final_frcmod_path=provisional_frcmod,
510
+ validation=validation,
511
+ antechamber=antechamber_run,
512
+ parmchk2=parmchk2_run,
513
+ provisional_mol2_path=generated_mol2,
514
+ warnings=warnings,
515
+ )
516
+
517
+
518
+ def _promote_provisional_am1bcc_ligand(
519
+ extracted: ExtractedLigand,
520
+ *,
521
+ provisional_item: LigandWorkflowItem,
522
+ output_dir: str | Path,
523
+ ) -> LigandWorkflowItem:
524
+ ligand = extracted.config
525
+ if provisional_item.final_mol2_path is None or provisional_item.final_frcmod_path is None:
526
+ raise LigandWorkflowError(f"Ligand {ligand.id} has incomplete provisional AM1-BCC parameters.")
527
+ parameters_dir = Path(output_dir) / "ligands" / ligand.id / "parameters"
528
+ parameters_dir.mkdir(parents=True, exist_ok=True)
529
+ final_mol2 = parameters_dir / f"{ligand.id}.final.mol2"
530
+ validation = validate_and_write_final_mol2(
531
+ mol2_path=provisional_item.final_mol2_path,
532
+ extracted_atoms=extracted.atoms,
533
+ ligand=ligand,
534
+ final_mol2_path=final_mol2,
535
+ charges_csv_path=parameters_dir / "charges.csv",
536
+ validation_json_path=parameters_dir / "validation.json",
537
+ )
538
+ final_frcmod = parameters_dir / f"{ligand.id}.frcmod"
539
+ shutil.copyfile(provisional_item.final_frcmod_path, final_frcmod)
540
+ warnings = list(provisional_item.warnings)
541
+ warnings.extend(validation.warnings)
542
+ warnings.append("Promoted the all-ligand provisional AM1-BCC model to the final AM1-BCC model.")
543
+ return LigandWorkflowItem(
544
+ ligand_id=ligand.id,
545
+ selector=ligand.selector.model_dump(mode="json"),
546
+ residue_identity=extracted.residue.id.to_dict(),
547
+ atom_count=len(extracted.atoms),
548
+ charge_method=ligand.charge_method,
549
+ atom_types=ligand.atom_types,
550
+ net_charge=ligand.net_charge,
551
+ multiplicity=ligand.multiplicity,
552
+ extracted_pdb_path=extracted.pdb_path,
553
+ identity_path=extracted.identity_path,
554
+ final_mol2_path=final_mol2,
555
+ final_frcmod_path=final_frcmod,
556
+ validation=validation,
557
+ antechamber=provisional_item.antechamber,
558
+ parmchk2=provisional_item.parmchk2,
559
+ provisional_mol2_path=provisional_item.final_mol2_path,
560
+ warnings=warnings,
561
+ )
562
+
563
+
564
+ def _finalize_qm_ligand(
565
+ extracted: ExtractedLigand,
566
+ *,
567
+ output_dir: str | Path,
568
+ method_name: str,
569
+ provisional_item: LigandWorkflowItem,
570
+ point_charges: PointChargeSelection | None,
571
+ ) -> LigandWorkflowItem:
572
+ ligand = extracted.config
573
+ parameters_dir = Path(output_dir) / "ligands" / ligand.id / "parameters"
574
+ parameters_dir.mkdir(parents=True, exist_ok=True)
575
+ assert provisional_item.final_mol2_path is not None
576
+ pyscf_mol2 = parameters_dir / f"{ligand.id}.pyscf_charges.mol2"
577
+ qm_result = derive_pyscf_charges(
578
+ extracted=extracted,
579
+ provisional_mol2_path=provisional_item.final_mol2_path,
580
+ output_mol2_path=pyscf_mol2,
581
+ output_dir=output_dir,
582
+ method_name=method_name,
583
+ point_charges=point_charges,
584
+ )
585
+ final_mol2 = parameters_dir / f"{ligand.id}.final.mol2"
586
+ validation = validate_and_write_final_mol2(
587
+ mol2_path=pyscf_mol2,
588
+ extracted_atoms=extracted.atoms,
589
+ ligand=ligand,
590
+ final_mol2_path=final_mol2,
591
+ charges_csv_path=parameters_dir / "charges.csv",
592
+ validation_json_path=parameters_dir / "validation.json",
593
+ )
594
+ warnings = list(provisional_item.warnings)
595
+ warnings.extend(qm_result.warnings)
596
+ warnings.append("Final mol2 charges are PySCF QMMESP-fitted charges; provisional charges were replaced.")
597
+ if provisional_item.final_frcmod_path is None:
598
+ raise LigandWorkflowError(f"Ligand {ligand.id} has no provisional bonded parameter file.")
599
+ final_frcmod = parameters_dir / f"{ligand.id}.frcmod"
600
+ shutil.copyfile(provisional_item.final_frcmod_path, final_frcmod)
601
+ return LigandWorkflowItem(
602
+ ligand_id=ligand.id,
603
+ selector=ligand.selector.model_dump(mode="json"),
604
+ residue_identity=extracted.residue.id.to_dict(),
605
+ atom_count=len(extracted.atoms),
606
+ charge_method=ligand.charge_method,
607
+ atom_types=ligand.atom_types,
608
+ net_charge=ligand.net_charge,
609
+ multiplicity=ligand.multiplicity,
610
+ extracted_pdb_path=extracted.pdb_path,
611
+ identity_path=extracted.identity_path,
612
+ final_mol2_path=final_mol2,
613
+ final_frcmod_path=final_frcmod,
614
+ validation=validation,
615
+ antechamber=provisional_item.antechamber,
616
+ parmchk2=provisional_item.parmchk2,
617
+ qm=qm_result,
618
+ provisional_mol2_path=provisional_item.final_mol2_path,
619
+ warnings=warnings,
620
+ )
621
+
622
+
623
+ def _build_qmmesp_provisional_system(
624
+ structure: PdbStructure,
625
+ manifest: ManifestConfig,
626
+ ligand_result: LigandStageResult,
627
+ *,
628
+ output_dir: str | Path,
629
+ protonation_result: ProtonationResult | None,
630
+ context: str = "QMMESP provisional",
631
+ ) -> QmmespProvisionalSystem:
632
+ from mdprep.leap.builder import TLeapOutputs, build_tleap_script
633
+
634
+ output = Path(output_dir)
635
+ work_dir = output / "qmmesp" / "provisional_leap"
636
+ input_dir = work_dir / "input"
637
+ work_dir.mkdir(parents=True, exist_ok=True)
638
+ leap_input = prepare_leap_input_pdb(
639
+ structure,
640
+ input_dir / "provisional_input.pdb",
641
+ manifest=manifest,
642
+ ligand_result=ligand_result,
643
+ )
644
+ sources = forcefield_sources(
645
+ protein_forcefield=manifest.protein.forcefield,
646
+ water_model=manifest.protein.water_model,
647
+ ligands=manifest.ligands,
648
+ )
649
+ ligand_files = validate_ligand_parameter_files(
650
+ manifest=manifest,
651
+ structure=leap_input.structure,
652
+ ligand_result=ligand_result,
653
+ )
654
+ disulfides = (
655
+ disulfide_bond_commands(structure=leap_input.structure, protonation_result=protonation_result)
656
+ if protonation_result is not None
657
+ else []
658
+ )
659
+ append_disulfide_conect_records(leap_input.path, disulfides)
660
+ metal_setup_commands = _qmmesp_provisional_metal_setup(
661
+ structure,
662
+ manifest,
663
+ output_dir=output / "qmmesp" / "provisional_metals",
664
+ )
665
+ outputs = TLeapOutputs(
666
+ prmtop=work_dir / "provisional.prmtop",
667
+ inpcrd=work_dir / "provisional.inpcrd",
668
+ pdb=work_dir / "provisional.pdb",
669
+ )
670
+ script = build_tleap_script(
671
+ sources=sources,
672
+ ligands=ligand_files,
673
+ input_pdb=leap_input.path,
674
+ disulfide_bonds=disulfides,
675
+ outputs=outputs,
676
+ work_dir=work_dir,
677
+ setup_commands=metal_setup_commands,
678
+ )
679
+ script_path = work_dir / "tleap.in"
680
+ script_path.write_text(script, encoding="utf-8")
681
+ run = run_tleap(script_path, work_dir=work_dir)
682
+ assert_tleap_success(
683
+ run.summary,
684
+ fail_on_warnings=manifest.validation.fail_on_warnings,
685
+ context=context,
686
+ )
687
+ for path in [outputs.prmtop, outputs.inpcrd, outputs.pdb]:
688
+ if not path.exists() or path.stat().st_size == 0:
689
+ raise LigandWorkflowError(f"{context} tleap did not produce {path}")
690
+ return QmmespProvisionalSystem(
691
+ leap_input_path=leap_input.path,
692
+ script_path=script_path,
693
+ tleap_run=run,
694
+ prmtop_path=outputs.prmtop,
695
+ inpcrd_path=outputs.inpcrd,
696
+ pdb_path=outputs.pdb,
697
+ )
698
+
699
+
700
+ def _qmmesp_provisional_metal_setup(
701
+ structure: PdbStructure,
702
+ manifest: ManifestConfig,
703
+ *,
704
+ output_dir: Path,
705
+ ) -> list[str]:
706
+ """Parameterize configured ions in a provisional Amber environment."""
707
+
708
+ if not manifest.metals:
709
+ return []
710
+ from mdprep.config.models import NonbondedMetalConfig
711
+ from mdprep.metals.coordination import resolve_metal_sites
712
+ from mdprep.metals.nonbonded import prepare_nonbonded_site
713
+ from mdprep.structure.selectors import resolve_residue_selector
714
+
715
+ sites = resolve_metal_sites(structure, manifest, validate_mcpb_cutoff=True)
716
+ qmmesp_residue_keys: set[tuple[str, int, str | None]] = set()
717
+ for ligand in manifest.ligands:
718
+ if ligand.charge_method != "qmmesp_pyscf":
719
+ continue
720
+ residue = resolve_residue_selector(structure, ligand.selector.model_dump())
721
+ qmmesp_residue_keys.add((residue.id.chain_id, residue.id.resid, residue.id.icode))
722
+ commands: list[str] = []
723
+ parameter_by_type: dict[str, tuple[str, str]] = {}
724
+ template_by_resname: dict[str, tuple[str, int, str]] = {}
725
+ for site in sites:
726
+ if site.config.model == "bonded_mcpb":
727
+ for bond in site.bonds:
728
+ key = (
729
+ bond.coordinator_residue.id.chain_id,
730
+ bond.coordinator_residue.id.resid,
731
+ bond.coordinator_residue.id.icode,
732
+ )
733
+ if key in qmmesp_residue_keys:
734
+ raise LigandWorkflowError(
735
+ f"QMMESP target ligand is part of bonded MCPB.py site {site.config.id!r}. "
736
+ "MCPB.py step 3 would replace that ligand's QMMESP charges, so these two "
737
+ "charge models cannot be combined for the same coordinating residue."
738
+ )
739
+ for residue in site.additional_residues:
740
+ key = (residue.id.chain_id, residue.id.resid, residue.id.icode)
741
+ if key in qmmesp_residue_keys:
742
+ raise LigandWorkflowError(
743
+ f"QMMESP target ligand is an additional residue in bonded MCPB.py site "
744
+ f"{site.config.id!r}. MCPB.py step 3 would replace that ligand's QMMESP "
745
+ "charges, so these two charge models cannot be combined for that residue."
746
+ )
747
+ assert site.config.mcpb is not None
748
+ family = site.config.mcpb.provisional_nonbonded_parameter_set
749
+ if family is None: # normally rejected during manifest validation
750
+ raise LigandWorkflowError(
751
+ f"Bonded MCPB.py site {site.config.id!r} needs "
752
+ "mcpb.provisional_nonbonded_parameter_set for the provisional Amber system."
753
+ )
754
+ temporary_config = site.config.model_copy(
755
+ update={
756
+ "model": "nonbonded",
757
+ "nonbonded": NonbondedMetalConfig(parameter_set=family),
758
+ "mcpb": None,
759
+ }
760
+ )
761
+ provisional_site = replace(site, config=temporary_config)
762
+ else:
763
+ provisional_site = site
764
+ result = prepare_nonbonded_site(
765
+ provisional_site,
766
+ water_model=manifest.protein.water_model,
767
+ output_dir=output_dir / provisional_site.config.id,
768
+ )
769
+ for artifact in result.ions:
770
+ parameter = artifact.parameter
771
+ signature = (parameter.parameter_set, parameter.frcmod_name)
772
+ previous = parameter_by_type.get(parameter.atom_type)
773
+ if previous is not None and previous != signature:
774
+ raise LigandWorkflowError(
775
+ f"Provisional Amber topology requests atom type "
776
+ f"{parameter.atom_type} with conflicting ion models {previous} and "
777
+ f"{signature}."
778
+ )
779
+ parameter_by_type[parameter.atom_type] = signature
780
+ residue_name = artifact.ion.residue.id.resname
781
+ template = (
782
+ artifact.ion.element,
783
+ artifact.ion.charge,
784
+ artifact.ion.atom.name,
785
+ )
786
+ prior_template = template_by_resname.get(residue_name)
787
+ if prior_template is not None and prior_template != template:
788
+ raise LigandWorkflowError(
789
+ f"Provisional Amber topology maps metal residue name {residue_name!r} "
790
+ f"to conflicting templates {prior_template} and {template}."
791
+ )
792
+ template_by_resname[residue_name] = template
793
+ commands.extend(result.leap_commands)
794
+ return _deduplicate_tleap_setup(commands)
795
+
796
+
797
+ def _deduplicate_tleap_setup(commands: list[str]) -> list[str]:
798
+ result: list[str] = []
799
+ seen_commands: set[str] = set()
800
+ seen_blocks: set[tuple[str, ...]] = set()
801
+ index = 0
802
+ while index < len(commands):
803
+ if commands[index] == "addAtomTypes {":
804
+ end = index + 1
805
+ while end < len(commands) and commands[end] != "}":
806
+ end += 1
807
+ if end >= len(commands):
808
+ raise LigandWorkflowError(
809
+ "Unterminated addAtomTypes block in provisional metal setup."
810
+ )
811
+ block = tuple(commands[index : end + 1])
812
+ if block not in seen_blocks:
813
+ seen_blocks.add(block)
814
+ result.extend(block)
815
+ index = end + 1
816
+ continue
817
+ command = commands[index]
818
+ if command not in seen_commands:
819
+ seen_commands.add(command)
820
+ result.append(command)
821
+ index += 1
822
+ return result
823
+
824
+
825
+ def _coordinates(extracted: ExtractedLigand):
826
+ import numpy as np
827
+
828
+ return np.asarray([[atom.x, atom.y, atom.z] for atom in extracted.atoms], dtype=float)
829
+
830
+
831
+ def _copy_user_mol2(ligand, parameters_dir: Path, *, suffix: str) -> Path:
832
+ assert ligand.user_mol2 is not None
833
+ source = Path(ligand.user_mol2)
834
+ if not source.exists():
835
+ raise FileNotFoundError(f"Ligand {ligand.id} user_mol2 does not exist: {source}")
836
+ target = parameters_dir / f"{ligand.id}.{suffix}.mol2"
837
+ shutil.copyfile(source, target)
838
+ return target