"""Переиспользуемые Qt-панель и диалог сравнения с CSV-эталоном.""" # График, контейнер кривой и папки передаются потребителем. import json # Отчёт сохраняет показатели и параметры сравнения в переносимом формате. from pathlib import Path # Имя файла используется в статусе, полный путь — при записи отчёта. import re # Единицы в скобках проверяются до сравнения каналов. try: # Один порт поддерживает современные и совместимые Windows-сборки. from PySide6.QtCore import QThread, Qt, Signal # Qt6 доставляет результаты рабочего потока в интерфейс. from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Используем стандартные элементы Qt6; создание ячеек с единицами и подсказками выполняет общий заполнитель таблицы. except ImportError: # Qt5 доступен на системах, где Qt6 не поддерживается. from PySide2.QtCore import QThread, Qt, Signal # Сохраняем тот же контракт сигналов и отмены на Qt5. from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFileDialog, QHBoxLayout, QHeaderView, QLabel, QPushButton, QSplitter, QTableWidget, QVBoxLayout, QWidget # Qt5 использует тот же заполнитель ячеек с единицами и подсказками, что современный порт. from set_devices.signal_reference import compare, read_reference, trace_from_pairs # Единственное вычислительное ядро работает по всем исходным отсчётам. from set_devices.qt_ports.circuit_parameters import CircuitParameters # Необязательные известные R/L/C ограничивают идентификацию электрического эквивалента. from protocan.plot import Viewport # Общий контейнер области просмотра не зависит от конкретного виджета графика. from set_devices.qt_ports.reference_results import ComparisonAnalysis, populate_comparison_table # Анализ, единицы показателей и подсказки одинаковы в отдельном и встроенном виде. def _model_channels(sources, references, cache, cancel): # Подготовка связанных физических каналов выполняется только в рабочем потоке. measured = {} # Сохраняем исходные временные метки для совместной проверки Uce/Uge/Ic. for index, (signal, points) in enumerate(sources): # Истории и преобразования соответствуют одному снимку измерительной записи. if re.match(r'^\s*(uce|uge|ic)\b', signal.name, re.IGNORECASE): # Не копируем произвольные дополнительные каналы, не участвующие в электрической модели. if not points: # Отсутствующий связанный канал не должен ломать корректное сравнение выбранного измерения. continue # Совместный анализ сообщит недостаточный набор физических наблюдений. trace = cache.get(index) # Выбранный канал уже подготовлен основным сравнением. if trace is None: # Остальные физические каналы преобразуются только при первом использовании. trace = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Множители применяются в точности как на исходном графике. cache[index] = trace # Следующая настройка RLC не требует повторной копии полной истории. measured[str(index)] = trace # Индекс сохраняет неоднозначные одинаковые имена для проверки, не затирая их словарём. return dict(measured=measured, reference=references) # Сдвиг и общие начала координат добавляет независимое вычислительное ядро. def _stop_owned_worker(state): # Уничтожение родителя QWidget может обойти closeEvent вложенной панели. worker = state[0] # Состояние не обращается к уже уничтожаемой Python-обёртке виджета. if worker is not None: # Завершённые задания удаляются из держателя заранее. worker.requestInterruption() # Операция увидит отмену через штатный callback QThread. worker.wait() # QObject удаляет дочерний QThread только после выхода его run. class ComparisonWorker(QThread): # Чтение CSV и численный проход выполняются вне главного потока Qt. result = Signal(object) # Готовый результат передаётся интерфейсу без изменения исходных данных. failed = Signal(str) # Ошибка передаётся текстом и не оставляет частичный отчёт. def __init__(self, operation, parent): # Операция принимает callback проверки отмены. super().__init__(parent) # Диалог владеет временем жизни рабочего потока. self.operation = operation # Одинаковый worker обслуживает загрузку и расчёт. def run(self): # Qt вызывает этот метод в отдельном потоке. try: # Любая ошибка должна вернуться в UI, а не прервать главный цикл событий. self.result.emit(self.operation(self.isInterruptionRequested)) # Передаём операции штатный запрос отмены QThread. except (ValueError, OSError, InterruptedError) as error: # Ошибки данных, файлов и отмена уже содержат понятную причину. self.failed.emit(str(error)) # Показываем исходную причину без дополнительной обёртки. except Exception as error: # Неожиданная ошибка также должна завершить состояние занятости. self.failed.emit(f'Ошибка сравнения: {error}') # Уточняем источник неизвестной ошибки для пользователя. class _ReferenceComparisonView: # Панель и отдельный диалог используют одну реализацию параметров, worker и результата. def __init__(self, sources, parent=None, source_path='', *, plot_factory, series_factory, path_provider=None): # sources содержит пары объекта с name/display_value и неизменяемой полной истории. super().__init__(parent) # Родитель определяет окно и владение диалогом. self._series_factory = series_factory # Фабрика адаптирует общие Trace к кривым графика потребителя. self._dialog_path = path_provider or (lambda filename: filename) # Без callback используем стандартные относительные имена файлов. self.setWindowTitle('Сравнение с эталоном') # Заголовок одинаков для всех приложений, использующих порт. self.sources = sources # История фиксируется вызывающей стороной на время жизни диалога. self.source_path = source_path # Происхождение измерения включается в JSON-отчёт. self.references = {} # Каналы эталона появятся после успешного чтения CSV. self.reference_path = '' # Путь изменяется только после принятия загруженного эталона. self._source_cache = {} # Повторный расчёт не должен повторно применять множители к измерению. self._worker = None # Одновременно допускается только одна загрузка или вычисление. self._worker_state = [None] # Независимый держатель доступен обработчику destroyed без обращения к self. self.destroyed.connect(lambda *_args, state=self._worker_state: _stop_owned_worker(state)) # Останавливаем поток до удаления C++-детей даже при deleteLater родителя. self._closing = False # Флаг откладывает закрытие до завершения отменённого worker. self.comparison = None # До расчёта отчёт и график результата отсутствуют. root = QVBoxLayout(self) # Вертикальный макет объединяет параметры, результат и экспорт. row = QHBoxLayout() # Первая строка отвечает за выбор файлов и каналов. self.load_button = QPushButton('Открыть эталон CSV…') # Эталон загружается отдельно от исходного измерения. self.load_button.clicked.connect(self._open_reference) # Выбор файла запускает фоновое чтение. self.source_combo, self.reference_combo = QComboBox(), QComboBox() # Каналы измерения и эталона выбираются независимо. for signal, _ in sources: # Показываем все переданные каналы с непустой историей. self.source_combo.addItem(signal.name) # Имя берётся из общего контракта источника. row.addWidget(self.load_button) # Кнопка открытия предшествует выбору каналов. for label, control in (('Измерение', self.source_combo), ('Эталон', self.reference_combo)): # Подписи явно различают обе стороны сравнения. row.addWidget(QLabel(label)) # Подпись относится к следующему списку каналов. row.addWidget(control, 1) # Оба списка делят свободное место поровну. root.addLayout(row) # Завершаем строку выбора данных. row = QHBoxLayout() # Вторая строка задаёт временное совмещение и допуски. self.align_start = QCheckBox('Совместить начала записей') # Позволяет сравнивать записи с разными абсолютными началами. self.align_start.setChecked(True) # По умолчанию сравниваем форму от начала каждой записи. self.align_start.setToolTip('Совмещает первые отсчёты. Задержка самого переключения сохраняется.') # Объясняем отличие совмещения начала от поиска фронта. self.shift = self._number(-1e12, 1e12, 6) # Диапазон ±10^12 мкс и шесть знаков сохраняет прежнюю точность ввода сдвига. self.shift.setSuffix(' мкс') # В интерфейсе время вводится в микросекундах. self.shift.setToolTip('Положительное значение сдвигает эталон вправо') # Явно задаём знак временного сдвига. self.absolute = self._number(0., 1e12, 6) # Абсолютный допуск неотрицателен и измеряется в единицах канала. self.relative = self._number(0., 10000., 4) # Относительная часть допуска задаётся в процентах с четырьмя знаками. self.relative.setSuffix(' %') # Единица отделяет проценты от абсолютной амплитуды. row.addWidget(self.align_start) # Опция совмещения находится рядом со сдвигом. for label, control in (('Сдвиг эталона', self.shift), ('Допуск ±', self.absolute), ('+ от |эталона|', self.relative)): # Подписи отражают сумму абсолютной и процентной частей. row.addWidget(QLabel(label)) # Связываем пояснение с соответствующим полем. row.addWidget(control) # Ограниченные по ширине поля оставляют место всем параметрам. root.addLayout(row) # Добавляем настройки совмещения в общий макет. row = QHBoxLayout() # Третья строка управляет интервалом, расчётом и видом результата. self.limit_interval = QCheckBox('Только интервал, мкс') # Флажок включает ограничение расчёта по времени. self.start, self.stop = self._number(-1e12, 1e12, 6), self._number(-1e12, 1e12, 6) # Границы используют ту же шкалу мкс и точность, что сдвиг. self.stop.setValue(300.) # Начальный интервал 0–300 мкс сохраняет прежнее поведение диалога. self.visible_button = QPushButton('Из видимого участка') # Текущий масштаб графика можно использовать как интервал расчёта. self.visible_button.clicked.connect(self._visible_interval) # Переводим границы графика из мс в мкс. self.calculate_button = QPushButton('Сравнить') # Расчёт запускается явно после выбора параметров. self.calculate_button.clicked.connect(self._calculate) # Параметры фиксируются перед запуском worker. self.cancel_button = QPushButton('Отмена расчёта') # Длинную загрузку или расчёт можно прервать. self.cancel_button.clicked.connect(self._cancel) # Используем кооперативную отмену без остановки потока извне. self.mode = QComboBox() # Режим отображения не меняет уже вычисленные показатели. self.mode.addItems(['Наложение', 'Отклонение: измерение − эталон']) # Сначала показываем две кривые в общем масштабе. self.mode.currentIndexChanged.connect(self._show_plot) # Переключаем кривые без повторного численного прохода. for control in (self.limit_interval, self.start, QLabel('…'), self.stop, self.visible_button, self.calculate_button, self.cancel_button, self.mode): # Сохраняем порядок интервал → расчёт → вид. row.addWidget(control) # Каждый элемент относится к одной строке управления результатом. root.addLayout(row) # Завершаем область параметров. self.status = QLabel('Откройте эталон. Сравниваются все отсчёты выбранного канала после преобразования амплитуды.') # Общий порт не предполагает конкретный формат преобразования источника. self.status.setWordWrap(True) # Длинные ошибки CSV не расширяют окно. root.addWidget(self.status) # Состояние видно над графиком. self.circuit = CircuitParameters(self) # Неизвестные элементы по умолчанию не подменяются учебными номиналами. self.circuit.changed.connect(self._invalidate) # Изменение электрического ограничения делает прежние параметры модели неактуальными. root.addWidget(self.circuit) # Опциональные R/L/C доступны перед строкой анализа и результатами. self.analysis = ComparisonAnalysis(self) # Строка возможной причины и подробный отчёт повторяют подход Android GUI. root.addWidget(self.analysis) # Анализ располагается рядом с результатом, не изменяя исходные кривые. self.legend = QLabel() # Цветная легенда поясняет текущие кривые. root.addWidget(self.legend) # Легенда отделена от численной таблицы. splitter = QSplitter(Qt.Orientation.Horizontal) # Пользователь меняет соотношение ширины графика и таблицы. self.plot = plot_factory() # Конкретный canvas создаётся и настраивается адаптером приложения. splitter.addWidget(self.plot) # QSplitter принимает любой совместимый QWidget графика. self.table = QTableWidget(0, 2) # Каждому показателю соответствует название и значение. self.table.setHorizontalHeaderLabels(['Показатель', 'Значение']) # Заголовки поясняют две колонки отчёта. self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Показатели расчёта недоступны для ручной правки. self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents) # Названия и числа видны полностью по содержимому. self.table.setMinimumWidth(370) # Минимум 370 логических пикселей сохраняет читаемость показателей. splitter.addWidget(self.table) # Таблица располагается справа от кривых. splitter.setSizes([920, 380]) # Начальные ширины в логических пикселях отдают большую часть окна графику. root.addWidget(splitter, 1) # Результат получает всё свободное место по высоте. row = QHBoxLayout() # Нижняя строка содержит экспорт и пояснение алгоритма. self.report_button = QPushButton('Сохранить отчёт JSON…') # Экспорт доступен только для актуального результата. self.report_button.clicked.connect(self._save_report) # Имя файла предлагает внедрённый provider путей. row.addWidget(self.report_button) # Кнопка экспорта расположена перед пояснением. note = QLabel('Δ = измерение − эталон. Линейная интерполяция эталона; без экстраполяции и заполнения пропусков.') # Формула и ограничения относятся ко всем приложениям-потребителям. note.setWordWrap(True) # Текст остаётся читаемым при изменении размера окна. row.addWidget(note, 1) # Пояснение занимает оставшуюся ширину строки. root.addLayout(row) # Завершаем макет экспорта. self._inputs = [self.load_button, self.source_combo, self.reference_combo, self.align_start, self.shift, self.absolute, self.relative, self.limit_interval, self.start, self.stop, self.visible_button] # Во время расчёта входные данные не меняются. self._inputs.extend(self.circuit.controls) # Электрические ограничения также фиксируются на время рабочего задания. for control in (self.shift, self.absolute, self.relative, self.start, self.stop): # Любое числовое изменение делает результат устаревшим. control.valueChanged.connect(self._invalidate) # Сбрасываем старые кривые и отчёт до следующего расчёта. for control in (self.align_start, self.limit_interval): # Флажки также влияют на вычисляемую временную сетку. control.toggled.connect(self._invalidate) # Показатели не должны относиться к прежним условиям. self.source_combo.currentIndexChanged.connect(self._match_reference) # При смене измерения ищем одноимённый эталон. self.reference_combo.currentIndexChanged.connect(self._invalidate) # Смена эталонного канала требует нового расчёта. self._busy(False) # Настраиваем начальную доступность кнопок. QApplication.instance().aboutToQuit.connect(self._shutdown) # Завершение приложения дожидается отмены рабочего потока. @staticmethod # Фабрика числового поля не зависит от состояния диалога. def _number(low, high, decimals): # Границы и число десятичных знаков задаются для конкретной величины. box = QDoubleSpinBox() # Штатный элемент проверяет числовой ввод. box.setDecimals(decimals) # Сохраняем требуемую точность времени или допуска. box.setRange(low, high) # Ограничиваем значения допустимым диапазоном параметра. box.setMaximumWidth(150) # Ширина 150 логических пикселей сохраняет компактную строку параметров. return box # Вызывающий код задаёт единицы и обработчик изменений. def _busy(self, busy): # Единый переход состояния используется загрузкой и расчётом. for control in self._inputs: # Все параметры относятся к одному снимку задания. control.setEnabled(not busy) # Исключаем изменение входа работающего worker. self.calculate_button.setEnabled(not busy and bool(self.references) and bool(self.sources)) # Без измерения или эталона расчёт невозможен. self.cancel_button.setEnabled(busy) # Отменять можно только существующее задание. self.report_button.setEnabled(not busy and self.comparison is not None) # Отчёт относится исключительно к завершённому результату. def _start_work(self, operation, receive): # Операция и получатель отделяют чтение файла от численного расчёта. self._busy(True) # Блокируем параметры до запуска потока. worker = ComparisonWorker(operation, self) # Диалог сохраняет владение worker до его завершения. self._worker = worker # Ссылка используется для отмены и отложенного закрытия. self._worker_state[0] = worker # Сигнал уничтожения владельца получает актуальную задачу. worker.result.connect(receive) # Результат применяется в главном потоке Qt. worker.failed.connect(self.status.setText) # Ошибка появляется в общей строке состояния. worker.finished.connect(self._finished) # Освобождение worker восстанавливает доступность элементов. worker.start() # Начинаем фоновое чтение или расчёт. def _finished(self): # Вызывается после штатного завершения и после отмены. worker, self._worker = self._worker, None # Снимаем состояние занятости до возможного закрытия диалога. self._worker_state[0] = None # Уничтожение родителя больше не обращается к завершённому worker. if worker is None: # Синхронное закрытие могло уже очистить поток до доставки queued-сигнала. return # Не повторяем удаление и не обновляем закрытую панель. worker.deleteLater() # Qt безопасно удаляет завершившийся поток через очередь событий. self._busy(False) # Разрешаем новый расчёт либо экспорт принятого результата. if self._closing: # Запрос закрытия во время работы должен завершиться автоматически. self.close() # Повторное закрытие уже не встречает активный worker. def _cancel(self): # Отмена кооперативна и проверяется вычислительным ядром. if self._worker is not None: # Не обращаемся к уже завершённому потоку. self._worker.requestInterruption() # Ядро увидит запрос в ближайшей контрольной точке. self.status.setText('Остановка расчёта…') # Пользователь видит, что запрос принят. def _shutdown(self): # Перед выходом процесса нельзя уничтожать работающий QThread. self._closing = True # Поздние сигналы не должны публиковать результат в закрывающемся интерфейсе. if self._worker is not None: # Ожидание требуется только при активном задании. worker = self._worker # Сохраняем объект до очистки общего держателя. worker.requestInterruption() # Просим ядро прервать чтение или расчёт. worker.wait() # Дожидаемся остановки до уничтожения объектов Qt. self._worker = None # Смена записи может немедленно удалить закрытую панель. self._worker_state[0] = None # Сигнал destroyed не обращается к удалённому потоку повторно. worker.deleteLater() # Завершённый QThread безопасно освобождается очередью Qt. def _invalidate(self, *_): # Аргументы сигналов Qt не нужны для сброса результата. self.comparison = None # Старые показатели больше не соответствуют выбранным параметрам. self.analysis.set_result(None) # После изменения ввода прежняя гипотеза больше не является актуальной. self.table.setRowCount(0) # Убираем устаревшие численные значения. self.report_button.setEnabled(False) # Запрещаем экспорт прежнего результата. self.plot._series = [] # Старые кривые также не должны выглядеть актуальными. self.legend.clear() # Легенда очищается вместе с кривыми. self.plot._sync_bounds() # Адаптер графика пересчитывает пустую область просмотра. self.plot.update() # Запрашиваем перерисовку в главном потоке. if self.references: # После загрузки эталона подсказываем следующий шаг. self.status.setText('Параметры изменены — нажмите «Сравнить».') # Новый результат получается только явным расчётом. def _match_reference(self, *_): # Смена канала измерения ищет одноимённый столбец эталона. name = self.source_combo.currentText().strip().casefold() # Регистр и крайние пробелы не мешают совпадению имён. for index in range(self.reference_combo.count()): # Проверяем доступные каналы текущего эталона. if self.reference_combo.itemText(index).strip().casefold() == name: # Сопоставляем полное имя вместе с единицами. self.reference_combo.setCurrentIndex(index) # Предлагаем канал, соответствующий измерению. break # Уникальные имена не требуют дальнейшего поиска. self._invalidate() # Даже без совпадения старые показатели должны быть удалены. def _open_reference(self): # Правила начальной папки задаёт callback приложения. path, _ = QFileDialog.getOpenFileName(self, 'Открыть эталон', self._dialog_path(''), 'CSV (*.csv)') # Общая библиотека не создаёт каталоги самостоятельно. if path: # Отмена выбора файла сохраняет текущий эталон. self.load_reference(path) # Чтение выполняется отдельным worker. def load_reference(self, path): # Публичный вход позволяет потребителю открыть эталон программно. self._invalidate() # Новая загрузка не должна показывать старый отчёт как актуальный. self.status.setText('Загрузка эталона…') # Состояние обновляется до запуска чтения. def receive(traces): # Этот обработчик выполняется в потоке интерфейса. if self._closing: # Синхронное закрытие могло опередить доставку готового чтения. return # Не обновляем элементы закрываемой панели поздним результатом. self.references = traces # Принимаем только полностью прочитанный набор каналов. self.reference_path = str(path) # Путь отчёта соответствует принятому файлу. self.reference_combo.clear() # Удаляем имена каналов предыдущего эталона. self.reference_combo.addItems(list(traces)) # Сохраняем порядок столбцов CSV. self._match_reference() # Автоматически выбираем одноимённый канал при наличии. self.status.setText(f'Эталон: {Path(path).name}. Выберите каналы и нажмите «Сравнить».') # Подтверждаем успешную загрузку файла. self._start_work(lambda cancel: read_reference(path, cancel), receive) # Ядро получает callback отмены чтения. def _visible_interval(self): # Видимая область графика задаёт границы расчёта. bounds = self.plot.visible_bounds # Контракт графика возвращает время в миллисекундах. left, right = bounds.left * 1000, bounds.right * 1000 # Множитель 1000 переводит миллисекунды в микросекунды UI. self.limit_interval.setChecked(True) # Явно включаем использование выбранных границ. self.start.setValue(left) # Левая граница отсчитывается от начала измерения. self.stop.setValue(right) # Правая граница выражена в той же шкале. def _calculate(self): # Фиксируем параметры до передачи задания в поток. if not self.references or not self.sources or self._worker is not None: # Не запускаем неполное или параллельное сравнение. return # Сохраняем существующее задание и состояние входов. source_index = self.source_combo.currentIndex() # Индекс связывает выбранное имя с исходным снимком. signal, points = self.sources[source_index] # Полная история не зависит от экранного окна. reference = self.references[self.reference_combo.currentText()] # Выбираем один канал загруженного эталона. units = [re.search(r'\(([^()]*)\)\s*$', name) for name in (signal.name, reference.name)] # Единицы распознаются по конечным скобкам имени. if all(units) and units[0].group(1) != units[1].group(1): # Явно разные единицы нельзя сравнивать как одинаковые амплитуды. self._invalidate() # Предыдущий результат не относится к новому сочетанию каналов. self.status.setText('Единицы каналов различаются. Выберите каналы с одинаковыми единицами.') # Пользователь выбирает корректную пару. return # Не выполняем численный проход с несовместимыми единицами. options = dict(align_start=self.align_start.isChecked(), shift_ms=self.shift.value() / 1000., # Деление на 1000 переводит микросекунды UI в миллисекунды ядра. absolute_tolerance=self.absolute.value(), relative_tolerance_pct=self.relative.value(), # Допуски уже заданы в единицах ядра. interval=(self.start.value() / 1000., self.stop.value() / 1000.) if self.limit_interval.isChecked() else None) # Ограничение времени также переводится из мкс в мс. self._invalidate() # До расчёта удаляем старые кривые и показатели. self.status.setText('Сравнение всех отсчётов…') # Явно указываем работу с полной записью. options['circuit'] = self.circuit.values() # Значения GUI переводятся в СИ до перехода в рабочий поток. sources, references = self.sources, self.references # Сохраняем единый снимок связанных каналов и выбранного CSV. def operation(cancel): # Подготовка амплитуд и сравнение выполняются в worker. measured = self._source_cache.get(source_index) # Кэш привязан к неизменяемому снимку канала. if measured is None: # Преобразуем исходные значения только при первом использовании канала. measured = trace_from_pairs(signal.name, points, signal.display_value, cancel) # Применяем предоставленное источником преобразование амплитуд. self._source_cache[source_index] = measured # Повторные настройки допуска не требуют новой подготовки измерения. channels = _model_channels(sources, references, self._source_cache, cancel) # Синхронные Uce/Uge/Ic подготавливаются теми же преобразованиями амплитуд. return compare(measured, reference, cancel=cancel, channels=channels, **options) # Общее ядро выполняет полную статистику и идентификацию электрических моделей. self._start_work(operation, self._received) # Получатель публикует целый результат в главном потоке. def _received(self, result): # Таблица и график обновляются из одного результата. if self._closing: # Готовый результат мог стоять в очереди во время смены записи. return # Закрытая панель не должна публиковать устаревшие метрики. self.comparison = result # Сохраняем данные для переключения вида и экспорта. self.analysis.set_result(result) # Краткий вывод обновляется вместе с полной таблицей сравнения. populate_comparison_table(self.table, result) # Единицы и подсказки названий и чисел совпадают с боковой таблицей исходного графика. self.status.setText(f'{result.measured.name} ↔ {Path(self.reference_path).name}: {result.reference.name}. ' # Статус указывает фактически сравниваемые каналы и файл. 'Время от начала измерения; абсолютные ошибки — в единицах выбранного канала.') # Поясняем шкалу и единицы результата. self._show_plot() # Показываем текущий режим кривых без повторного расчёта. def _show_plot(self, *_): # Сигнал выбора режима может передавать неиспользуемый индекс. if self.comparison is None: # До успешного расчёта график результата отсутствует. return # Не создаём кривые из устаревших данных. result = self.comparison # Кривые и остаток принадлежат одному расчёту. curves = [(result.measured, 'Измерение', '#2f91ff'), (result.reference, 'Эталон', '#ffae42')] # Синий и оранжевый различают стороны наложения. self.legend.setText('━━ Измерение   ' # Цвет легенды совпадает с измеренной кривой. '━━ Эталон — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу. if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность. curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых. self.legend.setText('━━ Δ = измерение − эталон') # Формула фиксирует знак ошибки. self.plot._series = [self._series_factory(str(i), f'{label}: {trace.name}', color=color, timestamps=trace.times, values=trace.values) # Фабрика создаёт контейнер конкретного canvas без импорта приложения. for i, (trace, label, color) in enumerate(curves)] # Передаём полные массивы без экранного сокращения. self.plot.reset_interaction() # Новый вид начинает с нейтрального состояния навигации. self.plot.axis_lock_x.setChecked(False) # Освобождаем X для корректного показа нового результата. self.plot.axis_lock_y.setChecked(False) # Общий масштаб Y не наследует ручную фиксацию предыдущего режима. self.plot.axis_limits = None # Удаляем прежние ручные границы осей. self.plot._sync_bounds() # Адаптер рассчитывает общую область кривых. interval = result.metrics['interval_ms'] # При ограниченном расчёте показываем выбранный интервал. if interval is not None: # Полная запись остаётся видимой при отсутствии ограничения. bounds = self.plot.visible_bounds # После сброса границы соответствуют полной области результата. span = bounds.right - bounds.left # Нормируем миллисекунды в долю общей длительности. self.plot.viewport = Viewport(x=(interval[0] - bounds.left) / span, width=(interval[1] - interval[0]) / span) # Масштаб X соответствует выбранному интервалу ядра. self.plot.update() # Отложенная перерисовка выполняется главным потоком Qt. def report(self): # Структуру можно получить программно без диалога сохранения. if self.comparison is None: # Экспортировать устаревший или отсутствующий результат запрещено. return None # Отсутствие результата обозначается явно. return dict(measured_file=self.source_path, measured_channel=self.comparison.measured.name, # Источник измерения сохраняется рядом с выбранным каналом. reference_file=self.reference_path, reference_channel=self.comparison.reference.name, # Эталонный файл и канал определяют вторую сторону сравнения. time_unit='ms from first measured sample', # Машиночитаемый отчёт использует миллисекунды ядра. interpolation='linear; no extrapolation; gaps excluded', # Ограничения интерполяции сохраняются вместе с результатом. tolerance='absolute + relative_pct / 100 * abs(reference)', # Формула позволяет воспроизвести коридор допуска. metrics=self.comparison.metrics, analysis=self.comparison.analysis) # Отчёт сохраняет также гипотезы, признаки и ограничения анализа. def _save_report(self): # Сохранение файла выполняется только по явному действию пользователя. if self.comparison is None: # Защищаемся от программного вызова без расчёта. return # Не создаём пустой отчёт. path, _ = QFileDialog.getSaveFileName(self, 'Отчёт сравнения', self._dialog_path('reference_comparison.json'), 'JSON (*.json)') # Provider определяет начальную папку приложения. if path: # Отмена выбора не меняет состояние результата. try: # Файловые ошибки должны отображаться без закрытия диалога. Path(path).write_text(json.dumps(self.report(), ensure_ascii=False, indent=2) + '\n', encoding='utf-8') # UTF-8 и отступ в два пробела сохраняют читаемость русских имён. self.status.setText(f'Отчёт сохранён: {path}') # Подтверждаем фактический путь записанного отчёта. except OSError as error: # Недоступный каталог или файл не уничтожает рассчитанные данные. self.status.setText(str(error)) # Пользователь может выбрать другое место и повторить сохранение. class ReferenceComparisonPanel(_ReferenceComparisonView, QWidget): # Встроенный вид занимает область существующего графика без отдельного окна. back_requested = Signal() # Потребитель решает, какую страницу показать при возврате к записи. def __init__(self, *args, **kwargs): # Контракт данных и фабрик совпадает с отдельным диалогом. super().__init__(*args, **kwargs) # Создаём общие параметры, таблицу и график через QWidget. self.back_button = QPushButton('К записи') # Возврат сохраняет загруженный эталон и параметры скрытой панели. self.back_button.clicked.connect(self.back_requested.emit) # Не связываем общую библиотеку с компоновкой конкретной вкладки. row = QHBoxLayout() # Отдельная короткая строка не сжимает параметры сравнения. row.addWidget(self.back_button) # Кнопка размещается у начала панели. row.addStretch(1) # Свободная ширина остаётся справа от кнопки возврата. self.layout().insertLayout(0, row) # Навигация предшествует выбору файлов и каналов. def closeEvent(self, event): # Родитель может удалить панель сразу после close при смене записи. self._closing = True # Сигнал завершения worker больше не должен возобновлять использование панели. self._shutdown() # Синхронно отменяем и дожидаемся потока перед возможным deleteLater родителя. self.plot.close() # Освобождаем ресурсы графика после завершения вычислений. super().closeEvent(event) # QWidget принимает закрытие встроенной панели. class ReferenceComparisonDialog(_ReferenceComparisonView, QDialog): # Отдельное окно остаётся доступным другим потребителям общего модуля. def __init__(self, *args, **kwargs): # Используем тот же контракт источников и внедряемых фабрик. super().__init__(*args, **kwargs) # Общая реализация строит элементы внутри QDialog. self.setWindowModality(Qt.WindowModality.WindowModal) # Модальность ограничивается родительским окном. self.resize(1350, 880) # Размер в логических пикселях сохраняет прежний макет отдельного окна. def reject(self): # Escape и программное закрытие откладываются до остановки worker. if self._worker is not None: # Работающий поток должен закончиться раньше удаления диалога. self._closing = True # Запоминаем запрос закрытия до сигнала finished. self._cancel() # Кооперативно прерываем операцию ядра. return # Слот _finished завершит закрытие после остановки. self.plot.close() # Освобождаем ресурсы конкретного canvas. super().reject() # Сохраняем стандартный API и сигналы QDialog. def closeEvent(self, event): # Системная кнопка закрытия соблюдает тот же порядок отмены. if self._worker is not None: # Не уничтожаем QThread во время операции. self._closing = True # Запоминаем отложенное закрытие. self._cancel() # Запрос будет замечен ядром в контрольной точке. event.ignore() # Диалог существует до завершения worker. else: # Завершённый расчёт не препятствует закрытию. self.plot.close() # График завершает собственную фоновую работу. super().closeEvent(event) # Передаём QDialog штатное закрытие.