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
mdprep/metals/c4.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Apply and verify Amber 12-6-4 C4 terms with ParmEd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import shutil
|
|
8
|
+
|
|
9
|
+
from mdprep.external.discovery import which_executable
|
|
10
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
11
|
+
from mdprep.metals.nonbonded import resolve_amberhome
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class C4PostprocessError(ValueError):
|
|
15
|
+
"""Raised when a requested 12-6-4 topology cannot be produced."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class C4PostprocessRun:
|
|
20
|
+
input_prmtop_backup: Path
|
|
21
|
+
output_prmtop: Path
|
|
22
|
+
script_path: Path
|
|
23
|
+
stdout_path: Path
|
|
24
|
+
stderr_path: Path
|
|
25
|
+
atom_types: tuple[str, ...]
|
|
26
|
+
water_model: str
|
|
27
|
+
nonzero_c4_coefficients: int
|
|
28
|
+
result: CommandResult
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict[str, object]:
|
|
31
|
+
return {
|
|
32
|
+
"input_prmtop_backup": str(self.input_prmtop_backup),
|
|
33
|
+
"output_prmtop": str(self.output_prmtop),
|
|
34
|
+
"script_path": str(self.script_path),
|
|
35
|
+
"stdout_path": str(self.stdout_path),
|
|
36
|
+
"stderr_path": str(self.stderr_path),
|
|
37
|
+
"atom_types": list(self.atom_types),
|
|
38
|
+
"water_model": self.water_model,
|
|
39
|
+
"nonzero_c4_coefficients": self.nonzero_c4_coefficients,
|
|
40
|
+
"command": list(self.result.command),
|
|
41
|
+
"cwd": self.result.cwd,
|
|
42
|
+
"returncode": self.result.returncode,
|
|
43
|
+
"stdout": self.result.stdout,
|
|
44
|
+
"stderr": self.result.stderr,
|
|
45
|
+
"runtime_seconds": self.result.runtime_seconds,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def apply_12_6_4_c4(
|
|
50
|
+
prmtop: str | Path,
|
|
51
|
+
*,
|
|
52
|
+
atom_types: list[str],
|
|
53
|
+
water_model: str,
|
|
54
|
+
work_dir: str | Path,
|
|
55
|
+
executable: str = "parmed",
|
|
56
|
+
) -> C4PostprocessRun:
|
|
57
|
+
if not atom_types:
|
|
58
|
+
raise C4PostprocessError("At least one 12-6-4 metal atom type is required.")
|
|
59
|
+
exe = which_executable(executable)
|
|
60
|
+
if exe is None:
|
|
61
|
+
raise C4PostprocessError(
|
|
62
|
+
"ParmEd executable is required to add LENNARD_JONES_CCOEF for a 12-6-4 ion model."
|
|
63
|
+
)
|
|
64
|
+
water = water_model.upper()
|
|
65
|
+
if water not in {"TIP3P", "OPC"}:
|
|
66
|
+
raise C4PostprocessError(f"Unsupported 12-6-4 water model: {water_model}")
|
|
67
|
+
amberhome = resolve_amberhome()
|
|
68
|
+
polfile = amberhome / "dat" / "leap" / "parm" / "lj_1264_pol.dat"
|
|
69
|
+
if not polfile.is_file():
|
|
70
|
+
raise C4PostprocessError(f"Amber 12-6-4 polarizability file is missing: {polfile}")
|
|
71
|
+
|
|
72
|
+
topology = Path(prmtop)
|
|
73
|
+
work = Path(work_dir)
|
|
74
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
backup = work / f"{topology.name}.pre1264"
|
|
76
|
+
generated = work / f"{topology.name}.with1264"
|
|
77
|
+
shutil.copyfile(topology, backup)
|
|
78
|
+
mask = _atom_type_mask(atom_types)
|
|
79
|
+
script = work / "parmed.add1264.in"
|
|
80
|
+
script.write_text(
|
|
81
|
+
"\n".join(
|
|
82
|
+
[
|
|
83
|
+
f"add12_6_4 {mask} watermodel {water} polfile {polfile.resolve()}",
|
|
84
|
+
f"outparm {generated.name}",
|
|
85
|
+
"quit",
|
|
86
|
+
"",
|
|
87
|
+
]
|
|
88
|
+
),
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
result = run_command(
|
|
92
|
+
[exe, "-O", "-p", str(backup.resolve()), "-i", script.name],
|
|
93
|
+
cwd=work,
|
|
94
|
+
)
|
|
95
|
+
stdout_path = work / "parmed.add1264.stdout.txt"
|
|
96
|
+
stderr_path = work / "parmed.add1264.stderr.txt"
|
|
97
|
+
stdout_path.write_text(result.stdout, encoding="utf-8")
|
|
98
|
+
stderr_path.write_text(result.stderr, encoding="utf-8")
|
|
99
|
+
if result.returncode != 0:
|
|
100
|
+
raise C4PostprocessError(
|
|
101
|
+
f"ParmEd add12_6_4 failed with exit code {result.returncode}; see {stdout_path} and {stderr_path}."
|
|
102
|
+
)
|
|
103
|
+
if not generated.is_file() or generated.stat().st_size == 0:
|
|
104
|
+
raise C4PostprocessError(f"ParmEd did not produce 12-6-4 topology: {generated}")
|
|
105
|
+
contents = generated.read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
if "%FLAG LENNARD_JONES_CCOEF" not in contents:
|
|
107
|
+
raise C4PostprocessError(
|
|
108
|
+
f"ParmEd output lacks LENNARD_JONES_CCOEF and is not a valid 12-6-4 topology: {generated}"
|
|
109
|
+
)
|
|
110
|
+
c4_values = _prmtop_flag_values(contents, "LENNARD_JONES_CCOEF")
|
|
111
|
+
nonzero_c4 = sum(abs(value) > 0.0 for value in c4_values)
|
|
112
|
+
if nonzero_c4 == 0:
|
|
113
|
+
raise C4PostprocessError(
|
|
114
|
+
f"ParmEd produced a LENNARD_JONES_CCOEF array with no nonzero terms for "
|
|
115
|
+
f"{mask}; the requested metal atom type was not parameterized."
|
|
116
|
+
)
|
|
117
|
+
shutil.copyfile(generated, topology)
|
|
118
|
+
return C4PostprocessRun(
|
|
119
|
+
input_prmtop_backup=backup,
|
|
120
|
+
output_prmtop=topology,
|
|
121
|
+
script_path=script,
|
|
122
|
+
stdout_path=stdout_path,
|
|
123
|
+
stderr_path=stderr_path,
|
|
124
|
+
atom_types=tuple(sorted(set(atom_types))),
|
|
125
|
+
water_model=water,
|
|
126
|
+
nonzero_c4_coefficients=nonzero_c4,
|
|
127
|
+
result=result,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _atom_type_mask(atom_types: list[str]) -> str:
|
|
132
|
+
return "@%" + ",".join(sorted(set(atom_types)))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _prmtop_flag_values(contents: str, flag: str) -> list[float]:
|
|
136
|
+
lines = contents.splitlines()
|
|
137
|
+
marker = f"%FLAG {flag}"
|
|
138
|
+
try:
|
|
139
|
+
start = lines.index(marker) + 1
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
raise C4PostprocessError(f"Amber topology is missing %FLAG {flag}.") from exc
|
|
142
|
+
values: list[float] = []
|
|
143
|
+
for line in lines[start:]:
|
|
144
|
+
if line.startswith("%FLAG "):
|
|
145
|
+
break
|
|
146
|
+
if line.startswith("%") or not line.strip():
|
|
147
|
+
continue
|
|
148
|
+
for token in line.split():
|
|
149
|
+
try:
|
|
150
|
+
values.append(float(token.replace("D", "E")))
|
|
151
|
+
except ValueError as exc:
|
|
152
|
+
raise C4PostprocessError(
|
|
153
|
+
f"Malformed numeric value {token!r} in %FLAG {flag}."
|
|
154
|
+
) from exc
|
|
155
|
+
if not values:
|
|
156
|
+
raise C4PostprocessError(f"Amber topology %FLAG {flag} contains no values.")
|
|
157
|
+
return values
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Resolve and validate explicitly declared MCPB.py coordination sites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import dist
|
|
7
|
+
|
|
8
|
+
from mdprep.config.models import ManifestConfig, MetalSiteConfig
|
|
9
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
|
|
10
|
+
from mdprep.structure.selectors import SelectorError, resolve_atom_selector, resolve_residue_selector
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
MCPB_AUTOMATIC_DONOR_ELEMENTS = frozenset({"N", "O", "S", "F", "Cl", "Br", "I"})
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MetalCoordinationError(ValueError):
|
|
17
|
+
"""Raised when a metal site is ambiguous or inconsistent."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class ResolvedMetalIon:
|
|
22
|
+
site_id: str
|
|
23
|
+
element: str
|
|
24
|
+
charge: int
|
|
25
|
+
atom: AtomRecord
|
|
26
|
+
residue: ResidueRecord
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, object]:
|
|
29
|
+
return {
|
|
30
|
+
"site_id": self.site_id,
|
|
31
|
+
"selector": _atom_dict(self.atom),
|
|
32
|
+
"element": self.element,
|
|
33
|
+
"charge": self.charge,
|
|
34
|
+
"atom_serial": self.atom.serial,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ResolvedMetalBond:
|
|
40
|
+
ion: ResolvedMetalIon
|
|
41
|
+
coordinator: AtomRecord
|
|
42
|
+
coordinator_residue: ResidueRecord
|
|
43
|
+
distance_angstrom: float
|
|
44
|
+
discovered_by_cutoff: bool
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict[str, object]:
|
|
47
|
+
return {
|
|
48
|
+
"ion": _atom_dict(self.ion.atom),
|
|
49
|
+
"coordinator": _atom_dict(self.coordinator),
|
|
50
|
+
"distance_angstrom": self.distance_angstrom,
|
|
51
|
+
"discovered_by_mcpb_cutoff": self.discovered_by_cutoff,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ResolvedMetalSite:
|
|
57
|
+
config: MetalSiteConfig
|
|
58
|
+
ions: tuple[ResolvedMetalIon, ...]
|
|
59
|
+
bonds: tuple[ResolvedMetalBond, ...]
|
|
60
|
+
additional_residues: tuple[ResidueRecord, ...]
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, object]:
|
|
63
|
+
return {
|
|
64
|
+
"id": self.config.id,
|
|
65
|
+
"model": self.config.model,
|
|
66
|
+
"ions": [ion.to_dict() for ion in self.ions],
|
|
67
|
+
"bonds": [bond.to_dict() for bond in self.bonds],
|
|
68
|
+
"additional_residues": [residue.id.to_dict() for residue in self.additional_residues],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_metal_sites(
|
|
73
|
+
structure: PdbStructure,
|
|
74
|
+
manifest: ManifestConfig,
|
|
75
|
+
*,
|
|
76
|
+
validate_mcpb_cutoff: bool = True,
|
|
77
|
+
) -> list[ResolvedMetalSite]:
|
|
78
|
+
"""Resolve all configured ions and explicit bonds against ``structure``."""
|
|
79
|
+
|
|
80
|
+
bonded_sites = [site for site in manifest.metals if site.model == "bonded_mcpb"]
|
|
81
|
+
if len(bonded_sites) > 1:
|
|
82
|
+
raise MetalCoordinationError(
|
|
83
|
+
"Only one bonded_mcpb site is supported in one preparation. Put interacting ions "
|
|
84
|
+
"in the same site's ions list; independent MCPB.py residue-renaming outputs cannot "
|
|
85
|
+
"be combined safely."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
resolved: list[ResolvedMetalSite] = []
|
|
89
|
+
global_ion_keys: set[tuple[str, str, int, str | None, str]] = set()
|
|
90
|
+
for site in manifest.metals:
|
|
91
|
+
ions = tuple(_resolve_ion(structure, site, ion) for ion in site.ions)
|
|
92
|
+
for ion in ions:
|
|
93
|
+
key = ion.atom.atom_identity
|
|
94
|
+
if key in global_ion_keys:
|
|
95
|
+
raise MetalCoordinationError(f"Metal atom {_atom_label(ion.atom)} occurs in multiple sites.")
|
|
96
|
+
global_ion_keys.add(key)
|
|
97
|
+
|
|
98
|
+
bonds: tuple[ResolvedMetalBond, ...] = ()
|
|
99
|
+
additional: tuple[ResidueRecord, ...] = ()
|
|
100
|
+
if site.model == "bonded_mcpb":
|
|
101
|
+
assert site.mcpb is not None
|
|
102
|
+
ion_by_key = {ion.atom.atom_identity: ion for ion in ions}
|
|
103
|
+
bond_items: list[ResolvedMetalBond] = []
|
|
104
|
+
seen_pairs: set[tuple[tuple[str, str, int, str | None, str], tuple[str, str, int, str | None, str]]] = set()
|
|
105
|
+
for bond in site.mcpb.bonds:
|
|
106
|
+
try:
|
|
107
|
+
ion_atom, _ = _resolve_atom_and_residue(
|
|
108
|
+
structure, bond.ion.model_dump()
|
|
109
|
+
)
|
|
110
|
+
coordinator, coordinator_residue = _resolve_atom_and_residue(
|
|
111
|
+
structure, bond.coordinator.model_dump()
|
|
112
|
+
)
|
|
113
|
+
except SelectorError as exc:
|
|
114
|
+
raise MetalCoordinationError(
|
|
115
|
+
f"Metal site {site.id!r} bond selector failed: {exc}"
|
|
116
|
+
) from exc
|
|
117
|
+
ion = ion_by_key.get(ion_atom.atom_identity)
|
|
118
|
+
if ion is None:
|
|
119
|
+
raise MetalCoordinationError(
|
|
120
|
+
f"Metal site {site.id!r} bond ion {_atom_label(ion_atom)} is not listed "
|
|
121
|
+
"under that site's ions."
|
|
122
|
+
)
|
|
123
|
+
if coordinator.atom_identity in global_ion_keys:
|
|
124
|
+
raise MetalCoordinationError(
|
|
125
|
+
f"Metal site {site.id!r} uses metal atom {_atom_label(coordinator)} as a "
|
|
126
|
+
"coordinator; metal--metal bonds are not inferred by this workflow."
|
|
127
|
+
)
|
|
128
|
+
pair = (ion.atom.atom_identity, coordinator.atom_identity)
|
|
129
|
+
if pair in seen_pairs:
|
|
130
|
+
raise MetalCoordinationError(
|
|
131
|
+
f"Duplicate metal bond {_atom_label(ion.atom)}--{_atom_label(coordinator)}."
|
|
132
|
+
)
|
|
133
|
+
seen_pairs.add(pair)
|
|
134
|
+
distance = _distance(ion.atom, coordinator)
|
|
135
|
+
discovered = (
|
|
136
|
+
distance <= site.mcpb.cutoff_angstrom
|
|
137
|
+
and _element(coordinator) in MCPB_AUTOMATIC_DONOR_ELEMENTS
|
|
138
|
+
)
|
|
139
|
+
bond_items.append(
|
|
140
|
+
ResolvedMetalBond(
|
|
141
|
+
ion=ion,
|
|
142
|
+
coordinator=coordinator,
|
|
143
|
+
coordinator_residue=coordinator_residue,
|
|
144
|
+
distance_angstrom=distance,
|
|
145
|
+
discovered_by_cutoff=discovered,
|
|
146
|
+
)
|
|
147
|
+
)
|
|
148
|
+
bonds = tuple(bond_items)
|
|
149
|
+
additional = tuple(
|
|
150
|
+
_resolve_additional_residue(structure, site.id, selector.model_dump())
|
|
151
|
+
for selector in site.mcpb.additional_residues
|
|
152
|
+
)
|
|
153
|
+
if validate_mcpb_cutoff:
|
|
154
|
+
_validate_exact_mcpb_candidates(structure, site, ions, bonds)
|
|
155
|
+
|
|
156
|
+
resolved.append(
|
|
157
|
+
ResolvedMetalSite(config=site, ions=ions, bonds=bonds, additional_residues=additional)
|
|
158
|
+
)
|
|
159
|
+
return resolved
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _resolve_ion(structure: PdbStructure, site: MetalSiteConfig, config: object) -> ResolvedMetalIon:
|
|
163
|
+
selector = config.selector.model_dump() # type: ignore[attr-defined]
|
|
164
|
+
try:
|
|
165
|
+
atom, residue = _resolve_atom_and_residue(structure, selector)
|
|
166
|
+
except SelectorError as exc:
|
|
167
|
+
raise MetalCoordinationError(f"Metal site {site.id!r} ion selector failed: {exc}") from exc
|
|
168
|
+
element = config.element # type: ignore[attr-defined]
|
|
169
|
+
observed = _element(atom)
|
|
170
|
+
if observed and observed != element:
|
|
171
|
+
raise MetalCoordinationError(
|
|
172
|
+
f"Metal site {site.id!r} declares {element} for {_atom_label(atom)}, "
|
|
173
|
+
f"but the PDB element is {observed}."
|
|
174
|
+
)
|
|
175
|
+
if len(residue.atoms) != 1:
|
|
176
|
+
raise MetalCoordinationError(
|
|
177
|
+
f"Metal ion residue {residue.id.display()} contains {len(residue.atoms)} atoms; "
|
|
178
|
+
"a metal ion must be a one-atom residue."
|
|
179
|
+
)
|
|
180
|
+
if atom.serial is None:
|
|
181
|
+
raise MetalCoordinationError(f"Metal atom {_atom_label(atom)} has no PDB serial number.")
|
|
182
|
+
return ResolvedMetalIon(
|
|
183
|
+
site_id=site.id,
|
|
184
|
+
element=element,
|
|
185
|
+
charge=config.charge, # type: ignore[attr-defined]
|
|
186
|
+
atom=atom,
|
|
187
|
+
residue=residue,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _resolve_additional_residue(
|
|
192
|
+
structure: PdbStructure,
|
|
193
|
+
site_id: str,
|
|
194
|
+
selector: dict[str, object],
|
|
195
|
+
) -> ResidueRecord:
|
|
196
|
+
try:
|
|
197
|
+
return _resolve_residue_protonation_aware(structure, selector)
|
|
198
|
+
except SelectorError as exc:
|
|
199
|
+
raise MetalCoordinationError(
|
|
200
|
+
f"Metal site {site_id!r} additional residue selector failed: {exc}"
|
|
201
|
+
) from exc
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _validate_exact_mcpb_candidates(
|
|
205
|
+
structure: PdbStructure,
|
|
206
|
+
site: MetalSiteConfig,
|
|
207
|
+
ions: tuple[ResolvedMetalIon, ...],
|
|
208
|
+
bonds: tuple[ResolvedMetalBond, ...],
|
|
209
|
+
) -> None:
|
|
210
|
+
assert site.mcpb is not None
|
|
211
|
+
declared = {(bond.ion.atom.atom_identity, bond.coordinator.atom_identity) for bond in bonds}
|
|
212
|
+
discovered: set[
|
|
213
|
+
tuple[
|
|
214
|
+
tuple[str, str, int, str | None, str],
|
|
215
|
+
tuple[str, str, int, str | None, str],
|
|
216
|
+
]
|
|
217
|
+
] = set()
|
|
218
|
+
ion_keys = {ion.atom.atom_identity for ion in ions}
|
|
219
|
+
for ion in ions:
|
|
220
|
+
for atom in structure.atoms:
|
|
221
|
+
if atom.atom_identity in ion_keys:
|
|
222
|
+
continue
|
|
223
|
+
if _element(atom) not in MCPB_AUTOMATIC_DONOR_ELEMENTS:
|
|
224
|
+
continue
|
|
225
|
+
if _distance(ion.atom, atom) <= site.mcpb.cutoff_angstrom:
|
|
226
|
+
discovered.add((ion.atom.atom_identity, atom.atom_identity))
|
|
227
|
+
undeclared = discovered - declared
|
|
228
|
+
if undeclared:
|
|
229
|
+
formatted = ", ".join(
|
|
230
|
+
f"{_identity_label(ion)}--{_identity_label(atom)}"
|
|
231
|
+
for ion, atom in sorted(undeclared, key=str)
|
|
232
|
+
)
|
|
233
|
+
raise MetalCoordinationError(
|
|
234
|
+
f"MCPB.py would create undeclared bonds within {site.mcpb.cutoff_angstrom:.3f} A "
|
|
235
|
+
f"for site {site.id!r}: {formatted}. Add every intended bond under mcpb.bonds or "
|
|
236
|
+
"choose a cutoff that represents the explicitly approved coordination sphere."
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _distance(a: AtomRecord, b: AtomRecord) -> float:
|
|
241
|
+
return dist((a.x, a.y, a.z), (b.x, b.y, b.z))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
_PROTONATION_FAMILIES = (
|
|
245
|
+
frozenset({"HIS", "HID", "HIE", "HIP"}),
|
|
246
|
+
frozenset({"ASP", "ASH"}),
|
|
247
|
+
frozenset({"GLU", "GLH"}),
|
|
248
|
+
frozenset({"CYS", "CYM", "CYX"}),
|
|
249
|
+
frozenset({"LYS", "LYN"}),
|
|
250
|
+
frozenset({"HOH", "WAT", "H2O", "TIP3", "OPC"}),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _resolve_atom_and_residue(
|
|
255
|
+
structure: PdbStructure,
|
|
256
|
+
selector: dict[str, object],
|
|
257
|
+
) -> tuple[AtomRecord, ResidueRecord]:
|
|
258
|
+
residue = _resolve_residue_protonation_aware(structure, selector)
|
|
259
|
+
atom_name = str(selector["atom_name"])
|
|
260
|
+
matches = [atom for atom in residue.atoms if atom.name == atom_name]
|
|
261
|
+
if len(matches) != 1:
|
|
262
|
+
available = ", ".join(residue.atom_names())
|
|
263
|
+
raise SelectorError(
|
|
264
|
+
f"Expected one atom {atom_name!r} in {residue.id.display()}, found {len(matches)}. "
|
|
265
|
+
f"Available atoms: {available}"
|
|
266
|
+
)
|
|
267
|
+
return matches[0], residue
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _resolve_residue_protonation_aware(
|
|
271
|
+
structure: PdbStructure,
|
|
272
|
+
selector: dict[str, object],
|
|
273
|
+
) -> ResidueRecord:
|
|
274
|
+
try:
|
|
275
|
+
return resolve_residue_selector(structure, selector)
|
|
276
|
+
except SelectorError as original:
|
|
277
|
+
requested = str(selector.get("resname", ""))
|
|
278
|
+
family = next((item for item in _PROTONATION_FAMILIES if requested in item), None)
|
|
279
|
+
if family is None:
|
|
280
|
+
raise
|
|
281
|
+
relaxed = dict(selector)
|
|
282
|
+
relaxed.pop("atom_name", None)
|
|
283
|
+
relaxed.pop("resname", None)
|
|
284
|
+
try:
|
|
285
|
+
residue = resolve_residue_selector(structure, relaxed)
|
|
286
|
+
except SelectorError:
|
|
287
|
+
raise original
|
|
288
|
+
if residue.id.resname not in family:
|
|
289
|
+
raise original
|
|
290
|
+
return residue
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _element(atom: AtomRecord) -> str:
|
|
294
|
+
return (atom.element or "").strip().capitalize()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _atom_dict(atom: AtomRecord) -> dict[str, object]:
|
|
298
|
+
return {
|
|
299
|
+
"chain": atom.chain_id,
|
|
300
|
+
"resname": atom.resname,
|
|
301
|
+
"resid": atom.resid,
|
|
302
|
+
"icode": atom.icode,
|
|
303
|
+
"atom_name": atom.name,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _atom_label(atom: AtomRecord) -> str:
|
|
308
|
+
return _identity_label(atom.atom_identity)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _identity_label(identity: tuple[str, str, int, str | None, str]) -> str:
|
|
312
|
+
chain, resname, resid, icode, atom_name = identity
|
|
313
|
+
return f"{chain or '<blank>'}:{resname}{resid}{icode or ''}@{atom_name}"
|