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,204 @@
|
|
|
1
|
+
"""Residue and atom selector parsing and resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SelectorError(ValueError):
|
|
13
|
+
"""Raised when a selector cannot be resolved exactly."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class ResidueSelector:
|
|
18
|
+
chain_id: str | None
|
|
19
|
+
resid: int | None
|
|
20
|
+
resname: str | None = None
|
|
21
|
+
icode: str | None = None
|
|
22
|
+
|
|
23
|
+
def display(self) -> str:
|
|
24
|
+
chain = "*" if self.chain_id is None else self.chain_id
|
|
25
|
+
residue_number = "*" if self.resid is None else str(self.resid)
|
|
26
|
+
residue = f"{self.resname or ''}{residue_number}{self.icode or ''}"
|
|
27
|
+
return f"{chain}:{residue}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class AtomSelector:
|
|
32
|
+
residue: ResidueSelector
|
|
33
|
+
atom_name: str
|
|
34
|
+
|
|
35
|
+
def display(self) -> str:
|
|
36
|
+
return f"{self.residue.display()}@{self.atom_name}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_RESID_ONLY_BODY_RE = re.compile(r"^(?P<resid>-?\d+)(?P<icode>[A-Za-z]?)$")
|
|
40
|
+
_RESNAME_RESID_BODY_RE = re.compile(r"^(?P<resname>[A-Za-z0-9]{1,3})(?P<resid>-?\d+)(?P<icode>[A-Za-z]?)$")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_residue_selector(text: str) -> ResidueSelector:
|
|
44
|
+
if ":" not in text:
|
|
45
|
+
raise SelectorError(f"Residue selector {text!r} must contain ':'")
|
|
46
|
+
if "@" in text:
|
|
47
|
+
raise SelectorError(f"Residue selector {text!r} must not contain '@'")
|
|
48
|
+
chain_id, body = text.split(":", 1)
|
|
49
|
+
if not body:
|
|
50
|
+
raise SelectorError(f"Residue selector {text!r} is missing a residue number")
|
|
51
|
+
stripped_body = body.strip()
|
|
52
|
+
match = _RESID_ONLY_BODY_RE.match(stripped_body)
|
|
53
|
+
if match is None:
|
|
54
|
+
match = _RESNAME_RESID_BODY_RE.match(stripped_body)
|
|
55
|
+
if match is None:
|
|
56
|
+
raise SelectorError(f"Could not parse residue selector {text!r}")
|
|
57
|
+
resname = match.groupdict().get("resname")
|
|
58
|
+
return ResidueSelector(
|
|
59
|
+
chain_id=chain_id,
|
|
60
|
+
resname=resname,
|
|
61
|
+
resid=int(match.group("resid")),
|
|
62
|
+
icode=match.group("icode") or None,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_atom_selector(text: str) -> AtomSelector:
|
|
67
|
+
if "@" not in text:
|
|
68
|
+
raise SelectorError(f"Atom selector {text!r} must contain '@'")
|
|
69
|
+
residue_text, atom_name = text.split("@", 1)
|
|
70
|
+
atom_name = atom_name.strip()
|
|
71
|
+
if not atom_name:
|
|
72
|
+
raise SelectorError(f"Atom selector {text!r} is missing an atom name")
|
|
73
|
+
return AtomSelector(residue=parse_residue_selector(residue_text), atom_name=atom_name)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def resolve_all_residue_selector(
|
|
77
|
+
structure: PdbStructure,
|
|
78
|
+
selector: ResidueSelector | dict[str, Any] | str,
|
|
79
|
+
) -> list[ResidueRecord]:
|
|
80
|
+
resolved_selector = coerce_residue_selector(selector)
|
|
81
|
+
matches = [
|
|
82
|
+
residue
|
|
83
|
+
for residue in structure.residues
|
|
84
|
+
if _matches_residue_selector(residue, resolved_selector)
|
|
85
|
+
]
|
|
86
|
+
if not matches:
|
|
87
|
+
nearby = _nearby_residues(structure, resolved_selector)
|
|
88
|
+
suffix = f" Nearby residues: {', '.join(_format_residue(residue) for residue in nearby)}" if nearby else ""
|
|
89
|
+
raise SelectorError(f"No residue matched selector {resolved_selector.display()}.{suffix}")
|
|
90
|
+
return matches
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_residue_selector(
|
|
94
|
+
structure: PdbStructure,
|
|
95
|
+
selector: ResidueSelector | dict[str, Any] | str,
|
|
96
|
+
) -> ResidueRecord:
|
|
97
|
+
resolved_selector = coerce_residue_selector(selector)
|
|
98
|
+
matches = resolve_all_residue_selector(structure, resolved_selector)
|
|
99
|
+
if len(matches) > 1:
|
|
100
|
+
formatted = ", ".join(_format_residue(residue) for residue in matches)
|
|
101
|
+
raise SelectorError(
|
|
102
|
+
f"Selector {resolved_selector.display()} matched {len(matches)} residues: {formatted}"
|
|
103
|
+
)
|
|
104
|
+
return matches[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def resolve_atom_selector(
|
|
108
|
+
structure: PdbStructure,
|
|
109
|
+
selector: AtomSelector | dict[str, Any] | str,
|
|
110
|
+
) -> AtomRecord:
|
|
111
|
+
resolved_selector = coerce_atom_selector(selector)
|
|
112
|
+
residue = resolve_residue_selector(structure, resolved_selector.residue)
|
|
113
|
+
matches = [atom for atom in residue.atoms if atom.name == resolved_selector.atom_name]
|
|
114
|
+
if not matches:
|
|
115
|
+
available = ", ".join(residue.atom_names())
|
|
116
|
+
raise SelectorError(
|
|
117
|
+
f"No atom matched selector {resolved_selector.display()}. Available atoms: {available}"
|
|
118
|
+
)
|
|
119
|
+
if len(matches) > 1:
|
|
120
|
+
raise SelectorError(f"Selector {resolved_selector.display()} matched {len(matches)} atoms")
|
|
121
|
+
return matches[0]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def coerce_residue_selector(selector: ResidueSelector | dict[str, Any] | str) -> ResidueSelector:
|
|
125
|
+
if isinstance(selector, ResidueSelector):
|
|
126
|
+
return selector
|
|
127
|
+
if isinstance(selector, str):
|
|
128
|
+
return parse_residue_selector(selector)
|
|
129
|
+
if isinstance(selector, dict):
|
|
130
|
+
chain_value = selector.get("chain_id", selector.get("chain"))
|
|
131
|
+
if chain_value is not None:
|
|
132
|
+
chain_value = str(chain_value)
|
|
133
|
+
has_resid = "resid" in selector and selector.get("resid") is not None
|
|
134
|
+
resname = selector.get("resname")
|
|
135
|
+
if not has_resid and not resname:
|
|
136
|
+
raise SelectorError(f"Structured selector must include at least 'resname' or 'resid': {selector}")
|
|
137
|
+
return ResidueSelector(
|
|
138
|
+
chain_id=chain_value,
|
|
139
|
+
resname=str(resname) if resname is not None else None,
|
|
140
|
+
resid=int(selector["resid"]) if has_resid else None,
|
|
141
|
+
icode=selector.get("icode"),
|
|
142
|
+
)
|
|
143
|
+
raise SelectorError(f"Unsupported residue selector type: {type(selector).__name__}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def coerce_atom_selector(selector: AtomSelector | dict[str, Any] | str) -> AtomSelector:
|
|
147
|
+
if isinstance(selector, AtomSelector):
|
|
148
|
+
return selector
|
|
149
|
+
if isinstance(selector, str):
|
|
150
|
+
return parse_atom_selector(selector)
|
|
151
|
+
if isinstance(selector, dict):
|
|
152
|
+
atom_name = selector.get("atom_name", selector.get("atom"))
|
|
153
|
+
if not atom_name:
|
|
154
|
+
raise SelectorError(f"Structured atom selector is missing atom_name: {selector}")
|
|
155
|
+
residue_data = selector.get("residue", selector)
|
|
156
|
+
return AtomSelector(residue=coerce_residue_selector(residue_data), atom_name=str(atom_name).strip())
|
|
157
|
+
raise SelectorError(f"Unsupported atom selector type: {type(selector).__name__}")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _nearby_residues(structure: PdbStructure, selector: ResidueSelector) -> list[ResidueRecord]:
|
|
161
|
+
candidates: list[ResidueRecord] = []
|
|
162
|
+
if selector.resid is not None:
|
|
163
|
+
candidates = [
|
|
164
|
+
residue
|
|
165
|
+
for residue in structure.residues
|
|
166
|
+
if (selector.chain_id is None or residue.id.chain_id == selector.chain_id)
|
|
167
|
+
and abs(residue.id.resid - selector.resid) <= 2
|
|
168
|
+
]
|
|
169
|
+
if not candidates and selector.resname is not None:
|
|
170
|
+
candidates = [
|
|
171
|
+
residue
|
|
172
|
+
for residue in structure.residues
|
|
173
|
+
if (selector.chain_id is None or residue.id.chain_id == selector.chain_id)
|
|
174
|
+
and residue.id.resname == selector.resname
|
|
175
|
+
]
|
|
176
|
+
if not candidates:
|
|
177
|
+
candidates = [
|
|
178
|
+
residue
|
|
179
|
+
for residue in structure.residues
|
|
180
|
+
if selector.resid is not None and residue.id.resid == selector.resid
|
|
181
|
+
]
|
|
182
|
+
if not candidates:
|
|
183
|
+
candidates = structure.residues
|
|
184
|
+
return candidates[:5]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _matches_residue_selector(residue: ResidueRecord, selector: ResidueSelector) -> bool:
|
|
188
|
+
if selector.chain_id is not None and residue.id.chain_id != selector.chain_id:
|
|
189
|
+
return False
|
|
190
|
+
if selector.resid is not None and residue.id.resid != selector.resid:
|
|
191
|
+
return False
|
|
192
|
+
if selector.resname is not None and residue.id.resname != selector.resname:
|
|
193
|
+
return False
|
|
194
|
+
if selector.icode is not None and residue.id.icode != selector.icode:
|
|
195
|
+
return False
|
|
196
|
+
if selector.resid is not None and selector.icode is None and residue.id.icode is not None:
|
|
197
|
+
return False
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _format_residue(residue: ResidueRecord) -> str:
|
|
202
|
+
chain = residue.id.chain_id if residue.id.chain_id else "<blank>"
|
|
203
|
+
icode = residue.id.icode or ""
|
|
204
|
+
return f"chain={chain} resname={residue.id.resname} resid={residue.id.resid}{icode}"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""PDB writing helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from mdprep.structure.models import AtomRecord, PdbStructure
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_pdb(structure: PdbStructure, path: str | Path) -> None:
|
|
11
|
+
output_path = Path(path)
|
|
12
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
|
|
14
|
+
lines: list[str] = []
|
|
15
|
+
previous_atom: AtomRecord | None = None
|
|
16
|
+
ter_written_after: set[int] = set()
|
|
17
|
+
|
|
18
|
+
for atom in structure.atoms:
|
|
19
|
+
if previous_atom is not None and _needs_implicit_ter(previous_atom, atom):
|
|
20
|
+
previous_serial = previous_atom.serial
|
|
21
|
+
if previous_serial is None or previous_serial not in ter_written_after:
|
|
22
|
+
lines.append("TER\n")
|
|
23
|
+
lines.append(format_atom_record(atom))
|
|
24
|
+
if atom.serial is not None and atom.serial in structure.ter_after_serials:
|
|
25
|
+
lines.append("TER\n")
|
|
26
|
+
ter_written_after.add(atom.serial)
|
|
27
|
+
previous_atom = atom
|
|
28
|
+
|
|
29
|
+
lines.extend(_format_conect_records(structure))
|
|
30
|
+
lines.append("END\n")
|
|
31
|
+
output_path.write_text("".join(lines), encoding="utf-8")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _needs_implicit_ter(previous: AtomRecord, current: AtomRecord) -> bool:
|
|
35
|
+
if previous.chain_id != current.chain_id:
|
|
36
|
+
return True
|
|
37
|
+
# A same-chain HETATM must not be interpreted as the next peptide residue.
|
|
38
|
+
return previous.record_name == "ATOM" and current.record_name == "HETATM"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _format_conect_records(structure: PdbStructure) -> list[str]:
|
|
42
|
+
serials = {atom.serial for atom in structure.atoms if atom.serial is not None}
|
|
43
|
+
neighbors: dict[int, set[int]] = {}
|
|
44
|
+
for left, right in structure.conect_bonds:
|
|
45
|
+
if left not in serials or right not in serials:
|
|
46
|
+
continue
|
|
47
|
+
neighbors.setdefault(left, set()).add(right)
|
|
48
|
+
neighbors.setdefault(right, set()).add(left)
|
|
49
|
+
lines: list[str] = []
|
|
50
|
+
for source in sorted(neighbors):
|
|
51
|
+
targets = sorted(neighbors[source])
|
|
52
|
+
for offset in range(0, len(targets), 4):
|
|
53
|
+
group = targets[offset : offset + 4]
|
|
54
|
+
lines.append(
|
|
55
|
+
"CONECT" + f"{source:5d}" + "".join(f"{target:5d}" for target in group) + "\n"
|
|
56
|
+
)
|
|
57
|
+
return lines
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_atom_record(atom: AtomRecord) -> str:
|
|
61
|
+
serial = atom.serial if atom.serial is not None else 0
|
|
62
|
+
atom_name = _atom_name_field(atom)
|
|
63
|
+
altloc = atom.altloc or " "
|
|
64
|
+
chain_id = atom.chain_id if atom.chain_id else " "
|
|
65
|
+
icode = atom.icode or " "
|
|
66
|
+
occupancy = atom.occupancy if atom.occupancy is not None else 1.0
|
|
67
|
+
bfactor = atom.bfactor if atom.bfactor is not None else 0.0
|
|
68
|
+
element = (atom.element or "").rjust(2)
|
|
69
|
+
return (
|
|
70
|
+
f"{atom.record_name:<6}{serial:5d} {atom_name}{altloc}{atom.resname:>3} "
|
|
71
|
+
f"{chain_id}{atom.resid:4d}{icode} "
|
|
72
|
+
f"{atom.x:8.3f}{atom.y:8.3f}{atom.z:8.3f}"
|
|
73
|
+
f"{occupancy:6.2f}{bfactor:6.2f} {element}\n"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _atom_name_field(atom: AtomRecord) -> str:
|
|
78
|
+
if len(atom.original_line) >= 16:
|
|
79
|
+
original = atom.original_line[12:16]
|
|
80
|
+
if original.strip() == atom.name:
|
|
81
|
+
return original
|
|
82
|
+
if atom.element and len(atom.element.strip()) == 1 and len(atom.name) < 4:
|
|
83
|
+
return f" {atom.name:<3}"[:4]
|
|
84
|
+
return f"{atom.name:>4}"[-4:]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Optional OpenMM finite-energy validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from math import isfinite
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def openmm_available() -> bool:
|
|
10
|
+
try:
|
|
11
|
+
import openmm # noqa: F401
|
|
12
|
+
from openmm import app # noqa: F401
|
|
13
|
+
except Exception:
|
|
14
|
+
return False
|
|
15
|
+
return True
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def openmm_version() -> str:
|
|
19
|
+
try:
|
|
20
|
+
import openmm
|
|
21
|
+
except Exception:
|
|
22
|
+
return "not available"
|
|
23
|
+
return getattr(openmm, "__version__", "unknown")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_openmm_energy_check(prmtop: str | Path, inpcrd: str | Path) -> dict[str, object]:
|
|
27
|
+
try:
|
|
28
|
+
import openmm
|
|
29
|
+
from openmm import app, unit
|
|
30
|
+
except Exception as exc:
|
|
31
|
+
return {"available": False, "status": "skipped", "warning": f"OpenMM is unavailable: {exc}"}
|
|
32
|
+
try:
|
|
33
|
+
topology = app.AmberPrmtopFile(str(prmtop))
|
|
34
|
+
coordinates = app.AmberInpcrdFile(str(inpcrd))
|
|
35
|
+
system = topology.createSystem(nonbondedMethod=app.NoCutoff, constraints=None)
|
|
36
|
+
force_classes = [force.__class__.__name__ for force in system.getForces()]
|
|
37
|
+
custom_nonbonded_expressions = [
|
|
38
|
+
force.getEnergyFunction()
|
|
39
|
+
for force in system.getForces()
|
|
40
|
+
if isinstance(force, openmm.CustomNonbondedForce)
|
|
41
|
+
]
|
|
42
|
+
amber_12_6_4 = _prmtop_has_flag(prmtop, "LENNARD_JONES_CCOEF")
|
|
43
|
+
openmm_12_6_4 = any(
|
|
44
|
+
"r^4" in expression.replace(" ", "")
|
|
45
|
+
for expression in custom_nonbonded_expressions
|
|
46
|
+
)
|
|
47
|
+
if amber_12_6_4 and not openmm_12_6_4:
|
|
48
|
+
return {
|
|
49
|
+
"available": True,
|
|
50
|
+
"status": "error",
|
|
51
|
+
"error": (
|
|
52
|
+
"Amber topology contains LENNARD_JONES_CCOEF, but OpenMM did not create "
|
|
53
|
+
"a CustomNonbondedForce with the r^-4 term."
|
|
54
|
+
),
|
|
55
|
+
"version": getattr(openmm, "__version__", "unknown"),
|
|
56
|
+
"force_classes": force_classes,
|
|
57
|
+
"custom_nonbonded_expressions": custom_nonbonded_expressions,
|
|
58
|
+
"amber_12_6_4_detected": amber_12_6_4,
|
|
59
|
+
"openmm_12_6_4_force_present": openmm_12_6_4,
|
|
60
|
+
}
|
|
61
|
+
integrator = openmm.VerletIntegrator(1.0 * unit.femtoseconds)
|
|
62
|
+
context = openmm.Context(system, integrator)
|
|
63
|
+
context.setPositions(coordinates.positions)
|
|
64
|
+
state = context.getState(getEnergy=True)
|
|
65
|
+
energy = state.getPotentialEnergy().value_in_unit(unit.kilocalories_per_mole)
|
|
66
|
+
del context
|
|
67
|
+
del integrator
|
|
68
|
+
return {
|
|
69
|
+
"available": True,
|
|
70
|
+
"status": "ok" if isfinite(energy) else "error",
|
|
71
|
+
"potential_energy_kcal_mol": energy,
|
|
72
|
+
"finite": isfinite(energy),
|
|
73
|
+
"version": getattr(openmm, "__version__", "unknown"),
|
|
74
|
+
"force_classes": force_classes,
|
|
75
|
+
"custom_nonbonded_expressions": custom_nonbonded_expressions,
|
|
76
|
+
"amber_12_6_4_detected": amber_12_6_4,
|
|
77
|
+
"openmm_12_6_4_force_present": openmm_12_6_4,
|
|
78
|
+
}
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
return {"available": True, "status": "error", "error": str(exc)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _prmtop_has_flag(path: str | Path, flag: str) -> bool:
|
|
84
|
+
marker = f"%FLAG {flag}"
|
|
85
|
+
with Path(path).open("r", encoding="utf-8", errors="replace") as handle:
|
|
86
|
+
return any(line.rstrip("\r\n") == marker for line in handle)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Optional ParmEd validation for final Amber files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parmed_available() -> bool:
|
|
9
|
+
try:
|
|
10
|
+
import parmed # noqa: F401
|
|
11
|
+
except Exception:
|
|
12
|
+
return False
|
|
13
|
+
return True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parmed_version() -> str:
|
|
17
|
+
try:
|
|
18
|
+
import parmed
|
|
19
|
+
except Exception:
|
|
20
|
+
return "not available"
|
|
21
|
+
return getattr(parmed, "__version__", "unknown")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def run_parmed_check(prmtop: str | Path, inpcrd: str | Path) -> dict[str, object]:
|
|
25
|
+
try:
|
|
26
|
+
import parmed
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
return {"available": False, "status": "skipped", "warning": f"ParmEd is unavailable: {exc}"}
|
|
29
|
+
try:
|
|
30
|
+
structure = parmed.load_file(str(prmtop), str(inpcrd))
|
|
31
|
+
total_charge = sum(atom.charge for atom in structure.atoms)
|
|
32
|
+
coordinates = getattr(structure, "coordinates", None)
|
|
33
|
+
return {
|
|
34
|
+
"available": True,
|
|
35
|
+
"status": "ok",
|
|
36
|
+
"atom_count": len(structure.atoms),
|
|
37
|
+
"coordinate_count": len(coordinates) if coordinates is not None else 0,
|
|
38
|
+
"total_charge": total_charge,
|
|
39
|
+
"charge_near_integer": abs(total_charge - round(total_charge)) <= 0.05,
|
|
40
|
+
"version": getattr(parmed, "__version__", "unknown"),
|
|
41
|
+
}
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
return {"available": True, "status": "error", "error": str(exc)}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Final Amber output validation and reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from mdprep.config.models import ManifestConfig
|
|
10
|
+
from mdprep.structure.classify import is_water_residue, likely_ligands_or_cofactors
|
|
11
|
+
from mdprep.structure.pdb import PdbParseError, read_pdb
|
|
12
|
+
from mdprep.validation.openmm_check import run_openmm_energy_check
|
|
13
|
+
from mdprep.validation.parmed_check import run_parmed_check
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from mdprep.metals.workflow import MetalStageResult
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FinalValidationError(ValueError):
|
|
20
|
+
"""Raised when final Amber outputs fail required validation."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_final_outputs(
|
|
24
|
+
*,
|
|
25
|
+
manifest: ManifestConfig,
|
|
26
|
+
prmtop: str | Path,
|
|
27
|
+
inpcrd: str | Path,
|
|
28
|
+
pdb: str | Path,
|
|
29
|
+
metal_result: "MetalStageResult | None" = None,
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
warnings: list[str] = []
|
|
32
|
+
errors: list[str] = []
|
|
33
|
+
if metal_result is not None:
|
|
34
|
+
warnings.extend(metal_result.warnings)
|
|
35
|
+
paths = {
|
|
36
|
+
"prmtop": Path(prmtop),
|
|
37
|
+
"inpcrd": Path(inpcrd),
|
|
38
|
+
"pdb": Path(pdb),
|
|
39
|
+
}
|
|
40
|
+
file_checks = {}
|
|
41
|
+
for label, path in paths.items():
|
|
42
|
+
exists = path.exists()
|
|
43
|
+
size = path.stat().st_size if exists else 0
|
|
44
|
+
file_checks[label] = {"path": str(path), "exists": exists, "size_bytes": size}
|
|
45
|
+
if not exists:
|
|
46
|
+
errors.append(f"Missing final {label}: {path}")
|
|
47
|
+
elif size == 0:
|
|
48
|
+
errors.append(f"Final {label} is empty: {path}")
|
|
49
|
+
|
|
50
|
+
final_structure = None
|
|
51
|
+
if paths["pdb"].exists() and paths["pdb"].stat().st_size > 0:
|
|
52
|
+
try:
|
|
53
|
+
final_structure = read_pdb(paths["pdb"])
|
|
54
|
+
except (PdbParseError, ValueError) as exc:
|
|
55
|
+
errors.append(f"Final PDB is not parseable by mdprep: {exc}")
|
|
56
|
+
|
|
57
|
+
ligand_checks: list[dict[str, object]] = []
|
|
58
|
+
if final_structure is not None:
|
|
59
|
+
for ligand in manifest.ligands:
|
|
60
|
+
expected = ligand.selector.resname
|
|
61
|
+
accepted_resnames = {expected}
|
|
62
|
+
if metal_result is not None and metal_result.mcpb_site is not None:
|
|
63
|
+
accepted_resnames.update(
|
|
64
|
+
str(rename["final_resname"])
|
|
65
|
+
for rename in metal_result.mcpb_site.residue_renames
|
|
66
|
+
if rename.get("original_resname") == expected
|
|
67
|
+
)
|
|
68
|
+
residues = [
|
|
69
|
+
residue
|
|
70
|
+
for residue in final_structure.residues
|
|
71
|
+
if residue.id.resname in accepted_resnames
|
|
72
|
+
]
|
|
73
|
+
expected_names = _expected_ligand_atom_names(ligand.id, paths["pdb"].parent.parent)
|
|
74
|
+
atom_names_ok = bool(residues)
|
|
75
|
+
if expected_names:
|
|
76
|
+
atom_names_ok = any(residue.atom_names() == expected_names for residue in residues)
|
|
77
|
+
ok = bool(residues) and atom_names_ok
|
|
78
|
+
ligand_checks.append(
|
|
79
|
+
{
|
|
80
|
+
"ligand_id": ligand.id,
|
|
81
|
+
"resname": expected,
|
|
82
|
+
"present": bool(residues),
|
|
83
|
+
"atom_names_ok": atom_names_ok,
|
|
84
|
+
"ok": ok,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
if not ok:
|
|
88
|
+
errors.append(f"Configured ligand {ligand.id} ({expected}) was not preserved in final PDB.")
|
|
89
|
+
|
|
90
|
+
water_count = sum(1 for residue in final_structure.residues if is_water_residue(residue))
|
|
91
|
+
if manifest.solvation.enabled and water_count == 0:
|
|
92
|
+
errors.append("Solvation is enabled but no waters were found in final PDB.")
|
|
93
|
+
if not manifest.solvation.enabled and water_count > 0 and not manifest.structure.keep_crystal_waters:
|
|
94
|
+
errors.append("Solvation is disabled but unexpected waters were found in final PDB.")
|
|
95
|
+
|
|
96
|
+
unexpected = [
|
|
97
|
+
residue.id.to_dict()
|
|
98
|
+
for residue in likely_ligands_or_cofactors(final_structure.residues)
|
|
99
|
+
if residue.id.resname not in _allowed_final_heterogen_resnames(manifest, metal_result)
|
|
100
|
+
]
|
|
101
|
+
if unexpected:
|
|
102
|
+
errors.append(f"Unexpected heterogen residues found in final PDB: {unexpected}")
|
|
103
|
+
else:
|
|
104
|
+
water_count = 0
|
|
105
|
+
|
|
106
|
+
parmed = run_parmed_check(paths["prmtop"], paths["inpcrd"])
|
|
107
|
+
if parmed.get("status") == "skipped":
|
|
108
|
+
warnings.append(str(parmed.get("warning")))
|
|
109
|
+
elif parmed.get("status") == "error":
|
|
110
|
+
errors.append(f"ParmEd validation failed: {parmed.get('error')}")
|
|
111
|
+
elif parmed.get("charge_near_integer") is False:
|
|
112
|
+
errors.append("ParmEd total charge is not near an integer.")
|
|
113
|
+
|
|
114
|
+
if manifest.validation.run_openmm_energy_check:
|
|
115
|
+
openmm = run_openmm_energy_check(paths["prmtop"], paths["inpcrd"])
|
|
116
|
+
if openmm.get("status") == "skipped":
|
|
117
|
+
warnings.append(str(openmm.get("warning")))
|
|
118
|
+
elif openmm.get("status") == "error":
|
|
119
|
+
warnings.append(f"OpenMM energy check failed: {openmm.get('error')}")
|
|
120
|
+
elif openmm.get("finite") is False:
|
|
121
|
+
errors.append("OpenMM potential energy is not finite.")
|
|
122
|
+
else:
|
|
123
|
+
openmm = {"available": None, "status": "disabled"}
|
|
124
|
+
|
|
125
|
+
report = {
|
|
126
|
+
"final_prmtop_path": str(paths["prmtop"]),
|
|
127
|
+
"final_inpcrd_path": str(paths["inpcrd"]),
|
|
128
|
+
"final_pdb_path": str(paths["pdb"]),
|
|
129
|
+
"file_checks": file_checks,
|
|
130
|
+
"final_atom_count": len(final_structure.atoms) if final_structure is not None else None,
|
|
131
|
+
"final_residue_count": len(final_structure.residues) if final_structure is not None else None,
|
|
132
|
+
"ligand_presence_checks": ligand_checks,
|
|
133
|
+
"water_presence": {"water_count": water_count, "solvation_enabled": manifest.solvation.enabled},
|
|
134
|
+
"parmed": parmed,
|
|
135
|
+
"openmm": openmm,
|
|
136
|
+
"warnings": warnings,
|
|
137
|
+
"errors": errors,
|
|
138
|
+
}
|
|
139
|
+
if errors:
|
|
140
|
+
raise FinalValidationError("; ".join(errors))
|
|
141
|
+
return report
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def write_validation_reports(
|
|
145
|
+
report: dict[str, Any],
|
|
146
|
+
*,
|
|
147
|
+
json_path: str | Path,
|
|
148
|
+
markdown_path: str | Path,
|
|
149
|
+
) -> dict[str, Any]:
|
|
150
|
+
json_output = Path(json_path)
|
|
151
|
+
markdown_output = Path(markdown_path)
|
|
152
|
+
json_output.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
json_output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
154
|
+
markdown_output.write_text(_render_markdown(report), encoding="utf-8")
|
|
155
|
+
return report
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _expected_ligand_atom_names(ligand_id: str, output_dir: Path) -> list[str]:
|
|
159
|
+
identity = output_dir / "ligands" / ligand_id / "input" / "identity.json"
|
|
160
|
+
if not identity.exists():
|
|
161
|
+
return []
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(identity.read_text(encoding="utf-8"))
|
|
164
|
+
except json.JSONDecodeError:
|
|
165
|
+
return []
|
|
166
|
+
names = data.get("atom_names", [])
|
|
167
|
+
return [str(name) for name in names] if isinstance(names, list) else []
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _allowed_final_heterogen_resnames(
|
|
171
|
+
manifest: ManifestConfig,
|
|
172
|
+
metal_result: "MetalStageResult | None" = None,
|
|
173
|
+
) -> set[str]:
|
|
174
|
+
allowed = {ligand.selector.resname for ligand in manifest.ligands}
|
|
175
|
+
allowed.update(
|
|
176
|
+
ion.selector.resname
|
|
177
|
+
for site in manifest.metals
|
|
178
|
+
for ion in site.ions
|
|
179
|
+
)
|
|
180
|
+
if metal_result is not None and metal_result.mcpb_site is not None:
|
|
181
|
+
allowed.update(
|
|
182
|
+
str(rename["final_resname"])
|
|
183
|
+
for rename in metal_result.mcpb_site.residue_renames
|
|
184
|
+
)
|
|
185
|
+
if manifest.solvation.enabled and (
|
|
186
|
+
manifest.solvation.neutralize or manifest.solvation.salt_concentration_molar > 0
|
|
187
|
+
):
|
|
188
|
+
allowed.update(_ion_residue_name_aliases(manifest.solvation.positive_ion))
|
|
189
|
+
allowed.update(_ion_residue_name_aliases(manifest.solvation.negative_ion))
|
|
190
|
+
return allowed
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _ion_residue_name_aliases(name: str) -> set[str]:
|
|
194
|
+
stripped = name.strip()
|
|
195
|
+
if not stripped:
|
|
196
|
+
return set()
|
|
197
|
+
no_charge = stripped.rstrip("+-")
|
|
198
|
+
aliases = {
|
|
199
|
+
stripped,
|
|
200
|
+
stripped.upper(),
|
|
201
|
+
no_charge,
|
|
202
|
+
no_charge.upper(),
|
|
203
|
+
no_charge.capitalize(),
|
|
204
|
+
}
|
|
205
|
+
if stripped.endswith("+") or stripped.endswith("-"):
|
|
206
|
+
aliases.add(no_charge.capitalize() + stripped[-1])
|
|
207
|
+
aliases.add(no_charge.upper() + stripped[-1])
|
|
208
|
+
return {alias for alias in aliases if alias}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _render_markdown(report: dict[str, Any]) -> str:
|
|
212
|
+
lines = [
|
|
213
|
+
"# Validation Report",
|
|
214
|
+
"",
|
|
215
|
+
f"- Final atom count: {report['final_atom_count']}",
|
|
216
|
+
f"- Final residue count: {report['final_residue_count']}",
|
|
217
|
+
f"- ParmEd status: `{report['parmed'].get('status')}`",
|
|
218
|
+
f"- OpenMM status: `{report['openmm'].get('status')}`",
|
|
219
|
+
]
|
|
220
|
+
if report["openmm"].get("amber_12_6_4_detected"):
|
|
221
|
+
lines.append(
|
|
222
|
+
"- OpenMM 12-6-4 r^-4 force present: "
|
|
223
|
+
f"`{report['openmm'].get('openmm_12_6_4_force_present')}`"
|
|
224
|
+
)
|
|
225
|
+
lines.extend(["", "## Ligands", ""])
|
|
226
|
+
ligand_checks = report.get("ligand_presence_checks", [])
|
|
227
|
+
if ligand_checks:
|
|
228
|
+
for check in ligand_checks:
|
|
229
|
+
lines.append(f"- `{check['ligand_id']}` present={check['present']} atom_names_ok={check['atom_names_ok']}")
|
|
230
|
+
else:
|
|
231
|
+
lines.append("- None")
|
|
232
|
+
lines.extend(["", "## Warnings", ""])
|
|
233
|
+
warnings = report.get("warnings", [])
|
|
234
|
+
if warnings:
|
|
235
|
+
lines.extend(f"- {warning}" for warning in warnings)
|
|
236
|
+
else:
|
|
237
|
+
lines.append("- None")
|
|
238
|
+
lines.append("")
|
|
239
|
+
return "\n".join(lines)
|