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