Files

1266 lines
66 KiB
Python
Raw Permalink Normal View History

# 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))