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,828 @@
1
+ import os
2
+
3
+ from PyQt6 import (
4
+ QtCore,
5
+ )
6
+ from PyQt6 import (
7
+ QtWidgets as qtw,
8
+ )
9
+ from PyQt6.QtGui import QIntValidator
10
+
11
+ from qplot.datahandling.file_identity import (
12
+ database_instance,
13
+ database_instances_differ,
14
+ )
15
+ from qplot.datahandling.trusted_live_service import TRUSTED_LIVE_MODE
16
+
17
+ from ._commands import create_action, plot_measurement_command_spec
18
+ from ._config_persistence import (
19
+ persist_config_value,
20
+ set_widget_value_without_signals,
21
+ )
22
+ from ._help import show_quick_start
23
+ from ._refresh_interval import (
24
+ connect_refresh_interval_edits,
25
+ refresh_interval_value,
26
+ remember_refresh_interval,
27
+ set_refresh_interval,
28
+ )
29
+ from ._widgets import (
30
+ RunList,
31
+ moreInfo,
32
+ )
33
+ from ._widgets._run_formatting import run_is_complete
34
+
35
+ AUTO_PLOT_KEY = "user_preference.auto_plot"
36
+
37
+
38
+ class _RefreshIntervalSpinBox(qtw.QDoubleSpinBox):
39
+ """Fit the longest interval without the native spin box's surplus width."""
40
+
41
+ def sizeHint(self):
42
+ size = super().sizeHint()
43
+ option = qtw.QStyleOptionSpinBox()
44
+ self.initStyleOption(option)
45
+ option.rect = QtCore.QRect(QtCore.QPoint(), size)
46
+ edit_rect = self.style().subControlRect(
47
+ qtw.QStyle.ComplexControl.CC_SpinBox,
48
+ option, qtw.QStyle.SubControl.SC_SpinBoxEditField, self,
49
+ )
50
+ editor = self.lineEdit()
51
+ frame = qtw.QStyleOptionFrame()
52
+ frame.initFrom(editor)
53
+ frame.state |= qtw.QStyle.StateFlag.State_Sunken
54
+ if editor.hasFrame():
55
+ frame.lineWidth = editor.style().pixelMetric(
56
+ qtw.QStyle.PixelMetric.PM_DefaultFrameWidth, frame, editor
57
+ )
58
+ frame.rect = QtCore.QRect(QtCore.QPoint(), edit_rect.size())
59
+ contents = editor.style().subElementRect(
60
+ qtw.QStyle.SubElement.SE_LineEditContents, frame, editor
61
+ )
62
+ text_width = max(
63
+ editor.fontMetrics().horizontalAdvance(
64
+ self.prefix() + self.textFromValue(value) + self.suffix()
65
+ )
66
+ for value in (self.minimum(), self.maximum())
67
+ )
68
+ margins = editor.textMargins()
69
+ size.setWidth(
70
+ size.width() - contents.width() + text_width
71
+ + margins.left() + margins.right() + 4
72
+ )
73
+ return size
74
+
75
+ def minimumSizeHint(self):
76
+ return self.sizeHint()
77
+
78
+
79
+ def _run_timestamp_sort_key(metadata):
80
+ try:
81
+ return float(metadata.get("run_timestamp") or 0)
82
+ except (TypeError, ValueError):
83
+ return 0.0
84
+
85
+
86
+ class RunControlsMixin:
87
+ """
88
+ Main-window controls for run selection, refresh, and empty-state display.
89
+
90
+ Expects the host window to provide database and plotting actions such as
91
+ refreshMain, openRun, openPlot, and exportRunCsv.
92
+
93
+ """
94
+
95
+ def initRefresh(self):
96
+ """
97
+ Initialise the main window refresh controls.
98
+
99
+ Refresh checks for any new runs added to the dataset.
100
+
101
+ """
102
+ self.spinBox = _RefreshIntervalSpinBox()
103
+ self.spinBox.setObjectName("refreshIntervalSpin")
104
+ self.spinBox.setRange(0.0, 10_000.0)
105
+ self.spinBox.setSingleStep(0.1)
106
+ self.spinBox.setDecimals(1)
107
+ self.spinBox.setSuffix(" s")
108
+ self.spinBox.setSizePolicy(
109
+ qtw.QSizePolicy.Policy.Fixed, qtw.QSizePolicy.Policy.Fixed
110
+ )
111
+ self.spinBox.setAlignment(QtCore.Qt.AlignmentFlag.AlignRight)
112
+ self.spinBox.setToolTip("Refresh interval in seconds")
113
+ set_refresh_interval(
114
+ self.spinBox, self.config.get("user_preference.default_refresh_rate"),
115
+ )
116
+
117
+ self._automatic_refresh_epoch = 0
118
+ self._automatic_refresh_load_generation = None
119
+ self._automatic_refresh_instance = None
120
+ self._automatic_refresh_shutdown = False
121
+ connect_refresh_interval_edits(self.spinBox, self.monitorIntervalChanged)
122
+ # Keep exactly one connection. The timeout is deliberately routed
123
+ # through a lifecycle guard instead of directly to refreshMain:
124
+ # stopping a QTimer does not make a timeout already queued on the GUI
125
+ # event loop disappear.
126
+ self.monitor.timeout.connect(self._automatic_refresh_timeout)
127
+
128
+ self.autoPlotBox = qtw.QCheckBox()
129
+ self.autoPlotBox.setChecked(self.config.get(AUTO_PLOT_KEY))
130
+ self.autoPlotBox.setToolTip(
131
+ "Open newly detected runs and the newest running run when enabled"
132
+ )
133
+ self.autoPlotBox.toggled.connect(self._auto_plot_changed)
134
+
135
+ self.refreshDatabaseButton = qtw.QToolButton()
136
+ self.refreshDatabaseButton.setObjectName("refreshIconButton")
137
+ self.refreshDatabaseButton.setIcon(
138
+ self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_BrowserReload)
139
+ )
140
+ self.refreshDatabaseButton.setToolTip("Refresh the database run list (R)")
141
+ self.refreshDatabaseButton.setAccessibleName("Refresh database")
142
+ self.refreshDatabaseButton.setFixedSize(28, 26)
143
+ self.refreshDatabaseButton.clicked.connect(self.refreshMain)
144
+
145
+ self.closeAllPlotsButton = qtw.QToolButton()
146
+ self.closeAllPlotsButton.setObjectName("closeAllPlotsButton")
147
+ self.closeAllPlotsButton.setIcon(
148
+ self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_TitleBarCloseButton)
149
+ )
150
+ self.closeAllPlotsButton.setToolTip("Close all plot windows (Ctrl+Shift+W)")
151
+ self.closeAllPlotsButton.setAccessibleName("Close all plot windows")
152
+ self.closeAllPlotsButton.setFixedSize(28, 26)
153
+ self.closeAllPlotsButton.clicked.connect(self.closeAll)
154
+
155
+ def initRunDisplay(self):
156
+ sublayout = qtw.QHBoxLayout()
157
+ sublayout.setContentsMargins(8, 0, 8, 2)
158
+ sublayout.setSpacing(6)
159
+
160
+ sublayout.addWidget(qtw.QLabel("ID:"))
161
+
162
+ self.selected_run_id = None
163
+
164
+ self.run_idBox = qtw.QLineEdit()
165
+ self.run_idBox.setMaximumWidth(58)
166
+ self.run_idBox.setFixedWidth(58)
167
+ self.run_idBox.setValidator(QIntValidator())
168
+ self.run_idBox.setPlaceholderText("ID")
169
+ self.run_idBox.setToolTip("Run ID to plot")
170
+ self.run_idBox.textEdited.connect(self.update_run_id)
171
+ self.run_idBox.editingFinished.connect(self.sync_run_id_selection)
172
+ self.run_idBox.returnPressed.connect(self.openRun)
173
+ sublayout.addWidget(self.run_idBox)
174
+
175
+ sublayout.addWidget(qtw.QLabel("Measurement:"))
176
+
177
+ self.measurementBox = qtw.QLineEdit()
178
+ self.measurementBox.setMaximumWidth(46)
179
+ self.measurementBox.setFixedWidth(46)
180
+ self.measurementBox.setText("*")
181
+ self.measurementBox.setToolTip("Measurement to plot; * to plot all")
182
+ self.measurementBox.returnPressed.connect(self.openRun)
183
+ sublayout.addWidget(self.measurementBox)
184
+
185
+ self.plotRunButton = qtw.QToolButton()
186
+ self.plotRunButton.setObjectName("plotIconButton")
187
+ self.plotRunButton.setIcon(
188
+ self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_MediaPlay)
189
+ )
190
+ self.plotRunButton.setToolTip("Plot (Ctrl+Return)")
191
+ self.plotRunButton.setAccessibleName("Plot measurement")
192
+ self.plotRunButton.setFixedSize(28, 26)
193
+ self.plotRunButton.clicked.connect(self.openRun)
194
+ sublayout.addWidget(self.plotRunButton)
195
+
196
+ self.exportCsvButton = qtw.QToolButton()
197
+ self.exportCsvButton.setObjectName("exportIconButton")
198
+ self.exportCsvButton.setIcon(
199
+ self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_DialogSaveButton)
200
+ )
201
+ self.exportCsvButton.setToolTip("Export CSV")
202
+ self.exportCsvButton.setAccessibleName("Export measurement to CSV")
203
+ self.exportCsvButton.setFixedSize(28, 26)
204
+ self.exportCsvButton.clicked.connect(self.exportRunCsv)
205
+ sublayout.addWidget(self.exportCsvButton)
206
+
207
+ sublayout.addStretch()
208
+
209
+ sublayout.addWidget(qtw.QLabel("Auto-plot"))
210
+ sublayout.addWidget(self.autoPlotBox)
211
+
212
+ sublayout.addSpacing(12)
213
+ sublayout.addWidget(qtw.QLabel("Refresh:"))
214
+ sublayout.addWidget(self.spinBox)
215
+ sublayout.addWidget(self.refreshDatabaseButton)
216
+
217
+ self.l.addLayout(self.targetLayout)
218
+ self.l.addWidget(self.databaseLoadFrame)
219
+ self.l.addLayout(sublayout)
220
+
221
+ self.RunList = RunList(config=self.config)
222
+ self.RunList.selected.connect(self.updateSelected)
223
+ self.RunList.nonSingleSelection.connect(self.clear_non_single_run_selection)
224
+ self.RunList.plot.connect(self.openPlot)
225
+ self.RunList.previewPlotRequested.connect(self.open_run_preview_plot)
226
+ self.RunList.previewExportRequested.connect(self.export_run_preview_csv)
227
+ self.RunList.verticalScrollBar().valueChanged.connect(
228
+ lambda _: self._run_table_view_changed()
229
+ )
230
+
231
+ self.infoBox = moreInfo(preview_size=self.preview_size)
232
+ self.infoBox.preview.plotRequested.connect(self.open_preview_plot)
233
+ self.infoBox.preview.exportRequested.connect(self.export_preview_csv)
234
+ self.infoBox.preview.previewsReady.connect(self.RunList.set_run_previews)
235
+ self.infoBox.preview.previewGenerationChanged.connect(
236
+ self.RunList.set_run_preview_generating
237
+ )
238
+ self.infoBox.preview.databaseReplaced.connect(
239
+ self._reload_replaced_database
240
+ )
241
+ if self.fileTextbox.text() and self.RunList.topLevelItemCount():
242
+ self.infoBox.preview.set_database_runs(
243
+ self.fileTextbox.text(),
244
+ self.RunList.all_run_metadata(),
245
+ )
246
+ self._prioritize_preview_runs()
247
+
248
+ self._init_empty_state()
249
+ self.runInfoSplitter = qtw.QSplitter(QtCore.Qt.Orientation.Vertical)
250
+ self.runInfoSplitter.setHandleWidth(8)
251
+ self.runInfoSplitter.setChildrenCollapsible(True)
252
+ self.runInfoSplitter.setOpaqueResize(True)
253
+ self.runInfoSplitter.addWidget(self.RunList)
254
+ self.runInfoSplitter.addWidget(self.infoBox)
255
+ self.runInfoSplitter.setCollapsible(0, False)
256
+ self.runInfoSplitter.setCollapsible(1, True)
257
+ self.runInfoSplitter.setStretchFactor(0, 3)
258
+ self.runInfoSplitter.setStretchFactor(1, 2)
259
+ self.runInfoSplitter.setSizes([380, self._details_pane_height()])
260
+ self.runInfoSplitter.handle(1).setToolTip(
261
+ "Drag to resize the run list and details panes"
262
+ )
263
+ self.l.addWidget(self.emptyStateFrame)
264
+ self.l.addWidget(self.runInfoSplitter, 1)
265
+ self._sync_empty_state()
266
+
267
+ def initShortcuts(self):
268
+ """
269
+ Register keyboard shortcuts for common run actions.
270
+
271
+ """
272
+ plot_entered = create_action("run.plot_entered", self)
273
+ plot_entered.triggered.connect(lambda _: self.plotRunButton.click())
274
+ self.addAction(plot_entered)
275
+
276
+ plot_selected_all = create_action("run.plot_selected_all", self)
277
+ plot_selected_all.triggered.connect(self.open_selected_run_all)
278
+ self.addAction(plot_selected_all)
279
+
280
+ self.open_param_actions = []
281
+ for itr in range(9):
282
+ action = create_action(plot_measurement_command_spec(itr), self)
283
+ action.triggered.connect(lambda _, index=itr: self.open_param_by_index(index))
284
+ self.addAction(action)
285
+ self.open_param_actions.append(action)
286
+
287
+ def _init_empty_state(self):
288
+ """
289
+ Create the empty-database prompt shown before any runs are available.
290
+
291
+ """
292
+ self.emptyStateFrame = qtw.QFrame()
293
+ self.emptyStateFrame.setObjectName("mainEmptyState")
294
+ self.emptyStateFrame.setFrameShape(qtw.QFrame.Shape.NoFrame)
295
+ layout = qtw.QHBoxLayout(self.emptyStateFrame)
296
+ layout.setContentsMargins(8, 8, 8, 8)
297
+ layout.setSpacing(10)
298
+
299
+ icon = qtw.QLabel()
300
+ icon.setPixmap(
301
+ self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_DialogOpenButton).pixmap(24, 24)
302
+ )
303
+ layout.addWidget(icon)
304
+
305
+ text_layout = qtw.QVBoxLayout()
306
+ text_layout.setContentsMargins(0, 0, 0, 0)
307
+ text_layout.setSpacing(2)
308
+
309
+ title = qtw.QLabel("No database loaded")
310
+ title.setObjectName("mainEmptyStateTitle")
311
+ text_layout.addWidget(title)
312
+ self.emptyStateTitle = title
313
+
314
+ detail = qtw.QLabel(
315
+ "Drop a QCoDeS .db file onto the database field, or load one now."
316
+ )
317
+ detail.setObjectName("mainEmptyStateDetail")
318
+ detail.setWordWrap(True)
319
+ text_layout.addWidget(detail)
320
+ self.emptyStateDetail = detail
321
+ layout.addLayout(text_layout, 1)
322
+
323
+ load_button = qtw.QToolButton()
324
+ load_button.setObjectName("databaseIconButton")
325
+ load_button.setIcon(self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_DialogOpenButton))
326
+ load_button.setText("Load Database...")
327
+ load_button.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
328
+ load_button.setToolTip("Load a QCoDeS .db database")
329
+ load_button.setAccessibleName("Load database")
330
+ load_button.clicked.connect(self.getfile)
331
+ layout.addWidget(load_button)
332
+ self.emptyStateLoadButton = load_button
333
+
334
+ refresh_button = qtw.QToolButton()
335
+ refresh_button.setObjectName("databaseIconButton")
336
+ refresh_button.setIcon(self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_BrowserReload))
337
+ refresh_button.setText("Refresh")
338
+ refresh_button.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
339
+ refresh_button.setToolTip("Check for new measurements")
340
+ refresh_button.setAccessibleName("Refresh database")
341
+ refresh_button.clicked.connect(self.refreshMain)
342
+ layout.addWidget(refresh_button)
343
+ self.emptyStateRefreshButton = refresh_button
344
+
345
+ help_button = qtw.QToolButton()
346
+ help_button.setObjectName("databaseIconButton")
347
+ help_button.setIcon(self.style().standardIcon(qtw.QStyle.StandardPixmap.SP_DialogHelpButton))
348
+ help_button.setText("Quick Start")
349
+ help_button.setToolButtonStyle(QtCore.Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
350
+ help_button.setToolTip("Show the basic qPlot workflow")
351
+ help_button.setAccessibleName("Show quick start")
352
+ help_button.clicked.connect(lambda: show_quick_start(self))
353
+ layout.addWidget(help_button)
354
+ self.emptyStateHelpButton = help_button
355
+
356
+ def _sync_empty_state(self):
357
+ """
358
+ Show the empty prompt while no runs are available.
359
+
360
+ """
361
+ frame = getattr(self, "emptyStateFrame", None)
362
+ if frame is None:
363
+ return
364
+
365
+ database_path = ""
366
+ if hasattr(self, "fileTextbox"):
367
+ database_path = self.fileTextbox.text()
368
+
369
+ run_count = 0
370
+ run_list = getattr(self, "RunList", None)
371
+ if run_list is not None and hasattr(run_list, "topLevelItemCount"):
372
+ run_count = run_list.topLevelItemCount()
373
+
374
+ loading = getattr(self, "_database_load_active", False)
375
+ has_runs = run_count > 0
376
+ frame.setVisible(not loading and not has_runs)
377
+
378
+ if loading or has_runs:
379
+ return
380
+
381
+ if database_path:
382
+ sync_loaded = getattr(self, "_sync_loaded_empty_state", None)
383
+ if callable(sync_loaded):
384
+ sync_loaded(database_path)
385
+ else:
386
+ sync_empty = getattr(self, "_sync_no_database_empty_state", None)
387
+ if callable(sync_empty):
388
+ sync_empty()
389
+
390
+ def _sync_no_database_empty_state(self):
391
+ title = getattr(self, "emptyStateTitle", None)
392
+ if title is not None:
393
+ title.setText("No database loaded")
394
+
395
+ detail = getattr(self, "emptyStateDetail", None)
396
+ if detail is not None:
397
+ detail.setText(
398
+ "Drop a QCoDeS .db file onto the database field, or load one now."
399
+ )
400
+
401
+ self._set_empty_state_button_visible("emptyStateLoadButton", True)
402
+ self._set_empty_state_button_visible("emptyStateRefreshButton", False)
403
+ self._set_empty_state_button_visible("emptyStateHelpButton", True)
404
+
405
+ def _sync_loaded_empty_state(self, database_path):
406
+ title = getattr(self, "emptyStateTitle", None)
407
+ if title is not None:
408
+ title.setText("Waiting for measurements")
409
+
410
+ detail = getattr(self, "emptyStateDetail", None)
411
+ if detail is not None:
412
+ detail.setText(self._loaded_empty_database_detail(database_path))
413
+
414
+ self._set_empty_state_button_visible("emptyStateLoadButton", True)
415
+ self._set_empty_state_button_visible("emptyStateRefreshButton", True)
416
+ self._set_empty_state_button_visible("emptyStateHelpButton", False)
417
+
418
+ def _set_empty_state_button_visible(self, attr, visible):
419
+ button = getattr(self, attr, None)
420
+ if button is not None:
421
+ button.setVisible(visible)
422
+
423
+ def _loaded_empty_database_detail(self, database_path):
424
+ basename = database_path
425
+ if database_path:
426
+ basename = os.path.basename(database_path) or database_path
427
+
428
+ interval = self._current_refresh_interval()
429
+ if interval > 0:
430
+ return (
431
+ f"{basename} is loaded. qPlot will add measurements as they "
432
+ f"appear, checking every {interval:g} s."
433
+ )
434
+
435
+ return (
436
+ f"{basename} is loaded. Refresh is set to manual; press Refresh "
437
+ "to check for measurements."
438
+ )
439
+
440
+ def _current_refresh_interval(self):
441
+ spin_box = getattr(self, "spinBox", None)
442
+ if spin_box is None or not hasattr(spin_box, "value"):
443
+ return 0.0
444
+
445
+ try:
446
+ return refresh_interval_value(spin_box)
447
+ except (TypeError, ValueError):
448
+ return 0.0
449
+
450
+ @QtCore.pyqtSlot(float)
451
+ def monitorIntervalChanged(self, interval):
452
+ """
453
+ Updates the refresh interval for checking for new runs in database.
454
+
455
+ """
456
+ if not self._save_refresh_interval(interval):
457
+ self._sync_empty_state()
458
+ return
459
+ remember_refresh_interval(self.spinBox, interval)
460
+ self._apply_refresh_interval(interval)
461
+ self._sync_empty_state()
462
+
463
+ @QtCore.pyqtSlot(bool)
464
+ def _auto_plot_changed(self, checked):
465
+ """
466
+ Persists the Auto-plot checkbox state.
467
+
468
+ """
469
+ previous_checked = self.config.get(AUTO_PLOT_KEY)
470
+
471
+ def rollback():
472
+ checkbox = getattr(self, "autoPlotBox", None)
473
+ if checkbox is not None:
474
+ set_widget_value_without_signals(
475
+ checkbox,
476
+ checkbox.setChecked,
477
+ previous_checked,
478
+ )
479
+
480
+ if not persist_config_value(
481
+ self,
482
+ self.config,
483
+ AUTO_PLOT_KEY,
484
+ bool(checked),
485
+ "the Auto-plot preference",
486
+ rollback,
487
+ ):
488
+ return
489
+ if checked:
490
+ self._auto_plot_current_running_run()
491
+
492
+ def _auto_plot_current_running_run(self):
493
+ """
494
+ Opens the newest incomplete run already present in the run list.
495
+
496
+ """
497
+ # Stage 4 leaves plot-data materialisation on the legacy snapshot path.
498
+ # A trusted session may enter that path only through an explicit plot or
499
+ # export action; enabling a persisted preference is not such an action.
500
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
501
+ return None
502
+ generation_gate = getattr(
503
+ self,
504
+ "_database_generation_transaction_blocks_path",
505
+ None,
506
+ )
507
+ if callable(generation_gate) and generation_gate():
508
+ return None
509
+ run_list = getattr(self, "RunList", None)
510
+ if run_list is None or not hasattr(run_list, "all_run_metadata"):
511
+ return None
512
+
513
+ running_runs = [
514
+ metadata
515
+ for metadata in run_list.all_run_metadata().values()
516
+ if metadata.get("guid") and not run_is_complete(metadata)
517
+ ]
518
+ if not running_runs:
519
+ return None
520
+
521
+ metadata = max(
522
+ running_runs,
523
+ key=_run_timestamp_sort_key,
524
+ )
525
+ self.openPlot(metadata["guid"])
526
+ return metadata["guid"]
527
+
528
+ def _apply_refresh_interval(self, interval):
529
+ """
530
+ Reconcile the automatic-refresh timer with the committed DB lifecycle.
531
+
532
+ A positive preference is retained even when there is no database to
533
+ refresh. It becomes active only after a DatabaseInstance has been
534
+ accepted by database_load_finished.
535
+
536
+ """
537
+ generation_gate = getattr(
538
+ self,
539
+ "_database_generation_transaction_blocks_path",
540
+ None,
541
+ )
542
+ if callable(generation_gate) and generation_gate():
543
+ state = getattr(self, "_test_database_replacement_state", None)
544
+ if state is not None:
545
+ state.monitor_was_active = interval > 0
546
+ state.monitor_interval_ms = max(1, round(interval * 1000))
547
+ RunControlsMixin._update_automatic_refresh_timer(self, interval)
548
+
549
+ def _committed_refresh_database_instance(self):
550
+ """Return the accepted source instance, never inferring it from text."""
551
+ instance = getattr(self, "_loaded_database_instance", None)
552
+ if instance is None or getattr(instance, "identity", None) is None:
553
+ return None
554
+ return instance
555
+
556
+ def _automatic_refresh_should_run(self, interval):
557
+ """Whether the main refresh timer is presently allowed to run."""
558
+ if (
559
+ interval <= 0
560
+ or RunControlsMixin._committed_refresh_database_instance(self)
561
+ is None
562
+ ):
563
+ return False
564
+ if getattr(self, "_automatic_refresh_shutdown", False):
565
+ return False
566
+ if (
567
+ getattr(self, "_shutdown_started", False)
568
+ or getattr(self, "_shutdown_ready", False)
569
+ or getattr(self, "_database_load_active", False)
570
+ or getattr(self, "_database_view_released_for_generation", False)
571
+ ):
572
+ return False
573
+ generation_gate = getattr(
574
+ self,
575
+ "_database_generation_transaction_blocks_path",
576
+ None,
577
+ )
578
+ return not (callable(generation_gate) and generation_gate())
579
+
580
+ def _stop_automatic_refresh_timer(self):
581
+ """Stop the timer and invalidate timeout events already queued."""
582
+ self._automatic_refresh_epoch = (
583
+ getattr(self, "_automatic_refresh_epoch", 0) + 1
584
+ )
585
+ self._automatic_refresh_load_generation = None
586
+ self._automatic_refresh_instance = None
587
+ monitor = getattr(self, "monitor", None)
588
+ stop = getattr(monitor, "stop", None)
589
+ if callable(stop):
590
+ stop()
591
+
592
+ def _update_automatic_refresh_timer(self, interval=None):
593
+ """Start or pause automatic refresh according to authoritative state."""
594
+ if interval is None:
595
+ interval = self._current_refresh_interval()
596
+ try:
597
+ interval = float(interval)
598
+ except (TypeError, ValueError):
599
+ interval = 0.0
600
+
601
+ RunControlsMixin._stop_automatic_refresh_timer(self)
602
+ if not RunControlsMixin._automatic_refresh_should_run(self, interval):
603
+ return False
604
+
605
+ instance = RunControlsMixin._committed_refresh_database_instance(self)
606
+ self._automatic_refresh_load_generation = getattr(
607
+ self,
608
+ "_database_load_generation",
609
+ 0,
610
+ )
611
+ self._automatic_refresh_instance = instance
612
+ monitor = getattr(self, "monitor", None)
613
+ start = getattr(monitor, "start", None)
614
+ if not callable(start):
615
+ return False
616
+ start(max(1, round(interval * 1000)))
617
+ return True
618
+
619
+ @QtCore.pyqtSlot()
620
+ def _automatic_refresh_timeout(self):
621
+ """Refresh only if this timeout still belongs to the committed source."""
622
+ interval = self._current_refresh_interval()
623
+ expected_instance = getattr(self, "_automatic_refresh_instance", None)
624
+ if (
625
+ not RunControlsMixin._automatic_refresh_should_run(self, interval)
626
+ or expected_instance is None
627
+ or expected_instance
628
+ != RunControlsMixin._committed_refresh_database_instance(self)
629
+ or getattr(self, "_automatic_refresh_load_generation", None)
630
+ != getattr(self, "_database_load_generation", 0)
631
+ ):
632
+ RunControlsMixin._stop_automatic_refresh_timer(self)
633
+ return
634
+
635
+ # A file can disappear or be atomically replaced between timer ticks.
636
+ # Let the normal replacement lifecycle handle that, but never launch a
637
+ # refresh for the old instance.
638
+ database_path = expected_instance.logical_path
639
+ try:
640
+ current_instance = database_instance(database_path)
641
+ except OSError:
642
+ current_instance = None
643
+ if current_instance is None or database_instances_differ(
644
+ expected_instance,
645
+ current_instance,
646
+ ):
647
+ RunControlsMixin._stop_automatic_refresh_timer(self)
648
+ reload_database = getattr(self, "_reload_replaced_database", None)
649
+ if callable(reload_database):
650
+ reload_database(database_path)
651
+ return
652
+
653
+ self.refreshMain(automatic=True)
654
+
655
+ def _save_refresh_interval(self, interval):
656
+ """
657
+ Persists the main refresh interval as the user's default.
658
+
659
+ """
660
+ interval = float(interval)
661
+ current_interval = float(
662
+ self.config.get("user_preference.default_refresh_rate")
663
+ )
664
+
665
+ if current_interval == interval:
666
+ return True
667
+
668
+ def rollback():
669
+ spin_box = getattr(self, "spinBox", None)
670
+ if spin_box is not None:
671
+ set_refresh_interval(spin_box, current_interval)
672
+
673
+ return persist_config_value(
674
+ self,
675
+ self.config,
676
+ "user_preference.default_refresh_rate",
677
+ interval,
678
+ "the refresh interval",
679
+ rollback,
680
+ )
681
+
682
+ @QtCore.pyqtSlot(str)
683
+ def update_run_id(self, text):
684
+ """
685
+ Updates the run ID target entered into the run text box.
686
+
687
+ """
688
+ cancel_selected_detail = getattr(
689
+ self,
690
+ "_cancel_selected_run_detail",
691
+ None,
692
+ )
693
+ if callable(cancel_selected_detail):
694
+ cancel_selected_detail()
695
+ self._selected_run_guid = None
696
+ self.RunList.blockSignals(True)
697
+ self.RunList.clearSelection()
698
+ self.RunList.blockSignals(False)
699
+ release_selected = getattr(self, "_release_selected_dataset", None)
700
+ if callable(release_selected):
701
+ release_selected()
702
+ else:
703
+ self.ds = None
704
+ self._selected_dataset_key = None
705
+ self.infoBox.clear()
706
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
707
+ if derived_bridge is not None:
708
+ derived_bridge.request_priority_update()
709
+
710
+ try:
711
+ self.selected_run_id = int(text)
712
+ except ValueError:
713
+ self.selected_run_id = None
714
+
715
+ @QtCore.pyqtSlot()
716
+ def sync_run_id_selection(self):
717
+ """
718
+ Selects the typed run ID in the table if it is currently visible.
719
+
720
+ """
721
+ if self.selected_run_id is None:
722
+ return
723
+
724
+ matches = self.RunList.findItems(
725
+ str(self.selected_run_id),
726
+ QtCore.Qt.MatchFlag.MatchExactly,
727
+ 0,
728
+ )
729
+ if not matches:
730
+ return
731
+
732
+ item = matches[0]
733
+ self.RunList.setCurrentItem(item)
734
+ self.RunList.scrollToItem(item, qtw.QAbstractItemView.ScrollHint.PositionAtCenter)
735
+
736
+
737
+ def _run_table_view_changed(self):
738
+ generation_gate = getattr(
739
+ self,
740
+ "_database_generation_transaction_blocks_path",
741
+ None,
742
+ )
743
+ if callable(generation_gate) and generation_gate():
744
+ return
745
+ prioritize_details = getattr(self, "_prioritize_database_detail_runs", None)
746
+ if callable(prioritize_details):
747
+ prioritize_details()
748
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
749
+ if derived_bridge is not None:
750
+ derived_bridge.request_priority_update()
751
+ self._prioritize_preview_runs()
752
+
753
+
754
+ def _prioritize_preview_runs(self, run_ids=None):
755
+ generation_gate = getattr(
756
+ self,
757
+ "_database_generation_transaction_blocks_path",
758
+ None,
759
+ )
760
+ if callable(generation_gate) and generation_gate():
761
+ return
762
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
763
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
764
+ if derived_bridge is not None:
765
+ derived_bridge.request_priority_update()
766
+ return
767
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
768
+ prioritize = getattr(preview, "prioritize_runs", None)
769
+ if not callable(prioritize):
770
+ return
771
+
772
+ selected_run_ids, visible_run_ids = self._preview_priority_run_ids(run_ids)
773
+ prioritize(
774
+ selected_run_ids=selected_run_ids,
775
+ visible_run_ids=visible_run_ids,
776
+ )
777
+
778
+
779
+ def _preview_priority_run_ids(self, run_ids=None):
780
+ selected_run_ids = []
781
+ visible_run_ids = []
782
+ seen_selected = set()
783
+ seen_visible = set()
784
+
785
+ def add(target, seen, candidate):
786
+ if candidate is None:
787
+ return
788
+ try:
789
+ key = int(candidate)
790
+ except (TypeError, ValueError):
791
+ key = candidate
792
+ if key in seen:
793
+ return
794
+ target.append(candidate)
795
+ seen.add(key)
796
+
797
+ if isinstance(run_ids, (list, tuple, set)):
798
+ for run_id in run_ids:
799
+ add(selected_run_ids, seen_selected, run_id)
800
+ else:
801
+ add(selected_run_ids, seen_selected, run_ids)
802
+
803
+ run_list = getattr(self, "RunList", None)
804
+ selected = getattr(run_list, "selected_run_ids", None)
805
+ if callable(selected):
806
+ for run_id in selected():
807
+ add(selected_run_ids, seen_selected, run_id)
808
+
809
+ visible = getattr(run_list, "visible_run_ids", None)
810
+ if callable(visible):
811
+ for run_id in visible():
812
+ add(visible_run_ids, seen_visible, run_id)
813
+
814
+ return selected_run_ids, visible_run_ids
815
+
816
+ def _sync_refresh_interval(self):
817
+ interval = self.config.get("user_preference.default_refresh_rate")
818
+ if not hasattr(self, "spinBox"):
819
+ return
820
+
821
+ set_refresh_interval(self.spinBox, interval)
822
+ self._apply_refresh_interval(interval)
823
+
824
+ def _configured_preview_size(self):
825
+ return int(self.config.get("GUI.preview_size"))
826
+
827
+ def _details_pane_height(self):
828
+ return max(260, int(self.preview_size) + 84)