Files
templates/python/set_devices/signal_reference.py
T
Andrey 41e24629ce Расширить DEIF, загрузку CAN и общую обработку сигналов
Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt.
Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor.
Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов.
Обновить справочники устройств, проекты CCS и регрессионные проверки.
2026-10-24 15:40:51 +03:00

169 lines
24 KiB
Python

"""Сравнение полной записи с 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 # Публикуем только целое сравнение вместе с завершёнными гипотезами.