ivoryos 1.3.8__py3-none-any.whl → 1.4.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.

Potentially problematic release.


This version of ivoryos might be problematic. Click here for more details.

Files changed (34) hide show
  1. ivoryos/optimizer/ax_optimizer.py +44 -25
  2. ivoryos/optimizer/base_optimizer.py +15 -1
  3. ivoryos/optimizer/baybe_optimizer.py +24 -17
  4. ivoryos/optimizer/nimo_optimizer.py +26 -24
  5. ivoryos/routes/data/data.py +27 -9
  6. ivoryos/routes/data/templates/components/step_card.html +47 -11
  7. ivoryos/routes/data/templates/workflow_view.html +14 -5
  8. ivoryos/routes/design/design.py +31 -1
  9. ivoryos/routes/design/design_step.py +2 -1
  10. ivoryos/routes/design/templates/components/edit_action_form.html +16 -3
  11. ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
  12. ivoryos/routes/design/templates/experiment_builder.html +1 -0
  13. ivoryos/routes/execute/execute.py +36 -7
  14. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  15. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  16. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  17. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  18. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  19. ivoryos/socket_handlers.py +1 -1
  20. ivoryos/static/js/action_handlers.js +252 -118
  21. ivoryos/static/js/sortable_design.js +1 -0
  22. ivoryos/utils/bo_campaign.py +17 -16
  23. ivoryos/utils/db_models.py +122 -18
  24. ivoryos/utils/decorators.py +1 -0
  25. ivoryos/utils/form.py +22 -5
  26. ivoryos/utils/nest_script.py +314 -0
  27. ivoryos/utils/script_runner.py +447 -147
  28. ivoryos/utils/utils.py +11 -1
  29. ivoryos/version.py +1 -1
  30. {ivoryos-1.3.8.dist-info → ivoryos-1.4.0.dist-info}/METADATA +3 -2
  31. {ivoryos-1.3.8.dist-info → ivoryos-1.4.0.dist-info}/RECORD +34 -33
  32. {ivoryos-1.3.8.dist-info → ivoryos-1.4.0.dist-info}/WHEEL +0 -0
  33. {ivoryos-1.3.8.dist-info → ivoryos-1.4.0.dist-info}/licenses/LICENSE +0 -0
  34. {ivoryos-1.3.8.dist-info → ivoryos-1.4.0.dist-info}/top_level.txt +0 -0
@@ -1,15 +1,18 @@
1
1
  import ast
2
2
  import asyncio
3
3
  import os
4
- import csv
5
4
  import threading
6
5
  import time
7
6
  from datetime import datetime
7
+ from typing import List, Dict, Any
8
+
9
+ import pandas as pd
8
10
 
9
11
  from ivoryos.utils import utils, bo_campaign
10
12
  from ivoryos.utils.db_models import Script, WorkflowRun, WorkflowStep, db, WorkflowPhase
11
13
  from ivoryos.utils.global_config import GlobalConfig
12
14
  from ivoryos.utils.decorators import BUILDING_BLOCKS
15
+ from ivoryos.utils.nest_script import validate_and_nest_control_flow
13
16
 
14
17
  global_config = GlobalConfig()
15
18
  global deck
@@ -32,6 +35,8 @@ def pause(reason="Human intervention required"):
32
35
 
33
36
  class ScriptRunner:
34
37
  def __init__(self, globals_dict=None):
38
+ self.logger = None
39
+ self.socketio = None
35
40
  self.retry = False
36
41
  if globals_dict is None:
37
42
  globals_dict = globals()
@@ -74,13 +79,19 @@ class ScriptRunner:
74
79
  """Force stop everything, including ongoing tasks."""
75
80
  self.stop_current_event.set()
76
81
  self.abort_pending()
82
+ if self.lock.locked():
83
+ self.lock.release()
77
84
 
78
85
 
79
86
  def run_script(self, script, repeat_count=1, run_name=None, logger=None, socketio=None, config=None, bo_args=None,
80
- output_path="", compiled=False, current_app=None, history=None, optimizer=None):
87
+ output_path="", compiled=False, current_app=None, history=None, optimizer=None, batch_mode=None,
88
+ batch_size=1, objectives=None):
89
+ self.socketio = socketio
90
+ self.logger = logger
81
91
  global deck
82
92
  if deck is None:
83
93
  deck = global_config.deck
94
+
84
95
  # print("history", history)
85
96
  if self.current_app is None:
86
97
  self.current_app = current_app
