Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
376 lines
52 KiB
Python
376 lines
52 KiB
Python
"""Переиспользуемые 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('<span style="color:#2f91ff">━━ Измерение</span> ' # Цвет легенды совпадает с измеренной кривой.
|
||
'<span style="color:#ffae42">━━ Эталон</span> — общий масштаб Y') # Адаптер обязан сохранить единую амплитудную шкалу.
|
||
if self.mode.currentIndex() == 1: # Второй режим показывает только рассчитанную разность.
|
||
curves = [(result.difference, 'Δ', '#ff7b7b')] # Красноватый цвет отделяет остаток от исходных кривых.
|
||
self.legend.setText('<span style="color:#ff7b7b">━━ Δ = измерение − эталон</span>') # Формула фиксирует знак ошибки.
|
||
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 штатное закрытие.
|