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

Potentially problematic release.


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

Files changed (35) hide show
  1. ivoryos/optimizer/ax_optimizer.py +47 -25
  2. ivoryos/optimizer/base_optimizer.py +19 -1
  3. ivoryos/optimizer/baybe_optimizer.py +27 -17
  4. ivoryos/optimizer/nimo_optimizer.py +25 -16
  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 +71 -13
  14. ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
  15. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  16. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  17. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  18. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  19. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  20. ivoryos/socket_handlers.py +1 -1
  21. ivoryos/static/js/action_handlers.js +252 -118
  22. ivoryos/static/js/sortable_design.js +1 -0
  23. ivoryos/utils/bo_campaign.py +17 -16
  24. ivoryos/utils/db_models.py +122 -18
  25. ivoryos/utils/decorators.py +1 -0
  26. ivoryos/utils/form.py +32 -12
  27. ivoryos/utils/nest_script.py +314 -0
  28. ivoryos/utils/script_runner.py +436 -143
  29. ivoryos/utils/utils.py +11 -1
  30. ivoryos/version.py +1 -1
  31. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/METADATA +6 -4
  32. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/RECORD +35 -34
  33. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/WHEEL +0 -0
  34. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.dist-info}/licenses/LICENSE +0 -0
  35. {ivoryos-1.3.9.dist-info → ivoryos-1.4.1.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
@@ -131,112 +144,54 @@ class ScriptRunner:
131
144
  # exec_globals = {"deck": deck, "time": time, "registered_workflows":registered_workflows} # Add required global objects
132
145
  exec_globals.update(temp_connections)
133
146
 
134
- # Inject all block categories
135
- for category, data in BUILDING_BLOCKS.items():
136
- for method_name, method in data.items():
137
- exec_globals[method_name] = method["func"]
138
-
139
147
  exec_locals = {} # Local execution scope
140
148
 
141
149
  # Define function arguments manually in exec_locals
142
- exec_locals.update(kwargs)
150
+ # exec_locals.update(kwargs)
143
151
  index = 0
144
-
145
- # Execute each line dynamically
146
- while index < len(step_list):
147
- if self.stop_current_event.is_set():
148
- logger.info(f'Stopping execution during {section_name}')
149
- step = WorkflowStep(
150
- phase_id=phase_id,
151
- # phase=section_name,
152
- # repeat_index=i_progress,
153
- step_index=index,
154
- method_name="stop",
155
- start_time=datetime.now(),
156
- end_time=datetime.now(),
157
- run_error=False,
158
- )
159
- db.session.add(step)
160
- break
161
- line = step_list[index]
162
-
163
- method_name = line.strip()
164
- # start_time = datetime.now()
165
-
166
- step = WorkflowStep(
167
- phase_id=phase_id,
168
- # phase=section_name,
169
- # repeat_index=i_progress,
170
- step_index=index,
171
- method_name=method_name,
172
- start_time=datetime.now(),
173
- )
174
- db.session.add(step)
175
- db.session.flush()
176
-
177
- logger.info(f"Executing: {line}")
178
- socketio.emit('execution', {'section': f"{section_name}-{index}"})
179
-
180
- # if line.startswith("registered_workflows"):
181
- # line = line.replace("registered_workflows.", "")
182
- try:
183
- if line.startswith("time.sleep("): # add safe sleep for time.sleep lines
184
- duration_str = line.strip()[len("time.sleep("):-1]
185
- duration = float(duration_str)
186
- 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)
187
179
  else:
188
- if "await " in line:
189
- async_code = f"async def __async_exec_wrapper():\n"
190
- # indent all code lines by 4 spaces
191
- async_code += "\n".join(" " + line for line in line.splitlines())
192
- async_code += f"\n return locals()"
193
- exec(async_code, exec_globals, exec_locals)
194
- func = exec_locals.get("__async_exec_wrapper") or exec_globals.get("__async_exec_wrapper")
195
- # Capture the return value from asyncio.run
196
- result_locals = asyncio.run(func())
197
-
198
- # Update exec_locals with the returned locals
199
- exec_locals.update(result_locals)
200
-
201
-
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()
202
185
  else:
