Files
templates/python/set_devices/qt_ports/qcustomplot_renderer.py
T
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

98 lines
6.6 KiB
Python

"""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
import math # Кодируем разрывы кривой нечисловыми значениями Y, сохраняя упорядоченные координаты X для нативного движка.
from set_devices.plot_decimation import pixel_envelope # Передаём движку только точки, различимые при текущем масштабе изображения.
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), len(times), len(values), bounds.left, bounds.right, width) # Выборка зависит от исходного снимка, окна X и физической ширины изображения; масштаб Y её не меняет.
record = self.records.get(key)
if record is None:
self.serial += 1
path = Path(self.directory.name) / f'{self.serial}.bin'
points = [] # Временный список ограничен экранной шириной и не содержит миллионов исходных измерений.
for point in pixel_envelope(times, values, bounds.left, bounds.right, width): # Сохраняем первый, минимальный, максимальный и последний отсчёты каждого пиксельного столбца.
if point is not None: # Конечные измерения передаются с исходными координатами и амплитудами.
points.append(point) # Экстремумы остаются видимыми даже для импульса длиной в один исходный отсчёт.
elif points and math.isfinite(points[-1][1]): # Повторные маркеры одного разрыва не должны увеличивать размер передаваемого ряда.
points.append((points[-1][0], math.nan)) # Дублированная метка времени и NaN в Y разрывают путь без нарушения порядка X.
with path.open('wb') as stream:
array('d', (point[0] for point in points)).tofile(stream) # Записываем только экранные координаты X в прежнем формате 64-битных чисел.
array('d', (point[1] for point in points)).tofile(stream) # Массив Y соответствует сокращённому X и включает маркеры разрывов.
record = (path, times, values, len(points)) # Ссылки сохраняют идентичность исходного снимка; длина описывает только передаваемый экранный ряд.
self.records[key] = record
if len(self.records) > 4:
expired = self.records.pop(next(iter(self.records))) # Оставляем четыре последние экранные выборки, как и в кэше нативного процесса.
expired[0].unlink(missing_ok=True) # Освобождаем устаревший временный файл, чтобы навигация не накапливала данные на диске.
request = dict(file=str(record[0]), count=record[3], 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