ivoryos 1.2.5__py3-none-any.whl → 1.4.4__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 (75) hide show
  1. docs/source/conf.py +84 -0
  2. ivoryos/__init__.py +16 -246
  3. ivoryos/app.py +154 -0
  4. ivoryos/optimizer/ax_optimizer.py +55 -28
  5. ivoryos/optimizer/base_optimizer.py +20 -1
  6. ivoryos/optimizer/baybe_optimizer.py +27 -17
  7. ivoryos/optimizer/nimo_optimizer.py +173 -0
  8. ivoryos/optimizer/registry.py +3 -1
  9. ivoryos/routes/auth/auth.py +35 -8
  10. ivoryos/routes/auth/templates/change_password.html +32 -0
  11. ivoryos/routes/control/control.py +58 -28
  12. ivoryos/routes/control/control_file.py +12 -15
  13. ivoryos/routes/control/control_new_device.py +21 -11
  14. ivoryos/routes/control/templates/controllers.html +27 -0
  15. ivoryos/routes/control/utils.py +2 -0
  16. ivoryos/routes/data/data.py +110 -44
  17. ivoryos/routes/data/templates/components/step_card.html +78 -13
  18. ivoryos/routes/data/templates/workflow_view.html +343 -113
  19. ivoryos/routes/design/design.py +59 -10
  20. ivoryos/routes/design/design_file.py +3 -3
  21. ivoryos/routes/design/design_step.py +43 -17
  22. ivoryos/routes/design/templates/components/action_form.html +2 -2
  23. ivoryos/routes/design/templates/components/canvas_main.html +6 -1
  24. ivoryos/routes/design/templates/components/edit_action_form.html +18 -3
  25. ivoryos/routes/design/templates/components/info_modal.html +318 -0
  26. ivoryos/routes/design/templates/components/instruments_panel.html +23 -1
  27. ivoryos/routes/design/templates/components/python_code_overlay.html +27 -10
  28. ivoryos/routes/design/templates/experiment_builder.html +3 -0
  29. ivoryos/routes/execute/execute.py +82 -22
  30. ivoryos/routes/execute/templates/components/logging_panel.html +50 -25
  31. ivoryos/routes/execute/templates/components/run_tabs.html +45 -2
  32. ivoryos/routes/execute/templates/components/tab_bayesian.html +447 -325
  33. ivoryos/routes/execute/templates/components/tab_configuration.html +303 -18
  34. ivoryos/routes/execute/templates/components/tab_repeat.html +6 -2
  35. ivoryos/routes/execute/templates/experiment_run.html +0 -264
  36. ivoryos/routes/library/library.py +9 -11
  37. ivoryos/routes/main/main.py +30 -2
  38. ivoryos/server.py +180 -0
  39. ivoryos/socket_handlers.py +1 -1
  40. ivoryos/static/ivoryos_logo.png +0 -0
  41. ivoryos/static/js/action_handlers.js +259 -88
  42. ivoryos/static/js/socket_handler.js +40 -5
  43. ivoryos/static/js/sortable_design.js +29 -11
  44. ivoryos/templates/base.html +61 -2
  45. ivoryos/utils/bo_campaign.py +18 -17
  46. ivoryos/utils/client_proxy.py +267 -36
  47. ivoryos/utils/db_models.py +286 -60
  48. ivoryos/utils/decorators.py +34 -0
  49. ivoryos/utils/form.py +52 -19
  50. ivoryos/utils/global_config.py +21 -0
  51. ivoryos/utils/nest_script.py +314 -0
  52. ivoryos/utils/py_to_json.py +80 -10
  53. ivoryos/utils/script_runner.py +573 -189
  54. ivoryos/utils/task_runner.py +69 -22
  55. ivoryos/utils/utils.py +48 -5
  56. ivoryos/version.py +1 -1
  57. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/METADATA +109 -47
  58. ivoryos-1.4.4.dist-info/RECORD +119 -0
  59. ivoryos-1.4.4.dist-info/top_level.txt +3 -0
  60. tests/__init__.py +0 -0
  61. tests/conftest.py +133 -0
  62. tests/integration/__init__.py +0 -0
  63. tests/integration/test_route_auth.py +80 -0
  64. tests/integration/test_route_control.py +94 -0
  65. tests/integration/test_route_database.py +61 -0
  66. tests/integration/test_route_design.py +36 -0
  67. tests/integration/test_route_main.py +35 -0
  68. tests/integration/test_sockets.py +26 -0
  69. tests/unit/test_type_conversion.py +42 -0
  70. tests/unit/test_util.py +3 -0
  71. ivoryos/routes/api/api.py +0 -56
  72. ivoryos-1.2.5.dist-info/RECORD +0 -100
  73. ivoryos-1.2.5.dist-info/top_level.txt +0 -1
  74. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/WHEEL +0 -0
  75. {ivoryos-1.2.5.dist-info → ivoryos-1.4.4.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,10 @@
1
+ import importlib
1
2
  import os
2
3
  from flask import Blueprint, request, current_app, send_file, flash, redirect, url_for, session, render_template
3
4
  from flask_login import login_required
4
5
 
5
6
  from ivoryos.utils import utils
6
- from ivoryos.routes.control.utils import find_instrument_by_name
7
+ # from ivoryos.routes.control.utils import find_instrument_by_name
7
8
  from ivoryos.utils.global_config import GlobalConfig
8
9
 
9
10
  global_config = GlobalConfig()
@@ -28,16 +29,25 @@ def import_api():
28
29
  # filepath.replace('\\', '/')
29
30
  name = os.path.split(filepath)[-1].split('.')[0]
30
31
  try:
31
- spec = utils.importlib.util.spec_from_file_location(name, filepath)
32
- module = utils.importlib.util.module_from_spec(spec)
32
+ spec = importlib.util.spec_from_file_location(name, filepath)
33
+ module = importlib.util.module_from_spec(spec)
33
34
  spec.loader.exec_module(module)
34
- classes = utils.inspect.getmembers(module, utils.inspect.isclass)
35
- if len(classes) == 0:
36
- flash("Invalid import: no class found in the path")
37
- return redirect(url_for("control.controllers_home"))
38
- for i in classes:
39
- globals()[i[0]] = i[1]
40
- global_config.api_variables.add(i[0])
35
+ cls_dict = utils.create_module_snapshot(module=module)
36
+
37
+ def merge_to_global(old: dict, new: dict):
38
+ overwritten = []
39
+
40
+ for key, value in new.items():
41
+ if key in old:
42
+ overwritten.append(key) # record duplicates
43
+ old[key] = value # overwrite or insert
44
+
45
+ return overwritten
46
+
47
+ duplicates = merge_to_global(global_config.api_variables, cls_dict)
48
+ if duplicates:
49
+ # optionally, you can log duplicates
50
+ flash(f"Overwritten classes: {', '.join(duplicates)}")
41
51
  # should handle path error and file type error
42
52
  except Exception as e:
43
53
  flash(e.__str__())
@@ -107,7 +117,7 @@ def new_controller(instrument:str=None):
107
117
  args = None
108
118
  if instrument:
109
119
 
110
- device = globals()[instrument]
120
+ device = global_config.api_variables[instrument]
111
121
  args = utils.inspect.signature(device.__init__)
112
122
 
113
123
  if request.method == 'POST':
@@ -49,6 +49,24 @@
49
49
  </div>
50
50
  </div>
51
51
  {% endif %}
52
+
53
+ {% if block_variables %}
54
+ <div class="mb-4">
55
+ <h6 class="fw-bold text-secondary mb-2" style="letter-spacing: 1px;">Methods</h6>
56
+ <div class="list-group">
57
+ {% for inst in block_variables %}
58
+ <a class="list-group-item list-group-item-action d-flex align-items-center {% if instrument == inst %}active bg-warning text-dark border-0{% else %}bg-light{% endif %}"
59
+ href="{{ url_for('control.deck_controllers') }}?instrument={{ inst }}"
60
+ style="border-radius: 0.5rem; margin-bottom: 0.5rem; transition: background 0.2s;">
61
+ <span class="flex-grow-1">{{ inst | format_name }}</span>
62
+ {% if instrument == inst %}
63
+ <span class="ms-auto">&gt;</span>
64
+ {% endif %}
65
+ </a>
66
+ {% endfor %}
67
+ </div>
68
+ </div>
69
+ {% endif %}
52
70
  <!-- Action Buttons -->
53
71
  <div class="mb-4">
54
72
  <a href="{{ url_for('control.file.download_proxy', filetype='proxy') }}" class="btn btn-outline-primary w-100 mb-2">
@@ -89,6 +107,15 @@
89
107
  {{ field(class="btn btn-dark") }}
90
108
  {% elif field.type == "BooleanField" %}
91
109
  {{ field(class="form-check-input") }}
110
+ {% elif field.type == "FlexibleEnumField" %}
111
+ <input type="text" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.data }}"
112
+ list="{{ field.id }}_options" placeholder="{{ field.render_kw.placeholder if field.render_kw and field.render_kw.placeholder }}"
113
+ class="form-control">
114
+ <datalist id="{{ field.id }}_options">
115
+ {% for key in field.choices %}
116
+ <option value="{{ key }}">{{ key }}</option>
117
+ {% endfor %}
118
+ </datalist>
92
119
  {% else %}