203
- # print("just exec synchronously")
204
- exec(line, exec_globals, exec_locals)
205
- exec_globals.update(exec_locals)
206
- # return locals_dict
207
- # exec(line, exec_globals, exec_locals)
208
- # step.run_error = False
209
-
210
- except HumanInterventionRequired as e:
211
- logger.warning(f"Human intervention required: {e}")
212
- socketio.emit('human_intervention', {'message': str(e)})
213
- # Instead of auto-resume, explicitly stay paused until user action
214
- # step.run_error = False
215
- self.toggle_pause()
216
-
217
- except Exception as e:
218
- logger.error(f"Error during script execution: {e}")
219
- socketio.emit('error', {'message': str(e)})
220
-
221
- step.run_error = True
222
- self.toggle_pause()
223
- exec_locals.pop("__async_exec_wrapper", None)
224
- step.end_time = datetime.now()
225
- step.output = exec_locals
226
- db.session.commit()
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
- self.pause_event.wait()
229
-
230
- # todo update script during the run
231
- # _func_str = script.compile()
232
- # step_list: list = script.convert_to_lines(_func_str).get(section_name, [])
233
- if not step.run_error or not self.retry:
234
- index += 1
235
- output = {key: value for key, value in exec_locals.items() if key in return_list}
236
- return output # Return the 'results' variable
190
+ return results # Return the 'results' variable
237
191
 
238
192
  def _run_with_stop_check(self, script: Script, repeat_count: int, run_name: str, logger, socketio, config, bo_args,
239
- 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):
240
195
  time.sleep(1)
241
196
  # _func_str = script.compile()
242
197
  # step_list_dict: dict = script.convert_to_lines(_func_str)
@@ -255,26 +210,37 @@ class ScriptRunner:
255
210
  db.session.commit()
256
211
 
257
212
  try:
258
-
213
+ # if True:
259
214
  global_config.runner_status = {"id":run_id, "type": "workflow"}
260
215
  # Run "prep" section once
261
- 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))
262
217
  output_list = []
263
218
  _, arg_type = script.config("script")
264
219
  _, return_list = script.config_return()
265
220
  # Run "script" section multiple times
266
221
  if repeat_count:
267
- 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,
268
224
  run_name, return_list, compiled, logger, socketio,
269
- 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
+ )
270
228
  elif config:
271
- self._run_config_section(config, arg_type, output_list, script, run_name, logger,
272
- 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)
273
237
  # Run "cleanup" section once
274
- 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))
275
239
  # Reset the running flag when done
276
240
  # Save results if necessary
277
241
  if not script.python_script and return_list:
242
+ # print(output_list)
243
+
278
244
  filename = self._save_results(run_name, arg_type, return_list, output_list, logger, output_path)
279
245
  self._emit_progress(socketio, 100)
280
246
 
@@ -282,7 +248,8 @@ class ScriptRunner:
282
248
  logger.error(f"Error during script execution: {e.__str__()}")
283
249
  error_flag = True
284
250
  finally:
285
- self.lock.release()
251
+ if self.lock.locked():
252
+ self.lock.release()
286
253
  with current_app.app_context():
287
254
  run = db.session.get(WorkflowRun, run_id)
288
255
  if run is None:
@@ -294,7 +261,7 @@ class ScriptRunner:
294
261
  db.session.commit()
295
262
 
296
263
 
297
- 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):
298
265
  _func_str = script.python_script or script.compile()
299
266
  step_list: list = script.convert_to_lines(_func_str).get(section_name, [])
300
267
  if not step_list:
@@ -316,14 +283,15 @@ class ScriptRunner:
316
283
  db.session.flush()
317
284
  phase_id = phase.id
318
285
 
319
- 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)
320
287
  # Save phase-level output
321
288
  phase.outputs = step_outputs
322
289
  phase.end_time = datetime.now()
323
290
  db.session.commit()
324
291
  return step_outputs
325
292
 
326
- 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):
327
295
  if not compiled:
328
296
  for i in config:
329
297
  try:
@@ -334,13 +302,16 @@ class ScriptRunner:
334
302
  compiled = False
335
303
  break
336
304
  if compiled:
337
- for i, kwargs in enumerate(config):
338
- 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)
339
310
  if self.stop_pending_event.is_set():
340
311
  logger.info(f'Stopping execution during {run_name}: {i + 1}/{len(config)}')
341
312
  break
342
- logger.info(f'Executing {i + 1} of {len(config)} with kwargs = {kwargs}')
343
- 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
344
315
  self._emit_progress(socketio, progress)
