mdprep 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. mdprep/__init__.py +7 -0
  2. mdprep/_selftest_data/basic_protein.yaml +33 -0
  3. mdprep/_selftest_data/ligand_sub.mol2 +11 -0
  4. mdprep/ambertools/__init__.py +2 -0
  5. mdprep/ambertools/antechamber.py +110 -0
  6. mdprep/ambertools/commands.py +33 -0
  7. mdprep/ambertools/mol2.py +415 -0
  8. mdprep/ambertools/parmchk2.py +89 -0
  9. mdprep/ambertools/resp.py +570 -0
  10. mdprep/charges/__init__.py +1 -0
  11. mdprep/charges/esp_grid.py +190 -0
  12. mdprep/cli.py +451 -0
  13. mdprep/config/__init__.py +9 -0
  14. mdprep/config/loader.py +146 -0
  15. mdprep/config/models.py +1100 -0
  16. mdprep/external/__init__.py +14 -0
  17. mdprep/external/discovery.py +14 -0
  18. mdprep/external/runner.py +55 -0
  19. mdprep/leap/__init__.py +2 -0
  20. mdprep/leap/builder.py +476 -0
  21. mdprep/leap/forcefields.py +93 -0
  22. mdprep/leap/ions.py +112 -0
  23. mdprep/leap/log_parser.py +170 -0
  24. mdprep/leap/report.py +99 -0
  25. mdprep/leap/residues.py +653 -0
  26. mdprep/leap/runner.py +65 -0
  27. mdprep/ligands/__init__.py +2 -0
  28. mdprep/ligands/extract.py +355 -0
  29. mdprep/ligands/identity.py +39 -0
  30. mdprep/ligands/pyscf_charges.py +242 -0
  31. mdprep/ligands/report.py +146 -0
  32. mdprep/ligands/workflow.py +838 -0
  33. mdprep/md/__init__.py +5 -0
  34. mdprep/md/roe_brooks.py +650 -0
  35. mdprep/metals/__init__.py +2 -0
  36. mdprep/metals/c4.py +157 -0
  37. mdprep/metals/coordination.py +313 -0
  38. mdprep/metals/gxtb_hessian.py +467 -0
  39. mdprep/metals/hydrogenate.py +344 -0
  40. mdprep/metals/mace_polar1_hessian.py +357 -0
  41. mdprep/metals/mace_polar1_worker.py +295 -0
  42. mdprep/metals/mcpb.py +949 -0
  43. mdprep/metals/mcpb_compat.py +125 -0
  44. mdprep/metals/nonbonded.py +294 -0
  45. mdprep/metals/parameter_comparison.py +563 -0
  46. mdprep/metals/pyscf_mcpb.py +1388 -0
  47. mdprep/metals/report.py +119 -0
  48. mdprep/metals/workflow.py +320 -0
  49. mdprep/protonation/__init__.py +15 -0
  50. mdprep/protonation/apply.py +968 -0
  51. mdprep/protonation/disulfide_states.py +141 -0
  52. mdprep/protonation/histidine_geometry.py +950 -0
  53. mdprep/protonation/histidine_xtb.py +390 -0
  54. mdprep/protonation/overrides.py +64 -0
  55. mdprep/protonation/pka_rules.py +165 -0
  56. mdprep/protonation/propka.py +174 -0
  57. mdprep/protonation/propka_parser.py +140 -0
  58. mdprep/protonation/report.py +187 -0
  59. mdprep/protonation/states.py +52 -0
  60. mdprep/protonation/temporary_hydrogenation.py +238 -0
  61. mdprep/protonation/xtb_parser.py +79 -0
  62. mdprep/protonation/xtb_runner.py +108 -0
  63. mdprep/qm/__init__.py +2 -0
  64. mdprep/qm/point_charges.py +233 -0
  65. mdprep/qm/pyscf_esp.py +70 -0
  66. mdprep/qm/pyscf_runner.py +570 -0
  67. mdprep/refinement/__init__.py +5 -0
  68. mdprep/refinement/ash.py +541 -0
  69. mdprep/refinement/coordinates.py +210 -0
  70. mdprep/refinement/mapping.py +163 -0
  71. mdprep/refinement/report.py +66 -0
  72. mdprep/refinement/selection.py +436 -0
  73. mdprep/refinement/workflow.py +188 -0
  74. mdprep/reports/__init__.py +8 -0
  75. mdprep/reports/structure_report.py +92 -0
  76. mdprep/structure/__init__.py +14 -0
  77. mdprep/structure/classify.py +92 -0
  78. mdprep/structure/disulfides.py +51 -0
  79. mdprep/structure/inspect.py +105 -0
  80. mdprep/structure/models.py +86 -0
  81. mdprep/structure/normalize.py +363 -0
  82. mdprep/structure/pdb.py +511 -0
  83. mdprep/structure/repair.py +566 -0
  84. mdprep/structure/selectors.py +204 -0
  85. mdprep/structure/writer.py +84 -0
  86. mdprep/validation/__init__.py +2 -0
  87. mdprep/validation/openmm_check.py +86 -0
  88. mdprep/validation/parmed_check.py +43 -0
  89. mdprep/validation/topology.py +239 -0
  90. mdprep/workflows/__init__.py +6 -0
  91. mdprep/workflows/init.py +242 -0
  92. mdprep/workflows/inspect.py +21 -0
  93. mdprep/workflows/prepare.py +763 -0
  94. mdprep/workflows/selftest.py +220 -0
  95. mdprep/workflows/validate.py +30 -0
  96. mdprep-0.2.0.dist-info/METADATA +264 -0
  97. mdprep-0.2.0.dist-info/RECORD +100 -0
  98. mdprep-0.2.0.dist-info/WHEEL +4 -0
  99. mdprep-0.2.0.dist-info/entry_points.txt +2 -0
  100. mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