93
120
  {{ field(class="form-control") }}
94
121
  {% endif %}
@@ -13,6 +13,8 @@ def find_instrument_by_name(name: str):
13
13
  if name.startswith("deck"):
14
14
  name = name.replace("deck.", "")
15
15
  return getattr(global_config.deck, name)
16
+ elif name.startswith("blocks"):
17
+ return global_config.building_blocks[name]
16
18
  elif name in global_config.defined_variables:
17
19
  return global_config.defined_variables[name]
18
20
  elif name in globals():
@@ -3,7 +3,7 @@ import os
3
3
  from flask import Blueprint, redirect, url_for, request, render_template, current_app, jsonify, send_file
4
4
  from flask_login import login_required
5
5
 
6
- from ivoryos.utils.db_models import db, WorkflowRun, WorkflowStep
6
+ from ivoryos.utils.db_models import db, WorkflowRun, WorkflowStep, WorkflowPhase
7
7
 
8
8
  data = Blueprint('data', __name__, template_folder='templates')
9
9
 
@@ -37,7 +37,6 @@ def list_workflows():
37
37
  else:
38
38
  return render_template('workflow_database.html', workflows=workflows)
39
39
 
40
-
41
40
  @data.get("/executions/records/<int:workflow_id>")
42
41
  def workflow_logs(workflow_id:int):
