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,942 @@
1
+ from time import perf_counter
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ from PyQt6 import QtCore
5
+ from PyQt6 import QtWidgets as qtw
6
+
7
+ from qplot.datahandling.qcodes_cache import (
8
+ cache_has_no_written_data,
9
+ cache_is_live,
10
+ cache_parameter_is_synchronized,
11
+ restore_cache_parameter_publication_state,
12
+ set_cache_dataset_completed,
13
+ set_cache_parameter_synchronized,
14
+ snapshot_cache_parameter_publication_state,
15
+ update_cache_parameter_data,
16
+ )
17
+ from qplot.datahandling.readonly import quarantine_wal_for_replaced_database
18
+ from qplot.diagnostics import log_exception
19
+ from qplot.tools import loader
20
+ from qplot.tools.operation_registry import OperationValidationError
21
+
22
+ from ._dataset_handle import DatasetKey, dataset_key_matches_current_source
23
+ from ._refresh_interval import refresh_interval_value
24
+
25
+ if TYPE_CHECKING:
26
+ class _PlotRefreshBase(qtw.QMainWindow):
27
+ _last_error_text: str | None
28
+ _live: bool
29
+ axis_data: dict[str, Any]
30
+ axis_param: dict[str, Any]
31
+ config: Any
32
+ dataGrid: Any
33
+ display_param: Any
34
+ ds: Any
35
+ end_wait: Any
36
+ last_ds_len: int
37
+ lines: dict[Any, Any]
38
+ monitor: QtCore.QTimer
39
+ oper_widget: Any
40
+ param: Any
41
+ param_dict: dict[str, Any]
42
+ spinBox: qtw.QDoubleSpinBox
43
+ threadPool: QtCore.QThreadPool
44
+ worker: Any
45
+
46
+ @property
47
+ def axis_options(self) -> dict[str, str]: ...
48
+
49
+ def _set_param_axis_labels(self) -> None: ...
50
+ def hide_plot_state(self) -> None: ...
51
+ def initFrame(self) -> None: ...
52
+ def monitorIntervalChanged(self, interval: float) -> None: ...
53
+ def show_error(
54
+ self,
55
+ title: str,
56
+ message: str,
57
+ details: str | None = None,
58
+ ) -> None: ...
59
+ def show_plot_state(
60
+ self,
61
+ title: object,
62
+ detail: object | None = None,
63
+ kind: str = "info",
64
+ ) -> None: ...
65
+ def show_status(self, message: str, timeout: int = 5000) -> None: ...
66
+ else:
67
+ class _PlotRefreshBase:
68
+ pass
69
+
70
+
71
+ def plot_refresh_required(source: Any) -> bool:
72
+ """Return whether a source plot still needs live/final refresh work."""
73
+
74
+ predicate = getattr(source, "_refresh_monitor_required", None)
75
+ if callable(predicate):
76
+ return bool(predicate())
77
+
78
+ dataset = getattr(source, "ds", None)
79
+ return bool(getattr(dataset, "running", False))
80
+
81
+
82
+ class PlotRefreshMixin(_PlotRefreshBase):
83
+ """
84
+ Worker-backed plot refresh orchestration shared by plot windows.
85
+
86
+ """
87
+
88
+ def _can_process_refresh(self) -> bool:
89
+ """Return whether this window still has an active dataset owner."""
90
+
91
+ if not self.__dict__.get("_closed", False):
92
+ return True
93
+
94
+ if self.__dict__.get("_merged_trace_users", 0) <= 0:
95
+ return False
96
+
97
+ dataset_holder = self.__dict__.get("_dataset_holder")
98
+ dataset_key = self.__dict__.get("_dataset_key")
99
+ if not isinstance(dataset_holder, dict) or dataset_key is None:
100
+ return False
101
+
102
+ handle = dataset_holder.get(dataset_key)
103
+ return handle is not None and getattr(handle, "users", 0) > 0
104
+
105
+
106
+ def _source_database_matches_key(self) -> bool:
107
+ """Return whether the retained key still exactly names its source."""
108
+ dataset_key = self.__dict__.get("_dataset_key")
109
+ if not isinstance(dataset_key, DatasetKey):
110
+ return True
111
+ database_path = getattr(dataset_key, "database_path", None)
112
+ if not database_path:
113
+ return True
114
+ return dataset_key_matches_current_source(dataset_key)
115
+
116
+
117
+ def _source_database_is_current(self) -> bool:
118
+ """Stop before a replaced main, WAL, or SHM can back this plot."""
119
+
120
+ dataset_key = self.__dict__.get("_dataset_key")
121
+ database_path = getattr(dataset_key, "database_path", None)
122
+ if self._source_database_matches_key():
123
+ return True
124
+
125
+ quarantine_wal_for_replaced_database(database_path)
126
+ worker = self.__dict__.get("worker")
127
+ if worker is not None and getattr(worker, "running", False):
128
+ cancel = getattr(worker, "cancel", None)
129
+ if callable(cancel):
130
+ cancel()
131
+ monitor = self.__dict__.get("monitor")
132
+ if monitor is not None:
133
+ monitor.stop()
134
+ self.show_status(
135
+ "Database or SQLite sidecar was replaced; reloading source data.",
136
+ 5000,
137
+ )
138
+ self.show_plot_state(
139
+ "Database replaced",
140
+ "qPlot is reloading the replacement before this plot can refresh.",
141
+ kind="info",
142
+ )
143
+ replacement_signal = getattr(self, "database_replaced", None)
144
+ emit = getattr(replacement_signal, "emit", None)
145
+ if callable(emit):
146
+ emit(str(database_path))
147
+ return False
148
+
149
+
150
+ def _begin_refresh_publication(self, worker: Any) -> None:
151
+ """Capture the cache and plot fields a callback may mutate."""
152
+
153
+ publication_generation = (
154
+ self.__dict__.get("_qplot_publication_generation", 0) + 1
155
+ )
156
+ self._qplot_publication_generation = publication_generation
157
+ cache = getattr(self.ds, "cache", None)
158
+ parameter_name = self.param.name
159
+ fields = {}
160
+ for name in (
161
+ "_live",
162
+ "_qplot_display_synchronized",
163
+ "_qplot_display_uses_direct_sql",
164
+ "_heatmap_full_resolution",
165
+ "axis_data",
166
+ "axis_param",
167
+ "dataGrid",
168
+ "display_param",
169
+ "last_ds_len",
170
+ ):
171
+ fields[name] = (name in self.__dict__, self.__dict__.get(name))
172
+ try:
173
+ prior_cache = (
174
+ snapshot_cache_parameter_publication_state(
175
+ cache,
176
+ parameter_name,
177
+ )
178
+ if cache is not None
179
+ else None
180
+ )
181
+ except AttributeError:
182
+ # Narrow legacy/test cache doubles may not expose QCoDeS' private
183
+ # status mappings. They cannot receive the shared-cache mutation
184
+ # guarded here, but their plot fields still need rollback state.
185
+ prior_cache = None
186
+ worker._qplot_publication_snapshot = {
187
+ "cache": cache,
188
+ "parameter_name": parameter_name,
189
+ "prior_cache": prior_cache,
190
+ "committed_cache": None,
191
+ "fields": fields,
192
+ "generation": publication_generation,
193
+ }
194
+ worker._qplot_source_rejected = False
195
+
196
+
197
+ @staticmethod
198
+ def _record_refresh_cache_publication(worker: Any) -> None:
199
+ snapshot = getattr(worker, "_qplot_publication_snapshot", None)
200
+ if (
201
+ not isinstance(snapshot, dict)
202
+ or snapshot.get("prior_cache") is None
203
+ ):
204
+ return
205
+ snapshot["committed_cache"] = snapshot_cache_parameter_publication_state(
206
+ snapshot["cache"],
207
+ snapshot["parameter_name"],
208
+ )
209
+
210
+
211
+ def _rollback_refresh_publication(self, worker: Any) -> None:
212
+ """Remove stale cache/axis state before replacement recovery runs."""
213
+
214
+ snapshot = getattr(worker, "_qplot_publication_snapshot", None)
215
+ if not isinstance(snapshot, dict):
216
+ return
217
+ owns_publication = bool(
218
+ self.__dict__.get("_qplot_publication_generation")
219
+ == snapshot.get("generation")
220
+ and worker is self.__dict__.get("worker")
221
+ )
222
+ if not owns_publication:
223
+ worker._qplot_display_commit_ready = False
224
+ worker._qplot_source_rejected = True
225
+ worker._qplot_publication_snapshot = None
226
+ return
227
+ committed_cache = snapshot.get("committed_cache")
228
+ if committed_cache is not None:
229
+ restore_cache_parameter_publication_state(
230
+ snapshot["cache"],
231
+ snapshot["parameter_name"],
232
+ snapshot["prior_cache"],
233
+ committed_cache,
234
+ )
235
+ for name, (present, value) in snapshot["fields"].items():
236
+ if present:
237
+ self.__dict__[name] = value
238
+ else:
239
+ self.__dict__.pop(name, None)
240
+ worker._qplot_display_commit_ready = False
241
+ worker._qplot_source_rejected = True
242
+ worker._qplot_publication_snapshot = None
243
+
244
+
245
+ def _refresh_publication_source_is_current(
246
+ self,
247
+ worker: Any,
248
+ *,
249
+ clear_display=None,
250
+ ) -> bool:
251
+ """Guard one cache/display publication boundary and fail closed."""
252
+
253
+ snapshot = getattr(worker, "_qplot_publication_snapshot", None)
254
+ if isinstance(snapshot, dict):
255
+ owns_publication = bool(
256
+ self.__dict__.get("_qplot_publication_generation")
257
+ == snapshot.get("generation")
258
+ and worker is self.__dict__.get("worker")
259
+ )
260
+ if not owns_publication:
261
+ # A nested same-source callback already owns the plot. The
262
+ # older callback must stop harmlessly: rollback or renderer
263
+ # clearing here would corrupt the newer accepted publication.
264
+ worker._qplot_display_commit_ready = False
265
+ worker._qplot_publication_snapshot = None
266
+ return False
267
+ elif worker is not self.__dict__.get("worker"):
268
+ return False
269
+
270
+ if self._source_database_matches_key():
271
+ return True
272
+ self._rollback_refresh_publication(worker)
273
+ if callable(clear_display):
274
+ clear_display()
275
+ self._source_database_is_current()
276
+ return False
277
+
278
+
279
+ def _commit_refresh_publication(
280
+ self, worker: Any, *, preview_ready: bool = False
281
+ ) -> None:
282
+ """Release rollback state after the concrete display committed."""
283
+
284
+ worker._qplot_publication_snapshot = None
285
+ overlay = self.__dict__.get("plot_state_overlay")
286
+ if overlay is not None:
287
+ overlay.finish(worker)
288
+ publish_preview = self.__dict__.get("_plot_preview_sink")
289
+ if (
290
+ preview_ready
291
+ and callable(publish_preview)
292
+ and not getattr(worker, "operations", None)
293
+ and getattr(worker, "heatmap_axis_ranges", None) is None
294
+ and not getattr(worker, "is_cancelled", lambda: False)()
295
+ and self._source_database_matches_key()
296
+ ):
297
+ try:
298
+ publish_preview(self, worker)
299
+ except Exception as error:
300
+ # A supplementary image must never turn a successful plot
301
+ # publication into a plot load failure.
302
+ log_exception("Plot preview reuse failed", error, __name__)
303
+
304
+ def _refresh_monitor_required(self, dataset: Any | None = None) -> bool:
305
+ """Keep polling until this plot has committed its terminal display."""
306
+
307
+ if dataset is None:
308
+ dataset = self.ds
309
+ if getattr(dataset, "running", False):
310
+ return True
311
+
312
+ state = self.__dict__
313
+ if not state.get("_qplot_display_synchronized", True):
314
+ return True
315
+
316
+ # A direct-SQL heatmap deliberately bypasses the QCoDeS cache. Its
317
+ # successful display commit is therefore the terminal per-plot state.
318
+ if state.get("_qplot_display_uses_direct_sql", False):
319
+ return False
320
+
321
+ cache = getattr(dataset, "cache", None)
322
+ param = state.get("param")
323
+ if cache is None or param is None:
324
+ return False
325
+ return not cache_parameter_is_synchronized(cache, param.name)
326
+
327
+ def _sync_dataset_completion(self, dataset: Any) -> None:
328
+ """Schedule this plot's outstanding final worker read."""
329
+
330
+ if not self._refresh_monitor_required(dataset):
331
+ return
332
+
333
+ worker = getattr(self, "worker", None)
334
+ if worker is not None and getattr(worker, "running", False):
335
+ self._queue_pending_refresh()
336
+ return
337
+
338
+ self.load_data()
339
+
340
+ def _ensure_refresh_monitor(self) -> None:
341
+ """Restart polling when a terminal worker/display remains pending."""
342
+
343
+ if not self._can_process_refresh():
344
+ return
345
+ try:
346
+ dataset = self.ds
347
+ except (AttributeError, KeyError, RuntimeError):
348
+ return
349
+ if not self._refresh_monitor_required(dataset):
350
+ return
351
+
352
+ state = self.__dict__
353
+ monitor = state.get("monitor")
354
+ spin_box = state.get("spinBox")
355
+ if monitor is None or spin_box is None:
356
+ return
357
+ is_active = getattr(monitor, "isActive", None)
358
+ if callable(is_active) and is_active():
359
+ return
360
+ self.monitorIntervalChanged(refresh_interval_value(spin_box))
361
+
362
+ def _mark_display_synchronized(self, worker: Any) -> bool:
363
+ """Publish terminal plot state after the concrete display commit."""
364
+
365
+ if worker is None or worker is not self.__dict__.get("worker"):
366
+ return False
367
+ if getattr(worker, "is_cancelled", lambda: False)():
368
+ return False
369
+ if not self._refresh_publication_source_is_current(worker):
370
+ return False
371
+ if not getattr(worker, "_qplot_display_commit_ready", False):
372
+ return False
373
+ if getattr(worker, "dataset_completed", None) is not True:
374
+ return False
375
+
376
+ loaded_from_sql = bool(
377
+ getattr(worker, "loaded_from_sql_heatmap", False)
378
+ )
379
+ if not loaded_from_sql and not cache_parameter_is_synchronized(
380
+ self.ds.cache,
381
+ self.param.name,
382
+ ):
383
+ return False
384
+
385
+ self._qplot_display_synchronized = True
386
+ self._qplot_display_uses_direct_sql = loaded_from_sql
387
+ worker._qplot_display_commit_ready = False
388
+ return True
389
+
390
+ def load_data(
391
+ self,
392
+ wait_on_thread: bool = False,
393
+ *,
394
+ force_sql_heatmap: bool = False,
395
+ heatmap_axis_ranges: dict[str, tuple[float, float]] | None = None,
396
+ heatmap_full_axis_ranges: dict[str, tuple[float, float]] | None = None,
397
+ status_message: str | None = None,
398
+ ) -> bool:
399
+ """
400
+ Produces a worker for loading/refreshing the dataset.
401
+ Then adds the worker to the threadPool queue to work.
402
+
403
+ Can use wait_on_thread=True to force main thread to wait for callback.
404
+ Recommend to avoid where possible, as effects all windows.
405
+
406
+ Parameters
407
+ ----------
408
+ wait_on_thread : bool, optional
409
+ If true uses an QEventLoop to stop main code from running until
410
+ worker has finished its task. The default is False.
411
+
412
+ """
413
+ if not self._can_process_refresh():
414
+ return False
415
+ if not self._source_database_is_current():
416
+ return False
417
+
418
+ try:
419
+ operations = self.oper_widget.get_data()
420
+ except OperationValidationError as error:
421
+ message = str(error)
422
+ self.show_status(message, 10_000)
423
+ self.show_plot_state(
424
+ "Operations not applied",
425
+ message,
426
+ kind="error",
427
+ )
428
+ return False
429
+
430
+ loader_kwargs = {
431
+ "read_data": True,
432
+ "operations": operations,
433
+ "force_sql_heatmap": force_sql_heatmap,
434
+ "max_full_heatmap_points": self.config.get(
435
+ "runtime_settings.max_full_heatmap_points"
436
+ ),
437
+ "max_heatmap_grid_cells": self.config.get(
438
+ "runtime_settings.max_heatmap_grid_cells"
439
+ ),
440
+ "max_heatmap_grid_side": self.config.get(
441
+ "runtime_settings.max_heatmap_grid_side"
442
+ ),
443
+ "heatmap_axis_ranges": heatmap_axis_ranges,
444
+ "heatmap_full_axis_ranges": heatmap_full_axis_ranges,
445
+ }
446
+ database_identity = getattr(
447
+ getattr(self, "_dataset_key", None),
448
+ "database_identity",
449
+ None,
450
+ )
451
+ if database_identity is not None:
452
+ loader_kwargs["database_identity"] = database_identity
453
+
454
+ worker: Any = loader(
455
+ self.ds.cache,
456
+ self.param,
457
+ self.param_dict,
458
+ self.axis_options,
459
+ **loader_kwargs,
460
+ )
461
+ worker._qplot_operation_apply_generation = int(
462
+ getattr(self.oper_widget, "_apply_generation", 0)
463
+ )
464
+ dataset_key = getattr(self, "_dataset_key", None)
465
+ if dataset_key is not None:
466
+ worker.expected_database_path = dataset_key.database_path
467
+ worker.expected_resolved_database_path = (
468
+ dataset_key.resolved_database_path
469
+ )
470
+ worker.expected_sidecar_identities = dataset_key.sidecar_identities
471
+ worker.sidecar_identities = dataset_key.sidecar_identities
472
+ worker.dataset_length_at_start = self.ds.number_of_results
473
+
474
+ if status_message is not None:
475
+ message = status_message
476
+ else:
477
+ message = f"Loading data for {self.param.name}..."
478
+ self.show_status(message, 0)
479
+ self.show_plot_state(message, kind="loading")
480
+ worker.started_at = perf_counter()
481
+
482
+ # Callback
483
+ worker.emitter.finished.connect(
484
+ lambda finished, worker=worker: self.refreshPlot(finished, worker=worker)
485
+ )
486
+ worker_registry = getattr(self.threadPool, "_qplot_workers", None)
487
+ if worker_registry is not None:
488
+ worker_registry.add(worker)
489
+ worker.emitter.finished.connect(
490
+ lambda _finished, worker=worker, registry=worker_registry: (
491
+ registry.discard(worker)
492
+ )
493
+ )
494
+ # Error event handling
495
+ worker.emitter.errorOccurred.connect(
496
+ lambda err, worker=worker: self.err_raiser(err, worker=worker)
497
+ )
498
+ worker.emitter.printer.connect(
499
+ lambda message, worker=worker: self.worker_printer(
500
+ message,
501
+ worker=worker,
502
+ )
503
+ )
504
+
505
+ if wait_on_thread: # Force freeze main thread
506
+ hold_up = QtCore.QEventLoop()
507
+ self.end_wait.connect(hold_up.quit) # Release main thread event
508
+
509
+ if not getattr(self.ds, "running", False):
510
+ # A terminal reload supersedes the previous displayed snapshot.
511
+ # Keep it retryable until this worker's concrete display commit.
512
+ self._qplot_display_synchronized = False
513
+
514
+ # Run worker
515
+ self.worker = worker
516
+ overlay = self.__dict__.get("plot_state_overlay")
517
+ if overlay is not None:
518
+ overlay.track(worker)
519
+ worker_will_start = getattr(type(self), "_refresh_worker_will_start", None)
520
+ if callable(worker_will_start):
521
+ worker_will_start(self, worker)
522
+ try:
523
+ worker.start(self.threadPool)
524
+ except Exception:
525
+ worker.running = False
526
+ if overlay is not None:
527
+ overlay.show("Plot could not start", kind="error")
528
+ raise
529
+
530
+ if wait_on_thread:
531
+ hold_up.exec() # The actual place the code waits for self.end_wait.emit
532
+ self.end_wait.disconnect(hold_up.quit)
533
+
534
+ return True
535
+
536
+
537
+ @QtCore.pyqtSlot()
538
+ def refreshWindow(self, force: bool = False) -> None:
539
+ """
540
+ Event handler for monitor timeout and other refresh sources.
541
+
542
+ Check whether refresh should be done and attempts to refresh plot.
543
+
544
+ Parameters
545
+ ----------
546
+ force : bool, optional
547
+ Forces a refresh regarless of checks. The default is False.
548
+
549
+ """
550
+ self.monitor.stop()
551
+ if not self._can_process_refresh():
552
+ return
553
+ if not self._source_database_is_current():
554
+ return
555
+
556
+ retry = False
557
+ dataset = None
558
+
559
+ try:
560
+ dataset = self.ds
561
+ current_ds_len = dataset.number_of_results
562
+
563
+ # Plot has started, worker first defined in initFrame
564
+ if not hasattr(self, "worker"):
565
+ self.initFrame() # defined in children classes
566
+ retry = True
567
+ return
568
+
569
+ # Check if new data has been added to the dataset
570
+ if current_ds_len != self.last_ds_len or force:
571
+ if self.worker.running: # No need to run if already updating
572
+ self._queue_pending_refresh(force=force)
573
+ if force:
574
+ self.show_status("Refresh queued after the current load.", 3000)
575
+ return
576
+
577
+ # The actual refresh line
578
+ self.load_data()
579
+
580
+ elif self._refresh_monitor_required(dataset):
581
+ # Source completion is global, but this parameter/cache/display
582
+ # may still need its own successful terminal refresh.
583
+ self._sync_dataset_completion(dataset)
584
+
585
+ except Exception:
586
+ retry = True
587
+ raise
588
+
589
+ finally: # Ran after return or otherwise
590
+
591
+ # restart monitor
592
+ if retry or (
593
+ dataset is not None
594
+ and self._refresh_monitor_required(dataset)
595
+ ):
596
+ self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
597
+
598
+ # restard monitor if any subplots are live
599
+ elif dataset is not None:
600
+ lines = self.__dict__.get("lines")
601
+ if isinstance(lines, dict) and lines:
602
+ main_line = self.__dict__.get("line")
603
+ for subplot in lines.values():
604
+ if subplot is main_line:
605
+ continue
606
+ source = getattr(subplot, "from_win", None)
607
+ running = (
608
+ plot_refresh_required(source)
609
+ if source is not None
610
+ else bool(getattr(subplot, "running", False))
611
+ )
612
+ subplot.running = bool(running)
613
+ if running:
614
+ self.monitorIntervalChanged(refresh_interval_value(self.spinBox))
615
+ break
616
+
617
+
618
+ def _schedule_pending_refresh(self) -> None:
619
+ state = self.__dict__
620
+ if not state.get("_refresh_pending", False):
621
+ return
622
+ if state.get("_refresh_pending_scheduled", False):
623
+ return
624
+
625
+ self._refresh_pending_scheduled = True
626
+ QtCore.QTimer.singleShot(0, self._run_pending_refresh)
627
+
628
+
629
+ def _queue_pending_refresh(self, *, force: bool = False) -> None:
630
+ """Coalesce refreshes while retaining explicit-force provenance."""
631
+
632
+ state = self.__dict__
633
+ if not state.get("_refresh_pending", False):
634
+ self._refresh_pending_force = False
635
+ self._refresh_pending = True
636
+ if force:
637
+ self._refresh_pending_force = True
638
+
639
+
640
+ def _run_pending_refresh(self) -> None:
641
+ self._refresh_pending_scheduled = False
642
+ if not self.__dict__.get("_refresh_pending", False):
643
+ return
644
+ if not self._can_process_refresh():
645
+ self._refresh_pending = False
646
+ self._refresh_pending_force = False
647
+ return
648
+ if getattr(getattr(self, "worker", None), "running", False):
649
+ self._schedule_pending_refresh()
650
+ return
651
+
652
+ force = bool(self.__dict__.get("_refresh_pending_force", False))
653
+ self._refresh_pending = False
654
+ self._refresh_pending_force = False
655
+ if not force and not self._refresh_monitor_required():
656
+ return
657
+ self.refreshWindow(force=force)
658
+
659
+
660
+ @QtCore.pyqtSlot(bool)
661
+ def refreshPlot(
662
+ self,
663
+ finished: bool = True,
664
+ worker: Any | None = None,
665
+ ) -> bool | None:
666
+ """
667
+ Produces a shallow copy of data produced by worker.
668
+ This is inhertited by plot<1/2>d to actually use the loaded data.
669
+
670
+ Parameters
671
+ ----------
672
+ finished : bool
673
+ In the event the worker had to abort, finished is False and refresh
674
+ is not ran.
675
+
676
+ """
677
+ if worker is None:
678
+ worker = self.__dict__.get("worker")
679
+
680
+ if worker is None:
681
+ return False
682
+
683
+ current_worker = self.__dict__.get("worker")
684
+ if worker is not current_worker:
685
+ worker.running = False
686
+ return False
687
+ worker = current_worker
688
+
689
+ was_cancelled = bool(
690
+ getattr(worker, "is_cancelled", lambda: False)()
691
+ )
692
+
693
+ if not self._can_process_refresh():
694
+ worker.running = False
695
+ self.end_wait.emit()
696
+ return False
697
+ if not self._source_database_is_current():
698
+ # A worker can finish from a private snapshot after the source main
699
+ # file was atomically replaced. Do not let that stale result reach
700
+ # the shared cache or the concrete display callback.
701
+ worker.running = False
702
+ self.end_wait.emit()
703
+ return False
704
+
705
+ try:
706
+ worker._qplot_display_commit_ready = False
707
+ if was_cancelled:
708
+ worker.running = False
709
+ self.show_plot_state("Plot load cancelled")
710
+ return False
711
+
712
+ if not finished: # error in worker
713
+ worker.running = False
714
+ self.show_plot_state(
715
+ "Plot load failed",
716
+ "Check the status bar or diagnostic log for details.",
717
+ kind="error",
718
+ )
719
+ return False
720
+
721
+ self._begin_refresh_publication(worker)
722
+ # Update qcodes dataset variables if db read happened
723
+ if worker.read_data:
724
+ cache = self.ds.cache
725
+ name = self.param.name
726
+
727
+ cache_updated = update_cache_parameter_data(
728
+ cache,
729
+ name,
730
+ worker.updated_read_status,
731
+ worker.updated_write_status,
732
+ worker.cache_data,
733
+ dataset_completed=getattr(
734
+ worker,
735
+ "dataset_completed",
736
+ None,
737
+ ),
738
+ )
739
+ if not cache_updated:
740
+ worker._qplot_publication_snapshot = None
741
+ self._queue_pending_refresh()
742
+ self.show_status(
743
+ "A newer refresh finished first; synchronising this plot...",
744
+ 5000,
745
+ )
746
+ return False
747
+
748
+ self._record_refresh_cache_publication(worker)
749
+ if not self._refresh_publication_source_is_current(worker):
750
+ return False
751
+
752
+ if not cache_has_no_written_data(cache):
753
+ self._live = False
754
+
755
+ if (
756
+ getattr(worker, "dataset_completed", None) is True
757
+ and cache_is_live(self.ds.cache)
758
+ ):
759
+ # An in-memory live cache is already authoritative; no database
760
+ # cache commit is needed before its terminal display refresh.
761
+ set_cache_parameter_synchronized(
762
+ self.ds.cache,
763
+ self.param.name,
764
+ True,
765
+ )
766
+
767
+ # set data to be called by plot<1/2>d.refreshPlot()
768
+ self.axis_data = {
769
+ "x": worker.axis_data["x"],
770
+ "y": worker.axis_data["y"],
771
+ }
772
+ self.axis_param = {
773
+ "x": worker.axis_param["x"],
774
+ "y": worker.axis_param["y"],
775
+ }
776
+ self.display_param = getattr(worker, "display_param", self.param)
777
+
778
+ # For 2d plots
779
+ if hasattr(worker, "dataGrid"):
780
+ self.dataGrid = worker.dataGrid
781
+
782
+ # I didnt want to make this a dedicated callback for the few times
783
+ # it is used, as the performace hit is neglible
784
+ # Update text
785
+ self._set_param_axis_labels()
786
+ update_coordinate_context = getattr(
787
+ self,
788
+ "_update_coordinate_context",
789
+ None,
790
+ )
791
+ if callable(update_coordinate_context):
792
+ update_coordinate_context()
793
+ elapsed = perf_counter() - worker.started_at
794
+
795
+ if getattr(worker, "loaded_from_sql_heatmap", False):
796
+ loaded_points = getattr(worker, "loaded_point_count", None)
797
+ if getattr(worker, "aggregated_heatmap_source", False):
798
+ point_text = (
799
+ f"{loaded_points:,} aggregated cells"
800
+ if loaded_points is not None
801
+ else "aggregated cells"
802
+ )
803
+ else:
804
+ point_text = (
805
+ f"{loaded_points:,} sampled points"
806
+ if loaded_points is not None
807
+ else "sampled points"
808
+ )
809
+ self.show_status(
810
+ f"Loaded {point_text} for {self.param.name} "
811
+ f"in {elapsed:.2f} seconds",
812
+ 5000,
813
+ )
814
+ else:
815
+ self.show_status(
816
+ f"Loaded {self.ds.number_of_results:,} points for {self.param.name} "
817
+ f"in {elapsed:.2f} seconds",
818
+ 5000,
819
+ )
820
+ dataset_length = getattr(worker, "dataset_length_at_start", None)
821
+ if dataset_length is None:
822
+ dataset_length = self.ds.number_of_results
823
+ self.last_ds_len = dataset_length
824
+ from qplot.datahandling.trusted_plot import TrustedPlotDataset
825
+ if isinstance(self.ds, TrustedPlotDataset):
826
+ self.ds.number_of_results = max(self.ds.number_of_results, dataset_length)
827
+ overlay = self.__dict__.get("plot_state_overlay")
828
+ if overlay is not None:
829
+ overlay.rendering(worker)
830
+ else:
831
+ self.hide_plot_state()
832
+ if (
833
+ getattr(worker, "loaded_from_sql_heatmap", False)
834
+ and getattr(worker, "dataset_completed", None) is True
835
+ ):
836
+ # Direct SQL heatmaps intentionally bypass the QCoDeS cache.
837
+ # Publish the global source observation here; the concrete
838
+ # heatmap publishes its per-plot state only after rendering.
839
+ set_cache_dataset_completed(self.ds.cache, True)
840
+ worker._qplot_display_commit_ready = bool(
841
+ getattr(worker, "dataset_completed", None) is True
842
+ and (
843
+ getattr(worker, "loaded_from_sql_heatmap", False)
844
+ or cache_parameter_is_synchronized(
845
+ self.ds.cache,
846
+ self.param.name,
847
+ )
848
+ )
849
+ )
850
+ self._record_refresh_cache_publication(worker)
851
+ if not self._refresh_publication_source_is_current(worker):
852
+ return False
853
+ return True
854
+
855
+ except AttributeError as err:
856
+ # If worker starts too quickly, overwrites data and spits out error.
857
+ # This should no longer be possible so making error soft error.
858
+ # Keep the publication snapshot until the accepted source and this
859
+ # worker's ownership have both been revalidated. In particular,
860
+ # an AttributeError after cache/axis mutation must not turn a
861
+ # simultaneous database replacement into a committed stale view.
862
+ if not self._refresh_publication_source_is_current(worker):
863
+ return False
864
+ self.show_status(f"Refresh skipped: {err}", 10_000)
865
+ self.show_plot_state("Refresh skipped", str(err), kind="error")
866
+ if not self._refresh_publication_source_is_current(worker):
867
+ return False
868
+ worker._qplot_publication_snapshot = None
869
+ return None
870
+
871
+ except Exception:
872
+ # The concrete 1D/2D callback is not entered when the base
873
+ # publication raises. Roll back an owned partial publication here;
874
+ # a source mismatch performs the same rollback and recovery inside
875
+ # the guard, while lost ownership leaves the newer callback alone.
876
+ if isinstance(
877
+ getattr(worker, "_qplot_publication_snapshot", None),
878
+ dict,
879
+ ):
880
+ if self._refresh_publication_source_is_current(worker):
881
+ self._rollback_refresh_publication(worker)
882
+ if worker is self.__dict__.get("worker"):
883
+ self.show_plot_state("Plot display failed", kind="error")
884
+ raise
885
+
886
+ finally: # Allow code to move on from wait_on_thread
887
+ if worker is self.worker:
888
+ worker.running = False
889
+ self.end_wait.emit()
890
+ if (
891
+ not getattr(worker, "_qplot_display_commit_ready", False)
892
+ and not getattr(worker, "_qplot_source_rejected", False)
893
+ ):
894
+ self._ensure_refresh_monitor()
895
+ if not getattr(worker, "_qplot_source_rejected", False):
896
+ self._schedule_pending_refresh()
897
+
898
+
899
+ @QtCore.pyqtSlot(Exception)
900
+ def err_raiser(self, err: Exception, worker: Any | None = None) -> None:
901
+ if (
902
+ worker is not None
903
+ and getattr(worker, "is_cancelled", lambda: False)()
904
+ ):
905
+ worker.running = False
906
+ return
907
+
908
+ if not self._can_process_refresh():
909
+ if worker is not None:
910
+ worker.running = False
911
+ return
912
+
913
+ if worker is not None and worker is not self.worker:
914
+ return
915
+
916
+ if worker is not None and getattr(worker, "database_replaced", False):
917
+ self._source_database_is_current()
918
+ return
919
+
920
+ message = f"{type(err).__name__}: {err}"
921
+ log_exception("Plot worker error", err, __name__)
922
+ self.show_status(f"Worker error: {message}", 10_000)
923
+ self.show_plot_state("Plot load failed", message, kind="error")
924
+
925
+ if message == self._last_error_text:
926
+ return
927
+
928
+ self._last_error_text = message
929
+ self.show_error("Plot Error", "A plot worker failed.", message)
930
+
931
+
932
+ @QtCore.pyqtSlot(str)
933
+ def worker_printer(self, fstr: str, worker: Any | None = None) -> None:
934
+ if not self._can_process_refresh():
935
+ return
936
+
937
+ if worker is not None and worker is not self.worker:
938
+ return
939
+
940
+ # Worker print() often does not work, so done through event handlers
941
+ self.show_status(fstr, 5000)
942
+ print(fstr)