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.
Files changed (37) hide show
  1. alchemist_core/__init__.py +14 -7
  2. alchemist_core/acquisition/botorch_acquisition.py +14 -6
  3. alchemist_core/audit_log.py +594 -0
  4. alchemist_core/data/experiment_manager.py +69 -5
  5. alchemist_core/models/botorch_model.py +6 -4
  6. alchemist_core/models/sklearn_model.py +44 -6
  7. alchemist_core/session.py +600 -8
  8. alchemist_core/utils/doe.py +200 -0
  9. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/METADATA +57 -40
  10. alchemist_nrel-0.3.0.dist-info/RECORD +66 -0
  11. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/entry_points.txt +1 -0
  12. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/top_level.txt +1 -0
  13. api/main.py +19 -3
  14. api/models/requests.py +71 -0
  15. api/models/responses.py +144 -0
  16. api/routers/experiments.py +117 -5
  17. api/routers/sessions.py +329 -10
  18. api/routers/visualizations.py +10 -5
  19. api/services/session_store.py +210 -54
  20. api/static/NEW_ICON.ico +0 -0
  21. api/static/NEW_ICON.png +0 -0
  22. api/static/NEW_LOGO_DARK.png +0 -0
  23. api/static/NEW_LOGO_LIGHT.png +0 -0
  24. api/static/assets/api-vcoXEqyq.js +1 -0
  25. api/static/assets/index-C0_glioA.js +4084 -0
  26. api/static/assets/index-CB4V1LI5.css +1 -0
  27. api/static/index.html +14 -0
  28. api/static/vite.svg +1 -0
  29. run_api.py +55 -0
  30. ui/gpr_panel.py +7 -2
  31. ui/notifications.py +197 -10
  32. ui/ui.py +1117 -68
  33. ui/variables_setup.py +47 -2
  34. ui/visualizations.py +60 -3
  35. alchemist_nrel-0.2.1.dist-info/RECORD +0 -54
  36. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/WHEEL +0 -0
  37. {alchemist_nrel-0.2.1.dist-info → alchemist_nrel-0.3.0.dist-info}/licenses/LICENSE +0 -0
@@ -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 pickle
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}.pkl"
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
- with open(session_file, 'wb') as f:
54
- pickle.dump(self._sessions[session_id], f)
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("*.pkl"):
100
+ for session_file in self.persist_dir.glob("*.json"):
65
101
  try:
66
- with open(session_file, 'rb') as f:
67
- session_data = pickle.load(f)
68
- session_id = session_file.stem
69
-
70
- # Check if expired
71
- if datetime.now() > session_data["expires_at"]:
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
- self._sessions[session_id] = session_data
76
- loaded_count += 1
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
- # Check if expired
138
- if datetime.now() > session_data["expires_at"]:
139
- logger.info(f"Session {session_id} expired, removing")
140
- del self._sessions[session_id]
141
- return None
142
-
143
- # Update last accessed time
144
- session_data["last_accessed"] = datetime.now()
145
-
146
- # Save updated access time to disk
147
- self._save_to_disk(session_id)
148
-
149
- return session_data["session"]
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
- del self._sessions[session_id]
163
- self._delete_from_disk(session_id)
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
- extension = timedelta(hours=hours) if hours else self.default_ttl
209
- self._sessions[session_id]["expires_at"] = datetime.now() + extension
210
- self._save_to_disk(session_id)
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[bytes]:
333
+ def export_session(self, session_id: str) -> Optional[str]:
238
334
  """
239
- Export a session as bytes for download.
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
- Pickled session data or None if not found
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
- return pickle.dumps(self._sessions[session_id])
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: bytes, session_id: Optional[str] = None) -> Optional[str]:
396
+ def import_session(self, session_data: str, session_id: Optional[str] = None) -> Optional[str]:
257
397
  """
258
- Import a session from bytes.
398
+ Import a session from JSON string.
259
399
 
260
400
  Args:
261
- session_data: Pickled 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
- imported_data = pickle.loads(session_data)
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}")
Binary file
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};