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,950 @@
|
|
|
1
|
+
"""Temporary histidine tautomer geometry for xTB ranking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from math import cos, dist, radians, sin, sqrt
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.structure.classify import is_standard_protein_residue, is_water_residue
|
|
10
|
+
from mdprep.structure.models import AtomRecord, ResidueRecord
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HistidineGeometryError(ValueError):
|
|
14
|
+
"""Raised when temporary histidine tautomer geometry cannot be built."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class XyzAtom:
|
|
19
|
+
element: str
|
|
20
|
+
x: float
|
|
21
|
+
y: float
|
|
22
|
+
z: float
|
|
23
|
+
name: str
|
|
24
|
+
source: str = "input"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TemporaryWaterHydrogenRecord:
|
|
29
|
+
chain: str
|
|
30
|
+
resname: str
|
|
31
|
+
resid: int
|
|
32
|
+
icode: str | None
|
|
33
|
+
hydrogens_added: int
|
|
34
|
+
final_hydrogen_count: int
|
|
35
|
+
orientation: str
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, object]:
|
|
38
|
+
return {
|
|
39
|
+
"chain": self.chain,
|
|
40
|
+
"resname": self.resname,
|
|
41
|
+
"resid": self.resid,
|
|
42
|
+
"icode": self.icode,
|
|
43
|
+
"hydrogens_added": self.hydrogens_added,
|
|
44
|
+
"final_hydrogen_count": self.final_hydrogen_count,
|
|
45
|
+
"orientation": self.orientation,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class TautomerClusterModel:
|
|
51
|
+
atoms: list[XyzAtom]
|
|
52
|
+
fixed_atom_indices: list[int]
|
|
53
|
+
cap_atom_indices: list[int]
|
|
54
|
+
anchor_atom_indices: list[int]
|
|
55
|
+
temporary_water_hydrogens: list[TemporaryWaterHydrogenRecord]
|
|
56
|
+
warnings: list[str]
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict[str, object]:
|
|
59
|
+
return {
|
|
60
|
+
"atom_count": len(self.atoms),
|
|
61
|
+
"fixed_atom_indices": self.fixed_atom_indices,
|
|
62
|
+
"cap_atom_indices": self.cap_atom_indices,
|
|
63
|
+
"anchor_atom_indices": self.anchor_atom_indices,
|
|
64
|
+
"temporary_water_hydrogens_added": sum(
|
|
65
|
+
item.hydrogens_added for item in self.temporary_water_hydrogens
|
|
66
|
+
),
|
|
67
|
+
"waters_modified_for_xtb_only": [
|
|
68
|
+
item.to_dict() for item in self.temporary_water_hydrogens
|
|
69
|
+
],
|
|
70
|
+
"final_pdb_modified": False,
|
|
71
|
+
"warnings": self.warnings,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def place_histidine_tautomer_hydrogen(
|
|
76
|
+
residue: ResidueRecord,
|
|
77
|
+
*,
|
|
78
|
+
tautomer: str,
|
|
79
|
+
bond_length: float = 1.01,
|
|
80
|
+
) -> XyzAtom:
|
|
81
|
+
ring = {atom.name: atom for atom in residue.atoms}
|
|
82
|
+
if tautomer == "HID":
|
|
83
|
+
required = ("ND1", "CG", "CE1")
|
|
84
|
+
atom_name = "HD1"
|
|
85
|
+
nitrogen_name = "ND1"
|
|
86
|
+
neighbor_names = ("CG", "CE1")
|
|
87
|
+
elif tautomer == "HIE":
|
|
88
|
+
required = ("NE2", "CE1", "CD2")
|
|
89
|
+
atom_name = "HE2"
|
|
90
|
+
nitrogen_name = "NE2"
|
|
91
|
+
neighbor_names = ("CE1", "CD2")
|
|
92
|
+
else:
|
|
93
|
+
raise HistidineGeometryError(f"Unsupported histidine tautomer {tautomer!r}")
|
|
94
|
+
missing = [name for name in required if name not in ring]
|
|
95
|
+
if missing:
|
|
96
|
+
raise HistidineGeometryError(
|
|
97
|
+
f"Histidine {residue.id.display()} is missing required atoms for {tautomer}: {', '.join(missing)}. "
|
|
98
|
+
"Add a manual override if tautomer geometry cannot be generated."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
nitrogen = ring[nitrogen_name]
|
|
102
|
+
neighbor1 = ring[neighbor_names[0]]
|
|
103
|
+
neighbor2 = ring[neighbor_names[1]]
|
|
104
|
+
direction = (
|
|
105
|
+
2.0 * nitrogen.x - neighbor1.x - neighbor2.x,
|
|
106
|
+
2.0 * nitrogen.y - neighbor1.y - neighbor2.y,
|
|
107
|
+
2.0 * nitrogen.z - neighbor1.z - neighbor2.z,
|
|
108
|
+
)
|
|
109
|
+
unit = _normalize(direction)
|
|
110
|
+
return XyzAtom(
|
|
111
|
+
element="H",
|
|
112
|
+
x=nitrogen.x + unit[0] * bond_length,
|
|
113
|
+
y=nitrogen.y + unit[1] * bond_length,
|
|
114
|
+
z=nitrogen.z + unit[2] * bond_length,
|
|
115
|
+
name=atom_name,
|
|
116
|
+
source="histidine_tautomer",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def build_tautomer_xyz_atoms(
|
|
121
|
+
cluster_residues: list[ResidueRecord],
|
|
122
|
+
histidine: ResidueRecord,
|
|
123
|
+
*,
|
|
124
|
+
tautomer: str,
|
|
125
|
+
residue_states: dict[int, str] | None = None,
|
|
126
|
+
add_missing_water_hydrogens: bool = True,
|
|
127
|
+
water_oh_distance_angstrom: float = 0.9572,
|
|
128
|
+
water_hoh_angle_degrees: float = 104.52,
|
|
129
|
+
) -> list[XyzAtom]:
|
|
130
|
+
return build_tautomer_cluster_model(
|
|
131
|
+
cluster_residues,
|
|
132
|
+
histidine,
|
|
133
|
+
tautomer=tautomer,
|
|
134
|
+
residue_states=residue_states,
|
|
135
|
+
add_missing_water_hydrogens=add_missing_water_hydrogens,
|
|
136
|
+
water_oh_distance_angstrom=water_oh_distance_angstrom,
|
|
137
|
+
water_hoh_angle_degrees=water_hoh_angle_degrees,
|
|
138
|
+
).atoms
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_tautomer_cluster_model(
|
|
142
|
+
cluster_residues: list[ResidueRecord],
|
|
143
|
+
histidine: ResidueRecord,
|
|
144
|
+
*,
|
|
145
|
+
tautomer: str,
|
|
146
|
+
residue_states: dict[int, str] | None = None,
|
|
147
|
+
add_missing_water_hydrogens: bool = True,
|
|
148
|
+
water_oh_distance_angstrom: float = 0.9572,
|
|
149
|
+
water_hoh_angle_degrees: float = 104.52,
|
|
150
|
+
) -> TautomerClusterModel:
|
|
151
|
+
states = {} if residue_states is None else residue_states
|
|
152
|
+
atoms: list[XyzAtom] = []
|
|
153
|
+
fixed_atom_indices: list[int] = []
|
|
154
|
+
cap_atom_indices: list[int] = []
|
|
155
|
+
anchor_atom_indices: list[int] = []
|
|
156
|
+
temporary_water_hydrogens: list[TemporaryWaterHydrogenRecord] = []
|
|
157
|
+
warnings: list[str] = []
|
|
158
|
+
for residue in cluster_residues:
|
|
159
|
+
if is_standard_protein_residue(residue):
|
|
160
|
+
before = len(atoms)
|
|
161
|
+
fixed = _append_truncated_protein_residue(
|
|
162
|
+
atoms,
|
|
163
|
+
residue,
|
|
164
|
+
target_histidine=residue is histidine,
|
|
165
|
+
tautomer=tautomer if residue is histidine else None,
|
|
166
|
+
residue_state=states.get(id(residue), residue.id.resname),
|
|
167
|
+
)
|
|
168
|
+
fixed_atom_indices.extend(fixed["fixed"])
|
|
169
|
+
cap_atom_indices.extend(fixed["caps"])
|
|
170
|
+
anchor_atom_indices.extend(fixed["anchors"])
|
|
171
|
+
_require_retained_hydrogens(residue, atoms[before:])
|
|
172
|
+
continue
|
|
173
|
+
if is_water_residue(residue):
|
|
174
|
+
record = _append_water_residue(
|
|
175
|
+
atoms,
|
|
176
|
+
residue,
|
|
177
|
+
cluster_residues=cluster_residues,
|
|
178
|
+
fixed_atom_indices=fixed_atom_indices,
|
|
179
|
+
add_missing_hydrogens=add_missing_water_hydrogens,
|
|
180
|
+
oh_distance=water_oh_distance_angstrom,
|
|
181
|
+
hoh_angle_degrees=water_hoh_angle_degrees,
|
|
182
|
+
warnings=warnings,
|
|
183
|
+
)
|
|
184
|
+
if record is not None:
|
|
185
|
+
temporary_water_hydrogens.append(record)
|
|
186
|
+
continue
|
|
187
|
+
fixed_atom_indices.extend(_append_full_residue(atoms, residue))
|
|
188
|
+
|
|
189
|
+
atoms.append(place_histidine_tautomer_hydrogen(histidine, tautomer=tautomer))
|
|
190
|
+
return TautomerClusterModel(
|
|
191
|
+
atoms=atoms,
|
|
192
|
+
fixed_atom_indices=sorted(set(fixed_atom_indices)),
|
|
193
|
+
cap_atom_indices=sorted(set(cap_atom_indices)),
|
|
194
|
+
anchor_atom_indices=sorted(set(anchor_atom_indices)),
|
|
195
|
+
temporary_water_hydrogens=temporary_water_hydrogens,
|
|
196
|
+
warnings=warnings,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def write_xcontrol_fix_file(fixed_atom_indices: list[int], path: str | Path) -> None:
|
|
201
|
+
path_obj = Path(path)
|
|
202
|
+
if not fixed_atom_indices:
|
|
203
|
+
path_obj.write_text("", encoding="utf-8")
|
|
204
|
+
return
|
|
205
|
+
atom_text = ",".join(str(index) for index in sorted(set(fixed_atom_indices)))
|
|
206
|
+
path_obj.write_text(f"$fix\n atoms: {atom_text}\n$end\n", encoding="utf-8")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def write_xyz(atoms: list[XyzAtom], path: str | Path, *, comment: str = "mdprep histidine tautomer") -> None:
|
|
210
|
+
path_obj = Path(path)
|
|
211
|
+
path_obj.write_text(
|
|
212
|
+
"\n".join(
|
|
213
|
+
[
|
|
214
|
+
str(len(atoms)),
|
|
215
|
+
comment,
|
|
216
|
+
*[f"{atom.element} {atom.x:.6f} {atom.y:.6f} {atom.z:.6f}" for atom in atoms],
|
|
217
|
+
"",
|
|
218
|
+
]
|
|
219
|
+
),
|
|
220
|
+
encoding="utf-8",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def is_hydrogen_like(atom: AtomRecord) -> bool:
|
|
225
|
+
element_field = atom.original_line[76:78].strip() if len(atom.original_line) >= 78 else ""
|
|
226
|
+
if element_field:
|
|
227
|
+
return element_field.upper() == "H"
|
|
228
|
+
stripped = atom.name.strip()
|
|
229
|
+
while stripped and stripped[0].isdigit():
|
|
230
|
+
stripped = stripped[1:]
|
|
231
|
+
return stripped.upper().startswith("H")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def has_water_oxygen(residue: ResidueRecord) -> bool:
|
|
235
|
+
return any(_is_oxygen_atom(atom) for atom in residue.atoms)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def count_water_hydrogens(residue: ResidueRecord) -> int:
|
|
239
|
+
return sum(1 for atom in residue.atoms if is_hydrogen_like(atom))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def build_water_hydrogen_coordinates(
|
|
243
|
+
residue: ResidueRecord,
|
|
244
|
+
cluster_residues: list[ResidueRecord],
|
|
245
|
+
*,
|
|
246
|
+
oh_distance: float = 0.9572,
|
|
247
|
+
hoh_angle_degrees: float = 104.52,
|
|
248
|
+
) -> list[tuple[float, float, float]]:
|
|
249
|
+
oxygen_atoms = [atom for atom in residue.atoms if _is_oxygen_atom(atom)]
|
|
250
|
+
if not oxygen_atoms:
|
|
251
|
+
raise HistidineGeometryError(
|
|
252
|
+
f"Water residue {residue.id.display()} is in the xTB histidine cluster but has no oxygen atom."
|
|
253
|
+
)
|
|
254
|
+
if len(oxygen_atoms) > 1:
|
|
255
|
+
raise HistidineGeometryError(
|
|
256
|
+
f"Water residue {residue.id.display()} has multiple oxygen atoms; cannot add temporary xTB hydrogens."
|
|
257
|
+
)
|
|
258
|
+
existing_hydrogens = [atom for atom in residue.atoms if is_hydrogen_like(atom)]
|
|
259
|
+
if len(existing_hydrogens) >= 2:
|
|
260
|
+
return []
|
|
261
|
+
nearby_heavy = _nearby_nonwater_heavy_atoms(cluster_residues, water=residue)
|
|
262
|
+
if not existing_hydrogens:
|
|
263
|
+
coordinates, _ = _build_two_water_hydrogens(
|
|
264
|
+
oxygen_atoms[0],
|
|
265
|
+
nearby_heavy=nearby_heavy,
|
|
266
|
+
oh_distance=oh_distance,
|
|
267
|
+
hoh_angle_degrees=hoh_angle_degrees,
|
|
268
|
+
)
|
|
269
|
+
return coordinates
|
|
270
|
+
coordinates, _ = _build_second_water_hydrogen(
|
|
271
|
+
oxygen_atoms[0],
|
|
272
|
+
existing_hydrogens[0],
|
|
273
|
+
nearby_heavy=nearby_heavy,
|
|
274
|
+
oh_distance=oh_distance,
|
|
275
|
+
hoh_angle_degrees=hoh_angle_degrees,
|
|
276
|
+
)
|
|
277
|
+
return coordinates
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def heavy_atom_distance(a: AtomRecord, b: AtomRecord) -> float:
|
|
281
|
+
return dist((a.x, a.y, a.z), (b.x, b.y, b.z))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
BACKBONE_ATOMS = {"N", "H", "H1", "H2", "H3", "C", "O", "OXT"}
|
|
285
|
+
REMOVED_BACKBONE_HEAVY_ATOMS = ("N", "C", "O", "OXT")
|
|
286
|
+
TAUTOMER_HYDROGENS = {"HD1", "HE2", "1HD1", "2HD1", "1HE2", "2HE2"}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _append_truncated_protein_residue(
|
|
290
|
+
atoms: list[XyzAtom],
|
|
291
|
+
residue: ResidueRecord,
|
|
292
|
+
*,
|
|
293
|
+
target_histidine: bool,
|
|
294
|
+
tautomer: str | None,
|
|
295
|
+
residue_state: str,
|
|
296
|
+
) -> dict[str, list[int]]:
|
|
297
|
+
_validate_residue_hydrogen_state(
|
|
298
|
+
residue,
|
|
299
|
+
residue_state=residue_state,
|
|
300
|
+
target_histidine=target_histidine,
|
|
301
|
+
tautomer=tautomer,
|
|
302
|
+
)
|
|
303
|
+
atom_by_name = {atom.name.strip(): atom for atom in residue.atoms}
|
|
304
|
+
ca = atom_by_name.get("CA")
|
|
305
|
+
if ca is None:
|
|
306
|
+
raise HistidineGeometryError(
|
|
307
|
+
f"Protein residue {residue.id.display()} is missing CA; cannot build capped xTB cluster."
|
|
308
|
+
)
|
|
309
|
+
fixed: list[int] = []
|
|
310
|
+
caps: list[int] = []
|
|
311
|
+
anchors: list[int] = []
|
|
312
|
+
for atom in residue.atoms:
|
|
313
|
+
name = atom.name.strip()
|
|
314
|
+
if _is_removed_backbone_atom_or_hydrogen(atom, residue):
|
|
315
|
+
continue
|
|
316
|
+
if _skip_hydrogen_for_cluster_state(
|
|
317
|
+
atom,
|
|
318
|
+
residue,
|
|
319
|
+
residue_state=residue_state,
|
|
320
|
+
target_histidine=target_histidine,
|
|
321
|
+
):
|
|
322
|
+
continue
|
|
323
|
+
atoms.append(_xyz_from_atom(atom))
|
|
324
|
+
if not is_hydrogen_like(atom):
|
|
325
|
+
fixed.append(len(atoms))
|
|
326
|
+
if name == "CA":
|
|
327
|
+
anchors.append(len(atoms))
|
|
328
|
+
|
|
329
|
+
if residue.id.resname in {"HIS", "HID", "HIE", "HIP"}:
|
|
330
|
+
temporary_histidine_hydrogens = _temporary_histidine_hydrogens(
|
|
331
|
+
residue,
|
|
332
|
+
residue_state=residue_state,
|
|
333
|
+
target_histidine=target_histidine,
|
|
334
|
+
)
|
|
335
|
+
atoms.extend(temporary_histidine_hydrogens)
|
|
336
|
+
|
|
337
|
+
for boundary_name, cap_name in [("N", "HCA_NCAP"), ("C", "HCA_CCAP")]:
|
|
338
|
+
boundary = atom_by_name.get(boundary_name)
|
|
339
|
+
if boundary is None:
|
|
340
|
+
continue
|
|
341
|
+
cap = _cap_hydrogen_from_ca(ca, boundary, cap_name)
|
|
342
|
+
atoms.append(cap)
|
|
343
|
+
fixed.append(len(atoms))
|
|
344
|
+
caps.append(len(atoms))
|
|
345
|
+
return {"fixed": fixed, "caps": caps, "anchors": anchors}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _is_removed_backbone_atom_or_hydrogen(atom: AtomRecord, residue: ResidueRecord) -> bool:
|
|
349
|
+
name = atom.name.strip()
|
|
350
|
+
if name in BACKBONE_ATOMS:
|
|
351
|
+
return True
|
|
352
|
+
if not is_hydrogen_like(atom):
|
|
353
|
+
return False
|
|
354
|
+
return _hydrogen_is_near_any(atom, residue, REMOVED_BACKBONE_HEAVY_ATOMS)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _append_full_residue(atoms: list[XyzAtom], residue: ResidueRecord) -> list[int]:
|
|
358
|
+
fixed: list[int] = []
|
|
359
|
+
for atom in residue.atoms:
|
|
360
|
+
atoms.append(_xyz_from_atom(atom))
|
|
361
|
+
if not is_hydrogen_like(atom):
|
|
362
|
+
fixed.append(len(atoms))
|
|
363
|
+
return fixed
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _append_water_residue(
|
|
367
|
+
atoms: list[XyzAtom],
|
|
368
|
+
residue: ResidueRecord,
|
|
369
|
+
*,
|
|
370
|
+
cluster_residues: list[ResidueRecord],
|
|
371
|
+
fixed_atom_indices: list[int],
|
|
372
|
+
add_missing_hydrogens: bool,
|
|
373
|
+
oh_distance: float,
|
|
374
|
+
hoh_angle_degrees: float,
|
|
375
|
+
warnings: list[str],
|
|
376
|
+
) -> TemporaryWaterHydrogenRecord | None:
|
|
377
|
+
oxygen_atoms = [atom for atom in residue.atoms if _is_oxygen_atom(atom)]
|
|
378
|
+
if not oxygen_atoms:
|
|
379
|
+
raise HistidineGeometryError(
|
|
380
|
+
f"Water residue {residue.id.display()} is in the xTB histidine cluster but has no oxygen atom."
|
|
381
|
+
)
|
|
382
|
+
if len(oxygen_atoms) > 1:
|
|
383
|
+
raise HistidineGeometryError(
|
|
384
|
+
f"Water residue {residue.id.display()} has multiple oxygen atoms; cannot add temporary xTB hydrogens."
|
|
385
|
+
)
|
|
386
|
+
existing_hydrogens = [atom for atom in residue.atoms if is_hydrogen_like(atom)]
|
|
387
|
+
fixed_atom_indices.extend(_append_full_residue(atoms, residue))
|
|
388
|
+
if len(existing_hydrogens) >= 2:
|
|
389
|
+
return None
|
|
390
|
+
if not add_missing_hydrogens:
|
|
391
|
+
_require_water_hydrogens(residue, [_xyz_from_atom(atom) for atom in residue.atoms])
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
oxygen = oxygen_atoms[0]
|
|
395
|
+
nearby_heavy = _nearby_nonwater_heavy_atoms(cluster_residues, water=residue)
|
|
396
|
+
if not existing_hydrogens:
|
|
397
|
+
coordinates, orientation = _build_two_water_hydrogens(
|
|
398
|
+
oxygen,
|
|
399
|
+
nearby_heavy=nearby_heavy,
|
|
400
|
+
oh_distance=oh_distance,
|
|
401
|
+
hoh_angle_degrees=hoh_angle_degrees,
|
|
402
|
+
)
|
|
403
|
+
elif len(existing_hydrogens) == 1:
|
|
404
|
+
coordinates, orientation = _build_second_water_hydrogen(
|
|
405
|
+
oxygen,
|
|
406
|
+
existing_hydrogens[0],
|
|
407
|
+
nearby_heavy=nearby_heavy,
|
|
408
|
+
oh_distance=oh_distance,
|
|
409
|
+
hoh_angle_degrees=hoh_angle_degrees,
|
|
410
|
+
)
|
|
411
|
+
else:
|
|
412
|
+
coordinates = []
|
|
413
|
+
orientation = "input"
|
|
414
|
+
existing_names = {atom.name.strip().upper() for atom in residue.atoms}
|
|
415
|
+
for coordinate in coordinates:
|
|
416
|
+
atoms.append(
|
|
417
|
+
XyzAtom(
|
|
418
|
+
element="H",
|
|
419
|
+
x=coordinate[0],
|
|
420
|
+
y=coordinate[1],
|
|
421
|
+
z=coordinate[2],
|
|
422
|
+
name=_next_water_hydrogen_name(existing_names),
|
|
423
|
+
source="temporary_water_hydrogen",
|
|
424
|
+
)
|
|
425
|
+
)
|
|
426
|
+
if orientation == "deterministic_fallback":
|
|
427
|
+
warnings.append(
|
|
428
|
+
f"Water residue {residue.id.display()} received temporary xTB-only hydrogens "
|
|
429
|
+
"with an arbitrary deterministic orientation because no non-water heavy atoms were available."
|
|
430
|
+
)
|
|
431
|
+
return TemporaryWaterHydrogenRecord(
|
|
432
|
+
chain=residue.id.chain_id,
|
|
433
|
+
resname=residue.id.resname,
|
|
434
|
+
resid=residue.id.resid,
|
|
435
|
+
icode=residue.id.icode,
|
|
436
|
+
hydrogens_added=len(coordinates),
|
|
437
|
+
final_hydrogen_count=len(existing_hydrogens) + len(coordinates),
|
|
438
|
+
orientation=orientation,
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _validate_residue_hydrogen_state(
|
|
443
|
+
residue: ResidueRecord,
|
|
444
|
+
*,
|
|
445
|
+
residue_state: str,
|
|
446
|
+
target_histidine: bool,
|
|
447
|
+
tautomer: str | None,
|
|
448
|
+
) -> None:
|
|
449
|
+
if residue.id.resname in {"HIS", "HID", "HIE", "HIP"}:
|
|
450
|
+
_validate_histidine_fragment_hydrogens(
|
|
451
|
+
residue,
|
|
452
|
+
residue_state=residue_state,
|
|
453
|
+
target_histidine=target_histidine,
|
|
454
|
+
tautomer=tautomer,
|
|
455
|
+
)
|
|
456
|
+
elif residue_state in {"ASH", "GLH"}:
|
|
457
|
+
anchors = ("OD1", "OD2") if residue_state == "ASH" else ("OE1", "OE2")
|
|
458
|
+
if _hydrogen_count_near_any(residue, anchors) < 1:
|
|
459
|
+
raise HistidineGeometryError(
|
|
460
|
+
f"xTB cluster residue {residue.id.display()} is assigned {residue_state}, "
|
|
461
|
+
f"but no carboxyl proton is present on {'/'.join(anchors)}. "
|
|
462
|
+
"Hydrogenate the input consistently or use a manual override that matches the input geometry."
|
|
463
|
+
)
|
|
464
|
+
elif residue_state == "CYS":
|
|
465
|
+
if _hydrogen_count_near_any(residue, ("SG",)) < 1:
|
|
466
|
+
raise HistidineGeometryError(
|
|
467
|
+
f"xTB cluster residue {residue.id.display()} is assigned CYS, "
|
|
468
|
+
"but no thiol proton is present on SG."
|
|
469
|
+
)
|
|
470
|
+
elif residue_state == "LYS":
|
|
471
|
+
count = _hydrogen_count_near_any(residue, ("NZ",))
|
|
472
|
+
if count < 3:
|
|
473
|
+
raise HistidineGeometryError(
|
|
474
|
+
f"xTB cluster residue {residue.id.display()} is assigned LYS, "
|
|
475
|
+
f"but NZ has only {count} nearby hydrogens; expected 3."
|
|
476
|
+
)
|
|
477
|
+
elif residue_state == "LYN":
|
|
478
|
+
count = _hydrogen_count_near_any(residue, ("NZ",))
|
|
479
|
+
if count < 2:
|
|
480
|
+
raise HistidineGeometryError(
|
|
481
|
+
f"xTB cluster residue {residue.id.display()} is assigned LYN, "
|
|
482
|
+
f"but NZ has only {count} nearby hydrogens; expected at least 2 "
|
|
483
|
+
"before temporary-state canonicalization."
|
|
484
|
+
)
|
|
485
|
+
elif residue_state == "ARG":
|
|
486
|
+
required = {"NE": 1, "NH1": 2, "NH2": 2}
|
|
487
|
+
missing = [
|
|
488
|
+
f"{atom_name} expected {expected}, found {_hydrogen_count_near_any(residue, (atom_name,))}"
|
|
489
|
+
for atom_name, expected in required.items()
|
|
490
|
+
if _hydrogen_count_near_any(residue, (atom_name,)) < expected
|
|
491
|
+
]
|
|
492
|
+
if missing:
|
|
493
|
+
raise HistidineGeometryError(
|
|
494
|
+
f"xTB cluster residue {residue.id.display()} is assigned ARG, "
|
|
495
|
+
f"but guanidinium hydrogens are incomplete: {', '.join(missing)}."
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _validate_histidine_fragment_hydrogens(
|
|
500
|
+
residue: ResidueRecord,
|
|
501
|
+
*,
|
|
502
|
+
residue_state: str,
|
|
503
|
+
target_histidine: bool,
|
|
504
|
+
tautomer: str | None,
|
|
505
|
+
) -> None:
|
|
506
|
+
missing_atoms = [name for name in ("CG", "ND1", "CE1", "NE2", "CD2") if _atom_by_name(residue, name) is None]
|
|
507
|
+
if missing_atoms:
|
|
508
|
+
raise HistidineGeometryError(
|
|
509
|
+
f"Histidine {residue.id.display()} is missing required atoms: {', '.join(missing_atoms)}."
|
|
510
|
+
)
|
|
511
|
+
if target_histidine:
|
|
512
|
+
if tautomer not in {"HID", "HIE"}:
|
|
513
|
+
raise HistidineGeometryError("Target histidine tautomer must be HID or HIE.")
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
expected = {
|
|
517
|
+
"HID": (1, 0),
|
|
518
|
+
"HIE": (0, 1),
|
|
519
|
+
"HIP": (1, 1),
|
|
520
|
+
}.get(residue_state)
|
|
521
|
+
if expected is None:
|
|
522
|
+
raise HistidineGeometryError(
|
|
523
|
+
f"Neighbor histidine {residue.id.display()} is unresolved in the xTB cluster. "
|
|
524
|
+
"Assign it manually or let propka_xtb_his resolve all neutral histidines."
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _skip_hydrogen_for_cluster_state(
|
|
529
|
+
atom: AtomRecord,
|
|
530
|
+
residue: ResidueRecord,
|
|
531
|
+
*,
|
|
532
|
+
residue_state: str,
|
|
533
|
+
target_histidine: bool,
|
|
534
|
+
) -> bool:
|
|
535
|
+
if not is_hydrogen_like(atom):
|
|
536
|
+
return False
|
|
537
|
+
if residue.id.resname in {"HIS", "HID", "HIE", "HIP"} and _histidine_hydrogen_is_canonicalized(
|
|
538
|
+
atom,
|
|
539
|
+
residue,
|
|
540
|
+
target_histidine=target_histidine,
|
|
541
|
+
):
|
|
542
|
+
return True
|
|
543
|
+
if residue_state in {"ASP", "GLU"}:
|
|
544
|
+
anchors = ("OD1", "OD2") if residue_state == "ASP" else ("OE1", "OE2")
|
|
545
|
+
return _hydrogen_is_near_any(atom, residue, anchors)
|
|
546
|
+
if residue_state in {"CYM", "CYX"}:
|
|
547
|
+
return _hydrogen_is_near_any(atom, residue, ("SG",))
|
|
548
|
+
if residue_state == "LYN" and _hydrogen_is_near_any(atom, residue, ("NZ",)):
|
|
549
|
+
nz_hydrogens = sorted(
|
|
550
|
+
(
|
|
551
|
+
candidate
|
|
552
|
+
for candidate in residue.atoms
|
|
553
|
+
if is_hydrogen_like(candidate)
|
|
554
|
+
and _hydrogen_is_near_any(candidate, residue, ("NZ",))
|
|
555
|
+
),
|
|
556
|
+
key=lambda candidate: (candidate.name, candidate.serial or 0),
|
|
557
|
+
)
|
|
558
|
+
return any(atom is candidate for candidate in nz_hydrogens[2:])
|
|
559
|
+
return False
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _require_retained_hydrogens(residue: ResidueRecord, fragment_atoms: list[XyzAtom]) -> None:
|
|
563
|
+
non_cap_hydrogens = [
|
|
564
|
+
atom for atom in fragment_atoms if atom.element.upper() == "H" and atom.source == "input"
|
|
565
|
+
]
|
|
566
|
+
if not non_cap_hydrogens:
|
|
567
|
+
raise HistidineGeometryError(
|
|
568
|
+
f"xTB histidine cluster requires a hydrogenated protein model; "
|
|
569
|
+
f"residue {residue.id.display()} has no retained hydrogens after CA truncation. "
|
|
570
|
+
"Provide an input structure with hydrogens or add a manual HIS override."
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _histidine_hydrogen_is_canonicalized(
|
|
575
|
+
atom: AtomRecord,
|
|
576
|
+
residue: ResidueRecord,
|
|
577
|
+
*,
|
|
578
|
+
target_histidine: bool,
|
|
579
|
+
) -> bool:
|
|
580
|
+
return _hydrogen_is_near_any(atom, residue, ("ND1", "NE2"))
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _temporary_histidine_hydrogens(
|
|
584
|
+
residue: ResidueRecord,
|
|
585
|
+
*,
|
|
586
|
+
residue_state: str,
|
|
587
|
+
target_histidine: bool,
|
|
588
|
+
) -> list[XyzAtom]:
|
|
589
|
+
hydrogens: list[XyzAtom] = []
|
|
590
|
+
if _hydrogen_count_near_any(residue, ("CE1",)) < 1:
|
|
591
|
+
hydrogens.append(_place_histidine_carbon_hydrogen(residue, carbon_name="CE1"))
|
|
592
|
+
if _hydrogen_count_near_any(residue, ("CD2",)) < 1:
|
|
593
|
+
hydrogens.append(_place_histidine_carbon_hydrogen(residue, carbon_name="CD2"))
|
|
594
|
+
if target_histidine:
|
|
595
|
+
return hydrogens
|
|
596
|
+
if residue_state == "HID":
|
|
597
|
+
hydrogens.append(place_histidine_tautomer_hydrogen(residue, tautomer="HID"))
|
|
598
|
+
elif residue_state == "HIE":
|
|
599
|
+
hydrogens.append(place_histidine_tautomer_hydrogen(residue, tautomer="HIE"))
|
|
600
|
+
elif residue_state == "HIP":
|
|
601
|
+
hydrogens.append(place_histidine_tautomer_hydrogen(residue, tautomer="HID"))
|
|
602
|
+
hydrogens.append(place_histidine_tautomer_hydrogen(residue, tautomer="HIE"))
|
|
603
|
+
return hydrogens
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _place_histidine_carbon_hydrogen(
|
|
607
|
+
residue: ResidueRecord,
|
|
608
|
+
*,
|
|
609
|
+
carbon_name: str,
|
|
610
|
+
bond_length: float = 1.09,
|
|
611
|
+
) -> XyzAtom:
|
|
612
|
+
if carbon_name == "CE1":
|
|
613
|
+
neighbor_names = ("ND1", "NE2")
|
|
614
|
+
atom_name = "HE1"
|
|
615
|
+
elif carbon_name == "CD2":
|
|
616
|
+
neighbor_names = ("CG", "NE2")
|
|
617
|
+
atom_name = "HD2"
|
|
618
|
+
else:
|
|
619
|
+
raise HistidineGeometryError(f"Unsupported histidine carbon hydrogen anchor {carbon_name!r}.")
|
|
620
|
+
carbon = _atom_by_name(residue, carbon_name)
|
|
621
|
+
neighbor1 = _atom_by_name(residue, neighbor_names[0])
|
|
622
|
+
neighbor2 = _atom_by_name(residue, neighbor_names[1])
|
|
623
|
+
if carbon is None or neighbor1 is None or neighbor2 is None:
|
|
624
|
+
missing = [
|
|
625
|
+
name
|
|
626
|
+
for name, atom in ((carbon_name, carbon), (neighbor_names[0], neighbor1), (neighbor_names[1], neighbor2))
|
|
627
|
+
if atom is None
|
|
628
|
+
]
|
|
629
|
+
raise HistidineGeometryError(
|
|
630
|
+
f"Histidine {residue.id.display()} is missing required atoms for {atom_name}: {', '.join(missing)}."
|
|
631
|
+
)
|
|
632
|
+
direction = (
|
|
633
|
+
2.0 * carbon.x - neighbor1.x - neighbor2.x,
|
|
634
|
+
2.0 * carbon.y - neighbor1.y - neighbor2.y,
|
|
635
|
+
2.0 * carbon.z - neighbor1.z - neighbor2.z,
|
|
636
|
+
)
|
|
637
|
+
unit = _normalize(direction)
|
|
638
|
+
return XyzAtom(
|
|
639
|
+
element="H",
|
|
640
|
+
x=carbon.x + unit[0] * bond_length,
|
|
641
|
+
y=carbon.y + unit[1] * bond_length,
|
|
642
|
+
z=carbon.z + unit[2] * bond_length,
|
|
643
|
+
name=atom_name,
|
|
644
|
+
source="temporary_histidine_hydrogen",
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _require_water_hydrogens(residue: ResidueRecord, atoms: list[XyzAtom]) -> None:
|
|
649
|
+
hydrogens = [atom for atom in atoms if atom.element.upper() == "H"]
|
|
650
|
+
if len(hydrogens) < 2:
|
|
651
|
+
raise HistidineGeometryError(
|
|
652
|
+
f"Water residue {residue.id.display()} is in the xTB histidine cluster but lacks hydrogens. "
|
|
653
|
+
"Enable protonation.histidine.xtb.add_missing_water_hydrogens, remove nearby waters, "
|
|
654
|
+
"or add a manual HIS override."
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _xyz_from_atom(atom: AtomRecord) -> XyzAtom:
|
|
659
|
+
return XyzAtom(element=_element(atom), x=atom.x, y=atom.y, z=atom.z, name=atom.name, source="input")
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _cap_hydrogen_from_ca(ca: AtomRecord, boundary: AtomRecord, name: str, bond_length: float = 1.09) -> XyzAtom:
|
|
663
|
+
direction = (boundary.x - ca.x, boundary.y - ca.y, boundary.z - ca.z)
|
|
664
|
+
unit = _normalize(direction)
|
|
665
|
+
return XyzAtom(
|
|
666
|
+
element="H",
|
|
667
|
+
x=ca.x + unit[0] * bond_length,
|
|
668
|
+
y=ca.y + unit[1] * bond_length,
|
|
669
|
+
z=ca.z + unit[2] * bond_length,
|
|
670
|
+
name=name,
|
|
671
|
+
source="cap",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _normalize(vector: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
676
|
+
norm = sqrt(sum(value * value for value in vector))
|
|
677
|
+
if norm < 1.0e-8:
|
|
678
|
+
raise HistidineGeometryError("Degenerate histidine geometry; cannot place tautomer hydrogen.")
|
|
679
|
+
return (vector[0] / norm, vector[1] / norm, vector[2] / norm)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _element(atom: AtomRecord) -> str:
|
|
683
|
+
return atom.element or atom.name.strip()[0].upper()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _atom_by_name(residue: ResidueRecord, name: str) -> AtomRecord | None:
|
|
687
|
+
return next((atom for atom in residue.atoms if atom.name.strip() == name), None)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def _hydrogen_count_near_any(
|
|
691
|
+
residue: ResidueRecord,
|
|
692
|
+
atom_names: tuple[str, ...],
|
|
693
|
+
*,
|
|
694
|
+
cutoff_angstrom: float = 1.25,
|
|
695
|
+
) -> int:
|
|
696
|
+
return sum(
|
|
697
|
+
1
|
|
698
|
+
for atom in residue.atoms
|
|
699
|
+
if is_hydrogen_like(atom) and _hydrogen_is_near_any(atom, residue, atom_names, cutoff_angstrom=cutoff_angstrom)
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def _hydrogen_is_near_any(
|
|
704
|
+
hydrogen: AtomRecord,
|
|
705
|
+
residue: ResidueRecord,
|
|
706
|
+
atom_names: tuple[str, ...],
|
|
707
|
+
*,
|
|
708
|
+
cutoff_angstrom: float = 1.25,
|
|
709
|
+
) -> bool:
|
|
710
|
+
for name in atom_names:
|
|
711
|
+
anchor = _atom_by_name(residue, name)
|
|
712
|
+
if anchor is None:
|
|
713
|
+
continue
|
|
714
|
+
if dist((hydrogen.x, hydrogen.y, hydrogen.z), (anchor.x, anchor.y, anchor.z)) <= cutoff_angstrom:
|
|
715
|
+
return True
|
|
716
|
+
return False
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _is_oxygen_atom(atom: AtomRecord) -> bool:
|
|
720
|
+
element = _element(atom).upper()
|
|
721
|
+
return element == "O"
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _nearby_nonwater_heavy_atoms(
|
|
725
|
+
cluster_residues: list[ResidueRecord],
|
|
726
|
+
*,
|
|
727
|
+
water: ResidueRecord,
|
|
728
|
+
) -> list[AtomRecord]:
|
|
729
|
+
nearby: list[AtomRecord] = []
|
|
730
|
+
for residue in cluster_residues:
|
|
731
|
+
if residue is water:
|
|
732
|
+
continue
|
|
733
|
+
for atom in residue.atoms:
|
|
734
|
+
if not is_hydrogen_like(atom):
|
|
735
|
+
nearby.append(atom)
|
|
736
|
+
return nearby
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _build_two_water_hydrogens(
|
|
740
|
+
oxygen: AtomRecord,
|
|
741
|
+
*,
|
|
742
|
+
nearby_heavy: list[AtomRecord],
|
|
743
|
+
oh_distance: float,
|
|
744
|
+
hoh_angle_degrees: float,
|
|
745
|
+
) -> tuple[list[tuple[float, float, float]], str]:
|
|
746
|
+
half_angle = radians(hoh_angle_degrees / 2.0)
|
|
747
|
+
candidates: list[tuple[list[tuple[float, float, float]], str]] = []
|
|
748
|
+
for bisector in _candidate_bisectors(oxygen, nearby_heavy):
|
|
749
|
+
basis_a, basis_b = _perpendicular_basis(bisector)
|
|
750
|
+
for phi_degrees in (0.0, 45.0, 90.0, 135.0):
|
|
751
|
+
phi = radians(phi_degrees)
|
|
752
|
+
perpendicular = _add(
|
|
753
|
+
_scale(basis_a, cos(phi)),
|
|
754
|
+
_scale(basis_b, sin(phi)),
|
|
755
|
+
)
|
|
756
|
+
direction1 = _normalize_vec(
|
|
757
|
+
_add(_scale(bisector, cos(half_angle)), _scale(perpendicular, sin(half_angle)))
|
|
758
|
+
)
|
|
759
|
+
direction2 = _normalize_vec(
|
|
760
|
+
_add(_scale(bisector, cos(half_angle)), _scale(perpendicular, -sin(half_angle)))
|
|
761
|
+
)
|
|
762
|
+
candidates.append(
|
|
763
|
+
(
|
|
764
|
+
[
|
|
765
|
+
_point_from_atom(oxygen, direction1, oh_distance),
|
|
766
|
+
_point_from_atom(oxygen, direction2, oh_distance),
|
|
767
|
+
],
|
|
768
|
+
"clash_aware" if nearby_heavy else "deterministic_fallback",
|
|
769
|
+
)
|
|
770
|
+
)
|
|
771
|
+
return _best_water_candidate(candidates, nearby_heavy)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _build_second_water_hydrogen(
|
|
775
|
+
oxygen: AtomRecord,
|
|
776
|
+
existing_hydrogen: AtomRecord,
|
|
777
|
+
*,
|
|
778
|
+
nearby_heavy: list[AtomRecord],
|
|
779
|
+
oh_distance: float,
|
|
780
|
+
hoh_angle_degrees: float,
|
|
781
|
+
) -> tuple[list[tuple[float, float, float]], str]:
|
|
782
|
+
existing_direction = (
|
|
783
|
+
existing_hydrogen.x - oxygen.x,
|
|
784
|
+
existing_hydrogen.y - oxygen.y,
|
|
785
|
+
existing_hydrogen.z - oxygen.z,
|
|
786
|
+
)
|
|
787
|
+
try:
|
|
788
|
+
existing_unit = _normalize_vec(existing_direction)
|
|
789
|
+
except HistidineGeometryError as exc:
|
|
790
|
+
raise HistidineGeometryError(
|
|
791
|
+
f"Water residue {oxygen.chain_id}:{oxygen.resname}{oxygen.resid}{oxygen.icode or ''} "
|
|
792
|
+
"has a degenerate existing O-H vector."
|
|
793
|
+
) from exc
|
|
794
|
+
basis_a, basis_b = _perpendicular_basis(existing_unit)
|
|
795
|
+
angle = radians(hoh_angle_degrees)
|
|
796
|
+
candidates: list[tuple[list[tuple[float, float, float]], str]] = []
|
|
797
|
+
for phi_degrees in (0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0):
|
|
798
|
+
phi = radians(phi_degrees)
|
|
799
|
+
perpendicular = _add(_scale(basis_a, cos(phi)), _scale(basis_b, sin(phi)))
|
|
800
|
+
direction = _normalize_vec(
|
|
801
|
+
_add(_scale(existing_unit, cos(angle)), _scale(perpendicular, sin(angle)))
|
|
802
|
+
)
|
|
803
|
+
candidates.append(
|
|
804
|
+
(
|
|
805
|
+
[_point_from_atom(oxygen, direction, oh_distance)],
|
|
806
|
+
"clash_aware" if nearby_heavy else "deterministic_fallback",
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
return _best_water_candidate(candidates, nearby_heavy)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _candidate_bisectors(
|
|
813
|
+
oxygen: AtomRecord,
|
|
814
|
+
nearby_heavy: list[AtomRecord],
|
|
815
|
+
) -> list[tuple[float, float, float]]:
|
|
816
|
+
candidates: list[tuple[float, float, float]] = []
|
|
817
|
+
if nearby_heavy:
|
|
818
|
+
nearest = min(
|
|
819
|
+
nearby_heavy,
|
|
820
|
+
key=lambda atom: dist((oxygen.x, oxygen.y, oxygen.z), (atom.x, atom.y, atom.z)),
|
|
821
|
+
)
|
|
822
|
+
candidates.append(
|
|
823
|
+
_normalize_vec((oxygen.x - nearest.x, oxygen.y - nearest.y, oxygen.z - nearest.z))
|
|
824
|
+
)
|
|
825
|
+
summed = (0.0, 0.0, 0.0)
|
|
826
|
+
for atom in nearby_heavy:
|
|
827
|
+
direction = _normalize_vec((oxygen.x - atom.x, oxygen.y - atom.y, oxygen.z - atom.z))
|
|
828
|
+
summed = _add(summed, direction)
|
|
829
|
+
try:
|
|
830
|
+
candidates.append(_normalize_vec(summed))
|
|
831
|
+
except HistidineGeometryError:
|
|
832
|
+
pass
|
|
833
|
+
candidates.extend(
|
|
834
|
+
[
|
|
835
|
+
(1.0, 0.0, 0.0),
|
|
836
|
+
(0.0, 1.0, 0.0),
|
|
837
|
+
(0.0, 0.0, 1.0),
|
|
838
|
+
(-1.0, 0.0, 0.0),
|
|
839
|
+
(0.0, -1.0, 0.0),
|
|
840
|
+
(0.0, 0.0, -1.0),
|
|
841
|
+
]
|
|
842
|
+
)
|
|
843
|
+
unique: list[tuple[float, float, float]] = []
|
|
844
|
+
seen: set[tuple[int, int, int]] = set()
|
|
845
|
+
for candidate in candidates:
|
|
846
|
+
key = tuple(round(value * 1000) for value in candidate)
|
|
847
|
+
if key not in seen:
|
|
848
|
+
seen.add(key)
|
|
849
|
+
unique.append(candidate)
|
|
850
|
+
return unique
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _best_water_candidate(
|
|
854
|
+
candidates: list[tuple[list[tuple[float, float, float]], str]],
|
|
855
|
+
nearby_heavy: list[AtomRecord],
|
|
856
|
+
) -> tuple[list[tuple[float, float, float]], str]:
|
|
857
|
+
if not candidates:
|
|
858
|
+
raise HistidineGeometryError("No candidate water hydrogen coordinates were generated.")
|
|
859
|
+
best_coordinates, best_orientation = candidates[0]
|
|
860
|
+
best_score = _score_water_candidate(best_coordinates, nearby_heavy)
|
|
861
|
+
for coordinates, orientation in candidates[1:]:
|
|
862
|
+
score = _score_water_candidate(coordinates, nearby_heavy)
|
|
863
|
+
if score > best_score:
|
|
864
|
+
best_coordinates = coordinates
|
|
865
|
+
best_orientation = orientation
|
|
866
|
+
best_score = score
|
|
867
|
+
return best_coordinates, best_orientation
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
def _score_water_candidate(
|
|
871
|
+
coordinates: list[tuple[float, float, float]],
|
|
872
|
+
nearby_heavy: list[AtomRecord],
|
|
873
|
+
) -> float:
|
|
874
|
+
if not nearby_heavy:
|
|
875
|
+
return float("inf")
|
|
876
|
+
return min(
|
|
877
|
+
dist(coordinate, (atom.x, atom.y, atom.z))
|
|
878
|
+
for coordinate in coordinates
|
|
879
|
+
for atom in nearby_heavy
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _next_water_hydrogen_name(existing_names: set[str]) -> str:
|
|
884
|
+
for name in ("H1", "H2", "H3", "HW1", "HW2"):
|
|
885
|
+
if name not in existing_names:
|
|
886
|
+
existing_names.add(name)
|
|
887
|
+
return name
|
|
888
|
+
index = 1
|
|
889
|
+
while True:
|
|
890
|
+
name = f"HT{index}"
|
|
891
|
+
if name not in existing_names:
|
|
892
|
+
existing_names.add(name)
|
|
893
|
+
return name
|
|
894
|
+
index += 1
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def _point_from_atom(
|
|
898
|
+
atom: AtomRecord,
|
|
899
|
+
direction: tuple[float, float, float],
|
|
900
|
+
distance_angstrom: float,
|
|
901
|
+
) -> tuple[float, float, float]:
|
|
902
|
+
return (
|
|
903
|
+
atom.x + direction[0] * distance_angstrom,
|
|
904
|
+
atom.y + direction[1] * distance_angstrom,
|
|
905
|
+
atom.z + direction[2] * distance_angstrom,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _perpendicular_basis(
|
|
910
|
+
vector: tuple[float, float, float],
|
|
911
|
+
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
|
|
912
|
+
reference = (1.0, 0.0, 0.0)
|
|
913
|
+
if abs(_dot(vector, reference)) > 0.8:
|
|
914
|
+
reference = (0.0, 1.0, 0.0)
|
|
915
|
+
basis_a = _normalize_vec(_cross(vector, reference))
|
|
916
|
+
basis_b = _normalize_vec(_cross(vector, basis_a))
|
|
917
|
+
return basis_a, basis_b
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _normalize_vec(vector: tuple[float, float, float]) -> tuple[float, float, float]:
|
|
921
|
+
norm = sqrt(sum(value * value for value in vector))
|
|
922
|
+
if norm < 1.0e-8:
|
|
923
|
+
raise HistidineGeometryError("Degenerate geometry; cannot place temporary hydrogen.")
|
|
924
|
+
return (vector[0] / norm, vector[1] / norm, vector[2] / norm)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _add(
|
|
928
|
+
a: tuple[float, float, float],
|
|
929
|
+
b: tuple[float, float, float],
|
|
930
|
+
) -> tuple[float, float, float]:
|
|
931
|
+
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _scale(vector: tuple[float, float, float], factor: float) -> tuple[float, float, float]:
|
|
935
|
+
return (vector[0] * factor, vector[1] * factor, vector[2] * factor)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _dot(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
|
|
939
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
def _cross(
|
|
943
|
+
a: tuple[float, float, float],
|
|
944
|
+
b: tuple[float, float, float],
|
|
945
|
+
) -> tuple[float, float, float]:
|
|
946
|
+
return (
|
|
947
|
+
a[1] * b[2] - a[2] * b[1],
|
|
948
|
+
a[2] * b[0] - a[0] * b[2],
|
|
949
|
+
a[0] * b[1] - a[1] * b[0],
|
|
950
|
+
)
|