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