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,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}"
|