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,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,2 @@
1
+ """Final topology validation helpers."""
2
+
@@ -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)
@@ -0,0 +1,6 @@
1
+ """Workflow entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []
6
+