diff --git a/python/logic_analyzer/decoders/pwm_motor.py b/python/logic_analyzer/decoders/pwm_motor.py new file mode 100644 index 0000000..660ab8d --- /dev/null +++ b/python/logic_analyzer/decoders/pwm_motor.py @@ -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()) # Сегмент содержит физические амперы на обоих концах интервала. diff --git a/python/logic_analyzer/decoders/pwm_waveform.py b/python/logic_analyzer/decoders/pwm_waveform.py index f33d1d8..2d56ac7 100644 --- a/python/logic_analyzer/decoders/pwm_waveform.py +++ b/python/logic_analyzer/decoders/pwm_waveform.py @@ -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 # Результаты рассчитываются из записи и не предполагают заранее правильную трёхфазную систему. diff --git a/python/logic_analyzer/decoders/sigma_delta.py b/python/logic_analyzer/decoders/sigma_delta.py new file mode 100644 index 0000000..0487f6b --- /dev/null +++ b/python/logic_analyzer/decoders/sigma_delta.py @@ -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. diff --git a/tools/dsview/README.md b/tools/dsview/README.md index a057456..fb1cd1e 100644 --- a/tools/dsview/README.md +++ b/tools/dsview/README.md @@ -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 --capture --output `. + ## 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 --capture --output `. + Декодер восстанавливает кривые заполнения для 1–3 цифровых входов. В SETGUI он находится в меню **Анализаторы SET → ШИМ → синус · 1–3 канала**. [Настройки, режимы и ограничения](decoders/pwm_waveform/README.md). diff --git a/tools/dsview/build.py b/tools/dsview/build.py index cce252c..431230b 100644 --- a/tools/dsview/build.py +++ b/tools/dsview/build.py @@ -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(), diff --git a/tools/dsview/decoder-scope.patch b/tools/dsview/decoder-scope.patch new file mode 100644 index 0000000..d3cd0ff --- /dev/null +++ b/tools/dsview/decoder-scope.patch @@ -0,0 +1,188 @@ +--- a/DSView/pv/mainwindow.cpp ++++ b/DSView/pv/mainwindow.cpp +@@ -84,6 +84,8 @@ + #include "dock/protocoldock.h" + + #include "view/view.h" ++#include "view/decoder_scope.h" // Нативная проверка открывает и проверяет отдельный осциллограф в реальном процессе DSView. ++#include "view/decodetrace.h" // Тест использует настоящий нижний анализатор и его контекстное действие без подмены данных. + #include "view/trace.h" + #include "view/signal.h" + #include "view/dsosignal.h" +@@ -652,6 +654,51 @@ + + void MainWindow::checkHostScope() // Проверяет добавление аналоговых дорожек отдельно от сценариев диалогов декодеров. + { // Отдельная точка запуска не требует Ethernet-патча и доступна в базовой сборке DSView. ++ if (qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_DECODER_SCOPE")) { // Дополнительный сценарий выполняется только при явном запуске проверки и не влияет на пользовательскую сессию. ++ QTimer::singleShot(1500, _view, [this]() { // Пауза даёт трём анализаторам короткой тестовой записи завершить полный расчёт перед проверкой окна. ++ bool passed = !_session->get_decode_signals().empty(); // Без исходного анализатора положительный результат невозможен. ++ auto *trace = passed ? _session->get_decode_signals().back() : nullptr; // Тестовая запись заканчивается именно графиком токов Ia/Ib/Ic. ++ const double scale = _view->scale(); const int64_t offset = _view->offset(); // Независимое увеличение не должно менять область основной записи. ++ const size_t digital_count = _session->get_signals().size(); const size_t decoder_count = _session->get_decode_signals().size(); // Данные и соседние анализаторы сохраняются при открытии и закрытии дополнительного окна. ++ auto *stack = trace ? trace->decoder() : nullptr; const uint64_t results = stack ? stack->get_result_count() : 0; // Повторный анализ не требуется, поэтому число готовых результатов должно оставаться неизменным. ++ QString diagnostic = QString("target_y=%1 source_results=%2 running=%3").arg(trace ? trace->get_y() : -1).arg(results).arg(stack ? stack->IsRunning() : false); // Диагностика отличает ошибку выбора области от незавершённого исходного анализа. ++ auto mark = [&diagnostic, &passed](const char *stage) { if (!passed) diagnostic += QString(" failed_after=") + stage; }; // Первая неуспешная стадия остаётся видимой без повторного запуска отладочного сценария. ++ _view->get_time_view()->repaint(); _view->get_time_view()->grab().save(QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK")) + ".source.png"); // Перерисовывается настоящий временной Viewport, расположенный внутри общего QScrollArea::viewport. ++ auto find_scope = []() -> QWidget* { for (auto *widget : QApplication::topLevelWidgets()) if (widget->objectName() == "decoder_scope_window") return widget; return nullptr; }; // Проверка ищет настоящее отдельное окно, а не его миниатюру или область основного вида. ++ if (trace) { QMouseEvent click(QEvent::MouseButtonDblClick, QPointF(_view->get_view_width() / 2, trace->get_y() - trace->text_row_height() / 2), Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(_view->get_time_view(), &click); } // Событие получает именно временной график, а не его контейнер QScrollArea; точка лежит выше строки предупреждений. ++ QPointer scope = find_scope(); // Слабая ссылка проверяет фактическое удаление при закрытии окна. ++ passed = passed && scope && scope->isWindow() && scope->property("scope_decoder").toString() == "pwm_motor" && scope->property("scope_rows").toStringList() == QStringList({"wave_a", "wave_b", "wave_c"}); // В отдельном окне должны оказаться ровно три фазных тока без соседних графиков. ++ mark("open_selected"); // Проверяется выбор нижнего графика до остальных взаимодействий. ++ if (scope) { // Последующие действия выполняются только при успешном создании настоящего окна. ++ scope->resize(1100, 640); scope->grab(); passed = passed && scope->property("scope_segments").toULongLong() > 0; mark("render"); // QWidget::grab принудительно рисует новое окно даже до первого события его экспозиции в Windows. ++ const double span = scope->property("scope_span").toDouble(); const QPointF point(600, 300); // Внутренняя точка гарантированно находится в области графика после изменения размера. ++ QWheelEvent wheel(point, scope->mapToGlobal(point.toPoint()), QPoint(), QPoint(0, 120), Qt::NoButton, Qt::NoModifier, Qt::NoScrollPhase, false); QApplication::sendEvent(scope, &wheel); // Один реальный шаг колеса увеличивает временной масштаб только отдельного осциллографа. ++ passed = passed && scope->property("scope_span").toDouble() < span && _view->scale() == scale && _view->offset() == offset; // Проверяется независимость времени от основного DSView. ++ mark("zoom"); // Уменьшение видимого интервала подтверждается собственными координатами отдельного окна. ++ const double begin = scope->property("scope_start").toDouble(); QMouseEvent press(QEvent::MouseButtonPress, point, Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(scope, &press); // Начальная точка жеста принадлежит текущему увеличенному интервалу. ++ QMouseEvent move(QEvent::MouseMove, point - QPointF(100, 0), Qt::NoButton, Qt::LeftButton, Qt::NoModifier); QApplication::sendEvent(scope, &move); // Перемещение влево должно показать более поздний участок той же записи. ++ QMouseEvent release(QEvent::MouseButtonRelease, point - QPointF(100, 0), Qt::LeftButton, Qt::NoButton, Qt::NoModifier); QApplication::sendEvent(scope, &release); passed = passed && scope->property("scope_start").toDouble() > begin; // Отпускание завершает жест после реального изменения временного положения. ++ mark("pan"); // Прокрутка должна менять начало собственного видимого интервала. ++ auto *fit = scope->findChild("scope_fit_time"); if (fit) fit->click(); passed = passed && fit && std::abs(scope->property("scope_span").toDouble() - double(stack->sample_count())) < 0.5; mark("fit_time"); // Полная запись сравнивается с числом исходных отсчётов, а не с округлённым начальным масштабом основного окна. ++ auto *range = scope->findChild("scope_y_range"); if (range) range->setValue(50); passed = passed && range && range->value() == 50; // Ручная шкала ±50 А не меняет параметры расчётной модели двигателя. ++ auto *fit_y = scope->findChild("scope_fit_amplitude"); if (fit_y) fit_y->click(); passed = passed && fit_y && range && range->value() > 50; mark("fit_amplitude"); // Автомасштаб должен вместить пусковые токи около 80 А после ручного выбора ±50 А. ++ scope->repaint(); const QString screenshot = QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK")) + ".png"; passed = scope->grab().save(screenshot) && passed; // Снимок содержит только отдельный выбранный график и служит визуальным артефактом проверки. ++ passed = passed && stack->get_result_count() == results && _session->get_signals().size() == digital_count && _session->get_decode_signals().size() == decoder_count; // Все взаимодействия используют кэш и сохраняют исходную цифровую запись. ++ mark("cached_results"); // Открытие и перемещение графика не должны запускать повторное декодирование. ++ scope->close(); QApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); passed = passed && scope.isNull(); // Закрытие дополнительного осциллографа должно действительно освобождать его объект. ++ mark("close"); // Исчезновение окна проверяется слабой ссылкой на настоящий QWidget. ++ trace->show_scope_menu(_view->mapToGlobal(QPoint(100, 100))); auto *menu = _view->findChild("decoder_scope_menu"); // Повторное открытие проверяет альтернативный пользовательский путь через контекстное меню имени. ++ passed = passed && menu && menu->actions().size() == 1; if (menu && !menu->actions().empty()) { menu->actions().front()->trigger(); menu->close(); } // Нижний совмещённый график должен предлагаться одним действием A/B/C. ++ QPointer reopened = find_scope(); passed = passed && reopened; // Закрытие предыдущего окна не мешает повторному открытию того же анализатора. ++ mark("menu_reopen"); // Проверяется наличие одного контекстного действия и его связь с теми же готовыми данными. ++ _session->remove_decoder(int(decoder_count - 1)); QApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); // Удаление тестового анализатора воспроизводит смену источника и проверяет слабую связь окна с данными. ++ passed = passed && reopened.isNull() && _session->get_signals().size() == digital_count && _session->get_decode_signals().size() == decoder_count - 1; // Окно закрывается вместе с источником, а цифровые каналы и два соседних анализатора сохраняются. ++ mark("source_removal"); // Удаление исходного стека не должно оставлять окно с недействительными указателями. ++ } // В случае отсутствия окна журнал получит явный FAIL без обращения по нулевому указателю. ++ QFile report(QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK"))); if (report.open(QIODevice::WriteOnly | QIODevice::Truncate)) report.write(passed ? "PASS selected decoder scope / render / independent zoom and pan / range / cached results / reopen / source removal\n" : (QString("FAIL detached decoder scope ") + diagnostic + "\n").toUtf8()); // Отчёт проверяет полный жизненный цикл и при ошибке сохраняет координаты выбора и состояние исходного анализа. ++ }); // Проверка не блокирует главный цикл событий DSView во время ожидания результата. ++ return; // Демонстрационные CH1…CH4 в этом отдельном сценарии не добавляются. ++ } // В обычной работе ветка проверки отключена. + if (qEnvironmentVariableIsSet("DSVIEW_HOST_CHECK_SCOPE")) { // Явный проверочный режим использует реальные действия меню, не подключая USB или Ethernet. + const auto original_signals = _session->get_signals(); // Копия указателей проверяет сохранность существующей цифровой записи. + std::vector before; // Список позволяет обнаружить потерю или лишнее добавление любой дорожки. +--- a/DSView/pv/view/decodetrace.cpp ++++ b/DSView/pv/view/decodetrace.cpp +@@ -34,6 +34,7 @@ + #include + #include "decodetrace.h" + #include "pwm_waveform.h" // Индекс экстремумов сохраняет форму PWM при любом масштабе времени. ++#include "decoder_scope.h" // Самостоятельное окно использует тот же индекс выбранной графической области анализатора. + #include "../sigsession.h" + #include "../data/decoderstack.h" + #include "../data/decode/decoder.h" +@@ -222,6 +223,7 @@ + + _cur_row_headings.clear(); + _cur_row_heading_offsets.clear(); // Координаты и подписи пересоздаются вместе после изменения видимых строк декодера. ++ _scope_regions.clear(); // Старые области выбора не должны оставаться после смены раскладки, очистки или нового декодирования. + + // Show sample rate as 1Hz when it is unknown + if (samplerate == 0.0) +@@ -278,6 +280,8 @@ + const bool allocate = !row_combined || !wave_shown; // Каждому дополнительному расчётному графику всегда выделяется собственная область. + if (allocate) combined_y = y; // Раздельный режим начинает новую область для каждого канала. + const int wave_top = combined_y; // Следующие совмещённые фазы используют ту же вертикальную систему координат. ++ if (allocate) _scope_regions.push_back({wave_top - annotation_height * 0.5, wave_top + annotation_height * 2.5, QString::fromUtf8(dec->decoder()->id), QStringList{row_id}, get_name() + QStringLiteral(" · ") + (row_combined ? QStringLiteral("A / B / C") : row.title())}); // Координаты совпадают с областью отсечения нарисованного графика, а совмещённые фазы открываются вместе. ++ else _scope_regions.back().rows.append(row_id); // Вторая и третья фазы той же области добавляются в одно отдельное окно без предупреждений и других анализаторов. + 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)); // Совмещённые фазы получают одинаковую область отсечения. +@@ -786,6 +790,26 @@ + } + + //to show decoder's property setting dialog ++bool DecodeTrace::open_scope_at(int y) // Обработчик вызывается только для двойного щелчка в области данных DSView. ++{ // Текстовые аннотации и цифровые сигналы сохраняют прежнее поведение мыши. ++ for (const auto ®ion : _scope_regions) if (y >= region.top && y < region.bottom) { // Попадание учитывает актуальные высоты областей и объединение фаз текущего кадра. ++ auto *window = new DecoderScopeWindow(_decoder_stack, _view, region.decoder, region.rows, region.title); window->show(); // Новое окно разделяет готовые данные только выбранного анализатора и имеет собственную временную шкалу. ++ return true; // Исходный обработчик двойного щелчка больше не добавляет курсор поверх открытого графика. ++ } // Все области принадлежат только данному экземпляру декодера. ++ return false; // При отсутствии графика исходное взаимодействие DSView продолжается. ++} // Удаление источника автоматически закрывает созданное окно через слабую ссылку DecoderScopeWindow. ++ ++void DecodeTrace::show_scope_menu(const QPoint &position) // Альтернативный способ открытия доступен через название анализатора слева. ++{ // Меню содержит только реально отображаемые графические области текущего анализатора. ++ if (_scope_regions.empty()) return; // Обычные текстовые декодеры не получают пустого контекстного меню. ++ auto *menu = new QMenu(_view); menu->setObjectName("decoder_scope_menu"); menu->setAttribute(Qt::WA_DeleteOnClose); // Меню принадлежит текущему представлению и освобождается при закрытии. ++ for (const auto ®ion : _scope_regions) { // Раздельные фазы, общий A/B/C, θ и несущая перечисляются как отдельные варианты. ++ auto *action = menu->addAction(QString::fromUtf8("Открыть в отдельном окне: ") + region.title); // Название явно указывает, какой именно график будет вынесен. ++ connect(action, &QAction::triggered, this, [this, region]() { auto *window = new DecoderScopeWindow(_decoder_stack, _view, region.decoder, region.rows, region.title); window->show(); }); // Контекст DecodeTrace отменяет обработчик, если исходный анализатор удалён. ++ } // Выбор одной области не открывает соседние анализаторы или цифровые дорожки. ++ menu->popup(position); // Неблокирующее меню сохраняет обработку потока декодирования и команд SETGUI. ++} // Позиция передаётся в глобальных координатах мыши. ++ + bool DecodeTrace::create_popup(bool isnew) + { + (void)isnew; +--- a/DSView/pv/view/decodetrace.h ++++ b/DSView/pv/view/decodetrace.h +@@ -30,6 +30,7 @@ + #include + #include + #include ++#include // Безопасные идентификаторы фаз и дополнительных графиков не содержат указателей на результаты декодирования. + + #include "trace.h" + #include "../prop/binding/decoderoptions.h" +@@ -143,6 +144,8 @@ + void* get_key_handel(); + + bool create_popup(bool isnew); ++ bool open_scope_at(int y); // Двойной щелчок открывает только графическую область под указанной координатой представления. ++ void show_scope_menu(const QPoint &position); // Контекстное меню имени анализатора перечисляет его отдельные графики. + + protected: + void paint_type_options(QPainter &p, int right, const QPoint pt, QColor fore); +@@ -196,6 +199,8 @@ + + std::vector _cur_row_headings; + std::vector _cur_row_heading_offsets; // Относительные координаты заголовков учитывают разную высоту текстовых и графических строк. ++ struct ScopeRegion { double top, bottom; QString decoder; QStringList rows; QString title; }; // Одна область связывает фактические координаты кадра с безопасными идентификаторами выбранных кривых. ++ std::vector _scope_regions; // Список пересоздаётся при рисовании, учитывая совмещение фаз, скрытие строк и ручную высоту графиков. + + }; + +--- a/DSView/pv/view/header.cpp ++++ b/DSView/pv/view/header.cpp +@@ -25,6 +25,7 @@ + #include + #include + #include ++#include // Положение контекстного меню определяется текущим нажатием, без зависимости от последнего наведения мыши. + #include + #include + #include +@@ -544,14 +545,11 @@ + + void Header::contextMenuEvent(QContextMenuEvent *event) + { +- (void)event; +- +- int action; +- +- const auto t = get_mTrace(action, _mouse_point); +- +- if (!t || !t->selected() || action != Trace::LABEL) +- return; ++ int action = 0; // Поиск использует штатные области имени, метки и цвета дорожки. ++ auto *trace = get_mTrace(action, event->pos()); // Правый щелчок выбирает конкретный анализатор под курсором, включая ещё не выделенный. ++ if (trace && trace->get_type() == SR_CHANNEL_DECODER) { // Контекстное открытие графиков не изменяет меню цифровых и аппаратных аналоговых каналов. ++ static_cast(trace)->show_scope_menu(event->globalPos()); event->accept(); // Пользователь выбирает одну осциллограмму этого анализатора в неблокирующем меню. ++ } // Для текстового декодера без графических строк отдельное меню не появляется. + } + + void Header::on_action_set_name_triggered() +--- a/DSView/pv/view/viewport.cpp ++++ b/DSView/pv/view/viewport.cpp +@@ -1260,6 +1260,10 @@ + if (!_view.get_view_rect().contains(event->pos())) + return; + ++ if (event->button() == Qt::LeftButton) for (auto *trace : _view.session().get_decode_signals()) { // Двойной щелчок по графику анализатора обрабатывается раньше добавления обычных измерительных курсоров. ++ if (trace->open_scope_at(event->pos().y())) { event->accept(); return; } // Проверяются фактические области кадра; новое окно содержит только выбранную осциллограмму. ++ } // Щелчки по цифровым сигналам, текстам и остальным видам сохраняют прежнюю обработку DSView. ++ + int mode = _view.session().get_device()->get_work_mode(); + + if (mode == LOGIC) diff --git a/tools/dsview/decoders/pwm_motor/README.md b/tools/dsview/decoders/pwm_motor/README.md new file mode 100644 index 0000000..bc211da --- /dev/null +++ b/tools/dsview/decoders/pwm_motor/README.md @@ -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% относительно +идеализированной схемы. Этот допуск относится к математической проверке, +а не к точности восстановления реального тока. diff --git a/tools/dsview/decoders/pwm_motor/__init__.py b/tools/dsview/decoders/pwm_motor/__init__.py new file mode 100644 index 0000000..9bc9b71 --- /dev/null +++ b/tools/dsview/decoders/pwm_motor/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder # Стандартная точка входа sigrok регистрирует декодер асинхронного двигателя. diff --git a/tools/dsview/decoders/pwm_motor/pd.py b/tools/dsview/decoders/pwm_motor/pd.py new file mode 100644 index 0000000..dc5f827 --- /dev/null +++ b/tools/dsview/decoders/pwm_motor/pd.py @@ -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) # Последний диагностический интервал сохраняется даже при неизменных ключах. diff --git a/tools/dsview/decoders/pwm_waveform/README.md b/tools/dsview/decoders/pwm_waveform/README.md index 477c238..6bc1397 100644 --- a/tools/dsview/decoders/pwm_waveform/README.md +++ b/tools/dsview/decoders/pwm_waveform/README.md @@ -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`. diff --git a/tools/dsview/decoders/pwm_waveform/pd.py b/tools/dsview/decoders/pwm_waveform/pd.py index 7655185..d7faa1a 100644 --- a/tools/dsview/decoders/pwm_waveform/pd.py +++ b/tools/dsview/decoders/pwm_waveform/pd.py @@ -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) # Завершаются только полные окна и постоянные участки; отсутствующие фронты и неполные периоды не выдумываются. diff --git a/tools/dsview/decoders/sigma_delta/README.md b/tools/dsview/decoders/sigma_delta/README.md new file mode 100644 index 0000000..c9ab7ac --- /dev/null +++ b/tools/dsview/decoders/sigma_delta/README.md @@ -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`. diff --git a/tools/dsview/decoders/sigma_delta/__init__.py b/tools/dsview/decoders/sigma_delta/__init__.py new file mode 100644 index 0000000..956ef40 --- /dev/null +++ b/tools/dsview/decoders/sigma_delta/__init__.py @@ -0,0 +1 @@ +from .pd import Decoder # Стандартная точка входа sigrok загружает адаптер сигма-дельта потока из этого пакета. diff --git a/tools/dsview/decoders/sigma_delta/pd.py b/tools/dsview/decoders/sigma_delta/pd.py new file mode 100644 index 0000000..bf764c1 --- /dev/null +++ b/tools/dsview/decoders/sigma_delta/pd.py @@ -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 # Запоминаем предыдущий реальный фронт даже во время начального установления. diff --git a/tools/dsview/motor-currents.patch b/tools/dsview/motor-currents.patch new file mode 100644 index 0000000..1c7b97a --- /dev/null +++ b/tools/dsview/motor-currents.patch @@ -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 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_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); diff --git a/tools/dsview/native/decoder_scope.h b/tools/dsview/native/decoder_scope.h new file mode 100644 index 0000000..f8f2c5d --- /dev/null +++ b/tools/dsview/native/decoder_scope.h @@ -0,0 +1,157 @@ +// Самостоятельный осциллограф читает индекс готового декодера и не запускает повторный анализ записи. +#ifndef SET_DECODER_SCOPE_H // Защита позволяет включать виджет из графика и нативной проверки одного процесса. +#define SET_DECODER_SCOPE_H // Каждая единица трансляции получает единственное определение класса. +#include // Обычное окно Qt остаётся частью того же DSView и может разворачиваться независимо. +#include // Слабые ссылки предотвращают обращение к удалённым результатам или основному представлению. +#include // Кривые и физические шкалы выводятся без копирования снимка основного окна. +#include // Перетаскивание задаёт собственное положение временного окна. +#include // Колесо изменяет временной масштаб относительно точки под курсором. +#include // Панель команд сохраняет компактную горизонтальную компоновку. +#include // Растягиваемая область под панелью принадлежит только выбранному графику. +#include // Команды возвращают полную запись и подбирают вертикальный диапазон. +#include // Полушкала задаётся в физических единицах без повторного декодирования. +#include // Отдельные фазы можно временно скрывать в самостоятельном окне. +#include // Подпись объясняет назначение вертикального масштаба. +#include // Ограниченная частота обновления сохраняет отзывчивость при поступлении результатов. +#include // Бесконечности используются только как исходные границы поиска экстремумов. +#include // Преобразования шкал и увеличение выполняются в координатах отсчётов. +#include // Ограничение области не допускает выхода за границы записи. +#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 _stack; // Результаты принадлежат исходному декодеру и никогда не копируются целиком. + QPointer _source; // Основное представление используется для начального диапазона и актуальных цветов входов. + QString _decoder_id; // Имя декодера отделяет его строки от остальных уровней стека. + QStringList _rows; // Выбранные идентификаторы строк сохраняются безопасно, без указателей на удаляемые RowData. + QList _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 segments(const QString &id) const { // Видимый диапазон запрашивается из существующего индекса минимумов и максимумов. + std::vector 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(&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::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 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 предотвращает повторное определение виджета при включении в нативные проверки. diff --git a/tools/dsview/native/pwm_waveform.h b/tools/dsview/native/pwm_waveform.h index 3cb722d..57d2504 100644 --- a/tools/dsview/native/pwm_waveform.h +++ b/tools/dsview/native/pwm_waveform.h @@ -5,8 +5,111 @@ #include #include #include +#include // Двоичный поиск ограничивает обработку видимой областью записи. +#include // Координаты отсчётов хранятся без потери точности в 64 битах. +#include // Пирамида экстремумов строится постепенно в потоке декодирования. 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 samples; // Полные данные нужны только для видимых сегментов крупного масштаба. + std::vector> 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 &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(' '); diff --git a/tools/dsview/native/scope_demo.h b/tools/dsview/native/scope_demo.h new file mode 100644 index 0000000..cfb5f73 --- /dev/null +++ b/tools/dsview/native/scope_demo.h @@ -0,0 +1,29 @@ +#pragma once // Единственное определение переносимой модели при включении в несколько компонентов DSView. +#include // Перестановка границ и сравнение напряжений нужны для точной огибающей. +#include // Синус и операции над фазой повторяют идеальный источник осциллографа 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. diff --git a/tools/dsview/native/scope_trace.h b/tools/dsview/native/scope_trace.h new file mode 100644 index 0000000..aa1fe33 --- /dev/null +++ b/tools/dsview/native/scope_trace.h @@ -0,0 +1,55 @@ +#pragma once // Заголовочная реализация добавляет дорожку без отдельного списка исходников CMake и генерации moc. +#include "trace.h" // Базовая дорожка обеспечивает выбор, название, цвет и размещение на общей временной шкале. +#include "view.h" // Масштаб и смещение берутся из того же View, который рисует цифровые каналы. +#include "scope_demo.h" // Переносимая модель повторяет демонстрационные сигналы осциллографа Zynq. +#include // Непрерывная кривая передаётся 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. diff --git a/tools/dsview/protocol-files.json b/tools/dsview/protocol-files.json index 8ea0cd1..742ef78 100644 --- a/tools/dsview/protocol-files.json +++ b/tools/dsview/protocol-files.json @@ -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", diff --git a/tools/dsview/pwm-waveform.patch b/tools/dsview/pwm-waveform.patch index ea5f8b2..cf6c40a 100644 --- a/tools/dsview/pwm-waveform.patch +++ b/tools/dsview/pwm-waveform.patch @@ -4,56 +4,135 @@ #include #include #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 rows = _decoder_stack->get_rows_gshow(); + for (std::map::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 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 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 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 + + #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 &dest, + uint64_t start_sample, uint64_t end_sample); + ++ void get_pwm_waveform(std::vector &dest, uint64_t start, uint64_t end, size_t pixels); // Возвращаем ограниченную шириной окна геометрию с обоими экстремумами. ++ + void clear(); + + private: +@@ -68,6 +71,8 @@ + uint64_t _item_count; + std::vector _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 &dest, uint64_t start, uint64_t end, size_t pixels) // Геометрия строится без полного перебора списка аннотаций. ++{ // Чтение индекса синхронизируется с поступлением сегментов рабочего потока. ++ std::lock_guard lock(_global_visitor_mutex); // Та же блокировка защищает исходные аннотации и их индекс. ++ _pwm_envelope.lines(dest, start, end, pixels); // Сложность запроса зависит от экранной ширины, а не от длительности захвата. ++} // После возврата интерфейс рисует собственную копию геометрии без блокировки декодирования. ++ + uint64_t RowData::get_annotation_index(uint64_t start_sample) + { + std::lock_guard 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 + #include + ++#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 &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_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 &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) diff --git a/tools/dsview/scope-channel.patch b/tools/dsview/scope-channel.patch new file mode 100644 index 0000000..a6eb739 --- /dev/null +++ b/tools/dsview/scope-channel.patch @@ -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 _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 // Меню каналов встраивается в существующую прокручиваемую панель управления. ++#include // Флажки позволяют независимо добавить и убрать каждый канал осциллографа. + + #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 before; // Список позволяет обнаружить потерю или лишнее добавление любой дорожки. ++ _view->get_traces(ALL_VIEW, before); // Исходное число включает цифровые сигналы и уже добавленные декодеры. ++ const double scale = _view->scale(); // Включение осциллографа обязано сохранить выбранный масштаб времени. ++ QList channels; // Используем те же QAction, которые выбирает пользователь в меню осциллографа. ++ bool passed = true; // Любое несоответствие делает нативный сценарий неуспешным. ++ for (int channel = 1; channel <= 4; ++channel) { // Проверяем независимое включение всех четырёх источников Zynq. ++ auto action = _sampling_bar->findChild(QString("scope_channel_%1").arg(channel)); // Поиск идёт только в панели текущего экземпляра DSView. ++ if (!action) { passed = false; break; } // Отсутствие пользовательского элемента управления не маскируется проверкой только модели. ++ channels.append(action); // Сохраняем реальные действия для проверки удаления и повторного добавления. ++ action->setChecked(true); // Сигнал toggled выполняет производственный путь компоновки и обновления графика. ++ std::vector 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 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 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; + } diff --git a/tools/dsview/tests/check_decoder_scope_native.py b/tools/dsview/tests/check_decoder_scope_native.py new file mode 100644 index 0000000..1fb8325 --- /dev/null +++ b/tools/dsview/tests/check_decoder_scope_native.py @@ -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() # Прямой запуск выполняет полный пользовательский сценарий в нативном приложении. diff --git a/tools/dsview/tests/check_link_loss.cpp b/tools/dsview/tests/check_link_loss.cpp new file mode 100644 index 0000000..a28609e --- /dev/null +++ b/tools/dsview/tests/check_link_loss.cpp @@ -0,0 +1,125 @@ +#include // Настоящий цикл Qt выявляет модальные окна и повторный вход при сетевом таймауте. +#include // Нажатие штатной кнопки OK проверяет очистку указателя уведомления. +#include // Проверяем фактическую видимость текста ошибки, а не только существование оболочки уведомления. +#include // Кнопка закрытия получает обычные события мыши после проверки её видимой геометрии. +#include // Две страницы воспроизводят переход из DSView на другую вкладку SETGUI. +#include // Родительская страница задаёт настоящую область встраивания дочернего окна. +#include // Монотонное время ограничивает ожидание ответа и проверку отсутствия повторов. +#include // Уведомление может удалиться после OK, поэтому тест не хранит висячий указатель. +#include // Проверки выполняются в 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 // 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(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 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 visible; // Скрытые закрывающиеся объекты не считаются пользовательскими уведомлениями. + for (auto *widget : frame.findChildren(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()) 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()) 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(current->winId()))) { fail("packet error remains above another tab"); return; } // Проверяем фактическую Win32-видимость после скрытия нативного родителя. + pages->setCurrentIndex(0); // Возврат должен восстановить анализатор и доступную кнопку предупреждения. + if (!IsWindowVisible(reinterpret_cast(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 и не добавляет в него скрытые команды. diff --git a/tools/dsview/tests/check_link_loss.py b/tools/dsview/tests/check_link_loss.py new file mode 100644 index 0000000..4ff602e --- /dev/null +++ b/tools/dsview/tests/check_link_loss.py @@ -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() # Импорт файла не запускает компилятор, сеть или приложение. diff --git a/tools/dsview/tests/check_motor_native.py b/tools/dsview/tests/check_motor_native.py new file mode 100644 index 0000000..487605c --- /dev/null +++ b/tools/dsview/tests/check_motor_native.py @@ -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-записи. diff --git a/tools/dsview/tests/check_pwm_native.py b/tools/dsview/tests/check_pwm_native.py index e210f86..d007cfd 100644 --- a/tools/dsview/tests/check_pwm_native.py +++ b/tools/dsview/tests/check_pwm_native.py @@ -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() diff --git a/tools/dsview/tests/check_pwm_npc_native.py b/tools/dsview/tests/check_pwm_npc_native.py new file mode 100644 index 0000000..c6f8dbc --- /dev/null +++ b/tools/dsview/tests/check_pwm_npc_native.py @@ -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() # Дочерний процесс обязан завершить декодирование и закрыться штатно, оставив контрольный скриншот. diff --git a/tools/dsview/tests/check_scope_native.py b/tools/dsview/tests/check_scope_native.py new file mode 100644 index 0000000..241c1db --- /dev/null +++ b/tools/dsview/tests/check_scope_native.py @@ -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-тест. diff --git a/tools/dsview/tests/pwm_envelope.cpp b/tools/dsview/tests/pwm_envelope.cpp new file mode 100644 index 0000000..2b40e2f --- /dev/null +++ b/tools/dsview/tests/pwm_envelope.cpp @@ -0,0 +1,108 @@ +#include "pwm_waveform.h" // Проверяем тот же индекс, который использует нативная строка DSView. +#include // Монотонные часы измеряют стоимость обзорной перерисовки длинной записи. +#include // Результат содержит проверяемые размеры геометрии и измеренное время. +#include // Детерминированный шум проверяет сохранение нерегулярных экстремумов. +#include // Исключения завершают тест с ненулевым кодом даже в сборке 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 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(query_start - build_start).count() << " frame_ms=" << std::chrono::duration(finish - query_start).count() / 100 << std::endl; // Отчёт позволяет сравнить задержку до и после исправления. + return 0; // Нулевой код означает прохождение функциональных и нагрузочных проверок. +} // Все ресурсы индекса освобождаются автоматически после завершения теста. diff --git a/tools/dsview/tests/scope_demo_test.cpp b/tools/dsview/tests/scope_demo_test.cpp new file mode 100644 index 0000000..68a9a2d --- /dev/null +++ b/tools/dsview/tests/scope_demo_test.cpp @@ -0,0 +1,31 @@ +#include "scope_demo.h" // Проверяем тот же переносимый источник, который подключён к настоящему DSView. +#include // Ошибка физической модели должна завершать нативную проверку ненулевым кодом. +#include // Нечисловые значения воспроизводят повреждённые входные параметры. +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::infinity()) == 0); // Бесконечное время не распространяет NaN в геометрию Qt. + assert(set_scope::extrema(0, 0, std::numeric_limits::quiet_NaN()).high == 0); // Повреждённая граница интервала возвращает определённый пустой диапазон. + return 0; // Нулевой код сообщает об успешной нативной проверке модели Zynq. +} // Исполняемый файл можно запускать на любой платформе с компилятором C++11. diff --git a/tools/dsview/tests/test_install_decoders.py b/tools/dsview/tests/test_install_decoders.py index 192eec2..4e8719d 100644 --- a/tools/dsview/tests/test_install_decoders.py +++ b/tools/dsview/tests/test_install_decoders.py @@ -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') diff --git a/tools/dsview/tests/test_pwm_envelope.py b/tools/dsview/tests/test_pwm_envelope.py new file mode 100644 index 0000000..95ced1c --- /dev/null +++ b/tools/dsview/tests/test_pwm_envelope.py @@ -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() # Стандартный запуск возвращает ненулевой код при нарушении проверяемых свойств. diff --git a/tools/dsview/tests/test_pwm_frequencies.py b/tools/dsview/tests/test_pwm_frequencies.py new file mode 100644 index 0000000..a622450 --- /dev/null +++ b/tools/dsview/tests/test_pwm_frequencies.py @@ -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() # Ненулевой код завершения сообщает о нарушении измерительного контракта. diff --git a/tools/dsview/tests/test_pwm_motor.py b/tools/dsview/tests/test_pwm_motor.py new file mode 100644 index 0000000..9d2ec34 --- /dev/null +++ b/tools/dsview/tests/test_pwm_motor.py @@ -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() # Код возврата отражает численные и интеграционные проверки. diff --git a/tools/dsview/tests/test_pwm_npc.py b/tools/dsview/tests/test_pwm_npc.py new file mode 100644 index 0000000..32f83d7 --- /dev/null +++ b/tools/dsview/tests/test_pwm_npc.py @@ -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() # Ошибка любого нового режима возвращает ненулевой код завершения. diff --git a/tools/dsview/tests/test_runtime.py b/tools/dsview/tests/test_runtime.py index f5c0221..148c75d 100644 --- a/tools/dsview/tests/test_runtime.py +++ b/tools/dsview/tests/test_runtime.py @@ -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) # Для каждого переносимого пакета создаются вложенные каталоги. diff --git a/tools/dsview/tests/test_scope_demo.py b/tools/dsview/tests/test_scope_demo.py new file mode 100644 index 0000000..0378c6d --- /dev/null +++ b/tools/dsview/tests/test_scope_demo.py @@ -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) # Ненулевой код любого числового утверждения становится провалом теста. diff --git a/tools/dsview/tests/test_sigma_delta.py b/tools/dsview/tests/test_sigma_delta.py new file mode 100644 index 0000000..56b4a7a --- /dev/null +++ b/tools/dsview/tests/test_sigma_delta.py @@ -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 выдаёт количество проверок и описание несоответствий. diff --git a/tools/dsview/zynq-ethernet/README.md b/tools/dsview/zynq-ethernet/README.md index d783528..4a20467 100644 --- a/tools/dsview/zynq-ethernet/README.md +++ b/tools/dsview/zynq-ethernet/README.md @@ -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 --runtime <проверочный-runtime> --output <артефакты> # Проверяем собранный checkout на отдельной копии runtime с сохранением результатов в каталоге артефактов. +``` + +Нужен собранный `build-win64` и отдельная полная копия runtime: проверочный +EXE создаётся рядом с её ресурсами. Loopback-эмулятор занимает UDP 37575. +Сценарий проверяет остановку повтора, отсутствие модальности и дубликатов, +запоздавшие сигналы ошибки, ручной запуск, целостность сохранённого DSL +и штатное закрытие с открытым предупреждением. Текст и кнопка проверяются +на видимость в настоящем встроенном окне; переход на соседнюю страницу +проверяет скрытие через Win32. Снимки `notice-*.png` показывают предупреждение. + При поддержке ZLR1 частота 200 Мвыб/с доступна и для 5–8 каналов. В этом случае FPGA запускает поток по аппаратному триггеру и сжимает повторы уровней без потерь. Если входы меняются слишком часто и diff --git a/tools/dsview/zynq-ethernet/ethernet.patch b/tools/dsview/zynq-ethernet/ethernet.patch index c3b3e00..d790156 100644 --- a/tools/dsview/zynq-ethernet/ethernet.patch +++ b/tools/dsview/zynq-ethernet/ethernet.patch @@ -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(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(); + +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 // Проверяем перетаскивание через обработчики Qt, а не прямой вызов изменения высоты. + + #include + #include +@@ -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(QApplication::activeModalWidget()); // Получаем именно модальное окно, открытое create_popup(). ++ if (!dialog) return; // Отсутствующее окно приведёт к тайм-ауту внешнего нативного сценария. ++ if (phase == 0) for (auto button : dialog->findChildren()) { // Кнопки строк используют штатное свойство 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()) { // Параметр раскладки является представлением готовых данных, а не вычислительной настройкой. ++ 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(); // Проверяем тот же виджет, который принимает перетаскивание пользователем. ++ bool resize_ok = header && _view.can_resize_logic_rows(); // Режим Demo должен поддерживать высоту наравне с файловым и сетевым источником. ++ std::vector traces; // Сохраняем независимые размеры всех дорожек для проверки отсутствия побочных изменений. ++ _view.get_traces(TIME_VIEW, traces); // В список входят цифровые каналы и анализаторы текущей сессии. ++ std::map 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 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 ++#include // Предупреждение о сетевом обрыве живёт внутри панели анализатора вместо отдельного диалога. ++#include // Горизонтальная компоновка сохраняет видимыми причину ошибки и кнопку закрытия. ++#include // Текст и значок предупреждения показываются обычными дочерними виджетами. ++#include // Явная кнопка позволяет убрать сообщение без закрытия приложения. ++#include // Значок предупреждения берётся из текущей темы Qt. + #include + #include + #include +@@ -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(QStringLiteral("networkCaptureError")); // Каждая сессия показывает не более одной панели с причиной обрыва. ++ if (notice != nullptr) { // Повторная ошибка обновляет существующее предупреждение без отдельного окна. ++ notice->findChild(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 _cur_row_headings; ++ std::vector _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 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 > _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 &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 &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 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 time_traces; std::vector fft_traces; std::vector 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 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 _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);