alchemist-nrel 0.2.1__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alchemist_core/__init__.py +14 -7
- alchemist_core/acquisition/botorch_acquisition.py +14 -6
- alchemist_core/audit_log.py +594 -0
- alchemist_core/data/experiment_manager.py +69 -5
- alchemist_core/models/botorch_model.py +6 -4
- alchemist_core/models/sklearn_model.py +44 -6
- alchemist_core/session.py +600 -8
- alchemist_core/utils/doe.py +200 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/METADATA +57 -40
- alchemist_nrel-0.3.0.dist-info/RECORD +66 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/entry_points.txt +1 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/top_level.txt +1 -0
- api/main.py +19 -3
- api/models/requests.py +71 -0
- api/models/responses.py +144 -0
- api/routers/experiments.py +117 -5
- api/routers/sessions.py +329 -10
- api/routers/visualizations.py +10 -5
- api/services/session_store.py +210 -54
- api/static/NEW_ICON.ico +0 -0
- api/static/NEW_ICON.png +0 -0
- api/static/NEW_LOGO_DARK.png +0 -0
- api/static/NEW_LOGO_LIGHT.png +0 -0
- api/static/assets/api-vcoXEqyq.js +1 -0
- api/static/assets/index-C0_glioA.js +4084 -0
- api/static/assets/index-CB4V1LI5.css +1 -0
- api/static/index.html +14 -0
- api/static/vite.svg +1 -0
- run_api.py +55 -0
- ui/gpr_panel.py +7 -2
- ui/notifications.py +197 -10
- ui/ui.py +1117 -68
- ui/variables_setup.py +47 -2
- ui/visualizations.py +60 -3
- alchemist_nrel-0.2.1.dist-info/RECORD +0 -54
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/WHEEL +0 -0
- {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/licenses/LICENSE +0 -0
api/services/session_store.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Session Store - Session management with disk persistence.
|
|
3
3
|
|
|
4
4
|
Stores OptimizationSession instances with TTL and automatic cleanup.
|
|
5
|
-
Sessions are persisted to disk to survive server restarts.
|
|
5
|
+
Sessions are persisted to disk as JSON to survive server restarts.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from typing import Dict, Optional
|
|
@@ -10,8 +10,19 @@ from datetime import datetime, timedelta
|
|
|
10
10
|
import uuid
|
|
11
11
|
from alchemist_core.session import OptimizationSession
|
|
12
12
|
import logging
|
|
13
|
-
import
|
|
13
|
+
import json
|
|
14
|
+
import tempfile
|
|
14
15
|
from pathlib import Path
|
|
16
|
+
import threading
|
|
17
|
+
|
|
18
|
+
# TODO: Consider migrating per-session `threading.Lock()` to an async-compatible
|
|
19
|
+
# `anyio.Lock()` (or `asyncio.Lock`) for cleaner async endpoint integration.
|
|
20
|
+
#
|
|
21
|
+
# Rationale / next steps:
|
|
22
|
+
# - Many API endpoints are `async def` and blocking the event loop with
|
|
23
|
+
# `threading.Lock().acquire()` is undesirable.
|
|
24
|
+
# - A migration plan is in `memory/SESSION_LOCKING_ASYNC_PLAN.md` describing
|
|
25
|
+
# how to transition to `anyio.Lock()` and update handlers to use `async with`.
|
|
15
26
|
|
|
16
27
|
logger = logging.getLogger(__name__)
|
|
17
28
|
|
|
@@ -41,17 +52,42 @@ class SessionStore:
|
|
|
41
52
|
|
|
42
53
|
def _get_session_file(self, session_id: str) -> Path:
|
|
43
54
|
"""Get path to session file."""
|
|
44
|
-
return self.persist_dir / f"{session_id}.
|
|
55
|
+
return self.persist_dir / f"{session_id}.json"
|
|
45
56
|
|
|
46
57
|
def _save_to_disk(self, session_id: str):
|
|
47
|
-
"""Save session to disk."""
|
|
58
|
+
"""Save session to disk as JSON."""
|
|
48
59
|
if not self.persist_dir:
|
|
49
60
|
return
|
|
50
61
|
|
|
51
62
|
try:
|
|
52
63
|
session_file = self._get_session_file(session_id)
|
|
53
|
-
|
|
54
|
-
|
|
64
|
+
session_data = self._sessions[session_id]
|
|
65
|
+
|
|
66
|
+
# Create a temporary file for the session
|
|
67
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
68
|
+
session_data["session"].save_session(tmp.name)
|
|
69
|
+
temp_path = tmp.name
|
|
70
|
+
|
|
71
|
+
# Store metadata alongside session
|
|
72
|
+
metadata = {
|
|
73
|
+
"created_at": session_data["created_at"].isoformat(),
|
|
74
|
+
"last_accessed": session_data["last_accessed"].isoformat(),
|
|
75
|
+
"expires_at": session_data["expires_at"].isoformat()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Load session JSON and add metadata
|
|
79
|
+
with open(temp_path, 'r') as f:
|
|
80
|
+
session_json = json.load(f)
|
|
81
|
+
|
|
82
|
+
session_json["_session_store_metadata"] = metadata
|
|
83
|
+
|
|
84
|
+
# Write combined data
|
|
85
|
+
with open(session_file, 'w') as f:
|
|
86
|
+
json.dump(session_json, f, indent=2)
|
|
87
|
+
|
|
88
|
+
# Clean up temp file
|
|
89
|
+
Path(temp_path).unlink()
|
|
90
|
+
|
|
55
91
|
except Exception as e:
|
|
56
92
|
logger.error(f"Failed to save session {session_id}: {e}")
|
|
57
93
|
|
|
@@ -61,19 +97,39 @@ class SessionStore:
|
|
|
61
97
|
return
|
|
62
98
|
|
|
63
99
|
loaded_count = 0
|
|
64
|
-
for session_file in self.persist_dir.glob("*.
|
|
100
|
+
for session_file in self.persist_dir.glob("*.json"):
|
|
65
101
|
try:
|
|
66
|
-
with open(session_file, '
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
with open(session_file, 'r') as f:
|
|
103
|
+
session_json = json.load(f)
|
|
104
|
+
|
|
105
|
+
# Extract metadata
|
|
106
|
+
metadata = session_json.pop("_session_store_metadata", {})
|
|
107
|
+
|
|
108
|
+
# Check if expired
|
|
109
|
+
if metadata:
|
|
110
|
+
expires_at = datetime.fromisoformat(metadata["expires_at"])
|
|
111
|
+
if datetime.now() > expires_at:
|
|
72
112
|
session_file.unlink() # Delete expired session file
|
|
73
113
|
continue
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
114
|
+
|
|
115
|
+
# Write session data to temp file and load
|
|
116
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
117
|
+
json.dump(session_json, tmp, indent=2)
|
|
118
|
+
temp_path = tmp.name
|
|
119
|
+
|
|
120
|
+
# Load without retraining by default during startup
|
|
121
|
+
session = OptimizationSession.load_session(temp_path, retrain_on_load=False)
|
|
122
|
+
Path(temp_path).unlink()
|
|
123
|
+
session_id = session_file.stem
|
|
124
|
+
self._sessions[session_id] = {
|
|
125
|
+
"session": session,
|
|
126
|
+
"created_at": datetime.fromisoformat(metadata.get("created_at", datetime.now().isoformat())),
|
|
127
|
+
"last_accessed": datetime.fromisoformat(metadata.get("last_accessed", datetime.now().isoformat())),
|
|
128
|
+
"expires_at": datetime.fromisoformat(metadata.get("expires_at", (datetime.now() + self.default_ttl).isoformat())),
|
|
129
|
+
"lock": threading.Lock()
|
|
130
|
+
}
|
|
131
|
+
loaded_count += 1
|
|
132
|
+
|
|
77
133
|
except Exception as e:
|
|
78
134
|
logger.error(f"Failed to load session from {session_file}: {e}")
|
|
79
135
|
|
|
@@ -92,7 +148,7 @@ class SessionStore:
|
|
|
92
148
|
except Exception as e:
|
|
93
149
|
logger.error(f"Failed to delete session file {session_id}: {e}")
|
|
94
150
|
|
|
95
|
-
def create(self) -> str:
|
|
151
|
+
def create(self, name: Optional[str] = None, description: Optional[str] = None, tags: Optional[list] = None) -> str:
|
|
96
152
|
"""
|
|
97
153
|
Create a new session.
|
|
98
154
|
|
|
@@ -101,12 +157,28 @@ class SessionStore:
|
|
|
101
157
|
"""
|
|
102
158
|
session_id = str(uuid.uuid4())
|
|
103
159
|
session = OptimizationSession()
|
|
104
|
-
|
|
160
|
+
# Ensure session metadata matches store id
|
|
161
|
+
try:
|
|
162
|
+
session.metadata.session_id = session_id
|
|
163
|
+
except Exception:
|
|
164
|
+
pass
|
|
165
|
+
# Populate optional metadata
|
|
166
|
+
if name:
|
|
167
|
+
session.metadata.name = name
|
|
168
|
+
if description:
|
|
169
|
+
session.metadata.description = description
|
|
170
|
+
if tags:
|
|
171
|
+
try:
|
|
172
|
+
session.metadata.tags = tags
|
|
173
|
+
except Exception:
|
|
174
|
+
pass
|
|
175
|
+
|
|
105
176
|
self._sessions[session_id] = {
|
|
106
177
|
"session": session,
|
|
107
178
|
"created_at": datetime.now(),
|
|
108
179
|
"last_accessed": datetime.now(),
|
|
109
|
-
"expires_at": datetime.now() + self.default_ttl
|
|
180
|
+
"expires_at": datetime.now() + self.default_ttl,
|
|
181
|
+
"lock": threading.Lock()
|
|
110
182
|
}
|
|
111
183
|
|
|
112
184
|
# Persist to disk
|
|
@@ -133,20 +205,31 @@ class SessionStore:
|
|
|
133
205
|
return None
|
|
134
206
|
|
|
135
207
|
session_data = self._sessions[session_id]
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
208
|
+
lock = session_data.get("lock")
|
|
209
|
+
if lock:
|
|
210
|
+
with lock:
|
|
211
|
+
# Check if expired
|
|
212
|
+
if datetime.now() > session_data["expires_at"]:
|
|
213
|
+
logger.info(f"Session {session_id} expired, removing")
|
|
214
|
+
del self._sessions[session_id]
|
|
215
|
+
return None
|
|
216
|
+
|
|
217
|
+
# Update last accessed time
|
|
218
|
+
session_data["last_accessed"] = datetime.now()
|
|
219
|
+
|
|
220
|
+
# Save updated access time to disk
|
|
221
|
+
self._save_to_disk(session_id)
|
|
222
|
+
|
|
223
|
+
return session_data["session"]
|
|
224
|
+
else:
|
|
225
|
+
# Fallback (no lock present)
|
|
226
|
+
if datetime.now() > session_data["expires_at"]:
|
|
227
|
+
logger.info(f"Session {session_id} expired, removing")
|
|
228
|
+
del self._sessions[session_id]
|
|
229
|
+
return None
|
|
230
|
+
session_data["last_accessed"] = datetime.now()
|
|
231
|
+
self._save_to_disk(session_id)
|
|
232
|
+
return session_data["session"]
|
|
150
233
|
|
|
151
234
|
def delete(self, session_id: str) -> bool:
|
|
152
235
|
"""
|
|
@@ -159,8 +242,14 @@ class SessionStore:
|
|
|
159
242
|
True if deleted, False if not found
|
|
160
243
|
"""
|
|
161
244
|
if session_id in self._sessions:
|
|
162
|
-
|
|
163
|
-
|
|
245
|
+
lock = self._sessions[session_id].get("lock")
|
|
246
|
+
if lock:
|
|
247
|
+
with lock:
|
|
248
|
+
del self._sessions[session_id]
|
|
249
|
+
self._delete_from_disk(session_id)
|
|
250
|
+
else:
|
|
251
|
+
del self._sessions[session_id]
|
|
252
|
+
self._delete_from_disk(session_id)
|
|
164
253
|
logger.info(f"Deleted session {session_id}")
|
|
165
254
|
return True
|
|
166
255
|
return False
|
|
@@ -204,10 +293,17 @@ class SessionStore:
|
|
|
204
293
|
"""
|
|
205
294
|
if session_id not in self._sessions:
|
|
206
295
|
return False
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
296
|
+
lock = self._sessions[session_id].get("lock")
|
|
297
|
+
if lock:
|
|
298
|
+
with lock:
|
|
299
|
+
extension = timedelta(hours=hours) if hours else self.default_ttl
|
|
300
|
+
self._sessions[session_id]["expires_at"] = datetime.now() + extension
|
|
301
|
+
self._save_to_disk(session_id)
|
|
302
|
+
else:
|
|
303
|
+
extension = timedelta(hours=hours) if hours else self.default_ttl
|
|
304
|
+
self._sessions[session_id]["expires_at"] = datetime.now() + extension
|
|
305
|
+
self._save_to_disk(session_id)
|
|
306
|
+
|
|
211
307
|
logger.info(f"Extended TTL for session {session_id}")
|
|
212
308
|
return True
|
|
213
309
|
|
|
@@ -234,49 +330,109 @@ class SessionStore:
|
|
|
234
330
|
self._cleanup_expired()
|
|
235
331
|
return list(self._sessions.keys())
|
|
236
332
|
|
|
237
|
-
def export_session(self, session_id: str) -> Optional[
|
|
333
|
+
def export_session(self, session_id: str) -> Optional[str]:
|
|
238
334
|
"""
|
|
239
|
-
Export a session as
|
|
335
|
+
Export a session as JSON string for download.
|
|
240
336
|
|
|
241
337
|
Args:
|
|
242
338
|
session_id: Session identifier
|
|
243
339
|
|
|
244
340
|
Returns:
|
|
245
|
-
|
|
341
|
+
JSON string of session data or None if not found
|
|
246
342
|
"""
|
|
247
343
|
if session_id not in self._sessions:
|
|
248
344
|
return None
|
|
249
|
-
|
|
345
|
+
|
|
250
346
|
try:
|
|
251
|
-
|
|
347
|
+
lock = self._sessions[session_id].get("lock")
|
|
348
|
+
if lock:
|
|
349
|
+
with lock:
|
|
350
|
+
# Create temporary file
|
|
351
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
352
|
+
self._sessions[session_id]["session"].save_session(tmp.name)
|
|
353
|
+
temp_path = tmp.name
|
|
354
|
+
|
|
355
|
+
# Read the JSON content
|
|
356
|
+
with open(temp_path, 'r') as f:
|
|
357
|
+
json_content = f.read()
|
|
358
|
+
|
|
359
|
+
# Clean up temp file
|
|
360
|
+
Path(temp_path).unlink()
|
|
361
|
+
return json_content
|
|
362
|
+
else:
|
|
363
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
364
|
+
self._sessions[session_id]["session"].save_session(tmp.name)
|
|
365
|
+
temp_path = tmp.name
|
|
366
|
+
|
|
367
|
+
with open(temp_path, 'r') as f:
|
|
368
|
+
json_content = f.read()
|
|
369
|
+
|
|
370
|
+
Path(temp_path).unlink()
|
|
371
|
+
return json_content
|
|
252
372
|
except Exception as e:
|
|
253
373
|
logger.error(f"Failed to export session {session_id}: {e}")
|
|
254
374
|
return None
|
|
375
|
+
|
|
376
|
+
def persist_session_to_disk(self, session_id: str) -> bool:
|
|
377
|
+
"""
|
|
378
|
+
Persist the in-memory session to disk (overwrite existing persisted file).
|
|
379
|
+
|
|
380
|
+
Returns True on success, False otherwise.
|
|
381
|
+
"""
|
|
382
|
+
if session_id not in self._sessions:
|
|
383
|
+
return False
|
|
384
|
+
try:
|
|
385
|
+
lock = self._sessions[session_id].get('lock')
|
|
386
|
+
if lock:
|
|
387
|
+
with lock:
|
|
388
|
+
self._save_to_disk(session_id)
|
|
389
|
+
else:
|
|
390
|
+
self._save_to_disk(session_id)
|
|
391
|
+
return True
|
|
392
|
+
except Exception as e:
|
|
393
|
+
logger.error(f"Failed to persist session {session_id}: {e}")
|
|
394
|
+
return False
|
|
255
395
|
|
|
256
|
-
def import_session(self, session_data:
|
|
396
|
+
def import_session(self, session_data: str, session_id: Optional[str] = None) -> Optional[str]:
|
|
257
397
|
"""
|
|
258
|
-
Import a session from
|
|
398
|
+
Import a session from JSON string.
|
|
259
399
|
|
|
260
400
|
Args:
|
|
261
|
-
session_data:
|
|
401
|
+
session_data: JSON string of session data
|
|
262
402
|
session_id: Optional custom session ID (generates new one if None)
|
|
263
403
|
|
|
264
404
|
Returns:
|
|
265
405
|
Session ID or None if import failed
|
|
266
406
|
"""
|
|
267
407
|
try:
|
|
268
|
-
|
|
408
|
+
# Write JSON to temp file
|
|
409
|
+
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
|
|
410
|
+
tmp.write(session_data)
|
|
411
|
+
temp_path = tmp.name
|
|
412
|
+
|
|
413
|
+
# Load session without automatic retrain
|
|
414
|
+
session = OptimizationSession.load_session(temp_path, retrain_on_load=False)
|
|
415
|
+
Path(temp_path).unlink()
|
|
269
416
|
|
|
270
417
|
# Generate new session ID if not provided
|
|
271
418
|
if not session_id:
|
|
272
419
|
session_id = str(uuid.uuid4())
|
|
420
|
+
|
|
421
|
+
# Ensure session metadata session_id matches store id
|
|
422
|
+
try:
|
|
423
|
+
session.metadata.session_id = session_id
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
# Store session with metadata and lock
|
|
428
|
+
self._sessions[session_id] = {
|
|
429
|
+
"session": session,
|
|
430
|
+
"created_at": datetime.now(),
|
|
431
|
+
"last_accessed": datetime.now(),
|
|
432
|
+
"expires_at": datetime.now() + self.default_ttl,
|
|
433
|
+
"lock": threading.Lock()
|
|
434
|
+
}
|
|
273
435
|
|
|
274
|
-
# Update timestamps
|
|
275
|
-
imported_data["last_accessed"] = datetime.now()
|
|
276
|
-
imported_data["expires_at"] = datetime.now() + self.default_ttl
|
|
277
|
-
|
|
278
|
-
# Store session
|
|
279
|
-
self._sessions[session_id] = imported_data
|
|
280
436
|
self._save_to_disk(session_id)
|
|
281
437
|
|
|
282
438
|
logger.info(f"Imported session {session_id}")
|
api/static/NEW_ICON.ico
ADDED
|
Binary file
|
api/static/NEW_ICON.png
ADDED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
async function i(t,n,o=!1){const s={...n},e=await fetch(`/api/v1/sessions/${t}/experiments?auto_train=${o}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(!e.ok)throw new Error(`Add experiment failed: ${e.statusText}`);return e.json()}export{i as addExperiment};
|