"""Совместные электрические модели Uce, Uge и Ic на общей временной сетке.""" # Наличие трёх каналов даёт дополнительные проверяемые ограничения аппаратных гипотез. import math # Корни дисперсий задают оценку неопределённости коэффициентов проводящего состояния. import re # Физические каналы определяются по явным обозначениям, а не имени CSV. from statistics import median # Медианные уровни и MAD устойчивы к отдельным коммутационным выбросам. from set_devices.signal_fault_models import checkpoint, fit_model, interpolate # Переиспользуем численные методы без зависимости от GUI и циклического импорта ядра. def _channels(traces): # Дублирующиеся обозначения нельзя выбирать произвольно для физических расчётов. found = {} # Для каждой физической роли разрешён только один однозначный канал. for trace in traces.values(): # Имена и значения предоставляются снимком исходной записи. match = re.match(r'^\s*(uce|uge|ic)\s*\((V|A)\)\s*$', trace.name, re.IGNORECASE) # Требуем явные единицы В для напряжений и А для тока. if match and match.group(2).lower() == ('a' if match.group(1).lower() == 'ic' else 'v'): # Не используем ток в мА как амперы без явного преобразования. role = match.group(1).lower() # Внутренние обозначения не зависят от регистра заголовка. found[role] = trace if role not in found else None # Повторная роль помечается неоднозначной и не участвует в выводе. return {role: trace for role, trace in found.items() if trace is not None} # Неоднозначность не маскируется выбором первого столбца. def fit_joint_channels(result, channels, events=None, cancel=lambda: False): # Все связанные каналы интерполируются на общую фактическую сетку сравнения. if not channels: # Синхронные осциллограммы могут отсутствовать у одиночного источника. return dict(available=False, reason='Для совместной проверки нужны Uce (V), Uge (V) и Ic (A) измерения и эталона.'), [] # Не подменяем отсутствующий ток или затвор выдуманными данными. measured, reference = _channels(channels.get('measured', {})), _channels(channels.get('reference', {})) # Две стороны сравнения проверяются независимо. if any(role not in measured or role not in reference for role in ('uce', 'uge', 'ic')): # Физические ограничения требуют полного набора каналов с однозначными единицами. return dict(available=False, reason='Недостаточно однозначных синхронных каналов Uce/Uge/Ic в В/А.'), [] # Пропущенный канал остаётся ограничением диагностического вывода. count = len(result.difference.times) # Общая область уже ограничена интервалом и временем эталона. stride = max(1, math.ceil(count / 4096)) # До 4096 точек ограничивают дополнительную стоимость совместной электрической модели. points = [] # Каждая строка содержит синхронные тройки фактического измерения и эталона. for index in range(0, count, stride): # Изменение масштаба графика не влияет на используемую область записи. if index % 1024 == 0: # Порционная проверка отмены поддерживает отзывчивость на больших записях. checkpoint(cancel) # Не продолжаем подготовку после смены источника. stamp = result.difference.times[index] # Время отсчитывается от первого измеренного отсчёта в миллисекундах. a = {role: interpolate(trace.times, trace.values, stamp + channels['measured_origin_ms']) for role, trace in measured.items()} # Сохраняем временное взаимное положение измеренных каналов. b = {role: interpolate(trace.times, trace.values, stamp + channels['reference_origin_ms'] - channels['shift_ms']) for role, trace in reference.items()} # К эталону применяется тот же пользовательский сдвиг, что в основном сравнении. if all(value is not None for value in (*a.values(), *b.values())): # Только полностью наблюдаемые тройки пригодны для совместной физической проверки. points.append((stamp * 1000., a, b)) # Человеко-читаемое время событий хранится в микросекундах. if len(points) < 30: # Дополнительные модели требуют не менее 30 совместных наблюдений. return dict(available=False, reason='Недостаточно общих наблюдений трёх физических каналов.'), [] # Число точек явно ограничивает возможность идентификации. low = min(point[2]['uce'] for point in points) # Низкая полка эталона определяет проводящее состояние транзистора. high = max(point[2]['uce'] for point in points) # Высокая полка соответствует выключенному ключу. gate_high = max(point[2]['uge'] for point in points) # Опорный высокий уровень затвора определяется по данным эталона. on = [point for point in points if point[2]['uce'] < low + .02 * (high - low) and point[1]['uce'] < low + .25 * (high - low) and min(point[1]['uge'], point[2]['uge']) > .98 * gate_high] # ВАХ оценивается на устойчивой эталонной полке с высоким затвором и фактически проводящим измеренным ключом. off = [point for point in points if min(point[1]['uce'], point[2]['uce']) > low + .85 * (high - low) and max(point[1]['uge'], point[2]['uge']) < .3 * gate_high] # Утечка допустима как модель только при высоком Uce и низком Uge с обеих сторон; пробой с низким Uce рассматривается отдельно. report, findings = dict(available=True, count=len(points), on_count=len(on), off_count=len(off), contact_events=[]), [] # Совместные выводы сохраняют фактическую область подтверждения. fits = {} # Две модели проводимости отдельно оценивают исправный и измеренный ключ. for side, position in (('measured', 1), ('reference', 2)): # Коэффициенты U0 и Rd вычисляются из собственных тока и напряжения каждой стороны. if len(on) < 30: # Мало наблюдений проводящего состояния не позволяет оценить наклон ВАХ. continue # Не выдаём эквивалентное сопротивление по одному значению тока. currents = [point[position]['ic'] for point in on] # Ток задаётся в амперах, напряжение — в вольтах. if max(currents) - min(currents) < .1 * max(max(abs(value) for value in currents), 1e-12): # Размах менее 10% уровня не разделяет надёжно U0 и дифференциальное сопротивление. continue # Почти постоянная нагрузка не даёт идентифицировать две независимые величины. model = fit_model('conduction', 'Uce=Rdiff*Ic+U0', [currents, [1.] * len(on)], [point[position]['uce'] for point in on], ['resistance_ohm', 'voltage_offset_v']) # Модель является локальной линейной ВАХ, а не постоянным сопротивлением IGBT во всех режимах. if model: # QR-решатель дополнительно проверяет независимость тока и свободного члена. training_current = [value for index, value in enumerate(currents) if index % 3 != 1] # Оценка неопределённости использует только наблюдения, участвовавшие в подборе коэффициентов. average = sum(training_current) / len(training_current) # Центр обучающей выборки определяет ковариацию наклона и свободного члена. spread = sum((value - average) ** 2 for value in training_current) # Знаменатель ковариации имеет единицу А² и не увеличивается отложенными контрольными точками. model['slope_standard_error_ohm'] = math.sqrt(model['training_sse'] / max(1, len(training_current) - 2) / spread) # Стандартная ошибка оценивается по обучающим остаткам при независимом шуме и не покрывает систематическую ошибку датчика. fits[side] = model # Сохраняем полные параметры и контрольную ошибку каждой ВАХ. report['conduction'] = fits or dict(identifiable=False, reason='Нужны устойчивое включённое состояние и заметное изменение тока нагрузки.') # Неидентифицируемый наклон явно отличим от нулевого сопротивления. if len(fits) == 2: # Сравнение деградации требует оценок обеих сторон на одном режиме нагрузки. delta = fits['measured']['parameters']['resistance_ohm'] - fits['reference']['parameters']['resistance_ohm'] # Изменение дифференциального сопротивления измеряется в омах. uncertainty = math.hypot(fits['measured']['slope_standard_error_ohm'], fits['reference']['slope_standard_error_ohm']) # Независимые статистические ошибки наклонов складываются по дисперсии. report['conduction_change_ohm'] = delta # Численная разница доступна даже при недостаточной статистической значимости. if delta > max(5. * uncertainty, 1e-9): # Порог пяти стандартных ошибок ограничивает реакции на один только случайный шум. findings.append(dict(code='conduction', text=f'Совместная модель ВАХ: дифференциальное сопротивление выросло на {delta:.4g} Ом; возможны деградация ключа или сопротивление силового контакта.', model='Uce=Rdiff*Ic+U0')) # Обе физические причины дают похожий наклон и остаются альтернативами. if len(on) >= 30 and len(off) >= 30: # Разность токов включённого и выключенного состояний отделяет утечку от постоянного нуля датчика. on_error = [point[1]['ic'] - point[2]['ic'] for point in on] # Ошибка тока во включённом состоянии служит проверкой общей калибровки. off_error = [point[1]['ic'] - point[2]['ic'] for point in off] # Остаточный ток при низком затворе является кандидатом модели утечки. on_level, off_level = median(on_error), median(off_error) # Медианы подавляют отдельные импульсы коммутации. noise = max(1e-9, 1.4826 * median([abs(value - on_level) for value in on_error] + [abs(value - off_level) for value in off_error])) # Множитель 1,4826 переводит MAD в оценку СКО нормального шума. report['leakage'] = dict(formula='ΔIc_off=Ileak+b; ΔIc_on=b', off_current_a=off_level, on_offset_a=on_level, contrast_a=off_level - on_level, noise_sigma_a=noise) # Двухрежимная модель явно оценивает постоянную ошибку датчика b. if off_level > 5. * noise and off_level - on_level > 5. * noise: # Одновременно нужен положительный ток выключения и отличие от общей ошибки нуля. findings.append(dict(code='leakage', text=f'Совместная модель остаточного тока: при низком Uge дополнительный Ic≈{off_level - on_level:.4g} А; возможны утечка ключа или параллельная токовая цепь.', model='off_current')) # Посторонняя ветвь тока остаётся альтернативой утечке самого транзистора. for event in events or []: # Сегменты предполагаемого дребезга проверяются по синхронным затвору и току. interval = [point for point in points if event['start_us'] <= point[0] < event['stop_us']] # Проверяем только реально наблюдаемые точки внутри найденного прерывания. if not interval: # Слишком короткий эпизод может не попасть в ограниченную совместную выборку. continue # Отсутствие наблюдений не считается отсутствием провала. gate_ratio = sum(point[1]['uge'] < .5 * point[2]['uge'] for point in interval) / len(interval) # Доля одновременного провала управления количественно проверяет причину повторного выключения. current_ratio = sum(abs(point[1]['ic']) < .5 * abs(point[2]['ic']) for point in interval) / len(interval) # Провал тока ключа дополнительно подтверждает прерывание проводимости. report['contact_events'].append(dict(**event, gate_drop_fraction=gate_ratio, current_drop_fraction=current_ratio, supported=gate_ratio >= .8 and current_ratio >= .8)) # Подтверждение требует совпадения обоих провалов хотя бы в 80% наблюдений эпизода. return report, findings # Одновременные нарушения проводимости, утечки и контакта не скрывают друг друга.