Files
eumirts5-dds/stub/06-dds-pio-zero-rp2040.py
mss ebd2ac546a ai: вариант 6 генератора (PIO, неиспользуемые биты = 0), синхронизация с РПД
- 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: список вариантов приведён в соответствие
2026-11-02 13:46:40 +04:00

1293 lines
69 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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-программа. Число бит в 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>",
"# логарифмический свип 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))