mdprep 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+
@@ -0,0 +1,2 @@
1
+ """tleap system-build helpers."""
2
+
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
+ )