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,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
+