43
42
  """
@@ -45,52 +44,119 @@ def workflow_logs(workflow_id:int):
45
44
 
46
45
  get workflow data logs by workflow id
47
46
 
48
- .. http:get:: /executions/<int:workflow_id>
47
+ .. http:get:: /executions/records/<int:workflow_id>
49
48
 
50
49
  :param workflow_id: workflow id
51
50
  :type workflow_id: int
52
51
  """
52
+ workflow = db.session.get(WorkflowRun, workflow_id)
53
+ if not workflow:
54
+ return jsonify({"error": "Workflow not found"}), 404
55
+
56
+ # Query all phases for this run, ordered by start_time
57
+ phases = WorkflowPhase.query.filter_by(run_id=workflow_id).order_by(WorkflowPhase.start_time).all()
58
+
59
+ # Prepare grouped data for template (full objects)
60
+ grouped = {
61
+ "prep": [],
62
+ "script": {},
63
+ "cleanup": [],
64
+ }
65
+
66
+ # Prepare grouped data for JSON (dicts)
67
+ grouped_json = {
68
+ "prep": [],
69
+ "script": {},
70
+ "cleanup": [],
71
+ }
72
+
73
+ for phase in phases:
74
+ phase_dict = phase.as_dict()
75
+
76
+ # Steps sorted by step_index
77
+ steps = sorted(phase.steps, key=lambda s: s.step_index)
78
+ phase_steps_dicts = [s.as_dict() for s in steps]
79
+
80
+ if phase.name == "prep":
81
+ grouped["prep"].append(phase)
82
+ grouped_json["prep"].append({
83
+ **phase_dict,
84
+ "steps": phase_steps_dicts
85
+ })
53
86
 
