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,566 @@
1
+ """Explicit structure repair with strict identity and geometry validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, replace
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from math import dist
8
+ from pathlib import Path
9
+
10
+ import numpy as np
11
+
12
+ from mdprep.config.models import ManifestConfig, ResidueSelector
13
+ from mdprep.structure.classify import is_standard_protein_residue
14
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueRecord
15
+ from mdprep.structure.pdb import read_pdb
16
+ from mdprep.structure.writer import write_pdb
17
+
18
+
19
+ class StructureRepairError(ValueError):
20
+ """Raised when requested coordinate repair is unavailable or ambiguous."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StructureRepairResult:
25
+ backend: str
26
+ backend_version: str
27
+ input_path: Path
28
+ sequence_source_path: Path | None
29
+ raw_output_path: Path
30
+ output_path: Path
31
+ structure: PdbStructure
32
+ missing_residues_detected: tuple[dict[str, object], ...]
33
+ missing_heavy_atoms_detected: tuple[dict[str, object], ...]
34
+ added_atom_count: int
35
+ peptide_bond_checks: tuple[dict[str, object], ...]
36
+ random_seed: int
37
+ platform: str
38
+ warnings: tuple[str, ...]
39
+
40
+ def to_dict(self) -> dict[str, object]:
41
+ return {
42
+ "backend": self.backend,
43
+ "backend_version": self.backend_version,
44
+ "input_path": str(self.input_path),
45
+ "sequence_source_path": (
46
+ str(self.sequence_source_path)
47
+ if self.sequence_source_path is not None
48
+ else None
49
+ ),
50
+ "raw_output_path": str(self.raw_output_path),
51
+ "output_path": str(self.output_path),
52
+ "missing_residues_detected": list(self.missing_residues_detected),
53
+ "missing_heavy_atoms_detected": list(
54
+ self.missing_heavy_atoms_detected
55
+ ),
56
+ "added_atom_count": self.added_atom_count,
57
+ "peptide_bond_checks": list(self.peptide_bond_checks),
58
+ "random_seed": self.random_seed,
59
+ "platform": self.platform,
60
+ "warnings": list(self.warnings),
61
+ }
62
+
63
+
64
+ def repair_structure(
65
+ manifest: ManifestConfig,
66
+ *,
67
+ output_path: str | Path,
68
+ ) -> StructureRepairResult | None:
69
+ config = manifest.structure.repair
70
+ if config.backend == "none":
71
+ return None
72
+ if config.backend != "pdbfixer":
73
+ raise StructureRepairError(
74
+ f"Unsupported structure repair backend: {config.backend}"
75
+ )
76
+ try:
77
+ from openmm import Platform
78
+ from openmm.app import PDBFile
79
+ from pdbfixer import PDBFixer
80
+ except Exception as exc:
81
+ raise StructureRepairError(
82
+ "structure.repair.backend: pdbfixer was requested, but PDBFixer "
83
+ "and OpenMM are unavailable. Install PDBFixer or disable repair."
84
+ ) from exc
85
+
86
+ input_path = Path(manifest.project.input_structure)
87
+ sequence_path = (
88
+ Path(config.sequence_source) if config.sequence_source is not None else None
89
+ )
90
+ if not input_path.is_file():
91
+ raise FileNotFoundError(f"Input structure not found: {input_path}")
92
+ if sequence_path is not None and not sequence_path.is_file():
93
+ raise FileNotFoundError(
94
+ f"Structure repair sequence source not found: {sequence_path}"
95
+ )
96
+
97
+ try:
98
+ platform = Platform.getPlatformByName(config.platform)
99
+ fixer = PDBFixer(filename=str(input_path), platform=platform)
100
+ if sequence_path is not None:
101
+ sequence_fixer = PDBFixer(filename=str(sequence_path), platform=platform)
102
+ fixer.sequences = sequence_fixer.sequences
103
+ fixer.findMissingResidues()
104
+ detected_residues = _detected_missing_residues(fixer)
105
+ _validate_expected_missing_residues(
106
+ detected_residues,
107
+ expected=config.missing_residues,
108
+ )
109
+ fixer.findMissingAtoms()
110
+ detected_atoms = _detected_missing_atoms(fixer)
111
+ if detected_atoms and not config.add_missing_heavy_atoms:
112
+ formatted = ", ".join(
113
+ f"{item['chain']}:{item['resname']}{item['resid']} "
114
+ f"({','.join(item['atom_names'])})"
115
+ for item in detected_atoms
116
+ )
117
+ raise StructureRepairError(
118
+ "Missing protein heavy atoms were detected but "
119
+ "structure.repair.add_missing_heavy_atoms is false: "
120
+ + formatted
121
+ )
122
+ fixer.addMissingAtoms(seed=config.random_seed)
123
+ except StructureRepairError:
124
+ raise
125
+ except Exception as exc:
126
+ raise StructureRepairError(f"PDBFixer structure repair failed: {exc}") from exc
127
+
128
+ output = Path(output_path)
129
+ output.parent.mkdir(parents=True, exist_ok=True)
130
+ raw_output = output.with_name(output.stem + ".pdbfixer_raw.pdb")
131
+ with raw_output.open("w", encoding="utf-8") as handle:
132
+ PDBFile.writeFile(fixer.topology, fixer.positions, handle, keepIds=True)
133
+
134
+ original = read_pdb(
135
+ input_path,
136
+ altloc_policy=manifest.structure.altloc_policy,
137
+ )
138
+ repaired_raw = read_pdb(raw_output)
139
+ repaired = _restore_existing_atom_metadata(
140
+ original,
141
+ repaired_raw,
142
+ output_path=output,
143
+ )
144
+ checks = _validate_repaired_peptide_bonds(
145
+ repaired,
146
+ expected=config.missing_residues,
147
+ minimum=config.peptide_bond_min_angstrom,
148
+ maximum=config.peptide_bond_max_angstrom,
149
+ require_trans=config.require_trans_peptide_bonds,
150
+ minimum_abs_omega=config.trans_peptide_min_abs_degrees,
151
+ )
152
+ write_pdb(repaired, output)
153
+ warnings = (
154
+ "PDBFixer-generated missing coordinates are deterministic for the "
155
+ "recorded seed/platform but remain modeled coordinates; minimize the "
156
+ "final Amber system before production MD.",
157
+ )
158
+ return StructureRepairResult(
159
+ backend="pdbfixer",
160
+ backend_version=_package_version("pdbfixer"),
161
+ input_path=input_path,
162
+ sequence_source_path=sequence_path,
163
+ raw_output_path=raw_output,
164
+ output_path=output,
165
+ structure=repaired,
166
+ missing_residues_detected=tuple(detected_residues),
167
+ missing_heavy_atoms_detected=tuple(detected_atoms),
168
+ added_atom_count=len(repaired.atoms) - len(original.atoms),
169
+ peptide_bond_checks=tuple(checks),
170
+ random_seed=config.random_seed,
171
+ platform=config.platform,
172
+ warnings=warnings,
173
+ )
174
+
175
+
176
+ def _detected_missing_residues(fixer: object) -> list[dict[str, object]]:
177
+ topology = fixer.topology # type: ignore[attr-defined]
178
+ chains = list(topology.chains())
179
+ result: list[dict[str, object]] = []
180
+ for key, names in sorted(fixer.missingResidues.items()): # type: ignore[attr-defined]
181
+ if not names:
182
+ continue
183
+ chain_index, insertion_index = key
184
+ if chain_index >= len(chains):
185
+ raise StructureRepairError(
186
+ f"PDBFixer reported invalid missing-residue chain index {chain_index}."
187
+ )
188
+ chain = chains[chain_index]
189
+ residues = list(chain.residues())
190
+ previous_id = (
191
+ _integer_residue_id(residues[insertion_index - 1].id)
192
+ if insertion_index > 0
193
+ else None
194
+ )
195
+ next_id = (
196
+ _integer_residue_id(residues[insertion_index].id)
197
+ if insertion_index < len(residues)
198
+ else None
199
+ )
200
+ inferred = _infer_missing_residue_numbers(
201
+ previous_id,
202
+ next_id,
203
+ count=len(names),
204
+ )
205
+ for offset, name in enumerate(names):
206
+ result.append(
207
+ {
208
+ "chain": chain.id,
209
+ "resname": str(name),
210
+ "resid": inferred[offset],
211
+ "icode": None,
212
+ "chain_index": chain_index,
213
+ "insertion_index": insertion_index + offset,
214
+ }
215
+ )
216
+ return result
217
+
218
+
219
+ def _infer_missing_residue_numbers(
220
+ previous_id: int | None,
221
+ next_id: int | None,
222
+ *,
223
+ count: int,
224
+ ) -> list[int]:
225
+ if previous_id is not None and next_id is not None:
226
+ expected = list(range(previous_id + 1, next_id))
227
+ if len(expected) != count:
228
+ raise StructureRepairError(
229
+ "PDBFixer missing-residue placement is ambiguous: residue IDs "
230
+ f"{previous_id} and {next_id} delimit {len(expected)} position(s), "
231
+ f"but the sequence contains {count} missing residue(s)."
232
+ )
233
+ return expected
234
+ if previous_id is not None:
235
+ return list(range(previous_id + 1, previous_id + count + 1))
236
+ if next_id is not None:
237
+ return list(range(next_id - count, next_id))
238
+ raise StructureRepairError(
239
+ "PDBFixer reported missing residues in an empty chain; residue numbering "
240
+ "cannot be inferred safely."
241
+ )
242
+
243
+
244
+ def _integer_residue_id(value: str) -> int:
245
+ try:
246
+ return int(value)
247
+ except ValueError as exc:
248
+ raise StructureRepairError(
249
+ f"Structure repair requires integer PDB residue IDs; found {value!r}."
250
+ ) from exc
251
+
252
+
253
+ def _validate_expected_missing_residues(
254
+ detected: list[dict[str, object]],
255
+ *,
256
+ expected: list[ResidueSelector],
257
+ ) -> None:
258
+ detected_keys = {
259
+ (
260
+ str(item["chain"]),
261
+ str(item["resname"]),
262
+ int(item["resid"]),
263
+ item["icode"],
264
+ )
265
+ for item in detected
266
+ }
267
+ expected_keys = {
268
+ (item.chain, item.resname, item.resid, item.icode) for item in expected
269
+ }
270
+ if detected_keys != expected_keys:
271
+ missing_from_manifest = sorted(detected_keys - expected_keys)
272
+ requested_but_absent = sorted(expected_keys - detected_keys)
273
+ raise StructureRepairError(
274
+ "PDBFixer missing-residue detection does not exactly match "
275
+ "structure.repair.missing_residues. "
276
+ f"Unapproved detections: {missing_from_manifest or 'none'}; "
277
+ f"requested but not detected: {requested_but_absent or 'none'}."
278
+ )
279
+
280
+
281
+ def _detected_missing_atoms(fixer: object) -> list[dict[str, object]]:
282
+ values: list[dict[str, object]] = []
283
+ for residue, atoms in fixer.missingAtoms.items(): # type: ignore[attr-defined]
284
+ if not atoms:
285
+ continue
286
+ values.append(
287
+ {
288
+ "chain": residue.chain.id,
289
+ "resname": residue.name,
290
+ "resid": int(residue.id),
291
+ "icode": residue.insertionCode or None,
292
+ "atom_names": sorted(atom.name for atom in atoms),
293
+ "source": "missing_heavy_atoms",
294
+ }
295
+ )
296
+ for residue, atom_names in fixer.missingTerminals.items(): # type: ignore[attr-defined]
297
+ if not atom_names:
298
+ continue
299
+ values.append(
300
+ {
301
+ "chain": residue.chain.id,
302
+ "resname": residue.name,
303
+ "resid": int(residue.id),
304
+ "icode": residue.insertionCode or None,
305
+ "atom_names": sorted(str(name) for name in atom_names),
306
+ "source": "missing_terminal_atoms",
307
+ }
308
+ )
309
+ return sorted(
310
+ values,
311
+ key=lambda item: (
312
+ str(item["chain"]),
313
+ int(item["resid"]),
314
+ str(item["icode"] or ""),
315
+ ),
316
+ )
317
+
318
+
319
+ def _restore_existing_atom_metadata(
320
+ original: PdbStructure,
321
+ repaired: PdbStructure,
322
+ *,
323
+ output_path: Path,
324
+ ) -> PdbStructure:
325
+ original_by_identity = {atom.atom_identity: atom for atom in original.atoms}
326
+ repaired_by_identity = {atom.atom_identity: atom for atom in repaired.atoms}
327
+ lost = sorted(set(original_by_identity) - set(repaired_by_identity))
328
+ if lost:
329
+ raise StructureRepairError(
330
+ "PDBFixer removed or renamed existing atoms, which mdprep forbids: "
331
+ + ", ".join(str(item) for item in lost[:20])
332
+ )
333
+
334
+ restored_atoms: list[AtomRecord] = []
335
+ new_serial_by_identity: dict[tuple[str, str, int, str | None, str], int] = {}
336
+ for repaired_atom in repaired.atoms:
337
+ if repaired_atom.serial is None:
338
+ raise StructureRepairError(
339
+ f"PDBFixer output atom {repaired_atom.atom_identity} has no serial."
340
+ )
341
+ original_atom = original_by_identity.get(repaired_atom.atom_identity)
342
+ if original_atom is not None:
343
+ displacement = dist(
344
+ (original_atom.x, original_atom.y, original_atom.z),
345
+ (repaired_atom.x, repaired_atom.y, repaired_atom.z),
346
+ )
347
+ if displacement > 0.002:
348
+ raise StructureRepairError(
349
+ "PDBFixer moved an existing atom by more than 0.002 A: "
350
+ f"{original_atom.atom_identity} moved {displacement:.6f} A."
351
+ )
352
+ restored_atom = replace(
353
+ repaired_atom,
354
+ occupancy=original_atom.occupancy,
355
+ bfactor=original_atom.bfactor,
356
+ element=original_atom.element,
357
+ record_name=original_atom.record_name,
358
+ original_line=original_atom.original_line,
359
+ )
360
+ else:
361
+ restored_atom = repaired_atom
362
+ restored_atoms.append(restored_atom)
363
+ new_serial_by_identity[restored_atom.atom_identity] = repaired_atom.serial
364
+
365
+ old_identity_by_serial = {
366
+ atom.serial: atom.atom_identity
367
+ for atom in original.atoms
368
+ if atom.serial is not None
369
+ }
370
+ bonds = set(repaired.conect_bonds)
371
+ for left, right in original.conect_bonds:
372
+ left_identity = old_identity_by_serial.get(left)
373
+ right_identity = old_identity_by_serial.get(right)
374
+ if left_identity is None or right_identity is None:
375
+ continue
376
+ new_left = new_serial_by_identity.get(left_identity)
377
+ new_right = new_serial_by_identity.get(right_identity)
378
+ if new_left is not None and new_right is not None:
379
+ bonds.add((min(new_left, new_right), max(new_left, new_right)))
380
+
381
+ repaired_by_serial = {
382
+ atom.serial: atom
383
+ for atom in restored_atoms
384
+ if atom.serial is not None
385
+ }
386
+ serials_by_residue: dict[tuple[str, str, int, str | None], list[int]] = {}
387
+ for atom in restored_atoms:
388
+ if atom.serial is not None:
389
+ serials_by_residue.setdefault(atom.residue_key, []).append(atom.serial)
390
+ ter_after = set(repaired.ter_after_serials)
391
+ for old_serial in original.ter_after_serials:
392
+ identity = old_identity_by_serial.get(old_serial)
393
+ if identity is None:
394
+ continue
395
+ original_atom = original_by_identity.get(identity)
396
+ if original_atom is None:
397
+ continue
398
+ repaired_residue_serials = serials_by_residue.get(original_atom.residue_key, [])
399
+ if not repaired_residue_serials:
400
+ continue
401
+ # A terminal atom such as OXT may have been appended after the atom
402
+ # that preceded the input TER record. Move the boundary to the final
403
+ # atom of that repaired residue instead of splitting the residue.
404
+ ter_after = {
405
+ serial
406
+ for serial in ter_after
407
+ if repaired_by_serial.get(serial) is None
408
+ or repaired_by_serial[serial].residue_key != original_atom.residue_key
409
+ }
410
+ ter_after.add(repaired_residue_serials[-1])
411
+
412
+ return PdbStructure(
413
+ path=output_path,
414
+ atoms=restored_atoms,
415
+ residues=_build_residues_like(repaired.residues, restored_atoms),
416
+ model_count=1,
417
+ used_model=1,
418
+ warnings=list(original.warnings),
419
+ conect_bonds=bonds,
420
+ ter_after_serials=ter_after,
421
+ )
422
+
423
+
424
+ def _build_residues_like(
425
+ repaired_residues: list[ResidueRecord],
426
+ atoms: list[AtomRecord],
427
+ ) -> list[ResidueRecord]:
428
+ atoms_by_key: dict[tuple[str, str, int, str | None], list[AtomRecord]] = {}
429
+ for atom in atoms:
430
+ atoms_by_key.setdefault(atom.residue_key, []).append(atom)
431
+ result: list[ResidueRecord] = []
432
+ for index, residue in enumerate(repaired_residues):
433
+ residue_atoms = atoms_by_key.get(
434
+ (
435
+ residue.id.chain_id,
436
+ residue.id.resname,
437
+ residue.id.resid,
438
+ residue.id.icode,
439
+ ),
440
+ [],
441
+ )
442
+ if not residue_atoms:
443
+ continue
444
+ result.append(
445
+ ResidueRecord(
446
+ id=residue.id,
447
+ atoms=residue_atoms,
448
+ record_names={atom.record_name for atom in residue_atoms},
449
+ original_index=index,
450
+ )
451
+ )
452
+ return result
453
+
454
+
455
+ def _validate_repaired_peptide_bonds(
456
+ structure: PdbStructure,
457
+ *,
458
+ expected: list[ResidueSelector],
459
+ minimum: float,
460
+ maximum: float,
461
+ require_trans: bool,
462
+ minimum_abs_omega: float,
463
+ ) -> list[dict[str, object]]:
464
+ polymer = [
465
+ residue for residue in structure.residues if is_standard_protein_residue(residue)
466
+ ]
467
+ checks: list[dict[str, object]] = []
468
+ for selector in expected:
469
+ matches = [
470
+ residue
471
+ for residue in polymer
472
+ if residue.id.chain_id == selector.chain
473
+ and residue.id.resname == selector.resname
474
+ and residue.id.resid == selector.resid
475
+ and residue.id.icode == selector.icode
476
+ ]
477
+ if len(matches) != 1:
478
+ raise StructureRepairError(
479
+ f"Repaired residue {selector.chain}:{selector.resname}{selector.resid} "
480
+ f"resolved {len(matches)} times."
481
+ )
482
+ residue = matches[0]
483
+ index = polymer.index(residue)
484
+ neighbors: list[tuple[ResidueRecord, ResidueRecord]] = []
485
+ if index > 0 and polymer[index - 1].id.chain_id == residue.id.chain_id:
486
+ neighbors.append((polymer[index - 1], residue))
487
+ if (
488
+ index + 1 < len(polymer)
489
+ and polymer[index + 1].id.chain_id == residue.id.chain_id
490
+ ):
491
+ neighbors.append((residue, polymer[index + 1]))
492
+ for left, right in neighbors:
493
+ c_atom = _required_atom(left, "C")
494
+ n_atom = _required_atom(right, "N")
495
+ length = dist(
496
+ (c_atom.x, c_atom.y, c_atom.z),
497
+ (n_atom.x, n_atom.y, n_atom.z),
498
+ )
499
+ if not minimum <= length <= maximum:
500
+ raise StructureRepairError(
501
+ f"Repaired peptide bond {left.id.display()} C -- "
502
+ f"{right.id.display()} N is {length:.4f} A; required "
503
+ f"{minimum:.3f}-{maximum:.3f} A."
504
+ )
505
+ omega = _dihedral_degrees(
506
+ _required_atom(left, "CA"),
507
+ c_atom,
508
+ n_atom,
509
+ _required_atom(right, "CA"),
510
+ )
511
+ if require_trans and abs(omega) < minimum_abs_omega:
512
+ raise StructureRepairError(
513
+ f"Repaired peptide bond {left.id.display()}--"
514
+ f"{right.id.display()} has omega {omega:.2f} degrees; "
515
+ "an unapproved cis/non-trans peptide was generated."
516
+ )
517
+ checks.append(
518
+ {
519
+ "left_residue": left.id.to_dict(),
520
+ "right_residue": right.id.to_dict(),
521
+ "c_n_distance_angstrom": length,
522
+ "omega_degrees": omega,
523
+ "trans_required": require_trans,
524
+ "ok": True,
525
+ }
526
+ )
527
+ return checks
528
+
529
+
530
+ def _required_atom(residue: ResidueRecord, name: str) -> AtomRecord:
531
+ matches = [atom for atom in residue.atoms if atom.name == name]
532
+ if len(matches) != 1:
533
+ raise StructureRepairError(
534
+ f"Residue {residue.id.display()} requires exactly one {name} atom "
535
+ f"for peptide validation; found {len(matches)}."
536
+ )
537
+ return matches[0]
538
+
539
+
540
+ def _dihedral_degrees(
541
+ atom0: AtomRecord,
542
+ atom1: AtomRecord,
543
+ atom2: AtomRecord,
544
+ atom3: AtomRecord,
545
+ ) -> float:
546
+ points = [
547
+ np.asarray((atom.x, atom.y, atom.z), dtype=float)
548
+ for atom in (atom0, atom1, atom2, atom3)
549
+ ]
550
+ b0 = -(points[1] - points[0])
551
+ b1 = points[2] - points[1]
552
+ b2 = points[3] - points[2]
553
+ norm = np.linalg.norm(b1)
554
+ if norm == 0:
555
+ raise StructureRepairError("Cannot evaluate peptide omega for zero-length bond.")
556
+ b1 /= norm
557
+ v = b0 - np.dot(b0, b1) * b1
558
+ w = b2 - np.dot(b2, b1) * b1
559
+ return float(np.degrees(np.arctan2(np.dot(np.cross(b1, v), w), np.dot(v, w))))
560
+
561
+
562
+ def _package_version(name: str) -> str:
563
+ try:
564
+ return version(name)
565
+ except PackageNotFoundError:
566
+ return "unknown"