345
316
  # fname = f"{run_name}_script"
346
317
  # function = self.globals_dict[fname]
@@ -349,30 +320,33 @@ class ScriptRunner:
349
320
  run_id=run_id,
350
321
  name="main",
351
322
  repeat_index=i,
352
- parameters=kwargs,
323
+ parameters=kwargs_list,
353
324
  start_time=datetime.now()
354
325
  )
355
326
  db.session.add(phase)
356
327
  db.session.flush()
357
328
 
358
329
  phase_id = phase.id
359
- 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)
360
332
  if output:
361
333
  # kwargs.update(output)
362
- output_list.append(output)
363
- 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
364
337
  phase.end_time = datetime.now()
365
338
  db.session.commit()
339
+ return output_list
366
340
 
367
- def _run_repeat_section(self, repeat_count, arg_types, bo_args, output_list, script, run_name, return_list, compiled,
368
- 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):
369
344
  if bo_args:
370
345
  logger.info('Initializing optimizer...')
371
346
  if compiled:
372
347
  ax_client = bo_campaign.ax_init_opc(bo_args)
373
348
  else:
374
349
  if history:
375
- import pandas as pd
376
350
  file_path = os.path.join(output_path, history)
377
351
  previous_runs = pd.read_csv(file_path).to_dict(orient='records')
378
352
  ax_client = bo_campaign.ax_init_form(bo_args, arg_types, len(previous_runs))
@@ -385,12 +359,26 @@ class ScriptRunner:
385
359
  else:
386
360
  ax_client = bo_campaign.ax_init_form(bo_args, arg_types)
387
361
  elif optimizer and history:
388
- import pandas as pd
389
362
  file_path = os.path.join(output_path, history)
390
363
 
391
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
+
392
379
  optimizer.append_existing_data(previous_runs)
393
- for row in previous_runs:
380
+
381
+ for row in previous_runs.to_dict(orient='records'):
394
382
  output_list.append(row)
395
383
 
396
384
 
@@ -416,12 +404,13 @@ class ScriptRunner:
416
404
  if bo_args:
417
405
  try:
418
406
  parameters, trial_index = ax_client.get_next_trial()
419
- logger.info(f'Output value: {parameters}')
407
+ logger.info(f'Params value: {parameters}')
420
408
  # fname = f"{run_name}_script"
421
409
  # function = self.globals_dict[fname]
422
410
  phase.parameters = parameters
423
-
424
- 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)
425
414
 
426
415
  _output = {key: value for key, value in output.items() if key in return_list}
427
416
  ax_client.complete_trial(trial_index=trial_index, raw_data=_output)
@@ -432,28 +421,38 @@ class ScriptRunner:
432
421
  # Optimizer for UI
433
422
  elif optimizer:
434
423
  try:
435
- parameters = optimizer.suggest(1)
436
- logger.info(f'Output value: {parameters}')
424
+ parameters = optimizer.suggest(n=batch_size)
425
+ logger.info(f'Parameters: {parameters}')
437
426
  phase.parameters = parameters
438
- 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)
439
429
  if output:
440
430
  optimizer.observe(output)
441
- output.update(parameters)
431
+
432
+ else:
433
+ logger.info('No output from script')
434
+
435
+
442
436
  except Exception as e:
443
437
  logger.info(f'Optimization error: {e}')
444
438
  break
445
439
  else:
446
440
  # fname = f"{run_name}_script"
447
441
  # function = self.globals_dict[fname]
448
- output = self.exec_steps(script, "script", logger, socketio, phase_id)
449
-
442
+ output = await self.exec_steps(script, "script", phase_id, batch_size=batch_size)
450
443
  if output:
451
- output_list.append(output)
444
+ # print("output: ", output)
445
+ output_list.extend(output)
452
446
  logger.info(f'Output value: {output}')
453
447
  phase.outputs = output
454
448
  phase.end_time = datetime.now()
455
449
  db.session.commit()
456
-
450
+
451
+ early_stop = self._check_early_stop(output, objectives)
452
+ if early_stop:
453
+ logger.info('Early stopping')
454
+ break
455
+
457
456
  if bo_args:
458
457
  ax_client.save_to_json_file(os.path.join(output_path, f"{run_name}_ax_client.json"))