54
- if request.method == 'GET':
55
- workflow = db.session.get(WorkflowRun, workflow_id)
56
- steps = WorkflowStep.query.filter_by(workflow_id=workflow_id).order_by(WorkflowStep.start_time).all()
57
-
58
- # Use full objects for template rendering
59
- grouped = {
60
- "prep": [],
61
- "script": {},
62
- "cleanup": [],
63
- }
64
-
65
- # Use dicts for JSON response
66
- grouped_json = {
67
- "prep": [],
68
- "script": {},
69
- "cleanup": [],
70
- }
71
-
72
- for step in steps:
73
- step_dict = step.as_dict()
74
-
75
- if step.phase == "prep":
76
- grouped["prep"].append(step)
77
- grouped_json["prep"].append(step_dict)
78
-
79
- elif step.phase == "script":
80
- grouped["script"].setdefault(step.repeat_index, []).append(step)
81
- grouped_json["script"].setdefault(step.repeat_index, []).append(step_dict)
82
-
83
- elif step.phase == "cleanup" or step.method_name == "stop":
84
- grouped["cleanup"].append(step)
85
- grouped_json["cleanup"].append(step_dict)
86
-
87
- if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
88
- return jsonify({
89
- "workflow_info": workflow.as_dict(),
90
- "steps": grouped_json,
87
+ elif phase.name == "main":
88
+ grouped["script"].setdefault(phase.repeat_index, []).append(phase)
89
+ grouped_json["script"].setdefault(phase.repeat_index, []).append({
90
+ **phase_dict,
91
+ "steps": phase_steps_dicts
91
92
  })
92
- else:
93
- return render_template("workflow_view.html", workflow=workflow, grouped=grouped)
93
+
94
+ elif phase.name == "cleanup":
95
+ grouped["cleanup"].append(phase)
96
+ grouped_json["cleanup"].append({
97
+ **phase_dict,
98
+ "steps": phase_steps_dicts
99
+ })
100
+
101
+ if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
102
+ return jsonify({
103
+ "workflow_info": workflow.as_dict(),
104
+ "phases": grouped_json,
105
+ })
106
+ else:
107
+ return render_template("workflow_view.html", workflow=workflow, grouped=grouped)
108
+
109
+
110
+ @data.get("/executions/data/<int:workflow_id>")
111
+ def workflow_phase_data(workflow_id: int):
112
+ """
113
+ .. :quickref: Workflow Data Database; get workflow data for plotting
114
+
115
+ get workflow data for plotting by workflow id
116
+
117
+ .. http:get:: /executions/data/<int: workflow_id>
118
+
119
+ :param workflow_id: workflow id
120
+ """
121
+
122
+ workflow = db.session.get(WorkflowRun, workflow_id)
123
+ if not workflow:
124
+ return jsonify({})
125
+
126
+ phase_data = {}
127
+ main_phases = WorkflowPhase.query.filter_by(run_id=workflow_id, name='main') \
128
+ .order_by(WorkflowPhase.repeat_index).all()
129
+
130
+ for phase in main_phases:
131
+ outputs = phase.outputs or {}
132
+ phase_index = phase.repeat_index
133
+ phase_data[phase_index] = {}
134
+
135
+ # Normalize everything to a list of dicts
136
+ if isinstance(outputs, dict):
137
+ outputs = [outputs]
138
+ elif isinstance(outputs, list):
139
+ # flatten if it’s nested like [[{...}, {...}]]
140
+ outputs = [
141
+ item for sublist in outputs
142
+ for item in (sublist if isinstance(sublist, list) else [sublist])
143
+ ]
144
+
145
+ # convert each output entry to plotting format
146
+ for out in outputs:
147
+ if not isinstance(out, dict):
148
+ continue
149
+ for k, v in out.items():
150
+ if isinstance(v, (int, float)):
151
+ phase_data[phase_index].setdefault(k, []).append(
152
+ {"x": phase_index, "y": v}
153
+ )
154
+ elif isinstance(v, list) and all(isinstance(i, (int, float)) for i in v):
155
+ phase_data[phase_index].setdefault(k, []).extend(
156
+ {"x": phase_index, "y": val} for val in v
157
+ )
158
+
159
+ return jsonify(phase_data)
94
160
 
95
161
 
96
162
  @data.delete("/executions/records/<int:workflow_id>")
@@ -101,7 +167,7 @@ def delete_workflow_record(workflow_id: int):
101
167
 
102
168
  delete a workflow execution record by workflow id
103
169
 
104
- .. http:delete:: /executions/records/<int:workflow_id>
170
+ .. http:delete:: /executions/records/<int: workflow_id>
105
171
 
106
172
  :param workflow_id: workflow id
107
173
  :type workflow_id: int
@@ -124,7 +190,7 @@ def download_results(filename:str):
124
190
  :param filename: workflow data filename
125
191
  :type filename: str
126
192
 
127
- # :status 302: load pseudo deck and then redirects to :http:get:`/ivoryos/executions`
193
+ # :status 302: load pseudo deck and then redirects to :http:get:`/ivoryos/executions/records`
128
194
  """
129
195
 
130
196
  filepath = os.path.join(current_app.config["DATA_FOLDER"], filename)
@@ -1,13 +1,78 @@
1
- <div class="card mb-2 {{ 'border-danger text-danger bg-light' if step.run_error else 'border-secondary' }}">
2
- <div class="card-body p-2">
3
- <strong>{{ step.method_name | format_name }}</strong>
4
- <small class="text-muted">
5
- <i class="fas fa-play-circle me-1"></i> Start: {{ step.start_time.strftime('%H:%M:%S') if step.start_time else 'N/A' }}
6
- <i class="fas fa-stop-circle ms-2 me-1"></i> End: {{ step.end_time.strftime('%H:%M:%S') if step.end_time else 'N/A' }}
7
- <!-- {% if step.run_error %}
8
- <i class="fas fa-stop-circle ms-2 me-1"></i> Error: {{ step.run_error if step.run_error else 'N/A' }}
9
- {% endif %} -->
10
- </small>
11
- <!-- <small>Error: {{ step.run_error }}</small> -->
12
- </div>
13
- </div>
1
+ <div class="card mb-2 {{ 'border-danger text-danger bg-light' if phase.run_error else 'border-secondary' }}">
2
+ <div class="card-body p-2">
3
+ <small class="text-muted">
4
+ <i class="fas fa-play-circle me-1"></i> Start: {{ phase.start_time.strftime('%H:%M:%S') if phase.start_time else 'N/A' }}
5
+ <i class="fas fa-stop-circle ms-2 me-1"></i> End: {{ phase.end_time.strftime('%H:%M:%S') if phase.end_time else 'N/A' }}
6
+ </small>
7
+ {% if phase.parameters %}
8
+ <div class="mt-2">
9
+ <strong>Parameters: </strong>
10
+ {% if phase.parameters is mapping %}
11
+ {% if phase.parameters %}
12
+ <div class="mt-2">
13
+ <strong>Parameters: </strong>
14
+ {% for key, value in phase.parameters.items() %}
15
+ <span class="badge bg-secondary me-1">{{ key }}: {{ value }}</span>
16
+ {% endfor %}
17
+ </div>
18
+ {% endif %}
19
+ {% else %}
20
+ {% for batch in phase.parameters %}
21
+ <div class="mt-1">
22
+ <span class="badge bg-info text-dark me-1">Batch {{ loop.index }}</span>
23
+ {% for key, value in batch.items() %}
24
+ <span class="badge bg-secondary me-1">{{ key }}: {{ value }}</span>
25
+ {% endfor %}
26
+ </div>
27
+ {% endfor %}
28
+ {% endif %}
29
+ </div>
30
+ {% endif %}
31
+
32
+ {% if phase.steps %}
33
+ <div class="mt-2">
34
+ <strong>Steps:</strong>
35
+ <ul class="mb-0">
36
+ {% for step in phase.steps %}
37
+ <li class="{{ 'text-danger' if step.run_error else '' }}">
38
+ {{ step.method_name }}
39
+ <small class="text-muted">
40
+ ({{ step.start_time.strftime('%H:%M:%S') if step.start_time else 'N/A' }} –
41
+ {{ step.end_time.strftime('%H:%M:%S') if step.end_time else 'N/A' }})
42
+ </small>
43
+ </li>
44
+ {% endfor %}
45
+ </ul>
46
+ </div>
47
+ {% endif %}
48
+ {% if phase.outputs %}
49
+ <div class="mt-2">
50
+ <strong>Outputs:</strong>
51
+
52
+ {% if phase.outputs is mapping %}
53
+ {% for key, value in phase.outputs.items() %}
54
+ <span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
55
+ {% endfor %}
56
+
57
+ {% elif phase.outputs is sequence %}
58
+ {% for batch in phase.outputs %}
59
+ <div class="mt-1">
60
+ <span class="badge bg-info text-dark me-1">Batch {{ loop.index }}</span>
61
+ {% if batch is mapping %}
62
+ {% for key, value in batch.items() %}
63
+ <span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
64
+ {% endfor %}
65
+ {% elif batch is sequence %}
66
+ {% for kwargs in batch %}
67
+ {% for key, value in kwargs.items() %}
68
+ <span class="badge bg-success me-1">{{ key }}: {{ value }}</span>
69
+ {% endfor %}
70
+ {% endfor %}
71
+ {% endif %}
72
+ </div>
73
+ {% endfor %}
74
+ {% endif %}
75
+ </div>
76
+ {% endif %}
77
+ </div>
78
+ </div>