Add native QCustomPlot renderer and shared plot adapters
This commit is contained in:
@@ -100,7 +100,10 @@ class PlotDragInteraction:
|
||||
self.viewport = self._press_viewport.transform(self.core,
|
||||
pan_x=delta.x() / rect.width() if not self.lock_x else 0)
|
||||
self._refresh_controls()
|
||||
self.update()
|
||||
if self._dragged is not None:
|
||||
getattr(self, "update_overlay", self.update)()
|
||||
else:
|
||||
self.update()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() != Qt.LeftButton:
|
||||
|
||||
@@ -157,8 +157,8 @@ class DualAxisPlot:
|
||||
self._navigation_zoom(factor, True, pos)
|
||||
event.accept()
|
||||
|
||||
def paint_axes_and_markers(self, painter):
|
||||
super().paint_axes_and_markers(painter)
|
||||
def paint_axes(self, painter):
|
||||
super().paint_axes(painter)
|
||||
if not self.right_axis_keys or not self.core:
|
||||
return
|
||||
paint_right_axis(painter, self.core, self.value_rect(), self.right_visible_bounds)
|
||||
@@ -188,7 +188,7 @@ class SingleAmplitudeAxis:
|
||||
rect.setWidth(max(1, rect.width() - 72))
|
||||
return rect
|
||||
|
||||
def paint_axes_and_markers(self, painter):
|
||||
super().paint_axes_and_markers(painter)
|
||||
def paint_axes(self, painter):
|
||||
super().paint_axes(painter)
|
||||
if self.core and not self.has_left_axis():
|
||||
paint_right_axis(painter, self.core, self.value_rect(), self.visible_bounds)
|
||||
|
||||
46
python/set_devices/qt_ports/plot_layers.py
Normal file
46
python/set_devices/qt_ports/plot_layers.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Queued redraws and cached static layers for Qt plots (Qt 5/6)."""
|
||||
try:
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtGui import QPixmap, QPainter
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QTimer, Qt
|
||||
from PySide2.QtGui import QPixmap, QPainter
|
||||
|
||||
class PlotLayers:
|
||||
def init_plot_layers(self):
|
||||
self._static_revision = 0
|
||||
self._static_plot_layer = None
|
||||
self._redraw_timer = QTimer(self)
|
||||
self._redraw_timer.setSingleShot(True)
|
||||
self._redraw_timer.timeout.connect(self._flush_plot_update)
|
||||
|
||||
def update(self, *args):
|
||||
if not hasattr(self, '_redraw_timer'):
|
||||
return super().update(*args)
|
||||
self._static_revision += 1
|
||||
self.update_overlay()
|
||||
|
||||
def update_overlay(self):
|
||||
if not hasattr(self, '_redraw_timer'):
|
||||
return super().update()
|
||||
if not self._redraw_timer.isActive():
|
||||
self._redraw_timer.start(0)
|
||||
|
||||
def _flush_plot_update(self):
|
||||
super().update()
|
||||
|
||||
def paint_static_layer(self, painter, draw):
|
||||
ratio = self.devicePixelRatioF()
|
||||
key = (self._static_revision, self.width(), self.height(), ratio,
|
||||
tuple(self.plot_rect().getRect()))
|
||||
if self._static_plot_layer is None or self._static_plot_layer[0] != key:
|
||||
image = QPixmap(round(self.width()*ratio), round(self.height()*ratio))
|
||||
image.setDevicePixelRatio(ratio)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
layer = QPainter(image)
|
||||
try:
|
||||
draw(layer)
|
||||
finally:
|
||||
layer.end()
|
||||
self._static_plot_layer = key, image
|
||||
painter.drawPixmap(0, 0, self._static_plot_layer[1])
|
||||
@@ -64,6 +64,7 @@ class SignalProcessingPanel(QWidget):
|
||||
self._revision = 0
|
||||
self._job_revision = 0
|
||||
self.preserve_on_view_change = False
|
||||
self.preserve_on_data_change = False
|
||||
self.export_path = lambda filename: filename
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -153,7 +154,7 @@ class SignalProcessingPanel(QWidget):
|
||||
# depend on samples/units, not the source curve's checkbox.
|
||||
source = replace(source, series=tuple(replace(series, visible=True)
|
||||
for series in source.series))
|
||||
if prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
|
||||
if self.preserve_on_data_change or prepare(source, request.series.key, request.method, request.output_count, request.degree) == request:
|
||||
retained.append(result)
|
||||
if len(retained) != len(self._results):
|
||||
self._results = retained
|
||||
|
||||
53
python/set_devices/qt_ports/plot_workers.py
Normal file
53
python/set_devices/qt_ports/plot_workers.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Reusable background workers for dense plots and viewport measurements."""
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QRunnable, Signal
|
||||
from set_devices.plot_decimation import pixel_envelope, viewport_samples
|
||||
from set_devices.oscilloscope import measurements
|
||||
|
||||
class EnvelopeSignals(QObject):
|
||||
ready = Signal(str, object, object)
|
||||
|
||||
|
||||
class EnvelopeJob(QRunnable):
|
||||
def __init__(self, key, geometry, timestamps, values, left, right, width, signals):
|
||||
super().__init__()
|
||||
self.args = (timestamps, values, left, right, width)
|
||||
self.key, self.geometry, self.signals = key, geometry, signals
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
points = tuple(pixel_envelope(*self.args))
|
||||
except Exception:
|
||||
points = None
|
||||
try:
|
||||
self.signals.ready.emit(self.key, self.geometry, points)
|
||||
except RuntimeError:
|
||||
pass # The plot may have been closed while the worker was running.
|
||||
|
||||
|
||||
class NavigationSignals(QObject):
|
||||
ready = Signal(int, object, object)
|
||||
|
||||
|
||||
class NavigationJob(QRunnable):
|
||||
def __init__(self, revision, series, bounds, emitter):
|
||||
super().__init__()
|
||||
self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter
|
||||
|
||||
def run(self):
|
||||
stats = {}
|
||||
try:
|
||||
for key, times, values in self.series:
|
||||
points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right)
|
||||
if self.bounds.left <= x <= self.bounds.right]
|
||||
stats[key] = measurements(points), len(points)
|
||||
self.emitter.ready.emit(self.revision, stats, self.bounds)
|
||||
except Exception:
|
||||
try:
|
||||
self.emitter.ready.emit(self.revision, {}, self.bounds)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
|
||||
88
python/set_devices/qt_ports/qcustomplot_renderer.py
Normal file
88
python/set_devices/qt_ports/qcustomplot_renderer.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Isolated native QCustomPlot renderer; no Qt ABI mixing with PySide."""
|
||||
from array import array
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from threading import Lock
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QRunnable, Signal
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QRunnable, Signal
|
||||
|
||||
class NativePlotSignals(QObject):
|
||||
ready = Signal(str, object, object)
|
||||
|
||||
class NativePlotEngine:
|
||||
def __init__(self, executable):
|
||||
self.executable = Path(executable)
|
||||
self.process = None
|
||||
self.closed = False
|
||||
self.lock = Lock()
|
||||
self.directory = tempfile.TemporaryDirectory(prefix='setgui-qcp-')
|
||||
self.records = {}
|
||||
self.serial = 0
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
if self.process is not None:
|
||||
self.process.kill()
|
||||
if self.process.stdin is not None:
|
||||
self.process.stdin.close()
|
||||
# Files are owned by this TemporaryDirectory and released with the engine.
|
||||
|
||||
def render(self, times, values, bounds, width, height, color):
|
||||
with self.lock:
|
||||
if self.closed:
|
||||
raise RuntimeError("Plot closed")
|
||||
if self.process is None or self.process.poll() is not None:
|
||||
env = os.environ.copy()
|
||||
env['QT_QPA_PLATFORM'] = 'offscreen'
|
||||
env.pop('QT_PLUGIN_PATH', None)
|
||||
env['QT_QPA_PLATFORM_PLUGIN_PATH'] = str(self.executable.parent / 'platforms')
|
||||
self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0,
|
||||
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent)
|
||||
key = (id(times), id(values))
|
||||
record = self.records.get(key)
|
||||
if record is None:
|
||||
self.serial += 1
|
||||
path = Path(self.directory.name) / f'{self.serial}.bin'
|
||||
with path.open('wb') as stream:
|
||||
array('d', times).tofile(stream)
|
||||
array('d', values).tofile(stream)
|
||||
record = (path, times, values)
|
||||
self.records[key] = record
|
||||
if len(self.records) > 4:
|
||||
self.records.pop(next(iter(self.records)))
|
||||
request = dict(file=str(record[0]), count=len(times), width=width, height=height,
|
||||
left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color)
|
||||
self.process.stdin.write((json.dumps(request)+'\n').encode())
|
||||
self.process.stdin.flush()
|
||||
size = int(self.process.stdout.readline())
|
||||
if not 0 < size < 100_000_000:
|
||||
raise RuntimeError('Invalid QCustomPlot frame')
|
||||
chunks = bytearray()
|
||||
while len(chunks) < size:
|
||||
chunk = self.process.stdout.read(size-len(chunks))
|
||||
if not chunk:
|
||||
raise RuntimeError('QCustomPlot renderer stopped')
|
||||
chunks.extend(chunk)
|
||||
return bytes(chunks)
|
||||
|
||||
class NativePlotJob(QRunnable):
|
||||
def __init__(self, engine, key, geometry, times, values, bounds, width, height, color, signals):
|
||||
super().__init__()
|
||||
self.engine, self.key, self.geometry, self.signals = engine, key, geometry, signals
|
||||
self.args = times, values, bounds, width, height, color
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
png = self.engine.render(*self.args)
|
||||
except Exception:
|
||||
png = None
|
||||
try:
|
||||
self.signals.ready.emit(self.key, self.geometry, png)
|
||||
except RuntimeError:
|
||||
pass
|
||||
24
python/set_devices/qt_ports/tektronix_controls.py
Normal file
24
python/set_devices/qt_ports/tektronix_controls.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Tektronix-style record-length selector, including instrument readback."""
|
||||
try:
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
except ImportError:
|
||||
from PySide2.QtWidgets import QComboBox
|
||||
from set_devices.tektronix_presets import RECORD_LENGTHS
|
||||
|
||||
|
||||
class RecordLengthBox(QComboBox):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
for value in RECORD_LENGTHS:
|
||||
self.addItem(f"{value:,}".replace(",", " ") + " точек", value)
|
||||
|
||||
def value(self):
|
||||
return int(self.currentData())
|
||||
|
||||
def setValue(self, value):
|
||||
value = int(value)
|
||||
index = self.findData(value)
|
||||
if index < 0:
|
||||
self.addItem(f"{value:,}".replace(",", " ") + " точек", value)
|
||||
index = self.count() - 1
|
||||
self.setCurrentIndex(index)
|
||||
82
python/set_devices/qt_ports/viewport_navigation.py
Normal file
82
python/set_devices/qt_ports/viewport_navigation.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Debounced viewport statistics shared by desktop charts."""
|
||||
import time
|
||||
from dataclasses import replace
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt
|
||||
except ImportError:
|
||||
from PySide2.QtCore import QObject, QTimer, QThreadPool, Qt
|
||||
from .plot_workers import NavigationSignals as _NavigationSignals, NavigationJob as _NavigationJob
|
||||
from set_devices.oscilloscope import time_label
|
||||
def number(value):
|
||||
return f"{value:g}"
|
||||
|
||||
class ViewportNavigation(QObject):
|
||||
def __init__(self, chart):
|
||||
super().__init__(chart)
|
||||
self.chart = chart
|
||||
self._navigation_revision = 0
|
||||
self._navigation_busy = False
|
||||
self._navigation_emitter = _NavigationSignals(self)
|
||||
self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection)
|
||||
self._navigation_timer = QTimer(self)
|
||||
self._navigation_timer.setSingleShot(True)
|
||||
self._navigation_timer.setInterval(180)
|
||||
self._navigation_timer.timeout.connect(self._navigation_finished)
|
||||
|
||||
def _navigation_changed(self):
|
||||
self._navigation_revision += 1
|
||||
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
|
||||
self.chart._scope_measurements()
|
||||
self.chart._refresh_processing()
|
||||
self.chart._refresh_legend()
|
||||
return
|
||||
self._navigation_timer.start()
|
||||
|
||||
def _navigation_finished(self):
|
||||
if False:
|
||||
return
|
||||
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
|
||||
self.chart._scope_measurements()
|
||||
self.chart._refresh_processing()
|
||||
return
|
||||
if self._navigation_busy:
|
||||
self._navigation_timer.start()
|
||||
return
|
||||
self._navigation_busy = True
|
||||
bounds = self.chart.plot.visible_bounds
|
||||
panel = self.chart.processing
|
||||
offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0.
|
||||
# Navigation changes the next calculation's domain; retained curves keep
|
||||
# their original domain. Avoid preparing millions of inputs on the UI.
|
||||
panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset))
|
||||
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete]
|
||||
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds,
|
||||
self._navigation_emitter))
|
||||
|
||||
def _navigation_ready(self, revision, stats, bounds):
|
||||
self._navigation_busy = False
|
||||
if False or revision != self._navigation_revision:
|
||||
return
|
||||
for row in range(self.chart.legend.rowCount()):
|
||||
item = self.chart.legend.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
result, _ = stats.get(item.data(Qt.ItemDataRole.UserRole), (None, 0))
|
||||
for column, key in ((4, "rms"), (5, "maximum"), (6, "minimum")):
|
||||
cell = self.chart.legend.item(row, column)
|
||||
if cell is not None:
|
||||
cell.setText("—" if result is None else "%g" % result[key])
|
||||
scope = self.chart.scope
|
||||
result, count = stats.get(scope.channel.currentData(), (None, 0))
|
||||
if result is None:
|
||||
scope.readings.setText("Измерения: —")
|
||||
return
|
||||
text = " · ".join(f"{title} {number(result[key])}" for title, key in (
|
||||
("Min", "minimum"), ("Max", "maximum"), ("Размах", "peak_to_peak"),
|
||||
("Среднее", "mean"), ("RMS", "rms")))
|
||||
if not self.chart.fft.isChecked():
|
||||
frequency = result["frequency"]
|
||||
text += " · f ≈ " + (f"{number(frequency)} Гц" if frequency is not None else "—")
|
||||
text += f" · {time_label((bounds.right - bounds.left) / 10)}/дел"
|
||||
scope.readings.setText(text + f" · N={count}")
|
||||
|
||||
Reference in New Issue
Block a user