From 2c81de5d76390193eacf5ec54e8b3af98d818442 Mon Sep 17 00:00:00 2001 From: mss Date: Tue, 27 Oct 2026 20:23:07 +0400 Subject: [PATCH] =?UTF-8?q?ai:=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=20=D0=B2=20eumirts5-?= =?UTF-8?q?dds;=20=D0=B7=D0=B0=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=87=D0=B0=D1=81=D1=82=D1=8C=20A=20(=D1=81=D0=B1=D0=BE?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20DDS-=D1=81=D1=82=D0=B5=D0=BD=D0=B4=D0=B0)?= =?UTF-8?q?=20=D0=B8=20B=20(=D0=B8=D1=81=D1=81=D0=BB=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D1=82=D0=BE=D1=80=D0=B0,=20=D0=B4=D0=B8=D1=84?= =?UTF-8?q?=D1=84=D0=B5=D1=80=D0=B5=D0=BD=D1=86=D0=B8=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B0,=20RC-=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0);?= =?UTF-8?q?=20stub=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD=D0=B8?= =?UTF-8?q?=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=81=20=D0=A0?= =?UTF-8?q?=D0=9F=D0=94=20(=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20PIO-=D0=B3=D0=B5=D0=BD=D0=B5=D1=80=D0=B0=D1=82=D0=BE?= =?UTF-8?q?=D1=80);=20README=20=D1=81=20=D1=80=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=84=D1=80=D0=BE=D0=B2=D0=BA=D0=BE=D0=B9=20=D0=BD=D0=B0=D0=B7?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=B8=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D1=82=D0=BE=D0=B9=20=D0=BF=D0=B8=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 190 +-- ...peredatochnaya-funkciya-na-ou-student.docx | Bin 42221 -> 43493 bytes stub/01-dds-generator-rp2040-circuitpython.py | 60 +- stub/01-dds-generator-rp2040.ino | 58 +- stub/04-dds-pio-rp2040.py | 1260 +++++++++++++++++ task.md | 367 +++-- 6 files changed, 1685 insertions(+), 250 deletions(-) create mode 100644 stub/04-dds-pio-rp2040.py diff --git a/README.md b/README.md index 6bedf04..c1ba3c5 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,130 @@ -# Лабораторная: интегратор и дифференциатор на ОУ (АЧХ/ФЧХ + Monte Carlo + автосвип) +# ЭУМиРТС5-ДДС -Комплект к теме `topics/lab5-peredatochnaya-funkciya-na-ou.md` -(«Электронные устройства мехатронных и робототехнических систем», -Б1.В.06, 5 семестр, 4 ч). +Репозиторий студента для одной лабораторной работы. -> AI-сгенерировано (2026-10-26), требует проверки преподавателем -> перед использованием на стенде. +**Дисциплина:** Электронные устройства мехатронных и робототехнических +систем (ЭУМиРТС), Б1.В.06 +**Специальность:** 15.03.06 «Мехатроника и робототехника» +**Семестр:** 5 · **Объём:** 4 часа -## Контракт +Название репозитория расшифровывается так: -Оформлено по контракту `labs/`: +| Часть | Значение | +|---|---| +| `eumirts` | ЭУМиРТС — электронные устройства мехатронных и робототехнических систем | +| `5` | пятый семестр | +| `dds` | Direct Digital Synthesis — генератор на RP2040, который вы собираете | + +> Черновик. Требует проверки преподавателем перед выдачей студентам. + +--- + +## О чём работа + +Работа состоит из двух частей: + +**A. Стенд.** Вы собираете DDS-генератор на RP2040 (Raspberry Pi Pico) и +скрипт автоматического снятия АЧХ. Готового лабораторного генератора со +свипом не оказалось, поэтому измерительный прибор делается своими руками. + +**B. Исследование.** Этим стендом вы снимаете характеристики трёх схем — +интегратора на ОУ, дифференциатора на ОУ и пассивного RC-фильтра — и +сравниваете эксперимент с расчётом по передаточным функциям. + +Полное задание — в **[task.md](task.md)**. + +--- + +## Что в репозитории ``` -labs/lab5-peredatochnaya-funkciya-na-ou/ -├── meta.yaml ← контракт -├── task.md ← задание студенту -├── method.md ← методичка преподавателю -├── README.md ← этот файл -├── print/ ← брошюры DOCX (rpd methodical) -└── stub/ - ├── 01-dds-generator-rp2040.ino ← генератор-свип (Arduino RP2040; - │ оба ядра: earlephilhower и mbed) - ├── 01-dds-generator-rp2040-circuitpython.py - │ ← то же на CircuitPython (code.py) - ├── 02-izmerenie-achh.py ← автоснятие АЧХ по Serial - └── 03-blank-otchyota.md ← бланк отчёта студента +eumirts5-dds/ +├── README.md ← этот файл +├── task.md ← задание (части A и B) +├── stub/ ← заготовки: генераторы и скрипт +│ ├── 01-dds-generator-rp2040.ino ← вариант 1: Arduino C++ +│ ├── 01-dds-generator-rp2040-circuitpython.py ← вариант 2: CircuitPython +│ ├── 04-dds-pio-rp2040.py ← вариант 3: PIO + DMA +│ ├── 02-izmerenie-achh.py ← автоснятие АЧХ (на ПК) +│ └── 03-blank-otchyota.md ← бланк отчёта +└── print/ ← брошюра задания (DOCX) ``` -## Новое относительно `topics/lab5-peredatochnaya-funkciya-na-ou.md` +### Какой вариант генератора выбрать -1. **Monte Carlo по допускам** (`.step param`) — новый для курса анализ - в симуляторе: разброс K и f_c при ±5 % на компонентах, доля годных. -2. **Генератор-свип на RP2040** — автономный генератор для натурной - части: прямоугольник (ШИМ) и синус (DDS), логарифмический свип, - синхроимпульс для осциллографа, измерение амплитуды на ADC0. -3. **Хост-скрипт автоснятия АЧХ** — управление генератором по Serial, - сбор точек, экспорт CSV, построение графика. -4. **Явное разделение практики и лаборатории:** симуляция — на практическом - занятии (2 ч), железо — на лабораторном (4 ч), отчёт — СРС. +| Файл | Кому | Особенности | +|---|---|---| +| `01-...ip2040.ino` | есть Arduino IDE | программный DDS, работает на обоих ядрах | +| `01-...-circuitpython.py` | есть CircuitPython | то же, но медленнее (интерпретатор) | +| `04-dds-pio-rp2040.py` | **рекомендуется** | аппаратный PIO + DMA, точная частота, CPU свободен | -## Предупреждения по стенду +Для варианта на PIO нужна библиотека `adafruit_pioasm` в папке `/lib` +на плате. -- **ADC0 RP2040: 0…3,3 В.** Сигнал с выхода исследуемой цепи ≥ 3,3 В - подавать **только через делитель** или пиковый детектор. -- **Питание ОУ:** LM358 работает при однополярном +5…+12 В, TL081 — - при ±12 В. Уточнить по фактическому стенду. -- **Синус DDS:** качество зависит от внешнего ФНЧ после ШИМ (см. схему - ниже). Без фильтра на выходе будет меандр переменной скважности, - а не синус. +--- -## Схема DDS-генератора (внешняя обвязка) +## Быстрый старт -ШИМ сам по себе даёт **меандр переменной скважности**, а не синус. -Синус получается выделением огибающей фильтром. Поэтому на выходе -GP15 нужен внешний **ФНЧ**: +1. **Прошить генератор.** Файл переименовать в `code.py` (для CircuitPython) + или открыть в Arduino IDE (для `.ino`). +2. **Открыть консоль**, ввести `HELP` — увидеть список команд. +3. **Собрать ФНЧ** на выходе генератора (схема в `HELP`): ``` -GP15 (ШИМ 62,5 кГц) ──[R1 1к]──┬──[R2 1к]──┬──────► OUT (вход DUT) - │ │ - [C1 47н] [C2 47н] - │ │ - GND GND +GP15 ──[R1 1к]──┬──[R2 1к]──┬──► к схеме + │ │ + [C1 47н] [C2 47н] + │ │ + GND GND ``` -Пассивный ФНЧ 2-го порядка, **f_c ≈ 3,4 кГц**: +4. **Проверить сигнал:** `W S` (синус), `F 1000`, посмотреть осциллографом + (период ≈ 1 мс). +5. **Калибровать частоту**, если нужно: ввести `CAL <измеренное_значение>`. +6. **Снять АЧХ** скриптом `02-izmerenie-achh.py` (запускается на ПК). -- подавление несущей: `(62,5 / 3,4)² ≈ 338 ≈ 50 дБ` → пульсации ≈ 10 мВ; -- рабочий диапазон генератора: **≈ 1 Гц … 1 кГц** (для звена - с f_c = 16…34 Гц — с запасом). +--- -### Две оговорки +## Основные команды генератора -1. **Согласование сопротивлений.** Выход фильтра ≈ 1 кОм, вход - интегратора — R1 = 10 кОм → делитель 10/11, просадка ≈ 9 %. - Если нужна точность — поставить буфер-повторитель на ОУ - (**Sallen–Key**: тот же 2-й порядок + низкое выходное). +| Команда | Что делает | +|---|---| +| `HELP` или `?` | подробная справка со всеми командами и схемой | +| `I` | текущие настройки: частота, факт, потолок, `CPC` | +| `W S` / `W Q` | режим синуса / прямоугольника | +| `F <Гц>` | частота | +| `M <0..127>` | амплитуда синуса | +| `D <0..255>` | скважность прямоугольника | +| `A` | измерить размах на ADC0 (GP26) | +| `SWEEP f0 f1 n мс` | логарифмический свип | +| `B <1..8>` | число бит порта | +| `N <16..4096>` | длина периода в сэмплах | +| `O <1..64>` | точек на сэмпл | +| `CLC <1..8>` / `CAL <Гц>` | калибровка `CPC` вручную / автоматически | +| `FN <имя>` / `FP <выражение>` | форма сигнала из набора / своя формула | -2. **Постоянная составляющая.** При скважности 50 % среднее = `Uпит/2` - = 1,65 В, синус смещён. Где смещение недопустимо — разделительный - `C = 1 мкФ` последовательно + `R = 47 кОм` на землю (ФВЧ ≈ 3,4 Гц), - либо уменьшить амплитуду командой `M`. +--- -### Варианты фильтра под другую несущую +## Пины стенда -| SAMPLE_HZ | R | C | f_c | -|---|---|---|---| -| 62,5 кГц | 1 кОм | 47 нФ | 3,4 кГц | -| 62,5 кГц | 2,2 кОм | 22 нФ | 3,3 кГц | -| 31,25 кГц | 2,2 кОм | 47 нФ | 1,5 кГц | +| Пин | Назначение | +|---|---| +| GP8..GP15 | выход генератора (данные выровнены по старшему биту, MSB → GP15) | +| GP15 | ШИМ в режиме прямоугольника (`W Q`) | +| GP16 | синхроимпульс для осциллографа | +| GP26 (A0) | вход измерения ADC | -## Как использовать +**Внимание:** на вход ADC подавать не более **3,3 В**. Сигнал больше — +только через делитель. -1. `task.md` + `stub/03-blank-otchyota.md` — студенту. -2. `stub/01-dds-generator-rp2040.ino` — прошить в RP2040 заранее - (или дать студенту в задании 4); альтернатива — `...-circuitpython.py` - на Pico с прошивкой CircuitPython (как `code.py`). -3. `stub/02-izmerenie-achh.py` — запускается на ПК студента (нужен pyserial). -4. `method.md` — только преподавателю. -5. Брошюры: `uv run rpd methodical lab5-peredatochnaya-funkciya-na-ou`. +--- -## Связи +## Порядок сдачи -- **Практика** `pr5-raschet-analogovyh-ustrojstv` — расчётная часть - (K, R1, R2, фильтры) выполняется там. -- **Лекция** `le5-metody-modelirovaniya` — типы анализа; Monte Carlo - и `.step` — прямое применение. -- **Лекция** `le5-proektirovanie-analogovyh-ustrojstv` — топология ОУ. -- **ТАУ** (`b1-o-23`) — передаточные функции и типовые звенья идут - параллельно в 5 семестре; эта работа даёт им физическую реализацию. +1. Собрать и откалибровать стенд (часть A). +2. Снять характеристики трёх схем (часть B). +3. Заполнить `stub/03-blank-otchyota.md`. +4. Ответить на контрольные вопросы (в конце `task.md`). -## Статус - -Черновик (draft). Тема `lab5-peredatochnaya-funkciya-na-ou` существует -в `topics/`; комплект в `labs/` — подготовлен для интеграции после -проверки преподавателем и согласования сквозной работы по мехатронике. +Баллы по заданиям — в `task.md`, раздел «Оформление и оценивание». +Зачёт при **6 и более** баллах из 10. diff --git a/print/methodical-lab5-peredatochnaya-funkciya-na-ou-student.docx b/print/methodical-lab5-peredatochnaya-funkciya-na-ou-student.docx index 16305c23fb129beb10319f03fa1c5d14ac7980eb..85ed0228909eac0a558bad0264f2f52ed1f31db1 100644 GIT binary patch delta 8014 zcmZ8`Ra6{dmo4t@4xw>t+$C6Wx1hnjaShT~;{ObTjF7-bo7J z>uNSm%~Lku4IjGQ!j0X#bkJtbLQZ%J}C{jn!h@$lRAXm7U8+s#UNxNi7zeElu%PgM3NGM`iZm z7-*?SQ3C7HkrGtKi8(1dUyV^SG1cfDX_Ra#Q@|v#TahE^zx00CgTdHf*e1?C zzJpi1-e~Mglua0~;B_7sjOqD;<_D7;_s%qU7V!g0$RNzPb0#hm<25Zr8wqB{DkkQn z`%9ZsJ`yAGp)>`uBgwg<390x-$t=Mh>vf^THRmqlFBK(HtPb!Ue(H~R)(F&vTdpKH zQ&%YoBvH<#B1Iz|tV|S}3q*CWYZ`%@*QRLfoRi^>SIMn&f9<#^T>>6CEs8&_+BK~DM`ex1U48_KJ zCSoY&>=Y|h!iryjJO)b+*A31-*QFlbBFK#PAToIJx?#1G9;r(VDSaU5hD!}9pBdgs zWa(nq;z?Rt+o$0pd&Xi^o|~KfVDXhx1$((>WyipK={Fbul)>tNcoFB8YLQje5FZ8V z?k)qCTOHUl8&}0~ARf&Ou6~b)WF%92!+HUYWMN4wOILX47?m+WCC>_W*WtRyM>t8j z^mbVL6mX&RW5MMk>a@+?@Gwn`tVQuVda~#^EIfMWIbNrJPSzUCGX7z$8%(~EbcS5# zfqqJ#W&ViBA2vH?nu0Y%rjDh{qArF0{_p4`Y~sP~ufG=T8R!#ltXaNHq?xNx?7+~q zKl3)q(2o8xz-Q!Nx+Q7iK)@cl5}t0I8#yl&)2A4nP*z6pj?W8X?L>XAhg)M&CO588 zI7Y^Lw2zvzDOHuhVx)+vTpt*>Y~@pm3g8H`+0qy|-E#40R%#YS^3WD6mS%yqCF3nH z%u4_l$A%S4!yt$}k?OYkp6ih4b=cY;wdd49fJ>H`7MLyLWDt!9b+gzez>OUJr}I$j5h+v}NdL1225)>(u6Jyzw| zRZmcrH)uutb!&8xQx_%>I}?e`49D9u$)C3e)2H$yQy8m{ROk}!dy~vhD9CxVc)|Pr zBQEr$G}SHqqej6z3L)IJzWz8fu)!3(4zft3v2Jus$04&x3G8D{WC%d+5*GBeN_FrN zGG=F#Zr@%&`xEInm(M7Y25X>>cVZY$q2M2ehW_k(OB(K{z|Hrf83GlRnx;-Ta zgddSW7zOqV4sG-XhHb{Umc2ig8B4&Upnk1y*L?d6UupLR3Q74KGtk6Oy6lJ3P*q^~ z&p7eAYiBS%=ct>R&dD3zeqQTn@!S4&Gx9`Q7T|0^idne(b zRXkM5f9;Z3H%R)_q;e`5%Jc4HQ2jHMoez%K`P9vtti3Tx{=v5LyE$;>a-J0&Io|hT zko}%E$-iy-MixDn>7w=taTPhN%LwG1WTECe0veADGP3 z1QIvzG+On4I_O*de8!&}H2f-rk_PtZBD>r(a*iQw-Az`Puc)IfUd55*xw5(;Npa#h zWkd5mDq+UN0*)$S^F}hubc&ma;F>OO7!n8&Bz_X3afy78#@Hb_tr}u z&I10#IMMeN4JK;@BLJ>ZSOSfM)OQgHb5-O}88WazlFlBsCAYT1DT-D%TL)=QL%PQA;005mW^sotn2J32pFI}Am>do-1C2VJ`?r?umWlB(i@;a*sYqN;V(!@QEZ zbHbexV+(a>;Tz&{{<%Ev{*J~^)(K2=r!QoGzX%tm6>Bp~9Ucw$Gq(%FSU05boOA|b z*r8hVKcPsFys_=n8AMu|=3|A14<5Xwr^-jbLPvadu0Zf`tDT3jfncN~vPJ3j2M$A) zfr9;E8DTMT&fG~NcT(l{kEDh?a zka*ekGh)h3C6l>2K7$2Dx5g_ul_WXhn$)c4(=^9sMEBVBME*n_-3eo;## z|7lAH)0j@L;Qvha3-E}uNiHQ}KL(L@5*uGLtr9=9w^_}Tn_+HKuMckZ{AS}*Z=;Ma zIYL?Gc9VE8zs4Oej>}AANtf-FsJ5$CnFK-;u%!g2Qr`wd%PkgiNAH>>SOk!w>Yhz%vRuw(*}sP?ws-AFBc zE39CiJ&X2Zb^YwX6xiB6xkHEG!!|JV8*xBDAEsizGBQ<1hJAhcI?0Lg0OBXS1v>KTHu}F>la5(zD==C+w9IX+z4My3ra?YDnsi{ z#tYt`_m-{2oOx$*`_y+0sd5eHK|CivQy1o~SGT~1xUZoj-eV-u2hs6!pP~|VJhY>s zehQ~&WmCd(rez{O^*H<`T^>Q0J2tNp7=sA@W2i9zCb%M_d%$q(Cy+ambmjit4g}H> z6a~=9+&gJByT~D7M+tej1ZUHBYr<~#(=?1k_|RF#9V_ng%+C)kQ7jZ4gr8u8#<-qgAw2=}H29RA zoo1Ah%SC#Qtm=NCd9^d2uIpn)7ug8lq9X3DokIw9rAG)jRxU*1cDw zNKkmY?inW951RW=tkOl7+CNa%A5|E9r&mCIvlnxJAW)hl2AJFQr z-)}$>hC7hO!a3XMn+_60P#-gi6JPu~6-NgXQ}I)3ZvhE2?#mp^VvT!p?N3bgVt7!g z=?-7o!tPm+394nPIWM=8UVMaSxLD)DU`{xIv$O&aEn@IQ)LI~XaouRP-dUMBgN-g7 zYV7#DRN2$5Mu#Xth&m5`k($oP6pBBDmk2)jeIao+1;IO!G8ZzQs7oVqUv~*?0ixs{ z0dq^!|A<43)u1j+nQY;=Cq@;0db>)(U{!HAXh9z*nhG=0y{cK?rl&Z7^+HHSbv&HU zge-)tCfF}~nboNa7Q=*qZz9L%o-?3w7f%f9=9+DC*90?8*a(xVR}9_^a_A zTj7|g5nYYQT*SsM+9O9S>t?&a|Ic9Q)96EW6VUQ!#zHA(D=VU<;i@k~?vZ#^cCsf6 z8%%qrlo^2Cxn>sKd+*~Or;T+g?!(?T+3Vx65QN$O0tkLxjq!V9#U4G!>V5K?4w8iJ zxSTkBpPKget-a0c&+@YSAJLk6sNVAPr>A(5H`lK;4LnyrO#|?U=AObh-`?x}K zP|RsLkp*7JZ8HU3QeMPi7BQh5e5eSkFn*)=0$QEL zr6ldpoI^Kd{oq3^{oruR_pwULzO-J_D4)rS1NHa^Y_qTaCc=v<& zkGjR=<27pT(p3-9=%Tc6gI{x+^yIys; zTzkN@dTNCFbW$sjXaIRwWU4DSg8+>q2#*zMzcb|wmXYlr;!rv$g9A-A6VUPr<%KQW~;nzpa%;aw(IE!U82p`FoFF?7Ux1AE(AR` zzlkLLN!9Qklsom#=onb)I-NaLTl;m5`wAXOi`+3q9^x{N4I3N_Gth%x0YxE--1e5D zO0n@c33~fNWSiPV5xz8W(quupIA$Mw_d^Wba2vcy#+O zXDD?9o~Ir*Fy4f(RLdU0MKcVIZ(6eU)@Mah<5?{sBNKri0WxeC7;dUn@nHKg8*n_} zcv~dvEi#Jte7la+nLmGEvvUdKpo=XUbtM&v!KSQO(#x8kA58|@ z_hSPgxb;DWy*M_NU%LPAFNS7UI-*NaeKU)D_G?~Joh1^g2A{8Loy!0!DR~b?#AA5G z!$)2sMQ~#ZOG)F2HdEe5BaCA`eK1qXETJM>lCp7hieh#0qO;sO__K?3nADxdT;;Xa zyDgUJohCMZIO_3KZHB=VT+!y7nIbsk9k)sP4|Qq;Kx;HC z6DzLoOMYlR`)w$Zyy0MQs93I?#qM5bVjiDcqry=O-*08vqk&#|^d*8H{opKm>FRXp z357=_(z4I)YJs$y+nAq2^b&9l;L zQ)CJI*wUPiQC=kp(P}kg(1uDyzgJON=+K~T)fABrlGwFPsS~^l}dC=!1 zJ2cX3uQ{%8{9&bh>27@jFAHAC`+SaeB}9*Vr4b5S)3W7$;eS@PLw{~0vW&LgAve1K zt@TX4`Qxd{I?%0bqE$oK?5wF38KAMYgVB^R%eUQ~jGn$ZhDi5;-&lw64Yu-bhRAJR ze({ILCVWnxxxQe+jhU97$eNEya_RlYjJ05?z{Art+2{9UQ8!zshkWOY}{9 zr<~xc=I(?aa3YOLDQ~x?usXzdLF0fL+gGE}L>_0TlTfreH?ZNs`|C$in|T`Hb?A@y z&$+>Md}X=qzr8OoD)hjFX$G#5$o>y3bLJH)La=j!=_9u21we$GwF#3%D)#fmbtNlf z1vDXopp`dd`8QKyq|JD!8Q@1*MLGczQTpFqi_lqhMn<2kPH5ZD4`#iUXApaKTNPOv z$+Z%X(-X?`+Xa@Zm|54NORdj@dgFY4 zv1il+kbPf!pH=qb+nHilfEo^8?AcU7<|9%kM+SeiDRG`BK~TyU7?tjArQeKYXA*$~ z;?LE{Ep~iFvSZ*P2FkXX#cBfl^hOoE33`WMhTJK&?JWaf;~fn|O=xNNr!oSyYG%RO zD3&+u7#ly$A$g66`6`y8F5%CjQ99!QqDmvxtUCh;hOAOObB@K?(uW*IFO--1vjgJH z%kKw9VNz5?>E>!yHAb)s93FsiO|b2C;W9sF7Nnx(CK$YbsR{BU%w)}C?aYrYw-!!x zkXS~VuUHgUD6t34L1ROt2_?uIs-n4%t4MD?Q;aC-`>4rQG1IP@^njuof9e#>Xaw-5 zr%UpK(rRI$qz6ZsvhcigDDc+1pX)Y3s5+i$d6>MBE zenZ~>-Qx+I(`F{ujQr}u^z#DIgULN^;k0Bxp5o;Ay-?y@~Ve68C$jvx7$Bt=enSq=f1=48y$Qm^N(XKH{g?JOipN`4Gp>k{6?q%q$|UVyRq!i>%4}f zK1D)#MAWZ=0^d`v9!t-mq}8S&R+ct*pURQQEr4nIr1f%4+@?^G2W=&I#qs+b?PB9j z^Y^nQE_s~h=ZP0hf|tIHnG&t^={(TbL8XmS-*<;YOYJ|T{3iAKhB`Y+;Z&{Z8ej`@ z-V)hwP&i+HP#HIqRHeV>rmgl|Epy&!&n3lJXV-Pp;&tz_-9vaRFGWl&0;lvs{r6DI zPv>n8E+IPvfhJI-^87&fXTEW`01Lk+pz4msj5<3f-?_lwRbe7K-Y$p&n(@{8ZEq*c z*~N#+`nWea0>HZU>nir~JR@YIq{{|-vg6f zMdjgkb#Og|xd-P+=|hvkx2hq!i@+DoYgGu#<=E~<@*m~S26s6FvtBJ^eDL1}>Xpv7eCM&OHB z-j81lqS=Mtw>yWel#J|`rYU`_SZJzj#Wa{w8@{9|mU_xH#c?*wJ78Jp&EWJz2{}`h z=VgpYffNb`;^nIc_o5wvTVOfiYhvGUG1uwrx(N`f9sIUx&9Xs$D`>t_Bw*Umqe`b2 z7Hla4@Ra6uHs#2o*YW9SGN#rKt&Sj@7J2`p$5CZVKh|??t+jw!NpI3+2e)d0Z~Yj` zO;d#B?vcu9lYN`pa$qU)s3FqfWLBGmo+b0p#O2!IxZV53J*?Aq9^5eL?OB6jzjHxZ zbGIe#lU6TZxoFqLa)!Op*bj067@&J?qhB#!Q4V_#vOcCfsBEg*Wrplc&t!uXo-5Z5 z-yR?;G0a>Mp+^bVWv=UcOX|g#dA~QvsV-agutjoiMtIx|VQ!%)w^q*ZG|c>zKub4c z>$6Q(>bj8I8%$>q7(k}68(wSDcI^hJ0Y@srpJVu?R>luVB8`qoPA^gWyb+=))-0ZG zAF-g%N;}{<>iPJ7HNb2t#E}gOqVV3GaUQh5@Lpl4ZKAepsMtH8O~nZa7Bh*^hlzP& zqV|uc($L7SHcwZ#r0PyI8umv1{WwHYyC)d<-sn~43{Cz4EWRwSE(d6c8gFIk>RLj; z?%F~XIONnazZmN%53Jty9!2>)%o*T8=}ICN=l3{pSMdcyy_6}Ofd-R3fM|Pq(Z(Sp zq^bz<6L5gcI}%s7f^i~C*RXaQvpOGuhwH4OIH=o;0UrlF8YG;*Pl|CZM7rWR4RP4X zoY^*L#FbS8S4t``&%vUa09?OlG^ym^(aLX!5!92w)6sgNDA(>?O?*o>Z2SFTtpok| z?nEZ~_vnkwY~y3lVm;5~i6zSvL(`42Q}A>NNB>FDtaEPm@U-2UayJ2xqfE1uGy88g z;NoePI$gHjp_zpEcT+|_WNa9cVt(;hze7bPG);RB9_aXC2ch>A~K#2H{W<>+IIn{Q%#Ft!qqEv>%EFeT7rDpj$A{I?7iu#JO&=;{3Y~qw#BZvkn=F;WNArjA-t6ekQ57ah4pxiRk`b!r56W>3A@Q za}~{xLi|6@hK<}(d-~YJ`l8;D|Gx;f>5$6qKZ&abO)l_X7<;4U#P^SwL(bs;5%nnG ze|kcq6X5a3Nlg<8?pxMcRB{cs! Y3;#<0{N#cx(&W_=93-dG|33SF0fGcc5C8xG delta 6811 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 — амплитуда синуса (127 = максимум) # A — однократно измерить размах на ADC0 -> "A <мВ>" # SWEEP -# — логарифмический свип; "P <Гц> <мВ>" + импульс на GP14 +# — логарифмический свип; "P <Гц> <мВ>" + импульс на GP16 # STOP — прервать свип # # ─── СХЕМА СИНУСА (обязательна: ШИМ даёт меандр, а не синус) ─── @@ -55,7 +55,10 @@ import pwmio import supervisor PIN_OUT = board.GP15 # выход сигнала (синус — через RC-фильтр) -PIN_TRIG = board.GP14 # синхроимпульс (для осциллографа) +PIN_TRIG = board.GP16 # синхроимпульс. НЕ GP14: он входит в + # диапазон PIO (GP8..GP15) в варианте + # 04-dds-pio-rp2040.py; GP16 — единая + # разводка стенда для всех трёх вариантов PIN_ADC = board.GP26 # ADC0 = A0 — вход измерения (через делителем) CARRIER_HZ = 125000 # несущая ШИМ — много выше f_s, биений нет @@ -221,6 +224,52 @@ def trigger_pulse(): trig.value = False +def print_help(): + """Подробная справка по командам с форматами и примерами.""" + lines = [ + "# ─────────── DDS-генератор: команды ───────────", + "# Обозначения: <..> — обязательный аргумент.", + "# Команда вводится строкой, Enter — конец строки.", + "#", + "# РЕЖИМ И ЧАСТОТА", + "# W Q — режим прямоугольника (аппаратный ШИМ)", + "# W S — режим синуса (программный DDS -> ШИМ -> ФНЧ)", + "# F <Гц> — частота прямоугольника, 1..200000", + "# пример: F 1000", + "# SF <Гц> — частота СИНУСА, 1..f_s/2 (защита от алиасинга)", + "# пример: SF 25", + "#", + "# ФОРМА СИГНАЛА", + "# D <0..255> — скважность прямоугольника (128 = 50 %)", + "# пример: D 64 (25 % включено)", + "# M <0..127> — амплитуда синуса (127 = максимум)", + "# пример: M 32 (четверть шкалы)", + "#", + "# ИЗМЕРЕНИЕ И СВИП", + "# A — размах на ADC0 (GP26), мВ -> ответ 'A <мВ>'", + "# SWEEP ", + "# логарифмический свип f0..f1 за n точек,", + "# пауза dwell_ms на точку; печатает 'P <Гц> <мВ>',", + "# в конце 'DONE'", + "# пример: SWEEP 10 100000 25 150", + "# STOP — прервать свип", + "#", + "# СПРАВКА", + "# HELP | ? — этот текст", + "#", + "# ─── СХЕМА (синус: ШИМ даёт меандр, нужен ФНЧ) ───", + "# GP15 --[R1]--+--[R2]--+--> во вход схемы", + "# | |", + "# [C1] [C2] R1=R2=1к, C1=C2=47н", + "# | | (f_c подобрать под вашу f_s)", + "# GND GND", + "# Постоянная составляющая при 50% = Uпит/2. ADC0 — до 3.3 В (делитель!).", + "# ─────────────────────────────────────────────", + ] + for ln in lines: + print(ln) + + def handle(line, phase): """Выполнить команду. Возвращает (ok, phase). Любые ошибки — внутрь.""" line = line.strip() @@ -229,7 +278,9 @@ def handle(line, phase): parts = line.split(None, 1) # позиционно: sep=None, maxsplit=1 (в CP нет kwargs!) cmd = parts[0].upper() a = parts[1] if len(parts) > 1 else "" - if cmd == "F": + if cmd in ("HELP", "?"): + print_help() + elif cmd == "F": cmd_f(a) elif cmd == "SF": cmd_sf(a) @@ -266,7 +317,8 @@ def calibrate_fs(phase): # ─────────────────────────── main ───────────────────────────── def main(): global fs_eff - print("# DDS generator ready (CircuitPython). help: F/W/D/A/SWEEP/STOP") + print("# DDS generator ready (CircuitPython). Подробный хелп: HELP или ?") + print_help() phase = 0 try: fs_eff, phase = calibrate_fs(phase) diff --git a/stub/01-dds-generator-rp2040.ino b/stub/01-dds-generator-rp2040.ino index 304f6bc..c07cde0 100644 --- a/stub/01-dds-generator-rp2040.ino +++ b/stub/01-dds-generator-rp2040.ino @@ -80,7 +80,10 @@ // ─────────────────────────── пины ─────────────────────────── #define PIN_OUT 15 // выход сигнала (прямоугольник — прямо; синус — через RC) -#define PIN_TRIG 14 // синхроимпульс (для осциллографа) +#define PIN_TRIG 16 // синхроимпульс (для осциллографа) + // ВАЖНО: не GP14 — он попадает в диапазон PIO в + // варианте 04-dds-pio-rp2040.py; для единой разводки + // стенда синхро держим на GP16 во всех вариантах #define PIN_ADC 26 // ADC0 = A0 — вход измерения (выход DUT через делитель!) // ─────────────────────── параметры DDS ────────────────────── @@ -269,12 +272,60 @@ static void runSweep(float f0, float f1, int n, uint32_t dwell_ms) { setFrequency((uint32_t)f0); // вернуться к началу } +// ─────────────────────── подробный хелп ────────────────────── +static void printHelp() { + Serial.println(F("# ─────────── DDS-генератор: команды ───────────")); + Serial.println(F("# Обозначения: <..> — обязательный аргумент.")); + Serial.println(F("# Все команды вводятся строкой, Enter — конец строки.")); + Serial.println(F("#")); + Serial.println(F("# РЕЖИМ И ЧАСТОТА")); + Serial.println(F("# W Q — режим прямоугольника (аппаратный ШИМ)")); + Serial.println(F("# W S — режим синуса (DDS -> ШИМ -> внешний ФНЧ)")); + Serial.println(F("# F <Гц> — частота выхода, 1..500000")); + Serial.println(F("# в режиме Q — частота прямоугольника;")); + Serial.println(F("# в режиме S — то же (для совместимости)")); + Serial.println(F("# пример: F 1000")); + Serial.println(F("# SF <Гц> — частота синуса с защитой от алиасинга")); + Serial.println(F("# ограничена SAMPLE_HZ/2 (см. шапку файла)")); + Serial.println(F("# пример: SF 25")); + Serial.println(F("#")); + Serial.println(F("# ФОРМА СИГНАЛА")); + Serial.println(F("# D <0..255> — скважность прямоугольника (128 = 50 %)")); + Serial.println(F("# пример: D 64 (25 % включено)")); + Serial.println(F("# M <0..127> — амплитуда синуса (127 = максимум)")); + Serial.println(F("# пример: M 32 (четверть шкалы)")); + Serial.println(F("#")); + Serial.println(F("# ИЗМЕРЕНИЕ И СВИП")); + Serial.println(F("# A — размах на ADC0 (GP26), мВ -> ответ 'A <мВ>'")); + Serial.println(F("# SWEEP ")); + Serial.println(F("# логарифмический свип от f0 до f1 за n точек,")); + Serial.println(F("# пауза dwell_ms на точку; на точку печатает")); + Serial.println(F("# 'P <Гц> <мВ>', в конце 'DONE'")); + Serial.println(F("# пример: SWEEP 10 100000 25 150")); + Serial.println(F("# STOP — прервать свип")); + Serial.println(F("#")); + Serial.println(F("# СПРАВКА")); + Serial.println(F("# HELP | ? — этот текст")); + Serial.println(F("#")); + Serial.println(F("# ─── СХЕМА (синус: ШИМ даёт меандр, нужен ФНЧ) ───")); + Serial.println(F("# GP15 --[R1]--+--[R2]--+--> во вход схемы")); + Serial.println(F("# | |")); + Serial.println(F("# [C1] [C2] R1=R2=1к, C1=C2=47н")); + Serial.println(F("# | | (f_c ~ 3.4 кГц)")); + Serial.println(F("# GND GND")); + Serial.println(F("# Постоянная составляющая при 50% = Uпит/2: нужен разделительный")); + Serial.println(F("# конденсатор, если смещение мешает. ADC0 — до 3.3 В (делитель!).")); + Serial.println(F("# ─────────────────────────────────────────────")); +} + // ─────────────────────── разбор команд ────────────────────── static void handleLine(char *line) { char *cmd = strtok(line, " \t"); if (!cmd) return; - if (!strcasecmp(cmd, "F")) { + if (!strcasecmp(cmd, "HELP") || !strcmp(cmd, "?")) { + printHelp(); + } else if (!strcasecmp(cmd, "F")) { char *a = strtok(nullptr, " \t"); if (a) setFrequency((uint32_t)strtoul(a, nullptr, 10)); Serial.print("OK F "); Serial.println(g_freq); @@ -323,7 +374,8 @@ void setup() { startSampleClock(); setMode(MODE_SQUARE); setFrequency(1000); - Serial.println("# DDS generator ready (RP2040). help: F/W/D/A/SWEEP/STOP"); + Serial.println("# DDS generator ready (RP2040). Подробный хелп: HELP или ?"); + printHelp(); } void loop() { diff --git a/stub/04-dds-pio-rp2040.py b/stub/04-dds-pio-rp2040.py new file mode 100644 index 0000000..2d644de --- /dev/null +++ b/stub/04-dds-pio-rp2040.py @@ -0,0 +1,1260 @@ +# DDS-генератор на PIO (RP2040) — CircuitPython +# +# Вариант 3 генератора для лабораторных работ. Совместим по системе команд +# с вариантами 1 (.ino, Arduino) и 2 (01-...-circuitpython.py), но сигнал +# для режима «синус» формирует аппаратная PIO-машина RP2040, данные в неё +# подаются через DMA, а АЦП читается библиотекой analogbufio. +# +# ЧЕРНОВИК (AI-сгенерировано, 2026-10-27). Требует проверки преподавателем +# на реальном стенде. +# +# Требует библиотеку: 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 +# или автоматически: 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 на точку; печатает '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)) diff --git a/task.md b/task.md index e0fe19e..9c86c8d 100644 --- a/task.md +++ b/task.md @@ -1,188 +1,240 @@ -# Лабораторная работа +# Лабораторная работа: DDS-стенд и исследование фильтров -## Интегратор и дифференциатор на ОУ: передаточная функция, АЧХ/ФЧХ и разброс по допускам +## Электронные устройства мехатронных и робототехнических систем **Дисциплина:** Электронные устройства мехатронных и робототехнических -систем (Б1.В.06, очная форма) -**Семестр:** 5 · **Объём:** 4 часа · **Тема:** `lab5-peredatochnaya-funkciya-na-ou` -**Форма:** черновик — в учебный план пока не привязана +систем, Б1.В.06 +**Специальность:** 15.03.06 «Мехатроника и робототехника» +**Семестр:** 5 · **Объём:** 4 часа +**Стенд:** RP2040 (Raspberry Pi Pico) + макетная плата -> AI-сгенерировано (2026-10-26), требует проверки преподавателем. +> Черновик. Требует проверки преподавателем перед выдачей студентам. --- -## 1. Легенда +## 1. Зачем эта работа -В мехатронном модуле сигнал датчика проходит через аналоговую обработку: -фильтрацию, интегрирование, выделение скорости (дифференцирование). +В мехатронном модуле сигнал датчика проходит аналоговую обработку: +фильтрацию помех, интегрирование, выделение скорости (дифференцирование). Каждое такое звено описывается передаточной функцией, и от его параметров (K, τ) зависит поведение всей системы управления. -**Задача работы:** убедиться, что аналоговое звено на операционном -усилителе действительно реализует расчётную передаточную функцию — сначала -в симуляторе, затем на макетной плате. И понять, как **допуски** -резисторов и конденсаторов влияют на параметры звена в серии. +Чтобы **проверить звено на железе**, нужен генератор с известной частотой +и амплитудой. Готового лабораторного генератора со свипом под рукой не +оказалось — поэтому в первой части работы вы **сами делаете стенд**: +DDS-генератор на RP2040 и скрипт автоматического снятия АЧХ. -Работа состоит из двух частей: +Во второй части вы этим стендом **исследуете фильтры** — интегратор, +дифференциатор и RC-фильтр — и сравниваете эксперимент с расчётом. -- **Симуляция** (LTSpice): АЧХ/ФЧХ и **новый анализ — Monte Carlo** по допускам; -- **Железо** (макет + генератор): переходные процессы и **автоматическое - снятие АЧХ** генератором-свипом на RP2040. +### Две части работы + +| Часть | Что делаем | Результат | +|---|---|---| +| **A. Стенд** | собираем и настраиваем DDS-генератор | работающий прибор: частота, амплитуда, свип | +| **B. Исследование** | снимаем характеристики фильтров | АЧХ/ФЧХ, сравнение с расчётом | + +Часть A без части B бессмысленна: прибор делается, чтобы им измерять. +Часть B без части A невозможна: нечем снимать АЧХ. --- ## 2. Теория кратко -**Интегратор на ОУ** (R1 на входе, C1 в ООС): +### 2.1. Что должен делать генератор (часть A) +**DDS** (Direct Digital Synthesis) — синтез сигнала из заранее +посчитанного массива значений. Идея: + +1. считаем один период нужной формы — например синуса — в массив + из `m` точек; +2. подаём массив в PIO по кругу через DMA; +3. PIO выводит точки в порт с частотой `f_пио`; +4. частота выходного сигнала: `f_вых = f_пио / (CPC · m)`, + где `CPC` — число тактов PIO на один сэмпл. + +Чем больше `m`, тем точнее форма и выше разрешение уровня, но тем ниже +максимальная частота. + +### 2.2. Точность уровня: битность ≠ разрешение + +- **Битность** `B` — сколько бит выводится в порт сразу (мгновенный код). +- **Эффективная разрядность** — сколько уровней различает **среднее** + после фильтра: `(2^B − 1)·m + 1`. + +При `B = 4` и `m = 256` порт даёт 4 бита, но среднее различает +3841 уровень (~11.9 бит). Так работает дельта-сигма: соседние коды +чередуются во времени, а ФНЧ усредняет. + +### 2.3. Интегратор на ОУ + +**Идеальный** (R1 на входе, C1 в ООС): + +``` W(s) = −1 / (R1·C1·s) +``` -**Реальный интегратор** (резистор утечки R2 параллельно C1) — это +На постоянном токе усиление бесконечно, поэтому **реальный** интегратор +всегда содержит резистор утечки R2 параллельно C1 — это превращает его в **апериодическое звено первого порядка**: +``` W(s) = −R2 / (R1 · (1 + R2·C1·s)) = −K / (τ·s + 1) +K = R2/R1, τ = R2·C1, f_c = 1/(2π·τ) +``` -где K = R2/R1, τ = R2·C1, f_c = 1/(2π·τ). +### 2.4. Дифференциатор на ОУ -**Дифференциатор** (C1 на входе, R1 в ООС): +C1 на входе, R1 в ООС: +``` W(s) = −R1·C1·s +``` -**Модель для работы:** K = −10, τ = 10 мс, f_c ≈ 16 Гц -(R1 = 10 кОм, R2 = 100 кОм, C1 = 0,1 мкФ). +На высоких частотах усиление растёт, поэтому на практике вход +ограничивают резистором последовательно с C1. + +### 2.5. RC-фильтр нижних частот + +Пассивный ФНЧ первого порядка: + +``` +K(f) = 1 / √(1 + (f/f_c)²), f_c = 1/(2π·R·C) +наклон за f_c: −20 дБ/дек +``` --- -## 3. Задания +## 3. Часть A. Сборка и настройка стенда -### Задание 1 (1 ч). Симуляция: АЧХ и ФЧХ +### Задание A1 (0,5 ч). Прошивка генератора и первый запуск -В LTSpice собрать интегратор с резистором утечки: R1 = 10 кОм, -R2 = 100 кОм, C1 = 0,1 мкФ (R2 || C1 в ООС). +В папке `stub/` три варианта генератора — **выберите один**: -1. Задать анализ `.ac dec 20 1 100k`. -2. Снять АЧХ в децибелах и ФЧХ. -3. По графику определить: - - усиление на плоском участке (должно быть ≈ 20 дБ, т. е. K = 10); - - частоту сопряжения f_c (где спад −3 дБ); - - наклон спада (ожидается −20 дБ/дек). -4. Сравнить с расчётом по формулам из п. 2. +| Файл | Платформа | Как формируется синус | +|---|---|---| +| `01-...ip2040.ino` | Arduino C++ | программный DDS + ШИМ | +| `01-...-circuitpython.py` | CircuitPython | программный DDS + ШИМ | +| `04-dds-pio-rp2040.py` | CircuitPython | **аппаратный PIO + DMA** | -### Задание 2 (1 ч). Симуляция: Monte Carlo по допускам ⭐ +**Рекомендуется вариант на PIO** — он даёт точную частоту и не грузит CPU. -**Новый анализ.** Реальные резисторы и конденсаторы имеют допуск ±5 % -(ряд E24 — не лучше). Оценим разброс параметров звена. +1. Залейте файл на Pico (CircuitPython: переименовать в `code.py`). +2. Откройте консоль, введите `HELP` — увидите список команд. +3. Введите `I` — генератор покажет текущие настройки, включая + **фактическую** частоту и потолок `f_max`. +4. Соберите внешний ФНЧ на выходе (схема — в `HELP`): -1. Задать разброс: `.step param tol list -5 -2.5 0 2.5 5` и применить - его к номиналам (например `{100k*(1+tol/100)}` для R2, аналогично R1 и C1). - Либо, для полноценного статистического анализа, использовать - `.step param run 1 100 1` с генератором случайных отклонений. -2. Прогнать **не менее 100 прогонов**. -3. Для каждого прогона зафиксировать K (усиление на плоском участке) и f_c. -4. Построить гистограммы распределения K и f_c. -5. Посчитать **долю экземпляров**, укладывающихся в полосу ±10 % от - номинального значения K и f_c. -6. Сделать вывод: **какой компонент** вносит наибольший вклад в разброс - и почему. +``` +GP15 ──[R1 1к]──┬──[R2 1к]──┬──► выход + │ │ + [C1 47н] [C2 47н] + │ │ + GND GND +``` -### Задание 3 (1 ч). Железо: переходные процессы +**Проверка:** подать `W S` (синус), `F 1000`, посмотреть осциллографом. +Период должен быть ≈ 1 мс. -Собрать на макетной плате: +### Задание A2 (0,5 ч). Калибровка частоты -**3.1. Интегратор** (R1 = 10 кОм, C1 = 0,1 мкФ): +Частота зависит от `CPC` — числа тактов PIO на сэмпл. Оно может отличаться +на разных прошивках. -- подать прямоугольник 1 кГц (от RP2040-генератора или лабораторного); -- наблюдать пилообразный сигнал на выходе; -- замерить размах пилы и сравнить с - U_m = U_вх · T / (2 · R1 · C1); -- убедиться, что при росте частоты размах падает обратно пропорционально. +1. Поставьте `F 1000`, измерьте период осциллографом. +2. Если факт отличается от запроса — введите `CAL <измеренное_значение>`, + например `CAL 500` при запросе 1000. Генератор подстроит `CPC` сам. +3. Повторите замер — теперь должно совпасть. +4. **Запишите** подобранное `CPC` (видно командой `I`) — оно нужно для + расчёта погрешности в отчёте. -**3.2. Дифференциатор** (C1 = 0,1 мкФ на входе, R1 = 10 кОм в ООС): +> Если частота вообще не достижима, генератор скажет +> `! ЗАПРОС ВЫШЕ ПОТОЛКА: факт ... Гц`. Это значит `m` слишком велико: +> уменьшите `N` или `O`. -- подать пилу 1 кГц; -- наблюдать импульсы в моменты перепада; -- сравнить амплитуду с U_m = R1·C1 · dU_вх/dt. +### Задание A3 (0,5 ч). Автоматическое снятие АЧХ -**3.3. Роль резистора утечки:** собрать интегратор с R2 = 100 кОм -параллельно C1 и без него; объяснить, что меняется на постоянном токе. +1. Соберите на макетной плате простой RC-фильтр (см. часть B, задание B3). +2. Подключите: выход генератора → вход фильтра; выход фильтра → + вход измерения (ADC0, GP26). **Если сигнал больше 3,3 В — через делитель.** +3. Запустите хост-скрипт: -### Задание 4 (1 ч). Железо: автоматическое снятие АЧХ +``` +python 02-izmerenie-achh.py --port /dev/ttyACM0 \ + --f0 10 --f1 100000 --n 25 --dwell 150 --uin 500 --out achh.csv --plot +``` -Использовать подготовленный генератор-свип: +4. Получите CSV с точками «частота → амплитуда». Это ваш измерительный + прибор на вторую часть работы. -- **`stub/01-dds-generator-rp2040.ino`** — прошить в RP2040 (Raspberry Pi - Pico) в Arduino IDE (работает в обоих ядрах: earlephilhower и официальном - mbed). Прямоугольник (ШИМ) и синус (DDS) + логарифмический свип - + измерение амплитуды на ADC0. -- **`stub/01-dds-generator-rp2040-circuitpython.py`** — альтернатива без - Arduino: положить файл на Pico с прошивкой CircuitPython (переименовать - в code.py). Протокол команд тот же. -- **`stub/02-izmerenie-achh.py`** — хост-скрипт: запускает свип по Serial, - собирает точки, строит АЧХ. +**Результат части A:** работающий стенд, калиброванная частота, скрипт, +дающий АЧХ в CSV. -Порядок: +--- -1. Прошить генератор, проверить команды `F`, `W S`, `M`, `A` в мониторе - порта. -2. **Собрать внешний ФНЧ** на выходе генератора (без него будет меандр, - а не синус): +## 4. Часть B. Исследование фильтров - ``` - GP15 ──[R1 1к]──┬──[R2 1к]──┬──────► вход схемы - │ │ - [C1 47н] [C2 47н] - │ │ - GND GND - ``` +Все три задания выполняются **одним и тем же стендом**, меняется только +исследуемая схема. Для каждой схемы: - Это пассивный ФНЧ 2-го порядка, f_c ≈ 3,4 кГц; он подавляет несущую - ШИМ 62,5 кГц примерно на 50 дБ. **Зарисовать фильтр в отчёт.** +1. рассчитать параметры (K, τ или f_c) по формулам из п. 2; +2. собрать схему на макетной плате; +3. снять АЧХ стендом (частичный диапазон вокруг f_c, 8–12 точек); +4. наложить экспериментальные точки на расчётную кривую; +5. оценить расхождение и объяснить его. -3. Выход фильтра → вход схемы; выход схемы → вход измерения (ADC0). - **Если сигнал превышает 3,3 В — только через делитель.** -4. Запустить: - ``` - python 02-izmerenie-achh.py --port /dev/ttyACM0 \ - --f0 10 --f1 100000 --n 25 --dwell 150 --uin 500 --out achh.csv --plot - ``` -5. Импортировать CSV, нанести точки на модельную кривую из задания 1. -6. Объяснить расхождения. +### Задание B1 (1 ч). Интегратор на ОУ -> **Смещение:** при скважности 50 % среднее на выходе ШИМ = `Uпит/2` -> = 1,65 В, поэтому синус смещён относительно земли. Если это мешает -> измерению — поставьте разделительный `C = 1 мкФ` + `R = 47 кОм` -> на землю, либо уменьшите амплитуду командой `M`. +**Схема:** R1 = 10 кОм на вход, R2 = 100 кОм ‖ C1 = 0,1 мкФ в ООС. -> Свип-генератор — студенческая заготовка (черновик). Если на стенде -> есть серийный функциональный генератор с режимом свипа/логарифмической -> развёртки — можно использовать его, принцип тот же. +- Рассчитать: `K = R2/R1`, `τ = R2·C1`, `f_c = 1/(2π·τ)`. +- Снять АЧХ в диапазоне **1 Гц … 10·f_c**. +- Определить по графику плоский участок, частоту сопряжения, наклон. +- **Проверить форму во времени:** подать прямоугольник, увидеть пилу. + Размах пилы `U_m = U_вх·T/(2·R1·C1)`. -### Задание 5 (СРС / отчёт). Сведение результатов +**Контрольный вопрос:** почему `f_c` определяется `R2·C1`, а не `R1·C1`? -Свести в одну таблицу «**расчёт / симуляция / железо**» параметры -K, τ, f_c. Построить совмещённый график АЧХ (три кривые). +### Задание B2 (1 ч). Дифференциатор на ОУ + +**Схема:** C1 = 0,1 мкФ на вход, R1 = 10 кОм в ООС. + +- Рассчитать `W(s) = −R1·C1·s`, ожидаемый наклон **+20 дБ/дек**. +- Снять АЧХ в диапазоне, где схема ещё устойчива. +- **Проверить во времени:** подать пилу — увидеть импульсы в моменты + перепада. Амплитуда `U_m = R1·C1·dU_вх/dt`. +- Объяснить, почему реальный дифференциатор требует ограничения входа. + +**Контрольный вопрос:** чем отличается идеальный дифференциатор от +реального и почему вход ограничивают резистором? + +### Задание B3 (0,5 ч). Пассивный RC-фильтр + +**Схема:** R = 1 кОм, C = 0,1 мкФ (или по вашему варианту). + +- Рассчитать `f_c = 1/(2π·R·C)`, ожидаемый наклон −20 дБ/дек. +- Снять АЧХ стендом в диапазоне **0,1·f_c … 100·f_c** (10–12 точек). +- Наложить на расчётную кривую `K(f) = 1/√(1+(f/f_c)²)`. +- Сравнить расхождение с допусками компонентов (±5 %). + +**Контрольный вопрос:** почему реальная АЧХ за `f_c` уходит круче +−20 дБ/дек? (подсказка: паразитные параметры, вход ADC) + +### Задание B4 (0,5 ч). Сравнение и отчёт + +Свести результаты трёх схем в таблицу: + +| Схема | f_c расчёт | f_c опыт | Расхождение | Наклон расчёт | Наклон опыт | +|---|---|---|---|---|---| +| Интегратор | | | | −20 дБ/дек | | +| Дифференциатор | | | | +20 дБ/дек | | +| RC-фильтр | | | | −20 дБ/дек | | Сделать выводы: -- причины расхождения расчёта и симуляции; -- причины расхождения симуляции и железа; -- что даёт анализ Monte Carlo для реального проектирования. - ---- - -## 4. Варианты - -| № | R1, кОм | R2, кОм | C1, мкФ | K | τ, мс | f_c, Гц | -|---|---------|---------|---------|---|-------|---------| -| 1 | 10 | 100 | 0,1 | 10 | 10 | 15,9 | -| 2 | 10 | 47 | 0,1 | 4,7 | 4,7 | 33,9 | -| 3 | 4,7 | 100 | 0,1 | 21 | 10 | 15,9 | -| 4 | 10 | 100 | 0,047 | 10 | 4,7 | 33,9 | -| 5 | 1 | 47 | 1,0 | 47 | 47 | 3,4 | -| 6 | 22 | 220 | 0,022 | 10 | 4,84 | 32,9 | - -*Примечание. Номиналы приведены к ряду E24. Вариант 2 и 4 дают одинаковую -f_c при разном K — удобно для сравнения вклада параметров.* +- какой разброс дали допуски компонентов; +- где опыт расходится с расчётом сильнее всего и почему; +- какую роль сыграла калибровка генератора (задание A2). --- @@ -190,51 +242,56 @@ f_c при разном K — удобно для сравнения вклад Отчёт содержит: -1. Схему интегратора и дифференциатора (KiCad или от руки). -2. АЧХ и ФЧХ из LTSpice. -3. Гистограммы Monte Carlo (K и f_c) + доля годных. -4. Осциллограммы переходных процессов (пила, импульсы). -5. CSV-точки автоматического свипа + совмещённый график АЧХ. -6. Итоговую таблицу «расчёт / симуляция / железо». -7. Выводы. +1. Схемы: ФНЧ стенда, интегратор, дифференциатор, RC-фильтр. +2. Настройки генератора из `I` (включая `CPC` после калибровки). +3. Осциллограммы: синус с генератора, пила интегратора, импульсы + дифференциатора. +4. Три графика АЧХ с наложенными расчётными кривыми. +5. Итоговую таблицу из задания B4. +6. Выводы по расхождениям. | Задание | Баллы | -|---------|-------| -| 1. Симуляция АЧХ/ФЧХ | 2,0 | -| 2. Monte Carlo | 2,5 | -| 3. Железо: переходные процессы | 2,0 | -| 4. Железо: автоснятие АЧХ | 2,0 | -| 5. Отчёт: сведение | 1,5 | +|---|---| +| A1. Прошивка и первый запуск | 1,0 | +| A2. Калибровка частоты | 1,0 | +| A3. Автоснятие АЧХ | 2,0 | +| B1. Интегратор | 2,0 | +| B2. Дифференциатор | 2,0 | +| B3. RC-фильтр | 1,0 | +| B4. Сводка и выводы | 1,0 | | **Итого** | **10** | -Зачёт — при **6 и более** баллах. Расхождение с эталоном ±10 % -(допуски компонентов). +Зачёт — при **6 и более** баллах. --- ## 6. Контрольные вопросы -1. Чему равна передаточная функция идеального интегратора и почему без - резистора утечки она нереализуема на практике? -2. Как по АЧХ отличить идеальный интегратор от апериодического звена - первого порядка? -3. Почему частота сопряжения определяется R2·C1, а не R1·C1? -4. Как связаны наклон АЧХ в дБ/дек и порядок звена? -5. Какой компонент даёт наибольший вклад в разброс f_c при допуске ±5 % - и почему? -6. Как на форме переходного процесса сказывается конечная полоса - пропускания операционного усилителя? +**По стенду (часть A):** + +1. Чему равна частота выходного сигнала DDS и какие величины её задают? +2. Что такое `CPC` и почему его приходится калибровать? +3. Чем битность порта отличается от эффективной разрядности? +4. Почему для синуса нужен внешний ФНЧ после ШИМ? + +**По фильтрам (часть B):** + +5. Чему равна передаточная функция идеального интегратора и почему без + резистора утечки её нельзя реализовать? +6. Как по АЧХ отличить идеальный интегратор от апериодического звена? +7. Почему наклон АЧХ звена первого порядка равен −20 дБ/дек? +8. Как допуски R и C влияют на `f_c` и что даёт анализ Monte Carlo? --- ## 7. Литература 1. Хоровиц П., Хилл У. Искусство схемотехники. 3-е изд. М.: Бином, 2014. - Гл. 4. + Гл. 4 (ОУ в линейном режиме). 2. Титце У., Шенк К. Полупроводниковая схемотехника. 12-е изд. М.: ДМК - Пресс, 2008. Т. 1, гл. 11. + Пресс, 2008. Т. 1, гл. 11 (частотные характеристики). 3. Бесекерский В.А., Попов Е.П. Теория систем автоматического - регулирования. М.: Наука, 2010. Гл. 2. + регулирования. М.: Наука, 2010. Гл. 2 (типовые звенья). 4. LTSpice XVII User Guide. Analog Devices. -5. LM358 / TL081 datasheet. Texas Instruments. -6. RP2040 Datasheet. Raspberry Pi Ltd. +5. RP2040 Datasheet. Raspberry Pi Ltd (PIO, DMA, ADC). +6. LM358 / TL081 datasheet. Texas Instruments.