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,238 @@
1
+ """Temporary PDBFixer hydrogenation for xTB histidine-tautomer clusters.
2
+
3
+ The generated hydrogens are environment atoms for HID/HIE ranking only. They
4
+ are never transferred to the protonation-stage output structure; the normal
5
+ Amber/tleap hydrogenation remains authoritative for the prepared system.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import OrderedDict
11
+ from dataclasses import dataclass, replace
12
+ from importlib.metadata import PackageNotFoundError, version
13
+ from math import dist
14
+ from pathlib import Path
15
+ import random
16
+
17
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
18
+ from mdprep.structure.pdb import read_pdb
19
+ from mdprep.structure.writer import write_pdb
20
+
21
+
22
+ class TemporaryHydrogenationError(ValueError):
23
+ """Raised when a safe temporary hydrogenated environment cannot be built."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TemporaryHydrogenationResult:
28
+ backend: str
29
+ backend_version: str | None
30
+ platform: str
31
+ ph: float
32
+ random_seed: int
33
+ input_path: Path
34
+ raw_output_path: Path
35
+ restored_output_path: Path
36
+ structure: PdbStructure
37
+ added_hydrogen_count: int
38
+ maximum_original_atom_displacement_angstrom: float
39
+ final_prepared_pdb_modified: bool = False
40
+
41
+ def to_dict(self) -> dict[str, object]:
42
+ return {
43
+ "backend": self.backend,
44
+ "backend_version": self.backend_version,
45
+ "platform": self.platform,
46
+ "ph": self.ph,
47
+ "random_seed": self.random_seed,
48
+ "input_path": str(self.input_path),
49
+ "raw_output_path": str(self.raw_output_path),
50
+ "restored_output_path": str(self.restored_output_path),
51
+ "added_hydrogen_count": self.added_hydrogen_count,
52
+ "maximum_original_atom_displacement_angstrom": (
53
+ self.maximum_original_atom_displacement_angstrom
54
+ ),
55
+ "final_prepared_pdb_modified": self.final_prepared_pdb_modified,
56
+ "interpretation": (
57
+ "PDBFixer hydrogens complete the local xTB HID/HIE environment only. "
58
+ "They are not copied into the protonation-stage or final prepared PDB."
59
+ ),
60
+ }
61
+
62
+
63
+ def add_temporary_protein_hydrogens(
64
+ structure: PdbStructure,
65
+ *,
66
+ ph: float,
67
+ work_dir: str | Path,
68
+ random_seed: int = 20260722,
69
+ ) -> TemporaryHydrogenationResult:
70
+ """Add a complete temporary H environment without changing input atoms."""
71
+
72
+ try:
73
+ from openmm import Platform
74
+ from openmm.app import PDBFile
75
+ from pdbfixer import PDBFixer
76
+ except Exception as exc:
77
+ raise TemporaryHydrogenationError(
78
+ "protonation.histidine.xtb.add_missing_protein_hydrogens was requested, "
79
+ "but PDBFixer and OpenMM are unavailable. Install PDBFixer or set the "
80
+ "option to false and provide a fully hydrogenated input model."
81
+ ) from exc
82
+
83
+ work = Path(work_dir)
84
+ work.mkdir(parents=True, exist_ok=True)
85
+ input_path = work / "histidine_xtb_environment_input.pdb"
86
+ raw_path = work / "histidine_xtb_environment.pdbfixer_raw.pdb"
87
+ restored_path = work / "histidine_xtb_environment.hydrogenated.pdb"
88
+ write_pdb(structure, input_path)
89
+
90
+ try:
91
+ platform_name = "Reference"
92
+ platform = Platform.getPlatformByName(platform_name)
93
+ fixer = PDBFixer(filename=str(input_path), platform=platform)
94
+ # OpenMM Modeller places hydrogens with Python's global random
95
+ # generator before minimizing them. Restore the caller's RNG state
96
+ # after making this scientific input reproducible.
97
+ random_state = random.getstate()
98
+ random.seed(random_seed)
99
+ try:
100
+ fixer.addMissingHydrogens(float(ph))
101
+ finally:
102
+ random.setstate(random_state)
103
+ with raw_path.open("w", encoding="utf-8") as handle:
104
+ PDBFile.writeFile(fixer.topology, fixer.positions, handle, keepIds=True)
105
+ except Exception as exc:
106
+ raise TemporaryHydrogenationError(
107
+ f"PDBFixer could not build the temporary xTB hydrogen environment: {exc}"
108
+ ) from exc
109
+
110
+ raw = read_pdb(raw_path)
111
+ restored, maximum_displacement = _restore_and_validate(
112
+ structure,
113
+ raw,
114
+ output_path=restored_path,
115
+ )
116
+ write_pdb(restored, restored_path)
117
+ added = len(restored.atoms) - len(structure.atoms)
118
+ if added <= 0:
119
+ raise TemporaryHydrogenationError(
120
+ "PDBFixer did not add any temporary hydrogens to a dehydrogenated "
121
+ "histidine-ranking environment."
122
+ )
123
+ return TemporaryHydrogenationResult(
124
+ backend="pdbfixer",
125
+ backend_version=_package_version("pdbfixer"),
126
+ platform=platform_name,
127
+ ph=float(ph),
128
+ random_seed=random_seed,
129
+ input_path=input_path,
130
+ raw_output_path=raw_path,
131
+ restored_output_path=restored_path,
132
+ structure=restored,
133
+ added_hydrogen_count=added,
134
+ maximum_original_atom_displacement_angstrom=maximum_displacement,
135
+ )
136
+
137
+
138
+ def _restore_and_validate(
139
+ reference: PdbStructure,
140
+ observed: PdbStructure,
141
+ *,
142
+ output_path: Path,
143
+ ) -> tuple[PdbStructure, float]:
144
+ if len(observed.residues) != len(reference.residues):
145
+ raise TemporaryHydrogenationError(
146
+ "Temporary PDBFixer hydrogenation changed the residue count: "
147
+ f"{len(reference.residues)} -> {len(observed.residues)}."
148
+ )
149
+ atoms: list[AtomRecord] = []
150
+ maximum_displacement = 0.0
151
+ serial = 0
152
+ for expected, actual in zip(reference.residues, observed.residues, strict=True):
153
+ expected_key = (expected.id.chain_id, expected.id.resid, expected.id.icode)
154
+ actual_key = (actual.id.chain_id, actual.id.resid, actual.id.icode)
155
+ if expected_key != actual_key or expected.id.resname != actual.id.resname:
156
+ raise TemporaryHydrogenationError(
157
+ "Temporary PDBFixer hydrogenation changed residue identity/order: "
158
+ f"expected {expected.id.display()}, found {actual.id.display()}."
159
+ )
160
+ expected_by_name = {atom.name: atom for atom in expected.atoms}
161
+ if len(expected_by_name) != len(expected.atoms):
162
+ raise TemporaryHydrogenationError(
163
+ f"Input residue {expected.id.display()} has duplicate atom names."
164
+ )
165
+ actual_names = {atom.name for atom in actual.atoms}
166
+ missing = sorted(set(expected_by_name) - actual_names)
167
+ if missing:
168
+ raise TemporaryHydrogenationError(
169
+ f"Temporary PDBFixer hydrogenation removed atoms from "
170
+ f"{expected.id.display()}: {missing}"
171
+ )
172
+ residue_record_name = "HETATM" if expected.record_names == {"HETATM"} else "ATOM"
173
+ for atom in actual.atoms:
174
+ serial += 1
175
+ original = expected_by_name.get(atom.name)
176
+ if original is not None:
177
+ displacement = dist(
178
+ (original.x, original.y, original.z),
179
+ (atom.x, atom.y, atom.z),
180
+ )
181
+ maximum_displacement = max(maximum_displacement, displacement)
182
+ if displacement > 0.002:
183
+ raise TemporaryHydrogenationError(
184
+ "Temporary PDBFixer hydrogenation moved an original atom by "
185
+ f"{displacement:.6f} A: {original.atom_identity}."
186
+ )
187
+ elif (atom.element or "").strip().upper() != "H":
188
+ raise TemporaryHydrogenationError(
189
+ "Temporary PDBFixer hydrogenation added a non-hydrogen atom "
190
+ f"to {expected.id.display()}: {atom.name}."
191
+ )
192
+ atoms.append(
193
+ replace(
194
+ atom,
195
+ serial=serial,
196
+ resname=expected.id.resname,
197
+ chain_id=expected.id.chain_id,
198
+ resid=expected.id.resid,
199
+ icode=expected.id.icode,
200
+ record_name=(original.record_name if original else residue_record_name),
201
+ occupancy=(original.occupancy if original else atom.occupancy),
202
+ bfactor=(original.bfactor if original else atom.bfactor),
203
+ element=(original.element if original else atom.element),
204
+ )
205
+ )
206
+ return (
207
+ PdbStructure(
208
+ path=output_path,
209
+ atoms=atoms,
210
+ residues=_build_residues(atoms),
211
+ model_count=1,
212
+ used_model=1,
213
+ warnings=list(reference.warnings),
214
+ ),
215
+ maximum_displacement,
216
+ )
217
+
218
+
219
+ def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
220
+ grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
221
+ for atom in atoms:
222
+ grouped.setdefault(atom.residue_key, []).append(atom)
223
+ return [
224
+ ResidueRecord(
225
+ id=ResidueId(chain_id=key[0], resname=key[1], resid=key[2], icode=key[3]),
226
+ atoms=residue_atoms,
227
+ record_names={atom.record_name for atom in residue_atoms},
228
+ original_index=index,
229
+ )
230
+ for index, (key, residue_atoms) in enumerate(grouped.items())
231
+ ]
232
+
233
+
234
+ def _package_version(name: str) -> str | None:
235
+ try:
236
+ return version(name)
237
+ except PackageNotFoundError:
238
+ return None
@@ -0,0 +1,79 @@
1
+ """xTB output energy parsing and tautomer selection helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import re
8
+
9
+
10
+ HARTREE_TO_KCAL_MOL = 627.509474
11
+
12
+
13
+ class XtbParseError(ValueError):
14
+ """Raised when xTB output does not contain a parseable energy."""
15
+
16
+
17
+ ENERGY_PATTERNS = [
18
+ re.compile(r"TOTAL\s+ENERGY\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
19
+ re.compile(r"total\s+energy\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
20
+ re.compile(r"::\s*total\s+energy\s+(-?\d+(?:\.\d+)?)", re.IGNORECASE),
21
+ re.compile(r"total\s+E\s*=\s*(-?\d+(?:\.\d+)?)", re.IGNORECASE),
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class XtbEnergyComparison:
27
+ hid_energy_hartree: float
28
+ hie_energy_hartree: float
29
+ delta_kcal_mol: float
30
+ selected_state: str
31
+ close_call: bool
32
+ warnings: list[str]
33
+
34
+
35
+ def parse_xtb_energy_text(text: str, *, source: str = "xTB stdout") -> float:
36
+ energies: list[float] = []
37
+ for line in text.splitlines():
38
+ for pattern in ENERGY_PATTERNS:
39
+ match = pattern.search(line)
40
+ if match:
41
+ energies.append(float(match.group(1)))
42
+ break
43
+ if not energies:
44
+ raise XtbParseError(f"Could not parse xTB total energy from {source}")
45
+ return energies[-1]
46
+
47
+
48
+ def parse_xtb_energy_file(path: str | Path) -> float:
49
+ path_obj = Path(path)
50
+ return parse_xtb_energy_text(path_obj.read_text(encoding="utf-8"), source=str(path_obj))
51
+
52
+
53
+ def compare_hid_hie_energies(
54
+ *,
55
+ hid_energy_hartree: float,
56
+ hie_energy_hartree: float,
57
+ close_call_kcal_mol: float,
58
+ ) -> XtbEnergyComparison:
59
+ delta = (hid_energy_hartree - hie_energy_hartree) * HARTREE_TO_KCAL_MOL
60
+ warnings: list[str] = []
61
+ if hid_energy_hartree < hie_energy_hartree:
62
+ selected = "HID"
63
+ elif hie_energy_hartree < hid_energy_hartree:
64
+ selected = "HIE"
65
+ else:
66
+ selected = "HIE"
67
+ warnings.append("HID and HIE xTB energies are equal; selected HIE by deterministic tie-break.")
68
+ close_call = abs(delta) <= close_call_kcal_mol
69
+ if close_call:
70
+ warnings.append("HID/HIE xTB energy difference is within the configured close-call threshold.")
71
+ return XtbEnergyComparison(
72
+ hid_energy_hartree=hid_energy_hartree,
73
+ hie_energy_hartree=hie_energy_hartree,
74
+ delta_kcal_mol=delta,
75
+ selected_state=selected,
76
+ close_call=close_call,
77
+ warnings=warnings,
78
+ )
79
+
@@ -0,0 +1,108 @@
1
+ """xTB command construction and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from mdprep.config.models import HistidineXtbConfig
9
+ from mdprep.external.discovery import which_executable
10
+ from mdprep.external.runner import CommandResult, run_command
11
+
12
+
13
+ class XtbExecutionError(RuntimeError):
14
+ """Raised when xTB is requested but unavailable or fails."""
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class XtbRunResult:
19
+ command_result: CommandResult
20
+ stdout_path: Path
21
+ stderr_path: Path
22
+
23
+
24
+ def resolve_xtb_executable(config: HistidineXtbConfig) -> str:
25
+ configured = config.executable
26
+ if Path(configured).exists():
27
+ return configured
28
+ found = which_executable(configured)
29
+ if found:
30
+ return found
31
+ raise XtbExecutionError(f"xTB executable not found: {configured}")
32
+
33
+
34
+ def build_xtb_command(
35
+ *,
36
+ config: HistidineXtbConfig,
37
+ xyz_path: str | Path,
38
+ cluster_charge: int,
39
+ executable: str | None = None,
40
+ input_path: str | Path | None = None,
41
+ ) -> list[str]:
42
+ exe = executable or config.executable
43
+ command = [exe, str(xyz_path)]
44
+ extra_args = list(config.extra_args)
45
+ if config.model == "gfn2":
46
+ command.extend(["--gfn", "2"])
47
+ elif "--gxtb" not in extra_args:
48
+ command.append("--gxtb")
49
+ if config.mode == "opt":
50
+ command.extend(["--opt", config.opt_level])
51
+ command.extend(["--chrg", str(cluster_charge), "--uhf", "0"])
52
+ command.extend(["--iterations", str(config.scf_iterations)])
53
+ if config.electronic_temperature_kelvin is not None:
54
+ command.extend(["--etemp", str(config.electronic_temperature_kelvin)])
55
+ if config.solvent is not None and not _extra_args_define_solvent(extra_args):
56
+ command.extend(["--alpb", config.solvent])
57
+ if input_path is not None:
58
+ command.extend(["--input", str(input_path)])
59
+ command.extend(extra_args)
60
+ return command
61
+
62
+
63
+ def run_xtb(
64
+ *,
65
+ config: HistidineXtbConfig,
66
+ xyz_path: str | Path,
67
+ work_dir: str | Path,
68
+ cluster_charge: int,
69
+ stdout_path: str | Path,
70
+ stderr_path: str | Path,
71
+ input_path: str | Path | None = None,
72
+ ) -> XtbRunResult:
73
+ executable = resolve_xtb_executable(config)
74
+ command = build_xtb_command(
75
+ config=config,
76
+ xyz_path=Path(xyz_path).name,
77
+ cluster_charge=cluster_charge,
78
+ executable=executable,
79
+ input_path=Path(input_path).name if input_path is not None else None,
80
+ )
81
+ result = run_command(command, cwd=work_dir)
82
+ stdout = Path(stdout_path)
83
+ stderr = Path(stderr_path)
84
+ stdout.write_text(result.stdout, encoding="utf-8")
85
+ stderr.write_text(result.stderr, encoding="utf-8")
86
+ if result.returncode != 0:
87
+ raise XtbExecutionError(
88
+ "xTB failed with exit code "
89
+ f"{result.returncode}. Command: {' '.join(result.command)}. "
90
+ f"See {stdout} and {stderr}.\n"
91
+ f"stdout tail:\n{_tail(result.stdout)}\n"
92
+ f"stderr tail:\n{_tail(result.stderr)}"
93
+ )
94
+ return XtbRunResult(command_result=result, stdout_path=stdout, stderr_path=stderr)
95
+
96
+
97
+ def _extra_args_define_solvent(extra_args: list[str]) -> bool:
98
+ return any(arg in {"--alpb", "--gbsa"} for arg in extra_args)
99
+
100
+
101
+ def _tail(text: str, *, max_lines: int = 20, max_chars: int = 4000) -> str:
102
+ if not text:
103
+ return "<empty>"
104
+ lines = text.rstrip().splitlines()[-max_lines:]
105
+ tail = "\n".join(lines)
106
+ if len(tail) > max_chars:
107
+ return tail[-max_chars:]
108
+ return tail
mdprep/qm/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """QM backend helpers."""
2
+
@@ -0,0 +1,233 @@
1
+ """MM point-charge extraction for QMMESP electrostatic embedding."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ import json
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from mdprep.config.models import LigandConfig, ManifestConfig
13
+ from mdprep.structure.classify import STANDARD_PROTEIN_RESIDUES, WATER_RESIDUES
14
+
15
+
16
+ class PointChargeError(ValueError):
17
+ """Raised when QMMESP point charges cannot be extracted safely."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class PointCharge:
22
+ x: float
23
+ y: float
24
+ z: float
25
+ charge: float
26
+ residue_name: str
27
+ residue_number: int | None
28
+ atom_name: str
29
+ category: str
30
+
31
+ def to_dict(self) -> dict[str, object]:
32
+ return {
33
+ "x": self.x,
34
+ "y": self.y,
35
+ "z": self.z,
36
+ "charge": self.charge,
37
+ "residue_name": self.residue_name,
38
+ "residue_number": self.residue_number,
39
+ "atom_name": self.atom_name,
40
+ "category": self.category,
41
+ }
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class PointChargeSelection:
46
+ target_atom_indices: list[int]
47
+ point_charges: list[PointCharge]
48
+ total_before_cutoff: int
49
+ total_after_cutoff: int
50
+ net_embedding_charge: float
51
+ min_distance: float | None
52
+ max_distance: float | None
53
+ categories: dict[str, int]
54
+
55
+ def to_dict(self) -> dict[str, object]:
56
+ return {
57
+ "target_atom_indices": self.target_atom_indices,
58
+ "target_atom_count": len(self.target_atom_indices),
59
+ "target_ligand_excluded_from_embedding": True,
60
+ "point_charge_count_before_cutoff": self.total_before_cutoff,
61
+ "point_charge_count_after_cutoff": self.total_after_cutoff,
62
+ "net_embedding_charge": self.net_embedding_charge,
63
+ "min_distance": self.min_distance,
64
+ "max_distance": self.max_distance,
65
+ "categories": self.categories,
66
+ "qmmesp_interpretation": (
67
+ "The target-ligand SCF uses electrostatic QM/MM embedding through "
68
+ "pyscf.qmmm.mm_charge. MM point charges polarize the target-ligand QM density; "
69
+ "they are not fitted and are not written to the ligand mol2."
70
+ ),
71
+ }
72
+
73
+ @property
74
+ def charge_array(self) -> np.ndarray:
75
+ return np.asarray([charge.charge for charge in self.point_charges], dtype=float)
76
+
77
+ @property
78
+ def coordinate_array(self) -> np.ndarray:
79
+ return np.asarray([[charge.x, charge.y, charge.z] for charge in self.point_charges], dtype=float)
80
+
81
+
82
+ def parmed_available() -> bool:
83
+ try:
84
+ import parmed # noqa: F401
85
+ except Exception:
86
+ return False
87
+ return True
88
+
89
+
90
+ def extract_point_charges_from_prmtop(
91
+ *,
92
+ prmtop: str | Path,
93
+ inpcrd: str | Path,
94
+ ligand: LigandConfig,
95
+ manifest: ManifestConfig,
96
+ target_coordinates: np.ndarray,
97
+ target_atom_names: list[str] | None = None,
98
+ ) -> PointChargeSelection:
99
+ try:
100
+ import parmed
101
+ except Exception as exc:
102
+ raise PointChargeError("ParmEd is required for qmmesp_pyscf point-charge extraction.") from exc
103
+ try:
104
+ structure = parmed.load_file(str(prmtop), str(inpcrd))
105
+ except Exception as exc:
106
+ raise PointChargeError(f"Could not load provisional Amber topology with ParmEd: {exc}") from exc
107
+ coordinates = np.asarray(structure.coordinates, dtype=float)
108
+ target_indices = _find_target_ligand_atoms(
109
+ structure,
110
+ ligand,
111
+ topology_coordinates=coordinates,
112
+ target_coordinates=target_coordinates,
113
+ target_atom_names=target_atom_names,
114
+ )
115
+ target_set = set(target_indices)
116
+ target_coords = np.asarray(target_coordinates, dtype=float)
117
+ charges: list[PointCharge] = []
118
+ before_cutoff = 0
119
+ distances_kept: list[float] = []
120
+ categories: dict[str, int] = {}
121
+ for index, atom in enumerate(structure.atoms):
122
+ if index in target_set:
123
+ continue
124
+ category = _category(atom)
125
+ env = ligand.qmmesp.environment if ligand.qmmesp is not None else None
126
+ if env is not None:
127
+ if category == "protein" and not env.include_protein:
128
+ continue
129
+ if category == "water" and not env.include_waters:
130
+ continue
131
+ if category == "ligand" and not env.include_other_ligands:
132
+ continue
133
+ before_cutoff += 1
134
+ distance = float(np.min(np.linalg.norm(target_coords - coordinates[index], axis=1)))
135
+ cutoff = ligand.qmmesp.embedding_cutoff_angstrom if ligand.qmmesp is not None else None
136
+ if cutoff is not None and distance > cutoff:
137
+ continue
138
+ residue = atom.residue
139
+ charge = PointCharge(
140
+ x=float(coordinates[index][0]),
141
+ y=float(coordinates[index][1]),
142
+ z=float(coordinates[index][2]),
143
+ charge=float(atom.charge),
144
+ residue_name=str(residue.name),
145
+ residue_number=getattr(residue, "number", None),
146
+ atom_name=str(atom.name),
147
+ category=category,
148
+ )
149
+ charges.append(charge)
150
+ distances_kept.append(distance)
151
+ categories[category] = categories.get(category, 0) + 1
152
+ return PointChargeSelection(
153
+ target_atom_indices=target_indices,
154
+ point_charges=charges,
155
+ total_before_cutoff=before_cutoff,
156
+ total_after_cutoff=len(charges),
157
+ net_embedding_charge=float(sum(charge.charge for charge in charges)),
158
+ min_distance=min(distances_kept) if distances_kept else None,
159
+ max_distance=max(distances_kept) if distances_kept else None,
160
+ categories=categories,
161
+ )
162
+
163
+
164
+ def write_point_charge_files(selection: PointChargeSelection, *, csv_path: str | Path, xyz_path: str | Path, summary_path: str | Path) -> None:
165
+ csv_output = Path(csv_path)
166
+ csv_output.parent.mkdir(parents=True, exist_ok=True)
167
+ with csv_output.open("w", encoding="utf-8", newline="") as handle:
168
+ writer = csv.DictWriter(handle, fieldnames=["x", "y", "z", "charge", "residue_name", "residue_number", "atom_name", "category"])
169
+ writer.writeheader()
170
+ for charge in selection.point_charges:
171
+ writer.writerow(charge.to_dict())
172
+ xyz_output = Path(xyz_path)
173
+ lines = [str(len(selection.point_charges)), "mdprep MM point charges"]
174
+ for charge in selection.point_charges:
175
+ lines.append(f"X {charge.x:.8f} {charge.y:.8f} {charge.z:.8f} {charge.charge:.8f}")
176
+ xyz_output.write_text("\n".join(lines) + "\n", encoding="utf-8")
177
+ Path(summary_path).write_text(json.dumps(selection.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8")
178
+
179
+
180
+ def _find_target_ligand_atoms(
181
+ structure: object,
182
+ ligand: LigandConfig,
183
+ *,
184
+ topology_coordinates: np.ndarray,
185
+ target_coordinates: np.ndarray,
186
+ target_atom_names: list[str] | None,
187
+ coordinate_tolerance_angstrom: float = 0.1,
188
+ ) -> list[int]:
189
+ target = np.asarray(target_coordinates, dtype=float)
190
+ if target.ndim != 2 or target.shape[1] != 3 or len(target) == 0:
191
+ raise PointChargeError("Target-ligand coordinates must have shape (n_atoms, 3) and be non-empty.")
192
+ if target_atom_names is not None and len(target_atom_names) != len(target):
193
+ raise PointChargeError("Target-ligand atom-name and coordinate counts do not match.")
194
+
195
+ matches: list[tuple[list[int], float]] = []
196
+ rejected: list[str] = []
197
+ for residue in structure.residues:
198
+ if residue.name != ligand.selector.resname:
199
+ continue
200
+ indices = [atom.idx for atom in residue.atoms]
201
+ if len(indices) != len(target):
202
+ rejected.append(f"residue {getattr(residue, 'number', '?')}: {len(indices)} atoms")
203
+ continue
204
+ names = [str(atom.name) for atom in residue.atoms]
205
+ if target_atom_names is not None and names != target_atom_names:
206
+ rejected.append(f"residue {getattr(residue, 'number', '?')}: atom names {names}")
207
+ continue
208
+ candidate = topology_coordinates[indices]
209
+ max_deviation = float(np.max(np.linalg.norm(candidate - target, axis=1)))
210
+ if max_deviation <= coordinate_tolerance_angstrom:
211
+ matches.append((indices, max_deviation))
212
+ else:
213
+ rejected.append(
214
+ f"residue {getattr(residue, 'number', '?')}: coordinate max deviation "
215
+ f"{max_deviation:.3f} A"
216
+ )
217
+ if len(matches) != 1:
218
+ details = "; ".join(rejected) if rejected else "no residue-name candidates"
219
+ raise PointChargeError(
220
+ f"Could not map ligand {ligand.id} uniquely in provisional topology; "
221
+ "mapping requires residue name, atom count/order/names, and coordinates to agree. "
222
+ f"Candidates: {details}."
223
+ )
224
+ return matches[0][0]
225
+
226
+
227
+ def _category(atom: object) -> str:
228
+ name = str(atom.residue.name).upper()
229
+ if name in WATER_RESIDUES:
230
+ return "water"
231
+ if name in STANDARD_PROTEIN_RESIDUES:
232
+ return "protein"
233
+ return "ligand"