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.
- pkasso-0.5.0/LICENSE.md +21 -0
- pkasso-0.5.0/PKG-INFO +103 -0
- pkasso-0.5.0/README.md +64 -0
- pkasso-0.5.0/pkasso/__init__.py +15 -0
- pkasso-0.5.0/pkasso/api/__init__.py +1 -0
- pkasso-0.5.0/pkasso/api/app.py +47 -0
- pkasso-0.5.0/pkasso/api/chemistry.py +120 -0
- pkasso-0.5.0/pkasso/api/config.py +14 -0
- pkasso-0.5.0/pkasso/api/feedback.py +45 -0
- pkasso-0.5.0/pkasso/api/render.py +211 -0
- pkasso-0.5.0/pkasso/api/state.py +41 -0
- pkasso-0.5.0/pkasso/api/static/app.css +318 -0
- pkasso-0.5.0/pkasso/api/static/app.js +168 -0
- pkasso-0.5.0/pkasso/api/static/bind-logo-single-sq.svg +1 -0
- pkasso-0.5.0/pkasso/api/static/bind-rounded_nobg.svg +1 -0
- pkasso-0.5.0/pkasso/api/static/favicon.ico +0 -0
- pkasso-0.5.0/pkasso/api/static/styles.css +1879 -0
- pkasso-0.5.0/pkasso/api/templates/page.html +145 -0
- pkasso-0.5.0/pkasso/api/web.py +205 -0
- pkasso-0.5.0/pkasso/cli.py +325 -0
- pkasso-0.5.0/pkasso/coupling.py +272 -0
- pkasso-0.5.0/pkasso/data/smarts_pattern.tsv +144 -0
- pkasso-0.5.0/pkasso/data/weight_acid.pth +0 -0
- pkasso-0.5.0/pkasso/data/weight_base.pth +0 -0
- pkasso-0.5.0/pkasso/external/__init__.py +0 -0
- pkasso-0.5.0/pkasso/external/descriptor.py +194 -0
- pkasso-0.5.0/pkasso/external/gcn_conv.py +134 -0
- pkasso-0.5.0/pkasso/external/inits.py +14 -0
- pkasso-0.5.0/pkasso/external/ionization_group.py +119 -0
- pkasso-0.5.0/pkasso/external/net.py +62 -0
- pkasso-0.5.0/pkasso/external/pka.py +63 -0
- pkasso-0.5.0/pkasso/external/scatter.py +39 -0
- pkasso-0.5.0/pkasso/main.py +1511 -0
- pkasso-0.5.0/pkasso/postprocess.py +320 -0
- pkasso-0.5.0/pkasso/predict_pka.py +355 -0
- pkasso-0.5.0/pkasso/py_interface.py +103 -0
- pkasso-0.5.0/pkasso/special_cases.py +295 -0
- pkasso-0.5.0/pkasso/tautomers.py +179 -0
- pkasso-0.5.0/pkasso/transitions.py +431 -0
- pkasso-0.5.0/pkasso/utils.py +107 -0
- pkasso-0.5.0/pkasso.egg-info/PKG-INFO +103 -0
- pkasso-0.5.0/pkasso.egg-info/SOURCES.txt +50 -0
- pkasso-0.5.0/pkasso.egg-info/dependency_links.txt +1 -0
- pkasso-0.5.0/pkasso.egg-info/entry_points.txt +3 -0
- pkasso-0.5.0/pkasso.egg-info/requires.txt +26 -0
- pkasso-0.5.0/pkasso.egg-info/top_level.txt +1 -0
- pkasso-0.5.0/pyproject.toml +92 -0
- pkasso-0.5.0/setup.cfg +4 -0
- pkasso-0.5.0/tests/test_cli.py +70 -0
- pkasso-0.5.0/tests/test_coupling.py +154 -0
- pkasso-0.5.0/tests/test_main.py +198 -0
- pkasso-0.5.0/tests/test_tautomers.py +209 -0
pkasso-0.5.0/LICENSE.md
ADDED
|
@@ -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
|