150 lines
25 KiB
Python
150 lines
25 KiB
Python
"""Проверки измерения несущей ШИМ и электрической частоты поля по цифровым фронтам.""" # Синтетические записи проверяют численные результаты без оборудования и установленного DSView.
|
|
import importlib.util # Адаптер загружается с подменённым sigrokdecode для проверки фактических аннотаций.
|
|
import math # Синус и интеграл линейной частоты формируют стационарные сигналы и разгон.
|
|
import sys # Таблица модулей временно предоставляет адаптеру изолированное ядро и заглушку sigrok.
|
|
import types # ModuleType создаёт минимальный модуль окружения встроенного декодера.
|
|
import unittest # Стандартный набор проверок запускается и в Python 3.8 для Windows 7.
|
|
from unittest.mock import patch # Подмена импортов ограничивается загрузкой проверяемого адаптера.
|
|
from test_pwm_waveform import ROOT, core # Используем тот же источник ядра, что существующие проверки восстановления кривой.
|
|
|
|
|
|
class FrequencyTests(unittest.TestCase): # Проверяем физический смысл частот отдельно от геометрии нативного графика.
|
|
def signal(self, state, frequency=50, phase=0, active=True, duration=0.3, slope=0): # Генератор выдаёт реальные цифровые фронты, а не готовые значения заполнения.
|
|
events = [] # Результаты содержат только полностью декодированные периоды или окна.
|
|
for index in range(round(duration * 2000) + 1): # Несущая 2000 Гц при дискретизации 1 МГц даёт 500 отсчётов на период.
|
|
stamp = index * 500 # Активные фронты расположены равномерно с периодом 500 отсчётов.
|
|
seconds = stamp / 1000000 # Электрическая фаза рассчитывается в секундах, независимо от частоты несущей.
|
|
angle = math.tau * (frequency * seconds + slope * seconds * seconds / 2) + phase # Интеграл f(t)=frequency+slope*t задаёт непрерывную фазу при разгоне.
|
|
width = round(250 + 200 * math.sin(angle)) # Заполнение 10–90% оставляет оба фронта при любой фазе синусоиды.
|
|
events.extend(state.update(stamp, active)) # Активный фронт завершает предыдущий измеряемый период ШИМ.
|
|
events.extend(state.update(stamp + width, not active)) # Противоположный фронт задаёт длительность активной части текущего периода.
|
|
return events # Последний неполный период поля намеренно не достраивается.
|
|
|
|
def test_three_phases_polarities_and_window_modes(self): # Обе полярности и оба режима должны давать одну электрическую частоту для каждой фазы.
|
|
for frequency in (25, 50, 125): # Периоды поля содержат соответственно 80, 40 и 16 периодов несущей 2 кГц.
|
|
for phase in (0, math.tau / 3, 2 * math.tau / 3): # Трёхфазная система отличается сдвигом 120 электрических градусов.
|
|
for active in (True, False): # Инверсия физического входа не должна менять измеренную частоту.
|
|
for carrier in (0, 2000): # Автоматический режим и синхронные окна проверяются на одних цифровых фронтах.
|
|
with self.subTest(frequency=frequency, phase=phase, active=active, carrier=carrier): # Ошибка указывает конкретный режим и фазу.
|
|
events = self.signal(core.PwmChannel(1000000, active, carrier), frequency, phase, active) # Все вычисления выполняет штатный потоковый декодер.
|
|
fields = [event['field'] for event in events if event['field'] is not None] # Отбираем только завершённые электрические периоды.
|
|
self.assertGreater(len(fields), 3) # Запись длительностью 300 мс должна содержать несколько полных измерений даже при 25 Гц.
|
|
for field in fields: # Проверяется каждый период, а не только среднее, способное скрыть удвоения частоты.
|
|
self.assertAlmostEqual(field['frequency'], frequency, delta=frequency * 0.01) # Погрешность менее 1% учитывает дискретизацию ширины импульса и интерполяцию.
|
|
self.assertEqual(field['carrier_frequency'], 2000) # Средняя несущая берётся из фронтов и не зависит от фазы поля.
|
|
self.assertTrue(all(event['carrier_frequency'] == 2000 for event in events if event['carrier_frequency'] is not None)) # Каждое доступное измерение несущей соответствует реальным периодам.
|
|
|
|
def test_accelerating_field_tracks_each_complete_period(self): # Измерение должно следовать разгону, видимому на пользовательском примере.
|
|
fields = [event['field'] for event in self.signal(core.PwmChannel(1000000), frequency=10, duration=0.5, slope=140) if event['field'] is not None] # Частота растёт с 10 до 80 Гц за полсекунды.
|
|
self.assertGreater(len(fields), 10) # Число завершённых периодов исключает единственный усреднённый результат на всю запись.
|
|
for field in fields: # У линейного разгона средняя частота периода равна мгновенной частоте в его середине.
|
|
seconds = (field['start'] + field['end']) / 2000000 - 0.00025 # Из середины периода вычитаем задержку полпериода несущей: 250 мкс.
|
|
expected = 10 + 140 * seconds # Эталон выводится непосредственно из заданного закона разгона, Гц.
|
|
self.assertAlmostEqual(field['frequency'], expected, delta=expected * 0.01) # Проверка обнаруживает неверное удвоение, постоянное значение и существенное отставание.
|
|
self.assertGreater(fields[-1]['frequency'], fields[0]['frequency'] * 2) # Последовательность измерений действительно отражает увеличение частоты.
|
|
|
|
def test_window_frequency_does_not_replace_measured_carrier(self): # Ошибочно заданная несущая не должна выдаваться за результат измерения.
|
|
events = self.signal(core.PwmChannel(1000000, carrier_hz=1000)) # Окна 1 кГц обрабатывают физический сигнал с несущей 2 кГц.
|
|
measured = [event for event in events if event['carrier_frequency'] is not None] # Первые окна без двух активных фронтов исключаются из сравнения.
|
|
self.assertGreater(len(measured), 100) # Достаточная длина проверяет устойчивую работу режима окон.
|
|
self.assertTrue(all(event['frequency'] == 1000 and event['carrier_frequency'] == 2000 for event in measured)) # Частота интегрирования и фактическая несущая остаются независимыми величинами.
|
|
fields = [event['field'] for event in measured if event['field'] is not None] # Даже при двух импульсах в одном окне огибающая содержит электрическую основную частоту.
|
|
self.assertTrue(fields) # Заданный размер окна не отключает измерение поля.
|
|
self.assertTrue(all(abs(field['frequency'] - 50) < 0.5 and field['carrier_frequency'] == 2000 for field in fields)) # Поле и средняя несущая одновременно получены из одной записи.
|
|
|
|
def test_constant_duty_and_small_ripple_have_no_field(self): # Постоянная несущая и квантование около нуля не должны превращаться в частоту поля.
|
|
for widths in ((250,), (249, 251), (100,), (400,)): # 50%, дрожание ±0,2 процентного пункта и постоянные 20/80% не проходят полосу 49–51%.
|
|
state, events = core.PwmChannel(1000000), [] # Каждый вектор начинается с нового независимого состояния.
|
|
for index in range(200): # Сто миллисекунд дают множество несущих периодов, но ни одного электрического цикла.
|
|
events.extend(state.update(index * 500, True)) # Активный фронт подтверждает наличие реальной несущей.
|
|
events.extend(state.update(index * 500 + widths[index % len(widths)], False)) # Заполнение постоянно либо колеблется внутри полосы гистерезиса.
|
|
self.assertTrue(events) # Отсутствие поля проверяется при наличии успешно декодированных импульсов.
|
|
self.assertTrue(all(event['field'] is None for event in events)) # Ни постоянный уровень, ни малое дрожание не дают ложной электрической частоты.
|
|
|
|
def test_variable_carrier_is_measured_for_each_real_period(self): # Автоматический режим должен следовать изменению частоты ШИМ, а не запоминать первое значение.
|
|
state = core.PwmChannel(1000000) # Частота дискретизации 1 МГц переводит длины периодов непосредственно в микросекунды.
|
|
state.update(0, False) # Низкий начальный уровень позволяет первому настоящему фронту открыть измерение.
|
|
stamp = 100 # Первый полный импульс начинается через 100 мкс от начала записи.
|
|
state.update(stamp, True) # Этот фронт ещё не задаёт частоту, поскольку предыдущий полный период отсутствует.
|
|
for period in (500, 400, 250, 1000): # Эталонная несущая последовательно принимает значения 2 кГц, 2,5 кГц, 4 кГц и 1 кГц.
|
|
state.update(stamp + period // 2, False) # Заполнение каждого переменного периода составляет 50%.
|
|
stamp += period # Следующий фронт завершает именно текущий период, а не среднее нескольких разных периодов.
|
|
event = state.update(stamp, True)[0] # Автоматический декодер выдаёт один результат на один завершённый период.
|
|
self.assertEqual(event['carrier_frequency'], 1000000 / period) # Численное измерение связано с текущей парой фронтов.
|
|
self.assertIsNone(event['field']) # Изменение несущей при постоянном заполнении не создаёт модуляцию поля.
|
|
|
|
def test_smoothed_waveform_keeps_stationary_field_frequency(self): # Включённый фильтр должен подавлять пульсации без изменения установившегося электрического периода.
|
|
events = self.signal(core.PwmChannel(1000000, smoothing_ms=1)) # Постоянная времени 1 мс сравнима с двумя периодами несущей 2 кГц.
|
|
fields = [event['field'] for event in events if event['field'] is not None] # Начальный переходный процесс отделяется от завершённых периодов фильтрованной кривой.
|
|
self.assertGreater(len(fields), 5) # Фильтр не должен полностью подавлять измерение поля 50 Гц.
|
|
self.assertTrue(all(abs(field['frequency'] - 50) < 0.5 for field in fields[2:])) # После затухания начального переходного процесса период сохраняется с погрешностью менее 1%.
|
|
|
|
def test_incomplete_field_does_not_emit_frequency(self): # Одного положительного перехода недостаточно для полного периода поля.
|
|
events = self.signal(core.PwmChannel(1000000), frequency=50, phase=-math.pi / 2, duration=0.012) # Двенадцать миллисекунд короче полного периода 50 Гц, равного 20 мс.
|
|
self.assertTrue(all(event['field'] is None for event in events)) # Анализатор ждёт второго подтверждённого восходящего пересечения.
|
|
|
|
def test_absent_edges_do_not_report_nominal_carrier(self): # Окна при постоянном уровне не являются доказательством наличия несущей.
|
|
state = core.PwmChannel(1000, carrier_hz=100) # Окно 10 отсчётов соответствует заданной частоте интегрирования 100 Гц.
|
|
state.update(0, False) # Начальный уровень не считается реальным активным фронтом.
|
|
events = state.update(100, False) # Десять окон полностью заполнены нулём без единого импульса.
|
|
self.assertTrue(all(event['carrier_frequency'] is None for event in events)) # Отсутствующая несущая остаётся неизвестной независимо от настройки окна.
|
|
state.update(110, True) # Первый действительный фронт задаёт только начало будущего измерения.
|
|
state.update(115, False) # Пять отсчётов высокого уровня формируют половину периода.
|
|
state.update(120, True) # Второй фронт завершает период 10 отсчётов, то есть 100 Гц.
|
|
events = state.update(170, True) # После исчезновения фронтов прежняя частота должна перестать считаться текущей.
|
|
self.assertEqual(events[0]['carrier_frequency'], 100) # Первое следующее окно ещё относится к недавно измеренной несущей.
|
|
self.assertIsNone(events[-1]['carrier_frequency']) # Длительное насыщение не сохраняет устаревшее числовое значение.
|
|
self.assertTrue(all(event['field'] is None for event in events)) # Один переход между постоянными уровнями не создаёт полный период поля.
|
|
|
|
def adapter(self, carrier=0): # Загружаем адаптер без внешнего Python-модуля sigrokdecode или нативного процесса.
|
|
class FakeDecoder: # Минимальная реализация интерфейса принимает штатные аннотации декодера.
|
|
def register(self, kind): # Регистрация возвращает идентификатор выхода для последующих put.
|
|
return kind # Нативный дескриптор не нужен для проверки содержимого аннотаций.
|
|
def put(self, start, end, output, data): # Аргументы совпадают с контрактом sigrokdecode.
|
|
self.emitted.append((start, end, data)) # Сохраняем интервалы и типы строк для проверки отображения.
|
|
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_frequency_adapter', ROOT / 'tools/dsview/decoders/pwm_waveform/pd.py') # Проверяем именно исходный адаптер поставки.
|
|
adapter = importlib.util.module_from_spec(spec) # Отдельный экземпляр модуля не меняет результаты других проверок.
|
|
spec.loader.exec_module(adapter) # Определение Decoder связывается с контролируемой заглушкой sigrok.
|
|
decoder = adapter.Decoder() # Конструктор сбрасывает состояния каналов так же, как при настоящем декодировании.
|
|
decoder.channels_state = {channel: core.PwmChannel(1000000, carrier_hz=carrier) for channel in (0, 2)} # Необязательный канал B отсутствует, а C остаётся доступным.
|
|
decoder.emitted = [] # Выходные аннотации начинают новую независимую запись.
|
|
decoder.start() # Регистрируем выход штатным методом адаптера.
|
|
return decoder # Тесты подают цифровые уровни через тот же _update, который вызывает decode.
|
|
|
|
def test_adapter_emits_independent_field_rows_and_preserves_curve_contract(self): # Проверяем видимые подписи обеих частот и совместимость нативного графика.
|
|
decoder = self.adapter() # Каналы A и C измеряются независимо при отсутствии B.
|
|
for index in range(400): # Двести миллисекунд содержат несколько периодов обоих электрических сигналов.
|
|
decoder.samplenum = index * 500 # Общие активные фронты задают несущую 2 кГц.
|
|
decoder._update([True, 255, True]) # Неподключённый B имеет служебное значение и не должен создавать аннотации.
|
|
widths = {channel: round(250 + 200 * math.sin(math.tau * frequency * index / 2000)) for channel, frequency in ((0, 50), (2, 25))} # A работает на 50 Гц, а C на 25 Гц при одинаковой несущей.
|
|
pins = [True, 255, True] # Уровни обоих подключённых каналов сохраняются между их индивидуальными спадами.
|
|
for channel in sorted(widths, key=widths.get): # Спады подаются в хронологическом порядке даже при разной ширине импульсов.
|
|
decoder.samplenum = index * 500 + widths[channel] # Время каждого события задано целым номером отсчёта.
|
|
pins[channel] = False # Меняется только выбранный вход, остальные сохраняют прежнее состояние.
|
|
decoder._update(pins) # Адаптер сам распределяет события по состояниям подключённых каналов.
|
|
fields = [event for event in decoder.emitted if event[2][0] >= 6] # Индексы дополнительных строк не затрагивают графику и заполнение 0–5.
|
|
self.assertEqual({event[2][0] for event in fields}, {6, 8}) # Канал B не создаёт пустые результаты или измерения чужого входа.
|
|
for start, end, (kind, labels) in fields: # Проверяем интервал полного периода и физические значения в видимых строках.
|
|
frequency = 50 if kind == 6 else 25 # Каждая строка должна сохранить частоту именно своего канала.
|
|
self.assertAlmostEqual(1000000 / (end - start), frequency, delta=frequency * 0.01) # Аннотация занимает целый электрический период и читается при обзорном масштабе.
|
|
self.assertIn('PWM carrier=2000.000 Hz', labels[0]) # Несущая выводится явным текстом с единицами Гц.
|
|
self.assertIn('Field={:.3f} Hz'.format(frequency), labels[0]) # Поле присутствует в той же основной подписи, что несущая.
|
|
self.assertEqual(labels[-1], '2kHz/{}Hz'.format(frequency)) # Даже самый короткий вариант сохраняет обе частоты, чтобы обзорный масштаб не скрывал несущую.
|
|
curves = [event for event in decoder.emitted if event[2][0] < 3] # Нативный renderer продолжает получать прежние графические классы.
|
|
self.assertTrue(all(event[2][1][-1].startswith('PWM1 ') for event in curves)) # Версионный контракт сегментов кривой остаётся неизменным.
|
|
|
|
def test_adapter_distinguishes_window_and_carrier_in_visible_label(self): # Оба числовых значения должны быть различимы непосредственно в интерфейсе.
|
|
decoder = self.adapter(carrier=1000) # Окна заданы на 1 кГц при фактической несущей 2 кГц.
|
|
for stamp, level in ((0, False), (100, True), (350, False), (600, True), (850, False), (1100, True), (1350, False), (1600, True), (2000, False)): # Последовательность содержит несколько полных периодов несущей до второго окна.
|
|
decoder.samplenum = stamp # Время каждого уровня передаётся в штатных номерах отсчётов sigrok.
|
|
decoder._update([level, 255, level]) # Оба подключённых канала получают один и тот же тестовый сигнал.
|
|
labels = [event[2][1][0] for event in decoder.emitted if event[2][0] in (3, 5)] # Проверяется строка заполнения, доступная и без завершённого периода поля.
|
|
self.assertTrue(any('PWM carrier=2000.000 Hz' in label and 'window=1000.000 Hz' in label for label in labels)) # Подпись не подменяет реальную несущую заданной частотой расчётного окна.
|
|
|
|
|
|
if __name__ == '__main__': # Прямой запуск файла равнозначен обнаружению стандартным unittest.
|
|
unittest.main() # Ненулевой код завершения сообщает о нарушении измерительного контракта.
|