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,390 @@
1
+ """xTB HID/HIE histidine tautomer selection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from itertools import chain
7
+ import json
8
+ from pathlib import Path
9
+ import shutil
10
+
11
+ from mdprep.config.models import ManifestConfig
12
+ from mdprep.protonation.histidine_geometry import (
13
+ HistidineGeometryError,
14
+ build_tautomer_cluster_model,
15
+ heavy_atom_distance,
16
+ is_hydrogen_like,
17
+ write_xcontrol_fix_file,
18
+ write_xyz,
19
+ )
20
+ from mdprep.protonation.xtb_parser import XtbEnergyComparison, compare_hid_hie_energies, parse_xtb_energy_file
21
+ from mdprep.protonation.xtb_runner import XtbExecutionError, run_xtb
22
+ from mdprep.structure.classify import is_likely_ligand_or_cofactor, is_water_residue
23
+ from mdprep.structure.models import PdbStructure, ResidueRecord
24
+ from mdprep.structure.selectors import SelectorError, resolve_residue_selector
25
+
26
+
27
+ class HistidineXtbError(ValueError):
28
+ """Raised when HID/HIE xTB selection cannot be completed."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class HistidineXtbSelection:
33
+ residue: ResidueRecord
34
+ hid_energy_hartree: float
35
+ hie_energy_hartree: float
36
+ delta_kcal_mol: float
37
+ selected_state: str
38
+ close_call: bool
39
+ warnings: list[str]
40
+ mode: str
41
+ model: str
42
+ executable: str
43
+ cluster_charge: int
44
+ output_dir: Path
45
+ temporary_water_hydrogens_added: int
46
+ waters_modified_for_xtb_only: list[dict[str, object]]
47
+ final_pdb_modified_by_temporary_water_hydrogens: bool
48
+
49
+ def to_dict(self) -> dict[str, object]:
50
+ return {
51
+ "chain": self.residue.id.chain_id,
52
+ "resid": self.residue.id.resid,
53
+ "icode": self.residue.id.icode,
54
+ "hid_energy_hartree": self.hid_energy_hartree,
55
+ "hie_energy_hartree": self.hie_energy_hartree,
56
+ "delta_kcal_mol": self.delta_kcal_mol,
57
+ "selected_state": self.selected_state,
58
+ "close_call": self.close_call,
59
+ "warnings": self.warnings,
60
+ "mode": self.mode,
61
+ "model": self.model,
62
+ "executable": self.executable,
63
+ "cluster_charge": self.cluster_charge,
64
+ "output_dir": str(self.output_dir),
65
+ "temporary_water_hydrogens_added": self.temporary_water_hydrogens_added,
66
+ "waters_modified_for_xtb_only": self.waters_modified_for_xtb_only,
67
+ "final_pdb_modified_by_temporary_water_hydrogens": self.final_pdb_modified_by_temporary_water_hydrogens,
68
+ }
69
+
70
+
71
+ def select_histidine_tautomer(
72
+ structure: PdbStructure,
73
+ histidine: ResidueRecord,
74
+ manifest: ManifestConfig,
75
+ *,
76
+ work_dir: str | Path,
77
+ planned_states: dict[int, str],
78
+ ) -> HistidineXtbSelection:
79
+ config = manifest.protonation.histidine.xtb
80
+ hist_dir = Path(work_dir) / _histidine_dir_name(histidine)
81
+ hist_dir.mkdir(parents=True, exist_ok=True)
82
+ cluster_residues = build_histidine_cluster(
83
+ structure,
84
+ histidine,
85
+ cutoff_angstrom=config.cutoff_angstrom,
86
+ )
87
+ charge_breakdown = cluster_charge_breakdown(cluster_residues, manifest, planned_states)
88
+ cluster_charge = sum(int(item["charge"]) for item in charge_breakdown)
89
+ hid_xyz = hist_dir / "HID.xyz"
90
+ hie_xyz = hist_dir / "HIE.xyz"
91
+ try:
92
+ cluster_kwargs = {
93
+ "residue_states": planned_states,
94
+ "add_missing_water_hydrogens": config.add_missing_water_hydrogens,
95
+ "water_oh_distance_angstrom": config.water_oh_distance_angstrom,
96
+ "water_hoh_angle_degrees": config.water_hoh_angle_degrees,
97
+ }
98
+ hid_model = build_tautomer_cluster_model(
99
+ cluster_residues,
100
+ histidine,
101
+ tautomer="HID",
102
+ **cluster_kwargs,
103
+ )
104
+ hie_model = build_tautomer_cluster_model(
105
+ cluster_residues,
106
+ histidine,
107
+ tautomer="HIE",
108
+ **cluster_kwargs,
109
+ )
110
+ write_xyz(hid_model.atoms, hid_xyz, comment="HID")
111
+ write_xyz(hie_model.atoms, hie_xyz, comment="HIE")
112
+ hid_input = hist_dir / "HID_xtb.inp"
113
+ hie_input = hist_dir / "HIE_xtb.inp"
114
+ write_xcontrol_fix_file(hid_model.fixed_atom_indices, hid_input)
115
+ write_xcontrol_fix_file(hie_model.fixed_atom_indices, hie_input)
116
+ except HistidineGeometryError as exc:
117
+ raise HistidineXtbError(str(exc)) from exc
118
+
119
+ hid_run_dir, hid_run_xyz, hid_run_input = _prepare_isolated_xtb_work_dir(
120
+ hist_dir,
121
+ tautomer="HID",
122
+ xyz_path=hid_xyz,
123
+ input_path=hid_input,
124
+ )
125
+ hie_run_dir, hie_run_xyz, hie_run_input = _prepare_isolated_xtb_work_dir(
126
+ hist_dir,
127
+ tautomer="HIE",
128
+ xyz_path=hie_xyz,
129
+ input_path=hie_input,
130
+ )
131
+ hid_stdout = hid_run_dir / "HID_xtb_stdout.txt"
132
+ hie_stdout = hie_run_dir / "HIE_xtb_stdout.txt"
133
+ hid_stderr = hid_run_dir / "HID_xtb_stderr.txt"
134
+ hie_stderr = hie_run_dir / "HIE_xtb_stderr.txt"
135
+ try:
136
+ hid_run = run_xtb(
137
+ config=config,
138
+ xyz_path=hid_run_xyz,
139
+ work_dir=hid_run_dir,
140
+ cluster_charge=cluster_charge,
141
+ stdout_path=hid_stdout,
142
+ stderr_path=hid_stderr,
143
+ input_path=hid_run_input if config.mode == "opt" and hid_model.fixed_atom_indices else None,
144
+ )
145
+ hie_run = run_xtb(
146
+ config=config,
147
+ xyz_path=hie_run_xyz,
148
+ work_dir=hie_run_dir,
149
+ cluster_charge=cluster_charge,
150
+ stdout_path=hie_stdout,
151
+ stderr_path=hie_stderr,
152
+ input_path=hie_run_input if config.mode == "opt" and hie_model.fixed_atom_indices else None,
153
+ )
154
+ hid_energy = parse_xtb_energy_file(hid_stdout)
155
+ hie_energy = parse_xtb_energy_file(hie_stdout)
156
+ except (XtbExecutionError, ValueError) as exc:
157
+ raise HistidineXtbError(str(exc)) from exc
158
+
159
+ comparison = compare_hid_hie_energies(
160
+ hid_energy_hartree=hid_energy,
161
+ hie_energy_hartree=hie_energy,
162
+ close_call_kcal_mol=config.energy_close_call_kcal_mol,
163
+ )
164
+ temporary_water_records = [record.to_dict() for record in hid_model.temporary_water_hydrogens]
165
+ model_warnings = _unique_strings(chain(hid_model.warnings, hie_model.warnings))
166
+ selection = HistidineXtbSelection(
167
+ residue=histidine,
168
+ hid_energy_hartree=comparison.hid_energy_hartree,
169
+ hie_energy_hartree=comparison.hie_energy_hartree,
170
+ delta_kcal_mol=comparison.delta_kcal_mol,
171
+ selected_state=comparison.selected_state,
172
+ close_call=comparison.close_call,
173
+ warnings=comparison.warnings + model_warnings,
174
+ mode=config.mode,
175
+ model=config.model,
176
+ executable=hid_run.command_result.command[0],
177
+ cluster_charge=cluster_charge,
178
+ output_dir=hist_dir,
179
+ temporary_water_hydrogens_added=sum(
180
+ int(record["hydrogens_added"]) for record in temporary_water_records
181
+ ),
182
+ waters_modified_for_xtb_only=temporary_water_records,
183
+ final_pdb_modified_by_temporary_water_hydrogens=False,
184
+ )
185
+ cluster_summary = {
186
+ "HID": hid_model.to_dict(),
187
+ "HIE": hie_model.to_dict(),
188
+ "model": "CA-truncated hydrogen-preserving cluster with fixed CA/capping atoms",
189
+ "cluster_charge": cluster_charge,
190
+ "charge_breakdown": charge_breakdown,
191
+ "HID_element_counts": _element_counts(hid_model.atoms),
192
+ "HIE_element_counts": _element_counts(hie_model.atoms),
193
+ "HID_min_interatomic_distance": _min_interatomic_distance(hid_model.atoms),
194
+ "HIE_min_interatomic_distance": _min_interatomic_distance(hie_model.atoms),
195
+ "tautomer_work_directories": {
196
+ "HID": str(hid_run_dir),
197
+ "HIE": str(hie_run_dir),
198
+ },
199
+ "temporary_water_hydrogens_for_xtb_only": {
200
+ "hydrogens_added": selection.temporary_water_hydrogens_added,
201
+ "waters_modified": selection.waters_modified_for_xtb_only,
202
+ "final_pdb_modified": False,
203
+ },
204
+ }
205
+ (hist_dir / "cluster_model.json").write_text(
206
+ json.dumps(cluster_summary, indent=2, sort_keys=True) + "\n",
207
+ encoding="utf-8",
208
+ )
209
+ (hist_dir / "energies.json").write_text(
210
+ json.dumps(selection.to_dict(), indent=2, sort_keys=True) + "\n",
211
+ encoding="utf-8",
212
+ )
213
+ return selection
214
+
215
+
216
+ def _prepare_isolated_xtb_work_dir(
217
+ histidine_dir: Path,
218
+ *,
219
+ tautomer: str,
220
+ xyz_path: Path,
221
+ input_path: Path,
222
+ ) -> tuple[Path, Path, Path]:
223
+ """Create a clean xTB directory so restart files cannot cross-contaminate trials."""
224
+ run_dir = histidine_dir / tautomer
225
+ if run_dir.exists():
226
+ shutil.rmtree(run_dir)
227
+ run_dir.mkdir(parents=True)
228
+ run_xyz = run_dir / xyz_path.name
229
+ run_input = run_dir / input_path.name
230
+ shutil.copyfile(xyz_path, run_xyz)
231
+ shutil.copyfile(input_path, run_input)
232
+ return run_dir, run_xyz, run_input
233
+
234
+
235
+ def build_histidine_cluster(
236
+ structure: PdbStructure,
237
+ histidine: ResidueRecord,
238
+ *,
239
+ cutoff_angstrom: float,
240
+ ) -> list[ResidueRecord]:
241
+ hist_heavy = [atom for atom in histidine.atoms if not is_hydrogen_like(atom)]
242
+ if not hist_heavy:
243
+ raise HistidineXtbError(f"Histidine {histidine.id.display()} has no heavy atoms.")
244
+ cluster: list[ResidueRecord] = []
245
+ for residue in structure.residues:
246
+ if residue is histidine:
247
+ cluster.append(residue)
248
+ continue
249
+ for atom in residue.atoms:
250
+ if is_hydrogen_like(atom):
251
+ continue
252
+ if any(heavy_atom_distance(atom, hist_atom) <= cutoff_angstrom for hist_atom in hist_heavy):
253
+ cluster.append(residue)
254
+ break
255
+ return cluster
256
+
257
+
258
+ def estimate_cluster_charge(
259
+ cluster_residues: list[ResidueRecord],
260
+ manifest: ManifestConfig,
261
+ planned_states: dict[int, str],
262
+ ) -> int:
263
+ return sum(int(item["charge"]) for item in cluster_charge_breakdown(cluster_residues, manifest, planned_states))
264
+
265
+
266
+ def cluster_charge_breakdown(
267
+ cluster_residues: list[ResidueRecord],
268
+ manifest: ManifestConfig,
269
+ planned_states: dict[int, str],
270
+ ) -> list[dict[str, object]]:
271
+ ligand_charges = _ligand_charge_by_residue(cluster_residues, manifest)
272
+ terms: list[dict[str, object]] = []
273
+ for residue in cluster_residues:
274
+ if id(residue) in ligand_charges:
275
+ terms.append(
276
+ {
277
+ "residue": residue.id.display(),
278
+ "state": residue.id.resname,
279
+ "charge": ligand_charges[id(residue)],
280
+ "source": "configured_ligand",
281
+ }
282
+ )
283
+ continue
284
+ resname = planned_states.get(id(residue), residue.id.resname)
285
+ if is_water_residue(residue):
286
+ terms.append(
287
+ {
288
+ "residue": residue.id.display(),
289
+ "state": resname,
290
+ "charge": 0,
291
+ "source": "water",
292
+ }
293
+ )
294
+ continue
295
+ if is_likely_ligand_or_cofactor(residue):
296
+ raise HistidineXtbError(
297
+ f"Unknown heterogen {residue.id.display()} encountered in xTB cluster; configure it under ligands."
298
+ )
299
+ residue_charge = {
300
+ "ASP": -1,
301
+ "GLU": -1,
302
+ "CYM": -1,
303
+ "ASH": 0,
304
+ "GLH": 0,
305
+ "CYS": 0,
306
+ "CYX": 0,
307
+ "HID": 0,
308
+ "HIE": 0,
309
+ "HIS": 0,
310
+ "LYS": 1,
311
+ "ARG": 1,
312
+ "HIP": 1,
313
+ "LYN": 0,
314
+ }.get(resname, 0)
315
+ terms.append(
316
+ {
317
+ "residue": residue.id.display(),
318
+ "state": resname,
319
+ "charge": residue_charge,
320
+ "source": "planned_state" if id(residue) in planned_states else "input_state_or_default",
321
+ }
322
+ )
323
+ return terms
324
+
325
+
326
+ def _ligand_charge_by_residue(
327
+ residues: list[ResidueRecord],
328
+ manifest: ManifestConfig,
329
+ ) -> dict[int, int]:
330
+ result: dict[int, int] = {}
331
+ structure = PdbStructure(
332
+ path=Path("cluster"),
333
+ atoms=[atom for residue in residues for atom in residue.atoms],
334
+ residues=residues,
335
+ model_count=1,
336
+ )
337
+ for ligand in manifest.ligands:
338
+ try:
339
+ residue = resolve_residue_selector(structure, ligand.selector.model_dump())
340
+ except SelectorError:
341
+ continue
342
+ result[id(residue)] = ligand.net_charge
343
+ return result
344
+
345
+
346
+ def _histidine_dir_name(residue: ResidueRecord) -> str:
347
+ chain = residue.id.chain_id if residue.id.chain_id else "blank"
348
+ return f"{chain}_HIS{residue.id.resid}{residue.id.icode or ''}"
349
+
350
+
351
+ def _unique_strings(values) -> list[str]:
352
+ result: list[str] = []
353
+ seen: set[str] = set()
354
+ for value in values:
355
+ if value not in seen:
356
+ seen.add(value)
357
+ result.append(value)
358
+ return result
359
+
360
+
361
+ def _element_counts(atoms) -> dict[str, int]:
362
+ counts: dict[str, int] = {}
363
+ for atom in atoms:
364
+ counts[atom.element] = counts.get(atom.element, 0) + 1
365
+ return dict(sorted(counts.items()))
366
+
367
+
368
+ def _min_interatomic_distance(atoms) -> dict[str, object] | None:
369
+ if len(atoms) < 2:
370
+ return None
371
+ best = None
372
+ best_pair = None
373
+ for left_index, left in enumerate(atoms):
374
+ for right_index in range(left_index + 1, len(atoms)):
375
+ right = atoms[right_index]
376
+ distance = (
377
+ (left.x - right.x) ** 2
378
+ + (left.y - right.y) ** 2
379
+ + (left.z - right.z) ** 2
380
+ ) ** 0.5
381
+ if best is None or distance < best:
382
+ best = distance
383
+ best_pair = (left_index + 1, left.name, left.element, right_index + 1, right.name, right.element)
384
+ if best_pair is None:
385
+ return None
386
+ return {
387
+ "distance_angstrom": best,
388
+ "atom1": {"index": best_pair[0], "name": best_pair[1], "element": best_pair[2]},
389
+ "atom2": {"index": best_pair[3], "name": best_pair[4], "element": best_pair[5]},
390
+ }
@@ -0,0 +1,64 @@
1
+ """Manual protonation override resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from mdprep.config.models import ManifestConfig
8
+ from mdprep.protonation.states import validate_state_transition
9
+ from mdprep.structure.models import ResidueRecord
10
+ from mdprep.structure.selectors import SelectorError, resolve_residue_selector
11
+ from mdprep.structure.models import PdbStructure
12
+
13
+
14
+ class ManualOverrideError(ValueError):
15
+ """Raised when a manual protonation override cannot be applied."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ManualOverrideAssignment:
20
+ residue: ResidueRecord
21
+ requested_state: str
22
+ reason: str
23
+ selector: dict[str, object]
24
+
25
+
26
+ def resolve_manual_overrides(
27
+ structure: PdbStructure,
28
+ manifest: ManifestConfig,
29
+ ) -> list[ManualOverrideAssignment]:
30
+ assignments: list[ManualOverrideAssignment] = []
31
+ for override in manifest.protonation.overrides:
32
+ selector_data = override.selector.model_dump()
33
+ selector_display = _selector_display(selector_data)
34
+ try:
35
+ residue = resolve_residue_selector(structure, selector_data)
36
+ except SelectorError as exc:
37
+ raise ManualOverrideError(
38
+ f"Manual protonation selector did not resolve exactly one residue: {selector_display}. {exc}"
39
+ ) from exc
40
+ try:
41
+ validate_state_transition(
42
+ current_resname=residue.id.resname,
43
+ requested_state=override.state,
44
+ selector=selector_display,
45
+ )
46
+ except ValueError as exc:
47
+ raise ManualOverrideError(str(exc)) from exc
48
+ assignments.append(
49
+ ManualOverrideAssignment(
50
+ residue=residue,
51
+ requested_state=override.state,
52
+ reason=override.reason,
53
+ selector=selector_data,
54
+ )
55
+ )
56
+ return assignments
57
+
58
+
59
+ def _selector_display(selector: dict[str, object]) -> str:
60
+ chain = selector.get("chain", selector.get("chain_id", ""))
61
+ chain_display = chain if chain != "" else "<blank>"
62
+ icode = selector.get("icode") or ""
63
+ return f"{chain_display}:{selector.get('resname', '')}{selector['resid']}{icode}"
64
+
@@ -0,0 +1,165 @@
1
+ """pH/pKa residue-state assignment rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Literal
7
+
8
+ from mdprep.protonation.propka_parser import PropkaRecord
9
+ from mdprep.structure.models import ResidueRecord
10
+
11
+
12
+ class PkaRuleError(ValueError):
13
+ """Raised when pKa rules require unsupported automatic chemistry."""
14
+
15
+
16
+ AutoSource = Literal["propka", "propka_xtb_his", "input_state"]
17
+
18
+
19
+ AMBER_INPUT_STATES = {"ASH", "GLH", "HID", "HIE", "HIP", "LYN", "CYM", "CYX"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class PkaDecision:
24
+ residue: ResidueRecord
25
+ final_state: str | None
26
+ source: AutoSource
27
+ reason: str
28
+ pka: float | None = None
29
+ needs_xtb: bool = False
30
+ warnings: list[str] = field(default_factory=list)
31
+
32
+
33
+ def decide_residue_state(
34
+ residue: ResidueRecord,
35
+ *,
36
+ record: PropkaRecord | None,
37
+ ph: float,
38
+ method: Literal["propka", "propka_xtb_his"],
39
+ ) -> PkaDecision | None:
40
+ resname = residue.id.resname
41
+ if resname in AMBER_INPUT_STATES:
42
+ return PkaDecision(
43
+ residue=residue,
44
+ final_state=resname,
45
+ source="input_state",
46
+ reason="Amber-specific input residue state preserved",
47
+ pka=record.pka if record else None,
48
+ warnings=_close_to_ph_warning(residue, ph, record),
49
+ )
50
+
51
+ if resname not in {"ASP", "GLU", "CYS", "LYS", "ARG", "HIS"}:
52
+ return None
53
+
54
+ warnings = _close_to_ph_warning(residue, ph, record)
55
+ if record is None:
56
+ warnings.append(f"Missing pKa for {_format_residue(residue)}; using conservative default.")
57
+ if resname == "HIS":
58
+ return _neutral_histidine_without_pka(residue, ph, method, warnings)
59
+ return PkaDecision(
60
+ residue=residue,
61
+ final_state=_conservative_default(resname),
62
+ source="propka",
63
+ reason="Missing PropKa pKa; conservative default used",
64
+ pka=None,
65
+ warnings=warnings,
66
+ )
67
+
68
+ protonated = ph < record.pka
69
+ if resname == "ASP":
70
+ return _decision(residue, "ASH" if protonated else "ASP", record, ph, warnings)
71
+ if resname == "GLU":
72
+ return _decision(residue, "GLH" if protonated else "GLU", record, ph, warnings)
73
+ if resname == "CYS":
74
+ return _decision(residue, "CYS" if protonated else "CYM", record, ph, warnings)
75
+ if resname == "LYS":
76
+ return _decision(residue, "LYS" if protonated else "LYN", record, ph, warnings)
77
+ if resname == "ARG":
78
+ if not protonated:
79
+ warnings.append(
80
+ "ARG deprotonation is not automatically represented in mdprep v0.2; "
81
+ "use a manual override or custom parameters if needed."
82
+ )
83
+ return _decision(residue, "ARG", record, ph, warnings)
84
+ if resname == "HIS":
85
+ if protonated:
86
+ return _decision(residue, "HIP", record, ph, warnings)
87
+ if method == "propka":
88
+ raise PkaRuleError(
89
+ f"Neutral HIS {_format_residue(residue)} requires HID/HIE assignment; "
90
+ "use method: propka_xtb_his or add a manual HIS override to HID/HIE/HIP."
91
+ )
92
+ return PkaDecision(
93
+ residue=residue,
94
+ final_state=None,
95
+ source="propka_xtb_his",
96
+ reason="Neutral HIS requires HID/HIE xTB comparison",
97
+ pka=record.pka,
98
+ needs_xtb=True,
99
+ warnings=warnings,
100
+ )
101
+ return None
102
+
103
+
104
+ def _neutral_histidine_without_pka(
105
+ residue: ResidueRecord,
106
+ ph: float,
107
+ method: Literal["propka", "propka_xtb_his"],
108
+ warnings: list[str],
109
+ ) -> PkaDecision:
110
+ if method == "propka":
111
+ raise PkaRuleError(
112
+ f"Neutral HIS {_format_residue(residue)} has no pKa record and requires HID/HIE assignment; "
113
+ "use method: propka_xtb_his or add a manual HIS override to HID/HIE/HIP."
114
+ )
115
+ return PkaDecision(
116
+ residue=residue,
117
+ final_state=None,
118
+ source="propka_xtb_his",
119
+ reason="Missing HIS pKa; conservative neutral HIS sent to xTB comparison",
120
+ pka=None,
121
+ needs_xtb=True,
122
+ warnings=warnings,
123
+ )
124
+
125
+
126
+ def _decision(
127
+ residue: ResidueRecord,
128
+ state: str,
129
+ record: PropkaRecord,
130
+ ph: float,
131
+ warnings: list[str],
132
+ ) -> PkaDecision:
133
+ return PkaDecision(
134
+ residue=residue,
135
+ final_state=state,
136
+ source="propka",
137
+ reason=f"pH {ph:g} {'<' if ph < record.pka else '>='} pKa {record.pka:g}",
138
+ pka=record.pka,
139
+ warnings=warnings,
140
+ )
141
+
142
+
143
+ def _close_to_ph_warning(
144
+ residue: ResidueRecord,
145
+ ph: float,
146
+ record: PropkaRecord | None,
147
+ ) -> list[str]:
148
+ if record is not None and abs(ph - record.pka) <= 0.5:
149
+ return [f"pKa for {_format_residue(residue)} is within 0.5 pH units of target pH."]
150
+ return []
151
+
152
+
153
+ def _conservative_default(resname: str) -> str:
154
+ return {
155
+ "ASP": "ASP",
156
+ "GLU": "GLU",
157
+ "CYS": "CYS",
158
+ "LYS": "LYS",
159
+ "ARG": "ARG",
160
+ }[resname]
161
+
162
+
163
+ def _format_residue(residue: ResidueRecord) -> str:
164
+ chain = residue.id.chain_id if residue.id.chain_id else "<blank>"
165
+ return f"{chain}:{residue.id.resname}{residue.id.resid}{residue.id.icode or ''}"