@@ -97,12 +108,14 @@ class ScriptRunner:
97
108
  thread = threading.Thread(
98
109
  target=self._run_with_stop_check,
99
110
  args=(script, repeat_count, run_name, logger, socketio, config, bo_args, output_path, current_app, compiled,
100
- history, optimizer)
111
+ history, optimizer, batch_mode, batch_size, objectives),
101
112
  )
102
113
  thread.start()
103
114
  return thread
104
115
 
105
- def exec_steps(self, script, section_name, logger, socketio, phase_id, **kwargs):
116
+
117
+
118
+ async def exec_steps(self, script, section_name, phase_id, kwargs_list=None, batch_size=1):
106
119
  """
107
120
  Executes a function defined in a string line by line
108
121
  :param func_str: The function as a string
@@ -110,6 +123,8 @@ class ScriptRunner:
110
123
  :return: The final result of the function execution
111
124
  """
112
125
  _func_str = script.python_script or script.compile()
126
+ _, return_list = script.config_return()
127
+
113
128
  step_list: list = script.convert_to_lines(_func_str).get(section_name, [])
114
129
  global deck
115
130
  # global deck, registered_workflows
@@ -129,112 +144,54 @@ class ScriptRunner:
129
144
  # exec_globals = {"deck": deck, "time": time, "registered_workflows":registered_workflows} # Add required global objects
130
145
  exec_globals.update(temp_connections)
131
146
 
132
- # Inject all block categories
133
- for category, data in BUILDING_BLOCKS.items():
134
- for method_name, method in data.items():
135
- exec_globals[method_name] = method["func"]
136
-
137
147
  exec_locals = {} # Local execution scope
138
148
 
139
149
  # Define function arguments manually in exec_locals
140
- exec_locals.update(kwargs)
150
+ # exec_locals.update(kwargs)
141
151
  index = 0
142
-
143
- # Execute each line dynamically
144
- while index < len(step_list):
145
- if self.stop_current_event.is_set():
146
- logger.info(f'Stopping execution during {section_name}')
147
- step = WorkflowStep(
148
- phase_id=phase_id,
149
- # phase=section_name,
150
- # repeat_index=i_progress,
151
- step_index=index,
152
- method_name="stop",
153
- start_time=datetime.now(),
154
- end_time=datetime.now(),
155
- run_error=False,
156
- )
157
- db.session.add(step)
158
- break
159
- line = step_list[index]
160
-
161
- method_name = line.strip()
162
- # start_time = datetime.now()
163
-
164
- step = WorkflowStep(
165
- phase_id=phase_id,
166
- # phase=section_name,
167
- # repeat_index=i_progress,
168
- step_index=index,
169
- method_name=method_name,
170
- start_time=datetime.now(),
171
- )
172
- db.session.add(step)
173
- db.session.flush()
174
-
175
- logger.info(f"Executing: {line}")
176
- socketio.emit('execution', {'section': f"{section_name}-{index}"})
177
-
178
- # if line.startswith("registered_workflows"):
179
- # line = line.replace("registered_workflows.", "")
180
- try:
181
- if line.startswith("time.sleep("): # add safe sleep for time.sleep lines
182
- duration_str = line.strip()[len("time.sleep("):-1]
183
- duration = float(duration_str)
184
- self.safe_sleep(duration)
152
+ if kwargs_list:
153
+ results = kwargs_list.copy()
154
+ else:
155
+ results = []
156
+ nest_script = validate_and_nest_control_flow(script.script_dict.get(section_name, []))
157
+
158
+ for step in nest_script:
159
+ action = step["action"]
160
+ instrument = step["instrument"]
161
+ action_id = step["id"]
162
+ if action == "if":
163
+ await self._execute_if_batched(step, results, phase_id=phase_id, step_index=action_id, section_name=section_name)
164
+ elif action == "repeat":
165
+ await self._execute_repeat_batched(step, results, phase_id=phase_id, step_index=action_id, section_name=section_name)
166
+ elif action == "while":
167
+ await self._execute_while_batched(step, results, phase_id=phase_id, step_index=action_id, section_name=section_name)
168
+ elif instrument == "variable":
169
+ await self._execute_variable_batched(step, results, phase_id=phase_id, step_index=action_id, section_name=section_name)
170
+ print("Variable executed", "current context", context)
171
+ else:
172
+ # Regular action - check if batch
173
+ if step.get("batch_action", False):
174
+ # Execute once for all samples
175
+ if results:
176
+ await self._execute_action_once(step, results[0], phase_id=phase_id, step_index=action_id, section_name=section_name)
177
+ else:
178
+ await self._execute_action_once(step, {}, phase_id=phase_id, step_index=action_id, section_name=section_name)
185
179
  else:
