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
mdprep/metals/c4.py ADDED
@@ -0,0 +1,157 @@
1
+ """Apply and verify Amber 12-6-4 C4 terms with ParmEd."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ import shutil
8
+
9
+ from mdprep.external.discovery import which_executable
10
+ from mdprep.external.runner import CommandResult, run_command
11
+ from mdprep.metals.nonbonded import resolve_amberhome
12
+
13
+
14
+ class C4PostprocessError(ValueError):
15
+ """Raised when a requested 12-6-4 topology cannot be produced."""
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class C4PostprocessRun:
20
+ input_prmtop_backup: Path
21
+ output_prmtop: Path
22
+ script_path: Path
23
+ stdout_path: Path
24
+ stderr_path: Path
25
+ atom_types: tuple[str, ...]
26
+ water_model: str
27
+ nonzero_c4_coefficients: int
28
+ result: CommandResult
29
+
30
+ def to_dict(self) -> dict[str, object]:
31
+ return {
32
+ "input_prmtop_backup": str(self.input_prmtop_backup),
33
+ "output_prmtop": str(self.output_prmtop),
34
+ "script_path": str(self.script_path),
35
+ "stdout_path": str(self.stdout_path),
36
+ "stderr_path": str(self.stderr_path),
37
+ "atom_types": list(self.atom_types),
38
+ "water_model": self.water_model,
39
+ "nonzero_c4_coefficients": self.nonzero_c4_coefficients,
40
+ "command": list(self.result.command),
41
+ "cwd": self.result.cwd,
42
+ "returncode": self.result.returncode,
43
+ "stdout": self.result.stdout,
44
+ "stderr": self.result.stderr,
45
+ "runtime_seconds": self.result.runtime_seconds,
46
+ }
47
+
48
+
49
+ def apply_12_6_4_c4(
50
+ prmtop: str | Path,
51
+ *,
52
+ atom_types: list[str],
53
+ water_model: str,
54
+ work_dir: str | Path,
55
+ executable: str = "parmed",
56
+ ) -> C4PostprocessRun:
57
+ if not atom_types:
58
+ raise C4PostprocessError("At least one 12-6-4 metal atom type is required.")
59
+ exe = which_executable(executable)
60
+ if exe is None:
61
+ raise C4PostprocessError(
62
+ "ParmEd executable is required to add LENNARD_JONES_CCOEF for a 12-6-4 ion model."
63
+ )
64
+ water = water_model.upper()
65
+ if water not in {"TIP3P", "OPC"}:
66
+ raise C4PostprocessError(f"Unsupported 12-6-4 water model: {water_model}")
67
+ amberhome = resolve_amberhome()
68
+ polfile = amberhome / "dat" / "leap" / "parm" / "lj_1264_pol.dat"
69
+ if not polfile.is_file():
70
+ raise C4PostprocessError(f"Amber 12-6-4 polarizability file is missing: {polfile}")
71
+
72
+ topology = Path(prmtop)
73
+ work = Path(work_dir)
74
+ work.mkdir(parents=True, exist_ok=True)
75
+ backup = work / f"{topology.name}.pre1264"
76
+ generated = work / f"{topology.name}.with1264"
77
+ shutil.copyfile(topology, backup)
78
+ mask = _atom_type_mask(atom_types)
79
+ script = work / "parmed.add1264.in"
80
+ script.write_text(
81
+ "\n".join(
82
+ [
83
+ f"add12_6_4 {mask} watermodel {water} polfile {polfile.resolve()}",
84
+ f"outparm {generated.name}",
85
+ "quit",
86
+ "",
87
+ ]
88
+ ),
89
+ encoding="utf-8",
90
+ )
91
+ result = run_command(
92
+ [exe, "-O", "-p", str(backup.resolve()), "-i", script.name],
93
+ cwd=work,
94
+ )
95
+ stdout_path = work / "parmed.add1264.stdout.txt"
96
+ stderr_path = work / "parmed.add1264.stderr.txt"
97
+ stdout_path.write_text(result.stdout, encoding="utf-8")
98
+ stderr_path.write_text(result.stderr, encoding="utf-8")
99
+ if result.returncode != 0:
100
+ raise C4PostprocessError(
101
+ f"ParmEd add12_6_4 failed with exit code {result.returncode}; see {stdout_path} and {stderr_path}."
102
+ )
103
+ if not generated.is_file() or generated.stat().st_size == 0:
104
+ raise C4PostprocessError(f"ParmEd did not produce 12-6-4 topology: {generated}")
105
+ contents = generated.read_text(encoding="utf-8", errors="replace")
106
+ if "%FLAG LENNARD_JONES_CCOEF" not in contents:
107
+ raise C4PostprocessError(
108
+ f"ParmEd output lacks LENNARD_JONES_CCOEF and is not a valid 12-6-4 topology: {generated}"
109
+ )
110
+ c4_values = _prmtop_flag_values(contents, "LENNARD_JONES_CCOEF")
111
+ nonzero_c4 = sum(abs(value) > 0.0 for value in c4_values)
112
+ if nonzero_c4 == 0:
113
+ raise C4PostprocessError(
114
+ f"ParmEd produced a LENNARD_JONES_CCOEF array with no nonzero terms for "
115
+ f"{mask}; the requested metal atom type was not parameterized."
116
+ )
117
+ shutil.copyfile(generated, topology)
118
+ return C4PostprocessRun(
119
+ input_prmtop_backup=backup,
120
+ output_prmtop=topology,
121
+ script_path=script,
122
+ stdout_path=stdout_path,
123
+ stderr_path=stderr_path,
124
+ atom_types=tuple(sorted(set(atom_types))),
125
+ water_model=water,
126
+ nonzero_c4_coefficients=nonzero_c4,
127
+ result=result,
128
+ )
129
+
130
+
131
+ def _atom_type_mask(atom_types: list[str]) -> str:
132
+ return "@%" + ",".join(sorted(set(atom_types)))
133
+
134
+
135
+ def _prmtop_flag_values(contents: str, flag: str) -> list[float]:
136
+ lines = contents.splitlines()
137
+ marker = f"%FLAG {flag}"
138
+ try:
139
+ start = lines.index(marker) + 1
140
+ except ValueError as exc:
141
+ raise C4PostprocessError(f"Amber topology is missing %FLAG {flag}.") from exc
142
+ values: list[float] = []
143
+ for line in lines[start:]:
144
+ if line.startswith("%FLAG "):
145
+ break
146
+ if line.startswith("%") or not line.strip():
147
+ continue
148
+ for token in line.split():
149
+ try:
150
+ values.append(float(token.replace("D", "E")))
151
+ except ValueError as exc:
152
+ raise C4PostprocessError(
153
+ f"Malformed numeric value {token!r} in %FLAG {flag}."
154
+ ) from exc
155
+ if not values:
156
+ raise C4PostprocessError(f"Amber topology %FLAG {flag} contains no values.")
157
+ return values
@@ -0,0 +1,313 @@
1
+ """Resolve and validate explicitly declared MCPB.py coordination sites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from math import dist
7
+
8
+ from mdprep.config.models import ManifestConfig, MetalSiteConfig
9
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
10
+ from mdprep.structure.selectors import SelectorError, resolve_atom_selector, resolve_residue_selector
11
+
12
+
13
+ MCPB_AUTOMATIC_DONOR_ELEMENTS = frozenset({"N", "O", "S", "F", "Cl", "Br", "I"})
14
+
15
+
16
+ class MetalCoordinationError(ValueError):
17
+ """Raised when a metal site is ambiguous or inconsistent."""
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ResolvedMetalIon:
22
+ site_id: str
23
+ element: str
24
+ charge: int
25
+ atom: AtomRecord
26
+ residue: ResidueRecord
27
+
28
+ def to_dict(self) -> dict[str, object]:
29
+ return {
30
+ "site_id": self.site_id,
31
+ "selector": _atom_dict(self.atom),
32
+ "element": self.element,
33
+ "charge": self.charge,
34
+ "atom_serial": self.atom.serial,
35
+ }
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ResolvedMetalBond:
40
+ ion: ResolvedMetalIon
41
+ coordinator: AtomRecord
42
+ coordinator_residue: ResidueRecord
43
+ distance_angstrom: float
44
+ discovered_by_cutoff: bool
45
+
46
+ def to_dict(self) -> dict[str, object]:
47
+ return {
48
+ "ion": _atom_dict(self.ion.atom),
49
+ "coordinator": _atom_dict(self.coordinator),
50
+ "distance_angstrom": self.distance_angstrom,
51
+ "discovered_by_mcpb_cutoff": self.discovered_by_cutoff,
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ResolvedMetalSite:
57
+ config: MetalSiteConfig
58
+ ions: tuple[ResolvedMetalIon, ...]
59
+ bonds: tuple[ResolvedMetalBond, ...]
60
+ additional_residues: tuple[ResidueRecord, ...]
61
+
62
+ def to_dict(self) -> dict[str, object]:
63
+ return {
64
+ "id": self.config.id,
65
+ "model": self.config.model,
66
+ "ions": [ion.to_dict() for ion in self.ions],
67
+ "bonds": [bond.to_dict() for bond in self.bonds],
68
+ "additional_residues": [residue.id.to_dict() for residue in self.additional_residues],
69
+ }
70
+
71
+
72
+ def resolve_metal_sites(
73
+ structure: PdbStructure,
74
+ manifest: ManifestConfig,
75
+ *,
76
+ validate_mcpb_cutoff: bool = True,
77
+ ) -> list[ResolvedMetalSite]:
78
+ """Resolve all configured ions and explicit bonds against ``structure``."""
79
+
80
+ bonded_sites = [site for site in manifest.metals if site.model == "bonded_mcpb"]
81
+ if len(bonded_sites) > 1:
82
+ raise MetalCoordinationError(
83
+ "Only one bonded_mcpb site is supported in one preparation. Put interacting ions "
84
+ "in the same site's ions list; independent MCPB.py residue-renaming outputs cannot "
85
+ "be combined safely."
86
+ )
87
+
88
+ resolved: list[ResolvedMetalSite] = []
89
+ global_ion_keys: set[tuple[str, str, int, str | None, str]] = set()
90
+ for site in manifest.metals:
91
+ ions = tuple(_resolve_ion(structure, site, ion) for ion in site.ions)
92
+ for ion in ions:
93
+ key = ion.atom.atom_identity
94
+ if key in global_ion_keys:
95
+ raise MetalCoordinationError(f"Metal atom {_atom_label(ion.atom)} occurs in multiple sites.")
96
+ global_ion_keys.add(key)
97
+
98
+ bonds: tuple[ResolvedMetalBond, ...] = ()
99
+ additional: tuple[ResidueRecord, ...] = ()
100
+ if site.model == "bonded_mcpb":
101
+ assert site.mcpb is not None
102
+ ion_by_key = {ion.atom.atom_identity: ion for ion in ions}
103
+ bond_items: list[ResolvedMetalBond] = []
104
+ seen_pairs: set[tuple[tuple[str, str, int, str | None, str], tuple[str, str, int, str | None, str]]] = set()
105
+ for bond in site.mcpb.bonds:
106
+ try:
107
+ ion_atom, _ = _resolve_atom_and_residue(
108
+ structure, bond.ion.model_dump()
109
+ )
110
+ coordinator, coordinator_residue = _resolve_atom_and_residue(
111
+ structure, bond.coordinator.model_dump()
112
+ )
113
+ except SelectorError as exc:
114
+ raise MetalCoordinationError(
115
+ f"Metal site {site.id!r} bond selector failed: {exc}"
116
+ ) from exc
117
+ ion = ion_by_key.get(ion_atom.atom_identity)
118
+ if ion is None:
119
+ raise MetalCoordinationError(
120
+ f"Metal site {site.id!r} bond ion {_atom_label(ion_atom)} is not listed "
121
+ "under that site's ions."
122
+ )
123
+ if coordinator.atom_identity in global_ion_keys:
124
+ raise MetalCoordinationError(
125
+ f"Metal site {site.id!r} uses metal atom {_atom_label(coordinator)} as a "
126
+ "coordinator; metal--metal bonds are not inferred by this workflow."
127
+ )
128
+ pair = (ion.atom.atom_identity, coordinator.atom_identity)
129
+ if pair in seen_pairs:
130
+ raise MetalCoordinationError(
131
+ f"Duplicate metal bond {_atom_label(ion.atom)}--{_atom_label(coordinator)}."
132
+ )
133
+ seen_pairs.add(pair)
134
+ distance = _distance(ion.atom, coordinator)
135
+ discovered = (
136
+ distance <= site.mcpb.cutoff_angstrom
137
+ and _element(coordinator) in MCPB_AUTOMATIC_DONOR_ELEMENTS
138
+ )
139
+ bond_items.append(
140
+ ResolvedMetalBond(
141
+ ion=ion,
142
+ coordinator=coordinator,
143
+ coordinator_residue=coordinator_residue,
144
+ distance_angstrom=distance,
145
+ discovered_by_cutoff=discovered,
146
+ )
147
+ )
148
+ bonds = tuple(bond_items)
149
+ additional = tuple(
150
+ _resolve_additional_residue(structure, site.id, selector.model_dump())
151
+ for selector in site.mcpb.additional_residues
152
+ )
153
+ if validate_mcpb_cutoff:
154
+ _validate_exact_mcpb_candidates(structure, site, ions, bonds)
155
+
156
+ resolved.append(
157
+ ResolvedMetalSite(config=site, ions=ions, bonds=bonds, additional_residues=additional)
158
+ )
159
+ return resolved
160
+
161
+
162
+ def _resolve_ion(structure: PdbStructure, site: MetalSiteConfig, config: object) -> ResolvedMetalIon:
163
+ selector = config.selector.model_dump() # type: ignore[attr-defined]
164
+ try:
165
+ atom, residue = _resolve_atom_and_residue(structure, selector)
166
+ except SelectorError as exc:
167
+ raise MetalCoordinationError(f"Metal site {site.id!r} ion selector failed: {exc}") from exc
168
+ element = config.element # type: ignore[attr-defined]
169
+ observed = _element(atom)
170
+ if observed and observed != element:
171
+ raise MetalCoordinationError(
172
+ f"Metal site {site.id!r} declares {element} for {_atom_label(atom)}, "
173
+ f"but the PDB element is {observed}."
174
+ )
175
+ if len(residue.atoms) != 1:
176
+ raise MetalCoordinationError(
177
+ f"Metal ion residue {residue.id.display()} contains {len(residue.atoms)} atoms; "
178
+ "a metal ion must be a one-atom residue."
179
+ )
180
+ if atom.serial is None:
181
+ raise MetalCoordinationError(f"Metal atom {_atom_label(atom)} has no PDB serial number.")
182
+ return ResolvedMetalIon(
183
+ site_id=site.id,
184
+ element=element,
185
+ charge=config.charge, # type: ignore[attr-defined]
186
+ atom=atom,
187
+ residue=residue,
188
+ )
189
+
190
+
191
+ def _resolve_additional_residue(
192
+ structure: PdbStructure,
193
+ site_id: str,
194
+ selector: dict[str, object],
195
+ ) -> ResidueRecord:
196
+ try:
197
+ return _resolve_residue_protonation_aware(structure, selector)
198
+ except SelectorError as exc:
199
+ raise MetalCoordinationError(
200
+ f"Metal site {site_id!r} additional residue selector failed: {exc}"
201
+ ) from exc
202
+
203
+
204
+ def _validate_exact_mcpb_candidates(
205
+ structure: PdbStructure,
206
+ site: MetalSiteConfig,
207
+ ions: tuple[ResolvedMetalIon, ...],
208
+ bonds: tuple[ResolvedMetalBond, ...],
209
+ ) -> None:
210
+ assert site.mcpb is not None
211
+ declared = {(bond.ion.atom.atom_identity, bond.coordinator.atom_identity) for bond in bonds}
212
+ discovered: set[
213
+ tuple[
214
+ tuple[str, str, int, str | None, str],
215
+ tuple[str, str, int, str | None, str],
216
+ ]
217
+ ] = set()
218
+ ion_keys = {ion.atom.atom_identity for ion in ions}
219
+ for ion in ions:
220
+ for atom in structure.atoms:
221
+ if atom.atom_identity in ion_keys:
222
+ continue
223
+ if _element(atom) not in MCPB_AUTOMATIC_DONOR_ELEMENTS:
224
+ continue
225
+ if _distance(ion.atom, atom) <= site.mcpb.cutoff_angstrom:
226
+ discovered.add((ion.atom.atom_identity, atom.atom_identity))
227
+ undeclared = discovered - declared
228
+ if undeclared:
229
+ formatted = ", ".join(
230
+ f"{_identity_label(ion)}--{_identity_label(atom)}"
231
+ for ion, atom in sorted(undeclared, key=str)
232
+ )
233
+ raise MetalCoordinationError(
234
+ f"MCPB.py would create undeclared bonds within {site.mcpb.cutoff_angstrom:.3f} A "
235
+ f"for site {site.id!r}: {formatted}. Add every intended bond under mcpb.bonds or "
236
+ "choose a cutoff that represents the explicitly approved coordination sphere."
237
+ )
238
+
239
+
240
+ def _distance(a: AtomRecord, b: AtomRecord) -> float:
241
+ return dist((a.x, a.y, a.z), (b.x, b.y, b.z))
242
+
243
+
244
+ _PROTONATION_FAMILIES = (
245
+ frozenset({"HIS", "HID", "HIE", "HIP"}),
246
+ frozenset({"ASP", "ASH"}),
247
+ frozenset({"GLU", "GLH"}),
248
+ frozenset({"CYS", "CYM", "CYX"}),
249
+ frozenset({"LYS", "LYN"}),
250
+ frozenset({"HOH", "WAT", "H2O", "TIP3", "OPC"}),
251
+ )
252
+
253
+
254
+ def _resolve_atom_and_residue(
255
+ structure: PdbStructure,
256
+ selector: dict[str, object],
257
+ ) -> tuple[AtomRecord, ResidueRecord]:
258
+ residue = _resolve_residue_protonation_aware(structure, selector)
259
+ atom_name = str(selector["atom_name"])
260
+ matches = [atom for atom in residue.atoms if atom.name == atom_name]
261
+ if len(matches) != 1:
262
+ available = ", ".join(residue.atom_names())
263
+ raise SelectorError(
264
+ f"Expected one atom {atom_name!r} in {residue.id.display()}, found {len(matches)}. "
265
+ f"Available atoms: {available}"
266
+ )
267
+ return matches[0], residue
268
+
269
+
270
+ def _resolve_residue_protonation_aware(
271
+ structure: PdbStructure,
272
+ selector: dict[str, object],
273
+ ) -> ResidueRecord:
274
+ try:
275
+ return resolve_residue_selector(structure, selector)
276
+ except SelectorError as original:
277
+ requested = str(selector.get("resname", ""))
278
+ family = next((item for item in _PROTONATION_FAMILIES if requested in item), None)
279
+ if family is None:
280
+ raise
281
+ relaxed = dict(selector)
282
+ relaxed.pop("atom_name", None)
283
+ relaxed.pop("resname", None)
284
+ try:
285
+ residue = resolve_residue_selector(structure, relaxed)
286
+ except SelectorError:
287
+ raise original
288
+ if residue.id.resname not in family:
289
+ raise original
290
+ return residue
291
+
292
+
293
+ def _element(atom: AtomRecord) -> str:
294
+ return (atom.element or "").strip().capitalize()
295
+
296
+
297
+ def _atom_dict(atom: AtomRecord) -> dict[str, object]:
298
+ return {
299
+ "chain": atom.chain_id,
300
+ "resname": atom.resname,
301
+ "resid": atom.resid,
302
+ "icode": atom.icode,
303
+ "atom_name": atom.name,
304
+ }
305
+
306
+
307
+ def _atom_label(atom: AtomRecord) -> str:
308
+ return _identity_label(atom.atom_identity)
309
+
310
+
311
+ def _identity_label(identity: tuple[str, str, int, str | None, str]) -> str:
312
+ chain, resname, resid, icode, atom_name = identity
313
+ return f"{chain or '<blank>'}:{resname}{resid}{icode or ''}@{atom_name}"