pkasso 0.5.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.
- pkasso/__init__.py +15 -0
- pkasso/api/__init__.py +1 -0
- pkasso/api/app.py +47 -0
- pkasso/api/chemistry.py +120 -0
- pkasso/api/config.py +14 -0
- pkasso/api/feedback.py +45 -0
- pkasso/api/render.py +211 -0
- pkasso/api/state.py +41 -0
- pkasso/api/static/app.css +318 -0
- pkasso/api/static/app.js +168 -0
- pkasso/api/static/bind-logo-single-sq.svg +1 -0
- pkasso/api/static/bind-rounded_nobg.svg +1 -0
- pkasso/api/static/favicon.ico +0 -0
- pkasso/api/static/styles.css +1879 -0
- pkasso/api/templates/page.html +145 -0
- pkasso/api/web.py +205 -0
- pkasso/cli.py +325 -0
- pkasso/coupling.py +272 -0
- pkasso/data/smarts_pattern.tsv +144 -0
- pkasso/data/weight_acid.pth +0 -0
- pkasso/data/weight_base.pth +0 -0
- pkasso/external/__init__.py +0 -0
- pkasso/external/descriptor.py +194 -0
- pkasso/external/gcn_conv.py +134 -0
- pkasso/external/inits.py +14 -0
- pkasso/external/ionization_group.py +119 -0
- pkasso/external/net.py +62 -0
- pkasso/external/pka.py +63 -0
- pkasso/external/scatter.py +39 -0
- pkasso/main.py +1511 -0
- pkasso/postprocess.py +320 -0
- pkasso/predict_pka.py +355 -0
- pkasso/py_interface.py +103 -0
- pkasso/special_cases.py +295 -0
- pkasso/tautomers.py +179 -0
- pkasso/transitions.py +431 -0
- pkasso/utils.py +107 -0
- pkasso-0.5.0.dist-info/METADATA +103 -0
- pkasso-0.5.0.dist-info/RECORD +43 -0
- pkasso-0.5.0.dist-info/WHEEL +5 -0
- pkasso-0.5.0.dist-info/entry_points.txt +3 -0
- pkasso-0.5.0.dist-info/licenses/LICENSE.md +21 -0
- pkasso-0.5.0.dist-info/top_level.txt +1 -0
pkasso/__init__.py
ADDED
|
@@ -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}")
|
pkasso/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pKasso web application."""
|
pkasso/api/app.py
ADDED
|
@@ -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()
|
pkasso/api/chemistry.py
ADDED
|
@@ -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")
|
pkasso/api/config.py
ADDED
|
@@ -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
|
pkasso/api/feedback.py
ADDED
|
@@ -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
|
+
)
|
pkasso/api/render.py
ADDED
|
@@ -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
|
pkasso/api/state.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .config import DEFAULT_LIGAND, DEFAULT_SMILES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class AppState:
|
|
11
|
+
ligand: str = DEFAULT_LIGAND
|
|
12
|
+
smiles: str = DEFAULT_SMILES
|
|
13
|
+
ph: float = 7.0
|
|
14
|
+
nmols_export: int = 3
|
|
15
|
+
tautomer_search: bool = True
|
|
16
|
+
scan_enabled: bool = False
|
|
17
|
+
smiles_out: list[str] = field(default_factory=list)
|
|
18
|
+
mols_out: list[Any] = field(default_factory=list)
|
|
19
|
+
scan: Any | None = None
|
|
20
|
+
scan_figures: dict[int, str] = field(default_factory=dict)
|
|
21
|
+
error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SESSIONS: dict[str, AppState] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def update_state_from_form(state: AppState, form: dict[str, str]) -> None:
|
|
28
|
+
state.ligand = form.get("ligand", DEFAULT_LIGAND).strip() or DEFAULT_LIGAND
|
|
29
|
+
state.smiles = form.get("smiles", DEFAULT_SMILES).strip()
|
|
30
|
+
state.tautomer_search = form.get("tautomer_search") == "on"
|
|
31
|
+
state.scan_enabled = form.get("scan_enabled") == "on"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
state.ph = max(0.0, min(14.0, float(form.get("ph", "7.0"))))
|
|
35
|
+
except ValueError:
|
|
36
|
+
state.ph = 7.0
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
state.nmols_export = max(1, min(20, int(form.get("nmols_export", "3"))))
|
|
40
|
+
except ValueError:
|
|
41
|
+
state.nmols_export = 3
|