186
- if "await " in line:
187
- async_code = f"async def __async_exec_wrapper():\n"
188
- # indent all code lines by 4 spaces
189
- async_code += "\n".join(" " + line for line in line.splitlines())
190
- async_code += f"\n return locals()"
191
- exec(async_code, exec_globals, exec_locals)
192
- func = exec_locals.get("__async_exec_wrapper") or exec_globals.get("__async_exec_wrapper")
193
- # Capture the return value from asyncio.run
194
- result_locals = asyncio.run(func())
195
-
196
- # Update exec_locals with the returned locals
197
- exec_locals.update(result_locals)
198
-
199
-
180
+ # Execute for each sample
181
+ if results:
182
+ for context in results:
183
+ await self._execute_action(step, context, phase_id=phase_id, step_index=action_id, section_name=section_name)
184
+ self.pause_event.wait()
200
185
  else:
201
- # print("just exec synchronously")
202
- exec(line, exec_globals, exec_locals)
203
- exec_globals.update(exec_locals)
204
- # return locals_dict
205
- # exec(line, exec_globals, exec_locals)
206
- # step.run_error = False
207
-
208
- except HumanInterventionRequired as e:
209
- logger.warning(f"Human intervention required: {e}")
210
- socketio.emit('human_intervention', {'message': str(e)})
211
- # Instead of auto-resume, explicitly stay paused until user action
212
- # step.run_error = False
213
- self.toggle_pause()
214
-
215
- except Exception as e:
216
- logger.error(f"Error during script execution: {e}")
217
- socketio.emit('error', {'message': str(e)})
218
-
219
- step.run_error = True
220
- self.toggle_pause()
221
- exec_locals.pop("__async_exec_wrapper", None)
222
- step.end_time = datetime.now()
223
- step.output = exec_locals
224
- db.session.commit()
225
-
226
- self.pause_event.wait()
186
+ for _ in range(batch_size):
187
+ await self._execute_action(step, {}, phase_id=phase_id, step_index=action_id, section_name=section_name)
188
+ self.pause_event.wait()
227
189
 
228
- # todo update script during the run
229
- # _func_str = script.compile()
230
- # step_list: list = script.convert_to_lines(_func_str).get(section_name, [])
231
- if not step.run_error or not self.retry:
232
- index += 1
233
-
234
- return exec_locals # Return the 'results' variable
190
+ return results # Return the 'results' variable
235
191
 
236
192
  def _run_with_stop_check(self, script: Script, repeat_count: int, run_name: str, logger, socketio, config, bo_args,
237
- output_path, current_app, compiled, history=None, optimizer=None):
193
+ output_path, current_app, compiled, history=None, optimizer=None, batch_mode=None,
194
+ batch_size=None, objectives=None):
238
195
  time.sleep(1)
239
196
  # _func_str = script.compile()
240
197
  # step_list_dict: dict = script.convert_to_lines(_func_str)
@@ -250,27 +207,40 @@ class ScriptRunner:
250
207
  db.session.add(run)
251
208
  db.session.flush()
252
209
  run_id = run.id # Save the ID
253
- try:
210
+ db.session.commit()
254
211
 
212
+ try:
213
+ # if True:
255
214
  global_config.runner_status = {"id":run_id, "type": "workflow"}
256
215
  # Run "prep" section once
257
- self._run_actions(script, section_name="prep", logger=logger, socketio=socketio, run_id=run_id)
216
+ asyncio.run(self._run_actions(script, section_name="prep", logger=logger, socketio=socketio, run_id=run_id))
258
217
  output_list = []
259
218
  _, arg_type = script.config("script")
260
219
  _, return_list = script.config_return()
261
220
  # Run "script" section multiple times
262
221
  if repeat_count:
263
- self._run_repeat_section(repeat_count, arg_type, bo_args, output_list, script,
222
+ asyncio.run(
223
+ self._run_repeat_section(repeat_count, arg_type, bo_args, output_list, script,
264
224
  run_name, return_list, compiled, logger, socketio,
265
- history, output_path, run_id=run_id, optimizer=optimizer)
225
+ history, output_path, run_id=run_id, optimizer=optimizer,
226
+ batch_mode=batch_mode, batch_size=batch_size, objectives=objectives)
227
+ )
266
228
  elif config:
267
- self._run_config_section(config, arg_type, output_list, script, run_name, logger,
268
- socketio, run_id=run_id, compiled=compiled)
229
+ asyncio.run(
230
+ self._run_config_section(
231
+ config, arg_type, output_list, script, run_name, logger,
232
+ socketio, run_id=run_id, compiled=compiled, batch_mode=batch_mode, batch_size=batch_size
233
+ )
234
+ )
235
+ # self._run_config_section(config, arg_type, output_list, script, run_name, logger,
236
+ # socketio, run_id=run_id, compiled=compiled)
269
237
  # Run "cleanup" section once
