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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""PropKa execution workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mdprep.config.models import ManifestConfig
|
|
10
|
+
from mdprep.external.discovery import which_executable
|
|
11
|
+
from mdprep.external.runner import CommandResult, run_command
|
|
12
|
+
from mdprep.protonation.propka_parser import PropkaRecord, parse_propka_file
|
|
13
|
+
from mdprep.structure.models import AtomRecord, PdbStructure, ResidueId, ResidueRecord
|
|
14
|
+
from mdprep.structure.writer import write_pdb
|
|
15
|
+
from dataclasses import replace
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PropkaExecutionError(RuntimeError):
|
|
19
|
+
"""Raised when PropKa execution fails or produces no pKa file."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class PropkaWorkflowResult:
|
|
24
|
+
executable: str
|
|
25
|
+
command: tuple[str, ...]
|
|
26
|
+
cwd: str
|
|
27
|
+
returncode: int
|
|
28
|
+
runtime_seconds: float
|
|
29
|
+
stdout_path: Path
|
|
30
|
+
stderr_path: Path
|
|
31
|
+
output_pka_path: Path
|
|
32
|
+
parsed_pkas_path: Path
|
|
33
|
+
records: list[PropkaRecord]
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, object]:
|
|
36
|
+
return {
|
|
37
|
+
"executable": self.executable,
|
|
38
|
+
"command": list(self.command),
|
|
39
|
+
"cwd": self.cwd,
|
|
40
|
+
"returncode": self.returncode,
|
|
41
|
+
"runtime_seconds": self.runtime_seconds,
|
|
42
|
+
"stdout_path": str(self.stdout_path),
|
|
43
|
+
"stderr_path": str(self.stderr_path),
|
|
44
|
+
"output_pka_path": str(self.output_pka_path),
|
|
45
|
+
"parsed_pkas_path": str(self.parsed_pkas_path),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run_propka_workflow(
|
|
50
|
+
structure: PdbStructure,
|
|
51
|
+
manifest: ManifestConfig,
|
|
52
|
+
*,
|
|
53
|
+
work_dir: str | Path,
|
|
54
|
+
) -> PropkaWorkflowResult:
|
|
55
|
+
propka_dir = Path(work_dir)
|
|
56
|
+
propka_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
executable = resolve_propka_executable(manifest)
|
|
58
|
+
input_pdb = propka_dir / "propka_input.pdb"
|
|
59
|
+
propka_structure = _canonicalized_structure_for_propka(structure, input_pdb)
|
|
60
|
+
write_pdb(propka_structure, input_pdb)
|
|
61
|
+
|
|
62
|
+
command = [executable, input_pdb.name, *manifest.protonation.propka.extra_args]
|
|
63
|
+
result = run_command(command, cwd=propka_dir)
|
|
64
|
+
stdout_path = propka_dir / "propka_stdout.txt"
|
|
65
|
+
stderr_path = propka_dir / "propka_stderr.txt"
|
|
66
|
+
stdout_path.write_text(result.stdout, encoding="utf-8")
|
|
67
|
+
stderr_path.write_text(result.stderr, encoding="utf-8")
|
|
68
|
+
if manifest.protonation.propka.require_success and result.returncode != 0:
|
|
69
|
+
raise PropkaExecutionError(
|
|
70
|
+
f"PropKa failed with exit code {result.returncode}. See {stdout_path} and {stderr_path}."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
pka_path = _locate_pka_file(propka_dir)
|
|
74
|
+
output_pka_path = propka_dir / "propka_output.pka"
|
|
75
|
+
output_pka_path.write_text(pka_path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
76
|
+
records = parse_propka_file(output_pka_path)
|
|
77
|
+
parsed_path = propka_dir / "parsed_pkas.csv"
|
|
78
|
+
_write_parsed_csv(records, parsed_path)
|
|
79
|
+
return PropkaWorkflowResult(
|
|
80
|
+
executable=executable,
|
|
81
|
+
command=result.command,
|
|
82
|
+
cwd=result.cwd,
|
|
83
|
+
returncode=result.returncode,
|
|
84
|
+
runtime_seconds=result.runtime_seconds,
|
|
85
|
+
stdout_path=stdout_path,
|
|
86
|
+
stderr_path=stderr_path,
|
|
87
|
+
output_pka_path=output_pka_path,
|
|
88
|
+
parsed_pkas_path=parsed_path,
|
|
89
|
+
records=records,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_propka_executable(manifest: ManifestConfig) -> str:
|
|
94
|
+
configured = manifest.protonation.propka.executable
|
|
95
|
+
if configured:
|
|
96
|
+
if Path(configured).exists():
|
|
97
|
+
return configured
|
|
98
|
+
found = which_executable(configured)
|
|
99
|
+
if found:
|
|
100
|
+
return found
|
|
101
|
+
raise PropkaExecutionError(f"Configured PropKa executable not found: {configured}")
|
|
102
|
+
for name in manifest.protonation.propka.fallback_executables:
|
|
103
|
+
found = which_executable(name)
|
|
104
|
+
if found:
|
|
105
|
+
return found
|
|
106
|
+
searched = ", ".join(manifest.protonation.propka.fallback_executables)
|
|
107
|
+
raise PropkaExecutionError(f"PropKa executable not found. Searched: {searched}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _canonicalized_structure_for_propka(structure: PdbStructure, path: Path) -> PdbStructure:
|
|
111
|
+
atoms = [replace(atom, resname=_canonical_resname(atom.resname)) for atom in structure.atoms]
|
|
112
|
+
residues: list[ResidueRecord] = []
|
|
113
|
+
current_key: tuple[str, str, int, str | None] | None = None
|
|
114
|
+
current_atoms: list[AtomRecord] = []
|
|
115
|
+
for atom in atoms:
|
|
116
|
+
key = atom.residue_key
|
|
117
|
+
if current_key is not None and key != current_key:
|
|
118
|
+
residues.append(_residue_from_atoms(current_key, current_atoms, len(residues)))
|
|
119
|
+
current_atoms = []
|
|
120
|
+
current_key = key
|
|
121
|
+
current_atoms.append(atom)
|
|
122
|
+
if current_key is not None:
|
|
123
|
+
residues.append(_residue_from_atoms(current_key, current_atoms, len(residues)))
|
|
124
|
+
return PdbStructure(
|
|
125
|
+
path=path,
|
|
126
|
+
atoms=atoms,
|
|
127
|
+
residues=residues,
|
|
128
|
+
model_count=structure.model_count,
|
|
129
|
+
used_model=structure.used_model,
|
|
130
|
+
warnings=list(structure.warnings),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _residue_from_atoms(
|
|
135
|
+
key: tuple[str, str, int, str | None],
|
|
136
|
+
atoms: list[AtomRecord],
|
|
137
|
+
index: int,
|
|
138
|
+
) -> ResidueRecord:
|
|
139
|
+
chain_id, resname, resid, icode = key
|
|
140
|
+
return ResidueRecord(
|
|
141
|
+
id=ResidueId(chain_id=chain_id, resname=resname, resid=resid, icode=icode),
|
|
142
|
+
atoms=atoms,
|
|
143
|
+
record_names={atom.record_name for atom in atoms},
|
|
144
|
+
original_index=index,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _canonical_resname(resname: str) -> str:
|
|
149
|
+
return {
|
|
150
|
+
"ASH": "ASP",
|
|
151
|
+
"GLH": "GLU",
|
|
152
|
+
"HID": "HIS",
|
|
153
|
+
"HIE": "HIS",
|
|
154
|
+
"HIP": "HIS",
|
|
155
|
+
"CYM": "CYS",
|
|
156
|
+
"CYX": "CYS",
|
|
157
|
+
"LYN": "LYS",
|
|
158
|
+
}.get(resname, resname)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _locate_pka_file(propka_dir: Path) -> Path:
|
|
162
|
+
candidates = sorted(path for path in propka_dir.glob("*.pka") if path.name != "propka_output.pka")
|
|
163
|
+
if not candidates:
|
|
164
|
+
raise PropkaExecutionError(f"PropKa did not produce a .pka file in {propka_dir}")
|
|
165
|
+
return candidates[0]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _write_parsed_csv(records: list[PropkaRecord], path: Path) -> None:
|
|
169
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
170
|
+
writer = csv.DictWriter(handle, fieldnames=["resname", "resid", "chain_id", "pka", "raw_line"])
|
|
171
|
+
writer.writeheader()
|
|
172
|
+
for record in records:
|
|
173
|
+
writer.writerow(record.to_dict())
|
|
174
|
+
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""PropKa output parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from mdprep.structure.models import PdbStructure, ResidueRecord
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PropkaParseError(ValueError):
|
|
13
|
+
"""Raised when parsed PropKa records cannot be mapped safely."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
_PKA_LINE = re.compile(
|
|
17
|
+
r"^\s*(?P<resname>ASP|GLU|HIS|LYS|CYS|ARG)\s+"
|
|
18
|
+
r"(?P<resid>-?\d+)\s+"
|
|
19
|
+
r"(?P<chain>\S)\s+"
|
|
20
|
+
r"(?P<pka>-?\d+(?:\.\d+)?)\b"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PropkaRecord:
|
|
26
|
+
resname: str
|
|
27
|
+
resid: int
|
|
28
|
+
chain_id: str
|
|
29
|
+
pka: float
|
|
30
|
+
raw_line: str
|
|
31
|
+
|
|
32
|
+
def to_dict(self) -> dict[str, object]:
|
|
33
|
+
return {
|
|
34
|
+
"resname": self.resname,
|
|
35
|
+
"resid": self.resid,
|
|
36
|
+
"chain_id": self.chain_id,
|
|
37
|
+
"pka": self.pka,
|
|
38
|
+
"raw_line": self.raw_line,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_propka_file(path: str | Path) -> list[PropkaRecord]:
|
|
43
|
+
lines = Path(path).read_text(encoding="utf-8").splitlines()
|
|
44
|
+
summary_records = _parse_summary_records(lines)
|
|
45
|
+
if summary_records:
|
|
46
|
+
return summary_records
|
|
47
|
+
records: list[PropkaRecord] = []
|
|
48
|
+
for line in lines:
|
|
49
|
+
record = parse_propka_line(line)
|
|
50
|
+
if record is not None:
|
|
51
|
+
records.append(record)
|
|
52
|
+
return records
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def parse_propka_line(line: str) -> PropkaRecord | None:
|
|
56
|
+
match = _PKA_LINE.match(line)
|
|
57
|
+
if match is None:
|
|
58
|
+
return None
|
|
59
|
+
chain_id = match.group("chain")
|
|
60
|
+
if chain_id in {"-", "_"}:
|
|
61
|
+
chain_id = ""
|
|
62
|
+
return PropkaRecord(
|
|
63
|
+
resname=match.group("resname"),
|
|
64
|
+
resid=int(match.group("resid")),
|
|
65
|
+
chain_id=chain_id,
|
|
66
|
+
pka=float(match.group("pka")),
|
|
67
|
+
raw_line=line,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse_summary_records(lines: list[str]) -> list[PropkaRecord]:
|
|
72
|
+
in_summary = False
|
|
73
|
+
records: list[PropkaRecord] = []
|
|
74
|
+
for line in lines:
|
|
75
|
+
if "SUMMARY OF THIS PREDICTION" in line:
|
|
76
|
+
in_summary = True
|
|
77
|
+
continue
|
|
78
|
+
if in_summary and line.startswith("----") and records:
|
|
79
|
+
break
|
|
80
|
+
if not in_summary:
|
|
81
|
+
continue
|
|
82
|
+
record = parse_propka_line(line)
|
|
83
|
+
if record is not None:
|
|
84
|
+
records.append(record)
|
|
85
|
+
return records
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def map_propka_records(
|
|
89
|
+
structure: PdbStructure,
|
|
90
|
+
records: list[PropkaRecord],
|
|
91
|
+
) -> dict[int, PropkaRecord]:
|
|
92
|
+
mapped: dict[int, PropkaRecord] = {}
|
|
93
|
+
for record in records:
|
|
94
|
+
matches = [
|
|
95
|
+
residue
|
|
96
|
+
for residue in structure.residues
|
|
97
|
+
if residue.id.chain_id == record.chain_id
|
|
98
|
+
and residue.id.resid == record.resid
|
|
99
|
+
and _propka_family_name(residue.id.resname) == record.resname
|
|
100
|
+
]
|
|
101
|
+
if len(matches) > 1:
|
|
102
|
+
formatted = ", ".join(_format_residue(residue) for residue in matches)
|
|
103
|
+
raise PropkaParseError(
|
|
104
|
+
f"PropKa record maps ambiguously to mdprep residues: {record.raw_line!r}. "
|
|
105
|
+
f"Matches: {formatted}. Use a manual override for residues with insertion codes."
|
|
106
|
+
)
|
|
107
|
+
if len(matches) == 0:
|
|
108
|
+
continue
|
|
109
|
+
residue = matches[0]
|
|
110
|
+
key = id(residue)
|
|
111
|
+
if key in mapped:
|
|
112
|
+
raise PropkaParseError(
|
|
113
|
+
f"Multiple PropKa records map to residue {_format_residue(residue)}."
|
|
114
|
+
)
|
|
115
|
+
mapped[key] = record
|
|
116
|
+
return mapped
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _propka_family_name(resname: str) -> str | None:
|
|
120
|
+
return {
|
|
121
|
+
"ASP": "ASP",
|
|
122
|
+
"ASH": "ASP",
|
|
123
|
+
"GLU": "GLU",
|
|
124
|
+
"GLH": "GLU",
|
|
125
|
+
"HIS": "HIS",
|
|
126
|
+
"HID": "HIS",
|
|
127
|
+
"HIE": "HIS",
|
|
128
|
+
"HIP": "HIS",
|
|
129
|
+
"LYS": "LYS",
|
|
130
|
+
"LYN": "LYS",
|
|
131
|
+
"CYS": "CYS",
|
|
132
|
+
"CYM": "CYS",
|
|
133
|
+
"CYX": "CYS",
|
|
134
|
+
"ARG": "ARG",
|
|
135
|
+
}.get(resname)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _format_residue(residue: ResidueRecord) -> str:
|
|
139
|
+
chain = residue.id.chain_id if residue.id.chain_id else "<blank>"
|
|
140
|
+
return f"{chain}:{residue.id.resname}{residue.id.resid}{residue.id.icode or ''}"
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Protonation-stage report writers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from mdprep.protonation.apply import ProtonationRecord, ProtonationResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CSV_COLUMNS = [
|
|
14
|
+
"chain",
|
|
15
|
+
"resid",
|
|
16
|
+
"icode",
|
|
17
|
+
"original_resname",
|
|
18
|
+
"final_resname",
|
|
19
|
+
"source",
|
|
20
|
+
"pka",
|
|
21
|
+
"ph",
|
|
22
|
+
"reason",
|
|
23
|
+
"changed",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def write_protonation_reports(
|
|
28
|
+
result: ProtonationResult,
|
|
29
|
+
*,
|
|
30
|
+
json_path: str | Path,
|
|
31
|
+
csv_path: str | Path,
|
|
32
|
+
markdown_path: str | Path,
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
report = result.to_report_dict()
|
|
35
|
+
json_output = Path(json_path)
|
|
36
|
+
csv_output = Path(csv_path)
|
|
37
|
+
markdown_output = Path(markdown_path)
|
|
38
|
+
json_output.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
csv_output.parent.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
json_output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
42
|
+
_write_csv(result.records, csv_output)
|
|
43
|
+
markdown_output.write_text(_render_markdown(report), encoding="utf-8")
|
|
44
|
+
return report
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _write_csv(records: list[ProtonationRecord], path: Path) -> None:
|
|
48
|
+
with path.open("w", encoding="utf-8", newline="") as handle:
|
|
49
|
+
writer = csv.DictWriter(handle, fieldnames=CSV_COLUMNS)
|
|
50
|
+
writer.writeheader()
|
|
51
|
+
for record in records:
|
|
52
|
+
data = record.to_dict()
|
|
53
|
+
writer.writerow({column: data[column] for column in CSV_COLUMNS})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _render_markdown(report: dict[str, Any]) -> str:
|
|
57
|
+
lines = [
|
|
58
|
+
"# Protonation Report",
|
|
59
|
+
"",
|
|
60
|
+
"## Summary",
|
|
61
|
+
"",
|
|
62
|
+
f"- Method: `{report['method']}`",
|
|
63
|
+
f"- pH: {report['ph']}",
|
|
64
|
+
f"- Hydrogens removed: {report['hydrogen_atoms_removed']}",
|
|
65
|
+
"- Reused initial assignments after refinement: "
|
|
66
|
+
f"{report['reused_initial_assignments_after_refinement']}",
|
|
67
|
+
"- Refined hydrogens preserved: "
|
|
68
|
+
f"{report['preserved_refined_hydrogen_count']}",
|
|
69
|
+
f"- Residues changed: {len(report['residues_changed'])}",
|
|
70
|
+
f"- Explicit unchanged assignments: {len(report['residues_unchanged_but_explicitly_assigned'])}",
|
|
71
|
+
"",
|
|
72
|
+
"## Manual Overrides",
|
|
73
|
+
"",
|
|
74
|
+
]
|
|
75
|
+
lines.extend(_record_lines(report["manual_overrides_applied"]))
|
|
76
|
+
lines.extend(["", "## Metal-Coordination Assignments", ""])
|
|
77
|
+
lines.extend(_record_lines(report.get("metal_coordination_assignments_applied", [])))
|
|
78
|
+
lines.extend(["", "## Disulfide Assignments", ""])
|
|
79
|
+
lines.extend(_record_lines(report["disulfide_assignments_applied"]))
|
|
80
|
+
lines.extend(["", "## PropKa Assignments", ""])
|
|
81
|
+
lines.extend(_record_lines(report.get("propka_assignments_applied", [])))
|
|
82
|
+
lines.extend(["", "## Input States Preserved", ""])
|
|
83
|
+
lines.extend(_record_lines(report.get("input_state_assignments_applied", [])))
|
|
84
|
+
lines.extend(["", "## xTB Histidine Selections", ""])
|
|
85
|
+
lines.extend(_xtb_lines(report.get("xtb_histidines", [])))
|
|
86
|
+
lines.extend(["", "## Temporary Protein Hydrogenation For xTB", ""])
|
|
87
|
+
lines.extend(
|
|
88
|
+
_temporary_protein_hydrogenation_lines(
|
|
89
|
+
report.get("xtb_temporary_protein_hydrogenation")
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
lines.extend(["", "## Temporary Water Hydrogens For xTB Clusters", ""])
|
|
93
|
+
lines.extend(_temporary_water_hydrogen_lines(report.get("temporary_water_hydrogens_for_xtb_clusters", [])))
|
|
94
|
+
lines.extend(["", "## Hydrogens Removed", ""])
|
|
95
|
+
lines.append(f"- {report['hydrogen_atoms_removed']}")
|
|
96
|
+
lines.extend(["", "## Remaining HIS Residues", ""])
|
|
97
|
+
lines.extend(_residue_lines(report["unresolved_histidines_remaining_as_his"]))
|
|
98
|
+
lines.extend(["", "## Unassigned Titratable Residues", ""])
|
|
99
|
+
lines.extend(_residue_lines(report["titratable_residues_not_explicitly_assigned"]))
|
|
100
|
+
lines.extend(["", "## Warnings", ""])
|
|
101
|
+
if report["warnings"]:
|
|
102
|
+
lines.extend(f"- {warning}" for warning in report["warnings"])
|
|
103
|
+
else:
|
|
104
|
+
lines.append("- None")
|
|
105
|
+
lines.append("")
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _record_lines(records: list[dict[str, Any]]) -> list[str]:
|
|
110
|
+
if not records:
|
|
111
|
+
return ["- None"]
|
|
112
|
+
return [
|
|
113
|
+
f"- {_format_record(record)} via `{record['source']}`: {record['reason']}"
|
|
114
|
+
for record in records
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _residue_lines(residues: list[dict[str, Any]]) -> list[str]:
|
|
119
|
+
if not residues:
|
|
120
|
+
return ["- None"]
|
|
121
|
+
return [f"- {_format_residue(residue)}" for residue in residues]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _xtb_lines(selections: list[dict[str, Any]]) -> list[str]:
|
|
125
|
+
if not selections:
|
|
126
|
+
return ["- None"]
|
|
127
|
+
lines: list[str] = []
|
|
128
|
+
for selection in selections:
|
|
129
|
+
chain = selection["chain"] or "<blank>"
|
|
130
|
+
icode = selection["icode"] or ""
|
|
131
|
+
lines.append(
|
|
132
|
+
f"- {chain}:HIS{selection['resid']}{icode} -> {selection['selected_state']} "
|
|
133
|
+
f"(delta HID-HIE {selection['delta_kcal_mol']:.3f} kcal/mol, "
|
|
134
|
+
f"model `{selection['model']}`, mode `{selection['mode']}`)"
|
|
135
|
+
)
|
|
136
|
+
return lines
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _temporary_water_hydrogen_lines(clusters: list[dict[str, Any]]) -> list[str]:
|
|
140
|
+
if not clusters:
|
|
141
|
+
return ["- None"]
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
for cluster in clusters:
|
|
144
|
+
lines.append(
|
|
145
|
+
f"- {cluster['histidine']}: added {cluster['temporary_water_hydrogens_added']} "
|
|
146
|
+
"temporary water hydrogens for xTB tautomer ranking only; final PDB modified: "
|
|
147
|
+
f"{cluster['final_pdb_modified']}"
|
|
148
|
+
)
|
|
149
|
+
for water in cluster["waters_modified_for_xtb_only"]:
|
|
150
|
+
chain = water["chain"] or "<blank>"
|
|
151
|
+
icode = water["icode"] or ""
|
|
152
|
+
lines.append(
|
|
153
|
+
f" - {chain}:{water['resname']}{water['resid']}{icode}: "
|
|
154
|
+
f"{water['hydrogens_added']} added"
|
|
155
|
+
)
|
|
156
|
+
return lines
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _temporary_protein_hydrogenation_lines(
|
|
160
|
+
hydrogenation: dict[str, Any] | None,
|
|
161
|
+
) -> list[str]:
|
|
162
|
+
if hydrogenation is None:
|
|
163
|
+
return ["- Not required"]
|
|
164
|
+
return [
|
|
165
|
+
f"- Backend: `{hydrogenation['backend']}` "
|
|
166
|
+
f"({hydrogenation['backend_version'] or 'unknown version'})",
|
|
167
|
+
f"- Temporary hydrogens added: {hydrogenation['added_hydrogen_count']}",
|
|
168
|
+
"- Maximum displacement of an original atom: "
|
|
169
|
+
f"{hydrogenation['maximum_original_atom_displacement_angstrom']:.6f} A",
|
|
170
|
+
f"- Final prepared PDB modified: {hydrogenation['final_prepared_pdb_modified']}",
|
|
171
|
+
f"- Temporary hydrogenated environment: `{hydrogenation['restored_output_path']}`",
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _format_record(record: dict[str, Any]) -> str:
|
|
176
|
+
chain = record["chain"] or "<blank>"
|
|
177
|
+
icode = record["icode"] or ""
|
|
178
|
+
return (
|
|
179
|
+
f"{chain}:{record['original_resname']}{record['resid']}{icode} "
|
|
180
|
+
f"-> {record['final_resname']}"
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _format_residue(residue: dict[str, Any]) -> str:
|
|
185
|
+
chain = residue["chain_id"] or "<blank>"
|
|
186
|
+
icode = residue["icode"] or ""
|
|
187
|
+
return f"{chain}:{residue['resname']}{residue['resid']}{icode}"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Residue protonation-state family validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProtonationStateError(ValueError):
|
|
7
|
+
"""Raised for incompatible residue protonation state assignments."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
STATE_FAMILIES: dict[str, tuple[str, ...]] = {
|
|
11
|
+
"ASP": ("ASP", "ASH"),
|
|
12
|
+
"ASH": ("ASP", "ASH"),
|
|
13
|
+
"GLU": ("GLU", "GLH"),
|
|
14
|
+
"GLH": ("GLU", "GLH"),
|
|
15
|
+
"LYS": ("LYS", "LYN"),
|
|
16
|
+
"LYN": ("LYS", "LYN"),
|
|
17
|
+
"ARG": ("ARG",),
|
|
18
|
+
"HIS": ("HIS", "HID", "HIE", "HIP"),
|
|
19
|
+
"HID": ("HIS", "HID", "HIE", "HIP"),
|
|
20
|
+
"HIE": ("HIS", "HID", "HIE", "HIP"),
|
|
21
|
+
"HIP": ("HIS", "HID", "HIE", "HIP"),
|
|
22
|
+
"CYS": ("CYS", "CYM", "CYX"),
|
|
23
|
+
"CYM": ("CYS", "CYM", "CYX"),
|
|
24
|
+
"CYX": ("CYS", "CYM", "CYX"),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
KNOWN_STATES = frozenset(STATE_FAMILIES)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def allowed_states_for_resname(resname: str) -> tuple[str, ...]:
|
|
31
|
+
return STATE_FAMILIES.get(resname, ())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_state_transition(
|
|
35
|
+
*,
|
|
36
|
+
current_resname: str,
|
|
37
|
+
requested_state: str,
|
|
38
|
+
selector: str,
|
|
39
|
+
) -> None:
|
|
40
|
+
if requested_state not in KNOWN_STATES:
|
|
41
|
+
raise ProtonationStateError(
|
|
42
|
+
f"Unknown protonation state for selector {selector}: requested {requested_state}."
|
|
43
|
+
)
|
|
44
|
+
allowed = allowed_states_for_resname(current_resname)
|
|
45
|
+
if not allowed or requested_state not in allowed:
|
|
46
|
+
allowed_text = ", ".join(allowed) if allowed else "none"
|
|
47
|
+
raise ProtonationStateError(
|
|
48
|
+
f"Incompatible protonation override for selector {selector}: "
|
|
49
|
+
f"current residue {current_resname}, requested {requested_state}, "
|
|
50
|
+
f"allowed states: {allowed_text}."
|
|
51
|
+
)
|
|
52
|
+
|