Add native QCustomPlot renderer and shared plot adapters

This commit is contained in:
2026-10-07 11:33:14 +03:00
parent 90046de7fd
commit 79b0cde20e
23 changed files with 44815 additions and 35 deletions

View File

@@ -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:

View File

@@ -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)

View 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])

View File

@@ -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

View 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

View 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

View 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)

View 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}")