qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,608 @@
1
+ from dataclasses import dataclass
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ import numpy as np
5
+ import numpy.typing as npt
6
+ import pyqtgraph as pg
7
+ from PyQt6 import QtCore, QtGui
8
+ from PyQt6 import QtWidgets as qtw
9
+
10
+ from ._commands import command_spec, command_with_status, create_action
11
+ from ._native_transforms import NativePlotDataItem
12
+
13
+ if TYPE_CHECKING:
14
+ class _Plot1DSnapBase(qtw.QMainWindow):
15
+ toolbarCo_ord: qtw.QToolBar
16
+ snap_to_trace_action: QtGui.QAction | None
17
+ trace_label: qtw.QLabel | None
18
+ lines: dict[Any, Any]
19
+ pos_labels: dict[str, qtw.QLabel]
20
+ plot: Any
21
+ right_vb: Any
22
+ snap_marker: Any
23
+ _snap_marker_view: Any
24
+ ds: Any
25
+ param: Any
26
+
27
+ def initLabels(self) -> None: ...
28
+
29
+ def initMenu(self) -> None: ...
30
+
31
+ def mouseMoved(self, pos: object) -> None: ...
32
+
33
+ def register_shortcut(
34
+ self,
35
+ action: QtGui.QAction,
36
+ shortcut: object,
37
+ status_tip: str | None = None,
38
+ ) -> None: ...
39
+
40
+ @staticmethod
41
+ def formatNum(num: float, sf: int = 3) -> str: ...
42
+
43
+ def _set_cursor_index_label(self, text: str) -> None: ...
44
+ def _cursor_1d_x_data(self) -> Any: ...
45
+ else:
46
+ class _Plot1DSnapBase:
47
+ pass
48
+
49
+
50
+ SNAP_TO_TRACE_COMMAND = command_spec("plot.snap_to_trace")
51
+ SNAP_TO_TRACE_SHORTCUT_LABEL = SNAP_TO_TRACE_COMMAND.shortcut_display_text()
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class _SnapTraceSample:
56
+ x_value: float
57
+ y_value: float
58
+ point_number: int
59
+ x_view_value: float = float("nan")
60
+ y_view_value: float = float("nan")
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class _LineData:
65
+ x_view: npt.ArrayLike
66
+ y_view: npt.ArrayLike
67
+ x_raw: npt.ArrayLike
68
+ y_raw: npt.ArrayLike
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class _NearestTracePoint:
73
+ label: Any
74
+ x_value: float
75
+ y_value: float
76
+ viewbox: Any
77
+ point_number: int
78
+ x_view_value: float
79
+ y_view_value: float
80
+
81
+ def __iter__(self):
82
+ """Retain the historical five-value private-method unpacking API."""
83
+
84
+ yield self.label
85
+ yield self.x_value
86
+ yield self.y_value
87
+ yield self.viewbox
88
+ yield self.point_number
89
+
90
+
91
+ def _nearest_trace_sample(
92
+ x_data: npt.ArrayLike,
93
+ y_data: npt.ArrayLike,
94
+ cursor_x: float,
95
+ cursor_y: float | None = None,
96
+ ) -> _SnapTraceSample | None:
97
+ """
98
+ Return the finite plotted sample nearest to a cursor X coordinate.
99
+
100
+ """
101
+ x_data = np.asarray(x_data, dtype=float)
102
+ y_data = np.asarray(y_data, dtype=float)
103
+ count = min(x_data.size, y_data.size)
104
+ if count == 0:
105
+ return None
106
+
107
+ x_data = x_data[:count]
108
+ y_data = y_data[:count]
109
+ finite = np.isfinite(x_data) & np.isfinite(y_data)
110
+ if not np.any(finite):
111
+ return None
112
+
113
+ finite_indices = np.flatnonzero(finite)
114
+ x_values = x_data[finite]
115
+ y_values = y_data[finite]
116
+ if cursor_y is None:
117
+ distances = np.abs(x_values - cursor_x)
118
+ else:
119
+ distances = np.square(x_values - cursor_x) + np.square(y_values - cursor_y)
120
+ index = int(np.argmin(distances))
121
+
122
+ return _SnapTraceSample(
123
+ x_value=float(x_values[index]),
124
+ y_value=float(y_values[index]),
125
+ point_number=int(finite_indices[index]) + 1,
126
+ )
127
+
128
+
129
+ def _line_is_snap_visible(line: object) -> bool:
130
+ """
131
+ Return whether a plotted line should participate in snap selection.
132
+
133
+ """
134
+ is_visible = getattr(line, "isVisible", None)
135
+ if callable(is_visible):
136
+ return bool(is_visible())
137
+ return True
138
+
139
+
140
+ def _line_snap_data(line: object | None) -> _LineData | None:
141
+ """
142
+ Return full trace samples with aligned physical and view coordinates.
143
+
144
+ Downsampling, clipping and dynamic range limiting only change rendering.
145
+ Neither display positions nor peak/mean aggregates identify raw samples.
146
+ Apply native processing before log mapping, keeping the resulting arrays
147
+ aligned for snapping and selection statistics.
148
+
149
+ """
150
+ if line is None or not _line_is_snap_visible(line):
151
+ return None
152
+
153
+ get_data = getattr(line, "getData", None)
154
+ if not callable(get_data):
155
+ return None
156
+
157
+ view_data = get_data()
158
+ if view_data is None or view_data[0] is None or view_data[1] is None:
159
+ return None
160
+
161
+ get_original = getattr(line, "getOriginalDataset", None)
162
+ raw_data = get_original() if callable(get_original) else view_data
163
+ if raw_data is None or raw_data[0] is None or raw_data[1] is None:
164
+ raw_data = view_data
165
+ if callable(get_original):
166
+ opts = getattr(line, "opts", {})
167
+ processing = {
168
+ key: opts.get(key, False)
169
+ for key in ("subtractMeanMode", "fftMode", "derivativeMode", "phasemapMode")
170
+ }
171
+ if any(processing.values()):
172
+ # Share the rendering mapping, without display reduction.
173
+ analysis_line = NativePlotDataItem(
174
+ x=raw_data[0], y=raw_data[1], **processing,
175
+ )
176
+ raw_data = analysis_line.getData()
177
+ if raw_data[0] is None or raw_data[1] is None:
178
+ return None
179
+ # Native log-X drops DC within the FFT stage. Phase map runs later
180
+ # and replaces both coordinates with full input-derived samples,
181
+ # so that FFT slice must not remove a valid phase-map point here.
182
+ if (processing["fftMode"] and not processing["phasemapMode"]
183
+ and opts.get("logMode", (False, False))[0]):
184
+ raw_data = (raw_data[0][1:], raw_data[1][1:])
185
+ view_data = tuple(np.asarray(values, dtype=float) for values in raw_data)
186
+ log_mode = opts.get("logMode", (False, False))
187
+ mapped = []
188
+ for values, logarithmic in zip(view_data, log_mode, strict=True):
189
+ if logarithmic:
190
+ with np.errstate(divide="ignore", invalid="ignore"):
191
+ values = np.log10(values)
192
+ values[~np.isfinite(values)] = np.nan
193
+ mapped.append(values)
194
+ view_data = tuple(mapped)
195
+ return _LineData(
196
+ x_view=view_data[0],
197
+ y_view=view_data[1],
198
+ x_raw=raw_data[0],
199
+ y_raw=raw_data[1],
200
+ )
201
+
202
+
203
+ def _scene_distance_squared(
204
+ first: QtCore.QPointF,
205
+ second: QtCore.QPointF,
206
+ ) -> float:
207
+ """
208
+ Return squared distance between two scene points.
209
+
210
+ """
211
+ return (first.x() - second.x()) ** 2 + (first.y() - second.y()) ** 2
212
+
213
+
214
+ class Plot1DSnapMixin(_Plot1DSnapBase):
215
+ """Snap-to-trace cursor readout for 1D plot windows."""
216
+
217
+ def initLabels(self):
218
+ """
219
+ Sets up coordinate labels and trace snapping command for 1d plots.
220
+
221
+ """
222
+ super().initLabels()
223
+
224
+ self.trace_label = qtw.QLabel("")
225
+ self.trace_label.setMinimumWidth(0)
226
+ self.toolbarCo_ord.addWidget(self.trace_label)
227
+ self._update_coordinate_context()
228
+
229
+ self.snap_to_trace_action = create_action(
230
+ SNAP_TO_TRACE_COMMAND,
231
+ self,
232
+ text=f"Snap to Trace ({SNAP_TO_TRACE_SHORTCUT_LABEL})",
233
+ status_tip="Toggle snap-to-trace cursor readout",
234
+ checkable=True,
235
+ )
236
+ self.snap_to_trace_action.setToolTip(
237
+ "Lock the coordinate readout to the nearest plotted data point"
238
+ )
239
+ self.register_shortcut(
240
+ self.snap_to_trace_action,
241
+ command_with_status(
242
+ "plot.snap_to_trace",
243
+ "Toggle snap-to-trace cursor readout",
244
+ )
245
+ )
246
+ self.snap_to_trace_action.toggled.connect(self._snap_to_trace_toggled)
247
+
248
+
249
+ def initMenu(self):
250
+ """
251
+ Adds 1d-specific commands to the plot window menu bar.
252
+
253
+ """
254
+ super().initMenu()
255
+
256
+ view_menu = self._menu_by_title("&View")
257
+ if view_menu is None or self.snap_to_trace_action is None:
258
+ return
259
+
260
+ actions = view_menu.actions()
261
+ before = actions[0] if actions else None
262
+ view_menu.insertAction(before, self.snap_to_trace_action)
263
+ view_menu.insertSeparator(before)
264
+
265
+
266
+ def _menu_by_title(self, title):
267
+ """
268
+ Returns the menu matching a top-level menu title.
269
+
270
+ """
271
+ menu_bar = self.menuBar()
272
+ if menu_bar is None:
273
+ return None
274
+
275
+ for action in menu_bar.actions():
276
+ if action.text() == title:
277
+ return action.menu()
278
+ return None
279
+
280
+
281
+ @QtCore.pyqtSlot(bool)
282
+ def _snap_to_trace_toggled(self, enabled):
283
+ """
284
+ Handles the snap-to-trace toggle state.
285
+
286
+ """
287
+ if not enabled:
288
+ self._hide_snap_marker()
289
+ self._clear_snap_report()
290
+
291
+
292
+ @QtCore.pyqtSlot(object)
293
+ def mouseMoved(self, pos):
294
+ """
295
+ Updates the coordinate readout, optionally snapping to a 1d trace.
296
+
297
+ """
298
+ if not (
299
+ self.snap_to_trace_action is not None
300
+ and self.snap_to_trace_action.isChecked()
301
+ ):
302
+ super().mouseMoved(pos)
303
+ return
304
+
305
+ if not self.plot.sceneBoundingRect().contains(pos):
306
+ self._hide_snap_marker()
307
+ self._clear_snap_report()
308
+ self._set_cursor_index_label("")
309
+ return
310
+
311
+ nearest = self._nearest_trace_point(pos)
312
+ if nearest is None:
313
+ self._hide_snap_marker()
314
+ self._clear_snap_report()
315
+ self._set_cursor_index_label("")
316
+ return
317
+
318
+ self._set_cursor_index_label(f"[{nearest.point_number - 1}]")
319
+ self.pos_labels["x"].setText(f"x = {self.formatNum(nearest.x_value)};")
320
+ self.pos_labels["y"].setText(f"y = {self.formatNum(nearest.y_value)}")
321
+ self._show_snap_report(nearest.label, nearest.point_number)
322
+ self._show_snap_marker(
323
+ nearest.x_view_value,
324
+ nearest.y_view_value,
325
+ nearest.viewbox,
326
+ )
327
+
328
+
329
+ def _show_snap_report(self, label, point_number):
330
+ """
331
+ Shows the active run, plotted axes, and snapped point.
332
+
333
+ """
334
+ self._update_coordinate_context(label, point_number)
335
+
336
+
337
+ def _clear_snap_report(self):
338
+ """
339
+ Restores the persistent run and axis relationship message.
340
+
341
+ """
342
+ self._update_coordinate_context()
343
+
344
+
345
+ def _update_coordinate_context(self, label=None, point_number=None):
346
+ """Show ``Run N: measurement vs setpoint`` and optional snap detail."""
347
+
348
+ if self.trace_label is None:
349
+ return
350
+
351
+ run_id, vertical, horizontal, tooltip = self._coordinate_context_parts(label)
352
+ message = f"Run {run_id}: {vertical} vs {horizontal}"
353
+ if point_number is not None:
354
+ message += f" (snapped to point {point_number - 1})"
355
+ self.trace_label.setText(message)
356
+ self.trace_label.setToolTip(tooltip)
357
+ self.trace_label.adjustSize()
358
+ self.trace_label.updateGeometry()
359
+ self.toolbarCo_ord.updateGeometry()
360
+
361
+
362
+ def _coordinate_context_parts(self, label=None):
363
+ """Return run and human-readable plotted-axis names for the message."""
364
+
365
+ line = self.lines.get(label) if label is not None else self.__dict__.get("line")
366
+ source = getattr(line, "from_win", self)
367
+ try:
368
+ dataset = source.ds
369
+ except (AttributeError, RuntimeError):
370
+ dataset = None
371
+ run_id = getattr(dataset, "run_id", "?")
372
+
373
+ host_options = self.__dict__.get("_axis_selection", {})
374
+ if not host_options:
375
+ try:
376
+ host_options = self.axis_options
377
+ except RuntimeError:
378
+ host_options = {}
379
+ horizontal_source = self
380
+ horizontal_axis = "x"
381
+ source_axis = "y"
382
+ if source is not self:
383
+ axis_order = getattr(line, "choose_from", None)
384
+ if axis_order is not None and len(axis_order) == 2:
385
+ horizontal_axis = axis_order[0]
386
+ source_axis = axis_order[1]
387
+ horizontal_source = source
388
+ source_options = (
389
+ host_options
390
+ if source is self
391
+ else getattr(source, "axis_options", {})
392
+ )
393
+ horizontal_name = source_options.get(horizontal_axis)
394
+ if horizontal_name is None:
395
+ horizontal_name = host_options.get("x", "x")
396
+ horizontal_source = self
397
+ horizontal_axis = "x"
398
+ horizontal = self._parameter_display_name(
399
+ horizontal_source,
400
+ horizontal_name,
401
+ horizontal_axis,
402
+ )
403
+ vertical_name = source_options.get(source_axis)
404
+ if vertical_name is None:
405
+ try:
406
+ source_param = source.param
407
+ except (AttributeError, RuntimeError):
408
+ source_param = None
409
+ vertical_name = getattr(source_param, "name", "y")
410
+ vertical = self._parameter_display_name(
411
+ source,
412
+ vertical_name,
413
+ source_axis,
414
+ )
415
+
416
+ display_label = getattr(self, "_trace_display_label", None)
417
+ if label is not None and callable(display_label):
418
+ tooltip = str(display_label(label, line))
419
+ else:
420
+ tooltip = str(getattr(source, "label", ""))
421
+ return run_id, vertical, horizontal, tooltip
422
+
423
+
424
+ @staticmethod
425
+ def _parameter_display_name(source, name, axis):
426
+ """Resolve a parameter label without discarding a useful name."""
427
+
428
+ param = getattr(source, "param_dict", {}).get(name)
429
+ if param is None:
430
+ axis_param = getattr(source, "axis_param", {})
431
+ candidate = axis_param.get(axis) if isinstance(axis_param, dict) else None
432
+ if getattr(candidate, "name", name) == name:
433
+ param = candidate
434
+ if param is None:
435
+ candidate = getattr(source, "param", None)
436
+ if getattr(candidate, "name", None) == name:
437
+ param = candidate
438
+ return str(getattr(param, "label", None) or getattr(param, "name", None) or name)
439
+
440
+ def _nearest_trace_point(self, scene_pos):
441
+ """
442
+ Finds the plotted data point nearest to the mouse position.
443
+
444
+ """
445
+ nearest = None
446
+ nearest_distance = None
447
+
448
+ for label, line in self.lines.items():
449
+ data = _line_snap_data(line)
450
+ if data is None:
451
+ continue
452
+
453
+ viewbox = self._viewbox_for_line(line)
454
+ sample, distance = self._nearest_scene_trace_sample(
455
+ data.x_view,
456
+ data.y_view,
457
+ scene_pos,
458
+ viewbox,
459
+ data.x_raw,
460
+ data.y_raw,
461
+ )
462
+ if sample is None:
463
+ continue
464
+
465
+ if nearest_distance is None or distance < nearest_distance:
466
+ nearest = _NearestTracePoint(
467
+ label=label,
468
+ x_value=sample.x_value,
469
+ y_value=sample.y_value,
470
+ viewbox=viewbox,
471
+ point_number=sample.point_number,
472
+ x_view_value=sample.x_view_value,
473
+ y_view_value=sample.y_view_value,
474
+ )
475
+ nearest_distance = distance
476
+
477
+ return nearest
478
+
479
+
480
+ @staticmethod
481
+ def _nearest_scene_trace_sample(
482
+ x_data,
483
+ y_data,
484
+ scene_pos,
485
+ viewbox,
486
+ raw_x_data=None,
487
+ raw_y_data=None,
488
+ ):
489
+ """Return the nearest full trace sample with aligned physical values."""
490
+
491
+ x_data = np.asarray(x_data, dtype=float)
492
+ y_data = np.asarray(y_data, dtype=float)
493
+ if raw_x_data is None:
494
+ raw_x_data = x_data
495
+ if raw_y_data is None:
496
+ raw_y_data = y_data
497
+ raw_x_data = np.asarray(raw_x_data, dtype=float)
498
+ raw_y_data = np.asarray(raw_y_data, dtype=float)
499
+ count = min(
500
+ x_data.size,
501
+ y_data.size,
502
+ raw_x_data.size,
503
+ raw_y_data.size,
504
+ )
505
+ if count == 0:
506
+ return None, None
507
+
508
+ x_data = x_data[:count]
509
+ y_data = y_data[:count]
510
+ raw_x_data = raw_x_data[:count]
511
+ raw_y_data = raw_y_data[:count]
512
+ finite = (
513
+ np.isfinite(x_data)
514
+ & np.isfinite(y_data)
515
+ & np.isfinite(raw_x_data)
516
+ & np.isfinite(raw_y_data)
517
+ )
518
+ if not np.any(finite):
519
+ return None, None
520
+
521
+ finite_indices = np.flatnonzero(finite)
522
+ x_values = x_data[finite]
523
+ y_values = y_data[finite]
524
+ raw_x_values = raw_x_data[finite]
525
+ raw_y_values = raw_y_data[finite]
526
+ # Read the affine coefficients directly: subtracting mapped unit
527
+ # points loses precision when a narrow sweep is far from zero.
528
+ # childTransform also updates the matrix after a range/geometry change.
529
+ transform = viewbox.childTransform() * viewbox.sceneTransform()
530
+ origin = viewbox.mapSceneToView(scene_pos)
531
+ scene_origin = viewbox.mapViewToScene(origin)
532
+ x_offsets = x_values - origin.x()
533
+ y_offsets = y_values - origin.y()
534
+ # Work near the pointer so neither the large absolute data coordinates
535
+ # nor the transform's large translation enter the vectorized mapping.
536
+ scene_dx = (
537
+ (scene_origin.x() - scene_pos.x())
538
+ + x_offsets * transform.m11()
539
+ + y_offsets * transform.m21()
540
+ )
541
+ scene_dy = (
542
+ (scene_origin.y() - scene_pos.y())
543
+ + x_offsets * transform.m12()
544
+ + y_offsets * transform.m22()
545
+ )
546
+ distances = np.square(scene_dx) + np.square(scene_dy)
547
+ index = int(np.argmin(distances))
548
+ sample = _SnapTraceSample(
549
+ x_value=float(raw_x_values[index]),
550
+ y_value=float(raw_y_values[index]),
551
+ point_number=int(finite_indices[index]) + 1,
552
+ x_view_value=float(x_values[index]),
553
+ y_view_value=float(y_values[index]),
554
+ )
555
+ return sample, float(distances[index])
556
+
557
+
558
+ def _viewbox_for_line(self, line):
559
+ """
560
+ Returns the viewbox that owns a plotted line.
561
+
562
+ """
563
+ get_viewbox = getattr(line, "getViewBox", None)
564
+ if callable(get_viewbox):
565
+ viewbox = get_viewbox()
566
+ if viewbox is not None:
567
+ return viewbox
568
+ if getattr(line, "side", "left") == "right" and self.right_vb is not None:
569
+ return self.right_vb
570
+
571
+ return self.plot.vb
572
+
573
+
574
+ def _show_snap_marker(self, x_value, y_value, viewbox):
575
+ """
576
+ Places a small marker on the snapped data point.
577
+
578
+ """
579
+ if self.snap_marker is None:
580
+ self.snap_marker = pg.ScatterPlotItem(
581
+ symbol="s",
582
+ size=3,
583
+ pen=pg.mkPen("k", width=1),
584
+ brush=pg.mkBrush("w"),
585
+ )
586
+ # The marker follows the pointer and is often directly over the
587
+ # trace. Keep it behind plot lines so the line receives a
588
+ # double-click before the marker can consume it.
589
+ self.snap_marker.setZValue(-1)
590
+
591
+ if self._snap_marker_view is not viewbox:
592
+ self._hide_snap_marker()
593
+ viewbox.addItem(self.snap_marker, ignoreBounds=True)
594
+ self._snap_marker_view = viewbox
595
+
596
+ self.snap_marker.setData([x_value], [y_value])
597
+
598
+
599
+ def _hide_snap_marker(self):
600
+ """
601
+ Removes the snap marker from whichever viewbox currently owns it.
602
+
603
+ """
604
+ if self.snap_marker is None or self._snap_marker_view is None:
605
+ return
606
+
607
+ self._snap_marker_view.removeItem(self.snap_marker)
608
+ self._snap_marker_view = None