270
- self._run_actions(script, section_name="cleanup", logger=logger, socketio=socketio,run_id=run_id)
238
+ asyncio.run(self._run_actions(script, section_name="cleanup", logger=logger, socketio=socketio,run_id=run_id))
271
239
  # Reset the running flag when done
272
240
  # Save results if necessary
273
241
  if not script.python_script and return_list:
242
+ # print(output_list)
243
+
274
244
  filename = self._save_results(run_name, arg_type, return_list, output_list, logger, output_path)
275
245
  self._emit_progress(socketio, 100)
276
246
 
@@ -278,16 +248,20 @@ class ScriptRunner:
278
248
  logger.error(f"Error during script execution: {e.__str__()}")
279
249
  error_flag = True
280
250
  finally:
281
- self.lock.release()
251
+ if self.lock.locked():
252
+ self.lock.release()
282
253
  with current_app.app_context():
283
254
  run = db.session.get(WorkflowRun, run_id)
284
- run.end_time = datetime.now()
285
- run.data_path = filename
286
- run.run_error = error_flag
287
- db.session.commit()
255
+ if run is None:
256
+ logger.info("Error: Run not found in database.")
257
+ else:
258
+ run.end_time = datetime.now()
259
+ run.data_path = filename
260
+ run.run_error = error_flag
261
+ db.session.commit()
288
262
 
289
263
 
290
- def _run_actions(self, script, section_name="", logger=None, socketio=None, run_id=None):
264
+ async def _run_actions(self, script, section_name="", logger=None, socketio=None, run_id=None):
291
265
  _func_str = script.python_script or script.compile()
292
266
  step_list: list = script.convert_to_lines(_func_str).get(section_name, [])
293
267
  if not step_list:
@@ -309,14 +283,15 @@ class ScriptRunner:
309
283
  db.session.flush()
310
284
  phase_id = phase.id
311
285
 
312
- step_outputs = self.exec_steps(script, section_name, logger, socketio, phase_id=phase_id)
286
+ step_outputs = await self.exec_steps(script, section_name, phase_id=phase_id)
313
287
  # Save phase-level output
314
288
  phase.outputs = step_outputs
315
289
  phase.end_time = datetime.now()
316
290
  db.session.commit()
317
291
  return step_outputs
318
292
 
319
- def _run_config_section(self, config, arg_type, output_list, script, run_name, logger, socketio, run_id, compiled=True):
293
+ async def _run_config_section(self, config, arg_type, output_list, script, run_name, logger, socketio, run_id,
294
+ compiled=True, batch_mode=False, batch_size=1):
320
295
  if not compiled:
321
296
  for i in config:
322
297
  try:
@@ -327,13 +302,16 @@ class ScriptRunner:
327
302
  compiled = False
328
303
  break
329
304
  if compiled:
330
- for i, kwargs in enumerate(config):
331
- kwargs = dict(kwargs)
305
+ batch_size = int(batch_size)
306
+ nested_list = [config[i:i + batch_size] for i in range(0, len(config), batch_size)]
307
+
308
+ for i, kwargs_list in enumerate(nested_list):
309
+ # kwargs = dict(kwargs)
332
310
  if self.stop_pending_event.is_set():
333
311
  logger.info(f'Stopping execution during {run_name}: {i + 1}/{len(config)}')
334
312
  break
335
- logger.info(f'Executing {i + 1} of {len(config)} with kwargs = {kwargs}')
336
- progress = ((i + 1) * 100 / len(config)) - 0.1
313
+ logger.info(f'Executing {i + 1} of {len(nested_list)} with kwargs = {kwargs_list}')
314
+ progress = ((i + 1) * 100 / len(nested_list)) - 0.1
337
315
  self._emit_progress(socketio, progress)
338
316
  # fname = f"{run_name}_script"
339
317
  # function = self.globals_dict[fname]
@@ -342,30 +320,33 @@ class ScriptRunner:
342
320
  run_id=run_id,
343
321
  name="main",
344
322
  repeat_index=i,
345
- parameters=kwargs,
323
+ parameters=kwargs_list,
346
324
  start_time=datetime.now()
347
325
  )
348
326
  db.session.add(phase)
349
327
  db.session.flush()
350
328
 
351
329
  phase_id = phase.id
352
- output = self.exec_steps(script, "script", logger, socketio, phase_id, **kwargs)
330
+ output = await self.exec_steps(script, "script", phase_id, kwargs_list=kwargs_list, )
331
+ # print(output)
353
332
  if output:
