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
mdprep/metals/mcpb.py ADDED
@@ -0,0 +1,949 @@
1
+ """Preserved, staged MCPB.py workflow for bonded metal centers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict
6
+ from dataclasses import dataclass, replace
7
+ from math import dist
8
+ from pathlib import Path
9
+ import os
10
+ import re
11
+ import shutil
12
+ import sys
13
+ from typing import TYPE_CHECKING
14
+
15
+ from mdprep.ambertools.mol2 import Mol2Error, read_mol2
16
+ from mdprep.config.models import ManifestConfig
17
+ from mdprep.external.discovery import which_executable
18
+ from mdprep.external.runner import CommandResult, run_command
19
+ from mdprep.metals.coordination import ResolvedMetalSite
20
+ from mdprep.metals.nonbonded import (
21
+ AmberIonParameter,
22
+ NonbondedMetalError,
23
+ resolve_amberhome,
24
+ write_single_ion_mol2,
25
+ )
26
+ from mdprep.structure.classify import is_standard_protein_residue, is_water_residue
27
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
28
+ from mdprep.structure.pdb import read_pdb
29
+ from mdprep.structure.writer import write_pdb
30
+
31
+ if TYPE_CHECKING:
32
+ from mdprep.ligands.workflow import LigandStageResult
33
+ from mdprep.metals.parameter_comparison import McpbParameterComparisonResult
34
+ from mdprep.metals.pyscf_mcpb import McpbPySCFResult
35
+ from mdprep.refinement.workflow import RefinementResult
36
+
37
+
38
+ class McpbWorkflowError(ValueError):
39
+ """Raised when a bonded MCPB.py workflow cannot be completed safely."""
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class McpbRun:
44
+ step: str
45
+ result: CommandResult
46
+ stdout_path: Path
47
+ stderr_path: Path
48
+
49
+ def to_dict(self) -> dict[str, object]:
50
+ return {
51
+ "step": self.step,
52
+ "command": list(self.result.command),
53
+ "cwd": self.result.cwd,
54
+ "returncode": self.result.returncode,
55
+ "stdout": self.result.stdout,
56
+ "stderr": self.result.stderr,
57
+ "runtime_seconds": self.result.runtime_seconds,
58
+ "stdout_path": str(self.stdout_path),
59
+ "stderr_path": str(self.stderr_path),
60
+ }
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class McpbNumberingMap:
65
+ original_by_mcpb_atom_id: dict[int, AtomRecord]
66
+ original_by_mcpb_resid: dict[int, ResidueRecord]
67
+ mcpb_atom_id_by_original_identity: dict[tuple[str, str, int, str | None, str], int]
68
+ mcpb_resid_by_original_key: dict[tuple[str, int, str | None], int]
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class McpbRefittedLigand:
73
+ """One non-protein residue whose final charges come from joint MCPB RESP."""
74
+
75
+ ligand_id: str
76
+ original_resname: str
77
+ final_resname: str
78
+ final_mol2_path: Path
79
+ provisional_mol2_path: Path
80
+ atom_names: tuple[str, ...]
81
+ atom_count: int
82
+ fragment_charge: float
83
+ maximum_coordinate_deviation_angstrom: float
84
+
85
+ def to_dict(self) -> dict[str, object]:
86
+ return {
87
+ "ligand_id": self.ligand_id,
88
+ "charge_source": "joint_mcpb_resp_pyscf",
89
+ "original_resname": self.original_resname,
90
+ "final_resname": self.final_resname,
91
+ "final_mol2_path": str(self.final_mol2_path),
92
+ "provisional_mol2_path": str(self.provisional_mol2_path),
93
+ "atom_names": list(self.atom_names),
94
+ "atom_count": self.atom_count,
95
+ "fragment_charge": self.fragment_charge,
96
+ "maximum_coordinate_deviation_angstrom": (
97
+ self.maximum_coordinate_deviation_angstrom
98
+ ),
99
+ "fragment_charge_note": (
100
+ "This is a fragment charge from one joint metal-cluster RESP fit; "
101
+ "it is not independently constrained to the isolated formal charge."
102
+ ),
103
+ }
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class McpbSiteResult:
108
+ site_id: str
109
+ workflow: str
110
+ complete: bool
111
+ work_dir: Path
112
+ input_path: Path
113
+ mcpb_input_pdb_path: Path
114
+ restored_final_pdb_path: Path | None
115
+ final_frcmod_path: Path | None
116
+ generated_tleap_path: Path | None
117
+ leap_setup_commands: tuple[str, ...]
118
+ leap_bond_commands: tuple[str, ...]
119
+ residue_renames: tuple[dict[str, object], ...]
120
+ runs: tuple[McpbRun, ...]
121
+ expected_qm_artifacts: tuple[str, ...]
122
+ generated_files: tuple[Path, ...]
123
+ amberhome: Path
124
+ refitted_ligands: tuple[McpbRefittedLigand, ...] = ()
125
+ pyscf_result: "McpbPySCFResult | None" = None
126
+ parameter_comparison: "McpbParameterComparisonResult | None" = None
127
+
128
+ def to_dict(self) -> dict[str, object]:
129
+ return {
130
+ "site_id": self.site_id,
131
+ "model": "bonded_mcpb",
132
+ "workflow": self.workflow,
133
+ "complete": self.complete,
134
+ "work_dir": str(self.work_dir),
135
+ "input_path": str(self.input_path),
136
+ "mcpb_input_pdb_path": str(self.mcpb_input_pdb_path),
137
+ "restored_final_pdb_path": (
138
+ str(self.restored_final_pdb_path) if self.restored_final_pdb_path else None
139
+ ),
140
+ "final_frcmod_path": str(self.final_frcmod_path) if self.final_frcmod_path else None,
141
+ "generated_tleap_path": (
142
+ str(self.generated_tleap_path) if self.generated_tleap_path else None
143
+ ),
144
+ "leap_setup_commands": list(self.leap_setup_commands),
145
+ "leap_bond_commands": list(self.leap_bond_commands),
146
+ "residue_renames": list(self.residue_renames),
147
+ "runs": [run.to_dict() for run in self.runs],
148
+ "expected_qm_artifacts": list(self.expected_qm_artifacts),
149
+ "generated_files": [str(path) for path in self.generated_files],
150
+ "amberhome": str(self.amberhome),
151
+ "refitted_ligands": [item.to_dict() for item in self.refitted_ligands],
152
+ "pyscf": self.pyscf_result.to_dict() if self.pyscf_result else None,
153
+ "parameter_comparison": (
154
+ self.parameter_comparison.to_dict()
155
+ if self.parameter_comparison is not None
156
+ else None
157
+ ),
158
+ }
159
+
160
+
161
+ def run_mcpb_site(
162
+ site: ResolvedMetalSite,
163
+ *,
164
+ structure: PdbStructure,
165
+ manifest: ManifestConfig,
166
+ ligand_result: "LigandStageResult",
167
+ output_dir: str | Path,
168
+ provisional_prmtop: str | Path | None = None,
169
+ provisional_inpcrd: str | Path | None = None,
170
+ refinement_result: "RefinementResult | None" = None,
171
+ ) -> McpbSiteResult:
172
+ if site.config.model != "bonded_mcpb" or site.config.mcpb is None:
173
+ raise McpbWorkflowError(f"Metal site {site.config.id!r} is not configured for MCPB.py.")
174
+ mcpb = site.config.mcpb
175
+ executable = which_executable(mcpb.executable)
176
+ if executable is None:
177
+ raise McpbWorkflowError(f"AmberTools executable not found: {mcpb.executable}")
178
+ try:
179
+ amberhome = resolve_amberhome(Path(executable).resolve().parent.parent)
180
+ except NonbondedMetalError as exc:
181
+ raise McpbWorkflowError(str(exc)) from exc
182
+ mcpb_environment = dict(os.environ)
183
+ mcpb_environment["AMBERHOME"] = str(amberhome)
184
+ _validate_mcpb_structure_scope(structure)
185
+ _validate_nonstandard_site_residues(site, manifest)
186
+
187
+ work = Path(output_dir)
188
+ work.mkdir(parents=True, exist_ok=True)
189
+ group_name = _safe_group_name(site.config.id)
190
+ input_pdb = work / "system.mcpb_input.pdb"
191
+ numbering = write_mcpb_numbered_pdb(structure, input_pdb)
192
+ ligand_mol2, ligand_frcmods, gaff = _stage_ligand_parameters(
193
+ manifest=manifest,
194
+ ligand_result=ligand_result,
195
+ work_dir=work,
196
+ )
197
+ ion_mol2 = _write_mcpb_ion_mol2(site, numbering=numbering, work_dir=work)
198
+ input_path = work / "mcpb.in"
199
+ input_path.write_text(
200
+ build_mcpb_input(
201
+ site,
202
+ manifest=manifest,
203
+ numbering=numbering,
204
+ original_pdb=input_pdb,
205
+ ion_mol2=ion_mol2,
206
+ ligand_mol2=ligand_mol2,
207
+ ligand_frcmods=ligand_frcmods,
208
+ gaff=gaff,
209
+ group_name=group_name,
210
+ ),
211
+ encoding="utf-8",
212
+ )
213
+
214
+ runs: list[McpbRun] = []
215
+ runs.append(
216
+ _run_mcpb(
217
+ executable,
218
+ input_path,
219
+ "1",
220
+ work,
221
+ environment=mcpb_environment,
222
+ )
223
+ )
224
+ for required_input in _required_qm_inputs(
225
+ work,
226
+ group_name=group_name,
227
+ software_version=mcpb.software_version,
228
+ ):
229
+ if not required_input.is_file() or required_input.stat().st_size == 0:
230
+ raise McpbWorkflowError(
231
+ f"MCPB.py step 1 did not produce required QM input: {required_input}"
232
+ )
233
+ expected = _expected_qm_artifacts(
234
+ group_name,
235
+ mcpb.force_constant_method,
236
+ mcpb.software_version,
237
+ )
238
+ if mcpb.workflow == "prepare_inputs":
239
+ return McpbSiteResult(
240
+ site_id=site.config.id,
241
+ workflow=mcpb.workflow,
242
+ complete=False,
243
+ work_dir=work,
244
+ input_path=input_path,
245
+ mcpb_input_pdb_path=input_pdb,
246
+ restored_final_pdb_path=None,
247
+ final_frcmod_path=None,
248
+ generated_tleap_path=None,
249
+ leap_setup_commands=(),
250
+ leap_bond_commands=(),
251
+ residue_renames=(),
252
+ runs=tuple(runs),
253
+ expected_qm_artifacts=expected,
254
+ generated_files=tuple(_generated_files(work)),
255
+ amberhome=amberhome,
256
+ )
257
+
258
+ pyscf_result = None
259
+ if mcpb.workflow == "complete":
260
+ _stage_qm_artifacts(mcpb, group_name=group_name, work_dir=work)
261
+ elif mcpb.workflow == "pyscf":
262
+ if provisional_prmtop is None or provisional_inpcrd is None:
263
+ raise McpbWorkflowError(
264
+ "mcpb.workflow: pyscf requires the pre-MCPB Amber prmtop/inpcrd "
265
+ "for electrostatic embedding."
266
+ )
267
+ from mdprep.metals.pyscf_mcpb import McpbPySCFError, run_mcpb_pyscf
268
+
269
+ try:
270
+ pyscf_result = run_mcpb_pyscf(
271
+ site,
272
+ group_name=group_name,
273
+ work_dir=work,
274
+ provisional_prmtop=provisional_prmtop,
275
+ provisional_inpcrd=provisional_inpcrd,
276
+ provisional_structure=structure,
277
+ refinement_result=refinement_result,
278
+ )
279
+ except McpbPySCFError as exc:
280
+ raise McpbWorkflowError(str(exc)) from exc
281
+ else:
282
+ raise McpbWorkflowError(f"Unsupported completed MCPB workflow: {mcpb.workflow}")
283
+ step_2 = {
284
+ "seminario": "2s",
285
+ "modified_seminario": "2ms",
286
+ "empirical": "2e",
287
+ "z_matrix": "2z",
288
+ }[mcpb.force_constant_method]
289
+ step_3 = {
290
+ "all_ligating": "3a",
291
+ "backbone_heavy": "3b",
292
+ "backbone_all": "3c",
293
+ "backbone_and_cb": "3d",
294
+ }[mcpb.charge_restraint]
295
+ for step in (step_2, step_3, "4b"):
296
+ runs.append(
297
+ _run_mcpb(
298
+ executable,
299
+ input_path,
300
+ step,
301
+ work,
302
+ environment=mcpb_environment,
303
+ )
304
+ )
305
+
306
+ generated_pdb = work / f"{group_name}_mcpbpy.pdb"
307
+ final_frcmod = work / f"{group_name}_mcpbpy.frcmod"
308
+ tleap_path = work / f"{group_name}_tleap.in"
309
+ for required in (generated_pdb, final_frcmod, tleap_path):
310
+ if not required.is_file() or required.stat().st_size == 0:
311
+ raise McpbWorkflowError(f"MCPB.py did not produce required artifact: {required}")
312
+ if mcpb.force_constant_method in {"seminario", "modified_seminario"}:
313
+ from mdprep.metals.parameter_comparison import (
314
+ McpbParameterComparisonError,
315
+ parse_mcpb_seminario_terms,
316
+ )
317
+
318
+ try:
319
+ parse_mcpb_seminario_terms(final_frcmod)
320
+ except McpbParameterComparisonError as exc:
321
+ raise McpbWorkflowError(
322
+ "MCPB.py produced invalid Seminario bonded parameters for metal site "
323
+ f"{site.config.id!r}: {exc}"
324
+ ) from exc
325
+ restored_pdb = work / f"{group_name}_mcpbpy.restored_ids.pdb"
326
+ renames = restore_mcpb_residue_identities(
327
+ generated_pdb,
328
+ numbering=numbering,
329
+ output_path=restored_pdb,
330
+ )
331
+ setup_commands, bond_commands = parse_mcpb_tleap_commands(tleap_path, work_dir=work)
332
+ refitted_ligands = collect_mcpb_refitted_ligands(
333
+ manifest=manifest,
334
+ structure=structure,
335
+ ligand_result=ligand_result,
336
+ residue_renames=renames,
337
+ work_dir=work,
338
+ leap_setup_commands=setup_commands,
339
+ )
340
+ parameter_comparison = None
341
+ if mcpb.parameter_comparison is not None:
342
+ from mdprep.metals.parameter_comparison import (
343
+ McpbParameterComparisonError,
344
+ compare_mcpb_parameter_files,
345
+ )
346
+
347
+ try:
348
+ parameter_comparison = compare_mcpb_parameter_files(
349
+ final_frcmod,
350
+ config=mcpb.parameter_comparison,
351
+ output_dir=work / "parameter_comparison",
352
+ )
353
+ except McpbParameterComparisonError as exc:
354
+ raise McpbWorkflowError(str(exc)) from exc
355
+ return McpbSiteResult(
356
+ site_id=site.config.id,
357
+ workflow=mcpb.workflow,
358
+ complete=True,
359
+ work_dir=work,
360
+ input_path=input_path,
361
+ mcpb_input_pdb_path=input_pdb,
362
+ restored_final_pdb_path=restored_pdb,
363
+ final_frcmod_path=final_frcmod,
364
+ generated_tleap_path=tleap_path,
365
+ leap_setup_commands=tuple(setup_commands),
366
+ leap_bond_commands=tuple(bond_commands),
367
+ residue_renames=tuple(renames),
368
+ runs=tuple(runs),
369
+ expected_qm_artifacts=expected,
370
+ generated_files=tuple(_generated_files(work)),
371
+ amberhome=amberhome,
372
+ refitted_ligands=tuple(refitted_ligands),
373
+ pyscf_result=pyscf_result,
374
+ parameter_comparison=parameter_comparison,
375
+ )
376
+
377
+
378
+ def collect_mcpb_refitted_ligands(
379
+ *,
380
+ manifest: ManifestConfig,
381
+ structure: PdbStructure,
382
+ ligand_result: "LigandStageResult",
383
+ residue_renames: list[dict[str, object]],
384
+ work_dir: str | Path,
385
+ leap_setup_commands: list[str],
386
+ ) -> list[McpbRefittedLigand]:
387
+ """Validate and expose MCPB's RESP-fitted mol2 for configured cofactors."""
388
+
389
+ configured = [
390
+ ligand
391
+ for ligand in manifest.ligands
392
+ if ligand.charge_method == "mcpb_resp_pyscf"
393
+ ]
394
+ if not configured:
395
+ return []
396
+ items = {item.ligand_id: item for item in ligand_result.ligands}
397
+ output: list[McpbRefittedLigand] = []
398
+ work = Path(work_dir)
399
+ for ligand in configured:
400
+ selector = ligand.selector
401
+ matches = [
402
+ residue
403
+ for residue in structure.residues
404
+ if residue.id.chain_id == selector.chain
405
+ and residue.id.resname == selector.resname
406
+ and residue.id.resid == selector.resid
407
+ and residue.id.icode == selector.icode
408
+ ]
409
+ if len(matches) != 1:
410
+ raise McpbWorkflowError(
411
+ f"Could not map MCPB RESP ligand {ligand.id!r} uniquely in the "
412
+ "hydrogenated input structure."
413
+ )
414
+ residue = matches[0]
415
+ matching_renames = [
416
+ rename
417
+ for rename in residue_renames
418
+ if rename.get("chain") == selector.chain
419
+ and rename.get("resid") == selector.resid
420
+ and rename.get("icode") == selector.icode
421
+ and rename.get("original_resname") == selector.resname
422
+ ]
423
+ if len(matching_renames) != 1:
424
+ raise McpbWorkflowError(
425
+ f"MCPB.py did not report one exact residue rename for RESP ligand "
426
+ f"{ligand.id!r}."
427
+ )
428
+ final_resname = str(matching_renames[0]["final_resname"])
429
+ final_mol2 = (work / f"{final_resname}.mol2").resolve()
430
+ if not final_mol2.is_file() or final_mol2.stat().st_size == 0:
431
+ raise McpbWorkflowError(
432
+ f"MCPB.py did not produce the final RESP-fitted mol2 for ligand "
433
+ f"{ligand.id!r}: {final_mol2}"
434
+ )
435
+ try:
436
+ parsed = read_mol2(final_mol2)
437
+ except Mol2Error as exc:
438
+ raise McpbWorkflowError(
439
+ f"Could not validate MCPB RESP mol2 for ligand {ligand.id!r}: {exc}"
440
+ ) from exc
441
+ expected_names = residue.atom_names()
442
+ observed_names = [atom.name for atom in parsed.atoms]
443
+ if observed_names != expected_names:
444
+ raise McpbWorkflowError(
445
+ f"MCPB RESP mol2 atom-name/order mismatch for ligand {ligand.id!r}: "
446
+ f"{observed_names} != {expected_names}."
447
+ )
448
+ if {atom.subst_name for atom in parsed.atoms} != {final_resname}:
449
+ raise McpbWorkflowError(
450
+ f"MCPB RESP mol2 for ligand {ligand.id!r} does not use its final "
451
+ f"residue name {final_resname!r}."
452
+ )
453
+ deviations = [
454
+ dist(
455
+ (pdb_atom.x, pdb_atom.y, pdb_atom.z),
456
+ (mol2_atom.x, mol2_atom.y, mol2_atom.z),
457
+ )
458
+ for pdb_atom, mol2_atom in zip(residue.atoms, parsed.atoms, strict=True)
459
+ ]
460
+ maximum_deviation = max(deviations, default=0.0)
461
+ if maximum_deviation > 0.002:
462
+ raise McpbWorkflowError(
463
+ f"MCPB RESP mol2 moved ligand {ligand.id!r} by "
464
+ f"{maximum_deviation:.6f} A; only charge/type changes are allowed."
465
+ )
466
+ item = items.get(ligand.id)
467
+ if item is None or item.provisional_mol2_path is None:
468
+ raise McpbWorkflowError(
469
+ f"MCPB RESP ligand {ligand.id!r} has no recorded provisional AM1-BCC mol2."
470
+ )
471
+ load_command = f"{final_resname} = loadmol2 {final_mol2}"
472
+ if load_command not in leap_setup_commands:
473
+ raise McpbWorkflowError(
474
+ f"Final MCPB tleap setup does not load the RESP-fitted mol2 for "
475
+ f"ligand {ligand.id!r}: {load_command}"
476
+ )
477
+ output.append(
478
+ McpbRefittedLigand(
479
+ ligand_id=ligand.id,
480
+ original_resname=selector.resname,
481
+ final_resname=final_resname,
482
+ final_mol2_path=final_mol2,
483
+ provisional_mol2_path=Path(item.provisional_mol2_path).resolve(),
484
+ atom_names=tuple(observed_names),
485
+ atom_count=len(observed_names),
486
+ fragment_charge=float(parsed.total_charge),
487
+ maximum_coordinate_deviation_angstrom=maximum_deviation,
488
+ )
489
+ )
490
+ return output
491
+
492
+
493
+ def write_mcpb_numbered_pdb(
494
+ structure: PdbStructure,
495
+ path: str | Path,
496
+ ) -> McpbNumberingMap:
497
+ atoms: list[AtomRecord] = []
498
+ original_by_atom: dict[int, AtomRecord] = {}
499
+ original_by_residue: dict[int, ResidueRecord] = {}
500
+ atom_id_by_identity: dict[tuple[str, str, int, str | None, str], int] = {}
501
+ resid_by_key: dict[tuple[str, int, str | None], int] = {}
502
+ atom_id = 0
503
+ for residue_id, residue in enumerate(structure.residues, start=1):
504
+ original_by_residue[residue_id] = residue
505
+ residue_key = (residue.id.chain_id, residue.id.resid, residue.id.icode)
506
+ if residue_key in resid_by_key:
507
+ raise McpbWorkflowError(
508
+ f"Residue identity {residue.id.display()} is ambiguous before MCPB.py numbering."
509
+ )
510
+ resid_by_key[residue_key] = residue_id
511
+ for atom in residue.atoms:
512
+ atom_id += 1
513
+ if atom.atom_identity in atom_id_by_identity:
514
+ raise McpbWorkflowError(f"Duplicate atom identity before MCPB.py: {atom.atom_identity}")
515
+ atom_id_by_identity[atom.atom_identity] = atom_id
516
+ original_by_atom[atom_id] = atom
517
+ atoms.append(
518
+ replace(
519
+ atom,
520
+ serial=atom_id,
521
+ chain_id="A",
522
+ resid=residue_id,
523
+ icode=None,
524
+ altloc=None,
525
+ )
526
+ )
527
+ numbered = PdbStructure(
528
+ path=Path(path),
529
+ atoms=atoms,
530
+ residues=_build_residues(atoms),
531
+ model_count=1,
532
+ used_model=1,
533
+ warnings=list(structure.warnings),
534
+ )
535
+ write_pdb(numbered, path)
536
+ return McpbNumberingMap(
537
+ original_by_mcpb_atom_id=original_by_atom,
538
+ original_by_mcpb_resid=original_by_residue,
539
+ mcpb_atom_id_by_original_identity=atom_id_by_identity,
540
+ mcpb_resid_by_original_key=resid_by_key,
541
+ )
542
+
543
+
544
+ def build_mcpb_input(
545
+ site: ResolvedMetalSite,
546
+ *,
547
+ manifest: ManifestConfig,
548
+ numbering: McpbNumberingMap,
549
+ original_pdb: Path,
550
+ ion_mol2: list[Path],
551
+ ligand_mol2: list[Path],
552
+ ligand_frcmods: list[Path],
553
+ gaff: int,
554
+ group_name: str,
555
+ ) -> str:
556
+ assert site.config.mcpb is not None
557
+ mcpb = site.config.mcpb
558
+ ion_ids = [numbering.mcpb_atom_id_by_original_identity[ion.atom.atom_identity] for ion in site.ions]
559
+ additional_resids = [
560
+ numbering.mcpb_resid_by_original_key[
561
+ (residue.id.chain_id, residue.id.resid, residue.id.icode)
562
+ ]
563
+ for residue in site.additional_residues
564
+ ]
565
+ added_pairs = [
566
+ (
567
+ numbering.mcpb_atom_id_by_original_identity[bond.ion.atom.atom_identity],
568
+ numbering.mcpb_atom_id_by_original_identity[bond.coordinator.atom_identity],
569
+ )
570
+ for bond in site.bonds
571
+ if not bond.discovered_by_cutoff
572
+ ]
573
+ lines = [
574
+ f"original_pdb {original_pdb.name}",
575
+ "ion_ids " + " ".join(str(value) for value in ion_ids),
576
+ "ion_mol2files " + " ".join(path.name for path in ion_mol2),
577
+ f"group_name {group_name}",
578
+ f"cut_off {mcpb.cutoff_angstrom:.6f}",
579
+ f"force_field {manifest.protein.forcefield}",
580
+ f"water_model {manifest.protein.water_model.lower()}",
581
+ f"ion_paraset {mcpb.provisional_nonbonded_parameter_set}",
582
+ f"gaff {gaff}",
583
+ f"software_version {mcpb.software_version}",
584
+ f"smmodel_chg {mcpb.small_model_charge}",
585
+ f"smmodel_spin {mcpb.small_model_spin}",
586
+ f"lgmodel_chg {mcpb.large_model_charge}",
587
+ f"lgmodel_spin {mcpb.large_model_spin}",
588
+ f"scale_factor {mcpb.scale_factor:.8f}",
589
+ f"large_opt {mcpb.large_opt}",
590
+ ]
591
+ if ligand_mol2:
592
+ lines.append("naa_mol2files " + " ".join(path.name for path in ligand_mol2))
593
+ if ligand_frcmods:
594
+ lines.append("frcmod_files " + " ".join(path.name for path in ligand_frcmods))
595
+ if additional_resids:
596
+ lines.append("additional_resids " + " ".join(str(value) for value in additional_resids))
597
+ if added_pairs:
598
+ lines.append(
599
+ "add_bonded_pairs " + " ".join(f"{a}-{b}" for a, b in added_pairs)
600
+ )
601
+ lines.append("")
602
+ return "\n".join(lines)
603
+
604
+
605
+ def restore_mcpb_residue_identities(
606
+ generated_pdb: str | Path,
607
+ *,
608
+ numbering: McpbNumberingMap,
609
+ output_path: str | Path,
610
+ ) -> list[dict[str, object]]:
611
+ generated = read_pdb(generated_pdb)
612
+ atoms: list[AtomRecord] = []
613
+ renames_by_resid: dict[int, dict[str, object]] = {}
614
+ for atom in generated.atoms:
615
+ original_atom = numbering.original_by_mcpb_atom_id.get(atom.serial or -1)
616
+ original_residue = numbering.original_by_mcpb_resid.get(atom.resid)
617
+ if original_atom is None or original_residue is None:
618
+ raise McpbWorkflowError(
619
+ f"Could not map MCPB.py atom {atom.serial} residue {atom.resid} back to input identity."
620
+ )
621
+ coordinate_deviation = dist(
622
+ (atom.x, atom.y, atom.z),
623
+ (original_atom.x, original_atom.y, original_atom.z),
624
+ )
625
+ if coordinate_deviation > 0.002:
626
+ raise McpbWorkflowError(
627
+ "MCPB.py changed an input atom coordinate by "
628
+ f"{coordinate_deviation:.6f} A for {original_atom.atom_identity}; "
629
+ "the refined fixed geometry must be preserved."
630
+ )
631
+ if atom.resname != original_residue.id.resname:
632
+ renames_by_resid.setdefault(
633
+ atom.resid,
634
+ {
635
+ "chain": original_residue.id.chain_id,
636
+ "resid": original_residue.id.resid,
637
+ "icode": original_residue.id.icode,
638
+ "original_resname": original_residue.id.resname,
639
+ "final_resname": atom.resname,
640
+ "source": "MCPB.py",
641
+ },
642
+ )
643
+ atoms.append(
644
+ replace(
645
+ atom,
646
+ serial=original_atom.serial,
647
+ chain_id=original_residue.id.chain_id,
648
+ resid=original_residue.id.resid,
649
+ icode=original_residue.id.icode,
650
+ element=original_atom.element,
651
+ )
652
+ )
653
+ restored = PdbStructure(
654
+ path=Path(output_path),
655
+ atoms=atoms,
656
+ residues=_build_residues(atoms),
657
+ model_count=1,
658
+ used_model=1,
659
+ )
660
+ write_pdb(restored, output_path)
661
+ return [renames_by_resid[key] for key in sorted(renames_by_resid)]
662
+
663
+
664
+ def parse_mcpb_tleap_commands(
665
+ path: str | Path,
666
+ *,
667
+ work_dir: str | Path,
668
+ ) -> tuple[list[str], list[str]]:
669
+ """Extract only parameter/type setup and bond commands from MCPB.py output."""
670
+
671
+ work = Path(work_dir)
672
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
673
+ setup: list[str] = []
674
+ bonds: list[str] = []
675
+ in_atom_types = False
676
+ for raw in lines:
677
+ line = raw.strip()
678
+ if not line or line.startswith("#"):
679
+ continue
680
+ if line == "addAtomTypes {":
681
+ in_atom_types = True
682
+ setup.append(line)
683
+ continue
684
+ if in_atom_types:
685
+ setup.append(line)
686
+ if line == "}":
687
+ in_atom_types = False
688
+ continue
689
+ if " = loadmol2 " in line:
690
+ prefix, filename = line.rsplit(" ", 1)
691
+ local = work / filename
692
+ if not local.is_file():
693
+ raise McpbWorkflowError(f"MCPB.py tleap input references missing mol2: {local}")
694
+ setup.append(f"{prefix} {local.resolve()}")
695
+ elif line.startswith("loadamberparams "):
696
+ filename = line.split(maxsplit=1)[1]
697
+ local = work / filename
698
+ setup.append(
699
+ f"loadamberparams {local.resolve()}" if local.is_file() else line
700
+ )
701
+ elif line.startswith("loadoff "):
702
+ filename = line.split(maxsplit=1)[1]
703
+ local = work / filename
704
+ if not local.is_file():
705
+ raise McpbWorkflowError(f"MCPB.py tleap input references missing library: {local}")
706
+ setup.append(f"loadoff {local.resolve()}")
707
+ elif line.startswith("bond mol."):
708
+ bonds.append("bond system." + line[len("bond mol.") :].replace(" mol.", " system."))
709
+ if not setup:
710
+ raise McpbWorkflowError(f"No MCPB.py parameter setup commands were found in {path}.")
711
+ if not bonds:
712
+ raise McpbWorkflowError(f"No bonded metal-site commands were found in {path}.")
713
+ return setup, bonds
714
+
715
+
716
+ def _run_mcpb(
717
+ executable: str,
718
+ input_path: Path,
719
+ step: str,
720
+ work_dir: Path,
721
+ *,
722
+ environment: dict[str, str] | None = None,
723
+ ) -> McpbRun:
724
+ if step in {"2s", "2ms"}:
725
+ compatibility_script = Path(__file__).with_name("mcpb_compat.py").resolve()
726
+ command = [
727
+ sys.executable,
728
+ str(compatibility_script),
729
+ "--mcpb-script",
730
+ executable,
731
+ "--",
732
+ "-i",
733
+ input_path.name,
734
+ "-s",
735
+ step,
736
+ ]
737
+ else:
738
+ command = [executable, "-i", input_path.name, "-s", step]
739
+ if environment is None:
740
+ result = run_command(command, cwd=work_dir)
741
+ else:
742
+ result = run_command(command, cwd=work_dir, env=environment)
743
+ label = step.replace("/", "_")
744
+ stdout_path = work_dir / f"mcpb.step_{label}.stdout.txt"
745
+ stderr_path = work_dir / f"mcpb.step_{label}.stderr.txt"
746
+ stdout_path.write_text(result.stdout, encoding="utf-8")
747
+ stderr_path.write_text(result.stderr, encoding="utf-8")
748
+ if result.returncode != 0:
749
+ raise McpbWorkflowError(
750
+ f"MCPB.py step {step} failed with exit code {result.returncode}. "
751
+ f"See {stdout_path} and {stderr_path}."
752
+ )
753
+ return McpbRun(step=step, result=result, stdout_path=stdout_path, stderr_path=stderr_path)
754
+
755
+
756
+ def _write_mcpb_ion_mol2(
757
+ site: ResolvedMetalSite,
758
+ *,
759
+ numbering: McpbNumberingMap,
760
+ work_dir: Path,
761
+ ) -> list[Path]:
762
+ paths: list[Path] = []
763
+ for index, ion in enumerate(site.ions, start=1):
764
+ atom_type = f"{ion.element}{ion.charge}+"
765
+ parameter = AmberIonParameter(
766
+ element=ion.element,
767
+ charge=ion.charge,
768
+ atom_type=atom_type,
769
+ mass=0.0,
770
+ rmin_over_2_angstrom=0.0,
771
+ epsilon_kcal_mol=0.0,
772
+ parameter_set="mcpb_input",
773
+ water_model="",
774
+ frcmod_name="",
775
+ frcmod_path=Path("."),
776
+ )
777
+ numbered_atom_id = numbering.mcpb_atom_id_by_original_identity[ion.atom.atom_identity]
778
+ numbered_resid = numbering.mcpb_resid_by_original_key[
779
+ (ion.residue.id.chain_id, ion.residue.id.resid, ion.residue.id.icode)
780
+ ]
781
+ numbered_atom = replace(ion.atom, serial=numbered_atom_id, chain_id="A", resid=numbered_resid, icode=None)
782
+ numbered_residue = ResidueRecord(
783
+ id=ResidueId("A", ion.residue.id.resname, numbered_resid),
784
+ atoms=[numbered_atom],
785
+ record_names={numbered_atom.record_name},
786
+ original_index=numbered_resid - 1,
787
+ )
788
+ numbered_ion = replace(ion, atom=numbered_atom, residue=numbered_residue)
789
+ path = work_dir / f"ion_{index}_{ion.residue.id.resname}.mol2"
790
+ write_single_ion_mol2(numbered_ion, parameter, path)
791
+ paths.append(path)
792
+ return paths
793
+
794
+
795
+ def _stage_ligand_parameters(
796
+ *,
797
+ manifest: ManifestConfig,
798
+ ligand_result: "LigandStageResult",
799
+ work_dir: Path,
800
+ ) -> tuple[list[Path], list[Path], int]:
801
+ by_id = {item.ligand_id: item for item in ligand_result.ligands}
802
+ families = {ligand.atom_types for ligand in manifest.ligands}
803
+ if len(families) > 1:
804
+ raise McpbWorkflowError(
805
+ "MCPB.py accepts one GAFF family per run; bonded metal preparation cannot mix GAFF and GAFF2."
806
+ )
807
+ gaff = 0 if not families else (1 if next(iter(families)) == "gaff" else 2)
808
+ mol2_paths: list[Path] = []
809
+ frcmod_paths: list[Path] = []
810
+ seen_resnames: set[str] = set()
811
+ for ligand in manifest.ligands:
812
+ item = by_id.get(ligand.id)
813
+ if item is None or item.final_mol2_path is None or item.final_frcmod_path is None:
814
+ raise McpbWorkflowError(f"Ligand {ligand.id!r} has no final parameters for MCPB.py.")
815
+ resname = ligand.selector.resname
816
+ if resname not in seen_resnames:
817
+ mol2_target = work_dir / f"{resname}.mol2"
818
+ shutil.copyfile(item.final_mol2_path, mol2_target)
819
+ mol2_paths.append(mol2_target)
820
+ seen_resnames.add(resname)
821
+ frcmod_target = work_dir / f"ligand_{_safe_group_name(ligand.id)}.frcmod"
822
+ shutil.copyfile(item.final_frcmod_path, frcmod_target)
823
+ frcmod_paths.append(frcmod_target)
824
+ return mol2_paths, frcmod_paths, gaff
825
+
826
+
827
+ def _stage_qm_artifacts(config: object, *, group_name: str, work_dir: Path) -> None:
828
+ artifacts = config.artifacts # type: ignore[attr-defined]
829
+ assert artifacts is not None
830
+ copies = [(artifacts.large_mk_log, work_dir / f"{group_name}_large_mk.log")]
831
+ if artifacts.small_fc_log:
832
+ copies.append((artifacts.small_fc_log, work_dir / f"{group_name}_small_fc.log"))
833
+ if artifacts.small_opt_fchk:
834
+ copies.append((artifacts.small_opt_fchk, work_dir / f"{group_name}_small_opt.fchk"))
835
+ for source, target in copies:
836
+ source_path = Path(source)
837
+ if not source_path.is_file() or source_path.stat().st_size == 0:
838
+ raise McpbWorkflowError(f"Configured MCPB.py QM artifact is missing or empty: {source_path}")
839
+ if source_path.resolve() != target.resolve():
840
+ shutil.copyfile(source_path, target)
841
+
842
+
843
+ def _expected_qm_artifacts(
844
+ group_name: str,
845
+ method: str,
846
+ software_version: str,
847
+ ) -> tuple[str, ...]:
848
+ names = [f"{group_name}_large_mk.log"]
849
+ if method == "z_matrix" or (
850
+ method in {"seminario", "modified_seminario"} and software_version == "gms"
851
+ ):
852
+ names.append(f"{group_name}_small_fc.log")
853
+ if method in {"seminario", "modified_seminario"} and software_version != "gms":
854
+ names.append(f"{group_name}_small_opt.fchk")
855
+ return tuple(names)
856
+
857
+
858
+ def _required_qm_inputs(
859
+ work_dir: Path,
860
+ *,
861
+ group_name: str,
862
+ software_version: str,
863
+ ) -> tuple[Path, Path, Path]:
864
+ suffix = ".inp" if software_version == "gms" else ".com"
865
+ return (
866
+ work_dir / f"{group_name}_small_opt{suffix}",
867
+ work_dir / f"{group_name}_small_fc{suffix}",
868
+ work_dir / f"{group_name}_large_mk{suffix}",
869
+ )
870
+
871
+
872
+ def _validate_nonstandard_site_residues(
873
+ site: ResolvedMetalSite,
874
+ manifest: ManifestConfig,
875
+ ) -> None:
876
+ """Require parameters for every non-protein/non-water MCPB site residue."""
877
+
878
+ ligand_keys = {
879
+ (ligand.selector.chain, ligand.selector.resid, ligand.selector.icode)
880
+ for ligand in manifest.ligands
881
+ }
882
+ site_residues = {
883
+ (
884
+ bond.coordinator_residue.id.chain_id,
885
+ bond.coordinator_residue.id.resid,
886
+ bond.coordinator_residue.id.icode,
887
+ ): bond.coordinator_residue
888
+ for bond in site.bonds
889
+ }
890
+ site_residues.update(
891
+ {
892
+ (residue.id.chain_id, residue.id.resid, residue.id.icode): residue
893
+ for residue in site.additional_residues
894
+ }
895
+ )
896
+ missing = [
897
+ residue.id.display()
898
+ for key, residue in site_residues.items()
899
+ if not is_standard_protein_residue(residue)
900
+ and not is_water_residue(residue)
901
+ and key not in ligand_keys
902
+ ]
903
+ if missing:
904
+ raise McpbWorkflowError(
905
+ "Every non-protein/non-water residue in an MCPB.py site must be configured and "
906
+ "parameterized under ligands; missing: " + ", ".join(sorted(missing))
907
+ )
908
+
909
+
910
+ def _validate_mcpb_structure_scope(structure: PdbStructure) -> None:
911
+ protein_chains = {
912
+ residue.id.chain_id
913
+ for residue in structure.residues
914
+ if is_standard_protein_residue(residue)
915
+ }
916
+ if len(protein_chains) > 1:
917
+ raise McpbWorkflowError(
918
+ "Bonded MCPB.py preparation currently supports one protein chain because MCPB.py "
919
+ "discards PDB chain IDs while constructing capped QM models. Use the nonbonded model "
920
+ "or prepare a reviewed one-chain MCPB input."
921
+ )
922
+
923
+
924
+ def _safe_group_name(value: str) -> str:
925
+ safe = re.sub(r"[^A-Za-z0-9_]", "_", value).strip("_")
926
+ if not safe:
927
+ safe = "metal_site"
928
+ if safe[0].isdigit():
929
+ safe = "site_" + safe
930
+ return safe
931
+
932
+
933
+ def _generated_files(work_dir: Path) -> list[Path]:
934
+ return sorted((path for path in work_dir.iterdir() if path.is_file()), key=lambda path: path.name)
935
+
936
+
937
+ def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
938
+ grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
939
+ for atom in atoms:
940
+ grouped.setdefault(atom.residue_key, []).append(atom)
941
+ return [
942
+ ResidueRecord(
943
+ id=ResidueId(chain_id=key[0], resname=key[1], resid=key[2], icode=key[3]),
944
+ atoms=residue_atoms,
945
+ record_names={atom.record_name for atom in residue_atoms},
946
+ original_index=index,
947
+ )
948
+ for index, (key, residue_atoms) in enumerate(grouped.items())
949
+ ]