xenosite-predict 0.2.1__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.
- xenosite/predict/__init__.py +65 -0
- xenosite/predict/__main__.py +51 -0
- xenosite/predict/_private.py +36 -0
- xenosite/predict/api.py +147 -0
- xenosite/predict/backends/__init__.py +133 -0
- xenosite/predict/backends/adapters.py +180 -0
- xenosite/predict/backends/http.py +60 -0
- xenosite/predict/backends/legacy.py +71 -0
- xenosite/predict/backends/onnx.py +111 -0
- xenosite/predict/compare.py +85 -0
- xenosite/predict/errors.py +33 -0
- xenosite/predict/features/__init__.py +13 -0
- xenosite/predict/forest.py +433 -0
- xenosite/predict/models/__init__.py +14 -0
- xenosite/predict/molecule.py +91 -0
- xenosite/predict/numbering.py +606 -0
- xenosite/predict/parallel.py +430 -0
- xenosite/predict/py.typed +1 -0
- xenosite/predict/registry.py +126 -0
- xenosite/predict/symmetry.py +24 -0
- xenosite/predict/types.py +119 -0
- xenosite/predict/v0_legacy/__init__.py +1 -0
- xenosite/predict/v0_legacy/features/__init__.py +38 -0
- xenosite/predict/v0_legacy/features/_ob.py +217 -0
- xenosite/predict/v0_legacy/features/atom.py +550 -0
- xenosite/predict/v0_legacy/features/bond.py +659 -0
- xenosite/predict/v0_legacy/features/bond_lonepair.py +774 -0
- xenosite/predict/v0_legacy/features/heuristic.py +79 -0
- xenosite/predict/v0_legacy/features/molgraph.py +208 -0
- xenosite/predict/v0_legacy/features/name_tables.py +2018 -0
- xenosite/predict/v0_legacy/features/names.py +48 -0
- xenosite/predict/v0_legacy/features/phase1_mol.py +70 -0
- xenosite/predict/v0_legacy/features/quinone.py +116 -0
- xenosite/predict/v0_legacy/features/reactivity_mol.py +69 -0
- xenosite/predict/v0_legacy/features/two_stage.py +65 -0
- xenosite/predict/v0_legacy/features/ugt.py +342 -0
- xenosite/predict/v0_legacy/models/__init__.py +20 -0
- xenosite/predict/v0_legacy/models/_base.py +86 -0
- xenosite/predict/v0_legacy/models/bioactivation.py +74 -0
- xenosite/predict/v0_legacy/models/epoxidation.py +99 -0
- xenosite/predict/v0_legacy/models/ndealk.py +143 -0
- xenosite/predict/v0_legacy/models/phase1.py +193 -0
- xenosite/predict/v0_legacy/models/quinone.py +169 -0
- xenosite/predict/v0_legacy/models/reactivity.py +115 -0
- xenosite/predict/v0_legacy/models/ugt.py +50 -0
- xenosite/predict/v0_legacy/symmetry.py +218 -0
- xenosite/predict/v1/__init__.py +1 -0
- xenosite/predict/weights.py +339 -0
- xenosite_predict-0.2.1.dist-info/METADATA +197 -0
- xenosite_predict-0.2.1.dist-info/RECORD +51 -0
- xenosite_predict-0.2.1.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Public user API for ``xenosite.predict``.
|
|
2
|
+
|
|
3
|
+
Call :func:`predict` with a SMILES string or an existing :class:`Molecule`.
|
|
4
|
+
Results append; parse/canonicalize happens once when several models run.
|
|
5
|
+
|
|
6
|
+
See the package README for backends, environment variables, and versions.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .api import list_models, predict
|
|
10
|
+
from .parallel import apredict, apredict_many, predict_many
|
|
11
|
+
from .registry import register_model
|
|
12
|
+
from .errors import (
|
|
13
|
+
BackendNotConfigured,
|
|
14
|
+
InvalidMolecule,
|
|
15
|
+
ModelNotAvailable,
|
|
16
|
+
OpenBabelNotAvailable,
|
|
17
|
+
UnknownModel,
|
|
18
|
+
WeightsDownloadError,
|
|
19
|
+
WeightsNotFound,
|
|
20
|
+
)
|
|
21
|
+
from .weights import download_weights, ensure_weights
|
|
22
|
+
from .types import (
|
|
23
|
+
AtomBondResult,
|
|
24
|
+
AtomResult,
|
|
25
|
+
Atoms,
|
|
26
|
+
BondResult,
|
|
27
|
+
Bonds,
|
|
28
|
+
Metabolite,
|
|
29
|
+
ModelResult,
|
|
30
|
+
MolAtomPairResult,
|
|
31
|
+
MolAtomResult,
|
|
32
|
+
MolBondResult,
|
|
33
|
+
Molecule,
|
|
34
|
+
Result,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"predict",
|
|
39
|
+
"predict_many",
|
|
40
|
+
"apredict",
|
|
41
|
+
"apredict_many",
|
|
42
|
+
"list_models",
|
|
43
|
+
"register_model",
|
|
44
|
+
"Molecule",
|
|
45
|
+
"Atoms",
|
|
46
|
+
"Bonds",
|
|
47
|
+
"Result",
|
|
48
|
+
"MolBondResult",
|
|
49
|
+
"MolAtomResult",
|
|
50
|
+
"MolAtomPairResult",
|
|
51
|
+
"AtomResult",
|
|
52
|
+
"BondResult",
|
|
53
|
+
"AtomBondResult",
|
|
54
|
+
"Metabolite",
|
|
55
|
+
"ModelResult",
|
|
56
|
+
"InvalidMolecule",
|
|
57
|
+
"UnknownModel",
|
|
58
|
+
"BackendNotConfigured",
|
|
59
|
+
"WeightsNotFound",
|
|
60
|
+
"WeightsDownloadError",
|
|
61
|
+
"ModelNotAvailable",
|
|
62
|
+
"OpenBabelNotAvailable",
|
|
63
|
+
"download_weights",
|
|
64
|
+
"ensure_weights",
|
|
65
|
+
]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""``python -m xenosite.predict download`` — fetch ONNX weights via env URL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import WeightsDownloadError
|
|
10
|
+
from .weights import ENV_ONNX_URL, download_weights
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="python -m xenosite.predict",
|
|
16
|
+
description=(
|
|
17
|
+
"Download ONNX inference graphs. The archive URL comes from "
|
|
18
|
+
f"{ENV_ONNX_URL} unless --url is passed."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
22
|
+
dl = sub.add_parser("download", help="download and extract ONNX weights")
|
|
23
|
+
dl.add_argument(
|
|
24
|
+
"--url",
|
|
25
|
+
default=None,
|
|
26
|
+
help=f"tarball URL or local path (default: ${ENV_ONNX_URL})",
|
|
27
|
+
)
|
|
28
|
+
dl.add_argument(
|
|
29
|
+
"--dest",
|
|
30
|
+
type=Path,
|
|
31
|
+
default=None,
|
|
32
|
+
help="extract directory (default: cache or $XENOSITE_MODELS_WEIGHTS)",
|
|
33
|
+
)
|
|
34
|
+
dl.add_argument(
|
|
35
|
+
"--force",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="re-download even if *.onnx files are already present",
|
|
38
|
+
)
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
if args.cmd == "download":
|
|
41
|
+
try:
|
|
42
|
+
dest = download_weights(url=args.url, dest=args.dest, force=args.force)
|
|
43
|
+
except WeightsDownloadError as exc:
|
|
44
|
+
print(exc, file=sys.stderr)
|
|
45
|
+
return 1
|
|
46
|
+
print(dest)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
if __name__ == "__main__":
|
|
51
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Private integration API for in-repo callers (e.g. ``xenosite-api``).
|
|
2
|
+
|
|
3
|
+
Import as ``xenosite.predict._private``. Not part of the stable public API —
|
|
4
|
+
signature and exports may change without a major version bump.
|
|
5
|
+
|
|
6
|
+
Forest metabolite attachment is implemented once in :func:`~xenosite.predict.forest.attach_metabolites`.
|
|
7
|
+
This module re-exports that function (as ``add_metabolites``) plus discovery helpers.
|
|
8
|
+
|
|
9
|
+
Typical **xenosite-api** usage after legacy HTTP adapters populate scores::
|
|
10
|
+
|
|
11
|
+
from xenosite.predict.types import Molecule as PredictMolecule
|
|
12
|
+
from xenosite.predict._private import add_metabolites, metabolite_supported
|
|
13
|
+
|
|
14
|
+
mol = PredictMolecule.model_validate(api_molecule.model_dump())
|
|
15
|
+
if metabolites_requested:
|
|
16
|
+
add_metabolites(mol, mapped_smiles=mapped_smiles)
|
|
17
|
+
# copy ``result.metabolite`` (and optional map fields) back onto the API model
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .forest import (
|
|
23
|
+
attach_metabolites,
|
|
24
|
+
metabolite_supported,
|
|
25
|
+
supported_metabolite_models,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Alias for service layers; same function object as attach_metabolites.
|
|
29
|
+
add_metabolites = attach_metabolites
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"add_metabolites",
|
|
33
|
+
"attach_metabolites",
|
|
34
|
+
"metabolite_supported",
|
|
35
|
+
"supported_metabolite_models",
|
|
36
|
+
]
|
xenosite/predict/api.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""``predict`` / ``list_models`` — backend-agnostic user API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Iterable, Mapping, Optional, Union
|
|
6
|
+
|
|
7
|
+
from .backends import PredictBackend, resolve_backend, resolve_for_model
|
|
8
|
+
from .errors import BackendNotConfigured
|
|
9
|
+
from .molecule import as_molecule
|
|
10
|
+
from .registry import Spec, ensure_builtins, load_runner, normalize_models, registered
|
|
11
|
+
from ._private import add_metabolites
|
|
12
|
+
from .types import Molecule
|
|
13
|
+
|
|
14
|
+
ModelsArg = Union[str, Spec, Iterable[str | Spec]]
|
|
15
|
+
BackendArg = Union[str, PredictBackend, None]
|
|
16
|
+
BackendMap = Optional[Mapping[Spec, str | PredictBackend]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def predict(
|
|
20
|
+
inp: str | Molecule,
|
|
21
|
+
model: Optional[str] = None,
|
|
22
|
+
models: Optional[ModelsArg] = None,
|
|
23
|
+
*,
|
|
24
|
+
backend: BackendArg = None,
|
|
25
|
+
backends: BackendMap = None,
|
|
26
|
+
env: Optional[Mapping[str, str]] = None,
|
|
27
|
+
metabolites: bool = False,
|
|
28
|
+
metabolites_min_score: Optional[float] = None,
|
|
29
|
+
mapped_smiles: bool = False,
|
|
30
|
+
_parameter: Optional[Mapping[str, Any]] = None,
|
|
31
|
+
) -> Molecule:
|
|
32
|
+
"""Run one or more models and return a :class:`Molecule` with appended results.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
inp:
|
|
37
|
+
SMILES string or an existing :class:`Molecule` (results are appended).
|
|
38
|
+
model:
|
|
39
|
+
Single model name (default version). Ignored if ``models`` is set.
|
|
40
|
+
models:
|
|
41
|
+
Names and/or ``(name, version)`` pairs. Parse/canonicalize once.
|
|
42
|
+
backend:
|
|
43
|
+
Pin every model in this call: ``"onnx"``, ``"http"``, ``"legacy"``,
|
|
44
|
+
a URL, or a :class:`PredictBackend`. ``None`` uses the env picker.
|
|
45
|
+
backends:
|
|
46
|
+
Per-``(name, version)`` override (wins over ``backend``).
|
|
47
|
+
env:
|
|
48
|
+
Environment mapping for the picker. ``None`` uses ``os.environ``.
|
|
49
|
+
Tests should pass ``env={}`` or rely on the autouse clearer.
|
|
50
|
+
metabolites:
|
|
51
|
+
When ``True``, attach every metabolite structure the forest ruleset
|
|
52
|
+
generates for the substrate, annotated with predictor site scores and
|
|
53
|
+
sorted by score (descending). See ``xenosite.predict.forest``.
|
|
54
|
+
metabolites_min_score:
|
|
55
|
+
When set, drop metabolites whose site score is below this threshold.
|
|
56
|
+
Default ``None`` includes all forest products.
|
|
57
|
+
mapped_smiles:
|
|
58
|
+
When ``True`` (with ``metabolites=True``), add ``mapped_smiles`` to each
|
|
59
|
+
forest metabolite — canonical SMILES with ``:N`` atom-map numbers tracing
|
|
60
|
+
heavy atoms back to the parent (1-based; new atoms unmapped). ``map_idx``
|
|
61
|
+
is always populated when forest metabolites are attached.
|
|
62
|
+
_parameter:
|
|
63
|
+
Internal per-call options (not part of the public HTTP API). Runners
|
|
64
|
+
read ``molecule._parameter``; e.g. ``ndealk_site_mode`` is ``legacy``
|
|
65
|
+
for golden parity tests and ``principled`` (default) for production;
|
|
66
|
+
``quinone_omp_mode`` is ``legacy`` (deterministic sorted BFS) for golden
|
|
67
|
+
tests and ``principled`` (any qualifying tied shortest path; binary) for
|
|
68
|
+
production; ``mean`` averages all shortest-path indicators (fractional);
|
|
69
|
+
``symmetry_group_mode`` is ``openbabel`` for golden parity and ``rdkit``
|
|
70
|
+
(default) for production bond-class deduplication and score pooling
|
|
71
|
+
(mean of active scores per class); ``bond_nrings_mode`` is ``legacy``
|
|
72
|
+
(DFS back-edge atom counts) for golden/ob dumps and ``principled``
|
|
73
|
+
(RDKit ``RingInfo.NumAtomRings`` per BondTD endpoint) for production.
|
|
74
|
+
|
|
75
|
+
Notes
|
|
76
|
+
-----
|
|
77
|
+
One molecule at a time (no batch API). For many molecules use
|
|
78
|
+
:func:`xenosite.predict.predict_many` or :func:`xenosite.predict.apredict_many`.
|
|
79
|
+
Import does not open ONNX, HTTP, or OpenBabel.
|
|
80
|
+
First use downloads ONNX weights when XENOSITE_ONNX_URL is set.
|
|
81
|
+
"""
|
|
82
|
+
ensure_builtins()
|
|
83
|
+
if models is None:
|
|
84
|
+
models = model
|
|
85
|
+
specs = normalize_models(models)
|
|
86
|
+
_, molecule = as_molecule(inp)
|
|
87
|
+
if _parameter:
|
|
88
|
+
molecule._parameter = dict(_parameter)
|
|
89
|
+
|
|
90
|
+
for spec in specs:
|
|
91
|
+
be = resolve_for_model(spec, backend=backend, backends=backends, env=env)
|
|
92
|
+
runner = load_runner(*spec)
|
|
93
|
+
runner.predict_molecule(molecule, be)
|
|
94
|
+
if metabolites:
|
|
95
|
+
add_metabolites(
|
|
96
|
+
molecule,
|
|
97
|
+
min_score=metabolites_min_score,
|
|
98
|
+
mapped_smiles=mapped_smiles,
|
|
99
|
+
)
|
|
100
|
+
return molecule
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_models(
|
|
104
|
+
*,
|
|
105
|
+
backend: BackendArg = None,
|
|
106
|
+
env: Optional[Mapping[str, str]] = None,
|
|
107
|
+
) -> list[dict]:
|
|
108
|
+
"""What this process can actually run (backend-aware), not a fictional union.
|
|
109
|
+
|
|
110
|
+
Each item is ``{"name", "version", "available", "backend", "reason"}``.
|
|
111
|
+
"""
|
|
112
|
+
ensure_builtins()
|
|
113
|
+
try:
|
|
114
|
+
be = resolve_backend(backend, env=env)
|
|
115
|
+
available = set(be.available_models())
|
|
116
|
+
bname = be.name
|
|
117
|
+
except BackendNotConfigured as exc:
|
|
118
|
+
available = set()
|
|
119
|
+
bname = None
|
|
120
|
+
default_reason = str(exc)
|
|
121
|
+
else:
|
|
122
|
+
default_reason = ""
|
|
123
|
+
|
|
124
|
+
out = []
|
|
125
|
+
for info in registered():
|
|
126
|
+
spec = (info.name, info.version)
|
|
127
|
+
ok = spec in available and not info.blocked_reason
|
|
128
|
+
reason = info.blocked_reason or ("" if ok else (default_reason or "not on this backend"))
|
|
129
|
+
if ok and bname == "onnx" and info.name not in ("bioactivation",):
|
|
130
|
+
from .features import _ob
|
|
131
|
+
|
|
132
|
+
if not _ob.installed():
|
|
133
|
+
ok = False
|
|
134
|
+
reason = "OpenBabel is required for ONNX descriptors (uv add openbabel)"
|
|
135
|
+
out.append(
|
|
136
|
+
{
|
|
137
|
+
"name": info.name,
|
|
138
|
+
"version": info.version,
|
|
139
|
+
"available": bool(ok),
|
|
140
|
+
"backend": bname,
|
|
141
|
+
"reason": reason,
|
|
142
|
+
"heads": list(info.heads),
|
|
143
|
+
"two_stage": info.two_stage,
|
|
144
|
+
"pipeline": info.pipeline,
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
return out
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Backend protocol, picker, and env handling.
|
|
2
|
+
|
|
3
|
+
Picker (explicit env wins; first match):
|
|
4
|
+
|
|
5
|
+
1. ``XENOSITE_BACKEND`` is an ``http://`` / ``https://`` URL → HTTP backend
|
|
6
|
+
(deployed xenosite-api). Optional ``XENOSITE_API_KEY`` as Bearer.
|
|
7
|
+
2. Else ``XENOSITE_MODELS_WEIGHTS`` → local ONNX directory.
|
|
8
|
+
3. Else auto-detect ``./weights/onnx/v0`` (or a flat ``./weights/onnx`` tree) → local ONNX.
|
|
9
|
+
4. Else user cache (``$XDG_CACHE_HOME/xenosite/onnx/v0``) if ``*.onnx`` exist.
|
|
10
|
+
5. Else, when ``XENOSITE_ONNX_URL`` is set, fetch that archive into the cache
|
|
11
|
+
(an INFO line reports when weights are found or downloaded).
|
|
12
|
+
6. Else raise :class:`BackendNotConfigured`.
|
|
13
|
+
|
|
14
|
+
The archive URL is never compiled into this package; set ``XENOSITE_ONNX_URL``.
|
|
15
|
+
Tests must pass ``backend=`` / ``env={}`` and must not inherit a developer
|
|
16
|
+
shell. ``conftest.py`` clears ``XENOSITE_*`` unless a test opts in.
|
|
17
|
+
An isolated ``env={}`` does not auto-download.
|
|
18
|
+
|
|
19
|
+
Per-``(model, version)`` override: ``predict(..., backend=...)`` applies to all
|
|
20
|
+
models in the call; ``predict(..., backends={(name, version): backend})`` pins
|
|
21
|
+
individual models (so ONNX epoxidation can coexist with HTTP bioactivation).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Mapping, Optional, Protocol, runtime_checkable
|
|
29
|
+
|
|
30
|
+
from ..errors import BackendNotConfigured
|
|
31
|
+
from ..weights import ENV_ONNX_URL, ENV_WEIGHTS, resolve_onnx_dir
|
|
32
|
+
|
|
33
|
+
ENV_BACKEND = "XENOSITE_BACKEND"
|
|
34
|
+
ENV_API_KEY = "XENOSITE_API_KEY"
|
|
35
|
+
ENV_LEGACY_URL = "XENOSITE_LEGACY_TEST_URL"
|
|
36
|
+
|
|
37
|
+
Spec = tuple[str, str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class PredictBackend(Protocol):
|
|
42
|
+
"""Swappable backend. Native results need not look like :class:`Molecule`."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
def available_models(self) -> list[Spec]:
|
|
47
|
+
"""``(name, version)`` pairs this process can actually run on this backend."""
|
|
48
|
+
|
|
49
|
+
def predict_native(self, smiles: str, model: str, version: str) -> Any:
|
|
50
|
+
"""Run one model; return backend-native output for the adapter."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def is_url(value: str) -> bool:
|
|
54
|
+
return value.startswith("http://") or value.startswith("https://")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_backend(
|
|
58
|
+
backend: Optional[str | PredictBackend] = None,
|
|
59
|
+
*,
|
|
60
|
+
env: Optional[Mapping[str, str]] = None,
|
|
61
|
+
cwd: Optional[Path] = None,
|
|
62
|
+
auto_download: Optional[bool] = None,
|
|
63
|
+
) -> PredictBackend:
|
|
64
|
+
"""Resolve a backend. ``env=None`` uses ``os.environ``; tests should pass a dict.
|
|
65
|
+
|
|
66
|
+
``auto_download`` defaults on only when ``env is None`` and
|
|
67
|
+
``XENOSITE_ONNX_URL`` is set. Isolated ``env`` mappings do not fetch.
|
|
68
|
+
"""
|
|
69
|
+
if isinstance(backend, PredictBackend) and not isinstance(backend, str):
|
|
70
|
+
return backend
|
|
71
|
+
|
|
72
|
+
from .http import HttpBackend
|
|
73
|
+
from .legacy import LegacyTestBackend
|
|
74
|
+
from .onnx import OnnxBackend
|
|
75
|
+
|
|
76
|
+
if isinstance(backend, str):
|
|
77
|
+
if is_url(backend):
|
|
78
|
+
return HttpBackend(backend)
|
|
79
|
+
key = backend.lower()
|
|
80
|
+
if key in {"onnx", "local"}:
|
|
81
|
+
weights = resolve_onnx_dir(
|
|
82
|
+
env=env, cwd=cwd, auto_download=auto_download, fallback=True
|
|
83
|
+
)
|
|
84
|
+
if weights is None:
|
|
85
|
+
raise BackendNotConfigured(
|
|
86
|
+
"backend='onnx' needs local *.onnx files, "
|
|
87
|
+
f"{ENV_WEIGHTS}, or {ENV_ONNX_URL} (auto-downloaded on first use)."
|
|
88
|
+
)
|
|
89
|
+
return OnnxBackend(weights)
|
|
90
|
+
if key in {"legacy", "legacy-test", "test-api"}:
|
|
91
|
+
url = (env or os.environ).get(ENV_LEGACY_URL, "http://127.0.0.1:8099")
|
|
92
|
+
return LegacyTestBackend(url)
|
|
93
|
+
if key == "http":
|
|
94
|
+
e = env if env is not None else os.environ
|
|
95
|
+
url = e.get(ENV_BACKEND, "")
|
|
96
|
+
if not is_url(url):
|
|
97
|
+
raise BackendNotConfigured(
|
|
98
|
+
"backend='http' requires XENOSITE_BACKEND to be an http(s) URL"
|
|
99
|
+
)
|
|
100
|
+
return HttpBackend(url, api_key=e.get(ENV_API_KEY))
|
|
101
|
+
raise BackendNotConfigured(f"Unknown backend {backend!r}")
|
|
102
|
+
|
|
103
|
+
e = dict(os.environ if env is None else env)
|
|
104
|
+
cwd = cwd or Path.cwd()
|
|
105
|
+
|
|
106
|
+
url = e.get(ENV_BACKEND, "").strip()
|
|
107
|
+
if is_url(url):
|
|
108
|
+
return HttpBackend(url, api_key=e.get(ENV_API_KEY))
|
|
109
|
+
|
|
110
|
+
weights = resolve_onnx_dir(env=env, cwd=cwd, auto_download=auto_download)
|
|
111
|
+
if weights is not None:
|
|
112
|
+
return OnnxBackend(weights)
|
|
113
|
+
|
|
114
|
+
raise BackendNotConfigured(
|
|
115
|
+
"No predictor backend configured. Set XENOSITE_BACKEND to an http(s) "
|
|
116
|
+
"xenosite-api URL, XENOSITE_MODELS_WEIGHTS to an ONNX directory, put "
|
|
117
|
+
"*.onnx files under ./weights/onnx/v0, or set XENOSITE_ONNX_URL "
|
|
118
|
+
"(weights download on first predict())."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def resolve_for_model(
|
|
123
|
+
spec: Spec,
|
|
124
|
+
*,
|
|
125
|
+
backend: Optional[str | PredictBackend] = None,
|
|
126
|
+
backends: Optional[Mapping[Spec, str | PredictBackend]] = None,
|
|
127
|
+
env: Optional[Mapping[str, str]] = None,
|
|
128
|
+
cwd: Optional[Path] = None,
|
|
129
|
+
) -> PredictBackend:
|
|
130
|
+
"""Per-model override, then the call-level backend, then env picker."""
|
|
131
|
+
if backends and spec in backends:
|
|
132
|
+
return resolve_backend(backends[spec], env=env, cwd=cwd)
|
|
133
|
+
return resolve_backend(backend, env=env, cwd=cwd)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Shared adapters: backend-native scores → user-API :class:`Molecule` results.
|
|
2
|
+
|
|
3
|
+
Ported in spirit from ``xenosite-api`` ``v0/adapters.py``. One helper set, not
|
|
4
|
+
per-model copies. Numeric scores must match; 0-based RDKit indices; adapter
|
|
5
|
+
quirks (UGT replacing ``results``, quinone ``{}`` → 0.0) are not replayed
|
|
6
|
+
except insofar as scores stay the same.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Iterable, Optional, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from ..types import (
|
|
16
|
+
AtomBondResult,
|
|
17
|
+
AtomResult,
|
|
18
|
+
BondResult,
|
|
19
|
+
Metabolite,
|
|
20
|
+
MolAtomPairResult,
|
|
21
|
+
MolAtomResult,
|
|
22
|
+
MolBondResult,
|
|
23
|
+
Molecule,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def append_mol_bond(
|
|
28
|
+
molecule: Molecule,
|
|
29
|
+
*,
|
|
30
|
+
model: str,
|
|
31
|
+
version: str,
|
|
32
|
+
mol: float,
|
|
33
|
+
bond: Sequence[float],
|
|
34
|
+
) -> None:
|
|
35
|
+
molecule.results.append(
|
|
36
|
+
MolBondResult(model=model, version=version, mol=float(mol), bond=[float(x) for x in bond])
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def append_mol_atom(
|
|
41
|
+
molecule: Molecule,
|
|
42
|
+
*,
|
|
43
|
+
model: str,
|
|
44
|
+
version: str,
|
|
45
|
+
mol: float,
|
|
46
|
+
atom: Sequence[float],
|
|
47
|
+
metabolite: Optional[list[Metabolite]] = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
molecule.results.append(
|
|
50
|
+
MolAtomResult(
|
|
51
|
+
model=model,
|
|
52
|
+
version=version,
|
|
53
|
+
mol=float(mol),
|
|
54
|
+
atom=[float(x) for x in atom],
|
|
55
|
+
metabolite=metabolite,
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def append_atom(
|
|
61
|
+
molecule: Molecule,
|
|
62
|
+
*,
|
|
63
|
+
model: str,
|
|
64
|
+
version: str,
|
|
65
|
+
atom: Sequence[float],
|
|
66
|
+
) -> None:
|
|
67
|
+
molecule.results.append(
|
|
68
|
+
AtomResult(model=model, version=version, atom=[float(x) for x in atom])
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def append_bond(
|
|
73
|
+
molecule: Molecule,
|
|
74
|
+
*,
|
|
75
|
+
model: str,
|
|
76
|
+
version: str,
|
|
77
|
+
bond: Sequence[float],
|
|
78
|
+
) -> None:
|
|
79
|
+
molecule.results.append(
|
|
80
|
+
BondResult(model=model, version=version, bond=[float(x) for x in bond])
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def append_atom_bond(
|
|
85
|
+
molecule: Molecule,
|
|
86
|
+
*,
|
|
87
|
+
model: str,
|
|
88
|
+
version: str,
|
|
89
|
+
atom: Sequence[float],
|
|
90
|
+
bond: Sequence[float],
|
|
91
|
+
) -> None:
|
|
92
|
+
molecule.results.append(
|
|
93
|
+
AtomBondResult(
|
|
94
|
+
model=model,
|
|
95
|
+
version=version,
|
|
96
|
+
atom=[float(x) for x in atom],
|
|
97
|
+
bond=[float(x) for x in bond],
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def append_atom_pair(
|
|
103
|
+
molecule: Molecule,
|
|
104
|
+
*,
|
|
105
|
+
model: str,
|
|
106
|
+
version: str,
|
|
107
|
+
mol: float,
|
|
108
|
+
atom: Sequence[float],
|
|
109
|
+
pair: Sequence[float],
|
|
110
|
+
pair_idx: Sequence[tuple[int, int]],
|
|
111
|
+
) -> None:
|
|
112
|
+
pp = canonicalize_pair_idx(list(pair_idx), list(pair))
|
|
113
|
+
molecule.results.append(
|
|
114
|
+
MolAtomPairResult(
|
|
115
|
+
model=model,
|
|
116
|
+
version=version,
|
|
117
|
+
mol=float(mol),
|
|
118
|
+
atom=[float(x) for x in atom],
|
|
119
|
+
pair=pp["pair"],
|
|
120
|
+
pair_idx=pp["pair_idx"],
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def canonicalize_pair_idx(
|
|
126
|
+
pair_idx: Sequence[tuple[int, int]], pair: Sequence[float]
|
|
127
|
+
) -> dict:
|
|
128
|
+
ix = [(tuple(sorted(idxs)), float(x)) for idxs, x in zip(pair_idx, pair)]
|
|
129
|
+
ix.sort()
|
|
130
|
+
return {"pair_idx": [i for i, _ in ix], "pair": [x for _, x in ix]}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def canonical_bond_site_pair(a: int, b: int) -> tuple[int, int]:
|
|
134
|
+
"""Legacy ndealk/isozyme site keys use ascending atom ids (``2-1`` → ``1-2``)."""
|
|
135
|
+
if a <= b:
|
|
136
|
+
return a, b
|
|
137
|
+
return b, a
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def reorder_by_bond(
|
|
141
|
+
scores: Sequence[float],
|
|
142
|
+
current: Sequence[Iterable[int]],
|
|
143
|
+
new: Sequence[tuple[int, int]],
|
|
144
|
+
*,
|
|
145
|
+
fill: float = 0.0,
|
|
146
|
+
) -> list[float]:
|
|
147
|
+
"""Map bond scores onto ``molecule.bonds.idx`` (frozenset of atom ids)."""
|
|
148
|
+
lookup = {frozenset(b): i for i, b in enumerate(current)}
|
|
149
|
+
out: list[float] = []
|
|
150
|
+
for b in new:
|
|
151
|
+
key = frozenset(b)
|
|
152
|
+
out.append(float(scores[lookup[key]]) if key in lookup else fill)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def or_combine(values: Sequence[float]) -> float:
|
|
157
|
+
"""``1 - prod(1 - p)`` aggregation used by quinone atoms and bioactivation."""
|
|
158
|
+
if not values:
|
|
159
|
+
return 0.0
|
|
160
|
+
arr = np.asarray(values, dtype=float)
|
|
161
|
+
return float(1.0 - np.prod(1.0 - arr))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def safe_atom_index(v) -> int:
|
|
165
|
+
"""OpenBabel 1-based atom id → 0-based RDKit index (``v0/adapters.py``)."""
|
|
166
|
+
try:
|
|
167
|
+
return int(v) - 1
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
return v # type: ignore[return-value]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def legacy_atom_vector(site_map: dict, n_atoms: int, *, one_based: bool = True) -> list[float]:
|
|
173
|
+
"""Per-atom scores from a legacy site map → 0-based RDKit vector.
|
|
174
|
+
|
|
175
|
+
Delegates to :func:`xenosite.predict.numbering.legacy_site_to_atom_vector`
|
|
176
|
+
(handles gapped legacy OB 2.4 keys on ``[nH]`` SMILES).
|
|
177
|
+
"""
|
|
178
|
+
from ..numbering import legacy_site_to_atom_vector
|
|
179
|
+
|
|
180
|
+
return legacy_site_to_atom_vector(site_map, n_atoms)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""HTTP backend against a deployed xenosite-api (not the legacy Flask app).
|
|
2
|
+
|
|
3
|
+
``XENOSITE_BACKEND`` is the API origin (no trailing path required).
|
|
4
|
+
``XENOSITE_API_KEY`` is sent as ``Authorization: Bearer …`` when set.
|
|
5
|
+
|
|
6
|
+
This backend never loads ONNX. Live parity tests must pin ONNX vs the
|
|
7
|
+
legacy test-API, not vs production HTTP.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
from urllib.parse import quote
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ..errors import UnknownModel
|
|
18
|
+
from ..types import Molecule
|
|
19
|
+
|
|
20
|
+
# xenosite-api v0 routes (query: ?smiles=)
|
|
21
|
+
_V0_ROUTES: dict[tuple[str, str], str] = {
|
|
22
|
+
("epoxidation", "0"): "/v0/epoxidation",
|
|
23
|
+
("quinone", "0"): "/v0/quinone",
|
|
24
|
+
("ugt", "0"): "/v0/ugt",
|
|
25
|
+
("ndealk", "0"): "/v0/ndealk",
|
|
26
|
+
("isozyme", "0"): "/v0/isozyme",
|
|
27
|
+
("phase1", "0"): "/v0/phase1",
|
|
28
|
+
("bioactivation", "0"): "/v0/bioactivation",
|
|
29
|
+
("reactivity", "0"): "/v0/reactivity",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class HttpBackend:
|
|
34
|
+
"""GET ``{origin}{route}?smiles=`` and return a Molecule-shaped dict."""
|
|
35
|
+
|
|
36
|
+
name = "http"
|
|
37
|
+
|
|
38
|
+
def __init__(self, origin: str, api_key: Optional[str] = None, *, timeout: float = 60.0):
|
|
39
|
+
self.origin = origin.rstrip("/")
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
|
|
43
|
+
def _headers(self) -> dict[str, str]:
|
|
44
|
+
h = {"accept": "application/json"}
|
|
45
|
+
if self.api_key:
|
|
46
|
+
h["authorization"] = f"Bearer {self.api_key}"
|
|
47
|
+
return h
|
|
48
|
+
|
|
49
|
+
def available_models(self) -> list[tuple[str, str]]:
|
|
50
|
+
return list(_V0_ROUTES)
|
|
51
|
+
|
|
52
|
+
def predict_native(self, smiles: str, model: str, version: str) -> Any:
|
|
53
|
+
route = _V0_ROUTES.get((model, version))
|
|
54
|
+
if route is None:
|
|
55
|
+
raise UnknownModel(f"HTTP backend has no route for {model!r} {version!r}")
|
|
56
|
+
url = f"{self.origin}{route}"
|
|
57
|
+
with httpx.Client(timeout=self.timeout, headers=self._headers()) as client:
|
|
58
|
+
r = client.get(url, params={"smiles": smiles})
|
|
59
|
+
r.raise_for_status()
|
|
60
|
+
return r.json()
|