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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +67 -0
- qplot/_build_info.json +1 -0
- qplot/_metadata.py +75 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +578 -0
- qplot/configuration/config_schema.json +357 -0
- qplot/configuration/fonts.py +3 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +592 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2340 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2566 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +148 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +876 -0
- qplot/datahandling/trusted_heatmap.py +246 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1919 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1804 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +123 -0
- qplot/tools/general.py +139 -0
- qplot/tools/heatmap_geometry.py +391 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +650 -0
- qplot/tools/sample_statistics.py +111 -0
- qplot/tools/worker.py +2838 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4933 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_display_precision.py +48 -0
- qplot/windows/_dragdrop.py +70 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +210 -0
- qplot/windows/_mixed_appearance.py +303 -0
- qplot/windows/_mixed_plots.py +461 -0
- qplot/windows/_native_averaging.py +323 -0
- qplot/windows/_native_transforms.py +551 -0
- qplot/windows/_plot1d_snap.py +612 -0
- qplot/windows/_plot1d_traces.py +2236 -0
- qplot/windows/_plot2d_colorbar.py +1127 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2291 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +2082 -0
- qplot/windows/_plot_actions.py +2703 -0
- qplot/windows/_plot_annotation.py +186 -0
- qplot/windows/_plot_appearance.py +283 -0
- qplot/windows/_plot_axis_scaling.py +1521 -0
- qplot/windows/_plot_data_viewbox.py +94 -0
- qplot/windows/_plot_drop_axes.py +224 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +112 -0
- qplot/windows/_plot_marquee.py +659 -0
- qplot/windows/_plot_refresh.py +950 -0
- qplot/windows/_plot_source_promotion.py +178 -0
- qplot/windows/_plot_state.py +237 -0
- qplot/windows/_plot_status.py +115 -0
- qplot/windows/_plot_tiling.py +35 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +724 -0
- qplot/windows/_refresh_interval.py +172 -0
- qplot/windows/_run_controls.py +789 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +532 -0
- qplot/windows/_subplots/subplot2d.py +919 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +414 -0
- qplot/windows/main.py +1632 -0
- qplot/windows/plot1d.py +313 -0
- qplot/windows/plot2d.py +1935 -0
- qplotter-1.6.0.dist-info/METADATA +322 -0
- qplotter-1.6.0.dist-info/RECORD +130 -0
- qplotter-1.6.0.dist-info/WHEEL +5 -0
- qplotter-1.6.0.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
- 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()
|