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,551 @@
1
+ """qPlot's coordinate-safe mapping for PyQtGraph's native line controls."""
2
+
3
+ from fractions import Fraction
4
+ from typing import cast
5
+
6
+ import numpy as np
7
+ import pyqtgraph as pg
8
+ from PyQt6 import QtCore
9
+ from pyqtgraph.graphicsItems.PlotDataItem import PlotDataset
10
+
11
+ from qplot.datahandling.parameter_data import numeric_isfinite
12
+ from qplot.tools.plot_tools import (
13
+ _center_float_samples,
14
+ _fraction_as_float,
15
+ _integer_differences,
16
+ _integer_samples,
17
+ )
18
+ from qplot.tools.sample_statistics import finite_mean, sample_fraction
19
+
20
+ from ._display_precision import (
21
+ DISPLAY_PRECISION_MESSAGE,
22
+ float_display_loses_distinctions,
23
+ )
24
+
25
+ LINE_DISPLAY_RANGE_MESSAGE = (
26
+ "The line coordinate range cannot be displayed safely. "
27
+ "Recorded samples remain available for CSV export and processing."
28
+ )
29
+
30
+
31
+ def _safe_difference(values: np.ndarray) -> np.ndarray:
32
+ """Subtract samples before narrowing arithmetic can overflow or wrap."""
33
+ if values.dtype.kind == "b":
34
+ # NumPy's Boolean diff reports changes, losing their direction.
35
+ return np.diff(values.astype(np.int8)).astype(np.float64)
36
+ if values.dtype.kind in "iu":
37
+ # Narrow integers fit in int64 even after subtraction. For 64-bit
38
+ # integers, Python integers cover the full signed/unsigned difference
39
+ # range. Converting samples to float first would erase adjacent steps
40
+ # above 2**53; casting uint64 to int64 would wrap at the signed boundary.
41
+ dtype = np.int64 if values.dtype.itemsize < 8 else object
42
+ return np.diff(values.astype(dtype)).astype(np.float64)
43
+ if values.dtype.kind == "O":
44
+ # Limit operations retain integers alongside fractional clipped cells.
45
+ # Subtract adjacent integer cells before their float view is made.
46
+ return _integer_differences(values)
47
+ if values.dtype.kind == "f" and values.dtype.itemsize < 8:
48
+ # np.diff retains float16/float32, so even finite operands can
49
+ # overflow before the derivative divides or FFT validates the span.
50
+ return np.diff(values.astype(np.float64))
51
+ return np.diff(values)
52
+
53
+
54
+ def _center_integer_samples(values: np.ndarray) -> np.ndarray:
55
+ """Center integer samples before float conversion can erase their steps."""
56
+ if not len(values):
57
+ return np.array([], dtype=np.float64)
58
+ # Python integers avoid both uint64 wraparound and int64 overflow. Using
59
+ # the exact numerator also retains fractional means above float's integer
60
+ # precision (for example, four adjacent samples near 2**63).
61
+ total = sum(map(int, values))
62
+ count = len(values)
63
+ return np.fromiter(
64
+ ((count * int(value) - total) / count for value in values),
65
+ dtype=np.float64, count=count,
66
+ )
67
+
68
+
69
+ def _center_object_samples(values: np.ndarray) -> np.ndarray:
70
+ """Keep integer steps when a limit leaves mixed integer/float cells."""
71
+ numeric = values.astype(np.float64)
72
+ if not np.all(np.isfinite(numeric)):
73
+ return numeric - np.mean(numeric)
74
+ exact = [sample_fraction(value) for value in values]
75
+ mean = sum(exact) / len(exact)
76
+ return np.fromiter(
77
+ (_fraction_as_float(value - mean) for value in exact),
78
+ dtype=np.float64, count=len(exact),
79
+ )
80
+
81
+
82
+ def _safe_secant(x: np.ndarray, y: np.ndarray) -> np.ndarray:
83
+ """Recover finite quotients when either finite difference overflows."""
84
+ with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
85
+ numerator, denominator = _safe_difference(y), _safe_difference(x)
86
+ result = numerator / denominator
87
+ mixed_objects = ((x.dtype.kind == "O" and not _integer_samples(x))
88
+ or (y.dtype.kind == "O" and not _integer_samples(y)))
89
+ repair = ((~np.isfinite(numerator) | ~np.isfinite(denominator) | mixed_objects)
90
+ & numeric_isfinite(x[:-1]) & numeric_isfinite(x[1:])
91
+ & numeric_isfinite(y[:-1]) & numeric_isfinite(y[1:]))
92
+ for index in np.flatnonzero(repair):
93
+ # This is a rare fallback. Exact fractions avoid both difference
94
+ # overflow and underflow while dividing by a huge coordinate span.
95
+ cells = [value.item() if isinstance(value, np.generic) else value
96
+ for value in (x[index], x[index + 1], y[index], y[index + 1])]
97
+ left_x, right_x, left_y, right_y = map(sample_fraction, cells)
98
+ distance = right_x - left_x
99
+ if distance:
100
+ quotient = (right_y - left_y) / distance
101
+ try:
102
+ result[index] = float(quotient)
103
+ except OverflowError:
104
+ result[index] = np.inf if quotient > 0 else -np.inf
105
+ return result
106
+
107
+
108
+ class _FFTCoordinatesError(ValueError):
109
+ """Coordinates cannot define a native coordinate-domain FFT."""
110
+
111
+
112
+ def _interpolate_fft_samples(targets, coordinates, samples):
113
+ """Repair only interpolation segments whose finite difference overflows."""
114
+ with np.errstate(over="ignore", invalid="ignore"):
115
+ result = np.interp(targets, coordinates, samples)
116
+ differences = np.diff(samples)
117
+ segments = np.searchsorted(coordinates, targets, side="right") - 1
118
+ segments = np.clip(segments, 0, len(coordinates) - 2)
119
+ repair = (~np.isfinite(differences[segments])
120
+ & np.isfinite(samples[segments]) & np.isfinite(samples[segments + 1]))
121
+ for index in np.flatnonzero(repair):
122
+ segment = segments[index]
123
+ left, right, target = map(Fraction, (
124
+ float(coordinates[segment]), float(coordinates[segment + 1]), float(targets[index]),
125
+ ))
126
+ before, after = map(Fraction, (float(samples[segment]), float(samples[segment + 1])))
127
+ result[index] = float(before + (after - before) * (target - left) / (right - left))
128
+ return result
129
+
130
+
131
+ def _normalized_real_fft(samples):
132
+ """Bound FFT sums with binary scaling without erasing small source cells."""
133
+ count = len(samples)
134
+ if not np.all(np.isfinite(samples)):
135
+ return np.fft.rfft(samples) / count
136
+ largest = float(np.max(np.abs(samples)))
137
+ if largest <= np.finfo(float).max / count:
138
+ return np.fft.rfft(samples) / count
139
+ # Each butterfly is bounded by the sum of absolute source amplitudes.
140
+ # A length-sized binary divisor bounds that sum and retains normal small
141
+ # samples exactly; dividing by the largest sample can erase whole signals.
142
+ shift = (count - 1).bit_length()
143
+ scaled = np.ldexp(samples, -shift)
144
+ # Binary scaling may still round subnormal source cells. Transform just
145
+ # that tiny tail separately, with exact binary normalization in its range.
146
+ tail = (samples != 0) & (np.abs(scaled) < np.finfo(float).tiny)
147
+ if np.any(tail):
148
+ scaled[tail] = 0.
149
+ spectrum: np.ndarray = np.fft.rfft(scaled) / count
150
+ # Roundoff at the largest representable source must not turn bounded
151
+ # components into infinity while their binary units are restored.
152
+ bound = np.ldexp(largest, -shift)
153
+ spectrum = (np.ldexp(np.clip(spectrum.real, -bound, bound), shift)
154
+ + 1j * np.ldexp(np.clip(spectrum.imag, -bound, bound), shift))
155
+ if np.any(tail):
156
+ small = np.where(tail, samples, 0.)
157
+ tail_shift = -int(np.frexp(np.max(np.abs(small)))[1])
158
+ tail_spectrum = np.fft.rfft(np.ldexp(small, tail_shift)) / count
159
+ spectrum += (np.ldexp(tail_spectrum.real, -tail_shift)
160
+ + 1j * np.ldexp(tail_spectrum.imag, -tail_shift))
161
+ return spectrum
162
+
163
+
164
+ def _prepare_fft_coordinates(x: np.ndarray) -> tuple[np.ndarray, np.ndarray, float, bool]:
165
+ """Return increasing interpolation coordinates and the frequency step."""
166
+ message = "FFT requires finite, strictly monotonic coordinates with a nonzero span."
167
+ if x.dtype.kind == "b":
168
+ x = x.astype(np.uint8)
169
+ elif x.dtype.kind == "f" and x.dtype.itemsize < 8:
170
+ # Widen the coordinates too: x[-1] - x[0], interpolation, and the
171
+ # uniformity check must all operate beyond the source dtype's range.
172
+ x = x.astype(np.float64)
173
+ with np.errstate(over="ignore", invalid="ignore"):
174
+ dx = _safe_difference(x)
175
+ if not np.all(numeric_isfinite(x)) or len(x) == 0:
176
+ raise _FFTCoordinatesError(message)
177
+ # Retain PyQtGraph's supported one-sample DC spectrum.
178
+ if len(x) == 1:
179
+ return x, dx, 1., False
180
+ reverse = bool(np.all(dx < 0))
181
+ if reverse:
182
+ x, dx = x[::-1], -dx[::-1]
183
+ elif not np.all(dx > 0):
184
+ raise _FFTCoordinatesError(message)
185
+ coordinate_scale = 1.
186
+ if x.dtype.kind in "iuO":
187
+ # Rebase before float conversion to retain nearby steps above 2**53
188
+ # without unsigned/signed overflow.
189
+ exact: np.ndarray = x.astype(object)
190
+ if x.dtype.kind == "O" and not _integer_samples(x):
191
+ exact = cast(np.ndarray, np.frompyfunc(sample_fraction, 1, 1)(exact))
192
+ with np.errstate(over="ignore", invalid="ignore"):
193
+ try:
194
+ rebased = (exact - exact[0]).astype(np.float64)
195
+ except (TypeError, OverflowError):
196
+ exact = cast(np.ndarray, np.frompyfunc(sample_fraction, 1, 1)(exact))
197
+ rebased = np.fromiter(
198
+ (_fraction_as_float(value - exact[0]) for value in exact),
199
+ dtype=np.float64, count=len(exact),
200
+ )
201
+ if np.all(np.isfinite(rebased)):
202
+ x = rebased
203
+ else:
204
+ # Mixed float/integer coordinate records also use object cells.
205
+ # Their finite span can exceed float64 before rebasing finishes.
206
+ # Scale exact differences before converting them to float.
207
+ coordinate_scale = float(np.max(np.abs(x.astype(np.float64))))
208
+ anchor = sample_fraction(exact[0])
209
+ scale = Fraction(coordinate_scale)
210
+ x = np.fromiter(
211
+ (float((sample_fraction(value) - anchor) / scale) for value in exact),
212
+ dtype=np.float64, count=len(exact),
213
+ )
214
+ dx = np.diff(x)
215
+ with np.errstate(over="ignore", invalid="ignore"):
216
+ span = float(x[-1] - x[0])
217
+ if not np.isfinite(span):
218
+ # A finite sweep may cross zero over more than float64's range.
219
+ # Interpolate on scaled coordinates and restore units in frequency.
220
+ coordinate_scale = float(np.max(np.abs(x)))
221
+ x = x / coordinate_scale
222
+ dx = np.diff(x)
223
+ with np.errstate(over="ignore", invalid="ignore"):
224
+ spacing = float(x[-1] - x[0]) / (len(x) - 1)
225
+ if not (np.isfinite(spacing) and spacing > 0 and np.all(np.diff(x) > 0)):
226
+ raise _FFTCoordinatesError(message)
227
+ period = spacing * len(x)
228
+ frequency_step = ((1. / period) if np.isfinite(period)
229
+ else (1. / spacing) / len(x)) / coordinate_scale
230
+ # Finite acquired coordinates can imply an unrepresentable reciprocal
231
+ # axis. Reject before multiplying the bins (including 0 * infinity).
232
+ if (not np.isfinite(frequency_step) or frequency_step <= 0
233
+ or not np.isfinite(frequency_step * (len(x) // 2))):
234
+ raise _FFTCoordinatesError("FFT requires finite, representable frequencies.")
235
+ return x, dx, frequency_step, reverse
236
+
237
+
238
+ def native_fft_coordinate_error(x: np.ndarray | None) -> str | None:
239
+ """Validate populated traces; pending or empty curves have no spectrum."""
240
+ if x is None or len(x) == 0:
241
+ return None
242
+ try:
243
+ _prepare_fft_coordinates(x)
244
+ except _FFTCoordinatesError as error:
245
+ return str(error)
246
+ return None
247
+
248
+
249
+ class NativePlotDataItem(pg.PlotDataItem):
250
+ """Keep acquisition order while preparing native derivatives and FFTs."""
251
+
252
+ sigDisplayChanged = QtCore.pyqtSignal(object)
253
+ _datasetMapped: PlotDataset | None
254
+ _qplot_fft_error: str | None = None
255
+ _qplot_precision_error: str | None = None
256
+
257
+ def clear(self):
258
+ self._qplot_range_dataset = None
259
+ super().clear()
260
+
261
+ def axisDataBounds(self, ax, frac=1.0, orthoRange=None):
262
+ """Keep rejected mapped bounds available to qPlot's axis preflight."""
263
+ if self._qplot_precision_error != LINE_DISPLAY_RANGE_MESSAGE or self._datasetMapped is None:
264
+ return self.dataBounds(ax, frac, orthoRange)
265
+ # Hidden painting children report no bounds. Measure the processed
266
+ # samples in an unattached item so autoscale can reject the actual
267
+ # range without installing unsafe data on a scene renderer.
268
+ mapped = self._datasetMapped
269
+ bounds_item = pg.PlotCurveItem(
270
+ x=mapped.x, y=mapped.y, pen=self.opts["pen"],
271
+ shadowPen=self.opts["shadowPen"], fillLevel=self.opts["fillLevel"],
272
+ stepMode=self.opts["stepMode"],
273
+ )
274
+ return bounds_item.dataBounds(ax, frac, orthoRange)
275
+
276
+ def viewChanged(self, view, oldView):
277
+ super().viewChanged(view, oldView)
278
+ self._watch_display_range(view if isinstance(view, pg.ViewBox) else None)
279
+
280
+ def _watch_display_range(self, viewbox):
281
+ prior = getattr(self, "_qplot_range_viewbox", None)
282
+ if prior is viewbox:
283
+ return
284
+ if prior is not None:
285
+ try:
286
+ prior.sigTransformChanged.disconnect(self._schedule_display_range_check)
287
+ except (RuntimeError, TypeError):
288
+ pass
289
+ self._qplot_range_viewbox = viewbox
290
+ timer = getattr(self, "_qplot_range_timer", None)
291
+ if timer is not None:
292
+ timer.stop()
293
+ self._qplot_view_range_rejected = False
294
+ if viewbox is not None:
295
+ if not hasattr(self, "_qplot_range_timer"):
296
+ self._qplot_range_timer = QtCore.QTimer(self)
297
+ self._qplot_range_timer.setSingleShot(True)
298
+ self._qplot_range_timer.timeout.connect(self._check_display_range)
299
+ viewbox.sigTransformChanged.connect(self._schedule_display_range_check)
300
+ self._schedule_display_range_check()
301
+
302
+ def _schedule_display_range_check(self, *_args):
303
+ if not self._qplot_range_timer.isActive():
304
+ self._qplot_range_timer.start(0)
305
+
306
+ def _check_display_range(self):
307
+ # Mixed plots finish mirroring their axis owners after range signals.
308
+ # Inspect the composed drawing transform on the next event-loop turn.
309
+ if not isinstance(self.getViewBox(), pg.ViewBox):
310
+ return
311
+ transform = self.sceneTransform()
312
+ previous = self._qplot_precision_error
313
+ self._qplot_view_range_rejected = not bool(np.all(np.isfinite((
314
+ transform.m11(), transform.m12(), transform.m13(),
315
+ transform.m21(), transform.m22(), transform.m23(),
316
+ transform.m31(), transform.m32(), transform.m33(),
317
+ ))))
318
+ self._getDisplayDataset()
319
+ if previous != self._qplot_precision_error:
320
+ self.updateItems(styleUpdate=False)
321
+ self.sigDisplayChanged.emit(self)
322
+
323
+ def _mapped_range_is_displayable(self, dataset, viewbox):
324
+ # Cache extrema with the mapped dataset, not the viewport. Resizing
325
+ # and mouse movement must not repeatedly scan a large acquired trace.
326
+ if getattr(self, "_qplot_range_dataset", None) is not dataset:
327
+ x, y = dataset.x, dataset.y
328
+ if x.shape == y.shape:
329
+ paired = numeric_isfinite(x) & numeric_isfinite(y)
330
+ x, y = x[paired], y[paired]
331
+ span_values = []
332
+ for values in (x, y):
333
+ numeric = np.asarray(values, dtype=float)
334
+ finite = numeric[np.isfinite(numeric)]
335
+ span_values.append(float(np.max(finite)) - float(np.min(finite)) if finite.size else 0.)
336
+ self._qplot_range_spans = np.asarray(span_values)
337
+ self._qplot_range_dataset = dataset
338
+ spans = self._qplot_range_spans
339
+ pixels = np.array([max(1., viewbox.width()), max(1., viewbox.height())])
340
+ with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
341
+ return bool(np.all(np.isfinite(spans))
342
+ and np.all(np.isfinite(pixels[spans > 0] / spans[spans > 0])))
343
+
344
+ def viewRangeChanged(self, vb=None, ranges=None, changed=None):
345
+ super().viewRangeChanged(vb, ranges, changed)
346
+ self.sigDisplayChanged.emit(self)
347
+
348
+ def _precision_checked_samples(self, x, y):
349
+ self._qplot_precision_error = None
350
+ if x.dtype.kind not in "iuO" and y.dtype.kind not in "iuO":
351
+ return x, y
352
+ # Only paired finite samples can be drawn. Derivatives and phase maps
353
+ # can leave an exact coordinate opposite a missing slope; its rounded
354
+ # position must not reject unrelated finite plotted samples.
355
+ visible_x, visible_y = x, y
356
+ if x.shape == y.shape:
357
+ paired = numeric_isfinite(x) & numeric_isfinite(y)
358
+ for values, logarithmic in zip((x, y), self.opts["logMode"], strict=True):
359
+ if logarithmic:
360
+ # Log mapping also hides nonpositive samples. Compare only
361
+ # finite partners: Decimal NaN comparisons can raise.
362
+ paired[paired] &= values[paired] > 0
363
+ visible_x, visible_y = x[paired], y[paired]
364
+ # Centre-step plots supply N+1 edges and N values. Keep their existing
365
+ # conservative precision check without inventing sample pairings.
366
+ if (float_display_loses_distinctions(visible_x)
367
+ or float_display_loses_distinctions(visible_y)):
368
+ self._qplot_precision_error = DISPLAY_PRECISION_MESSAGE
369
+ return np.array([], dtype=float), np.array([], dtype=float)
370
+ return x, y
371
+
372
+ def _fourierTransform(self, x, y):
373
+ """Prepare increasing FFT coordinates without changing source data.
374
+
375
+ PyQtGraph's resampling assumes increasing coordinates. Reverse paired
376
+ samples only for strictly decreasing sweeps; sorting a reversing sweep
377
+ would merge different acquisition branches into a fictitious signal.
378
+ The window preflights all traces before enabling FFT. This defensive
379
+ empty mapping also keeps setData safe until its change signal lets the
380
+ window roll back FFT after a refresh introduces unsupported coordinates.
381
+ Mean subtraction has already run in the mapped dataset before this hook.
382
+ """
383
+ self._qplot_fft_error = None
384
+ try:
385
+ x, dx, frequency_step, reverse = _prepare_fft_coordinates(x)
386
+ except _FFTCoordinatesError as error:
387
+ self._qplot_fft_error = str(error)
388
+ return np.array([], dtype=float), np.array([], dtype=float)
389
+ if len(x) == 1:
390
+ # abs(minimum signed integer) wraps in the recorded dtype.
391
+ # The FFT spectrum uses floating magnitudes, including its DC bin.
392
+ return np.array([0.]), np.abs(y.astype(np.float64))
393
+ if reverse:
394
+ y = y[::-1]
395
+ if y.dtype.kind == "f" and y.dtype.itemsize < 8:
396
+ # NumPy's FFT retains float32 precision. Widen before its internal
397
+ # unnormalized sum can overflow, not just before normalization.
398
+ y = y.astype(np.float64)
399
+ dc_offset = None
400
+ if y.dtype.kind in "iu":
401
+ dc_offset = Fraction(sum(map(int, y)), len(y))
402
+ y = _center_integer_samples(y)
403
+ elif y.dtype.kind == "O":
404
+ numeric = y.astype(np.float64)
405
+ if (np.all(np.isfinite(numeric))
406
+ and np.max(np.abs(numeric)) <= np.finfo(float).max / 2.):
407
+ # FFT converts integer/object arrays to float. Remove their
408
+ # exact offset first so adjacent recorded integers survive;
409
+ # restore only DC after any nonuniform interpolation.
410
+ exact = [sample_fraction(value)
411
+ for value in y]
412
+ dc_offset = sum(exact, Fraction()) / len(exact)
413
+ y = np.array([float(value - dc_offset) for value in exact])
414
+ else:
415
+ y = numeric
416
+ # Keep the ordinary interpolation semantics: nonuniform sweeps have
417
+ # the mean of the resampled signal, not the acquired sample mean.
418
+ resampled = np.any(np.abs(dx - dx[0]) > abs(dx[0]) / 1000)
419
+ float_dc = None
420
+ if dc_offset is None and np.all(np.isfinite(y)) and not resampled:
421
+ # FFT accumulation can erase a measured cancellation residual.
422
+ # Compute DC before amplitude scaling can round that residual.
423
+ float_dc = finite_mean(y)
424
+ float_anchor = None
425
+ if dc_offset is None and y.dtype.kind == "f" and np.all(np.isfinite(y)):
426
+ # A large floating DC offset can leak into small AC bins during
427
+ # FFT accumulation. Nearby same-sign values satisfy Sterbenz's
428
+ # lemma: subtracting this recorded anchor retains every source
429
+ # difference exactly, including subnormals. Wider signals keep
430
+ # their existing scaling path so anchoring cannot erase samples.
431
+ anchor = float(y[0])
432
+ absolute = np.abs(y)
433
+ if (anchor != 0. and np.all(np.signbit(y) == np.signbit(anchor))
434
+ and np.min(absolute) >= abs(anchor) / 2.
435
+ and np.max(absolute) / 2. <= abs(anchor)):
436
+ float_anchor = Fraction(anchor)
437
+ y = y - anchor
438
+ # Match PyQtGraph's uniformity tolerance and normalized real FFT.
439
+ if resampled:
440
+ uniform_x = np.linspace(x[0], x[-1], len(x))
441
+ y = _interpolate_fft_samples(uniform_x, x, y)
442
+ if dc_offset is None and np.all(np.isfinite(y)):
443
+ if float_anchor is None:
444
+ float_dc = finite_mean(y)
445
+ else:
446
+ # Resampling changes the centered mean. Restore its sign
447
+ # and the exact anchor before rounding the resulting DC.
448
+ centered_mean = sum((Fraction(float(value)) for value in y), Fraction()) / len(y)
449
+ float_dc = float(float_anchor + centered_mean)
450
+ magnitudes = np.abs(_normalized_real_fft(y))
451
+ if np.all(np.isfinite(y)):
452
+ # The normalized magnitude cannot exceed the largest source cell.
453
+ # Retain that bound when hypot's final rounding reaches overflow.
454
+ magnitudes = np.minimum(magnitudes, np.max(np.abs(y)))
455
+ if dc_offset is not None:
456
+ # Resampling may change the centered mean. Restore its signed
457
+ # value before taking the magnitude, retaining the exact offset.
458
+ if resampled:
459
+ resampled_mean = sum((Fraction(float(value)) for value in y), Fraction()) / len(y)
460
+ dc_offset += resampled_mean
461
+ magnitudes[0] = abs(float(dc_offset))
462
+ elif float_dc is not None:
463
+ magnitudes[0] = abs(float_dc)
464
+ frequencies = np.arange(len(magnitudes), dtype=float) * frequency_step
465
+ return frequencies, magnitudes
466
+
467
+ def _getDisplayDataset(self) -> PlotDataset | None:
468
+ if self._datasetMapped is None:
469
+ # New data or processing can restore a representable range. Let
470
+ # native autorange consume those bounds before settled validation.
471
+ self._qplot_view_range_rejected = False
472
+ if self._qplot_precision_error == LINE_DISPLAY_RANGE_MESSAGE:
473
+ self._qplot_precision_error = None
474
+ dataset = self._get_mapped_display_dataset()
475
+ if getattr(self, "_qplot_range_dataset", None) is not self._datasetMapped:
476
+ self._qplot_range_dataset = None
477
+ viewbox = self.getViewBox()
478
+ if not isinstance(viewbox, pg.ViewBox):
479
+ viewbox = None
480
+ self._watch_display_range(viewbox)
481
+ if dataset is None or viewbox is None or self._qplot_precision_error:
482
+ return dataset
483
+ mapped = self._datasetMapped
484
+ if (getattr(self, "_qplot_view_range_rejected", False)
485
+ or (mapped is not None and not self._mapped_range_is_displayable(mapped, viewbox))):
486
+ self._qplot_precision_error = LINE_DISPLAY_RANGE_MESSAGE
487
+ return None
488
+ return dataset
489
+
490
+ def _get_mapped_display_dataset(self) -> PlotDataset | None:
491
+ # Isolate the private PyQtGraph mapping hook here. Seed only the mapped
492
+ # cache; upstream still owns log scaling, clipping, downsampling, curve
493
+ # updates and cache invalidation through its ordinary native setters.
494
+ source = self._dataset
495
+ if source is None:
496
+ self._qplot_precision_error = None
497
+ if source is not None and self._datasetMapped is None:
498
+ if self.opts["derivativeMode"] or self.opts["phasemapMode"]:
499
+ # Native derivative/phase mapping uses original samples,
500
+ # overriding mean subtraction and (in phase mode) FFT. Phase
501
+ # mode replaces both coordinates, so FFT/log-X must not
502
+ # discard its first sample.
503
+ x = source.y[:-1] if self.opts["phasemapMode"] else source.x[:-1]
504
+ y = _safe_secant(source.x, source.y)
505
+ elif self.opts["subtractMeanMode"] and source.y.dtype.kind in "iu":
506
+ x = source.x
507
+ y = _center_integer_samples(source.y)
508
+ if self.opts["fftMode"]:
509
+ x, y = self._fourierTransform(x, y)
510
+ if self.opts["logMode"][0]:
511
+ x, y = x[1:], y[1:]
512
+ elif self.opts["subtractMeanMode"] and source.y.dtype.kind == "f":
513
+ x = source.x
514
+ y = _center_float_samples(source.y)
515
+ if self.opts["fftMode"]:
516
+ x, y = self._fourierTransform(x, y)
517
+ if self.opts["logMode"][0]:
518
+ x, y = x[1:], y[1:]
519
+ elif source.y.dtype.kind == "O" or source.x.dtype.kind == "O":
520
+ # PyQtGraph's painter cannot call isfinite on object cells.
521
+ # Keep the original dataset exact for CSV/cursor access and
522
+ # map only the on-screen values to float.
523
+ x = source.x
524
+ y = source.y
525
+ if self.opts["subtractMeanMode"]:
526
+ y = (_center_object_samples(source.y) if source.y.dtype.kind == "O"
527
+ else _center_float_samples(y))
528
+ if self.opts["fftMode"]:
529
+ x, y = self._fourierTransform(x, y)
530
+ if self.opts["logMode"][0]:
531
+ x, y = x[1:], y[1:]
532
+ else:
533
+ # Upstream retains dense integer samples until QPainter
534
+ # converts them to doubles. Reject only conversions that
535
+ # merge distinct values, keeping originals for processing.
536
+ self._qplot_precision_error = None
537
+ if not self.opts["fftMode"]:
538
+ x, y = self._precision_checked_samples(source.x, source.y)
539
+ if self._qplot_precision_error:
540
+ self._datasetMapped = PlotDataset(x, y)
541
+ return super()._getDisplayDataset()
542
+ x, y = self._precision_checked_samples(x, y)
543
+ if x.dtype.kind == "O":
544
+ x = x.astype(np.float64)
545
+ if y.dtype.kind == "O":
546
+ y = y.astype(np.float64)
547
+ mapped = PlotDataset(x, y)
548
+ if True in self.opts["logMode"]:
549
+ mapped.applyLogMapping(self.opts["logMode"])
550
+ self._datasetMapped = mapped
551
+ return super()._getDisplayDataset()