354
333
  # kwargs.update(output)
355
- output_list.append(output)
356
- phase.outputs = {k:v for k, v in output.items() if k not in arg_type.keys()}
334
+ for output_dict in output:
335
+ output_list.append(output_dict)
336
+ phase.outputs = output
357
337
  phase.end_time = datetime.now()
358
338
  db.session.commit()
339
+ return output_list
359
340
 
360
- def _run_repeat_section(self, repeat_count, arg_types, bo_args, output_list, script, run_name, return_list, compiled,
361
- logger, socketio, history, output_path, run_id, optimizer=None):
341
+ async def _run_repeat_section(self, repeat_count, arg_types, bo_args, output_list, script, run_name, return_list, compiled,
342
+ logger, socketio, history, output_path, run_id, optimizer=None, batch_mode=None,
343
+ batch_size=None, objectives=None):
362
344
  if bo_args:
363
345
  logger.info('Initializing optimizer...')
364
346
  if compiled:
365
347
  ax_client = bo_campaign.ax_init_opc(bo_args)
366
348
  else:
367
349
  if history:
368
- import pandas as pd
369
350
  file_path = os.path.join(output_path, history)
370
351
  previous_runs = pd.read_csv(file_path).to_dict(orient='records')
371
352
  ax_client = bo_campaign.ax_init_form(bo_args, arg_types, len(previous_runs))
@@ -378,12 +359,26 @@ class ScriptRunner:
378
359
  else:
379
360
  ax_client = bo_campaign.ax_init_form(bo_args, arg_types)
380
361
  elif optimizer and history:
381
- import pandas as pd
382
362
  file_path = os.path.join(output_path, history)
383
363
 
384
364
  previous_runs = pd.read_csv(file_path)
365
+
366
+ expected_cols = list(arg_types.keys()) + list(return_list)
367
+
368
+ actual_cols = previous_runs.columns.tolist()
369
+
370
+ # NOT okay if it misses columns
371
+ if set(expected_cols) - set(actual_cols):
372
+ logger.warning(f"Missing columns from history .csv file. Expecting {expected_cols} but got {actual_cols}")
373
+ raise ValueError("Missing columns from history .csv file.")
374
+
375
+ # okay if there is extra columns
376
+ if set(actual_cols) - set(expected_cols):
377
+ logger.warning(f"Extra columns from history .csv file. Expecting {expected_cols} but got {actual_cols}")
378
+
385
379
  optimizer.append_existing_data(previous_runs)
386
- for row in previous_runs:
380
+
381
+ for row in previous_runs.to_dict(orient='records'):
387
382
  output_list.append(row)
388
383
 
389
384
 
@@ -409,12 +404,13 @@ class ScriptRunner:
409
404
  if bo_args:
410
405
  try:
411
406
  parameters, trial_index = ax_client.get_next_trial()
412
- logger.info(f'Output value: {parameters}')
407
+ logger.info(f'Params value: {parameters}')
413
408
  # fname = f"{run_name}_script"
414
409
  # function = self.globals_dict[fname]
415
410
  phase.parameters = parameters
416
-
417
- output = self.exec_steps(script, "script", logger, socketio, phase_id, **parameters)
411
+ if not type(parameters) is list:
412
+ parameters = [parameters]
413
+ output = await self.exec_steps(script, "script", phase_id, parameters)
418
414
 
419
415
  _output = {key: value for key, value in output.items() if key in return_list}
420
416
  ax_client.complete_trial(trial_index=trial_index, raw_data=_output)
@@ -422,30 +418,41 @@ class ScriptRunner:
422
418
  except Exception as e:
423
419
  logger.info(f'Optimization error: {e}')
424
420
  break
421
+ # Optimizer for UI
425
422
  elif optimizer:
426
423
  try:
427
- parameters = optimizer.suggest(1)
428
- logger.info(f'Output value: {parameters}')
424
+ parameters = optimizer.suggest(n=batch_size)
425
+ logger.info(f'Parameters: {parameters}')
429
426
  phase.parameters = parameters
430
- output = self.exec_steps(script, "script", logger, socketio, phase_id, **parameters)
427
+
428
+ output = await self.exec_steps(script, "script", phase_id, kwargs_list=parameters)
431
429
  if output:
432
430
  optimizer.observe(output)
433
- output.update(parameters)
431
+
432
+ else:
433
+ logger.info('No output from script')
434
+
435
+
434
436
  except Exception as e:
435
437
  logger.info(f'Optimization error: {e}')
436
438
  break
437
439
  else:
438
440
  # fname = f"{run_name}_script"
439
441
  # function = self.globals_dict[fname]
