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