Ночник в коридоре на фоторезисторе я собрал за час, а доводил неделю. Днём всё было тихо, но в сумерках реле начинало кашлять: щёлкнет, через полминуты щёлкнет обратно. Я вывел показания в монитор и увидел ожидаемое: сырые значения стояли ровно на пороге — 247, 253, 249, 252 при пороге 250. Датчик не был сломан: последние биты analogRead и не обязаны стоять как вкопанные.
Откуда дрожь. Даже при стабильном питании младшие один-два бита любого АЦП живут своей жизнью: кристалл греется и шумит, земля макетки гуляет, провода цепляют наводки, а один бит при 5 В — это 4,88 мВ, уровень, который легко набрать из помех. У чистого источника прыгает один бит, у макетки с моторчиком рядом — два-три. Это не брак, это физика 10-битного преобразователя.
Лечится всё на стороне скетча, и дорог три: усреднить кучу замеров на месте, вести бегущее окно последних значений или завести экспоненциальный фильтр в одну переменную. В уроке — все три, с ценой каждой в микросекундах, байтах и секундах запаздывания.
Наивное среднее: честные числа, враньё во времени
Первая идея у меня была самая прямолинейная: набрать сто замеров, поделить на сто. Работает — и подкладывает свинью. Каждый analogRead стоит около 112 мкс — замеряли ещё в уроке про потенциометр, — значит сотня замеров держит loop в плену 11 миллисекунд на каждый запрос значения. Но хуже другое: за эти 11 мс освещение успевает измениться, и среднее отвечает за прошлое. Порог в таком цикле отрабатывает с запозданием, а плата всё время сбора не видит ни кнопок, ни Serial.
Правильная версия того же среднего — бегущее окно. Заводим кольцевой буфер из N последних замеров и накопленную сумму. Пришёл новый замер: сумма += новый - самый старый, в буфере старый заменяется новым, указатель сдвигается по кругу — буфер[глава] = новый; глава = (глава + 1) % N. Никакого пересчёта сотни слагаемых — одна операция на замер, хоть миллион замеров в секунду крути. Память: N целых чисел, при N=16 это 32 байта RAM — терпимо даже для Uno.
Запаздывание у окна фиксированное: среднее по 16 замерам отвечает состоянию на восемь замеров назад, при опросе раз в 50 мс — на 400 мс. Окно больше — плавнее и тупее. Таблица ниже сводит цену всех трёх методов рядом.
| Метод | Память | Запаздывание | Код |
|---|---|---|---|
| Наивное среднее по 100 замерам | 4 байта под сумму | 11 мс сбора, отвечает за прошлое | два цикла for на каждый запрос |
| Окно с кольцевым буфером, N=16 | 32 байта + сумма | 8 замеров; при опросе раз в 50 мс — 400 мс | запись, вычитание, указатель по кругу |
| EMA с a=0,5 | одна переменная | около 3 замеров до 90% скачка | одна строка, реагирует почти сразу |
| EMA с a=0,9 | одна переменная | около 22 замеров до 90% скачка | одна строка на замер |
Мой рабочий инструмент — экспоненциальный фильтр. Формула в одну строку: y = a*y + (1-a)*x, где y — фильтрованное значение, x — свежий замер, a — доля памяти. При a=0,9 новое входит с весом 0,1: сильное сглаживание и медленная реакция. При a=0,5 — сглаживание вполсилы, фильтр почти не отстаёт. Влияние каждой точки истории на текущее значение тает с каждым шагом — отсюда «экспоненциальный», хотя самой истории в памяти нет: одна переменная.
В целых числах та же формула пишется без float: filtr = (filtr * 9 + syro) / 10 — это ровно a=0,9. Умножение на девятку и деление на десятку дёшево даже для Uno. Один нюанс, на котором я обжёгся: стартовое значение. Если заводить filtr нулём при реальных пятисот, фильтр полсекунды вылезает снизу. Лечится первым замером: filtr = syro — и фильтр стартует с места, без разбега.
Выбор a — компромисс, а не константа. Для ночника беру 0,9 и порог по фильтру: реле щёлкает дважды за вечер вместо сорока. Для ручки настройки — 0,5, иначе ползунок отстаёт от пальца. Похожий компромисс вокруг дребезга реле я разбирал в уроке про гистерезис реле на DHT — там мёртвая зона, здесь память фильтра, а болезнь одна: дрожащая граница.
И ловушка, на которой спотыкаются все, кто копирует коэффициент из чужого скетча: a без периода опроса не имеет смысла. Фильтр с a=0,9 при опросе раз в 50 мс догоняет скачок примерно за секунду, а тот же a=0,9 при опросе раз в 10 мс — за двести миллисекунд. Копируй коэффициент вместе с периодом: пара «a и период» и есть весь фильтр, по отдельности они означают что угодно.
// Ночник на фоторезисторе A1: порог по фильтрованному значению.const int PIN_FOTO = A1;const int PIN_RELE = 7;const long POROG = 250;long filtr = 0;boolean nachali = false; void setup() { pinMode(PIN_RELE, OUTPUT); Serial.begin(9600); Serial.println("Nochnik: porog po filtru, a = 0.9");} void loop() { long syro = analogRead(PIN_FOTO); if (!nachali) { // первый замер — старт без разбега с нуля filtr = syro; nachali = true; } filtr = (filtr * 9 + syro) / 10; // то же, что y = 0.9*y + 0.1*x Serial.print("syro="); Serial.print(syro); Serial.print(" filtr="); Serial.println(filtr); if (filtr < POROG) { // потемнело — свет включён digitalWrite(PIN_RELE, HIGH); } else { digitalWrite(PIN_RELE, LOW); } delay(100); // десять замеров в секунду}Оба ряда в симуляторе
У движка симулятора макетный ползунок идеально чистый — настоящий АЦП так не умеет. Чтобы дрожь младших бит была видна, шум добавлен в скетч честно: к каждому замеру прикладывается случайная добавка от минус 8 до плюс 8 — так ведёт себя реальная макетка с двумя шевелящимися битами. Диапазон не с потолка: на питании от USB я видел прыжок в плюс-минус два-три на чистой сборке, а моторчик на той же земле раздувал дрожь до десятка бит — тогда уже шумел не АЦП, а земля. Сырое значение и фильтр печатаются в одну строку парами syro= и filtr=: плоттер Serial построит оба ряда, и зазор между ними будет виден как на ладони.
Светодиод D12 включается по фильтрованному значению, когда filtr переваливает за 600. Подведи ползунок к границе: сырой ряд прыгает через порог туда-сюда на каждом замере, а фильтр пересекает его один раз — ровно то, что лечило кашель реле в моём ночнике.
У шума неприятная связь с остальной платой: чем быстрее опрашиваешь АЦП, тем он шумнее. В стандартном analogRead предделитель самый консервативный, и 112 мкс на замер — плата за тишину в младших битах. Разогнать преобразователь до десятков микросекунд можно, покрутив регистры ADC из обзора таймеров и регистров, но на малых предделителях шум вырастает в разы, и фильтр из этого урока перестаёт быть рекомендацией: без него быстрый АЦП теряет смысл.
Второй вывод — про внешние АЦП. ADS1115 на 16 бит разрешает 187,5 мкВ на бит в деликатном диапазоне — на такой чувствительности шумит уже всё: провод, земля, источник. Фильтр там нужен сильнее, чем нашему 10-битному: EMA с a=0,95 живёт в каждом моём скетче с этим чипом.
Докрутки на вырост. Редкий выброс — сосед воткнул зарядку в ту же розетку — и среднее, и EMA сглаживают неохотно: один замер под 1023 тащит окно вверх десятки миллисекунд. От выбросов лечит медиана из трёх замеров перед фильтром: делаешь три чтения подряд с паузой в пару миллисекунд, сортируешь и берёшь среднее по величине — выброс отлетает, а медленное изменение проходит насквозь. И полезно держать два фильтра сразу: быстрый с a=0,5 — видеть тренд в мониторе, медленный с a=0,95 — принимать решения по порогу.
- 1. Сколько времени занимает один analogRead на Uno?
- 2. Почему наивное среднее по ста замерам подводит по времени?
- 3. Строка filtr = (filtr * 9 + syro) / 10 — какой это коэффициент a?
- 4. Как избавиться от разбега фильтра с нуля при старте скетча?
- 5. В данных появился редкий выброс почти до 1023 — сосед воткнул зарядку. Что уберёт его мгновенно?
- Младшие 1-2 бита analogRead прыгают всегда: один бит при 5 В — 4,88 мВ, наводки макетки добирают их легко
- Наивное среднее по 100 замерам — 11 мс блокировки (по 112 мкс за замер) и ответ за прошлое; окно с кольцевым буфером считает каждый новый замер одной операцией
- EMA в одну переменную: y = a*y + (1-a)*x; целочисленный вид (filtr*9+syro)/10 — тот же a=0,9 без float; старт — первым замером, без разбега с нуля
- Цена сглаживания — запаздывание: до 90% скачка около 22 замеров при a=0,9 и 45 при a=0,95; умножь на свой период опроса и сверь с задачей
- Одиночные выбросы срезает медиана из трёх; на разогнанном АЦП и внешних 16-битных чипах фильтр обязателен — шум там в разы больше
Частые вопросы по теме урока
- Сколько времени занимает один analogRead на Uno?
- Стандартный analogRead с самым консервативным предделителем стоит около 112 мкс — отсюда 11 мс на наивное среднее из сотни замеров. около 112 мкс
- Почему наивное среднее по ста замерам подводит по времени?
- По 112 мкс на замер сотня тянет 11 миллисекунд блокировки — и усреднённое значение описывает уже не настоящий момент. Лечится бегущим окном или EMA. сбор ста замеров держит loop 11 мс, и среднее отвечает за прошлое состояние
- Строка filtr = (filtr * 9 + syro) / 10 — какой это коэффициент a?
- Целочисленный вид формулы y = a*y + (1-a)*x при a=0,9: девять десятых памяти фильтра и одна десятая свежего замера, без float. 0,9
- Как избавиться от разбега фильтра с нуля при старте скетча?
- Стартовое значение фильтра берут равным первому замеру — фильтр начинает с места. Нулевой старт при реальных пятисот даёт полсекунды лжи в начале. первым замером: filtr = syro
- В данных появился редкий выброс почти до 1023 — сосед воткнул зарядку. Что уберёт его мгновенно?
- Медиана из трёх отбрасывает одиночный выброс до усреднения: среднее по величине из трёх значений игнорирует крайнее. EMA и окно выброс лишь размажут. медиана из трёх замеров перед фильтром