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,2695 @@
1
+ import os
2
+ from functools import wraps
3
+ from inspect import signature
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from PyQt6 import QtCore
8
+
9
+ from qplot.datahandling.dimensions import (
10
+ MAX_SUPPORTED_PLOT_DIMENSIONS,
11
+ unsupported_plot_message,
12
+ )
13
+ from qplot.datahandling.file_identity import (
14
+ DatabaseInstance,
15
+ logical_database_path,
16
+ )
17
+ from qplot.datahandling.parameter_data import (
18
+ concatenate_record_samples,
19
+ parameter_data_for_export,
20
+ )
21
+ from qplot.datahandling.readonly import (
22
+ DatabaseInstanceChangedError,
23
+ load_by_guid_read_only,
24
+ )
25
+ from qplot.datahandling.trusted_live_service import (
26
+ SNAPSHOT_FALLBACK_MODE,
27
+ TRUSTED_LIVE_MODE,
28
+ )
29
+ from qplot.diagnostics import log_exception
30
+
31
+ from ._dataset_handle import (
32
+ DatasetHandle,
33
+ DatasetKey,
34
+ TraceKey,
35
+ bind_dataset_key_to_current_source,
36
+ close_dataset_connection,
37
+ dataset_key_matches_current_source,
38
+ )
39
+ from ._export_paths import choose_export_path, write_export_atomically
40
+ from ._plot2d_layers import _heatmap_layer_compatibility, _window_display_unit
41
+ from ._plot_refresh import plot_refresh_required
42
+ from ._refresh_interval import refresh_interval_value
43
+ from ._subplots.subplot1d import (
44
+ _subplot_axis_order,
45
+ _subplot_shared_parameter,
46
+ )
47
+ from .plot1d import plot1d
48
+ from .plot2d import plot2d
49
+
50
+
51
+ def _trusted_plot_action(method):
52
+ """Resume an explicit UI action only after broker metadata is available."""
53
+ call_signature = signature(method)
54
+
55
+ @wraps(method)
56
+ def invoke(self, *args, **kwargs):
57
+ arguments = call_signature.bind(self, *args, **kwargs).arguments
58
+ target = arguments.get("source_identity", arguments.get("guid"))
59
+ if self._defer_trusted_plot_action(
60
+ target, lambda: invoke(self, *args, **kwargs),
61
+ ):
62
+ return
63
+ return method(self, *args, **kwargs)
64
+ return invoke
65
+
66
+
67
+ def _plot_has_trace_window(target, candidate):
68
+ """Return whether a source window is already represented on a plot."""
69
+
70
+ checker = getattr(target, "_has_trace_window", None)
71
+ if callable(checker):
72
+ return checker(candidate)
73
+ return candidate.label in target.lines
74
+
75
+
76
+ def _combo_index_for_data(combo, value):
77
+ """Return the first combo row whose Python user data equals ``value``."""
78
+
79
+ count = getattr(combo, "count", None)
80
+ item_data = getattr(combo, "itemData", None)
81
+ if not callable(count) or not callable(item_data):
82
+ return -1
83
+
84
+ for index in range(count()):
85
+ if item_data(index) == value:
86
+ return index
87
+ return -1
88
+
89
+
90
+ def _exportable_measurement_params(dataset):
91
+ """Return dependent values, then standalone values, in run order."""
92
+ parameters = dataset.get_parameters()
93
+ setpoint_names = {
94
+ name
95
+ for param in parameters
96
+ for name in param.depends_on_
97
+ }
98
+ dependent = [param for param in parameters if param.depends_on_]
99
+ standalone = [
100
+ param for param in parameters
101
+ if not param.depends_on_ and param.name not in setpoint_names
102
+ ]
103
+ return dependent + standalone
104
+
105
+
106
+ def _csv_scalar_samples(values):
107
+ """Flatten a record, including arrays held in NumPy object cells."""
108
+ array = np.asarray(values)
109
+ if not array.dtype.hasobject:
110
+ return array.ravel()
111
+ samples = []
112
+ for value in array.ravel():
113
+ if isinstance(value, (np.ndarray, list, tuple)):
114
+ samples.extend(_csv_scalar_samples(value))
115
+ else:
116
+ samples.append(value)
117
+ # These cells can mix signed/unsigned integers (or integers and floats).
118
+ # Inferring a dtype again would undo extraction's precision protection.
119
+ return np.asarray(samples, dtype=object)
120
+
121
+
122
+ def _csv_scalar_columns(param_data):
123
+ """Align samples within each QCoDeS record before joining records."""
124
+ arrays = {name: np.asarray(values) for name, values in param_data.items()}
125
+ if not arrays:
126
+ return {}
127
+ # Regular, already aligned columns need no record-by-record expansion.
128
+ if (
129
+ len({values.shape for values in arrays.values()}) == 1
130
+ and all(not values.dtype.hasobject for values in arrays.values())
131
+ ):
132
+ return {name: values.ravel() for name, values in arrays.items()}
133
+
134
+ chunks = {name: [] for name in arrays}
135
+ for record in zip(*arrays.values(), strict=True):
136
+ samples = {
137
+ name: _csv_scalar_samples(values)
138
+ for name, values in zip(arrays, record, strict=True)
139
+ }
140
+ sample_count = max(values.size for values in samples.values())
141
+ for name, values in samples.items():
142
+ if values.size == sample_count:
143
+ chunks[name].append(values)
144
+ elif values.size == 1:
145
+ chunks[name].append(np.repeat(values, sample_count))
146
+ else:
147
+ raise ValueError(
148
+ f"Cannot align CSV record column {name}: "
149
+ f"{values.size} samples instead of {sample_count}."
150
+ )
151
+ return {
152
+ name: concatenate_record_samples(parts) if parts else np.array([], dtype=arrays[name].dtype)
153
+ for name, parts in chunks.items()
154
+ }
155
+
156
+
157
+ class PlotActionsMixin:
158
+ """
159
+ Plot launching, preview plotting, CSV export, and plot dataset tracking.
160
+
161
+ The mixin expects the owning window to provide MainWindow's widgets and
162
+ state, including windows, dataset_holder, config, threadPool, and status
163
+ helpers.
164
+ """
165
+
166
+ _selected_dataset_key: DatasetKey | None
167
+
168
+ def _defer_trusted_plot_action(self, target, resume):
169
+ if getattr(self, "_database_access_mode", None) != TRUSTED_LIVE_MODE:
170
+ return False
171
+ # Qt's str signal converts the run table's None argument to "".
172
+ # Both spellings select the current run, just as openPlot does.
173
+ if target == "":
174
+ target = None
175
+ selection = ((getattr(self, "selected_run_id", None),
176
+ getattr(self, "_selected_run_guid", None))
177
+ if target is None else None)
178
+ if target is None:
179
+ metadata = self._run_metadata_for_id(getattr(self, "selected_run_id", None))
180
+ target = metadata.get("guid") or getattr(self, "_selected_run_guid", None)
181
+ if not target:
182
+ return False
183
+ key = target if isinstance(target, DatasetKey) else self._current_dataset_key(target)
184
+ if (getattr(self, "ds", None) is not None
185
+ and getattr(self, "_selected_dataset_key", None) == key):
186
+ return False
187
+ if not self._generation_gate_allows_action(key.database_path, "opening plots"):
188
+ return True
189
+ try:
190
+ self._ensure_dataset_key_can_be_read(key)
191
+ except Exception as error:
192
+ self.show_error("Plot Load Failed", "Could not open the selected source.", str(error))
193
+ return True
194
+ ready = getattr(self, "_trusted_plot_ready", {})
195
+ if key in ready or self._dataset_handle_for_key(key) is not None:
196
+ return False
197
+ service = getattr(self, "_trusted_read_service", None)
198
+ if (service is None or service.closing or service.closed
199
+ or service.database_instance.identity != key.database_identity):
200
+ self.show_error("Plot Load Failed", "The trusted plot session is unavailable.",
201
+ "Reopen the source to start a new trusted session.")
202
+ return True
203
+ pending = getattr(self, "_trusted_plot_pending", None)
204
+ if pending is None:
205
+ pending = self._trusted_plot_pending = set()
206
+ if len(pending) >= 16:
207
+ self.show_status("Wait for the pending plots to open.", 5000)
208
+ return True
209
+ try:
210
+ request = service.submit_plot_dataset(key.guid)
211
+ except Exception as error:
212
+ self.show_error("Plot Load Failed", "Could not load plot metadata.", str(error))
213
+ return True
214
+ pending.add(request)
215
+ timer = QtCore.QTimer(self)
216
+ timer.setInterval(10)
217
+
218
+ def poll():
219
+ stale = (getattr(self, "_shutdown_started", False)
220
+ or service is not getattr(self, "_trusted_read_service", None)
221
+ or (selection is not None and selection != (
222
+ getattr(self, "selected_run_id", None),
223
+ getattr(self, "_selected_run_guid", None)))
224
+ or not self._dataset_key_is_current(key))
225
+ if stale:
226
+ request.cancel()
227
+ if not stale and not request.done:
228
+ return
229
+ timer.stop()
230
+ timer.deleteLater()
231
+ pending.discard(request)
232
+ if stale:
233
+ return
234
+ try:
235
+ dataset = request.wait(0)
236
+ ready = self.__dict__.setdefault("_trusted_plot_ready", {})
237
+ ready[key] = dataset
238
+ try:
239
+ resume()
240
+ finally:
241
+ ready.pop(key, None)
242
+ except Exception as error:
243
+ log_exception("Trusted plot launch failed", error, __name__)
244
+ self.show_error("Plot Load Failed", "Could not load the selected run.", str(error))
245
+
246
+ timer.timeout.connect(poll)
247
+ timer.start()
248
+ self.show_status("Loading plot metadata...", 0)
249
+ return True
250
+
251
+ def _generation_gate_allows_action(self, database_path=None, operation=None):
252
+ gate = getattr(self, "_database_generation_read_allowed", None)
253
+ if not callable(gate):
254
+ return True
255
+ return gate(
256
+ database_path,
257
+ operation=operation or "using run data",
258
+ )
259
+
260
+ @staticmethod
261
+ def _dataset_key_is_current(dataset_key):
262
+ return dataset_key_matches_current_source(
263
+ dataset_key,
264
+ allow_new_sidecars=True,
265
+ )
266
+
267
+
268
+ @staticmethod
269
+ def _bound_plot_dataset_key_is_current(dataset_key):
270
+ """Require the exact source identities retained by a plot key."""
271
+
272
+ return dataset_key_matches_current_source(dataset_key)
273
+
274
+
275
+ def _request_plot_source_recovery(self, dataset_key):
276
+ """Retire a plot source without trusting the controller baseline."""
277
+
278
+ replacement_handler = getattr(
279
+ self,
280
+ "_handle_plot_database_replaced",
281
+ None,
282
+ )
283
+ if callable(replacement_handler):
284
+ replacement_handler(dataset_key.database_path)
285
+ return
286
+
287
+ reload_replaced = getattr(self, "_reload_replaced_database", None)
288
+ if callable(reload_replaced):
289
+ reload_replaced(dataset_key.database_path)
290
+
291
+
292
+ def _ensure_bound_plot_dataset_key_can_be_read(self, dataset_key):
293
+ """Reject a retained plot key unless every accepted identity matches."""
294
+
295
+ if not PlotActionsMixin._generation_gate_allows_action(
296
+ self,
297
+ dataset_key.database_path,
298
+ "using run data",
299
+ ):
300
+ raise RuntimeError(
301
+ "Test-database generation owns this database path until its "
302
+ "released view has recovered."
303
+ )
304
+ if PlotActionsMixin._bound_plot_dataset_key_is_current(dataset_key):
305
+ return
306
+
307
+ PlotActionsMixin._request_plot_source_recovery(self, dataset_key)
308
+ raise RuntimeError(
309
+ "The database or its SQLite sidecars were replaced before the "
310
+ "plot could use its dataset."
311
+ )
312
+
313
+
314
+ def _rekey_dataset_handle_for_bound_plot(self, prior_key, bound_key):
315
+ """Replace an equal transient dict key with its exact plot binding."""
316
+
317
+ for held_key in list(self.dataset_holder):
318
+ if held_key != prior_key:
319
+ continue
320
+ handle = self.dataset_holder.pop(held_key)
321
+ held_sidecars = handle.sidecar_identities
322
+ if (
323
+ held_sidecars is not None
324
+ and not held_sidecars.issubset(bound_key.sidecar_identities)
325
+ ):
326
+ # This handle came from a different exact sidecar lineage even
327
+ # though DatasetKey deliberately excludes sidecars from hash
328
+ # equality. Do not let it satisfy the promoted plot key.
329
+ self.dataset_holder[held_key] = handle
330
+ return False
331
+ handle.sidecar_identities = bound_key.sidecar_identities
332
+ self.dataset_holder[bound_key] = handle
333
+ return True
334
+ return False
335
+
336
+
337
+ def _bind_plot_dataset_key(self, dataset_key):
338
+ """Finish first-sidecar binding before retaining a plot source."""
339
+
340
+ bound_key = bind_dataset_key_to_current_source(dataset_key)
341
+ if bound_key is None:
342
+ PlotActionsMixin._request_plot_source_recovery(self, dataset_key)
343
+ return None
344
+ PlotActionsMixin._rekey_dataset_handle_for_bound_plot(
345
+ self,
346
+ dataset_key,
347
+ bound_key,
348
+ )
349
+ try:
350
+ PlotActionsMixin._ensure_bound_plot_dataset_key_can_be_read(
351
+ self,
352
+ bound_key,
353
+ )
354
+ except RuntimeError:
355
+ return None
356
+ return bound_key
357
+
358
+
359
+ def _dataset_key_targets_loaded_database(self, dataset_key):
360
+ """Return whether a key names the database currently shown in the UI."""
361
+
362
+ file_textbox = getattr(self, "fileTextbox", None)
363
+ current_path = file_textbox.text() if file_textbox is not None else ""
364
+ return bool(
365
+ current_path
366
+ and logical_database_path(current_path) == dataset_key.database_path
367
+ )
368
+
369
+
370
+ def _ensure_dataset_key_can_be_read(self, dataset_key):
371
+ """Reject a plot read until an atomically replaced source is reloaded."""
372
+
373
+ if not PlotActionsMixin._generation_gate_allows_action(
374
+ self,
375
+ dataset_key.database_path,
376
+ "using run data",
377
+ ):
378
+ raise RuntimeError(
379
+ "Test-database generation owns this database path until its "
380
+ "released view has recovered."
381
+ )
382
+
383
+ if self._dataset_key_targets_loaded_database(dataset_key):
384
+ reload_if_changed = getattr(
385
+ self,
386
+ "_reload_if_database_instance_changed",
387
+ None,
388
+ )
389
+ if callable(reload_if_changed) and reload_if_changed(
390
+ dataset_key.database_path,
391
+ ):
392
+ raise RuntimeError(
393
+ "The database was replaced and is being reloaded before "
394
+ "its dataset can be opened."
395
+ )
396
+
397
+ if not self._dataset_key_is_current(dataset_key):
398
+ raise RuntimeError(
399
+ "The database was replaced before its dataset could be loaded."
400
+ )
401
+
402
+
403
+ def _discard_dataset_handle(self, dataset_key, handle):
404
+ """Remove and close one handle that cannot represent its key safely."""
405
+ if self.dataset_holder.get(dataset_key) is handle:
406
+ self.dataset_holder.pop(dataset_key, None)
407
+ if handle.dataset is getattr(self, "ds", None):
408
+ self.ds = None
409
+ self._selected_dataset_key = None
410
+ self.selected_run_id = None
411
+ try:
412
+ handle.close()
413
+ except Exception as err:
414
+ log_exception("Stale plot dataset cleanup failed", err, __name__)
415
+
416
+
417
+ def _dataset_handle_for_key(self, dataset_key):
418
+ """Return only a handle opened for the key's current file instance."""
419
+ key_is_current = self._dataset_key_is_current(dataset_key)
420
+ if not key_is_current:
421
+ stale_handle = self.dataset_holder.get(dataset_key)
422
+ if stale_handle is not None:
423
+ self._discard_dataset_handle(dataset_key, stale_handle)
424
+ return None
425
+
426
+ for cached_key in list(self.dataset_holder):
427
+ if (
428
+ cached_key != dataset_key
429
+ and cached_key.database_path == dataset_key.database_path
430
+ and cached_key.guid == dataset_key.guid
431
+ ):
432
+ superseded = self.dataset_holder.get(cached_key)
433
+ if superseded is not None:
434
+ self._discard_dataset_handle(cached_key, superseded)
435
+
436
+ handle = self.dataset_holder.get(dataset_key)
437
+ if handle is None:
438
+ return None
439
+ if (
440
+ handle.closed
441
+ or (
442
+ handle.database_identity is not None
443
+ and handle.database_identity != dataset_key.database_identity
444
+ )
445
+ or (
446
+ handle.sidecar_identities is not None
447
+ and handle.sidecar_identities
448
+ != dataset_key.sidecar_identities
449
+ )
450
+ ):
451
+ self._discard_dataset_handle(dataset_key, handle)
452
+ return None
453
+ if handle.database_identity is None:
454
+ handle.database_identity = dataset_key.database_identity
455
+ if handle.sidecar_identities is None:
456
+ handle.sidecar_identities = dataset_key.sidecar_identities
457
+ return handle
458
+
459
+ def _dataset_is_held(self, dataset):
460
+ return dataset is not None and any(
461
+ handle.dataset is dataset
462
+ for handle in self.dataset_holder.values()
463
+ )
464
+
465
+
466
+ def _close_dataset_if_unowned(self, dataset, context="Dataset cleanup failed"):
467
+ if dataset is None or dataset is getattr(self, "ds", None):
468
+ return False
469
+ if self._dataset_is_held(dataset):
470
+ return False
471
+ try:
472
+ return close_dataset_connection(dataset)
473
+ except Exception as err:
474
+ log_exception(context, err, __name__)
475
+ return False
476
+
477
+
478
+ def _replace_selected_dataset(self, dataset, dataset_key):
479
+ previous = getattr(self, "ds", None)
480
+ self.ds = dataset
481
+ self._selected_dataset_key = dataset_key
482
+ self._selected_run_guid = dataset_key.guid
483
+ if previous is not dataset:
484
+ self._close_dataset_if_unowned(
485
+ previous,
486
+ context="Previous selected dataset cleanup failed",
487
+ )
488
+
489
+
490
+ def _release_selected_dataset(self):
491
+ previous = getattr(self, "ds", None)
492
+ self.ds = None
493
+ self._selected_dataset_key = None
494
+ return self._close_dataset_if_unowned(
495
+ previous,
496
+ context="Selected dataset cleanup failed",
497
+ )
498
+
499
+
500
+ def _release_trusted_action_dataset(self, dataset):
501
+ """End an explicit trusted-mode DataSet's GUI-thread ownership.
502
+
503
+ Stage 4 permits legacy DataSet materialisation only at an explicit plot
504
+ or export boundary. A plot window retains its DataSet through
505
+ ``dataset_holder``; an unused or failed action must close its unowned
506
+ handle here so a later ordinary row selection has no SQLite cleanup to
507
+ perform.
508
+ """
509
+ if (
510
+ dataset is None
511
+ or getattr(self, "_database_access_mode", None)
512
+ != TRUSTED_LIVE_MODE
513
+ ):
514
+ return False
515
+ if getattr(self, "ds", None) is dataset:
516
+ self.ds = None
517
+ self._selected_dataset_key = None
518
+ return self._close_dataset_if_unowned(
519
+ dataset,
520
+ context="Explicit plot dataset cleanup failed",
521
+ )
522
+
523
+
524
+ def _clear_selected_run_state(self):
525
+ """Clear run actions and UI after a selected dataset cannot be loaded."""
526
+ cancel_detail = getattr(self, "_cancel_selected_run_detail", None)
527
+ if callable(cancel_detail):
528
+ cancel_detail()
529
+ self._release_selected_dataset()
530
+ self.selected_run_id = None
531
+ self._selected_run_guid = None
532
+
533
+ run_id_box = getattr(self, "run_idBox", None)
534
+ if run_id_box is not None:
535
+ signals_blocked = run_id_box.blockSignals(True)
536
+ try:
537
+ run_id_box.setText("")
538
+ finally:
539
+ run_id_box.blockSignals(signals_blocked)
540
+
541
+ run_list = getattr(self, "RunList", None)
542
+ if run_list is not None:
543
+ signals_blocked = run_list.blockSignals(True)
544
+ try:
545
+ run_list.clearSelection()
546
+ set_current_item = getattr(run_list, "setCurrentItem", None)
547
+ if callable(set_current_item):
548
+ set_current_item(None)
549
+ finally:
550
+ run_list.blockSignals(signals_blocked)
551
+
552
+ info_box = getattr(self, "infoBox", None)
553
+ if info_box is not None:
554
+ info_box.clear()
555
+ derived_bridge = getattr(self, "_trusted_derived_bridge", None)
556
+ if derived_bridge is not None:
557
+ derived_bridge.select_run(None)
558
+
559
+
560
+ @QtCore.pyqtSlot()
561
+ def clear_non_single_run_selection(self):
562
+ """Drop the single-run action target for an empty or multi-selection."""
563
+ self._clear_selected_run_state()
564
+
565
+
566
+ def _evict_dataset_handle(self, dataset_key):
567
+ handle = self.dataset_holder.pop(dataset_key, None)
568
+ if handle is None:
569
+ return False
570
+
571
+ handle.cancel_delete_timer()
572
+ if handle.dataset is getattr(self, "ds", None):
573
+ return True
574
+ try:
575
+ handle.close()
576
+ except Exception as err:
577
+ log_exception("Plot dataset cleanup failed", err, __name__)
578
+ return True
579
+
580
+
581
+ def _close_all_dataset_handles(self):
582
+ handles = list(self.dataset_holder.values())
583
+ self.dataset_holder.clear()
584
+ for handle in handles:
585
+ try:
586
+ handle.close()
587
+ except Exception as err:
588
+ log_exception("Plot dataset cleanup failed", err, __name__)
589
+
590
+
591
+ @staticmethod
592
+ def _dataset_key_matches_replaced_instance(dataset_key, database_source):
593
+ """Match a key against saved old-instance information."""
594
+
595
+ if dataset_key is None:
596
+ return False
597
+ if isinstance(database_source, DatasetKey):
598
+ logical_path = database_source.database_path
599
+ resolved_path = database_source.resolved_database_path
600
+ identity = database_source.database_identity
601
+ elif isinstance(database_source, DatabaseInstance):
602
+ logical_path = database_source.logical_path
603
+ resolved_path = database_source.resolved_path
604
+ identity = database_source.identity
605
+ else:
606
+ return dataset_key.database_path == logical_database_path(database_source)
607
+
608
+ if dataset_key.database_path != logical_path:
609
+ return False
610
+ if identity is not None and dataset_key.database_identity is not None:
611
+ return dataset_key.database_identity == identity
612
+ return dataset_key.resolved_database_path == resolved_path
613
+
614
+
615
+ def _detach_replaced_secondary_traces(self, database_source):
616
+ """Remove secondary plots whose source belongs to a replaced database.
617
+
618
+ A merged source can be closed and therefore absent from ``windows``
619
+ while its layer still owns a dataset handle and keeps its monitor
620
+ alive. Retire those consumers before evicting the source handle so a
621
+ plot backed by another database cannot retain or refresh stale data.
622
+ """
623
+ def source_matches(dataset_key):
624
+ return self._dataset_key_matches_replaced_instance(
625
+ dataset_key,
626
+ database_source,
627
+ )
628
+
629
+ for host in list(getattr(self, "windows", [])):
630
+ host_key = getattr(host, "_dataset_key", None)
631
+ # Hosts from the replaced database are closed below, and their
632
+ # close event already detaches every secondary line.
633
+ if (
634
+ host_key is not None
635
+ and source_matches(host_key)
636
+ ):
637
+ continue
638
+
639
+ for registry_name, main_item_name, remove_name in (
640
+ ("lines", "line", "remove_line"),
641
+ ("heatmaps", None, "remove_heatmap"),
642
+ ):
643
+ registry = getattr(host, registry_name, None)
644
+ remover = getattr(host, remove_name, None)
645
+ if not isinstance(registry, dict) or not callable(remover):
646
+ continue
647
+
648
+ main_item = host if main_item_name is None else getattr(
649
+ host,
650
+ main_item_name,
651
+ None,
652
+ )
653
+ stale_layers = [
654
+ (trace_key, layer)
655
+ for trace_key, layer in list(registry.items())
656
+ if (
657
+ layer is not None
658
+ and layer is not main_item
659
+ and getattr(
660
+ getattr(layer, "from_win", None),
661
+ "_dataset_key",
662
+ None,
663
+ ) is not None
664
+ and source_matches(layer.from_win._dataset_key)
665
+ )
666
+ ]
667
+ for trace_key, layer in stale_layers:
668
+ source = layer.from_win
669
+ try:
670
+ remover(
671
+ getattr(source, "label", ""),
672
+ trace_key=trace_key,
673
+ )
674
+ except Exception as err:
675
+ log_exception(
676
+ "Replaced database secondary-plot cleanup failed",
677
+ err,
678
+ __name__,
679
+ )
680
+
681
+
682
+ def _invalidate_database_runtime_state(self, database_source):
683
+ """Close datasets and plot windows backed by a replaced database."""
684
+ self._detach_replaced_secondary_traces(database_source)
685
+ selected_key = getattr(self, "_selected_dataset_key", None)
686
+ if (
687
+ selected_key is not None
688
+ and self._dataset_key_matches_replaced_instance(
689
+ selected_key,
690
+ database_source,
691
+ )
692
+ ):
693
+ self._release_selected_dataset()
694
+ self.selected_run_id = None
695
+
696
+ for win in list(getattr(self, "windows", [])):
697
+ dataset_key = getattr(win, "_dataset_key", None)
698
+ if (
699
+ dataset_key is None
700
+ or not self._dataset_key_matches_replaced_instance(
701
+ dataset_key,
702
+ database_source,
703
+ )
704
+ ):
705
+ continue
706
+ try:
707
+ win.close()
708
+ except Exception as err:
709
+ log_exception("Replaced database plot cleanup failed", err, __name__)
710
+
711
+ for dataset_key in list(self.dataset_holder):
712
+ if self._dataset_key_matches_replaced_instance(
713
+ dataset_key,
714
+ database_source,
715
+ ):
716
+ self._evict_dataset_handle(dataset_key)
717
+
718
+
719
+ def _handle_plot_database_replaced(self, database_path, source_window=None):
720
+ """Reload the current source, or retire a stale plot from another DB."""
721
+
722
+ file_textbox = getattr(self, "fileTextbox", None)
723
+ if file_textbox is None:
724
+ # Lightweight PlotActionsMixin hosts do not own MainWindow's path
725
+ # field. Let their normal instance-change hook perform recovery
726
+ # instead of entering controller-specific invalidation code.
727
+ reload_if_changed = getattr(
728
+ self,
729
+ "_reload_if_database_instance_changed",
730
+ None,
731
+ )
732
+ if callable(reload_if_changed):
733
+ return reload_if_changed(database_path)
734
+ return False
735
+
736
+ current_path = file_textbox.text()
737
+ source_key = getattr(source_window, "_dataset_key", None)
738
+ logical_source_path = (
739
+ source_key.database_path
740
+ if source_key is not None
741
+ else logical_database_path(database_path)
742
+ )
743
+ if (
744
+ current_path
745
+ and logical_database_path(current_path) == logical_source_path
746
+ ):
747
+ return self._reload_replaced_database(current_path)
748
+
749
+ # qPlot may retain plots from a database that is no longer selected in
750
+ # MainWindow. A replacement there must not switch the main UI back to
751
+ # that path; discard only those stale runtime objects instead.
752
+ self._invalidate_database_runtime_state(source_key or database_path)
753
+ return False
754
+
755
+ @QtCore.pyqtSlot(object)
756
+ def onClose(self, win):
757
+ """
758
+ Event handler for closing a Plot window.
759
+
760
+ """
761
+ self.windows.remove(win)
762
+ self.remove_ds_at(win._dataset_key)
763
+ self.post_admin()
764
+ self.show_status(f"Closed {win.label}", 3000)
765
+ del win
766
+
767
+
768
+ def _publish_displayed_plot_preview(self, plot, worker):
769
+ """Show one displayed measurement while run previews are queued."""
770
+
771
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
772
+ if preview is None or getattr(preview, "_shutting_down", False):
773
+ return
774
+ key = getattr(plot, "_dataset_key", None)
775
+ if not isinstance(key, DatasetKey) or key.guid not in preview.run_metadata:
776
+ return
777
+ if getattr(worker, "dataset_completed", None) is not True:
778
+ # For an active run, reuse only the same row prefix currently
779
+ # advertised by the run list. Its next metadata update will
780
+ # discard the provisional image and request fresh derived work.
781
+ try:
782
+ displayed_count = int(worker.dataset_length_at_start)
783
+ listed_count = int(
784
+ preview.run_metadata[key.guid]["result_count"]
785
+ )
786
+ except (AttributeError, KeyError, TypeError, ValueError):
787
+ return
788
+ if displayed_count != listed_count:
789
+ return
790
+ if not dataset_key_matches_current_source(key):
791
+ return
792
+ if getattr(preview, "_trusted_derived_mode", False):
793
+ bridge = getattr(self, "_trusted_derived_bridge", None)
794
+ instance = getattr(bridge, "_database_instance", None)
795
+ if not getattr(bridge, "_accepting_publications", False):
796
+ return
797
+ else:
798
+ instance = getattr(preview, "database_instance", None)
799
+ if (
800
+ instance is None
801
+ or instance.logical_path != key.database_path
802
+ or instance.resolved_path != key.resolved_database_path
803
+ or instance.identity != key.database_identity
804
+ ):
805
+ return
806
+ preview_generation = preview.generation
807
+
808
+ from ._widgets.preview import render_displayed_plot_preview
809
+
810
+ plot_preview = render_displayed_plot_preview(
811
+ worker,
812
+ getattr(plot, "param", None),
813
+ preview.preview_size,
814
+ )
815
+ if (
816
+ plot_preview is None
817
+ or preview.generation != preview_generation
818
+ or not dataset_key_matches_current_source(key)
819
+ ):
820
+ return
821
+ if getattr(preview, "_trusted_derived_mode", False):
822
+ bridge = getattr(self, "_trusted_derived_bridge", None)
823
+ if (
824
+ not getattr(bridge, "_accepting_publications", False)
825
+ or getattr(bridge, "_database_instance", None) != instance
826
+ ):
827
+ return
828
+ elif preview.database_instance != instance:
829
+ return
830
+ preview.publish_plot_preview(key.guid, plot_preview)
831
+
832
+ @QtCore.pyqtSlot(object, object, tuple)
833
+ def openWin(
834
+ self,
835
+ widget,
836
+ key_or_ds,
837
+ *args,
838
+ show=True,
839
+ dataset_key: DatasetKey | None = None,
840
+ **kargs,
841
+ ):
842
+ """
843
+ Handles opening Plot window, widget.
844
+
845
+ """
846
+ if len(args) == 1 and isinstance(args[0], (tuple, list)):
847
+ args = tuple(args[0])
848
+
849
+ if isinstance(key_or_ds, DatasetKey):
850
+ ds = None
851
+ if dataset_key is not None and dataset_key != key_or_ds:
852
+ raise ValueError("Conflicting dataset keys supplied while opening a plot.")
853
+ dataset_key = key_or_ds
854
+ else:
855
+ ds = key_or_ds
856
+ if dataset_key is None:
857
+ dataset_key = self._current_dataset_key(ds.guid)
858
+
859
+ assert dataset_key is not None
860
+ self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
861
+ shared_handle = self._dataset_handle_for_key(dataset_key)
862
+ loaded_for_construction = shared_handle is None and ds is None
863
+ if shared_handle is not None:
864
+ construction_ds = shared_handle.dataset
865
+ elif loaded_for_construction:
866
+ construction_ds = self._load_dataset(dataset_key)
867
+ else:
868
+ construction_ds = ds
869
+
870
+ assert construction_ds.guid == dataset_key.guid
871
+ construction_holder = {
872
+ held_key: DatasetHandle(
873
+ dataset=handle.dataset,
874
+ users=handle.users,
875
+ database_identity=handle.database_identity,
876
+ sidecar_identities=handle.sidecar_identities,
877
+ )
878
+ for held_key, handle in self.dataset_holder.items()
879
+ }
880
+ construction_holder[dataset_key] = DatasetHandle(
881
+ dataset=construction_ds,
882
+ database_identity=dataset_key.database_identity,
883
+ sidecar_identities=dataset_key.sidecar_identities,
884
+ )
885
+
886
+ try:
887
+ win = widget(
888
+ dataset_key,
889
+ *args,
890
+ self.config,
891
+ self.threadPool,
892
+ construction_holder,
893
+ show=show,
894
+ **kargs,
895
+ )
896
+ window_type = getattr(win, "operation_kind", None)
897
+ if window_type is None:
898
+ legacy_window_type = win.__class__.__name__
899
+ if legacy_window_type in {"plot1d", "plot2d", "sweeper"}:
900
+ window_type = legacy_window_type
901
+ if window_type not in {"plot1d", "plot2d", "sweeper"}:
902
+ raise TypeError(f"Unknown window of type: {win.__class__.__name__}")
903
+ self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
904
+ except Exception:
905
+ if "win" in locals():
906
+ worker = getattr(win, "worker", None)
907
+ cancel = getattr(worker, "cancel", None)
908
+ if callable(cancel):
909
+ cancel()
910
+ close = getattr(win, "close", None)
911
+ if callable(close):
912
+ close()
913
+ if loaded_for_construction:
914
+ try:
915
+ close_dataset_connection(construction_ds)
916
+ except Exception as err:
917
+ log_exception("Unused plot dataset cleanup failed", err, __name__)
918
+ raise
919
+
920
+ win._dataset_holder = self.dataset_holder
921
+ self.add_ds_at(dataset_key, ds=construction_ds)
922
+
923
+ self.windows.append(win)
924
+ win._plot_preview_sink = self._publish_displayed_plot_preview
925
+
926
+ win.closed.connect(self.onClose)
927
+ database_replaced = getattr(win, "database_replaced", None)
928
+ connect_replacement = getattr(database_replaced, "connect", None)
929
+ if callable(connect_replacement):
930
+ connect_replacement(
931
+ lambda database_path, source_window=win: (
932
+ self._handle_plot_database_replaced(
933
+ database_path,
934
+ source_window,
935
+ )
936
+ )
937
+ )
938
+ win.make_ds.connect(self.add_ds_at)
939
+ win.previewTraceDropRequested.connect(self.add_dropped_preview_to_plot)
940
+ if window_type == "plot1d":
941
+ win.get_mergables.connect(lambda: self.get_1d_wins(win))
942
+ win.remove_dataset.connect(self.remove_ds_at)
943
+ win._trace_candidate_provider = (
944
+ lambda target_window=win: self._trace_candidates_for_plot(target_window)
945
+ )
946
+ win._trace_add_request = (
947
+ lambda source_key, parameter_name, target_window=win:
948
+ self.add_trace_to_plot(target_window, source_key, parameter_name)
949
+ )
950
+
951
+ elif window_type == "plot2d":
952
+ win.open_subplot.connect(self.openWin)
953
+ win.close_sweeps_requested.connect(self.close_sweeps_from_plot)
954
+ win._heatmap_candidate_provider = (
955
+ lambda target_window=win: self._heatmap_candidates_for_plot(
956
+ target_window
957
+ )
958
+ )
959
+ win._heatmap_add_request = (
960
+ lambda source_key, parameter_name, target_window=win:
961
+ self.add_heatmap_to_plot(
962
+ target_window,
963
+ source_key,
964
+ parameter_name,
965
+ )
966
+ )
967
+ remove_dataset = getattr(win, "remove_dataset", None)
968
+ if remove_dataset is not None:
969
+ remove_dataset.connect(self.remove_ds_at)
970
+
971
+ elif window_type == "sweeper":
972
+ win.merge_compatibility_changed.connect(self.post_admin)
973
+ for item in self.windows:
974
+ if item.ds == win.ds and item.param == win.param and isinstance(item, plot2d):
975
+ win.sweep_moved.connect(item.update_sweep_line)
976
+ win.remove_sweep.connect(item.remove_sweep)
977
+ item.sweep_moved.connect(win.update_sweep_line)
978
+ break
979
+ self.post_admin()
980
+
981
+ if show:
982
+ win.update_theme(self.config)
983
+
984
+ win.move(self._next_plot_x, self._next_plot_y)
985
+ win.show()
986
+
987
+ tolerance = 30
988
+ plot_width = win.width()
989
+ plot_height = win.height()
990
+ self._next_plot_x += plot_width
991
+ if self._next_plot_x + plot_width - tolerance > self.screenrect.right():
992
+ self._next_plot_x = self.screenrect.left()
993
+ self._next_plot_y += plot_height
994
+
995
+ if self._next_plot_y + plot_height - tolerance > self.screenrect.bottom():
996
+ self._next_plot_y = self.screenrect.top()
997
+
998
+
999
+ @QtCore.pyqtSlot(object, object)
1000
+ def close_sweeps_from_plot(self, source_win, sweep_ids):
1001
+ """
1002
+ Close cut windows associated with a heatmap cut-line action.
1003
+
1004
+ """
1005
+ target_ids = set(sweep_ids)
1006
+ if not target_ids:
1007
+ return
1008
+
1009
+ for item in list(self.windows):
1010
+ window_type = getattr(item, "operation_kind", item.__class__.__name__)
1011
+ if window_type != "sweeper":
1012
+ continue
1013
+
1014
+ try:
1015
+ same_source = item.ds == source_win.ds and item.param == source_win.param
1016
+ should_close = same_source and item.sweep_id in target_ids
1017
+ except (AttributeError, RuntimeError):
1018
+ continue
1019
+
1020
+ if should_close:
1021
+ item.close()
1022
+
1023
+
1024
+ @QtCore.pyqtSlot(str)
1025
+ def updateSelected(self, guid):
1026
+ """
1027
+ Dispatch ordinary selection to one accepted, DataSet-free reader.
1028
+
1029
+ """
1030
+ if not PlotActionsMixin._generation_gate_allows_action(
1031
+ self,
1032
+ operation="selecting a run",
1033
+ ):
1034
+ return
1035
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
1036
+ update_trusted = getattr(self, "_update_trusted_selected_run", None)
1037
+ if callable(update_trusted):
1038
+ update_trusted(guid)
1039
+ else:
1040
+ self.show_error(
1041
+ "Trusted Selection Unavailable",
1042
+ "The accepted trusted live-reader selection path is unavailable.",
1043
+ "Reload the database to start a new trusted session. qPlot did "
1044
+ "not fall back to loading a snapshot DataSet.",
1045
+ )
1046
+ return
1047
+ if (
1048
+ getattr(self, "_database_access_mode", None)
1049
+ == SNAPSHOT_FALLBACK_MODE
1050
+ ):
1051
+ update_snapshot = getattr(self, "_update_snapshot_selected_run", None)
1052
+ if callable(update_snapshot):
1053
+ update_snapshot(guid)
1054
+ else:
1055
+ self.show_error(
1056
+ "Snapshot Selection Unavailable",
1057
+ "The accepted snapshot selection path is unavailable.",
1058
+ "Reload the database to start a new snapshot session. qPlot "
1059
+ "did not load a QCoDeS DataSet for ordinary selection.",
1060
+ )
1061
+ return
1062
+ self.show_error(
1063
+ "Selection Unavailable",
1064
+ "No accepted database reader is bound to the current selection.",
1065
+ "Reload the database to establish a trusted live or snapshot "
1066
+ "session. qPlot did not load a QCoDeS DataSet.",
1067
+ )
1068
+
1069
+
1070
+ def _run_metadata_for_guid(self, guid):
1071
+ run_list = getattr(self, "RunList", None)
1072
+ if run_list is None or not hasattr(run_list, "_item_for_guid"):
1073
+ return {}
1074
+
1075
+ item = run_list._item_for_guid(guid)
1076
+ if item is None:
1077
+ return {}
1078
+ return dict(getattr(item, "run_metadata", {}) or {})
1079
+
1080
+
1081
+ def _run_point_count(self, metadata):
1082
+ for key in ("result_count", "setpoint_count", "expected_results"):
1083
+ value = metadata.get(key)
1084
+ if value is None:
1085
+ continue
1086
+ try:
1087
+ return int(value)
1088
+ except (TypeError, ValueError):
1089
+ continue
1090
+ return None
1091
+
1092
+
1093
+ @QtCore.pyqtSlot()
1094
+ @_trusted_plot_action
1095
+ def openRun(self):
1096
+ """
1097
+ Plots the requested measurement for the requested run.
1098
+
1099
+ """
1100
+ if not PlotActionsMixin._generation_gate_allows_action(
1101
+ self,
1102
+ operation="opening a plot",
1103
+ ):
1104
+ return
1105
+ ds = self._dataset_for_plot_target()
1106
+ if ds is None:
1107
+ return
1108
+
1109
+ try:
1110
+ params = self._selected_measurement_params(ds)
1111
+ except Exception as error:
1112
+ log_exception("Run parameter enumeration failed", error, __name__)
1113
+ self._close_dataset_if_unowned(ds)
1114
+ self.show_error(
1115
+ "Run Load Failed",
1116
+ "Could not read the selected run's measurements.",
1117
+ str(error),
1118
+ )
1119
+ return
1120
+ if params is None:
1121
+ self._close_dataset_if_unowned(ds)
1122
+ return
1123
+
1124
+ dataset_key = self._accepted_plot_target_key(ds)
1125
+ if dataset_key is None:
1126
+ self._close_dataset_if_unowned(ds)
1127
+ return
1128
+ self._replace_selected_dataset(ds, dataset_key)
1129
+ try:
1130
+ self.openPlot(params=params)
1131
+ finally:
1132
+ self._release_trusted_action_dataset(ds)
1133
+
1134
+
1135
+ @QtCore.pyqtSlot()
1136
+ @_trusted_plot_action
1137
+ def open_selected_run_all(self):
1138
+ """
1139
+ Opens every plottable measurement in the currently selected table row.
1140
+
1141
+ """
1142
+ if not PlotActionsMixin._generation_gate_allows_action(
1143
+ self,
1144
+ operation="opening plots",
1145
+ ):
1146
+ return
1147
+ guid = getattr(self, "_selected_run_guid", None)
1148
+ if self.ds is None and not guid:
1149
+ self.show_status("Select a run before plotting all measurements.", 5000)
1150
+ return
1151
+
1152
+ self.openPlot(guid)
1153
+
1154
+
1155
+ @QtCore.pyqtSlot()
1156
+ def exportRunCsv(self):
1157
+ """
1158
+ Exports the requested run and measurement data to a CSV file.
1159
+
1160
+ """
1161
+ if not PlotActionsMixin._generation_gate_allows_action(
1162
+ self,
1163
+ operation="exporting run data",
1164
+ ):
1165
+ return
1166
+ request = self._run_csv_export_request()
1167
+ if request is None:
1168
+ return
1169
+ dataset_key, parameter_names, run_id = request
1170
+ self._export_measurement_csv(
1171
+ dataset_key,
1172
+ parameter_names,
1173
+ run_id=run_id,
1174
+ )
1175
+
1176
+
1177
+ @QtCore.pyqtSlot(str)
1178
+ def export_preview_csv(self, parameter_name):
1179
+ """
1180
+ Exports the measurement represented by a selected-run preview image.
1181
+
1182
+ """
1183
+ if not PlotActionsMixin._generation_gate_allows_action(
1184
+ self,
1185
+ operation="exporting preview data",
1186
+ ):
1187
+ return
1188
+ guid = getattr(self, "_selected_run_guid", None)
1189
+ if self.ds is None and not guid:
1190
+ self.show_status("Select a run before exporting a preview.", 5000)
1191
+ return
1192
+
1193
+ dataset_key = getattr(self, "_selected_dataset_key", None)
1194
+ if dataset_key is None:
1195
+ dataset_key = self._current_dataset_key(guid or self.ds.guid)
1196
+ self._export_preview_csv(dataset_key, parameter_name)
1197
+
1198
+
1199
+ @QtCore.pyqtSlot(str, str)
1200
+ def export_run_preview_csv(self, guid, parameter_name):
1201
+ """
1202
+ Exports the measurement represented by a run-table preview image.
1203
+
1204
+ """
1205
+ if not PlotActionsMixin._generation_gate_allows_action(
1206
+ self,
1207
+ operation="exporting preview data",
1208
+ ):
1209
+ return
1210
+ if not guid:
1211
+ self.show_status("Select a run before exporting a preview.", 5000)
1212
+ return
1213
+
1214
+ self._export_preview_csv(self._current_dataset_key(guid), parameter_name)
1215
+
1216
+
1217
+ def _export_preview_csv(self, dataset_key, parameter_name):
1218
+ """Export one preview through a fresh, action-owned dataset view."""
1219
+ default_name = self._default_preview_export_filename(
1220
+ dataset_key,
1221
+ parameter_name,
1222
+ )
1223
+ filename = self._choose_csv_export_filename(default_name)
1224
+ if filename is None:
1225
+ return
1226
+
1227
+ dataset = None
1228
+ original_dataset_key = dataset_key
1229
+ try:
1230
+ # Preview and plot datasets intentionally remain frozen at their
1231
+ # original read-only WAL snapshots. Export needs the newest
1232
+ # committed rows, so choose the destination first and only then
1233
+ # bypass DatasetHandle caching. No action-owned snapshot waits
1234
+ # behind the modal save dialog.
1235
+ try:
1236
+ dataset_key = self._bind_one_shot_dataset_key(dataset_key)
1237
+ dataset = self._load_run_csv_dataset(dataset_key)
1238
+ param = self._measurement_param_by_name(dataset, parameter_name)
1239
+ except DatabaseInstanceChangedError as err:
1240
+ self._handle_run_csv_source_replaced(
1241
+ dataset_key or original_dataset_key,
1242
+ )
1243
+ log_exception("Preview CSV run load failed", err, __name__)
1244
+ self.show_error(
1245
+ "Run Load Failed",
1246
+ f"Could not load run with GUID {original_dataset_key.guid}.",
1247
+ str(err),
1248
+ )
1249
+ return
1250
+ except Exception as err:
1251
+ log_exception("Preview CSV run load failed", err, __name__)
1252
+ self.show_error(
1253
+ "Run Load Failed",
1254
+ f"Could not load run with GUID {dataset_key.guid}.",
1255
+ str(err),
1256
+ )
1257
+ return
1258
+
1259
+ if param is None:
1260
+ self.show_status(
1261
+ f"No preview export found for {parameter_name}.",
1262
+ 5000,
1263
+ )
1264
+ return
1265
+
1266
+ try:
1267
+ frame = self._measurement_dataframe(dataset, [param])
1268
+ # The read-only connection intentionally keeps its original
1269
+ # SQLite snapshot readable if the path is replaced. Reject
1270
+ # that obsolete frame before it reaches the destination.
1271
+ self._require_run_csv_source_current(dataset_key)
1272
+ self._publish_preview_csv(frame, filename, dataset_key)
1273
+ except DatabaseInstanceChangedError as err:
1274
+ self._handle_run_csv_source_replaced(dataset_key)
1275
+ log_exception("Preview CSV export failed", err, __name__)
1276
+ self.show_error(
1277
+ "CSV Export Failed",
1278
+ "Could not export the selected measurement data.",
1279
+ str(err),
1280
+ )
1281
+ return
1282
+ except Exception as err:
1283
+ log_exception("Preview CSV export failed", err, __name__)
1284
+ self.show_error(
1285
+ "CSV Export Failed",
1286
+ "Could not export the selected measurement data.",
1287
+ str(err),
1288
+ )
1289
+ return
1290
+
1291
+ self.show_status(f"Exported CSV: {filename}", 5000)
1292
+ finally:
1293
+ if dataset is not None:
1294
+ try:
1295
+ close_dataset_connection(dataset)
1296
+ except Exception as err:
1297
+ log_exception(
1298
+ "Preview CSV dataset cleanup failed",
1299
+ err,
1300
+ __name__,
1301
+ )
1302
+
1303
+
1304
+ def _export_measurement_csv(
1305
+ self,
1306
+ dataset_key,
1307
+ parameter_names,
1308
+ *,
1309
+ run_id=None,
1310
+ ):
1311
+ """Export a captured run request through a fresh read-only dataset."""
1312
+ parameter_names = tuple(str(name) for name in parameter_names)
1313
+ if not parameter_names:
1314
+ self.show_status("No plottable measurements to export for this run.", 5000)
1315
+ return
1316
+
1317
+ default_name = self._default_run_csv_export_filename(
1318
+ dataset_key,
1319
+ parameter_names,
1320
+ run_id=run_id,
1321
+ )
1322
+ filename = self._choose_csv_export_filename(default_name)
1323
+ if filename is None:
1324
+ return
1325
+
1326
+ dataset = None
1327
+ original_dataset_key = dataset_key
1328
+ try:
1329
+ # The destination is approved before this action-owned view is
1330
+ # opened, so a modal save dialog cannot retain an obsolete SQLite
1331
+ # snapshot. The read layer also binds acquisition to the database
1332
+ # identity captured in ``dataset_key``.
1333
+ dataset_key = self._bind_one_shot_dataset_key(dataset_key)
1334
+ dataset = self._load_run_csv_dataset(dataset_key)
1335
+ params = self._measurement_params_by_names(dataset, parameter_names)
1336
+ frame = self._measurement_dataframe(dataset, params)
1337
+ self._require_run_csv_source_current(dataset_key)
1338
+ write_export_atomically(
1339
+ filename,
1340
+ lambda temporary: frame.to_csv(temporary, index=False),
1341
+ before_publish=(
1342
+ lambda: self._require_run_csv_source_current(dataset_key)
1343
+ ),
1344
+ )
1345
+ except DatabaseInstanceChangedError:
1346
+ self._handle_run_csv_source_replaced(
1347
+ dataset_key or original_dataset_key,
1348
+ )
1349
+ return
1350
+ except Exception as err:
1351
+ log_exception("CSV export failed", err, __name__)
1352
+ self.show_error(
1353
+ "CSV Export Failed",
1354
+ "Could not export the selected measurement data.",
1355
+ str(err),
1356
+ )
1357
+ return
1358
+ finally:
1359
+ if dataset is not None:
1360
+ try:
1361
+ close_dataset_connection(dataset)
1362
+ except Exception as err:
1363
+ log_exception("CSV dataset cleanup failed", err, __name__)
1364
+
1365
+ self.show_status(f"Exported CSV: {filename}", 5000)
1366
+
1367
+
1368
+ def _load_run_csv_dataset(self, dataset_key):
1369
+ """Fresh-load one export view for an exact database instance."""
1370
+ self._require_run_csv_source_current(dataset_key)
1371
+ load_kwargs = {}
1372
+ if dataset_key.database_identity is not None:
1373
+ load_kwargs["expected_database_identity"] = (
1374
+ dataset_key.database_identity
1375
+ )
1376
+ dataset = load_by_guid_read_only(
1377
+ dataset_key.guid,
1378
+ dataset_key.database_path,
1379
+ **load_kwargs,
1380
+ )
1381
+ try:
1382
+ self._require_run_csv_source_current(dataset_key)
1383
+ except Exception:
1384
+ try:
1385
+ close_dataset_connection(dataset)
1386
+ finally:
1387
+ raise
1388
+ return dataset
1389
+
1390
+
1391
+ def _bind_one_shot_dataset_key(self, dataset_key):
1392
+ """Bind compatible first sidecars before an action-owned read."""
1393
+
1394
+ bound_key = bind_dataset_key_to_current_source(dataset_key)
1395
+ if bound_key is None:
1396
+ raise DatabaseInstanceChangedError(
1397
+ "The database or its SQLite sidecars were replaced before qPlot "
1398
+ "could open the export view."
1399
+ )
1400
+ return bound_key
1401
+
1402
+
1403
+ def _require_run_csv_source_current(self, dataset_key):
1404
+ """Reject an export when its captured database instance was replaced."""
1405
+ if not self._bound_plot_dataset_key_is_current(dataset_key):
1406
+ raise DatabaseInstanceChangedError(
1407
+ "The database or its SQLite sidecars were replaced while "
1408
+ "qPlot was exporting run data."
1409
+ )
1410
+
1411
+
1412
+ def _handle_run_csv_source_replaced(self, dataset_key):
1413
+ """Request replacement recovery without reporting a generic CSV error."""
1414
+ reload_if_changed = getattr(
1415
+ self,
1416
+ "_reload_if_database_instance_changed",
1417
+ None,
1418
+ )
1419
+ if callable(reload_if_changed):
1420
+ reload_if_changed(dataset_key.database_path)
1421
+ self.show_status(
1422
+ "CSV export stopped because the database was replaced; "
1423
+ "reloading the source. No CSV was written.",
1424
+ 5000,
1425
+ )
1426
+
1427
+
1428
+ def _choose_csv_export_filename(self, default_name):
1429
+ try:
1430
+ destination = choose_export_path(
1431
+ self,
1432
+ caption="Export CSV",
1433
+ suggested_path=default_name,
1434
+ name_filter="CSV files (*.csv)",
1435
+ required_suffix=".csv",
1436
+ replace_title="Replace CSV File?",
1437
+ file_description="CSV file",
1438
+ )
1439
+ except Exception as err:
1440
+ log_exception("CSV export destination rejected", err, __name__)
1441
+ self.show_error(
1442
+ "CSV Export Failed",
1443
+ "qPlot could not safely use the selected CSV destination.",
1444
+ str(err),
1445
+ )
1446
+ return None
1447
+ if destination is None:
1448
+ self.show_status("CSV export cancelled.", 3000)
1449
+ return None
1450
+ return destination
1451
+
1452
+
1453
+ def _publish_preview_csv(self, frame, filename, dataset_key):
1454
+ """Stage a preview CSV and publish it only for the requested source."""
1455
+ # The source must still be the captured database instance immediately
1456
+ # before the staged file atomically replaces the requested destination.
1457
+ write_export_atomically(
1458
+ filename,
1459
+ lambda temporary: frame.to_csv(temporary, index=False),
1460
+ before_publish=(
1461
+ lambda: self._require_run_csv_source_current(dataset_key)
1462
+ ),
1463
+ )
1464
+
1465
+
1466
+ @QtCore.pyqtSlot(str)
1467
+ @_trusted_plot_action
1468
+ def openPlot(self, guid: str | DatasetKey = None, params: list = None, show: bool = True):
1469
+ """
1470
+ Opens plot windows for the selected or requested run.
1471
+
1472
+ """
1473
+ retained_dataset_key = isinstance(guid, DatasetKey)
1474
+ target_path = guid.database_path if retained_dataset_key else None
1475
+ if not PlotActionsMixin._generation_gate_allows_action(
1476
+ self,
1477
+ target_path,
1478
+ "opening plots",
1479
+ ):
1480
+ return
1481
+
1482
+ # ``DataSet`` implements truthiness through ``__len__``, which queries
1483
+ # its SQLite connection. A Windows replacement test (and a real
1484
+ # replacement race) may have deliberately released that old read-only
1485
+ # connection before this requested DatasetKey is invalidated. Presence
1486
+ # of a selected dataset is the relevant check here, not its row count.
1487
+ selected_guid = getattr(self, "_selected_run_guid", None)
1488
+ if self.ds is None and not guid and not selected_guid:
1489
+ self.show_status("Select a run before opening plots.", 5000)
1490
+ return
1491
+
1492
+ self.show_status("Opening plots...", 0)
1493
+
1494
+ if isinstance(guid, DatasetKey):
1495
+ dataset_key = guid
1496
+ guid = dataset_key.guid
1497
+ else:
1498
+ target_guid = guid or selected_guid or getattr(self.ds, "guid", "")
1499
+ dataset_key = self._current_dataset_key(target_guid)
1500
+
1501
+ loaded_by_open_plot = False
1502
+ try:
1503
+ if retained_dataset_key:
1504
+ self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
1505
+ else:
1506
+ self._ensure_dataset_key_can_be_read(dataset_key)
1507
+ if (
1508
+ self.ds is None
1509
+ or getattr(self, "_selected_dataset_key", None) != dataset_key
1510
+ ):
1511
+ handle = self._dataset_handle_for_key(dataset_key)
1512
+ if handle is None:
1513
+ ds = self._load_dataset(dataset_key)
1514
+ loaded_by_open_plot = True
1515
+ else:
1516
+ ds = handle.dataset
1517
+ else:
1518
+ ds = self.ds
1519
+
1520
+ prior_key = dataset_key
1521
+ if retained_dataset_key:
1522
+ self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
1523
+ else:
1524
+ dataset_key = self._bind_plot_dataset_key(dataset_key)
1525
+ if dataset_key is None:
1526
+ raise RuntimeError(
1527
+ "The database or its SQLite sidecars changed while "
1528
+ "the plot dataset was being acquired."
1529
+ )
1530
+ if (
1531
+ self.ds is ds
1532
+ and getattr(self, "_selected_dataset_key", None)
1533
+ == prior_key
1534
+ ):
1535
+ self._selected_dataset_key = dataset_key
1536
+ self._plot_target_dataset_key = dataset_key
1537
+ except Exception as err:
1538
+ if loaded_by_open_plot and "ds" in locals():
1539
+ self._close_dataset_if_unowned(
1540
+ ds,
1541
+ context="Unused plot dataset cleanup failed",
1542
+ )
1543
+ log_exception("Plot run load failed", err, __name__)
1544
+ self.show_error("Run Load Failed", "Could not load the selected run.", str(err))
1545
+ return
1546
+
1547
+ opened = 0
1548
+ skipped = 0
1549
+ unsupported = []
1550
+ try:
1551
+ if not params:
1552
+ params = ds.get_parameters()
1553
+
1554
+ for param in params:
1555
+ if param.depends_on == "":
1556
+ continue
1557
+
1558
+ depends_on = param.depends_on_
1559
+ if len(depends_on) > MAX_SUPPORTED_PLOT_DIMENSIONS:
1560
+ unsupported.append(
1561
+ unsupported_plot_message(param.name, depends_on)
1562
+ )
1563
+ continue
1564
+ skip = False
1565
+
1566
+ if len(depends_on) == 1:
1567
+ for win in self.windows:
1568
+ if (
1569
+ win._dataset_key == dataset_key
1570
+ and win.param == param
1571
+ and isinstance(win, plot1d)
1572
+ ):
1573
+ skipped += 1
1574
+ skip = True
1575
+ break
1576
+ if skip:
1577
+ continue
1578
+
1579
+ self.openWin(
1580
+ plot1d,
1581
+ ds,
1582
+ param,
1583
+ refrate=refresh_interval_value(self.spinBox),
1584
+ show=show,
1585
+ dataset_key=dataset_key,
1586
+ )
1587
+ opened += 1
1588
+
1589
+ else:
1590
+ for win in self.windows:
1591
+ if (
1592
+ win._dataset_key == dataset_key
1593
+ and win.param == param
1594
+ and isinstance(win, plot2d)
1595
+ ):
1596
+ skipped += 1
1597
+ skip = True
1598
+ break
1599
+ if skip:
1600
+ continue
1601
+
1602
+ self.openWin(
1603
+ plot2d,
1604
+ ds,
1605
+ param,
1606
+ refrate=refresh_interval_value(self.spinBox),
1607
+ show=show,
1608
+ dataset_key=dataset_key,
1609
+ )
1610
+ opened += 1
1611
+
1612
+ self.post_admin()
1613
+
1614
+ if opened and show:
1615
+ preview = getattr(getattr(self, "infoBox", None), "preview", None)
1616
+ request_previews = getattr(preview, "request_guids", None)
1617
+ if callable(request_previews):
1618
+ request_previews([dataset_key.guid])
1619
+
1620
+ if opened:
1621
+ noun = "plot" if opened == 1 else "plots"
1622
+ message = f"Opened {opened} {noun}."
1623
+ if unsupported:
1624
+ message += f" Skipped {len(unsupported)} unsupported measurement(s)."
1625
+ self.show_status(message, 8000 if unsupported else 5000)
1626
+ elif skipped:
1627
+ self.show_status("Selected plot windows are already open.", 5000)
1628
+ elif unsupported:
1629
+ self.show_status(unsupported[0], 10_000)
1630
+ else:
1631
+ self.show_status("No plottable parameters found for this run.", 5000)
1632
+
1633
+ except Exception as err:
1634
+ log_exception("Plot open failed", err, __name__)
1635
+ self.show_error("Plot Open Failed", "Could not open plot windows.", str(err))
1636
+ finally:
1637
+ dataset_is_held = dataset_key in self.dataset_holder or any(
1638
+ handle.dataset is ds for handle in self.dataset_holder.values()
1639
+ )
1640
+ if loaded_by_open_plot and self.ds is not ds and not dataset_is_held:
1641
+ self._close_dataset_if_unowned(
1642
+ ds,
1643
+ context="Unused plot dataset cleanup failed",
1644
+ )
1645
+
1646
+
1647
+ @_trusted_plot_action
1648
+ def open_param_by_index(self, index: int):
1649
+ """
1650
+ Open the indexed dependent parameter for the selected run.
1651
+
1652
+ """
1653
+ if not PlotActionsMixin._generation_gate_allows_action(
1654
+ self,
1655
+ operation="opening a plot",
1656
+ ):
1657
+ return
1658
+ if self.ds is None:
1659
+ dataset = self._dataset_for_plot_target()
1660
+ if dataset is None:
1661
+ return
1662
+ dataset_key = self._accepted_plot_target_key(dataset)
1663
+ if dataset_key is None:
1664
+ self._close_dataset_if_unowned(dataset)
1665
+ return
1666
+ self._replace_selected_dataset(
1667
+ dataset,
1668
+ dataset_key,
1669
+ )
1670
+ action_dataset = self.ds
1671
+ try:
1672
+ params = [
1673
+ param
1674
+ for param in action_dataset.get_parameters()
1675
+ if param.depends_on != ""
1676
+ ]
1677
+ if index >= len(params):
1678
+ self.show_status(f"Run has no parameter {index + 1}.", 5000)
1679
+ return
1680
+
1681
+ self.openPlot(params=[params[index]])
1682
+ finally:
1683
+ self._release_trusted_action_dataset(action_dataset)
1684
+
1685
+
1686
+ @QtCore.pyqtSlot(str)
1687
+ @_trusted_plot_action
1688
+ def open_preview_plot(self, parameter_name):
1689
+ """
1690
+ Open the plot represented by a double-clicked preview image.
1691
+
1692
+ """
1693
+ if not PlotActionsMixin._generation_gate_allows_action(
1694
+ self,
1695
+ operation="opening a preview plot",
1696
+ ):
1697
+ return
1698
+ if self.ds is None:
1699
+ dataset = self._dataset_for_plot_target()
1700
+ if dataset is None:
1701
+ return
1702
+ dataset_key = self._accepted_plot_target_key(dataset)
1703
+ if dataset_key is None:
1704
+ self._close_dataset_if_unowned(dataset)
1705
+ return
1706
+ self._replace_selected_dataset(
1707
+ dataset,
1708
+ dataset_key,
1709
+ )
1710
+ action_dataset = self.ds
1711
+ try:
1712
+ for param in action_dataset.get_parameters():
1713
+ if param.name == parameter_name and param.depends_on != "":
1714
+ self.openPlot(params=[param])
1715
+ return
1716
+
1717
+ self.show_status(f"No preview plot found for {parameter_name}.", 5000)
1718
+ finally:
1719
+ self._release_trusted_action_dataset(action_dataset)
1720
+
1721
+
1722
+ def open_selected_measurement(self, parameter_name):
1723
+ """Materialise the selected run only after an explicit menu action."""
1724
+ return self.open_preview_plot(str(parameter_name))
1725
+
1726
+
1727
+ @QtCore.pyqtSlot(str, str)
1728
+ @_trusted_plot_action
1729
+ def open_run_preview_plot(self, guid, parameter_name):
1730
+ """
1731
+ Open the plot represented by a double-clicked run-table preview image.
1732
+
1733
+ """
1734
+ if not PlotActionsMixin._generation_gate_allows_action(
1735
+ self,
1736
+ operation="opening a preview plot",
1737
+ ):
1738
+ return
1739
+ if not guid:
1740
+ self.show_status("Select a run before opening a preview plot.", 5000)
1741
+ return
1742
+
1743
+ dataset_key = self._current_dataset_key(guid)
1744
+ if (
1745
+ self.ds is None
1746
+ or getattr(self, "_selected_dataset_key", None) != dataset_key
1747
+ ):
1748
+ try:
1749
+ handle = self._dataset_handle_for_key(dataset_key)
1750
+ if handle is None:
1751
+ dataset = self._load_dataset(dataset_key)
1752
+ else:
1753
+ dataset = handle.dataset
1754
+ self._replace_selected_dataset(dataset, dataset_key)
1755
+ except Exception as err:
1756
+ log_exception("Preview plot run load failed", err, __name__)
1757
+ self.show_error("Run Load Failed", f"Could not load run with GUID {guid}.", str(err))
1758
+ return
1759
+
1760
+ self.open_preview_plot(parameter_name)
1761
+
1762
+
1763
+ @QtCore.pyqtSlot(object, object, str)
1764
+ def add_dropped_preview_to_plot(self, target_win, source_identity, parameter_name):
1765
+ """
1766
+ Add a run-table preview to the plot it was dropped onto.
1767
+
1768
+ """
1769
+ # The plot is normally in front of the main window when a drop occurs.
1770
+ # Keep its status bar in sync with the main-window feedback produced by
1771
+ # ``add_trace_to_plot`` so a post-drop rejection is visible where the
1772
+ # user performed the action.
1773
+ previous_target = getattr(self, "_preview_drop_feedback_window", None)
1774
+ self._preview_drop_feedback_window = target_win
1775
+ try:
1776
+ return self.add_trace_to_plot(
1777
+ target_win,
1778
+ source_identity,
1779
+ parameter_name,
1780
+ )
1781
+ finally:
1782
+ self._preview_drop_feedback_window = previous_target
1783
+
1784
+
1785
+ @_trusted_plot_action
1786
+ def add_trace_to_plot(self, target_win, source_identity, parameter_name, param=None):
1787
+ """
1788
+ Add a compatible parameter to an existing plot.
1789
+
1790
+ The historical method name remains the entry point used by run-table
1791
+ menus and preview drops. Heatmap targets dispatch to the corresponding
1792
+ layer workflow; line targets retain the existing secondary-trace path.
1793
+
1794
+ """
1795
+ if getattr(target_win, "operation_kind", None) == "plot2d":
1796
+ return self.add_heatmap_to_plot(
1797
+ target_win,
1798
+ source_identity,
1799
+ parameter_name,
1800
+ param=param,
1801
+ )
1802
+
1803
+ source_path = (
1804
+ source_identity.database_path
1805
+ if isinstance(source_identity, DatasetKey)
1806
+ else None
1807
+ )
1808
+ if not PlotActionsMixin._generation_gate_allows_action(
1809
+ self,
1810
+ source_path,
1811
+ "adding a preview trace",
1812
+ ):
1813
+ return False
1814
+ if target_win is None or not hasattr(target_win, "option_boxes"):
1815
+ self.show_status("Drop traces onto a compatible line plot.", 5000)
1816
+ return False
1817
+
1818
+ if isinstance(source_identity, DatasetKey):
1819
+ source_key = source_identity
1820
+ else:
1821
+ source_key = self._current_dataset_key(source_identity)
1822
+ source_guid = source_key.guid
1823
+ if param is None:
1824
+ try:
1825
+ param = self._parameter_from_key(source_key, parameter_name)
1826
+ except Exception as err:
1827
+ log_exception("Trace source run load failed", err, __name__)
1828
+ self.show_error(
1829
+ "Run Load Failed",
1830
+ f"Could not load run with GUID {source_guid}.",
1831
+ str(err),
1832
+ )
1833
+ return False
1834
+
1835
+ if param is None or not getattr(param, "depends_on", ""):
1836
+ self.show_status(f"No preview plot found for {parameter_name}.", 5000)
1837
+ return False
1838
+
1839
+ if len(getattr(param, "depends_on_", ())) != 1:
1840
+ self.show_status("Only 1D measurements can be added as traces.", 5000)
1841
+ return False
1842
+
1843
+ if tuple(param.depends_on_) != tuple(target_win.param.depends_on_):
1844
+ self.show_status(
1845
+ f"Cannot add {parameter_name}; the plot axes do not match.",
1846
+ 5000,
1847
+ )
1848
+ return False
1849
+
1850
+ from_win = self._plot_window_for_param(source_key, param)
1851
+ if from_win == target_win:
1852
+ self.show_status(f"Skipped {target_win.label}; source and target are the same.", 5000)
1853
+ return False
1854
+
1855
+ if from_win is None:
1856
+ from_win = self._open_hidden_trace_window(source_key, param, target_win)
1857
+ if from_win is None:
1858
+ return False
1859
+
1860
+ self.get_1d_wins(target_win)
1861
+ if target_win.option_boxes[-1].isEnabled():
1862
+ box = target_win.option_boxes[-1]
1863
+ else:
1864
+ target_win.add_option_box()
1865
+ box = target_win.option_boxes[-1]
1866
+
1867
+ source_trace_key = getattr(from_win, "_trace_key", from_win.label)
1868
+ index = _combo_index_for_data(box.option_box, source_trace_key)
1869
+ if index < 0:
1870
+ index = box.option_box.findText(from_win.label)
1871
+ if index < 0:
1872
+ self.show_status(
1873
+ f"Cannot add {parameter_name}; it is already shown or incompatible.",
1874
+ 5000,
1875
+ )
1876
+ if not from_win.visible:
1877
+ from_win.close()
1878
+ return False
1879
+
1880
+ box.option_box.setCurrentIndex(index)
1881
+ if not getattr(from_win, "visible", False):
1882
+ from_win.close()
1883
+ return True
1884
+
1885
+
1886
+ def _trace_candidates_for_plot(self, target_win):
1887
+ """Return loaded-database 1D measurements compatible with a plot."""
1888
+
1889
+ target_axes = tuple(
1890
+ getattr(getattr(target_win, "param", None), "depends_on_", ())
1891
+ )
1892
+ if len(target_axes) != 1:
1893
+ return []
1894
+
1895
+ run_list = getattr(self, "RunList", None)
1896
+ all_run_metadata = getattr(run_list, "all_run_metadata", None)
1897
+ if not callable(all_run_metadata):
1898
+ return []
1899
+
1900
+ candidates = []
1901
+ primary_trace_key = TraceKey(target_win._dataset_key, target_win.param.name)
1902
+ for run_id, metadata in all_run_metadata().items():
1903
+ if tuple(metadata.get("sweep_parameters") or ()) != target_axes:
1904
+ continue
1905
+ guid = metadata.get("guid")
1906
+ if not guid:
1907
+ continue
1908
+ source_key = self._current_dataset_key(guid)
1909
+ for parameter_name in metadata.get("measure_parameters") or ():
1910
+ trace_key = TraceKey(source_key, parameter_name)
1911
+ if (
1912
+ trace_key == primary_trace_key
1913
+ or trace_key in getattr(target_win, "lines", {})
1914
+ ):
1915
+ continue
1916
+ candidates.append((f"ID:{run_id} {parameter_name}", trace_key))
1917
+ return candidates
1918
+
1919
+
1920
+ def _heatmap_candidates_for_plot(self, target_win):
1921
+ """Return loaded-database 2D measurements compatible with a heatmap."""
1922
+
1923
+ target_axes = tuple(
1924
+ getattr(getattr(target_win, "param", None), "depends_on_", ())
1925
+ )
1926
+ if len(target_axes) != 2:
1927
+ return []
1928
+
1929
+ run_list = getattr(self, "RunList", None)
1930
+ all_run_metadata = getattr(run_list, "all_run_metadata", None)
1931
+ if not callable(all_run_metadata):
1932
+ return []
1933
+
1934
+ candidates = []
1935
+ primary_key = TraceKey(target_win._dataset_key, target_win.param.name)
1936
+ plotted = getattr(target_win, "heatmaps", {})
1937
+ for run_id, metadata in all_run_metadata().items():
1938
+ axes = tuple(metadata.get("sweep_parameters") or ())
1939
+ if len(axes) != 2 or set(axes) != set(target_axes):
1940
+ continue
1941
+ guid = metadata.get("guid")
1942
+ if not guid:
1943
+ continue
1944
+ source_key = self._current_dataset_key(guid)
1945
+ for parameter_name in metadata.get("measure_parameters") or ():
1946
+ heatmap_key = TraceKey(source_key, parameter_name)
1947
+ if heatmap_key == primary_key or heatmap_key in plotted:
1948
+ continue
1949
+ candidates.append(
1950
+ (f"ID:{run_id} {parameter_name}", heatmap_key)
1951
+ )
1952
+ return candidates
1953
+
1954
+
1955
+ @_trusted_plot_action
1956
+ def add_heatmap_to_plot(
1957
+ self,
1958
+ target_win,
1959
+ source_identity,
1960
+ parameter_name,
1961
+ param=None,
1962
+ ):
1963
+ """Add a compatible 2D parameter as a layer on an existing heatmap."""
1964
+
1965
+ source_path = (
1966
+ source_identity.database_path
1967
+ if isinstance(source_identity, DatasetKey)
1968
+ else None
1969
+ )
1970
+ if not PlotActionsMixin._generation_gate_allows_action(
1971
+ self,
1972
+ source_path,
1973
+ "adding a preview heatmap",
1974
+ ):
1975
+ return False
1976
+
1977
+ add_heatmap = getattr(target_win, "add_heatmap", None)
1978
+ if (
1979
+ target_win is None
1980
+ or getattr(target_win, "operation_kind", None) != "plot2d"
1981
+ or not callable(add_heatmap)
1982
+ ):
1983
+ self.show_status("Drop heatmaps onto a compatible heatmap plot.", 5000)
1984
+ return False
1985
+
1986
+ if isinstance(source_identity, DatasetKey):
1987
+ source_key = source_identity
1988
+ else:
1989
+ source_key = self._current_dataset_key(source_identity)
1990
+ source_guid = source_key.guid
1991
+
1992
+ if param is None:
1993
+ try:
1994
+ param = self._parameter_from_key(source_key, parameter_name)
1995
+ except Exception as err:
1996
+ log_exception("Heatmap source run load failed", err, __name__)
1997
+ self.show_error(
1998
+ "Run Load Failed",
1999
+ f"Could not load run with GUID {source_guid}.",
2000
+ str(err),
2001
+ )
2002
+ return False
2003
+
2004
+ if param is None or not getattr(param, "depends_on", ""):
2005
+ self.show_status(f"No preview plot found for {parameter_name}.", 5000)
2006
+ return False
2007
+
2008
+ source_axes = tuple(getattr(param, "depends_on_", ()))
2009
+ target_axes = tuple(getattr(target_win.param, "depends_on_", ()))
2010
+ if len(source_axes) != 2:
2011
+ self.show_status("Only 2D measurements can be added as heatmaps.", 5000)
2012
+ return False
2013
+ if len(target_axes) != 2 or set(source_axes) != set(target_axes):
2014
+ self.show_status(
2015
+ f"Cannot add {parameter_name}; the heatmap axes do not match.",
2016
+ 5000,
2017
+ )
2018
+ return False
2019
+
2020
+ source_trace_key = TraceKey(source_key, param.name)
2021
+ heatmaps = getattr(target_win, "heatmaps", {})
2022
+ if source_trace_key in heatmaps:
2023
+ self.show_status(
2024
+ f"Skipped {parameter_name}; that heatmap is already shown.",
2025
+ 5000,
2026
+ )
2027
+ return False
2028
+ if source_trace_key == getattr(target_win, "_trace_key", None):
2029
+ self.show_status(
2030
+ f"Skipped {target_win.label}; source and target are the same.",
2031
+ 5000,
2032
+ )
2033
+ return False
2034
+
2035
+ from_win = self._plot_window_for_param(
2036
+ source_key,
2037
+ param,
2038
+ operation_kind="plot2d",
2039
+ )
2040
+ if from_win == target_win:
2041
+ self.show_status(
2042
+ f"Skipped {target_win.label}; source and target are the same.",
2043
+ 5000,
2044
+ )
2045
+ return False
2046
+
2047
+ if from_win is None:
2048
+ from_win = self._open_hidden_trace_window(source_key, param, target_win)
2049
+ if from_win is None:
2050
+ return False
2051
+
2052
+ _axis_order, compatibility_error = _heatmap_layer_compatibility(
2053
+ target_win,
2054
+ from_win,
2055
+ )
2056
+ if compatibility_error is not None:
2057
+ if compatibility_error == "the displayed value units do not match":
2058
+ compatibility_error += (
2059
+ f" (source: {_window_display_unit(from_win) or 'unitless'};"
2060
+ f" target: {_window_display_unit(target_win) or 'unitless'})"
2061
+ )
2062
+ self.show_status(
2063
+ f"Cannot add {parameter_name}; {compatibility_error}.",
2064
+ 5000,
2065
+ )
2066
+ if not getattr(from_win, "visible", False):
2067
+ from_win.close()
2068
+ return False
2069
+
2070
+ try:
2071
+ added = bool(add_heatmap(from_win))
2072
+ except Exception as err:
2073
+ log_exception("Heatmap layer construction failed", err, __name__)
2074
+ self.show_error(
2075
+ "Heatmap Add Failed",
2076
+ f"Could not add {parameter_name} to the heatmap.",
2077
+ str(err),
2078
+ )
2079
+ added = False
2080
+
2081
+ if not getattr(from_win, "visible", False):
2082
+ from_win.close()
2083
+
2084
+ if not added:
2085
+ self.show_status(
2086
+ f"Cannot add {parameter_name}; it is already shown or incompatible.",
2087
+ 5000,
2088
+ )
2089
+ return False
2090
+
2091
+ sync_layer_views = getattr(
2092
+ target_win,
2093
+ "_sync_secondary_heatmap_view_ranges",
2094
+ None,
2095
+ )
2096
+ if callable(sync_layer_views):
2097
+ sync_layer_views()
2098
+
2099
+ self.show_status(f"Added {from_win.label} to {target_win.label}.", 3000)
2100
+ return True
2101
+
2102
+
2103
+ def _parameter_from_guid(self, guid, parameter_name):
2104
+ return self._parameter_from_key(self._current_dataset_key(guid), parameter_name)
2105
+
2106
+
2107
+ def _parameter_from_key(self, dataset_key, parameter_name):
2108
+ ds = self._dataset_for_key(dataset_key)
2109
+ try:
2110
+ return self._measurement_param_by_name(ds, parameter_name)
2111
+ finally:
2112
+ self._close_dataset_if_unowned(
2113
+ ds,
2114
+ context="Parameter lookup dataset cleanup failed",
2115
+ )
2116
+
2117
+
2118
+ def _measurement_param_by_name(self, dataset, parameter_name):
2119
+ for param in dataset.get_parameters():
2120
+ if param.name == parameter_name:
2121
+ return param
2122
+ return None
2123
+
2124
+
2125
+ def _dataset_for_guid(self, guid):
2126
+ return self._dataset_for_key(self._current_dataset_key(guid))
2127
+
2128
+
2129
+ def _dataset_for_key(self, dataset_key):
2130
+ handle = self._dataset_handle_for_key(dataset_key)
2131
+ if handle is not None:
2132
+ return handle.dataset
2133
+ return self._load_dataset(dataset_key)
2134
+
2135
+
2136
+ def _plot_window_for_param(self, dataset_key, param, operation_kind=None):
2137
+ for win in self.windows:
2138
+ try:
2139
+ window_kind = getattr(
2140
+ win,
2141
+ "operation_kind",
2142
+ win.__class__.__name__,
2143
+ )
2144
+ if (
2145
+ win._dataset_key == dataset_key
2146
+ and win.param.name == param.name
2147
+ and (
2148
+ operation_kind is None
2149
+ or window_kind == operation_kind
2150
+ )
2151
+ ):
2152
+ return win
2153
+ except AttributeError:
2154
+ continue
2155
+ return None
2156
+
2157
+
2158
+ def _open_hidden_trace_window(self, source_key, param, target_win):
2159
+ before = set(id(win) for win in self.windows)
2160
+ self.openPlot(guid=source_key, params=[param], show=False)
2161
+
2162
+ for win in reversed(self.windows):
2163
+ if id(win) in before:
2164
+ continue
2165
+ try:
2166
+ if win._dataset_key == source_key and win.param.name == param.name:
2167
+ if plot_refresh_required(win):
2168
+ target_win.toolbarRef.show()
2169
+ return win
2170
+ except AttributeError:
2171
+ continue
2172
+
2173
+ self.show_status(f"Could not prepare {param.name} for adding to the plot.", 5000)
2174
+ return None
2175
+
2176
+
2177
+ def _selected_measurement_params(self, dataset):
2178
+ """
2179
+ Returns the measurement parameters requested by the Measurement field.
2180
+
2181
+ """
2182
+ params = [param for param in dataset.get_parameters() if param.depends_on != ""]
2183
+ names = self._selected_measurement_names(
2184
+ tuple(param.name for param in params),
2185
+ dataset.run_id,
2186
+ )
2187
+ if names is None:
2188
+ return None
2189
+ selected_names = set(names)
2190
+ return [param for param in params if param.name in selected_names]
2191
+
2192
+
2193
+ def _selected_measurement_names(self, parameter_names, run_id):
2194
+ """Apply the Measurement field to immutable parameter names."""
2195
+ measurement_box = getattr(self, "measurementBox", None)
2196
+ measurement = (
2197
+ measurement_box.text().strip()
2198
+ if measurement_box is not None
2199
+ else "*"
2200
+ )
2201
+
2202
+ if measurement in ("", "*"):
2203
+ return tuple(parameter_names)
2204
+
2205
+ try:
2206
+ index = int(measurement)
2207
+ except ValueError:
2208
+ self.show_status("Measurement must be a number or *.", 5000)
2209
+ return None
2210
+
2211
+ if index < 1 or index > len(parameter_names):
2212
+ self.show_status(
2213
+ f"Run {run_id} has no measurement {index}.",
2214
+ 5000,
2215
+ )
2216
+ return None
2217
+
2218
+ return (parameter_names[index - 1],)
2219
+
2220
+
2221
+ def _run_csv_export_request(self):
2222
+ """Capture a run key and names without opening an export dataset."""
2223
+ database_path = self.fileTextbox.text()
2224
+ if not database_path:
2225
+ self.show_status("Load a database before plotting or exporting.", 5000)
2226
+ return None
2227
+
2228
+ run_id = self.selected_run_id
2229
+ if run_id is None:
2230
+ self.show_status("Enter a Run ID before plotting or exporting.", 5000)
2231
+ return None
2232
+
2233
+ reload_if_changed = getattr(
2234
+ self,
2235
+ "_reload_if_database_instance_changed",
2236
+ None,
2237
+ )
2238
+ if callable(reload_if_changed) and reload_if_changed(database_path):
2239
+ self.show_status(
2240
+ "Database was replaced; reloading before exporting the run.",
2241
+ 5000,
2242
+ )
2243
+ return None
2244
+
2245
+ selected_dataset = getattr(self, "ds", None)
2246
+ selected_key = getattr(self, "_selected_dataset_key", None)
2247
+ if (
2248
+ selected_dataset is not None
2249
+ and getattr(selected_dataset, "run_id", None) == run_id
2250
+ and selected_key is not None
2251
+ and selected_key.database_path
2252
+ == logical_database_path(database_path)
2253
+ ):
2254
+ try:
2255
+ parameter_names = tuple(
2256
+ param.name
2257
+ for param in _exportable_measurement_params(selected_dataset)
2258
+ )
2259
+ except Exception as error:
2260
+ log_exception("Run parameter enumeration failed", error, __name__)
2261
+ self.show_error(
2262
+ "Run Load Failed",
2263
+ "Could not read the selected run's measurements.",
2264
+ str(error),
2265
+ )
2266
+ return None
2267
+ # Parameter enumeration may reuse an explicit-action dataset, but
2268
+ # this new export request is rebound to the currently accepted full
2269
+ # DatabaseInstance after the replacement guard above.
2270
+ dataset_key = self._current_dataset_key(selected_key.guid)
2271
+ else:
2272
+ metadata = self._run_metadata_for_id(run_id)
2273
+ guid = metadata.get("guid")
2274
+ if not guid:
2275
+ self.show_status(
2276
+ f"Run {run_id} is not available in the loaded database.",
2277
+ 5000,
2278
+ )
2279
+ return None
2280
+ dataset_key = self._current_dataset_key(str(guid))
2281
+ parameter_names = tuple(
2282
+ str(name)
2283
+ for name in metadata.get("measure_parameters") or ()
2284
+ )
2285
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
2286
+ # The trusted run list is populated progressively. Its initial
2287
+ # measurement names may omit standalones until derived metadata
2288
+ # arrives. An explicit export can enumerate a short-lived
2289
+ # action view now, before the modal destination dialog.
2290
+ dataset = None
2291
+ try:
2292
+ dataset_key = self._bind_one_shot_dataset_key(dataset_key)
2293
+ dataset = self._load_run_csv_dataset(dataset_key)
2294
+ parameter_names = tuple(
2295
+ param.name
2296
+ for param in _exportable_measurement_params(dataset)
2297
+ )
2298
+ self._require_run_csv_source_current(dataset_key)
2299
+ except DatabaseInstanceChangedError:
2300
+ self._handle_run_csv_source_replaced(dataset_key)
2301
+ return None
2302
+ except Exception as error:
2303
+ log_exception("Run parameter enumeration failed", error, __name__)
2304
+ self.show_error(
2305
+ "Run Load Failed",
2306
+ "Could not read the selected run's measurements.",
2307
+ str(error),
2308
+ )
2309
+ return None
2310
+ finally:
2311
+ if dataset is not None:
2312
+ try:
2313
+ close_dataset_connection(dataset)
2314
+ except Exception as error:
2315
+ log_exception(
2316
+ "CSV parameter dataset cleanup failed",
2317
+ error,
2318
+ __name__,
2319
+ )
2320
+
2321
+ requested_names = self._selected_measurement_names(
2322
+ parameter_names,
2323
+ run_id,
2324
+ )
2325
+ if requested_names is None:
2326
+ return None
2327
+ return dataset_key, requested_names, run_id
2328
+
2329
+
2330
+ def _run_metadata_for_id(self, run_id):
2331
+ """Return already-loaded run metadata without opening SQLite."""
2332
+ run_list = getattr(self, "RunList", None)
2333
+ all_run_metadata = getattr(run_list, "all_run_metadata", None)
2334
+ if not callable(all_run_metadata):
2335
+ return {}
2336
+ runs = all_run_metadata()
2337
+ metadata = runs.get(run_id)
2338
+ if metadata is None:
2339
+ metadata = runs.get(str(run_id))
2340
+ return dict(metadata or {})
2341
+
2342
+
2343
+ def _measurement_params_by_names(self, dataset, parameter_names):
2344
+ """Resolve captured measurement names against a freshly loaded run."""
2345
+ parameters = {
2346
+ param.name: param for param in _exportable_measurement_params(dataset)
2347
+ }
2348
+ resolved = []
2349
+ for name in parameter_names:
2350
+ param = parameters.get(name)
2351
+ if param is None:
2352
+ raise ValueError(
2353
+ f"Measurement parameter {name!r} is not exportable in the "
2354
+ "freshly loaded run."
2355
+ )
2356
+ resolved.append(param)
2357
+ return resolved
2358
+
2359
+
2360
+ def _dataset_for_plot_target(self):
2361
+ """
2362
+ Loads the dataset requested by the Run field.
2363
+
2364
+ """
2365
+ if not PlotActionsMixin._generation_gate_allows_action(
2366
+ self,
2367
+ operation="loading the selected run",
2368
+ ):
2369
+ return None
2370
+ if not self.fileTextbox.text():
2371
+ self.show_status("Load a database before plotting or exporting.", 5000)
2372
+ return None
2373
+
2374
+ if getattr(self, "selected_run_id", None) is None:
2375
+ self.show_status("Enter a Run ID before plotting or exporting.", 5000)
2376
+ return None
2377
+
2378
+ reload_if_changed = getattr(
2379
+ self,
2380
+ "_reload_if_database_instance_changed",
2381
+ None,
2382
+ )
2383
+ if callable(reload_if_changed) and reload_if_changed(self.fileTextbox.text()):
2384
+ self.show_status(
2385
+ "Database was replaced; reloading before opening the run.",
2386
+ 5000,
2387
+ )
2388
+ return None
2389
+
2390
+ run_id = self.selected_run_id
2391
+ metadata = self._run_metadata_for_id(run_id)
2392
+ guid = metadata.get("guid")
2393
+ if not guid:
2394
+ self.show_status(
2395
+ f"Run ID {run_id} is not available in the loaded database.",
2396
+ 5000,
2397
+ )
2398
+ return None
2399
+ dataset_key = self._current_dataset_key(str(guid))
2400
+ self._plot_target_dataset_key = None
2401
+ try:
2402
+ dataset = self._load_dataset(dataset_key)
2403
+ if getattr(dataset, "guid", None) != dataset_key.guid or int(
2404
+ getattr(dataset, "run_id", -1)
2405
+ ) != int(run_id):
2406
+ self._close_dataset_if_unowned(dataset)
2407
+ raise RuntimeError(
2408
+ "The selected GUID did not resolve to the requested run ID."
2409
+ )
2410
+ self._plot_target_dataset_key = dataset_key
2411
+ return dataset
2412
+ except DatabaseInstanceChangedError:
2413
+ if callable(reload_if_changed):
2414
+ reload_if_changed(self.fileTextbox.text())
2415
+ self.show_status(
2416
+ "Database was replaced; reloading before opening the run.",
2417
+ 5000,
2418
+ )
2419
+ return None
2420
+ except Exception as error:
2421
+ if not self._dataset_key_is_current(dataset_key):
2422
+ if callable(reload_if_changed):
2423
+ reload_if_changed(self.fileTextbox.text())
2424
+ self.show_status(
2425
+ "Database was replaced; reloading before opening the run.",
2426
+ 5000,
2427
+ )
2428
+ return None
2429
+ log_exception("Run ID load failed", error, __name__)
2430
+ self.show_error(
2431
+ "Run Load Failed",
2432
+ f"Could not load Run ID {self.selected_run_id}.",
2433
+ str(error),
2434
+ )
2435
+ return None
2436
+
2437
+
2438
+ def _accepted_plot_target_key(self, dataset):
2439
+ """Bind first sidecars and recheck before plot UI publication."""
2440
+
2441
+ dataset_key = getattr(self, "_plot_target_dataset_key", None)
2442
+ if not isinstance(dataset_key, DatasetKey) or dataset_key.guid != str(
2443
+ getattr(dataset, "guid", "")
2444
+ ):
2445
+ dataset_key = self._current_dataset_key(str(dataset.guid))
2446
+ bound_key = PlotActionsMixin._bind_plot_dataset_key(self, dataset_key)
2447
+ if bound_key is not None:
2448
+ self._plot_target_dataset_key = bound_key
2449
+ return bound_key
2450
+ self.show_status(
2451
+ "Database was replaced; reloading before opening the run.",
2452
+ 5000,
2453
+ )
2454
+ return None
2455
+
2456
+
2457
+ def _measurement_dataframe(self, dataset, params):
2458
+ """
2459
+ Builds a flat CSV-friendly dataframe for the selected measurement data.
2460
+
2461
+ """
2462
+ frames = []
2463
+ prefix_columns = len(params) > 1
2464
+ for param in params:
2465
+ param_data = parameter_data_for_export(dataset, param.name)
2466
+ columns = {}
2467
+ for name, values in _csv_scalar_columns(param_data).items():
2468
+ column_name = f"{param.name}.{name}" if prefix_columns else name
2469
+ # Object samples may need both uint64 and negative int64, or
2470
+ # exact integers alongside floats. Disable pandas' inference.
2471
+ dtype: str | type[object] | None = object if values.dtype.hasobject else None
2472
+ if values.dtype.kind in "iu":
2473
+ # Alignment adds blanks to shorter columns. Nullable
2474
+ # integers retain exact values instead of becoming floats.
2475
+ prefix = "U" if values.dtype.kind == "u" else ""
2476
+ dtype = f"{prefix}Int{values.dtype.itemsize * 8}"
2477
+ columns[column_name] = pd.Series(values, dtype=dtype)
2478
+ frames.append(pd.DataFrame(columns))
2479
+
2480
+ return pd.concat(frames, axis=1) if frames else pd.DataFrame()
2481
+
2482
+
2483
+ def _default_export_filename(self, dataset, params):
2484
+ """
2485
+ Returns a default CSV export path.
2486
+
2487
+ """
2488
+ database_folder = os.path.dirname(self.fileTextbox.text())
2489
+ measurement = "all" if len(params) != 1 else params[0].name
2490
+ filename = self._safe_filename(f"run_{dataset.run_id}_{measurement}.csv")
2491
+ return os.path.join(database_folder or os.getcwd(), filename)
2492
+
2493
+
2494
+ def _default_run_csv_export_filename(
2495
+ self,
2496
+ dataset_key,
2497
+ parameter_names,
2498
+ *,
2499
+ run_id=None,
2500
+ ):
2501
+ """Return a CSV suggestion from a captured export request."""
2502
+ if run_id is None:
2503
+ run_list = getattr(self, "RunList", None)
2504
+ run_id_for_guid = getattr(run_list, "run_id_for_guid", None)
2505
+ if callable(run_id_for_guid):
2506
+ run_id = run_id_for_guid(dataset_key.guid)
2507
+ run_identifier = dataset_key.guid if run_id is None else run_id
2508
+ measurement = (
2509
+ "all" if len(parameter_names) != 1 else parameter_names[0]
2510
+ )
2511
+ filename = self._safe_filename(
2512
+ f"run_{run_identifier}_{measurement}.csv"
2513
+ )
2514
+ database_folder = os.path.dirname(dataset_key.database_path)
2515
+ return os.path.join(database_folder or os.getcwd(), filename)
2516
+
2517
+
2518
+ def _default_preview_export_filename(self, dataset_key, parameter_name):
2519
+ """Return a preview destination suggestion without opening its dataset."""
2520
+ run_id = None
2521
+ run_list = getattr(self, "RunList", None)
2522
+ run_id_for_guid = getattr(run_list, "run_id_for_guid", None)
2523
+ if callable(run_id_for_guid):
2524
+ run_id = run_id_for_guid(dataset_key.guid)
2525
+
2526
+ selected_key = getattr(self, "_selected_dataset_key", None)
2527
+ selected_dataset = getattr(self, "ds", None)
2528
+ if run_id is None and selected_key == dataset_key and selected_dataset is not None:
2529
+ run_id = getattr(selected_dataset, "run_id", None)
2530
+
2531
+ run_identifier = dataset_key.guid if run_id is None else run_id
2532
+ filename = self._safe_filename(
2533
+ f"run_{run_identifier}_{parameter_name}.csv",
2534
+ )
2535
+ database_folder = os.path.dirname(dataset_key.database_path)
2536
+ return os.path.join(database_folder or os.getcwd(), filename)
2537
+
2538
+
2539
+ def _safe_filename(self, filename):
2540
+ """
2541
+ Replaces path-hostile characters in a suggested filename.
2542
+
2543
+ """
2544
+ return "".join(char if char.isalnum() or char in "._-" else "_" for char in filename)
2545
+
2546
+
2547
+ @QtCore.pyqtSlot(object)
2548
+ def add_ds_at(self, dataset_key: DatasetKey, ds=None):
2549
+ """
2550
+ Tracks a dataset used by one or more plot windows.
2551
+
2552
+ """
2553
+ self._ensure_bound_plot_dataset_key_can_be_read(dataset_key)
2554
+ handle = self._dataset_handle_for_key(dataset_key)
2555
+ if handle is None:
2556
+ ds = self._load_dataset(dataset_key) if ds is None else ds
2557
+ assert ds.guid == dataset_key.guid
2558
+
2559
+ if not self._bound_plot_dataset_key_is_current(dataset_key):
2560
+ self._close_dataset_if_unowned(
2561
+ ds,
2562
+ context="Replaced database dataset cleanup failed",
2563
+ )
2564
+ raise RuntimeError(
2565
+ "The database was replaced while its dataset was being acquired."
2566
+ )
2567
+ self.dataset_holder[dataset_key] = DatasetHandle(
2568
+ dataset=ds,
2569
+ database_identity=dataset_key.database_identity,
2570
+ sidecar_identities=dataset_key.sidecar_identities,
2571
+ )
2572
+ else:
2573
+ handle.retain()
2574
+
2575
+
2576
+ @QtCore.pyqtSlot(object)
2577
+ def remove_ds_at(self, dataset_key: DatasetKey):
2578
+ """
2579
+ Decreases the user count for a cached plot dataset.
2580
+
2581
+ """
2582
+ handle = self.dataset_holder.get(dataset_key)
2583
+ if handle is None:
2584
+ self.show_status("Trying to remove dataset that does not exist.", 5000)
2585
+ return
2586
+
2587
+ handle.release()
2588
+
2589
+ if handle.users <= 0:
2590
+ del_time = self.config.get("runtime_settings.del_grace_period")
2591
+
2592
+ if del_time == 0:
2593
+ self._evict_dataset_handle(dataset_key)
2594
+
2595
+ elif handle.delete_timer is None:
2596
+ del_timer = QtCore.QTimer()
2597
+ del_timer.setSingleShot(True)
2598
+ handle.delete_timer = del_timer
2599
+ del_timer.timeout.connect(
2600
+ lambda key=dataset_key: self._evict_dataset_handle(key)
2601
+ )
2602
+ del_timer.start(int(del_time * 1000))
2603
+
2604
+
2605
+ def _current_dataset_key(self, guid: str) -> DatasetKey:
2606
+ database_path = self.fileTextbox.text()
2607
+ loaded_instance = getattr(self, "_loaded_database_instance", None)
2608
+ if (
2609
+ isinstance(loaded_instance, DatabaseInstance)
2610
+ and loaded_instance.logical_path
2611
+ == logical_database_path(database_path)
2612
+ ):
2613
+ return DatasetKey(
2614
+ loaded_instance.logical_path,
2615
+ guid,
2616
+ loaded_instance.identity,
2617
+ loaded_instance.resolved_path,
2618
+ sidecar_identities=loaded_instance.sidecar_identities,
2619
+ )
2620
+ return DatasetKey(database_path, guid)
2621
+
2622
+
2623
+ def _load_dataset(self, dataset_key: DatasetKey):
2624
+ self._ensure_dataset_key_can_be_read(dataset_key)
2625
+
2626
+ prepared = getattr(self, "_trusted_plot_ready", {}).get(dataset_key)
2627
+ if prepared is not None:
2628
+ return prepared
2629
+ if getattr(self, "_database_access_mode", None) == TRUSTED_LIVE_MODE:
2630
+ handle = self._dataset_handle_for_key(dataset_key)
2631
+ if handle is not None:
2632
+ return handle.dataset
2633
+ raise RuntimeError("Trusted plot metadata must be requested asynchronously.")
2634
+
2635
+ try:
2636
+ load_kwargs = {}
2637
+ if dataset_key.database_identity is not None:
2638
+ load_kwargs["expected_database_identity"] = (
2639
+ dataset_key.database_identity
2640
+ )
2641
+ dataset = load_by_guid_read_only(
2642
+ dataset_key.guid,
2643
+ dataset_key.database_path,
2644
+ **load_kwargs,
2645
+ )
2646
+ except Exception:
2647
+ # A replacement can race the check above. The read layer rejects
2648
+ # that identity change without consuming its sidecar; this second
2649
+ # check starts MainWindow's replacement reload when applicable.
2650
+ self._ensure_dataset_key_can_be_read(dataset_key)
2651
+ raise
2652
+ try:
2653
+ self._ensure_dataset_key_can_be_read(dataset_key)
2654
+ except Exception:
2655
+ try:
2656
+ close_dataset_connection(dataset)
2657
+ finally:
2658
+ raise
2659
+ return dataset
2660
+
2661
+
2662
+ def post_admin(self):
2663
+ """
2664
+ Updates the plot windows' internal track of other open windows.
2665
+
2666
+ """
2667
+ for item in self.windows:
2668
+ if isinstance(item, plot1d):
2669
+ self.get_1d_wins(item)
2670
+
2671
+
2672
+ def get_1d_wins(self, win):
2673
+ """
2674
+ Finds compatible 1D plot windows for adding secondary traces to win.
2675
+
2676
+ """
2677
+ wins = []
2678
+
2679
+ for item in self.windows:
2680
+ try:
2681
+ compatible = _subplot_axis_order(
2682
+ win.axis_options,
2683
+ item.axis_options,
2684
+ source_is_cut=(
2685
+ getattr(item, "operation_kind", item.__class__.__name__)
2686
+ == "sweeper"
2687
+ ),
2688
+ shared_parameter=_subplot_shared_parameter(win),
2689
+ )
2690
+ if compatible is not None and not _plot_has_trace_window(win, item):
2691
+ wins.append(item)
2692
+ except (AttributeError, RuntimeError):
2693
+ continue
2694
+
2695
+ win.update_line_picker(wins)