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,125 @@
1
+ """Numerically neutral compatibility adapter for MCPB.py Seminario parsing.
2
+
3
+ Some real, symmetric Cartesian Hessians yield complex-conjugate eigenpairs for
4
+ MCPB.py's non-symmetric 3x3 interatomic blocks. Their projected force constant
5
+ can be exactly real but retain NumPy's complex scalar type, which MCPB.py cannot
6
+ round. This adapter converts only negligible-imaginary scalar results to real
7
+ numbers and fails on any material imaginary component.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import runpy
14
+ import sys
15
+ from collections.abc import Callable, Sequence
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+
21
+
22
+ class McpbSeminarioCompatibilityError(ValueError):
23
+ """Raised when a projected MCPB value is materially complex."""
24
+
25
+
26
+ def coerce_negligible_imaginary(
27
+ value: Any,
28
+ *,
29
+ label: str,
30
+ relative_tolerance: float = 1.0e-10,
31
+ ) -> Any:
32
+ """Return the real part only when the imaginary residual is negligible."""
33
+
34
+ if not np.iscomplexobj(value):
35
+ return value
36
+ scalar = complex(value)
37
+ limit = relative_tolerance * max(1.0, abs(scalar.real))
38
+ if abs(scalar.imag) > limit:
39
+ raise McpbSeminarioCompatibilityError(
40
+ f"{label} is materially complex: {scalar!r}; allowed imaginary "
41
+ f"magnitude is {limit:.3e}. The Hessian cannot be passed safely to MCPB.py."
42
+ )
43
+ return float(scalar.real)
44
+
45
+
46
+ def _coerce_result(value: Any, *, label: str, counter: list[int]) -> Any:
47
+ if isinstance(value, tuple):
48
+ return tuple(
49
+ _coerce_result(item, label=f"{label}[{index}]", counter=counter)
50
+ for index, item in enumerate(value)
51
+ )
52
+ converted = coerce_negligible_imaginary(value, label=label)
53
+ if converted is not value:
54
+ counter[0] += 1
55
+ return converted
56
+
57
+
58
+ def run_mcpb_with_compatibility(
59
+ mcpb_script: str | Path,
60
+ mcpb_args: Sequence[str],
61
+ ) -> None:
62
+ """Run MCPB.py after installing the strict scalar-type compatibility shim."""
63
+
64
+ script = Path(mcpb_script).resolve()
65
+ if not script.is_file() or script.stat().st_size == 0:
66
+ raise McpbSeminarioCompatibilityError(
67
+ f"MCPB.py script is missing or empty: {script}"
68
+ )
69
+ try:
70
+ from pymsmt.mcpb import gene_final_frcmod_file as seminario
71
+ except Exception as exc: # pragma: no cover - depends on external AmberTools
72
+ raise McpbSeminarioCompatibilityError(
73
+ "MCPB.py compatibility adapter could not import pymsmt from the active "
74
+ "Python environment. Run mdprep from the AmberTools environment."
75
+ ) from exc
76
+
77
+ counter = [0]
78
+ originals = {
79
+ "get_bond_fc_with_sem": seminario.get_bond_fc_with_sem,
80
+ "get_ang_fc": seminario.get_ang_fc,
81
+ "get_mod_ang_fc": seminario.get_mod_ang_fc,
82
+ }
83
+
84
+ def wrap(name: str) -> Callable[..., Any]:
85
+ original = originals[name]
86
+
87
+ def compatible(*args: Any, **kwargs: Any) -> Any:
88
+ return _coerce_result(
89
+ original(*args, **kwargs),
90
+ label=name,
91
+ counter=counter,
92
+ )
93
+
94
+ return compatible
95
+
96
+ for name in originals:
97
+ setattr(seminario, name, wrap(name))
98
+ previous_argv = sys.argv
99
+ sys.argv = [str(script), *mcpb_args]
100
+ try:
101
+ runpy.run_path(str(script), run_name="__main__")
102
+ finally:
103
+ sys.argv = previous_argv
104
+ for name, original in originals.items():
105
+ setattr(seminario, name, original)
106
+ print(
107
+ "[mdprep] MCPB Seminario compatibility adapter converted "
108
+ f"{counter[0]} negligible-imaginary scalar value(s) to real values."
109
+ )
110
+
111
+
112
+ def main(argv: Sequence[str] | None = None) -> int:
113
+ parser = argparse.ArgumentParser(description=__doc__)
114
+ parser.add_argument("--mcpb-script", required=True)
115
+ parser.add_argument("mcpb_args", nargs=argparse.REMAINDER)
116
+ options = parser.parse_args(argv)
117
+ mcpb_args = list(options.mcpb_args)
118
+ if mcpb_args[:1] == ["--"]:
119
+ mcpb_args = mcpb_args[1:]
120
+ run_mcpb_with_compatibility(options.mcpb_script, mcpb_args)
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__": # pragma: no cover - exercised through MCPB external tests
125
+ raise SystemExit(main())
@@ -0,0 +1,294 @@
1
+ """Amber Li/Merz nonbonded ion parameter preparation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ import os
7
+ from pathlib import Path
8
+ import shutil
9
+
10
+ from mdprep.config.models import MetalSiteConfig
11
+ from mdprep.metals.coordination import ResolvedMetalIon, ResolvedMetalSite
12
+
13
+
14
+ class NonbondedMetalError(ValueError):
15
+ """Raised when the requested Amber ion model is unavailable or inconsistent."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class AmberIonParameter:
20
+ element: str
21
+ charge: int
22
+ atom_type: str
23
+ mass: float
24
+ rmin_over_2_angstrom: float
25
+ epsilon_kcal_mol: float
26
+ parameter_set: str
27
+ water_model: str
28
+ frcmod_name: str
29
+ frcmod_path: Path
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return {
33
+ "element": self.element,
34
+ "charge": self.charge,
35
+ "atom_type": self.atom_type,
36
+ "mass": self.mass,
37
+ "rmin_over_2_angstrom": self.rmin_over_2_angstrom,
38
+ "epsilon_kcal_mol": self.epsilon_kcal_mol,
39
+ "parameter_set": self.parameter_set,
40
+ "water_model": self.water_model,
41
+ "frcmod_name": self.frcmod_name,
42
+ "frcmod_path": str(self.frcmod_path),
43
+ }
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class NonbondedIonArtifact:
48
+ ion: ResolvedMetalIon
49
+ parameter: AmberIonParameter
50
+ mol2_path: Path
51
+ leap_variable: str
52
+
53
+ def to_dict(self) -> dict[str, object]:
54
+ return {
55
+ **self.ion.to_dict(),
56
+ "parameter": self.parameter.to_dict(),
57
+ "mol2_path": str(self.mol2_path),
58
+ "leap_variable": self.leap_variable,
59
+ }
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class NonbondedSiteResult:
64
+ site_id: str
65
+ parameter_set: str
66
+ ions: tuple[NonbondedIonArtifact, ...]
67
+ leap_commands: tuple[str, ...]
68
+
69
+ def to_dict(self) -> dict[str, object]:
70
+ return {
71
+ "site_id": self.site_id,
72
+ "model": "nonbonded",
73
+ "parameter_set": self.parameter_set,
74
+ "ions": [ion.to_dict() for ion in self.ions],
75
+ "leap_commands": list(self.leap_commands),
76
+ }
77
+
78
+
79
+ def prepare_nonbonded_site(
80
+ site: ResolvedMetalSite,
81
+ *,
82
+ water_model: str,
83
+ output_dir: str | Path,
84
+ amberhome: str | Path | None = None,
85
+ ) -> NonbondedSiteResult:
86
+ if site.config.model != "nonbonded" or site.config.nonbonded is None:
87
+ raise NonbondedMetalError(f"Metal site {site.config.id!r} is not a nonbonded site.")
88
+ # Metal setup commands are composed into tleap scripts executed from
89
+ # several different stage directories. Resolve generated templates once
90
+ # so every preserved script remains independently runnable.
91
+ output = Path(output_dir).resolve()
92
+ output.mkdir(parents=True, exist_ok=True)
93
+ parameter_set = _canonical_parameter_set(site.config.nonbonded.parameter_set)
94
+ amber_root = resolve_amberhome(amberhome)
95
+
96
+ artifacts: list[NonbondedIonArtifact] = []
97
+ commands: list[str] = []
98
+ defined_types: set[str] = set()
99
+ loaded_frcmods: set[Path] = set()
100
+ residue_templates: dict[str, tuple[str, int, str]] = {}
101
+ for index, ion in enumerate(site.ions, start=1):
102
+ parameter = load_amber_ion_parameter(
103
+ element=ion.element,
104
+ charge=ion.charge,
105
+ water_model=water_model,
106
+ parameter_set=parameter_set,
107
+ amberhome=amber_root,
108
+ )
109
+ residue_name = ion.residue.id.resname
110
+ atom_name = ion.atom.name
111
+ signature = (ion.element, ion.charge, atom_name)
112
+ previous = residue_templates.get(residue_name)
113
+ if previous is not None and previous != signature:
114
+ raise NonbondedMetalError(
115
+ f"Metal residue name {residue_name!r} is used with incompatible ion templates: "
116
+ f"{previous} and {signature}. Use distinct PDB residue names."
117
+ )
118
+ residue_templates[residue_name] = signature
119
+ mol2_path = output / f"{site.config.id}.{index}.{residue_name}.mol2"
120
+ write_single_ion_mol2(ion, parameter, mol2_path)
121
+ variable = _leap_variable(site.config, index, residue_name)
122
+ if parameter.atom_type not in defined_types:
123
+ commands.extend(
124
+ [
125
+ "addAtomTypes {",
126
+ f' {{ "{parameter.atom_type}" "{ion.element}" "sp3" }}',
127
+ "}",
128
+ ]
129
+ )
130
+ defined_types.add(parameter.atom_type)
131
+ if parameter.frcmod_path not in loaded_frcmods:
132
+ commands.append(f"loadamberparams {parameter.frcmod_path}")
133
+ loaded_frcmods.add(parameter.frcmod_path)
134
+ if previous is None:
135
+ commands.append(f"{variable} = loadmol2 {mol2_path}")
136
+ if variable != residue_name:
137
+ commands.append(f"{residue_name} = {variable}")
138
+ artifacts.append(
139
+ NonbondedIonArtifact(
140
+ ion=ion,
141
+ parameter=parameter,
142
+ mol2_path=mol2_path,
143
+ leap_variable=variable,
144
+ )
145
+ )
146
+ return NonbondedSiteResult(
147
+ site_id=site.config.id,
148
+ parameter_set=parameter_set,
149
+ ions=tuple(artifacts),
150
+ leap_commands=tuple(commands),
151
+ )
152
+
153
+
154
+ def ion_frcmod_name(*, charge: int, water_model: str, parameter_set: str) -> str:
155
+ """Return the exact AmberTools Li/Merz frcmod filename."""
156
+
157
+ if charge not in {1, 2, 3, 4}:
158
+ raise NonbondedMetalError("Amber Li/Merz ion models support charges +1 through +4.")
159
+ water = water_model.lower()
160
+ if water not in {"tip3p", "opc"}:
161
+ raise NonbondedMetalError(
162
+ f"mdprep supports Li/Merz metal parameters only for configured TIP3P or OPC, not {water_model}."
163
+ )
164
+ family = _canonical_parameter_set(parameter_set)
165
+ suffix = {"12_6": "126", "12_6_4": "1264", "hfe": "hfe", "iod": "iod"}[family]
166
+ if water == "opc":
167
+ return f"frcmod.ionslm_{suffix}_opc"
168
+ if charge == 1:
169
+ if family in {"12_6", "hfe"}:
170
+ return "frcmod.ions1lm_126_tip3p"
171
+ if family == "iod":
172
+ return "frcmod.ions1lm_iod"
173
+ return "frcmod.ions1lm_1264_tip3p"
174
+ return f"frcmod.ions234lm_{suffix}_tip3p"
175
+
176
+
177
+ def load_amber_ion_parameter(
178
+ *,
179
+ element: str,
180
+ charge: int,
181
+ water_model: str,
182
+ parameter_set: str,
183
+ amberhome: str | Path,
184
+ ) -> AmberIonParameter:
185
+ name = ion_frcmod_name(
186
+ charge=charge,
187
+ water_model=water_model,
188
+ parameter_set=parameter_set,
189
+ )
190
+ path = Path(amberhome) / "dat" / "leap" / "parm" / name
191
+ if not path.is_file():
192
+ raise NonbondedMetalError(f"Amber ion parameter file is missing: {path}")
193
+ atom_type = f"{element}{charge}+"
194
+ mass, radius, epsilon = _parse_frcmod_ion(path, atom_type)
195
+ return AmberIonParameter(
196
+ element=element,
197
+ charge=charge,
198
+ atom_type=atom_type,
199
+ mass=mass,
200
+ rmin_over_2_angstrom=radius,
201
+ epsilon_kcal_mol=epsilon,
202
+ parameter_set=_canonical_parameter_set(parameter_set),
203
+ water_model=water_model,
204
+ frcmod_name=name,
205
+ frcmod_path=path.resolve(),
206
+ )
207
+
208
+
209
+ def resolve_amberhome(explicit: str | Path | None = None) -> Path:
210
+ candidates: list[Path] = []
211
+ if explicit is not None:
212
+ candidates.append(Path(explicit))
213
+ for variable in ("AMBERHOME", "CONDA_PREFIX"):
214
+ value = os.environ.get(variable)
215
+ if value:
216
+ candidates.append(Path(value))
217
+ tleap = shutil.which("tleap")
218
+ if tleap:
219
+ candidates.append(Path(tleap).resolve().parent.parent)
220
+ for candidate in candidates:
221
+ if (candidate / "dat" / "leap" / "parm").is_dir():
222
+ return candidate.resolve()
223
+ raise NonbondedMetalError(
224
+ "Could not locate AmberTools data. Set AMBERHOME or run mdprep in an AmberTools environment."
225
+ )
226
+
227
+
228
+ def write_single_ion_mol2(
229
+ ion: ResolvedMetalIon,
230
+ parameter: AmberIonParameter,
231
+ path: str | Path,
232
+ ) -> None:
233
+ atom = ion.atom
234
+ residue = ion.residue.id.resname
235
+ text = (
236
+ "@<TRIPOS>MOLECULE\n"
237
+ f"{residue}\n"
238
+ "1 0 1 0 0\n"
239
+ "SMALL\n"
240
+ "USER_CHARGES\n"
241
+ "\n"
242
+ "@<TRIPOS>ATOM\n"
243
+ f"{1:7d} {atom.name:<8s} {atom.x:12.6f} {atom.y:12.6f} {atom.z:12.6f} "
244
+ f"{parameter.atom_type:<8s} {1:4d} {residue:<8s} {float(ion.charge):12.6f}\n"
245
+ "@<TRIPOS>BOND\n"
246
+ "@<TRIPOS>SUBSTRUCTURE\n"
247
+ f"{1:6d} {residue:<8s} {1:6d} GROUP 0 **** **** 0 ROOT\n"
248
+ )
249
+ output = Path(path)
250
+ output.parent.mkdir(parents=True, exist_ok=True)
251
+ output.write_text(text, encoding="utf-8")
252
+
253
+
254
+ def _parse_frcmod_ion(path: Path, atom_type: str) -> tuple[float, float, float]:
255
+ section = ""
256
+ mass: float | None = None
257
+ radius: float | None = None
258
+ epsilon: float | None = None
259
+ for raw in path.read_text(encoding="utf-8").splitlines():
260
+ stripped = raw.strip()
261
+ if stripped in {"MASS", "BOND", "ANGLE", "DIHE", "IMPROPER", "NONBON"}:
262
+ section = stripped
263
+ continue
264
+ fields = stripped.split()
265
+ if not fields or fields[0] != atom_type:
266
+ continue
267
+ try:
268
+ if section == "MASS":
269
+ mass = float(fields[1])
270
+ elif section == "NONBON":
271
+ radius = float(fields[1])
272
+ epsilon = float(fields[2])
273
+ except (IndexError, ValueError) as exc:
274
+ raise NonbondedMetalError(
275
+ f"Malformed {section} entry for {atom_type} in {path}."
276
+ ) from exc
277
+ if mass is None or radius is None or epsilon is None:
278
+ raise NonbondedMetalError(
279
+ f"Amber parameter set {path.name} has no complete MASS/NONBON entry for "
280
+ f"{atom_type}; choose a supported element, oxidation state, and parameter family."
281
+ )
282
+ return mass, radius, epsilon
283
+
284
+
285
+ def _canonical_parameter_set(value: str) -> str:
286
+ return "12_6" if value == "cm" else value
287
+
288
+
289
+ def _leap_variable(site: MetalSiteConfig, index: int, residue_name: str) -> str:
290
+ safe = "".join(character if character.isalnum() else "_" for character in site.id.upper())
291
+ safe = safe.strip("_") or "METAL"
292
+ if safe[0].isdigit():
293
+ safe = "M_" + safe
294
+ return f"{safe}_{index}_{residue_name}"