Расширить DSView осциллографом и декодерами двигателя и сигма-дельта

This commit is contained in:
2026-11-12 15:00:41 +03:00
parent fa5ea166a9
commit 1fc88684d5
41 changed files with 4147 additions and 151 deletions
+146
View File
@@ -0,0 +1,146 @@
"""Расчёт токов асинхронного двигателя по состояниям ключей инвертора.""" # Модель не зависит от GUI, sigrok и сторонних пакетов Python.
import cmath # Комплексные числа представляют неподвижные электрические оси α и β.
import math # Проверки параметров и преобразования СИ используют стандартную библиотеку.
def finite(value, name, minimum=None, strict=False): # Единая проверка исключает NaN, бесконечности и нефизические параметры.
number = float(value) # Параметры sigrok и непосредственных вызовов приводятся к одному числовому типу.
if not math.isfinite(number) or minimum is not None and (number <= minimum if strict else number < minimum): # Строгая граница применяется к сопротивлениям и индуктивностям.
raise ValueError('{} has an invalid value: {}'.format(name, value)) # Сообщение указывает конкретную настройку пользователя.
return number # Возвращается только проверенное значение в единицах вызывающей стороны.
def clarke(values): # Амплитудно-инвариантное преобразование сохраняет амплитуду симметричных фаз.
return complex((2 * values[0] - values[1] - values[2]) / 3, (values[1] - values[2]) / math.sqrt(3)) # Оси αβ соответствуют порядку фаз A, B, C.
def phases(vector): # Обратное преобразование восстанавливает три фазы трёхпроводной нагрузки.
return (vector.real, -vector.real / 2 + math.sqrt(3) * vector.imag / 2, -vector.real / 2 - math.sqrt(3) * vector.imag / 2) # Сумма фаз равна нулю по первому закону Кирхгофа.
class InductionMotor: # Линейная модель короткозамкнутого двигателя использует приведённые к статору параметры ротора.
def __init__(self, rs=1.0, rr=0.8, lls_mh=5.0, llr_mh=5.0, lm_mh=150.0, pole_pairs=2, speed_rpm=1440.0, initial_flux=0.0, flux_angle=0.0, ia=0.0, ib=0.0): # Значения по умолчанию являются примером, а не паспортом конкретного двигателя.
self.rs = finite(rs, 'Stator R (ohm)', 0, True) # Положительное сопротивление одной обмотки статора задаётся в омах.
self.rr = finite(rr, 'Rotor R referred to stator (ohm)', 0, True) # Сопротивление ротора должно быть приведено к той же обмотке статора.
lls = finite(lls_mh, 'Stator leakage L (mH)', 0, True) / 1000 # Миллигенри переводятся в генри перед вычислением уравнений.
llr = finite(llr_mh, 'Rotor leakage L (mH)', 0, True) / 1000 # Параметр ротора также приводится к статору и переводится в генри.
lm = finite(lm_mh, 'Magnetizing L (mH)', 0, True) / 1000 # Взаимная индуктивность задаёт магнитную связь без насыщения.
pairs = finite(pole_pairs, 'Pole pairs', 1) # Число пар полюсов переводит механическую скорость в электрическую.
if pairs != int(pairs): # Дробное число пар полюсов не описывает физический двигатель.
raise ValueError('Pole pairs must be an integer.') # Ошибка выдаётся до расчёта токов.
omega = finite(speed_rpm, 'Mechanical speed (rpm)') * pairs * math.tau / 60 # Один оборот равен 2π радиан, одна минута содержит 60 секунд.
lr = llr + lm # Полная индуктивность ротора включает рассеяние и намагничивание.
self.lsigma = lls + lm * llr / lr # Устойчивая форма Ls−Lm²/Lr не теряет точность при малом рассеянии.
self.k = lm / lr # Коэффициент связи переводит потокосцепление ротора в уравнение статора.
self.a = -(self.rs + self.rr * self.k * self.k) / self.lsigma # Первый диагональный коэффициент учитывает потери статора и приведённого ротора.
self.b = self.k * complex(self.rr / lr, -omega) / self.lsigma # Влияние потока ротора включает его вращение относительно неподвижных осей.
self.c = self.rr * self.k # Ток статора возбуждает поток ротора через взаимную индуктивность.
self.d = complex(-self.rr / lr, omega) # Поток ротора затухает с постоянной Lr/Rr и вращается с электрической скоростью ротора.
self.det = self.a * self.d - self.b * self.c # Определитель нужен для точного вынужденного решения при постоянном напряжении.
self.mid = (self.a + self.d) / 2 # Половина следа матрицы задаёт среднее собственных значений.
self.root = cmath.sqrt(((self.a - self.d) / 2) ** 2 + self.b * self.c) # Разность собственных значений позволяет вычислить матричную экспоненту без numpy.
initial = (finite(ia, 'Initial Ia (A)'), finite(ib, 'Initial Ib (A)')) # Начальные линейные токи задаются в амперах.
self.current = clarke((initial[0], initial[1], -sum(initial))) # Третья фаза определяется условием трёхпроводного подключения.
self.flux = finite(initial_flux, 'Initial rotor flux linkage (Wb)', 0) * cmath.exp(1j * math.radians(finite(flux_angle, 'Initial rotor flux angle (deg)'))) # Модуль и угол задают неизвестное при начале записи начальное потокосцепление.
self.cache = {} # Повторяющиеся интервалы переключений используют готовую матрицу перехода.
def advance(self, seconds, voltage): # Между фронтами ключей напряжение неизменно, поэтому используется точное линейное решение.
dt = finite(seconds, 'Time interval (s)', 0) # Отрицательное время и неконечные интервалы запрещены.
if not dt: # Повторные события одного отсчёта меняют ключи без изменения физического состояния.
return phases(self.current) # Начальный ток сохраняется без искусственного шага интегрирования.
matrix = self.cache.get(dt) # На равномерной сетке большинство длительностей повторяется.
if matrix is None: # Новая длительность требует вычисления exp(A·dt) ровно один раз.
if abs(self.root * dt) < 1e-8: # При близких собственных значениях деление на их разность теряет точность.
factor = cmath.exp(self.mid * dt) # Общая экспонента остаётся ограниченной для пассивного двигателя.
ratio = factor * dt * (1 + (self.root * dt) ** 2 / 6) # Разложение sinh(z)/z сохраняет точность около нуля.
center = factor * (1 + (self.root * dt) ** 2 / 2) # Разложение cosh(z) согласовано с тем же пределом.
else: # Раздельные экспоненты избегают переполнения cosh при сильно различающихся постоянных времени.
first, second = cmath.exp((self.mid + self.root) * dt), cmath.exp((self.mid - self.root) * dt) # Обе моды учитывают затухание и вращение потока ротора.
ratio, center = (first - second) / (2 * self.root), (first + second) / 2 # Эти коэффициенты задают экспоненту матрицы размерности два.
matrix = (center + ratio * (self.a - self.mid), ratio * self.b, ratio * self.c, center + ratio * (self.d - self.mid)) # Порядок элементов соответствует строкам тока статора и потока ротора.
if len(self.cache) >= 1024: # Ограничение в 1024 различных длительности исключает рост памяти на нерегулярном ШИМ.
self.cache.clear() # Сброс кэша влияет только на производительность, а не на состояние двигателя.
self.cache[dt] = matrix # Сохраняется только матрица перехода, независимая от текущего напряжения.
drive = clarke(voltage) / self.lsigma # Фазные напряжения переводятся в неподвижный вектор скорости изменения тока.
equilibrium_i, equilibrium_flux = -self.d * drive / self.det, self.c * drive / self.det # Частное решение −A⁻¹B·u учитывает постоянное напряжение на всём интервале.
delta_i, delta_flux = self.current - equilibrium_i, self.flux - equilibrium_flux # Начальное состояние переносится относительно частного решения.
self.current = equilibrium_i + matrix[0] * delta_i + matrix[1] * delta_flux # Ток обновляется без явного Эйлера и зависимости устойчивости от частоты отображения.
self.flux = equilibrium_flux + matrix[2] * delta_i + matrix[3] * delta_flux # Поток использует прежний ток, сохранённый до обновления состояния.
if not all(math.isfinite(value) for value in (self.current.real, self.current.imag, self.flux.real, self.flux.imag)): # Чрезмерные параметры не должны порождать молчаливые NaN в графиках.
raise ValueError('Motor state overflow: check motor parameters and capture timing.') # Сообщение связывает ошибку с физическими параметрами и временем записи.
return phases(self.current) # Возвращаются мгновенные токи трёх эквивалентных обмоток в амперах.
class PwmMotor: # Связь логических входов, силовой схемы и общего двигателя не содержит зависимости от DSView.
def __init__(self, samplerate, topology='2-level', dc_voltage=560.0, midpoint_percent=50.0, connection='star', active_high=True, **motor): # Полное напряжение звена задаётся между положительной и отрицательной шинами.
self.rate = finite(samplerate, 'Samplerate (Hz)', 0, True) # Частота захвата переводит номера отсчётов в секунды.
if topology not in ('2-level', '3-level P/N', '3-level NPC') or connection not in ('star', 'delta'): # Поддерживаются явные схемы без автоматического угадывания кодировки.
raise ValueError('Unsupported inverter topology or winding connection.') # Неизвестная схема не подменяется другой электрической моделью.
dc = finite(dc_voltage, 'Total DC link (V)', 0, True) # Значение в вольтах относится ко всему звену, а не к его половине.
split = finite(midpoint_percent, 'Upper DC link (%)', 0, True) / 100 # Доля верхней половины позволяет моделировать дисбаланс конденсаторов.
if split >= 1: # Обе половины звена должны иметь строго положительное напряжение.
raise ValueError('Upper DC link fraction must be between 0 and 100 percent.') # Граничные значения не описывают трёхуровневое звено.
self.upper, self.lower = dc * split, -dc * (1 - split) # Потенциалы полюсов отсчитываются от средней точки конденсаторов.
self.topology, self.connection, self.active_high = topology, connection, bool(active_high) # Параметры сохраняются для интерпретации каждой новой комбинации ключей.
ia, ib = motor.get('ia', 0.0), motor.get('ib', 0.0) # Начальные токи пользователь задаёт в линейных проводах независимо от схемы обмоток.
if connection == 'delta': # В симметричном треугольнике линейный ток равен разности токов соседних обмоток.
motor.update(ia=(2 * ia + ib) / 3, ib=(ib - ia) / 3) # Обратное преобразование предполагает отсутствие циркулирующего тока нулевой последовательности.
self.motor = InductionMotor(**motor) # Параметры Rr и Lr всегда приведены к одной фактической обмотке статора.
self.sample, self.pins, self.poles = None, None, (0.0, 0.0, 0.0) # До первого отсчёта не создаётся вымышленный участок записи.
self.warnings = () # Диагностика сообщает о комбинациях, не задающих однозначное выходное напряжение.
self.interval_warnings = () # Завершённый интервал сохраняет собственную диагностику до приёма следующего состояния затворов.
def currents(self): # Публичные результаты относятся к проводам A, B, C, подключённым к инвертору.
a, b, c = phases(self.motor.current) # Эквивалентные токи обмоток сохраняют условие отсутствия нулевой последовательности.
return (a - c, b - a, c - b) if self.connection == 'delta' else (a, b, c) # Для треугольника выводятся линейные токи, а не токи внутри обмоток.
def set_gates(self, pins): # Нумерация: Q1/P фаз A/B/C, Q4/N фаз A/B/C, затем Q2/Q3 каждой фазы.
if len(pins) != 12 or any(value not in (0, 1, None) for value in pins): # Отсутствующий логический канал обозначается None до нормализации полярности.
raise ValueError('Expected twelve gate inputs containing 0, 1 or None.') # Служебные значения sigrok не принимаются за реальный логический уровень.
bits = tuple(None if value is None else bool(value) == self.active_high for value in pins) # Инверсия применяется только к реально назначенным входам.
required = range(3) if self.topology == '2-level' else range(6) if self.topology == '3-level P/N' else range(12) # Три, шесть или двенадцать каналов нужны выбранной схеме.
if any(bits[index] is None for index in required): # Неполная трёхфазная схема не позволяет определить линейные напряжения двигателя.
raise ValueError('Assign all gate channels required by the selected topology.') # Ошибка возникает до продолжения расчёта по отсутствующим сигналам.
if self.topology == '2-level' and any(value is not None for value in bits[3:6]) and any(value is None for value in bits[3:6]): # Нижние ключи двухуровневой схемы назначаются одновременно для всех фаз.
raise ValueError('Assign either all three lower gates or none.') # Частично измеренные нижние ключи не смешиваются с идеальными дополнениями.
poles, warnings = [], [] # Каждая фаза сначала получает собственный потенциал и список неоднозначностей.
currents = self.currents() # Направление линейного тока задаёт приближённую проводимость обратных диодов во время dead time.
for phase in range(3): # Все входы одного отсчёта обрабатываются одновременно, без ложных промежуточных состояний.
high, low = bits[phase], bits[phase + 3] # В P/N это команды положительного и отрицательного уровней; в NPC это внешние Q1 и Q4.
state = (high, low) # Стандартная таблица P/N использует только два внешних логических входа.
table = {(True, False): self.upper, (False, True): self.lower, (False, False): 0.0} # Состояние 00 задаёт среднюю точку только в явной кодировке P/N.
if self.topology == '2-level': # Один вход на фазу означает идеальную комплементарную пару без измеренного dead time.
if low is None: # Нижняя команда восстанавливается только когда пользователь не назначил соответствующие входы.
low = not high # Низкий уровень верхней команды соответствует отрицательной шине.
state = (high, low) # Реальные нижние команды сохраняют интервалы одновременного закрытия ключей.
table = {(True, False): self.upper, (False, True): self.lower} # Одновременное включение двух ключей не имеет допустимого фазного напряжения.
if self.topology == '3-level NPC': # NPC обрабатывает четыре отдельных затворных сигнала каждого плеча.
state = (high, bits[6 + 2 * phase], bits[7 + 2 * phase], low) # Физический порядок ключей идёт сверху вниз от положительной шины к отрицательной.
table = {(True, True, False, False): self.upper, (False, True, True, False): 0.0, (False, False, True, True): self.lower} # Допустимые состояния Q1…Q4 равны 1100, 0110 и 0011.
if state in table: # Однозначная команда непосредственно определяет полюсное напряжение.
pole = table[state] # Токи интегрируются по реальному напряжению, а не по сглаженному заполнению ШИМ.
elif not any(state): # Полностью закрытое плечо проводит через обратные диоды при ненулевом токе.
pole = self.lower if currents[phase] > 1e-9 else self.upper if currents[phase] < -1e-9 else 0.0 # Порог 1 нА подавляет выбор шины по ошибке округления около нулевого тока.
warnings.append('Phase {}: diode/dead-time approximation'.format('ABC'[phase])) # Неизвестные падения на диодах и смена проводимости явно отмечаются как приближение.
else: # Недопустимые перекрытия и неполные состояния NPC не интерпретируются как достоверное напряжение.
pole = self.poles[phase] # Последний определённый потенциал используется только как явно помеченное продолжение оценки.
warnings.append('Phase {}: invalid gates {}; current estimate unreliable'.format('ABC'[phase], ''.join(str(int(value)) for value in state))) # Диагностика содержит фазу и фактическую комбинацию ключей.
poles.append(pole) # Все три полюса сохраняются до вычисления напряжений обмоток.
self.pins, self.poles, self.warnings = bits, tuple(poles), tuple(warnings) # Состояние относится к интервалу после текущего одновременного переключения.
def update(self, sample, pins=None): # Сначала завершается предыдущий электрический интервал и только затем меняются ключи.
stamp = int(sample) # Номера отсчётов сохраняются целыми, чтобы исключить накопление ошибки временной сетки.
if stamp != sample or stamp < 0 or self.sample is not None and stamp < self.sample: # Отсчёты должны поступать в хронологическом порядке.
raise ValueError('Sample numbers must be nonnegative, integral and monotonic.') # Ошибка входной последовательности не маскируется нулевой длительностью.
previous, start = self.currents(), self.sample # Левый конец сегмента берётся до интегрирования предыдущего напряжения.
self.interval_warnings = self.warnings # Выходной пакет должен описывать ключи рассчитанного интервала, а не уже принятую новую команду.
if start is not None and stamp > start: # Первый отсчёт определяет только начальное состояние записи.
a, b, c = self.poles # Полюсные напряжения неизменны с прошлого обработанного фронта.
voltage = (a - b, b - c, c - a) if self.connection == 'delta' else (a, b, c) # Преобразование Кларка само вычитает плавающий потенциал нейтрали звезды.
self.motor.advance((stamp - start) / self.rate, voltage) # Точное решение учитывает длительность каждого фактического состояния ключей.
self.sample = stamp # Обновление временной отметки предшествует приёму новых команд того же отсчёта.
if pins is not None: # Периодические точки графика могут приходить без нового фронта.
self.set_gates(pins) # Новые уровни действуют только справа от своей временной отметки.
elif self.pins is not None: # Переоценка диодов на сетке ограничивает ошибку около смены знака тока.
self.set_gates(tuple(None if value is None else int(value == self.active_high) for value in self.pins)) # Логические уровни переводятся обратно во входную полярность без двойной инверсии.
return None if start is None or stamp == start else (start, stamp, previous, self.currents()) # Сегмент содержит физические амперы на обоих концах интервала.
+152 -1
View File
@@ -19,6 +19,11 @@ class PwmChannel:
self.window_index = 1
self.high = 0
self.previous = None
self.carrier_start = self.carrier_period = None # Два действительных активных фронта нужны для измерения несущей; начальный уровень не считается фронтом.
self.carrier_count = self.carrier_samples = 0 # Счётчик полных периодов и их суммарная длина в отсчётах позволяют усреднять несущую без хранения записи.
self.field_previous = self.field_crossing = self.field_pending = None # Интерполированное пересечение уровня 50% отделено от подтверждённого полного периода поля.
self.field_armed = False # До отрицательной полуволны начальный фрагмент огибающей не может открыть измерение поля.
self.field_carrier = None # Снимок счётчиков несущей привязывает среднюю частоту к последнему завершённому периоду поля.
def next_boundary(self):
if self.window is None or self.origin is None:
@@ -34,9 +39,33 @@ class PwmChannel:
alpha = -math.expm1(-(midpoint - previous[0]) / self.tau)
value = previous[1] + alpha * (value - previous[1])
self.previous = (midpoint, value)
carrier = self.rate / self.carrier_period if self.carrier_period and end - self.carrier_start <= 2 * self.carrier_period else None # После двух периодов без фронтов прежняя несущая больше не считается текущим измерением.
field = self._field(midpoint, value) # Частота поля определяется по той же нормированной огибающей, которая выводится на график.
return dict(start=start, end=end, duty=duty, value=value,
frequency=self.rate / (end - start), midpoint=midpoint,
previous=previous)
previous=previous, carrier_frequency=carrier, field=field) # Частота окна остаётся совместимой со старым контрактом, измеренная несущая и полный период поля передаются отдельно.
def _field(self, sample, value): # Потоковый детектор измеряет электрическую частоту между одноимёнными пересечениями уровня заполнения 50%.
previous = self.field_previous # Предыдущая точка нужна для линейной интерполяции времени пересечения между серединами периодов ШИМ.
self.field_previous = (sample, value) # Храним только последнюю точку, поэтому память не зависит от длительности захвата.
if value <= -0.02: # Гистерезис 0,02 нормированной шкалы соответствует одному процентному пункту заполнения ниже 50%.
self.field_armed = True # Отрицательная полуволна разрешает одно следующее положительное пересечение.
if self.field_armed and previous is not None and previous[1] <= 0 < value: # Учитываем только восходящий переход, чтобы не удвоить электрическую частоту.
self.field_pending = previous[0] - previous[1] * (sample - previous[0]) / (value - previous[1]) # Интерполяция устраняет квантование периода поля шагом несущей.
if value <= 0: # Возврат через нуль до положительного порога означает неподтверждённое пересечение.
self.field_pending = None # Колебания внутри полосы гистерезиса не создают отдельные периоды.
if not self.field_armed or self.field_pending is None or value < 0.02: # Положительный порог также отстоит от 50% на один процентный пункт заполнения.
return None # Постоянное заполнение и неполные полуволны не получают выдуманную частоту поля.
crossing = self.field_pending # Подтверждение использует время нулевого пересечения, а не более позднее достижение порога.
last = self.field_crossing # Два подтверждённых восходящих пересечения ограничивают один полный электрический период.
counters = (self.carrier_count, self.carrier_samples) # Снимок относится только к действительно наблюдавшимся активным фронтам.
old_counters = self.field_carrier # Разность накопленных счётчиков даёт среднюю несущую за текущий период поля.
self.field_crossing, self.field_carrier = crossing, counters # Следующий период начинается с текущего подтверждённого пересечения.
self.field_armed, self.field_pending = False, None # Новое измерение разрешается только после следующей отрицательной полуволны.
if last is None: # Первое пересечение задаёт начало, но ещё не содержит полного периода.
return None # Начальный неполный фрагмент записи исключён из результата.
count, elapsed = counters[0] - old_counters[0], counters[1] - old_counters[1] # Длительность измеренных периодов несущей выражена в отсчётах, а не миллисекундах.
return dict(start=round(last), end=round(crossing), frequency=self.rate / (crossing - last), carrier_frequency=self.rate * count / elapsed if elapsed else None) # Полный период поля получает частоту в Гц и среднюю измеренную несущую, если за интервал были фронты.
def update(self, sample, pin):
level = bool(pin) == self.active_high
@@ -48,6 +77,8 @@ class PwmChannel:
if sample < self.last:
raise ValueError('Samples must be monotonic.')
events = []
if level != self.level and level and self.window is None: # В автоматическом режиме новый активный фронт одновременно завершает текущий период ШИМ.
self._carrier_edge(sample) # Обновляем измерение до выдачи завершённого периода, чтобы частота относилась именно к нему.
if self.window is not None:
boundary = self.next_boundary()
while boundary <= sample:
@@ -58,6 +89,8 @@ class PwmChannel:
self.window_index += 1
boundary = self.next_boundary()
self.high += (sample - self.last) * self.level
if level != self.level and level: # В режиме окон частота несущей всё равно измеряется по фронтам, независимо от заданного размера окна.
self._carrier_edge(sample) # Фронт на границе окна относится к следующему окну и не меняет уже выданное измерение.
elif level != self.level:
if level:
if self.start is not None and self.fall is not None:
@@ -67,3 +100,121 @@ class PwmChannel:
self.fall = sample
self.last, self.level = sample, level
return events
def _carrier_edge(self, sample): # Оба режима восстановления используют единый счётчик фактических периодов несущей.
if self.carrier_start is not None and sample > self.carrier_start: # Первый фронт только задаёт начало; нулевой период не допускается.
self.carrier_period = sample - self.carrier_start # Интервал активный фронт — активный фронт задаёт полный период ШИМ в отсчётах.
self.carrier_count += 1 # Счётчик нужен для среднего числа полных периодов за электрический цикл.
self.carrier_samples += self.carrier_period # Суммируем длительности, чтобы переменная несущая усреднялась как число периодов за время.
self.carrier_start = sample # Следующее измерение начинается с текущего реального фронта.
class NpcChannel(PwmChannel): # Один вход NPC объединяет четыре затвора S1…S4, расположенные сверху вниз в фазной стойке.
STATES = {(1, 1, 0, 0): 1, (0, 1, 1, 0): 0, (0, 0, 1, 1): -1} # Допустимые состояния классического NPC дают +Udc/2, нейтраль и −Udc/2 соответственно.
def __init__(self, samplerate, active_high=True, carrier_hz=0, smoothing_ms=0): # Настройки времени и полярности совпадают с двухуровневым режимом.
super().__init__(samplerate, active_high, carrier_hz, smoothing_ms) # Общий расчёт частоты поля и фильтра применяется к среднему знаковому уровню NPC.
self.integral = self.known = 0 # Интеграл уровня выражен в отсчётах; known хранит длительность достоверных состояний.
self.last_valid = self.pulse_sign = None # Пропуск dead-time не должен ошибочно создавать новый импульс той же полярности.
def state(self, gates): # Преобразование использует все четыре затвора, а не только два внешних ключа.
return self.STATES.get(tuple(int(bool(pin) == self.active_high) for pin in gates)) # Неизвестное состояние, включая все выключенные ключи, возвращает None вместо выдуманного нуля напряжения.
def _accumulate(self, sample): # Между цифровыми событиями уровень стойки считается постоянным только при допустимой комбинации затворов.
if self.level is not None: # Во время dead-time напряжение зависит от тока и диодов и не определяется одними управляющими сигналами.
self.integral += (sample - self.last) * self.level # Знаковая площадь сохраняет отличие положительных, нулевых и отрицательных участков.
self.known += sample - self.last # В среднем используются лишь участки, однозначно восстановленные по затворам.
self.last = sample # Новый интервал начинается после полностью учтённой части времени.
def _npc_emit(self, start, end): # Среднее по допустимым состояниям является оценкой огибающей, а не измерением напряжения в dead-time.
coverage = self.known / (end - start) # Доля известного времени позволяет отделить короткие коммутационные паузы от повреждённой записи.
if coverage < 0.95: # При более чем 5% неизвестного времени в периоде оценка считается недостаточно определённой и пропускается.
self.previous = self.field_previous = self.field_crossing = self.field_pending = self.field_carrier = None # Разрыв не должен соединяться графиком или измерением поля с предыдущим достоверным участком.
self.field_armed = False # Новое поле должно заново подтвердить отрицательную полуволну.
return [] # Неизвестный интервал не превращается в фиктивный уровень нуля.
high = (self.integral / self.known + 1) * (end - start) / 2 # Общий _emit ожидает эквивалентную активную длительность; преобразование сохраняет средний уровень −1…+1.
event = self._emit(start, end, high) # Общая обработка формирует середину периода, частоты и фильтрованное значение.
event['valid_fraction'] = coverage # Точность определения уровня видна в числовой аннотации и экспорте.
return [event] # Формат списка совместим с обновлением двухуровневого канала.
def update(self, sample, gates): # Одно событие получает атомарный снимок четырёх цифровых входов.
level = self.state(gates) # Только три штатные комбинации имеют определённый нормированный уровень стойки.
if self.last is None: # Начальный уровень не является обнаруженным фронтом несущей.
self.last, self.level, self.last_valid = sample, level, level # Состояние начинается с текущего снимка без выдуманного предыдущего импульса.
if self.window is not None: # В режиме заданной несущей интегрирование начинается с области декодирования.
self.origin = self.start = sample # Границы окон совпадают с прежним контрактом PwmChannel.
return [] # Начальная точка ещё не содержит полного периода.
if sample < self.last: # Равные номера допустимы для одновременных фронтов разных каналов.
raise ValueError('Samples must be monotonic.') # Отрицательные интервалы нельзя интегрировать или выдавать в DSView.
entry = level in (-1, 1) and level != self.last_valid # Начало ненулевого импульса распознаётся с учётом последнего достоверного состояния до dead-time.
events = [] # Результаты содержат лишь полные периоды или окна.
if self.window is not None: # Фиксированные окна сохраняют 0/100% и работают при отсутствии переключений.
boundary = self.next_boundary() # Граница рассчитывается от начала, поэтому дробный период не накапливает ошибку округления.
while boundary <= sample: # Одно цифровое событие может завершить несколько окон постоянного уровня.
self._accumulate(boundary) # Учёт предыдущего состояния заканчивается точно на границе окна.
events.extend(self._npc_emit(self.start, boundary)) # Неизвестные участки проверяются до формирования результата.
self.start = boundary # Следующее окно начинается без перекрытия с предыдущим.
self.integral = self.known = 0 # Площадь и известное время относятся только к новому окну.
self.window_index += 1 # Номер следующей границы задаёт её положение в общей шкале отсчётов.
boundary = self.next_boundary() # Повторное вычисление сохраняет точность дробной частоты.
self._accumulate(sample) # Оставшаяся часть предыдущего уровня учитывается до изменения затворов.
if entry: # Частота несущей измеряется по началам последовательных ненулевых импульсов одинакового знака.
if self.pulse_sign == level: # Переход положительной полуволны в отрицательную не является коротким периодом несущей.
self._carrier_edge(sample) # Измеренный период не подменяется частотой заданного окна.
if self.window is None and self.start is not None: # Автоматический режим завершает период на одноимённом фронте следующего импульса.
events.extend(self._npc_emit(self.start, sample)) # Огибающая получается из интеграла всех трёх уровней за полный период.
else: # Первое появление другой полярности заново синхронизирует несущую этой полуволны.
self.carrier_start, self.carrier_period = sample, None # Интервал через смену знака не принимается за измеренную частоту.
self.pulse_sign = level # Следующий ненулевой импульс сравнивается с текущей полярностью.
if self.window is None: # В автоматическом режиме начало и интеграл обновляются только на опорных фронтах.
self.start = sample # Начальный неполный фрагмент полуволны исключается.
self.integral = self.known = 0 # Новый период не наследует площадь предыдущего.
self.level = level # Новое состояние действует только после текущего цифрового события.
if level is not None: # Неизвестная коммутационная пауза не стирает информацию о последнем допустимом уровне.
self.last_valid = level # Это исключает ложные импульсы при кратком выключении и возврате к прежней комбинации.
return events # Адаптер получает тот же поток результатов, что для обычного ШИМ.
class PhaseAnalysis: # Согласование времён A/B/C отделено от конкретного числа затворов в фазе.
def __init__(self, samplerate): # Частота нужна для контроля устаревших оценок фазового периода.
self.rate = samplerate # Все временные координаты остаются в отсчётах захвата.
self.points, self.fields = {}, {} # Для интерполяции хранятся две последние точки каждой огибающей и последний полный период поля.
self.last_vector = None # Одинаковый общий момент времени не должен выдавать повторный угол.
def add(self, channel, event): # Каждый восстановленный период обновляет только свою фазу.
points = self.points.setdefault(channel, []) # Отсутствующая фаза не получает нулевой или выдуманный сигнал.
if event.get('previous', True) is None: # Ядро явно разрывает огибающую после недостаточно определённого окна NPC.
points.clear() # Интерполяция не должна соединять новую точку с данными до неизвестного участка.
self.fields.pop(channel, None) # Межфазные углы также должны заново дождаться полного достоверного периода.
points.append((event['midpoint'], event['value'])) # Угол вычисляется по восстановленным средним уровням, а не по мгновенным коммутациям.
del points[:-2] # Ограниченная история сохраняет постоянный расход памяти на длинных записях.
if event['field'] is not None: # Межфазные углы требуют наблюдавшихся полных электрических периодов.
field = event['field'] # Результат содержит последнее восходящее пересечение и частоту данной фазы.
self.fields[channel] = (field['end'], self.rate / field['frequency']) # Период хранится в отсчётах для вычисления сдвига в градусах.
if any(len(self.points.get(ch, [])) < 2 for ch in range(3)): # Пространственный вектор нельзя восстановить из неподключённых или ещё не заполненных A/B/C.
return None # Начальный участок записи не получает ложного θ.
sample = min(self.points[ch][-1][0] for ch in range(3)) # Общая точка не выходит за наблюдавшееся время ни одной фазы.
if sample <= max(self.points[ch][0][0] for ch in range(3)) or self.last_vector is not None and sample <= self.last_vector: # Требуется общий интервал и новое время, без экстраполяции остановившейся фазы.
return None # Несогласованные точки не смешиваются в один вектор.
values = [] # Три значения будут относиться к одному и тому же моменту времени.
for ch in range(3): # Порядок A/B/C определяет положительное направление вращения.
left, right = self.points[ch] # Две соседние точки ограничивают достоверный интервал данной фазы.
values.append(left[1] + (right[1] - left[1]) * (sample - left[0]) / (right[0] - left[0])) # Линейная интерполяция устраняет сдвиг, вызванный разными моментами фронтов PWM A/B/C.
alpha = (2 * values[0] - values[1] - values[2]) / 3 # Полное преобразование Кларка удаляет синфазную составляющую, включая добавку SVPWM.
beta = (values[1] - values[2]) / math.sqrt(3) # Ортогональная ось соответствует последовательности A, B, C.
self.last_vector = sample # Данный момент времени считается обработанным даже при почти нулевом векторе.
if math.hypot(alpha, beta) < 0.02: # Ниже 2% нормированного уровня направление вектора не считается определённым.
return dict(sample=sample, theta=None) # Разрыв явно запрещает соединять графиком углы через нулевую амплитуду.
return dict(sample=sample, theta=math.degrees(math.atan2(beta, alpha)) % 360) # θ выражен в электрических градусах 0…360 относительно положительной оси A.
def differences(self, sample): # Положительный результат A→B означает запаздывание фазы B относительно A.
angles = {} # Отсутствующая пара не получает выдуманный сдвиг 120 градусов.
for first, second in ((0, 1), (1, 2), (2, 0)): # Измеряются все три запрошенные пары независимо.
if first not in self.fields or second not in self.fields: # Каждой фазе пары нужен хотя бы один полный измеренный период.
continue # Две подключённые фазы всё ещё могут дать одну доступную пару.
a, b = self.fields[first], self.fields[second] # Последние одноимённые пересечения задают фазовые часы двух огибающих.
if any(sample < item[0] or sample - item[0] > 2 * item[1] for item in (a, b)): # После двух периодов без нового измерения прежний сдвиг становится устаревшим.
continue # Остановившийся или пропавший вход не сохраняет старое числовое значение.
angle = 360 * ((sample - a[0]) / a[1] - (sample - b[0]) / b[1]) # Разность фазовых часов выражает запаздывание второй огибающей в градусах.
angles['ABC'[first] + '→' + 'ABC'[second]] = (angle + 180) % 360 - 180 # Знаковый диапазон −180…180 различает запаздывание и опережение, включая обратную последовательность фаз.
return angles # Результаты рассчитываются из записи и не предполагают заранее правильную трёхфазную систему.
@@ -0,0 +1,39 @@
"""Потоковый CIC-декодер однобитного сигма-дельта модулятора без зависимостей от GUI.""" # Ядро одинаково работает в DSView и автономных анализаторах.
class SigmaDeltaFilter: # Каскад интеграторов и гребенчатых звеньев реализует фильтры sinc первого, второго и третьего порядка.
def __init__(self, decimation=256, order=3, invert=False): # Децимация задаётся числом тактов на результат; 256 и sinc3 являются начальными настройками анализатора.
if isinstance(decimation, bool) or not isinstance(decimation, int) or not 1 <= decimation <= 65536: # Ограничение 65536 тактов удерживает максимальное усиление sinc3 в пределах 49 бит.
raise ValueError('Decimation must be an integer from 1 to 65536.') # Дробные и неположительные окна не определяют допустимую децимацию.
if isinstance(order, bool) or not isinstance(order, int) or order not in (1, 2, 3): # Число интеграторов должно совпадать с числом гребенчатых звеньев.
raise ValueError('Filter order must be 1, 2 or 3.') # Неизвестный порядок нельзя незаметно заменять sinc3.
self.decimation = decimation # Каждый результат появляется после этого числа принятых битов.
self.order = order # Порядок также определяет длительность начального установления фильтра.
self.invert = bool(invert) # Инверсия применяется к битам до фильтрации и меняет знак восстановленного сигнала.
self.gain = decimation ** order # Усиление CIC при постоянном входе равно R в степени порядка при дифференциальной задержке один.
self.mask = (1 << self.gain.bit_length()) - 1 # Модуль степени двойки строго больше усиления сохраняет точный результат при переполнениях интеграторов.
self.integrators = [0] * order # Нулевое начальное состояние не предполагает неизвестную предысторию захвата.
self.delays = [0] * order # Каждый гребенчатый каскад хранит свой предыдущий вход на пониженной частоте.
self.phase = 0 # Счётчик внутри окна исключает накопление дробной ошибки децимации.
self.blocks = 0 # Первые order минус один результатов относятся к установлению и не выдаются пользователю.
def feed(self, bit): # Принимается ровно один уровень DATA на выбранном фронте CLK.
if bit not in (0, 1): # Служебные значения неподключённых каналов sigrok не являются цифровыми уровнями.
raise ValueError('Sigma-delta data must be a logic level 0 or 1.') # Ошибка назначения входов не должна превращаться в правдоподобную амплитуду.
value = int(bit) ^ self.invert # Униполярное представление позволяет нормировать результат без знакового переполнения.
for stage in range(self.order): # Последовательно обновляем интеграторы на полной частоте модулятора.
self.integrators[stage] = (self.integrators[stage] + value) & self.mask # Модульная арифметика ограничивает память независимо от длительности записи.
value = self.integrators[stage] # Следующий интегратор получает уже обновлённый выход предыдущего.
self.phase += 1 # Считаем только фактически захваченные тактовые фронты.
if self.phase < self.decimation: # Между границами децимации выходной отсчёт ещё не готов.
return None # Неполные окна не дополняются вымышленными битами.
self.phase = 0 # Новый блок начинается со следующего реального бита.
for stage in range(self.order): # Гребенчатые звенья выполняются только на выходной частоте.
previous = value # Задержка сохраняет вход текущего звена, а не его разность.
value = (value - self.delays[stage]) & self.mask # Разности устраняют накопление интеграторов, включая их модульные переполнения.
self.delays[stage] = previous # Это состояние используется через R входных тактов.
self.blocks = min(self.order, self.blocks + 1) # Счётчик установления насыщается и не растёт вместе с записью.
if self.blocks < self.order: # Для sincN пропускаем первые N минус один выходных блоков с неизвестной предысторией.
return None # Частично установившийся фильтр не выдаёт ложное смещение постоянного сигнала.
code = 2 * value - self.gain # Биполярный код принимает диапазон от минус R**N до плюс R**N без привязки к конкретному АЦП.
return {'code': code, 'gain': self.gain, 'value': code / self.gain, 'density': value / self.gain} # Нормированная амплитуда и фильтрованная плотность единиц сохраняют точный масштаб CIC.
+32
View File
@@ -77,6 +77,11 @@ requires Windows 10/11; it does not add Windows 7 support.
## SET decoders
The **Sigma-delta** decoder (`sigma_delta`) reconstructs a clocked one-bit
DATA/CLK stream using sinc1/2/3 decimation. It includes sampling-edge and polarity
selection, normalized samples, voltage scaling and clock/output-rate annotations.
See [sigma-delta settings](decoders/sigma_delta/README.md).
`decoders/` contains the sigrok front ends for `gate_driver_timing` (1SP0635 /
1SD536F2), `set_uart` (SET v1/v2, ProtoCAN bridge), `set_can` (ProtoCAN, SET v2,
Balsam), and `pm35_uart` (TMS320F28335 MODBUS 03/06).
@@ -226,8 +231,35 @@ section headings. The changes are packaged in `decoder-panel.patch`.
Добавление пакетов обновляет Python-декодеры без пересборки EXE;
изменение раздела «Пользовательские» применяется при следующей нативной сборке.
## Фазные токи асинхронного двигателя
**PWM motor currents** (`pwm_motor`) оценивает линейные Ia/Ib/Ic в амперах
по ШИМ двухуровневого или трёхуровневого инвертора, полному напряжению звена
и параметрам короткозамкнутого асинхронного двигателя. Входы: три верхние
команды, шесть команд P/N или двенадцать затворов NPC. Скорость ротора
задаётся постоянной; доступны звезда/треугольник и начальные токи/поток.
[Назначение входов, параметры и ограничения](decoders/pwm_motor/README.md).
Цветные кривые и шкала ампер требуют `motor-currents.patch`, который штатный
рецепт применяет после остальных графических патчей. Адаптер и общее ядро
входят в общую ведомость установки. Проверка:
`python -m unittest discover -s tools/dsview/tests -p test_pwm_motor.py -v`.
Нативная проверка готовой DSL-записи:
`python tools/dsview/tests/check_motor_native.py --runtime <dsview> --capture <motor.dsl> --output <artifacts>`.
## PWM waveform / ШИМ → синус
Double-click an individual decoder plot to open it in a separate scope window.
The decoder label context menu offers the same action for each visible plot.
Combined A/B/C opens as one scope; split phases, theta and carrier open separately.
Wheel zoom, drag to pan, full-record fit, amplitude fit, physical Y range and
phase visibility affect only the extra window. It shares the decoder's indexed
results and follows input colours; removing the source decoder closes the scope.
The build includes `decoder-scope.patch` and `native/decoder_scope.h`.
Native interaction check (a 120000-sample DSL with the motor decoder last):
`python tools/dsview/tests/check_decoder_scope_native.py --runtime <dsview> --capture <example.dsl> --output <artifacts>`.
Декодер восстанавливает кривые заполнения для 1–3 цифровых входов.
В SETGUI он находится в меню **Анализаторы SET → ШИМ → синус · 1–3 канала**.
[Настройки, режимы и ограничения](decoders/pwm_waveform/README.md).
+20
View File
@@ -31,6 +31,9 @@ def recipe_digest():
recipe = Path(__file__).resolve().parent
inputs = [recipe / 'build.py', recipe / 'protocol-files.json', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h', recipe / 'pwm-waveform.patch', recipe / 'native/pwm_waveform.h'] # Ведомость и нативные патчи входят в версию рецепта.
inputs += [source for source, _ in protocol_payload()] # Изменение парсера, адаптера или JSON-карты делает установленный компонент устаревшим.
inputs += [recipe / 'zynq-ethernet/ethernet.patch'] # Геометрия цифровых дорожек и анализаторов входит в Ethernet-патч, поэтому его изменение также требует пересборки runtime.
inputs += [recipe / 'scope-channel.patch', recipe / 'native/scope_demo.h', recipe / 'native/scope_trace.h', recipe / 'motor-currents.patch'] # Шкала фазных токов и поддержка нового графического декодера входят в версию нативного компонента.
inputs += [recipe / 'decoder-scope.patch', recipe / 'native/decoder_scope.h'] # Отдельные окна выбранных осциллограмм входят в версию рецепта и требуют обновления нативного компонента.
digest = hashlib.sha256()
for item in inputs:
digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0')
@@ -85,6 +88,9 @@ def main():
('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch', 'pwm-waveform.patch')]
if args.extra_patch:
patches.append(args.extra_patch.resolve())
patches.append(Path(__file__).with_name('scope-channel.patch')) # Аналоговые дорожки подключаются после настройки цифрового интерфейса, включая Ethernet-патч.
patches.append(Path(__file__).with_name('motor-currents.patch')) # Токовые кривые расширяют готовую компоновку PWM после базовых и Ethernet-изменений.
patches.append(Path(__file__).with_name('decoder-scope.patch')) # Независимые окна подключаются после окончательной компоновки графических строк и фазных токов.
# Later patches may change the context of earlier ones. Check and apply
# their combined diff without touching the checkout's real index.
with tempfile.TemporaryDirectory() as temp:
@@ -98,6 +104,9 @@ def main():
capture_output=True).returncode:
subprocess.run(patch_cmd, cwd=source, input=combined, check=True)
shutil.copy2(Path(__file__).with_name('native') / 'pwm_waveform.h', source / 'DSView/pv/view/pwm_waveform.h')
for name in ('scope_demo.h', 'scope_trace.h'): # Переносимая модель и Qt-дорожка устанавливаются рядом с базовым Trace.
shutil.copy2(Path(__file__).with_name('native') / name, source / 'DSView/pv/view' / name) # Копируем канонические исходники без изменения внешнего проекта Zynq.
shutil.copy2(Path(__file__).with_name('native') / 'decoder_scope.h', source / 'DSView/pv/view/decoder_scope.h') # Виджет отдельного окна поставляется как обычный C++11-заголовок без отдельного процесса и генерации moc.
env = dict(os.environ)
env['PATH'] = os.pathsep.join([str(prefix/'bin'), str(args.msys_root/'usr/bin'), env.get('PATH', '')])
env['PKG_CONFIG_PATH'] = str(prefix/'lib/pkgconfig')
@@ -150,7 +159,18 @@ def main():
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch', 'pwm-waveform.patch'):
shutil.copy2(Path(__file__).with_name(patch_name), output/patch_name)
shutil.copy2(Path(__file__).with_name('native') / 'pwm_waveform.h', output / 'pwm_waveform.h')
for name in ('scope_demo.h', 'scope_trace.h'): # Вместе с GPL-компонентом поставляется исходник новой демонстрационной дорожки.
shutil.copy2(Path(__file__).with_name('native') / name, output / name) # Содержимое пакета совпадает с реально скомпилированными заголовками.
shutil.copy2(Path(__file__).with_name('scope-channel.patch'), output / 'scope-channel.patch') # Патч меню и компоновки позволяет воспроизвести расширение на закреплённом upstream.
shutil.copy2(Path(__file__).with_name('motor-currents.patch'), output / 'motor-currents.patch') # Исходники расширения токовых графиков поставляются вместе с GPL-компонентом.
for name in ('decoder-scope.patch', 'native/decoder_scope.h'): # Исходники самостоятельного осциллографа нужны для воспроизводимости поставляемого GPL-компонента.
shutil.copy2(Path(__file__).parent / name, output / Path(name).name) # Патч интеграции и заголовок виджета соответствуют скомпилированному расширению.
(output / 'scope-channel-1').write_text('DSView Zynq demo scope channels 1\n', encoding='utf-8') # Маркер различает установленный компонент с добавлением осциллографических дорожек.
(output/'SOURCE.txt').write_text('https://github.com/DreamSourceLab/DSView\nCommit: '+revision+'\nPatches: setgui-host.patch, decoder-panel.patch, windows-usb-events.patch, pwm-waveform.patch (pwm_waveform.h)\nLicense: GPL-3.0-or-later (COPYING)\n', encoding='utf-8')
with (output / 'SOURCE.txt').open('a', encoding='utf-8') as manifest: # Происхождение нового источника сохраняется в пользовательском runtime.
manifest.write('Scope: scope-channel.patch, scope_demo.h, scope_trace.h\nModel: zynq/demo/hdmi_usb_menu/scope_signal.h (scope_hz, scope_peak_v, scope_colors, scope_value_v, scope_interval_extrema)\n') # Указываем исходные параметры и функции для проверки соответствия проекту Zynq.
manifest.write('Motor currents: motor-currents.patch; decoders/pwm_motor; common/setgui_decoders/pwm_motor.py\n') # Поставляемые исходники связывают графики ампер с единственной переносимой моделью двигателя.
manifest.write('Detached decoder scopes: decoder-scope.patch; decoder_scope.h\n') # Манифест фиксирует отдельные окна конкретных графических областей декодеров.
(output/'host-build.json').write_text(json.dumps({
'recipe': recipe_digest(),
'executable': hashlib.sha256((output/'DSView.exe').read_bytes()).hexdigest(),
File diff suppressed because one or more lines are too long
+138
View File
@@ -0,0 +1,138 @@
# ШИМ 2/3 уровня → токи асинхронного двигателя
В SETGUI: **DSView → Анализаторы SET → ШИМ 2/3 уровня → токи асинхронного двигателя**.
В списке декодеров DSView: **Пользовательские → PWM motor currents**.
На общей временной шкале отображаются расчётные линейные **Ia, Ib, Ic в амперах**:
A — голубой, B — жёлтый, C — зелёный. `Waveform layout` переключает
совмещённый (`combined`) и раздельный (`split`) вид.
Это модельная оценка. Напряжения звена и ШИМ недостаточно, чтобы однозначно
определить реальный ток: нужны параметры двигателя, скорость и начальное
электромагнитное состояние. Значения по умолчанию — демонстрационный пример,
они не являются параметрами подключённой машины.
## Входы и уровни
Обязательные `A/B/C P / Q1` — верхние команды трёх фаз.
Режим выбирается вручную в `Inverter / Уровни и кодировка`.
| Режим | Назначенные входы | Соответствие напряжению полюса |
|---|---|---|
| `2-level` | 3 верхние команды | 1 → +Uверх, 0 → −Uниз; идеальная комплементарная пара |
| `2-level` с нижними ключами | 3 верхние и 3 нижние команды | 10 → +Uверх, 01 → −Uниз; 00 → приближение обратных диодов |
| `3-level P/N` | 3 команды P и 3 команды N | 10 → +Uверх, 00 → средняя точка, 01 → −Uниз; 11 недопустимо |
| `3-level NPC` | 12 затворов: Q1, Q2, Q3, Q4 каждой фазы | 1100 → +Uверх, 0110 → средняя точка, 0011 → −Uниз |
В NPC физический порядок Q1…Q4 идёт сверху вниз по плечу. В диалоге сначала
расположены Q1 всех фаз, затем Q4 всех фаз, затем Q2/Q3 каждой фазы.
Назначать входы нужно по их именам, а не по порядку физических каналов прибора.
Нижние команды двухуровневой схемы назначаются либо все три, либо ни одной.
Для NPC необходимы все двенадцать входов. `Gate active level` инвертирует
все назначенные входы одновременно.
`Total DC link` — полное напряжение между положительной и отрицательной
шинами, **не напряжение одного конденсатора**. При Udc = 600 В и `Upper DC
link = 50%` уровни равны +300, 0 и −300 В. При 60% уровни +360, 0 и −240 В.
Напряжение считается постоянным на всей записи; пульсации звена по отдельному
аналоговому каналу в этой версии не измеряются.
Токи интегрируются по фактическим длительностям состояний ключей. Одинаковые
потенциалы всех фаз не создают ток плавающей звезды. Заполнение ШИМ не
подменяется искусственным синусом и не сглаживается перед расчётом.
Постоянные команды, включая 0/100%, также обрабатываются.
## Настройка двигателя
| Параметр | Смысл |
|---|---|
| `Winding` | `star` — звезда без нейтрального провода; `delta` — симметричный треугольник |
| `Stator R` | Сопротивление **одной обмотки** статора, Ом |
| `Rotor R referred` | Сопротивление ротора, приведённое к одной обмотке статора, Ом |
| `Stator leakage` | Индуктивность рассеяния статора, мГн |
| `Rotor leakage referred` | Индуктивность рассеяния ротора, приведённая к статору, мГн |
| `Magnetizing` | Взаимная индуктивность намагничивания, мГн |
| `Pole pairs` | Целое число пар полюсов, не число полюсов |
| `Fixed rotor speed` | Механическая скорость ротора, об/мин; отрицательное значение означает обратное вращение |
| `Initial rotor flux linkage` | Начальное потокосцепление ротора, Вб·виток = В·с |
| `Initial flux angle` | Начальный угол потока относительно оси A, градусы |
| `Initial line Ia/Ib` | Начальные линейные токи; Ic = −Ia−Ib, А |
| `Plot +/- range` | Полушкала графика, А; не ограничивает физический расчёт |
| `Plot sampling` | Частота дополнительных точек графика, Гц; каждый фактический фронт также создаёт точку |
Параметры обмотки и параметры эквивалентной звезды нельзя смешивать.
Для `delta` задайте параметры фактической обмотки треугольника; декодер
сам преобразует токи ветвей в линейные токи проводов инвертора.
Ток нулевой последовательности, циркулирующий внутри треугольника,
в симметричной модели не учитывается.
При поле 50 Гц и двух парах полюсов синхронная скорость равна 1500 об/мин.
Например, 1440 об/мин задаёт скольжение 4%. `speed = 0` описывает включение
с неподвижным ротором и нарастание потока. **Скорость остаётся постоянной**:
механический разгон, момент нагрузки и инерция в этой версии не рассчитываются.
Поэтому для участка разгона нужна отдельная оценка скорости; одна постоянная
настройка не воспроизводит весь механический переходный процесс.
Нулевые начальные токи и поток соответствуют невозбуждённому двигателю.
Если запись начинается в рабочем режиме, начальный участок содержит
переходный процесс модели: задайте известные начальные условия или
анализируйте установившуюся часть достаточно длинной записи.
## Уравнения и точность
Используется линейная αβ-модель короткозамкнутого асинхронного двигателя
в неподвижных осях статора. Для комплексных векторов тока `is` и
потокосцепления ротора `ψr`:
```text
Lr = Llr + Lm
k = Lm / Lr
Lσ = Lls + Lm·Llr/Lr
ωr = 2π · pole_pairs · speed_rpm / 60
dψr/dt = Rr·k·is − (Rr/Lr − jωr)·ψr
dis/dt = (vs − Rs·is − k·dψr/dt) / Lσ
```
Между фронтами используется аналитическая матричная экспонента для
постоянного напряжения. Изменение частоты точек графика не меняет решение
на неизменном участке. Чем выше частота точек, тем точнее криволинейный
переход передаётся отрезками на экране. Интервалы изменения проводимости
обратных диодов определяются приближённо на этой же сетке.
Физическая основа модели и обозначения параметров:
[MathWorks: Induction Motor](https://www.mathworks.com/help/mcb/ref/inductionmotor.html),
[MathWorks: Induction Machine Squirrel Cage](https://www.mathworks.com/help/simscape-electrical/ref/inductionmachinesquirrelcage.html).
Не моделируются магнитное насыщение, потери в стали, температурное изменение
сопротивлений, скин-эффект ротора, падения напряжения на силовых ключах,
паразитные ёмкости и высокочастотные выбросы. Без параметров конкретного
двигателя и проверки по датчику тока нельзя заявить погрешность относительно
реального агрегата.
Строка `Model / gate warnings` помечает токи как расчётные. При недопустимой
комбинации затворов используется последний определённый потенциал
соответствующей фазы и выдаётся предупреждение: оценка тока на этом участке
недостоверна. Интервалы полностью закрытого плеча отмечаются как приближение
диодной проводимости. Частичные состояния NPC в dead time также помечаются
как неоднозначные, их нельзя принимать за достоверную модель коммутации.
При выходе за `Plot +/- range` обрезается только изображение, появляется
предупреждение; реальные амперы сохраняются в таблице и в Python-выходе
`CURRENTS`. Увеличьте шкалу для просмотра полного размаха.
## Сборка и проверка
Ядро: `python/logic_analyzer/decoders/pwm_motor.py`. Адаптер и ядро входят
в общую ведомость `protocol-files.json`, доступны штатному установщику.
В обычном DSView выводятся числовые аннотации. Для цветных кривых и шкалы
в амперах требуется пересборка нативного DSView штатным рецептом с
`pwm-waveform.patch` и `motor-currents.patch`.
```powershell
python -m unittest discover -s tools/dsview/tests -p test_pwm_motor.py -v
```
Независимый эталон — комплексное сопротивление Т-схемы замещения:
проверяются амплитуда и фаза на неподвижном роторе, рабочем скольжении,
синхронной скорости, рекуперации и реверсе с допуском 0,2% относительно
идеализированной схемы. Этот допуск относится к математической проверке,
а не к точности восстановления реального тока.
@@ -0,0 +1 @@
from .pd import Decoder # Стандартная точка входа sigrok регистрирует декодер асинхронного двигателя.
+108
View File
@@ -0,0 +1,108 @@
"""Фазные токи асинхронного двигателя из двух- или трёхуровневого ШИМ.""" # Аннотации работают и без расширенного графического рендерера.
import math # Масштаб графика и частота выходных точек проверяются до начала декодирования.
import sigrokdecode as srd # Штатный API DSView предоставляет цифровые входы и выходные аннотации.
from common.setgui_decoders.pwm_motor import PwmMotor # Единственная реализация модели поставляется из переносимого ядра templates.
class Decoder(srd.Decoder): # Адаптер только связывает события sigrok с общей физической моделью.
api_version = 3 # DSView использует третью версию API ожидания фронтов.
id = 'pwm_motor' # Постоянный идентификатор связывает меню, сохранённые DSL и нативный рендерер.
name = 'PWM motor currents' # Короткое имя различает расчётные токи и существующую огибающую заполнения.
longname = 'PWM 2/3-level / induction motor phase currents' # Полное имя доступно в поиске анализаторов DSView.
desc = 'Estimated induction motor line currents in amperes from switching states; motor parameters required.' # Подсказка явно сообщает расчётный характер токов.
license = 'gplv2+' # Лицензия совместима с остальными переносимыми декодерами поставки.
inputs = ['logic'] # Декодер получает реальные логические затворные сигналы без базового UART или CAN.
outputs = ['pwm_motor'] # Структурированный выход позволяет повторно использовать токи в стеке анализаторов.
tags = ['Clock/timing', 'Util'] # Стандартные категории сохраняют совместимость со штатным поиском DSView.
channels = tuple({'id': ch + '_p', 'name': ch.upper() + ' P / Q1', 'desc': 'Upper gate or positive-level command'} for ch in 'abc') # Верхние команды всех трёх фаз обязательны для восстановления линейных напряжений.
optional_channels = tuple({'id': ch + '_n', 'name': ch.upper() + ' N / Q4', 'desc': 'Lower gate; required for 3-level modes'} for ch in 'abc') + tuple({'id': ch + '_q' + str(q), 'name': ch.upper() + ' Q' + str(q), 'desc': 'NPC inner gate; required in 3-level NPC'} for ch in 'abc' for q in (2, 3)) # Дополнительные входы поддерживают шесть команд P/N и двенадцать затворов NPC.
options = ( # Физические параметры задаются для одной обмотки; роторные величины приведены к статору.
{'id': 'topology', 'desc': 'Inverter / Уровни и кодировка', 'default': '2-level', 'values': ('2-level', '3-level P/N', '3-level NPC')}, # Режим определяет таблицу преобразования логических входов в полюсные напряжения.
{'id': 'dc_voltage', 'desc': 'Total DC link / Полное Udc (V)', 'default': 560.0}, # Пример 560 В относится ко всему звену между шинами плюс и минус.
{'id': 'midpoint_percent', 'desc': 'Upper DC link / Верхняя половина (%)', 'default': 50.0}, # 50 процентов соответствует двум равным конденсаторам трёхуровневого звена.
{'id': 'connection', 'desc': 'Winding / Соединение обмоток', 'default': 'star', 'values': ('star', 'delta')}, # На графике в обоих случаях выводятся линейные токи проводов A, B, C.
{'id': 'active_level', 'desc': 'Gate active level / Активный уровень', 'default': 'high', 'values': ('high', 'low')}, # Общая полярность применяется ко всем назначенным затворам.
{'id': 'rs', 'desc': 'Stator R / R статора (ohm)', 'default': 1.0}, # Пример сопротивления одной обмотки статора в омах необходимо заменить параметром двигателя.
{'id': 'rr', 'desc': 'Rotor R referred / R ротора приведённое (ohm)', 'default': 0.8}, # Сопротивление ротора задаётся приведённым к одной обмотке статора.
{'id': 'lls_mh', 'desc': 'Stator leakage / L рассеяния статора (mH)', 'default': 5.0}, # Индуктивность рассеяния отличается от полной индуктивности обмотки.
{'id': 'llr_mh', 'desc': 'Rotor leakage referred / L рассеяния ротора (mH)', 'default': 5.0}, # Значение ротора также должно быть приведено к статору.
{'id': 'lm_mh', 'desc': 'Magnetizing / L намагничивания (mH)', 'default': 150.0}, # Взаимная индуктивность задаёт магнитный поток линейной ненасыщенной модели.
{'id': 'pole_pairs', 'desc': 'Pole pairs / Число пар полюсов', 'default': 2}, # Две пары полюсов дают синхронную скорость 1500 об/мин при поле 50 Гц.
{'id': 'speed_rpm', 'desc': 'Fixed rotor speed / Скорость ротора (rpm)', 'default': 1440.0}, # Постоянная механическая скорость задаёт скольжение; ноль моделирует неподвижный ротор.
{'id': 'initial_flux', 'desc': 'Initial rotor flux linkage / Начальный поток (Wb)', 'default': 0.0}, # Нулевое начальное потокосцепление соответствует невозбуждённой машине.
{'id': 'flux_angle', 'desc': 'Initial flux angle / Угол потока (deg)', 'default': 0.0}, # Угол начального вектора отсчитывается от оси фазы A к оси β.
{'id': 'ia', 'desc': 'Initial line Ia / Начальный Ia (A)', 'default': 0.0}, # Ток A задаёт одно из двух независимых начальных состояний трёхпроводной системы.
{'id': 'ib', 'desc': 'Initial line Ib / Начальный Ib (A)', 'default': 0.0}, # Третий ток автоматически равен минус сумме Ia и Ib.
{'id': 'plot_range_a', 'desc': 'Plot +/- range / Шкала графика +/- (A)', 'default': 100.0}, # Масштаб меняет только отрисовку, а не расчёт или физическую амплитуду.
{'id': 'output_hz', 'desc': 'Plot sampling / Точек графика в секунду (Hz)', 'default': 100000}, # Сетка 100 кГц дополняет обязательные точки каждого фронта и работает при постоянных уровнях.
{'id': 'waveform_layout', 'desc': 'Waveform layout / Вид графиков', 'default': 'combined', 'values': ('combined', 'split')}, # Три фазных тока можно совместить или разнести по отдельным областям.
) # Значения сохраняются штатным механизмом настроек и DSL-сессий.
annotations = (('current-a', 'Estimated Ia (A)'), ('current-b', 'Estimated Ib (A)'), ('current-c', 'Estimated Ic (A)'), ('warnings', 'Model / gate warnings')) # Первые три класса используют контракт сегментов графического рендерера.
annotation_rows = (('wave_a', 'Ia estimate (A)', (0,)), ('wave_b', 'Ib estimate (A)', (1,)), ('wave_c', 'Ic estimate (A)', (2,)), ('warnings', 'Model / gate warnings', (3,))) # Идентификаторы wave_a…wave_c поддерживают прежние цвета и индекс экстремумов.
def __init__(self): # Конструктор не обращается к частоте захвата до получения metadata.
self.reset() # Каждый экземпляр анализатора начинает с независимого состояния двигателя.
def reset(self): # Повторное декодирование не должно использовать токи или поток предыдущей записи.
self.samplerate, self.model, self.warning_start, self.warning_text = None, None, None, None # Диагностика и физическое состояние сбрасываются совместно.
self.clipped = False # Превышение шкалы прежней записи не переносится в новый расчёт.
def metadata(self, key, value): # Движок сообщает фактическую частоту цифрового захвата.
if key == srd.SRD_CONF_SAMPLERATE: # Остальные метаданные не участвуют в переводе отсчётов в секунды.
self.samplerate = value # Частота применяется при создании модели после валидации настроек.
def start(self): # Выходные дескрипторы создаются штатным механизмом sigrok.
self.out_ann = self.register(srd.OUTPUT_ANN) # Аннотации доступны в таблице результатов и на графиках.
self.out_python = self.register(srd.OUTPUT_PYTHON) # Структурированные данные содержат ненормированные амперы.
def _emit(self, event): # Представление сегмента не изменяет физических токов модели.
if event is None: # Первый отсчёт или повторная временная отметка не образуют интервала.
return # Нулевая длительность не создаёт ложную линию на графике.
start, end, before, after = event # Время хранится в отсчётах, оба вектора тока заданы в амперах.
clipped = max(abs(value) for value in before + after) > self.scale # Превышение пользовательской шкалы должно быть видно в диагностике.
self.clipped = clipped # Состояние масштаба используется общей диагностической строкой текущего интервала.
for phase in range(3): # Каждый линейный провод получает отдельный цветной сегмент и физическую подпись.
label = 'Estimated I{}={:+.6f} A{}'.format('ABC'[phase], after[phase], ' [outside plot range]' if clipped else '') # Таблица сохраняет реальную амплитуду даже при выходе кривой за шкалу.
self.put(start, end, self.out_ann, [phase, [label, 'PWM1 {:.12g} {:.12g}'.format(max(-1, min(1, before[phase] / self.scale)), max(-1, min(1, after[phase] / self.scale)))]]) # За границей шкалы ограничивается только геометрия; физические амперы сохраняются в подписи и структурированном выходе.
self.put(start, end, self.out_python, ['CURRENTS', {'ia': after[0], 'ib': after[1], 'ic': after[2], 'unit': 'A', 'estimated': True, 'warnings': self.model.interval_warnings, 'outside_plot_range': clipped}]) # Диагностика относится к завершённому электрическому интервалу, а не к следующей команде затворов.
def _warning(self, sample, final=False): # Одинаковая диагностика объединяется в один интервал вместо повторения на каждой точке графика.
text = 'Estimated induction motor currents; fixed speed; motor parameters and initial state required.' # Постоянная пометка предотвращает восприятие оценки как измеренных токов.
if self.model.warnings: # Невозможные команды ключей и модель диодов дополняют общую оговорку о расчёте.
text += ' ' + '; '.join(self.model.warnings) # В подпись включаются конкретные фазы и состояния затворов.
if getattr(self, 'clipped', False): # Ограниченная шкалой геометрия должна сопровождаться заметным предупреждением.
text += ' Current exceeds plot range; increase +/- A.' # Пользователь может расширить шкалу без изменения физической модели двигателя.
if self.warning_text is not None and (final or text != self.warning_text) and sample > self.warning_start: # Завершённый интервал выводится при смене условий или окончании записи.
self.put(self.warning_start, sample, self.out_ann, [3, [self.warning_text]]) # Диагностика видна на соответствующем по времени участке захвата.
if final or text != self.warning_text: # Начало нового интервала совпадает с изменением модельной достоверности.
self.warning_start, self.warning_text = sample, text # Повторяющийся текст не раздувает число аннотаций.
def decode(self): # Ожидание фронтов дополняется сеткой, поэтому постоянный ШИМ 0/100 процентов также рассчитывается.
if not self.samplerate: # Без частоты нельзя перевести длительность затворных команд в физическое время.
raise ValueError('Cannot estimate motor current without samplerate.') # Расчёт не подменяет неизвестную частоту условной константой.
self.scale, output = float(self.options['plot_range_a']), float(self.options['output_hz']) # Шкала в амперах и частота точек задаются независимо от параметров двигателя.
if not math.isfinite(self.scale) or self.scale <= 0 or not math.isfinite(output) or output <= 0: # Нулевой масштаб и неконечная сетка не допускаются.
raise ValueError('Plot range and plot sample rate must be finite and positive.') # Исправление настройки не требует изменения записи.
if self.options['active_level'] not in ('high', 'low') or self.options['waveform_layout'] not in ('combined', 'split'): # Повреждённые сохранённые опции не интерпретируются произвольно.
raise ValueError('Invalid gate polarity or waveform layout.') # Явная ошибка сохраняет смысл полярности сигналов.
keys = ('topology', 'dc_voltage', 'midpoint_percent', 'connection', 'rs', 'rr', 'lls_mh', 'llr_mh', 'lm_mh', 'pole_pairs', 'speed_rpm', 'initial_flux', 'flux_angle', 'ia', 'ib') # В ядро передаются только физические параметры модели.
self.model = PwmMotor(self.samplerate, active_high=self.options['active_level'] == 'high', **{key: self.options[key] for key in keys}) # Проверка схемы и единиц выполняется единственной общей реализацией.
present = tuple(self.has_channel(index) for index in range(12)) # Неназначенные необязательные входы не считаются логическим нулём.
pins = list(self.wait({})) # Копирование освобождает внутренний tuple DSView перед следующим ожиданием.
self.model.update(self.samplenum, tuple(pins[index] if present[index] else None for index in range(12))) # Первый набор уровней определяет начальные состояния ключей.
self._warning(self.samplenum) # Отметка расчётного характера начинается с первого фактического отсчёта записи.
step = max(1, round(self.samplerate / output)) # Шаг в целых отсчётах ограничивается разрешением исходного захвата.
boundary = self.samplenum + step # Равномерная сетка привязана к началу выбранной области декодирования.
edges = [{index: 'e'} for index in range(12) if present[index]] # Одновременные фронты считываются одним набором уровней.
while True: # Завершение захвата штатно останавливает wait без достраивания неизвестного сигнала.
pins = list(self.wait(edges + [{'skip': max(1, boundary - self.samplenum)}])) # События ключей обрабатываются даже между соседними точками сетки.
self._emit(self.model.update(self.samplenum, tuple(pins[index] if present[index] else None for index in range(12)))) # Старое напряжение интегрируется до фронта, новая команда действует после него.
self._warning(self.samplenum) # Изменившиеся ошибки затворов получают собственный временной интервал.
if self.samplenum >= boundary: # Фронт точно на сетке не должен создавать второй сегмент нулевой длины.
boundary += ((self.samplenum - boundary) // step + 1) * step # Сетка остаётся равномерной независимо от количества переключений между её узлами.
def end(self): # Последний доступный отсчёт завершает только известную часть записи.
if self.model is not None and self.model.sample is not None: # Ошибка настроек до первого отсчёта не оставляет пригодного физического состояния.
last = max(self.samplenum, getattr(self, 'last_samplenum', self.samplenum)) # DSView передаёт фактический конец через last_samplenum, отдельно от последнего сработавшего wait.
self._emit(self.model.update(last)) # Завершается только известный хвост записи до её фактической правой границы.
self._warning(last, final=True) # Последний диагностический интервал сохраняется даже при неизменных ключах.
+96 -3
View File
@@ -6,6 +6,72 @@
цветную кривую с общей временной шкалой записи и строку заполнения.
Для большего числа входов добавьте ещё один экземпляр декодера.
Всё перечисленное ниже — опции **этого же анализатора PWM waveform**:
- **PWM topology / Схема ШИМ**: `two-level` — один вход на фазу;
`npc-three-level` — классический NPC, четыре затвора S1…S4 сверху вниз.
Прежние входы PWM A/B/C соответствуют S1; дополнительные A2/A3/A4,
B2/B3/B4, C2/C3/C4 — остальным затворам. Для трёх фаз нужны 12 входов.
Каждая подключённая фаза требует все четыре сигнала.
- **Waveform / Форма сигнала**: `envelope` показывает средний уровень,
`switching` — приближённое коммутационное напряжение по состояниям
затворов. Для двух уровней шкала −1…+1; для NPC — −1, 0, +1
(соответственно −Udc/2, нейтраль, +Udc/2). Это относительные уровни:
напряжение шины, аналоговые выбросы и скорость переключения не измеряются.
- **Theta ABC 0-360 deg / Угол вектора**: отдельный график θ по всем трём
огибающим. Они интерполируются к общему времени; преобразование Кларка
удаляет синфазную составляющую, затем вычисляется atan2(β, α).
0° соответствует положительному направлению оси A. При амплитуде вектора
менее 0,02 о.е. угол не определяется; переход 360→0 рисуется разрывом.
Это угол восстановленного вектора напряжения, не измерение угла ротора
или магнитного потока двигателя.
- **Calculated triangle / Расчётная несущая**: отдельная строка одного
треугольника −1…+1 либо двух синфазных смещённых треугольников NPC
(−1…0 и 0…+1). Период задаётся окном или оценивается по одноимённым
фронтам первой подключённой фазы. Начальная фаза условна: внутренний
счётчик модулятора и его реальную несущую цифровой захват не измеряет.
- **Phase angles AB BC CA / Углы между фазами**: измеренные сдвиги
A→B, B→C, C→A в диапазоне −180…+180°. Положительный знак означает
запаздывание второй фазы. Используются одноимённые пересечения огибающих
и последний полный период каждой фазы; старые данные через два периода
без обновления не выводятся. При разгоне это локальная оценка сдвига.
Для двух подключённых фаз выводится только доступная пара.
NPC: `1100 → +1`, `0110 → 0`, `0011 → −1`. Остальные состояния,
включая все выключенные ключи во время dead-time, имеют неизвестное
напряжение и отмечаются отдельной строкой. В коммутационном виде это
разрывы. Огибающая оценивается по известной части периода с явной
подписью `valid`; при покрытии менее 95% период пропускается и история
поля сбрасывается. Поэтому короткие паузы дают приближённую оценку,
а длительные нарушения не маскируются нейтралью. Для устойчивого
усреднения, особенно при насыщении, укажите известную **Carrier Hz**;
автоматический режим использует начала ненулевых импульсов одинакового
знака и заново синхронизируется при смене полуволны.
Таблица NPC: [Output Voltage and Switching States](https://e2e.ti.com/cfs-file/__key/communityserver-discussions-components-files/38/Three_2D00_Level-Converters_2D00_1.pdf).
Преобразование координат: [Microchip Clarke Transformation](https://onlinedocs.microchip.com/oxy/GUID-0A4BC4EE-29F5-4736-8125-17139B84E7B5-en-US-2/GUID-92ACB0C5-9954-44B5-8583-47F0E6960A32.html).
В настройках **Decoders → PWM waveform → Waveform layout / Вид графиков**:
- `split` — отдельные области A/B/C с общей временной шкалой (по умолчанию).
- `combined` — все видимые кривые в одной области с общей шкалой −1…+1.
Цвета сохраняются в обоих режимах: A — голубой, B — жёлтый, C — зелёный.
Неподключённые и скрытые каналы не занимают место в общем графике.
Выбранный режим сохраняется вместе с настройками декодера в записи DSL.
Высота текстовых аннотаций ограничена высотой шрифта плюс 2 экранных пикселя
(по 1 сверху и снизу), включая увеличение высоты дорожки анализатора.
Строка **PWM carrier / field** каждого канала показывает измеренную среднюю
несущую ШИМ и электрическую частоту поля за полный период огибающей, в Гц.
Она появляется после двух восходящих пересечений уровня заполнения 50%.
Время пересечения интерполируется между точками кривой; гистерезис ±1
процентный пункт заполнения подавляет дрожание около 50%. При разгоне
частота обновляется каждый электрический период. Фильтр Low-pass влияет
также на измеряемую огибающую. Для постоянного заполнения, неполного
периода и огибающей, не пересекающей полосу 49–51%, частота поля не выводится.
Это электрическая частота модуляции, а не механическая частота вращения.
Кривая — измеренное заполнение `2 * Ton / T - 1`, а не искусственный синус.
50% соответствует нулю, 0% — −1, 100% — +1. Значения между серединами
периодов соединены прямыми. Это нормированная модуляция, не измерение
@@ -20,8 +86,11 @@
- **Carrier Hz > 0**: интегрирование заполнения в равных временных окнах
длиной `1/f`, начиная с начала области декодирования. Работает на 0/100%.
Окна не синхронизированы с фронтами; при неверной несущей или быстро
меняющейся модуляции возможны биения/искажения. В таблице указана частота
окна, а не измеренная частота ШИМ. Неполное последнее окно пропускается.
меняющейся модуляции возможны биения/искажения. В строке заполнения
`window` обозначает частоту окна, а `PWM carrier` — частоту между
фактическими активными фронтами. Пока не получены два фронта или после
отсутствия фронтов дольше двух последних измеренных периодов несущая
помечается `unavailable`. Неполное последнее окно пропускается.
- **Low-pass time constant (ms)**: 0 отключает фильтр; положительное значение
включает фильтр первого порядка. Он подавляет пульсации, но вносит задержку
и ослабление. Для начала используйте 0.
@@ -35,7 +104,23 @@
`native/pwm_waveform.h`** (штатный `tools/dsview/build.py` включает оба).
В старом/стандартном DSView будут только числовые аннотации.
Проверки: `python -m unittest discover -s tools/dsview/tests -p test_pwm_waveform.py`.
Проверки: `python -m unittest discover -s tools/dsview/tests -p "test_pwm*.py"`.
Опции NPC в реальном DSView проверяет
`python tools/dsview/tests/check_pwm_npc_native.py --runtime C:/path/to/dsview --output C:/path/to/npc-check`.
Добавьте `--mode envelope` или `--layout split` для второго вида основной
кривой либо раздельных фаз. Сценарий не использует оборудование.
Длинные записи отображаются через индекс минимумов и максимумов: обзорная
перерисовка выдаёт не более двух отрезков на пиксель независимо от количества
периодов. Одиночные выбросы сохраняются; при увеличении масштаба возвращаются
исходные сегменты. Полные числовые аннотации остаются в таблице и экспорте.
Индекс очищается вместе с записью при повторном анализе.
Проверка нативного индекса и миллиона периодов (Windows, MSYS2 UCRT64 с Qt 5):
`python -m unittest discover -s tools/dsview/tests -p test_pwm_envelope.py`.
Тест сравнивает экстремумы с полным перебором, проверяет увеличение масштаба,
разрывы, повторное заполнение и ограниченное число экранных отрезков.
Нативная проверка (Python с PySide6, Windows):
@@ -46,3 +131,11 @@ python tools/dsview/tests/check_pwm_native.py --runtime C:/path/to/dsview --outp
Она создаёт синтетическую трёхфазную запись, открывает её в отдельном DSView,
сохраняет `pwm-native.png` и закрывает процесс. На изображении должны быть
три синусоиды со сдвигом 120°. Аппаратный захват не запускается.
По умолчанию проверяется `split`; добавьте `--layout combined`, чтобы проверить
наложение трёх фаз. Параметр `--max-height 4X` увеличивает высоту дорожек:
кривые растут, а текстовые блоки должны оставаться высотой шрифта плюс 2 пикселя.
Для проверки длинного захвата добавьте `--seconds 10 --carrier-hz 20000
--timeout 60`. Тест ждёт подтверждения полного декодирования, проверяет
ответы потока интерфейса на WM_NULL с тайм-аутом в одну секунду и сохраняет
итоговый экран в `pwm-complete.png`.
+113 -13
View File
@@ -1,6 +1,13 @@
"""PWM envelope on up to three independent logic channels."""
import sigrokdecode as srd
from common.setgui_decoders.pwm_waveform import PwmChannel
from common.setgui_decoders.pwm_waveform import PwmChannel, NpcChannel, PhaseAnalysis # Общие ядра восстанавливают два уровня, стойку NPC и согласованный трёхфазный вектор без зависимости от интерфейса.
def short_frequency(value): # Компактная подпись сохраняет обе частоты даже при небольшой ширине электрического периода на экране.
if value is None: # Отсутствующее измерение не должно выглядеть как нулевая частота.
return '?' # Короткий знак оставляет место второму, доступному измерению.
scale, unit = (1000000, 'MHz') if value >= 1000000 else (1000, 'kHz') if value >= 1000 else (1, 'Hz') # Приставки СИ переводят герцы в кило- и мегагерцы без изменения физического значения.
return '{:.3g}{}'.format(value / scale, unit) # Три значащие цифры достаточны для обзорной подписи; полная точность остаётся в первой строке аннотации.
class Decoder(srd.Decoder):
@@ -17,17 +24,27 @@ class Decoder(srd.Decoder):
optional_channels = (
{'id': 'b', 'name': 'PWM B', 'desc': 'Second PWM input'},
{'id': 'c', 'name': 'PWM C', 'desc': 'Third PWM input'},
*tuple({'id': ch + str(gate), 'name': ch.upper() + str(gate), 'desc': 'NPC ' + ch.upper() + ' S' + str(gate) + ' (top to bottom)'} for ch in 'abc' for gate in (2, 3, 4)), # Прежние A/B/C становятся S1 фаз; дополнительные девять входов завершают 12 затворов NPC, сохраняя совместимость записей DSL.
)
options = tuple(
{'id': 'active_' + ch, 'desc': 'PWM ' + ch.upper() + ' active level',
'default': 'high', 'values': ('high', 'low')} for ch in 'abc'
) + (
{'id': 'input_mode', 'desc': 'PWM topology / Схема ШИМ', 'default': 'two-level', 'values': ('two-level', 'npc-three-level')}, # NPC использует четыре затвора на фазу, обычный режим сохраняет один вход на фазу.
{'id': 'waveform_mode', 'desc': 'Waveform / Форма сигнала', 'default': 'envelope', 'values': ('envelope', 'switching')}, # Envelope выводит средние уровни, switching — оценку коммутационного напряжения непосредственно по состояниям затворов.
{'id': 'show_theta', 'desc': 'Theta ABC 0-360 deg / Угол вектора', 'default': 'no', 'values': ('no', 'yes')}, # θ является отдельной опцией и требует всех трёх восстановленных фаз.
{'id': 'show_carrier', 'desc': 'Calculated triangle / Расчётная несущая', 'default': 'no', 'values': ('no', 'yes')}, # Треугольник восстанавливается по периодам и не выдаётся за измеренную аналоговую несущую.
{'id': 'show_phase_angles', 'desc': 'Phase angles AB BC CA / Углы между фазами', 'default': 'no', 'values': ('no', 'yes')}, # Знаковые сдвиги фаз измеряются по одноимённым пересечениям огибающих.
{'id': 'waveform_layout', 'desc': 'Waveform layout / Вид графиков', 'default': 'split', 'values': ('split', 'combined')}, # Split сохраняет отдельную область каждого входа; combined совмещает цветные кривые на общей шкале −1…+1.
{'id': 'carrier_hz', 'desc': 'Carrier Hz (0 = edge-to-edge)', 'default': 0},
{'id': 'smoothing_ms', 'desc': 'Low-pass time constant (ms, 0 = off)', 'default': 0.0},
)
annotations = (
('wave-a', 'Wave A'), ('wave-b', 'Wave B'), ('wave-c', 'Wave C'),
('duty-a', 'Duty A'), ('duty-b', 'Duty B'), ('duty-c', 'Duty C'),
('frequency-a', 'PWM carrier / field A'), ('frequency-b', 'PWM carrier / field B'), ('frequency-c', 'PWM carrier / field C'), # Индексы 6–8 дополняют прежние аннотации, сохраняя контракт графических строк 0–2.
('theta', 'Theta ABC (deg)'), ('carrier', 'Calculated carrier'), ('phase-angles', 'Phase lag (deg)'), # Индексы 9–11 отделяют графические углы и несущую от числового измерения сдвигов.
('npc-unknown-a', 'NPC A unknown'), ('npc-unknown-b', 'NPC B unknown'), ('npc-unknown-c', 'NPC C unknown'), # Индексы 12–14 явно обозначают комбинации затворов, по которым напряжение не определяется.
)
annotation_rows = (
('wave_a', 'A: -1 .. +1', (0,)),
@@ -36,6 +53,13 @@ class Decoder(srd.Decoder):
('duty_a', 'A duty / frequency', (3,)),
('duty_b', 'B duty / frequency', (4,)),
('duty_c', 'C duty / frequency', (5,)),
('frequency_a', 'A PWM carrier / field', (6,)), # Длинная аннотация охватывает полный период поля и показывает обе частоты канала A при обзорном масштабе.
('frequency_b', 'B PWM carrier / field', (7,)), # Независимый расчёт канала B сохраняет его собственную частоту и положение электрического периода.
('frequency_c', 'C PWM carrier / field', (8,)), # Для необязательного канала C строка заполняется только при фактическом подключении.
('wave_theta', 'Theta ABC: 0 .. 360 deg', (9,)), # Нормированный графический контракт переводится в электрические градусы подписью шкалы и числовыми аннотациями.
('wave_carrier', 'Calculated triangle / NPC pair', (10,)), # Одна общая строка показывает один треугольник для двух уровней или два смещённых треугольника NPC.
('phase_angles', 'Phase lag A->B / B->C / C->A (deg)', (11,)), # Положительное значение означает запаздывание второй указанной фазы.
('npc_unknown', 'NPC dead-time / unknown voltage', (12, 13, 14)), # Недопустимое состояние не маскируется фиктивным нулём коммутационного напряжения.
)
def __init__(self):
@@ -44,6 +68,11 @@ class Decoder(srd.Decoder):
def reset(self):
self.samplerate = None
self.channels_state = {}
self._mode, self._npc = 'envelope', False # Старые сохранённые настройки продолжают открываться как обычная огибающая двухуровневого ШИМ.
self._show_theta = self._show_carrier = self._show_angles = False # Дополнительные расчётные строки включаются только явными опциями данного экземпляра.
self._phase_channels = {ch: (ch,) for ch in range(3)} # Прежняя нумерация A/B/C сохраняется для тестов, записей и обычного режима.
self._raw, self._theta_previous = {}, None # Координаты коммутаций и предыдущий θ принадлежат только текущему декодированию.
self._phase_analysis, self._last_pins = None, None # Согласование фаз и завершающий снимок не переносятся в следующую запись.
def metadata(self, key, value):
if key == srd.SRD_CONF_SAMPLERATE:
@@ -53,30 +82,105 @@ class Decoder(srd.Decoder):
self.out_ann = self.register(srd.OUTPUT_ANN)
def _update(self, pins):
self._last_pins = list(pins) # sigrok повторно использует свой кортеж; независимая копия нужна для корректного завершения окон.
for ch, state in self.channels_state.items():
for event in state.update(self.samplenum, pins[ch]):
label = 'D={:.3f}% {}'.format(event['duty'] * 100,
('window ' if state.window else 'f=') + '{:.3f} Hz'.format(event['frequency']))
inputs = [pins[index] for index in self._phase_channels[ch]] # В NPC одному восстановленному каналу соответствуют четыре физических входа.
level = state.state(inputs) if self._npc else (1 if bool(inputs[0]) == state.active_high else -1) # Двухуровневый уровень нормирован к ±1, а неопределённая комбинация NPC остаётся None.
if self._mode == 'switching' or self._npc: # Коммутационный график и предупреждения NPC используют ту же временную последовательность цифровых состояний.
self._switching(ch, level) # Неизвестные интервалы разрывают кривую и явно отмечаются отдельной аннотацией.
for event in state.update(self.samplenum, inputs if self._npc else inputs[0]): # Ядра получают собственный формат входа, сохраняя единый формат восстановленных результатов.
carrier = '{:.3f} Hz'.format(event['carrier_frequency']) if event['carrier_frequency'] is not None else 'unavailable' # Не подменяем измеренную несущую заданной частотой окна или устаревшим значением после исчезновения фронтов.
window = ' window={:.3f} Hz'.format(event['frequency']) if state.window else '' # Частота интегрирования обозначается отдельно и только в режиме заданной несущей.
label = 'D={:.3f}% PWM carrier={}{}'.format(event['duty'] * 100, carrier, window) # Подпись каждого периода явно связывает несущую с измеренными фронтами ШИМ.
if self._npc: # В NPC D является эквивалентным заполнением среднего уровня, а не временем включения одного ключа.
label = 'NPC mean={:+.6f} valid={:.3f}% PWM carrier={}{}'.format(2 * event['duty'] - 1, 100 * event['valid_fraction'], carrier, window) # Доля известного времени показывает влияние исключённых коммутационных пауз на оценку.
self.put(event['start'], event['end'], self.out_ann,
[3 + ch, [label, '{:.2f}%'.format(event['duty'] * 100)]])
field = event['field'] # Дополнительная строка появляется только после завершения полного электрического периода.
if field is not None: # Неполные периоды и постоянное заполнение не создают числового результата поля.
carrier = '{:.3f} Hz'.format(field['carrier_frequency']) if field['carrier_frequency'] is not None else 'unavailable' # Средняя несущая измеряется независимо от числа окон восстановления за период поля.
field_text = 'Field={:.3f} Hz'.format(field['frequency']) # Частота огибающей выражена в электрических герцах, без пересчёта в механические обороты.
carrier_short, field_short = short_frequency(field['carrier_frequency']), short_frequency(field['frequency']) # При обзорном масштабе приставки СИ позволяют одновременно прочитать несущую и поле.
self.put(field['start'], field['end'], self.out_ann, [6 + ch, ['PWM carrier={} {}'.format(carrier, field_text), 'PWM={} / {}'.format(carrier, field_text), 'PWM={} F={}'.format(carrier_short, field_short), '{}/{}'.format(carrier_short, field_short)]]) # Все варианты подписи сохраняют порядок «несущая / поле», включая самый короткий для узкого периода.
previous = event['previous']
if previous is not None:
if previous is not None and self._mode == 'envelope': # Переключение вида меняет только график; частоты и углы всегда рассчитываются по огибающим.
# Last label is the versioned native renderer contract.
self.put(previous[0], event['midpoint'], self.out_ann, [ch, [
'PWM {}: {:+.6f}'.format('ABC'[ch], event['value']),
'PWM1 {:.9f} {:.9f}'.format(previous[1], event['value'])]])
if self._show_carrier and ch == min(self.channels_state): # Общая расчётная несущая использует периоды первой подключённой фазы и не дублируется для B/C.
self._curve(10, event['start'], event['midpoint'], -1, 1, 'Calculated carrier') # Первая половина условного периода линейно поднимается от минимума к максимуму.
self._curve(10, event['midpoint'], event['end'], 1, -1, 'Calculated carrier') # Вторая половина завершает треугольник без накопления ошибки времени.
if self._phase_analysis is not None: # При выключенных угловых опциях согласование фаз не тратит время на длинных записях.
if event['previous'] is None: # Новая история огибающей означает начало записи либо разрыв неизвестного состояния NPC.
self._theta_previous = None # График θ не соединяется через интервал, где одна из фаз не была восстановлена.
vector = self._phase_analysis.add(ch, event) # Точки всех фаз интерполируются к общему наблюдавшемуся времени.
if self._show_theta and vector is not None: # Начальная неполная тройка фаз не создаёт угла пространственного вектора.
self._theta(vector) # Переход 360→0 разрывается на границе, сохраняя правильную пилообразную форму.
if self._show_angles and event['field'] is not None and ch == min(self.channels_state): # Один полный период опорной фазы задаёт интервал числового результата всех доступных пар.
angles = self._phase_analysis.differences(event['midpoint']) # Сдвиг вычисляется из фактических фазовых часов, а не из заданных 120 градусов.
if angles: # Устаревшие или ещё не измеренные пары не должны получать числовую подпись.
labels = [' '.join('{}={:+.3f} deg'.format(pair, value) for pair, value in angles.items()), ' / '.join('{:+.1f}'.format(value) for value in angles.values())] # Полная подпись сохраняет направление каждой пары; короткая следует порядку заголовка.
self.put(event['field']['start'], event['field']['end'], self.out_ann, [11, labels]) # Значения доступны на временной диаграмме, в таблице результатов и при экспорте.
def _curve(self, kind, start, end, a, b, label): # Все расчётные кривые используют существующий индекс экстремумов и контракт PWM1.
start, end = round(start), round(end) # Целые номера отсчётов требуются API аннотаций sigrok.
if end > start: # Пустой интервал не может быть сегментом индексированной кривой.
self.put(start, end, self.out_ann, [kind, [label, 'PWM1 {:.9f} {:.9f}'.format(max(-1, min(1, a)), max(-1, min(1, b)))]]) # Ограничение защищает нативный контракт нормированных значений от машинного округления.
def _switching(self, ch, level): # Импульсная осциллограмма строится по сменам состояний без выдуманных аналоговых выбросов.
previous = self._raw.get(ch) # Предыдущее состояние определяет уровень между двумя цифровыми событиями.
if previous is not None and self.samplenum > previous[0]: # Совпадающие события не создают отрицательную или нулевую длительность.
start, old = previous # Начало интервала и уровень относятся только к выбранной фазе.
if old is None: # Ни одна из трёх штатных комбинаций NPC не определяла напряжение на этом участке.
self.put(start, self.samplenum, self.out_ann, [12 + ch, ['NPC {}: unknown voltage / dead-time'.format('ABC'[ch]), 'unknown']]) # Неизвестное время остаётся видимым даже при выборе огибающей.
elif self._mode == 'switching': # Основная графическая строка может показывать коммутации вместо средней огибающей.
corner = self.samplenum - 1 if level is not None and level != old else self.samplenum # Фронт занимает один отсчёт, поскольку аналоговая скорость переключения не измерена.
self._curve(ch, start, max(start, corner), old, old, 'Switching {}: {:+d}'.format('ABC'[ch], old)) # Постоянный уровень сохраняет точную длительность между событиями захвата.
if level is not None and level != old: # Через неизвестный уровень не проводится выдуманный соединительный фронт.
self._curve(ch, max(start, corner), self.samplenum, old, level, 'Switching {} edge'.format('ABC'[ch])) # Нормированный переход отображается только в пределах разрешения записи.
self._raw[ch] = (self.samplenum, level) # Следующее событие продолжит график только от текущего достоверного состояния.
def _theta(self, vector): # Угол общего вектора выводится отдельно от углов между фазами.
sample, angle = vector['sample'], vector['theta'] # Координата соответствует единому времени A/B/C после интерполяции.
previous = self._theta_previous # Предыдущая точка нужна для графика и обнаружения циклического перехода через нуль.
if angle is not None and previous is not None: # Нулевая амплитуда или разрыв не соединяются произвольным направлением вектора.
start, old = previous # Угол выражен в градусах до преобразования в нативную шкалу −1…+1.
if abs(angle - old) <= 180: # Обычный участок не пересекает границу электрического оборота.
self._curve(9, start, sample, old / 180 - 1, angle / 180 - 1, 'Theta={:.3f} deg'.format(angle)) # Подпись содержит физическую величину, а renderer получает нормированные концы.
else: # Циклическое значение должно сбрасываться вертикально, а не проходить ложные промежуточные углы.
forward = old > angle # Направление определяет границу +360→0 либо 0→+360.
delta = angle - old + (360 if forward else -360) # Кратчайшее изменение восстанавливает непрерывный угол на одном малом интервале.
boundary = start + ((360 if forward else 0) - old) * (sample - start) / delta # Линейная интерполяция определяет момент перехода через границу оборота.
self._curve(9, start, boundary, old / 180 - 1, 1 if forward else -1, 'Theta wrap') # Первая часть заканчивается на достигнутой границе шкалы.
self._curve(9, boundary, sample, -1 if forward else 1, angle / 180 - 1, 'Theta={:.3f} deg'.format(angle)) # Вторая часть начинается с противоположного края без ложной диагонали.
self._theta_previous = (sample, angle) if angle is not None else None # Неопределённое направление явно разрывает историю угла.
def decode(self):
if not self.samplerate:
raise ValueError('Cannot decode without samplerate.')
self.channels_state = {ch: PwmChannel(self.samplerate,
self._npc = self.options.get('input_mode', 'two-level') == 'npc-three-level' # Отсутствующая опция старой записи сохраняет прежнюю схему одного входа на фазу.
self._mode = self.options.get('waveform_mode', 'envelope') # Вид кривой не меняет численные измерения заполнения, частот и углов.
self._show_theta, self._show_carrier, self._show_angles = (self.options.get(key, 'no') == 'yes' for key in ('show_theta', 'show_carrier', 'show_phase_angles')) # Все дополнительные расчёты включаются независимо в настройках одного анализатора.
self._phase_channels = {ch: (ch, 3 + ch * 3, 4 + ch * 3, 5 + ch * 3) if self._npc else (ch,) for ch in range(3)} # S1 сохраняет прежние индексы A/B/C, а S2…S4 последовательно занимают девять новых входов.
for ch, indices in self._phase_channels.items(): # Неполная стойка NPC не должна трактоваться как неподключённый или нулевой уровень фазы.
connected = [self.has_channel(index) for index in indices] # Наличие проверяется для каждого физического затвора отдельно.
if any(connected) and not all(connected): # Каждая используемая фаза NPC требует всех четырёх управляющих сигналов.
raise ValueError('NPC phase {} requires all four gates S1..S4 (top to bottom).'.format('ABC'[ch])) # Понятная ошибка предотвращает неверную реконструкцию из неполных входов.
channel_class = NpcChannel if self._npc else PwmChannel # Один и тот же экземпляр декодера поддерживает две схемы без отдельного анализатора.
self.channels_state = {ch: channel_class(self.samplerate, # Тип канала определяет преобразование цифровых состояний в средний уровень.
self.options['active_' + 'abc'[ch]] == 'high',
self.options['carrier_hz'], self.options['smoothing_ms'])
for ch in range(3) if self.has_channel(ch)}
if self._show_theta and len(self.channels_state) != 3: # Для угла пространственного вектора нужны три реальные огибающие, а не предположение о симметричной нагрузке.
raise ValueError('Theta requires all three phases A/B/C.') # Отсутствующая фаза не достраивается искусственной синусоидой.
if self._show_angles and len(self.channels_state) < 2: # Межфазный угол невозможно измерить по одному подключённому входу.
raise ValueError('Phase angles require at least two phases.') # В двухфазной записи рассчитываются только доступные пары.
self._phase_analysis = PhaseAnalysis(self.samplerate) if self._show_theta or self._show_angles else None # Выключенные опции сохраняют прежнюю стоимость обработки обычного ШИМ.
pins = list(self.wait({}))
self._update(pins)
while True:
conditions = [{ch: 'e'} for ch in self.channels_state]
conditions = [{index: 'e'} for ch in self.channels_state for index in self._phase_channels[ch]] # В NPC пробуждение вызывается каждым затвором, включая короткие dead-time и запрещённые комбинации.
boundaries = [s.next_boundary() for s in self.channels_state.values()
if s.next_boundary() is not None]
if boundaries:
@@ -85,9 +189,5 @@ class Decoder(srd.Decoder):
self._update(pins)
def end(self):
# Complete fixed windows only; never infer a partial edge-based period.
if self.channels_state:
pins = [0, 0, 0]
for ch, state in self.channels_state.items():
pins[ch] = state.level == state.active_high
self._update(pins)
if self.channels_state and self._last_pins is not None: # Последний снимок содержит все двенадцать затворов NPC либо прежние входы A/B/C.
self._update(self._last_pins) # Завершаются только полные окна и постоянные участки; отсутствующие фронты и неполные периоды не выдумываются.
@@ -0,0 +1,50 @@
# Сигма-дельта: DATA + CLK
Декодер `sigma_delta` восстанавливает сигнал из однобитного потока
сигма-дельта модулятора (PDM) с отдельным тактовым входом. В SETGUI он
доступен в меню анализаторов как **Сигма-дельта · DATA/CLK, sinc1/2/3**,
в обычном списке DSView — как **Sigma-delta**.
Назначьте захваченные линии **DATA** и **CLK**. Выберите фронт приёма согласно
документации модулятора: `rising` или `falling`. Если полярность DATA обратная,
включите `Invert DATA`. Биты принимаются на фронтах CLK даже при постоянном DATA;
начальный уровень захвата до первого выбранного фронта не считается битом.
Для нескольких модуляторов добавьте несколько экземпляров анализатора с общим CLK.
| Настройка | Значение и смысл |
| --- | --- |
| Decimation filter | `sinc1`, `sinc2`, `sinc3`; по умолчанию `sinc3` |
| Decimation / OSR | Целое число входных тактов на выходной отсчёт: 1–65536; по умолчанию 256 |
| Bipolar full-scale input | Положительное напряжение идеального входа при 100% единиц; по умолчанию 1 В |
Выход CIC нормируется на усиление `OSR**order`: нулевая плотность соответствует
−1, плотность 50% — нулю, 100% — +1. Напряжение равно нормированному значению,
умноженному на `Bipolar full-scale input`. Это идеальный масштаб модулятора,
который следует брать из его документации; напряжение питания и опорное напряжение
не обязательно совпадают с этим параметром. Код — биполярное целое в диапазоне
`−OSR**order … +OSR**order`, без привязки к разрядности конкретного АЦП.
В таблице результатов доступны нормированное значение и код, напряжение,
фильтрованная плотность единиц, частота CLK и выходная частота `CLK / OSR`.
Для sinc2/3 плотность взвешена фильтром и отличается от простого подсчёта
единиц последнего блока. Амплитуда восстанавливается и без метаданных частоты
захвата; частоты в герцах тогда не выводятся.
Начальные `order − 1` выходных блоков пропускаются до установления фильтра.
Неполный блок в конце записи не выдаётся. Аннотация охватывает фронты последнего
блока OSR; фильтр сохраняет причинную задержку и не сдвигает результат назад.
Групповая задержка sincN равна `N × (OSR − 1) / 2` периодов CLK.
Изменение выбранного фронта, инверсии или OSR требует повторного декодирования.
Поддерживается однобитный поток с отдельным CLK. Для готовых слов АЦП по SPI
используйте соответствующий SPI-декодер; Manchester без отдельного CLK здесь
не разбирается. Точность ограничена захватом: пропущенные тактовые фронты
невозможно восстановить автоматически.
Двухлинейный интерфейс и семейство sinc1/2/3 описаны в
[TI SDFM Interface Design](https://software-dl.ti.com/processor-industrial-sw/esd/motor_control_sdk/am243x/11_00_00_06/docs/api_guide_am243x/SDFM_DESIGN.html).
Реализация фильтра находится в `python/logic_analyzer/decoders/sigma_delta.py`.
Общая ведомость `protocol-files.json` включает адаптер и ядро в сборку DSView
и в `install_decoders.ps1`; после установки перезапустите DSView.
Проверки: `python -m unittest discover -s tools/dsview/tests -p test_sigma_delta.py -v`.
@@ -0,0 +1 @@
from .pd import Decoder # Стандартная точка входа sigrok загружает адаптер сигма-дельта потока из этого пакета.
+87
View File
@@ -0,0 +1,87 @@
"""Восстановление сигма-дельта потока DATA/CLK с децимацией sinc1, sinc2 или sinc3.""" # Адаптер использует API sigrok версии 3 и переносимое ядро фильтра.
import math # Проверка конечности исключает NaN и бесконечности в масштабе напряжения и частоте захвата.
import sigrokdecode as srd # DSView предоставляет каналы, ожидание фронтов и вывод аннотаций.
from common.setgui_decoders.sigma_delta import SigmaDeltaFilter # Установщик переносит каноническое ядро в закрытый пакет DSView.
class Decoder(srd.Decoder): # Один экземпляр анализирует один однобитный модулятор с отдельной тактовой линией.
api_version = 3 # Этот контракт поддерживается штатным libsigrokdecode DSView.
id = 'sigma_delta' # Постоянный идентификатор используется установщиком, меню и сохранёнными сессиями.
name = 'Sigma-delta' # Короткое имя доступно в штатном поиске декодеров DSView.
longname = 'Sigma-delta / PDM with clock' # Полное имя уточняет формат однобитного потока с внешним CLK.
desc = 'Reconstructs a clocked 1-bit sigma-delta stream using sinc1/2/3 decimation.' # Описание отличает вход модулятора от готовых слов АЦП по SPI.
license = 'gplv2+' # Лицензия согласована с соседними адаптерами sigrok.
inputs = ['logic'] # Декодер подключается непосредственно к цифровым линиям захвата.
outputs = ['sigma_delta'] # Структурированные отсчёты могут использоваться последующим Python-декодером.
tags = ['Util'] # Общая категория поддерживается стандартными установками sigrok.
channels = ( # Оба сигнала обязательны, поскольку плотность единиц измеряется по тактам, а не длительности импульсов.
{'id': 'data', 'name': 'DATA', 'desc': 'Sigma-delta bit stream'}, # Первый вход содержит уровень однобитного модулятора.
{'id': 'clk', 'name': 'CLK', 'desc': 'Modulator clock'}, # Второй вход определяет моменты приёма DATA.
) # Отдельные экземпляры декодера позволяют анализировать несколько модуляторов с общим CLK.
options = ( # Настройки сохраняются обычным механизмом DSView без внешних файлов.
{'id': 'clock_edge', 'desc': 'Sampling edge', 'default': 'rising', 'values': ('rising', 'falling')}, # Фронт выбирается согласно документации подключённого модулятора.
{'id': 'filter', 'desc': 'Decimation filter', 'default': 'sinc3', 'values': ('sinc1', 'sinc2', 'sinc3')}, # Более высокий порядок сильнее подавляет высокочастотную составляющую потока.
{'id': 'decimation', 'desc': 'Decimation / OSR (clock cycles)', 'default': 256}, # R входных тактов формируют один выходной отсчёт; допустимы значения 1–65536.
{'id': 'invert', 'desc': 'Invert DATA', 'default': 'no', 'values': ('no', 'yes')}, # Инверсия меняет полярность до прохождения фильтра.
{'id': 'full_scale', 'desc': 'Bipolar full-scale input (V)', 'default': 1.0}, # Это идеальный вход при 100 процентах единиц, а не обязательно напряжение питания или опоры.
) # По умолчанию напряжение численно совпадает с нормированной амплитудой при масштабе ±1 В.
annotations = ( # Числовые строки работают и в standalone DSView без специальной нативной отрисовки.
('value', 'Normalized value'), # Индекс ноль показывает восстановленную биполярную амплитуду.
('voltage', 'Input voltage'), # Индекс один показывает амплитуду с заданным физическим масштабом.
('density', 'Filtered one density'), # Индекс два даёт фильтрованную плотность единиц для диагностики полярности.
('rate', 'Clock / output rate'), # Индекс три связывает частоту захваченного CLK с выходной частотой децимации.
) # Аннотации создаются на каждый установившийся полный выходной блок.
annotation_rows = ( # Каждая величина имеет независимую строку в таблице результатов.
('value', 'Value: -1 .. +1', (0,)), # Диапазон соответствует идеальным крайним плотностям ноль и единица.
('voltage', 'Input voltage', (1,)), # Размерность напряжения указана и в подписи каждого отсчёта.
('density', 'Filtered density', (2,)), # Для sinc2/3 это взвешенная плотность, а не простое число единиц последнего блока.
('rate', 'CLK / output rate', (3,)), # Частота доступна только при наличии метаданных частоты захвата.
) # Порядок строк сохраняет совпадение с индексами классов аннотаций.
def __init__(self): # DSView создаёт объект до передачи параметров и метаданных.
self.reset() # Новый анализ не наследует состояние предыдущего захвата.
def reset(self): # Частота захвата может отсутствовать: амплитуду всё равно можно восстановить по CLK.
self.samplerate = None # Пока метаданные не получены, частоты в герцах не вычисляются.
def metadata(self, key, value): # Частота захвата приходит отдельным сообщением движка sigrok.
if key == srd.SRD_CONF_SAMPLERATE: # Остальные метаданные не относятся к переводу номеров отсчётов в секунды.
if not math.isfinite(value) or value <= 0: # Частота должна быть конечной положительной величиной в отсчётах в секунду.
raise ValueError('Samplerate must be finite and positive.') # Некорректные метаданные не должны порождать деление на ноль.
self.samplerate = value # Частота используется только для измерений CLK и выходного потока.
def start(self): # Выходы регистрируются после создания объекта и до вызова decode.
self.out_ann = self.register(srd.OUTPUT_ANN) # Аннотации остаются доступны обычному окну результатов DSView.
self.out_python = self.register(srd.OUTPUT_PYTHON) # Числа передаются без потерь от форматирования текстовых подписей.
def decode(self): # Ожидание только выбранных фронтов уменьшает число обращений к движку захвата.
if not self.has_channel(0) or not self.has_channel(1): # Достоверное восстановление требует и DATA, и тактовой линии.
raise ValueError('Both DATA and CLK channels are required.') # Служебное значение отсутствующего входа нельзя считать реальными битами.
filters = {'sinc1': 1, 'sinc2': 2, 'sinc3': 3} # Отображение фиксирует число каскадов без разбора произвольной строки.
if self.options['filter'] not in filters or self.options['clock_edge'] not in ('rising', 'falling') or self.options['invert'] not in ('no', 'yes'): # Повреждённая сохранённая сессия должна явно сообщать о неверной настройке.
raise ValueError('Invalid filter, sampling edge or DATA polarity.') # Не подставляем другой режим фильтрации незаметно для пользователя.
state = SigmaDeltaFilter(self.options['decimation'], filters[self.options['filter']], self.options['invert'] == 'yes') # Ядро проверяет целочисленный OSR и сбрасывает память CIC.
full_scale = float(self.options['full_scale']) # Физический масштаб задаётся положительным напряжением идеального верхнего предела.
if not math.isfinite(full_scale) or full_scale <= 0: # Нулевой и неконечный масштаб делают физический результат бессмысленным.
raise ValueError('Bipolar full-scale input must be finite and positive.') # Отрицательная полярность задаётся отдельной опцией инверсии DATA.
edge = 'r' if self.options['clock_edge'] == 'rising' else 'f' # API sigrok обозначает нарастающий и спадающий фронты буквами r и f.
block_start, previous_sample = None, None # Границы блока и предыдущий такт нужны для аннотаций и измерения частоты.
while True: # Завершение захвата останавливает wait штатным механизмом sigrok без дополнения неполного блока.
pins = list(self.wait({1: edge})) # Копируем уровни выбранного фронта в список: DSView повторно заполняет внутренний tuple и требует освобождать его до следующего wait.
sample = self.samplenum # Номер отсчёта задаёт фактически наблюдавшееся время фронта.
if state.phase == 0: # Первый бит очередного блока определяет его начало независимо от прогрева фильтра.
block_start = sample # Начальный уровень записи до первого фронта не считается битом.
event = state.feed(pins[0]) # В фильтр поступает именно дискретный уровень DATA, без интегрирования ширины импульса.
if event is not None: # Первые N минус один блоков и неполные окна намеренно не создают результата.
intervals = state.decimation - 1 # Между R захваченными фронтами находится R минус один полный период CLK.
elapsed = sample - block_start if intervals else sample - previous_sample if previous_sample is not None else 0 # При R=1 частота измеряется между соседними выходными отсчётами.
clock = self.samplerate * (intervals or 1) / elapsed if self.samplerate and elapsed > 0 else None # Начальный одиночный фронт не позволяет достоверно измерить частоту.
event.update(voltage=event['value'] * full_scale, clock_hz=clock, output_hz=clock / state.decimation if clock is not None else None) # Напряжение и частота вычисляются из фильтрованной амплитуды и захваченного CLK.
event.update(decimation=state.decimation, order=state.order) # Структурированный результат содержит параметры для однозначной интерпретации кода.
self.put(block_start, sample, self.out_ann, [0, ['Value={:+.6f} code={}/{}'.format(event['value'], event['code'], event['gain']), '{:+.6f}'.format(event['value'])]]) # Точная шкала кода явно показывает усиление R**N.
self.put(block_start, sample, self.out_ann, [1, ['Input={:+.9g} V'.format(event['voltage']), '{:+.6g} V'.format(event['voltage'])]]) # Физическое значение сохраняет знак биполярного модулятора.
self.put(block_start, sample, self.out_ann, [2, ['Filtered ones={:.6f}%'.format(event['density'] * 100), '{:.3f}%'.format(event['density'] * 100)]]) # Множитель сто переводит долю единиц в проценты.
if clock is not None: # Без частоты захвата вывод амплитуды продолжается, а строка герц остаётся пустой.
self.put(block_start, sample, self.out_ann, [3, ['CLK={:.9g} Hz output={:.9g} Hz'.format(clock, event['output_hz'])]]) # Частота децимации равна частоте CLK, делённой на OSR.
self.put(block_start, sample, self.out_python, ['SAMPLE', event]) # Следующий декодер получает один пакет на каждый действительный результат фильтра.
previous_sample = sample # Запоминаем предыдущий реальный фронт даже во время начального установления.
+136
View File
@@ -0,0 +1,136 @@
--- a/DSView/pv/view/decodetrace.cpp
+++ b/DSView/pv/view/decodetrace.cpp
@@ -188,9 +188,17 @@
}
+static QString pwm_motor_scale(pv::data::decode::Decoder *decoder) // Физическая шкала тока относится к тому же экземпляру, который создал нормированные сегменты.
+{ // Старый декодер заполнения сохраняет прежнюю безразмерную шкалу.
+ if (QString::fromUtf8(decoder->decoder()->id) != "pwm_motor") return ": -1 .. +1"; // Только модель двигателя имеет значения в амперах.
+ const auto option = decoder->options().find("plot_range_a"); // Шкала сохраняется штатным механизмом настроек DSL.
+ const double range = option != decoder->options().end() && option->second && g_variant_is_of_type(option->second, G_VARIANT_TYPE_DOUBLE) ? g_variant_get_double(option->second) : 100.0; // Сто ампер соответствует исходному значению адаптера при отсутствии опции в записи.
+ return QString(": -%1 .. +%1 A (estimate)").arg(range, 0, 'g', 6); // Единицы и расчётный характер значения видны рядом с общей либо отдельной кривой.
+} // Физический ток в таблице остаётся ненормированным независимо от выбранной шкалы.
+
static bool pwm_combined(pv::data::decode::Decoder *decoder) // Режим берётся из штатных настроек конкретного экземпляра декодера.
{ // Старые записи без новой опции должны открываться с прежними отдельными графиками.
- if (QString::fromUtf8(decoder->decoder()->id) != "pwm_waveform") return false; // Настройка PWM не меняет компоновку остальных анализаторов.
+ if ((QString::fromUtf8(decoder->decoder()->id) != "pwm_waveform" && QString::fromUtf8(decoder->decoder()->id) != "pwm_motor")) return false; // Настройка PWM не меняет компоновку остальных анализаторов.
const auto option = decoder->options().find("waveform_layout"); // Ищем сохранённый параметр без добавления отсутствующего ключа в карту.
return option != decoder->options().end() && option->second && g_variant_is_of_type(option->second, G_VARIANT_TYPE_STRING) // Проверяем тип перед чтением, чтобы повреждённая настройка не завершила приложение.
&& QString::fromUtf8(g_variant_get_string(option->second, nullptr)) == "combined"; // Только явно выбранный combined включает наложение цветных кривых.
@@ -260,7 +268,7 @@
if ((*i).second) {
const Row &row = (*i).first;
// Для обзорного масштаба берём индексированные экстремумы, сохраняя кривую и короткие выбросы.
- if (QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" // Специальная геометрия применяется только к декодеру восстановления ШИМ.
+ if ((QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" || QString::fromUtf8(dec->decoder()->id) == "pwm_motor") // Специальная геометрия применяется только к декодеру восстановления ШИМ.
&& row.row() && QString::fromUtf8(row.row()->id).startsWith("wave_")) { // Числовые строки duty продолжают использовать штатные аннотации.
std::vector<set_pwm::Line> points; // Геометрия ограничивается шириной видимой области вместо числа периодов во всей записи.
_decoder_stack->get_pwm_waveform(points, row, start_sample, end_sample, std::max(1, right - left)); // Индекс экстремумов сохраняет короткие пики при уменьшении масштаба.
@@ -295,7 +303,7 @@
_cur_row_heading_offsets.push_back(row_top); // Заголовок графика привязывается к его фактической верхней границе.
row_top += annotation_height * 3; // Масштабируемая область сохраняет прежнюю высоту из трёх строк на график.
y += annotation_height * 3; // Следующая графическая область начинается сразу после текущей.
- QString title = row.title(); // Раздельный график сохраняет прежнее название своего канала.
+ QString title = row.title() + (QString::fromUtf8(dec->decoder()->id) == "pwm_motor" ? pwm_motor_scale(dec) : QString()); // Раздельный график сохраняет прежнее название своего канала.
if (row_combined) { // Общая подпись перечисляет только основные видимые фазы, сохраняя собственные шкалы θ и несущей.
QStringList channels; // Список нужен для записи с одним, двумя или тремя видимыми входами.
for (const auto &entry : rows) { // Порядок совпадает с порядком строк и цветами A, B, C.
@@ -303,7 +311,7 @@
&& QString::fromUtf8(entry.first.row()->id).size() == 6 && QString::fromUtf8(entry.first.row()->id).startsWith("wave_") && _decoder_stack->has_annotations(entry.first)) // Из общей подписи исключены неподключённые фазы и дополнительные расчётные графики.
channels.append(QString::fromUtf8(entry.first.row()->id).right(1).toUpper()); // Суффикс wave_a…wave_c даёт обозначение фазы без разбора текста аннотаций.
} // Полученный список соответствует кривым, которые действительно будут нарисованы.
- title = QString::fromUtf8(dec->decoder()->name) + ": " + channels.join(" / ") + ": -1 .. +1"; // Общая шкала остаётся нормированной для всех видимых каналов.
+ title = QString::fromUtf8(dec->decoder()->name) + ": " + channels.join(" / ") + pwm_motor_scale(dec); // Общая шкала остаётся нормированной для всех видимых каналов.
} // Значения PWM и числовые результаты не изменяются при переключении вида.
_cur_row_headings.push_back(title); // Единственный заголовок относится ко всей выделенной области.
} // Повторная совмещённая фаза не сдвигает последующие аннотации.
@@ -627,6 +635,7 @@
{
on_new_decode_data();
_session->decode_done();
+ if (_view) _view->data_updated(); // Финальная перерисовка обновляет кэш после последних сегментов и диагностических строк, добавленных в end декодера.
}
int DecodeTrace::rows_size()
@@ -646,7 +655,7 @@
_decoder_stack->has_annotations((*i).first) &&
(*i).second)
{ // Учитываем только заполненные строки, разрешённые в настройках Decoders.
- const bool waveform = QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" // Особая высота относится только к кривым восстановления ШИМ.
+ const bool waveform = (QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" || QString::fromUtf8(dec->decoder()->id) == "pwm_motor") // Особая высота относится только к кривым восстановления ШИМ.
&& _row.row() && QString::fromUtf8(_row.row()->id).startsWith("wave_"); // Числовые duty/frequency строки сохраняют собственное место.
const bool phase_wave = waveform && QString::fromUtf8(_row.row()->id).size() == 6; // Только wave_a…wave_c относятся к общей области фаз; θ и несущая имеют собственную высоту.
if (phase_wave && combined && wave_shown) continue; // Следующая совмещённая основная фаза не увеличивает полную высоту дорожки.
@@ -674,7 +683,7 @@
int size = 0; // Текстовый анализатор без графиков не имеет масштабируемой области.
const auto rows = _decoder_stack->get_rows_gshow(); // Используем тот же порядок видимых строк, что нативный рендерер.
for (auto dec : _decoder_stack->stack()) { // У каждого экземпляра стека собственная настройка совмещения фаз.
- if (!dec->shown() || QString::fromUtf8(dec->decoder()->id) != "pwm_waveform") continue; // Скрытые и обычные текстовые декодеры не меняют фиксированную геометрию.
+ if (!dec->shown() || (QString::fromUtf8(dec->decoder()->id) != "pwm_waveform" && QString::fromUtf8(dec->decoder()->id) != "pwm_motor")) continue; // Скрытые и обычные текстовые декодеры не меняют фиксированную геометрию.
bool phase_shown = false; // Совмещённая область A/B/C учитывается отдельно от самостоятельных графиков θ и несущей.
for (const auto &entry : rows) { // Неподключённые и скрытые каналы исключаются до выделения высоты.
if (entry.first.decoder() != dec->decoder() || !entry.second || !entry.first.row() || !_decoder_stack->has_annotations(entry.first)) continue; // Должны совпасть экземпляр декодера, наличие данных и разрешение показа строки.
--- a/DSView/pv/data/decoderstack.cpp
+++ b/DSView/pv/data/decoderstack.cpp
@@ -187,7 +187,7 @@
const Row row(decc, ann_row, order);
// Add a new empty row data object
- const bool waveform = QString::fromUtf8(decc->id) == "pwm_waveform" && QString::fromUtf8(ann_row->id).startsWith("wave_"); // Только графические строки PWM требуют многоуровневого индекса.
+ const bool waveform = (QString::fromUtf8(decc->id) == "pwm_waveform" || QString::fromUtf8(decc->id) == "pwm_motor") && QString::fromUtf8(ann_row->id).startsWith("wave_"); // Только графические строки PWM требуют многоуровневого индекса.
_rows[row] = new decode::RowData(waveform); // Индекс создаётся вместе со строкой и очищается при повторном анализе.
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
if (iter == _rows_gshow.end()) {
--- a/DSView/pv/data/decode/decoder.cpp
+++ b/DSView/pv/data/decode/decoder.cpp
@@ -83,7 +83,7 @@
if (_setted) {
bool changed = !_committed || _probes != _probes_back || _decode_start != _decode_start_back || _decode_end != _decode_end_back; // Повторный анализ нужен при первом применении, новых входах или изменении границ отсчётов.
for (const auto &option : _options_back) { // Диалог повторно передаёт и неизменённые параметры, поэтому сравниваем значения, а не факт вызова setter.
- if (std::string(_decoder->id) == "pwm_waveform" && option.first == "waveform_layout") continue; // Наложение фаз меняет только отрисовку уже рассчитанных кривых.
+ if ((std::string(_decoder->id) == "pwm_waveform" || std::string(_decoder->id) == "pwm_motor") && option.first == "waveform_layout") continue; // Наложение фаз меняет только отрисовку уже рассчитанных кривых.
const auto previous = _options.find(option.first); // Ищем значение, с которым построены сохранённые аннотации.
GVariant *value = previous == _options.end() ? nullptr : previous->second; // Отсутствующий ключ означает штатное значение декодера, а не автоматическое изменение.
if (!value) for (const GSList *item = _decoder->options; item; item = item->next) { // Сравниваем явно сохранённый параметр с его исходным значением по умолчанию.
--- a/DSView/pv/dock/protocoldock.cpp
+++ b/DSView/pv/dock/protocoldock.cpp
@@ -383,7 +383,7 @@
log(QString("display cache phase %1: %2").arg(phase).arg(ok ? "ok" : "FAIL")); // Журнал различает видимость, неизменённый диалог и раскладку графиков.
} // После всех принятий остаются результаты единственного исходного декодирования.
auto decoder = stack->stack().front(); // Контрольное изменение реального параметра должно по-прежнему требовать нового анализа.
- if (QString::fromUtf8(decoder->decoder()->id) == "pwm_waveform") { // Проверка использует математический параметр сглаживания штатного PWM-декодера.
+ if ((QString::fromUtf8(decoder->decoder()->id) == "pwm_waveform" || QString::fromUtf8(decoder->decoder()->id) == "pwm_motor")) { // Проверка использует математический параметр сглаживания штатного PWM-декодера.
decoder->set_option("smoothing_ms", g_variant_new_double(1.0)); // Отличие на одну миллисекунду меняет вычисляемую кривую, поэтому игнорировать его нельзя.
ok = decoder->commit() && ok; // Новое вычислительное значение обязано инвалидировать прежние результаты.
decoder->set_option("smoothing_ms", g_variant_new_double(0.0)); // Возвращаем стандартный режим без сглаживания для проверочной записи.
@@ -465,8 +465,8 @@
const auto after = _protocol_lay_items.size();
ok = phase % 2 == 0 ? after == before : after == (phase == 1 ? before - 1 : 0);
}
- } else if (phase < 9) { // Проверяем также новый парсер терминальных UART-посылок ТМС.
- const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart", "tms_uart"})[phase - 4]; // Индекс отсчитывается после четырёх проверок удаления анализаторов.
+ } else if (phase < 10) { // Проверяем штатное добавление пяти протокольных анализаторов и модели токов двигателя через реальные диалоги DSView.
+ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart", "tms_uart", "pwm_motor"})[phase - 4]; // Индекс отсчитывается после четырёх проверок удаления анализаторов.
if (get_protocol_index_by_id(id) < 0) ok = false;
else {
QTimer::singleShot(80, this, [report, id]() {
@@ -486,7 +486,7 @@
ok = QString(trace->decoder()->stack().back()->decoder()->id) == id;
}
}
- } else if (phase == 9) { // После пяти прикладных анализаторов проверяем удаление всего набора дорожек.
+ } else if (phase == 10) { // После шести анализаторов, включая фазные токи двигателя, проверяем удаление всего набора дорожек.
answer(QMessageBox::Yes);
on_del_all_protocol();
ok = _protocol_lay_items.empty() && _session->get_decode_signals().empty();
@@ -1155,7 +1155,7 @@
{
SearchComboBox *panel = new SearchComboBox(this);
- const QStringList customIds = {"gate_driver_timing", "transistor_pair", "pwm_waveform", "set_uart", "set_can", "pm35_uart", "tms_uart", "plates_pbus", "plates_sbus"}; // При сборке пользовательский раздел включает анализаторы обеих шин 96-контактного интерфейса.
+ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "pwm_waveform", "pwm_motor", "set_uart", "set_can", "pm35_uart", "tms_uart", "plates_pbus", "plates_sbus"}; // При сборке пользовательский раздел включает анализаторы обеих шин 96-контактного интерфейса.
for (bool custom : {true, false}) {
for (auto info : _decoderInfoList) {
srd_decoder *dec = (srd_decoder *)(info->_data_handle);
+157
View File
@@ -0,0 +1,157 @@
// Самостоятельный осциллограф читает индекс готового декодера и не запускает повторный анализ записи.
#ifndef SET_DECODER_SCOPE_H // Защита позволяет включать виджет из графика и нативной проверки одного процесса.
#define SET_DECODER_SCOPE_H // Каждая единица трансляции получает единственное определение класса.
#include <QWidget> // Обычное окно Qt остаётся частью того же DSView и может разворачиваться независимо.
#include <QPointer> // Слабые ссылки предотвращают обращение к удалённым результатам или основному представлению.
#include <QPainter> // Кривые и физические шкалы выводятся без копирования снимка основного окна.
#include <QMouseEvent> // Перетаскивание задаёт собственное положение временного окна.
#include <QWheelEvent> // Колесо изменяет временной масштаб относительно точки под курсором.
#include <QHBoxLayout> // Панель команд сохраняет компактную горизонтальную компоновку.
#include <QVBoxLayout> // Растягиваемая область под панелью принадлежит только выбранному графику.
#include <QPushButton> // Команды возвращают полную запись и подбирают вертикальный диапазон.
#include <QDoubleSpinBox> // Полушкала задаётся в физических единицах без повторного декодирования.
#include <QCheckBox> // Отдельные фазы можно временно скрывать в самостоятельном окне.
#include <QLabel> // Подпись объясняет назначение вертикального масштаба.
#include <QTimer> // Ограниченная частота обновления сохраняет отзывчивость при поступлении результатов.
#include <limits> // Бесконечности используются только как исходные границы поиска экстремумов.
#include <cmath> // Преобразования шкал и увеличение выполняются в координатах отсчётов.
#include <algorithm> // Ограничение области не допускает выхода за границы записи.
#include "view.h" // Начальная область времени наследуется от текущего представления DSView.
#include "../sigsession.h" // Цвет каждой фазы определяется её назначенным цифровым входом.
#include "../data/decoderstack.h" // Существующий индекс выдаёт ограниченное числом пикселей количество отрезков.
#include "../data/decode/decoder.h" // Параметры и назначения входов берутся из действующего экземпляра декодера.
#include "../data/decode/row.h" // Искомые строки заново разрешаются после изменения настроек декодирования.
#include "pwm_waveform.h" // Отдельное окно использует тот же контракт нормированных сегментов PWM1.
namespace pv { namespace view { // Виджет относится к представлению DSView, не к приложению SETGUI.
class DecoderScopeWindow : public QWidget { // Один экземпляр показывает только выбранную графическую область декодера.
QPointer<pv::data::DecoderStack> _stack; // Результаты принадлежат исходному декодеру и никогда не копируются целиком.
QPointer<View> _source; // Основное представление используется для начального диапазона и актуальных цветов входов.
QString _decoder_id; // Имя декодера отделяет его строки от остальных уровней стека.
QStringList _rows; // Выбранные идентификаторы строк сохраняются безопасно, без указателей на удаляемые RowData.
QList<QCheckBox*> _checks; // Каждый переключатель относится к одной выбранной кривой, например Ia, Ib или Ic.
QDoubleSpinBox *_range; // Полушкала определяет только видимую амплитуду в этом окне.
double _start = 0, _span = 2, _center = 0, _half = 1; // Время выражено в отсчётах; амплитуда — в физических единицах графика.
double _drag_start = 0; // Начало временного окна запоминается один раз при захвате мыши.
QPoint _drag_point; // Смещение курсора переводится в отсчёты относительно исходной позиции перетаскивания.
bool _dragging = false; // Масштаб и положение меняются только при нажатой левой кнопке внутри графика.
QRectF plot() const { return QRectF(86, 82, std::max(1, width() - 112), std::max(1, height() - 136)); } // Поля оставляют место для панели, значений вертикальной шкалы и времени в миллисекундах.
double count() const { return _stack ? std::max(2.0, double(_stack->sample_count())) : 2.0; } // Минимальные два отсчёта исключают деление на ноль даже при очистке источника.
void bound() { // Каждое изменение времени проверяется независимо от масштаба основного окна.
_span = std::max(2.0, std::min(_span, count())); // Увеличение ограничено двумя отсчётами, уменьшение — полной длиной записи.
_start = std::max(0.0, std::min(_start, count() - _span)); // Прокрутка не выходит за начало или конец цифрового захвата.
setProperty("scope_start", _start); setProperty("scope_span", _span); update(); // Нативная проверка читает фактическую область; перерисовка не затрагивает исходное представление.
} // Преобразования не вызывают begin_decode_work и не меняют опции анализатора.
pv::data::decode::Decoder *decoder() const { // После изменения стека нельзя использовать старый указатель на конфигурацию декодера.
if (_stack) for (auto *item : _stack->stack()) if (QString::fromUtf8(item->decoder()->id) == _decoder_id) return item; // Поиск ограничен исходным экземпляром стека, поэтому соседние анализаторы не попадают в окно.
return nullptr; // Удалённая конфигурация отображается как отсутствие данных.
} // Указатель используется только в текущем проходе GUI-потока.
double physical(double value) const { // Нормированная геометрия преобразуется в единицы конкретной выбранной области.
if (_rows.contains("wave_theta")) return (value + 1) * 180; // θ кодируется диапазоном −1…+1, соответствующим 0…360 электрическим градусам.
auto *item = decoder(); // Параметр шкалы относится к тому же декодеру, который нормировал сегменты.
if (item && _decoder_id == "pwm_motor") { // Только токовая модель имеет амперы вместо относительных уровней.
const auto option = item->options().find("plot_range_a"); // Пользователь мог изменить исходную полушкалу после открытия окна.
if (option != item->options().end() && option->second && g_variant_is_of_type(option->second, G_VARIANT_TYPE_DOUBLE)) return value * g_variant_get_double(option->second); // Умножение восстанавливает сохранённые физические амперы.
return value * 100; // Значение 100 А совпадает со штатным значением адаптера при отсутствии сохранённой настройки.
} // Напряжения и расчётная несущая остаются в относительных единицах.
return value; // Внешнее окно не создаёт дополнительную фильтрацию или интерполяцию данных.
} // На графике видны исходные пульсации и разрывы выбранного анализатора.
QColor colour(const QString &row) const { // Цвет фазного результата следует за назначенным цифровым входом.
auto *item = decoder(); // Текущие назначения берутся заново, чтобы не хранить удалённые указатели на входы.
if (_source && item && (row == "wave_a" || row == "wave_b" || row == "wave_c")) { // Общие θ и несущая используют нейтральный цвет темы.
const QString input = row.right(1) + (_decoder_id == "pwm_motor" ? "_p" : ""); // Верхняя команда фазы называется a/b/c либо a_p/b_p/c_p.
for (auto *probe : item->binded_probe_list()) if (QString::fromUtf8(probe->id) == input) { // Остальные затворы не определяют цвет фазной кривой.
const int index = item->binded_probe_index(probe); // Пользователь может назначить фазы в произвольном порядке физических каналов.
for (auto *signal : _source->session().get_signals()) if (signal->signal_type() == SR_CHANNEL_LOGIC && signal->get_index() == index && signal->get_colour().isValid()) return signal->get_colour(); // Пользовательские цвета сохраняются при открытии и последующих изменениях.
} // При отсутствии подходящего входа используется контрастный нейтральный цвет.
} // Прямой доступ выполняется только пока исходное представление и стек существуют.
return QColor(210, 225, 240); // Светлый цвет обеспечивает видимость общих графиков на тёмном фоне SETGUI.
} // Цвет не зависит от порядка физического хранения входных каналов.
std::vector<set_pwm::Line> segments(const QString &id) const { // Видимый диапазон запрашивается из существующего индекса минимумов и максимумов.
std::vector<set_pwm::Line> result; // Размер ограничен шириной окна, а не длиной захвата.
if (_stack && _source) for (const auto &entry : _stack->get_rows_gshow()) { // Наличие данных проверяется в действующем стеке после возможного повторного анализа.
const auto &row = entry.first; // Копия карты действует только в текущем вызове и не переживает смену конфигурации.
if (row.row() && QString::fromUtf8(row.decoder()->id) == _decoder_id && QString::fromUtf8(row.row()->id) == id) { // Выбирается ровно строка запрошенного графика, без цифровых дорожек и предупреждений.
_stack->get_pwm_waveform(result, row, uint64_t(_start), uint64_t(std::ceil(_start + _span)), size_t(plot().width())); // Индекс сохраняет короткие выбросы и возвращает исходные сегменты при увеличении.
break; // У конкретного экземпляра анализатора идентификатор строки уникален.
} // Скрытие строки в основном окне не скрывает её в самостоятельном осциллографе.
} // Пустой результат корректен во время очистки, загрузки или отсутствия данных в выбранном интервале.
return result; // Владение исходными аннотациями остаётся у DecoderStack.
} // Защиту данных от потока декодирования обеспечивает существующий get_pwm_waveform.
public: // Конструктор и команды используются одинаково пользователем и нативной интеграционной проверкой.
DecoderScopeWindow(pv::data::DecoderStack *stack, View *source, const QString &decoder_id, const QStringList &rows, const QString &title) : QWidget(source->window(), Qt::Window), _stack(stack), _source(source), _decoder_id(decoder_id), _rows(rows) { // Окно имеет собственную рамку ОС, но остаётся дочерним объектом текущего DSView.
setObjectName("decoder_scope_window"); setAttribute(Qt::WA_DeleteOnClose); setWindowTitle(title + QString::fromUtf8(" — отдельный осциллограф")); // Закрытие удаляет только дополнительное окно и освобождает его обработчики.
setProperty("scope_decoder", decoder_id); setProperty("scope_rows", rows); resize(1100, 640); setMinimumSize(640, 360); // Идентификаторы позволяют проверить выбор конкретного нижнего графика; размеры заданы в пикселях интерфейса.
auto *layout = new QVBoxLayout(this); auto *bar = new QHBoxLayout; layout->addLayout(bar); layout->addStretch(); // Панель остаётся сверху при любом размере отдельного окна.
auto *all = new QPushButton(QString::fromUtf8("Вся запись"), this); all->setObjectName("scope_fit_time"); bar->addWidget(all); // Команда восстанавливает полный временной диапазон только этого осциллографа.
auto *amplitude = new QPushButton(QString::fromUtf8("По амплитуде"), this); amplitude->setObjectName("scope_fit_amplitude"); bar->addWidget(amplitude); // Автомасштаб учитывает экстремумы всех включённых фаз в видимом интервале.
bar->addWidget(new QLabel(QString::fromUtf8("Полушкала Y:"), this)); _range = new QDoubleSpinBox(this); _range->setObjectName("scope_y_range"); _range->setDecimals(6); _range->setRange(0.000001, 1e12); // Диапазон от микродоли единицы до 10¹² допускает малые пульсации и произвольные физические шкалы.
_range->setSuffix(_decoder_id == "pwm_motor" ? " A" : rows.contains("wave_theta") ? QString::fromUtf8(" °") : QString::fromUtf8(" о.е.")); bar->addWidget(_range); // Единицы явно различают амперы, электрические градусы и нормированные уровни.
_half = rows.contains("wave_theta") ? 180 : std::max(0.000001, std::abs(physical(1))); _center = rows.contains("wave_theta") ? 180 : 0; _range->setValue(_half); // Начальная вертикальная область совпадает с исходной шкалой токов, напряжения либо угла.
for (const auto &row : rows) { // Для совмещённых токов или напряжений создаётся по независимому переключателю на фазу.
auto *check = new QCheckBox(row.size() == 6 ? row.right(1).toUpper() : row == "wave_theta" ? QString::fromUtf8("θ") : QString::fromUtf8("Несущая"), this); check->setChecked(true); check->setObjectName("scope_" + row); bar->addWidget(check); _checks.append(check); // Имена A/B/C сохраняют смысл фаз при любом назначении физических каналов.
connect(check, &QCheckBox::toggled, this, [this]() { update(); }); // Выбор фазы меняет только видимость текущего окна.
} // Список цифровых каналов и остальные анализаторы сюда не добавляются.
bar->addStretch(); setToolTip(QString::fromUtf8("Колесо — масштаб времени; левая кнопка и перемещение — прокрутка")); // Подсказка описывает независимое управление осциллографом.
connect(all, &QPushButton::clicked, this, [this]() { _start = 0; _span = count(); bound(); }); // Полный захват доступен без изменения масштаба основной записи.
connect(amplitude, &QPushButton::clicked, this, [this]() { fit_amplitude(); }); // Вертикальная подгонка использует текущие видимые данные без нового расчёта токов.
connect(_range, static_cast<void(QDoubleSpinBox::*)(double)>(&QDoubleSpinBox::valueChanged), this, [this](double value) { _half = value; update(); }); // Ручная полушкала не меняет поток, токи или параметры двигателя.
connect(stack, &QObject::destroyed, this, &QWidget::close); connect(source, &QObject::destroyed, this, &QWidget::close); // Удаление декодера или исходного представления безопасно закрывает связанное окно.
auto *timer = new QTimer(this); timer->setInterval(150); connect(timer, &QTimer::timeout, this, [this]() { if (isVisible()) update(); }); timer->start(); // Обновление не чаще примерно семи раз в секунду ограничивает нагрузку и отражает новые данные и цвета.
const double rate = std::max(1.0, stack->samplerate()); _start = std::max(0.0, double(source->offset()) * source->scale() * rate); _span = source->get_view_width() * source->scale() * rate; bound(); // Начальное окно времени совпадает с видимым участком исходного графика.
} // QWidget владеет кнопками и таймером, отдельного процесса или копии захвата не создаётся.
void fit_amplitude() { // Автомасштаб подходит и для пускового тока, и для малых установившихся пульсаций.
double low = std::numeric_limits<double>::infinity(), high = -low; // Начальные границы не влияют на экстремумы действительных сегментов.
for (int i = 0; i < _rows.size(); ++i) if (_checks[i]->isChecked()) for (const auto &line : segments(_rows[i])) { // Скрытые в отдельном окне фазы не расширяют выбранную вертикальную шкалу.
low = std::min(low, std::min(physical(line.a), physical(line.b))); high = std::max(high, std::max(physical(line.a), physical(line.b))); // Линейный сегмент достигает экстремумов на концах, включая пики индекса.
} // Индекс обеспечивает ограниченное время работы даже для длинной записи.
if (std::isfinite(low) && std::isfinite(high)) { _center = (low + high) / 2; _range->setValue(std::max(0.000001, (high - low) * 0.55)); update(); } // Десять процентов свободного места предотвращают касание кривой рамки; постоянный уровень остаётся видимым.
} // При пустом интервале предыдущая ручная шкала сохраняется.
protected: // Взаимодействия используют собственные координаты и не отправляют команды основному графику.
void paintEvent(QPaintEvent *) override { // Вся доступная область отводится выбранным кривым и их измерительным шкалам.
QPainter p(this); p.fillRect(rect(), QColor(16, 23, 32)); const QRectF box = plot(); const QColor text(210, 225, 240); p.setPen(text); // Цвета согласованы с тёмной темой SETGUI и сохраняют контраст подписей.
const double rate = _stack ? std::max(1.0, _stack->samplerate()) : 1; // Координаты времени переводятся из исходных отсчётов в миллисекунды.
for (int i = 0; i <= 10; ++i) { // Десять равных интервалов формируют привычную осциллографическую временную сетку.
const double x = box.left() + box.width() * i / 10; p.setPen(QPen(QColor(53, 68, 82), 1, Qt::DotLine)); p.drawLine(QPointF(x, box.top()), QPointF(x, box.bottom())); p.setPen(text); // Вертикальные линии разделяют одинаковые интервалы времени.
p.drawText(QRectF(x - 38, box.bottom() + 8, 76, 20), Qt::AlignCenter, QString::number((_start + _span * i / 10) * 1000 / rate, 'g', 6)); // Подпись показывает абсолютное время записи в миллисекундах.
} // Масштаб и начало могут меняться независимо от основного DSView.
for (int i = 0; i <= 8; ++i) { // Восемь делений обеспечивают читаемую вертикальную шкалу выбранной физической величины.
const double y = box.top() + box.height() * i / 8; p.setPen(QPen(QColor(53, 68, 82), 1, Qt::DotLine)); p.drawLine(QPointF(box.left(), y), QPointF(box.right(), y)); p.setPen(text); // Горизонтальные уровни сохраняются при изменении размера окна.
p.drawText(QRectF(2, y - 10, 76, 20), Qt::AlignRight | Qt::AlignVCenter, QString::number(_center + _half * (1 - i / 4.0), 'g', 6)); // Значения относятся к амперам, градусам либо относительным уровням, указанным на панели.
} // Подгонка амплитуды меняет только центр и шаг данной шкалы.
p.drawText(QRectF(box.left(), 54, box.width(), 22), Qt::AlignLeft, windowTitle().section(QString::fromUtf8(" — "), 0, 0)); p.drawText(QRectF(box.left(), height() - 24, box.width(), 20), Qt::AlignCenter, QString::fromUtf8("Время, мс · колесо: масштаб · перетаскивание: прокрутка")); // Название и подсказка относятся только к выбранному графику.
p.save(); p.setClipRect(box); p.setRenderHint(QPainter::Antialiasing); size_t total = 0; // Отсечение не позволяет сильным токам перекрывать панель и числовые шкалы.
for (int i = 0; i < _rows.size(); ++i) if (_checks[i]->isChecked()) { // Каждая видимая фаза выводится своим цветом без копирования остальных дорожек.
const auto points = segments(_rows[i]); QVector<QLineF> lines; lines.reserve(int(points.size()) * 2); total += points.size(); // Резерв учитывает возможные две смещённые несущие NPC.
bool npc_pair = false; auto *item = decoder(); // Признак дополнительного нижнего треугольника берётся из реальной топологии анализатора.
if (item && _rows[i] == "wave_carrier") { const auto mode = item->options().find("input_mode"); npc_pair = mode != item->options().end() && mode->second && g_variant_is_of_type(mode->second, G_VARIANT_TYPE_STRING) && QString::fromUtf8(g_variant_get_string(mode->second, nullptr)) == "npc-three-level"; } // Для обычного ШИМ рисуется один треугольник −1…+1, для NPC — синфазные −1…0 и 0…+1.
for (const auto &line : points) { // На обзорном масштабе индекс ограничивает число отрезков числом экранных пикселей.
const double x1 = box.left() + (line.start - _start) / _span * box.width(), x2 = box.left() + (line.end - _start) / _span * box.width(); // Время каждого исходного сегмента переводится в локальные координаты отдельного окна.
const double a = npc_pair ? (line.a + 1) / 2 : line.a, b = npc_pair ? (line.b + 1) / 2 : line.b; // Верхняя несущая NPC занимает только положительную половину шкалы.
const auto y = [this, box](double value) { return box.center().y() - (physical(value) - _center) / _half * box.height() / 2; }; // Физическая полушкала применяется после восстановления ампер либо электрического угла.
lines.append(QLineF(x1, y(a), x2, y(b))); if (npc_pair) lines.append(QLineF(x1, y(a - 1), x2, y(b - 1))); // Разрывы и импульсные фронты передаются непосредственно исходными сегментами.
} // Дополнительное сглаживание сигнала между точками не выполняется.
p.setPen(QPen(colour(_rows[i]), 1.6)); p.drawLines(lines); // Пакетный вывод сохраняет тонкие пульсации каждого фазного тока.
} // Другие анализаторы текущего захвата не запрашиваются и не перерисовываются этим окном.
p.restore(); setProperty("scope_segments", qulonglong(total)); if (!total) { p.setPen(text); p.drawText(box, Qt::AlignCenter, QString::fromUtf8("Нет данных в выбранном интервале")); } // Пустой участок либо повторный анализ отображается явно и безопасно.
} // Исходный график и его временной масштаб остаются неизменными.
void wheelEvent(QWheelEvent *event) override { // Увеличение привязано к месту интересующего перехода под курсором.
if (!plot().contains(event->position())) { event->ignore(); return; } // Колесо над панелью сохраняет обычное поведение её элементов управления.
const double fraction = (event->position().x() - plot().left()) / plot().width(), anchor = _start + fraction * _span; // Опорная временная точка должна остаться на том же месте экрана.
_span = std::max(2.0, std::min(count(), _span / std::pow(1.25, event->angleDelta().y() / 120.0))); _start = anchor - fraction * _span; bound(); event->accept(); // Один обычный шаг колеса изменяет масштаб в 1,25 раза; 120 единиц соответствуют одному шагу Qt.
} // В основном окне масштабирование не вызывается.
void mousePressEvent(QMouseEvent *event) override { // Начало прокрутки фиксируется только для выбранной осциллограммы.
if (event->button() == Qt::LeftButton && plot().contains(event->pos())) { _dragging = true; _drag_point = event->pos(); _drag_start = _start; setCursor(Qt::ClosedHandCursor); event->accept(); } // Перетаскивание сохраняет временную точку, захваченную пользователем.
} // Кнопки панели продолжают получать свои обычные события.
void mouseMoveEvent(QMouseEvent *event) override { // Смещение мыши пересчитывается в отсчёты текущего увеличенного интервала.
if (_dragging) { _start = _drag_start - (event->pos().x() - _drag_point.x()) / plot().width() * _span; bound(); event->accept(); } // Перемещение вправо показывает более ранние данные, как в обычном осциллографе.
} // Ограничение bound не позволяет уйти за границы исходного захвата.
void mouseReleaseEvent(QMouseEvent *event) override { // Отпускание кнопки завершает жест без инерции и без изменения исходной записи.
if (event->button() == Qt::LeftButton) { _dragging = false; unsetCursor(); event->accept(); } // Следующее движение без нажатой кнопки не прокручивает график.
} // Виджет не захватывает мышь после окончания взаимодействия.
}; // Все дочерние элементы и таймер уничтожаются вместе с отдельным осциллографом.
} } // Пространства имён совпадают с остальными представлениями DSView.
#endif // SET_DECODER_SCOPE_H предотвращает повторное определение виджета при включении в нативные проверки.
+103
View File
@@ -5,8 +5,111 @@
#include <QPainter>
#include <QStringList>
#include <cmath>
#include <algorithm> // Двоичный поиск ограничивает обработку видимой областью записи.
#include <cstdint> // Координаты отсчётов хранятся без потери точности в 64 битах.
#include <vector> // Пирамида экстремумов строится постепенно в потоке декодирования.
namespace set_pwm {
struct Line { // Отрезок кривой хранит координаты отсчётов и нормированные значения.
double start, end, a, b; // Время переводится в пиксели только при окончательной отрисовке.
}; // Одна колонка обзорного графика требует не более двух таких отрезков.
class Envelope { // Индекс позволяет рисовать длинную запись за время, зависящее от ширины окна.
struct Sample { // Исходные сегменты сохраняются для точного отображения при увеличении.
uint64_t start, end; // Целые номера отсчётов определяют границы сегмента PWM1.
double a, b; // Нормированные значения на концах сегмента лежат в диапазоне −1…+1.
}; // Сегменты добавляются в хронологическом порядке и могут иметь разрывы.
struct Range { // Узел пирамиды содержит оба экстремума своего диапазона.
double low, high; // Минимум и максимум сохраняют короткие выбросы при уменьшении масштаба.
}; // Усреднение здесь не применяется, поскольку оно скрывало бы пики.
std::vector<Sample> samples; // Полные данные нужны только для видимых сегментов крупного масштаба.
std::vector<std::vector<Range>> levels; // Уровень k объединяет блоки из 2^(k+1) сегментов.
static Range bounds(const Sample &s) { // Линейный сегмент достигает экстремумов на своих концах.
return {std::min(s.a, s.b), std::max(s.a, s.b)}; // Порядок значений не зависит от направления наклона.
} // Промежуточные точки линейного сегмента не расширяют этот диапазон.
static Range merge(Range a, Range b) { // Объединение узлов сохраняет экстремумы обеих частей.
return {std::min(a.low, b.low), std::max(a.high, b.high)}; // Операция применяется при добавлении и запросах.
} // Пирамида не изменяет исходные значения PWM.
Range range(size_t first, size_t last) const { // Полуоткрытый диапазон разбивается на готовые двоичные блоки.
Range result = {1.0, -1.0}; // Обратные границы нейтральны для допустимых значений −1…+1.
size_t level = 0; // Нулевой шаг работает с исходными сегментами.
while (first < last) { // Каждый проход поднимается на один уровень вместо обхода всех периодов.
if (first & 1) { // Левый непарный узел нельзя объединить с соседом за пределами запроса.
result = merge(result, level ? levels[level - 1][first] : bounds(samples[first])); // Готовый узел заменяет целый блок периодов.
++first; // Обработанный узел исключается из оставшегося диапазона.
} // Левая граница теперь выровнена на пару.
if (last & 1) { // Правый непарный узел также обрабатывается отдельно.
--last; // Правая граница полуоткрытого диапазона перемещается к последнему узлу.
result = merge(result, level ? levels[level - 1][last] : bounds(samples[last])); // Сохраняются оба экстремума правого блока.
} // После обработки края оставшиеся узлы можно объединить попарно.
first /= 2; // Индекс левой границы переводится на уровень родителей.
last /= 2; // Правая граница переводится на тот же уровень.
++level; // Следующий проход использует вдвое более крупные блоки.
} // Запрос требует лишь логарифмического числа объединений.
return result; // Диапазон не зависит от количества скрытых за пикселем периодов.
} // Вызывающая сторона передаёт только непустые диапазоны.
static double value(const Sample &s, double time) { // Значение на границе пикселя получается линейной интерполяцией.
return s.a + (s.b - s.a) * ((time - s.start) / (s.end - s.start)); // Длительность положительна благодаря проверке append.
} // Обрезка не переносит экстремум соседнего пикселя в текущий.
public: // Потокобезопасность обеспечивает существующая блокировка RowData.
void clear() { // Повторное декодирование должно удалить и сегменты, и индекс предыдущей записи.
samples.clear(); // Старые координаты больше не участвуют в поиске.
levels.clear(); // Старые экстремумы не должны попадать в новый график.
} // Память исходных сегментов может повторно использоваться в следующем захвате.
void append(uint64_t start, uint64_t end, double a, double b) { // Один PWM1-сегмент индексируется сразу при поступлении аннотации.
if (end <= start || (!samples.empty() && start < samples.back().end)) return; // Пустые и перекрывающиеся сегменты нарушили бы двоичный поиск.
samples.push_back({start, end, a, b}); // Точные значения остаются доступны при любом увеличении.
size_t index = samples.size() - 1; // Пересчитывается только путь нового сегмента к корню.
Range current = bounds(samples.back()); // Начальный диапазон содержит концы нового сегмента.
for (size_t level = 0; index || level < levels.size(); ++level) { // Рост дерева требует O(log N) действий на сегмент.
if (level == levels.size()) levels.emplace_back(); // Новый уровень появляется только при увеличении высоты дерева.
if (index & 1) current = merge(level ? levels[level - 1][index - 1] : bounds(samples[index - 1]), current); // Правый потомок объединяется с уже готовым левым.
index /= 2; // Переходим к индексу родительского узла.
if (index == levels[level].size()) levels[level].push_back(current); // Новый блок добавляется без перестройки предыдущих блоков.
else levels[level][index] = current; // Незавершённый блок обновляется после появления правого потомка.
} // Остальные узлы пирамиды остаются неизменными.
} // Индексация выполняется в рабочем потоке, а не во время перерисовки интерфейса.
void lines(std::vector<Line> &dest, uint64_t start, uint64_t end, size_t pixels) const { // Выборка возвращает геометрию только для видимой области.
if (samples.empty() || end <= start || !pixels) return; // Пустая область не требует вычислений или рисования.
const auto first = std::upper_bound(samples.begin(), samples.end(), start, [](double t, const Sample &s) { return t < s.end; }); // Исключаем сегменты, завершившиеся слева от окна.
const auto last = std::lower_bound(first, samples.end(), end, [](const Sample &s, double t) { return s.start < t; }); // Исключаем сегменты, начинающиеся справа от окна.
if (size_t(last - first) <= pixels * 2) { // При двух сегментах на пиксель точная геометрия уже достаточно мала.
for (auto i = first; i != last; ++i) { // Крупный масштаб сохраняет исходные отрезки и мгновенные переходы между смежными интервалами.
if (i != samples.begin() && i->start >= start) { // Предыдущий сегмент нужен также при сбросе θ точно на левой границе видимого окна.
const auto previous = i - 1; // Соседний сегмент содержит уровень непосредственно перед текущим интервалом.
if (previous->end == i->start && previous->b != i->a) dest.push_back({double(i->start), double(i->start), previous->b, i->a}); // Совпадающие границы означают вертикальный фронт, включая 360°↔0°; через отсутствие данных соединение не проводится.
} // Реальные промежутки без измерений остаются разрывами графика.
dest.push_back({double(i->start), double(i->end), i->a, i->b}); // Наклон и значения самого измеренного сегмента сохраняются без изменений.
} // Дополнительная вертикаль не расширяет временной диапазон и не создаёт ложного наклонного участка θ.
return; // Пирамида нужна только для плотного обзорного графика.
} // Для плотной записи число отрезков ограничивается шириной окна.
double previous_time = -1, previous_x = 0, previous_value = 0; // Состояние соединяет соседние непустые колонки, сохраняя разрывы.
for (size_t pixel = 0; pixel < pixels; ++pixel) { // Одна итерация соответствует одной экранной колонке.
const double left = start + double(end - start) * pixel / pixels; // Левая граница колонки выражена в отсчётах.
const double right = start + double(end - start) * (pixel + 1) / pixels; // Правая граница не накапливает ошибку округления.
const auto begin = std::upper_bound(first, last, left, [](double t, const Sample &s) { return t < s.end; }); // Двоичный поиск пропускает невидимые периоды.
const auto finish = std::lower_bound(begin, last, right, [](const Sample &s, double t) { return s.start < t; }); // В колонку входят только пересекающие её сегменты.
if (begin == finish) { previous_time = -1; continue; } // Пустая колонка разрывает кривую вместо выдуманного соединения.
const double begin_time = std::max(left, double(begin->start)); // Первый сегмент обрезается по реальному началу данных.
const double finish_time = std::min(right, double((finish - 1)->end)); // Последний сегмент обрезается по реальному концу данных.
const double a = value(*begin, begin_time), b = value(*(finish - 1), finish_time); // Концы нужны для соединения соседних колонок.
Range extent = {std::min(a, b), std::max(a, b)}; // Начальные экстремумы учитывают обрезанные границы колонки.
if (finish - begin > 1) { // Внутренние концы крайних сегментов тоже могут содержать экстремумы.
extent = merge(extent, {std::min(begin->b, (finish - 1)->a), std::max(begin->b, (finish - 1)->a)}); // Значения за внешними границами колонки не включаются.
if (finish - begin > 2) extent = merge(extent, range(size_t(begin - samples.begin() + 1), size_t(finish - samples.begin() - 1))); // Пирамида заменяет обход всех внутренних периодов.
} // Все экстремумы текущей колонки теперь учтены.
const double x = (left + right) / 2; // Обзорная вертикаль располагается в центре соответствующего пикселя.
if (previous_time == begin_time) dest.push_back({previous_x, x, previous_value, a}); // Соединяем только непрерывные данные соседних колонок.
dest.push_back({x, x, extent.low, extent.high}); // Вертикальный отрезок сохраняет минимум и максимум даже одиночного выброса.
previous_time = finish_time; // Следующая колонка проверит непрерывность по границе данных.
previous_x = x; // Координата предыдущей колонки нужна для соединительного отрезка.
previous_value = b; // Сохраняется последнее измеренное значение текущей колонки.
} // На экран выдаётся не более двух отрезков на пиксель независимо от длительности записи.
} // Полные аннотации при этом остаются доступны таблице, экспорту и увеличению масштаба.
}; // Индекс принадлежит строке аннотаций и освобождается вместе с ней.
inline bool segment(const QString &text, double &a, double &b)
{
const QStringList fields = text.split(' ');
+29
View File
@@ -0,0 +1,29 @@
#pragma once // Единственное определение переносимой модели при включении в несколько компонентов DSView.
#include <algorithm> // Перестановка границ и сравнение напряжений нужны для точной огибающей.
#include <cmath> // Синус и операции над фазой повторяют идеальный источник осциллографа Zynq.
namespace set_scope { // Модель не зависит от Qt, аппаратного захвата и состояния цифровых каналов.
static const double hz[] = {1000.0, 500.3, 1999.7, 750.2}; // Частоты CH1…CH4 в Гц перенесены из zynq/demo/hdmi_usb_menu/scope_signal.h.
static const double peak[] = {0.80, 0.60, 0.70, 0.60}; // Амплитуды в вольтах взяты из scope_peak_v того же демонстрационного источника Zynq.
static const unsigned rgb[] = {0xf9ce4b, 0x33d2ed, 0x72e08a, 0xdb86ee}; // Цвета RGB888 сохраняют соответствие каналам осциллографа Zynq.
static const double tau = 6.2831853071795864769; // Полный оборот 2π в радианах переводит фазу периода в аргумент синуса.
inline double value(unsigned channel, double seconds) { // Напряжение рассчитывается по абсолютному времени общей записи, выраженному в секундах.
if (channel >= 4 || !std::isfinite(seconds)) return 0; // Неверный канал либо время не должны приводить к чтению за пределами таблиц.
const double cycles = seconds * hz[channel], phase = cycles - std::floor(cycles); // Фаза остаётся в диапазоне 0…1 также для отрицательного времени.
return peak[channel] * ((channel & 1) ? (phase < 0.5 ? 1.0 : -1.0) : std::sin(tau * phase)); // CH1/CH3 дают синус, CH2/CH4 — меандр с заполнением 50%, как после scope_init Zynq.
} // Расчёт не читает АЦП и не выдаёт демонстрационный сигнал за измеренный.
struct Range { double low, high; }; // Минимум и максимум одного временного интервала задаются в вольтах.
inline Range extrema(unsigned channel, double first, double last) { // Аналитическая огибающая сохраняет быстрые колебания при уменьшении масштаба.
if (channel >= 4 || !std::isfinite(first) || !std::isfinite(last)) return {0, 0}; // Повреждённые параметры не поступают в вычисления фазы.
if (first > last) std::swap(first, last); // Обратное направление просмотра не меняет физический диапазон напряжения.
const double a = value(channel, first), b = value(channel, last); // Значения концов учитываются даже у интервала короче одного периода.
Range result = {std::min(a, b), std::max(a, b)}; // Начальный диапазон расширяется только при наличии внутреннего экстремума.
const double x = first * hz[channel], z = last * hz[channel]; // Время переводится в периоды соответствующего канала.
if (channel & 1) { // Для меандра скачок возможен на каждой границе половины периода.
if (std::floor(2 * x) != std::floor(2 * z)) result = {-peak[channel], peak[channel]}; // Наличие фронта включает оба уровня вместо ложной медленной волны.
} else { // Для синуса внутренние экстремумы приходятся на четверть и три четверти периода.
if (std::ceil(x - 0.25) <= std::floor(z - 0.25)) result.high = peak[channel]; // Фаза 1/4 периода задаёт положительный пик в вольтах.
if (std::ceil(x - 0.75) <= std::floor(z - 0.75)) result.low = -peak[channel]; // Фаза 3/4 периода задаёт отрицательный пик в вольтах.
} // Для длинного интервала расчёт остаётся постоянным по времени и не перебирает все периоды.
return result; // Оба экстремума используются при рисовании одного экранного пикселя.
} // Модель повторяет scope_value_v и scope_interval_extrema исходного проекта Zynq.
} // Пространство имён исключает совпадения с функциями аппаратного осциллографа DSView.
+55
View File
@@ -0,0 +1,55 @@
#pragma once // Заголовочная реализация добавляет дорожку без отдельного списка исходников CMake и генерации moc.
#include "trace.h" // Базовая дорожка обеспечивает выбор, название, цвет и размещение на общей временной шкале.
#include "view.h" // Масштаб и смещение берутся из того же View, который рисует цифровые каналы.
#include "scope_demo.h" // Переносимая модель повторяет демонстрационные сигналы осциллографа Zynq.
#include <QPainterPath> // Непрерывная кривая передаётся Qt одним пакетом вместо вызова для каждого отсчёта.
namespace pv { namespace view { // Дорожка принадлежит тому же пространству имён, что остальные графики DSView.
class ScopeTrace : public Trace { // Виртуальный осциллограф дополняет цифровую сессию без подмены устройства и его каналов.
unsigned _channel; // Индекс 0…3 выбирает частоту, амплитуду, форму и цвет исходного эмулятора Zynq.
bool _active = false; // Канал появляется только после явного выбора в меню осциллографа.
public: // Управление доступно только владельцу View и общему рендереру дорожек.
enum { Type = 100 }; // Локальный тип отделён от SR_CHANNEL_* библиотеки, чтобы аппаратные приведения типов не затрагивали эмулятор.
explicit ScopeTrace(unsigned channel) : Trace(QString::fromUtf8("A%1 · Zynq DEMO").arg(channel + 1), channel, Type), _channel(channel) { // Номер A1…A4 явно отличает эмуляцию от входов D0…D15.
_colour = QColor::fromRgb(set_scope::rgb[channel]); // Цвет трассы совпадает с исходным каналом Zynq.
_v_offset = 0; // До первой компоновки координата определена и не участвует в переполнении сортировки.
} // Родитель QObject назначается владельцем после создания дорожки.
bool enabled() override { return _active; } // Общий рендерер обрабатывает только выбранные каналы.
void set_active(bool active) { _active = active; } // Повторный выбор скрывает ту же дорожку, сохраняя её имя и цвет.
int get_zero_vpos() override { return get_y(); } // Маркер нуля следует за дорожкой при вертикальной прокрутке цифровой записи.
int rows_size() override { return 6; } // Шесть обычных строк дают начальную высоту 120 пикселей при масштабе 1X DSView.
QRect get_view_rect() override { return QRect(0, get_y() - _totalHeight / 2, _view->get_view_width(), _totalHeight); } // Область графика учитывает прокрутку и реальную высоту дорожки.
void paint_back(QPainter &, int, int, QColor, QColor) override {} // Сетка рисуется вместе с кривой и не меняет общий фон цифровых дорожек.
void paint_mid(QPainter &, int, int, QColor, QColor) override {} // Эмулятор рисуется передним слоем также до первого аппаратного захвата.
void paint_fore(QPainter &p, int left, int right, QColor fore, QColor) override { // Общий слой Viewport обеспечивает показ эмуляции при пустой или остановленной записи.
if (!_active || !_view || right <= left || !std::isfinite(_view->scale()) || _view->scale() <= 0) return; // Неполная геометрия при запуске и изменении размера безопасно пропускается.
const QRect area = get_view_rect(); // Вертикальные границы принадлежат исключительно этой дорожке.
const double top = area.top() + 22, bottom = area.bottom() - 12, zero = (top + bottom) / 2, gain = (bottom - top) / 2; // Верхние 22 и нижние 12 пикселей оставляют место подписи; полный диапазон равен −1…+1 В.
if (gain <= 0) return; // Сжатая до нуля дорожка не создаёт отрицательный масштаб напряжения.
p.save(); // Перо, сглаживание и область отсечения других каналов сохраняются.
p.setClipRect(area.intersected(QRect(left, area.top(), right - left, area.height()))); // Кривая не перекрывает соседние сигналы и заголовки.
p.setPen(QPen(fore, 1, Qt::DotLine)); // Пунктирная сетка имеет постоянную толщину одного экранного пикселя.
for (int division = -4; division <= 4; ++division) p.drawLine(QPointF(left, zero - gain * division / 4), QPointF(right, zero - gain * division / 4)); // Восемь делений охватывают 2 В, то есть 0,25 В/дел.
p.drawText(QPointF(left + 4, top - 5), QString::fromUtf8("Zynq DEMO · %1 · %2 Гц · ±%3 В · 0,25 В/дел").arg((_channel & 1) ? QString::fromUtf8("меандр") : QString::fromUtf8("синус")).arg(set_scope::hz[_channel]).arg(set_scope::peak[_channel])); // Единицы и источник остаются видимыми рядом с осциллограммой.
p.drawText(QPointF(left + 4, zero - 3), QString::fromUtf8("0 В")); // Нулевой уровень исключает неоднозначность вертикального положения сигнала.
p.setPen(QPen(_colour, 1.5)); // Толщина в экранных пикселях и цвет сохраняются при масштабировании времени.
p.setBrush(Qt::NoBrush); // Наследованная кисть маркеров не должна заливать область между началом и концом осциллограммы.
p.setRenderHint(QPainter::Antialiasing, true); // Сглаживание применяется к геометрии, ограниченной шириной экрана.
QPainterPath path; // Путь содержит не более нескольких точек на экранный пиксель независимо от длины записи.
const double scale = _view->scale(), offset = _view->offset(); // Секунды на пиксель и смещение в пикселях совпадают с цифровыми сигналами DSView.
const bool envelope = scale * set_scope::hz[_channel] >= 0.25; // При четверти периода на пиксель и плотнее рисуем точную огибающую, предотвращая алиасинг.
for (int x = left; x <= right; ++x) { // Линейная сложность по ширине окна не зависит от частоты эмулятора или длительности захвата.
const double first = (x + offset) * scale, last = first + scale; // Абсолютное время сохраняет фазу при прокрутке и увеличении записи.
if (envelope) { // Каждый пиксель представляет диапазон напряжений за соответствующий интервал времени.
const auto range = set_scope::extrema(_channel, first, last); // Аналитический расчёт сохраняет оба пика даже при множестве периодов внутри пикселя.
path.moveTo(x, zero - range.low * gain); // Нижний экстремум переводится из вольт в вертикальные экранные координаты.
path.lineTo(x, zero - range.high * gain); // Верхний экстремум завершает вертикальный отрезок огибающей.
} else { // При увеличении масштаба показываем форму исходного синуса или меандра.
const double y = zero - set_scope::value(_channel, first) * gain; // Напряжение рассчитывается в тот же момент времени, что и цифровая дорожка.
if (x == left) path.moveTo(x, y); else path.lineTo(x, y); // Последовательные точки образуют непрерывную осциллограмму без соединения разных дорожек.
} // Значение фазы зависит только от времени записи, поэтому перерисовка не запускает генератор заново.
} // Объём временной геометрии остаётся ограниченным даже у часового захвата.
p.drawPath(path); // Одним вызовом выводим готовую кривую или огибающую.
p.restore(); // Последующие каналы получают исходные настройки QPainter.
} // Эмуляция не записывается в аппаратные данные и не меняет результаты декодеров.
}; // Жизненным циклом дорожки управляет родительский View через QObject.
} } // Завершаем пространства имён компонентов отображения DSView.
+26 -2
View File
@@ -1,6 +1,30 @@
{
"adapters": ["gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart", "tms_uart", "plates_pbus", "plates_sbus", "pwm_waveform"],
"cores": ["__init__", "gate_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart", "template_protocols", "tms_uart", "pwm_waveform"],
"adapters": [
"gate_driver_timing",
"transistor_pair",
"set_uart",
"set_can",
"pm35_uart",
"tms_uart",
"plates_pbus",
"plates_sbus",
"pwm_waveform",
"sigma_delta",
"pwm_motor"
],
"cores": [
"__init__",
"gate_timing",
"transistor_pair",
"set_uart",
"set_can",
"pm35_uart",
"template_protocols",
"tms_uart",
"pwm_waveform",
"sigma_delta",
"pwm_motor"
],
"template_files": [
"protocan/__init__.py",
"protocan/native.py",
+248 -39
View File
@@ -4,56 +4,135 @@
#include <QScrollArea>
#include <QApplication>
#include "decodetrace.h"
+#include "pwm_waveform.h"
+#include "pwm_waveform.h" // Индекс экстремумов сохраняет форму PWM при любом масштабе времени.
#include "../sigsession.h"
#include "../data/decoderstack.h"
#include "../data/decode/decoder.h"
@@ -269,5 +270,35 @@
@@ -212,6 +213,14 @@
}
}
+static bool pwm_combined(pv::data::decode::Decoder *decoder) // Режим берётся из штатных настроек конкретного экземпляра декодера.
+{ // Старые записи без новой опции должны открываться с прежними отдельными графиками.
+ if (QString::fromUtf8(decoder->decoder()->id) != "pwm_waveform") return false; // Настройка PWM не меняет компоновку остальных анализаторов.
+ const auto option = decoder->options().find("waveform_layout"); // Ищем сохранённый параметр без добавления отсутствующего ключа в карту.
+ return option != decoder->options().end() && option->second && g_variant_is_of_type(option->second, G_VARIANT_TYPE_STRING) // Проверяем тип перед чтением, чтобы повреждённая настройка не завершила приложение.
+ && QString::fromUtf8(g_variant_get_string(option->second, nullptr)) == "combined"; // Только явно выбранный combined включает наложение цветных кривых.
+} // Отсутствующее или неизвестное значение безопасно оставляет режим split.
+
void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColor back)
{
using namespace pv::data::decode;
@@ -261,6 +270,9 @@
for(auto dec :_decoder_stack->stack()) {
if (dec->shown()) {
+ const bool combined = pwm_combined(dec); // Каждый экземпляр PWM может независимо отображаться совместно или раздельно.
+ int combined_y = y; // Общая область начинается на месте первой видимой кривой этого экземпляра.
+ bool wave_shown = false; // Скрытые и ещё не заполненные строки не должны резервировать высоту общего графика.
const std::map<const pv::data::decode::Row, bool> rows = _decoder_stack->get_rows_gshow();
for (std::map<const pv::data::decode::Row, bool>::const_iterator i = rows.begin();
i != rows.end(); i++) {
@@ -268,6 +280,70 @@
_decoder_stack->has_annotations((*i).first)) {
if ((*i).second) {
const Row &row = (*i).first;
+ // A PWM envelope stays a curve even when carrier periods
+ // are narrower than a pixel (normal annotation fallback
+ // would replace the entire sine with a solid box).
+ if (QString::fromUtf8(dec->decoder()->id) == "pwm_waveform"
+ && row.row() && QString::fromUtf8(row.row()->id).startsWith("wave_")) {
+ std::vector<Annotation*> points;
+ _decoder_stack->get_annotation_subset(points, row, start_sample, end_sample);
+ p.save();
+ const int wave_y = y + annotation_height;
+ p.setClipRect(QRectF(left, y - annotation_height * 0.5,
+ right - left, annotation_height * 3));
+ p.setPen(QPen(fore, 1, Qt::DotLine));
+ p.drawLine(QPointF(left, wave_y), QPointF(right, wave_y));
+ p.setRenderHint(QPainter::Antialiasing, true);
+ for (Annotation *a : points) {
+ if (a->annotations().empty()) continue;
+ const QColor colors[] = {QColor(80, 210, 255), QColor(255, 194, 70), QColor(145, 230, 100)};
+ p.setPen(QPen(colors[a->format() % 3], 1.6));
+ set_pwm::draw(p, a->annotations().back(),
+ a->start_sample() / samples_per_pixel - pixels_offset,
+ a->end_sample() / samples_per_pixel - pixels_offset,
+ wave_y, annotation_height * 1.20);
+ }
+ p.restore();
+ y += annotation_height * 3;
+ _cur_row_headings.push_back(row.title());
+ _cur_row_headings.push_back(QString());
+ _cur_row_headings.push_back(QString());
+ continue;
+ }
+ // Для обзорного масштаба берём индексированные экстремумы, сохраняя кривую и короткие выбросы.
+ if (QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" // Специальная геометрия применяется только к декодеру восстановления ШИМ.
+ && row.row() && QString::fromUtf8(row.row()->id).startsWith("wave_")) { // Числовые строки duty продолжают использовать штатные аннотации.
+ std::vector<set_pwm::Line> points; // Геометрия ограничивается шириной видимой области вместо числа периодов во всей записи.
+ _decoder_stack->get_pwm_waveform(points, row, start_sample, end_sample, std::max(1, right - left)); // Индекс экстремумов сохраняет короткие пики при уменьшении масштаба.
+ const QString row_id = QString::fromUtf8(row.row()->id); // Идентификатор отделяет измеренные фазы от дополнительных расчётных графиков.
+ const bool phase_wave = row_id == "wave_a" || row_id == "wave_b" || row_id == "wave_c"; // θ и несущая не должны совмещаться с фазами даже при общей компоновке A/B/C.
+ const bool row_combined = combined && phase_wave; // Наложение применяется только к основным трём осциллограммам.
+ const bool allocate = !row_combined || !wave_shown; // Каждому дополнительному расчётному графику всегда выделяется собственная область.
+ if (allocate) combined_y = y; // Раздельный режим начинает новую область для каждого канала.
+ const int wave_top = combined_y; // Следующие совмещённые фазы используют ту же вертикальную систему координат.
+ p.save(); // Локальные настройки пера и отсечения не затрагивают остальные строки анализатора.
+ const int wave_y = wave_top + annotation_height; // Нулевая линия располагается в середине собственной либо общей трёхстрочной области PWM.
+ p.setClipRect(QRectF(left, wave_top - annotation_height * 0.5, right - left, annotation_height * 3)); // Совмещённые фазы получают одинаковую область отсечения.
+ p.setPen(QPen(fore, 1, Qt::DotLine)); // Пунктирная линия задаёт уровень заполнения 50 процентов.
+ if (allocate) p.drawLine(QPointF(left, wave_y), QPointF(right, wave_y)); // Общая нулевая линия рисуется один раз и не перекрывает уже выведенные фазы.
+ p.setRenderHint(QPainter::Antialiasing, true); // Сглаживание применяется уже к ограниченному числу отрезков.
+ QColor curve_colour = fore; // Общие θ и несущая сохраняют нейтральное оформление, поскольку не относятся к отдельной фазе.
+ if (phase_wave) { // Цвет фазной кривой определяется назначенным входом A, B или C, а не порядком физических каналов.
+ const QString input_id = row_id.right(1) + (QString::fromUtf8(dec->decoder()->id) == "pwm_motor" ? QStringLiteral("_p") : QString()); // PWM waveform использует входы a/b/c, а модель токов двигателя — a_p/b_p/c_p; оба варианта связывают фазу с её основной верхней командой.
+ for (const auto *probe : dec->binded_probe_list()) { // Рассматриваем реальные назначения входов из сохранённых настроек анализатора.
+ if (QString::fromUtf8(probe->id) != input_id) continue; // Дополнительные затворы NPC и входы других фаз не задают цвет текущей кривой.
+ const int physical_index = dec->binded_probe_index(probe); // Индекс физического канала учитывает произвольное назначение A/B/C на входы прибора.
+ for (auto *signal : _session->get_signals()) { // Читаем актуальные цвета цифровых дорожек при каждой перерисовке, без повторного декодирования.
+ if (signal->signal_type() == SR_CHANNEL_LOGIC && signal->get_index() == physical_index) { // Аналоговые сигналы с тем же номером не являются исходным цифровым входом фазы.
+ curve_colour = signal->get_colour().isValid() ? signal->get_colour() : fore; // Повторяем правило цифровой дорожки: явный цвет входа либо цвет текста темы, если пользовательский цвет ещё не задан.
+ break; // Назначению соответствует только одна цифровая дорожка.
+ } // При отсутствии входной дорожки остаётся безопасный нейтральный цвет.
+ } // Изменения цвета входа сразу отражаются на обеих связанных дорожках.
+ break; // Другие назначенные входы больше не влияют на найденную фазу.
+ } // Поиск выполняется один раз на строку графика, а не на каждую аннотацию или отсчёт.
+ } // Режимы огибающей, коммутаций и совмещённых фаз используют одно правило цвета.
+ p.setPen(QPen(curve_colour, phase_wave ? 1.6 : 1.0)); // Толщина фазных и общих расчётных графиков сохраняется; меняется только согласование цвета с входом.
+ const auto topology = dec->options().find("input_mode"); // Схема NPC требует двух смещённых треугольников вместо одного двухуровневого.
+ const bool npc_carrier = row_id == "wave_carrier" && topology != dec->options().end() && topology->second && g_variant_is_of_type(topology->second, G_VARIANT_TYPE_STRING) && QString::fromUtf8(g_variant_get_string(topology->second, nullptr)) == "npc-three-level"; // Старые записи и обычный ШИМ сохраняют один треугольник −1…+1.
+ QVector<QLineF> lines; // Qt получает готовую геометрию одним пакетным вызовом.
+ lines.reserve(int(points.size())); // Единственное выделение памяти исключает рост буфера при заполнении.
+ for (const auto &point : points) { // В плотном масштабе здесь не более двух отрезков на экранный пиксель.
+ const double x1 = point.start / samples_per_pixel - pixels_offset, x2 = point.end / samples_per_pixel - pixels_offset; // Обе смещённые несущие используют одинаковые времена начала и конца сегмента.
+ const double a = npc_carrier ? (point.a + 1) / 2 : point.a, b = npc_carrier ? (point.b + 1) / 2 : point.b; // Верхний треугольник NPC занимает диапазон 0…+1, остальные кривые сохраняют исходную нормировку.
+ lines.append(QLineF(x1, wave_y - a * annotation_height * 1.20, x2, wave_y - b * annotation_height * 1.20)); // Основная геометрия использует прежнюю общую шкалу времени и высоту графика.
+ if (npc_carrier) lines.append(QLineF(x1, wave_y - (a - 1) * annotation_height * 1.20, x2, wave_y - (b - 1) * annotation_height * 1.20)); // Нижний синфазный треугольник NPC смещён на единицу в диапазон −1…0.
+ } // Полные исходные сегменты автоматически возвращаются при увеличении масштаба.
+ p.drawLines(lines); // Пакетная отрисовка не создаёт отдельный вызов QPainter на каждый период длинной записи.
+ p.restore(); // Цвет, сглаживание и отсечение не переходят на числовые строки.
+ if (allocate) { // Число заголовков и вертикальный шаг должны совпадать с rows_size().
+ y += annotation_height * 3; // Область PWM занимает три высоты строки независимо от количества совмещённых фаз.
+ QString title = row.title(); // Раздельный график сохраняет прежнее название своего канала.
+ if (row_combined) { // Общая подпись перечисляет только основные видимые фазы, сохраняя собственные шкалы θ и несущей.
+ QStringList channels; // Список нужен для записи с одним, двумя или тремя видимыми входами.
+ for (const auto &entry : rows) { // Порядок совпадает с порядком строк и цветами A, B, C.
+ if (entry.first.decoder() == dec->decoder() && entry.second && entry.first.row() // Не включаем скрытые строки и строки других экземпляров стека.
+ && QString::fromUtf8(entry.first.row()->id).size() == 6 && QString::fromUtf8(entry.first.row()->id).startsWith("wave_") && _decoder_stack->has_annotations(entry.first)) // Из общей подписи исключены неподключённые фазы и дополнительные расчётные графики.
+ channels.append(QString::fromUtf8(entry.first.row()->id).right(1).toUpper()); // Суффикс wave_a…wave_c даёт обозначение фазы без разбора текста аннотаций.
+ } // Полученный список соответствует кривым, которые действительно будут нарисованы.
+ title = QString::fromUtf8(dec->decoder()->name) + ": " + channels.join(" / ") + ": -1 .. +1"; // Общая шкала остаётся нормированной для всех видимых каналов.
+ } // Значения PWM и числовые результаты не изменяются при переключении вида.
+ _cur_row_headings.push_back(title); // Единственный заголовок относится ко всей выделенной области.
+ _cur_row_headings.push_back(QString()); // Вторая строка резервирует место для верхней и нижней полуволн.
+ _cur_row_headings.push_back(QString()); // Третья строка завершает прежнюю высоту области PWM.
+ } // Повторная совмещённая фаза не сдвигает последующие аннотации.
+ wave_shown = wave_shown || phase_wave; // Отдельный θ или треугольник не заменяет первую видимую фазу общей области.
+ continue; // Числовой рендерер не должен рисовать служебный контракт PWM1 поверх кривой.
+ } // Остальные строки проходят штатную отрисовку текстовых аннотаций.
const uint64_t min_annotation =
_decoder_stack->get_min_annotation(row);
@@ -586,7 +617,12 @@
@@ -328,6 +391,7 @@
double samples_per_pixel, double pixels_offset, int y,
size_t base_colour, double min_annWidth, QColor fore, QColor back, double &last_x)
{
+ h = std::min(h, p.fontMetrics().height() + 2); // Ограничиваем фон высотой шрифта плюс два экранных пикселя: по одному сверху и снизу, независимо от растягивания дорожки.
const double start = max(a.start_sample() / samples_per_pixel -
pixels_offset, (double)left);
const double end = min(a.end_sample() / samples_per_pixel -
@@ -579,13 +643,22 @@
for(auto dec : _decoder_stack->stack()) {
if (dec->shown()) {
auto rows = _decoder_stack->get_rows_gshow();
+ const bool combined = pwm_combined(dec); // Раскладка использует ту же настройку, что и отрисовка кривых.
+ bool wave_shown = false; // Общая область учитывается один раз среди фактически видимых строк.
for (auto i = rows.begin(); i != rows.end(); i++) {
pv::data::decode::Row _row = (*i).first;
if (_row.decoder() == dec->decoder() &&
_decoder_stack->has_annotations((*i).first) &&
(*i).second)
+ {
size++;
+ if (QString::fromUtf8(dec->decoder()->id) == "pwm_waveform"
+ && _row.row() && QString::fromUtf8(_row.row()->id).startsWith("wave_"))
+ size += 2;
+ }
- size++;
+ { // Учитываем только заполненные строки, разрешённые в настройках Decoders.
+ const bool waveform = QString::fromUtf8(dec->decoder()->id) == "pwm_waveform" // Особая высота относится только к кривым восстановления ШИМ.
+ && _row.row() && QString::fromUtf8(_row.row()->id).startsWith("wave_"); // Числовые duty/frequency строки сохраняют собственное место.
+ const bool phase_wave = waveform && QString::fromUtf8(_row.row()->id).size() == 6; // Только wave_a…wave_c относятся к общей области фаз; θ и несущая имеют собственную высоту.
+ if (phase_wave && combined && wave_shown) continue; // Следующая совмещённая основная фаза не увеличивает полную высоту дорожки.
+ size += waveform ? 3 : 1; // Кривая занимает три строки, текстовая аннотация — одну.
+ wave_shown = wave_shown || phase_wave; // Дополнительные кривые не влияют на учёт первой основной фазы.
+ } // Число строк совпадает с шагом y и количеством заголовков в paint_mid().
}
}
else {
@@ -68,3 +147,133 @@
for (bool custom : {true, false}) {
for (auto info : _decoderInfoList) {
srd_decoder *dec = (srd_decoder *)(info->_data_handle);
--- a/DSView/pv/data/decode/rowdata.h
+++ b/DSView/pv/data/decode/rowdata.h
@@ -27,6 +27,7 @@
#include <mutex>
#include "annotation.h"
+#include "../../view/pwm_waveform.h" // Индекс PWM хранится рядом с исходными аннотациями и использует ту же блокировку.
namespace pv {
namespace data {
@@ -35,7 +36,7 @@
class RowData
{
public:
- RowData();
+ RowData(bool pwm_waveform = false); // Только строки wave_a…wave_c создают индекс; остальные декодеры сохраняют прежнее поведение.
~RowData();
public:
@@ -60,6 +61,8 @@
void get_annotation_subset(std::vector<pv::data::decode::Annotation*> &dest,
uint64_t start_sample, uint64_t end_sample);
+ void get_pwm_waveform(std::vector<set_pwm::Line> &dest, uint64_t start, uint64_t end, size_t pixels); // Возвращаем ограниченную шириной окна геометрию с обоими экстремумами.
+
void clear();
private:
@@ -68,6 +71,8 @@
uint64_t _item_count;
std::vector<Annotation*> _annotations;
static std::mutex _global_visitor_mutex;
+ bool _pwm_waveform; // Признак кривой исключает разбор текстов обычных протокольных аннотаций.
+ set_pwm::Envelope _pwm_envelope; // Полные сегменты и пирамида экстремумов принадлежат текущей строке.
};
}
--- a/DSView/pv/data/decode/rowdata.cpp
+++ b/DSView/pv/data/decode/rowdata.cpp
@@ -35,11 +35,12 @@
std::mutex RowData::_global_visitor_mutex;
-RowData::RowData() :
+RowData::RowData(bool pwm_waveform) : // Тип строки задаётся один раз при создании таблицы декодера.
_max_annotation(0),
_min_annotation(0)
{
_item_count = 0;
+ _pwm_waveform = pwm_waveform; // Индекс нужен только графическим строкам декодера PWM waveform.
}
RowData::~RowData()
@@ -56,6 +57,7 @@
delete p;
}
_annotations.clear();
+ _pwm_envelope.clear(); // Новое декодирование не должно использовать экстремумы предыдущего захвата.
_item_count = 0;
_min_annotation = 0;
}
@@ -96,6 +98,12 @@
}
}
+void RowData::get_pwm_waveform(std::vector<set_pwm::Line> &dest, uint64_t start, uint64_t end, size_t pixels) // Геометрия строится без полного перебора списка аннотаций.
+{ // Чтение индекса синхронизируется с поступлением сегментов рабочего потока.
+ std::lock_guard<std::mutex> lock(_global_visitor_mutex); // Та же блокировка защищает исходные аннотации и их индекс.
+ _pwm_envelope.lines(dest, start, end, pixels); // Сложность запроса зависит от экранной ширины, а не от длительности захвата.
+} // После возврата интерфейс рисует собственную копию геометрии без блокировки декодирования.
+
uint64_t RowData::get_annotation_index(uint64_t start_sample)
{
std::lock_guard<std::mutex> lock(_global_visitor_mutex);
@@ -118,6 +126,11 @@
try {
_annotations.push_back(a);
+ if (_pwm_waveform && !a->annotations().empty()) { // Только известный контракт PWM1 содержит нормированные концы кривой.
+ double first, last; // Значения разбираются один раз при поступлении вместо каждого кадра интерфейса.
+ if (set_pwm::segment(a->annotations().back(), first, last)) // Проверка контракта исключает повреждённые и нечисловые значения.
+ _pwm_envelope.append(a->start_sample(), a->end_sample(), first, last); // Пирамида экстремумов обновляется постепенно в рабочем потоке.
+ } // Таблица и экспорт по-прежнему получают каждую исходную аннотацию.
_item_count = _annotations.size();
_max_annotation = max(_max_annotation, a->end_sample() - a->start_sample());
--- a/DSView/pv/data/decoderstack.h
+++ b/DSView/pv/data/decoderstack.h
@@ -30,6 +30,7 @@
#include <QString>
#include <mutex>
+#include "../view/pwm_waveform.h" // Общий формат ограниченной геометрии связывает строку данных с нативным представлением.
#include "decode/row.h"
#include "../data/signaldata.h"
#include "decode/decoderstatus.h"
@@ -113,6 +114,8 @@
const decode::Row &row, uint64_t start_sample,
uint64_t end_sample);
+ void get_pwm_waveform(std::vector<set_pwm::Line> &dest, const decode::Row &row, uint64_t start, uint64_t end, size_t pixels); // Запрос видимой кривой не копирует миллионы исходных аннотаций.
+
uint64_t get_annotation_index(
const decode::Row &row, uint64_t start_sample);
uint64_t get_max_annotation(const decode::Row &row);
--- a/DSView/pv/data/decoderstack.cpp
+++ b/DSView/pv/data/decoderstack.cpp
@@ -188,7 +188,8 @@
const Row row(decc, ann_row, order);
// Add a new empty row data object
- _rows[row] = new decode::RowData();
+ const bool waveform = QString::fromUtf8(decc->id) == "pwm_waveform" && QString::fromUtf8(ann_row->id).startsWith("wave_"); // Только графические строки PWM требуют многоуровневого индекса.
+ _rows[row] = new decode::RowData(waveform); // Индекс создаётся вместе со строкой и очищается при повторном анализе.
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
if (iter == _rows_gshow.end()) {
_rows_gshow[row] = true;
@@ -227,6 +228,12 @@
start_sample, end_sample);
}
+
+void DecoderStack::get_pwm_waveform(std::vector<set_pwm::Line> &dest, const decode::Row &row, uint64_t start, uint64_t end, size_t pixels) // Передаём запрос только строке выбранного канала PWM.
+{ // Набор строк стабилен во время декодирования и отрисовки.
+ auto iter = _rows.find(row); // Находим данные по существующему ключу декодера и строки.
+ if (iter != _rows.end()) iter->second->get_pwm_waveform(dest, start, end, pixels); // Отсутствующая строка корректно возвращает пустую кривую.
+} // Вектор содержит независимую геометрию без указателей на изменяемые аннотации.
uint64_t DecoderStack::get_annotation_index(
const Row &row, uint64_t start_sample)
+152
View File
@@ -0,0 +1,152 @@
--- a/DSView/pv/view/view.h
+++ b/DSView/pv/view/view.h
@@ -64,4 +64,5 @@
namespace view {
+class ScopeTrace; // Виртуальные аналоговые дорожки принадлежат представлению и не меняют аппаратный захват.
class Header;
class DevMode;
@@ -439,4 +440,6 @@
pv::toolbars::SamplingBar *_sampling_bar;
+ std::vector<ScopeTrace*> _scope_traces; // Четыре демонстрационных канала сохраняют настройки видимости в текущей сессии.
+ QAction *_scope_action = nullptr; // Доступ к меню зависит от цифрового режима текущего прибора.
QWidget *_viewcenter;
ViewStatus *_viewbottom;
--- a/DSView/pv/view/view.cpp
+++ b/DSView/pv/view/view.cpp
@@ -42,4 +42,7 @@
#include "lissajoustrace.h"
#include "analogsignal.h"
+#include "scope_trace.h" // Аналоговая эмуляция Zynq отображается вместе с цифровыми каналами.
+#include <QToolButton> // Меню каналов встраивается в существующую прокручиваемую панель управления.
+#include <QMenu> // Флажки позволяют независимо добавить и убрать каждый канал осциллографа.
#include "../sigsession.h"
@@ -202,4 +205,28 @@
connect(_header, SIGNAL(header_updated()),this, SLOT(header_updated()));
+ _trace_view_map[ScopeTrace::Type] = TIME_VIEW; // Виртуальный осциллограф использует ту же шкалу времени и прокрутку, что цифровые сигналы.
+ auto scope_button = new QToolButton(_sampling_bar); // Кнопка принадлежит штатной панели DSView и работает также без SETGUI.
+ scope_button->setObjectName("scope_channel_button"); // Постоянное имя позволяет проверять настоящий интерфейс в нативном тесте.
+ scope_button->setText(QString::fromUtf8("Осциллограф +")); // Название явно обозначает добавление аналоговой дорожки к существующим сигналам.
+ scope_button->setToolTip(QString::fromUtf8("Добавить канал осциллографа: эмуляция Zynq на общей шкале времени")); // Пользователь видит происхождение данных до включения канала.
+ scope_button->setPopupMode(QToolButton::InstantPopup); // Одно нажатие сразу открывает выбор каналов.
+ auto scope_menu = new QMenu(scope_button); // Меню и его действия удаляются вместе с панелью управления.
+ scope_button->setMenu(scope_menu); // Выбор канала выполняется внутри существующего DSView без отдельного окна генератора.
+ for (unsigned channel = 0; channel < 4; ++channel) { // Эмулятор Zynq предоставляет четыре независимых канала CH1…CH4.
+ auto trace = new ScopeTrace(channel); // Сохраняем точные частоты, формы и амплитуды исходного эмулятора.
+ trace->setParent(this); // QObject освобождает дорожку при уничтожении представления без участия аппаратной сессии.
+ _scope_traces.push_back(trace); // Постоянный объект сохраняет имя, цвет и высоту при скрытии и повторном включении.
+ auto action = scope_menu->addAction(QString::fromUtf8("CH%1 · %2 · %3 Гц · ±%4 В").arg(channel + 1).arg((channel & 1) ? QString::fromUtf8("меандр") : QString::fromUtf8("синус")).arg(set_scope::hz[channel]).arg(set_scope::peak[channel])); // Подпись показывает параметры до добавления канала.
+ action->setObjectName(QString("scope_channel_%1").arg(channel + 1)); // Идентификатор связывает действие с конкретным каналом при проверке интерфейса.
+ action->setCheckable(true); // Флажок одновременно показывает состояние и позволяет убрать дорожку повторным нажатием.
+ connect(action, &QAction::toggled, this, [this, trace](bool active) { // Меняем только выбранный аналоговый канал, сохраняя цифровые сигналы и декодеры.
+ trace->set_active(active); // Источник не запускает отдельный захват и не открывает сетевое соединение.
+ signals_changed(nullptr); // Пересчитываем высоту дорожек и диапазон общей вертикальной прокрутки.
+ set_all_update(true); // Обновляем кэш диаграммы после изменения набора дорожек.
+ viewport_update(); // Новый канал появляется сразу, в том числе до первого аппаратного захвата.
+ }); // Контекст View отключает обработчик при закрытии его окна.
+ } // Каждый канал можно включать независимо от остальных трёх.
+ _scope_action = _sampling_bar->addWidget(scope_button); // Кнопка доступна рядом с обычными настройками захвата, включая отдельный запуск DSView.
+ _scope_action->setEnabled(_device_agent->have_instance() && _device_agent->get_work_mode() == LOGIC); // Добавление доступно для цифровых источников, без вмешательства в штатный DSO.
ADD_UI(this);
}
@@ -393,4 +420,8 @@
traces.push_back(t);
}
+
+ if (_device_agent->get_work_mode() == LOGIC && (type == ALL_VIEW || type == TIME_VIEW)) { // Виртуальные аналоги дополняют только цифровую временную область.
+ for (auto trace : _scope_traces) if (trace->enabled()) traces.push_back(trace); // Скрытые каналы не занимают место и не участвуют в обработке мыши.
+ } // Данные устройства, спектр и аппаратный осциллограф сохраняют собственные списки каналов.
auto lissajous = _session->get_lissajous_trace();
@@ -706,4 +737,5 @@
void View::signals_changed(const Trace* eventTrace)
{
+ if (_scope_action) _scope_action->setEnabled(_device_agent->have_instance() && _device_agent->get_work_mode() == LOGIC); // Меню следует за сменой прибора или режима без удаления выбранных демо-каналов.
double actualMargin = SignalMargin;
int total_rows = 0;
@@ -841,4 +873,9 @@
time_traces.push_back(t);
}
+
+ for (auto trace : _scope_traces) { // Аналоговые дорожки участвуют в общей сортировке после перемещения мышью.
+ if (!trace->enabled()) continue; // Скрытый демо-канал не занимает места в компоновке.
+ if (trace->get_view_index() == -1) time_traces.push_back(trace); else all_traces.push_back(trace); // Новый канал появляется сверху, перемещённый сохраняет выбранный порядок среди цифровых сигналов.
+ } // Переупорядочивание не требует добавлять эмуляцию в аппаратный список каналов.
sort(all_traces.begin(), all_traces.end(), compare_trace_view_index);
--- a/DSView/pv/mainwindow.cpp
+++ b/DSView/pv/mainwindow.cpp
@@ -612,4 +612,38 @@
}
+ void MainWindow::checkHostScope() // Проверяет добавление аналоговых дорожек отдельно от сценариев диалогов декодеров.
+ { // Отдельная точка запуска не требует Ethernet-патча и доступна в базовой сборке DSView.
+ if (qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_SCOPE")) { // Явный проверочный режим использует реальные действия меню, не подключая USB или Ethernet.
+ const auto original_signals = _session->get_signals(); // Копия указателей проверяет сохранность существующей цифровой записи.
+ std::vector<pv::view::Trace*> before; // Список позволяет обнаружить потерю или лишнее добавление любой дорожки.
+ _view->get_traces(ALL_VIEW, before); // Исходное число включает цифровые сигналы и уже добавленные декодеры.
+ const double scale = _view->scale(); // Включение осциллографа обязано сохранить выбранный масштаб времени.
+ QList<QAction*> channels; // Используем те же QAction, которые выбирает пользователь в меню осциллографа.
+ bool passed = true; // Любое несоответствие делает нативный сценарий неуспешным.
+ for (int channel = 1; channel <= 4; ++channel) { // Проверяем независимое включение всех четырёх источников Zynq.
+ auto action = _sampling_bar->findChild<QAction*>(QString("scope_channel_%1").arg(channel)); // Поиск идёт только в панели текущего экземпляра DSView.
+ if (!action) { passed = false; break; } // Отсутствие пользовательского элемента управления не маскируется проверкой только модели.
+ channels.append(action); // Сохраняем реальные действия для проверки удаления и повторного добавления.
+ action->setChecked(true); // Сигнал toggled выполняет производственный путь компоновки и обновления графика.
+ std::vector<pv::view::Trace*> current; // Новый список должен содержать ровно одну дополнительную дорожку на выбранный канал.
+ _view->get_traces(ALL_VIEW, current); // Проверяем состояние того же списка, который рисует Viewport.
+ passed = passed && current.size() == before.size() + channel && _view->scale() == scale; // Добавление не должно терять цифровые каналы или сбрасывать время.
+ } // Все четыре канала одновременно участвуют в итоговой компоновке.
+ for (auto action : channels) action->setChecked(false); // Убираем все добавленные дорожки через пользовательские действия.
+ std::vector<pv::view::Trace*> after; // Повторное чтение обнаруживает оставшиеся скрытые трассы в активном списке.
+ _view->get_traces(ALL_VIEW, after); // Исходная сессия должна восстановиться без перезагрузки захвата.
+ passed = passed && after == before && _session->get_signals() == original_signals; // Сравниваем идентичность объектов, а не только количество каналов.
+ if (channels.size() == 4) { channels[0]->setChecked(true); channels[1]->setChecked(true); } // Итоговый снимок показывает синус и меандр рядом с исходными цифровыми сигналами.
+ _view->set_scale_offset(2e-6, 0); // Две микросекунды на пиксель позволяют визуально проверить форму синуса 1 кГц и меандра 500,3 Гц.
+ QTimer::singleShot(200, this, [this, passed]() { // Даём нативному циклу событий завершить компоновку и перерисовку всех дорожек.
+ const QString screenshot = QString::fromUtf8(qgetenv("DSVIEW_HOST_SCREENSHOT")); // Артефакт сохраняется только по явно указанному тестом пути.
+ const bool rendered = _view->grab().save(screenshot); // Проверяем фактический рендер настоящего Qt-представления, включая цифровые каналы.
+ QFile report(QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK"))); // Тест получает результат через существующий диагностический канал DSView.
+ if (report.open(QIODevice::WriteOnly | QIODevice::Truncate)) report.write(passed && rendered ? "PASS scope add/remove/digital preservation/render\n" : "FAIL scope integration\n"); // Успех требует корректного поведения меню и сохранённого изображения.
+ }); // Захват и дочерний процесс продолжают работать до штатной команды CLOSE от теста.
+ return; // Проверка осциллографа не запускает посторонние сценарии диалогов декодеров.
+ } // В обычном запуске эта ветка полностью отключена.
+ } // В обычном запуске проверочная функция не вызывается.
+
void MainWindow::on_protocol(bool visible)
{
--- a/DSView/pv/view/header.cpp
+++ b/DSView/pv/view/header.cpp
@@ -316,11 +316,5 @@
std::vector<Trace*> traces;
- for (auto s : _view.session().get_decode_signals()){
- traces.push_back(s);
- }
-
- for (auto s : _view.session().get_signals()){
- traces.push_back(s);
- }
+ _view.get_traces(TIME_VIEW, traces); // Индексы перемещения включают также виртуальные аналоговые дорожки, отсутствующие в аппаратной сессии.
sort(traces.begin(), traces.end(), View::compare_trace_y);
--- a/DSView/pv/mainwindow.h
+++ b/DSView/pv/mainwindow.h
@@ -98,4 +98,5 @@
const char *openHostFile(const QString &path);
void checkHostDecoders();
+ void checkHostScope(); // Нативный сценарий проверяет реальные действия меню и сохранность цифровой записи.
public slots:
--- a/DSView/pv/mainframe.cpp
+++ b/DSView/pv/mainframe.cpp
@@ -769,5 +769,5 @@
QFrame::show();
if (!qgetenv("DSVIEW_HOST_CHECK").isEmpty())
- QTimer::singleShot(3500, _mainWindow, &MainWindow::checkHostDecoders);
+ QTimer::singleShot(3500, _mainWindow, qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_SCOPE") ? &MainWindow::checkHostScope : &MainWindow::checkHostDecoders); // Явный параметр выбирает проверку осциллографа, сохраняя прежние сценарии декодеров.
return true;
}
@@ -0,0 +1,44 @@
"""Проверка отдельного окна выбранного графика в настоящем встроенном DSView.""" # Сценарий использует готовую цифровую запись и не подключается к аппаратуре.
import argparse # Проверяемая сборка, запись и каталог результатов задаются явно.
import os # Изолированное окружение предотвращает смешение Qt6 родителя и Qt5 дочернего DSView.
from pathlib import Path # Абсолютные пути не зависят от рабочего каталога проверяемого компонента.
import subprocess # Сценарий управляет только собственным новым процессом DSView.
import time # Монотонные часы ограничивают ожидание нативной проверки и штатного завершения.
def main(): # Нативные действия выполняются встроенным тестовым обработчиком окна DSView.
parser = argparse.ArgumentParser(description=__doc__) # Описание доступно без создания окон через стандартный --help.
parser.add_argument('--runtime', type=Path, required=True) # Каталог должен содержать собранный DSView с decoder-scope.patch.
parser.add_argument('--capture', type=Path, required=True) # Сохранённая DSL-сессия содержит огибающую, коммутации и последний декодер pwm_motor.
parser.add_argument('--output', type=Path, required=True) # Отчёт, журнал и снимок выбранного графика помещаются отдельно от исходных данных.
parser.add_argument('--timeout', type=float, default=45) # Общий предел 45 секунд покрывает загрузку runtime, расчёт и пользовательские взаимодействия.
args = parser.parse_args() # Все обязательные параметры проверяются до запуска приложения.
from PySide6.QtWidgets import QApplication, QWidget # Родительское окно воспроизводит встраивание DSView в SETGUI через настоящий HWND.
runtime, capture, output = args.runtime.resolve(), args.capture.resolve(), args.output.resolve() # Проверка работает и при путях с пробелами без построения shell-команд.
output.mkdir(parents=True, exist_ok=True) # Каталог результатов создаётся в заданном пользователем месте.
report = output / 'decoder-scope.txt'; report.write_text('', encoding='utf-8') # Старый PASS не может скрыть провал нового запуска.
app = QApplication.instance() or QApplication([]); window = QWidget(); window.resize(1500, 1000); window.show() # Высота позволяет выбрать нижний график после цифровых входов NPC.
environment = {key: value for key, value in os.environ.items() if not key.startswith(('PYTHON', 'QT_', 'QML'))} # Нативный компонент загружает только собственные Python и плагины Qt.
environment.update(PATH=str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32'), DSVIEW_HOST_CHECK=str(report), DSVIEW_HOST_CHECK_SCOPE='1', DSVIEW_HOST_CHECK_DECODER_SCOPE='1') # Явные параметры включают только сценарий выбранного декодерного осциллографа.
with (output / 'decoder-scope.log').open('w', encoding='utf-8') as log: # Ошибки импорта и фактические размеры декодирования сохраняются для диагностики.
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--loglevel', '3', '--embed-parent', str(int(window.winId())), str(capture)], cwd=runtime, env=environment, stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) # Отдельный тестовый процесс не изменяет открытую рабочую сессию пользователя.
try: # Все пути завершения освобождают именно созданный этой проверкой процесс.
deadline = time.monotonic() + args.timeout # Ограниченное ожидание предотвращает бесконечную проверку при ошибке создания окна.
while process.poll() is None and not report.read_text(encoding='utf-8') and time.monotonic() < deadline: # Ожидается полный отчёт нативного взаимодействия, а не только открытие файла.
app.processEvents(); time.sleep(0.02) # Короткая пауза сохраняет обработку встраивания и штатных сообщений Qt.
result = report.read_text(encoding='utf-8') # Завершённый отчёт содержит результат выбора, масштаба, прокрутки, повторного открытия и удаления источника.
assert result.startswith('PASS '), result or 'Native scope check timed out' # Тайм-аут и частичный успех не признаются выполнением пользовательского сценария.
assert Path(str(report) + '.png').is_file(), 'No separate scope screenshot' # Проверка обязана сохранить настоящий снимок отдельной осциллограммы.
process.stdin.write(b'CLOSE\n'); process.stdin.flush(); deadline = time.monotonic() + 5 # Штатная команда закрывает только тестовый DSView после завершённых взаимодействий.
while process.poll() is None and time.monotonic() < deadline: # Родитель продолжает обрабатывать закрытие встроенного окна.
app.processEvents(); time.sleep(0.02) # Неблокирующий короткий опрос ограничивает задержку завершения.
assert process.poll() == 0, 'DSView did not close cleanly' # Ненулевой код выявляет повреждение жизненного цикла отдельного или основного окна.
text = (output / 'decoder-scope.log').read_text(encoding='utf-8', errors='replace') # Проверяется завершённый журнал после штатного выхода дочернего процесса.
assert 'Traceback (most recent call last)' not in text and text.count('Decoded sample count:120000') == 3, 'Expected one complete decode of each saved analyzer' # Три анализатора обрабатывают все отсчёты ровно один раз, включая все взаимодействия отдельного окна.
print(result.strip()); print(Path(str(report) + '.png')) # Краткий итог и путь к снимку позволяют быстро проверить результат.
finally: # Неудачная проверка не оставляет собственные окна DSView в фоне.
if process.poll() is None: process.terminate() # Чужие процессы и рабочие захваты пользователя не затрагиваются.
process.wait(timeout=5); window.close() # Освобождение системных дескрипторов завершается за ограниченное время.
if __name__ == '__main__': main() # Прямой запуск выполняет полный пользовательский сценарий в нативном приложении.
+125
View File
@@ -0,0 +1,125 @@
#include <QApplication> // Настоящий цикл Qt выявляет модальные окна и повторный вход при сетевом таймауте.
#include <QAbstractButton> // Нажатие штатной кнопки OK проверяет очистку указателя уведомления.
#include <QLabel> // Проверяем фактическую видимость текста ошибки, а не только существование оболочки уведомления.
#include <QMouseEvent> // Кнопка закрытия получает обычные события мыши после проверки её видимой геометрии.
#include <QStackedWidget> // Две страницы воспроизводят переход из DSView на другую вкладку SETGUI.
#include <QVBoxLayout> // Родительская страница задаёт настоящую область встраивания дочернего окна.
#include <QElapsedTimer> // Монотонное время ограничивает ожидание ответа и проверку отсутствия повторов.
#include <QPointer> // Уведомление может удалиться после OK, поэтому тест не хранит висячий указатель.
#include <QTimer> // Проверки выполняются в GUI-потоке одновременно с настоящим драйвером захвата.
#include "pv/appcontrol.h" // Используем штатную инициализацию libsigrok и сессии DSView.
#include "pv/config/appconfig.h" // Каталог записи направляется в отдельную папку проверки.
#include "pv/log.h" // Инициализация журнала необходима до запуска аппаратного ядра DSView.
#include "pv/mainframe.h" // Проверяем закрытие настоящего главного окна вместе с предупреждением.
#include "pv/sigsession.h" // Режимы Single, Repetitive и Loop запускаются обычным методом сессии.
#include "pv/ui/langresource.h" // Ресурсы сообщений загружаются так же, как в рабочем приложении.
#include <windows.h> // Win32-проверка учитывает скрытие панели другим процессом, которое не меняет внутренний флаг видимости Qt.
int main(int argc, char **argv) { // Исполняемый тест линкуется с теми же объектами, что и DSView, без его main.cpp.
QApplication application(argc, argv); // Цикл событий нужен для обработки сетевого завершения и показа уведомления.
application.setProperty("embeddedHost", true); // Закрытие кадра завершает тест и не записывает пользовательскую геометрию окна.
application.setQuitOnLastWindowClosed(false); // Закрытие отдельного уведомления не должно завершать встроенный DSView.
dsv_log_init(); // Подготавливаем штатную инфраструктуру диагностики до создания устройств.
auto &config = AppConfig::Instance(); // Профиль тестового EXE расположен в отдельном runtime.
config.LoadAll(); // Загружаем обязательные значения оформления и захвата.
config.userHistory.saveDir = QString::fromUtf8(qgetenv("DSVIEW_CAPTURE_DIR")); // Все записи остаются в каталоге артефактов проверки.
config.frameOptions.style = "dark"; // Снимок предупреждения использует тему SETGUI со скриншота ошибки.
LangResource::Instance()->Load(config.frameOptions.language); // Штатные строки интерфейса нужны конструктору главного окна.
auto *control = AppControl::Instance(); // Тест пользуется настоящей сессией, а не подменой алгоритма повтора.
if (!control->Init()) return 2; // Ошибка зависимости отличима от регрессии потери связи.
ds_device_handle handle = 0; // Публичный API возвращает найденный прибор, обслуживаемый локальным UDP-эмулятором.
if (ds_add_zynq_device("127.0.0.1", &handle) != SR_OK) return 3; // Реальная плата и Ethernet-сеть для проверки не нужны.
int result = 1; // Успех фиксируется только после всех трёх обрывов и штатного закрытия.
{ // Главное окно должно уничтожиться до освобождения аппаратного ядра.
pv::MainFrame frame; // Обычный кадр создаёт те же обработчики ошибок и панели, что и DSView.exe.
control->Start(); // Запускаем обработку сообщений сессии после создания главного окна.
auto *session = control->GetSession(); // Публичные методы позволяют запускать реальные захваты без имитации кнопок.
if (!session->set_device(handle)) return 4; // Захват должен относиться к найденному сетевому прибору.
QWidget host; // Отдельное окно моделирует оболочку SETGUI с несколькими страницами.
auto *host_layout = new QVBoxLayout(&host); // Область DSView следует размеру родительского окна.
auto *pages = new QStackedWidget(&host); // Переключение страницы должно скрывать всё содержимое DSView, включая сообщение об ошибке.
auto *dsview_page = new QWidget(pages); // Нативный HWND первой страницы становится родителем настоящего окна DSView.
pages->addWidget(dsview_page); // Первая страница отводится встроенному анализатору.
pages->addWidget(new QLabel(QStringLiteral("Другая вкладка SETGUI"), pages)); // Вторая страница воспроизводит экран Zynq с пользовательского скриншота.
host_layout->addWidget(pages); // Родительский layout задаёт видимые границы страниц.
host.resize(1100, 700); // Размер в логических пикселях оставляет видимыми панели и предупреждение.
host.show(); // Для реальной проверки видимости нужен показанный нативный родитель.
if (!frame.ShowEmbedded(static_cast<quintptr>(dsview_page->winId()))) return 6; // Используем штатное Win32-встраивание, а не отдельное окно DSView.
QTimer timer; // Один периодический таймер отслеживает конечное состояние трёх сценариев.
QElapsedTimer elapsed; // Общий дедлайн исключает зависание CI на регрессии модального окна.
elapsed.start(); // Срок включает таймауты драйвера и ожидание возможного автоповтора.
int scenario = 0; // Сначала Repetitive без данных, затем Repetitive с данными, в конце Loop без данных.
qint64 observed_at = -1; // Отрицательное значение означает, что уведомление текущего захвата ещё не появилось.
QPointer<QWidget> notice; // Отслеживаем стабильность уведомления независимо от реализации его оформления.
auto start = [&]() { // Каждый сценарий начинается явным ручным запуском, как после восстановления линка.
session->set_collect_mode(scenario == 2 ? pv::COLLECT_LOOP : pv::COLLECT_REPEAT); // Повтор и кольцевой режим должны останавливаться при одном обрыве.
session->set_repeat_intvl(0.01); // Интервал 10 мс провоцирует прежнюю гонку между END и обработчиком ошибки GUI.
if (scenario == 1) { // Во втором сценарии проверяем также штатное завершение дисковой записи после обрыва.
const QByteArray path = (config.userHistory.saveDir + "/partial-rj2.dsl").toUtf8(); // Имя относится только к изолированным артефактам текущего запуска.
if (!session->get_device()->set_config_uint64(SR_CONF_ZYNQ_RECORD_SAMPLES, 1000000)) return false; // Миллион отсчётов превышает посланный префикс 65 536 и позволяет дождаться сетевого таймаута.
if (!session->get_device()->set_config_string(SR_CONF_ZYNQ_RECORD_FILE, path.constData())) return false; // Передаём путь тем же публичным API, который вызывает кнопка Run после выбора файла.
} // Остальные сценарии проверяют потерю связи ещё до появления первого отсчёта.
observed_at = -1; // Новый захват должен самостоятельно выдать новое уведомление.
return session->start_capture(false); // Обычный захват использует реальный приём UDP и таймаут драйвера.
}; // Повторный запуск проверяет восстановление после сброса обработанной ошибки.
QObject::connect(&timer, &QTimer::timeout, &frame, [&]() { // Все обращения к виджетам выполняются в их GUI-потоке.
auto fail = [&](const char *reason) { // Ошибка теста выводит причину и прекращает дальнейшие проверки.
fprintf(stderr, "FAIL: %s\n", reason); // Журнал запуска остаётся пригодным для диагностики без скриншота.
application.exit(1); // Даже открытый модальный диалог не должен подвесить завершение теста.
}; // Единственный путь неуспеха сохраняет изначальное значение result.
if (elapsed.elapsed() > 30000) { fail("link loss timeout"); return; } // Предел 30 секунд превышает три аппаратных таймаута и окна наблюдения.
QList<QWidget *> visible; // Скрытые закрывающиеся объекты не считаются пользовательскими уведомлениями.
for (auto *widget : frame.findChildren<QWidget *>(QStringLiteral("networkCaptureError"))) if (widget->isVisible()) visible.append(widget); // Счётчик выявляет несколько предупреждений одной сетевой сессии.
if (visible.isEmpty()) return; // До аппаратного таймаута уведомления быть не должно.
if (visible.size() != 1) { fail("duplicate packet error dialogs"); return; } // Любое второе окно означает возврат исходной ошибки.
if (QApplication::activeModalWidget()) { fail("link error blocks the application"); return; } // Предупреждение должно оставлять доступными отключение и закрытие.
if (session->is_working() || session->get_device()->is_collecting()) { fail("capture restarted after link loss"); return; } // Завершение обязано остановить и сессию, и драйвер.
auto *current = visible.front(); // Геометрию и содержимое проверяем у единственного показанного уведомления.
QLabel *message = nullptr; // Одного заголовка Packet Error недостаточно: пользователь должен видеть причину обрыва.
for (auto *label : current->findChildren<QLabel *>()) if (label->isVisible() && label->text().contains(QStringLiteral("RJ2"))) message = label; // Ищем настоящий отображаемый текст аппаратной диагностики.
if (!message || message->visibleRegion().isEmpty()) { fail("packet error details are invisible"); return; } // Проверка воспроизводит пустую оболочку с пользовательского скриншота.
QAbstractButton *dismiss = nullptr; // Кнопка должна быть доступна мышью, а не только программным вызовом click.
for (auto *button : current->findChildren<QAbstractButton *>()) if (button->isVisible() && button->isEnabled() && !button->visibleRegion().isEmpty()) dismiss = button; // Отбрасываем скрытые кнопки вложенного QMessageBox.
if (!dismiss) { fail("packet error dismiss button is invisible"); return; } // Отсутствующий OK нельзя считать успешной неблокирующей диагностикой.
if (current->isWindow()) { fail("packet error escapes its DSView panel"); return; } // Самостоятельное окно остаётся поверх посторонних вкладок после скрытия нативного родителя.
const QRect bounds(current->mapTo(frame.GetMainWindow(), QPoint()), current->size()); // Сравниваем видимую геометрию в общей системе координат панели DSView.
if (!frame.GetMainWindow()->rect().contains(bounds)) { fail("packet error lies outside the DSView panel"); return; } // Уведомление не должно выходить за границы встроенного анализатора.
if (observed_at < 0) { // Первый тик после обрыва запоминает существующее уведомление.
notice = visible.front(); // Следующие тики проверят, что окно не было заменено новым.
observed_at = elapsed.elapsed(); // От этого момента выдерживаем паузу длиннее таймаута драйвера.
pages->setCurrentIndex(1); // Переходим на соседнюю вкладку, как на втором пользовательском скриншоте.
if (IsWindowVisible(reinterpret_cast<HWND>(current->winId()))) { fail("packet error remains above another tab"); return; } // Проверяем фактическую Win32-видимость после скрытия нативного родителя.
pages->setCurrentIndex(0); // Возврат должен восстановить анализатор и доступную кнопку предупреждения.
if (!IsWindowVisible(reinterpret_cast<HWND>(current->winId()))) { fail("packet error did not return with DSView"); return; } // Уведомление сохраняется до явного закрытия пользователем.
current->grab().save(config.userHistory.saveDir + QStringLiteral("/notice-%1.png").arg(scenario)); // Сохраняем само предупреждение с текстом и кнопкой для визуальной проверки.
for (int queued = 0; queued < 3; ++queued) QMetaObject::invokeMethod(frame.GetMainWindow(), "on_session_error", Qt::QueuedConnection); // Запоздавшие сигналы не должны создавать пустые сообщения Undefined Error.
} // Очередь событий обрабатывается между тиками настоящего Qt.
if (notice != visible.front()) { fail("packet error dialog was replaced"); return; } // Обновление не должно порождать цепочку новых объектов.
if (elapsed.elapsed() - observed_at < 3500) return; // Пауза 3,5 секунды превышает сетевой таймаут и обнаруживает скрытый автоповтор.
fprintf(stdout, "PASS scenario %d: stopped, one nonmodal notice\n", scenario); // Отдельный результат подтверждает каждый вариант обрыва.
fflush(stdout); // Внешний сценарий получает сообщение даже при последующей ошибке завершения.
if (scenario < 2) { // Первые два обрыва проверяют ручной запуск без перезапуска приложения.
QMouseEvent press(QEvent::MouseButtonPress, dismiss->rect().center(), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); // Нажимаем в центре фактически видимой кнопки закрытия.
QApplication::sendEvent(dismiss, &press); // Используем штатный обработчик мыши кнопки, а не прямой вызов click.
QMouseEvent release(QEvent::MouseButtonRelease, dismiss->rect().center(), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); // Отпускание в той же точке завершает пользовательское нажатие.
QApplication::sendEvent(dismiss, &release); // Закрытие предупреждения должно освободить его объект перед следующим обрывом.
++scenario; // Следующий сценарий меняет условия эмулятора по номеру новой UDP-сессии.
if (!start()) fail("manual restart after link loss failed"); // Восстановленный пользовательский запуск не должен блокироваться старой ошибкой.
return; // Новое уведомление ожидается на следующих тиках таймера.
} // В последнем сценарии предупреждение остаётся открытым до закрытия приложения.
frame.grab().save(QString::fromUtf8(qgetenv("DSVIEW_CAPTURE_DIR")) + "/link-loss.png"); // Сохраняем настоящий кадр остановленной сессии.
session->clear_store_confirm_flag(); // Принятый в предыдущем сценарии фрагмент уже проверяется на диске; тест подтверждает отказ от дополнительного сохранения.
timer.stop(); // Повторный тик не должен обращаться к закрывающемуся окну.
result = frame.close() ? 0 : 1; // Закрытие с открытым предупреждением обязано пройти штатный closeEvent.
application.exit(result); // Возвращаем проверенный результат, не ожидая принудительного завершения процесса.
}); // Обработчик живёт не дольше главного окна проверки.
timer.start(50); // Период 50 мс проверяет состояние интерфейса без занятого ожидания.
QTimer::singleShot(0, &frame, [&]() { if (!start()) application.exit(5); }); // Запуск откладывается до начала обычной обработки GUI-событий.
const int loop_result = application.exec(); // Нативные сетевые ошибки проходят через настоящий цикл событий DSView.
if (loop_result != 0) result = loop_result; // Код ошибки дедлайна или модальности не может превратиться в успешный результат.
control->Stop(); // Освобождаем аппаратную сессию до уничтожения дочерних виджетов.
} // Деструкторы проверяют также время жизни неблокирующего уведомления.
control->UnInit(); // Освобождаем декодеры и libsigrok после закрытия окон.
dsv_log_uninit(); // Закрываем штатный журнал перед возвратом результата теста.
return result; // Нулевой код подтверждает остановку, восстановление и штатное закрытие.
} // Тест не включается в пользовательский EXE и не добавляет в него скрытые команды.
+87
View File
@@ -0,0 +1,87 @@
"""Собирает нативную проверку и воспроизводит обрыв RJ2 на локальном UDP-эмуляторе.""" # Реальные GUI-объекты выявляют повторные окна, модальность и зависание закрытия.
import argparse # Явные пути позволяют проверить любой собранный runtime закреплённого DSView.
import json # Метаданные дисковой записи подтверждают точное число принятых до обрыва отсчётов.
import os # Окружение тестового процесса очищается от Python и Qt родительского приложения.
from pathlib import Path # Артефакты и исходники адресуются абсолютными путями.
import re # Выделяем объекты и библиотеки из созданного CMake файла Ninja.
import socket # Эмулятор использует тот же UDP-порт и little-endian протокол RJ2, что плата.
import struct # Заголовок ZLP состоит из четырёх 32-битных слов little endian.
import subprocess # Проверочный EXE линкуется с настоящими объектами текущей сборки.
import threading # Эмулятор отвечает параллельно с отдельным процессом Qt.
import time # Небольшой интервал между UDP-пакетами исключает случайное переполнение loopback.
import zipfile # После обрыва проверяем сохранённый непрерывный фрагмент аппаратной записи.
def main(): # Отдельный сценарий не требует PySide, тестового устройства и изменения рабочего EXE.
parser = argparse.ArgumentParser(description=__doc__) # Справка описывает назначение нативной проверки.
parser.add_argument('--source', type=Path, required=True) # Указываем уже собранный checkout DSView с build-win64.
parser.add_argument('--runtime', type=Path, required=True) # Полный runtime предоставляет DLL, ресурсы и декодеры.
parser.add_argument('--output', type=Path, required=True) # Все новые файлы и записи сохраняются в отдельном каталоге.
parser.add_argument('--msys-root', type=Path, default=Path('C:/msys64')) # SDK совпадает с использованным при основной сборке UCRT64.
args = parser.parse_args() # Ошибочные параметры отклоняются до запуска эмулятора и компилятора.
source, runtime, output = (path.resolve() for path in (args.source, args.runtime, args.output)) # Исключаем зависимость путей от cwd компилятора.
output.mkdir(parents=True, exist_ok=True) # Сценарий не удаляет прежние артефакты и не затрагивает записи пользователя.
build = source / 'build-win64' # Это штатный каталог сборки канонического рецепта.
sdk = args.msys_root.resolve() / 'ucrt64/bin' # В UCRT64 находятся компилятор и Ninja той же ABI.
env = dict(os.environ, PATH=str(sdk) + os.pathsep + os.environ.get('PATH', '')) # Компилятор должен найти свои DLL без изменения системного PATH.
commands = subprocess.check_output([str(sdk / 'ninja.exe'), '-t', 'commands', 'CMakeFiles/DSView.dir/DSView/main.cpp.obj'], cwd=build, env=env, text=True) # Берём настоящие include и define текущей сборки.
compile_flags = commands.strip().splitlines()[-1].split(' -MD ')[0] # Убираем генерацию зависимостей рабочего main.cpp, оставляя все флаги компилятора.
obj = output / 'check_link_loss.obj' # Проверочный main хранится отдельно от объектов рабочего приложения.
subprocess.run(compile_flags + ' -o ' + subprocess.list2cmdline([str(obj)]) + ' -c ' + subprocess.list2cmdline([str(Path(__file__).with_suffix('.cpp'))]), cwd=build, env=env, check=True) # Компилируем тест тем же C++11 и заголовками Qt5.
ninja = (build / 'build.ninja').read_text(encoding='utf-8') # Метаданные Ninja перечисляют все реальные объекты и библиотеки DSView.
edge = next(line for line in ninja.splitlines() if line.startswith('build ') and 'CXX_EXECUTABLE_LINKER' in line) # В рецепте есть единственный пользовательский EXE.
objects = re.findall(r'CMakeFiles/[^ ]+\.obj', edge.split(' | ')[0]) # Объекты включают ресурсы, moc, аппаратный драйвер и обработчик GUI.
objects.remove('CMakeFiles/DSView.dir/DSView/main.cpp.obj') # Рабочая точка входа заменяется проверочной без изменения остальных объектов.
libraries = re.search(r'^ LINK_LIBRARIES = (.+)$', ninja, re.MULTILINE).group(1) # Сохраняем полный штатный набор библиотек, включая Python и GIO.
executable = runtime / 'check_link_loss.exe' # Штатный поиск ресурсов DSView использует каталог EXE, поэтому тест располагается рядом с ресурсами отдельного проверочного runtime.
response = output / 'link-loss.rsp' # Response-файл обходит ограничение длины командной строки Windows.
response.write_text(' '.join(objects) + ' "' + obj.as_posix() + '" ' + libraries, encoding='utf-8') # Прямые слеши сохраняют Windows-путь при разборе response-файла GCC, где обратный слеш служит экранированием.
subprocess.run([str(sdk / 'c++.exe'), '@' + str(response), '-o', str(executable)], cwd=build, env=env, check=True) # Линкуем те же объекты, что вошли в исправленный DSView.
udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Отдельный локальный сокет не подключается к реальной плате.
udp.bind(('127.0.0.1', 37575)) # Порт 37575 задан общим заголовком zynq_la_pl.h протокола RJ2.
udp.settimeout(0.1) # Таймаут 100 мс позволяет быстро остановить фоновую фикстуру после завершения EXE.
stopped, sessions, errors = threading.Event(), set(), [] # Уникальные nonce выявляют самопроизвольные повторные захваты.
def serve(): # Один поток отвечает на discovery и затем имитирует исчезновение платы во время захвата.
try: # Ошибки фикстуры передаются главному потоку, чтобы их нельзя было принять за штатный обрыв.
while not stopped.is_set(): # Внешний сценарий управляет временем жизни только собственного сокета.
try: request, peer = udp.recvfrom(2048) # Буфер вмещает обычный START и расширенную конфигурацию триггера.
except socket.timeout: continue # Отсутствие трафика не является ошибкой эмулятора.
if len(request) < 16: continue # Неполный заголовок не принадлежит протоколу тестовой платы.
magic, operation, nonce, value = struct.unpack_from('<4I', request) # Формат совпадает с четырьмя little-endian словами драйвера.
if operation == 0: # Discovery возвращает INFO только до фактического старта данной сессии.
if nonce not in sessions: udp.sendto(struct.pack('<4I', magic, 0, nonce, 200000000), peer) # Частота 200 МГц разрешена для ZLP2, ZLT и ZLR; после START heartbeat исчезает.
elif operation == 1 and nonce not in sessions: # Повторная отправка START с тем же nonce не считается новым захватом.
sessions.add(nonce) # Каждая новая сессия должна появляться только после явного запуска из нативного теста.
if len(sessions) == 2: # Второй захват обрывается после реально принятых данных.
for sequence in range(64): # 64 пакета по 1024 отсчёта дают непрерывный префикс длиной 65 536 отсчётов.
udp.sendto(struct.pack('<4I', magic, 1, nonce, sequence) + bytes([sequence & 255]) * 1024, peer) # Нумерация без пропусков проверяет сохранение хвоста до сетевого таймаута.
time.sleep(0.001) # Пауза 1 мс предотвращает переполнение очереди UDP при медленном тестовом хосте.
except Exception as error: errors.append(error) # Ошибка потока обязана завершить итоговую проверку неуспехом.
thread = threading.Thread(target=serve, daemon=True) # Поток обслуживает только локальную фикстуру текущего запуска.
thread.start() # Discovery рабочего драйвера выполняется синхронно ещё до первого Qt-таймера.
child_env = {key: value for key, value in os.environ.items() if not key.startswith(('PYTHON', 'QT_', 'QML'))} # Python и Qt пакета SETGUI не должны подменять ABI нативного DSView.
child_env['PATH'] = str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32') # Все DLL берутся из проверяемого runtime.
child_env['QT_PLUGIN_PATH'] = str(runtime) # EXE находится в каталоге артефактов, а плагины Qt остаются в runtime.
child_env['PYTHONHOME'] = str(runtime) # Встроенный Python читает стандартную библиотеку именно проверяемого компонента.
child_env['DSVIEW_CAPTURE_DIR'] = str(output) # Нативный тест сохраняет аппаратные фрагменты и скриншот рядом с отчётом.
try: # Фоновый сокет освобождается при любом исходе компиляции и запуска.
checked = subprocess.run([str(executable)], cwd=runtime, env=child_env, capture_output=True, text=True, errors='replace', timeout=40) # Дедлайн 40 секунд включает нативный предел 30 секунд и остановку декодеров.
(output / 'link-loss.log').write_text(checked.stdout + checked.stderr, encoding='utf-8') # Полный журнал остаётся доступным при ошибке теста.
print(checked.stdout, end='') # Показываем отдельные результаты трёх аппаратных таймаутов.
if checked.returncode: raise RuntimeError(f'Native link-loss check failed ({checked.returncode}): {checked.stderr}') # Падение процесса не маскируется успешной работой эмулятора.
if errors: raise errors[0] # Проверка требует корректного выполнения фоновой фикстуры.
assert len(sessions) == 3, f'Unexpected automatic captures: {len(sessions)}' # Три ручных запуска должны создать ровно три nonce.
recordings = list(output.glob('*.dsl')) # Запись после обрыва должна быть опубликована штатным дисковым писателем.
assert recordings, 'No preserved capture after partial receive' # Потеря связи не должна удалять принятый непрерывный фрагмент.
for path in recordings: # Проверяем целостность всех опубликованных аппаратных архивов текущего запуска.
with zipfile.ZipFile(path) as archive: # Метаданные и отсчёты должны относиться к одному завершённому архиву.
assert archive.testzip() is None # Контроль CRC выявляет незавершённый или повреждённый файл после обрыва.
assert json.loads(archive.read('zynq_recording.json'))['samples'] == 65536 # Ровно 64 пакета по 1024 отсчёта подтверждают сохранение всего непрерывного префикса.
print('PASS: three manual captures, preserved DSL, normal close with warning open') # Итог охватывает исходный пользовательский сценарий и сохранность записи.
finally: # Освобождение фикстуры не зависит от результата нативного процесса.
stopped.set() # Поток завершится после ближайшего короткого таймаута сокета.
thread.join(timeout=2) # Предел 2 секунды исключает бессрочное ожидание фонового recvfrom.
udp.close() # Закрываем только созданный этим сценарием loopback-сокет.
if __name__ == '__main__': main() # Импорт файла не запускает компилятор, сеть или приложение.
+62
View File
@@ -0,0 +1,62 @@
"""Проверка токовых графиков в настоящем DSView без аппаратного захвата.""" # Сценарий открывает готовую синтетическую DSL-запись и сохраняет изображение результата.
import argparse # Пути runtime, захвата и артефактов задаются явно.
import os # Чистое окружение исключает конфликт Qt и Python родительского процесса.
from pathlib import Path # Все артефакты сохраняются в выбранном каталоге проверки.
import re # Подтверждённый HWND извлекается из штатного протокола встраивания.
import subprocess # Проверяется собранный DSView.exe, а не заглушка графического движка.
import time # Ограниченные ожидания позволяют завершить зависший тест без вмешательства в пользовательскую сессию.
def main(): # Проверка управляет только дочерним экземпляром, который сама запустила.
parser = argparse.ArgumentParser(description=__doc__) # Назначение сценария видно при запуске с --help.
parser.add_argument('--runtime', required=True, type=Path) # Каталог должен содержать полный собранный компонент DSView.
parser.add_argument('--capture', required=True, type=Path) # Запись содержит назначения входов и все параметры двигателя.
parser.add_argument('--output', required=True, type=Path) # Изображение и журнал отделены от исходной записи.
parser.add_argument('--timeout', type=float, default=45) # Ожидание полного декодирования ограничено 45 секундами.
args = parser.parse_args() # Все пути читаются до создания окна или процесса.
from PySide6.QtWidgets import QApplication, QWidget # Qt используется только для тестового родительского окна и снимка дочернего компонента.
runtime, capture, output = args.runtime.resolve(), args.capture.resolve(), args.output.resolve() # Относительные параметры не зависят от рабочего каталога дочернего процесса.
output.mkdir(parents=True, exist_ok=True) # Каталог артефактов создаётся отдельно для каждого сценария.
app, window = QApplication([]), QWidget() # Окно воспроизводит штатное встраивание DSView в SETGUI.
window.resize(1500, 1000) # Высоты достаточно для двенадцати цифровых входов и токового графика NPC.
window.setWindowTitle('Induction motor current decoder verification') # Заголовок отличает автоматическую проверку от рабочего окна пользователя.
window.show() # Родитель получает настоящий HWND перед запуском встраиваемого компонента.
env = {key: value for key, value in os.environ.items() if not key.startswith(('PYTHON', 'QT_', 'QML'))} # Нативный компонент загружает собственные Qt и встроенный Python.
env['PATH'] = str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32') # DLL выбираются из проверяемой поставки и системного каталога Windows.
env['DSVIEW_HOST_SCREENSHOT'] = str(output / 'motor-loading.png') # Встроенный механизм подтверждает фактическое создание и отрисовку окна DSView.
with (output / 'motor-native.log').open('w', encoding='utf-8') as log: # Журнал сохраняет сообщения импорта декодера и завершения обработки записи.
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--loglevel', '3', '--embed-parent', str(int(window.winId())), str(capture)], cwd=runtime, env=env, stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) # Запускается только новый тестовый процесс без видимого терминала.
try: # Любая ошибка завершается очисткой именно этого дочернего процесса.
deadline, completed_at, report = time.monotonic() + args.timeout, None, '' # Дополнительная пауза после декодирования позволяет завершить очередную перерисовку.
while time.monotonic() < deadline and process.poll() is None: # Условие ограничивает ожидание по времени и учитывает преждевременное завершение DSView.
app.processEvents() # Родительское Qt-окно продолжает обрабатывать встраивание и отрисовку.
report = (output / 'motor-native.log').read_text(encoding='utf-8', errors='replace') # Чтение журнала не изменяет захват или состояние декодера.
if 'Traceback (most recent call last)' in report: # Ошибка Python-модели должна немедленно провалить нативную проверку.
raise AssertionError('Decoder failed; inspect motor-native.log') # Диагностика остаётся доступной в каталоге артефактов.
ready = re.search(r'DSVIEW_READY (\d+)', report) # Дескриптор принадлежит только созданному тестом компоненту.
if 'Decoded sample count:' in report and completed_at is None: # Сам факт открытия файла недостаточен без полного декодирования.
completed_at = time.monotonic() # Запоминается время фактического окончания расчёта.
window.resize(1501, 1000) # Изменение размера тестового родителя сбрасывает сохранённую Windows поверхность встраиваемого окна перед снимком.
if completed_at is not None and time.monotonic() - completed_at > 1 and ready: # Одна секунда после расчёта даёт Qt возможность показать все рассчитанные кривые.
image = window.screen().grabWindow(int(ready.group(1))) # Снимок берётся непосредственно с HWND проверяемого нативного окна.
assert not image.isNull() and image.save(str(output / 'motor-complete.png')), 'Native screenshot failed' # Пустой или несохранённый результат не считается визуальной проверкой.
break # После сохранения изображения можно штатно закрыть тестовый экземпляр.
time.sleep(0.02) # Двадцать миллисекунд ограничивают нагрузку опроса и не задерживают интерфейс надолго.
assert completed_at is not None and (output / 'motor-complete.png').is_file(), 'Motor decode did not finish; inspect motor-native.log' # Тайм-аут не маскируется успехом запуска EXE.
process.stdin.write(b'CLOSE\n') # Стандартный протокол встраивания закрывает только тестовую сессию.
process.stdin.flush() # Команда немедленно передаётся дочернему процессу.
deadline = time.monotonic() + 5 # Штатное закрытие ограничено пятью секундами.
while time.monotonic() < deadline and process.poll() is None: # Ожидание не блокирует Qt родительского окна.
app.processEvents() # Сообщения уничтожения дочерних окон обрабатываются обычным образом.
time.sleep(0.02) # Частый короткий опрос не создаёт длинной блокирующей паузы.
assert process.poll() == 0, 'DSView did not close cleanly' # Проверяется корректный жизненный цикл нового анализатора.
finally: # Ошибочный тест не должен оставлять свой DSView в фоне.
if process.poll() is None: # Чужие и уже завершённые процессы не затрагиваются.
process.terminate() # Завершается только дочерний процесс текущего сценария.
process.wait(timeout=5) # Освобождение дескриптора процесса ограничено по времени.
window.close() # Тестовое родительское окно больше не требуется.
print(output / 'motor-complete.png') # Готовый снимок можно открыть для визуальной проверки фазных кривых и шкалы ампер.
if __name__ == '__main__': # Импорт сценария не должен сам запускать графический компонент.
main() # Прямой запуск выполняет один нативный тест выбранной DSL-записи.
+62 -11
View File
@@ -3,27 +3,36 @@
Run with a Python containing PySide6. No acquisition hardware is accessed.
"""
import argparse
import ctypes # Win32 WM_NULL проверяет отзывчивость реального потока интерфейса дочернего DSView.
import json
import math
import os
import re # Готовый HWND читается из штатного ответа host protocol 1.
from pathlib import Path
import subprocess
import time
import zipfile
def fixture(path):
count, rate, period = 60032, 1000000, 500
def fixture(path, seconds=0.060032, carrier_hz=2000, layout='split', max_height='1X', show_text=False, text_only=False): # Видимость текстов и графиков воспроизводит пользовательскую компоновку и позволяет сравнить интервалы при разных высотах.
rate = 1000000 # Частота 1 МГц даёт 50 отсчётов на период при нагрузочной несущей 20 кГц.
if seconds <= 0 or not 0 < carrier_hz <= rate / 2: # Каждый период должен занимать хотя бы два отсчёта.
raise ValueError('Positive duration and carrier <= 500000 Hz are required.') # Некорректный сценарий не должен создавать повреждённую запись.
count, period = math.ceil(seconds * rate / 64) * 64, round(rate / carrier_hz) # Формат DSL требует выравнивания числа отсчётов по 64 битам.
decoders = [dict(id='pwm_waveform', version=2, label='PWM sine A / B / C',
view_index=3, channel=[{'a': 0}, {'b': 1}, {'c': 2}],
options=dict(active_a='high', active_b='high', active_c='high',
carrier_hz=0, smoothing_ms=0.0),
carrier_hz=0, smoothing_ms=0.0, waveform_layout=layout), # Используем штатный параметр Decoders, который также сохраняется в записи DSL.
**{'stacked decoders': [], 'show': {'pwm_waveform': True,
**{'pwm_waveform: '+ch+' duty / frequency': False for ch in 'ABC'}}})]
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2',
decoder=decoders, channel=[dict(index=i, enabled=True, name='D'+str(i),
colour='default', strigger=0, type=10000, view_index=i) for i in range(3)],
**{'Sample count': str(count), 'Sample rate': str(rate), 'Max Height': '1X'})
**{'Sample count': str(count), 'Sample rate': str(rate), 'Max Height': max_height}) # Большая высота дорожек не должна увеличивать фон числовых аннотаций.
if show_text or text_only: # Текстовые строки нужны для проверки расстояний и переключения видимых полей.
decoders[0]['show'].update({'pwm_waveform: ' + ch + ' duty / frequency': True for ch in 'ABC'}) # Показываем исходные числовые поля всех трёх каналов без изменения расчётных параметров.
if text_only: # Изолированная текстовая компоновка позволяет проверить независимость от масштаба графиков.
decoders[0]['show'].update({'pwm_waveform: ' + ch + ': -1 .. +1': False for ch in 'ABC'}) # Скрываем только графические строки, сохраняя их рассчитанные данные.
header = ('[version]\nversion = 3\n[header]\ndriver = virtual-session\n'
'device mode = 0\ncapturefile = data\n'
f'total samples = {count}\ntotal probes = 3\ntotal blocks = 1\n'
@@ -35,10 +44,17 @@ def fixture(path):
archive.writestr('decoders', json.dumps(decoders))
for ch in range(3):
bits = bytearray(count // 8)
for i in range(count):
width = round(250 + 200 * math.sin(2 * math.pi * (i // period) / 40 + ch * 2 * math.pi / 3))
if i % period < width:
bits[i // 8] |= 1 << (i % 8)
for i in range(0, count, period): # Обходим периоды вместо всех отсчётов, чтобы длинная фикстура создавалась быстро.
width = min(count - i, round(period * (0.5 + 0.4 * math.sin(2 * math.pi * i / rate * 50 + ch * 2 * math.pi / 3)))) # Синус 50 Гц задаёт заполнение от 10 до 90 процентов со сдвигом фаз 120 градусов.
if not width: # Очень короткий завершающий импульс может округлиться до нуля.
continue # Нулевая длительность сохраняет исходные нулевые биты.
first, last = i // 8, (i + width - 1) // 8 # Вычисляем крайние байты активного импульса.
if first == last: # Импульс внутри одного байта заполняется одной битовой маской.
bits[first] |= ((1 << width) - 1) << (i % 8) # Смещение сохраняет позицию фронта с точностью одного отсчёта.
else: # Длинный импульс состоит из двух неполных байтов и полного внутреннего диапазона.
bits[first] |= (255 << (i % 8)) & 255 # В первом байте активны биты начиная с фронта до конца байта.
bits[first + 1:last] = b'\xff' * (last - first - 1) # Внутренние полные байты заполняются без цикла по отдельным отсчётам.
bits[last] |= (1 << ((i + width - 1) % 8 + 1)) - 1 # Последний байт обрезается точно по спаду импульса.
archive.writestr(f'L-{ch}/0', bits)
@@ -46,13 +62,21 @@ def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--runtime', type=Path, required=True)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--seconds', type=float, default=0.060032) # Прежний короткий сценарий остаётся значением по умолчанию.
parser.add_argument('--carrier-hz', type=int, default=2000) # Нагрузочная проверка использует 20 кГц и сотни тысяч периодов.
parser.add_argument('--timeout', type=float, default=30) # Ограничиваем время ожидания завершения декодирования.
parser.add_argument('--layout', choices=('split', 'combined'), default='split') # Один нативный сценарий проверяет оба пользовательских вида графиков.
parser.add_argument('--max-height', choices=('1X', '2X', '3X', '4X'), default='1X') # Проверка на увеличенной дорожке воспроизводит прежнее растягивание текстовых полей.
parser.add_argument('--show-text', action='store_true') # Включаем duty/frequency для проверки всей смешанной компоновки.
parser.add_argument('--text-only', action='store_true') # Фиксированные интервалы проверяются также при полностью скрытых графиках.
parser.add_argument('--check-display-cache', action='store_true') # Нативный сценарий принимает реальные диалоги видимости, неизменённых настроек и вида PWM без нового анализа.
args = parser.parse_args()
from PySide6.QtWidgets import QApplication, QWidget
runtime, output = args.runtime.resolve(), args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
capture = output / 'pwm-three-phase.dsl'
screenshot = output / 'pwm-native.png'
fixture(capture)
fixture(capture, args.seconds, args.carrier_hz, args.layout, args.max_height, args.show_text, args.text_only) # Все параметры восстанавливаются из обычного DSL, включая видимость строк и масштаб дорожек.
app = QApplication([])
window = QWidget()
window.resize(1400, 850)
@@ -61,16 +85,40 @@ def main():
env = {k: v for k, v in os.environ.items() if not k.startswith(('PYTHON', 'QT_', 'QML'))}
env['PATH'] = str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32')
env['DSVIEW_HOST_SCREENSHOT'] = str(screenshot)
cache_report = output / 'display-cache.txt' # Отчёт нативных проверок отделён от журнала единственного декодирования.
if args.check_display_cache: # Обычный smoke-тест не открывает проверочные модальные диалоги.
cache_report.write_text('', encoding='utf-8') # Исключаем ложный успех от артефактов прежнего запуска.
env['DSVIEW_HOST_CHECK'] = str(cache_report) # Используем существующий механизм нативных проверок встроенного DSView.
env['DSVIEW_HOST_CHECK_DISPLAY_CACHE'] = '1' # Проверяем сохранение готовых результатов вместо удаления анализаторов.
began = time.time()
with (output / 'pwm-native.log').open('w', encoding='utf-8') as log:
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--embed-parent',
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--loglevel', '3', '--embed-parent', # Информационный журнал подтверждает фактическое завершение анализа всей записи.
str(int(window.winId())), str(capture)], cwd=runtime, env=env,
stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW)
try:
deadline = time.monotonic() + 8
deadline = time.monotonic() + args.timeout # Длинная запись должна полностью обработаться до штатного закрытия.
completed, hwnd, response_times = False, None, [] # Фиксируем завершение и задержку обработки сообщений настоящим интерфейсом DSView.
user32 = ctypes.WinDLL('user32', use_last_error=True) # Системный API проверяет только дочерний процесс, запущенный этим тестом.
user32.SendMessageTimeoutW.argtypes = [ctypes.c_void_p, ctypes.c_uint, ctypes.c_size_t, ctypes.c_ssize_t, ctypes.c_uint, ctypes.c_uint, ctypes.POINTER(ctypes.c_size_t)] # Типы указателей учитывают 64-битный HWND.
while time.monotonic() < deadline and process.poll() is None:
app.processEvents()
report = (output / 'pwm-native.log').read_text(encoding='utf-8', errors='replace') # Чтение журнала не изменяет состояние декодера или прибора.
ready = re.search(r'DSVIEW_READY (\d+)', report) # Используем подтверждённый HWND из штатного протокола встраивания.
if ready: # До готовности нативного окна измерять его отзывчивость нельзя.
hwnd = int(ready.group(1)) # Дескриптор относится только к нашему тестовому экземпляру.
result, started = ctypes.c_size_t(), time.monotonic() # Монотонные часы измеряют обработку одного сообщения.
responded = user32.SendMessageTimeoutW(hwnd, 0, 0, 0, 2, 1000, ctypes.byref(result)) # WM_NULL с SMTO_ABORTIFHUNG ожидает не более одной секунды и не меняет приложение.
response_times.append(time.monotonic() - started) # Задержка показывает блокировку цикла событий во время анализа и рисования.
assert responded, 'DSView UI probe failed, Win32 error={}'.format(ctypes.get_last_error()) # Код Win32 отличает реальное зависание от запрета доступа к чужому HWND или смены рабочего стола.
cache = cache_report.read_text(encoding='utf-8') if args.check_display_cache else 'PASS' # Ожидаем завершения всех действий в реальном диалоге настроек.
assert 'FAIL' not in cache, cache # Ошибочное требование повторного анализа немедленно завершает сценарий с диагностикой фазы.
if 'Decoded sample count:' in report and screenshot.exists() and 'PASS' in cache: # Успех требует полного исходного анализа и подтверждённого сохранения кэша.
completed = True # В журнале есть подтверждение обработки всех отсчётов.
window.screen().grabWindow(hwnd).save(str(output / 'pwm-complete.png')) # Сохраняем итоговый экран после завершения длинного анализа.
break # Далее проверяем штатное закрытие завершённого экземпляра.
time.sleep(.02)
assert completed, 'PWM decode did not complete; inspect pwm-native.log' # Тайм-аут не должен маскироваться успешным принудительным закрытием.
print('UI max response ms:', round(max(response_times, default=0) * 1000, 3)) # Отчёт связывает нагрузочный сценарий с реальной задержкой интерфейса.
if process.poll() is None:
process.stdin.write(b'CLOSE\n')
process.stdin.flush()
@@ -80,6 +128,9 @@ def main():
time.sleep(.02)
assert process.poll() == 0, 'DSView did not close cleanly; inspect pwm-native.log'
assert screenshot.exists() and screenshot.stat().st_mtime >= began, 'No fresh render artifact'
if args.check_display_cache: # Проверка по журналу дополняет результат принятия каждого настоящего диалога.
assert report.count('Decoded sample count:') == 1, 'Display changes triggered another decode' # Все переключения обязаны использовать результаты единственного анализа записи.
print(cache.strip()) # Отчёт показывает отдельный успех скрытия строки, неизменённого OK и изменения вида графиков.
finally:
if process.poll() is None:
process.terminate()
@@ -0,0 +1,49 @@
"""Нативная проверка опций NPC, θ, треугольника и фазовых сдвигов в одном DSView.""" # Скрипт запускает только собственный экземпляр с синтетической записью, не обращаясь к оборудованию.
import argparse # Параметры выбирают тестовый runtime и режим отображения.
import json # Метаданные DSL содержат обычные настройки того же анализатора PWM waveform.
import math # Выравнивание длины записи соответствует 64-битным блокам DSView.
from pathlib import Path # Абсолютные пути исключают зависимость от каталога запуска.
import sys # Стандартная нативная проверка вызывается с собственным контролируемым списком аргументов.
import zipfile # DSL является архивом метаданных и упакованных цифровых каналов.
import check_pwm_native as native # Повторно используем проверку окончания декодирования, отзывчивости и штатного закрытия.
from test_pwm_npc import npc_frames, INDICES # Генератор совпадает с численно проверенными входными векторами четырёх затворов на фазу.
def fixture(path, seconds=.06, carrier_hz=2000, layout='combined', max_height='1X', show_text=False, text_only=False): # Сигнатура сохраняет совместимость с существующим нативным сценарием.
count = math.ceil(seconds * 1000000 / 64) * 64 # Частота 1 МГц и длина, кратная 64 битам, согласованы с цифровыми потоками DSL.
options = dict(active_a='high', active_b='high', active_c='high', input_mode='npc-three-level', waveform_mode=MODE, waveform_layout=layout, carrier_hz=2000, smoothing_ms=0.0, show_theta='yes', show_carrier='yes', show_phase_angles='yes') # Все новые опции включаются штатными параметрами одного экземпляра PWM waveform.
channels = [{('abc'[ch] if gate == 0 else 'abc'[ch] + str(gate + 1)): index} for ch in range(3) for gate, index in enumerate(INDICES[ch])] # S1 сохраняет прежние имена A/B/C, дополнительные затворы имеют явные номера S2…S4.
show = {'pwm_waveform': True} # Видимость относится к строкам результата, а не отключает цифровые входы затворов.
show.update({'pwm_waveform: ' + ch + ' duty / frequency': False for ch in 'ABC'}) # Короткие заполнения скрыты, чтобы все расчётные графики помещались на контрольном экране.
show.update({'pwm_waveform: ' + ch + ' PWM carrier / field': False for ch in 'ABC'}) # Значения частот уже проверяются численными тестами и не заслоняют θ, несущую и фазовые углы.
decoders = [dict(id='pwm_waveform', version=2, label='NPC ABC / theta / carrier / phase lag', view_index=12, channel=channels, options=options, **{'stacked decoders': [], 'show': show})] # Запись загружается обычным механизмом сохранённого анализатора DSView.
session = dict(Device='virtual-session', DeviceMode=0, Version=3, Title='DSView v1.3.2', decoder=decoders, channel=[dict(index=index, enabled=True, name='D' + str(index), colour='default', strigger=0, type=10000, view_index=index) for index in range(12)], **{'Sample count': str(count), 'Sample rate': '1000000', 'Max Height': max_height}) # Все двенадцать физических входов остаются доступными декодеру.
header = '[version]\nversion = 3\n[header]\ndriver = virtual-session\ndevice mode = 0\ncapturefile = data\ntotal samples = {}\ntotal probes = 12\ntotal blocks = 1\nsamplerate = 1000000 Hz\ntrigger time = 0\ntrigger pos = 0\n'.format(count) # Заголовок фиксирует ту же временную шкалу, что генератор фронтов.
header += ''.join('probe{} = D{}\n'.format(index, index) for index in range(12)) # Каждый физический затвор имеет собственную цифровую дорожку.
bits = [bytearray(count // 8) for _ in range(12)] # Пакуются реальные логические уровни, а не готовые кривые результата.
frames = list(npc_frames(seconds=seconds)) # Последовательность содержит только фактические изменения допустимых комбинаций NPC.
frames.append((count, frames[-1][1])) # Последний уровень достраивается до конца записи без выдуманного переключения.
for (start, pins), (end, _) in zip(frames, frames[1:]): # Между соседними снимками все цифровые входы постоянны.
for index, level in enumerate(pins): # Двенадцать входов обрабатываются независимо.
if level: # Нулевые биты уже присутствуют в исходных bytearray.
for sample in range(start, min(end, count)): # Синтетический короткий сценарий допускает прямое заполнение каждого отсчёта.
bits[index][sample // 8] |= 1 << (sample % 8) # Младший бит соответствует первому отсчёту байта в формате DSL.
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as archive: # Полная фикстура хранится одним файлом и открывается без оборудования.
archive.writestr('header', header) # Стандартный заголовок определяет частоту и число проб.
archive.writestr('session', json.dumps(session)) # Видимость и расположение дорожек восстанавливаются штатным путём.
archive.writestr('decoders', json.dumps(decoders)) # Выбранная топология и новые опции сохраняются вместе с записью.
for index, data in enumerate(bits): # Один блок каждого входа содержит все отсчёты сценария.
archive.writestr('L-{}/0'.format(index), data) # Пути соответствуют индексу физического канала DSView.
if __name__ == '__main__': # Проверка запускается явно после сборки нативного компонента.
parser = argparse.ArgumentParser(description=__doc__) # Интерфейс не добавляет зависимостей к обычному unittest.
parser.add_argument('--runtime', type=Path, required=True) # Используется отдельный тестовый runtime либо явно выбранный локальный компонент.
parser.add_argument('--output', type=Path, required=True) # Все журналы и изображения сохраняются в выделенной папке сценария.
parser.add_argument('--mode', choices=('envelope', 'switching'), default='switching') # Один сценарий проверяет оба пользовательских вида основного графика.
parser.add_argument('--layout', choices=('split', 'combined'), default='combined') # Общая компоновка должна сохранять отдельные области θ и треугольника.
args = parser.parse_args() # Параметры проверяются до запуска дочернего DSView.
MODE = args.mode # Выбранный вид встраивается в обычные настройки декодера DSL.
native.fixture = fixture # Стандартный сценарий получает двенадцатиканальную NPC-запись вместо прежнего двоичного ШИМ.
sys.argv = [sys.argv[0], '--runtime', str(args.runtime), '--output', str(args.output), '--seconds', '.06', '--layout', args.layout] # Остальные проверки выполняются тем же существующим нативным инструментом.
native.main() # Дочерний процесс обязан завершить декодирование и закрыться штатно, оставив контрольный скриншот.
+47
View File
@@ -0,0 +1,47 @@
"""Проверка меню осциллографа в настоящем встроенном DSView с цифровой записью.""" # Запускает только собственный дочерний процесс и сохраняет изображение результата.
import argparse # Пути к проверяемому runtime и артефактам задаются явно.
import os # Изолированное окружение исключает смешение Qt5 дочернего процесса и Qt6 проверочного окна.
from pathlib import Path # Все артефакты размещаются в указанном каталоге.
import subprocess # Нативный DSView проходит тот же протокол встраивания, что в SETGUI.
import time # Монотонные часы ограничивают ожидание готовности и штатного завершения.
from check_pwm_native import fixture # Общая цифровая запись также проверяет сохранность готового декодера PWM.
def main(): # Проверка не подключается к USB или сети и не изменяет записи пользователя.
parser = argparse.ArgumentParser(description=__doc__) # Интерфейс сценария повторяет существующие нативные проверки рецепта.
parser.add_argument('--runtime', type=Path, required=True) # Проверяется конкретная собранная папка, а не установленный глобально DSView.
parser.add_argument('--output', type=Path, required=True) # Изображение, журнал и отчёт сохраняются вместе для диагностики.
args = parser.parse_args() # Некорректные аргументы завершают сценарий до создания GUI.
from PySide6.QtWidgets import QApplication, QWidget # Qt6 предоставляет настоящее родительское окно для нативного Qt5 DSView.
runtime, output = args.runtime.resolve(), args.output.resolve() # Дочерний процесс получает однозначные абсолютные пути.
output.mkdir(parents=True, exist_ok=True) # Каталог артефактов создаётся до подготовки проверочной записи.
capture, report, screenshot = output / 'digital.dsl', output / 'scope-check.txt', output / 'scope-native.png' # Каждый результат имеет отдельное назначение.
fixture(capture, seconds=0.008192) # Цифровая запись 8,192 мс содержит несколько периодов синуса и меандра эмулятора.
report.write_text('', encoding='utf-8') # Старый успешный отчёт не может скрыть ошибку нового запуска.
app = QApplication.instance() or QApplication([]) # Один цикл событий обслуживает родительскую область встраивания.
window = QWidget() # Изолированное окно принадлежит только проверке.
window.resize(1400, 900) # Размер в логических пикселях оставляет место для аналоговых, цифровых и декодированных дорожек.
window.show() # Настоящий HWND необходим протоколу --embed-parent DSView.
environment = {key: value for key, value in os.environ.items() if not key.startswith(('PYTHON', 'QT_', 'QML'))} # Дочерний Qt5 не наследует плагины и Python runtime Qt6.
environment.update(PATH=str(runtime) + os.pathsep + str(Path(os.environ['SystemRoot']) / 'System32'), DSVIEW_HOST_CHECK=str(report), DSVIEW_HOST_CHECK_SCOPE='1', DSVIEW_HOST_SCREENSHOT=str(screenshot)) # Проверка меню и снимок включаются только для этого процесса.
with (output / 'scope-native.log').open('w', encoding='utf-8') as log: # Журнал сохраняется даже при нативном падении.
process = subprocess.Popen([str(runtime / 'DSView.exe'), '--embed-parent', str(int(window.winId())), str(capture)], cwd=runtime, env=environment, stdin=subprocess.PIPE, stdout=log, stderr=log, creationflags=subprocess.CREATE_NO_WINDOW) # Открываем синтетическую цифровую запись через штатный нативный путь.
try: # Процесс освобождается при успехе, тайм-ауте и ошибке утверждения.
deadline = time.monotonic() + 30 # Тридцать секунд ограничивают запуск и проверку короткой записи.
while process.poll() is None and not report.read_text(encoding='utf-8') and time.monotonic() < deadline: # Ожидаем результата реальных действий меню, поддерживая родительский цикл событий.
app.processEvents() # Вложенный DSView получает resize и paint без блокировки окна.
time.sleep(0.02) # Короткая пауза ограничивает загрузку процессора при ожидании отчёта.
result = report.read_text(encoding='utf-8') # Отчёт формируется нативной проверкой добавления и удаления каналов.
assert result.startswith('PASS'), result or 'DSView не завершил проверку; см. scope-native.log' # Тайм-аут и аварийное завершение не выдаются за успех.
assert screenshot.is_file(), 'Не создан снимок осциллограммы' # Успех требует фактического рендера новых дорожек.
process.stdin.write(b'CLOSE\n') # Закрываем собственный экземпляр тем же способом, что SETGUI.
process.stdin.flush() # Команда немедленно поступает в канал нативного компонента.
deadline = time.monotonic() + 5 # Штатное завершение короткой синтетической записи не должно зависать.
while process.poll() is None and time.monotonic() < deadline: # Продолжаем обслуживать родителя до завершения дочернего процесса.
app.processEvents() # Встраивание остаётся действительным до закрытия DSView.
time.sleep(0.02) # Не выполняем активное ожидание завершения.
assert process.poll() == 0, 'DSView не завершился штатно' # Нативное падение после проверки также считается ошибкой.
print(result.strip(), screenshot) # Краткий отчёт указывает доступный для визуальной проверки артефакт.
finally: # Не оставляем проверочный DSView после провала проверки.
if process.poll() is None: process.terminate() # Завершаем только собственный дочерний процесс, не затрагивая пользовательские сессии.
process.wait(timeout=5) # Освобождаем дескрипторы процесса перед закрытием родительского окна.
window.close() # Проверочная область встраивания больше не нужна.
if __name__ == '__main__': main() # Прямой запуск сценария выполняет полный нативный smoke-тест.
+108
View File
@@ -0,0 +1,108 @@
#include "pwm_waveform.h" // Проверяем тот же индекс, который использует нативная строка DSView.
#include <chrono> // Монотонные часы измеряют стоимость обзорной перерисовки длинной записи.
#include <iostream> // Результат содержит проверяемые размеры геометрии и измеренное время.
#include <random> // Детерминированный шум проверяет сохранение нерегулярных экстремумов.
#include <stdexcept> // Исключения завершают тест с ненулевым кодом даже в сборке NDEBUG.
static void check(bool condition, const char *message) { // Проверки не отключаются оптимизациями компилятора.
if (!condition) throw std::runtime_error(message); // Ошибка сообщает, какое свойство графика нарушено.
} // Успешная проверка не изменяет состояние индекса.
int main() { // Тест работает без прибора, окна и цикла событий Qt.
set_pwm::Envelope envelope; // Один индекс соответствует одной графической строке PWM.
std::vector<set_pwm::Line> original, actual; // Эталон хранит полные отрезки для независимого сравнения.
envelope.lines(actual, 0, 100, 10); // Запрос до поступления данных должен быть безопасным.
check(actual.empty(), "empty envelope"); // Пустая запись не должна рисовать выдуманный сигнал.
std::mt19937 random(7351); // Постоянное зерно делает случайные экстремумы воспроизводимыми.
double previous = 0; // Начальное значение общей непрерывной кривой равно заполнению 50 процентов.
for (size_t index = 0; index < 4097; ++index) { // Нечётная длина проверяет незавершённые узлы пирамиды.
const double next = double(random() % 20001) / 10000 - 1; // Значения покрывают нормированный диапазон с шагом 0,0001.
envelope.append(index * 10, (index + 1) * 10, previous, next); // Каждый линейный сегмент занимает десять отсчётов.
original.push_back({double(index * 10), double((index + 1) * 10), previous, next}); // Эталон сохраняет координаты без использования индекса.
previous = next; // Соседние сегменты соединяются точно по своим концам.
if (index == 2 || index == 1024 || index == 4096) { // Запросы во время поступления данных проверяют обновление неполного дерева.
actual.clear(); // Каждый запрос получает отдельный выходной вектор, как при перерисовке DSView.
envelope.lines(actual, 0, (index + 1) * 10, 1); // Вся текущая запись сворачивается в единственный пиксель.
double low = 1, high = -1; // Полный перебор эталона независимо находит оба экстремума.
for (const auto &line : original) { low = std::min(low, std::min(line.a, line.b)); high = std::max(high, std::max(line.a, line.b)); } // Эталон не использует дерево или его правила разбиения.
check(actual.size() == 1 && actual[0].a == low && actual[0].b == high, "streaming extrema"); // Даже незавершённый блок должен сохранять все пики.
} // Следующая итерация продолжает пополнять уже опрошенный индекс.
} // Готовая нерегулярная запись используется для проверки разных областей и масштабов.
for (size_t pixels : {size_t(1), size_t(7), size_t(31), size_t(512)}) { // Ширины охватывают одноточечный обзор и обычное экранное окно.
const uint64_t start = 17, end = 40953; // Обе границы проходят внутри сегментов и требуют интерполяции.
actual.clear(); // Результат не должен включать геометрию предыдущего масштаба.
envelope.lines(actual, start, end, pixels); // Запрашиваем плотное представление с обрезкой краёв.
check(actual.size() <= pixels * 2, "bounded geometry"); // Число вызовов рисования не должно расти с количеством периодов.
size_t column = 0; // Каждая вертикаль соответствует очередной непустой колонке.
for (const auto &line : actual) { // Соединительные отрезки не участвуют в сравнении вертикальных экстремумов.
if (line.start != line.end) continue; // Наклонные соединения обеспечивают непрерывность, а не диапазон колонки.
const double left = start + double(end - start) * column / pixels; // Эталон использует физические границы пикселя.
const double right = start + double(end - start) * (column + 1) / pixels; // Правая граница не округляется до номера периода.
double low = 1, high = -1; // Диапазон вычисляется полным перебором всех исходных сегментов.
for (const auto &source : original) { // Независимый алгоритм намеренно медленный, чтобы проверить индекс.
const double begin = std::max(source.start, left), finish = std::min(source.end, right); // Находим реальное пересечение сегмента с колонкой.
if (finish <= begin) continue; // Сегменты вне колонки не могут расширять её экстремумы.
const double a = source.a + (source.b - source.a) * (begin - source.start) / (source.end - source.start); // Левый конец обрезается аналитически.
const double b = source.a + (source.b - source.a) * (finish - source.start) / (source.end - source.start); // Правый конец обрезается независимо от индекса.
low = std::min(low, std::min(a, b)); // Сохраняем самый низкий из всех видимых концов.
high = std::max(high, std::max(a, b)); // Сохраняем самый высокий из всех видимых концов.
} // Полный перебор образует эталон без прореживания.
check(std::abs(line.a - low) < 1e-12 && std::abs(line.b - high) < 1e-12, "clipped pixel extrema"); // Пирамида должна давать тот же диапазон с точностью double.
++column; // Следующая вертикаль относится к следующему пикселю.
} // Проверены все вертикали текущего масштаба.
check(column == pixels, "all visible columns"); // Непрерывная запись должна покрывать каждую колонку.
} // Изменение ширины окна не должно менять измеренные данные.
actual.clear(); // Увеличенный фрагмент проверяется отдельно от обзорного графика.
envelope.lines(actual, 20000, 20100, 1000); // На крупном масштабе десять сегментов занимают тысячу пикселей.
check(actual.size() == 10, "zoom restores original segments"); // Исходные периоды не должны исчезать после обзорной отрисовки.
for (size_t index = 0; index < actual.size(); ++index) { // Сравниваем каждый восстановленный отрезок с полным эталоном.
const auto &expected = original[2000 + index]; // Номер исходного периода следует из координаты 20000 / 10.
check(actual[index].start == expected.start && actual[index].end == expected.end && actual[index].a == expected.a && actual[index].b == expected.b, "exact zoom values"); // Увеличение должно вернуть точные координаты и значения.
} // Проверка исключает разрушительное прореживание исходных аннотаций.
envelope.clear(); // Новый захват должен сбросить старые сегменты и все уровни индекса.
actual.clear(); // Удаляем геометрию предыдущей записи.
envelope.lines(actual, 0, 40970, 100); // Те же координаты после очистки больше не содержат сигнала.
check(actual.empty(), "clear removes stale extrema"); // Старые выбросы не должны переходить в новый анализ.
for (uint64_t index = 0; index < 100; ++index) { // Два участка разделены настоящим отсутствием данных.
envelope.append(index, index + 1, 0, 0); // Первая часть записи имеет нулевую модуляцию.
envelope.append(index, index, 1, 1); // Пустой сегмент не должен нарушать поиск или вызывать деление на ноль.
} // Первый участок занимает отсчёты от нуля до ста.
for (uint64_t index = 300; index < 400; ++index) envelope.append(index, index + 1, 1, 1); // Второй участок начинается после разрыва в двести отсчётов.
actual.clear(); // Проверяем плотную геометрию разрывной записи.
envelope.lines(actual, 0, 400, 4); // Разрыв занимает две полные экранные колонки.
check(actual.size() == 2 && actual[0].a == 0 && actual[1].a == 1, "gaps remain disconnected"); // Между участками не должно появиться искусственной наклонной линии.
for (double direction : {1.0, -1.0}) { // Угол может вращаться в обе стороны, поэтому проверяем сброс 360→0 и обратный переход 0→360.
envelope.clear(); // Каждый вариант вращения проверяется независимо от предыдущей записи.
envelope.append(0, 50, direction * 0.9, direction); // Первый участок заканчивается точно на одном краю нормированной шкалы θ.
envelope.append(50, 100, -direction, -direction * 0.9); // Смежный участок начинается на противоположном краю в тот же отсчёт.
actual.clear(); // Запрашиваем полную геометрию одного циклического перехода.
envelope.lines(actual, 0, 100, 1000); // Крупный масштаб должен сохранить точный момент сброса на отсчёте 50.
check(actual.size() == 3 && actual[1].start == 50 && actual[1].end == 50 && actual[1].a == direction && actual[1].b == -direction, "theta wrap has exact vertical edge"); // Между двумя пилообразными участками требуется вертикаль без ложной диагонали.
actual.clear(); // Повторный запрос начинается непосредственно с момента циклического перехода.
envelope.lines(actual, 50, 100, 1000); // Левая граница окна не должна скрывать вертикаль θ.
check(actual.size() == 2 && actual[0].start == 50 && actual[0].end == 50, "theta edge survives left viewport boundary"); // Соседний невидимый сегмент нужен только для восстановления фронта на границе.
envelope.clear(); // Отдельно отличаем циклический сброс от действительно неизвестного интервала.
envelope.append(0, 50, direction * 0.9, direction); // Первый известный участок остаётся тем же.
envelope.append(51, 100, -direction, -direction * 0.9); // Один пропущенный отсчёт обозначает отсутствие данных, а не мгновенный переход.
actual.clear(); // Старые вертикали не должны переноситься в новый запрос.
envelope.lines(actual, 0, 100, 1000); // Проверяем сохранность разрыва даже при большом увеличении.
check(actual.size() == 2 && actual[0].end == 50 && actual[1].start == 51, "unknown theta interval stays disconnected"); // Новый соединитель не имеет права достраивать неизвестные значения угла.
} // Проверка касается геометрии и не изменяет сами рассчитанные углы или выбор направления вращения.
envelope.clear(); // Длинный нагрузочный сценарий начинается с чистого состояния.
const auto build_start = std::chrono::steady_clock::now(); // Измеряем создание индекса отдельно от перерисовки.
for (uint64_t index = 0; index < 1000000; ++index) { // Миллион периодов соответствует 50 секундам при несущей 20 кГц.
envelope.append(index * 50, (index + 1) * 50, index == 500000 ? -1 : 0, index == 500001 ? 1 : 0); // Одиночные отрицательный и положительный выбросы должны пережить обзор.
} // Длинная запись содержит оба пика среди почти постоянного сигнала.
const auto query_start = std::chrono::steady_clock::now(); // Стоимость создания индекса не включается в задержку интерфейса.
for (int frame = 0; frame < 100; ++frame) { // Сто последовательных кадров выявляют случайный полный обход в перерисовке.
actual.clear(); // Размер геометрии измеряется для каждого кадра отдельно.
envelope.lines(actual, 0, 50000000, 1920); // Ширина 1920 пикселей моделирует полный экран с 50-секундным захватом.
check(actual.size() <= 3840, "million periods bounded"); // Даже миллион периодов выдаёт не более двух отрезков на пиксель.
} // Повторная отрисовка использует уже готовый индекс без разбора строк PWM1.
double low = 1, high = -1; // Проверяем, что нагрузочное уплотнение не скрывает одиночные выбросы.
for (const auto &line : actual) { low = std::min(low, std::min(line.a, line.b)); high = std::max(high, std::max(line.a, line.b)); } // Учитываем все видимые отрезки конечного кадра.
check(low == -1 && high == 1, "single period spikes preserved"); // Редкие пики должны оставаться видимыми среди миллиона периодов.
const auto finish = std::chrono::steady_clock::now(); // Фиксируем итог после всех запросов геометрии.
std::cout << "periods=1000000 lines=" << actual.size() << " build_ms=" << std::chrono::duration<double, std::milli>(query_start - build_start).count() << " frame_ms=" << std::chrono::duration<double, std::milli>(finish - query_start).count() / 100 << std::endl; // Отчёт позволяет сравнить задержку до и после исправления.
return 0; // Нулевой код означает прохождение функциональных и нагрузочных проверок.
} // Все ресурсы индекса освобождаются автоматически после завершения теста.
+31
View File
@@ -0,0 +1,31 @@
#include "scope_demo.h" // Проверяем тот же переносимый источник, который подключён к настоящему DSView.
#include <cassert> // Ошибка физической модели должна завершать нативную проверку ненулевым кодом.
#include <limits> // Нечисловые значения воспроизводят повреждённые входные параметры.
int main() { // Проверка не требует Qt, аппаратного анализатора или платы Zynq.
const double epsilon = 1e-9; // Допуск 1 нВ учитывает погрешность вычисления sin, сохраняя проверку амплитуды в вольтах.
for (unsigned channel = 0; channel < 4; ++channel) { // Все каналы проверяются независимо, включая две разные формы сигнала.
const double period = 1 / set_scope::hz[channel]; // Период в секундах рассчитывается из частоты исходного эмулятора Zynq.
assert(std::abs(set_scope::value(channel, period / 4) - set_scope::peak[channel]) < epsilon); // Четверть периода даёт положительный пик у обеих форм.
assert(std::abs(set_scope::value(channel, 3 * period / 4) + set_scope::peak[channel]) < epsilon); // Три четверти периода дают отрицательный пик.
assert(std::abs(set_scope::value(channel, -period / 4) + set_scope::peak[channel]) < epsilon); // Отрицательное время сохраняет непрерывность фазы при прокрутке.
assert(std::abs(set_scope::value(channel, period / 8) - set_scope::value(channel, period * 5.125)) < epsilon); // Сдвиг на пять целых периодов не изменяет напряжение.
const auto full = set_scope::extrema(channel, 0, 500); // Пятьсот секунд моделируют очень длинную развёртку с сотнями тысяч периодов в пикселе.
assert(full.low == -set_scope::peak[channel] && full.high == set_scope::peak[channel]); // Огибающая обязана сохранять оба пика без алиасинга.
const auto narrow = set_scope::extrema(channel, period * 0.20, period * 0.30); // Внутренний максимум нельзя обнаружить только по концам интервала.
assert(narrow.high == set_scope::peak[channel]); // Аналитический расчёт обязан найти пик между экранными отсчётами.
const auto reverse = set_scope::extrema(channel, period * 0.30, period * 0.20); // Переставленные границы воспроизводят обратное направление выделения.
assert(reverse.low == narrow.low && reverse.high == narrow.high); // Направление просмотра не должно менять диапазон напряжения.
for (int index = 0; index < 300; ++index) { // Непериодическая сетка интервалов выявляет ошибки поиска внутренних экстремумов.
const double first = (index - 150) * period / 37, last = first + period / 13; // Интервалы охватывают отрицательное время и разные положения относительно фронтов.
const auto range = set_scope::extrema(channel, first, last); // Огибающая вычисляется производственным алгоритмом с постоянной сложностью.
for (int sample = 0; sample <= 100; ++sample) { // Плотная независимая выборка проверяет отсутствие потерянных пиков и фронтов.
const double value = set_scope::value(channel, first + (last - first) * sample / 100); // Напряжение берётся внутри проверяемого временного интервала.
assert(value >= range.low - epsilon && value <= range.high + epsilon); // Ни одно значение идеального источника не должно выходить за рассчитанную огибающую.
} // Проверка включает меандр с разрывами и гладкий синус.
} // Всего проверяется более 120 тысяч точек на различных интервалах.
} // Проверены частоты, амплитуды, фаза и длинная развёртка всех четырёх источников.
assert(set_scope::value(4, 0) == 0); // Несуществующий пятый канал безопасно отклоняется.
assert(set_scope::value(0, std::numeric_limits<double>::infinity()) == 0); // Бесконечное время не распространяет NaN в геометрию Qt.
assert(set_scope::extrema(0, 0, std::numeric_limits<double>::quiet_NaN()).high == 0); // Повреждённая граница интервала возвращает определённый пустой диапазон.
return 0; // Нулевой код сообщает об успешной нативной проверке модели Zynq.
} // Исполняемый файл можно запускать на любой платформе с компилятором C++11.
+1 -1
View File
@@ -48,7 +48,7 @@ sys.path.insert(0, str(root))
stub = types.ModuleType('sigrokdecode')
stub.Decoder = type('Decoder', (), {})
sys.modules['sigrokdecode'] = stub
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart', 'tms_uart', 'plates_pbus', 'plates_sbus', 'pwm_waveform'): # Все анализаторы, включая обе 96-контактные шины, загружаются без установленного SETGUI.
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart', 'tms_uart', 'plates_pbus', 'plates_sbus', 'pwm_waveform', 'sigma_delta'): # Изолированный Python должен импортировать все устанавливаемые анализаторы, включая сигма-дельта и его общее ядро.
module = importlib.import_module(name + '.pd')
assert module.Decoder.api_version == 3
core = importlib.import_module('common.setgui_decoders.gate_timing')
+30
View File
@@ -0,0 +1,30 @@
"""Проверка точности и масштабируемости нативного индекса кривой PWM.""" # Тест компилирует фактическую реализацию DSView без подключения прибора.
import os # Изолированное окружение содержит DLL выбранного MSYS2 SDK.
from pathlib import Path # Пути вычисляются относительно теста и настроенного SDK.
import subprocess # Нативный тест запускается отдельным процессом с проверкой кода возврата.
import tempfile # Исполняемый файл создаётся вне исходников и автоматически удаляется.
import unittest # Проверка входит в существующий набор unittest discover.
class PwmEnvelopeTests(unittest.TestCase): # Функциональные и нагрузочные сценарии используют реальный заголовок C++.
def test_native_extrema_zoom_gaps_and_million_periods(self): # Один запуск проверяет данные и ограниченную геометрию длинного захвата.
candidates = [Path(os.environ.get('SETGUI_MSYS_ROOT', 'C:/msys64')), Path('C:/setcorp/tools/msys64')] # Учитываем явную настройку и оба поддерживаемых расположения SDK.
prefix = next((root / 'ucrt64' for root in candidates if (root / 'ucrt64/bin/g++.exe').is_file()), None) # Компилятор должен принадлежать тому же SDK, что заголовки и DLL Qt.
if os.name != 'nt' or prefix is None: # Переносимые Python-тесты продолжают работать без необязательного нативного SDK.
self.skipTest('Для нативного теста необходим Windows MSYS2 UCRT64 с Qt 5.') # Причина пропуска видна в отчёте unittest.
recipe = Path(__file__).resolve().parents[1] # Заголовок и тест берутся из одного рецепта DSView.
env = dict(os.environ) # Сохраняем стандартные системные настройки процесса компилятора.
env['PATH'] = str(prefix / 'bin') + os.pathsep + env.get('PATH', '') # Компилятор и тест находят DLL UCRT64 без глобального изменения PATH.
with tempfile.TemporaryDirectory(prefix='pwm-envelope-') as temporary: # Все создаваемые бинарные файлы принадлежат только текущему тесту.
executable = Path(temporary) / 'pwm-envelope.exe' # Результат компиляции не меняет установленный DSView.
includes = [recipe / 'native', prefix / 'include', prefix / 'include/QtCore', prefix / 'include/QtGui'] # QtGui предоставляет используемые заголовком типы QPainter и QLineF.
command = [str(prefix / 'bin/g++.exe'), '-std=c++11', '-O2', *('-I' + str(path) for path in includes), str(recipe / 'tests/pwm_envelope.cpp'), '-L' + str(prefix / 'lib'), '-lQt5Gui', '-lQt5Core', '-o', str(executable)] # Стандарт C++11 совпадает с нативной сборкой DSView, оптимизация моделирует рабочий компонент.
compiled = subprocess.run(command, env=env, capture_output=True, text=True, errors='replace', timeout=60) # Ограничение времени выявляет зависший компилятор без бесконечного ожидания.
self.assertEqual(compiled.returncode, 0, compiled.stdout + compiled.stderr) # Ошибки компиляции сообщаются вместе с диагностикой SDK.
checked = subprocess.run([str(executable)], env=env, capture_output=True, text=True, errors='replace', timeout=30) # Миллион периодов должен обрабатываться в ограниченное время.
self.assertEqual(checked.returncode, 0, checked.stdout + checked.stderr) # Сам C++-тест независимо проверяет экстремумы, разрывы, очистку и точный крупный масштаб.
print(checked.stdout.strip()) # Измеренная задержка кадра сохраняется в отчёте для сравнения производительности.
if __name__ == '__main__': # Файл можно запустить отдельно или через общий unittest discover.
unittest.main() # Стандартный запуск возвращает ненулевой код при нарушении проверяемых свойств.
+149
View File
@@ -0,0 +1,149 @@
"""Проверки измерения несущей ШИМ и электрической частоты поля по цифровым фронтам.""" # Синтетические записи проверяют численные результаты без оборудования и установленного 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() # Ненулевой код завершения сообщает о нарушении измерительного контракта.
+179
View File
@@ -0,0 +1,179 @@
"""Независимые электрические проверки модели двигателя и цифрового адаптера.""" # Проверки не требуют анализатора, GUI или установленного sigrok.
import cmath # Комплексное сопротивление схемы замещения даёт независимый эталон установившегося тока.
import importlib.util # Исходные модули загружаются напрямую из канонической поставки templates.
import math # Синусоидальные напряжения и допуски выражаются в физических единицах СИ.
from pathlib import Path # Пути вычисляются относительно самого набора тестов.
import sys # Заглушка sigrok действует только при импорте адаптера.
import types # Минимальный модуль окружения не зависит от установленного DSView.
import unittest # Проверки доступны в стандартном Python без сторонних зависимостей.
from unittest.mock import patch # Подмена импортов ограничивается контекстом одной проверки.
ROOT = Path(__file__).resolve().parents[3] # Корень templates содержит общую Python-модель и инструменты DSView.
SPEC = importlib.util.spec_from_file_location('motor_core_test', ROOT / 'python/logic_analyzer/decoders/pwm_motor.py') # Проверяется фактический исходник поставляемого физического ядра.
core = importlib.util.module_from_spec(SPEC) # Отдельное пространство имён исключает установленную устаревшую копию модели.
SPEC.loader.exec_module(core) # Классы модели доступны без импорта остальных протоколов проекта.
class MotorTests(unittest.TestCase): # Эталоны проверяют физические свойства, а не воспроизводят матричный алгоритм реализации.
def test_sine_matches_independent_steady_state_equivalent_circuit(self): # Установившийся ток сравнивается с классической Т-схемой замещения при разных скольжениях.
omega, peak, dt = math.tau * 50, 230.0, 0.0001 # Поле 50 Гц, фазная амплитуда 230 В и шаг представления 100 мкс задают независимый режим испытания.
for rpm in (0, 1440, 1500, 1560, -1440): # Неподвижный ротор, двигатель, синхронная скорость, рекуперация и реверс проверяют знак вращательных членов.
with self.subTest(rpm=rpm): # Ошибка указывает механическую скорость, при которой нарушен эталон.
motor = core.InductionMotor(speed_rpm=rpm) # Используются явно документированные примерные параметры двигателя.
for step in range(20000): # Две секунды позволяют затухнуть электрическим переходным процессам.
voltage = peak * cmath.exp(1j * omega * (step + 0.5) * dt) # Значение в середине интервала аппроксимирует непрерывный источник с малой известной погрешностью.
motor.advance(dt, core.phases(voltage)) # Воздействие подаётся как три реальных фазных напряжения.
slip = 1 - rpm * 2 * math.tau / 60 / omega # Две пары полюсов переводят механическую скорость ротора в относительное скольжение.
magnetizing = 1j * omega * 0.15 # Примерная взаимная индуктивность 150 мГн переводится в комплексное сопротивление на 50 Гц.
if abs(slip) < 1e-10: # При нулевом скольжении ветвь приведённого ротора имеет бесконечное активное сопротивление.
parallel = magnetizing # Остаётся только ток намагничивания и ток потерь статора.
else: # В остальных режимах роторная ветвь содержит активную часть Rr/s и индуктивность рассеяния.
rotor = 0.8 / slip + 1j * omega * 0.005 # 0,8 Ом и 5 мГн соответствуют независимой схеме замещения одной обмотки.
parallel = magnetizing * rotor / (magnetizing + rotor) # Параллельное соединение ветвей ротора и намагничивания вычисляется по закону Ома.
expected = peak / (1.0 + 1j * omega * 0.005 + parallel) # Последовательные потери статора и рассеяние дают эталон комплексного фазного тока.
self.assertLess(abs(motor.current - expected), abs(expected) * 0.002) # Амплитуда и фазовый сдвиг должны совпасть с независимым эталоном в пределах 0,2 процента.
def test_constant_voltage_matches_ohms_law_at_locked_rotor(self): # При постоянном токе и неподвижном роторе установившийся ток определяется сопротивлением статора.
motor = core.InductionMotor(speed_rpm=0) # Нулевая скорость исключает генераторную ЭДС вращения.
result = motor.advance(10, (100, -50, -50)) # Десять секунд значительно превосходят постоянные времени примерной машины.
self.assertAlmostEqual(result[0], 100, places=7) # При Rs=1 Ом напряжение 100 В создаёт ток 100 А.
self.assertAlmostEqual(sum(result), 0, places=12) # Трёхпроводная модель не создаёт паразитный ток нейтрали.
def test_transition_is_independent_of_plot_sampling(self): # Число точек графика не должно изменять физический ток при неизменном напряжении.
one, many = core.InductionMotor(), core.InductionMotor() # Начальные параметры и состояния обоих независимых двигателей одинаковы.
one.advance(0.05, (180, -130, -50)) # Один большой интервал проверяет устойчивость точного решения.
for step in range(500): # Второй расчёт разбивает те же 50 мс на небольшие участки.
many.advance(0.0001, (180, -130, -50)) # Физическое воздействие не меняется при добавлении выходных точек.
self.assertLess(abs(one.current - many.current), 1e-8) # Совпадение отделяет модельную точность от частоты отображения.
self.assertLess(abs(one.flux - many.flux), 1e-9) # Поток ротора также не зависит от выходной сетки.
def test_invalid_physical_parameters_are_rejected(self): # Неконечные и нефизические значения не должны доходить до матричной экспоненты.
for settings in ({'rs': 0}, {'rr': -1}, {'lls_mh': 0}, {'llr_mh': -1}, {'lm_mh': float('nan')}, {'pole_pairs': 1.5}, {'speed_rpm': float('inf')}): # Граничные условия проверяют разные физические входы.
with self.subTest(settings=settings), self.assertRaises(ValueError): # Для каждого некорректного параметра ожидается явная диагностика.
core.InductionMotor(**settings) # Ошибка должна обнаруживаться при создании модели.
class InverterTests(unittest.TestCase): # Электрическая топология проверяется отдельно от переходной модели двигателя.
def gates(self, upper, lower=None, inner=None): # Набор затворов следует публичному порядку входов адаптера.
return tuple(upper) + tuple(lower if lower is not None else [None] * 3) + tuple(inner if inner is not None else [None] * 6) # Необязательные входы отличаются от измеренного логического нуля.
def test_two_level_dc_voltage_scales_current(self): # Удвоение напряжения в линейной модели должно удвоить ток каждого проводника.
values = [] # Сохраняем две независимые оценки после одинакового переключения.
for dc in (300, 600): # Различается только полное напряжение между шинами звена.
model = core.PwmMotor(1000000, dc_voltage=dc) # Частота 1 МГц переводит 1000 отсчётов в одну миллисекунду.
model.update(0, self.gates((1, 0, 0))) # Первое состояние задаёт положительное линейное напряжение фазы A.
values.append(model.update(1000)[3]) # Постоянный участок рассчитывается даже без единого дополнительного фронта.
for small, large in zip(*values): # Каждая фаза должна соблюдать ту же линейную зависимость.
self.assertAlmostEqual(large, 2 * small, places=8) # Никакая нормировка графика не участвует в физических амперах.
def test_common_mode_voltage_does_not_drive_floating_star(self): # Одинаковое переключение всех плеч не создаёт линейных напряжений.
model = core.PwmMotor(1000) # Симметричная машина подключена звездой без нейтрального провода.
model.update(0, self.gates((1, 1, 1))) # Все выходы подключены к одной и той же положительной шине.
self.assertEqual(model.update(100)[3], (0, 0, 0)) # Синфазный ШИМ не должен вызывать ложный двигательному току сигнал.
def test_three_level_encoding_and_midpoint_imbalance(self): # P/N-кодировка должна различать плюс, среднюю точку и минус.
model = core.PwmMotor(1000, topology='3-level P/N', dc_voltage=600, midpoint_percent=60) # Дисбаланс 60/40 процентов создаёт шины +360 и −240 В.
model.update(0, self.gates((1, 0, 0), (0, 0, 1))) # Команды фаз равны 10, 00 и 01 соответственно.
self.assertEqual(model.poles, (360, 0, -240)) # Ни один из трёх уровней не выводится из заполнения других фаз.
self.assertEqual(model.warnings, ()) # Все три команды допустимы для P/N.
def test_npc_twelve_gates_match_signed_three_level_commands(self): # Реальные Q1…Q4 должны создавать те же напряжения, что явная кодировка P/N.
npc = core.PwmMotor(1000000, topology='3-level NPC') # Четыре ключа на фазу требуют двенадцати назначенных входов.
pn = core.PwmMotor(1000000, topology='3-level P/N') # Сравниваем электрическое воздействие, а не внутренние булевы поля.
npc.update(0, self.gates((1, 0, 0), (0, 0, 1), (1, 0, 1, 1, 0, 1))) # Q1…Q4 фаз A/B/C равны 1100, 0110 и 0011.
pn.update(0, self.gates((1, 0, 0), (0, 0, 1))) # P/N фаз A/B/C равны 10, 00 и 01.
self.assertEqual(npc.poles, pn.poles) # Разные входные кодировки должны давать одинаковые полюсные напряжения.
self.assertEqual(npc.update(1000)[3], pn.update(1000)[3]) # Совпадение физических токов проверяет весь путь до модели двигателя.
def test_delta_reports_line_currents(self): # Для тех же сопротивлений обмотки линейный постоянный ток треугольника втрое больше звезды.
values = [] # Сохраняем установившиеся токи двух схем подключения.
for connection in ('star', 'delta'): # Меняется соединение обмоток при одинаковых параметрах каждой обмотки.
model = core.PwmMotor(1000, connection=connection, speed_rpm=0) # Неподвижный ротор и постоянное напряжение дают простой независимый эталон.
model.update(0, self.gates((1, 0, 0))) # Полюсное напряжение инвертора одинаково в обоих опытах.
values.append(model.update(10000)[3]) # Десять секунд обеспечивают завершение электрического переходного процесса.
self.assertAlmostEqual(values[1][0], 3 * values[0][0], places=6) # График должен показывать именно ток провода, а не ток одной ветви треугольника.
def test_invalid_or_missing_gates_are_explicit(self): # Невозможные комбинации не должны выглядеть как достоверно рассчитанное напряжение.
model = core.PwmMotor(1000, topology='3-level P/N') # В этом режиме нижние команды всех фаз обязательны.
with self.assertRaises(ValueError): # Служебное отсутствие канала не равнозначно команде средней точки.
model.update(0, self.gates((1, 0, 0))) # Назначены только три верхних входа вместо шести.
model.update(0, self.gates((1, 0, 0), (1, 0, 0))) # Команда 11 в фазе A недопустима.
self.assertIn('invalid gates 11', model.warnings[0]) # Предупреждение должно содержать реальную ошибочную комбинацию.
with self.assertRaises(ValueError): # Время не может двигаться назад при потоковом декодировании.
model.update(-1) # Проверяется явная ошибка входной временной отметки.
def test_active_low_preserves_electrical_waveform(self): # Глобальная инверсия затворных сигналов не должна менять физическое воздействие.
high, low = core.PwmMotor(1000), core.PwmMotor(1000, active_high=False) # Различается только настройка активной полярности входов.
high.update(0, self.gates((1, 0, 0))) # Высокоактивный верхний ключ A подключает фазу к положительной шине.
low.update(0, self.gates((0, 1, 1))) # Инвертированные физические уровни задают те же включённые ключи.
self.assertEqual(high.update(10)[3], low.update(10)[3]) # Токи совпадают после нескольких периодических точек без фронтов.
def test_interval_warnings_belong_to_previous_gate_state(self): # Восстановление допустимой команды не должно скрывать ошибку предшествующего участка тока.
model = core.PwmMotor(1000, topology='3-level P/N') # Команды P/N позволяют явно подать невозможное одновременное включение.
model.update(0, self.gates((1, 0, 0), (1, 0, 0))) # На первом интервале фаза A имеет недопустимую комбинацию 11.
model.update(10, self.gates((1, 0, 0), (0, 0, 0))) # В конце интервала входы переключаются в допустимое состояние 10.
self.assertEqual(model.warnings, ()) # Новый интервал уже имеет определённое выходное напряжение.
self.assertIn('invalid gates 11', model.interval_warnings[0]) # Завершённый пакет сохраняет реальную недостоверность предыдущих десяти отсчётов.
class AdapterTests(unittest.TestCase): # Минимальный движок ожидания проверяет настоящий цикл decode на постоянном участке и на фронтах.
def decoder(self, settings=None): # Каждая проверка получает новый адаптер и собственный журнал выходов.
class FakeDecoder: # Совпадают только необходимые адаптеру публичные методы sigrok.
def register(self, kind): # Дескриптор позволяет различать аннотации и структурированный поток.
return kind # Нативная библиотека для этого теста не нужна.
def has_channel(self, index): # В базовом опыте назначены три двухуровневых верхних команды.
return index < 3 # Отсутствующие нижние ключи моделируются идеальными комплементарными парами.
def put(self, start, end, output, data): # Выходы собираются без изменения типов и единиц данных адаптера.
self.emitted.append((start, end, output, data)) # Время и содержимое доступны независимым утверждениям теста.
def wait(self, conditions): # Виртуальный захват содержит фронт на отсчёте 23 и заканчивается на отсчёте 103.
if conditions == {}: # Начальный запрос должен вернуть уровни без ожидания первого фронта.
self.samplenum = 0 # Область декодирования начинается с первого отсчёта.
else: # Следующее событие выбирается между фактическим фронтом и точкой периодической сетки.
boundary = self.samplenum + conditions[-1]['skip'] # Последнее условие адаптера задаёт максимальный интервал до новой точки.
self.samplenum = min(boundary, 23) if self.samplenum < 23 else boundary # Фронт не должен пропасть между узлами выходной сетки.
if self.samplenum > 103: # Окончание захвата не создаёт вымышленного полного выходного окна.
self.samplenum = 103 # Последний известный отсчёт используется штатным end.
raise EOFError # Тестовая остановка имитирует завершение wait в движке декодирования.
return ([1, 0, 0] if self.samplenum < 23 else [0, 1, 0]) + [255] * 9 # Уровни меняются одновременно; служебные 255 отсутствующих входов фильтруются адаптером.
stub = types.ModuleType('sigrokdecode') # Окружение импорта ограничено заглушкой публичных констант.
stub.Decoder, stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = FakeDecoder, 0, 1, 10000 # Значения выходов различимы в журнале теста.
with patch.dict(sys.modules, {'sigrokdecode': stub, 'common.setgui_decoders.pwm_motor': core}): # Реальный адаптер связывается с фактической проверяемой моделью.
spec = importlib.util.spec_from_file_location('motor_adapter_test', ROOT / 'tools/dsview/decoders/pwm_motor/pd.py') # Загружается исходный модуль, который попадёт в runtime.
module = importlib.util.module_from_spec(spec) # Независимый экземпляр исключает влияние предыдущих настроек.
spec.loader.exec_module(module) # Определение класса выполняется в контролируемом окружении sigrok.
decoder = module.Decoder() # Состояние двигателя сбрасывается штатным конструктором.
decoder.options = {option['id']: option['default'] for option in decoder.options} # Настройки по умолчанию воспроизводят поведение диалога DSView.
decoder.options.update(settings or {}) # Проверка может изменить шкалу или частоту отображения.
decoder.emitted, decoder.samplenum = [], 0 # Выходной журнал и временная отметка начинаются с пустого захвата.
decoder.metadata(stub.SRD_CONF_SAMPLERATE, 1000000) # Частота 1 МГц задаёт 10 отсчётов на точку при выходе 100 кГц.
decoder.start() # Оба типа выхода регистрируются перед decode.
return decoder # Дальнейшая проверка запускает настоящий цикл адаптера.
def test_real_decode_keeps_edges_constant_spans_units_and_final_tail(self): # Один сценарий проверяет временную сетку, фронты, амперы и конец записи.
decoder = self.decoder() # Используются реальные настройки по умолчанию, включая частоту 100 кГц.
with self.assertRaises(EOFError): # Конец виртуальной записи штатно прерывает ожидание событий.
decoder.decode() # Выполняется фактический код адаптера без ручного вызова его внутренних методов.
decoder.samplenum, decoder.last_samplenum = 100, 103 # DSView сохраняет samplenum последнего wait и передаёт окончание записи отдельным атрибутом.
decoder.end() # Последний частичный интервал до отсчёта 103 должен быть сохранён.
packets = [event for event in decoder.emitted if event[2] == 1] # Структурированный поток содержит ненормированные линейные токи.
self.assertEqual(packets[0][0], 0) # Начальный постоянный участок до первого фронта не теряется.
self.assertEqual(packets[-1][1], 103) # Конец не округляется до следующего полного окна в 110 отсчётов.
self.assertIn(23, [event[1] for event in packets]) # Фронт между точками 20 и 30 должен завершить собственный сегмент.
self.assertTrue(all(event[3][1]['unit'] == 'A' and event[3][1]['estimated'] for event in packets)) # Амперы и расчётный характер доступны следующему анализатору.
self.assertTrue(all(abs(sum(event[3][1][key] for key in ('ia', 'ib', 'ic'))) < 1e-9 for event in packets)) # Все выходные точки соблюдают первый закон Кирхгофа.
warnings = [event for event in decoder.emitted if event[2] == 0 and event[3][0] == 3] # Диагностическая строка должна охватить всю оценённую запись.
self.assertEqual((warnings[0][0], warnings[-1][1]), (0, 103)) # Даже неизменная диагностика завершается при end.
def test_plot_clipping_keeps_unclipped_amperes(self): # Узкая шкала не должна ограничивать физический расчёт двигателя.
decoder = self.decoder({'plot_range_a': 0.001}) # Один миллиампер намного меньше токов выбранного испытательного напряжения.
with self.assertRaises(EOFError): # Виртуальная запись заканчивается тем же механизмом, что и обычный сценарий.
decoder.decode() # Адаптер рассчитывает ток, превышающий заданную графическую шкалу.
decoder.end() # Превышение шкалы должно попасть и в последний интервал диагностики.
packets = [event[3][1] for event in decoder.emitted if event[2] == 1] # Численные результаты извлекаются из структурированного выхода.
self.assertTrue(any(abs(packet['ia']) > 0.001 and packet['outside_plot_range'] for packet in packets)) # Истинные амперы сохраняются при насыщении только изображения.
curves = [event for event in decoder.emitted if event[2] == 0 and event[3][0] < 3] # Только графические классы используют служебный формат PWM1.
self.assertTrue(all(abs(float(value)) <= 1 for event in curves for value in event[3][1][-1].split()[1:])) # Координаты остаются совместимыми с ограниченным нативным индексом экстремумов.
if __name__ == '__main__': # Набор можно запускать отдельно от GUI и других тестов проекта.
unittest.main() # Код возврата отражает численные и интеграционные проверки.
+181
View File
@@ -0,0 +1,181 @@
"""Проверки 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() # Ошибка любого нового режима возвращает ненулевой код завершения.
+4
View File
@@ -47,6 +47,10 @@ class RuntimeTests(unittest.TestCase):
(recipe / 'protocol-files.json').write_bytes(RECIPE.with_name('protocol-files.json').read_bytes()) # Реальная ведомость гарантирует включение новых зависимостей и карт.
files = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h',
recipe / 'decoders/gate_driver_timing/pd.py']
files += [recipe / 'pwm-waveform.patch', recipe / 'native/pwm_waveform.h'] # Изменение отрисовки или индекса PWM тоже должно делать установленный runtime устаревшим.
files += [recipe / 'zynq-ethernet/ethernet.patch'] # Проверяем, что исправление перетаскивания дорожек не признаёт прежний нативный компонент актуальным.
files += [recipe / 'scope-channel.patch', recipe / 'native/scope_demo.h', recipe / 'native/scope_trace.h', recipe / 'motor-currents.patch'] # Изменения шкалы фазных токов должны делать прежний нативный компонент устаревшим вместе с остальными графическими патчами.
files += [recipe / 'decoder-scope.patch', recipe / 'native/decoder_scope.h'] # Проверяется обязательная пересборка при изменении независимых окон осциллограмм или их интеграции с исходными графиками.
files += [root / item.relative_to(RECIPE.parents[2]) for item, _ in BUILD.protocol_payload()] # Проверка охватывает все канонические модули и JSON-карты, перечисленные в поставке.
for item in files:
item.parent.mkdir(parents=True, exist_ok=True) # Для каждого переносимого пакета создаются вложенные каталоги.
+17
View File
@@ -0,0 +1,17 @@
"""Нативная проверка напряжений, фазы и сохранения экстремумов эмулятора Zynq.""" # Тест использует производственный заголовок без отдельной реализации генератора.
import os # Путь к DLL компилятора задаётся только дочернему проверочному процессу.
from pathlib import Path # Пути вычисляются относительно переносимого рецепта DSView.
import shutil # Системный компилятор обнаруживается без установки новых зависимостей.
import subprocess # Настоящая C++-модель компилируется и исполняется в отдельном процессе.
import tempfile # Исполняемые артефакты не попадают в исходники рецепта.
import unittest # Проверка включается в существующий набор тестов templates.
class ScopeDemoTests(unittest.TestCase): # Один нативный сценарий проверяет все четыре канала и крайние случаи времени.
def test_native_signal_and_envelope(self): # Числовые утверждения выполняются компилятором того же семейства, что сборка DSView.
compiler = shutil.which('g++') or ('C:/msys64/ucrt64/bin/g++.exe' if Path('C:/msys64/ucrt64/bin/g++.exe').is_file() else None) # Используем уже установленный C++ SDK, не меняя настройки компьютера.
if not compiler: self.skipTest('Для нативной проверки нужен компилятор g++') # Отсутствие необязательного SDK явно видно в результате тестирования.
here = Path(__file__).resolve().parent # Исходник проверки и производственный заголовок принадлежат одному checkout.
environment = dict(os.environ, PATH=str(Path(compiler).parent) + os.pathsep + os.environ.get('PATH', '')) # Windows находит DLL UCRT64 без копирования в системные каталоги.
with tempfile.TemporaryDirectory(prefix='scope-demo-') as temporary: # Каждый запуск получает собственный исполняемый файл.
executable = Path(temporary) / ('scope-test.exe' if os.name == 'nt' else 'scope-test') # Расширение соответствует платформе проверочного процесса.
subprocess.run([compiler, '-std=c++11', '-O2', '-I', str(here.parent / 'native'), str(here / 'scope_demo_test.cpp'), '-o', str(executable)], env=environment, check=True, capture_output=True) # Проверяем компиляцию модели без Qt и аппаратных библиотек.
subprocess.run([str(executable)], env=environment, check=True, capture_output=True) # Ненулевой код любого числового утверждения становится провалом теста.
+217
View File
@@ -0,0 +1,217 @@
"""Контрольные потоки, независимая FIR-модель и проверка поставки сигма-дельта декодера.""" # Проверки не требуют настоящего прибора и установленного DSView.
import ast # Совместимость синтаксиса проверяется для Python 3.6 старых установок DSView.
import importlib.util # Ядро и адаптер загружаются непосредственно из проверяемых исходников.
import random # Закреплённое зерно формирует воспроизводимый поток для независимой свёртки.
from pathlib import Path # Пути вычисляются относительно templates, независимо от текущего каталога.
import subprocess # Изолированный Python проверяет переносимость упакованного декодера.
import sys # Исполняемый файл тестового Python используется для проверки чистого импорта.
import tempfile # Упаковка выполняется в отдельный каталог без изменения установленного DSView.
import types # Минимальный модуль sigrokdecode подменяет только границу API приложения.
import unittest # Тесты доступны через стандартный unittest discovery.
import weakref # Модель движка проверяет освобождение внутреннего буфера уровней перед следующим wait.
from unittest.mock import patch # Подмена импортов действует только во время загрузки адаптера.
ROOT = Path(__file__).resolve().parents[3] # Общий корень содержит python и tools/dsview одного checkout templates.
def load(name, path): # Общая загрузка не добавляет исходники в глобальный путь поиска Python.
spec = importlib.util.spec_from_file_location(name, ROOT / path) # Явный путь исключает случайный импорт установленной старой версии.
module = importlib.util.module_from_spec(spec) # Каждый вызов создаёт самостоятельное пространство имён.
spec.loader.exec_module(module) # Выполняем настоящий исходник, а не его копию в тесте.
return module # Тесты получают и фильтр, и рецепт упаковки без зависимостей от GUI.
CORE = load('sigma_delta_core', 'python/logic_analyzer/decoders/sigma_delta.py') # Одна каноническая реализация используется всеми сценариями.
class CaptureEnd(Exception): # Исключение воспроизводит остановку wait на конце конечного цифрового захвата.
pass # Дополнительные данные не нужны: неполный блок остаётся только во внутреннем состоянии фильтра.
class PinBuffer: # Возвращаемый движком буфер нельзя удерживать между вызовами wait в libsigrokdecode4DSL.
def __init__(self, pins): # Обёртка позволяет обнаружить оставшуюся ссылку без зависимости от счётчика ссылок Python.
self.pins = pins # Храним уровни только одного выбранного фронта захвата.
def __iter__(self): # Копирование в список должно сразу освободить временный объект результата wait.
return iter(self.pins) # Новый список получает значения уровней без ссылки на внутренний буфер движка.
def __getitem__(self, index): # Прямое чтение допустимо, но сохранение этого объекта до следующего wait недопустимо.
return self.pins[index] # Так модель воспроизводит обычный доступ к tuple возвращённых уровней.
class FakeDecoder: # Модель границы sigrok принимает настоящие переходы CLK и уровни DATA.
def has_channel(self, channel): # Отсутствующий обязательный канал должен обнаруживаться до ожидания фронтов.
return channel in self.connected # Сценарии отдельно исключают DATA и CLK.
def register(self, kind): # Различаем аннотации и структурированные Python-пакеты.
return kind # Нативные дескрипторы для проверки содержимого выходов не требуются.
def put(self, start, end, kind, data): # Записываем фактические границы и полезную нагрузку каждого результата.
self.emitted.append((start, end, kind, data)) # Поздние проверки могут обнаружить лишний бит или неполный выходной блок.
def wait(self, condition): # Сканируем только переходы CLK требуемой полярности.
if getattr(self, 'last_buffer', lambda: None)() is not None: # Настоящий DSView заполняет прежний tuple через PyTuple_SetItem, требующий отсутствия внешних ссылок.
raise RuntimeError('DSView pin buffer retained across wait calls.') # Регрессия обнаруживается до следующего выхода, как ошибка SystemError нативного движка.
self.conditions.append(condition) # Проверяем, что адаптер запрашивает фронт нужной линии.
for sample, pins in self.trace: # Изменения DATA между фронтами остаются видны, но не запускают приём бита.
previous, self.last_pins = self.last_pins, pins # Храним предыдущий уровень CLK, включая начальный уровень записи.
if previous[1] != pins[1] and pins[1] == (condition == {1: 'r'}): # Нарастающий фронт завершается единицей, спадающий нулём.
self.samplenum = sample # Номер реально выбранного фронта задаёт время результата.
buffer = PinBuffer(pins) # Временная обёртка моделирует принадлежащий движку буфер pinvalues.
self.last_buffer = weakref.ref(buffer) # Слабая ссылка обнаруживает удержание результата адаптером и сама не продлевает его жизнь.
return buffer # DATA берётся в момент CLK, а уровни нужно скопировать до следующего вызова wait.
raise CaptureEnd() # Конец записи не добавляет отсутствующие фронты и не вызывает искусственный flush.
def capture(bits, falling_bits=None, initial_clock=0): # Генерируем физическую трассу с десятью отсчётами на такт.
trace = [] # Начальное состояние задаётся отдельно и не считается событием фронта.
for index, bit in enumerate(bits): # Каждый бит имеет подготовку DATA, нарастающий и спадающий фронты CLK.
trace.append((index * 10 + 1, (bit, 0))) # Установка DATA до такта проверяет отсутствие приёма по его собственному фронту.
trace.append((index * 10 + 3, (bit, 1))) # Через два отсчёта после подготовки приходит нарастающий фронт CLK.
trace.append((index * 10 + 8, (falling_bits[index] if falling_bits is not None else bit, 0))) # Спадающий фронт может захватывать независимое тестовое значение DATA.
return ((0, initial_clock), trace) # Начальное состояние CLK позволяет проверять захват, начавшийся с высокого уровня.
def adapter(options=None, bits=(), falling_bits=None, samplerate=1000000, connected=(0, 1), initial_clock=0): # Все настройки сценария передаются до вызова decode, как в sigrok.
stub = types.ModuleType('sigrokdecode') # Тест не использует установленный на компьютере libsigrokdecode.
stub.Decoder, stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = FakeDecoder, 0, 1, 2 # Числа лишь различают типы выходов и сообщение о частоте.
with patch.dict(sys.modules, {'sigrokdecode': stub, 'common.setgui_decoders.sigma_delta': CORE}): # Явная подмена ядра не затрагивает модули других тестов DSView.
module = load('sigma_delta_adapter', 'tools/dsview/decoders/sigma_delta/pd.py') # Загружаем настоящий адаптер API версии 3.
decoder = module.Decoder() # Конструктор сбрасывает метаданные перед новым захватом.
decoder.options = {item['id']: item['default'] for item in decoder.options} # Начальные настройки берутся из декларации адаптера.
decoder.options.update(options or {}) # Сценарий переопределяет только проверяемые параметры.
decoder.connected = connected # Подключённые каналы учитываются штатным методом has_channel.
initial, trace = capture(bits, falling_bits, initial_clock) # Преобразуем биты в последовательность физических состояний входов.
decoder.last_pins, decoder.trace = initial, iter(trace) # Начальный уровень не возвращается как выбранный тактовый фронт.
decoder.emitted, decoder.conditions = [], [] # Каждый захват получает отдельные журналы результатов и ожиданий.
if samplerate is not None: # В отдельном сценарии частота захвата намеренно неизвестна.
decoder.metadata(2, samplerate) # При десяти отсчётах на такт CLK равен samplerate / 10.
decoder.start() # Регистрируем оба выхода до передачи первых фронтов.
return decoder # Сценарий сам выбирает ожидаемое завершение или ошибку конфигурации.
def samples(decoder): # Выделяем только структурированные выходные отсчёты среди текстовых аннотаций.
return [event for event in decoder.emitted if event[2] == 1] # Дескриптор один соответствует OUTPUT_PYTHON тестового движка.
class SigmaDeltaTests(unittest.TestCase): # Независимые ожидаемые результаты выявляют ошибки порядка каскадов и децимации.
def test_cic_matches_independent_fir_convolution(self): # Каскад скользящих сумм задаёт эталон без интеграторов и гребенчатых разностей.
rng = random.Random(271828) # Постоянное зерно делает ошибку воспроизводимой на любой платформе.
bits = [rng.randrange(2) for _ in range(5000)] # Длинный поток многократно переполняет ограниченные регистры CIC.
for order in (1, 2, 3): # Проверяются все поддерживаемые sinc-фильтры.
for ratio in (1, 2, 3, 7, 16, 256): # Нечётные OSR выявляют скрытую зависимость от степени двойки.
with self.subTest(order=order, ratio=ratio): # Диагностика указывает режим несовпавшего фильтра.
taps = [1] # Импульсная характеристика до первого каскада является единичным импульсом.
for _ in range(order): # Сворачиваем N прямоугольных окон длины OSR.
taps = [sum(taps[max(0, index - ratio + 1):min(len(taps), index + 1)]) for index in range(len(taps) + ratio - 1)] # Коэффициенты строятся прямой FIR-свёрткой без алгоритма проверяемого ядра.
state = CORE.SigmaDeltaFilter(ratio, order) # Каждый режим начинает с чистых регистров.
for index, bit in enumerate(bits): # Фильтру передаются те же биты, что использует независимый эталон.
event = state.feed(bit) # До границы выходного блока результат отсутствует.
ready = (index + 1) % ratio == 0 and index + 1 >= order * ratio # Ожидаемые границы учитывают децимацию и начальное установление.
self.assertEqual(event is not None, ready) # Проверяем также точное число пропущенных и выданных результатов.
if ready: # Прямую свёртку вычисляем только на установившихся выходных отсчётах.
weighted = sum(tap * bits[index - lag] for lag, tap in enumerate(taps)) # FIR использует входную историю и не зависит от внутренних регистров CIC.
self.assertEqual(event['code'], 2 * weighted - sum(taps)) # Целочисленный код обязан совпадать точно, без допуска округления.
self.assertEqual(event['density'], weighted / ratio ** order) # Проверяется нормировка фильтрованной плотности единиц.
self.assertEqual(event['value'], event['code'] / ratio ** order) # Нормированная амплитуда должна сохранять тот же масштаб.
def test_dc_extremes_zero_and_inversion(self): # Контрольные плотности проверяют знак и физически понятные крайние значения.
for order in (1, 2, 3): # Нулевая предыстория не должна смещать постоянный сигнал после установления.
for pattern, expected in (([0], -1), ([1], 1), ([0, 1], 0), ([0, 1, 1, 1], 0.5)): # Плотности 0, 100, 50 и 75 процентов имеют известную нормированную амплитуду.
for invert in (False, True): # Инверсия должна менять знак при любом порядке фильтра.
state = CORE.SigmaDeltaFilter(8, order, invert) # OSR восемь содержит целое число периодов всех контрольных шаблонов.
results = [event['value'] for bit in pattern * 64 for event in [state.feed(bit)] if event is not None] # Собираем только завершённые и установившиеся отсчёты.
self.assertTrue(results) # Пустой выход не может считаться успешной проверкой постоянного сигнала.
self.assertEqual(set(results), {expected * (-1 if invert else 1)}) # Каждый выходной отсчёт обязан иметь правильный знак и масштаб.
def test_invalid_filter_settings_and_logic_levels(self): # Ошибочные настройки должны отклоняться до появления числового результата.
for ratio in (0, -1, 1.5, True, 65537): # Проверяются обе границы диапазона и недопустимые типы OSR.
with self.assertRaises(ValueError): # Тихое округление OSR меняло бы частоту и усиление фильтра.
CORE.SigmaDeltaFilter(ratio) # Используется реальный конструктор ядра.
for order in (0, 4, 1.5, True): # Разрешены только целые порядки один, два и три.
with self.assertRaises(ValueError): # Неизвестный порядок не должен заменяться начальным значением.
CORE.SigmaDeltaFilter(order=order) # Проверка независима от адаптера DSView.
for bit in (-1, 2, 255, None): # Значение 255 моделирует неподключённый цифровой вход sigrok.
with self.assertRaises(ValueError): # Невалидный вход нельзя включать в статистику единиц.
CORE.SigmaDeltaFilter().feed(bit) # Ошибка обнаруживается на первом неправильном уровне.
def test_adapter_edges_rates_voltage_and_partial_tail(self): # Проверяется полный путь от переходов CLK до аннотаций и Python-пакетов.
for edge in ('rising', 'falling'): # Разные значения на двух фронтах выявляют выбор неверной полярности CLK.
decoder = adapter({'filter': 'sinc1', 'decimation': 4, 'clock_edge': edge, 'full_scale': 2.5}, [1] * 11, [0] * 11) # Одиннадцать битов содержат два полных блока и неполный хвост из трёх битов.
with self.assertRaises(CaptureEnd): # Реальный decode завершается только по концу трассы.
decoder.decode() # Все числовые результаты формирует адаптер, а не тестовый фильтр.
output = samples(decoder) # Текстовые строки анализируются отдельно от структурированного канала.
self.assertEqual(len(output), 2) # Последние три бита не должны создавать третий отсчёт.
for start, end, _, packet in output: # Проверяем каждый выданный блок, включая первый после начала записи.
self.assertEqual(end - start, 30) # Четыре фронта CLK охватывают три периода по десять отсчётов.
self.assertEqual(packet[0], 'SAMPLE') # Тип пакета стабилен для последующего стекового декодера.
self.assertEqual(packet[1]['value'], 1 if edge == 'rising' else -1) # DATA на спаде намеренно противоположна уровню на нарастающем фронте.
self.assertEqual(packet[1]['voltage'], 2.5 if edge == 'rising' else -2.5) # Физический масштаб должен умножать уже восстановленную амплитуду.
self.assertEqual(packet[1]['clock_hz'], 100000) # Мегагерц захвата и десять отсчётов на период означают CLK 100 кГц.
self.assertEqual(packet[1]['output_hz'], 25000) # OSR четыре уменьшает частоту результата до 25 кГц.
self.assertEqual({tuple(condition.items()) for condition in decoder.conditions}, {((1, 'r' if edge == 'rising' else 'f'),)}) # Адаптер должен ждать только выбранный фронт второго входа.
self.assertEqual(len(decoder.emitted), 10) # Два результата содержат по четыре аннотации и одному Python-пакету.
def test_adapter_warmup_missing_samplerate_and_initial_high_clock(self): # Начальный высокий CLK не считается фронтом, а отсутствие герц не мешает фильтрации.
decoder = adapter({'decimation': 4}, [1] * 15, samplerate=None, initial_clock=1) # sinc3 пропускает два первых блока и имеет один полный установленный результат.
with self.assertRaises(CaptureEnd): # Пятнадцатый бит оставляет незавершённый четвёртый блок.
decoder.decode() # Конец захвата не должен дополнять его шестнадцатым битом.
output = samples(decoder) # Результаты должны существовать без метаданных частоты.
self.assertEqual(len(output), 1) # Выдаётся только третий полный блок после двух блоков установления.
self.assertEqual(output[0][:2], (83, 113)) # Третий блок содержит реальные нарастающие фронты девятого–двенадцатого битов.
self.assertEqual(output[0][3][1]['value'], 1) # Начальное установление не должно создавать отрицательное смещение постоянного DATA.
self.assertIsNone(output[0][3][1]['clock_hz']) # Без частоты захвата нельзя присваивать CLK вымышленное значение.
self.assertEqual({event[3][0] for event in decoder.emitted if event[2] == 0}, {0, 1, 2}) # Три амплитудные строки остаются, строка частоты отсутствует.
def test_single_bit_decimation_and_reset(self): # OSR один проверяет отсутствие деления на ноль и границы точечных аннотаций.
decoder = adapter({'filter': 'sinc1', 'decimation': 1, 'invert': 'yes'}, [0, 1]) # Инверсия должна действовать до формирования нормированного кода.
with self.assertRaises(CaptureEnd): # Два фронта дают два результата и затем штатный конец захвата.
decoder.decode() # Первая частота неизвестна, вторая измеряется по предыдущему фронту.
output = samples(decoder) # В режиме OSR один каждое принятое значение создаёт пакет.
self.assertEqual([event[3][1]['value'] for event in output], [1, -1]) # Получаем инвертированные уровни в биполярном диапазоне.
self.assertIsNone(output[0][3][1]['clock_hz']) # Один фронт не позволяет измерить период.
self.assertEqual(output[1][3][1]['output_hz'], 100000) # При OSR один выходная частота совпадает с CLK.
self.assertTrue(all(start == end for start, end, _, _ in output)) # Точечный блок не получает искусственно удлинённый временной интервал.
decoder.reset() # Повторное использование объекта начинает новый захват без старых метаданных.
self.assertIsNone(decoder.samplerate) # Частота предыдущего прибора не должна переноситься в новый захват.
def test_adapter_rejects_missing_channels_and_invalid_options(self): # Некорректная конфигурация не должна дожидаться первого такта.
for connected in ((0,), (1,), ()): # Поочерёдно отсутствуют CLK, DATA и оба входа.
with self.assertRaises(ValueError): # Требование обязательных каналов проверяет сам адаптер.
adapter(connected=connected).decode() # Пустая трасса отличает раннюю проверку от случайного успешного чтения.
for options in ({'full_scale': 0}, {'full_scale': -1}, {'full_scale': float('nan')}, {'full_scale': float('inf')}, {'filter': 'sinc4'}, {'clock_edge': 'both'}, {'invert': 'maybe'}, {'decimation': 2.5}): # Ошибки охватывают масштаб, перечисления и тип OSR.
with self.assertRaises(ValueError): # Ни одна ошибка не должна превращаться в CaptureEnd или числовой отсчёт.
adapter(options).decode() # Проверяются фактические параметры нового декодера.
for rate in (0, -1, float('nan'), float('inf')): # Частота захвата задаётся только положительным конечным числом.
with self.assertRaises(ValueError): # Метаданные проверяются до расчёта частоты CLK.
adapter(samplerate=rate) # Неверная частота не должна попадать в выходной пакет.
def test_packaged_adapter_imports_in_isolated_python(self): # Ведомость должна включать адаптер и каноническое ядро без установленного SETGUI.
build = load('sigma_delta_packager', 'tools/dsview/build.py') # Используем штатную упаковку встроенного DSView.
with tempfile.TemporaryDirectory() as temporary: # Каталог содержит только подготовленную поставку, без внешних зависимостей.
root = Path(temporary) # Все файлы проверки ограничены временной директорией.
build.package_decoders(root) # Общий рецепт копирует адаптеры и ядра из protocol-files.json.
script = '\n'.join(( # Изолированный процесс не наследует sys.path и подмены модулей текущих тестов.
'import importlib, pathlib, sys, types # Для импорта нужны только стандартные модули Python.', # Каждая строка дочернего сценария также документирована.
'root = pathlib.Path(sys.argv[1]) # Корень упакованных декодеров передаётся отдельным аргументом.', # Аргумент исключает зависимость от рабочего каталога.
'sys.path.insert(0, str(root)) # Разрешаем импорт только из подготовленной поставки.', # Исходники templates не добавляются в поиск.
"stub = types.ModuleType('sigrokdecode') # Моделируем только доступную при импорте базу API.", # Настоящий DSView не запускается.
'stub.Decoder = object # Методы базы при проверке импорта не вызываются.', # Выходные константы нужны только после start.
"sys.modules['sigrokdecode'] = stub # Подмена ограничена дочерним процессом.", # Глобальное окружение пользователя не изменяется.
"adapter = importlib.import_module('sigma_delta') # Проверяем стандартную точку входа пакета.", # Импорт обязан транзитивно загрузить ядро из common.
"core = importlib.import_module('common.setgui_decoders.sigma_delta') # Ядро должно входить в ту же поставку.", # Проверяем отсутствие скрытой зависимости от рабочего checkout.
'pathlib.Path(core.__file__).resolve().relative_to(root.resolve()) # Местоположение ядра обязано принадлежать пакету.', # Неправильное происхождение вызывает ошибку.
"assert adapter.Decoder.id == 'sigma_delta' # Идентификатор совпадает с меню приложения.", # Сохранённые сессии используют тот же идентификатор.
"assert core.SigmaDeltaFilter(1, 1).feed(1)['value'] == 1 # Упакованное ядро выполняет действительное восстановление.", # Проверяется и работоспособность, и полнота импорта.
)) # В сценарии используются настоящие переводы строк, а не экранированные команды оболочки.
result = subprocess.run([sys.executable, '-I', '-c', script, str(root / 'decoders')], cwd=temporary, capture_output=True, text=True) # Отдельные аргументы сохраняют пути с пробелами и не запускают оболочку.
self.assertEqual(result.returncode, 0, result.stderr) # Отсутствующий модуль или неверное расположение ядра делают проверку неуспешной.
def test_portable_python_syntax(self): # Старые версии DSView используют Python 3.6, хотя сами тесты могут выполняться новым Python.
paths = [ROOT / 'python/logic_analyzer/decoders/sigma_delta.py'] + list((ROOT / 'tools/dsview/decoders/sigma_delta').glob('*.py')) # Проверяются оба устанавливаемых слоя и точка входа пакета.
for path in paths: # Ошибка указывает точное имя файла поставки.
ast.parse(path.read_text(encoding='utf-8'), filename=str(path), feature_version=(3, 6)) # Одновременно проверяем UTF-8 и совместимость синтаксиса.
if __name__ == '__main__': # Разрешаем запуск отдельного тестового файла без pytest и GUI.
unittest.main() # Стандартный runner выдаёт количество проверок и описание несоответствий.
+35 -1
View File
@@ -42,7 +42,21 @@ python tools/dsview/zynq-ethernet/build.py --msys-root C:/msys64 --output build/
**Options → Max Height** задаёт высоту дорожек от 1X до 5X.
Перетаскивание двойной черты под меткой канала меняет высоту
этой дорожки. Колесо над метками прокручивает список каналов.
этой дорожки. Оно доступно для всех цифровых источников: Demo,
USB, Zynq, обычных файлов DSL и записей RJ2. Графики PWM изменяют
высоту таким же жестом. Текстовые поля анализаторов имеют фиксированную
высоту по размеру шрифта: между заголовком и полем, а также между
полем и следующим заголовком остаётся 2 пикселя. Высота входных каналов,
**Max Height** и растягивание PWM не увеличивают эти промежутки.
Нижнюю границу можно захватить по всей ширине заголовка дорожки.
Размеры каналов и анализаторов независимы и сохраняются при изменении
размера окна и обновлении декодирования. Изменение **Max Height**
задаёт общий размер заново. Колесо над метками прокручивает список дорожек.
Скрытие и показ строк анализатора, обычное подтверждение неизменённых
настроек и переключение PWM **split / combined** используют уже рассчитанные
аннотации. Повторный анализ выполняется при смене входов, параметров расчёта,
границ анализируемого диапазона или исходной записи.
Новые файлы содержат индекс `zynq_tiles.bin`. Общий масштаб читает
индекс, при увеличении график читает точные отсчёты нужных блоков.
@@ -51,6 +65,26 @@ python tools/dsview/zynq-ethernet/build.py --msys-root C:/msys64 --output build/
При сетевой ошибке файл содержит непрерывный префикс принятых данных.
При ошибке диска может остаться временный файл `.part-*`.
Потеря связи останавливает также **Repetitive** и **Loop**. Причина ошибки
и кнопка **Закрыть** показаны в панели DSView перед графиком. Предупреждение
скрывается вместе с вкладкой и не блокирует закрытие DSView или приложения-хоста.
После восстановления связи захват запускается
вручную кнопкой **Run**; повторные окна ошибки не накапливаются.
Нативная проверка обрыва без платы запускается на Windows из каталога рецепта:
```powershell
python tests/check_link_loss.py --source <checkout-DSView> --runtime <проверочный-runtime> --output <артефакты> # Проверяем собранный checkout на отдельной копии runtime с сохранением результатов в каталоге артефактов.
```
Нужен собранный `build-win64` и отдельная полная копия runtime: проверочный
EXE создаётся рядом с её ресурсами. Loopback-эмулятор занимает UDP 37575.
Сценарий проверяет остановку повтора, отсутствие модальности и дубликатов,
запоздавшие сигналы ошибки, ручной запуск, целостность сохранённого DSL
и штатное закрытие с открытым предупреждением. Текст и кнопка проверяются
на видимость в настоящем встроенном окне; переход на соседнюю страницу
проверяет скрытие через Win32. Снимки `notice-*.png` показывают предупреждение.
При поддержке ZLR1 частота 200 Мвыб/с доступна и для 5–8 каналов.
В этом случае FPGA запускает поток по аппаратному триггеру и сжимает
повторы уровней без потерь. Если входы меняются слишком часто и
+642 -80
View File
@@ -80,6 +80,58 @@ index 58d2d15..869a6c1 100644
}
ret = a.exec(); //Run the application
diff --git a/DSView/pv/data/decode/decoder.cpp b/DSView/pv/data/decode/decoder.cpp
index 2a1bdeb..5b1f9ff 100644
--- a/DSView/pv/data/decode/decoder.cpp
+++ b/DSView/pv/data/decode/decoder.cpp
@@ -43,6 +43,7 @@ Decoder::Decoder(const srd_decoder *const dec):
Decoder::~Decoder()
{
+ for (const auto &option : _options) if (option.second) g_variant_unref(option.second); // Применённый снимок владеет своими ссылками независимо от редактируемых значений диалога.
for (auto i = _options_back.begin(); i != _options_back.end(); i++){
if ((*i).second)
g_variant_unref((*i).second);
@@ -80,12 +81,26 @@ void Decoder::set_decode_region(uint64_t start, uint64_t end)
bool Decoder::commit()
{
if (_setted) {
+ bool changed = !_committed || _probes != _probes_back || _decode_start != _decode_start_back || _decode_end != _decode_end_back; // Повторный анализ нужен при первом применении, новых входах или изменении границ отсчётов.
+ for (const auto &option : _options_back) { // Диалог повторно передаёт и неизменённые параметры, поэтому сравниваем значения, а не факт вызова setter.
+ if (std::string(_decoder->id) == "pwm_waveform" && option.first == "waveform_layout") continue; // Наложение фаз меняет только отрисовку уже рассчитанных кривых.
+ const auto previous = _options.find(option.first); // Ищем значение, с которым построены сохранённые аннотации.
+ GVariant *value = previous == _options.end() ? nullptr : previous->second; // Отсутствующий ключ означает штатное значение декодера, а не автоматическое изменение.
+ if (!value) for (const GSList *item = _decoder->options; item; item = item->next) { // Сравниваем явно сохранённый параметр с его исходным значением по умолчанию.
+ const auto definition = static_cast<const srd_decoder_option*>(item->data); // Описание sigrok содержит типизированное значение по умолчанию для каждого параметра.
+ if (option.first == definition->id) { value = definition->def; break; } // Выбираем только совпадающий идентификатор параметра текущего декодера.
+ } // Неизвестный параметр с новым значением безопасно считается изменением анализа.
+ if (!value || !option.second || !g_variant_equal(value, option.second)) changed = true; // GVariant сравнивается по типу и содержимому, а не по адресу нового объекта из диалога.
+ } // Видимость строк хранится отдельно и никогда не влияет на вычислительные параметры.
+ for (const auto &option : _options) if (option.second) g_variant_unref(option.second); // Освобождаем ссылки предыдущего снимка только после сравнения, чтобы значения оставались действительными.
_probes = _probes_back;
_options = _options_back;
+ for (const auto &option : _options) if (option.second) g_variant_ref(option.second); // Отдельная ссылка сохраняет применённое значение при последующем редактировании или отмене диалога.
_decode_start = _decode_start_back;
_decode_end = _decode_end_back;
_setted = false;
- return true;
+ _committed = true; // После первого применения можно повторно использовать готовые результаты при равных настройках.
+ return changed; // Принятие диалога без изменения анализа вызывает только обновление представления.
}
else {
return false;
diff --git a/DSView/pv/data/decode/decoder.h b/DSView/pv/data/decode/decoder.h
index 9f8e084..17c4044 100644
--- a/DSView/pv/data/decode/decoder.h
+++ b/DSView/pv/data/decode/decoder.h
@@ -123,6 +123,7 @@ private:
uint64_t _decode_end_back;
bool _setted;
+ bool _committed = false; // Первый набор настроек требует анализа даже при пустых параметрах; последующие сравниваются с применённым снимком.
bool _shown;
};
diff --git a/DSView/pv/data/logicsnapshot.cpp b/DSView/pv/data/logicsnapshot.cpp
index b2226cd..59d5a0c 100644
--- a/DSView/pv/data/logicsnapshot.cpp
@@ -443,6 +495,153 @@ index ae9132d..12a88a8 100644
}
inline bool is_virtual(){
diff --git a/DSView/pv/dialogs/decoderoptionsdlg.cpp b/DSView/pv/dialogs/decoderoptionsdlg.cpp
index bf0b8e9..4820d02 100644
--- a/DSView/pv/dialogs/decoderoptionsdlg.cpp
+++ b/DSView/pv/dialogs/decoderoptionsdlg.cpp
@@ -297,18 +297,15 @@ DsComboBox* DecoderOptionsDlg::create_probe_selector(
DsComboBox *selector = new DsComboBox(parent);
selector->addItem("-", QVariant::fromValue(-1));
- int dex = 0;
const int binded_index = decoder->binded_probe_index(pdch);
for(auto s : sigs)
{
- dex++;
-
if (s->signal_type() == SR_CHANNEL_LOGIC && s->enabled()){
selector->addItem(s->get_name(),QVariant::fromValue(s->get_index()));
if (binded_index == s->get_index()){
- selector->setCurrentIndex(dex);
+ selector->setCurrentIndex(selector->count() - 1); // Индекс берётся из списка доступных входов, поэтому отключённые каналы не меняют сохранённую привязку при обычном OK.
}
}
}
@@ -500,8 +497,7 @@ void DecoderOptionsDlg::commit_decoder_probes(data::decode::Decoder *dec)
for(auto &p : _probe_selectors)
{
- if(p._decoder != dec)
- break;
+ if (p._decoder != dec) continue; // Пропускаем селекторы других уровней стека, чтобы неизменённый диалог не очищал входы вложенного декодера.
const int selection = p._combo->itemData(p._combo->currentIndex()).value<int>();
diff --git a/DSView/pv/dock/protocoldock.cpp b/DSView/pv/dock/protocoldock.cpp
index 4654789..ad1fd6f 100644
--- a/DSView/pv/dock/protocoldock.cpp
+++ b/DSView/pv/dock/protocoldock.cpp
@@ -27,6 +27,8 @@
#include "../dialogs/protocollist.h"
#include "../dialogs/protocolexp.h"
#include "../view/view.h"
+#include "../view/header.h" // Нативная проверка отправляет события мыши настоящему заголовку дорожек.
+#include <QMouseEvent> // Проверяем перетаскивание через обработчики Qt, а не прямой вызов изменения высоты.
#include <QObject>
#include <QHBoxLayout>
@@ -349,6 +351,99 @@ void ProtocolDock::check_host_decoders()
if (file.open(QIODevice::WriteOnly | QIODevice::Append))
file.write((message + "\n").toUtf8());
};
+ if (qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_DISPLAY_CACHE")) { // Отдельный нативный сценарий проверяет реальные диалоги поверх полностью рассчитанной записи.
+ auto timer = new QTimer(this); // Ожидаем окончания рабочего потока без блокирования интерфейса и декодирования.
+ int attempts = 0; // Ограничиваем ожидание шестьюдесятью секундами при интервале 50 миллисекунд.
+ connect(timer, &QTimer::timeout, this, [this, timer, attempts, log, report]() mutable { // Состояние ожидания принадлежит только текущему проверочному запуску.
+ if (++attempts > 1200) { log("FAIL display cache timeout"); timer->stop(); timer->deleteLater(); return; } // Неполный анализ не может дать ложный успешный результат.
+ const auto traces = _session->get_decode_signals(); // Проверяем анализатор, восстановленный из обычного файла DSL.
+ if (traces.empty()) return; // Восстановление списка декодеров может завершиться после появления окна.
+ auto trace = traces.front(); // Одного трёхфазного анализатора достаточно для проверки изменения видимости и параметров.
+ auto stack = trace->decoder(); // Готовые аннотации должны остаться в том же стеке после принятия диалога.
+ if (stack->IsRunning() || stack->get_result_count() == 0 || stack->options_changed()) return; // Переключаем поля только после фактического окончания исходного расчёта.
+ timer->stop(); // Вложенный цикл модального диалога не должен повторно запускать этот сценарий.
+ const auto before = stack->get_result_count(); // Очистка стека при ошибочном повторном анализе будет видна по числу результатов.
+ bool ok = true; // Каждая фаза обязана подтвердить сохранение расчётных данных.
+ for (int phase = 0; phase < 3; ++phase) { // Проверяем скрытие строки, неизменённый OK и переключение вида графиков PWM.
+ bool edited = phase == 1; // Во второй фазе намеренно принимаем диалог без каких-либо изменений.
+ QTimer::singleShot(30, this, [phase, &edited]() { // Меняем настоящие элементы штатного Decoder Options после открытия диалога.
+ auto dialog = qobject_cast<dialogs::DecoderOptionsDlg*>(QApplication::activeModalWidget()); // Получаем именно модальное окно, открытое create_popup().
+ if (!dialog) return; // Отсутствующее окно приведёт к тайм-ауту внешнего нативного сценария.
+ if (phase == 0) for (auto button : dialog->findChildren<QPushButton*>()) { // Кнопки строк используют штатное свойство index, в отличие от кнопки видимости всего декодера.
+ if (button->property("index").isValid() && button->property("index").toInt() == 0) { button->click(); edited = true; break; } // Скрываем первую строку тем же сигналом, что пользовательское нажатие.
+ } // Кнопки OK, Cancel и скрытия декодера не затрагиваются.
+ if (phase == 2) for (auto combo : dialog->findChildren<QComboBox*>()) { // Параметр раскладки является представлением готовых данных, а не вычислительной настройкой.
+ const int combined = combo->findText("combined"), split = combo->findText("split"); // Находим список по двум штатным значениям, не по порядковому номеру виджета.
+ if (combined >= 0 && split >= 0) { combo->setCurrentIndex(combo->currentIndex() == combined ? split : combined); edited = true; break; } // Переключаем split/combined через настоящий редактор свойств.
+ } // Другие параметры анализа остаются прежними.
+ QMetaObject::invokeMethod(dialog, "on_accept", Qt::DirectConnection); // Вызываем штатный слот OK с проверкой диапазона; create_popup() затем применяет настройки после Accepted.
+ }); // Таймер срабатывает в штатном вложенном цикле событий модального окна.
+ const bool reanalyze = trace->create_popup(false); // Это тот же признак, по которому SigSession очищает аннотации и ставит повторную задачу.
+ ok = ok && edited && !reanalyze && stack->get_result_count() == before && !stack->options_changed(); // Данные и очередь расчёта должны сохраниться во всех трёх фазах.
+ log(QString("display cache phase %1: %2").arg(phase).arg(ok ? "ok" : "FAIL")); // Журнал различает видимость, неизменённый диалог и раскладку графиков.
+ } // После всех принятий остаются результаты единственного исходного декодирования.
+ auto decoder = stack->stack().front(); // Контрольное изменение реального параметра должно по-прежнему требовать нового анализа.
+ if (QString::fromUtf8(decoder->decoder()->id) == "pwm_waveform") { // Проверка использует математический параметр сглаживания штатного PWM-декодера.
+ decoder->set_option("smoothing_ms", g_variant_new_double(1.0)); // Отличие на одну миллисекунду меняет вычисляемую кривую, поэтому игнорировать его нельзя.
+ ok = decoder->commit() && ok; // Новое вычислительное значение обязано инвалидировать прежние результаты.
+ decoder->set_option("smoothing_ms", g_variant_new_double(0.0)); // Возвращаем стандартный режим без сглаживания для проверочной записи.
+ ok = decoder->commit() && ok; // Возврат математической настройки также требует пересчёта.
+ decoder->set_option("smoothing_ms", g_variant_new_double(0.0)); // Новый объект GVariant с прежним содержимым не является изменением настройки.
+ ok = !decoder->commit() && ok; // Сравнение значений предотвращает ложную инвалидизацию по адресу объекта.
+ } // Изменения параметров выполняются после проверки кэша и не запускают дополнительный расчёт тестовой записи.
+ _view.signals_changed(nullptr); // Готовим окончательную компоновку после изменения видимых строк и вида графиков.
+ _view.grab().save(report + ".display-cache.png"); // Сохраняем артефакт настоящего рендерера для проверки фиксированных промежутков.
+ log(ok ? "PASS" : "FAIL display cache"); // Внешний сценарий требует явного успеха каждой проверки.
+ timer->deleteLater(); // Освобождаем проверочный таймер штатно в потоке интерфейса.
+ }); // Обычный запуск без переменной окружения не создаёт проверочные диалоги и таймеры.
+ timer->start(50); // Интервал 50 миллисекунд сохраняет отзывчивость и быстро замечает завершение расчёта.
+ return; // Проверка кэша не смешивается с независимыми сценариями удаления анализаторов и растягивания дорожек.
+ } // Остальные нативные проверки продолжают работать по прежнему пути.
+ auto header = _view.findChild<view::Header*>(); // Проверяем тот же виджет, который принимает перетаскивание пользователем.
+ bool resize_ok = header && _view.can_resize_logic_rows(); // Режим Demo должен поддерживать высоту наравне с файловым и сетевым источником.
+ std::vector<view::Trace*> traces; // Сохраняем независимые размеры всех дорожек для проверки отсутствия побочных изменений.
+ _view.get_traces(TIME_VIEW, traces); // В список входят цифровые каналы и анализаторы текущей сессии.
+ std::map<view::Trace*, int> original; // Ключом служит объект, поэтому одинаковые индексы канала и анализаторов не смешиваются.
+ for (auto *trace : traces) original[trace] = trace->custom_row_height(); // После проверки возвращаем исходную компоновку пользователя.
+ auto drag = [this, header](view::Trace *trace, int delta) { // Воспроизводим полный жест на нижней границе выбранной дорожки.
+ const int bottom = trace->get_v_offset() + trace->get_totalHeight() / 2 + view::View::SignalMargin; // Берём границу в координатах содержимого, учитывая текущую высоту.
+ _view.verticalScrollBar()->setValue(qMax(0, bottom - header->height() + 20)); // Подводим ручку к видимой части заголовка, в том числе после появления прокрутки.
+ const QPoint start(header->width() / 2, trace->get_y() + trace->get_totalHeight() / 2 + view::View::SignalMargin); // Середина заголовка проверяет захват по всей ширине, а не только у номера канала.
+ const QPoint finish = start + QPoint(0, delta); // Положительное смещение растягивает дорожку, отрицательное сжимает.
+ QMouseEvent press(QEvent::MouseButtonPress, start, header->mapToGlobal(start), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); // Нажимаем левую кнопку на разделителе.
+ QApplication::sendEvent(header, &press); // Запускаем обычный обработчик выбора дорожки для изменения высоты.
+ QMouseEvent move(QEvent::MouseMove, finish, header->mapToGlobal(finish), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); // Держим кнопку нажатой при перемещении курсора.
+ QApplication::sendEvent(header, &move); // Высота и соседние дорожки должны обновиться ещё до отпускания.
+ QMouseEvent release(QEvent::MouseButtonRelease, finish, header->mapToGlobal(finish), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); // Завершаем жест даже за пределами исходной строки.
+ QApplication::sendEvent(header, &release); // Проверяем завершающую ветку обработчика и сброс состояния захвата.
+ }; // Тот же жест используется для каналов и декодеров.
+ for (int type : {SR_CHANNEL_LOGIC, SR_CHANNEL_DECODER}) { // Оба типа обязаны поддерживать независимое изменение высоты.
+ view::Trace *selected = nullptr; // Для каждого типа достаточно одной реальной дорожки с её собственными строками.
+ for (auto *trace : traces) if (trace->get_type() == type && trace->enabled() && trace->resize_rows_size() > 0) { selected = trace; break; } // Пропускаем выключенные входы и текстовые анализаторы с фиксированной геометрией.
+ if (!selected || !resize_ok) continue; // Обычный файл может не содержать анализаторов; каналы при этом проверяются полностью.
+ std::map<view::Trace*, int> before; // Запоминаем полные высоты до жеста, чтобы обнаружить коллизии индексов.
+ for (auto *trace : traces) before[trace] = trace->get_totalHeight(); // Проверяем также все остальные анализаторы и входы.
+ const int rows = selected->resize_rows_size(); // Смешанный анализатор растягивает только графики, сохраняя промежутки текстовых полей.
+ const int fixed = selected->layout_height() - rows * selected->get_row_height(); // Постоянная часть не должна исчезать при сжатии графиков до минимума.
+ drag(selected, 48 * rows); // Увеличиваем каждую строку на сорок восемь пикселей, заметно больше зоны захвата.
+ resize_ok = selected->get_totalHeight() == before[selected] + 48 * rows; // Полная высота должна соответствовать жесту с учётом числа строк.
+ for (auto *trace : traces) if (trace != selected) resize_ok = resize_ok && trace->get_totalHeight() == before[trace]; // Размер остальных дорожек должен остаться прежним.
+ _view.signals_changed(nullptr); // Имитируем перераскладку при изменении окна или обновлении результатов декодирования.
+ resize_ok = resize_ok && selected->get_totalHeight() == before[selected] + 48 * rows; // Ручная высота должна переживать обновление представления.
+ drag(selected, -selected->get_totalHeight() - 100); // Проверяем сжатие далеко за верхнюю границу дорожки.
+ resize_ok = resize_ok && selected->get_totalHeight() == fixed + 12 * rows; // Минимум двенадцать пикселей на графическую строку сохраняет фиксированные текстовые поля.
+ drag(selected, 5000 * rows); // Проверяем большой жест и появление вертикальной прокрутки.
+ resize_ok = resize_ok && selected->get_totalHeight() == fixed + 1000 * rows; // Предел в тысячу пикселей применяется только к графикам и не увеличивает текстовые интервалы.
+ selected->set_row_height(80); // Для снимка оставляем удобную высоту восемьдесят пикселей на строку.
+ _view.signals_changed(nullptr); // Готовим кадр, показывающий независимо растянутый канал и анализатор.
+ log(QString("resize %1: %2").arg(type == SR_CHANNEL_LOGIC ? "channel" : "analyzer").arg(resize_ok ? "ok" : "FAIL")); // Отчёт различает проверенные типы дорожек.
+ } // Один и тот же код жестов обязан работать в Demo, файловом и сетевом режиме.
+ _view.verticalScrollBar()->setValue(0); // На контрольном кадре показываем начало списка дорожек.
+ _view.grab().save(report + ".row-resize.png"); // Сохраняем реальную отрисовку после независимого растягивания.
+ for (auto entry : original) entry.first->set_row_height(entry.second); // Возвращаем размеры до запуска проверки, не меняя пользовательский профиль.
+ _view.signals_changed(nullptr); // Восстанавливаем исходные позиции перед штатными тестами диалогов.
+ if (!resize_ok) { log("FAIL row resizing"); return; } // Ошибка жеста не должна маскироваться успешной проверкой декодеров.
+ if (qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_RESIZE_ONLY")) { log("PASS"); return; } // Отдельный запуск позволяет проверить обычный DSL без предустановленных анализаторов.
auto timer = new QTimer(this);
int phase = 0;
connect(timer, &QTimer::timeout, this, [this, timer, phase, log, report]() mutable {
diff --git a/DSView/pv/dock/triggerdock.cpp b/DSView/pv/dock/triggerdock.cpp
index 8280cdc..5716485 100644
--- a/DSView/pv/dock/triggerdock.cpp
@@ -508,22 +707,92 @@ index 8280cdc..5716485 100644
//_serial_data_comboBox
diff --git a/DSView/pv/mainwindow.cpp b/DSView/pv/mainwindow.cpp
index b63b17a..81b32f5 100644
--- a/DSView/pv/mainwindow.cpp
+++ b/DSView/pv/mainwindow.cpp
@@ -492,6 +492,11 @@ namespace pv
@@ -21,6 +21,11 @@
*/
#include <QAction>
+#include <QFrame> // Предупреждение о сетевом обрыве живёт внутри панели анализатора вместо отдельного диалога.
+#include <QHBoxLayout> // Горизонтальная компоновка сохраняет видимыми причину ошибки и кнопку закрытия.
+#include <QLabel> // Текст и значок предупреждения показываются обычными дочерними виджетами.
+#include <QPushButton> // Явная кнопка позволяет убрать сообщение без закрытия приложения.
+#include <QStyle> // Значок предупреждения берётся из текущей темы Qt.
#include <QButtonGroup>
#include <QFileDialog>
#include <QFileInfo>
@@ -468,6 +473,7 @@
void MainWindow::on_session_error()
{
+ if (_session->get_error() == SigSession::No_err) return; // Уже обработанные сигналы из очереди не должны создавать пустые повторные окна.
QString title;
QString details;
QString ch_status = "";
@@ -492,7 +498,13 @@
title = L_S(STR_PAGE_MSG, S_ID(IDS_MSG_PACKET_ERROR), "Packet Error");
details = L_S(STR_PAGE_MSG, S_ID(IDS_MSG_PACKET_ERROR_DET),
"the content of received packet are not expected!");
- _session->refresh(0);
+ if (_device_agent->is_network()) {
+ QString reason;
+ if (_device_agent->get_config_string(SR_CONF_ZYNQ_LAST_ERROR, reason) && !reason.isEmpty())
+ details = reason;
+ }
_session->refresh(0);
+ if (_device_agent->is_network()) _session->stop_capture(); // Потеря Ethernet завершает работу и таймеры повтора до показа уведомления, сохраняя принятые данные.
+ else _session->refresh(0); // Для USB сохраняем штатное восстановление после повреждённого пакета.
break;
case SigSession::Data_overflow:
@@ -1988,6 +1993,12 @@ namespace pv
dsv_info("MainWindow::on_session_error(),Data_overflow, stop capture");
@@ -507,6 +519,38 @@
break;
}
+ if (_device_agent->is_network() && _session->get_error() == SigSession::Pkt_data_err) { // Сетевой обрыв завершает захват, оставляя доступными остальные команды приложения.
+ _session->clear_error(); // Запоздавшие сигналы уже обработанной ошибки не создают повторные уведомления.
+ auto *notice = findChild<QFrame *>(QStringLiteral("networkCaptureError")); // Каждая сессия показывает не более одной панели с причиной обрыва.
+ if (notice != nullptr) { // Повторная ошибка обновляет существующее предупреждение без отдельного окна.
+ notice->findChild<QLabel *>(QStringLiteral("networkCaptureErrorText"))->setText(details); // Пользователь видит последнюю причину ошибки драйвера RJ2.
+ return; // Существующая панель уже содержит доступную кнопку закрытия.
+ } // Новую панель создаём только при отсутствии предыдущей.
+ notice = new QFrame(_central_widget); // Обычный дочерний виджет скрывается вместе с DSView и не остаётся над соседними вкладками SETGUI.
+ notice->setObjectName(QStringLiteral("networkCaptureError")); // Устойчивое имя позволяет обновлять диагностику без дубликатов.
+ notice->setAttribute(Qt::WA_DeleteOnClose); // Явное закрытие освобождает виджет и убирает его из компоновки.
+ notice->setFrameShape(QFrame::StyledPanel); // Штатная рамка темы отделяет предупреждение от панели управления и графика.
+ notice->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Maximum); // Панель занимает доступную ширину и только высоту, необходимую для текста.
+ auto *row = new QHBoxLayout(notice); // Значок, причина ошибки и кнопка располагаются в одной адаптивной строке.
+ row->setContentsMargins(12, 8, 12, 8); // Отступы в логических пикселях оставляют текст читаемым при масштабировании Windows.
+ auto *icon = new QLabel(notice); // Значок предупреждения не подменяет текстовую диагностику.
+ icon->setPixmap(style()->standardIcon(QStyle::SP_MessageBoxWarning).pixmap(24, 24)); // Штатный значок Qt размером 24 логических пикселя сохраняет оформление предупреждения.
+ row->addWidget(icon); // Значок располагается слева от объяснения сетевой ошибки.
+ auto *message = new QLabel(details, notice); // Причина обрыва видна непосредственно внутри панели DSView.
+ message->setObjectName(QStringLiteral("networkCaptureErrorText")); // Именованный текст обновляется при следующей ошибке той же сессии.
+ message->setTextFormat(Qt::PlainText); // Диагностика драйвера отображается как текст без интерпретации разметки.
+ message->setWordWrap(true); // Длинный адрес или причина ошибки переносятся без обрезания содержимого.
+ message->setTextInteractionFlags(Qt::TextSelectableByMouse); // Пользователь может скопировать причину ошибки для диагностики связи.
+ row->addWidget(message, 1); // Растягиваемая область текста оставляет кнопку закрытия видимой справа.
+ auto *dismiss = new QPushButton(QStringLiteral("Закрыть"), notice); // Явная кнопка убирает предупреждение без подтверждения или перезапуска приложения.
+ connect(dismiss, &QPushButton::clicked, notice, &QWidget::close); // Нажатие закрывает только предупреждение, сохраняя запись и состояние анализатора.
+ row->addWidget(dismiss); // Кнопка участвует в компоновке и не может остаться скрытой во вложенном диалоге.
+ connect(_session->device_event_object(), SIGNAL(device_updated()), notice, SLOT(deleteLater())); // Переподключение убирает устаревшее предупреждение предыдущей сессии.
+ _vertical_layout->insertWidget(_vertical_layout->indexOf(_view), notice); // Размещаем уведомление перед графиком, сохраняя доступ к панели Start и Stop.
+ notice->show(); // Неблокирующий дочерний виджет не создаёт отдельного окна и вложенного цикла событий.
+ return; // Остальные ошибки продолжают использовать существующие диалоги ниже.
+ } // Особое поведение ограничено ошибкой сетевого захвата.
+
pv::dialogs::DSMessageBox msg(this, title);
msg.mBox()->setText(details);
msg.mBox()->setStandardButtons(QMessageBox::Ok);
@@ -602,7 +646,7 @@
void MainWindow::checkHostDecoders()
{
- on_protocol(true);
+ if (!qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_DISPLAY_CACHE")) on_protocol(true); // Проверка повторного использования данных открывает свойства напрямую, не заполняя панель списком миллионов результатов.
_protocol_widget->check_host_decoders();
}
@@ -1988,6 +2032,12 @@
{
update_toolbar_view_status();
_protocol_widget->update_view_status();
@@ -536,7 +805,7 @@ index b63b17a..81b32f5 100644
break;
}
case DSV_MSG_CURRENT_DEVICE_CHANGE_PREV:
@@ -2074,6 +2085,8 @@ namespace pv
@@ -2074,6 +2124,8 @@
{
_trigger_widget->device_updated();
_measure_widget->reload();
@@ -546,10 +815,9 @@ index b63b17a..81b32f5 100644
break;
}
diff --git a/DSView/pv/sigsession.cpp b/DSView/pv/sigsession.cpp
index 1ad7cc4..cf8eaf3 100644
--- a/DSView/pv/sigsession.cpp
+++ b/DSView/pv/sigsession.cpp
@@ -315,9 +315,7 @@ namespace pv
@@ -315,9 +315,7 @@
std::string file_name = pv::path::ToUnicodePath(name);
dsv_info("Load file: \"%s\"", file_name.c_str());
@@ -560,10 +828,12 @@ index 1ad7cc4..cf8eaf3 100644
{
dsv_err("Load file error!");
return false;
@@ -455,6 +453,29 @@ namespace pv
@@ -453,6 +451,29 @@
{
assert(samplelimits != 0);
_capture_data->_cur_samplelimits = samplelimits;
}
+ }
+
+ bool SigSession::show_zynq_recording()
+ {
+ QString file;
@@ -585,12 +855,10 @@ index 1ad7cc4..cf8eaf3 100644
+ bool SigSession::has_zynq_disk_view() const
+ {
+ return _view_data->get_logic()->is_disk_recording();
+ }
+
}
void SigSession::capture_init()
{
// update instant setting
@@ -1223,7 +1244,10 @@ namespace pv
@@ -1223,7 +1244,10 @@
if (_capture_data->get_logic()->last_ended())
{
@@ -602,10 +870,11 @@ index 1ad7cc4..cf8eaf3 100644
bool bNotFree = _is_decoding && _view_data == _capture_data;
@@ -1434,6 +1458,18 @@ namespace pv
@@ -1433,6 +1457,18 @@
assert(!_is_task_end);
feed_in_logic(*(const sr_datafeed_logic *)packet->payload);
break;
+
+ case SR_DF_ZYNQ_DISK:
+ {
+ auto *logic = _capture_data->get_logic();
@@ -617,11 +886,10 @@ index 1ad7cc4..cf8eaf3 100644
+ }
+ break;
+ }
+
case SR_DF_DSO:
assert(packet->payload);
assert(!_is_task_end);
@@ -1812,7 +1848,15 @@ namespace pv
@@ -1812,7 +1848,15 @@
void SigSession::nodata_timeout()
{
@@ -638,7 +906,16 @@ index 1ad7cc4..cf8eaf3 100644
_device_agent.get_config_byte(SR_CONF_TRIGGER_SOURCE, flag);
if (flag != DSO_TRIGGER_AUTO)
{
@@ -2247,7 +2291,7 @@ namespace pv
@@ -2109,7 +2153,7 @@
dsv_err("The collected data is error!");
// trig next collect
- if (is_repeat_mode() && _is_working && event == DS_EV_COLLECT_TASK_END)
+ if (is_repeat_mode() && _is_working && _error == No_err && event == DS_EV_COLLECT_TASK_END) // Повреждённая запись не запускается повторно, даже когда драйвер завершил цикл с обычным событием END.
{
_callback->trigger_message(DSV_MSG_TRIG_NEXT_COLLECT);
}
@@ -2247,7 +2291,7 @@
{
if (_device_agent.get_work_mode() == LOGIC)
{
@@ -955,8 +1232,244 @@ index fde2169..724b535 100644
QAction *_action_single;
QAction *_action_loop;
diff --git a/DSView/pv/view/decodetrace.cpp b/DSView/pv/view/decodetrace.cpp
index fd0f68c..bd87394 100644
--- a/DSView/pv/view/decodetrace.cpp
+++ b/DSView/pv/view/decodetrace.cpp
@@ -161,7 +161,7 @@ void DecodeTrace::paint_back(QPainter &p, int left, int right, QColor fore, QCol
QPen pen(backFore);
pen.setStyle(Qt::DotLine);
p.setPen(pen);
- const double sigY = get_y() - (_totalHeight - _view->get_signalHeight())*0.5;
+ const double sigY = get_y() - (_totalHeight - get_row_height())*0.5; // Ось анализатора следует за его индивидуальной высотой, а не за размером цифровых входов.
p.drawLine(left, sigY, right, sigY);
// --draw decode region control
@@ -186,31 +186,6 @@ void DecodeTrace::paint_back(QPainter &p, int left, int right, QColor fore, QCol
p.drawPolygon(start_points, countof(start_points));
p.drawPolygon(end_points, countof(end_points));
- // --draw headings
- const int row_height = _view->get_signalHeight();
- for (size_t i = 0; i < _cur_row_headings.size(); i++)
- {
- const int y = i * row_height + get_y() - _totalHeight * 0.5;
-
- p.setPen(QPen(Qt::NoPen));
- p.setBrush(QApplication::palette().brush(QPalette::WindowText));
-
- const QRect r(left + ArrowSize * 2, y,
- right - left, row_height / 2);
- const QString h(_cur_row_headings[i]);
- const int f = Qt::AlignLeft | Qt::AlignVCenter |
- Qt::TextDontClip;
- const QPointF points[] = {
- QPointF(left, r.center().y() - ArrowSize),
- QPointF(left + ArrowSize, r.center().y()),
- QPointF(left, r.center().y() + ArrowSize)
- };
- p.drawPolygon(points, countof(points));
-
- // Draw the text
- p.setPen(fore);
- p.drawText(r, f, h);
- }
}
static bool pwm_combined(pv::data::decode::Decoder *decoder) // Режим берётся из штатных настроек конкретного экземпляра декодера.
@@ -238,6 +213,7 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
double samplerate = _decoder_stack->samplerate();
_cur_row_headings.clear();
+ _cur_row_heading_offsets.clear(); // Координаты и подписи пересоздаются вместе после изменения видимых строк декодера.
// Show sample rate as 1Hz when it is unknown
if (samplerate == 0.0)
@@ -260,7 +236,10 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
if (end_sample < start_sample)
return;
- const int annotation_height = _view->get_signalHeight();
+ const int annotation_height = get_row_height(); // Ручной масштаб определяет только высоту графических областей PWM.
+ const int text_height = (text_row_height() - 6) / 2 + 2; // Цветное поле имеет высоту шрифта и по одному пикселю внутреннего отступа сверху и снизу.
+ const int text_step = text_row_height(); // Шаг включает заголовок, поле и фиксированные промежутки по 2 пикселя.
+ int row_top = 0; // Относительная верхняя граница учитывает разную высоту текстовых полей и графиков.
// Iterate through the rows
assert(_view);
@@ -305,7 +284,9 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
p.drawLines(lines); // Пакетная отрисовка не создаёт отдельный вызов QPainter на каждый период длинной записи.
p.restore(); // Цвет, сглаживание и отсечение не переходят на числовые строки.
if (allocate) { // Число заголовков и вертикальный шаг должны совпадать с rows_size().
- y += annotation_height * 3; // Область PWM занимает три высоты строки независимо от количества совмещённых фаз.
+ _cur_row_heading_offsets.push_back(row_top); // Заголовок графика привязывается к его фактической верхней границе.
+ row_top += annotation_height * 3; // Масштабируемая область сохраняет прежнюю высоту из трёх строк на график.
+ y += annotation_height * 3; // Следующая графическая область начинается сразу после текущей.
QString title = row.title(); // Раздельный график сохраняет прежнее название своего канала.
if (row_combined) { // Общая подпись перечисляет только основные видимые фазы, сохраняя собственные шкалы θ и несущей.
QStringList channels; // Список нужен для записи с одним, двумя или тремя видимыми входами.
@@ -317,13 +298,13 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
title = QString::fromUtf8(dec->decoder()->name) + ": " + channels.join(" / ") + ": -1 .. +1"; // Общая шкала остаётся нормированной для всех видимых каналов.
} // Значения PWM и числовые результаты не изменяются при переключении вида.
_cur_row_headings.push_back(title); // Единственный заголовок относится ко всей выделенной области.
- _cur_row_headings.push_back(QString()); // Вторая строка резервирует место для верхней и нижней полуволн.
- _cur_row_headings.push_back(QString()); // Третья строка завершает прежнюю высоту области PWM.
} // Повторная совмещённая фаза не сдвигает последующие аннотации.
wave_shown = wave_shown || phase_wave; // Отдельный θ или треугольник не заменяет первую видимую фазу общей области.
continue; // Числовой рендерер не должен рисовать служебный контракт PWM1 поверх кривой.
} // Остальные строки проходят штатную отрисовку текстовых аннотаций.
+ _cur_row_heading_offsets.push_back(row_top); // Каждая текстовая строка получает собственную компактную позицию заголовка.
+ y = get_y() - _totalHeight / 2 + row_top + text_height + text_height / 2; // После заголовка высотой text_height−2 оставляем ровно 2 пикселя до цветного поля.
const uint64_t min_annotation =
_decoder_stack->get_min_annotation(row);
const double min_annWidth = min_annotation / samples_per_pixel;
@@ -344,24 +325,28 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
for(Annotation *a : annotations){
draw_annotation(*a, p, get_text_colour(),
- annotation_height, left, right,
+ text_height, left, right, // Фон текстовой аннотации не зависит от высоты графика или цифрового входа.
samples_per_pixel, pixels_offset, y,
0, min_annWidth, fore, back, last_x);
}
}
}
else {
- draw_nodetail(p, annotation_height, left, right, y, 0, fore, back);
+ draw_nodetail(p, text_height, left, right, y, 0, fore, back); // При обзорном масштабе подсказка занимает ту же фиксированную высоту, что обычное поле.
}
- y += annotation_height;
+ row_top += text_step; // Между нижним краем поля и заголовком следующего блока остаётся ровно 2 пикселя.
+ y = get_y() - (_totalHeight - annotation_height) / 2 + row_top; // Восстанавливаем начало возможного следующего графика после компактной текстовой строки.
_cur_row_headings.push_back(row.title());
}
}
}
} else {
- draw_unshown_row(p, y, annotation_height, left, right, L_S(STR_PAGE_DLG, S_ID(IDS_DLG_UNSHOWN), "Unshown"), fore, back);
- y += annotation_height;
+ _cur_row_heading_offsets.push_back(row_top); // Скрытый декодер использует такую же геометрию заголовка, как текстовые строки.
+ y = get_y() - _totalHeight / 2 + row_top + text_height + text_height / 2; // Подсказка скрытого декодера располагается под заголовком с зазором 2 пикселя.
+ draw_unshown_row(p, y, text_height, left, right, L_S(STR_PAGE_DLG, S_ID(IDS_DLG_UNSHOWN), "Unshown"), fore, back); // Скрытая строка не растягивается вместе с графиком PWM.
+ row_top += text_step; // Следующий блок начинается после фиксированного двухпиксельного промежутка.
+ y = get_y() - (_totalHeight - annotation_height) / 2 + row_top; // Следующая графическая область получает собственную исходную координату.
_cur_row_headings.push_back(dec->decoder()->name);
}
}
@@ -369,13 +354,22 @@ void DecodeTrace::paint_mid(QPainter &p, int left, int right, QColor fore, QColo
void DecodeTrace::paint_fore(QPainter &p, int left, int right, QColor fore, QColor back)
{
- using namespace pv::data::decode;
-
- (void)p;
- (void)left;
- (void)right;
- (void)fore;
- (void)back;
+ (void)back; // Подписи используют передний цвет, а фон уже отрисован предыдущими слоями.
+ const int heading_height = (text_row_height() - 6) / 2; // Два внешних зазора по 2 пикселя и внутренние поля аннотации по 1 пикселю исключаются из высоты заголовка.
+ for (size_t i = 0; i < _cur_row_headings.size(); ++i) { // paint_mid() уже подготовил координаты текущего кадра после изменения видимости и раскладки.
+ const int y = _cur_row_heading_offsets[i] + get_y() - _totalHeight * 0.5; // Заголовок располагается над собственной текстовой или графической областью без задержки на один кадр.
+ p.setPen(QPen(Qt::NoPen)); // Стрелка заголовка заполняется без дополнительной рамки.
+ p.setBrush(QApplication::palette().brush(QPalette::WindowText)); // Цвет стрелки соответствует штатной палитре приложения.
+ const QRect r(left + ArrowSize * 2, y, right - left, heading_height); // Двойной размер стрелки сохраняет прежний горизонтальный отступ, высота определяется только шрифтом.
+ const QPointF points[] = { // Три вершины образуют прежний указатель строки слева от подписи.
+ QPointF(left, r.center().y() - ArrowSize), // Верхняя вершина центрируется относительно фиксированной высоты заголовка.
+ QPointF(left + ArrowSize, r.center().y()), // Острие указывает на область данных соответствующей строки.
+ QPointF(left, r.center().y() + ArrowSize) // Нижняя вершина сохраняет симметричную форму указателя.
+ }; // Геометрия не зависит от ручного масштаба графика или высоты входных каналов.
+ p.drawPolygon(points, countof(points)); // Рисуем указатель рядом с текущим заголовком.
+ p.setPen(fore); // Текст получает контрастный цвет штатной темы анализатора.
+ p.drawText(r, Qt::AlignLeft | Qt::AlignVCenter | Qt::TextDontClip, _cur_row_headings[i]); // Подпись рисуется после данных с актуальной позицией уже в первом кадре после переключения полей.
+ } // Следующий заголовок использует собственное смещение, включая компактные текстовые строки.
}
void DecodeTrace::draw_annotation(const pv::data::decode::Annotation &a,
@@ -612,7 +606,7 @@ void DecodeTrace::on_new_decode_data()
if (_view && _view->session().is_stopped_status())
_view->data_updated();
- if (_totalHeight/_view->get_signalHeight() != rows_size())
+ if (_totalHeight != layout_height()) // Сравниваем реальную смешанную высоту, чтобы фиксированный текст не вызывал повторную перераскладку на каждом обновлении.
_view->signals_changed(NULL);
}
@@ -659,6 +653,38 @@ int DecodeTrace::rows_size()
return size == 0 ? 1 : size;
}
+int DecodeTrace::text_row_height() const // Компактная геометрия использует тот же размер шрифта, что Viewport::doPaint().
+{ // Метрика вычисляется заново, чтобы изменение шрифта сохраняло читаемость подписей и полей.
+ QFont font = QApplication::font(); // Базовый шрифт совпадает со шрифтом обычного виджета временной области.
+ font.setPointSizeF(std::min(10.0, double(AppConfig::Instance().appOptions.fontSize))); // Ограничение десятью пунктами повторяет штатную настройку отрисовки Viewport.
+ return QFontMetrics(font).height() * 2 + 6; // Заголовок и аннотация занимают по высоте шрифта; 2 пикселя внутри поля и два внешних зазора по 2 пикселя дают ещё 6.
+} // Высота не зависит от Max Height, размера входных каналов и перетаскивания графика.
+
+int DecodeTrace::resize_rows_size() // Только фактически показанные кривые участвуют в ручном масштабировании анализатора.
+{ // Отбор строк повторяет условия rows_size() и paint_mid(), включая совмещение фаз.
+ int size = 0; // Текстовый анализатор без графиков не имеет масштабируемой области.
+ const auto rows = _decoder_stack->get_rows_gshow(); // Используем тот же порядок видимых строк, что нативный рендерер.
+ for (auto dec : _decoder_stack->stack()) { // У каждого экземпляра стека собственная настройка совмещения фаз.
+ if (!dec->shown() || QString::fromUtf8(dec->decoder()->id) != "pwm_waveform") continue; // Скрытые и обычные текстовые декодеры не меняют фиксированную геометрию.
+ bool phase_shown = false; // Совмещённая область A/B/C учитывается отдельно от самостоятельных графиков θ и несущей.
+ for (const auto &entry : rows) { // Неподключённые и скрытые каналы исключаются до выделения высоты.
+ if (entry.first.decoder() != dec->decoder() || !entry.second || !entry.first.row() || !_decoder_stack->has_annotations(entry.first)) continue; // Должны совпасть экземпляр декодера, наличие данных и разрешение показа строки.
+ if (!QString::fromUtf8(entry.first.row()->id).startsWith("wave_")) continue; // Числовые duty и frequency остаются компактными текстовыми полями.
+ const bool phase_wave = QString::fromUtf8(entry.first.row()->id).size() == 6; // Только идентификаторы wave_a…wave_c обозначают основные фазные осциллограммы.
+ if (phase_wave && pwm_combined(dec) && phase_shown) continue; // Повторная основная фаза не увеличивает общую область A/B/C.
+ size += 3; // Каждый самостоятельный график, включая θ и расчётную несущую, занимает три масштабируемые строки.
+ phase_shown = phase_shown || phase_wave; // Дополнительные графики не влияют на наличие основной совмещённой области.
+ } // Следующий экземпляр декодера получает независимую область графика.
+ } // Сумма учитывает только графики, которые действительно выводятся.
+ return size; // Ноль отключает ручку масштабирования у чисто текстового анализатора.
+} // Изменение видимости фаз автоматически меняет число масштабируемых строк.
+
+int DecodeTrace::layout_height() // Общая высота должна совпадать с последовательностью областей в paint_mid().
+{ // Текстовые строки сохраняют размер и промежутки даже при сильном растяжении графика.
+ const int scalable = resize_rows_size(); // Графики уже учитывают режимы split и combined и скрытые фазы.
+ return scalable * get_row_height() + (rows_size() - scalable) * text_row_height(); // Остальные строки, включая подсказку скрытого декодера, занимают фиксированную высоту.
+} // View использует эту величину для положения соседних дорожек и вертикальной прокрутки.
+
void DecodeTrace::paint_type_options(QPainter &p, int right, const QPoint pt, QColor fore)
{
(void)pt;
@@ -780,7 +806,8 @@ bool DecodeTrace::create_popup(bool isnew)
}
}
- return ret;
+ if (!ret && _view) _view->signals_changed(nullptr); // Изменение видимости или вида графиков обновляет компоновку из готовых аннотаций без очистки и повторного декодирования.
+ return ret; // Только вычислительные изменения разрешают SigSession сбросить результаты и запустить анализ заново.
}
} // namespace view
diff --git a/DSView/pv/view/decodetrace.h b/DSView/pv/view/decodetrace.h
index 29ebe57..5ff3596 100644
--- a/DSView/pv/view/decodetrace.h
+++ b/DSView/pv/view/decodetrace.h
@@ -127,6 +127,9 @@ public:
void paint_fore(QPainter &p, int left, int right, QColor fore, QColor back);
int rows_size();
+ int layout_height() override; // Полная высота складывается из фиксированных текстовых полей и масштабируемых областей графиков.
+ int resize_rows_size() override; // Возвращаем только число масштабируемых строк PWM, чтобы жест не менял текстовые интервалы.
+ int text_row_height() const; // Высота заголовка, поля и двух зазоров по два пикселя определяется тем же шрифтом, что отрисовка.
QRectF get_rect(DecodeSetRegions type, int y, int right);
@@ -192,6 +195,7 @@ private:
uint64_t _decode_cursor2;
std::vector<QString> _cur_row_headings;
+ std::vector<int> _cur_row_heading_offsets; // Относительные координаты заголовков учитывают разную высоту текстовых и графических строк.
};
diff --git a/DSView/pv/view/header.cpp b/DSView/pv/view/header.cpp
index 0fab4b6..cd60ecb 100644
index 0fab4b6..bf49b6f 100644
--- a/DSView/pv/view/header.cpp
+++ b/DSView/pv/view/header.cpp
@@ -28,6 +28,7 @@
@@ -978,13 +1491,13 @@ index 0fab4b6..cd60ecb 100644
+
+Trace* Header::resize_handle_at(const QPoint &point)
+{
+ if (!_view.can_resize_logic_rows() || point.x() < width() - 30)
+ if (!_view.can_resize_logic_rows()) // Одинаковая граница доступна всем цифровым источникам, включая обычные файлы и Demo.
+ return nullptr;
+ std::vector<Trace*> traces;
+ _view.get_traces(TIME_VIEW, traces);
+ for (auto *trace : traces) {
+ if (trace->get_type() == SR_CHANNEL_LOGIC && trace->enabled() &&
+ qAbs(point.y() - handle_y(trace)) <= 4)
+ if ((trace->get_type() == SR_CHANNEL_LOGIC || trace->get_type() == SR_CHANNEL_DECODER || trace->get_type() == 100) && trace->enabled() && trace->resize_rows_size() > 0 && // Ручка изменяет цифровые входы, графики и эмулятор осциллографа с локальным типом 100; текстовые поля сохраняют фиксированный интервал.
+ qAbs(point.y() - handle_y(trace)) <= 5) // Полоса шириной десять пикселей облегчает захват нижней границы по всей ширине заголовка.
+ return trace;
+ }
+ return nullptr;
@@ -1002,10 +1515,10 @@ index 0fab4b6..cd60ecb 100644
+ grip.setAlpha(125);
+ painter.setPen(grip);
+ for (auto *trace : traces) {
+ if (trace->get_type() == SR_CHANNEL_LOGIC && trace->enabled()) {
+ if ((trace->get_type() == SR_CHANNEL_LOGIC || trace->get_type() == SR_CHANNEL_DECODER || trace->get_type() == 100) && trace->enabled()) { // Показываем одинаковый разделитель для цифровых каналов, декодеров и эмулятора осциллографа с локальным типом 100.
+ const int y = handle_y(trace);
+ painter.drawLine(w - 25, y, w - 5, y);
+ painter.drawLine(w - 25, y + 2, w - 5, y + 2);
+ painter.drawLine(trace->get_leftWidth(), y, w - 5, y); // Нижняя граница проходит под названием и номером, чтобы её можно было захватить в любом месте.
+ painter.drawLine(trace->get_leftWidth(), y + 2, w - 5, y + 2); // Вторая линия с отступом два пикселя отличает ручку высоты от оси сигнала.
+ }
+ }
+ }
@@ -1019,7 +1532,7 @@ index 0fab4b6..cd60ecb 100644
+ if (event->button() == Qt::LeftButton) {
+ if (auto *trace = resize_handle_at(event->pos())) {
+ _resize_channel = trace->get_index();
+ _resize_trace = trace; // Адрес различает анализаторы и каналы даже при одинаковом номере входа.
+ _resize_start_y = event->globalY();
+ _resize_start_height = trace->get_totalHeight();
+ setCursor(Qt::SizeVerCursor);
@@ -1044,9 +1557,9 @@ index 0fab4b6..cd60ecb 100644
{
assert(event);
+ if (_resize_channel >= 0 && event->button() == Qt::LeftButton) {
+ _view.resize_logic_row(_resize_channel, _resize_start_height + event->globalY() - _resize_start_y);
+ _resize_channel = -1;
+ if (_resize_trace && event->button() == Qt::LeftButton) { // Завершаем только начатое изменение высоты, не запуская перенос дорожки.
+ _view.resize_trace_row(_resize_trace, _resize_start_height + event->globalY() - _resize_start_y); // Разность экранных координат даёт изменение полной высоты в пикселях.
+ _resize_trace = nullptr; // Освобождаем выбранную дорожку после отпускания мыши.
+ unsetCursor();
+ event->accept();
+ return;
@@ -1073,13 +1586,13 @@ index 0fab4b6..cd60ecb 100644
{
assert(event);
+ if (_resize_channel >= 0) {
+ if (_resize_trace) { // Удержание нижней границы меняет высоту выбранной дорожки независимо от типа источника.
+ if (event->buttons() & Qt::LeftButton) {
+ _view.resize_logic_row(_resize_channel, _resize_start_height + event->globalY() - _resize_start_y);
+ _view.resize_trace_row(_resize_trace, _resize_start_height + event->globalY() - _resize_start_y); // Перестраиваем соседние дорожки при каждом перемещении, не допуская их наложения.
+ event->accept();
+ return;
+ }
+ _resize_channel = -1;
+ _resize_trace = nullptr; // Сбрасываем захват, если отпускание кнопки произошло за пределами окна.
+ }
+ if (_drag_traces.empty() && resize_handle_at(event->pos()))
+ setCursor(Qt::SizeVerCursor);
@@ -1093,13 +1606,13 @@ index 0fab4b6..cd60ecb 100644
void Header::leaveEvent(QEvent*)
{
+ if (_resize_channel < 0)
+ if (!_resize_trace) // Сохраняем курсор изменения высоты при выходе за заголовок с удерживаемой мышью.
+ unsetCursor();
_mouse_point = QPoint(-1, -1);
update();
}
diff --git a/DSView/pv/view/header.h b/DSView/pv/view/header.h
index 51f6164..0191c03 100644
index 51f6164..661928a 100644
--- a/DSView/pv/view/header.h
+++ b/DSView/pv/view/header.h
@@ -49,6 +49,7 @@ public:
@@ -1114,31 +1627,46 @@ index 51f6164..0191c03 100644
std::list<std::pair<Trace*, int> > _drag_traces;
Trace *_context_trace;
bool _mouse_is_down;
+ int _resize_channel = -1;
+ Trace *_resize_trace = nullptr; // Запоминаем саму дорожку: номера каналов и разных анализаторов могут совпадать.
+ int _resize_start_y = 0;
+ int _resize_start_height = 0;
};
} // namespace view
diff --git a/DSView/pv/view/trace.cpp b/DSView/pv/view/trace.cpp
index 5540899..ad68b39 100644
index 5540899..6b60e8c 100644
--- a/DSView/pv/view/trace.cpp
+++ b/DSView/pv/view/trace.cpp
@@ -127,7 +127,12 @@ void Trace::set_index_list(const std::list<int> &index_list)
@@ -85,6 +85,7 @@ Trace::Trace(const Trace &t) :
_sec_index(t._sec_index),
_old_v_offset(t._old_v_offset),
_totalHeight(t._totalHeight),
+ _custom_row_height(t._custom_row_height), // При явном копировании дорожки сохраняем выбранную пользователем высоту строки.
_typeWidth(t._typeWidth),
_text_size(t._text_size)
{
@@ -127,9 +128,19 @@ void Trace::set_index_list(const std::list<int> &index_list)
int Trace::get_zero_vpos()
{
- return _v_offset;
+ return get_y();
+}
+
}
+int Trace::get_y() const
+{
+ return _v_offset - (_view ? _view->vertical_offset() : 0);
}
+}
+
+int Trace::get_row_height() const // Единая геометрия используется и раскладкой, и отрисовкой многострочных анализаторов.
+{ // Выбор размера не зависит от сетевого, файлового или демонстрационного источника.
+ return _custom_row_height > 0 ? _custom_row_height : (_view ? _view->get_signalHeight() : 20); // До привязки к View используем двадцать пикселей, соответствующих закрытой константе View::MaxHeightUnit.
+} // Ручное изменение влияет только на эту дорожку.
+
void Trace::resize()
@@ -138,7 +143,7 @@ void Trace::set_view(pv::view::View *view)
{
}
@@ -138,7 +149,7 @@ void Trace::set_view(pv::view::View *view)
{
assert(view);
_view = view;
@@ -1148,10 +1676,22 @@ index 5540899..ad68b39 100644
void Trace::set_viewport(pv::view::Viewport *viewport)
diff --git a/DSView/pv/view/trace.h b/DSView/pv/view/trace.h
index d05c8fb..32e3a0d 100644
index d05c8fb..6ceafa9 100644
--- a/DSView/pv/view/trace.h
+++ b/DSView/pv/view/trace.h
@@ -239,9 +239,7 @@ public:
@@ -142,6 +142,11 @@ public:
inline int get_totalHeight(){
return _totalHeight;
}
+ int get_row_height() const; // Возвращает высоту одной строки этой дорожки, включая индивидуальный масштаб анализатора.
+ virtual int layout_height() { return get_row_height() * rows_size(); } // Обычная дорожка занимает произведение масштаба строки на число строк; анализатор уточняет смешанную геометрию.
+ virtual int resize_rows_size() { return rows_size(); } // У цифрового сигнала все строки масштабируются, а текст анализатора исключается переопределением.
+ inline int custom_row_height() const { return _custom_row_height; } // Ноль означает штатную высоту из Options, положительное значение задаётся перетаскиванием.
+ inline void set_row_height(int height) { _custom_row_height = height; } // Храним размер в объекте дорожки, чтобы удалённый анализатор не передал его новому с тем же индексом.
/**
* Sets the height of this signal.
@@ -239,9 +244,7 @@ public:
/**
* Gets the y-offset of the axis.
*/
@@ -1162,8 +1702,16 @@ index d05c8fb..32e3a0d 100644
/**
* Determines if a point is in the header rect.
@@ -341,6 +344,7 @@ protected:
int _sec_index;
int _old_v_offset;
int _totalHeight;
+ int _custom_row_height = 0; // Индивидуальная высота строки в пикселях; новая дорожка начинает со штатного масштаба.
int _typeWidth;
int _view_index;
diff --git a/DSView/pv/view/view.cpp b/DSView/pv/view/view.cpp
index c35f8f8..088d6f8 100644
index c35f8f8..44c35b0 100644
--- a/DSView/pv/view/view.cpp
+++ b/DSView/pv/view/view.cpp
@@ -578,6 +578,9 @@ void View::set_search_pos(uint64_t search_pos, bool hit)
@@ -1189,14 +1737,13 @@ index c35f8f8..088d6f8 100644
}
void View::update_scale_offset()
@@ -673,12 +678,37 @@ void View::mode_changed()
@@ -673,12 +678,39 @@ void View::mode_changed()
_scale = max(min(_scale, _maxscale), _minscale);
}
+bool View::can_resize_logic_rows() const
+{
+ return _device_agent->is_network() ||
+ (_device_agent->is_file() && _session->has_zynq_disk_view());
+ return _device_agent->get_work_mode() == LOGIC; // Все цифровые источники используют одинаковые размеры дорожек, включая USB, Demo и любые записи DSL.
+}
+
+int View::vertical_offset() const
@@ -1204,17 +1751,20 @@ index c35f8f8..088d6f8 100644
+ return verticalScrollBar()->value();
+}
+
+void View::resize_logic_row(int channel, int height)
+{
+ if (!can_resize_logic_rows())
+ return;
+ const int requested = max(12, min(5 * MaxHeightUnit, height));
+ auto current = _logic_row_heights.find(channel);
+ if (current != _logic_row_heights.end() && current->second == requested)
+ return;
+ _logic_row_heights[channel] = requested;
+ signals_changed(nullptr);
+}
+void View::resize_trace_row(Trace *trace, int height) // Получаем полную желаемую высоту дорожки после движения мыши.
+{ // Храним индивидуальный размер в самой дорожке, без коллизий индексов анализаторов.
+ if (!can_resize_logic_rows() || !trace) return; // Осциллограф сохраняет собственное управление масштабом, а пустой захват не обрабатывается.
+ std::vector<Trace*> traces; // Текущий список позволяет проверить, что анализатор не был удалён во время перетаскивания.
+ get_traces(TIME_VIEW, traces); // Меняем только дорожки временной области, не затрагивая FFT.
+ if (std::find(traces.begin(), traces.end(), trace) == traces.end()) return; // Не обращаемся к объекту, которого больше нет в текущей сессии.
+ const int rows = trace->resize_rows_size(); // Текстовые поля исключаются из числа растягиваемых строк анализатора.
+ if (rows == 0) return; // Полностью текстовый анализатор всегда сохраняет фиксированные промежутки.
+ const int fixed = trace->layout_height() - rows * trace->get_row_height(); // Постоянная часть включает заголовки и текстовые поля между графиками.
+ const int requested = max(12, min(50 * MaxHeightUnit, (height - fixed + rows / 2) / rows)); // Высота 12–1000 пикселей относится только к графикам; неподвижная текстовая часть вычитается до деления.
+ if (trace->get_row_height() == requested) return; // Повторное событие мыши не должно запускать лишнюю перераскладку.
+ trace->set_row_height(requested); // Изменение анализатора не меняет высоту его входных каналов или других декодеров.
+ signals_changed(nullptr); // Пересчитываем положения дорожек и диапазон вертикальной прокрутки.
+} // Рендерер анализатора читает тот же размер через get_row_height().
+
void View::signals_changed(const Trace* eventTrace)
{
@@ -1227,7 +1777,7 @@ index c35f8f8..088d6f8 100644
std::vector<Trace*> time_traces;
std::vector<Trace*> fft_traces;
std::vector<Trace*> traces;
@@ -762,7 +792,15 @@ void View::signals_changed(const Trace* eventTrace)
@@ -762,7 +794,15 @@ void View::signals_changed(const Trace* eventTrace)
max_height = (v + 1) * MaxHeightUnit;
}
@@ -1235,7 +1785,7 @@ index c35f8f8..088d6f8 100644
+ if (resizable_logic) {
+ // Use the selected multiplier even when the tracks need scrolling.
+ if (_logic_max_height != max_height) {
+ _logic_row_heights.clear();
+ for (auto *trace : time_traces) trace->set_row_height(0); // Явное изменение Options → Max Height сбрасывает индивидуальные размеры всех дорожек.
+ _logic_max_height = max_height;
+ }
+ _signalHeight = max_height;
@@ -1244,22 +1794,16 @@ index c35f8f8..088d6f8 100644
actualMargin /= 2;
_signalHeight = max(1.0, (_time_viewport->height()
- 2 * actualMargin * label_size) * 1.0 / total_rows);
@@ -815,7 +853,13 @@ void View::signals_changed(const Trace* eventTrace)
@@ -815,7 +855,7 @@ void View::signals_changed(const Trace* eventTrace)
if (t->rows_size() == 0)
continue;
- const double traceHeight = _signalHeight*t->rows_size();
+ int row_height = _signalHeight;
+ if (resizable_logic && t->get_type() == SR_CHANNEL_LOGIC) {
+ const auto custom = _logic_row_heights.find(t->get_index());
+ if (custom != _logic_row_heights.end())
+ row_height = custom->second;
+ }
+ const int traceHeight = row_height * t->rows_size();
+ const int traceHeight = resizable_logic ? t->layout_height() : _signalHeight * t->rows_size(); // Анализатор суммирует фиксированные текстовые поля и масштабируемые графики, остальные режимы сохраняют штатную высоту.
t->set_totalHeight((int)traceHeight);
t->set_v_offset(next_v_offset + 0.5 * traceHeight + actualMargin);
next_v_offset += traceHeight + 2 * actualMargin;
@@ -831,6 +875,7 @@ void View::signals_changed(const Trace* eventTrace)
@@ -831,6 +871,7 @@ void View::signals_changed(const Trace* eventTrace)
sig->set_scale(sig->get_totalHeight());
}
}
@@ -1267,7 +1811,7 @@ index c35f8f8..088d6f8 100644
_time_viewport->clear_measure();
_session->update_dso_data_scale();
}
@@ -969,6 +1014,7 @@ void View::v_scroll_value_changed(int value)
@@ -969,6 +1010,7 @@ void View::v_scroll_value_changed(int value)
{
(void)value;
_header->update();
@@ -1275,11 +1819,13 @@ index c35f8f8..088d6f8 100644
viewport_update();
}
@@ -1367,6 +1413,10 @@ void View::splitterMoved(int pos, int index)
@@ -1367,6 +1409,12 @@ void View::splitterMoved(int pos, int index)
void View::reload()
{
+ _logic_row_heights.clear();
+ std::vector<Trace*> traces; // При смене устройства сбрасываем размеры ещё существующих дорожек перед обновлением сессии.
+ get_traces(TIME_VIEW, traces); // Временная область содержит цифровые каналы и анализаторы текущего источника.
+ for (auto *trace : traces) trace->set_row_height(0); // Новая конфигурация источника начинает со штатных высот.
+ _logic_max_height = 0;
+ _logic_content_height = 0;
+ verticalScrollBar()->setValue(0);
@@ -1287,7 +1833,7 @@ index c35f8f8..088d6f8 100644
/*
diff --git a/DSView/pv/view/view.h b/DSView/pv/view/view.h
index aa738ec..9ab29bc 100644
index aa738ec..731d5fb 100644
--- a/DSView/pv/view/view.h
+++ b/DSView/pv/view/view.h
@@ -25,6 +25,7 @@
@@ -1303,16 +1849,15 @@ index aa738ec..9ab29bc 100644
return _signalHeight;
}
+ bool can_resize_logic_rows() const;
+ void resize_logic_row(int channel, int height);
+ void resize_trace_row(Trace *trace, int height); // Полная высота в пикселях относится к конкретному каналу или анализатору, а не к номеру входа.
+ int vertical_offset() const;
int headerWidth();
@@ -459,6 +463,9 @@ private:
@@ -459,6 +463,8 @@ private:
int64_t _preOffset;
int _spanY;
int _signalHeight;
+ std::map<int, int> _logic_row_heights;
+ int _logic_max_height = 0;
+ int _logic_content_height = 0;
bool _updating_scroll;
@@ -1541,3 +2086,20 @@ index 3b298d4..90d0ff1 100644
if (vdev->version == 1)
{
if (unzLocateFile(vdev->archive, "data", 0) != UNZ_OK)
diff --git a/DSView/pv/dock/measuredock.cpp b/DSView/pv/dock/measuredock.cpp
--- a/DSView/pv/dock/measuredock.cpp
+++ b/DSView/pv/dock/measuredock.cpp
@@ -771,10 +771,11 @@
{
QString border_width = isCursor ? "0px" : "1px";
QString hoverColor = isCursor ? cursorColor.darker().name() : bkColor.name();
+ QString horizontal_padding = "; padding-left:4px; padding-right:4px"; // По четыре логических пикселя с каждой стороны соответствуют запасу +8 в расчёте ширины номерных кнопок и перекрывают слишком широкие отступы темы SETGUI.
QString normal = "{background-color:" + cursorColor.name() +
- "; color:black" + "; border-width:" + border_width + ";}";
+ "; color:black" + "; border-width:" + border_width + horizontal_padding + ";}"; // Номер получает всю рассчитанную область текста без обрезания отступами общей темы.
QString hover = "{background-color:" + hoverColor +
- "; color:black" + "; border-width:" + border_width + ";}";
+ "; color:black" + "; border-width:" + border_width + horizontal_padding + ";}"; // Номер получает всю рассчитанную область текста без обрезания отступами общей темы.
QString style = "QPushButton:hover" + hover +
"QPushButton" + normal;
btn->setStyleSheet(style);