Добавить общее ядро DEIF, эмуляцию приборов и переносимые панели Python/Qt. Расширить загрузчики F2812 и F28335, обновление Flash и профиль STM Invertor. Добавить альтернативный МНК Eigen, модели неисправностей и диагностику сигналов. Обновить справочники устройств, проекты CCS и регрессионные проверки.
397 lines
73 KiB
Python
397 lines
73 KiB
Python
"""Подбор конкурирующих моделей отклонения и эквивалентного колебательного контура.""" # Численный модуль использует стандартную библиотеку и не зависит от Qt.
|
|
from bisect import bisect_left # Поиск соседних отсчётов сохраняет реальные, в том числе неравномерные временные сетки.
|
|
import math # Экспоненты, тригонометрия и логарифмы задают динамические модели и критерий BIC.
|
|
from statistics import median # Робастные начальные оценки уменьшают влияние отдельных выбросов.
|
|
|
|
MAX_FIT_POINTS = 2048 # Ограничение относится только к подбору моделей; исходные метрики и поиск событий сохраняют все точки.
|
|
|
|
|
|
def checkpoint(cancel): # Подбор нелинейных параметров обязан прерываться при смене записи или закрытии окна.
|
|
if cancel(): # Callback передаётся из существующего рабочего потока сравнения.
|
|
raise InterruptedError('Анализ моделей отменён') # Не публикуем незавершённый подбор как достоверный результат.
|
|
|
|
|
|
def bounded_minimum(operation, left, right, iterations=24): # Ограниченный золотой поиск уточняет одну нелинейную координату без внешнего оптимизатора.
|
|
ratio = (math.sqrt(5.) - 1.) / 2. # Золотое сечение переиспользует одну оценку функции на каждом шаге.
|
|
a, b = right - ratio * (right - left), left + ratio * (right - left) # Две внутренние точки определяют следующую область поиска.
|
|
fa, fb = operation(a), operation(b) # Операция возвращает модель с обучающей ошибкой или отсутствие допустимого решения.
|
|
error = lambda model: model['training_sse'] if model else math.inf # Некорректный кандидат не может выигрывать ограниченную оптимизацию.
|
|
for _ in range(iterations): # Фиксированное число итераций ограничивает время и задаёт воспроизводимую точность.
|
|
if error(fa) < error(fb): # Минимум остаётся в левой части текущей области.
|
|
right, b, fb = b, a, fa # Правая внутренняя точка переиспользует уже вычисленное значение.
|
|
a = right - ratio * (right - left) # Новая точка лежит внутри уменьшенного интервала.
|
|
fa = operation(a) # Пересчитываем только одну модель за итерацию.
|
|
else: # Лучшее направление лежит справа или обе оценки одинаковы.
|
|
left, a, fa = a, b, fb # Левая точка также переиспользуется без повторного подбора.
|
|
b = left + ratio * (right - left) # Вычисляем новую внутреннюю правую точку.
|
|
fb = operation(b) # Контрольные наблюдения по-прежнему не участвуют в целевой функции.
|
|
return fa if error(fa) < error(fb) else fb # Возвращаем лучший допустимый набор параметров найденного интервала.
|
|
|
|
|
|
def interpolate(times, values, stamp): # Интерполяция для моделей не продолжает кривую за границы записи.
|
|
index = bisect_left(times, stamp) # Находим первую опору не левее запрошенного времени.
|
|
if index < len(times) and times[index] == stamp: # Точное попадание не зависит от соседнего пропуска.
|
|
return values[index] if math.isfinite(values[index]) else None # Неконечная опора остаётся отсутствующим наблюдением.
|
|
if index == 0 or index == len(times): # За границами данных экстраполяция запрещена.
|
|
return None # Модель с отсутствующей опорой не получает искусственного крайнего значения.
|
|
left, right = values[index - 1], values[index] # Две соседние амплитуды определяют отрезок интерполяции.
|
|
if not math.isfinite(left) or not math.isfinite(right): # Через явный разрыв NaN модель не строится.
|
|
return None # Невалидный участок будет исключён до сравнения моделей.
|
|
return left + (right - left) * (stamp - times[index - 1]) / (times[index] - times[index - 1]) # Вес вычисляется по фактическому времени, а не номеру точки.
|
|
|
|
|
|
def least_squares(columns, values): # Небольшой QR-решатель избегает неустойчивых нормальных уравнений при разном масштабе параметров.
|
|
count, size = len(values), len(columns) # Число наблюдений и неизвестных определяет размеры ортогональной системы.
|
|
if count <= size or not size: # Для оценки ошибки нужны наблюдения сверх числа параметров.
|
|
return None # Недоопределённую модель нельзя использовать для аппаратной гипотезы.
|
|
q, upper = [], [[0.] * size for _ in range(size)] # Храним только малую треугольную матрицу и ортогональные столбцы.
|
|
scales = [math.sqrt(sum(value * value for value in column)) for column in columns] # Нормируем столбцы перед проверкой независимости параметров.
|
|
for index, column in enumerate(columns): # Модифицированный Грамм–Шмидт выполняется отдельно для каждого неизвестного.
|
|
if scales[index] <= 1e-30: # Нулевой столбец означает, что параметр не наблюдается в выбранных данных.
|
|
return None # Не выдаём произвольный коэффициент для ненаблюдаемого воздействия.
|
|
vector = [value / scales[index] for value in column] # Безразмерная нормировка снижает влияние единиц В, А и мкс на численную устойчивость.
|
|
for _ in range(2): # Повторная ортогонализация уменьшает потерю точности у почти зависимых столбцов.
|
|
for previous, basis in enumerate(q): # Удаляем уже описанные направления изменения сигнала.
|
|
projection = sum(a * b for a, b in zip(basis, vector)) # Скалярное произведение определяет долю предыдущего базиса.
|
|
upper[previous][index] += projection # Обе ортогонализации относятся к одной треугольной системе.
|
|
vector = [value - projection * base for value, base in zip(vector, basis)] # Остаток содержит только новое независимое направление.
|
|
norm = math.sqrt(sum(value * value for value in vector)) # Длина остатка показывает идентифицируемость следующего параметра.
|
|
if norm < 1e-9: # Относительная независимость меньше 10^-9 считается численно вырожденной.
|
|
return None # Например, постоянный ток не позволяет раздельно определить U0 и сопротивление.
|
|
upper[index][index] = norm # Диагональ задаёт масштаб нового ортогонального направления.
|
|
q.append([value / norm for value in vector]) # Единичный столбец сохраняет устойчивую проекцию правой части.
|
|
target = [sum(a * b for a, b in zip(basis, values)) for basis in q] # Проецируем измерение на полученный ортонормированный базис.
|
|
coefficients = [0.] * size # Обратный ход начинается с последнего неизвестного.
|
|
for index in reversed(range(size)): # Верхнетреугольная система решается без дополнительной факторизации.
|
|
coefficients[index] = (target[index] - sum(upper[index][j] * coefficients[j] for j in range(index + 1, size))) / upper[index][index] # Вычитаем уже найденные параметры справа от диагонали.
|
|
return [value / scale for value, scale in zip(coefficients, scales)] # Возвращаем параметры в исходных физических единицах столбцов.
|
|
|
|
|
|
def evaluate(columns, coefficients): # Одна формула вычисляет прогноз как для обучающих, так и для контрольных точек.
|
|
return [sum(column[index] * value for column, value in zip(columns, coefficients)) for index in range(len(columns[0]))] # Сумма базисных функций не обращается к измеренной амплитуде.
|
|
|
|
|
|
def fit_model(code, formula, columns, values, names, extra_parameters=0, params=None): # Каждый кандидат оценивается на одних и тех же наблюдениях.
|
|
training = [index for index in range(len(values)) if index % 3 != 1] # Каждая третья точка оставляется для проверки интерполяционного прогноза.
|
|
coefficients = least_squares([[column[i] for i in training] for column in columns], [values[i] for i in training]) # Контрольные амплитуды не участвуют в оценке линейных коэффициентов.
|
|
if coefficients is None: # Вырожденная модель не должна выигрывать из-за произвольных параметров.
|
|
return None # Вызывающая сторона честно сообщает отсутствие идентифицируемой оценки.
|
|
prediction = evaluate(columns, coefficients) # Один набор параметров применяется также к отложенным точкам.
|
|
parameters = dict(zip(names, coefficients)) # Имена отражают физический смысл оценённых коэффициентов.
|
|
parameters.update(params or {}) # Нелинейные параметры включаются в тот же машиночитаемый отчёт.
|
|
return score_model(code, formula, values, prediction, len(columns) + extra_parameters, parameters) # BIC учитывает и линейные, и нелинейные степени свободы.
|
|
|
|
|
|
def score_model(code, formula, values, prediction, parameters, params): # Показатели качества имеют одинаковое определение для всех конкурентов.
|
|
residuals = [value - fitted for value, fitted in zip(values, prediction)] # Остаток всегда сохраняет знак «измерение минус модель».
|
|
square = sum(value * value for value in residuals) # SSE измеряется в квадрате единиц анализируемого канала.
|
|
validation = [value for index, value in enumerate(residuals) if index % 3 == 1] # Считаем ошибку только по точкам, не использованным для линейного подбора.
|
|
training_square = sum(value * value for index, value in enumerate(residuals) if index % 3 != 1) # Нелинейный поиск оптимизирует только обучающую часть.
|
|
training_count = len(values) - len(validation) # BIC вычисляется на тех же наблюдениях, на которых оценивались параметры модели.
|
|
mean = sum(values) / len(values) # Среднее определяет постоянную модель для вычисления R².
|
|
variance = sum((value - mean) ** 2 for value in values) # Полный разброс нужен только для описания качества, а не для постановки диагноза.
|
|
residual_mean = sum(residuals) / len(residuals) # Среднее остатка вычисляем один раз, сохраняя линейную сложность оценки кандидата.
|
|
centered = [value - residual_mean for value in residuals] # Автокорреляция оценивается после удаления среднего остатка.
|
|
denominator = sum(value * value for value in centered) # Нулевой остаток не имеет определённой автокорреляции.
|
|
floor = max(1e-24, max(abs(value) for value in values) ** 2 * 1e-24) # Численный пол 10^-12 амплитудного масштаба не является допуском исправности.
|
|
return dict(code=code, formula=formula, parameters=params, parameter_count=parameters, count=len(values), sse=square, training_sse=training_square, # Сохраняем параметры и число степеней свободы для независимого воспроизведения.
|
|
rmse=math.sqrt(square / len(values)), validation_rmse=math.sqrt(sum(value * value for value in validation) / max(1, len(validation))), # Контрольная ошибка отделена от ошибки по всей выборке.
|
|
bic=training_count * math.log(max(training_square / training_count, floor)) + parameters * math.log(training_count), bic_count=training_count, # Для гауссовой ошибки сравниваем BIC обучения, сохраняя контрольные точки вне оценки параметров и критерия.
|
|
r_squared=1. - square / variance if variance > floor else None, residual_lag1=sum(a * b for a, b in zip(centered, centered[1:])) / denominator if denominator > floor else None) # Корреляция остатка показывает неописанную динамику и ограничение гауссового критерия.
|
|
|
|
|
|
def _samples(result, maximum=MAX_FIT_POINTS, cancel=lambda: False): # Стратифицированная выборка сохраняет экстремумы ошибки каждого временного блока и поддерживает отмену.
|
|
count = len(result.difference.times) # Метрики и старые признаки уже рассчитаны по всем исходным отсчётам.
|
|
width = max(1, math.ceil(count / max(1, maximum // 4))) # В каждом блоке оставляем начало, конец и два экстремума остатка.
|
|
selected = set() # Совпадающие индексы не должны менять вес отдельного наблюдения.
|
|
for start in range(0, count, width): # Число выбранных точек ограничено независимо от размера исходного CSV.
|
|
checkpoint(cancel) # Подготовка многомиллионной записи прерывается между небольшими временными блоками.
|
|
stop = min(count, start + width) # Последний неполный блок также участвует в подборе.
|
|
valid = [i for i in range(start, stop) if math.isfinite(result.expected[i]) and math.isfinite(result.difference.values[i])] # Пропуски остаются исключёнными из подбора.
|
|
if valid: # Пустой блок не создаёт фиктивного измерения.
|
|
selected.update((valid[0], valid[-1], min(valid, key=lambda i: result.difference.values[i]), max(valid, key=lambda i: result.difference.values[i]))) # Узкие импульсы сохраняются даже при длинной записи.
|
|
return [(result.difference.times[i] * 1000., result.expected[i], result.expected[i] + result.difference.values[i]) for i in sorted(selected)] # Модели используют микросекунды и исходные единицы амплитуды.
|
|
|
|
|
|
def fit_reference_models(result, events, cancel=lambda: False): # Конкурируют нормальный эталон, калибровка, дрейф, задержка, растяжение и разрывы.
|
|
samples = _samples(result, cancel=cancel) # Подбор ограничен по объёму и не меняет полную статистику сравнения.
|
|
if len(samples) < 24: # Трёхчастное разбиение требует достаточно обучающих и контрольных точек.
|
|
return dict(models=[], best=None, sampled_points=len(samples), sampling='стратифицированные экстремумы остатка') # Не называем короткую запись пригодной для идентификации модели.
|
|
duration = samples[-1][0] - samples[0][0] # Длительность выражена в микросекундах.
|
|
margin = .1 * duration if events else 0. # Общий интервал опор исключает экстраполяцию при поиске задержки до 10% длительности.
|
|
reference_times = result.reference.times # Повторно используем полную миллисекундную сетку без многомиллионной дополнительной копии.
|
|
samples = [point for point in samples if reference_times[0] * 1000. + margin <= point[0] <= reference_times[-1] * 1000. - margin] # Все глобальные модели сравниваются на строго одинаковой области поддержки в мкс.
|
|
if len(samples) < 24: # Слишком короткое перекрытие не позволяет честно сравнивать кандидатов.
|
|
return dict(models=[], best=None, sampled_points=len(samples), sampling='нет общего интервала моделей') # Не подменяем отсутствующие точки экстраполяцией.
|
|
times, reference, measured = map(list, zip(*samples)) # Параллельные массивы имеют одинаковую длину и порядок.
|
|
ones = [1.] * len(times) # Свободный член описывает смещение нуля в единицах канала.
|
|
models = [score_model('reference', 'y(t)=r(t)', measured, reference, 0, {})] # Исходный эталон является обязательной нулевой гипотезой.
|
|
offset = fit_model('offset', 'y(t)=r(t)+b', [ones], [value - base for value, base in zip(measured, reference)], ['offset']) # Смещение подбирается по остатку при фиксированном единичном усилении.
|
|
if offset: # Ошибки остатка совпадают с ошибками исходной аддитивной модели.
|
|
models.append(offset) # Не вводим лишний коэффициент усиления для постоянного смещения.
|
|
for code, formula, columns, names in [('gain', 'y(t)=a*r(t)+b', [reference, ones], ['gain', 'offset']), # Аффинная модель отделяет масштаб измерения от аппаратной динамики.
|
|
('drift', 'y(t)=a*r(t)+b+c*(t-t0)', [reference, ones, [stamp - times[0] for stamp in times]], ['gain', 'offset', 'drift_per_us']), # Линейный дрейф описывает изменение питания или нуля по времени.
|
|
('constant', 'y(t)=b', [ones], ['level'])]: # Постоянная модель проверяет отсутствие ожидаемого переключения.
|
|
candidate = fit_model(code, formula, columns, measured, names) # Линейные коэффициенты получают независимую контрольную ошибку.
|
|
if candidate: # Вырожденные варианты не входят в рейтинг.
|
|
models.append(candidate) # Все допустимые кандидаты сохраняются для полного отчёта.
|
|
centers = [event[0] * 1000. for event in events] # Центры эталонных переходов задают локальное растяжение без изменения паузы между импульсами.
|
|
if centers and len(centers) <= 32 and margin > 0. and result.metrics['outside_tolerance']: # Не подбираем сложную динамику для допустимого сигнала или более 32 неоднозначных переходов.
|
|
def temporal(lag, stretch, code): # Нелинейные параметры подбираются отдельно от линейных усиления и нуля.
|
|
checkpoint(cancel) # Каждый кандидат проверяет отмену до большого прохода интерполяции.
|
|
warped = [] # Прогноз эталона строится на той же сетке всех глобальных моделей.
|
|
for stamp in times: # Сдвиг и растяжение не меняют метки фактического измерения.
|
|
center = min(centers, key=lambda value: abs(stamp - lag - value)) # Ближайший фронт определяет локальную систему времени.
|
|
query = center + (stamp - lag - center) / stretch # Положительный lag задерживает измерение, stretch>1 растягивает фронт.
|
|
value = interpolate(reference_times, result.reference.values, query / 1000.) # Перевод мкс в мс сохраняет исходную полную сетку без копирования.
|
|
if value is None: # Отсутствующая опора делает именно этого кандидата недопустимым.
|
|
return None # Нельзя улучшать SSE уменьшением числа контрольных точек.
|
|
warped.append(value) # Временная модель сохраняет все наблюдения общей области.
|
|
return fit_model(code, 'y(t)=a*r(te+(t-delay-te)/stretch)+b', [warped, ones], measured, ['gain', 'offset'], 1 if code == 'timing' else 2, dict(delay_us=lag, stretch=stretch)) # BIC штрафует оба нелинейных параметра растяжения.
|
|
best_delay = None # Чистая задержка конкурирует с изменением формы фронта.
|
|
for index in range(21): # Грубая сетка из 21 точки охватывает ±10% общей длительности.
|
|
candidate = temporal(-margin + 2. * margin * index / 20., 1., 'timing') # Растяжение фиксировано единицей для отдельной модели задержки.
|
|
if candidate and (best_delay is None or candidate['training_sse'] < best_delay['training_sse']): # Нелинейный поиск использует только обучающие остатки.
|
|
best_delay = candidate # Контрольные точки не выбирают оптимальную задержку.
|
|
if best_delay: # При наличии перекрытия уточняем задержку последовательным уменьшением шага.
|
|
step = margin / 10. # Начальный шаг равен расстоянию между грубыми кандидатами.
|
|
for _ in range(9): # Девять уточнений дают разрешение существенно меньше исходного шага фронта.
|
|
for lag in (best_delay['parameters']['delay_us'] - step / 2., best_delay['parameters']['delay_us'] + step / 2.): # Симметричный поиск допускает опережение и запаздывание.
|
|
candidate = temporal(lag, 1., 'timing') # Линейные параметры заново оптимизируются для каждой задержки.
|
|
if candidate and candidate['training_sse'] < best_delay['training_sse']: # Контрольная ошибка остаётся независимой от решения.
|
|
best_delay = candidate # Принимаем только реальное улучшение обучающего описания.
|
|
step /= 2. # Локальный шаг уменьшается геометрически.
|
|
models.append(best_delay) # Чистая задержка получает самостоятельную строку отчёта.
|
|
best_stretch = best_delay # Единичное растяжение служит допустимой исходной точкой.
|
|
for stretch in (.4, .6, .8, 1., 1.3, 1.7, 2.3, 3., 4.): # Диапазон 0,4–4 охватывает ускоренные и замедленные фронты без неограниченного переобучения.
|
|
candidate = temporal(best_delay['parameters']['delay_us'], stretch, 'slow_switching') # Одновременное уточнение задержки выполняется следующим координатным проходом.
|
|
if candidate and candidate['training_sse'] < best_stretch['training_sse']: # Выбираем старт по обучающим точкам.
|
|
best_stretch = candidate # Лучший локальный масштаб определяет следующую область поиска.
|
|
lag_step, scale_step = margin / 10., .5 # Начальные интервалы координатного уточнения заданы в мкс и безразмерном масштабе.
|
|
for _ in range(9): # Ограниченное число шагов сохраняет предсказуемое время работы.
|
|
lag, stretch = best_stretch['parameters']['delay_us'], best_stretch['parameters']['stretch'] # Обе координаты уточняются относительно текущего решения.
|
|
for next_lag, next_stretch in ((lag - lag_step, stretch), (lag + lag_step, stretch), (lag, max(.25, stretch - scale_step)), (lag, min(5., stretch + scale_step))): # Ограничения 0,25–5 исключают вырожденное сжатие времени.
|
|
candidate = temporal(next_lag, next_stretch, 'slow_switching') # Для каждой комбинации коэффициенты a и b оцениваются QR-методом.
|
|
if candidate and candidate['training_sse'] < best_stretch['training_sse']: # Контрольные точки не участвуют в поиске.
|
|
best_stretch = candidate # Сохраняем лучшее согласование динамики фронтов.
|
|
lag_step /= 2.; scale_step /= 2. # Обе сетки последовательно уточняются.
|
|
if best_stretch['code'] == 'slow_switching': # Совпадение с чистой задержкой не создаёт дублирующую модель.
|
|
models.append(best_stretch) # Штраф за дополнительное растяжение учитывается при окончательном выборе BIC.
|
|
span = result.metrics['reference_max'] - result.metrics['reference_min'] # Масштаб состояния берётся из полного сравниваемого эталона.
|
|
if span > 0.: # Для постоянной полки невозможно определить противоположное состояние контакта.
|
|
active, intervals = None, [] # Кусочная модель оценивает границы каждого дополнительного переключения.
|
|
training = [index for index in range(len(times)) if index % 3 != 1] # Моменты разрывов ищутся только на обучающих точках.
|
|
for index in training: # Принадлежность состоянию определяется относительно двух эталонных полок.
|
|
target = result.metrics['reference_max'] if reference[index] < (result.metrics['reference_min'] + result.metrics['reference_max']) / 2. else result.metrics['reference_min'] # Противоположная полка соответствует прерыванию нормального состояния.
|
|
contact = abs(measured[index] - target) < .3 * span and abs(measured[index] - reference[index]) > .5 * span # Пространство состояний ограничено наблюдаемым полным размахом.
|
|
if contact and active is None: # Новый интервал начинается с первого подтверждённого противоположного состояния.
|
|
active = index # Сохраняем индекс, а не округлённое время.
|
|
elif not contact and active is not None: # Возврат завершает кусок неправильного состояния.
|
|
if times[index] > times[active] and len(intervals) < 12: # Максимум 12 сегментов ограничивает сложность и вычислительную стоимость модели.
|
|
intervals.append((times[active], times[index])) # Длительность измеряется по исходной временной сетке.
|
|
active = None # Следующий разрыв подбирается отдельно.
|
|
if intervals: # Без завершённых прерываний кусочная модель отсутствует.
|
|
boxes = [[float(left <= stamp < right) for stamp in times] for left, right in intervals] # Границы, найденные по обучению, применяются также к контрольным временам.
|
|
candidate = fit_model('contact_bounce', 'y(t)=a*r(t)+b+sum(cj*I[tj0,tj1])', [reference, ones, *boxes], measured, ['gain', 'offset', *[f'event_{i}_amplitude' for i in range(len(boxes))]], 2 * len(boxes), dict(events=[dict(start_us=left, stop_us=right, duration_us=right - left) for left, right in intervals])) # BIC включает амплитуду и обе границы каждого прерывания.
|
|
if candidate: # Линейно зависимые сегменты не должны попадать в рейтинг.
|
|
models.append(candidate) # Совместный дефект может оставить звон в остатке этой кусочной модели.
|
|
baseline = models[0] # Исходный эталон задаёт общую точку отсчёта улучшения.
|
|
floor = max(1e-24, max(abs(value) for value in measured) ** 2 * 1e-24) # Все глобальные модели используют один численный пол дисперсии даже при подгонке аддитивного остатка.
|
|
for model in models: # Единый масштаб предотвращает искусственное преимущество модели при почти нулевой численной ошибке.
|
|
count = model['bic_count'] # Число обучающих точек одинаково для всех глобальных конкурентов.
|
|
model['bic'] = count * math.log(max(model['training_sse'] / count, floor)) + model['parameter_count'] * math.log(count) # Штраф и дисперсия оцениваются на одной и той же обучающей выборке.
|
|
for model in models: # Все показатели сравнения моделей вычисляются на одном наборе времён.
|
|
model['delta_bic'] = baseline['bic'] - model['bic'] # Положительное значение означает преимущество относительно неизменённого эталона.
|
|
model['explained_error_pct'] = 100. * (1. - model['sse'] / baseline['sse']) if baseline['sse'] > 0. else 0. # Показываем, какую долю исходной ошибки описала модель.
|
|
model['supported'] = model['delta_bic'] >= 10. and model['validation_rmse'] < .8 * baseline['validation_rmse'] and model['explained_error_pct'] > 50. # Решение требует штрафованного улучшения и снижения контрольной ошибки минимум на 20%.
|
|
models.sort(key=lambda model: model['bic']) # Рейтинг показывает конкурентов, а не единственный заранее выбранный диагноз.
|
|
best = models[0] if models[0]['supported'] else baseline # При недостатке доказательств сохраняем исходную гипотезу без аппаратного вывода.
|
|
return dict(models=models, best=best['code'], sampled_points=len(times), interval_us=[times[0], times[-1]], sampling='до 2048 стратифицированных точек, включая экстремумы; контроль — каждая третья точка') # Отчёт честно отделяет полную статистику от ограниченного подбора.
|
|
|
|
|
|
def fit_damped_ring(times_us, values, cancel=lambda: False): # Свободный отклик второго порядка описывается экспонентой и синусом с неизвестной фазой.
|
|
if len(values) < 24 or times_us[-1] <= times_us[0]: # Короткая или нулевая временная область не позволяет идентифицировать колебание.
|
|
return None # Отсутствующая оценка не заменяется произвольной частотой.
|
|
full_level = median(values[-max(8, len(values) // 4):]) # Начальную частоту оцениваем до прореживания, чтобы не принять алиас за реальный резонанс.
|
|
full_amplitude = max(abs(value - full_level) for value in values) # Полоса переходов задаётся полным наблюдаемым откликом.
|
|
full_crossings, previous = [], 0 # Сначала используем полную временную сетку для оценки доступной частоты.
|
|
for index, (stamp, value) in enumerate(zip(times_us, values)): # Исходные пересечения уровня не зависят от ограниченной выборки нелинейного подбора.
|
|
if index % 16384 == 0: # Подготовку большого окна также можно прервать.
|
|
checkpoint(cancel) # Не ждём окончания длинного прохода после запроса отмены.
|
|
sign = 1 if value - full_level > .05 * full_amplitude else -1 if value - full_level < -.05 * full_amplitude else 0 # Гистерезис подавляет поздний шум относительно начального колебания.
|
|
if sign and previous and sign != previous: # Противоположные значимые полуволны определяют половину периода.
|
|
full_crossings.append(stamp) # Время сохраняется до любой децимации.
|
|
if sign: # Промежуточная зона не меняет последнюю подтверждённую полуволну.
|
|
previous = sign # Дальнейшее пересечение сравнивается с наблюдаемым знаком.
|
|
if len(full_crossings) < 4: # Короткий звон или одиночный выброс не дают устойчивой начальной частоты.
|
|
return None # Не создаём резонансную модель без нескольких наблюдаемых полупериодов.
|
|
initial_frequency = .5 / median(b - a for a, b in zip(full_crossings, full_crossings[1:])) # Частота определяется до возможного понижения частоты дискретизации.
|
|
full_step = median(b - a for a, b in zip(times_us, times_us[1:])) # Фактический шаг исходных наблюдений задаёт допустимую децимацию.
|
|
if full_step <= 0.: # Невозрастающая сетка не имеет физически определённой частоты выборки.
|
|
return None # Не исправляем повреждённые метки времени молча.
|
|
maximum_stride = max(1, int(.2 / (initial_frequency * full_step))) # Сохраняем минимум пять отсчётов на исходно наблюдаемый период.
|
|
stride = min(maximum_stride, max(1, math.ceil(len(values) / 512))) # Ограничение памяти не должно создавать ложную низкочастотную модель из высокочастотного звона.
|
|
times = [stamp - times_us[0] for stamp in times_us[::stride][:512]] # Если безопасная децимация недостаточна, сокращаем окно, сохраняя разрешение периода.
|
|
measured = list(values[::stride][:512]) # Подбор получает не более 512 реально наблюдаемых амплитуд без алиасного понижения частоты.
|
|
duration = times[-1] - times[0] # Длительность участка выражена в мкс.
|
|
step = median(b - a for a, b in zip(times, times[1:])) # Медианный шаг устойчив к отдельным неравномерным промежуткам.
|
|
if max(b - a for a, b in zip(times, times[1:])) > 5. * step: # Через большой ненаблюдавшийся промежуток колебание не подбирается.
|
|
return None # Спектральная интерпретация разорванного участка была бы неоднозначной.
|
|
level = median(measured[-max(8, len(measured) // 4):]) # Хвост задаёт начальную оценку установившегося уровня.
|
|
amplitude = max(abs(value - level) for value in measured) # Оценка амплитуды нужна только для устойчивого поиска начальной частоты.
|
|
previous, crossings = 0, [] # Переходы через небольшой гистерезис задают начальную частоту, но не сам диагноз.
|
|
for stamp, value in zip(times, measured): # Анализируем обе полуволны колебания относительно хвостовой полки.
|
|
sign = 1 if value - level > .05 * amplitude else -1 if value - level < -.05 * amplitude else 0 # Полоса 5% начальной амплитуды подавляет шум после затухания.
|
|
if sign and previous and sign != previous: # Между противоположными полуволнами проходит приблизительно половина периода.
|
|
crossings.append(stamp) # Сохраняем времена для робастной начальной оценки периода.
|
|
if sign: # Нулевая полоса не создаёт дополнительных смен знака.
|
|
previous = sign # Последняя значимая полуволна остаётся опорной.
|
|
if len(crossings) < 4: # Менее двух наблюдаемых периодов недостаточно для уверенной идентификации затухания.
|
|
return None # Одиночный выброс не объявляется резонансом.
|
|
initial = .5 / median(b - a for a, b in zip(crossings, crossings[1:])) # Частота в циклах/мкс соответствует МГц.
|
|
ones, best = [1.] * len(times), None # Постоянный уровень подбирается вместе с двумя фазовыми компонентами.
|
|
def candidate(frequency, alpha): # Метод переменной проекции отделяет нелинейные частоту/затухание от линейных коэффициентов.
|
|
checkpoint(cancel) # Длительный перебор можно кооперативно отменить.
|
|
if not 1.5 / duration <= frequency <= .2 / step or alpha < 0.: # Требуем наблюдаемые периоды и минимум пять отсчётов на период, сохраняя запас до границы Найквиста.
|
|
return None # Неоднозначные или нарастающие решения этой пассивной модели отклоняются.
|
|
cosine = [math.exp(-alpha * stamp) * math.cos(math.tau * frequency * stamp) for stamp in times] # Первая фазовая компонента затухающего отклика.
|
|
sine = [math.exp(-alpha * stamp) * math.sin(math.tau * frequency * stamp) for stamp in times] # Вторая компонента допускает произвольную начальную фазу.
|
|
return fit_model('damped_rlc', 'v(t)=Vinf+exp(-alpha*t)*(A*cos(2*pi*f*t)+B*sin(2*pi*f*t))', [ones, cosine, sine], measured, ['level', 'cosine', 'sine'], 2, dict(frequency_hz=frequency * 1e6, alpha_per_us=alpha)) # BIC учитывает пять оцениваемых параметров.
|
|
for fi in range(17): # Начальная частота уточняется в диапазоне ±35% оценки по переходам через уровень.
|
|
frequency = initial * (.65 + .7 * fi / 16.) # Ограниченный диапазон снижает риск выбора гармоники вместо основного колебания.
|
|
for alpha in [0.] + [10. ** (-1.5 + index * 3. / 10.) / duration for index in range(11)]: # Логарифмическая сетка охватывает слабое и быстрое затухание относительно окна наблюдения.
|
|
model = candidate(frequency, alpha) # Линейные параметры подбираются QR-методом для каждой пары частоты и затухания.
|
|
if model and (best is None or model['training_sse'] < best['training_sse']): # Контрольные точки не выбирают параметры нелинейного поиска.
|
|
best = model # Сохраняем лучшее объяснение обучающих наблюдений.
|
|
if best is None: # Частота могла оказаться выше разрешения исходной записи.
|
|
return None # Недостаточная дискретизация не компенсируется выдуманными параметрами.
|
|
frequency_step, alpha_step = .05 * initial, max(best['parameters']['alpha_per_us'] * .5, 1. / duration) # Локальное уточнение начинается с масштаба грубой сетки.
|
|
for _ in range(10): # Десять координатных уточнений дают воспроизводимое ограниченное время подбора.
|
|
frequency, alpha = best['parameters']['frequency_hz'] / 1e6, best['parameters']['alpha_per_us'] # Возвращаем текущие параметры к внутренним мкс.
|
|
for next_frequency, next_alpha in ((frequency - frequency_step, alpha), (frequency + frequency_step, alpha), (frequency, max(0., alpha - alpha_step)), (frequency, alpha + alpha_step)): # Частота и затухание уточняются независимо с неотрицательным ограничением потерь.
|
|
model = candidate(next_frequency, next_alpha) # Линейные коэффициенты пересчитываются при каждом изменении динамики.
|
|
if model and model['training_sse'] < best['training_sse']: # Принимаем только улучшение на обучающей части.
|
|
best = model # Контрольный остаток остаётся показателем проверки модели.
|
|
frequency_step /= 2.; alpha_step /= 2. # Постепенно уменьшаем обе области поиска.
|
|
for _ in range(4): # Повторное точное уточнение снимает взаимное влияние ошибки частоты и оценённого затухания.
|
|
frequency, alpha = best['parameters']['frequency_hz'] / 1e6, best['parameters']['alpha_per_us'] # Исходная точка уже найдена грубой сеткой и локальными шагами.
|
|
refined = bounded_minimum(lambda value: candidate(value, alpha), frequency * .97, frequency * 1.03) # Частота уточняется внутри локального диапазона ±3%.
|
|
if refined and refined['training_sse'] < best['training_sse']: # Золотой поиск не должен ухудшать уже полученное решение.
|
|
best = refined # Новая частота уменьшает систематическую ошибку фазы по всему окну.
|
|
frequency = best['parameters']['frequency_hz'] / 1e6 # Следующее уточнение затухания использует уже исправленную частоту.
|
|
refined = bounded_minimum(lambda value: candidate(frequency, value), 0., max(3. * alpha, 5. / duration)) # Неотрицательное затухание уточняется независимо от положения грубой логарифмической сетки.
|
|
if refined and refined['training_sse'] < best['training_sse']: # Сохраняем только реальное улучшение обучающего описания.
|
|
best = refined # Время затухания больше не наследует грубую ошибку предыдущей сетки частоты.
|
|
params = best['parameters'] # Дополнительные физические параметры вычисляются из найденных полюсов второго порядка.
|
|
omega = math.tau * params['frequency_hz'] # Угловая частота затухающего колебания выражается в рад/с.
|
|
alpha = params['alpha_per_us'] * 1e6 # Переводим скорость затухания из 1/мкс в 1/с.
|
|
natural = math.hypot(omega, alpha) # Для недодемпфированного отклика omega0²=omega_d²+alpha².
|
|
params.update(amplitude=math.hypot(params['cosine'], params['sine']), decay_time_us=1e6 / alpha if alpha > 0. else None, damping_ratio=alpha / natural, # Амплитуда относится к началу выбранного участка; отсутствие потерь не создаёт Infinity в JSON.
|
|
natural_frequency_hz=natural / math.tau, quality_factor=natural / (2. * alpha) if alpha > 0. else None, lc_product=1. / natural ** 2, r_over_l=2. * alpha) # LC и R/L идентифицируются без задания отдельного R, L или C для последовательного эквивалента.
|
|
constant = fit_model('constant', 'v(t)=Vinf', [ones], measured, ['level']) # Проверяем, что сложный отклик лучше простой постоянной полки.
|
|
best.update(delta_bic=constant['bic'] - best['bic'], interval_us=[times_us[0], times_us[0] + times[-1]], frequency_resolution_hz=1e6 / duration, input_points=len(values), decimation_stride=stride) # Отчёт сохраняет фактическое укороченное окно и безопасный шаг подбора.
|
|
best['supported'] = best['delta_bic'] >= 10. and best['r_squared'] is not None and best['r_squared'] >= .8 and best['validation_rmse'] < .5 * constant['validation_rmse'] # Пригодность отклика проверяется штрафом сложности, R² и контрольной ошибкой.
|
|
return best # Возвращаем также неподтверждённый подбор, чтобы отчёт показывал причину отказа от гипотезы.
|
|
|
|
|
|
def equivalent_rlc(model, circuit=None): # Параметры относятся только к явно выбранному последовательному эквиваленту второго порядка.
|
|
if not model or not model.get('supported'): # По плохо описанному колебанию нельзя вычислять электрические элементы.
|
|
return dict(identifiable=False, reason='Затухающий отклик не подтверждён данными.') # Честно сохраняем отсутствие физической оценки.
|
|
supplied = {key: float(value) for key, value in (circuit or {}).items() if key in ('inductance_h', 'capacitance_f', 'resistance_ohm') and math.isfinite(value) and value > 0.} # Нулевые значения означают неизвестные элементы, единицы — СИ.
|
|
product, ratio = model['parameters']['lc_product'], model['parameters']['r_over_l'] # Найденные полюса определяют только LC и R/L без дополнительного ограничения.
|
|
if 'inductance_h' in supplied: # Известная индуктивность однозначно задаёт оставшиеся параметры эквивалента.
|
|
inductance = supplied['inductance_h'] # Используем явно введённое значение в генри.
|
|
elif 'capacitance_f' in supplied: # Известная ёмкость позволяет восстановить L из произведения LC.
|
|
inductance = product / supplied['capacitance_f'] # Произведение LC имеет единицу с².
|
|
elif 'resistance_ohm' in supplied and ratio > 0.: # При ненулевом затухании известное сопротивление определяет индуктивность.
|
|
inductance = supplied['resistance_ohm'] / ratio # Деление R на R/L возвращает генри.
|
|
else: # Одна осциллограмма без дополнительного элемента не определяет все три неизвестных.
|
|
return dict(identifiable=False, lc_product=product, r_over_l=ratio, supplied=supplied, reason='Нужен хотя бы один известный R, L или C; по отклику определяются только LC и R/L.') # Не выдаём произвольные значения отдельных элементов.
|
|
estimated = dict(inductance_h=inductance, capacitance_f=product / inductance, resistance_ohm=ratio * inductance) # Это эквивалентные параметры полюсов, а не автоматически номиналы RC-снаббера.
|
|
mismatch = {key: (estimated[key] / value - 1.) * 100. for key, value in supplied.items()} # Несколько известных элементов позволяют проверить согласованность предположенной топологии.
|
|
return dict(identifiable=True, topology='последовательный эквивалент RLC', estimated=estimated, supplied=supplied, deviation_pct=mismatch) # Отделяем введённые значения от восстановленных и не скрываем противоречия.
|
|
|
|
|
|
def fit_ringing_pair(result, events, circuit=None, cancel=lambda: False, delay_us=0.): # Компенсируем оценённую задержку перед сравнением полюсов, чтобы сдвиг фронта не выглядел отказом снаббера.
|
|
rising = [event for event in events if event[2] == 1][-4:] # Не более четырёх последних выключений ограничивают стоимость нелинейного подбора.
|
|
pairs = [] # Каждый участок имеет собственные границы, качество и параметры.
|
|
low, high = result.metrics['reference_min'], result.metrics['reference_max'] # Огибающая взята только из реально сравниваемого эталона.
|
|
for center, width, _ in rising: # Время и длительность события выражены во внутренних миллисекундах.
|
|
checkpoint(cancel) # Смена записи может отменить подбор между отдельными выключениями.
|
|
start = bisect_left(result.difference.times, center + width / 2.) # Начинаем поиск установившейся полки после пересечения верхнего порога фронта.
|
|
stop_time = min(result.difference.times[-1], result.measured.times[-1] - delay_us / 1000., center + max(40. * width, .002)) # Ограничиваем обе стороны реальными опорами после компенсации задержки без экстраполяции.
|
|
stop = bisect_left(result.difference.times, stop_time) # Конечный индекс не выходит за реально наблюдаемое перекрытие.
|
|
for index in range(max(1, start), min(stop - 1, len(result.expected) - 1)): # Ищем первый максимум эталона после завершения основного роста напряжения.
|
|
if result.expected[index] > low + .85 * (high - low) and result.expected[index - 1] <= result.expected[index] > result.expected[index + 1]: # Первый пик на высокой полке отделяет колебательный отклик от незавершённого фронта.
|
|
start = index # Неизвестная фаза синуса допускает начало подбора с любого пика.
|
|
break # Последующие колебания не должны сдвигать начало до почти нулевой амплитуды.
|
|
indices = range(start, stop) # Обе кривые подбираются на строго одинаковом временном интервале.
|
|
if stop - start < 24 or any(not math.isfinite(result.expected[i]) or not math.isfinite(result.difference.values[i]) for i in indices): # Разрыв внутри окна не маскируется удалением отдельных отсчётов колебания.
|
|
continue # Такому выключению не приписываем достоверный резонансный контур.
|
|
times = [result.difference.times[i] * 1000. for i in indices] # Подбор использует микросекунды без потери дробной части.
|
|
reference = [result.expected[i] for i in indices] # Эталон уже интерполирован на фактическую сетку измерения.
|
|
measured = [interpolate(result.measured.times, result.measured.values, result.difference.times[i] + delay_us / 1000.) for i in indices] # Положительная задержка сдвигает окно измерения к соответствующему выключению.
|
|
if any(value is None for value in measured): # Через пропуск или отсутствие опоры после сдвига отклик не подбирается.
|
|
continue # Окно не уменьшается отдельно для одной из сравниваемых моделей.
|
|
fitted_reference = fit_damped_ring(times, reference, cancel) # Затухание исправного отклика определяется по данным, а не фиксированной константой.
|
|
fitted_measured = fit_damped_ring(times, measured, cancel) # Частота, затухание и фаза измерения подбираются независимо.
|
|
if fitted_measured: # Сохраняем также неудачное по качеству согласование для объяснения неопределённости.
|
|
pair = dict(measured=fitted_measured, reference=fitted_reference, equivalent=equivalent_rlc(fitted_measured, circuit), compensated_delay_us=delay_us) # Эквивалент и компенсация времени сохраняются рядом с качеством подбора.
|
|
if fitted_reference and fitted_reference['supported']: # Без пригодного эталонного колебания нельзя оценить отношение затуханий.
|
|
a, b = fitted_measured['parameters'], fitted_reference['parameters'] # Параметры имеют одинаковое начало выбранного участка и одинаковые единицы.
|
|
pair.update(amplitude_ratio=a['amplitude'] / b['amplitude'] if b['amplitude'] > 1e-12 else None, # Отношение амплитуд не зависит от единицы напряжения.
|
|
decay_ratio=a['decay_time_us'] / b['decay_time_us'] if a['decay_time_us'] is not None and b['decay_time_us'] else None, frequency_change_pct=(a['frequency_hz'] / b['frequency_hz'] - 1.) * 100.) # Показываем рост длительности звона и изменение резонансной частоты относительно эталона.
|
|
pairs.append(pair) # Отдельные выключения остаются доступны в JSON для проверки устойчивости оценки.
|
|
return pairs # Отсутствие пригодных окон является явным отсутствием модели, а не нормальным состоянием снаббера.
|
|
|
|
|
|
def identify_fault_models(result, events, features, physical, role, circuit=None, channels=None, cancel=lambda: False): # Формируем гипотезы из подтверждённых моделей, а не только из фиксированных порогов формы.
|
|
from set_devices.signal_joint_models import fit_joint_channels # Ленивый импорт устраняет цикл между совместными моделями и общими численными методами.
|
|
checkpoint(cancel) # Не начинаем дорогой подбор после отмены исходного сравнения.
|
|
report = fit_reference_models(result, events, cancel) # Все глобальные конкуренты оцениваются на одинаковых временах.
|
|
best = next((model for model in report['models'] if model['code'] == report['best']), None) # Временная модель нужна также для корректного выбора окна выключения.
|
|
delay = best['parameters'].get('delay_us', 0.) if best and best['supported'] else 0. # Компенсируем только подтверждённый по BIC и контролю временной сдвиг.
|
|
rings = fit_ringing_pair(result, events, circuit, cancel, delay) if physical and role == 'uce' else [] # Электрическая интерпретация колебания допустима только для силового напряжения.
|
|
report.update(ringing=rings, assumptions=['BIC — сравнительный критерий при предположении гауссовой ошибки, не вероятность неисправности.', 'Контрольные точки чередуются во времени; это проверка интерполяционного прогноза, не независимый эксперимент.', 'Подвыборка сохраняет экстремумы: статистические показатели условны относительно выбранных точек.', 'RLC — последовательный эквивалент полюсов; номиналы реального RC-снаббера автоматически не определяются.']) # Ограничения метода сохраняются рядом с численными результатами.
|
|
report['criteria'] = dict(delta_bic_min=10., validation_improvement_min_pct=20., explained_error_min_pct=50., ring_r_squared_min=.8, ring_amplitude_or_decay_ratio=1.5, ring_frequency_change_pct=15., stretch_min=1.15) # Начальные критерии принятия модели явно доступны пользователю и не выдаются за паспортные нормы транзистора.
|
|
report['circuit_supplied_si'] = dict(circuit or {}) # Известные элементы сохраняются даже при отсутствии пригодного колебательного участка.
|
|
contact_events = best['parameters'].get('events', []) if best and best['code'] == 'contact_bounce' else [] # Сегменты кусочной модели проверяются независимыми физическими каналами.
|
|
joint, joint_findings = fit_joint_channels(result, channels, contact_events, cancel) if physical else (dict(available=False, reason='Физическая роль канала не определена.'), []) # Не выполняем электрические расчёты по произвольному неизвестному каналу.
|
|
report['joint'] = joint # Совместная идентификация проводимости, утечки и провалов управления экспортируется вместе с глобальным рейтингом.
|
|
findings = list(joint_findings) # Несколько моделей могут описывать разные участки совместного дефекта.
|
|
if result.metrics['outside_tolerance'] == 0: # Даже хорошее улучшение на малом шуме не отменяет пользовательский критерий допустимости.
|
|
return report, findings # Допустимый выбранный канал не отменяет отдельно подтверждённые совместные гипотезы.
|
|
if best and best['supported']: # Аппаратная формулировка требует проверки качества, а не только наличия признака.
|
|
code, params = best['code'], best['parameters'] # Параметры выбранной модели будут показаны в подробном отчёте.
|
|
if code == 'contact_bounce' and physical and features['measured_edges'] >= features['reference_edges'] + 2: # Кусочное объяснение подтверждается полными переходами на всей записи.
|
|
text = f'Модель прерываний: {len(params["events"])} эпизода; возможны плохой контакт (дребезг) или нестабильное управление затвором.' # Длительности каждого эпизода доступны в параметрах модели.
|
|
findings.append(dict(code=code, text=text, model=code)) # Причина ссылается на конкретный численно проверенный кандидат.
|
|
elif code == 'slow_switching' and physical and params['stretch'] > 1.15: # Замедление должно превышать 15% при уже доказанном модельном улучшении.
|
|
findings.append(dict(code=code, text=f'Модель растяжения фронтов: ×{params["stretch"]:.3g}; возможны ослабленный драйвер или изменение цепи затвора.', model=code)) # Формулируем гипотезу через фактически оценённый масштаб времени.
|
|
elif code in ('timing', 'slow_switching') and abs(params['delay_us']) > 3. * (features['sample_step_ms'] or 0.) * 1000.: # Задержка должна превышать три исходных шага, даже если BIC чувствителен к малому шуму.
|
|
findings.append(dict(code='timing', text=f'Модель задержки: {params["delay_us"]:+.4g} мкс; проверьте синхронизацию записей и задержку управления.', model=code)) # Отдельная причина аппаратной задержки по одному каналу не доказывается.
|
|
elif code == 'constant' and physical and features['reference_edges']: # Постоянная амплитуда выигрывает у динамического эталона с ожидаемыми переходами.
|
|
state = 'высоким' if params['level'] > (result.metrics['reference_min'] + result.metrics['reference_max']) / 2. else 'низким' # Середина эталонных состояний определяет тип отсутствующего переключения.
|
|
text = f'Модель постоянного уровня: {result.measured.name} остаётся {state}; возможны отказ ключа, обрыв или ошибка управления.' # Направление неисправности остаётся возможной причиной, а не доказанным компонентом.
|
|
findings.append(dict(code='no_switching', text=text, model=code)) # Сохраняем совместимый код группы отсутствия переключения.
|
|
elif code in ('offset', 'gain', 'drift'): # Калибровочные и медленные изменения конкурируют с аппаратной динамикой.
|
|
texts = dict(offset=f'Модель смещения: Δ0={params.get("offset", 0.):+.4g}; проверьте ноль датчика, общую точку и режим питания.', # Постоянное смещение задано в единицах выбранного канала.
|
|
gain=f'Модель масштаба: коэффициент {params.get("gain", 1.):.5g}, смещение {params.get("offset", 0.):+.4g}; возможна ошибка измерительного тракта или изменение режима.', # Усиление является безразмерной оценкой соответствия эталону.
|
|
drift=f'Модель дрейфа: {params.get("drift_per_us", 0.):+.4g} ед./мкс; возможны нестабильность питания или уход нуля измерения.') # Производная уровня явно содержит единицу времени.
|
|
findings.append(dict(code=code, text=texts[code], model=code)) # Сохраняем идентифицированный вид отклонения отдельно от аппаратной причины.
|
|
gain = abs(best['parameters'].get('gain', 1.)) if best else 1. # Общий масштаб измерительного тракта не должен ошибочно удваивать амплитуду «неисправного» снаббера.
|
|
for pair in rings: # Разные выключения могут подтверждать дополнительный резонансный дефект поверх дребезга.
|
|
measured, reference = pair['measured'], pair['reference'] # Каждая сторона имеет собственные показатели качества подбора.
|
|
ratio = pair.get('amplitude_ratio') # Без пригодного эталонного отклика относительная амплитуда не определена.
|
|
worse = ratio is not None and ratio / max(gain, 1e-9) > 1.5 or pair.get('decay_ratio') is not None and pair['decay_ratio'] > 1.5 or abs(pair.get('frequency_change_pct', 0.)) > 15. # Параметры должны измениться заметно, а не только улучшить микроскопический остаток.
|
|
if measured['supported'] and reference and reference['supported'] and worse: # Аппаратная гипотеза требует пригодных моделей обеих осциллограмм.
|
|
params = measured['parameters'] # Оценённые частота и затухание делают вывод проверяемым.
|
|
text = f'Модель звона RLC: f={params["frequency_hz"] / 1e6:.3g} МГц, τ={params["decay_time_us"]:.3g} мкс; изменилось демпфирование — возможны нарушение снаббера или паразитного контура.' if params['decay_time_us'] is not None else f'Модель звона RLC: f={params["frequency_hz"] / 1e6:.3g} МГц, затухание не разрешено; проверьте снаббер и паразитный контур.' # Нулевое оценённое затухание не превращается в бесконечность JSON.
|
|
findings.append(dict(code='snubber', text=text, model='damped_rlc')) # Привязываем причину к параметрической модели второго порядка.
|
|
break # Несколько выключений не дублируют одинаковую строку неисправности.
|
|
if not findings: # Недостаточно описанная форма должна оставаться неопределённой.
|
|
code = 'noise' if features['residual_reversals'] >= 5 else 'deviation' # Остаточные колебания обозначают помеху без приписывания конкретного электрического отказа.
|
|
text = 'Остаток не объяснён проверенными моделями: возможны шум, помехи или неучтённая динамика.' if code == 'noise' else 'Ни одна модель не дала достаточного подтверждения причины отклонения; проверьте режим и измерительный тракт.' # Отсутствие подходящей модели не считается доказательством исправности.
|
|
findings.append(dict(code=code, text=text, model=None)) # Явная неопределённость сохраняется в краткой строке и JSON.
|
|
return report, findings # Численные результаты и текстовые гипотезы принадлежат одному снимку данных.
|