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/leap/runner.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""tleap subprocess wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.external.discovery import which_executable
|
|
10
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
11
|
+
from mdprep.leap.log_parser import LeapLogSummary, parse_tleap_log
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TLeapRunError(RuntimeError):
|
|
15
|
+
"""Raised when tleap cannot be launched."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class TLeapRun:
|
|
20
|
+
command_result: CommandResult
|
|
21
|
+
input_path: Path
|
|
22
|
+
log_path: Path
|
|
23
|
+
summary: LeapLogSummary
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, object]:
|
|
26
|
+
return {
|
|
27
|
+
"command": list(self.command_result.command),
|
|
28
|
+
"cwd": self.command_result.cwd,
|
|
29
|
+
"returncode": self.command_result.returncode,
|
|
30
|
+
"stdout": self.command_result.stdout,
|
|
31
|
+
"stderr": self.command_result.stderr,
|
|
32
|
+
"runtime_seconds": self.command_result.runtime_seconds,
|
|
33
|
+
"input_path": str(self.input_path),
|
|
34
|
+
"log_path": str(self.log_path),
|
|
35
|
+
"summary": self.summary.to_dict(),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run_tleap(
|
|
40
|
+
input_path: str | Path,
|
|
41
|
+
*,
|
|
42
|
+
work_dir: str | Path,
|
|
43
|
+
executable: str = "tleap",
|
|
44
|
+
) -> TLeapRun:
|
|
45
|
+
exe = which_executable(executable)
|
|
46
|
+
if exe is None:
|
|
47
|
+
raise TLeapRunError(f"AmberTools executable not found: {executable}")
|
|
48
|
+
work = Path(work_dir)
|
|
49
|
+
script = Path(input_path)
|
|
50
|
+
for stale_log in (work / "leap.log", work / "tleap.log"):
|
|
51
|
+
if stale_log.exists():
|
|
52
|
+
stale_log.unlink()
|
|
53
|
+
result = run_command([exe, "-f", script.name], cwd=work)
|
|
54
|
+
generated_log = work / "leap.log"
|
|
55
|
+
log_path = work / "tleap.log"
|
|
56
|
+
if generated_log.exists():
|
|
57
|
+
shutil.copyfile(generated_log, log_path)
|
|
58
|
+
else:
|
|
59
|
+
log_path.write_text(result.stdout + "\n" + result.stderr, encoding="utf-8")
|
|
60
|
+
return TLeapRun(
|
|
61
|
+
command_result=result,
|
|
62
|
+
input_path=script,
|
|
63
|
+
log_path=log_path,
|
|
64
|
+
summary=parse_tleap_log(log_path, returncode=result.returncode),
|
|
65
|
+
)
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Ligand residue extraction from prepared structures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections import Counter
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from dataclasses import replace
|
|
11
|
+
|
|
12
|
+
from mdprep.config.models import LigandConfig, ManifestConfig
|
|
13
|
+
from mdprep.ligands.identity import ligand_identity_dict
|
|
14
|
+
from mdprep.structure.models import PdbStructure, ResidueRecord
|
|
15
|
+
from mdprep.structure.pdb import infer_element
|
|
16
|
+
from mdprep.structure.selectors import SelectorError, resolve_residue_selector
|
|
17
|
+
from mdprep.structure.writer import write_pdb
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LigandExtractionError(ValueError):
|
|
21
|
+
"""Raised when configured ligands cannot be extracted safely."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ExtractedLigand:
|
|
26
|
+
config: LigandConfig
|
|
27
|
+
residue: ResidueRecord
|
|
28
|
+
pdb_path: Path
|
|
29
|
+
identity_path: Path
|
|
30
|
+
warnings: list[str]
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def atoms(self):
|
|
34
|
+
return self.residue.atoms
|
|
35
|
+
|
|
36
|
+
def identity_dict(self) -> dict[str, object]:
|
|
37
|
+
return ligand_identity_dict(
|
|
38
|
+
ligand_id=self.config.id,
|
|
39
|
+
selector=self.config.selector.model_dump(mode="json"),
|
|
40
|
+
residue=self.residue,
|
|
41
|
+
net_charge=self.config.net_charge,
|
|
42
|
+
multiplicity=self.config.multiplicity,
|
|
43
|
+
expected_formula=self.config.expected_formula,
|
|
44
|
+
charge_method=self.config.charge_method,
|
|
45
|
+
atom_types=self.config.atom_types,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def extract_configured_ligands(
|
|
50
|
+
structure: PdbStructure,
|
|
51
|
+
manifest: ManifestConfig,
|
|
52
|
+
*,
|
|
53
|
+
output_dir: str | Path,
|
|
54
|
+
) -> list[ExtractedLigand]:
|
|
55
|
+
extracted: list[ExtractedLigand] = []
|
|
56
|
+
for ligand in manifest.ligands:
|
|
57
|
+
extracted.append(extract_ligand(structure, ligand, output_dir=output_dir))
|
|
58
|
+
return extracted
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def extract_ligand(
|
|
62
|
+
structure: PdbStructure,
|
|
63
|
+
ligand: LigandConfig,
|
|
64
|
+
*,
|
|
65
|
+
output_dir: str | Path,
|
|
66
|
+
) -> ExtractedLigand:
|
|
67
|
+
try:
|
|
68
|
+
residue = resolve_residue_selector(structure, ligand.selector.model_dump())
|
|
69
|
+
except SelectorError as exc:
|
|
70
|
+
raise LigandExtractionError(
|
|
71
|
+
f"Ligand {ligand.id} selector did not resolve exactly one residue: {exc}"
|
|
72
|
+
) from exc
|
|
73
|
+
if not residue.atoms:
|
|
74
|
+
raise LigandExtractionError(f"Ligand {ligand.id} selector resolved a residue with zero atoms.")
|
|
75
|
+
|
|
76
|
+
warnings: list[str] = []
|
|
77
|
+
fixed_atoms = []
|
|
78
|
+
for atom in residue.atoms:
|
|
79
|
+
if atom.element is None:
|
|
80
|
+
atom_field = atom.original_line[12:16] if len(atom.original_line) >= 16 else None
|
|
81
|
+
inferred = infer_element(
|
|
82
|
+
atom.name,
|
|
83
|
+
resname=atom.resname,
|
|
84
|
+
record_name=atom.record_name,
|
|
85
|
+
atom_field=atom_field,
|
|
86
|
+
)
|
|
87
|
+
warnings.append(f"Inferred missing element for ligand {ligand.id} atom {atom.name}: {inferred}")
|
|
88
|
+
fixed_atoms.append(atom.__class__(**{**atom.__dict__, "element": inferred}))
|
|
89
|
+
else:
|
|
90
|
+
fixed_atoms.append(atom)
|
|
91
|
+
fixed_atoms, rename_map = _uniquify_duplicate_atom_names(fixed_atoms)
|
|
92
|
+
if rename_map:
|
|
93
|
+
renamed = ", ".join(f"{old}->{new}" for old, new in rename_map)
|
|
94
|
+
warnings.append(
|
|
95
|
+
"Ligand atom names were not unique in the input PDB; generated deterministic "
|
|
96
|
+
f"Amber-safe names for this ligand: {renamed}."
|
|
97
|
+
)
|
|
98
|
+
if fixed_atoms != residue.atoms:
|
|
99
|
+
residue = ResidueRecord(
|
|
100
|
+
id=residue.id,
|
|
101
|
+
atoms=fixed_atoms,
|
|
102
|
+
record_names=residue.record_names,
|
|
103
|
+
original_index=residue.original_index,
|
|
104
|
+
)
|
|
105
|
+
warnings.extend(_validate_ligand_formula(ligand, residue))
|
|
106
|
+
|
|
107
|
+
ligand_dir = Path(output_dir) / "ligands" / ligand.id / "input"
|
|
108
|
+
ligand_dir.mkdir(parents=True, exist_ok=True)
|
|
109
|
+
pdb_path = ligand_dir / f"{ligand.id}.pdb"
|
|
110
|
+
identity_path = ligand_dir / "identity.json"
|
|
111
|
+
ligand_structure = PdbStructure(
|
|
112
|
+
path=pdb_path,
|
|
113
|
+
atoms=list(residue.atoms),
|
|
114
|
+
residues=[residue],
|
|
115
|
+
model_count=1,
|
|
116
|
+
)
|
|
117
|
+
write_pdb(ligand_structure, pdb_path)
|
|
118
|
+
conect_lines = _ligand_conect_lines(structure.path, residue.atoms)
|
|
119
|
+
if conect_lines:
|
|
120
|
+
_insert_conect_before_end(pdb_path, conect_lines)
|
|
121
|
+
warnings.append(
|
|
122
|
+
f"Preserved {len(conect_lines)} ligand CONECT record(s) for {ligand.id} in the extracted PDB."
|
|
123
|
+
)
|
|
124
|
+
elif _requires_pdb_chemistry_perception(ligand):
|
|
125
|
+
warnings.append(
|
|
126
|
+
"No ligand CONECT records were found in the input PDB. AmberTools will infer ligand "
|
|
127
|
+
"connectivity from coordinates; for chemically sensitive or complex substrates, provide "
|
|
128
|
+
"a curated user_mol2/user_frcmod so atom types and bonded terms are not inferred from PDB geometry."
|
|
129
|
+
)
|
|
130
|
+
extracted = ExtractedLigand(
|
|
131
|
+
config=ligand,
|
|
132
|
+
residue=residue,
|
|
133
|
+
pdb_path=pdb_path,
|
|
134
|
+
identity_path=identity_path,
|
|
135
|
+
warnings=warnings,
|
|
136
|
+
)
|
|
137
|
+
identity_path.write_text(
|
|
138
|
+
json.dumps(extracted.identity_dict(), indent=2, sort_keys=True) + "\n",
|
|
139
|
+
encoding="utf-8",
|
|
140
|
+
)
|
|
141
|
+
return extracted
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _uniquify_duplicate_atom_names(atoms: list) -> tuple[list, list[tuple[str, str]]]:
|
|
145
|
+
counts: dict[str, int] = {}
|
|
146
|
+
for atom in atoms:
|
|
147
|
+
counts[atom.name] = counts.get(atom.name, 0) + 1
|
|
148
|
+
duplicate_names = {name for name, count in counts.items() if count > 1}
|
|
149
|
+
if not duplicate_names:
|
|
150
|
+
return atoms, []
|
|
151
|
+
|
|
152
|
+
used = {atom.name for atom in atoms if atom.name not in duplicate_names}
|
|
153
|
+
element_counters: dict[str, int] = {}
|
|
154
|
+
renamed_atoms = []
|
|
155
|
+
rename_map: list[tuple[str, str]] = []
|
|
156
|
+
for atom in atoms:
|
|
157
|
+
if atom.name not in duplicate_names:
|
|
158
|
+
renamed_atoms.append(atom)
|
|
159
|
+
continue
|
|
160
|
+
element = (atom.element or infer_element(atom.name, resname=atom.resname, record_name=atom.record_name) or "X").upper()
|
|
161
|
+
new_name = _next_unique_ligand_atom_name(element, used, element_counters)
|
|
162
|
+
used.add(new_name)
|
|
163
|
+
renamed_atoms.append(replace(atom, name=new_name))
|
|
164
|
+
rename_map.append((atom.name, new_name))
|
|
165
|
+
return renamed_atoms, rename_map
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _next_unique_ligand_atom_name(
|
|
169
|
+
element: str,
|
|
170
|
+
used: set[str],
|
|
171
|
+
counters: dict[str, int],
|
|
172
|
+
) -> str:
|
|
173
|
+
symbol = "".join(char for char in element.upper() if char.isalnum()) or "X"
|
|
174
|
+
if len(symbol) > 2:
|
|
175
|
+
symbol = symbol[:2]
|
|
176
|
+
while True:
|
|
177
|
+
counters[symbol] = counters.get(symbol, 0) + 1
|
|
178
|
+
suffix = _base36(counters[symbol])
|
|
179
|
+
candidate = f"{symbol}{suffix}"
|
|
180
|
+
if len(candidate) > 4:
|
|
181
|
+
candidate = f"{symbol[:1]}{suffix}"[-4:]
|
|
182
|
+
if candidate not in used:
|
|
183
|
+
return candidate
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _base36(value: int) -> str:
|
|
187
|
+
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
188
|
+
if value <= 0:
|
|
189
|
+
return "0"
|
|
190
|
+
digits = []
|
|
191
|
+
current = value
|
|
192
|
+
while current:
|
|
193
|
+
current, remainder = divmod(current, 36)
|
|
194
|
+
digits.append(alphabet[remainder])
|
|
195
|
+
return "".join(reversed(digits))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _requires_pdb_chemistry_perception(ligand: LigandConfig) -> bool:
|
|
199
|
+
if ligand.charge_method in {"am1bcc", "mcpb_resp_pyscf"}:
|
|
200
|
+
return True
|
|
201
|
+
if ligand.charge_method in {"gas_resp_pyscf", "qmmesp_pyscf"}:
|
|
202
|
+
return ligand.user_mol2 is None
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
_FORMULA_TOKEN = re.compile(r"([A-Z][a-z]?)([0-9]*)")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _validate_ligand_formula(
|
|
210
|
+
ligand: LigandConfig,
|
|
211
|
+
residue: ResidueRecord,
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
"""Require explicit ligand hydrogens and validate an optional exact formula.
|
|
214
|
+
|
|
215
|
+
PDB coordinates do not encode bond orders or protonation. A molecule that
|
|
216
|
+
contains no hydrogen is therefore rejected unless the user explicitly
|
|
217
|
+
declares a hydrogen-free expected formula. An exact formula is the only
|
|
218
|
+
reliable way to detect a partially hydrogenated ligand, so production
|
|
219
|
+
examples should always set expected_formula.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
observed = Counter((atom.element or "").strip().capitalize() for atom in residue.atoms)
|
|
223
|
+
observed.pop("", None)
|
|
224
|
+
expected = _parse_formula(ligand.expected_formula) if ligand.expected_formula else None
|
|
225
|
+
|
|
226
|
+
if observed.get("H", 0) == 0 and (expected is None or expected.get("H", 0) > 0):
|
|
227
|
+
warning = (
|
|
228
|
+
f"WARNING [LIGAND_HYDROGENS_MISSING] Ligand {ligand.id} "
|
|
229
|
+
f"({residue.id.chain_id}:{residue.id.resname}:{residue.id.resid}) contains no "
|
|
230
|
+
"explicit hydrogen atoms."
|
|
231
|
+
)
|
|
232
|
+
error = (
|
|
233
|
+
f"ERROR [LIGAND_CHEMISTRY_UNRESOLVED] Ligand {ligand.id} cannot be sent to "
|
|
234
|
+
"AM1-BCC or QM charge derivation without an explicit, validated protonation "
|
|
235
|
+
"state. Add ligand hydrogens before running mdprep and set expected_formula; "
|
|
236
|
+
"mdprep will not guess ligand protonation."
|
|
237
|
+
)
|
|
238
|
+
raise LigandExtractionError(f"{warning}\n{error}")
|
|
239
|
+
|
|
240
|
+
warnings: list[str] = []
|
|
241
|
+
if expected is None:
|
|
242
|
+
warnings.append(
|
|
243
|
+
f"WARNING [LIGAND_FORMULA_UNVERIFIED] Ligand {ligand.id} contains explicit "
|
|
244
|
+
"hydrogens, but expected_formula was not supplied; mdprep cannot prove that "
|
|
245
|
+
"the ligand is completely protonated."
|
|
246
|
+
)
|
|
247
|
+
return warnings
|
|
248
|
+
|
|
249
|
+
if observed == expected:
|
|
250
|
+
return warnings
|
|
251
|
+
|
|
252
|
+
observed_heavy = Counter({key: value for key, value in observed.items() if key != "H"})
|
|
253
|
+
expected_heavy = Counter({key: value for key, value in expected.items() if key != "H"})
|
|
254
|
+
if observed_heavy == expected_heavy and observed.get("H", 0) < expected.get("H", 0):
|
|
255
|
+
missing = expected.get("H", 0) - observed.get("H", 0)
|
|
256
|
+
warning = (
|
|
257
|
+
f"WARNING [LIGAND_HYDROGENS_MISSING] Ligand {ligand.id} has "
|
|
258
|
+
f"{observed.get('H', 0)} explicit H atom(s), but expected_formula "
|
|
259
|
+
f"{ligand.expected_formula} requires {expected.get('H', 0)}; {missing} hydrogen "
|
|
260
|
+
"atom(s) are missing."
|
|
261
|
+
)
|
|
262
|
+
error = (
|
|
263
|
+
f"ERROR [LIGAND_CHEMISTRY_UNRESOLVED] Ligand {ligand.id} is only partially "
|
|
264
|
+
"hydrogenated. Add the missing ligand hydrogens before running mdprep; "
|
|
265
|
+
"automatic ligand protonation is intentionally unsupported."
|
|
266
|
+
)
|
|
267
|
+
raise LigandExtractionError(f"{warning}\n{error}")
|
|
268
|
+
|
|
269
|
+
raise LigandExtractionError(
|
|
270
|
+
f"ERROR [LIGAND_FORMULA_MISMATCH] Ligand {ligand.id} has observed formula "
|
|
271
|
+
f"{_format_formula(observed)}, but expected_formula is {ligand.expected_formula}. "
|
|
272
|
+
"Correct the input coordinates or the manifest; mdprep will not alter ligand "
|
|
273
|
+
"composition silently."
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_formula(formula: str) -> Counter[str]:
|
|
278
|
+
counts: Counter[str] = Counter()
|
|
279
|
+
position = 0
|
|
280
|
+
for match in _FORMULA_TOKEN.finditer(formula):
|
|
281
|
+
if match.start() != position:
|
|
282
|
+
raise LigandExtractionError(f"Invalid expected_formula: {formula}")
|
|
283
|
+
element, count_text = match.groups()
|
|
284
|
+
if element in counts:
|
|
285
|
+
raise LigandExtractionError(
|
|
286
|
+
f"Invalid expected_formula {formula}: element {element} appears more than once"
|
|
287
|
+
)
|
|
288
|
+
count = int(count_text) if count_text else 1
|
|
289
|
+
if count <= 0:
|
|
290
|
+
raise LigandExtractionError(
|
|
291
|
+
f"Invalid expected_formula {formula}: element counts must be positive"
|
|
292
|
+
)
|
|
293
|
+
counts[element] = count
|
|
294
|
+
position = match.end()
|
|
295
|
+
if position != len(formula) or not counts:
|
|
296
|
+
raise LigandExtractionError(f"Invalid expected_formula: {formula}")
|
|
297
|
+
return counts
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _format_formula(counts: Counter[str]) -> str:
|
|
301
|
+
ordered = []
|
|
302
|
+
for element in ("C", "H"):
|
|
303
|
+
if element in counts:
|
|
304
|
+
ordered.append(element + (str(counts[element]) if counts[element] != 1 else ""))
|
|
305
|
+
for element in sorted(key for key in counts if key not in {"C", "H"}):
|
|
306
|
+
ordered.append(element + (str(counts[element]) if counts[element] != 1 else ""))
|
|
307
|
+
return "".join(ordered)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _ligand_conect_lines(input_path: Path, atoms: list) -> list[str]:
|
|
311
|
+
serials = {atom.serial for atom in atoms if atom.serial is not None}
|
|
312
|
+
if not serials or not input_path.exists():
|
|
313
|
+
return []
|
|
314
|
+
|
|
315
|
+
lines: list[str] = []
|
|
316
|
+
seen: set[str] = set()
|
|
317
|
+
for line in input_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
318
|
+
if not line.startswith("CONECT"):
|
|
319
|
+
continue
|
|
320
|
+
numbers = _parse_conect_numbers(line)
|
|
321
|
+
if not numbers:
|
|
322
|
+
continue
|
|
323
|
+
source, *targets = numbers
|
|
324
|
+
if source not in serials:
|
|
325
|
+
continue
|
|
326
|
+
selected_targets = [target for target in targets if target in serials]
|
|
327
|
+
for offset in range(0, len(selected_targets), 4):
|
|
328
|
+
conect = _format_conect(source, selected_targets[offset : offset + 4])
|
|
329
|
+
if conect not in seen:
|
|
330
|
+
seen.add(conect)
|
|
331
|
+
lines.append(conect)
|
|
332
|
+
return lines
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _parse_conect_numbers(line: str) -> list[int]:
|
|
336
|
+
numbers: list[int] = []
|
|
337
|
+
for field in line[6:].split():
|
|
338
|
+
try:
|
|
339
|
+
numbers.append(int(field))
|
|
340
|
+
except ValueError:
|
|
341
|
+
continue
|
|
342
|
+
return numbers
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _format_conect(source: int, targets: list[int]) -> str:
|
|
346
|
+
return "CONECT" + f"{source:5d}" + "".join(f"{target:5d}" for target in targets) + "\n"
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _insert_conect_before_end(path: Path, conect_lines: list[str]) -> None:
|
|
350
|
+
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
351
|
+
insert_at = len(lines)
|
|
352
|
+
if lines and lines[-1].strip() == "END":
|
|
353
|
+
insert_at = len(lines) - 1
|
|
354
|
+
lines[insert_at:insert_at] = conect_lines
|
|
355
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Ligand identity helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mdprep.structure.models import AtomRecord, ResidueRecord
|
|
6
|
+
from mdprep.structure.pdb import infer_element
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def ligand_identity_dict(
|
|
10
|
+
*,
|
|
11
|
+
ligand_id: str,
|
|
12
|
+
selector: dict[str, object],
|
|
13
|
+
residue: ResidueRecord,
|
|
14
|
+
net_charge: int,
|
|
15
|
+
multiplicity: int,
|
|
16
|
+
expected_formula: str | None,
|
|
17
|
+
charge_method: str,
|
|
18
|
+
atom_types: str,
|
|
19
|
+
) -> dict[str, object]:
|
|
20
|
+
return {
|
|
21
|
+
"ligand_id": ligand_id,
|
|
22
|
+
"selector": selector,
|
|
23
|
+
"atom_count": len(residue.atoms),
|
|
24
|
+
"atom_names": [atom.name for atom in residue.atoms],
|
|
25
|
+
"elements": [atom_element(atom) for atom in residue.atoms],
|
|
26
|
+
"residue_name": residue.id.resname,
|
|
27
|
+
"residue_number": residue.id.resid,
|
|
28
|
+
"chain_id": residue.id.chain_id,
|
|
29
|
+
"icode": residue.id.icode,
|
|
30
|
+
"net_charge": net_charge,
|
|
31
|
+
"multiplicity": multiplicity,
|
|
32
|
+
"expected_formula": expected_formula,
|
|
33
|
+
"charge_method": charge_method,
|
|
34
|
+
"atom_types": atom_types,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def atom_element(atom: AtomRecord) -> str:
|
|
39
|
+
return atom.element or infer_element(atom.name) or ""
|