Files
templates/python/logic_analyzer/decoders/pwm_motor.py
T

147 lines
25 KiB
Python

"""Расчёт токов асинхронного двигателя по состояниям ключей инвертора.""" # Модель не зависит от 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()) # Сегмент содержит физические амперы на обоих концах интервала.