"""Сравнение полной записи с CSV-эталоном независимо от Qt и приложения.""" # Ядро сохраняет все исходные отсчёты. from array import array # Массивы double компактно хранят амплитуды без потери точности. from dataclasses import dataclass, field # Контейнеры связывают кривые, показатели и независимый словарь анализа каждого результата. from set_devices.signal_diagnostics import analyze_comparison # Общий локальный анализ работает без GUI и выполняется вместе со сравнением в worker. import csv # Потоковый parser поддерживает разделители и кавычки CSV. from datetime import datetime # Абсолютное ISO-время переводится в миллисекунды. import math # Проверки конечности отделяют корректные измерения от пропусков. @dataclass # Создаём конструктор независимого от GUI контейнера кривой. class Trace: # Временная шкала каждого канала измеряется в миллисекундах. name: str # Имя канала используется в выборе, легенде и отчёте. times: list # Строго возрастающие метки могут быть общими для каналов CSV. values: array # Амплитуды в единицах канала; NaN обозначает разрыв записи. @dataclass # Передаём вычисленный результат между слоями приложения единым объектом. class Comparison: # Полные кривые отделены от экранной огибающей. measured: Trace # Измерение со временем относительно первого отсчёта. reference: Trace # Эталон после совмещения начал и временного сдвига. difference: Trace # Остаток на общей части временной сетки измерения. expected: array # Интерполированный эталон на сетке остатка, включая пропуски. metrics: dict # Показатели ошибки и параметры воспроизводимого расчёта. analysis: dict = field(default_factory=dict) # Необязательное поле сохраняет совместимость прежнего конструктора с пятью аргументами. def checkpoint(cancel): # Callback позволяет прерывать расчёт без зависимости от потоковой библиотеки. if cancel(): # Проверяем запрос в контрольных точках прохода по данным. raise InterruptedError('Расчёт отменён') # Не публикуем частичный результат после отмены. def trace_from_pairs(name, points, transform=float, cancel=lambda: False): # Преобразование амплитуд задаёт потребитель, например множитель и IQ. times, values = [], array('d') # Сохраняем время списком, амплитуды — 64-битными double. previous = -math.inf # Первая конечная метка допустима независимо от её знака. for index, (stamp, value) in enumerate(points): # Обходим полную историю без экранного сокращения. if index % 16384 == 0: # Проверяем отмену порциями по 16384 точки для снижения накладных расходов. checkpoint(cancel) # Прерываем подготовку по запросу вызывающей стороны. if not math.isfinite(stamp) or stamp <= previous: # Повторы и обратный ход времени делают интерполяцию неоднозначной. raise ValueError('Время должно строго возрастать, без повторов') # Не исправляем исходную сетку молча. times.append(stamp) # Дробное время сохраняется без округления. values.append(transform(value)) # Применяем масштаб до сравнения ровно один раз. previous = stamp # Следующая точка должна находиться строго правее текущей. if not times: # Пустая запись не имеет начала для совмещения. raise ValueError('В канале нет отсчётов') # Сообщаем отсутствие исходных данных. return Trace(name, times, values) # Возвращаем снимок, независимый от изменяемого живого буфера. def read_reference(path, cancel=lambda: False): # Путь задаётся потребителем без правил хранения конкретного приложения. """Читает CSV потоково, сохраняя пустые ячейки и NaN как пропуски.""" # Разрывы впоследствии исключаются из статистики. with open(path, encoding='utf-8-sig', newline='') as stream: # Поддерживаем UTF-8 с необязательным BOM и штатную обработку CSV-переносов. first = stream.readline() # Заголовок определяет разделитель столбцов. delimiter = ';' if ';' in first else '\t' if '\t' in first else ',' # Принимаем точку с запятой, табуляцию и запятую. stream.seek(0) # Заголовок должен также попасть в CSV-parser. rows = csv.reader(stream, delimiter=delimiter) # Читаем построчно без хранения текста всего файла. header = [s.strip() for s in next(rows, [])] # Пробелы вокруг имён не являются частью имени канала. if len(header) < 2 or header[0] not in ('time_ms', 'timestamp'): # Требуем явную единицу времени и хотя бы один канал. raise ValueError('Эталон: первый столбец должен быть time_ms или timestamp') # Не угадываем шкалу неизвестного формата. names = header[1:] # Остальные столбцы содержат амплитуды каналов. if any(not name for name in names) or len(set(names)) != len(names): # Имена должны однозначно определять столбец. raise ValueError('Эталон: требуются непустые уникальные имена каналов') # Предотвращаем потерю повторного столбца в словаре. times = [] # Одна временная сетка разделяется всеми каналами файла. traces = {name: Trace(name, times, array('d')) for name in names} # Каждый канал получает собственный массив амплитуд. for index, row in enumerate(rows, 2): # Первая строка данных имеет номер 2 после заголовка. if index % 16384 == 0: # Проверяем отмену раз в 16384 строки большого CSV. checkpoint(cancel) # Закрытие окна не требует дочитывать весь файл. if len(row) != len(header): # Каждая строка должна соответствовать заявленным столбцам. raise ValueError(f'Эталон, строка {index}: неверное число столбцов') # Номер строки помогает найти повреждение. if header[0] == 'time_ms': # Относительное время уже выражено во внутренних миллисекундах. stamp = float(row[0]) # Сохраняем дробные доли миллисекунды. else: # timestamp задаёт абсолютное ISO-время с часовым поясом. date = datetime.fromisoformat(row[0].replace('Z', '+00:00')) # Суффикс UTC нормализуется также для Python 3.8. if date.tzinfo is None: # Время без пояса зависит от настроек компьютера. raise ValueError('timestamp эталона должен содержать часовой пояс') # Исключаем неоднозначное абсолютное время. stamp = date.timestamp() * 1000 # Множитель 1000 переводит UNIX-секунды в миллисекунды. if not math.isfinite(stamp) or (times and stamp <= times[-1]): # Сетка обязана быть конечной и строго возрастающей. raise ValueError(f'Эталон, строка {index}: время должно строго возрастать') # Не сортируем и не удаляем отсчёты автоматически. times.append(stamp) # Добавляем общую метку всех каналов текущей строки. for trace, text in zip(traces.values(), row[1:]): # Порядок каналов соответствует порядку заголовка. value = float(text) if text.strip() else math.nan # Пустая ячейка обозначает разрыв, а не нулевую амплитуду. if math.isinf(value): # Бесконечность считается повреждением данных, в отличие от NaN. raise ValueError(f'Эталон, строка {index}: бесконечное значение') # Указываем строку недопустимой амплитуды. trace.values.append(value) # Сохраняем каждое значение без децимации. if len(times) < 2: # Для линейной интерполяции необходим хотя бы один отрезок. raise ValueError('Эталон должен содержать хотя бы две временные точки') # Одноточечный эталон не публикуется. checkpoint(cancel) # Проверяем отмену и для короткого файла перед возвратом. return traces # Выбор конкретного канала остаётся вызывающей стороне. def compare(measured, reference, *, align_start=True, shift_ms=0., # Положительный сдвиг задерживает эталон на заданные миллисекунды. interval=None, absolute_tolerance=0., relative_tolerance_pct=0., # Интервал отсчитывается от начала измерения; допуск состоит из двух частей. cancel=lambda: False, circuit=None, channels=None): # Необязательные параметры контура и связанные каналы расширяют анализ без изменения прежних вызовов. """Интерполирует эталон на сетку измерения без экстраполяции и заполнения NaN.""" # Статистика использует все корректные исходные точки. if not measured.times or len(reference.times) < 2: # Нужны начало измерения и две опоры эталона. raise ValueError('Недостаточно данных для сравнения') # Пустые данные не должны давать нулевую ошибку. if any(not math.isfinite(x) for x in (shift_ms, absolute_tolerance, relative_tolerance_pct)): # Неконечные параметры нарушают координаты и допуск. raise ValueError('Параметры сравнения должны быть конечными числами') # Сообщаем причину до начала прохода. if absolute_tolerance < 0 or relative_tolerance_pct < 0: # Коридор допуска имеет неотрицательную ширину. raise ValueError('Допуск должен быть неотрицательным') # Отрицательный допуск не имеет физического смысла. if interval is not None and (not all(math.isfinite(x) for x in interval) or interval[0] >= interval[1]): # Обе границы должны образовывать конечный непустой интервал. raise ValueError('Начало интервала должно быть меньше конца') # Не переставляем ошибочно введённые границы. origin = measured.times[0] # Начало измерения задаёт ноль результирующей шкалы времени. ref_origin = reference.times[0] if align_start else origin # Опция совмещения отделяет относительное сравнение от абсолютного. mt = [t - origin for t in measured.times] # Нормализуем временную сетку измерения без изменения амплитуд. rt = [t - ref_origin + shift_ms for t in reference.times] # Совмещаем начало и применяем сдвиг эталона вправо. left, right = interval if interval is not None else (mt[0], mt[-1]) # По умолчанию используем полную длительность измерения. times, differences, expected = [], array('d'), array('d') # Остаток и интерполированный эталон получают общую сетку. count = candidates = outside = invalid = beyond = 0 # Считаем сравнения, выбранные точки, отсутствие покрытия, пропуски и превышения. mean = m2 = mean_abs = mean_square = 0. # Потоковые накопители среднего, дисперсии, MAE и среднего квадрата. peak, peak_time, first_outside = -1., None, None # Начальный максимум ниже нуля регистрирует и полностью совпадающие кривые. ref_min, ref_max = math.inf, -math.inf # Размах определяется на реально сравниваемой части эталона. j = 0 # Указатель отрезка эталона перемещается только вперёд. rv = reference.values # Локальная ссылка уменьшает накладные расходы основного прохода. for index, (stamp, value) in enumerate(zip(mt, measured.values)): # Перебираем каждый исходный отсчёт измерения. if index % 16384 == 0: # Проверка раз в 16384 точки ограничивает стоимость поддержки отмены. checkpoint(cancel) # Прерываем устаревший или отменённый расчёт. if stamp < left or stamp > right: # Границы интервала включаются, внешние точки исключаются. continue # Посторонние участки записи не влияют на статистику. candidates += 1 # Покрытие относится ко всем выбранным точкам измерения. if stamp < rt[0] or stamp > rt[-1]: # Вне временной сетки эталона экстраполяция запрещена. outside += 1 # Отдельно учитываем отсутствие покрытия по времени. continue # Не подставляем крайнее значение за пределами записи. while j + 1 < len(rt) and rt[j + 1] <= stamp: # Находим левую опору без повторного поиска с начала. j += 1 # Однонаправленное продвижение сохраняет линейную сложность. target = rv[j] # Точное совпадение с узлом не зависит от соседних пропусков. if stamp != rt[j]: # Между узлами используем две соседние опоры. fraction = (stamp - rt[j]) / (rt[j + 1] - rt[j]) # Доля положения внутри временного отрезка безразмерна. target = target + fraction * (rv[j + 1] - target) # NaN любой опоры сохраняет разрыв интерполяции. times.append(stamp) # Сохраняем общую временную сетку, включая пропуски. expected.append(target) # Интерполированные амплитуды доступны для дальнейшего анализа. if not math.isfinite(value) or not math.isfinite(target): # Некорректные значения любой кривой исключаются из показателей. invalid += 1 # Пропуски уменьшают процент покрытия интервала. differences.append(math.nan) # График остатка сохраняет разрыв на этом месте. continue # Не загрязняем численные накопители значениями NaN. difference = value - target # Знак ошибки всегда означает измерение минус эталон. differences.append(difference) # Каждая корректная точка получает собственный остаток. count += 1 # Число сравнений служит знаменателем средних показателей. delta = difference - mean # Алгоритм Уэлфорда использует предыдущее среднее. mean += delta / count # Обновляем среднее смещение без накопления большой суммы. m2 += delta * (difference - mean) # Центральный момент устойчиво оценивает разброс остатка. magnitude = abs(difference) # Модуль нужен для MAE, максимума и допуска. mean_abs += (magnitude - mean_abs) / count # Потоково обновляем среднюю абсолютную ошибку. mean_square += (difference * difference - mean_square) / count # Средний квадрат затем преобразуется в RMSE. ref_min, ref_max = min(ref_min, target), max(ref_max, target) # Нормализация использует фактический размах эталона. if magnitude > peak: # При равных максимумах оставляем время первого. peak, peak_time = magnitude, stamp # Величина и время максимума относятся к одной точке. if magnitude > absolute_tolerance + relative_tolerance_pct * .01 * abs(target): # Множитель 0,01 переводит процент в долю модуля эталона. beyond += 1 # Равенство границе допустимо; учитываем строгое превышение. if first_outside is None: # Время первого выхода не заменяется последующими. first_outside = stamp # Сохраняем миллисекунды от начала измерения. checkpoint(cancel) # Не публикуем результат, отменённый в последней порции. if not count: # Без корректного перекрытия показатели не определены. raise ValueError('Нет общих корректных отсчётов в выбранном интервале') # Вызывающая сторона сбрасывает старые показатели. rmse = math.sqrt(max(0., mean_square)) # Защита от отрицательного округления перед извлечением корня. metrics = dict(count=count, selected_points=candidates, outside_reference=outside, # Различаем использованные, выбранные и непокрытые точки. invalid_points=invalid, coverage_pct=100. * count / candidates, # Покрытие выражается в процентах от выбранных точек. bias=mean, mae=mean_abs, rmse=rmse, error_std=math.sqrt(max(0., m2 / count)), # СКО характеризует всю сравниваемую запись. max_abs=peak, max_abs_time_ms=peak_time, # Максимум хранится в единицах канала, время — в миллисекундах. nrmse_span_pct=100. * rmse / (ref_max - ref_min) if ref_max > ref_min else None, # Постоянный эталон не имеет ненулевого размаха для нормализации. outside_tolerance=beyond, outside_tolerance_pct=100. * beyond / count, # Превышения считаются только среди корректных сравнений. first_outside_time_ms=first_outside, # None явно означает отсутствие выхода за допуск. absolute_tolerance=absolute_tolerance, relative_tolerance_pct=relative_tolerance_pct, # Сохраняем параметры коридора для отчёта. align_start=align_start, shift_ms=shift_ms, interval_ms=interval, reference_min=ref_min, reference_max=ref_max) # Настройки и пределы сравниваемого эталона обеспечивают воспроизводимость анализа. result = Comparison(Trace(measured.name, mt, measured.values), # Повторно используем полный массив амплитуд измерения. Trace(reference.name, rt, reference.values), # Полный эталон доступен для наложения на график. Trace('Измерение − эталон', times, differences), expected, metrics) # Остаток и показатели относятся ко всем корректным исходным точкам. context = dict(channels, measured_origin_ms=origin, reference_origin_ms=ref_origin, shift_ms=shift_ms) if channels else None # Связанные каналы сохраняют взаимное положение относительно выбранного измерения и единого сдвига эталона. result.analysis = analyze_comparison(result, cancel, circuit=circuit, channels=context) # Идентификация моделей использует тот же снимок данных и явно заданные электрические ограничения. return result # Публикуем только целое сравнение вместе с завершёнными гипотезами.