# DDS-генератор на PIO (RP2040) — CircuitPython # # Отдельный экземпляр варианта 3 (04-dds-pio-rp2040.py) с исправлением # поведения неиспользуемых битов порта. # # ЧЕМ ОТЛИЧАЕТСЯ ОТ 04-dds-pio-rp2040.py: # PIO-машина управляет ВСЕМИ восемью линиями GP8..GP15 независимо от # BITS. Неиспользуемые младшие биты ВЫВОДЯТСЯ В 0 аппаратно, а не # остаются входами/«плавающими». В прежней версии при BITS<8 пины # GP8..GP(15-BITS) вообще не конфигурировались как выходы, хотя шапка # обещала «= 0»: на макете они висели в воздухе и собирали наводки. # Теперь: out pins, 8 (всегда 8 бит), буфер хранит значение, # сдвинутое влево на (8-BITS) — выравнивание по старшему биту то же. # # Вариант 3 генератора для лабораторных работ. Совместим по системе команд # с вариантами 1 (.ino, Arduino) и 2 (01-...-circuitpython.py), но сигнал # для режима «синус» формирует аппаратная PIO-машина RP2040, данные в неё # подаются через DMA, а АЦП читается библиотекой analogbufio. # # ПРОВЕРЕНО НА СТЕНДЕ 2026-10-28 (преподаватель): цифровой выход PIO # снят логическим анализатором — форма синуса верная. Лестница R-2R при # этой проверке НЕ запаивалась, поэтому аналоговая часть (размах после # внешнего ФНЧ, реальная разрядность по постоянному уровню) ещё не # измерялась. # # AI-сгенерировано, 2026-10-27; проверка цифровой части — 2026-10-28. # # Требует библиотеку: adafruit_pioasm -> скопировать в /lib на плате. # https://github.com/adafruit/Adafruit_CircuitPython_PIOASM # На плату положить как code.py (или запускать из REPL). # # ───────────────────────── ЗАЧЕМ ТРЕТИЙ ВАРИАНТ ───────────────────────── # # Варианты 1 и 2 формируют синус программно: каждый такт надо успеть # посчитать следующую выборку и записать её в ШИМ. Чем выше несущая, # тем больше нагрузка на CPU, и на CircuitPython (интерпретатор) частота # выборок падает до единиц-десятков кГц (см. шапку варианта 2). # # Здесь считает PIO: CPU один раз готовит буфер, дальше вообще не # участвует. PIO-машина делает ровно две вещи в цикле: # # pull block ; взять следующее 32-битное слово из FIFO (через DMA) # out pins, 8 ; вывести 8 бит слова в GP8..GP15 (лишние = 0) # # То есть PIO = «проигрыватель» заранее посчитанного буфера. Никаких # вычислений sin() в реальном времени — весь сигнал посчитан в Python # заранее и лежит в массиве, который DMA подаёт в FIFO по кругу. # # Частота выходного сигнала НЕ зависит от содержимого буфера и задаётся # только двумя вещами: # # f_вых = f_пио / (2 * длина_буфера) # # где 2 — число тактов PIO на один сэмпл (pull block + out pins). Длина # буфера равна N * OVERSAMPLE (см. ниже). Меняя только частоту PIO, можно # двигать f_вых, вообще не пересчитывая сигнал. # # ─────────────────── ЧИСЛО БИТ И ТОЧНОСТЬ УРОВНЯ ──────────── # # Число бит BITS задаёт разрядность порта (1..8). Если BITS мало, шаг # квантования большой, и «в лоб» сигнал получился бы ступенчатым. # # Здесь применяется ДЕЛЬТА-СИГМА 1-го порядка: на каждом шаге выход # меняется только на +1, -1 или 0. Отсюда два важных свойства: # # 1. СОСЕДНИЕ ЗНАЧЕНИЯ БУФЕРА ВСЕГДА ОТЛИЧАЮТСЯ НЕ БОЛЕЕ ЧЕМ НА 1. # Это гарантия: никаких скачков «на половину диапазона». # (В прежней версии через ШИМ чётной базы скачки доходили до 4 при # 3 битах — это недопустимо.) # # 2. Средний уровень за период точно равен целевому: аккумулятор # накапливает разность «цель минус выход» и отрабатывает её. # Дробная точность возникает из чередования соседних кодов во # времени, поэтому отдельный ШИМ младшего бита не нужен. # # БИТНОСТЬ и ЭФФЕКТИВНАЯ РАЗРЯДНОСТЬ — РАЗНЫЕ величины: # * битность B — мгновенный код на пинах (всегда ровно B бит); # * эфф. разрядность — разрешение СРЕДНЕГО после ФНЧ: # число уровней = (2^B - 1) * m + 1 # При малом B они заметно расходятся: B=1, m=256 даёт 257 уровней # среднего (~8 бит) при одном бите на порту. # # При этом соседние ЧИСЛА не обязаны различаться одним битом: переход # 7 -> 8 (0111 -> 1000) меняет все биты, и это НОРМАЛЬНО и полезно — # так и выглядит переход между соседними уровнями квантования. # # ─── ОГРАНИЧЕНИЕ СКОРОСТИ (slew) ─── # Дельта-сигма не может нарастать быстрее 1 за шаг. Если целевая функция # меняется быстрее, буфер получится с искажением. Поэтому: # - цель ограничивается по наклону до 1 на шаг; # - буфер считается на ДВА периода, берётся второй: аккумулятор к тому # моменту в установившемся режиме, и стык цикла (DMA крутит буфер # по кругу) получается бесшовным. # Команда I показывает max|Δu| — если он > 1, форма искажена, нужно # увеличить N или уменьшить амплитуду M. # # ─── ПРОИЗВОЛЬНАЯ ФУНКЦИЯ ─── # Форма сигнала задаётся функцией func(t), где t — «время внутри периода»: # 0 в начале, 1 в конце, шаг 1/m (m = N*OVERSAMPLE). По умолчанию — # sin(2*pi*t). Команда FN выбирает форму из набора, FP принимает # произвольное выражение от t, например: # FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t) # Это даёт студенту генератор по любой введённой формуле. # # Оговорка: между соседними сэмплами сигнала base меняется на 2 (шаг пары # «чётное/нечётное»). Это неизбежная цена того, что в паре кодов обязан # меняться ровно один бит. Такие переходы в OVERSAMPLE раз реже тактов PIO # и подавляются внешним ФНЧ. # # ───────────────────────── ПИНЫ ───────────────────────── # # Используются GP8..GP15. PIO управляет ВСЕМИ восемью линиями, поэтому # неиспользуемые младшие биты всегда выведены в НОЛЬ (не «плавают»). # Данные ВЫРАВНЕНЫ ПО СТАРШЕМУ биту порта: старший бит значения всегда # на GP15, младший — на GP(16-B). Пины ниже принудительно в нуле: # # B=8 -> данные GP8 (мл.бит) ... GP15 (ст.бит) — вся гребёнка # B=4 -> данные GP12 ... GP15, GP8..GP11 = 0 (выход, низкий уровень) # B=3 -> данные GP13 ... GP15, GP8..GP12 = 0 (выход, низкий уровень) # B=1 -> данные GP15, GP8..GP14 = 0 (выход, низкий уровень) # # Выравнивание по старшему биту даёт полную амплитуду на выходе при # любой разрядности: 3-битное значение 0..7 занимает ВСЮ шкалу ЦАП, # а не первые 3/8 её (как было бы при выравнивании по младшему биту). # Полная аналоговая разрядность получается на лестнице R-2R, # подключённой к GP8..GP15 (GP15 — старший разряд). # # ─────────────────────── ВНЕШНИЙ ФИЛЬТР ─────────────────────── # # GP15 ──[R1]──┬──[R2]──┬──────► OUT (вход DUT) # │ │ # [C1] [C2] # │ │ # GND GND # # При BITS=1 форма сигнала на GP15 — это ШИМ со средней скважностью, # равной уровню сигнала. Для выделения синуса нужен ФНЧ, как в вариантах # 1 и 2. При BITS>1 и лестнице R-2R ФНЧ нужен только для сглаживания # ступенек дискретизации; f_c можно поднять. # # Постоянная составляющая: при полной амплитуде средний уровень = половина # шкалы. Где смещение недопустимо — разделительный 1 мкФ + 47 кОм на землю. # # ─────────────────────── КОМАНДЫ (совместимы с вариантами 1 и 2) ─────── # # F <Гц> — частота (1..200000); в режиме S управляет частотой # прямоугольника (историческая совместимость) # SF <Гц> — частота СИНУСА (1..200000) # W Q | W S — режим: Q — прямоугольник (PWM), S — синус (PIO) # D <0..255> — скважность прямоугольника # M <0..127> — амплитуда синуса # A — измерить размах на ADC0 -> "A <мВ>" # SWEEP f0 f1 n ms — логарифмический свип: на точку "P <Гц> <мВ>", затем "DONE" # STOP — прервать свип # # ДОПОЛНИТЕЛЬНЫЕ команды этого варианта (их и стоит «крутить» на занятии): # # B <1..8> — число бит порта # O <1..64> — точек на сэмпл (дробление периода: m = N*O) # N <16..4096> — длина одного периода в «полезных» сэмплах # FN <имя> — форма сигнала из набора: sin, sine2, triangle, # saw, square # FP <выражение> — произвольная функция от t (время внутри периода # 0..1), например: # FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t) # I — показать настройки, f_min/f_max и наклон цели # # ОГРАНИЧЕНИЕ (важно для лаборатории): минимальная частота PIO = # 125 МГц / 65536 ≈ 1907 Гц (аппаратный предел делителя clkdiv). # Значит f_вых_min = 1907 / (2 * N * OVERSAMPLE). Для типовых параметров # (N=256, OVERSAMPLE=4) это ≈ 0,93 Гц. Если нужна ещё ниже — увеличивайте # N или OVERSAMPLE (растёт буфер). Команда I показывает обе частоты. # # ВНИМАНИЕ: ADC0 (GP26) — вход 0..3,3 В. Сигнал больше 3,3 В подавать # только через делитель. Схему без питания к ADC не подключать. import array import math import sys import time import board import digitalio import pwmio try: import adafruit_pioasm except ImportError: # pragma: no cover print("Нужна библиотека adafruit_pioasm в /lib (см. шапку файла)") raise try: import rp2pio except ImportError: # pragma: no cover print("rp2pio доступен только на RP2040/RP2350") raise try: import analogbufio except ImportError: # pragma: no cover analogbufio = None print("# analogbufio недоступен: команды A и SWEEP работают по-простому") # ─────────────────────────── константы ─────────────────────────── PIN_MSB = board.GP15 # PWM для режима Q (прямоугольник) PIN_TRIG = board.GP16 # синхроимпульс — ВНЕ диапазона PIO (GP8..GP15)! PIN_ADC = board.GP26 # ADC0 — вход измерения # # ─── КАРТА ПИНОВ (важно: без пересечений) ─── # GP8..GP15 — выход PIO в режиме S. Управляются ВСЕ 8 линий, поэтому # неиспользуемые младшие биты гарантированно в нуле. # Данные выровнены ПО СТАРШЕМУ биту: старший бит значения # всегда на GP15, диапазон GP(16-B)..GP15, пины ниже — 0. # GP15 — PWM в режиме Q (прямоугольник). С PIO не пересекается: # режимы включаются по очереди (ensure_sm/release_sm). # GP16 — синхроимпульс. ВАЖНО: не GP14! При B>=2 PIO занимает # непрерывный диапазон, в который GP14 попадает. # GP26 (A0) — вход ADC. # Синхро и ADC вынесены за пределы GP8..GP15. SYS_CLK = 125_000_000 # тактовая RP2040, Гц # Тактов PIO на один сэмпл. Программа = pull block + out pins = 2 такта, # это подтверждено замером: при CPC=1 частота выходила ВДВОЕ меньше # запрошенной (1000 -> 500), значит реально нужно CPC=2. # При расхождении на конкретном стенде — подстроить командой CLC # или автоматически: CAL <измеренная_Гц>. CPC = 2 MAX_CLKDIV = 65536 # аппаратный предел делителя ADC_FULL_MV = 3300 # опорное напряжение ADC, мВ # Лимит буфера сэмплов. SRAM RP2040 — 264 КБ; CircuitPython, USB и ADC # занимают ~60-70 КБ, под буфер реально доступно ~180 КБ. Берём 128 КБ # (32 768 сэмплов по 4 байта) — с запасом под ADC-буфер и фрагментацию. MAX_BUFFER_BYTES = 128 * 1024 # PIO-программа. Число бит в out — непосредственный операнд инструкции, # поэтому программа собирается заново при смене BITS. Цикл организует # автоматический wrap. # # ВАЖНО (это и есть исправление): выводим ВСЕГДА 8 бит — `out pins, 8` — # независимо от BITS. Тогда PIO конфигурирует GP8..GP15 как выходы и # каждый такт выставляет на них состояние из OSR. Неиспользуемые младшие # биты при этом получают физический 0, потому что в буфер пишется # значение, сдвинутое влево на (8-BITS) (см. _build_buffer). # # auto_pull=False => OSR заполняется ТОЛЬКО командой pull, поэтому # одно слово FIFO = ровно один вывод out pins,8. Остаток бит слова # отбрасывается при следующем pull (шунтировать его не нужно). PROG_TEMPLATE = """ .program dds pull block out pins, 8 """ def _default_wave(t): """Форма по умолчанию: синус. t — доля периода [0,1).""" return math.sin(2.0 * math.pi * t) # ─────────────────────────── класс PioDDS ─────────────────────────── class PioDDS: """DDS на PIO: буфер сэмплов -> DMA -> FIFO -> out pins. Свойства, которые студент может менять на занятии: bits — число бит порта (1..8) oversample — оверсемплинг младшего бита (1..64) n — длина периода в «полезных» сэмплах (16..4096) level — амплитуда (0..127, как команда M) freq — частота выходного сигнала, Гц """ def __init__(self, bits=8, oversample=4, n=256, freq=1000): self.bits = max(1, min(8, int(bits))) self.oversample = max(1, min(64, int(oversample))) self.n = max(16, min(4096, int(n))) self.level = 127 self.freq = max(1, min(200000, int(freq))) self.cpc = CPC # тактов PIO на сэмпл (калибровка) self._func = _default_wave # форма сигнала (func(t)) self._wave_name = "sin" # имя формы для команды I self.buffer = None self.sm = None self._build_buffer() # ─────────────── управление ресурсом (GPIO) ─────────────── # # ВАЖНО: PWM (режим Q) и PIO (режим S) оба используют GP15, а # CircuitPython по умолчанию выделяет пин эксклюзивно. Поэтому машина # создаётся лениво при переходе в режим S и освобождается при возврате # в Q. Одновременно пин держит только один периферийный блок. def ensure_sm(self): """Создать PIO-машину, если её ещё нет (пин должен быть свободен).""" if self.sm is None: self._build_sm() return self.sm def release_sm(self): """Остановить PIO и вернуть пины (перед запуском PWM).""" if self.sm is not None: try: self.sm.deinit() except Exception: pass self.sm = None # ─────────────── расчёт буфера ─────────────── def _build_buffer(self): """Посчитать N*OVERSAMPLE сэмплов синуса. Ключевой принцип: внутри одной группы оверсемплинга переключается ТОЛЬКО МЛАДШИЙ бит. Старшие биты (base) на протяжении группы НЕ меняются — иначе на выходе получались бы «скачки» всего порта (например 127 = 0111 1111 -> 128 = 1000 0000 меняет все 8 бит). Как это устроено: 1. base — ЧЁТНОЕ число: младший бит у него всегда 0 и свободен под ШИМ. Тогда пара (base, base+1) отличается ровно одним битом (например 126 и 127). 2. Доля единиц в младшем бите за OVERSAMPLE слотов задаёт дробную часть уровня (это ШИМ на одном бите). 3. Остаток (то, что не влезло в дробь на этом шаге) переносится на СЛЕДУЮЩИЙ сэмпл сигнала — error diffusion. Благодаря этому среднее по времени всё равно точно равно целевому уровню, хотя мгновенно доступны только значения base и base+1. Оговорка: base меняется на 2 между группами (ступенька в 2 LSB) — это неизбежно, раз пара «шаг ШИМ» обязана быть чётной. Но такие переходы происходят в OVERSAMPLE раз реже тактов PIO и подавляются внешним ФНЧ. Внутри группы старшие биты стабильны. """ bits, oso, n = self.bits, self.oversample, self.n full = (1 << bits) - 1 # максимум для данного числа бит m = n * oso # всего точек сигнала на период # Защита по ОЗУ: RP2040 имеет 264 КБ, но Python в CircuitPython # обычно может занять ~100-150 КБ. Буфер — array('I') по 4 байта # на сэмпл. N=4096, OVERSAMPLE=64 дают 1 МБ и гарантированно # уронят плату MemoryError. Ограничиваем заранее и говорим # понятным текстом, что уменьшить. max_samples = MAX_BUFFER_BYTES // 4 if m > max_samples: raise ValueError( "Буфер слишком большой: N=%d x OVERSAMPLE=%d = %d сэмплов " "(%d КБ), лимит %d КБ. Уменьшите N или OVERSAMPLE." % (n, oso, m, m * 4 // 1024, MAX_BUFFER_BYTES // 1024) ) # 1) Целевые уровни и дельта-сигма считаются ПОТОКОВО, без списка # на два периода: раньше строился массив 2*m и брался второй # период, что вдвое расходовало ОЗУ. Теперь нужен только # буфер m сэмплов, а «первый период» проигрывается насквозь # для установления аккумулятора. # # t — «время внутри периода», меняется 0..1 с шагом 1/m. def target_at(k): return self._target((k % m) / m) # 2) Ограничение скорости изменения цели до 1 на шаг выполняется # прямо в цикле ниже (переменная prev_u). # 3) Дельта-сигма 1-го порядка: выход меняется только на ±1 или 0, # значит СОСЕДНИЕ ЗНАЧЕНИЯ ВСЕГДА ОТЛИЧАЮТСЯ НЕ БОЛЕЕ ЧЕМ НА 1. full_f = float(full) v = max(0, min(full, int(round(target_at(0))))) acc = 0.0 out = array.array("I", [0]) * m slew_max = 0.0 prev_u = None idx = 0 # два прохода: первый «холостой» (устанавливает аккумулятор), # второй пишет в буфер for period in (0, 1): for k in range(m): u = target_at(k) # ограничение наклона if prev_u is not None: d = u - prev_u if d > 1.0: u = prev_u + 1.0 elif d < -1.0: u = prev_u - 1.0 if abs(u - prev_u) > slew_max: slew_max = abs(u - prev_u) prev_u = u # дельта-сигма acc += u - v if acc >= 1.0: v += 1 acc -= 1.0 elif acc <= -1.0: v -= 1 acc += 1.0 if v < 0: v = 0 elif v > full: v = full if period == 1: # Выравнивание по старшему биту: значение 0..full # занимает старшие BITS линий GP(16-B)..GP15, а # младшие (8-BITS) остаются нулями. PIO выводит все # 8 бит, поэтому эти нули попадают на пины физически. out[idx] = v << (8 - bits) idx += 1 self._slew_max = slew_max self._slew_ok = slew_max <= 1.0 self.buffer = out def _target(self, t): """Целевой уровень в шкале [0, full] для момента t в [0,1). По умолчанию — синус. Может быть заменён произвольной функцией студента (см. set_function): func(t) задаёт значение в диапазоне [-1, 1], t — доля периода (0 в начале, 1 в конце, шаг 1/m). """ f = self._func(t) if f < -1.0: f = -1.0 elif f > 1.0: f = 1.0 full = (1 << self.bits) - 1 return (f + 1.0) * 0.5 * full * (self.level / 127.0) def set_function(self, func, name=None): """Заменить форму сигнала функцией func(t). func принимает t в [0,1) и возвращает значение в [-1, 1]. t — доля периода: 0 в начале, 1 в конце, шаг 1/(N*OVERSAMPLE). На занятии проще пользоваться командами генератора: FN triangle — форма из набора FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t) — своя формула Из кода можно передать любую именованную функцию: def wave(t): return math.sin(2*math.pi*t) dds.set_function(wave) """ self._func = func self._wave_name = name or getattr(func, "__name__", "custom") self._check_slew() self._build_buffer() if self.sm is not None: self._repush() def reset_function(self): """Вернуть форму по умолчанию (чистый синус).""" self._func = _default_wave self._wave_name = "sin" self._check_slew() self._build_buffer() if self.sm is not None: self._repush() def _check_slew(self): """Оценить, не превышает ли функция скорость, которую может отработать дельта-сигма. Возвращает (max|Δu|, ok).""" m = self.n * self.oversample us = [self._target(k / m) for k in range(min(m, 4096))] if len(us) < 2: return 0.0, True dmax = max(abs(a - b) for a, b in zip(us, us[1:])) return dmax, dmax <= 1.0 def effective_bits(self): """Точная оценка разрешения СРЕДНЕГО уровня за период. Дельта-сигма чередует соседние коды, а внешний ФНЧ усредняет. Число различимых средних за m точек: (2^B - 1) * m + 1 (а не m * 2^B, как давала упрощённая формула B + log2(m) — она завышала на 1 бит при малых B, например B=1 давала 9 бит вместо фактических 8). В CircuitPython math.log2 НЕТ (в CPython есть) — считаем через math.log(x) / math.log(2). """ full = (1 << self.bits) - 1 levels = full * (self.n * self.oversample) + 1 if levels <= 1: return float(self.bits) return math.log(levels) / math.log(2) # ─────────────── сборка PIO-машины ─────────────── def f_pio(self): """Требуемая частота PIO: f_вых * тактов_на_сэмпл * длина_буфера.""" return self.freq * self.cpc * (self.n * self.oversample) def f_out_max(self): """Максимальная достижимая f_вых при текущих N и OVERSAMPLE. f_пио не может превысить тактовую (SYS_CLK), значит f_вых_max = SYS_CLK / (self.cpc * N * OVERSAMPLE). """ return SYS_CLK / (self.cpc * self.n * self.oversample) def _clamp_f_pio(self): return max(int(SYS_CLK / MAX_CLKDIV), min(SYS_CLK, int(self.f_pio()))) def _build_sm(self): if self.sm is not None: try: self.sm.deinit() except Exception: pass # Выравнивание ПО СТАРШЕМУ биту порта (left-aligned): старший бит # значения всегда на GP15, данные занимают GP(16-bits)..GP15. # Младшие пины диапазона GP8..GP(15-bits) выводятся в 0. # # B=8 -> данные GP8 ... GP15 (вся гребёнка) # B=4 -> данные GP12 ... GP15 (GP8..GP11 = 0) # B=3 -> данные GP13 ... GP15 (GP8..GP12 = 0) # B=1 -> данные GP15 (GP8..GP14 = 0) # # ОТЛИЧИЕ ОТ 04-...: PIO всегда занимает ВЕСЬ диапазон GP8..GP15 # (программа out pins,8), поэтому «лишние» линии — это реальные # выходы с низким уровнем, а не свободные входы. first_num = 8 first_pin = getattr(board, "GP%d" % first_num) # Защита от наложения портов: PIO всегда занимает GP8..GP15. # Синхроимпульс и ADC должны быть ВНЕ этого диапазона. pio_pins = set(range(first_num, 16)) for name, pin in (("PIN_TRIG", 16), ("PIN_ADC", 26)): if pin in pio_pins: raise ValueError( "%s=GP%d попадает в диапазон PIO GP%d..GP15. " "Перенесите этот пин." % (name, pin, first_num) ) # Диагностика: у пина TRIG не должно быть активного владельца. # (CircuitPython сам бросит ValueError при попытке занять занятый # пин, но лучше проверить заранее и сказать понятнее.) # ВАЖНО: у adafruit_pioasm.Program нет метода .assemble() — # собранные инструкции лежат в свойстве .assembled, а настройки # из директив — в словаре .pio_kwargs (см. документацию библиотеки: # program = adafruit_pioasm.Program(text) # sm = rp2pio.StateMachine(program.assembled, **program.pio_kwargs) program = adafruit_pioasm.Program(PROG_TEMPLATE) # Собираем аргументы конструктора: настройки из .pio_kwargs плюс # наши явные параметры. Через setdefault — чтобы будущие версии # библиотеки не вызвали конфликт «duplicate keyword argument». kwargs = dict(program.pio_kwargs) kwargs.setdefault("frequency", self._clamp_f_pio()) kwargs.setdefault("first_out_pin", first_pin) # всегда GP8 kwargs.setdefault("out_pin_count", 8) # всегда все 8 линий kwargs.setdefault("auto_pull", False) # тянем явно: pull block kwargs.setdefault("pull_threshold", 32) kwargs.setdefault("out_shift_right", True) # младшие биты -> порт kwargs.setdefault("initial_out_pin_state", 0) # Все 8 линий — выходы. Неиспользуемые младшие биты держат 0 # (значение в буфере выровнено влево, см. _build_buffer). kwargs.setdefault("initial_out_pin_direction", 0xFF) self.sm = rp2pio.StateMachine(program.assembled, **kwargs) # DMA по кругу: буфер проигрывается бесконечно, CPU не участвует self.sm.background_write(loop=self.buffer) def _repush(self): """Перезапустить DMA на новый буфер (после его пересчёта).""" if self.sm is None: return try: self.sm.stop_background_write() except Exception: pass self.sm.background_write(loop=self.buffer) # ─────────────── настройки ─────────────── def set_frequency(self, f): """Установить частоту. Возвращает фактическую f_вых (может быть меньше запрошенной, если упёрлись в тактовую PIO).""" self.freq = max(1, min(200000, int(f))) target = self._clamp_f_pio() if self.sm is None: return self.freq try: self.sm.frequency = target # проверяем, что установка реально сработала: у rp2pio свойство # frequency может отличаться от запрошенного (округление), но # не должно отличаться в разы. Если отличается — пересобираем. actual = int(self.sm.frequency) if target > 0 and not (0.5 <= actual / target <= 2.0): self._build_sm() except Exception: self._build_sm() # запасной путь: пересборка # фактическая частота выхода после обрезки делителя/тактовой f_real = self._clamp_f_pio() / (self.cpc * self.n * self.oversample) return f_real def min_m_for_bits(self): """Минимальное число точек на период, чтобы синус полной амплитуды уложился в скорость дельта-сигмы (max|Δu| <= 1). Из max|Δu| = pi*full*amp/m <= 1 следует m >= pi*full*amp. """ full = (1 << self.bits) - 1 return int(math.pi * full * (self.level / 127.0)) + 1 def slew_warning(self): """Текст предупреждения, если форма не уложится в slew, или None.""" if getattr(self, "_slew_ok", True): return None m = self.n * self.oversample need = self.min_m_for_bits() return ("форма не уложится: max|Δu|=%.2f > 1 при m=%d. Нужно m>=%d " "(увеличьте N или OVERSAMPLE примерно в %d раз, либо " "уменьшите амплитуду M)." % (self._slew_max, m, need, max(1, (need + m - 1) // m))) def set_cpc(self, cpc): """Задать число тактов PIO на сэмпл (калибровка частоты).""" self.cpc = max(1, min(8, int(cpc))) def calibrate(self, measured_hz): """Автокалибровка CPC по измеренной частоте. Ставим частоту F, измеряем осциллографом M. Тогда f_вых = f_пио / (T * m) = F * CPC / T => T = F * CPC / M Новое CPC делаем равным T, чтобы в следующий раз факт совпал с запросом. Возвращает (было, стало). """ was = self.cpc if measured_hz <= 0: return was, was t_needed = self.freq * self.cpc / float(measured_hz) self.cpc = max(1, min(8, int(round(t_needed)))) self.set_frequency(self.freq) # применить новый f_пио return was, self.cpc def needs_warning(self): """True, если запрошенная частота недостижима при текущих N и O.""" return self.f_pio() > SYS_CLK def suggest_reduction(self): """Подсказка, что изменить, чтобы достичь requested-частоты.""" # нужен меньший CPC*N*O: предлагаем уменьшить N или O need = self.freq * self.cpc # требуется N*O не больше max_n_o = max(1, int(SYS_CLK / need)) return ("при N=%d и O=%d максимум %.0f Гц. Чтобы получить %d Гц, " "нужно N*O <= %d (например N=%d, O=1)." % (self.n, self.oversample, self.f_out_max(), self.freq, max_n_o, min(self.n, max_n_o))) def set_amplitude(self, level): self.level = max(0, min(127, int(level))) self._build_buffer() if self.sm is not None: self._repush() def set_bits(self, bits): bits = max(1, min(8, int(bits))) if bits == self.bits: return was_active = self.sm is not None self.bits = bits self._build_buffer() # шкала зависит от числа бит if was_active: self._build_sm() # сдвиг в буфере зависит от бит def set_oversample(self, oso): oso = max(1, min(64, int(oso))) if oso == self.oversample: return was_active = self.sm is not None old = self.oversample self.oversample = oso try: self._build_buffer() except ValueError: self.oversample = old # откат: объект остаётся рабочим raise if was_active: self._build_sm() def set_n(self, n): n = max(16, min(4096, int(n))) if n == self.n: return was_active = self.sm is not None old = self.n self.n = n try: self._build_buffer() # длина буфера изменилась except ValueError: self.n = old # откат raise if was_active: self._build_sm() # f_pio и размер DMA-буфера def info(self): f_pio = self.f_pio() f_pio_clamped = self._clamp_f_pio() f_min = (SYS_CLK / MAX_CLKDIV) / (self.cpc * self.n * self.oversample) f_max = self.f_out_max() # ФАКТИЧЕСКАЯ частота после обрезки f_pio по тактовой. Показывать # только запрошенную f_out вводит в заблуждение: при запросе выше # потолка реально выходит f_max (обычно чуть ниже из-за голодания # FIFO на пределе). f_real = f_pio_clamped / (self.cpc * self.n * self.oversample) wf = " ! ЗАПРОС ВЫШЕ ПОТОЛКА: факт %.0f Гц" % f_real if \ self.needs_warning() else "" wave = getattr(self, "_wave_name", "sin") slew = getattr(self, "_slew_max", 0.0) sleww = " ok" if getattr(self, "_slew_ok", True) else \ " ! ЦЕЛЬ СЛИШКОМ БЫСТРАЯ (max|Δu|=%.2f>1)" % slew return ("BITS=%d OVERSAMPLE=%d (эфф.разрядность %.1f бит) N=%d " "BUFFER=%d AMP=%d f_out=%d Гц (факт %.0f) f_pio=%d Гц " "CPC=%d CLKDIV=%.2f f_min=%.2f f_max=%.0f Гц shape=%s " "slew=%.3f%s%s" % ( self.bits, self.oversample, self.effective_bits(), self.n, self.n * self.oversample, self.level, self.freq, f_real, f_pio, self.cpc, SYS_CLK / max(1, f_pio_clamped), f_min, f_max, wave, slew, sleww, wf)) def deinit(self): if self.sm is not None: try: self.sm.deinit() except Exception: pass self.sm = None # ───────────────────── прямоугольник на PWM ───────────────────── # # Режим Q оставлен на аппаратном ШИМ (как в вариантах 1 и 2): для # прямоугольника PIO не нужен, а PWM проще и точнее по частоте. # Пины разные (PWM — на GP15, PIO при BITS>1 занимает GP8..GP15), # поэтому в режиме S PWM глушится, а в режиме Q глушится PIO — # см. cmd_w ниже. class SquarePWM: def __init__(self, pin=PIN_MSB): self.pwm = pwmio.PWMOut(pin, frequency=1000, duty_cycle=0, variable_frequency=True) self.enabled = False def set_frequency(self, f): self.pwm.frequency = max(1, min(200000, int(f))) def set_duty(self, d): d = max(0, min(255, int(d))) self.pwm.duty_cycle = d * 65535 // 255 def off(self): self.pwm.duty_cycle = 0 def deinit(self): try: self.pwm.deinit() except Exception: pass # ─────────────────────────── АЦП ─────────────────────────── def measure_amplitude_mv(window_ms=50, rate=200000): """Размах (пик-пик) на ADC0 в милливольтах. Чтение через analogbufio (аппаратный ADC + DMA). Если библиотеки нет — откатываемся на простое аналоговое чтение (медленнее и грубее). """ if analogbufio is None: import analogio adc = analogio.AnalogIn(PIN_ADC) vmin, vmax = 65535, 0 t0 = time.monotonic() while (time.monotonic() - t0) * 1000 < window_ms: v = adc.value if v < vmin: vmin = v if v > vmax: vmax = v adc.deinit() return (vmax - vmin) * ADC_FULL_MV // 65535 length = int(rate * window_ms / 1000) length = max(64, min(8192, length)) # ограничение по ОЗУ buf = array.array("H", [0]) * length adc = analogbufio.BufferedIn(PIN_ADC, sample_rate=rate) try: adc.readinto(buf, loop=False) finally: adc.deinit() # при loop=False 12-битные значения масштабированы в 16 бит return (max(buf) - min(buf)) * ADC_FULL_MV // 65535 # ─────────────────────────── свип ─────────────────────────── # # ВАЖНО: периферия создаётся ЛЕНИВО, внутри ensure_trig(), а не на уровне # модуля. Если создать DigitalInOut при загрузке файла и пин занят — упадёт # весь модуль, и мы увидим «program запускается, ничего не выводит». # Ленивая инициализация позволяет сначала напечатать HELP и сообщить # понятную ошибку. trig = None def ensure_trig(): """Создать синхроимпульс при первом использовании.""" global trig if trig is None: trig = digitalio.DigitalInOut(PIN_TRIG) trig.direction = digitalio.Direction.OUTPUT trig.value = False return trig def trigger_pulse(): t = ensure_trig() t.value = True time.sleep(0.00005) t.value = False def run_sweep(dds, square, mode, f0, f1, count, dwell_ms): """Логарифмический свип. Печатает 'P <Гц> <мВ>', в конце 'DONE'.""" count = max(2, count) if f1 <= f0: f1 = f0 * 10.0 ratio = (f1 / f0) ** (1.0 / (count - 1)) f = float(f0) try: for _ in range(count): if mode == "S": dds.set_frequency(int(f)) else: square.set_frequency(int(f)) trigger_pulse() time.sleep(dwell_ms / 1000.0) mv = measure_amplitude_mv() sys.stdout.write("P %d %d\n" % (int(f), mv)) f *= ratio except KeyboardInterrupt: sys.stdout.write("DONE\n") raise sys.stdout.write("DONE\n") # ───────── предустановленные формы сигнала (для команды FN) ───────── def _wave_sin(t): return math.sin(2.0 * math.pi * t) def _wave_sine2(t): # ваш пример: 0.3*sin(t) + 0.5*sin(2t) в долях периода return 0.3 * math.sin(2.0 * math.pi * t) + \ 0.5 * math.sin(4.0 * math.pi * t) def _wave_triangle(t): return 4.0 * abs(t - 0.5) - 1.0 def _wave_saw(t): return 2.0 * t - 1.0 def _wave_square(t): return 1.0 if t < 0.5 else -1.0 WAVES = { "sin": _wave_sin, "sine2": _wave_sine2, "triangle": _wave_triangle, "saw": _wave_saw, "square": _wave_square, } # Разрешённые имена для команд FP/FN. Ограничиваем окружение eval, чтобы # студент не мог дотянуться до встроенных объектов, только математика. _EXPR_NAMES = { "sin": math.sin, "cos": math.cos, "tan": math.tan, "pi": math.pi, "abs": abs, } def _eval_expr(expr, t): """Вычислить выражение от t. Отдельная функция (не lambda) — на плате CircuitPython синтаксис lambda в разборе команд не сработал. Окружение ограничено: только sin/cos/tan/pi/abs и переменная t. Встроенные объекты закрыты, поэтому __import__ и open недоступны. """ env = dict(_EXPR_NAMES) env["t"] = t return eval(expr, {"__builtins__": {}}, env) # защита от выражений, которые «зависают» или дают огромные значения EXPR_LEN_MAX = 120 def _expr_ok(expr): """Проверить выражение до использования. Возвращает текст ошибки или None, если всё в порядке.""" if len(expr) > EXPR_LEN_MAX: return "выражение слишком длинное (лимит %d)" % EXPR_LEN_MAX # пробуем вычислить в нескольких точках — ловим синтаксис и деление на 0 for t in (0.0, 0.125, 0.333, 0.5, 0.75): try: v = _eval_expr(expr, t) except Exception as e: return "%s (при t=%.3f)" % (e, t) if v != v: # NaN return "не числовой результат (NaN)" if abs(v) > 1e6: return "слишком большое значение (%g) — форма вне [-1,1]" % v return None def _make_expr_wave(expr): """Вернуть функцию func(t) по тексту выражения, без lambda.""" def wave(t): return _eval_expr(expr, t) return wave # ─────────────────────────── main ─────────────────────────── # ─────────────────────────── подробный хелп ─────────────────────────── def print_help(): """Подробная справка по командам с форматами и примерами.""" lines = [ "# ─────────── DDS-генератор на PIO: команды ───────────", "# Обозначения: <..> — обязательный аргумент.", "# Команда вводится строкой, Enter — конец строки.", "#", "# РЕЖИМ И ЧАСТОТА", "# W Q — режим прямоугольника (аппаратный ШИМ на GP15)", "# W S — режим синуса (аппаратный PIO на GP8..GP15)", "# F <Гц> — частота, 1..200000", "# в режиме Q — прямоугольника; в S — синуса", "# пример: F 1000", "# SF <Гц> — частота СИНУСА (то же, но явно), 1..200000", "# пример: SF 25", "#", "# ФОРМА СИГНАЛА", "# D <0..255> — скважность прямоугольника (128 = 50 %)", "# действует только в режиме Q", "# пример: D 64 (25 % включено)", "# M <0..127> — амплитуда синуса (127 = максимум)", "# пример: M 32 (четверть шкалы)", "#", "# ИЗМЕРЕНИЕ И СВИП", "# A — размах на ADC0 (GP26), мВ -> ответ 'A <мВ>'", "# SWEEP ", "# логарифмический свип f0..f1 за n точек,", "# пауза dwell_ms на точку; печатает 'P <Гц> <мВ>',", "# в конце 'DONE'", "# пример: SWEEP 10 100000 25 150", "# STOP — прервать свип (надёжнее Ctrl-C)", "#", "# КАРТА ПИНОВ (пересечений нет)", "# GP8..GP15 — выход PIO в режиме S. Данные выровнены по", "# СТАРШЕМУ биту: старший бит всегда на GP15,", "# диапазон GP(16-B)..GP15; пины ниже = 0", "# GP15 — ШИМ в режиме Q (прямоугольник)", "# GP16 — синхроимпульс (вне диапазона PIO)", "# GP26 (A0) — вход ADC, до 3.3 В (через делитель)", "#", "# НАСТРОЙКИ PIO (только этот вариант) — применяются в режиме S", "# B <1..8> — число бит порта (разрядность, GP8..GP15)", "# B 1 — только GP15 (как в вариантах 1 и 2)", "# B 8 — все восемь пинов (нужна лестница R-2R)", "# пример: B 1", "# O <1..64> — точек на сэмпл (влияет на m = N*O, см. ниже)", "# пример: O 4", "# N <16..4096> — длина периода в «полезных» сэмплах", "# пример: N 256", "# I — текущие настройки, f_min/f_max, slew", "# CLC <1..8> — тактов PIO на сэмпл вручную. По умолчанию 2", "# (pull block + out pins). Если факт вдвое", "# меньше/больше запроса — подстройте.", "# CAL <Гц> — автокалибровка: поставьте частоту, измерьте", "# осциллографом и введите измеренное значение,", "# например: CAL 500 (при запросе 1000).", "# CPC подстроится сам, факт совпадёт с запросом.", "#", "# ФОРМА СИГНАЛА (func(t), t — время внутри периода, 0..1)", "# FN <имя> — форма из набора: sin, sine2, triangle,", "# saw, square. пример: FN triangle", "# FP <выражение> — своя функция от t; доступны sin, cos, tan,", "# pi, abs. пример:", "# FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t)", "#", "# КАК РАБОТАЕТ ТОЧНОСТЬ (дельта-сигма)", "# Выход меняется только на +1/-1/0 за шаг, поэтому СОСЕДНИЕ", "# ЗНАЧЕНИЯ БУФЕРА ВСЕГДА ОТЛИЧАЮТСЯ НЕ БОЛЕЕ ЧЕМ НА 1.", "# Средний уровень за период точен; дробная точность возникает", "# из чередования соседних кодов во времени.", "# Ограничение: цель не должна меняться быстрее 1 за шаг.", "# При max|Δu|>1 форма искажается — увеличьте N или уменьшите M.", "# Для синуса полной амплитуды нужно m=N*O >= ~802 при B=8.", "#", "# ЛИМИТ ОЗУ: буфер = N*O сэмплов по 4 байта, не больше 64 КБ", "# (16384 сэмплов). Например N=4096 и O=64 дадут 1 МБ и будут", "# отклонены с ERR — уменьшите N или O.", "#", "# ПРЕДЕЛ ЧАСТОТЫ: f_вых = f_пио/(2*N*O), а f_пио <= 125 МГц.", "# Значит f_вых_max = 125e6/(2*N*O) — при N=256, O=4 это ~61 кГц.", "# Если запросить выше, будет выведено '! ВНИМАНИЕ' с подсказкой,", "# какие N/O взять. Команда I показывает f_min и f_max.", "#", "# СПРАВКА", "# HELP | ? — этот текст", "#", "# ─── СХЕМА (синус: нужен ФНЧ; RC как в вариантах 1 и 2) ───", "# GP15 --[R1]--+--[R2]--+--> во вход схемы", "# | |", "# [C1] [C2] R1=R2=1к, C1=C2=47н", "# | | (f_c ~ 3.4 кГц)", "# GND GND", "# При B>1 младшие биты — на GP8..GP14; без лестницы R-2R они", "# практически не влияют (осциллограф на GP15 покажет 'огрубление').", "# ADC0 — до 3.3 В (делитель!). Постоянная составляющая = Uпит/2.", "# ─────────────────────────────────────────────", ] for ln in lines: sys.stdout.write(ln + "\n") def main(): # Сначала сообщаем о старте — до создания периферии. Если что-то из # железа не поднимется, мы это увидим (раньше первый print стоял # ПОСЛЕ конструкторов, и при ошибке не выводилось ничего). print("# DDS на PIO (RP2040 / CircuitPython).") print("# Подробный хелп: HELP или ?") dds = None square = None try: dds = PioDDS(bits=8, oversample=4, n=256, freq=1000) dds.set_frequency(1000) print("# " + dds.info()) square = SquarePWM() square.set_frequency(1000) square.set_duty(128) print(" (режим Q: PWM на GP15; режим S: PIO на GP8..GP15)") except Exception as e: # показываем причину, а не молча завершаемся print("! ОШИБКА инициализации:", repr(e)) if dds is not None: try: dds.deinit() except Exception: pass if square is not None: try: square.deinit() except Exception: pass return mode = "Q" saved_square_hz = 1000 # частота режима Q, сохраняется при W S try: while True: line = sys.stdin.readline() if not line: # EOF (порт закрыт) — иначе бесконечный холостой цикл break parts = line.split() if not parts: continue cmd = parts[0].upper() if cmd in ("HELP", "?"): print_help() elif cmd == "F" and len(parts) > 1: hz = int(float(parts[1])) if mode == "S": dds.set_frequency(hz) if dds.needs_warning(): print("OK F %d (синус)" % dds.freq) print("! ВНИМАНИЕ: частота недостижима — %s" % dds.suggest_reduction()) print("! Сейчас реально: %.0f Гц" % dds.f_out_max()) else: print("OK F %d (синус)" % dds.freq) else: square.set_frequency(hz) print("OK F %d (прямоугольник)" % hz) elif cmd == "SF" and len(parts) > 1: dds.set_frequency(int(float(parts[1]))) if dds.needs_warning(): print("OK SF %d" % dds.freq) print("! ВНИМАНИЕ: частота недостижима — %s" % dds.suggest_reduction()) print("! Сейчас реально: %.0f Гц" % dds.f_out_max()) else: print("OK SF %d" % dds.freq) elif cmd == "W" and len(parts) > 1: want = "S" if parts[1].upper().startswith("S") else "Q" if want == mode: print("OK W %s (уже)" % mode) else: try: if want == "S": # запомнить частоту PWM, чтобы не потерять её # при возврате в режим Q (сценарий F 500 -> W S -> W Q) saved_square_hz = square.pwm.frequency # погасить выход перед освобождением пинов, # затем отдать GPIO блоку PIO square.off() square.deinit() dds.ensure_sm() dds.set_frequency(dds.freq) mode = "S" print("OK W S (PIO: %s)" % dds.info()) else: # GPIO от PIO к PWM (обратный перехват) dds.release_sm() square = SquarePWM() # вернуть ту частоту, что была в режиме Q square.set_frequency(saved_square_hz) square.set_duty(128) mode = "Q" print("OK W Q (PWM на GP15, %d Гц)" % saved_square_hz) except Exception as e: # не роняем консоль: сообщаем, что именно не вышло print("ERR W %s:" % want, repr(e)) mode = "Q" if want == "S" else "S" elif cmd == "D" and len(parts) > 1: if mode == "Q": square.set_duty(int(parts[1])) print("OK D %s" % parts[1]) elif cmd == "M" and len(parts) > 1: dds.set_amplitude(int(parts[1])) print("OK M %d" % dds.level) w = dds.slew_warning() if w: print("! ВНИМАНИЕ:", w) elif cmd == "A": print("A %d" % measure_amplitude_mv()) elif cmd == "SWEEP" and len(parts) > 4: run_sweep(dds, square, mode, float(parts[1]), float(parts[2]), int(parts[3]), int(parts[4])) elif cmd == "STOP": print("OK STOP (если свип идёт — надёжнее Ctrl-C)") elif cmd == "B" and len(parts) > 1: try: dds.set_bits(int(parts[1])) print("OK B %d (применится в режиме S)" % dds.bits) except Exception as e: print("ERR B:", repr(e)) elif cmd == "O" and len(parts) > 1: try: dds.set_oversample(int(parts[1])) print("OK O %d" % dds.oversample) except Exception as e: print("ERR O:", repr(e)) elif cmd == "N" and len(parts) > 1: try: dds.set_n(int(parts[1])) print("OK N %d" % dds.n) except Exception as e: print("ERR N:", repr(e)) elif cmd == "FN" and len(parts) > 1: name = parts[1].lower() if name in WAVES: try: dds.set_function(WAVES[name], name=name) print("OK FN %s" % name) if not dds._slew_ok: print("! ВНИМАНИЕ: max|Δu|=%.2f > 1 — форма " "исказится. Увеличьте N (сейчас %d) " "или уменьшите амплитуду M." % (dds._slew_max, dds.n)) except Exception as e: print("ERR FN:", repr(e)) else: print("ERR FN: неизвестная форма. Доступны: %s" % ", ".join(sorted(WAVES))) elif cmd == "FP" and len(parts) > 1: # FP <выражение от t> — своя функция, например: # FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t) expr = line.strip()[len("FP"):].strip() if expr: err = _expr_ok(expr) if err: print("ERR FP: %s" % err) else: try: f = _make_expr_wave(expr) dds.set_function(f, name="FP:" + expr) print("OK FP %s" % expr) if not dds._slew_ok: print("! ВНИМАНИЕ: max|Δu|=%.2f > 1 — форма " "исказится. Увеличьте N или уменьшите M." % dds._slew_max) except Exception as e: print("ERR FP:", repr(e)) dds.reset_function() else: print("ERR FP: укажите выражение, например " "FP 0.3*sin(2*pi*t)+0.5*sin(4*pi*t)") elif cmd == "CLC" and len(parts) > 1: try: dds.set_cpc(int(parts[1])) dds.set_frequency(dds.freq) # пересчитать f_pio print("OK CLC %d (f_out=%.1f Гц)" % (dds.cpc, dds.freq)) except Exception as e: print("ERR CLC:", repr(e)) elif cmd == "CAL" and len(parts) > 1: # CAL <измеренная_Гц> — автокалибровка по осциллографу try: was, now = dds.calibrate(float(parts[1])) print("OK CAL: CPC %d -> %d (f_out=%.1f Гц)" % (was, now, dds.freq)) except Exception as e: print("ERR CAL:", repr(e)) elif cmd == "I": print("# " + dds.info()) else: print("ERR unknown command: %s" % cmd) except KeyboardInterrupt: pass finally: dds.deinit() square.deinit() if trig is not None: try: trig.deinit() except Exception: pass print("stopped") # ─────────────── точка входа ─────────────── # # На плате (файл code.py) запускаем генератор всегда. Стандартная проверка # `if __name__ == "__main__":` ненадёжна в CircuitPython: имя модуля при # автозапуске не всегда равно "__main__", и тогда скрипт «стартует, ничего # не выводит и завершается». Поэтому запускаем безусловно, но с двумя # исключениями: # 1) файл импортируют как модуль для опытов в REPL — отключить # автозапуск можно, поставив _START_GENERATOR = False; # 2) ошибки печатаем, а не молчим. _START_GENERATOR = True if _START_GENERATOR: try: main() except KeyboardInterrupt: pass except Exception as _e: try: import sys as _sys _sys.print_exception(_e) except Exception: print("! ОШИБКА:", repr(_e))