Files
templates/tools/dsview/tests/test_pwm_npc.py
T

182 lines
29 KiB
Python

"""Проверки NPC, коммутационного вида, θ, расчётной несущей и межфазных углов.""" # Синтетические цифровые входы проверяют опции одного анализатора без силового оборудования.
import importlib.util # Адаптер загружается из канонического исходника вместе с изолированной заглушкой sigrok.
import math # Эталонные трёхфазные огибающие и направления вектора задаются аналитически.
import sys # Таблица модулей ограничивает подмену импортов временем загрузки адаптера.
import types # ModuleType создаёт минимальное окружение встроенного sigrokdecode.
import unittest # Проверки используют стандартную библиотеку и совместимы с Python 3.8.
from unittest.mock import patch # Внешний интерпретатор DSView не нужен для численных проверок адаптера.
from test_pwm_waveform import ROOT, core # Оба режима проверяются на том же ядре, что восстановление обычной огибающей.
GATES = {1: (1, 1, 0, 0), 0: (0, 1, 1, 0), -1: (0, 0, 1, 1)} # Классическая стойка NPC нумерует S1…S4 сверху вниз.
INDICES = {ch: (ch, 3 + ch * 3, 4 + ch * 3, 5 + ch * 3) for ch in range(3)} # Старые входы A/B/C сохраняют положение S1 каждой фазы.
def npc_frames(seconds=.12, frequency=50, offsets=(0, -120, -240), reverse=False): # Генератор формирует дискретные состояния всех двенадцати затворов при несущей 2 кГц.
changes = {} # Одинаковые моменты разных фаз объединяются в один атомарный снимок цифровых входов.
for period in range(round(seconds * 2000)): # При 1 МГц период несущей 2 кГц занимает ровно 500 отсчётов.
base = period * 500 # Начало периода задаёт синфазную треугольную несущую PD для трёх фаз.
for ch, offset in enumerate(offsets): # Независимые смещения позволяют проверить как правильную, так и обратную последовательность.
angle = math.tau * frequency * (base + 250) / 1000000 + math.radians(-offset if reverse else offset) # Модуляция берётся в середине окна, совпадающей с временной координатой восстановленной огибающей.
mean = .8 * math.cos(angle) # Амплитуда 0,8 оставляет ненулевые интервалы обоих уровней каждой полуволны.
if mean >= 0: # Положительная полуволна сравнивается с верхним треугольником 0…+1.
states = ((base, 1), (base + round(mean * 250), 0), (base + round((1 - mean / 2) * 500), 1)) # Состояние +1 обрамляет нулевой промежуток около вершины несущей.
else: # Отрицательная полуволна сравнивается с нижним треугольником −1…0.
states = ((base, 0), (base + round((1 + mean) * 250), -1), (base + round((1 - mean) * 250), 0)) # Отрицательный импульс расположен вокруг вершины нижнего треугольника.
for stamp, level in states: # События каждого затвора определяются полным допустимым состоянием стойки.
changes.setdefault(stamp, {})[ch] = GATES[level] # Несколько состояний после округления к одному отсчёту объединяются последним реальным уровнем.
pins = [0] * 12 # Снимок заполняется допустимыми комбинациями всех фаз в первом событии.
for stamp, updates in sorted(changes.items()): # Адаптер получает строго неубывающие номера отсчётов.
for ch, gates in updates.items(): # Одновременно изменённые фазы сохраняются в одной записи.
for index, level in zip(INDICES[ch], gates): # Порядок соответствует входам S1, S2, S3, S4 каждой фазы.
pins[index] = level # Физический цифровой вход имеет только значения 0 или 1.
yield stamp, tuple(pins) # Неизменяемый снимок исключает случайное изменение уже сохранённых тестовых событий.
class NpcTests(unittest.TestCase): # Физическая таблица NPC и алгоритмы углов проверяются отдельно от нативной компоновки графиков.
def test_truth_table_polarity_and_unknown_states(self): # Все шестнадцать комбинаций должны сохранять различие между допустимым уровнем и неизвестным напряжением.
for active in (True, False): # Полярность сигналов затворов настраивается независимо от схемы стойки.
state = core.NpcChannel(1000000, active) # Параметры времени не влияют на декодирование логического состояния.
for bits in range(16): # Четыре затвора дают шестнадцать возможных входных комбинаций.
gates = tuple((bits >> shift) & 1 for shift in (3, 2, 1, 0)) # Старший бит соответствует верхнему ключу S1.
expected = core.NpcChannel.STATES.get(gates) # Только три штатные комбинации имеют однозначный уровень напряжения.
physical = gates if active else tuple(1 - bit for bit in gates) # Инвертированные драйверы представляют ту же физическую комбинацию противоположными входными уровнями.
self.assertEqual(state.state(physical), expected) # Ни dead-time 0000, ни запрещённые комбинации не должны стать фиктивной нейтралью.
def test_signed_integration_and_fixed_windows(self): # Огибающая должна учитывать знаковую площадь всех трёх уровней.
state = core.NpcChannel(1000, carrier_hz=100) # Окно 10 отсчётов соответствует 100 Гц при дискретизации 1 кГц.
state.update(0, GATES[1]) # Первые два отсчёта имеют положительное напряжение.
state.update(2, GATES[0]) # Три следующих отсчёта соответствуют нейтрали.
state.update(5, GATES[-1]) # Последние пять отсчётов имеют отрицательное напряжение.
event = state.update(10, GATES[0])[0] # Полное окно содержит площадь +2−5, делённую на длительность 10.
self.assertAlmostEqual(event['value'], -.3) # Средний уровень равен −0,3, а не заполнению отдельного затвора.
self.assertEqual(event['valid_fraction'], 1) # Все отсчёты восстановлены по допустимым состояниям.
self.assertIsNone(event['carrier_frequency']) # Один переход между положительным и отрицательным уровнем не является измерением полного периода несущей.
def test_unknown_time_is_excluded_and_long_gaps_reset_field(self): # Короткие dead-time допускают оценку по известным участкам, длительные ошибки разрывают измерение.
state = core.NpcChannel(10000, carrier_hz=100) # Период 100 отсчётов позволяет точно задать долю неизвестного времени.
state.update(0, GATES[1]) # Начальная допустимая часть окна имеет уровень +1.
state.update(49, (0, 0, 0, 0)) # Один отсчёт без включённых ключей считается неизвестным, а не нулевым.
state.update(50, GATES[0]) # Остальная половина окна соответствует достоверной нейтрали.
event = state.update(100, (1, 1, 1, 1))[0] # Первое окно содержит 99 известных отсчётов и единичную коммутационную паузу.
self.assertAlmostEqual(event['value'], 49 / 99) # Среднее нормируется по известному времени, явно оставаясь оценкой.
self.assertEqual(event['valid_fraction'], .99) # Покрытие отражает исключённую неизвестную длительность.
state.update(190, GATES[0]) # Следующее окно почти целиком содержит запрещённое состояние всех включённых ключей.
self.assertEqual(state.update(200, GATES[1]), []) # Недостаточно определённое окно не выдаёт числа или нарисованного нуля.
self.assertIsNone(state.previous) # График не соединяет достоверные данные через длительную неизвестную область.
self.assertIsNone(state.field_crossing) # Частота поля не измеряется через потерянную часть записи.
def test_npc_sine_and_frequency_in_both_modes(self): # Восстановление NPC должно работать как по фронтам, так и с заданной несущей.
for carrier in (0, 2000): # Нулевая настройка измеряет периоды импульсов; 2 кГц задаёт равномерные окна.
state, events = core.NpcChannel(1000000, carrier_hz=carrier), [] # Состояние не переносится между режимами.
for stamp, pins in npc_frames(): # Синтетическая запись содержит шесть полных электрических периодов.
events.extend(state.update(stamp, [pins[index] for index in INDICES[0]])) # Проверяется реальная последовательность четырёх затворов фазы A.
self.assertGreater(len(events), 100) # Результат должен содержать множество полных периодов несущей.
for event in events: # Контролируется вся огибающая, включая смену знака и начало новой полуволны.
expected = .8 * math.cos(math.tau * 50 * event['midpoint'] / 1000000) # Эталон задан синусоидой в том же моменте времени, что восстановленная точка.
self.assertAlmostEqual(event['value'], expected, delta=.065) # Допуск учитывает дискретизацию и автоматическое окно между движущимися фронтами.
fields = [event['field'] for event in events if event['field'] is not None] # Электрическая частота измеряется по полным периодам знаковой огибающей.
self.assertGreaterEqual(len(fields), 3) # Начальный фрагмент не должен скрыть установившееся поле.
self.assertTrue(all(abs(field['frequency'] - 50) < 1 for field in fields)) # Смена знака ненулевых импульсов не удваивает частоту поля.
def test_vector_angle_common_mode_reverse_and_zero(self): # θ должен удалять синфазную составляющую и учитывать фактическое направление последовательности.
for direction in (1, -1): # Прямое и обратное вращения различаются знаком электрического угла.
analysis, results = core.PhaseAnalysis(1000000), [] # Каждый закон вращения начинается с пустой истории.
for sample in range(0, 40001, 500): # Один оборот поля занимает 20 мс при частоте 50 Гц.
angle = direction * math.tau * 50 * sample / 1000000 # Эталонный угол известен независимо от наблюдавшихся фаз.
for ch in range(3): # Полное преобразование Кларка должно работать при общей добавке к трём фазам.
value = .5 * math.cos(angle - ch * math.tau / 3) + .1 # Синфазное смещение 0,1 не должно поворачивать вектор поля.
vector = analysis.add(ch, dict(midpoint=sample, value=value, field=None)) # Каждая огибающая обновляется своим событием.
if vector is not None: # Первые точки используются только для создания интервала интерполяции.
expected = math.degrees(angle) % 360 # Нулевой угол соответствует положительному максимуму A.
error = (vector['theta'] - expected + 180) % 360 - 180 # Сравнение учитывает эквивалентность 0 и 360 градусов.
self.assertAlmostEqual(error, 0, places=8) # Симметричные аналитические фазы должны восстанавливать точный угол.
results.append(vector) # Число результатов проверяет наличие работающей графической последовательности.
self.assertGreater(len(results), 50) # θ не должен выдаваться только при пересечениях нуля отдельной фазы.
zero = core.PhaseAnalysis(1000) # Одинаковые значения A/B/C содержат только синфазную составляющую.
for sample in (0, 10): # Две точки каждой фазы образуют общий интервал.
for ch in range(3): # Направление вектора при одинаковых фазах неопределённо.
vector = zero.add(ch, dict(midpoint=sample, value=.5, field=None)) # Ненулевой общий уровень не означает ненулевой пространственный вектор.
self.assertIsNone(vector['theta']) # Околонулевая амплитуда не получает выдуманное направление.
def test_vector_gap_discards_old_phase_and_lag(self): # После потери одного входа никакая интерполяция или оценка угла не должна пересекать неизвестный участок.
analysis = core.PhaseAnalysis(1000000) # Все времена заданы в отсчётах одной общей шкалы.
for sample in (0, 500): # Две точки формируют первоначальные согласованные огибающие.
for ch in range(3): # До разрыва доступны все три фазы.
analysis.add(ch, dict(midpoint=sample, value=.5 * math.cos(ch * math.tau / 3), field=None)) # Ненулевой стационарный вектор создаёт предыдущую историю.
analysis.fields[0] = (500, 20000) # Имитируем измеренный период первой фазы до неизвестного участка.
vector = analysis.add(0, dict(midpoint=10000, value=.5, field=None, previous=None)) # Ядро помечает первый достоверный результат после пропуска отсутствием предыдущей точки.
self.assertIsNone(vector) # Одной новой точки недостаточно для восстановления общего вектора после разрыва.
self.assertEqual(analysis.points[0], [(10000, .5)]) # Старые точки A не участвуют в интерполяции через потерянный интервал.
self.assertNotIn(0, analysis.fields) # Старый межфазный период сбрасывается вместе с огибающей, независимо от тайм-аута.
def test_signed_phase_lags_and_stale_inputs(self): # Численные сдвиги должны отличать реальную асимметрию от идеальных 120 градусов.
analysis = core.PhaseAnalysis(1000000) # Период 20 тысяч отсчётов соответствует полю 50 Гц.
analysis.fields = {0: (20000, 20000), 1: (20000 + 20000 / 3, 20000), 2: (20000 + 40000 / 3, 20000)} # Последовательные одноимённые пересечения сдвинуты на 120 градусов.
angles = analysis.differences(40000) # Все три измерения свежие и относятся к одному моменту.
self.assertTrue(all(abs(value - 120) < 1e-9 for value in angles.values())) # Сдвиги A→B, B→C и C→A рассчитываются независимо, включая переход через оборот.
analysis.fields[1] = (20000 + 20000 / 4, 20000) # Искусственно изменяем только фазу B на запаздывание 90 градусов относительно A.
self.assertAlmostEqual(analysis.differences(40000)['A→B'], 90) # Измерение не подставляет заранее известное значение 120 градусов.
self.assertEqual(analysis.differences(100000), {}) # Устаревшие более чем на два периода входы не сохраняют прежние углы.
def adapter(self, frames, connected=tuple(range(12)), **options): # Заглушка воспроизводит полный жизненный цикл wait/start/decode/end.
class StopCapture(Exception): # Исчерпание подготовленной записи моделирует завершение потока sigrok.
pass # Исключение не маскируется как ошибка проверяемого декодера.
class FakeDecoder: # Минимальный интерфейс сохраняет все аннотации реального адаптера.
def register(self, kind): # Регистрация аннотационного выхода не требует нативного дескриптора.
return kind # Для проверок достаточно постоянного идентификатора.
def has_channel(self, channel): # Подключение отдельных затворов проверяется до запуска обработки.
return channel in connected # Неполная стойка NPC должна быть отвергнута самим адаптером.
def put(self, start, end, output, data): # Формат соответствует API sigrokdecode.
self.emitted.append((start, end, data)) # Сохраняются как видимые подписи, так и нативный контракт кривых.
def wait(self, conditions): # Подготовленные снимки выдаются в хронологическом порядке.
try: # Конец итератора отделяется от вычислительных ошибок анализатора.
self.samplenum, pins = next(self.frames) # Одно событие содержит атомарный снимок всех подключённых входов.
except StopIteration: # Запись завершена после последнего фактического изменения состояния.
raise StopCapture() # Штатный end затем проверит завершение только полных окон.
return pins # Служебное ожидание пропуска не меняет доступные цифровые значения.
stub = types.ModuleType('sigrokdecode') # Изолированный модуль существует только во время импорта адаптера.
stub.Decoder, stub.OUTPUT_ANN = FakeDecoder, 0 # Внешний интерпретатор нужен только в нативной проверке, а не в численных тестах.
with patch.dict(sys.modules, {'sigrokdecode': stub, 'common.setgui_decoders.pwm_waveform': core}): # Подмена ядра исключает импорт устаревшей установленной копии.
spec = importlib.util.spec_from_file_location('pwm_npc_adapter', ROOT / 'tools/dsview/decoders/pwm_waveform/pd.py') # Загружается файл, включаемый в поставку DSView.
module = importlib.util.module_from_spec(spec) # Отдельный объект не затрагивает экземпляры других тестов.
spec.loader.exec_module(module) # Базовый класс связывается с минимальным FakeDecoder.
decoder = module.Decoder() # Конструктор создаёт пустое состояние всех новых опций.
decoder.options = {entry['id']: entry['default'] for entry in decoder.options} # Значения по умолчанию берутся из реального интерфейса анализатора.
decoder.options.update(options) # Тест изменяет только проверяемые пользовательские опции.
decoder.frames, decoder.emitted, decoder.samplerate = iter(frames), [], 1000000 # Частота дискретизации совпадает с синтетическими номерами отсчётов.
decoder.start() # Выход аннотаций регистрируется штатным методом.
try: # Ошибки настройки должны распространяться наружу, а нормальный конец захвата обрабатывается отдельно.
decoder.decode() # Вся диспетчеризация затворов и опций выполняется настоящим адаптером.
except StopCapture: # После исчерпания записи сохраняется текущий сампл, как в штатном декодировании.
decoder.end() # Завершаются только полные интервалы и постоянные участки.
return decoder # Проверки анализируют конечный набор кривых и численных измерений.
def test_adapter_npc_all_options_and_compact_angles(self): # Все дополнительные результаты должны работать в одном экземпляре анализатора.
decoder = self.adapter(npc_frames(), input_mode='npc-three-level', carrier_hz=2000, waveform_mode='switching', show_theta='yes', show_carrier='yes', show_phase_angles='yes') # Фиксированные окна согласуют три фазы и дают эталон 2 кГц/50 Гц.
kinds = {entry[2][0] for entry in decoder.emitted} # Типы аннотаций отделяют фазные осциллограммы от θ, несущей и углов.
self.assertTrue({0, 1, 2, 6, 7, 8, 9, 10, 11}.issubset(kinds)) # Требуются три фазы, три частоты и все три запрошенные дополнительные функции.
raw = [entry for entry in decoder.emitted if entry[2][0] < 3] # Коммутационный режим должен содержать три действительных уровня NPC.
levels = {float(entry[2][1][-1].split()[1]) for entry in raw} # Читается тот же нормированный контракт, который получает нативный renderer.
self.assertTrue({-1, 0, 1}.issubset(levels)) # Отрицательные состояния не теряются при переходе от двоичного режима к NPC.
angles = [entry for entry in decoder.emitted if entry[2][0] == 11] # Межфазные измерения появляются после получения полных периодов всех необходимых фаз.
self.assertGreater(len(angles), 1) # Опция должна выдавать последовательность обновлений на записи.
self.assertTrue(all('A→B=' in entry[2][1][0] and 'B→C=' in entry[2][1][0] and 'C→A=' in entry[2][1][0] for entry in angles)) # Каждая доступная пара имеет явное обозначение направления.
for entry in angles: # Наличие подписей недостаточно: реальная запись должна давать близкие к 120 градусам значения.
values = [float(value) for value in entry[2][1][1].split(' / ')] # Короткая подпись сохраняет порядок трёх пар A→B, B→C, C→A.
self.assertTrue(all(abs(value - 120) < 1 for value in values)) # Допуск один градус учитывает целочисленное измерение импульсов при дискретизации 1 МГц.
triangles = [entry for entry in decoder.emitted if entry[2][0] == 10] # Два сегмента одного периода задают расчётный треугольник.
self.assertEqual(triangles[0][2][1][-1], 'PWM1 -1.000000000 1.000000000') # Верхняя и нижняя несущие NPC выводятся нативно из одного нормированного треугольника.
def test_missing_gates_and_missing_phases_are_explicit_errors(self): # Неполные входы не должны незаметно превращаться в неверную реконструкцию.
with self.assertRaisesRegex(ValueError, 'all four gates'): # Один вход A не определяет состояние стойки NPC.
self.adapter([], connected=(0,), input_mode='npc-three-level') # Ошибка должна возникнуть до ожидания первого события.
with self.assertRaisesRegex(ValueError, 'all three phases'): # Пространственный угол требует реальных A/B/C.
self.adapter([], connected=(0,), show_theta='yes') # Обычный однофазный ШИМ остаётся допустимым лишь при выключенном θ.
with self.assertRaisesRegex(ValueError, 'at least two phases'): # Межфазный угол нельзя вычислять по одному каналу.
self.adapter([], connected=(0,), show_phase_angles='yes') # Отсутствующая пара не заменяется идеальным сдвигом 120 градусов.
if __name__ == '__main__': # Файл можно запускать отдельно при отладке или через стандартное обнаружение тестов.
unittest.main() # Ошибка любого нового режима возвращает ненулевой код завершения.