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,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()
@@ -0,0 +1,9 @@
1
+ """Configuration models and loading helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mdprep.config.loader import load_manifest
6
+ from mdprep.config.models import ManifestConfig
7
+
8
+ __all__ = ["ManifestConfig", "load_manifest"]
9
+