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,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
+ )