Ночник на фоторезисторе жил на подоконнике месяц и делал всё, что от него ждали, — пока в один из дней не начал мигать днём. Смотрю в лог: показание прыгнуло с 340 на 715 на один отсчёт и вернулось обратно; порог ночника — 600, и единичный скачок щёлкал туда-сюда. Виноват оказался не фоторезистор и не проводка: в момент скачка на той же линии 5 В щёлкнуло реле в соседней сборке, и выброс прилетел прямо во вход АЦП. Среднее по журналу из урока про массивы мелкую дрожь гасило честно, но одиночный выброс оно не отбрасывает, а размазывает: сотня лишних единиц на десять чтений — плюс десять к каждой позиции.
В этом уроке три приёма, каждый со своей работой. Сумма в long — скучная на вид привычка, которая однажды спасёт от молчаливой лжи при накоплении. Усечённое среднее из десяти чтений: выкидываем минимум и максимум, делим на восемь — одиночный выброс уходит в мусорку целиком. И медиана из трёх: одна строка без сортировки, через которую выброс пролетает мимо, — самый дешёвый импульсный фильтр, какой я знаю.
Стенд прежний: потенциометр на A0 из урока про аналоговые входы, крыльчатка на D9 покажет разницу глазами. В симуляторе шума нет — движок отдаёт ровные значения, поэтому помеху добавляет сам скетч случайной добавкой к каждому чтению; на настоящей плате она приезжает бесплатно.
Дрожь трёх сортов и кому что давать
Первый сорт — мелкая дрожь: младшие один-два разряда 10-битного отсчёта пляшут даже на идеальном входе, потому что АЦП шипит сам и питание чуть-чуть дышит. Лечится усреднением: шум симметричный, плюсы и минусы гасят друг друга. Второй сорт — одиночные выбросы: щелчок реле, пуск мотора, пик зуммера — один отсчёт уезжает на десятки или сотни единиц и возвращается; усреднение его не лечит, а размазывает, здесь нужна медиана. Третий сорт — медленный дрейф: фоторезистор плывёт от заката, термистор из урока про NTC — от нагрева платы; это не шум, а меняющийся сигнал, и фильтры тут не помощники — нужен порог с гистерезисом.
Цена процедур копеечная. Одно преобразование АЦП на Uno занимает порядка ста микросекунд, десять чтений — миллисекунда, и даже в быстром loop это незаметно. А вот с суммой начинаются байты, и тут всплывают типы из урока про переменные и типы: int на Uno — 16 бит, граница 32 767. Сумма десяти чтений по максимуму — 10 230, влезает с запасом. Но журнал вырастет до 33 чтений — 33 759, граница перелетена, и сумма молча станет отрицательной: переполнение со знаком не печатает предупреждений и не сыпет логами. Привычка, которую я советую безусловно: сумматор держать в long — 32 бита, потолок 2 147 483 647, запас в шестьдесят пять тысяч раз.
Усечённое среднее живёт в том же цикле, что и сумма: попутно ищем минимум и максимум, потом (sum - min - max) / 8. Из десяти чтений выкидываем самое низкое и самое высокое и делим на восемь. Одиночный выброс почти всегда становится максимумом или минимумом и уходит в вычет целиком — размазываться больше нечему. Предел метода — два выброса из десяти: пара симметричных выбросов уже не отличима от честного сигнала, и дальше нужны медианы.
Медиана из трёх: одна строка без сортировки
Медиана — середина упорядоченной тройки: у 500, 900 и 505 это 505. Сортировать три числа ради одного значения — роскошь; середину берут через минимум и максимум в одну строку: med = max(min(a, b), min(max(a, b), c)). Как это читается: внутренняя пара отрезает наибольшее из трёх, меньшая пара держит низ, внешний max забирает средний по величине отсчёт. Что бы ни было с крайними — в ответ попадает середина.
Чем медиана лучше среднего против выбросов: она их не размазывает, а отбрасывает — выброс хоть на 300, хоть на 700 единиц лежит не в середине тройки и в ответ не попадает. Скобки в формуле не взаимозаменяемы: перепутанный порядок отрезает середину вместо края, и медиана молча превратится в крайнее значение — я проверял это на себе, перепечатывая формулу с бумаги. Цена: медиана не усредняет мелкий шум — по трём отсчётам дрожь в единицу-другую LSB проходит насквозь. Поэтому в скетче ниже они работают связкой: медиана из трёх гасит импульсы, усечённое среднее из десяти дожимает дрожь, и мотор получает значение, за которое не стыдно. Тринадцать чтений на всё — миллисекунда с копейками.
const int PIN_MOTOR = 9; int chistoe() { int x = analogRead(A0) + random(-40, 41); // шум: в симуляторе его нет if (x < 0) x = 0; if (x > 1023) x = 1023; return x;} void setup() { Serial.begin(9600); Serial.println("filtr start");} void loop() { int syro = chistoe(); // одно чтение как есть int a = chistoe(); int b = chistoe(); int c = chistoe(); int med = max(min(a, b), min(max(a, b), c)); // медиана из трёх long sum = 0; // сумматор: long, не int int mini = 1023; int maxi = 0; for (int i = 0; i < 10; i++) { int x = chistoe(); sum = sum + x; if (x < mini) mini = x; if (x > maxi) maxi = x; } int usred = (sum - mini - maxi) / 8; // усечённое среднее Serial.print("syroe="); Serial.println(syro); Serial.print("usred="); Serial.println(usred); Serial.print("med="); Serial.println(med); analogWrite(PIN_MOTOR, map(med, 0, 1023, 0, 255)); delay(300);}Читать лог проще по тройкам строк. syroe — одно чтение как есть: пляшет на всю добавку, у меня разлетается на десятки единиц. usred — усечённое среднее: тихое, но выброс, застрявший внутри окна, дёргает его на несколько единиц ещё несколько оборотов подряд. med — медиана из трёх: стоит как вкопанная, пока ручку не трогают, и на импульс не реагирует вовсе. Крыльчатка на D9 получает map от медианы — скорость честно следует за ручкой и не дёргается на выбросах.
Добавка random(-40, 41) — это модель, и урок про случайные числа объясняет, почему добавка вылетает за скобки диапазона честно: именно так выглядит шум на живом входе. Тот же приём лечения дрожи работает и на других приборах: в уроке про подрагивание серво восемь чтений гасят дрожь рога — соседняя глава той же книги. А перед порогом ночника фильтр обязателен: одиночный скачок без медианы — это ложное включение, как в моей истории с реле.
Сколько чтений брать — компромисс с арифметикой. Мелкий шум при усреднении падает как корень из числа отсчётов: десять чтений гасят дрожь примерно втрое, сто — вдесять, а время растёт линейно — сто чтений это уже десять миллисекунд вместо одной. Для ночника и индикаторов десяти за глаза; для медленной температуры хватит и четырёх. Гнаться за стами я не вижу смысла: на живой плате основная дрожь обычно не в АЦП, а в питании, и лечится она конденсатором, а не длиной цикла.
Чем такой пакетный проход лучше скользящего журнала из урока про массивы: свежестью. Журнал смешивает показания за последние восемь оборотов loop, и при быстром повороте ручки среднее отстаёт; пакет из тринадцати свежих чтений собирается за одну миллисекунду и отстаёт только на собственное окно. Для порогов и ручек я беру пакет, для плавных индикаторов — журнал: у обоих паттернов своя работа.
| Метод | Окно | Что гасит | Чего не вывозит |
|---|---|---|---|
| Одно чтение | 1 | ничего | и дрожь, и выбросы доезжают как есть |
| Среднее из 10 | 10 | мелкую симметричную дрожь | выброс размазывает на десятую часть |
| Усечённое из 10 | 8 | дрожь плюс один выброс целиком | два выброса уже неотличимы от сигнала |
| Медиана из 3 | 3 | одиночный выброс, не размазывая | мелкую дрожь не усредняет |
Куда двигаться дальше. Первый шаг — снять искусственную добавку и запустить фильтр на живой плате: random здесь изображает то, что на настоящем железе приезжает само. И не забудьте зажим внутри chistoe(): добавка может увести чтение за границы диапазона, а без constrain(x, 0, 1023) минимум и максимум поедут в вычет вместе с мусором. Второй шаг — поставить фильтр перед порогом ночника и посчитать в логе, сколько раз за вечер порог ложный: у меня после медианы счёт ушёл в ноль, а до неё — три-четыре щелчка за вечер, ровно по числу пусков холодильника.
И про масштаб приёма: связка «медиана плюс усечённое среднее» переезжает в проекты как есть — газ на MQ-2, микрофон, влажность почвы, любой вход, перед которым стоит порог или сервопривод. Фильтр — это не ремонт сигнала, а гигиена перед решением: индикатор и порог получают сигнал, по которому не приходится оправдываться. Мой финальный тест для любого фильтра прост: щёлкните реле рядом с макеткой и посмотрите в лог — если сырой отсчёт взлетел, а медиана не моргнула, фильтр собран правильно.
- 1. Сумматор объявлен int. В журнале стало 35 чтений, каждое близко к 1023. Что произойдёт?
- 2. Одно чтение из десяти получило выброс плюс 300. Что сделает обычное среднее?
- 3. Что вернёт med = max(min(a, b), min(max(a, b), c)); для a=500, b=900, c=505?
- 4. Почему медиана из трёх не заменяет среднее полностью?
- 5. Сколько времени займут 13 чтений АЦП для связки «медиана плюс усечённое среднее»?
- Три сорта дрожи требуют разных лекарств: мелкая — усреднение, одиночный выброс — медиана, медленный дрейф — порог с гистерезисом.
- Сумматор чтений держат в long: int на Uno — 16 бит, граница 32 767, и 33 чтения по максимуму уже перелетают её молча.
- Усечённое среднее: (sum - min - max) / 8 из десяти чтений — одиночный выброс уходит в вычет целиком, а не размазывается.
- Медиана из трёх в одну строку: max(min(a,b), min(max(a,b),c)) — выброс любого размера лежит вне середины и в ответ не попадает.
- Цена фильтра — миллисекунда на 13 чтений АЦП; в симуляторе шум добавляет скетч, на живой плате он приезжает сам.
Частые вопросы по теме урока
- Сумматор объявлен int. В журнале стало 35 чтений, каждое близко к 1023. Что произойдёт?
- 35 x 1023 = 35 805 — за границей 16-битного int. Переполнение со знаком проходит без предупреждений, поэтому сумматор держат в long. Сумма перелетит границу 32 767 и молча станет отрицательной
- Одно чтение из десяти получило выброс плюс 300. Что сделает обычное среднее?
- Выброс делится на размер окна и попадает в каждую позицию результата. Усечённое среднее вместо этого выкидывает максимум целиком. Размажет выброс: каждая из десяти позиций результата получит плюс 30
- Что вернёт med = max(min(a, b), min(max(a, b), c)); для a=500, b=900, c=505?
- Внутренняя пара отрезает наибольшее: min(max(500,900),505)=505. Меньшая пара — min(500,900)=500. Внешний max забирает середину: max(505,500)=505. 505
- Почему медиана из трёх не заменяет среднее полностью?
- Медиана гасит импульсы, но не сглаживает мелкий шум: середина тройки дрожит вместе с отсчётами. Поэтому в скетче они работают связкой. По трём точкам мелкая дрожь проходит насквозь — медиана не усредняет шум
- Сколько времени займут 13 чтений АЦП для связки «медиана плюс усечённое среднее»?
- Одно преобразование на Uno — порядка ста микросекунд, 13 чтений — миллисекунда с копейками. Фильтр дешевле, чем один кадр большинства дисплеев. Порядка миллисекунды: одно преобразование занимает около 100 мкс