alchemist-nrel 0.3.0__py3-none-any.whl → 0.3.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.
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.4
2
+ Name: alchemist-nrel
3
+ Version: 0.3.1
4
+ Summary: Active learning and optimization toolkit for chemical and materials research
5
+ Author-email: Caleb Coatney <caleb.coatney@nrel.gov>
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://github.com/NREL/ALchemist
8
+ Project-URL: Documentation, https://nrel.github.io/ALchemist/
9
+ Project-URL: Source, https://github.com/NREL/ALchemist
10
+ Project-URL: Bug Tracker, https://github.com/NREL/ALchemist/issues
11
+ Project-URL: Changelog, https://github.com/NREL/ALchemist/releases
12
+ Keywords: active learning,bayesian optimization,gaussian processes,materials science,chemistry
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: License :: OSI Approved :: BSD License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
22
+ Requires-Python: >=3.11
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy
26
+ Requires-Dist: pandas
27
+ Requires-Dist: scipy
28
+ Requires-Dist: matplotlib
29
+ Requires-Dist: mplcursors
30
+ Requires-Dist: scikit-learn
31
+ Requires-Dist: scikit-optimize
32
+ Requires-Dist: botorch
33
+ Requires-Dist: torch
34
+ Requires-Dist: gpytorch
35
+ Requires-Dist: ax-platform
36
+ Requires-Dist: customtkinter
37
+ Requires-Dist: tksheet
38
+ Requires-Dist: tabulate
39
+ Requires-Dist: ctkmessagebox
40
+ Requires-Dist: joblib
41
+ Requires-Dist: fastapi>=0.109.0
42
+ Requires-Dist: uvicorn[standard]>=0.27.0
43
+ Requires-Dist: pydantic>=2.5.0
44
+ Requires-Dist: python-multipart>=0.0.6
45
+ Requires-Dist: requests
46
+ Provides-Extra: test
47
+ Requires-Dist: pytest>=8.0.0; extra == "test"
48
+ Requires-Dist: pytest-cov>=4.0.0; extra == "test"
49
+ Requires-Dist: pytest-anyio>=0.0.0; extra == "test"
50
+ Requires-Dist: httpx>=0.25.0; extra == "test"
51
+ Requires-Dist: requests>=2.31.0; extra == "test"
52
+ Provides-Extra: dev
53
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
54
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
55
+ Requires-Dist: pytest-anyio>=0.0.0; extra == "dev"
56
+ Requires-Dist: httpx>=0.25.0; extra == "dev"
57
+ Requires-Dist: requests>=2.31.0; extra == "dev"
58
+ Dynamic: license-file
59
+
60
+ <img src="docs/assets/NEW_LOGO_LIGHT.png" alt="ALchemist" width="50%" />
61
+
62
+ **ALchemist: Active Learning Toolkit for Chemical and Materials Research**
63
+
64
+ ALchemist is a modular Python toolkit that brings active learning and Bayesian optimization to experimental design in chemical and materials research. It is designed for scientists and engineers who want to efficiently explore or optimize high-dimensional variable spaces—using intuitive graphical interfaces, programmatic APIs, or autonomous optimization workflows.
65
+
66
+ **NLR Software Record:** SWR-25-102
67
+
68
+ ---
69
+
70
+ ## Documentation
71
+
72
+ Full user guide and documentation:
73
+ [https://nrel.github.io/ALchemist/](https://nrel.github.io/ALchemist/)
74
+
75
+ ---
76
+
77
+ ## Overview
78
+
79
+ **Key Features:**
80
+
81
+ - **Flexible variable space definition**: Real, integer, and categorical variables with bounds or discrete values
82
+ - **Probabilistic surrogate modeling**: Gaussian process regression via BoTorch or scikit-learn backends
83
+ - **Advanced acquisition strategies**: Efficient sampling using qEI, qPI, qUCB, and qNegIntegratedPosteriorVariance
84
+ - **Modern web interface**: React-based UI with FastAPI backend for seamless active learning workflows
85
+ - **Desktop GUI**: CustomTkinter desktop application for offline optimization
86
+ - **Session management**: Save/load optimization sessions with audit logs for reproducibility
87
+ - **Multiple interfaces**: No-code GUI, Python Session API, or REST API for different use cases
88
+ - **Autonomous optimization**: Human-out-of-the-loop operation for real-time process control
89
+ - **Experiment tracking**: CSV logging, reproducible random seeds, and comprehensive audit trails
90
+ - **Extensibility**: Abstract interfaces for models and acquisition functions enable future backend and workflow expansion
91
+ **Architecture:**
92
+
93
+ ALchemist is built on a clean, modular architecture:
94
+
95
+ - **Core Session API**: Headless Bayesian optimization engine (`alchemist_core`) that powers all interfaces
96
+ - **Desktop Application**: CustomTkinter GUI using the Core Session API, designed for human-in-the-loop and offline optimization
97
+ - **REST API**: FastAPI server providing a thin wrapper around the Core Session API for remote access
98
+ - **Web Application**: React UI consuming the REST API, supporting both interactive and autonomous optimization workflows
99
+
100
+ Session files (JSON format) are fully interoperable between desktop and web applications, enabling seamless workflow transitions.
101
+
102
+ ---
103
+
104
+ ## Use Cases
105
+
106
+ - **Interactive Optimization**: Use desktop or web GUI for manual experiment design and human-in-the-loop optimization
107
+ - **Programmatic Workflows**: Import the Session API in Python scripts or Jupyter notebooks for batch processing
108
+ - **Autonomous Optimization**: Use the REST API to integrate ALchemist with automated laboratory equipment for real-time process control
109
+ - **Remote Monitoring**: Web dashboard provides read-only monitoring mode when ALchemist is being remote-controlled
110
+
111
+ For detailed application examples, see [Use Cases](https://nrel.github.io/ALchemist/use_cases/) in the documentation.
112
+
113
+ ---
114
+
115
+ ## Installation
116
+
117
+ **Requirements:** Python 3.11 or higher
118
+
119
+ **Recommended (Optional):** We recommend using [Anaconda](https://www.anaconda.com/products/distribution) to manage Python environments:
120
+
121
+ ```bash
122
+ conda create -n alchemist-env python=3.11
123
+ conda activate alchemist-env
124
+ ```
125
+
126
+ **Basic Installation:**
127
+
128
+ ```bash
129
+ pip install alchemist-nrel
130
+ ```
131
+
132
+ **From GitHub:**
133
+ > *Note: This installs the latest unreleased version. The web application is not pre-built with this method because static build files are not included in the repository.*
134
+
135
+ ```bash
136
+ pip install git+https://github.com/NREL/ALchemist.git
137
+ ```
138
+
139
+ For advanced installation options, Docker deployment, and development setup, see the [Advanced Installation Guide](https://nrel.github.io/ALchemist/#advanced-installation) in the documentation.
140
+
141
+ ---
142
+
143
+ ## Running ALchemist
144
+
145
+ **Web Application:**
146
+ ```bash
147
+ alchemist-web
148
+ ```
149
+ Opens at [http://localhost:8000/app](http://localhost:8000/app)
150
+
151
+ **Desktop Application:**
152
+ ```bash
153
+ alchemist
154
+ ```
155
+
156
+ For detailed usage instructions, see [Getting Started](https://nrel.github.io/ALchemist/) in the documentation.
157
+
158
+ ---
159
+
160
+ ## Development Status
161
+
162
+ ALchemist is under active development at NLR as part of the DataHub project within the ChemCatBio consortium.
163
+
164
+ ---
165
+
166
+ ## Issues & Troubleshooting
167
+
168
+ If you encounter any issues or have questions, please [open an issue on GitHub](https://github.com/NREL/ALchemist/issues) or contact ccoatney@nrel.gov.
169
+
170
+ For the latest known issues and troubleshooting tips, see the [Issues & Troubleshooting Log](docs/ISSUES_LOG.md).
171
+
172
+ We appreciate your feedback and bug reports to help improve ALchemist!
173
+
174
+ ---
175
+
176
+ ## License
177
+
178
+ This project is licensed under the BSD 3-Clause License. See the [LICENSE](LICENSE) file for details.
179
+
180
+ ---
181
+
182
+ ## Repository
183
+
184
+ [https://github.com/NREL/ALchemist](https://github.com/NREL/ALchemist)
185
+
@@ -1,52 +1,52 @@
1
1
  main.py,sha256=3sAO2QZxxibs4WRT82i2w6KVBBFmYEMNUoGiMYFowOw,126
2
- run_api.py,sha256=-oxrFnDztyI0rfvZv0-QTBAbgW3OcCArdqg3c5l18qs,1849
3
2
  alchemist_core/__init__.py,sha256=jYIygJyhCXUmX3oAaxw687uLLQcRSuxNRQrMeuWuuuI,2023
4
3
  alchemist_core/audit_log.py,sha256=s8h3YKBgvcu_tgIrjP69rNr6yOnbks5J2RR_m2bwB4Q,22531
5
4
  alchemist_core/config.py,sha256=Sk5eM1okktO5bUMlMPv9yzF2fpuiyGr9LUtlCWIBDc8,3366
6
5
  alchemist_core/events.py,sha256=ty9nRzfZGHzk6b09dALIwrMY_5PYSv0wMaw94JLDjSk,6717
7
- alchemist_core/session.py,sha256=UCjMEWTWPY9ywsuS1meWGF9hqVse7DaFFnhr2oW9qwc,48228
6
+ alchemist_core/session.py,sha256=OraGE2y78s9cbhfpxr4jllfet-GclCfTj0yV1D9S_2M,55330
8
7
  alchemist_core/acquisition/__init__.py,sha256=3CYGI24OTBS66ETrlGFyHCNpfS6DBMP41MZDhvjFEzg,32
9
8
  alchemist_core/acquisition/base_acquisition.py,sha256=s51vGx0b0Nt91lSCiVwYP9IClugVg2VJ21dn2n_4LIs,483
10
- alchemist_core/acquisition/botorch_acquisition.py,sha256=dGzXY7XMbrRzOIFC4UoA6mMYEDplKgb58ym_TzmiMss,31332
9
+ alchemist_core/acquisition/botorch_acquisition.py,sha256=r2Z510UIrPZCS-uXWZzgtaNhmCUh0CsTrAJQCWdevxA,31401
11
10
  alchemist_core/acquisition/skopt_acquisition.py,sha256=YRdANqgiN3GWd4sn16oruN6jVnI4RLmvLhBMUfYyLp4,13115
12
11
  alchemist_core/data/__init__.py,sha256=wgEb03x0RzVCi0uJXOzEKXkbA2oNHom5EgSB3tKgl1E,256
13
- alchemist_core/data/experiment_manager.py,sha256=gyccrEq9ddTdKZ4zmCfGXB2bK7zZaCjL9a0FzZ9-eoY,8723
12
+ alchemist_core/data/experiment_manager.py,sha256=LujWfFRlOSP7rok8sxTc6NTjetN7u7RnsOo5W2I5o-w,9013
14
13
  alchemist_core/data/search_space.py,sha256=oA9YEF3JRWpRklHzSo_Uxlmfy7bHwZfLZFDf4_nl4ew,6230
15
14
  alchemist_core/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- alchemist_core/models/ax_model.py,sha256=Fnu19Et376WxLXpApZSalIbWHQE9OQnMk41_PW69XnQ,6040
17
15
  alchemist_core/models/base_model.py,sha256=gIpC2eoTZcp4ozI0Rctcxt_4bLhgaE6ALYCzvuIrMZw,3145
18
16
  alchemist_core/models/botorch_model.py,sha256=VbZjggt8PszTJeG0x1A1vGGM1kT95Bjset3HWT6PQEM,42263
19
- alchemist_core/models/sklearn_model.py,sha256=iCwZ-ZWZuYgFpRQDoWwaIBBWz9dPdf-6wNGPdUHM9cU,36698
17
+ alchemist_core/models/sklearn_model.py,sha256=XEr2yAQtVQd3beGAoQnuNmt_inzEsKAwpGo-Gfdp2vY,38059
20
18
  alchemist_core/utils/__init__.py,sha256=oQsvUqukRng8GgiZSPMM-xmB-Lv46XveJzYQr2MdkOc,99
21
19
  alchemist_core/utils/doe.py,sha256=hnrhIzm3Nz0qZBW4PXaNQ6pTDgiLn_lUhbMPmWmk_6Y,7110
22
- alchemist_nrel-0.3.0.dist-info/licenses/LICENSE,sha256=wdIWWEj59ztfQViDuT_9wG3L1K8afUpRSygimXw36wY,1511
20
+ alchemist_nrel-0.3.1.dist-info/licenses/LICENSE,sha256=wdIWWEj59ztfQViDuT_9wG3L1K8afUpRSygimXw36wY,1511
23
21
  api/__init__.py,sha256=ODc6pq4OSImgK4xvhX_zMhqUjIc7JvLfqxKF_-Ubw7g,49
24
22
  api/dependencies.py,sha256=sF1YYjnFRaw7nj7Y7URKLF2Ek-EfaXqjOyV1Zbktz2g,1090
25
- api/example_client.py,sha256=aZMNuJmt00hpNEkijn5RnVEV7ZPfKzwQxucSasTrrdw,6645
26
- api/main.py,sha256=OHs00UruREujnNT2h1lXtK_ZiNz14lwYH67kqH8o78o,4215
23
+ api/example_client.py,sha256=RjEOvZItzgmGdP6V5j106LQqmQg0WIEr72xf4oMJZHo,6924
24
+ api/main.py,sha256=Fee_u_dvNcLzNE-kRhoinfEYnIf5e-i71ZwdNOHu7hY,4301
25
+ api/run_api.py,sha256=YGyLUJNbdsDGEKsd_EsrA1gGheGW53etn8F3ku44q1g,1993
27
26
  api/middleware/__init__.py,sha256=WM4JEg3DibymvEvQ6hx_FJkv8lKLjHD48qNouSORGxA,313
28
27
  api/middleware/error_handlers.py,sha256=k5hNo6W5QDjGRHUY8Le-t7ubWkwSZBFpsTKcEF0nweI,4545
29
28
  api/models/__init__.py,sha256=YFtp8989mH9Zjzvd8W6pXklQJXTf8zWj4I2YWnLegDQ,1204
30
- api/models/requests.py,sha256=Hez7Pnk1Mm7hjsbsG1Kv7pX7imsIkqovroGRmUaIL38,9938
31
- api/models/responses.py,sha256=5jzoIm7HiLWJPJ2U_iL3ACeixWdIuFwjMnhVNE9vCRE,12789
29
+ api/models/requests.py,sha256=SvAL36-WcuaibKkI2hsNPDEpptZHw88_Oj961dD37vE,10853
30
+ api/models/responses.py,sha256=58oWYfKcQpUpa3oF_VY-QfNTaLxGu_O1cgBHB-Banjs,13704
32
31
  api/routers/__init__.py,sha256=Mhg62NdA6iaPy-L5HLVp_dd9aUHmJ72KtMSRyO2kusA,180
33
- api/routers/acquisition.py,sha256=7c9ifuCFeDj8osWRHgCjAwbXjhd3iEBlVLW23FOAZXI,5710
34
- api/routers/experiments.py,sha256=q7mgGmzHqe9wr9njSbUw5gmWt2kGoSv86-qbSakSNe0,9467
32
+ api/routers/acquisition.py,sha256=GzncAXsQzhldB-gngpMcUa5choaoAqMqLrpqszRjMFc,6683
33
+ api/routers/experiments.py,sha256=h4UI96MwSVL5VmibI-ebdsOl0f332edcifmdNL8OVFg,10329
35
34
  api/routers/models.py,sha256=32Ln0MtlnCEjfN3Q6Io_EBwwwGoJXb73UbQEMIcVGjI,3651
36
- api/routers/sessions.py,sha256=5yvNNgQzZadEq6MU-rjV1DFgtH6awyxYne5UTVZ7NCU,15541
35
+ api/routers/sessions.py,sha256=3h9veIWxx4F1bP_YgWwOCtuKIDe2e8VcaBCjQxmZT7c,19073
37
36
  api/routers/variables.py,sha256=TiByX1ITabBDdTSMGAPa1lGd0LBipNgDfmPsbvTEAdE,10108
38
37
  api/routers/visualizations.py,sha256=QPe1PkgGtzb4Oe4YYgFoi7J1oHJGS5pa1g75KKBDqUM,24180
38
+ api/routers/websocket.py,sha256=AnENOTSk8LOZ5XD_5O29srOfdQ8U136Yzw6dwMca9t4,4361
39
39
  api/services/__init__.py,sha256=0jw0tkL-8CtChv5ytdXRFeIz1OTVz7Vw1UaaDo86PQs,106
40
- api/services/session_store.py,sha256=bVzRaf4YFoNKkXuSmzs76F1qaIl_6lUUI3KZJJgVc3U,16255
40
+ api/services/session_store.py,sha256=BIGJUAiOEQ3uH40wUXuzqd7djy60SE6PQDKVBquJS9Y,18989
41
41
  api/static/NEW_ICON.ico,sha256=V4zY86qhPT24SSYK8VL5Ax5AezWxOfvfeHWBXisudOU,247870
42
42
  api/static/NEW_ICON.png,sha256=7UUPRgQ6-Ncv1xvB_57QMfrMY8xxHn16mLHc_zUGmCE,62788
43
43
  api/static/NEW_LOGO_DARK.png,sha256=O4p2tfTBuChSSPRl-Fzue1qoQdLkqXHBofNyiEzRNLs,128146
44
44
  api/static/NEW_LOGO_LIGHT.png,sha256=XBcv5-snGDTpjCrk7UfuJiFbSqrsGRafk7vNBj9eJnM,131686
45
- api/static/index.html,sha256=P9QXmIzDTIx7ybgXS7xZXUpvYsmc19j2zaXVFgIJQjQ,499
45
+ api/static/index.html,sha256=ttWE08A9TQ0ai63juHHo0SvcPWczYg4ANob2GB4ZQ5Q,499
46
46
  api/static/vite.svg,sha256=SnSK_UQ5GLsWWRyDTEAdrjPoeGGrXbrQgRw6O0qSFPs,1497
47
47
  api/static/assets/api-vcoXEqyq.js,sha256=LDSOiSvi1Zc7SRry3ldpA__egc6GAFbzQt9QzBzbTIQ,303
48
- api/static/assets/index-C0_glioA.js,sha256=PDlXBE-WtR6W_pMcuQepY5R62Ih_mQC6GLf0FuszNks,5741783
49
- api/static/assets/index-CB4V1LI5.css,sha256=s-fUWesYGl8kRqel4-sVfuc-xgbVvoJkjocPZFG1y7k,19867
48
+ api/static/assets/index-DWfIKU9j.js,sha256=raGrwAEmx2gqVz8E7HlQ8KiqOFPYOQuHpKI4rqYdydw,5745850
49
+ api/static/assets/index-sMIa_1hV.css,sha256=1-xNuB1JVE5hK0YzdpYb6ys1dMNJMDgx6IMv712yryI,20007
50
50
  ui/__init__.py,sha256=H4kWlVey7KKf3iPQi74zuM7FSOg5Gh-ii3UwSTuIp8A,1203
51
51
  ui/acquisition_panel.py,sha256=zF-mQDrs-Y7sf2GXYF-bPlO9UXZMTzYRMDN-Wn5FyWw,39647
52
52
  ui/custom_widgets.py,sha256=UXNv4DiTw3tFC0VaN1Qtcf_-9umX34uDn46-cEA6cs0,3812
@@ -59,8 +59,8 @@ ui/ui_utils.py,sha256=yud2-9LvT4XBcjTyfwUX5tYGNZRlAUVlu2YpcNY1HKA,658
59
59
  ui/utils.py,sha256=m19YFFkEUAY46YSj6S5RBmfUFjIWOk7F8CB4oKDRRZw,1078
60
60
  ui/variables_setup.py,sha256=6hphCy66uLsjIX7FjFtY6-fBfZ6cgfpviXXX9JBhuc4,23618
61
61
  ui/visualizations.py,sha256=FCpuehMi2Cf3Jpuycqoj43oJoCU-QAr8-6Sp6LCO4hE,70371
62
- alchemist_nrel-0.3.0.dist-info/METADATA,sha256=lkBbue0m5K4jyGYvhygJixkTlQhFvQ3oaQHiETdZcEs,7450
63
- alchemist_nrel-0.3.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
- alchemist_nrel-0.3.0.dist-info/entry_points.txt,sha256=asivtbePfGKa57dNbOe43lVbfN51S9cuJwfUoaE9TOs,69
65
- alchemist_nrel-0.3.0.dist-info/top_level.txt,sha256=-T0oWa1AfOHigC-CJFatoYBZapTpqTBVccZ7eelT5jY,35
66
- alchemist_nrel-0.3.0.dist-info/RECORD,,
62
+ alchemist_nrel-0.3.1.dist-info/METADATA,sha256=SxYr-5UGvB6fL_O_OvopkBMkM4QsBSWBhi_t88t0nFw,7122
63
+ alchemist_nrel-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
64
+ alchemist_nrel-0.3.1.dist-info/entry_points.txt,sha256=e2QcTxh-pidX_eJlQRk9yE3hLMYA0YnUzSh3sxqvdzY,73
65
+ alchemist_nrel-0.3.1.dist-info/top_level.txt,sha256=dwh-oxj7H6oAGYchcUDyfiu9UxxzCn4hUDx1oeM2k-8,27
66
+ alchemist_nrel-0.3.1.dist-info/RECORD,,
@@ -1,3 +1,3 @@
1
1
  [console_scripts]
2
2
  alchemist = main:main
3
- alchemist-web = run_api:main
3
+ alchemist-web = api.run_api:main
@@ -1,5 +1,4 @@
1
1
  alchemist_core
2
2
  api
3
3
  main
4
- run_api
5
4
  ui
api/example_client.py CHANGED
@@ -90,8 +90,13 @@ def main():
90
90
  json={"experiments": experiments}
91
91
  )
92
92
  response.raise_for_status()
93
- n_added = response.json()["n_added"]
94
- print(f" ✓ Added {n_added} experiments")
93
+ batch_result = response.json()
94
+ n_added = batch_result.get("n_added", len(experiments))
95
+ total_experiments = batch_result.get("n_experiments")
96
+ if total_experiments is not None and total_experiments >= n_added:
97
+ print(f" ✓ Added {n_added} experiments (total: {total_experiments})")
98
+ else:
99
+ print(f" ✓ Added {n_added} experiments")
95
100
 
96
101
  # Get data summary
97
102
  response = requests.get(f"{BASE_URL}/sessions/{session_id}/experiments/summary")
api/main.py CHANGED
@@ -16,7 +16,7 @@ from fastapi import FastAPI
16
16
  from fastapi.middleware.cors import CORSMiddleware
17
17
  from fastapi.staticfiles import StaticFiles
18
18
  from fastapi.responses import FileResponse
19
- from .routers import sessions, variables, experiments, models, acquisition, visualizations
19
+ from .routers import sessions, variables, experiments, models, acquisition, visualizations, websocket
20
20
  from .middleware.error_handlers import add_exception_handlers
21
21
  import logging
22
22
 
@@ -64,6 +64,7 @@ app.include_router(experiments.router, prefix="/api/v1/sessions", tags=["Experim
64
64
  app.include_router(models.router, prefix="/api/v1/sessions", tags=["Models"])
65
65
  app.include_router(acquisition.router, prefix="/api/v1/sessions", tags=["Acquisition"])
66
66
  app.include_router(visualizations.router, prefix="/api/v1/sessions", tags=["Visualizations"])
67
+ app.include_router(websocket.router, prefix="/api/v1", tags=["WebSocket"])
67
68
 
68
69
 
69
70
  @app.get("/")
api/models/requests.py CHANGED
@@ -93,13 +93,17 @@ class AddExperimentRequest(BaseModel):
93
93
  inputs: Dict[str, Union[float, int, str]] = Field(..., description="Variable values")
94
94
  output: Optional[float] = Field(None, description="Target/output value")
95
95
  noise: Optional[float] = Field(None, description="Measurement uncertainty")
96
+ iteration: Optional[int] = Field(None, description="Iteration number (auto-assigned if None)")
97
+ reason: Optional[str] = Field(None, description="Reason for this experiment")
96
98
 
97
99
  model_config = ConfigDict(
98
100
  json_schema_extra={
99
101
  "example": {
100
102
  "inputs": {"temperature": 350, "catalyst": "A"},
101
103
  "output": 0.85,
102
- "noise": 0.02
104
+ "noise": 0.02,
105
+ "iteration": 1,
106
+ "reason": "Initial Design"
103
107
  }
104
108
  }
105
109
  )
@@ -271,3 +275,22 @@ class LockDecisionRequest(BaseModel):
271
275
  }
272
276
  }
273
277
  )
278
+
279
+
280
+ # ============================================================
281
+ # Session Lock Models
282
+ # ============================================================
283
+
284
+ class SessionLockRequest(BaseModel):
285
+ """Request to lock a session for programmatic control."""
286
+ locked_by: str = Field(..., description="Identifier of the client locking the session")
287
+ client_id: Optional[str] = Field(None, description="Optional unique client identifier")
288
+
289
+ model_config = ConfigDict(
290
+ json_schema_extra={
291
+ "example": {
292
+ "locked_by": "Reactor Controller v1.2",
293
+ "client_id": "lab-3-workstation"
294
+ }
295
+ }
296
+ )
api/models/responses.py CHANGED
@@ -434,3 +434,26 @@ class LockDecisionResponse(BaseModel):
434
434
  }
435
435
  }
436
436
  )
437
+
438
+
439
+ # ============================================================
440
+ # Session Lock Models
441
+ # ============================================================
442
+
443
+ class SessionLockResponse(BaseModel):
444
+ """Response for session lock operations."""
445
+ locked: bool = Field(..., description="Whether the session is locked")
446
+ locked_by: Optional[str] = Field(None, description="Identifier of who locked the session")
447
+ locked_at: Optional[str] = Field(None, description="When the session was locked")
448
+ lock_token: Optional[str] = Field(None, description="Token for unlocking (only on lock)")
449
+
450
+ model_config = ConfigDict(
451
+ json_schema_extra={
452
+ "example": {
453
+ "locked": True,
454
+ "locked_by": "Reactor Controller v1.2",
455
+ "locked_at": "2025-12-04T16:30:00",
456
+ "lock_token": "550e8400-e29b-41d4-a716-446655440000"
457
+ }
458
+ }
459
+ )
@@ -59,6 +59,31 @@ async def suggest_next_experiments(
59
59
  # Convert to list of dicts
60
60
  suggestions = suggestions_df.to_dict('records')
61
61
 
62
+ # Record acquisition in audit log
63
+ if suggestions:
64
+ # Get current max iteration from experiments
65
+ iteration = None
66
+ if not session.experiment_manager.df.empty and 'Iteration' in session.experiment_manager.df.columns:
67
+ iteration = int(session.experiment_manager.df['Iteration'].max()) + 1
68
+
69
+ # Build parameters dict with only fields that exist
70
+ acq_params = {
71
+ "goal": request.goal,
72
+ "n_suggestions": request.n_suggestions
73
+ }
74
+ if request.xi is not None:
75
+ acq_params["xi"] = request.xi
76
+ if request.kappa is not None:
77
+ acq_params["kappa"] = request.kappa
78
+
79
+ session.audit_log.lock_acquisition(
80
+ strategy=request.strategy,
81
+ parameters=acq_params,
82
+ suggestions=suggestions,
83
+ iteration=iteration,
84
+ notes=f"Suggested {len(suggestions)} point(s) using {request.strategy}"
85
+ )
86
+
62
87
  logger.info(f"Generated {len(suggestions)} suggestions for session {session_id} using {request.strategy}")
63
88
 
64
89
  return AcquisitionResponse(
@@ -51,31 +51,47 @@ async def add_experiment(
51
51
  session.add_experiment(
52
52
  inputs=experiment.inputs,
53
53
  output=experiment.output,
54
- noise=experiment.noise
54
+ noise=experiment.noise,
55
+ iteration=experiment.iteration,
56
+ reason=experiment.reason
55
57
  )
56
58
 
57
59
  n_experiments = len(session.experiment_manager.df)
58
60
  logger.info(f"Added experiment to session {session_id}. Total: {n_experiments}")
59
61
 
60
- # Auto-train if requested
62
+ # Auto-train if requested (need at least 5 points to train)
61
63
  model_trained = False
62
64
  training_metrics = None
63
65
 
64
- if auto_train and n_experiments >= 5: # Minimum data for training
66
+ if auto_train and n_experiments >= 5:
65
67
  try:
66
68
  # Use previous config or provided config
67
69
  backend = training_backend or (session.model_backend if session.model else "sklearn")
68
70
  kernel = training_kernel or "rbf"
69
71
 
72
+ # Note: Input/output transforms are now automatically applied by core Session.train_model()
73
+ # for BoTorch models. No need to specify them here unless overriding defaults.
70
74
  result = session.train_model(backend=backend, kernel=kernel)
71
75
  model_trained = True
72
76
  metrics = result.get("metrics", {})
77
+ hyperparameters = result.get("hyperparameters", {})
73
78
  training_metrics = {
74
79
  "rmse": metrics.get("rmse"),
75
80
  "r2": metrics.get("r2"),
76
81
  "backend": backend
77
82
  }
78
83
  logger.info(f"Auto-trained model for session {session_id}: {training_metrics}")
84
+
85
+ # Record in audit log if this is an optimization iteration
86
+ if experiment.iteration is not None and experiment.iteration > 0:
87
+ session.audit_log.lock_model(
88
+ backend=backend,
89
+ kernel=kernel,
90
+ hyperparameters=hyperparameters,
91
+ cv_metrics=metrics,
92
+ iteration=experiment.iteration,
93
+ notes=f"Auto-trained after iteration {experiment.iteration}"
94
+ )
79
95
  except Exception as e:
80
96
  logger.error(f"Auto-train failed for session {session_id}: {e}")
81
97
  # Don't fail the whole request, just log it
api/routers/sessions.py CHANGED
@@ -4,11 +4,14 @@ Sessions router - Session lifecycle management.
4
4
 
5
5
  from fastapi import APIRouter, HTTPException, status, UploadFile, File, Depends
6
6
  from fastapi.responses import Response, FileResponse, JSONResponse
7
- from ..models.requests import UpdateMetadataRequest, LockDecisionRequest
7
+ from typing import Optional
8
+ from ..models.requests import UpdateMetadataRequest, LockDecisionRequest, SessionLockRequest
8
9
  from ..models.responses import (
9
10
  SessionCreateResponse, SessionInfoResponse, SessionStateResponse,
10
- SessionMetadataResponse, AuditLogResponse, AuditEntryResponse, LockDecisionResponse
11
+ SessionMetadataResponse, AuditLogResponse, AuditEntryResponse, LockDecisionResponse,
12
+ SessionLockResponse
11
13
  )
14
+ from .websocket import broadcast_to_session
12
15
  from ..services import session_store
13
16
  from ..dependencies import get_session
14
17
  from alchemist_core.session import OptimizationSession
@@ -463,3 +466,109 @@ async def upload_session(file: UploadFile = File(...)):
463
466
  status_code=status.HTTP_400_BAD_REQUEST,
464
467
  detail=f"Failed to upload session: {str(e)}"
465
468
  )
469
+
470
+
471
+ # ============================================================
472
+ # Session Locking Endpoints
473
+ # ============================================================
474
+
475
+ @router.post("/sessions/{session_id}/lock", response_model=SessionLockResponse)
476
+ async def lock_session(
477
+ session_id: str,
478
+ request: SessionLockRequest
479
+ ):
480
+ """
481
+ Lock a session for external programmatic control.
482
+
483
+ When locked, the web UI should enter monitor-only mode.
484
+ Returns a lock_token that must be used to unlock.
485
+ """
486
+ try:
487
+ result = session_store.lock_session(
488
+ session_id=session_id,
489
+ locked_by=request.locked_by,
490
+ client_id=request.client_id
491
+ )
492
+
493
+ # Broadcast lock event to WebSocket clients
494
+ await broadcast_to_session(session_id, {
495
+ "event": "lock_status_changed",
496
+ "locked": True,
497
+ "locked_by": request.locked_by,
498
+ "locked_at": result["locked_at"]
499
+ })
500
+
501
+ return SessionLockResponse(**result)
502
+ except KeyError:
503
+ raise HTTPException(
504
+ status_code=status.HTTP_404_NOT_FOUND,
505
+ detail=f"Session {session_id} not found or expired"
506
+ )
507
+ except Exception as e:
508
+ raise HTTPException(
509
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
510
+ detail=f"Failed to lock session: {str(e)}"
511
+ )
512
+
513
+
514
+ @router.delete("/sessions/{session_id}/lock", response_model=SessionLockResponse)
515
+ async def unlock_session(
516
+ session_id: str,
517
+ lock_token: Optional[str] = None
518
+ ):
519
+ """
520
+ Unlock a session.
521
+
522
+ Optionally provide lock_token for verification.
523
+ If no token provided, forcibly unlocks (use with caution).
524
+ """
525
+ try:
526
+ result = session_store.unlock_session(session_id=session_id, lock_token=lock_token)
527
+
528
+ # Broadcast unlock event to WebSocket clients
529
+ await broadcast_to_session(session_id, {
530
+ "event": "lock_status_changed",
531
+ "locked": False,
532
+ "locked_by": None,
533
+ "locked_at": None
534
+ })
535
+
536
+ return SessionLockResponse(**result)
537
+ except KeyError:
538
+ raise HTTPException(
539
+ status_code=status.HTTP_404_NOT_FOUND,
540
+ detail=f"Session {session_id} not found or expired"
541
+ )
542
+ except ValueError as e:
543
+ raise HTTPException(
544
+ status_code=status.HTTP_403_FORBIDDEN,
545
+ detail=str(e)
546
+ )
547
+ except Exception as e:
548
+ raise HTTPException(
549
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
550
+ detail=f"Failed to unlock session: {str(e)}"
551
+ )
552
+
553
+
554
+ @router.get("/sessions/{session_id}/lock", response_model=SessionLockResponse)
555
+ async def get_lock_status(session_id: str):
556
+ """
557
+ Get current lock status of a session.
558
+
559
+ Used by web UI to detect when external controller has taken control
560
+ and automatically enter monitor mode.
561
+ """
562
+ try:
563
+ result = session_store.get_lock_status(session_id=session_id)
564
+ return SessionLockResponse(**result)
565
+ except KeyError:
566
+ raise HTTPException(
567
+ status_code=status.HTTP_404_NOT_FOUND,
568
+ detail=f"Session {session_id} not found or expired"
569
+ )
570
+ except Exception as e:
571
+ raise HTTPException(
572
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
573
+ detail=f"Failed to get lock status: {str(e)}"
574
+ )