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

98 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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