- stub/06-dds-pio-zero-rp2040.py: новый вариант; PIO всегда выводит 8 бит (out pins, 8), младшие неиспользуемые линии GP8..GP(15-BITS) — выходы в 0 - stub/04-dds-pio-rp2040.py: подтянута шапка с отметкой о проверке на стенде - task.md, print/: пересобраны из РПД (таблица вариантов + брошюра студента) - README.md: список вариантов приведён в соответствие
1266 lines
66 KiB
Python
1266 lines
66 KiB
Python
# DDS-генератор на PIO (RP2040) — CircuitPython
|
||
#
|
||
# Вариант 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, BITS ; вывести BITS младших бит слова в порт
|
||
#
|
||
# То есть 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. Данные ВЫРАВНЕНЫ ПО СТАРШЕМУ биту порта:
|
||
# старший бит значения всегда на 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. Данные выровнены ПО СТАРШЕМУ
|
||
# биту: старший бит значения всегда на GP15, диапазон
|
||
# GP(16-B)..GP15; пины ниже не управляются.
|
||
# 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 <n>
|
||
# или автоматически: 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-программа. BITS подставляется при сборке: у PIO число бит в out —
|
||
# это непосредственный операнд инструкции, поэтому при смене BITS
|
||
# программу собираем заново. Цикл организует автоматический wrap.
|
||
#
|
||
# auto_pull=False => OSR заполняется ТОЛЬКО командой pull, поэтому
|
||
# одно слово FIFO = ровно один вывод out pins,B. Остаток бит слова
|
||
# отбрасывается при следующем pull (шунтировать его не нужно).
|
||
PROG_TEMPLATE = """
|
||
.program dds
|
||
pull block
|
||
out pins, {bits}
|
||
"""
|
||
|
||
|
||
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:
|
||
out[idx] = v
|
||
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) при этом не управляются.
|
||
#
|
||
# B=8 -> данные GP8 ... GP15 (вся гребёнка)
|
||
# B=4 -> данные GP12 ... GP15 (GP8..GP11 не трогаются)
|
||
# B=3 -> данные GP13 ... GP15 (GP8..GP12 не трогаются)
|
||
# B=1 -> данные GP15
|
||
first_num = 16 - self.bits
|
||
first_pin = getattr(board, "GP%d" % first_num)
|
||
|
||
# Защита от наложения портов: PIO занимает GP(first_num)..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 при BITS=%d. "
|
||
"Уменьшите BITS или перенесите этот пин."
|
||
% (name, pin, first_num, self.bits)
|
||
)
|
||
# Диагностика: у пина 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.format(bits=self.bits)
|
||
)
|
||
|
||
# Собираем аргументы конструктора: настройки из .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)
|
||
kwargs.setdefault("out_pin_count", self.bits)
|
||
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)
|
||
kwargs.setdefault("initial_out_pin_direction", (1 << self.bits) - 1)
|
||
|
||
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() # меняется out_pin_count
|
||
|
||
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>",
|
||
"# логарифмический свип 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))
|