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,190 @@
|
|
|
1
|
+
"""Deterministic layered Merz-Kollman ESP grid generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import cos, pi, sin, sqrt
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EspGridError(ValueError):
|
|
13
|
+
"""Raised when an ESP grid cannot be generated safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VDW_RADII = {
|
|
17
|
+
"H": 1.20,
|
|
18
|
+
"C": 1.70,
|
|
19
|
+
"N": 1.55,
|
|
20
|
+
"O": 1.52,
|
|
21
|
+
"S": 1.80,
|
|
22
|
+
"P": 1.80,
|
|
23
|
+
"F": 1.47,
|
|
24
|
+
"CL": 1.75,
|
|
25
|
+
"BR": 1.85,
|
|
26
|
+
"I": 1.98,
|
|
27
|
+
"FE": 2.00,
|
|
28
|
+
"ZN": 2.10,
|
|
29
|
+
"MG": 1.73,
|
|
30
|
+
"NA": 2.27,
|
|
31
|
+
"K": 2.75,
|
|
32
|
+
"CA": 2.31,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class EspGrid:
|
|
38
|
+
points: np.ndarray
|
|
39
|
+
atom_indices: list[int]
|
|
40
|
+
shell_scales: list[float]
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> dict[str, object]:
|
|
43
|
+
return {
|
|
44
|
+
"point_count": int(len(self.points)),
|
|
45
|
+
"atom_indices": self.atom_indices,
|
|
46
|
+
"shell_scales": self.shell_scales,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def generate_merz_kollman_grid(
|
|
51
|
+
*,
|
|
52
|
+
elements: list[str],
|
|
53
|
+
coordinates: np.ndarray,
|
|
54
|
+
vdw_scale_factors: list[float],
|
|
55
|
+
point_density_per_square_angstrom: float,
|
|
56
|
+
exclude_inside_vdw_scale: float,
|
|
57
|
+
max_points: int,
|
|
58
|
+
) -> EspGrid:
|
|
59
|
+
coords = np.asarray(coordinates, dtype=float)
|
|
60
|
+
if coords.shape != (len(elements), 3):
|
|
61
|
+
raise EspGridError("Coordinate array shape does not match element list.")
|
|
62
|
+
if not elements:
|
|
63
|
+
raise EspGridError("Cannot generate an ESP grid for zero atoms.")
|
|
64
|
+
if not np.all(np.isfinite(coords)):
|
|
65
|
+
raise EspGridError("ESP-grid coordinates contain non-finite values.")
|
|
66
|
+
if not vdw_scale_factors or any(
|
|
67
|
+
not np.isfinite(scale) or scale <= 0 for scale in vdw_scale_factors
|
|
68
|
+
):
|
|
69
|
+
raise EspGridError("ESP-grid vdW scale factors must be finite, positive, and non-empty.")
|
|
70
|
+
if not np.isfinite(point_density_per_square_angstrom) or point_density_per_square_angstrom <= 0:
|
|
71
|
+
raise EspGridError("ESP-grid surface-point density must be finite and positive.")
|
|
72
|
+
if not np.isfinite(exclude_inside_vdw_scale) or exclude_inside_vdw_scale <= 0:
|
|
73
|
+
raise EspGridError("ESP-grid exclusion scale must be finite and positive.")
|
|
74
|
+
if max_points < len(elements) + 1:
|
|
75
|
+
raise EspGridError("ESP-grid max_points is too small for the number of fitted atoms.")
|
|
76
|
+
|
|
77
|
+
points: list[np.ndarray] = []
|
|
78
|
+
atom_indices: list[int] = []
|
|
79
|
+
shell_scales: list[float] = []
|
|
80
|
+
radii = np.asarray([vdw_radius(element) for element in elements], dtype=float)
|
|
81
|
+
for atom_index, center in enumerate(coords):
|
|
82
|
+
for scale in vdw_scale_factors:
|
|
83
|
+
shell_radius = radii[atom_index] * scale
|
|
84
|
+
point_count = max(
|
|
85
|
+
6,
|
|
86
|
+
int(round(4.0 * pi * shell_radius * shell_radius * point_density_per_square_angstrom)),
|
|
87
|
+
)
|
|
88
|
+
unit_sphere = _fibonacci_sphere(point_count)
|
|
89
|
+
for unit_point in unit_sphere:
|
|
90
|
+
point = center + unit_point * shell_radius
|
|
91
|
+
if _inside_other_atom(
|
|
92
|
+
point,
|
|
93
|
+
coords,
|
|
94
|
+
radii,
|
|
95
|
+
parent_index=atom_index,
|
|
96
|
+
exclude_scale=exclude_inside_vdw_scale,
|
|
97
|
+
):
|
|
98
|
+
continue
|
|
99
|
+
points.append(point)
|
|
100
|
+
atom_indices.append(atom_index)
|
|
101
|
+
shell_scales.append(scale)
|
|
102
|
+
if not points:
|
|
103
|
+
raise EspGridError("No ESP grid points survived vdW exclusion.")
|
|
104
|
+
|
|
105
|
+
deduped_points, deduped_atoms, deduped_scales = _dedupe(points, atom_indices, shell_scales)
|
|
106
|
+
if len(deduped_points) < len(elements) + 1:
|
|
107
|
+
raise EspGridError("Too few ESP grid points were generated for charge fitting.")
|
|
108
|
+
if len(deduped_points) > max_points:
|
|
109
|
+
raise EspGridError(
|
|
110
|
+
f"Merz-Kollman grid contains {len(deduped_points)} points, exceeding "
|
|
111
|
+
f"grid.max_points={max_points}; refusing to thin the RESP sampling grid."
|
|
112
|
+
)
|
|
113
|
+
return EspGrid(
|
|
114
|
+
points=np.asarray(deduped_points, dtype=float),
|
|
115
|
+
atom_indices=deduped_atoms,
|
|
116
|
+
shell_scales=deduped_scales,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def vdw_radius(element: str) -> float:
|
|
121
|
+
key = element.strip().upper()
|
|
122
|
+
if key not in VDW_RADII:
|
|
123
|
+
raise EspGridError(f"No vdW radius is configured for element {element!r}.")
|
|
124
|
+
return VDW_RADII[key]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def write_grid_xyz(grid: EspGrid, path: str | Path) -> None:
|
|
128
|
+
output = Path(path)
|
|
129
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
lines = [str(len(grid.points)), "mdprep ESP grid"]
|
|
131
|
+
for point in grid.points:
|
|
132
|
+
lines.append(f"X {point[0]:.8f} {point[1]:.8f} {point[2]:.8f}")
|
|
133
|
+
output.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_grid_xyz(path: str | Path) -> np.ndarray:
|
|
137
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
138
|
+
data = []
|
|
139
|
+
for line in lines[2:]:
|
|
140
|
+
fields = line.split()
|
|
141
|
+
if len(fields) == 4:
|
|
142
|
+
data.append([float(fields[1]), float(fields[2]), float(fields[3])])
|
|
143
|
+
return np.asarray(data, dtype=float)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fibonacci_sphere(count: int) -> np.ndarray:
|
|
147
|
+
points = []
|
|
148
|
+
golden_angle = pi * (3.0 - sqrt(5.0))
|
|
149
|
+
for index in range(count):
|
|
150
|
+
y = 1.0 - (2.0 * index + 1.0) / count
|
|
151
|
+
radius = sqrt(max(0.0, 1.0 - y * y))
|
|
152
|
+
theta = golden_angle * index
|
|
153
|
+
points.append([cos(theta) * radius, y, sin(theta) * radius])
|
|
154
|
+
return np.asarray(points, dtype=float)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _inside_other_atom(
|
|
158
|
+
point: np.ndarray,
|
|
159
|
+
coords: np.ndarray,
|
|
160
|
+
radii: np.ndarray,
|
|
161
|
+
*,
|
|
162
|
+
parent_index: int,
|
|
163
|
+
exclude_scale: float,
|
|
164
|
+
) -> bool:
|
|
165
|
+
for index, center in enumerate(coords):
|
|
166
|
+
if index == parent_index:
|
|
167
|
+
continue
|
|
168
|
+
if np.linalg.norm(point - center) < radii[index] * exclude_scale:
|
|
169
|
+
return True
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _dedupe(
|
|
174
|
+
points: list[np.ndarray],
|
|
175
|
+
atom_indices: list[int],
|
|
176
|
+
shell_scales: list[float],
|
|
177
|
+
) -> tuple[list[np.ndarray], list[int], list[float]]:
|
|
178
|
+
seen: set[tuple[int, int, int]] = set()
|
|
179
|
+
deduped_points: list[np.ndarray] = []
|
|
180
|
+
deduped_atoms: list[int] = []
|
|
181
|
+
deduped_scales: list[float] = []
|
|
182
|
+
for point, atom_index, scale in zip(points, atom_indices, shell_scales, strict=True):
|
|
183
|
+
key = tuple(int(round(value * 10000.0)) for value in point)
|
|
184
|
+
if key in seen:
|
|
185
|
+
continue
|
|
186
|
+
seen.add(key)
|
|
187
|
+
deduped_points.append(point)
|
|
188
|
+
deduped_atoms.append(atom_index)
|
|
189
|
+
deduped_scales.append(scale)
|
|
190
|
+
return deduped_points, deduped_atoms, deduped_scales
|
mdprep/cli.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Typer command-line interface for mdprep."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from mdprep import __version__
|
|
13
|
+
from mdprep.config.loader import load_manifest
|
|
14
|
+
from mdprep.config.models import McpbParameterComparisonConfig
|
|
15
|
+
from mdprep.metals.parameter_comparison import (
|
|
16
|
+
McpbParameterComparisonError,
|
|
17
|
+
compare_mcpb_parameter_files,
|
|
18
|
+
)
|
|
19
|
+
from mdprep.md.roe_brooks import RoeBrooksError, run_roe_brooks
|
|
20
|
+
from mdprep.structure.inspect import InspectionSummary
|
|
21
|
+
from mdprep.structure.normalize import StructureNormalizationError
|
|
22
|
+
from mdprep.structure.pdb import PdbParseError, VALID_ALTLOC_POLICIES
|
|
23
|
+
from mdprep.workflows.init import generate_starter_manifest
|
|
24
|
+
from mdprep.workflows.inspect import inspect_structure
|
|
25
|
+
from mdprep.workflows.prepare import PrepareWorkflowError, prepare_system
|
|
26
|
+
from mdprep.workflows.selftest import run_selftest
|
|
27
|
+
from mdprep.workflows.validate import validate_system
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = typer.Typer(
|
|
31
|
+
name="mdprep",
|
|
32
|
+
help="Reproducible Amber MD preparation workflow manager.",
|
|
33
|
+
no_args_is_help=True,
|
|
34
|
+
)
|
|
35
|
+
console = Console()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _version_callback(value: bool) -> None:
|
|
39
|
+
if value:
|
|
40
|
+
console.print(f"mdprep {__version__}")
|
|
41
|
+
raise typer.Exit()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback()
|
|
45
|
+
def callback(
|
|
46
|
+
version: bool = typer.Option(
|
|
47
|
+
False,
|
|
48
|
+
"--version",
|
|
49
|
+
callback=_version_callback,
|
|
50
|
+
is_eager=True,
|
|
51
|
+
help="Print the mdprep version and exit.",
|
|
52
|
+
),
|
|
53
|
+
) -> None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("config-check")
|
|
58
|
+
def config_check(
|
|
59
|
+
paths: list[Path] = typer.Argument(..., help="One or more YAML manifest paths."),
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Validate one or more manifest files."""
|
|
62
|
+
|
|
63
|
+
table = Table(title="Manifest validation")
|
|
64
|
+
table.add_column("File")
|
|
65
|
+
table.add_column("Status")
|
|
66
|
+
table.add_column("Message")
|
|
67
|
+
|
|
68
|
+
failed = False
|
|
69
|
+
for path in paths:
|
|
70
|
+
try:
|
|
71
|
+
load_manifest(path)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
failed = True
|
|
74
|
+
table.add_row(str(path), "FAIL", str(exc))
|
|
75
|
+
else:
|
|
76
|
+
table.add_row(str(path), "PASS", "valid")
|
|
77
|
+
|
|
78
|
+
console.print(table)
|
|
79
|
+
if failed:
|
|
80
|
+
raise typer.Exit(1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.command("inspect")
|
|
84
|
+
def inspect_command(
|
|
85
|
+
input_structure: Path = typer.Argument(..., help="Input PDB file."),
|
|
86
|
+
altloc_policy: str | None = typer.Option(
|
|
87
|
+
None,
|
|
88
|
+
"--altloc-policy",
|
|
89
|
+
help="Alternate-location policy: highest_occupancy, first, or fail.",
|
|
90
|
+
),
|
|
91
|
+
disulfide_cutoff: float | None = typer.Option(
|
|
92
|
+
None,
|
|
93
|
+
"--disulfide-cutoff",
|
|
94
|
+
help="SG-SG distance cutoff for possible disulfides.",
|
|
95
|
+
),
|
|
96
|
+
json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
|
|
97
|
+
config: Path | None = typer.Option(
|
|
98
|
+
None,
|
|
99
|
+
"--config",
|
|
100
|
+
help="Manifest path to supply structure and disulfide inspection defaults.",
|
|
101
|
+
),
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Inspect an input structure."""
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
config_altloc_policy = None
|
|
107
|
+
config_disulfide_cutoff = None
|
|
108
|
+
if config is not None:
|
|
109
|
+
manifest = load_manifest(config)
|
|
110
|
+
config_altloc_policy = manifest.structure.altloc_policy
|
|
111
|
+
config_disulfide_cutoff = manifest.disulfides.detection_cutoff_angstrom
|
|
112
|
+
|
|
113
|
+
selected_altloc_policy = altloc_policy or config_altloc_policy or "highest_occupancy"
|
|
114
|
+
selected_disulfide_cutoff = disulfide_cutoff or config_disulfide_cutoff or 2.2
|
|
115
|
+
if selected_altloc_policy not in VALID_ALTLOC_POLICIES:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"Invalid altloc policy {selected_altloc_policy!r}; expected one of {sorted(VALID_ALTLOC_POLICIES)}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
summary = inspect_structure(
|
|
121
|
+
input_structure,
|
|
122
|
+
altloc_policy=selected_altloc_policy, # type: ignore[arg-type]
|
|
123
|
+
disulfide_cutoff_angstrom=selected_disulfide_cutoff,
|
|
124
|
+
)
|
|
125
|
+
if json_output:
|
|
126
|
+
console.print(json.dumps(summary.to_dict(), indent=2, sort_keys=True))
|
|
127
|
+
else:
|
|
128
|
+
_render_inspection(summary)
|
|
129
|
+
except (FileNotFoundError, PdbParseError, ValueError) as exc:
|
|
130
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
131
|
+
raise typer.Exit(1) from exc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.command("init")
|
|
135
|
+
def init_command(
|
|
136
|
+
input_structure: Path = typer.Argument(..., help="Input PDB/mmCIF file."),
|
|
137
|
+
output: Path = typer.Option(Path("system.yaml"), "-o", "--output", help="Output YAML path."),
|
|
138
|
+
overwrite: bool = typer.Option(
|
|
139
|
+
False,
|
|
140
|
+
"--overwrite",
|
|
141
|
+
"--force",
|
|
142
|
+
help="Overwrite an existing output manifest.",
|
|
143
|
+
),
|
|
144
|
+
forcefield: str = typer.Option("ff14SB", "--forcefield", help="Protein force field."),
|
|
145
|
+
water_model: str = typer.Option("TIP3P", "--water-model", help="Water model."),
|
|
146
|
+
ph: float = typer.Option(7.0, "--ph", help="Target pH for starter manifest defaults."),
|
|
147
|
+
protonation_method: str = typer.Option(
|
|
148
|
+
"manual_only",
|
|
149
|
+
"--protonation-method",
|
|
150
|
+
help="Initial protonation method: manual_only, propka, or propka_xtb_his.",
|
|
151
|
+
),
|
|
152
|
+
output_dir: str | None = typer.Option(
|
|
153
|
+
None,
|
|
154
|
+
"--output-dir",
|
|
155
|
+
help="Preparation output directory. Defaults to prepared/<input stem>.",
|
|
156
|
+
),
|
|
157
|
+
include_ligand_placeholders: bool = typer.Option(
|
|
158
|
+
False,
|
|
159
|
+
"--include-ligand-placeholders",
|
|
160
|
+
help="Add active placeholder ligand blocks for detected ligands. Review net charges before use.",
|
|
161
|
+
),
|
|
162
|
+
) -> None:
|
|
163
|
+
"""Create an initial manifest from an input structure."""
|
|
164
|
+
|
|
165
|
+
if forcefield not in {"ff14SB", "ff19SB"}:
|
|
166
|
+
console.print("[red]Error:[/red] --forcefield must be ff14SB or ff19SB")
|
|
167
|
+
raise typer.Exit(1)
|
|
168
|
+
if water_model not in {"TIP3P", "OPC"}:
|
|
169
|
+
console.print("[red]Error:[/red] --water-model must be TIP3P or OPC")
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
if protonation_method not in {"manual_only", "propka", "propka_xtb_his"}:
|
|
172
|
+
console.print("[red]Error:[/red] --protonation-method must be manual_only, propka, or propka_xtb_his")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
try:
|
|
175
|
+
manifest_path = generate_starter_manifest(
|
|
176
|
+
input_structure,
|
|
177
|
+
output_path=output,
|
|
178
|
+
overwrite=overwrite,
|
|
179
|
+
forcefield=forcefield,
|
|
180
|
+
water_model=water_model,
|
|
181
|
+
ph=ph,
|
|
182
|
+
output_dir=output_dir,
|
|
183
|
+
protonation_method=protonation_method,
|
|
184
|
+
include_ligand_placeholders=include_ligand_placeholders,
|
|
185
|
+
)
|
|
186
|
+
except (FileExistsError, FileNotFoundError, PdbParseError, ValueError) as exc:
|
|
187
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
188
|
+
raise typer.Exit(1) from exc
|
|
189
|
+
console.print(f"Wrote starter manifest: {manifest_path}")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.command("prepare")
|
|
193
|
+
def prepare_command(
|
|
194
|
+
manifest: Path = typer.Argument(..., help="YAML manifest path."),
|
|
195
|
+
stop_after: str | None = typer.Option(
|
|
196
|
+
None,
|
|
197
|
+
"--stop-after",
|
|
198
|
+
help=(
|
|
199
|
+
"Stop after a supported workflow stage: structure, protonation, refinement, "
|
|
200
|
+
"ligands, metals, tleap, or md."
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite mdprep-generated outputs."),
|
|
204
|
+
resume: bool = typer.Option(
|
|
205
|
+
False,
|
|
206
|
+
"--resume",
|
|
207
|
+
help=(
|
|
208
|
+
"Resume from a matching converged ASH refinement after an interrupted "
|
|
209
|
+
"downstream stage."
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
quiet: bool = typer.Option(False, "--quiet", help="Reduce command output."),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Prepare an Amber system from a manifest."""
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
result = prepare_system(
|
|
218
|
+
manifest,
|
|
219
|
+
stop_after=stop_after,
|
|
220
|
+
overwrite=overwrite,
|
|
221
|
+
resume=resume,
|
|
222
|
+
)
|
|
223
|
+
except (
|
|
224
|
+
PrepareWorkflowError,
|
|
225
|
+
StructureNormalizationError,
|
|
226
|
+
FileExistsError,
|
|
227
|
+
FileNotFoundError,
|
|
228
|
+
PdbParseError,
|
|
229
|
+
ValueError,
|
|
230
|
+
) as exc:
|
|
231
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
232
|
+
raise typer.Exit(1) from exc
|
|
233
|
+
if not quiet:
|
|
234
|
+
console.print(f"Wrote output PDB: {result.output_path}")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@app.command("validate")
|
|
238
|
+
def validate_command(
|
|
239
|
+
prmtop: Path = typer.Argument(..., help="Amber topology file."),
|
|
240
|
+
inpcrd: Path = typer.Argument(..., help="Amber coordinate file."),
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Validate a prepared Amber system."""
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
validate_system(prmtop, inpcrd)
|
|
246
|
+
except ValueError as exc:
|
|
247
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
248
|
+
raise typer.Exit(1) from exc
|
|
249
|
+
console.print("Validation passed")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.command("run-md")
|
|
253
|
+
def run_md_command(
|
|
254
|
+
manifest_path: Path = typer.Argument(..., help="Manifest containing molecular_dynamics settings."),
|
|
255
|
+
prmtop: Path | None = typer.Option(
|
|
256
|
+
None,
|
|
257
|
+
"--prmtop",
|
|
258
|
+
help="Amber topology; defaults to <project.output_dir>/final/system.prmtop.",
|
|
259
|
+
),
|
|
260
|
+
inpcrd: Path | None = typer.Option(
|
|
261
|
+
None,
|
|
262
|
+
"--inpcrd",
|
|
263
|
+
help="Amber coordinates; defaults to <project.output_dir>/final/system.inpcrd.",
|
|
264
|
+
),
|
|
265
|
+
output_dir: Path | None = typer.Option(
|
|
266
|
+
None,
|
|
267
|
+
"--output-dir",
|
|
268
|
+
help="MD output directory; defaults to <project.output_dir>/md/roe_brooks_2020.",
|
|
269
|
+
),
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Run manifest-configured Roe--Brooks MD from an existing Amber system."""
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
manifest = load_manifest(manifest_path, resolve_paths=True)
|
|
275
|
+
project_output = Path(manifest.project.output_dir)
|
|
276
|
+
result = run_roe_brooks(
|
|
277
|
+
prmtop_path=prmtop or project_output / "final" / "system.prmtop",
|
|
278
|
+
inpcrd_path=inpcrd or project_output / "final" / "system.inpcrd",
|
|
279
|
+
output_dir=(
|
|
280
|
+
output_dir
|
|
281
|
+
or project_output / "md" / "roe_brooks_2020"
|
|
282
|
+
),
|
|
283
|
+
config=manifest.molecular_dynamics,
|
|
284
|
+
)
|
|
285
|
+
except (FileNotFoundError, RoeBrooksError, ValueError) as exc:
|
|
286
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
287
|
+
raise typer.Exit(1) from exc
|
|
288
|
+
console.print(
|
|
289
|
+
f"MD completed on {result.platform}; final coordinates: {result.final_pdb}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@app.command("compare-mcpb")
|
|
294
|
+
def compare_mcpb_command(
|
|
295
|
+
candidate_frcmod: Path = typer.Argument(
|
|
296
|
+
..., help="Candidate MCPB.py-generated frcmod file."
|
|
297
|
+
),
|
|
298
|
+
reference_frcmod: Path = typer.Argument(
|
|
299
|
+
..., help="Selected reference MCPB.py-generated frcmod file."
|
|
300
|
+
),
|
|
301
|
+
output_dir: Path = typer.Option(
|
|
302
|
+
Path("mcpb_parameter_comparison"),
|
|
303
|
+
"--output-dir",
|
|
304
|
+
help="Directory for JSON, CSV, and Markdown comparison reports.",
|
|
305
|
+
),
|
|
306
|
+
candidate_label: str = typer.Option("xTB MCPB", "--candidate-label"),
|
|
307
|
+
reference_label: str = typer.Option("MCPB reference", "--reference-label"),
|
|
308
|
+
require_exact_term_set: bool = typer.Option(
|
|
309
|
+
True,
|
|
310
|
+
"--require-exact-term-set/--allow-term-set-difference",
|
|
311
|
+
help="Require candidate and reference to contain identical MCPB Seminario terms.",
|
|
312
|
+
),
|
|
313
|
+
max_bond_relative_rmse: float | None = typer.Option(
|
|
314
|
+
None, "--max-bond-relative-rmse"
|
|
315
|
+
),
|
|
316
|
+
max_angle_relative_rmse: float | None = typer.Option(
|
|
317
|
+
None, "--max-angle-relative-rmse"
|
|
318
|
+
),
|
|
319
|
+
max_bond_distance_rmse: float | None = typer.Option(
|
|
320
|
+
None, "--max-bond-distance-rmse"
|
|
321
|
+
),
|
|
322
|
+
max_angle_value_rmse: float | None = typer.Option(
|
|
323
|
+
None, "--max-angle-value-rmse"
|
|
324
|
+
),
|
|
325
|
+
report_only: bool = typer.Option(
|
|
326
|
+
False,
|
|
327
|
+
"--report-only",
|
|
328
|
+
help="Write a failing report without returning a nonzero exit status.",
|
|
329
|
+
),
|
|
330
|
+
) -> None:
|
|
331
|
+
"""Compare MCPB Seminario bond/angle terms against a selected reference."""
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
config = McpbParameterComparisonConfig(
|
|
335
|
+
reference_frcmod=str(reference_frcmod),
|
|
336
|
+
candidate_label=candidate_label,
|
|
337
|
+
reference_label=reference_label,
|
|
338
|
+
require_exact_term_set=require_exact_term_set,
|
|
339
|
+
max_bond_force_constant_relative_rmse=max_bond_relative_rmse,
|
|
340
|
+
max_angle_force_constant_relative_rmse=max_angle_relative_rmse,
|
|
341
|
+
max_bond_equilibrium_distance_rmse_angstrom=max_bond_distance_rmse,
|
|
342
|
+
max_angle_equilibrium_value_rmse_degrees=max_angle_value_rmse,
|
|
343
|
+
fail_on_thresholds=not report_only,
|
|
344
|
+
)
|
|
345
|
+
result = compare_mcpb_parameter_files(
|
|
346
|
+
candidate_frcmod,
|
|
347
|
+
config=config,
|
|
348
|
+
output_dir=output_dir,
|
|
349
|
+
)
|
|
350
|
+
except (McpbParameterComparisonError, ValueError) as exc:
|
|
351
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
352
|
+
raise typer.Exit(1) from exc
|
|
353
|
+
status = {
|
|
354
|
+
"pass": "PASS",
|
|
355
|
+
"fail": "FAIL (report only)",
|
|
356
|
+
"report_only": "REPORT ONLY (no numeric acceptance thresholds)",
|
|
357
|
+
}[result.status]
|
|
358
|
+
console.print(f"MCPB parameter comparison: {status}")
|
|
359
|
+
console.print(f"Wrote report: {result.json_path}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command("selftest")
|
|
363
|
+
def selftest_command(
|
|
364
|
+
quick: bool = typer.Option(False, "--quick", help="Run package-level checks only."),
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Run package self-tests that do not require external chemistry tools."""
|
|
367
|
+
|
|
368
|
+
summary = run_selftest(quick=quick, console=console)
|
|
369
|
+
if not summary.passed:
|
|
370
|
+
raise typer.Exit(1)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def main() -> None:
|
|
374
|
+
app()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _render_inspection(summary: InspectionSummary) -> None:
|
|
378
|
+
data = summary.to_dict()
|
|
379
|
+
counts = data["counts"]
|
|
380
|
+
assert isinstance(counts, dict)
|
|
381
|
+
|
|
382
|
+
overview = Table(title="Structure summary")
|
|
383
|
+
overview.add_column("Field")
|
|
384
|
+
overview.add_column("Value")
|
|
385
|
+
overview.add_row("Path", str(data["path"]))
|
|
386
|
+
overview.add_row("Atoms", str(data["total_atoms"]))
|
|
387
|
+
overview.add_row("Residues", str(data["total_residues"]))
|
|
388
|
+
overview.add_row("MODEL records", str(data["model_count"]))
|
|
389
|
+
overview.add_row("Used MODEL", str(data["used_model"]))
|
|
390
|
+
overview.add_row("Protein residues", str(counts["protein_residues"]))
|
|
391
|
+
overview.add_row("Water residues", str(counts["water_residues"]))
|
|
392
|
+
overview.add_row("Heterogen residues", str(counts["heterogen_residues"]))
|
|
393
|
+
overview.add_row("Likely ligands/cofactors", str(counts["likely_ligands"]))
|
|
394
|
+
console.print(overview)
|
|
395
|
+
|
|
396
|
+
chains = Table(title="Chains")
|
|
397
|
+
chains.add_column("Chain ID")
|
|
398
|
+
chains.add_column("Display")
|
|
399
|
+
for chain in data["chains"]:
|
|
400
|
+
assert isinstance(chain, dict)
|
|
401
|
+
chains.add_row(str(chain["chain_id"]), str(chain["display"]))
|
|
402
|
+
console.print(chains)
|
|
403
|
+
|
|
404
|
+
_render_residue_table("Likely ligands/cofactors", summary.likely_ligands)
|
|
405
|
+
_render_residue_table("Histidines", summary.histidines)
|
|
406
|
+
_render_residue_table("Titratable residues", summary.titratable_residues)
|
|
407
|
+
_render_disulfide_table(summary)
|
|
408
|
+
|
|
409
|
+
if summary.structure.warnings:
|
|
410
|
+
warnings = Table(title="Warnings")
|
|
411
|
+
warnings.add_column("Message")
|
|
412
|
+
for warning in summary.structure.warnings:
|
|
413
|
+
warnings.add_row(warning)
|
|
414
|
+
console.print(warnings)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _render_residue_table(title: str, residues: list[object]) -> None:
|
|
418
|
+
table = Table(title=title)
|
|
419
|
+
table.add_column("Chain")
|
|
420
|
+
table.add_column("Resname")
|
|
421
|
+
table.add_column("Resid")
|
|
422
|
+
table.add_column("Icode")
|
|
423
|
+
table.add_column("Atoms")
|
|
424
|
+
for residue in residues:
|
|
425
|
+
residue_id = residue.id # type: ignore[attr-defined]
|
|
426
|
+
table.add_row(
|
|
427
|
+
residue_id.chain_id or "<blank>",
|
|
428
|
+
residue_id.resname,
|
|
429
|
+
str(residue_id.resid),
|
|
430
|
+
residue_id.icode or "",
|
|
431
|
+
str(len(residue.atoms)), # type: ignore[attr-defined]
|
|
432
|
+
)
|
|
433
|
+
console.print(table)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _render_disulfide_table(summary: InspectionSummary) -> None:
|
|
437
|
+
table = Table(title="Possible disulfides")
|
|
438
|
+
table.add_column("Residue A")
|
|
439
|
+
table.add_column("Residue B")
|
|
440
|
+
table.add_column("Distance (angstrom)")
|
|
441
|
+
for candidate in summary.possible_disulfides:
|
|
442
|
+
table.add_row(
|
|
443
|
+
candidate.a.display(),
|
|
444
|
+
candidate.b.display(),
|
|
445
|
+
f"{candidate.distance_angstrom:.3f}",
|
|
446
|
+
)
|
|
447
|
+
console.print(table)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
main()
|