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/leap/ions.py ADDED
@@ -0,0 +1,112 @@
1
+ """Ion and salt command helpers for tleap scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from math import cos, radians, sqrt
7
+
8
+
9
+ class IonPlanError(ValueError):
10
+ """Raised when ion placement cannot be planned safely."""
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class IonPlan:
15
+ neutralizing_ion: str | None
16
+ neutralizing_count: int
17
+ salt_pairs: int
18
+ commands: list[str]
19
+
20
+ def to_dict(self) -> dict[str, object]:
21
+ return {
22
+ "neutralizing_ion": self.neutralizing_ion,
23
+ "neutralizing_count": self.neutralizing_count,
24
+ "salt_pairs": self.salt_pairs,
25
+ "commands": self.commands,
26
+ }
27
+
28
+
29
+ def neutralizing_ion_count(
30
+ total_charge: float,
31
+ *,
32
+ positive_ion: str,
33
+ negative_ion: str,
34
+ tolerance: float = 0.05,
35
+ ) -> tuple[str | None, int]:
36
+ rounded = round(total_charge)
37
+ if abs(total_charge - rounded) > tolerance:
38
+ raise IonPlanError(
39
+ f"System charge {total_charge:.6f} is not within {tolerance:.2f} e of an integer."
40
+ )
41
+ if rounded < 0:
42
+ return positive_ion, abs(rounded)
43
+ if rounded > 0:
44
+ return negative_ion, rounded
45
+ return None, 0
46
+
47
+
48
+ def salt_pair_count(molarity: float, volume_a3: float) -> int:
49
+ if molarity < 0:
50
+ raise IonPlanError("Salt concentration cannot be negative.")
51
+ if volume_a3 <= 0:
52
+ raise IonPlanError("Periodic box volume must be positive to add salt pairs.")
53
+ return round(molarity * volume_a3 * 0.000602214076)
54
+
55
+
56
+ def build_ion_plan(
57
+ *,
58
+ total_charge: float,
59
+ neutralize: bool,
60
+ positive_ion: str,
61
+ negative_ion: str,
62
+ salt_concentration_molar: float,
63
+ volume_a3: float | None,
64
+ ) -> IonPlan:
65
+ commands: list[str] = []
66
+ neutralizing_ion: str | None = None
67
+ neutralizing_count = 0
68
+ if neutralize:
69
+ neutralizing_ion, neutralizing_count = neutralizing_ion_count(
70
+ total_charge,
71
+ positive_ion=positive_ion,
72
+ negative_ion=negative_ion,
73
+ )
74
+ if neutralizing_ion is not None and neutralizing_count > 0:
75
+ commands.append(f"addionsrand system {neutralizing_ion} {neutralizing_count}")
76
+
77
+ salt_pairs = 0
78
+ if salt_concentration_molar > 0:
79
+ if volume_a3 is None:
80
+ raise IonPlanError("Cannot add salt pairs because solvated box volume was not determined.")
81
+ salt_pairs = salt_pair_count(salt_concentration_molar, volume_a3)
82
+ if salt_pairs > 0:
83
+ commands.append(f"addionsrand system {positive_ion} {salt_pairs}")
84
+ commands.append(f"addionsrand system {negative_ion} {salt_pairs}")
85
+ return IonPlan(
86
+ neutralizing_ion=neutralizing_ion,
87
+ neutralizing_count=neutralizing_count,
88
+ salt_pairs=salt_pairs,
89
+ commands=commands,
90
+ )
91
+
92
+
93
+ def amber_inpcrd_box_volume(path: str) -> float | None:
94
+ """Return periodic box volume from an Amber restart/inpcrd file, if present."""
95
+
96
+ lines = [line.strip() for line in open(path, encoding="utf-8").read().splitlines() if line.strip()]
97
+ if len(lines) < 3:
98
+ return None
99
+ fields = lines[-1].split()
100
+ if len(fields) < 6:
101
+ return None
102
+ try:
103
+ a, b, c, alpha, beta, gamma = (float(value) for value in fields[:6])
104
+ except ValueError:
105
+ return None
106
+ cos_a = cos(radians(alpha))
107
+ cos_b = cos(radians(beta))
108
+ cos_g = cos(radians(gamma))
109
+ factor = 1.0 + 2.0 * cos_a * cos_b * cos_g - cos_a * cos_a - cos_b * cos_b - cos_g * cos_g
110
+ if factor <= 0:
111
+ return None
112
+ return a * b * c * sqrt(factor)
@@ -0,0 +1,170 @@
1
+ """Parse tleap logs into validation-friendly summaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+
10
+ class LeapLogError(ValueError):
11
+ """Raised when a tleap log indicates an unsafe build."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class LeapLogSummary:
16
+ returncode: int
17
+ warnings: list[str] = field(default_factory=list)
18
+ errors: list[str] = field(default_factory=list)
19
+ unknown_residues: list[str] = field(default_factory=list)
20
+ missing_atom_types: list[str] = field(default_factory=list)
21
+ missing_parameters: list[str] = field(default_factory=list)
22
+ atoms_created: list[str] = field(default_factory=list)
23
+ total_charge: float | None = None
24
+ check_output: list[str] = field(default_factory=list)
25
+ log_tail: list[str] = field(default_factory=list)
26
+
27
+ def to_dict(self) -> dict[str, object]:
28
+ return {
29
+ "returncode": self.returncode,
30
+ "warnings": self.warnings,
31
+ "errors": self.errors,
32
+ "unknown_residues": self.unknown_residues,
33
+ "missing_atom_types": self.missing_atom_types,
34
+ "missing_parameters": self.missing_parameters,
35
+ "atoms_created": self.atoms_created,
36
+ "total_charge": self.total_charge,
37
+ "check_output": self.check_output,
38
+ "log_tail": self.log_tail,
39
+ }
40
+
41
+
42
+ _CHARGE_PATTERNS = [
43
+ re.compile(r"Total unperturbed charge:\s*([-+]?\d+(?:\.\d+)?)", re.IGNORECASE),
44
+ re.compile(r"unperturbed charge of the unit:\s*([-+]?\d+(?:\.\d+)?)", re.IGNORECASE),
45
+ ]
46
+
47
+
48
+ def parse_tleap_log(path: str | Path, *, returncode: int = 0) -> LeapLogSummary:
49
+ log_path = Path(path)
50
+ text = log_path.read_text(encoding="utf-8", errors="replace") if log_path.exists() else ""
51
+ return parse_tleap_log_text(text, returncode=returncode)
52
+
53
+
54
+ def parse_tleap_log_text(text: str, *, returncode: int = 0) -> LeapLogSummary:
55
+ warnings: list[str] = []
56
+ errors: list[str] = []
57
+ unknown_residues: list[str] = []
58
+ missing_atom_types: list[str] = []
59
+ missing_parameters: list[str] = []
60
+ atoms_created: list[str] = []
61
+ check_output: list[str] = []
62
+ total_charge: float | None = None
63
+
64
+ for raw_line in text.splitlines():
65
+ line = raw_line.strip()
66
+ lower = line.lower()
67
+ if not line:
68
+ continue
69
+ if "warning:" in lower:
70
+ warnings.append(line)
71
+ if "fatal error" in lower or lower.startswith("error:") or " error!" in lower:
72
+ errors.append(line)
73
+ if "unknown residue" in lower:
74
+ unknown_residues.append(line)
75
+ errors.append(line)
76
+ if "does not have a type" in lower or "could not find type" in lower:
77
+ missing_atom_types.append(line)
78
+ errors.append(line)
79
+ if (
80
+ "could not find bond parameter" in lower
81
+ or "could not find angle parameter" in lower
82
+ or "could not find torsion parameter" in lower
83
+ or "missing parameters" in lower
84
+ ):
85
+ missing_parameters.append(line)
86
+ errors.append(line)
87
+ if "created a new atom named" in lower:
88
+ atoms_created.append(line)
89
+ if lower.startswith("checking") or "unit is ok" in lower:
90
+ check_output.append(line)
91
+ for pattern in _CHARGE_PATTERNS:
92
+ match = pattern.search(line)
93
+ if match:
94
+ total_charge = float(match.group(1))
95
+
96
+ return LeapLogSummary(
97
+ returncode=returncode,
98
+ warnings=warnings,
99
+ errors=_dedupe(errors),
100
+ unknown_residues=_dedupe(unknown_residues),
101
+ missing_atom_types=_dedupe(missing_atom_types),
102
+ missing_parameters=_dedupe(missing_parameters),
103
+ atoms_created=atoms_created,
104
+ total_charge=total_charge,
105
+ check_output=check_output,
106
+ log_tail=_tail_lines(text),
107
+ )
108
+
109
+
110
+ def assert_tleap_success(
111
+ summary: LeapLogSummary,
112
+ *,
113
+ fail_on_warnings: bool,
114
+ context: str,
115
+ ) -> None:
116
+ if summary.returncode != 0:
117
+ raise LeapLogError(
118
+ "\n".join(
119
+ [
120
+ f"{context} tleap exited with code {summary.returncode}.",
121
+ *_summary_details(summary),
122
+ ]
123
+ )
124
+ )
125
+ if summary.unknown_residues:
126
+ raise LeapLogError(f"{context} tleap reported unknown residues: {summary.unknown_residues}")
127
+ if summary.missing_atom_types:
128
+ raise LeapLogError(f"{context} tleap reported missing atom types: {summary.missing_atom_types}")
129
+ if summary.missing_parameters:
130
+ raise LeapLogError(f"{context} tleap reported missing parameters: {summary.missing_parameters}")
131
+ if summary.errors:
132
+ raise LeapLogError(f"{context} tleap reported errors: {summary.errors}")
133
+ if fail_on_warnings and summary.warnings:
134
+ raise LeapLogError(f"{context} tleap warnings are fatal by validation config: {summary.warnings}")
135
+
136
+
137
+ def _dedupe(values: list[str]) -> list[str]:
138
+ seen: set[str] = set()
139
+ deduped: list[str] = []
140
+ for value in values:
141
+ if value in seen:
142
+ continue
143
+ seen.add(value)
144
+ deduped.append(value)
145
+ return deduped
146
+
147
+
148
+ def _summary_details(summary: LeapLogSummary) -> list[str]:
149
+ details: list[str] = []
150
+ if summary.errors:
151
+ details.append(f"errors: {summary.errors}")
152
+ if summary.unknown_residues:
153
+ details.append(f"unknown residues: {summary.unknown_residues}")
154
+ if summary.missing_atom_types:
155
+ details.append(f"missing atom types: {summary.missing_atom_types}")
156
+ if summary.missing_parameters:
157
+ details.append(f"missing parameters: {summary.missing_parameters}")
158
+ if summary.warnings:
159
+ details.append(f"warnings: {summary.warnings}")
160
+ if summary.log_tail:
161
+ details.append("tleap log tail:")
162
+ details.extend(summary.log_tail)
163
+ return details
164
+
165
+
166
+ def _tail_lines(text: str, *, lines: int = 30) -> list[str]:
167
+ stripped = text.strip()
168
+ if not stripped:
169
+ return []
170
+ return stripped.splitlines()[-lines:]
mdprep/leap/report.py ADDED
@@ -0,0 +1,99 @@
1
+ """tleap report writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from mdprep.leap.builder import TLeapStageResult
10
+
11
+
12
+ def write_tleap_reports(
13
+ result: TLeapStageResult,
14
+ *,
15
+ json_path: str | Path,
16
+ markdown_path: str | Path,
17
+ ) -> dict[str, Any]:
18
+ report = result.to_report_dict()
19
+ json_output = Path(json_path)
20
+ markdown_output = Path(markdown_path)
21
+ json_output.parent.mkdir(parents=True, exist_ok=True)
22
+ json_output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
23
+ markdown_output.write_text(_render_markdown(report), encoding="utf-8")
24
+ return report
25
+
26
+
27
+ def _render_markdown(report: dict[str, Any]) -> str:
28
+ lines = [
29
+ "# tleap Report",
30
+ "",
31
+ "## Force Fields",
32
+ "",
33
+ ]
34
+ lines.extend(f"- `{source}`" for source in report["force_fields_sourced"])
35
+ lines.extend(
36
+ [
37
+ "",
38
+ "## Ligands",
39
+ "",
40
+ ]
41
+ )
42
+ ligands = report.get("ligands", [])
43
+ if ligands:
44
+ for ligand in ligands:
45
+ lines.append(
46
+ f"- `{ligand['ligand_id']}`: mol2 `{ligand['final_mol2_path']}`, "
47
+ f"frcmod `{ligand['final_frcmod_path']}`"
48
+ )
49
+ else:
50
+ lines.append("- None")
51
+ lines.extend(["", "### MCPB joint-RESP ligands", ""])
52
+ mcpb_resp_ligands = report.get("mcpb_resp_ligands", [])
53
+ if mcpb_resp_ligands:
54
+ for ligand in mcpb_resp_ligands:
55
+ lines.append(
56
+ f"- `{ligand['ligand_id']}`: final template "
57
+ f"`{ligand['final_mol2_path']}` (provisional mol2 excluded from "
58
+ "the final tLEAP ligand list)"
59
+ )
60
+ else:
61
+ lines.append("- None")
62
+ lines.extend(["", "## Disulfide Bonds", ""])
63
+ bonds = report.get("disulfide_bond_commands", [])
64
+ if bonds:
65
+ lines.extend(f"- `{bond['command']}`" for bond in bonds)
66
+ else:
67
+ lines.append("- None")
68
+ lines.extend(["", "## 12-6-4 Post-processing", ""])
69
+ c4_runs = report.get("c4_postprocessing", [])
70
+ if c4_runs:
71
+ for run in c4_runs:
72
+ lines.append(
73
+ f"- `{run['output_prmtop']}`: atom types "
74
+ + ", ".join(f"`{atom_type}`" for atom_type in run["atom_types"])
75
+ )
76
+ else:
77
+ lines.append("- None")
78
+ lines.extend(
79
+ [
80
+ "",
81
+ "## Outputs",
82
+ "",
83
+ f"- Dry prmtop: `{report['dry_outputs']['prmtop']}`",
84
+ f"- Dry inpcrd: `{report['dry_outputs']['inpcrd']}`",
85
+ f"- Final prmtop: `{report['final_outputs']['prmtop']}`",
86
+ f"- Final inpcrd: `{report['final_outputs']['inpcrd']}`",
87
+ f"- Final PDB: `{report['final_outputs']['pdb']}`",
88
+ "",
89
+ "## Warnings",
90
+ "",
91
+ ]
92
+ )
93
+ warnings = report.get("warnings") or []
94
+ if warnings:
95
+ lines.extend(f"- {warning}" for warning in warnings)
96
+ else:
97
+ lines.append("- None")
98
+ lines.append("")
99
+ return "\n".join(lines)