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,511 @@
1
+ """Lightweight fixed-width PDB parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import OrderedDict, defaultdict
6
+ from pathlib import Path
7
+ from typing import Iterable, Literal
8
+
9
+ from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
10
+
11
+
12
+ AltlocPolicy = Literal["highest_occupancy", "first", "fail"]
13
+ VALID_ALTLOC_POLICIES = {"highest_occupancy", "first", "fail"}
14
+
15
+
16
+ class PdbParseError(ValueError):
17
+ """Raised when a PDB file cannot be parsed under the requested policy."""
18
+
19
+
20
+ def read_pdb(
21
+ path: str | Path,
22
+ *,
23
+ altloc_policy: AltlocPolicy = "highest_occupancy",
24
+ ) -> PdbStructure:
25
+ pdb_path = Path(path)
26
+ if pdb_path.suffix.lower() in {".cif", ".mmcif"}:
27
+ raise PdbParseError("mmCIF input is not supported in mdprep Task 2; provide a PDB file.")
28
+ if altloc_policy not in VALID_ALTLOC_POLICIES:
29
+ raise PdbParseError(
30
+ f"Invalid altloc policy {altloc_policy!r}; expected one of {sorted(VALID_ALTLOC_POLICIES)}"
31
+ )
32
+ if not pdb_path.exists():
33
+ raise FileNotFoundError(f"Input structure not found: {pdb_path}")
34
+
35
+ lines = pdb_path.read_text(encoding="utf-8").splitlines()
36
+ model_count = sum(1 for line in lines if line.startswith("MODEL"))
37
+ warnings: list[str] = []
38
+ if model_count == 0:
39
+ model_count = 1
40
+ model_lines = lines
41
+ else:
42
+ model_lines = _first_model_lines(lines)
43
+ if model_count > 1:
44
+ warnings.append(f"Input contains {model_count} MODEL records; using MODEL 1 only.")
45
+
46
+ raw_atoms = [_parse_atom_line(line) for line in model_lines if line.startswith(("ATOM", "HETATM"))]
47
+ atoms = _apply_altloc_policy(raw_atoms, altloc_policy)
48
+ residues = _build_residues(atoms)
49
+ present_serials = {atom.serial for atom in atoms if atom.serial is not None}
50
+ if len(present_serials) != sum(atom.serial is not None for atom in atoms):
51
+ raise PdbParseError("PDB atom serials must be unique when present.")
52
+ ter_after_serials = _parse_ter_after_serials(model_lines, present_serials)
53
+ # CONECT records conventionally follow ENDMDL, so inspect the complete
54
+ # file while filtering endpoints to the selected first model/altloc.
55
+ conect_bonds = _parse_conect_bonds(lines, present_serials)
56
+ return PdbStructure(
57
+ path=pdb_path,
58
+ atoms=atoms,
59
+ residues=residues,
60
+ model_count=model_count,
61
+ used_model=1,
62
+ warnings=warnings,
63
+ conect_bonds=conect_bonds,
64
+ ter_after_serials=ter_after_serials,
65
+ )
66
+
67
+
68
+ def _parse_ter_after_serials(
69
+ lines: list[str],
70
+ present_serials: set[int],
71
+ ) -> set[int]:
72
+ result: set[int] = set()
73
+ last_serial: int | None = None
74
+ for line in lines:
75
+ if line.startswith(("ATOM", "HETATM")):
76
+ serial = _parse_int(line[6:11])
77
+ if serial in present_serials:
78
+ last_serial = serial
79
+ elif line.startswith("TER") and last_serial is not None:
80
+ result.add(last_serial)
81
+ return result
82
+
83
+
84
+ def _parse_conect_bonds(
85
+ lines: list[str],
86
+ present_serials: set[int],
87
+ ) -> set[tuple[int, int]]:
88
+ bonds: set[tuple[int, int]] = set()
89
+ for line in lines:
90
+ if not line.startswith("CONECT"):
91
+ continue
92
+ values: list[int] = []
93
+ for field in line[6:].split():
94
+ try:
95
+ values.append(int(field))
96
+ except ValueError:
97
+ continue
98
+ if len(values) < 2:
99
+ continue
100
+ source, *targets = values
101
+ if source not in present_serials:
102
+ continue
103
+ for target in targets:
104
+ if target not in present_serials or target == source:
105
+ continue
106
+ bonds.add((min(source, target), max(source, target)))
107
+ return bonds
108
+
109
+
110
+ def _first_model_lines(lines: Iterable[str]) -> list[str]:
111
+ in_first_model = False
112
+ collected: list[str] = []
113
+ for line in lines:
114
+ if line.startswith("MODEL"):
115
+ if not in_first_model and not collected:
116
+ in_first_model = True
117
+ continue
118
+ if in_first_model:
119
+ continue
120
+ if line.startswith("ENDMDL") and in_first_model:
121
+ break
122
+ if in_first_model:
123
+ collected.append(line)
124
+ return collected
125
+
126
+
127
+ def _parse_atom_line(line: str) -> AtomRecord:
128
+ record_name = line[0:6].strip()
129
+ if record_name not in {"ATOM", "HETATM"}:
130
+ raise PdbParseError(f"Unsupported atom record {record_name!r}")
131
+
132
+ serial = _parse_int(line[6:11])
133
+ name = line[12:16].strip()
134
+ altloc = _blank_to_none(line[16:17])
135
+ resname = line[17:20].strip()
136
+ chain_id = line[21:22]
137
+ if chain_id == " ":
138
+ chain_id = ""
139
+ resid_text = line[22:26].strip()
140
+ if not resid_text:
141
+ raise PdbParseError(f"Missing residue number in line: {line}")
142
+ resid = int(resid_text)
143
+ icode = _blank_to_none(line[26:27])
144
+ x = _parse_float_required(line[30:38], "x", line)
145
+ y = _parse_float_required(line[38:46], "y", line)
146
+ z = _parse_float_required(line[46:54], "z", line)
147
+ occupancy = _parse_float(line[54:60])
148
+ bfactor = _parse_float(line[60:66])
149
+ element = _blank_to_none(line[76:78] if len(line) >= 78 else "")
150
+ if element is None:
151
+ element = infer_element(
152
+ name,
153
+ resname=resname,
154
+ record_name=record_name,
155
+ atom_field=line[12:16],
156
+ )
157
+
158
+ return AtomRecord(
159
+ serial=serial,
160
+ name=name,
161
+ altloc=altloc,
162
+ resname=resname,
163
+ chain_id=chain_id,
164
+ resid=resid,
165
+ icode=icode,
166
+ x=x,
167
+ y=y,
168
+ z=z,
169
+ occupancy=occupancy,
170
+ bfactor=bfactor,
171
+ element=element,
172
+ record_name=record_name, # type: ignore[arg-type]
173
+ original_line=line,
174
+ )
175
+
176
+
177
+ STANDARD_ELEMENT_BY_PROTEIN_ATOM = {
178
+ "C": "C",
179
+ "CA": "C",
180
+ "CB": "C",
181
+ "CG": "C",
182
+ "CG1": "C",
183
+ "CG2": "C",
184
+ "CD": "C",
185
+ "CD1": "C",
186
+ "CD2": "C",
187
+ "CE": "C",
188
+ "CE1": "C",
189
+ "CE2": "C",
190
+ "CE3": "C",
191
+ "CH2": "C",
192
+ "CZ": "C",
193
+ "CZ2": "C",
194
+ "CZ3": "C",
195
+ "N": "N",
196
+ "ND1": "N",
197
+ "ND2": "N",
198
+ "NE": "N",
199
+ "NE1": "N",
200
+ "NE2": "N",
201
+ "NH1": "N",
202
+ "NH2": "N",
203
+ "NZ": "N",
204
+ "O": "O",
205
+ "OD1": "O",
206
+ "OD2": "O",
207
+ "OE1": "O",
208
+ "OE2": "O",
209
+ "OG": "O",
210
+ "OG1": "O",
211
+ "OH": "O",
212
+ "OXT": "O",
213
+ "S": "S",
214
+ "SD": "S",
215
+ "SG": "S",
216
+ }
217
+
218
+ STANDARD_PROTEIN_RESNAMES = {
219
+ "ALA",
220
+ "ARG",
221
+ "ASN",
222
+ "ASP",
223
+ "ASH",
224
+ "CYS",
225
+ "CYM",
226
+ "CYX",
227
+ "GLN",
228
+ "GLU",
229
+ "GLH",
230
+ "GLY",
231
+ "HIS",
232
+ "HID",
233
+ "HIE",
234
+ "HIP",
235
+ "ILE",
236
+ "LEU",
237
+ "LYS",
238
+ "LYN",
239
+ "MET",
240
+ "PHE",
241
+ "PRO",
242
+ "SER",
243
+ "THR",
244
+ "TRP",
245
+ "TYR",
246
+ "VAL",
247
+ }
248
+
249
+ PERIODIC_ELEMENTS = {
250
+ "H",
251
+ "HE",
252
+ "LI",
253
+ "BE",
254
+ "B",
255
+ "C",
256
+ "N",
257
+ "O",
258
+ "F",
259
+ "NE",
260
+ "NA",
261
+ "MG",
262
+ "AL",
263
+ "SI",
264
+ "P",
265
+ "S",
266
+ "CL",
267
+ "AR",
268
+ "K",
269
+ "CA",
270
+ "SC",
271
+ "TI",
272
+ "V",
273
+ "CR",
274
+ "MN",
275
+ "FE",
276
+ "CO",
277
+ "NI",
278
+ "CU",
279
+ "ZN",
280
+ "GA",
281
+ "GE",
282
+ "AS",
283
+ "SE",
284
+ "BR",
285
+ "KR",
286
+ "RB",
287
+ "SR",
288
+ "Y",
289
+ "ZR",
290
+ "NB",
291
+ "MO",
292
+ "TC",
293
+ "RU",
294
+ "RH",
295
+ "PD",
296
+ "AG",
297
+ "CD",
298
+ "IN",
299
+ "SN",
300
+ "SB",
301
+ "TE",
302
+ "I",
303
+ "XE",
304
+ "CS",
305
+ "BA",
306
+ "LA",
307
+ "CE",
308
+ "PR",
309
+ "ND",
310
+ "PM",
311
+ "SM",
312
+ "EU",
313
+ "GD",
314
+ "TB",
315
+ "DY",
316
+ "HO",
317
+ "ER",
318
+ "TM",
319
+ "YB",
320
+ "LU",
321
+ "HF",
322
+ "TA",
323
+ "W",
324
+ "RE",
325
+ "OS",
326
+ "IR",
327
+ "PT",
328
+ "AU",
329
+ "HG",
330
+ "TL",
331
+ "PB",
332
+ "BI",
333
+ "PO",
334
+ "AT",
335
+ "RN",
336
+ "FR",
337
+ "RA",
338
+ "AC",
339
+ "TH",
340
+ "PA",
341
+ "U",
342
+ "NP",
343
+ "PU",
344
+ "AM",
345
+ "CM",
346
+ "BK",
347
+ "CF",
348
+ "ES",
349
+ "FM",
350
+ "MD",
351
+ "NO",
352
+ "LR",
353
+ "RF",
354
+ "DB",
355
+ "SG",
356
+ "BH",
357
+ "HS",
358
+ "MT",
359
+ "DS",
360
+ "RG",
361
+ "CN",
362
+ "NH",
363
+ "FL",
364
+ "MC",
365
+ "LV",
366
+ "TS",
367
+ "OG",
368
+ }
369
+
370
+
371
+ def infer_element(
372
+ atom_name: str,
373
+ *,
374
+ resname: str | None = None,
375
+ record_name: str | None = None,
376
+ atom_field: str | None = None,
377
+ ) -> str | None:
378
+ stripped = atom_name.strip()
379
+ if not stripped:
380
+ return None
381
+ while stripped and stripped[0].isdigit():
382
+ stripped = stripped[1:]
383
+ if not stripped:
384
+ return None
385
+ upper = stripped.upper()
386
+ residue_upper = (resname or "").strip().upper()
387
+ # Some AmberTools/MCPB.py PDB writers emit a one-atom ion as ATOM rather
388
+ # than HETATM and align its atom field like a one-letter element (for
389
+ # example " FE "). A coincident elemental residue/atom symbol is the
390
+ # unambiguous ion identity in either record form.
391
+ if (
392
+ record_name in {"ATOM", "HETATM"}
393
+ and residue_upper == upper
394
+ and upper in PERIODIC_ELEMENTS
395
+ ):
396
+ return _canonical_element(upper)
397
+ if resname in STANDARD_PROTEIN_RESNAMES:
398
+ if upper.startswith("H"):
399
+ return "H"
400
+ mapped = STANDARD_ELEMENT_BY_PROTEIN_ATOM.get(upper)
401
+ if mapped is not None:
402
+ return mapped
403
+ return upper[0]
404
+ if atom_field:
405
+ aligned = atom_field.rstrip()
406
+ if aligned.startswith(" ") and upper[0] in PERIODIC_ELEMENTS:
407
+ return _canonical_element(upper[0])
408
+ if not aligned.startswith(" ") and len(upper) >= 2 and upper[:2] in PERIODIC_ELEMENTS:
409
+ return _canonical_element(upper[:2])
410
+ if len(upper) >= 2 and upper[:2] in PERIODIC_ELEMENTS:
411
+ return _canonical_element(upper[:2])
412
+ if upper[0] in PERIODIC_ELEMENTS:
413
+ return _canonical_element(upper[0])
414
+ return upper[0]
415
+
416
+
417
+ def _canonical_element(symbol: str) -> str:
418
+ upper = symbol.upper()
419
+ return upper[0] + upper[1:].lower()
420
+
421
+
422
+ def _apply_altloc_policy(atoms: list[AtomRecord], policy: AltlocPolicy) -> list[AtomRecord]:
423
+ grouped: dict[tuple[str, str, int, str | None, str], list[tuple[int, AtomRecord]]] = defaultdict(list)
424
+ for index, atom in enumerate(atoms):
425
+ grouped[atom.atom_identity].append((index, atom))
426
+
427
+ selected_indices: set[int] = set()
428
+ for identity, entries in grouped.items():
429
+ alternates = [(index, atom) for index, atom in entries if atom.altloc is not None]
430
+ if not alternates:
431
+ selected_indices.update(index for index, _ in entries)
432
+ continue
433
+ if policy == "fail":
434
+ display = _identity_display(identity)
435
+ raise PdbParseError(f"Unresolved alternate locations found for atom {display}")
436
+ selected_index, _ = _select_altloc(entries, policy)
437
+ selected_indices.add(selected_index)
438
+
439
+ return [atom for index, atom in enumerate(atoms) if index in selected_indices]
440
+
441
+
442
+ def _select_altloc(
443
+ entries: list[tuple[int, AtomRecord]],
444
+ policy: AltlocPolicy,
445
+ ) -> tuple[int, AtomRecord]:
446
+ blank_entries = [(index, atom) for index, atom in entries if atom.altloc is None]
447
+ if policy == "first":
448
+ return blank_entries[0] if blank_entries else entries[0]
449
+
450
+ if blank_entries:
451
+ blank_index, blank_atom = blank_entries[0]
452
+ blank_occ = blank_atom.occupancy if blank_atom.occupancy is not None else 0.0
453
+ best_alt_index, best_alt = max(
454
+ [(index, atom) for index, atom in entries if atom.altloc is not None],
455
+ key=lambda item: (item[1].occupancy if item[1].occupancy is not None else 0.0, -item[0]),
456
+ )
457
+ best_alt_occ = best_alt.occupancy if best_alt.occupancy is not None else 0.0
458
+ if best_alt_occ > blank_occ:
459
+ return best_alt_index, best_alt
460
+ return blank_index, blank_atom
461
+
462
+ return max(
463
+ entries,
464
+ key=lambda item: (item[1].occupancy if item[1].occupancy is not None else 0.0, -item[0]),
465
+ )
466
+
467
+
468
+ def _build_residues(atoms: list[AtomRecord]) -> list[ResidueRecord]:
469
+ grouped: "OrderedDict[tuple[str, str, int, str | None], list[AtomRecord]]" = OrderedDict()
470
+ for atom in atoms:
471
+ grouped.setdefault(atom.residue_key, []).append(atom)
472
+
473
+ residues: list[ResidueRecord] = []
474
+ for index, ((chain_id, resname, resid, icode), residue_atoms) in enumerate(grouped.items()):
475
+ residues.append(
476
+ ResidueRecord(
477
+ id=ResidueId(chain_id=chain_id, resname=resname, resid=resid, icode=icode),
478
+ atoms=residue_atoms,
479
+ record_names={atom.record_name for atom in residue_atoms},
480
+ original_index=index,
481
+ )
482
+ )
483
+ return residues
484
+
485
+
486
+ def _identity_display(identity: tuple[str, str, int, str | None, str]) -> str:
487
+ chain_id, resname, resid, icode, atom_name = identity
488
+ chain = chain_id if chain_id else "<blank>"
489
+ return f"{chain}:{resname}{resid}{icode or ''}@{atom_name}"
490
+
491
+
492
+ def _parse_int(text: str) -> int | None:
493
+ stripped = text.strip()
494
+ return int(stripped) if stripped else None
495
+
496
+
497
+ def _parse_float(text: str) -> float | None:
498
+ stripped = text.strip()
499
+ return float(stripped) if stripped else None
500
+
501
+
502
+ def _parse_float_required(text: str, field_name: str, line: str) -> float:
503
+ stripped = text.strip()
504
+ if not stripped:
505
+ raise PdbParseError(f"Missing {field_name} coordinate in line: {line}")
506
+ return float(stripped)
507
+
508
+
509
+ def _blank_to_none(text: str) -> str | None:
510
+ stripped = text.strip()
511
+ return stripped if stripped else None