mdprep 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdprep/__init__.py +7 -0
- mdprep/_selftest_data/basic_protein.yaml +33 -0
- mdprep/_selftest_data/ligand_sub.mol2 +11 -0
- mdprep/ambertools/__init__.py +2 -0
- mdprep/ambertools/antechamber.py +110 -0
- mdprep/ambertools/commands.py +33 -0
- mdprep/ambertools/mol2.py +415 -0
- mdprep/ambertools/parmchk2.py +89 -0
- mdprep/ambertools/resp.py +570 -0
- mdprep/charges/__init__.py +1 -0
- mdprep/charges/esp_grid.py +190 -0
- mdprep/cli.py +451 -0
- mdprep/config/__init__.py +9 -0
- mdprep/config/loader.py +146 -0
- mdprep/config/models.py +1100 -0
- mdprep/external/__init__.py +14 -0
- mdprep/external/discovery.py +14 -0
- mdprep/external/runner.py +55 -0
- mdprep/leap/__init__.py +2 -0
- mdprep/leap/builder.py +476 -0
- mdprep/leap/forcefields.py +93 -0
- mdprep/leap/ions.py +112 -0
- mdprep/leap/log_parser.py +170 -0
- mdprep/leap/report.py +99 -0
- mdprep/leap/residues.py +653 -0
- mdprep/leap/runner.py +65 -0
- mdprep/ligands/__init__.py +2 -0
- mdprep/ligands/extract.py +355 -0
- mdprep/ligands/identity.py +39 -0
- mdprep/ligands/pyscf_charges.py +242 -0
- mdprep/ligands/report.py +146 -0
- mdprep/ligands/workflow.py +838 -0
- mdprep/md/__init__.py +5 -0
- mdprep/md/roe_brooks.py +650 -0
- mdprep/metals/__init__.py +2 -0
- mdprep/metals/c4.py +157 -0
- mdprep/metals/coordination.py +313 -0
- mdprep/metals/gxtb_hessian.py +467 -0
- mdprep/metals/hydrogenate.py +344 -0
- mdprep/metals/mace_polar1_hessian.py +357 -0
- mdprep/metals/mace_polar1_worker.py +295 -0
- mdprep/metals/mcpb.py +949 -0
- mdprep/metals/mcpb_compat.py +125 -0
- mdprep/metals/nonbonded.py +294 -0
- mdprep/metals/parameter_comparison.py +563 -0
- mdprep/metals/pyscf_mcpb.py +1388 -0
- mdprep/metals/report.py +119 -0
- mdprep/metals/workflow.py +320 -0
- mdprep/protonation/__init__.py +15 -0
- mdprep/protonation/apply.py +968 -0
- mdprep/protonation/disulfide_states.py +141 -0
- mdprep/protonation/histidine_geometry.py +950 -0
- mdprep/protonation/histidine_xtb.py +390 -0
- mdprep/protonation/overrides.py +64 -0
- mdprep/protonation/pka_rules.py +165 -0
- mdprep/protonation/propka.py +174 -0
- mdprep/protonation/propka_parser.py +140 -0
- mdprep/protonation/report.py +187 -0
- mdprep/protonation/states.py +52 -0
- mdprep/protonation/temporary_hydrogenation.py +238 -0
- mdprep/protonation/xtb_parser.py +79 -0
- mdprep/protonation/xtb_runner.py +108 -0
- mdprep/qm/__init__.py +2 -0
- mdprep/qm/point_charges.py +233 -0
- mdprep/qm/pyscf_esp.py +70 -0
- mdprep/qm/pyscf_runner.py +570 -0
- mdprep/refinement/__init__.py +5 -0
- mdprep/refinement/ash.py +541 -0
- mdprep/refinement/coordinates.py +210 -0
- mdprep/refinement/mapping.py +163 -0
- mdprep/refinement/report.py +66 -0
- mdprep/refinement/selection.py +436 -0
- mdprep/refinement/workflow.py +188 -0
- mdprep/reports/__init__.py +8 -0
- mdprep/reports/structure_report.py +92 -0
- mdprep/structure/__init__.py +14 -0
- mdprep/structure/classify.py +92 -0
- mdprep/structure/disulfides.py +51 -0
- mdprep/structure/inspect.py +105 -0
- mdprep/structure/models.py +86 -0
- mdprep/structure/normalize.py +363 -0
- mdprep/structure/pdb.py +511 -0
- mdprep/structure/repair.py +566 -0
- mdprep/structure/selectors.py +204 -0
- mdprep/structure/writer.py +84 -0
- mdprep/validation/__init__.py +2 -0
- mdprep/validation/openmm_check.py +86 -0
- mdprep/validation/parmed_check.py +43 -0
- mdprep/validation/topology.py +239 -0
- mdprep/workflows/__init__.py +6 -0
- mdprep/workflows/init.py +242 -0
- mdprep/workflows/inspect.py +21 -0
- mdprep/workflows/prepare.py +763 -0
- mdprep/workflows/selftest.py +220 -0
- mdprep/workflows/validate.py +30 -0
- mdprep-0.2.0.dist-info/METADATA +264 -0
- mdprep-0.2.0.dist-info/RECORD +100 -0
- mdprep-0.2.0.dist-info/WHEEL +4 -0
- mdprep-0.2.0.dist-info/entry_points.txt +2 -0
- mdprep-0.2.0.dist-info/licenses/LICENSE +21 -0
mdprep/structure/pdb.py
ADDED
|
@@ -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
|