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