Расширить DSView осциллографом и декодерами двигателя и сигма-дельта
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
"""Расчёт токов асинхронного двигателя по состояниям ключей инвертора.""" # Модель не зависит от GUI, sigrok и сторонних пакетов Python.
|
||||
import cmath # Комплексные числа представляют неподвижные электрические оси α и β.
|
||||
import math # Проверки параметров и преобразования СИ используют стандартную библиотеку.
|
||||
|
||||
|
||||
def finite(value, name, minimum=None, strict=False): # Единая проверка исключает NaN, бесконечности и нефизические параметры.
|
||||
number = float(value) # Параметры sigrok и непосредственных вызовов приводятся к одному числовому типу.
|
||||
if not math.isfinite(number) or minimum is not None and (number <= minimum if strict else number < minimum): # Строгая граница применяется к сопротивлениям и индуктивностям.
|
||||
raise ValueError('{} has an invalid value: {}'.format(name, value)) # Сообщение указывает конкретную настройку пользователя.
|
||||
return number # Возвращается только проверенное значение в единицах вызывающей стороны.
|
||||
|
||||
|
||||
def clarke(values): # Амплитудно-инвариантное преобразование сохраняет амплитуду симметричных фаз.
|
||||
return complex((2 * values[0] - values[1] - values[2]) / 3, (values[1] - values[2]) / math.sqrt(3)) # Оси αβ соответствуют порядку фаз A, B, C.
|
||||
|
||||
|
||||
def phases(vector): # Обратное преобразование восстанавливает три фазы трёхпроводной нагрузки.
|
||||
return (vector.real, -vector.real / 2 + math.sqrt(3) * vector.imag / 2, -vector.real / 2 - math.sqrt(3) * vector.imag / 2) # Сумма фаз равна нулю по первому закону Кирхгофа.
|
||||
|
||||
|
||||
class InductionMotor: # Линейная модель короткозамкнутого двигателя использует приведённые к статору параметры ротора.
|
||||
def __init__(self, rs=1.0, rr=0.8, lls_mh=5.0, llr_mh=5.0, lm_mh=150.0, pole_pairs=2, speed_rpm=1440.0, initial_flux=0.0, flux_angle=0.0, ia=0.0, ib=0.0): # Значения по умолчанию являются примером, а не паспортом конкретного двигателя.
|
||||
self.rs = finite(rs, 'Stator R (ohm)', 0, True) # Положительное сопротивление одной обмотки статора задаётся в омах.
|
||||
self.rr = finite(rr, 'Rotor R referred to stator (ohm)', 0, True) # Сопротивление ротора должно быть приведено к той же обмотке статора.
|
||||
lls = finite(lls_mh, 'Stator leakage L (mH)', 0, True) / 1000 # Миллигенри переводятся в генри перед вычислением уравнений.
|
||||
llr = finite(llr_mh, 'Rotor leakage L (mH)', 0, True) / 1000 # Параметр ротора также приводится к статору и переводится в генри.
|
||||
lm = finite(lm_mh, 'Magnetizing L (mH)', 0, True) / 1000 # Взаимная индуктивность задаёт магнитную связь без насыщения.
|
||||
pairs = finite(pole_pairs, 'Pole pairs', 1) # Число пар полюсов переводит механическую скорость в электрическую.
|
||||
if pairs != int(pairs): # Дробное число пар полюсов не описывает физический двигатель.
|
||||
raise ValueError('Pole pairs must be an integer.') # Ошибка выдаётся до расчёта токов.
|
||||
omega = finite(speed_rpm, 'Mechanical speed (rpm)') * pairs * math.tau / 60 # Один оборот равен 2π радиан, одна минута содержит 60 секунд.
|
||||
lr = llr + lm # Полная индуктивность ротора включает рассеяние и намагничивание.
|
||||
self.lsigma = lls + lm * llr / lr # Устойчивая форма Ls−Lm²/Lr не теряет точность при малом рассеянии.
|
||||
self.k = lm / lr # Коэффициент связи переводит потокосцепление ротора в уравнение статора.
|
||||
self.a = -(self.rs + self.rr * self.k * self.k) / self.lsigma # Первый диагональный коэффициент учитывает потери статора и приведённого ротора.
|
||||
self.b = self.k * complex(self.rr / lr, -omega) / self.lsigma # Влияние потока ротора включает его вращение относительно неподвижных осей.
|
||||
self.c = self.rr * self.k # Ток статора возбуждает поток ротора через взаимную индуктивность.
|
||||
self.d = complex(-self.rr / lr, omega) # Поток ротора затухает с постоянной Lr/Rr и вращается с электрической скоростью ротора.
|
||||
self.det = self.a * self.d - self.b * self.c # Определитель нужен для точного вынужденного решения при постоянном напряжении.
|
||||
self.mid = (self.a + self.d) / 2 # Половина следа матрицы задаёт среднее собственных значений.
|
||||
self.root = cmath.sqrt(((self.a - self.d) / 2) ** 2 + self.b * self.c) # Разность собственных значений позволяет вычислить матричную экспоненту без numpy.
|
||||
initial = (finite(ia, 'Initial Ia (A)'), finite(ib, 'Initial Ib (A)')) # Начальные линейные токи задаются в амперах.
|
||||
self.current = clarke((initial[0], initial[1], -sum(initial))) # Третья фаза определяется условием трёхпроводного подключения.
|
||||
self.flux = finite(initial_flux, 'Initial rotor flux linkage (Wb)', 0) * cmath.exp(1j * math.radians(finite(flux_angle, 'Initial rotor flux angle (deg)'))) # Модуль и угол задают неизвестное при начале записи начальное потокосцепление.
|
||||
self.cache = {} # Повторяющиеся интервалы переключений используют готовую матрицу перехода.
|
||||
|
||||
def advance(self, seconds, voltage): # Между фронтами ключей напряжение неизменно, поэтому используется точное линейное решение.
|
||||
dt = finite(seconds, 'Time interval (s)', 0) # Отрицательное время и неконечные интервалы запрещены.
|
||||
if not dt: # Повторные события одного отсчёта меняют ключи без изменения физического состояния.
|
||||
return phases(self.current) # Начальный ток сохраняется без искусственного шага интегрирования.
|
||||
matrix = self.cache.get(dt) # На равномерной сетке большинство длительностей повторяется.
|
||||
if matrix is None: # Новая длительность требует вычисления exp(A·dt) ровно один раз.
|
||||
if abs(self.root * dt) < 1e-8: # При близких собственных значениях деление на их разность теряет точность.
|
||||
factor = cmath.exp(self.mid * dt) # Общая экспонента остаётся ограниченной для пассивного двигателя.
|
||||
ratio = factor * dt * (1 + (self.root * dt) ** 2 / 6) # Разложение sinh(z)/z сохраняет точность около нуля.
|
||||
center = factor * (1 + (self.root * dt) ** 2 / 2) # Разложение cosh(z) согласовано с тем же пределом.
|
||||
else: # Раздельные экспоненты избегают переполнения cosh при сильно различающихся постоянных времени.
|
||||
first, second = cmath.exp((self.mid + self.root) * dt), cmath.exp((self.mid - self.root) * dt) # Обе моды учитывают затухание и вращение потока ротора.
|
||||
ratio, center = (first - second) / (2 * self.root), (first + second) / 2 # Эти коэффициенты задают экспоненту матрицы размерности два.
|
||||
matrix = (center + ratio * (self.a - self.mid), ratio * self.b, ratio * self.c, center + ratio * (self.d - self.mid)) # Порядок элементов соответствует строкам тока статора и потока ротора.
|
||||
if len(self.cache) >= 1024: # Ограничение в 1024 различных длительности исключает рост памяти на нерегулярном ШИМ.
|
||||
self.cache.clear() # Сброс кэша влияет только на производительность, а не на состояние двигателя.
|
||||
self.cache[dt] = matrix # Сохраняется только матрица перехода, независимая от текущего напряжения.
|
||||
drive = clarke(voltage) / self.lsigma # Фазные напряжения переводятся в неподвижный вектор скорости изменения тока.
|
||||
equilibrium_i, equilibrium_flux = -self.d * drive / self.det, self.c * drive / self.det # Частное решение −A⁻¹B·u учитывает постоянное напряжение на всём интервале.
|
||||
delta_i, delta_flux = self.current - equilibrium_i, self.flux - equilibrium_flux # Начальное состояние переносится относительно частного решения.
|
||||
self.current = equilibrium_i + matrix[0] * delta_i + matrix[1] * delta_flux # Ток обновляется без явного Эйлера и зависимости устойчивости от частоты отображения.
|
||||
self.flux = equilibrium_flux + matrix[2] * delta_i + matrix[3] * delta_flux # Поток использует прежний ток, сохранённый до обновления состояния.
|
||||
if not all(math.isfinite(value) for value in (self.current.real, self.current.imag, self.flux.real, self.flux.imag)): # Чрезмерные параметры не должны порождать молчаливые NaN в графиках.
|
||||
raise ValueError('Motor state overflow: check motor parameters and capture timing.') # Сообщение связывает ошибку с физическими параметрами и временем записи.
|
||||
return phases(self.current) # Возвращаются мгновенные токи трёх эквивалентных обмоток в амперах.
|
||||
|
||||
|
||||
class PwmMotor: # Связь логических входов, силовой схемы и общего двигателя не содержит зависимости от DSView.
|
||||
def __init__(self, samplerate, topology='2-level', dc_voltage=560.0, midpoint_percent=50.0, connection='star', active_high=True, **motor): # Полное напряжение звена задаётся между положительной и отрицательной шинами.
|
||||
self.rate = finite(samplerate, 'Samplerate (Hz)', 0, True) # Частота захвата переводит номера отсчётов в секунды.
|
||||
if topology not in ('2-level', '3-level P/N', '3-level NPC') or connection not in ('star', 'delta'): # Поддерживаются явные схемы без автоматического угадывания кодировки.
|
||||
raise ValueError('Unsupported inverter topology or winding connection.') # Неизвестная схема не подменяется другой электрической моделью.
|
||||
dc = finite(dc_voltage, 'Total DC link (V)', 0, True) # Значение в вольтах относится ко всему звену, а не к его половине.
|
||||
split = finite(midpoint_percent, 'Upper DC link (%)', 0, True) / 100 # Доля верхней половины позволяет моделировать дисбаланс конденсаторов.
|
||||
if split >= 1: # Обе половины звена должны иметь строго положительное напряжение.
|
||||
raise ValueError('Upper DC link fraction must be between 0 and 100 percent.') # Граничные значения не описывают трёхуровневое звено.
|
||||
self.upper, self.lower = dc * split, -dc * (1 - split) # Потенциалы полюсов отсчитываются от средней точки конденсаторов.
|
||||
self.topology, self.connection, self.active_high = topology, connection, bool(active_high) # Параметры сохраняются для интерпретации каждой новой комбинации ключей.
|
||||
ia, ib = motor.get('ia', 0.0), motor.get('ib', 0.0) # Начальные токи пользователь задаёт в линейных проводах независимо от схемы обмоток.
|
||||
if connection == 'delta': # В симметричном треугольнике линейный ток равен разности токов соседних обмоток.
|
||||
motor.update(ia=(2 * ia + ib) / 3, ib=(ib - ia) / 3) # Обратное преобразование предполагает отсутствие циркулирующего тока нулевой последовательности.
|
||||
self.motor = InductionMotor(**motor) # Параметры Rr и Lr всегда приведены к одной фактической обмотке статора.
|
||||
self.sample, self.pins, self.poles = None, None, (0.0, 0.0, 0.0) # До первого отсчёта не создаётся вымышленный участок записи.
|
||||
self.warnings = () # Диагностика сообщает о комбинациях, не задающих однозначное выходное напряжение.
|
||||
self.interval_warnings = () # Завершённый интервал сохраняет собственную диагностику до приёма следующего состояния затворов.
|
||||
|
||||
def currents(self): # Публичные результаты относятся к проводам A, B, C, подключённым к инвертору.
|
||||
a, b, c = phases(self.motor.current) # Эквивалентные токи обмоток сохраняют условие отсутствия нулевой последовательности.
|
||||
return (a - c, b - a, c - b) if self.connection == 'delta' else (a, b, c) # Для треугольника выводятся линейные токи, а не токи внутри обмоток.
|
||||
|
||||
def set_gates(self, pins): # Нумерация: Q1/P фаз A/B/C, Q4/N фаз A/B/C, затем Q2/Q3 каждой фазы.
|
||||
if len(pins) != 12 or any(value not in (0, 1, None) for value in pins): # Отсутствующий логический канал обозначается None до нормализации полярности.
|
||||
raise ValueError('Expected twelve gate inputs containing 0, 1 or None.') # Служебные значения sigrok не принимаются за реальный логический уровень.
|
||||
bits = tuple(None if value is None else bool(value) == self.active_high for value in pins) # Инверсия применяется только к реально назначенным входам.
|
||||
required = range(3) if self.topology == '2-level' else range(6) if self.topology == '3-level P/N' else range(12) # Три, шесть или двенадцать каналов нужны выбранной схеме.
|
||||
if any(bits[index] is None for index in required): # Неполная трёхфазная схема не позволяет определить линейные напряжения двигателя.
|
||||
raise ValueError('Assign all gate channels required by the selected topology.') # Ошибка возникает до продолжения расчёта по отсутствующим сигналам.
|
||||
if self.topology == '2-level' and any(value is not None for value in bits[3:6]) and any(value is None for value in bits[3:6]): # Нижние ключи двухуровневой схемы назначаются одновременно для всех фаз.
|
||||
raise ValueError('Assign either all three lower gates or none.') # Частично измеренные нижние ключи не смешиваются с идеальными дополнениями.
|
||||
poles, warnings = [], [] # Каждая фаза сначала получает собственный потенциал и список неоднозначностей.
|
||||
currents = self.currents() # Направление линейного тока задаёт приближённую проводимость обратных диодов во время dead time.
|
||||
for phase in range(3): # Все входы одного отсчёта обрабатываются одновременно, без ложных промежуточных состояний.
|
||||
high, low = bits[phase], bits[phase + 3] # В P/N это команды положительного и отрицательного уровней; в NPC это внешние Q1 и Q4.
|
||||
state = (high, low) # Стандартная таблица P/N использует только два внешних логических входа.
|
||||
table = {(True, False): self.upper, (False, True): self.lower, (False, False): 0.0} # Состояние 00 задаёт среднюю точку только в явной кодировке P/N.
|
||||
if self.topology == '2-level': # Один вход на фазу означает идеальную комплементарную пару без измеренного dead time.
|
||||
if low is None: # Нижняя команда восстанавливается только когда пользователь не назначил соответствующие входы.
|
||||
low = not high # Низкий уровень верхней команды соответствует отрицательной шине.
|
||||
state = (high, low) # Реальные нижние команды сохраняют интервалы одновременного закрытия ключей.
|
||||
table = {(True, False): self.upper, (False, True): self.lower} # Одновременное включение двух ключей не имеет допустимого фазного напряжения.
|
||||
if self.topology == '3-level NPC': # NPC обрабатывает четыре отдельных затворных сигнала каждого плеча.
|
||||
state = (high, bits[6 + 2 * phase], bits[7 + 2 * phase], low) # Физический порядок ключей идёт сверху вниз от положительной шины к отрицательной.
|
||||
table = {(True, True, False, False): self.upper, (False, True, True, False): 0.0, (False, False, True, True): self.lower} # Допустимые состояния Q1…Q4 равны 1100, 0110 и 0011.
|
||||
if state in table: # Однозначная команда непосредственно определяет полюсное напряжение.
|
||||
pole = table[state] # Токи интегрируются по реальному напряжению, а не по сглаженному заполнению ШИМ.
|
||||
elif not any(state): # Полностью закрытое плечо проводит через обратные диоды при ненулевом токе.
|
||||
pole = self.lower if currents[phase] > 1e-9 else self.upper if currents[phase] < -1e-9 else 0.0 # Порог 1 нА подавляет выбор шины по ошибке округления около нулевого тока.
|
||||
warnings.append('Phase {}: diode/dead-time approximation'.format('ABC'[phase])) # Неизвестные падения на диодах и смена проводимости явно отмечаются как приближение.
|
||||
else: # Недопустимые перекрытия и неполные состояния NPC не интерпретируются как достоверное напряжение.
|
||||
pole = self.poles[phase] # Последний определённый потенциал используется только как явно помеченное продолжение оценки.
|
||||
warnings.append('Phase {}: invalid gates {}; current estimate unreliable'.format('ABC'[phase], ''.join(str(int(value)) for value in state))) # Диагностика содержит фазу и фактическую комбинацию ключей.
|
||||
poles.append(pole) # Все три полюса сохраняются до вычисления напряжений обмоток.
|
||||
self.pins, self.poles, self.warnings = bits, tuple(poles), tuple(warnings) # Состояние относится к интервалу после текущего одновременного переключения.
|
||||
|
||||
def update(self, sample, pins=None): # Сначала завершается предыдущий электрический интервал и только затем меняются ключи.
|
||||
stamp = int(sample) # Номера отсчётов сохраняются целыми, чтобы исключить накопление ошибки временной сетки.
|
||||
if stamp != sample or stamp < 0 or self.sample is not None and stamp < self.sample: # Отсчёты должны поступать в хронологическом порядке.
|
||||
raise ValueError('Sample numbers must be nonnegative, integral and monotonic.') # Ошибка входной последовательности не маскируется нулевой длительностью.
|
||||
previous, start = self.currents(), self.sample # Левый конец сегмента берётся до интегрирования предыдущего напряжения.
|
||||
self.interval_warnings = self.warnings # Выходной пакет должен описывать ключи рассчитанного интервала, а не уже принятую новую команду.
|
||||
if start is not None and stamp > start: # Первый отсчёт определяет только начальное состояние записи.
|
||||
a, b, c = self.poles # Полюсные напряжения неизменны с прошлого обработанного фронта.
|
||||
voltage = (a - b, b - c, c - a) if self.connection == 'delta' else (a, b, c) # Преобразование Кларка само вычитает плавающий потенциал нейтрали звезды.
|
||||
self.motor.advance((stamp - start) / self.rate, voltage) # Точное решение учитывает длительность каждого фактического состояния ключей.
|
||||
self.sample = stamp # Обновление временной отметки предшествует приёму новых команд того же отсчёта.
|
||||
if pins is not None: # Периодические точки графика могут приходить без нового фронта.
|
||||
self.set_gates(pins) # Новые уровни действуют только справа от своей временной отметки.
|
||||
elif self.pins is not None: # Переоценка диодов на сетке ограничивает ошибку около смены знака тока.
|
||||
self.set_gates(tuple(None if value is None else int(value == self.active_high) for value in self.pins)) # Логические уровни переводятся обратно во входную полярность без двойной инверсии.
|
||||
return None if start is None or stamp == start else (start, stamp, previous, self.currents()) # Сегмент содержит физические амперы на обоих концах интервала.
|
||||
@@ -19,6 +19,11 @@ class PwmChannel:
|
||||
self.window_index = 1
|
||||
self.high = 0
|
||||
self.previous = None
|
||||
self.carrier_start = self.carrier_period = None # Два действительных активных фронта нужны для измерения несущей; начальный уровень не считается фронтом.
|
||||
self.carrier_count = self.carrier_samples = 0 # Счётчик полных периодов и их суммарная длина в отсчётах позволяют усреднять несущую без хранения записи.
|
||||
self.field_previous = self.field_crossing = self.field_pending = None # Интерполированное пересечение уровня 50% отделено от подтверждённого полного периода поля.
|
||||
self.field_armed = False # До отрицательной полуволны начальный фрагмент огибающей не может открыть измерение поля.
|
||||
self.field_carrier = None # Снимок счётчиков несущей привязывает среднюю частоту к последнему завершённому периоду поля.
|
||||
|
||||
def next_boundary(self):
|
||||
if self.window is None or self.origin is None:
|
||||
@@ -34,9 +39,33 @@ class PwmChannel:
|
||||
alpha = -math.expm1(-(midpoint - previous[0]) / self.tau)
|
||||
value = previous[1] + alpha * (value - previous[1])
|
||||
self.previous = (midpoint, value)
|
||||
carrier = self.rate / self.carrier_period if self.carrier_period and end - self.carrier_start <= 2 * self.carrier_period else None # После двух периодов без фронтов прежняя несущая больше не считается текущим измерением.
|
||||
field = self._field(midpoint, value) # Частота поля определяется по той же нормированной огибающей, которая выводится на график.
|
||||
return dict(start=start, end=end, duty=duty, value=value,
|
||||
frequency=self.rate / (end - start), midpoint=midpoint,
|
||||
previous=previous)
|
||||
previous=previous, carrier_frequency=carrier, field=field) # Частота окна остаётся совместимой со старым контрактом, измеренная несущая и полный период поля передаются отдельно.
|
||||
|
||||
def _field(self, sample, value): # Потоковый детектор измеряет электрическую частоту между одноимёнными пересечениями уровня заполнения 50%.
|
||||
previous = self.field_previous # Предыдущая точка нужна для линейной интерполяции времени пересечения между серединами периодов ШИМ.
|
||||
self.field_previous = (sample, value) # Храним только последнюю точку, поэтому память не зависит от длительности захвата.
|
||||
if value <= -0.02: # Гистерезис 0,02 нормированной шкалы соответствует одному процентному пункту заполнения ниже 50%.
|
||||
self.field_armed = True # Отрицательная полуволна разрешает одно следующее положительное пересечение.
|
||||
if self.field_armed and previous is not None and previous[1] <= 0 < value: # Учитываем только восходящий переход, чтобы не удвоить электрическую частоту.
|
||||
self.field_pending = previous[0] - previous[1] * (sample - previous[0]) / (value - previous[1]) # Интерполяция устраняет квантование периода поля шагом несущей.
|
||||
if value <= 0: # Возврат через нуль до положительного порога означает неподтверждённое пересечение.
|
||||
self.field_pending = None # Колебания внутри полосы гистерезиса не создают отдельные периоды.
|
||||
if not self.field_armed or self.field_pending is None or value < 0.02: # Положительный порог также отстоит от 50% на один процентный пункт заполнения.
|
||||
return None # Постоянное заполнение и неполные полуволны не получают выдуманную частоту поля.
|
||||
crossing = self.field_pending # Подтверждение использует время нулевого пересечения, а не более позднее достижение порога.
|
||||
last = self.field_crossing # Два подтверждённых восходящих пересечения ограничивают один полный электрический период.
|
||||
counters = (self.carrier_count, self.carrier_samples) # Снимок относится только к действительно наблюдавшимся активным фронтам.
|
||||
old_counters = self.field_carrier # Разность накопленных счётчиков даёт среднюю несущую за текущий период поля.
|
||||
self.field_crossing, self.field_carrier = crossing, counters # Следующий период начинается с текущего подтверждённого пересечения.
|
||||
self.field_armed, self.field_pending = False, None # Новое измерение разрешается только после следующей отрицательной полуволны.
|
||||
if last is None: # Первое пересечение задаёт начало, но ещё не содержит полного периода.
|
||||
return None # Начальный неполный фрагмент записи исключён из результата.
|
||||
count, elapsed = counters[0] - old_counters[0], counters[1] - old_counters[1] # Длительность измеренных периодов несущей выражена в отсчётах, а не миллисекундах.
|
||||
return dict(start=round(last), end=round(crossing), frequency=self.rate / (crossing - last), carrier_frequency=self.rate * count / elapsed if elapsed else None) # Полный период поля получает частоту в Гц и среднюю измеренную несущую, если за интервал были фронты.
|
||||
|
||||
def update(self, sample, pin):
|
||||
level = bool(pin) == self.active_high
|
||||
@@ -48,6 +77,8 @@ class PwmChannel:
|
||||
if sample < self.last:
|
||||
raise ValueError('Samples must be monotonic.')
|
||||
events = []
|
||||
if level != self.level and level and self.window is None: # В автоматическом режиме новый активный фронт одновременно завершает текущий период ШИМ.
|
||||
self._carrier_edge(sample) # Обновляем измерение до выдачи завершённого периода, чтобы частота относилась именно к нему.
|
||||
if self.window is not None:
|
||||
boundary = self.next_boundary()
|
||||
while boundary <= sample:
|
||||
@@ -58,6 +89,8 @@ class PwmChannel:
|
||||
self.window_index += 1
|
||||
boundary = self.next_boundary()
|
||||
self.high += (sample - self.last) * self.level
|
||||
if level != self.level and level: # В режиме окон частота несущей всё равно измеряется по фронтам, независимо от заданного размера окна.
|
||||
self._carrier_edge(sample) # Фронт на границе окна относится к следующему окну и не меняет уже выданное измерение.
|
||||
elif level != self.level:
|
||||
if level:
|
||||
if self.start is not None and self.fall is not None:
|
||||
@@ -67,3 +100,121 @@ class PwmChannel:
|
||||
self.fall = sample
|
||||
self.last, self.level = sample, level
|
||||
return events
|
||||
|
||||
def _carrier_edge(self, sample): # Оба режима восстановления используют единый счётчик фактических периодов несущей.
|
||||
if self.carrier_start is not None and sample > self.carrier_start: # Первый фронт только задаёт начало; нулевой период не допускается.
|
||||
self.carrier_period = sample - self.carrier_start # Интервал активный фронт — активный фронт задаёт полный период ШИМ в отсчётах.
|
||||
self.carrier_count += 1 # Счётчик нужен для среднего числа полных периодов за электрический цикл.
|
||||
self.carrier_samples += self.carrier_period # Суммируем длительности, чтобы переменная несущая усреднялась как число периодов за время.
|
||||
self.carrier_start = sample # Следующее измерение начинается с текущего реального фронта.
|
||||
|
||||
|
||||
class NpcChannel(PwmChannel): # Один вход NPC объединяет четыре затвора S1…S4, расположенные сверху вниз в фазной стойке.
|
||||
STATES = {(1, 1, 0, 0): 1, (0, 1, 1, 0): 0, (0, 0, 1, 1): -1} # Допустимые состояния классического NPC дают +Udc/2, нейтраль и −Udc/2 соответственно.
|
||||
|
||||
def __init__(self, samplerate, active_high=True, carrier_hz=0, smoothing_ms=0): # Настройки времени и полярности совпадают с двухуровневым режимом.
|
||||
super().__init__(samplerate, active_high, carrier_hz, smoothing_ms) # Общий расчёт частоты поля и фильтра применяется к среднему знаковому уровню NPC.
|
||||
self.integral = self.known = 0 # Интеграл уровня выражен в отсчётах; known хранит длительность достоверных состояний.
|
||||
self.last_valid = self.pulse_sign = None # Пропуск dead-time не должен ошибочно создавать новый импульс той же полярности.
|
||||
|
||||
def state(self, gates): # Преобразование использует все четыре затвора, а не только два внешних ключа.
|
||||
return self.STATES.get(tuple(int(bool(pin) == self.active_high) for pin in gates)) # Неизвестное состояние, включая все выключенные ключи, возвращает None вместо выдуманного нуля напряжения.
|
||||
|
||||
def _accumulate(self, sample): # Между цифровыми событиями уровень стойки считается постоянным только при допустимой комбинации затворов.
|
||||
if self.level is not None: # Во время dead-time напряжение зависит от тока и диодов и не определяется одними управляющими сигналами.
|
||||
self.integral += (sample - self.last) * self.level # Знаковая площадь сохраняет отличие положительных, нулевых и отрицательных участков.
|
||||
self.known += sample - self.last # В среднем используются лишь участки, однозначно восстановленные по затворам.
|
||||
self.last = sample # Новый интервал начинается после полностью учтённой части времени.
|
||||
|
||||
def _npc_emit(self, start, end): # Среднее по допустимым состояниям является оценкой огибающей, а не измерением напряжения в dead-time.
|
||||
coverage = self.known / (end - start) # Доля известного времени позволяет отделить короткие коммутационные паузы от повреждённой записи.
|
||||
if coverage < 0.95: # При более чем 5% неизвестного времени в периоде оценка считается недостаточно определённой и пропускается.
|
||||
self.previous = self.field_previous = self.field_crossing = self.field_pending = self.field_carrier = None # Разрыв не должен соединяться графиком или измерением поля с предыдущим достоверным участком.
|
||||
self.field_armed = False # Новое поле должно заново подтвердить отрицательную полуволну.
|
||||
return [] # Неизвестный интервал не превращается в фиктивный уровень нуля.
|
||||
high = (self.integral / self.known + 1) * (end - start) / 2 # Общий _emit ожидает эквивалентную активную длительность; преобразование сохраняет средний уровень −1…+1.
|
||||
event = self._emit(start, end, high) # Общая обработка формирует середину периода, частоты и фильтрованное значение.
|
||||
event['valid_fraction'] = coverage # Точность определения уровня видна в числовой аннотации и экспорте.
|
||||
return [event] # Формат списка совместим с обновлением двухуровневого канала.
|
||||
|
||||
def update(self, sample, gates): # Одно событие получает атомарный снимок четырёх цифровых входов.
|
||||
level = self.state(gates) # Только три штатные комбинации имеют определённый нормированный уровень стойки.
|
||||
if self.last is None: # Начальный уровень не является обнаруженным фронтом несущей.
|
||||
self.last, self.level, self.last_valid = sample, level, level # Состояние начинается с текущего снимка без выдуманного предыдущего импульса.
|
||||
if self.window is not None: # В режиме заданной несущей интегрирование начинается с области декодирования.
|
||||
self.origin = self.start = sample # Границы окон совпадают с прежним контрактом PwmChannel.
|
||||
return [] # Начальная точка ещё не содержит полного периода.
|
||||
if sample < self.last: # Равные номера допустимы для одновременных фронтов разных каналов.
|
||||
raise ValueError('Samples must be monotonic.') # Отрицательные интервалы нельзя интегрировать или выдавать в DSView.
|
||||
entry = level in (-1, 1) and level != self.last_valid # Начало ненулевого импульса распознаётся с учётом последнего достоверного состояния до dead-time.
|
||||
events = [] # Результаты содержат лишь полные периоды или окна.
|
||||
if self.window is not None: # Фиксированные окна сохраняют 0/100% и работают при отсутствии переключений.
|
||||
boundary = self.next_boundary() # Граница рассчитывается от начала, поэтому дробный период не накапливает ошибку округления.
|
||||
while boundary <= sample: # Одно цифровое событие может завершить несколько окон постоянного уровня.
|
||||
self._accumulate(boundary) # Учёт предыдущего состояния заканчивается точно на границе окна.
|
||||
events.extend(self._npc_emit(self.start, boundary)) # Неизвестные участки проверяются до формирования результата.
|
||||
self.start = boundary # Следующее окно начинается без перекрытия с предыдущим.
|
||||
self.integral = self.known = 0 # Площадь и известное время относятся только к новому окну.
|
||||
self.window_index += 1 # Номер следующей границы задаёт её положение в общей шкале отсчётов.
|
||||
boundary = self.next_boundary() # Повторное вычисление сохраняет точность дробной частоты.
|
||||
self._accumulate(sample) # Оставшаяся часть предыдущего уровня учитывается до изменения затворов.
|
||||
if entry: # Частота несущей измеряется по началам последовательных ненулевых импульсов одинакового знака.
|
||||
if self.pulse_sign == level: # Переход положительной полуволны в отрицательную не является коротким периодом несущей.
|
||||
self._carrier_edge(sample) # Измеренный период не подменяется частотой заданного окна.
|
||||
if self.window is None and self.start is not None: # Автоматический режим завершает период на одноимённом фронте следующего импульса.
|
||||
events.extend(self._npc_emit(self.start, sample)) # Огибающая получается из интеграла всех трёх уровней за полный период.
|
||||
else: # Первое появление другой полярности заново синхронизирует несущую этой полуволны.
|
||||
self.carrier_start, self.carrier_period = sample, None # Интервал через смену знака не принимается за измеренную частоту.
|
||||
self.pulse_sign = level # Следующий ненулевой импульс сравнивается с текущей полярностью.
|
||||
if self.window is None: # В автоматическом режиме начало и интеграл обновляются только на опорных фронтах.
|
||||
self.start = sample # Начальный неполный фрагмент полуволны исключается.
|
||||
self.integral = self.known = 0 # Новый период не наследует площадь предыдущего.
|
||||
self.level = level # Новое состояние действует только после текущего цифрового события.
|
||||
if level is not None: # Неизвестная коммутационная пауза не стирает информацию о последнем допустимом уровне.
|
||||
self.last_valid = level # Это исключает ложные импульсы при кратком выключении и возврате к прежней комбинации.
|
||||
return events # Адаптер получает тот же поток результатов, что для обычного ШИМ.
|
||||
|
||||
|
||||
class PhaseAnalysis: # Согласование времён A/B/C отделено от конкретного числа затворов в фазе.
|
||||
def __init__(self, samplerate): # Частота нужна для контроля устаревших оценок фазового периода.
|
||||
self.rate = samplerate # Все временные координаты остаются в отсчётах захвата.
|
||||
self.points, self.fields = {}, {} # Для интерполяции хранятся две последние точки каждой огибающей и последний полный период поля.
|
||||
self.last_vector = None # Одинаковый общий момент времени не должен выдавать повторный угол.
|
||||
|
||||
def add(self, channel, event): # Каждый восстановленный период обновляет только свою фазу.
|
||||
points = self.points.setdefault(channel, []) # Отсутствующая фаза не получает нулевой или выдуманный сигнал.
|
||||
if event.get('previous', True) is None: # Ядро явно разрывает огибающую после недостаточно определённого окна NPC.
|
||||
points.clear() # Интерполяция не должна соединять новую точку с данными до неизвестного участка.
|
||||
self.fields.pop(channel, None) # Межфазные углы также должны заново дождаться полного достоверного периода.
|
||||
points.append((event['midpoint'], event['value'])) # Угол вычисляется по восстановленным средним уровням, а не по мгновенным коммутациям.
|
||||
del points[:-2] # Ограниченная история сохраняет постоянный расход памяти на длинных записях.
|
||||
if event['field'] is not None: # Межфазные углы требуют наблюдавшихся полных электрических периодов.
|
||||
field = event['field'] # Результат содержит последнее восходящее пересечение и частоту данной фазы.
|
||||
self.fields[channel] = (field['end'], self.rate / field['frequency']) # Период хранится в отсчётах для вычисления сдвига в градусах.
|
||||
if any(len(self.points.get(ch, [])) < 2 for ch in range(3)): # Пространственный вектор нельзя восстановить из неподключённых или ещё не заполненных A/B/C.
|
||||
return None # Начальный участок записи не получает ложного θ.
|
||||
sample = min(self.points[ch][-1][0] for ch in range(3)) # Общая точка не выходит за наблюдавшееся время ни одной фазы.
|
||||
if sample <= max(self.points[ch][0][0] for ch in range(3)) or self.last_vector is not None and sample <= self.last_vector: # Требуется общий интервал и новое время, без экстраполяции остановившейся фазы.
|
||||
return None # Несогласованные точки не смешиваются в один вектор.
|
||||
values = [] # Три значения будут относиться к одному и тому же моменту времени.
|
||||
for ch in range(3): # Порядок A/B/C определяет положительное направление вращения.
|
||||
left, right = self.points[ch] # Две соседние точки ограничивают достоверный интервал данной фазы.
|
||||
values.append(left[1] + (right[1] - left[1]) * (sample - left[0]) / (right[0] - left[0])) # Линейная интерполяция устраняет сдвиг, вызванный разными моментами фронтов PWM A/B/C.
|
||||
alpha = (2 * values[0] - values[1] - values[2]) / 3 # Полное преобразование Кларка удаляет синфазную составляющую, включая добавку SVPWM.
|
||||
beta = (values[1] - values[2]) / math.sqrt(3) # Ортогональная ось соответствует последовательности A, B, C.
|
||||
self.last_vector = sample # Данный момент времени считается обработанным даже при почти нулевом векторе.
|
||||
if math.hypot(alpha, beta) < 0.02: # Ниже 2% нормированного уровня направление вектора не считается определённым.
|
||||
return dict(sample=sample, theta=None) # Разрыв явно запрещает соединять графиком углы через нулевую амплитуду.
|
||||
return dict(sample=sample, theta=math.degrees(math.atan2(beta, alpha)) % 360) # θ выражен в электрических градусах 0…360 относительно положительной оси A.
|
||||
|
||||
def differences(self, sample): # Положительный результат A→B означает запаздывание фазы B относительно A.
|
||||
angles = {} # Отсутствующая пара не получает выдуманный сдвиг 120 градусов.
|
||||
for first, second in ((0, 1), (1, 2), (2, 0)): # Измеряются все три запрошенные пары независимо.
|
||||
if first not in self.fields or second not in self.fields: # Каждой фазе пары нужен хотя бы один полный измеренный период.
|
||||
continue # Две подключённые фазы всё ещё могут дать одну доступную пару.
|
||||
a, b = self.fields[first], self.fields[second] # Последние одноимённые пересечения задают фазовые часы двух огибающих.
|
||||
if any(sample < item[0] or sample - item[0] > 2 * item[1] for item in (a, b)): # После двух периодов без нового измерения прежний сдвиг становится устаревшим.
|
||||
continue # Остановившийся или пропавший вход не сохраняет старое числовое значение.
|
||||
angle = 360 * ((sample - a[0]) / a[1] - (sample - b[0]) / b[1]) # Разность фазовых часов выражает запаздывание второй огибающей в градусах.
|
||||
angles['ABC'[first] + '→' + 'ABC'[second]] = (angle + 180) % 360 - 180 # Знаковый диапазон −180…180 различает запаздывание и опережение, включая обратную последовательность фаз.
|
||||
return angles # Результаты рассчитываются из записи и не предполагают заранее правильную трёхфазную систему.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Потоковый CIC-декодер однобитного сигма-дельта модулятора без зависимостей от GUI.""" # Ядро одинаково работает в DSView и автономных анализаторах.
|
||||
|
||||
|
||||
class SigmaDeltaFilter: # Каскад интеграторов и гребенчатых звеньев реализует фильтры sinc первого, второго и третьего порядка.
|
||||
def __init__(self, decimation=256, order=3, invert=False): # Децимация задаётся числом тактов на результат; 256 и sinc3 являются начальными настройками анализатора.
|
||||
if isinstance(decimation, bool) or not isinstance(decimation, int) or not 1 <= decimation <= 65536: # Ограничение 65536 тактов удерживает максимальное усиление sinc3 в пределах 49 бит.
|
||||
raise ValueError('Decimation must be an integer from 1 to 65536.') # Дробные и неположительные окна не определяют допустимую децимацию.
|
||||
if isinstance(order, bool) or not isinstance(order, int) or order not in (1, 2, 3): # Число интеграторов должно совпадать с числом гребенчатых звеньев.
|
||||
raise ValueError('Filter order must be 1, 2 or 3.') # Неизвестный порядок нельзя незаметно заменять sinc3.
|
||||
self.decimation = decimation # Каждый результат появляется после этого числа принятых битов.
|
||||
self.order = order # Порядок также определяет длительность начального установления фильтра.
|
||||
self.invert = bool(invert) # Инверсия применяется к битам до фильтрации и меняет знак восстановленного сигнала.
|
||||
self.gain = decimation ** order # Усиление CIC при постоянном входе равно R в степени порядка при дифференциальной задержке один.
|
||||
self.mask = (1 << self.gain.bit_length()) - 1 # Модуль степени двойки строго больше усиления сохраняет точный результат при переполнениях интеграторов.
|
||||
self.integrators = [0] * order # Нулевое начальное состояние не предполагает неизвестную предысторию захвата.
|
||||
self.delays = [0] * order # Каждый гребенчатый каскад хранит свой предыдущий вход на пониженной частоте.
|
||||
self.phase = 0 # Счётчик внутри окна исключает накопление дробной ошибки децимации.
|
||||
self.blocks = 0 # Первые order минус один результатов относятся к установлению и не выдаются пользователю.
|
||||
|
||||
def feed(self, bit): # Принимается ровно один уровень DATA на выбранном фронте CLK.
|
||||
if bit not in (0, 1): # Служебные значения неподключённых каналов sigrok не являются цифровыми уровнями.
|
||||
raise ValueError('Sigma-delta data must be a logic level 0 or 1.') # Ошибка назначения входов не должна превращаться в правдоподобную амплитуду.
|
||||
value = int(bit) ^ self.invert # Униполярное представление позволяет нормировать результат без знакового переполнения.
|
||||
for stage in range(self.order): # Последовательно обновляем интеграторы на полной частоте модулятора.
|
||||
self.integrators[stage] = (self.integrators[stage] + value) & self.mask # Модульная арифметика ограничивает память независимо от длительности записи.
|
||||
value = self.integrators[stage] # Следующий интегратор получает уже обновлённый выход предыдущего.
|
||||
self.phase += 1 # Считаем только фактически захваченные тактовые фронты.
|
||||
if self.phase < self.decimation: # Между границами децимации выходной отсчёт ещё не готов.
|
||||
return None # Неполные окна не дополняются вымышленными битами.
|
||||
self.phase = 0 # Новый блок начинается со следующего реального бита.
|
||||
for stage in range(self.order): # Гребенчатые звенья выполняются только на выходной частоте.
|
||||
previous = value # Задержка сохраняет вход текущего звена, а не его разность.
|
||||
value = (value - self.delays[stage]) & self.mask # Разности устраняют накопление интеграторов, включая их модульные переполнения.
|
||||
self.delays[stage] = previous # Это состояние используется через R входных тактов.
|
||||
self.blocks = min(self.order, self.blocks + 1) # Счётчик установления насыщается и не растёт вместе с записью.
|
||||
if self.blocks < self.order: # Для sincN пропускаем первые N минус один выходных блоков с неизвестной предысторией.
|
||||
return None # Частично установившийся фильтр не выдаёт ложное смещение постоянного сигнала.
|
||||
code = 2 * value - self.gain # Биполярный код принимает диапазон от минус R**N до плюс R**N без привязки к конкретному АЦП.
|
||||
return {'code': code, 'gain': self.gain, 'value': code / self.gain, 'density': value / self.gain} # Нормированная амплитуда и фильтрованная плотность единиц сохраняют точный масштаб CIC.
|
||||
Reference in New Issue
Block a user