440
- output = self.exec_steps(script, "script", logger, socketio, phase_id)
441
-
442
+ output = await self.exec_steps(script, "script", phase_id, batch_size=batch_size)
442
443
  if output:
443
- output_list.append(output)
444
+ # print("output: ", output)
445
+ output_list.extend(output)
444
446
  logger.info(f'Output value: {output}')
445
447
  phase.outputs = output
446
448
  phase.end_time = datetime.now()
447
449
  db.session.commit()
448
-
450
+
451
+ early_stop = self._check_early_stop(output, objectives)
452
+ if early_stop:
453
+ logger.info('Early stopping')
454
+ break
455
+
449
456
  if bo_args:
450
457
  ax_client.save_to_json_file(os.path.join(output_path, f"{run_name}_ax_client.json"))
451
458
  logger.info(
@@ -455,14 +462,14 @@ class ScriptRunner:
455
462
 
456
463
  @staticmethod
457
464
  def _save_results(run_name, arg_type, return_list, output_list, logger, output_path):
458
- args = list(arg_type.keys()) if arg_type else []
459
- args.extend(return_list)
465
+ output_columns = list(arg_type.keys()) + list(return_list)
466
+
460
467
  filename = run_name + "_" + datetime.now().strftime("%Y-%m-%d %H-%M") + ".csv"
461
468
  file_path = os.path.join(output_path, filename)
462
- with open(file_path, "w", newline='') as file:
463
- writer = csv.DictWriter(file, fieldnames=args)
464
- writer.writeheader()
465
- writer.writerows(output_list)
469
+ df = pd.DataFrame(output_list)
470
+ df = df.loc[:, [c for c in output_columns if c in df.columns]]
471
+
472
+ df. to_csv(file_path, index=False)
466
473
  logger.info(f'Results saved to {file_path}')
467
474
  return filename
468
475
 
@@ -486,4 +493,297 @@ class ScriptRunner:
486
493
  "paused": self.paused,
487
494
  "stop_pending": self.stop_pending_event.is_set(),
488
495
  "stop_current": self.stop_current_event.is_set(),
489
- }
496
+ }
497
+
498
+
499
+ async def _execute_steps_batched(self, steps: List[Dict], contexts: List[Dict[str, Any]], phase_id, step_index, section_name):
500
+ """
501
+ Execute a list of steps for multiple samples, batching where appropriate.
502
+ """
503
+ for step in steps:
504
+ action = step["action"]
505
+
506
+ if action == "if":
507
+ await self._execute_if_batched(step, contexts, phase_id, step_index, section_name)
508
+ elif action == "repeat":
509
+ await self._execute_repeat_batched(step, contexts, phase_id, step_index, section_name)
510
+ elif action == "while":
511
+ await self._execute_while_batched(step, contexts, phase_id, step_index, section_name)
512
+ else:
513
+ # Regular action - check if batch
514
+ if step.get("batch_action", False):
515
+ # Execute once for all samples
516
+ await self._execute_action_once(step, contexts[0], phase_id=phase_id, step_index=step_index, section_name=section_name)
517
+ else:
518
+ # Execute for each sample
519
+ for context in contexts:
520
+ await self._execute_action(step, context, phase_id=phase_id, step_index=step_index, section_name=section_name)
521
+
522
+
523
+ async def _execute_if_batched(self, step: Dict, contexts: List[Dict[str, Any]], phase_id, step_index, section_name):
524
+ """Execute if/else block for multiple samples."""
525
+ # Evaluate condition for each sample
526
+ for context in contexts:
527
+ condition = self._evaluate_condition(step["args"]["statement"], context)
528
+
529
+ if condition:
530
+ await self._execute_steps_batched(step["if_block"], [context], phase_id=phase_id, step_index=step_index, section_name=section_name)
531
+ else:
532
+ await self._execute_steps_batched(step["else_block"], [context], phase_id=phase_id, step_index=step_index, section_name=section_name)
533
+
534
+
535
+ async def _execute_repeat_batched(self, step: Dict, contexts: List[Dict[str, Any]], phase_id, step_index, section_name):
536
+ """Execute repeat block for multiple samples."""
537
+ times = step["args"].get("times", 1)
538
+
539
+ for i in range(times):
540
+ # Add repeat index to all contexts
541
+ for context in contexts:
542
+ context["repeat_index"] = i
543
+
544
+ await self._execute_steps_batched(step["repeat_block"], contexts, phase_id=phase_id, step_index=step_index, section_name=section_name)
545
+
546
+
547
+ async def _execute_while_batched(self, step: Dict, contexts: List[Dict[str, Any]], phase_id, step_index, section_name):
548
+ """Execute while block for multiple samples."""
549
+ max_iterations = step["args"].get("max_iterations", 1000)
550
+ active_contexts = contexts.copy()
551
+ iteration = 0
552
+
553
+ while iteration < max_iterations and active_contexts:
554
+ # Filter contexts that still meet the condition
555
+ still_active = []
556
+
557
+ for context in active_contexts:
558
+ condition = self._evaluate_condition(step["args"]["statement"], context)
559
+
560
+ if condition:
561
+ context["while_index"] = iteration
562
+ still_active.append(context)
563
+
564
+ if not still_active:
565
+ break
566
+
567
+ # Execute for contexts that are still active
568
+ await self._execute_steps_batched(step["while_block"], still_active, phase_id=phase_id, step_index=step_index, section_name=section_name)
569
+ active_contexts = still_active
570
+ iteration += 1
571
+
572
+ if iteration >= max_iterations:
573
+ raise RuntimeError(f"While loop exceeded max iterations ({max_iterations})")
574
+
575
+ async def _execute_action(self, step: Dict, context: Dict[str, Any], phase_id=1, step_index=1, section_name=None):
576
+ """Execute a single action with parameter substitution."""
577
+ # Substitute parameters in args
578
+ substituted_args = self._substitute_params(step["args"], context)
579
+
580
+ # Get the component and method
581
+ instrument = step.get("instrument", "")
582
+ action = step["action"]
583
+ if instrument and "." in instrument:
584
+ instrument_type, instrument = instrument.split(".")
585
+ else:
586
+ instrument_type = ""
587
+ # Execute the action
588
+ step_db = WorkflowStep(
589
+ phase_id=phase_id,
590
+ step_index=step_index,
591
+ method_name=action,
592
+ start_time=datetime.now(),
593
+ )
594
+ db.session.add(step_db)
595
+ db.session.flush()
596
+ try:
597
+ self.socketio.emit('execution', {'section': f"{section_name}-{step_index}"})
598
+ if action == "wait":
599
+ duration = float(substituted_args["statement"])
600
+ self.safe_sleep(duration)
601
+
602
+ elif action == "pause":
603
+ msg = substituted_args.get("statement", "")
604
+ pause(msg)
605
+
606
+ elif instrument_type == "deck" and hasattr(deck, instrument):
607
+ component = getattr(deck, instrument)
608
+ if hasattr(component, action):
609
+ method = getattr(component, action)
610
+
611
+ # Execute and handle return value
612
+ if step.get("coroutine", False):
613
+ result = await method(**substituted_args)
614
+ else:
615
+ result = method(**substituted_args)
616
+
617
+ # Store return value if specified
618
+ return_var = step.get("return", "")
619
+ if return_var:
620
+ context[return_var] = result
621
+
622
+ elif instrument_type == "blocks" and instrument in BUILDING_BLOCKS.keys():
623
+ # Inject all block categories
624
+ method_collection = BUILDING_BLOCKS[instrument]
625
+ if action in method_collection.keys():
626
+ method = method_collection[action]["func"]
627
+
628
+ # Execute and handle return value
629
+ # print(step.get("coroutine", False))
630
+ if step.get("coroutine", False):
631
+ result = await method(**substituted_args)
632
+ else:
633
+ result = method(**substituted_args)
634
+
635
+ # Store return value if specified
636
+ return_var = step.get("return", "")
637
+ if return_var:
638
+ context[return_var] = result
639
+ except HumanInterventionRequired as e:
640
+ self.logger.warning(f"Human intervention required: {e}")
641
+ self.socketio.emit('human_intervention', {'message': str(e)})
642
+ # Instead of auto-resume, explicitly stay paused until user action
643
+ # step.run_error = False
644
+ self.toggle_pause()
645
+
646
+ except Exception as e:
647
+ self.logger.error(f"Error during script execution: {e}")
648
+ self.socketio.emit('error', {'message': str(e)})
649
+
650
+ step_db.run_error = True
651
+ self.toggle_pause()
652
+ finally:
653
+ step_db.end_time = datetime.now()
654
+ step_db.output = context
655
+ db.session.commit()
656
+
657
+ self.pause_event.wait()
658
+
659
+ return context
660
+
661
+ async def _execute_action_once(self, step: Dict, context: Dict[str, Any], phase_id, step_index, section_name):
662
+ """Execute a batch action once (not per sample)."""
663
+ # print(f"Executing batch action: {step['action']}")
664
+ return await self._execute_action(step, context, phase_id=phase_id, step_index=step_index, section_name=section_name)
665
+
666
+ @staticmethod
667
+ def _substitute_params(args: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
668
+ """Substitute parameter placeholders like #param_1 with actual values."""
669
+ substituted = {}
670
+
671
+ for key, value in args.items():
672
+ if isinstance(value, str) and value.startswith("#"):
673
+ param_name = value[1:] # Remove '#'
674
+ substituted[key] = context.get(param_name, value)
675
+ else:
676
+ substituted[key] = value
677
+
678
+ return substituted
679
+
680
+ @staticmethod
681
+ def _evaluate_condition(condition_str: str, context: Dict[str, Any]) -> bool:
682
+ """
683
+ Safely evaluate a condition string with context variables.
684
+ """
685
+ # Create evaluation context with all variables
686
+ eval_context = {}
687
+
688
+ # Substitute variables in the condition string
689
+ substituted = condition_str
690
+ for key, value in context.items():
691
+ # Replace #variable with actual variable name for eval
692
+ substituted = substituted.replace(f"#{key}", key)
693
+ # Add variable to eval context
694
+ eval_context[key] = value
695
+
696
+ try:
697
+ # Safe evaluation with variables in scope
698
+ result = eval(substituted, {"__builtins__": {}}, eval_context)
699
+ return bool(result)
700
+ except Exception as e:
701
+ raise ValueError(f"Error evaluating condition '{condition_str}': {e}")
702
+
703
+ def _check_early_stop(self, output, objectives):
704
+ for row in output:
705
+ all_met = True
706
+ for obj in objectives:
707
+ name = obj['name']
708
+ minimize = obj.get('minimize', True)
709
+ threshold = obj.get('early_stop', None)
710
+
711
+ if threshold is None:
712
+ all_met = False
713
+ break# Skip if no early stop defined
714
+
715
+ value = row[name]
716
+ if minimize and value > threshold:
717
+ all_met = False
718
+ break
719
+ elif not minimize and value < threshold:
720
+ all_met = False
721
+ break
722
+
723
+ if all_met:
724
+ return True # At least one row meets all early stop thresholds
725
+
726
+ return False # No row met all thresholds
727
+
728
+ async def _execute_variable_batched(self, step: Dict, contexts: List[Dict[str, Any]], phase_id, step_index,
729
+ section_name):
730
+ """Execute variable assignment for multiple samples."""
731
+ var_name = step["action"] # "vial" in your example
732
+ var_value = step["args"]["statement"]
733
+ arg_type = step["arg_types"]["statement"]
734
+
735
+ for context in contexts:
736
+ # Substitute any variable references in the value
737
+ if isinstance(var_value, str):
738
+ substituted_value = var_value
739
+
740
+ # Replace all variable references (with or without #) with their values
741
+ for key, val in context.items():
742
+ # Handle both #variable and variable (without #)
743
+ substituted_value = substituted_value.replace(f"#{key}", str(val))
744
+ # For expressions like "vial+10", replace variable name directly
745
+ # Use word boundaries to avoid partial matches
746
+ import re
747
+ substituted_value = re.sub(r'\b' + re.escape(key) + r'\b', str(val), substituted_value)
748
+
749
+ # Handle based on type
750
+ if arg_type == "float":
751
+ try:
752
+ # Evaluate as expression (e.g., "10.0+10" becomes 20.0)
753
+ result = eval(substituted_value, {"__builtins__": {}}, {})
754
+ context[var_name] = float(result)
755
+ except:
756
+ # If eval fails, try direct conversion
757
+ context[var_name] = float(substituted_value)
758
+
759
+ elif arg_type == "int":
760
+ try:
761
+ result = eval(substituted_value, {"__builtins__": {}}, {})
762
+ context[var_name] = int(result)
763
+ except:
764
+ context[var_name] = int(substituted_value)
765
+
766
+ elif arg_type == "bool":
767
+ try:
768
+ # Evaluate boolean expressions
769
+ result = eval(substituted_value, {"__builtins__": {}}, {})
770
+ context[var_name] = bool(result)
771
+ except:
772
+ context[var_name] = substituted_value.lower() in ['true', '1', 'yes']
773
+
774
+ else: # "str"
775
+ # For strings, check if it looks like an expression
776
+ if any(char in substituted_value for char in ['+', '-', '*', '/', '>', '<', '=', '(', ')']):
777
+ try:
778
+ # Try to evaluate as expression
779
+ result = eval(substituted_value, {"__builtins__": {}}, {})
780
+ context[var_name] = result
781
+ except:
782
+ # If eval fails, store as string
783
+ context[var_name] = substituted_value
784
+ else:
785
+ context[var_name] = substituted_value
786
+ else:
787
+ # Direct numeric or boolean value
788
+ context[var_name] = var_value
789
+