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