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