chemetrian 0.1.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.
- chemetrian-0.1.0/LICENSE +21 -0
- chemetrian-0.1.0/PKG-INFO +94 -0
- chemetrian-0.1.0/README.md +68 -0
- chemetrian-0.1.0/pyproject.toml +43 -0
- chemetrian-0.1.0/src/chemetrian/__init__.py +31 -0
- chemetrian-0.1.0/src/chemetrian/_http.py +112 -0
- chemetrian-0.1.0/src/chemetrian/_validation.py +60 -0
- chemetrian-0.1.0/src/chemetrian/bayesian_optimization.py +213 -0
- chemetrian-0.1.0/src/chemetrian/client.py +59 -0
- chemetrian-0.1.0/src/chemetrian/exceptions.py +52 -0
chemetrian-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Chemetrian
|
|
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.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: chemetrian
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Chemetrian API (Bayesian Optimization, and more).
|
|
5
|
+
Home-page: https://www.chemetrian.com
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: chemetrian,bayesian-optimization,chemistry,api-client
|
|
8
|
+
Author: Chemetrian
|
|
9
|
+
Author-email: dev@chemetrian.com
|
|
10
|
+
Requires-Python: >=3.9,<4.0
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
21
|
+
Requires-Dist: pandas (>=1.5)
|
|
22
|
+
Requires-Dist: requests (>=2.28,<3.0)
|
|
23
|
+
Project-URL: Repository, https://gitlab.com/patel.simd/chemetrian-python-sdk
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# chemetrian
|
|
27
|
+
|
|
28
|
+
Official Python SDK for the Chemetrian API. Drive Bayesian Optimization (and future
|
|
29
|
+
services) with pandas DataFrames and one API key.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install chemetrian
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from chemetrian import Chemetrian
|
|
41
|
+
|
|
42
|
+
client = Chemetrian(api_key="sk_live_…") # or set CHEMETRIAN_API_KEY
|
|
43
|
+
# QA: Chemetrian(api_key="sk_live_…", base_url="https://qa.chemetrian.com")
|
|
44
|
+
|
|
45
|
+
features = [
|
|
46
|
+
{"feature_name": "temp", "feature_type": "CONTINUOUS_NUMERICAL",
|
|
47
|
+
"lower_bound": 20, "upper_bound": 120, "step_size": 10},
|
|
48
|
+
{"feature_name": "catalyst", "feature_type": "CATEGORICAL",
|
|
49
|
+
"feature_values": ["Pd", "Ni", "Cu"]},
|
|
50
|
+
]
|
|
51
|
+
targets = [{"target_name": "yield", "goal": "maximize", "threshold": 0}]
|
|
52
|
+
|
|
53
|
+
# 1. Start a campaign → DataFrame of suggested experiments (priority == 1)
|
|
54
|
+
df = client.bayesian_optimization.initialize(
|
|
55
|
+
project="amide-coupling", workflow_name="run-1",
|
|
56
|
+
features=features, targets=targets, batch_size=4,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# 2. Run the suggested rows, fill in the measured target, submit → next batch
|
|
60
|
+
df.loc[df["priority"] == 1, "yield"] = [72.4, 55.1, 38.9, 80.2]
|
|
61
|
+
df = client.bayesian_optimization.run_round("amide-coupling", "run-1", df, batch_size=4)
|
|
62
|
+
|
|
63
|
+
# 3. (Optional) start from existing data instead of step 1
|
|
64
|
+
df = client.bayesian_optimization.initialize_with_experiments(
|
|
65
|
+
project="amide-coupling", workflow_name="run-2",
|
|
66
|
+
experiments=historical_df, targets=targets, batch_size=4, # features auto-detected
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Each call submits a job and blocks until it finishes (polling under the hood), then returns
|
|
71
|
+
a DataFrame. `priority`: `1` = suggested (run it), `-1` = completed.
|
|
72
|
+
|
|
73
|
+
## Projects & workflows
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client.list_projects()
|
|
77
|
+
client.create_project("amide-coupling", description="Q3 scouting")
|
|
78
|
+
client.bayesian_optimization.list_workflows("amide-coupling")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
All raise subclasses of `chemetrian.ChemetrianError`: `ValidationError` (client-side),
|
|
84
|
+
`AuthError` (401), `InsufficientCreditsError` (402), `NotFoundError` (404),
|
|
85
|
+
`RateLimitError` (429, auto-retried), `JobFailedError`, `JobTimeoutError`.
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
| arg / env | purpose |
|
|
90
|
+
|---|---|
|
|
91
|
+
| `api_key` / `CHEMETRIAN_API_KEY` | your key |
|
|
92
|
+
| `base_url` / `CHEMETRIAN_API_URL` | API host (QA vs prod) |
|
|
93
|
+
| `poll_interval`, `poll_timeout` | how long the SDK waits for a job |
|
|
94
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# chemetrian
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the Chemetrian API. Drive Bayesian Optimization (and future
|
|
4
|
+
services) with pandas DataFrames and one API key.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install chemetrian
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from chemetrian import Chemetrian
|
|
16
|
+
|
|
17
|
+
client = Chemetrian(api_key="sk_live_…") # or set CHEMETRIAN_API_KEY
|
|
18
|
+
# QA: Chemetrian(api_key="sk_live_…", base_url="https://qa.chemetrian.com")
|
|
19
|
+
|
|
20
|
+
features = [
|
|
21
|
+
{"feature_name": "temp", "feature_type": "CONTINUOUS_NUMERICAL",
|
|
22
|
+
"lower_bound": 20, "upper_bound": 120, "step_size": 10},
|
|
23
|
+
{"feature_name": "catalyst", "feature_type": "CATEGORICAL",
|
|
24
|
+
"feature_values": ["Pd", "Ni", "Cu"]},
|
|
25
|
+
]
|
|
26
|
+
targets = [{"target_name": "yield", "goal": "maximize", "threshold": 0}]
|
|
27
|
+
|
|
28
|
+
# 1. Start a campaign → DataFrame of suggested experiments (priority == 1)
|
|
29
|
+
df = client.bayesian_optimization.initialize(
|
|
30
|
+
project="amide-coupling", workflow_name="run-1",
|
|
31
|
+
features=features, targets=targets, batch_size=4,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
# 2. Run the suggested rows, fill in the measured target, submit → next batch
|
|
35
|
+
df.loc[df["priority"] == 1, "yield"] = [72.4, 55.1, 38.9, 80.2]
|
|
36
|
+
df = client.bayesian_optimization.run_round("amide-coupling", "run-1", df, batch_size=4)
|
|
37
|
+
|
|
38
|
+
# 3. (Optional) start from existing data instead of step 1
|
|
39
|
+
df = client.bayesian_optimization.initialize_with_experiments(
|
|
40
|
+
project="amide-coupling", workflow_name="run-2",
|
|
41
|
+
experiments=historical_df, targets=targets, batch_size=4, # features auto-detected
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Each call submits a job and blocks until it finishes (polling under the hood), then returns
|
|
46
|
+
a DataFrame. `priority`: `1` = suggested (run it), `-1` = completed.
|
|
47
|
+
|
|
48
|
+
## Projects & workflows
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
client.list_projects()
|
|
52
|
+
client.create_project("amide-coupling", description="Q3 scouting")
|
|
53
|
+
client.bayesian_optimization.list_workflows("amide-coupling")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Errors
|
|
57
|
+
|
|
58
|
+
All raise subclasses of `chemetrian.ChemetrianError`: `ValidationError` (client-side),
|
|
59
|
+
`AuthError` (401), `InsufficientCreditsError` (402), `NotFoundError` (404),
|
|
60
|
+
`RateLimitError` (429, auto-retried), `JobFailedError`, `JobTimeoutError`.
|
|
61
|
+
|
|
62
|
+
## Configuration
|
|
63
|
+
|
|
64
|
+
| arg / env | purpose |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `api_key` / `CHEMETRIAN_API_KEY` | your key |
|
|
67
|
+
| `base_url` / `CHEMETRIAN_API_URL` | API host (QA vs prod) |
|
|
68
|
+
| `poll_interval`, `poll_timeout` | how long the SDK waits for a job |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "chemetrian"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Official Python SDK for the Chemetrian API (Bayesian Optimization, and more)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
authors = ["Chemetrian <dev@chemetrian.com>"]
|
|
8
|
+
homepage = "https://www.chemetrian.com"
|
|
9
|
+
repository = "https://gitlab.com/patel.simd/chemetrian-python-sdk"
|
|
10
|
+
keywords = ["chemetrian", "bayesian-optimization", "chemistry", "api-client"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Science/Research",
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
17
|
+
]
|
|
18
|
+
packages = [{ include = "chemetrian", from = "src" }]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.dependencies]
|
|
21
|
+
python = "^3.9"
|
|
22
|
+
requests = "^2.28"
|
|
23
|
+
pandas = ">=1.5"
|
|
24
|
+
|
|
25
|
+
# Dev-only — NOT installed for partners (kept out of the published package). `jupyter` is a
|
|
26
|
+
# meta-package that pulls in the whole notebook stack; it belongs here, not in runtime deps.
|
|
27
|
+
[tool.poetry.group.dev.dependencies]
|
|
28
|
+
pytest = "^7"
|
|
29
|
+
responses = "^0.23"
|
|
30
|
+
jupyter = "^1.1.1"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["poetry-core>=1.8.0"]
|
|
34
|
+
build-backend = "poetry.core.masonry.api"
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
# Default run (`poetry run pytest` from the SDK root) is the MOCKED suite only. e2e/ is a
|
|
38
|
+
# LIVE suite with its own e2e/pytest.ini (its own rootdir) and must never be collected here.
|
|
39
|
+
testpaths = ["tests"]
|
|
40
|
+
markers = [
|
|
41
|
+
"e2e: LIVE end-to-end test against a real backend (defined for the e2e/ suite).",
|
|
42
|
+
"slow: submits REAL compute and polls for minutes (e2e/ suite).",
|
|
43
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Official Python SDK for the Chemetrian API."""
|
|
2
|
+
|
|
3
|
+
from .client import Chemetrian
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ChemetrianError,
|
|
6
|
+
ValidationError,
|
|
7
|
+
APIError,
|
|
8
|
+
AuthError,
|
|
9
|
+
InsufficientCreditsError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
RateLimitError,
|
|
13
|
+
JobFailedError,
|
|
14
|
+
JobTimeoutError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Chemetrian",
|
|
21
|
+
"ChemetrianError",
|
|
22
|
+
"ValidationError",
|
|
23
|
+
"APIError",
|
|
24
|
+
"AuthError",
|
|
25
|
+
"InsufficientCreditsError",
|
|
26
|
+
"NotFoundError",
|
|
27
|
+
"ConflictError",
|
|
28
|
+
"RateLimitError",
|
|
29
|
+
"JobFailedError",
|
|
30
|
+
"JobTimeoutError",
|
|
31
|
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Internal HTTP transport for the SDK.
|
|
2
|
+
|
|
3
|
+
Not part of the public API — it just knows the base URL, attaches the Bearer key, maps
|
|
4
|
+
error responses to typed exceptions, retries on 429 (honoring Retry-After), and polls jobs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
APIError,
|
|
14
|
+
AuthError,
|
|
15
|
+
InsufficientCreditsError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
ConflictError,
|
|
18
|
+
RateLimitError,
|
|
19
|
+
JobFailedError,
|
|
20
|
+
JobTimeoutError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_BASE_URL = "https://www.chemetrian.com" # prod; QA is https://qa.chemetrian.com
|
|
24
|
+
_API_PREFIX = "/api/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HttpClient:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
api_key: str,
|
|
31
|
+
base_url: str | None = None,
|
|
32
|
+
timeout: float = 30.0,
|
|
33
|
+
max_retries: int = 5,
|
|
34
|
+
):
|
|
35
|
+
self.api_key = api_key
|
|
36
|
+
self.base_url = (
|
|
37
|
+
base_url or os.environ.get("CHEMETRIAN_API_URL") or DEFAULT_BASE_URL
|
|
38
|
+
).rstrip("/")
|
|
39
|
+
self.timeout = timeout
|
|
40
|
+
self.max_retries = max_retries
|
|
41
|
+
self.session = requests.Session()
|
|
42
|
+
self.session.headers.update(
|
|
43
|
+
{"Authorization": f"Bearer {api_key}", "Accept": "application/json"}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def request(self, method: str, path: str, json=None, params=None) -> dict:
|
|
47
|
+
url = f"{self.base_url}{_API_PREFIX}{path}"
|
|
48
|
+
attempt = 0
|
|
49
|
+
while True:
|
|
50
|
+
resp = self.session.request(
|
|
51
|
+
method, url, json=json, params=params, timeout=self.timeout
|
|
52
|
+
)
|
|
53
|
+
# Auto-backoff on rate limiting so honest callers don't have to.
|
|
54
|
+
if resp.status_code == 429 and attempt < self.max_retries:
|
|
55
|
+
time.sleep(self._retry_after(resp, default=1.0))
|
|
56
|
+
attempt += 1
|
|
57
|
+
continue
|
|
58
|
+
return self._handle(resp)
|
|
59
|
+
|
|
60
|
+
def poll_job(
|
|
61
|
+
self, job_id: str, interval: float = 2.0, timeout: float = 1800.0
|
|
62
|
+
) -> dict:
|
|
63
|
+
"""Poll GET /jobs/{id} until it finishes; return the result dict."""
|
|
64
|
+
start = time.monotonic()
|
|
65
|
+
while True:
|
|
66
|
+
result = self.request("GET", f"/jobs/{job_id}")
|
|
67
|
+
status = result.get("status")
|
|
68
|
+
if status == "finished":
|
|
69
|
+
return result
|
|
70
|
+
if status == "failed":
|
|
71
|
+
raise JobFailedError(f"Job {job_id} finished in a failed state.")
|
|
72
|
+
if time.monotonic() - start > timeout:
|
|
73
|
+
raise JobTimeoutError(
|
|
74
|
+
f"Job {job_id} did not finish within {timeout:.0f}s."
|
|
75
|
+
)
|
|
76
|
+
time.sleep(interval)
|
|
77
|
+
|
|
78
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
79
|
+
def _handle(self, resp) -> dict:
|
|
80
|
+
if 200 <= resp.status_code < 300:
|
|
81
|
+
return resp.json() if resp.content else {}
|
|
82
|
+
detail = self._detail(resp)
|
|
83
|
+
code = resp.status_code
|
|
84
|
+
if code == 401:
|
|
85
|
+
raise AuthError(detail, 401)
|
|
86
|
+
if code == 402:
|
|
87
|
+
raise InsufficientCreditsError(detail, 402)
|
|
88
|
+
if code == 404:
|
|
89
|
+
raise NotFoundError(detail, 404)
|
|
90
|
+
if code == 409:
|
|
91
|
+
raise ConflictError(detail, 409)
|
|
92
|
+
if code == 429:
|
|
93
|
+
raise RateLimitError(detail, retry_after=self._retry_after(resp))
|
|
94
|
+
raise APIError(detail, code)
|
|
95
|
+
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _retry_after(resp, default: float = 0.0) -> float:
|
|
98
|
+
try:
|
|
99
|
+
return float(resp.headers.get("Retry-After", default) or default)
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
return default
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _detail(resp) -> str:
|
|
105
|
+
try:
|
|
106
|
+
data = resp.json()
|
|
107
|
+
d = data.get("detail")
|
|
108
|
+
if isinstance(d, str):
|
|
109
|
+
return d
|
|
110
|
+
return str(d) if d else (resp.text or f"HTTP {resp.status_code}")
|
|
111
|
+
except ValueError:
|
|
112
|
+
return resp.text or f"HTTP {resp.status_code}"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Client-side validation — fail fast on obvious mistakes before hitting the network.
|
|
2
|
+
|
|
3
|
+
This is a convenience/UX layer (better errors, less wasted traffic for honest callers).
|
|
4
|
+
It is NOT a security boundary — the server validates everything independently.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .exceptions import ValidationError
|
|
8
|
+
|
|
9
|
+
VALID_FEATURE_TYPES = {"DISCRETE_NUMERICAL", "CONTINUOUS_NUMERICAL", "CATEGORICAL"}
|
|
10
|
+
VALID_GOALS = {"maximize", "minimize"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_features(features: list) -> None:
|
|
14
|
+
if not features:
|
|
15
|
+
raise ValidationError("At least one feature is required.")
|
|
16
|
+
for f in features:
|
|
17
|
+
name = f.get("feature_name")
|
|
18
|
+
if not name:
|
|
19
|
+
raise ValidationError("Every feature needs a 'feature_name'.")
|
|
20
|
+
ftype = f.get("feature_type")
|
|
21
|
+
if ftype not in VALID_FEATURE_TYPES:
|
|
22
|
+
raise ValidationError(
|
|
23
|
+
f"Feature '{name}': feature_type must be one of "
|
|
24
|
+
f"{sorted(VALID_FEATURE_TYPES)}."
|
|
25
|
+
)
|
|
26
|
+
if ftype == "CONTINUOUS_NUMERICAL":
|
|
27
|
+
for k in ("lower_bound", "upper_bound", "step_size"):
|
|
28
|
+
if f.get(k) is None:
|
|
29
|
+
raise ValidationError(
|
|
30
|
+
f"Feature '{name}' (CONTINUOUS_NUMERICAL) needs '{k}'."
|
|
31
|
+
)
|
|
32
|
+
if f["lower_bound"] >= f["upper_bound"]:
|
|
33
|
+
raise ValidationError(
|
|
34
|
+
f"Feature '{name}': lower_bound must be < upper_bound."
|
|
35
|
+
)
|
|
36
|
+
if f["step_size"] <= 0:
|
|
37
|
+
raise ValidationError(f"Feature '{name}': step_size must be > 0.")
|
|
38
|
+
else: # DISCRETE_NUMERICAL / CATEGORICAL
|
|
39
|
+
if not f.get("feature_values"):
|
|
40
|
+
raise ValidationError(
|
|
41
|
+
f"Feature '{name}' ({ftype}) needs non-empty 'feature_values'."
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def validate_targets(targets: list) -> None:
|
|
46
|
+
if not targets:
|
|
47
|
+
raise ValidationError("At least one target is required.")
|
|
48
|
+
for t in targets:
|
|
49
|
+
if not t.get("target_name"):
|
|
50
|
+
raise ValidationError("Every target needs a 'target_name'.")
|
|
51
|
+
if (t.get("goal") or "").lower() not in VALID_GOALS:
|
|
52
|
+
raise ValidationError(
|
|
53
|
+
f"Target '{t.get('target_name')}': goal must be "
|
|
54
|
+
"'maximize' or 'minimize'."
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_batch_size(batch_size) -> None:
|
|
59
|
+
if not isinstance(batch_size, int) or batch_size <= 0:
|
|
60
|
+
raise ValidationError("batch_size must be a positive integer.")
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Bayesian Optimization service — the `client.bayesian_optimization` namespace.
|
|
2
|
+
|
|
3
|
+
Each optimization call submits a job and blocks (polling) until it finishes, then returns
|
|
4
|
+
a pandas DataFrame of experiments. The async job/poll is hidden from the caller.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from . import _validation
|
|
12
|
+
from .exceptions import JobFailedError, JobTimeoutError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _staged_poll_delay(elapsed: float) -> float:
|
|
16
|
+
"""Backoff cadence for long-running jobs (mirrors the web app's polling): fast early,
|
|
17
|
+
gentle later. elapsed is seconds since the poll started."""
|
|
18
|
+
if elapsed <= 10:
|
|
19
|
+
return 1.0
|
|
20
|
+
if elapsed <= 30:
|
|
21
|
+
return 2.0
|
|
22
|
+
if elapsed <= 210:
|
|
23
|
+
return 5.0
|
|
24
|
+
if elapsed <= 1800:
|
|
25
|
+
return 30.0
|
|
26
|
+
return 180.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _to_records(experiments) -> list:
|
|
30
|
+
"""Accept a DataFrame or a list of row-dicts; return a list of row-dicts."""
|
|
31
|
+
if isinstance(experiments, pd.DataFrame):
|
|
32
|
+
return experiments.to_dict(orient="records")
|
|
33
|
+
if isinstance(experiments, list):
|
|
34
|
+
return experiments
|
|
35
|
+
raise _validation.ValidationError(
|
|
36
|
+
"experiments must be a pandas DataFrame or a list of row dicts."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BayesianOptimizationService:
|
|
41
|
+
def __init__(
|
|
42
|
+
self, http, poll_interval: float | None = None, poll_timeout: float = 1800.0
|
|
43
|
+
):
|
|
44
|
+
self._http = http
|
|
45
|
+
# None -> staged backoff (fast early, gentle later). A number -> fixed interval.
|
|
46
|
+
self._poll_interval = poll_interval
|
|
47
|
+
self._poll_timeout = poll_timeout
|
|
48
|
+
|
|
49
|
+
def initialize(
|
|
50
|
+
self,
|
|
51
|
+
project: str,
|
|
52
|
+
workflow_name: str,
|
|
53
|
+
features: list,
|
|
54
|
+
targets: list,
|
|
55
|
+
batch_size: int,
|
|
56
|
+
exploit_explore_strategy: str = "balanced",
|
|
57
|
+
wait: bool = True,
|
|
58
|
+
):
|
|
59
|
+
"""Start a campaign. With wait=True (default) block until the first batch is ready
|
|
60
|
+
and return it as a DataFrame; with wait=False return the submit handle."""
|
|
61
|
+
_validation.validate_features(features)
|
|
62
|
+
_validation.validate_targets(targets)
|
|
63
|
+
_validation.validate_batch_size(batch_size)
|
|
64
|
+
body = {
|
|
65
|
+
"workflow_name": workflow_name,
|
|
66
|
+
"features": features,
|
|
67
|
+
"targets": targets,
|
|
68
|
+
"batch_size": batch_size,
|
|
69
|
+
"exploit_explore_strategy": exploit_explore_strategy,
|
|
70
|
+
}
|
|
71
|
+
submit = self._http.request(
|
|
72
|
+
"POST", f"/projects/{project}/bo/initialize", json=body
|
|
73
|
+
)
|
|
74
|
+
return self._finish(project, workflow_name, submit, wait)
|
|
75
|
+
|
|
76
|
+
def initialize_with_experiments(
|
|
77
|
+
self,
|
|
78
|
+
project: str,
|
|
79
|
+
workflow_name: str,
|
|
80
|
+
experiments,
|
|
81
|
+
targets: list,
|
|
82
|
+
batch_size: int,
|
|
83
|
+
features: list | None = None,
|
|
84
|
+
exploit_explore_strategy: str = "balanced",
|
|
85
|
+
wait: bool = True,
|
|
86
|
+
):
|
|
87
|
+
"""Seed a campaign from existing measured experiments. Omit `features` to
|
|
88
|
+
auto-detect the parameter space from the data. See `wait`."""
|
|
89
|
+
_validation.validate_targets(targets)
|
|
90
|
+
_validation.validate_batch_size(batch_size)
|
|
91
|
+
if features is not None:
|
|
92
|
+
_validation.validate_features(features)
|
|
93
|
+
body = {
|
|
94
|
+
"workflow_name": workflow_name,
|
|
95
|
+
"targets": targets,
|
|
96
|
+
"batch_size": batch_size,
|
|
97
|
+
"experiments": _to_records(experiments),
|
|
98
|
+
"features": features,
|
|
99
|
+
"exploit_explore_strategy": exploit_explore_strategy,
|
|
100
|
+
}
|
|
101
|
+
submit = self._http.request(
|
|
102
|
+
"POST",
|
|
103
|
+
f"/projects/{project}/bo/initialize-with-experiments",
|
|
104
|
+
json=body,
|
|
105
|
+
)
|
|
106
|
+
return self._finish(project, workflow_name, submit, wait)
|
|
107
|
+
|
|
108
|
+
def run_round(
|
|
109
|
+
self,
|
|
110
|
+
project: str,
|
|
111
|
+
workflow_name: str,
|
|
112
|
+
experiments,
|
|
113
|
+
batch_size: int,
|
|
114
|
+
wait: bool = True,
|
|
115
|
+
):
|
|
116
|
+
"""Submit the returned frame with measured target values filled in; get the next
|
|
117
|
+
suggested batch back. See `wait`."""
|
|
118
|
+
_validation.validate_batch_size(batch_size)
|
|
119
|
+
body = {"experiments": _to_records(experiments), "batch_size": batch_size}
|
|
120
|
+
submit = self._http.request(
|
|
121
|
+
"POST",
|
|
122
|
+
f"/projects/{project}/workflows/{workflow_name}/rounds",
|
|
123
|
+
json=body,
|
|
124
|
+
)
|
|
125
|
+
return self._finish(project, workflow_name, submit, wait)
|
|
126
|
+
|
|
127
|
+
def list_workflows(self, project: str, job_type: str | None = None) -> list:
|
|
128
|
+
"""List campaigns in a project, grouped by job type."""
|
|
129
|
+
params = {"job_type": job_type} if job_type else None
|
|
130
|
+
resp = self._http.request(
|
|
131
|
+
"GET", f"/projects/{project}/workflows", params=params
|
|
132
|
+
)
|
|
133
|
+
return resp.get("workflows", [])
|
|
134
|
+
|
|
135
|
+
def get_global_shap(self, project: str, workflow_name: str) -> pd.DataFrame:
|
|
136
|
+
"""Global feature importance for the latest completed round, as signed
|
|
137
|
+
contributions in [-1, 1] (sign = direction, magnitude = strength). Returns a
|
|
138
|
+
DataFrame indexed by feature, one column per target. Empty for a round with no
|
|
139
|
+
model yet (a pure initialize)."""
|
|
140
|
+
resp = self._http.request(
|
|
141
|
+
"GET", f"/projects/{project}/workflows/{workflow_name}/global-shap"
|
|
142
|
+
)
|
|
143
|
+
return pd.DataFrame(resp.get("globalShap") or {})
|
|
144
|
+
|
|
145
|
+
def get_workflow_results(
|
|
146
|
+
self,
|
|
147
|
+
project: str,
|
|
148
|
+
workflow_name: str,
|
|
149
|
+
include_all: bool = False,
|
|
150
|
+
include_shap: bool = False,
|
|
151
|
+
):
|
|
152
|
+
"""The single poller + recovery call. Returns the latest round's DataFrame if it
|
|
153
|
+
has finished, else a status dict `{"status": ..., "round": ...}`.
|
|
154
|
+
|
|
155
|
+
include_all=True returns EVERY candidate (priority 0/-1/1) with predictions, so you
|
|
156
|
+
can pick or update an experiment outside the suggested batch. Off by default because
|
|
157
|
+
it can be large for big design spaces.
|
|
158
|
+
include_shap=True keeps the per-row SHAP columns (per-experiment feature
|
|
159
|
+
attributions), dropped by default. For aggregate importance use get_global_shap."""
|
|
160
|
+
raw = self._fetch_workflow_result(
|
|
161
|
+
project, workflow_name, include_all, include_shap
|
|
162
|
+
)
|
|
163
|
+
if raw.get("status") == "finished":
|
|
164
|
+
return pd.DataFrame(raw.get("experiments") or [])
|
|
165
|
+
return {"status": raw.get("status"), "round": raw.get("round")}
|
|
166
|
+
|
|
167
|
+
# ── internals ────────────────────────────────────────────────────────────
|
|
168
|
+
def _fetch_workflow_result(
|
|
169
|
+
self,
|
|
170
|
+
project: str,
|
|
171
|
+
workflow_name: str,
|
|
172
|
+
include_all: bool = False,
|
|
173
|
+
include_shap: bool = False,
|
|
174
|
+
) -> dict:
|
|
175
|
+
params = {}
|
|
176
|
+
if include_all:
|
|
177
|
+
params["include_all"] = "true"
|
|
178
|
+
if include_shap:
|
|
179
|
+
params["include_shap"] = "true"
|
|
180
|
+
return self._http.request(
|
|
181
|
+
"GET",
|
|
182
|
+
f"/projects/{project}/workflows/{workflow_name}/results",
|
|
183
|
+
params=params or None,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def _finish(self, project: str, workflow_name: str, submit: dict, wait: bool):
|
|
187
|
+
if not wait:
|
|
188
|
+
return submit
|
|
189
|
+
return self._poll_workflow(project, workflow_name)
|
|
190
|
+
|
|
191
|
+
def _poll_workflow(self, project: str, workflow_name: str) -> pd.DataFrame:
|
|
192
|
+
start = time.monotonic()
|
|
193
|
+
while True:
|
|
194
|
+
raw = self._fetch_workflow_result(project, workflow_name)
|
|
195
|
+
status = raw.get("status")
|
|
196
|
+
if status == "finished":
|
|
197
|
+
return pd.DataFrame(raw.get("experiments") or [])
|
|
198
|
+
if status == "failed":
|
|
199
|
+
raise JobFailedError(
|
|
200
|
+
f"The latest round of '{workflow_name}' finished in a failed state."
|
|
201
|
+
)
|
|
202
|
+
elapsed = time.monotonic() - start
|
|
203
|
+
if elapsed > self._poll_timeout:
|
|
204
|
+
raise JobTimeoutError(
|
|
205
|
+
f"'{workflow_name}' did not finish within "
|
|
206
|
+
f"{self._poll_timeout:.0f}s."
|
|
207
|
+
)
|
|
208
|
+
delay = (
|
|
209
|
+
self._poll_interval
|
|
210
|
+
if self._poll_interval is not None
|
|
211
|
+
else _staged_poll_delay(elapsed)
|
|
212
|
+
)
|
|
213
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The top-level Chemetrian client.
|
|
2
|
+
|
|
3
|
+
Multi-service by design: each service hangs off the client (`client.bayesian_optimization`),
|
|
4
|
+
so new services slot in later without changing existing code. Cross-service resources
|
|
5
|
+
(projects) live directly on the client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
from ._http import HttpClient
|
|
11
|
+
from .bayesian_optimization import BayesianOptimizationService
|
|
12
|
+
from .exceptions import ValidationError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Chemetrian:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
api_key: str | None = None,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
timeout: float = 30.0,
|
|
21
|
+
poll_interval: float | None = None,
|
|
22
|
+
poll_timeout: float = 1800.0,
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Args:
|
|
26
|
+
api_key: your `sk_live_…` key. Falls back to the CHEMETRIAN_API_KEY env var.
|
|
27
|
+
base_url: API host. Defaults to production; pass a QA host or set
|
|
28
|
+
CHEMETRIAN_API_URL to point elsewhere.
|
|
29
|
+
timeout: per-request timeout (seconds).
|
|
30
|
+
poll_interval: seconds between polls while waiting for a job. Default (None)
|
|
31
|
+
uses a staged backoff (1s early → up to 3min for long jobs); pass a number
|
|
32
|
+
for a fixed interval.
|
|
33
|
+
poll_timeout: give up waiting after this many seconds.
|
|
34
|
+
"""
|
|
35
|
+
api_key = api_key or os.environ.get("CHEMETRIAN_API_KEY")
|
|
36
|
+
if not api_key:
|
|
37
|
+
raise ValidationError(
|
|
38
|
+
"An API key is required — pass api_key=… or set CHEMETRIAN_API_KEY."
|
|
39
|
+
)
|
|
40
|
+
if not api_key.startswith("sk_"):
|
|
41
|
+
raise ValidationError(
|
|
42
|
+
"API key looks malformed (expected it to start with 'sk_')."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
self._http = HttpClient(api_key, base_url=base_url, timeout=timeout)
|
|
46
|
+
|
|
47
|
+
# Services
|
|
48
|
+
self.bayesian_optimization = BayesianOptimizationService(
|
|
49
|
+
self._http, poll_interval=poll_interval, poll_timeout=poll_timeout
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# ── projects (cross-service) ─────────────────────────────────────────────
|
|
53
|
+
def list_projects(self) -> list:
|
|
54
|
+
return self._http.request("GET", "/projects").get("projects", [])
|
|
55
|
+
|
|
56
|
+
def create_project(self, name: str, description: str = "") -> dict:
|
|
57
|
+
return self._http.request(
|
|
58
|
+
"POST", "/projects", json={"name": name, "description": description}
|
|
59
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Exception hierarchy for the Chemetrian SDK.
|
|
2
|
+
|
|
3
|
+
All SDK errors derive from ChemetrianError, so callers can catch broadly or narrowly.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ChemetrianError(Exception):
|
|
8
|
+
"""Base class for all SDK errors."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ValidationError(ChemetrianError):
|
|
12
|
+
"""A request failed client-side validation before it was ever sent."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class APIError(ChemetrianError):
|
|
16
|
+
"""The API returned an error response."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
self.status_code = status_code
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AuthError(APIError):
|
|
24
|
+
"""401 — missing / invalid / expired / revoked API key."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InsufficientCreditsError(APIError):
|
|
28
|
+
"""402 — the owner account is out of credits."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NotFoundError(APIError):
|
|
32
|
+
"""404 — project / workflow / job not found in this workspace."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ConflictError(APIError):
|
|
36
|
+
"""409 — already exists (e.g. a campaign name is taken)."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RateLimitError(APIError):
|
|
40
|
+
"""429 — rate limit or concurrency cap exceeded."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, retry_after: float | None = None):
|
|
43
|
+
super().__init__(message, status_code=429)
|
|
44
|
+
self.retry_after = retry_after
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JobFailedError(ChemetrianError):
|
|
48
|
+
"""A submitted optimization job finished in a failed state."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class JobTimeoutError(ChemetrianError):
|
|
52
|
+
"""A job did not finish within the client's poll timeout."""
|