qplotter 1.6.0b3__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 (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2572 @@
1
+ from collections.abc import Mapping
2
+ from datetime import datetime
3
+ from typing import cast
4
+
5
+ from PyQt6 import (
6
+ QtCore,
7
+ QtGui,
8
+ )
9
+ from PyQt6 import (
10
+ QtWidgets as qtw,
11
+ )
12
+
13
+ from qplot.datahandling.trusted_live_queries import (
14
+ TrustedParameterView,
15
+ TrustedRunRecord,
16
+ TrustedSelectedRunDetail,
17
+ TrustedSetpointSummary,
18
+ )
19
+ from qplot.datahandling.trusted_presentation import (
20
+ TRUSTED_PRESENTATION_MAX_TOOLTIP_BYTES,
21
+ bounded_presentation_error,
22
+ bounded_presentation_text,
23
+ bounded_selected_run_fields,
24
+ normalize_presentation_tree,
25
+ )
26
+
27
+ from .._commands import (
28
+ configure_action,
29
+ create_action,
30
+ plot_measurement_command_spec,
31
+ )
32
+ from .._config_persistence import persist_config_value
33
+ from ._run_formatting import ( # noqa: F401
34
+ complete_cell_sort_value,
35
+ format_complete_cell,
36
+ format_duration_dhms,
37
+ format_parameter_list,
38
+ format_parameter_list_html,
39
+ format_point_count,
40
+ format_progress,
41
+ format_progress_percent,
42
+ format_run_duration,
43
+ format_run_state,
44
+ format_run_status,
45
+ format_storage_size,
46
+ format_time_taken_seconds,
47
+ format_timestamp,
48
+ measured_parameter_count,
49
+ point_count_sort_value,
50
+ progress_percent_value,
51
+ run_failed,
52
+ run_is_complete,
53
+ run_tooltip_plain_text,
54
+ run_tooltip_text,
55
+ time_taken_seconds,
56
+ )
57
+ from .details_tables import (
58
+ FULL_VALUE_ID_ROLE,
59
+ CopyableTableWidget,
60
+ SnapshotTreePageRequest,
61
+ SnapshotTreeValueRequest,
62
+ TrustedFullValueDialog,
63
+ WrappedValueDelegate, # noqa: F401 - re-exported for compatibility
64
+ format_value,
65
+ infoTree,
66
+ snapshot_parameters,
67
+ )
68
+ from .preview import (
69
+ COLLAPSE_MINIMUM_RATIO,
70
+ PreviewTab,
71
+ )
72
+ from .run_list_items import (
73
+ MEASUREMENT_PREVIEW_SIZE,
74
+ EqualsAlignedDelegate,
75
+ RunPreviewCell,
76
+ SortableTreeWidgetItem,
77
+ )
78
+
79
+ MAX_RUN_PREVIEW_WIDGETS = 500
80
+ RUN_LIST_EVENT_YIELD_ROWS = 250
81
+ RUN_TABLE_COLUMN_WIDTHS_KEY = "GUI.run_table_column_widths"
82
+ RUN_TABLE_VISIBLE_COLUMNS_KEY = "GUI.run_table_visible_columns"
83
+
84
+
85
+ class RunList(qtw.QTreeWidget):
86
+ """
87
+ A modified PyQt6.QtWidgets.QTreeWidget, formated as a list which displays
88
+ all run_ids and other properties found in self.cols.
89
+
90
+ All QTreeWidgetItem are converted to SortableTreeWidgetItem to allow the user to sort
91
+ by any columns.
92
+
93
+ """
94
+
95
+ column_ids = (
96
+ "run_id",
97
+ "experiment",
98
+ "sample",
99
+ "measurements",
100
+ "setpoints",
101
+ "name",
102
+ "started",
103
+ "completed",
104
+ "status",
105
+ "duration",
106
+ "size",
107
+ "guid",
108
+ )
109
+ column_labels = {
110
+ "run_id": "ID",
111
+ "experiment": "Experiment",
112
+ "sample": "Sample",
113
+ "measurements": "Measurements",
114
+ "setpoints": "Setpoints",
115
+ "name": "Name",
116
+ "started": "Started",
117
+ "completed": "Completed",
118
+ "status": "Status",
119
+ "duration": "Duration",
120
+ "size": "Size",
121
+ "guid": "GUID",
122
+ }
123
+ cols = [
124
+ "ID",
125
+ "Experiment",
126
+ "Sample",
127
+ "Measurements",
128
+ "Setpoints",
129
+ "Name",
130
+ "Started",
131
+ "Completed",
132
+ "Status",
133
+ "Duration",
134
+ "Size",
135
+ "GUID",
136
+ ]
137
+ default_visible_column_ids = (
138
+ "run_id",
139
+ "measurements",
140
+ "setpoints",
141
+ "started",
142
+ "status",
143
+ "duration",
144
+ "size",
145
+ )
146
+ default_visible_columns = (
147
+ "ID",
148
+ "Measurements",
149
+ "Setpoints",
150
+ "Started",
151
+ "Status",
152
+ "Duration",
153
+ "Size",
154
+ )
155
+ column_widths = {
156
+ "ID": 44,
157
+ "Measurements": 96,
158
+ "Experiment": 120,
159
+ "Sample": 112,
160
+ "Name": 150,
161
+ "Status": 140,
162
+ "Duration": 96,
163
+ "Size": 62,
164
+ "Completed": 142,
165
+ "GUID": 286,
166
+ }
167
+ elastic_column_widths = {
168
+ "Setpoints": 170,
169
+ "Started": 142,
170
+ }
171
+ representative_column_values = {
172
+ "ID": "9999",
173
+ "Experiment": "experiment-name",
174
+ "Sample": "sample-name",
175
+ "Name": "run-name",
176
+ "Setpoints": "1,200,120 = 10,001 × 60",
177
+ "Started": "2026-05-04 13:05:16",
178
+ "Status": "Interrupted (100.0%)",
179
+ "Duration": "57,116.6 s",
180
+ "Size": "116 MB",
181
+ "Completed": "2026-05-04 13:05:16",
182
+ "GUID": "00000000-0000-0000-0000-000000000000",
183
+ }
184
+ readable_column_widths = {
185
+ "ID": 37,
186
+ "Measurements": 92,
187
+ "Experiment": 96,
188
+ "Sample": 88,
189
+ "Name": 112,
190
+ "Setpoints": 100,
191
+ "Started": 128,
192
+ "Status": 132,
193
+ "Duration": 84,
194
+ "Size": 54,
195
+ "Completed": 128,
196
+ "GUID": 220,
197
+ }
198
+ minimum_column_widths = {
199
+ "ID": 34,
200
+ "Measurements": 84,
201
+ "Experiment": 80,
202
+ "Sample": 72,
203
+ "Name": 80,
204
+ "Setpoints": 80,
205
+ "Started": 84,
206
+ "Status": 72,
207
+ "Duration": 68,
208
+ "Size": 50,
209
+ "Completed": 84,
210
+ "GUID": 120,
211
+ }
212
+ compact_growth_order = (
213
+ "Measurements",
214
+ "Experiment",
215
+ "Sample",
216
+ "Name",
217
+ "Started",
218
+ "Completed",
219
+ "Duration",
220
+ "Size",
221
+ "Status",
222
+ "GUID",
223
+ "Setpoints",
224
+ "ID",
225
+ )
226
+ preferred_growth_order = (
227
+ "Setpoints",
228
+ "Name",
229
+ "Experiment",
230
+ "Sample",
231
+ "Started",
232
+ "Completed",
233
+ "Duration",
234
+ "Size",
235
+ "Measurements",
236
+ "Status",
237
+ "GUID",
238
+ "ID",
239
+ )
240
+ compact_shrink_order = (
241
+ "Setpoints",
242
+ "GUID",
243
+ "Name",
244
+ "Experiment",
245
+ "Sample",
246
+ "Started",
247
+ "Completed",
248
+ "Measurements",
249
+ "Duration",
250
+ "Status",
251
+ "Size",
252
+ "ID",
253
+ )
254
+
255
+ selected = QtCore.pyqtSignal([str])
256
+ nonSingleSelection = QtCore.pyqtSignal()
257
+ plot = QtCore.pyqtSignal([str])
258
+ previewPlotRequested = QtCore.pyqtSignal(str, str)
259
+ previewExportRequested = QtCore.pyqtSignal(str, str)
260
+ _shortcut_keys = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ"
261
+
262
+ def __init__(
263
+ self,
264
+ *args,
265
+ initalize=False,
266
+ initialize=None,
267
+ config=None,
268
+ **kargs,
269
+ ):
270
+ super().__init__(*args, **kargs)
271
+ # Retained as no-op compatibility flags. Database loading is owned by
272
+ # the controller and arrives through addRuns/setRuns.
273
+ _ = initalize, initialize
274
+
275
+ self.watching: list[SortableTreeWidgetItem] = []
276
+ self.preview_cells: dict[str, RunPreviewCell] = {}
277
+ self._preview_publication_suspended = False
278
+ self._items_by_guid: dict[str, SortableTreeWidgetItem] = {}
279
+ self._resizing_columns = False
280
+ self._manual_column_widths = False
281
+ self._config = config
282
+ self._saved_column_widths = None
283
+ self._column_width_cache: dict[str, int] = {}
284
+ self._column_width_save_timer = QtCore.QTimer(self)
285
+ self._column_width_save_timer.setSingleShot(True)
286
+ self._column_width_save_timer.setInterval(250)
287
+ self._column_width_save_timer.timeout.connect(self._persist_column_widths)
288
+ self._preview_widgets_enabled = True
289
+ self.maxRunId = 0
290
+
291
+ self.setColumnCount(len(self.cols))
292
+ self.setHeaderLabels(self.cols)
293
+ header = self.header()
294
+ if header is not None:
295
+ header.setStretchLastSection(False)
296
+ header.setMinimumSectionSize(32)
297
+ header.setMinimumHeight(
298
+ max(header.sizeHint().height(), self.fontMetrics().height() + 8)
299
+ )
300
+ for column in range(len(self.cols)):
301
+ header.setSectionResizeMode(
302
+ column,
303
+ qtw.QHeaderView.ResizeMode.Interactive,
304
+ )
305
+ self.setRootIsDecorated(False)
306
+ self.setIndentation(0)
307
+ self.setUniformRowHeights(False)
308
+ self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
309
+ self.setHorizontalScrollMode(qtw.QAbstractItemView.ScrollMode.ScrollPerPixel)
310
+ self._setpoints_delegate = EqualsAlignedDelegate(self)
311
+ self.setItemDelegateForColumn(
312
+ self.cols.index("Setpoints"),
313
+ self._setpoints_delegate,
314
+ )
315
+ self._column_width_cache = self._preferred_column_widths()
316
+ self._apply_column_widths(
317
+ [self._column_width_cache[name] for name in self.cols]
318
+ )
319
+ self.apply_configured_column_widths()
320
+ self.apply_configured_column_visibility()
321
+
322
+ if header is not None:
323
+ header.sectionResized.connect(self._column_resized)
324
+ header.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
325
+ header.customContextMenuRequested.connect(self._open_header_menu)
326
+
327
+ # Slot connections
328
+ self.itemSelectionChanged.connect(self.onSelect)
329
+ self.itemDoubleClicked.connect(self._double_clicked)
330
+
331
+ # Setup Context Menu
332
+ self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu)
333
+ self.customContextMenuRequested.connect(self.prepareMenu)
334
+
335
+ context_action = create_action(
336
+ "context.show",
337
+ self,
338
+ status_tip="Show run-list context menu",
339
+ )
340
+ context_action.setShortcutContext(
341
+ QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut
342
+ )
343
+ context_action.triggered.connect(self.openKeyboardMenu)
344
+ self.addAction(context_action)
345
+
346
+ def addRuns(self, runs, *, continue_loading=None, commit_check=None):
347
+ """
348
+ Adds Row to table.
349
+
350
+ Parameters
351
+ ----------
352
+ runs : dict{int: dict}
353
+ Row data to be added.
354
+ Plain run metadata supplied by the controller.
355
+
356
+ """
357
+ if not runs:
358
+ return True
359
+
360
+ previous_max_run_id = self.maxRunId
361
+ added_items: list[SortableTreeWidgetItem] = []
362
+ guid_history: list[
363
+ tuple[
364
+ str,
365
+ SortableTreeWidgetItem,
366
+ SortableTreeWidgetItem | None,
367
+ ]
368
+ ] = []
369
+ preview_history: list[tuple[str, RunPreviewCell, RunPreviewCell | None]] = []
370
+ committed = False
371
+
372
+ def roll_back_added_items():
373
+ for guid, cell, previous_cell in reversed(preview_history):
374
+ if self.preview_cells.get(guid) is not cell:
375
+ continue
376
+ if previous_cell is None:
377
+ self.preview_cells.pop(guid, None)
378
+ else:
379
+ self.preview_cells[guid] = previous_cell
380
+ cell.deleteLater()
381
+
382
+ for item in reversed(added_items):
383
+ for watching_index in range(len(self.watching) - 1, -1, -1):
384
+ if self.watching[watching_index] is item:
385
+ del self.watching[watching_index]
386
+ item_index = self.indexOfTopLevelItem(item)
387
+ if item_index >= 0:
388
+ self.takeTopLevelItem(item_index)
389
+
390
+ for guid, item, previous_item in reversed(guid_history):
391
+ if self._items_by_guid.get(guid) is not item:
392
+ continue
393
+ if previous_item is None:
394
+ self._items_by_guid.pop(guid, None)
395
+ else:
396
+ self._items_by_guid[guid] = previous_item
397
+
398
+ self.maxRunId = previous_max_run_id
399
+
400
+ try:
401
+ if (
402
+ self._preview_widgets_enabled
403
+ and self.topLevelItemCount() + len(runs) > MAX_RUN_PREVIEW_WIDGETS
404
+ ):
405
+ self._disable_measurement_preview_widgets()
406
+
407
+ # Prevent constant resorting while rows are added.
408
+ self.setSortingEnabled(False)
409
+
410
+ run_count = len(runs)
411
+ for row_index, (run_id, metadata) in enumerate(runs.items(), start=1):
412
+ if callable(continue_loading) and not continue_loading():
413
+ return False
414
+ append_to_watching = False
415
+ measurement_count = measured_parameter_count(metadata)
416
+ arr = self._run_column_texts(run_id, metadata)
417
+
418
+ if not run_is_complete(metadata):
419
+ append_to_watching = True
420
+
421
+ # Convert arr to easy to sort QTreeWidgetItem
422
+ item = SortableTreeWidgetItem(arr)
423
+ item.set_guid(str(metadata.get("guid") or ""))
424
+ item.run_metadata = dict(metadata)
425
+ for col_name in ("ID", "Setpoints", "Size"):
426
+ item.setTextAlignment(
427
+ self.cols.index(col_name),
428
+ QtCore.Qt.AlignmentFlag.AlignRight
429
+ | QtCore.Qt.AlignmentFlag.AlignVCenter,
430
+ )
431
+ item.setTextAlignment(
432
+ self.cols.index("Status"), QtCore.Qt.AlignmentFlag.AlignCenter
433
+ )
434
+ item.setTextAlignment(
435
+ self.cols.index("Duration"),
436
+ QtCore.Qt.AlignmentFlag.AlignRight
437
+ | QtCore.Qt.AlignmentFlag.AlignVCenter,
438
+ )
439
+ item.setData(
440
+ self.cols.index("Measurements"),
441
+ QtCore.Qt.ItemDataRole.UserRole,
442
+ measurement_count,
443
+ )
444
+ item.setData(
445
+ self.cols.index("Measurements"),
446
+ QtCore.Qt.ItemDataRole.AccessibleTextRole,
447
+ self._measurement_accessible_text(metadata, measurement_count),
448
+ )
449
+ item.setSizeHint(
450
+ self.cols.index("Measurements"),
451
+ QtCore.QSize(0, MEASUREMENT_PREVIEW_SIZE + 6),
452
+ )
453
+ item.setData(
454
+ self.cols.index("Setpoints"),
455
+ QtCore.Qt.ItemDataRole.UserRole,
456
+ point_count_sort_value(metadata),
457
+ )
458
+ item.setData(
459
+ self.cols.index("Started"),
460
+ QtCore.Qt.ItemDataRole.UserRole,
461
+ metadata.get("run_timestamp"),
462
+ )
463
+ item.setData(
464
+ self.cols.index("Completed"),
465
+ QtCore.Qt.ItemDataRole.UserRole,
466
+ metadata.get("completed_timestamp"),
467
+ )
468
+ item.setData(
469
+ self.cols.index("Status"),
470
+ QtCore.Qt.ItemDataRole.UserRole,
471
+ complete_cell_sort_value(metadata),
472
+ )
473
+ item.setData(
474
+ self.cols.index("Duration"),
475
+ QtCore.Qt.ItemDataRole.UserRole,
476
+ time_taken_seconds(metadata),
477
+ )
478
+ item.setData(
479
+ self.cols.index("Size"),
480
+ QtCore.Qt.ItemDataRole.UserRole,
481
+ metadata.get("storage_bytes"),
482
+ )
483
+ item.update_tooltip()
484
+
485
+ # Add to top. Record every mutation before the next operation
486
+ # so a nested-event abort or widget error can remove the whole
487
+ # page instead of leaving an unacknowledged prefix visible.
488
+ self.addTopLevelItem(item)
489
+ added_items.append(item)
490
+ previous_item = self._items_by_guid.get(item.guid)
491
+ guid_history.append((item.guid, item, previous_item))
492
+ self._items_by_guid[item.guid] = item
493
+ if self._preview_widgets_enabled:
494
+ previous_cell = self.preview_cells.get(item.guid)
495
+ try:
496
+ self._set_measurement_preview_cell(item, measurement_count)
497
+ finally:
498
+ current_cell = self.preview_cells.get(item.guid)
499
+ if (
500
+ current_cell is not None
501
+ and current_cell is not previous_cell
502
+ ):
503
+ preview_history.append(
504
+ (item.guid, current_cell, previous_cell)
505
+ )
506
+ else:
507
+ self._set_compact_measurement_cell(item, measurement_count)
508
+
509
+ # If unfinished run
510
+ if append_to_watching:
511
+ self.watching.append(item)
512
+
513
+ if row_index < run_count and row_index % RUN_LIST_EVENT_YIELD_ROWS == 0:
514
+ # Basic-row construction is deliberately bounded so a large
515
+ # trusted page or initial run list cannot monopolise Qt. Detail
516
+ # work starts only after addRuns returns (or after the refresh
517
+ # worker receives its explicit publication acknowledgement).
518
+ QtCore.QCoreApplication.processEvents(
519
+ QtCore.QEventLoop.ProcessEventsFlag.AllEvents,
520
+ 5,
521
+ )
522
+ if callable(continue_loading) and not continue_loading():
523
+ return False
524
+
525
+ # Callers that stage database-backed rows may need one last source
526
+ # identity observation after the nested event yields above. Keep
527
+ # it inside this transaction so failure removes the entire page.
528
+ if callable(commit_check) and not commit_check():
529
+ return False
530
+
531
+ self._setpoints_delegate.invalidate_width_cache()
532
+ self.setSortingEnabled(True)
533
+ # maxRunId is the accepted GUI cursor. Publish it only after every
534
+ # row in this page and the final view update have succeeded.
535
+ self.maxRunId = max(previous_max_run_id, max(runs, default=0))
536
+ committed = True
537
+ return True
538
+ finally:
539
+ if not committed:
540
+ roll_back_added_items()
541
+ self._setpoints_delegate.invalidate_width_cache()
542
+ self.setSortingEnabled(True)
543
+
544
+ def updateRuns(self, runs):
545
+ """
546
+ Merge updated metadata into existing rows.
547
+
548
+ Background detail loading uses this to fill expensive columns without
549
+ rebuilding the table or disturbing the user's selection.
550
+
551
+ """
552
+ if not runs:
553
+ return {}
554
+
555
+ updated = {}
556
+ sorting_enabled = self.isSortingEnabled()
557
+ sort_column = self.sortColumn()
558
+ mutable_columns = {
559
+ self.cols.index(name)
560
+ for name in (
561
+ "Measurements",
562
+ "Setpoints",
563
+ "Experiment",
564
+ "Sample",
565
+ "Name",
566
+ "Started",
567
+ "Status",
568
+ "Duration",
569
+ "Size",
570
+ "Completed",
571
+ "GUID",
572
+ )
573
+ }
574
+ suspend_sorting = sorting_enabled and sort_column in mutable_columns
575
+ if suspend_sorting:
576
+ self.setSortingEnabled(False)
577
+ for run_id, metadata in runs.items():
578
+ guid = metadata.get("guid")
579
+ item = self._item_for_guid(guid)
580
+ if item is None:
581
+ continue
582
+
583
+ merged_metadata = dict(metadata)
584
+ if (
585
+ item.run_metadata.get("storage_bytes") is not None
586
+ and item.run_metadata.get("storage_bytes_estimated") is False
587
+ and merged_metadata.get("storage_bytes_estimated") is True
588
+ ):
589
+ merged_metadata.pop("storage_bytes", None)
590
+ merged_metadata.pop("storage_bytes_estimated", None)
591
+
592
+ item.run_metadata.update(merged_metadata)
593
+ self._refresh_run_item(item)
594
+ self._sync_watching_item(item)
595
+ updated[run_id] = dict(item.run_metadata)
596
+
597
+ self._setpoints_delegate.invalidate_width_cache()
598
+ if suspend_sorting:
599
+ self.setSortingEnabled(True)
600
+ return updated
601
+
602
+ def _refresh_run_item(self, item):
603
+ metadata = item.run_metadata
604
+ measurement_count = measured_parameter_count(metadata)
605
+
606
+ self._refresh_metadata_columns(item)
607
+
608
+ measurements_col = self.cols.index("Measurements")
609
+ item.setData(
610
+ measurements_col,
611
+ QtCore.Qt.ItemDataRole.UserRole,
612
+ measurement_count,
613
+ )
614
+ item.setData(
615
+ measurements_col,
616
+ QtCore.Qt.ItemDataRole.AccessibleTextRole,
617
+ self._measurement_accessible_text(metadata, measurement_count),
618
+ )
619
+ item.setSizeHint(
620
+ measurements_col,
621
+ QtCore.QSize(0, MEASUREMENT_PREVIEW_SIZE + 6),
622
+ )
623
+ cell = self.preview_cells.get(item.guid)
624
+ if self._preview_widgets_enabled and (
625
+ cell is None or cell.placeholder_count != measurement_count
626
+ ):
627
+ self._set_measurement_preview_cell(item, measurement_count)
628
+ elif not self._preview_widgets_enabled:
629
+ self._set_compact_measurement_cell(item, measurement_count)
630
+ elif cell is not None:
631
+ cell.update_placeholder_metadata(metadata)
632
+
633
+ setpoints_col = self.cols.index("Setpoints")
634
+ item.setText(setpoints_col, format_point_count(metadata))
635
+ item.setData(
636
+ setpoints_col,
637
+ QtCore.Qt.ItemDataRole.UserRole,
638
+ point_count_sort_value(metadata),
639
+ )
640
+
641
+ complete_col = self.cols.index("Status")
642
+ item.setText(complete_col, format_complete_cell(metadata))
643
+ item.setData(
644
+ complete_col,
645
+ QtCore.Qt.ItemDataRole.UserRole,
646
+ complete_cell_sort_value(metadata),
647
+ )
648
+
649
+ duration_col = self.cols.index("Duration")
650
+ item.setText(duration_col, format_time_taken_seconds(metadata))
651
+ item.setData(
652
+ duration_col,
653
+ QtCore.Qt.ItemDataRole.UserRole,
654
+ time_taken_seconds(metadata),
655
+ )
656
+
657
+ size_col = self.cols.index("Size")
658
+ item.setText(size_col, format_storage_size(metadata.get("storage_bytes")))
659
+ item.setData(
660
+ size_col,
661
+ QtCore.Qt.ItemDataRole.UserRole,
662
+ metadata.get("storage_bytes"),
663
+ )
664
+
665
+ item.update_tooltip()
666
+
667
+ @classmethod
668
+ def _run_column_texts(cls, run_id, metadata):
669
+ values = {
670
+ "ID": str(run_id),
671
+ "Measurements": "",
672
+ "Setpoints": format_point_count(metadata),
673
+ "Experiment": cls._metadata_cell_text(metadata.get("exp_name")),
674
+ "Sample": cls._metadata_cell_text(metadata.get("sample_name")),
675
+ "Name": cls._metadata_cell_text(metadata.get("name")),
676
+ "Started": format_timestamp(metadata.get("run_timestamp")),
677
+ "Status": format_complete_cell(metadata),
678
+ "Duration": format_time_taken_seconds(metadata),
679
+ "Size": format_storage_size(metadata.get("storage_bytes")),
680
+ "Completed": cls._format_completed_timestamp(metadata),
681
+ "GUID": cls._metadata_cell_text(metadata.get("guid")),
682
+ }
683
+ return [values[name] for name in cls.cols]
684
+
685
+ @staticmethod
686
+ def _metadata_cell_text(value):
687
+ return "" if value is None else str(value)
688
+
689
+ @staticmethod
690
+ def _format_completed_timestamp(metadata):
691
+ completed_timestamp = metadata.get("completed_timestamp")
692
+ if completed_timestamp:
693
+ return format_timestamp(completed_timestamp)
694
+ if run_is_complete(metadata):
695
+ return "unknown"
696
+ return "Ongoing"
697
+
698
+ def _refresh_metadata_columns(self, item):
699
+ metadata = item.run_metadata
700
+ values = {
701
+ "Experiment": self._metadata_cell_text(metadata.get("exp_name")),
702
+ "Sample": self._metadata_cell_text(metadata.get("sample_name")),
703
+ "Name": self._metadata_cell_text(metadata.get("name")),
704
+ "Started": format_timestamp(metadata.get("run_timestamp")),
705
+ "Completed": self._format_completed_timestamp(metadata),
706
+ "GUID": self._metadata_cell_text(metadata.get("guid") or item.guid),
707
+ }
708
+ for name, value in values.items():
709
+ item.setText(self.cols.index(name), value)
710
+
711
+ item.setData(
712
+ self.cols.index("Started"),
713
+ QtCore.Qt.ItemDataRole.UserRole,
714
+ metadata.get("run_timestamp"),
715
+ )
716
+ item.setData(
717
+ self.cols.index("Completed"),
718
+ QtCore.Qt.ItemDataRole.UserRole,
719
+ metadata.get("completed_timestamp"),
720
+ )
721
+
722
+ def _sync_watching_item(self, item):
723
+ watching = item in self.watching
724
+ complete = run_is_complete(item.run_metadata)
725
+ if complete and watching:
726
+ self.watching.remove(item)
727
+ elif not complete and not watching:
728
+ self.watching.append(item)
729
+
730
+ def clear(self):
731
+ self.preview_cells = {}
732
+ self._items_by_guid = {}
733
+ self._preview_widgets_enabled = True
734
+ self.setUniformRowHeights(False)
735
+ self._setpoints_delegate.invalidate_width_cache()
736
+ super().clear()
737
+
738
+ def _set_measurement_preview_cell(self, item, measurement_count):
739
+ column = self.cols.index("Measurements")
740
+ cell = RunPreviewCell(item.guid, measurement_count, self)
741
+ cell.update_placeholder_metadata(item.run_metadata)
742
+ cell.plotRequested.connect(self._preview_plot_requested)
743
+ cell.exportRequested.connect(self._preview_export_requested)
744
+ accessible_text = self._measurement_accessible_text(
745
+ item.run_metadata,
746
+ measurement_count,
747
+ )
748
+ cell.setAccessibleName(accessible_text)
749
+ cell.setAccessibleDescription(
750
+ "Measurement previews. Focus a preview for plot and export actions."
751
+ )
752
+ self.preview_cells[item.guid] = cell
753
+ self.setItemWidget(item, column, cell)
754
+
755
+ def _set_compact_measurement_cell(self, item, measurement_count):
756
+ column = self.cols.index("Measurements")
757
+ item.setText(column, str(measurement_count))
758
+ item.setSizeHint(column, QtCore.QSize(0, 22))
759
+ item.setToolTip(
760
+ column,
761
+ "Inline previews are disabled for this large run list. "
762
+ "Select the run to use the Preview tab.",
763
+ )
764
+
765
+ def _disable_measurement_preview_widgets(self):
766
+ column = self.cols.index("Measurements")
767
+ for guid, cell in tuple(self.preview_cells.items()):
768
+ item = self._items_by_guid.get(guid)
769
+ if item is not None:
770
+ self.removeItemWidget(item, column)
771
+ self._set_compact_measurement_cell(
772
+ item,
773
+ measured_parameter_count(item.run_metadata),
774
+ )
775
+ cell.deleteLater()
776
+ self.preview_cells.clear()
777
+ self._preview_widgets_enabled = False
778
+ self.setUniformRowHeights(True)
779
+
780
+ @QtCore.pyqtSlot(str, object)
781
+ def set_run_previews(self, guid, previews):
782
+ if self._preview_publication_suspended:
783
+ return
784
+ cell = self.preview_cells.get(guid)
785
+ if cell is not None:
786
+ cell.show_previews(previews)
787
+
788
+ def accepts_run_preview(self, guid):
789
+ """Return whether one inline cell can own a decoded thumbnail now."""
790
+
791
+ return (
792
+ not self._preview_publication_suspended
793
+ and str(guid or "") in self.preview_cells
794
+ )
795
+
796
+ def run_preview_is_ready(self, guid):
797
+ """Expose bounded inline-cell readiness for acceptance diagnostics."""
798
+
799
+ cell = self.preview_cells.get(str(guid or ""))
800
+ return bool(cell is not None and cell._has_rendered_previews)
801
+
802
+ @QtCore.pyqtSlot(str, bool)
803
+ def set_run_preview_generating(self, guid, generating):
804
+ if self._preview_publication_suspended:
805
+ return
806
+ cell = self.preview_cells.get(guid)
807
+ if cell is not None:
808
+ cell.set_generating(generating)
809
+
810
+ def set_preview_publication_suspended(self, suspended):
811
+ """Gate source-bound preview callbacks during a run-list transaction."""
812
+ previous = self._preview_publication_suspended
813
+ self._preview_publication_suspended = bool(suspended)
814
+ return previous
815
+
816
+ def show_run_preview_placeholders(self):
817
+ """Discard rendered run-list previews without changing row metadata."""
818
+ for cell in tuple(self.preview_cells.values()):
819
+ cell.show_placeholders(generating=False)
820
+
821
+ @QtCore.pyqtSlot(str, str)
822
+ def _preview_plot_requested(self, guid, parameter):
823
+ item = self._item_for_guid(guid)
824
+ if item is not None:
825
+ self.setCurrentItem(item)
826
+ self.previewPlotRequested.emit(guid, parameter)
827
+
828
+ @QtCore.pyqtSlot(str, str)
829
+ def _preview_export_requested(self, guid, parameter):
830
+ item = self._item_for_guid(guid)
831
+ if item is not None:
832
+ self.setCurrentItem(item)
833
+ self.previewExportRequested.emit(guid, parameter)
834
+
835
+ def _item_for_guid(self, guid):
836
+ return self._items_by_guid.get(guid)
837
+
838
+ @staticmethod
839
+ def _measurement_accessible_text(metadata, measurement_count):
840
+ parameters = [
841
+ str(parameter)
842
+ for parameter in metadata.get("measure_parameters", [])
843
+ if parameter
844
+ ]
845
+ noun = "measurement" if measurement_count == 1 else "measurements"
846
+ summary = f"{measurement_count} {noun}"
847
+ if parameters:
848
+ summary += f": {', '.join(parameters)}"
849
+ return summary
850
+
851
+ def _column_resized(self, column, old_size, new_size):
852
+ if self._resizing_columns or old_size == new_size:
853
+ return
854
+
855
+ if 0 <= column < len(self.cols) and new_size >= 32:
856
+ self._column_width_cache[self.cols[column]] = new_size
857
+ self._manual_column_widths = True
858
+ if self._config is not None:
859
+ self._column_width_save_timer.start()
860
+
861
+ def reset_column_widths(self):
862
+ self._column_width_save_timer.stop()
863
+ if self._config is not None and not persist_config_value(
864
+ self,
865
+ self._config,
866
+ RUN_TABLE_COLUMN_WIDTHS_KEY,
867
+ [],
868
+ "the run-table column widths",
869
+ ):
870
+ return False
871
+
872
+ self._saved_column_widths = None
873
+ self._manual_column_widths = False
874
+ self._column_width_cache = self._preferred_column_widths()
875
+ self._resize_columns(force=True)
876
+ return True
877
+
878
+ def apply_configured_column_widths(self):
879
+ """Apply saved widths, or restore responsive defaults when none exist."""
880
+ widths = self._configured_column_widths()
881
+ self._column_width_save_timer.stop()
882
+ if widths is None:
883
+ self._saved_column_widths = None
884
+ self._manual_column_widths = False
885
+ self._column_width_cache = self._preferred_column_widths()
886
+ self._resize_columns(force=True)
887
+ return
888
+
889
+ self._apply_column_widths(widths)
890
+ self._saved_column_widths = widths
891
+ self._manual_column_widths = True
892
+
893
+ def _configured_column_widths(self):
894
+ if self._config is None:
895
+ return None
896
+ widths = self._config.get(RUN_TABLE_COLUMN_WIDTHS_KEY)
897
+ if not widths:
898
+ return None
899
+ return list(widths)
900
+
901
+ def _apply_column_widths(self, widths):
902
+ self._resizing_columns = True
903
+ try:
904
+ for column, (name, width) in enumerate(zip(self.cols, widths, strict=True)):
905
+ self._column_width_cache[name] = width
906
+ self.setColumnWidth(column, width)
907
+ finally:
908
+ self._resizing_columns = False
909
+
910
+ def _persist_column_widths(self):
911
+ if self._config is None or not self._manual_column_widths:
912
+ return
913
+
914
+ for column, name in enumerate(self.cols):
915
+ if not self.isColumnHidden(column) and self.columnWidth(column) >= 32:
916
+ self._column_width_cache[name] = self.columnWidth(column)
917
+ persisted_widths = [
918
+ max(32, self._column_width_cache.get(name, 32)) for name in self.cols
919
+ ]
920
+ previous_widths = self._saved_column_widths
921
+
922
+ def rollback():
923
+ if previous_widths is None:
924
+ self._manual_column_widths = False
925
+ self._resize_columns(force=True)
926
+ else:
927
+ self._apply_column_widths(previous_widths)
928
+ self._manual_column_widths = True
929
+
930
+ if persist_config_value(
931
+ self,
932
+ self._config,
933
+ RUN_TABLE_COLUMN_WIDTHS_KEY,
934
+ persisted_widths,
935
+ "the run-table column widths",
936
+ rollback,
937
+ ):
938
+ self._saved_column_widths = [
939
+ max(32, self._column_width_cache.get(name, 32)) for name in self.cols
940
+ ]
941
+
942
+ def visible_column_ids(self):
943
+ return [
944
+ column_id
945
+ for column, column_id in enumerate(self.column_ids)
946
+ if not self.isColumnHidden(column)
947
+ ]
948
+
949
+ def visible_columns(self):
950
+ return [
951
+ self.column_labels[column_id] for column_id in self.visible_column_ids()
952
+ ]
953
+
954
+ def apply_configured_column_visibility(self):
955
+ """Show the columns selected in the current configuration."""
956
+ self._apply_visible_column_ids(self._configured_visible_column_ids())
957
+
958
+ def _configured_visible_column_ids(self):
959
+ if self._config is None:
960
+ return list(self.default_visible_column_ids)
961
+ configured = self._config.get(RUN_TABLE_VISIBLE_COLUMNS_KEY)
962
+ configured_set = set(configured)
963
+ return [
964
+ column_id for column_id in self.column_ids if column_id in configured_set
965
+ ]
966
+
967
+ def set_column_visible(self, column, visible):
968
+ """Show or hide one column and persist the complete visible set."""
969
+ column_id = self._resolve_column_id(column)
970
+ visible_ids = self.visible_column_ids()
971
+ currently_visible = column_id in visible_ids
972
+ if bool(visible) == currently_visible:
973
+ return True
974
+
975
+ if visible:
976
+ visible_ids.append(column_id)
977
+ else:
978
+ visible_ids.remove(column_id)
979
+ visible_set = set(visible_ids)
980
+ visible_ids = [
981
+ candidate for candidate in self.column_ids if candidate in visible_set
982
+ ]
983
+
984
+ if self._config is not None and not persist_config_value(
985
+ self,
986
+ self._config,
987
+ RUN_TABLE_VISIBLE_COLUMNS_KEY,
988
+ visible_ids,
989
+ "the run-table columns",
990
+ ):
991
+ return False
992
+
993
+ self._apply_visible_column_ids(visible_ids)
994
+ return True
995
+
996
+ def reset_column_visibility(self):
997
+ visible_ids = list(self.default_visible_column_ids)
998
+ if self._config is not None and not persist_config_value(
999
+ self,
1000
+ self._config,
1001
+ RUN_TABLE_VISIBLE_COLUMNS_KEY,
1002
+ visible_ids,
1003
+ "the run-table columns",
1004
+ ):
1005
+ return False
1006
+
1007
+ self._apply_visible_column_ids(visible_ids)
1008
+ return True
1009
+
1010
+ def _resolve_column_id(self, column):
1011
+ if column in self.column_ids:
1012
+ return column
1013
+ for column_id, label in self.column_labels.items():
1014
+ if column == label:
1015
+ return column_id
1016
+ raise ValueError(f"Unknown run-table column: {column!r}")
1017
+
1018
+ def _apply_visible_column_ids(self, visible_ids):
1019
+ visible_set = set(visible_ids)
1020
+ for column, name in enumerate(self.cols):
1021
+ width = self.columnWidth(column)
1022
+ if not self.isColumnHidden(column) and width >= 32:
1023
+ self._column_width_cache[name] = width
1024
+
1025
+ self._resizing_columns = True
1026
+ try:
1027
+ for column, (column_id, name) in enumerate(
1028
+ zip(self.column_ids, self.cols, strict=True)
1029
+ ):
1030
+ is_visible = column_id in visible_set
1031
+ self.setColumnHidden(column, not is_visible)
1032
+ if is_visible:
1033
+ self.setColumnWidth(
1034
+ column,
1035
+ max(32, self._column_width_cache.get(name, 32)),
1036
+ )
1037
+ finally:
1038
+ self._resizing_columns = False
1039
+
1040
+ sort_column = self.sortColumn()
1041
+ if (
1042
+ self.isSortingEnabled()
1043
+ and 0 <= sort_column < len(self.cols)
1044
+ and self.isColumnHidden(sort_column)
1045
+ and visible_set
1046
+ ):
1047
+ first_visible_column = next(
1048
+ column
1049
+ for column, column_id in enumerate(self.column_ids)
1050
+ if column_id in visible_set
1051
+ )
1052
+ header = self.header()
1053
+ sort_order = (
1054
+ header.sortIndicatorOrder()
1055
+ if header is not None
1056
+ else QtCore.Qt.SortOrder.AscendingOrder
1057
+ )
1058
+ self.sortItems(first_visible_column, sort_order)
1059
+
1060
+ if not self._manual_column_widths:
1061
+ self._resize_columns(force=True)
1062
+
1063
+ def _build_header_menu(self):
1064
+ menu = qtw.QMenu(self)
1065
+ columns_menu = menu.addMenu("Columns")
1066
+ visible_ids = set(self.visible_column_ids())
1067
+ for column_id in self.column_ids:
1068
+ label = self.column_labels[column_id]
1069
+ action = columns_menu.addAction(label)
1070
+ action.setCheckable(True)
1071
+ action.setChecked(column_id in visible_ids)
1072
+ action.setProperty("runTableColumnId", column_id)
1073
+ action.triggered.connect(
1074
+ lambda checked, selected=column_id, source=action: (
1075
+ self._toggle_column_from_action(
1076
+ selected,
1077
+ checked,
1078
+ source,
1079
+ )
1080
+ )
1081
+ )
1082
+
1083
+ columns_menu.addSeparator()
1084
+ defaults_action = columns_menu.addAction("Restore defaults")
1085
+ defaults_action.triggered.connect(self.reset_column_visibility)
1086
+
1087
+ menu.addSeparator()
1088
+ reset_action = menu.addAction("Reset column widths")
1089
+ reset_action.triggered.connect(self.reset_column_widths)
1090
+ return menu
1091
+
1092
+ def _toggle_column_from_action(self, column_id, visible, action):
1093
+ if not self.set_column_visible(column_id, visible):
1094
+ action.setChecked(column_id in self.visible_column_ids())
1095
+
1096
+ def _open_header_menu(self, pos):
1097
+ header = self.header()
1098
+ if header is None:
1099
+ return
1100
+ menu = self._build_header_menu()
1101
+ menu.exec(header.mapToGlobal(pos))
1102
+
1103
+ def _resize_columns(self, force=False):
1104
+ if self._manual_column_widths and not force:
1105
+ return
1106
+
1107
+ header = self.header()
1108
+ if header is not None:
1109
+ header.setStretchLastSection(False)
1110
+ header.setMinimumSectionSize(32)
1111
+
1112
+ for col in range(len(self.cols)):
1113
+ header.setSectionResizeMode(col, qtw.QHeaderView.ResizeMode.Interactive)
1114
+
1115
+ viewport = self.viewport()
1116
+ available_width = viewport.width() if viewport is not None else 0
1117
+ visible_names = self.visible_columns()
1118
+ if not visible_names:
1119
+ return
1120
+
1121
+ preferred_widths = {
1122
+ name: width
1123
+ for name, width in self._preferred_column_widths().items()
1124
+ if name in visible_names
1125
+ }
1126
+ preferred_width = sum(preferred_widths.values())
1127
+ if available_width <= 0:
1128
+ available_width = preferred_width
1129
+
1130
+ if available_width < preferred_width:
1131
+ readable_widths = {
1132
+ name: width
1133
+ for name, width in self.readable_column_widths.items()
1134
+ if name in visible_names
1135
+ }
1136
+ minimum_widths = {
1137
+ name: width
1138
+ for name, width in self.minimum_column_widths.items()
1139
+ if name in visible_names
1140
+ }
1141
+ readable_width = sum(readable_widths.values())
1142
+ if available_width < readable_width:
1143
+ widths = self._grow_column_widths(
1144
+ minimum_widths,
1145
+ readable_widths,
1146
+ available_width,
1147
+ [
1148
+ name
1149
+ for name in self.compact_growth_order
1150
+ if name in visible_names
1151
+ ],
1152
+ )
1153
+ else:
1154
+ widths = self._grow_column_widths(
1155
+ readable_widths,
1156
+ preferred_widths,
1157
+ available_width,
1158
+ [
1159
+ name
1160
+ for name in self.preferred_growth_order
1161
+ if name in visible_names
1162
+ ],
1163
+ )
1164
+ else:
1165
+ extra_width = max(0, available_width - preferred_width)
1166
+ widths = dict(preferred_widths)
1167
+ if "Setpoints" in widths and "Started" in widths:
1168
+ setpoints_extra = (extra_width * 2) // 3
1169
+ widths["Setpoints"] += setpoints_extra
1170
+ widths["Started"] += extra_width - setpoints_extra
1171
+ else:
1172
+ elastic_name = next(
1173
+ (
1174
+ name
1175
+ for name in (
1176
+ "Setpoints",
1177
+ "Name",
1178
+ "Started",
1179
+ "Experiment",
1180
+ "Sample",
1181
+ "GUID",
1182
+ )
1183
+ if name in widths
1184
+ ),
1185
+ visible_names[-1],
1186
+ )
1187
+ widths[elastic_name] += extra_width
1188
+
1189
+ self._resizing_columns = True
1190
+ try:
1191
+ for name, width in widths.items():
1192
+ self._column_width_cache[name] = width
1193
+ self.setColumnWidth(self.cols.index(name), width)
1194
+ finally:
1195
+ self._resizing_columns = False
1196
+
1197
+ def _preferred_column_widths(self):
1198
+ """Return roomy widths adjusted for the active platform font."""
1199
+ widths = {
1200
+ **self.elastic_column_widths,
1201
+ **self.column_widths,
1202
+ }
1203
+ metrics = QtGui.QFontMetrics(self.font())
1204
+ for name, value in self.representative_column_values.items():
1205
+ widths[name] = max(widths[name], metrics.horizontalAdvance(value) + 12)
1206
+ return widths
1207
+
1208
+ def _grow_column_widths(self, base_widths, target_widths, available_width, order):
1209
+ widths = dict(base_widths)
1210
+ deficit = sum(widths.values()) - available_width
1211
+ if deficit > 0:
1212
+ return widths
1213
+
1214
+ extra_width = max(0, available_width - sum(widths.values()))
1215
+ for name in order:
1216
+ target = target_widths.get(name, widths.get(name, 0))
1217
+ grow_by = min(max(0, target - widths.get(name, 0)), extra_width)
1218
+ widths[name] = widths.get(name, 0) + grow_by
1219
+ extra_width -= grow_by
1220
+ if extra_width <= 0:
1221
+ break
1222
+ return widths
1223
+
1224
+ def resizeEvent(self, event):
1225
+ super().resizeEvent(event)
1226
+ self._resize_columns()
1227
+
1228
+ def setRuns(self, runs):
1229
+ """
1230
+ Reset the table from controller-supplied plain run metadata.
1231
+
1232
+ """
1233
+ self.clear()
1234
+ self.watching = []
1235
+ self.preview_cells.clear()
1236
+ self._items_by_guid.clear()
1237
+ self.maxRunId = 0
1238
+
1239
+ self.addRuns(runs)
1240
+ return runs
1241
+
1242
+ def all_run_metadata(self):
1243
+ runs = {}
1244
+ for index in range(self.topLevelItemCount()):
1245
+ item = self.topLevelItem(index)
1246
+ if item is None:
1247
+ continue
1248
+
1249
+ run_id: int | str
1250
+ try:
1251
+ run_id = int(item.text(0))
1252
+ except ValueError:
1253
+ run_id = item.text(0)
1254
+
1255
+ runs[run_id] = dict(getattr(item, "run_metadata", {}))
1256
+ return runs
1257
+
1258
+ def visible_run_ids(self, limit=50):
1259
+ run_ids: list[int | str] = []
1260
+ viewport = self.viewport()
1261
+ if viewport is None:
1262
+ return run_ids
1263
+
1264
+ viewport_rect = viewport.rect()
1265
+ for index in range(self.topLevelItemCount()):
1266
+ item = self.topLevelItem(index)
1267
+ if item is None:
1268
+ continue
1269
+
1270
+ rect = self.visualItemRect(item)
1271
+ if not rect.isValid():
1272
+ continue
1273
+ if rect.bottom() < viewport_rect.top():
1274
+ continue
1275
+ if rect.top() > viewport_rect.bottom():
1276
+ if run_ids:
1277
+ break
1278
+ continue
1279
+
1280
+ run_id = self._item_run_id(item)
1281
+ if run_id is None:
1282
+ continue
1283
+ run_ids.append(run_id)
1284
+ if len(run_ids) >= limit:
1285
+ break
1286
+ return run_ids
1287
+
1288
+ def selected_run_ids(self):
1289
+ run_ids = []
1290
+ for item in self.selectedItems():
1291
+ if isinstance(item, SortableTreeWidgetItem):
1292
+ run_id = self._item_run_id(item)
1293
+ if run_id is not None:
1294
+ run_ids.append(run_id)
1295
+ return run_ids
1296
+
1297
+ def run_id_for_guid(self, guid):
1298
+ item = self._item_for_guid(guid)
1299
+ if item is None:
1300
+ return None
1301
+ return self._item_run_id(item)
1302
+
1303
+ def _item_run_id(self, item):
1304
+ try:
1305
+ return int(item.text(0))
1306
+ except (TypeError, ValueError):
1307
+ text = item.text(0)
1308
+ return text if text else None
1309
+
1310
+ def checkWatching(self, statuses):
1311
+ """
1312
+ Apply controller-supplied statuses to unfinished runs.
1313
+
1314
+ """
1315
+ statuses = statuses or {}
1316
+ to_remove = []
1317
+ updated_runs = {}
1318
+ for run in self.watching:
1319
+ status = statuses.get(run.guid, {})
1320
+ if not status:
1321
+ continue
1322
+
1323
+ if status.get("database_modified_timestamp") is not None:
1324
+ run.run_metadata["database_modified_timestamp"] = status[
1325
+ "database_modified_timestamp"
1326
+ ]
1327
+
1328
+ if status.get("run_timestamp") is not None:
1329
+ run.run_metadata["run_timestamp"] = status["run_timestamp"]
1330
+
1331
+ if status.get("is_completed") is not None:
1332
+ run.run_metadata["is_completed"] = bool(status["is_completed"])
1333
+
1334
+ shape_metadata_changed = False
1335
+ for field in (
1336
+ "point_shape",
1337
+ "setpoint_shape",
1338
+ "setpoint_shape_source",
1339
+ "setpoint_count",
1340
+ "setpoint_count_source",
1341
+ "expected_results",
1342
+ "expected_results_source",
1343
+ ):
1344
+ if field not in status:
1345
+ continue
1346
+ if run.run_metadata.get(field) != status[field]:
1347
+ shape_metadata_changed = True
1348
+ # None is meaningful here: it clears a stale early inference.
1349
+ run.run_metadata[field] = status[field]
1350
+
1351
+ if status.get("result_count") is not None:
1352
+ run.run_metadata["result_count"] = status["result_count"]
1353
+
1354
+ if status.get("result_count") is not None or shape_metadata_changed:
1355
+ points_col = self.cols.index("Setpoints")
1356
+ run.setText(points_col, format_point_count(run.run_metadata))
1357
+ run.setData(
1358
+ points_col,
1359
+ QtCore.Qt.ItemDataRole.UserRole,
1360
+ point_count_sort_value(run.run_metadata),
1361
+ )
1362
+ complete_col = self.cols.index("Status")
1363
+ run.setText(complete_col, format_complete_cell(run.run_metadata))
1364
+ run.setData(
1365
+ complete_col,
1366
+ QtCore.Qt.ItemDataRole.UserRole,
1367
+ progress_percent_value(run.run_metadata),
1368
+ )
1369
+ time_taken_col = self.cols.index("Duration")
1370
+ run.setText(time_taken_col, format_time_taken_seconds(run.run_metadata))
1371
+ run.setData(
1372
+ time_taken_col,
1373
+ QtCore.Qt.ItemDataRole.UserRole,
1374
+ time_taken_seconds(run.run_metadata),
1375
+ )
1376
+
1377
+ completion_metadata_changed = False
1378
+ if "read_setpoint_count" in status:
1379
+ run.run_metadata["read_setpoint_count"] = status["read_setpoint_count"]
1380
+ completion_metadata_changed = True
1381
+
1382
+ if status.get("measurement_exception") is not None:
1383
+ run.run_metadata["measurement_exception"] = status[
1384
+ "measurement_exception"
1385
+ ]
1386
+ completion_metadata_changed = True
1387
+
1388
+ if completion_metadata_changed:
1389
+ complete_col = self.cols.index("Status")
1390
+ run.setText(complete_col, format_complete_cell(run.run_metadata))
1391
+ run.setData(
1392
+ complete_col,
1393
+ QtCore.Qt.ItemDataRole.UserRole,
1394
+ complete_cell_sort_value(run.run_metadata),
1395
+ )
1396
+
1397
+ if status.get("storage_bytes") is not None:
1398
+ storage_col = self.cols.index("Size")
1399
+ run.run_metadata["storage_bytes"] = status["storage_bytes"]
1400
+ run.setText(storage_col, format_storage_size(status["storage_bytes"]))
1401
+ run.setData(
1402
+ storage_col,
1403
+ QtCore.Qt.ItemDataRole.UserRole,
1404
+ status["storage_bytes"],
1405
+ )
1406
+
1407
+ completed_timestamp = status.get("completed_timestamp")
1408
+ if completed_timestamp is not None:
1409
+ run.run_metadata["completed_timestamp"] = completed_timestamp
1410
+
1411
+ if run_is_complete(run.run_metadata):
1412
+ complete_col = self.cols.index("Status")
1413
+ run.setText(complete_col, format_complete_cell(run.run_metadata))
1414
+ run.setData(
1415
+ complete_col,
1416
+ QtCore.Qt.ItemDataRole.UserRole,
1417
+ complete_cell_sort_value(run.run_metadata),
1418
+ )
1419
+ time_taken_col = self.cols.index("Duration")
1420
+ run.setText(time_taken_col, format_time_taken_seconds(run.run_metadata))
1421
+ run.setData(
1422
+ time_taken_col,
1423
+ QtCore.Qt.ItemDataRole.UserRole,
1424
+ time_taken_seconds(run.run_metadata),
1425
+ )
1426
+ to_remove.append(run)
1427
+
1428
+ self._refresh_metadata_columns(run)
1429
+ run.update_tooltip()
1430
+ run_id: int | str
1431
+ try:
1432
+ run_id = int(run.text(0))
1433
+ except ValueError:
1434
+ run_id = run.text(0)
1435
+ updated_runs[run_id] = dict(run.run_metadata)
1436
+
1437
+ # Remove runs outside for loops to prevent interfering with loop indexing
1438
+ for run in to_remove:
1439
+ self.watching.remove(run)
1440
+
1441
+ return updated_runs
1442
+
1443
+ @QtCore.pyqtSlot(QtCore.QPoint)
1444
+ def prepareMenu(self, pos):
1445
+ """
1446
+ Produces the context menu at mouse position on right click.
1447
+ Allows user to open specific plots from the selected run.
1448
+
1449
+ Selects the row under the pointer before building actions so every menu
1450
+ command targets the row that was actually clicked.
1451
+
1452
+ Parameters
1453
+ ----------
1454
+ pos : PyQt6.QtCore.QPoint
1455
+ The cursor position to open the menu at.
1456
+
1457
+ """
1458
+ main = self.main_window()
1459
+ if main is None:
1460
+ return
1461
+
1462
+ item = self.itemAt(pos)
1463
+ if item is None:
1464
+ main.show_status("Right-click a run to open its plot menu.", 3000)
1465
+ return
1466
+ item = cast(qtw.QTreeWidgetItem, item)
1467
+ while True:
1468
+ parent = item.parent()
1469
+ if parent is None:
1470
+ break
1471
+ item = parent
1472
+ if not isinstance(item, SortableTreeWidgetItem):
1473
+ return
1474
+
1475
+ if self.currentItem() is not item or item not in self.selectedItems():
1476
+ self.clearSelection()
1477
+ self.setCurrentItem(item)
1478
+ item.setSelected(True)
1479
+
1480
+ menu = qtw.QMenu(self)
1481
+
1482
+ open_all = create_action(
1483
+ "run.plot_selected_all",
1484
+ menu,
1485
+ text="&Plot all",
1486
+ )
1487
+ self._set_action_shortcut(open_all, "run.plot_selected_all")
1488
+ open_all.triggered.connect(lambda _,: main.open_selected_run_all())
1489
+ menu.addAction(open_all)
1490
+
1491
+ if main.ds is None:
1492
+ parameter_targets = [
1493
+ (str(name), None)
1494
+ for name in item.run_metadata.get("measure_parameters", ())
1495
+ if name
1496
+ ]
1497
+ else:
1498
+ parameter_targets = [
1499
+ (param.name, param)
1500
+ for param in main.ds.get_parameters()
1501
+ if param.depends_on
1502
+ ]
1503
+
1504
+ # Create an action for all dependant parameters in the loaded dataset,
1505
+ # linking the coresponding parameter to the openPlot.
1506
+ for itr, (parameter_name, param) in enumerate(parameter_targets):
1507
+ open_win = QtGui.QAction(f" - {parameter_name}", menu)
1508
+ if itr < 9:
1509
+ self._set_action_shortcut(
1510
+ open_win,
1511
+ plot_measurement_command_spec(itr),
1512
+ )
1513
+
1514
+ # Due to the for loop, the lambda function sets param as an optional
1515
+ # default. Otherwise, param is set by the last iteration of the for loop.
1516
+ # This will be done a few times through the program but this note
1517
+ # may be missing
1518
+ if param is None:
1519
+ open_win.triggered.connect(
1520
+ lambda _, name=parameter_name: main.open_selected_measurement(name)
1521
+ )
1522
+ else:
1523
+ open_win.triggered.connect(
1524
+ lambda _, selected_param=param: main.openPlot(
1525
+ params=[selected_param]
1526
+ )
1527
+ )
1528
+
1529
+ menu.addAction(open_win)
1530
+
1531
+ # Display context menu
1532
+ menu.exec(self.mapToGlobal(pos))
1533
+
1534
+ @QtCore.pyqtSlot()
1535
+ def openKeyboardMenu(self):
1536
+ """
1537
+ Opens the run context menu from the keyboard.
1538
+
1539
+ """
1540
+ item = self.currentItem()
1541
+ pos = self.visualItemRect(item).center() if item else self.rect().center()
1542
+ self.prepareMenu(pos)
1543
+
1544
+ def main_window(self):
1545
+ """
1546
+ Returns the owning main window regardless of intermediate layouts.
1547
+
1548
+ """
1549
+ window = self.window()
1550
+ if hasattr(window, "ds") and hasattr(window, "openPlot"):
1551
+ return window
1552
+
1553
+ parent = self.parentWidget()
1554
+ while parent is not None:
1555
+ if hasattr(parent, "ds") and hasattr(parent, "openPlot"):
1556
+ return parent
1557
+ parent = parent.parentWidget()
1558
+
1559
+ return None
1560
+
1561
+ def _set_action_shortcut(self, action, command):
1562
+ """
1563
+ Sets a context-menu action shortcut.
1564
+
1565
+ """
1566
+ configure_action(action, command)
1567
+ action.setShortcutContext(QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut)
1568
+ if hasattr(action, "setShortcutVisibleInContextMenu"):
1569
+ action.setShortcutVisibleInContextMenu(True)
1570
+
1571
+ @QtCore.pyqtSlot()
1572
+ def onSelect(self):
1573
+ """
1574
+ Event handler for right/click on table.
1575
+ This emits a signal connected to:
1576
+ qplot.windows.main.MainWindow.updateSelected()
1577
+ for further loading.
1578
+
1579
+ Returns
1580
+ -------
1581
+ None.
1582
+
1583
+ """
1584
+ if len(self.selectedItems()) == 1: # Check multiple items are not selected
1585
+ item = self.selectedItems()[0]
1586
+ if isinstance(item, SortableTreeWidgetItem):
1587
+ self.selected.emit(item.guid)
1588
+ return
1589
+
1590
+ # A zero- or multiple-row selection must explicitly invalidate the
1591
+ # MainWindow's single-run action target. Previously it emitted no
1592
+ # signal, leaving the previous single selection available to plot and
1593
+ # CSV actions even though it was no longer visible in the run list.
1594
+ self.nonSingleSelection.emit()
1595
+
1596
+ @QtCore.pyqtSlot(qtw.QTreeWidgetItem, int)
1597
+ def _double_clicked(self, item, column):
1598
+ """
1599
+ Emits a signal to tell qplot.windows.main.MainWindow to open all params
1600
+ of selected row.
1601
+
1602
+ Parameters
1603
+ ----------
1604
+ Unused but required by signal
1605
+
1606
+ """
1607
+ self.plot.emit(None)
1608
+
1609
+ def add_plot(self, target_win, param):
1610
+ """
1611
+ Event handler for add _ to _ context menu option
1612
+
1613
+ Parameters
1614
+ ----------
1615
+ target_win : qplot.windows.plotWin.plotWidget
1616
+ The subplot will be added to this window.
1617
+ param : qcodes.dataset.descriptions.param_spec.ParamSpec
1618
+ The depandant parameter that will be added to the target_win.
1619
+
1620
+ """
1621
+ main = self.main_window()
1622
+ if main is None:
1623
+ return
1624
+
1625
+ selected = self.selectedItems()
1626
+ if not selected:
1627
+ return
1628
+ selected_item = selected[0]
1629
+ if not isinstance(selected_item, SortableTreeWidgetItem):
1630
+ return
1631
+
1632
+ main.add_trace_to_plot(target_win, selected_item.guid, param.name, param=param)
1633
+
1634
+ def add_all(self, target_win, param_dict):
1635
+ """
1636
+ Event handler for add _ to _ context menu all action.
1637
+ Add all plots which are able to be added to the target window
1638
+
1639
+ Parameters
1640
+ ----------
1641
+ target_win : qplot.windows.plotWin.plotWidget
1642
+ The subplot will be added to this window.
1643
+ param_dict : dict{qcodes.dataset.descriptions.param_spec.ParamSpec}
1644
+ A dictionary of all parameters to try to add.
1645
+
1646
+ """
1647
+ for param, depends_on in param_dict.items():
1648
+ if depends_on == target_win.param.depends_on_:
1649
+ self.add_plot(target_win, param)
1650
+
1651
+
1652
+ class moreInfo(qtw.QTabWidget):
1653
+ snapshotPageRequested = QtCore.pyqtSignal(object)
1654
+ snapshotFullValueRequested = QtCore.pyqtSignal(object)
1655
+
1656
+ def __init__(self, *args, preview_size=None):
1657
+ super().__init__(*args)
1658
+ self.setObjectName("runDetailsTabs")
1659
+ self._trusted_full_values = {}
1660
+ self._full_value_dialog = None
1661
+ self._trusted_detail_identity = None
1662
+ self._trusted_snapshot_session_token = ""
1663
+ self._pending_snapshot_value_request = None
1664
+
1665
+ self.overview = CopyableTableWidget()
1666
+ self.parameters = CopyableTableWidget()
1667
+ self.preview = PreviewTab(preview_size=preview_size)
1668
+ self._update_preview_minimum_height()
1669
+ self.metadata = infoTree(expand_all=True, truncate_values=True)
1670
+ self.snapshot = infoTree(expand_all=True, truncate_values=False)
1671
+ self.raw = infoTree(
1672
+ expand_all=False,
1673
+ truncate_values=False,
1674
+ expand_top_level=True,
1675
+ )
1676
+ self.metadata.fullValueRequested.connect(self._show_trusted_full_value)
1677
+ self.raw.fullValueRequested.connect(self._show_trusted_full_value)
1678
+ self.snapshot.snapshotPageRequested.connect(self._relay_snapshot_page_request)
1679
+ self.snapshot.snapshotFullValueRequested.connect(
1680
+ self._relay_snapshot_full_value_request
1681
+ )
1682
+
1683
+ self._setup_table(self.overview, ["Field", "Value"])
1684
+ self._setup_table(
1685
+ self.parameters,
1686
+ ["Name", "Label", "Unit", "From", "To", "Steps", "Delay", "Instrument"],
1687
+ )
1688
+
1689
+ self.addTab(self.overview, "Overview")
1690
+ self.addTab(self.parameters, "Sweep parameters")
1691
+ self.addTab(self.preview, "Preview")
1692
+ self.addTab(self.metadata, "Metadata")
1693
+ self.addTab(self.snapshot, "Snapshot")
1694
+ self.addTab(self.raw, "Raw key-value")
1695
+ self.currentChanged.connect(self._preview_tab_changed)
1696
+ self._preview_tab_changed(self.currentIndex())
1697
+
1698
+ def _preview_tab_changed(self, index):
1699
+ self.preview.set_preview_active(self.widget(index) is self.preview)
1700
+
1701
+ def set_preview_size(self, preview_size):
1702
+ self.preview.set_preview_size(preview_size)
1703
+ self._update_preview_minimum_height()
1704
+
1705
+ def _update_preview_minimum_height(self):
1706
+ preferred_height = self.preview.preferred_tab_height() + 36
1707
+ self.setMinimumHeight(max(1, round(preferred_height * COLLAPSE_MINIMUM_RATIO)))
1708
+
1709
+ def _setup_table(self, table, headers):
1710
+ table.setObjectName("detailsTable")
1711
+ table.setColumnCount(len(headers))
1712
+ table.setHorizontalHeaderLabels(headers)
1713
+ table.verticalHeader().hide()
1714
+ table.verticalHeader().setMinimumSectionSize(16)
1715
+ table.verticalHeader().setDefaultSectionSize(20)
1716
+ table.horizontalHeader().setFixedHeight(22)
1717
+ table.setAlternatingRowColors(True)
1718
+ table.setEditTriggers(qtw.QAbstractItemView.EditTrigger.NoEditTriggers)
1719
+ table.setSelectionBehavior(qtw.QAbstractItemView.SelectionBehavior.SelectRows)
1720
+ table.setTextElideMode(QtCore.Qt.TextElideMode.ElideRight)
1721
+ table.setWordWrap(False)
1722
+ table.horizontalHeader().setStretchLastSection(True)
1723
+
1724
+ def setInfo(
1725
+ self,
1726
+ info,
1727
+ dataset=None,
1728
+ run_metadata=None,
1729
+ setpoint_summaries=None,
1730
+ ):
1731
+ self.clear()
1732
+
1733
+ self._set_overview(info, dataset, run_metadata=run_metadata)
1734
+ self._set_parameters(info, dataset, setpoint_summaries)
1735
+ self.preview.set_current_run(dataset)
1736
+ self.metadata.setInfo(info.get("MetaData", {}))
1737
+ self.snapshot.setInfo(info.get("Snapshot", {}))
1738
+ self.raw.setInfo(info)
1739
+
1740
+ def set_trusted_run_detail(self, detail: TrustedSelectedRunDetail) -> None:
1741
+ """Render a selected trusted run without dataset or database access."""
1742
+ self._set_plain_run_detail(detail, preview_guid=None)
1743
+
1744
+ def set_trusted_derived_metadata(
1745
+ self,
1746
+ run_metadata,
1747
+ parameters,
1748
+ setpoint_summaries,
1749
+ metadata,
1750
+ ) -> None:
1751
+ """Render one Stage 5B metadata publication without database access."""
1752
+
1753
+ run_metadata = self._trusted_run_metadata(run_metadata)
1754
+ guid = run_metadata.get("guid")
1755
+ self.clear()
1756
+ self._set_trusted_overview(run_metadata, parameters)
1757
+ self._set_trusted_parameters(
1758
+ tuple(parameters or ()),
1759
+ tuple(setpoint_summaries or ()),
1760
+ {},
1761
+ )
1762
+ self.preview.set_current_guid(guid)
1763
+ self.metadata.setBoundedView(normalize_presentation_tree(metadata or {}))
1764
+ unavailable = normalize_presentation_tree(
1765
+ {
1766
+ "Status": (
1767
+ "Snapshot detail is not part of bounded background derived work."
1768
+ )
1769
+ }
1770
+ )
1771
+ self.snapshot.setBoundedView(unavailable)
1772
+ self.raw.setBoundedView(
1773
+ normalize_presentation_tree(
1774
+ {
1775
+ "Run": run_metadata,
1776
+ "Derived metadata": metadata or {},
1777
+ }
1778
+ )
1779
+ )
1780
+
1781
+ def set_snapshot_run_detail(self, detail: TrustedSelectedRunDetail) -> None:
1782
+ """Render snapshot detail primitives and preview by plain GUID."""
1783
+ if not isinstance(detail, TrustedSelectedRunDetail):
1784
+ raise TypeError("detail must be a TrustedSelectedRunDetail.")
1785
+ guid = detail.run.as_dict().get("guid")
1786
+ self._set_plain_run_detail(detail, preview_guid=guid)
1787
+
1788
+ def _set_plain_run_detail(self, detail, *, preview_guid) -> None:
1789
+ if not isinstance(detail, TrustedSelectedRunDetail):
1790
+ raise TypeError("detail must be a TrustedSelectedRunDetail.")
1791
+
1792
+ detail_run = detail.run.as_dict()
1793
+ detail_guid = str(detail_run.get("guid") or "")
1794
+ detail_identity = (detail.run.run_id, detail_guid)
1795
+ raw_expanded = (
1796
+ self.raw.expandedPaths()
1797
+ if self._trusted_detail_identity == detail_identity
1798
+ else None
1799
+ )
1800
+ self.clear()
1801
+ self._trusted_detail_identity = detail_identity
1802
+ self._trusted_full_values = {
1803
+ value.identifier: value for value in detail.presentation.full_values
1804
+ }
1805
+ run_metadata = detail_run
1806
+ run_metadata.setdefault("run_id", detail.run.run_id)
1807
+ snapshot = detail.snapshot
1808
+ presentation = detail.presentation
1809
+ snapshot_parameters = {
1810
+ parameter.name: dict(parameter.fields) for parameter in snapshot.parameters
1811
+ }
1812
+
1813
+ self._set_trusted_overview(run_metadata, detail.parameters)
1814
+ self._set_trusted_parameters(
1815
+ detail.parameters,
1816
+ detail.setpoint_summaries,
1817
+ snapshot_parameters,
1818
+ parameters_truncated=presentation.parameters_truncated,
1819
+ )
1820
+ if preview_guid is None:
1821
+ self.preview.show_trusted_live_placeholder()
1822
+ else:
1823
+ self.preview.set_current_guid(preview_guid)
1824
+ self.metadata.setBoundedView(presentation.metadata)
1825
+ self.snapshot.setBoundedView(snapshot, expand_all=False)
1826
+ self._trusted_snapshot_session_token = self.snapshot.snapshotSessionToken
1827
+ self.raw.setBoundedView(
1828
+ presentation.raw,
1829
+ expanded_paths=raw_expanded,
1830
+ )
1831
+ self.snapshot.expandDefaultSnapshotContainer()
1832
+
1833
+ @QtCore.pyqtSlot(str, str)
1834
+ def _show_trusted_full_value(self, identifier, path) -> None:
1835
+ value = self._trusted_full_values.get(str(identifier))
1836
+ if value is None:
1837
+ return
1838
+ dialog = self._full_value_dialog
1839
+ if dialog is None:
1840
+ dialog = TrustedFullValueDialog(self)
1841
+ self._full_value_dialog = dialog
1842
+ dialog.show_value(
1843
+ path=str(path),
1844
+ text=value.text,
1845
+ utf8_bytes=value.utf8_bytes,
1846
+ )
1847
+
1848
+ @QtCore.pyqtSlot(object)
1849
+ def _relay_snapshot_page_request(self, request) -> None:
1850
+ if (
1851
+ isinstance(request, SnapshotTreePageRequest)
1852
+ and request.session_token
1853
+ and request.session_token == self._trusted_snapshot_session_token
1854
+ ):
1855
+ self.snapshotPageRequested.emit(request)
1856
+
1857
+ @QtCore.pyqtSlot(object)
1858
+ def _relay_snapshot_full_value_request(self, request) -> None:
1859
+ if (
1860
+ not isinstance(request, SnapshotTreeValueRequest)
1861
+ or not request.session_token
1862
+ or request.session_token != self._trusted_snapshot_session_token
1863
+ or self._pending_snapshot_value_request is not None
1864
+ ):
1865
+ return
1866
+ self._pending_snapshot_value_request = request
1867
+ self.snapshotFullValueRequested.emit(request)
1868
+
1869
+ def accept_snapshot_page(self, page) -> bool:
1870
+ """Publish one already-bounded Snapshot page on the Qt owner thread."""
1871
+
1872
+ if QtCore.QThread.currentThread() != self.thread():
1873
+ raise RuntimeError("Snapshot page publication must run on the Qt thread.")
1874
+ return self.snapshot.acceptSnapshotPage(page)
1875
+
1876
+ def reject_snapshot_page(self, request) -> bool:
1877
+ """Release one failed page request without exposing stale content."""
1878
+
1879
+ return self.reject_snapshot_request(request)
1880
+
1881
+ def reject_snapshot_request(self, request) -> bool:
1882
+ """Release one exact failed Snapshot request without changing content."""
1883
+
1884
+ if QtCore.QThread.currentThread() != self.thread():
1885
+ raise RuntimeError("Snapshot request rejection must run on the Qt thread.")
1886
+ if isinstance(request, SnapshotTreePageRequest):
1887
+ return self.snapshot.rejectSnapshotPage(request)
1888
+ if (
1889
+ isinstance(request, SnapshotTreeValueRequest)
1890
+ and request == self._pending_snapshot_value_request
1891
+ and request.session_token == self._trusted_snapshot_session_token
1892
+ ):
1893
+ self._pending_snapshot_value_request = None
1894
+ return True
1895
+ return False
1896
+
1897
+ def show_snapshot_full_value(
1898
+ self,
1899
+ request: SnapshotTreeValueRequest,
1900
+ result,
1901
+ ) -> bool:
1902
+ """Show exact Snapshot text only after its complete request fence wins."""
1903
+
1904
+ if QtCore.QThread.currentThread() != self.thread():
1905
+ raise RuntimeError("Snapshot value publication must run on the Qt thread.")
1906
+ if (
1907
+ not isinstance(request, SnapshotTreeValueRequest)
1908
+ or request != self._pending_snapshot_value_request
1909
+ or request.session_token != self._trusted_snapshot_session_token
1910
+ ):
1911
+ return False
1912
+ text = getattr(result, "text", None)
1913
+ utf8_bytes = getattr(result, "utf8_bytes", None)
1914
+ if not isinstance(text, str) or type(utf8_bytes) is not int or utf8_bytes < 0:
1915
+ return False
1916
+ try:
1917
+ if len(text.encode("utf-8")) != utf8_bytes:
1918
+ return False
1919
+ except UnicodeEncodeError:
1920
+ return False
1921
+ self._pending_snapshot_value_request = None
1922
+ dialog = self._full_value_dialog
1923
+ if dialog is None:
1924
+ dialog = TrustedFullValueDialog(self)
1925
+ self._full_value_dialog = dialog
1926
+ dialog.show_value(
1927
+ path=request.path,
1928
+ text=text,
1929
+ utf8_bytes=utf8_bytes,
1930
+ )
1931
+ return True
1932
+
1933
+ def invalidate_trusted_snapshot(self) -> None:
1934
+ """Drop the active Snapshot session, its items, and any exact viewer."""
1935
+
1936
+ if QtCore.QThread.currentThread() != self.thread():
1937
+ raise RuntimeError("Snapshot invalidation must run on the Qt thread.")
1938
+ self._trusted_snapshot_session_token = ""
1939
+ self._pending_snapshot_value_request = None
1940
+ self.snapshot.invalidateSnapshot()
1941
+ dialog = self._full_value_dialog
1942
+ self._full_value_dialog = None
1943
+ if dialog is not None:
1944
+ dialog.discard_value()
1945
+ dialog.deleteLater()
1946
+
1947
+ def invalidate_trusted_full_values(self) -> None:
1948
+ """Drop the sole selected-detail backing and any open exact viewer."""
1949
+
1950
+ self._trusted_full_values = {}
1951
+ self.invalidate_trusted_snapshot()
1952
+ for tree in (self.metadata, self.raw):
1953
+ iterator = qtw.QTreeWidgetItemIterator(tree)
1954
+ while iterator.value() is not None:
1955
+ item = iterator.value()
1956
+ item.setData(0, FULL_VALUE_ID_ROLE, None)
1957
+ item.setData(1, FULL_VALUE_ID_ROLE, None)
1958
+ iterator += 1
1959
+
1960
+ def set_trusted_run_loading(
1961
+ self,
1962
+ run: TrustedRunRecord | Mapping[str, object] | None = None,
1963
+ ) -> None:
1964
+ """Render cached basics and loading placeholders without I/O."""
1965
+ run_metadata = self._trusted_run_metadata(run)
1966
+ self.clear()
1967
+ self._set_trusted_overview(run_metadata)
1968
+ self._set_parameter_message("Loading run details...")
1969
+ self.preview.show_trusted_live_placeholder()
1970
+ self._set_bounded_placeholder_views(
1971
+ run_metadata,
1972
+ "Status",
1973
+ "Loading run details...",
1974
+ )
1975
+
1976
+ def set_snapshot_run_loading(
1977
+ self,
1978
+ run: TrustedRunRecord | Mapping[str, object] | None = None,
1979
+ ) -> None:
1980
+ """Render snapshot basics immediately and queue preview by GUID."""
1981
+ run_metadata = self._trusted_run_metadata(run)
1982
+ self.clear()
1983
+ self._set_trusted_overview(run_metadata)
1984
+ self._set_parameter_message("Loading run details...")
1985
+ self.preview.set_current_guid(run_metadata.get("guid"))
1986
+ self._set_bounded_placeholder_views(
1987
+ run_metadata,
1988
+ "Status",
1989
+ "Loading run details...",
1990
+ )
1991
+
1992
+ def set_snapshot_run_unavailable(
1993
+ self,
1994
+ run: TrustedRunRecord | Mapping[str, object] | None = None,
1995
+ ) -> None:
1996
+ """Render fallback basics without starting selected-detail I/O.
1997
+
1998
+ Snapshot-fallback previews retain their separately accepted GUID path,
1999
+ but ordinary row selection does not prepare another private database
2000
+ snapshot merely to fill the detail tabs.
2001
+ """
2002
+ run_metadata = self._trusted_run_metadata(run)
2003
+ message = (
2004
+ "Detailed run metadata is deferred in snapshot fallback. "
2005
+ "Use an explicit plot or CSV action to materialise this run."
2006
+ )
2007
+ self.clear()
2008
+ self._set_trusted_overview(run_metadata)
2009
+ self._set_parameter_message("Detailed run metadata unavailable")
2010
+ self.preview.set_current_guid(run_metadata.get("guid"))
2011
+ self._set_bounded_placeholder_views(run_metadata, "Status", message)
2012
+
2013
+ def set_trusted_run_error(
2014
+ self,
2015
+ error: BaseException | str,
2016
+ run: TrustedRunRecord | Mapping[str, object] | None = None,
2017
+ ) -> None:
2018
+ """Render a bounded trusted-detail failure while retaining basics."""
2019
+ run_metadata = self._trusted_run_metadata(run)
2020
+ message = bounded_presentation_error(error)
2021
+ self.clear()
2022
+ self._set_trusted_overview(run_metadata)
2023
+ self._set_parameter_message("Run details unavailable")
2024
+ self.preview.show_trusted_live_placeholder()
2025
+ self._set_bounded_placeholder_views(run_metadata, "Error", message)
2026
+
2027
+ def set_snapshot_run_error(
2028
+ self,
2029
+ error: BaseException | str,
2030
+ run: TrustedRunRecord | Mapping[str, object] | None = None,
2031
+ ) -> None:
2032
+ """Retain snapshot preview and basics when detail loading fails."""
2033
+ run_metadata = self._trusted_run_metadata(run)
2034
+ message = bounded_presentation_error(error)
2035
+ self.clear()
2036
+ self._set_trusted_overview(run_metadata)
2037
+ self._set_parameter_message("Run details unavailable")
2038
+ self.preview.set_current_guid(run_metadata.get("guid"))
2039
+ self._set_bounded_placeholder_views(run_metadata, "Error", message)
2040
+
2041
+ def _set_bounded_placeholder_views(self, run_metadata, field, message):
2042
+ status_view = normalize_presentation_tree({field: message})
2043
+ raw_view = normalize_presentation_tree({"Run": run_metadata, field: message})
2044
+ self.metadata.setBoundedView(status_view)
2045
+ self.snapshot.setBoundedView(status_view)
2046
+ self.raw.setBoundedView(raw_view)
2047
+
2048
+ def update_live_run_details(self, run_metadata):
2049
+ """Patch authoritative live fields without rebuilding run details."""
2050
+ if not self._has_authoritative_run_state(run_metadata):
2051
+ return
2052
+
2053
+ is_completed = run_metadata.get("is_completed")
2054
+ updates = {
2055
+ "Status": self._status_text(None, run_metadata),
2056
+ "Duration": self._time_taken_from_metadata(run_metadata),
2057
+ "Started": self._run_timestamp(None, run_metadata, "run_timestamp"),
2058
+ }
2059
+ data_points = self._trusted_data_points_text(run_metadata)
2060
+ if data_points is not None:
2061
+ updates["Data points"] = data_points
2062
+ elif any(
2063
+ field in run_metadata
2064
+ for field in ("read_setpoint_count", "setpoint_count")
2065
+ ):
2066
+ # An explicit unavailable count invalidates the previously shown
2067
+ # value. A status-only update carries no count observation.
2068
+ row = self._overview_row("Data points")
2069
+ if row is not None:
2070
+ self.overview.removeRow(row)
2071
+ if bool(is_completed):
2072
+ updates["Completed"] = self._run_timestamp(
2073
+ None,
2074
+ run_metadata,
2075
+ "completed_timestamp",
2076
+ )
2077
+
2078
+ vertical_scroll_bar = cast(
2079
+ qtw.QScrollBar,
2080
+ self.overview.verticalScrollBar(),
2081
+ )
2082
+ horizontal_scroll_bar = cast(
2083
+ qtw.QScrollBar,
2084
+ self.overview.horizontalScrollBar(),
2085
+ )
2086
+ vertical_scroll = vertical_scroll_bar.value()
2087
+ horizontal_scroll = horizontal_scroll_bar.value()
2088
+ for field, value in updates.items():
2089
+ if self._has_value(value):
2090
+ self._set_overview_value(field, value)
2091
+ vertical_scroll_bar.setValue(vertical_scroll)
2092
+ horizontal_scroll_bar.setValue(horizontal_scroll)
2093
+
2094
+ def clear(self):
2095
+ self.invalidate_trusted_full_values()
2096
+ self._trusted_detail_identity = None
2097
+ self.overview.setRowCount(0)
2098
+ self.parameters.clearSpans()
2099
+ self.parameters.setRowCount(0)
2100
+ self.preview.clear_current_run()
2101
+ self.metadata.clear()
2102
+ self.snapshot.clear()
2103
+ self.raw.clear()
2104
+
2105
+ def scrollToTop(self):
2106
+ self.overview.scrollToTop()
2107
+ self.parameters.scrollToTop()
2108
+ self.metadata.scrollToTop()
2109
+ self.snapshot.scrollToTop()
2110
+ self.raw.scrollToTop()
2111
+
2112
+ def _set_overview(self, info, dataset, run_metadata=None):
2113
+ structure = info.get("Data Structure", {})
2114
+ param_info = {
2115
+ key: value for key, value in structure.items() if isinstance(value, dict)
2116
+ }
2117
+ measured = list((run_metadata or {}).get("measure_parameters") or [])
2118
+ setpoints = list((run_metadata or {}).get("sweep_parameters") or [])
2119
+ if not measured and not setpoints and dataset is not None:
2120
+ params = list(dataset.get_parameters())
2121
+ setpoint_names = {
2122
+ axis for param in params for axis in getattr(param, "depends_on_", ())
2123
+ }
2124
+ measured = [
2125
+ getattr(param, "name", "")
2126
+ for param in params
2127
+ if getattr(param, "name", "") not in setpoint_names
2128
+ ]
2129
+ setpoints = [
2130
+ getattr(param, "name", "")
2131
+ for param in params
2132
+ if getattr(param, "name", "") in setpoint_names
2133
+ ]
2134
+ elif not measured and not setpoints:
2135
+ measured = [
2136
+ name for name, details in param_info.items() if details.get("axes")
2137
+ ]
2138
+ setpoints = [
2139
+ name for name, details in param_info.items() if not details.get("axes")
2140
+ ]
2141
+
2142
+ rows = [
2143
+ ("Status", self._status_text(dataset, run_metadata)),
2144
+ ("Data points", structure.get("Data points")),
2145
+ ("Duration", self._time_taken_value(dataset, info, run_metadata)),
2146
+ ("Measured parameters", ", ".join(measured)),
2147
+ ("Setpoints", ", ".join(setpoints)),
2148
+ ("Started", self._run_timestamp(dataset, run_metadata, "run_timestamp")),
2149
+ (
2150
+ "Completed",
2151
+ self._run_timestamp(dataset, run_metadata, "completed_timestamp"),
2152
+ ),
2153
+ ("Experiment", self._run_attr(dataset, run_metadata, "exp_name")),
2154
+ ("Sample", self._run_attr(dataset, run_metadata, "sample_name")),
2155
+ ("Name", self._run_attr(dataset, run_metadata, "name")),
2156
+ ("GUID", self._run_attr(dataset, run_metadata, "guid")),
2157
+ ]
2158
+ rows = [(key, value) for key, value in rows if self._has_value(value)]
2159
+
2160
+ self._fill_key_value_table(self.overview, rows)
2161
+
2162
+ def _set_parameters(self, info, dataset, setpoint_summaries=None):
2163
+ params = list(dataset.get_parameters()) if dataset is not None else []
2164
+ snapshot_params = snapshot_parameters(info.get("Snapshot"))
2165
+ seen_axes = set()
2166
+ for param in params:
2167
+ for axis in getattr(param, "depends_on_", ()):
2168
+ if axis in seen_axes:
2169
+ continue
2170
+ seen_axes.add(axis)
2171
+
2172
+ setpoint_summaries = {
2173
+ str(name): dict(summary)
2174
+ for name, summary in (setpoint_summaries or {}).items()
2175
+ }
2176
+ setpoint_rows = []
2177
+ measured_rows = []
2178
+
2179
+ for param in params:
2180
+ name = getattr(param, "name", "")
2181
+ snap = snapshot_params.get(name, {})
2182
+ is_setpoint = name in seen_axes and not getattr(param, "depends_on_", ())
2183
+ values = self._parameter_row_values(
2184
+ param, snap, is_setpoint, setpoint_summaries
2185
+ )
2186
+
2187
+ if is_setpoint:
2188
+ setpoint_rows.append(values)
2189
+ else:
2190
+ measured_rows.append(values)
2191
+
2192
+ self._fill_parameter_groups(setpoint_rows, measured_rows)
2193
+
2194
+ def _set_trusted_overview(self, run_metadata, parameters=()):
2195
+ run_metadata = dict(bounded_selected_run_fields(run_metadata or {}))
2196
+ if parameters and not (
2197
+ run_metadata.get("measure_parameters")
2198
+ or run_metadata.get("sweep_parameters")
2199
+ ):
2200
+ setpoint_names = {
2201
+ name for parameter in parameters for name in parameter.depends_on
2202
+ }
2203
+ run_metadata["measure_parameters"] = [
2204
+ parameter.name
2205
+ for parameter in parameters
2206
+ if parameter.name not in setpoint_names
2207
+ ]
2208
+ run_metadata["sweep_parameters"] = [
2209
+ parameter.name
2210
+ for parameter in parameters
2211
+ if parameter.name in setpoint_names
2212
+ ]
2213
+ run_metadata = dict(bounded_selected_run_fields(run_metadata))
2214
+ info = {
2215
+ "Data Structure": {
2216
+ "Data points": self._trusted_data_points_text(run_metadata),
2217
+ }
2218
+ }
2219
+ self._set_overview(info, None, run_metadata=run_metadata)
2220
+
2221
+ @staticmethod
2222
+ def _trusted_logical_point_count(run_metadata):
2223
+ acquired = run_metadata.get("read_setpoint_count")
2224
+ if type(acquired) is int and acquired >= 0:
2225
+ return acquired
2226
+ observed = run_metadata.get("setpoint_count")
2227
+ if (
2228
+ run_metadata.get("setpoint_count_source") == "observed"
2229
+ and type(observed) is int
2230
+ and observed >= 0
2231
+ ):
2232
+ return observed
2233
+ return None
2234
+
2235
+ @classmethod
2236
+ def _trusted_data_points_text(cls, run_metadata):
2237
+ logical_count = cls._trusted_logical_point_count(run_metadata)
2238
+ if logical_count is None:
2239
+ return None
2240
+ if run_metadata.get("setpoint_shape") or run_metadata.get("point_shape"):
2241
+ formatted = format_point_count(run_metadata)
2242
+ if formatted != "unknown":
2243
+ return formatted
2244
+ return f"{logical_count:,}"
2245
+
2246
+ def _set_trusted_parameters(
2247
+ self,
2248
+ parameters: tuple[TrustedParameterView, ...],
2249
+ summaries: tuple[TrustedSetpointSummary, ...],
2250
+ snapshot_params,
2251
+ *,
2252
+ parameters_truncated=False,
2253
+ ) -> None:
2254
+ seen_axes = set()
2255
+ for parameter in parameters:
2256
+ for axis in parameter.depends_on:
2257
+ if axis in seen_axes:
2258
+ continue
2259
+ seen_axes.add(axis)
2260
+
2261
+ summary_values = {
2262
+ summary.name: {
2263
+ "from": summary.first,
2264
+ "to": summary.last,
2265
+ "steps": summary.steps,
2266
+ }
2267
+ for summary in summaries
2268
+ }
2269
+ setpoint_rows = []
2270
+ measured_rows = []
2271
+ for parameter in parameters:
2272
+ snap = snapshot_params.get(parameter.name, {})
2273
+ is_setpoint = parameter.name in seen_axes and not parameter.depends_on
2274
+ values = self._parameter_row_values(
2275
+ parameter,
2276
+ snap,
2277
+ is_setpoint,
2278
+ summary_values,
2279
+ )
2280
+ if is_setpoint:
2281
+ setpoint_rows.append(values)
2282
+ else:
2283
+ measured_rows.append(values)
2284
+
2285
+ self._fill_parameter_groups(setpoint_rows, measured_rows)
2286
+ if parameters_truncated:
2287
+ row = self.parameters.rowCount()
2288
+ self.parameters.insertRow(row)
2289
+ self.parameters.setSpan(row, 0, 1, self.parameters.columnCount())
2290
+ item = self._table_item(
2291
+ "Additional or oversized parameter details were omitted at "
2292
+ "presentation limits."
2293
+ )
2294
+ font = item.font()
2295
+ font.setItalic(True)
2296
+ item.setFont(font)
2297
+ self.parameters.setItem(row, 0, item)
2298
+ self._resize_table(self.parameters)
2299
+
2300
+ def _fill_parameter_groups(self, setpoint_rows, measured_rows):
2301
+ groups = [
2302
+ ("Set parameters", setpoint_rows),
2303
+ ("Measure parameters", measured_rows),
2304
+ ]
2305
+ self.parameters.clearSpans()
2306
+ self.parameters.setRowCount(sum(1 + len(rows) for _, rows in groups))
2307
+
2308
+ row = 0
2309
+ for heading, rows in groups:
2310
+ self._set_parameter_heading_row(row, heading)
2311
+ row += 1
2312
+ for values in rows:
2313
+ for col, value in enumerate(values):
2314
+ self.parameters.setItem(
2315
+ row, col, self._table_item(value, max_len=80)
2316
+ )
2317
+ row += 1
2318
+
2319
+ self._resize_table(self.parameters)
2320
+
2321
+ def _set_parameter_message(self, message):
2322
+ self.parameters.clearSpans()
2323
+ self.parameters.setRowCount(1)
2324
+ self.parameters.setSpan(0, 0, 1, self.parameters.columnCount())
2325
+ item = self._table_item(message)
2326
+ font = item.font()
2327
+ font.setItalic(True)
2328
+ item.setFont(font)
2329
+ self.parameters.setItem(0, 0, item)
2330
+ self._resize_table(self.parameters)
2331
+
2332
+ def _trusted_run_metadata(self, run):
2333
+ if run is None:
2334
+ return {}
2335
+ if isinstance(run, TrustedRunRecord):
2336
+ metadata = dict(run.fields)
2337
+ metadata.setdefault("run_id", run.run_id)
2338
+ return dict(bounded_selected_run_fields(metadata))
2339
+ if isinstance(run, Mapping):
2340
+ return dict(bounded_selected_run_fields(run))
2341
+ raise TypeError("run must be a TrustedRunRecord, mapping, or None.")
2342
+
2343
+ def _set_parameter_heading_row(self, row, heading):
2344
+ for col in range(self.parameters.columnCount()):
2345
+ item = self._table_item(heading if col == 0 else "")
2346
+ font = item.font()
2347
+ font.setBold(True)
2348
+ item.setFont(font)
2349
+ item.setToolTip(heading)
2350
+ self.parameters.setItem(row, col, item)
2351
+
2352
+ def _parameter_row_values(self, param, snap, is_setpoint, setpoint_summaries):
2353
+ name = getattr(param, "name", "")
2354
+ common = [
2355
+ name,
2356
+ getattr(param, "label", "") or snap.get("label", ""),
2357
+ getattr(param, "unit", "") or snap.get("unit", ""),
2358
+ ]
2359
+ instrument = snap.get("instrument_name", snap.get("instrument", ""))
2360
+
2361
+ if not is_setpoint:
2362
+ return common + ["", "", "", "", instrument]
2363
+
2364
+ summary = setpoint_summaries.get(name, {})
2365
+ return common + [
2366
+ summary.get("from", snap.get("value", snap.get("raw_value", ""))),
2367
+ summary.get("to", snap.get("value", snap.get("raw_value", ""))),
2368
+ summary.get("steps", ""),
2369
+ self._parameter_delay(snap),
2370
+ instrument,
2371
+ ]
2372
+
2373
+ def _parameter_delay(self, snap):
2374
+ for key in ("delay", "post_delay", "inter_delay"):
2375
+ value = snap.get(key)
2376
+ if self._has_value(value):
2377
+ return value
2378
+ return ""
2379
+
2380
+ def _time_taken_value(self, dataset, info, run_metadata=None):
2381
+ if self._has_authoritative_run_state(run_metadata):
2382
+ return self._time_taken_from_metadata(run_metadata)
2383
+
2384
+ started = self._dataset_attr(dataset, "run_timestamp_raw")
2385
+ completed = self._dataset_attr(dataset, "completed_timestamp_raw")
2386
+ if not self._has_value(started):
2387
+ started = self._dataset_attr(dataset, "run_timestamp")
2388
+ if not self._has_value(completed):
2389
+ completed = self._dataset_attr(dataset, "completed_timestamp")
2390
+ if not self._has_value(started):
2391
+ return ""
2392
+
2393
+ end = completed if self._has_value(completed) else (
2394
+ (run_metadata or {}).get("database_modified_timestamp")
2395
+ )
2396
+ if not self._has_value(end):
2397
+ return ""
2398
+ try:
2399
+ seconds = max(
2400
+ 0, self._timestamp_seconds(end) - self._timestamp_seconds(started)
2401
+ )
2402
+ except (TypeError, ValueError):
2403
+ return ""
2404
+
2405
+ per_point = self._time_per_point(seconds, info, dataset)
2406
+ if self._has_value(per_point):
2407
+ return f"{seconds:.2f} s\t({format_duration_dhms(seconds)}; {per_point} s/point)"
2408
+ return f"{seconds:.2f} s\t({format_duration_dhms(seconds)})"
2409
+
2410
+ def _time_taken_from_metadata(self, run_metadata):
2411
+ seconds = time_taken_seconds(run_metadata)
2412
+ if seconds is None:
2413
+ return ""
2414
+
2415
+ points = self._trusted_logical_point_count(run_metadata)
2416
+ per_point = self._time_per_point(
2417
+ seconds,
2418
+ {"Data Structure": {"Data points": points}},
2419
+ None,
2420
+ )
2421
+ if self._has_value(per_point):
2422
+ return f"{seconds:.2f} s\t({format_duration_dhms(seconds)}; {per_point} s/point)"
2423
+ return f"{seconds:.2f} s\t({format_duration_dhms(seconds)})"
2424
+
2425
+ def _timestamp_seconds(self, value):
2426
+ if isinstance(value, (int, float)):
2427
+ return float(value)
2428
+
2429
+ if isinstance(value, datetime):
2430
+ return value.timestamp()
2431
+
2432
+ text = str(value)
2433
+ for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
2434
+ try:
2435
+ return datetime.strptime(text, fmt).timestamp()
2436
+ except ValueError:
2437
+ pass
2438
+
2439
+ return float(value)
2440
+
2441
+ def _time_per_point(self, seconds, info, dataset):
2442
+ points = info.get("Data Structure", {}).get("Data points")
2443
+ try:
2444
+ points = float(points)
2445
+ except (TypeError, ValueError):
2446
+ return ""
2447
+
2448
+ if points <= 0:
2449
+ return ""
2450
+
2451
+ return f"{seconds / points:.3g}"
2452
+
2453
+ def _fill_key_value_table(self, table, rows):
2454
+ table.setRowCount(len(rows))
2455
+ for row, (key, value) in enumerate(rows):
2456
+ table.setItem(row, 0, self._table_item(key))
2457
+ table.setItem(row, 1, self._table_item(value, max_len=140))
2458
+ self._resize_table(table)
2459
+
2460
+ def _resize_table(self, table):
2461
+ header = table.horizontalHeader()
2462
+ last_col = table.columnCount() - 1
2463
+ stretch_cols = {last_col}
2464
+ if table.columnCount() > 2:
2465
+ stretch_cols.update({0, 1})
2466
+
2467
+ for col in range(table.columnCount()):
2468
+ if col in stretch_cols:
2469
+ header.setSectionResizeMode(col, qtw.QHeaderView.ResizeMode.Stretch)
2470
+ else:
2471
+ header.setSectionResizeMode(
2472
+ col, qtw.QHeaderView.ResizeMode.ResizeToContents
2473
+ )
2474
+ header.setStretchLastSection(False)
2475
+ for row in range(table.rowCount()):
2476
+ table.setRowHeight(row, 20)
2477
+
2478
+ def _table_item(self, value, max_len=None):
2479
+ text = format_value(value, max_len=max_len)
2480
+ item = qtw.QTableWidgetItem(text)
2481
+ tooltip, _truncated = bounded_presentation_text(
2482
+ format_value(value),
2483
+ limit=TRUSTED_PRESENTATION_MAX_TOOLTIP_BYTES,
2484
+ )
2485
+ item.setToolTip(tooltip)
2486
+ return item
2487
+
2488
+ def _dataset_attr(self, dataset, name):
2489
+ if dataset is None:
2490
+ return ""
2491
+ value = getattr(dataset, name, "")
2492
+ return value() if callable(value) else value
2493
+
2494
+ def _run_attr(self, dataset, run_metadata, name):
2495
+ if run_metadata is not None:
2496
+ value = run_metadata.get(name)
2497
+ if self._has_value(value):
2498
+ return value
2499
+ return self._dataset_attr(dataset, name)
2500
+
2501
+ def _run_timestamp(self, dataset, run_metadata, name):
2502
+ if self._has_authoritative_run_state(run_metadata):
2503
+ if name == "completed_timestamp" and not bool(
2504
+ run_metadata.get("is_completed")
2505
+ ):
2506
+ return ""
2507
+ value = run_metadata.get(name)
2508
+ else:
2509
+ value = self._dataset_attr(dataset, name)
2510
+ if isinstance(value, (int, float)):
2511
+ return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M:%S")
2512
+ return value
2513
+
2514
+ def _status_text(self, dataset, run_metadata=None):
2515
+ if self._has_authoritative_run_state(run_metadata):
2516
+ return format_run_state(run_metadata)
2517
+
2518
+ running = self._dataset_attr(dataset, "running")
2519
+ if running is True:
2520
+ return format_run_state({"is_completed": False})
2521
+ if running is False:
2522
+ return format_run_state({"is_completed": True})
2523
+ return ""
2524
+
2525
+ def _has_authoritative_run_state(self, run_metadata):
2526
+ return run_metadata is not None and "is_completed" in run_metadata
2527
+
2528
+ def _set_overview_value(self, field, value):
2529
+ row = self._overview_row(field)
2530
+ if row is None:
2531
+ row = self._overview_insert_row(field)
2532
+ self.overview.insertRow(row)
2533
+ self.overview.setItem(row, 0, self._table_item(field))
2534
+ self.overview.setItem(row, 1, self._table_item(value))
2535
+ self.overview.setRowHeight(row, 20)
2536
+
2537
+ def _overview_row(self, field):
2538
+ for row in range(self.overview.rowCount()):
2539
+ item = self.overview.item(row, 0)
2540
+ if item is not None and item.text() == field:
2541
+ return row
2542
+ return None
2543
+
2544
+ def _overview_insert_row(self, field):
2545
+ field_order = (
2546
+ "Status",
2547
+ "Data points",
2548
+ "Duration",
2549
+ "Measured parameters",
2550
+ "Setpoints",
2551
+ "Started",
2552
+ "Completed",
2553
+ "Experiment",
2554
+ "Sample",
2555
+ "Name",
2556
+ "GUID",
2557
+ )
2558
+ target_order = field_order.index(field)
2559
+ for row in range(self.overview.rowCount()):
2560
+ item = self.overview.item(row, 0)
2561
+ if item is None:
2562
+ continue
2563
+ try:
2564
+ current_order = field_order.index(item.text())
2565
+ except ValueError:
2566
+ continue
2567
+ if current_order > target_order:
2568
+ return row
2569
+ return self.overview.rowCount()
2570
+
2571
+ def _has_value(self, value):
2572
+ return value is not None and value != ""