pkasso 0.5.0__tar.gz

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 (52) hide show
  1. pkasso-0.5.0/LICENSE.md +21 -0
  2. pkasso-0.5.0/PKG-INFO +103 -0
  3. pkasso-0.5.0/README.md +64 -0
  4. pkasso-0.5.0/pkasso/__init__.py +15 -0
  5. pkasso-0.5.0/pkasso/api/__init__.py +1 -0
  6. pkasso-0.5.0/pkasso/api/app.py +47 -0
  7. pkasso-0.5.0/pkasso/api/chemistry.py +120 -0
  8. pkasso-0.5.0/pkasso/api/config.py +14 -0
  9. pkasso-0.5.0/pkasso/api/feedback.py +45 -0
  10. pkasso-0.5.0/pkasso/api/render.py +211 -0
  11. pkasso-0.5.0/pkasso/api/state.py +41 -0
  12. pkasso-0.5.0/pkasso/api/static/app.css +318 -0
  13. pkasso-0.5.0/pkasso/api/static/app.js +168 -0
  14. pkasso-0.5.0/pkasso/api/static/bind-logo-single-sq.svg +1 -0
  15. pkasso-0.5.0/pkasso/api/static/bind-rounded_nobg.svg +1 -0
  16. pkasso-0.5.0/pkasso/api/static/favicon.ico +0 -0
  17. pkasso-0.5.0/pkasso/api/static/styles.css +1879 -0
  18. pkasso-0.5.0/pkasso/api/templates/page.html +145 -0
  19. pkasso-0.5.0/pkasso/api/web.py +205 -0
  20. pkasso-0.5.0/pkasso/cli.py +325 -0
  21. pkasso-0.5.0/pkasso/coupling.py +272 -0
  22. pkasso-0.5.0/pkasso/data/smarts_pattern.tsv +144 -0
  23. pkasso-0.5.0/pkasso/data/weight_acid.pth +0 -0
  24. pkasso-0.5.0/pkasso/data/weight_base.pth +0 -0
  25. pkasso-0.5.0/pkasso/external/__init__.py +0 -0
  26. pkasso-0.5.0/pkasso/external/descriptor.py +194 -0
  27. pkasso-0.5.0/pkasso/external/gcn_conv.py +134 -0
  28. pkasso-0.5.0/pkasso/external/inits.py +14 -0
  29. pkasso-0.5.0/pkasso/external/ionization_group.py +119 -0
  30. pkasso-0.5.0/pkasso/external/net.py +62 -0
  31. pkasso-0.5.0/pkasso/external/pka.py +63 -0
  32. pkasso-0.5.0/pkasso/external/scatter.py +39 -0
  33. pkasso-0.5.0/pkasso/main.py +1511 -0
  34. pkasso-0.5.0/pkasso/postprocess.py +320 -0
  35. pkasso-0.5.0/pkasso/predict_pka.py +355 -0
  36. pkasso-0.5.0/pkasso/py_interface.py +103 -0
  37. pkasso-0.5.0/pkasso/special_cases.py +295 -0
  38. pkasso-0.5.0/pkasso/tautomers.py +179 -0
  39. pkasso-0.5.0/pkasso/transitions.py +431 -0
  40. pkasso-0.5.0/pkasso/utils.py +107 -0
  41. pkasso-0.5.0/pkasso.egg-info/PKG-INFO +103 -0
  42. pkasso-0.5.0/pkasso.egg-info/SOURCES.txt +50 -0
  43. pkasso-0.5.0/pkasso.egg-info/dependency_links.txt +1 -0
  44. pkasso-0.5.0/pkasso.egg-info/entry_points.txt +3 -0
  45. pkasso-0.5.0/pkasso.egg-info/requires.txt +26 -0
  46. pkasso-0.5.0/pkasso.egg-info/top_level.txt +1 -0
  47. pkasso-0.5.0/pyproject.toml +92 -0
  48. pkasso-0.5.0/setup.cfg +4 -0
  49. pkasso-0.5.0/tests/test_cli.py +70 -0
  50. pkasso-0.5.0/tests/test_coupling.py +154 -0
  51. pkasso-0.5.0/tests/test_main.py +198 -0
  52. pkasso-0.5.0/tests/test_tautomers.py +209 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-Present Bind Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pkasso-0.5.0/PKG-INFO ADDED
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: pkasso
3
+ Version: 0.5.0
4
+ Summary: Prediction of protonation states for small molecules
5
+ Author: Bind Research
6
+ Maintainer-email: Soeren von Buelow <soeren.buelow@bindresearch.org>, Thomas Loehr <thomas@bindresearch.org>
7
+ License: MIT
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: <3.14,>=3.10
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE.md
14
+ Requires-Dist: rdkit>=2026.03.02
15
+ Requires-Dist: numpy<3,>=1.26
16
+ Requires-Dist: scipy<2,>=1.11
17
+ Requires-Dist: pyyaml<7,>=6
18
+ Requires-Dist: svgutils<1,>=0.3
19
+ Requires-Dist: matplotlib<4,>=3.8
20
+ Requires-Dist: torch<3,>=2.2
21
+ Requires-Dist: torch-geometric<3,>=2.5
22
+ Requires-Dist: pandas<3,>=2.1
23
+ Requires-Dist: tqdm<5,>=4.66
24
+ Requires-Dist: click<9,>=8.1
25
+ Requires-Dist: networkx<4,>=3.1
26
+ Requires-Dist: ipython<9,>=8.39
27
+ Provides-Extra: webserver
28
+ Requires-Dist: fastapi<1,>=0.115; extra == "webserver"
29
+ Requires-Dist: uvicorn[standard]; extra == "webserver"
30
+ Requires-Dist: jinja2; extra == "webserver"
31
+ Requires-Dist: python-multipart; extra == "webserver"
32
+ Requires-Dist: itsdangerous; extra == "webserver"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest<9,>=7; extra == "dev"
35
+ Requires-Dist: pytest-cov<8,>=4; extra == "dev"
36
+ Requires-Dist: mypy<2,>=1.10; extra == "dev"
37
+ Requires-Dist: ruff<1,>=0.4; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # pKasso
41
+
42
+ pKasso determines protonation states for small molecules based on the pKa predictor MolGpKa (https://github.com/Xundrug/MolGpKa).
43
+
44
+ One protonation microstate describes a unique charge pattern on the protonable sites of molecules. pKasso enumerates protonation microstates, screens pKa values between connected microstates, and predicts pH-dependent microstate frequencies based on the graph of free energy differences between microstates.
45
+
46
+ The easiest way to run pKasso is via the **webserver**: [ADD LINK TO WEBSERVER]
47
+
48
+ # Installation
49
+
50
+ ```
51
+ # Create conda environment
52
+ conda create -n pkasso python=3.12
53
+ conda activate pkasso
54
+
55
+ # Install pkasso from PyPI
56
+ pip install pkasso
57
+ ```
58
+
59
+ # Command line interface
60
+
61
+ The command line interface is called via `pkasso`.
62
+
63
+ 1) `pkasso single`: Calculate single pH-dependent microstate probabilities given a SMILES string
64
+ 2) `pkasso batch`: Batch process a .smi file to calculate pH-dependent microstates
65
+ 3) `pkasso scan`: Scan a pH range and plot the microstate distributions for all pH values (for a single molecule); calculate macro-pKa values.
66
+
67
+ ```
68
+ pkasso --smiles 'OC(=O)C(c1ccc(O)cc1)CNCCN'
69
+ # equivalent to
70
+ # pkasso single --smiles 'OC(=O)C(c1ccc(O)cc1)CNCCN'
71
+ ```
72
+
73
+ Get help for different pKasso options (single prediction, batch prediction, pH scan) with
74
+ ```
75
+ pkasso --help
76
+ pkasso single --help
77
+ pkasso batch --help
78
+ pkasso scan --help
79
+ ```
80
+
81
+ # Python interface (e.g. in a notebook)
82
+
83
+ ```
84
+ from pkasso import protonate
85
+
86
+ name = 'mymolecule'
87
+ smiles = r'OC(=O)C(c1ccc(O)cc1)CNCCN'
88
+ pH = 7.0
89
+
90
+ # Include microstates with probability of 20% compared to most probable microstate
91
+ # Select cutoff_export = 1. to only output the most likely microstate
92
+ cutoff_export = 0.2
93
+
94
+ # protonate accepts a smiles string or an rdkit Mol as input
95
+ smiles_out, mols_out = protonate(smiles, name=name, pH=pH, cutoff_export=cutoff_export)
96
+ print(smiles_out)
97
+ ```
98
+
99
+ For more examples, see the jupyter notebook in `example/example.ipynb`.
100
+
101
+ # Local webserver
102
+
103
+ A local webserver can be hosted via `pip install pkasso[webserver]` followed by calling `pkasso-web` or by downloading and running the docker image [ADD LINK].
pkasso-0.5.0/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # pKasso
2
+
3
+ pKasso determines protonation states for small molecules based on the pKa predictor MolGpKa (https://github.com/Xundrug/MolGpKa).
4
+
5
+ One protonation microstate describes a unique charge pattern on the protonable sites of molecules. pKasso enumerates protonation microstates, screens pKa values between connected microstates, and predicts pH-dependent microstate frequencies based on the graph of free energy differences between microstates.
6
+
7
+ The easiest way to run pKasso is via the **webserver**: [ADD LINK TO WEBSERVER]
8
+
9
+ # Installation
10
+
11
+ ```
12
+ # Create conda environment
13
+ conda create -n pkasso python=3.12
14
+ conda activate pkasso
15
+
16
+ # Install pkasso from PyPI
17
+ pip install pkasso
18
+ ```
19
+
20
+ # Command line interface
21
+
22
+ The command line interface is called via `pkasso`.
23
+
24
+ 1) `pkasso single`: Calculate single pH-dependent microstate probabilities given a SMILES string
25
+ 2) `pkasso batch`: Batch process a .smi file to calculate pH-dependent microstates
26
+ 3) `pkasso scan`: Scan a pH range and plot the microstate distributions for all pH values (for a single molecule); calculate macro-pKa values.
27
+
28
+ ```
29
+ pkasso --smiles 'OC(=O)C(c1ccc(O)cc1)CNCCN'
30
+ # equivalent to
31
+ # pkasso single --smiles 'OC(=O)C(c1ccc(O)cc1)CNCCN'
32
+ ```
33
+
34
+ Get help for different pKasso options (single prediction, batch prediction, pH scan) with
35
+ ```
36
+ pkasso --help
37
+ pkasso single --help
38
+ pkasso batch --help
39
+ pkasso scan --help
40
+ ```
41
+
42
+ # Python interface (e.g. in a notebook)
43
+
44
+ ```
45
+ from pkasso import protonate
46
+
47
+ name = 'mymolecule'
48
+ smiles = r'OC(=O)C(c1ccc(O)cc1)CNCCN'
49
+ pH = 7.0
50
+
51
+ # Include microstates with probability of 20% compared to most probable microstate
52
+ # Select cutoff_export = 1. to only output the most likely microstate
53
+ cutoff_export = 0.2
54
+
55
+ # protonate accepts a smiles string or an rdkit Mol as input
56
+ smiles_out, mols_out = protonate(smiles, name=name, pH=pH, cutoff_export=cutoff_export)
57
+ print(smiles_out)
58
+ ```
59
+
60
+ For more examples, see the jupyter notebook in `example/example.ipynb`.
61
+
62
+ # Local webserver
63
+
64
+ A local webserver can be hosted via `pip install pkasso[webserver]` followed by calling `pkasso-web` or by downloading and running the docker image [ADD LINK].
@@ -0,0 +1,15 @@
1
+ """Public package exports for pKasso."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ __all__ = ["batch_protonate", "protonate", "scan_pH"]
8
+
9
+
10
+ def __getattr__(name: str) -> Any:
11
+ if name in __all__:
12
+ from pkasso import py_interface
13
+
14
+ return getattr(py_interface, name)
15
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1 @@
1
+ """pKasso web application."""
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import importlib.util
5
+ import textwrap
6
+
7
+ WEB_DEPENDENCIES = {
8
+ "fastapi": "fastapi",
9
+ "jinja2": "jinja2",
10
+ "multipart": "python-multipart",
11
+ "itsdangerous": "itsdangerous",
12
+ "uvicorn": "uvicorn",
13
+ }
14
+
15
+
16
+ def main() -> None:
17
+ parser = argparse.ArgumentParser(description="Run the pKasso daisyUI/HTMX GUI.")
18
+ parser.add_argument("--host", default="0.0.0.0")
19
+ parser.add_argument("--port", default=8001, type=int)
20
+ parser.add_argument("--reload", action="store_true", help="Reload when local code changes.")
21
+ args = parser.parse_args()
22
+
23
+ missing = [
24
+ package_name for module_name, package_name in WEB_DEPENDENCIES.items() if importlib.util.find_spec(module_name) is None
25
+ ]
26
+ if missing:
27
+ parser.exit(
28
+ 1,
29
+ "The pKasso web server dependencies are not installed.\n"
30
+ f"Missing: {', '.join(sorted(set(missing)))}.\n"
31
+ "Install them with: pip install 'pkasso[webserver]'\n",
32
+ )
33
+
34
+ import uvicorn
35
+
36
+ url = f"http://{args.host}:{args.port}"
37
+ print(
38
+ textwrap.dedent(f"""
39
+ pKasso GUI is running at {url}
40
+ Press Ctrl+C to stop.
41
+ """).strip()
42
+ )
43
+ uvicorn.run("pkasso.api.web:app", host=args.host, port=args.port, reload=args.reload)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ main()
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import io
5
+ import numpy as np
6
+ import os
7
+ from typing import Any
8
+ from rdkit.Chem import AllChem
9
+ from rdkit import Chem
10
+
11
+ from .config import CUTOFF_STATES
12
+ from .state import AppState
13
+
14
+ from ..py_interface import scan_pH, protonate
15
+ from ..postprocess import draw_mols
16
+
17
+ os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib-pkasso")
18
+
19
+
20
+ def _pyplot() -> Any:
21
+ import matplotlib
22
+
23
+ matplotlib.use("Agg")
24
+ import matplotlib.pyplot as plt
25
+
26
+ return plt
27
+
28
+
29
+ def render_svg_image(svg: str | bytes) -> str:
30
+ if isinstance(svg, bytes):
31
+ return svg.decode("utf-8")
32
+ return svg
33
+
34
+
35
+ def figure_to_svg(fig: Any) -> str:
36
+ plt = _pyplot()
37
+ if fig is None:
38
+ fig = plt.gcf()
39
+ elif hasattr(fig, "figure") and not hasattr(fig, "savefig"):
40
+ fig = fig.figure
41
+
42
+ buffer = io.StringIO()
43
+ fig.savefig(buffer, format="svg", bbox_inches="tight")
44
+ plt.close(fig)
45
+ return buffer.getvalue()
46
+
47
+
48
+ def compute_prediction(state: AppState) -> None:
49
+
50
+ state.error = None
51
+ smiles_out, mols_out = protonate(
52
+ state.smiles,
53
+ name=state.ligand,
54
+ pH=state.ph,
55
+ cutoff_export=0.0,
56
+ cutoff_states=CUTOFF_STATES,
57
+ tautomer_search=state.tautomer_search,
58
+ )
59
+ state.smiles_out = list(smiles_out[: state.nmols_export])
60
+ state.mols_out = list(mols_out[: state.nmols_export])
61
+ state.scan = None
62
+ state.scan_figures.clear()
63
+
64
+
65
+ def compute_scan(state: AppState) -> None:
66
+
67
+ state.error = None
68
+ state.scan = scan_pH(
69
+ state.smiles,
70
+ name=state.ligand,
71
+ cutoff_states=CUTOFF_STATES,
72
+ tautomer_search=state.tautomer_search,
73
+ pHs=np.arange(0, 14.1, 0.25, dtype=np.float64),
74
+ )
75
+ state.scan_figures.clear()
76
+
77
+
78
+ def draw_molecule_grid(mols: list[Any], show_probability: bool = True) -> str:
79
+
80
+ if not mols:
81
+ return ""
82
+
83
+ svg = draw_mols(
84
+ mols,
85
+ subImgSize=(400, 350),
86
+ max_cols=3,
87
+ show_probability=show_probability,
88
+ )
89
+ return render_svg_image(svg)
90
+
91
+
92
+ def draw_single_molecule(mol: Any) -> str:
93
+
94
+ svg = draw_mols([mol], subImgSize=(520, 430), max_cols=1, show_probability=False)
95
+ return render_svg_image(svg)
96
+
97
+
98
+ def scan_figure_svg(state: AppState, highlight_idx: int) -> str | None:
99
+
100
+ if state.scan is None:
101
+ return None
102
+
103
+ max_idx = len(getattr(state.scan, "mols_relevant", []))
104
+ highlight_idx = max(0, min(max_idx, highlight_idx))
105
+ if highlight_idx not in state.scan_figures:
106
+ fig = state.scan.plot_scan(highlight_idx=highlight_idx)
107
+ state.scan_figures[highlight_idx] = figure_to_svg(fig)
108
+ return state.scan_figures[highlight_idx]
109
+
110
+
111
+ def sdf_for_state(state: AppState) -> bytes:
112
+
113
+ sdf = ""
114
+ for mol in state.mols_out:
115
+ mol2 = copy.deepcopy(mol)
116
+ mol2 = Chem.AddHs(mol2, addCoords=True)
117
+ AllChem.EmbedMolecule(mol2, randomSeed=1, useRandomCoords=True)
118
+ AllChem.UFFOptimizeMolecule(mol2)
119
+ sdf += Chem.MolToMolBlock(mol2) + "\n$$$$\n"
120
+ return sdf.encode("utf-8")
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ API_ROOT = Path(__file__).resolve().parent
7
+ STATIC_DIR = API_ROOT / "static"
8
+ TEMPLATE_DIR = API_ROOT / "templates"
9
+ DATA_DIR = API_ROOT / "data"
10
+ FEEDBACK_DB = DATA_DIR / "feedback.sqlite3"
11
+
12
+ DEFAULT_LIGAND = "mymolecule"
13
+ DEFAULT_SMILES = "OC(=O)C(c1ccc(O)cc1)CNCCN"
14
+ CUTOFF_STATES = 200
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import sqlite3
4
+ from datetime import datetime, timezone
5
+
6
+ from .config import FEEDBACK_DB
7
+
8
+
9
+ MAX_SMILES_LEN = 4000
10
+ MAX_COMMENT_LEN = 8000
11
+
12
+
13
+ def clean_feedback(smiles: str, comment: str) -> tuple[str, str]:
14
+ smiles = smiles.strip()
15
+ comment = comment.strip()
16
+ if not smiles:
17
+ raise ValueError("Please enter a SMILES code.")
18
+ if not comment:
19
+ raise ValueError("Please enter a comment.")
20
+ if len(smiles) > MAX_SMILES_LEN:
21
+ raise ValueError("SMILES code is too long.")
22
+ if len(comment) > MAX_COMMENT_LEN:
23
+ raise ValueError("Comment is too long.")
24
+ return smiles, comment
25
+
26
+
27
+ def save_feedback(smiles: str, comment: str) -> None:
28
+ FEEDBACK_DB.parent.mkdir(parents=True, exist_ok=True)
29
+ created_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
30
+
31
+ with sqlite3.connect(FEEDBACK_DB) as db:
32
+ db.execute(
33
+ """
34
+ CREATE TABLE IF NOT EXISTS feedback (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ smiles TEXT NOT NULL,
37
+ comment TEXT NOT NULL,
38
+ created_at TEXT NOT NULL
39
+ )
40
+ """
41
+ )
42
+ db.execute(
43
+ "INSERT INTO feedback (smiles, comment, created_at) VALUES (?, ?, ?)",
44
+ (smiles, comment, created_at),
45
+ )
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ from typing import Any
5
+
6
+ from .chemistry import draw_molecule_grid, draw_single_molecule, scan_figure_svg
7
+ from .state import AppState
8
+
9
+
10
+ def esc(value: Any) -> str:
11
+ return html.escape(str(value), quote=True)
12
+
13
+
14
+ def render_empty(message: str) -> str:
15
+ return f"""
16
+ <div class="rounded-lg border border-dashed border-[color:var(--bind-border)] bg-white p-8 text-center text-sm text-[color:var(--bind-muted)]">
17
+ {esc(message)}
18
+ </div>
19
+ """
20
+
21
+
22
+ def render_alert(message: str) -> str:
23
+ return f"""
24
+ <div role="alert" class="alert alert-error rounded-lg">
25
+ <span>{esc(message)}</span>
26
+ </div>
27
+ """
28
+
29
+
30
+ def render_form(state: AppState) -> str:
31
+ tautomer_checked = "checked" if state.tautomer_search else ""
32
+ scan_checked = "checked" if state.scan_enabled else ""
33
+ return f"""
34
+ <form id="pkasso-form"
35
+ class="space-y-5"
36
+ hx-post="/predict"
37
+ hx-target="#results"
38
+ hx-swap="innerHTML"
39
+ hx-indicator="#predict-indicator">
40
+ <label class="form-control w-full">
41
+ <div class="label px-0"><span class="label-text text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--bind-muted)]">Small molecule name</span></div>
42
+ <input name="ligand" class="input input-bordered input-sm rounded-lg border-[color:var(--bind-border)] bg-white" value="{esc(state.ligand)}" />
43
+ </label>
44
+
45
+ <label class="form-control w-full">
46
+ <div class="label px-0"><span class="label-text text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--bind-muted)]">SMILES code</span></div>
47
+ <textarea name="smiles" class="textarea textarea-bordered min-h-28 rounded-lg border-[color:var(--bind-border)] bg-white font-mono text-sm">{esc(state.smiles)}</textarea>
48
+ </label>
49
+
50
+ <div class="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,7rem)]">
51
+ <div class="space-y-3">
52
+ <label class="flex min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-[color:var(--bind-border)] bg-[color:var(--bind-soft)] px-3 py-2">
53
+ <input name="tautomer_search" type="checkbox" class="bind-toggle" {tautomer_checked} />
54
+ <span class="min-w-0 text-sm font-medium">Tautomer search</span>
55
+ <span class="bind-toggle-state ml-auto text-[11px] font-bold uppercase tracking-[0.12em] text-[color:var(--bind-muted)]"></span>
56
+ </label>
57
+
58
+ <label class="flex min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-[color:var(--bind-border)] bg-[color:var(--bind-soft)] px-3 py-2">
59
+ <input name="scan_enabled" type="checkbox" class="bind-toggle" {scan_checked} />
60
+ <span class="min-w-0 text-sm font-medium">Full pH scan</span>
61
+ <span class="bind-toggle-state ml-auto text-[11px] font-bold uppercase tracking-[0.12em] text-[color:var(--bind-muted)]"></span>
62
+ </label>
63
+ </div>
64
+
65
+ <label class="form-control min-w-0">
66
+ <div class="label px-0 py-1"><span class="label-text text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--bind-muted)]">Max states</span></div>
67
+ <input name="nmols_export" type="number" min="1" max="20" value="{state.nmols_export}"
68
+ class="input input-bordered input-sm min-w-0 rounded-lg border-[color:var(--bind-border)] bg-white" />
69
+ </label>
70
+ </div>
71
+
72
+ <div class="rounded-lg border border-[color:var(--bind-border)] bg-white p-4">
73
+ <div class="mb-3 flex items-center justify-between gap-3">
74
+ <span class="text-xs font-semibold uppercase tracking-[0.14em] text-[color:var(--bind-muted)]">pH</span>
75
+ <output id="phValue" for="phRange"
76
+ class="min-w-14 rounded-lg bg-[color:var(--bind-soft)] px-2 py-1 text-center text-sm font-semibold text-[color:var(--bind-green)]">
77
+ {state.ph:.1f}
78
+ </output>
79
+ </div>
80
+ <input id="phRange" name="ph" type="range" min="0" max="14" step="0.1" value="{state.ph:.1f}"
81
+ class="bind-range w-full"
82
+ aria-describedby="phValue"
83
+ data-ph-range
84
+ style="--ph-position: {state.ph / 14 * 100:.3f}" />
85
+ <div class="mt-2 grid grid-cols-3 text-[11px] font-medium text-[color:var(--bind-muted)]">
86
+ <span>0</span><span class="text-center">7</span><span class="text-right">14</span>
87
+ </div>
88
+ </div>
89
+
90
+ <button type="submit" class="btn btn-primary btn-sm w-full rounded-lg">
91
+ <span style='color:white;'>Calculate states</span>
92
+ <span id="predict-indicator" class="loading loading-spinner loading-xs htmx-indicator"></span>
93
+ </button>
94
+ </form>
95
+ """
96
+
97
+
98
+ def render_results(state: AppState) -> str:
99
+ if state.error:
100
+ return render_alert(state.error)
101
+
102
+ if not state.smiles_out:
103
+ return render_empty("Enter a SMILES string and calculate states to begin.")
104
+
105
+ smiles_lines = "\n".join(state.smiles_out)
106
+ try:
107
+ molecule_grid = draw_molecule_grid(state.mols_out, show_probability=True)
108
+ except Exception as exc: # pragma: no cover - visual helper failure path
109
+ molecule_grid = render_alert(f"Could not render molecule images: {exc}")
110
+
111
+ scan_html = (
112
+ render_scan(state)
113
+ if state.scan is not None
114
+ else render_empty("Enable pH scan and calculate states to inspect microstate distributions.")
115
+ )
116
+ return f"""
117
+ <section class="space-y-4">
118
+ <div class="flex flex-wrap items-start justify-between gap-3 border-b border-[color:var(--bind-border)] pb-4">
119
+ <div>
120
+ <p class="section-kicker">Single pH prediction</p>
121
+ <h2 class="mt-1 text-2xl font-semibold tracking-tight">Predicted states at pH {state.ph:.1f}</h2>
122
+ <p class="mt-1 text-sm text-[color:var(--bind-muted)]">{len(state.mols_out)} exported microstate(s)</p>
123
+ </div>
124
+ <div class="flex flex-wrap gap-2">
125
+ <a class="btn btn-outline btn-sm rounded-lg" href="/download/sdf">Download SDF</a>
126
+ <button type="button" class="btn btn-secondary btn-sm rounded-lg" data-feedback-open>Feedback</button>
127
+ </div>
128
+ </div>
129
+
130
+ <pre class="overflow-x-auto rounded-lg border border-[color:var(--bind-border)] bg-[color:var(--bind-soft)] p-4 text-sm text-[#32716D]"><code>{esc(smiles_lines)}</code></pre>
131
+
132
+ <div class="overflow-x-auto rounded-lg border border-[color:var(--bind-border)] bg-white p-3 [&_svg]:max-w-full [&_svg]:h-auto">
133
+ {molecule_grid}
134
+ </div>
135
+
136
+ <div id="scan-panel">
137
+ {scan_html}
138
+ </div>
139
+ </section>
140
+ """
141
+
142
+
143
+ def render_scan(state: AppState) -> str:
144
+ if state.scan is None:
145
+ return render_empty("Enable pH scan and calculate states to inspect microstate distributions.")
146
+
147
+ mols_relevant = list(getattr(state.scan, "mols_relevant", []))
148
+ plot = scan_plot_svg(state, 0)
149
+ microstates = []
150
+ for idx, mol in enumerate(mols_relevant):
151
+ try:
152
+ mol_svg = draw_single_molecule(mol)
153
+ except Exception as exc: # pragma: no cover - visual helper failure path
154
+ mol_svg = render_alert(f"Could not render microstate {idx + 1}: {exc}")
155
+ microstates.append(
156
+ f"""
157
+ <button type="button"
158
+ class="microstate rounded-lg border border-[color:var(--bind-border)] bg-white p-3 text-left transition hover:border-accent hover:bg-accent/5 focus:outline-none focus:ring-2 focus:ring-accent"
159
+ data-microstate-enlarge
160
+ data-microstate-title="Microstate {idx + 1}"
161
+ hx-get="/scan/plot?highlight_idx={idx + 1}"
162
+ hx-target="#scan-plot"
163
+ hx-swap="innerHTML"
164
+ hx-trigger="mouseenter, focus, click">
165
+ <span class="mb-2 block text-xs font-semibold uppercase tracking-[0.12em] text-[color:var(--bind-muted)]">Microstate {idx + 1}</span>
166
+ <span class="microstate-image block [&_svg]:h-auto [&_svg]:max-w-full">{mol_svg}</span>
167
+ </button>
168
+ """
169
+ )
170
+
171
+ if not microstates:
172
+ microstate_html = render_empty("No relevant microstates were returned for this scan.")
173
+ else:
174
+ grid_cols = "2xl:grid-cols-3" if len(microstates) > 4 else ""
175
+ microstate_html = f"""
176
+ <div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-2 {grid_cols}">
177
+ {"".join(microstates)}
178
+ </div>
179
+ """
180
+
181
+ return f"""
182
+ <section class="rounded-lg border border-[color:var(--bind-border)] bg-white p-4">
183
+ <div class="mb-3 flex flex-wrap items-center justify-between gap-3">
184
+ <div>
185
+ <p class="section-kicker">Full pH scan</p>
186
+ <h2 class="mt-1 text-2xl font-semibold tracking-tight">Microstate distributions</h2>
187
+ <p class="mt-1 text-sm text-[color:var(--bind-muted)]">Hover a microstate image to highlight it in the distribution plot; click to enlarge it.</p>
188
+ </div>
189
+ <button type="button"
190
+ class="btn btn-ghost btn-sm rounded-lg"
191
+ hx-get="/scan/plot?highlight_idx=0"
192
+ hx-target="#scan-plot"
193
+ hx-swap="innerHTML">
194
+ Clear highlight
195
+ </button>
196
+ </div>
197
+ <div class="grid grid-cols-1 gap-5 xl:grid-cols-[minmax(0,1.35fr)_minmax(28rem,0.95fr)]">
198
+ <div id="scan-plot" class="rounded-lg border border-[color:var(--bind-border)] bg-white p-4 [&_svg]:h-auto [&_svg]:w-full [&_svg]:max-w-full">
199
+ {plot}
200
+ </div>
201
+ {microstate_html}
202
+ </div>
203
+ </section>
204
+ """
205
+
206
+
207
+ def scan_plot_svg(state: AppState, highlight_idx: int) -> str:
208
+ plot = scan_figure_svg(state, highlight_idx)
209
+ if plot is None:
210
+ return render_empty("Run a pH scan to show the distribution plot.")
211
+ return plot