Расширить DSView осциллографом и декодерами двигателя и сигма-дельта
This commit is contained in:
@@ -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()) # Сегмент содержит физические амперы на обоих концах интервала.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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 регистрирует декодер асинхронного двигателя.
|
||||
@@ -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) # Последний диагностический интервал сохраняется даже при неизменных ключах.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 загружает адаптер сигма-дельта потока из этого пакета.
|
||||
@@ -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 # Запоминаем предыдущий реальный фронт даже во время начального установления.
|
||||
@@ -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);
|
||||
@@ -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 предотвращает повторное определение виджета при включении в нативные проверки.
|
||||
@@ -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(' ');
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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() # Прямой запуск выполняет полный пользовательский сценарий в нативном приложении.
|
||||
@@ -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 и не добавляет в него скрытые команды.
|
||||
@@ -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() # Импорт файла не запускает компилятор, сеть или приложение.
|
||||
@@ -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-записи.
|
||||
@@ -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() # Дочерний процесс обязан завершить декодирование и закрыться штатно, оставив контрольный скриншот.
|
||||
@@ -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-тест.
|
||||
@@ -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; // Нулевой код означает прохождение функциональных и нагрузочных проверок.
|
||||
} // Все ресурсы индекса освобождаются автоматически после завершения теста.
|
||||
@@ -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.
|
||||
@@ -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')
|
||||
|
||||
@@ -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() # Стандартный запуск возвращает ненулевой код при нарушении проверяемых свойств.
|
||||
@@ -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() # Ненулевой код завершения сообщает о нарушении измерительного контракта.
|
||||
@@ -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() # Код возврата отражает численные и интеграционные проверки.
|
||||
@@ -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() # Ошибка любого нового режима возвращает ненулевой код завершения.
|
||||
@@ -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) # Для каждого переносимого пакета создаются вложенные каталоги.
|
||||
|
||||
@@ -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) # Ненулевой код любого числового утверждения становится провалом теста.
|
||||
@@ -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 выдаёт количество проверок и описание несоответствий.
|
||||
@@ -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 запускает поток по аппаратному триггеру и сжимает
|
||||
повторы уровней без потерь. Если входы меняются слишком часто и
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user