mdprep 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|