Стенд ручного управления я собирал по готовой схеме: потенциометр на аналоговом входе, серво на D11, одна строка write(map(analogRead(...))) — и по идее всё уже должно было работать. Работало, но как-то стыдно: ручка стояла неподвижно, а серво мелко тикала на месте, стоит руке дрогнуть — рог стрелял на пару градусов в сторону. На видео такие стенды выглядят шелковыми, а у меня механика вела себя как испорченный замок в двери. Полчаса я честно подозревал брак потенциометра, пока не вывел показания в Serial: при неподвижной ручке значения гуляли на две-четыре единицы АЦП туда-сюда.
Проблема тут двойная, и лечится она двумя разными слоями. Первый слой — вход: показания АЦП никогда не стоят на месте, щётка потенциометра скользит по дорожке с шумом, рука дрожит физиологически, и прямое отображение чтений в угол превращает каждый щелчок в команду «поезжай». Второй слой — профиль движения: даже с чистым входом серво, едущая от 30° к 140° с постоянной скоростью, выглядит как шлагбаум в метро, а не как живая механика.
Линейную интерполяцию на фиксированном шаге мы разобрали в уроке про плавное движение, а её версию без единого delay — в уроке про интерполяцию без delay; здесь следующий этаж: скользящее среднее на входе и easing на выходе — движение, которое разгоняется и тормозит. Всё это собирается в один скетч из сорока строк и сразу пробуется в симуляторе.
Слой первый: скользящее среднее на восьми замерах
Скользящее среднее — самый дешёвый фильтр из существующих: держим последние восемь чтений АЦП и работаем с их суммой, делённой на восемь. Каждые 20 миллисекунд в буфер входит новое чтение, старое вылетает, сумма правится двумя операциями вместо пересчёта с нуля: summa = summa − старое + новое. Буфер кольцевой — указатель доходит до края и возвращается в начало, память одна и та же восемь ячеек. Для контроллера это примерно ничего: пара сложений и обращение к массиву.
Цена фильтра — запаздывание. Окно из восьми замеров при периоде 20 мс — это 160 мс задержки между движением ручки и реакцией серво; рука её не замечает, а глаз видит, как серво «плывёт» за целью мягко, без нервных рывков. У моего стенда дрожь в две-четыре единицы АЦП среднее из восьми сжимает до долей единицы: map растягивает АЦП в 180 градусов с коэффициентом почти шестая часть градуса на единицу, и четыре единицы дрожи — это меньше градуса на выходе. Не ноль, но уже ниже порога, который замечает механика.
Окно подбирается под ось, и это тот случай, где мёртвая зона из урока про гистерезис потенциометра хорошо работает в паре со средним: фильтр убирает дрожь, мёртвая зона — то, что из неё выжило. Четыре замера — быстро, но нервно; тридцать два — серво реагирует так, будто думает; больше шестнадцати на оси, которой управляет человек, я не ставлю никогда. Для рычага камеры мои восемь, для тяжёлой роборуки с редуктором — двенадцать.
Слой второй: easing — шаг пропорционален остатку
Идея easing в одной строке: на каждом тике серво проезжает не фиксированный градус, а долю оставшегося пути. Остаток большой — шаг большой, серво разгоняется; остаток тает — шаг тает вместе с ним, и последние градусы серво проползает почти бесшумно, аккуратно доводя механику. Математически позиция умножает остаток на 0,92 каждые 20 миллисекунд, и разница между этим и линейным ходом видна с трёх метров: линейная версия доезжает до упора и стучит, easing приходит как лифт с доводкой дверей.
Доля K_EASE = 0,08 подобрана под ощущение «живого» привода: с ней ход в 90 градусов занимает около полутора секунд, из которых первая треть пути проходится стремительно, а хвост растягивается. Хотите служебный тон — поднимите до 0,15, хотите театральность — опустите до 0,04. К этой же теме примыкает скорость, вычисленная из потенциометра: там ползунок задаёт темп линейного шага, здесь он задаёт цель, а темп рождается из профиля.
Полезно один раз просчитать easing на бумаге, без перебора наугад. Каждый тик умножает остаток на 0,92: за 25 тиков — полсекунды — остаток сжимается примерно в двенадцать раз, а ход в 90 градусов добирается до MIN_SHAG за 65 тиков, те самые полторы секунды из практики. Цифры удобны при подборе темпа: медленнее вдвое — делите K_EASE пополам или растягивайте период тика до 40 мс; на тяжёлой оси я выбрал второй путь, серво такие паузы отрабатывает не хуже. А вот долю больше трети за тик не ставьте: ход вырождается в короткие прыжки, и тихая доводка умирает.
#include <Servo.h> Servo rulevoy; // серво оси, D11 const int PIN_POT = A0; // ручка заданияconst int OKNO = 8; // окно скользящего среднегоconst unsigned long T_FILTR = 20; // период опроса ручкиconst unsigned long T_HOD = 20; // период тика easingconst float K_EASE = 0.08; // доля остатка за один тикconst float MIN_SHAG = 0.4; // ближе цели — досыпаем до конца int bufer[8]; // кольцевой буфер замеровint idx = 0;long summa = 0;float tsel = 90; // отфильтрованная цель, градусыfloat poz = 90; // текущая позиция с дробьюint celyaPech = -1; // последний целый градус, ушедший в write unsigned long tF = 0;unsigned long tH = 0;unsigned long tLog = 0; void setup() { rulevoy.attach(11); rulevoy.write(90); for (int i = 0; i < OKNO; i++) { // наполняем окно сразу bufer[i] = analogRead(PIN_POT); summa = summa + bufer[i]; } tsel = summa * 180.0 / 1023.0 / OKNO; poz = tsel; rulevoy.write((int)(poz + 0.5)); Serial.begin(9600); Serial.println("easing: okno 8, K 0.08");} void loop() { unsigned long now = millis(); if (now - tF >= T_FILTR) { // слой 1: фильтр входа tF = now; int nov = analogRead(PIN_POT); summa = summa - bufer[idx] + nov; bufer[idx] = nov; idx = idx + 1; if (idx >= OKNO) idx = 0; // кольцо замкнулось tsel = summa * 180.0 / 1023.0 / OKNO; } if (now - tH >= T_HOD) { // слой 2: easing к цели tH = now; float ostatok = tsel - poz; if (abs(ostatok) <= MIN_SHAG) { poz = tsel; // хвост — досыпаем и стоим } else { poz = poz + ostatok * K_EASE; } int celaya = (int)(poz + 0.5); if (celaya != celyaPech) { // write только на смене градуса rulevoy.write(celaya); celyaPech = celaya; } } if (now - tLog >= 500) { // журнал: цель и позиция tLog = now; Serial.print("tsel="); Serial.print(tsel); Serial.print(" poz="); Serial.println(poz); }}Пройдём по решениям. Два независимых таймера на millis — принцип из урока про millis вместо delay: фильтр живёт на своих 20 мс, ход — на своих, и они не обязаны совпадать. Позицию я держу во float и пишу в серво только тогда, когда целый градус изменился: библиотека Servo принимает целое, а если писать округлённое значение каждого тика, дробные хвосты теряются — об этом отдельная грабля ниже. MIN_SHAG в 0,4 градуса нужен, чтобы геометрическая прогрессия не сходилась вечно: без него серво бесконечно ползла бы на микро-шагах, которые не может отработать.
Ноль в setup — важная деталь старта: буфер наполняется восемью реальными чтениями, и цель сразу стоит там, где ручка, — серво не делает холостой рывок от условных 90 градусов к фактическому положению. Я однажды сэкономил на этом цикле и получил серво, которая при каждом включении отстреливала на треть хода: фильтр с пустым окном выдавал мусорную цель первые две сотни миллисекунд.
Журнал раз в 500 мс печатает цель и позицию рядом — по нему видно всё воспитание: цель прыгнула на сорок градусов, позиция догоняет с торможением, разница съёживается на глазах. Если разница после затихания не ноль, а гуляет — окно мало или шум входа чудовищен, и пора добавлять мёртвую зону из гистерезисного урока.
| Подход | Ход | Что видит глаз | Где уместно |
|---|---|---|---|
| write(map(...)) напрямую | мгновенный скачок | дёргается на каждый щелчок АЦП | переключатель на два положения |
| линейный шаг 1° за тик | постоянная скорость | ровно, но старт и стоп со стуком | ворота, кран, конвейер |
| easing, K = 0,08 | разгон и тихая доводка | механика ведёт себя как живая | манипуляторы, поворот камер |
| среднее из 8 + easing | запаздывание 160 мс | дрожь входа исчезает целиком | ручное управление с потенциометра |
В симуляторе вход идеален, и может показаться, что среднее — перестраховка. На живой плате разница видна в первый вечер: щёточный потенциометр на три доллара живёт своей жизнью, и без фильтра серво тикает на каждую пылинку на дорожке. Второй сюрприз живой платы — характер шума: бывают одиночные выбросы, когда АЦП на одно чтение выдаёт чушь. Скользящее среднее размазывает такой выброс по восьми тикам, но не убирает; если выбросы случаются, ставьте медиану из трёх замеров, как в уроке про стрелку-уровнемер бака, — она одиночную чушь отбрасывает целиком.
Отдельная история — тяжёлые механики, где easing одной константой не закрывается. Роборука с плечом в триста граммов на рычаге в двадцать сантиметров разгоняется охотно, а вот тормозить на ходу уже не успевает: инерция доезжает за рамкой профиля, и позиция переливается через цель. Для таких осей я добавляю потолок скорости: шаг за тик обрезается сверху, скажем, тремя градусами, и получается трапеция — разгон от easing, полка с потолком, доводка на хвосте. Это уже четверть шага к промышленным приводам, и для камеры с ветровым грузом я считаю его обязательным, а не украшением.
И последнее, про сохранение руки: те же два слоя работают на любой оси, которой управляет человек, — джойстик, энкодер, слайдер. Разница только в окне и в K_EASE, а вся остальная механика воспитания серво переносится без изменений. Я держу этот скетч как шаблон в отдельной папке: новые оси начинаются с копирования и подбора двух констант, а не с чистого листа.
- Дёрганье серво за ручкой — два слоя проблем: шумный вход и резкий профиль движения
- Скользящее среднее из 8 замеров стоит два сложения и даёт 160 мс запаздывания при опросе раз в 20 мс
- Easing: серво за тик проезжает долю остатка (K = 0,08) — 90° примерно за полторы секунды с тихой доводкой
- Позиция живёт во float, write зовётся только на смене целого градуса — иначе хвосты не доезжают
- Одиночные выбросы АЦП среднее не убирает — для них есть медиана из трёх замеров
- 1. Ручка потенциометра неподвижна, а серво тикает на месте. Что показывает Serial?
- 2. Сколько стоит скользящее среднее из восьми замеров с периодом 20 мс?
- 3. Что делает easing с ходом серво?
- 4. Почему позицию держат во float, если write принимает целые?
- 5. В Serial виден одиночный дикий выброс АЦП раз в минуту. Что поможет лучше среднего?
Частые вопросы по теме урока
- Ручка потенциометра неподвижна, а серво тикает на месте. Что показывает Serial?
- У моего стенда дрожь была в две-четыре единицы АЦП: щётка и сам преобразователь шумят даже в покое. map растягивает эту дрожь в градусы, и серво честно едет на каждый скачок. Показания гуляют на несколько единиц — вход шумит
- Сколько стоит скользящее среднее из восьми замеров с периодом 20 мс?
- Сумма правится двумя операциями: минус старое чтение, плюс новое. Цена — запаздывание, равное окну на период: 8 × 20 мс = 160 мс, рука её не замечает. Два сложения на тик и 160 мс запаздывания реакции
- Что делает easing с ходом серво?
- На каждом тике позиция сокращает остаток на долю K_EASE. Остаток большой — шаг большой, остаток тает — серво проползает последние градусы почти бесшумно. Шаг пропорционален остатку пути — быстрый старт, тихая доводка
- Почему позицию держат во float, если write принимает целые?
- Если писать округлённое значение каждого тика, шаг меньше полуградуса теряется в округлении, и хвост не доезжает никогда. Дробь копим сами, write зовём на смене целого. Чтобы дробные хвосты накапливались и серво доезжала до цели
- В Serial виден одиночный дикий выброс АЦП раз в минуту. Что поможет лучше среднего?
- Медиана выброс отбрасывает целиком: две честные замера перекрывают одну чушь. Среднее же размазывает выброс по окну — тем меньше, чем шире окно, но не убирает. Опросить вход сто раз за тик и взять максимум