mdprep/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """mdprep package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
6
+
7
+ __all__ = ["__version__"]
@@ -0,0 +1,33 @@
1
+ project:
2
+ name: selftest
3
+ input_structure: input.pdb
4
+ output_dir: prepared/selftest
5
+
6
+ structure:
7
+ keep_crystal_waters: true
8
+ remove_unknown_heterogens: false
9
+
10
+ protein:
11
+ forcefield: ff14SB
12
+ water_model: TIP3P
13
+
14
+ protonation:
15
+ method: manual_only
16
+
17
+ disulfides:
18
+ auto_detect: true
19
+ detection_cutoff_angstrom: 2.2
20
+ force: []
21
+ forbid: []
22
+
23
+ ligands: []
24
+ metals: []
25
+
26
+ solvation:
27
+ enabled: true
28
+
29
+ validation:
30
+ fail_on_missing_parameters: true
31
+
32
+ molecular_dynamics:
33
+ enabled: false
@@ -0,0 +1,11 @@
1
+ @<TRIPOS>MOLECULE
2
+ SUB
3
+ 2 1 0 0 0
4
+ SMALL
5
+ USER_CHARGES
6
+
7
+ @<TRIPOS>ATOM
8
+ 1 C1 5.0000 5.0000 5.0000 c3 1 SUB 0.100000
9
+ 2 O1 6.0000 5.0000 5.0000 o 1 SUB -0.100000
10
+ @<TRIPOS>BOND
11
+ 1 1 2 1
@@ -0,0 +1,2 @@
1
+ """AmberTools command wrappers."""
2
+
@@ -0,0 +1,110 @@
1
+ """Antechamber wrapper for ligand AM1-BCC parameterization."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from mdprep.ambertools.commands import AmberToolRun, AmberToolsError
8
+ from mdprep.config.models import LigandConfig
9
+ from mdprep.external.discovery import which_executable
10
+ from mdprep.external.runner import run_command
11
+
12
+
13
+ def build_antechamber_command(
14
+ *,
15
+ executable: str,
16
+ input_pdb: str | Path,
17
+ output_mol2: str | Path,
18
+ residue_name: str,
19
+ ligand: LigandConfig,
20
+ ) -> list[str]:
21
+ return [
22
+ executable,
23
+ "-i",
24
+ str(input_pdb),
25
+ "-fi",
26
+ "pdb",
27
+ "-o",
28
+ str(output_mol2),
29
+ "-fo",
30
+ "mol2",
31
+ "-rn",
32
+ residue_name,
33
+ "-nc",
34
+ str(ligand.net_charge),
35
+ "-m",
36
+ str(ligand.multiplicity),
37
+ "-c",
38
+ "bcc",
39
+ "-at",
40
+ ligand.atom_types,
41
+ "-an",
42
+ "n",
43
+ "-du",
44
+ "n",
45
+ "-seq",
46
+ "n",
47
+ "-s",
48
+ "2",
49
+ ]
50
+
51
+
52
+ def run_antechamber(
53
+ *,
54
+ ligand: LigandConfig,
55
+ input_pdb: str | Path,
56
+ output_mol2: str | Path,
57
+ residue_name: str,
58
+ work_dir: str | Path,
59
+ executable: str = "antechamber",
60
+ ) -> AmberToolRun:
61
+ exe = _resolve_executable(executable)
62
+ work = Path(work_dir)
63
+ stdout_path = work / "antechamber_stdout.txt"
64
+ stderr_path = work / "antechamber_stderr.txt"
65
+ input_path = Path(input_pdb).resolve()
66
+ output = Path(output_mol2).resolve()
67
+ command = build_antechamber_command(
68
+ executable=exe,
69
+ input_pdb=input_path,
70
+ output_mol2=output,
71
+ residue_name=residue_name,
72
+ ligand=ligand,
73
+ )
74
+ result = run_command(command, cwd=work)
75
+ stdout_path.write_text(result.stdout, encoding="utf-8")
76
+ stderr_path.write_text(result.stderr, encoding="utf-8")
77
+ if result.returncode != 0:
78
+ raise AmberToolsError(
79
+ "\n".join(
80
+ [
81
+ f"antechamber failed with exit code {result.returncode}.",
82
+ f"Command: {' '.join(result.command)}",
83
+ f"See {stdout_path} and {stderr_path}.",
84
+ f"stdout tail:\n{_tail(result.stdout)}",
85
+ f"stderr tail:\n{_tail(result.stderr)}",
86
+ ]
87
+ )
88
+ )
89
+ if not output.exists():
90
+ raise AmberToolsError(f"antechamber did not produce expected mol2 file: {output}")
91
+ return AmberToolRun(
92
+ command_result=result,
93
+ stdout_path=stdout_path,
94
+ stderr_path=stderr_path,
95
+ output_path=output,
96
+ )
97
+
98
+
99
+ def _resolve_executable(name: str) -> str:
100
+ found = which_executable(name)
101
+ if found:
102
+ return found
103
+ raise AmberToolsError(f"AmberTools executable not found: {name}")
104
+
105
+
106
+ def _tail(text: str, *, lines: int = 20) -> str:
107
+ stripped = text.strip()
108
+ if not stripped:
109
+ return "<empty>"
110
+ return "\n".join(stripped.splitlines()[-lines:])
@@ -0,0 +1,33 @@
1
+ """Shared AmberTools command result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from mdprep.external.runner import CommandResult
9
+
10
+
11
+ class AmberToolsError(RuntimeError):
12
+ """Raised when an AmberTools command is unavailable or fails."""
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class AmberToolRun:
17
+ command_result: CommandResult
18
+ stdout_path: Path
19
+ stderr_path: Path
20
+ output_path: Path
21
+
22
+ def to_dict(self) -> dict[str, object]:
23
+ return {
24
+ "command": list(self.command_result.command),
25
+ "cwd": self.command_result.cwd,
26
+ "returncode": self.command_result.returncode,
27
+ "stdout": self.command_result.stdout,
28
+ "stderr": self.command_result.stderr,
29
+ "runtime_seconds": self.command_result.runtime_seconds,
30
+ "stdout_path": str(self.stdout_path),
31
+ "stderr_path": str(self.stderr_path),
32
+ "output_path": str(self.output_path),
33
+ }
@@ -0,0 +1,415 @@
1
+ """Focused Tripos mol2 parser, writer, and ligand validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from dataclasses import dataclass, replace
8
+ from math import dist
9
+ from pathlib import Path
10
+
11
+ from mdprep.config.models import LigandConfig
12
+ from mdprep.structure.models import AtomRecord
13
+ from mdprep.structure.pdb import PERIODIC_ELEMENTS, infer_element
14
+
15
+
16
+ class Mol2Error(ValueError):
17
+ """Raised when a mol2 file cannot be parsed or validated safely."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Mol2Atom:
22
+ atom_id: int
23
+ name: str
24
+ x: float
25
+ y: float
26
+ z: float
27
+ atom_type: str
28
+ subst_id: int
29
+ subst_name: str
30
+ charge: float
31
+
32
+
33
+ @dataclass
34
+ class Mol2File:
35
+ path: Path
36
+ lines: list[str]
37
+ molecule_name: str | None
38
+ atoms: list[Mol2Atom]
39
+ atom_line_indices: list[int]
40
+
41
+ @property
42
+ def total_charge(self) -> float:
43
+ return sum(atom.charge for atom in self.atoms)
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class Mol2ValidationResult:
48
+ final_mol2_path: Path
49
+ charges_csv_path: Path
50
+ validation_json_path: Path
51
+ charge_sum_before_correction: float
52
+ charge_correction_applied: float
53
+ charge_sum_final: float
54
+ coordinate_max_deviation: float
55
+ atom_names_preserved: bool
56
+ warnings: list[str]
57
+
58
+ def to_dict(self) -> dict[str, object]:
59
+ return {
60
+ "final_mol2_path": str(self.final_mol2_path),
61
+ "charges_csv_path": str(self.charges_csv_path),
62
+ "validation_json_path": str(self.validation_json_path),
63
+ "charge_sum_before_correction": self.charge_sum_before_correction,
64
+ "charge_correction_applied": self.charge_correction_applied,
65
+ "charge_sum_final": self.charge_sum_final,
66
+ "coordinate_max_deviation": self.coordinate_max_deviation,
67
+ "atom_names_preserved": self.atom_names_preserved,
68
+ "warnings": self.warnings,
69
+ }
70
+
71
+
72
+ def read_mol2(path: str | Path) -> Mol2File:
73
+ mol2_path = Path(path)
74
+ lines = mol2_path.read_text(encoding="utf-8").splitlines()
75
+ atom_start = _marker_index(lines, "@<TRIPOS>ATOM")
76
+ if atom_start is None:
77
+ raise Mol2Error(f"mol2 file is missing @<TRIPOS>ATOM section: {mol2_path}")
78
+ atom_end = next(
79
+ (index for index in range(atom_start + 1, len(lines)) if lines[index].startswith("@<TRIPOS>")),
80
+ len(lines),
81
+ )
82
+ atoms: list[Mol2Atom] = []
83
+ atom_line_indices: list[int] = []
84
+ for index in range(atom_start + 1, atom_end):
85
+ line = lines[index]
86
+ if not line.strip():
87
+ continue
88
+ atoms.append(_parse_atom_line(line, mol2_path))
89
+ atom_line_indices.append(index)
90
+ if not atoms:
91
+ raise Mol2Error(f"mol2 file has no atom records: {mol2_path}")
92
+ molecule_name = _molecule_name(lines)
93
+ return Mol2File(
94
+ path=mol2_path,
95
+ lines=lines,
96
+ molecule_name=molecule_name,
97
+ atoms=atoms,
98
+ atom_line_indices=atom_line_indices,
99
+ )
100
+
101
+
102
+ def write_mol2(mol2: Mol2File, path: str | Path) -> None:
103
+ output = Path(path)
104
+ output.parent.mkdir(parents=True, exist_ok=True)
105
+ lines = list(mol2.lines)
106
+ for atom, line_index in zip(mol2.atoms, mol2.atom_line_indices, strict=True):
107
+ lines[line_index] = _format_atom(atom)
108
+ output.write_text("\n".join(lines) + "\n", encoding="utf-8")
109
+
110
+
111
+ def write_mol2_with_charges(
112
+ mol2_path: str | Path,
113
+ charges: list[float],
114
+ output_path: str | Path,
115
+ ) -> None:
116
+ mol2 = read_mol2(mol2_path)
117
+ if len(charges) != len(mol2.atoms):
118
+ raise Mol2Error(
119
+ f"Charge count {len(charges)} does not match mol2 atom count {len(mol2.atoms)}."
120
+ )
121
+ mol2.atoms = [
122
+ replace(atom, charge=float(charge))
123
+ for atom, charge in zip(mol2.atoms, charges, strict=True)
124
+ ]
125
+ write_mol2(mol2, output_path)
126
+
127
+
128
+ def validate_and_write_final_mol2(
129
+ *,
130
+ mol2_path: str | Path,
131
+ extracted_atoms: list[AtomRecord],
132
+ ligand: LigandConfig,
133
+ final_mol2_path: str | Path,
134
+ charges_csv_path: str | Path,
135
+ validation_json_path: str | Path,
136
+ ) -> Mol2ValidationResult:
137
+ mol2 = read_mol2(mol2_path)
138
+ warnings: list[str] = []
139
+ if len(mol2.atoms) != len(extracted_atoms):
140
+ raise Mol2Error(
141
+ f"Ligand {ligand.id} atom-count mismatch: extracted PDB has "
142
+ f"{len(extracted_atoms)} atoms but mol2 has {len(mol2.atoms)} atoms."
143
+ )
144
+ _validate_element_order(mol2.atoms, extracted_atoms, ligand.id)
145
+ atoms = list(mol2.atoms)
146
+ if ligand.preserve_atom_names:
147
+ atoms = [
148
+ replace(mol2_atom, name=pdb_atom.name)
149
+ for mol2_atom, pdb_atom in zip(atoms, extracted_atoms, strict=True)
150
+ ]
151
+ atom_names_preserved = [atom.name for atom in atoms] == [atom.name for atom in extracted_atoms]
152
+ if ligand.preserve_atom_names and not ligand.allow_atom_renaming and not atom_names_preserved:
153
+ raise Mol2Error(f"Ligand {ligand.id} atom names could not be preserved in final mol2.")
154
+
155
+ coordinate_max_deviation = _max_coordinate_deviation(atoms, extracted_atoms)
156
+ if (
157
+ ligand.preserve_coordinates
158
+ and not ligand.allow_coordinate_changes
159
+ and coordinate_max_deviation > 0.05
160
+ ):
161
+ raise Mol2Error(
162
+ f"Ligand {ligand.id} mol2 coordinates deviate from extracted PDB by "
163
+ f"{coordinate_max_deviation:.3f} A; maximum allowed is 0.050 A."
164
+ )
165
+
166
+ charge_before = sum(atom.charge for atom in atoms)
167
+ residual = ligand.net_charge - charge_before
168
+ correction = 0.0
169
+ if abs(residual) > 0.01 + 1.0e-9:
170
+ raise Mol2Error(
171
+ f"Ligand {ligand.id} mol2 charge sum {charge_before:.6f} differs from "
172
+ f"target charge {ligand.net_charge} by {residual:.6f} e."
173
+ )
174
+ if abs(residual) > 1.0e-9:
175
+ target_index = max(range(len(atoms)), key=lambda index: abs(atoms[index].charge))
176
+ atoms[target_index] = replace(atoms[target_index], charge=atoms[target_index].charge + residual)
177
+ correction = residual
178
+ warnings.append(
179
+ f"Applied charge residual {residual:.6f} e to atom {atoms[target_index].name}."
180
+ )
181
+
182
+ mol2.atoms = atoms
183
+ final_path = Path(final_mol2_path)
184
+ write_mol2(mol2, final_path)
185
+ charges_path = Path(charges_csv_path)
186
+ _write_charges_csv(atoms, charges_path)
187
+ result = Mol2ValidationResult(
188
+ final_mol2_path=final_path,
189
+ charges_csv_path=charges_path,
190
+ validation_json_path=Path(validation_json_path),
191
+ charge_sum_before_correction=charge_before,
192
+ charge_correction_applied=correction,
193
+ charge_sum_final=sum(atom.charge for atom in atoms),
194
+ coordinate_max_deviation=coordinate_max_deviation,
195
+ atom_names_preserved=atom_names_preserved,
196
+ warnings=warnings,
197
+ )
198
+ result.validation_json_path.parent.mkdir(parents=True, exist_ok=True)
199
+ result.validation_json_path.write_text(
200
+ json.dumps(result.to_dict(), indent=2, sort_keys=True) + "\n",
201
+ encoding="utf-8",
202
+ )
203
+ return result
204
+
205
+
206
+ def _parse_atom_line(line: str, path: Path) -> Mol2Atom:
207
+ fields = line.split()
208
+ if len(fields) < 9:
209
+ raise Mol2Error(f"Invalid mol2 atom record in {path}: {line!r}")
210
+ try:
211
+ return Mol2Atom(
212
+ atom_id=int(fields[0]),
213
+ name=fields[1],
214
+ x=float(fields[2]),
215
+ y=float(fields[3]),
216
+ z=float(fields[4]),
217
+ atom_type=fields[5],
218
+ subst_id=int(fields[6]),
219
+ subst_name=fields[7],
220
+ charge=float(fields[8]),
221
+ )
222
+ except ValueError as exc:
223
+ raise Mol2Error(f"Invalid mol2 atom record in {path}: {line!r}") from exc
224
+
225
+
226
+ def _format_atom(atom: Mol2Atom) -> str:
227
+ return (
228
+ f"{atom.atom_id:7d} {atom.name:<8} "
229
+ f"{atom.x:10.4f} {atom.y:10.4f} {atom.z:10.4f} "
230
+ f"{atom.atom_type:<8} {atom.subst_id:4d} {atom.subst_name:<8} {atom.charge:10.6f}"
231
+ )
232
+
233
+
234
+ def _marker_index(lines: list[str], marker: str) -> int | None:
235
+ for index, line in enumerate(lines):
236
+ if line.strip() == marker:
237
+ return index
238
+ return None
239
+
240
+
241
+ def _molecule_name(lines: list[str]) -> str | None:
242
+ index = _marker_index(lines, "@<TRIPOS>MOLECULE")
243
+ if index is None:
244
+ return None
245
+ for line in lines[index + 1 :]:
246
+ stripped = line.strip()
247
+ if stripped:
248
+ return stripped
249
+ return None
250
+
251
+
252
+ def _validate_element_order(mol2_atoms: list[Mol2Atom], pdb_atoms: list[AtomRecord], ligand_id: str) -> None:
253
+ mol2_elements = [_element_from_mol2(atom) for atom in mol2_atoms]
254
+ pdb_elements = [_element_from_pdb_atom(atom) for atom in pdb_atoms]
255
+ if mol2_elements != pdb_elements:
256
+ raise Mol2Error(
257
+ f"Ligand {ligand_id} element-order mismatch between extracted PDB and mol2: "
258
+ f"{pdb_elements} != {mol2_elements}."
259
+ )
260
+
261
+
262
+ def _element_from_mol2(atom: Mol2Atom) -> str:
263
+ return _element_from_atom_type(atom.atom_type, fallback_name=atom.name)
264
+
265
+
266
+ GAFF_LIKE_ATOM_TYPE_ELEMENTS = {
267
+ **{
268
+ atom_type: "C"
269
+ for atom_type in {
270
+ "c",
271
+ "c1",
272
+ "c2",
273
+ "c3",
274
+ "c5",
275
+ "c6",
276
+ "ca",
277
+ "cc",
278
+ "cd",
279
+ "ce",
280
+ "cf",
281
+ "cg",
282
+ "ch",
283
+ "cp",
284
+ "cq",
285
+ "cu",
286
+ "cv",
287
+ "cx",
288
+ "cy",
289
+ "cz",
290
+ }
291
+ },
292
+ **{
293
+ atom_type: "H"
294
+ for atom_type in {
295
+ "h",
296
+ "h1",
297
+ "h2",
298
+ "h3",
299
+ "h4",
300
+ "h5",
301
+ "ha",
302
+ "hc",
303
+ "hn",
304
+ "ho",
305
+ "hp",
306
+ "hs",
307
+ "hw",
308
+ "hx",
309
+ }
310
+ },
311
+ **{
312
+ atom_type: "N"
313
+ for atom_type in {
314
+ "n",
315
+ "n1",
316
+ "n2",
317
+ "n3",
318
+ "n4",
319
+ "na",
320
+ "nb",
321
+ "nc",
322
+ "nd",
323
+ "ne",
324
+ "nf",
325
+ "nh",
326
+ "ni",
327
+ "nj",
328
+ "nk",
329
+ "nl",
330
+ "nm",
331
+ "nn",
332
+ "no",
333
+ "np",
334
+ "nq",
335
+ "ns",
336
+ "nt",
337
+ "nx",
338
+ "ny",
339
+ "nz",
340
+ }
341
+ },
342
+ **{atom_type: "O" for atom_type in {"o", "oh", "os", "ow"}},
343
+ **{
344
+ atom_type: "P"
345
+ for atom_type in {"p", "p2", "p3", "p4", "p5", "pb", "pc", "pd", "pe", "pf", "px", "py"}
346
+ },
347
+ **{atom_type: "S" for atom_type in {"s", "s2", "s4", "s6", "sh", "ss", "sp", "sx", "sy"}},
348
+ }
349
+
350
+ LOWERCASE_ELEMENT_ATOM_TYPES = {
351
+ "cl": "CL",
352
+ "br": "BR",
353
+ "f": "F",
354
+ "i": "I",
355
+ }
356
+
357
+
358
+ def _element_from_atom_type(atom_type: str, *, fallback_name: str = "") -> str:
359
+ token = atom_type.split(".", 1)[0].strip()
360
+ if not token:
361
+ return _element_from_atom_name(fallback_name)
362
+ lower = token.lower()
363
+ if lower in LOWERCASE_ELEMENT_ATOM_TYPES:
364
+ return LOWERCASE_ELEMENT_ATOM_TYPES[lower]
365
+ if token.islower():
366
+ if lower in GAFF_LIKE_ATOM_TYPE_ELEMENTS:
367
+ return GAFF_LIKE_ATOM_TYPE_ELEMENTS[lower]
368
+ if lower.upper() in PERIODIC_ELEMENTS:
369
+ return lower.upper()
370
+ return _element_from_atom_name(token)
371
+
372
+
373
+ def _element_from_pdb_atom(atom: AtomRecord) -> str:
374
+ if atom.element:
375
+ return atom.element.upper()
376
+ atom_field = atom.original_line[12:16] if len(atom.original_line) >= 16 else None
377
+ inferred = infer_element(
378
+ atom.name,
379
+ resname=atom.resname,
380
+ record_name=atom.record_name,
381
+ atom_field=atom_field,
382
+ )
383
+ return (inferred or "").upper()
384
+
385
+
386
+ def _element_from_atom_name(name: str) -> str:
387
+ stripped = name.strip()
388
+ while stripped and stripped[0].isdigit():
389
+ stripped = stripped[1:]
390
+ upper = stripped.upper()
391
+ if len(upper) >= 2 and upper[:2] in PERIODIC_ELEMENTS:
392
+ return upper[:2]
393
+ if not upper:
394
+ return ""
395
+ if upper[0] in PERIODIC_ELEMENTS:
396
+ return upper[0]
397
+ return upper[0]
398
+
399
+
400
+ def _max_coordinate_deviation(mol2_atoms: list[Mol2Atom], pdb_atoms: list[AtomRecord]) -> float:
401
+ if not mol2_atoms:
402
+ return 0.0
403
+ return max(
404
+ dist((mol2.x, mol2.y, mol2.z), (pdb.x, pdb.y, pdb.z))
405
+ for mol2, pdb in zip(mol2_atoms, pdb_atoms, strict=True)
406
+ )
407
+
408
+
409
+ def _write_charges_csv(atoms: list[Mol2Atom], path: Path) -> None:
410
+ path.parent.mkdir(parents=True, exist_ok=True)
411
+ with path.open("w", encoding="utf-8", newline="") as handle:
412
+ writer = csv.DictWriter(handle, fieldnames=["atom_id", "atom_name", "charge"])
413
+ writer.writeheader()
414
+ for atom in atoms:
415
+ writer.writerow({"atom_id": atom.atom_id, "atom_name": atom.name, "charge": atom.charge})
@@ -0,0 +1,89 @@
1
+ """parmchk2 wrapper for ligand frcmod generation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from mdprep.ambertools.commands import AmberToolRun, AmberToolsError
8
+ from mdprep.config.models import LigandConfig
9
+ from mdprep.external.discovery import which_executable
10
+ from mdprep.external.runner import run_command
11
+
12
+
13
+ def build_parmchk2_command(
14
+ *,
15
+ executable: str,
16
+ input_mol2: str | Path,
17
+ output_frcmod: str | Path,
18
+ ligand: LigandConfig,
19
+ ) -> list[str]:
20
+ return [
21
+ executable,
22
+ "-i",
23
+ str(input_mol2),
24
+ "-f",
25
+ "mol2",
26
+ "-o",
27
+ str(output_frcmod),
28
+ "-s",
29
+ ligand.atom_types,
30
+ ]
31
+
32
+
33
+ def run_parmchk2(
34
+ *,
35
+ ligand: LigandConfig,
36
+ input_mol2: str | Path,
37
+ output_frcmod: str | Path,
38
+ work_dir: str | Path,
39
+ executable: str = "parmchk2",
40
+ ) -> AmberToolRun:
41
+ exe = _resolve_executable(executable)
42
+ work = Path(work_dir)
43
+ stdout_path = work / "parmchk2_stdout.txt"
44
+ stderr_path = work / "parmchk2_stderr.txt"
45
+ input_path = Path(input_mol2).resolve()
46
+ output = Path(output_frcmod).resolve()
47
+ command = build_parmchk2_command(
48
+ executable=exe,
49
+ input_mol2=input_path,
50
+ output_frcmod=output,
51
+ ligand=ligand,
52
+ )
53
+ result = run_command(command, cwd=work)
54
+ stdout_path.write_text(result.stdout, encoding="utf-8")
55
+ stderr_path.write_text(result.stderr, encoding="utf-8")
56
+ if result.returncode != 0:
57
+ raise AmberToolsError(
58
+ "\n".join(
59
+ [
60
+ f"parmchk2 failed with exit code {result.returncode}.",
61
+ f"Command: {' '.join(result.command)}",
62
+ f"See {stdout_path} and {stderr_path}.",
63
+ f"stdout tail:\n{_tail(result.stdout)}",
64
+ f"stderr tail:\n{_tail(result.stderr)}",
65
+ ]
66
+ )
67
+ )
68
+ if not output.exists():
69
+ raise AmberToolsError(f"parmchk2 did not produce expected frcmod file: {output}")
70
+ return AmberToolRun(
71
+ command_result=result,
72
+ stdout_path=stdout_path,
73
+ stderr_path=stderr_path,
74
+ output_path=output,
75
+ )
76
+
77
+
78
+ def _resolve_executable(name: str) -> str:
79
+ found = which_executable(name)
80
+ if found:
81
+ return found
82
+ raise AmberToolsError(f"AmberTools executable not found: {name}")
83
+
84
+
85
+ def _tail(text: str, *, lines: int = 20) -> str:
86
+ stripped = text.strip()
87
+ if not stripped:
88
+ return "<empty>"
89
+ return "\n".join(stripped.splitlines()[-lines:])