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,14 @@
|
|
|
1
|
+
"""External command helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mdprep.external.discovery import optional_executable_report, which_executable
|
|
6
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"CommandResult",
|
|
10
|
+
"optional_executable_report",
|
|
11
|
+
"run_command",
|
|
12
|
+
"which_executable",
|
|
13
|
+
]
|
|
14
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Optional external executable discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from shutil import which
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def which_executable(name: str) -> str | None:
|
|
9
|
+
return which(name)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def optional_executable_report(names: list[str]) -> dict[str, str | None]:
|
|
13
|
+
return {name: which_executable(name) for name in names}
|
|
14
|
+
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Small subprocess wrapper with reproducibility metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from time import perf_counter
|
|
8
|
+
from typing import Mapping, Sequence
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class CommandResult:
|
|
14
|
+
command: tuple[str, ...]
|
|
15
|
+
cwd: str
|
|
16
|
+
returncode: int
|
|
17
|
+
stdout: str
|
|
18
|
+
stderr: str
|
|
19
|
+
runtime_seconds: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_command(
|
|
23
|
+
command: Sequence[str],
|
|
24
|
+
*,
|
|
25
|
+
cwd: str | Path | None = None,
|
|
26
|
+
env: Mapping[str, str] | None = None,
|
|
27
|
+
timeout: float | None = None,
|
|
28
|
+
check: bool = False,
|
|
29
|
+
) -> CommandResult:
|
|
30
|
+
workdir = Path(cwd) if cwd is not None else Path.cwd()
|
|
31
|
+
started = perf_counter()
|
|
32
|
+
completed = subprocess.run(
|
|
33
|
+
list(command),
|
|
34
|
+
cwd=workdir,
|
|
35
|
+
env=dict(env) if env is not None else None,
|
|
36
|
+
timeout=timeout,
|
|
37
|
+
text=True,
|
|
38
|
+
capture_output=True,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
runtime = perf_counter() - started
|
|
42
|
+
result = CommandResult(
|
|
43
|
+
command=tuple(str(part) for part in command),
|
|
44
|
+
cwd=str(workdir),
|
|
45
|
+
returncode=completed.returncode,
|
|
46
|
+
stdout=completed.stdout,
|
|
47
|
+
stderr=completed.stderr,
|
|
48
|
+
runtime_seconds=runtime,
|
|
49
|
+
)
|
|
50
|
+
if check and result.returncode != 0:
|
|
51
|
+
raise RuntimeError(
|
|
52
|
+
f"Command failed with exit code {result.returncode}: {' '.join(result.command)}"
|
|
53
|
+
)
|
|
54
|
+
return result
|
|
55
|
+
|
mdprep/leap/__init__.py
ADDED
mdprep/leap/builder.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Build and run tleap scripts for final Amber outputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mdprep.config.models import ManifestConfig
|
|
11
|
+
from mdprep.leap.forcefields import ForceFieldError, ForceFieldSources, forcefield_sources
|
|
12
|
+
from mdprep.leap.ions import IonPlan, IonPlanError, amber_inpcrd_box_volume, build_ion_plan
|
|
13
|
+
from mdprep.leap.log_parser import LeapLogError, assert_tleap_success
|
|
14
|
+
from mdprep.leap.residues import (
|
|
15
|
+
DisulfideBondCommand,
|
|
16
|
+
LeapInputResult,
|
|
17
|
+
LeapResidueError,
|
|
18
|
+
LigandParameterFiles,
|
|
19
|
+
append_disulfide_conect_records,
|
|
20
|
+
disulfide_bond_commands,
|
|
21
|
+
prepare_leap_input_pdb,
|
|
22
|
+
validate_ligand_parameter_files,
|
|
23
|
+
validate_tleap_ligand_coordinates,
|
|
24
|
+
)
|
|
25
|
+
from mdprep.leap.runner import TLeapRun, TLeapRunError, run_tleap
|
|
26
|
+
from mdprep.ligands.workflow import LigandStageResult
|
|
27
|
+
from mdprep.metals.workflow import MetalStageResult
|
|
28
|
+
from mdprep.metals.mcpb import McpbRefittedLigand
|
|
29
|
+
from mdprep.metals.c4 import C4PostprocessError, C4PostprocessRun, apply_12_6_4_c4
|
|
30
|
+
from mdprep.protonation.apply import ProtonationResult
|
|
31
|
+
from mdprep.structure.models import PdbStructure
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TLeapBuildError(ValueError):
|
|
35
|
+
"""Raised when final tleap system generation cannot complete safely."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TLeapOutputs:
|
|
40
|
+
prmtop: Path
|
|
41
|
+
inpcrd: Path
|
|
42
|
+
pdb: Path
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, object]:
|
|
45
|
+
return {
|
|
46
|
+
"prmtop": str(self.prmtop),
|
|
47
|
+
"inpcrd": str(self.inpcrd),
|
|
48
|
+
"pdb": str(self.pdb),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class TLeapStageResult:
|
|
54
|
+
forcefields: ForceFieldSources
|
|
55
|
+
leap_input: LeapInputResult
|
|
56
|
+
ligands: list[LigandParameterFiles]
|
|
57
|
+
mcpb_resp_ligands: list[McpbRefittedLigand]
|
|
58
|
+
disulfide_bonds: list[DisulfideBondCommand]
|
|
59
|
+
dry_run: TLeapRun
|
|
60
|
+
dry_outputs: TLeapOutputs
|
|
61
|
+
final_run: TLeapRun | None
|
|
62
|
+
final_outputs: TLeapOutputs
|
|
63
|
+
solvation_enabled: bool
|
|
64
|
+
solvation_box_type: str
|
|
65
|
+
buffer_angstrom: float
|
|
66
|
+
neutralization_requested: bool
|
|
67
|
+
salt_concentration_molar: float
|
|
68
|
+
ion_plan: IonPlan | None
|
|
69
|
+
salt_volume_a3: float | None
|
|
70
|
+
c4_postprocessing: list[C4PostprocessRun]
|
|
71
|
+
warnings: list[str]
|
|
72
|
+
|
|
73
|
+
def to_report_dict(self) -> dict[str, object]:
|
|
74
|
+
return {
|
|
75
|
+
"force_fields_sourced": self.forcefields.all_sources,
|
|
76
|
+
"force_field_warnings": self.forcefields.warnings,
|
|
77
|
+
"water_box": self.forcefields.water_box,
|
|
78
|
+
"ligands": [ligand.to_dict() for ligand in self.ligands],
|
|
79
|
+
"mcpb_resp_ligands": [
|
|
80
|
+
ligand.to_dict() for ligand in self.mcpb_resp_ligands
|
|
81
|
+
],
|
|
82
|
+
"disulfide_bond_commands": [bond.to_dict() for bond in self.disulfide_bonds],
|
|
83
|
+
"leap_input": self.leap_input.to_dict(),
|
|
84
|
+
"dry_tleap": self.dry_run.to_dict(),
|
|
85
|
+
"dry_outputs": self.dry_outputs.to_dict(),
|
|
86
|
+
"dry_system_charge": self.dry_run.summary.total_charge,
|
|
87
|
+
"solvation_enabled": self.solvation_enabled,
|
|
88
|
+
"solvation_box_type": self.solvation_box_type,
|
|
89
|
+
"buffer_angstrom": self.buffer_angstrom,
|
|
90
|
+
"neutralization_requested": self.neutralization_requested,
|
|
91
|
+
"neutralizing_ions_added": (
|
|
92
|
+
self.ion_plan.neutralizing_count if self.ion_plan is not None else 0
|
|
93
|
+
),
|
|
94
|
+
"salt_concentration_molar": self.salt_concentration_molar,
|
|
95
|
+
"salt_ion_pairs_requested": self.ion_plan.salt_pairs if self.ion_plan is not None else 0,
|
|
96
|
+
"salt_volume_a3": self.salt_volume_a3,
|
|
97
|
+
"c4_postprocessing": [run.to_dict() for run in self.c4_postprocessing],
|
|
98
|
+
"final_tleap": self.final_run.to_dict() if self.final_run is not None else None,
|
|
99
|
+
"final_outputs": self.final_outputs.to_dict(),
|
|
100
|
+
"warnings": self.warnings,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def run_tleap_stage(
|
|
105
|
+
*,
|
|
106
|
+
structure: PdbStructure,
|
|
107
|
+
manifest: ManifestConfig,
|
|
108
|
+
output_dir: str | Path,
|
|
109
|
+
protonation_result: ProtonationResult,
|
|
110
|
+
ligand_result: LigandStageResult,
|
|
111
|
+
metal_result: MetalStageResult | None = None,
|
|
112
|
+
) -> TLeapStageResult:
|
|
113
|
+
output = Path(output_dir)
|
|
114
|
+
input_dir = output / "leap" / "input"
|
|
115
|
+
dry_dir = output / "leap" / "dry"
|
|
116
|
+
final_dir = output / "final"
|
|
117
|
+
input_dir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
dry_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
final_dir.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
sources = forcefield_sources(
|
|
123
|
+
protein_forcefield=manifest.protein.forcefield,
|
|
124
|
+
water_model=manifest.protein.water_model,
|
|
125
|
+
ligands=manifest.ligands,
|
|
126
|
+
)
|
|
127
|
+
metal_structure = (
|
|
128
|
+
metal_result.structure
|
|
129
|
+
if metal_result is not None and metal_result.structure is not None
|
|
130
|
+
else structure
|
|
131
|
+
)
|
|
132
|
+
bonded_mcpb_complete = bool(
|
|
133
|
+
metal_result is not None
|
|
134
|
+
and metal_result.mcpb_site is not None
|
|
135
|
+
and metal_result.mcpb_site.complete
|
|
136
|
+
)
|
|
137
|
+
mcpb_resp_ligand_ids = {
|
|
138
|
+
ligand.id
|
|
139
|
+
for ligand in manifest.ligands
|
|
140
|
+
if ligand.charge_method == "mcpb_resp_pyscf"
|
|
141
|
+
}
|
|
142
|
+
refitted_ligands = (
|
|
143
|
+
list(metal_result.mcpb_site.refitted_ligands)
|
|
144
|
+
if bonded_mcpb_complete
|
|
145
|
+
and metal_result is not None
|
|
146
|
+
and metal_result.mcpb_site is not None
|
|
147
|
+
else []
|
|
148
|
+
)
|
|
149
|
+
if {item.ligand_id for item in refitted_ligands} != mcpb_resp_ligand_ids:
|
|
150
|
+
raise LeapResidueError(
|
|
151
|
+
"Final tLEAP build requires one MCPB RESP-fitted mol2 for every "
|
|
152
|
+
"mcpb_resp_pyscf ligand; expected "
|
|
153
|
+
f"{sorted(mcpb_resp_ligand_ids)}, obtained "
|
|
154
|
+
f"{sorted(item.ligand_id for item in refitted_ligands)}."
|
|
155
|
+
)
|
|
156
|
+
leap_input = prepare_leap_input_pdb(
|
|
157
|
+
metal_structure,
|
|
158
|
+
input_dir / "system.leap_input.pdb",
|
|
159
|
+
manifest=None if bonded_mcpb_complete else manifest,
|
|
160
|
+
ligand_result=None if bonded_mcpb_complete else ligand_result,
|
|
161
|
+
# MCPB.py writes bond commands against its own deterministic,
|
|
162
|
+
# one-based residue order. Original PDB residue numbers can be
|
|
163
|
+
# negative, zero, non-contiguous, or insertion-coded, and LEaP
|
|
164
|
+
# otherwise remaps those numbers independently. Keep atom order
|
|
165
|
+
# and coordinates unchanged while making the input numbering
|
|
166
|
+
# match MCPB's command indices exactly.
|
|
167
|
+
renumber_residues_sequentially=bonded_mcpb_complete,
|
|
168
|
+
)
|
|
169
|
+
ligand_files = validate_ligand_parameter_files(
|
|
170
|
+
manifest=manifest,
|
|
171
|
+
structure=structure,
|
|
172
|
+
ligand_result=ligand_result,
|
|
173
|
+
excluded_ligand_ids=mcpb_resp_ligand_ids,
|
|
174
|
+
)
|
|
175
|
+
disulfide_bonds = (
|
|
176
|
+
[]
|
|
177
|
+
if bonded_mcpb_complete
|
|
178
|
+
else disulfide_bond_commands(
|
|
179
|
+
structure=leap_input.structure,
|
|
180
|
+
protonation_result=protonation_result,
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
conect_records = append_disulfide_conect_records(leap_input.path, disulfide_bonds)
|
|
184
|
+
dry_outputs = TLeapOutputs(
|
|
185
|
+
prmtop=dry_dir / "system.dry.prmtop",
|
|
186
|
+
inpcrd=dry_dir / "system.dry.inpcrd",
|
|
187
|
+
pdb=dry_dir / "system.dry.pdb",
|
|
188
|
+
)
|
|
189
|
+
dry_script = build_tleap_script(
|
|
190
|
+
sources=sources,
|
|
191
|
+
ligands=ligand_files,
|
|
192
|
+
input_pdb=leap_input.path,
|
|
193
|
+
disulfide_bonds=disulfide_bonds,
|
|
194
|
+
outputs=dry_outputs,
|
|
195
|
+
work_dir=dry_dir,
|
|
196
|
+
setup_commands=metal_result.leap_setup_commands if metal_result else None,
|
|
197
|
+
extra_bond_commands=metal_result.leap_bond_commands if metal_result else None,
|
|
198
|
+
)
|
|
199
|
+
dry_input = dry_dir / "tleap.in"
|
|
200
|
+
dry_input.write_text(dry_script, encoding="utf-8")
|
|
201
|
+
dry_run = run_tleap(dry_input, work_dir=dry_dir)
|
|
202
|
+
assert_tleap_success(
|
|
203
|
+
dry_run.summary,
|
|
204
|
+
fail_on_warnings=manifest.validation.fail_on_warnings,
|
|
205
|
+
context="dry",
|
|
206
|
+
)
|
|
207
|
+
_ensure_outputs(dry_outputs)
|
|
208
|
+
c4_runs: list[C4PostprocessRun] = []
|
|
209
|
+
if metal_result is not None and metal_result.c4_atom_types:
|
|
210
|
+
c4_runs.append(
|
|
211
|
+
apply_12_6_4_c4(
|
|
212
|
+
dry_outputs.prmtop,
|
|
213
|
+
atom_types=metal_result.c4_atom_types,
|
|
214
|
+
water_model=manifest.protein.water_model,
|
|
215
|
+
work_dir=dry_dir / "c4",
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
dry_coordinate_checks = validate_tleap_ligand_coordinates(
|
|
219
|
+
manifest=manifest,
|
|
220
|
+
reference_structure=structure,
|
|
221
|
+
output_pdb=dry_outputs.pdb,
|
|
222
|
+
stage="dry",
|
|
223
|
+
output_resnames_by_ligand_id={
|
|
224
|
+
item.ligand_id: item.final_resname
|
|
225
|
+
for item in refitted_ligands
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
warnings = list(sources.warnings)
|
|
230
|
+
if conect_records:
|
|
231
|
+
warnings.append(
|
|
232
|
+
"Disulfide SG-SG bonds were encoded as PDB CONECT records in the tleap input PDB."
|
|
233
|
+
)
|
|
234
|
+
warnings.extend(leap_input.structure.warnings)
|
|
235
|
+
warnings.extend(warning for warning in dry_run.summary.warnings)
|
|
236
|
+
warnings.extend(
|
|
237
|
+
f"Ligand {check['ligand_id']} dry tleap coordinate max deviation: "
|
|
238
|
+
f"{check['max_coordinate_deviation_angstrom']:.3f} A"
|
|
239
|
+
for check in dry_coordinate_checks
|
|
240
|
+
)
|
|
241
|
+
ion_plan: IonPlan | None = None
|
|
242
|
+
salt_volume_a3: float | None = None
|
|
243
|
+
final_run: TLeapRun | None = None
|
|
244
|
+
if not manifest.solvation.enabled:
|
|
245
|
+
final_outputs = _copy_outputs(dry_outputs, final_dir)
|
|
246
|
+
else:
|
|
247
|
+
final_outputs, final_run, ion_plan, salt_volume_a3, final_c4 = _run_solvated_build(
|
|
248
|
+
manifest=manifest,
|
|
249
|
+
sources=sources,
|
|
250
|
+
ligands=ligand_files,
|
|
251
|
+
input_pdb=leap_input.path,
|
|
252
|
+
disulfide_bonds=disulfide_bonds,
|
|
253
|
+
dry_charge=dry_run.summary.total_charge,
|
|
254
|
+
output_dir=output,
|
|
255
|
+
metal_result=metal_result,
|
|
256
|
+
)
|
|
257
|
+
if final_c4 is not None:
|
|
258
|
+
c4_runs.append(final_c4)
|
|
259
|
+
warnings.extend(final_run.summary.warnings)
|
|
260
|
+
|
|
261
|
+
except (
|
|
262
|
+
ForceFieldError,
|
|
263
|
+
IonPlanError,
|
|
264
|
+
LeapLogError,
|
|
265
|
+
LeapResidueError,
|
|
266
|
+
TLeapRunError,
|
|
267
|
+
C4PostprocessError,
|
|
268
|
+
FileNotFoundError,
|
|
269
|
+
) as exc:
|
|
270
|
+
raise TLeapBuildError(str(exc)) from exc
|
|
271
|
+
|
|
272
|
+
return TLeapStageResult(
|
|
273
|
+
forcefields=sources,
|
|
274
|
+
leap_input=leap_input,
|
|
275
|
+
ligands=ligand_files,
|
|
276
|
+
mcpb_resp_ligands=refitted_ligands,
|
|
277
|
+
disulfide_bonds=disulfide_bonds,
|
|
278
|
+
dry_run=dry_run,
|
|
279
|
+
dry_outputs=dry_outputs,
|
|
280
|
+
final_run=final_run,
|
|
281
|
+
final_outputs=final_outputs,
|
|
282
|
+
solvation_enabled=manifest.solvation.enabled,
|
|
283
|
+
solvation_box_type=manifest.solvation.box,
|
|
284
|
+
buffer_angstrom=manifest.solvation.buffer_angstrom,
|
|
285
|
+
neutralization_requested=manifest.solvation.neutralize,
|
|
286
|
+
salt_concentration_molar=manifest.solvation.salt_concentration_molar,
|
|
287
|
+
ion_plan=ion_plan,
|
|
288
|
+
salt_volume_a3=salt_volume_a3,
|
|
289
|
+
c4_postprocessing=c4_runs,
|
|
290
|
+
warnings=warnings,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def build_tleap_script(
|
|
295
|
+
*,
|
|
296
|
+
sources: ForceFieldSources,
|
|
297
|
+
ligands: list[LigandParameterFiles],
|
|
298
|
+
input_pdb: str | Path,
|
|
299
|
+
disulfide_bonds: list[DisulfideBondCommand],
|
|
300
|
+
outputs: TLeapOutputs,
|
|
301
|
+
work_dir: str | Path | None = None,
|
|
302
|
+
solvation_command: str | None = None,
|
|
303
|
+
ion_commands: list[str] | None = None,
|
|
304
|
+
setup_commands: list[str] | None = None,
|
|
305
|
+
extra_bond_commands: list[str] | None = None,
|
|
306
|
+
) -> str:
|
|
307
|
+
lines: list[str] = []
|
|
308
|
+
for source in sources.all_sources:
|
|
309
|
+
lines.append(f"source {source}")
|
|
310
|
+
for ligand in ligands:
|
|
311
|
+
mol2_path = _tleap_path(ligand.final_mol2_path, work_dir)
|
|
312
|
+
frcmod_path = _tleap_path(ligand.final_frcmod_path, work_dir)
|
|
313
|
+
lines.append(f"{ligand.variable_name} = loadmol2 {mol2_path}")
|
|
314
|
+
if ligand.variable_name != ligand.residue_name:
|
|
315
|
+
lines.append(f"{ligand.residue_name} = {ligand.variable_name}")
|
|
316
|
+
lines.append(f"loadamberparams {frcmod_path}")
|
|
317
|
+
lines.extend(setup_commands or [])
|
|
318
|
+
lines.append(f"system = loadpdb {_tleap_path(input_pdb, work_dir)}")
|
|
319
|
+
lines.extend(bond.command for bond in disulfide_bonds if bond.command.startswith("bond "))
|
|
320
|
+
lines.extend(command for command in (extra_bond_commands or []) if command.startswith("bond "))
|
|
321
|
+
if solvation_command is not None:
|
|
322
|
+
lines.append(solvation_command)
|
|
323
|
+
lines.extend(ion_commands or [])
|
|
324
|
+
lines.extend(
|
|
325
|
+
[
|
|
326
|
+
"check system",
|
|
327
|
+
"charge system",
|
|
328
|
+
f"savepdb system {outputs.pdb.name}",
|
|
329
|
+
f"saveamberparm system {outputs.prmtop.name} {outputs.inpcrd.name}",
|
|
330
|
+
"quit",
|
|
331
|
+
"",
|
|
332
|
+
]
|
|
333
|
+
)
|
|
334
|
+
return "\n".join(lines)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _tleap_path(path: str | Path, work_dir: str | Path | None) -> str:
|
|
338
|
+
"""Return a path that tleap can resolve from its execution directory."""
|
|
339
|
+
path_obj = Path(path)
|
|
340
|
+
if work_dir is None:
|
|
341
|
+
return str(path_obj)
|
|
342
|
+
base = Path(work_dir)
|
|
343
|
+
base_abs = base if base.is_absolute() else Path.cwd() / base
|
|
344
|
+
target_abs = path_obj if path_obj.is_absolute() else Path.cwd() / path_obj
|
|
345
|
+
return os.path.relpath(target_abs.resolve(), base_abs.resolve())
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def solvation_command(*, box: str, water_box: str, buffer_angstrom: float) -> str:
|
|
349
|
+
if box == "truncated_octahedron":
|
|
350
|
+
return f"solvateOct system {water_box} {buffer_angstrom:.3f}"
|
|
351
|
+
if box == "rectangular":
|
|
352
|
+
return f"solvateBox system {water_box} {buffer_angstrom:.3f}"
|
|
353
|
+
raise TLeapBuildError(f"Unsupported solvation box: {box}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _run_solvated_build(
|
|
357
|
+
*,
|
|
358
|
+
manifest: ManifestConfig,
|
|
359
|
+
sources: ForceFieldSources,
|
|
360
|
+
ligands: list[LigandParameterFiles],
|
|
361
|
+
input_pdb: Path,
|
|
362
|
+
disulfide_bonds: list[DisulfideBondCommand],
|
|
363
|
+
dry_charge: float | None,
|
|
364
|
+
output_dir: Path,
|
|
365
|
+
metal_result: MetalStageResult | None,
|
|
366
|
+
) -> tuple[TLeapOutputs, TLeapRun, IonPlan | None, float | None, C4PostprocessRun | None]:
|
|
367
|
+
if dry_charge is None and manifest.solvation.neutralize:
|
|
368
|
+
raise TLeapBuildError("tleap dry-system charge was not parsed; cannot neutralize safely.")
|
|
369
|
+
solv_dir = output_dir / "leap" / "solvated"
|
|
370
|
+
solv_dir.mkdir(parents=True, exist_ok=True)
|
|
371
|
+
final_outputs = TLeapOutputs(
|
|
372
|
+
prmtop=solv_dir / "system.solvated.prmtop",
|
|
373
|
+
inpcrd=solv_dir / "system.solvated.inpcrd",
|
|
374
|
+
pdb=solv_dir / "system.solvated.pdb",
|
|
375
|
+
)
|
|
376
|
+
solvate = solvation_command(
|
|
377
|
+
box=manifest.solvation.box,
|
|
378
|
+
water_box=sources.water_box,
|
|
379
|
+
buffer_angstrom=manifest.solvation.buffer_angstrom,
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
volume_a3: float | None = None
|
|
383
|
+
if manifest.solvation.salt_concentration_molar > 0:
|
|
384
|
+
pre_outputs = TLeapOutputs(
|
|
385
|
+
prmtop=solv_dir / "system.presalt.prmtop",
|
|
386
|
+
inpcrd=solv_dir / "system.presalt.inpcrd",
|
|
387
|
+
pdb=solv_dir / "system.presalt.pdb",
|
|
388
|
+
)
|
|
389
|
+
pre_input = solv_dir / "tleap.presalt.in"
|
|
390
|
+
pre_input.write_text(
|
|
391
|
+
build_tleap_script(
|
|
392
|
+
sources=sources,
|
|
393
|
+
ligands=ligands,
|
|
394
|
+
input_pdb=input_pdb,
|
|
395
|
+
disulfide_bonds=disulfide_bonds,
|
|
396
|
+
outputs=pre_outputs,
|
|
397
|
+
work_dir=solv_dir,
|
|
398
|
+
solvation_command=solvate,
|
|
399
|
+
setup_commands=metal_result.leap_setup_commands if metal_result else None,
|
|
400
|
+
extra_bond_commands=metal_result.leap_bond_commands if metal_result else None,
|
|
401
|
+
),
|
|
402
|
+
encoding="utf-8",
|
|
403
|
+
)
|
|
404
|
+
pre_run = run_tleap(pre_input, work_dir=solv_dir)
|
|
405
|
+
assert_tleap_success(
|
|
406
|
+
pre_run.summary,
|
|
407
|
+
fail_on_warnings=manifest.validation.fail_on_warnings,
|
|
408
|
+
context="pre-salt solvated",
|
|
409
|
+
)
|
|
410
|
+
_ensure_outputs(pre_outputs)
|
|
411
|
+
volume_a3 = amber_inpcrd_box_volume(str(pre_outputs.inpcrd))
|
|
412
|
+
if volume_a3 is None:
|
|
413
|
+
raise TLeapBuildError(
|
|
414
|
+
"Could not determine solvated box volume; cannot add configured salt concentration."
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
ion_plan = build_ion_plan(
|
|
418
|
+
total_charge=dry_charge or 0.0,
|
|
419
|
+
neutralize=manifest.solvation.neutralize,
|
|
420
|
+
positive_ion=manifest.solvation.positive_ion,
|
|
421
|
+
negative_ion=manifest.solvation.negative_ion,
|
|
422
|
+
salt_concentration_molar=manifest.solvation.salt_concentration_molar,
|
|
423
|
+
volume_a3=volume_a3,
|
|
424
|
+
)
|
|
425
|
+
script = build_tleap_script(
|
|
426
|
+
sources=sources,
|
|
427
|
+
ligands=ligands,
|
|
428
|
+
input_pdb=input_pdb,
|
|
429
|
+
disulfide_bonds=disulfide_bonds,
|
|
430
|
+
outputs=final_outputs,
|
|
431
|
+
work_dir=solv_dir,
|
|
432
|
+
solvation_command=solvate,
|
|
433
|
+
ion_commands=ion_plan.commands,
|
|
434
|
+
setup_commands=metal_result.leap_setup_commands if metal_result else None,
|
|
435
|
+
extra_bond_commands=metal_result.leap_bond_commands if metal_result else None,
|
|
436
|
+
)
|
|
437
|
+
script_path = solv_dir / "tleap.in"
|
|
438
|
+
script_path.write_text(script, encoding="utf-8")
|
|
439
|
+
final_run = run_tleap(script_path, work_dir=solv_dir)
|
|
440
|
+
assert_tleap_success(
|
|
441
|
+
final_run.summary,
|
|
442
|
+
fail_on_warnings=manifest.validation.fail_on_warnings,
|
|
443
|
+
context="solvated",
|
|
444
|
+
)
|
|
445
|
+
_ensure_outputs(final_outputs)
|
|
446
|
+
c4_run: C4PostprocessRun | None = None
|
|
447
|
+
if metal_result is not None and metal_result.c4_atom_types:
|
|
448
|
+
c4_run = apply_12_6_4_c4(
|
|
449
|
+
final_outputs.prmtop,
|
|
450
|
+
atom_types=metal_result.c4_atom_types,
|
|
451
|
+
water_model=manifest.protein.water_model,
|
|
452
|
+
work_dir=solv_dir / "c4",
|
|
453
|
+
)
|
|
454
|
+
copied = _copy_outputs(final_outputs, output_dir / "final")
|
|
455
|
+
return copied, final_run, ion_plan, volume_a3, c4_run
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _copy_outputs(outputs: TLeapOutputs, final_dir: Path) -> TLeapOutputs:
|
|
459
|
+
final_dir.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
copied = TLeapOutputs(
|
|
461
|
+
prmtop=final_dir / "system.prmtop",
|
|
462
|
+
inpcrd=final_dir / "system.inpcrd",
|
|
463
|
+
pdb=final_dir / "system.pdb",
|
|
464
|
+
)
|
|
465
|
+
shutil.copyfile(outputs.prmtop, copied.prmtop)
|
|
466
|
+
shutil.copyfile(outputs.inpcrd, copied.inpcrd)
|
|
467
|
+
shutil.copyfile(outputs.pdb, copied.pdb)
|
|
468
|
+
return copied
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _ensure_outputs(outputs: TLeapOutputs) -> None:
|
|
472
|
+
for path in [outputs.prmtop, outputs.inpcrd, outputs.pdb]:
|
|
473
|
+
if not path.exists():
|
|
474
|
+
raise FileNotFoundError(f"tleap did not produce expected output: {path}")
|
|
475
|
+
if path.stat().st_size == 0:
|
|
476
|
+
raise TLeapBuildError(f"tleap produced an empty output file: {path}")
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Amber force-field mapping helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from mdprep.config.models import LigandConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ForceFieldError(ValueError):
|
|
11
|
+
"""Raised when a manifest requests an unsupported force-field setting."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
PROTEIN_LEAPRC = {
|
|
15
|
+
"ff14SB": "leaprc.protein.ff14SB",
|
|
16
|
+
"ff19SB": "leaprc.protein.ff19SB",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
WATER_LEAPRC = {
|
|
20
|
+
"TIP3P": "leaprc.water.tip3p",
|
|
21
|
+
"OPC": "leaprc.water.opc",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
WATER_BOX = {
|
|
25
|
+
"TIP3P": "TIP3PBOX",
|
|
26
|
+
"OPC": "OPCBOX",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
LIGAND_LEAPRC = {
|
|
30
|
+
"gaff": "leaprc.gaff",
|
|
31
|
+
"gaff2": "leaprc.gaff2",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ForceFieldSources:
|
|
37
|
+
protein: str
|
|
38
|
+
water: str
|
|
39
|
+
ligand: list[str]
|
|
40
|
+
water_box: str
|
|
41
|
+
warnings: list[str]
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def all_sources(self) -> list[str]:
|
|
45
|
+
return [self.protein, self.water, *self.ligand]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def protein_leaprc(name: str) -> str:
|
|
49
|
+
try:
|
|
50
|
+
return PROTEIN_LEAPRC[name]
|
|
51
|
+
except KeyError as exc:
|
|
52
|
+
raise ForceFieldError(f"Unsupported protein force field: {name}") from exc
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def water_leaprc(name: str) -> str:
|
|
56
|
+
try:
|
|
57
|
+
return WATER_LEAPRC[name]
|
|
58
|
+
except KeyError as exc:
|
|
59
|
+
raise ForceFieldError(f"Unsupported water model: {name}") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def water_box(name: str) -> str:
|
|
63
|
+
try:
|
|
64
|
+
return WATER_BOX[name]
|
|
65
|
+
except KeyError as exc:
|
|
66
|
+
raise ForceFieldError(f"Unsupported water model: {name}") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ligand_leaprc(name: str) -> str:
|
|
70
|
+
try:
|
|
71
|
+
return LIGAND_LEAPRC[name]
|
|
72
|
+
except KeyError as exc:
|
|
73
|
+
raise ForceFieldError(f"Unsupported ligand atom type family: {name}") from exc
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def forcefield_sources(
|
|
77
|
+
*,
|
|
78
|
+
protein_forcefield: str,
|
|
79
|
+
water_model: str,
|
|
80
|
+
ligands: list[LigandConfig],
|
|
81
|
+
) -> ForceFieldSources:
|
|
82
|
+
ligand_families = sorted({ligand.atom_types for ligand in ligands})
|
|
83
|
+
ligand_sources = [ligand_leaprc(family) for family in ligand_families]
|
|
84
|
+
warnings: list[str] = []
|
|
85
|
+
if len(ligand_families) > 1:
|
|
86
|
+
warnings.append("Both GAFF and GAFF2 ligand force fields are sourced.")
|
|
87
|
+
return ForceFieldSources(
|
|
88
|
+
protein=protein_leaprc(protein_forcefield),
|
|
89
|
+
water=water_leaprc(water_model),
|
|
90
|
+
ligand=ligand_sources,
|
|
91
|
+
water_box=water_box(water_model),
|
|
92
|
+
warnings=warnings,
|
|
93
|
+
)
|