459
458
  logger.info(
@@ -463,14 +462,14 @@ class ScriptRunner:
463
462
 
464
463
  @staticmethod
465
464
  def _save_results(run_name, arg_type, return_list, output_list, logger, output_path):
466
- args = list(arg_type.keys()) if arg_type else []
467
- args.extend(return_list)
465
+ output_columns = list(arg_type.keys()) + list(return_list)
466
+
468
467
  filename = run_name + "_" + datetime.now().strftime("%Y-%m-%d %H-%M") + ".csv"
469
468
  file_path = os.path.join(output_path, filename)
470
- with open(file_path, "w", newline='') as file:
471
- writer = csv.DictWriter(file, fieldnames=args)
472
- writer.writeheader()
473
- 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)
474
473
  logger.info(f'Results saved to {file_path}')
475
474
  return filename
476
475
 
@@ -494,4 +493,298 @@ class ScriptRunner:
494
493
  "paused": self.paused,
495
494
  "stop_pending": self.stop_pending_event.is_set(),
496
495
  "stop_current": self.stop_current_event.is_set(),
497
- }
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("statement", 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
+ # print(f"step {section_name}-{step_index}")
598
+ self.socketio.emit('execution', {'section': f"{section_name}-{step_index-1}"})
599
+ if action == "wait":
600
+ duration = float(substituted_args["statement"])
601
+ self.safe_sleep(duration)
602
+
603
+ elif action == "pause":
604
+ msg = substituted_args.get("statement", "")
605
+ pause(msg)
606
+
607
+ elif instrument_type == "deck" and hasattr(deck, instrument):
608
+ component = getattr(deck, instrument)
609
+ if hasattr(component, action):
610
+ method = getattr(component, action)
611
+
612
+ # Execute and handle return value
613
+ if step.get("coroutine", False):
614
+ result = await method(**substituted_args)
615
+ else:
616
+ result = method(**substituted_args)
617
+
618
+ # Store return value if specified
619
+ return_var = step.get("return", "")
620
+ if return_var:
621
+ context[return_var] = result
622
+
623
+ elif instrument_type == "blocks" and instrument in BUILDING_BLOCKS.keys():
624
+ # Inject all block categories
625
+ method_collection = BUILDING_BLOCKS[instrument]
626
+ if action in method_collection.keys():
627
+ method = method_collection[action]["func"]
628
+
629
+ # Execute and handle return value
630
+ # print(step.get("coroutine", False))
631
+ if step.get("coroutine", False):
632
+ result = await method(**substituted_args)
633
+ else:
634
+ result = method(**substituted_args)
635
+
636
+ # Store return value if specified
637
+ return_var = step.get("return", "")
638
+ if return_var:
639
+ context[return_var] = result
640
+ except HumanInterventionRequired as e:
641
+ self.logger.warning(f"Human intervention required: {e}")
642
+ self.socketio.emit('human_intervention', {'message': str(e)})
643
+ # Instead of auto-resume, explicitly stay paused until user action
644
+ # step.run_error = False
645
+ self.toggle_pause()
646
+
647
+ except Exception as e:
648
+ self.logger.error(f"Error during script execution: {e}")
649
+ self.socketio.emit('error', {'message': str(e)})
650
+
651
+ step_db.run_error = True
652
+ self.toggle_pause()
653
+ finally:
654
+ step_db.end_time = datetime.now()
655
+ step_db.output = context
656
+ db.session.commit()
657
+
658
+ self.pause_event.wait()
659
+
660
+ return context
661
+
662
+ async def _execute_action_once(self, step: Dict, context: Dict[str, Any], phase_id, step_index, section_name):
663
+ """Execute a batch action once (not per sample)."""
664
+ # print(f"Executing batch action: {step['action']}")
665
+ return await self._execute_action(step, context, phase_id=phase_id, step_index=step_index, section_name=section_name)
666
+
667
+ @staticmethod
668
+ def _substitute_params(args: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
669
+ """Substitute parameter placeholders like #param_1 with actual values."""
670
+ substituted = {}
671
+
672
+ for key, value in args.items():
673
+ if isinstance(value, str) and value.startswith("#"):
674
+ param_name = value[1:] # Remove '#'
675
+ substituted[key] = context.get(param_name, value)
676
+ else:
677
+ substituted[key] = value
678
+
679
+ return substituted
680
+
681
+ @staticmethod
682
+ def _evaluate_condition(condition_str: str, context: Dict[str, Any]) -> bool:
683
+ """
684
+ Safely evaluate a condition string with context variables.
685
+ """
686
+ # Create evaluation context with all variables
687
+ eval_context = {}
688
+
689
+ # Substitute variables in the condition string
690
+ substituted = condition_str
691
+ for key, value in context.items():
692
+ # Replace #variable with actual variable name for eval
693
+ substituted = substituted.replace(f"#{key}", key)
694
+ # Add variable to eval context
695
+ eval_context[key] = value
696
+
697
+ try:
698
+ # Safe evaluation with variables in scope
699
+ result = eval(substituted, {"__builtins__": {}}, eval_context)
700
+ return bool(result)
701
+ except Exception as e:
702
+ raise ValueError(f"Error evaluating condition '{condition_str}': {e}")
703
+
704
+ def _check_early_stop(self, output, objectives):
705
+ for row in output:
706
+ all_met = True
707
+ for obj in objectives:
708
+ name = obj['name']
709
+ minimize = obj.get('minimize', True)
710
+ threshold = obj.get('early_stop', None)
711
+
712
+ if threshold is None:
713
+ all_met = False
714
+ break# Skip if no early stop defined
715
+
716
+ value = row[name]
717
+ if minimize and value > threshold:
718
+ all_met = False
719
+ break
720
+ elif not minimize and value < threshold:
721
+ all_met = False
722
+ break
723
+
724
+ if all_met:
725
+ return True # At least one row meets all early stop thresholds
726
+
727
+ return False # No row met all thresholds
728
+
729
+ async def _execute_variable_batched(self, step: Dict, contexts: List[Dict[str, Any]], phase_id, step_index,
730
+ section_name):
731
+ """Execute variable assignment for multiple samples."""
732
+ var_name = step["action"] # "vial" in your example
733
+ var_value = step["args"]["statement"]
734
+ arg_type = step["arg_types"]["statement"]
735
+
736
+ for context in contexts:
737
+ # Substitute any variable references in the value
738
+ if isinstance(var_value, str):
739
+ substituted_value = var_value
740
+
741
+ # Replace all variable references (with or without #) with their values
742
+ for key, val in context.items():
743
+ # Handle both #variable and variable (without #)
744
+ substituted_value = substituted_value.replace(f"#{key}", str(val))
745
+ # For expressions like "vial+10", replace variable name directly
746
+ # Use word boundaries to avoid partial matches
747
+ import re
748
+ substituted_value = re.sub(r'\b' + re.escape(key) + r'\b', str(val), substituted_value)
749
+
750
+ # Handle based on type
751
+ if arg_type == "float":
752
+ try:
753
+ # Evaluate as expression (e.g., "10.0+10" becomes 20.0)
754
+ result = eval(substituted_value, {"__builtins__": {}}, {})
755
+ context[var_name] = float(result)
756
+ except:
757
+ # If eval fails, try direct conversion
758
+ context[var_name] = float(substituted_value)
759
+
760
+ elif arg_type == "int":
761
+ try:
762
+ result = eval(substituted_value, {"__builtins__": {}}, {})
763
+ context[var_name] = int(result)
764
+ except:
765
+ context[var_name] = int(substituted_value)
766
+
767
+ elif arg_type == "bool":
768
+ try:
769
+ # Evaluate boolean expressions
770
+ result = eval(substituted_value, {"__builtins__": {}}, {})
771
+ context[var_name] = bool(result)
772
+ except:
773
+ context[var_name] = substituted_value.lower() in ['true', '1', 'yes']
774
+
775
+ else: # "str"
776
+ # For strings, check if it looks like an expression
777
+ if any(char in substituted_value for char in ['+', '-', '*', '/', '>', '<', '=', '(', ')']):
778
+ try:
779
+ # Try to evaluate as expression
780
+ result = eval(substituted_value, {"__builtins__": {}}, {})
781
+ context[var_name] = result
782
+ except:
783
+ # If eval fails, store as string
784
+ context[var_name] = substituted_value
785
+ else:
786
+ context[var_name] = substituted_value
787
+ else:
788
+ # Direct numeric or boolean value
789
+ context[var_name] = var_value
790
+